创作数据流程收束
This commit is contained in:
@@ -82,7 +82,7 @@ docs/
|
||||
│ ├─ AI_NATIVE_UNIFIED_ROLE_ATTRIBUTE_SYSTEM_PRD_2026-04-02.md
|
||||
│ ├─ BUILD_SYSTEM_ATTRIBUTE_SIMILARITY_PRD_2026-04-02.md
|
||||
│ └─ RUNTIME_ITEM_GENERATION_CURRENT_SYSTEM_DESIGN.md
|
||||
├─ reference/[QwenSpriteSheetTool.tsx](src/tools/QwenSpriteSheetTool.tsx)
|
||||
├─ reference/
|
||||
│ ├─ README.md
|
||||
│ └─ FUNCTION_SCRIPT_CATALOG_2026-04-04.md
|
||||
└─ technical/
|
||||
|
||||
10
README.md
10
README.md
@@ -10,7 +10,7 @@
|
||||
- NPC 交易、送礼、求助、招募
|
||||
- 宝藏交互
|
||||
- 同伴跟随与战斗
|
||||
- 预设编辑器 / NPC 视觉编辑器 / 行为编辑器
|
||||
- 游戏主流程内嵌的角色资产工坊、自定义世界实体编辑与角色形象编辑
|
||||
- 自动存档与继续游戏
|
||||
|
||||
## 运行
|
||||
@@ -99,11 +99,11 @@ npm run check:content
|
||||
- [src/hooks/useCombatFlow.ts](/E:/Repos/Genarrative/src/hooks/useCombatFlow.ts)
|
||||
- [src/hooks/useStoryGeneration.ts](/E:/Repos/Genarrative/src/hooks/useStoryGeneration.ts)
|
||||
|
||||
编辑器:
|
||||
主流程内嵌编辑能力:
|
||||
|
||||
- [src/components/PresetEditor.tsx](/E:/Repos/Genarrative/src/components/PresetEditor.tsx)
|
||||
- [src/components/NpcVisualEditor.tsx](/E:/Repos/Genarrative/src/components/NpcVisualEditor.tsx)
|
||||
- [src/components/StateFunctionEditor.tsx](/E:/Repos/Genarrative/src/components/StateFunctionEditor.tsx)
|
||||
- [src/components/CustomWorldEntityEditorModal.tsx](/E:/Repos/Genarrative/src/components/CustomWorldEntityEditorModal.tsx)
|
||||
- [src/components/CustomWorldNpcVisualEditor.tsx](/E:/Repos/Genarrative/src/components/CustomWorldNpcVisualEditor.tsx)
|
||||
- [src/components/CustomWorldRoleAssetStudioModal.tsx](/E:/Repos/Genarrative/src/components/CustomWorldRoleAssetStudioModal.tsx)
|
||||
|
||||
核心数据:
|
||||
|
||||
|
||||
@@ -251,8 +251,8 @@ custom-world-library
|
||||
| `anchorContent / creatorIntent / anchorPack / lockState` 被直接塞进 legacy profile | 创作态元数据 | 会跟随自动保存一起写进作品库 profile,但 runtime 并不以这些字段为正式运行时输入 | 当前更像创作态元数据泄漏进运行时 profile |
|
||||
| `qualityFindings` | session / contract 字段 | contract、session store、测试里存在,但没形成生成、渲染、发布阻断闭环 | 当前主链悬空 |
|
||||
| `checkpoints` | session 字段 | session store 会记录,但主工作区和结果页没有真实展示入口 | 当前主链悬空 |
|
||||
| `suggestedActions` | session 字段 | session 会生成,但主工作区没有接 `QuickActions` 等面板 | 当前主链悬空 |
|
||||
| `pendingClarifications` | session 字段 | session 有数据,但澄清面板未接入主工作区 | 当前主链悬空 |
|
||||
| `suggestedActions` | session 字段 | session 会生成,但当前正式工作区没有对应消费面;旧 `QuickActions` 面板已在 `2026-04-21` 判定退出当前版本主链并物理删除 | 当前主链悬空 |
|
||||
| `pendingClarifications` | session 字段 | session 有数据,但当前正式工作区没有对应消费面;旧澄清面板已在 `2026-04-21` 判定退出当前版本主链并物理删除 | 当前主链悬空 |
|
||||
| `operations` 历史 | session 字段 | 主工作区只展示当前 `activeOperation` 横幅,不展示完整历史 | 当前主链弱消费 |
|
||||
| `roleAssetSummaryLabel / cover* / counts` 等 works 字段 | works 聚合字段 | 后端能返回,但主平台 create tab 没走 `works` 入口 | 当前主链弱消费 |
|
||||
|
||||
@@ -266,11 +266,11 @@ custom-world-library
|
||||
| --- | --- | --- |
|
||||
| 结果页直接生成 playable/story/landmark | `CustomWorldResultView.tsx` 仍可直接调用 AI 生成 | 与 Agent 对象精修链重复,且不会同步回 session |
|
||||
| 结果页直接编辑 `CustomWorldProfile` | `CustomWorldEntityEditorModal` 仍挂在结果页 | 把结果页继续维持成旧编辑器,而不是 Agent 流程的收口层 |
|
||||
| 旧 `custom-world/sessions` 世界生成 | `aiService.generateCustomWorldProfile()` 仍完整可用 | 与 Agent 八锚点世界创建重复 |
|
||||
| 旧 `custom-world/sessions` 世界生成 | `2026-04-20` 审计时仍完整可用;`2026-04-21` 已完成物理删除 | 与 Agent 八锚点世界创建重复;当前遗留问题已转为文档口径清理 |
|
||||
| 作品库 `publish/unpublish` 与 Agent `publish_world` | 两套“发布”概念并行 | 一套作用于 library profile,一套想作用于 Agent session,但后者还未打通 |
|
||||
| 结果页自动保存 | `generatedCustomWorldProfile` 变化时自动 `upsertCustomWorldProfile()` | 让“草稿保存”“作品库存档”“正式发布”语义混在一起 |
|
||||
|
||||
## 7.2 冗余或未接线组件
|
||||
## 7.2 冗余或已退出当前版本主链的组件
|
||||
|
||||
`src/components/custom-world-agent/CustomWorldAgentWorkspace.tsx` 当前只真正接了:
|
||||
|
||||
@@ -280,7 +280,7 @@ custom-world-library
|
||||
4. `CustomWorldAgentThread`
|
||||
5. `CustomWorldAgentComposer`
|
||||
|
||||
但同目录下已经存在且主工作区未接线的组件包括:
|
||||
在 `2026-04-21` 清理前,同目录下还存在一组未接线旧组件:
|
||||
|
||||
1. `CustomWorldAgentLockBar.tsx`
|
||||
2. `CustomWorldAgentDraftDrawer.tsx`
|
||||
@@ -291,6 +291,25 @@ custom-world-library
|
||||
7. `CustomWorldAgentClarificationPanel.tsx`
|
||||
8. `CustomWorldGenerateEntityModal.tsx`
|
||||
|
||||
其中:
|
||||
|
||||
1. `CustomWorldAgentDraftDrawer.tsx` 已在批次 A 清理中删除
|
||||
2. `CustomWorldAgentLockBar.tsx`
|
||||
3. `CustomWorldAgentDraftDetailPanel.tsx`
|
||||
4. `CustomWorldAgentQuickActions.tsx`
|
||||
5. `CustomWorldAgentSummaryPanel.tsx`
|
||||
6. `CustomWorldAgentIntentSummaryPanel.tsx`
|
||||
7. `CustomWorldAgentClarificationPanel.tsx`
|
||||
8. `CustomWorldGenerateEntityModal.tsx`
|
||||
|
||||
已在批次 D 清理中判定为退出当前版本主链,并完成物理删除。
|
||||
|
||||
因此这里的审计结论需要更新为:
|
||||
|
||||
1. 它们不再属于“待接线组件”
|
||||
2. 它们属于已确认退场的旧副面板链
|
||||
3. 当前版本如果还要补 `suggestedActions / pendingClarifications / draftCards` 的消费面,应基于新的主链设计重新定义,而不是默认把旧面板接回来
|
||||
|
||||
另外,`src/components/custom-world-home/CustomWorldCreationHub.tsx` 也已存在,但平台 `create` tab 还没有把它接成主入口。
|
||||
|
||||
---
|
||||
@@ -345,16 +364,16 @@ Agent session
|
||||
2. 只有 `publish_world` 成功后,才产出正式 `CustomWorldProfile` 并允许主入口进入世界。
|
||||
3. `qualityFindings / blocker` 必须在 foundation draft 完成、资产写回后、publish 前持续重跑。
|
||||
|
||||
## P1:决定旧 world session 流程的命运
|
||||
## P1:继续做旧 world session 链的文档收口
|
||||
|
||||
当前最容易继续制造重复复杂度的是旧 `custom-world/sessions` 链。
|
||||
`2026-04-21` 更新:
|
||||
|
||||
建议二选一:
|
||||
旧 `custom-world/sessions` 链已经完成物理删除。
|
||||
|
||||
1. 明确保留为“快速世界生成兼容模式”,但从主入口降级。
|
||||
2. 明确进入淘汰路径,逐步下线 `generateCustomWorldProfile()` 这条旧链。
|
||||
因此这里不再是“保留还是淘汰”的开放问题,而是:
|
||||
|
||||
不建议继续让它和 Agent 八锚点链同时作为主入口长期并存。
|
||||
1. 继续清理由这条旧链残留在审计、PRD、知识图谱中的过时口径
|
||||
2. 把当前正式主链与仍保留的兼容层边界写清楚
|
||||
|
||||
## P1:把 works 创作中心接回主平台
|
||||
|
||||
@@ -365,16 +384,23 @@ Agent session
|
||||
3. 已发布 profile 通过“进入世界”或“查看详情”进入。
|
||||
4. `myEntries` 退回为作品库子集,而不是 create tab 的唯一数据源。
|
||||
|
||||
## P1:补齐 Agent workspace 的最小闭环
|
||||
## P1:为悬空 session 字段重新定义最小闭环
|
||||
|
||||
建议优先接上:
|
||||
`2026-04-21` 更新:
|
||||
|
||||
1. `CustomWorldAgentQuickActions`
|
||||
2. `CustomWorldAgentDraftDrawer`
|
||||
3. `CustomWorldAgentDraftDetailPanel`
|
||||
4. `CustomWorldAgentClarificationPanel`
|
||||
原文这里建议把旧 `QuickActions / DraftDrawer / DraftDetailPanel / ClarificationPanel` 接回主工作区。
|
||||
|
||||
如果这几个面板不接上,`suggestedActions / pendingClarifications / draftCards` 这些 session 字段会长期处于悬空状态。
|
||||
但这些旧副面板已经在当前版本收口判断中被明确认定为:
|
||||
|
||||
1. 不属于现行主链
|
||||
2. 不再作为当前版本默认待落地项
|
||||
3. 已完成物理删除
|
||||
|
||||
因此当前更准确的建议应该是:
|
||||
|
||||
1. 如果 `suggestedActions / pendingClarifications / draftCards` 仍要进入正式主流程,需要先重新定义符合当前极简工作区的消费方式
|
||||
2. 不应再以“把旧副面板接回来”作为默认方案
|
||||
3. 在没有新主链设计前,这些字段继续标记为“主链悬空”
|
||||
|
||||
## P2:等主链收口后再清桥接字段
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# 工程死分支清理执行记录 A(2026-04-21)
|
||||
|
||||
更新时间:`2026-04-21`
|
||||
|
||||
## 0. 本批次目标
|
||||
|
||||
这份记录对应:
|
||||
|
||||
- `docs/planning/ENGINEERING_DEAD_CODE_AND_HIDDEN_BRANCH_CLEANUP_PLAN_2026-04-21.md`
|
||||
- 其中的 `P0 + 批次 A`
|
||||
|
||||
本批次只做一件事:
|
||||
|
||||
**先清理高置信度、低耦合、无正式入口的小型孤岛与残留壳子。**
|
||||
|
||||
这批对象有一个共同特征:
|
||||
|
||||
1. 当前没有正式运行时引用
|
||||
2. 没有当前主链计划要接回
|
||||
3. 删除后有明确替代路径,或者本身只是历史占位
|
||||
|
||||
因此这批次不碰运行时真相链、不碰鉴权链、不碰任务物品主链,只先做低风险去噪。
|
||||
|
||||
---
|
||||
|
||||
## 1. 本批次已处理对象
|
||||
|
||||
## 1.1 已删除文件
|
||||
|
||||
| 文件 | 判定 | 删除原因 | 替代路径 / 当前真相源 | 验证口径 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `src/services/customWorldPresentation.stub.ts` | 无引用占位 stub | 文件本身就是占位实现,且正式逻辑已由 `customWorldPresentation.ts` 承接 | `src/services/customWorldPresentation.ts` | 符号级检索确认正式调用方都指向正式实现 |
|
||||
| `src/services/typewriter.ts` | 无引用 helper 残留 | 独立 helper 已失效,正式链路已在 `storyPresentation.ts` / `storyRenderingHelpers.ts` 等处内联或迁移 | `src/hooks/story/storyPresentation.ts`、`src/hooks/story/storyRenderingHelpers.ts` | `getTypewriterDelay` 调用点未指向该文件 |
|
||||
| `src/prompts/customWorldOrchestratorPrompts.ts` | 前端孤岛 prompt 壳 | 当前无正式 import,正式主编排 prompt 已收口到后端 prompt 目录,前端 `ai.ts` 也保留自己的现行实现 | `server-node/src/prompts/customWorldOrchestratorPrompts.ts`、`src/services/ai.ts` | 全仓检索仅剩文档引用,无代码消费 |
|
||||
| `src/prompts/storyOrchestratorPrompts.ts` | 前端孤岛 prompt 壳 | 当前无正式 import,剧情语言修复 prompt 已由后端 prompt 目录承接,前端当前执行路径不依赖该文件 | `server-node/src/prompts/storyOrchestratorPrompts.ts`、`src/services/ai.ts` | 全仓检索仅剩文档引用,无代码消费 |
|
||||
| `src/components/custom-world-home/CustomWorldCreationLauncherModal.tsx` | 无入口 UI 壳层 | 最近两轮工程审计都确认无运行时引用,当前平台主流程未接这条入口 | 当前平台正式入口链 | 文件级检索确认无组件 import |
|
||||
| `src/components/custom-world-agent/CustomWorldAgentLauncherModal.tsx` | 无入口 UI 壳层 | Agent 创作主流程已切到当前工作区链路,这个旧 modal 没有接线价值 | 当前 Agent 工作区主链 | 文件级检索确认无组件 import |
|
||||
| `src/components/custom-world-agent/CustomWorldAgentDraftDrawer.tsx` | 无入口 UI 壳层 | 只有孤立 UI 实现,没有正式调用链,也不在当前结果页 / 工作区主链中 | 当前 Agent 工作区与结果页正式链 | 文件级检索确认无组件 import |
|
||||
|
||||
---
|
||||
|
||||
## 2. 本批次为什么先删这 7 个
|
||||
|
||||
这批文件适合先处理,不是因为它们最大,而是因为它们最清晰:
|
||||
|
||||
1. **没有正式入口。**
|
||||
本轮检索没有发现主工程 import。
|
||||
2. **删除后不会形成职责空洞。**
|
||||
要么已有正式替代路径,要么本身只是历史占位。
|
||||
3. **不会误伤当前重点链路。**
|
||||
这批不涉及运行时快照、鉴权、任务、物品、AI 正式编排主链。
|
||||
4. **可以最快降低目录噪音。**
|
||||
先把真假并存的壳子删掉,后面做批次 B/C/D 时判断成本会更低。
|
||||
|
||||
---
|
||||
|
||||
## 3. 本批次暂不处理对象
|
||||
|
||||
以下对象虽然已进入首轮台账,但本批次暂不删除:
|
||||
|
||||
1. `src/components/GameShell.tsx`
|
||||
2. `src/components/custom-world-home/CustomWorldCreationHub.tsx`
|
||||
3. `src/hooks/story/storyBootstrap.ts`
|
||||
4. `src/hooks/useEquipmentFlow.ts`
|
||||
5. `src/hooks/useForgeFlow.ts`
|
||||
6. `src/hooks/useInventoryFlow.ts`
|
||||
7. `src/data/buildTagSimilarity.generated.ts`
|
||||
|
||||
暂缓原因分别是:
|
||||
|
||||
1. 仍属于旧主流程 / 旧 flow 级别对象,删除前要先核对更多历史依赖和替代路径
|
||||
2. 部分对象仍有测试引用或更大的上下文耦合
|
||||
3. `buildTagSimilarity.generated.ts` 虽无正式业务 import,但属于生成产物,处理前还要确认脚本链与文档链
|
||||
|
||||
这批对象更适合进入:
|
||||
|
||||
1. `批次 B:旧 flow / 旧 shell / 旧 hook`
|
||||
2. 或独立的数据产物复核批次
|
||||
|
||||
---
|
||||
|
||||
## 4. 本批次同步更新的文档
|
||||
|
||||
本批次除了删文件,还同步做了文档回填:
|
||||
|
||||
1. 新增本执行记录,说明本批删了什么、为什么删、哪些对象暂缓
|
||||
2. 更新 `docs/audits/engineering/README.md`,把这份执行记录加入当前审计入口
|
||||
|
||||
这样做的目的,是避免再次出现:
|
||||
|
||||
1. 代码删了
|
||||
2. 但审计入口还是旧状态
|
||||
3. 后续开发又从旧清单里重复判断一遍
|
||||
|
||||
---
|
||||
|
||||
## 5. 验证方式
|
||||
|
||||
本批次验证采用两层口径:
|
||||
|
||||
## 5.1 删除前验证
|
||||
|
||||
1. 文件级检索确认无正式 import
|
||||
2. 符号级检索确认关键导出没有被主链消费
|
||||
3. 结合 `2026-04-20` 工程审计交叉确认这些对象已被标记为高置信度孤岛
|
||||
|
||||
## 5.2 删除后验证
|
||||
|
||||
建议至少执行:
|
||||
|
||||
1. `npm run check:encoding`
|
||||
2. `npm run build`
|
||||
|
||||
说明:
|
||||
|
||||
- 当前仓库已知 `typecheck` 与 `lint` 仍处于红线阶段,因此本批不把它们作为“由本批引入的新失败”判断口径
|
||||
- 本批主要验证目标是:删除小残留后,不产生新的导入断裂和构建断裂
|
||||
|
||||
---
|
||||
|
||||
## 6. 本批次结果判断
|
||||
|
||||
本批次完成后,工程至少获得了 3 个直接收益:
|
||||
|
||||
1. `src/prompts/`、`src/services/`、`src/components/custom-world-*` 中少了一批无入口孤岛
|
||||
2. 当前目录里“看起来像正式入口,其实已经废弃”的误导性对象减少
|
||||
3. 后续可以把精力集中到真正高价值的批次 B/C/D,而不是继续被小残留分散判断成本
|
||||
|
||||
---
|
||||
|
||||
## 7. 下一批建议
|
||||
|
||||
建议严格按计划继续往下推进:
|
||||
|
||||
1. 批次 B:`GameShell`、`storyBootstrap`、`useEquipmentFlow`、`useForgeFlow`、`useInventoryFlow`
|
||||
2. 批次 C:`runtimeStoryCoordinator`、`runtimeStoryService`、`apiClient`
|
||||
3. 批次 D:`npcEncounterActions`、`questDirector`、`runtimeItemAiDirector`、`ai.ts`
|
||||
|
||||
一句话总结本批次:
|
||||
|
||||
**先把最确定的死分支和占位壳子清掉,让主工程少一些假入口、假主源、假能力,再进入更重的主链收口。**
|
||||
@@ -0,0 +1,145 @@
|
||||
# 工程死分支清理执行记录 B(2026-04-21)
|
||||
|
||||
更新时间:`2026-04-21`
|
||||
|
||||
## 0. 本批次目标
|
||||
|
||||
这份记录对应清洗计划中的:
|
||||
|
||||
- `批次 B:旧 flow / 旧 shell / 旧 hook`
|
||||
|
||||
本批次聚焦的不是小型 stub,而是:
|
||||
|
||||
**已经退出正式主流程、但仍占着高辨识度命名和旧职责心智的壳层与流程 Hook。**
|
||||
|
||||
这类文件如果继续留在仓库里,问题比小 helper 更大,因为它们会持续制造误判:
|
||||
|
||||
1. 新人会以为它们还是正式入口
|
||||
2. 后续开发会误判“应该往这里接逻辑”
|
||||
3. review 时会多出一层“旧主链是不是还活着”的判断成本
|
||||
|
||||
---
|
||||
|
||||
## 1. 本批次已处理对象
|
||||
|
||||
## 1.1 已删除文件
|
||||
|
||||
| 文件 | 判定 | 删除原因 | 替代路径 / 当前真相源 | 验证口径 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `src/components/GameShell.tsx` | 旧主流程壳层残留 | 当前正式壳层已由 `src/components/game-shell/GameShellRuntime.tsx` 承接,旧文件无正式 import | `src/components/game-shell/GameShellRuntime.tsx`、`src/hooks/useGameShellRuntime.ts` | 全仓检索未发现对旧 `GameShell` 组件的正式消费 |
|
||||
| `src/hooks/story/storyBootstrap.ts` | 旧启动流程 Hook 残留 | 当前主剧情启动链已不再调用该 Hook,继续保留只会误导人以为它还是故事初始化入口 | 当前 story runtime / coordinator 链 | 全仓检索未发现 `useStoryBootstrap` 消费方 |
|
||||
| `src/hooks/useEquipmentFlow.ts` | 旧装备流程 Hook 残留 | 当前正式背包与装备链未消费该 Hook,属于旧流程实现残留 | 当前 inventory / runtime 正式链 | 符号级检索仅命中定义文件自身 |
|
||||
| `src/hooks/useForgeFlow.ts` | 旧锻造流程 Hook 残留 | 当前正式锻造入口未通过该 Hook 进入主链,保留会制造旧流程错觉 | 当前 inventory / runtime 正式链 | 符号级检索仅命中定义文件自身 |
|
||||
| `src/hooks/useInventoryFlow.ts` | 旧背包使用流程 Hook 残留 | 当前主流程未消费该 Hook,属于旧状态推进实现残留 | 当前 inventory / runtime 正式链 | 符号级检索仅命中定义文件自身 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 为什么这批要紧跟批次 A 处理
|
||||
|
||||
批次 A 清掉的是“小型假入口”。
|
||||
|
||||
批次 B 清掉的是“高辨识度旧主链”。
|
||||
|
||||
这批必须紧跟着做,原因是:
|
||||
|
||||
1. 它们虽然比 stub 更大,但引用关系同样清楚
|
||||
2. 它们的误导性比小残留更强
|
||||
3. 不先处理这批,后面做批次 C/D 时,很容易继续有人拿旧 flow Hook 当候选接线点
|
||||
|
||||
一句话讲:
|
||||
|
||||
**批次 A 是去噪,批次 B 是拔掉旧路牌。**
|
||||
|
||||
---
|
||||
|
||||
## 3. 本批次删除后的结构变化
|
||||
|
||||
本批次完成后,仓库里的流程心智会更清楚:
|
||||
|
||||
1. 游戏壳层正式入口继续收敛到 `src/components/game-shell/**`
|
||||
2. 旧 `GameShell.tsx` 不再和 `GameShellRuntime.tsx` 并存
|
||||
3. 旧的装备 / 锻造 / 背包单独 flow Hook 不再伪装成还在生效的正式实现
|
||||
4. 旧 `storyBootstrap` 不再和当前 story runtime 链并存
|
||||
|
||||
这会直接减少两类误判:
|
||||
|
||||
1. “是不是还有旧主流程没迁完”
|
||||
2. “我是不是应该把新逻辑继续补进这些旧 Hook”
|
||||
|
||||
---
|
||||
|
||||
## 4. 本批次暂不处理对象
|
||||
|
||||
虽然批次 B 已经处理了旧 shell / old flow / old bootstrap,但以下对象仍暂缓:
|
||||
|
||||
1. `src/components/custom-world-home/CustomWorldCreationHub.tsx`
|
||||
2. `src/data/buildTagSimilarity.generated.ts`
|
||||
3. 批次 C 的运行时真相链:
|
||||
- `src/hooks/story/runtimeStoryCoordinator.ts`
|
||||
- `src/services/runtimeStoryService.ts`
|
||||
- `src/services/apiClient.ts`
|
||||
4. 批次 D 的混合执行层:
|
||||
- `src/hooks/story/npcEncounterActions.ts`
|
||||
- `src/services/questDirector.ts`
|
||||
- `src/services/runtimeItemAiDirector.ts`
|
||||
- `src/services/ai.ts`
|
||||
|
||||
暂缓原因很明确:
|
||||
|
||||
1. 这些对象要么仍在当前正式链上
|
||||
2. 要么涉及运行时真相与鉴权边界
|
||||
3. 不能按“无引用旧壳”同一口径直接删除
|
||||
|
||||
---
|
||||
|
||||
## 5. 本批次验证方式
|
||||
|
||||
## 5.1 删除前验证
|
||||
|
||||
1. 全仓检索 `GameShell` 旧组件消费方,确认当前正式壳层已切到 `game-shell/` 目录
|
||||
2. 全仓检索 `useStoryBootstrap`
|
||||
3. 全仓检索旧装备 / 锻造 / 背包 flow Hook 导出的 handler 名称
|
||||
4. 交叉确认当前正式主链入口已存在替代实现
|
||||
|
||||
## 5.2 删除后验证
|
||||
|
||||
建议至少执行:
|
||||
|
||||
1. `npm run check:encoding`
|
||||
2. `npm run build`
|
||||
|
||||
如果这两项通过,说明:
|
||||
|
||||
1. 删除没有引入新的导入断裂
|
||||
2. 主工程构建链仍然成立
|
||||
|
||||
---
|
||||
|
||||
## 6. 本批次结果判断
|
||||
|
||||
本批次完成后,工程获得的直接收益是:
|
||||
|
||||
1. 旧主流程壳层不再和现行壳层并存
|
||||
2. 旧流程 Hook 不再占据 `src/hooks/` 的主路径注意力
|
||||
3. 当前正式入口和历史残留的边界更清楚
|
||||
4. 后续开发更不容易把新逻辑接回旧流程壳子
|
||||
|
||||
---
|
||||
|
||||
## 7. 下一批建议
|
||||
|
||||
建议下一步进入真正有结构价值的收口:
|
||||
|
||||
1. `批次 C:运行时真相收口`
|
||||
- `runtimeStoryCoordinator`
|
||||
- `runtimeStoryService`
|
||||
- `apiClient`
|
||||
2. `批次 D:任务 / 物品 / AI 混合执行层收口`
|
||||
- `npcEncounterActions`
|
||||
- `questDirector`
|
||||
- `runtimeItemAiDirector`
|
||||
- `ai.ts`
|
||||
|
||||
一句话总结本批次:
|
||||
|
||||
**这一步不是在“删几个没用 Hook”,而是在把已经退场的旧主流程壳层和旧 flow 路牌从主工程里真正拔掉,让现行架构不再和历史壳子并排站着。**
|
||||
@@ -0,0 +1,177 @@
|
||||
# 工程死分支清理执行记录 C(2026-04-21)
|
||||
|
||||
更新时间:`2026-04-21`
|
||||
|
||||
## 0. 本批次目标
|
||||
|
||||
这份记录对应清洗计划中的:
|
||||
|
||||
- `批次 C:运行时真相收口`
|
||||
|
||||
但这次不是“一口气把运行时真相链全删干净”,而是先做其中最明确、风险最低、最不该继续拖的那一段:
|
||||
|
||||
1. **收掉前端本地自动登录用户名 / 密码真相**
|
||||
2. **把登录恢复改成优先依赖服务端 session / refresh**
|
||||
|
||||
同时,这一批也明确记录了一件事:
|
||||
|
||||
**运行时快照前置写入链当前还不能直接砍。**
|
||||
|
||||
原因不是“不想动”,而是服务端当前 `runtime story` 动作入口仍然以远端快照作为执行基线。
|
||||
在后端 contract 没先改好之前,前端不能假装自己已经退出这条链。
|
||||
|
||||
---
|
||||
|
||||
## 1. 本批次已处理对象
|
||||
|
||||
## 1.1 已收口的鉴权链
|
||||
|
||||
| 文件 | 处理动作 | 本批结论 |
|
||||
| --- | --- | --- |
|
||||
| `src/services/apiClient.ts` | 删除本地自动登录用户名 / 密码存取逻辑 | 前端不再保存 auto auth 账号密码 |
|
||||
| `src/services/authService.ts` | 去掉对本地游客凭证的读写依赖 | 自动游客登录改为仅本次生成凭证,不再长期落本地 |
|
||||
| `src/components/auth/AuthGate.tsx` | 去掉“必须先有本地 access token 才尝试恢复”的前置假设 | 登录恢复改为优先尝试服务端 `getCurrentAuthUser()` / refresh session |
|
||||
| `src/services/authService.test.ts` | 改写游客自动登录相关断言 | 验证改为“生成临时凭证并完成登录”,而不是“落本地账号密码” |
|
||||
| `src/components/auth/AuthGate.test.tsx` | 改写登录恢复 mock | 验证改为“先尝试服务端会话恢复,再决定是否走游客兜底” |
|
||||
|
||||
---
|
||||
|
||||
## 2. 本批次为什么先做这段
|
||||
|
||||
这批优先级高,是因为它同时满足 4 条:
|
||||
|
||||
1. **风险明确。**
|
||||
浏览器保存自动登录用户名 / 密码,本身就不符合“前端只做表现、后端负责鉴权真相”的方向。
|
||||
2. **替代路径已经存在。**
|
||||
后端已经有 refresh session cookie 与 `getCurrentAuthUser()`,不是没有可替代能力。
|
||||
3. **改动边界清楚。**
|
||||
这一段主要落在前端鉴权恢复逻辑和测试,不会直接波及运行时战斗、任务、物品、剧情主链。
|
||||
4. **收益直接。**
|
||||
一旦收掉,前端就少了一份最不该长期保留的高风险真相。
|
||||
|
||||
一句话讲:
|
||||
|
||||
**这一步先把“浏览器记住游客账号密码再重登”这条假真相链拔掉。**
|
||||
|
||||
---
|
||||
|
||||
## 3. 本批次明确没做的事
|
||||
|
||||
## 3.1 没有直接删除 `runtimeStoryCoordinator.ts` 里的前置 `putSaveSnapshot(...)`
|
||||
|
||||
这不是漏做,而是明确暂缓。
|
||||
|
||||
当前复核结果是:
|
||||
|
||||
1. `server-node/src/modules/story/storyActionService.ts`
|
||||
2. `server-node/src/routes/runtimeRoutes.ts`
|
||||
3. `server-node/src/repositories/runtimeRepository.ts`
|
||||
|
||||
这条后端链当前仍然通过远端快照读取运行时状态,再执行:
|
||||
|
||||
1. `getRuntimeStoryState`
|
||||
2. `resolveRuntimeStoryAction`
|
||||
|
||||
也就是说,当前真实情况不是“前端多写了一份完全没用的镜像”,而是:
|
||||
|
||||
**前端在提交动作前先把当前状态写回远端快照,后端再基于这份快照执行业务动作。**
|
||||
|
||||
在这个 contract 没先升级为“前端只发 action,后端自己持有完整 session 真相”之前,前端不能直接把这一步砍掉。
|
||||
|
||||
否则会出现:
|
||||
|
||||
1. 动作请求仍在走
|
||||
2. 但服务端读取到的执行基线不完整
|
||||
3. 最后不是收口真相,而是把主链打断
|
||||
|
||||
## 3.2 没有删除 `runtimeStoryService.ts` / `runtimeStoryCoordinator.ts` 的快照再水合逻辑
|
||||
|
||||
这一步本轮也做了复核,结论是:
|
||||
|
||||
1. 我曾尝试把 `runtimeStoryCoordinator.ts` 中对服务端返回快照的重复再水合去掉
|
||||
2. 但对应的 `runtimeStoryCoordinator` 测试立即暴露出:当前后端返回的快照在部分战斗场景下还不是完整水合态
|
||||
3. 说明前端当前这层再水合仍然有现实职责,不是纯多余代码
|
||||
|
||||
所以这一步本批明确结论是:
|
||||
|
||||
**暂不删除,等后端快照 contract 先补完整后再做。**
|
||||
|
||||
---
|
||||
|
||||
## 4. 本批次验证结果
|
||||
|
||||
本批次已完成的定向验证:
|
||||
|
||||
1. `npx vitest run src/services/authService.test.ts`
|
||||
2. `npx vitest run src/components/auth/AuthGate.test.tsx`
|
||||
3. `npx vitest run src/hooks/story/runtimeStoryCoordinator.test.ts`
|
||||
4. `npm run check:encoding`
|
||||
|
||||
结果:
|
||||
|
||||
1. `authService` 测试通过
|
||||
2. `AuthGate` 测试通过
|
||||
3. `runtimeStoryCoordinator` 测试通过
|
||||
4. 编码检查通过
|
||||
|
||||
另外执行了:
|
||||
|
||||
1. `npm run build`
|
||||
|
||||
结果:
|
||||
|
||||
构建产物生成成功,但 `build-gate` 仍因主包 chunk warning 拦截失败。
|
||||
当前失败点仍是已知的主包体积问题:
|
||||
|
||||
- `AuthenticatedApp-*.js` 超过当前 warning 门槛
|
||||
|
||||
这属于仓库当前既有工程问题,不是本批次引入的新断裂。
|
||||
|
||||
---
|
||||
|
||||
## 5. 本批次完成后的实际收益
|
||||
|
||||
这一步完成后,工程在鉴权边界上有了两个明确改善:
|
||||
|
||||
1. **前端不再保存自动登录用户名 / 密码。**
|
||||
浏览器只保留 access token,本地高风险游客凭证真相已经收掉。
|
||||
2. **登录恢复逻辑更接近服务端为真相源。**
|
||||
`AuthGate` 不再假设“没有本地 token 就一定还没登录”,而是优先尝试服务端会话恢复。
|
||||
|
||||
这意味着前端鉴权链已经从:
|
||||
|
||||
```text
|
||||
本地用户名/密码 -> 再次 entry -> 拿 token
|
||||
```
|
||||
|
||||
进一步收到了:
|
||||
|
||||
```text
|
||||
refresh session / 当前会话 -> 恢复用户
|
||||
兜底时才创建一次游客凭证
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 本批次后续建议
|
||||
|
||||
要继续完成批次 C,下一步不该直接在前端硬删,而应该先补后端 contract:
|
||||
|
||||
1. 让 `runtime story` 动作链逐步摆脱“前端先写远端快照”的依赖
|
||||
2. 让服务端自己持有更完整的运行时 session 真相
|
||||
3. 等后端返回快照已经稳定水合后,再删前端的重复再水合
|
||||
|
||||
换句话说,批次 C 的后半段应该拆成:
|
||||
|
||||
1. **C-1:鉴权真相收口**
|
||||
本批已完成
|
||||
2. **C-2:运行时快照 contract 后端化**
|
||||
需要先改后端
|
||||
3. **C-3:前端镜像写入与重复水合退场**
|
||||
依赖 C-2
|
||||
|
||||
---
|
||||
|
||||
## 7. 一句话总结
|
||||
|
||||
**批次 C 这一轮已经先把“浏览器长期保存游客账号密码”这条最不该存在的鉴权假真相链收掉了;而运行时快照前置写入这条链经过复核确认仍受后端 contract 约束,不能在服务端未先补齐前硬砍。**
|
||||
@@ -0,0 +1,56 @@
|
||||
# 工程死分支清理执行记录 D(2026-04-21)
|
||||
|
||||
更新时间:`2026-04-21`
|
||||
|
||||
## 0. 本批次目标
|
||||
|
||||
本批次继续清理上一轮复核后剩余的低风险数据产物与测试占位:
|
||||
|
||||
1. 未接入业务的生成产物
|
||||
2. 只为测试替换真实实现的空 stub
|
||||
3. 支撑这些残留的配置与脚本
|
||||
|
||||
---
|
||||
|
||||
## 1. 已删除对象
|
||||
|
||||
| 文件 | 判定 | 删除原因 | 替代路径 / 当前真相源 |
|
||||
| --- | --- | --- | --- |
|
||||
| `src/data/buildTagSimilarity.generated.ts` | 未接入业务的生成产物 | 运行时代码不 import;Build 相似度当前由 `buildTags.ts` 中的属性亲和度逻辑计算 | `src/data/buildTags.ts` |
|
||||
| `scripts/generate-build-tag-similarity.py` | 已无输出目标的生成脚本 | 只负责生成已删除的矩阵文件,继续保留会误导后续开发恢复旧主源 | `src/data/buildTags.ts` 的手工审表逻辑 |
|
||||
| `src/data/customWorldCharacterLoadout.stub.ts` | 测试专用空 stub | 只通过 `vitest.config.ts` alias 替换真实实现;真实实现已经稳定存在 | `src/data/customWorldCharacterLoadout.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 2. 同步更新
|
||||
|
||||
本批次同步移除了:
|
||||
|
||||
1. `vitest.config.ts` 中指向 `customWorldCharacterLoadout.stub.ts` 的 alias
|
||||
2. `BUILD_SYSTEM_ATTRIBUTE_SIMILARITY_PRD_2026-04-02.md` 中把旧 generated 矩阵描述为当前文件的表述
|
||||
3. 清理计划里对 `buildTagSimilarity.generated.ts` 的未处理状态说明
|
||||
|
||||
---
|
||||
|
||||
## 3. 验证口径
|
||||
|
||||
删除前已确认:
|
||||
|
||||
1. `buildTagSimilarity.generated.ts` 无运行时代码引用
|
||||
2. `customWorldCharacterLoadout.stub.ts` 只被 `vitest.config.ts` alias 引用
|
||||
3. 真实 `customWorldCharacterLoadout.ts` 仍被 `characterPresets.ts` 与 `npcInteractions.ts` 使用,不能删除
|
||||
|
||||
删除后建议验证:
|
||||
|
||||
1. `npm run check:encoding`
|
||||
2. 与自定义世界开局物品相关的测试
|
||||
|
||||
---
|
||||
|
||||
## 4. 当前结论
|
||||
|
||||
本批次完成后,剩余清理对象已经不再适合按“无引用直接删”推进。后续如果继续清,需要先改 contract 或主链职责:
|
||||
|
||||
1. 运行时快照真相链
|
||||
2. 任务 / 物品 / AI 混合执行层
|
||||
3. 大型主流程组件继续拆分,而不是直接删除
|
||||
@@ -0,0 +1,503 @@
|
||||
# 前端应迁后端逻辑审计(2026-04-21)
|
||||
|
||||
更新时间:`2026-04-21`
|
||||
|
||||
## 0. 审计目标
|
||||
|
||||
这份文档只回答一个问题:
|
||||
|
||||
**当前前端代码里,哪些逻辑已经明显越过“前端只做表现,Express 后端负责逻辑、数据与存储”的边界,应该继续迁到后端。**
|
||||
|
||||
本轮不改业务代码,只做:
|
||||
|
||||
1. 基于当前仓库状态给出高置信度候选点
|
||||
2. 标明代码证据
|
||||
3. 给出迁移优先级
|
||||
4. 说明迁移后前端应该保留什么、移走什么
|
||||
|
||||
---
|
||||
|
||||
## 1. 结论先行
|
||||
|
||||
结合当前代码与已有边界文档,前端里仍有 6 类逻辑应该继续后移:
|
||||
|
||||
1. **运行时快照前置写入与本地镜像解释**
|
||||
2. **鉴权 token 的浏览器本地真相**
|
||||
3. **平台浏览历史的本地真相与迁移状态**
|
||||
4. **NPC 待接委托“换单”仍由前端直接触发正式生成**
|
||||
5. **quest/runtime item 的双环境混合编排**
|
||||
6. **浏览器侧大型 AI orchestration 与 prompt/repair/fallback 主链**
|
||||
|
||||
一句话判断:
|
||||
|
||||
**当前前端已经不是最早那种“大量主算”的状态,但仍然保留了运行时镜像、生成编排和部分正式真相。后端边界还需要再收一轮,前端才算真正退回表现层。**
|
||||
|
||||
---
|
||||
|
||||
## 2. 审计依据
|
||||
|
||||
### 2.1 文档依据
|
||||
|
||||
1. `docs/experience/PROJECT_WORK_EXPERIENCE_PLAYBOOK.md`
|
||||
2. `docs/planning/EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md`
|
||||
3. `docs/technical/RUNTIME_STORY_BACKEND_BOUNDARY_MIGRATION_2026-04-19.md`
|
||||
4. `docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md`
|
||||
5. `docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md`
|
||||
|
||||
### 2.2 当前代码依据
|
||||
|
||||
1. `src/hooks/story/runtimeStoryCoordinator.ts`
|
||||
2. `src/services/apiClient.ts`
|
||||
3. `src/services/platformBrowseHistory.ts`
|
||||
4. `src/components/game-shell/PreGameSelectionFlow.tsx`
|
||||
5. `src/hooks/story/npcEncounterActions.ts`
|
||||
6. `src/services/questDirector.ts`
|
||||
7. `src/services/runtimeItemAiDirector.ts`
|
||||
8. `src/services/ai.ts`
|
||||
|
||||
---
|
||||
|
||||
## 3. 当前高置信度应后移逻辑
|
||||
|
||||
## 3.1 运行时快照前置写入仍在前端
|
||||
|
||||
### 代码证据
|
||||
|
||||
`src/hooks/story/runtimeStoryCoordinator.ts` 当前仍存在以下链路:
|
||||
|
||||
1. `syncRuntimeSnapshot(...)`
|
||||
2. `syncRuntimeSnapshot(...)` 内部直接调用 `putSaveSnapshot(...)`
|
||||
3. `loadServerRuntimeOptionCatalog(...)` 在请求 `getRuntimeStoryState(...)` 之前先写本地快照
|
||||
4. `resolveServerRuntimeChoice(...)` 在请求 `resolveRuntimeStoryAction(...)` 之前先写本地快照
|
||||
|
||||
对应位置:
|
||||
|
||||
1. `src/hooks/story/runtimeStoryCoordinator.ts:21`
|
||||
2. `src/hooks/story/runtimeStoryCoordinator.ts:25`
|
||||
3. `src/hooks/story/runtimeStoryCoordinator.ts:36`
|
||||
4. `src/hooks/story/runtimeStoryCoordinator.ts:99`
|
||||
|
||||
### 当前问题
|
||||
|
||||
这意味着运行时正式动作发起前,前端仍会先落一份自己的快照真相,再去请求后端。
|
||||
|
||||
这条链的问题不是“有没有缓存”,而是:
|
||||
|
||||
1. 前端仍在承担正式提交前的状态镜像
|
||||
2. 快照解释权没有完全收回到后端
|
||||
3. 运行时主链仍处于“本地镜像 + 服务端会话”并存状态
|
||||
|
||||
### 迁移建议
|
||||
|
||||
后端继续承接:
|
||||
|
||||
1. 运行时快照写入
|
||||
2. 快照版本解释
|
||||
3. 动作提交前的状态一致性校验
|
||||
|
||||
前端只保留:
|
||||
|
||||
1. 当前展示用的 view model
|
||||
2. 可选的只读恢复缓存
|
||||
3. 纯表现态的 loading / transition / animation state
|
||||
|
||||
### 优先级
|
||||
|
||||
`P0`
|
||||
|
||||
---
|
||||
|
||||
## 3.2 鉴权 token 仍由前端 localStorage 持有真相
|
||||
|
||||
### 代码证据
|
||||
|
||||
`src/services/apiClient.ts` 当前仍直接访问 `window.localStorage` 保存 access token:
|
||||
|
||||
1. `getStoredAccessToken()`
|
||||
2. `setStoredAccessToken(...)`
|
||||
3. `clearStoredAccessToken(...)`
|
||||
4. `withAuthorizationHeaders(...)` 直接从本地 token 组装请求头
|
||||
|
||||
对应位置:
|
||||
|
||||
1. `src/services/apiClient.ts:333`
|
||||
2. `src/services/apiClient.ts:341`
|
||||
3. `src/services/apiClient.ts:362`
|
||||
4. `src/services/apiClient.ts:382`
|
||||
|
||||
### 当前问题
|
||||
|
||||
第三批清理已经收掉了“自动登录用户名/密码”本地真相,但 access token 仍然由浏览器长期持有。
|
||||
|
||||
这在当前项目边界下仍有两个问题:
|
||||
|
||||
1. 正式鉴权真相仍没有完全收回后端 session 边界
|
||||
2. 前端 SDK 仍然负担 token 生命周期的关键部分
|
||||
|
||||
### 迁移建议
|
||||
|
||||
后端继续承接:
|
||||
|
||||
1. session / refresh / cookie 真相
|
||||
2. 鉴权状态续期
|
||||
3. token 更新与失效策略
|
||||
|
||||
前端只保留:
|
||||
|
||||
1. 当前是否已登录的展示态
|
||||
2. 统一的请求封装
|
||||
3. 401 后的 UI 响应
|
||||
|
||||
### 优先级
|
||||
|
||||
`P0`
|
||||
|
||||
---
|
||||
|
||||
## 3.3 平台浏览历史仍是“前端本地历史 + 后端回填”的双真相
|
||||
|
||||
### 代码证据
|
||||
|
||||
`src/services/platformBrowseHistory.ts` 当前仍维护一整套本地历史真相:
|
||||
|
||||
1. `readPlatformBrowseHistory(...)`
|
||||
2. `writePlatformBrowseHistory(...)`
|
||||
3. `hasPendingPlatformBrowseHistoryMigration(...)`
|
||||
4. `markPlatformBrowseHistoryMigrated(...)`
|
||||
|
||||
对应位置:
|
||||
|
||||
1. `src/services/platformBrowseHistory.ts:77`
|
||||
2. `src/services/platformBrowseHistory.ts:103`
|
||||
3. `src/services/platformBrowseHistory.ts:151`
|
||||
4. `src/services/platformBrowseHistory.ts:164`
|
||||
|
||||
`src/components/game-shell/PreGameSelectionFlow.tsx` 当前仍显式做:
|
||||
|
||||
1. 先 `writePlatformBrowseHistory(...)`
|
||||
2. 再调用 `upsertProfileBrowseHistory(...)`
|
||||
3. 同步成功后 `markPlatformBrowseHistoryMigrated(...)`
|
||||
4. 启动阶段读取 `readPlatformBrowseHistory(...)`
|
||||
5. 根据 `hasPendingPlatformBrowseHistoryMigration(...)` 决定是否补同步
|
||||
|
||||
对应位置:
|
||||
|
||||
1. `src/components/game-shell/PreGameSelectionFlow.tsx:383`
|
||||
2. `src/components/game-shell/PreGameSelectionFlow.tsx:392`
|
||||
3. `src/components/game-shell/PreGameSelectionFlow.tsx:394`
|
||||
4. `src/components/game-shell/PreGameSelectionFlow.tsx:433`
|
||||
5. `src/components/game-shell/PreGameSelectionFlow.tsx:466`
|
||||
|
||||
### 当前问题
|
||||
|
||||
这条链已经不是单纯缓存,而是:
|
||||
|
||||
1. 本地历史存储
|
||||
2. 本地同步标记
|
||||
3. 后端历史持久化
|
||||
|
||||
三套状态同时存在。
|
||||
|
||||
### 迁移建议
|
||||
|
||||
后端继续承接:
|
||||
|
||||
1. 浏览历史唯一持久化真相
|
||||
2. 历史去重、排序、截断
|
||||
3. 迁移完成标记
|
||||
|
||||
前端只保留:
|
||||
|
||||
1. 展示缓存
|
||||
2. 弱网下的临时 optimistic UI
|
||||
3. 刷新后重新拉取远端结果
|
||||
|
||||
### 优先级
|
||||
|
||||
`P1`
|
||||
|
||||
---
|
||||
|
||||
## 3.4 NPC 待接委托“换单”仍由前端直接发起正式生成
|
||||
|
||||
### 代码证据
|
||||
|
||||
`src/hooks/story/npcEncounterActions.ts` 当前仍保留:
|
||||
|
||||
1. `replacePendingNpcQuestOffer = async () => { ... }`
|
||||
2. 内部直接调用 `generateQuestForNpcEncounter(...)`
|
||||
|
||||
对应位置:
|
||||
|
||||
1. `src/hooks/story/npcEncounterActions.ts:1561`
|
||||
2. `src/hooks/story/npcEncounterActions.ts:1595`
|
||||
|
||||
### 当前问题
|
||||
|
||||
聊天后是否挂出待接委托已经后移,但“换一份委托”这条分支仍然是:
|
||||
|
||||
1. 前端组装上下文
|
||||
2. 前端决定调用生成
|
||||
3. 前端直接把结果写回当前 story UI
|
||||
|
||||
这仍属于正式运行时任务编排没有收干净。
|
||||
|
||||
### 迁移建议
|
||||
|
||||
后端继续承接:
|
||||
|
||||
1. NPC 待接委托换单决策
|
||||
2. 是否允许换单
|
||||
3. 换单后的任务草案生成
|
||||
4. 对应聊天态快照回填
|
||||
|
||||
前端只保留:
|
||||
|
||||
1. 点击“换一份委托”
|
||||
2. loading / error 展示
|
||||
3. 消费后端返回的新 pending quest offer
|
||||
|
||||
### 优先级
|
||||
|
||||
`P0`
|
||||
|
||||
---
|
||||
|
||||
## 3.5 questDirector 仍是前端 SDK 与生成编排混合体
|
||||
|
||||
### 代码证据
|
||||
|
||||
`src/services/questDirector.ts` 当前同时承担:
|
||||
|
||||
1. `generateQuestForNpcEncounter(...)`
|
||||
2. 浏览器路径 `requestJson('/api/runtime/quests/generate')`
|
||||
3. 非浏览器路径 `requestChatMessageContent(...)`
|
||||
4. 本地 `compileQuestIntentToQuest(...)` fallback
|
||||
|
||||
对应位置:
|
||||
|
||||
1. `src/services/questDirector.ts:213`
|
||||
2. `src/services/questDirector.ts:242`
|
||||
3. `src/services/questDirector.ts:267`
|
||||
4. `src/services/questDirector.ts:256`
|
||||
5. `src/services/questDirector.ts:281`
|
||||
6. `src/services/questDirector.ts:293`
|
||||
|
||||
### 当前问题
|
||||
|
||||
这类文件虽然浏览器正式路径已经优先走后端,但职责仍混在一起:
|
||||
|
||||
1. 前端 SDK
|
||||
2. Quest prompt 编排
|
||||
3. Quest intent 解析
|
||||
4. deterministic fallback compile
|
||||
|
||||
这会导致边界长期模糊,也让前端仍像“半个服务端”。
|
||||
|
||||
### 迁移建议
|
||||
|
||||
后端继续承接:
|
||||
|
||||
1. quest intent 生成
|
||||
2. prompt 组装
|
||||
3. JSON 解析
|
||||
4. fallback compile
|
||||
|
||||
前端只保留:
|
||||
|
||||
1. `requestGenerateQuest(...)` 这类轻量 SDK
|
||||
2. 请求参数组装
|
||||
3. 结果消费
|
||||
|
||||
### 优先级
|
||||
|
||||
`P1`
|
||||
|
||||
---
|
||||
|
||||
## 3.6 runtimeItemAiDirector 仍是前端 SDK 与意图生成混合体
|
||||
|
||||
### 代码证据
|
||||
|
||||
`src/services/runtimeItemAiDirector.ts` 当前同时承担:
|
||||
|
||||
1. `generateRuntimeItemAiIntents(...)`
|
||||
2. 浏览器路径 `requestJson('/api/runtime/items/runtime-intent')`
|
||||
3. 非浏览器路径 `requestChatMessageContent(...)`
|
||||
4. 本地 `buildRuntimeItemAiIntent(...)` fallback
|
||||
|
||||
对应位置:
|
||||
|
||||
1. `src/services/runtimeItemAiDirector.ts:84`
|
||||
2. `src/services/runtimeItemAiDirector.ts:94`
|
||||
3. `src/services/runtimeItemAiDirector.ts:118`
|
||||
|
||||
### 当前问题
|
||||
|
||||
它和 `questDirector` 是同类问题:
|
||||
|
||||
1. 正式浏览器路径已经走后端
|
||||
2. 但前端文件仍然承担完整生成逻辑认知
|
||||
3. 文件职责仍然是双环境混合
|
||||
|
||||
### 迁移建议
|
||||
|
||||
后端继续承接:
|
||||
|
||||
1. runtime item intent prompt
|
||||
2. 模型调用
|
||||
3. 结果解析与 fallback
|
||||
|
||||
前端只保留:
|
||||
|
||||
1. 轻量请求 SDK
|
||||
2. 结果到 UI 的映射
|
||||
|
||||
### 优先级
|
||||
|
||||
`P1`
|
||||
|
||||
---
|
||||
|
||||
## 3.7 `src/services/ai.ts` 仍是浏览器侧正式 AI orchestration 热点
|
||||
|
||||
### 代码证据
|
||||
|
||||
当前 `src/services/ai.ts` 仍直接承担以下正式链路:
|
||||
|
||||
1. `requestChatMessageContent(...)`
|
||||
2. `requestPlainTextCompletionFromClient(...)`
|
||||
3. `streamPlainTextCompletionFromClient(...)`
|
||||
4. `generateCustomWorldProfile(...)`
|
||||
5. `generateInitialStory(...)`
|
||||
6. `generateNextStep(...)`
|
||||
7. `streamNpcChatDialogue(...)`
|
||||
8. `streamNpcRecruitDialogue(...)`
|
||||
|
||||
对应位置:
|
||||
|
||||
1. `src/services/ai.ts:1732`
|
||||
2. `src/services/ai.ts:1868`
|
||||
3. `src/services/ai.ts:2038`
|
||||
4. `src/services/ai.ts:2339`
|
||||
5. `src/services/ai.ts:2447`
|
||||
6. `src/services/ai.ts:2487`
|
||||
7. `src/services/ai.ts:2529`
|
||||
8. `src/services/ai.ts:2570`
|
||||
|
||||
并且文件内仍保留:
|
||||
|
||||
1. JSON repair
|
||||
2. prompt 组装
|
||||
3. response normalize
|
||||
4. fallback/offline 响应
|
||||
5. 角色聊天建议与摘要生成
|
||||
|
||||
### 当前问题
|
||||
|
||||
这说明浏览器端并不只是“请求一个后端接口”,而是还在承担:
|
||||
|
||||
1. prompt source
|
||||
2. 生成策略
|
||||
3. 错误修复
|
||||
4. fallback 编排
|
||||
5. 多类业务场景的正式 AI 出口
|
||||
|
||||
这与“前端只做表现”存在明确冲突。
|
||||
|
||||
### 迁移建议
|
||||
|
||||
后端继续承接:
|
||||
|
||||
1. story / npc / recruit / custom-world 的 prompt 编排
|
||||
2. JSON repair
|
||||
3. fallback 策略
|
||||
4. streaming orchestration
|
||||
5. 模型调用与日志
|
||||
|
||||
前端只保留:
|
||||
|
||||
1. 轻量 AI SDK
|
||||
2. SSE 文本流展示
|
||||
3. UI fallback 呈现
|
||||
|
||||
### 优先级
|
||||
|
||||
`P0`
|
||||
|
||||
---
|
||||
|
||||
## 4. 可以暂时保留在前端的部分
|
||||
|
||||
下面这些内容即使和上述模块同文件出现,也不属于必须后移的对象:
|
||||
|
||||
1. 面板开关、loading、error、streaming 文本展示
|
||||
2. 动画时间线、过场状态、临时 UI 回显
|
||||
3. 表单草稿、筛选词、排序选项
|
||||
4. 只影响表现、不影响正式真相的 view model 拼接
|
||||
|
||||
迁移时要注意:
|
||||
|
||||
**不是把所有前端代码都往后端搬,而是把“正式状态解释、规则裁决、生成编排、持久化真相”搬走。**
|
||||
|
||||
---
|
||||
|
||||
## 5. 推荐迁移顺序
|
||||
|
||||
## 5.1 第一阶段
|
||||
|
||||
先收最危险的正式真相:
|
||||
|
||||
1. `runtimeStoryCoordinator.ts`
|
||||
2. `apiClient.ts`
|
||||
3. `npcEncounterActions.ts` 里的 quest replace 分支
|
||||
|
||||
原因:
|
||||
|
||||
1. 这三处最直接影响运行时真相和动作主链
|
||||
2. 不先收这些,前端仍然不是纯表现层
|
||||
|
||||
## 5.2 第二阶段
|
||||
|
||||
再拆双环境混合服务:
|
||||
|
||||
1. `questDirector.ts`
|
||||
2. `runtimeItemAiDirector.ts`
|
||||
3. `platformBrowseHistory.ts`
|
||||
|
||||
原因:
|
||||
|
||||
1. 这几处已经有后端承接基础
|
||||
2. 迁移成本相对可控
|
||||
|
||||
## 5.3 第三阶段
|
||||
|
||||
最后继续压缩浏览器 AI orchestration:
|
||||
|
||||
1. `src/services/ai.ts`
|
||||
2. 相关 prompt builder / repair helper / offline fallback
|
||||
|
||||
原因:
|
||||
|
||||
1. 这部分体量大
|
||||
2. 链路多
|
||||
3. 更适合在前两阶段把 contract 稳住后集中拆
|
||||
|
||||
---
|
||||
|
||||
## 6. 建议产出物
|
||||
|
||||
如果后续按这份文档继续落地,建议每一批都至少同步产出:
|
||||
|
||||
1. 一份落地文档,说明迁移了哪条链
|
||||
2. 一组 contract/route 变更说明
|
||||
3. 一组前端 SDK 收缩说明
|
||||
4. 一组防回退测试
|
||||
|
||||
---
|
||||
|
||||
## 7. 一句话结论
|
||||
|
||||
当前前端最需要继续后移的,不是零散小工具,而是:
|
||||
|
||||
**运行时快照前置写入、鉴权 token、本地浏览历史真相、NPC 委托换单、quest/runtime item 双环境混合编排,以及 `src/services/ai.ts` 里仍然留在浏览器的正式 AI orchestration。**
|
||||
@@ -4,21 +4,36 @@
|
||||
|
||||
## 当前推荐入口
|
||||
|
||||
1. [CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md](./CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md)
|
||||
1. [FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md](./FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md)
|
||||
这一版是本轮前端越界逻辑专项审计,专门汇总当前仍应继续迁到 Express 后端的运行时、鉴权、生成编排与本地真相残留。
|
||||
2. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_D_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_D_2026-04-21.md)
|
||||
这一版是第四批落地记录,聚焦未接入业务的数据生成产物、测试专用 stub 与对应配置残留出清。
|
||||
3. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_C_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_C_2026-04-21.md)
|
||||
这一版是第三批落地记录,聚焦鉴权真相收口,先移除前端保存自动登录用户名/密码的本地真相,并明确运行时快照前置写入为什么当前还不能硬砍。
|
||||
4. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_B_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_B_2026-04-21.md)
|
||||
这一版是第二批落地记录,聚焦旧主流程壳层、旧 bootstrap 和旧 inventory / forge / equipment flow Hook 的正式出清。
|
||||
5. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_A_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_A_2026-04-21.md)
|
||||
这一版是第一批落地记录,聚焦高置信度小型孤岛、prompt 壳子、stub 和无入口 modal 的首轮清理。
|
||||
6. [CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md](./CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md)
|
||||
这一版是面向当前仓库状态的优化点盘点,适合直接拿来排优先级和拆执行批次。
|
||||
2. [ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md](./ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md)
|
||||
7. [ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md](./ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md)
|
||||
这一版是对 `2026-04-19` 基线的当前仓库复核,明确哪些问题已经处理、哪些表述需要纠正、热点又迁移到了哪里。
|
||||
3. [ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md](./ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md)
|
||||
8. [ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md](./ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md)
|
||||
这一版保留原始问题快照和执行回填,适合回看“为什么会有这轮清理与边界收口”。
|
||||
4. [ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md)
|
||||
9. [ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md)
|
||||
这一版最适合作为当前工程基线,重点从“是否真正绿色”“门禁有没有覆盖真实风险”来判断仓库状态。
|
||||
5. [ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md)
|
||||
10. [ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md)
|
||||
适合回看运行时主链路、Story/Combat 边界、分层过渡期问题。
|
||||
6. [ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md)
|
||||
11. [ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md)
|
||||
适合看第一轮系统性工程扫描,了解最早的问题基线。
|
||||
|
||||
## 融合结论
|
||||
|
||||
- 最新专项审计已经把“前端哪些逻辑还该后移到后端”收敛到 6 类:运行时快照、本地 token、本地浏览历史、NPC 委托换单、quest/runtime item 混合编排、浏览器 AI orchestration。
|
||||
- 工程大清洗已经开始进入实际执行阶段,首批高置信度小型孤岛和残留壳子已开始清理。
|
||||
- 第二批已经开始清理旧主流程壳层与旧 flow Hook,当前主工程的“现行入口”和“历史入口”边界正在变得更清楚。
|
||||
- 第三批已经先完成鉴权真相收口的一段,前端不再保存自动登录用户名/密码;运行时快照链仍需先补后端 contract,再继续往前删。
|
||||
- 第四批已经继续收掉未接入业务的数据生成产物、测试专用 stub 与对应脚本/配置残留,主工程里的“假数据主源”进一步减少。
|
||||
- 当前仓库已经完成“旧 dev 插件链路删除、根目录噪音清理、`server-node -> src/**` 反向依赖切断”这批第一阶段任务。
|
||||
- 当前如果想直接判断“今天先优化什么”,优先看 `CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md`。
|
||||
- 当前的新重点已经进一步收敛到三类:未接线孤岛模块、前端残留的运行时/鉴权真相、热点向 prompt/runtime profile/平台入口壳层迁移。
|
||||
|
||||
@@ -22,20 +22,20 @@
|
||||
|
||||
### 2.1 编辑器入口与页签整理
|
||||
|
||||
- 保留 `/preset-editor`、`/npc-editor`、`/function-editor`
|
||||
- 新增 `/behavior-editor` 作为“选项行为”编辑页别名
|
||||
- 当时曾保留 `/preset-editor`、`/npc-editor`、`/function-editor`
|
||||
- 当时还新增过 `/behavior-editor` 作为“选项行为”编辑页别名
|
||||
- 将原先单独的 `NPC 视觉` 标签并回 `NPC` 编辑页
|
||||
- 将 `Function` 页签改名为 `选项行为`
|
||||
|
||||
结论:
|
||||
- 路由层要尽量兼容旧入口,避免历史链接失效
|
||||
- 独立编辑器入口如果没有继续接入主流程,应及时物理删除,不要长期保留兼容壳
|
||||
- 页签命名要贴近创作者语言,而不是内部实现命名
|
||||
|
||||
### 2.2 NPC 视觉模块并入 NPC 编辑
|
||||
|
||||
完成内容:
|
||||
|
||||
- 将 [NpcVisualEditor](/E:/Repos/Genarrative/src/components/NpcVisualEditor.tsx) 嵌入 [PresetEditor](/E:/Repos/Genarrative/src/components/PresetEditor.tsx) 的 NPC 编辑页
|
||||
- 当时曾将 `NpcVisualEditor` 嵌入 `PresetEditor` 的 NPC 编辑页
|
||||
- 让 NPC 文本字段与视觉字段围绕同一个当前选中 NPC 联动
|
||||
- 保留视觉覆盖保存与全局布局保存能力
|
||||
|
||||
|
||||
@@ -86,6 +86,22 @@
|
||||
2. 不为了“文档里写过”就把所有没接线面板都接进来
|
||||
3. 不把当前工作区重新改造成一个更复杂的大后台
|
||||
|
||||
补充更新(`2026-04-21`):
|
||||
|
||||
上一轮审计里提到的一组旧 Agent 副面板,已经在工程清理中被明确判定为退出当前版本主链并物理删除,包括:
|
||||
|
||||
1. `CustomWorldAgentLockBar.tsx`
|
||||
2. `CustomWorldAgentQuickActions.tsx`
|
||||
3. `CustomWorldAgentSummaryPanel.tsx`
|
||||
4. `CustomWorldAgentIntentSummaryPanel.tsx`
|
||||
5. `CustomWorldAgentClarificationPanel.tsx`
|
||||
6. `CustomWorldAgentDraftDetailPanel.tsx`
|
||||
7. `CustomWorldDraftCardDetailModal.tsx`
|
||||
8. `CustomWorldDraftEditPanel.tsx`
|
||||
9. `CustomWorldGenerateEntityModal.tsx`
|
||||
|
||||
所以这里的“不为了文档里写过就全接进来”,现在不只是态度提醒,而是已经执行过的现实边界。
|
||||
|
||||
### 3.3 不把结果页继续当旧编辑器扩写
|
||||
|
||||
这轮明确不再鼓励:
|
||||
@@ -244,6 +260,15 @@
|
||||
|
||||
尤其是旧 `custom-world/sessions` 这条链,如果还要保留,也只能是兼容入口,不能再和 Agent 主链平起平坐。
|
||||
|
||||
补充更新(`2026-04-21`):
|
||||
|
||||
这条旧 `custom-world/sessions` 世界生成链已经完成物理删除。
|
||||
|
||||
因此阶段三在当前仓库里的剩余任务,不再是“决定要不要保留这条旧链”,而是:
|
||||
|
||||
1. 继续收掉结果页 legacy profile 直改能力的误导性职责
|
||||
2. 继续清理文档里把旧链当成现行选项的表述
|
||||
|
||||
---
|
||||
|
||||
## 4.5 第五件事:把文稿里那些“这轮不做”的未完成项从主叙事里移掉
|
||||
@@ -323,6 +348,12 @@
|
||||
2. 把“未来也许做”从“这轮要做”里拆开
|
||||
3. 让所有当前规划只服务当前版本
|
||||
|
||||
就当前状态补一句最重要的执行口径:
|
||||
|
||||
1. 已经物理删除的旧链和旧副面板,不再作为“本轮待落地项”
|
||||
2. 历史 PRD 可以保留实现设想,但必须和“当前版本执行规划”分开
|
||||
3. 当前版本规划只保留仍对正式主链有现实约束的事项
|
||||
|
||||
这一阶段的目标是:
|
||||
|
||||
**让接下来所有开发都围绕同一套现实目标执行。**
|
||||
|
||||
@@ -164,7 +164,7 @@ Git 分支治理可以后置做,但不能和首轮工程清洗混在一起,
|
||||
11. `src/services/typewriter.ts`
|
||||
12. `src/prompts/customWorldOrchestratorPrompts.ts`
|
||||
13. `src/prompts/storyOrchestratorPrompts.ts`
|
||||
14. `src/data/buildTagSimilarity.generated.ts`
|
||||
14. `src/data/buildTagSimilarity.generated.ts`(已在后续清理批次中删除)
|
||||
|
||||
这些文件不能直接判死刑,但必须进入“保留 / 接回 / 归档 / 删除”四选一清单。
|
||||
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
|
||||
更新时间:`2026-04-13`
|
||||
|
||||
## 0.1 当前状态说明(2026-04-21)
|
||||
|
||||
这份文档保留为第一阶段历史落地方案。
|
||||
|
||||
补充边界:
|
||||
|
||||
1. 文中出现的旧 Agent 副面板、旧世界生成链,不代表它们仍是当前版本待落地项
|
||||
2. 已被工程清理判定退出主链并删除的对象,应以最新清理记录为准,不再按这里的历史计划继续接回
|
||||
3. 当前版本执行优先级,应回到 `docs/planning/CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md`
|
||||
|
||||
## 0. 文档目的
|
||||
|
||||
这份文档用于把以下两份 PRD 收束成可直接开工的第一阶段实现方案:
|
||||
@@ -1056,6 +1066,16 @@ onSubmit({
|
||||
|
||||
## 12. 落地文件清单
|
||||
|
||||
### 当前状态补充(2026-04-21)
|
||||
|
||||
下面这份文件清单是第一阶段编写当日的历史落地清单。
|
||||
|
||||
需要特别注意:
|
||||
|
||||
1. 其中列出的 `CustomWorldAgentLockBar.tsx`、`CustomWorldAgentQuickActions.tsx` 等旧副面板,已经在当前版本清理中退出主链并物理删除
|
||||
2. 因此这里不能再被当作“当前版本仍要补齐的现行文件清单”
|
||||
3. 当前仍然有效的执行边界,应以最新优化规划和清理记录为准
|
||||
|
||||
## 12.1 shared
|
||||
|
||||
必须新增:
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
|
||||
更新时间:`2026-04-13`
|
||||
|
||||
## 0.1 当前状态说明(2026-04-21)
|
||||
|
||||
这份文档保留为第二阶段历史落地方案。
|
||||
|
||||
补充边界:
|
||||
|
||||
1. 文中提到的 `CustomWorldAgentIntentSummaryPanel`、`CustomWorldAgentClarificationPanel`、`CustomWorldAgentLockBar`、`CustomWorldAgentQuickActions` 等旧副面板,已经在当前版本收口判断中退出主链并物理删除
|
||||
2. 这些内容现在只能作为历史设计参考,不再作为当前版本默认待接线项
|
||||
3. 当前如果要重新设计这些能力的消费方式,应基于现行主链重新定义
|
||||
## 0. 文档目的
|
||||
|
||||
这份文档用于把以下两份文档进一步收束成第二阶段实现方案:
|
||||
@@ -548,6 +557,21 @@ buildPendingClarifications(intent, readiness)
|
||||
|
||||
## 10. 前端实现方案
|
||||
|
||||
### 当前状态补充(2026-04-21)
|
||||
|
||||
这一节描述的是第二阶段编写当时的右侧副面板方案。
|
||||
|
||||
但当前版本已经明确:
|
||||
|
||||
1. `CustomWorldAgentIntentSummaryPanel`
|
||||
2. `CustomWorldAgentClarificationPanel`
|
||||
3. `CustomWorldAgentLockBar`
|
||||
4. `CustomWorldAgentQuickActions`
|
||||
|
||||
这组旧副面板不再作为当前版本默认待接线方向,其中对应已落地但退出主链的文件也已物理删除。
|
||||
|
||||
因此本节以下内容应视为历史设计稿,不再直接代表当前版本执行方案。
|
||||
|
||||
## 10.1 修改 `CustomWorldAgentWorkspace.tsx`
|
||||
|
||||
第二阶段它不再只是空壳工作区,而要新增:
|
||||
@@ -679,6 +703,12 @@ buildPendingClarifications(intent, readiness)
|
||||
|
||||
## 13. 落地文件清单
|
||||
|
||||
### 当前状态补充(2026-04-21)
|
||||
|
||||
本节列的是第二阶段历史文件清单。
|
||||
|
||||
其中涉及旧副面板的部分,现在只保留历史参考价值,不再等同于当前版本待落地项。
|
||||
|
||||
## 13.1 shared
|
||||
|
||||
必须修改:
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
|
||||
更新时间:`2026-04-14`
|
||||
|
||||
## 0.1 当前状态说明(2026-04-21)
|
||||
|
||||
这份文档保留为第三阶段历史落地方案。
|
||||
|
||||
补充边界:
|
||||
|
||||
1. 文中涉及的 `CustomWorldAgentQuickActions`、`CustomWorldAgentDraftDetailPanel`、`CustomWorldDraftCardDetailModal` 等旧副面板,已在当前版本清理中退出主链并物理删除
|
||||
2. 因此这份文档里的对应实现章节,不能再直接视为当前版本待继续补完的目标
|
||||
3. 当前版本只保留对正式主链仍有现实约束的能力项
|
||||
## 0. 文档目的
|
||||
|
||||
这份文档用于把以下几份文档进一步收束成第三阶段实现方案:
|
||||
@@ -721,6 +730,21 @@ object_refining
|
||||
|
||||
## 11. 前端实现方案
|
||||
|
||||
### 当前状态补充(2026-04-21)
|
||||
|
||||
这一节描述的是第三阶段编写当时的草稿抽屉与详情侧栏方案。
|
||||
|
||||
但当前版本已经明确:
|
||||
|
||||
1. `CustomWorldAgentDraftDrawer.tsx` 已在 earlier cleanup 中删除
|
||||
2. `CustomWorldAgentDraftDetailPanel.tsx`
|
||||
3. `CustomWorldDraftCardDetailModal.tsx`
|
||||
4. `CustomWorldAgentQuickActions.tsx`
|
||||
|
||||
已在当前版本收口判断中退出主链并物理删除。
|
||||
|
||||
因此本节以下内容应视为历史设计稿,而不是当前版本默认待落地方案。
|
||||
|
||||
## 11.1 修改 `CustomWorldAgentQuickActions.tsx`
|
||||
|
||||
第三阶段必须让:
|
||||
@@ -918,6 +942,12 @@ creatorIntentReadiness.isReady === false
|
||||
|
||||
## 14. 落地文件清单
|
||||
|
||||
### 当前状态补充(2026-04-21)
|
||||
|
||||
本节列的是第三阶段历史文件清单。
|
||||
|
||||
其中涉及旧抽屉、旧详情面板、旧快捷动作面板的部分,当前仅保留历史参考意义。
|
||||
|
||||
## 14.1 shared
|
||||
|
||||
必须修改:
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
|
||||
更新时间:`2026-04-14`
|
||||
|
||||
## 0.1 当前状态说明(2026-04-21)
|
||||
|
||||
这份文档保留为第四阶段历史落地方案。
|
||||
|
||||
补充边界:
|
||||
|
||||
1. 文中聚焦的草稿详情编辑链与实体生成弹窗链,在当前版本收口判断中已经退出主链
|
||||
2. 对应的 `CustomWorldAgentDraftDetailPanel`、`CustomWorldDraftEditPanel`、`CustomWorldGenerateEntityModal`、`CustomWorldAgentQuickActions` 等文件已物理删除
|
||||
3. 当前版本不再把这套旧副面板链作为默认待接线方向
|
||||
## 0. 文档目的
|
||||
|
||||
这份文档用于把以下几份文档进一步收束成第四阶段实现方案:
|
||||
@@ -665,6 +674,21 @@ generateAdditionalLandmarks(params)
|
||||
|
||||
## 10. 前端实现方案
|
||||
|
||||
### 当前状态补充(2026-04-21)
|
||||
|
||||
这一节描述的是第四阶段编写当时的草稿详情编辑链与实体生成弹窗链。
|
||||
|
||||
但当前版本已经明确:
|
||||
|
||||
1. `CustomWorldAgentDraftDetailPanel.tsx`
|
||||
2. `CustomWorldDraftEditPanel.tsx`
|
||||
3. `CustomWorldGenerateEntityModal.tsx`
|
||||
4. `CustomWorldAgentQuickActions.tsx`
|
||||
|
||||
已在当前版本收口判断中退出主链并物理删除。
|
||||
|
||||
因此本节以下内容只保留历史设计参考价值,不再直接代表当前版本执行方向。
|
||||
|
||||
## 10.1 修改 `CustomWorldAgentDraftDetailPanel.tsx`
|
||||
|
||||
第四阶段它要从只读详情升级成:
|
||||
@@ -851,6 +875,12 @@ editableSectionIds: []
|
||||
|
||||
## 13. 落地文件清单
|
||||
|
||||
### 当前状态补充(2026-04-21)
|
||||
|
||||
本节列的是第四阶段历史文件清单。
|
||||
|
||||
其中涉及草稿详情编辑链与实体生成弹窗链的部分,不再等同于当前版本待落地清单。
|
||||
|
||||
## 13.1 shared
|
||||
|
||||
必须修改:
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
|
||||
更新时间:`2026-04-14`
|
||||
|
||||
## 0.1 当前状态说明(2026-04-21)
|
||||
|
||||
这份文档保留为第五阶段历史落地方案。
|
||||
|
||||
补充边界:
|
||||
|
||||
1. 文中继续沿用的 `CustomWorldAgentDraftDetailPanel`、`CustomWorldAgentQuickActions` 等旧副面板,在当前版本已经退出主链并物理删除
|
||||
2. 因此这份文档里的实现安排,不应再被直接视为当前版本执行清单
|
||||
3. 当前版本是否重引入相关能力,必须基于新的主链设计重新判断
|
||||
## 0. 文档目的
|
||||
|
||||
这份文档用于把以下几份文档进一步收束成第五阶段实现方案:
|
||||
@@ -195,6 +204,19 @@
|
||||
|
||||
## 7.2 入口位置
|
||||
|
||||
### 当前状态补充(2026-04-21)
|
||||
|
||||
这一节以下描述依赖当时仍被视为现行方案的旧详情面板链与旧快捷动作链。
|
||||
|
||||
但当前版本已经明确:
|
||||
|
||||
1. `CustomWorldAgentDraftDetailPanel.tsx`
|
||||
2. `CustomWorldAgentQuickActions.tsx`
|
||||
|
||||
已退出主链并物理删除。
|
||||
|
||||
因此这里关于入口位置的说明,现在只能作为历史资产工坊设计参考,不再代表当前版本 UI 入口。
|
||||
|
||||
### 角色卡详情入口
|
||||
|
||||
在 `CustomWorldAgentDraftDetailPanel` 中,当当前卡类型为:
|
||||
@@ -548,6 +570,12 @@ mergeRoleAssetIntoDraftProfile(draftProfile, payload);
|
||||
|
||||
## 11. 前端实现方案
|
||||
|
||||
### 当前状态补充(2026-04-21)
|
||||
|
||||
本节以下内容依赖旧详情面板链与旧快捷动作面板。
|
||||
|
||||
这些文件当前已经退出主链并删除,所以这里不再是当前版本的直接执行清单。
|
||||
|
||||
## 11.1 修改 `CustomWorldAgentDraftDetailPanel.tsx`
|
||||
|
||||
当卡片类型为 `character` 时,新增:
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
|
||||
更新时间:`2026-04-14`
|
||||
|
||||
## 0.1 当前状态说明(2026-04-21)
|
||||
|
||||
这份文档保留为第六阶段历史落地方案。
|
||||
|
||||
补充边界:
|
||||
|
||||
1. 文中继续引用的 `CustomWorldAgentDraftDetailPanel`、`CustomWorldAgentQuickActions` 等旧副面板,在当前版本已经退出主链并物理删除
|
||||
2. 这份文档可用于回看当时的资产工坊设想,但不代表当前版本仍按这里逐项补齐
|
||||
3. 当前执行边界以最新优化规划和阶段四清理边界文档为准
|
||||
## 0. 文档目的
|
||||
|
||||
这份文档用于把以下几份文档进一步收束成第六阶段实现方案:
|
||||
@@ -190,6 +199,19 @@
|
||||
|
||||
## 7.2 入口位置
|
||||
|
||||
### 当前状态补充(2026-04-21)
|
||||
|
||||
这一节以下描述依赖当时仍被视为现行方案的旧详情面板链与旧快捷动作链。
|
||||
|
||||
但当前版本已经明确:
|
||||
|
||||
1. `CustomWorldAgentDraftDetailPanel.tsx`
|
||||
2. `CustomWorldAgentQuickActions.tsx`
|
||||
|
||||
已退出主链并物理删除。
|
||||
|
||||
因此这里关于入口位置的说明,现在只保留历史场景资产工坊设计参考价值。
|
||||
|
||||
### 地点卡详情入口
|
||||
|
||||
在 `CustomWorldAgentDraftDetailPanel` 中,当当前卡类型为:
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
|
||||
更新时间:`2026-04-12`
|
||||
|
||||
## 0.1 当前状态说明(2026-04-21)
|
||||
|
||||
这份文档保留为历史 PRD 基线,用于回看当时的完整目标设计。
|
||||
|
||||
但需要特别注意:
|
||||
|
||||
1. 文中涉及的一部分旧 Agent 副面板与旧世界生成链,已经在 `2026-04-21` 的工程清理中判定退出当前版本主链并完成物理删除
|
||||
2. 当前版本的真实执行边界,以 `docs/planning/CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md`、`docs/technical/CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md` 和最新工程清理批次记录为准
|
||||
3. 阅读这份文档时,应把它视为“历史完整设计稿”,而不是“当前版本仍要全部继续落地的执行清单”
|
||||
|
||||
## 0. 文档目的
|
||||
|
||||
这份 PRD 用于把以下几份分析文档收束成一份可直接指导编码落地的新创作工具产品需求文档:
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
- `src/data/buildDamage.ts`
|
||||
- `src/data/buildTags.ts`
|
||||
- `src/data/buildTagSimilarity.generated.ts`
|
||||
|
||||
现状不是“标签各自独立生效”,而是:
|
||||
|
||||
@@ -263,7 +262,7 @@ type BuildDamageBreakdown = {
|
||||
|
||||
## 4.3 相似度来源
|
||||
|
||||
当前仓库已有 `src/data/buildTagSimilarity.generated.ts`,但新方案不再以“标签-标签相似度矩阵”为主数据源。
|
||||
旧版曾生成过标签-标签相似度矩阵,但新方案不再以“标签-标签相似度矩阵”为主数据源。
|
||||
|
||||
建议改为新增:
|
||||
|
||||
@@ -401,13 +400,13 @@ finalDamage =
|
||||
### 风险 3:旧数据迁移成本
|
||||
|
||||
问题:
|
||||
现有 `buildTagSimilarity.generated.ts` 将弱化甚至失去主要用途。
|
||||
旧标签相似度矩阵已经不再作为主数据源。
|
||||
|
||||
对策:
|
||||
|
||||
1. 本期不强制删除旧文件。
|
||||
2. 新逻辑只依赖新 affinity 表。
|
||||
3. 等新系统稳定后,再清理旧相似度矩阵和旧展示逻辑。
|
||||
1. 新逻辑只依赖标签定义中的属性亲和度。
|
||||
2. 旧相似度矩阵生成产物已从主工程移除。
|
||||
3. 后续若需要重新引入自动建议,也应输出为审表辅助数据,而不是运行时真相源。
|
||||
|
||||
## 11. 一句话结论
|
||||
|
||||
|
||||
@@ -74,7 +74,6 @@
|
||||
| `src/prompts/storyOrchestratorPrompts.ts` | 剧情中文修复 | `STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT` |
|
||||
| `src/prompts/customWorldRolePromptDefaults.ts` | 角色资产工作台默认词唯一主源 | `buildDefaultRolePromptBundle` |
|
||||
| `src/prompts/customWorldEntityActionPrompts.ts` | 编辑器技能动作词 | `buildSkillActionPrompt` |
|
||||
| `src/prompts/qwenSpriteSheetToolPrompts.ts` | Qwen 精灵图工具 prompt 模型 | 主 prompt / sheet prompt / repair prompt / negative prompt 系列 |
|
||||
|
||||
### 3.3 共享层
|
||||
|
||||
@@ -91,7 +90,6 @@
|
||||
- `src/services/questPrompt.ts`
|
||||
- `src/services/runtimeItemAiPrompt.ts`
|
||||
- `server-node/src/services/eightAnchorPromptBuilder.ts`
|
||||
- `src/tools/qwenSpriteSheetToolModel.ts`
|
||||
- `src/components/asset-studio/customWorldRolePromptDefaults.ts`
|
||||
- `packages/shared/src/assets/qwenSprite.ts`
|
||||
|
||||
@@ -110,14 +108,12 @@
|
||||
| --- | --- | --- |
|
||||
| `server-node/src/prompts/characterAssetPrompts.ts` | 正式角色资产生成 prompt | 后端角色主图、动作试片、角色场景词主源 |
|
||||
| `packages/shared/src/prompts/qwenSprite.ts` | 共享角色主 prompt 模板 | 共享给后端资产链使用的基础模板 |
|
||||
| `src/prompts/qwenSpriteSheetToolPrompts.ts` | 工具链 prompt 模型 | Qwen 精灵图工具主词、分镜词、修帧词、负面词 |
|
||||
| `src/prompts/customWorldRolePromptDefaults.ts` | 工作台默认词种子 | 角色视觉词、动画词、场景词默认值 |
|
||||
| `src/prompts/customWorldEntityActionPrompts.ts` | 编辑器动作词 | 技能动作描述 prompt builder |
|
||||
|
||||
当前调用关系:
|
||||
|
||||
- `server-node/src/modules/assets/characterAssetRoutes.ts` 调用 `server-node/src/prompts/characterAssetPrompts.ts`
|
||||
- `src/tools/QwenSpriteSheetTool.tsx` 通过兼容层消费 `src/prompts/qwenSpriteSheetToolPrompts.ts`
|
||||
- `src/components/CustomWorldRoleAssetStudioModal.tsx` 通过兼容层消费 `src/prompts/customWorldRolePromptDefaults.ts`
|
||||
- `src/components/CustomWorldEntityEditorModal.tsx` 直接调用 `src/prompts/customWorldEntityActionPrompts.ts`
|
||||
|
||||
@@ -145,7 +141,7 @@
|
||||
- `src/services/customWorld.ts` 中的自定义世界分阶段 prompt 与场景背景图 prompt
|
||||
- `src/services/ai.ts` 中的世界修复 / 语言修复 / JSON only system prompt
|
||||
- `src/services/prompt.ts`、`characterChatPrompt.ts`、`questPrompt.ts`、`runtimeItemAiPrompt.ts` 这批前端 prompt 脚本
|
||||
- `src/tools/qwenSpriteSheetToolModel.ts`、`src/components/asset-studio/customWorldRolePromptDefaults.ts`、`src/components/CustomWorldEntityEditorModal.tsx` 里的工具 / 编辑器 prompt 散点
|
||||
- `src/components/asset-studio/customWorldRolePromptDefaults.ts`、`src/components/CustomWorldEntityEditorModal.tsx` 里的工具 / 编辑器 prompt 散点
|
||||
|
||||
## 8. 当前仍在非 Prompt 目录中的相关文件
|
||||
|
||||
|
||||
36
docs/technical/CREATION_PAGE_MOBILE_UI_FIX_2026-04-21.md
Normal file
36
docs/technical/CREATION_PAGE_MOBILE_UI_FIX_2026-04-21.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# 创作页移动端 UI 修复记录
|
||||
|
||||
日期:`2026-04-21`
|
||||
|
||||
## 问题定位
|
||||
|
||||
本轮修复只处理创作页表现层,不新增创作流程。
|
||||
|
||||
当前移动端问题主要来自三处:
|
||||
|
||||
1. 平台页在 `platformTab === 'create'` 时直接渲染 `CustomWorldCreationHub`,绕过了 `PlatformHomeView` 的移动端外壳,导致底部 Tab 栏没有挂载。
|
||||
2. 创作中心内部仍混用 `pixel-*` 九宫格样式、`bg-black/*`、`text-white`、`border-white/*` 等暗色 Tailwind 类,亮色主题下会出现深色块和低对比文字。
|
||||
3. 创作中心根节点自带 `h-full overflow-y-auto`,放回平台页后容易与平台页主滚动区抢滚动权,手机上会显得布局混乱。
|
||||
|
||||
## 落地约束
|
||||
|
||||
1. 创作页仍复用现有平台首页,不新增页面和新系统。
|
||||
2. 移动端底部 Tab 必须始终由 `PlatformHomeView` 统一渲染,创作页只作为 `create` Tab 的内容。
|
||||
3. 创作中心内部不再使用深色硬编码作为默认底色,普通卡片、筛选 Tab、空状态和按钮统一使用 `platform-*` token。
|
||||
4. 创作中心不再自建整页滚动,只把内容交给平台页主滚动区,避免嵌套滚动。
|
||||
5. UI 中不增加规则说明类文案,只保留必要入口、状态和作品信息。
|
||||
|
||||
## 编码方案
|
||||
|
||||
1. 在 `PlatformHomeView` 增加可选的 `createTabContent`,让当前 Agent 创作中心接回平台页统一外壳。
|
||||
2. `PreGameSelectionFlow` 不再在 `platformTab === 'create'` 时绕过 `PlatformHomeView`,而是把 `CustomWorldCreationHub` 作为创作 Tab 内容传入。
|
||||
3. `CustomWorldCreationHub` 改为无内部整页滚动的内容容器,标题、返回、计数、错误、加载骨架都使用平台 token。
|
||||
4. `CustomWorldCreationStartCard` 与 `CustomWorldWorkCard` 从像素暗色面板切换为平台卡片样式,保留游戏化主视觉但跟随亮暗主题。
|
||||
5. `CustomWorldWorkTabs` 改用 `platform-tab`,并保持横向滚动与清晰选中态。
|
||||
|
||||
## 验收要点
|
||||
|
||||
1. 手机宽度下进入“创作”后,底部“首页 / 创作 / 存档 / 我的”Tab 始终可见。
|
||||
2. 亮色主题下创作页默认卡片不出现大面积黑色底板。
|
||||
3. 创作页只有平台页主内容区滚动,底部 Tab 不随作品列表滚走。
|
||||
4. 桌面端仍可通过左侧平台导航进入创作页。
|
||||
@@ -6,12 +6,13 @@
|
||||
|
||||
当前这条 Agent 创作流已经完成阶段一到阶段三的主要收口。
|
||||
|
||||
阶段四中的“文档清理”已经开始做,但还没有形成独立、完整的新主链。
|
||||
阶段四中的“文档清理”已经开始做,但还没有形成独立、完整的新主链审计闭环。
|
||||
|
||||
因此这轮可以执行的清理只有一类:
|
||||
因此这轮可以执行的清理现在有两类:
|
||||
|
||||
1. 删除已经不再从当前主入口可达的旧 `custom-world/sessions` 世界生成链
|
||||
2. 保留仍在服务 `Agent session` 主链或已保存作品兼容编辑体验的底层能力
|
||||
2. 删除已经完全脱离 `CustomWorldAgentWorkspace` 主链、只剩孤立互相引用与自测覆盖的 `custom-world-agent` 旧面板
|
||||
3. 保留仍在服务 `Agent session` 主链或已保存作品兼容编辑体验的底层能力
|
||||
|
||||
这轮不做:
|
||||
|
||||
@@ -60,7 +61,8 @@
|
||||
原因:
|
||||
|
||||
1. 文档清理已经开始,但还没有完整收束到单一结论文档
|
||||
2. 旧 `custom-world/sessions` 生成链虽然已经不在主入口上,但还未清干净
|
||||
2. 旧 `custom-world/sessions` 生成链已经完成物理清理,但与之相关的审计/PRD/知识图谱文档仍需继续统一口径
|
||||
3. `custom-world-agent` 孤岛面板已经完成第二轮物理清理,但阶段四文档总收口仍未完全覆盖所有历史 PRD 口径
|
||||
|
||||
---
|
||||
|
||||
@@ -72,12 +74,29 @@
|
||||
2. `server-node/src/routes/runtimeRoutes.ts` 里的旧 `custom-world/sessions` 路由
|
||||
3. `server-node/src/services/customWorldGenerationService.ts`
|
||||
4. 与这条旧链对应的测试
|
||||
5. `server-node/src/services/customWorldSessionStore.ts`
|
||||
6. `src/components/custom-world-agent/CustomWorldAgentLockBar.tsx`
|
||||
7. `src/components/custom-world-agent/CustomWorldAgentQuickActions.tsx`
|
||||
8. `src/components/custom-world-agent/CustomWorldAgentSummaryPanel.tsx`
|
||||
9. `src/components/custom-world-agent/CustomWorldAgentIntentSummaryPanel.tsx`
|
||||
10. `src/components/custom-world-agent/CustomWorldAgentClarificationPanel.tsx`
|
||||
11. `src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx`
|
||||
12. `src/components/custom-world-agent/CustomWorldDraftCardDetailModal.tsx`
|
||||
13. `src/components/custom-world-agent/CustomWorldDraftEditPanel.tsx`
|
||||
14. `src/components/custom-world-agent/CustomWorldGenerateEntityModal.tsx`
|
||||
15. 仅为上述孤岛面板存在的对应测试文件
|
||||
|
||||
不允许删除:
|
||||
|
||||
1. `server-node/src/services/customWorldSessionStore.ts`
|
||||
2. `server-node/src/repositories/runtimeRepository.ts` 中被 Agent session 复用的 session 持久化能力
|
||||
3. `src/services/aiService.ts` 里仍在使用的 `generateCustomWorldProfile` 及其现代封装
|
||||
1. `server-node/src/repositories/runtimeRepository.ts` 中被 Agent session 复用的 session 持久化能力
|
||||
2. `src/services/aiService.ts` 里仍在使用的 `generateCustomWorldProfile` 及其现代封装
|
||||
3. 已保存作品结果页仍在使用的 legacy 编辑器兼容能力
|
||||
4. `src/components/custom-world-agent/CustomWorldAgentWorkspace.tsx` 及其仍在主链上的 5 个子模块:
|
||||
- `CustomWorldAgentHeader`
|
||||
- `EightAnchorProgressBar`
|
||||
- `CustomWorldAgentOperationBanner`
|
||||
- `CustomWorldAgentThread`
|
||||
- `CustomWorldAgentComposer`
|
||||
|
||||
---
|
||||
|
||||
@@ -87,6 +106,8 @@
|
||||
|
||||
1. `src/services/aiService.ts` 不再暴露旧 `custom-world/sessions` 请求函数
|
||||
2. `server-node/src/routes/runtimeRoutes.ts` 不再挂旧 session 路由
|
||||
3. 仓库里不再有主流程可达的旧世界生成入口
|
||||
4. Agent 主链与已保存作品编辑链仍然可用
|
||||
|
||||
3. `server-node/src/services/customWorldSessionStore.ts` 与 `server-node/src/services/customWorldGenerationService.ts` 已物理删除
|
||||
4. 仓库里不再有主流程可达的旧世界生成入口
|
||||
5. `CustomWorldAgentWorkspace.tsx` 只保留当前正式主链需要的 5 个子模块
|
||||
6. 与旧 Agent 草稿面板相关的孤岛 UI 与自测不再继续占据正式目录注意力
|
||||
7. Agent 主链与已保存作品编辑链仍然可用
|
||||
|
||||
@@ -26,10 +26,6 @@
|
||||
- `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`
|
||||
|
||||
---
|
||||
|
||||
@@ -50,7 +46,6 @@
|
||||
- 状态行为覆盖保存
|
||||
- 角色主形象生成、发布与任务查询
|
||||
- 角色动作生成、导入、发布、模板读取与任务查询
|
||||
- Qwen 精灵主图、精灵表、修帧与资产保存
|
||||
|
||||
---
|
||||
|
||||
|
||||
85
docs/technical/EDITOR_ENTRY_CLEANUP_2026-04-21.md
Normal file
85
docs/technical/EDITOR_ENTRY_CLEANUP_2026-04-21.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# 主流程外编辑器入口清理说明(2026-04-21)
|
||||
|
||||
日期:`2026-04-21`
|
||||
|
||||
## 1. 文档目标
|
||||
|
||||
记录本轮对“挂在主流程路由外的旧编辑器入口”和“仍把这些入口当现役能力的残留说明”做的收口,避免后续开发再次把历史入口误判成正式能力。
|
||||
|
||||
---
|
||||
|
||||
## 2. 本轮清理结论
|
||||
|
||||
本轮确认后,当前前端正式入口只保留游戏主流程:
|
||||
|
||||
- `src/routing/appRoutes.tsx` 仅保留 `game`
|
||||
|
||||
本轮删除或收口的对象:
|
||||
|
||||
- 独立前端工具路由 `qwen-sprite-tool`
|
||||
- 仅服务该独立入口的前端页面 `src/tools/QwenSpriteSheetTool.tsx`
|
||||
- 仅服务该独立入口的工具模型与持久化封装
|
||||
- 仅服务该独立入口的后端路由 `server-node/src/modules/assets/qwenSpriteRoutes.ts`
|
||||
- 路由测试里把旧编辑器 / 独立工具入口当作现役分支的断言
|
||||
- README、经验文档、类型检查配置中已经失效的旧编辑器文件引用
|
||||
|
||||
---
|
||||
|
||||
## 3. 为什么可以删除
|
||||
|
||||
本轮删除对象满足下面几个条件:
|
||||
|
||||
1. 不在当前玩家主流程中可达
|
||||
2. 没有继续嵌入正式创作主链
|
||||
3. 当前仓库已有主流程内嵌的替代能力
|
||||
4. 保留它们只会继续制造“看起来还能进、实际上已经不走”的假入口
|
||||
|
||||
其中需要特别区分的是:
|
||||
|
||||
- `src/editor/shared/editorApiClient.ts`
|
||||
- `server-node/src/modules/editor/editorRoutes.ts`
|
||||
- `src/components/CustomWorldEntityEditorModal.tsx`
|
||||
- `src/components/CustomWorldNpcVisualEditor.tsx`
|
||||
- `src/components/CustomWorldRoleAssetStudioModal.tsx`
|
||||
|
||||
这些仍然服务当前主流程内嵌编辑能力,因此本轮不删除。
|
||||
|
||||
---
|
||||
|
||||
## 4. 当前保留的编辑能力边界
|
||||
|
||||
当前保留的是“嵌入主流程的编辑能力”,不是“独立编辑器站点”:
|
||||
|
||||
- 自定义世界实体编辑
|
||||
- 自定义世界角色形象编辑
|
||||
- 主流程内的角色资产工坊模态
|
||||
- 与这些能力配套的 `/api/editor/*` 与 `/api/assets/character-*` 接口
|
||||
|
||||
后续如果还要新增编辑能力,应优先:
|
||||
|
||||
1. 先确认是否真的需要独立入口
|
||||
2. 默认优先接回主流程模态或正式创作链
|
||||
3. 如果只是内部工具,不要长期挂在正式前端路由里
|
||||
|
||||
---
|
||||
|
||||
## 5. 本轮同步更新
|
||||
|
||||
本轮已同步更新:
|
||||
|
||||
- `README.md`
|
||||
- `docs/experience/CODEX_PAST_WORK_EXPERIENCE_SUMMARY.md`
|
||||
- `src/routing/appRoutes.tsx`
|
||||
- `src/routing/appRoutes.test.ts`
|
||||
- `server-node/src/app.ts`
|
||||
- `tsconfig.typecheck-guardrails.json`
|
||||
|
||||
---
|
||||
|
||||
## 6. 后续建议
|
||||
|
||||
后续继续清理时,优先沿着这条规则推进:
|
||||
|
||||
1. 先识别是否还在主流程可达
|
||||
2. 再判断是否仍有正式嵌入点
|
||||
3. 若只剩文档、测试、兼容判断或独立路由壳,直接成批收口
|
||||
@@ -150,10 +150,11 @@ JWT 现状:
|
||||
- `POST /api/custom-world/scene-image`
|
||||
- `POST /api/runtime/story/initial`
|
||||
- `POST /api/runtime/story/continue`
|
||||
- `POST /api/runtime/custom-world/sessions`
|
||||
- `GET /api/runtime/custom-world/sessions/:sessionId`
|
||||
- `POST /api/runtime/custom-world/sessions/:sessionId/answers`
|
||||
- `GET /api/runtime/custom-world/sessions/:sessionId/generate/stream`
|
||||
- `POST /api/runtime/custom-world/agent/sessions`
|
||||
- `GET /api/runtime/custom-world/agent/sessions/:sessionId`
|
||||
- `POST /api/runtime/custom-world/agent/sessions/:sessionId/messages`
|
||||
- `POST /api/runtime/custom-world/agent/sessions/:sessionId/actions`
|
||||
- `GET /api/runtime/custom-world/works`
|
||||
- `POST /api/runtime/chat/character/suggestions`
|
||||
- `POST /api/runtime/chat/character/summary`
|
||||
- `POST /api/runtime/chat/character/reply/stream`
|
||||
@@ -183,10 +184,6 @@ JWT 现状:
|
||||
- `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`
|
||||
|
||||
编辑器与资产接口门禁:
|
||||
|
||||
@@ -227,9 +224,7 @@ Custom World:
|
||||
编辑器与资产工具层:
|
||||
|
||||
- `src/editor/shared/editorApiClient.ts`
|
||||
- `src/editor/shared/useJsonSave.ts`
|
||||
- `src/components/preset-editor/characterAssetStudioPersistence.ts`
|
||||
- `src/tools/qwenSpriteSheetToolPersistence.ts`
|
||||
|
||||
## 10. 当前 Vite 角色
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
- `server-node/src/services/eightAnchorPromptBuilder.ts`
|
||||
- `server-node/src/modules/assets/characterAssetRoutes.ts`
|
||||
- `src/services/**`
|
||||
- `src/tools/qwenSpriteSheetToolModel.ts`
|
||||
- `src/components/**`
|
||||
|
||||
问题主要有三类:
|
||||
@@ -50,7 +49,6 @@ src/prompts/
|
||||
├─ customWorldPrompts.ts
|
||||
├─ customWorldRolePromptDefaults.ts
|
||||
├─ questPrompts.ts
|
||||
├─ qwenSpriteSheetToolPrompts.ts
|
||||
├─ runtimeItemPrompts.ts
|
||||
├─ storyOrchestratorPrompts.ts
|
||||
└─ storyPromptBuilders.ts
|
||||
@@ -82,8 +80,6 @@ src/prompts/
|
||||
- 八锚点状态推断、模式规则与正式单轮共创 prompt
|
||||
- `src/prompts/customWorldPrompts.ts`
|
||||
- 自定义世界分阶段生成 prompt 与场景背景图 prompt
|
||||
- `src/prompts/qwenSpriteSheetToolPrompts.ts`
|
||||
- 精灵图工具主词 / 分镜词 / 修帧词 / 负面词
|
||||
- `src/prompts/customWorldRolePromptDefaults.ts`
|
||||
- 角色资产工作台默认 prompt 种子唯一主源
|
||||
- `src/prompts/customWorldEntityActionPrompts.ts`
|
||||
@@ -127,7 +123,6 @@ src/prompts/
|
||||
- `src/services/characterChatPrompt.ts`
|
||||
- `src/services/questPrompt.ts`
|
||||
- `src/services/runtimeItemAiPrompt.ts`
|
||||
- `src/tools/qwenSpriteSheetToolModel.ts`
|
||||
- `src/components/asset-studio/customWorldRolePromptDefaults.ts`
|
||||
- `packages/shared/src/assets/qwenSprite.ts`
|
||||
|
||||
|
||||
@@ -676,7 +676,7 @@ PixelMotion 很关键的一点,不是要求 16 帧都完美,而是允许修
|
||||
### 13.4 推荐目录结构
|
||||
|
||||
```text
|
||||
pixelmotion-qwen/
|
||||
pixelmotion-workflow/
|
||||
refs/
|
||||
master.png
|
||||
pose_board_run.png
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
- [AGENT_RESULT_PROFILE_SYNC_PHASE2_2026-04-20.md](./AGENT_RESULT_PROFILE_SYNC_PHASE2_2026-04-20.md):阶段二把平台创作入口统一到聚合作品列表,并收紧 Agent 结果页的新增入口职责边界。
|
||||
- [AGENT_RESULT_PROFILE_SYNC_PHASE3_2026-04-20.md](./AGENT_RESULT_PROFILE_SYNC_PHASE3_2026-04-20.md):阶段三继续降级旧 pipeline,让创作中心只认 Agent 草稿与已发布作品,并把 Agent 结果页冻结为预览收口层。
|
||||
- [CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md](./CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md):对照当前优化计划核查四阶段完成度,并明确这轮只允许物理删除旧 `custom-world/sessions` 世界生成链,不误伤 Agent 主链与已保存作品兼容编辑链。
|
||||
- [CREATION_PAGE_MOBILE_UI_FIX_2026-04-21.md](./CREATION_PAGE_MOBILE_UI_FIX_2026-04-21.md):创作页移动端底部 Tab、亮色主题 token 与滚动权责修复记录。
|
||||
- [TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md](./TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md):把外部仓库 TXT 模式完整迁入当前项目的冻结边界、模块映射、分阶段计划与验收清单。
|
||||
- [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 冻结版本、热点文件编辑规则与集成窗口清单。
|
||||
|
||||
@@ -32,7 +32,6 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"check": "npm run lint && npm run test && npm run build && npm run check:content",
|
||||
"generate:build-tags": "py -3 scripts/generate-build-tag-similarity.py",
|
||||
"check:data": "node scripts/run-tsx.cjs scripts/validate-content.ts",
|
||||
"check:overrides": "node scripts/run-tsx.cjs scripts/validate-overrides.ts",
|
||||
"check:smoke": "node scripts/run-tsx.cjs scripts/smoke-content.ts",
|
||||
|
||||
@@ -1,357 +0,0 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
from vikingdb import VikingDB, IAM, EmbeddingClient
|
||||
from vikingdb.vector import EmbeddingData, EmbeddingModelOpt, EmbeddingRequest
|
||||
except ImportError as exc: # pragma: no cover
|
||||
raise SystemExit(
|
||||
"Missing dependency: vikingdb-python-sdk.\n"
|
||||
"Install it with: py -3 -m pip install vikingdb-python-sdk"
|
||||
) from exc
|
||||
|
||||
|
||||
def zh(value: str) -> str:
|
||||
return value.encode("utf-8").decode("unicode_escape")
|
||||
|
||||
|
||||
BUILD_TAGS = [
|
||||
{
|
||||
"label": zh(r"\u5feb\u5251"),
|
||||
"aliases": ["duelist", "swift blade", "swiftblade", zh(r"\u5251\u5feb"), zh(r"\u5feb\u5203")],
|
||||
"description": zh(r"\u4ee5\u9ad8\u901f\u8f7b\u5175\u5668\u3001\u8fde\u7eed\u51fa\u624b\u548c\u8d34\u8eab\u538b\u8feb\u4e3a\u6838\u5fc3\u7684\u8fd1\u6218\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u8fde\u6bb5"),
|
||||
"aliases": ["combo", "chain", zh(r"\u8fde\u51fb")],
|
||||
"description": zh(r"\u4f9d\u8d56\u8fde\u7eed\u547d\u4e2d\u4e0e\u591a\u6bb5\u8282\u594f\u538b\u5236\u7684\u8f93\u51fa\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u7a81\u8fdb"),
|
||||
"aliases": ["dash", "lunge", "mobility engage"],
|
||||
"description": zh(r"\u5f3a\u8c03\u5feb\u901f\u8d34\u8fd1\u76ee\u6807\u3001\u62a2\u5360\u8eab\u4f4d\u548c\u5148\u624b\u5207\u5165\u7684\u6218\u6597\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u8ffd\u51fb"),
|
||||
"aliases": ["chase", "follow-up", "finisher chase"],
|
||||
"description": zh(r"\u64c5\u957f\u5728\u5bf9\u624b\u5931\u8861\u6216\u88ab\u51fb\u9000\u540e\u7ee7\u7eed\u8ffd\u6253\u7684\u6218\u6597\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u5feb\u88ad"),
|
||||
"aliases": ["assassin", "rogue", "ambush", zh(r"\u523a\u51fb")],
|
||||
"description": zh(r"\u5f3a\u8c03\u77ed\u65f6\u5207\u5165\u3001\u70b9\u6740\u5f31\u70b9\u548c\u8fc5\u901f\u8131\u79bb\u7684\u523a\u51fb\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u8fdc\u5c04"),
|
||||
"aliases": ["projectile", "ranged", "arrow", zh(r"\u5c04\u51fb")],
|
||||
"description": zh(r"\u4ee5\u6295\u5c04\u7269\u3001\u4e2d\u8fdc\u8ddd\u79bb\u7275\u5236\u548c\u5b89\u5168\u8f93\u51fa\u4e3a\u6838\u5fc3\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u6e38\u51fb"),
|
||||
"aliases": ["scout", "skirmish", "harass", "fieldcraft"],
|
||||
"description": zh(r"\u5f3a\u8c03\u8fb9\u79fb\u52a8\u8fb9\u8f93\u51fa\u3001\u8bd5\u63a2\u62c9\u626f\u548c\u62e9\u673a\u518d\u5165\u573a\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u673a\u52a8"),
|
||||
"aliases": ["mobility", "nimble", "agile"],
|
||||
"description": zh(r"\u4ee3\u8868\u9ad8\u4f4d\u79fb\u3001\u9ad8\u8eab\u6cd5\u548c\u5feb\u901f\u6362\u4f4d\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u98ce\u884c"),
|
||||
"aliases": ["wind", "gust", "speed", zh(r"\u75be\u884c")],
|
||||
"description": zh(r"\u5f3a\u8c03\u8f7b\u7075\u6b65\u6cd5\u3001\u79fb\u901f\u4f18\u52bf\u548c\u8fc5\u901f\u8c03\u4f4d\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u91cd\u51fb"),
|
||||
"aliases": ["heavy", "slam", "mighty", "crush"],
|
||||
"description": zh(r"\u5f3a\u8c03\u539a\u91cd\u6253\u51fb\u3001\u5355\u6b21\u9ad8\u538b\u8f93\u51fa\u548c\u6b63\u9762\u7838\u7a7f\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u7206\u53d1"),
|
||||
"aliases": ["burst", "nova", "sudden damage"],
|
||||
"description": zh(r"\u4ee3\u8868\u77ed\u7a97\u53e3\u5185\u8fc5\u901f\u62ac\u9ad8\u4f24\u5bb3\u5cf0\u503c\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u7834\u7532"),
|
||||
"aliases": ["breaker", "armor break", "shatter"],
|
||||
"description": zh(r"\u64c5\u957f\u6495\u5f00\u9632\u5fa1\u3001\u6253\u65ad\u5b88\u52bf\u548c\u9488\u5bf9\u786c\u76ee\u6807\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u538b\u5236"),
|
||||
"aliases": ["tempo", "pressure", "control offense"],
|
||||
"description": zh(r"\u901a\u8fc7\u6301\u7eed\u4e3b\u52a8\u8fdb\u653b\u4e0e\u8282\u594f\u5360\u4f18\u8feb\u4f7f\u5bf9\u624b\u5931\u8bef\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u538b\u8840"),
|
||||
"aliases": ["low hp", "berserk", "risk damage"],
|
||||
"description": zh(r"\u4ee5\u5192\u9669\u538b\u4f4e\u8840\u7ebf\u6362\u53d6\u66f4\u5f3a\u653b\u51fb\u6027\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u5b88\u5fa1"),
|
||||
"aliases": ["ward", "guard", "protector", "defense"],
|
||||
"description": zh(r"\u5f3a\u8c03\u51cf\u4f24\u3001\u7a33\u5b88\u548c\u9876\u4f4f\u6b63\u9762\u4f24\u5bb3\u7684\u9632\u5fa1\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u62a4\u4f53"),
|
||||
"aliases": ["barrier", "shielding", "spirit guard", "spirit"],
|
||||
"description": zh(r"\u504f\u5411\u62a4\u7f69\u3001\u62a4\u8eab\u6c14\u52b2\u548c\u72b6\u6001\u6297\u538b\u7684\u9632\u5fa1\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u91cd\u7532"),
|
||||
"aliases": ["tank", "heavy armor", "iron wall"],
|
||||
"description": zh(r"\u4ee3\u8868\u9ad8\u786c\u5ea6\u62a4\u7532\u3001\u6b63\u9762\u627f\u4f24\u4e0e\u7a33\u5b9a\u7ad9\u573a\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u53cd\u51fb"),
|
||||
"aliases": ["counter", "riposte", "retaliate"],
|
||||
"description": zh(r"\u901a\u8fc7\u683c\u6321\u3001\u7ad9\u6869\u4e0e\u540e\u624b\u60e9\u7f5a\u5f62\u6210\u6536\u76ca\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u9547\u90aa"),
|
||||
"aliases": ["banish", "holy ward", "warding seal"],
|
||||
"description": zh(r"\u64c5\u957f\u538b\u5236\u90aa\u795f\u3001\u5492\u715e\u548c\u5f02\u7c7b\u80fd\u91cf\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u6cd5\u4fee"),
|
||||
"aliases": ["caster", "mage", "arcane", "spell"],
|
||||
"description": zh(r"\u4ee5\u6cd5\u672f\u9a71\u52a8\u8f93\u51fa\u3001\u63a7\u5236\u548c\u8d44\u6e90\u8fd0\u8f6c\u7684\u6838\u5fc3\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u6cd5\u529b"),
|
||||
"aliases": ["mana", "magic", "essence", "spirit power"],
|
||||
"description": zh(r"\u56f4\u7ed5\u6cd5\u529b\u4e0a\u9650\u3001\u6cd5\u672f\u6d88\u8017\u4e0e\u6cd5\u80fd\u5faa\u73af\u6784\u7b51\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u96f7\u6cd5"),
|
||||
"aliases": ["lightning", "thunder", "storm"],
|
||||
"description": zh(r"\u4ee3\u8868\u9ad8\u538b\u96f7\u7cfb\u672f\u6cd5\u3001\u77ac\u65f6\u9707\u8361\u548c\u9ebb\u75f9\u538b\u5236\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u7b26\u9635"),
|
||||
"aliases": ["sigil", "formation", "seal", "rune"],
|
||||
"description": zh(r"\u901a\u8fc7\u7b26\u7bb4\u3001\u6cd5\u9635\u548c\u9884\u5e03\u7f6e\u6548\u679c\u6539\u53d8\u6218\u573a\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u63a7\u573a"),
|
||||
"aliases": ["control", "crowd control", "lockdown"],
|
||||
"description": zh(r"\u4ee5\u9650\u5236\u884c\u52a8\u3001\u5c01\u9501\u7a7a\u95f4\u548c\u538b\u7f29\u9009\u62e9\u4e3a\u6838\u5fc3\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u8fc7\u8f7d"),
|
||||
"aliases": ["overload", "surge", "power spike"],
|
||||
"description": zh(r"\u5728\u77ed\u65f6\u95f4\u5185\u63a8\u52a8\u9ad8\u6cd5\u8017\u4e0e\u9ad8\u5f3a\u5ea6\u91ca\u653e\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u56de\u590d"),
|
||||
"aliases": ["heal", "healing", "recovery", "restore"],
|
||||
"description": zh(r"\u5f3a\u8c03\u5373\u65f6\u6062\u590d\u4e0e\u6218\u540e\u7eed\u63a5\u80fd\u529b\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u62a4\u6301"),
|
||||
"aliases": ["support", "aid", "blessing"],
|
||||
"description": zh(r"\u901a\u8fc7\u589e\u76ca\u3001\u62ac\u7a33\u6001\u548c\u4fdd\u62a4\u961f\u53cb\u6765\u5efa\u7acb\u4f18\u52bf\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u7eed\u6218"),
|
||||
"aliases": ["sustain", "endurance", "long fight"],
|
||||
"description": zh(r"\u9762\u5411\u957f\u7ebf\u6218\u6597\u3001\u8d44\u6e90\u6301\u7eed\u4e0e\u5bb9\u9519\u63d0\u5347\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u547d\u7eb9"),
|
||||
"aliases": ["fate", "omen", "destiny"],
|
||||
"description": zh(r"\u56f4\u7ed5\u547d\u8fd0\u3001\u5370\u8bb0\u4e0e\u89e6\u53d1\u5f0f\u8fde\u9501\u6536\u76ca\u6784\u7b51\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u673a\u7f18"),
|
||||
"aliases": ["fortune", "luck", "opportunity"],
|
||||
"description": zh(r"\u4f9d\u8d56\u65f6\u673a\u3001\u8fd0\u52bf\u548c\u989d\u5916\u6536\u76ca\u89e6\u53d1\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u51b7\u5374"),
|
||||
"aliases": ["cooldown", "cdr", "recharge"],
|
||||
"description": zh(r"\u901a\u8fc7\u66f4\u5feb\u5468\u8f6c\u6280\u80fd\u4e0e\u9053\u5177\u6765\u6eda\u52a8\u4f18\u52bf\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u7edf\u5fa1"),
|
||||
"aliases": ["commander", "command", "leader"],
|
||||
"description": zh(r"\u5f3a\u8c03\u6574\u4f53\u534f\u8c03\u3001\u56e2\u961f\u6536\u76ca\u548c\u7efc\u5408\u8c03\u5ea6\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u5747\u8861"),
|
||||
"aliases": ["balanced", "adaptable", "all-round"],
|
||||
"description": zh(r"\u6ca1\u6709\u660e\u663e\u77ed\u677f\uff0c\u504f\u91cd\u4e2d\u540e\u671f\u7a33\u5b9a\u6210\u578b\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u5de5\u5de7"),
|
||||
"aliases": ["craft", "artisan", "utility", "socket"],
|
||||
"description": zh(r"\u504f\u5411\u5de5\u827a\u3001\u5668\u68b0\u3001\u9576\u5d4c\u548c\u8f85\u52a9\u6784\u7b51\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u70bc\u836f"),
|
||||
"aliases": ["alchemy", "potion", "tonic"],
|
||||
"description": zh(r"\u56f4\u7ed5\u836f\u5242\u3001\u4e34\u65f6\u5f3a\u5316\u548c\u6218\u4e2d\u8865\u7ed9\u7684\u5de5\u827a\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u5148\u950b"),
|
||||
"aliases": ["vanguard", "frontline"],
|
||||
"description": zh(r"\u4ee3\u8868\u961f\u4f0d\u4e2d\u7684\u6b63\u9762\u5f00\u8def\u3001\u5403\u7ebf\u4e0e\u538b\u524d\u6392\u804c\u8d23\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u72c2\u6218"),
|
||||
"aliases": ["berserker", "rage"],
|
||||
"description": zh(r"\u4ee5\u8840\u91cf\u4ea4\u6362\u3001\u731b\u653b\u548c\u9ad8\u98ce\u9669\u9ad8\u56de\u62a5\u4e3a\u7279\u8272\u7684\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u6cd5\u5251"),
|
||||
"aliases": ["spellblade", "bladecaster"],
|
||||
"description": zh(r"\u878d\u5408\u5175\u5203\u4e0e\u672f\u6cd5\uff0c\u64c5\u957f\u4e2d\u8ddd\u79bb\u538b\u8feb\u7684\u6df7\u5408\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u5723\u4f51"),
|
||||
"aliases": ["paladin", "holy guard"],
|
||||
"description": zh(r"\u517c\u5177\u9632\u62a4\u3001\u56de\u590d\u548c\u60e9\u6212\u80fd\u529b\u7684\u795d\u798f\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u5821\u5792"),
|
||||
"aliases": ["fortress", "bulwark"],
|
||||
"description": zh(r"\u4ee5\u7a33\u5b9a\u7ad9\u573a\u3001\u786c\u6297\u4e0e\u53cd\u6253\u4e3a\u6838\u5fc3\u7684\u91cd\u9632\u5fa1\u6807\u7b7e\u3002"),
|
||||
},
|
||||
{
|
||||
"label": zh(r"\u8d77\u624b"),
|
||||
"aliases": ["starter", "legacy"],
|
||||
"description": zh(r"\u504f\u8fc7\u6e21\u4e0e\u8d77\u6b65\u7528\u9014\u7684\u65e9\u671f\u6784\u7b51\u6807\u7b7e\u3002"),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def build_prompt(definition: dict) -> str:
|
||||
aliases = "\u3001".join(definition["aliases"])
|
||||
return f"{definition['label']}:{definition['description']} 别名:{aliases}。"
|
||||
|
||||
|
||||
def load_env_file(path: Path, protected_keys: set[str]) -> None:
|
||||
if not path.exists():
|
||||
return
|
||||
|
||||
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
|
||||
key, value = line.split("=", 1)
|
||||
key = key.strip()
|
||||
if not key or key in protected_keys:
|
||||
continue
|
||||
|
||||
value = value.strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
||||
value = value[1:-1]
|
||||
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
def load_local_env() -> None:
|
||||
root_dir = Path(__file__).resolve().parents[1]
|
||||
protected_keys = set(os.environ)
|
||||
|
||||
load_env_file(root_dir / ".env", protected_keys)
|
||||
load_env_file(root_dir / ".env.local", protected_keys)
|
||||
|
||||
|
||||
def create_embedding_client() -> EmbeddingClient:
|
||||
access_key = os.getenv("VOLCENGINE_ACCESS_KEY_ID") or os.getenv("VIKINGDB_ACCESS_KEY_ID")
|
||||
secret_key = os.getenv("VOLCENGINE_SECRET_ACCESS_KEY") or os.getenv("VIKINGDB_SECRET_ACCESS_KEY")
|
||||
host = os.getenv("VIKINGDB_HOST", "api-vikingdb.vikingdb.cn-beijing.volces.com")
|
||||
region = os.getenv("VIKINGDB_REGION", "cn-beijing")
|
||||
|
||||
if not access_key or not secret_key:
|
||||
raise SystemExit(
|
||||
"Missing VikingDB credentials.\n"
|
||||
"Required:\n"
|
||||
" VOLCENGINE_ACCESS_KEY_ID\n"
|
||||
" VOLCENGINE_SECRET_ACCESS_KEY\n"
|
||||
"Optional:\n"
|
||||
" VIKINGDB_HOST (default: api-vikingdb.vikingdb.cn-beijing.volces.com)\n"
|
||||
" VIKINGDB_REGION (default: cn-beijing)\n"
|
||||
)
|
||||
|
||||
service = VikingDB(
|
||||
host=host,
|
||||
region=region,
|
||||
auth=IAM(ak=access_key, sk=secret_key),
|
||||
)
|
||||
return EmbeddingClient(service)
|
||||
|
||||
|
||||
def encode_texts(client: EmbeddingClient, texts: list[str]) -> np.ndarray:
|
||||
request = EmbeddingRequest(
|
||||
data=[EmbeddingData(text=text) for text in texts],
|
||||
dense_model=EmbeddingModelOpt(name="bge-large-zh"),
|
||||
)
|
||||
response = client.embedding(request)
|
||||
result = getattr(response, "result", None)
|
||||
data = getattr(result, "data", None) if result is not None else None
|
||||
if data is None and isinstance(result, dict):
|
||||
data = result.get("data")
|
||||
if data is None:
|
||||
data = getattr(response, "data", None)
|
||||
|
||||
if data is None:
|
||||
raise ValueError("Embedding response did not include any data entries.")
|
||||
|
||||
embeddings: list[list[float]] = []
|
||||
for item in data:
|
||||
dense = getattr(item, "dense", None)
|
||||
if dense is None and isinstance(item, dict):
|
||||
dense = item.get("dense")
|
||||
if dense is None:
|
||||
dense = getattr(item, "embedding", None)
|
||||
if dense is None and isinstance(item, dict):
|
||||
dense = item.get("embedding")
|
||||
if dense is None:
|
||||
raise ValueError("Embedding response item did not include a dense vector.")
|
||||
embeddings.append(dense)
|
||||
|
||||
matrix = np.array(embeddings, dtype=np.float32)
|
||||
norms = np.linalg.norm(matrix, axis=1, keepdims=True)
|
||||
norms[norms == 0] = 1.0
|
||||
return matrix / norms
|
||||
|
||||
|
||||
def main():
|
||||
load_local_env()
|
||||
client = create_embedding_client()
|
||||
prompts = [build_prompt(definition) for definition in BUILD_TAGS]
|
||||
embeddings = encode_texts(client, prompts)
|
||||
|
||||
threshold = 0.35
|
||||
pairs: list[tuple[str, str, float]] = []
|
||||
for index, left in enumerate(BUILD_TAGS):
|
||||
for other_index in range(index + 1, len(BUILD_TAGS)):
|
||||
right = BUILD_TAGS[other_index]
|
||||
similarity = float(np.dot(embeddings[index], embeddings[other_index]))
|
||||
if similarity < threshold:
|
||||
continue
|
||||
pairs.append((left["label"], right["label"], round(similarity, 4)))
|
||||
|
||||
output_path = Path(__file__).resolve().parents[1] / "src" / "data" / "buildTagSimilarity.generated.ts"
|
||||
lines = [
|
||||
"export const BUILD_TAG_SIMILARITY_PAIRS: Array<readonly [string, string, number]> = ["
|
||||
]
|
||||
for left, right, similarity in pairs:
|
||||
lines.append(f" ['{left}', '{right}', {similarity}],")
|
||||
lines.append("] as const;")
|
||||
output_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
print(json.dumps({
|
||||
"output": str(output_path),
|
||||
"pair_count": len(pairs),
|
||||
"model": "bge-large-zh",
|
||||
}, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -8,7 +8,6 @@ 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';
|
||||
@@ -119,18 +118,6 @@ export function createApp(context: AppContext) {
|
||||
createCharacterAssetRoutes(context.config, context.llmClient),
|
||||
),
|
||||
);
|
||||
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' }),
|
||||
|
||||
@@ -12,7 +12,6 @@ import { UserSessionRepository } from './repositories/userSessionRepository.js';
|
||||
import { CaptchaChallengeStore } from './services/captchaChallengeStore.js';
|
||||
import { CustomWorldAgentOrchestrator } from './services/customWorldAgentOrchestrator.js';
|
||||
import { CustomWorldAgentSessionStore } from './services/customWorldAgentSessionStore.js';
|
||||
import { CustomWorldSessionStore } from './services/customWorldSessionStore.js';
|
||||
import { UpstreamLlmClient } from './services/llmClient.js';
|
||||
import type { SmsVerificationService } from './services/smsVerificationService.js';
|
||||
import type { WechatAuthService } from './services/wechatAuthService.js';
|
||||
@@ -30,7 +29,6 @@ export type AppContext = {
|
||||
userSessionRepository: UserSessionRepository;
|
||||
runtimeRepository: RuntimeRepository;
|
||||
llmClient: UpstreamLlmClient;
|
||||
customWorldSessions: CustomWorldSessionStore;
|
||||
customWorldAgentSessions: CustomWorldAgentSessionStore;
|
||||
customWorldAgentOrchestrator: CustomWorldAgentOrchestrator;
|
||||
smsVerificationService: SmsVerificationService;
|
||||
|
||||
@@ -1,907 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -3,8 +3,6 @@ import { z } from 'zod';
|
||||
|
||||
import type { ListCustomWorldWorksResponse } from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
import type {
|
||||
AnswerCustomWorldSessionQuestionRequest,
|
||||
CreateCustomWorldSessionRequest,
|
||||
CustomWorldGalleryDetailResponse,
|
||||
CustomWorldGalleryResponse,
|
||||
CustomWorldLibraryMutationResponse,
|
||||
@@ -21,7 +19,6 @@ import type {
|
||||
SavedGameSnapshotInput,
|
||||
} from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import {
|
||||
CUSTOM_WORLD_GENERATION_MODES,
|
||||
PLATFORM_THEMES,
|
||||
} from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import type {
|
||||
@@ -41,7 +38,6 @@ import { badRequest, notFound } from '../errors.js';
|
||||
import {
|
||||
asyncHandler,
|
||||
jsonClone,
|
||||
prepareEventStreamResponse,
|
||||
sendApiResponse,
|
||||
} from '../http.js';
|
||||
import { requireJwtAuth } from '../middleware/auth.js';
|
||||
@@ -67,7 +63,6 @@ import {
|
||||
npcRecruitDialogueRequestSchema,
|
||||
} from '../services/chatService.js';
|
||||
import { generateCustomWorldEntity } from '../services/customWorldEntityGenerationService.js';
|
||||
import { generateCustomWorldProfile } from '../services/customWorldGenerationService.js';
|
||||
import { generateSceneNpcForLandmark } from '../services/customWorldSceneNpcGenerationService.js';
|
||||
import { listCustomWorldWorkSummaries } from '../services/customWorldWorkSummaryService.js';
|
||||
import { generateQuestForNpcEncounter } from '../services/questService.js';
|
||||
@@ -133,17 +128,6 @@ const customWorldEntitySchema = z.object({
|
||||
kind: z.enum(['playable', 'story', 'landmark']),
|
||||
});
|
||||
|
||||
const customWorldSessionSchema = z.object({
|
||||
settingText: z.string().trim().min(1),
|
||||
creatorIntent: jsonObjectSchema.nullable().optional().default(null),
|
||||
generationMode: z.enum(CUSTOM_WORLD_GENERATION_MODES).default('fast'),
|
||||
});
|
||||
|
||||
const customWorldAnswerSchema = z.object({
|
||||
questionId: z.string().trim().min(1),
|
||||
answer: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
const runtimeItemIntentSchema = z.object({
|
||||
context: jsonObjectSchema,
|
||||
plans: z.array(jsonObjectSchema),
|
||||
@@ -792,128 +776,6 @@ export function createRuntimeRoutes(context: AppContext) {
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/custom-world/sessions',
|
||||
routeMeta({ operation: 'runtime.customWorldSession.create' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = customWorldSessionSchema.parse(
|
||||
request.body,
|
||||
) as CreateCustomWorldSessionRequest;
|
||||
sendApiResponse(
|
||||
response,
|
||||
await context.customWorldSessions.create(
|
||||
request.userId!,
|
||||
payload.settingText,
|
||||
payload.creatorIntent,
|
||||
payload.generationMode,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/runtime/custom-world/sessions/:sessionId',
|
||||
routeMeta({ operation: 'runtime.customWorldSession.get' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const session = await context.customWorldSessions.get(
|
||||
request.userId!,
|
||||
readParam(request.params.sessionId),
|
||||
);
|
||||
if (!session) {
|
||||
throw notFound('custom world session not found');
|
||||
}
|
||||
sendApiResponse(response, session);
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/custom-world/sessions/:sessionId/answers',
|
||||
routeMeta({ operation: 'runtime.customWorldSession.answer' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = customWorldAnswerSchema.parse(
|
||||
request.body,
|
||||
) as AnswerCustomWorldSessionQuestionRequest;
|
||||
const session = await context.customWorldSessions.answer(
|
||||
request.userId!,
|
||||
readParam(request.params.sessionId),
|
||||
payload.questionId,
|
||||
payload.answer,
|
||||
);
|
||||
if (!session) {
|
||||
throw notFound('custom world session not found');
|
||||
}
|
||||
sendApiResponse(response, session);
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/runtime/custom-world/sessions/:sessionId/generate/stream',
|
||||
routeMeta({ operation: 'runtime.customWorldSession.generateStream' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const session = await context.customWorldSessions.get(
|
||||
request.userId!,
|
||||
readParam(request.params.sessionId),
|
||||
);
|
||||
if (!session) {
|
||||
throw notFound('custom world session not found');
|
||||
}
|
||||
|
||||
prepareEventStreamResponse(request, response);
|
||||
const controller = new AbortController();
|
||||
|
||||
request.on('close', () => {
|
||||
controller.abort();
|
||||
});
|
||||
|
||||
const writeEvent = (event: string, payload: Record<string, unknown>) => {
|
||||
response.write(`event: ${event}\n`);
|
||||
response.write(`data: ${JSON.stringify(payload)}\n\n`);
|
||||
};
|
||||
|
||||
writeEvent('progress', { phase: 'preparing', progress: 10 });
|
||||
await context.customWorldSessions.updateStatus(
|
||||
request.userId!,
|
||||
readParam(request.params.sessionId),
|
||||
'generating',
|
||||
);
|
||||
writeEvent('progress', { phase: 'requesting_llm', progress: 45 });
|
||||
|
||||
try {
|
||||
const profile = await generateCustomWorldProfile(context, session, {
|
||||
signal: controller.signal,
|
||||
onProgress: (progress) => {
|
||||
writeEvent(
|
||||
'progress',
|
||||
progress as unknown as Record<string, unknown>,
|
||||
);
|
||||
},
|
||||
});
|
||||
await context.customWorldSessions.setResult(
|
||||
request.userId!,
|
||||
readParam(request.params.sessionId),
|
||||
profile,
|
||||
);
|
||||
writeEvent('progress', { phase: 'completed', progress: 100 });
|
||||
writeEvent('result', { profile });
|
||||
writeEvent('done', { ok: true });
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'custom world generation failed';
|
||||
await context.customWorldSessions.updateStatus(
|
||||
request.userId!,
|
||||
readParam(request.params.sessionId),
|
||||
'generation_error',
|
||||
message,
|
||||
);
|
||||
writeEvent('error', { message });
|
||||
} finally {
|
||||
response.end();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/items/runtime-intent',
|
||||
routeMeta({ operation: 'runtime.items.intent' }),
|
||||
|
||||
@@ -16,7 +16,6 @@ import { CaptchaChallengeStore } from './services/captchaChallengeStore.js';
|
||||
import { CustomWorldAgentAutoAssetService } from './services/customWorldAgentAutoAssetService.js';
|
||||
import { CustomWorldAgentOrchestrator } from './services/customWorldAgentOrchestrator.js';
|
||||
import { CustomWorldAgentSessionStore } from './services/customWorldAgentSessionStore.js';
|
||||
import { CustomWorldSessionStore } from './services/customWorldSessionStore.js';
|
||||
import { UpstreamLlmClient } from './services/llmClient.js';
|
||||
import { createSmsVerificationService } from './services/smsVerificationService.js';
|
||||
import { createWechatAuthService } from './services/wechatAuthService.js';
|
||||
@@ -113,7 +112,6 @@ export async function createAppContext(config: AppConfig = loadConfig()) {
|
||||
userSessionRepository: new UserSessionRepository(db),
|
||||
runtimeRepository,
|
||||
llmClient: new UpstreamLlmClient(config, logger),
|
||||
customWorldSessions: new CustomWorldSessionStore(runtimeRepository),
|
||||
customWorldAgentSessions,
|
||||
customWorldAgentOrchestrator: new CustomWorldAgentOrchestrator(
|
||||
customWorldAgentSessions,
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { AppContext } from '../context.js';
|
||||
import {
|
||||
type CustomWorldGenerationProgress,
|
||||
generateCustomWorldProfileFromOrchestrator,
|
||||
type GenerateCustomWorldProfileInput,
|
||||
} from '../modules/ai/customWorldOrchestrator.js';
|
||||
import type { CustomWorldSession } from './customWorldSessionStore.js';
|
||||
|
||||
export async function generateCustomWorldProfile(
|
||||
context: AppContext,
|
||||
session: CustomWorldSession,
|
||||
options: {
|
||||
onProgress?: (progress: CustomWorldGenerationProgress) => void;
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
) {
|
||||
const input = {
|
||||
settingText: session.settingText,
|
||||
creatorIntent: session.creatorIntent,
|
||||
generationMode: session.generationMode,
|
||||
} satisfies GenerateCustomWorldProfileInput;
|
||||
|
||||
const profile = await generateCustomWorldProfileFromOrchestrator(
|
||||
context.llmClient,
|
||||
input,
|
||||
{
|
||||
onProgress: options.onProgress,
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
|
||||
return JSON.parse(JSON.stringify(profile)) as Record<string, unknown>;
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import type { JsonObject } from '../../../packages/shared/src/contracts/common.js';
|
||||
import type {
|
||||
CustomWorldGenerationMode,
|
||||
CustomWorldQuestion,
|
||||
CustomWorldSessionRecord,
|
||||
CustomWorldSessionStatus,
|
||||
} from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
|
||||
|
||||
export type CustomWorldSession = {
|
||||
sessionId: string;
|
||||
status: CustomWorldSessionStatus;
|
||||
settingText: string;
|
||||
creatorIntent: JsonObject | null;
|
||||
generationMode: CustomWorldGenerationMode;
|
||||
questions: CustomWorldQuestion[];
|
||||
result?: JsonObject;
|
||||
lastError?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
function cloneSession(session: CustomWorldSession) {
|
||||
return JSON.parse(JSON.stringify(session)) as CustomWorldSession;
|
||||
}
|
||||
|
||||
function toSessionRecord(session: CustomWorldSession): CustomWorldSessionRecord {
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
status: session.status,
|
||||
settingText: session.settingText,
|
||||
creatorIntent: session.creatorIntent,
|
||||
generationMode: session.generationMode,
|
||||
questions: session.questions,
|
||||
result: session.result,
|
||||
lastError: session.lastError,
|
||||
createdAt: session.createdAt,
|
||||
updatedAt: session.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function toSession(record: CustomWorldSessionRecord) {
|
||||
return cloneSession({
|
||||
sessionId: record.sessionId,
|
||||
status: record.status,
|
||||
settingText: record.settingText,
|
||||
creatorIntent: record.creatorIntent ?? null,
|
||||
generationMode: record.generationMode,
|
||||
questions: record.questions,
|
||||
result: record.result,
|
||||
lastError: record.lastError,
|
||||
createdAt: record.createdAt,
|
||||
updatedAt: record.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
function hasPendingQuestion(questions: CustomWorldQuestion[]) {
|
||||
return questions.some((question) => !question.answer?.trim());
|
||||
}
|
||||
|
||||
function buildClarificationQuestions(
|
||||
settingText: string,
|
||||
creatorIntent: JsonObject | null,
|
||||
) {
|
||||
const questions: CustomWorldQuestion[] = [];
|
||||
const worldHook =
|
||||
typeof creatorIntent?.worldHook === 'string' ? creatorIntent.worldHook.trim() : '';
|
||||
const playerPremise =
|
||||
typeof creatorIntent?.playerPremise === 'string' ? creatorIntent.playerPremise.trim() : '';
|
||||
const openingSituation =
|
||||
typeof creatorIntent?.openingSituation === 'string'
|
||||
? creatorIntent.openingSituation.trim()
|
||||
: '';
|
||||
const coreConflicts = Array.isArray(creatorIntent?.coreConflicts)
|
||||
? creatorIntent.coreConflicts
|
||||
: [];
|
||||
|
||||
if (!worldHook && settingText.trim().length < 24) {
|
||||
questions.push({
|
||||
id: 'world_hook',
|
||||
label: '世界核心',
|
||||
question: '请用一句话补充这个世界最核心的命题或独特卖点。',
|
||||
});
|
||||
}
|
||||
if (!playerPremise) {
|
||||
questions.push({
|
||||
id: 'player_premise',
|
||||
label: '玩家身份',
|
||||
question: '玩家在这个世界里是什么身份、立场或来历?',
|
||||
});
|
||||
}
|
||||
if (!openingSituation) {
|
||||
questions.push({
|
||||
id: 'opening_situation',
|
||||
label: '开局处境',
|
||||
question: '故事开局时,玩家正处于什么局面?',
|
||||
});
|
||||
}
|
||||
if (coreConflicts.length === 0) {
|
||||
questions.push({
|
||||
id: 'core_conflict',
|
||||
label: '核心冲突',
|
||||
question: '这个世界当前最核心的冲突、危机或悬念是什么?',
|
||||
});
|
||||
}
|
||||
|
||||
return questions;
|
||||
}
|
||||
|
||||
export class CustomWorldSessionStore {
|
||||
constructor(
|
||||
private readonly runtimeRepository: RuntimeRepositoryPort,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
userId: string,
|
||||
settingText: string,
|
||||
creatorIntent: JsonObject | null,
|
||||
generationMode: CustomWorldGenerationMode,
|
||||
) {
|
||||
const sessionId = `custom-world-session-${crypto.randomBytes(16).toString('hex')}`;
|
||||
const now = new Date().toISOString();
|
||||
const session: CustomWorldSession = {
|
||||
sessionId,
|
||||
status: 'ready_to_generate',
|
||||
settingText,
|
||||
creatorIntent,
|
||||
generationMode,
|
||||
questions: buildClarificationQuestions(settingText, creatorIntent),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
if (hasPendingQuestion(session.questions)) {
|
||||
session.status = 'clarifying';
|
||||
}
|
||||
|
||||
await this.runtimeRepository.upsertCustomWorldSession(
|
||||
userId,
|
||||
sessionId,
|
||||
toSessionRecord(session),
|
||||
);
|
||||
return cloneSession(session);
|
||||
}
|
||||
|
||||
async list(userId: string) {
|
||||
const sessions = await this.runtimeRepository.listCustomWorldSessions(userId);
|
||||
return sessions.map((session) => toSession(session));
|
||||
}
|
||||
|
||||
async get(userId: string, sessionId: string) {
|
||||
const session = await this.runtimeRepository.getCustomWorldSession(
|
||||
userId,
|
||||
sessionId,
|
||||
);
|
||||
return session ? toSession(session) : null;
|
||||
}
|
||||
|
||||
async answer(
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
questionId: string,
|
||||
answer: string,
|
||||
) {
|
||||
const session = await this.get(userId, sessionId);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const question = session.questions.find((item) => item.id === questionId);
|
||||
if (!question) {
|
||||
return null;
|
||||
}
|
||||
|
||||
question.answer = answer;
|
||||
session.status = hasPendingQuestion(session.questions)
|
||||
? 'clarifying'
|
||||
: 'ready_to_generate';
|
||||
session.updatedAt = new Date().toISOString();
|
||||
await this.runtimeRepository.upsertCustomWorldSession(
|
||||
userId,
|
||||
sessionId,
|
||||
toSessionRecord(session),
|
||||
);
|
||||
return cloneSession(session);
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
status: CustomWorldSessionStatus,
|
||||
lastError = '',
|
||||
) {
|
||||
const session = await this.get(userId, sessionId);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
session.status = status;
|
||||
session.lastError = lastError || undefined;
|
||||
session.updatedAt = new Date().toISOString();
|
||||
await this.runtimeRepository.upsertCustomWorldSession(
|
||||
userId,
|
||||
sessionId,
|
||||
toSessionRecord(session),
|
||||
);
|
||||
return cloneSession(session);
|
||||
}
|
||||
|
||||
async setResult(userId: string, sessionId: string, result: JsonObject) {
|
||||
const session = await this.get(userId, sessionId);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
session.status = 'completed';
|
||||
session.lastError = undefined;
|
||||
session.result = JSON.parse(JSON.stringify(result)) as JsonObject;
|
||||
session.updatedAt = new Date().toISOString();
|
||||
await this.runtimeRepository.upsertCustomWorldSession(
|
||||
userId,
|
||||
sessionId,
|
||||
toSessionRecord(session),
|
||||
);
|
||||
return cloneSession(session);
|
||||
}
|
||||
}
|
||||
@@ -1,807 +0,0 @@
|
||||
import {AnimatePresence, motion} from 'motion/react';
|
||||
import {lazy, Suspense, useCallback, useEffect, useMemo, useState} from 'react';
|
||||
|
||||
import {getLiveGamePlayTimeMs} from '../data/runtimeStats';
|
||||
import type { HydratedSavedGameSnapshot } from '../persistence/runtimeSnapshotTypes';
|
||||
import {getWorldCampScenePreset} from '../data/scenePresets';
|
||||
import {BottomTab} from '../hooks/useGameFlow';
|
||||
import {resolveActiveSceneActBlueprint} from '../services/customWorldSceneActRuntime';
|
||||
import {
|
||||
type BattleRewardUi,
|
||||
type CharacterChatUi,
|
||||
type GoalFlowUi,
|
||||
type InventoryFlowUi,
|
||||
type NpcChatQuestOfferUi,
|
||||
type QuestFlowUi,
|
||||
type StoryGenerationNpcUi,
|
||||
} from '../hooks/useStoryGeneration';
|
||||
import {
|
||||
type Character,
|
||||
type CustomWorldProfile,
|
||||
type CompanionRenderState,
|
||||
type GameState,
|
||||
type StoryMoment,
|
||||
type StoryOption,
|
||||
} from '../types';
|
||||
import {CHROME_ICONS, getNineSliceStyle, TAB_ICONS, UI_CHROME} from '../uiAssets';
|
||||
import {CharacterSelectionFlow} from './game-shell/CharacterSelectionFlow';
|
||||
import {PreGameSelectionFlow} from './game-shell/PreGameSelectionFlow';
|
||||
import {SCENE_TRANSITION_FUNCTION_MODES, useSceneTransitionModel} from './game-shell/useSceneTransitionModel';
|
||||
import {useGameShellViewModel} from './game-shell/useGameShellViewModel';
|
||||
import {GameCanvas} from './GameCanvas';
|
||||
import {PixelIcon} from './PixelIcon';
|
||||
|
||||
interface GameShellSessionProps {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
isLoading: boolean;
|
||||
aiError: string | null;
|
||||
bottomTab: BottomTab;
|
||||
setBottomTab: (tab: BottomTab) => void;
|
||||
isMapOpen: boolean;
|
||||
setIsMapOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
interface GameShellStoryProps {
|
||||
displayedOptions: StoryOption[];
|
||||
canRefreshOptions: boolean;
|
||||
handleRefreshOptions: () => void;
|
||||
handleChoice: (option: StoryOption) => void;
|
||||
handleNpcChatInput: (input: string) => boolean;
|
||||
exitNpcChat: () => boolean;
|
||||
handleMapTravelToScene: (sceneId: string) => boolean;
|
||||
npcUi: StoryGenerationNpcUi;
|
||||
characterChatUi: CharacterChatUi;
|
||||
inventoryUi: InventoryFlowUi;
|
||||
battleRewardUi: BattleRewardUi;
|
||||
questUi: QuestFlowUi;
|
||||
npcChatQuestOfferUi: NpcChatQuestOfferUi;
|
||||
goalUi: GoalFlowUi;
|
||||
}
|
||||
|
||||
interface GameShellEntryProps {
|
||||
hasSavedGame: boolean;
|
||||
savedSnapshot: HydratedSavedGameSnapshot | null;
|
||||
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
|
||||
handleStartNewGame: () => void;
|
||||
handleSaveAndExit: () => void;
|
||||
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
|
||||
handleBackToWorldSelect: () => void;
|
||||
handleCharacterSelect: (character: Character) => void;
|
||||
}
|
||||
|
||||
interface GameShellCompanionProps {
|
||||
companionRenderStates: CompanionRenderState[];
|
||||
buildCompanionRenderStates: (state: GameState) => CompanionRenderState[];
|
||||
onBenchCompanion: (npcId: string) => void;
|
||||
onActivateRosterCompanion: (npcId: string, swapNpcId?: string | null) => void;
|
||||
}
|
||||
|
||||
interface GameShellAudioProps {
|
||||
musicVolume: number;
|
||||
onMusicVolumeChange: (value: number) => void;
|
||||
}
|
||||
|
||||
interface GameShellProps {
|
||||
session: GameShellSessionProps;
|
||||
story: GameShellStoryProps;
|
||||
entry: GameShellEntryProps;
|
||||
companions: GameShellCompanionProps;
|
||||
audio: GameShellAudioProps;
|
||||
}
|
||||
|
||||
const AdventureEntityModal = lazy(async () => {
|
||||
const module = await import('./AdventureEntityModal');
|
||||
|
||||
return {
|
||||
default: module.AdventureEntityModal,
|
||||
};
|
||||
});
|
||||
|
||||
const CharacterChatModal = lazy(async () => {
|
||||
const module = await import('./CharacterChatModal');
|
||||
|
||||
return {
|
||||
default: module.CharacterChatModal,
|
||||
};
|
||||
});
|
||||
|
||||
const CompanionCampModal = lazy(async () => {
|
||||
const module = await import('./CompanionCampModal');
|
||||
|
||||
return {
|
||||
default: module.CompanionCampModal,
|
||||
};
|
||||
});
|
||||
|
||||
const MapModal = lazy(async () => {
|
||||
const module = await import('./MapModal');
|
||||
|
||||
return {
|
||||
default: module.MapModal,
|
||||
};
|
||||
});
|
||||
|
||||
const NpcModals = lazy(async () => {
|
||||
const module = await import('./NpcModals');
|
||||
|
||||
return {
|
||||
default: module.NpcModals,
|
||||
};
|
||||
});
|
||||
|
||||
const AdventurePanel = lazy(async () => {
|
||||
const module = await import('./AdventurePanel');
|
||||
|
||||
return {
|
||||
default: module.AdventurePanel,
|
||||
};
|
||||
});
|
||||
|
||||
const CharacterPanel = lazy(async () => {
|
||||
const module = await import('./CharacterPanel');
|
||||
|
||||
return {
|
||||
default: module.CharacterPanel,
|
||||
};
|
||||
});
|
||||
|
||||
const InventoryPanel = lazy(async () => {
|
||||
const module = await import('./InventoryPanel');
|
||||
|
||||
return {
|
||||
default: module.InventoryPanel,
|
||||
};
|
||||
});
|
||||
|
||||
function ModalLoadingFallback({
|
||||
label,
|
||||
onClose,
|
||||
}: {
|
||||
label: string;
|
||||
onClose?: (() => void) | null;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[90] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm"
|
||||
onClick={onClose ?? undefined}
|
||||
>
|
||||
<div
|
||||
className="pixel-nine-slice pixel-modal-shell flex min-h-40 w-full max-w-md items-center justify-center px-6 py-8 text-center text-sm text-zinc-300 shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelLoadingFallback({
|
||||
label,
|
||||
}: {
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="pixel-nine-slice flex min-h-0 flex-1 items-center justify-center px-4 py-6 text-center text-xs uppercase tracking-[0.24em] text-zinc-500" style={getNineSliceStyle(UI_CHROME.modalPanel)}>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GameShell({session, story, entry, companions, audio}: GameShellProps) {
|
||||
const {
|
||||
gameState,
|
||||
currentStory,
|
||||
isLoading,
|
||||
aiError,
|
||||
bottomTab,
|
||||
setBottomTab,
|
||||
isMapOpen,
|
||||
setIsMapOpen,
|
||||
} = session;
|
||||
const {
|
||||
displayedOptions,
|
||||
canRefreshOptions,
|
||||
handleRefreshOptions,
|
||||
handleChoice,
|
||||
handleNpcChatInput,
|
||||
exitNpcChat,
|
||||
handleMapTravelToScene,
|
||||
npcUi,
|
||||
characterChatUi,
|
||||
inventoryUi,
|
||||
battleRewardUi,
|
||||
questUi,
|
||||
npcChatQuestOfferUi,
|
||||
goalUi,
|
||||
} = story;
|
||||
const {
|
||||
hasSavedGame,
|
||||
savedSnapshot,
|
||||
handleContinueGame,
|
||||
handleStartNewGame,
|
||||
handleSaveAndExit,
|
||||
handleCustomWorldSelect,
|
||||
handleBackToWorldSelect,
|
||||
handleCharacterSelect,
|
||||
} = entry;
|
||||
const {
|
||||
companionRenderStates,
|
||||
buildCompanionRenderStates,
|
||||
onBenchCompanion,
|
||||
onActivateRosterCompanion,
|
||||
} = companions;
|
||||
const {musicVolume, onMusicVolumeChange} = audio;
|
||||
|
||||
const [clockNow, setClockNow] = useState(() => Date.now());
|
||||
const openingCampSceneId = useMemo(
|
||||
() => (gameState.worldType ? getWorldCampScenePreset(gameState.worldType)?.id ?? null : null),
|
||||
[gameState.worldType],
|
||||
);
|
||||
const hasNpcModalOpen = Boolean(npcUi.tradeModal || npcUi.giftModal || npcUi.recruitModal);
|
||||
const {
|
||||
selectionStage,
|
||||
setSelectionStage,
|
||||
overlayPanel,
|
||||
openOverlayPanel,
|
||||
closeOverlayPanel,
|
||||
selectedSceneEntity,
|
||||
setSelectedSceneEntity,
|
||||
openPartyMemberDetails,
|
||||
closeAdventureEntityModal,
|
||||
showTeamModal,
|
||||
openCampModal,
|
||||
closeCampModal,
|
||||
resetForSaveAndExit,
|
||||
shouldMountAdventureEntityModal,
|
||||
shouldMountCampModal,
|
||||
shouldMountMapModal,
|
||||
shouldMountCharacterChatModal,
|
||||
shouldMountNpcModals,
|
||||
} = useGameShellViewModel({
|
||||
gameState,
|
||||
isMapOpen,
|
||||
characterChatModalOpen: Boolean(characterChatUi.modal),
|
||||
hasNpcModalOpen,
|
||||
});
|
||||
const {
|
||||
visibleGameState,
|
||||
visibleCurrentStory,
|
||||
sceneTransitionPhase,
|
||||
sceneTransitionToken,
|
||||
setSceneTransitionDurations,
|
||||
beginSceneTransition,
|
||||
} = useSceneTransitionModel({
|
||||
gameState,
|
||||
currentStory,
|
||||
openingCampSceneId,
|
||||
});
|
||||
const isCharacterSelectionStage =
|
||||
gameState.currentScene === 'Selection' &&
|
||||
Boolean(gameState.worldType) &&
|
||||
!gameState.playerCharacter;
|
||||
const collapseTopStage = gameState.currentScene === 'Selection';
|
||||
const shouldHideStoryOptions = sceneTransitionPhase !== 'idle';
|
||||
const visibleStoryForRender = visibleCurrentStory;
|
||||
|
||||
const dialogueIndicator = useMemo(() => {
|
||||
if (!isLoading || visibleCurrentStory?.displayMode !== 'dialogue' || visibleGameState.currentEncounter?.kind !== 'npc') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lastSpeaker = visibleCurrentStory.dialogue?.[visibleCurrentStory.dialogue.length - 1]?.speaker ?? null;
|
||||
return {
|
||||
showPlayer: true,
|
||||
showEncounter: true,
|
||||
activeSpeaker: lastSpeaker === 'player' ? 'player' : lastSpeaker ? 'npc' : null,
|
||||
} as const;
|
||||
}, [visibleCurrentStory?.dialogue, visibleCurrentStory?.displayMode, visibleGameState.currentEncounter?.kind, isLoading]);
|
||||
|
||||
const characterChatSummaries = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
Object.entries(gameState.characterChats ?? {}).map(([characterId, record]) => [characterId, record.summary]),
|
||||
),
|
||||
[gameState.characterChats],
|
||||
);
|
||||
|
||||
const visibleCompanionRenderStates = useMemo(
|
||||
() => buildCompanionRenderStates(visibleGameState),
|
||||
[buildCompanionRenderStates, visibleGameState],
|
||||
);
|
||||
|
||||
const canvasCompanionRenderStates = useMemo(() => {
|
||||
const activeEncounterNpcId = visibleGameState.currentEncounter?.kind === 'npc'
|
||||
? visibleGameState.currentEncounter.id ?? null
|
||||
: null;
|
||||
if (!activeEncounterNpcId) return visibleCompanionRenderStates;
|
||||
return visibleCompanionRenderStates.filter(companion => companion.npcId !== activeEncounterNpcId);
|
||||
}, [visibleCompanionRenderStates, visibleGameState.currentEncounter]);
|
||||
|
||||
const livePlayTimeMs = useMemo(
|
||||
() => getLiveGamePlayTimeMs(gameState.runtimeStats, clockNow),
|
||||
[clockNow, gameState.runtimeStats],
|
||||
);
|
||||
const activeSceneAct = useMemo(
|
||||
() => resolveActiveSceneActBlueprint({
|
||||
profile: visibleGameState.customWorldProfile,
|
||||
sceneId: visibleGameState.currentScenePreset?.id ?? null,
|
||||
storyEngineMemory: visibleGameState.storyEngineMemory,
|
||||
}),
|
||||
[
|
||||
visibleGameState.currentScenePreset?.id,
|
||||
visibleGameState.customWorldProfile,
|
||||
visibleGameState.storyEngineMemory,
|
||||
],
|
||||
);
|
||||
const activeSceneChapter = useMemo(() => {
|
||||
if (!visibleGameState.customWorldProfile || !visibleGameState.currentScenePreset?.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
visibleGameState.customWorldProfile.sceneChapterBlueprints?.find(
|
||||
entry => entry.sceneId === visibleGameState.currentScenePreset?.id
|
||||
|| entry.linkedLandmarkIds.includes(visibleGameState.currentScenePreset?.id ?? ''),
|
||||
) ?? null
|
||||
);
|
||||
}, [
|
||||
visibleGameState.currentScenePreset?.id,
|
||||
visibleGameState.customWorldProfile,
|
||||
]);
|
||||
|
||||
const adventureStatistics = useMemo(
|
||||
() => ({
|
||||
playTimeMs: livePlayTimeMs,
|
||||
hostileNpcsDefeated: gameState.runtimeStats.hostileNpcsDefeated,
|
||||
questsAccepted: gameState.runtimeStats.questsAccepted,
|
||||
questsCompleted: visibleGameState.quests.filter(quest => quest.status === 'completed' || quest.status === 'turned_in').length,
|
||||
questsTurnedIn: visibleGameState.quests.filter(quest => quest.status === 'turned_in').length,
|
||||
itemsUsed: gameState.runtimeStats.itemsUsed,
|
||||
scenesTraveled: gameState.runtimeStats.scenesTraveled,
|
||||
currentSceneName: visibleGameState.currentScenePreset?.name ?? 'Current Area',
|
||||
playerCurrency: visibleGameState.playerCurrency,
|
||||
inventoryItemCount: visibleGameState.playerInventory.reduce((sum, item) => sum + item.quantity, 0),
|
||||
inventoryStackCount: visibleGameState.playerInventory.length,
|
||||
activeCompanionCount: visibleGameState.companions.length,
|
||||
rosterCompanionCount: visibleGameState.roster.length,
|
||||
}),
|
||||
[
|
||||
gameState.runtimeStats.itemsUsed,
|
||||
gameState.runtimeStats.hostileNpcsDefeated,
|
||||
gameState.runtimeStats.questsAccepted,
|
||||
gameState.runtimeStats.scenesTraveled,
|
||||
livePlayTimeMs,
|
||||
visibleGameState.companions.length,
|
||||
visibleGameState.currentScenePreset?.name,
|
||||
visibleGameState.playerCurrency,
|
||||
visibleGameState.playerInventory,
|
||||
visibleGameState.quests,
|
||||
visibleGameState.roster.length,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!gameState.playerCharacter || gameState.currentScene !== 'Story') {
|
||||
return;
|
||||
}
|
||||
|
||||
setClockNow(Date.now());
|
||||
const intervalId = window.setInterval(() => setClockNow(Date.now()), 1000);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [gameState.currentScene, gameState.playerCharacter]);
|
||||
|
||||
const handleSceneTransitionChoice = useCallback((option: StoryOption) => {
|
||||
const transitionMode = SCENE_TRANSITION_FUNCTION_MODES[option.functionId];
|
||||
if (transitionMode) {
|
||||
beginSceneTransition(transitionMode);
|
||||
}
|
||||
handleChoice(option);
|
||||
}, [beginSceneTransition, handleChoice]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fusion-pixel-app pixel-root-shell flex h-screen max-h-screen flex-col overflow-hidden font-sans text-zinc-100"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(rgba(8, 10, 14, 0.82), rgba(8, 10, 14, 0.82)), url("${UI_CHROME.appBackground}")`,
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'repeat',
|
||||
}}
|
||||
>
|
||||
<div className={`relative ${collapseTopStage ? 'h-0 border-b-0' : 'h-[36%] border-b border-white/5'}`}>
|
||||
{collapseTopStage ? null : (
|
||||
<GameCanvas
|
||||
scrollWorld={visibleGameState.scrollWorld}
|
||||
animationState={visibleGameState.animationState}
|
||||
playerCharacter={visibleGameState.playerCharacter}
|
||||
encounter={visibleGameState.currentEncounter}
|
||||
currentScenePreset={visibleGameState.currentScenePreset}
|
||||
worldType={visibleGameState.worldType}
|
||||
sceneHostileNpcs={visibleGameState.sceneHostileNpcs}
|
||||
playerX={visibleGameState.playerX}
|
||||
playerOffsetY={visibleGameState.playerOffsetY}
|
||||
playerFacing={visibleGameState.playerFacing}
|
||||
playerActionMode={visibleGameState.playerActionMode}
|
||||
inBattle={visibleGameState.inBattle}
|
||||
playerHp={visibleGameState.playerHp}
|
||||
playerMaxHp={visibleGameState.playerMaxHp}
|
||||
playerMana={visibleGameState.playerMana}
|
||||
playerMaxMana={visibleGameState.playerMaxMana}
|
||||
activeCombatEffects={visibleGameState.activeCombatEffects}
|
||||
companions={canvasCompanionRenderStates}
|
||||
npcStates={visibleGameState.npcStates}
|
||||
dialogueIndicator={dialogueIndicator}
|
||||
npcAffinityEffect={visibleStoryForRender?.npcAffinityEffect ?? null}
|
||||
onEntitySelect={setSelectedSceneEntity}
|
||||
onSceneNameClick={() => setIsMapOpen(true)}
|
||||
sceneTransitionPhase={sceneTransitionPhase}
|
||||
sceneTransitionToken={sceneTransitionToken}
|
||||
onSceneTransitionDurationsChange={setSceneTransitionDurations}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`pixel-app-shell flex min-h-0 flex-1 flex-col ${isCharacterSelectionStage ? 'justify-center p-4 sm:p-5' : 'p-3 sm:p-4'}`}
|
||||
style={{
|
||||
backgroundColor: isCharacterSelectionStage ? '#0d1016' : undefined,
|
||||
backgroundImage: isCharacterSelectionStage
|
||||
? undefined
|
||||
: `linear-gradient(rgba(10, 12, 18, 0.55), rgba(10, 12, 18, 0.55)), url("${UI_CHROME.appBackground}")`,
|
||||
backgroundPosition: isCharacterSelectionStage ? undefined : 'center',
|
||||
backgroundRepeat: isCharacterSelectionStage ? undefined : 'repeat',
|
||||
}}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
{!gameState.worldType && (
|
||||
<PreGameSelectionFlow
|
||||
selectionStage={selectionStage}
|
||||
setSelectionStage={setSelectionStage}
|
||||
gameState={gameState}
|
||||
hasSavedGame={hasSavedGame}
|
||||
savedSnapshot={savedSnapshot}
|
||||
handleContinueGame={handleContinueGame}
|
||||
handleStartNewGame={handleStartNewGame}
|
||||
handleCustomWorldSelect={handleCustomWorldSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{gameState.worldType && !gameState.playerCharacter && (
|
||||
<motion.div
|
||||
key="character-select-shell"
|
||||
initial={{opacity: 0, y: 12}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0, y: -12}}
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<CharacterSelectionFlow
|
||||
worldType={gameState.worldType}
|
||||
customWorldProfile={gameState.customWorldProfile}
|
||||
onBack={() => {
|
||||
handleBackToWorldSelect();
|
||||
setSelectionStage('platform');
|
||||
}}
|
||||
onConfirm={handleCharacterSelect}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{visibleGameState.playerCharacter && visibleStoryForRender && (
|
||||
<motion.div key="story-flow" initial={{opacity: 0}} animate={{opacity: 1}} className="flex h-full min-h-0 flex-col">
|
||||
<div className="story-top-tabs mb-3 grid grid-cols-3 gap-2 sm:gap-3">
|
||||
<button
|
||||
onClick={() => setBottomTab('character')}
|
||||
className={`pixel-nine-slice pixel-pressable pixel-tab-button ${bottomTab === 'character' ? 'pixel-tab-button--active text-white' : 'text-zinc-300'}`}
|
||||
style={getNineSliceStyle(bottomTab === 'character' ? UI_CHROME.tabActive : UI_CHROME.tabInactive, {paddingX: 10, paddingY: 8})}
|
||||
>
|
||||
<span className="pixel-tab-button__inner">
|
||||
<PixelIcon
|
||||
src={bottomTab === 'character' ? TAB_ICONS.character.active : TAB_ICONS.character.inactive}
|
||||
className={`pixel-tab-button__icon ${bottomTab === 'character' ? 'opacity-100' : 'opacity-70'}`}
|
||||
/>
|
||||
<span className="pixel-tab-button__label">队伍</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBottomTab('adventure')}
|
||||
className={`pixel-nine-slice pixel-pressable pixel-tab-button ${bottomTab === 'adventure' ? 'pixel-tab-button--active text-white' : 'text-zinc-300'}`}
|
||||
style={getNineSliceStyle(bottomTab === 'adventure' ? UI_CHROME.tabActive : UI_CHROME.tabInactive, {paddingX: 10, paddingY: 8})}
|
||||
>
|
||||
<span className="pixel-tab-button__inner">
|
||||
<PixelIcon
|
||||
src={bottomTab === 'adventure' ? TAB_ICONS.adventure.active : TAB_ICONS.adventure.inactive}
|
||||
className={`pixel-tab-button__icon ${bottomTab === 'adventure' ? 'opacity-100' : 'opacity-70'}`}
|
||||
/>
|
||||
<span className="pixel-tab-button__label">冒险</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBottomTab('inventory')}
|
||||
className={`pixel-nine-slice pixel-pressable pixel-tab-button ${bottomTab === 'inventory' ? 'pixel-tab-button--active text-white' : 'text-zinc-300'}`}
|
||||
style={getNineSliceStyle(bottomTab === 'inventory' ? UI_CHROME.tabActive : UI_CHROME.tabInactive, {paddingX: 10, paddingY: 8})}
|
||||
>
|
||||
<span className="pixel-tab-button__inner">
|
||||
<PixelIcon
|
||||
src={bottomTab === 'inventory' ? TAB_ICONS.inventory.active : TAB_ICONS.inventory.inactive}
|
||||
className={`pixel-tab-button__icon ${bottomTab === 'inventory' ? 'opacity-100' : 'opacity-70'}`}
|
||||
/>
|
||||
<span className="pixel-tab-button__label">背包</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{bottomTab === 'character' && (
|
||||
<Suspense fallback={<PanelLoadingFallback label="正在加载队伍面板" />}>
|
||||
<CharacterPanel
|
||||
worldType={visibleGameState.worldType}
|
||||
customWorldProfile={visibleGameState.customWorldProfile}
|
||||
playerCharacter={visibleGameState.playerCharacter}
|
||||
playerHp={visibleGameState.playerHp}
|
||||
playerMaxHp={visibleGameState.playerMaxHp}
|
||||
playerMana={visibleGameState.playerMana}
|
||||
playerMaxMana={visibleGameState.playerMaxMana}
|
||||
playerEquipment={visibleGameState.playerEquipment}
|
||||
activeBuildBuffs={visibleGameState.activeBuildBuffs}
|
||||
companionRenderStates={companionRenderStates}
|
||||
npcStates={visibleGameState.npcStates}
|
||||
quests={visibleGameState.quests}
|
||||
companionArcStates={
|
||||
visibleGameState.storyEngineMemory?.companionArcStates ?? []
|
||||
}
|
||||
companionResolutions={
|
||||
visibleGameState.storyEngineMemory?.companionResolutions ?? []
|
||||
}
|
||||
onOpenCamp={openCampModal}
|
||||
onOpenCharacterChat={characterChatUi.openChat}
|
||||
chatSummaries={characterChatSummaries}
|
||||
onInspectMember={openPartyMemberDetails}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{bottomTab === 'adventure' && (
|
||||
<Suspense fallback={<PanelLoadingFallback label="正在加载冒险面板" />}>
|
||||
<AdventurePanel
|
||||
aiError={aiError}
|
||||
currentStory={visibleStoryForRender}
|
||||
isLoading={isLoading}
|
||||
displayedOptions={displayedOptions}
|
||||
hideOptions={shouldHideStoryOptions}
|
||||
canRefreshOptions={canRefreshOptions}
|
||||
onRefreshOptions={handleRefreshOptions}
|
||||
onChoice={handleSceneTransitionChoice}
|
||||
onSubmitNpcChatInput={handleNpcChatInput}
|
||||
onExitNpcChat={exitNpcChat}
|
||||
onOpenCharacter={() => openOverlayPanel('character')}
|
||||
onOpenInventory={() => openOverlayPanel('inventory')}
|
||||
playerCharacter={visibleGameState.playerCharacter}
|
||||
worldType={visibleGameState.worldType}
|
||||
quests={visibleGameState.quests}
|
||||
questUi={questUi}
|
||||
npcChatQuestOfferUi={npcChatQuestOfferUi}
|
||||
goalStack={goalUi.goalStack}
|
||||
goalPulse={goalUi.pulse}
|
||||
onDismissGoalPulse={goalUi.dismissPulse}
|
||||
battleRewardUi={battleRewardUi}
|
||||
playerHp={visibleGameState.playerHp}
|
||||
playerMaxHp={visibleGameState.playerMaxHp}
|
||||
playerMana={visibleGameState.playerMana}
|
||||
playerMaxMana={visibleGameState.playerMaxMana}
|
||||
playerSkillCooldowns={visibleGameState.playerSkillCooldowns}
|
||||
inBattle={visibleGameState.inBattle}
|
||||
currentNpcBattleMode={visibleGameState.currentNpcBattleMode}
|
||||
chapterState={visibleGameState.chapterState ?? null}
|
||||
journeyBeat={
|
||||
visibleGameState.storyEngineMemory?.currentJourneyBeat ?? null
|
||||
}
|
||||
currentSceneActTitle={activeSceneAct?.title ?? null}
|
||||
currentSceneActIndex={
|
||||
activeSceneChapter && activeSceneAct
|
||||
? (() => {
|
||||
const actIndex = activeSceneChapter.acts.findIndex(
|
||||
act => act.id === activeSceneAct.id,
|
||||
);
|
||||
return actIndex >= 0 ? actIndex + 1 : null;
|
||||
})()
|
||||
: null
|
||||
}
|
||||
currentSceneActCount={activeSceneChapter?.acts.length ?? null}
|
||||
statistics={adventureStatistics}
|
||||
musicVolume={musicVolume}
|
||||
onMusicVolumeChange={onMusicVolumeChange}
|
||||
onSaveAndExit={() => {
|
||||
resetForSaveAndExit();
|
||||
handleSaveAndExit();
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{bottomTab === 'inventory' && (
|
||||
<Suspense fallback={<PanelLoadingFallback label="正在加载背包面板" />}>
|
||||
<InventoryPanel
|
||||
playerCharacter={visibleGameState.playerCharacter}
|
||||
worldType={visibleGameState.worldType}
|
||||
playerInventory={visibleGameState.playerInventory}
|
||||
playerCurrency={visibleGameState.playerCurrency}
|
||||
playerHp={visibleGameState.playerHp}
|
||||
playerMaxHp={visibleGameState.playerMaxHp}
|
||||
playerMana={visibleGameState.playerMana}
|
||||
playerMaxMana={visibleGameState.playerMaxMana}
|
||||
inBattle={visibleGameState.inBattle}
|
||||
onUseItem={inventoryUi.useInventoryItem}
|
||||
onEquipItem={inventoryUi.equipInventoryItem}
|
||||
forgeRecipes={inventoryUi.forgeRecipes}
|
||||
onCraftRecipe={inventoryUi.craftRecipe}
|
||||
onDismantleItem={inventoryUi.dismantleItem}
|
||||
onReforgeItem={inventoryUi.reforgeItem}
|
||||
continueGameDigest={
|
||||
visibleGameState.storyEngineMemory?.continueGameDigest ?? null
|
||||
}
|
||||
narrativeCodex={
|
||||
visibleGameState.storyEngineMemory?.narrativeCodex ?? []
|
||||
}
|
||||
narrativeQaReport={
|
||||
visibleGameState.storyEngineMemory?.narrativeQaReport ?? null
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{shouldMountAdventureEntityModal && (
|
||||
<Suspense fallback={<ModalLoadingFallback label="正在加载冒险详情..." onClose={closeAdventureEntityModal} />}>
|
||||
<AdventureEntityModal
|
||||
selection={selectedSceneEntity}
|
||||
gameState={gameState}
|
||||
onClose={closeAdventureEntityModal}
|
||||
onOpenCharacterChat={target => {
|
||||
closeAdventureEntityModal();
|
||||
characterChatUi.openChat(target);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{overlayPanel && gameState.playerCharacter && (
|
||||
<motion.div
|
||||
initial={{opacity: 0}}
|
||||
animate={{opacity: 1}}
|
||||
exit={{opacity: 0}}
|
||||
className="fixed inset-0 z-[65] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm"
|
||||
onClick={closeOverlayPanel}
|
||||
>
|
||||
<motion.div
|
||||
initial={{opacity: 0, scale: 0.96, y: 8}}
|
||||
animate={{opacity: 1, scale: 1, y: 0}}
|
||||
exit={{opacity: 0, scale: 0.96, y: 8}}
|
||||
transition={{duration: 0.18, ease: 'easeOut'}}
|
||||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,60rem)] w-full max-w-5xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
<div className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
|
||||
<div className="min-w-0 pr-10 text-sm font-semibold text-white">{overlayPanel === 'character' ? '队伍' : '背包'}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeOverlayPanel}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 p-5">
|
||||
{overlayPanel === 'character' ? (
|
||||
<Suspense fallback={<PanelLoadingFallback label="正在加载队伍面板" />}>
|
||||
<CharacterPanel
|
||||
worldType={gameState.worldType}
|
||||
customWorldProfile={gameState.customWorldProfile}
|
||||
playerCharacter={gameState.playerCharacter}
|
||||
playerHp={gameState.playerHp}
|
||||
playerMaxHp={gameState.playerMaxHp}
|
||||
playerMana={gameState.playerMana}
|
||||
playerMaxMana={gameState.playerMaxMana}
|
||||
playerEquipment={gameState.playerEquipment}
|
||||
activeBuildBuffs={gameState.activeBuildBuffs}
|
||||
companionRenderStates={companionRenderStates}
|
||||
npcStates={gameState.npcStates}
|
||||
quests={gameState.quests}
|
||||
onOpenCamp={() => {
|
||||
closeOverlayPanel();
|
||||
openCampModal();
|
||||
}}
|
||||
onOpenCharacterChat={target => {
|
||||
closeOverlayPanel();
|
||||
characterChatUi.openChat(target);
|
||||
}}
|
||||
chatSummaries={characterChatSummaries}
|
||||
onInspectMember={openPartyMemberDetails}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
<Suspense fallback={<PanelLoadingFallback label="正在加载背包面板" />}>
|
||||
<InventoryPanel
|
||||
playerCharacter={gameState.playerCharacter}
|
||||
worldType={gameState.worldType}
|
||||
playerInventory={gameState.playerInventory}
|
||||
playerCurrency={gameState.playerCurrency}
|
||||
playerHp={gameState.playerHp}
|
||||
playerMaxHp={gameState.playerMaxHp}
|
||||
playerMana={gameState.playerMana}
|
||||
playerMaxMana={gameState.playerMaxMana}
|
||||
inBattle={gameState.inBattle}
|
||||
onUseItem={inventoryUi.useInventoryItem}
|
||||
onEquipItem={inventoryUi.equipInventoryItem}
|
||||
forgeRecipes={inventoryUi.forgeRecipes}
|
||||
onCraftRecipe={inventoryUi.craftRecipe}
|
||||
onDismantleItem={inventoryUi.dismantleItem}
|
||||
onReforgeItem={inventoryUi.reforgeItem}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{shouldMountCampModal && (
|
||||
<Suspense fallback={<ModalLoadingFallback label="正在加载队伍营地..." onClose={closeCampModal} />}>
|
||||
<CompanionCampModal
|
||||
isOpen={showTeamModal}
|
||||
playerCharacter={gameState.playerCharacter}
|
||||
companions={gameState.companions}
|
||||
roster={gameState.roster}
|
||||
inBattle={gameState.inBattle}
|
||||
onClose={closeCampModal}
|
||||
onBenchCompanion={onBenchCompanion}
|
||||
onActivateCompanion={onActivateRosterCompanion}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{shouldMountMapModal && (
|
||||
<Suspense fallback={<ModalLoadingFallback label="正在加载地图..." onClose={() => setIsMapOpen(false)} />}>
|
||||
<MapModal
|
||||
isOpen={isMapOpen}
|
||||
currentScenePreset={gameState.currentScenePreset}
|
||||
worldType={gameState.worldType}
|
||||
canTravel={!gameState.inBattle && !isLoading}
|
||||
onTravelToScene={scene => {
|
||||
const triggered = handleMapTravelToScene(scene.id);
|
||||
if (triggered) {
|
||||
setIsMapOpen(false);
|
||||
}
|
||||
}}
|
||||
isTraveling={isLoading}
|
||||
onClose={() => setIsMapOpen(false)}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{shouldMountCharacterChatModal && (
|
||||
<Suspense fallback={<ModalLoadingFallback label="正在加载角色聊天..." onClose={characterChatUi.closeChat} />}>
|
||||
<CharacterChatModal
|
||||
modal={characterChatUi.modal}
|
||||
onClose={characterChatUi.closeChat}
|
||||
onDraftChange={characterChatUi.setDraft}
|
||||
onUseSuggestion={characterChatUi.useSuggestion}
|
||||
onRefreshSuggestions={characterChatUi.refreshSuggestions}
|
||||
onSendDraft={characterChatUi.sendDraft}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{shouldMountNpcModals && (
|
||||
<Suspense fallback={<ModalLoadingFallback label="正在加载场景角色交互..." />}>
|
||||
<NpcModals gameState={gameState} npcUi={npcUi} />
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,9 +9,9 @@ import { AuthGate } from './AuthGate';
|
||||
import { useAuthUi } from './AuthUiContext';
|
||||
|
||||
const authMocks = vi.hoisted(() => ({
|
||||
getStoredAccessToken: vi.fn(),
|
||||
ensureAutoAuthUser: vi.fn(),
|
||||
getAuthLoginOptions: vi.fn(),
|
||||
getCurrentAuthUser: vi.fn(),
|
||||
loginWithPhoneCode: vi.fn(),
|
||||
sendPhoneLoginCode: vi.fn(),
|
||||
startWechatLogin: vi.fn(),
|
||||
@@ -20,7 +20,6 @@ const authMocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock('../../services/apiClient', () => ({
|
||||
AUTH_STATE_EVENT: 'genarrative-auth-state-changed',
|
||||
getStoredAccessToken: authMocks.getStoredAccessToken,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/authService', () => ({
|
||||
@@ -31,9 +30,9 @@ vi.mock('../../services/authService', () => ({
|
||||
getAuthAuditLogs: vi.fn(),
|
||||
getAuthLoginOptions: authMocks.getAuthLoginOptions,
|
||||
getAuthRiskBlocks: vi.fn(),
|
||||
getCurrentAuthUser: authMocks.getCurrentAuthUser,
|
||||
getAuthSessions: vi.fn(),
|
||||
getCaptchaChallengeFromError: vi.fn(() => null),
|
||||
getCurrentAuthUser: vi.fn(),
|
||||
liftAuthRiskBlock: vi.fn(),
|
||||
loginWithPhoneCode: authMocks.loginWithPhoneCode,
|
||||
logoutAllAuthSessions: vi.fn(),
|
||||
@@ -76,8 +75,11 @@ const mockUser: AuthUser = {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authMocks.getStoredAccessToken.mockReturnValue(null);
|
||||
authMocks.consumeAuthCallbackResult.mockReturnValue(null);
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
user: null,
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
authMocks.loginWithPhoneCode.mockResolvedValue(mockUser);
|
||||
authMocks.sendPhoneLoginCode.mockResolvedValue({
|
||||
cooldownSeconds: 60,
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
import { useGameSettings } from '../../hooks/useGameSettings';
|
||||
import {
|
||||
AUTH_STATE_EVENT,
|
||||
getStoredAccessToken,
|
||||
} from '../../services/apiClient';
|
||||
import {
|
||||
type AuthAuditLogEntry,
|
||||
@@ -228,12 +227,6 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setShowLoginModal(true);
|
||||
}
|
||||
|
||||
const token = getStoredAccessToken();
|
||||
if (!token) {
|
||||
await resolveGuestFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const nextSession = await getCurrentAuthUser();
|
||||
if (!isActive) {
|
||||
@@ -241,9 +234,8 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
}
|
||||
|
||||
if (!nextSession.user) {
|
||||
setUser(null);
|
||||
setAvailableLoginMethods(nextSession.availableLoginMethods);
|
||||
setStatus('unauthenticated');
|
||||
await resolveGuestFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { CustomWorldAgentClarificationPanel } from './CustomWorldAgentClarificationPanel';
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('clarification panel shows pending questions and ready state', () => {
|
||||
const pendingHtml = renderToStaticMarkup(
|
||||
<CustomWorldAgentClarificationPanel
|
||||
readiness={{
|
||||
isReady: false,
|
||||
completedKeys: ['world_hook'],
|
||||
missingKeys: ['player_premise', 'core_conflict'],
|
||||
}}
|
||||
pendingClarifications={[
|
||||
{
|
||||
id: 'player_premise',
|
||||
label: '玩家身份与开局',
|
||||
question: '玩家是谁,故事开场时卡在什么处境里?',
|
||||
targetKey: 'player_premise',
|
||||
priority: 2,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
const readyHtml = renderToStaticMarkup(
|
||||
<CustomWorldAgentClarificationPanel
|
||||
readiness={{
|
||||
isReady: true,
|
||||
completedKeys: [
|
||||
'world_hook',
|
||||
'player_premise',
|
||||
'theme_and_tone',
|
||||
'core_conflict',
|
||||
'relationship_seed',
|
||||
'iconic_element',
|
||||
],
|
||||
missingKeys: [],
|
||||
}}
|
||||
pendingClarifications={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(pendingHtml).toContain('待补充问题');
|
||||
expect(pendingHtml).toContain('玩家是谁,故事开场时卡在什么处境里');
|
||||
expect(readyHtml).toContain('当前设定已齐备,可以进入下一阶段');
|
||||
});
|
||||
|
||||
test('falls back to stable keys when clarification ids are empty', () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
render(
|
||||
<CustomWorldAgentClarificationPanel
|
||||
readiness={{
|
||||
isReady: false,
|
||||
completedKeys: [],
|
||||
missingKeys: ['player_premise', 'core_conflict'],
|
||||
}}
|
||||
pendingClarifications={[
|
||||
{
|
||||
id: '',
|
||||
label: '玩家身份与开局',
|
||||
question: '玩家是谁,故事开场时卡在什么处境里?',
|
||||
targetKey: 'player_premise',
|
||||
priority: 2,
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
label: '核心冲突',
|
||||
question: '第一阶段最直接撞上的冲突是什么?',
|
||||
targetKey: 'core_conflict',
|
||||
priority: 1,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/玩家身份与开局/u)).toBeTruthy();
|
||||
expect(screen.getByText(/核心冲突/u)).toBeTruthy();
|
||||
|
||||
const duplicateKeyCalls = consoleErrorSpy.mock.calls.filter((call) =>
|
||||
call.some(
|
||||
(arg) =>
|
||||
typeof arg === 'string' &&
|
||||
arg.includes('Encountered two children with the same key'),
|
||||
),
|
||||
);
|
||||
|
||||
expect(duplicateKeyCalls).toHaveLength(0);
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
import type {
|
||||
CreatorIntentReadiness,
|
||||
CustomWorldPendingClarification,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
|
||||
type CustomWorldAgentClarificationPanelProps = {
|
||||
pendingClarifications: CustomWorldPendingClarification[];
|
||||
readiness: CreatorIntentReadiness;
|
||||
};
|
||||
|
||||
export function CustomWorldAgentClarificationPanel({
|
||||
pendingClarifications,
|
||||
readiness,
|
||||
}: CustomWorldAgentClarificationPanelProps) {
|
||||
if (readiness.isReady) {
|
||||
return (
|
||||
<section className="rounded-[1.5rem] border border-emerald-300/18 bg-emerald-500/8 px-4 py-4">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-emerald-100/80">
|
||||
下一阶段
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">
|
||||
当前设定已齐备,可以进入下一阶段
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="platform-remap-surface rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
待补充问题
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">
|
||||
先补最关键的 1 到 3 项
|
||||
</div>
|
||||
</div>
|
||||
<span className="rounded-full border border-sky-300/20 bg-sky-500/10 px-3 py-1 text-[11px] text-sky-100">
|
||||
{pendingClarifications.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
{pendingClarifications.slice(0, 3).map((item, index) => (
|
||||
<div
|
||||
key={item.id.trim() || `clarification-${item.targetKey}-${index}`}
|
||||
className="rounded-[1.15rem] border border-white/8 bg-white/5 px-3 py-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{index + 1}. {item.label}
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-500">P{item.priority}</div>
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-zinc-300">
|
||||
{item.question}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import type { CustomWorldDraftCardDetail } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import { CustomWorldAgentDraftDetailPanel } from './CustomWorldAgentDraftDetailPanel';
|
||||
import { CustomWorldGenerateEntityModal } from './CustomWorldGenerateEntityModal';
|
||||
|
||||
const CHARACTER_DETAIL: CustomWorldDraftCardDetail = {
|
||||
id: 'character-1',
|
||||
kind: 'character',
|
||||
title: '沈砺',
|
||||
sections: [
|
||||
{
|
||||
id: 'name',
|
||||
label: '角色名',
|
||||
value: '沈砺',
|
||||
},
|
||||
{
|
||||
id: 'publicMask',
|
||||
label: '外显身份',
|
||||
value: '守灯会里最熟悉旧航道的人。',
|
||||
},
|
||||
{
|
||||
id: 'summary',
|
||||
label: '角色摘要',
|
||||
value: '他像旧友,但也像一把始终没收回鞘的刀。',
|
||||
},
|
||||
],
|
||||
linkedIds: ['thread-1'],
|
||||
locked: false,
|
||||
editable: true,
|
||||
editableSectionIds: ['name', 'publicMask', 'summary'],
|
||||
warningMessages: [],
|
||||
assetStatus: 'missing',
|
||||
assetStatusLabel: '待生成主图',
|
||||
};
|
||||
|
||||
function DetailInteractionHarness() {
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [generateMode, setGenerateMode] = useState<'character' | 'landmark' | null>(
|
||||
null,
|
||||
);
|
||||
const [savedPayload, setSavedPayload] = useState<string>('');
|
||||
|
||||
return (
|
||||
<>
|
||||
<CustomWorldAgentDraftDetailPanel
|
||||
detail={CHARACTER_DETAIL}
|
||||
loading={false}
|
||||
editMode={editMode}
|
||||
onClose={() => {}}
|
||||
onStartEdit={() => {
|
||||
setEditMode(true);
|
||||
}}
|
||||
onCancelEdit={() => {
|
||||
setEditMode(false);
|
||||
}}
|
||||
onSave={(sections) => {
|
||||
setSavedPayload(JSON.stringify(sections));
|
||||
setEditMode(false);
|
||||
}}
|
||||
onGenerateCharacter={() => {
|
||||
setGenerateMode('character');
|
||||
}}
|
||||
onGenerateLandmark={() => {
|
||||
setGenerateMode('landmark');
|
||||
}}
|
||||
onOpenRoleAssetStudio={() => {}}
|
||||
/>
|
||||
<CustomWorldGenerateEntityModal
|
||||
open={generateMode !== null}
|
||||
mode={generateMode ?? 'character'}
|
||||
anchorCardTitle={CHARACTER_DETAIL.title}
|
||||
onClose={() => {
|
||||
setGenerateMode(null);
|
||||
}}
|
||||
onSubmit={() => {
|
||||
setGenerateMode(null);
|
||||
}}
|
||||
/>
|
||||
<div data-testid="saved-payload">{savedPayload}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
test('draft detail panel supports edit save and opening generate modals', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<DetailInteractionHarness />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '编辑设定' }));
|
||||
const summaryInput = screen.getByLabelText('角色摘要');
|
||||
await user.clear(summaryInput);
|
||||
await user.type(summaryInput, '他像旧友,也像最早知道航道秘密的人。');
|
||||
await user.click(screen.getByRole('button', { name: '保存' }));
|
||||
|
||||
expect(screen.getByTestId('saved-payload').textContent).toContain(
|
||||
'他像旧友,也像最早知道航道秘密的人。',
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '新增角色' }));
|
||||
expect(screen.getByRole('button', { name: '生成角色' })).toBeTruthy();
|
||||
expect(screen.getByText('当前参考卡')).toBeTruthy();
|
||||
const closeButtons = screen.getAllByRole('button', { name: '关闭' });
|
||||
await user.click(closeButtons[closeButtons.length - 1]!);
|
||||
|
||||
expect(screen.getByRole('button', { name: '角色资产' })).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '新增场景' }));
|
||||
expect(screen.getByRole('button', { name: '生成场景' })).toBeTruthy();
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { CustomWorldAgentDraftDetailPanel } from './CustomWorldAgentDraftDetailPanel';
|
||||
|
||||
test('draft detail panel renders sections and warnings', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldAgentDraftDetailPanel
|
||||
detail={{
|
||||
id: 'thread-1',
|
||||
kind: 'thread',
|
||||
title: '谁掌握航道解释权',
|
||||
sections: [
|
||||
{
|
||||
id: 'thread-type',
|
||||
label: '线程类型',
|
||||
value: '明线',
|
||||
},
|
||||
{
|
||||
id: 'thread-conflict',
|
||||
label: '冲突内容',
|
||||
value: '守灯会与沉船商盟正在争夺航道解释权。',
|
||||
},
|
||||
],
|
||||
linkedIds: ['character-1', 'landmark-1'],
|
||||
locked: false,
|
||||
editable: true,
|
||||
editableSectionIds: ['title', 'summary', 'conflictType', 'stakes'],
|
||||
warningMessages: ['这条线还缺少更明确的地点挂点。'],
|
||||
}}
|
||||
loading={false}
|
||||
onClose={() => {}}
|
||||
onStartEdit={() => {}}
|
||||
onGenerateCharacter={() => {}}
|
||||
onGenerateLandmark={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('谁掌握航道解释权');
|
||||
expect(html).toContain('线程类型');
|
||||
expect(html).toContain('守灯会与沉船商盟');
|
||||
expect(html).toContain('继续精修');
|
||||
expect(html).toContain('编辑设定');
|
||||
expect(html).toContain('新增角色');
|
||||
});
|
||||
|
||||
test('draft detail panel renders scene chapter label and background preview', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldAgentDraftDetailPanel
|
||||
detail={{
|
||||
id: 'scene-chapter-docks',
|
||||
kind: 'scene_chapter',
|
||||
title: '潮汐码头章节',
|
||||
sections: [
|
||||
{
|
||||
id: 'sceneName',
|
||||
label: '所属场景',
|
||||
value: '潮汐码头',
|
||||
},
|
||||
{
|
||||
id: 'act:act-docks-1:backgroundImageSrc',
|
||||
label: '第 1 幕背景图',
|
||||
value: '/images/scene/docks-act-1.webp',
|
||||
},
|
||||
],
|
||||
linkedIds: ['landmark-docks', 'thread-smuggling'],
|
||||
locked: false,
|
||||
editable: true,
|
||||
editableSectionIds: ['title', 'summary', 'act:act-docks-1:title'],
|
||||
warningMessages: [],
|
||||
}}
|
||||
loading={false}
|
||||
onClose={() => {}}
|
||||
onStartEdit={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('场景章节');
|
||||
expect(html).toContain('第 1 幕背景图');
|
||||
expect(html).toContain('img');
|
||||
});
|
||||
@@ -1,221 +0,0 @@
|
||||
import type { CustomWorldDraftCardDetail } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import { CustomWorldDraftEditPanel } from './CustomWorldDraftEditPanel';
|
||||
|
||||
type CustomWorldAgentDraftDetailPanelProps = {
|
||||
detail: CustomWorldDraftCardDetail | null;
|
||||
loading: boolean;
|
||||
busy?: boolean;
|
||||
editMode?: boolean;
|
||||
onClose: () => void;
|
||||
onStartEdit?: () => void;
|
||||
onCancelEdit?: () => void;
|
||||
onSave?: (
|
||||
sections: Array<{
|
||||
sectionId: string;
|
||||
value: string;
|
||||
}>,
|
||||
) => void;
|
||||
onGenerateCharacter?: () => void;
|
||||
onGenerateLandmark?: () => void;
|
||||
onOpenRoleAssetStudio?: () => void;
|
||||
};
|
||||
|
||||
function resolveKindLabel(kind: CustomWorldDraftCardDetail['kind']) {
|
||||
if (kind === 'world') return '世界总卡';
|
||||
if (kind === 'camp') return '营地';
|
||||
if (kind === 'faction') return '势力';
|
||||
if (kind === 'character') return '角色';
|
||||
if (kind === 'landmark') return '地点';
|
||||
if (kind === 'thread') return '线程';
|
||||
if (kind === 'chapter') return '第一幕';
|
||||
if (kind === 'scene_chapter') return '场景章节';
|
||||
return '草稿卡';
|
||||
}
|
||||
|
||||
function ActionButton(props: {
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
tone?: 'default' | 'sky';
|
||||
}) {
|
||||
const { label, onClick, disabled = false, tone = 'default' } = props;
|
||||
|
||||
if (!onClick) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`rounded-full border px-3 py-1.5 text-[11px] transition ${
|
||||
tone === 'sky'
|
||||
? 'border-sky-300/20 bg-sky-500/10 text-sky-100 hover:text-white'
|
||||
: 'border-white/10 bg-black/20 text-zinc-300 hover:text-white'
|
||||
} disabled:cursor-not-allowed disabled:opacity-45`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomWorldAgentDraftDetailPanel({
|
||||
detail,
|
||||
loading,
|
||||
busy = false,
|
||||
editMode = false,
|
||||
onClose,
|
||||
onStartEdit,
|
||||
onCancelEdit,
|
||||
onSave,
|
||||
onGenerateCharacter,
|
||||
onGenerateLandmark,
|
||||
onOpenRoleAssetStudio,
|
||||
}: CustomWorldAgentDraftDetailPanelProps) {
|
||||
const shouldRenderImagePreview = (
|
||||
detailKind: CustomWorldDraftCardDetail['kind'],
|
||||
sectionId: string,
|
||||
value: string,
|
||||
) =>
|
||||
detailKind === 'scene_chapter' &&
|
||||
sectionId.endsWith(':backgroundImageSrc') &&
|
||||
value !== '待继续精修';
|
||||
|
||||
return (
|
||||
<section className="platform-remap-surface rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
卡片详情
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">
|
||||
{loading ? '正在读取' : detail?.title || '选择一张草稿卡'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[11px] text-zinc-300 transition hover:text-white"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="mt-4 rounded-[1.15rem] border border-white/8 bg-white/5 px-4 py-5 text-sm leading-7 text-zinc-300">
|
||||
正在整理这张卡的内容。
|
||||
</div>
|
||||
) : detail ? (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full border border-sky-300/20 bg-sky-500/10 px-3 py-1 text-[11px] text-sky-100">
|
||||
{resolveKindLabel(detail.kind)}
|
||||
</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[11px] text-zinc-300">
|
||||
关联 {detail.linkedIds.length}
|
||||
</span>
|
||||
{detail.editable ? (
|
||||
<span className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-3 py-1 text-[11px] text-emerald-100">
|
||||
可编辑
|
||||
</span>
|
||||
) : null}
|
||||
{detail.kind === 'character' && detail.assetStatusLabel ? (
|
||||
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[11px] text-amber-100">
|
||||
{detail.assetStatusLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{!editMode && detail.editable ? (
|
||||
<ActionButton
|
||||
label="编辑设定"
|
||||
onClick={onStartEdit}
|
||||
disabled={busy}
|
||||
/>
|
||||
) : null}
|
||||
{!editMode && detail.kind === 'character' ? (
|
||||
<ActionButton
|
||||
label="角色资产"
|
||||
onClick={onOpenRoleAssetStudio}
|
||||
disabled={busy}
|
||||
tone="sky"
|
||||
/>
|
||||
) : null}
|
||||
{!editMode ? (
|
||||
<>
|
||||
<ActionButton
|
||||
label="新增角色"
|
||||
onClick={onGenerateCharacter}
|
||||
disabled={busy}
|
||||
tone="sky"
|
||||
/>
|
||||
<ActionButton
|
||||
label="新增场景"
|
||||
onClick={onGenerateLandmark}
|
||||
disabled={busy}
|
||||
tone="sky"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{editMode && onSave && onCancelEdit ? (
|
||||
<CustomWorldDraftEditPanel
|
||||
detail={detail}
|
||||
disabled={busy}
|
||||
onSave={onSave}
|
||||
onCancel={onCancelEdit}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{detail.sections.map((section) => (
|
||||
<div
|
||||
key={section.id}
|
||||
className="rounded-[1.1rem] border border-white/8 bg-white/5 px-3 py-3"
|
||||
>
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-400">
|
||||
{section.label}
|
||||
</div>
|
||||
{shouldRenderImagePreview(detail.kind, section.id, section.value) ? (
|
||||
<img
|
||||
src={section.value}
|
||||
alt={section.label}
|
||||
className="mt-3 h-40 w-full rounded-[1rem] border border-white/10 object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<div className="mt-2 whitespace-pre-wrap text-sm leading-7 text-zinc-100">
|
||||
{section.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.warningMessages.length > 0 ? (
|
||||
<div className="rounded-[1.15rem] border border-amber-300/20 bg-amber-500/10 px-4 py-4">
|
||||
<div className="text-[11px] font-bold tracking-[0.18em] text-amber-100">
|
||||
继续精修
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{detail.warningMessages.map((message, index) => (
|
||||
<div
|
||||
key={`${detail.id}-warning-${index}`}
|
||||
className="text-sm leading-7 text-amber-50"
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 rounded-[1.15rem] border border-dashed border-white/10 bg-black/22 px-4 py-5 text-sm leading-7 text-zinc-400">
|
||||
从草稿抽屉里点开一张卡,就能在这里看世界底稿的具体内容。
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import type { CustomWorldDraftCardSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
|
||||
type CustomWorldAgentDraftDrawerProps = {
|
||||
draftCards: CustomWorldDraftCardSummary[];
|
||||
activeCardId?: string | null;
|
||||
onSelectCard: (cardId: string) => void;
|
||||
};
|
||||
|
||||
const DRAWER_KIND_ORDER: CustomWorldDraftCardSummary['kind'][] = [
|
||||
'world',
|
||||
'chapter',
|
||||
'scene_chapter',
|
||||
'thread',
|
||||
'faction',
|
||||
'character',
|
||||
'landmark',
|
||||
'camp',
|
||||
];
|
||||
|
||||
function resolveGroupLabel(kind: CustomWorldDraftCardSummary['kind']) {
|
||||
if (kind === 'world') return '世界总卡';
|
||||
if (kind === 'chapter') return '第一幕';
|
||||
if (kind === 'scene_chapter') return '场景章节';
|
||||
if (kind === 'thread') return '世界线程';
|
||||
if (kind === 'faction') return '势力';
|
||||
if (kind === 'character') return '关键角色';
|
||||
if (kind === 'landmark') return '关键地点';
|
||||
if (kind === 'camp') return '营地';
|
||||
return '草稿卡';
|
||||
}
|
||||
|
||||
export function CustomWorldAgentDraftDrawer({
|
||||
draftCards,
|
||||
activeCardId,
|
||||
onSelectCard,
|
||||
}: CustomWorldAgentDraftDrawerProps) {
|
||||
const groupedCards = DRAWER_KIND_ORDER.map((kind) => ({
|
||||
kind,
|
||||
items: draftCards.filter((card) => card.kind === kind),
|
||||
})).filter((group) => group.items.length > 0);
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
草稿抽屉
|
||||
</div>
|
||||
{groupedCards.length > 0 ? (
|
||||
<div className="mt-3 space-y-4">
|
||||
{groupedCards.map((group) => (
|
||||
<section key={group.kind}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-[11px] tracking-[0.18em] text-zinc-400">
|
||||
{resolveGroupLabel(group.kind)}
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-500">
|
||||
{group.items.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{group.items.map((card, index) => {
|
||||
const isActive = activeCardId === card.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={card.id || `${group.kind}-card-${index}`}
|
||||
type="button"
|
||||
onClick={() => onSelectCard(card.id)}
|
||||
className={`w-full rounded-[1.2rem] border px-3 py-3 text-left transition ${
|
||||
isActive
|
||||
? 'border-sky-300/30 bg-sky-500/10'
|
||||
: 'border-white/8 bg-white/5 hover:border-white/14'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{card.title}
|
||||
</div>
|
||||
{card.warningCount > 0 ? (
|
||||
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-2 py-0.5 text-[10px] text-amber-100">
|
||||
{card.warningCount}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-zinc-400">
|
||||
{card.subtitle}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-zinc-300">
|
||||
{card.summary}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<span className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-200">
|
||||
关联 {card.linkedIds.length}
|
||||
</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-200">
|
||||
{card.status === 'warning' ? '待精修' : '建议稿'}
|
||||
</span>
|
||||
{card.kind === 'character' && card.assetStatusLabel ? (
|
||||
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
|
||||
{card.assetStatusLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 text-sm leading-7 text-zinc-400">
|
||||
当前设定收束后,世界底稿会先从这里长出来。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { CustomWorldAgentIntentSummaryPanel } from './CustomWorldAgentIntentSummaryPanel';
|
||||
|
||||
test('intent summary panel shows collected custom world anchors', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldAgentIntentSummaryPanel
|
||||
creatorIntent={{
|
||||
sourceMode: 'freeform',
|
||||
rawSettingText: '',
|
||||
worldHook: '一个被潮雾切开的列岛世界。',
|
||||
themeKeywords: ['海岛'],
|
||||
toneDirectives: ['冷峻'],
|
||||
playerPremise: '玩家是失职返乡的守灯人。',
|
||||
openingSituation: '开局站在即将熄灭的旧灯塔上。',
|
||||
coreConflicts: ['守灯会与沉船商盟争夺航道解释权'],
|
||||
keyCharacters: [],
|
||||
iconicElements: ['潮雾钟声'],
|
||||
}}
|
||||
readiness={{
|
||||
isReady: false,
|
||||
completedKeys: [
|
||||
'world_hook',
|
||||
'player_premise',
|
||||
'theme_and_tone',
|
||||
'core_conflict',
|
||||
'iconic_element',
|
||||
],
|
||||
missingKeys: ['relationship_seed'],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('已收集设定');
|
||||
expect(html).toContain('世界一句话');
|
||||
expect(html).toContain('一个被潮雾切开的列岛世界');
|
||||
expect(html).toContain('守灯会与沉船商盟争夺航道解释权');
|
||||
expect(html).toContain('5/6');
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
import type { CreatorIntentReadiness } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import {
|
||||
evaluateCustomWorldCreatorIntentReadiness,
|
||||
hasMeaningfulCustomWorldCreatorIntent,
|
||||
normalizeCustomWorldCreatorIntent,
|
||||
} from '../../services/customWorldCreatorIntent';
|
||||
|
||||
type CustomWorldAgentIntentSummaryPanelProps = {
|
||||
creatorIntent: Record<string, unknown> | null;
|
||||
readiness: CreatorIntentReadiness;
|
||||
};
|
||||
|
||||
export function CustomWorldAgentIntentSummaryPanel({
|
||||
creatorIntent,
|
||||
readiness,
|
||||
}: CustomWorldAgentIntentSummaryPanelProps) {
|
||||
const intent = normalizeCustomWorldCreatorIntent(creatorIntent);
|
||||
const resolvedReadiness =
|
||||
readiness ?? evaluateCustomWorldCreatorIntentReadiness(intent);
|
||||
const items = [
|
||||
{
|
||||
label: '世界一句话',
|
||||
value: intent?.worldHook || '',
|
||||
ready: resolvedReadiness.completedKeys.includes('world_hook'),
|
||||
},
|
||||
{
|
||||
label: '玩家身份',
|
||||
value: intent?.playerPremise || '',
|
||||
ready: Boolean(intent?.playerPremise),
|
||||
},
|
||||
{
|
||||
label: '开局处境',
|
||||
value: intent?.openingSituation || '',
|
||||
ready: Boolean(intent?.openingSituation),
|
||||
},
|
||||
{
|
||||
label: '核心冲突',
|
||||
value: intent?.coreConflicts.join('、') || '',
|
||||
ready: resolvedReadiness.completedKeys.includes('core_conflict'),
|
||||
},
|
||||
{
|
||||
label: '主题气质',
|
||||
value:
|
||||
[...(intent?.themeKeywords ?? []), ...(intent?.toneDirectives ?? [])]
|
||||
.filter(Boolean)
|
||||
.join('、') || '',
|
||||
ready: resolvedReadiness.completedKeys.includes('theme_and_tone'),
|
||||
},
|
||||
{
|
||||
label: '标志性要素',
|
||||
value: intent?.iconicElements.join('、') || '',
|
||||
ready: resolvedReadiness.completedKeys.includes('iconic_element'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="platform-remap-surface rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
已收集设定
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">
|
||||
{resolvedReadiness.isReady ? '创作输入已齐备' : '继续收世界骨架'}
|
||||
</div>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[11px] text-zinc-300">
|
||||
{resolvedReadiness.completedKeys.length}/6
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{hasMeaningfulCustomWorldCreatorIntent(intent) ? (
|
||||
<div className="mt-4 grid gap-2 sm:grid-cols-2">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className={`rounded-[1.15rem] border px-3 py-3 ${
|
||||
item.ready
|
||||
? 'border-emerald-300/18 bg-emerald-500/8'
|
||||
: 'border-white/8 bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-400">
|
||||
{item.label}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-zinc-100">
|
||||
{item.value || '待补充'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 rounded-[1.15rem] border border-dashed border-white/10 bg-black/22 px-4 py-5 text-sm text-zinc-400">
|
||||
还在收集你的世界设定
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
type CustomWorldAgentLauncherModalProps = {
|
||||
isOpen: boolean;
|
||||
seedText: string;
|
||||
isBusy: boolean;
|
||||
error: string | null;
|
||||
onClose: () => void;
|
||||
onSeedTextChange: (value: string) => void;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
export function CustomWorldAgentLauncherModal({
|
||||
isOpen,
|
||||
seedText,
|
||||
isBusy,
|
||||
error,
|
||||
onClose,
|
||||
onSeedTextChange,
|
||||
onConfirm,
|
||||
}: CustomWorldAgentLauncherModalProps) {
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="platform-overlay fixed inset-0 z-[90] flex items-center justify-center p-4 backdrop-blur-sm">
|
||||
<div className="platform-modal-shell platform-remap-surface flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden rounded-[2rem] shadow-[0_30px_90px_rgba(0,0,0,0.6)]">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-white">
|
||||
开始和 Agent 共创
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">
|
||||
输入一段种子灵感,先进入新的工作区。
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isBusy}
|
||||
className="rounded-full border border-white/10 bg-white/5 p-2 text-zinc-300 transition hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
|
||||
<label className="block">
|
||||
<div className="mb-2 text-sm font-medium text-zinc-200">
|
||||
Seed Text
|
||||
</div>
|
||||
<textarea
|
||||
value={seedText}
|
||||
onChange={(event) => onSeedTextChange(event.target.value)}
|
||||
rows={7}
|
||||
placeholder="例:一个被潮雾切碎的列岛世界,灯塔守望者、沉船秘术与旧盟约残片正在重新苏醒。"
|
||||
className="w-full rounded-3xl border border-white/10 bg-black/30 px-4 py-3 text-sm leading-7 text-white outline-none transition focus:border-sky-400/40"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error ? (
|
||||
<div className="mt-4 rounded-3xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-3 border-t border-white/10 px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isBusy}
|
||||
className="rounded-2xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-300 transition hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={isBusy}
|
||||
className="rounded-2xl bg-sky-400 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:bg-sky-300 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
{isBusy ? '处理中...' : '开始共创'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
type CustomWorldAgentLockBarProps = {
|
||||
lockState: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
function readLockedItems(lockState: Record<string, unknown> | null) {
|
||||
if (!lockState) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...new Set(
|
||||
Object.entries(lockState)
|
||||
.flatMap(([key, value]) =>
|
||||
Array.isArray(value)
|
||||
? value
|
||||
.map((item) => String(item).trim())
|
||||
.filter(Boolean)
|
||||
.map((item) => `${key}:${item}`)
|
||||
: typeof value === 'string' && value.trim()
|
||||
? [`${key}:${value.trim()}`]
|
||||
: [],
|
||||
)
|
||||
)].slice(0, 8);
|
||||
}
|
||||
|
||||
export function CustomWorldAgentLockBar({
|
||||
lockState,
|
||||
}: CustomWorldAgentLockBarProps) {
|
||||
const lockedItems = readLockedItems(lockState);
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
锁定内容
|
||||
</div>
|
||||
{lockedItems.length > 0 ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{lockedItems.map((item, index) => (
|
||||
<span
|
||||
key={`locked-item-${index}-${item}`}
|
||||
className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[11px] text-amber-100"
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 text-sm leading-7 text-zinc-400">
|
||||
暂未锁定内容
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import type { CustomWorldSuggestedAction } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
|
||||
type CustomWorldAgentQuickActionsProps = {
|
||||
suggestedActions: CustomWorldSuggestedAction[];
|
||||
disabled: boolean;
|
||||
canDraftFoundation: boolean;
|
||||
showEntityActions?: boolean;
|
||||
showSummaryAction?: boolean;
|
||||
onRequestSummary: () => void;
|
||||
onDraftFoundation: () => void;
|
||||
onGenerateCharacter?: () => void;
|
||||
onGenerateLandmark?: () => void;
|
||||
onGenerateRoleAssets?: () => void;
|
||||
showRoleAssetAction?: boolean;
|
||||
onFocusSuggestedAction: (action?: CustomWorldSuggestedAction) => void;
|
||||
};
|
||||
|
||||
function QuickActionButton(props: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled: boolean;
|
||||
tone?: 'default' | 'sky' | 'amber';
|
||||
}) {
|
||||
const { label, onClick, disabled, tone = 'default' } = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`rounded-[1.1rem] border px-4 py-3 text-left text-sm transition disabled:cursor-not-allowed disabled:opacity-45 ${
|
||||
tone === 'amber'
|
||||
? 'border-amber-300/20 bg-amber-500/10 text-amber-100 hover:text-white'
|
||||
: tone === 'sky'
|
||||
? 'border-sky-300/20 bg-sky-500/10 text-sky-100 hover:text-white'
|
||||
: 'border-white/10 bg-black/20 text-zinc-200 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomWorldAgentQuickActions({
|
||||
suggestedActions,
|
||||
disabled,
|
||||
canDraftFoundation,
|
||||
showEntityActions = false,
|
||||
showSummaryAction = true,
|
||||
onRequestSummary,
|
||||
onDraftFoundation,
|
||||
onGenerateCharacter,
|
||||
onGenerateLandmark,
|
||||
onGenerateRoleAssets,
|
||||
showRoleAssetAction = false,
|
||||
onFocusSuggestedAction,
|
||||
}: CustomWorldAgentQuickActionsProps) {
|
||||
const summaryAction = suggestedActions.find(
|
||||
(action) => action.type === 'request_summary',
|
||||
);
|
||||
const draftAction = suggestedActions.find(
|
||||
(action) => action.type === 'draft_foundation',
|
||||
);
|
||||
const refinementActions = suggestedActions.filter(
|
||||
(action) =>
|
||||
action.type !== 'request_summary' && action.type !== 'draft_foundation',
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
快捷动作
|
||||
</div>
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{showSummaryAction ? (
|
||||
<QuickActionButton
|
||||
label={summaryAction?.label ?? '总结当前设定'}
|
||||
onClick={onRequestSummary}
|
||||
disabled={disabled}
|
||||
tone="sky"
|
||||
/>
|
||||
) : null}
|
||||
{draftAction && canDraftFoundation ? (
|
||||
<QuickActionButton
|
||||
label={draftAction.label}
|
||||
onClick={onDraftFoundation}
|
||||
disabled={disabled}
|
||||
tone="amber"
|
||||
/>
|
||||
) : null}
|
||||
{showEntityActions && onGenerateCharacter ? (
|
||||
<QuickActionButton
|
||||
label="新增角色"
|
||||
onClick={onGenerateCharacter}
|
||||
disabled={disabled}
|
||||
/>
|
||||
) : null}
|
||||
{showEntityActions && onGenerateLandmark ? (
|
||||
<QuickActionButton
|
||||
label="新增场景"
|
||||
onClick={onGenerateLandmark}
|
||||
disabled={disabled}
|
||||
/>
|
||||
) : null}
|
||||
{showRoleAssetAction && onGenerateRoleAssets ? (
|
||||
<QuickActionButton
|
||||
label="生成角色主图与动作"
|
||||
onClick={onGenerateRoleAssets}
|
||||
disabled={disabled}
|
||||
tone="amber"
|
||||
/>
|
||||
) : null}
|
||||
{refinementActions.length > 0 ? (
|
||||
refinementActions.slice(0, 2).map((action) => (
|
||||
<QuickActionButton
|
||||
key={action.id}
|
||||
label={action.label}
|
||||
onClick={() => onFocusSuggestedAction(action)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))
|
||||
) : !draftAction || !canDraftFoundation ? (
|
||||
<QuickActionButton
|
||||
label={showEntityActions ? '继续精修当前草稿' : '继续补充设定'}
|
||||
onClick={() => onFocusSuggestedAction()}
|
||||
disabled={disabled}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
|
||||
type CustomWorldAgentSummaryPanelProps = {
|
||||
session: CustomWorldAgentSessionSnapshot;
|
||||
};
|
||||
|
||||
function readSummaryText(
|
||||
draftProfile: Record<string, unknown> | null,
|
||||
fallback: string,
|
||||
) {
|
||||
const title =
|
||||
typeof draftProfile?.title === 'string' ? draftProfile.title.trim() : '';
|
||||
const summary =
|
||||
typeof draftProfile?.summary === 'string'
|
||||
? draftProfile.summary.trim()
|
||||
: '';
|
||||
|
||||
return {
|
||||
title: title || '世界摘要待整理',
|
||||
summary: summary || fallback,
|
||||
};
|
||||
}
|
||||
|
||||
export function CustomWorldAgentSummaryPanel({
|
||||
session,
|
||||
}: CustomWorldAgentSummaryPanelProps) {
|
||||
const pendingCount = session.pendingClarifications.length;
|
||||
const { title, summary } = readSummaryText(
|
||||
session.draftProfile,
|
||||
'第一阶段先收住世界设定,后续阶段再把这里整理成更完整的世界底稿摘要。',
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface rounded-[1.75rem] border border-white/10 bg-black/20 px-4 py-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
顶部摘要
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[11px] text-zinc-300">
|
||||
消息 {session.messages.length}
|
||||
</span>
|
||||
<span className="rounded-full border border-sky-300/20 bg-sky-500/10 px-3 py-1 text-[11px] text-sky-100">
|
||||
待澄清 {pendingCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-sm leading-7 text-zinc-300">
|
||||
{summary}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import type { CustomWorldDraftCardDetail } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import { CustomWorldAgentDraftDetailPanel } from './CustomWorldAgentDraftDetailPanel';
|
||||
|
||||
type CustomWorldDraftCardDetailModalProps = {
|
||||
open: boolean;
|
||||
detail: CustomWorldDraftCardDetail | null;
|
||||
loading: boolean;
|
||||
busy?: boolean;
|
||||
editMode?: boolean;
|
||||
onClose: () => void;
|
||||
onStartEdit?: () => void;
|
||||
onCancelEdit?: () => void;
|
||||
onSave?: (
|
||||
sections: Array<{
|
||||
sectionId: string;
|
||||
value: string;
|
||||
}>,
|
||||
) => void;
|
||||
onGenerateCharacter?: () => void;
|
||||
onGenerateLandmark?: () => void;
|
||||
onOpenRoleAssetStudio?: () => void;
|
||||
};
|
||||
|
||||
export function CustomWorldDraftCardDetailModal({
|
||||
open,
|
||||
detail,
|
||||
loading,
|
||||
busy = false,
|
||||
editMode = false,
|
||||
onClose,
|
||||
onStartEdit,
|
||||
onCancelEdit,
|
||||
onSave,
|
||||
onGenerateCharacter,
|
||||
onGenerateLandmark,
|
||||
onOpenRoleAssetStudio,
|
||||
}: CustomWorldDraftCardDetailModalProps) {
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="platform-overlay fixed inset-0 z-[95] flex items-end justify-center p-3 backdrop-blur-sm xl:hidden">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭卡片详情"
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 cursor-default"
|
||||
/>
|
||||
<div className="relative z-10 max-h-[85vh] w-full max-w-2xl overflow-y-auto">
|
||||
<CustomWorldAgentDraftDetailPanel
|
||||
detail={detail}
|
||||
loading={loading}
|
||||
busy={busy}
|
||||
editMode={editMode}
|
||||
onClose={onClose}
|
||||
onStartEdit={onStartEdit}
|
||||
onCancelEdit={onCancelEdit}
|
||||
onSave={onSave}
|
||||
onGenerateCharacter={onGenerateCharacter}
|
||||
onGenerateLandmark={onGenerateLandmark}
|
||||
onOpenRoleAssetStudio={onOpenRoleAssetStudio}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { CustomWorldAgentDraftDetailPanel } from './CustomWorldAgentDraftDetailPanel';
|
||||
|
||||
test('draft detail panel renders editable form in edit mode', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldAgentDraftDetailPanel
|
||||
detail={{
|
||||
id: 'character-1',
|
||||
kind: 'character',
|
||||
title: '沈砺',
|
||||
sections: [
|
||||
{
|
||||
id: 'name',
|
||||
label: '角色名',
|
||||
value: '沈砺',
|
||||
},
|
||||
{
|
||||
id: 'publicMask',
|
||||
label: '外显身份',
|
||||
value: '守灯会里最熟悉旧航道的人。',
|
||||
},
|
||||
{
|
||||
id: 'summary',
|
||||
label: '角色摘要',
|
||||
value: '他像旧友,但也像一把始终没收回鞘的刀。',
|
||||
},
|
||||
],
|
||||
linkedIds: ['thread-1'],
|
||||
locked: false,
|
||||
editable: true,
|
||||
editableSectionIds: ['name', 'publicMask', 'summary'],
|
||||
warningMessages: [],
|
||||
}}
|
||||
loading={false}
|
||||
editMode
|
||||
onClose={() => {}}
|
||||
onCancelEdit={() => {}}
|
||||
onSave={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('保存');
|
||||
expect(html).toContain('取消');
|
||||
expect(html).toContain('角色名');
|
||||
expect(html).toContain('textarea');
|
||||
});
|
||||
|
||||
test('draft detail panel uses textarea for scene chapter act narrative fields', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldAgentDraftDetailPanel
|
||||
detail={{
|
||||
id: 'scene-chapter-docks',
|
||||
kind: 'scene_chapter',
|
||||
title: '潮汐码头章节',
|
||||
sections: [
|
||||
{
|
||||
id: 'title',
|
||||
label: '场景章节标题',
|
||||
value: '潮汐码头章节',
|
||||
},
|
||||
{
|
||||
id: 'act:act-docks-1:summary',
|
||||
label: '第 1 幕摘要',
|
||||
value: '玩家刚抵达时,林潮先决定要不要放行。',
|
||||
},
|
||||
{
|
||||
id: 'act:act-docks-1:encounterNpcIds',
|
||||
label: '第 1 幕相遇 NPC',
|
||||
value: '林潮\n晏九',
|
||||
},
|
||||
{
|
||||
id: 'act:act-docks-1:transitionHook',
|
||||
label: '第 1 幕过渡钩子',
|
||||
value: '确认站位后,真正的封锁者会压上来。',
|
||||
},
|
||||
],
|
||||
linkedIds: ['thread-smuggling'],
|
||||
locked: false,
|
||||
editable: true,
|
||||
editableSectionIds: [
|
||||
'title',
|
||||
'act:act-docks-1:summary',
|
||||
'act:act-docks-1:encounterNpcIds',
|
||||
'act:act-docks-1:transitionHook',
|
||||
],
|
||||
warningMessages: [],
|
||||
}}
|
||||
loading={false}
|
||||
editMode
|
||||
onClose={() => {}}
|
||||
onCancelEdit={() => {}}
|
||||
onSave={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('第 1 幕摘要');
|
||||
expect(html).toContain('第 1 幕相遇 NPC');
|
||||
expect(html).toContain('第 1 幕过渡钩子');
|
||||
expect(html).toContain('textarea');
|
||||
});
|
||||
@@ -1,141 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { CustomWorldDraftCardDetail } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
|
||||
type CustomWorldDraftEditPanelProps = {
|
||||
detail: CustomWorldDraftCardDetail;
|
||||
disabled?: boolean;
|
||||
onSave: (
|
||||
sections: Array<{
|
||||
sectionId: string;
|
||||
value: string;
|
||||
}>,
|
||||
) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
function shouldUseTextarea(sectionId: string, value: string) {
|
||||
const sceneActField = sectionId.match(/^act:[^:]+:(.+)$/u)?.[1] ?? null;
|
||||
return (
|
||||
value.length > 28 ||
|
||||
value.includes('\n') ||
|
||||
sectionId === 'summary' ||
|
||||
sectionId === 'tone' ||
|
||||
sectionId === 'coreConflicts' ||
|
||||
sectionId === 'hiddenHook' ||
|
||||
sectionId === 'secret' ||
|
||||
sectionId === 'stakes' ||
|
||||
sectionId === 'openingEvent' ||
|
||||
sectionId === 'understandingShift' ||
|
||||
sectionId === 'description' ||
|
||||
sceneActField === 'summary' ||
|
||||
sceneActField === 'encounterNpcIds' ||
|
||||
sceneActField === 'actGoal' ||
|
||||
sceneActField === 'transitionHook'
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomWorldDraftEditPanel({
|
||||
detail,
|
||||
disabled = false,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: CustomWorldDraftEditPanelProps) {
|
||||
const editableSections = useMemo(
|
||||
() =>
|
||||
detail.sections.filter((section) =>
|
||||
detail.editableSectionIds.includes(section.id),
|
||||
),
|
||||
[detail],
|
||||
);
|
||||
const [draftValues, setDraftValues] = useState<Record<string, string>>(() =>
|
||||
Object.fromEntries(
|
||||
editableSections.map((section) => [section.id, section.value]),
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setDraftValues(
|
||||
Object.fromEntries(editableSections.map((section) => [section.id, section.value])),
|
||||
);
|
||||
}, [editableSections]);
|
||||
|
||||
if (editableSections.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{editableSections.map((section) => {
|
||||
const value = draftValues[section.id] ?? '';
|
||||
const multiline = shouldUseTextarea(section.id, value);
|
||||
|
||||
return (
|
||||
<label
|
||||
key={section.id}
|
||||
className="block rounded-[1.1rem] border border-white/8 bg-white/5 px-3 py-3"
|
||||
>
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-400">
|
||||
{section.label}
|
||||
</div>
|
||||
{multiline ? (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setDraftValues((current) => ({
|
||||
...current,
|
||||
[section.id]: nextValue,
|
||||
}));
|
||||
}}
|
||||
rows={4}
|
||||
disabled={disabled}
|
||||
className="mt-2 w-full resize-none rounded-[0.9rem] border border-white/10 bg-black/26 px-3 py-3 text-sm leading-7 text-white outline-none transition focus:border-sky-400/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setDraftValues((current) => ({
|
||||
...current,
|
||||
[section.id]: nextValue,
|
||||
}));
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="mt-2 h-11 w-full rounded-[0.9rem] border border-white/10 bg-black/26 px-3 text-sm text-white outline-none transition focus:border-sky-400/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={disabled}
|
||||
className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-200 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSave(
|
||||
editableSections.map((section) => ({
|
||||
sectionId: section.id,
|
||||
value: draftValues[section.id] ?? '',
|
||||
})),
|
||||
);
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="rounded-full border border-sky-300/20 bg-sky-500/10 px-4 py-2 text-sm font-medium text-sky-100 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type CustomWorldGenerateEntityModalProps = {
|
||||
open: boolean;
|
||||
mode: 'character' | 'landmark';
|
||||
anchorCardTitle?: string | null;
|
||||
disabled?: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (payload: {
|
||||
count: number;
|
||||
promptText: string;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
export function CustomWorldGenerateEntityModal({
|
||||
open,
|
||||
mode,
|
||||
anchorCardTitle,
|
||||
disabled = false,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: CustomWorldGenerateEntityModalProps) {
|
||||
const [count, setCount] = useState(2);
|
||||
const [promptText, setPromptText] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCount(2);
|
||||
setPromptText('');
|
||||
}, [open, mode]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = mode === 'character' ? '新增角色' : '新增场景';
|
||||
const submitLabel = mode === 'character' ? '生成角色' : '生成场景';
|
||||
|
||||
return (
|
||||
<div className="platform-overlay fixed inset-0 z-[96] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭新增弹窗"
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 cursor-default"
|
||||
/>
|
||||
<div className="platform-modal-shell platform-remap-surface relative z-10 w-full max-w-xl rounded-[1.8rem] px-4 py-4 shadow-[0_18px_60px_rgba(0,0,0,0.35)] sm:px-5 sm:py-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
AI 扩写
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">{title}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={disabled}
|
||||
className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[11px] text-zinc-300 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
{anchorCardTitle ? (
|
||||
<div className="rounded-[1.1rem] border border-white/8 bg-white/5 px-3 py-3">
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-400">
|
||||
当前参考卡
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-zinc-100">{anchorCardTitle}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-[1.1rem] border border-white/8 bg-white/5 px-3 py-3">
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-400">数量</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
{[1, 2, 3].map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setCount(value)}
|
||||
disabled={disabled}
|
||||
className={`rounded-full border px-4 py-2 text-sm transition ${
|
||||
count === value
|
||||
? 'border-sky-300/20 bg-sky-500/10 text-sky-100'
|
||||
: 'border-white/10 bg-black/20 text-zinc-300 hover:text-white'
|
||||
} disabled:cursor-not-allowed disabled:opacity-45`}
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="block rounded-[1.1rem] border border-white/8 bg-white/5 px-3 py-3">
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-400">
|
||||
补充要求
|
||||
</div>
|
||||
<textarea
|
||||
value={promptText}
|
||||
onChange={(event) => setPromptText(event.target.value)}
|
||||
rows={5}
|
||||
disabled={disabled}
|
||||
className="mt-2 w-full resize-none rounded-[0.9rem] border border-white/10 bg-black/26 px-3 py-3 text-sm leading-7 text-white outline-none transition focus:border-sky-400/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={disabled}
|
||||
className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-200 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSubmit({
|
||||
count,
|
||||
promptText: promptText.trim(),
|
||||
});
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="rounded-full border border-sky-300/20 bg-sky-500/10 px-4 py-2 text-sm font-medium text-sky-100 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
{submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,8 +21,10 @@ type CustomWorldCreationHubProps = {
|
||||
|
||||
function EmptyState({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="platform-remap-surface flex min-h-[16rem] flex-col items-center justify-center rounded-[1.8rem] border border-white/8 bg-white/5 px-6 py-8 text-center">
|
||||
<div className="text-lg font-semibold text-white">{title}</div>
|
||||
<div className="platform-subpanel flex min-h-[14rem] flex-col items-center justify-center rounded-[1.6rem] px-6 py-8 text-center">
|
||||
<div className="text-lg font-semibold text-[var(--platform-text-strong)]">
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -52,35 +54,26 @@ export function CustomWorldCreationHub({
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full min-h-0 flex-col overflow-y-auto overscroll-y-contain pr-1 pb-[max(1rem,env(safe-area-inset-bottom))]"
|
||||
style={{ WebkitOverflowScrolling: 'touch' }}
|
||||
>
|
||||
<div
|
||||
className="platform-remap-surface sticky top-0 z-20 -mx-3 px-3 pb-4 pt-1 sm:static sm:mx-0 sm:bg-none sm:px-0 sm:pb-5 sm:pt-0"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(180deg, var(--platform-modal-fill), transparent)',
|
||||
}}
|
||||
>
|
||||
<div className="platform-page-stage platform-remap-surface space-y-4 px-3 pb-4 pt-3 sm:px-4 sm:pt-4">
|
||||
<div className="pb-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="rounded-full border border-white/10 bg-black/18 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white"
|
||||
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
<div className="mt-4 text-[1.8rem] font-black leading-tight text-white sm:text-[2.3rem]">
|
||||
<div className="mt-4 text-[1.8rem] font-black leading-tight text-[var(--platform-text-strong)] sm:text-[2.3rem]">
|
||||
创作中心
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden shrink-0 gap-2 sm:flex">
|
||||
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[11px] text-amber-100">
|
||||
<span className="platform-pill platform-pill--warm px-3 py-1 text-[11px]">
|
||||
草稿 {draftCount}
|
||||
</span>
|
||||
<span className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-3 py-1 text-[11px] text-emerald-100">
|
||||
<span className="platform-pill platform-pill--success px-3 py-1 text-[11px]">
|
||||
已发布 {publishedCount}
|
||||
</span>
|
||||
</div>
|
||||
@@ -98,12 +91,12 @@ export function CustomWorldCreationHub({
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-3xl border border-rose-400/18 bg-rose-500/10 px-4 py-4 text-sm leading-7 text-rose-100">
|
||||
<div className="platform-banner platform-banner--danger rounded-[1.4rem] px-4 py-4 text-sm leading-7">
|
||||
<div>{error}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="mt-3 rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-200 transition hover:text-white"
|
||||
className="platform-button platform-button--ghost mt-3 min-h-0 rounded-full px-4 py-2 text-sm"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
@@ -115,15 +108,15 @@ export function CustomWorldCreationHub({
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={`skeleton-${index}`}
|
||||
className="min-h-[12rem] rounded-[1.8rem] border border-white/8 bg-white/5 p-5"
|
||||
className="platform-subpanel min-h-[12rem] rounded-[1.6rem] p-5"
|
||||
>
|
||||
<div className="h-4 w-20 rounded-full bg-white/10" />
|
||||
<div className="mt-6 h-8 w-36 rounded-full bg-white/10" />
|
||||
<div className="mt-4 h-4 w-full rounded-full bg-white/10" />
|
||||
<div className="mt-2 h-4 w-4/5 rounded-full bg-white/10" />
|
||||
<div className="h-4 w-20 rounded-full bg-[var(--platform-track-fill)]" />
|
||||
<div className="mt-6 h-8 w-36 rounded-full bg-[var(--platform-track-fill)]" />
|
||||
<div className="mt-4 h-4 w-full rounded-full bg-[var(--platform-track-fill)]" />
|
||||
<div className="mt-2 h-4 w-4/5 rounded-full bg-[var(--platform-track-fill)]" />
|
||||
<div className="mt-8 flex gap-2">
|
||||
<div className="h-7 w-20 rounded-full bg-white/10" />
|
||||
<div className="h-7 w-20 rounded-full bg-white/10" />
|
||||
<div className="h-7 w-20 rounded-full bg-[var(--platform-track-fill)]" />
|
||||
<div className="h-7 w-20 rounded-full bg-[var(--platform-track-fill)]" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import type { CustomWorldQuestion } from '../../../packages/shared/src/contracts/runtime';
|
||||
|
||||
type CustomWorldCreationLauncherModalProps = {
|
||||
isOpen: boolean;
|
||||
mode: 'create' | 'resume';
|
||||
seedText: string;
|
||||
seedTextLocked: boolean;
|
||||
questions: CustomWorldQuestion[];
|
||||
answers: Record<string, string>;
|
||||
isBusy: boolean;
|
||||
error: string | null;
|
||||
lastError?: string | null;
|
||||
primaryLabel: string;
|
||||
onClose: () => void;
|
||||
onSeedTextChange: (value: string) => void;
|
||||
onAnswerChange: (questionId: string, value: string) => void;
|
||||
onPrimaryAction: () => void;
|
||||
};
|
||||
|
||||
export function CustomWorldCreationLauncherModal({
|
||||
isOpen,
|
||||
mode,
|
||||
seedText,
|
||||
seedTextLocked,
|
||||
questions,
|
||||
answers,
|
||||
isBusy,
|
||||
error,
|
||||
lastError = null,
|
||||
primaryLabel,
|
||||
onClose,
|
||||
onSeedTextChange,
|
||||
onAnswerChange,
|
||||
onPrimaryAction,
|
||||
}: CustomWorldCreationLauncherModalProps) {
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const unansweredQuestions = questions.filter((question) => !question.answer?.trim());
|
||||
|
||||
return (
|
||||
<div className="platform-overlay fixed inset-0 z-[90] flex items-center justify-center p-4 backdrop-blur-sm">
|
||||
<div className="platform-modal-shell platform-remap-surface flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden rounded-[2rem] shadow-[0_30px_90px_rgba(0,0,0,0.6)]">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-white">
|
||||
{mode === 'create' ? '新建作品' : '继续创作'}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">
|
||||
输入一点灵感,开始共创一个新世界。
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isBusy}
|
||||
className="rounded-full border border-white/10 bg-white/5 p-2 text-zinc-300 transition hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
|
||||
<div className="space-y-4">
|
||||
<label className="block">
|
||||
<div className="mb-2 text-sm font-medium text-zinc-200">
|
||||
世界灵感
|
||||
</div>
|
||||
<textarea
|
||||
value={seedText}
|
||||
onChange={(event) => onSeedTextChange(event.target.value)}
|
||||
rows={seedTextLocked ? 4 : 6}
|
||||
readOnly={seedTextLocked}
|
||||
placeholder="例:一个被潮雾切碎的列岛世界,灯塔守望者、沉船秘术与旧盟约残片正在重新苏醒。"
|
||||
className={`w-full rounded-3xl border border-white/10 bg-black/30 px-4 py-3 text-sm leading-7 text-white outline-none transition focus:border-sky-400/40 ${
|
||||
seedTextLocked ? 'cursor-not-allowed opacity-75' : ''
|
||||
}`}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{unansweredQuestions.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-3xl border border-white/10 bg-black/20 px-4 py-3 text-sm leading-7 text-zinc-300">
|
||||
先补齐几条关键锚点,再开始生成。
|
||||
</div>
|
||||
{unansweredQuestions.map((question) => (
|
||||
<label key={question.id} className="block">
|
||||
<div className="mb-2 text-sm font-medium text-zinc-200">
|
||||
{question.label}
|
||||
</div>
|
||||
<div className="mb-2 text-xs leading-6 text-zinc-400">
|
||||
{question.question}
|
||||
</div>
|
||||
<textarea
|
||||
value={answers[question.id] ?? question.answer ?? ''}
|
||||
onChange={(event) =>
|
||||
onAnswerChange(question.id, event.target.value)
|
||||
}
|
||||
rows={3}
|
||||
placeholder="补充一句就可以。"
|
||||
className="w-full rounded-3xl border border-white/10 bg-black/30 px-4 py-3 text-sm leading-7 text-white outline-none transition focus:border-sky-400/40"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{lastError ? (
|
||||
<div className="rounded-3xl border border-amber-400/25 bg-amber-500/10 px-4 py-3 text-sm leading-6 text-amber-100">
|
||||
上次生成未完成:{lastError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-3xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-3 border-t border-white/10 px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isBusy}
|
||||
className="rounded-2xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-300 transition hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPrimaryAction}
|
||||
disabled={isBusy}
|
||||
className="rounded-2xl bg-sky-400 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:bg-sky-300 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
{isBusy ? '处理中...' : primaryLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
||||
|
||||
type CustomWorldCreationStartCardProps = {
|
||||
onCreateNew: () => void;
|
||||
};
|
||||
@@ -8,35 +6,22 @@ export function CustomWorldCreationStartCard({
|
||||
onCreateNew,
|
||||
}: CustomWorldCreationStartCardProps) {
|
||||
return (
|
||||
<div
|
||||
className="pixel-nine-slice pixel-panel overflow-hidden"
|
||||
style={getNineSliceStyle(UI_CHROME.storyPanel, {
|
||||
paddingX: 18,
|
||||
paddingY: 16,
|
||||
})}
|
||||
>
|
||||
<div className="relative overflow-hidden rounded-[1.75rem] border border-white/8 bg-[radial-gradient(circle_at_top_left,rgba(125,211,252,0.18),transparent_36%),linear-gradient(180deg,rgba(8,10,14,0.28),rgba(8,10,14,0.82))] px-5 py-5">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<div className="text-2xl font-black text-white sm:text-3xl">
|
||||
新建作品
|
||||
</div>
|
||||
<div className="platform-surface platform-surface--hero relative overflow-hidden px-5 py-5">
|
||||
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
|
||||
<div className="relative z-10 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<div className="text-2xl font-black text-white sm:text-3xl">
|
||||
新建作品
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreateNew}
|
||||
className="pixel-nine-slice pixel-pressable w-full text-left sm:w-auto"
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||||
paddingX: 18,
|
||||
paddingY: 11,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-semibold text-white">新建作品</span>
|
||||
<span className="text-white/60">→</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreateNew}
|
||||
className="platform-button platform-button--primary w-full justify-between rounded-[1.1rem] text-left sm:w-auto"
|
||||
>
|
||||
<span className="text-sm font-semibold">新建作品</span>
|
||||
<span aria-hidden="true">→</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
||||
|
||||
function formatUpdatedAt(value: string) {
|
||||
const date = new Date(value);
|
||||
@@ -31,76 +30,71 @@ export function CustomWorldWorkCard({
|
||||
const roleCountLabel = isDraft ? '角色' : '可扮演角色';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pixel-nine-slice pixel-panel relative overflow-hidden"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, {
|
||||
paddingX: 16,
|
||||
paddingY: 15,
|
||||
})}
|
||||
>
|
||||
<div className="platform-surface platform-interactive-card relative min-h-[13.5rem] overflow-hidden px-4 py-4 sm:min-h-[14rem]">
|
||||
<CustomWorldCoverArtwork
|
||||
imageSrc={item.coverImageSrc}
|
||||
title={item.title}
|
||||
fallbackLabel={item.title.slice(0, 4) || '封面'}
|
||||
fallbackLabel="封面"
|
||||
renderMode={item.coverRenderMode}
|
||||
characterImageSrcs={item.coverCharacterImageSrcs}
|
||||
className="absolute inset-0"
|
||||
className="platform-cover-artwork absolute inset-0"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
|
||||
<div className="relative z-10 flex h-full min-h-[12rem] flex-col">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span
|
||||
className={`rounded-full border px-3 py-1 text-[10px] tracking-[0.18em] ${
|
||||
className={`platform-pill px-3 py-1 text-[10px] ${
|
||||
isDraft
|
||||
? 'border-amber-300/20 bg-amber-500/10 text-amber-100'
|
||||
: 'border-emerald-300/20 bg-emerald-500/10 text-emerald-100'
|
||||
? 'platform-pill--warm'
|
||||
: 'platform-pill--success'
|
||||
}`}
|
||||
>
|
||||
{isDraft ? '草稿' : '已发布'}
|
||||
</span>
|
||||
{item.stageLabel ? (
|
||||
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[10px] text-zinc-200">
|
||||
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
|
||||
{item.stageLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-400">
|
||||
<div className="shrink-0 text-[11px] text-[var(--platform-text-soft)]">
|
||||
{formatUpdatedAt(item.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="text-2xl font-black text-white">
|
||||
<div className="text-2xl font-black text-[var(--platform-text-strong)]">
|
||||
{item.title}
|
||||
</div>
|
||||
<div className="mt-1 text-xs tracking-[0.12em] text-zinc-400">
|
||||
<div className="mt-1 text-xs tracking-[0.12em] text-[var(--platform-text-soft)]">
|
||||
{item.subtitle}
|
||||
</div>
|
||||
<div className="mt-3 line-clamp-3 text-sm leading-7 text-zinc-200/90">
|
||||
<div className="mt-3 line-clamp-3 text-sm leading-7 text-[color:color-mix(in_srgb,var(--platform-text-base)_92%,transparent)]">
|
||||
{item.summary}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex items-center justify-between gap-3 pt-4">
|
||||
<div className="mt-auto flex flex-col gap-3 pt-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[10px] text-zinc-100">
|
||||
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
|
||||
{roleCountLabel} {item.playableNpcCount}
|
||||
</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[10px] text-zinc-100">
|
||||
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
|
||||
地点 {item.landmarkCount}
|
||||
</span>
|
||||
{item.roleVisualReadyCount ? (
|
||||
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[10px] text-amber-100">
|
||||
<span className="platform-pill platform-pill--warm px-3 py-1 text-[10px]">
|
||||
主图 {item.roleVisualReadyCount}
|
||||
</span>
|
||||
) : null}
|
||||
{item.roleAnimationReadyCount ? (
|
||||
<span className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-3 py-1 text-[10px] text-emerald-100">
|
||||
<span className="platform-pill platform-pill--success px-3 py-1 text-[10px]">
|
||||
动作 {item.roleAnimationReadyCount}
|
||||
</span>
|
||||
) : null}
|
||||
{item.roleAssetSummaryLabel ? (
|
||||
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[10px] text-zinc-200">
|
||||
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
|
||||
{item.roleAssetSummaryLabel}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -108,7 +102,7 @@ export function CustomWorldWorkCard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="rounded-full border border-sky-300/20 bg-sky-500/10 px-4 py-2 text-sm font-medium text-sky-100 transition hover:text-white"
|
||||
className="platform-button platform-button--primary min-h-0 shrink-0 rounded-full px-4 py-2 text-sm"
|
||||
>
|
||||
{isDraft ? (hasFoundationDraft ? '继续精修' : '继续创作') : '进入世界'}
|
||||
</button>
|
||||
|
||||
@@ -23,7 +23,7 @@ export function CustomWorldWorkTabs({
|
||||
onChange,
|
||||
}: CustomWorldWorkTabsProps) {
|
||||
return (
|
||||
<div className="platform-remap-surface flex items-center gap-2 overflow-x-auto pb-1">
|
||||
<div className="platform-remap-surface flex items-center gap-2 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{FILTER_OPTIONS.map((option) => {
|
||||
const count =
|
||||
option.id === 'draft'
|
||||
@@ -37,10 +37,8 @@ export function CustomWorldWorkTabs({
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => onChange(option.id)}
|
||||
className={`shrink-0 rounded-full border px-4 py-2 text-sm transition ${
|
||||
activeFilter === option.id
|
||||
? 'border-sky-300/20 bg-sky-500/10 text-sky-100'
|
||||
: 'border-white/10 bg-black/18 text-zinc-300 hover:text-white'
|
||||
className={`platform-tab shrink-0 px-4 py-2 text-sm ${
|
||||
activeFilter === option.id ? 'platform-tab--active' : ''
|
||||
}`}
|
||||
>
|
||||
{option.label} {count}
|
||||
|
||||
@@ -19,7 +19,13 @@ import {
|
||||
UserPlus,
|
||||
UserRound,
|
||||
} from 'lucide-react';
|
||||
import { type ComponentType, useMemo } from 'react';
|
||||
import {
|
||||
type ComponentType,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import type {
|
||||
CustomWorldGalleryCard,
|
||||
@@ -52,6 +58,47 @@ const MOBILE_PAGE_STAGE_CLASS =
|
||||
'platform-page-stage platform-remap-surface space-y-4 pb-2';
|
||||
const DESKTOP_PAGE_STAGE_CLASS =
|
||||
'platform-page-stage platform-remap-surface space-y-5 pb-4';
|
||||
const DESKTOP_LAYOUT_QUERY = '(min-width: 1024px)';
|
||||
|
||||
function usePlatformDesktopLayout() {
|
||||
const [isDesktopLayout, setIsDesktopLayout] = useState(() => {
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
typeof window.matchMedia !== 'function'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return window.matchMedia(DESKTOP_LAYOUT_QUERY).matches;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
typeof window.matchMedia !== 'function'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia(DESKTOP_LAYOUT_QUERY);
|
||||
const updateLayout = (event?: MediaQueryListEvent) => {
|
||||
setIsDesktopLayout(event?.matches ?? mediaQuery.matches);
|
||||
};
|
||||
|
||||
updateLayout();
|
||||
|
||||
// 平台页只挂载当前断点外壳,避免隐藏的移动端/桌面端内容重复抢占查询。
|
||||
if (typeof mediaQuery.addEventListener === 'function') {
|
||||
mediaQuery.addEventListener('change', updateLayout);
|
||||
return () => mediaQuery.removeEventListener('change', updateLayout);
|
||||
}
|
||||
|
||||
mediaQuery.addListener(updateLayout);
|
||||
return () => mediaQuery.removeListener(updateLayout);
|
||||
}, []);
|
||||
|
||||
return isDesktopLayout;
|
||||
}
|
||||
|
||||
function SectionHeader({ title, detail }: { title: string; detail: string }) {
|
||||
return (
|
||||
@@ -653,6 +700,7 @@ export function PlatformHomeView({
|
||||
onOpenGalleryDetail,
|
||||
onOpenLibraryDetail,
|
||||
onOpenProfileDashboardCard,
|
||||
createTabContent,
|
||||
}: {
|
||||
activeTab: PlatformHomeTab;
|
||||
onTabChange: (tab: PlatformHomeTab) => void;
|
||||
@@ -679,9 +727,11 @@ export function PlatformHomeView({
|
||||
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
|
||||
) => void;
|
||||
onOpenProfileDashboardCard?: (cardKey: ProfileDashboardCardKey) => void;
|
||||
createTabContent?: ReactNode;
|
||||
}) {
|
||||
const authUi = useAuthUi();
|
||||
const isAuthenticated = Boolean(authUi?.user);
|
||||
const isDesktopLayout = usePlatformDesktopLayout();
|
||||
const featuredShelf = useMemo(
|
||||
() => featuredEntries.slice(0, 6),
|
||||
[featuredEntries],
|
||||
@@ -732,7 +782,7 @@ export function PlatformHomeView({
|
||||
const desktopReleaseGrid = latestEntries.slice(0, 6);
|
||||
const desktopLibraryPreview = myEntries.slice(0, 2);
|
||||
|
||||
let content = (
|
||||
let content: ReactNode = (
|
||||
<div className={MOBILE_PAGE_STAGE_CLASS}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -824,59 +874,62 @@ export function PlatformHomeView({
|
||||
);
|
||||
|
||||
if (activeTab === 'create') {
|
||||
content = (
|
||||
<div className={MOBILE_PAGE_STAGE_CLASS}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenCreateTypePicker}
|
||||
className={`${HERO_SURFACE_CLASS} relative block w-full overflow-hidden px-[18px] py-4 text-left`}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
|
||||
<div className="relative z-10 flex min-h-[10rem] flex-col justify-between">
|
||||
<span className="platform-pill platform-pill--cool w-fit">
|
||||
CREATE
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-3xl font-black text-white">开启新的创作</div>
|
||||
<div className="mt-2 max-w-[28rem] text-sm leading-6 text-zinc-200/88">
|
||||
先选择游戏类型,再进入对应的创作工作台继续推进。
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-2 text-sm font-semibold text-white/90">
|
||||
<span>选择类型并继续</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
content =
|
||||
createTabContent ?? (
|
||||
<div className={MOBILE_PAGE_STAGE_CLASS}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenCreateTypePicker}
|
||||
className={`${HERO_SURFACE_CLASS} relative block w-full overflow-hidden px-[18px] py-4 text-left`}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
|
||||
<div className="relative z-10 flex min-h-[10rem] flex-col justify-between">
|
||||
<span className="platform-pill platform-pill--cool w-fit">
|
||||
CREATE
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-3xl font-black text-white">
|
||||
开启新的创作
|
||||
</div>
|
||||
<div className="mt-2 max-w-[28rem] text-sm leading-6 text-zinc-200/88">
|
||||
先选择游戏类型,再进入对应的创作工作台继续推进。
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-2 text-sm font-semibold text-white/90">
|
||||
<span>选择类型并继续</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</button>
|
||||
|
||||
<section>
|
||||
<SectionHeader title="我的创作" detail="草稿与已发布" />
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取你的作品..." />
|
||||
) : myEntries.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-2.5 sm:gap-3 xl:grid-cols-3">
|
||||
{myEntries.map(
|
||||
(entry: CustomWorldLibraryEntry<CustomWorldProfile>) => (
|
||||
<CreationLibraryCard
|
||||
key={`${entry.ownerUserId}:${entry.profileId}:mine`}
|
||||
entry={entry}
|
||||
onClick={() => onOpenLibraryDetail(entry)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyShelf
|
||||
text={
|
||||
isAuthenticated
|
||||
? '你还没有保存任何自定义世界,先创建一个草稿开始吧。'
|
||||
: '登录后查看你的作品。'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
<section>
|
||||
<SectionHeader title="我的创作" detail="草稿与已发布" />
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取你的作品..." />
|
||||
) : myEntries.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-2.5 sm:gap-3 xl:grid-cols-3">
|
||||
{myEntries.map(
|
||||
(entry: CustomWorldLibraryEntry<CustomWorldProfile>) => (
|
||||
<CreationLibraryCard
|
||||
key={`${entry.ownerUserId}:${entry.profileId}:mine`}
|
||||
entry={entry}
|
||||
onClick={() => onOpenLibraryDetail(entry)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyShelf
|
||||
text={
|
||||
isAuthenticated
|
||||
? '你还没有保存任何自定义世界,先创建一个草稿开始吧。'
|
||||
: '登录后查看你的作品。'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeTab === 'saves') {
|
||||
@@ -1117,7 +1170,7 @@ export function PlatformHomeView({
|
||||
);
|
||||
}
|
||||
|
||||
const desktopContent =
|
||||
const desktopContent: ReactNode =
|
||||
activeTab === 'home' ? (
|
||||
<div className={DESKTOP_PAGE_STAGE_CLASS}>
|
||||
{platformError ? (
|
||||
@@ -1415,9 +1468,9 @@ export function PlatformHomeView({
|
||||
content
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex h-full min-h-0 flex-col lg:hidden">
|
||||
if (!isDesktopLayout) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="mb-4">
|
||||
<PlatformBrandLogo />
|
||||
</div>
|
||||
@@ -1461,8 +1514,12 @@ export function PlatformHomeView({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<div className="hidden h-full min-h-0 lg:flex lg:flex-col">
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="platform-desktop-shell flex h-full min-h-0 flex-col p-5 xl:p-6">
|
||||
<div className="platform-desktop-topbar flex items-center gap-4 px-5 py-4">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-5">
|
||||
|
||||
@@ -1540,6 +1540,64 @@ export function PreGameSelectionFlow({
|
||||
customWorldWorkEntries.length > 0
|
||||
? customWorldWorkEntries
|
||||
: buildCreationHubFallbackItems(savedCustomWorldEntries);
|
||||
const creationHubContent = (
|
||||
<CustomWorldCreationHub
|
||||
items={creationHubItems}
|
||||
loading={isLoadingPlatform}
|
||||
error={isLoadingPlatform ? null : (platformError ?? creationTypeError)}
|
||||
onBack={() => {
|
||||
setPlatformTab('home');
|
||||
}}
|
||||
onRetry={() => {
|
||||
setPlatformError(null);
|
||||
void refreshCustomWorldWorks().catch((error) => {
|
||||
setPlatformError(
|
||||
resolveErrorMessage(error, '读取创作作品列表失败。'),
|
||||
);
|
||||
});
|
||||
}}
|
||||
onCreateNew={openCreationTypePicker}
|
||||
onResumeDraft={(sessionId) => {
|
||||
runProtectedAction(() => {
|
||||
void handleOpenCreationWork({
|
||||
workId: `draft:${sessionId}`,
|
||||
sourceType: 'agent_session',
|
||||
status: 'draft',
|
||||
title: '',
|
||||
subtitle: '',
|
||||
summary: '',
|
||||
coverImageSrc: null,
|
||||
coverRenderMode: 'image',
|
||||
coverCharacterImageSrcs: [],
|
||||
updatedAt: new Date().toISOString(),
|
||||
publishedAt: null,
|
||||
stage: null,
|
||||
stageLabel: '',
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
roleVisualReadyCount: 0,
|
||||
roleAnimationReadyCount: 0,
|
||||
roleAssetSummaryLabel: null,
|
||||
sessionId,
|
||||
profileId: null,
|
||||
canResume: true,
|
||||
canEnterWorld: false,
|
||||
});
|
||||
});
|
||||
}}
|
||||
onEnterPublished={(profileId) => {
|
||||
runProtectedAction(() => {
|
||||
const matchedWork = creationHubItems.find(
|
||||
(entry) => entry.profileId === profileId,
|
||||
);
|
||||
if (!matchedWork) {
|
||||
return;
|
||||
}
|
||||
void handleOpenCreationWork(matchedWork);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -1552,106 +1610,48 @@ export function PreGameSelectionFlow({
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
{platformTab === 'create' ? (
|
||||
<CustomWorldCreationHub
|
||||
items={creationHubItems}
|
||||
loading={isLoadingPlatform}
|
||||
error={isLoadingPlatform ? null : (platformError ?? creationTypeError)}
|
||||
onBack={() => {
|
||||
setPlatformTab('home');
|
||||
}}
|
||||
onRetry={() => {
|
||||
setPlatformError(null);
|
||||
void refreshCustomWorldWorks().catch((error) => {
|
||||
setPlatformError(
|
||||
resolveErrorMessage(error, '读取创作作品列表失败。'),
|
||||
);
|
||||
});
|
||||
}}
|
||||
onCreateNew={openCreationTypePicker}
|
||||
onResumeDraft={(sessionId) => {
|
||||
runProtectedAction(() => {
|
||||
void handleOpenCreationWork({
|
||||
workId: `draft:${sessionId}`,
|
||||
sourceType: 'agent_session',
|
||||
status: 'draft',
|
||||
title: '',
|
||||
subtitle: '',
|
||||
summary: '',
|
||||
coverImageSrc: null,
|
||||
coverRenderMode: 'image',
|
||||
coverCharacterImageSrcs: [],
|
||||
updatedAt: new Date().toISOString(),
|
||||
publishedAt: null,
|
||||
stage: null,
|
||||
stageLabel: '',
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
roleVisualReadyCount: 0,
|
||||
roleAnimationReadyCount: 0,
|
||||
roleAssetSummaryLabel: null,
|
||||
sessionId,
|
||||
profileId: null,
|
||||
canResume: true,
|
||||
canEnterWorld: false,
|
||||
});
|
||||
});
|
||||
}}
|
||||
onEnterPublished={(profileId) => {
|
||||
runProtectedAction(() => {
|
||||
const matchedWork = creationHubItems.find(
|
||||
(entry) => entry.profileId === profileId,
|
||||
);
|
||||
if (!matchedWork) {
|
||||
return;
|
||||
}
|
||||
void handleOpenCreationWork(matchedWork);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<PlatformHomeView
|
||||
activeTab={platformTab}
|
||||
onTabChange={setPlatformTab}
|
||||
hasSavedGame={hasSavedGame}
|
||||
savedSnapshot={savedSnapshot}
|
||||
saveEntries={saveEntries}
|
||||
saveError={saveError}
|
||||
featuredEntries={featuredGalleryEntries}
|
||||
latestEntries={publishedGalleryEntries}
|
||||
myEntries={savedCustomWorldEntries}
|
||||
historyEntries={historyEntries}
|
||||
profileDashboard={profileDashboard}
|
||||
isLoadingPlatform={isLoadingPlatform}
|
||||
isLoadingDashboard={isLoadingDashboard}
|
||||
isResumingSaveWorldKey={isResumingSaveWorldKey}
|
||||
platformError={
|
||||
isLoadingPlatform ? null : (platformError ?? creationTypeError)
|
||||
<PlatformHomeView
|
||||
activeTab={platformTab}
|
||||
onTabChange={setPlatformTab}
|
||||
hasSavedGame={hasSavedGame}
|
||||
savedSnapshot={savedSnapshot}
|
||||
saveEntries={saveEntries}
|
||||
saveError={saveError}
|
||||
featuredEntries={featuredGalleryEntries}
|
||||
latestEntries={publishedGalleryEntries}
|
||||
myEntries={savedCustomWorldEntries}
|
||||
historyEntries={historyEntries}
|
||||
profileDashboard={profileDashboard}
|
||||
isLoadingPlatform={isLoadingPlatform}
|
||||
isLoadingDashboard={isLoadingDashboard}
|
||||
isResumingSaveWorldKey={isResumingSaveWorldKey}
|
||||
platformError={
|
||||
isLoadingPlatform ? null : (platformError ?? creationTypeError)
|
||||
}
|
||||
dashboardError={isLoadingDashboard ? null : dashboardError}
|
||||
createTabContent={creationHubContent}
|
||||
onContinueGame={handleContinueGame}
|
||||
onResumeSave={(entry) => {
|
||||
void handleResumeSaveEntry(entry);
|
||||
}}
|
||||
onOpenCreateWorld={openCustomWorldCreator}
|
||||
onOpenCreateTypePicker={openCreationTypePicker}
|
||||
onOpenGalleryDetail={(entry) => {
|
||||
runProtectedAction(() => {
|
||||
void openGalleryDetail(entry);
|
||||
});
|
||||
}}
|
||||
onOpenLibraryDetail={(entry) => {
|
||||
runProtectedAction(() => {
|
||||
openLibraryDetail(entry);
|
||||
});
|
||||
}}
|
||||
onOpenProfileDashboardCard={() => {
|
||||
if (dashboardError) {
|
||||
void refreshProfileDashboard();
|
||||
}
|
||||
dashboardError={isLoadingDashboard ? null : dashboardError}
|
||||
onContinueGame={handleContinueGame}
|
||||
onResumeSave={(entry) => {
|
||||
void handleResumeSaveEntry(entry);
|
||||
}}
|
||||
onOpenCreateWorld={openCustomWorldCreator}
|
||||
onOpenCreateTypePicker={openCreationTypePicker}
|
||||
onOpenGalleryDetail={(entry) => {
|
||||
runProtectedAction(() => {
|
||||
void openGalleryDetail(entry);
|
||||
});
|
||||
}}
|
||||
onOpenLibraryDetail={(entry) => {
|
||||
runProtectedAction(() => {
|
||||
openLibraryDetail(entry);
|
||||
});
|
||||
}}
|
||||
onOpenProfileDashboardCard={() => {
|
||||
if (dashboardError) {
|
||||
void refreshProfileDashboard();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,822 +0,0 @@
|
||||
export const BUILD_TAG_SIMILARITY_PAIRS: Array<readonly [string, string, number]> = [
|
||||
['快剑', '连段', 0.5652],
|
||||
['快剑', '突进', 0.639],
|
||||
['快剑', '追击', 0.5853],
|
||||
['快剑', '快袭', 0.7138],
|
||||
['快剑', '远射', 0.5732],
|
||||
['快剑', '游击', 0.5988],
|
||||
['快剑', '机动', 0.6052],
|
||||
['快剑', '风行', 0.6161],
|
||||
['快剑', '重击', 0.6202],
|
||||
['快剑', '爆发', 0.5647],
|
||||
['快剑', '破甲', 0.614],
|
||||
['快剑', '压制', 0.4927],
|
||||
['快剑', '压血', 0.4749],
|
||||
['快剑', '守御', 0.5533],
|
||||
['快剑', '护体', 0.4756],
|
||||
['快剑', '重甲', 0.5429],
|
||||
['快剑', '反击', 0.5565],
|
||||
['快剑', '镇邪', 0.4676],
|
||||
['快剑', '法修', 0.4834],
|
||||
['快剑', '法力', 0.4449],
|
||||
['快剑', '雷法', 0.5596],
|
||||
['快剑', '符阵', 0.502],
|
||||
['快剑', '控场', 0.4504],
|
||||
['快剑', '过载', 0.488],
|
||||
['快剑', '回复', 0.4804],
|
||||
['快剑', '护持', 0.4336],
|
||||
['快剑', '续战', 0.5132],
|
||||
['快剑', '命纹', 0.4916],
|
||||
['快剑', '机缘', 0.3826],
|
||||
['快剑', '冷却', 0.5143],
|
||||
['快剑', '统御', 0.4761],
|
||||
['快剑', '均衡', 0.4664],
|
||||
['快剑', '工巧', 0.4952],
|
||||
['快剑', '炼药', 0.496],
|
||||
['快剑', '先锋', 0.4771],
|
||||
['快剑', '狂战', 0.647],
|
||||
['快剑', '法剑', 0.7139],
|
||||
['快剑', '圣佑', 0.4072],
|
||||
['快剑', '堡垒', 0.5619],
|
||||
['快剑', '起手', 0.4805],
|
||||
['连段', '突进', 0.6269],
|
||||
['连段', '追击', 0.689],
|
||||
['连段', '快袭', 0.6255],
|
||||
['连段', '远射', 0.5834],
|
||||
['连段', '游击', 0.6451],
|
||||
['连段', '机动', 0.608],
|
||||
['连段', '风行', 0.5415],
|
||||
['连段', '重击', 0.6386],
|
||||
['连段', '爆发', 0.645],
|
||||
['连段', '破甲', 0.6281],
|
||||
['连段', '压制', 0.6701],
|
||||
['连段', '压血', 0.5097],
|
||||
['连段', '守御', 0.5739],
|
||||
['连段', '护体', 0.5288],
|
||||
['连段', '重甲', 0.5223],
|
||||
['连段', '反击', 0.6474],
|
||||
['连段', '镇邪', 0.4724],
|
||||
['连段', '法修', 0.5649],
|
||||
['连段', '法力', 0.5241],
|
||||
['连段', '雷法', 0.5451],
|
||||
['连段', '符阵', 0.5724],
|
||||
['连段', '控场', 0.6077],
|
||||
['连段', '过载', 0.5676],
|
||||
['连段', '回复', 0.5579],
|
||||
['连段', '护持', 0.5517],
|
||||
['连段', '续战', 0.6264],
|
||||
['连段', '命纹', 0.5979],
|
||||
['连段', '机缘', 0.4965],
|
||||
['连段', '冷却', 0.5609],
|
||||
['连段', '统御', 0.5969],
|
||||
['连段', '均衡', 0.5373],
|
||||
['连段', '工巧', 0.5193],
|
||||
['连段', '炼药', 0.5167],
|
||||
['连段', '先锋', 0.5145],
|
||||
['连段', '狂战', 0.5901],
|
||||
['连段', '法剑', 0.5712],
|
||||
['连段', '圣佑', 0.4439],
|
||||
['连段', '堡垒', 0.5764],
|
||||
['连段', '起手', 0.5323],
|
||||
['突进', '追击', 0.7523],
|
||||
['突进', '快袭', 0.7773],
|
||||
['突进', '远射', 0.681],
|
||||
['突进', '游击', 0.7741],
|
||||
['突进', '机动', 0.7414],
|
||||
['突进', '风行', 0.6838],
|
||||
['突进', '重击', 0.6753],
|
||||
['突进', '爆发', 0.679],
|
||||
['突进', '破甲', 0.7068],
|
||||
['突进', '压制', 0.6313],
|
||||
['突进', '压血', 0.5655],
|
||||
['突进', '守御', 0.6259],
|
||||
['突进', '护体', 0.5488],
|
||||
['突进', '重甲', 0.564],
|
||||
['突进', '反击', 0.6671],
|
||||
['突进', '镇邪', 0.5221],
|
||||
['突进', '法修', 0.5476],
|
||||
['突进', '法力', 0.4857],
|
||||
['突进', '雷法', 0.5302],
|
||||
['突进', '符阵', 0.6334],
|
||||
['突进', '控场', 0.5836],
|
||||
['突进', '过载', 0.6108],
|
||||
['突进', '回复', 0.589],
|
||||
['突进', '护持', 0.5765],
|
||||
['突进', '续战', 0.622],
|
||||
['突进', '命纹', 0.5123],
|
||||
['突进', '机缘', 0.546],
|
||||
['突进', '冷却', 0.5704],
|
||||
['突进', '统御', 0.5823],
|
||||
['突进', '均衡', 0.5443],
|
||||
['突进', '工巧', 0.5262],
|
||||
['突进', '炼药', 0.5517],
|
||||
['突进', '先锋', 0.6528],
|
||||
['突进', '狂战', 0.7068],
|
||||
['突进', '法剑', 0.5439],
|
||||
['突进', '圣佑', 0.4391],
|
||||
['突进', '堡垒', 0.6254],
|
||||
['突进', '起手', 0.6149],
|
||||
['追击', '快袭', 0.7158],
|
||||
['追击', '远射', 0.667],
|
||||
['追击', '游击', 0.7451],
|
||||
['追击', '机动', 0.6764],
|
||||
['追击', '风行', 0.6563],
|
||||
['追击', '重击', 0.65],
|
||||
['追击', '爆发', 0.6452],
|
||||
['追击', '破甲', 0.672],
|
||||
['追击', '压制', 0.6919],
|
||||
['追击', '压血', 0.5459],
|
||||
['追击', '守御', 0.6761],
|
||||
['追击', '护体', 0.6079],
|
||||
['追击', '重甲', 0.521],
|
||||
['追击', '反击', 0.7187],
|
||||
['追击', '镇邪', 0.5213],
|
||||
['追击', '法修', 0.5634],
|
||||
['追击', '法力', 0.5432],
|
||||
['追击', '雷法', 0.5044],
|
||||
['追击', '符阵', 0.6157],
|
||||
['追击', '控场', 0.5958],
|
||||
['追击', '过载', 0.6295],
|
||||
['追击', '回复', 0.6354],
|
||||
['追击', '护持', 0.6463],
|
||||
['追击', '续战', 0.7218],
|
||||
['追击', '命纹', 0.5615],
|
||||
['追击', '机缘', 0.5268],
|
||||
['追击', '冷却', 0.6234],
|
||||
['追击', '统御', 0.6099],
|
||||
['追击', '均衡', 0.5722],
|
||||
['追击', '工巧', 0.4781],
|
||||
['追击', '炼药', 0.5361],
|
||||
['追击', '先锋', 0.5768],
|
||||
['追击', '狂战', 0.6197],
|
||||
['追击', '法剑', 0.5672],
|
||||
['追击', '圣佑', 0.5151],
|
||||
['追击', '堡垒', 0.5949],
|
||||
['追击', '起手', 0.5904],
|
||||
['快袭', '远射', 0.6641],
|
||||
['快袭', '游击', 0.7421],
|
||||
['快袭', '机动', 0.6528],
|
||||
['快袭', '风行', 0.6794],
|
||||
['快袭', '重击', 0.7028],
|
||||
['快袭', '爆发', 0.6717],
|
||||
['快袭', '破甲', 0.6879],
|
||||
['快袭', '压制', 0.6159],
|
||||
['快袭', '压血', 0.5831],
|
||||
['快袭', '守御', 0.6133],
|
||||
['快袭', '护体', 0.5434],
|
||||
['快袭', '重甲', 0.5328],
|
||||
['快袭', '反击', 0.6898],
|
||||
['快袭', '镇邪', 0.5335],
|
||||
['快袭', '法修', 0.5486],
|
||||
['快袭', '法力', 0.5028],
|
||||
['快袭', '雷法', 0.593],
|
||||
['快袭', '符阵', 0.5869],
|
||||
['快袭', '控场', 0.5595],
|
||||
['快袭', '过载', 0.5888],
|
||||
['快袭', '回复', 0.5748],
|
||||
['快袭', '护持', 0.5483],
|
||||
['快袭', '续战', 0.5473],
|
||||
['快袭', '命纹', 0.5426],
|
||||
['快袭', '机缘', 0.4854],
|
||||
['快袭', '冷却', 0.5745],
|
||||
['快袭', '统御', 0.5406],
|
||||
['快袭', '均衡', 0.5206],
|
||||
['快袭', '工巧', 0.5192],
|
||||
['快袭', '炼药', 0.5656],
|
||||
['快袭', '先锋', 0.5619],
|
||||
['快袭', '狂战', 0.6826],
|
||||
['快袭', '法剑', 0.5964],
|
||||
['快袭', '圣佑', 0.4988],
|
||||
['快袭', '堡垒', 0.5941],
|
||||
['快袭', '起手', 0.5629],
|
||||
['远射', '游击', 0.6795],
|
||||
['远射', '机动', 0.6245],
|
||||
['远射', '风行', 0.5979],
|
||||
['远射', '重击', 0.6841],
|
||||
['远射', '爆发', 0.5983],
|
||||
['远射', '破甲', 0.5703],
|
||||
['远射', '压制', 0.5474],
|
||||
['远射', '压血', 0.4747],
|
||||
['远射', '守御', 0.5923],
|
||||
['远射', '护体', 0.5016],
|
||||
['远射', '重甲', 0.5697],
|
||||
['远射', '反击', 0.5669],
|
||||
['远射', '镇邪', 0.4811],
|
||||
['远射', '法修', 0.5174],
|
||||
['远射', '法力', 0.4697],
|
||||
['远射', '雷法', 0.5274],
|
||||
['远射', '符阵', 0.5635],
|
||||
['远射', '控场', 0.562],
|
||||
['远射', '过载', 0.526],
|
||||
['远射', '回复', 0.5325],
|
||||
['远射', '护持', 0.5249],
|
||||
['远射', '续战', 0.643],
|
||||
['远射', '命纹', 0.4921],
|
||||
['远射', '机缘', 0.4417],
|
||||
['远射', '冷却', 0.4675],
|
||||
['远射', '统御', 0.5189],
|
||||
['远射', '均衡', 0.5349],
|
||||
['远射', '工巧', 0.5065],
|
||||
['远射', '炼药', 0.5366],
|
||||
['远射', '先锋', 0.5259],
|
||||
['远射', '狂战', 0.5781],
|
||||
['远射', '法剑', 0.5516],
|
||||
['远射', '圣佑', 0.4589],
|
||||
['远射', '堡垒', 0.5505],
|
||||
['远射', '起手', 0.5292],
|
||||
['游击', '机动', 0.7143],
|
||||
['游击', '风行', 0.6702],
|
||||
['游击', '重击', 0.6773],
|
||||
['游击', '爆发', 0.6322],
|
||||
['游击', '破甲', 0.6881],
|
||||
['游击', '压制', 0.624],
|
||||
['游击', '压血', 0.5784],
|
||||
['游击', '守御', 0.6592],
|
||||
['游击', '护体', 0.5893],
|
||||
['游击', '重甲', 0.5299],
|
||||
['游击', '反击', 0.7005],
|
||||
['游击', '镇邪', 0.5426],
|
||||
['游击', '法修', 0.5741],
|
||||
['游击', '法力', 0.5228],
|
||||
['游击', '雷法', 0.523],
|
||||
['游击', '符阵', 0.6419],
|
||||
['游击', '控场', 0.6207],
|
||||
['游击', '过载', 0.5284],
|
||||
['游击', '回复', 0.556],
|
||||
['游击', '护持', 0.5766],
|
||||
['游击', '续战', 0.6038],
|
||||
['游击', '命纹', 0.5388],
|
||||
['游击', '机缘', 0.5112],
|
||||
['游击', '冷却', 0.554],
|
||||
['游击', '统御', 0.5779],
|
||||
['游击', '均衡', 0.5303],
|
||||
['游击', '工巧', 0.568],
|
||||
['游击', '炼药', 0.5643],
|
||||
['游击', '先锋', 0.6093],
|
||||
['游击', '狂战', 0.7051],
|
||||
['游击', '法剑', 0.6137],
|
||||
['游击', '圣佑', 0.4791],
|
||||
['游击', '堡垒', 0.6628],
|
||||
['游击', '起手', 0.5949],
|
||||
['机动', '风行', 0.779],
|
||||
['机动', '重击', 0.6363],
|
||||
['机动', '爆发', 0.627],
|
||||
['机动', '破甲', 0.6149],
|
||||
['机动', '压制', 0.6331],
|
||||
['机动', '压血', 0.5046],
|
||||
['机动', '守御', 0.6324],
|
||||
['机动', '护体', 0.5913],
|
||||
['机动', '重甲', 0.5902],
|
||||
['机动', '反击', 0.5831],
|
||||
['机动', '镇邪', 0.4794],
|
||||
['机动', '法修', 0.5937],
|
||||
['机动', '法力', 0.5797],
|
||||
['机动', '雷法', 0.5596],
|
||||
['机动', '符阵', 0.5786],
|
||||
['机动', '控场', 0.5914],
|
||||
['机动', '过载', 0.5971],
|
||||
['机动', '回复', 0.5985],
|
||||
['机动', '护持', 0.5888],
|
||||
['机动', '续战', 0.6009],
|
||||
['机动', '命纹', 0.565],
|
||||
['机动', '机缘', 0.5935],
|
||||
['机动', '冷却', 0.5674],
|
||||
['机动', '统御', 0.5976],
|
||||
['机动', '均衡', 0.5708],
|
||||
['机动', '工巧', 0.6219],
|
||||
['机动', '炼药', 0.5386],
|
||||
['机动', '先锋', 0.5903],
|
||||
['机动', '狂战', 0.6296],
|
||||
['机动', '法剑', 0.534],
|
||||
['机动', '圣佑', 0.4903],
|
||||
['机动', '堡垒', 0.5817],
|
||||
['机动', '起手', 0.5714],
|
||||
['风行', '重击', 0.5814],
|
||||
['风行', '爆发', 0.5888],
|
||||
['风行', '破甲', 0.5965],
|
||||
['风行', '压制', 0.5891],
|
||||
['风行', '压血', 0.4822],
|
||||
['风行', '守御', 0.5779],
|
||||
['风行', '护体', 0.563],
|
||||
['风行', '重甲', 0.5303],
|
||||
['风行', '反击', 0.5606],
|
||||
['风行', '镇邪', 0.4994],
|
||||
['风行', '法修', 0.5666],
|
||||
['风行', '法力', 0.5465],
|
||||
['风行', '雷法', 0.6185],
|
||||
['风行', '符阵', 0.6019],
|
||||
['风行', '控场', 0.5591],
|
||||
['风行', '过载', 0.5711],
|
||||
['风行', '回复', 0.5502],
|
||||
['风行', '护持', 0.5305],
|
||||
['风行', '续战', 0.5345],
|
||||
['风行', '命纹', 0.5222],
|
||||
['风行', '机缘', 0.4927],
|
||||
['风行', '冷却', 0.5579],
|
||||
['风行', '统御', 0.5759],
|
||||
['风行', '均衡', 0.5518],
|
||||
['风行', '工巧', 0.5349],
|
||||
['风行', '炼药', 0.4949],
|
||||
['风行', '先锋', 0.5716],
|
||||
['风行', '狂战', 0.637],
|
||||
['风行', '法剑', 0.5606],
|
||||
['风行', '圣佑', 0.4625],
|
||||
['风行', '堡垒', 0.5604],
|
||||
['风行', '起手', 0.5593],
|
||||
['重击', '爆发', 0.6945],
|
||||
['重击', '破甲', 0.6774],
|
||||
['重击', '压制', 0.6399],
|
||||
['重击', '压血', 0.5782],
|
||||
['重击', '守御', 0.6601],
|
||||
['重击', '护体', 0.5804],
|
||||
['重击', '重甲', 0.7689],
|
||||
['重击', '反击', 0.6758],
|
||||
['重击', '镇邪', 0.5743],
|
||||
['重击', '法修', 0.5315],
|
||||
['重击', '法力', 0.5534],
|
||||
['重击', '雷法', 0.6306],
|
||||
['重击', '符阵', 0.5642],
|
||||
['重击', '控场', 0.555],
|
||||
['重击', '过载', 0.603],
|
||||
['重击', '回复', 0.6222],
|
||||
['重击', '护持', 0.5424],
|
||||
['重击', '续战', 0.6046],
|
||||
['重击', '命纹', 0.5489],
|
||||
['重击', '机缘', 0.4778],
|
||||
['重击', '冷却', 0.5272],
|
||||
['重击', '统御', 0.5262],
|
||||
['重击', '均衡', 0.5299],
|
||||
['重击', '工巧', 0.5468],
|
||||
['重击', '炼药', 0.5696],
|
||||
['重击', '先锋', 0.5126],
|
||||
['重击', '狂战', 0.6859],
|
||||
['重击', '法剑', 0.593],
|
||||
['重击', '圣佑', 0.5253],
|
||||
['重击', '堡垒', 0.6473],
|
||||
['重击', '起手', 0.5027],
|
||||
['爆发', '破甲', 0.6471],
|
||||
['爆发', '压制', 0.6149],
|
||||
['爆发', '压血', 0.6011],
|
||||
['爆发', '守御', 0.6566],
|
||||
['爆发', '护体', 0.6024],
|
||||
['爆发', '重甲', 0.5939],
|
||||
['爆发', '反击', 0.6182],
|
||||
['爆发', '镇邪', 0.5866],
|
||||
['爆发', '法修', 0.5946],
|
||||
['爆发', '法力', 0.5942],
|
||||
['爆发', '雷法', 0.6125],
|
||||
['爆发', '符阵', 0.6034],
|
||||
['爆发', '控场', 0.553],
|
||||
['爆发', '过载', 0.6815],
|
||||
['爆发', '回复', 0.6327],
|
||||
['爆发', '护持', 0.5936],
|
||||
['爆发', '续战', 0.5908],
|
||||
['爆发', '命纹', 0.6207],
|
||||
['爆发', '机缘', 0.5688],
|
||||
['爆发', '冷却', 0.655],
|
||||
['爆发', '统御', 0.5539],
|
||||
['爆发', '均衡', 0.5714],
|
||||
['爆发', '工巧', 0.5236],
|
||||
['爆发', '炼药', 0.5637],
|
||||
['爆发', '先锋', 0.5253],
|
||||
['爆发', '狂战', 0.6509],
|
||||
['爆发', '法剑', 0.5871],
|
||||
['爆发', '圣佑', 0.5475],
|
||||
['爆发', '堡垒', 0.6038],
|
||||
['爆发', '起手', 0.578],
|
||||
['破甲', '压制', 0.6264],
|
||||
['破甲', '压血', 0.5547],
|
||||
['破甲', '守御', 0.7071],
|
||||
['破甲', '护体', 0.658],
|
||||
['破甲', '重甲', 0.7112],
|
||||
['破甲', '反击', 0.6969],
|
||||
['破甲', '镇邪', 0.6075],
|
||||
['破甲', '法修', 0.5574],
|
||||
['破甲', '法力', 0.5077],
|
||||
['破甲', '雷法', 0.5545],
|
||||
['破甲', '符阵', 0.6422],
|
||||
['破甲', '控场', 0.5732],
|
||||
['破甲', '过载', 0.5188],
|
||||
['破甲', '回复', 0.5907],
|
||||
['破甲', '护持', 0.5939],
|
||||
['破甲', '续战', 0.587],
|
||||
['破甲', '命纹', 0.5605],
|
||||
['破甲', '机缘', 0.4785],
|
||||
['破甲', '冷却', 0.5631],
|
||||
['破甲', '统御', 0.5967],
|
||||
['破甲', '均衡', 0.5164],
|
||||
['破甲', '工巧', 0.5391],
|
||||
['破甲', '炼药', 0.543],
|
||||
['破甲', '先锋', 0.6307],
|
||||
['破甲', '狂战', 0.6562],
|
||||
['破甲', '法剑', 0.6461],
|
||||
['破甲', '圣佑', 0.542],
|
||||
['破甲', '堡垒', 0.6839],
|
||||
['破甲', '起手', 0.562],
|
||||
['压制', '压血', 0.6276],
|
||||
['压制', '守御', 0.6984],
|
||||
['压制', '护体', 0.6072],
|
||||
['压制', '重甲', 0.545],
|
||||
['压制', '反击', 0.7119],
|
||||
['压制', '镇邪', 0.5967],
|
||||
['压制', '法修', 0.553],
|
||||
['压制', '法力', 0.5656],
|
||||
['压制', '雷法', 0.5769],
|
||||
['压制', '符阵', 0.5715],
|
||||
['压制', '控场', 0.749],
|
||||
['压制', '过载', 0.6006],
|
||||
['压制', '回复', 0.5653],
|
||||
['压制', '护持', 0.6715],
|
||||
['压制', '续战', 0.5801],
|
||||
['压制', '命纹', 0.551],
|
||||
['压制', '机缘', 0.5549],
|
||||
['压制', '冷却', 0.5747],
|
||||
['压制', '统御', 0.6469],
|
||||
['压制', '均衡', 0.5886],
|
||||
['压制', '工巧', 0.5383],
|
||||
['压制', '炼药', 0.535],
|
||||
['压制', '先锋', 0.5772],
|
||||
['压制', '狂战', 0.5509],
|
||||
['压制', '法剑', 0.5397],
|
||||
['压制', '圣佑', 0.5325],
|
||||
['压制', '堡垒', 0.6023],
|
||||
['压制', '起手', 0.5394],
|
||||
['压血', '守御', 0.5914],
|
||||
['压血', '护体', 0.5175],
|
||||
['压血', '重甲', 0.5008],
|
||||
['压血', '反击', 0.6153],
|
||||
['压血', '镇邪', 0.5773],
|
||||
['压血', '法修', 0.4587],
|
||||
['压血', '法力', 0.4814],
|
||||
['压血', '雷法', 0.5198],
|
||||
['压血', '符阵', 0.5016],
|
||||
['压血', '控场', 0.5041],
|
||||
['压血', '过载', 0.5253],
|
||||
['压血', '回复', 0.5365],
|
||||
['压血', '护持', 0.515],
|
||||
['压血', '续战', 0.497],
|
||||
['压血', '命纹', 0.4709],
|
||||
['压血', '机缘', 0.4604],
|
||||
['压血', '冷却', 0.5177],
|
||||
['压血', '统御', 0.4647],
|
||||
['压血', '均衡', 0.4405],
|
||||
['压血', '工巧', 0.405],
|
||||
['压血', '炼药', 0.5014],
|
||||
['压血', '先锋', 0.5009],
|
||||
['压血', '狂战', 0.6444],
|
||||
['压血', '法剑', 0.5183],
|
||||
['压血', '圣佑', 0.4573],
|
||||
['压血', '堡垒', 0.5345],
|
||||
['压血', '起手', 0.4848],
|
||||
['守御', '护体', 0.7607],
|
||||
['守御', '重甲', 0.7127],
|
||||
['守御', '反击', 0.7066],
|
||||
['守御', '镇邪', 0.6594],
|
||||
['守御', '法修', 0.5957],
|
||||
['守御', '法力', 0.614],
|
||||
['守御', '雷法', 0.5409],
|
||||
['守御', '符阵', 0.6034],
|
||||
['守御', '控场', 0.6585],
|
||||
['守御', '过载', 0.5236],
|
||||
['守御', '回复', 0.5995],
|
||||
['守御', '护持', 0.7566],
|
||||
['守御', '续战', 0.6493],
|
||||
['守御', '命纹', 0.5943],
|
||||
['守御', '机缘', 0.494],
|
||||
['守御', '冷却', 0.5445],
|
||||
['守御', '统御', 0.6908],
|
||||
['守御', '均衡', 0.6017],
|
||||
['守御', '工巧', 0.5528],
|
||||
['守御', '炼药', 0.5365],
|
||||
['守御', '先锋', 0.665],
|
||||
['守御', '狂战', 0.62],
|
||||
['守御', '法剑', 0.6191],
|
||||
['守御', '圣佑', 0.665],
|
||||
['守御', '堡垒', 0.7582],
|
||||
['守御', '起手', 0.5109],
|
||||
['护体', '重甲', 0.6496],
|
||||
['护体', '反击', 0.6368],
|
||||
['护体', '镇邪', 0.6807],
|
||||
['护体', '法修', 0.6148],
|
||||
['护体', '法力', 0.6455],
|
||||
['护体', '雷法', 0.5794],
|
||||
['护体', '符阵', 0.6043],
|
||||
['护体', '控场', 0.5781],
|
||||
['护体', '过载', 0.5113],
|
||||
['护体', '回复', 0.5927],
|
||||
['护体', '护持', 0.7176],
|
||||
['护体', '续战', 0.5704],
|
||||
['护体', '命纹', 0.6048],
|
||||
['护体', '机缘', 0.4744],
|
||||
['护体', '冷却', 0.4956],
|
||||
['护体', '统御', 0.6238],
|
||||
['护体', '均衡', 0.5194],
|
||||
['护体', '工巧', 0.4965],
|
||||
['护体', '炼药', 0.5417],
|
||||
['护体', '先锋', 0.5728],
|
||||
['护体', '狂战', 0.5373],
|
||||
['护体', '法剑', 0.6043],
|
||||
['护体', '圣佑', 0.725],
|
||||
['护体', '堡垒', 0.6437],
|
||||
['护体', '起手', 0.4898],
|
||||
['重甲', '反击', 0.5685],
|
||||
['重甲', '镇邪', 0.556],
|
||||
['重甲', '法修', 0.4907],
|
||||
['重甲', '法力', 0.5033],
|
||||
['重甲', '雷法', 0.513],
|
||||
['重甲', '符阵', 0.5623],
|
||||
['重甲', '控场', 0.5215],
|
||||
['重甲', '过载', 0.5094],
|
||||
['重甲', '回复', 0.573],
|
||||
['重甲', '护持', 0.579],
|
||||
['重甲', '续战', 0.6005],
|
||||
['重甲', '命纹', 0.5357],
|
||||
['重甲', '机缘', 0.4124],
|
||||
['重甲', '冷却', 0.4427],
|
||||
['重甲', '统御', 0.5444],
|
||||
['重甲', '均衡', 0.5009],
|
||||
['重甲', '工巧', 0.5093],
|
||||
['重甲', '炼药', 0.5303],
|
||||
['重甲', '先锋', 0.5833],
|
||||
['重甲', '狂战', 0.6125],
|
||||
['重甲', '法剑', 0.5387],
|
||||
['重甲', '圣佑', 0.5324],
|
||||
['重甲', '堡垒', 0.7125],
|
||||
['重甲', '起手', 0.473],
|
||||
['反击', '镇邪', 0.6254],
|
||||
['反击', '法修', 0.5644],
|
||||
['反击', '法力', 0.543],
|
||||
['反击', '雷法', 0.5453],
|
||||
['反击', '符阵', 0.6064],
|
||||
['反击', '控场', 0.615],
|
||||
['反击', '过载', 0.5353],
|
||||
['反击', '回复', 0.5944],
|
||||
['反击', '护持', 0.6343],
|
||||
['反击', '续战', 0.576],
|
||||
['反击', '命纹', 0.5603],
|
||||
['反击', '机缘', 0.5539],
|
||||
['反击', '冷却', 0.5888],
|
||||
['反击', '统御', 0.5744],
|
||||
['反击', '均衡', 0.5154],
|
||||
['反击', '工巧', 0.4982],
|
||||
['反击', '炼药', 0.5217],
|
||||
['反击', '先锋', 0.5842],
|
||||
['反击', '狂战', 0.6543],
|
||||
['反击', '法剑', 0.581],
|
||||
['反击', '圣佑', 0.5798],
|
||||
['反击', '堡垒', 0.6901],
|
||||
['反击', '起手', 0.5609],
|
||||
['镇邪', '法修', 0.5702],
|
||||
['镇邪', '法力', 0.5942],
|
||||
['镇邪', '雷法', 0.6029],
|
||||
['镇邪', '符阵', 0.6614],
|
||||
['镇邪', '控场', 0.568],
|
||||
['镇邪', '过载', 0.5081],
|
||||
['镇邪', '回复', 0.5846],
|
||||
['镇邪', '护持', 0.5381],
|
||||
['镇邪', '续战', 0.4889],
|
||||
['镇邪', '命纹', 0.5353],
|
||||
['镇邪', '机缘', 0.457],
|
||||
['镇邪', '冷却', 0.5112],
|
||||
['镇邪', '统御', 0.5196],
|
||||
['镇邪', '均衡', 0.4713],
|
||||
['镇邪', '工巧', 0.4463],
|
||||
['镇邪', '炼药', 0.4968],
|
||||
['镇邪', '先锋', 0.4949],
|
||||
['镇邪', '狂战', 0.591],
|
||||
['镇邪', '法剑', 0.6089],
|
||||
['镇邪', '圣佑', 0.6872],
|
||||
['镇邪', '堡垒', 0.6238],
|
||||
['镇邪', '起手', 0.4864],
|
||||
['法修', '法力', 0.7208],
|
||||
['法修', '雷法', 0.6333],
|
||||
['法修', '符阵', 0.6298],
|
||||
['法修', '控场', 0.5764],
|
||||
['法修', '过载', 0.578],
|
||||
['法修', '回复', 0.5988],
|
||||
['法修', '护持', 0.5658],
|
||||
['法修', '续战', 0.541],
|
||||
['法修', '命纹', 0.6459],
|
||||
['法修', '机缘', 0.5116],
|
||||
['法修', '冷却', 0.5266],
|
||||
['法修', '统御', 0.6418],
|
||||
['法修', '均衡', 0.4631],
|
||||
['法修', '工巧', 0.5871],
|
||||
['法修', '炼药', 0.6282],
|
||||
['法修', '先锋', 0.5125],
|
||||
['法修', '狂战', 0.5753],
|
||||
['法修', '法剑', 0.7104],
|
||||
['法修', '圣佑', 0.5691],
|
||||
['法修', '堡垒', 0.5534],
|
||||
['法修', '起手', 0.5379],
|
||||
['法力', '雷法', 0.61],
|
||||
['法力', '符阵', 0.5773],
|
||||
['法力', '控场', 0.4987],
|
||||
['法力', '过载', 0.605],
|
||||
['法力', '回复', 0.598],
|
||||
['法力', '护持', 0.5795],
|
||||
['法力', '续战', 0.5531],
|
||||
['法力', '命纹', 0.6539],
|
||||
['法力', '机缘', 0.5452],
|
||||
['法力', '冷却', 0.5194],
|
||||
['法力', '统御', 0.6022],
|
||||
['法力', '均衡', 0.5077],
|
||||
['法力', '工巧', 0.5687],
|
||||
['法力', '炼药', 0.5806],
|
||||
['法力', '先锋', 0.4548],
|
||||
['法力', '狂战', 0.5491],
|
||||
['法力', '法剑', 0.6307],
|
||||
['法力', '圣佑', 0.5769],
|
||||
['法力', '堡垒', 0.5276],
|
||||
['法力', '起手', 0.4906],
|
||||
['雷法', '符阵', 0.597],
|
||||
['雷法', '控场', 0.5121],
|
||||
['雷法', '过载', 0.5585],
|
||||
['雷法', '回复', 0.5585],
|
||||
['雷法', '护持', 0.4631],
|
||||
['雷法', '续战', 0.4514],
|
||||
['雷法', '命纹', 0.5399],
|
||||
['雷法', '机缘', 0.4319],
|
||||
['雷法', '冷却', 0.4935],
|
||||
['雷法', '统御', 0.5187],
|
||||
['雷法', '均衡', 0.4183],
|
||||
['雷法', '工巧', 0.4696],
|
||||
['雷法', '炼药', 0.5434],
|
||||
['雷法', '先锋', 0.4505],
|
||||
['雷法', '狂战', 0.5692],
|
||||
['雷法', '法剑', 0.6448],
|
||||
['雷法', '圣佑', 0.5153],
|
||||
['雷法', '堡垒', 0.4846],
|
||||
['雷法', '起手', 0.4587],
|
||||
['符阵', '控场', 0.6108],
|
||||
['符阵', '过载', 0.5434],
|
||||
['符阵', '回复', 0.5793],
|
||||
['符阵', '护持', 0.5548],
|
||||
['符阵', '续战', 0.5653],
|
||||
['符阵', '命纹', 0.5807],
|
||||
['符阵', '机缘', 0.5305],
|
||||
['符阵', '冷却', 0.5247],
|
||||
['符阵', '统御', 0.596],
|
||||
['符阵', '均衡', 0.4758],
|
||||
['符阵', '工巧', 0.5118],
|
||||
['符阵', '炼药', 0.5587],
|
||||
['符阵', '先锋', 0.5208],
|
||||
['符阵', '狂战', 0.6399],
|
||||
['符阵', '法剑', 0.6213],
|
||||
['符阵', '圣佑', 0.5678],
|
||||
['符阵', '堡垒', 0.6326],
|
||||
['符阵', '起手', 0.5444],
|
||||
['控场', '过载', 0.5345],
|
||||
['控场', '回复', 0.5483],
|
||||
['控场', '护持', 0.5773],
|
||||
['控场', '续战', 0.5117],
|
||||
['控场', '命纹', 0.5334],
|
||||
['控场', '机缘', 0.5049],
|
||||
['控场', '冷却', 0.5248],
|
||||
['控场', '统御', 0.6024],
|
||||
['控场', '均衡', 0.5389],
|
||||
['控场', '工巧', 0.5029],
|
||||
['控场', '炼药', 0.4973],
|
||||
['控场', '先锋', 0.5409],
|
||||
['控场', '狂战', 0.5177],
|
||||
['控场', '法剑', 0.4913],
|
||||
['控场', '圣佑', 0.4957],
|
||||
['控场', '堡垒', 0.5744],
|
||||
['控场', '起手', 0.5304],
|
||||
['过载', '回复', 0.621],
|
||||
['过载', '护持', 0.523],
|
||||
['过载', '续战', 0.5862],
|
||||
['过载', '命纹', 0.4679],
|
||||
['过载', '机缘', 0.4845],
|
||||
['过载', '冷却', 0.6125],
|
||||
['过载', '统御', 0.5142],
|
||||
['过载', '均衡', 0.512],
|
||||
['过载', '工巧', 0.4477],
|
||||
['过载', '炼药', 0.5165],
|
||||
['过载', '先锋', 0.472],
|
||||
['过载', '狂战', 0.5719],
|
||||
['过载', '法剑', 0.5009],
|
||||
['过载', '圣佑', 0.4471],
|
||||
['过载', '堡垒', 0.4894],
|
||||
['过载', '起手', 0.5507],
|
||||
['回复', '护持', 0.6385],
|
||||
['回复', '续战', 0.7208],
|
||||
['回复', '命纹', 0.5697],
|
||||
['回复', '机缘', 0.5503],
|
||||
['回复', '冷却', 0.6266],
|
||||
['回复', '统御', 0.6014],
|
||||
['回复', '均衡', 0.5601],
|
||||
['回复', '工巧', 0.551],
|
||||
['回复', '炼药', 0.6223],
|
||||
['回复', '先锋', 0.49],
|
||||
['回复', '狂战', 0.5537],
|
||||
['回复', '法剑', 0.5084],
|
||||
['回复', '圣佑', 0.5796],
|
||||
['回复', '堡垒', 0.5532],
|
||||
['回复', '起手', 0.5605],
|
||||
['护持', '续战', 0.6602],
|
||||
['护持', '命纹', 0.5718],
|
||||
['护持', '机缘', 0.5891],
|
||||
['护持', '冷却', 0.5337],
|
||||
['护持', '统御', 0.6472],
|
||||
['护持', '均衡', 0.5856],
|
||||
['护持', '工巧', 0.5238],
|
||||
['护持', '炼药', 0.5062],
|
||||
['护持', '先锋', 0.6196],
|
||||
['护持', '狂战', 0.5233],
|
||||
['护持', '法剑', 0.4996],
|
||||
['护持', '圣佑', 0.6853],
|
||||
['护持', '堡垒', 0.6118],
|
||||
['护持', '起手', 0.522],
|
||||
['续战', '命纹', 0.5695],
|
||||
['续战', '机缘', 0.5065],
|
||||
['续战', '冷却', 0.5479],
|
||||
['续战', '统御', 0.6108],
|
||||
['续战', '均衡', 0.6118],
|
||||
['续战', '工巧', 0.4802],
|
||||
['续战', '炼药', 0.5326],
|
||||
['续战', '先锋', 0.5283],
|
||||
['续战', '狂战', 0.589],
|
||||
['续战', '法剑', 0.5245],
|
||||
['续战', '圣佑', 0.4996],
|
||||
['续战', '堡垒', 0.5803],
|
||||
['续战', '起手', 0.5138],
|
||||
['命纹', '机缘', 0.6391],
|
||||
['命纹', '冷却', 0.4859],
|
||||
['命纹', '统御', 0.5763],
|
||||
['命纹', '均衡', 0.4904],
|
||||
['命纹', '工巧', 0.5419],
|
||||
['命纹', '炼药', 0.5336],
|
||||
['命纹', '先锋', 0.4693],
|
||||
['命纹', '狂战', 0.5297],
|
||||
['命纹', '法剑', 0.5789],
|
||||
['命纹', '圣佑', 0.5901],
|
||||
['命纹', '堡垒', 0.5077],
|
||||
['命纹', '起手', 0.5058],
|
||||
['机缘', '冷却', 0.4758],
|
||||
['机缘', '统御', 0.5276],
|
||||
['机缘', '均衡', 0.5299],
|
||||
['机缘', '工巧', 0.5537],
|
||||
['机缘', '炼药', 0.4607],
|
||||
['机缘', '先锋', 0.4412],
|
||||
['机缘', '狂战', 0.4916],
|
||||
['机缘', '法剑', 0.4279],
|
||||
['机缘', '圣佑', 0.5334],
|
||||
['机缘', '堡垒', 0.4821],
|
||||
['机缘', '起手', 0.5271],
|
||||
['冷却', '统御', 0.4681],
|
||||
['冷却', '均衡', 0.5192],
|
||||
['冷却', '工巧', 0.4528],
|
||||
['冷却', '炼药', 0.5105],
|
||||
['冷却', '先锋', 0.4123],
|
||||
['冷却', '狂战', 0.5277],
|
||||
['冷却', '法剑', 0.4966],
|
||||
['冷却', '圣佑', 0.427],
|
||||
['冷却', '堡垒', 0.5101],
|
||||
['冷却', '起手', 0.5162],
|
||||
['统御', '均衡', 0.5641],
|
||||
['统御', '工巧', 0.5768],
|
||||
['统御', '炼药', 0.5346],
|
||||
['统御', '先锋', 0.633],
|
||||
['统御', '狂战', 0.5482],
|
||||
['统御', '法剑', 0.5855],
|
||||
['统御', '圣佑', 0.5481],
|
||||
['统御', '堡垒', 0.5774],
|
||||
['统御', '起手', 0.5428],
|
||||
['均衡', '工巧', 0.5957],
|
||||
['均衡', '炼药', 0.4911],
|
||||
['均衡', '先锋', 0.4931],
|
||||
['均衡', '狂战', 0.488],
|
||||
['均衡', '法剑', 0.5058],
|
||||
['均衡', '圣佑', 0.4665],
|
||||
['均衡', '堡垒', 0.5519],
|
||||
['均衡', '起手', 0.5537],
|
||||
['工巧', '炼药', 0.6352],
|
||||
['工巧', '先锋', 0.4415],
|
||||
['工巧', '狂战', 0.4979],
|
||||
['工巧', '法剑', 0.5548],
|
||||
['工巧', '圣佑', 0.4727],
|
||||
['工巧', '堡垒', 0.5285],
|
||||
['工巧', '起手', 0.5812],
|
||||
['炼药', '先锋', 0.4232],
|
||||
['炼药', '狂战', 0.5542],
|
||||
['炼药', '法剑', 0.5878],
|
||||
['炼药', '圣佑', 0.4755],
|
||||
['炼药', '堡垒', 0.5155],
|
||||
['炼药', '起手', 0.5478],
|
||||
['先锋', '狂战', 0.5637],
|
||||
['先锋', '法剑', 0.516],
|
||||
['先锋', '圣佑', 0.4738],
|
||||
['先锋', '堡垒', 0.6068],
|
||||
['先锋', '起手', 0.5946],
|
||||
['狂战', '法剑', 0.6253],
|
||||
['狂战', '圣佑', 0.4844],
|
||||
['狂战', '堡垒', 0.6703],
|
||||
['狂战', '起手', 0.5352],
|
||||
['法剑', '圣佑', 0.5668],
|
||||
['法剑', '堡垒', 0.5968],
|
||||
['法剑', '起手', 0.4903],
|
||||
['圣佑', '堡垒', 0.5733],
|
||||
['圣佑', '起手', 0.4104],
|
||||
['堡垒', '起手', 0.5186],
|
||||
] as const;
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { InventoryItem } from '../types';
|
||||
|
||||
export function buildCustomWorldStarterInventoryItems(): InventoryItem[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
export function buildCustomWorldStarterEquipmentItems(): InventoryItem[] {
|
||||
return [];
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export function EditorEmptyState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-6 text-sm text-zinc-300">
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { SaveBar, SelectField, type SelectFieldOption } from './FormFields';
|
||||
import { SectionCard } from './SectionCard';
|
||||
|
||||
export function EditorSelectionCard({
|
||||
title,
|
||||
description,
|
||||
selectLabel,
|
||||
selectValue,
|
||||
onSelectChange,
|
||||
selectOptions,
|
||||
saveLabel,
|
||||
onSave,
|
||||
isSaving,
|
||||
saveMessage,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
selectLabel: string;
|
||||
selectValue: string | number;
|
||||
onSelectChange: (value: string) => void;
|
||||
selectOptions: SelectFieldOption[];
|
||||
saveLabel: string;
|
||||
onSave: () => void;
|
||||
isSaving: boolean;
|
||||
saveMessage: string | null;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<SectionCard title={title} description={description}>
|
||||
<SelectField
|
||||
label={selectLabel}
|
||||
value={selectValue}
|
||||
onChange={onSelectChange}
|
||||
options={selectOptions}
|
||||
/>
|
||||
{children}
|
||||
<SaveBar
|
||||
saveLabel={saveLabel}
|
||||
onSave={onSave}
|
||||
isSaving={isSaving}
|
||||
saveMessage={saveMessage}
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export function cloneValue<T>(value: T): T {
|
||||
if (typeof structuredClone === 'function') {
|
||||
return structuredClone(value);
|
||||
}
|
||||
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
@@ -27,10 +27,6 @@ export const ASSET_API_PATHS = {
|
||||
characterAnimationJobs: `${ASSETS_API_BASE_PATH}/character-animation/jobs`,
|
||||
characterAnimationImportVideo: `${ASSETS_API_BASE_PATH}/character-animation/import-video`,
|
||||
characterAnimationTemplates: `${ASSETS_API_BASE_PATH}/character-animation/templates`,
|
||||
qwenSpriteMaster: `${ASSETS_API_BASE_PATH}/qwen-sprite/master`,
|
||||
qwenSpriteSheet: `${ASSETS_API_BASE_PATH}/qwen-sprite/sheet`,
|
||||
qwenSpriteFrameRepair: `${ASSETS_API_BASE_PATH}/qwen-sprite/frame-repair`,
|
||||
qwenSpriteSave: `${ASSETS_API_BASE_PATH}/qwen-sprite/save`,
|
||||
} as const;
|
||||
|
||||
export const EDITOR_ITEM_CATALOG_API_PATH =
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
saveEditorJsonResource,
|
||||
type EditorJsonResourceId,
|
||||
} from './editorApiClient';
|
||||
|
||||
type UseJsonSaveOptions = {
|
||||
resourceId: EditorJsonResourceId;
|
||||
payload: Record<string, unknown>;
|
||||
validate?: () => string[];
|
||||
successMessage: string;
|
||||
errorMessage: string;
|
||||
};
|
||||
|
||||
export function useJsonSave({
|
||||
resourceId,
|
||||
payload,
|
||||
validate,
|
||||
successMessage,
|
||||
errorMessage,
|
||||
}: UseJsonSaveOptions) {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveMessage, setSaveMessage] = useState<string | null>(null);
|
||||
|
||||
const save = async () => {
|
||||
setIsSaving(true);
|
||||
setSaveMessage(null);
|
||||
|
||||
const validationErrors = validate?.() ?? [];
|
||||
if (validationErrors.length > 0) {
|
||||
setSaveMessage(validationErrors.slice(0, 3).join(' | '));
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await saveEditorJsonResource(resourceId, payload, errorMessage);
|
||||
setSaveMessage(successMessage);
|
||||
} catch (error) {
|
||||
setSaveMessage(error instanceof Error ? error.message : errorMessage);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { isSaving, saveMessage, save };
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
|
||||
import type { Character, Encounter, GameState, StoryMoment, StoryOption } from '../../types';
|
||||
import {
|
||||
playOpeningAdventureSequence,
|
||||
type PreparedOpeningAdventure,
|
||||
} from './openingAdventure';
|
||||
|
||||
type BuildFallbackStoryForState = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
|
||||
type GenerateStoryForState = (params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
history: StoryMoment[];
|
||||
choice?: string;
|
||||
lastFunctionId?: string | null;
|
||||
optionCatalog?: StoryOption[] | null;
|
||||
}) => Promise<StoryMoment>;
|
||||
|
||||
type BuildDialogueStoryMoment = (
|
||||
npcName: string,
|
||||
text: string,
|
||||
options: StoryOption[],
|
||||
streaming?: boolean,
|
||||
) => StoryMoment;
|
||||
|
||||
export function useStoryBootstrap(params: {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
isLoading: boolean;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
|
||||
setAiError: Dispatch<SetStateAction<string | null>>;
|
||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
prepareOpeningAdventure: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => PreparedOpeningAdventure | null;
|
||||
getNpcEncounterKey: (encounter: Encounter) => string;
|
||||
buildFallbackStoryForState: BuildFallbackStoryForState;
|
||||
generateStoryForState: GenerateStoryForState;
|
||||
buildDialogueStoryMoment: BuildDialogueStoryMoment;
|
||||
buildStoryContextFromState: (
|
||||
state: GameState,
|
||||
extras?: {
|
||||
lastFunctionId?: string | null;
|
||||
openingCampBackground?: string | null;
|
||||
openingCampDialogue?: string | null;
|
||||
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
getStoryGenerationHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
hasRenderableDialogueTurns: (text: string, npcName: string) => boolean;
|
||||
inferOpeningCampFollowupOptions: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
baseOptions: StoryOption[],
|
||||
openingBackground: string,
|
||||
openingDialogue: string,
|
||||
) => Promise<StoryOption[]>;
|
||||
getTypewriterDelay: (char: string) => number;
|
||||
isNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
isInitialCompanionEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
}) {
|
||||
const {
|
||||
gameState,
|
||||
currentStory,
|
||||
isLoading,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
prepareOpeningAdventure,
|
||||
getNpcEncounterKey,
|
||||
buildFallbackStoryForState,
|
||||
generateStoryForState,
|
||||
buildDialogueStoryMoment,
|
||||
buildStoryContextFromState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
hasRenderableDialogueTurns,
|
||||
inferOpeningCampFollowupOptions,
|
||||
getTypewriterDelay,
|
||||
isNpcEncounter,
|
||||
isInitialCompanionEncounter,
|
||||
} = params;
|
||||
|
||||
const [preparedOpeningAdventure, setPreparedOpeningAdventure] =
|
||||
useState<PreparedOpeningAdventure | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
gameState.currentScene !== 'Story' ||
|
||||
!gameState.playerCharacter ||
|
||||
gameState.storyHistory.length > 0 ||
|
||||
currentStory ||
|
||||
!isNpcEncounter(gameState.currentEncounter) ||
|
||||
gameState.currentEncounter.specialBehavior !== 'initial_companion'
|
||||
) {
|
||||
setPreparedOpeningAdventure(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setPreparedOpeningAdventure(
|
||||
prepareOpeningAdventure(gameState, gameState.playerCharacter),
|
||||
);
|
||||
}, [
|
||||
currentStory,
|
||||
gameState,
|
||||
isNpcEncounter,
|
||||
prepareOpeningAdventure,
|
||||
]);
|
||||
|
||||
const startOpeningAdventure = useCallback(async () => {
|
||||
if (
|
||||
!gameState.playerCharacter ||
|
||||
!isNpcEncounter(gameState.currentEncounter)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const encounter = gameState.currentEncounter;
|
||||
if (encounter.specialBehavior !== 'initial_companion') {
|
||||
return;
|
||||
}
|
||||
|
||||
const preparedStory =
|
||||
preparedOpeningAdventure?.encounterKey === getNpcEncounterKey(encounter)
|
||||
? preparedOpeningAdventure
|
||||
: prepareOpeningAdventure(gameState, gameState.playerCharacter);
|
||||
|
||||
if (!preparedStory) {
|
||||
return;
|
||||
}
|
||||
|
||||
await playOpeningAdventureSequence({
|
||||
gameState,
|
||||
character: gameState.playerCharacter,
|
||||
encounter,
|
||||
preparedStory,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
buildDialogueStoryMoment,
|
||||
buildStoryContextFromState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
hasRenderableDialogueTurns,
|
||||
inferOpeningCampFollowupOptions,
|
||||
getTypewriterDelay,
|
||||
});
|
||||
}, [
|
||||
buildDialogueStoryMoment,
|
||||
buildStoryContextFromState,
|
||||
gameState,
|
||||
getNpcEncounterKey,
|
||||
getStoryGenerationHostileNpcs,
|
||||
getTypewriterDelay,
|
||||
hasRenderableDialogueTurns,
|
||||
inferOpeningCampFollowupOptions,
|
||||
isNpcEncounter,
|
||||
prepareOpeningAdventure,
|
||||
preparedOpeningAdventure,
|
||||
setAiError,
|
||||
setCurrentStory,
|
||||
setGameState,
|
||||
setIsLoading,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const startStory = async () => {
|
||||
if (
|
||||
gameState.currentScene !== 'Story' ||
|
||||
!gameState.worldType ||
|
||||
!gameState.playerCharacter ||
|
||||
currentStory ||
|
||||
isLoading
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
gameState.storyHistory.length === 0 &&
|
||||
isInitialCompanionEncounter(gameState.currentEncounter) &&
|
||||
!gameState.npcInteractionActive
|
||||
) {
|
||||
setAiError(null);
|
||||
void startOpeningAdventure();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
setAiError(null);
|
||||
const nextStory = await generateStoryForState({
|
||||
state: gameState,
|
||||
character: gameState.playerCharacter,
|
||||
history: [],
|
||||
});
|
||||
setGameState(applyStoryReasoningRecovery(gameState));
|
||||
setCurrentStory(nextStory);
|
||||
} catch (error) {
|
||||
console.error('Failed to start story:', error);
|
||||
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
|
||||
setCurrentStory(
|
||||
buildFallbackStoryForState(gameState, gameState.playerCharacter),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void startStory();
|
||||
}, [
|
||||
buildFallbackStoryForState,
|
||||
currentStory,
|
||||
gameState,
|
||||
generateStoryForState,
|
||||
isInitialCompanionEncounter,
|
||||
isLoading,
|
||||
setAiError,
|
||||
setCurrentStory,
|
||||
setGameState,
|
||||
setIsLoading,
|
||||
startOpeningAdventure,
|
||||
]);
|
||||
|
||||
const resetPreparedOpeningAdventure = useCallback(() => {
|
||||
setPreparedOpeningAdventure(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
preparedOpeningAdventure,
|
||||
startOpeningAdventure,
|
||||
resetPreparedOpeningAdventure,
|
||||
};
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import {
|
||||
applyEquipmentLoadoutToState,
|
||||
getEquipmentSlotFromItem,
|
||||
getEquipmentSlotLabel,
|
||||
} from '../data/equipmentEffects';
|
||||
import {
|
||||
EQUIPMENT_EQUIP_FUNCTION,
|
||||
EQUIPMENT_UNEQUIP_FUNCTION,
|
||||
} from '../data/functionCatalog';
|
||||
import {
|
||||
addInventoryItems,
|
||||
removeInventoryItem,
|
||||
} from '../data/npcInteractions';
|
||||
import { EquipmentSlotId, GameState, InventoryItem } from '../types';
|
||||
import type { CommitGeneratedState } from './generatedState';
|
||||
|
||||
function normalizeEquippedItem(item: InventoryItem) {
|
||||
return {
|
||||
...item,
|
||||
quantity: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function buildEquipResultText(
|
||||
item: InventoryItem,
|
||||
slot: EquipmentSlotId,
|
||||
replacedItem?: InventoryItem | null,
|
||||
) {
|
||||
return replacedItem
|
||||
? `你将${replacedItem.name}从${getEquipmentSlotLabel(slot)}位上换下,改为装备${item.name}。`
|
||||
: `你将${item.name}装备在${getEquipmentSlotLabel(slot)}位上。`;
|
||||
}
|
||||
|
||||
function buildUnequipResultText(item: InventoryItem) {
|
||||
return `你卸下了${item.name},暂时收回背包。`;
|
||||
}
|
||||
|
||||
export function useEquipmentFlow({
|
||||
gameState,
|
||||
commitGeneratedState,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
commitGeneratedState: CommitGeneratedState;
|
||||
}) {
|
||||
const handleEquipInventoryItem = useCallback(
|
||||
async (itemId: string) => {
|
||||
if (!gameState.playerCharacter || gameState.inBattle) return false;
|
||||
|
||||
const item = gameState.playerInventory.find(
|
||||
(candidate) => candidate.id === itemId,
|
||||
);
|
||||
if (!item || item.quantity <= 0) return false;
|
||||
|
||||
const slot = getEquipmentSlotFromItem(item);
|
||||
if (!slot) return false;
|
||||
|
||||
const replacedItem = gameState.playerEquipment[slot];
|
||||
const nextEquipment = {
|
||||
...gameState.playerEquipment,
|
||||
[slot]: normalizeEquippedItem(item),
|
||||
};
|
||||
|
||||
let nextInventory = removeInventoryItem(
|
||||
gameState.playerInventory,
|
||||
item.id,
|
||||
1,
|
||||
);
|
||||
if (replacedItem) {
|
||||
nextInventory = addInventoryItems(nextInventory, [replacedItem]);
|
||||
}
|
||||
|
||||
const nextState = applyEquipmentLoadoutToState(
|
||||
{
|
||||
...gameState,
|
||||
playerInventory: nextInventory,
|
||||
},
|
||||
nextEquipment,
|
||||
);
|
||||
|
||||
await commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
`装备${item.name}`,
|
||||
buildEquipResultText(item, slot, replacedItem),
|
||||
EQUIPMENT_EQUIP_FUNCTION.id,
|
||||
);
|
||||
|
||||
return true;
|
||||
},
|
||||
[commitGeneratedState, gameState],
|
||||
);
|
||||
|
||||
const handleUnequipItem = useCallback(
|
||||
async (slot: EquipmentSlotId) => {
|
||||
if (!gameState.playerCharacter || gameState.inBattle) return false;
|
||||
|
||||
const equippedItem = gameState.playerEquipment[slot];
|
||||
if (!equippedItem) return false;
|
||||
|
||||
const nextEquipment = {
|
||||
...gameState.playerEquipment,
|
||||
[slot]: null,
|
||||
};
|
||||
const nextState = applyEquipmentLoadoutToState(
|
||||
{
|
||||
...gameState,
|
||||
playerInventory: addInventoryItems(gameState.playerInventory, [
|
||||
equippedItem,
|
||||
]),
|
||||
},
|
||||
nextEquipment,
|
||||
);
|
||||
|
||||
await commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
`卸下${equippedItem.name}`,
|
||||
buildUnequipResultText(equippedItem),
|
||||
EQUIPMENT_UNEQUIP_FUNCTION.id,
|
||||
);
|
||||
|
||||
return true;
|
||||
},
|
||||
[commitGeneratedState, gameState],
|
||||
);
|
||||
|
||||
return {
|
||||
handleEquipInventoryItem,
|
||||
handleUnequipItem,
|
||||
};
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import {
|
||||
buildForgeSuccessText,
|
||||
executeDismantleItem,
|
||||
executeForgeRecipe,
|
||||
executeReforgeItem,
|
||||
getForgeRecipeViews,
|
||||
getReforgeCostView,
|
||||
} from '../data/forgeSystem';
|
||||
import {
|
||||
FORGE_CRAFT_FUNCTION,
|
||||
FORGE_DISMANTLE_FUNCTION,
|
||||
FORGE_REFORGE_FUNCTION,
|
||||
} from '../data/functionCatalog';
|
||||
import type { GameState } from '../types';
|
||||
import type { CommitGeneratedState } from './generatedState';
|
||||
|
||||
export function useForgeFlow({
|
||||
gameState,
|
||||
commitGeneratedState,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
commitGeneratedState: CommitGeneratedState;
|
||||
}) {
|
||||
const forgeRecipes = useMemo(
|
||||
() =>
|
||||
getForgeRecipeViews(
|
||||
gameState.playerInventory,
|
||||
gameState.playerCurrency,
|
||||
gameState.worldType,
|
||||
),
|
||||
[gameState.playerCurrency, gameState.playerInventory, gameState.worldType],
|
||||
);
|
||||
|
||||
const handleCraftRecipe = useCallback(
|
||||
async (recipeId: string) => {
|
||||
if (!gameState.playerCharacter || gameState.inBattle) return false;
|
||||
|
||||
const result = executeForgeRecipe(
|
||||
gameState.playerInventory,
|
||||
recipeId,
|
||||
gameState.worldType,
|
||||
gameState.playerCurrency,
|
||||
);
|
||||
if (!result) return false;
|
||||
|
||||
const recipe = forgeRecipes.find(
|
||||
(candidate) => candidate.id === recipeId,
|
||||
);
|
||||
const nextState: GameState = {
|
||||
...gameState,
|
||||
playerCurrency: result.currency,
|
||||
playerInventory: result.inventory,
|
||||
};
|
||||
|
||||
await commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
`制作${result.createdItem.name}`,
|
||||
buildForgeSuccessText('craft', {
|
||||
recipeName: recipe?.name ?? recipeId,
|
||||
createdItemName: result.createdItem.name,
|
||||
currencyText: recipe?.currencyText,
|
||||
}),
|
||||
FORGE_CRAFT_FUNCTION.id,
|
||||
);
|
||||
|
||||
return true;
|
||||
},
|
||||
[commitGeneratedState, forgeRecipes, gameState],
|
||||
);
|
||||
|
||||
const handleDismantleItem = useCallback(
|
||||
async (itemId: string) => {
|
||||
if (!gameState.playerCharacter || gameState.inBattle) return false;
|
||||
|
||||
const sourceItem = gameState.playerInventory.find(
|
||||
(item) => item.id === itemId,
|
||||
);
|
||||
if (!sourceItem) return false;
|
||||
|
||||
const result = executeDismantleItem(gameState.playerInventory, itemId);
|
||||
if (!result) return false;
|
||||
|
||||
const nextState: GameState = {
|
||||
...gameState,
|
||||
playerInventory: result.inventory,
|
||||
};
|
||||
|
||||
await commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
`拆解${sourceItem.name}`,
|
||||
buildForgeSuccessText('dismantle', {
|
||||
sourceItemName: sourceItem.name,
|
||||
outputNames: result.outputs.map((item) => item.name),
|
||||
}),
|
||||
FORGE_DISMANTLE_FUNCTION.id,
|
||||
);
|
||||
|
||||
return true;
|
||||
},
|
||||
[commitGeneratedState, gameState],
|
||||
);
|
||||
|
||||
const handleReforgeItem = useCallback(
|
||||
async (itemId: string) => {
|
||||
if (!gameState.playerCharacter || gameState.inBattle) return false;
|
||||
|
||||
const sourceItem = gameState.playerInventory.find(
|
||||
(item) => item.id === itemId,
|
||||
);
|
||||
if (!sourceItem) return false;
|
||||
|
||||
const result = executeReforgeItem(
|
||||
gameState.playerInventory,
|
||||
itemId,
|
||||
gameState.playerCurrency,
|
||||
);
|
||||
if (!result) return false;
|
||||
|
||||
const nextState: GameState = {
|
||||
...gameState,
|
||||
playerCurrency: Math.max(
|
||||
0,
|
||||
gameState.playerCurrency - result.currencyCost,
|
||||
),
|
||||
playerInventory: result.inventory,
|
||||
};
|
||||
|
||||
const reforgeCost = getReforgeCostView(sourceItem, gameState.worldType);
|
||||
|
||||
await commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
`重铸${sourceItem.name}`,
|
||||
buildForgeSuccessText('reforge', {
|
||||
sourceItemName: sourceItem.name,
|
||||
createdItemName: result.reforgedItem.name,
|
||||
currencyText: reforgeCost.currencyText,
|
||||
}),
|
||||
FORGE_REFORGE_FUNCTION.id,
|
||||
);
|
||||
|
||||
return true;
|
||||
},
|
||||
[commitGeneratedState, gameState],
|
||||
);
|
||||
|
||||
return {
|
||||
forgeRecipes,
|
||||
handleCraftRecipe,
|
||||
handleDismantleItem,
|
||||
handleReforgeItem,
|
||||
getReforgeCostView,
|
||||
};
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { appendBuildBuffs } from '../data/buildDamage';
|
||||
import { INVENTORY_USE_FUNCTION } from '../data/functionCatalog';
|
||||
import {
|
||||
buildInventoryUseResultText,
|
||||
isInventoryItemUsable,
|
||||
resolveInventoryItemUseEffect,
|
||||
} from '../data/inventoryEffects';
|
||||
import { removeInventoryItem } from '../data/npcInteractions';
|
||||
import { incrementGameRuntimeStats } from '../data/runtimeStats';
|
||||
import { GameState } from '../types';
|
||||
import type { CommitGeneratedState } from './generatedState';
|
||||
|
||||
type TickCooldowns = (
|
||||
cooldowns: Record<string, number>,
|
||||
) => Record<string, number>;
|
||||
|
||||
export function useInventoryFlow({
|
||||
gameState,
|
||||
commitGeneratedState,
|
||||
tickCooldowns,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
commitGeneratedState: CommitGeneratedState;
|
||||
tickCooldowns: TickCooldowns;
|
||||
}) {
|
||||
const handleUseInventoryItem = useCallback(
|
||||
async (itemId: string) => {
|
||||
if (!gameState.playerCharacter) return false;
|
||||
|
||||
const item = gameState.playerInventory.find(
|
||||
(candidate) => candidate.id === itemId,
|
||||
);
|
||||
if (!item || !isInventoryItemUsable(item) || item.quantity <= 0)
|
||||
return false;
|
||||
|
||||
const effect = resolveInventoryItemUseEffect(
|
||||
item,
|
||||
gameState.playerCharacter,
|
||||
);
|
||||
if (!effect) return false;
|
||||
|
||||
if (
|
||||
effect.hpRestore <= 0 &&
|
||||
effect.manaRestore <= 0 &&
|
||||
effect.cooldownReduction <= 0 &&
|
||||
effect.buildBuffs.length <= 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let cooldowns = gameState.playerSkillCooldowns;
|
||||
for (let index = 0; index < effect.cooldownReduction; index += 1) {
|
||||
cooldowns = tickCooldowns(cooldowns);
|
||||
}
|
||||
|
||||
const nextState: GameState = {
|
||||
...gameState,
|
||||
playerHp: Math.min(
|
||||
gameState.playerMaxHp,
|
||||
gameState.playerHp + effect.hpRestore,
|
||||
),
|
||||
playerMana: Math.min(
|
||||
gameState.playerMaxMana,
|
||||
gameState.playerMana + effect.manaRestore,
|
||||
),
|
||||
playerSkillCooldowns: cooldowns,
|
||||
activeBuildBuffs: appendBuildBuffs(
|
||||
gameState.activeBuildBuffs,
|
||||
effect.buildBuffs,
|
||||
),
|
||||
playerInventory: removeInventoryItem(
|
||||
gameState.playerInventory,
|
||||
item.id,
|
||||
1,
|
||||
),
|
||||
runtimeStats: incrementGameRuntimeStats(gameState.runtimeStats, {
|
||||
itemsUsed: 1,
|
||||
}),
|
||||
};
|
||||
|
||||
await commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
`使用${item.name}`,
|
||||
buildInventoryUseResultText(item, effect),
|
||||
INVENTORY_USE_FUNCTION.id,
|
||||
);
|
||||
|
||||
return true;
|
||||
},
|
||||
[commitGeneratedState, gameState, tickCooldowns],
|
||||
);
|
||||
|
||||
return {
|
||||
handleUseInventoryItem,
|
||||
};
|
||||
}
|
||||
@@ -1365,6 +1365,15 @@ body {
|
||||
background: var(--platform-track-fill);
|
||||
}
|
||||
|
||||
.platform-cover-artwork {
|
||||
background: radial-gradient(
|
||||
circle at top,
|
||||
var(--platform-surface-glow-a),
|
||||
transparent 42%
|
||||
),
|
||||
var(--platform-subpanel-fill);
|
||||
}
|
||||
|
||||
.platform-theme--light
|
||||
:where(
|
||||
.platform-surface:not(.platform-surface--hero),
|
||||
@@ -2398,6 +2407,26 @@ button {
|
||||
@media (max-width: 640px) {
|
||||
:root {
|
||||
--ui-scale: 0.8;
|
||||
--platform-bottom-nav-height: 3.35rem;
|
||||
--platform-bottom-nav-label-size: 10px;
|
||||
}
|
||||
|
||||
.platform-main-shell {
|
||||
padding-inline: max(0.75rem, env(safe-area-inset-left)) max(
|
||||
0.75rem,
|
||||
env(safe-area-inset-right)
|
||||
);
|
||||
padding-top: max(0.75rem, env(safe-area-inset-top));
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.platform-page-stage {
|
||||
border-radius: 1.45rem;
|
||||
}
|
||||
|
||||
.platform-bottom-nav {
|
||||
position: relative;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.selection-hero-brand {
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
export const CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT = `你是 JSON 修复器。
|
||||
你会收到一段本应为单个 JSON 对象的文本。
|
||||
你的唯一任务是把它修复成能被 JSON.parse 直接解析的单个 JSON 对象。
|
||||
不要输出 Markdown、代码块、解释、注释或额外文字。
|
||||
尽量保留原始语义,只修复格式问题;必要时可以补齐缺失的引号、逗号、括号、数组闭合或缺失字段。`;
|
||||
|
||||
export const CUSTOM_WORLD_GENERATION_JSON_ONLY_SYSTEM_PROMPT = `你是严格的自定义世界 JSON 生成器。
|
||||
只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。`;
|
||||
@@ -1,629 +0,0 @@
|
||||
export type QwenSpriteActionTemplateId =
|
||||
| 'idle'
|
||||
| 'run'
|
||||
| 'attack_slash'
|
||||
| 'hurt'
|
||||
| 'die';
|
||||
|
||||
export type QwenSpriteActionTemplate = {
|
||||
id: QwenSpriteActionTemplateId;
|
||||
label: string;
|
||||
loop: boolean;
|
||||
defaultFps: number;
|
||||
bodyTravel: string;
|
||||
weaponRule: string;
|
||||
stagingDirection?: string;
|
||||
defaultDetailText?: string;
|
||||
sequenceLines: [string, string, string, string];
|
||||
ending: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_MASTER_NEGATIVE_PROMPT =
|
||||
'正面视角,左朝向,完全 90 度纯右视图,镜头透视,半身像,脚被裁切,头顶被裁切,多角色,复杂背景,建筑场景,道具堆叠,漂浮物,烟雾环境,武器消失,武器换手,额外手臂,额外腿,服装变化,脸部变化,模糊,运动模糊,文字,水印,UI 元素';
|
||||
|
||||
export const DEFAULT_SHEET_NEGATIVE_PROMPT =
|
||||
'多角色,左右朝向混乱,前视图,背视图,镜头切换,景别变化,特写,脚底裁切,头顶裁切,缺手,缺脚,额外肢体,武器消失,武器换手,服装变化,脸部变化,发型变化,动作不连续,重复帧过多,构图混乱,背景复杂,强透视,运动模糊,残影,文字,水印,UI,边框覆盖角色';
|
||||
|
||||
export const DEFAULT_REPAIR_NEGATIVE_PROMPT =
|
||||
'多角色,错误朝向,缺手,缺脚,额外肢体,武器消失,武器换手,脸部变化,发型变化,服装变化,模糊,运动模糊,复杂背景,文字,水印';
|
||||
|
||||
const CHIBI_STYLE_TEXT =
|
||||
'Q版大头身动作角色,头身比固定控制在 2 到 3 头身,头部占比明显更大,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。';
|
||||
const PIXEL_STYLE_TEXT =
|
||||
'参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。';
|
||||
const SIDE_FACING_RIGHT_TEXT =
|
||||
'角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。';
|
||||
const SUBJECT_ONLY_TEXT =
|
||||
'画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。';
|
||||
const CLEAN_BACKGROUND_TEXT =
|
||||
'背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。';
|
||||
const STYLE_REFERENCE_SCOPE_TEXT =
|
||||
'参考图不仅用于约束像素画风、颜色组织、头身比例、右朝向和镜头语言,还要严格约束身体结构骨架。生成结果必须优先沿用参考图的人形动作角色身体结构,包括头、躯干、双臂、双腿、站姿重心和整体头身比;可以变化发型、服装、主题配饰和材质,但不要脱离参考图的人形身体结构。';
|
||||
const CONCEPT_INTERPRETATION_TEXT =
|
||||
'请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先生成人形拟人化角色,让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。';
|
||||
const HUMANLIKE_PRIORITY_TEXT =
|
||||
'默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时,才改为对应非人身体。';
|
||||
const CONCEPT_HIERARCHY_TEXT =
|
||||
'视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。';
|
||||
const THEME_APPLICATION_BOUNDARY_TEXT =
|
||||
'主题词默认只作用在角色自身的服装剪裁、材质、纹样、饰品、武器和发光细节上,不要把主题词自动扩写成背景建筑、自然场景、漂浮装饰或额外环境物件。';
|
||||
const CHIBI_CHARACTER_TEXT =
|
||||
'即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色,方便读图和后续动画化。';
|
||||
|
||||
const SETTING_AND_ROLE_ALIGNMENT_TEXT =
|
||||
'先从角色设定中提炼世界设定、时代氛围、阵营身份、职业职责、战斗习惯、装备结构、材质配色和情绪气质,再把这些信息落实到发型、服装层次、护具、武器、饰品、轮廓和动作节奏里,不要混入与设定无关的现代服饰、写实摄影镜头、枪械体系或其他世界观元素。';
|
||||
const CHARACTER_DETAIL_COVERAGE_TEXT =
|
||||
'角色描述需要尽量落到可视化细节:发型与脸部识别点、年龄层与气质、服装层次、护具与配饰、武器/法器类型、主色与材质、职业姿态与世界观痕迹,避免只有抽象身份词。';
|
||||
|
||||
export const DEFAULT_CHARACTER_BRIEF =
|
||||
'魔潮复苏边境城邦中的少女遗迹冒险者,Q版大头身,约 2 到 3 头身,金棕色微卷短发,琥珀色眼睛,额前碎发与侧边发束清晰,表情明亮但带警觉;穿分层轻甲、短披风和旅行短裙,皮革护腕、金属护膝、系带短靴完整可见;腰间挂药剂包、地图筒与小型护符,右手持单手短剑,主色为蜂蜜金、墨绿和旧银,轮廓利落,像长期在遗迹与荒野间行动的年轻探索者。';
|
||||
|
||||
export const PLAYABLE_CHARACTER_STYLE_REFERENCE_SOURCES = [
|
||||
'/character/Sword Princess/Original/Hero/idle/Idle01.png',
|
||||
'/character/Archer Hero/Original/Hero/idle/idle01.png',
|
||||
'/character/Girl Hero 1/Original/Hero/Idle/Idle01.png',
|
||||
'/character/Punch Hero 3/Original/Hero/Idle/Idle01.png',
|
||||
'/character/Fighter 4/original/Hero/idle/idle01.png',
|
||||
];
|
||||
|
||||
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 ACTION_TEMPLATE_DETAILS: Record<
|
||||
QwenSpriteActionTemplateId,
|
||||
{ stagingDirection: string; defaultDetailText: string }
|
||||
> = {
|
||||
idle: {
|
||||
stagingDirection:
|
||||
'演出重点是轻呼吸、微幅重心起伏、戒备感和可循环衔接。',
|
||||
defaultDetailText:
|
||||
'保持与角色设定一致的戒备气质和职业站姿,重心稳在脚下,呼吸起伏轻微,披风、衣摆、发梢和小型饰品只有细小摆动,武器安静跟随身体微动,像角色在所属世界里随时准备探索、交涉或开战的待机瞬间。',
|
||||
},
|
||||
run: {
|
||||
stagingDirection:
|
||||
'演出重点是持续推进、步点清楚、上身稳定和装备惯性。',
|
||||
defaultDetailText:
|
||||
'跑动时要体现角色职业和世界设定中的装备重量感,身体略微前压,步幅清晰,手臂与武器顺势摆动,披风、衣摆和挂件跟着惯性后甩,像角色正穿过战场、街巷或遗迹通道迅速移动,节奏连续稳定。',
|
||||
},
|
||||
attack_slash: {
|
||||
stagingDirection:
|
||||
'演出重点是收身蓄力、爆发斩击、武器轨迹和收招稳定。',
|
||||
defaultDetailText:
|
||||
'攻击前先有短暂收身蓄力,再顺着主手武器做干净利落的前踏斩击,爆发点明确,武器轨迹贴合角色职业习惯与世界观武器设定,衣摆和挂件随发力甩动,收招后还能稳住重心,像久经战斗训练的真实角色动作。',
|
||||
},
|
||||
hurt: {
|
||||
stagingDirection:
|
||||
'演出重点是冲击反馈、短暂失衡、惯性摆动和重新稳住。',
|
||||
defaultDetailText:
|
||||
'受击瞬间要有明确冲击反馈,肩背后仰或身体侧偏,表情短促吃痛但不夸张,武器和手臂因惯性发生合理摆动,服装层次和挂件跟着抖动一下,随后努力稳住姿态,像这个角色在其世界观里真实挨到一击后的反应。',
|
||||
},
|
||||
die: {
|
||||
stagingDirection:
|
||||
'演出重点是失衡下坠、动作逐渐停尽和终止姿态清晰。',
|
||||
defaultDetailText:
|
||||
'死亡动作要先表现失衡与力量被抽离,再完成明显倒下或瘫落,武器和四肢随惯性下坠,披风、衣摆和饰品有最后一次明显摆动,最终停在清晰的终止姿态,符合角色身份、装备重量和世界氛围,不要轻飘或喜剧化。',
|
||||
},
|
||||
};
|
||||
|
||||
export function getActionTemplateById(id: QwenSpriteActionTemplateId) {
|
||||
const template =
|
||||
QWEN_SPRITE_ACTION_TEMPLATES.find((candidate) => candidate.id === id) ??
|
||||
QWEN_SPRITE_ACTION_TEMPLATES[0];
|
||||
return {
|
||||
...template,
|
||||
...ACTION_TEMPLATE_DETAILS[template.id],
|
||||
};
|
||||
}
|
||||
|
||||
export function readFileAsDataUrl(file: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result ?? ''));
|
||||
reader.onerror = () => reject(reader.error ?? new Error('读取文件失败'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function loadImageFromSource(source: string) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.crossOrigin = 'anonymous';
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error(`加载图片失败:${source}`));
|
||||
image.src = source;
|
||||
});
|
||||
}
|
||||
|
||||
function drawContainedImage(
|
||||
context: CanvasRenderingContext2D,
|
||||
image: HTMLImageElement,
|
||||
options: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
},
|
||||
) {
|
||||
const fitScale = Math.min(
|
||||
options.width / image.width,
|
||||
options.height / image.height,
|
||||
);
|
||||
const drawWidth = image.width * fitScale;
|
||||
const drawHeight = image.height * fitScale;
|
||||
const drawX = options.x + (options.width - drawWidth) / 2;
|
||||
const drawY = options.y + (options.height - drawHeight) / 2;
|
||||
|
||||
context.drawImage(image, drawX, drawY, drawWidth, drawHeight);
|
||||
}
|
||||
|
||||
export async function sliceSpriteSheetFrames(
|
||||
spriteSource: string,
|
||||
options: {
|
||||
rows: number;
|
||||
cols: number;
|
||||
},
|
||||
) {
|
||||
const image = await loadImageFromSource(spriteSource);
|
||||
const frameWidth = Math.floor(image.width / options.cols);
|
||||
const frameHeight = Math.floor(image.height / options.rows);
|
||||
const frames: string[] = [];
|
||||
|
||||
for (let rowIndex = 0; rowIndex < options.rows; rowIndex += 1) {
|
||||
for (let colIndex = 0; colIndex < options.cols; colIndex += 1) {
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('无法创建画布上下文');
|
||||
}
|
||||
|
||||
canvas.width = frameWidth;
|
||||
canvas.height = frameHeight;
|
||||
context.drawImage(
|
||||
image,
|
||||
colIndex * frameWidth,
|
||||
rowIndex * frameHeight,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
0,
|
||||
0,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
);
|
||||
frames.push(canvas.toDataURL('image/png'));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
frames,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
};
|
||||
}
|
||||
|
||||
export async function extractSpriteFrame(
|
||||
spriteSource: string,
|
||||
options: {
|
||||
rows: number;
|
||||
cols: number;
|
||||
frameIndex: number;
|
||||
outputSize?: number;
|
||||
},
|
||||
) {
|
||||
const sliced = await sliceSpriteSheetFrames(spriteSource, {
|
||||
rows: options.rows,
|
||||
cols: options.cols,
|
||||
});
|
||||
const frameSource = sliced.frames[options.frameIndex];
|
||||
|
||||
if (!frameSource) {
|
||||
throw new Error('帧索引超出范围。');
|
||||
}
|
||||
|
||||
if (!options.outputSize) {
|
||||
return frameSource;
|
||||
}
|
||||
|
||||
const image = await loadImageFromSource(frameSource);
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('无法创建画布上下文');
|
||||
}
|
||||
|
||||
canvas.width = options.outputSize;
|
||||
canvas.height = options.outputSize;
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
export async function replaceSpriteFrame(
|
||||
spriteSource: string,
|
||||
options: {
|
||||
rows: number;
|
||||
cols: number;
|
||||
frameIndex: number;
|
||||
replacementSource: string;
|
||||
},
|
||||
) {
|
||||
const spriteImage = await loadImageFromSource(spriteSource);
|
||||
const replacementImage = await loadImageFromSource(options.replacementSource);
|
||||
const frameWidth = Math.floor(spriteImage.width / options.cols);
|
||||
const frameHeight = Math.floor(spriteImage.height / options.rows);
|
||||
const rowIndex = Math.floor(options.frameIndex / options.cols);
|
||||
const colIndex = options.frameIndex % options.cols;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('无法创建画布上下文');
|
||||
}
|
||||
|
||||
canvas.width = spriteImage.width;
|
||||
canvas.height = spriteImage.height;
|
||||
context.drawImage(spriteImage, 0, 0);
|
||||
context.clearRect(
|
||||
colIndex * frameWidth,
|
||||
rowIndex * frameHeight,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
);
|
||||
context.drawImage(
|
||||
replacementImage,
|
||||
colIndex * frameWidth,
|
||||
rowIndex * frameHeight,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
);
|
||||
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
export function buildOrderedActiveFrameIndices(
|
||||
frameOrder: number[],
|
||||
activeFrames: number[],
|
||||
) {
|
||||
return frameOrder.filter((frameIndex) => activeFrames.includes(frameIndex));
|
||||
}
|
||||
|
||||
export function buildOrderedActiveFrameSources(
|
||||
frameDataUrls: string[],
|
||||
frameOrder: number[],
|
||||
activeFrames: number[],
|
||||
) {
|
||||
return buildOrderedActiveFrameIndices(frameOrder, activeFrames)
|
||||
.map((frameIndex) => frameDataUrls[frameIndex] ?? '')
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export async function composeSpriteSheetFromFrames(
|
||||
frameSources: string[],
|
||||
options: {
|
||||
cols: number;
|
||||
rows?: number;
|
||||
frameWidth?: number;
|
||||
frameHeight?: number;
|
||||
padToGrid?: boolean;
|
||||
},
|
||||
) {
|
||||
if (frameSources.length === 0) {
|
||||
throw new Error('没有可用于拼接精灵表的帧。');
|
||||
}
|
||||
|
||||
const images = await Promise.all(
|
||||
frameSources.map((source) => loadImageFromSource(source)),
|
||||
);
|
||||
const frameWidth =
|
||||
options.frameWidth ??
|
||||
Math.max(...images.map((image) => image.width), 1);
|
||||
const frameHeight =
|
||||
options.frameHeight ??
|
||||
Math.max(...images.map((image) => image.height), 1);
|
||||
const rows =
|
||||
options.rows ?? Math.max(1, Math.ceil(images.length / options.cols));
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('无法创建画布上下文');
|
||||
}
|
||||
|
||||
canvas.width = frameWidth * options.cols;
|
||||
canvas.height = frameHeight * rows;
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const totalCells = options.padToGrid ? rows * options.cols : images.length;
|
||||
for (let index = 0; index < totalCells; index += 1) {
|
||||
const image = images[index];
|
||||
if (!image) {
|
||||
continue;
|
||||
}
|
||||
const rowIndex = Math.floor(index / options.cols);
|
||||
const colIndex = index % options.cols;
|
||||
drawContainedImage(context, image, {
|
||||
x: colIndex * frameWidth,
|
||||
y: rowIndex * frameHeight,
|
||||
width: frameWidth,
|
||||
height: frameHeight,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
dataUrl: canvas.toDataURL('image/png'),
|
||||
rows,
|
||||
cols: options.cols,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
frameCount: frameSources.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildPlayableCharacterStyleReferenceBoard(
|
||||
sources = PLAYABLE_CHARACTER_STYLE_REFERENCE_SOURCES,
|
||||
) {
|
||||
const images = await Promise.all(
|
||||
sources.map((source) => loadImageFromSource(source)),
|
||||
);
|
||||
const cols = 3;
|
||||
const rows = 2;
|
||||
const cellSize = 320;
|
||||
const padding = 24;
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('无法创建画布上下文');
|
||||
}
|
||||
|
||||
canvas.width = cols * cellSize + padding * 2;
|
||||
canvas.height = rows * cellSize + padding * 2;
|
||||
context.fillStyle = '#f6f0dd';
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
context.imageSmoothingEnabled = false;
|
||||
|
||||
images.forEach((image, index) => {
|
||||
const colIndex = index % cols;
|
||||
const rowIndex = Math.floor(index / cols);
|
||||
drawContainedImage(context, image, {
|
||||
x: padding + colIndex * cellSize,
|
||||
y: padding + rowIndex * cellSize,
|
||||
width: cellSize,
|
||||
height: cellSize,
|
||||
});
|
||||
});
|
||||
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
export function buildMasterPrompt(characterBrief: string) {
|
||||
return [
|
||||
'???2D ???????????????????????????????????????????? sprite sheet ???',
|
||||
`?????${SIDE_FACING_RIGHT_TEXT}`,
|
||||
`?????${SUBJECT_ONLY_TEXT}`,
|
||||
`?????1:1 ??????????????????????????????????????????????????${CLEAN_BACKGROUND_TEXT}`,
|
||||
`?????${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT} ?????????????????????????????????????/??/???????????????????????`,
|
||||
SETTING_AND_ROLE_ALIGNMENT_TEXT,
|
||||
CHARACTER_DETAIL_COVERAGE_TEXT,
|
||||
CONCEPT_INTERPRETATION_TEXT,
|
||||
HUMANLIKE_PRIORITY_TEXT,
|
||||
CONCEPT_HIERARCHY_TEXT,
|
||||
THEME_APPLICATION_BOUNDARY_TEXT,
|
||||
characterBrief.trim(),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function buildSheetPrompt(options: {
|
||||
characterBrief: string;
|
||||
actionTemplate: QwenSpriteActionTemplate;
|
||||
extraDirection: string;
|
||||
}) {
|
||||
return [
|
||||
`???1??????????? 4x4 ? sprite sheet?? 16 ????????????????????????????????????????????????????????????????????????????????????????????????????? 90 ??????${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT}`,
|
||||
SETTING_AND_ROLE_ALIGNMENT_TEXT,
|
||||
CONCEPT_INTERPRETATION_TEXT,
|
||||
HUMANLIKE_PRIORITY_TEXT,
|
||||
CONCEPT_HIERARCHY_TEXT,
|
||||
THEME_APPLICATION_BOUNDARY_TEXT,
|
||||
`????${options.actionTemplate.label}`,
|
||||
`???????${options.actionTemplate.stagingDirection ?? ACTION_TEMPLATE_DETAILS[options.actionTemplate.id].stagingDirection}`,
|
||||
`?????${options.actionTemplate.loop ? '?' : '?'}`,
|
||||
`?????${options.actionTemplate.bodyTravel}`,
|
||||
`?????${options.actionTemplate.weaponRule}`,
|
||||
...options.actionTemplate.sequenceLines,
|
||||
`?????${options.actionTemplate.ending}`,
|
||||
'?????????????????????????????????????????????????????????????????????????????????? sprite frames?',
|
||||
options.characterBrief.trim(),
|
||||
`???????${options.extraDirection.trim() || options.actionTemplate.defaultDetailText || ACTION_TEMPLATE_DETAILS[options.actionTemplate.id].defaultDetailText}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function buildRepairPrompt(options: {
|
||||
issueText: string;
|
||||
useNeighborLabel: '???' | '???';
|
||||
}) {
|
||||
return [
|
||||
`???1??????????2??????????3???????2??${options.useNeighborLabel}?`,
|
||||
`?????????????????????????????????????????????????????????2???????1?????????????? 90 ??????${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT} ${SETTING_AND_ROLE_ALIGNMENT_TEXT} ${CONCEPT_INTERPRETATION_TEXT} ${HUMANLIKE_PRIORITY_TEXT} ${CONCEPT_HIERARCHY_TEXT} ${THEME_APPLICATION_BOUNDARY_TEXT} ???3???????????????? sprite sheet ??`,
|
||||
'?????????????????????????',
|
||||
`?????${options.issueText.trim() || '????????????????????'}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildVideoActionPrompt(options: {
|
||||
actionTemplate: QwenSpriteActionTemplate;
|
||||
actionDetailText: string;
|
||||
useChromaKey: boolean;
|
||||
characterBrief: string;
|
||||
}) {
|
||||
return [
|
||||
`???????????????? ${options.actionTemplate.label}?`,
|
||||
`??????1??????????????????????????????????? 90 ??????${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT}`,
|
||||
SETTING_AND_ROLE_ALIGNMENT_TEXT,
|
||||
CONCEPT_INTERPRETATION_TEXT,
|
||||
HUMANLIKE_PRIORITY_TEXT,
|
||||
CONCEPT_HIERARCHY_TEXT,
|
||||
THEME_APPLICATION_BOUNDARY_TEXT,
|
||||
`???????${options.actionTemplate.stagingDirection ?? ACTION_TEMPLATE_DETAILS[options.actionTemplate.id].stagingDirection}`,
|
||||
`?????${options.actionTemplate.sequenceLines.join('?')}??????${options.actionTemplate.ending}?`,
|
||||
options.useChromaKey
|
||||
? '??????????????????????????????'
|
||||
: '?????????????',
|
||||
`???????${options.actionDetailText.trim() || options.actionTemplate.defaultDetailText || ACTION_TEMPLATE_DETAILS[options.actionTemplate.id].defaultDetailText}`,
|
||||
`?????${options.characterBrief.trim()}`,
|
||||
'?????????????????????????????????????????',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
export async function triggerDataUrlDownload(
|
||||
filename: string,
|
||||
dataUrl: string,
|
||||
) {
|
||||
const response = await fetch(dataUrl);
|
||||
const blob = await response.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
|
||||
export function triggerJsonDownload(filename: string, value: unknown) {
|
||||
const blob = new Blob([JSON.stringify(value, null, 2)], {
|
||||
type: 'application/json',
|
||||
});
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
|
||||
export function buildDefaultFrameOrder(frameCount: number) {
|
||||
return Array.from({ length: frameCount }, (_, index) => index);
|
||||
}
|
||||
|
||||
export function restoreAllFrames(frameCount: number) {
|
||||
return buildDefaultFrameOrder(frameCount);
|
||||
}
|
||||
|
||||
export function buildMasterNegativePrompt(_characterBrief: string) {
|
||||
return `${DEFAULT_MASTER_NEGATIVE_PROMPT},不要机械地把主题词直接画成完整怪物本体,也不要把主题词自动扩写成角色以外的场景元素,除非文字设定明确要求`;
|
||||
}
|
||||
|
||||
export function buildSheetNegativePrompt(_characterBrief: string) {
|
||||
return `${DEFAULT_SHEET_NEGATIVE_PROMPT},不要机械地把主题词直接画成完整怪物本体,除非文字设定明确要求非人身体`;
|
||||
}
|
||||
|
||||
export function buildRepairNegativePrompt(_characterBrief: string) {
|
||||
return `${DEFAULT_REPAIR_NEGATIVE_PROMPT},不要机械地把主题词直接画成完整怪物本体,除非文字设定明确要求非人身体`;
|
||||
}
|
||||
|
||||
export function moveFrameOrderItem(
|
||||
frameOrder: number[],
|
||||
frameIndex: number,
|
||||
direction: -1 | 1,
|
||||
) {
|
||||
const currentOrderIndex = frameOrder.indexOf(frameIndex);
|
||||
if (currentOrderIndex < 0) {
|
||||
return frameOrder;
|
||||
}
|
||||
|
||||
const targetIndex = currentOrderIndex + direction;
|
||||
if (targetIndex < 0 || targetIndex >= frameOrder.length) {
|
||||
return frameOrder;
|
||||
}
|
||||
|
||||
const nextOrder = [...frameOrder];
|
||||
const [item] = nextOrder.splice(currentOrderIndex, 1);
|
||||
nextOrder.splice(targetIndex, 0, item);
|
||||
return nextOrder;
|
||||
}
|
||||
|
||||
export function toggleActiveFrame(activeFrames: number[], frameIndex: number) {
|
||||
if (activeFrames.includes(frameIndex)) {
|
||||
return activeFrames.filter((item) => item !== frameIndex);
|
||||
}
|
||||
|
||||
return [...activeFrames, frameIndex].sort((left, right) => left - right);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export const STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT = `你是 RPG 中文叙事文本修复器。
|
||||
你会收到一个已经解析过的剧情 JSON 对象。
|
||||
你的唯一任务是把 storyText 和 options[].actionText 中的英文句子、中英混杂句式、英文解释改写成自然中文。
|
||||
必须保持 JSON 结构、encounter、options 数量、options 顺序以及每个 functionId 完全不变。
|
||||
只输出一个 JSON 对象,不要输出 Markdown、代码块、解释、注释或额外文字。`;
|
||||
@@ -9,7 +9,7 @@ describe('matchAppRoute', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('routes deprecated editor paths back to the main game', () => {
|
||||
it('routes former standalone editor paths back to the main game', () => {
|
||||
expect(matchAppRoute('/item-editor/tools')).toEqual({
|
||||
kind: 'game',
|
||||
});
|
||||
@@ -26,10 +26,4 @@ describe('matchAppRoute', () => {
|
||||
kind: 'game',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the sprite tool route', () => {
|
||||
expect(matchAppRoute('/sprite-tool')).toEqual({
|
||||
kind: 'qwen-sprite-tool',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,9 +9,6 @@ type AppRouteComponent = LazyExoticComponent<
|
||||
export type AppRouteMatch =
|
||||
| {
|
||||
kind: 'game';
|
||||
}
|
||||
| {
|
||||
kind: 'qwen-sprite-tool';
|
||||
};
|
||||
|
||||
export type ResolvedAppRoute = {
|
||||
@@ -23,15 +20,6 @@ export type ResolvedAppRoute = {
|
||||
};
|
||||
|
||||
const GameApp = lazy(() => import('../AuthenticatedApp')) as AppRouteComponent;
|
||||
const QwenSpriteToolApp = lazy(
|
||||
() => import('../tools/QwenSpriteSheetTool'),
|
||||
) as AppRouteComponent;
|
||||
|
||||
const QWEN_SPRITE_TOOL_PREFIXES = [
|
||||
'/qwen-sprite-tool',
|
||||
'/sprite-tool',
|
||||
'/pixelmotion-qwen',
|
||||
];
|
||||
|
||||
function normalizeRoutePath(pathname: string) {
|
||||
const trimmedPathname = pathname.trim().toLowerCase();
|
||||
@@ -43,25 +31,8 @@ function normalizeRoutePath(pathname: string) {
|
||||
return trimmedPathname.replace(/\/+$/u, '');
|
||||
}
|
||||
|
||||
function matchesRoutePrefix(pathname: string, prefix: string) {
|
||||
const normalizedPrefix = normalizeRoutePath(prefix);
|
||||
|
||||
return (
|
||||
pathname === normalizedPrefix || pathname.startsWith(`${normalizedPrefix}/`)
|
||||
);
|
||||
}
|
||||
|
||||
export function matchAppRoute(pathname: string): AppRouteMatch {
|
||||
const normalizedPathname = normalizeRoutePath(pathname);
|
||||
const isQwenSpriteToolRoute = QWEN_SPRITE_TOOL_PREFIXES.some((prefix) =>
|
||||
matchesRoutePrefix(normalizedPathname, prefix),
|
||||
);
|
||||
|
||||
if (isQwenSpriteToolRoute) {
|
||||
return {
|
||||
kind: 'qwen-sprite-tool',
|
||||
};
|
||||
}
|
||||
void normalizeRoutePath(pathname);
|
||||
|
||||
return {
|
||||
kind: 'game',
|
||||
@@ -71,15 +42,6 @@ export function matchAppRoute(pathname: string): AppRouteMatch {
|
||||
export function resolveAppRoute(pathname: string): ResolvedAppRoute {
|
||||
const matchedRoute = matchAppRoute(pathname);
|
||||
|
||||
if (matchedRoute.kind === 'qwen-sprite-tool') {
|
||||
return {
|
||||
kind: matchedRoute.kind,
|
||||
loadingEyebrow: '正在载入精灵表工坊',
|
||||
loadingText: '正在载入 Qwen 精灵表工具...',
|
||||
Component: QwenSpriteToolApp,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'game',
|
||||
loadingEyebrow: '正在载入游戏',
|
||||
|
||||
@@ -10,11 +10,7 @@ import type {
|
||||
SendCustomWorldAgentMessageRequest,
|
||||
} from '../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type {
|
||||
AnswerCustomWorldSessionQuestionRequest,
|
||||
CreateCustomWorldSessionRequest,
|
||||
CustomWorldGenerationProgress,
|
||||
CustomWorldSessionRecord,
|
||||
CustomWorldSessionSummary,
|
||||
GenerateCustomWorldProfileInput,
|
||||
GenerateCustomWorldProfileOptions,
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
@@ -357,190 +353,8 @@ export async function generateCustomWorldProfile(
|
||||
input: GenerateCustomWorldProfileInput | string,
|
||||
options: GenerateCustomWorldProfileOptions = {},
|
||||
): Promise<CustomWorldProfile> {
|
||||
const normalizedInput =
|
||||
typeof input === 'string'
|
||||
? {
|
||||
settingText: input,
|
||||
creatorIntent: null,
|
||||
generationMode: 'full' as const,
|
||||
}
|
||||
: {
|
||||
settingText: input.settingText,
|
||||
creatorIntent: input.creatorIntent ?? null,
|
||||
generationMode:
|
||||
input.generationMode === 'fast'
|
||||
? ('fast' as const)
|
||||
: ('full' as const),
|
||||
};
|
||||
|
||||
const session = await createCustomWorldSession({
|
||||
settingText: normalizedInput.settingText,
|
||||
creatorIntent: normalizedInput.creatorIntent as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null,
|
||||
generationMode: normalizedInput.generationMode,
|
||||
});
|
||||
|
||||
const fallbackAnswerMap: Record<string, string> = {
|
||||
world_hook:
|
||||
typeof normalizedInput.creatorIntent?.worldHook === 'string' &&
|
||||
normalizedInput.creatorIntent.worldHook.trim()
|
||||
? normalizedInput.creatorIntent.worldHook.trim()
|
||||
: normalizedInput.settingText.trim().slice(0, 120) ||
|
||||
'这是一个围绕失衡秩序展开的世界。',
|
||||
player_premise:
|
||||
typeof normalizedInput.creatorIntent?.playerPremise === 'string' &&
|
||||
normalizedInput.creatorIntent.playerPremise.trim()
|
||||
? normalizedInput.creatorIntent.playerPremise.trim()
|
||||
: '玩家是一名被卷入局势中心的行动者。',
|
||||
opening_situation:
|
||||
typeof normalizedInput.creatorIntent?.openingSituation === 'string' &&
|
||||
normalizedInput.creatorIntent.openingSituation.trim()
|
||||
? normalizedInput.creatorIntent.openingSituation.trim()
|
||||
: '故事开局时,玩家正身处风暴边缘,必须立刻判断立场与风险。',
|
||||
core_conflict:
|
||||
Array.isArray(normalizedInput.creatorIntent?.coreConflicts) &&
|
||||
normalizedInput.creatorIntent.coreConflicts.length > 0
|
||||
? normalizedInput.creatorIntent.coreConflicts
|
||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter(Boolean)
|
||||
.join(';')
|
||||
: '旧秩序与新威胁正在同时逼近,各方都在争夺主动权。',
|
||||
};
|
||||
|
||||
for (const question of session.questions ?? []) {
|
||||
if (question.answer?.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const answer =
|
||||
fallbackAnswerMap[question.id] || normalizedInput.settingText.trim();
|
||||
await answerCustomWorldSessionQuestion(session.sessionId, {
|
||||
questionId: question.id,
|
||||
answer,
|
||||
});
|
||||
}
|
||||
|
||||
return streamCustomWorldSessionGeneration(session.sessionId, options);
|
||||
}
|
||||
|
||||
export async function streamCustomWorldSessionGeneration(
|
||||
sessionId: string,
|
||||
options: GenerateCustomWorldProfileOptions = {},
|
||||
): Promise<CustomWorldProfile> {
|
||||
const response = await fetchWithApiAuth(
|
||||
`${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}/generate/stream`,
|
||||
{
|
||||
method: 'GET',
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const responseText = await response.text();
|
||||
throw new Error(parseApiErrorMessage(responseText, '生成自定义世界失败'));
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error('自定义世界生成流不可用');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
let latestProfile: Record<string, unknown> | null = null;
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
while (buffer.includes('\n\n')) {
|
||||
const boundary = buffer.indexOf('\n\n');
|
||||
const eventBlock = buffer.slice(0, boundary);
|
||||
buffer = buffer.slice(boundary + 2);
|
||||
|
||||
let eventName = '';
|
||||
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('event:')) {
|
||||
eventName = line.slice(6).trim();
|
||||
continue;
|
||||
}
|
||||
if (!line.startsWith('data:')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const payloadText = line.slice(5).trim();
|
||||
if (!payloadText) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const payload = JSON.parse(payloadText) as Record<string, unknown>;
|
||||
if (eventName === 'progress') {
|
||||
if (
|
||||
typeof payload.phaseId === 'string' &&
|
||||
typeof payload.phaseLabel === 'string' &&
|
||||
typeof payload.phaseDetail === 'string' &&
|
||||
typeof payload.overallProgress === 'number' &&
|
||||
Array.isArray(payload.steps)
|
||||
) {
|
||||
options.onProgress?.(
|
||||
payload as unknown as CustomWorldGenerationProgress,
|
||||
);
|
||||
} else {
|
||||
options.onProgress?.({
|
||||
phaseId: 'finalize',
|
||||
phaseLabel:
|
||||
typeof payload.phase === 'string'
|
||||
? payload.phase
|
||||
: 'generating',
|
||||
phaseDetail:
|
||||
typeof payload.phase === 'string'
|
||||
? payload.phase
|
||||
: 'generating',
|
||||
overallProgress:
|
||||
typeof payload.progress === 'number'
|
||||
? payload.progress / 100
|
||||
: 0,
|
||||
completedWeight:
|
||||
typeof payload.progress === 'number' ? payload.progress : 0,
|
||||
totalWeight: 100,
|
||||
elapsedMs: 0,
|
||||
estimatedRemainingMs: null,
|
||||
activeStepIndex: 0,
|
||||
steps: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
if (
|
||||
eventName === 'result' &&
|
||||
payload.profile &&
|
||||
typeof payload.profile === 'object'
|
||||
) {
|
||||
latestProfile = payload.profile as Record<string, unknown>;
|
||||
}
|
||||
if (eventName === 'error') {
|
||||
throw new Error(
|
||||
typeof payload.message === 'string'
|
||||
? payload.message
|
||||
: '生成自定义世界失败',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!latestProfile) {
|
||||
throw new Error('自定义世界生成未返回结果');
|
||||
}
|
||||
|
||||
return latestProfile as unknown as CustomWorldProfile;
|
||||
const aiClient = await loadLegacyAiModule();
|
||||
return aiClient.generateCustomWorldProfile(input, options);
|
||||
}
|
||||
|
||||
export async function generateCustomWorldSceneImage(
|
||||
@@ -625,22 +439,6 @@ export async function generateCustomWorldLandmark(payload: {
|
||||
return response.entity;
|
||||
}
|
||||
|
||||
export async function createCustomWorldSession(payload: {
|
||||
settingText: string;
|
||||
creatorIntent?: Record<string, unknown> | null;
|
||||
generationMode: 'fast' | 'full';
|
||||
}) {
|
||||
return requestJson<CustomWorldSessionSummary>(
|
||||
`${RUNTIME_API_BASE}/custom-world/sessions`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload satisfies CreateCustomWorldSessionRequest),
|
||||
},
|
||||
'创建自定义世界会话失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function listCustomWorldWorks() {
|
||||
const response = await requestJson<ListCustomWorldWorksResponse>(
|
||||
`${RUNTIME_API_BASE}/custom-world/works`,
|
||||
@@ -842,33 +640,6 @@ export async function getCustomWorldAgentCardDetail(
|
||||
return response.card as CustomWorldDraftCardDetail;
|
||||
}
|
||||
|
||||
export async function getCustomWorldSession(sessionId: string) {
|
||||
return requestJson<CustomWorldSessionRecord>(
|
||||
`${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取自定义世界会话失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function answerCustomWorldSessionQuestion(
|
||||
sessionId: string,
|
||||
payload: { questionId: string; answer: string },
|
||||
) {
|
||||
return requestJson<CustomWorldSessionSummary>(
|
||||
`${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}/answers`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(
|
||||
payload satisfies AnswerCustomWorldSessionQuestionRequest,
|
||||
),
|
||||
},
|
||||
'提交自定义世界补充设定失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function streamCharacterPanelChatReply(
|
||||
world: WorldType,
|
||||
playerCharacter: Character,
|
||||
|
||||
@@ -9,8 +9,6 @@ import {
|
||||
} from '../../packages/shared/src/http';
|
||||
|
||||
const ACCESS_TOKEN_KEY = 'genarrative.auth.access-token.v1';
|
||||
const AUTO_AUTH_USERNAME_KEY = 'genarrative.auth.auto-username.v1';
|
||||
const AUTO_AUTH_PASSWORD_KEY = 'genarrative.auth.auto-password.v1';
|
||||
export const AUTH_STATE_EVENT = 'genarrative-auth-state-changed';
|
||||
const REQUEST_ID_HEADER = 'x-request-id';
|
||||
const API_VERSION_HEADER = 'x-api-version';
|
||||
@@ -376,46 +374,6 @@ export function clearStoredAccessToken(
|
||||
}
|
||||
}
|
||||
|
||||
export function getStoredAutoAuthCredentials() {
|
||||
if (!canUseLocalStorage()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const username = window.localStorage.getItem(AUTO_AUTH_USERNAME_KEY)?.trim() || '';
|
||||
const password = window.localStorage.getItem(AUTO_AUTH_PASSWORD_KEY)?.trim() || '';
|
||||
|
||||
if (!username || !password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
username,
|
||||
password,
|
||||
};
|
||||
}
|
||||
|
||||
export function setStoredAutoAuthCredentials(credentials: {
|
||||
username: string;
|
||||
password: string;
|
||||
}) {
|
||||
if (!canUseLocalStorage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(AUTO_AUTH_USERNAME_KEY, credentials.username.trim());
|
||||
window.localStorage.setItem(AUTO_AUTH_PASSWORD_KEY, credentials.password.trim());
|
||||
}
|
||||
|
||||
export function clearStoredAutoAuthCredentials() {
|
||||
if (!canUseLocalStorage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(AUTO_AUTH_USERNAME_KEY);
|
||||
window.localStorage.removeItem(AUTO_AUTH_PASSWORD_KEY);
|
||||
emitAuthStateChange();
|
||||
}
|
||||
|
||||
function withAuthorizationHeaders(
|
||||
headers?: HeadersInit,
|
||||
options: Pick<ApiRequestOptions, 'omitEnvelopeHeader' | 'skipAuth'> = {},
|
||||
|
||||
@@ -7,9 +7,7 @@ const { requestJsonMock } = vi.hoisted(() => ({
|
||||
import {
|
||||
ApiClientError,
|
||||
clearStoredAccessToken,
|
||||
clearStoredAutoAuthCredentials,
|
||||
getStoredAccessToken,
|
||||
getStoredAutoAuthCredentials,
|
||||
setStoredAccessToken,
|
||||
} from './apiClient';
|
||||
import {
|
||||
@@ -67,7 +65,6 @@ describe('authService auto auth', () => {
|
||||
});
|
||||
requestJsonMock.mockReset();
|
||||
clearStoredAccessToken();
|
||||
clearStoredAutoAuthCredentials();
|
||||
});
|
||||
|
||||
it('creates credentials that match current username/password constraints', () => {
|
||||
@@ -78,7 +75,7 @@ describe('authService auto auth', () => {
|
||||
expect(credentials.password.length).toBeGreaterThanOrEqual(6);
|
||||
});
|
||||
|
||||
it('stores jwt and auto credentials after auth entry', async () => {
|
||||
it('stores jwt after auth entry without persisting guest credentials locally', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
token: 'jwt-token-value',
|
||||
user: {
|
||||
@@ -99,10 +96,6 @@ describe('authService auto auth', () => {
|
||||
|
||||
expect(user.username).toBe('guest_abc123abc123');
|
||||
expect(getStoredAccessToken()).toBe('jwt-token-value');
|
||||
expect(getStoredAutoAuthCredentials()).toEqual({
|
||||
username: 'guest_abc123abc123',
|
||||
password: 'auto_secret_password',
|
||||
});
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/entry',
|
||||
expect.objectContaining({
|
||||
@@ -115,9 +108,7 @@ describe('authService auto auth', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('reuses stored auto credentials before generating a new account', async () => {
|
||||
window.localStorage.setItem('genarrative.auth.auto-username.v1', 'guest_saveduser01');
|
||||
window.localStorage.setItem('genarrative.auth.auto-password.v1', 'auto_saved_password');
|
||||
it('creates a fresh guest credential pair for auto auth when a session is missing', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
token: 'jwt-restored',
|
||||
user: {
|
||||
@@ -132,16 +123,24 @@ describe('authService auto auth', () => {
|
||||
});
|
||||
|
||||
const result = await ensureAutoAuthUser();
|
||||
const authEntryBody = JSON.parse(
|
||||
requestJsonMock.mock.calls[0]?.[1]?.body as string,
|
||||
) as {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
expect(result.user.username).toBe('guest_saveduser01');
|
||||
expect(result.credentials).toEqual({
|
||||
username: 'guest_saveduser01',
|
||||
password: 'auto_saved_password',
|
||||
});
|
||||
expect(result.credentials.username).toMatch(/^guest_[a-z0-9]{12}$/u);
|
||||
expect(result.credentials.password).toMatch(
|
||||
/^auto_[a-z0-9]{24}_[a-z0-9]{8}$/u,
|
||||
);
|
||||
expect(authEntryBody).toEqual(result.credentials);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/entry',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: expect.any(String),
|
||||
}),
|
||||
'登录失败',
|
||||
);
|
||||
@@ -168,7 +167,13 @@ describe('authService auto auth', () => {
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledTimes(1);
|
||||
expect(firstResult).toEqual(secondResult);
|
||||
expect(getStoredAutoAuthCredentials()).toEqual(firstResult.credentials);
|
||||
const authEntryBody = JSON.parse(
|
||||
requestJsonMock.mock.calls[0]?.[1]?.body as string,
|
||||
) as {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
expect(authEntryBody).toEqual(firstResult.credentials);
|
||||
});
|
||||
|
||||
it('sends phone login code through the new auth endpoint', async () => {
|
||||
|
||||
@@ -24,11 +24,8 @@ import type {
|
||||
import {
|
||||
ApiClientError,
|
||||
clearStoredAccessToken,
|
||||
clearStoredAutoAuthCredentials,
|
||||
getStoredAutoAuthCredentials,
|
||||
requestJson,
|
||||
setStoredAccessToken,
|
||||
setStoredAutoAuthCredentials,
|
||||
} from './apiClient';
|
||||
|
||||
export type { AuthUser } from '../../packages/shared/src/contracts/auth';
|
||||
@@ -121,7 +118,6 @@ export function createAutoAuthCredentials(): AutoAuthCredentials {
|
||||
|
||||
export function clearAuthSession() {
|
||||
clearStoredAccessToken();
|
||||
clearStoredAutoAuthCredentials();
|
||||
}
|
||||
|
||||
export async function sendPhoneLoginCode(
|
||||
@@ -249,14 +245,12 @@ export async function authEntryWithStoredCredentials(
|
||||
normalizedCredentials.username,
|
||||
normalizedCredentials.password,
|
||||
);
|
||||
setStoredAutoAuthCredentials(normalizedCredentials);
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function ensureAutoAuthUser() {
|
||||
pendingAutoAuthUser ??= (async () => {
|
||||
const credentials =
|
||||
getStoredAutoAuthCredentials() ?? createAutoAuthCredentials();
|
||||
const credentials = createAutoAuthCredentials();
|
||||
const user = await authEntryWithStoredCredentials(credentials);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { WorldType } from '../types';
|
||||
|
||||
const ATTRIBUTE_LABELS = {
|
||||
strength: 'Strength',
|
||||
agility: 'Agility',
|
||||
intelligence: 'Intelligence',
|
||||
spirit: 'Spirit',
|
||||
} as const;
|
||||
|
||||
const RESOURCE_LABELS = {
|
||||
hp: 'HP',
|
||||
mp: 'MP',
|
||||
maxHp: '生命上限',
|
||||
maxMp: '灵力上限',
|
||||
damage: 'Damage',
|
||||
guard: 'Guard',
|
||||
range: 'Range',
|
||||
cooldown: 'Cooldown',
|
||||
manaCost: '灵力消耗',
|
||||
} as const;
|
||||
|
||||
export function buildThemedSkillName(_profile: unknown, style: string, index = 0) {
|
||||
return `${style || 'skill'}-${index + 1}`;
|
||||
}
|
||||
|
||||
export function buildCustomCampSceneName(profile: { name?: string; camp?: { name?: string | null } | null } | null | undefined) {
|
||||
return profile?.camp?.name?.trim() || (profile?.name ? `${profile.name}归舍` : '归舍');
|
||||
}
|
||||
|
||||
export function getAttributeLabelsForWorld(_worldType: WorldType | null) {
|
||||
return ATTRIBUTE_LABELS;
|
||||
}
|
||||
|
||||
export function getResourceLabelsForWorld(_worldType: WorldType | null) {
|
||||
return RESOURCE_LABELS;
|
||||
}
|
||||
|
||||
export function buildThemedItemName(_profile: unknown, category: string, rarity: string, seedKey: string) {
|
||||
return `${category}-${rarity}-${seedKey}`;
|
||||
}
|
||||
|
||||
export function buildThemedItemDescription(_profile: unknown, category: string, rarity: string, seedKey: string) {
|
||||
return `${category}-${rarity}-${seedKey} description`;
|
||||
}
|
||||
|
||||
export function inferCustomItemMechanics() {
|
||||
return {
|
||||
tags: [],
|
||||
equipmentSlotId: null,
|
||||
statProfile: null,
|
||||
useProfile: null,
|
||||
value: 0,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export function getTypewriterDelay(char: string) {
|
||||
if (/[。!??!]/u.test(char)) return 240;
|
||||
if (/[,、;:,;:]/u.test(char)) return 150;
|
||||
if (/\s/u.test(char)) return 45;
|
||||
return 90;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,147 +0,0 @@
|
||||
import {
|
||||
buildDefaultFrameOrder,
|
||||
DEFAULT_CHARACTER_BRIEF,
|
||||
buildMasterNegativePrompt,
|
||||
buildMasterPrompt,
|
||||
buildOrderedActiveFrameIndices,
|
||||
buildOrderedActiveFrameSources,
|
||||
buildRepairNegativePrompt,
|
||||
buildRepairPrompt,
|
||||
buildSheetNegativePrompt,
|
||||
buildSheetPrompt,
|
||||
buildVideoActionPrompt,
|
||||
getActionTemplateById,
|
||||
moveFrameOrderItem,
|
||||
PLAYABLE_CHARACTER_STYLE_REFERENCE_SOURCES,
|
||||
restoreAllFrames,
|
||||
toggleActiveFrame,
|
||||
} from './qwenSpriteSheetToolModel';
|
||||
|
||||
describe('qwenSpriteSheetToolModel', () => {
|
||||
it('builds ordered active frame indices from current order and active set', () => {
|
||||
expect(buildOrderedActiveFrameIndices([3, 1, 0, 2], [0, 2, 3])).toEqual([
|
||||
3, 0, 2,
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds ordered active frame sources', () => {
|
||||
expect(
|
||||
buildOrderedActiveFrameSources(
|
||||
['f0', 'f1', 'f2', 'f3'],
|
||||
[3, 1, 0, 2],
|
||||
[0, 2, 3],
|
||||
),
|
||||
).toEqual(['f3', 'f0', 'f2']);
|
||||
});
|
||||
|
||||
it('moves a frame forward or backward in order', () => {
|
||||
expect(moveFrameOrderItem([0, 1, 2, 3], 2, -1)).toEqual([0, 2, 1, 3]);
|
||||
expect(moveFrameOrderItem([0, 1, 2, 3], 1, 1)).toEqual([0, 2, 1, 3]);
|
||||
expect(moveFrameOrderItem([0, 1, 2, 3], 0, -1)).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
|
||||
it('toggles active frames without duplicating indices', () => {
|
||||
expect(toggleActiveFrame([0, 2, 3], 2)).toEqual([0, 3]);
|
||||
expect(toggleActiveFrame([0, 3], 2)).toEqual([0, 2, 3]);
|
||||
});
|
||||
|
||||
it('restores all frames to the default order', () => {
|
||||
expect(buildDefaultFrameOrder(4)).toEqual([0, 1, 2, 3]);
|
||||
expect(restoreAllFrames(4)).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
|
||||
it('provides action-specific default detail text for all five action templates', () => {
|
||||
const actionTemplateIds = ['idle', 'run', 'attack_slash', 'hurt', 'die'] as const;
|
||||
|
||||
actionTemplateIds.forEach((actionTemplateId) => {
|
||||
const actionTemplate = getActionTemplateById(actionTemplateId);
|
||||
expect(actionTemplate.defaultDetailText?.length ?? 0).toBeGreaterThan(20);
|
||||
expect(actionTemplate.stagingDirection?.length ?? 0).toBeGreaterThan(8);
|
||||
});
|
||||
});
|
||||
|
||||
it('builds a sheet prompt that contains the template structure', () => {
|
||||
const actionTemplate = getActionTemplateById('attack_slash');
|
||||
const prompt = buildSheetPrompt({
|
||||
characterBrief: '黑发青年剑士,右手持长剑。',
|
||||
actionTemplate,
|
||||
extraDirection: '每格边界清晰。',
|
||||
});
|
||||
|
||||
expect(prompt).toContain('4x4');
|
||||
expect(prompt).toContain('横斩攻击');
|
||||
expect(prompt).toContain('1-4 帧');
|
||||
expect(prompt).toContain('黑发青年剑士');
|
||||
expect(prompt).toContain('每格边界清晰');
|
||||
expect(prompt).toContain('大头身');
|
||||
});
|
||||
|
||||
it('builds a master prompt with square canvas and richer world-character detail coverage', () => {
|
||||
const prompt = buildMasterPrompt(DEFAULT_CHARACTER_BRIEF);
|
||||
|
||||
expect(prompt).toContain('1:1');
|
||||
expect(prompt).toContain('sprite sheet');
|
||||
expect(prompt).toContain('90');
|
||||
expect(prompt).toContain(DEFAULT_CHARACTER_BRIEF);
|
||||
expect(prompt).toContain('????????????');
|
||||
expect(prompt).toContain('????????????????????????');
|
||||
});
|
||||
|
||||
it('strengthens non-human species traits for siren-like characters', () => {
|
||||
const prompt = buildMasterPrompt('海妖,海洋歌者,蓝绿色鳞片,海妖耳鳍。');
|
||||
const negativePrompt = buildMasterNegativePrompt(
|
||||
'海妖,海洋歌者,蓝绿色鳞片,海妖耳鳍。',
|
||||
);
|
||||
|
||||
expect(prompt).toContain('如果文字设定没有明确要求非人身体结构,默认优先生成人形拟人化角色');
|
||||
expect(prompt).toContain('严格约束身体结构骨架');
|
||||
expect(prompt).toContain('沿用参考图的人形动作角色身体结构');
|
||||
expect(prompt).toContain('主题词默认只作用在角色自身');
|
||||
expect(negativePrompt).toContain('不要机械地把主题词直接画成完整怪物本体');
|
||||
});
|
||||
|
||||
it('keeps theme words on the character instead of leaking into the background', () => {
|
||||
const prompt = buildMasterPrompt('机械祭司,冷白金属外套,环形圣徽。');
|
||||
|
||||
expect(prompt).toContain('主题词默认只作用在角色自身');
|
||||
expect(prompt).toContain('不要把主题词自动扩写成背景建筑');
|
||||
expect(prompt).not.toContain('水母国王');
|
||||
});
|
||||
|
||||
it('builds a repair prompt that keeps chibi ratio', () => {
|
||||
const prompt = buildRepairPrompt({
|
||||
issueText: '修复头部和手部比例。',
|
||||
useNeighborLabel: '上一帧',
|
||||
});
|
||||
|
||||
expect(prompt).toContain('上一帧');
|
||||
expect(prompt).toContain('大头身');
|
||||
});
|
||||
|
||||
it('builds a video action prompt with pixel style constraints', () => {
|
||||
const actionTemplate = getActionTemplateById('run');
|
||||
const prompt = buildVideoActionPrompt({
|
||||
actionTemplate,
|
||||
actionDetailText: '?????????????????????????????????????????????',
|
||||
characterBrief: '?????????????????????????????????????????????',
|
||||
useChromaKey: true,
|
||||
});
|
||||
|
||||
expect(prompt).toContain(actionTemplate.label);
|
||||
expect(prompt).toContain(actionTemplate.stagingDirection ?? '');
|
||||
expect(prompt).toContain('90');
|
||||
expect(prompt).toContain('Q');
|
||||
expect(prompt).toContain('sprite');
|
||||
});
|
||||
|
||||
it('builds generic theme over-literalization negatives', () => {
|
||||
expect(buildSheetNegativePrompt('海妖')).toContain('不要机械地把主题词直接画成完整怪物本体');
|
||||
expect(buildRepairNegativePrompt('海妖')).toContain('不要机械地把主题词直接画成完整怪物本体');
|
||||
expect(buildMasterNegativePrompt('机械祭司')).toContain('不要把主题词自动扩写成角色以外的场景元素');
|
||||
});
|
||||
|
||||
it('contains built-in playable character style reference sources', () => {
|
||||
expect(PLAYABLE_CHARACTER_STYLE_REFERENCE_SOURCES.length).toBeGreaterThan(0);
|
||||
expect(PLAYABLE_CHARACTER_STYLE_REFERENCE_SOURCES.some((source) => source.includes('Girl Hero 1'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export * from '../prompts/qwenSpriteSheetToolPrompts';
|
||||
@@ -1,113 +0,0 @@
|
||||
import {
|
||||
ASSET_API_PATHS,
|
||||
postApiJson,
|
||||
} from '../editor/shared/editorApiClient';
|
||||
|
||||
const QWEN_SPRITE_MASTER_API_PATH = ASSET_API_PATHS.qwenSpriteMaster;
|
||||
const QWEN_SPRITE_SHEET_API_PATH = ASSET_API_PATHS.qwenSpriteSheet;
|
||||
const QWEN_SPRITE_FRAME_REPAIR_API_PATH =
|
||||
ASSET_API_PATHS.qwenSpriteFrameRepair;
|
||||
const QWEN_SPRITE_SAVE_API_PATH = ASSET_API_PATHS.qwenSpriteSave;
|
||||
|
||||
export type QwenSpriteImageDraft = {
|
||||
id: string;
|
||||
label: string;
|
||||
imageSrc: string;
|
||||
remoteUrl?: string;
|
||||
};
|
||||
|
||||
export type GenerateQwenSpritePayload = {
|
||||
promptText: string;
|
||||
negativePrompt: string;
|
||||
model: string;
|
||||
size: string;
|
||||
promptExtend: boolean;
|
||||
candidateCount: number;
|
||||
seed?: number;
|
||||
referenceImages: string[];
|
||||
};
|
||||
|
||||
export type RepairQwenSpriteFramePayload = {
|
||||
promptText: string;
|
||||
negativePrompt: string;
|
||||
model: string;
|
||||
size: string;
|
||||
promptExtend: boolean;
|
||||
seed?: number;
|
||||
referenceImages: string[];
|
||||
};
|
||||
|
||||
export type SaveQwenSpriteAssetPayload = {
|
||||
assetKey: string;
|
||||
actionKey: string;
|
||||
masterSource: string;
|
||||
sheetSource: string;
|
||||
framesDataUrls: string[];
|
||||
metadata: Record<string, unknown>;
|
||||
prompts: Record<string, unknown>;
|
||||
};
|
||||
|
||||
async function postJson<T>(
|
||||
url: string,
|
||||
payload: Record<string, unknown>,
|
||||
fallbackMessage: string,
|
||||
) {
|
||||
return postApiJson<T>(url, payload, fallbackMessage);
|
||||
}
|
||||
|
||||
export async function generateQwenSpriteMaster(
|
||||
payload: GenerateQwenSpritePayload,
|
||||
) {
|
||||
return postJson<{
|
||||
ok: true;
|
||||
draftId: string;
|
||||
drafts: QwenSpriteImageDraft[];
|
||||
model: string;
|
||||
size: string;
|
||||
promptText: string;
|
||||
negativePrompt: string;
|
||||
}>(QWEN_SPRITE_MASTER_API_PATH, payload, '生成主图失败');
|
||||
}
|
||||
|
||||
export async function generateQwenSpriteSheet(
|
||||
payload: GenerateQwenSpritePayload,
|
||||
) {
|
||||
return postJson<{
|
||||
ok: true;
|
||||
draftId: string;
|
||||
drafts: QwenSpriteImageDraft[];
|
||||
model: string;
|
||||
size: string;
|
||||
promptText: string;
|
||||
negativePrompt: string;
|
||||
}>(QWEN_SPRITE_SHEET_API_PATH, payload, '生成精灵表失败');
|
||||
}
|
||||
|
||||
export async function repairQwenSpriteFrame(
|
||||
payload: RepairQwenSpriteFramePayload,
|
||||
) {
|
||||
return postJson<{
|
||||
ok: true;
|
||||
draftId: string;
|
||||
drafts: QwenSpriteImageDraft[];
|
||||
repairedFrame: QwenSpriteImageDraft | null;
|
||||
model: string;
|
||||
size: string;
|
||||
promptText: string;
|
||||
negativePrompt: string;
|
||||
}>(QWEN_SPRITE_FRAME_REPAIR_API_PATH, payload, '修复帧失败');
|
||||
}
|
||||
|
||||
export async function saveQwenSpriteAsset(
|
||||
payload: SaveQwenSpriteAssetPayload,
|
||||
) {
|
||||
return postJson<{
|
||||
ok: true;
|
||||
assetId: string;
|
||||
assetDir: string;
|
||||
masterImagePath: string | null;
|
||||
sheetImagePath: string;
|
||||
framePaths: string[];
|
||||
saveMessage: string;
|
||||
}>(QWEN_SPRITE_SAVE_API_PATH, payload, '保存精灵表失败');
|
||||
}
|
||||
@@ -30,7 +30,6 @@
|
||||
"src/components/MedievalNpcAnimator.tsx",
|
||||
"src/components/npcRenderUtils.ts",
|
||||
"src/components/CharacterDetailModal.tsx",
|
||||
"src/components/NpcVisualEditor.tsx",
|
||||
"src/components/adventure-panel/**/*.tsx",
|
||||
"src/components/game-canvas/**/*.tsx",
|
||||
"src/components/game-shell/**/*.ts",
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import {fileURLToPath, URL} from 'node:url';
|
||||
|
||||
import {defineConfig} from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'./customWorldCharacterLoadout': fileURLToPath(new URL('./src/data/customWorldCharacterLoadout.stub.ts', import.meta.url)),
|
||||
'../data/customWorldCharacterLoadout': fileURLToPath(new URL('./src/data/customWorldCharacterLoadout.stub.ts', import.meta.url)),
|
||||
'../../data/customWorldCharacterLoadout': fileURLToPath(new URL('./src/data/customWorldCharacterLoadout.stub.ts', import.meta.url)),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'node',
|
||||
globals: true,
|
||||
|
||||
Reference in New Issue
Block a user