From 6be3afe45ae94bc8ce51c08abe0e246225376a1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Sat, 25 Apr 2026 14:29:44 +0800 Subject: [PATCH 1/2] Refactor server-rs runtime and update related docs --- ...INPUT_AND_AI_BOUNDARY_DESIGN_2026-04-06.md | 1 - ...PTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md | 1 - ...R_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md | 2 - ...R_PHASE6_IMPLEMENTATION_PLAN_2026-04-14.md | 1 - ...REATOR_AND_GAMEPLAY_FLOW_PRD_2026-04-20.md | 1 - ..._ENTITY_ACTIONS_RS_MIGRATION_2026-04-24.md | 2 +- ..._WORLD_ASSET_PROMPT_DEFAULTS_2026-04-24.md | 2 +- ...RESULT_ENTITY_GENERATION_FIX_2026-04-24.md | 2 +- ...D_SCENE_DANGER_LEVEL_REMOVAL_2026-04-25.md | 27 + ...OPENING_AND_RUNTIME_CHAT_FIX_2026-04-25.md | 49 ++ ...TION_PROMPT_SCRIPT_MIGRATION_2026-04-25.md | 32 + .../shared/src/contracts/rpgAgentDraft.ts | 2 - .../src/contracts/rpgCreationFixtures.ts | 4 - server-rs/crates/api-server/src/app.rs | 8 + .../crates/api-server/src/custom_world.rs | 63 +- .../src/custom_world_agent_entities.rs | 4 +- .../crates/api-server/src/custom_world_ai.rs | 163 +---- .../src/custom_world_asset_prompts.rs | 371 +---------- .../src/custom_world_foundation_draft.rs | 578 +++--------------- server-rs/crates/api-server/src/main.rs | 2 + .../src/prompt/character_animation.rs | 297 +++++++++ .../api-server/src/prompt/character_visual.rs | 67 ++ .../api-server/src/prompt/foundation_draft.rs | 534 ++++++++++++++++ server-rs/crates/api-server/src/prompt/mod.rs | 4 + .../api-server/src/prompt/scene_background.rs | 167 +++++ .../crates/api-server/src/runtime_chat.rs | 145 +++++ .../spacetime-module/src/custom_world/mod.rs | 4 +- src/components/CustomWorldEntityCatalog.tsx | 2 - .../CustomWorldEntityEditorModal.test.tsx | 3 - src/components/CustomWorldResultView.test.tsx | 2 - .../game-canvas/GameCanvasEntityLayer.tsx | 9 +- .../rpgCreationResultFormMapper.ts | 1 - ...gEntryFlowShell.agent.interaction.test.tsx | 1 - src/data/characterPresets.customWorld.test.ts | 2 - src/data/customWorldLibrary.ts | 17 +- src/data/customWorldSceneGraph.ts | 1 - src/data/customWorldVisuals.ts | 6 +- src/data/sceneBackgrounds.test.ts | 3 - src/data/scenePresets.test.ts | 2 - .../npcEncounterActions.test.ts | 31 +- .../useRpgRuntimeNpcInteraction.ts | 22 +- src/hooks/useGameFlow.customWorld.test.tsx | 3 - src/prompts/customWorldPrompts.test.ts | 1 - src/prompts/customWorldPrompts.ts | 39 +- src/services/ai.test.ts | 3 - src/services/ai.ts | 1 - src/services/aiTypes.ts | 1 - src/services/customWorld.test.ts | 3 - src/services/customWorld.ts | 11 +- src/services/customWorldBuilder.test.ts | 1 - src/services/customWorldBuilder.ts | 5 - src/services/customWorldCamp.ts | 3 - src/services/customWorldCover.test.ts | 2 - src/services/customWorldOwnedSettingLayers.ts | 8 +- src/services/prompt.test.ts | 1 - src/types/customWorld.ts | 2 - 56 files changed, 1561 insertions(+), 1158 deletions(-) create mode 100644 docs/technical/CUSTOM_WORLD_SCENE_DANGER_LEVEL_REMOVAL_2026-04-25.md create mode 100644 docs/technical/NPC_INTERACTION_OPENING_AND_RUNTIME_CHAT_FIX_2026-04-25.md create mode 100644 docs/technical/SERVER_RS_GENERATION_PROMPT_SCRIPT_MIGRATION_2026-04-25.md create mode 100644 server-rs/crates/api-server/src/prompt/character_animation.rs create mode 100644 server-rs/crates/api-server/src/prompt/character_visual.rs create mode 100644 server-rs/crates/api-server/src/prompt/foundation_draft.rs create mode 100644 server-rs/crates/api-server/src/prompt/mod.rs create mode 100644 server-rs/crates/api-server/src/prompt/scene_background.rs create mode 100644 server-rs/crates/api-server/src/runtime_chat.rs diff --git a/docs/design/CUSTOM_WORLD_CREATOR_INPUT_AND_AI_BOUNDARY_DESIGN_2026-04-06.md b/docs/design/CUSTOM_WORLD_CREATOR_INPUT_AND_AI_BOUNDARY_DESIGN_2026-04-06.md index 0dd5da38..dfb04ed9 100644 --- a/docs/design/CUSTOM_WORLD_CREATOR_INPUT_AND_AI_BOUNDARY_DESIGN_2026-04-06.md +++ b/docs/design/CUSTOM_WORLD_CREATOR_INPUT_AND_AI_BOUNDARY_DESIGN_2026-04-06.md @@ -325,7 +325,6 @@ 例如: - `initialAffinity` -- `dangerLevel` - 精确数值型 build 倾向 - 复杂掉落预算 diff --git a/docs/design/LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md b/docs/design/LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md index c6502283..c769fb56 100644 --- a/docs/design/LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md +++ b/docs/design/LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md @@ -596,7 +596,6 @@ chapterXpBudget = 1. `SceneChapterBlueprint.acts` 数量 2. 当前章节 hostile NPC 数量 3. 当前章节任务 step 中战斗目标占比 -4. `dangerLevel` 5. linked thread 是否为主线高压线程 ## 6.4 实际速度评估规则 diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md index be0a52ff..823446b3 100644 --- a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md @@ -307,7 +307,6 @@ 1. `name` 2. `description` -3. `dangerLevel` ## 7.4 第四阶段不允许编辑的内容 @@ -425,7 +424,6 @@ 1. `id` 2. `name` 3. `description` 或 `summary` -4. `dangerLevel` 5. `purpose` 6. `mood` diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE6_IMPLEMENTATION_PLAN_2026-04-14.md b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE6_IMPLEMENTATION_PLAN_2026-04-14.md index bb642bd1..1c2f2a2f 100644 --- a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE6_IMPLEMENTATION_PLAN_2026-04-14.md +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE6_IMPLEMENTATION_PLAN_2026-04-14.md @@ -505,7 +505,6 @@ draftProfile.landmarks.find(...) kind: 'camp' | 'landmark'; name: string; description: string; - dangerLevel?: string; imageSrc?: string; }; onPublishSuccess: (payload) => void; diff --git a/docs/prd/AI_NATIVE_SCENE_MULTI_ACT_CREATOR_AND_GAMEPLAY_FLOW_PRD_2026-04-20.md b/docs/prd/AI_NATIVE_SCENE_MULTI_ACT_CREATOR_AND_GAMEPLAY_FLOW_PRD_2026-04-20.md index db992d2f..86a5f1bb 100644 --- a/docs/prd/AI_NATIVE_SCENE_MULTI_ACT_CREATOR_AND_GAMEPLAY_FLOW_PRD_2026-04-20.md +++ b/docs/prd/AI_NATIVE_SCENE_MULTI_ACT_CREATOR_AND_GAMEPLAY_FLOW_PRD_2026-04-20.md @@ -213,7 +213,6 @@ 1. 开局场景允许配置的字段必须与普通场景一致,至少包括: - `name` - `description` - - `dangerLevel` - `imageSrc` - `sceneNpcIds` - `connections` diff --git a/docs/technical/CUSTOM_WORLD_AGENT_ENTITY_ACTIONS_RS_MIGRATION_2026-04-24.md b/docs/technical/CUSTOM_WORLD_AGENT_ENTITY_ACTIONS_RS_MIGRATION_2026-04-24.md index 5eb299f5..24948fc7 100644 --- a/docs/technical/CUSTOM_WORLD_AGENT_ENTITY_ACTIONS_RS_MIGRATION_2026-04-24.md +++ b/docs/technical/CUSTOM_WORLD_AGENT_ENTITY_ACTIONS_RS_MIGRATION_2026-04-24.md @@ -55,7 +55,7 @@ name, role, publicMask, hiddenHook, relationToPlayer, summary, threadIds 新增场景保留旧 Node 的 system prompt 与 user prompt 字段约束: ```text -name, purpose, mood, dangerLevel, secret, summary, threadIds, characterIds +name, purpose, mood, secret, summary, threadIds, characterIds ``` Rust 侧只做最小归一化:补 `id`、去除重名、限制数量 `1..=3`,不改写提示词原文语义。 diff --git a/docs/technical/CUSTOM_WORLD_ASSET_PROMPT_DEFAULTS_2026-04-24.md b/docs/technical/CUSTOM_WORLD_ASSET_PROMPT_DEFAULTS_2026-04-24.md index 7883774c..64b65cd6 100644 --- a/docs/technical/CUSTOM_WORLD_ASSET_PROMPT_DEFAULTS_2026-04-24.md +++ b/docs/technical/CUSTOM_WORLD_ASSET_PROMPT_DEFAULTS_2026-04-24.md @@ -60,6 +60,6 @@ - 某一幕连续重试仍失败时,只允许在该幕写入 `backgroundGenerationError` 作为诊断字段;只要至少一幕成功,草稿仍应完成并让前端展示成功图片。 - 只有全部幕背景均失败时,才把“生成幕背景图失败”作为草稿素材阶段失败原因保存。 - Rust 服务实际生图模型读取 `DASHSCOPE_SCENE_IMAGE_MODEL` / `DASHSCOPE_COVER_IMAGE_MODEL` / `DASHSCOPE_REFERENCE_IMAGE_MODEL`;兼容旧 `DASHSCOPE_IMAGE_MODEL`,避免 `.env.example` 中配置了模型但服务端仍使用硬编码模型。 -- 自动草稿幕背景不能把 `backgroundPromptText` 直接作为最终 `prompt` 传给 DashScope;它必须像草稿页手动生成一样,把幕级描述作为 `userPrompt`,并用同一个地点对象的 `name/description/dangerLevel` 作为场景上下文,再由 `build_custom_world_scene_image_prompt` 统一拼入世界名、世界摘要、风格、玩家目标、场景名、场景描述和负面词。用户不修改默认描述直接点生成时,手动生成与自动草稿生成的正式生图上下文必须一致。 +- 自动草稿幕背景不能把 `backgroundPromptText` 直接作为最终 `prompt` 传给 DashScope;它必须像草稿页手动生成一样,把幕级描述作为 `userPrompt`,并用同一个地点对象的 `name/description` 作为场景上下文,再由 `build_custom_world_scene_image_prompt` 统一拼入世界名、世界摘要、风格、玩家目标、场景名、场景描述和负面词。用户不修改默认描述直接点生成时,手动生成与自动草稿生成的正式生图上下文必须一致。 - 自动草稿幕背景的默认尺寸必须与草稿页手动生成默认尺寸一致,当前统一为 `1280*720`;不能在自动链路中单独改成 `1600*900`,否则同一 prompt 在同一模型下也可能因供应商尺寸支持或耗时不同而表现不一致。 - 批量自动生图失败日志必须保留 `AppError.details.message` 中的供应商真实原因,不能只记录 `AppError.message()` 的 HTTP 泛化文案,否则排查时只能看到“上游服务请求失败”,无法确认是尺寸、模型、限流、超时还是内容审核失败。 diff --git a/docs/technical/CUSTOM_WORLD_RESULT_ENTITY_GENERATION_FIX_2026-04-24.md b/docs/technical/CUSTOM_WORLD_RESULT_ENTITY_GENERATION_FIX_2026-04-24.md index cecdcf58..2c0adeec 100644 --- a/docs/technical/CUSTOM_WORLD_RESULT_ENTITY_GENERATION_FIX_2026-04-24.md +++ b/docs/technical/CUSTOM_WORLD_RESULT_ENTITY_GENERATION_FIX_2026-04-24.md @@ -15,7 +15,7 @@ LLM 扩展提示词为了草稿卡片简洁,只要求返回角色的 `publicMa 但结果页与运行时 `CustomWorldProfile` 读取的是当前完整字段: - NPC:`description / backstory / personality / motivation / relationshipHooks / tags / initialAffinity` -- 场景:`description / dangerLevel / sceneNpcIds / connections` +- 场景:`description / sceneNpcIds / connections` 因此新增实体即使后端动作成功,也可能因为字段缺失或关联字段名不一致,在结果页表现为“生成后没有可用内容 / 场景没有 NPC 关联”。 diff --git a/docs/technical/CUSTOM_WORLD_SCENE_DANGER_LEVEL_REMOVAL_2026-04-25.md b/docs/technical/CUSTOM_WORLD_SCENE_DANGER_LEVEL_REMOVAL_2026-04-25.md new file mode 100644 index 00000000..3a6e5701 --- /dev/null +++ b/docs/technical/CUSTOM_WORLD_SCENE_DANGER_LEVEL_REMOVAL_2026-04-25.md @@ -0,0 +1,27 @@ +# 自定义世界场景 dangerLevel 字段移除落地说明(2026-04-25) + +## 背景 + +自定义世界场景对象过去在开局归处、地标、草稿实体和生图上下文中携带 `dangerLevel`。该字段会让场景结构额外承载“危险等级”枚举,并在提示词中要求模型生成固定的 `low|medium|high|extreme` 值。当前场景设计更依赖 `description`、`visualDescription`、幕级事件和任务描述表达氛围,不再需要独立危险等级字段。 + +## 落地范围 + +- 场景数据结构删除 `dangerLevel`,包括开局归处、地标、共享草稿协议与前端表单映射。 +- 自定义世界生成、修复和补全提示词不再要求模型输出 `dangerLevel`。 +- 场景生图上下文只使用世界名、世界摘要、整体基调、玩家目标、场景名、场景描述和用户画面描述,不再拼接危险等级氛围。 +- Rust API 与 SpacetimeDB 模块展示卡片不再从 `dangerLevel` 读取副标题或场景上下文。 +- 测试夹具移除 `dangerLevel` 输入,避免新测试继续固化该字段。 + +## 兼容策略 + +- 旧存档或旧 LLM 返回中若仍包含 `dangerLevel`,前端和 Rust 归一化流程不再读取或回写该字段。 +- 不新增替代字段;需要表达危险、压迫或安全感时,写入 `description`、`visualDescription`、`mood`、`sceneTaskDescription` 或幕级描述。 +- `server-node` 旧实现不作为兼容目标,本次仅清理当前前端、共享协议与 `server-rs` 链路。 + +## 编码落点 + +- `src/types/customWorld.ts`:删除场景类型上的 `dangerLevel`。 +- `src/prompts/customWorldPrompts.ts` 与 `server-rs/crates/api-server/src/prompt/foundation_draft.rs`:删除所有生成/修复 `dangerLevel` 的模板与约束。 +- `src/services/customWorld.ts`、`src/services/customWorldCamp.ts`、`src/services/customWorldBuilder.ts`:删除归一化和 fallback 写入。 +- `src/data/customWorldLibrary.ts`、`src/data/customWorldSceneGraph.ts`、`src/data/customWorldVisuals.ts`:删除持久化、场景图谱和视觉上下文映射。 +- `server-rs/crates/api-server` 与 `server-rs/crates/spacetime-module`:删除 Rust 侧默认 JSON、提示词、实体卡片副标题和场景上下文读取。 diff --git a/docs/technical/NPC_INTERACTION_OPENING_AND_RUNTIME_CHAT_FIX_2026-04-25.md b/docs/technical/NPC_INTERACTION_OPENING_AND_RUNTIME_CHAT_FIX_2026-04-25.md new file mode 100644 index 00000000..d8b28ed0 --- /dev/null +++ b/docs/technical/NPC_INTERACTION_OPENING_AND_RUNTIME_CHAT_FIX_2026-04-25.md @@ -0,0 +1,49 @@ +# NPC 相遇先手发言与 Runtime 聊天路由修复 + +## 背景 + +进入游戏与 NPC 相遇后,前端会调用 `POST /api/runtime/chat/npc/turn/stream` 生成 NPC 主动开场或聊天回合。Rust API 尚未承接旧 Node 的该路由时,接口返回 404,前端解析为“资源不存在”,导致: + +1. NPC 主动开场失败,对话框中没有先发言。 +2. 点击聊天选项继续对话时同样报“资源不存在”。 +3. 场景中和平相遇 NPC 的朝向与站位不稳定,不能稳定表现为与主角对望。 + +## 本轮落地规则 + +1. 前端只负责表现和触发,不在本地补 LLM 编排。 +2. Rust API 必须至少提供契约兼容的后端 SSE 路由,避免回退到 server-node。 +3. 任意好感度下,首次与一个 NPC 相遇都先进入 NPC 主动开场;后续再按敌对/普通分支处理。 +4. 和平相遇态 NPC 固定使用已解析相遇锚点,与主角形成面对面的右侧对称表现,并强制朝向主角。 + +## 代码设计 + +### Rust Runtime Chat + +新增 `server-rs/crates/api-server/src/runtime_chat.rs`: + +1. 注册 `POST /api/runtime/chat/npc/turn/stream`。 +2. 返回前端已支持的 SSE 事件: + - `reply_delta`:增量文本。 + - `complete`:`npcReply / affinityDelta / affinityText / suggestions / pendingQuestOffer / chatDirective`。 +3. 当前先提供后端确定性兜底回复,保证 Rust API 迁移期间链路可用;后续完整 LLM 编排应继续在 Rust API 内实现,不回接 server-node。 + +### 前端交互 + +调整 `src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts`: + +1. 首次相遇判断提前到敌对短路之前。 +2. `firstMeaningfulContactResolved` 为 false 时,无论好感度或敌对状态如何,都调用 `startNpcInitiatedOpening(...)`。 + +调整 `src/components/game-canvas/GameCanvasEntityLayer.tsx`: + +1. 和平相遇 NPC 使用 `RESOLVED_ENTITY_X_METERS` 作为稳定右侧锚点。 +2. 渲染朝向直接使用 `getFacingTowardPlayer(...)` 结果,避免通用 NPC 形象被二次反转。 + +## 后续迁移点 + +Rust 兜底路由只解决运行时断链。完整体验仍应继续迁移旧 Node 中 NPC 聊天编排能力,包括: + +1. 基于上下文的 LLM 回复。 +2. 聊天建议生成。 +3. 待接委托 `pendingQuestOffer` 的服务端判定与生成。 +4. 限轮与强制退出指令的完整结算。 diff --git a/docs/technical/SERVER_RS_GENERATION_PROMPT_SCRIPT_MIGRATION_2026-04-25.md b/docs/technical/SERVER_RS_GENERATION_PROMPT_SCRIPT_MIGRATION_2026-04-25.md new file mode 100644 index 00000000..167cba0a --- /dev/null +++ b/docs/technical/SERVER_RS_GENERATION_PROMPT_SCRIPT_MIGRATION_2026-04-25.md @@ -0,0 +1,32 @@ +# Rust 生成链路 Prompt 脚本迁移设计(2026-04-25) + +## 1. 目标 + +把 `server-rs` 中四条现役生成链路的提示词从业务流程文件中抽离到独立 `prompt` 目录,后续迭代只修改 prompt 脚本,不在路由、任务、资产持久化代码中直接堆提示词。 + +## 2. 目录约定 + +新增目录:`server-rs/crates/api-server/src/prompt/` + +模块划分: + +1. `scene_background.rs`:场景背景图与幕背景图提示词。 +2. `character_visual.rs`:角色主形象提示词与负向提示词。 +3. `character_animation.rs`:角色动作、序列帧、图生视频、动作迁移提示词。 +4. `foundation_draft.rs`:草稿生成各阶段 JSON 系统提示词、修复提示词、框架/角色/场景/档案分阶段 user prompt。 +5. `mod.rs`:统一导出子模块。 + +## 3. 迁移边界 + +1. 只迁移 prompt 构造与 prompt 常量,不迁移 DashScope、OSS、SpacetimeDB、任务状态、并发控制和持久化逻辑。 +2. `custom_world.rs` 只保留场景幕引用收集、校验和调用生成服务,不再承载幕背景图提示词正文。 +3. `custom_world_ai.rs` 只保留图片生成、下载、入库、接口 payload 归一化;场景图 prompt builder 迁入 `prompt::scene_background`。 +4. `custom_world_asset_prompts.rs` 作为兼容转发层保留,避免一次性改动角色资产调用点过大;真实提示词脚本迁入 `prompt::character_visual` 与 `prompt::character_animation`。 +5. `custom_world_foundation_draft.rs` 只保留分阶段编排、JSON 解析、归一化和写回;所有阶段 prompt builder 迁入 `prompt::foundation_draft`。 + +## 4. 验收标准 + +1. `cargo fmt -p api-server` 通过。 +2. `cargo check -p api-server` 通过。 +3. 四条链路仍能从原调用点拿到相同语义的提示词。 +4. 文档明确后续 prompt 修改主源在 `src/prompt/`。 diff --git a/packages/shared/src/contracts/rpgAgentDraft.ts b/packages/shared/src/contracts/rpgAgentDraft.ts index 9339e234..3866785a 100644 --- a/packages/shared/src/contracts/rpgAgentDraft.ts +++ b/packages/shared/src/contracts/rpgAgentDraft.ts @@ -83,7 +83,6 @@ export interface RpgAgentFoundationDraftLandmark { mood: string; importance: string; secret?: string; - dangerLevel?: string; imageSrc?: string | null; generatedSceneAssetId?: string | null; generatedScenePrompt?: string | null; @@ -121,7 +120,6 @@ export interface RpgAgentFoundationDraftCamp { name: string; description: string; mood: string; - dangerLevel?: string; imageSrc?: string | null; generatedSceneAssetId?: string | null; generatedScenePrompt?: string | null; diff --git a/packages/shared/src/contracts/rpgCreationFixtures.ts b/packages/shared/src/contracts/rpgCreationFixtures.ts index b0f122e0..c8a28d55 100644 --- a/packages/shared/src/contracts/rpgCreationFixtures.ts +++ b/packages/shared/src/contracts/rpgCreationFixtures.ts @@ -176,7 +176,6 @@ export function createRpgAgentFoundationDraftProfileFixture(): RpgAgentFoundatio mood: '潮湿、压抑、风声不止', importance: '开局核心场景', secret: '高处潮痕说明海面异常抬升过。', - dangerLevel: 'high', imageSrc: '/generated-custom-world-scenes/landmark-1/latest-scene.png', generatedSceneAssetId: 'scene-asset-landmark-1', characterIds: ['story-1'], @@ -189,7 +188,6 @@ export function createRpgAgentFoundationDraftProfileFixture(): RpgAgentFoundatio name: '回潮暂栖所', description: '能暂时收拢队伍、整理灯册与潮路线索的落脚点。', mood: '克制、紧绷,但还能暂时收拢局势', - dangerLevel: 'low', imageSrc: '/custom/camp/huichao.png', generatedSceneAssetId: 'scene-asset-camp-1', summary: '玩家能在这里整理情报、回看旧灯册和沉船名单。', @@ -437,14 +435,12 @@ export function createRpgCreationPublishedProfileFixture(): CustomWorldProfileRe camp: { name: draft.camp?.name, description: draft.camp?.description, - dangerLevel: draft.camp?.dangerLevel, imageSrc: draft.camp?.imageSrc, }, landmarks: draft.landmarks.map((landmark) => ({ id: landmark.id, name: landmark.name, description: landmark.description, - dangerLevel: landmark.dangerLevel, imageSrc: landmark.imageSrc, sceneNpcIds: landmark.characterIds, connections: [ diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 7e19a372..7bef22e0 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -89,6 +89,7 @@ use crate::{ runtime_browse_history::{ delete_runtime_browse_history, get_runtime_browse_history, post_runtime_browse_history, }, + runtime_chat::stream_runtime_npc_chat_turn, runtime_inventory::get_runtime_inventory_state, runtime_profile::{get_profile_dashboard, get_profile_play_stats, get_profile_wallet_ledger}, runtime_save::{ @@ -237,6 +238,13 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/runtime/chat/npc/turn/stream", + post(stream_runtime_npc_chat_turn).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/auth/logout", post(logout) diff --git a/server-rs/crates/api-server/src/custom_world.rs b/server-rs/crates/api-server/src/custom_world.rs index c65c0b04..ec8c5b9e 100644 --- a/server-rs/crates/api-server/src/custom_world.rs +++ b/server-rs/crates/api-server/src/custom_world.rs @@ -63,6 +63,9 @@ use crate::{ generate_custom_world_foundation_draft, }, http_error::AppError, + prompt::scene_background::{ + SceneActBackgroundPromptParams, build_scene_act_background_image_prompt, + }, request_context::RequestContext, state::AppState, }; @@ -1598,7 +1601,7 @@ async fn generate_draft_foundation_act_backgrounds( act_ref.scene_id.as_str(), act_ref.scene_name.as_str(), act_ref.scene_description.as_str(), - act_ref.prompt.as_str(), + act_ref.scene_image_prompt.as_str(), ) .await }; @@ -1772,9 +1775,13 @@ struct SceneActGenerationRef { scene_name: String, scene_description: String, prompt: String, + scene_image_prompt: String, } fn collect_scene_act_refs(draft_profile: &Value) -> Vec { + let world_name = + json_text_from_value(draft_profile, "name").unwrap_or_else(|| "未命名世界".to_string()); + let world_tone = json_text_from_value(draft_profile, "tone").unwrap_or_default(); let scene_context_by_id = collect_scene_context_by_id(draft_profile); draft_profile .get("sceneChapterBlueprints") @@ -1800,9 +1807,10 @@ fn collect_scene_act_refs(draft_profile: &Value) -> Vec { description: json_text_from_value(chapter, "description") .or_else(|| json_text_from_value(chapter, "summary")) .unwrap_or_default(), - danger_level: json_text_from_value(chapter, "dangerLevel").unwrap_or_default(), }); let scene_contexts = scene_context_by_id.clone(); + let world_name = world_name.clone(); + let world_tone = world_tone.clone(); chapter .get("acts") .and_then(Value::as_array) @@ -1838,8 +1846,31 @@ fn collect_scene_act_refs(draft_profile: &Value) -> Vec { id: act_scene_id.clone(), name: scene_name, description: chapter_scene_context.description.clone(), - danger_level: chapter_scene_context.danger_level.clone(), }); + let title = json_text_from_value(act, "title") + .unwrap_or_else(|| format!("第{}幕", act_index + 1)); + let summary = json_text_from_value(act, "summary").unwrap_or_default(); + let act_goal = json_text_from_value(act, "actGoal").unwrap_or_default(); + let transition_hook = + json_text_from_value(act, "transitionHook").unwrap_or_default(); + let primary_role_name = json_first_text_from_value( + act, + &["primaryRoleName", "primaryRole", "mainRoleName"], + ) + .unwrap_or_default(); + let scene_image_prompt = + build_scene_act_background_image_prompt(SceneActBackgroundPromptParams { + world_name: world_name.as_str(), + world_tone: world_tone.as_str(), + scene_name: scene_context.name.as_str(), + title: title.as_str(), + summary: summary.as_str(), + act_goal: act_goal.as_str(), + transition_hook: transition_hook.as_str(), + primary_role_name: primary_role_name.as_str(), + support_role_names: collect_scene_act_support_role_names(act), + prompt_text: prompt.as_str(), + }); SceneActGenerationRef { chapter_index, @@ -1847,19 +1878,18 @@ fn collect_scene_act_refs(draft_profile: &Value) -> Vec { scene_id: act_scene_id, scene_name: scene_context.name, scene_description: scene_context.description, - prompt: prompt.clone(), + prompt, + scene_image_prompt, } }) }) .collect() } - #[derive(Clone, Debug)] struct SceneImageContext { id: String, name: String, description: String, - danger_level: String, } fn collect_scene_context_by_id(draft_profile: &Value) -> BTreeMap { @@ -1895,10 +1925,26 @@ fn scene_context_from_object( description: read_string_field(object, "description") .or_else(|| read_string_field(object, "visualDescription")) .unwrap_or_default(), - danger_level: read_string_field(object, "dangerLevel").unwrap_or_default(), }) } +fn collect_scene_act_support_role_names(act: &Value) -> Vec { + // 兼容旧 Node 自动资产链路可能写入的 supportRoleNames,也兼容单字段字符串,避免迁移后丢上下文。 + let mut names = act + .get("supportRoleNames") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .collect::>(); + + names.extend(json_text_from_value(act, "supportRoleName")); + names.extend(json_text_from_value(act, "supportRoles")); + names +} fn validate_scene_act_background_prompts(act_refs: &[SceneActGenerationRef]) -> Result<(), String> { if let Some(act_ref) = act_refs.iter().find(|act_ref| act_ref.prompt.is_empty()) { return Err(format!( @@ -2664,8 +2710,7 @@ mod tests { { "id": "scene-office", "name": "旧港办公室", - "description": "旧港边缘的玻璃办公室,窗外能看到潮湿码头。", - "dangerLevel": "low" + "description": "旧港边缘的玻璃办公室,窗外能看到潮湿码头。" } ], "sceneChapterBlueprints": [ diff --git a/server-rs/crates/api-server/src/custom_world_agent_entities.rs b/server-rs/crates/api-server/src/custom_world_agent_entities.rs index 932eb888..be247b2a 100644 --- a/server-rs/crates/api-server/src/custom_world_agent_entities.rs +++ b/server-rs/crates/api-server/src/custom_world_agent_entities.rs @@ -205,7 +205,7 @@ fn build_custom_world_agent_landmark_expansion_prompt(params: ExpansionPromptPar params.prompt_seed } ), - "返回 JSON 数组。每个对象字段只允许包含:name, purpose, mood, dangerLevel, secret, summary, threadIds, characterIds。".to_string(), + "返回 JSON 数组。每个对象字段只允许包含:name, purpose, mood, secret, summary, threadIds, characterIds。".to_string(), "threadIds / characterIds 必须优先引用现有对象 id。".to_string(), ] .join("\n") @@ -341,7 +341,6 @@ fn normalize_generated_landmark_profile_fields(object: &mut JsonMap, #[serde(default)] description: Option, - #[serde(default)] - danger_level: Option, } #[derive(Clone, Debug, Default, Deserialize)] @@ -340,7 +342,6 @@ struct OptimizedCoverUpload { bytes: Vec, } -const DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT: &str = "文字,水印,logo,UI界面,对话框,边框,人物近景特写,多人合照,模糊,低清晰度,畸形建筑,现代车辆,监控摄像头"; const COVER_OUTPUT_WIDTH: u32 = 1600; const COVER_OUTPUT_HEIGHT: u32 = 900; const COVER_UPLOAD_MAX_BYTES: usize = 10 * 1024 * 1024; @@ -575,7 +576,6 @@ pub(crate) async fn generate_custom_world_scene_image_for_profile( id: Some(scene_id.to_string()), name: Some(scene_name.to_string()), description: Some(scene_description.to_string()), - danger_level: None, }), }; let normalized = normalize_scene_image_request(payload)?; @@ -1147,7 +1147,6 @@ fn build_landmark_fallback(world_name: &str) -> Value { "name": "新场景", "description": format!("围绕《{world_name}》当前主线冲突扩展出的关键场景。"), "visualDescription": "低照度、层次复杂、带有明显环境叙事痕迹。", - "dangerLevel": "medium", "sceneNpcIds": [], "connections": [], "narrativeResidues": [], @@ -1180,14 +1179,24 @@ fn normalize_scene_image_request( } let prompt = trim_to_option(payload.prompt.as_deref()).unwrap_or_else(|| { - build_custom_world_scene_image_prompt( - &profile, - &landmark, - payload.user_prompt.as_deref().unwrap_or_default(), - reference_image_src.is_some(), - landmark_name.as_deref(), - world_name.as_str(), - ) + build_custom_world_scene_image_prompt(SceneImagePromptParams { + profile: SceneImagePromptProfile { + name: profile.name.as_deref().unwrap_or_default(), + subtitle: profile.subtitle.as_deref().unwrap_or_default(), + tone: profile.tone.as_deref().unwrap_or_default(), + player_goal: profile.player_goal.as_deref().unwrap_or_default(), + summary: profile.summary.as_deref().unwrap_or_default(), + setting_text: profile.setting_text.as_deref().unwrap_or_default(), + }, + landmark: SceneImagePromptLandmark { + name: landmark.name.as_deref().unwrap_or_default(), + description: landmark.description.as_deref().unwrap_or_default(), + }, + user_prompt: payload.user_prompt.as_deref().unwrap_or_default(), + has_reference_image: reference_image_src.is_some(), + fallback_landmark_name: landmark_name.as_deref(), + fallback_world_name: world_name.as_str(), + }) }); if prompt.is_empty() { return Err( @@ -1212,117 +1221,6 @@ fn normalize_scene_image_request( }) } -fn build_custom_world_scene_image_prompt( - profile: &SceneImageProfileInput, - landmark: &SceneImageLandmarkInput, - user_prompt: &str, - has_reference_image: bool, - fallback_landmark_name: Option<&str>, - fallback_world_name: &str, -) -> String { - let world_name = clamp_scene_image_text( - trim_to_option(profile.name.as_deref()) - .unwrap_or_else(|| fallback_world_name.to_string()) - .as_str(), - 18, - ); - let world_subtitle = clamp_scene_image_text( - trim_to_option(profile.subtitle.as_deref()) - .unwrap_or_default() - .as_str(), - 18, - ); - let world_tone = clamp_scene_image_text( - trim_to_option(profile.tone.as_deref()) - .unwrap_or_default() - .as_str(), - 48, - ); - let world_goal = clamp_scene_image_text( - trim_to_option(profile.player_goal.as_deref()) - .unwrap_or_default() - .as_str(), - 48, - ); - let world_summary = clamp_scene_image_text( - trim_to_option(profile.summary.as_deref()) - .unwrap_or_default() - .as_str(), - 72, - ); - let world_setting = clamp_scene_image_text( - trim_to_option(profile.setting_text.as_deref()) - .unwrap_or_default() - .as_str(), - 72, - ); - let landmark_name = clamp_scene_image_text( - trim_to_option(landmark.name.as_deref()) - .or_else(|| fallback_landmark_name.map(ToOwned::to_owned)) - .unwrap_or_else(|| "未命名场景".to_string()) - .as_str(), - 18, - ); - let landmark_description = clamp_scene_image_text( - trim_to_option(landmark.description.as_deref()) - .unwrap_or_default() - .as_str(), - 96, - ); - let requested_visual = clamp_scene_image_text(user_prompt, 120); - let danger_mood = describe_danger_level( - trim_to_option(landmark.danger_level.as_deref()) - .unwrap_or_default() - .as_str(), - ); - - vec![ - "为横版 16:9 2D RPG 生成高完成度像素风场景背景,适合作为剧情探索与战斗底图。".to_string(), - "画面构图必须严格按上下 1:1 分区:上半部分严格控制在整张图的 1/2 高度内,只描绘场景远景与中远景轮廓,不要让背景内容向下侵占超过半屏。".to_string(), - "下半部分严格占据整张图的 1/2 高度,用于玩家角色站位与展示,必须是模拟 3D 游戏视角的地面近景,有明确的透视延伸和近大远小关系,不是平铺的 2D 侧视地面。".to_string(), - "下半部分的内容必须是明确可站立的地面本体,例如道路、石板、平台、广场、甲板、沙地或草地,要有连续、稳定、可落脚的站位逻辑,不能只是装饰性前景、坑洞、障碍堆、栏杆带或不可通行的景物。".to_string(), - "下半部分地面近景要保持相对简洁、低细节、轮廓清楚、便于角色站立,不要堆满道具、植被、碎石、栏杆或复杂装饰。".to_string(), - if has_reference_image { - "已提供一张自定义参考图,请沿用其构图、镜头或氛围线索,同时继续满足本次场景需求。".to_string() - } else { - String::new() - }, - format!( - "世界:{}{}。", - if world_name.is_empty() { - "未命名世界" - } else { - world_name.as_str() - }, - if world_subtitle.is_empty() { - String::new() - } else { - format!(",{world_subtitle}") - } - ), - conditional_prompt_line("玩家设定", world_setting.as_str()), - conditional_prompt_line("世界概述", world_summary.as_str()), - conditional_prompt_line("整体基调", world_tone.as_str()), - conditional_prompt_line("玩家目标关联", world_goal.as_str()), - format!( - "场景名称:{}。", - if landmark_name.is_empty() { - "未命名场景" - } else { - landmark_name.as_str() - } - ), - conditional_prompt_line("场景描述", landmark_description.as_str()), - conditional_prompt_line("本次想要生成的画面内容", requested_visual.as_str()), - format!("{danger_mood}。"), - "不要出现 UI、字幕、文字、水印、logo 或装饰边框,人物仅可作为很小的远景剪影,画面重点放在场景本身,不要遮挡下半部分的角色展示区域。".to_string(), - ] - .into_iter() - .filter(|line| !line.is_empty()) - .collect::>() - .join("") -} - fn require_dashscope_settings(state: &AppState) -> Result { // Stage 2 的真实图片生成统一走 DashScope,这里先把配置缺失拦在业务入口前。 let base_url = state.config.dashscope_base_url.trim().trim_end_matches('/'); @@ -2362,10 +2260,6 @@ fn mime_to_extension(mime_type: &str) -> &str { } } -fn clamp_scene_image_text(value: &str, max_length: usize) -> String { - clamp_text(value, max_length, true) -} - fn conditional_prompt_line(prefix: &str, value: &str) -> String { if value.is_empty() { String::new() @@ -2374,17 +2268,6 @@ fn conditional_prompt_line(prefix: &str, value: &str) -> String { } } -fn describe_danger_level(danger_level: &str) -> String { - match danger_level.trim().to_ascii_lowercase().as_str() { - "low" | "低" => "气氛相对平静,但暗藏细节张力".to_string(), - "medium" | "中" => "带有明确的探索压力与潜在威胁".to_string(), - "high" | "高" => "危险感强烈,空间中有明显压迫感".to_string(), - "extreme" | "极高" => "极端危险,环境本身就像会吞没闯入者".to_string(), - _ if !danger_level.trim().is_empty() => format!("危险氛围:{}", danger_level.trim()), - _ => "危险气质保持克制但不可忽视".to_string(), - } -} - fn sanitize_storage_segment(value: &str, fallback: &str) -> String { let normalized = value .trim() @@ -2627,7 +2510,6 @@ mod tests { id: Some("reef_temple".to_string()), name: Some("礁石神殿".to_string()), description: Some("古老礁石上的半沉神殿。".to_string()), - danger_level: None, }), }; @@ -2665,7 +2547,6 @@ mod tests { id: Some("reef_temple".to_string()), name: Some("礁石神殿".to_string()), description: Some("古老礁石上的半沉神殿。".to_string()), - danger_level: Some("high".to_string()), }; let manual_prompt = build_custom_world_scene_image_prompt( &profile_input, diff --git a/server-rs/crates/api-server/src/custom_world_asset_prompts.rs b/server-rs/crates/api-server/src/custom_world_asset_prompts.rs index 942a16da..d99218a6 100644 --- a/server-rs/crates/api-server/src/custom_world_asset_prompts.rs +++ b/server-rs/crates/api-server/src/custom_world_asset_prompts.rs @@ -1,365 +1,6 @@ -use crate::character_animation_assets::find_motion_template; -use shared_contracts::assets::CharacterAnimationStrategy; - -/// 自定义世界角色主图提示词脚本。 -pub(crate) fn build_character_visual_prompt( - prompt_text: &str, - character_brief_text: Option<&str>, -) -> String { - let character_brief = [character_brief_text.unwrap_or_default(), prompt_text] - .into_iter() - .map(str::trim) - .filter(|value| !value.is_empty()) - .collect::>() - .join("\n"); - - build_master_prompt(character_brief.as_str()) -} - -/// 角色主图统一提示词骨架,迁移自旧共享 qwenSprite 主链。 -fn build_master_prompt(character_brief: &str) -> String { - [ - "单人,2D 横版游戏角色标准设定图,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。".to_string(), - "视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。".to_string(), - "主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。".to_string(), - "画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。".to_string(), - "风格要求:横版像素动作角色体型,头身比优先控制在 1 到 1.5 头身,保留清楚的头、躯干、双臂和双腿轮廓。明确的像素动作角色设定稿气质,整体按像素游戏角色设计方向组织,使用深色清楚轮廓、稳定剪影、有限大色块和硬朗边缘,不要柔和厚涂插画感,发型、服装、配饰优先形成醒目可读的像素级识别点,适合横版动作 sprite 资产。高可读性游戏角色设定图,形体清晰,服装层次明确,便于后续连续动作生成。".to_string(), - "请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,只有当文字设定明确要求非人结构时,才改为对应非人身体。".to_string(), - "主题词默认只作用在角色自身的服装剪裁、材质、纹样、饰品、发光细节上,不要把主题词自动扩写成背景建筑、自然场景、漂浮装饰或额外环境物件。".to_string(), - "视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。".to_string(), - character_brief.trim().to_string(), - ] - .into_iter() - .filter(|value| !value.trim().is_empty()) - .collect::>() - .join("\n") -} - -/// 自定义世界角色主图负面提示词脚本。 -pub(crate) fn build_character_visual_negative_prompt() -> String { - [ - "正面视角", - "左朝向", - "完全 90 度纯右视图", - "镜头透视", - "半身像", - "脚被裁切", - "头顶被裁切", - "多角色", - "复杂背景", - "建筑场景", - "漂浮物", - "烟雾环境", - "武器消失", - "武器换手", - "额外手臂", - "额外腿", - "服装变化", - "脸部变化", - "模糊", - "运动模糊", - "文字", - "水印", - "UI 元素", - "软萌 Q版大头贴", - "儿童绘本风", - "厚涂插画感", - "低对比柔边", - ] - .join(",") -} - -pub(crate) fn build_character_animation_prompt( - strategy: &CharacterAnimationStrategy, - prompt_text: &str, - character_brief_text: Option<&str>, - action_template_id: Option<&str>, - animation: &str, - frame_count: u32, - fps: u32, - duration_seconds: u32, - loop_: bool, - use_chroma_key: bool, -) -> String { - match strategy { - CharacterAnimationStrategy::ImageToVideo => build_ark_character_animation_prompt( - animation, - prompt_text, - character_brief_text, - action_template_id, - loop_, - use_chroma_key, - ), - CharacterAnimationStrategy::ImageSequence => { - build_image_sequence_prompt(animation, prompt_text, frame_count, use_chroma_key) - } - CharacterAnimationStrategy::MotionTransfer - | CharacterAnimationStrategy::ReferenceToVideo => build_npc_animation_prompt( - animation, - prompt_text, - character_brief_text, - action_template_id, - loop_, - use_chroma_key, - fps, - duration_seconds, - ), - } -} - -fn build_image_sequence_prompt( - animation: &str, - prompt_text: &str, - frame_count: u32, - use_chroma_key: bool, -) -> String { - [ - format!( - "同一角色连续 {} 帧动作序列,动作主题是 {}。", - frame_count, animation - ), - "固定机位,单人,全身,侧身朝右,保持同一套服装、发型、武器和体型。".to_string(), - "帧间动作连续,姿态逐步推进,不要换人,不要跳变,不要多余物体。".to_string(), - if use_chroma_key { - "纯绿色背景,无地面装饰,方便后期抠像。".to_string() - } else { - "背景尽量纯净,避免复杂场景。".to_string() - }, - prompt_text.trim().to_string(), - ] - .into_iter() - .filter(|value| !value.trim().is_empty()) - .collect::>() - .join(" ") -} - -fn build_npc_animation_prompt( - animation: &str, - prompt_text: &str, - character_brief_text: Option<&str>, - action_template_id: Option<&str>, - loop_: bool, - use_chroma_key: bool, - fps: u32, - duration_seconds: u32, -) -> String { - let character_brief = build_compact_animation_character_brief(character_brief_text); - let action_detail_text = sanitize_animation_prompt_text(prompt_text, 140); - let loop_rule = if loop_ { - "这是循环动作,直接进入动作循环中段,不要开场静止站桩,不要把主参考图原样作为第一帧。" - .to_string() - } else if animation == "die" { - "这是死亡终结动作,首帧参考主图角色形象即可,尾帧停在死亡结束姿态,不要回到主图形象。" - .to_string() - } else { - "这是非循环动作,首帧和尾帧都要回到参考主图角色形象,中段完成动作变化。".to_string() - }; - - if let Some(template) = action_template_id.and_then(|id| find_motion_template(id)) { - return [ - format!( - "单人 NPC 全身动作视频,动作主题是 {}。角色固定为同一人,右向斜侧身,镜头稳定,轮廓清晰,武器不可丢失。", - template.animation - ), - if use_chroma_key { - "背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。".to_string() - } else { - "背景简洁纯净,无复杂场景。".to_string() - }, - if character_brief.is_empty() { - String::new() - } else { - format!("角色设定:{}。", character_brief) - }, - format!("动作补充:{}。", template.prompt_suffix), - if action_detail_text.is_empty() { - String::new() - } else { - format!("动作细节:{}。", action_detail_text) - }, - format!("目标帧率 {} fps,时长约 {} 秒。", fps.clamp(1, 60), duration_seconds.clamp(1, 8)), - loop_rule, - ] - .into_iter() - .filter(|value| !value.trim().is_empty()) - .collect::>() - .join(" "); - } - - [ - format!("单人 NPC 全身动作视频,动作主题是 {}。", animation), - "角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。".to_string(), - "动作连贯,避免服装、发型、面部、武器随机漂移。".to_string(), - if use_chroma_key { - "背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。".to_string() - } else { - "背景简洁纯净,无复杂场景。".to_string() - }, - if character_brief.is_empty() { - String::new() - } else { - format!("角色设定:{}。", character_brief) - }, - if action_detail_text.is_empty() { - String::new() - } else { - action_detail_text - }, - format!( - "目标帧率 {} fps,时长约 {} 秒。", - fps.clamp(1, 60), - duration_seconds.clamp(1, 8) - ), - loop_rule, - ] - .into_iter() - .filter(|value| !value.trim().is_empty()) - .collect::>() - .join(" ") -} - -fn build_ark_character_animation_prompt( - animation: &str, - prompt_text: &str, - character_brief_text: Option<&str>, - action_template_id: Option<&str>, - loop_: bool, - use_chroma_key: bool, -) -> String { - let normalized_animation_name = animation.trim().replace(char::is_whitespace, "_"); - let normalized_animation_name = if normalized_animation_name.is_empty() { - "idle".to_string() - } else { - normalized_animation_name - }; - let character_brief = build_compact_animation_character_brief(character_brief_text); - let action_detail_text = sanitize_animation_prompt_text(prompt_text, 140); - if let Some(template) = action_template_id.and_then(find_motion_template) { - return build_video_action_prompt( - template.id, - template.prompt_suffix, - action_detail_text.as_str(), - Some(character_brief.as_str()), - use_chroma_key, - ); - } - - build_video_action_prompt( - normalized_animation_name.as_str(), - if loop_ { - "循环动作必须自然闭环,不要静止开场。" - } else { - "中段完成完整动作变化,收束干净。" - }, - action_detail_text.as_str(), - Some(character_brief.as_str()), - use_chroma_key, - ) -} - -/// 角色动作视频统一提示词骨架,按每个动作模板与补充描述生成。 -fn build_video_action_prompt( - action_id: &str, - action_sequence: &str, - action_detail_text: &str, - character_brief_text: Option<&str>, - use_chroma_key: bool, -) -> String { - [ - format!("单人全身角色动作视频,动作英文名是 {}。", action_id), - "角色固定为图1同一角色,保持右向斜侧身动作视角,镜头稳定,轮廓清晰,不要退化成完全 90 度纯右视图。".to_string(), - "视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。".to_string(), - "主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。".to_string(), - "画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。".to_string(), - "风格要求:横版像素动作角色体型,头身比优先控制在 1 到 1.5 头身,保留清楚的头、躯干、双臂和双腿轮廓。明确的像素动作角色设定稿气质,整体按像素游戏角色设计方向组织,使用深色清楚轮廓、稳定剪影、有限大色块和硬朗边缘,不要柔和厚涂插画感,发型、服装、配饰优先形成醒目可读的像素级识别点,适合横版动作 sprite 资产。高可读性游戏角色设定图,形体清晰,服装层次明确,便于后续连续动作生成。".to_string(), - format!("动作结构:{}。结尾要求:动作收束清楚,便于后续抽帧。", action_sequence), - if use_chroma_key { - "背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。".to_string() - } else { - "背景简洁纯净,无其他人物和复杂场景元素,方便后期抽帧。".to_string() - }, - format!( - "动作补充细节:{}", - if action_detail_text.trim().is_empty() { - "保持动作清晰、节奏明确、适合后续抽帧为 sprite sheet。" - } else { - action_detail_text.trim() - } - ), - character_brief_text - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(|value| format!("角色设定:{}。", value)) - .unwrap_or_default(), - "目标是后续抽帧为横版动作游戏精灵表,因此不要镜头切换,不要景别变化,不要角色漂移。".to_string(), - ] - .into_iter() - .filter(|value| !value.trim().is_empty()) - .collect::>() - .join(" ") -} - -pub(crate) fn build_fallback_moderation_safe_animation_prompt( - animation: &str, - loop_: bool, - use_chroma_key: bool, -) -> String { - [ - format!("单人全身角色动作视频,动作主题是 {}。", animation), - "角色固定为同一人,右向斜侧身,镜头稳定,轮廓清楚。".to_string(), - if loop_ { - "循环动作直接进入稳定循环,不要静止开场,不要定格首帧。".to_string() - } else { - "非循环动作首尾回到角色标准站姿,中段完成动作变化。".to_string() - }, - if use_chroma_key { - "背景为纯绿色绿幕,无其他人物和场景元素。".to_string() - } else { - "背景简洁纯净。".to_string() - }, - ] - .join(" ") -} - -fn sanitize_animation_prompt_text(value: &str, max_length: usize) -> String { - value - .replace(char::is_whitespace, " ") - .replace("血浆", "") - .replace("喷血", "") - .replace("鲜血", "") - .replace("断肢", "") - .replace("斩首", "") - .replace("裸体", "") - .replace("裸露", "") - .replace("色情", "") - .replace("性交", "") - .replace("死亡", "倒地结束") - .replace("死去", "倒地结束") - .replace("击杀", "倒地结束") - .replace("受击", "失衡") - .replace("受伤", "失衡") - .replace("砍杀", "挥击") - .replace("斩击", "挥击") - .split_whitespace() - .collect::>() - .join(" ") - .chars() - .take(max_length) - .collect::() - .trim() - .to_string() -} - -fn build_compact_animation_character_brief(value: Option<&str>) -> String { - let normalized = sanitize_animation_prompt_text(value.unwrap_or_default(), 160); - if normalized.is_empty() { - return String::new(); - } - normalized - .split(['/', '|', '\n', ',', ',', '。', ';', ';']) - .map(str::trim) - .filter(|item| !item.is_empty()) - .take(4) - .collect::>() - .join(",") -} +pub(crate) use crate::prompt::character_animation::{ + build_character_animation_prompt, build_fallback_moderation_safe_animation_prompt, +}; +pub(crate) use crate::prompt::character_visual::{ + build_character_visual_negative_prompt, build_character_visual_prompt, +}; diff --git a/server-rs/crates/api-server/src/custom_world_foundation_draft.rs b/server-rs/crates/api-server/src/custom_world_foundation_draft.rs index 263f525e..41a2a16e 100644 --- a/server-rs/crates/api-server/src/custom_world_foundation_draft.rs +++ b/server-rs/crates/api-server/src/custom_world_foundation_draft.rs @@ -1,4 +1,14 @@ -use platform_llm::{LlmClient, LlmMessage, LlmTextRequest}; +use crate::prompt::foundation_draft::{ + build_custom_world_framework_json_repair_prompt, build_custom_world_framework_prompt, + build_custom_world_landmark_network_batch_json_repair_prompt, + build_custom_world_landmark_network_batch_prompt, + build_custom_world_landmark_seed_batch_json_repair_prompt, + build_custom_world_landmark_seed_batch_prompt, + build_custom_world_role_batch_json_repair_prompt, build_custom_world_role_batch_prompt, + build_custom_world_role_outline_batch_json_repair_prompt, + build_custom_world_role_outline_batch_prompt, +}; +use platform_llm::{LlmClient, LlmMessage, LlmTextRequest}; use serde_json::{Map as JsonMap, Value as JsonValue, json}; use shared_contracts::runtime::ExecuteCustomWorldAgentActionRequest; use spacetime_client::CustomWorldAgentSessionRecord; @@ -158,7 +168,6 @@ const FOUNDATION_DRAFT_LANDMARK_COUNT: usize = 2; const FOUNDATION_ROLE_OUTLINE_BATCH_SIZE: usize = 2; const FOUNDATION_LANDMARK_BATCH_SIZE: usize = 2; const FOUNDATION_ROLE_DETAIL_BATCH_SIZE: usize = 2; -const CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES: [i64; 4] = [15, 30, 60, 90]; async fn request_foundation_json_stage( llm_client: &LlmClient, @@ -604,360 +613,6 @@ fn build_eight_anchor_foundation_text(anchor_content: &JsonValue) -> String { sections.join("\n") } -fn build_custom_world_framework_prompt(setting_text: &str) -> String { - [ - "请先根据下面的玩家设定创建一份“世界核心骨架”,后续我会分步骤生成角色名单、场景名单和详细档案。".to_string(), - "你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(), - "这一步只保留世界顶层信息与一个开局归处场景,不要输出 playableNpcs、storyNpcs、landmarks,也不要展开人物和地图细节。".to_string(), - "玩家设定:".to_string(), - setting_text.trim().to_string(), - "".to_string(), - "输出 JSON 模板:".to_string(), - "{".to_string(), - " \"name\": \"世界名称\",".to_string(), - " \"subtitle\": \"世界副标题\",".to_string(), - " \"summary\": \"世界概述\",".to_string(), - " \"tone\": \"世界基调\",".to_string(), - " \"playerGoal\": \"玩家核心目标\",".to_string(), - " \"templateWorldType\": \"WUXIA|XIANXIA\",".to_string(), - " \"majorFactions\": [\"势力甲\", \"势力乙\"],".to_string(), - " \"coreConflicts\": [\"冲突甲\", \"冲突乙\"],".to_string(), - " \"camp\": {".to_string(), - " \"name\": \"开局归处名称\",".to_string(), - " \"description\": \"这是玩家进入世界后的第一处落脚点描述\",".to_string(), - " \"sceneTaskDescription\": \"首次进入该场景时要生成的章节任务核心上下文\",".to_string(), - " \"actBackgroundPromptTexts\": [\"开局第一幕背景画面描述\", \"开局第二幕背景画面描述\", \"开局第三幕背景画面描述\"],".to_string(), - " \"actEventDescriptions\": [\"开局第一幕事件描述\", \"开局第二幕事件描述\", \"开局第三幕事件描述\"],".to_string(), - " \"dangerLevel\": \"low|medium|high|extreme\"".to_string(), - " }".to_string(), - "}".to_string(), - "".to_string(), - "要求:".to_string(), - "- 所有生成文本都必须使用中文。".to_string(), - "- 这一步只输出顶层 9 个字段:name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。".to_string(), - "- 这是一个完全独立的自定义世界;不要在任何正文里直接写出“武侠世界”“仙侠世界”等现成世界名。".to_string(), - "- templateWorldType 只是系统兼容字段,不代表正文应当引用的世界名称。".to_string(), - "- camp 必须表示玩家开局时的落脚处,名字不要直接写成“某某营地”,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念。".to_string(), - "- camp.sceneTaskDescription 必须描述玩家首次进入开局场景时要完成的核心任务,会作为游戏章节任务生成上下文,控制在 24 到 56 个汉字内。".to_string(), - "- camp.actBackgroundPromptTexts 必须恰好 3 条,分别对应开局场景第 1/2/3 幕背景图画面内容描述;每条都必须可直接交给生图模型,控制在 40 到 90 个汉字内。".to_string(), - "- camp.actEventDescriptions 必须恰好 3 条,分别描述每一幕发生的事件;事件必须和当前幕对面的角色强相关,控制在 24 到 56 个汉字内。".to_string(), - "- 不要输出 playableNpcs、storyNpcs、landmarks、items,也不要输出任何角色和地图细节。".to_string(), - "- majorFactions 保持 2 到 3 个,coreConflicts 保持 2 到 3 个。".to_string(), - "- 世界设定必须直接源自玩家输入,不要脱离主题乱扩写。".to_string(), - "- 每个字符串尽量简洁:subtitle 控制在 8 到 18 个汉字内,summary 控制在 16 到 32 个汉字内,tone 控制在 6 到 16 个汉字内,playerGoal 控制在 16 到 32 个汉字内,camp.description 控制在 18 到 40 个汉字内。".to_string(), - "- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(), - ].join("\n") -} - -fn build_custom_world_framework_json_repair_prompt(response_text: &str) -> String { - [ - "下面这段文本本应是自定义世界核心骨架的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。", - "请只输出修复后的 JSON 对象。", - "顶层必须只包含:name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。", - "不要输出 playableNpcs、storyNpcs、landmarks、items 或任何其他字段。", - "majorFactions 与 coreConflicts 必须是字符串数组。", - "camp 必须是对象,且包含:name、description、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions、dangerLevel。", - "原始文本:", - response_text.trim(), - ].join("\n") -} - -fn build_custom_world_role_outline_batch_prompt( - framework: &JsonValue, - role_type: &str, - batch_count: usize, - forbidden_names: &[String], -) -> String { - let key = role_key(role_type); - let label = if role_type == "playable" { - "可扮演角色" - } else { - "场景角色" - }; - [ - format!("请根据下面的世界核心信息,生成一批{label}框架名单。"), - "后续我会继续补全人物档案,所以这一步每个角色只保留身份骨架与资产默认描述字段。".to_string(), - "你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(), - "世界核心信息:".to_string(), - build_framework_summary_text(framework, 0), - if forbidden_names.is_empty() { "".to_string() } else { format!("这些名字已经生成,禁止重复:{}", forbidden_names.join("、")) }, - "".to_string(), - "输出 JSON 模板:".to_string(), - "{".to_string(), - format!(" \"{key}\": ["), - " {".to_string(), - " \"name\": \"角色名称\",".to_string(), - " \"title\": \"称号\",".to_string(), - " \"role\": \"身份\",".to_string(), - " \"description\": \"极简定位描述\",".to_string(), - " \"visualDescription\": \"默认角色形象描述\",".to_string(), - " \"actionDescription\": \"默认角色动作描述\",".to_string(), - " \"sceneVisualDescription\": \"默认出现场景描述\",".to_string(), - " \"initialAffinity\": 18,".to_string(), - " \"relationshipHooks\": [\"一个关系切入口\"],".to_string(), - " \"tags\": [\"标签1\", \"标签2\"]".to_string(), - " }".to_string(), - " ]".to_string(), - "}".to_string(), - "".to_string(), - "要求:".to_string(), - format!("- 必须生成恰好 {batch_count} 个{label}。"), - "- 这是一个完全独立的自定义世界;不要把角色写成来自“武侠世界”“仙侠世界”等现成世界。".to_string(), - "- 名称必须具体且互不重复,不要使用 角色1、NPC1、场景角色1 之类的占位名。".to_string(), - "- 只保留:name、title、role、description、visualDescription、actionDescription、sceneVisualDescription、initialAffinity、relationshipHooks、tags。".to_string(), - "- visualDescription 是打开角色形象图像生成面板时默认填入的角色形象描述,必须具体到体型、服装、轮廓与识别点,控制在 24 到 60 个汉字内。".to_string(), - "- actionDescription 是打开每个角色动作视频生成面板时默认填入的动作描述,必须体现该角色默认动作节奏、武器或施法方式,控制在 18 到 48 个汉字内。".to_string(), - "- sceneVisualDescription 是该角色常出现或关联的场景画面描述,会作为场景生图描述框的默认候选,控制在 24 到 60 个汉字内。".to_string(), - "- relationshipHooks 最多 1 条;tags 保持 1 到 2 个。".to_string(), - "- description 控制在 8 到 18 个汉字内,title 和 role 也尽量短。".to_string(), - "- initialAffinity 必须是 -40 到 90 的整数。".to_string(), - if role_type == "playable" { "- 可扮演角色的定位必须明显不同,通常使用 18 到 40 的初始好感。".to_string() } else { "- 场景角色要覆盖势力成员、居民、异类或怪物,不要全是同一种身份;敌对或怪物型角色可以使用负好感。".to_string() }, - "- 所有生成文本都必须使用中文。".to_string(), - "- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(), - ].into_iter().filter(|value| !value.is_empty()).collect::>().join("\n") -} - -fn build_custom_world_role_outline_batch_json_repair_prompt( - response_text: &str, - role_type: &str, - expected_count: usize, - forbidden_names: &[String], -) -> String { - let key = role_key(role_type); - [ - format!("下面这段文本本应是自定义世界{}框架名单批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。", if role_type == "playable" { "可扮演角色" } else { "场景角色" }), - "请只输出修复后的 JSON 对象。".to_string(), - format!("顶层必须只包含一个 {key} 数组。"), - format!("必须保留恰好 {expected_count} 个角色对象。"), - if forbidden_names.is_empty() { "".to_string() } else { format!("禁止使用这些重复名:{}。", forbidden_names.join("、")) }, - "每个角色只包含:name、title、role、description、visualDescription、actionDescription、sceneVisualDescription、initialAffinity、relationshipHooks、tags。".to_string(), - "如果缺少字段:字符串补空字符串,relationshipHooks 和 tags 补空数组,initialAffinity 补默认整数。".to_string(), - "不要输出 backstory、skills、landmarks 或任何其他字段。".to_string(), - "原始文本:".to_string(), - response_text.trim().to_string(), - ].into_iter().filter(|value| !value.is_empty()).collect::>().join("\n") -} -fn build_custom_world_landmark_seed_batch_prompt( - framework: &JsonValue, - batch_count: usize, - forbidden_names: &[String], -) -> String { - [ - "请根据下面的世界核心信息,生成一批关键场景框架名单。".to_string(), - "后续我会继续补全场景网络,所以这一步每个地点只保留场景骨架、地点默认生图描述和逐幕背景描述。".to_string(), - "你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(), - "世界核心信息:".to_string(), - build_framework_summary_text(framework, 0), - if forbidden_names.is_empty() { "".to_string() } else { format!("这些地点已经生成,禁止重复:{}", forbidden_names.join("、")) }, - "".to_string(), - "输出 JSON 模板:".to_string(), - "{".to_string(), - " \"landmarks\": [".to_string(), - " {".to_string(), - " \"name\": \"场景名称\",".to_string(), - " \"description\": \"场景极简描述\",".to_string(), - " \"visualDescription\": \"默认场景生图描述\",".to_string(), - " \"sceneTaskDescription\": \"首次进入该场景时要生成的章节任务核心上下文\",".to_string(), - " \"actBackgroundPromptTexts\": [\"第一幕背景画面描述\", \"第二幕背景画面描述\", \"第三幕背景画面描述\"],".to_string(), - " \"actEventDescriptions\": [\"第一幕事件描述\", \"第二幕事件描述\", \"第三幕事件描述\"],".to_string(), - " \"dangerLevel\": \"low|medium|high|extreme\"".to_string(), - " }".to_string(), - " ]".to_string(), - "}".to_string(), - "".to_string(), - "要求:".to_string(), - format!("- 必须生成恰好 {batch_count} 个关键场景。"), - "- 这是一个完全独立的自定义世界;地点名称必须直接服务玩家输入主题。".to_string(), - "- 名称必须具体且互不重复,不要使用 地点1、场景1 之类的占位名。".to_string(), - "- 每个地点只保留:name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions、dangerLevel。".to_string(), - "- sceneTaskDescription 必须描述玩家首次进入该场景时要完成的核心任务,会作为游戏章节任务生成上下文,控制在 24 到 56 个汉字内。".to_string(), - "- visualDescription 是打开场景背景图像生成面板时默认填入的场景描述,必须具体到画面主体、远近景层次、地面可站立区域和氛围识别点,控制在 32 到 80 个汉字内。".to_string(), - "- actBackgroundPromptTexts 必须恰好 3 条,分别对应这个场景章节的第 1/2/3 幕背景图画面内容描述;每条都必须是大模型根据当前地点、主线阶段和可出场角色直接写出的画面描述,控制在 40 到 90 个汉字内。".to_string(), - "- actBackgroundPromptTexts 禁止使用“某某第1幕背景;玩家会在……”这类标题、摘要、规则句拼接格式;必须像可直接交给生图模型的自然画面描述。".to_string(), - "- actEventDescriptions 必须恰好 3 条,分别描述每一幕发生的事件;事件必须和当前幕对面的角色强相关,控制在 24 到 56 个汉字内。".to_string(), - "- description 控制在 12 到 24 个汉字内。".to_string(), - "- dangerLevel 只能是 low、medium、high、extreme 之一。".to_string(), - "- 所有生成文本都必须使用中文。".to_string(), - "- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(), - ].into_iter().filter(|value| !value.is_empty()).collect::>().join("\n") -} - -fn build_custom_world_landmark_seed_batch_json_repair_prompt( - response_text: &str, - expected_count: usize, - forbidden_names: &[String], -) -> String { - [ - "下面这段文本本应是自定义世界关键场景框架名单批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。".to_string(), - "请只输出修复后的 JSON 对象。".to_string(), - "顶层必须只包含一个 landmarks 数组。".to_string(), - format!("必须保留恰好 {expected_count} 个地点对象。"), - if forbidden_names.is_empty() { "".to_string() } else { format!("禁止使用这些重复名:{}。", forbidden_names.join("、")) }, - "每个地点只包含:name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions、dangerLevel。".to_string(), - "如果缺少字段:字符串补空字符串,actBackgroundPromptTexts 和 actEventDescriptions 补空数组,dangerLevel 补 medium。".to_string(), - "不要输出 sceneNpcNames、connectedLandmarks、items 或任何其他字段。".to_string(), - "原始文本:".to_string(), - response_text.trim().to_string(), - ].into_iter().filter(|value| !value.is_empty()).collect::>().join("\n") -} - -fn build_custom_world_landmark_network_batch_prompt( - framework: &JsonValue, - story_npcs: &[JsonValue], - landmark_batch: &[JsonValue], -) -> String { - [ - "请补全下面这一批关键场景的探索网络信息。".to_string(), - "你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(), - "世界核心信息:".to_string(), - build_framework_summary_text(framework, 10), - "可用场景角色名单:".to_string(), - names_from_entries(story_npcs).join("、"), - "本批场景:".to_string(), - compact_json_text(&JsonValue::Array(landmark_batch.to_vec())), - "".to_string(), - "输出 JSON 模板:".to_string(), - "{".to_string(), - " \"landmarks\": [".to_string(), - " {".to_string(), - " \"name\": \"场景名称\",".to_string(), - " \"description\": \"场景描述\",".to_string(), - " \"dangerLevel\": \"low|medium|high|extreme\",".to_string(), - " \"sceneNpcNames\": [\"会在这里出现的角色名\"],".to_string(), - " \"connectedLandmarkNames\": [\"相邻或可通往的地点名\"],".to_string(), - " \"entryHook\": \"玩家进入这里时首先遇到的钩子\"".to_string(), - " }".to_string(), - " ]".to_string(), - "}".to_string(), - "".to_string(), - "要求:".to_string(), - "- 必须只补全本批场景,name 必须与本批场景完全一致,不得增删改名。".to_string(), - "- sceneNpcNames 只能引用上方可用场景角色名单中的名字,每个地点 1 到 3 个。".to_string(), - "- connectedLandmarkNames 优先引用已知关键场景名称,每个地点 1 到 3 个。".to_string(), - "- entryHook 控制在 16 到 36 个汉字内。".to_string(), - "- 所有生成文本都必须使用中文。".to_string(), - "- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(), - ].into_iter().filter(|value| !value.is_empty()).collect::>().join("\n") -} - -fn build_custom_world_landmark_network_batch_json_repair_prompt( - response_text: &str, - expected_names: &[String], -) -> String { - [ - "下面这段文本本应是自定义世界关键场景探索网络补全批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。".to_string(), - "请只输出修复后的 JSON 对象。".to_string(), - "顶层必须只包含一个 landmarks 数组。".to_string(), - format!("这个数组里只能保留这些地点名:{}。", expected_names.join("、")), - "名称必须与名单完全一致,不得增删改名;如果原文遗漏,可按名单顺序补齐占位对象。".to_string(), - "每个地点都必须包含:name、description、dangerLevel、sceneNpcNames、connectedLandmarkNames、entryHook。".to_string(), - "如果缺少字段:字符串补空字符串,数组补空数组,dangerLevel 补 medium。".to_string(), - "不要新增名单外的地点。".to_string(), - "原始文本:".to_string(), - response_text.trim().to_string(), - ].join("\n") -} - -fn build_custom_world_role_batch_prompt( - framework: &JsonValue, - role_type: &str, - role_batch: &[JsonValue], - stage: &str, -) -> String { - let key = role_key(role_type); - let label = if role_type == "playable" { - "可扮演角色" - } else { - "场景角色" - }; - let stage_label = if stage == "narrative" { - "叙事档案" - } else { - "养成档案" - }; - let required_fields = if stage == "narrative" { - "name、backstory、personality、motivation、combatStyle" - } else { - "name、backstoryReveal、skills、initialItems" - }; - let template_extra = if stage == "narrative" { - [ - " \"backstory\": \"公开背景\",", - " \"personality\": \"性格关键词\",", - " \"motivation\": \"当前动机\",", - " \"combatStyle\": \"行动或战斗风格\"", - ] - .join("\n") - } else { - [ - " \"backstoryReveal\": { \"publicSummary\": \"公开摘要\", \"chapters\": [{ \"affinityRequired\": 15, \"title\": \"羁绊章节\", \"summary\": \"章节摘要\" }] },", - " \"skills\": [{ \"name\": \"技能名\", \"summary\": \"技能摘要\", \"style\": \"风格\" }],", - " \"initialItems\": [{ \"name\": \"物品名\", \"category\": \"道具\", \"quantity\": 1, \"rarity\": \"common\", \"description\": \"描述\", \"tags\": [\"标签\"] }]", - ].join("\n") - }; - [ - format!("请为下面这一批{label}补全{stage_label}。"), - "你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(), - "世界核心信息:".to_string(), - build_framework_summary_text(framework, 10), - "本批角色:".to_string(), - build_role_outline_prompt_text(role_batch, framework, role_type), - "".to_string(), - "输出 JSON 模板:".to_string(), - "{".to_string(), - format!(" \"{key}\": ["), - " {".to_string(), - " \"name\": \"角色名称\",".to_string(), - template_extra, - " }".to_string(), - " ]".to_string(), - "}".to_string(), - "".to_string(), - "要求:".to_string(), - "- 必须只补全本批角色,name 必须与本批角色完全一致,不得增删改名。".to_string(), - format!("- 每个角色必须包含:{required_fields}。"), - if stage == "narrative" { "- backstory 控制在 32 到 80 个汉字内;personality、motivation、combatStyle 都要短而具体。".to_string() } else { format!("- backstoryReveal 必须包含 publicSummary 和 4 个 chapters,chapters.affinityRequired 固定为 {}。", CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.iter().map(i64::to_string).collect::>().join("、")) }, - if stage == "narrative" { "- 不要输出 backstoryReveal、skills、initialItems。".to_string() } else { "- skills 默认 3 个;initialItems 默认 3 个;不要输出 backstory、personality、motivation、combatStyle。".to_string() }, - "- 所有生成文本都必须使用中文。".to_string(), - "- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(), - ].into_iter().filter(|value| !value.is_empty()).collect::>().join("\n") -} - -fn build_custom_world_role_batch_json_repair_prompt( - response_text: &str, - role_type: &str, - stage: &str, - expected_names: &[String], -) -> String { - let key = role_key(role_type); - if stage == "narrative" { - return [ - format!("下面这段文本本应是自定义世界{}叙事档案补全批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。", if role_type == "playable" { "可扮演角色" } else { "场景角色" }), - "请只输出修复后的 JSON 对象。".to_string(), - format!("顶层必须只包含一个 {key} 数组。"), - format!("这个数组里只能保留这些角色名:{}。", expected_names.join("、")), - "名称必须与名单完全一致,不得增删改名;如果原文遗漏,可按名单顺序补齐占位对象。".to_string(), - "每个角色都必须包含:name、backstory、personality、motivation、combatStyle。".to_string(), - "如果缺少字段:字符串补空字符串。".to_string(), - "不要输出 backstoryReveal、skills、initialItems,也不要新增名单外的角色。".to_string(), - "原始文本:".to_string(), - response_text.trim().to_string(), - ].join("\n"); - } - [ - format!("下面这段文本本应是自定义世界{}档案补全批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。", if role_type == "playable" { "可扮演角色" } else { "场景角色" }), - "请只输出修复后的 JSON 对象。".to_string(), - format!("顶层必须只包含一个 {key} 数组。"), - format!("这个数组里只能保留这些角色名:{}。", expected_names.join("、")), - "名称必须与名单完全一致,不得增删改名;如果原文遗漏,可按名单顺序补齐占位对象。".to_string(), - "每个角色都必须包含:name、backstoryReveal、skills、initialItems。".to_string(), - format!("backstoryReveal 必须包含 publicSummary 和 4 个 chapters,chapters.affinityRequired 固定为 {}。", CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.iter().map(i64::to_string).collect::>().join("、")), - "skills 默认补成 3 个对象,每个对象包含 name、summary、style;initialItems 默认补成 3 个对象,每个对象包含 name、category、quantity、rarity、description、tags。".to_string(), - "不要输出 backstory、personality、motivation、combatStyle、landmarks,也不要新增名单外的角色。".to_string(), - "原始文本:".to_string(), - response_text.trim().to_string(), - ].join("\n") -} #[cfg(test)] fn build_foundation_draft_user_prompt(session: &CustomWorldAgentSessionRecord) -> String { let anchor_content = to_pretty_json(&session.anchor_content); @@ -1071,14 +726,15 @@ fn build_foundation_draft_profile_from_framework( )]) }), ); - let camp = framework.get("camp").cloned().unwrap_or_else(|| json!({ "name": "开局归处", "description": "玩家进入世界后的第一处落脚点。", "dangerLevel": "low" })); + let camp = framework.get("camp").cloned().unwrap_or_else(|| json!({ "name": "开局归处", "description": "玩家进入世界后的第一处落脚点。" })); object.insert("camp".to_string(), camp.clone()); object.insert( "playableNpcs".to_string(), JsonValue::Array(playable_detailed), ); object.insert("storyNpcs".to_string(), JsonValue::Array(story_detailed)); - let scene_chapter_blueprints = build_scene_chapter_blueprints_from_camp_and_landmarks(&camp, &landmarks); + let scene_chapter_blueprints = + build_scene_chapter_blueprints_from_camp_and_landmarks(&camp, &landmarks); object.insert("landmarks".to_string(), JsonValue::Array(landmarks)); object.insert("chapters".to_string(), JsonValue::Array(Vec::new())); object.insert( @@ -1127,8 +783,8 @@ fn build_scene_chapter_blueprint_from_scene( ) -> JsonValue { let scene_name = json_text(scene, "name") .unwrap_or_else(|| format!("{}{}", fallback_name_prefix, chapter_index + 1)); - let scene_id = json_text(scene, "id") - .unwrap_or_else(|| format!("{}-{}", id_prefix, chapter_index + 1)); + let scene_id = + json_text(scene, "id").unwrap_or_else(|| format!("{}-{}", id_prefix, chapter_index + 1)); let summary = json_text(scene, "description").unwrap_or_default(); let scene_task_description = json_text(scene, "sceneTaskDescription") .unwrap_or_else(|| build_default_scene_task_description(&scene_name, &summary)); @@ -1201,7 +857,9 @@ fn build_scene_act_blueprint_from_landmark( fn build_default_scene_task_description(scene_name: &str, scene_summary: &str) -> String { if scene_summary.trim().is_empty() { - return format!("首次进入{scene_name}时,确认当前场景的核心异常、关键角色与下一步行动方向。"); + return format!( + "首次进入{scene_name}时,确认当前场景的核心异常、关键角色与下一步行动方向。" + ); } format!("首次进入{scene_name}时,围绕{scene_summary}确认核心异常、关键角色与下一步行动方向。") } @@ -1269,7 +927,7 @@ fn normalize_framework_shape(framework: &mut JsonValue, setting_text: &str) { object.insert("coreConflicts".to_string(), JsonValue::Array(Vec::new())); } if !object.get("camp").is_some_and(JsonValue::is_object) { - object.insert("camp".to_string(), json!({ "name": "开局归处", "description": "玩家进入世界后的第一处落脚点。", "dangerLevel": "low" })); + object.insert("camp".to_string(), json!({ "name": "开局归处", "description": "玩家进入世界后的第一处落脚点。" })); } if let Some(camp) = object.get_mut("camp").and_then(JsonValue::as_object_mut) { let camp_name = camp @@ -1350,131 +1008,6 @@ fn normalize_framework_shape(framework: &mut JsonValue, setting_text: &str) { } } -fn build_framework_summary_text(framework: &JsonValue, max_landmarks: usize) -> String { - let landmark_text = array_field(framework, "landmarks") - .into_iter() - .take(max_landmarks) - .map(|landmark| { - format!( - "{}({},{})", - json_text(&landmark, "name").unwrap_or_default(), - json_text(&landmark, "dangerLevel").unwrap_or_default(), - json_text(&landmark, "description").unwrap_or_default() - ) - }) - .filter(|value| !value.trim().is_empty()) - .collect::>() - .join("、"); - [ - format!("世界:{}", json_text(framework, "name").unwrap_or_default()), - format!( - "副标题:{}", - json_text(framework, "subtitle").unwrap_or_default() - ), - format!( - "世界概述:{}", - json_text(framework, "summary").unwrap_or_default() - ), - format!( - "世界基调:{}", - json_text(framework, "tone").unwrap_or_default() - ), - format!( - "玩家核心目标:{}", - json_text(framework, "playerGoal").unwrap_or_default() - ), - json_string_array(framework, "majorFactions") - .map(|items| format!("主要势力:{}", items.join("、"))) - .unwrap_or_default(), - json_string_array(framework, "coreConflicts") - .map(|items| format!("核心冲突:{}", items.join("、"))) - .unwrap_or_default(), - format!( - "开局归处:{}({})", - json_path_text(framework, &["camp", "name"]).unwrap_or_default(), - json_path_text(framework, &["camp", "description"]).unwrap_or_default() - ), - if landmark_text.is_empty() { - String::new() - } else { - format!("关键场景:{landmark_text}") - }, - ] - .into_iter() - .filter(|value| !value.is_empty()) - .collect::>() - .join("\n") -} - -fn build_role_outline_prompt_text( - role_batch: &[JsonValue], - framework: &JsonValue, - role_type: &str, -) -> String { - role_batch - .iter() - .map(|role| { - let appearance_text = if role_type == "story" { - landmark_names_for_role( - framework, - json_text(role, "name").unwrap_or_default().as_str(), - ) - .join("、") - } else { - String::new() - }; - [ - format!( - "- {} / {}", - json_text(role, "name").unwrap_or_default(), - json_text(role, "title").unwrap_or_default() - ), - format!("身份:{}", json_text(role, "role").unwrap_or_default()), - format!( - "框架描述:{}", - json_text(role, "description").unwrap_or_default() - ), - format!( - "预设好感:{}", - role.get("initialAffinity") - .and_then(JsonValue::as_i64) - .unwrap_or(0) - ), - json_string_array(role, "relationshipHooks") - .map(|items| format!("关系切入口:{}", items.join("、"))) - .unwrap_or_default(), - json_string_array(role, "tags") - .map(|items| format!("标签:{}", items.join("、"))) - .unwrap_or_default(), - if appearance_text.is_empty() { - String::new() - } else { - format!("出现场景:{appearance_text}") - }, - ] - .into_iter() - .filter(|value| !value.is_empty()) - .collect::>() - .join("\n") - }) - .collect::>() - .join("\n") -} - -fn landmark_names_for_role(framework: &JsonValue, role_name: &str) -> Vec { - array_field(framework, "landmarks") - .into_iter() - .filter_map(|landmark| { - let names = json_string_array(&landmark, "sceneNpcNames").unwrap_or_default(); - if names.iter().any(|name| name == role_name) { - json_text(&landmark, "name") - } else { - None - } - }) - .collect() -} - fn role_key(role_type: &str) -> &'static str { if role_type == "playable" { "playableNpcs" @@ -1679,8 +1212,9 @@ fn normalize_scene_chapter_blueprint(chapter: JsonValue) -> JsonValue { .and_then(JsonValue::as_str) .map(str::trim) .filter(|value| !value.is_empty()) - .unwrap_or("第一幕"); - object.insert("title".to_string(), JsonValue::String(title.to_string())); + .map(ToOwned::to_owned) + .unwrap_or_else(|| "第一幕".to_string()); + object.insert("title".to_string(), JsonValue::String(title.clone())); let summary = object .get("summary") .and_then(JsonValue::as_str) @@ -1695,7 +1229,7 @@ fn normalize_scene_chapter_blueprint(chapter: JsonValue) -> JsonValue { .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) - .unwrap_or_else(|| build_default_scene_task_description(title, summary.as_str())); + .unwrap_or_else(|| build_default_scene_task_description(title.as_str(), summary.as_str())); object.insert( "sceneTaskDescription".to_string(), JsonValue::String(scene_task_description), @@ -1794,12 +1328,18 @@ fn normalize_scene_act_blueprint(act: JsonValue, index: usize) -> JsonValue { .unwrap_or_else(|| { build_default_act_event_description(summary.as_str(), opposite_npc_id.as_str(), index) }); - object.insert("encounterNpcIds".to_string(), JsonValue::Array(encounter_npc_ids)); + object.insert( + "encounterNpcIds".to_string(), + JsonValue::Array(encounter_npc_ids), + ); object.insert( "primaryNpcId".to_string(), JsonValue::String(opposite_npc_id.clone()), ); - object.insert("oppositeNpcId".to_string(), JsonValue::String(opposite_npc_id)); + object.insert( + "oppositeNpcId".to_string(), + JsonValue::String(opposite_npc_id), + ); object.insert( "eventDescription".to_string(), JsonValue::String(event_description), @@ -1979,11 +1519,17 @@ mod tests { let landmarks = vec![json!({ "name": "雾港码头", "description": "旧船骨露出黑潮。", + "sceneTaskDescription": "首次进入雾港码头时,查明黑潮船骨与灯童丁目击证词的关联。", "actBackgroundPromptTexts": [ "潮湿木栈桥在青灰雾里延伸,近处有可站立的破旧甲板,远处旧船骨与灯塔剪影压低天空。", "封锁绳与巡海灯横切码头,中景堆满浸水货箱,远景黑潮拍打沉船残骸。", "退潮后的泥滩露出父亲留下的海图匣,雾中灯火错位闪烁,岸边留出对峙站位。" ], + "actEventDescriptions": [ + "灯童丁在潮湿栈桥上拦住玩家,交出与旧船骨有关的第一句证词。", + "灯童丁发现巡海灯突然转向,逼玩家判断封锁线真正保护的目标。", + "灯童丁指认海图匣位置,玩家必须在退潮前确认父亲留下的暗号。" + ], "sceneNpcNames": ["灯童丁"] })]; @@ -2000,6 +1546,20 @@ mod tests { "潮湿木栈桥在青灰雾里延伸,近处有可站立的破旧甲板,远处旧船骨与灯塔剪影压低天空。" )) ); + assert_eq!( + blueprints[0].get("sceneTaskDescription"), + Some(&json!( + "首次进入雾港码头时,查明黑潮船骨与灯童丁目击证词的关联。" + )) + ); + assert_eq!(acts[0].get("oppositeNpcId"), Some(&json!("灯童丁"))); + assert_eq!(acts[0].get("primaryNpcId"), Some(&json!("灯童丁"))); + assert_eq!( + acts[0].get("eventDescription"), + Some(&json!( + "灯童丁在潮湿栈桥上拦住玩家,交出与旧船骨有关的第一句证词。" + )) + ); assert!( !acts[0] .get("backgroundPromptText") @@ -2014,8 +1574,7 @@ mod tests { let mut framework = json!({ "camp": { "name": "萧家祖宅", - "description": "玩家开局并成长的家族祖宅。", - "dangerLevel": "low" + "description": "玩家开局并成长的家族祖宅。" } }); normalize_framework_shape(&mut framework, "废柴少年的逆袭传奇"); @@ -2038,10 +1597,22 @@ mod tests { assert_eq!(opening_chapter.get("sceneId"), Some(&json!("camp-1"))); assert_eq!(opening_acts.len(), 3); - assert!(opening_acts.iter().all(|act| act - .get("backgroundPromptText") - .and_then(JsonValue::as_str) - .is_some_and(|value| !value.trim().is_empty()))); + assert!(opening_acts.iter().all(|act| { + act.get("backgroundPromptText") + .and_then(JsonValue::as_str) + .is_some_and(|value| !value.trim().is_empty()) + })); + assert!( + opening_chapter + .get("sceneTaskDescription") + .and_then(JsonValue::as_str) + .is_some_and(|value| !value.trim().is_empty()) + ); + assert!(opening_acts.iter().all(|act| { + act.get("eventDescription") + .and_then(JsonValue::as_str) + .is_some_and(|value| !value.trim().is_empty()) + })); assert_eq!(blueprints.len(), 2); } @@ -2056,6 +1627,11 @@ mod tests { ); assert_eq!(act.get("backgroundPromptText"), Some(&json!(""))); + assert!( + act.get("eventDescription") + .and_then(JsonValue::as_str) + .is_some_and(|value| value.contains("玩家进入雾港码头")) + ); } #[test] @@ -2127,7 +1703,7 @@ mod tests { request_capture.clone(), vec![ llm_response( - r#"{"name":"雾港归航","subtitle":"失灯旧案","summary":"守灯人与群岛议会围绕沉船旧案对峙。","tone":"海雾悬疑","playerGoal":"查清父亲沉船真相","templateWorldType":"WUXIA","majorFactions":["群岛议会","灯塔署"],"coreConflicts":["守灯塔的旧档案被人改写。"],"camp":{"name":"旧灯塔归舍","description":"海雾边缘的守灯人旧居。","dangerLevel":"low"}}"#, + r#"{"name":"雾港归航","subtitle":"失灯旧案","summary":"守灯人与群岛议会围绕沉船旧案对峙。","tone":"海雾悬疑","playerGoal":"查清父亲沉船真相","templateWorldType":"WUXIA","majorFactions":["群岛议会","灯塔署"],"coreConflicts":["守灯塔的旧档案被人改写。"],"camp":{"name":"旧灯塔归舍","description":"海雾边缘的守灯人旧居。"}}"#, ), llm_response( r#"{"playableNpcs":[{"name":"岑灯","title":"返乡守灯人","role":"主角代理","description":"追查旧案的人","initialAffinity":24,"relationshipHooks":["旧案牵连"],"tags":["守灯人"]}]}"#, @@ -2145,10 +1721,10 @@ mod tests { r#"{"storyNpcs":[{"name":"档吏庚","title":"旧档吏","role":"保管者","description":"藏起原始卷宗","initialAffinity":10,"relationshipHooks":["原始卷宗"],"tags":["档案"]},{"name":"潮女辛","title":"听潮女","role":"引路人","description":"听懂海雾低语","initialAffinity":35,"relationshipHooks":["海雾低语"],"tags":["引路"]}]}"#, ), llm_response( - r#"{"landmarks":[{"name":"旧灯塔","description":"雾中仍亮着错位灯火","dangerLevel":"medium"},{"name":"沉船湾","description":"退潮后露出旧船骨","dangerLevel":"high"}]}"#, + r#"{"landmarks":[{"name":"旧灯塔","description":"雾中仍亮着错位灯火"},{"name":"沉船湾","description":"退潮后露出旧船骨"}]}"#, ), llm_response( - r#"{"landmarks":[{"name":"旧灯塔","description":"雾中仍亮着错位灯火","dangerLevel":"medium","sceneNpcNames":["灯童丁","档吏庚"],"connectedLandmarkNames":["沉船湾"],"entryHook":"灯火按被篡改的航线闪烁。"},{"name":"沉船湾","description":"退潮后露出旧船骨","dangerLevel":"high","sceneNpcNames":["船魂戊","潮医乙"],"connectedLandmarkNames":["旧灯塔"],"entryHook":"旧船骨里传出父亲留下的暗号。"}]}"#, + r#"{"landmarks":[{"name":"旧灯塔","description":"雾中仍亮着错位灯火","sceneNpcNames":["灯童丁","档吏庚"],"connectedLandmarkNames":["沉船湾"],"entryHook":"灯火按被篡改的航线闪烁。"},{"name":"沉船湾","description":"退潮后露出旧船骨","sceneNpcNames":["船魂戊","潮医乙"],"connectedLandmarkNames":["旧灯塔"],"entryHook":"旧船骨里传出父亲留下的暗号。"}]}"#, ), llm_response( r#"{"playableNpcs":[{"name":"岑灯","backstory":"被停职的守灯人返乡后发现父亲沉船案被改写。","personality":"克制执拗","motivation":"查清父亲沉船真相","combatStyle":"借灯火与海图周旋"}]}"#, diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 9bf18ace..f258fbbc 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -36,12 +36,14 @@ mod logout_all; mod password_entry; mod password_management; mod phone_auth; +mod prompt; mod puzzle; mod puzzle_agent_turn; mod refresh_session; mod request_context; mod response_headers; mod runtime_browse_history; +mod runtime_chat; mod runtime_inventory; mod runtime_profile; mod runtime_save; diff --git a/server-rs/crates/api-server/src/prompt/character_animation.rs b/server-rs/crates/api-server/src/prompt/character_animation.rs new file mode 100644 index 00000000..84caf004 --- /dev/null +++ b/server-rs/crates/api-server/src/prompt/character_animation.rs @@ -0,0 +1,297 @@ +use crate::character_animation_assets::find_motion_template; +use shared_contracts::assets::CharacterAnimationStrategy; + +pub(crate) fn build_character_animation_prompt( + strategy: &CharacterAnimationStrategy, + prompt_text: &str, + character_brief_text: Option<&str>, + action_template_id: Option<&str>, + animation: &str, + frame_count: u32, + fps: u32, + duration_seconds: u32, + loop_: bool, + use_chroma_key: bool, +) -> String { + match strategy { + CharacterAnimationStrategy::ImageToVideo => build_ark_character_animation_prompt( + animation, + prompt_text, + character_brief_text, + action_template_id, + loop_, + use_chroma_key, + ), + CharacterAnimationStrategy::ImageSequence => { + build_image_sequence_prompt(animation, prompt_text, frame_count, use_chroma_key) + } + CharacterAnimationStrategy::MotionTransfer + | CharacterAnimationStrategy::ReferenceToVideo => build_npc_animation_prompt( + animation, + prompt_text, + character_brief_text, + action_template_id, + loop_, + use_chroma_key, + fps, + duration_seconds, + ), + } +} + +fn build_image_sequence_prompt( + animation: &str, + prompt_text: &str, + frame_count: u32, + use_chroma_key: bool, +) -> String { + [ + format!( + "同一角色连续 {} 帧动作序列,动作主题是 {}。", + frame_count, animation + ), + "固定机位,单人,全身,侧身朝右,保持同一套服装、发型、武器和体型。".to_string(), + "帧间动作连续,姿态逐步推进,不要换人,不要跳变,不要多余物体。".to_string(), + if use_chroma_key { + "纯绿色背景,无地面装饰,方便后期抠像。".to_string() + } else { + "背景尽量纯净,避免复杂场景。".to_string() + }, + prompt_text.trim().to_string(), + ] + .into_iter() + .filter(|value| !value.trim().is_empty()) + .collect::>() + .join(" ") +} + +fn build_npc_animation_prompt( + animation: &str, + prompt_text: &str, + character_brief_text: Option<&str>, + action_template_id: Option<&str>, + loop_: bool, + use_chroma_key: bool, + fps: u32, + duration_seconds: u32, +) -> String { + let character_brief = build_compact_animation_character_brief(character_brief_text); + let action_detail_text = sanitize_animation_prompt_text(prompt_text, 140); + let loop_rule = if loop_ { + "这是循环动作,直接进入动作循环中段,不要开场静止站桩,不要把主参考图原样作为第一帧。" + .to_string() + } else if animation == "die" { + "这是死亡终结动作,首帧参考主图角色形象即可,尾帧停在死亡结束姿态,不要回到主图形象。" + .to_string() + } else { + "这是非循环动作,首帧和尾帧都要回到参考主图角色形象,中段完成动作变化。".to_string() + }; + + if let Some(template) = action_template_id.and_then(|id| find_motion_template(id)) { + return [ + format!( + "单人 NPC 全身动作视频,动作主题是 {}。角色固定为同一人,右向斜侧身,镜头稳定,轮廓清晰,武器不可丢失。", + template.animation + ), + if use_chroma_key { + "背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。".to_string() + } else { + "背景简洁纯净,无复杂场景。".to_string() + }, + if character_brief.is_empty() { + String::new() + } else { + format!("角色设定:{}。", character_brief) + }, + format!("动作补充:{}。", template.prompt_suffix), + if action_detail_text.is_empty() { + String::new() + } else { + format!("动作细节:{}。", action_detail_text) + }, + format!("目标帧率 {} fps,时长约 {} 秒。", fps.clamp(1, 60), duration_seconds.clamp(1, 8)), + loop_rule, + ] + .into_iter() + .filter(|value| !value.trim().is_empty()) + .collect::>() + .join(" "); + } + + [ + format!("单人 NPC 全身动作视频,动作主题是 {}。", animation), + "角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。".to_string(), + "动作连贯,避免服装、发型、面部、武器随机漂移。".to_string(), + if use_chroma_key { + "背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。".to_string() + } else { + "背景简洁纯净,无复杂场景。".to_string() + }, + if character_brief.is_empty() { + String::new() + } else { + format!("角色设定:{}。", character_brief) + }, + if action_detail_text.is_empty() { + String::new() + } else { + action_detail_text + }, + format!( + "目标帧率 {} fps,时长约 {} 秒。", + fps.clamp(1, 60), + duration_seconds.clamp(1, 8) + ), + loop_rule, + ] + .into_iter() + .filter(|value| !value.trim().is_empty()) + .collect::>() + .join(" ") +} + +fn build_ark_character_animation_prompt( + animation: &str, + prompt_text: &str, + character_brief_text: Option<&str>, + action_template_id: Option<&str>, + loop_: bool, + use_chroma_key: bool, +) -> String { + let normalized_animation_name = animation.trim().replace(char::is_whitespace, "_"); + let normalized_animation_name = if normalized_animation_name.is_empty() { + "idle".to_string() + } else { + normalized_animation_name + }; + let character_brief = build_compact_animation_character_brief(character_brief_text); + let action_detail_text = sanitize_animation_prompt_text(prompt_text, 140); + if let Some(template) = action_template_id.and_then(find_motion_template) { + return build_video_action_prompt( + template.id, + template.prompt_suffix, + action_detail_text.as_str(), + Some(character_brief.as_str()), + use_chroma_key, + ); + } + + build_video_action_prompt( + normalized_animation_name.as_str(), + if loop_ { + "循环动作必须自然闭环,不要静止开场。" + } else { + "中段完成完整动作变化,收束干净。" + }, + action_detail_text.as_str(), + Some(character_brief.as_str()), + use_chroma_key, + ) +} + +/// 角色动作视频统一提示词骨架,按每个动作模板与补充描述生成。 +fn build_video_action_prompt( + action_id: &str, + action_sequence: &str, + action_detail_text: &str, + character_brief_text: Option<&str>, + use_chroma_key: bool, +) -> String { + [ + format!("单人全身角色动作视频,动作英文名是 {}。", action_id), + "角色固定为图1同一角色,保持右向斜侧身动作视角,镜头稳定,轮廓清晰,不要退化成完全 90 度纯右视图。".to_string(), + "视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。".to_string(), + "主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。".to_string(), + "画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。".to_string(), + "风格要求:横版像素动作角色体型,头身比优先控制在 1 到 1.5 头身,保留清楚的头、躯干、双臂和双腿轮廓。明确的像素动作角色设定稿气质,整体按像素游戏角色设计方向组织,使用深色清楚轮廓、稳定剪影、有限大色块和硬朗边缘,不要柔和厚涂插画感,发型、服装、配饰优先形成醒目可读的像素级识别点,适合横版动作 sprite 资产。高可读性游戏角色设定图,形体清晰,服装层次明确,便于后续连续动作生成。".to_string(), + format!("动作结构:{}。结尾要求:动作收束清楚,便于后续抽帧。", action_sequence), + if use_chroma_key { + "背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。".to_string() + } else { + "背景简洁纯净,无其他人物和复杂场景元素,方便后期抽帧。".to_string() + }, + format!( + "动作补充细节:{}", + if action_detail_text.trim().is_empty() { + "保持动作清晰、节奏明确、适合后续抽帧为 sprite sheet。" + } else { + action_detail_text.trim() + } + ), + character_brief_text + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| format!("角色设定:{}。", value)) + .unwrap_or_default(), + "目标是后续抽帧为横版动作游戏精灵表,因此不要镜头切换,不要景别变化,不要角色漂移。".to_string(), + ] + .into_iter() + .filter(|value| !value.trim().is_empty()) + .collect::>() + .join(" ") +} + +pub(crate) fn build_fallback_moderation_safe_animation_prompt( + animation: &str, + loop_: bool, + use_chroma_key: bool, +) -> String { + [ + format!("单人全身角色动作视频,动作主题是 {}。", animation), + "角色固定为同一人,右向斜侧身,镜头稳定,轮廓清楚。".to_string(), + if loop_ { + "循环动作直接进入稳定循环,不要静止开场,不要定格首帧。".to_string() + } else { + "非循环动作首尾回到角色标准站姿,中段完成动作变化。".to_string() + }, + if use_chroma_key { + "背景为纯绿色绿幕,无其他人物和场景元素。".to_string() + } else { + "背景简洁纯净。".to_string() + }, + ] + .join(" ") +} + +fn sanitize_animation_prompt_text(value: &str, max_length: usize) -> String { + value + .replace(char::is_whitespace, " ") + .replace("血浆", "") + .replace("喷血", "") + .replace("鲜血", "") + .replace("断肢", "") + .replace("斩首", "") + .replace("裸体", "") + .replace("裸露", "") + .replace("色情", "") + .replace("性交", "") + .replace("死亡", "倒地结束") + .replace("死去", "倒地结束") + .replace("击杀", "倒地结束") + .replace("受击", "失衡") + .replace("受伤", "失衡") + .replace("砍杀", "挥击") + .replace("斩击", "挥击") + .split_whitespace() + .collect::>() + .join(" ") + .chars() + .take(max_length) + .collect::() + .trim() + .to_string() +} + +fn build_compact_animation_character_brief(value: Option<&str>) -> String { + let normalized = sanitize_animation_prompt_text(value.unwrap_or_default(), 160); + if normalized.is_empty() { + return String::new(); + } + normalized + .split(['/', '|', '\n', ',', ',', '。', ';', ';']) + .map(str::trim) + .filter(|item| !item.is_empty()) + .take(4) + .collect::>() + .join(",") +} diff --git a/server-rs/crates/api-server/src/prompt/character_visual.rs b/server-rs/crates/api-server/src/prompt/character_visual.rs new file mode 100644 index 00000000..8652f9a3 --- /dev/null +++ b/server-rs/crates/api-server/src/prompt/character_visual.rs @@ -0,0 +1,67 @@ +/// 自定义世界角色主图提示词脚本。 +pub(crate) fn build_character_visual_prompt( + prompt_text: &str, + character_brief_text: Option<&str>, +) -> String { + let character_brief = [character_brief_text.unwrap_or_default(), prompt_text] + .into_iter() + .map(str::trim) + .filter(|value| !value.is_empty()) + .collect::>() + .join("\n"); + + build_master_prompt(character_brief.as_str()) +} + +/// 角色主图统一提示词骨架,迁移自旧共享 qwenSprite 主链。 +fn build_master_prompt(character_brief: &str) -> String { + [ + "单人,2D 横版游戏角色标准设定图,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。".to_string(), + "视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。".to_string(), + "主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。".to_string(), + "画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。".to_string(), + "风格要求:横版像素动作角色体型,头身比优先控制在 1 到 1.5 头身,保留清楚的头、躯干、双臂和双腿轮廓。明确的像素动作角色设定稿气质,整体按像素游戏角色设计方向组织,使用深色清楚轮廓、稳定剪影、有限大色块和硬朗边缘,不要柔和厚涂插画感,发型、服装、配饰优先形成醒目可读的像素级识别点,适合横版动作 sprite 资产。高可读性游戏角色设定图,形体清晰,服装层次明确,便于后续连续动作生成。".to_string(), + "请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,只有当文字设定明确要求非人结构时,才改为对应非人身体。".to_string(), + "主题词默认只作用在角色自身的服装剪裁、材质、纹样、饰品、发光细节上,不要把主题词自动扩写成背景建筑、自然场景、漂浮装饰或额外环境物件。".to_string(), + "视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。".to_string(), + character_brief.trim().to_string(), + ] + .into_iter() + .filter(|value| !value.trim().is_empty()) + .collect::>() + .join("\n") +} + +/// 自定义世界角色主图负面提示词脚本。 +pub(crate) fn build_character_visual_negative_prompt() -> String { + [ + "正面视角", + "左朝向", + "完全 90 度纯右视图", + "镜头透视", + "半身像", + "脚被裁切", + "头顶被裁切", + "多角色", + "复杂背景", + "建筑场景", + "漂浮物", + "烟雾环境", + "武器消失", + "武器换手", + "额外手臂", + "额外腿", + "服装变化", + "脸部变化", + "模糊", + "运动模糊", + "文字", + "水印", + "UI 元素", + "软萌 Q版大头贴", + "儿童绘本风", + "厚涂插画感", + "低对比柔边", + ] + .join(",") +} diff --git a/server-rs/crates/api-server/src/prompt/foundation_draft.rs b/server-rs/crates/api-server/src/prompt/foundation_draft.rs new file mode 100644 index 00000000..1372f3bc --- /dev/null +++ b/server-rs/crates/api-server/src/prompt/foundation_draft.rs @@ -0,0 +1,534 @@ +use serde_json::Value as JsonValue; + +const CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES: [i64; 4] = [15, 30, 60, 90]; + +pub(crate) fn build_custom_world_framework_prompt(setting_text: &str) -> String { + [ + "请先根据下面的玩家设定创建一份“世界核心骨架”,后续我会分步骤生成角色名单、场景名单和详细档案。".to_string(), + "你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(), + "这一步只保留世界顶层信息与一个开局归处场景,不要输出 playableNpcs、storyNpcs、landmarks,也不要展开人物和地图细节。".to_string(), + "玩家设定:".to_string(), + setting_text.trim().to_string(), + "".to_string(), + "输出 JSON 模板:".to_string(), + "{".to_string(), + " \"name\": \"世界名称\",".to_string(), + " \"subtitle\": \"世界副标题\",".to_string(), + " \"summary\": \"世界概述\",".to_string(), + " \"tone\": \"世界基调\",".to_string(), + " \"playerGoal\": \"玩家核心目标\",".to_string(), + " \"templateWorldType\": \"WUXIA|XIANXIA\",".to_string(), + " \"majorFactions\": [\"势力甲\", \"势力乙\"],".to_string(), + " \"coreConflicts\": [\"冲突甲\", \"冲突乙\"],".to_string(), + " \"camp\": {".to_string(), + " \"name\": \"开局归处名称\",".to_string(), + " \"description\": \"这是玩家进入世界后的第一处落脚点描述\",".to_string(), + " \"sceneTaskDescription\": \"首次进入该场景时要生成的章节任务核心上下文\",".to_string(), + " \"actBackgroundPromptTexts\": [\"开局第一幕背景画面描述\", \"开局第二幕背景画面描述\", \"开局第三幕背景画面描述\"],".to_string(), + " \"actEventDescriptions\": [\"开局第一幕事件描述\", \"开局第二幕事件描述\", \"开局第三幕事件描述\"],".to_string(), + " }".to_string(), + "}".to_string(), + "".to_string(), + "要求:".to_string(), + "- 所有生成文本都必须使用中文。".to_string(), + "- 这一步只输出顶层 9 个字段:name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。".to_string(), + "- 这是一个完全独立的自定义世界;不要在任何正文里直接写出“武侠世界”“仙侠世界”等现成世界名。".to_string(), + "- templateWorldType 只是系统兼容字段,不代表正文应当引用的世界名称。".to_string(), + "- camp 必须表示玩家开局时的落脚处,名字不要直接写成“某某营地”,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念。".to_string(), + "- camp.sceneTaskDescription 必须描述玩家首次进入开局场景时要完成的核心任务,会作为游戏章节任务生成上下文,控制在 24 到 56 个汉字内。".to_string(), + "- camp.actBackgroundPromptTexts 必须恰好 3 条,分别对应开局场景第 1/2/3 幕背景图画面内容描述;每条都必须可直接交给生图模型,控制在 40 到 90 个汉字内。".to_string(), + "- camp.actEventDescriptions 必须恰好 3 条,分别描述每一幕发生的事件;事件必须和当前幕对面的角色强相关,控制在 24 到 56 个汉字内。".to_string(), + "- 不要输出 playableNpcs、storyNpcs、landmarks、items,也不要输出任何角色和地图细节。".to_string(), + "- majorFactions 保持 2 到 3 个,coreConflicts 保持 2 到 3 个。".to_string(), + "- 世界设定必须直接源自玩家输入,不要脱离主题乱扩写。".to_string(), + "- 每个字符串尽量简洁:subtitle 控制在 8 到 18 个汉字内,summary 控制在 16 到 32 个汉字内,tone 控制在 6 到 16 个汉字内,playerGoal 控制在 16 到 32 个汉字内,camp.description 控制在 18 到 40 个汉字内。".to_string(), + "- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(), + ].join("\n") +} + +pub(crate) fn build_custom_world_framework_json_repair_prompt(response_text: &str) -> String { + [ + "下面这段文本本应是自定义世界核心骨架的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。", + "请只输出修复后的 JSON 对象。", + "顶层必须只包含:name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。", + "不要输出 playableNpcs、storyNpcs、landmarks、items 或任何其他字段。", + "majorFactions 与 coreConflicts 必须是字符串数组。", + "camp 必须是对象,且包含:name、description、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions。", + "原始文本:", + response_text.trim(), + ].join("\n") +} + +pub(crate) fn build_custom_world_role_outline_batch_prompt( + framework: &JsonValue, + role_type: &str, + batch_count: usize, + forbidden_names: &[String], +) -> String { + let key = role_key(role_type); + let label = if role_type == "playable" { + "可扮演角色" + } else { + "场景角色" + }; + [ + format!("请根据下面的世界核心信息,生成一批{label}框架名单。"), + "后续我会继续补全人物档案,所以这一步每个角色只保留身份骨架与资产默认描述字段。".to_string(), + "你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(), + "世界核心信息:".to_string(), + build_framework_summary_text(framework, 0), + if forbidden_names.is_empty() { "".to_string() } else { format!("这些名字已经生成,禁止重复:{}", forbidden_names.join("、")) }, + "".to_string(), + "输出 JSON 模板:".to_string(), + "{".to_string(), + format!(" \"{key}\": ["), + " {".to_string(), + " \"name\": \"角色名称\",".to_string(), + " \"title\": \"称号\",".to_string(), + " \"role\": \"身份\",".to_string(), + " \"description\": \"极简定位描述\",".to_string(), + " \"visualDescription\": \"默认角色形象描述\",".to_string(), + " \"actionDescription\": \"默认角色动作描述\",".to_string(), + " \"sceneVisualDescription\": \"默认出现场景描述\",".to_string(), + " \"initialAffinity\": 18,".to_string(), + " \"relationshipHooks\": [\"一个关系切入口\"],".to_string(), + " \"tags\": [\"标签1\", \"标签2\"]".to_string(), + " }".to_string(), + " ]".to_string(), + "}".to_string(), + "".to_string(), + "要求:".to_string(), + format!("- 必须生成恰好 {batch_count} 个{label}。"), + "- 这是一个完全独立的自定义世界;不要把角色写成来自“武侠世界”“仙侠世界”等现成世界。".to_string(), + "- 名称必须具体且互不重复,不要使用 角色1、NPC1、场景角色1 之类的占位名。".to_string(), + "- 只保留:name、title、role、description、visualDescription、actionDescription、sceneVisualDescription、initialAffinity、relationshipHooks、tags。".to_string(), + "- visualDescription 是打开角色形象图像生成面板时默认填入的角色形象描述,必须具体到体型、服装、轮廓与识别点,控制在 24 到 60 个汉字内。".to_string(), + "- actionDescription 是打开每个角色动作视频生成面板时默认填入的动作描述,必须体现该角色默认动作节奏、武器或施法方式,控制在 18 到 48 个汉字内。".to_string(), + "- sceneVisualDescription 是该角色常出现或关联的场景画面描述,会作为场景生图描述框的默认候选,控制在 24 到 60 个汉字内。".to_string(), + "- relationshipHooks 最多 1 条;tags 保持 1 到 2 个。".to_string(), + "- description 控制在 8 到 18 个汉字内,title 和 role 也尽量短。".to_string(), + "- initialAffinity 必须是 -40 到 90 的整数。".to_string(), + if role_type == "playable" { "- 可扮演角色的定位必须明显不同,通常使用 18 到 40 的初始好感。".to_string() } else { "- 场景角色要覆盖势力成员、居民、异类或怪物,不要全是同一种身份;敌对或怪物型角色可以使用负好感。".to_string() }, + "- 所有生成文本都必须使用中文。".to_string(), + "- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(), + ].into_iter().filter(|value| !value.is_empty()).collect::>().join("\n") +} + +pub(crate) fn build_custom_world_role_outline_batch_json_repair_prompt( + response_text: &str, + role_type: &str, + expected_count: usize, + forbidden_names: &[String], +) -> String { + let key = role_key(role_type); + [ + format!("下面这段文本本应是自定义世界{}框架名单批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。", if role_type == "playable" { "可扮演角色" } else { "场景角色" }), + "请只输出修复后的 JSON 对象。".to_string(), + format!("顶层必须只包含一个 {key} 数组。"), + format!("必须保留恰好 {expected_count} 个角色对象。"), + if forbidden_names.is_empty() { "".to_string() } else { format!("禁止使用这些重复名:{}。", forbidden_names.join("、")) }, + "每个角色只包含:name、title、role、description、visualDescription、actionDescription、sceneVisualDescription、initialAffinity、relationshipHooks、tags。".to_string(), + "如果缺少字段:字符串补空字符串,relationshipHooks 和 tags 补空数组,initialAffinity 补默认整数。".to_string(), + "不要输出 backstory、skills、landmarks 或任何其他字段。".to_string(), + "原始文本:".to_string(), + response_text.trim().to_string(), + ].into_iter().filter(|value| !value.is_empty()).collect::>().join("\n") +} +pub(crate) fn build_custom_world_landmark_seed_batch_prompt( + framework: &JsonValue, + batch_count: usize, + forbidden_names: &[String], +) -> String { + [ + "请根据下面的世界核心信息,生成一批关键场景框架名单。".to_string(), + "后续我会继续补全场景网络,所以这一步每个地点只保留场景骨架、地点默认生图描述和逐幕背景描述。".to_string(), + "你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(), + "世界核心信息:".to_string(), + build_framework_summary_text(framework, 0), + if forbidden_names.is_empty() { "".to_string() } else { format!("这些地点已经生成,禁止重复:{}", forbidden_names.join("、")) }, + "".to_string(), + "输出 JSON 模板:".to_string(), + "{".to_string(), + " \"landmarks\": [".to_string(), + " {".to_string(), + " \"name\": \"场景名称\",".to_string(), + " \"description\": \"场景极简描述\",".to_string(), + " \"visualDescription\": \"默认场景生图描述\",".to_string(), + " \"sceneTaskDescription\": \"首次进入该场景时要生成的章节任务核心上下文\",".to_string(), + " \"actBackgroundPromptTexts\": [\"第一幕背景画面描述\", \"第二幕背景画面描述\", \"第三幕背景画面描述\"],".to_string(), + " \"actEventDescriptions\": [\"第一幕事件描述\", \"第二幕事件描述\", \"第三幕事件描述\"],".to_string(), + " }".to_string(), + " ]".to_string(), + "}".to_string(), + "".to_string(), + "要求:".to_string(), + format!("- 必须生成恰好 {batch_count} 个关键场景。"), + "- 这是一个完全独立的自定义世界;地点名称必须直接服务玩家输入主题。".to_string(), + "- 名称必须具体且互不重复,不要使用 地点1、场景1 之类的占位名。".to_string(), + "- 每个地点只保留:name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions。".to_string(), + "- sceneTaskDescription 必须描述玩家首次进入该场景时要完成的核心任务,会作为游戏章节任务生成上下文,控制在 24 到 56 个汉字内。".to_string(), + "- visualDescription 是打开场景背景图像生成面板时默认填入的场景描述,必须具体到画面主体、远近景层次、地面可站立区域和氛围识别点,控制在 32 到 80 个汉字内。".to_string(), + "- actBackgroundPromptTexts 必须恰好 3 条,分别对应这个场景章节的第 1/2/3 幕背景图画面内容描述;每条都必须是大模型根据当前地点、主线阶段和可出场角色直接写出的画面描述,控制在 40 到 90 个汉字内。".to_string(), + "- actBackgroundPromptTexts 禁止使用“某某第1幕背景;玩家会在……”这类标题、摘要、规则句拼接格式;必须像可直接交给生图模型的自然画面描述。".to_string(), + "- actEventDescriptions 必须恰好 3 条,分别描述每一幕发生的事件;事件必须和当前幕对面的角色强相关,控制在 24 到 56 个汉字内。".to_string(), + "- description 控制在 12 到 24 个汉字内。".to_string(), + "- 所有生成文本都必须使用中文。".to_string(), + "- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(), + ].into_iter().filter(|value| !value.is_empty()).collect::>().join("\n") +} + +pub(crate) fn build_custom_world_landmark_seed_batch_json_repair_prompt( + response_text: &str, + expected_count: usize, + forbidden_names: &[String], +) -> String { + [ + "下面这段文本本应是自定义世界关键场景框架名单批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。".to_string(), + "请只输出修复后的 JSON 对象。".to_string(), + "顶层必须只包含一个 landmarks 数组。".to_string(), + format!("必须保留恰好 {expected_count} 个地点对象。"), + if forbidden_names.is_empty() { "".to_string() } else { format!("禁止使用这些重复名:{}。", forbidden_names.join("、")) }, + "每个地点只包含:name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions。".to_string(), + "如果缺少字段:字符串补空字符串,actBackgroundPromptTexts 和 actEventDescriptions 补空数组。".to_string(), + "不要输出 sceneNpcNames、connectedLandmarks、items 或任何其他字段。".to_string(), + "原始文本:".to_string(), + response_text.trim().to_string(), + ].into_iter().filter(|value| !value.is_empty()).collect::>().join("\n") +} + +pub(crate) fn build_custom_world_landmark_network_batch_prompt( + framework: &JsonValue, + story_npcs: &[JsonValue], + landmark_batch: &[JsonValue], +) -> String { + [ + "请补全下面这一批关键场景的探索网络信息。".to_string(), + "你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(), + "世界核心信息:".to_string(), + build_framework_summary_text(framework, 10), + "可用场景角色名单:".to_string(), + names_from_entries(story_npcs).join("、"), + "本批场景:".to_string(), + compact_json_text(&JsonValue::Array(landmark_batch.to_vec())), + "".to_string(), + "输出 JSON 模板:".to_string(), + "{".to_string(), + " \"landmarks\": [".to_string(), + " {".to_string(), + " \"name\": \"场景名称\",".to_string(), + " \"description\": \"场景描述\",".to_string(), + " \"sceneNpcNames\": [\"会在这里出现的角色名\"],".to_string(), + " \"connectedLandmarkNames\": [\"相邻或可通往的地点名\"],".to_string(), + " \"entryHook\": \"玩家进入这里时首先遇到的钩子\"".to_string(), + " }".to_string(), + " ]".to_string(), + "}".to_string(), + "".to_string(), + "要求:".to_string(), + "- 必须只补全本批场景,name 必须与本批场景完全一致,不得增删改名。".to_string(), + "- sceneNpcNames 只能引用上方可用场景角色名单中的名字,每个地点 1 到 3 个。".to_string(), + "- connectedLandmarkNames 优先引用已知关键场景名称,每个地点 1 到 3 个。".to_string(), + "- entryHook 控制在 16 到 36 个汉字内。".to_string(), + "- 所有生成文本都必须使用中文。".to_string(), + "- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(), + ].into_iter().filter(|value| !value.is_empty()).collect::>().join("\n") +} + +pub(crate) fn build_custom_world_landmark_network_batch_json_repair_prompt( + response_text: &str, + expected_names: &[String], +) -> String { + [ + "下面这段文本本应是自定义世界关键场景探索网络补全批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。".to_string(), + "请只输出修复后的 JSON 对象。".to_string(), + "顶层必须只包含一个 landmarks 数组。".to_string(), + format!("这个数组里只能保留这些地点名:{}。", expected_names.join("、")), + "名称必须与名单完全一致,不得增删改名;如果原文遗漏,可按名单顺序补齐占位对象。".to_string(), + "每个地点都必须包含:name、description、sceneNpcNames、connectedLandmarkNames、entryHook。".to_string(), + "如果缺少字段:字符串补空字符串,数组补空数组。".to_string(), + "不要新增名单外的地点。".to_string(), + "原始文本:".to_string(), + response_text.trim().to_string(), + ].join("\n") +} + +pub(crate) fn build_custom_world_role_batch_prompt( + framework: &JsonValue, + role_type: &str, + role_batch: &[JsonValue], + stage: &str, +) -> String { + let key = role_key(role_type); + let label = if role_type == "playable" { + "可扮演角色" + } else { + "场景角色" + }; + let stage_label = if stage == "narrative" { + "叙事档案" + } else { + "养成档案" + }; + let required_fields = if stage == "narrative" { + "name、backstory、personality、motivation、combatStyle" + } else { + "name、backstoryReveal、skills、initialItems" + }; + let template_extra = if stage == "narrative" { + [ + " \"backstory\": \"公开背景\",", + " \"personality\": \"性格关键词\",", + " \"motivation\": \"当前动机\",", + " \"combatStyle\": \"行动或战斗风格\"", + ] + .join("\n") + } else { + [ + " \"backstoryReveal\": { \"publicSummary\": \"公开摘要\", \"chapters\": [{ \"affinityRequired\": 15, \"title\": \"羁绊章节\", \"summary\": \"章节摘要\" }] },", + " \"skills\": [{ \"name\": \"技能名\", \"summary\": \"技能摘要\", \"style\": \"风格\" }],", + " \"initialItems\": [{ \"name\": \"物品名\", \"category\": \"道具\", \"quantity\": 1, \"rarity\": \"common\", \"description\": \"描述\", \"tags\": [\"标签\"] }]", + ].join("\n") + }; + [ + format!("请为下面这一批{label}补全{stage_label}。"), + "你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(), + "世界核心信息:".to_string(), + build_framework_summary_text(framework, 10), + "本批角色:".to_string(), + build_role_outline_prompt_text(role_batch, framework, role_type), + "".to_string(), + "输出 JSON 模板:".to_string(), + "{".to_string(), + format!(" \"{key}\": ["), + " {".to_string(), + " \"name\": \"角色名称\",".to_string(), + template_extra, + " }".to_string(), + " ]".to_string(), + "}".to_string(), + "".to_string(), + "要求:".to_string(), + "- 必须只补全本批角色,name 必须与本批角色完全一致,不得增删改名。".to_string(), + format!("- 每个角色必须包含:{required_fields}。"), + if stage == "narrative" { "- backstory 控制在 32 到 80 个汉字内;personality、motivation、combatStyle 都要短而具体。".to_string() } else { format!("- backstoryReveal 必须包含 publicSummary 和 4 个 chapters,chapters.affinityRequired 固定为 {}。", CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.iter().map(i64::to_string).collect::>().join("、")) }, + if stage == "narrative" { "- 不要输出 backstoryReveal、skills、initialItems。".to_string() } else { "- skills 默认 3 个;initialItems 默认 3 个;不要输出 backstory、personality、motivation、combatStyle。".to_string() }, + "- 所有生成文本都必须使用中文。".to_string(), + "- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(), + ].into_iter().filter(|value| !value.is_empty()).collect::>().join("\n") +} + +pub(crate) fn build_custom_world_role_batch_json_repair_prompt( + response_text: &str, + role_type: &str, + stage: &str, + expected_names: &[String], +) -> String { + let key = role_key(role_type); + if stage == "narrative" { + return [ + format!("下面这段文本本应是自定义世界{}叙事档案补全批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。", if role_type == "playable" { "可扮演角色" } else { "场景角色" }), + "请只输出修复后的 JSON 对象。".to_string(), + format!("顶层必须只包含一个 {key} 数组。"), + format!("这个数组里只能保留这些角色名:{}。", expected_names.join("、")), + "名称必须与名单完全一致,不得增删改名;如果原文遗漏,可按名单顺序补齐占位对象。".to_string(), + "每个角色都必须包含:name、backstory、personality、motivation、combatStyle。".to_string(), + "如果缺少字段:字符串补空字符串。".to_string(), + "不要输出 backstoryReveal、skills、initialItems,也不要新增名单外的角色。".to_string(), + "原始文本:".to_string(), + response_text.trim().to_string(), + ].join("\n"); + } + [ + format!("下面这段文本本应是自定义世界{}档案补全批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。", if role_type == "playable" { "可扮演角色" } else { "场景角色" }), + "请只输出修复后的 JSON 对象。".to_string(), + format!("顶层必须只包含一个 {key} 数组。"), + format!("这个数组里只能保留这些角色名:{}。", expected_names.join("、")), + "名称必须与名单完全一致,不得增删改名;如果原文遗漏,可按名单顺序补齐占位对象。".to_string(), + "每个角色都必须包含:name、backstoryReveal、skills、initialItems。".to_string(), + format!("backstoryReveal 必须包含 publicSummary 和 4 个 chapters,chapters.affinityRequired 固定为 {}。", CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.iter().map(i64::to_string).collect::>().join("、")), + "skills 默认补成 3 个对象,每个对象包含 name、summary、style;initialItems 默认补成 3 个对象,每个对象包含 name、category、quantity、rarity、description、tags。".to_string(), + "不要输出 backstory、personality、motivation、combatStyle、landmarks,也不要新增名单外的角色。".to_string(), + "原始文本:".to_string(), + response_text.trim().to_string(), + ].join("\n") +} + +fn build_framework_summary_text(framework: &JsonValue, max_landmarks: usize) -> String { + let landmark_text = array_field(framework, "landmarks") + .into_iter() + .take(max_landmarks) + .map(|landmark| { + format!( + "{}({})", + json_text(&landmark, "name").unwrap_or_default(), + json_text(&landmark, "description").unwrap_or_default() + ) + }) + .filter(|value| !value.trim().is_empty()) + .collect::>() + .join("、"); + [ + format!("世界:{}", json_text(framework, "name").unwrap_or_default()), + format!( + "副标题:{}", + json_text(framework, "subtitle").unwrap_or_default() + ), + format!( + "世界概述:{}", + json_text(framework, "summary").unwrap_or_default() + ), + format!( + "世界基调:{}", + json_text(framework, "tone").unwrap_or_default() + ), + format!( + "玩家核心目标:{}", + json_text(framework, "playerGoal").unwrap_or_default() + ), + json_string_array(framework, "majorFactions") + .map(|items| format!("主要势力:{}", items.join("、"))) + .unwrap_or_default(), + json_string_array(framework, "coreConflicts") + .map(|items| format!("核心冲突:{}", items.join("、"))) + .unwrap_or_default(), + format!( + "开局归处:{}({})", + json_path_text(framework, &["camp", "name"]).unwrap_or_default(), + json_path_text(framework, &["camp", "description"]).unwrap_or_default() + ), + if landmark_text.is_empty() { + String::new() + } else { + format!("关键场景:{landmark_text}") + }, + ] + .into_iter() + .filter(|value| !value.is_empty()) + .collect::>() + .join("\n") +} + +fn build_role_outline_prompt_text( + role_batch: &[JsonValue], + framework: &JsonValue, + role_type: &str, +) -> String { + role_batch + .iter() + .map(|role| { + let appearance_text = if role_type == "story" { + landmark_names_for_role( + framework, + json_text(role, "name").unwrap_or_default().as_str(), + ) + .join("、") + } else { + String::new() + }; + [ + format!( + "- {} / {}", + json_text(role, "name").unwrap_or_default(), + json_text(role, "title").unwrap_or_default() + ), + format!("身份:{}", json_text(role, "role").unwrap_or_default()), + format!( + "框架描述:{}", + json_text(role, "description").unwrap_or_default() + ), + format!( + "预设好感:{}", + role.get("initialAffinity") + .and_then(JsonValue::as_i64) + .unwrap_or(0) + ), + json_string_array(role, "relationshipHooks") + .map(|items| format!("关系切入口:{}", items.join("、"))) + .unwrap_or_default(), + json_string_array(role, "tags") + .map(|items| format!("标签:{}", items.join("、"))) + .unwrap_or_default(), + if appearance_text.is_empty() { + String::new() + } else { + format!("出现场景:{appearance_text}") + }, + ] + .into_iter() + .filter(|value| !value.is_empty()) + .collect::>() + .join("\n") + }) + .collect::>() + .join("\n") +} + +fn landmark_names_for_role(framework: &JsonValue, role_name: &str) -> Vec { + array_field(framework, "landmarks") + .into_iter() + .filter_map(|landmark| { + let names = json_string_array(&landmark, "sceneNpcNames").unwrap_or_default(); + if names.iter().any(|name| name == role_name) { + json_text(&landmark, "name") + } else { + None + } + }) + .collect() +} + +fn role_key(role_type: &str) -> &'static str { + if role_type == "playable" { + "playableNpcs" + } else { + "storyNpcs" + } +} + +fn array_field(value: &JsonValue, key: &str) -> Vec { + value + .get(key) + .and_then(JsonValue::as_array) + .cloned() + .unwrap_or_default() +} + +fn names_from_entries(entries: &[JsonValue]) -> Vec { + entries + .iter() + .filter_map(|entry| json_text(entry, "name")) + .filter(|value| !value.is_empty()) + .collect() +} + +fn json_text(value: &JsonValue, key: &str) -> Option { + json_path_text(value, &[key]) +} + +fn json_path_text(value: &JsonValue, path: &[&str]) -> Option { + let mut current = value; + for segment in path { + current = current.get(*segment)?; + } + current + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn json_string_array(value: &JsonValue, key: &str) -> Option> { + let items = value + .get(key)? + .as_array()? + .iter() + .filter_map(|entry| entry.as_str().map(str::trim)) + .filter(|entry| !entry.is_empty()) + .map(ToOwned::to_owned) + .collect::>(); + if items.is_empty() { None } else { Some(items) } +} + +fn compact_json_text(value: &JsonValue) -> String { + serde_json::to_string(value).unwrap_or_else(|_| "null".to_string()) +} diff --git a/server-rs/crates/api-server/src/prompt/mod.rs b/server-rs/crates/api-server/src/prompt/mod.rs new file mode 100644 index 00000000..ad972e90 --- /dev/null +++ b/server-rs/crates/api-server/src/prompt/mod.rs @@ -0,0 +1,4 @@ +pub(crate) mod character_animation; +pub(crate) mod character_visual; +pub(crate) mod foundation_draft; +pub(crate) mod scene_background; diff --git a/server-rs/crates/api-server/src/prompt/scene_background.rs b/server-rs/crates/api-server/src/prompt/scene_background.rs new file mode 100644 index 00000000..a56024d5 --- /dev/null +++ b/server-rs/crates/api-server/src/prompt/scene_background.rs @@ -0,0 +1,167 @@ +#[derive(Clone, Debug, Default)] +pub(crate) struct SceneImagePromptProfile<'a> { + pub name: &'a str, + pub subtitle: &'a str, + pub tone: &'a str, + pub player_goal: &'a str, + pub summary: &'a str, + pub setting_text: &'a str, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct SceneImagePromptLandmark<'a> { + pub name: &'a str, + pub description: &'a str, +} + +#[derive(Clone, Debug)] +pub(crate) struct SceneImagePromptParams<'a> { + pub profile: SceneImagePromptProfile<'a>, + pub landmark: SceneImagePromptLandmark<'a>, + pub user_prompt: &'a str, + pub has_reference_image: bool, + pub fallback_landmark_name: Option<&'a str>, + pub fallback_world_name: &'a str, +} + +#[derive(Clone, Debug)] +pub(crate) struct SceneActBackgroundPromptParams<'a> { + pub world_name: &'a str, + pub world_tone: &'a str, + pub scene_name: &'a str, + pub title: &'a str, + pub summary: &'a str, + pub act_goal: &'a str, + pub transition_hook: &'a str, + pub primary_role_name: &'a str, + pub support_role_names: Vec, + pub prompt_text: &'a str, +} + +pub(crate) const DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT: &str = "文字,水印,logo,UI界面,对话框,边框,人物近景特写,多人合照,模糊,低清晰度,畸形建筑,现代车辆,监控摄像头"; + +pub(crate) fn build_custom_world_scene_image_prompt(params: SceneImagePromptParams<'_>) -> String { + let world_name = clamp_scene_image_text( + if params.profile.name.trim().is_empty() { + params.fallback_world_name + } else { + params.profile.name + }, + 18, + ); + let world_subtitle = clamp_scene_image_text(params.profile.subtitle, 18); + let world_tone = clamp_scene_image_text(params.profile.tone, 48); + let world_goal = clamp_scene_image_text(params.profile.player_goal, 48); + let world_summary = clamp_scene_image_text(params.profile.summary, 72); + let world_setting = clamp_scene_image_text(params.profile.setting_text, 72); + let landmark_name = clamp_scene_image_text( + if params.landmark.name.trim().is_empty() { + params.fallback_landmark_name.unwrap_or("未命名场景") + } else { + params.landmark.name + }, + 18, + ); + let landmark_description = clamp_scene_image_text(params.landmark.description, 96); + let requested_visual = clamp_scene_image_text(params.user_prompt, 120); + + vec![ + "为横版 16:9 2D RPG 生成高完成度像素风场景背景,适合作为剧情探索与战斗底图。".to_string(), + "画面构图必须严格按上下 1:1 分区:上半部分严格控制在整张图的 1/2 高度内,只描绘场景远景与中远景轮廓,不要让背景内容向下侵占超过半屏。".to_string(), + "下半部分严格占据整张图的 1/2 高度,用于玩家角色站位与展示,必须是模拟 3D 游戏视角的地面近景,有明确的透视延伸和近大远小关系,不是平铺的 2D 侧视地面。".to_string(), + "下半部分的内容必须是明确可站立的地面本体,例如道路、石板、平台、广场、甲板、沙地或草地,要有连续、稳定、可落脚的站位逻辑,不能只是装饰性前景、坑洞、障碍堆、栏杆带或不可通行的景物。".to_string(), + "下半部分地面近景要保持相对简洁、低细节、轮廓清楚、便于角色站立,不要堆满道具、植被、碎石、栏杆或复杂装饰。".to_string(), + if params.has_reference_image { + "已提供一张自定义参考图,请沿用其构图、镜头或氛围线索,同时继续满足本次场景需求。".to_string() + } else { + String::new() + }, + format!( + "世界:{}{}。", + if world_name.is_empty() { + "未命名世界" + } else { + world_name.as_str() + }, + if world_subtitle.is_empty() { + String::new() + } else { + format!(",{world_subtitle}") + } + ), + conditional_prompt_line("玩家设定", world_setting.as_str()), + conditional_prompt_line("世界概述", world_summary.as_str()), + conditional_prompt_line("整体基调", world_tone.as_str()), + conditional_prompt_line("玩家目标关联", world_goal.as_str()), + format!( + "场景名称:{}。", + if landmark_name.is_empty() { + "未命名场景" + } else { + landmark_name.as_str() + } + ), + conditional_prompt_line("场景描述", landmark_description.as_str()), + conditional_prompt_line("本次想要生成的画面内容", requested_visual.as_str()), + "不要出现 UI、字幕、文字、水印、logo 或装饰边框,人物仅可作为很小的远景剪影,画面重点放在场景本身,不要遮挡下半部分的角色展示区域。".to_string(), + ] + .into_iter() + .filter(|line| !line.is_empty()) + .collect::>() + .join("") +} + +pub(crate) fn build_scene_act_background_image_prompt( + params: SceneActBackgroundPromptParams<'_>, +) -> String { + // 幕背景图不是普通地点图,必须把世界、幕目标、过渡钩子和角色关系一起写入图像提示词, + // 同时明确禁止角色立绘和 UI 元素进入背景资产。 + [ + format!("这是世界《{}》中的场景幕背景图。", params.world_name), + format!("场景:{}", params.scene_name), + format!("幕标题:{}", params.title), + format!("幕摘要:{}", params.summary), + format!("幕目标:{}", params.act_goal), + format!("过渡钩子:{}", params.transition_hook), + format!( + "主角色:{}", + if params.primary_role_name.trim().is_empty() { + "待补主角色" + } else { + params.primary_role_name.trim() + } + ), + if params.support_role_names.is_empty() { + String::new() + } else { + format!("辅助角色:{}", params.support_role_names.join("、")) + }, + format!("世界气质:{}", params.world_tone), + format!("背景描述:{}", params.prompt_text), + "要求:只生成环境背景,不出现角色立绘、站位 UI、对白框、按钮或文字。".to_string(), + ] + .into_iter() + .filter(|line| !line.trim().is_empty()) + .collect::>() + .join("\n") +} + +fn clamp_scene_image_text(value: &str, max_length: usize) -> String { + value + .trim() + .replace(char::is_whitespace, " ") + .chars() + .take(max_length) + .collect::() + .trim() + .to_string() +} + +fn conditional_prompt_line(prefix: &str, value: &str) -> String { + if value.is_empty() { + String::new() + } else { + format!("{prefix}:{value}。") + } +} + diff --git a/server-rs/crates/api-server/src/runtime_chat.rs b/server-rs/crates/api-server/src/runtime_chat.rs new file mode 100644 index 00000000..6f5ef330 --- /dev/null +++ b/server-rs/crates/api-server/src/runtime_chat.rs @@ -0,0 +1,145 @@ +use axum::{ + Json, + extract::Extension, + http::{StatusCode, header}, + response::{IntoResponse, Response}, +}; +use serde::Deserialize; +use serde_json::{Value, json}; + +use crate::{http_error::AppError, request_context::RequestContext}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NpcChatTurnRequest { + encounter: Value, + player_message: String, + #[serde(default)] + npc_initiates_conversation: bool, + #[serde(default)] + chat_directive: Option, +} + +pub async fn stream_runtime_npc_chat_turn( + Extension(request_context): Extension, + Json(payload): Json, +) -> Result { + let npc_name = read_string_field(&payload.encounter, "npcName") + .or_else(|| read_string_field(&payload.encounter, "name")) + .unwrap_or_else(|| "对方".to_string()); + let player_message = payload.player_message.trim(); + if player_message.is_empty() { + return Err(runtime_chat_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "runtime-chat", + "message": "playerMessage 不能为空", + })), + )); + } + + let npc_reply = build_deterministic_npc_reply( + npc_name.as_str(), + player_message, + payload.npc_initiates_conversation, + ); + let suggestions = build_deterministic_chat_suggestions(npc_name.as_str(), player_message); + let complete_payload = json!({ + "npcReply": npc_reply, + "affinityDelta": 0, + "affinityText": "关系暂未变化", + "suggestions": suggestions, + "pendingQuestOffer": null, + "chatDirective": build_completion_directive(payload.chat_directive.as_ref()), + }); + + let mut body = String::new(); + append_sse_event(&request_context, &mut body, "reply_delta", &json!({ "text": npc_reply }))?; + append_sse_event(&request_context, &mut body, "complete", &complete_payload)?; + Ok(build_event_stream_response(body)) +} + +fn build_deterministic_npc_reply( + npc_name: &str, + player_message: &str, + npc_initiates_conversation: bool, +) -> String { + // Rust API 尚未迁入旧 Node 的完整 LLM NPC 聊天编排前,先由后端提供稳定兜底,保证相遇与选项聊天链路不断。 + if npc_initiates_conversation { + return format!("{npc_name}看向你,先开口说道:“你来了。先别急着走,我正有话想和你说。”"); + } + format!("{npc_name}听完你的话,回应道:“{player_message}。我明白你的意思,我们继续说。”") +} + +fn build_deterministic_chat_suggestions(npc_name: &str, player_message: &str) -> Vec { + // 建议只承载玩家可点选的行动意图,不在 UI 里额外塞说明文案。 + vec![ + format!("继续询问{npc_name}的近况"), + "追问这里发生了什么".to_string(), + if player_message.contains('帮') || player_message.contains('忙') { + "请对方说清需要什么帮助".to_string() + } else { + "换个轻松的话题".to_string() + }, + ] +} + +fn build_completion_directive(chat_directive: Option<&Value>) -> Value { + let Some(directive) = chat_directive else { + return Value::Null; + }; + json!({ + "turnLimit": directive.get("turnLimit").cloned().unwrap_or(Value::Null), + "remainingTurns": directive.get("remainingTurns").cloned().unwrap_or(Value::Null), + "forceExit": directive.get("forceExitAfterTurn").and_then(Value::as_bool).unwrap_or(false), + "closingMode": directive.get("closingMode").cloned().unwrap_or(Value::Null), + }) +} + +fn read_string_field(value: &Value, field: &str) -> Option { + value + .get(field) + .and_then(Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(ToOwned::to_owned) +} + +fn append_sse_event( + request_context: &RequestContext, + body: &mut String, + event: &str, + payload: &Value, +) -> Result<(), Response> { + let payload_text = serde_json::to_string(payload).map_err(|error| { + runtime_chat_error_response( + request_context, + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "runtime-chat", + "message": format!("SSE payload 序列化失败:{error}"), + })), + ) + })?; + body.push_str("event: "); + body.push_str(event); + body.push('\n'); + body.push_str("data: "); + body.push_str(&payload_text); + body.push_str("\n\n"); + Ok(()) +} + +fn build_event_stream_response(body: String) -> Response { + ( + [ + (header::CONTENT_TYPE, "text/event-stream; charset=utf-8"), + (header::CACHE_CONTROL, "no-cache"), + ], + body, + ) + .into_response() +} + +fn runtime_chat_error_response(request_context: &RequestContext, error: AppError) -> Response { + error.into_response_with_context(Some(request_context)) +} diff --git a/server-rs/crates/spacetime-module/src/custom_world/mod.rs b/server-rs/crates/spacetime-module/src/custom_world/mod.rs index 78c648bf..d9e79293 100644 --- a/server-rs/crates/spacetime-module/src/custom_world/mod.rs +++ b/server-rs/crates/spacetime-module/src/custom_world/mod.rs @@ -3298,7 +3298,7 @@ fn upsert_generated_entity_card( .unwrap_or_else(|| "新角色".to_string()) } RpgAgentDraftCardKind::Landmark => { - read_optional_text_field(entity_object, &["purpose", "mood", "dangerLevel"]) + read_optional_text_field(entity_object, &["purpose", "mood"]) .unwrap_or_else(|| "新地点".to_string()) } _ => "新增对象".to_string(), @@ -3820,7 +3820,7 @@ fn upsert_asset_role_card(ctx: &ReducerContext, session_id: &str, role_id: &str, fn upsert_asset_scene_card(ctx: &ReducerContext, session_id: &str, scene_id: &str, scene_kind: &str, scene: &JsonMap, updated_at_micros: i64) -> Result<(), String> { let kind = if scene_kind == "camp" { RpgAgentDraftCardKind::Camp } else { RpgAgentDraftCardKind::Landmark }; let title = read_optional_text_field(scene, &["name"]).unwrap_or_else(|| if scene_kind == "camp" { "开局营地" } else { "场景" }.to_string()); - let subtitle = read_optional_text_field(scene, &["purpose", "mood", "dangerLevel"]).unwrap_or_else(|| "场景资产已就绪".to_string()); + let subtitle = read_optional_text_field(scene, &["purpose", "mood"]).unwrap_or_else(|| "场景资产已就绪".to_string()); let summary = read_optional_text_field(scene, &["summary", "description", "publicMask"]).unwrap_or_else(|| "场景图已写回草稿。".to_string()); upsert_asset_card(ctx, session_id, scene_id, kind, &title, &subtitle, &summary, None, Some("场景图已就绪"), updated_at_micros) } diff --git a/src/components/CustomWorldEntityCatalog.tsx b/src/components/CustomWorldEntityCatalog.tsx index 4ef3d278..05cb09b3 100644 --- a/src/components/CustomWorldEntityCatalog.tsx +++ b/src/components/CustomWorldEntityCatalog.tsx @@ -784,7 +784,6 @@ function buildOpeningSceneSearchText( return [ campScene.name, campScene.description, - campScene.dangerLevel, profile.playerGoal, profile.summary, '开局场景', @@ -920,7 +919,6 @@ function buildLandmarkSearchText( return [ landmark.name, landmark.description, - landmark.dangerLevel, ...landmark.sceneNpcIds.map((npcId) => storyNpcById.get(npcId)?.name ?? ''), ...landmark.connections.flatMap((connection) => [ landmarkById.get(connection.targetLandmarkId)?.name ?? '', diff --git a/src/components/CustomWorldEntityEditorModal.test.tsx b/src/components/CustomWorldEntityEditorModal.test.tsx index 767a34e2..3f58dcd8 100644 --- a/src/components/CustomWorldEntityEditorModal.test.tsx +++ b/src/components/CustomWorldEntityEditorModal.test.tsx @@ -190,7 +190,6 @@ function createProfile(): CustomWorldProfile { camp: { name: '潮灯居', description: '玩家最初落脚的旧灯塔内院。', - dangerLevel: 'medium', }, landmarks: [], creatorIntent: null, @@ -215,7 +214,6 @@ function createProfileWithLandmark(): CustomWorldProfile { id: 'landmark-1', name: '沉钟栈桥', description: '旧钟与潮声常年相撞的码头栈桥。', - dangerLevel: 'medium', imageSrc: '/generated-custom-world-scenes/original-scene.png', sceneNpcIds: ['story-1', 'story-2', 'story-3'], connections: [], @@ -277,7 +275,6 @@ function CampEditorFlowHarness() { id: 'custom-scene-camp', name: '潮灯居', description: '玩家最初落脚的旧灯塔内院。', - dangerLevel: 'medium', imageSrc: '/generated-custom-world-scenes/original-camp.png', sceneNpcIds: ['story-1', 'story-2', 'story-3'], connections: [ diff --git a/src/components/CustomWorldResultView.test.tsx b/src/components/CustomWorldResultView.test.tsx index 2a6d75ca..6273d73b 100644 --- a/src/components/CustomWorldResultView.test.tsx +++ b/src/components/CustomWorldResultView.test.tsx @@ -182,7 +182,6 @@ const baseProfile = { camp: { name: '潮灯居', description: '玩家最初落脚的旧灯塔内院。', - dangerLevel: 'medium', }, anchorContent: { worldPromise: { @@ -233,7 +232,6 @@ const baseProfile = { id: 'landmark-1', name: '沉钟栈桥', description: '旧钟与潮声常年相撞的码头栈桥。', - dangerLevel: 'medium', sceneNpcIds: ['story-1'], connections: [], }, diff --git a/src/components/game-canvas/GameCanvasEntityLayer.tsx b/src/components/game-canvas/GameCanvasEntityLayer.tsx index 7f7ec032..28b203e5 100644 --- a/src/components/game-canvas/GameCanvasEntityLayer.tsx +++ b/src/components/game-canvas/GameCanvasEntityLayer.tsx @@ -352,9 +352,7 @@ export function GameCanvasEntityLayer({ const isCampCompanionEncounter = encounter.specialBehavior === 'initial_companion' || encounter.specialBehavior === 'camp_companion'; - const peacefulAnchorX = isCampCompanionEncounter - ? RESOLVED_ENTITY_X_METERS - : encounter.xMeters ?? monsterAnchorMeters; + const peacefulAnchorX = RESOLVED_ENTITY_X_METERS; const isPeacefulEncounterMoving = (!isCampCompanionEncounter && sceneTransitionPhase !== 'idle') || Math.abs(peacefulAnchorX - RESOLVED_ENTITY_X_METERS) > 0.01; @@ -373,10 +371,7 @@ export function GameCanvasEntityLayer({ const peacefulBottomOffsetPx = peacefulResolvedCharacter ? getCharacterBottomOffsetPx(stageLiftPx, peacefulResolvedCharacter) : stageLiftPx + peacefulHostileBottomOffsetPx; - const peacefulNpcSpriteFacing = - encounter.kind === 'treasure' || peacefulResolvedCharacter - ? towardPeacefulPlayer - : getRenderableNpcFacing(encounter, towardPeacefulPlayer, {medievalVisual: true}); + const peacefulNpcSpriteFacing = towardPeacefulPlayer; return (
npc.id), connections: previousLandmark diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index ed770019..782373f2 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -434,7 +434,6 @@ const compiledAgentDraftSession: CustomWorldAgentSessionSnapshot = { id: 'landmark-1', name: '回潮旧灯塔', description: '旧灯塔是整片群岛最先看见异动的地方。', - dangerLevel: 'high', sceneNpcIds: ['story-1'], connections: [], }, diff --git a/src/data/characterPresets.customWorld.test.ts b/src/data/characterPresets.customWorld.test.ts index 4dc9dc29..181a5c0e 100644 --- a/src/data/characterPresets.customWorld.test.ts +++ b/src/data/characterPresets.customWorld.test.ts @@ -185,7 +185,6 @@ describe('characterPresets custom world runtime characters', () => { { name: '夜港旧栈', description: '潮雾和旧木桥把视线切成断续几段。', - dangerLevel: 'medium', sceneNpcNames: ['沈雾', '陆沉', '顾潮'], connections: [ { @@ -198,7 +197,6 @@ describe('characterPresets custom world runtime characters', () => { { name: '断桥外沿', description: '旧桥断口还挂着潮湿残旗。', - dangerLevel: 'high', sceneNpcNames: ['沈雾', '陆沉', '顾潮'], connections: [ { diff --git a/src/data/customWorldLibrary.ts b/src/data/customWorldLibrary.ts index 9a0ad3fb..76a570db 100644 --- a/src/data/customWorldLibrary.ts +++ b/src/data/customWorldLibrary.ts @@ -858,7 +858,6 @@ function normalizeLandmark( id: toText(value.id, `saved-landmark-${index + 1}`), name, description: toText(value.description), - dangerLevel: toText(value.dangerLevel), imageSrc: toText(value.imageSrc) || undefined, narrativeResidues: preserveStructuredRecordArray( @@ -891,7 +890,6 @@ function normalizeCampScene( name: toText(value.name, fallback.name), description: toText(value.description, fallback.description), visualDescription: toText(value.visualDescription) || undefined, - dangerLevel: toText(value.dangerLevel, fallback.dangerLevel), imageSrc: toText(value.imageSrc) || undefined, sceneNpcIds: toStringArray(value.sceneNpcIds), connections: toRecordArray(value.connections) @@ -978,6 +976,8 @@ function normalizeSceneActBlueprint( const advanceRule = toText(value.advanceRule); const title = toText(value.title); const summary = toText(value.summary); + const primaryNpcId = toText(value.primaryNpcId, encounterNpcIds[0] ?? ''); + const oppositeNpcId = toText(value.oppositeNpcId, primaryNpcId); if (!title && !summary && encounterNpcIds.length === 0) { return null; @@ -997,7 +997,14 @@ function normalizeSceneActBlueprint( backgroundImageSrc: toText(value.backgroundImageSrc) || undefined, backgroundAssetId: toText(value.backgroundAssetId) || undefined, encounterNpcIds, - primaryNpcId: toText(value.primaryNpcId, encounterNpcIds[0] ?? ''), + primaryNpcId, + oppositeNpcId, + eventDescription: toText( + value.eventDescription, + oppositeNpcId + ? `第 ${index + 1} 幕中,玩家与${oppositeNpcId}正面接触,推动当前场景事件升级。` + : `第 ${index + 1} 幕中,玩家处理当前场景的关键事件。`, + ), linkedThreadIds: toStringArray(value.linkedThreadIds), advanceRule: SCENE_ACT_ADVANCE_RULES.has(advanceRule as never) ? (advanceRule as SceneActBlueprint['advanceRule']) @@ -1033,6 +1040,10 @@ function normalizeSceneChapterBlueprints(value: unknown) { sceneId, title: toText(entry.title, toText(entry.sceneName, sceneId)), summary: toText(entry.summary), + sceneTaskDescription: toText( + entry.sceneTaskDescription, + `首次进入${toText(entry.title, toText(entry.sceneName, sceneId))}时,确认当前场景核心任务与关键角色。`, + ), linkedThreadIds: toStringArray(entry.linkedThreadIds), linkedLandmarkIds: toStringArray(entry.linkedLandmarkIds), acts, diff --git a/src/data/customWorldSceneGraph.ts b/src/data/customWorldSceneGraph.ts index 0eab1cb2..739d366e 100644 --- a/src/data/customWorldSceneGraph.ts +++ b/src/data/customWorldSceneGraph.ts @@ -384,7 +384,6 @@ export function normalizeCustomWorldLandmarks(params: { name: landmark.name, description: landmark.description, visualDescription: landmark.visualDescription, - dangerLevel: landmark.dangerLevel, imageSrc: landmark.imageSrc, narrativeResidues: landmark.narrativeResidues, sceneNpcIds: resolveSceneNpcIdsForLandmark( diff --git a/src/data/customWorldVisuals.ts b/src/data/customWorldVisuals.ts index 4bd0c859..d81c304c 100644 --- a/src/data/customWorldVisuals.ts +++ b/src/data/customWorldVisuals.ts @@ -204,7 +204,7 @@ type CustomWorldSceneImageMatchOptions = { | 'camp' | 'ownedSettingLayers' > | null; - landmark?: Pick | null; + landmark?: Pick | null; usedImageSrcs?: Iterable; }; @@ -328,7 +328,6 @@ function buildSourceText( themeHints, landmark?.name, landmark?.description, - landmark?.dangerLevel, `scene-${index + 1}`, seedKey, ]).join(' '); @@ -492,7 +491,7 @@ export function resolveCustomWorldLandmarkImage( | 'compatibilityTemplateWorldType' | 'ownedSettingLayers' >, - landmark: Pick, + landmark: Pick, index: number, usedImageSrcs?: Iterable, ) { @@ -586,7 +585,6 @@ export function resolveCustomWorldCampSceneImage( id: 'custom-scene-camp', name: campScene.name, description: campScene.description, - dangerLevel: campScene.dangerLevel, }, usedImageSrcs, }, diff --git a/src/data/sceneBackgrounds.test.ts b/src/data/sceneBackgrounds.test.ts index 3117176b..240c475c 100644 --- a/src/data/sceneBackgrounds.test.ts +++ b/src/data/sceneBackgrounds.test.ts @@ -70,7 +70,6 @@ describe('scene background assets', () => { id: 'landmark-1', name: '残城旧营', description: '断墙、军帐与营火灰烬混在一起,像一处被遗弃的边关驻地。', - dangerLevel: 'high', imageSrc: generatedImage, sceneNpcIds: [], connections: [], @@ -79,7 +78,6 @@ describe('scene background assets', () => { id: 'landmark-2', name: '雾锁渡桥', description: '古桥横跨冷河,雾气压在水面上,只有残灯还在摇晃。', - dangerLevel: 'medium', sceneNpcIds: [], connections: [], }, @@ -87,7 +85,6 @@ describe('scene background assets', () => { id: 'landmark-3', name: '地宫裂隙', description: '墓道向下坍塌,石阶与机关残痕一路通往地底深处。', - dangerLevel: 'extreme', sceneNpcIds: [], connections: [], }, diff --git a/src/data/scenePresets.test.ts b/src/data/scenePresets.test.ts index 60d01528..6133ed63 100644 --- a/src/data/scenePresets.test.ts +++ b/src/data/scenePresets.test.ts @@ -140,7 +140,6 @@ describe('scenePresets custom world npc mapping', () => { { name: '雾潮码头', description: '旧船桩和潮雾把视线切成断续的几段。', - dangerLevel: 'high', sceneNpcNames: ['沈雾', '陆沉', '顾潮'], connections: [ { @@ -153,7 +152,6 @@ describe('scenePresets custom world npc mapping', () => { { name: '断桥旧道', description: '半塌的桥面上还挂着旧索和残旗。', - dangerLevel: 'high', sceneNpcNames: ['沈雾', '陆沉', '顾潮'], connections: [ { diff --git a/src/hooks/rpg-runtime-story/npcEncounterActions.test.ts b/src/hooks/rpg-runtime-story/npcEncounterActions.test.ts index f328b369..3570d6d9 100644 --- a/src/hooks/rpg-runtime-story/npcEncounterActions.test.ts +++ b/src/hooks/rpg-runtime-story/npcEncounterActions.test.ts @@ -1047,8 +1047,14 @@ describe('npcEncounterActions', () => { ]); }); - it('opens hostile npc encounters as a declaration dialogue with only escape and fight options', () => { + it('lets hostile npc encounters speak first on first contact', async () => { const encounter = createEncounter(); + streamNpcChatTurnMock.mockResolvedValueOnce({ + affinityDelta: 0, + affinityText: '关系暂未变化', + npcReply: '先别急着拔剑,我有话要问你。', + suggestions: ['你想问什么'], + }); const actions = createNpcEncounterActions({ gameState: createState({ currentEncounter: encounter, @@ -1071,6 +1077,7 @@ describe('npcEncounterActions', () => { }); expect(actions.enterNpcInteraction(encounter, '与断桥客搭话')).toBe(true); + await flushAsyncWork(); expect(actions.setGameState).toHaveBeenCalledWith( expect.objectContaining({ @@ -1080,18 +1087,26 @@ describe('npcEncounterActions', () => { expect(actions.setCurrentStory).toHaveBeenCalledWith( expect.objectContaining({ displayMode: 'dialogue', - options: [ + dialogue: [ expect.objectContaining({ - functionId: 'battle_escape_breakout', - actionText: '逃跑', - }), - expect.objectContaining({ - functionId: 'npc_fight', - actionText: '与他对战', + speaker: 'npc', + text: '先别急着拔剑,我有话要问你。', }), ], }), ); + expect(streamNpcChatTurnMock).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({id: 'npc-rival'}), + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + '【NPC 主动开场】', + expect.anything(), + expect.objectContaining({npcInitiatesConversation: true}), + ); }); it('lets the current act primary npc enter limited chat even with negative affinity', () => { diff --git a/src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts b/src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts index 5ebaffd9..5a584584 100644 --- a/src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts +++ b/src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts @@ -1466,17 +1466,6 @@ export function createStoryNpcEncounterActions({ nextTurnCount: 0, }); - if ((npcState.affinity < 0 || encounter.hostile) && !limitedChatDirective) { - setCurrentStory( - buildHostileNpcStoryMoment( - encounter, - playerCharacter, - npcState.affinity, - ), - ); - return true; - } - const npcInteractionOptions = getAvailableOptionsForState(nextState, playerCharacter) ?? []; const chatOptions = npcInteractionOptions.filter((option) => @@ -1514,6 +1503,17 @@ export function createStoryNpcEncounterActions({ return true; } + if ((npcState.affinity < 0 || encounter.hostile) && !limitedChatDirective) { + setCurrentStory( + buildHostileNpcStoryMoment( + encounter, + playerCharacter, + npcState.affinity, + ), + ); + return true; + } + return enterNpcChat( encounter, seedChatOption, diff --git a/src/hooks/useGameFlow.customWorld.test.tsx b/src/hooks/useGameFlow.customWorld.test.tsx index 4ce53694..a61b45c1 100644 --- a/src/hooks/useGameFlow.customWorld.test.tsx +++ b/src/hooks/useGameFlow.customWorld.test.tsx @@ -213,14 +213,12 @@ function buildSavedProfile() { camp: { name: '回潮暂栖所', description: '能暂时收拢队伍、整理灯册与潮路线索的落脚点。', - dangerLevel: 'low', }, landmarks: [ { id: 'landmark-1', name: '回潮旧灯塔', description: '被海雾啃旧的石塔仍在夜里维持着微弱灯火。', - dangerLevel: 'high', sceneNpcIds: ['story-1'], connections: [ { @@ -243,7 +241,6 @@ function buildSavedProfile() { id: 'landmark-2', name: '雾栈尽头', description: '旧栈桥尽头只剩断索、潮板和被抹掉的船名。', - dangerLevel: 'high', sceneNpcIds: [], connections: [ { diff --git a/src/prompts/customWorldPrompts.test.ts b/src/prompts/customWorldPrompts.test.ts index c91cfad4..5f7ce51c 100644 --- a/src/prompts/customWorldPrompts.test.ts +++ b/src/prompts/customWorldPrompts.test.ts @@ -16,7 +16,6 @@ const framework = { camp: { name: '旧灯塔营地', description: '潮雾里的临时归处。', - dangerLevel: 'medium', }, playableNpcs: [], storyNpcs: [], diff --git a/src/prompts/customWorldPrompts.ts b/src/prompts/customWorldPrompts.ts index 887c8158..0057079d 100644 --- a/src/prompts/customWorldPrompts.ts +++ b/src/prompts/customWorldPrompts.ts @@ -35,7 +35,6 @@ type CustomWorldGenerationLandmarkOutline = { name: string; description: string; visualDescription?: string; - dangerLevel: string; sceneNpcNames: string[]; connections: Array<{ targetLandmarkName: string; @@ -58,7 +57,6 @@ type CustomWorldGenerationFramework = { camp: { name: string; description: string; - dangerLevel: string; }; playableNpcs: CustomWorldGenerationRoleOutline[]; storyNpcs: CustomWorldGenerationRoleOutline[]; @@ -92,7 +90,7 @@ function buildFrameworkSummaryText( .slice(0, maxLandmarks) .map( (landmark) => - `${landmark.name}(${landmark.dangerLevel},${landmark.description})`, + `${landmark.name}(${landmark.description})`, ) .join('、'); @@ -193,7 +191,6 @@ export function buildCustomWorldFrameworkPrompt(settingText: string) { ' "camp": {', ' "name": "开局归处名称",', ' "description": "这是玩家进入世界后的第一处落脚点描述",', - ' "dangerLevel": "low|medium|high|extreme"', ' }', '}', '', @@ -460,7 +457,7 @@ export function buildCustomWorldFrameworkJsonRepairPrompt( '顶层必须只包含:name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。', '不要输出 playableNpcs、storyNpcs、landmarks、items 或任何其他字段。', 'majorFactions 与 coreConflicts 必须是字符串数组。', - 'camp 必须是对象,且包含:name、description、dangerLevel。', + 'camp 必须是对象,且包含:name、description。', '原始文本:', responseText.trim(), ].join('\n'); @@ -576,7 +573,6 @@ export function buildCustomWorldLandmarkSeedBatchPrompt(params: { ' {', ' "name": "场景名称",', ' "description": "极简场景描述",', - ' "dangerLevel": "low|medium|high|extreme"', ' }', ' ]', '}', @@ -584,7 +580,7 @@ export function buildCustomWorldLandmarkSeedBatchPrompt(params: { '要求:', `- 必须生成恰好 ${batchCount} 个 landmarks。`, '- 这是一个完全独立的自定义世界;不要在场景描述里直接写“武侠世界”“仙侠世界”等现成世界名。', - '- 这一步只保留:name、description、dangerLevel。', + '- 这一步只保留:name、description。', '- 不要输出 sceneNpcNames、connections、imageSrc 或其他字段。', '- 名称必须具体且互不重复,不要使用 场景1、地点1 之类的占位名。', '- description 控制在 8 到 18 个汉字内。', @@ -610,7 +606,7 @@ export function buildCustomWorldLandmarkSeedBatchJsonRepairPrompt(params: { forbiddenNames.length > 0 ? `禁止使用这些重复场景名:${forbiddenNames.join('、')}。` : '', - '每个地标只包含:name、description、dangerLevel。', + '每个地标只包含:name、description。', '不要输出 sceneNpcNames、connections 或其他字段。', '原始文本:', responseText.trim(), @@ -643,7 +639,7 @@ export function buildCustomWorldLandmarkNetworkBatchPrompt(params: { landmarkBatch .map( (landmark) => - `- ${landmark.name} / 危险度:${landmark.dangerLevel} / 描述:${landmark.description}`, + `- ${landmark.name} / 描述:${landmark.description}`, ) .join('\n'), '', @@ -672,7 +668,7 @@ export function buildCustomWorldLandmarkNetworkBatchPrompt(params: { `- 每个场景必须提供恰好 2 条 connections;relativePosition 只能使用:${relativePositionValues}。`, '- targetLandmarkName 必须来自全部场景名,且不能连接自己,两个目标场景不能重复。', '- summary 控制在 4 到 10 个汉字内。', - '- 不要输出 description、dangerLevel、backstory 或其他字段。', + '- 不要输出 description、backstory 或其他字段。', '- 所有生成文本都必须使用中文。', '- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。', ].join('\n'); @@ -691,7 +687,7 @@ export function buildCustomWorldLandmarkNetworkBatchJsonRepairPrompt(params: { `landmarks 里只能保留这些场景名:${expectedNames.join('、')}。`, '每个场景对象只包含:name、sceneNpcNames、connections。', 'connections 里的每个对象必须包含:targetLandmarkName、relativePosition、summary。', - '不要输出 description、dangerLevel 或其他字段。', + '不要输出 description 或其他字段。', '原始文本:', responseText.trim(), ].join('\n'); @@ -883,7 +879,6 @@ export function buildCustomWorldGenerationPrompt(settingText: string) { ' "camp": {', ' "name": "开局归处名称",', ' "description": "玩家进入世界后的第一处落脚点描述",', - ' "dangerLevel": "low|medium|high|extreme"', ' },', ' "playableNpcs": [', ' {', @@ -957,7 +952,6 @@ export function buildCustomWorldGenerationPrompt(settingText: string) { ' {', ' "name": "场景名称",', ' "description": "场景描述",', - ' "dangerLevel": "low|medium|high|extreme",', ' "sceneNpcNames": ["会在这个场景出现的角色1", "会在这个场景出现的角色2", "会在这个场景出现的角色3"],', ' "connections": [', ' {', @@ -1005,21 +999,6 @@ function clampSceneImageText(value: string, maxLength: number) { return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}…`; } -function describeDangerLevel(dangerLevel: string) { - const normalized = dangerLevel.trim().toLowerCase(); - if (normalized === 'low' || normalized === '低') - return '气氛相对平静,但暗藏细节张力'; - if (normalized === 'medium' || normalized === '中') - return '带有明确的探索压力与潜在威胁'; - if (normalized === 'high' || normalized === '高') - return '危险感强烈,空间中有明显压迫感'; - if (normalized === 'extreme' || normalized === '极高') - return '极端危险,环境本身就像会吞没闯入者'; - return dangerLevel.trim() - ? `危险氛围:${dangerLevel.trim()}` - : '危险气质保持克制但不可忽视'; -} - export const DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT = [ '文字', '水印', @@ -1041,7 +1020,7 @@ export function buildCustomWorldSceneImagePrompt( CustomWorldProfile, 'name' | 'subtitle' | 'summary' | 'tone' | 'playerGoal' | 'settingText' >, - landmark: Pick, + landmark: Pick, userPrompt = '', options: { hasReferenceImage?: boolean; @@ -1056,7 +1035,6 @@ export function buildCustomWorldSceneImagePrompt( const landmarkName = clampSceneImageText(landmark.name, 18) || '未命名场景'; const landmarkDescription = clampSceneImageText(landmark.description, 96); const requestedVisual = clampSceneImageText(userPrompt, 120); - const dangerMood = describeDangerLevel(landmark.dangerLevel); return [ '为横版 16:9 2D RPG 生成高完成度像素风场景背景,适合作为剧情探索与战斗底图。', @@ -1075,7 +1053,6 @@ export function buildCustomWorldSceneImagePrompt( `场景名称:${landmarkName}。`, landmarkDescription ? `场景描述:${landmarkDescription}。` : '', requestedVisual ? `本次想要生成的画面内容:${requestedVisual}。` : '', - `${dangerMood}。`, '不要出现 UI、字幕、文字、水印、logo 或装饰边框,人物仅可作为很小的远景剪影,画面重点放在场景本身,不要遮挡下半部分的角色展示区域。', ] .filter(Boolean) diff --git a/src/services/ai.test.ts b/src/services/ai.test.ts index c75f4c09..61a17484 100644 --- a/src/services/ai.test.ts +++ b/src/services/ai.test.ts @@ -339,7 +339,6 @@ function createLandmark( return { name: `场景${index + 1}`, description: `场景描述${index + 1}`, - dangerLevel: 'high', sceneNpcNames: options?.storyNpcNames ?? [ `世界NPC${index + 1}`, `世界NPC${index + 2}`, @@ -931,7 +930,6 @@ describe('ai orchestration fallbacks', () => { id: 'landmark-1', name: '雾潮码头', description: '被潮雾与旧升降机包围的码头。', - dangerLevel: 'high', }, userPrompt: '雨夜的栈桥横跨黑色海沟,塔楼灯火被潮雾吞没。', size: '1280*720', @@ -1002,7 +1000,6 @@ describe('ai orchestration fallbacks', () => { id: 'landmark-1', name: '雾潮码头', description: '被潮雾与旧升降机包围的码头。', - dangerLevel: 'high', }, }), ).rejects.toThrow('DashScope API key 无效。'); diff --git a/src/services/ai.ts b/src/services/ai.ts index a9ca2c25..bb5d3eaf 100644 --- a/src/services/ai.ts +++ b/src/services/ai.ts @@ -1987,7 +1987,6 @@ export async function generateCustomWorldSceneImage({ id: landmark.id, name: landmark.name, description: landmark.description, - dangerLevel: landmark.dangerLevel, }, ...(referenceImageSrc?.trim() ? { referenceImageSrc: referenceImageSrc.trim() } diff --git a/src/services/aiTypes.ts b/src/services/aiTypes.ts index ad0cab8c..51ded8b9 100644 --- a/src/services/aiTypes.ts +++ b/src/services/aiTypes.ts @@ -69,7 +69,6 @@ export interface CustomWorldSceneImageRequest { id: string; name: string; description: string; - dangerLevel: string; }; userPrompt?: string; prompt?: string; diff --git a/src/services/customWorld.test.ts b/src/services/customWorld.test.ts index d81adc4e..144358d3 100644 --- a/src/services/customWorld.test.ts +++ b/src/services/customWorld.test.ts @@ -86,7 +86,6 @@ describe('normalizeCustomWorldProfile', () => { { name: '北侧塌桥', description: '横跨裂谷的旧桥只剩半截石拱。', - dangerLevel: 'high', }, ], }; @@ -191,7 +190,6 @@ describe('normalizeCustomWorldProfile', () => { { name: '北侧塌桥', description: '断桥上方还残留着旧索道。', - dangerLevel: 'high', sceneNpcNames: ['梁砺'], connections: [ { @@ -204,7 +202,6 @@ describe('normalizeCustomWorldProfile', () => { { name: '雾潮码头', description: '潮雾会把来路和去路都遮住一半。', - dangerLevel: 'medium', sceneNpcNames: ['苏雾', '顾岚'], connections: [], }, diff --git a/src/services/customWorld.ts b/src/services/customWorld.ts index 0afca2eb..ffc97745 100644 --- a/src/services/customWorld.ts +++ b/src/services/customWorld.ts @@ -115,7 +115,6 @@ export interface CustomWorldGenerationLandmarkOutline { name: string; description: string; visualDescription?: string; - dangerLevel: string; sceneNpcNames: string[]; connections: CustomWorldGenerationLandmarkConnectionOutline[]; } @@ -125,7 +124,6 @@ export interface CustomWorldGenerationCampOutline { name: string; description: string; visualDescription?: string; - dangerLevel: string; imageSrc?: string; sceneNpcIds?: string[]; sceneNpcNames?: string[]; @@ -714,7 +712,6 @@ export function normalizeCustomWorldGenerationFramework( camp: { name: fallback.camp?.name ?? '归舍', description: fallback.camp?.description ?? '', - dangerLevel: fallback.camp?.dangerLevel ?? 'low', }, playableNpcs: [], storyNpcs: [], @@ -786,7 +783,6 @@ export function buildCustomWorldRawProfileFromFramework( camp: { name: framework.camp.name, description: framework.camp.description, - dangerLevel: framework.camp.dangerLevel, }, playableNpcs: framework.playableNpcs.map((npc) => ({ name: npc.name, @@ -816,7 +812,6 @@ export function buildCustomWorldRawProfileFromFramework( name: landmark.name, description: landmark.description, visualDescription: landmark.visualDescription, - dangerLevel: landmark.dangerLevel, sceneNpcNames: [...landmark.sceneNpcNames], connections: landmark.connections.map((connection) => ({ targetLandmarkName: connection.targetLandmarkName, @@ -1071,7 +1066,6 @@ function normalizeCampOutline( name: toText(item.name) || fallback.name, description: toText(item.description) || fallback.description, visualDescription: toText(item.visualDescription) || undefined, - dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel, imageSrc: toText(item.imageSrc) || undefined, sceneNpcIds: toStringArray(item.sceneNpcIds), sceneNpcNames: [ @@ -1107,7 +1101,6 @@ function normalizeLandmarkOutlineList(value: unknown) { toText(item.description) || truncateText(`${name}暗藏新的局势变化。`, 40), visualDescription: toText(item.visualDescription) || undefined, - dangerLevel: toText(item.dangerLevel) || 'medium', sceneNpcNames: [ ...toStringArray(item.sceneNpcNames), ...toStringArray(item.npcs, 'name'), @@ -1168,7 +1161,6 @@ function normalizeLandmarkDraftList(value: unknown) { name, description: toText(item.description), visualDescription: toText(item.visualDescription) || undefined, - dangerLevel: toText(item.dangerLevel), imageSrc: toText(item.imageSrc) || undefined, sceneNpcIds: toStringArray(item.sceneNpcIds), sceneNpcNames: [ @@ -1215,7 +1207,6 @@ function normalizeCampScene( name: toText(item.name) || fallback.name, description: toText(item.description) || fallback.description, visualDescription: toText(item.visualDescription) || undefined, - dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel, imageSrc: toText(item.imageSrc) || undefined, sceneNpcIds: toStringArray(item.sceneNpcIds), connections: toRecordArray(item.connections) @@ -1439,7 +1430,7 @@ export function buildCustomWorldReferenceText( .slice(0, 10) .map( (landmark) => - `- ${landmark.name}:${landmark.description};危险度:${landmark.dangerLevel};场景角色:${ + `- ${landmark.name}:${landmark.description};场景角色:${ landmark.sceneNpcIds .map((npcId) => storyNpcById.get(npcId)?.name) .filter(Boolean) diff --git a/src/services/customWorldBuilder.test.ts b/src/services/customWorldBuilder.test.ts index 44aea7e5..18a8c300 100644 --- a/src/services/customWorldBuilder.test.ts +++ b/src/services/customWorldBuilder.test.ts @@ -75,7 +75,6 @@ describe('buildExpandedCustomWorldProfile', () => { id: 'landmark-1', name: '断桥旧哨', description: '旧哨火和断桥一起守着边城北口。', - dangerLevel: 'high', sceneNpcIds: ['story-1'], connections: [], }, diff --git a/src/services/customWorldBuilder.ts b/src/services/customWorldBuilder.ts index 48c0f010..1ca073cc 100644 --- a/src/services/customWorldBuilder.ts +++ b/src/services/customWorldBuilder.ts @@ -158,11 +158,6 @@ export function buildExpandedCustomWorldProfile( ...landmark, id: landmark.id || createEntryId('landmark', landmark.name, index), description: clampText(landmark.description, 96), - dangerLevel: - landmark.dangerLevel || - (resolveCustomWorldCompatibilityTemplateWorldType(profile) === WorldType.XIANXIA - ? 'high' - : 'medium'), })); const landmarkIdByReference = new Map(); landmarkDrafts.forEach((landmark) => { diff --git a/src/services/customWorldCamp.ts b/src/services/customWorldCamp.ts index 71dcb875..9b3e479c 100644 --- a/src/services/customWorldCamp.ts +++ b/src/services/customWorldCamp.ts @@ -14,7 +14,6 @@ type CampProfileSeed = Pick< | 'name' | 'description' | 'visualDescription' - | 'dangerLevel' | 'imageSrc' | 'sceneNpcIds' | 'connections' @@ -92,7 +91,6 @@ export function buildFallbackCustomWorldCampScene( id: 'custom-scene-camp', name: fallbackName, description: buildFallbackCampDescription(profile, fallbackName), - dangerLevel: 'low', sceneNpcIds: [], connections: [], narrativeResidues: null, @@ -110,7 +108,6 @@ export function resolveCustomWorldCampScene( name: camp?.name?.trim() || fallback.name, description: camp?.description?.trim() || fallback.description, visualDescription: camp?.visualDescription?.trim() || undefined, - dangerLevel: camp?.dangerLevel?.trim() || fallback.dangerLevel, imageSrc: camp?.imageSrc?.trim() || undefined, sceneNpcIds: Array.isArray(camp?.sceneNpcIds) ? [...new Set(camp.sceneNpcIds.map((entry) => entry.trim()).filter(Boolean))] diff --git a/src/services/customWorldCover.test.ts b/src/services/customWorldCover.test.ts index 1b66fef7..cb1707d7 100644 --- a/src/services/customWorldCover.test.ts +++ b/src/services/customWorldCover.test.ts @@ -59,7 +59,6 @@ function createBaseProfile(): CustomWorldProfile { id: 'camp-1', name: '守夜营地', description: '潮线后的临时据点。', - dangerLevel: 'medium', imageSrc: '/images/camp/camp.webp', sceneNpcIds: [], connections: [], @@ -69,7 +68,6 @@ function createBaseProfile(): CustomWorldProfile { id: 'landmark-1', name: '潮汐码头', description: '涨潮时会吞掉半截栈桥。', - dangerLevel: 'high', imageSrc: '/images/landmark/docks.webp', sceneNpcIds: [], connections: [], diff --git a/src/services/customWorldOwnedSettingLayers.ts b/src/services/customWorldOwnedSettingLayers.ts index 7828efbb..af8a8b4c 100644 --- a/src/services/customWorldOwnedSettingLayers.ts +++ b/src/services/customWorldOwnedSettingLayers.ts @@ -353,7 +353,7 @@ function inferRoleArchetypeLabel( } function inferSceneBucketLabel( - landmark: Pick, + landmark: Pick, ) { const source = `${landmark.name} ${landmark.description}`; @@ -365,9 +365,7 @@ function inferSceneBucketLabel( if (/[洞|穴|地下|遗迹|墓]/u.test(source)) return '地底遗迹区'; if (/[街|巷|城|镇|居]/u.test(source)) return '群落聚居区'; - return landmark.dangerLevel === 'high' || landmark.dangerLevel === 'extreme' - ? '高压交汇区' - : '叙事缓冲区'; + return '叙事缓冲区'; } function buildRoleArchetypes(profile: CustomWorldProfile) { @@ -388,7 +386,7 @@ function buildSceneBuckets(profile: CustomWorldProfile) { id: `scene-bucket-${index + 1}`, label: inferSceneBucketLabel(landmark), moodTags: dedupeStrings( - [landmark.dangerLevel, ...splitToneTags(profile.tone)], + splitToneTags(profile.tone), 4, ), keywords: dedupeStrings([landmark.name, landmark.description], 4), diff --git a/src/services/prompt.test.ts b/src/services/prompt.test.ts index a7b9ab22..40c29e59 100644 --- a/src/services/prompt.test.ts +++ b/src/services/prompt.test.ts @@ -112,7 +112,6 @@ describe('buildUserPrompt', () => { id: 'landmark-1', name: '断桥旧哨', description: '旧哨火和断桥一起守着边城北口。', - dangerLevel: 'high', sceneNpcIds: ['story-1'], connections: [], }, diff --git a/src/types/customWorld.ts b/src/types/customWorld.ts index 23544b83..9dd13150 100644 --- a/src/types/customWorld.ts +++ b/src/types/customWorld.ts @@ -375,7 +375,6 @@ export interface CustomWorldCampScene { name: string; description: string; visualDescription?: string; - dangerLevel: string; imageSrc?: string; sceneNpcIds: string[]; connections: CustomWorldSceneConnection[]; @@ -387,7 +386,6 @@ export interface CustomWorldLandmark { name: string; description: string; visualDescription?: string; - dangerLevel: string; imageSrc?: string; sceneNpcIds: string[]; connections: CustomWorldSceneConnection[]; From 2ebfd1cf55565406ac520833bd4466a91b852fef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Sat, 25 Apr 2026 15:10:24 +0800 Subject: [PATCH 2/2] Prune obsolete docs and update navigation --- .env.example | 18 +- .env.local | 3 - .gitignore | 5 - README.md | 7 +- bash.exe.stackdump | 28 + docs/README.md | 8 +- .../FUNCTION_DESIGN_AUDIT_2026-04-03.md | 2 +- ...QUIREMENT_COMPLETENESS_AUDIT_2026-04-14.md | 2 +- docs/audits/README.md | 2 +- ...G_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md | 2 +- ...RING_OPTIMIZATION_PRIORITIES_2026-04-10.md | 256 - ...P_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md | 12 +- ...P_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md | 6 +- ...INEERING_OPTIMIZATION_REVIEW_2026-03-29.md | 278 - ...INEERING_OPTIMIZATION_REVIEW_2026-03-30.md | 290 -- ...INEERING_OPTIMIZATION_REVIEW_2026-04-01.md | 200 - ...OGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md | 2 +- docs/audits/engineering/README.md | 39 +- ..._NODE_FREEZE_AND_DEPRECATION_2026-04-24.md | 65 +- ...DITOR_GAME_PRESET_TEXT_AUDIT_2026-03-25.md | 228 - ...AME_EDITOR_PRESET_TEXT_AUDIT_2026-03-29.md | 91 - ...AME_EDITOR_PRESET_TEXT_AUDIT_2026-03-30.md | 87 - ..._EDITOR_TEXT_AUDIT_2026-03-30_CONTINUED.md | 280 - ..._UI_PRESET_EDITOR_TEXT_AUDIT_2026-03-31.md | 194 - ..._UI_PRESET_EDITOR_TEXT_AUDIT_2026-04-01.md | 325 -- docs/audits/text/README.md | 13 +- ...TION21_APPLICATION_MATERIALS_2026-04-14.md | 2 +- ...PPLICATION_OVERVIEW_13_21_24_2026-04-14.md | 4 +- ...NT_GAME_ITERATION_PRIORITIES_2026-04-03.md | 293 -- ...D_HIDDEN_BRANCH_CLEANUP_PLAN_2026-04-21.md | 4 +- ...END_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md | 588 --- ...XPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md | 447 -- docs/planning/README.md | 5 +- ...STACK_COLD_BUILD_TIMEOUT_FIX_2026-04-25.md | 56 + ...ITE_CROSS_CUTTING_GOVERNANCE_2026-04-22.md | 3 +- ...KEND_IMPLEMENTATION_BASELINE_2026-04-25.md | 33 + .../EDITOR_ASSET_API_MIGRATION_2026-04-08.md | 101 - ...S_BACKEND_INTEGRATION_FREEZE_2026-04-09.md | 108 - ...ASK4_AI_ORCHESTRATION_STATUS_2026-04-08.md | 109 - ...ESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md | 425 -- ...END_MIGRATION_EXECUTION_PLAN_2026-04-21.md | 190 - ...OARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md | 2 +- ...INGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md | 2 +- ...M4_MODULE_AI_BASELINE_DESIGN_2026-04-21.md | 7 +- ...EPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md | 43 +- .../NODE_BACKEND_MODULE_AND_API_INDEX.md | 399 -- .../NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md | 245 - ...VER_TEST_AND_DEPLOY_BASELINE_2026-04-08.md | 226 - .../PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md | 185 - docs/technical/README.md | 18 +- ...HAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md | 3 +- ...NTIME_NPC_CHAT_LLM_MIGRATION_2026-04-25.md | 56 + .../RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md | 2 +- ...ND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md | 2 +- ...AND_AGENT_LLM_FAILURE_POLICY_2026-04-23.md | 2 +- ...ONTRACTS_CRATE_STAGE1_DESIGN_2026-04-21.md | 2 +- ..._AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md | 2 +- ...DB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md | 4 +- ...M_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md | 14 +- ...VEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md | 4 +- package-lock.json | 1422 +---- package.json | 20 +- packages/shared/src/prompts/qwenSprite.ts | 5 +- scripts/check-server-node-freeze.mjs | 127 - scripts/deploy.sh | 71 - scripts/dev-node.mjs | 548 -- scripts/dev-rust-stack.sh | 10 +- scripts/dev-server/README.md | 8 +- scripts/dev-web-rust.mjs | 1 - scripts/m7-api-compare.ts | 170 - scripts/run-caddy-dev.mjs | 7 +- scripts/server-node-freeze-baseline.json | 1241 ----- scripts/server-node-frozen.mjs | 8 - scripts/smoke-same-origin-stack.ts | 436 -- scripts/smoke-server-node.ts | 406 -- scripts/update.sh | 76 - server-node/build.mjs | 16 - server-node/ecosystem.config.cjs | 18 - .../manifests/backend-capability-index.json | 2250 -------- server-node/package-lock.json | 3860 -------------- server-node/package.json | 40 - .../generateBackendCapabilityArtifacts.ts | 372 -- .../sql/schema/00_schema_migrations.sql | 5 - server-node/sql/schema/01_users.sql | 16 - server-node/sql/schema/02_save_snapshots.sql | 10 - .../sql/schema/03_runtime_settings.sql | 6 - .../sql/schema/04_custom_world_profiles.sql | 24 - server-node/sql/schema/05_auth_identities.sql | 24 - server-node/sql/schema/06_user_sessions.sql | 17 - server-node/sql/schema/07_auth_audit_logs.sql | 14 - server-node/sql/schema/08_sms_auth_events.sql | 29 - .../sql/schema/09_auth_risk_blocks.sql | 13 - server-node/sql/schema/README.md | 8 - server-node/src/app.test.ts | 4631 ----------------- server-node/src/app.ts | 240 - server-node/src/auth/authRequestContext.ts | 15 - server-node/src/auth/authService.ts | 1537 ------ server-node/src/auth/password.ts | 16 - server-node/src/auth/phoneNumber.ts | 55 - server-node/src/auth/refreshSessionCookie.ts | 111 - server-node/src/auth/token.ts | 63 - .../bridges/legacyInventoryRuntimeBridge.ts | 25 - .../src/bridges/legacyNpcTask6Bridge.ts | 26 - .../src/bridges/legacyQuestProgressBridge.ts | 15 - .../src/bridges/legacyQuestRuntimeBridge.ts | 9 - .../src/bridges/legacyRuntimeItemBridge.ts | 6 - .../bridges/legacyTreasureRuntimeBridge.ts | 3 - server-node/src/config.test.ts | 61 - server-node/src/config.ts | 547 -- server-node/src/context.ts | 54 - server-node/src/db.test.ts | 186 - server-node/src/db.ts | 168 - server-node/src/db/migrations.ts | 388 -- server-node/src/errors.ts | 173 - server-node/src/http.ts | 318 -- server-node/src/logging.ts | 77 - .../src/manifest/backendCapabilityManifest.ts | 1696 ------ server-node/src/middleware/auth.ts | 43 - server-node/src/middleware/errorHandler.ts | 54 - server-node/src/middleware/requestId.ts | 16 - .../src/middleware/responseEnvelope.ts | 49 - server-node/src/middleware/routeMeta.ts | 10 - server-node/src/migrate.ts | 30 - .../src/modules/ai/chatOrchestrator.ts | 420 -- .../src/modules/ai/chatPromptBuilders.ts | 1 - .../src/modules/ai/customWorldOrchestrator.ts | 440 -- .../src/modules/ai/orchestrator.test.ts | 803 --- .../src/modules/ai/storyOrchestrator.ts | 594 --- .../modules/ai/storyPromptBuilders.test.ts | 54 - .../src/modules/ai/storyPromptBuilders.ts | 1 - .../assets/characterAssetRoutes.test.ts | 1358 ----- .../modules/assets/characterAssetRoutes.ts | 3084 ----------- .../modules/combat/combatResolutionService.ts | 635 --- .../custom-world/creatorIntentRuntime.ts | 528 -- .../runtime-profile/buildAttributeSchema.ts | 365 -- .../runtime-profile/buildCompiledProfile.ts | 410 -- .../runtime-profile/creatorIntentBridge.ts | 82 - .../custom-world/runtime-profile/index.ts | 13 - .../runtime-profile/normalizeCamp.ts | 178 - .../runtime-profile/normalizeLandmark.ts | 151 - .../runtime-profile/normalizeRole.ts | 541 -- .../runtime-profile/normalizeSceneChapter.ts | 123 - .../runtime-profile/normalizeShared.ts | 248 - .../custom-world/runtimeProfile.test.ts | 133 - .../modules/custom-world/runtimeProfile.ts | 6 - .../src/modules/custom-world/runtimeTypes.ts | 439 -- .../src/modules/editor/editorRoutes.ts | 145 - .../inventoryMutationService.test.ts | 230 - .../inventory/inventoryMutationService.ts | 458 -- .../inventory/inventoryStoryActionService.ts | 195 - .../npcInventoryStoryActionService.ts | 386 -- .../src/modules/npc/npcInteractionService.ts | 458 -- .../modules/npc/npcTask6Primitives.test.ts | 150 - .../src/modules/npc/npcTask6Primitives.ts | 411 -- .../chapterProgressionPlanner.test.ts | 225 - .../progression/chapterProgressionPlanner.ts | 480 -- .../hostileProgressionService.test.ts | 182 - .../progression/hostileProgressionService.ts | 353 -- .../modules/progression/levelBenchmarks.ts | 63 - .../progression/npcLevelResolver.test.ts | 82 - .../modules/progression/npcLevelResolver.ts | 106 - .../playerProgressionService.test.ts | 58 - .../progression/playerProgressionService.ts | 192 - .../quest/questProgressionService.test.ts | 103 - .../modules/quest/questProgressionService.ts | 224 - .../quest/questRuntimeSignalService.ts | 84 - .../modules/quest/questStoryActionService.ts | 639 --- .../src/modules/quest/questTask6Bridge.ts | 17 - .../src/modules/quest/runtimeQuestModule.ts | 1201 ----- .../RpgRuntimeOptionCompiler.ts | 14 - .../RpgRuntimeSessionDomain.test.ts | 100 - .../RpgRuntimeSessionDomain.ts | 1440 ----- .../RpgRuntimeSessionLoader.ts | 13 - .../RpgRuntimeSessionPrimitives.ts | 29 - .../RpgRuntimeSnapshotSync.ts | 13 - .../RpgRuntimeStoryActionDomain.ts | 1176 ----- .../RpgRuntimeStoryActionService.ts | 10 - .../RpgRuntimeStoryPresentationCompiler.ts | 8 - .../RpgRuntimeStoryStateService.ts | 8 - .../modules/runtime-item/runtimeItemModule.ts | 758 --- .../runtime-item/runtimeTreasureModule.ts | 80 - .../treasureStoryActionService.ts | 140 - .../src/modules/runtime/runtimeBuildModule.ts | 211 - .../runtime/runtimeEconomyPrimitives.test.ts | 50 - .../runtime/runtimeEconomyPrimitives.ts | 75 - .../modules/runtime/runtimeEquipmentModule.ts | 211 - .../src/modules/runtime/runtimeForgeModule.ts | 468 -- .../runtime/runtimeInventoryEffectsModule.ts | 130 - .../modules/runtime/runtimeNarrativeMemory.ts | 88 - .../runtime/runtimeNpcStatePrimitives.test.ts | 76 - .../runtime/runtimeNpcStatePrimitives.ts | 117 - .../runtime/runtimeSnapshotHydration.test.ts | 218 - .../runtime/runtimeSnapshotHydration.ts | 657 --- .../runtime/runtimeStatePrimitives.test.ts | 113 - .../modules/runtime/runtimeStatePrimitives.ts | 221 - .../modules/runtime/runtimeTreasureTexts.ts | 49 - server-node/src/observability.test.ts | 286 - .../src/prompts/characterAssetPrompts.ts | 279 - server-node/src/prompts/chatPromptBuilders.ts | 621 --- .../src/prompts/customWorldAgentPrompts.ts | 57 - .../src/prompts/customWorldEntityPrompts.ts | 249 - .../prompts/customWorldOrchestratorPrompts.ts | 61 - server-node/src/prompts/customWorldPrompts.ts | 645 --- .../src/prompts/customWorldSceneNpcPrompts.ts | 104 - server-node/src/prompts/eightAnchorPrompts.ts | 784 --- server-node/src/prompts/questPrompts.ts | 168 - server-node/src/prompts/runtimeItemPrompts.ts | 43 - .../src/prompts/storyOrchestratorPrompts.ts | 33 - .../src/prompts/storyPromptBuilders.ts | 197 - .../repositories/RpgAgentSessionRepository.ts | 100 - .../repositories/RpgWorldProfileRepository.ts | 433 -- .../repositories/authAuditLogRepository.ts | 105 - .../repositories/authIdentityRepository.ts | 156 - .../repositories/authRiskBlockRepository.ts | 128 - .../customWorldLibraryMetadata.test.ts | 76 - .../customWorldLibraryMetadata.ts | 204 - .../rpg-entry/RpgEntryRepositories.test.ts | 241 - .../rpg-entry/RpgSaveArchiveRepository.ts | 36 - .../rpg-entry/RpgWorldLibraryRepository.ts | 92 - .../rpg-profile/RpgBrowseHistoryRepository.ts | 42 - .../RpgProfileDashboardRepository.ts | 49 - .../RpgProfileRepositories.test.ts | 154 - .../RpgRuntimeSnapshotRepository.test.ts | 126 - .../RpgRuntimeSnapshotRepository.ts | 35 - .../repositories/rpgWorldRepositoryShared.ts | 116 - .../src/repositories/runtimeRepository.ts | 1320 ----- .../repositories/smsAuthEventRepository.ts | 302 -- .../src/repositories/userRepository.ts | 290 -- .../src/repositories/userSessionRepository.ts | 214 - server-node/src/routes/authRoutes.ts | 534 -- server-node/src/routes/bigFishProxyRoutes.ts | 341 -- server-node/src/routes/customWorldAgent.ts | 272 - server-node/src/routes/puzzleProxyRoutes.ts | 451 -- .../routes/rpg-entry/rpgEntrySaveRoutes.ts | 151 - .../routes/rpg-entry/rpgWorldLibraryRoutes.ts | 338 -- .../routes/rpg-profile/rpgProfileRoutes.ts | 214 - .../rpg-runtime/rpgRuntimeAiAssistRoutes.ts | 370 -- .../rpg-runtime/rpgRuntimeStoryRoutes.test.ts | 2741 ---------- .../rpg-runtime/rpgRuntimeStoryRoutes.ts | 104 - .../src/routes/rpgRouteBoundaries.test.ts | 524 -- server-node/src/server.ts | 236 - .../RpgWorldPreviewCompiler.fixture.test.ts | 80 - .../services/RpgWorldPreviewCompiler.test.ts | 269 - .../src/services/RpgWorldPreviewCompiler.ts | 65 - .../src/services/RpgWorldWorkCoverResolver.ts | 46 - ...gWorldWorkSummaryAssembler.fixture.test.ts | 96 - .../services/RpgWorldWorkSummaryAssembler.ts | 301 -- .../services/RpgWorldWorkSummaryService.ts | 44 - .../src/services/captchaChallengeStore.ts | 97 - server-node/src/services/chatService.test.ts | 82 - server-node/src/services/chatService.ts | 108 - .../draftFoundationExecutor.ts | 145 - .../executorShared.ts | 108 - .../expandLongTailExecutor.ts | 116 - .../generateCharactersExecutor.ts | 110 - .../generateLandmarksExecutor.ts | 110 - .../generateRoleAssetsExecutor.ts | 82 - .../generateSceneAssetsExecutor.ts | 88 - .../helpers.ts | 58 - .../customWorldAgentActionExecutors/index.ts | 105 - .../publishWorldExecutor.ts | 166 - .../revertCheckpointExecutor.ts | 95 - .../syncResultProfileExecutor.ts | 87 - .../syncRoleAssetsExecutor.ts | 97 - .../syncSceneAssetsExecutor.ts | 88 - .../customWorldAgentActionExecutors/types.ts | 29 - .../updateDraftCardExecutor.ts | 111 - .../customWorldAgentActionRegistry.test.ts | 260 - .../customWorldAgentActionRegistry.ts | 403 -- .../customWorldAgentAssetBridgeService.ts | 324 -- .../customWorldAgentAutoAssetService.test.ts | 401 -- .../customWorldAgentAutoAssetService.ts | 771 --- .../customWorldAgentChangeSummaryService.ts | 92 - .../customWorldAgentClarificationService.ts | 161 - .../customWorldAgentDraftCompiler.test.ts | 211 - .../services/customWorldAgentDraftCompiler.ts | 1733 ------ .../customWorldAgentDraftEditService.ts | 453 -- ...customWorldAgentEntityGenerationService.ts | 649 --- ...omWorldAgentFoundationDraftService.test.ts | 407 -- .../customWorldAgentFoundationDraftService.ts | 2182 -------- ...customWorldAgentIntentExtractionService.ts | 1128 ---- .../customWorldAgentMessageTurnService.ts | 196 - .../services/customWorldAgentOrchestrator.ts | 675 --- .../services/customWorldAgentPhase2.test.ts | 290 -- .../services/customWorldAgentPhase3.test.ts | 444 -- .../services/customWorldAgentPhase4.test.ts | 705 --- .../services/customWorldAgentPhase5.test.ts | 983 ---- .../customWorldAgentPublishingService.ts | 256 - .../customWorldAgentQualityGateService.ts | 88 - .../customWorldAgentRepositoryTestHelpers.ts | 305 -- .../customWorldAgentResultSyncService.test.ts | 118 - .../customWorldAgentResultSyncService.ts | 150 - ...tomWorldAgentRoleAssetStateService.test.ts | 129 - .../customWorldAgentRoleAssetStateService.ts | 489 -- .../services/customWorldAgentSessionStore.ts | 451 -- .../customWorldAgentSnapshotBuilder.ts | 199 - .../customWorldAgentSuggestedActionService.ts | 82 - .../services/customWorldAgentTestHelpers.ts | 321 -- .../customWorldCoverAssetService.test.ts | 278 - .../services/customWorldCoverAssetService.ts | 758 --- ...customWorldEntityGenerationService.test.ts | 157 - .../customWorldEntityGenerationService.ts | 901 ---- .../customWorldSceneNpcGenerationService.ts | 514 -- ...orldWorkSummaryService.integration.test.ts | 113 - .../eightAnchorCompatibilityService.ts | 593 --- .../src/services/eightAnchorPromptBuilder.ts | 1 - .../eightAnchorSingleTurnService.test.ts | 420 -- .../services/eightAnchorSingleTurnService.ts | 322 -- server-node/src/services/llmClient.ts | 510 -- server-node/src/services/questService.ts | 140 - .../rpgAgentSessionCompatibility.test.ts | 158 - .../rpgAgentSessionCompatibility.ts | 443 -- .../rpgAgentSessionFactory.ts | 74 - .../rpgAgentSessionRecord.ts | 98 - .../rpgAgentSessionRepositoryAdapter.ts | 24 - .../rpgCreationPreviewProfileBuilder.ts | 348 -- .../src/services/runtimeItemService.ts | 107 - .../src/services/sceneImageService.test.ts | 442 -- server-node/src/services/sceneImageService.ts | 505 -- .../services/smsVerificationService.test.ts | 76 - .../src/services/smsVerificationService.ts | 289 - server-node/src/services/storyService.ts | 76 - server-node/src/services/wechatAuthService.ts | 220 - .../src/services/wechatAuthStateStore.ts | 32 - .../src/testFixtures/runtimeCharacter.ts | 34 - server-node/src/testHttp.ts | 92 - server-node/src/types/express.d.ts | 15 - server-node/test.mjs | 54 - server-node/tsconfig.json | 23 - server-rs/README.md | 9 +- .../src/custom_world_foundation_draft.rs | 9 +- server-rs/crates/api-server/src/main.rs | 1 + .../api-server/src/prompt/scene_background.rs | 1 - .../crates/api-server/src/runtime_chat.rs | 302 +- .../api-server/src/runtime_chat_prompt.rs | 549 ++ .../flow/storyOpeningCampDialogue.ts | 2 +- .../functionCatalog/npc/npcChatQuestOffer.ts | 2 +- .../state/battleAttackBasic.ts | 4 +- .../functionCatalog/state/battleUseSkill.ts | 4 +- view-llm-logs.ps1 | 42 - vite.config.ts | 7 +- 341 files changed, 1352 insertions(+), 90709 deletions(-) create mode 100644 bash.exe.stackdump delete mode 100644 docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_PRIORITIES_2026-04-10.md delete mode 100644 docs/audits/engineering/ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md delete mode 100644 docs/audits/engineering/ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md delete mode 100644 docs/audits/engineering/ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md delete mode 100644 docs/audits/text/EDITOR_GAME_PRESET_TEXT_AUDIT_2026-03-25.md delete mode 100644 docs/audits/text/GAME_EDITOR_PRESET_TEXT_AUDIT_2026-03-29.md delete mode 100644 docs/audits/text/GAME_EDITOR_PRESET_TEXT_AUDIT_2026-03-30.md delete mode 100644 docs/audits/text/GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-03-30_CONTINUED.md delete mode 100644 docs/audits/text/GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-03-31.md delete mode 100644 docs/audits/text/GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-04-01.md delete mode 100644 docs/planning/CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md delete mode 100644 docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md delete mode 100644 docs/planning/EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md create mode 100644 docs/technical/API_SERVER_DEV_STACK_COLD_BUILD_TIMEOUT_FIX_2026-04-25.md create mode 100644 docs/technical/CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md delete mode 100644 docs/technical/EDITOR_ASSET_API_MIGRATION_2026-04-08.md delete mode 100644 docs/technical/EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md delete mode 100644 docs/technical/EXPRESS_BACKEND_TASK4_AI_ORCHESTRATION_STATUS_2026-04-08.md delete mode 100644 docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md delete mode 100644 docs/technical/FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md delete mode 100644 docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md delete mode 100644 docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md delete mode 100644 docs/technical/NODE_SERVER_TEST_AND_DEPLOY_BASELINE_2026-04-08.md delete mode 100644 docs/technical/PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md create mode 100644 docs/technical/RUNTIME_NPC_CHAT_LLM_MIGRATION_2026-04-25.md delete mode 100644 scripts/check-server-node-freeze.mjs delete mode 100644 scripts/deploy.sh delete mode 100644 scripts/dev-node.mjs delete mode 100644 scripts/m7-api-compare.ts delete mode 100644 scripts/server-node-freeze-baseline.json delete mode 100644 scripts/server-node-frozen.mjs delete mode 100644 scripts/smoke-same-origin-stack.ts delete mode 100644 scripts/smoke-server-node.ts delete mode 100644 scripts/update.sh delete mode 100644 server-node/build.mjs delete mode 100644 server-node/ecosystem.config.cjs delete mode 100644 server-node/manifests/backend-capability-index.json delete mode 100644 server-node/package-lock.json delete mode 100644 server-node/package.json delete mode 100644 server-node/scripts/generateBackendCapabilityArtifacts.ts delete mode 100644 server-node/sql/schema/00_schema_migrations.sql delete mode 100644 server-node/sql/schema/01_users.sql delete mode 100644 server-node/sql/schema/02_save_snapshots.sql delete mode 100644 server-node/sql/schema/03_runtime_settings.sql delete mode 100644 server-node/sql/schema/04_custom_world_profiles.sql delete mode 100644 server-node/sql/schema/05_auth_identities.sql delete mode 100644 server-node/sql/schema/06_user_sessions.sql delete mode 100644 server-node/sql/schema/07_auth_audit_logs.sql delete mode 100644 server-node/sql/schema/08_sms_auth_events.sql delete mode 100644 server-node/sql/schema/09_auth_risk_blocks.sql delete mode 100644 server-node/sql/schema/README.md delete mode 100644 server-node/src/app.test.ts delete mode 100644 server-node/src/app.ts delete mode 100644 server-node/src/auth/authRequestContext.ts delete mode 100644 server-node/src/auth/authService.ts delete mode 100644 server-node/src/auth/password.ts delete mode 100644 server-node/src/auth/phoneNumber.ts delete mode 100644 server-node/src/auth/refreshSessionCookie.ts delete mode 100644 server-node/src/auth/token.ts delete mode 100644 server-node/src/bridges/legacyInventoryRuntimeBridge.ts delete mode 100644 server-node/src/bridges/legacyNpcTask6Bridge.ts delete mode 100644 server-node/src/bridges/legacyQuestProgressBridge.ts delete mode 100644 server-node/src/bridges/legacyQuestRuntimeBridge.ts delete mode 100644 server-node/src/bridges/legacyRuntimeItemBridge.ts delete mode 100644 server-node/src/bridges/legacyTreasureRuntimeBridge.ts delete mode 100644 server-node/src/config.test.ts delete mode 100644 server-node/src/config.ts delete mode 100644 server-node/src/context.ts delete mode 100644 server-node/src/db.test.ts delete mode 100644 server-node/src/db.ts delete mode 100644 server-node/src/db/migrations.ts delete mode 100644 server-node/src/errors.ts delete mode 100644 server-node/src/http.ts delete mode 100644 server-node/src/logging.ts delete mode 100644 server-node/src/manifest/backendCapabilityManifest.ts delete mode 100644 server-node/src/middleware/auth.ts delete mode 100644 server-node/src/middleware/errorHandler.ts delete mode 100644 server-node/src/middleware/requestId.ts delete mode 100644 server-node/src/middleware/responseEnvelope.ts delete mode 100644 server-node/src/middleware/routeMeta.ts delete mode 100644 server-node/src/migrate.ts delete mode 100644 server-node/src/modules/ai/chatOrchestrator.ts delete mode 100644 server-node/src/modules/ai/chatPromptBuilders.ts delete mode 100644 server-node/src/modules/ai/customWorldOrchestrator.ts delete mode 100644 server-node/src/modules/ai/orchestrator.test.ts delete mode 100644 server-node/src/modules/ai/storyOrchestrator.ts delete mode 100644 server-node/src/modules/ai/storyPromptBuilders.test.ts delete mode 100644 server-node/src/modules/ai/storyPromptBuilders.ts delete mode 100644 server-node/src/modules/assets/characterAssetRoutes.test.ts delete mode 100644 server-node/src/modules/assets/characterAssetRoutes.ts delete mode 100644 server-node/src/modules/combat/combatResolutionService.ts delete mode 100644 server-node/src/modules/custom-world/creatorIntentRuntime.ts delete mode 100644 server-node/src/modules/custom-world/runtime-profile/buildAttributeSchema.ts delete mode 100644 server-node/src/modules/custom-world/runtime-profile/buildCompiledProfile.ts delete mode 100644 server-node/src/modules/custom-world/runtime-profile/creatorIntentBridge.ts delete mode 100644 server-node/src/modules/custom-world/runtime-profile/index.ts delete mode 100644 server-node/src/modules/custom-world/runtime-profile/normalizeCamp.ts delete mode 100644 server-node/src/modules/custom-world/runtime-profile/normalizeLandmark.ts delete mode 100644 server-node/src/modules/custom-world/runtime-profile/normalizeRole.ts delete mode 100644 server-node/src/modules/custom-world/runtime-profile/normalizeSceneChapter.ts delete mode 100644 server-node/src/modules/custom-world/runtime-profile/normalizeShared.ts delete mode 100644 server-node/src/modules/custom-world/runtimeProfile.test.ts delete mode 100644 server-node/src/modules/custom-world/runtimeProfile.ts delete mode 100644 server-node/src/modules/custom-world/runtimeTypes.ts delete mode 100644 server-node/src/modules/editor/editorRoutes.ts delete mode 100644 server-node/src/modules/inventory/inventoryMutationService.test.ts delete mode 100644 server-node/src/modules/inventory/inventoryMutationService.ts delete mode 100644 server-node/src/modules/inventory/inventoryStoryActionService.ts delete mode 100644 server-node/src/modules/inventory/npcInventoryStoryActionService.ts delete mode 100644 server-node/src/modules/npc/npcInteractionService.ts delete mode 100644 server-node/src/modules/npc/npcTask6Primitives.test.ts delete mode 100644 server-node/src/modules/npc/npcTask6Primitives.ts delete mode 100644 server-node/src/modules/progression/chapterProgressionPlanner.test.ts delete mode 100644 server-node/src/modules/progression/chapterProgressionPlanner.ts delete mode 100644 server-node/src/modules/progression/hostileProgressionService.test.ts delete mode 100644 server-node/src/modules/progression/hostileProgressionService.ts delete mode 100644 server-node/src/modules/progression/levelBenchmarks.ts delete mode 100644 server-node/src/modules/progression/npcLevelResolver.test.ts delete mode 100644 server-node/src/modules/progression/npcLevelResolver.ts delete mode 100644 server-node/src/modules/progression/playerProgressionService.test.ts delete mode 100644 server-node/src/modules/progression/playerProgressionService.ts delete mode 100644 server-node/src/modules/quest/questProgressionService.test.ts delete mode 100644 server-node/src/modules/quest/questProgressionService.ts delete mode 100644 server-node/src/modules/quest/questRuntimeSignalService.ts delete mode 100644 server-node/src/modules/quest/questStoryActionService.ts delete mode 100644 server-node/src/modules/quest/questTask6Bridge.ts delete mode 100644 server-node/src/modules/quest/runtimeQuestModule.ts delete mode 100644 server-node/src/modules/rpg-runtime-story/RpgRuntimeOptionCompiler.ts delete mode 100644 server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionDomain.test.ts delete mode 100644 server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionDomain.ts delete mode 100644 server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionLoader.ts delete mode 100644 server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionPrimitives.ts delete mode 100644 server-node/src/modules/rpg-runtime-story/RpgRuntimeSnapshotSync.ts delete mode 100644 server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryActionDomain.ts delete mode 100644 server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryActionService.ts delete mode 100644 server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryPresentationCompiler.ts delete mode 100644 server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryStateService.ts delete mode 100644 server-node/src/modules/runtime-item/runtimeItemModule.ts delete mode 100644 server-node/src/modules/runtime-item/runtimeTreasureModule.ts delete mode 100644 server-node/src/modules/runtime-item/treasureStoryActionService.ts delete mode 100644 server-node/src/modules/runtime/runtimeBuildModule.ts delete mode 100644 server-node/src/modules/runtime/runtimeEconomyPrimitives.test.ts delete mode 100644 server-node/src/modules/runtime/runtimeEconomyPrimitives.ts delete mode 100644 server-node/src/modules/runtime/runtimeEquipmentModule.ts delete mode 100644 server-node/src/modules/runtime/runtimeForgeModule.ts delete mode 100644 server-node/src/modules/runtime/runtimeInventoryEffectsModule.ts delete mode 100644 server-node/src/modules/runtime/runtimeNarrativeMemory.ts delete mode 100644 server-node/src/modules/runtime/runtimeNpcStatePrimitives.test.ts delete mode 100644 server-node/src/modules/runtime/runtimeNpcStatePrimitives.ts delete mode 100644 server-node/src/modules/runtime/runtimeSnapshotHydration.test.ts delete mode 100644 server-node/src/modules/runtime/runtimeSnapshotHydration.ts delete mode 100644 server-node/src/modules/runtime/runtimeStatePrimitives.test.ts delete mode 100644 server-node/src/modules/runtime/runtimeStatePrimitives.ts delete mode 100644 server-node/src/modules/runtime/runtimeTreasureTexts.ts delete mode 100644 server-node/src/observability.test.ts delete mode 100644 server-node/src/prompts/characterAssetPrompts.ts delete mode 100644 server-node/src/prompts/chatPromptBuilders.ts delete mode 100644 server-node/src/prompts/customWorldAgentPrompts.ts delete mode 100644 server-node/src/prompts/customWorldEntityPrompts.ts delete mode 100644 server-node/src/prompts/customWorldOrchestratorPrompts.ts delete mode 100644 server-node/src/prompts/customWorldPrompts.ts delete mode 100644 server-node/src/prompts/customWorldSceneNpcPrompts.ts delete mode 100644 server-node/src/prompts/eightAnchorPrompts.ts delete mode 100644 server-node/src/prompts/questPrompts.ts delete mode 100644 server-node/src/prompts/runtimeItemPrompts.ts delete mode 100644 server-node/src/prompts/storyOrchestratorPrompts.ts delete mode 100644 server-node/src/prompts/storyPromptBuilders.ts delete mode 100644 server-node/src/repositories/RpgAgentSessionRepository.ts delete mode 100644 server-node/src/repositories/RpgWorldProfileRepository.ts delete mode 100644 server-node/src/repositories/authAuditLogRepository.ts delete mode 100644 server-node/src/repositories/authIdentityRepository.ts delete mode 100644 server-node/src/repositories/authRiskBlockRepository.ts delete mode 100644 server-node/src/repositories/customWorldLibraryMetadata.test.ts delete mode 100644 server-node/src/repositories/customWorldLibraryMetadata.ts delete mode 100644 server-node/src/repositories/rpg-entry/RpgEntryRepositories.test.ts delete mode 100644 server-node/src/repositories/rpg-entry/RpgSaveArchiveRepository.ts delete mode 100644 server-node/src/repositories/rpg-entry/RpgWorldLibraryRepository.ts delete mode 100644 server-node/src/repositories/rpg-profile/RpgBrowseHistoryRepository.ts delete mode 100644 server-node/src/repositories/rpg-profile/RpgProfileDashboardRepository.ts delete mode 100644 server-node/src/repositories/rpg-profile/RpgProfileRepositories.test.ts delete mode 100644 server-node/src/repositories/rpg-runtime/RpgRuntimeSnapshotRepository.test.ts delete mode 100644 server-node/src/repositories/rpg-runtime/RpgRuntimeSnapshotRepository.ts delete mode 100644 server-node/src/repositories/rpgWorldRepositoryShared.ts delete mode 100644 server-node/src/repositories/runtimeRepository.ts delete mode 100644 server-node/src/repositories/smsAuthEventRepository.ts delete mode 100644 server-node/src/repositories/userRepository.ts delete mode 100644 server-node/src/repositories/userSessionRepository.ts delete mode 100644 server-node/src/routes/authRoutes.ts delete mode 100644 server-node/src/routes/bigFishProxyRoutes.ts delete mode 100644 server-node/src/routes/customWorldAgent.ts delete mode 100644 server-node/src/routes/puzzleProxyRoutes.ts delete mode 100644 server-node/src/routes/rpg-entry/rpgEntrySaveRoutes.ts delete mode 100644 server-node/src/routes/rpg-entry/rpgWorldLibraryRoutes.ts delete mode 100644 server-node/src/routes/rpg-profile/rpgProfileRoutes.ts delete mode 100644 server-node/src/routes/rpg-runtime/rpgRuntimeAiAssistRoutes.ts delete mode 100644 server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.test.ts delete mode 100644 server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.ts delete mode 100644 server-node/src/routes/rpgRouteBoundaries.test.ts delete mode 100644 server-node/src/server.ts delete mode 100644 server-node/src/services/RpgWorldPreviewCompiler.fixture.test.ts delete mode 100644 server-node/src/services/RpgWorldPreviewCompiler.test.ts delete mode 100644 server-node/src/services/RpgWorldPreviewCompiler.ts delete mode 100644 server-node/src/services/RpgWorldWorkCoverResolver.ts delete mode 100644 server-node/src/services/RpgWorldWorkSummaryAssembler.fixture.test.ts delete mode 100644 server-node/src/services/RpgWorldWorkSummaryAssembler.ts delete mode 100644 server-node/src/services/RpgWorldWorkSummaryService.ts delete mode 100644 server-node/src/services/captchaChallengeStore.ts delete mode 100644 server-node/src/services/chatService.test.ts delete mode 100644 server-node/src/services/chatService.ts delete mode 100644 server-node/src/services/customWorldAgentActionExecutors/draftFoundationExecutor.ts delete mode 100644 server-node/src/services/customWorldAgentActionExecutors/executorShared.ts delete mode 100644 server-node/src/services/customWorldAgentActionExecutors/expandLongTailExecutor.ts delete mode 100644 server-node/src/services/customWorldAgentActionExecutors/generateCharactersExecutor.ts delete mode 100644 server-node/src/services/customWorldAgentActionExecutors/generateLandmarksExecutor.ts delete mode 100644 server-node/src/services/customWorldAgentActionExecutors/generateRoleAssetsExecutor.ts delete mode 100644 server-node/src/services/customWorldAgentActionExecutors/generateSceneAssetsExecutor.ts delete mode 100644 server-node/src/services/customWorldAgentActionExecutors/helpers.ts delete mode 100644 server-node/src/services/customWorldAgentActionExecutors/index.ts delete mode 100644 server-node/src/services/customWorldAgentActionExecutors/publishWorldExecutor.ts delete mode 100644 server-node/src/services/customWorldAgentActionExecutors/revertCheckpointExecutor.ts delete mode 100644 server-node/src/services/customWorldAgentActionExecutors/syncResultProfileExecutor.ts delete mode 100644 server-node/src/services/customWorldAgentActionExecutors/syncRoleAssetsExecutor.ts delete mode 100644 server-node/src/services/customWorldAgentActionExecutors/syncSceneAssetsExecutor.ts delete mode 100644 server-node/src/services/customWorldAgentActionExecutors/types.ts delete mode 100644 server-node/src/services/customWorldAgentActionExecutors/updateDraftCardExecutor.ts delete mode 100644 server-node/src/services/customWorldAgentActionRegistry.test.ts delete mode 100644 server-node/src/services/customWorldAgentActionRegistry.ts delete mode 100644 server-node/src/services/customWorldAgentAssetBridgeService.ts delete mode 100644 server-node/src/services/customWorldAgentAutoAssetService.test.ts delete mode 100644 server-node/src/services/customWorldAgentAutoAssetService.ts delete mode 100644 server-node/src/services/customWorldAgentChangeSummaryService.ts delete mode 100644 server-node/src/services/customWorldAgentClarificationService.ts delete mode 100644 server-node/src/services/customWorldAgentDraftCompiler.test.ts delete mode 100644 server-node/src/services/customWorldAgentDraftCompiler.ts delete mode 100644 server-node/src/services/customWorldAgentDraftEditService.ts delete mode 100644 server-node/src/services/customWorldAgentEntityGenerationService.ts delete mode 100644 server-node/src/services/customWorldAgentFoundationDraftService.test.ts delete mode 100644 server-node/src/services/customWorldAgentFoundationDraftService.ts delete mode 100644 server-node/src/services/customWorldAgentIntentExtractionService.ts delete mode 100644 server-node/src/services/customWorldAgentMessageTurnService.ts delete mode 100644 server-node/src/services/customWorldAgentOrchestrator.ts delete mode 100644 server-node/src/services/customWorldAgentPhase2.test.ts delete mode 100644 server-node/src/services/customWorldAgentPhase3.test.ts delete mode 100644 server-node/src/services/customWorldAgentPhase4.test.ts delete mode 100644 server-node/src/services/customWorldAgentPhase5.test.ts delete mode 100644 server-node/src/services/customWorldAgentPublishingService.ts delete mode 100644 server-node/src/services/customWorldAgentQualityGateService.ts delete mode 100644 server-node/src/services/customWorldAgentRepositoryTestHelpers.ts delete mode 100644 server-node/src/services/customWorldAgentResultSyncService.test.ts delete mode 100644 server-node/src/services/customWorldAgentResultSyncService.ts delete mode 100644 server-node/src/services/customWorldAgentRoleAssetStateService.test.ts delete mode 100644 server-node/src/services/customWorldAgentRoleAssetStateService.ts delete mode 100644 server-node/src/services/customWorldAgentSessionStore.ts delete mode 100644 server-node/src/services/customWorldAgentSnapshotBuilder.ts delete mode 100644 server-node/src/services/customWorldAgentSuggestedActionService.ts delete mode 100644 server-node/src/services/customWorldAgentTestHelpers.ts delete mode 100644 server-node/src/services/customWorldCoverAssetService.test.ts delete mode 100644 server-node/src/services/customWorldCoverAssetService.ts delete mode 100644 server-node/src/services/customWorldEntityGenerationService.test.ts delete mode 100644 server-node/src/services/customWorldEntityGenerationService.ts delete mode 100644 server-node/src/services/customWorldSceneNpcGenerationService.ts delete mode 100644 server-node/src/services/customWorldWorkSummaryService.integration.test.ts delete mode 100644 server-node/src/services/eightAnchorCompatibilityService.ts delete mode 100644 server-node/src/services/eightAnchorPromptBuilder.ts delete mode 100644 server-node/src/services/eightAnchorSingleTurnService.test.ts delete mode 100644 server-node/src/services/eightAnchorSingleTurnService.ts delete mode 100644 server-node/src/services/llmClient.ts delete mode 100644 server-node/src/services/questService.ts delete mode 100644 server-node/src/services/rpg-agent-session-store/rpgAgentSessionCompatibility.test.ts delete mode 100644 server-node/src/services/rpg-agent-session-store/rpgAgentSessionCompatibility.ts delete mode 100644 server-node/src/services/rpg-agent-session-store/rpgAgentSessionFactory.ts delete mode 100644 server-node/src/services/rpg-agent-session-store/rpgAgentSessionRecord.ts delete mode 100644 server-node/src/services/rpg-agent-session-store/rpgAgentSessionRepositoryAdapter.ts delete mode 100644 server-node/src/services/rpgCreationPreviewProfileBuilder.ts delete mode 100644 server-node/src/services/runtimeItemService.ts delete mode 100644 server-node/src/services/sceneImageService.test.ts delete mode 100644 server-node/src/services/sceneImageService.ts delete mode 100644 server-node/src/services/smsVerificationService.test.ts delete mode 100644 server-node/src/services/smsVerificationService.ts delete mode 100644 server-node/src/services/storyService.ts delete mode 100644 server-node/src/services/wechatAuthService.ts delete mode 100644 server-node/src/services/wechatAuthStateStore.ts delete mode 100644 server-node/src/testFixtures/runtimeCharacter.ts delete mode 100644 server-node/src/testHttp.ts delete mode 100644 server-node/src/types/express.d.ts delete mode 100644 server-node/test.mjs delete mode 100644 server-node/tsconfig.json create mode 100644 server-rs/crates/api-server/src/runtime_chat_prompt.rs delete mode 100644 view-llm-logs.ps1 diff --git a/.env.example b/.env.example index d84b691f..1be72344 100644 --- a/.env.example +++ b/.env.example @@ -13,15 +13,9 @@ VITE_LLM_PROXY_BASE_URL="/api/llm" # Optional frontend override for the local custom-world scene image proxy path. VITE_SCENE_IMAGE_PROXY_BASE_URL="/api/custom-world/scene-image" -# Legacy Node backend address. Do not use for new runtime routes. -NODE_SERVER_ADDR=":8081" -NODE_SERVER_TARGET="http://127.0.0.1:8081" - -# Backend switch for local dev proxy. -# Runtime API routes default to Rust Axum api-server. -GENARRATIVE_BACKEND_STACK="rust" +# Runtime API routes use Rust Axum api-server. RUST_SERVER_TARGET="http://127.0.0.1:3100" -# Optional hard override. When set, it wins over GENARRATIVE_BACKEND_STACK/NODE_SERVER_TARGET/RUST_SERVER_TARGET. +# Optional hard override. When set, it wins over RUST_SERVER_TARGET. GENARRATIVE_RUNTIME_SERVER_TARGET="" # Rust api-server local target used by the Big Fish / Puzzle compatibility gateways @@ -37,18 +31,14 @@ GENARRATIVE_SPACETIME_DATABASE="genarrative-dev" GENARRATIVE_SPACETIME_POOL_SIZE="4" # Local Caddy upstream target used for dist-based testing. -CADDY_API_UPSTREAM="http://127.0.0.1:8081" +CADDY_API_UPSTREAM="http://127.0.0.1:3100" # Editor and asset tool APIs. Defaults are enabled outside production and # disabled in production unless explicitly enabled. EDITOR_API_ENABLED="true" ASSETS_API_ENABLED="true" -# Node backend PostgreSQL connection string. -# Runtime persistence now uses PostgreSQL as the only formal backend baseline. -DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:5432/genarrative" - -# Node backend JWT settings. +# Rust api-server JWT settings. JWT_SECRET="CHANGE_ME_FOR_PRODUCTION" # Access token 有效期。 JWT_EXPIRES_IN="2h" diff --git a/.env.local b/.env.local index fe798ce3..cc41c716 100644 --- a/.env.local +++ b/.env.local @@ -34,8 +34,6 @@ ALIYUN_SMS_RETURN_VERIFY_CODE="false" VITE_AUTH_ALLOW_DEV_GUEST="false" -DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:5432/genarrative" - # 启用服务端大模型调试日志(记录所有输入输出) LLM_DEBUG_LOG="true" @@ -49,7 +47,6 @@ ALIYUN_OSS_ACCESS_KEY_ID="LTAI5t7aiyw6uDFW4miJvU8f" ALIYUN_OSS_ACCESS_KEY_SECRET="XblWGE6CO1WLnSBdMRVpL6lut4GSoS" # Local Rust backend target for Vite dev proxy. -GENARRATIVE_BACKEND_STACK="rust" RUST_SERVER_TARGET="http://127.0.0.1:3100" GENARRATIVE_API_TARGET="http://127.0.0.1:3100" diff --git a/.gitignore b/.gitignore index 6e5a2cbc..2ff401b5 100644 --- a/.gitignore +++ b/.gitignore @@ -20,11 +20,6 @@ temp-build-goal-check/ *.py[cod] /public/generated-custom-world-scenes temp*build*/ -/server-node/dist/ -/server-node/logs/* -!/server-node/logs/.gitkeep -/server-node/data/* -!/server-node/data/.gitkeep /server-rs/target/ /server-rs/.spacetimedb/ /server-rs/.data/ diff --git a/README.md b/README.md index c9b219e4..3f07df16 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ 前置条件: - Node.js +- Rust / Cargo +- SpacetimeDB CLI 安装依赖: @@ -42,9 +44,8 @@ npm run dev 补充说明: -- `npm run dev` 会同时启动 Vite 与 Express 后端,适合完整联调。 -- 如果没有显式配置 `DATABASE_URL`,且本机 `PostgreSQL` 不可用,开发模式会自动回退到内存版 `pg-mem`,方便先跑通鉴权与存档主链。 -- 如果只想单独启动前端页面,可使用 `npm run dev:web`。 +- `npm run dev` 会启动 SpacetimeDB standalone、Rust `api-server` 与 Vite 前端,适合完整联调。 +- 如果只想单独启动前端页面,可使用 `npm run dev:web`,默认代理到本地 Rust `api-server`。 构建生产包: diff --git a/bash.exe.stackdump b/bash.exe.stackdump new file mode 100644 index 00000000..eb999467 --- /dev/null +++ b/bash.exe.stackdump @@ -0,0 +1,28 @@ +Stack trace: +Frame Function Args +0007FFFFB520 00021005FE8E (000210285F68, 00021026AB6E, 000000000000, 0007FFFFA420) msys-2.0.dll+0x1FE8E +0007FFFFB520 0002100467F9 (000000000000, 000000000000, 000000000000, 0007FFFFB7F8) msys-2.0.dll+0x67F9 +0007FFFFB520 000210046832 (000210286019, 0007FFFFB3D8, 000000000000, 000000000000) msys-2.0.dll+0x6832 +0007FFFFB520 000210068CF6 (000000000000, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x28CF6 +0007FFFFB520 000210068E24 (0007FFFFB530, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x28E24 +0007FFFFB800 00021006A225 (0007FFFFB530, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x2A225 +End of stack trace +Loaded modules: +000100400000 bash.exe +7FFA3C060000 ntdll.dll +7FFA3B490000 KERNEL32.DLL +7FFA390F0000 KERNELBASE.dll +7FFA3BE50000 USER32.dll +7FFA38E90000 win32u.dll +7FFA3A230000 GDI32.dll +7FFA38D60000 gdi32full.dll +7FFA38EC0000 msvcp_win.dll +7FFA38930000 ucrtbase.dll +000210040000 msys-2.0.dll +7FFA39EB0000 advapi32.dll +7FFA3A180000 msvcrt.dll +7FFA3BCA0000 sechost.dll +7FFA3B5F0000 RPCRT4.dll +7FFA37D70000 CRYPTBASE.DLL +7FFA38B40000 bcryptPrimitives.dll +7FFA3A260000 IMM32.DLL diff --git a/docs/README.md b/docs/README.md index 84bb39a1..3d74691c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,6 @@ # 文档总览 -`docs/` 现在按主题拆成了 6 类,`docs/prd/` 保持独立,不参与本次整理改写。 +`docs/` 现在按主题拆成了 6 类;旧后端路线文档开始聚合和删除,后续实现以 Rust / SpacetimeDB 当前基线为准。 ## 快速入口 @@ -10,15 +10,15 @@ - [技术方案](./technical/README.md):动画、服务端、外部产品形态拆解。 - [规划与优先级](./planning/README.md):当前阶段的迭代排序与落地优先级。 - [参考目录](./reference/README.md):脚本/Function 速查入口。 -- [PRD](./prd/):产品需求与阶段计划,原样保留。 +- [PRD](./prd):产品需求与阶段计划。 ## 推荐阅读顺序 1. 先看 [经验沉淀](./experience/README.md),快速建立这个项目的开发共识。 2. 再看 [工程审查总览](./audits/engineering/README.md) 和 [文本审计总览](./audits/text/README.md),了解当前风险。 3. 需要排期时看 [规划与优先级](./planning/README.md)。 -4. 需要补方案时进入 [系统设计](./design/README.md) / [技术方案](./technical/README.md)。 -5. 需要对齐目标边界时再进入 [PRD](./prd/)。 +4. 需要补方案时进入 [系统设计](./design/README.md) / [技术方案](./technical/README.md);涉及后端先看 [当前后端实现基线](./technical/CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md)。 +5. 需要对齐目标边界时再进入 [PRD](./prd)。 ## 分类规则 diff --git a/docs/audits/FUNCTION_DESIGN_AUDIT_2026-04-03.md b/docs/audits/FUNCTION_DESIGN_AUDIT_2026-04-03.md index 85f46480..d4ff3621 100644 --- a/docs/audits/FUNCTION_DESIGN_AUDIT_2026-04-03.md +++ b/docs/audits/FUNCTION_DESIGN_AUDIT_2026-04-03.md @@ -7,7 +7,7 @@ - `docs/experience/ADVENTURE_RUNTIME_DEV_EXPERIENCE.md` - `docs/experience/PROJECT_WORK_EXPERIENCE_PLAYBOOK.md` - `docs/experience/PROJECT_DEVELOPMENT_EXPERIENCE.md` -- `docs/planning/CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md` +- `docs/audits/engineering/README.md` - `src/data/stateFunctions.ts` - `src/data/npcInteractions.ts` - `src/data/treasureInteractions.ts` diff --git a/docs/audits/FUNCTION_REQUIREMENT_COMPLETENESS_AUDIT_2026-04-14.md b/docs/audits/FUNCTION_REQUIREMENT_COMPLETENESS_AUDIT_2026-04-14.md index 61f1cb76..20e55c4e 100644 --- a/docs/audits/FUNCTION_REQUIREMENT_COMPLETENESS_AUDIT_2026-04-14.md +++ b/docs/audits/FUNCTION_REQUIREMENT_COMPLETENESS_AUDIT_2026-04-14.md @@ -9,7 +9,7 @@ - `docs/audits/FUNCTION_DESIGN_AUDIT_2026-04-03.md` - `docs/reference/FUNCTION_SCRIPT_CATALOG_2026-04-04.md` - `docs/experience/ADVENTURE_RUNTIME_DEV_EXPERIENCE.md` -- `docs/planning/CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md` +- `docs/audits/engineering/README.md` 本次实际核对了这些实现入口: diff --git a/docs/audits/README.md b/docs/audits/README.md index 998b2de7..1c9b4df8 100644 --- a/docs/audits/README.md +++ b/docs/audits/README.md @@ -4,7 +4,7 @@ ## 系列总览 -- [engineering/README.md](./engineering/README.md):工程优化审查三轮记录的融合入口。 +- [engineering/README.md](./engineering/README.md):当前工程优化审查与历史结论聚合入口。 - [text/README.md](./text/README.md):文本、英文残留、乱码审计系列的融合入口。 ## 专项审计 diff --git a/docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md b/docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md index da119699..ac5bd098 100644 --- a/docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md +++ b/docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md @@ -349,7 +349,7 @@ 文档依据: 1. `docs/audits/engineering/README.md` -2. `docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_PRIORITIES_2026-04-10.md` +2. `docs/technical/CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md` 3. `docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md` 4. `docs/experience/PROJECT_WORK_EXPERIENCE_PLAYBOOK.md` diff --git a/docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_PRIORITIES_2026-04-10.md b/docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_PRIORITIES_2026-04-10.md deleted file mode 100644 index d06ee069..00000000 --- a/docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_PRIORITIES_2026-04-10.md +++ /dev/null @@ -1,256 +0,0 @@ -# 当前工程优化优先级汇总(2026-04-10) - -## 结论先说 - -和 `2026-04-01` 那轮工程审查相比,当前仓库的主问题已经发生了明显迁移: - -- 运行时主链拆分已经有进展,`useStoryGeneration.ts` 不再是最高复杂度热点。 -- `typecheck`、前后端测试、内容校验、编码校验都已经回到可通过状态。 -- 当前真正卡住工程节奏的,已经变成: - - 绿色门禁不可信 - - 构建 warning 仍然会直接打断发布门禁 - - 自定义世界 / 编辑器 / 资产链路出现了新的巨型模块热点 - - 生成产物与旧工具链残留开始反向污染 lint、watch 和本地开发信号 - -一句话判断: - -**现在最该优先做的,不是继续扩功能,而是先把门禁重新拉回可信状态,再拆 editor / custom world / assets 这批新的复杂度中心。** - ---- - -## 2026-04-10 当前校验快照 - -本次汇总不是只复述旧文档,额外执行了当前仓库校验命令。 - -| 项目 | 结果 | 说明 | -| --- | --- | --- | -| `npm run check:encoding` | 通过 | 编码基线正常 | -| `npm run typecheck` | 通过 | 当前严格类型门禁可通过 | -| `npm run test` | 通过 | `92` 个测试文件、`228` 个测试通过 | -| `npm run server-node:test:baseline` | 通过 | 观测基线正常 | -| `npm run server-node:test` | 通过 | `72` 个后端测试通过 | -| `npm run check:content` | 通过 | 内容与覆盖校验正常 | -| `npm run lint:eslint` | 失败 | `330` 个 error、`4` 个 warning | -| `npm run build` | 失败 | 构建完成,但因 warning 被 `build-gate` 拦截 | - -当前状态说明: - -- 仓库不是“完全不可用”,而是已经进入“测试绿,但门禁信号不一致”的阶段。 -- 这类状态比纯红线更危险,因为团队会误以为主链已经稳定。 - ---- - -## P0:先恢复可信的绿色门禁 - -### P0-1:修复 lint 失真,重新建立可信基线 - -这是当前第一优先级。 - -#### 证据 - -- `npm run lint:eslint` 当前失败,报出 `330` 个 error、`4` 个 warning。 -- 问题既有真实源码问题,也有明显的门禁污染: - - `src/`、`server-node/`、`scripts/` 中存在 import 排序、未使用导入、少量 hook 规则问题。 - - `temp-build-goal-check/` 这类生成产物目录也被 ESLint 扫描进来,放大了噪音。 -- `.eslintrc.cjs` 当前忽略了 `dist`、`media` 等目录,但没有忽略 `temp-build-goal-check`。 -- `vite.config.ts` 的 `server.watch.ignored` 已经忽略了 `**/temp*build*/**`,说明当前 watch 口径和 lint 口径并不一致。 - -#### 影响 - -- 团队无法快速判断“现在是源码真问题,还是产物目录噪音”。 -- lint 失真会直接削弱 review、回归和集成效率。 -- 在这种状态下继续加功能,只会让真实错误被更多噪音淹没。 - -#### 当前建议 - -1. 先清理或迁出 `temp-build-goal-check/` 这类生成产物目录,至少不要再让它进入 lint 扫描范围。 -2. 统一 `watch / lint / build` 对临时目录和生成目录的忽略口径。 -3. 再集中清当前源码层 lint 问题,优先处理: - - import 排序 - - 未使用导入 - - 少量真实规则错误,例如 hook 误用和 `ban-types` - ---- - -### P0-2:修复构建 warning,恢复可发布构建 - -这是和 P0-1 同级的阻塞项。 - -#### 证据 - -- `npm run build` 当前会被 `scripts/build-gate.mjs` 拦截。 -- 当前构建输出里最关键的 warning 有两类: - - `src/services/ai.ts` 虽然尝试走动态加载,但又被 `src/components/CustomWorldEntityEditorModal.tsx` 静态引入,导致拆包失效。 - - `AuthenticatedApp-*.js` 达到 `1078.61 kB`,超过当前 `750 kB` 的 chunk warning 门槛。 -- 同轮构建里,`index-*.css` 也已经达到 `157.56 kB`,说明不仅 JS 主块重,样式也在继续膨胀。 - -#### 影响 - -- 当前不是“构建有一点 warning 可以先带着走”,而是发布门禁已经被 warning 直接打断。 -- editor / custom world / asset 工具能力正在把非主链代码重新带回主包路径。 -- 后续如果继续叠加这条链路,首屏、缓存和回归都会继续变差。 - -#### 当前建议 - -1. 先切断 `CustomWorldEntityEditorModal.tsx -> ../services/ai` 的静态依赖,让 `ai.ts` 真正留在懒加载路径。 -2. 把自定义世界编辑器、资产工作台、非首屏工具能力继续从 `AuthenticatedApp` 主块中拆出。 -3. 保持 `build warning = 失败` 的策略,不建议通过放宽阈值掩盖问题。 - ---- - -## P1:拆掉新的复杂度中心 - -### P1-1:优先拆 editor / custom world / assets 新热点 - -旧的运行时主链热点已经有所缓解,但复杂度并没有消失,而是转移到了新的模块上。 - -#### 当前大文件热点 - -前端: - -- `src/components/CustomWorldEntityEditorModal.tsx`:`2778` 行 -- `src/services/ai.ts`:`2454` 行 -- `src/services/customWorld.ts`:`2217` 行 -- `src/data/npcInteractions.ts`:`2103` 行 -- `src/data/characterPresets.ts`:`1953` 行 -- `src/services/prompt.ts`:`1725` 行 - -后端: - -- `server-node/src/modules/assets/characterAssetRoutes.ts`:`2295` 行 -- `server-node/src/app.test.ts`:`1527` 行 -- `server-node/src/auth/authService.ts`:`1243` 行 -- `server-node/src/modules/quest/runtimeQuestModule.ts`:`1137` 行 - -工具链: - -- `scripts/dev-server/*.ts`:已于 `2026-04-19` 删除,旧 Vite 本地 API 链路不再保留实现代码 - -#### 影响 - -- 复杂度并没有真正被消灭,而是从运行时 story hook 转移到了自定义世界、资产编辑、提示词和数据装配链。 -- 这些文件大多同时承载了: - - 领域规则 - - API 调用 - - 文本拼装 - - UI 状态 - - 工具流程 -- 后续任何一个小改动,都容易牵动整条大链,回归成本会再次上升。 - -#### 当前建议 - -1. 前端优先拆 `CustomWorldEntityEditorModal.tsx`,按“世界锚点 / 角色 / 地点 / 资产 / 高级设置”分段。 -2. 后端优先拆 `characterAssetRoutes.ts`,把 route、job orchestration、文件发布、模板读取拆开。 -3. 把 `src/services/ai.ts` 和 `src/services/customWorld.ts` 继续按运行时 / 编辑器 / 资产工具三条职责分层。 - ---- - -### P1-2:继续收口 editor / assets 工具链边界(旧链路已删除) - -这项的重要性正在上升。 - -#### 证据 - -- `docs/technical/EDITOR_ASSET_API_MIGRATION_2026-04-08.md` 已说明 editor/assets API 已经迁到 `server-node`,方向是对的。 -- `scripts/dev-server/*.ts` 旧 Vite 本地 API 实现代码已于 `2026-04-19` 删除,仓库里不再保留并行实现。 -- 目录 `temp-build-goal-check/` 当前包含 `15099` 个文件,已经开始干扰 lint 和本地开发信号。 -- 相关日志里还出现了大量指向 `temp-build-goal-check` 的页面 reload 与 `ENOENT` 噪音。 - -#### 影响 - -- editor/assets 正式入口已经收口到 `server-node`,这部分双链路问题已解除。 -- 当前更大的噪音来源已经转移到临时构建目录、检查目录和历史日志残留。 - -#### 当前建议 - -1. 保持 `scripts/dev-server/README.md` 作为迁移结果标记,不要恢复旧 Vite `/api/*` 本地插件链。 -2. 将临时构建目录、检查目录、导出目录统一移出主工程扫描面。 -3. 继续以 `server-node/src/modules/editor/**`、`server-node/src/modules/assets/**` 与 `src/editor/shared/editorApiClient.ts` 作为唯一推荐入口,减少后续回流。 - ---- - -## P2:继续做架构收口,但不必抢在 P0 前面 - -### P2-1:继续压缩前端遗留 AI / 自定义世界实现 - -这一项仍然值得做,但当前不再是最前面的阻塞。 - -#### 原因 - -- `docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md` 显示正式运行时主链已经大幅回收到后端。 -- 当前更明显的遗留,已经集中到编辑器、自定义世界工作台和资产工具,而不是正式运行时 story 主链。 - -#### 当前建议 - -1. 继续让正式运行时保持“后端为真相源”。 -2. 对仍留在前端的大 AI / prompt / custom world 实现,优先做职责收缩,而不是继续在原文件上堆逻辑。 - ---- - -### P2-2:继续优化自定义世界工作台,但以“减负”和“分层”为主 - -这一项更适合作为 P0、P1 稳住后的下一轮重点。 - -#### 依据 - -- `docs/audits/CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md` 已经明确指出: - - 自定义世界入口、澄清、锁定、局部重生成、结果工作台仍是半收口状态。 -- 当前最大的前端热点文件也集中在这条链路上,说明它已经不仅是产品问题,也是工程复杂度问题。 - -#### 当前建议 - -1. 优先减少“大一统编辑弹窗”的职责,把高杠杆编辑和高级编辑分层。 -2. 让自定义世界生成、锁定、局部重生成规则继续向后端收口。 -3. 移动端优先,避免长表单和重弹窗继续吞掉维护成本。 - ---- - -## 推荐执行顺序 - -### 第一阶段:先把门禁拉回可信 - -1. 修 lint 口径失真 -2. 清生成产物扫描污染 -3. 修 build warning - -### 第二阶段:再拆新的复杂度中心 - -1. 拆 `CustomWorldEntityEditorModal.tsx` -2. 拆 `characterAssetRoutes.ts` -3. 收缩 `src/services/ai.ts` / `src/services/customWorld.ts` - -### 第三阶段:最后收 editor / custom world 架构尾巴 - -1. 清理旧 Vite 工具链残留 -2. 继续把自定义世界和资产工具收回正式后端边界 - ---- - -## 当前不建议优先做的事 - -- 不建议在当前 lint 与 build 仍然是红线时继续横向扩 editor / custom world 功能。 -- 不建议通过放宽 chunk warning 阈值来“修复”构建。 -- 不建议继续在 `CustomWorldEntityEditorModal.tsx`、`src/services/ai.ts`、`characterAssetRoutes.ts` 这类巨型文件中直接堆新逻辑。 - ---- - -## 本文依据 - -文档依据: - -- `docs/audits/engineering/ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md` -- `docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md` -- `docs/technical/EDITOR_ASSET_API_MIGRATION_2026-04-08.md` -- `docs/audits/CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md` -- `docs/planning/CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md` - -当前仓库校验依据: - -- `npm run check:encoding` -- `npm run typecheck` -- `npm run test` -- `npm run server-node:test:baseline` -- `npm run server-node:test` -- `npm run check:content` -- `npm run lint:eslint` -- `npm run build` diff --git a/docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md b/docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md index 58a6ce54..811c7242 100644 --- a/docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md +++ b/docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md @@ -124,9 +124,8 @@ 本次审计结合了四类证据: 1. 文档基线: - - `docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_PRIORITIES_2026-04-10.md` - - `docs/planning/EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md` - - `docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md` + - `docs/audits/engineering/README.md` + - `docs/technical/CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md` - `scripts/dev-server/README.md` 2. 当前入口核对: - `src/main.tsx` @@ -591,10 +590,9 @@ 文档依据: -1. `docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_PRIORITIES_2026-04-10.md` -2. `docs/planning/EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md` -3. `docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md` -4. `scripts/dev-server/README.md` +1. `docs/audits/engineering/README.md` +2. `docs/technical/CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md` +3. `scripts/dev-server/README.md` 当前仓库扫描依据: diff --git a/docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md b/docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md index a7538437..0177754d 100644 --- a/docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md +++ b/docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md @@ -366,9 +366,8 @@ 文档依据: 1. `docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md` -2. `docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_PRIORITIES_2026-04-10.md` -3. `docs/planning/EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md` -4. `docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md` +2. `docs/audits/engineering/README.md` +3. `docs/technical/CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md` 当前仓库复核依据: @@ -381,4 +380,3 @@ 7. `src/services/runtimeItemAiDirector.ts` 8. `src/services/apiClient.ts` 9. 当前依赖图扫描结果与当前大文件体量扫描结果 - diff --git a/docs/audits/engineering/ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md b/docs/audits/engineering/ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md deleted file mode 100644 index 14871a1f..00000000 --- a/docs/audits/engineering/ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md +++ /dev/null @@ -1,278 +0,0 @@ -# 工程优化审查报告(2026-03-29) - -## 说明 - -- 扫描范围:`src/`、`scripts/`、`docs/`、`package.json`、`vite.config.ts`、`tsconfig.json` -- 已执行校验:`npm run lint`、`npm run build`、`npm run check:content` -- 本报告只从工程角度讨论结构、边界、质量门禁、可维护性与可扩展性 -- 按仓库说明,暂不讨论中文乱码本身 - -## 当前结论 - -项目当前**可构建、可运行、内容校验可通过**,说明基础功能链路是通的;但从工程视角看,已经出现明显的“单点过重、边界混杂、质量门禁偏弱、编辑器与运行时耦合”问题。继续叠需求会越来越依赖人工记忆和局部经验,回归风险会持续上升。 - -当前最值得优先处理的不是单个 UI 细节,而是以下四个工程主题: - -1. 运行时主链路的职责拆分还不够,核心 hook / 组件已经过载 -2. 缺少真正的工程质量门禁,`lint` 目前本质上只是 `tsc` -3. 编辑器、运行时、类后端能力都混在同一个 Vite 配置里 -4. 持久化、AI 调用、编辑器保存等基础设施仍然是“分散手写” - -## 运行状态快照 - -- `npm run lint` 通过 -- `npm run build` 通过 -- `npm run check:content` 通过 -- 应用代码下未发现测试文件:`src/`、`scripts/`、`docs/` 内没有 `*.test.*` / `*.spec.*` -- 构建产物已出现较大 chunk -- `dist/assets/App-*.js` 约 `407 KB` -- `dist/assets/itemCatalog-*.js` 约 `414 KB` -- `dist/assets/PresetEditor-*.js` 约 `109 KB` - -## 代码体征 - -下列文件已经明显进入“超大模块”区间: - -| 文件 | 行数 | 观察 | -| --- | ---: | --- | -| `src/hooks/useStoryGeneration.ts` | 3304 | 同时管理剧情、NPC 交互、交易、送礼、招募、任务、角色聊天、道具/锻造接入 | -| `src/components/PresetEditor.tsx` | 2244 | 多编辑器入口聚合在一个巨型组件中 | -| `src/hooks/useCombatFlow.ts` | 1791 | 同时承担战斗推演、动画时序、逃跑演出、状态落地 | -| `src/components/GameShell.tsx` | 1592 | 入口 UI、选角、世界选择、自定义世界、场景切换、浮层控制全部集中 | -| `src/types.ts` | 663 | 运行时、AI、编辑器、自定义世界、背包、任务类型集中在一个总文件 | - -补充信号: - -- `src/components/GameShell.tsx` 内有 16 个 `useState`、10 个 `useEffect`、13 个 `useMemo` -- `src/hooks/useStoryGeneration.ts` 虽然只有少量 React state,但内部累计 40+ 个函数,已经是“巨型流程控制器” -- `src/hooks/useCombatFlow.ts` 内有大量时间常量、动画常量、`sleep + setGameState` 过程式循环,测试成本很高 - -## 优先级问题 - -## P0:运行时主链路职责过度集中 - -证据: - -- `src/hooks/useStoryGeneration.ts:868-930` 进入 hook 后立即开始定义交易、送礼、招募、角色聊天等子流程 -- `src/hooks/useStoryGeneration.ts:3191-3303` 返回对象同时暴露剧情、任务、NPC UI、角色聊天 UI、背包/锻造 UI -- `src/components/GameShell.tsx:293-360` 组件 props 很多,内部 state 也很多,承担“壳层 + 流程 + 浮层 + 自定义世界生成 + 场景切换” -- `src/hooks/useCombatFlow.ts:559-1787` 将战斗计算和战斗演出揉在同一层里 - -影响: - -- 任何一个新需求都容易同时碰到剧情、UI、战斗、背包、NPC 关系四五条链路 -- 代码 review 很难聚焦,改动一处时往往需要脑内跟完整条大流程 -- 单元测试难写,因为逻辑不是纯函数,而是大量闭包 + 过程式状态推进 -- 长期会形成“只有熟悉历史上下文的人才能安全修改”的隐性门槛 - -建议: - -- 将 `useStoryGeneration` 拆为“剧情推进”“NPC 交互”“角色聊天”“任务结算”“模态框控制”几个子域 -- 将 `useCombatFlow` 拆成“纯战斗结算引擎”和“战斗播放适配层” -- 让 `GameShell` 回到壳层职责,只负责路由态、页面态、模态挂载与 props 编排 -- 以“领域职责”拆分,而不是按“文件太长了随便切一刀”拆分 - -## P0:缺少真正的工程质量门禁 - -证据: - -- `package.json:11` 的 `lint` 实际只有 `tsc --noEmit` -- `package.json` 中没有 `test`、`format`、`lint:fix` 等基础脚本 -- 根目录未发现 `.eslintrc*`、`.prettierrc*`、`.editorconfig` -- 代码目录下没有测试文件 - -影响: - -- 当前项目的“能过 lint”只代表类型没炸,不代表风格一致、依赖正确、Hooks 规则正确、死代码已清理 -- 大型 hook / 大型组件的重构几乎没有自动回归保护 -- 运行时行为、编辑器行为、AI fallback 行为主要依赖人工回归 - -建议: - -- 补齐 ESLint、Prettier、EditorConfig,至少覆盖 React Hooks、import、unused code、复杂度基线 -- 引入 Vitest,先覆盖纯数据层与纯规则层 -- 为 `useCombatFlow`、`stateFunctions`、`npcInteractions`、`questFlow` 增加单元测试 -- 为“开局 -> 选世界 -> 选角色 -> 进入剧情 -> 战斗 -> 存档恢复”补最小 E2E smoke -- CI 中至少串联:类型检查 + 单测 + build + 内容校验 - -## P1:编辑器、运行时、类后端能力全部耦合在 Vite 配置里 - -证据: - -- `vite.config.ts:151-203` 在 Vite 插件里实现了 LLM 代理 -- `vite.config.ts:206-269` 在 Vite 插件里实现了通用 JSON 文件读写 API -- `vite.config.ts:253` 直接写回 `src/data/*.json` -- `vite.config.ts:265-266` 和 `vite.config.ts:400-401` 在 `preview` 阶段也挂了这些接口 -- `vite.config.ts:425-434` 启动时默认把这些“编辑器后端能力”全部注册进去 - -影响: - -- 本地编辑器能力与运行时能力没有清晰边界 -- `preview` 环境仍可写源码文件,发布边界不清晰 -- 未来如果要做独立部署、多人协作、远程编辑、权限控制,会非常难迁移 -- Vite 配置同时扮演构建配置、代理层、文件服务层、编辑器后端,职责失衡 - -建议: - -- 将编辑器读写 API 从 `vite.config.ts` 抽到独立的本地工具服务或独立脚本 -- 至少区分 `dev-only write api` 与 `preview/prod read-only api` -- 对编辑器保存接口建立统一客户端 SDK,避免组件直接散落 `fetch('/api/...')` -- LLM 代理也建议独立成 `server/` 或 `scripts/dev-server/`,不要继续长在构建配置里 - -## P1:持久化策略分散,且直接序列化大状态对象 - -证据: - -- `src/hooks/useGamePersistence.ts:152-167` 会在状态变化时自动把完整快照写入 `localStorage` -- `src/hooks/useGamePersistence.ts:157-163` 快照包含 `gameState + bottomTab + currentStory` -- `src/hooks/useGamePersistence.ts:68-116` 恢复逻辑已经开始承担大量 schema 纠偏职责 -- `src/data/customWorldLibrary.ts:1-282` 自定义世界库单独维护一套 `localStorage` 读写与 normalize -- `src/hooks/useGameSettings.ts` 也单独维护一套本地设置持久化 - -影响: - -- 状态结构一旦继续膨胀,快照写入频率和反序列化成本都会增加 -- schema 迁移会越来越依赖手工 normalize 补丁 -- 不同持久化入口各写一套 parser / normalizer,风格和鲁棒性难统一 -- 当前保存的是“运行中大对象”,而不是“稳定领域快照”,长期会放大兼容成本 - -建议: - -- 建立统一的 persistence 层,集中管理 key、version、migration、节流、序列化策略 -- 对 `GameState` 做“可持久化切片”和“运行时临时切片”分层 -- 自动保存增加节流/去抖,避免每次状态波动都全量落盘 -- 如果继续扩展角色聊天、自定义世界、编辑器草稿,建议评估 IndexedDB 替代 `localStorage` - -## P1:运行时与编辑器仍在同一个前端入口体系中,包体继续膨胀 - -证据: - -- `src/main.tsx:21-34` 通过 `window.location.pathname` 手写分发页面 -- `src/main.tsx:60` 只有“游戏”和“PresetEditor”两个大入口 -- `PresetEditor`、`ItemCatalogEditor`、`StateFunctionEditor` 都属于重型模块 -- 构建产物已经出现 `App` 约 `407 KB`、`itemCatalog` 约 `414 KB` 的 chunk - -影响: - -- 游戏端与编辑器端的演进节奏被绑定在一个 SPA 入口上 -- 编辑器相关数据和静态资源容易继续抬高构建体积 -- 未来增加更多编辑器页、更多世界模板、更多资源目录后,冷启动成本会更明显 - -建议: - -- 将编辑器拆成独立入口,至少做成独立 route module,而不是单个 `PresetEditor` -- 继续下钻按 tab 做懒加载,尤其是 `items/functions/npcs` -- 将静态大数据、资源目录索引、编辑器专用预览逻辑做更细的 chunk 拆分 -- 如果项目后续会长期保留编辑器,建议直接分成 game app / editor app 两个 entry - -## P2:编辑器基础设施重复实现较多 - -证据: - -- `src/components/PresetEditor.tsx:111-181` 自己实现 `cloneValue`、`saveJsonObject` -- `src/components/StateFunctionEditor.tsx:113-130` 再次实现 `cloneValue`、`SectionCard` -- `src/components/ItemCatalogEditor.tsx:94` 再次实现保存请求 -- `src/hooks/useInventoryFlow.ts:8`、`src/hooks/useEquipmentFlow.ts:10`、`src/hooks/useForgeFlow.ts:12`、`src/hooks/useTreasureFlow.ts:10` 重复声明 `CommitGeneratedState` - -影响: - -- 修改保存行为、错误处理、深拷贝策略时需要多处同步 -- 编辑器 UI 风格与交互行为容易逐步漂移 -- 公共契约没有收拢到共享层,维护成本会逐步抬高 - -建议: - -- 抽 `editor/shared/` 层,集中放保存 SDK、表单字段、卡片容器、克隆工具、错误处理 -- 抽通用的 `CommitGeneratedState` 类型定义 -- 将编辑器请求和覆盖保存逻辑统一走一个 client - -## P2:类型系统已经出现“总文件过载” - -证据: - -- `src/types.ts` 共 663 行 -- `src/types.ts:1-260` 同时包含世界、动画、技能、对话、自定义世界、物品等类型 -- `src/types.ts:536-663` 又继续承接剧情、聊天、任务、`GameState`、AI 响应 - -影响: - -- 任一领域类型变化都会增加总文件冲突概率 -- 新人理解类型边界成本高 -- 编辑器类型、运行时类型、AI 传输类型被放在一起,不利于演化 - -建议: - -- 按领域拆分:`types/combat.ts`、`types/story.ts`、`types/item.ts`、`types/customWorld.ts`、`types/persistence.ts` -- `GameState` 相关类型与 editor override 类型分开 -- AI request/response contract 单独收口,避免继续堆进总类型文件 - -## P2:AI 客户端层过厚,且重复了多套请求与解析逻辑 - -证据: - -- `src/services/ai.ts` 共 1153 行 -- `src/services/ai.ts:540-605`、`608-678`、`745-790` 分别手写了 JSON completion、纯文本 completion、流式 completion -- `src/services/ai.ts:680-697` 手写了多段 JSON 解析兜底 -- `src/services/ai.ts:76-78`、`591-594`、`662-666` 主要依赖 `console.*` 打日志 - -影响: - -- LLM 行为扩展时容易继续复制请求模板、错误处理、超时逻辑 -- 错误分类不够稳定,观测主要停留在 console 层 -- prompt、transport、fallback、parse 被放在一起,后续测试和替换模型都不够轻 - -建议: - -- 抽 `llmClient`,统一 transport、timeout、stream、error taxonomy -- 抽 `llmParsers`,将 JSON parse / plain text parse / suggestion parse 独立 -- 为关键 prompt 输出建立 fixture 测试,至少覆盖 fallback 与异常响应 -- 如果后续要接多个模型,尽早把 provider 层和 prompt 层解耦 - -## P2:手写路由与死代码开始累积 - -证据: - -- `src/main.tsx:21-34` 采用手写 `pathname.startsWith(...)` -- `src/components/GameShell.tsx:1511` 存在 `false && showTeamModal` - -影响: - -- 路由能力不具备可扩展性,也不利于后续加 404、重定向、权限判断、嵌套路由 -- 死代码继续堆积后,会误导维护者对真实入口和真实 UI 状态的判断 - -建议: - -- 引入正式路由层,哪怕只做轻量路由也比手写分发更清晰 -- 清理已经废弃的 UI 分支和不可达逻辑 -- 对“临时下线的功能”改为 feature flag 或明确注释,不要用 `false &&` - -## 建议落地顺序 - -### 第一阶段:先补工程底座 - -- 增加 ESLint / Prettier / EditorConfig -- 增加 `test` 脚本与 Vitest -- 把 CI 最小闭环搭起来:类型检查、单测、build、内容校验 - -### 第二阶段:先拆边界,再拆大文件 - -- 先把 Vite 中的编辑器写文件接口、LLM 代理抽走 -- 再把 `GameShell`、`useStoryGeneration`、`useCombatFlow` 按职责拆域 -- 拆分时优先保持外部接口稳定,避免一次性全仓大改 - -### 第三阶段:收敛基础设施 - -- 统一 persistence 层 -- 统一 editor shared 层 -- 统一 AI client 层 -- 拆分 `types.ts` - -### 第四阶段:降低发布成本 - -- 将 editor 与 game 做更明确的入口拆分 -- 优化 chunk 边界 -- 评估是否把编辑器做成独立 app - -## 一句话结论 - -这个仓库当前最需要优化的不是“再补几个功能”,而是**把已经验证有效的玩法与工具链,从“靠大文件和经验串起来”升级为“靠清晰边界、统一基础设施和自动化门禁支撑起来”**。只要这一步不做,后续每次加内容、加编辑器能力、加 AI 流程,工程成本都会持续上升。 diff --git a/docs/audits/engineering/ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md b/docs/audits/engineering/ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md deleted file mode 100644 index e91e65e6..00000000 --- a/docs/audits/engineering/ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md +++ /dev/null @@ -1,290 +0,0 @@ -# 工程优化审查报告(2026-03-30) - -## 审查范围 - -- 扫描范围:`src/`、`scripts/`、`docs/`、`.github/`、`package.json`、`tsconfig.json`、`vite.config.ts` -- 实际执行:`npm run lint`、`npm run test`、`npm run build`、`npm run check:content` -- 说明:按仓库要求,本报告不讨论中文乱码问题,只讨论工程结构、边界、质量门禁、可维护性和后续扩展成本 - -## 先说结论 - -这轮代码库相较 `docs/audits/engineering/ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md` 已经有明显进展,项目不再是“所有能力都糊在一个入口文件里”的状态了,但整体仍然处于“重构过渡期”。 - -已经落地的积极变化: - -- 入口路由已经从手写 `pathname` 分发,收敛到 `src/main.tsx` + `src/routing/appRoutes.tsx` -- 持久化能力已经抽到 `src/persistence/` -- 编辑器公共能力已经出现 `src/editor/shared/` -- `CI + ESLint + Prettier + Vitest` 已经接入 -- 本地 API 插件已经从 `vite.config.ts` 抽走,落到 `scripts/dev-server/localApiPlugins.ts` -- `preview` 环境里的 JSON 写入接口已经改成只读,这一点比上轮更安全 - -但当前仍然存在 5 个值得优先处理的工程问题: - -1. 运行时主链仍然过于集中,`story/combat` 的真实边界还没有彻底拆开 -2. `src/services/ai.ts` 仍处于迁移中间态,存在重复实现和旧逻辑残留 -3. 编辑器主入口仍是大型聚合组件,迁移残留没有清干净 -4. 质量门禁已经有框架,但还不够“硬”,warning 和测试覆盖缺口仍然明显 -5. 运行时渲染层和构建体积仍偏重,重 UI 模块还没拆到合适粒度 - -## 当前运行状态 - -- `npm run test` 通过,6 个测试文件共 18 个测试全部通过 -- `npm run build` 通过 -- `npm run check:content` 通过 -- `npm run lint` 通过,但仍有 76 条 warning - -当前构建产物里仍然存在较重 chunk: - -- `dist/assets/GameCanvas-*.js` 约 `346.58 kB` -- `dist/assets/App-*.js` 约 `326.89 kB` -- `dist/assets/index-*.js` 约 `197.80 kB` -- `dist/assets/index-*.css` 约 `117.37 kB` - -## P0:运行时主链仍然过于集中,Story/Combat 边界还没有拆透 - -### 现状 - -虽然 `App.tsx` 已经明显瘦身,`GameShell` 也比之前更像壳层,但真正决定游戏推进的主逻辑仍然高度集中在两个大 hook 里: - -- `src/hooks/useStoryGeneration.ts:824` -- `src/hooks/useCombatFlow.ts:382` - -### 证据 - -`useStoryGeneration` 仍然同时编排了多个本应继续拆开的子领域: - -- `src/hooks/useStoryGeneration.ts:852` 接入 `useCharacterChatFlow` -- `src/hooks/useStoryGeneration.ts:1583` 接入 `useTreasureFlow` -- `src/hooks/useStoryGeneration.ts:1588` 接入 `useInventoryFlow` -- `src/hooks/useStoryGeneration.ts:1593` 接入 `useEquipmentFlow` -- `src/hooks/useStoryGeneration.ts:1597` 接入 `useForgeFlow` -- 文件总长仍有约 `3240` 行 -- 结尾返回对象同时暴露剧情推进、地图旅行、NPC 交易/送礼/招募、角色聊天、背包与锻造 UI 能力,典型位置在 `src/hooks/useStoryGeneration.ts:3171-3219` - -`useCombatFlow` 也不是纯计算层,它仍然同时承担: - -- 战斗前后状态推导 -- 动画播放与时间推进 -- `setGameState` 驱动的可视化编排 -- 逃跑流程与 story 响应同步 - -关键位置: - -- `src/hooks/useCombatFlow.ts:382` `useCombatFlow` -- `src/hooks/useCombatFlow.ts:1195` `playEscapeSequenceWithStorySync` - -### 影响 - -- 任何一个“剧情选项新增”都很容易同时碰到 battle、npc、quest、inventory、chat 五条链路 -- review 成本高,回归范围判断依赖人脑上下文 -- 单测很难往 hook 级别补,因为副作用、异步节奏和 UI 状态混在一起 -- 后续想继续做 camp、custom world、更多 companion 玩法时,改动会继续集中到这两个入口 - -### 建议 - -- 把 `useStoryGeneration` 继续下钻成“剧情推进 orchestrator + 领域 action service” -- `useStoryGeneration` 自己只保留编排,不再直接维护 trade/gift/recruit/chat/inventory/forge 的全部细节 -- `useCombatFlow` 继续向“纯战斗结算”和“播放适配层”分离 -- 先稳定公开接口,再做内部拆分,避免一次性大改 - -## P1:AI 服务迁移只完成了一半,`src/services/ai.ts` 仍然存在双轨实现 - -### 现状 - -仓库已经新增了: - -- `src/services/llmClient.ts` -- `src/services/llmParsers.ts` -- `src/services/aiFallbacks.ts` -- `src/services/aiTypes.ts` - -这说明拆层方向是对的。但 `src/services/ai.ts` 还没有真正变成“纯 orchestration 层”,里面仍然保留着一整套旧 transport / parse / fallback 逻辑。 - -### 证据 - -- `src/services/ai.ts:64-66` 已经开始导入 `llmClient` -- `src/services/ai.ts:89-95` 仍然保留本地 `resolveTimeoutMs` 和超时常量 -- `src/services/ai.ts:647` 仍然保留 `_requestPlainTextCompletion` -- `src/services/ai.ts:719` 仍然保留 `_parseJsonResponseText` -- `src/services/ai.ts:739` 仍然保留 `_parseLineListContent` -- `src/services/ai.ts:784` 仍然保留 `_streamPlainTextCompletion` -- `src/services/ai.ts:885-904` 仍然保留一批旧的 `_buildOffline...` helper - -与之对应,新的实现已经在下面这些文件里存在: - -- `src/services/llmClient.ts` -- `src/services/llmParsers.ts` - -### 影响 - -- 同一类能力现在有两套真相源,后续改错误分类、超时策略、SSE 行为时容易漏改 -- 新同学读代码时很难判断应该继续改 `ai.ts`,还是应该去改 `llmClient.ts` -- 迁移残留会拉高维护成本,也会让测试边界变得模糊 - -### 建议 - -- 把 `src/services/ai.ts` 收敛成“业务 prompt 编排 + fallback 选择”层 -- 彻底删掉未再需要的 `_requestPlainTextCompletion`、`_streamPlainTextCompletion`、`_parse*` 等旧 helper -- 所有 transport / timeout / connectivity error / SSE 解析都只保留在 `llmClient.ts` 和 `llmParsers.ts` -- 迁移完成后,给 `ai.ts` 增加一组 orchestration 级测试,防止 fallback 分支回归 - -## P1:编辑器主入口仍然太重,而且过渡态残留还在 - -### 现状 - -编辑器公共能力已经开始沉淀到 `src/editor/shared/`,这是好事;但主编辑器入口仍然比较重,且部分文件还保留着迁移过程里的死代码和注释块。 - -### 证据 - -`PresetEditor` 仍然是一个大型聚合组件: - -- `src/components/PresetEditor.tsx:402` `CharacterPresetPanel` -- `src/components/PresetEditor.tsx:1174` `SceneNpcPresetPanel` -- `src/components/PresetEditor.tsx:1547` `ScenePresetPanel` -- `src/components/PresetEditor.tsx:1852` `MonsterPresetPanel` -- `src/components/PresetEditor.tsx:2218` `PresetEditor` -- 文件总长仍有约 `2279` 行 - -同时,文件里还留着明显的过渡态残留: - -- `src/components/PresetEditor.tsx:227` 仍然保留未使用的 `_SectionCard` -- `src/components/NpcVisualEditor.tsx:684` 保留 `if (false)` 的旧保存路径 -- `src/components/NpcVisualEditor.tsx:685` 明确写着 “Deprecated inline save path kept only until the shared client migration is cleaned up.” -- `src/components/NpcVisualEditor.tsx:724` 还有第二处 `if (false)` 残留 - -### 影响 - -- 编辑器后续继续扩展时,容易重新长回“大一统文件” -- 过渡代码会误导维护者,以为旧保存链路仍然有效 -- 公共层虽然建起来了,但如果不清理旧代码,长期会形成“共享层 + 本地特例”并存 - -### 建议 - -- 以“一个 tab 一个容器”的方式,把 `PresetEditor` 再拆一层 -- 清理 `NpcVisualEditor` 里的废弃代码块,不要再保留 `if (false)` 分支 -- 对编辑器共享层设定明确规则:保存请求、克隆、Section 容器、错误提示都必须走 shared -- 对编辑器做一次“小型迁移完成清扫”,优先删掉已经废弃但还挂在文件里的旧路径 - -## P1:质量门禁已经接上,但还不够硬 - -### 现状 - -基础设施已经比上轮完整很多,但当前门禁仍然偏“有检查,不够严格”。 - -### 证据 - -当前 lint 结果: - -- 本次 `npm run lint` 实际输出 `76` 条 warning,虽然命令返回成功 - -脚本和规则层面的原因也很明确: - -- `package.json:12` 的 `lint` 仍然是 `eslint . ... && tsc --noEmit`,没有 `--max-warnings 0` -- `package.json:11` 的 `lint:guardrails` 虽然加了 `--max-warnings 0`,但它只覆盖一组显式 allowlist 文件 -- `package.json:18` 的 `check` 会先跑 `lint:guardrails`,再跑宽松版 `lint` -- `.eslintrc.cjs:45-61` 里大量规则仍然是 `warn` -- `.github/workflows/ci.yml:28-40` 已经把 `lint / guardrails / test / build / check:content` 都接到 CI,但 warning 仍能稳定进主干 - -测试覆盖也还是偏薄: - -- `src/` 当前共有 `126` 个文件 -- 其中测试文件只有 `6` 个 -- 现有测试主要覆盖 `routing`、`persistence`、`jsonClient`、`llmParsers`、`battlePlan` -- 关键主链如 `useStoryGeneration`、`useCombatFlow` 播放层、`GameShell` 集成链路、编辑器保存流程仍然没有直接测试 - -### 影响 - -- 代码库会持续积累“已知 warning,但先不处理”的债务 -- 工程信号会逐渐失真,lint 通过不代表代码足够干净 -- 大 hook 和大组件的重构依然主要依赖人工回归 - -### 建议 - -- 先把 warning 收敛到一个可控范围,再把全仓 `lint` 切成 `--max-warnings 0` -- `lint:guardrails` 不要长期靠 allowlist,应该逐步扩大到全仓 -- 优先补三类测试: - - `useStoryGeneration` 的状态推进和 modal 决策 - - `useCombatFlow` 播放层的关键分支 - - 编辑器保存链路和覆盖数据回写 - -## P2:运行时渲染层仍然偏重,chunk 也还没有拆到理想粒度 - -### 现状 - -入口已经有了 route lazy load,模态框也做了一部分懒加载,但核心运行时渲染层仍然比较重。 - -### 证据 - -- `src/components/AdventurePanel.tsx:470` 导出主组件,文件总长约 `1538` 行 -- `src/components/GameCanvas.tsx:472` 导出主组件,文件总长约 `1131` 行 -- `src/components/GameCanvas.tsx:768` 仍然存在 `false && companions.map(...)` 的死分支 -- 本次构建里 `GameCanvas` 和 `App` 仍然是最大 chunk 之一 - -### 影响 - -- 运行时页面的首屏与热区模块仍然偏重 -- 渲染逻辑、场景动画逻辑、实体选中逻辑继续堆在同一层,review 和测试都偏吃力 -- 清理死分支前,维护者对“哪些渲染路径是真实生效的”判断成本更高 - -### 建议 - -- `GameCanvas` 继续拆成 scene layer、entity layer、effect layer、overlay layer -- `AdventurePanel` 继续下沉 quest/stats/settings/reward 子面板 -- 清理 `false &&` 与未使用辅助组件,避免假分支继续留在主路径文件中 -- 结合真实 chunk 数据做一次 route 内部分包,而不是只靠入口级 lazy - -## P2:TypeScript 安全基线仍然偏宽松 - -### 现状 - -当前类型拆分方向是好的,`src/types.ts` 已经退化成 barrel,真实类型开始向 `src/types/` 下沉。但 TypeScript 编译配置还比较保守,类型系统还没有真正变成强约束。 - -### 证据 - -- `tsconfig.json:12` `skipLibCheck: true` -- `tsconfig.json:16` `allowJs: true` -- 当前没有启用 `strict` -- 当前没有启用 `noUncheckedIndexedAccess` - -### 影响 - -- 对大对象和字典访问的保护仍然偏弱 -- 新模块迁移到更细类型后,收益会被宽松编译选项部分抵消 -- “代码能过类型检查”并不等于边界足够安全 - -### 建议 - -- 不建议一次性全仓开严格模式 -- 可以先从 `src/services/`、`src/persistence/`、`src/hooks/combat/` 这些相对纯的目录启更严格约束 -- 至少先评估开启 `noUncheckedIndexedAccess` 和减少 `allowJs` 的必要性 - -## 建议的落地顺序 - -### 第一阶段:先把过渡态清干净 - -- 清理 `ai.ts` 的旧 transport / parser / fallback 实现 -- 清理 `NpcVisualEditor`、`GameCanvas`、`PresetEditor` 等文件里的 `if (false)`、未使用 helper、废弃注释块 -- 把 lint warning 数量先打下来 - -### 第二阶段:拆主链,不再让大 hook 继续膨胀 - -- 继续拆 `useStoryGeneration` -- 继续拆 `useCombatFlow` -- 优先把“领域动作”和“播放/展示编排”分开 - -### 第三阶段:补门禁 - -- 给主链补单测和少量集成 smoke -- 让全仓 lint 朝 `--max-warnings 0` 收敛 -- 把 warning 从“长期存在”变成“短周期清零” - -### 第四阶段:优化运行时体积 - -- 细化 `GameCanvas` 和 `AdventurePanel` 的模块边界 -- 按实际交互热区做 chunk 继续拆分 -- 用真实构建产物持续追踪是否降重 - -## 一句话结论 - -这轮仓库已经从“完全依赖大文件硬扛”进步到“基础设施开始成形”,但当前最需要做的已经不是继续加功能,而是把这轮重构收尾做完整:继续拆主链、删掉迁移残留、把 lint/test 门禁变硬、再顺手压缩运行时大模块。只要这一步补上,后续加剧情、加编辑器能力、加自定义世界都会轻很多。 diff --git a/docs/audits/engineering/ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md b/docs/audits/engineering/ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md deleted file mode 100644 index 675e6710..00000000 --- a/docs/audits/engineering/ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md +++ /dev/null @@ -1,200 +0,0 @@ -# 工程优化审查报告(2026-04-01) - -## 审查范围 - -- 扫描范围:`src/`、`scripts/`、`docs/`、`.github/`、`package.json`、`tsconfig*.json`、`vite.config.ts`、`vitest.config.ts` -- 审查方式:阅读当前工作区代码结构,抽查核心运行时、编辑器、服务层与开发脚本,并执行工程命令验证现状 -- 当前快照说明:仓库存在大量未提交改动,本报告基于当前工作区状态,不假定这些改动都已经合入主分支 -- 说明:按仓库要求,不把中文乱码本身当成本次审查重点;只讨论工程结构、门禁、可维护性、可测试性和扩展成本 - -## 已执行检查 - -- `npm run lint:eslint` - 结果:失败。`src/components/ItemCatalogEditor.tsx:167` 存在未使用的 `isSearchPending` 和 `startTransition` -- `npm run typecheck` - 结果:通过 -- `npm run test` - 结果:通过,默认套件实际执行 10 个测试文件、28 个测试 -- `npm run build` - 结果:通过,但 `src/services/customWorldPresentation.ts:163-169` 出现 duplicate key 警告 -- `npm run check:content` - 结果:通过 - -## 当前结论 - -这轮代码库已经明显比前几版更有工程骨架了,至少有这些积极变化: - -- `src/main.tsx` + `src/routing/appRoutes.tsx` 已经承担了入口路由分发 -- `src/App.tsx` 已经比过去瘦很多,主流程开始交给 hook 和壳组件 -- `src/components/PresetEditor.tsx` 已经成为较薄的 lazy shell,而不是继续堆成巨型入口 -- `src/editor/shared/jsonClient.ts`、`src/persistence/`、`src/hooks/combat/`、`src/hooks/story/` 这些目录说明仓库已经开始做分层 -- CI、Vitest、ESLint、内容校验脚本都已经接上,不再是完全裸奔状态 - -但从工程角度看,当前最值得优先优化的,不是继续加功能,而是把“半完成的工程化”补齐。核心问题集中在 6 个方面。 - -## P0:质量门禁和真实风险点仍然脱节 - -### 现状 - -仓库已经引入了 lint、typecheck、test、build 和 content checks,但关键热区并没有真正纳入统一门禁。 - -### 证据 - -- `.eslintrc.cjs:47-63` 的 `ignorePatterns` 直接跳过了多个高复杂度核心文件: - `src/components/AdventurePanel.tsx`、`src/components/NpcVisualEditor.tsx`、`src/components/preset-editor/PresetEditorPanels.tsx`、`src/hooks/useStoryGeneration.ts`、`src/services/customWorldPresentation.ts` -- `tsconfig.typecheck-guardrails.json:6-15` 只对非常有限的一小组文件开启严格类型检查,远没有覆盖主运行时链路 -- `vitest.config.ts:8-10` 把 `customWorldPresentation` 映射到 stub,`vitest.config.ts:20` 还排除了真实存在的 `src/services/ai.test.ts` -- 当前 `src/` 下共有 161 个文件,测试文件共有 11 个,但默认套件只执行其中 10 个 -- `npm run build` 已经能暴露 `src/services/customWorldPresentation.ts:163-169` 的 duplicate key 警告,但这块文件同时被 ESLint ignore、被 Vitest stub 掉,说明真实风险没有被完整看见 - -### 影响 - -- 工程信号不一致:`test` 绿、`build` 过,不代表关键模块真的健康 -- 复杂模块越是难测,越容易被长期豁免,最后演变成“最关键的地方最不受控” -- 后续重构会缺乏可靠的回归保护,review 只能更多依赖人工记忆 - -### 建议 - -- 先缩小 `.eslintrc.cjs` 的 ignore 范围,优先把 `useStoryGeneration.ts`、`customWorldPresentation.ts`、`PresetEditorPanels.tsx` 拉回 lint -- 把 `src/services/ai.test.ts` 重新纳入默认测试套件,除非有明确且短期的阻塞原因 -- 不要长期依赖 `tsconfig.typecheck-guardrails.json` 的 allowlist,至少把 `src/hooks/`、`src/services/`、`src/components/game-shell/` 逐步纳入 strict 范围 -- 对 build warning 建立明确策略:要么在 CI 中失败,要么把 warning 收敛到零 - -## P0:当前工作区不在真正的绿色基线 - -### 现状 - -当前代码不是“纯优化空间”问题,而是已经存在直接可见的门禁破口。 - -### 证据 - -- `package.json:11-15` 把 `lint:eslint` 和 `typecheck` 定义成正式脚本,说明它们本来就属于项目基线 -- 实际执行 `npm run lint:eslint` 时,`src/components/ItemCatalogEditor.tsx:167` 报出未使用变量错误 -- `src/components/ItemCatalogEditor.tsx:167` 引入了 `useTransition()` 返回值,但当前组件没有消费它 -- `npm run build` 虽然成功,但 `src/services/customWorldPresentation.ts:163-169` 仍然有重复 object key 警告 - -### 影响 - -- 团队会越来越难区分“可接受的技术债”和“已经破坏基线的问题” -- 继续叠加功能会把问题扩散到更多文件,后面补起来成本更高 - -### 建议 - -- 先恢复工作区绿色基线,再继续推进大功能 -- 把“lint 零错误、build 零 warning”作为下一轮工程整理的硬目标 - -## P1:运行时主链路仍然被少数超级模块吸住 - -### 现状 - -入口已经变薄,但主复杂度仍集中在少数大文件里,尤其是故事推进、战斗同步和界面编排三层。 - -### 证据 - -- `src/hooks/useStoryGeneration.ts` 当前约 2210 行 -- `src/hooks/useStoryGeneration.ts:694` 导出主 hook,`src/hooks/useStoryGeneration.ts:1416` 接入 `useTreasureFlow`,后面还继续承接 NPC 互动、库存、打字机、AI、历史推进和故事回写 -- `src/hooks/useCombatFlow.ts:134` 是主战斗 hook,`src/hooks/useCombatFlow.ts:796-832` 仍然负责逃跑流程与 story sync 的耦合 -- `src/components/GameShell.tsx` 当前约 791 行,`src/components/GameShell.tsx:260-269` 管理一组本地 UI 状态,`src/components/GameShell.tsx:482` 继续处理场景切换时的选择编排 -- 构建产物里 `dist/assets/App-*.js` 约 389 kB,`dist/assets/index-*.js` 约 198 kB,说明主运行时 chunk 仍然偏重 - -### 影响 - -- 任何一个功能变化都容易跨 story、combat、transition、panel 几条链一起改 -- hook 单测越来越难写,因为副作用、异步和 UI 编排仍然混在一起 -- App 主 chunk 偏重,会继续拖累首屏和回归速度 - -### 建议 - -- 继续把 `useStoryGeneration` 收敛成 orchestration 层,把 treasure、NPC、inventory、chat、typewriter、AI 回写拆成更纯的领域 action -- 让 `useCombatFlow` 更明确地区分“战斗结算”和“播放同步” -- 把 `GameShell` 进一步下沉为 scene transition、selection flow、overlay panel 三类 view-model - -## P1:编辑器共享层只迁移了一半,重复基础设施还在 - -### 现状 - -编辑器入口已经做了 shell 化,但真正的复杂度仍然堆在大型面板组件里,而且共享层没有吃干净。 - -### 证据 - -- `src/components/PresetEditor.tsx:41` 的入口已经很薄,说明方向是对的 -- 但 `src/components/preset-editor/PresetEditorPanels.tsx` 仍然约 2163 行 -- `src/components/preset-editor/PresetEditorPanels.tsx:55` 仍然自带 `cloneValue` -- `src/components/preset-editor/PresetEditorPanels.tsx:117` 仍然自带 `saveJsonObject` -- `src/components/preset-editor/PresetEditorPanels.tsx:189` 仍然自带 `SectionCard` -- 与之对应,`src/editor/shared/jsonClient.ts:29-40` 已经提供了共享版 `fetchJson` / `saveJsonObject` -- `src/components/preset-editor/PresetEditorPanels.tsx:364`、`:1128`、`:1500`、`:1806` 仍然把四个大型 panel 放在同一个文件里 - -### 影响 - -- 编辑器的保存、错误处理、基础 UI 容器会继续多处复制,后续很难统一行为 -- 迁移看起来开始了,但没有真正收尾,维护者仍然需要在“大文件 + 共享层”之间来回切换 - -### 建议 - -- 继续把 `PresetEditorPanels.tsx` 拆成按 tab 或按领域分文件 -- 统一复用 `src/editor/shared/` 下的保存客户端、基础容器、表单片段 -- 对编辑器做一次“小型迁移收尾”,目标是消灭重复的基础 helper - -## P1:本地开发 API 层与构建工具耦合过深 - -### 现状 - -本地 API 插件已经把很多临时逻辑吸收进项目内部,这是好事;但它现在承担的职责太多,且全部挂在 Vite 插件层。 - -### 证据 - -- `vite.config.ts:7-18` 直接把 `createLocalApiPlugins(__dirname, env)` 注入到 Vite config -- `scripts/dev-server/localApiPlugins.ts` 当前约 394 行 -- `scripts/dev-server/localApiPlugins.ts:150` 定义 LLM proxy 插件 -- `scripts/dev-server/localApiPlugins.ts:216` 定义通用 JSON 文件编辑插件 -- `scripts/dev-server/localApiPlugins.ts:265` 直接把编辑器保存结果写回 `src/data/*.json` -- `scripts/dev-server/localApiPlugins.ts:429` 再统一把所有插件拼到一起 - -### 影响 - -- dev server、preview server、编辑器持久化和 LLM 代理被绑在一个文件里,测试与替换成本都偏高 -- 随着编辑器继续扩张,这个文件会继续演化成“隐形后端” -- 生产与开发环境的边界容易模糊,问题排查也更依赖熟悉 Vite 插件机制的人 - -### 建议 - -- 至少先按职责把 `localApiPlugins.ts` 拆成 `llm-proxy`、`json-editor-api`、`asset-catalog` 三块 -- 下一阶段可以考虑把编辑器 API 抽成独立本地服务层,而不是继续塞在 Vite 插件里 -- 给 JSON 写入接口补 schema 校验,而不只是“是 object 就写入” - -## P2:构建体积仍有继续优化空间 - -### 现状 - -路由 lazy load 和部分 modal lazy load 已经做了,但主游戏运行时包仍然偏大。 - -### 证据 - -- `dist/assets/App-*.js` 约 389 kB -- `dist/assets/index-*.js` 约 198 kB -- `dist/assets/index-*.css` 约 117 kB -- `src/components/GameShell.tsx`、`src/hooks/useStoryGeneration.ts`、`src/services/prompt.ts` 仍然是较大的主链路文件 - -### 影响 - -- 新人本地启动、构建和回归阅读成本仍然偏高 -- 主运行时模块越重,越不利于后续继续做场景扩展和编辑器共存 - -### 建议 - -- 优先沿着“运行时 orchestration 拆分”去减主 chunk,而不是单纯追求更多 lazy import -- 对 `prompt`、自定义世界、编辑器预览等非首屏关键代码继续做边界拆分 -- 每轮重构后用真实构建产物复测,而不是只凭代码体感判断 - -## 建议的落地顺序 - -1. 先恢复绿色基线:修掉 `ItemCatalogEditor` lint 错误,处理 `customWorldPresentation` 的 duplicate key warning -2. 再补齐门禁:缩小 ESLint ignore、把 `ai.test.ts` 拉回默认测试、扩大 strict typecheck 覆盖 -3. 然后拆主链:优先处理 `useStoryGeneration`、`useCombatFlow`、`GameShell` -4. 再做编辑器迁移收尾:拆 `PresetEditorPanels.tsx`,统一共享层 -5. 最后处理 dev API 分层和 bundle 体积 - -## 一句话结论 - -这个仓库已经从“功能堆叠期”进入“工程收尾期”了。当前最值得做的不是再加一层玩法,而是把门禁补齐、把超级模块拆开、把半迁移状态收尾;只要这一步做稳,后续继续扩展剧情、编辑器和自定义世界的成本都会明显下降。 diff --git a/docs/audits/engineering/FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md b/docs/audits/engineering/FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md index 63b5b541..da42e462 100644 --- a/docs/audits/engineering/FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md +++ b/docs/audits/engineering/FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md @@ -40,7 +40,7 @@ ### 2.1 文档依据 1. `docs/experience/PROJECT_WORK_EXPERIENCE_PLAYBOOK.md` -2. `docs/planning/EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md` +2. `docs/technical/CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.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` diff --git a/docs/audits/engineering/README.md b/docs/audits/engineering/README.md index 0fd8f240..db0cbbc1 100644 --- a/docs/audits/engineering/README.md +++ b/docs/audits/engineering/README.md @@ -1,36 +1,31 @@ # 工程优化审查总览 -这一组是同主题的连续审查记录,建议不要把它们当作三份彼此独立的文档来看。 +这一组只保留仍能指导当前 Rust / SpacetimeDB 主线的工程审查入口。早期连续扫描的有效结论已经合并到本 README 的“融合结论”,不再保留逐日旧稿。 ## 当前推荐入口 -1. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_F_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_F_2026-04-21.md) +1. [SERVER_NODE_FREEZE_AND_DEPRECATION_2026-04-24.md](./SERVER_NODE_FREEZE_AND_DEPRECATION_2026-04-24.md) + 这一版是旧 Node 后端冻结、第一批物理删除与后续批次边界记录,明确当前工程只保留 Rust / SpacetimeDB 主线入口。 +2. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_F_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_F_2026-04-21.md) 这一版是第六批落地记录,聚焦删除无入口 `questDirector`、旧观察文案 helper、一次性硬编码同步脚本,并补齐后端运行时 function catalog 契约覆盖。 -2. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_E_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_E_2026-04-21.md) +3. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_E_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_E_2026-04-21.md) 这一版是第五批落地记录,聚焦旧命名 re-export、空路由骨架、旧发布服务、前端 prompt 镜像与无入口编辑器壳层的物理删除。 -3. [FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md](./FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md) - 这一版是本轮前端越界逻辑专项审计,专门汇总当前仍应继续迁到 Express 后端的运行时、鉴权、生成编排与本地真相残留。 -4. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_D_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_D_2026-04-21.md) +4. [FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md](./FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md) + 这一版是本轮前端越界逻辑专项审计,专门汇总当前仍应继续迁到 `server-rs` 的运行时、鉴权、生成编排与本地真相残留。 +5. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_D_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_D_2026-04-21.md) 这一版是第四批落地记录,聚焦未接入业务的数据生成产物、测试专用 stub 与对应配置残留出清。 -5. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_C_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_C_2026-04-21.md) +6. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_C_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_C_2026-04-21.md) 这一版是第三批落地记录,聚焦鉴权真相收口,先移除前端保存自动登录用户名/密码的本地真相,并明确运行时快照前置写入为什么当前还不能硬砍。 -6. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_B_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_B_2026-04-21.md) +7. [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 的正式出清。 -7. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_A_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_A_2026-04-21.md) +8. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_A_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_A_2026-04-21.md) 这一版是第一批落地记录,聚焦高置信度小型孤岛、prompt 壳子、stub 和无入口 modal 的首轮清理。 -8. [CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md](./CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md) +9. [CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md](./CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md) 这一版是面向当前仓库状态的优化点盘点,适合直接拿来排优先级和拆执行批次。 -9. [ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md](./ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md) +10. [ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md](./ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md) 这一版是对 `2026-04-19` 基线的当前仓库复核,明确哪些问题已经处理、哪些表述需要纠正、热点又迁移到了哪里。 -10. [ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md](./ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md) +11. [ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md](./ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md) 这一版保留原始问题快照和执行回填,适合回看“为什么会有这轮清理与边界收口”。 -11. [ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md) - 这一版最适合作为当前工程基线,重点从“是否真正绿色”“门禁有没有覆盖真实风险”来判断仓库状态。 -12. [ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md) - 适合回看运行时主链路、Story/Combat 边界、分层过渡期问题。 -13. [ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md) - 适合看第一轮系统性工程扫描,了解最早的问题基线。 - ## 融合结论 - 最新专项审计已经把“前端哪些逻辑还该后移到后端”收敛到 6 类:运行时快照、本地 token、本地浏览历史、NPC 委托换单、quest/runtime item 混合编排、浏览器 AI orchestration。 @@ -43,11 +38,9 @@ - 当前仓库已经完成“旧 dev 插件链路删除、根目录噪音清理、`server-node -> src/**` 反向依赖切断”这批第一阶段任务。 - 当前如果想直接判断“今天先优化什么”,优先看 `CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md`。 - 当前的新重点已经进一步收敛到三类:未接线孤岛模块、前端残留的运行时/鉴权真相、热点向 prompt/runtime profile/平台入口壳层迁移。 -- 三轮结论是一致收敛的:问题不在“有没有开始工程化”,而在“工程化是否真正覆盖了最危险的主链路”。 -- 最新一轮已经把关注点集中到质量门禁、真实绿色基线、关键模块豁免和 build warning 上。 +- 早期三轮工程扫描的结论已经聚合为一条长期规则:工程化不能只看目录和拆分动作,必须覆盖真实主链、质量门禁、绿色基线、关键模块豁免和 build warning。 - `2026-04-19` 这一轮把问题压实到了四类:仓库噪音、旧 dev 入口残留、前端越界运行时逻辑、巨型热点文件。 - `2026-04-20` 这一轮进一步确认:前两类已经阶段性完成,当前真正剩下的是边界尾巴和新热点迁移。 -- 如果只是为了判断现在先做什么,直接从 `2026-04-01` 开始即可。 - 如果是要看当前清理和边界收口的最新状态,优先看 `ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md`。 - 如果是要看“当前可执行的优化点清单”,优先看 `CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md`。 -- 如果是要做长期重构方案,再按 `2026-03-29 -> 2026-03-30 -> 2026-04-01 -> 2026-04-19 -> 2026-04-20` 的顺序回看演进。 +- 如果是要做长期重构方案,从 `2026-04-19`、`2026-04-20` 与当前 dead-code batch 记录开始即可。 diff --git a/docs/audits/engineering/SERVER_NODE_FREEZE_AND_DEPRECATION_2026-04-24.md b/docs/audits/engineering/SERVER_NODE_FREEZE_AND_DEPRECATION_2026-04-24.md index ff177dd7..85a89750 100644 --- a/docs/audits/engineering/SERVER_NODE_FREEZE_AND_DEPRECATION_2026-04-24.md +++ b/docs/audits/engineering/SERVER_NODE_FREEZE_AND_DEPRECATION_2026-04-24.md @@ -12,11 +12,11 @@ 2. 禁止新增从前端、Rust 后端、脚本或配置主动调用 `server-node/` 的逻辑。 3. 禁止在 `server-node/` 内继续新增业务能力;后续能力必须落到 `server-rs/` 对应 crate。 4. 历史文档、审计文档、迁移基线中允许保留 `server-node/` 作为旧系统来源说明,但不得把它描述成当前推荐实现。 -5. 删除 `server-node/` 前,必须先完成提示词资产与提示词相关工作流的最终迁移确认。 +5. 第一批物理删除后,提示词资产与提示词相关工作流继续按迁移核对项追踪,不再恢复旧工程目录。 -## 3. 删除前阻断项 +## 3. 删除前迁移核对项 -以下资产仍需要在删除目录前逐项确认迁移或废弃: +以下资产曾作为删除前核对项。第一批物理删除后,旧实现不再从工作区直接读取;如需继续核对能力缺口,只允许通过历史提交、迁移文档或 `server-rs/` 已迁移实现追溯: 1. `server-node/src/prompts/customWorldEntityPrompts.ts`:自定义世界实体生成 prompt。 2. `server-node/src/prompts/customWorldSceneNpcPrompts.ts`:自定义世界场景 NPC prompt。 @@ -27,17 +27,17 @@ ## 4. 工程防线 -1. 根目录 `package.json` 中的 `server-node:*` 脚本统一改为冻结失败入口。 -2. 新增 `npm run check:server-node-freeze`,用于阻止新增 `server-node` 引用。 -3. 新增 `scripts/server-node-frozen.mjs`,任何旧 `server-node:*` 入口被误执行时都会直接失败并提示迁移到 `server-rs/`。 -4. 新增 `scripts/server-node-freeze-baseline.json`,只允许冻结前已经存在的引用继续作为迁移基线存在。 +1. 第一批物理删除后,根目录 `package.json` 不再保留 `server-node:*`、`dev:node`、`check:server-node-freeze` 等旧入口。 +2. Vite、Caddy 与本地开发脚本默认只指向 Rust `api-server`,不再保留 Node/Rust 后端切换开关。 +3. 历史文档允许保留旧 `server-node` 字样,但新增工程入口、脚本、依赖、运行说明不得再指向旧 Node 后端。 +4. 若后续需要恢复旧能力,只能迁移到 `server-rs/` 对应 crate 或 Axum facade,不恢复 `server-node/` 工程目录。 ## 5. 后续处理顺序 -1. 优先迁移或废弃提示词资产与 prompt 工作流。 -2. 确认前端不再通过任何路径调用 Node 后端能力。 -3. 删除旧脚本、旧 smoke、旧 manifest 与 `server-node/` 目录。 -4. 删除冻结基线检查中对历史引用的豁免。 +1. 继续核对提示词资产与 prompt 工作流是否已完整落到 Rust 主线。 +2. 继续把前端残留业务编排迁入 `server-rs/`。 +3. 清理技术索引中容易误导当前入口的 Node / Express 文案。 +4. 保留历史审计材料,但不得把旧 Node 后端描述为当前推荐实现。 ## 6. 已确认迁移项 @@ -50,3 +50,46 @@ 3. 使用位置:`generate_draft_foundation_act_backgrounds(...)` 收集 `sceneChapterBlueprints[].acts[]` 后,先构造幕背景图专用提示词,再调用 `generate_custom_world_scene_image_for_profile(...)`。 4. 保留语义:世界名、场景名、幕标题、幕摘要、幕目标、过渡钩子、主角色、辅助角色、世界气质、背景描述,以及“只生成环境背景,不出现角色立绘、站位 UI、对白框、按钮或文字”的约束。 5. 迁移边界:`server-node/` 仅作为历史来源说明,不再参与运行;后续调整统一修改 Rust 主源。 + +## 7. 第一批安全删除记录(2026-04-25) + +本批次开始把冻结隔离升级为物理删除。执行依据是项目后端主线已固定为 `server-rs/` 的 Rust + SpacetimeDB 多 crate 方案,旧 `server-node/` 不再作为可运行、可扩展、可引用的工程目录保留。 + +### 7.1 删除范围 + +1. 删除 `server-node/` 目录本体,旧实现只允许通过历史提交、迁移文档和已迁移到 `server-rs/` 的代码追溯。 +2. 删除旧 Node 后端专用入口:`scripts/dev-node.mjs`、`scripts/server-node-frozen.mjs`、`scripts/check-server-node-freeze.mjs`、`scripts/server-node-freeze-baseline.json`、`scripts/smoke-server-node.ts`、`scripts/smoke-same-origin-stack.ts`、`scripts/m7-api-compare.ts`、`scripts/deploy.sh`、`scripts/update.sh`、`view-llm-logs.ps1`。 +3. 根目录 `package.json` 删除 `server-node:*`、`dev:node`、`m7:api-compare` 与 `check:server-node-freeze` 等旧入口,并移除 `express`、`@types/express` 依赖。 +4. `npm run dev` 改为启动 Rust 本地栈;Vite 和 Caddy 默认只代理到 Rust `api-server`,不再保留 `GENARRATIVE_BACKEND_STACK` 的 Node/Rust 双栈切换口。 +5. 清理 `.gitignore` 中只服务 `server-node/` 的忽略规则,并同步 `README.md`、`.env.example`、`server-rs/README.md` 与 `scripts/dev-server/README.md`。 + +### 7.2 暂不处理范围 + +1. 历史 PRD、审计、迁移基线中的 `server-node` 文案暂时保留为历史记录,不在第一批中大规模改写。 +2. `backend-rewrite-tasklist/` 中以旧 Node 后端为对照的迁移材料暂时保留,作为后续核对 Rust 主线能力缺口的历史审计输入。 +3. `src/services/ai.ts` 与 `src/prompts/customWorldPrompts.ts` 的前端残留编排不属于本批 Node 后端删除范围;后续继续按“前端只负责表现,业务逻辑进入 `server-rs/`”单独收口。 + +### 7.3 后续批次建议 + +1. 技术文档索引中的 Node / Express 后端条目只保留为历史资料,不再作为当前入口或推荐方案。 +2. 后续如继续整理历史文档,只把仍描述 `Express / PostgreSQL` 为当前目标架构的文字修正为“历史阶段口径”。 +3. 继续把前端残留业务逻辑迁入 `server-rs`;涉及 SpacetimeDB 的设计、实现、脚本和绑定继续显式使用相关 skill。 + +### 7.4 本轮安全核对结果 + +2026-04-25 本轮开始分批删除时,已确认第一批工程入口层面满足以下条件: + +1. 工作区根目录下已不存在 `server-node/` 物理目录。 +2. `scripts/` 下已不存在旧 Node 后端专用运行、冻结、smoke、API 对比脚本。 +3. 根目录 `package.json` 不再包含 `server-node:*`、`dev:node`、`m7:api-compare` 与 `check:server-node-freeze` 入口。 +4. `package.json` 与 `package-lock.json` 不再包含 `express`、`@types/express`、`pg`、`postgres` 依赖包。 +5. `README.md`、`scripts/dev-server/README.md`、`server-rs/README.md`、`vite.config.ts`、`scripts/*.mjs`、`src/`、`packages/` 与 `server-rs/` 未发现仍主动启动或调用 `server-node` 的当前工程入口。 + +### 7.5 第二批删除边界 + +第二批不再删除可运行工程代码,而是清理“容易误导当前实现口径”的历史文档索引: + +1. 只修正文档中仍把 `server-node`、Express 或 PostgreSQL 描述为当前推荐后端的句子。 +2. 保留审计、PRD、迁移基线中作为历史事实、旧实现来源、能力对照的 `server-node` 引用。 +3. 不大规模重写包含中文剧情、需求、审计结论的历史文档,避免把真实历史上下文抹平。 +4. 若发现某个历史文档仍指导新开发继续写 Node 后端,先把该文档改为“历史阶段口径”,再继续工程处理。 diff --git a/docs/audits/text/EDITOR_GAME_PRESET_TEXT_AUDIT_2026-03-25.md b/docs/audits/text/EDITOR_GAME_PRESET_TEXT_AUDIT_2026-03-25.md deleted file mode 100644 index 27a5219a..00000000 --- a/docs/audits/text/EDITOR_GAME_PRESET_TEXT_AUDIT_2026-03-25.md +++ /dev/null @@ -1,228 +0,0 @@ -# 编辑器 UI / 游戏 UI / 预设内容英文与乱码审计 - -更新时间:`2026-03-25` - -## 范围与方法 - -- 范围只覆盖当前源码里会直接进入编辑器 UI、游戏运行时 UI、预设内容预览的文案。 -- 本轮直接按 `utf-8` 读取 `src/components` 与 `src/data` 复核,不把旧审计文档当最终事实来源。 -- 不统计 `import`、类型名、变量名、接口字段名、资源路径等纯开发层英文。 - -## 结论摘要 - -- 当前目标范围内,确认到 **1 处直接写进源码的中文乱码**: - - `src/components/PresetEditor.tsx:72` 的 `鐗╁搧` -- 当前更大的问题已经不是“大片中文乱码”,而是 **编辑器与部分游戏界面还残留成组英文 UI 文案**。 -- 预设内容层面的英文主要集中在 **原始枚举值 / 构筑字段**,例如 `common / rare / legendary`、`neutral`、`buildProfile.role`、`idle / move / attack / die`、`steady / burst / mobility / finisher / projectile`。这些值本身在数据层可以保留英文,但当前有一部分被界面直接原样显示出来了。 - -## 编辑器 UI - -### 英文残留 - -- `src/components/PresetEditor.tsx:84-89` - - 编辑器标签仍是 `Characters / NPCs / Scenes / Monsters / Items / Functions` -- `src/components/PresetEditor.tsx:97-104` - - 预设编辑里仍直接使用 `idle / move / attack / die` 与 `steady / burst / mobility / finisher / projectile` -- `src/components/StateFunctionEditor.tsx:333-367` - - 预览实体与效果摘要仍是英文: - - `Preview NPC` - - `Fallback NPC preview for ...` - - `Preview Treasure` - - `Treasure preview for ...` - - `Damage x / Incoming x / Heal + / Mana + / Cooldown + / Turn x` -- `src/components/StateFunctionEditor.tsx:496-607` - - 预览阶段与提示说明仍有整段英文: - - `Player Turn Preview` - - `Escape Preview` - - `Travel Result Preview` - - `Explore Preview` - - `Call-Out Preview` - - `Idle Behavior Preview` - - `Predicted skill: ...` - - `Monster counter template uses ...` - - `Battle behaviors are driven by skill weights ...` - - `Escape behaviors always use the chase flow ...` -- `src/components/StateFunctionEditor.tsx:855-939` - - 预览面板头部与信息卡仍是英文: - - `Option` - - `Mode` - - `Replay Preview` - - `Preview Playing` - - `Preview Ready` - - `Live Player` - - `Live Scene` - - `No scene` - - `Resolved Plan` - - `Option kind` - - `Target scene` - - `Cooldowns` - - `Battle Snapshot` - - `Animation` - - `Delivery` - - `Damage` - - `Predicted kill` - - `Target survives` - - `Snapshot based on live playback` -- `src/components/ItemCatalogEditor.tsx:25` - - 稀有度选项仍是 `common / uncommon / rare / epic / legendary` -- `src/components/ItemCatalogEditor.tsx:486-560` - - 物品预览区仍直接显示英文键和值: - - `rarity` - - `value` - - `usable` - - `yes / no` - - `equip` - - `world` - - `neutral` - - `HP / MP / Damage / Guard` - - `HP Restore / MP Restore / CD Reduce` - - `Build / 套装` - - `Role / Set / Piece` - - `none / standalone` -- `src/components/NpcVisualEditor.tsx` - - 目前大部分文案已中文化,但 `NPC` 缩写仍在标题、字段、保存提示中大量保留,属于低优先级统一项,不是乱码问题。 - -### 确认的中文乱码 - -| 文件 | 位置 | 当前文本 | 判断 | -| --- | --- | --- | --- | -| `src/components/PresetEditor.tsx` | `72` | `鐗╁搧` | 明确乱码,语义应为“物品” | - -### 编辑器 UI 小结 - -- **最高优先级乱码修复点**:`PresetEditor.tsx:72` -- **最高优先级英文清理点**:`StateFunctionEditor.tsx`、`ItemCatalogEditor.tsx` - -## 游戏各界面 UI - -### 英文残留 - -- `src/components/GameShell.tsx:414` - - 标题副标仍是 `TAVERNREALMS` -- `src/components/GameShell.tsx:1083-1094` - - 团队弹窗里仍保留 `TavernRealms` -- `src/components/CharacterChatModal.tsx:52,73,76` - - `CHARACTER CHAT` - - `HP` - - `MP` -- `src/components/CharacterDetailModal.tsx:24-40` - - 属性与技能风格映射仍为英文: - - `Strength / Agility / Intelligence / Spirit` - - `Burst / Steady / Mobility / Finisher / Projectile` - - `Female / Male / Unknown` -- `src/components/CharacterDetailModal.tsx:149,186,194-271` - - 详情弹窗区块标题与标签仍有整段英文: - - `INITIAL COMPANION` - - `Close character details` - - `Profile` - - `Candidate` - - `Gender` - - `Stats` - - `Max HP / Max MP` - - `Journey` - - `Reason / Goal` - - `Skills / Loadout / Pack / Backstory / Personality` -- `src/components/InventoryPanel.tsx:39-50` - - 稀有度标签仍是 `Legendary / Epic / Rare / Uncommon / Common` -- `src/components/InventoryPanel.tsx:179-196,215` - - 物品详情仍是英文: - - `Quantity` - - `Owner` - - `Usable` - - `Yes / No` - - `Equipable` - - `Value` - - `Type` - - `Tags` - - `no-tags` -- `src/components/AdventureEntityModal.tsx:206-217` - - 自动生成的物品描述仍是英文整句: - - `helps restore HP` - - `supports MP recovery` - - `fits offensive loadouts` - - `supports defensive gearing` - - `works as a rare trinket-grade pickup` - - `can be saved for crafting or trading` - - `${item.name} can be kept for trading, gifting, or future build planning.` - - `${item.name} is a ${item.category} item that ...` -- `src/components/AdventureEntityModal.tsx:226-235` - - 物品属性摘要仍是英文: - - `HP` - - `MP` - - `Damage` - - `Guard x...` -- `src/components/AdventureEntityModal.tsx:1271-1332` - - NPC 物品详情弹窗仍有一整块英文: - - `ITEM DETAIL` - - `NPC inventory` - - `Quantity` - - `Value` - - `Equip Slot` - - `Not equippable` - - `Usable item` - - `Story, trade, or gift resource` - - `Type` - - `Rarity` - - `Tags` - - `No tags` -- `src/components/CompanionCampModal.tsx:152,176-177,213,216` - - `Active` - - `HP` - - `MP` - - `待命 roster` - - `Reserve` -- `src/components/NpcModals.tsx:507` - - 招募替换提示里仍混入 `roster` - -### 当前未确认到的乱码 - -- 本轮没有在游戏运行时 UI 组件里复核到新的、直接写死在源码中的中文乱码。 -- 当前游戏 UI 的主要问题已经转为“英文标签 / 英文句子未汉化”,不是大面积中文乱码。 - -## 预设内容 - -### 直接会透到 UI 的英文源 - -- `src/data/itemDesign.ts:52-72` - - 材质主题里直接保存了英文原始值: - - `worldAffinity: "neutral"` - - `role: "fieldcraft" / "breaker" ...` - - `rarity: "common"` - - `tags: ["scout", "craft"]` -- `src/data/itemCatalog.ts:260-270` - - `designed.rarity / designed.worldAffinity / designed.buildProfile` 会原样流入物品目录数据 -- `src/components/ItemCatalogEditor.tsx:486-560` - - 上述原始字段当前会在编辑器预览里被直接显示,因此形成了可见英文泄漏 -- `src/data/questFlow.ts:24-30` - - 任务奖励物品稀有度仍是 `rare / uncommon` -- `src/data/npcInteractions.ts:71-82,102-103,166` - - 稀有度与标签推断仍使用 `common / uncommon / rare / epic / legendary` - - 物品标签仍使用 `weapon / armor` -- `src/components/PresetEditor.tsx:97-104` - - 预设编辑直接使用 `idle / move / attack / die` - - 技能风格直接使用 `steady / burst / mobility / finisher / projectile` -- `src/components/StateFunctionEditor.tsx:85-98` - - 行为编辑直接使用 `battle / idle` - - 朝向直接使用 `left / right` - - 怪物动画直接使用 `idle / move / attack` - - 风格直接使用 `steady / burst / mobility / finisher / projectile` - -### 当前未确认到的乱码 - -- 本轮没有在 `src/data/*.ts` 的预设正文里复核到新的、直接写死的中文乱码。 -- 当前预设内容层面的问题,主要是“英文字段值没有在显示层做 label 映射”,不是正文汉字被写坏。 - -## 建议处理顺序 - -1. 先修 `src/components/PresetEditor.tsx:72` 的 `鐗╁搧`。 -2. 再集中处理 `src/components/StateFunctionEditor.tsx` 的整组英文预览与提示文案。 -3. 然后处理 `src/components/ItemCatalogEditor.tsx` 的物品预览英文键名与英文值。 -4. 之后清理游戏运行时最明显的英文块: - - `src/components/CharacterDetailModal.tsx` - - `src/components/InventoryPanel.tsx` - - `src/components/AdventureEntityModal.tsx` - - `src/components/CompanionCampModal.tsx` - - `src/components/CharacterChatModal.tsx` - - `src/components/GameShell.tsx` -5. 最后为预设源字段补统一显示映射,把 `common / rare / legendary / neutral / idle / left / right ...` 全部收口到统一词典。 - diff --git a/docs/audits/text/GAME_EDITOR_PRESET_TEXT_AUDIT_2026-03-29.md b/docs/audits/text/GAME_EDITOR_PRESET_TEXT_AUDIT_2026-03-29.md deleted file mode 100644 index b4da9ec0..00000000 --- a/docs/audits/text/GAME_EDITOR_PRESET_TEXT_AUDIT_2026-03-29.md +++ /dev/null @@ -1,91 +0,0 @@ -# 游戏 UI / 预设 / 编辑器 UI 英文与乱码复查 - -日期:`2026-03-29` - -## 结论摘要 - -- 当前分支里,**确认存在源码级中文乱码的文件只有 1 个**:`src/components/CustomWorldEntityEditorModal.tsx`。 -- 历史上已经出现过的两处高风险乱码,本次**未复现**: - - `src/components/GameShell.tsx` 角色选择页返回按钮 - - `src/data/npcInteractions.ts` 切磋敌对动作文案 -- 目前更主要的问题已经从“大面积乱码”转成了三类: - - 运行时 UI 里的英文缩写或英文句子 - - 编辑器 UI 里的英文术语、原始枚举值和英文缩写 - - 预设数据中的英文名称、分类、标签、世界倾向、构筑角色等原始值直接透到 UI - -## 复查口径 - -- 只统计会直接进入游戏 UI、编辑器 UI、预设预览或运行时详情的文本。 -- 不把纯内部实现名算进问题范围,比如接口路径、变量名、导入路径、素材文件夹名。 -- 终端输出已切换到 UTF-8 后复查,避免把“控制台显示乱码”误判成“源码真实乱码”。 - -## 一、已确认的源码级乱码 - -### 1. 编辑器 UI - -| 文件 | 行号 | 当前文本 | 说明 | -| --- | --- | --- | --- | -| `src/components/CustomWorldEntityEditorModal.tsx` | `360`, `381` | `褰撳墠浼氱洿鎺...`、`棰勮 #...` | 场景预设选择弹窗的说明文案和预设编号标签已写坏 | -| `src/components/CustomWorldEntityEditorModal.tsx` | `551`, `559`, `569`, `572`, `578`, `581`, `584`, `587` | `褰撳墠澶栬妯℃澘`、`褰㈣薄妯℃澘`、`鍚嶇О`、`绉板彿 / 韬唤`、`鑳屾櫙`、`鎬ф牸`、`鎴樻枟椋庢牸`、`鏍囩` | 可扮演角色编辑表单的标题与字段标签存在真实乱码 | -| `src/components/CustomWorldEntityEditorModal.tsx` | `663`, `666`, `669`, `672` | `鍚嶇О`、`韬唤 / 鑱岃兘`、`鎻忚堪`、`鍔ㄦ満` | 普通 NPC 编辑表单字段标签存在真实乱码,即“创建自定义世界 -> NPC 编辑页”这一段 | -| `src/components/CustomWorldEntityEditorModal.tsx` | `721`, `732`, `736`, `739` | `鍦烘櫙`、`棰勮鍥句腑鐨勫垏纾...`、`鍚嶇О`、`鎻忚堪` | 场景编辑器默认回退文案、说明文案和字段标签存在真实乱码 | -| `src/components/CustomWorldEntityEditorModal.tsx` | `771`, `791` | ``瑙掕壊-${...}``、`['绾跨储', '浜掑姩']` | 默认新建数据本身带乱码,会继续流入编辑器与结果页 | - -## 二、游戏 UI 中的英文残留 - -| 文件 | 行号 | 当前文本/值 | 说明 | -| --- | --- | --- | --- | -| `src/components/AdventurePanel.tsx` | `179-187` | `restores HP during an adventure run`、`fits offensive loadouts` 等整句英文 | 奖励物品自动描述仍是英文句子,会直接进入运行时奖励详情 | -| `src/components/AdventureEntityModal.tsx` | `815-816`, `988`, `1124-1126`, `1497` | `HP`、`MP` | 玩家、怪物、NPC 状态条与效果预览仍使用英文缩写 | -| `src/components/AdventureEntityModal.tsx` | `1071`, `1109`, `1426` | `NPC 信息`、`敌对NPC` / `NPC`、`NPC 背包` | NPC 相关标题和标签仍是中英混排 | -| `src/components/CompanionCampModal.tsx` | `176-177`, `232-233`, `254` | `HP`、`MP`、`NPC` | 同行编队卡片和空态提示仍保留英文缩写 | -| `src/components/NpcModals.tsx` | `251`, `355`, `407` | `NPC 商品列表`、`NPC 商品`、`HP` / `MP` | 商店弹窗标题和物品效果预览仍是中英混排 | -| `src/components/CharacterDetailModal.tsx` | `117` | `数量 x{item.quantity}` | 数量前缀里仍保留英文乘号写法 | - -## 三、编辑器 UI 中的英文残留 - -| 文件 | 行号 | 当前文本/值 | 说明 | -| --- | --- | --- | --- | -| `src/components/ItemCatalogEditor.tsx` | `601`, `617-623` | `neutral / wuxia / xianxia`、`tag` 原始值 | 世界倾向和标签直接显示原始英文值 | -| `src/components/ItemCatalogEditor.tsx` | `637-651`, `689` | `HP`、`MP`、`Build Buff`、`CD` | 属性预览、使用效果和背包卡片预览仍有英文缩写/术语 | -| `src/components/ItemCatalogEditor.tsx` | `662-665` | `buildProfile.role`、`synergy` 原始值 | 构筑角色和协同标签直接暴露英文角色定位值 | -| `src/components/ItemCatalogEditor.tsx` | `712`, `824`, `841` | `物品 ID`、`使用 Build Buff`、`套装 ID` | 字段标签里仍有 `ID` / `Build Buff` | -| `src/components/StateFunctionEditor.tsx` | `843-846`, `910` | `HP`、`No visible target` | 预览摘要和实时状态里仍有英文缩写与整句英文 | -| `src/components/StateFunctionEditor.tsx` | `1089-1091` | `Option behavior overrides saved.`、`Failed to save option behavior overrides` | 保存反馈文案仍是英文 | -| `src/components/StateFunctionEditor.tsx` | `1133`, `1212`, `1218` | `definition.state`、`AnimationState`、`idle/move/attack` | 原始状态值和动画值仍直接显示 | -| `src/components/PresetEditor.tsx` | `86-88`, `2212` | `NPC`、`敌对 NPC` | 页签和说明文案仍有 `NPC` 英文缩写 | -| `src/components/PresetEditor.tsx` | `893-910`, `1997-2000` | `AnimationState`、`steady/burst/...`、`idle/move/attack/die` | 技能动作、技能风格、怪物预览动作直接显示原始英文枚举值 | -| `src/components/PresetEditor.tsx` | `942`, `1015`, `1142`, `1456`, `1477`, `1483`, `1752`, `1788`, `1796`, `2037`, `2137`, `2174` | `Build Buff`、`ID`、`FPS` | 多个编辑字段和动画配置项仍保留英文术语 | -| `src/components/NpcVisualEditor.tsx` | `568-571`, `581`, `817` | `NPC 视觉编辑器`、`当前 NPC`、`x / y` | 标题、字段名和坐标显示仍有英文缩写 | - -## 四、预设 / 数据层中的英文残留 - -这些内容虽然不一定直接在当前文件里渲染,但会进入运行时详情、掉落展示、物品编辑器、预设编辑器或交易界面。 - -| 文件 | 行号 | 当前文本/值 | 透出路径 | -| --- | --- | --- | --- | -| `src/data/monsterPresets.ts` | `438-456`, `479-499`, `535-540`, `647-650` | `Material`、`Relic`、`Armor`、`Consumable`、`Stone Shell Shard`、`Blood Lens`、英文描述句子、`rare/uncommon`、`material/relic/mana` | 会进入怪物掉落、物品详情、交易弹窗和编辑器预览 | -| `src/data/itemDesign.ts` | `56-72`, `123-149`, `602-604`, `761-762`, `832-834` | `worldAffinity` 的 `neutral/wuxia/xianxia`,`role` 的 `fieldcraft/breaker/caster/berserker/assassin`,`tags` 的 `breaker/burst/mana`,`pieceName` 的 `dust/crystal/gem`,以及 `build` 混排短语 | 会直接透到 `ItemCatalogEditor` 的世界、标签、构筑角色、协同标签和套装信息 | -| `src/data/characterPresets.ts` | `368-379`, `384-386`, `525-543`, `839-857`, `1024-1045` | `Double Jump`、`jump attack`、`Wall Slide`、`blunt/dry/direct`、`wary/fragmented` 等原始动作名和对话风格值 | 会被 `PresetEditor` 的动作/风格选择器直接显示 | - -## 五、未复现的问题 - -- `src/components/GameShell.tsx` 角色选择页返回按钮旧乱码已修复,当前为“返回”。 -- `src/data/npcInteractions.ts` 旧的切磋动作乱码已修复,当前为“敌对/切磋前蓄力,点击后转为原地闪避”。 -- 本次扫描 `src/components`、`src/data` 未发现 `�`(replacement character)类型的编码损坏。 -- 除 `src/components/CustomWorldEntityEditorModal.tsx` 外,本次未再确认到新的源码级中文乱码文件。 -- 自定义世界的 NPC 视觉编辑组件 `src/components/CustomWorldNpcVisualEditor.tsx` 本次未发现新的乱码。 - -## 六、建议处理顺序 - -1. 先修 `src/components/CustomWorldEntityEditorModal.tsx` 的真实乱码。 -2. 再清理 `src/components/AdventurePanel.tsx` 和 `src/data/monsterPresets.ts` 的整句英文,因为它们最容易直接破坏玩家观感。 -3. 为高频缩写和枚举值补统一映射层: - - `NPC` - - `HP` / `MP` / `CD` - - `worldAffinity` - - `role` - - `tags` - - `AnimationState` - - 技能风格 `steady/burst/mobility/finisher/projectile` -4. 最后统一编辑器里所有 `ID`、`FPS`、`Build Buff` 之类术语的显示策略。 diff --git a/docs/audits/text/GAME_EDITOR_PRESET_TEXT_AUDIT_2026-03-30.md b/docs/audits/text/GAME_EDITOR_PRESET_TEXT_AUDIT_2026-03-30.md deleted file mode 100644 index dfb070bd..00000000 --- a/docs/audits/text/GAME_EDITOR_PRESET_TEXT_AUDIT_2026-03-30.md +++ /dev/null @@ -1,87 +0,0 @@ -# 游戏 UI / 预设 / 编辑器 UI 英文与乱码复核 - -日期:`2026-03-30` - -## 结论摘要 - -- 当前分支里,确认仍在真实渲染的源码级乱码主要集中在 2 个文件: - - `src/components/GameShell.tsx` - - `src/components/CustomWorldEntityEditorModal.tsx` -- `src/components/NpcVisualEditor.tsx` 中确实还留有旧乱码字符串,但位于 `/* ... */` 注释块里,本次不计入“当前 UI 问题”。 -- 英文残留仍然较多,主要分为三类: - - 游戏运行时界面的英文标题、空态文案和缩写 - - 编辑器界面的英文术语、英文保存反馈和原始枚举值 - - 预设 / 数据层中的英文名称、标签、角色定位、动画目录和 build 相关原值直接透到 UI - -## 复核口径 - -- 显式按 UTF-8 读取文件,避免把终端编码问题误判成源码乱码。 -- 只统计会进入游戏 UI、编辑器 UI、预设预览或结果页的文本。 -- 注释块、变量名、导入路径、接口路径等内部实现名不计入本次问题清单。 -- 英文残留部分以下表中的“当前确实会显示或透传”的高优先级项为主。 - -## 一、已确认的真实乱码 - -| 范围 | 文件 | 行号 | 当前文本 | 说明 | -| --- | --- | --- | --- | --- | -| 游戏 UI | `src/components/GameShell.tsx` | `565` | `瑙掕壊` | 主界面底部“角色”页签已写坏 | -| 游戏 UI | `src/components/GameShell.tsx` | `578` | `鍐掗櫓` | 主界面底部“冒险”页签已写坏 | -| 游戏 UI | `src/components/GameShell.tsx` | `591` | `鑳屽寘` | 主界面底部“背包”页签已写坏 | -| 游戏 UI | `src/components/GameShell.tsx` | `710` | `闃熶紞` / `鑳屽寘` | 浮层标题根据面板切换时会显示乱码 | -| 编辑器 UI | `src/components/CustomWorldEntityEditorModal.tsx` | `386` | `宸查€?` | 场景预设选择弹窗中的“已选中”状态标签已写坏 | -| 编辑器 UI | `src/components/CustomWorldEntityEditorModal.tsx` | `432` | `鍙栨秷` | 统一保存栏的取消按钮已写坏 | -| 编辑器 UI | `src/components/CustomWorldEntityEditorModal.tsx` | `436` | `淇濆瓨淇敼` | 统一保存栏的主按钮文案已写坏 | - -## 二、游戏 UI 中的英文残留 - -| 文件 | 行号 | 当前文本 / 值 | 说明 | -| --- | --- | --- | --- | -| `src/components/AdventurePanel.tsx` | `363` | `Currency` | 任务奖励卡的货币标题仍是英文 | -| `src/components/AdventurePanel.tsx` | `371` | `No item bounty attached to this quest.` | 任务奖励空态文案仍是英文 | -| `src/components/AdventurePanel.tsx` | `1424-1428` | `LOOT CACHE`、`Tap an item icon to inspect its details.`、`No usable loot dropped this time, but the battle is still settled.` | 战利品弹层标题和说明仍是整句英文 | -| `src/components/AdventurePanel.tsx` | `1442` | `No loot dropped this time.` | 战利品列表空态文案仍是英文 | -| `src/components/AdventurePanel.tsx` | `1352`, `1524` | `x{item.quantity}`、`HP` / `MP` | 数量展示与效果预览仍保留英文缩写 | -| `src/components/AdventureEntityModal.tsx` | `892-899` | `label="HP"`、`label="MP"` | 同行状态估计卡仍使用英文缩写 | -| `src/components/AdventureEntityModal.tsx` | `1073`, `1111`, `1428` | `NPC 信息`、`敌对NPC` / `NPC`、`NPC 背包` | NPC 详情区仍是中英混排 | -| `src/components/CompanionCampModal.tsx` | `177-178`, `233-234`, `255` | `HP`、`MP`、`NPC` | 营地卡片和空态提示仍保留英文缩写 | -| `src/components/NpcModals.tsx` | `79`, `252`, `273`, `356`, `408` | `x{item.quantity}`、`NPC 商品列表`、`这个 NPC 当前没有可售商品。`、`NPC 商品`、`HP` / `MP` | 交易弹窗、详情弹窗和数量角标存在中英混排 | -| `src/components/CharacterDetailModal.tsx` | `117` | `数量 x{item.quantity}` | 角色详情中的数量前缀仍保留英文 `x` | - -## 三、编辑器 UI 中的英文残留 - -| 文件 | 行号 | 当前文本 / 值 | 说明 | -| --- | --- | --- | --- | -| `src/components/ItemCatalogEditor.tsx` | `576-581`, `621-624` | `fieldcraft`、`breaker`、`mana`、`boots`、`dust`、`crystal`、`gem` 等原值 | 物品标签、构筑角色、部件名和协同信息会直接显示英文原值 | -| `src/components/ItemCatalogEditor.tsx` | `648`, `671`, `729-783`, `800` | `HP` / `MP` / `CD`、`物品 ID`、`使用 Build Buff`、`套装 ID` | 物品编辑器预览与字段标签仍有英文缩写 / 术语 | -| `src/components/StateFunctionEditor.tsx` | `818-821`, `885`, `915` | `HP`、`No visible target`、`n/a` | 选项行为预览面板仍有英文缩写和英文空态 | -| `src/components/StateFunctionEditor.tsx` | `1060-1064` | `Failed to save option behavior overrides`、`Option behavior overrides saved.` | 保存反馈仍是英文 | -| `src/components/StateFunctionEditor.tsx` | `1106`, `1185`, `1191` | `battle/idle`、`AnimationState` 的原始动作值、`idle/move/attack` | 状态和动作枚举值直接显示为英文 | -| `src/components/PresetEditor.tsx` | `88`, `90`, `1474-1501`, `1806-1814` | `NPC`、`敌对 NPC`、`NPC ID`、`关联角色 ID`、`敌对资源 ID`、`连接场景 ID` | 多个标签和页签仍保留英文缩写 / `ID` | -| `src/components/PresetEditor.tsx` | `101-106`, `896-913`, `2008-2018` | `idle/move/attack/die`、`steady/burst/mobility/finisher/projectile` | 角色技能和敌对资源预览会直接显示英文枚举值 | -| `src/components/PresetEditor.tsx` | `945`, `2155`, `2192` | `Build Buff`、`FPS` | 技能编辑和动作图集配置仍有英文术语 | -| `src/components/NpcVisualEditor.tsx` | `416-461` | `Failed to load NPC visual overrides`、`Failed to load NPC layout config`、`using bundled defaults` | NPC 视觉编辑器的加载失败提示仍是英文 | -| `src/components/NpcVisualEditor.tsx` | `678`, `718` | `Saved NPC visual overrides to ...`、`Saved shared NPC layout config.` | 保存成功反馈仍是英文 | -| `src/components/NpcVisualEditor.tsx` | `903`, `906`, `919`, `1226` | `NPC 视觉编辑器`、`当前 NPC`、`x ... / y ...` | 标题、字段标签与坐标信息仍存在中英混排 | - -## 四、预设 / 数据层中会透到 UI 的英文值 - -| 文件 | 行号 | 当前文本 / 值 | 透出路径 | -| --- | --- | --- | --- | -| `src/data/monsterPresets.ts` | `494-512`, `522-540`, `647-652`, `718-723` | `Armor`、`Relic`、`Material`、`Consumable`、`Carapace Plate`、`Guard Core`、`Spore Pouch`、`Burst Cap`、`Ashfire Feather`、英文描述句子、`rare/uncommon` | 会进入怪物掉落、战利品详情、交易弹窗和物品预览 | -| `src/data/itemDesign.ts` | `56-57`, `67-68`, `123-149` | `worldAffinity: neutral/wuxia/xianxia`、`role: fieldcraft/breaker/caster/berserker/assassin`、`tags` 中的 `caster/mana/burst/assassin` | 会透到 `ItemCatalogEditor` 的世界、角色定位、标签和协同信息 | -| `src/data/itemDesign.ts` | `213-219`, `538-545`, `589-604`, `731-762`, `820-834`, `906-918` | `pieceName: boots/chest/gloves/...`、`build`、`setId`、`role`、`dust/crystal/gem` 等 | 会透到物品编辑器、套装信息和部件信息展示 | -| `src/data/characterPresets.ts` | `54-69` | `blunt/wary/dry/direct/fragmented` | 对话风格与性格归类原值会被编辑器直接显示 | -| `src/data/characterPresets.ts` | `368-379`, `525-526`, `839-850`, `1024-1025` | `Double Jump`、`jump attack`、`Wall Slide` | 角色动作目录 / 前缀原值会被 `PresetEditor` 直接显示 | -| `src/data/characterPresets.ts` | `384-386`, `541-543`, `855-857`, `1045` | `guardStyle` / `warmStyle` / `truthStyle` 对应的英文原值 | 角色预设风格字段在编辑器中仍会显示英文 | - -## 五、未计入项 - -- `src/components/NpcVisualEditor.tsx:681-683`、`721-722` 的乱码字符串位于块注释内,不会进入当前界面,因此未计入本次“活跃问题”。 -- `docs/*.md` 里的历史审计文档和旧清单不在本次范围内,本次只统计游戏 UI、预设和编辑器 UI。 - -## 六、建议处理顺序 - -1. 先修 `src/components/GameShell.tsx` 和 `src/components/CustomWorldEntityEditorModal.tsx` 的真实乱码,因为它们已经直接出现在主流程界面。 -2. 再清理 `src/components/AdventurePanel.tsx` 的英文空态、战利品标题和 `Currency`,这是玩家最容易直接看到的一批英文。 -3. 然后统一编辑器术语映射,优先处理 `HP` / `MP` / `NPC` / `ID` / `FPS` / `Build Buff` / `AnimationState`。 -4. 最后为 `src/data/itemDesign.ts`、`src/data/monsterPresets.ts`、`src/data/characterPresets.ts` 增加显示层映射,避免原始英文值继续直接透到编辑器和运行时界面。 diff --git a/docs/audits/text/GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-03-30_CONTINUED.md b/docs/audits/text/GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-03-30_CONTINUED.md deleted file mode 100644 index 5047d8bf..00000000 --- a/docs/audits/text/GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-03-30_CONTINUED.md +++ /dev/null @@ -1,280 +0,0 @@ -# 游戏 UI / 预设实体 / 编辑器 UI 英文与乱码复核(续) - -日期:`2026-03-30` - -## 说明 - -- 这份文档是对当前分支的重新复核,不直接沿用旧审计文档的正文,因为旧文档本身已经存在较明显乱码。 -- 本轮重点覆盖三类范围: - - 游戏运行时 UI:`src/components/` 下实际会进入主流程的界面,以及 `src/components/game-shell/` - - 编辑器 UI:`src/components/*Editor*.tsx`、`src/components/preset-editor/`、`src/editor/shared/` - - 预设实体 / 数据层:`src/data/` 中会被编辑器、预览面板或游戏详情页直接透出的文本 -- 复核方式: - - 直接按 UTF-8 读取源码,避免把终端显示问题误判成源码乱码 - - 只记录会显示在玩家或编辑器使用者面前的文本 - - `import`、类型名、变量名、接口字段名、纯内部注释默认不计入 - - 但保存 / 加载提示这类虽然来自 helper 文件、最终会显示到 UI 的字符串,仍计入 - -## 结论摘要 - -- 当前分支里,真正“源码里已经写坏”的中文乱码,主要集中在 4 个位置: - - `src/components/GameShell.tsx` - - `src/components/preset-editor/shared.ts` - - `src/components/CustomWorldEntityEditorModal.tsx` - - `src/components/preset-editor/PresetEditorPanels.tsx` -- 其中最严重的是 `src/components/preset-editor/PresetEditorPanels.tsx`: - - 角色/NPC/场景/敌对 NPC 资源四个子面板里都有残缺字符串 - - 同时混有 `NPC`、`ID`、`FPS`、`Build Buff`、`Medieval NPC` 等英文术语 -- 数据层 `src/data/` 本轮没有再扫到新的中文乱码;问题更多是英文预设值直接透到编辑器 / 预览 UI。 -- 游戏运行时 UI 侧已经比旧清单干净很多,但仍有几块明显英文残留: - - `AdventurePanel` - - `AdventureEntityModal` - - `CompanionCampModal` - - `NpcModals` - - `game-shell/CharacterSelectionFlow` - -## 一、已确认的真乱码 - -| 范围 | 文件 | 行号 | 当前文本示例 | 说明 | -| --- | --- | --- | --- | --- | -| 游戏 UI | `src/components/GameShell.tsx` | `565`, `578`, `591`, `710` | `瑙掕壊`、`鍐掗櫓`、`鑳屽寘`、`闃熶紞` | 主界面底部 tab 和浮层标题已写坏 | -| 编辑器 UI | `src/components/preset-editor/shared.ts` | `42-55` | `瑙掕壊`、`鍦烘櫙`、`鐗╁搧`、`鏁屽 NPC`、`姝︿緺`、`浠欎緺`、`鑷畾涔変笘鐣?` | 新版预设编辑器 tab 与世界标签已写坏 | -| 编辑器 UI | `src/components/CustomWorldEntityEditorModal.tsx` | `383`, `429`, `433` | `宸查€?`、`鍙栨秷`、`淇濆瓨淇敼` | 自定义世界实体编辑弹窗里的已选中/取消/保存文案乱码 | -| 编辑器 UI | `src/components/preset-editor/PresetEditorPanels.tsx` | `251`, `530`, `1383`, `1468`, `1478`, `1830` 等多处 | `鏂版妧鑳?`、`鏂板鎶€鑳?`、`绾満鏅?`、`鑳屾櫙鍥捐矾寰?`、`涓嶈缃?`、`... FPS銆?` | 新版预设编辑器存在大面积残缺字符串,部分已经带 `?` 结尾 | - -### `PresetEditorPanels.tsx` 乱码分布 - -- 角色预设区: - - `251`, `310`, `323`, `379`, `467-688`, `719-802` - - 示例:`新技�?`、`预览技�?`、`法力消�?`、`属性面�?`、`主场�?` -- NPC 预设区: - - `1000-1208` - - 示例:`这里汇总了场景里的所�?NPC 角色预设�?`、`如果�?NPC 绑定了角色技能...�?`、`敌对 NPC 会沿用战斗资源预设展示...�?` -- 场景预设区: - - `1244-1478` - - 示例:`没有可编辑的场景预设�?`、`敌�?NPC`、`纯场�?`、`背景图路�?`、`不设�?` -- 敌对 NPC 资源区: - - `1551-1851` - - 示例:`没有可编辑的敌对 NPC 资源�?`、`基础数�?`、`最大生�?`、`... 和 FPS�?`、`起始�?` - -## 二、游戏 UI 中仍会显示的英文 - -### 1. 主冒险面板 - -- `src/components/AdventurePanel.tsx:363` - - `Currency` -- `src/components/AdventurePanel.tsx:371` - - `No item bounty attached to this quest.` -- `src/components/AdventurePanel.tsx:1424` - - `LOOT CACHE` -- `src/components/AdventurePanel.tsx:1427-1428` - - `Tap an item icon to inspect its details.` - - `No usable loot dropped this time, but the battle is still settled.` -- `src/components/AdventurePanel.tsx:1442` - - `No loot dropped this time.` -- `src/components/AdventurePanel.tsx:1524` - - `HP` / `MP` - -### 2. 实体详情与交互弹窗 - -- `src/components/AdventureEntityModal.tsx:1163-1165` - - `x{item.quantity}` - - `Inspect` -- `src/components/AdventureEntityModal.tsx:1428` - - `NPC 背包` -- `src/components/CompanionCampModal.tsx:177-178`, `233-234`, `255` - - `HP` - - `MP` - - `NPC` -- `src/components/NpcModals.tsx:252`, `273`, `356`, `408` - - `NPC 商品列表` - - `这个 NPC 当前没有可售商品。` - - `NPC 商品` - - `HP` / `MP` - -### 3. 开场选角流 - -- `src/components/game-shell/CharacterSelectionFlow.tsx:28-32` - - `Sword Princess` - - `Royal Blade` - - `Vanguard` - - `Twin Blade Rogue` - - `Assassin` - - `Armored Spear` -- `src/components/game-shell/CharacterSelectionFlow.tsx:35-39` - - `STR` - - `AGI` - - `INT` - - `SPI` -- `src/components/game-shell/CharacterSelectionFlow.tsx:329-333` - - `Character Stats` - - `Gender:` - -## 三、编辑器 UI 中仍会显示的英文 - -### 1. 旧预设编辑入口 - -- `src/components/PresetEditor.tsx:61-69` - - `Preset Workshop` - - `Unified Preset Preview And Editor` - - `Manage character, NPC, scene, monster, item, and behavior presets from one editor shell. Each tab now loads its own container so the entry component stays small and focused.` - -### 2. 新预设编辑器共享配置 - -- `src/components/preset-editor/shared.ts:60-72` - - `idle` - - `move` - - `attack` - - `die` - - `steady` - - `burst` - - `mobility` - - `finisher` - - `projectile` - -### 3. 新预设编辑器主面板 - -- `src/components/preset-editor/PresetEditorPanels.tsx:620` - - `Build Buff` -- `src/components/preset-editor/PresetEditorPanels.tsx:966` - - `No NPC presets available.` -- `src/components/preset-editor/PresetEditorPanels.tsx:1100-1202` - - `NPC` - - `NPC ID` - - `Medieval NPC` -- `src/components/preset-editor/PresetEditorPanels.tsx:1830`, `1867` - - `FPS` - -### 4. 物品编辑器 - -- `src/components/ItemCatalogEditor.tsx:648` - - `HP` - - `MP` - - `CD` -- `src/components/ItemCatalogEditor.tsx:783` - - `Build Buff` -- `src/components/ItemCatalogEditor.tsx:800` - - `套装 ID` -- `src/components/ItemCatalogEditor.tsx:576-585`, `793-800` - - `selectedItem.tags`、`buildProfile.role`、`setId` 等原始英文值会直接显示在预览或输入框里 - -### 5. 选项行为编辑器 - -- `src/components/StateFunctionEditor.tsx:818`, `821` - - `HP` - - `No visible target` -- `src/components/StateFunctionEditor.tsx:885`, `915` - - `HP` - - `n/a` -- `src/components/StateFunctionEditor.tsx:1060-1064` - - `Failed to save option behavior overrides` - - `Option behavior overrides saved.` -- `src/components/StateFunctionEditor.tsx:1185` - - `AnimationState` 枚举值直接作为 label 显示 -- `src/components/StateFunctionEditor.tsx:1191` - - `idle` / `move` / `attack` -- `src/components/StateFunctionEditor.tsx:1217` - - `steady` / `burst` / `mobility` / `finisher` / `projectile` - -### 6. NPC 视觉编辑器与自定义世界编辑器 - -- `src/components/npcVisualEditorPersistence.ts:27-32`, `46-51` - - `Failed to save NPC visual overrides` - - `Saved NPC visual overrides to src/data/npcVisualOverrides.json.` - - `Failed to save NPC layout config` - - `Saved shared NPC layout config.` -- `src/components/CustomWorldEntityCatalog.tsx:345` - - `MedievalFantasyCharacters` -- `src/components/CustomWorldEntityEditorModal.tsx:457` - - `MedievalFantasyCharacters` - -## 四、预设实体 / 数据层中会透到 UI 的英文值 - -### 1. 物品预设 - -- `src/data/itemDesign.ts:52-58`, `67-69`, `123-149` - - `worldAffinity: "neutral" / "wuxia" / "xianxia"` - - `role: "fieldcraft" / "breaker" / "caster" / "berserker" / "assassin"` - - `rarity: "common" / "rare" / "epic"` - - `tags: ["caster", "mana"]` 等 -- `src/data/itemDesign.ts:213-219` - - `pieceName: "boots" / "chest" / "gloves" / "helm" / "leggings" / "shield" / "weapon"` -- `src/data/itemDesign.ts:538-545`, `588-606`, `730-766`, `818-836`, `904-919` - - 描述和 profile 中直接拼入 `build`、`role`、`dust`、`crystal`、`gem` 等英文值 - - 这些字段会在 `ItemCatalogEditor` 预览和构筑信息里直出 - -### 2. 敌对资源 / 掉落预设 - -- `src/data/monsterPresets.ts:494-540`, `647-723` - - 掉落类别:`Armor`、`Relic`、`Material`、`Consumable` - - 掉落名称:`Carapace Plate`、`Guard Core`、`Spore Pouch`、`Burst Cap`、`Ashfire Feather`、`Serpent Eye`、`Tide Ink`、`Lake Pearl`、`Thorn Nectar` - - 掉落描述整句仍是英文 -- 这些条目会直接进入掉落预览、NPC 交易与物品详情 - -### 3. 角色预设 - -- `src/data/characterPresets.ts:53-70` - - 对话风格值:`blunt`、`wary`、`evasive`、`measured`、`gentle`、`teasing`、`dry`、`steady`、`direct`、`fragmented`、`deflecting` -- `src/data/characterPresets.ts:368-379`, `525-536`, `839-850`, `1024-1038` - - 动画资源名:`Double Jump`、`jump attack`、`Wall Slide`、`skill1 bullet FX` 等 -- `src/data/characterPresets.ts:384-386`, `541-543`, `855-857`, `1043-1045` - - `guardStyle` / `warmStyle` / `truthStyle` 的英文原值 -- 这些值会在角色预设编辑器与动作 / 风格下拉中透出 - -### 4. Build / 标签词典 - -- `src/data/buildTags.ts:42`, `56`, `91`, `126-147`, `309-316` - - `assassin` - - `fieldcraft` - - `breaker` - - `caster` - - `armor` - - `relic` - - `material` - - `consumable` - - `rare` - - `wuxia` - - `xianxia` - - `neutral` -- 这些原始 tag 会通过物品标签、build profile 和编辑器预览进入显示层 - -## 五、本轮复核中未发现新增中文乱码的范围 - -### 游戏 UI - -- `src/components/CharacterChatModal.tsx` -- `src/components/CharacterDetailModal.tsx` -- `src/components/CharacterPanel.tsx` -- `src/components/MapModal.tsx` - -说明: -- 上述文件大体已中文化。 -- 仍可能存在少量英文缩写、内部 ID 或技术词,但本轮没有再发现新的明显中文乱码。 - -### 数据层 - -- `src/data/scenePresets.ts` -- `src/data/npcInteractions.ts` -- `src/data/treasureInteractions.ts` -- `src/data/customWorldLibrary.ts` -- `src/data/customWorldRuntime.ts` - -说明: -- 本轮在 `src/data/` 中没有扫到新的中文乱码。 -- 当前数据层问题主要是英文 tag、role、rarity、pieceName 等原始值会被上层编辑器直接显示。 - -## 六、建议优先级 - -1. 先修 `src/components/preset-editor/PresetEditorPanels.tsx` - - 当前最集中的真乱码源 - - 已经影响角色 / NPC / 场景 / 敌对资源四个主编辑子页 -2. 再修 `src/components/preset-editor/shared.ts` 与 `src/components/GameShell.tsx` - - 一个影响预设编辑入口 tab 与世界标签 - - 一个影响玩家主界面底部导航 -3. 然后处理 `src/components/CustomWorldEntityEditorModal.tsx` - - 量不大,但按钮文案已经坏到影响操作判断 -4. 最后统一清英文术语 - - 游戏 UI:`AdventurePanel`、`AdventureEntityModal`、`CompanionCampModal`、`NpcModals`、`CharacterSelectionFlow` - - 编辑器 UI:`PresetEditor.tsx`、`ItemCatalogEditor.tsx`、`StateFunctionEditor.tsx`、`npcVisualEditorPersistence.ts` - - 数据层:`itemDesign.ts`、`monsterPresets.ts`、`characterPresets.ts`、`buildTags.ts` - diff --git a/docs/audits/text/GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-03-31.md b/docs/audits/text/GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-03-31.md deleted file mode 100644 index b1248e3b..00000000 --- a/docs/audits/text/GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-03-31.md +++ /dev/null @@ -1,194 +0,0 @@ -# 游戏 UI / 预设 / 编辑器 UI 文案排查 - -日期:`2026-03-31` - -## 说明 - -- 本文档基于当前分支源码重新复核,直接按 UTF-8 读取,不沿用旧审计文档中的乱码文本。 -- 只记录会出现在游戏 UI、预设编辑器 UI、结果页预览或保存反馈中的文本。 -- `import`、变量名、注释、仅内部使用的路径名,不计入本次问题清单。 -- 位图图片里的内嵌文本未做 OCR,本次只看源码层可见文案。 - -## 结论摘要 - -- 当前问题可以分成 3 类: - - 真实中文乱码或截断。 - - 英文或英文缩写直接暴露在中文界面。 - - 预设数据中的英文原始值直接透出到编辑器或预览。 -- 乱码最集中的文件: - - `src/components/preset-editor/PresetEditorPanels.tsx` - - `src/components/NpcVisualEditor.tsx` - - `src/components/CustomWorldEntityEditorModal.tsx` - - `src/components/GameShell.tsx` - - `src/editor/shared/FormFields.tsx` -- 英文最集中的文件: - - `src/components/adventure-panel/AdventurePanelOverlays.tsx` - - `src/components/game-shell/PreGameSelectionFlow.tsx` - - `src/components/game-shell/CharacterSelectionFlow.tsx` - - `src/components/PresetEditor.tsx` - - `src/components/ItemCatalogEditor.tsx` - - `src/components/StateFunctionEditor.tsx` -- 预设数据层仍有一批英文原始值会直接透出到 UI: - - `src/data/itemDesign.ts` - - `src/data/monsterPresets.ts` - - `src/data/characterPresets.ts` - - `src/data/buildTags.ts` - -## 一、已确认的中文乱码 / 截断 - -| 范围 | 文件 | 行号 | 当前文本示例 | 说明 | -| --- | --- | --- | --- | --- | -| 游戏 UI | `src/components/GameShell.tsx` | `598`, `611`, `624` | `瑙掕壊` / `鍐掗櫓` / `鑳屽寘` | 主流程底部三个 tab 标签已写坏 | -| 游戏 UI | `src/components/AdventurePanel.tsx` | `569-571` | `已完�?` / `已交�?` / `进行�?` | 任务状态标签出现截断乱码 | -| 游戏 UI | `src/components/CharacterDetailModal.tsx` | `223` | `属�?` | 角色详情分区标题截断 | -| 编辑器 UI | `src/components/CustomWorldEntityEditorModal.tsx` | `242`, `384`, `430`, `434` | `鏀寔...URL` / `宸查€?` / `鍙栨秷` / `淇濆瓨淇敼` | 自定义世界实体编辑弹窗的占位、选中态、取消和保存按钮已写坏 | -| 编辑器 UI | `src/components/preset-editor/shared.ts` | `42-55` | `瑙掕壊` / `鍦烘櫙` / `鏁屽 NPC` / `姝︿緺` / `浠欎緺` / `鑷畾涔変笘鐣?` | 预设编辑器主 tab 和世界标签存在乱码 | -| 编辑器 UI | `src/components/preset-editor/PresetEditorPanels.tsx` | `1269`, `1364`, `1371-1372`, `1467`, `1477-1486`, `1521`, `1654-1661`, `1689`, `1707` | 多处整句乱码 | 主编辑面板说明文案、预览模式、帮助文本、提示段落大面积损坏 | -| 编辑器 UI | `src/components/NpcVisualEditor.tsx` | `463`, `521`, `550`, `701-705`, `719`, `786-833` | 多处整句乱码 | NPC 视觉编辑器的空态、失败提示、回滚提示、页头说明和多组选项已写坏 | -| 编辑器 UI | `src/editor/shared/FormFields.tsx` | `156` | `淇濆瓨涓?..` | 通用保存按钮的“保存中...”状态显示乱码 | - -## 二、游戏 UI 中的英文残留 - -### 1. 冒险主界面与奖励弹层 - -- `src/components/adventure-panel/AdventurePanelOverlays.tsx:114-125` - - 奖励物品描述 fallback 仍是整句英文,如 `restores HP during the run`、`works as a rare relic reward`。 -- `src/components/adventure-panel/AdventurePanelOverlays.tsx:136-157` - - 任务目标展示里仍有 `BOUNTY TARGET`、`CACHE TRACE`、`SPAR SESSION`、`Inspect the hidden reward site`。 -- `src/components/adventure-panel/AdventurePanelOverlays.tsx:262-291` - - 任务奖励卡里仍有 `REWARD CACHE`、`Tap an item icon to inspect its details.`、`Affinity`、`Currency`、`No item bounty attached to this quest.`。 -- `src/components/adventure-panel/AdventurePanelOverlays.tsx:351-358` - - 目标详情卡仍有 `Objective`、`Area`。 -- `src/components/adventure-panel/AdventurePanelOverlays.tsx:490`, `525`, `668` - - 统计说明、保存禁用提示、空任务提示仍是英文,如 `Inspect play time, kills, quests, and travel history.`、`Saving is temporarily disabled...`、`No active quests yet.`。 -- `src/components/adventure-panel/AdventurePanelOverlays.tsx:749`, `781-785`, `831`, `887-908`, `925-1016` - - 完成奖励与战斗奖励弹层仍有 `Claim reward`、`QUEST COMPLETE`、`Reward ready`、`Quest reward claimed`、`Battle reward`、`LOOT CACHE`、`No loot dropped this time.`、`Rarity`、`Quantity`、`Slot`、`Not equippable`、`Usable directly`、`Effect preview: HP + ... / MP + ...`。 - -### 2. 实体详情与 NPC 交互 - -- `src/components/AdventureEntityModal.tsx:1073`, `1111`, `1163-1165`, `1252`, `1428` - - 仍有 `NPC 信息`、`NPC`、`x{item.quantity}`、`Inspect`、`Character`、`NPC 背包`。 -- `src/components/AdventureEntityModal.tsx:892`, `898` - - 同伴状态标签仍直接显示 `HP` / `MP`。 -- `src/components/CompanionCampModal.tsx:177-178`, `233-234`, `255` - - 同伴卡片和空态句子里仍有 `HP` / `MP` / `NPC`。 -- `src/components/NpcModals.tsx:79`, `252`, `273`, `356`, `408` - - 交易弹窗与详情弹窗里仍有 `x{item.quantity}`、`NPC 商品列表`、`这个 NPC 当前没有可售商品。`、`NPC 商品`、`效果预览:HP + ... / MP + ...`。 - -### 3. 开场流程与角色选择 - -- `src/components/game-shell/CharacterSelectionFlow.tsx:28-44` - - 角色名、称号、定位、标签全部是英文,如 `Sword Princess`、`Royal Blade`、`Vanguard`、`STR`、`AGI`、`Female`、`Male`。 -- `src/components/game-shell/CharacterSelectionFlow.tsx:329-391` - - 面板标题和按钮仍有 `Character Stats`、`Gender:`、`Backstory`、`Customize`、`Details`、`Enter Camp`、`Go`。 -- `src/components/game-shell/PreGameSelectionFlow.tsx:63-75` - - 自定义世界生成进度仍全是英文,如 `Finalizing world archive...`、`Generating core NPCs...`、`Parsing world setup...`。 -- `src/components/game-shell/PreGameSelectionFlow.tsx:252-308` - - 开场按钮和入口仍有 `New Game`、`Start Game`、`Developer Team`、`Go`、`CONTACTS`、`WORLD SELECT`、`Back`。 -- `src/components/game-shell/PreGameSelectionFlow.tsx:344-421` - - 世界卡片与自定义世界入口仍有 `Online`、`Featured`、`Saved`、`Playable`、`Landmarks`、`Custom`、`Create Custom World`、`Enter a world setup...`。 -- `src/components/GameShell.tsx:630`, `651`, `695` - - Suspense fallback 仍显示 `Loading party panel`、`Loading adventure panel`、`Loading inventory panel`。 - -### 4. 其他游戏 UI - -- `src/components/CharacterDetailModal.tsx:112` - - `数量 x{item.quantity}` 中的 `x` 仍保留英文数量前缀。 - -## 三、编辑器 UI 中的英文残留 - -### 1. 编辑器入口与共享配置 - -- `src/components/PresetEditor.tsx:65-73` - - 页头完整为英文:`Preset Workshop`、`Unified Preset Preview And Editor` 及其说明段。 -- `src/components/preset-editor/shared.ts:43`, `60-72` - - 主 tab 仍有 `NPC`;动画和技能风格选项仍直接使用 `idle`、`move`、`attack`、`die`、`steady`、`burst`、`mobility`、`finisher`、`projectile`。 - -### 2. 预设编辑器主面板 - -- `src/components/preset-editor/PresetEditorPanels.tsx:1267`, `1594` - - 保存反馈仍是 `Saved.`。 -- `src/components/preset-editor/PresetEditorPanels.tsx:1277-1279`, `1327-1328`, `1414-1415`, `1608-1609`, `1647-1648` - - 多个分区标题和描述仍是占位英文 `Section` / `Editor section.`。 -- `src/components/preset-editor/PresetEditorPanels.tsx:1283`, `1442`, `1448` - - 表单标签出现错误拼接,如 `Field"NPC"`、`Field"ID"`。 -- `src/components/preset-editor/PresetEditorPanels.tsx:1320`, `1640` - - 保存按钮文字仍是 `Save`。 -- `src/components/preset-editor/PresetEditorPanels.tsx:1421`, `1658-1661`, `1692`, `1698`, `1701`, `1710`, `2149` - - 仍有 `NPC ID`、`Monster Encounter`、`NPC Encounter`、`Empty Scene`、`None`、`NPC`、`FPS` 等英文或英文缩写。 - -### 3. 物品 / 行为 / NPC 视觉编辑器 - -- `src/components/ItemCatalogEditor.tsx:648`, `729`, `736`, `760`, `767`, `783`, `800` - - 仍有 `HP`、`MP`、`CD`、`Build Buff`、`ID`。 -- `src/components/ItemCatalogEditor.tsx:793-817` - - `buildProfile.role`、`setId`、`pieceName` 等原始英文值直接显示在输入框。 -- `src/components/StateFunctionEditor.tsx:818-821`, `885`, `915` - - 预览信息里仍有 `HP`、`No visible target`、`n/a`。 -- `src/components/StateFunctionEditor.tsx:1060-1064` - - 保存失败/成功提示仍是英文:`Failed to save option behavior overrides`、`Option behavior overrides saved.`。 -- `src/components/StateFunctionEditor.tsx:1106`, `1185`, `1191`, `1217` - - 仍直接展示 `battle` / `idle`、`AnimationState` 原值、`idle` / `move` / `attack`,以及 `steady` / `burst` / `mobility` / `finisher` / `projectile`。 -- `src/components/NpcVisualEditor.tsx:538`, `714`, `781`, `798` - - 仍有 `Save failed`、`Current NPC`、`Custom Hair Color`、`Hide Facial Hair`。 -- `src/components/npcVisualEditorPersistence.ts:26`, `31`, `45`, `50` - - 保存提示仍为 `Failed to save NPC visual overrides`、`Saved NPC visual overrides to src/data/npcVisualOverrides.json.`、`Failed to save NPC layout config`、`Saved shared NPC layout config.`。 - -### 4. 自定义世界结果页 / 编辑弹窗 - -- `src/components/CustomWorldEntityCatalog.tsx:346` - - 说明文案里直接暴露资产名 `MedievalFantasyCharacters`。 -- `src/components/CustomWorldEntityEditorModal.tsx:242`, `458` - - 图片路径占位里仍保留 `URL`;NPC 形象编辑说明里直接出现 `MedievalFantasyCharacters`。 - -## 四、预设 / 数据层中会透出 UI 的英文原始值 - -### 1. 物品预设 - -- `src/data/itemDesign.ts:56-58`, `67-69`, `123-149` - - `worldAffinity`、`role`、`rarity`、`tags` 中仍有 `neutral`、`wuxia`、`xianxia`、`fieldcraft`、`breaker`、`caster`、`berserker`、`assassin`、`common`、`rare`、`epic` 等原始值。 -- `src/data/itemDesign.ts:213-219` - - `pieceName` 仍为 `boots`、`chest`、`gloves`、`helm`、`leggings`、`shield`、`weapon`。 -- `src/data/itemDesign.ts:538-545`, `581-598`, `731-748`, `906-913` - - 描述拼接和构筑信息里仍直接出现 `build`、`role`、`dust`、`crystal`、`gem` 等英文原始词。 -- 这些值会直接透出到 `ItemCatalogEditor` 的标签、构筑字段和预览信息。 - -### 2. 怪物掉落预设 - -- `src/data/monsterPresets.ts:494-536`, `647-721` - - 掉落类别仍有 `Armor`、`Relic`、`Material`、`Consumable`。 - - 掉落名称仍有 `Carapace Plate`、`Guard Core`、`Spore Pouch`、`Burst Cap`、`Ashfire Feather`、`Serpent Eye`、`Tide Ink`、`Lake Pearl`、`Thorn Nectar`。 - - 掉落描述仍有整句英文,如 `A toxin sac prized by alchemists and assassins alike.`。 -- 这些值会进入战斗奖励、物品详情和交易 UI。 - -### 3. 角色预设 - -- `src/data/characterPresets.ts:54-70` - - 会话风格原始值仍为 `blunt`、`wary`、`evasive`、`measured`、`gentle`、`teasing`、`dry`、`steady`、`direct`、`fragmented`、`deflecting`。 -- `src/data/characterPresets.ts:368-386`, `525-543`, `839-857`, `1024-1045` - - 动画文件夹 / 前缀与风格原始值仍有 `Double Jump`、`jump attack`、`Wall Slide`、`guardStyle`、`warmStyle`、`truthStyle`。 -- 这些值会透出到角色预设编辑器、技能预览和部分选择器。 - -### 4. Build / 标签词典 - -- `src/data/buildTags.ts:42`, `56`, `91`, `126-147`, `308-316` - - 仍有 `assassin`、`fieldcraft`、`breaker`、`caster`、`weapon`、`armor`、`relic`、`material`、`consumable`、`rare`、`wuxia`、`xianxia`、`neutral` 等原始标签。 -- 这些值会在物品编辑器标签、构筑画像和相似度映射结果中直接显示。 - -## 五、优先级建议 - -1. 先修 `src/components/preset-editor/PresetEditorPanels.tsx` 和 `src/components/NpcVisualEditor.tsx` - - 这两处是当前编辑器侧最严重的问题源,既有大面积乱码,也有大量英文占位词。 -2. 再修游戏首屏与奖励相关 UI - - 优先处理 `src/components/adventure-panel/AdventurePanelOverlays.tsx` - - 优先处理 `src/components/game-shell/PreGameSelectionFlow.tsx` - - 优先处理 `src/components/game-shell/CharacterSelectionFlow.tsx` -3. 然后修直接影响主流程判断的乱码 - - `src/components/GameShell.tsx` - - `src/components/AdventurePanel.tsx` - - `src/components/CharacterDetailModal.tsx` - - `src/components/CustomWorldEntityEditorModal.tsx` - - `src/editor/shared/FormFields.tsx` -4. 最后补“显示层映射” - - 为 `itemDesign.ts`、`monsterPresets.ts`、`characterPresets.ts`、`buildTags.ts` 这类预设原始值统一增加中文显示映射,避免继续把内部英文值直接透给编辑器和游戏 UI。 - diff --git a/docs/audits/text/GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-04-01.md b/docs/audits/text/GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-04-01.md deleted file mode 100644 index 96af33ee..00000000 --- a/docs/audits/text/GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-04-01.md +++ /dev/null @@ -1,325 +0,0 @@ -# 游戏 UI / 预设 / 编辑器文本审计 - -日期:`2026-04-01` - -## 范围 - -- 扫描范围:`src/components/`、`src/editor/`、`src/routing/`、`src/hooks/`、`src/services/`、`src/data/` -- 聚焦对象: - - 游戏内实际可见 UI 文本 - - 预设编辑器与自定义世界编辑器中的可见文本 - - 会直接透出到游戏 UI / 编辑器 UI 的预设原始值 -- 未覆盖: - - 图片资源内嵌文字的 OCR - - `docs/` 历史文档本身 - - 单纯内部实现用的 import path、className、asset path、纯 id 常量 - -## 方法 - -- 先做一轮源码级 AST 扫描,抽取 JSX 可见文本、按钮文案、占位文案、标签文案和常见说明文案。 -- 再做一轮“反向解码”复核: - - `瑙掕壊 -> 角色` - - `鍦烘櫙 -> 场景` - - `姝︿緺 -> 武侠` - - `鏈煡 AI 閿欒 -> 未知 AI 错误` -- 结论只保留当前源码里仍然存在的问题,不直接沿用旧审计文档。 - -## 结论摘要 - -- 当前仍然有 3 类问题: - 1. 真实乱码:主要在 `appRoutes.tsx`、`AdventurePanel.tsx`、`CharacterDetailModal.tsx`、`useStoryGeneration.ts`、`preset-editor/shared.ts` 和 4 个拆分后的预设面板文件中。 - 2. 游戏 / 编辑器英文残留:主要在 `AdventurePanelOverlays.tsx`、`AdventureEntityModal.tsx`、`PreGameSelectionFlow.tsx`、`NpcVisualEditor.tsx`、`ItemCatalogEditor.tsx`、`StateFunctionEditor.tsx`、自定义世界编辑器几处。 - 3. 预设原始值直接透出:主要在 `characterPresets.ts`、`itemDesign.ts`、`monsterPresets.ts`、`buildTags.ts`、`scenePresets.ts`、`stateFunctions.ts`。 -- 编辑器侧当前最明显的重灾区不是旧的 `PresetEditorPanels.tsx` 大文件,而是已经拆分出的: - - `src/components/preset-editor/shared.ts` - - `src/components/preset-editor/CharacterPresetPanel.tsx` - - `src/components/preset-editor/SceneNpcPresetPanel.tsx` - - `src/components/preset-editor/ScenePresetPanel.tsx` - - `src/components/preset-editor/MonsterPresetPanel.tsx` -- 游戏主流程里影响最直观的点: - - 路由加载页文本乱码 - - 冒险面板里的任务状态 / 对话状态 / NPC 交互短描述乱码 - - AI 错误兜底文案乱码 - -## 一、游戏 UI:已确认乱码 - -| 文件 | 行号 | 当前文本 / 范围 | 说明 | -| --- | --- | --- | --- | -| `src/routing/appRoutes.tsx` | `103-115` | `LOADING EDITOR`、`LOADING GAME`;`姝e湪杞藉叆缂栬緫鍣?..`;`姝e湪杞藉叆鍐掗櫓...` | 路由级加载屏文案。后两段是真乱码;结合反向解码可确定原意分别接近“正在载入编辑器...”和“正在载入冒险...”。 | -| `src/components/AdventurePanel.tsx` | `99`、`101`、`103`、`109`、`111`、`113` | `查看库存与价�?`、`聊聊并试探口�?`、`看看能得到什么帮�?`、`离开并继续探�?`、`战斗决胜�?`、`切磋几招看身�?` | NPC 交互短描述里有多处截断 / 乱码。 | -| `src/components/AdventurePanel.tsx` | `200`、`203` | `可作为制作材�?`、`任务奖励物品,可用于后续路线、交易或构筑规划�?` | 任务奖励物品说明文本被截断。 | -| `src/components/AdventurePanel.tsx` | `569-571` | `已完�?`、`已交�?`、`进行�?` | 任务状态标签乱码。 | -| `src/components/AdventurePanel.tsx` | `771` | `�?` | 对话气泡里的屏幕阅读器标签损坏。 | -| `src/components/AdventurePanel.tsx` | `833`、`837`、`870` | `剧情推演�?..`、`对话进行�?`、`剧情推理完成,继续后显示新的冒险选项�?` | 加载态 / 流式对话态 / 继续冒险提示都有截断。 | -| `src/components/CharacterDetailModal.tsx` | `35-36`、`223` | `女�?`、`男�?`、`属�?` | 性别标签与“属性”标题乱码。 | -| `src/hooks/useStoryGeneration.ts` | `1214`、`1266`、`1409`、`1549`、`1978`、`2325` | `鏈煡 AI 閿欒` | 游戏故事流里 AI 失败时的统一兜底提示乱码;可反解为“未知 AI 错误”。 | - -## 二、游戏 UI:英文残留 - -### 1. 冒险面板和奖励弹层 - -- `src/components/adventure-panel/AdventurePanelOverlays.tsx` - - `554-570`:`Adventure stats`、`Current area:`、`ADVENTURE SUMMARY`、`enemies defeated`、`items in inventory`、`scene transitions so far` - - `622-668`:`Quest log`、`Total quests:`、`No active quests yet.` - - `711-798`:`QUEST BRIEF`、`Claim reward`、`QUEST COMPLETE`、`Reward ready`、`Reward pickup is now available in the quest log.`、`Open quest log` - - `887-1016`:`Battle reward`、`Defeated enemies:`、`BATTLE END`、`LOOT CACHE`、`Tap an item icon to inspect its details.`、`No usable loot dropped this time.`、`No loot dropped this time.`、`Rarity:`、`Quantity:`、`Slot:`、`Not equippable`、`Usable directly`、`Passive / non-immediate item`、`Effect preview: HP +`、`MP +`、`Cooldown -`、`Tags:`、`none` -- `src/components/AdventurePanel.tsx` - - `359-388`:`REWARD CACHE`、`Tap an item icon to inspect its details.`、`Affinity`、`Currency`、`No item bounty attached to this quest.` - - `636-638`:`Current area` - - `803`、`824`:两个按钮都显示 `Refresh` - -### 2. 实体详情、同伴、交易 - -- `src/components/AdventureEntityModal.tsx` - - `892`、`898`:`HP`、`MP` - - `1073`:`NPC 信息` - - `1111`:`敌对NPC`、`NPC` - - `1163`:数量前缀 `x{item.quantity}` - - `1165`:`Inspect` - - `1428`:`NPC 背包` -- `src/components/CompanionCampModal.tsx` - - `177-178`、`233-234`:`HP`、`MP` - - `255`:`NPC` -- `src/components/NpcModals.tsx` - - `252`、`273`、`356`:`NPC 商品列表`、`这个 NPC 当前没有可售商品。`、`NPC 商品` - - `408`:`效果预览:HP +... / MP +... / 冷却 -...` -- `src/components/CharacterDetailModal.tsx` - - `112`:`数量 x{item.quantity}` - -### 3. 开场流程与加载态 - -- `src/components/game-shell/PreGameSelectionFlow.tsx` - - `48-49`:`QQ Group`、`WeChat` - - `81`、`89`:`核心NPC` - - `471-473`:`Wuxia Base` - - `519-527`:`Custom`、`Create Custom World`、`Enter a world setup and let the system generate playable characters, NPCs, items, and landmarks.` -- `src/components/game-shell/CharacterSelectionFlow.tsx` - - `401`:`Character Details` - - `406`:`Current Character` -- `src/components/GameCanvas.tsx` - - `32`:`Loading scene` -- `src/components/GameShell.tsx` - - `859`:`正在加载 NPC 交互...` - -### 4. 运行时文案源头 - -- `src/data/sceneObservation.ts` - - `9-36` 整段观察结果仍是英文: - - `You pause to listen...` - - `Possible NPCs: ...` - - `Possible hostile NPCs: ...` - - `Possible treasure clues: ...` - - `Boss clue: ...` -- `src/hooks/useStoryGeneration.ts` - - `216`、`219`:最近战斗 / 最近协作提示仍是英文 - - `639-646`:营地聊天结果文本混用了英文句子 - - `662-667`:预览对话选项里仍有 `Speak with ...` 与 `Focus on the person in front of you first...` - -## 三、编辑器 UI:已确认乱码 - -### 1. 共享标签与世界名 - -- `src/components/preset-editor/shared.ts` - - `42`:`瑙掕壊`,可反解为 `角色` - - `44`:`鍦烘櫙`,可反解为 `场景` - - `45`:`鏁屽 NPC`,可反解为 `敌对 NPC` - - `46`:`鐗╁搧`,可反解为 `物品` - - `47`:`鍔熻兘`,可反解为 `功能` - - `53`:`姝︿緺`,可反解为 `武侠` - - `54`:`浠欎緺`,可反解为 `仙侠` - - `55`:`鑷畾涔変笘鐣?`,基本可判定原意是“自定义世界”,但当前字符串已经不完整 - -### 2. 角色预设面板 - -- `src/components/preset-editor/CharacterPresetPanel.tsx` - - `79`:空状态 / 顶部说明整段乱码 - - `372-373`:装备区标题乱码 - - `395-397`:背包区标题乱码 - - `446-447`:技能预览相关标签乱码 - - `475-477`:技能区提示乱码 - - `590-592`:底部说明大段乱码 - -### 3. 场景 NPC 预设面板 - -- `src/components/preset-editor/SceneNpcPresetPanel.tsx` - - `87`:空状态整段乱码 - - `267`:技能预览空态说明乱码 - - `346`:角色 ID 标签乱码 - - `352`:怪物预设 ID 标签乱码 - - `389`:视觉编辑器说明整段乱码 - -### 4. 场景预设面板 - -- `src/components/preset-editor/ScenePresetPanel.tsx` - - `52`:空状态整段乱码 - - `220-221`:敌对 NPC 分区标题乱码 - - `254-255`:场景 ID 标签乱码 - - `298-299`:怪物 ID 列表标签乱码 - - `316-317`:关联 NPC 分区标题乱码 - -### 5. 怪物预设面板 - -- `src/components/preset-editor/MonsterPresetPanel.tsx` - - `53`:顶部说明整段乱码 - -## 四、编辑器 UI:英文残留 - -### 1. 预设编辑器主面板 - -- `src/components/preset-editor/CharacterPresetPanel.tsx` - - `278-279`:`Character List`、`Choose a player character, preview it live, and edit the preset fields.` - - `325`:`Save Character Overrides` - - 多处通用占位仍是 `Section`、`Editor section.`、`Field` - - `347`:`Inventory World` - - `468-484`:`Skill Loadout`、`Add Skill` - - `510`:`Skill ID` - - `651`:`Character ID` - - `682`:`Asset Variant` - - `698`:`Personality` - - `713`:`Attributes`、`Adjust the four core character attributes.` - - `772`:`Unset` - - `798`:`scene-id-1 / scene-id-2` -- `src/components/preset-editor/SceneNpcPresetPanel.tsx` - - `181-182`:`NPC Library`、`Browse and select an NPC preset.` - - `186`:`NPC ID` - - `223`:`Save NPC Overrides` - - `230-246`:`Skill Preview`、`Preview ranged skills from the linked character.`、`Skill`、`World` - - `275-276`:`Hostile NPCs use monster presets...`、`Narrative NPCs can preview linked visuals...` - - `318-382`:`NPC Details`、`Role`、`Avatar`、`Initial Affinity`、`Description`、`Visual Editor` -- `src/components/preset-editor/ScenePresetPanel.tsx` - - `145`:`Scene` - - `172`:`Save` - - `179-193`:`Scene Preview`、`Preview Mode`、`Monster Preview`、`NPC Preview`、`Treasure Preview`、`Empty` - - `223`、`230`、`233`、`242`:`None`、`NPC` - - `248-272`:`Scene Details`、`World`、`Name`、`Description` - - `288`:`Unset` -- `src/components/preset-editor/MonsterPresetPanel.tsx` - - `172`:`Save Monster Overrides` - - `179-180`:`Monster Override Preview`、`Editor section.` - - `213-222`:`Attack Range:`、`Speed:`、`HP:`、`Max HP:` - - `236`:`Monster ID` - - `242`:`Name` - - `258`:`Intro Action` - - `373`:`FPS` - -### 2. 其他编辑器 / 自定义世界 - -- `src/components/ItemCatalogEditor.tsx` - - `654`:`HP`、`MP`、`CD` - - `677`、`806`:`ID` - - `789`:`Build Buff` - - `458`、`863`:`public/Icons`、`itemOverrides.json` -- `src/components/NpcVisualEditor.tsx` - - `463`、`702`、`708`、`718`:`NPC` - - `977`:`Shift` - - `1028-1052`:`Current loadout:`、`Unknown headgear`、`No headgear`、`Unknown main hand`、`No main hand`、`Unknown off hand`、`No off hand` -- `src/components/CustomWorldEntityCatalog.tsx` - - `139`、`268`、`276`、`349`:`NPC` - - `224`:`WORLD DOSSIER` - - `346`:`MedievalFantasyCharacters` -- `src/components/CustomWorldEntityEditorModal.tsx` - - `242`:`URL` - - `460`:`MedievalFantasyCharacters` - - `478-479`、`730`、`758-759`:`AI`、`AI生成NPC形象`、`AI生成场景` - - `631-653`:`NPC` -- `src/components/PresetEditor.tsx` - - `71`:介绍文案里仍然直接显示 `NPC` -- `src/components/StateFunctionEditor.tsx` - - `803`:`Failed to play preview` - - `818-821`:`HP`、`No visible target` - - `915`:`n/a` - - `1060-1064`:`Failed to save option behavior overrides`、`Option behavior overrides saved.` - - `1106`:直接显示原始 `state` - - `1185`:直接把 `AnimationState` 值作为 label - - `1191`:敌对 NPC 反应动画里仍直接显示 `idle` / `move` / `attack` - - `1217`:技能风格仍直接依赖 `steady` / `burst` / `mobility` / `finisher` / `projectile` - -## 五、预设 / 数据层:会直接透出 UI 的英文原始值 - -这一部分不是“源码内部英文就算问题”,而是“当前编辑器或预览没有做显示映射,导致原始英文值直接露给用户”。 - -### 1. 角色预设 - -- `src/data/characterPresets.ts` - - 动作文件夹 / 前缀仍是英文: - - `363-379` - - `520-536` - - `739-755` - - `834-850` - - `1019-1038` - - 对话风格原始值仍是英文: - - `384-386`:`blunt` / `dry` / `direct` - - `541-543`:`wary` / `dry` / `fragmented` - - `760-762`:`blunt` / `teasing` / `deflecting` - - `855-857`:`blunt` / `steady` / `direct` - - `1043-1045`:`measured` / `steady` / `fragmented` - - 技能风格 / 投射方式仍是英文: - - `407-408`、`445`、`473-474` - - `572-573`、`604-605`、`636-637`、`668-669`、`700-701` - - `791-792`、`815-818` - - `886`、`906`、`926-927`、`958-959`、`990-991` - - `1066-1077`、`1109-1110`、`1133-1146`、`1178-1179` - - 这些值当前会在角色预设编辑器、技能预览和部分行为预览里直接露出。 - -### 2. 物品设计 / Build 标签 - -- `src/data/itemDesign.ts` - - `56-201`:`worldAffinity` / `role` / `rarity` 原始值仍是英文,如 `neutral`、`wuxia`、`xianxia`、`fieldcraft`、`breaker`、`berserker`、`legendary` - - `213-219`:`pieceName` 仍是 `boots`、`chest`、`gloves`、`helm`、`leggings`、`shield`、`weapon` - - `820`:说明文本里仍混入 `build` -- `src/data/buildTags.ts` - - `11-291`:整套 build tag id 都是英文,如 `quickblade`、`combo`、`dash`、`ranged`、`burst`、`caster`、`vanguard`、`paladin`、`starter` - - 这些值会进入物品编辑器、构筑标签和相关预览。 - -### 3. 怪物与掉落 - -- `src/data/monsterPresets.ts` - - `490-736`:掉落 id、稀有度、tag 原始值大量是英文,如 `rare`、`uncommon`、`armor`、`material`、`relic`、`healing` - - `718-736`:有两条掉落本身是完整英文可见值: - - `Consumable` / `Thorn Nectar` / `Sticky sap that can be refined into emergency recovery tonic.` - - `Relic` / `Devour Bloom` / `A predatory blossom that stores concentrated life force.` - -### 4. 场景 / 行为 / 锻造 / NPC 交互 - -- `src/data/scenePresets.ts` - - `349-651`:场景 id 全部是英文连字符格式,如 `wuxia-bamboo-road`、`xianxia-cloud-gate` - - 当前在编辑器 ID 字段中会直接显示。 -- `src/data/stateFunctions.ts` - - `113-372`:`category` 原始值仍是 `battle` / `recovery` / `escape` / `idle` - - 编辑器预览还会直接显示动画 / delivery 原始值。 -- `src/data/forgeSystem.ts` - - `264`:描述里混入 `build` - - `274-281`:`relic`、`epic`、`setId`、`pieceName` 等原始值会进入物品编辑器链路 -- `src/data/npcInteractions.ts` - - `207-209`:兜底对话风格仍是 `measured` / `steady` / `fragmented` - -## 六、建议修复顺序 - -1. 先修最影响主流程观感的真实乱码。 - - `src/routing/appRoutes.tsx` - - `src/components/AdventurePanel.tsx` - - `src/components/CharacterDetailModal.tsx` - - `src/hooks/useStoryGeneration.ts` -2. 再修预设编辑器的共享标签和 4 个拆分面板。 - - `src/components/preset-editor/shared.ts` - - `src/components/preset-editor/CharacterPresetPanel.tsx` - - `src/components/preset-editor/SceneNpcPresetPanel.tsx` - - `src/components/preset-editor/ScenePresetPanel.tsx` - - `src/components/preset-editor/MonsterPresetPanel.tsx` -3. 再统一清理英文残留。 - - 游戏端优先:`AdventurePanelOverlays.tsx`、`AdventureEntityModal.tsx`、`PreGameSelectionFlow.tsx` - - 编辑器端优先:`ItemCatalogEditor.tsx`、`NpcVisualEditor.tsx`、`StateFunctionEditor.tsx`、自定义世界编辑器 -4. 最后做“显示层映射”,避免预设原始英文继续漏到 UI。 - - `characterPresets.ts` - - `itemDesign.ts` - - `buildTags.ts` - - `monsterPresets.ts` - - `scenePresets.ts` - - `stateFunctions.ts` - -## 七、备注 - -- 本次结论以当前源码为准,和旧审计文档相比,已有一部分旧问题已经被修掉。 -- `src/components/preset-editor/PresetEditorPanels.tsx` 现在只是 re-export 壳文件,真正的问题已经分散到拆分后的 panel 文件里。 -- `src/components/preset-editor/shared.ts` 里的几处乱码已经可以明确反解,适合优先直接修正。 -- `src/data/` 中很多英文值本身可能是内部枚举,但只要当前编辑器 / 预览没有做中文映射,就仍应视为“会暴露到用户侧”的文本问题。 diff --git a/docs/audits/text/README.md b/docs/audits/text/README.md index 0abe2d0a..b1134ef2 100644 --- a/docs/audits/text/README.md +++ b/docs/audits/text/README.md @@ -1,6 +1,6 @@ # 文本与乱码审计总览 -这一组文档记录的是同一条清理链路的不同阶段:从“发现哪里有英文/乱码”到“扩展到 prompt、npcInteraction、编辑器深层文本”。 +这一组只保留当前仍需要执行的文本与乱码审计入口。早期逐日扫描已经聚合到当前结论,不再保留旧稿链路。 ## 当前推荐入口 @@ -11,17 +11,8 @@ 3. [GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-04-02.md](./GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-04-02.md) 适合看“扩展重查版”的 UI / 预设 / 编辑器问题面。 -## 历史时间线 - -- [EDITOR_GAME_PRESET_TEXT_AUDIT_2026-03-25.md](./EDITOR_GAME_PRESET_TEXT_AUDIT_2026-03-25.md):较早期的整体首轮盘点。 -- [GAME_EDITOR_PRESET_TEXT_AUDIT_2026-03-29.md](./GAME_EDITOR_PRESET_TEXT_AUDIT_2026-03-29.md):复查阶段,开始收紧范围和口径。 -- [GAME_EDITOR_PRESET_TEXT_AUDIT_2026-03-30.md](./GAME_EDITOR_PRESET_TEXT_AUDIT_2026-03-30.md):继续复核真实乱码与英文残留。 -- [GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-03-30_CONTINUED.md](./GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-03-30_CONTINUED.md):对上一轮的续扫补充。 -- [GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-03-31.md](./GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-03-31.md):继续收敛 UI、预设与编辑器问题。 -- [GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-04-01.md](./GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-04-01.md):进入更明确的审计范围与方法阶段。 - ## 融合结论 -- 早期几份文档主要负责“摸清哪里有问题”。 +- 早期几轮扫描已经完成使命,核心结论是:不要把乱码当成普通文案改写,先确认真实编码;文本修复要优先处理会进入玩家体验和 AI 生成链路的内容。 - `2026-04-02` 两份文档开始把重点收敛到真正会影响玩家体验和 AI 生成质量的链路。 - 现在做文本修复时,不必从最早一份开始逐篇读;优先看 `CHINESE_MOJIBAKE_INVENTORY` 和 `2026-04-02` 两份即可。 diff --git a/docs/planning/BEIJING_DIRECTION21_APPLICATION_MATERIALS_2026-04-14.md b/docs/planning/BEIJING_DIRECTION21_APPLICATION_MATERIALS_2026-04-14.md index 6c7090f4..fa7c3eb7 100644 --- a/docs/planning/BEIJING_DIRECTION21_APPLICATION_MATERIALS_2026-04-14.md +++ b/docs/planning/BEIJING_DIRECTION21_APPLICATION_MATERIALS_2026-04-14.md @@ -259,7 +259,7 @@ 可复用来源: - `README.md` -- `docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md` +- `docs/technical/CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md` - `docs/experience/PROJECT_DEVELOPMENT_EXPERIENCE.md` ### 5.2 技术研发情况 diff --git a/docs/planning/BEIJING_POLICY_APPLICATION_OVERVIEW_13_21_24_2026-04-14.md b/docs/planning/BEIJING_POLICY_APPLICATION_OVERVIEW_13_21_24_2026-04-14.md index afc846ed..20ff260a 100644 --- a/docs/planning/BEIJING_POLICY_APPLICATION_OVERVIEW_13_21_24_2026-04-14.md +++ b/docs/planning/BEIJING_POLICY_APPLICATION_OVERVIEW_13_21_24_2026-04-14.md @@ -101,8 +101,8 @@ - `docs/prd/AI_NATIVE_CUSTOM_WORLD_CREATION_FLOW_OPTIMIZATION_PRD_2026-04-06.md` - `docs/prd/AI_NATIVE_STORY_ENGINE_PHASE1_IMPLEMENTATION_PLAN_2026-04-06.md` - `docs/prd/AI_NATIVE_STORY_ENGINE_PHASE2_IMPLEMENTATION_PLAN_2026-04-06.md` -- `docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md` -- `docs/planning/CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md` +- `docs/technical/CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md` +- `docs/audits/engineering/README.md` - `docs/experience/PROJECT_DEVELOPMENT_EXPERIENCE.md` ## 5. 三个方向的共用材料包 diff --git a/docs/planning/CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md b/docs/planning/CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md deleted file mode 100644 index 85f01d44..00000000 --- a/docs/planning/CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md +++ /dev/null @@ -1,293 +0,0 @@ -# 当前游戏优先迭代清单(2026-04-03) - -## 结论先说 - -当前阶段最不该做的,是继续零散加玩法、加场景、加文案,却让主链路、规则底座和工程门禁继续处在半完成状态。 - -按现有文档和代码状态看,建议优先级顺序如下: - -1. `P0`:先恢复工程绿色基线,并把运行时主链路继续拆开 -2. `P1`:再落统一角色属性底座,作为战斗 / 对话 / 招募 / Build / 掉落 / 任务的共同语义基础 -3. `P1`:在统一属性底座之上,重做 Build、运行时物品奖励、任务系统三条核心玩法链 -4. `P2`:最后收尾编辑器共享层、本地 API 分层、移动端体验与运行时包体优化 - -一句话判断: - -**现在的优先级不是“继续扩玩法宽度”,而是“先把底层规则、主流程边界和工程可维护性补齐,再扩玩法深度”。** - -补充更新(`2026-04-21`): - -当前与“主流程边界补齐”直接对应的执行基线,已经从泛化的 `GameShell / useStoryGeneration / customWorld` 热点讨论,收口成两条正式技术方案: - -1. [../technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md):负责创作入口 -> Agent session -> result preview -> published profile 主链。 -2. [../technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md):负责平台入口 -> 继续游戏/开始游戏 -> RPG runtime -> runtime story 主链。 - -因此本文里的 `P0-2`,当前应按这两条主线落地,而不是继续围绕旧 `GameShell` / `PreGameSelectionFlow` / `runtimeRoutes.ts` 命名做泛化式重构。 - ---- - -## 优先级清单 - -## P0-1:恢复绿色基线,收紧质量门禁 - -### 为什么必须排第一 - -- `docs/audits/engineering/ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md` 已明确指出:当前最值得优先优化的不是继续加功能,而是把“半完成的工程化”补齐。 -- 文档中提到过 `lint` 失败、`build` warning、核心热区文件被 ESLint ignore、部分测试未进入默认套件,这意味着当前代码库还不在真正稳定的绿色基线。 -- 在这种状态下继续叠加新玩法,只会把问题扩散到更多运行时链路和编辑器链路。 - -### 本阶段要做什么 - -- 修复现有 `lint` / `build warning` / 明确可见的门禁破口 -- 缩小高风险核心文件的 ignore 范围 -- 让 `lint + typecheck + test + build + check:content` 成为可信的统一门禁 -- 对 warning 建立“尽快清零”策略,而不是长期带病开发 - -### 做到什么算完成 - -- 主开发分支长期保持 `npm run check` 可稳定通过 -- 核心运行时文件不再依赖长期 ignore 才能过门禁 -- 构建 warning 收敛到零或有非常明确的短期处理计划 - -### 为什么它比新功能更优先 - -- 没有绿色基线,后续所有大改都缺少可靠回归保护 -- 这一步是后面统一属性、任务重构、物品系统重构的前置条件 - ---- - -## P0-2:继续拆运行时主链路,防止核心 hook 和壳层继续膨胀 - -### 为什么必须紧跟在 P0-1 后面 - -- `docs/experience/PROJECT_DEVELOPMENT_EXPERIENCE.md`、`docs/experience/PROJECT_WORK_EXPERIENCE_PLAYBOOK.md`、`docs/experience/ADVENTURE_RUNTIME_DEV_EXPERIENCE.md` 都反复强调:这个项目是叙事、状态、演出、界面四条链路耦合的复合项目,不能靠大文件硬扛。 -- `docs/audits/engineering/ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md` 和 `docs/audits/engineering/ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md` 一致指出,`useStoryGeneration`、`useCombatFlow`、`GameShell` 仍然是当前最大的复杂度集中点。 -- 如果不先拆主链,后面的统一属性系统、任务系统、物品导演层都会继续堆进现有巨型流程控制器,技术债只会翻倍。 - -### 本阶段要做什么 - -- 按 [../technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md) 收口创作链,统一 `Agent session -> result preview -> published profile` -- 按 [../technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md) 收口 `rpgEntry -> rpgSession -> rpgRuntime -> rpgRuntimeStory -> rpgProfile` -- 旧 `GameShell / PreGameSelectionFlow / runtimeRoutes.ts / storyActionService.ts` 只作为历史热区名或兼容 façade,不再作为当前新任务默认落点 - -### 做到什么算完成 - -- 新功能接入时,不需要再跨 `story + combat + panel + modal` 四五层一起改 -- 核心流程可以按领域补测试,而不是只能做人工回归 -- 后续玩法扩展能优先加领域模块,而不是继续往大 hook 里塞逻辑 - -### 这一项的实际意义 - -- 这是“后续还能继续做大”的结构前提 -- 不做这一步,任何系统升级都会越来越难落地 - ---- - -## P1-1:落地统一角色属性系统,作为全玩法共同底座 - -### 为什么它是最优先的玩法底座 - -- `docs/prd/AI_NATIVE_UNIFIED_ROLE_ATTRIBUTE_SYSTEM_PRD_2026-04-02.md` 已经把问题说得很清楚:当前玩家、NPC、怪物、Build、对话、掉落还没有共享同一套解释坐标。 -- 当前项目已经有 NPC 关系、怪物标签、Build 语义、自定义世界生成能力,但这些系统之间还缺一套统一的世界级属性 schema。 -- 如果先做任务、物品、Build 深化,而不先统一属性,后面很容易再次出现“每个系统各自解释角色”的分裂。 - -### 本阶段要做什么 - -- 为预设世界固化世界级属性 schema -- 为玩家角色、怪物、关键 NPC 补 `attributeProfile` -- 建立统一的属性解析与校验层 -- 先让对话 / 招募 / 送礼 / 详情面板开始读取这套新属性解释 - -### 做到什么算完成 - -- 玩家、NPC、怪物都能落到同一套属性语义里 -- 聊天、送礼、招募至少有一条链可以直接解释到属性层 -- 自定义世界也能生成并持久化自己的属性 schema - -### 为什么这项优先于“多做内容” - -- 这是后面 Build、物品、任务三条系统统一升级的共同前提 -- 没有这层底座,玩法会继续“能跑,但彼此不共语义” - ---- - -## P1-2:把 Build 系统从“标签互相影响”改成“标签匹配角色属性” - -### 为什么这里要尽快做 - -- `docs/prd/BUILD_SYSTEM_ATTRIBUTE_SIMILARITY_PRD_2026-04-02.md` 指出:当前 Build 更像标签网络效应,解释成本高、平衡成本高、角色差异感不够强。 -- 一旦统一角色属性系统先落地,Build 就是最适合第二个接入的玩法层,因为它最直接影响战斗反馈和角色成长感。 - -### 本阶段要做什么 - -- 为 Build 标签补属性亲和度向量 -- 改写 `buildDamage` 逻辑,让每个标签独立匹配当前角色属性画像 -- 调整 Build 面板文案,从“标签协同”转成“属性适配度” - -### 做到什么算完成 - -- 玩家能理解“为什么这个标签适合当前角色” -- 新增标签只影响自身贡献,不再扰动整张标签网络 -- Build 面板能解释收益来自哪些属性 - -### 实际收益 - -- 提高可解释性 -- 降低平衡难度 -- 让角色差异感真正进入 Build 体验 - ---- - -## P1-3:重做运行时物品奖励,让奖励真正贴合场景、NPC、最近事件和 Build 缺口 - -### 为什么它值得排在任务系统前面 - -- `docs/design/AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md` 明确指出:当前宝藏、NPC、任务、锻造等入口都有物品,但缺少统一导演层,奖励与场景/NPC/事件的贴合度不够高。 -- 相比任务系统,运行时物品奖励能更快提升“世界贴脸感”和“当下反馈质量”,且可以先从宝藏入口低风险落地。 - -### 本阶段要做什么 - -- 增加运行时物品上下文采样、导演层、编译器和叙事回写层 -- 统一宝藏、NPC 奖励、怪物掉落、任务奖励的物品生成入口 -- 让奖励优先围绕 Build 标签、限时 Build Buff、少量数值补足来设计 - -### 做到什么算完成 - -- 至少宝藏和 NPC 奖励接入统一导演层 -- 物品能解释“为什么在这里出现、和谁有关、补的是什么方向” -- 物品来源可以进入背包、剧情、锻造与存档的同一套结构 - -### 实际收益 - -- 奖励不再像泛用掉落池 -- 世界、人物、最近剧情与成长反馈终于真正连起来 - ---- - -## P1-4:把任务系统从“单目标单阶段”升级成“意图 -> 合约 -> 信号推进” - -### 为什么它仍然是高优先级 - -- `docs/prd/AI_NATIVE_QUEST_SYSTEM_PRD_2026-04-02.md` 已经指出:当前任务闭环是成立的,但任务来源偏静态、结构偏扁平、状态过粗、奖励和关系变化也不够贴语境。 -- 当前项目已经具备任务 UI、任务奖励、NPC 交互、剧情推进链,这说明任务系统适合做“升级”,而不是推倒重来。 - -### 本阶段要做什么 - -- 新增任务生成上下文、AI 任务意图层、本地任务编译层 -- 把任务推进改成统一 signal 驱动 -- 支持多 step、阶段揭示、完成后回报、后续钩子 - -### 做到什么算完成 - -- NPC 接任务不再只是静态模板,而是能根据当前局面生成任务意图 -- 运行时能用统一 signal 推进任务步骤 -- 奖励除了货币/道具,还能自然进入关系、情报、后续机会 - -### 为什么它排在物品系统之后 - -- 任务系统耦合更深,适合作为统一属性和统一奖励导演层之后的升级项 -- 先把属性和物品奖励理顺,任务系统落地时会更稳 - ---- - -## P2-1:收尾编辑器共享层与本地 API 分层,让内容扩张不再继续拖慢主项目 - -### 为什么它不是最前面,但也不能拖太久 - -- 最近几份工程审查都指出:编辑器共享层、本地 JSON 写入接口、LLM 代理、Vite 插件职责仍然处于迁移中间态。 -- 当前项目已经进入“内容工具很多、正式运行时也很重”的阶段,若不收尾这部分,后续每次扩内容都会重复踩基础设施问题。 - -### 本阶段要做什么 - -- 继续拆 editor shared 层 -- 清理迁移残留和死分支 -- 把本地 API 至少按 `llm proxy / json editor api / asset catalog` 分职责拆开 - -### 做到什么算完成 - -- 编辑器保存、共享组件、共享 client 不再重复实现 -- 本地 API 分工清晰,dev / preview 边界清楚 -- 编辑器扩展不再继续依赖大聚合组件 - ---- - -## P2-2:继续优化移动端冒险体验、首屏信息密度与运行时包体 - -### 为什么它放在 P2 - -- `docs/experience/PROJECT_DEVELOPMENT_EXPERIENCE.md` 和 `docs/experience/MOBILE_UI_DEV_EXPERIENCE.md` 都强调:冒险页必须优先保证上方演出、一屏选项和文本区自适应。 -- 但从当前文档判断,移动端体验和包体问题更像“持续治理项”,不是当前阶段最核心的系统阻塞点。 - -### 本阶段要做什么 - -- 继续优化冒险页一屏布局与文本滚动策略 -- 拆 `GameCanvas`、`AdventurePanel` 等高热区大模块 -- 按真实交互热区继续做 chunk 拆分 - -### 做到什么算完成 - -- 手机首屏稳定容纳画布、文本和关键选项 -- 核心页面热区模块更容易维护和测试 -- 构建产物中的主 chunk 有持续下降趋势 - ---- - -## 不建议当前优先做的事 - -以下内容不是不能做,而是不建议排在当前这轮前面: - -- 大量新增世界、场景、角色 preset -- 继续横向扩 NPC 交互种类,但不补统一规则底座 -- 继续堆宝藏、掉落、锻造分支,但不先做统一物品导演层 -- 继续增加任务模板数量,但不升级任务 contract -- 继续往 `useStoryGeneration` / `useCombatFlow` / `GameShell` / `PreGameSelectionFlow` / `runtimeRoutes.ts` / `storyActionService.ts` 里直接塞新逻辑 - -原因很简单: - -**这些工作会让表面内容变多,但不会让项目变得更稳,反而会放大当前已经存在的结构问题。** - ---- - -## 推荐迭代顺序 - -### 第一阶段:先稳住工程与主流程 - -1. 绿色基线与门禁收紧 -2. 创作链按 `CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 收口 -3. RPG 进入游戏与运行时链按 `RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 收口 - -### 第二阶段:先补统一语义底座 - -1. 统一角色属性系统 -2. Build 改为属性适配 - -### 第三阶段:再深化 AI 原生玩法闭环 - -1. 运行时物品导演层 -2. 任务意图与 contract 系统 - -### 第四阶段:最后做工具与体验收尾 - -1. 编辑器共享层 / 本地 API 分层 -2. 移动端体验与包体优化 - ---- - -## 本清单的主要依据 - -- `docs/experience/PROJECT_DEVELOPMENT_EXPERIENCE.md` -- `docs/experience/PROJECT_WORK_EXPERIENCE_PLAYBOOK.md` -- `docs/experience/ADVENTURE_RUNTIME_DEV_EXPERIENCE.md` -- `docs/audits/engineering/ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md` -- `docs/audits/engineering/ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md` -- `docs/audits/engineering/ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md` -- `docs/design/AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md` -- `docs/prd/AI_NATIVE_UNIFIED_ROLE_ATTRIBUTE_SYSTEM_PRD_2026-04-02.md` -- `docs/prd/BUILD_SYSTEM_ATTRIBUTE_SIMILARITY_PRD_2026-04-02.md` -- `docs/prd/AI_NATIVE_QUEST_SYSTEM_PRD_2026-04-02.md` - -## 最后结论 - -如果只保留一句话,那就是: - -**当前最优先的迭代方向,不是继续堆新内容,而是先把工程基线、主流程边界和统一规则底座补齐;只有这样,AI 原生任务、物品、Build 和后续内容扩展才会真正开始越做越顺。** diff --git a/docs/planning/ENGINEERING_DEAD_CODE_AND_HIDDEN_BRANCH_CLEANUP_PLAN_2026-04-21.md b/docs/planning/ENGINEERING_DEAD_CODE_AND_HIDDEN_BRANCH_CLEANUP_PLAN_2026-04-21.md index e6c36e6b..d7a66c92 100644 --- a/docs/planning/ENGINEERING_DEAD_CODE_AND_HIDDEN_BRANCH_CLEANUP_PLAN_2026-04-21.md +++ b/docs/planning/ENGINEERING_DEAD_CODE_AND_HIDDEN_BRANCH_CLEANUP_PLAN_2026-04-21.md @@ -139,10 +139,10 @@ Git 分支治理可以后置做,但不能和首轮工程清洗混在一起, 本计划基于现有文档已经确认的结论推进,重点参考: -1. `docs/audits/engineering/ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md` +1. `docs/audits/engineering/README.md` 2. `docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md` 3. `docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md` -4. `docs/planning/EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md` +4. `docs/technical/CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md` 5. `docs/experience/PROJECT_WORK_EXPERIENCE_PLAYBOOK.md` 按当前审计结果,首轮就应重点关注下面 3 组对象。 diff --git a/docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md b/docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md deleted file mode 100644 index d37d65ec..00000000 --- a/docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md +++ /dev/null @@ -1,588 +0,0 @@ -# Express 后端化并行任务拆分规划(2026-04-08) - -## 1. 目的 - -这份文档用于把 [EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md](./EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md) 进一步拆成可并行推进、尽量互不冲突的任务流。 - -目标不是把大重构拆成很多零碎 TODO,而是把它拆成: - -- 可以同时开工 -- 写入边界清晰 -- 交付物明确 -- 依赖关系稳定 -- 最后容易集成 - ---- - -## 2. 并行拆分原则 - -## 2.1 基本原则 - -- 每条任务尽量拥有独占目录或独占模块,不去抢同一批热点文件。 -- 热点集成文件只由“集成岗”或最后一轮集成处理,不作为多个任务的日常编辑目标。 -- 先搭协议边界,再迁规则执行,再收缩前端。 -- 前端与后端可以并行推进,但前提是先冻结 contract。 -- 编辑器链路和正式运行时链路分开拆,避免互相阻塞。 - -## 2.2 当前最容易冲突的文件 - -以下文件建议默认只由集成岗或最后一轮联调处理: - -- `server-node/src/context.ts` -- `server-node/src/routes/runtimeRoutes.ts` -- `server-node/src/app.ts` -- `src/services/apiClient.ts` -- `src/hooks/useStoryGeneration.ts` -- `src/hooks/useGameFlow.ts` -- `src/components/GameShell.tsx` - -其他任务如果必须影响这些文件,优先通过: - -- 新增独立模块 -- 新增 adapter -- 新增中间层入口 - -而不是直接在热点文件中大改。 - ---- - -## 3. 建议并行批次 - -## 批次 A:可立即并行开工 - -- 任务 0:集成岗与接口冻结 -- 任务 1:共享 contract 与目录抽离 -- 任务 2:PostgreSQL 持久化基线收口 -- 任务 3:服务端 HTTP 基础设施与统一响应壳层 -- 任务 8:编辑器 API 归口与工具链隔离 -- 任务 9:测试、观测与部署基线 - -## 批次 B:在 contract 初版落地后并行开工 - -- 任务 4:服务端 AI 编排收口 -- 任务 5:运行时领域模块 A,Story / Combat / NPC -- 任务 6:运行时领域模块 B,Inventory / Quest / Build / Runtime Item -- 任务 7:前端 SDK、鉴权、持久化瘦身 - -## 批次 C:在服务端 action 和 view model 稳定后开工 - -- 任务 10:前端主流程壳层与大 hook 瘦身 - ---- - -## 4. 任务拆分 - -## 任务 0:集成岗与接口冻结 - -### 目标 - -负责冻结边界、维护接口文档、控制热点文件的合并节奏,避免多人同时改核心入口。 - -### 独占范围 - -- `docs/planning/**` -- `docs/technical/**` -- 最终集成时的热点入口文件 - -### 主要输出 - -- 统一任务看板 -- contract 版本表 -- 热点文件编辑规则 -- 每日或每阶段集成清单 - -### 验收标准 - -- 团队知道哪些文件不能多人同时改 -- 每条任务都有明确的上游 contract 与下游接入点 - ---- - -## 任务 1:共享 Contract 与目录抽离 - -### 目标 - -先把前后端共同识别的类型、schema、响应结构、错误结构抽出来,切断 `server-node -> src/**` 的长期反向依赖。 - -### 独占范围 - -- `packages/shared/**` -- 新建的共享类型、schema、contract 目录 - -### 可改边界 - -- `server-node/src/**` 中的 import 替换入口 -- `src/**` 中的 import 替换入口 - -### 暂不负责 - -- 具体业务规则迁移 -- 前端页面行为调整 -- 数据库实现细节 - -### 主要输出 - -- 统一 API envelope -- 统一错误对象 -- 统一 action / response contract -- 统一领域类型和状态枚举 - -### 验收标准 - -- 新增服务端模块不需要继续直接依赖前端目录里的实现细节 -- 前后端都以共享 contract 为边界协作 - -### 并行关系 - -- 可与任务 2、任务 3、任务 8、任务 9 同时启动 -- 是任务 4、任务 5、任务 6、任务 7 的上游基础 - ---- - -## 任务 2:PostgreSQL 持久化基线收口 - -### 目标 - -把“已经切到 PostgreSQL”的状态收成真正稳定的后端基线,清掉 SQLite 残留口径与仓储层耦合问题。 - -### 独占范围 - -- `server-node/src/config.ts` -- `server-node/src/db.ts` -- `server-node/src/repositories/**` -- `server-node/src/app.test.ts` -- `.env.example` - -### 暂不负责 - -- 剧情规则 -- 选项结算 -- 前端状态瘦身 - -### 主要输出 - -- PostgreSQL 连接配置 -- 仓储层接口统一 -- 数据表初始化/迁移方案 -- 运行时持久化测试基线 -- 文档中的数据库现状统一 - -### 验收标准 - -- 后端运行时数据完全以后端数据库为准 -- 配置、日志、测试、文档里不再把 SQLite 写成当前正式现状 - -### 并行关系 - -- 可与任务 1、任务 3、任务 8、任务 9 同时启动 -- 为任务 5、任务 6、任务 7 提供稳定持久化基础 - ---- - -## 任务 3:服务端 HTTP 基础设施与统一响应壳层 - -### 目标 - -建立统一的服务端响应结构、错误结构、请求链路日志、版本字段和中间件壳层。 - -### 独占范围 - -- `server-node/src/http.ts` -- `server-node/src/errors.ts` -- `server-node/src/middleware/**` -- `server-node/src/app.ts` - -### 可改边界 - -- 为 route 层提供新的响应 helper -- 为后续 action 接口提供统一 envelope - -### 暂不负责 - -- 具体 story / combat / quest 业务逻辑 -- 前端页面层接入 - -### 主要输出 - -- 统一 JSON 响应格式 -- 统一错误格式 -- `requestId` -- `latency` 与关键日志字段 -- 路由级版本与元信息壳层 - -### 验收标准 - -- 后端所有新接口都能套用同一层响应约定 -- 前端不需要为不同接口写多套错误解析逻辑 - -### 并行关系 - -- 可与任务 1、任务 2、任务 8、任务 9 同时启动 -- 是任务 4、任务 5、任务 6、任务 7 的共同基础 - ---- - -## 任务 4:服务端 AI 编排收口 - -### 目标 - -把正式运行时的 prompt 组装、模型调用、容错、SSE 转发都收回后端,浏览器不再保留正式运行时 AI fallback。 - -### 独占范围 - -- `server-node/src/services/llmClient.ts` -- `server-node/src/services/chatService.ts` -- `server-node/src/services/storyService.ts` -- `server-node/src/services/customWorldGenerationService.ts` -- `server-node/src/services/questService.ts` -- `server-node/src/services/runtimeItemService.ts` - -### 可新增目录 - -- `server-node/src/modules/ai/**` - -### 暂不负责 - -- 前端主流程组件 -- 数据库存储实现 - -### 主要输出 - -- 后端统一 AI orchestration 层 -- 流式接口统一适配 -- prompt 复用策略 -- 前端 fallback 清理清单 - -### 验收标准 - -- 正式运行时不再依赖浏览器端大体量 AI 实现作为兜底 -- AI 失败、超时、流式中断都能在后端统一处理 - -### 并行关系 - -- 建议在任务 1、任务 3 有初版后启动 -- 可与任务 5、任务 6、任务 7 并行 - ---- - -## 任务 5:运行时领域模块 A,Story / Combat / NPC - -### 目标 - -把剧情推进、战斗结算、NPC 交互这些最核心的运行时状态迁移到后端领域模块。 - -### 独占范围 - -- `server-node/src/modules/story/**` -- `server-node/src/modules/combat/**` -- `server-node/src/modules/npc/**` - -### 可改边界 - -- 为 route/action 层提供服务接口 -- 为前端提供 view model 所需聚合结果 - -### 暂不负责 - -- 背包、Build、任务奖励编排 -- 编辑器接口 - -### 主要输出 - -- story action resolver -- combat resolution service -- npc interaction service -- 统一返回给 UI 的 presentation/view model 结构 - -### 验收标准 - -- 前端不再本地决定 function 合法性、战斗结果、NPC 关键关系变化 -- 点击选项时,后端能返回完整下一步展示结果 - -### 并行关系 - -- 依赖任务 1、任务 3 -- 可与任务 4、任务 6、任务 7 并行 - ---- - -## 任务 6:运行时领域模块 B,Inventory / Quest / Build / Runtime Item - -### 目标 - -把任务推进、运行时物品、背包/装备、Build 收益等剩余核心规则迁到后端。 - -### 独占范围 - -- `server-node/src/modules/inventory/**` -- `server-node/src/modules/quest/**` -- `server-node/src/modules/build/**` -- `server-node/src/modules/runtime-item/**` - -### 可改边界 - -- 调用任务 2 的仓储层 -- 使用任务 1 的共享 contract - -### 暂不负责 - -- 前端页面层改造 -- Story / Combat / NPC 主链路 - -### 主要输出 - -- inventory mutation service -- quest signal progression service -- build calculation service -- runtime item resolution service - -### 验收标准 - -- 背包、任务、Build、运行时物品不再由前端保留正式结算逻辑 -- 这些领域能独立测试,不依赖 UI hook - -### 并行关系 - -- 依赖任务 1、任务 2、任务 3 -- 可与任务 4、任务 5、任务 7 并行 - ---- - -## 任务 7:前端 SDK、鉴权、持久化瘦身 - -### 目标 - -让前端从“业务执行层”退回“API 消费层 + 表现层状态协调层”。 - -### 独占范围 - -- `src/services/apiClient.ts` -- `src/services/authService.ts` -- `src/services/storageService.ts` -- `src/services/aiService.ts` -- `src/hooks/useGamePersistence.ts` -- `src/hooks/useGameSettings.ts` - -### 暂不负责 - -- 页面组件大范围重构 -- `useStoryGeneration.ts` 主流程瘦身 - -### 主要输出 - -- 轻量前端 SDK -- 统一鉴权请求层 -- 统一错误态与重试策略 -- 远端快照/设置消费层 -- 正式运行时浏览器 fallback 下线方案 - -### 验收标准 - -- 前端服务层不再保留完整正式规则或正式 AI 编排 -- 存档与设置以后端返回结果为准 - -### 并行关系 - -- 依赖任务 1、任务 3 -- 可与任务 4、任务 5、任务 6 并行 -- 为任务 10 提供稳定的 API 消费层 - ---- - -## 任务 8:编辑器 API 归口与工具链隔离 - -### 目标 - -把编辑器的写盘、生成、任务查询能力从“散落接口”整理成清晰的编辑器后端模块,避免继续污染正式运行时。 - -### 独占范围 - -- `src/editor/shared/**` -- `src/components/preset-editor/**` -- `src/components/npcVisualEditorPersistence.ts` -- `src/components/preset-editor/characterAssetStudioPersistence.ts` -- `scripts/dev-server/**` -- `server-node/src/modules/editor/**` -- `server-node/src/modules/assets/**` - -### 暂不负责 - -- 主游戏运行时 action 逻辑 -- 正式剧情流转 - -### 主要输出 - -- `/api/editor/*` 与 `/api/assets/*` 命名空间 -- 统一 editor client SDK -- 写接口权限边界 -- 编辑器工具链迁移清单 - -### 验收标准 - -- 编辑器组件不再散落直连多个写接口 -- 编辑器 API 与运行时 API 的职责边界清晰 - -### 并行关系 - -- 可与任务 1、任务 2、任务 3、任务 9 同时启动 -- 与任务 5、任务 6、任务 10 基本不冲突 - ---- - -## 任务 9:测试、观测与部署基线 - -### 目标 - -为整个后端化改造提供自动回归、链路日志和部署基线,避免“功能迁过去了但不可验证”。 - -### 独占范围 - -- `server-node/src/**/*.test.ts` -- `scripts/**` -- 部署与运维相关文档 -- 反向代理与 smoke 测试脚本 - -### 暂不负责 - -- 具体业务模块实现 - -### 主要输出 - -- 后端接口测试 -- 关键主链路 smoke -- request/response 日志校验 -- 同域部署基线 -- 回滚、备份、迁移检查清单 - -### 验收标准 - -- `web + server` 改造过程有最小自动回归保护 -- 关键接口失败时能追踪到请求链路 - -### 并行关系 - -- 可与任务 1、任务 2、任务 3、任务 8 同时启动 -- 后续持续跟进任务 4、任务 5、任务 6、任务 7、任务 10 的交付 - ---- - -## 任务 10:前端主流程壳层与大 Hook 瘦身 - -### 目标 - -在服务端 action 和前端 SDK 稳定后,把 `GameShell`、`useStoryGeneration` 这一层改成真正的表现层协调器。 - -### 独占范围 - -- `src/hooks/useStoryGeneration.ts` -- `src/hooks/story/**` -- `src/hooks/useGameFlow.ts` -- `src/components/GameShell.tsx` -- `src/components/AdventurePanel.tsx` -- `src/components/NpcModals.tsx` -- `src/components/auth/**` - -### 暂不负责 - -- 数据库、服务端仓储 -- 编辑器 API - -### 主要输出 - -- 面向 action/view model 的前端流程层 -- 页面表现态与业务态分离 -- 大 hook 拆分后的协调层 -- 更容易测试和替换的主流程壳层 - -### 验收标准 - -- 前端主流程不再直接吞下完整运行时规则 -- 页面层主要消费后端 view model,而不是本地自算结果 - -### 并行关系 - -- 依赖任务 5、任务 6、任务 7 至少有一轮稳定输出 -- 是最后一批大规模前端接入任务 - ---- - -## 5. 推荐协作顺序 - -## 第一步:先定边界 - -先启动: - -- 任务 0 -- 任务 1 -- 任务 2 -- 任务 3 - -这一轮完成后,团队会得到: - -- 统一 contract -- 稳定数据库基线 -- 稳定后端响应壳层 -- 稳定任务分工边界 - -## 第二步:领域层和工具层分头推进 - -在第一步基础上并行启动: - -- 任务 4 -- 任务 5 -- 任务 6 -- 任务 7 -- 任务 8 -- 任务 9 - -这一轮是整个改造的主生产阶段。 - -## 第三步:最后收前端主流程 - -最后启动: - -- 任务 10 - -原因很简单: - -- 如果太早改 `useStoryGeneration` 和 `GameShell`,前端还没有稳定的 action contract 和 view model,会反复返工。 - ---- - -## 6. 建议的多人分工方式 - -如果是 4 人并行,建议: - -- 1 人负责任务 1 + 任务 0 的 contract/集成 -- 1 人负责任务 2 + 任务 3 的后端基建 -- 1 人负责任务 4 + 任务 5 的运行时主链 -- 1 人负责任务 8 + 任务 9,之后转入任务 7 或任务 10 - -如果是 6 人并行,建议: - -- 1 人负责任务 0 + 任务 1 -- 1 人负责任务 2 -- 1 人负责任务 3 + 任务 9 -- 1 人负责任务 4 -- 1 人负责任务 5 -- 1 人负责任务 6 + 任务 8 - -前端主流程任务 10 建议在第二轮由最熟悉当前 UI 壳层的人接手。 - ---- - -## 7. 合并规则建议 - -- 每条任务优先新增目录和新模块,少直接改热点文件。 -- 热点文件统一在集成窗口合并,不在多个任务里同步推进。 -- 任何任务如果需要改 `useStoryGeneration.ts`,默认先暂停并和任务 10 对齐。 -- 任何任务如果需要改 `server-node/src/routes/runtimeRoutes.ts`,默认先走任务 0 的接口冻结表。 -- 编辑器链路和正式运行时链路不要混在同一个 PR 里。 - ---- - -## 8. 一句话结论 - -这次重构最稳的并行方式不是“大家一起改前后端”,而是: - -**先用 contract、数据库基线和 HTTP 壳层把边界钉死,再让服务端领域迁移、编辑器归口、前端瘦身分轨并行,最后由主流程壳层统一接入。** diff --git a/docs/planning/EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md b/docs/planning/EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md deleted file mode 100644 index 7e78a1ba..00000000 --- a/docs/planning/EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md +++ /dev/null @@ -1,447 +0,0 @@ -# Express 后端化工程重构规划(2026-04-08) - -## 1. 背景 - -当前项目已经引入 `Express` 后端,且 `server-node/` 已经承接了运行时鉴权、存档、设置、自定义世界、剧情生成、角色聊天、NPC 对话、运行时物品意图、任务生成等能力。 - -但从当前工程状态看,项目仍处于“后端已存在,但运行时领域层尚未完全脱前端”的过渡态,主要表现为: - -- 前端 `src/hooks/useStoryGeneration.ts` 仍然承担了大量运行时编排、规则拼接与状态推进职责。 -- 前端 `src/services/ai.ts` 仍然保留了完整的 AI 调用、提示词拼装和本地兜底实现。 -- 前端 `src/hooks/useGamePersistence.ts` 仍在承担较重的存档恢复、schema 纠偏与归一化职责。 -- `server-node/src/**` 当前仍在直接引用 `src/types`、`src/data`、`src/services` 中的内容,分层尚未真正闭合。 -- 编辑器相关写接口仍然散落在前端组件与 `jsonClient` 中,运行时 API 与编辑器 API 还没有完全归口。 - -现在既然已经明确“前端只负责做表现,所有逻辑、数据都放到后端进行运算和存储”,就需要把这个原则升级成整个工程的硬边界,而不是只停留在一部分接口迁移完成的状态。 - ---- - -## 2. 重构总原则 - -本轮重构只坚持一个核心原则: - -**前端不是业务执行层,而是表现层;后端才是唯一的运行时真相来源。** - -进一步展开为: - -- 前端只负责页面结构、动画演出、输入采集、局部交互态、加载态和错误态展示。 -- 后端负责鉴权、会话、规则计算、剧情推进、AI 编排、任务推进、道具结算、Build 结算、存档读写与持久化。 -- 浏览器内不再保留“正式运行时业务规则”的第二套实现。 -- 浏览器内允许存在少量纯表现计算,但不允许成为游戏状态真相来源。 -- 编辑器能力与正式运行时能力分离,避免 dev 工具链继续污染正式运行时边界。 - ---- - -## 3. 重构目标 - -## 3.1 目标状态 - -- 浏览器只发送“玩家意图”和必要的展示参数,不直接提交完整运行时真相。 -- `Express` 后端成为唯一的运行时状态源、规则执行源和 AI 调度源。 -- 运行时快照、任务状态、NPC 状态、背包、属性、Build、剧情历史全部以后端持久化结果为准。 -- 前端不再直接 import 正式运行时 AI 逻辑、提示词逻辑和关键规则逻辑。 -- `server-node` 不再依赖 `src/**` 中的前端实现细节,而是依赖独立共享层。 -- 编辑器 API、运行时 API、资产生成 API 形成清晰命名空间和权限边界。 - -## 3.2 非目标 - -- 本轮不追求一次性重写所有玩法系统。 -- 本轮不再讨论关系型数据库选型切换,当前后端以 `PostgreSQL` 为准。 -- 本轮不改动已有中文剧情、设定和文案方向。 -- 本轮不为了“前后端分离”牺牲移动端体验与当前主流程可玩性。 - ---- - -## 4. 职责边界 - -| 领域 | 前端职责 | 后端职责 | -| --- | --- | --- | -| 页面与流程壳层 | 页面切换、面板开关、布局、自适应、动效、加载态 | 不负责页面 UI | -| 用户输入 | 收集点击、拖拽、表单输入、选项选择 | 校验输入是否合法,解释输入对应的运行时动作 | -| 游戏状态 | 仅持有当前展示所需 view model 和局部 UI state | 持有完整游戏状态、快照、事件日志、版本号 | -| 剧情推进 | 展示文本流、选项、动画时间线 | 生成剧情、决定选项集合、推进故事状态 | -| 战斗与数值 | 播放攻击、受击、死亡、位移 | 计算伤害、蓝耗、CD、死亡、掉落、逃跑结果 | -| NPC/同伴交互 | 展示面板、聊天输入框、关系反馈演出 | 计算关系变化、招募条件、交易合法性、对话结果 | -| 背包/装备/Build | 展示背包、装备栏、Build 面板 | 计算背包变化、装备结果、Build 收益与约束 | -| 任务系统 | 展示任务卡片、任务进度、奖励动画 | 生成任务、推进 signal、发放奖励 | -| AI 调用 | 不直接请求正式运行时模型 | 统一做 prompt 组装、模型调用、超时重试、日志 | -| 持久化 | 最多保留极少量表现态缓存 | 负责存档、设置、用户数据、迁移、恢复 | -| 编辑器 | 调用 SDK、展示工具面板 | 负责写盘、生成任务、队列、权限与审计 | - -## 4.1 前端允许保留的状态 - -- 当前面板是否打开 -- 当前动画是否播放中 -- 当前流式文本已经显示到哪一段 -- 表单草稿、搜索词、临时筛选条件 -- 与展示相关的 viewport / media / motion 状态 - -## 4.2 前端禁止继续承载的职责 - -- function 合法性判定 -- 怪物/NPC/任务/物品结算 -- 正式运行时 prompt 组装 -- 正式运行时 AI fallback -- 存档 schema 迁移主逻辑 -- 以 `localStorage` 作为正式运行时主存储 -- 编辑器组件直接散落 `fetch('/api/...')` 访问写接口 - ---- - -## 5. 当前工程问题归纳 - -## 5.1 运行时领域逻辑仍然偏前端中心 - -- `useStoryGeneration` 仍然是大体量编排热区,承接了剧情、NPC、战斗后续、任务和部分故事引擎逻辑。 -- `src/services/ai.ts` 体量很大,说明正式运行时 AI 编排尚未完全移出浏览器。 -- 当前“后端接口 + 前端兜底”的过渡模式,容易让正式逻辑继续双份存在。 - -## 5.2 服务端分层还没真正闭合 - -- `server-node` 当前仍直接引用 `src/types`、`src/data`、`src/services`。 -- 这意味着后端虽然有了入口,但核心领域模型仍然绑在前端目录结构上。 -- 继续沿着这条路开发,会让后端无法独立测试、独立构建和独立演进。 - -## 5.3 运行时持久化边界还不够干净 - -- 虽然正式存档已经走远端接口,但前端仍承担较重的恢复、归一化、迁移纠偏逻辑。 -- 这会导致“存档解释权”同时存在于前后端两边,后续迭代容易失配。 - -## 5.4 编辑器与运行时 API 仍然混杂 - -- 编辑器读写接口目前仍然有散落访问点。 -- 资产生成、JSON 写盘、运行时 API 还没有形成清晰的接口分域。 -- 继续混用会让权限控制、生产部署和后续多人协作变得困难。 - -## 5.5 当前协议更像“接口迁移”,还不是“后端驱动运行时” - -- 目前很多接口是把已有前端逻辑搬成了远端调用入口。 -- 但真正理想状态应该是:玩家点击后,后端完成规则结算、状态推进、AI 调用和持久化,再把展示模型返回给前端。 - ---- - -## 6. 目标架构 - -```text -Browser -├─ 页面 / 动画 / 交互 / ViewModel 渲染 -├─ 轻量前端 SDK(只负责请求与状态绑定) -└─ 局部 UI State - -packages/shared -├─ contracts -├─ schemas -├─ domain-types -└─ api-client-types - -server-node -├─ src/modules/auth -├─ src/modules/runtime-session -├─ src/modules/story -├─ src/modules/combat -├─ src/modules/npc -├─ src/modules/inventory -├─ src/modules/build -├─ src/modules/quest -├─ src/modules/custom-world -├─ src/modules/editor -├─ src/shared/http -├─ src/shared/infra -└─ src/shared/llm - -storage -├─ postgres -├─ uploads -└─ generated -``` - -## 6.1 共享层原则 - -- `packages/shared` 只放类型、schema、协议、纯函数和序列化约定。 -- 共享层不放浏览器专属实现,也不放 Node 专属 IO。 -- 所有可执行运行时规则默认放后端,不放共享层。 - -## 6.2 前端目录目标 - -前端建议逐步收敛成下面的职责结构: - -```text -src/ -├─ app -├─ pages -├─ widgets -├─ features -├─ entities -├─ shared/api -├─ shared/ui -└─ shared/lib -``` - -其中: - -- `shared/api` 只保留面向后端 contract 的 SDK。 -- `features` 只组织交互流程和 UI 组合,不再承载正式运行时规则。 -- 超大 hook 逐步拆成“页面状态协调层 + 远端 action 调用层 + 表现层状态”。 - ---- - -## 7. 关键协议重构方向 - -当前最值得尽快统一的,不是继续加接口数量,而是把协议升级成“意图驱动”。 - -推荐核心动作协议: - -```json -{ - "sessionId": "runtime-session-id", - "clientVersion": 12, - "action": { - "type": "story_choice", - "functionId": "fight_attack", - "targetId": "npc_merchant_01", - "payload": { - "optionId": "opt_02" - } - } -} -``` - -后端统一返回: - -```json -{ - "sessionId": "runtime-session-id", - "serverVersion": 13, - "viewModel": {}, - "presentation": { - "storyText": "", - "options": [], - "battlePlayback": null, - "toast": null - }, - "patches": [], - "meta": { - "requestId": "req_xxx" - } -} -``` - -协议约束: - -- 前端不再提交完整 `gameState` 作为后端运算依据。 -- 前端提交的是“玩家意图”,不是“玩家已经算好的结果”。 -- 后端返回的是“下一帧该怎么演”的展示模型,而不是只回一个零散字段。 - ---- - -## 8. 分阶段重构路线 - -## P0:先冻结边界,建立共享协议层 - -### 本阶段目标 - -把“前端只做表现,后端负责运行时真相”从口头原则变成工程边界。 - -### 主要任务 - -- 提取 `shared contracts`,把 `server-node` 对 `src/**` 的依赖逐步迁出。 -- 固化统一的 API 响应结构、错误结构、`requestId`、版本字段。 -- 明确运行时 API 命名空间与编辑器 API 命名空间。 -- 新功能一律禁止再把正式运行时规则写回前端。 -- 为关键运行时入口补健康检查、日志字段、耗时统计。 - -### 交付物 - -- 共享类型与 schema 目录 -- 统一 API 约定文档 -- 服务端模块边界草图 -- 前端 SDK 基础层 - -### 验收标准 - -- `server-node` 可以不依赖 `src/**` 中的前端运行时实现继续编译。 -- 新增运行时需求不再允许“前端先写一版、后端再补一版”。 - -## P1:把运行时状态与持久化解释权收回后端 - -### 本阶段目标 - -让后端成为运行时状态、快照、恢复和迁移的唯一解释者。 - -### 主要任务 - -- 建立 `runtime session` / `snapshot aggregate`。 -- 将存档恢复、版本迁移、默认值补齐、schema 纠偏迁到后端。 -- 把前端 `useGamePersistence` 收敛为“拉取快照 + 触发保存 + 接收 view model”。 -- 设置、快照、自定义世界库统一归入运行时仓储接口。 -- 明确哪些内容允许本地缓存,哪些必须以后端结果为准。 - -### 交付物 - -- 统一的运行时 session API -- 快照版本迁移服务 -- 服务端持久化 schema 文档 - -### 验收标准 - -- 前端不再承担正式存档恢复迁移的主逻辑。 -- 同一份存档的解释权只存在于后端。 - -## P2:把核心规则结算从前端迁到后端 - -### 本阶段目标 - -把“剧情推进、战斗、NPC、任务、物品、Build”这些真正影响状态的领域结算全部后端化。 - -### 主要任务 - -- 把 function 合法性过滤迁入后端。 -- 把战斗结算、蓝耗、伤害、死亡、掉落、逃跑结果迁入后端。 -- 把 NPC 交互决策、招募条件、关系变化、交易合法性迁入后端。 -- 把任务推进 signal、奖励结算、运行时物品结果、Build 结果迁入后端。 -- 前端收到的只是一份下一步展示所需的聚合 view model 与演出计划。 - -### 交付物 - -- 运行时 action resolver -- 统一领域服务接口 -- 面向 UI 的 view model assembler - -### 验收标准 - -- 前端点击一个选项时,发送的是 action,不是本地先算完再上传结果。 -- 正式运行时的数值、资源、状态迁移不再依赖浏览器逻辑。 - -## P3:把 AI 编排彻底收口到后端 - -### 本阶段目标 - -让浏览器彻底退出正式运行时 AI 调用与 prompt 组装。 - -### 主要任务 - -- 把剧情生成、角色聊天、NPC 对话、自定义世界生成、任务生成、物品意图生成等统一后端执行。 -- 清理前端正式运行时代码中的 AI fallback。 -- 将 prompt 构造、模型容错、超时、重试、日志、SSE 转发统一收口到后端。 -- 对需要复用的 prompt 纯函数进行共享层抽取,但执行权只留在后端。 - -### 交付物 - -- 后端 AI orchestration 模块 -- 统一 SSE/streaming 适配层 -- 精简后的前端 AI SDK - -### 验收标准 - -- 浏览器正式运行时代码不再直接 import 大体量 AI 编排模块。 -- 无后端时,正式运行时不再默默回退到另一套浏览器逻辑。 - -## P4:把编辑器与资产流程独立成正式后端模块 - -### 本阶段目标 - -让编辑器能力不再作为运行时副产物存在,而是成为有边界的工具后端模块。 - -### 主要任务 - -- 建立 `/api/editor/*`、`/api/assets/*` 等明确命名空间。 -- 给编辑器写接口补权限、环境门禁、审计日志。 -- 统一编辑器 JSON 读写、资产生成、任务查询接口。 -- 前端编辑器组件全部改走统一 SDK,不再散落直连接口。 - -### 交付物 - -- editor API contract -- 统一 editor client SDK -- 生成任务与写盘适配器 - -### 验收标准 - -- 编辑器写接口不再散落在多个组件内部。 -- 运行时 API 与编辑器 API 的职责边界清晰。 - -## P5:补齐质量门禁、部署路径和观测能力 - -### 本阶段目标 - -让这次后端化重构可以稳定上线,而不是只在本地联调成立。 - -### 主要任务 - -- 为后端补单测、接口测试和关键链路 smoke。 -- 为前端补 contract 测试,确保 UI 不依赖本地规则。 -- 建立 `Nginx/Caddy -> dist + /api` 的同域部署路径。 -- 为流式接口补代理配置、超时、取消和日志。 -- 为数据库迁移、备份、回滚预留脚本。 - -### 验收标准 - -- `web + server` 可以独立构建、独立测试、联合部署。 -- 关键主流程至少具备一条可自动验证的 smoke path。 - ---- - -## 9. 具体迁移清单 - -## 9.1 优先迁移对象 - -- `src/hooks/useStoryGeneration.ts` -- `src/hooks/useGamePersistence.ts` -- `src/services/ai.ts` -- `src/services/aiService.ts` -- `src/services/storageService.ts` -- `src/services/authService.ts` -- 编辑器持久化模块与 `src/editor/shared/jsonClient.ts` - -## 9.2 优先抽离到共享层的内容 - -- 领域类型定义 -- zod schema 或等价校验协议 -- API 请求与响应 contract -- 纯序列化函数 -- 前后端都要认识的 enum / id / status 常量 - -## 9.3 不建议抽到共享层的内容 - -- 依赖数据库、文件系统、LLM、日志的服务 -- 正式运行时规则执行器 -- 存档迁移执行器 -- 资产生成任务调度器 - ---- - -## 10. 实施顺序建议 - -推荐顺序如下: - -1. 先抽共享类型与协议,切断 `server-node -> src/**` 的反向依赖。 -2. 再把运行时 session、快照解释权、存档迁移收回后端。 -3. 再迁核心规则结算,让前端从“业务执行层”退回“表现协调层”。 -4. 然后彻底收口 AI 编排,移除正式运行时浏览器 fallback。 -5. 最后归整编辑器 API、部署路径、测试门禁和观测能力。 - -不建议的顺序: - -1. 先零散把几个接口改成后端。 -2. 继续保留前端完整 fallback。 -3. 最后再补共享层和协议。 - -这个顺序会把“双份逻辑并存”的过渡期拖得很长,后面会越来越难收口。 - ---- - -## 11. 风险与控制点 - -- 最大风险不是“迁不动”,而是长期维持双份规则。 -- 后端化期间必须避免再往前端加新的正式运行时规则。 -- 协议演进要带版本号,否则快照和 UI 很容易错位。 -- 前端瘦身不能牺牲移动端一屏体验,表现层拆分仍要遵守移动端优先。 -- 编辑器 API 必须和正式运行时隔离,不要为了方便继续走混用路径。 - ---- - -## 12. 一句话结论 - -这次重构的核心不是“把几个请求改成走 Express”,而是: - -**把项目从“前端主导运行时、后端承接部分接口”的过渡架构,升级成“Express 后端统一持有运行时真相,前端只负责表现和交互”的正式工程架构。** diff --git a/docs/planning/README.md b/docs/planning/README.md index 74aedef0..3ca10087 100644 --- a/docs/planning/README.md +++ b/docs/planning/README.md @@ -3,12 +3,10 @@ ## 当前入口 - [ENGINEERING_DEAD_CODE_AND_HIDDEN_BRANCH_CLEANUP_PLAN_2026-04-21.md](./ENGINEERING_DEAD_CODE_AND_HIDDEN_BRANCH_CLEANUP_PLAN_2026-04-21.md):面向无用历史代码、隐形多数据链路和半成品实现的一轮工程大清洗执行计划,强调先建台账、再删重收口、最后恢复主工程可读性。 -- [CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md](./CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md):当前阶段最值得优先做什么、为什么,以及它和审计/PRD 的对应关系。 - [../technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md):当前创作入口、Agent session、结果页自动保存、作品库与进入世界主链的正式文件级重构基线;涉及目录落位、命名规范、阶段验收与工作包拆分时优先看这一份。 - [../technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md):当前平台入口、继续游戏、角色选择、RPG runtime 与 runtime story 主链的正式文件级重构基线;涉及入口壳层、session、runtime、story、route/service/repository 拆分时优先看这一份。 - [CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md](./CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md):创作链高层目标、冻结边界与执行顺序说明;文件级拆分与阶段验收以创作链重构执行方案为准。 -- [EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md](./EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md):基于“前端只做表现、逻辑与数据全部后端化”的工程重构规划。 -- [EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md](./EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md):将后端化重构拆成可并行推进、尽量不冲突的任务流与协作顺序。 +- [../technical/CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md](../technical/CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md):当前后端唯一落地口径,后续排期涉及服务端、数据真相或 SpacetimeDB 时优先按这一份判断方向。 - [BEIJING_POLICY_APPLICATION_OVERVIEW_13_21_24_2026-04-14.md](./BEIJING_POLICY_APPLICATION_OVERVIEW_13_21_24_2026-04-14.md):北京市方向 13 / 21 / 24 的统一判断、共用材料框架和准备顺序。 - [BEIJING_DIRECTION13_APPLICATION_MATERIALS_2026-04-14.md](./BEIJING_DIRECTION13_APPLICATION_MATERIALS_2026-04-14.md):方向 13 软件智能化提升奖励的硬门槛、必交材料、底稿建议和证据清单。 - [BEIJING_DIRECTION21_APPLICATION_MATERIALS_2026-04-14.md](./BEIJING_DIRECTION21_APPLICATION_MATERIALS_2026-04-14.md):方向 21 “创赢未来”成长计划的报名表、BP、Demo 和融资规划整理。 @@ -18,4 +16,5 @@ - 需要排期、拆阶段、判断先修基线还是先加功能时,先看这份。 - 当前如果要推进创作链或 RPG 运行时主链重构,先看上面的两份 `2026-04-21` 执行方案,再回来看高层优先级和冻结边界。 +- 涉及后端方案时,不再参考已删除的 Express / Node 规划文档,统一回到 Rust / SpacetimeDB 当前基线。 - 这份文档大量引用了经验文档、工程审查和 PRD,适合作为跨文档导航页使用。 diff --git a/docs/technical/API_SERVER_DEV_STACK_COLD_BUILD_TIMEOUT_FIX_2026-04-25.md b/docs/technical/API_SERVER_DEV_STACK_COLD_BUILD_TIMEOUT_FIX_2026-04-25.md new file mode 100644 index 00000000..2870b2ad --- /dev/null +++ b/docs/technical/API_SERVER_DEV_STACK_COLD_BUILD_TIMEOUT_FIX_2026-04-25.md @@ -0,0 +1,56 @@ +# api-server 本地 Rust 栈冷编译等待修复记录 + +日期:`2026-04-25` + +## 1. 背景 + +本地执行 `npm run dev:rust` 时,日志出现: + +```text +[dev:rust] 等待 api-server 就绪 +Compiling api-server v0.1.0 +[dev:rust] 等待 api-server 就绪超时: http://127.0.0.1:8082/healthz +[dev:rust] 停止 api-server +error: linking with `link.exe` failed: exit code: 143 +``` + +这类失败发生在 `api-server` 仍处于 `cargo run` 的冷编译或链接阶段时,`/healthz` 还没有机会监听端口。 + +## 2. 根因 + +根目录 `scripts/dev-rust-stack.sh` 同时使用 `SPACETIME_TIMEOUT_SECONDS=60` 控制: + +1. SpacetimeDB standalone 的启动等待。 +2. Rust `api-server` 的 `/healthz` 就绪等待。 + +SpacetimeDB 的本地启动通常较快,但 `api-server` 在 Windows MSVC 链接、依赖增量失效、首次构建或新增大依赖后可能超过 60 秒。脚本在超时后执行清理逻辑,主动杀掉仍在运行的 `cargo run` 子进程,因此 `link.exe exit code: 143` 是被本地栈脚本中断后的表现,不应优先判断为 Visual Studio Build Tools 损坏。 + +## 3. 修复口径 + +`scripts/dev-rust-stack.sh` 将 SpacetimeDB 与 `api-server` 的等待窗口拆开: + +1. `SPACETIME_TIMEOUT_SECONDS` 继续只控制 SpacetimeDB 就绪等待,默认 `60` 秒。 +2. 新增 `API_SERVER_TIMEOUT_SECONDS` 控制 `api-server` `/healthz` 就绪等待,默认 `300` 秒。 +3. 新增命令行参数 `--api-timeout-seconds ` 便于本地低性能机器或全量重编译时临时放宽。 +4. `api-server` 进程如果在等待窗口内自行退出,仍立即报错,不吞掉真实编译错误。 + +## 4. 使用方式 + +常规本地启动继续使用: + +```bash +npm run dev:rust +``` + +如本地需要更长冷编译窗口,可执行: + +```bash +npm run dev:rust -- --api-timeout-seconds 600 +``` + +## 5. 验收标准 + +1. 冷编译期间脚本不会在 60 秒时误杀 `cargo run -p api-server`。 +2. `/healthz` 真正可访问后,脚本继续启动 Vite。 +3. 如果 `api-server` 编译失败或运行时提前退出,脚本仍能快速停止并输出原始错误。 +4. SpacetimeDB 启动异常仍使用独立的 `--spacetime-timeout-seconds` 判断。 diff --git a/docs/technical/BACKEND_REWRITE_CROSS_CUTTING_GOVERNANCE_2026-04-22.md b/docs/technical/BACKEND_REWRITE_CROSS_CUTTING_GOVERNANCE_2026-04-22.md index f1999976..dd45ccbb 100644 --- a/docs/technical/BACKEND_REWRITE_CROSS_CUTTING_GOVERNANCE_2026-04-22.md +++ b/docs/technical/BACKEND_REWRITE_CROSS_CUTTING_GOVERNANCE_2026-04-22.md @@ -112,7 +112,7 @@ 1. 工程修改必须同步对应阶段任务清单。 2. 新增或改变接口时,同步更新 [RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md](./RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md)。 -3. 仍存在 Node 旧能力差异时,同步更新 [NODE_BACKEND_MODULE_AND_API_INDEX.md](./NODE_BACKEND_MODULE_AND_API_INDEX.md) 的过期说明或新增 Rust 侧补充索引。 +3. 仍存在旧能力差异时,同步更新 [CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md](./CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md) 或新增 Rust 侧补充索引。 4. M4 结构变更同步维护 RPG runtime 链路文档。 5. M5 结构变更同步维护 creation flow 链路文档。 6. M6 资产链路变更同步维护 OSS / asset_object / generated path 文档。 @@ -135,4 +135,3 @@ 3. 关键 SSE 接口联调。 4. SpacetimeDB publish / rollback 演练。 5. 灰度环境双跑对比。 - diff --git a/docs/technical/CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md b/docs/technical/CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md new file mode 100644 index 00000000..7eba374a --- /dev/null +++ b/docs/technical/CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md @@ -0,0 +1,33 @@ +# 当前后端实现基线(2026-04-25) + +## 1. 当前唯一落地口径 + +后续正式后端实现统一以 `server-rs` 为准: + +- HTTP 门面:Rust `api-server` / Axum。 +- 实时状态与业务真相:`crates/spacetime-module` / SpacetimeDB。 +- 共享领域与契约:`server-rs` 多 crate 分层维护。 +- 前端职责:只做表现、输入采集、临时 UI 状态与服务端结果渲染。 + +涉及 SpacetimeDB 的表、reducer、绑定生成、发布、本地联调,必须按仓库内 SpacetimeDB skills 执行。 + +## 2. 已替代的旧方向 + +以下旧方向不再作为新功能设计和编码依据: + +- `server-node` / Express / PostgreSQL 正式后端路线。 +- Go 服务端试验路线。 +- 浏览器侧承担正式运行时逻辑、正式生成编排或正式数据真相的路线。 + +旧实现只允许作为迁移参考:可以阅读其 contract、提示词、测试用例和边界经验,但不得为了兼容旧服务端继续扩展新代码。 + +## 3. 新文档落点 + +后续补充后端方案时优先落到这些文档族: + +- Rust / SpacetimeDB 架构与切流:`SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md`、`BACKEND_REWRITE_CROSS_CUTTING_GOVERNANCE_2026-04-22.md`、`M7_TEST_DEPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md`。 +- SpacetimeDB 模块拆分:`SPACETIME_MODULE_LIB_RS_SPLIT_EXECUTION_2026-04-23.md`。 +- Rust API 路由索引:`RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md`。 +- 本地与远端部署:`RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md`、`JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md`。 + +如果旧文档与本基线冲突,以本基线和更新日期更近的 Rust / SpacetimeDB 文档为准。 diff --git a/docs/technical/EDITOR_ASSET_API_MIGRATION_2026-04-08.md b/docs/technical/EDITOR_ASSET_API_MIGRATION_2026-04-08.md deleted file mode 100644 index 1768e251..00000000 --- a/docs/technical/EDITOR_ASSET_API_MIGRATION_2026-04-08.md +++ /dev/null @@ -1,101 +0,0 @@ -# 编辑器与资产 API 迁移清单(2026-04-08) - -## 1. 任务定位 - -对应 [EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md](../planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md) 中的任务 8:编辑器 API 归口与工具链隔离。 - -本轮目标是把编辑器写盘、资产生成、生成任务查询从旧的 Vite 本地 API 插件里收口到 `server-node`,并把前端编辑器组件改成通过统一 SDK 访问。 - ---- - -## 2. 新命名空间 - -编辑器写盘与读取: - -- `GET /api/editor/catalog/items` -- `GET /api/editor/json/:resourceId` -- `POST /api/editor/json/:resourceId` - -资产生成与任务查询: - -- `POST /api/assets/character-visual/generate` -- `POST /api/assets/character-visual/publish` -- `GET /api/assets/character-visual/jobs/:taskId` -- `POST /api/assets/character-animation/generate` -- `POST /api/assets/character-animation/publish` -- `GET /api/assets/character-animation/jobs/:taskId` -- `POST /api/assets/character-animation/import-video` -- `GET /api/assets/character-animation/templates` - ---- - -## 3. 前端接入 - -统一入口: - -- `src/editor/shared/editorApiClient.ts` - -已切换的编辑器链路: - -- 角色预设覆盖保存 -- 敌人预设覆盖保存 -- 场景预设覆盖保存 -- 场景角色覆盖保存 -- NPC 形象覆盖与布局配置保存 -- 物品目录读取与物品覆盖保存 -- 状态行为覆盖保存 -- 角色主形象生成、发布与任务查询 -- 角色动作生成、导入、发布、模板读取与任务查询 - ---- - -## 4. 权限与环境边界 - -`server-node` 通过环境变量控制工具接口: - -- `EDITOR_API_ENABLED`:控制 `/api/editor/*`。 -- `ASSETS_API_ENABLED`:控制 `/api/assets/*`。 - -默认策略: - -- 非 `production` 环境默认开启。 -- `production` 环境默认关闭。 -- `ASSETS_API_ENABLED` 未设置时跟随 `EDITOR_API_ENABLED`。 - -这批接口会读写 `src/data/*.json` 与 `public/generated-*`,不应作为正式运行时 API 使用。 - ---- - -## 5. 旧工具链隔离状态 - -自 `2026-04-19` 起,`scripts/dev-server/**` 中的旧 Vite 本地插件实现代码已经从仓库删除,也不再作为当前开发入口使用。 - -当前保留状态: - -- `scripts/dev-server/` 目录只保留迁移说明 README。 -- 旧链路的历史背景由 `docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md` 等审计文档承接。 - -新增编辑器或资产能力时,应优先写入: - -- `server-node/src/modules/editor/**` -- `server-node/src/modules/assets/**` -- `src/editor/shared/editorApiClient.ts` - -不要再新增旧式散落接口: - -- `/api/item-overrides` -- `/api/npc-visual-overrides` -- `/api/character-overrides` -- `/api/character-visual/*` -- `/api/animation/*` -- `/api/qwen-sprite/*` - ---- - -## 6. 当前验收状态 - -- `/api/editor/*` 与 `/api/assets/*` 命名空间已落地。 -- 前端编辑器组件已通过统一 SDK 或资源 ID 访问编辑器 API。 -- Vite 已代理 `/api/editor` 与 `/api/assets` 到 Node 后端。 -- 写接口已经有环境门禁。 -- 旧 Vite 本地插件代码已删除,不再保留并行实现。 diff --git a/docs/technical/EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md b/docs/technical/EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md deleted file mode 100644 index 34b861d0..00000000 --- a/docs/technical/EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md +++ /dev/null @@ -1,108 +0,0 @@ -# Express 后端接口冻结与集成清单(2026-04-09) - -## 1. 目的 - -这份文档补齐 `docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md` 中任务 0 缺失的仓库内产物,用来明确: - -- 当前 contract 的冻结版本 -- 热点文件的编辑规则 -- 各类改动进入集成窗口前的最小检查清单 - -它不是新的重构计划,而是给当前并行改造提供一个统一落库的“不要互相踩”的边界表。 - ---- - -## 2. Contract 版本表 - -| 范围 | 当前版本 | 源头文件 | 说明 | -| --- | --- | --- | --- | -| 统一 API envelope | `2026-04-08 / v1` | `packages/shared/src/http.ts` | `ApiResponse`、错误结构、`meta` 字段、envelope 头约定的统一来源。 | -| auth contract | `2026-04-08` | `packages/shared/src/contracts/auth.ts` | 前后端都以 shared auth contract 识别登录、用户信息与 token 响应。 | -| runtime snapshot/settings contract | `2026-04-08` | `packages/shared/src/contracts/runtime.ts` | 存档、设置、自定义世界会话与库表相关请求/响应来源。 | -| runtime story action contract | `2026-04-08` | `packages/shared/src/contracts/story.ts` | `RuntimeStoryActionRequest/Response`、Task5/Task6 function id 与 view model 来源。 | -| Node HTTP route meta | `2026-04-08` | `server-node/src/app.ts` | `/api/auth`、`/api/runtime/story`、`/api/editor`、`/api/assets` 都以这一轮 route version 为当前冻结口径。 | -| editor/assets route 命名空间 | `2026-04-08` | `server-node/src/modules/editor/editorRoutes.ts`、`server-node/src/modules/assets/**` | 编辑器与资产接口统一走 `/api/editor/*`、`/api/assets/*`。 | - ---- - -## 3. 热点文件编辑规则 - -以下文件继续视为高冲突入口,默认不要在多个任务里并行大改: - -- `server-node/src/context.ts` -- `server-node/src/routes/runtimeRoutes.ts` -- `server-node/src/app.ts` -- `src/services/apiClient.ts` -- `src/hooks/useStoryGeneration.ts` -- `src/hooks/useGameFlow.ts` -- `src/components/GameShell.tsx` - -统一规则: - -- 新需求优先新增独立模块,再通过桥接或小入口接入,不要直接把逻辑堆进热点文件。 -- 需要改 `server-node/src/routes/runtimeRoutes.ts` 时,先确认 shared contract 是否已落库,再补 route 接入。 -- 需要改 `src/hooks/useStoryGeneration.ts` 时,优先确认是否其实应该落到 `server-node/src/modules/**`、`src/services/runtimeStoryService.ts` 或 `src/hooks/story/**`。 -- 编辑器链路与正式运行时链路不要混在同一轮提交里。 -- 如果同一轮同时碰到后端 action 与前端 UI 壳层,先冻结 action/view model,再接 UI。 - ---- - -## 4. 集成窗口清单 - -### 4.1 shared contract 变更 - -- 只在 `packages/shared/**` 改类型、schema、纯序列化约定。 -- 同步检查 `server-node/src/**` 和 `src/**` 是否都已切到 shared contract。 -- 至少跑一次 `npm run server-node:test`。 -- 如果前端消费层也改了,再补 `npm run typecheck`。 - -### 4.2 runtime action / domain module 变更 - -- 业务规则优先写在 `server-node/src/modules/**`,不要直接写回前端 hook。 -- 如果影响 `RuntimeStoryActionResponse`,同步检查 `packages/shared/src/contracts/story.ts`。 -- 至少覆盖对应模块测试,或补到 `server-node/src/modules/story/storyActionRoutes.test.ts`。 -- 合并前至少跑一次 `npm run server-node:test`。 - -### 4.3 persistence / repository / config 变更 - -- 只把 PostgreSQL 视为正式基线。 -- 如果改到 `server-node/src/db.ts`、`repositories/**`、迁移脚本,优先确认 `pg-mem` 测试仍通过。 -- 合并前至少跑一次 `npm run server-node:test`。 - -### 4.4 editor / assets 变更 - -- 后端入口只放在 `/api/editor/*`、`/api/assets/*`。 -- 前端统一从 `src/editor/shared/editorApiClient.ts` 或对应 persistence 层进入。 -- 不要新增旧 Vite 本地插件式散落接口。 - -### 4.5 前端壳层接入变更 - -- 优先消费 `runtime story state / action response / shared contract`,不要把正式规则写回前端。 -- 如果恢复流程有改动,优先以后端 runtime state 为准。 -- 若影响主流程,至少补对应 hook / view model 测试并跑 `npm run typecheck`。 - ---- - -## 5. 当前剩余非冻结区 - -以下几块仍处于“可继续收口但尚未完全冻结”的状态,改动时要额外小心: - -- `server-node/src/bridges/legacyBuildRuntimeBridge.ts` -- `server-node/src/bridges/legacyInventoryRuntimeBridge.ts` -- `server-node/src/modules/ai/storyOrchestrator.ts` -- `server-node/src/modules/ai/chatOrchestrator.ts` -- `server-node/src/modules/ai/customWorldOrchestrator.ts` - -它们目前仍残留一部分对 `src/**` 历史实现的复用,不建议在没有额外测试兜底时顺手混改。 - ---- - -## 6. 本轮落库结论 - -从 2026-04-09 起,仓库内已经具备任务 0 要求的这几类最小产物: - -- contract 版本表 -- 热点文件编辑规则 -- 集成窗口检查清单 - -后续如果 shared contract、runtime action 或热点入口发生明显演进,应优先更新这份文档,而不是让口径只停留在聊天记录里。 diff --git a/docs/technical/EXPRESS_BACKEND_TASK4_AI_ORCHESTRATION_STATUS_2026-04-08.md b/docs/technical/EXPRESS_BACKEND_TASK4_AI_ORCHESTRATION_STATUS_2026-04-08.md deleted file mode 100644 index bc20b053..00000000 --- a/docs/technical/EXPRESS_BACKEND_TASK4_AI_ORCHESTRATION_STATUS_2026-04-08.md +++ /dev/null @@ -1,109 +0,0 @@ -# Express 后端任务 4 AI 编排收口状态(2026-04-08) - -## 1. 结论 - -按 `EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md` 的任务 4 定义,本轮已经把正式运行时的 `story / character chat / npc chat / custom world generation / quest intent / runtime item intent` 的主要 AI 编排入口收回到 Express 后端。 - -当前可以视为: - -- 正式运行时主链已不再依赖浏览器端大体量 AI 实现作为兜底。 -- prompt 组装、上游模型请求、SSE 转发已以后端为主。 -- 前端保留的本地 AI 大模块只通过懒加载方式服务于非正式运行时遗留入口,不再作为正式运行时默认路径。 - ---- - -## 2. 已完成项 - -### 2.1 后端统一 orchestration 入口 - -- `server-node/src/modules/ai/storyOrchestrator.ts` -- `server-node/src/modules/ai/chatOrchestrator.ts` -- `server-node/src/modules/ai/customWorldOrchestrator.ts` - -这些模块承接: - -- story prompt 组装 -- character chat prompt 组装 -- npc chat / recruit prompt 组装 -- custom world generation 后端入口封装 - -### 2.2 服务层收口 - -已收口到后端服务或模块的文件: - -- `server-node/src/services/llmClient.ts` -- `server-node/src/services/storyService.ts` -- `server-node/src/services/chatService.ts` -- `server-node/src/services/customWorldGenerationService.ts` -- `server-node/src/services/questService.ts` -- `server-node/src/services/runtimeItemService.ts` - -### 2.3 前端正式运行时 fallback 清理 - -`src/services/aiService.ts` 已完成以下收缩: - -- story 正式路径不再 fallback 到浏览器本地 AI 编排 -- character suggestions / summary / reply stream 不再 fallback 到浏览器本地 AI 编排 -- npc dialogue / recruit stream 不再 fallback 到浏览器本地 AI 编排 -- 移除了正式运行时对 `VITE_ENABLE_BROWSER_RUNTIME_AI_FALLBACK` 的依赖 -- 移除了正式运行时对本地轻量离线文案 fallback 的默认依赖 -- 对 `./ai` 的引用改为懒加载,避免正式运行时默认把大体量 AI 模块打进主路径 -- 正式运行时主流程 hook 已统一改走 `aiService`,不再直接从 `src/services/ai.ts` 获取 story/chat 主链能力 - -### 2.4 旧 bridge 清理 - -已移除: - -- `server-node/src/bridges/legacyAiRuntimeBridge.ts` - ---- - -## 3. 当前边界说明 - -以下项仍然存在于前端目录,但不再属于正式运行时默认 AI 执行路径: - -- `src/services/ai.ts` -- `src/services/aiFallbacks.ts` -- `src/components/CustomWorldEntityEditorModal.tsx` 中的工具链直连调用 - -它们当前主要作为: - -- 兼容性遗留实现 -- 懒加载的非主路径工具能力 -- 非本轮正式运行时链路的复用来源 - -这不再构成任务 4 的主阻塞,但后续仍应继续配合任务 1 / 任务 7 做彻底分层。 - ---- - -## 4. 非任务 4 主阻塞但需要记录的事项 - -### 4.1 仍属编辑器/工具链范畴的遗留调用 - -- `generateCustomWorldSceneImage` 仍通过懒加载复用旧实现。 - -原因: - -- 该能力属于自定义世界工具链,不是正式运行时剧情 / 对话主链。 -- 当前不会再影响“浏览器正式运行时是否依赖本地大 AI 编排”这一任务 4 验收项。 - -### 4.2 分层彻底闭合仍需后续任务配合 - -尽管任务 4 已完成主链收口,但以下更深层收敛仍建议交由后续任务继续推进: - -- 继续减少 `server-node` 对 `src/**` 纯提示词/纯规则模块的历史复用 -- 继续把共享 contract / schema 下沉到 `packages/shared` -- 继续把工具链与正式运行时拆分 - -这些属于任务 1、任务 7、任务 8 的后续工作,不再阻塞任务 4 验收。 - ---- - -## 5. 本轮建议验收口径 - -任务 4 可按以下口径验收: - -- 浏览器正式运行时不再默认兜底到本地大体量 AI 编排 -- story/chat/custom world generation 主链 prompt 组装与请求执行权在后端 -- SSE 主链以后端转发为准 -- upstream timeout / abort / error 统一走后端处理链 diff --git a/docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md b/docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md deleted file mode 100644 index 0002d48f..00000000 --- a/docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md +++ /dev/null @@ -1,425 +0,0 @@ -# Express 后端并行任务完成度审计(2026-04-09) - -## 1. 审计范围 - -本次审计以 `docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md` 为基准,只检查仓库内可直接验证的代码、测试、脚本和文档产物。 - -不能仅靠仓库静态内容确认的团队协作物,例如看板、口头冻结流程、每日集成节奏,只按“仓库内可见状态”记录,不把它们误判成完全验收。 - ---- - -## 2. 任务完成度总览 - -| 任务 | 状态 | 结论 | -| --- | --- | --- | -| 任务 0:集成岗与接口冻结 | 基本完成 | 已补齐接口冻结与集成清单文档,contract 版本表、热点文件编辑规则、集成窗口检查项都已落库;但团队执行节奏本身仍无法仅凭仓库静态确认。 | -| 任务 1:共享 Contract 与目录抽离 | 部分完成 | `packages/shared` 已建立并承接 auth/runtime/story contract,且 NPC Task6 bridge 与 chat prompt builder 已进一步下沉到后端本地模块;但 AI 编排主链仍残留少量 `src/**` 反向依赖,分层尚未完全闭合。 | -| 任务 2:PostgreSQL 持久化基线收口 | 已完成 | `server-node/src/config.ts`、`db.ts`、`repositories/**`、迁移脚本、`pg-mem` 测试与部署文档均已到位。 | -| 任务 3:HTTP 基础设施与统一响应壳层 | 已完成 | 统一错误格式、`requestId`、route meta、响应壳层、观测测试已落地。 | -| 任务 4:服务端 AI 编排收口 | 基本完成 | orchestration 模块、SSE 转发和主链调用已回到后端,但仍有少量 prompt/规则模块通过 `src/**` 复用,属于后续继续收敛项。 | -| 任务 5:Story / Combat / NPC | 已完成 | 后端 story action route、session 组装、combat/npc 领域服务和对应回归测试已落地。 | -| 任务 6:Inventory / Quest / Build / Runtime Item | 已完成 | 对应模块、服务与回归测试已经覆盖主要正式运行时结算。 | -| 任务 7:前端 SDK、鉴权、持久化瘦身 | 部分完成 | `apiClient`、`authService`、`storageService` 已统一,但前端仍保留一部分存档归一化和主流程协调职责。 | -| 任务 8:编辑器 API 归口与工具链隔离 | 基本完成 | editor/assets 模块、前端 editor client、迁移文档均已出现,职责边界基本清晰。 | -| 任务 9:测试、观测与部署基线 | 已完成 | baseline test、smoke、proxy smoke、部署与回滚清单、日志链路均已具备。 | -| 任务 10:前端主流程壳层与大 Hook 瘦身 | 部分完成 | `GameShellRuntime` / `useGameShellRuntimeViewModel` 以及新的 `storyRequestCoordinator` 已拆出,但 `useStoryGeneration.ts` 仍然过重,主流程尚未彻底退回“表现层协调器”。 | - ---- - -## 3. 本轮补齐项 - -本轮先补齐了任务 0 缺失的仓库内协作产物: - -- 新增 `docs/technical/EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md` - - 落库当前 contract 版本表 - - 明确热点文件编辑规则 - - 补齐 shared/runtime/editor/frontend 壳层各自的集成窗口清单 - -这让任务 0 不再只停留在规划文档里,而是有了一份可随版本一起更新的冻结口径。 - -随后补上了一段此前仍偏向前端快照解释的恢复链路: - -- `src/hooks/story/runtimeStoryCoordinator.ts` - - 新增 `resumeServerRuntimeStory` - - 继续游戏时,若当前是正式运行时故事快照,会先请求 `/api/runtime/story/state/:sessionId` - - 让当前 `storyText` 与可用 `options` 优先以后端 runtime state 为准 - -- `src/hooks/useGamePersistence.ts` - - `continueSavedGame()` 现在会优先走后端 runtime story 恢复 - - 如果服务端恢复失败,再回退到本地快照归一化结果 - - 额外补了 `bottomTab` 归一化,避免恢复时吃到宽泛字符串 - -- `src/hooks/story/runtimeStoryCoordinator.test.ts` - - 新增“继续游戏时优先从后端恢复 runtime story”的测试 - - 新增“非正式运行时快照不额外请求后端”的测试 - -这次补丁对应的是任务 7 与任务 10 之间的一段未完全闭合边界:前端在恢复流程里不应该只把远端存档当作“原始 JSON 缓存”,而应优先相信后端当前 runtime state。 - -此外,本轮还继续补了任务 1 的一段后端分层收口: - -- 新增 `server-node/src/modules/runtime/runtimeStatePrimitives.ts` - - 后端本地承接 `addInventoryItems` - - 后端本地承接 `removeInventoryItem` - - 后端本地承接 `incrementGameRuntimeStats` - - 后端本地承接 `buildRelationState` -- 新增 `server-node/src/modules/runtime/runtimeStatePrimitives.test.ts` - - 校验背包合并、移除、运行时统计累加、关系阶段映射 -- 调整桥接层: - - `server-node/src/bridges/legacyInventoryRuntimeBridge.ts` - - `server-node/src/bridges/legacyNpcTask6Bridge.ts` - - `server-node/src/modules/quest/questTask6Bridge.ts` - -这意味着任务 5/6 主链里最基础的一批状态原语,已经不再依赖前端 `src/data/runtimeStats.ts`、`src/data/attributeResolver.ts`、`src/data/npcInteractions.ts` 的对应实现。 - -本轮又继续把 NPC Task6 bridge 里最后一批直接挂到前端 `npcInteractions.ts` 的函数下沉到了后端本地: - -- 新增 `server-node/src/modules/npc/npcTask6Primitives.ts` - - 后端本地承接 `applyStoryChoiceToStanceProfile` - - 后端本地承接 `buildInitialNpcState` - - 后端本地承接 `syncNpcTradeInventory` - - 后端本地承接 `getGiftCandidates` - - 后端本地承接 `buildNpcGiftCommitActionText` - - 后端本地承接 `buildNpcGiftResultText` - - 后端本地承接 `buildNpcTradeTransactionActionText` - - 后端本地承接 `buildNpcTradeTransactionResultText` -- 新增 `server-node/src/modules/npc/npcTask6Primitives.test.ts` - - 覆盖 NPC 初始库存生成 - - 覆盖交易库存刷新时保留非交易物品 - - 覆盖赠礼偏好排序 -- 调整桥接层: - - `server-node/src/bridges/legacyNpcTask6Bridge.ts` - -这意味着 Task6 的 NPC 交易/赠礼/初始库存这条支链,已经不再直接依赖前端 `src/data/npcInteractions.ts`。 - -同时还把叙事语言检测工具下沉到了共享层: - -- 新增 `packages/shared/src/llm/narrativeLanguage.ts` -- `src/services/narrativeLanguage.ts` 改为复用共享实现 -- `server-node/src/modules/ai/storyOrchestrator.ts` 改为直接依赖共享层 - -这部分虽然不是大块业务迁移,但它属于任务 1 最稳定的一类收口:把纯函数共识从前端目录中抽离出来。 - -再补充一批已经完成的后端纯原语迁移: - -- 新增 `server-node/src/modules/runtime/runtimeEconomyPrimitives.ts` - - 后端本地承接 `formatCurrency` - - 后端本地承接 `getInventoryItemValue` - - 后端本地承接 `getNpcPurchasePrice` - - 后端本地承接 `getNpcBuybackPrice` -- 新增 `server-node/src/modules/runtime/runtimeTreasureTexts.ts` - - 后端本地承接 `buildTreasureResultText` -- 新增 `server-node/src/modules/runtime/runtimeEconomyPrimitives.test.ts` - - 覆盖交易定价、货币文本、宝藏奖励文本 - -对应桥接层: - -- `server-node/src/bridges/legacyNpcTask6Bridge.ts` - - 不再依赖前端 `src/data/economy.ts` -- `server-node/src/bridges/legacyTreasureRuntimeBridge.ts` - - 不再从前端导出 `buildTreasureResultText` - -这说明任务 5/6 主链中一部分交易、礼物、宝藏结算反馈文本,也已经从前端数据层抽离。 - -本轮又进一步补了 NPC 状态与叙事记忆的后端本地原语: - -- 新增 `server-node/src/modules/runtime/runtimeNpcStatePrimitives.ts` - - 后端本地承接 `normalizeNpcPersistentState` - - 后端本地承接 `markNpcFirstMeaningfulContactResolved` -- 新增 `server-node/src/modules/runtime/runtimeNarrativeMemory.ts` - - 后端本地承接 `appendStoryEngineCarrierMemory` -- 新增 `server-node/src/modules/runtime/runtimeNpcStatePrimitives.test.ts` - - 覆盖 NPC 状态归一化、首次有效接触标记、叙事载体记忆写入 - -对应桥接层: - -- `server-node/src/bridges/legacyNpcTask6Bridge.ts` - - 不再依赖前端 `src/services/storyEngine/echoMemory.ts` - - 不再依赖前端 `src/data/npcInteractions.ts` 中的 NPC 状态归一化与首次接触标记逻辑 - -这进一步缩小了后端在任务 5/6 主链上对前端 story-engine 服务目录的借用范围。 - -本轮还整块收口了 Quest 与 Runtime Item 两条桥接链: - -- 新增 `server-node/src/modules/quest/runtimeQuestModule.ts` - - 后端本地承接 `buildQuestForEncounter` - - 后端本地承接 `evaluateQuestOpportunity` - - 后端本地承接 `buildFallbackQuestIntent` - - 后端本地承接 `compileQuestIntentToQuest` - - 后端本地承接 `buildQuestGenerationContextFromState` - - 后端本地承接 `buildQuestIntentPrompt` - - 后端本地承接 Quest 进度归一化与 signal 推进 -- 更新桥接层: - - `server-node/src/bridges/legacyQuestProgressBridge.ts` - - `server-node/src/bridges/legacyQuestRuntimeBridge.ts` - -这意味着 Quest 的“确定性委托构建 + AI 意图上下文 + 任务推进”已经不再依赖前端 `src/data/questFlow.ts`、`src/services/questDirector.ts`、`src/services/questPrompt.ts`。 - -同时,Runtime Item 也已经收回到后端本地: - -- 新增 `server-node/src/modules/runtime-item/runtimeItemModule.ts` - - 后端本地承接 `buildRuntimeItemAiIntent` - - 后端本地承接 `buildRuntimeItemIntentPrompt` - - 后端本地承接 `buildLooseRuntimeItemGenerationContext` - - 后端本地承接 `buildQuestRuntimeItemGenerationContext` - - 后端本地承接 `buildDirectedRuntimeReward` - - 后端本地承接 `buildRuntimeInventoryStock` - - 后端本地承接 `flattenDirectedRuntimeRewardItems` -- 新增 `server-node/src/modules/runtime-item/runtimeTreasureModule.ts` - - 后端本地承接 `resolveTreasureReward` -- 更新桥接层: - - `server-node/src/bridges/legacyRuntimeItemBridge.ts` - - `server-node/src/bridges/legacyRuntimeItemResolutionBridge.ts` - - `server-node/src/bridges/legacyTreasureRuntimeBridge.ts` - -这说明 Runtime Item / Treasure 相关的 AI 意图、奖励生成和库存生成,也已经从前端目录中抽离。 - -本轮还继续整块收口了 Build / Inventory / Forge / Equipment 规则桥接: - -- 新增 `server-node/src/modules/runtime/runtimeEquipmentModule.ts` - - 后端本地承接 `getEquipmentSlotFromItem` - - 后端本地承接 `getEquipmentSlotLabel` - - 后端本地承接 `getEquipmentBonuses` - - 后端本地承接 `applyEquipmentLoadoutToState` -- 新增 `server-node/src/modules/runtime/runtimeInventoryEffectsModule.ts` - - 后端本地承接 `isInventoryItemUsable` - - 后端本地承接 `resolveInventoryItemUseEffect` - - 后端本地承接 `buildInventoryUseResultText` -- 新增 `server-node/src/modules/runtime/runtimeForgeModule.ts` - - 后端本地承接 `getForgeRecipeViews` - - 后端本地承接 `executeForgeRecipe` - - 后端本地承接 `executeDismantleItem` - - 后端本地承接 `executeReforgeItem` - - 后端本地承接 `getReforgeCostView` - - 后端本地承接 `buildForgeSuccessText` -- 新增 `server-node/src/modules/runtime/runtimeBuildModule.ts` - - 后端本地承接 `appendBuildBuffs` - - 后端本地承接 `getPlayerBuildDamageBreakdown` - - 后端本地承接 `resolvePlayerOutgoingDamageResult` - -对应桥接层: - -- `server-node/src/bridges/legacyBuildRuntimeBridge.ts` -- `server-node/src/bridges/legacyInventoryRuntimeBridge.ts` - -这意味着 Build / Inventory / Forge / Equipment 相关的后端主链结算,已经不再依赖前端 `src/data/buildDamage.ts`、`src/data/equipmentEffects.ts`、`src/data/forgeSystem.ts`、`src/data/inventoryEffects.ts`。 - -本轮又补了一段任务 1 的 AI 编排收口: - -- 新增 `server-node/src/modules/ai/chatPromptBuilders.ts` - - 后端本地承接 character chat reply / suggestions / summary prompt 组装 - - 后端本地承接 npc chat / recruit prompt 组装 -- 调整: - - `server-node/src/modules/ai/chatOrchestrator.ts` - - `server-node/src/modules/ai/orchestrator.test.ts` - -这意味着 chat orchestration 已不再依赖前端 `src/services/characterChatPrompt.ts` 与 `src/services/prompt.ts`。 - -本轮继续把 story orchestration 主链也收回到了后端本地: - -- 新增 `server-node/src/modules/ai/storyPromptBuilders.ts` - - 后端本地承接 `SYSTEM_PROMPT` - - 后端本地承接 `buildUserPrompt` -- 重写 `server-node/src/modules/ai/storyOrchestrator.ts` - - 正式生产链不再依赖前端 `src/services/prompt.ts` - - 正式生产链不再依赖前端 `src/data/stateFunctions.ts` - - 正式生产链不再依赖前端 `src/data/scenePresets.ts` - - 正式生产链不再依赖前端 `src/data/hostileNpcs.ts` -- 调整: - - `server-node/src/modules/ai/orchestrator.test.ts` - -到这里,`server-node` 正式生产代码路径里,Story / Chat / Quest / Runtime Item / Treasure / Build / Inventory / Forge / NPC Task6 主链都已经从前端 `src/**` 目录脱钩。 - -本轮也继续推进了任务 10 的主流程瘦身: - -- 新增 `src/hooks/story/storyRequestCoordinator.ts` - - 抽离运行时 option source 解析 - - 抽离服务端 option catalog 回退策略 - - 抽离 initial / next story 请求参数协调 -- 新增 `src/hooks/story/storyRequestCoordinator.test.ts` - - 覆盖服务端 option catalog 切换 - - 覆盖显式 option catalog 短路 - - 覆盖服务端目录加载失败时回退本地可用项 -- 调整: - - `src/hooks/useStoryGeneration.ts` - -这说明 `useStoryGeneration.ts` 虽然仍重,但“故事请求协调”已经不再和主流程 UI 状态、NPC/战斗/宝藏后续处理混在同一个大段里。 - -本轮又补了一段任务 10 的纯展示逻辑拆分: - -- 新增 `src/hooks/story/storyPresentation.ts` - - 抽离 story options 去重与补齐 - - 抽离对白 turn 解析 - - 抽离 dialogue story moment 组装 - - 抽离 typewriter delay -- 新增 `src/hooks/story/storyPresentation.test.ts` - - 覆盖对白解析与 dialogue story moment - - 覆盖选项池去重与补齐 -- 调整: - - `src/hooks/useStoryGeneration.ts` - -这意味着 `useStoryGeneration.ts` 又减少了一批与 React 状态本身无关的纯函数逻辑,任务 10 的主流程壳层拆分继续向前推进。 - -本轮还补上了任务 1 的最后一条后端反向依赖: - -- 删除 `server-node/src/bridges/legacyCustomWorldAiBridge.ts` -- 重写 `server-node/src/modules/ai/customWorldOrchestrator.ts` - - 后端本地承接 custom world generation 的 deterministic 生成流程 - - 后端本地承接 generation progress 汇报 - -这意味着 `server-node/src/**` 的正式生产代码路径已经不再反向依赖前端 `src/**` 目录。 - ---- - -## 4. 仍需继续收口的重点 - -### 4.1 任务 1 的剩余问题 - -当前从仓库内直接扫描看,`server-node/src/**` 的正式生产代码路径已经不再存在对前端 `src/**` 的反向依赖。 - -其中 Story / Chat / Quest / Runtime Item / Treasure / Build / Inventory / Forge / Equipment / NPC Task6 / Custom World generation 相关正式生产链都已经从这个列表中退出。 - -同时,`server-node/src/modules/ai/orchestrator.test.ts` 已不再直接依赖前端 `src/services/prompt.ts` 与 `src/data/stateFunctions.ts`。 - -这说明任务 1 在“后端正式生产运行时不反向依赖前端目录”这一层面已经完成。 - -#### 4.1.1 当前残留依赖的真实形态 - -从当前状态看,任务 1 后续不再是“补洞”,而是“优化”: - -- 继续提高 custom world generation 的质量与保真度 -- 继续把真正通用的 prompt / JSON repair / batch helper 整理进 `packages/shared` 或 `server-node/src/modules/ai/**` -- 维持后端不再回流引用前端目录的约束 - -#### 4.1.2 更适合的继续收口顺序 - -结合当前状态,任务 1 后续更适合做的是能力优化: - -1. 继续增强 custom world generation 的语义保真度与校验强度。 -2. 继续把 prompt builder、JSON repair、批处理工具整理到 `packages/shared` 或 `server-node/src/modules/ai/**`。 -3. 持续维持“后端正式生产代码不反向依赖前端目录”的约束,避免后续重新开洞。 - -### 4.2 任务 7 的剩余问题 - -前端虽然已经大量改成 SDK 消费层,但 `src/persistence/runtimeSnapshot.ts` 里仍保留较重的存档归一化与迁移修复逻辑。 - -这部分后续仍建议继续向后端迁移,避免前后端双边解释快照。 - -#### 4.2.1 `runtimeSnapshot.ts` 当前仍承担的职责 - -从文件本身看,`src/persistence/runtimeSnapshot.ts` 仍不是一个单纯的“读取后端结果并转成 UI 状态”的轻薄消费层。 - -它当前仍直接负责: - -- 存档迁移 manifest 应用 -- roster 归一化 -- player currency 默认值补齐 -- equipment loadout 回填与属性重算 -- NPC persistent state 归一化 -- quest log 归一化 -- runtime stats 归一化 -- scene encounter preview 修复 -- story engine memory 缺省补齐 - -这说明任务 7 的剩余问题,不只是“前端还有一点 normalize 代码”,而是: - -- 前端仍在解释正式存档的结构语义 -- 前端仍在决定若干正式运行时字段的缺省和修复策略 -- 后端与前端之间仍存在“双边都能定义快照有效形态”的空间 - -#### 4.2.2 下一步更合适的迁移方向 - -结合本轮已经补上的“继续游戏优先以后端 runtime story state 为准”这段恢复链路,任务 7 后续更适合继续向这个方向推进: - -1. 把 `runtimeSnapshot.ts` 中的迁移、归一化、缺省补齐继续下沉到后端持久化层或专门的 runtime snapshot service。 -2. 让前端拿到的远端快照尽量已经是“可直接消费的正式形态”,而不是“仍需前端补算的半成品”。 -3. 把前端保留的本地 hydrate 逻辑收缩到纯 UI 恢复字段,例如当前页签、面板开合、局部显示态。 - -只有这样,任务 7 才算真正回到“前端只做消费层,后端才是正式状态解释者”的目标。 - -### 4.3 任务 10 的剩余问题 - -当前最大的未完成项仍然是: - -- `src/hooks/useStoryGeneration.ts` - -它仍承担了大量: - -- 正式运行时故事编排 -- 本地 fallback 组织 -- NPC / 宝藏 / 战斗后续协调 -- 主流程状态推进 - -现阶段虽然已经有: - -- `src/hooks/useGameShellRuntime.ts` -- `src/components/game-shell/useGameShellRuntimeViewModel.ts` -- `src/hooks/story/runtimeStoryCoordinator.ts` -- `src/hooks/story/storyRequestCoordinator.ts` - -但主流程还没有彻底完成 action/view model 化,任务 10 仍应视为“进行中”。 - -#### 4.3.1 `useStoryGeneration.ts` 当前仍然为什么过重 - -从仓库现状看,`src/hooks/useStoryGeneration.ts` 目前仍有约 1700 行,且其过重问题已经不是单纯“文件太长”,而是它还保留着大量正式业务协调职责。 - -当前这个 hook 里仍集中着: - -- `buildStoryContextFromState` 这一整块故事上下文拼装 -- AI 请求前的 option catalog / fallback 组织 -- NPC / 宝藏 / 战斗 / inventory / goal / session 多条子链的集中协调 -- 本地 fallback story 生成 -- 一部分运行时规则与 narrative context 的最终拼装入口 - -这意味着即使已经拆出了: - -- `runtimeStoryCoordinator` -- `storyRequestCoordinator` -- `choiceActions` -- `npcEncounterActions` -- `sessionActions` - -`useStoryGeneration.ts` 仍然不是“单纯的 UI 壳层 hook”,而更像前端侧的运行时总协调器。 - -#### 4.3.2 任务 10 后续更值得优先拆的部分 - -按当前文件结构看,后续最值得优先继续抽离的不是零散按钮逻辑,而是下面三类真正还握在主 hook 里的核心块: - -1. `buildStoryContextFromState` 这一整块 story-engine 叙事上下文装配。 -2. `buildFallbackStoryForState` / `getAvailableOptionsForState` 这类“正式规则兜底与选项来源判断”。 -3. NPC / Treasure / Character Chat / Session 这些子流之间的最终总装协调。 - -如果只是继续把零散函数拆到 `src/hooks/story/**`,但上述三块还留在主 hook 里,那么任务 10 仍然不会真正完成。 - -### 4.4 建议的下一轮补齐顺序 - -结合任务 1、任务 7、任务 10 当前的剩余形态,后续更合适的补齐顺序建议是: - -1. 先继续收任务 7 的 runtime snapshot 解释权,把正式快照的迁移、修复、归一化口径继续回收到后端。 -2. 再继续收任务 10,把 `useStoryGeneration.ts` 压回主流程协调壳层,而不是继续让它承担正式运行时总装职责。 - -这样排的原因是: - -- 任务 1 已经完成后,新的主阻塞就变成了任务 7 和任务 10。 -- 如果任务 7 不继续收,前端仍会在恢复链路里保留正式状态解释权,任务 10 也很难真正变薄。 -- 等到 runtime state 与 snapshot 口径都更稳定后,再继续瘦 `useStoryGeneration.ts`,返工会更少。 - ---- - -## 5. 本轮验证 - -已通过: - -- `npm run server-node:test` -- `npx vitest run src/hooks/story/storyRequestCoordinator.test.ts src/hooks/story/runtimeStoryCoordinator.test.ts` -- `npm run typecheck` -- `npm run check:encoding` - ---- - -## 6. 结论 - -从仓库可验证结果看,任务 2、3、5、6、9 已经达到“可认为完成”的状态;任务 0、4、8 基本完成;任务 1、7、10 仍有明显后续收口空间。 - -当前最主要的未完成中心已经不再是后端基建,而是: - -**把前端主流程和存档恢复彻底收成“以后端 runtime state 和 view model 为唯一真相源”。** diff --git a/docs/technical/FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md b/docs/technical/FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md deleted file mode 100644 index 8813f208..00000000 --- a/docs/technical/FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md +++ /dev/null @@ -1,190 +0,0 @@ -# 前端逻辑后移实施方案(2026-04-21) - -更新时间:`2026-04-21` - -## 1. 目标 - -本方案只回答一件事: - -**怎样把当前仍残留在前端的正式运行时逻辑、正式会话真相与正式生成编排,继续收回到 Express 后端。** - -这份文档不是泛泛而谈的方向说明,而是直接面向本轮与后续几轮编码落地的实施基线。 - ---- - -## 2. 本轮确定的硬边界 - -根据仓库约束与当前审计结果,本轮继续冻结以下边界: - -1. 前端只负责表现、输入采集、临时 UI 状态与服务端结果渲染。 -2. 后端负责正式鉴权、正式会话、正式运行时快照、正式任务生成、正式运行时物品意图生成、正式自定义世界生成。 -3. `codex/backend-rewrite-spacetimedb` 目标分支的鉴权仍以服务端签发 JWT、前端 Bearer token 携带为准;本轮合入不采用 `codex/dev` 的 access cookie 会话方案。 -4. 浏览器内不再把浏览历史作为本地正式真相,不再保留正式 quest / runtime item / custom world 生成编排。 -5. 运行时主链必须继续向“前端提交意图,后端解释快照并返回展示模型”收敛。 - ---- - -## 3. 现状拆分 - -当前残留问题已经收敛为三批: - -### 3.1 第一批:正式真相仍在前端 - -1. `src/services/apiClient.ts` - - 浏览器当前仍保存 access token,并在请求层拼接 `Authorization: Bearer ...` - - 该链路在 `codex/backend-rewrite-spacetimedb` 仍是既定正式实现,不再按 cookie access session 改写 -2. `src/services/authService.ts` - - 登录、微信绑定、回调消费流程都要与 JWT/Bearer 方案保持一致,避免混入 access cookie 分支语义 -3. `src/components/game-shell/PreGameSelectionFlow.tsx` - - 浏览历史仍是本地写入 + 后端回填的双真相 -4. `src/services/platformBrowseHistory.ts` - - 维护浏览历史本地存储、迁移标记与同步状态 - -### 3.2 第二批:运行时主链仍依赖前端预写快照 - -1. `src/hooks/story/runtimeStoryCoordinator.ts` - - 在请求 runtime state / runtime action 前,仍先 `PUT /runtime/save/snapshot` -2. `src/hooks/story/npcEncounterActions.ts` - - 待接委托的“更换任务”“放弃任务”仍由前端正式结算 - -### 3.3 第三批:正式生成编排仍残留在浏览器 - -1. `src/services/questDirector.ts` -2. `src/services/runtimeItemAiDirector.ts` -3. `src/services/aiService.ts` 的 custom world profile 生成入口 -4. `src/services/ai.ts` 中仍保留的浏览器侧 legacy AI orchestration - ---- - -## 4. 分批实施策略 - -## 4.1 第一批:先收正式真相 - -### 鉴权 - -目标状态: - -1. 后端继续通过 JWT 承载 access token,并只从 `Authorization: Bearer ...` 读取当前访问身份。 -2. 前端请求层继续负责保存、刷新和携带 access token;公开请求与静默探测不得误清正式 token。 -3. access cookie 会话方案不进入 `codex/backend-rewrite-spacetimedb`,避免和目标分支已有 JWT 方案并存。 -4. `AuthGate` 通过 refresh cookie / `/api/auth/me` 恢复出用户后,必须先确保本地 access token 可用,再把 `readyUser` 暴露给运行时、设置、作品列表等受保护业务 hook;业务 hook 不能只凭 `user.id` 在 token 尚未补齐时启动远端请求。 - -本批涉及: - -1. `server-node/src/routes/authRoutes.ts` -2. `server-node/src/middleware/auth.ts` -3. `src/services/apiClient.ts` -4. `src/services/authService.ts` -5. `src/components/auth/AuthGate.tsx` - -### 浏览历史 - -目标状态: - -1. 浏览历史唯一真相在 `runtimeRepository`。 -2. 前端不再保留本地浏览历史、迁移标记、同步标记。 -3. 浏览历史只通过 `storageService` 读取和写入。 - -本批涉及: - -1. `src/components/game-shell/PreGameSelectionFlow.tsx` -2. `src/components/game-shell/PlatformHomeView.tsx` -3. `src/services/storageService.ts` -4. `src/services/platformBrowseHistory.ts` - -## 4.2 第二批:把 runtime story 快照解释权收回后端 - -目标状态: - -1. 前端不再通过单独的 `PUT /runtime/save/snapshot` 预写快照再触发动作。 -2. runtime state / runtime action 允许前端提交当前快照上下文,由后端内部决定是否写入、如何解释、何时持久化。 -3. NPC 待接委托的 replace / abandon / accept 全部走后端 runtime action。 - -建议实施方式: - -1. 扩展 `packages/shared/src/contracts/story.ts` - - `RuntimeStoryActionRequest` 增加可选 `snapshot` - - 新增 `RuntimeStoryStateRequest` -2. 新增 `POST /api/runtime/story/state/resolve` -3. `storyActionService` 内部统一处理“请求携带快照上下文时的服务端同步” -4. 把 `npc_chat_quest_offer_replace` / `npc_chat_quest_offer_abandon` 接到后端 runtime action - -## 4.3 第三批:把正式生成编排收成后端唯一出口 - -目标状态: - -1. `questDirector` 只保留轻量 SDK。 -2. `runtimeItemAiDirector` 只保留轻量 SDK。 -3. custom world profile 正式生成走后端 route。 -4. 浏览器侧 `src/services/ai.ts` 不再承担正式浏览器主链。 - -建议实施方式: - -1. `server-node/src/routes/runtimeRoutes.ts` - - 补 `custom-world/profile` 正式 route -2. `src/services/aiService.ts` - - custom world 入口改走后端 -3. `src/services/questDirector.ts` - - 只请求 `/api/runtime/quests/generate` -4. `src/services/runtimeItemAiDirector.ts` - - 只请求 `/api/runtime/items/runtime-intent` - ---- - -## 5. 本轮落地范围 - -本轮优先完成以下内容: - -1. 鉴权维持 `codex/backend-rewrite-spacetimedb` 既有 JWT/Bearer 方案,不合入 `codex/dev` 的 access cookie 访问认证。 -2. 浏览历史从前端本地真相后移到后端唯一真相。 -3. custom world profile 正式生成入口补齐后端 route,并把前端收成 SDK。 -4. `questDirector` / `runtimeItemAiDirector` 收缩为前端 SDK。 -5. runtime story contract 开始补“随请求提交快照上下文”的后端承接能力,并把 NPC 待接委托 replace / abandon 接到后端。 - -### 5.1 已完成 - -1. `codex/backend-rewrite-spacetimedb` 本轮保留 JWT access token + refresh cookie 组合方案,不合入 access cookie 写入与读取链路。 -2. 浏览历史已收敛为后端唯一真相,前端不再维护正式本地 browse history 链。 -3. runtime story 已支持随请求提交 snapshot,由后端内部解释与持久化。 -4. NPC 待接委托 `replace / abandon / accept` 已以后端 runtime action 为准。 -5. custom world profile 浏览器正式入口已改走后端 route。 -6. `questDirector` / `runtimeItemAiDirector` 已收缩为前端 SDK,不再承担正式浏览器编排。 -7. NPC 招募正式结算已迁到后端: - - 前端只负责招募对白展示与 release 目标选择 - - 后端负责 `npcStates / companions / roster / currentEncounter / storyHistory` 正式结算 - - 满员换队招募已由后端承接 - -### 5.2 剩余未完成 - -1. `src/services/ai.ts` 仍保留 legacy fallback / test 能力,尚未彻底压缩出正式浏览器主链。 -2. 仍需继续审视是否存在其他 NPC / 运行时分支,把正式状态裁决留在前端。 - ---- - -## 6. 验收标准 - -### 第一批验收 - -1. 浏览器继续保存 access token,并由 `fetchWithApiAuth` 稳定拼接 `Authorization: Bearer ...`。 -2. 401 刷新链只在已发送 Bearer token 时触发,并且刷新响应必须返回新的 JWT。 -3. 浏览历史仅通过远端接口读写。 -4. `src/services/platformBrowseHistory.ts` 不再是正式链路依赖。 -5. 手机验证码登录、微信回调或 refresh cookie 会话恢复完成后,首屏并发读取设置、存档、个人看板、浏览历史、作品列表前,必须已经完成 access token 写入,避免出现“用户已 ready 但请求缺少 Authorization Bearer Token”的竞态。 - -### 第二批验收 - -1. `runtimeStoryCoordinator.ts` 不再在动作前独立 `PUT /runtime/save/snapshot`。 -2. `NPC` 待接委托 replace / abandon / accept 都以后端返回结果为准。 - -### 第三批验收 - -1. `questDirector.ts` 与 `runtimeItemAiDirector.ts` 不再保留正式 fallback orchestration。 -2. custom world profile 的浏览器正式入口不再直接 import legacy `./ai`。 - ---- - -## 7. 一句话结论 - -这轮迁移的重点不是“把几个 helper 挪到 server-node 目录”,而是: - -**把前端里仍然承担正式真相、正式运行时解释和正式生成编排的那一层职责,继续收回到 Express 后端。** diff --git a/docs/technical/M3_PROFILE_DASHBOARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md b/docs/technical/M3_PROFILE_DASHBOARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md index 4d4e4782..ee919e86 100644 --- a/docs/technical/M3_PROFILE_DASHBOARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md +++ b/docs/technical/M3_PROFILE_DASHBOARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md @@ -11,7 +11,7 @@ - [M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md](./M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md) - [M3_BROWSE_HISTORY_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md](./M3_BROWSE_HISTORY_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md) -- [NODE_BACKEND_MODULE_AND_API_INDEX.md](./NODE_BACKEND_MODULE_AND_API_INDEX.md) +- [CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md](./CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md) - `server-node/src/routes/rpg-profile/rpgProfileRoutes.ts` - `server-node/src/repositories/runtimeRepository.ts` diff --git a/docs/technical/M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md b/docs/technical/M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md index 3dcd8695..de455441 100644 --- a/docs/technical/M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md +++ b/docs/technical/M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md @@ -10,7 +10,7 @@ 关联现状: - [SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](./SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md) -- [NODE_BACKEND_MODULE_AND_API_INDEX.md](./NODE_BACKEND_MODULE_AND_API_INDEX.md) +- [CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md](./CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md) - `server-node/src/routes/rpg-profile/rpgProfileRoutes.ts` - `server-node/src/repositories/runtimeRepository.ts` diff --git a/docs/technical/M4_MODULE_AI_BASELINE_DESIGN_2026-04-21.md b/docs/technical/M4_MODULE_AI_BASELINE_DESIGN_2026-04-21.md index 9e4068a5..414568fa 100644 --- a/docs/technical/M4_MODULE_AI_BASELINE_DESIGN_2026-04-21.md +++ b/docs/technical/M4_MODULE_AI_BASELINE_DESIGN_2026-04-21.md @@ -24,11 +24,8 @@ 本文以以下现有文档和代码为准: 1. [SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](./SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md) -2. [NODE_BACKEND_MODULE_AND_API_INDEX.md](./NODE_BACKEND_MODULE_AND_API_INDEX.md) -3. [EXPRESS_BACKEND_TASK4_AI_ORCHESTRATION_STATUS_2026-04-08.md](./EXPRESS_BACKEND_TASK4_AI_ORCHESTRATION_STATUS_2026-04-08.md) -4. `server-node/src/modules/ai/storyOrchestrator.ts` -5. `server-node/src/modules/ai/chatOrchestrator.ts` -6. `server-node/src/modules/ai/customWorldOrchestrator.ts` +2. [CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md](./CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md) +3. 历史 Node AI 编排代码仅作为迁移背景,不再作为当前实现依据。 ## 3. 现状问题 diff --git a/docs/technical/M7_TEST_DEPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md b/docs/technical/M7_TEST_DEPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md index 2f3af1e4..837b2fd7 100644 --- a/docs/technical/M7_TEST_DEPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md +++ b/docs/technical/M7_TEST_DEPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md @@ -11,7 +11,7 @@ M7 的目标不是新增玩法功能,而是在 `M0 ~ M6` 已迁移的 Rust 后 1. 固定本地、灰度、切流前的检查命令。 2. 固定 `Axum + SpacetimeDB + OSS` 的部署与回滚口径。 3. 固定观测字段、慢请求、上游失败日志与资产任务日志。 -4. 固定旧 `server-node` 与新 `server-rs` 的双跑和 API 对比方式。 +4. 固定旧 `server-node` 删除后的 Rust 主线回归与部署验证方式。 5. 等价拆分 `server-rs/crates/spacetime-module/src/lib.rs`,避免 SpacetimeDB 主工程继续退化为单大文件。 ## 2. 执行约束 @@ -19,8 +19,8 @@ M7 的目标不是新增玩法功能,而是在 `M0 ~ M6` 已迁移的 Rust 后 1. 不改变现有 HTTP contract、SSE contract、SpacetimeDB 表名、reducer 名、procedure 名和对象键前缀。 2. 不把 LLM、OSS、短信、微信等外部副作用移入 SpacetimeDB reducer。 3. `spacetime-module` 拆分只做物理结构收口,不做 schema 重命名、字段删除、字段重排或 reducer/procedure 改名。 -4. 迁移期保留 `server-node` 作为回退锚点,M7 不删除旧后端。 -5. 前端切换默认仍指向 Node;只有显式设置 `GENARRATIVE_BACKEND_STACK=rust` 或 `GENARRATIVE_RUNTIME_SERVER_TARGET` 时才切到 Rust。 +4. `server-node/` 已进入物理删除流程,M7 不再把旧 Node 后端作为运行时回退锚点。 +5. 前端默认指向 Rust `api-server`;如需临时覆盖目标,只允许使用 `RUST_SERVER_TARGET` 或 `GENARRATIVE_RUNTIME_SERVER_TARGET` 指向 Rust 兼容服务。 ## 3. 测试体系 @@ -36,7 +36,6 @@ M7 固定四层测试入口: ```powershell .\server-rs\scripts\m7-preflight.ps1 .\server-rs\scripts\smoke.ps1 -node scripts\run-tsx.cjs scripts\m7-api-compare.ts ``` ## 4. 部署准备 @@ -72,32 +71,32 @@ OSS / CDN / 域名方案: 6. `DASHSCOPE_BASE_URL`、`DASHSCOPE_API_KEY` 7. `SMS_AUTH_ENABLED` 与短信供应商变量 8. `WECHAT_AUTH_ENABLED` 与微信 OAuth 变量 -9. `GENARRATIVE_BACKEND_STACK`、`NODE_SERVER_TARGET`、`RUST_SERVER_TARGET`、`GENARRATIVE_RUNTIME_SERVER_TARGET` +9. `RUST_SERVER_TARGET`、`GENARRATIVE_RUNTIME_SERVER_TARGET` ## 5. 灰度与切流 灰度环境固定为三段: -1. `shadow`:Node 继续承接用户流量,Rust 只由脚本和内部账号请求。 -2. `dual-run`:同一组 smoke/API compare 同时打 Node 与 Rust,差异必须登记。 -3. `rust-primary`:反向代理或 Vite dev proxy 指向 Rust,Node 进程保留但不作为主入口。 +1. `preflight`:只跑 Rust 预检、smoke 与人工主链验证,不接正式用户流量。 +2. `limited-rust`:小范围账号或灰度域名访问 Rust `api-server`,差异必须登记到 M7 验收记录。 +3. `rust-primary`:反向代理或 Vite dev proxy 指向 Rust `api-server`,旧 Node 后端不作为运行时入口保留。 前端切换方式: -1. 默认 `GENARRATIVE_BACKEND_STACK=node`。 -2. 本地或灰度切 Rust 设置 `GENARRATIVE_BACKEND_STACK=rust`,并配置 `RUST_SERVER_TARGET`。 -3. 紧急回退设置 `GENARRATIVE_BACKEND_STACK=node` 或直接覆盖 `GENARRATIVE_RUNTIME_SERVER_TARGET` 指回 Node。 +1. 默认使用 `RUST_SERVER_TARGET` 或 `GENARRATIVE_API_TARGET` 指向 Rust `api-server`。 +2. 本地或灰度可覆盖 `GENARRATIVE_RUNTIME_SERVER_TARGET`,但目标仍必须是 Rust 兼容服务。 +3. 紧急回退优先回滚到上一个 Rust 发布包或反向代理配置,不恢复 `server-node/` 工程目录。 -## 6. API 对比 +## 6. API 回归 -`scripts/m7-api-compare.ts` 负责对比 Node 与 Rust 的基础 contract: +第一批删除后不再保留 Node/Rust 对比脚本,M7 回归改为 Rust 主线 contract 验证: -1. 默认对比 `/healthz` 与 `/api/auth/login-options`。 -2. 可通过 `M7_COMPARE_PATHS` 扩展只读路径清单。 -3. 对比时会固定传入 `x-request-id`,并归一化 `requestId / timestamp / latencyMs` 等波动字段。 -4. 默认严格模式下发现差异直接返回非零退出码。 +1. `server-rs/scripts/m7-preflight.ps1` 覆盖 Rust 工作区构建、测试与关键脚本门禁。 +2. `server-rs/scripts/smoke.ps1` 覆盖 `/healthz`、envelope 与 request id 基础 contract。 +3. `server-rs/scripts/oss-smoke.ps1` 覆盖真实 OSS 链路。 +4. 新增只读 contract 时优先补进 Rust 侧 smoke 或 handler 测试,不恢复 Node 对比脚本。 -该脚本只承担“无状态 GET contract”对比;带登录、写入、OSS 或 SSE 的主流程仍由专门 smoke 脚本负责。 +带登录、写入、OSS 或 SSE 的主流程仍由专门 smoke、handler 测试和人工验证清单负责。 ## 7. 观测能力 @@ -113,10 +112,10 @@ M7 观测字段固定为: ## 8. 数据迁移与回滚 -当前 M7 不做一次性“Node PostgreSQL 全量导入 SpacetimeDB”的危险迁移,采用双跑验证与按主链确认的渐进策略: +当前 M7 不做一次性历史数据导入 SpacetimeDB 的危险迁移,采用按主链确认的渐进策略: 1. 已迁移主链以 SpacetimeDB 为真相源。 -2. 未迁移或灰度失败主链继续回退到 Node。 +2. 未迁移或灰度失败主链必须继续迁入 Rust 主线后再开放,不回退到旧 Node 工程。 3. 资产二进制以 OSS 为真相,不回滚到本地 `public/generated-*` 写盘。 4. 若 SpacetimeDB schema 需要清库重发,只允许在开发库或明确灰度库执行 `--clear-database`。 5. 生产回滚优先切反向代理目标,不优先改代码。 @@ -128,6 +127,6 @@ M7 完成时必须满足: 1. M7 文档、脚本、任务清单均同步。 2. `api-server` 和 `spacetime-module` 至少通过 `cargo check`。 3. 基础 smoke 脚本可执行,并覆盖 `healthz + envelope + request id`。 -4. Node/Rust API 对比脚本可执行。 -5. Vite dev proxy 已具备 Node/Rust 切换与回退开关。 +4. Rust 主线预检和 smoke 脚本可执行。 +5. Vite dev proxy 默认指向 Rust `api-server`,仅保留 Rust 目标覆盖开关。 6. `spacetime-module` 已从单 `lib.rs` 拆为按 `runtime / gameplay / custom_world / asset_metadata / ai` 组织的文件结构。 diff --git a/docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md b/docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md deleted file mode 100644 index c0dd2aaa..00000000 --- a/docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md +++ /dev/null @@ -1,399 +0,0 @@ -# Node 后端模块与接口索引 - -> 该文档由 `server-node/src/manifest/backendCapabilityManifest.ts` 自动生成。 -> 生成命令:`npm run server-node:manifest:backend` -> 生成时间:`2026-04-20T14:26:38.663Z` -> -> 过期说明:该索引生成于 `2026-04-20`,其中 `createQwenSpriteRoutes` 与 `/api/assets/qwen-sprite/*` 相关描述已在 `2026-04-21` 后失效。当前 Node 现役资产挂载面仅保留 `createCharacterAssetRoutes`;`Qwen` 仅剩 prompt 模板复用与 `/generated-qwen-sprites/*` 历史路径兼容,不再存在独立路由主链。 - -## 总览 - -- 对外挂载面:6 个 -- 已登记路由:96 条 -- 内部模块目录:12 个 -- 公开接口:10 条 -- JWT 接口:69 条 -- 受环境开关控制的接口:17 条 -- 流式接口:6 条 - -## 产物 - -- JSON 清单:`server-node/manifests/backend-capability-index.json` -- Markdown 索引:`docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md` -- Manifest 源:`server-node/src/manifest/backendCapabilityManifest.ts` - -## 对外挂载面 - -### 资产生成工具面 - -- 标识:`assets` -- 路由数:14 -- 入口:`server-node/src/app.ts -> /api/assets -> createCharacterAssetRoutes`;`server-node/src/app.ts -> /api/assets/qwen-sprite -> createQwenSpriteRoutes` -- 关联模块:`assets` -- 责任: - - 生成角色主形象、动作、动作模板与工作流缓存。 - - 承接 Qwen 精灵表主图、整表、修帧与保存链路。 - - 把产物发布到 `public/generated-*` 目录并落地局部 manifest。 -- 主要服务边界: - - 负责对接 DashScope、Ark 等外部媒体供应商,但不维护 runtime 快照与业务状态。 - - 统一受 `ASSETS_API_ENABLED` 开关控制,产物以文件与 JSON manifest 形式落在仓库工作区。 - -### 鉴权与会话面 - -- 标识:`auth` -- 路由数:17 -- 入口:`server-node/src/app.ts -> /api/auth -> createAuthRoutes` -- 关联模块:无 -- 责任: - - 承接本地账号、短信验证码与微信登录流程。 - - 管理 refresh session、用户信息、会话吊销、审计日志与风险拦截。 -- 主要服务边界: - - HTTP 层只做 schema 校验、请求上下文拼装与 Cookie 管理,核心鉴权逻辑统一收口到 `server-node/src/auth/*`。 - - 用户、身份、会话、风控与短信事件等持久化职责全部下沉到 repository 层,避免路由直接碰数据库细节。 - -### 编辑器工具面 - -- 标识:`editor` -- 路由数:3 -- 入口:`server-node/src/app.ts -> /api/editor -> createEditorRoutes` -- 关联模块:`editor` -- 责任: - - 读取编辑器资源 JSON。 - - 回写编辑器覆盖文件。 - - 枚举 `public/Icons` 下的物品图标资源。 -- 主要服务边界: - - 只对工作区文件系统与 `public` 目录负责,不参与运行时数据库存储。 - - 统一受 `EDITOR_API_ENABLED` 开关控制,生产环境可按需关闭。 - -### 基础健康检查 - -- 标识:`health` -- 路由数:1 -- 入口:`server-node/src/app.ts -> /healthz -> createApp` -- 关联模块:无 -- 责任: - - 提供 Node 后端进程级健康探针。 - - 给反向代理、部署平台和本地联调提供最小可用状态确认。 -- 主要服务边界: - - 只返回服务静态信息,不触达数据库、鉴权或外部模型供应商。 - -### 运行时主能力面 - -- 标识:`runtime-main` -- 路由数:59 -- 入口:`server-node/src/app.ts -> /api -> createRuntimeRoutes`;`server-node/src/routes/runtimeRoutes.ts -> /runtime/custom-world/agent -> createCustomWorldAgentRoutes` -- 关联模块:`ai`、`custom-world`、`quest`、`runtime`、`runtime-item`、`story` -- 责任: - - 承接运行时资料库、公开画廊、存档、设置与个人档案接口。 - - 承接剧情生成、聊天流、任务生成、运行时物品意图与自定义世界链路。 - - 承接 Custom World Agent 会话、消息流和操作回放。 -- 主要服务边界: - - HTTP contract 收口在 `runtimeRoutes.ts`,真正的世界生成、剧情、聊天、任务和资源逻辑继续下沉到 `services/*` 与 `src/modules/*`。 - - 除公开画廊外,运行时接口统一走 JWT 鉴权,并依赖 `runtimeRepository`、session store 与 LLM client 执行。 - -### 运行时 Story Action 面 - -- 标识:`runtime-story-action` -- 路由数:2 -- 入口:`server-node/src/app.ts -> /api/runtime/story -> createStoryActionRoutes` -- 关联模块:`story`、`quest`、`inventory`、`runtime-item`、`npc`、`progression`、`combat`、`runtime` -- 责任: - - 把前端 story choice 动作解析为新的运行时状态。 - - 查询指定 story session 的可恢复状态。 -- 主要服务边界: - - 路由层只做鉴权与 schema 校验,真正的动作分发与跨模块协作集中在 `storyActionService.ts`。 - - Story Action 会联动 quest、inventory、runtime-item、npc 等内部模块,但对前端只暴露 story 这一条稳定入口。 - -## 接口索引 - -| 方法 | 路径 | 访问 | 响应 | 挂载面 | 内部模块 | 说明 | -| --- | --- | --- | --- | --- | --- | --- | -| POST | `/api/assets/character-animation/generate` | 开关: ASSETS_API_ENABLED | json | `assets` | `assets` | 生成角色动作草稿。 | -| POST | `/api/assets/character-animation/import-video` | 开关: ASSETS_API_ENABLED | json | `assets` | `assets` | 导入动作参考视频并转为可消费素材。 | -| GET | `/api/assets/character-animation/jobs/:taskId` | 开关: ASSETS_API_ENABLED | json | `assets` | `assets` | 查询角色动作生成任务状态。 | -| POST | `/api/assets/character-animation/publish` | 开关: ASSETS_API_ENABLED | json | `assets` | `assets` | 发布角色动作帧集到 public 目录。 | -| GET | `/api/assets/character-animation/templates` | 开关: ASSETS_API_ENABLED | json | `assets` | `assets` | 列出内置角色动作模板。 | -| POST | `/api/assets/character-visual/generate` | 开关: ASSETS_API_ENABLED | json | `assets` | `assets` | 生成角色主形象候选图。 | -| GET | `/api/assets/character-visual/jobs/:taskId` | 开关: ASSETS_API_ENABLED | json | `assets` | `assets` | 查询角色主形象生成任务状态。 | -| POST | `/api/assets/character-visual/publish` | 开关: ASSETS_API_ENABLED | json | `assets` | `assets` | 发布选中的角色主形象到 public 目录。 | -| POST | `/api/assets/character-workflow-cache` | 开关: ASSETS_API_ENABLED | json | `assets` | `assets` | 保存角色资产工作流缓存。 | -| GET | `/api/assets/character-workflow-cache/:characterId` | 开关: ASSETS_API_ENABLED | json | `assets` | `assets` | 按角色读取角色资产工作流缓存。 | -| POST | `/api/assets/qwen-sprite/frame-repair` | 开关: ASSETS_API_ENABLED | json | `assets` | `assets` | 对单帧做 Qwen 修复。 | -| POST | `/api/assets/qwen-sprite/master` | 开关: ASSETS_API_ENABLED | json | `assets` | `assets` | 生成 Qwen 精灵主图。 | -| POST | `/api/assets/qwen-sprite/save` | 开关: ASSETS_API_ENABLED | json | `assets` | `assets` | 保存 Qwen 精灵资产到 public 目录。 | -| POST | `/api/assets/qwen-sprite/sheet` | 开关: ASSETS_API_ENABLED | json | `assets` | `assets` | 生成 Qwen 精灵表。 | -| GET | `/api/auth/audit-logs` | JWT | json | `auth` | 无 | 查询当前账号的鉴权审计日志。 | -| POST | `/api/auth/entry` | 公开 | json | `auth` | 无 | 用户名密码登录;不存在则创建本地账号。 | -| GET | `/api/auth/login-options` | 公开 | json | `auth` | 无 | 返回当前启用的登录方式与入口配置。 | -| POST | `/api/auth/logout` | JWT | json | `auth` | 无 | 退出当前会话并清理 refresh cookie。 | -| POST | `/api/auth/logout-all` | JWT | json | `auth` | 无 | 退出当前账号的全部会话。 | -| GET | `/api/auth/me` | JWT | json | `auth` | 无 | 读取当前登录用户的鉴权资料。 | -| POST | `/api/auth/phone/change` | JWT | json | `auth` | 无 | 已登录用户更换绑定手机号。 | -| POST | `/api/auth/phone/login` | 公开 | json | `auth` | 无 | 手机号验证码登录。 | -| POST | `/api/auth/phone/send-code` | 公开 | json | `auth` | 无 | 发送手机号登录或绑定验证码。 | -| POST | `/api/auth/refresh` | 公开 | json | `auth` | 无 | 使用 refresh session 刷新 JWT。 | -| GET | `/api/auth/risk-blocks` | JWT | json | `auth` | 无 | 查询当前用户命中的风控封禁。 | -| POST | `/api/auth/risk-blocks/:scopeType/lift` | JWT | json | `auth` | 无 | 请求解除指定维度的风控拦截。 | -| GET | `/api/auth/sessions` | JWT | json | `auth` | 无 | 列出当前账号的活跃会话。 | -| POST | `/api/auth/sessions/:sessionId/revoke` | JWT | json | `auth` | 无 | 吊销指定会话。 | -| POST | `/api/auth/wechat/bind-phone` | JWT | json | `auth` | 无 | 为已登录微信账号绑定手机号。 | -| GET | `/api/auth/wechat/callback` | 公开 | redirect | `auth` | 无 | 处理微信回调并重定向回前端。 | -| GET | `/api/auth/wechat/start` | 公开 | json | `auth` | 无 | 发起微信登录并返回授权 URL。 | -| POST | `/api/custom-world/cover-image` | JWT | json | `runtime-main` | `custom-world`、`assets` | 生成自定义世界封面图。 | -| POST | `/api/custom-world/cover-upload` | JWT | json | `runtime-main` | `custom-world`、`assets` | 上传并落地自定义世界封面图。 | -| POST | `/api/custom-world/entity` | JWT | json | `runtime-main` | `custom-world`、`ai` | 按世界 profile 生成单个角色或地标实体。 | -| POST | `/api/custom-world/scene-image` | JWT | json | `runtime-main` | `custom-world`、`assets` | 生成自定义世界场景图。 | -| POST | `/api/custom-world/scene-npc` | JWT | json | `runtime-main` | `custom-world`、`ai`、`npc` | 按地标生成场景 NPC。 | -| GET | `/api/editor/catalog/items` | 开关: EDITOR_API_ENABLED | json | `editor` | `editor` | 列出 `public/Icons` 下的物品图标资源。 | -| GET | `/api/editor/json/:resourceId` | 开关: EDITOR_API_ENABLED | json | `editor` | `editor` | 读取指定编辑器资源 JSON。 | -| POST | `/api/editor/json/:resourceId` | 开关: EDITOR_API_ENABLED | json | `editor` | `editor` | 回写指定编辑器资源 JSON。 | -| POST | `/api/llm/chat/completions` | JWT | proxy | `runtime-main` | `ai` | 把聊天补全请求透传到上游模型。 | -| DELETE | `/api/profile/browse-history` | JWT | json | `runtime-main` | `runtime` | 清空平台浏览历史。 | -| GET | `/api/profile/browse-history` | JWT | json | `runtime-main` | `runtime` | 读取平台浏览历史。 | -| POST | `/api/profile/browse-history` | JWT | json | `runtime-main` | `runtime` | 写入或批量同步平台浏览历史。 | -| GET | `/api/profile/dashboard` | JWT | json | `runtime-main` | `runtime` | 读取运行时个人主页汇总。 | -| GET | `/api/profile/play-stats` | JWT | json | `runtime-main` | `runtime` | 读取个人游玩统计。 | -| GET | `/api/profile/save-archives` | JWT | json | `runtime-main` | `runtime` | 列出个人存档摘要。 | -| POST | `/api/profile/save-archives/:worldKey` | JWT | json | `runtime-main` | `runtime` | 恢复指定世界的最近存档。 | -| GET | `/api/profile/wallet-ledger` | JWT | json | `runtime-main` | `runtime` | 列出个人资产流水。 | -| POST | `/api/runtime/chat/character/reply/stream` | JWT | stream | `runtime-main` | `ai`、`story` | 流式生成角色回复。 | -| POST | `/api/runtime/chat/character/suggestions` | JWT | json | `runtime-main` | `ai`、`story` | 生成角色聊天建议语。 | -| POST | `/api/runtime/chat/character/summary` | JWT | json | `runtime-main` | `ai`、`story` | 生成角色聊天摘要。 | -| POST | `/api/runtime/chat/npc/dialogue/stream` | JWT | stream | `runtime-main` | `ai`、`npc`、`story` | 流式生成 NPC 对话。 | -| POST | `/api/runtime/chat/npc/recruit/stream` | JWT | stream | `runtime-main` | `ai`、`npc`、`story` | 流式生成招募 NPC 对话。 | -| POST | `/api/runtime/chat/npc/turn/stream` | JWT | stream | `runtime-main` | `ai`、`npc`、`story` | 流式生成 NPC 单回合发言。 | -| GET | `/api/runtime/custom-world-gallery` | 公开 | json | `runtime-main` | `custom-world`、`runtime` | 列出公开的自定义世界画廊。 | -| GET | `/api/runtime/custom-world-gallery/:ownerUserId/:profileId` | 公开 | json | `runtime-main` | `custom-world`、`runtime` | 读取指定公开世界作品详情。 | -| GET | `/api/runtime/custom-world-library` | JWT | json | `runtime-main` | `custom-world`、`runtime` | 列出当前账号的自定义世界资料库。 | -| DELETE | `/api/runtime/custom-world-library/:profileId` | JWT | json | `runtime-main` | `custom-world`、`runtime` | 删除指定自定义世界 profile。 | -| PUT | `/api/runtime/custom-world-library/:profileId` | JWT | json | `runtime-main` | `custom-world`、`runtime` | 写入或更新指定自定义世界 profile。 | -| POST | `/api/runtime/custom-world-library/:profileId/publish` | JWT | json | `runtime-main` | `custom-world`、`runtime` | 发布指定世界到公开画廊。 | -| POST | `/api/runtime/custom-world-library/:profileId/unpublish` | JWT | json | `runtime-main` | `custom-world`、`runtime` | 撤回指定世界的公开发布状态。 | -| POST | `/api/runtime/custom-world/agent/sessions` | JWT | json | `runtime-main` | `custom-world`、`ai` | 创建 Custom World Agent 会话。 | -| GET | `/api/runtime/custom-world/agent/sessions/:sessionId` | JWT | json | `runtime-main` | `custom-world`、`ai` | 读取 Agent 会话快照。 | -| POST | `/api/runtime/custom-world/agent/sessions/:sessionId/actions` | JWT | json | `runtime-main` | `custom-world`、`ai`、`assets` | 执行 Agent 卡片生成、资产同步或发布动作。 | -| GET | `/api/runtime/custom-world/agent/sessions/:sessionId/cards/:cardId` | JWT | json | `runtime-main` | `custom-world`、`ai` | 读取 Agent 卡片详情。 | -| POST | `/api/runtime/custom-world/agent/sessions/:sessionId/messages` | JWT | json | `runtime-main` | `custom-world`、`ai` | 向 Agent 会话提交一条创作消息。 | -| POST | `/api/runtime/custom-world/agent/sessions/:sessionId/messages/stream` | JWT | stream | `runtime-main` | `custom-world`、`ai` | 流式提交 Agent 消息并实时接收回执。 | -| GET | `/api/runtime/custom-world/agent/sessions/:sessionId/operations/:operationId` | JWT | json | `runtime-main` | `custom-world`、`ai` | 查询 Agent 后台操作状态。 | -| POST | `/api/runtime/custom-world/entity` | JWT | json | `runtime-main` | `custom-world`、`ai` | 按世界 profile 生成单个角色或地标实体(兼容路径)。 | -| POST | `/api/runtime/custom-world/scene-npc` | JWT | json | `runtime-main` | `custom-world`、`ai`、`npc` | 按地标生成场景 NPC(兼容路径)。 | -| POST | `/api/runtime/custom-world/sessions` | JWT | json | `runtime-main` | `custom-world` | 创建传统自定义世界问答会话。 | -| GET | `/api/runtime/custom-world/sessions/:sessionId` | JWT | json | `runtime-main` | `custom-world` | 读取传统自定义世界问答会话。 | -| POST | `/api/runtime/custom-world/sessions/:sessionId/answers` | JWT | json | `runtime-main` | `custom-world` | 回答传统自定义世界问答题目。 | -| GET | `/api/runtime/custom-world/sessions/:sessionId/generate/stream` | JWT | stream | `runtime-main` | `custom-world`、`ai` | 流式编译传统自定义世界 profile。 | -| GET | `/api/runtime/custom-world/works` | JWT | json | `runtime-main` | `custom-world`、`runtime` | 列出当前账号的自定义世界作品汇总。 | -| POST | `/api/runtime/items/runtime-intent` | JWT | json | `runtime-main` | `runtime-item`、`ai` | 生成运行时物品意图。 | -| DELETE | `/api/runtime/profile/browse-history` | JWT | json | `runtime-main` | `runtime` | 清空平台浏览历史。(兼容路径) | -| GET | `/api/runtime/profile/browse-history` | JWT | json | `runtime-main` | `runtime` | 读取平台浏览历史。(兼容路径) | -| POST | `/api/runtime/profile/browse-history` | JWT | json | `runtime-main` | `runtime` | 写入或批量同步平台浏览历史。(兼容路径) | -| GET | `/api/runtime/profile/dashboard` | JWT | json | `runtime-main` | `runtime` | 读取运行时个人主页汇总。(兼容路径) | -| GET | `/api/runtime/profile/play-stats` | JWT | json | `runtime-main` | `runtime` | 读取个人游玩统计。(兼容路径) | -| GET | `/api/runtime/profile/save-archives` | JWT | json | `runtime-main` | `runtime` | 列出个人存档摘要。(兼容路径) | -| POST | `/api/runtime/profile/save-archives/:worldKey` | JWT | json | `runtime-main` | `runtime` | 恢复指定世界的最近存档(兼容路径)。 | -| GET | `/api/runtime/profile/wallet-ledger` | JWT | json | `runtime-main` | `runtime` | 列出个人资产流水。(兼容路径) | -| POST | `/api/runtime/quests/generate` | JWT | json | `runtime-main` | `quest`、`ai` | 按当前遭遇生成任务候选。 | -| DELETE | `/api/runtime/save/snapshot` | JWT | json | `runtime-main` | `runtime` | 删除当前用户的运行时存档。 | -| GET | `/api/runtime/save/snapshot` | JWT | json | `runtime-main` | `runtime`、`progression`、`quest` | 读取当前用户的运行时存档。 | -| PUT | `/api/runtime/save/snapshot` | JWT | json | `runtime-main` | `runtime`、`progression`、`quest` | 保存并归一化当前运行时存档。 | -| GET | `/api/runtime/settings` | JWT | json | `runtime-main` | `runtime` | 读取运行时设置。 | -| PUT | `/api/runtime/settings` | JWT | json | `runtime-main` | `runtime` | 更新运行时设置。 | -| POST | `/api/runtime/story/actions/resolve` | JWT | json | `runtime-story-action` | `story`、`quest`、`inventory`、`runtime-item`、`npc`、`progression`、`combat`、`runtime` | 解析前端 story choice 动作为新的运行时结果。 | -| POST | `/api/runtime/story/continue` | JWT | json | `runtime-main` | `story`、`ai` | 生成下一段故事内容。 | -| POST | `/api/runtime/story/initial` | JWT | json | `runtime-main` | `story`、`ai` | 生成首段故事内容。 | -| GET | `/api/runtime/story/state/:sessionId` | JWT | json | `runtime-story-action` | `story`、`runtime` | 读取指定 story session 的运行时状态。 | -| GET | `/api/ws/health` | JWT | json | `runtime-main` | `runtime` | 保留给未来实时链路的占位健康检查。 | -| GET | `/healthz` | 公开 | json | `health` | 无 | 返回 Node 后端进程健康状态。 | - -## 内部模块边界 - -### AI 编排模块 - -- 标识:`ai` -- 目录:`server-node/src/modules/ai` -- 对外可见面:`runtime-main` -- 关联路由数:23 -- 职责: - - 统一剧情、多轮聊天与自定义世界编排器的 prompt 构造与输出归一化。 - - 屏蔽前端对不同 AI 链路的直接拼装细节。 -- 主要服务边界: - - 专注提示词与编排,不负责持久化与 HTTP 传输。 - - 通过 `services/llmClient.ts` 与外部模型交互,由路由与 service 层决定何时调用。 -- 关键文件: - - `server-node/src/modules/ai/chatOrchestrator.ts` - - `server-node/src/modules/ai/customWorldOrchestrator.ts` - - `server-node/src/modules/ai/storyOrchestrator.ts` - -### 资产工具模块 - -- 标识:`assets` -- 目录:`server-node/src/modules/assets` -- 对外可见面:`assets` -- 关联路由数:18 -- 职责: - - 承接角色资产与 Qwen 精灵表的生成、查询、发布和保存。 - - 维护资产流程需要的缓存、草稿与产物 manifest。 -- 主要服务边界: - - 以文件系统和外部媒体模型为主要边界,不碰 runtimeRepository。 - - 对外暴露稳定 HTTP 路径,对内通过私有 helper 处理媒体编码、任务轮询与写盘。 -- 关键文件: - - `server-node/src/modules/assets/characterAssetRoutes.ts` - - `server-node/src/modules/assets/qwenSpriteRoutes.ts` - -### 战斗结算模块 - -- 标识:`combat` -- 目录:`server-node/src/modules/combat` -- 对外可见面:`runtime-story-action` -- 关联路由数:1 -- 职责: - - 提供运行时战斗结算与数值变更能力。 - - 为 story action 里的战斗型交互提供纯计算服务。 -- 主要服务边界: - - 聚焦状态推导与结果计算,不负责 transport 与持久化。 -- 关键文件: - - `server-node/src/modules/combat/combatResolutionService.ts` - -### 自定义世界运行时模块 - -- 标识:`custom-world` -- 目录:`server-node/src/modules/custom-world` -- 对外可见面:`runtime-main` -- 关联路由数:26 -- 职责: - - 规范 creator intent、世界运行时类型与 profile compile。 - - 把世界创作输入整理成运行时可消费的数据结构。 -- 主要服务边界: - - 偏纯领域建模与 compile,不直接做 HTTP、数据库查询或模型调用。 -- 关键文件: - - `server-node/src/modules/custom-world/creatorIntentRuntime.ts` - - `server-node/src/modules/custom-world/runtimeProfile.ts` - - `server-node/src/modules/custom-world/runtimeTypes.ts` - -### 编辑器资源模块 - -- 标识:`editor` -- 目录:`server-node/src/modules/editor` -- 对外可见面:`editor` -- 关联路由数:3 -- 职责: - - 提供编辑器资源目录枚举与 JSON 读写入口。 -- 主要服务边界: - - 只负责工作区文件输入输出,不参与运行时业务计算。 -- 关键文件: - - `server-node/src/modules/editor/editorRoutes.ts` - -### 背包与物品变更模块 - -- 标识:`inventory` -- 目录:`server-node/src/modules/inventory` -- 对外可见面:`runtime-story-action` -- 关联路由数:1 -- 职责: - - 维护背包变更、NPC 背包交互与 story action 里的物品副作用。 -- 主要服务边界: - - 对运行时状态做局部变更,不直接暴露 HTTP 路由。 -- 关键文件: - - `server-node/src/modules/inventory/inventoryMutationService.ts` - - `server-node/src/modules/inventory/inventoryStoryActionService.ts` - - `server-node/src/modules/inventory/npcInventoryStoryActionService.ts` - -### NPC 交互模块 - -- 标识:`npc` -- 目录:`server-node/src/modules/npc` -- 对外可见面:`runtime-story-action`、`runtime-main` -- 关联路由数:6 -- 职责: - - 维护 NPC 互动规则、任务 primitive 与关系变更逻辑。 -- 主要服务边界: - - 专注 NPC 侧状态推导,供 story action 与聊天/任务链路复用。 -- 关键文件: - - `server-node/src/modules/npc/npcInteractionService.ts` - - `server-node/src/modules/npc/npcTask6Primitives.ts` - -### 成长与关卡进程模块 - -- 标识:`progression` -- 目录:`server-node/src/modules/progression` -- 对外可见面:`runtime-story-action`、`runtime-main` -- 关联路由数:3 -- 职责: - - 提供角色成长、敌对等级、章节推进与 benchmark 逻辑。 -- 主要服务边界: - - 只做成长数值与章节进度计算,由 runtime hydrate 与 story action 复用。 -- 关键文件: - - `server-node/src/modules/progression/playerProgressionService.ts` - - `server-node/src/modules/progression/hostileProgressionService.ts` - - `server-node/src/modules/progression/chapterProgressionPlanner.ts` - -### 任务运行时模块 - -- 标识:`quest` -- 目录:`server-node/src/modules/quest` -- 对外可见面:`runtime-main`、`runtime-story-action` -- 关联路由数:4 -- 职责: - - 生成任务意图、维护任务日志与处理任务进度信号。 - - 为运行时 quest 接口与 story action 提供统一任务语义。 -- 主要服务边界: - - 领域逻辑以 quest module 为中心,AI 生成只是一种输入来源。 - - 不直接处理 HTTP 响应,统一由 routes/service 层调用。 -- 关键文件: - - `server-node/src/modules/quest/runtimeQuestModule.ts` - - `server-node/src/modules/quest/questProgressionService.ts` - - `server-node/src/modules/quest/questStoryActionService.ts` - -### 运行时状态基座模块 - -- 标识:`runtime` -- 目录:`server-node/src/modules/runtime` -- 对外可见面:`runtime-main`、`runtime-story-action` -- 关联路由数:32 -- 职责: - - 定义运行时状态 primitive、经济与装备规则。 - - 负责存档 hydration、兼容迁移与状态归一化。 -- 主要服务边界: - - 是 runtimeRepository 与 story action 的共同状态基座,不承担 HTTP 入口职责。 -- 关键文件: - - `server-node/src/modules/runtime/runtimeSnapshotHydration.ts` - - `server-node/src/modules/runtime/runtimeStatePrimitives.ts` - - `server-node/src/modules/runtime/runtimeEquipmentModule.ts` - -### 运行时物品模块 - -- 标识:`runtime-item` -- 目录:`server-node/src/modules/runtime-item` -- 对外可见面:`runtime-main`、`runtime-story-action` -- 关联路由数:2 -- 职责: - - 生成运行时物品意图、物品奖励与剧情指纹。 - - 维护宝藏与物品解析逻辑。 -- 主要服务边界: - - 聚焦物品领域编译与奖励拼装,由 route/service 选择具体触发时机。 -- 关键文件: - - `server-node/src/modules/runtime-item/runtimeItemModule.ts` - - `server-node/src/modules/runtime-item/runtimeItemResolutionService.ts` - - `server-node/src/modules/runtime-item/treasureStoryActionService.ts` - -### 故事会话模块 - -- 标识:`story` -- 目录:`server-node/src/modules/story` -- 对外可见面:`runtime-main`、`runtime-story-action` -- 关联路由数:10 -- 职责: - - 维护运行时故事会话状态与 action 分发。 - - 为 story resolve、story state 查询提供统一入口。 -- 主要服务边界: - - story 模块是 runtime 主循环的编排层,必要时再向 quest、inventory、combat 等领域模块分发。 -- 关键文件: - - `server-node/src/modules/story/runtimeSession.ts` - - `server-node/src/modules/story/storyActionRoutes.ts` - - `server-node/src/modules/story/storyActionService.ts` - -## 维护规则 - -- 新增 `server-node/src/modules/*` 目录时,必须先补充 manifest 里的模块说明,再重新生成产物。 -- 新增或下线路由时,先更新 manifest 里的路由清单,再运行生成命令同步 JSON 与文档。 -- 如果路由来自兼容路径或中间件派生路径,`sourceHint` 需要指向源代码里的真实表达式,确保生成脚本能做最小校验。 diff --git a/docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md b/docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md deleted file mode 100644 index 20e1ddee..00000000 --- a/docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md +++ /dev/null @@ -1,245 +0,0 @@ -# Node 后端知识图谱 - -日期:`2026-04-08` - -## 1. 当前定位 - -当前运行时后端以 `server-node/` 为唯一有效服务端实现。 - -当前职责: - -- 承接运行时鉴权 -- 承接运行时持久化 -- 承接运行时 AI 接口 -- 承接编辑器写盘与资产生成工具接口 -- 为 Vite 前端提供开发期代理目标 - -当前不再使用: - -- 已删除的旧 Vite 本地 API 插件链路 `scripts/dev-server/*.ts` - -## 2. 技术栈 - -- HTTP 框架:`Express` -- 语言与构建:`TypeScript` + `tsx` + `esbuild` -- 数据库:`PostgreSQL` -- JWT:`jose` -- 密码哈希:`@node-rs/argon2` -- 日志:`pino` + `pino-http` + `pino-roll` - -## 3. 运行入口 - -推荐命令: - -```bash -npm run dev -``` - -相关脚本: - -- 根目录联调:`npm run dev` / `npm run dev:node` -- 仅前端开发:`npm run dev:web` -- 单独启动后端开发模式:`npm run server-node:dev` -- 构建后端:`npm run server-node:build` -- 运行后端测试:`npm run server-node:test` - -默认监听: - -- 前端:`3000` -- Node 后端:`8081` - -## 4. 目录与主入口 - -服务端主入口: - -- `server-node/src/server.ts` -- `server-node/src/app.ts` - -路由入口: - -- `server-node/src/routes/authRoutes.ts` -- `server-node/src/routes/runtimeRoutes.ts` -- `server-node/src/modules/editor/editorRoutes.ts` -- `server-node/src/modules/assets/characterAssetRoutes.ts` -- `server-node/src/modules/assets/qwenSpriteRoutes.ts` - -基础设施: - -- `server-node/src/config.ts` -- `server-node/src/logging.ts` -- `server-node/src/db.ts` -- `server-node/src/context.ts` - -数据访问: - -- `server-node/src/repositories/userRepository.ts` -- `server-node/src/repositories/runtimeRepository.ts` - -鉴权相关: - -- `server-node/src/auth/authService.ts` -- `server-node/src/auth/token.ts` -- `server-node/src/auth/password.ts` -- `server-node/src/middleware/auth.ts` - -## 5. 鉴权模型 - -当前采用: - -- 前端本地保存 `JWT + 自动生成的用户名密码` -- 请求头使用 `Authorization: Bearer ` -- 后端 middleware 统一解析出 `UserID` -- handler 不直接解析 token - -当前账号策略: - -- 默认自动匿名账号启动 -- 本地无 JWT 时,前端会自动生成随机用户名密码并调用 `POST /api/auth/entry` -- 本地 JWT 失效但仍保留随机凭据时,前端自动重新调用 `auth/entry` 恢复同一账号 - -JWT 现状: - -- 当前为永久签发 -- claim 仍保留:`sub`、`iat`、`iss`、`ver` -- `logout` 通过递增 `token_version` 立即失效旧 token - -## 6. 数据存储 - -当前数据库: - -- 当前运行时持久化已切换到 `PostgreSQL` -- 连接信息由后端 `DATABASE_URL` 环境变量注入 -- 不再以本地 `SQLite` 文件作为正式运行时数据库 -- 后端测试默认使用 `pg-mem` 作为内存级 PostgreSQL 兼容实现 -- 基础表初始化通过 `schema_migrations` 基线管理 -- 可通过 `npm run server-node:db:migrate` 主动校验并补齐数据库基线 - -当前核心表: - -- `users` -- `save_snapshots` -- `runtime_settings` -- `custom_world_profiles` - -当前隔离原则: - -- 所有运行时数据按用户隔离 - -## 7. 已承接接口 - -鉴权: - -- `POST /api/auth/entry` -- `GET /api/auth/me` -- `POST /api/auth/logout` - -运行时持久化: - -- `GET /api/runtime/save/snapshot` -- `PUT /api/runtime/save/snapshot` -- `DELETE /api/runtime/save/snapshot` -- `GET /api/runtime/settings` -- `PUT /api/runtime/settings` -- `GET /api/runtime/custom-world-library` -- `PUT /api/runtime/custom-world-library/:profileId` -- `DELETE /api/runtime/custom-world-library/:profileId` - -运行时 AI: - -- `POST /api/llm/chat/completions` -- `POST /api/custom-world/scene-image` -- `POST /api/runtime/story/initial` -- `POST /api/runtime/story/continue` -- `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` -- `POST /api/runtime/chat/npc/dialogue/stream` -- `POST /api/runtime/chat/npc/recruit/stream` -- `POST /api/runtime/items/runtime-intent` -- `POST /api/runtime/quests/generate` - -补充说明(`2026-04-19`): - -- `POST /api/custom-world/scene-image` 现在支持前端仅提交 `profile + landmark + userPrompt` 上下文,由后端统一补齐场景图 prompt 与默认 negative prompt。 -- runtime story option 的 `interaction` 元数据现在由后端随 option 一并返回,前端不再本地按 `functionId` 重建 NPC / treasure 交互语义。 - -编辑器工具: - -- `GET /api/editor/catalog/items` -- `GET /api/editor/json/:resourceId` -- `POST /api/editor/json/:resourceId` - -资产工具: - -- `POST /api/assets/character-visual/generate` -- `POST /api/assets/character-visual/publish` -- `GET /api/assets/character-visual/jobs/:taskId` -- `POST /api/assets/character-animation/generate` -- `POST /api/assets/character-animation/publish` -- `GET /api/assets/character-animation/jobs/:taskId` -- `POST /api/assets/character-animation/import-video` -- `GET /api/assets/character-animation/templates` - -编辑器与资产接口门禁: - -- `EDITOR_API_ENABLED` 控制 `/api/editor/*` -- `ASSETS_API_ENABLED` 控制 `/api/assets/*` -- 非生产环境默认开启,生产环境默认关闭 - -## 8. Story 与 Custom World 现状 - -Story: - -- Node 后端直接复用前端成熟 prompt 与归一化逻辑 -- 服务端走 `src/services/ai.ts` 中的严格版 story 生成链 - -Custom World: - -- Node 后端当前已自持 `server-node/src/modules/custom-world/**` 运行时模块 -- 已承接 `creator intent` 归一化、`anchorPack / lockState` 推导、framework normalize、runtime profile compile -- `customWorldOrchestrator` 与 `customWorldAgentFoundationDraftService` 已不再运行时 import 前端 `src/services/customWorld*.ts` 与 `src/types.js` -- `server-node/src/prompts/customWorldPrompts.ts` 已承接 foundation draft 与 scene image 使用的 custom world prompt source -- 上述 prompt 迁移只改变源码归属位置,没有改动提示词正文 -- 当前保留 `session + answers + SSE progress/result/error` 协议 -- 前端已支持接收真实阶段进度对象 - -## 9. 前端接入点 - -鉴权与请求: - -- `src/services/apiClient.ts` -- `src/services/authService.ts` -- `src/components/auth/AuthGate.tsx` - -运行时服务层: - -- `src/services/storageService.ts` -- `src/services/aiService.ts` - -编辑器与资产工具层: - -- `src/editor/shared/editorApiClient.ts` -- `src/components/preset-editor/characterAssetStudioPersistence.ts` - -## 10. 当前 Vite 角色 - -Vite 当前只负责代理,不再提供本地 API 插件。 - -当前代理目标: - -- `/api/auth` -- `/api/runtime` -- `/api/editor` -- `/api/assets` -- `/api/llm` -- `/api/custom-world/scene-image` -- `/api/ws` - -全部转发到 Node 后端。 - -`scripts/dev-server/` 目录现仅保留 README 作为迁移说明,旧本地 API 实现代码已于 `2026-04-19` 删除。 diff --git a/docs/technical/NODE_SERVER_TEST_AND_DEPLOY_BASELINE_2026-04-08.md b/docs/technical/NODE_SERVER_TEST_AND_DEPLOY_BASELINE_2026-04-08.md deleted file mode 100644 index 8ec3082e..00000000 --- a/docs/technical/NODE_SERVER_TEST_AND_DEPLOY_BASELINE_2026-04-08.md +++ /dev/null @@ -1,226 +0,0 @@ -# Node 后端测试、观测与部署基线 - -日期:`2026-04-08` - -## 1. 文档目标 - -这份文档用于落实 `EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md` 中的任务 9: - -- 给 Node 后端补最小自动回归 -- 给请求链路补最小可追踪基线 -- 给部署、回滚、迁移补最小操作清单 - -当前目标不是一次性把监控平台、CI/CD、容器编排全部做完,而是先确保: - -- 后端改动后有脚本能快速验证主链路 -- 线上或联调失败时能快速定位到具体请求 -- 发布前后有统一检查口径 - -## 2. 当前基线命令 - -推荐在仓库根目录执行: - -```bash -npm run server-node:db:migrate -npm run server-node:test:baseline -npm run server-node:smoke -npm run server-node:smoke:proxy -``` - -说明: - -- `npm run server-node:db:migrate` - - 用当前 `DATABASE_URL` 主动校验 PostgreSQL 基线是否可连接、可初始化 - - 会确保 `schema_migrations` 和运行时基础表已补齐 -- `npm run server-node:test:baseline` - - 当前先固定为任务 9 自己维护的观测基线测试 - - 已覆盖 `requestId` 回传、访问日志字段、错误日志链路 -- `npm run server-node:smoke` - - 启动一套基于 `pg-mem` 的临时 Express 服务 - - 不依赖本地 PostgreSQL - - 走真实 HTTP 调用验证 `healthz -> auth -> runtime save/settings -> logout` -- `npm run server-node:smoke:proxy` - - 基于已构建的 `dist + server-node/dist` - - 自动拉起 `server-node + 同域反向代理 harness` - - 用 `pg-mem` 跑同域反向代理链路 smoke - - 验证 `web -> reverse proxy -> /api/* -> server-node` 主链路 - -如果要一口气跑完整发布前基线,可执行: - -```bash -npm run server-node:check:deploy -``` - -补充说明: - -- `npm run server-node:test` 仍然可以继续作为更大范围的后端接口套件入口 -- 但它会跟随其他并行任务一起变化,不应替代任务 9 自己的稳定基线 - -## 2.1 任务 9 对照清单 - -当前按并行任务规划中的任务 9 逐项对照: - -- 后端接口测试:`npm run server-node:test` -- 关键主链路 smoke:`npm run server-node:smoke` -- request/response 日志校验:`npm run server-node:test:baseline` -- 同域部署基线:本文第 6 节与 `npm run server-node:smoke:proxy` -- 反向代理 smoke 测试脚本:`scripts/smoke-same-origin-stack.ts` -- 回滚、备份、迁移检查清单:本文第 8 节与 `npm run server-node:db:migrate` -- 发布前一键检查:`npm run server-node:check:deploy` - -## 3. 当前 smoke 覆盖范围 - -当前 smoke 脚本验证以下链路: - -- `GET /healthz` -- `POST /api/auth/entry` -- `GET /api/auth/me` -- `PUT /api/runtime/save/snapshot` -- `GET /api/runtime/save/snapshot` -- `PUT /api/runtime/settings` -- `GET /api/runtime/settings` -- `DELETE /api/runtime/save/snapshot` -- `POST /api/auth/logout` - -当前代理 smoke 额外验证: - -- `GET /` -- `GET /healthz`(本地反向代理健康探针) -- `POST /api/auth/entry` 经反代可用 -- `GET /api/auth/me` 经反代可用 -- `PUT /api/runtime/save/snapshot` 经反代可用 -- `GET /api/runtime/save/snapshot` 经反代可用 -- `X-Request-Id` 能穿过反向代理返回给调用方 - -当前 smoke 的定位是: - -- 优先覆盖后端化后最容易断的基础链路 -- 优先覆盖前端最依赖的鉴权和持久化能力 -- 不把 AI 上游依赖拉进最小回归集,避免把第三方波动误判成主链路回归 - -## 4. 当前观测基线 - -当前请求链路至少要满足以下约束: - -- 支持读取外部传入的 `X-Request-Id` -- 如果调用方没有传 `X-Request-Id`,后端自动生成 -- 响应头回写 `x-request-id` -- 访问日志至少包含: - - `request_id` - - `user_id` - - `method` - - `path` - - `status` - - `latency_ms` -- 错误日志至少包含: - - `request_id` - - `user_id` - - `err` - -当前目的很明确: - -- 浏览器、反向代理、Node 后端至少有一个共同可追踪的请求标识 -- 接口失败后,能从日志里快速找到对应请求 - -## 5. 部署前检查清单 - -发布前至少执行一次: - -- `npm run check:encoding` -- `npm run server-node:db:migrate` -- `npm run server-node:test:baseline` -- `npm run server-node:smoke` -- `npm run server-node:build` -- `npm run build` -- `npm run server-node:smoke:proxy` - -环境变量至少确认: - -- `DATABASE_URL` -- `JWT_SECRET` -- `NODE_SERVER_ADDR` -- `LOG_LEVEL` -- `LLM_API_KEY` 或 `ARK_API_KEY` -- `DASHSCOPE_API_KEY` - -部署前数据库检查: - -- 确认目标 PostgreSQL 可连接 -- 确认发布账号具备建表或执行初始化所需权限 -- 确认已执行 `npm run server-node:db:migrate` 或等效迁移步骤 -- 确认现网数据已完成备份 - -## 6. 同域部署基线 - -当前推荐仍然是同域部署: - -- Web 静态资源:`https://game.example.com/` -- Node API:`https://game.example.com/api/*` - -最小拓扑: - -```text -Browser - -> Nginx / Caddy - -> dist - -> server-node -``` - -反向代理至少要保留这些头: - -- `Host` -- `X-Forwarded-For` -- `X-Forwarded-Proto` -- `X-Request-Id` - -流式接口还要确保: - -- `proxy_buffering off` -- `X-Accel-Buffering: no` - -## 7. 发布后 smoke 清单 - -发布完成后至少人工或脚本确认一次: - -1. `GET /healthz` 返回 `200` -2. 响应头里能看到 `x-request-id` -3. `POST /api/auth/entry` 可正常注册或恢复账号 -4. `GET /api/auth/me` 可正常识别 token -5. `PUT /api/runtime/save/snapshot` 和 `GET /api/runtime/save/snapshot` 正常 -6. 日志中能用同一个 `request_id` 串起访问记录 - -如果线上使用反向代理生成请求 ID,还要额外确认: - -- 代理传入的 `X-Request-Id` 没有在 Node 层丢失 -- 同域入口 `/` 与 `/api/*` 可以通过同一个站点域名访问 - -## 8. 回滚与备份清单 - -回滚前先确认: - -- 当前发布包版本号或 commit 可定位 -- 当前数据库备份可恢复 -- 当前 `.env` 或 secret 版本可回退 - -需要回滚时按顺序执行: - -1. 停止新版本 Node 进程 -2. 切回上一个稳定前端静态包和 Node 构建产物 -3. 恢复上一个稳定环境变量版本 -4. 如果本次发布包含数据库结构变更,先确认是否需要回滚数据 -5. 回滚后重新执行 `healthz + auth + runtime save` 最小 smoke - -如果本次发布已经写入了不兼容数据结构: - -- 不要只回滚代码不验证数据兼容性 -- 必须先确认旧版本代码是否还能读取当前数据 - -## 9. 后续扩展方向 - -任务 9 的下一轮可以继续补: - -- 把 smoke 纳入 CI -- 为关键 API 增加结构化 contract 测试 -- 给上游 AI 调用补 vendor/model/errorCode 维度日志 -- 增加数据库迁移前后的自动检查脚本 -- 增加反向代理与正式环境的联调 smoke diff --git a/docs/technical/PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md b/docs/technical/PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md deleted file mode 100644 index beac32a6..00000000 --- a/docs/technical/PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md +++ /dev/null @@ -1,185 +0,0 @@ -# Prompt 目录收口方案(2026-04-19) - -## 1. 这次调整解决什么问题 - -此前提示词分散在多个后端、前端和工具文件里: - -- `server-node/src/modules/ai/**` -- `server-node/src/modules/quest/**` -- `server-node/src/modules/runtime-item/**` -- `server-node/src/services/customWorld*.ts` -- `server-node/src/services/eightAnchorPromptBuilder.ts` -- `server-node/src/modules/assets/characterAssetRoutes.ts` -- `src/services/**` -- `src/components/**` - -问题主要有三类: - -1. 业务逻辑和 prompt 文本混写,改提示词时容易顺手改坏运行时逻辑。 -2. 同一类 prompt 缺少集中入口,排查系统 prompt / user prompt / repair prompt 成本高。 -3. 老桥接层、测试和新业务链路同时依赖时,迁移成本高,容易出现导出断裂。 - -这次收口目标不是“重写全部 AI 链路”,而是把当前正式业务 prompt 主源收到独立目录,业务模块退化成“准备上下文 + 调用 prompt 脚本”。 - -## 2. 新目录 - -本轮落地后的目录: - -```text -packages/shared/src/prompts/ -└─ qwenSprite.ts - -server-node/src/prompts/ -├─ characterAssetPrompts.ts -├─ chatPromptBuilders.ts -├─ customWorldAgentPrompts.ts -├─ customWorldEntityPrompts.ts -├─ customWorldOrchestratorPrompts.ts -├─ customWorldSceneNpcPrompts.ts -├─ eightAnchorPrompts.ts -├─ questPrompts.ts -├─ runtimeItemPrompts.ts -├─ storyOrchestratorPrompts.ts -└─ storyPromptBuilders.ts - -src/prompts/ -├─ characterChatPrompts.ts -├─ customWorldEntityActionPrompts.ts -├─ customWorldOrchestratorPrompts.ts -├─ customWorldPrompts.ts -├─ customWorldRolePromptDefaults.ts -├─ questPrompts.ts -├─ runtimeItemPrompts.ts -├─ storyOrchestratorPrompts.ts -└─ storyPromptBuilders.ts -``` - -当前职责划分: - -- `chatPromptBuilders.ts` - - 角色私聊 / NPC 聊天 / 招募对话 prompt -- `storyPromptBuilders.ts` - - 主剧情 system prompt 与 user prompt builder -- `storyOrchestratorPrompts.ts` - - 剧情语言修复 prompt -- `questPrompts.ts` - - 任务意图 system prompt 与 user prompt builder -- `runtimeItemPrompts.ts` - - 运行时物品意图 system prompt 与 user prompt 文本装配 -- `customWorldOrchestratorPrompts.ts` - - 自定义世界主编排 JSON 生成与 repair prompt -- `customWorldAgentPrompts.ts` - - 世界草稿 JSON prompt、补角色 / 补地点 prompt -- `customWorldEntityPrompts.ts` - - 世界编辑器角色 / 场景实体生成 prompt -- `customWorldSceneNpcPrompts.ts` - - 世界编辑器场景 NPC 生成 prompt -- `characterAssetPrompts.ts` - - 角色主图 / 动作试片正式生成 prompt -- `eightAnchorPrompts.ts` - - 八锚点状态推断、模式规则与正式单轮共创 prompt -- `src/prompts/customWorldPrompts.ts` - - 自定义世界分阶段生成 prompt 与场景背景图 prompt -- `src/prompts/customWorldRolePromptDefaults.ts` - - 角色资产工作台默认 prompt 种子唯一主源 -- `src/prompts/customWorldEntityActionPrompts.ts` - - 编辑器技能动作 prompt -- `packages/shared/src/prompts/qwenSprite.ts` - - 共享资产层的基础角色 prompt 模板 - -## 3. 落地规则 - -### 3.1 业务模块只做两件事 - -1. 整理运行时上下文 -2. 调用 `server-node/src/prompts/**` 下的脚本输出 prompt - -不要在业务模块里继续直接内联大段 system prompt / repair prompt / user prompt 模板文本。 - -### 3.2 Prompt 文件只放文本相关职责 - -允许放: - -- system prompt 常量 -- user prompt builder -- repair prompt builder -- prompt 专用的文本摘要函数 - -不建议放: - -- 运行时状态 mutation -- 仓储读写 -- HTTP 处理 -- 与 prompt 无关的领域推导 - -### 3.3 兼容层保留旧导出 - -本轮对已有纯 prompt builder 文件采取了兼容迁移,旧路径保留为薄 re-export: - -- `server-node/src/modules/ai/chatPromptBuilders.ts` -- `server-node/src/modules/ai/storyPromptBuilders.ts` -- `server-node/src/services/eightAnchorPromptBuilder.ts` -- `src/services/prompt.ts` -- `src/services/characterChatPrompt.ts` -- `src/services/questPrompt.ts` -- `src/services/runtimeItemAiPrompt.ts` -- `src/components/asset-studio/customWorldRolePromptDefaults.ts` -- `packages/shared/src/assets/qwenSprite.ts` - -对于 `runtimeQuestModule.ts`、`runtimeItemModule.ts` 这类被桥接层直接引用的模块,本轮保留原导出名,通过 re-export 指向新 prompt 文件,保证兼容性。 - -## 4. 后续新增 prompt 的写法 - -新增提示词时按下面顺序处理: - -1. 先判断属于后端、前端/编辑器还是共享工具层。 -2. 后端正式业务优先补到 `server-node/src/prompts/*.ts`。 -3. 前端/编辑器 prompt 优先补到 `src/prompts/*.ts`。 -4. 可复用的共享资产 prompt 优先补到 `packages/shared/src/prompts/*.ts`。 -5. 业务模块只传入已经整理好的上下文字段,不在模块内部继续拼长文本。 -6. 至少补一条该 prompt 的调用链测试或现有测试断言。 - -建议命名: - -- system prompt:`XXX_SYSTEM_PROMPT` -- repair prompt:`buildXXXRepairPrompt` -- user prompt:`buildXXXPrompt` -- 纯文本装配:`buildXXXPromptText` - -## 5. 本轮范围与当前状态 - -本轮已经收口: - -- Story -- Chat -- Quest -- Runtime Item -- Custom World 主编排 -- Custom World Agent 草稿增补 -- Custom World 编辑器角色 / 场景 / 场景 NPC 生成 -- Character Asset -- Eight Anchor -- Scene Image -- 前端剧情 / 私聊 / 任务 / 物品 prompt 兼容层 -- 编辑器与工具链 prompt 种子 - -当前状态: - -- 正式业务 prompt 主源已经集中到 prompt 目录。 -- 旧 `services/`、`tools/`、`components/` 下保留的相关文件主要是兼容层或调用方。 -- 当前没有再发现需要优先继续抽离的大块业务 prompt 正文。 - -## 6. 验证方式 - -本轮调整后建议至少执行: - -- `npm run check:encoding` -- `npm run server-node:test` -- `npm --prefix server-node run build` - -本轮实测结果: - -- `npm run check:encoding` 通过 -- `npm --prefix server-node run build` 通过 -- `npm run build` 通过 -- `npm run server-node:test` 143 项全部通过 diff --git a/docs/technical/README.md b/docs/technical/README.md index 71b524d2..0e15cb5e 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -4,6 +4,8 @@ ## 文档列表 +- [CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md](./CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md):冻结当前后端唯一落地口径,明确新功能以 `server-rs + Axum + SpacetimeDB` 为准,旧 `server-node` / Express / PostgreSQL 与 Go 方向只允许作为迁移参考。 +- [API_SERVER_DEV_STACK_COLD_BUILD_TIMEOUT_FIX_2026-04-25.md](./API_SERVER_DEV_STACK_COLD_BUILD_TIMEOUT_FIX_2026-04-25.md):记录 `npm run dev:rust` 在 Windows 冷编译/链接阶段误把 `api-server` `/healthz` 等待判定为超时并杀掉 `cargo run` 的根因,以及将 SpacetimeDB 与 api-server 等待窗口拆分的脚本口径。 - [API_ERROR_DETAILS_MESSAGE_DISPLAY_FIX_2026-04-25.md](./API_ERROR_DETAILS_MESSAGE_DISPLAY_FIX_2026-04-25.md):记录统一 API envelope 错误展示优先读取 `error.details.message` 的修复口径,避免 Big Fish 发布校验等业务错误只显示通用“请求参数不合法”。 - [BIG_FISH_DIRECTION_TOUCH_CONTROL_2026-04-24.md](./BIG_FISH_DIRECTION_TOUCH_CONTROL_2026-04-24.md):记录大鱼吃小鱼从固定摇杆改为屏幕首触点方向控制,并要求本地直达局在未操作时保持对象运动。 - [RUST_WORKSPACE_DEFAULT_BUILD_SCOPE_FIX_2026-04-25.md](./RUST_WORKSPACE_DEFAULT_BUILD_SCOPE_FIX_2026-04-25.md):记录 `server-rs` 无参数 `cargo build` 链接 `spacetime-module` 失败的根因,并冻结默认只构建原生 `api-server`、模块产物继续走 `spacetime build` 的命令边界。 @@ -70,7 +72,7 @@ - [SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md):`M2` 第三张会话表 `refresh_session` 的 cookie/hash 边界、轮换与吊销语义、索引与迁移规则。 - [SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md):`M2` 第二张身份表 `auth_identity` 的 provider 范围、唯一约束、手机号/微信身份写入规则与迁移策略。 - [SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md):`M2` 第一张身份主表 `user_account` 的职责边界、字段、唯一约束、状态迁移、旧 `users` 映射与落地约束。 -- [SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](./SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md):基于当前 Node 后端能力清单,设计用 `SpacetimeDB + Axum + 阿里云 OSS` 重写后端的目标架构、模块映射、数据分层、迁移顺序与验收标准。 +- [SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](./SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md):基于旧 Node 后端能力清单,设计用 `SpacetimeDB + Axum + 阿里云 OSS` 重写后端的目标架构、模块映射、数据分层、迁移顺序与验收标准。 - [M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md](./M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md):冻结 M6 剩余的 STS 与服务端上传 helper 落地口径,明确当前上传主链为服务器上传 OSS,Web 端只负责签名读下载。 - [M6_ASSET_METADATA_HASH_VERSION_AND_SPECIALIZED_TABLE_BOUNDARY_2026-04-22.md](./M6_ASSET_METADATA_HASH_VERSION_AND_SPECIALIZED_TABLE_BOUNDARY_2026-04-22.md):冻结 M6 第一批内容 hash、版本、manifest、asset job 与强业务资产表的 Stage 1 边界,明确当前使用 `asset_object + asset_entity_binding + OSS manifest + AiTaskService` 闭合,不重复新增表。 - [M6_LEGACY_GENERATED_PATH_OSS_READ_COMPAT_2026-04-22.md](./M6_LEGACY_GENERATED_PATH_OSS_READ_COMPAT_2026-04-22.md):冻结 M6 旧 `/generated-*` 路径到 OSS 私有读同源代理的兼容口径,保证旧前端仍能直接消费图片、视频、动作帧与 manifest。 @@ -93,13 +95,11 @@ - [M6_CHARACTER_ANIMATION_IMPORT_AND_TEMPLATE_STAGE1_2026-04-22.md](./M6_CHARACTER_ANIMATION_IMPORT_AND_TEMPLATE_STAGE1_2026-04-22.md):冻结 `M6` 第一批角色动作模板查询与参考视频导入从旧 Node 本地草稿写盘切到 Rust `OSS` 草稿对象的接口 contract、对象键规划与暂不确认 `asset_object` 的边界。 - [M6_CHARACTER_WORKFLOW_CACHE_OSS_STAGE1_2026-04-22.md](./M6_CHARACTER_WORKFLOW_CACHE_OSS_STAGE1_2026-04-22.md):冻结 `M6` 第一批角色资产工作流缓存从旧 Node 本地 `workflow-cache.json` 切到 Rust `OSS` JSON 草稿对象的读写 contract、字段归一化与暂不落正式资产表的边界。 - [M6_CHARACTER_VISUAL_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](./M6_CHARACTER_VISUAL_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md):冻结 `M6` 第一批角色主形象 `generate / jobs / publish` 接口从旧本地 `public/generated-*` 真相切到 `OSS + asset_object + asset_entity_binding + AI task` 的最小闭环与兼容 contract。 -- [M7_TEST_DEPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md](./M7_TEST_DEPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md):冻结 `M7` 联调、回归、部署、观测、双跑对比、灰度切流、回滚和 `spacetime-module` 结构收口的可执行方案。 +- [M7_TEST_DEPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md](./M7_TEST_DEPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md):冻结 `M7` 联调、回归、部署、观测、Rust 主线灰度、回滚和 `spacetime-module` 结构收口的可执行方案。 - [M3_BROWSE_HISTORY_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md](./M3_BROWSE_HISTORY_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md):冻结 `M3` 第二批 `browse history` 纵向切片的 `user_browse_history` 表、双路径 facade、宽松归一化、去重排序规则与测试策略。 - [ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md](./ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md):冻结已确认 `asset_object` 绑定到业务实体槽位的首版 reducer/procedure、通用 `asset_entity_binding` 表与 Axum facade。 -- [FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md](./FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md):把鉴权、浏览历史、runtime story 快照、NPC 待接委托与正式生成编排继续后移到 Express 后端的实施方案与验收口径。 - [REPO_NOISE_CLEANUP_BASELINE_2026-04-19.md](./REPO_NOISE_CLEANUP_BASELINE_2026-04-19.md):落实工程清理审计第一阶段后的仓库噪音清理范围、忽略规则闭合点与后续约束。 - [ENCODING_CHECK_TRANSIENT_WORKSPACE_FIX_2026-04-22.md](./ENCODING_CHECK_TRANSIENT_WORKSPACE_FIX_2026-04-22.md):冻结编码检查不扫描临时 Cargo / verify 工作区、同时把 Rust 源文件纳入 UTF-8 校验的修复口径。 -- [PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md](./PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md):后端提示词收口到 `server-node/src/prompts/` 的目录方案、兼容策略与后续新增规则。 - [CUSTOM_WORLD_DRAFT_GENERATION_FAILURE_ANALYSIS_AND_FIX_2026-04-20.md](./CUSTOM_WORLD_DRAFT_GENERATION_FAILURE_ANALYSIS_AND_FIX_2026-04-20.md):世界草稿生成失败后等待页误显示为“卡在编译草稿卡”的根因拆解、主链与增强链路边界,以及本次修复策略。 - [CUSTOM_WORLD_AUTO_ASSET_VISIBILITY_FIX_2026-04-20.md](./CUSTOM_WORLD_AUTO_ASSET_VISIBILITY_FIX_2026-04-20.md):世界草稿里“资产已生成但结果页看不到”的根因拆解,包含角色主形象展示、分幕背景露出和 fallback 资源格式修复。 - [CUSTOM_WORLD_PHASE4_COUNT_SEMANTICS_ALIGNMENT_2026-04-20.md](./CUSTOM_WORLD_PHASE4_COUNT_SEMANTICS_ALIGNMENT_2026-04-20.md):Phase4 新增角色/地点后草稿作品卡数量统计与测试断言的语义对齐说明。 @@ -149,13 +149,6 @@ - [CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md#93-工作包-c前端结果页与编辑器拆分](./CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md#93-%E5%B7%A5%E4%BD%9C%E5%8C%85-c%E5%89%8D%E7%AB%AF%E7%BB%93%E6%9E%9C%E9%A1%B5%E4%B8%8E%E7%BC%96%E8%BE%91%E5%99%A8%E6%8B%86%E5%88%86):记录工作包 C 已完成的结果页壳层拆分、编辑器目标分发与 mapper 收口、角色资产工坊 section/workflow 拆分,以及仍保留的阶段性 shared 实现边界。 - [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_BACKEND_MODULE_AND_API_INDEX.md](./NODE_BACKEND_MODULE_AND_API_INDEX.md):由 `server-node/src/manifest/backendCapabilityManifest.ts` 生成的 Node 后端模块职责、挂载面与接口索引,后续新增模块/接口时同步更新这一份。 -- [NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md](./NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md):当前 Node 运行时后端的技术栈、入口、鉴权、存储与接口知识图谱。 -- [EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md](./EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md):Express 后端当前 contract 冻结版本、热点文件编辑规则与集成窗口清单。 -- [EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md](./EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md):按并行工作流文档逐项核对后的完成度审计与剩余收口点。 -- [EDITOR_ASSET_API_MIGRATION_2026-04-08.md](./EDITOR_ASSET_API_MIGRATION_2026-04-08.md):编辑器写盘、资产生成、任务查询从 Vite 本地插件迁到 Node 后端的接口与工具链清单。 -- [GO_SERVER_RUNTIME_INTEGRATION_2026-04-07.md](./GO_SERVER_RUNTIME_INTEGRATION_2026-04-07.md):Go 服务端接入、运行时持久化迁移与当前进展记录。 -- [GO_SERVER_TASKLIST_2026-04-08.md](./GO_SERVER_TASKLIST_2026-04-08.md):Go 服务端已完成与未完成事项的执行清单。 - [AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md](./AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md):AI 生成角色形象与角色动画的技术路线。 - [ALIYUN_NPC_IMAGE_ANIMATION_EXPERIMENT_2026-04-07.md](./ALIYUN_NPC_IMAGE_ANIMATION_EXPERIMENT_2026-04-07.md):面向编辑器的阿里云 NPC 形象与动作实验方案,按 4 条生成链路对比。 - [PIXELMOTION_TECHNICAL_BREAKDOWN_2026-04-04.md](./PIXELMOTION_TECHNICAL_BREAKDOWN_2026-04-04.md):PixelMotion 产品形态与能力拆解。 @@ -164,6 +157,5 @@ ## 使用建议 - 做实现选型时,优先看这一组。 +- 做后端实现前,先看 `CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md`,再进入具体 Rust / SpacetimeDB 方案。 - 做阶段排期时,把这一组和 `docs/planning/`、`docs/prd/` 一起看,更容易判断先后顺序。 - - diff --git a/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md index ba2081ff..34bb2ead 100644 --- a/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md +++ b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md @@ -44,8 +44,7 @@ 2. `docs/design/SCENE_CHAPTER_LOOP_AND_FIRST_ENTRY_CHAPTER_QUEST_DESIGN_2026-04-08.md` 3. `docs/experience/CURRENT_GAME_FULL_FLOW_PLAYTEST_REPORT_2026-04-07.md` 4. `docs/technical/RUNTIME_STORY_BACKEND_BOUNDARY_MIGRATION_2026-04-19.md` -5. `docs/technical/FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md` -6. `docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md` +5. `docs/technical/CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md` ### 1.3 本文刻意不覆盖的链路 diff --git a/docs/technical/RUNTIME_NPC_CHAT_LLM_MIGRATION_2026-04-25.md b/docs/technical/RUNTIME_NPC_CHAT_LLM_MIGRATION_2026-04-25.md new file mode 100644 index 00000000..db2fc956 --- /dev/null +++ b/docs/technical/RUNTIME_NPC_CHAT_LLM_MIGRATION_2026-04-25.md @@ -0,0 +1,56 @@ +# Runtime NPC 聊天 LLM 迁移设计(2026-04-25) + +## 背景 + +当前 `server-rs/crates/api-server/src/runtime_chat.rs` 已承接 `POST /api/runtime/chat/npc/turn/stream`,但只返回确定性兜底文本。实际游戏聊天里,NPC 回复、下一轮建议、好感变化和限轮收束都应该沿用旧 Node 服务器的 LLM 编排。 + +## 迁移源 + +本轮只参考旧 Node 已冻结实现,不恢复 `server-node` 服务,也不把前端切回 Express: + +1. `server-node/src/prompts/chatPromptBuilders.ts` +2. `server-node/src/modules/ai/chatOrchestrator.ts` +3. `server-node/src/services/chatService.ts` +4. `packages/shared/src/contracts/rpgRuntimeChat.ts` + +提示词常量必须原样迁移,禁止改写: + +1. `NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT` +2. `NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT` + +## 本轮落地边界 + +1. Rust `api-server` 在同一路由内优先调用 `platform-llm`。 +2. LLM 回复使用旧 Node 的 `buildNpcChatTurnReplyPrompt(...)` 等价构造逻辑,保持输入字段和中文上下文组织一致。 +3. LLM 建议使用旧 Node 的 `buildNpcChatTurnSuggestionPrompt(...)` 等价构造逻辑,解析规则保持“最多 3 行、去掉编号/项目符号”。 +4. 好感变化沿用旧 Node 的关键词打分规则: + - 正向与负向关键词计分。 + - 首聊无明显关键词时给 `+1`。 + - 单轮变化限制在 `[-3, 3]`。 +5. `chatDirective.forceExitAfterTurn / closingMode=foreshadow_close` 时不生成建议,返回空数组,并在 `complete.chatDirective.forceExit` 中显式告知前端退出。 +6. LLM 未配置或失败时继续返回后端兜底 SSE,保证相遇和点击聊天链路不断。 + +## 暂不落地 + +1. 暂不迁移 `maybeBuildPendingNpcQuestOffer(...)` 的完整 quest 生成链。 +2. 暂不新增 SpacetimeDB reducer;本路由属于 Axum 侧 LLM 编排,SpacetimeDB reducer 仍保持确定性。 +3. 暂不扩展前端 UI 文案。 + +## 工程落点 + +1. 新增 `server-rs/crates/api-server/src/runtime_chat_prompt.rs` + - 承载旧 Node 提示词常量。 + - 承载 NPC 聊天 prompt builder 与轻量 JSON 读取 helper。 +2. 修改 `server-rs/crates/api-server/src/runtime_chat.rs` + - 注入 `State`。 + - 优先 `LlmClient.stream_text(...)` 生成 `reply_delta`。 + - 再调用 `request_text(...)` 生成建议。 + - 计算 `affinityDelta / affinityText / chatDirective` 后输出 `complete`。 +3. 修改 `server-rs/crates/api-server/src/main.rs` + - 注册 `runtime_chat_prompt` 模块。 + +## 验收 + +1. `cargo fmt -p api-server` +2. `cargo check -p api-server` +3. `node scripts/check-encoding.mjs docs/technical/RUNTIME_NPC_CHAT_LLM_MIGRATION_2026-04-25.md server-rs/crates/api-server/src/runtime_chat.rs server-rs/crates/api-server/src/runtime_chat_prompt.rs server-rs/crates/api-server/src/main.rs` diff --git a/docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md b/docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md index 753fc251..de8fd3e1 100644 --- a/docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md +++ b/docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md @@ -163,6 +163,6 @@ ## 4. 维护规则 1. 新增、删除或改名 Rust 路由时,必须同步更新本索引。 -2. 如果 Node 后端 `NODE_BACKEND_MODULE_AND_API_INDEX.md` 的现役能力面发生变化,必须同时更新本索引与对应阶段任务清单。 +2. 如果 Rust 后端公开能力面发生变化,必须同时更新本索引、当前后端实现基线与对应阶段任务清单。 3. 任何 breaking route change 都必须先更新阶段设计文档,再改代码。 4. 真实切流前,必须用本索引对照代理层、前端调用面和 smoke 清单,避免只完成编译而遗漏外部可访问路径。 diff --git a/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md b/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md index 30d307d1..65a3f705 100644 --- a/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md +++ b/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md @@ -38,7 +38,7 @@ npm run dev:rust 5. 执行 `spacetime --root-dir=server-rs/.spacetimedb/local publish <本地数据库名> --server http://127.0.0.1:3101 --module-path server-rs/crates/spacetime-module -c=on-conflict --yes`,确保 publish 的签名身份与 standalone 的本地控制库一致,并在当前开发阶段允许新版模块表结构变化且发生 schema 冲突时清除旧模块数据。 6. 注入 `GENARRATIVE_API_*` 与 `GENARRATIVE_SPACETIME_*` 后启动 `cargo run -p api-server`;直接运行 `api-server` 时,如未显式设置 `GENARRATIVE_SPACETIME_DATABASE`,服务端也会向上查找 `spacetime.local.json` 作为本地默认库名。 7. 等待 `http://127.0.0.1:/healthz` 返回 HTTP 响应后再启动 Vite,避免前端初始化请求早于 Rust `api-server` 监听完成并在终端刷出 `ECONNREFUSED 127.0.0.1:`。 -8. 注入 `GENARRATIVE_BACKEND_STACK=rust`、`RUST_SERVER_TARGET`、`GENARRATIVE_RUNTIME_SERVER_TARGET` 后启动 Vite。 +8. 注入 `RUST_SERVER_TARGET`、`GENARRATIVE_RUNTIME_SERVER_TARGET` 后启动 Vite。 9. 任一子进程退出时,脚本回收其余子进程。 Vite 代理覆盖范围: diff --git a/docs/technical/RUST_LOCAL_DEV_SPACETIMEDB_PUBLISH_GUARD_AND_AGENT_LLM_FAILURE_POLICY_2026-04-23.md b/docs/technical/RUST_LOCAL_DEV_SPACETIMEDB_PUBLISH_GUARD_AND_AGENT_LLM_FAILURE_POLICY_2026-04-23.md index f15282ac..a7344d1b 100644 --- a/docs/technical/RUST_LOCAL_DEV_SPACETIMEDB_PUBLISH_GUARD_AND_AGENT_LLM_FAILURE_POLICY_2026-04-23.md +++ b/docs/technical/RUST_LOCAL_DEV_SPACETIMEDB_PUBLISH_GUARD_AND_AGENT_LLM_FAILURE_POLICY_2026-04-23.md @@ -30,7 +30,7 @@ ### 3.1 启动前强制 publish -`scripts/dev-node.mjs` 在拉起 Rust `api-server` 前,必须先执行: +`scripts/dev-rust-stack.sh` 在拉起 Rust `api-server` 前,必须先执行: ```bash spacetime publish xushi-p4wfr --server http://127.0.0.1:3101 --module-path D:\Genarrative\server-rs\crates\spacetime-module --yes diff --git a/docs/technical/RUST_SHARED_CONTRACTS_CRATE_STAGE1_DESIGN_2026-04-21.md b/docs/technical/RUST_SHARED_CONTRACTS_CRATE_STAGE1_DESIGN_2026-04-21.md index 90a7fa4b..f1ca2545 100644 --- a/docs/technical/RUST_SHARED_CONTRACTS_CRATE_STAGE1_DESIGN_2026-04-21.md +++ b/docs/technical/RUST_SHARED_CONTRACTS_CRATE_STAGE1_DESIGN_2026-04-21.md @@ -9,7 +9,7 @@ 关联现状: -- [EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md](./EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md) +- [CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md](./CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md) - [AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md](./AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md) - [AUTH_ME_QUERY_DESIGN_2026-04-21.md](./AUTH_ME_QUERY_DESIGN_2026-04-21.md) - [AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md](./AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md) diff --git a/docs/technical/SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md b/docs/technical/SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md index ef0f502c..88b1921b 100644 --- a/docs/technical/SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md +++ b/docs/technical/SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md @@ -59,7 +59,7 @@ - 浏览器直连会遇到 CORS - 更稳的方案是开发服务器代理,再由前端请求 `/api/llm/...` -[docs/audits/engineering/ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md](/E:/Repos/Genarrative/docs/audits/engineering/ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md) 也明确指出: +工程审计聚合结论也明确指出: - 编辑器、运行时、类后端能力全部耦合在 Vite 配置里 - 未来如果做独立部署、多人协作、远程编辑、权限控制,会非常难迁移 diff --git a/docs/technical/SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md b/docs/technical/SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md index bef49b4c..7d056c70 100644 --- a/docs/technical/SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md +++ b/docs/technical/SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md @@ -171,5 +171,5 @@ 1. [SPACETIMEDB_ASSET_OBJECT_STORAGE_DESIGN_2026-04-21.md](./SPACETIMEDB_ASSET_OBJECT_STORAGE_DESIGN_2026-04-21.md) 2. [SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](./SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md) -3. [../../../server-rs/crates/module-assets/README.md](../../../server-rs/crates/module-assets/README.md) -4. [../../../server-rs/crates/spacetime-module/README.md](../../../server-rs/crates/spacetime-module/README.md) +3. [../../server-rs/crates/module-assets/README.md](../../server-rs/crates/module-assets/README.md) +4. [../../server-rs/crates/spacetime-module/README.md](../../server-rs/crates/spacetime-module/README.md) diff --git a/docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md b/docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md index 1d4f55d2..e1140e3a 100644 --- a/docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md +++ b/docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md @@ -17,7 +17,7 @@ ## 2. 当前工程必须继承的能力基线 -以 `docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md` 当前生成结果为准,现有 Node 后端的能力基线是: +以下能力清单来自 `2026-04-20` 迁移设计时对旧 Node 后端的快照整理,只作为迁移参考,不再作为新功能扩展依据。后续实现方向以 `docs/technical/CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md` 为准。 - 对外挂载面:`6` 个 - 已登记路由:`96` 条 @@ -228,10 +228,10 @@ SpacetimeDB 官方文档对自动迁移的限制很强: 从当前版本开始,凡是涉及 `SpacetimeDB` 的设计、实现、脚本、调试与前端接入,统一要求显式使用以下 skill 作为执行依据: -1. [$spacetimedb-cli](.codex\\skills\\spacetimedb-cli\\SKILL.md) -2. [$spacetimedb-rust](.codex\\skills\\spacetimedb-rust\\SKILL.md) -3. [$spacetimedb-concepts](.codex\\skills\\spacetimedb-concepts\\SKILL.md) -4. [$spacetimedb-typescript](.codex\\skills\\spacetimedb-typescript\\SKILL.md) +1. [$spacetimedb-cli](../../.codex/skills/spacetimedb-cli/SKILL.md) +2. [$spacetimedb-rust](../../.codex/skills/spacetimedb-rust/SKILL.md) +3. [$spacetimedb-concepts](../../.codex/skills/spacetimedb-concepts/SKILL.md) +4. [$spacetimedb-typescript](../../.codex/skills/spacetimedb-typescript/SKILL.md) 执行要求: @@ -895,8 +895,8 @@ SpacetimeDB 自动迁移更适合“增量追加”,不适合高频改列结 5. `server-node/src/modules/story/storyActionRoutes.ts` 6. `server-node/src/repositories/runtimeRepository.ts` 7. `server-node/src/services/customWorldAgentOrchestrator.ts` -8. `docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md` -9. `docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md` +8. 本文第 2 节保留的旧 Node 能力快照 +9. `docs/technical/CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md` ## 17. 外部技术依据 diff --git a/docs/technical/TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md b/docs/technical/TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md index 688bc42c..2f137b9d 100644 --- a/docs/technical/TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md +++ b/docs/technical/TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md @@ -872,9 +872,9 @@ TXT 模式不得直接改坏当前 `runtime story` 主链。 ## 10.3 与 prompt 管理规范兼容 -必须遵守 [PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md](./PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md) 中已冻结的规则: +必须遵守当前 Rust 后端提示词迁移口径: -1. prompt 主源收口到 `server-node/src/prompts/` +1. prompt 主源收口到 `server-rs` 对应生成与编排模块 2. 业务模块只装配上下文 3. 兼容层按需保留,但不新增业务内联 prompt diff --git a/package-lock.json b/package-lock.json index 8ed494ac..166936f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,6 @@ "@tailwindcss/vite": "^4.1.14", "@vitejs/plugin-react": "^5.0.4", "dotenv": "^17.2.3", - "express": "^4.21.2", "lucide-react": "^0.546.0", "motion": "^12.23.24", "react": "^19.0.0", @@ -21,7 +20,6 @@ "devDependencies": { "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", - "@types/express": "^4.17.21", "@types/node": "^22.14.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -1378,6 +1376,64 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.8.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.8.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", @@ -1571,16 +1627,6 @@ "@babel/types": "^7.28.2" } }, - "node_modules/@types/body-parser": { - "version": "1.19.6", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", - "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "dev": true, - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, "node_modules/@types/chai": { "version": "4.3.20", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz", @@ -1596,62 +1642,17 @@ "@types/chai": "<5.2.0" } }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" }, - "node_modules/@types/express": { - "version": "4.17.25", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", - "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", - "dev": true, - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "^1" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "4.19.8", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", - "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", - "dev": true, - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "node_modules/@types/http-errors": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", - "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true - }, "node_modules/@types/node": { "version": "22.19.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", @@ -1661,18 +1662,6 @@ "undici-types": "~6.21.0" } }, - "node_modules/@types/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", - "dev": true - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true - }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -1697,36 +1686,6 @@ "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", "dev": true }, - "node_modules/@types/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", - "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", - "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", - "dev": true, - "dependencies": { - "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "<1" - } - }, - "node_modules/@types/serve-static/node_modules/@types/send": { - "version": "0.17.6", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", - "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", - "dev": true, - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", @@ -2104,18 +2063,6 @@ "deprecated": "Use your platform's native atob() and btoa() methods instead", "dev": true }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -2218,11 +2165,6 @@ "dequal": "^2.0.3" } }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" - }, "node_modules/array-union": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", @@ -2300,42 +2242,6 @@ "node": ">=6.0.0" } }, - "node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", - "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.14.0", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/body-parser/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, "node_modules/brace-expansion": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", @@ -2390,14 +2296,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -2411,6 +2309,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" @@ -2419,21 +2318,6 @@ "node": ">= 0.4" } }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2550,43 +2434,11 @@ "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", "dev": true }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2682,14 +2534,6 @@ "node": ">=0.4.0" } }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -2701,15 +2545,6 @@ "node": ">=6" } }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2786,6 +2621,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", @@ -2795,24 +2631,11 @@ "node": ">= 0.4" } }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" - }, "node_modules/electron-to-chromium": { "version": "1.5.321", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==" }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/enhanced-resolve": { "version": "5.20.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", @@ -2841,6 +2664,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, "engines": { "node": ">= 0.4" } @@ -2849,6 +2673,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, "engines": { "node": ">= 0.4" } @@ -2857,6 +2682,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, "dependencies": { "es-errors": "^1.3.0" }, @@ -2928,11 +2754,6 @@ "node": ">=6" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" - }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -3160,72 +2981,6 @@ "node": ">=0.10.0" } }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.3", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.14.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/express/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3321,36 +3076,6 @@ "node": ">=8" } }, - "node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/finalhandler/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -3403,14 +3128,6 @@ "node": ">= 6" } }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -3450,14 +3167,6 @@ } } }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -3481,6 +3190,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -3506,6 +3216,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", @@ -3529,6 +3240,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" @@ -3621,6 +3333,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, "engines": { "node": ">= 0.4" }, @@ -3652,6 +3365,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, "engines": { "node": ">= 0.4" }, @@ -3678,6 +3392,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, "dependencies": { "function-bind": "^1.1.2" }, @@ -3697,25 +3412,6 @@ "node": ">=12" } }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/http-proxy-agent": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", @@ -3743,17 +3439,6 @@ "node": ">= 6" } }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3802,15 +3487,8 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "engines": { - "node": ">= 0.10" - } + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true }, "node_modules/is-extglob": { "version": "2.1.1", @@ -4309,26 +3987,11 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, "engines": { "node": ">= 0.4" } }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -4338,14 +4001,6 @@ "node": ">= 8" } }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -4371,21 +4026,11 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, "engines": { "node": ">= 0.6" } @@ -4394,6 +4039,7 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, "dependencies": { "mime-db": "1.52.0" }, @@ -4497,14 +4143,6 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/node-releases": { "version": "2.0.36", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", @@ -4516,28 +4154,6 @@ "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", "dev": true }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -4618,14 +4234,6 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -4653,11 +4261,6 @@ "node": ">=8" } }, - "node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==" - }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -4798,18 +4401,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", @@ -4831,20 +4422,6 @@ "node": ">=6" } }, - "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -4871,28 +4448,6 @@ } ] }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -5048,29 +4603,11 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true }, "node_modules/saxes": { "version": "6.0.0", @@ -5097,61 +4634,6 @@ "semver": "bin/semver.js" } }, - "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/send/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, - "node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -5173,74 +4655,6 @@ "node": ">=8" } }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -5270,14 +4684,6 @@ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -5412,14 +4818,6 @@ "node": ">=8.0" } }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "engines": { - "node": ">=0.6" - } - }, "node_modules/tough-cookie": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", @@ -5516,18 +4914,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", @@ -5562,14 +4948,6 @@ "node": ">= 4.0.0" } }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -5618,22 +4996,6 @@ "requires-port": "^1.0.0" } }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/vite": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", @@ -8052,6 +7414,56 @@ "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" + }, + "dependencies": { + "@emnapi/core": { + "version": "1.8.1", + "bundled": true, + "optional": true, + "requires": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "@emnapi/runtime": { + "version": "1.8.1", + "bundled": true, + "optional": true, + "requires": { + "tslib": "^2.4.0" + } + }, + "@emnapi/wasi-threads": { + "version": "1.1.0", + "bundled": true, + "optional": true, + "requires": { + "tslib": "^2.4.0" + } + }, + "@napi-rs/wasm-runtime": { + "version": "1.1.1", + "bundled": true, + "optional": true, + "requires": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + } + }, + "@tybys/wasm-util": { + "version": "0.10.1", + "bundled": true, + "optional": true, + "requires": { + "tslib": "^2.4.0" + } + }, + "tslib": { + "version": "2.8.1", + "bundled": true, + "optional": true + } } }, "@tailwindcss/oxide-win32-arm64-msvc": { @@ -8187,16 +7599,6 @@ "@babel/types": "^7.28.2" } }, - "@types/body-parser": { - "version": "1.19.6", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", - "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "dev": true, - "requires": { - "@types/connect": "*", - "@types/node": "*" - } - }, "@types/chai": { "version": "4.3.20", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz", @@ -8210,62 +7612,17 @@ "dev": true, "requires": {} }, - "@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, - "requires": { - "@types/node": "*" - } - }, "@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" }, - "@types/express": { - "version": "4.17.25", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", - "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", - "dev": true, - "requires": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "^1" - } - }, - "@types/express-serve-static-core": { - "version": "4.19.8", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", - "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", - "dev": true, - "requires": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "@types/http-errors": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", - "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true - }, "@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, - "@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true - }, "@types/node": { "version": "22.19.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", @@ -8275,18 +7632,6 @@ "undici-types": "~6.21.0" } }, - "@types/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", - "dev": true - }, - "@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true - }, "@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -8309,38 +7654,6 @@ "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", "dev": true }, - "@types/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", - "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", - "dev": true, - "requires": { - "@types/node": "*" - } - }, - "@types/serve-static": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", - "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", - "dev": true, - "requires": { - "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "<1" - }, - "dependencies": { - "@types/send": { - "version": "0.17.6", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", - "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", - "dev": true, - "requires": { - "@types/mime": "^1", - "@types/node": "*" - } - } - } - }, "@typescript-eslint/eslint-plugin": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", @@ -8579,15 +7892,6 @@ "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", "dev": true }, - "accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "requires": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - } - }, "acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -8662,11 +7966,6 @@ "dequal": "^2.0.3" } }, - "array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" - }, "array-union": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", @@ -8709,40 +8008,6 @@ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.9.tgz", "integrity": "sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg==" }, - "body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", - "requires": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.14.0", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" - }, - "dependencies": { - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "requires": { - "ms": "2.0.0" - } - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - } - } - }, "brace-expansion": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", @@ -8774,11 +8039,6 @@ "update-browserslist-db": "^1.2.0" } }, - "bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" - }, "cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -8789,20 +8049,12 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, "requires": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, - "call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "requires": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - } - }, "callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -8884,34 +8136,11 @@ "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", "dev": true }, - "content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "requires": { - "safe-buffer": "5.2.1" - } - }, - "content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==" - }, "convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" }, - "cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==" - }, - "cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" - }, "cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -8984,11 +8213,6 @@ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "dev": true }, - "depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" - }, "dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -8996,11 +8220,6 @@ "dev": true, "peer": true }, - "destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==" - }, "detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -9055,27 +8274,18 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, "requires": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, - "ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" - }, "electron-to-chromium": { "version": "1.5.321", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==" }, - "encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==" - }, "enhanced-resolve": { "version": "5.20.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", @@ -9094,17 +8304,20 @@ "es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true }, "es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true }, "es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, "requires": { "es-errors": "^1.3.0" } @@ -9160,11 +8373,6 @@ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==" }, - "escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" - }, "escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -9317,64 +8525,6 @@ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true }, - "etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" - }, - "express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", - "requires": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.3", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.14.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "dependencies": { - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "requires": { - "ms": "2.0.0" - } - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - } - } - }, "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -9450,35 +8600,6 @@ "to-regex-range": "^5.0.1" } }, - "finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", - "requires": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" - }, - "dependencies": { - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "requires": { - "ms": "2.0.0" - } - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - } - } - }, "find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -9519,11 +8640,6 @@ "mime-types": "^2.1.12" } }, - "forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==" - }, "fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -9540,11 +8656,6 @@ "tslib": "^2.4.0" } }, - "fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==" - }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -9560,7 +8671,8 @@ "function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true }, "gensync": { "version": "1.0.0-beta.2", @@ -9577,6 +8689,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, "requires": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", @@ -9594,6 +8707,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, "requires": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" @@ -9657,7 +8771,8 @@ "gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true }, "graceful-fs": { "version": "4.2.11", @@ -9679,7 +8794,8 @@ "has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true }, "has-tostringtag": { "version": "1.0.2", @@ -9694,6 +8810,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, "requires": { "function-bind": "^1.1.2" } @@ -9707,18 +8824,6 @@ "whatwg-encoding": "^2.0.0" } }, - "http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "requires": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - } - }, "http-proxy-agent": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", @@ -9740,14 +8845,6 @@ "debug": "4" } }, - "iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "requires": { - "safer-buffer": ">= 2.1.2 < 3" - } - }, "ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -9783,12 +8880,8 @@ "inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true }, "is-extglob": { "version": "2.1.1", @@ -10073,17 +9166,8 @@ "math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" - }, - "media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==" - }, - "merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==" + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true }, "merge2": { "version": "1.4.1", @@ -10091,11 +9175,6 @@ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", "dev": true }, - "methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==" - }, "micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -10114,20 +9193,17 @@ } } }, - "mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" - }, "mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true }, "mime-types": { "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, "requires": { "mime-db": "1.52.0" } @@ -10199,11 +9275,6 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, - "negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==" - }, "node-releases": { "version": "2.0.36", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", @@ -10215,19 +9286,6 @@ "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", "dev": true }, - "object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==" - }, - "on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "requires": { - "ee-first": "1.1.1" - } - }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -10287,11 +9345,6 @@ "entities": "^6.0.0" } }, - "parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" - }, "path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -10310,11 +9363,6 @@ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true }, - "path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==" - }, "path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -10409,15 +9457,6 @@ } } }, - "proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "requires": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - } - }, "psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", @@ -10433,14 +9472,6 @@ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true }, - "qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", - "requires": { - "side-channel": "^1.1.0" - } - }, "querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -10453,22 +9484,6 @@ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "dev": true }, - "range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" - }, - "raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "requires": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" - } - }, "react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -10575,15 +9590,11 @@ "queue-microtask": "^1.2.2" } }, - "safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" - }, "safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true }, "saxes": { "version": "6.0.0", @@ -10604,59 +9615,6 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==" }, - "send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", - "requires": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" - }, - "dependencies": { - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "requires": { - "ms": "2.0.0" - }, - "dependencies": { - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - } - } - } - } - }, - "serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", - "requires": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" - } - }, - "setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" - }, "shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -10672,50 +9630,6 @@ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true }, - "side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "requires": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - } - }, - "side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "requires": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - } - }, - "side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "requires": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - } - }, - "side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "requires": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - } - }, "siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -10739,11 +9653,6 @@ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true }, - "statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==" - }, "std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -10841,11 +9750,6 @@ "is-number": "^7.0.0" } }, - "toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" - }, "tough-cookie": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", @@ -10911,15 +9815,6 @@ "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true }, - "type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "requires": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - } - }, "typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", @@ -10944,11 +9839,6 @@ "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", "dev": true }, - "unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" - }, "update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -10977,16 +9867,6 @@ "requires-port": "^1.0.0" } }, - "utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==" - }, - "vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" - }, "vite": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", diff --git a/package.json b/package.json index a4d6c994..531e2a9f 100644 --- a/package.json +++ b/package.json @@ -4,27 +4,16 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "node scripts/dev-node.mjs", + "dev": "node scripts/run-bash-script.mjs scripts/dev-rust-stack.sh", "dev:rust": "node scripts/run-bash-script.mjs scripts/dev-rust-stack.sh", "dev:rust:logs": "node scripts/run-bash-script.mjs scripts/spacetime-logs-local.sh", "dev:web": "node scripts/dev-web-rust.mjs", - "dev:node": "node scripts/dev-node.mjs", "spacetime:publish:maincloud": "node scripts/run-bash-script.mjs scripts/spacetime-publish-maincloud.sh", "api-server:maincloud": "node scripts/api-server-maincloud.mjs", "deploy:rust:remote": "node scripts/run-bash-script.mjs scripts/deploy-rust-remote.sh", "build:rust:ubuntu": "node scripts/run-bash-script.mjs scripts/deploy-rust-remote.sh", "serve:caddy": "node scripts/run-caddy-dev.mjs", - "server-node:dev": "node scripts/server-node-frozen.mjs", - "server-node:build": "node scripts/server-node-frozen.mjs", - "server-node:db:migrate": "node scripts/server-node-frozen.mjs", - "server-node:manifest:backend": "node scripts/server-node-frozen.mjs", - "server-node:test": "node scripts/server-node-frozen.mjs", - "server-node:test:baseline": "node scripts/server-node-frozen.mjs", - "server-node:smoke": "node scripts/server-node-frozen.mjs", - "server-node:smoke:proxy": "node scripts/server-node-frozen.mjs", - "server-node:check:deploy": "node scripts/server-node-frozen.mjs", "server-rs:m7:preflight": "powershell -ExecutionPolicy Bypass -File server-rs/scripts/m7-preflight.ps1", - "m7:api-compare": "node scripts/run-tsx.cjs scripts/m7-api-compare.ts", "build": "node scripts/build-gate.mjs", "build:raw": "node scripts/vite-cli.mjs build", "preview": "node scripts/vite-cli.mjs preview", @@ -34,7 +23,7 @@ "lint:guardrails": "npm run lint:eslint", "typecheck": "tsc -p tsconfig.typecheck-guardrails.json --noEmit", "typecheck:guardrails": "npm run typecheck", - "lint": "npm run check:encoding && npm run check:server-node-freeze && npm run lint:eslint && npm run typecheck", + "lint": "npm run check:encoding && npm run lint:eslint && npm run typecheck", "lint:fix": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --fix && prettier --write .", "format": "prettier --write .", "format:check": "prettier --check .", @@ -44,14 +33,12 @@ "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", - "check:content": "npm run check:data && npm run check:overrides && npm run check:smoke", - "check:server-node-freeze": "node scripts/check-server-node-freeze.mjs" + "check:content": "npm run check:data && npm run check:overrides && npm run check:smoke" }, "dependencies": { "@tailwindcss/vite": "^4.1.14", "@vitejs/plugin-react": "^5.0.4", "dotenv": "^17.2.3", - "express": "^4.21.2", "lucide-react": "^0.546.0", "motion": "^12.23.24", "react": "^19.0.0", @@ -61,7 +48,6 @@ "devDependencies": { "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", - "@types/express": "^4.17.21", "@types/node": "^22.14.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/packages/shared/src/prompts/qwenSprite.ts b/packages/shared/src/prompts/qwenSprite.ts index 8e1ecd9e..68b5ff83 100644 --- a/packages/shared/src/prompts/qwenSprite.ts +++ b/packages/shared/src/prompts/qwenSprite.ts @@ -7,9 +7,8 @@ * - 给后端角色动作视频生成链路提供标准动作 prompt 骨架 * * 当前角色资产主链中的关系是: - * 1. 前端或后端先拿到一段较短的描述文本 - * 2. server-node/src/prompts/characterAssetPrompts.ts - * 再调用本文件 buildMasterPrompt / buildVideoActionPrompt + * 1. 前端或 Rust 后端先拿到一段较短的描述文本 + * 2. 当前角色资产链路调用本文件 buildMasterPrompt / buildVideoActionPrompt * 把短描述扩成正式给模型吃的 prompt * * 因此本文件不要承载“角色卡字段挑选”或“UI 默认值”职责, diff --git a/scripts/check-server-node-freeze.mjs b/scripts/check-server-node-freeze.mjs deleted file mode 100644 index 359ca49a..00000000 --- a/scripts/check-server-node-freeze.mjs +++ /dev/null @@ -1,127 +0,0 @@ -#!/usr/bin/env node - -import { createHash } from 'node:crypto'; -import { existsSync, readdirSync, readFileSync } from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const baselinePath = path.join(repoRoot, 'scripts', 'server-node-freeze-baseline.json'); -const needle = 'server-node'; - -const ignoredDirectories = new Set([ - '.git', - '.codex', - '.codex-temp', - '.idea', - 'node_modules', - 'dist', - 'build', - 'coverage', - 'target', - 'logs', -]); - -const ignoredFiles = new Set([ - 'scripts/check-server-node-freeze.mjs', - 'scripts/server-node-freeze-baseline.json', - 'scripts/server-node-frozen.mjs', - 'docs/audits/engineering/SERVER_NODE_FREEZE_AND_DEPRECATION_2026-04-24.md', -]); - -const allowedExtensions = new Set([ - '.cjs', - '.js', - '.json', - '.md', - '.mjs', - '.ps1', - '.rs', - '.toml', - '.ts', - '.tsx', - '.yaml', - '.yml', -]); - -function toRepoPath(absolutePath) { - return path.relative(repoRoot, absolutePath).replaceAll(path.sep, '/'); -} - -function hashLine(line) { - return createHash('sha256').update(line.trim()).digest('hex'); -} - -function walk(directory, output) { - for (const entry of readdirSync(directory, { withFileTypes: true })) { - if (entry.isDirectory()) { - if (!ignoredDirectories.has(entry.name)) { - walk(path.join(directory, entry.name), output); - } - continue; - } - - const absolutePath = path.join(directory, entry.name); - const repoPath = toRepoPath(absolutePath); - if (ignoredFiles.has(repoPath)) { - continue; - } - if (!allowedExtensions.has(path.extname(entry.name).toLowerCase())) { - continue; - } - output.push(absolutePath); - } -} - -function collectReferences() { - const files = []; - walk(repoRoot, files); - - const references = new Map(); - for (const file of files) { - const repoPath = toRepoPath(file); - const content = readFileSync(file, 'utf8'); - const lines = content.split(/\r?\n/u); - for (const line of lines) { - if (!line.toLowerCase().includes(needle)) { - continue; - } - const key = `${repoPath}\u0000${hashLine(line)}`; - references.set(key, (references.get(key) || 0) + 1); - } - } - return references; -} - -function parseBaseline() { - if (!existsSync(baselinePath)) { - return new Map(); - } - const baseline = JSON.parse(readFileSync(baselinePath, 'utf8')); - return new Map(Object.entries(baseline.references || {})); -} - -const currentReferences = collectReferences(); -const baselineReferences = parseBaseline(); -const newReferences = []; - -for (const [key, count] of currentReferences.entries()) { - const allowedCount = baselineReferences.get(key) || 0; - if (count > allowedCount) { - const [repoPath] = key.split('\u0000'); - newReferences.push({ repoPath, count: count - allowedCount }); - } -} - -if (newReferences.length > 0) { - console.error('检测到冻结后新增的 server-node 引用,请迁移到 server-rs 或更新废弃审计后再处理:'); - for (const reference of newReferences.slice(0, 50)) { - console.error(`- ${reference.repoPath} (+${reference.count})`); - } - if (newReferences.length > 50) { - console.error(`... 另有 ${newReferences.length - 50} 处`); - } - process.exit(1); -} - -console.log('server-node freeze guard passed: 未发现冻结后新增引用。'); diff --git a/scripts/deploy.sh b/scripts/deploy.sh deleted file mode 100644 index c3f2e671..00000000 --- a/scripts/deploy.sh +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -usage() { - cat <<'EOF' -用法: - ./scripts/deploy.sh - -示例: - ./scripts/deploy.sh /work/server-node - -说明: - 1. 进入指定后端目录 - 2. 构建后端 - 3. 重启已有的 genarrative-server - 4. 如果 PM2 进程不存在,则使用 ecosystem.config.cjs 创建 - -注意: - - 不会执行 git pull - - 不会同步文件 - - 不会构建前端 -EOF -} - -require_command() { - local command_name="$1" - - if ! command -v "$command_name" >/dev/null 2>&1; then - echo "[deploy] 缺少命令: $command_name" >&2 - exit 1 - fi -} - -BACKEND_DIR="${1:-}" - -if [[ -z "${BACKEND_DIR}" || "${BACKEND_DIR}" == "-h" || "${BACKEND_DIR}" == "--help" ]]; then - usage - if [[ -z "${BACKEND_DIR}" ]]; then - exit 1 - fi - exit 0 -fi - -require_command npm -require_command pm2 - -if [[ ! -d "${BACKEND_DIR}" ]]; then - echo "[deploy] 后端目录不存在: ${BACKEND_DIR}" >&2 - exit 1 -fi - -if [[ ! -f "${BACKEND_DIR}/ecosystem.config.cjs" ]]; then - echo "[deploy] 缺少 PM2 配置文件: ${BACKEND_DIR}/ecosystem.config.cjs" >&2 - exit 1 -fi - -echo "[deploy] 后端目录: ${BACKEND_DIR}" - -cd "${BACKEND_DIR}" - -# 重新构建后端产物。 -echo "[deploy] 构建后端" -npm run build - -# 优先重启;如果进程还不存在,就直接创建。 -echo "[deploy] 重启或创建 PM2 服务" -pm2 restart genarrative-server --update-env \ - || pm2 start ecosystem.config.cjs - -echo "[deploy] 完成" diff --git a/scripts/dev-node.mjs b/scripts/dev-node.mjs deleted file mode 100644 index 2a96ac97..00000000 --- a/scripts/dev-node.mjs +++ /dev/null @@ -1,548 +0,0 @@ -import {spawn, spawnSync} from 'node:child_process'; -import {existsSync, readFileSync} from 'node:fs'; -import net from 'node:net'; -import path from 'node:path'; -import {fileURLToPath, pathToFileURL} from 'node:url'; - -const repoRoot = fileURLToPath(new URL('../', import.meta.url)); -const serverRoot = fileURLToPath(new URL('../server-node/', import.meta.url)); -const serverRsRoot = fileURLToPath(new URL('../server-rs/', import.meta.url)); -const viteCliPath = fileURLToPath(new URL('./vite-cli.mjs', import.meta.url)); -const serverTsxCliPath = fileURLToPath( - new URL('../server-node/node_modules/tsx/dist/cli.mjs', import.meta.url), -); -const serverTsxLoaderPath = fileURLToPath( - new URL('../server-node/node_modules/tsx/dist/loader.mjs', import.meta.url), -); -const serverTsxLoaderUrl = pathToFileURL(serverTsxLoaderPath).href; -const envExamplePath = fileURLToPath(new URL('../.env.example', import.meta.url)); -const envLocalPath = fileURLToPath(new URL('../.env.local', import.meta.url)); -const spacetimeConfigPath = fileURLToPath(new URL('../spacetime.json', import.meta.url)); -const spacetimeLocalConfigPath = fileURLToPath(new URL('../spacetime.local.json', import.meta.url)); -const bundledNodePath = fileURLToPath( - new URL('../.tools/node-v22.22.2-win-x64/node.exe', import.meta.url), -); -const bundledNpmCliPath = fileURLToPath( - new URL('../.tools/node-v22.22.2-win-x64/node_modules/npm/bin/npm-cli.js', import.meta.url), -); -const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; -const DEFAULT_DEV_DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:5432/genarrative'; -const DEV_MEMORY_DATABASE_URL = 'pg-mem://genarrative-dev'; -const DEFAULT_RUST_API_HOST = '127.0.0.1'; -const DEFAULT_RUST_API_PORT = '3100'; -const DEFAULT_SPACETIME_SERVER_URL = 'http://127.0.0.1:3001'; -const DEFAULT_SPACETIME_DATABASE = 'genarrative-dev'; -const DEFAULT_INTERNAL_API_SECRET = 'genarrative-dev-internal-bridge'; -const spacetimeModulePath = path.join(serverRsRoot, 'crates', 'spacetime-module'); -const spacetimeRustBindingsOutDir = path.join( - serverRsRoot, - 'crates', - 'spacetime-client', - 'src', - 'module_bindings', -); - -function parseEnvContents(contents) { - return contents - .split(/\r?\n/u) - .reduce((envMap, rawLine) => { - const line = rawLine.trim(); - if (!line || line.startsWith('#')) { - return envMap; - } - - const separatorIndex = line.indexOf('='); - if (separatorIndex < 0) { - return envMap; - } - - const key = line.slice(0, separatorIndex).trim(); - let value = line.slice(separatorIndex + 1).trim(); - - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - - envMap[key] = value; - return envMap; - }, {}); -} - -function readEnvFile(filePath) { - if (!existsSync(filePath)) { - return {}; - } - - return parseEnvContents(readFileSync(filePath, 'utf8')); -} - -function readJsonFile(filePath) { - if (!existsSync(filePath)) { - return null; - } - - try { - return JSON.parse(readFileSync(filePath, 'utf8')); - } catch { - return null; - } -} - -function resolveDatabaseProbeTarget(databaseUrl) { - const trimmed = databaseUrl.trim(); - if (!trimmed || !/^postgres(?:ql)?:\/\//u.test(trimmed)) { - return null; - } - - try { - const url = new URL(trimmed); - return { - host: url.hostname === '0.0.0.0' ? '127.0.0.1' : url.hostname, - port: Number(url.port || 5432), - }; - } catch { - return null; - } -} - -function checkTcpReachable(target, timeoutMs = 1500) { - return new Promise((resolve) => { - const socket = net.createConnection(target); - let settled = false; - - const finish = (result) => { - if (settled) { - return; - } - settled = true; - socket.destroy(); - resolve(result); - }; - - socket.setTimeout(timeoutMs); - socket.once('connect', () => finish(true)); - socket.once('timeout', () => finish(false)); - socket.once('error', () => finish(false)); - }); -} - -function resolveServerTarget(serverAddr) { - const trimmed = serverAddr.trim(); - - if (!trimmed) { - return 'http://127.0.0.1:8081'; - } - - if (/^https?:\/\//u.test(trimmed)) { - try { - const url = new URL(trimmed); - if (url.hostname === '0.0.0.0') { - url.hostname = '127.0.0.1'; - } - return url.toString().replace(/\/$/u, ''); - } catch { - return trimmed.replace(/\/$/u, ''); - } - } - - if (trimmed.startsWith(':')) { - return `http://127.0.0.1${trimmed}`; - } - - if (trimmed.startsWith('0.0.0.0:')) { - return `http://127.0.0.1:${trimmed.slice('0.0.0.0:'.length)}`; - } - - return `http://${trimmed}`; -} - -function redactDatabaseUrl(databaseUrl) { - const trimmed = `${databaseUrl || ''}`.trim(); - - if (!trimmed) { - return '[missing]'; - } - - if (trimmed.startsWith('pg-mem://')) { - return trimmed; - } - - try { - const url = new URL(trimmed); - const databaseName = url.pathname.replace(/^\/+/u, '') || 'postgres'; - const portSuffix = url.port ? `:${url.port}` : ''; - return `${url.protocol}//${url.hostname}${portSuffix}/${databaseName}`; - } catch { - return '[configured]'; - } -} - -function resolvePathEnvKey(envMap) { - return Object.keys(envMap).find((key) => key.toLowerCase() === 'path') || 'PATH'; -} - -function prependEnvPath(envMap, nextEntry) { - const pathKey = resolvePathEnvKey(envMap); - const currentValue = envMap[pathKey] || ''; - const normalizedEntry = path.resolve(nextEntry); - const segments = currentValue - .split(path.delimiter) - .map((entry) => entry.trim()) - .filter(Boolean) - .filter((entry) => { - try { - return path.resolve(entry) !== normalizedEntry; - } catch { - return entry !== nextEntry; - } - }); - - envMap[pathKey] = [nextEntry, ...segments].join(path.delimiter); -} - -function resolveSpacetimeCommand() { - const command = process.platform === 'win32' ? 'where' : 'which'; - const result = spawnSync(command, ['spacetime'], { - cwd: repoRoot, - env: mergedEnv, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - }); - - if (result.status !== 0) { - return null; - } - - const firstLine = `${result.stdout || ''}` - .split(/\r?\n/u) - .map((line) => line.trim()) - .find(Boolean); - - return firstLine || 'spacetime'; -} - -function runRequiredCommand(command, args, label) { - const result = spawnSync(command, args, { - cwd: repoRoot, - env: mergedEnv, - stdio: 'inherit', - }); - - if (result.status !== 0) { - console.error(`[dev:node] ${label} failed with exit code ${result.status ?? 1}`); - process.exit(result.status ?? 1); - } -} - -function ensureSpacetimeSchemaReady() { - const spacetimeCommand = resolveSpacetimeCommand(); - if (!spacetimeCommand) { - console.error( - '[dev:node] Missing `spacetime` CLI. Install or expose it in PATH before starting local dev.', - ); - process.exit(1); - } - - const spacetimeServerUrl = `${mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL || ''}`.trim(); - const spacetimeDatabase = `${mergedEnv.GENARRATIVE_SPACETIME_DATABASE || ''}`.trim(); - - if (!spacetimeServerUrl || !spacetimeDatabase) { - console.error( - '[dev:node] Missing GENARRATIVE_SPACETIME_SERVER_URL or GENARRATIVE_SPACETIME_DATABASE, cannot publish local schema.', - ); - process.exit(1); - } - - console.log( - `[dev:node] Publishing spacetime-module to ${spacetimeDatabase} (${spacetimeServerUrl}) before Rust api-server starts...`, - ); - runRequiredCommand( - spacetimeCommand, - [ - 'publish', - spacetimeDatabase, - '--server', - spacetimeServerUrl, - '--module-path', - spacetimeModulePath, - '--yes', - ...(mergedEnv.GENARRATIVE_SPACETIME_DELETE_DATA_ON_CONFLICT === '1' - ? ['--delete-data=on-conflict'] - : []), - ], - 'spacetime publish', - ); - - console.log('[dev:node] Generating Rust Spacetime bindings before Rust api-server starts...'); - runRequiredCommand( - spacetimeCommand, - [ - 'generate', - '--no-config', - '--lang', - 'rust', - '--out-dir', - spacetimeRustBindingsOutDir, - '--module-path', - spacetimeModulePath, - '--include-private', - '--yes', - ], - 'spacetime generate (rust)', - ); -} - -const exampleEnv = readEnvFile(envExamplePath); -const localEnv = readEnvFile(envLocalPath); -const spacetimeConfig = readJsonFile(spacetimeConfigPath); -const spacetimeLocalConfig = readJsonFile(spacetimeLocalConfigPath); - -const mergedEnv = { - ...exampleEnv, - ...localEnv, - ...process.env, -}; - -const runtimeNodePath = existsSync(bundledNodePath) - ? bundledNodePath - : process.execPath; -const runtimeNpmCliPath = existsSync(bundledNpmCliPath) - ? bundledNpmCliPath - : ''; -const runtimeNodeDir = path.dirname(runtimeNodePath); - -mergedEnv.PROJECT_ROOT = mergedEnv.PROJECT_ROOT || repoRoot; -mergedEnv.NODE_SERVER_ADDR = mergedEnv.NODE_SERVER_ADDR || ':8081'; -mergedEnv.NODE_SERVER_TARGET = - mergedEnv.NODE_SERVER_TARGET || resolveServerTarget(mergedEnv.NODE_SERVER_ADDR); -mergedEnv.GENARRATIVE_API_HOST = - mergedEnv.GENARRATIVE_API_HOST || DEFAULT_RUST_API_HOST; -mergedEnv.GENARRATIVE_API_PORT = - mergedEnv.GENARRATIVE_API_PORT || DEFAULT_RUST_API_PORT; -mergedEnv.GENARRATIVE_API_TARGET = - mergedEnv.GENARRATIVE_API_TARGET || - `http://${mergedEnv.GENARRATIVE_API_HOST}:${mergedEnv.GENARRATIVE_API_PORT}`; -mergedEnv.GENARRATIVE_INTERNAL_API_SECRET = - mergedEnv.GENARRATIVE_INTERNAL_API_SECRET || DEFAULT_INTERNAL_API_SECRET; -mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL = - mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL || DEFAULT_SPACETIME_SERVER_URL; -mergedEnv.GENARRATIVE_SPACETIME_DATABASE = - mergedEnv.GENARRATIVE_SPACETIME_DATABASE || - spacetimeLocalConfig?.database || - spacetimeConfig?.database || - DEFAULT_SPACETIME_DATABASE; -mergedEnv.DATABASE_URL = - mergedEnv.DATABASE_URL || DEFAULT_DEV_DATABASE_URL; -mergedEnv.VITE_DEV_HOST = mergedEnv.VITE_DEV_HOST || '127.0.0.1'; -prependEnvPath(mergedEnv, runtimeNodeDir); -mergedEnv.npm_config_scripts_prepend_node_path = 'true'; - -const exampleDatabaseUrl = `${exampleEnv.DATABASE_URL || ''}`.trim(); -const localDatabaseUrl = `${localEnv.DATABASE_URL || ''}`.trim(); -const processDatabaseUrl = `${process.env.DATABASE_URL || ''}`.trim(); -const exampleSpacetimeDatabase = `${exampleEnv.GENARRATIVE_SPACETIME_DATABASE || ''}`.trim(); -const localSpacetimeDatabase = `${localEnv.GENARRATIVE_SPACETIME_DATABASE || ''}`.trim(); -const processSpacetimeDatabase = `${process.env.GENARRATIVE_SPACETIME_DATABASE || ''}`.trim(); -const hasExplicitDatabaseUrl = - Boolean(processDatabaseUrl) || - (Boolean(localDatabaseUrl) && localDatabaseUrl !== exampleDatabaseUrl); -const hasExplicitSpacetimeDatabase = - Boolean(processSpacetimeDatabase) || - (Boolean(localSpacetimeDatabase) && localSpacetimeDatabase !== exampleSpacetimeDatabase); - -// 本地开发默认跟随仓库当前的 Spacetime 数据库名,只有显式覆盖时才尊重环境变量。 -if (!hasExplicitSpacetimeDatabase) { - mergedEnv.GENARRATIVE_SPACETIME_DATABASE = - spacetimeLocalConfig?.database || - spacetimeConfig?.database || - DEFAULT_SPACETIME_DATABASE; -} - -if (!hasExplicitDatabaseUrl) { - const databaseProbeTarget = resolveDatabaseProbeTarget(mergedEnv.DATABASE_URL); - if (databaseProbeTarget) { - const isReachable = await checkTcpReachable(databaseProbeTarget); - if (!isReachable) { - console.warn( - `[dev:node] PostgreSQL unavailable at ${databaseProbeTarget.host}:${databaseProbeTarget.port}; falling back to ${DEV_MEMORY_DATABASE_URL} for local dev.`, - ); - console.warn( - '[dev:node] Current session will use in-memory persistence only. Set DATABASE_URL in .env.local to restore PostgreSQL-backed runtime data.', - ); - mergedEnv.DATABASE_URL = DEV_MEMORY_DATABASE_URL; - } - } -} - -console.log(`[dev:node] PROJECT_ROOT=${mergedEnv.PROJECT_ROOT}`); -console.log(`[dev:node] NODE_SERVER_ADDR=${mergedEnv.NODE_SERVER_ADDR}`); -console.log(`[dev:node] NODE_SERVER_TARGET=${mergedEnv.NODE_SERVER_TARGET}`); -console.log(`[dev:node] GENARRATIVE_API_TARGET=${mergedEnv.GENARRATIVE_API_TARGET}`); -console.log('[dev:node] GENARRATIVE_INTERNAL_API_SECRET=[configured]'); -console.log( - `[dev:node] GENARRATIVE_SPACETIME_SERVER_URL=${mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL}`, -); -console.log( - `[dev:node] GENARRATIVE_SPACETIME_DATABASE=${mergedEnv.GENARRATIVE_SPACETIME_DATABASE}`, -); -console.log(`[dev:node] DATABASE_URL=${redactDatabaseUrl(mergedEnv.DATABASE_URL)}`); -console.log(`[dev:node] VITE_DEV_HOST=${mergedEnv.VITE_DEV_HOST}`); -console.log(`[dev:node] NODE_RUNTIME=${runtimeNodePath}`); - -ensureSpacetimeSchemaReady(); - -const children = new Set(); -let shuttingDown = false; -let pendingExitCode = 0; - -function stopChild(child) { - if (!child || child.exitCode !== null) { - return; - } - - child.kill('SIGTERM'); - - setTimeout(() => { - if (child.exitCode === null) { - child.kill('SIGKILL'); - } - }, 2000).unref(); -} - -function stopAllChildren() { - for (const child of children) { - stopChild(child); - } -} - -function finalizeExit(code = 0) { - pendingExitCode = code; - if (children.size === 0) { - process.exit(pendingExitCode); - } -} - -function requestShutdown(code = 0) { - if (!shuttingDown) { - shuttingDown = true; - pendingExitCode = code; - stopAllChildren(); - } - - finalizeExit(pendingExitCode); -} - -function registerChild(name, child, siblingProvider) { - children.add(child); - - child.on('error', (error) => { - console.error(`[dev:node] ${name} failed to start`, error); - requestShutdown(1); - }); - - child.on('exit', (code, signal) => { - children.delete(child); - - if (!shuttingDown) { - const resolvedExitCode = code ?? 1; - const signalSuffix = signal ? ` (${signal})` : ''; - console.error( - `[dev:node] ${name} exited with code ${resolvedExitCode}${signalSuffix}`, - ); - - const sibling = siblingProvider(); - if (sibling) { - stopChild(sibling); - } - - requestShutdown(resolvedExitCode); - return; - } - - finalizeExit(pendingExitCode); - }); -} - -const serverProcess = existsSync(serverTsxLoaderPath) - ? spawn(runtimeNodePath, ['--watch', '--import', serverTsxLoaderUrl, 'src/server.ts'], { - cwd: serverRoot, - env: mergedEnv, - stdio: 'inherit', - }) - : existsSync(serverTsxCliPath) - ? spawn(runtimeNodePath, [serverTsxCliPath, 'watch', 'src/server.ts'], { - cwd: serverRoot, - env: mergedEnv, - stdio: 'inherit', - }) - : runtimeNpmCliPath - ? spawn(runtimeNodePath, [runtimeNpmCliPath, 'run', 'dev'], { - cwd: serverRoot, - env: mergedEnv, - stdio: 'inherit', - }) - : spawn(npmCommand, ['run', 'dev'], { - cwd: serverRoot, - env: mergedEnv, - shell: process.platform === 'win32', - stdio: 'inherit', - }); - -const rustApiProcess = process.platform === 'win32' - ? spawn( - 'powershell', - [ - '-NoProfile', - '-ExecutionPolicy', - 'Bypass', - '-File', - path.join(serverRsRoot, 'scripts', 'dev.ps1'), - '-ApiHost', - mergedEnv.GENARRATIVE_API_HOST, - '-Port', - mergedEnv.GENARRATIVE_API_PORT, - ], - { - cwd: repoRoot, - env: mergedEnv, - stdio: 'inherit', - }, - ) - : spawn( - 'bash', - [ - path.join(serverRsRoot, 'scripts', 'dev.sh'), - ], - { - cwd: repoRoot, - env: mergedEnv, - stdio: 'inherit', - }, - ); - -const viteProcess = spawn( - runtimeNodePath, - [viteCliPath, '--port=3000', `--host=${mergedEnv.VITE_DEV_HOST}`], - { - cwd: repoRoot, - env: mergedEnv, - stdio: 'inherit', - }, -); - -registerChild('node server', serverProcess, () => viteProcess); -registerChild('rust api-server', rustApiProcess, () => viteProcess); -registerChild('vite dev server', viteProcess, () => serverProcess); - -process.on('SIGINT', () => { - console.log('[dev:node] received SIGINT, shutting down...'); - requestShutdown(0); -}); - -process.on('SIGTERM', () => { - console.log('[dev:node] received SIGTERM, shutting down...'); - requestShutdown(0); -}); diff --git a/scripts/dev-rust-stack.sh b/scripts/dev-rust-stack.sh index e01b93bd..320e5ec6 100644 --- a/scripts/dev-rust-stack.sh +++ b/scripts/dev-rust-stack.sh @@ -7,6 +7,7 @@ usage() { 用法: npm run dev:rust ./scripts/dev-rust-stack.sh --api-port 8090 --spacetime-port 3110 + ./scripts/dev-rust-stack.sh --api-timeout-seconds 600 ./scripts/dev-rust-stack.sh --skip-spacetime --skip-publish ./scripts/dev-rust-stack.sh --preserve-database npm run dev:rust:logs -- --follow @@ -183,6 +184,7 @@ SPACETIME_ROOT_DIR="${SERVER_RS_DIR}/.spacetimedb/local" DATABASE="" API_LOG="info,tower_http=info" SPACETIME_TIMEOUT_SECONDS="60" +API_SERVER_TIMEOUT_SECONDS="300" SKIP_SPACETIME=0 SKIP_PUBLISH=0 PRESERVE_DATABASE=0 @@ -256,6 +258,10 @@ while [[ $# -gt 0 ]]; do SPACETIME_TIMEOUT_SECONDS="${2:?缺少 --spacetime-timeout-seconds 的值}" shift 2 ;; + --api-timeout-seconds) + API_SERVER_TIMEOUT_SECONDS="${2:?缺少 --api-timeout-seconds 的值}" + shift 2 + ;; --skip-spacetime) SKIP_SPACETIME=1 shift @@ -322,6 +328,7 @@ echo "[dev:rust] rust api: ${RUST_SERVER_TARGET}" echo "[dev:rust] spacetime: ${SPACETIME_SERVER}" echo "[dev:rust] database: ${DATABASE}" echo "[dev:rust] spacetime root: ${SPACETIME_ROOT_DIR}" +echo "[dev:rust] api timeout: ${API_SERVER_TIMEOUT_SECONDS}s" if [[ "${SKIP_SPACETIME}" -ne 1 ]]; then mkdir -p "${SPACETIME_ROOT_DIR}" @@ -375,12 +382,11 @@ PIDS+=("${API_PID}") NAMES+=("api-server") echo "[dev:rust] 等待 api-server 就绪" -wait_for_api_server "${RUST_SERVER_TARGET}/healthz" "${SPACETIME_TIMEOUT_SECONDS}" "${API_PID}" +wait_for_api_server "${RUST_SERVER_TARGET}/healthz" "${API_SERVER_TIMEOUT_SECONDS}" "${API_PID}" echo "[dev:rust] 启动 vite" ( cd "${REPO_ROOT}" - GENARRATIVE_BACKEND_STACK="rust" \ RUST_SERVER_TARGET="${RUST_SERVER_TARGET}" \ GENARRATIVE_RUNTIME_SERVER_TARGET="${RUST_SERVER_TARGET}" \ VITE_DEV_HOST="${WEB_HOST}" \ diff --git a/scripts/dev-server/README.md b/scripts/dev-server/README.md index 847a17d1..ab33a41a 100644 --- a/scripts/dev-server/README.md +++ b/scripts/dev-server/README.md @@ -4,10 +4,10 @@ 当前正式开发入口统一为: -- `node scripts/dev-node.mjs` -- `server-node/src/modules/editor/**` -- `server-node/src/modules/assets/**` -- `src/editor/shared/editorApiClient.ts` +- `npm run dev` +- `scripts/dev-rust-stack.sh` +- `server-rs/crates/api-server/**` +- `server-rs/crates/spacetime-module/**` 该目录只保留本说明文件,作为迁移结果标记。 diff --git a/scripts/dev-web-rust.mjs b/scripts/dev-web-rust.mjs index 5c76eb2b..3039887f 100644 --- a/scripts/dev-web-rust.mjs +++ b/scripts/dev-web-rust.mjs @@ -2,7 +2,6 @@ import {spawn} from 'node:child_process'; const mergedEnv = { ...process.env, - GENARRATIVE_BACKEND_STACK: process.env.GENARRATIVE_BACKEND_STACK || 'rust', RUST_SERVER_TARGET: process.env.RUST_SERVER_TARGET || process.env.GENARRATIVE_API_TARGET || diff --git a/scripts/m7-api-compare.ts b/scripts/m7-api-compare.ts deleted file mode 100644 index 3a2c5244..00000000 --- a/scripts/m7-api-compare.ts +++ /dev/null @@ -1,170 +0,0 @@ -import assert from 'node:assert/strict'; - -type HttpMethod = 'GET'; - -interface CompareCase { - method: HttpMethod; - path: string; -} - -interface CompareResult { - path: string; - nodeStatus: number; - rustStatus: number; - matched: boolean; - reason?: string; -} - -const DEFAULT_NODE_BASE_URL = 'http://127.0.0.1:8081'; -const DEFAULT_RUST_BASE_URL = 'http://127.0.0.1:3000'; - -function readEnv(name: string, fallback: string): string { - const value = process.env[name]?.trim(); - return value ? value : fallback; -} - -function buildCases(): CompareCase[] { - const rawPaths = process.env.M7_COMPARE_PATHS?.trim(); - const paths = rawPaths - ? rawPaths.split(',').map((value) => value.trim()).filter(Boolean) - : ['/healthz', '/api/auth/login-options']; - - return paths.map((path) => ({ - method: 'GET', - path: path.startsWith('/') ? path : `/${path}`, - })); -} - -async function fetchJson(baseUrl: string, testCase: CompareCase, requestId: string) { - const url = new URL(testCase.path, baseUrl); - const response = await fetch(url, { - method: testCase.method, - headers: { - 'x-request-id': requestId, - 'x-genarrative-response-envelope': '1', - }, - }); - const text = await response.text(); - const json = text ? JSON.parse(text) : null; - - return { - status: response.status, - json: normalizeVolatileJson(json), - }; -} - -function normalizeVolatileJson(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(normalizeVolatileJson); - } - - if (!value || typeof value !== 'object') { - return value; - } - - const record = value as Record; - const normalized: Record = {}; - - for (const [key, child] of Object.entries(record)) { - if (['requestId', 'timestamp', 'latencyMs'].includes(key)) { - continue; - } - - normalized[key] = normalizeVolatileJson(child); - } - - return normalized; -} - -function stableStringify(value: unknown): string { - if (Array.isArray(value)) { - return `[${value.map(stableStringify).join(',')}]`; - } - - if (!value || typeof value !== 'object') { - return JSON.stringify(value); - } - - const entries = Object.entries(value as Record) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, child]) => `${JSON.stringify(key)}:${stableStringify(child)}`); - - return `{${entries.join(',')}}`; -} - -async function compareCase( - nodeBaseUrl: string, - rustBaseUrl: string, - testCase: CompareCase, -): Promise { - const requestId = `m7-api-compare-${testCase.path.replaceAll('/', '-')}`; - const [nodeResponse, rustResponse] = await Promise.all([ - fetchJson(nodeBaseUrl, testCase, requestId), - fetchJson(rustBaseUrl, testCase, requestId), - ]); - - if (nodeResponse.status !== rustResponse.status) { - return { - path: testCase.path, - nodeStatus: nodeResponse.status, - rustStatus: rustResponse.status, - matched: false, - reason: 'status 不一致', - }; - } - - const nodeBody = stableStringify(nodeResponse.json); - const rustBody = stableStringify(rustResponse.json); - if (nodeBody !== rustBody) { - return { - path: testCase.path, - nodeStatus: nodeResponse.status, - rustStatus: rustResponse.status, - matched: false, - reason: `body 不一致\nnode=${nodeBody}\nrust=${rustBody}`, - }; - } - - return { - path: testCase.path, - nodeStatus: nodeResponse.status, - rustStatus: rustResponse.status, - matched: true, - }; -} - -async function main() { - const nodeBaseUrl = readEnv('M7_NODE_BASE_URL', DEFAULT_NODE_BASE_URL); - const rustBaseUrl = readEnv('M7_RUST_BASE_URL', DEFAULT_RUST_BASE_URL); - const strict = process.env.M7_COMPARE_STRICT?.trim() !== 'false'; - const cases = buildCases(); - - console.log(`[m7:api-compare] node=${nodeBaseUrl}`); - console.log(`[m7:api-compare] rust=${rustBaseUrl}`); - console.log(`[m7:api-compare] cases=${cases.map((item) => item.path).join(', ')}`); - - const results = await Promise.all( - cases.map((testCase) => compareCase(nodeBaseUrl, rustBaseUrl, testCase)), - ); - - for (const result of results) { - const label = result.matched ? 'OK' : 'DIFF'; - console.log( - `[m7:api-compare] ${label} ${result.path} node=${result.nodeStatus} rust=${result.rustStatus}`, - ); - if (result.reason) { - console.log(result.reason); - } - } - - const failures = results.filter((result) => !result.matched); - if (strict) { - assert.equal(failures.length, 0, '存在 Node/Rust API contract 差异'); - } -} - -main().catch((error) => { - console.error('[m7:api-compare] failed'); - console.error(error); - process.exitCode = 1; -}); diff --git a/scripts/run-caddy-dev.mjs b/scripts/run-caddy-dev.mjs index b2f6a010..f8d78e72 100644 --- a/scripts/run-caddy-dev.mjs +++ b/scripts/run-caddy-dev.mjs @@ -53,9 +53,10 @@ function normalizePathForCaddy(filePath) { function resolveApiUpstream(env) { return ( - env.CADDY_API_UPSTREAM - || env.NODE_SERVER_TARGET - || 'http://127.0.0.1:8081' + env.CADDY_API_UPSTREAM || + env.GENARRATIVE_API_TARGET || + env.RUST_SERVER_TARGET || + 'http://127.0.0.1:3100' ); } diff --git a/scripts/server-node-freeze-baseline.json b/scripts/server-node-freeze-baseline.json deleted file mode 100644 index 23d7e4ec..00000000 --- a/scripts/server-node-freeze-baseline.json +++ /dev/null @@ -1,1241 +0,0 @@ -{ - "generatedAt": "2026-04-24", - "description": "server-node 冻结状态下允许保留的既有引用基线;冻结后不允许新增引用。", - "references": { - "AGENTS.md\u000045988b31f8fad12fb8bb4763a4f3d4dc10c23395c401563a2f8e1f08eda3282d": 1, - "backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md\u00007a8de9c30168f55a8d131ea3108e2c44897516af6e981b6fa637e404945e67a9": 1, - "backend-rewrite-tasklist/M0_CAPABILITY_SURFACE_BASELINE_2026-04-20.md\u00006c5e6d67ef9b19b63a070b0296e517db7e83bcec8ee803d9efb11e9429fd7547": 1, - "backend-rewrite-tasklist/M0_CAPABILITY_SURFACE_BASELINE_2026-04-20.md\u0000d291b0935293a4948f36a5f9709afefdae1e4439852da1d10bd15e2e121d3a6a": 1, - "backend-rewrite-tasklist/M0_CAPABILITY_SURFACE_BASELINE_2026-04-20.md\u0000b77f0484ff1f247cf32acf1f165c7ef6dbc69cc32516464cd3e7751b1474191f": 1, - "backend-rewrite-tasklist/M0_CAPABILITY_SURFACE_BASELINE_2026-04-20.md\u0000883f8218a699143bf9550e65b9abbf5f81680b49c8ada9933983e465c8c5c938": 1, - "backend-rewrite-tasklist/M0_CAPABILITY_SURFACE_BASELINE_2026-04-20.md\u00002b6bf9286ee8937bdd860be9cc17ad6a1091bb639959932c9e6047d585fb23ea": 1, - "backend-rewrite-tasklist/M0_CAPABILITY_SURFACE_BASELINE_2026-04-20.md\u000080a35bd9a2407e476fe378415290c804b2a9d9b133b8f83b02bb05bd8291b097": 1, - "backend-rewrite-tasklist/M0_CAPABILITY_SURFACE_BASELINE_2026-04-20.md\u00008da91693d7b3f8fb64f4ff39709f4bdad6494e121f3417ed7e437b3649c863f7": 1, - "backend-rewrite-tasklist/M0_CAPABILITY_SURFACE_BASELINE_2026-04-20.md\u00009012a4798b850ba23134ac4d20f60e4a4aca1f0e3060e2f0bbdf136dfdb70ba8": 1, - "backend-rewrite-tasklist/M0_CAPABILITY_SURFACE_BASELINE_2026-04-20.md\u0000af61921cea3a6717370b2e822c774674cc0aec9162f1842cf96c4f97eb6719c8": 1, - "backend-rewrite-tasklist/M0_FRONTEND_RESPONSE_CONTRACT_BASELINE_2026-04-20.md\u00006fb7e1246b06ad44e775ac264c97d4f0c098884fb810fffb2fb2ca439de53dfc": 1, - "backend-rewrite-tasklist/M0_FRONTEND_RESPONSE_CONTRACT_BASELINE_2026-04-20.md\u000095bf118f83ee82de3c32868d76b8ec8e0aefcb28fddcb953d7d30c68f8363095": 1, - "backend-rewrite-tasklist/M0_FRONTEND_RESPONSE_CONTRACT_BASELINE_2026-04-20.md\u000003f69617b44518b8c89971efb6db7ba8593c0d1e4a3a4113ccaa55bd731385d0": 1, - "backend-rewrite-tasklist/M0_FRONTEND_RESPONSE_CONTRACT_BASELINE_2026-04-20.md\u0000eca95be6e7bbc9de6455ae9b9375bc404819b9823f4dd3016d930e86ffd464d1": 1, - "backend-rewrite-tasklist/M0_GENERATED_STATIC_PREFIX_BASELINE_2026-04-20.md\u0000b4ed53f9cecbf30d83e3ee52952bce89c0963ee932ef786ff33c41bc37aa7d02": 1, - "backend-rewrite-tasklist/M0_GENERATED_STATIC_PREFIX_BASELINE_2026-04-20.md\u000075ca2112dd827023f724759d253d0e3436b9cd1797c76166c952e53f8499e8b1": 1, - "backend-rewrite-tasklist/M0_GENERATED_STATIC_PREFIX_BASELINE_2026-04-20.md\u0000f190fe3862bbf3c5485b704822caff42d75a3bc7851042ab451d903a8f5009c0": 1, - "backend-rewrite-tasklist/M0_GENERATED_STATIC_PREFIX_BASELINE_2026-04-20.md\u0000ce908e01f1ce7bc63b86197ed0e389b3bc8484819a51e3f36bcf71cdc683321d": 1, - "backend-rewrite-tasklist/M0_GENERATED_STATIC_PREFIX_BASELINE_2026-04-20.md\u000039c682400bbbf8dd1e6e75ef34b777a5753a78ba90ed12414014419c70d33bb3": 1, - "backend-rewrite-tasklist/M0_MODULE_MIGRATION_BASELINE_2026-04-20.md\u00006c5e6d67ef9b19b63a070b0296e517db7e83bcec8ee803d9efb11e9429fd7547": 1, - "backend-rewrite-tasklist/M0_MODULE_MIGRATION_BASELINE_2026-04-20.md\u0000f3410c1b6437e42ad0d69f7bd1c99408370f2bc59e304f8d6e5500aec19aec9b": 1, - "backend-rewrite-tasklist/M0_MODULE_MIGRATION_BASELINE_2026-04-20.md\u0000e8098135526dffe799c9280e1a2ef4582b26d8870471aa43f22fb7fac4b44d52": 1, - "backend-rewrite-tasklist/M0_MODULE_MIGRATION_BASELINE_2026-04-20.md\u0000ca79fa760fd78b54dcf246c04f22f081d03826944d20c72990cc4596e0ccbeb3": 1, - "backend-rewrite-tasklist/M0_MODULE_MIGRATION_BASELINE_2026-04-20.md\u0000543132a8c1cfe6953abd46161827cc478cdb8cd3d513a13fbe6e734fdd65e4c3": 1, - "backend-rewrite-tasklist/M0_MODULE_MIGRATION_BASELINE_2026-04-20.md\u0000b835bf74ae7ef74eb64af0fb38d6ec93a1e028ebb52bfbaa0bc19eea76ee940a": 1, - "backend-rewrite-tasklist/M0_MODULE_MIGRATION_BASELINE_2026-04-20.md\u0000a9e2668d290fad29344dcb712d12c2ef791758d7ea2800e82a387df1ab44d541": 1, - "backend-rewrite-tasklist/M0_MODULE_MIGRATION_BASELINE_2026-04-20.md\u0000fe68cc34ed7627946086d3fa98003dd2c2e887fb690ac677bd8ea2a08d989ebc": 1, - "backend-rewrite-tasklist/M0_MODULE_MIGRATION_BASELINE_2026-04-20.md\u00006b880488d999d1ca830452d396dbbccc65f515dfd7924f702180002aabc6090e": 1, - "backend-rewrite-tasklist/M0_MODULE_MIGRATION_BASELINE_2026-04-20.md\u000061ec594aafe6f0b5a309d23a8fc55e0549281e09695cee227241333cde8e0a21": 1, - "backend-rewrite-tasklist/M0_MODULE_MIGRATION_BASELINE_2026-04-20.md\u00007c25f46b2ac69d9b9d65fdd4125045b27a845626507369fce093f99960d679c7": 1, - "backend-rewrite-tasklist/M0_MODULE_MIGRATION_BASELINE_2026-04-20.md\u00003be9a0ef03f23a59edec11865b80ff11c2ed3714b22fff92d57adf18d45cb81f": 1, - "backend-rewrite-tasklist/M0_MODULE_MIGRATION_BASELINE_2026-04-20.md\u00002161e8e369c5f5f7093c0ea016a7077b6ca5e68b84ffda1fc449b17cd282c4f3": 1, - "backend-rewrite-tasklist/M0_MODULE_MIGRATION_BASELINE_2026-04-20.md\u00000e4fe40618112a84d60c38f609926d022d0a4005be2a87d059fe010b0a7737a7": 1, - "backend-rewrite-tasklist/M0_MODULE_MIGRATION_BASELINE_2026-04-20.md\u0000163a263d5794daa24f525883fc3a11c768361202c9c2a968e0b537d5fba95484": 1, - "backend-rewrite-tasklist/M0_PHASE_ACCEPTANCE_MATRIX_2026-04-20.md\u0000a114c530267b900294b1e275b57483c0654f2fba9811441c0e2c1ebb657b2867": 1, - "backend-rewrite-tasklist/M0_PHASE_ACCEPTANCE_MATRIX_2026-04-20.md\u0000221458c93ca3f9b81650d57a8d11e3479ec3b3a56b45f96ba5889bbf68df5ad0": 1, - "backend-rewrite-tasklist/M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md\u000025d32842a1d6cb6e92176913c34494fea42e58186d7d3868e4cbd5f99c130c64": 1, - "backend-rewrite-tasklist/M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md\u0000bc52fbd3b890985f5fd865e88598ba28f837c08980ff5f8a4e1ff8db5d7d24c4": 1, - "backend-rewrite-tasklist/M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md\u000089c2ca4755ebd93b0e8c7f061c7bc9f5e87a4804e9b5b376ee7ed792c8f57869": 1, - "backend-rewrite-tasklist/M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md\u00009691b86721a607c1576974fffbd9288c10239bd6b65076dd28234301082b4755": 1, - "backend-rewrite-tasklist/M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md\u0000cf67e3ac1b02e09e9d31785cc08d1a7e66dd0b74953f9c527fb007ccd2ca60a1": 1, - "backend-rewrite-tasklist/M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md\u00000e65dd8c18606e8a9a329a9b1b34984675d8d127248cb7b27d7b6c67cbc6a240": 1, - "backend-rewrite-tasklist/M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md\u000014b0824277838bcc47582d452d0f1a5c80cef12e7905bd7930fcabe03f8d050f": 1, - "backend-rewrite-tasklist/M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md\u000082bf2f60d387c35073188f049c3d3bea0c079e0bcd18aae05c8542735a4b1c40": 1, - "backend-rewrite-tasklist/M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md\u000060be1d0c1f35c95b4ac688affdfe2c24e93f2010e53fa71a50cae8189089de3e": 1, - "backend-rewrite-tasklist/M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md\u0000c007fa1a0a6b23f6ff66d5c6792db8ab021364676f755e256f5734a242a0b397": 1, - "backend-rewrite-tasklist/M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md\u0000b2d5150834f5b6d065c413ebf66acfea4151090bed2124b608ecbb739963ad0a": 1, - "backend-rewrite-tasklist/M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md\u0000a0b6a31e8f6b5b9af2dd0fabf935090af306e985192935be84cf26ab8eade8ac": 1, - "backend-rewrite-tasklist/M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md\u00004518097a58cc4f8d3e4e0bbfabf64a7e08ce76f40a1290bb62d1cee5a3436cc4": 1, - "backend-rewrite-tasklist/M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md\u0000e34e4729cf7110e981ce9fc0a3735d78cdba0e2adb7c252b1e534653060fdfd2": 1, - "backend-rewrite-tasklist/M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md\u000045fd84211718088954b95c252be973d6a0f1902aae55acb6578dacd3217b3ac5": 1, - "backend-rewrite-tasklist/M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md\u00009654671e380f1a7b086f04d6d3d15a9dc7a1c0070bca75b6f62a8ca4e5a099e1": 1, - "backend-rewrite-tasklist/M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md\u0000ebb6aec34e7c0a7be23e1fa75665b32171cd254faa9cc3fa42b71a37b5a6e58b": 1, - "backend-rewrite-tasklist/M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md\u00009f984eee0f0558e870f5cb18cdc4528db5a41d41ada29bd2dd97c0a1139d987d": 1, - "backend-rewrite-tasklist/M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md\u0000c6b0320ef3419386a53e9c21551e50d7ad13a6d670cac79aad0bda769f06ca6f": 1, - "backend-rewrite-tasklist/M0_ROUTE_MIGRATION_MATRIX_2026-04-20.md\u00006c5e6d67ef9b19b63a070b0296e517db7e83bcec8ee803d9efb11e9429fd7547": 1, - "backend-rewrite-tasklist/M0_ROUTE_MIGRATION_MATRIX_2026-04-20.md\u0000265091148237b23ce1795d1e587fd827cbc5fb133640dbcaca8e4e8be859a305": 1, - "backend-rewrite-tasklist/M0_ROUTE_MIGRATION_MATRIX_2026-04-20.md\u0000cb87497fcc882177d3af84cf989d616e2161018f15c14acd33344d171ec079c2": 1, - "backend-rewrite-tasklist/M0_ROUTE_MIGRATION_MATRIX_2026-04-20.md\u00001f8ab8f863f290fbf9b0e98cf104862766d01813fca95f1efcd3dedf3e7a6c5f": 1, - "backend-rewrite-tasklist/M0_SSE_INTERFACE_BASELINE_2026-04-20.md\u00006c5e6d67ef9b19b63a070b0296e517db7e83bcec8ee803d9efb11e9429fd7547": 1, - "backend-rewrite-tasklist/M0_SSE_INTERFACE_BASELINE_2026-04-20.md\u00006fb7e1246b06ad44e775ac264c97d4f0c098884fb810fffb2fb2ca439de53dfc": 1, - "backend-rewrite-tasklist/M0_SSE_INTERFACE_BASELINE_2026-04-20.md\u0000d9068a2b7360687b20cc86359e95c72ab1b22629b94d777572ebd782d6b8bddc": 1, - "backend-rewrite-tasklist/M0_SSE_INTERFACE_BASELINE_2026-04-20.md\u0000ea879ddfe019a14e428a2d4103d01de687b9f571ec83619f45f059524eaa5e84": 1, - "backend-rewrite-tasklist/M0_SSE_INTERFACE_BASELINE_2026-04-20.md\u00000c7dab32dd8d79f8a55dfe8de52df01da216ca64cb26cfb3fecacc667a6ed80a": 1, - "backend-rewrite-tasklist/M0_SSE_INTERFACE_BASELINE_2026-04-20.md\u0000ef564f7b3d27a584f95ce1531cf306145eb0a6bb5f070aa27c2443377dfe0cdb": 1, - "backend-rewrite-tasklist/M0_SSE_INTERFACE_BASELINE_2026-04-20.md\u0000798729f12c0ba12bb90570fe70923c9d038661886f5021acecb12fae54511cf4": 1, - "docs/audits/AGENT_TO_DRAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md\u0000d53458dfee41d3c3a26f6c66761b9871ac4647f427d15107551e51bad2e9d1b7": 1, - "docs/audits/AGENT_TO_DRAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md\u0000d9068a2b7360687b20cc86359e95c72ab1b22629b94d777572ebd782d6b8bddc": 2, - "docs/audits/AGENT_TO_DRAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md\u00003fb7958730ec076d3c5057ee5819402bb47a0e6c6b0fea10f68cfade2b66e81f": 1, - "docs/audits/AGENT_TO_DRAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md\u000073963a094a46e066dae72eb1cf0d342cacb7d57363c8a7592f1138d260269dad": 1, - "docs/audits/AGENT_TO_DRAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md\u000000ecc83edd3ee386e211002bb4ace7a362dd05095dc5651fba0e72274f9103ae": 1, - "docs/audits/AGENT_TO_DRAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md\u00003d2597c34b71d8f0afdcfd99238e425bef58fb78aa0df743ad3e155687a95942": 1, - "docs/audits/AGENT_TO_DRAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md\u0000ff3dc32a72b0f6b9e6be1bc149d15d4545fc703be69961d681a00be59beee2b3": 1, - "docs/audits/AGENT_TO_DRAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md\u000047fabb484aa8d502baa469802d2ee72d85281708ed8db3798266901fde117b31": 1, - "docs/audits/AGENT_TO_DRAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md\u000039c682400bbbf8dd1e6e75ef34b777a5753a78ba90ed12414014419c70d33bb3": 1, - "docs/audits/AGENT_TO_DRAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md\u0000c4bd4fd83cd69aa5168a981ed5f5b83b4e5ab25ed3d6820ce62c4968d626ef91": 1, - "docs/audits/AGENT_TO_DRAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md\u0000ef564f7b3d27a584f95ce1531cf306145eb0a6bb5f070aa27c2443377dfe0cdb": 1, - "docs/audits/CHARACTER_ASSET_PROMPT_CHAIN_AUDIT_2026-04-20.md\u0000491398d4c2b3de81ba0b01a58d1d03e33c250b3b5d926b2e3c8a6d91a72abfda": 4, - "docs/audits/CHARACTER_ASSET_PROMPT_CHAIN_AUDIT_2026-04-20.md\u0000a98dfaa4510ce77864accf9b8c5df1df31e4aa5b3cbb5a508b78351d3da7092b": 1, - "docs/audits/CHARACTER_ASSET_PROMPT_CHAIN_AUDIT_2026-04-20.md\u0000b4ed53f9cecbf30d83e3ee52952bce89c0963ee932ef786ff33c41bc37aa7d02": 3, - "docs/audits/CHARACTER_ASSET_PROMPT_CHAIN_AUDIT_2026-04-20.md\u0000ed38ad2b40d62039eca4fdaad65d3512ff26d1c87d34220c6fa963da04025946": 2, - "docs/audits/CHARACTER_ASSET_PROMPT_CHAIN_AUDIT_2026-04-20.md\u000059add105792395e82a765b4187d89192c8811cd4a9dc652219c147707f39ab72": 1, - "docs/audits/CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md\u00003fb7958730ec076d3c5057ee5819402bb47a0e6c6b0fea10f68cfade2b66e81f": 1, - "docs/audits/CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md\u0000132dddf2b2356bec3b88873a7dcc12b037bf193d49051bbce031390a1fc525bf": 1, - "docs/audits/CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md\u0000093951a59f9d75cdc88f4d976acb685f70a5dbaa653b84a64f36da38ae954eb1": 1, - "docs/audits/CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md\u00004ba9d25937a9ddaf4e4ac7261baf578b93df8dec203239f2436e2bea852f3284": 1, - "docs/audits/CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md\u0000d81a9401e6358e8ef4e1d9a8799d0d36ef904456015176d900e66712a5d5d4e3": 1, - "docs/audits/CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md\u00007089803ce571c1e30a0d8e9cc70036a29dda71b709aab00560ad714118286ca6": 1, - "docs/audits/CUSTOM_WORLD_PROFILE_MAPPING_AUDIT_2026-04-18.md\u0000798729f12c0ba12bb90570fe70923c9d038661886f5021acecb12fae54511cf4": 3, - "docs/audits/CUSTOM_WORLD_PROFILE_MAPPING_AUDIT_2026-04-18.md\u00003c22e30f19a83566f84b708750e3981c85a7dcfa8a5a780afc1517a315907598": 3, - "docs/audits/CUSTOM_WORLD_PROFILE_MAPPING_AUDIT_2026-04-18.md\u0000983a7434d67243336c4a732ec606cf84e65681e80491f210125200720beb612c": 2, - "docs/audits/CUSTOM_WORLD_PROFILE_MAPPING_AUDIT_2026-04-18.md\u0000d4097c649064b761c524ec17e5f2015a2d61b67fab204a463c9931935b396765": 1, - "docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md\u0000af5fe0565379c60a6b416068beb89c6ae896b392d0a38bb481d2176cc9586ac6": 1, - "docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md\u000063796956b89cae3b30a99d476d048ec0477b92845557a8dd2fc4ec5720e8002c": 1, - "docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md\u00002c4f253a5bf31927c52386f2fd16af7a4d26c58a2d43c3d476a8b7297b45e7f7": 1, - "docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md\u0000c6744966fe0f580b4073a8572669a5f99821419579cdc22a3277ea025460fb7c": 1, - "docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md\u00007670b226e1e15a4c154bb701832db0f0274b1aaa975046e71583e3c66609caaf": 1, - "docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md\u000012877451d0dd422a89860a9b59fbf3ef76d4575e1fa3dce85b9f3d335b809a43": 1, - "docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md\u000064179846bc43e6d04bca8eda15c62fb7c1704423946197454eb76d94fc0c590c": 1, - "docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md\u00003c7cdbae6b42ddd73b0beca3da6098951d7c8ed3732655bb480a7ce49e37e9e1": 1, - "docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md\u0000516f72c0495482631a1f25718adca05ba890bf786698013f9a36d0f1fa5aebe8": 1, - "docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md\u0000c5da60a1898bb7af987eeb7adf6bcffc761fbb2aa318978db9883783a79a1e58": 1, - "docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md\u00006d09ccd01829378040ab5174d3db2ea5d163da7fcdc78b00e9d7e9320336728a": 1, - "docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_PRIORITIES_2026-04-10.md\u0000f04c68d32e7e385578e5ec0b4e83e038e255e568b5afaed5d4efd4f57162c3ba": 1, - "docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_PRIORITIES_2026-04-10.md\u0000d53c33e2125bac0ee9d7b7d4a46c4fbe9a20baeef17d9a1b3d3a9f9362e76eb2": 1, - "docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_PRIORITIES_2026-04-10.md\u000021c5408e8583ec937c1b2e6f817c7391868ac718f7b01e566afd93ed403f0b32": 1, - "docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_PRIORITIES_2026-04-10.md\u0000f643ebda56ddd1d89bb277ad77db87843c794f8888a4852125aaa17e47ccf40c": 1, - "docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_PRIORITIES_2026-04-10.md\u00004991feb765eb926b18d3f8b6bbaf75f655cf3264b0eca74cb29c2abac17d9d5d": 1, - "docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_PRIORITIES_2026-04-10.md\u00001fb3e602b2c7ac87b54580fa4f4f1fe6c42bc4b0223917750454e75be69822b1": 1, - "docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_PRIORITIES_2026-04-10.md\u00002c39f76cb38df67cfa34769a9d72cf7c8edee9e26279a14ff20ac98d40609be1": 1, - "docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_PRIORITIES_2026-04-10.md\u0000589f47858719929bca101dd3839921999d2f60e50fb1ae4ef093458bc93e9c26": 1, - "docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_PRIORITIES_2026-04-10.md\u0000b117d37b95957a218c3a05f9ae4a1ab1a7d4ad116fff7d093b1729c707af118a": 1, - "docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_PRIORITIES_2026-04-10.md\u0000366ee372ba32a0ef02312969dd368ddd160cd528a9e9fb47a1903951c0962bc9": 1, - "docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_PRIORITIES_2026-04-10.md\u0000807e68ab8a82cdf9ec76e553033af5abd6218b2c35ac9d52ac4a876fed22e0b1": 1, - "docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_PRIORITIES_2026-04-10.md\u0000a16387a2480116743883402650c6df05b6dc30e7cd2a705d1e8962473a28b0b5": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md\u0000b208df007248684f45ec2bcfb4ab94130dc93f6c7d4be1bfe7bd3da0d0e20c52": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md\u00001ca82826b746b1b647f28c8cc289e4a86a3581fc150ee189ffcc6258fcd4fd66": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md\u0000c4353e26a96d3b77712ee4b4e64bcd3721fce67383077b5e4b9d457f7a4e31ec": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md\u0000b2747ebfee148b1645d05e9cdadb01434fc5890fc8a93da17b72788e6ce901a0": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md\u00002b7f9cd382f767f6c16eabd45824e809b77d3b0456d59d08ce21e17068d8a205": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md\u0000355d2bae2b9ecb450d2b14873b1a873bd99da84f48fd68d2ebaa51676fa29b6e": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md\u00007900a0da38aa888756cbda21c02d3d4a53e1388246a348ea991deb7b1933443c": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md\u0000b6e595a3895919c3ea6c9a51e845292061f75bd296418f10664d27f68f4358ec": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md\u0000293f0c58a4124c2d4ba91378089c94d64dd12e6c7f4aafeaa56bef3502f59321": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md\u000015a7c0fd879f31cf8fd3af5c663de6cae0ee3dcc5dab3828cf580ad661fc640b": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md\u000012b5a8c6a51711671a7a01a4ee9223caa8e0a6ef12b7dcb3e9d1f75f967bb5da": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md\u00008eb3a31cd79402ff162c569d2a5cfc0411d0f6097ae76252e5110ebadc030200": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md\u00005151dc4786d1b7d49ca3c81d5b3f6b9558fe2a01599fa52583e100b120a108ad": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md\u00008c39b82b5b65ce713d8d0102e169b31eed96a5d95e66a880bfad3430fb2e90c1": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md\u00007fbe98eeecbec2363b330a96bee24474333d7a97271ce1213f63a4978b5fb297": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md\u0000f9afe7dc078db37d489590d227027b1dd44b974e7f759f67227190a461052eca": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md\u0000b8028a259a0ded9a7009d67fb4dc00e7c915711e1af061b9df2032e8ef118493": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md\u00008e310b7a56e477bd9a0c2bca7a50c8987f446a9939b41444a30872bef84d703a": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md\u000021a4e6ae0c550fdb27c4f8768bbbc2ef6f2caace11d11580c5e191cf3499b735": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md\u000064d125677a717a6965f7166b94f145499a2cf17eed9b7c4e876d8b5650b2ed50": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md\u00007696247562cd0333e2320c959812fd8268af68694adb9c821ccb45090de58622": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md\u0000c8a26e6d2fb41f6ee54abf07f7287866f0923417062fa14c29e9e4fc1e47b8cd": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md\u000009e6c83700546b97245ec4d359a4ce017c41b3d54b421f5758363e626d1145b2": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md\u0000735e1e873c1115dd1202d7ee2aea8711a14691fcb5a225243cddfbf92f96d9b7": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md\u0000185da8222c46fae32244c99861a8dd40c48d16c502285fd5a53aaf80dcaf0356": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md\u0000478c6cd574aad56d1ecc8ef251c0f1dd720c16bb23335b914fc6fd93f0b75d1d": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md\u0000a392bf345b68937165ab9eff9a243e9ad3a974d37a6c8019c6969ad86041a0fe": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md\u00003995b9eeb17551721f1e0e80564e2c44dec1f68600f4977a02352ccc6e59a48c": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md\u000074298c876947d0de26a056616a03f31e72975076bb4796527c1d4964d16b17ff": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md\u00001a7ab1ff027172af6d430532013183f74b2d0c2cc185345339567989f537f0a1": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md\u00002ea67e634f7fdae9af0b6f0b06300d4021cb82f7ac78846b6f75fcf11a6edcdf": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md\u0000ab3ec10e3001aa28e3f67160f096d6497797ec4058401ec425e1015a08eb1f5f": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md\u0000da4451019f56ea613b93875253a74140fe3ffdfbafe793ac8c95b39ad9506394": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md\u00006d6d391a80d5fb88950f0d1682be32490dbb8940c6c27b1dfedb27edb7006526": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md\u00008a3740bf40fea0e4a9c38d9d0c794587fd6235725207a338f6e3afbead3930f4": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md\u00005573a6396e46fbfd77e571595cc16eee75f37398c8737f21d979eff9a2992e45": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md\u000059386071108df861b2828b4da60aeff213a2dfa03effaff9ddd3cc4c429f46ff": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md\u00006ec2d417f858a947ca97f93ffe8243543868e78222da026f36ef3b404a09fca0": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md\u0000b9bc40c276eea0f67fef74359d72469df870828a2f50466b40a77dbc2f798c25": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md\u0000808d9e46ac7b4eefab37e25a9ea7983e5b04dd7cb4428e25a7d06a600ffc9476": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md\u000009f5dd58bd8a1e7675be6f252c352eff73b3c307df2f9f73606eb996f7caf75f": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md\u000035ca974c2fb82746c44c2420340dab21694156b006a7a604a9ab2205eed38139": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md\u0000a5dcdb6ccbc9cdc7307526eb08d66adedf1112d54eaeee39654f063e4e379b36": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md\u00009fb04afc744b7ccbbf4800e565cf09f166161d4cc1413a49fee26e1774871340": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md\u00003dee6a92bd57ce2065d81cc59f61f7468c6c0ad97494b6a45ffe20e336795394": 1, - "docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md\u00009142bf6a0f901ccdeb54204c0179eb45da1026299b16e2e769d770c52022fd5b": 1, - "docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_A_2026-04-21.md\u0000e372023824041dc37925fa663fa9b99904aceafbdd14ca8ddca8d07f8875ffae": 1, - "docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_A_2026-04-21.md\u00003e2536aa770fbf7f8d760665dfb728a9546f1f810b388a1d60bd7f78ec6000c2": 1, - "docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_C_2026-04-21.md\u0000a8bf258368f76607cd6dfa3ae023a2c9edcd6a2045fcba0fe3fc3589810d98c7": 1, - "docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_C_2026-04-21.md\u0000fc8ab1297cd7ac2e646d18c91205d039fce0b8f744d483073191fb473288a755": 1, - "docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_C_2026-04-21.md\u00003d50294567429d4b54227d60bc74725f78f37ba535092835fd4b773fdb53e80f": 1, - "docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_E_2026-04-21.md\u00000ae42e113282fabcf0e184db04db9fbb96d310edda8b0cf4330e1b75f0560baf": 1, - "docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_E_2026-04-21.md\u00001928d3a8b72da9de47b84f8aa7cdc403c3ee614ff429b02e89e4a6ac0d20857b": 1, - "docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_E_2026-04-21.md\u000022ca70ffd255d8ed549b1967ce71226a8fa17944e762f60934630afd98a66668": 1, - "docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_E_2026-04-21.md\u000011f398dc3b807fc1aa5a10d0135d676ef65b914ca866443bc66a1b1baa30463f": 1, - "docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_E_2026-04-21.md\u000054644338eba439b224c3302c4ad25f9089ebe3f4c43915fb956e65f9582cdb59": 1, - "docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_E_2026-04-21.md\u0000bdedff5be7c77793631cd6b9dc564cd119012d46c3169f0183478953887d61be": 1, - "docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_E_2026-04-21.md\u0000abf2f9a1eb37f86d9544ec00d7e380d22db758cf4e2305adc51d95e4ac6cc8cb": 1, - "docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_E_2026-04-21.md\u0000b3fd3c3e443f1658ddca6dfb36b59696d562a4ac40c7d8fbe0df51f610074389": 1, - "docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_E_2026-04-21.md\u00008cc380dcf6c07b66d1511990e1a725b5cdf9d4af70d2ae080f16aa0408adeba9": 1, - "docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_E_2026-04-21.md\u0000529a9ac786c59b2d808349322f8574db2a7c3f1b59a728edd244750795035cc5": 1, - "docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_E_2026-04-21.md\u0000860006064cdde91ff49033d9328d246a375c934b1d66024260bfba6cc8607473": 1, - "docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_E_2026-04-21.md\u00008e95ded5d3762570147180d018f744ca2d75a12adf025c3d4b9fb177b60d4d72": 1, - "docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_E_2026-04-21.md\u0000c5b699573355b7fb2c0756829a7b822b77cbd5ada7705f171699f5291b2ab303": 1, - "docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_E_2026-04-21.md\u00006c464a89139fb709c66f07a18c6e50f3acf583faa0c60b6c2482fdf7fd253cab": 1, - "docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_E_2026-04-21.md\u0000917993c45af22929249203760cf001759a239ecd9d34cff8078cf87ad95f2516": 1, - "docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_E_2026-04-21.md\u0000c8a5874cc50e2715ceda384d556d77e7f58171a1388ec9a99dea57e2b2c0c7db": 1, - "docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_E_2026-04-21.md\u0000df94764534ab14ab4dc680a60cc4a00694caf611cdce2cda3e18623f911df137": 1, - "docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_E_2026-04-21.md\u0000e04debcb85c439f60e376620dfc61d63bd8aeecfec4cc58d6634fb3e0be21d4c": 1, - "docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_E_2026-04-21.md\u00005f52db01e77c6173f84c1a1c5eac7ac9c5ab038604a3148fd8714b9dfc06a7e2": 1, - "docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_E_2026-04-21.md\u0000f6dfea69c78ac01a682f09b3b285e56bd09773bcd2b86e258e4bed5e1d91fb8d": 1, - "docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_E_2026-04-21.md\u00006aea2761001efef6b91d5f57744c5b3464bd04b3c10d39d3e31b098d656e8a4a": 1, - "docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_E_2026-04-21.md\u00005637ce6e89b1f07bb90140b296560acb1772b81563932c90ce83ea0ebadaad61": 1, - "docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_E_2026-04-21.md\u00009bd5bcae2afcd4e7827787d29c58781712bc324ad883fd245ef932b14fa75d08": 1, - "docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_F_2026-04-21.md\u0000800b93fef1004b5df81e094dd3c6ccdaf4512982da7ee129590451f902169333": 1, - "docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_F_2026-04-21.md\u0000726732dac2de1e867e3b621dd06d7695fb3c03382de5cd5bae18f285255bab18": 1, - "docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_F_2026-04-21.md\u00001d66ca21b7fdfad2fdbe8ea2e571f866f47db4379b2882d34b45b3d86a660767": 1, - "docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_F_2026-04-21.md\u000052e0ab915078aa95ee88fd90fef2d5633312b2fdb3576ba9fae8e4ee8b61377b": 1, - "docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_F_2026-04-21.md\u000096e9a130bffd814eb952a831b13830516c82db25cdc8818f49a626e9c9eafd5b": 1, - "docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_F_2026-04-21.md\u0000b2b3e2e3864d1173144239caa99f52d6eda385ce1fc180e529949b698a87d98e": 1, - "docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_F_2026-04-21.md\u0000053bb70f8ceed6dd8ac384b8ea266faf9d225816d95e1f83f3b5e021c6261456": 1, - "docs/audits/engineering/FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md\u00007d379302b9ed1690e31b2731d2833bab81d94c4e8583b6b06dd9da0e38cadd71": 1, - "docs/audits/engineering/FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md\u000000532658521e6db306703dcb98c679524175a14eda9a3b7d66c5ed76a387d9aa": 1, - "docs/audits/engineering/README.md\u0000bc0982e95a444cab723caea791b7ced86335fe5970f0b2f9b38f161eaa638847": 1, - "docs/audits/FUNCTION_REQUIREMENT_COMPLETENESS_AUDIT_2026-04-14.md\u0000521c8f33885c533de9a4c5079b16833f07b280d6faa2e543869a3c91b4b0e20a": 1, - "docs/audits/FUNCTION_REQUIREMENT_COMPLETENESS_AUDIT_2026-04-14.md\u00002b920b27a592422e4b02fa9151c158a5a3d2eab4f8652edbc2a504082e36d2e6": 1, - "docs/audits/FUNCTION_REQUIREMENT_COMPLETENESS_AUDIT_2026-04-14.md\u0000795c38432ea566a14cfe677ea4ba92cac69bd745d5a0f5399d2f99beea8cdf96": 1, - "docs/audits/FUNCTION_REQUIREMENT_COMPLETENESS_AUDIT_2026-04-14.md\u00009eb5b944d06ee75016b2cf39deafd6cee434a4ebad79c5405f0626a7c7260cb5": 1, - "docs/audits/FUNCTION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md\u0000521c8f33885c533de9a4c5079b16833f07b280d6faa2e543869a3c91b4b0e20a": 1, - "docs/audits/FUNCTION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md\u00002b920b27a592422e4b02fa9151c158a5a3d2eab4f8652edbc2a504082e36d2e6": 1, - "docs/audits/FUNCTION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md\u0000795c38432ea566a14cfe677ea4ba92cac69bd745d5a0f5399d2f99beea8cdf96": 1, - "docs/audits/FUNCTION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md\u00009eb5b944d06ee75016b2cf39deafd6cee434a4ebad79c5405f0626a7c7260cb5": 1, - "docs/audits/FUNCTION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md\u0000361645b1d4313448ed9504d412ef862fd96587b6bae7a10d4fe1e9ce78c2764e": 1, - "docs/audits/FUNCTION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md\u00002d58689f446b45cb030c75b67e2f150804c7f03e9f174281fe4d0a38ed02e073": 1, - "docs/audits/FUNCTION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md\u0000d8133456e51929d664c481897143a9b2e63baf93ae5c4b8ab8add45e7380e088": 1, - "docs/audits/FUNCTION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md\u0000aaeaffb7c4fed2fecebb41239e49bf6ce5422c81e9972c741e9a974b7d4aa76f": 1, - "docs/audits/FUNCTION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md\u0000ad7dd5f62871fe7beba20f68fd3c635f988742adb01fe3d3209481c0d8d623a4": 1, - "docs/audits/FUNCTION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md\u00004ad738ce40f95fd8d5b62f9f3cc6cbd697851f80b5a6df7c098b540af096781c": 1, - "docs/audits/FUNCTION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md\u00004096e8a2d46fe783d4737c1864de51d81530634f2bc1c4f7fef22347dcde0800": 1, - "docs/audits/FUNCTION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md\u0000d7b829a6e0c0938a9cde34edb9a65f6073b79bf48a7d7e5575e5a030dec1cd32": 1, - "docs/audits/FUNCTION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md\u0000cceb2748acef6de3bab9bf6927730f3d23588a006ab3ab728b529911e9614be5": 1, - "docs/design/LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md\u000011510d45472267bacc628ef62e08243f17fe81be28b8feac2f24179afab5300b": 1, - "docs/design/LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md\u0000b38594661f621533c89e713de7c4fa6c2a8a0ffa5c2183adcef8aedf43ab939b": 1, - "docs/design/LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md\u00003fb7f7bc226fffdb60334fc68dd168893caf04ead2abee87a7b51b0fa089cb0a": 1, - "docs/design/LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md\u00009cae9e7c752ad49139623b05caf17dc1e817c8fde6f0992c70b6237c10548236": 1, - "docs/design/LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md\u0000bee35a59b96a30d04ff431d6b174d4fa43e90f4547bf4c027a3e5440f3e283ec": 1, - "docs/design/LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md\u00004bc2f286cc88fad84710b61fc3d4aec8a1c4de0178060e1672915f0086ead66d": 1, - "docs/design/LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md\u0000bd7b2ce60ecd0802151a57e30508667c4c4edd8e6faa6616590bb1a13d775297": 1, - "docs/design/LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md\u000010fb105a07e8e5a04b278b730fdf3272dfbd7385128edca05642fa950d602713": 1, - "docs/design/LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md\u000058838d84e0c18eab395ad01bdaeec6383d11c4219d08c2e5f912a1a2fa4411b4": 1, - "docs/design/SCENE_CHAPTER_BENCHMARK_GAP_AND_AI_NATIVE_EXPERIENCE_SUPPLEMENT_2026-04-08.md\u0000bed22e5361cf93462ef723014282563216eb38952db39e4681ab8aa1dfe99b42": 1, - "docs/planning/CUSTOM_WORLD_PROFILE_MAPPING_OPTIMIZATION_PLAN_2026-04-18.md\u0000d3d40ac12e6d84532f496a45e54084077de0aeaf673e4a818ee558688f7cae64": 3, - "docs/planning/CUSTOM_WORLD_PROFILE_MAPPING_OPTIMIZATION_PLAN_2026-04-18.md\u0000ad8d737b6c5616b35f5554208e068c830b1273a276913f9c91d4cad2b86875d3": 3, - "docs/planning/CUSTOM_WORLD_PROFILE_MAPPING_OPTIMIZATION_PLAN_2026-04-18.md\u0000aebdcddca1821b6e3462a7b47d6767073a62130473c12ebfb274c8ca0751f829": 2, - "docs/planning/ENGINEERING_DEAD_CODE_AND_HIDDEN_BRANCH_CLEANUP_PLAN_2026-04-21.md\u000064179846bc43e6d04bca8eda15c62fb7c1704423946197454eb76d94fc0c590c": 2, - "docs/planning/ENGINEERING_DEAD_CODE_AND_HIDDEN_BRANCH_CLEANUP_PLAN_2026-04-21.md\u0000fb993463445c772d83b781570910aa69d823181d5fc4370817580772137ba329": 2, - "docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md\u0000e4d048462697c6c73db23a8d72783e6a411d2280699e7f851a1da4c14b18aa6d": 1, - "docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md\u0000d9068a2b7360687b20cc86359e95c72ab1b22629b94d777572ebd782d6b8bddc": 1, - "docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md\u0000e4ae0d2ac489c7ec1d4a15b6c8f4f6be6740d6ad41bd504935fc26ef27882c2c": 2, - "docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md\u0000ae8483419d10f77e64c61612fad63cbca329edae2d20d6399e67339db59ab81c": 1, - "docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md\u0000cc0769b050a6002bc698c3936ff69b7d00c967618e2b0d0c3f913bf9d67f8e4e": 1, - "docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md\u0000152328ee302e400e62a042df24a009268cc72ac7fdaa6038988958063f14b888": 1, - "docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md\u0000c71e3572f198ca9f842a1f17cc845d8578e686bd48169ffc7456b8d5a43a3a86": 1, - "docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md\u000077f7c673736b732a89e86696635a05a9c647fbc48a720960b2189a821db6809d": 1, - "docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md\u0000789029ae2ec56eeb8983a37a393b3c2b241713e3c6023199a07e1cbc2bf25522": 1, - "docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md\u00006fb7e1246b06ad44e775ac264c97d4f0c098884fb810fffb2fb2ca439de53dfc": 1, - "docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md\u0000fcbe6fcdd0986da1cde4dc12f95c030bec908871cef4613d6ef4e025860d868c": 1, - "docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md\u00005aab5e47b6c78beab3d3b957597d92b5c324ba4c6280732cc09d625e2bee3584": 1, - "docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md\u0000fafac9e1779d6c47a66f7a1335a44b6dc587238ee29ea7a797ce2037ec4a0d61": 1, - "docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md\u000000ee385c5ee19044357b68561d180ff7f494478c7635ece0bef178556eed2435": 1, - "docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md\u0000ed07d1646c752c9e6c18cb80cb4e4a387a2ee96d5ab18661d9c015f95e0cbe5d": 1, - "docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md\u0000132dddf2b2356bec3b88873a7dcc12b037bf193d49051bbce031390a1fc525bf": 1, - "docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md\u00006e50eef4c4860ab72031fdafc2dd40e6680462103d9d9978efad07806a3fa3f9": 1, - "docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md\u00002e1d96dd81898ce5968e46c0696a531f2dd067e1d6de887f386af791848bf735": 1, - "docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md\u0000cf06fec5e5e97390b7edcb0b8a7b1c75fdf3a9774909b927c87fe4239c573a9f": 1, - "docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md\u0000521c8f33885c533de9a4c5079b16833f07b280d6faa2e543869a3c91b4b0e20a": 1, - "docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md\u0000e205cd5d32408785ab6a465a1163c8ea3e4c2692f06690f0b2e99c7a2cf7d4fd": 1, - "docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md\u000008d99990832ba07e6740ba9cd02fdd0c25a7aea31977dff4cf21e4996a0d2ee3": 1, - "docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md\u00002b920b27a592422e4b02fa9151c158a5a3d2eab4f8652edbc2a504082e36d2e6": 1, - "docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md\u00009eb5b944d06ee75016b2cf39deafd6cee434a4ebad79c5405f0626a7c7260cb5": 1, - "docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md\u00005c37ceb1dc182197f4ff420b86c29e2ac56ded5efd5e08daf05ddaaf301cedd5": 1, - "docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md\u0000795c38432ea566a14cfe677ea4ba92cac69bd745d5a0f5399d2f99beea8cdf96": 1, - "docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md\u0000f9afe7dc078db37d489590d227027b1dd44b974e7f759f67227190a461052eca": 1, - "docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md\u0000b8028a259a0ded9a7009d67fb4dc00e7c915711e1af061b9df2032e8ef118493": 1, - "docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md\u00001563649ec67710a8baced44efdf8f5703ca66519e16bd1917cc399add06a8e9e": 1, - "docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md\u000020f57f8456434f058b7a9e4c4940051414ea01e3f6b1a40b4331a44e040994cf": 1, - "docs/planning/EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md\u0000879344dbc4cccb352e35db66cd6440c4fa8c1e1134a013c4abfee2c0a64938f1": 1, - "docs/planning/EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md\u00009865d20c245fb4546fcbb171120fae68d8dac6945ff87f499f4568933a7e1efa": 1, - "docs/planning/EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md\u0000e7efb52435f42eb9dbe00f99aa599de59ee56538a36e3e463907ab56d1ca349e": 1, - "docs/planning/EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md\u00007ea4c84911c447cf9537182c116f77acd61fae7e55795f5f3bb2d786aae6e187": 1, - "docs/planning/EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md\u00001078021240c46c5e3f25563e7fca1db769c647f1eb75143c81a461aabcdc38f3": 1, - "docs/planning/EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md\u0000c9500dff8400a116a98d19feb6f5b96c7655ea9cbfd30d062ad98fd0ee2497a6": 1, - "docs/planning/EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md\u00005bd1ce06e9959f5730b87716abb0671278cffb30a5c8a7387ded119860936428": 1, - "docs/planning/EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md\u00004a0011c405caa264d929f484247a0bb47c0ec650be2665f8bb4ed5291f75e1e9": 1, - "docs/prd/ACCOUNT_SYSTEM_AND_LOGIN_ENTRY_PRD_2026-04-09.md\u00004aae14cd919ad3f9ee9af23c68ab86bfbad32f03035bcbb9a8dd381ce118576b": 2, - "docs/prd/ACCOUNT_SYSTEM_AND_LOGIN_ENTRY_PRD_2026-04-09.md\u000062441bcdee1f836b8b69a325b031b5619252e790ae501ce12a05f6bd5548ed22": 2, - "docs/prd/ACCOUNT_SYSTEM_AND_LOGIN_ENTRY_PRD_2026-04-09.md\u000018044b6302ea7e25a0cae1852203168eec2a662edfdb5ae1974346607039f873": 1, - "docs/prd/ACCOUNT_SYSTEM_AND_LOGIN_ENTRY_PRD_2026-04-09.md\u00000db9b9da879ac11eee6a09f2587761d765e4b03031059a325120a60963611105": 1, - "docs/prd/AI_CHARACTER_VISUAL_ANIMATION_MVP_PRD_2026-04-04.md\u0000b8028a259a0ded9a7009d67fb4dc00e7c915711e1af061b9df2032e8ef118493": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_BIG_FISH_GAME_CREATION_AND_GAMEPLAY_PRD_2026-04-22.md\u000018097d5eb05e8232c8cff02908b6a200768e8e251b926e08e8bd86d14d015318": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE1_IMPLEMENTATION_PLAN_2026-04-13.md\u0000d9068a2b7360687b20cc86359e95c72ab1b22629b94d777572ebd782d6b8bddc": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE1_IMPLEMENTATION_PLAN_2026-04-13.md\u000073963a094a46e066dae72eb1cf0d342cacb7d57363c8a7592f1138d260269dad": 2, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE1_IMPLEMENTATION_PLAN_2026-04-13.md\u0000ea879ddfe019a14e428a2d4103d01de687b9f571ec83619f45f059524eaa5e84": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE1_IMPLEMENTATION_PLAN_2026-04-13.md\u00002497413ef5d54ada799156f80274ac857d24cbfdb6b40b62adfea369be83d570": 2, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE1_IMPLEMENTATION_PLAN_2026-04-13.md\u0000ef564f7b3d27a584f95ce1531cf306145eb0a6bb5f070aa27c2443377dfe0cdb": 2, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE1_IMPLEMENTATION_PLAN_2026-04-13.md\u0000711cfa4d6d8c3bd294d296888b2598d289f1c20ab81f8208bd9d9c78e8023959": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE1_IMPLEMENTATION_PLAN_2026-04-13.md\u00009ddfeb8840883dda7821626d90a56fd591d8fc1fcbe1b5e4454bbad6e99919eb": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE1_IMPLEMENTATION_PLAN_2026-04-13.md\u00005748db249b754e554926ec5540f739429482785084e95c5051c054920fcadc53": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE1_IMPLEMENTATION_PLAN_2026-04-13.md\u0000f9f2cccc05600b591aa4b61134818ff158eb38c91e5296338d7a46927185880c": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE1_IMPLEMENTATION_PLAN_2026-04-13.md\u00007dbf1033c5c0ee25139572fa3bf541772540dcf6a8c1a315e57059433a997070": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE1_IMPLEMENTATION_PLAN_2026-04-13.md\u0000d6c8e2eb2e967bc80666e6e47d9560ea06dc4207f936c98af3220d9ba8415e42": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE1_IMPLEMENTATION_PLAN_2026-04-13.md\u000067e508b67fa5c218c07d14cc1818d252bcf26704a9db6dae49ce0ec5121abea9": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE1_IMPLEMENTATION_PLAN_2026-04-13.md\u0000791d439035244705a6448287cae219a675e471ee489cf3afc4aafbb8538c17b4": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE1_IMPLEMENTATION_PLAN_2026-04-13.md\u0000b404005a6fd11eb75522494521c88374c2ace5c461bd6f3c01bad477222e8dbe": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE1_IMPLEMENTATION_PLAN_2026-04-13.md\u0000dde2a7a524a217acdf4ba76fe624cb658e6f5c2325ffe60b6cb98f2b15cb9527": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE2_IMPLEMENTATION_PLAN_2026-04-13.md\u000073963a094a46e066dae72eb1cf0d342cacb7d57363c8a7592f1138d260269dad": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE2_IMPLEMENTATION_PLAN_2026-04-13.md\u00002497413ef5d54ada799156f80274ac857d24cbfdb6b40b62adfea369be83d570": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE2_IMPLEMENTATION_PLAN_2026-04-13.md\u0000ef564f7b3d27a584f95ce1531cf306145eb0a6bb5f070aa27c2443377dfe0cdb": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE2_IMPLEMENTATION_PLAN_2026-04-13.md\u00001e78c232c905d2c3e1d78366141d60266231cd784e5d06bf34e577bcdb4af660": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE2_IMPLEMENTATION_PLAN_2026-04-13.md\u0000a212c45e1a0adc7c2a175a2390e43fd11bde65cae1312a7ff1c2a70cfac19cc3": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE2_IMPLEMENTATION_PLAN_2026-04-13.md\u0000a1de2ff15475b864f29d51a327bb4abb00804931e6fff502758b559f4c4e64a3": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE2_IMPLEMENTATION_PLAN_2026-04-13.md\u00007ab1406b5470742d610db05fef45a820465e4b15698701cfc5dde0f068d5476f": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE2_IMPLEMENTATION_PLAN_2026-04-13.md\u0000d15a6d56ab5bacd51cba61cc33c75110b60f0c23eae46c6fb96ac6420df70dc4": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE2_IMPLEMENTATION_PLAN_2026-04-13.md\u00009fc34066e4ead77690955a7e29ee7aa90cb0074e8c8973efbfeda395a654d2fa": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE2_IMPLEMENTATION_PLAN_2026-04-13.md\u0000e4bdaf704fb465f282388a7793994a6b22101d250c5b65a2ce034607570d8340": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE2_IMPLEMENTATION_PLAN_2026-04-13.md\u0000e0af4079eea14486f8f0c6f8c5fdb7fb2efc6db639cca0adfa112a6d2009d5c6": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE2_IMPLEMENTATION_PLAN_2026-04-13.md\u0000762a252250aa2550c0c083bffb516d2331c7a75f74943f546bb3b59d005825ff": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md\u00002497413ef5d54ada799156f80274ac857d24cbfdb6b40b62adfea369be83d570": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md\u0000ef564f7b3d27a584f95ce1531cf306145eb0a6bb5f070aa27c2443377dfe0cdb": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md\u000073963a094a46e066dae72eb1cf0d342cacb7d57363c8a7592f1138d260269dad": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md\u0000140398f428bc4ff7e8e83ca64729ab9925dfd75845377a051d39be26b5f7e229": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md\u000070e4e5cfdcf28570d76a66e9344030dad59ba33d634e483dcac02e4ccb2ceb6f": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md\u0000791e8f23cf4763066fc7bb70fe25fc5752b3d86dc72265ceb9f4464139cfa5e3": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md\u0000a47b710152bcf1d5d3b029a223372a72bdf5901081fd432ebbeb3fa2ac5f0079": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md\u000051d0ba57eb76f04ca5aa277d727db490a5f3a7ef22475aa85e21ae25f8e587a7": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md\u00001d6a28713ddcdc81e9d606cbfb7d744bb6dad1cc0ae8693bbb6d4d0d810b8f3c": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md\u0000e4bdaf704fb465f282388a7793994a6b22101d250c5b65a2ce034607570d8340": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md\u000067e508b67fa5c218c07d14cc1818d252bcf26704a9db6dae49ce0ec5121abea9": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md\u000078ddba9e867fdb2dd01ab4cf69813329c431e7d51b24627bdd5e35dbf6ec8a68": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md\u000048266cdb7e05df170ceec6720d44b881dfe1de6f724158acb70e9d69396725ba": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md\u00002497413ef5d54ada799156f80274ac857d24cbfdb6b40b62adfea369be83d570": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md\u0000ef564f7b3d27a584f95ce1531cf306145eb0a6bb5f070aa27c2443377dfe0cdb": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md\u000070e4e5cfdcf28570d76a66e9344030dad59ba33d634e483dcac02e4ccb2ceb6f": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md\u000073963a094a46e066dae72eb1cf0d342cacb7d57363c8a7592f1138d260269dad": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md\u000095b1af3956407beec3a977efcdaadc8abbe7c92f519fe214dd034bbe0994802f": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md\u00004415e39b98750bb303f852eba5a63516ad760ac2896f36a9f74fdbcde8d6aa2c": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md\u0000583b9c033f5fd3c7d4ea951c03b9d86f9dd15d6f14ad0a2254c721d9fee0e11e": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md\u00007283a5906f999b879ffe3d38d44a1b795f477189707ceacbb9ce98e1ee51db96": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md\u0000f73b865595102858971b30d466824986cab21885f19c368a43880d14a9db9693": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md\u0000467fac8bb749ad065c79ab65e4337b4f35ef082f4e0ec111b11e1a0cbd755cf8": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md\u0000bad20eb60188b8eb32bc34a10d044361faa52b7f595c504ab232648e19e3f4b9": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md\u0000ce6c208b11ece12ec24ab576a337afb15fdd3a554152ba8d7215379ddbe79d32": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md\u0000e4bdaf704fb465f282388a7793994a6b22101d250c5b65a2ce034607570d8340": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md\u00001d6a28713ddcdc81e9d606cbfb7d744bb6dad1cc0ae8693bbb6d4d0d810b8f3c": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md\u0000762a252250aa2550c0c083bffb516d2331c7a75f74943f546bb3b59d005825ff": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md\u000048266cdb7e05df170ceec6720d44b881dfe1de6f724158acb70e9d69396725ba": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md\u00002497413ef5d54ada799156f80274ac857d24cbfdb6b40b62adfea369be83d570": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md\u0000ef564f7b3d27a584f95ce1531cf306145eb0a6bb5f070aa27c2443377dfe0cdb": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md\u000070e4e5cfdcf28570d76a66e9344030dad59ba33d634e483dcac02e4ccb2ceb6f": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md\u000073963a094a46e066dae72eb1cf0d342cacb7d57363c8a7592f1138d260269dad": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md\u0000b4ed53f9cecbf30d83e3ee52952bce89c0963ee932ef786ff33c41bc37aa7d02": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md\u00000b443aa5f7cf9288f449943aeaf7b360ee30720475d61bf51e39dd6473879729": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md\u0000c4bd4fd83cd69aa5168a981ed5f5b83b4e5ab25ed3d6820ce62c4968d626ef91": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md\u000026e0d8e2e7b48a9c1fb2b9de9ed21ca1950408a537a516ffa658af802c548d40": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md\u000045233df048f6981ea148e4ebd0c45ac98b3b1aecbad19f71c29ffff62c9a9868": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md\u0000b6b2772bed8502b92b92ad8e0744e498b35ddef3b00e4a57edc91de239714ce4": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md\u000056d84ad2b493795c1ffa4a3f6344cbc41e3cddc17a640d475d4159c38ed712a8": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md\u0000e4bdaf704fb465f282388a7793994a6b22101d250c5b65a2ce034607570d8340": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md\u00001d6a28713ddcdc81e9d606cbfb7d744bb6dad1cc0ae8693bbb6d4d0d810b8f3c": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md\u0000762a252250aa2550c0c083bffb516d2331c7a75f74943f546bb3b59d005825ff": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md\u0000b404005a6fd11eb75522494521c88374c2ace5c461bd6f3c01bad477222e8dbe": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE6_IMPLEMENTATION_PLAN_2026-04-14.md\u00002497413ef5d54ada799156f80274ac857d24cbfdb6b40b62adfea369be83d570": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE6_IMPLEMENTATION_PLAN_2026-04-14.md\u0000ef564f7b3d27a584f95ce1531cf306145eb0a6bb5f070aa27c2443377dfe0cdb": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE6_IMPLEMENTATION_PLAN_2026-04-14.md\u000070e4e5cfdcf28570d76a66e9344030dad59ba33d634e483dcac02e4ccb2ceb6f": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE6_IMPLEMENTATION_PLAN_2026-04-14.md\u000073963a094a46e066dae72eb1cf0d342cacb7d57363c8a7592f1138d260269dad": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE6_IMPLEMENTATION_PLAN_2026-04-14.md\u0000f190fe3862bbf3c5485b704822caff42d75a3bc7851042ab451d903a8f5009c0": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE6_IMPLEMENTATION_PLAN_2026-04-14.md\u0000d9068a2b7360687b20cc86359e95c72ab1b22629b94d777572ebd782d6b8bddc": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE6_IMPLEMENTATION_PLAN_2026-04-14.md\u0000eeae4b3eac262e38a17784637740475d49b169f03d7d745441ccdccfb5eea05c": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE6_IMPLEMENTATION_PLAN_2026-04-14.md\u0000bee768da786c3061f426af2bd174314fed380fae1b35942babf50e56ed9b50b0": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE6_IMPLEMENTATION_PLAN_2026-04-14.md\u0000912b12d9715ba1d94d3e33370a6e42ed9376f7e53041fba338590fe1e51e856d": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE6_IMPLEMENTATION_PLAN_2026-04-14.md\u0000e4bdaf704fb465f282388a7793994a6b22101d250c5b65a2ce034607570d8340": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE6_IMPLEMENTATION_PLAN_2026-04-14.md\u00001d6a28713ddcdc81e9d606cbfb7d744bb6dad1cc0ae8693bbb6d4d0d810b8f3c": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE6_IMPLEMENTATION_PLAN_2026-04-14.md\u0000762a252250aa2550c0c083bffb516d2331c7a75f74943f546bb3b59d005825ff": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE6_IMPLEMENTATION_PLAN_2026-04-14.md\u0000b404005a6fd11eb75522494521c88374c2ace5c461bd6f3c01bad477222e8dbe": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE6_IMPLEMENTATION_PLAN_2026-04-14.md\u00007e79f4d08485c0a2dfea6535f9cf9bebc521be59ad99e65bb1da287285c106c9": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE6_IMPLEMENTATION_PLAN_2026-04-14.md\u00000acde37b834341b7adba1407b84ff444ea342b46fc74ce1b6a69b1ed8557c4ff": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md\u0000491510e8ba67094ad2e874540545e7328e7433ebce7a984fc65669ca77765c65": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md\u0000c07efb153bcf7b3bf9d1ed78504a8dcfa2016de7ca8e07b68ff5e3f2ca967065": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md\u000012eebd04c1fc436c437c2f27565f5a8b484b828258303098ea535868a4462ab2": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md\u0000fba914738cb406678c9966d22b49ab2e20c3137c5aee92081dadfe50a3d548c4": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md\u0000f9f2cccc05600b591aa4b61134818ff158eb38c91e5296338d7a46927185880c": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md\u0000711cfa4d6d8c3bd294d296888b2598d289f1c20ab81f8208bd9d9c78e8023959": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md\u00005748db249b754e554926ec5540f739429482785084e95c5051c054920fcadc53": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md\u0000de933ca0c3fb2cf67b1dcf19f7e6faa624d72e1cd53f5b13aafbc3db71ecad87": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md\u00004fc0ff8d08844730ef20c20afef8becfbb6446a0b320fb60c9d71bbe5bce19c6": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md\u0000b8cd1a4aed1e86d0889ff4a5748924b2b8913a5f094fc45d7b0aa014215db022": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md\u0000a47b710152bcf1d5d3b029a223372a72bdf5901081fd432ebbeb3fa2ac5f0079": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md\u0000132dddf2b2356bec3b88873a7dcc12b037bf193d49051bbce031390a1fc525bf": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md\u00002ad14de73e25fb1feb3aa889383204f5aa6bf959f29f6e7ba77e7091dc85dbf7": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md\u0000de16cef1a3c034639e172eee66dadd5db40aaf9cc47f01cc9aeaabff14719316": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md\u000026e0d8e2e7b48a9c1fb2b9de9ed21ca1950408a537a516ffa658af802c548d40": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md\u00006e0d856a35e5e05c199e3184a45f16ea83ed6c69743e404bd17b8bbde034cb81": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md\u0000d20efe70ce83b31ab9dca73475158c3f64cee6ff07bf0771d4daad3eefe8ad34": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md\u000036393522a35e654bd7d67bd6b6120c08ac9d432137025547db52842e324aee26": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md\u0000d6c8e2eb2e967bc80666e6e47d9560ea06dc4207f936c98af3220d9ba8415e42": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md\u000067e508b67fa5c218c07d14cc1818d252bcf26704a9db6dae49ce0ec5121abea9": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md\u0000791d439035244705a6448287cae219a675e471ee489cf3afc4aafbb8538c17b4": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md\u0000fd94a43ae96e171cd22359253310b63c06483ef41f9dc0321add4af5e0d9a730": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md\u0000290d254f221327a08758245ce5340eb953db27545f0fdfc46b9e25bccfe8797a": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md\u00007c58e7b8856b7e68b35b614e01ccbc028fa62929e411301ae75acd7055d92e49": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md\u0000d2c18f5945d2ce75d18b48ec71af8e7831eb8701a82a0145cbd96c785a7be191": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md\u0000b97dbfd7ba19711b7786bf381cc841f92d62510797aab9eb6b5b0907b81885cf": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md\u000020c4e06b00b3bf0910f97eaed1b90a893e9d93758ea3074ffc9ca1d417c313e2": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md\u0000a48d1e47ee01f186863d92f8340d6c4b5233c7f4b57f4f07f2efee66dad75494": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md\u000057fc6e2a85a66398bc22cd2e0ec13a86692b598d1309ac4f2bec3a9c33cc35ca": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md\u000005c0924303f5ccab58e71d2c1072ba5c77a619572734b6b9f0a6f0b63d9a53bb": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md\u0000083ccfb130e7778750db168dac1be0f6cb2f6676bd14282a3ca18b8bad7a40a5": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_EIGHT_ANCHOR_MINIMUM_LOOP_PRD_2026-04-17.md\u00002497413ef5d54ada799156f80274ac857d24cbfdb6b40b62adfea369be83d570": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_EIGHT_ANCHOR_MINIMUM_LOOP_PRD_2026-04-17.md\u0000ef564f7b3d27a584f95ce1531cf306145eb0a6bb5f070aa27c2443377dfe0cdb": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_EIGHT_ANCHOR_MINIMUM_LOOP_PRD_2026-04-17.md\u00004db392000c789e58a5331ba82c10dbdb7270eaf30a096e7ea1f43ef42ebbbad1": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_EIGHT_ANCHOR_MINIMUM_LOOP_PRD_2026-04-17.md\u00009bc31d73fba2d0324350698a2d3f6bbf5d7009056cfdeff44be6b191bcd0acd8": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_EIGHT_ANCHOR_MINIMUM_LOOP_PRD_2026-04-17.md\u0000bf275f41ddcc20f0851efe2345cd07a4f716bf2058533d6990ef33384e68915e": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_EIGHT_ANCHOR_MINIMUM_LOOP_PRD_2026-04-17.md\u000019176756c9c4ef77889e945584af9fa4973ad22e98321359dd409e7a40beef32": 1, - "docs/prd/AI_NATIVE_AGENT_FIRST_EIGHT_ANCHOR_MINIMUM_LOOP_PRD_2026-04-17.md\u00007f862cbc2d3703be4890079b0966e855fa996f1afa367a4eb65001c6025f0713": 1, - "docs/prd/AI_NATIVE_CUSTOM_WORLD_CREATION_HUB_PRD_2026-04-13.md\u00009ddfeb8840883dda7821626d90a56fd591d8fc1fcbe1b5e4454bbad6e99919eb": 1, - "docs/prd/AI_NATIVE_NPC_CHAT_SINGLE_TURN_SESSION_PRD_2026-04-18.md\u0000dde2a7a524a217acdf4ba76fe624cb658e6f5c2325ffe60b6cb98f2b15cb9527": 1, - "docs/prd/AI_NATIVE_NPC_CHAT_SINGLE_TURN_SESSION_PRD_2026-04-18.md\u00001f62acad4ad19f3b1191ebbc95493c7050246ed1f649b56444ec6805b9ae8c8d": 1, - "docs/prd/AI_NATIVE_NPC_CHAT_SINGLE_TURN_SESSION_PRD_2026-04-18.md\u00005446610f1bac7195f74b16d6bf92d768a0bc91af9e141c5388575be82d434f9a": 1, - "docs/prd/AI_NATIVE_NPC_CHAT_SINGLE_TURN_SESSION_PRD_2026-04-18.md\u0000b12bc9664466f9a95c4e4f60141e7cc52e3047b9d65d4c569fcc0280e324ec0f": 1, - "docs/prd/AI_NATIVE_PUZZLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md\u0000800ffc432bf717a5a87433d8106232aa02633664aab6fe12fc5518bf2dd6755d": 1, - "docs/prd/AI_NATIVE_PUZZLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md\u0000802b6afb4081048f4fea1fb9f56e58dc891fb43208bdc746430d07c7fbcab30e": 1, - "docs/prd/AI_NATIVE_SCENE_CHAPTER_GAMEPLAY_PRD_AND_EXECUTION_PLAN_2026-04-08.md\u000075b752254c16a54782f6cfd2d4499612881b7c045cbe0f0331e708b6ed3284c5": 1, - "docs/prd/AI_NATIVE_SCENE_CHAPTER_GAMEPLAY_PRD_AND_EXECUTION_PLAN_2026-04-08.md\u00000a715455eacf4fc4ca80e52da7066b43d553dad8b2949d741fb6e972b6ce7297": 1, - "docs/prd/AI_NATIVE_SCENE_CHAPTER_GAMEPLAY_PRD_AND_EXECUTION_PLAN_2026-04-08.md\u0000d9068a2b7360687b20cc86359e95c72ab1b22629b94d777572ebd782d6b8bddc": 1, - "docs/prd/AI_NATIVE_SCENE_CHAPTER_GAMEPLAY_PRD_AND_EXECUTION_PLAN_2026-04-08.md\u0000870821f94843d7d6ef8031a0728fcb03274d98ad56dfc60d3d9599c668b778f3": 1, - "docs/prd/AI_NATIVE_SCENE_CHAPTER_GAMEPLAY_PRD_AND_EXECUTION_PLAN_2026-04-08.md\u0000ecf98584795397149bbe60ad918864fe355e1c33f721aacbf739ed628e7f3b47": 1, - "docs/prd/AI_NATIVE_SCENE_CHAPTER_GAMEPLAY_PRD_AND_EXECUTION_PLAN_2026-04-08.md\u00000826253e65afb6d0b837b0edb526458d9c06113cf73476441067adba8450e594": 1, - "docs/prd/AI_NATIVE_SCENE_CHAPTER_GAMEPLAY_PRD_AND_EXECUTION_PLAN_2026-04-08.md\u0000a8274c044c0c1f7ea81fc320a6fe8e732578f8284cedf0e897b014e0ba57d501": 1, - "docs/prd/AI_NATIVE_SCENE_CHAPTER_GAMEPLAY_PRD_AND_EXECUTION_PLAN_2026-04-08.md\u0000edbc4c03ca0127edfd620dc7a14ed9fc3a0c0d37aaf3cb40bcd40e6193aefb62": 1, - "docs/prd/AI_NATIVE_SCENE_CHAPTER_GAMEPLAY_PRD_AND_EXECUTION_PLAN_2026-04-08.md\u000010981c4ccf9cbb268733f620437b30366cf31dc9a0b12f24487db85b07322b81": 1, - "docs/prd/AI_NATIVE_SCENE_CHAPTER_GAMEPLAY_PRD_AND_EXECUTION_PLAN_2026-04-08.md\u0000c8736a82cb153f8e8eecedd142ec55698ada32be0448fbe616e69c77291b0522": 1, - "docs/prd/AI_NATIVE_SCENE_MULTI_ACT_CREATOR_AND_GAMEPLAY_FLOW_PRD_2026-04-20.md\u00001d6a28713ddcdc81e9d606cbfb7d744bb6dad1cc0ae8693bbb6d4d0d810b8f3c": 1, - "docs/prd/AI_NATIVE_SCENE_MULTI_ACT_CREATOR_AND_GAMEPLAY_FLOW_PRD_2026-04-20.md\u00004326517de02b2d3bac8811c81e8d11c9a4e0b1b031a1eb21c19efc69c294a596": 1, - "docs/prd/AI_NATIVE_SCENE_MULTI_ACT_CREATOR_AND_GAMEPLAY_FLOW_PRD_2026-04-20.md\u0000dafb274192ef13ffa0734946a9cf95ef5e77600083dd9cc170353c27c1a9495d": 1, - "docs/prd/AI_NATIVE_SCENE_MULTI_ACT_CREATOR_AND_GAMEPLAY_FLOW_PRD_2026-04-20.md\u00007f628f0c54841e9cc704b7802e537a39025cbd41834b6421259d1a28781086d0": 1, - "docs/prd/AI_NATIVE_SCENE_MULTI_ACT_CREATOR_AND_GAMEPLAY_FLOW_PRD_2026-04-20.md\u000027b1dd2e74d479990da851d75000d5ff08635db4629e17a666d0fdbb0bca0400": 1, - "docs/prd/TXT_MODE_CORE_GAMEPLAY_PRD_2026-04-20.md\u00005d8ddf4bec4e2dad46b05f07373b1dea9803c7622e56fbd1da2287dbe05f7821": 1, - "docs/prd/TXT_MODE_CORE_GAMEPLAY_PRD_2026-04-20.md\u00006f3779dccea09f6758ea5b1f8a431f5f056858549f8685817f80659f381885f3": 1, - "docs/prd/TXT_MODE_CORE_GAMEPLAY_PRD_2026-04-20.md\u0000dde2a7a524a217acdf4ba76fe624cb658e6f5c2325ffe60b6cb98f2b15cb9527": 1, - "docs/prd/TXT_MODE_CORE_GAMEPLAY_PRD_2026-04-20.md\u0000ad7892fbbeedfca860f48d7bddd80ecdf5d836c28b3d79a384b2a19c5a8717ad": 1, - "docs/prd/TXT_MODE_CORE_GAMEPLAY_PRD_2026-04-20.md\u000042567f5a28a493d571669d443d8fa2f7d173e7ffba037197bd0b58f626427648": 1, - "docs/prd/TXT_MODE_CORE_GAMEPLAY_PRD_2026-04-20.md\u00008045c32279aeb43e89b93769d3182246aa3c8c2b305f1b7959990333aee0a234": 1, - "docs/prd/TXT_MODE_CORE_GAMEPLAY_PRD_2026-04-20.md\u000045eb2014aec2855c232484530cf0cbfe205a2e760931f8ada7604a8fccdf9c88": 1, - "docs/reference/BUSINESS_PROMPT_INVENTORY_2026-04-19.md\u00007c6587c7167c5ba7999c070f3322de05f59e8aa2cc99a9d3f00a124bbfee7e66": 1, - "docs/reference/BUSINESS_PROMPT_INVENTORY_2026-04-19.md\u0000d896be1247f3fd235e01da0945d751ec26cd3d2b1a8df8a15c8fd3ef42871a15": 1, - "docs/reference/BUSINESS_PROMPT_INVENTORY_2026-04-19.md\u0000dd4447a3a439c543e1c139fb6fb063425253e862a8fe60ec89a608ffd17d4401": 1, - "docs/reference/BUSINESS_PROMPT_INVENTORY_2026-04-19.md\u0000ca7cbcb7b956a4e0d907644d95b81e91695584d01d1c64c3336c4eb672cff666": 1, - "docs/reference/BUSINESS_PROMPT_INVENTORY_2026-04-19.md\u00005993aa66c74dedc485c3721386c235bcc6431cd16196e2c79bf1b23276ff9cce": 1, - "docs/reference/BUSINESS_PROMPT_INVENTORY_2026-04-19.md\u000090707f44e5aeb1e970ad8a8b93d0f52a39cb99add8b9bd5f09d4b247e8d9fa95": 1, - "docs/reference/BUSINESS_PROMPT_INVENTORY_2026-04-19.md\u0000c58d248aadf49bd78f6b18005bb162b003463940656a1c79538f2fc67737522c": 1, - "docs/reference/BUSINESS_PROMPT_INVENTORY_2026-04-19.md\u000041bae9d80a0afd1cc38ef5e2653d8226d5d2cb6822dbdfd44a21ace243032a71": 1, - "docs/reference/BUSINESS_PROMPT_INVENTORY_2026-04-19.md\u000096fedf999012a9d0b42e8ef289d82bee9578cff5bec6158558f67021ef543b79": 1, - "docs/reference/BUSINESS_PROMPT_INVENTORY_2026-04-19.md\u0000440bea12f20a7031cf2786d35c94ed09629a6b92edb04e38c91155694bf8f3f1": 1, - "docs/reference/BUSINESS_PROMPT_INVENTORY_2026-04-19.md\u00009bf2ebda032ad41af26044c4d82c3aa94380712dac135d36ca72748ddf55e4df": 1, - "docs/reference/BUSINESS_PROMPT_INVENTORY_2026-04-19.md\u00004641eff0ee56579a535c3cb3e82c1a9fa681f4ba865637602602ec4cb4f50f20": 1, - "docs/reference/BUSINESS_PROMPT_INVENTORY_2026-04-19.md\u000068dcd91e276a44125c0432d3f04c1302a23ba03ced480b26d7fa84f564c3e284": 1, - "docs/reference/BUSINESS_PROMPT_INVENTORY_2026-04-19.md\u00009bc31d73fba2d0324350698a2d3f6bbf5d7009056cfdeff44be6b191bcd0acd8": 1, - "docs/reference/BUSINESS_PROMPT_INVENTORY_2026-04-19.md\u0000feeb6e10129bc0d3edbed51afa15ca736ee165ed0e87f9445f7fd766e4280758": 1, - "docs/reference/BUSINESS_PROMPT_INVENTORY_2026-04-19.md\u000063db1092b9231775b39a6899267787e99b5dcf132a5f126948814bf1b6856573": 1, - "docs/reference/BUSINESS_PROMPT_INVENTORY_2026-04-19.md\u0000e05f193f5f11300a5ab0dfb6069b2f177bb7117a90c2264f7b43856d6fbfd9fd": 1, - "docs/reference/BUSINESS_PROMPT_INVENTORY_2026-04-19.md\u0000b4aaa19cf86cee1caa56e36ade89699d3bbf2bd34d15070c6b14347e11488511": 1, - "docs/reference/BUSINESS_PROMPT_INVENTORY_2026-04-19.md\u00003e4a1e0d6d82bd103366f6a97dedeceae9f2a149bd6c42d0a0927cbac2c72ecc": 1, - "docs/reference/BUSINESS_PROMPT_INVENTORY_2026-04-19.md\u0000af857ee1d6f8fbe6ca3c988c28b78a04c8d82782262448c073594101f7b04420": 1, - "docs/reference/BUSINESS_PROMPT_INVENTORY_2026-04-19.md\u0000a16387a2480116743883402650c6df05b6dc30e7cd2a705d1e8962473a28b0b5": 1, - "docs/reference/BUSINESS_PROMPT_INVENTORY_2026-04-19.md\u0000ab6d2bf908bdc225978233c1b69f215e072c7c7edaad7996a7dfc0dcaaefd151": 1, - "docs/reference/BUSINESS_PROMPT_INVENTORY_2026-04-19.md\u0000bcf8a740ffa72200116fbc7a0ea131e473e6e0bf4a129e26f57d8ceba310b72d": 1, - "docs/technical/ADMIN_CONSOLE_SERVICE_DESIGN_2026-04-23.md\u0000ee95b3308e59017c9abef805bf4202097e3c3c396f72dc92e2f9c14bbefef5b1": 1, - "docs/technical/AGENT_DRAFT_RESULT_ASSET_MERGE_FIX_2026-04-21.md\u00002d8af19e247ac7d26fd5ef9c33551bb5b2d8400d6d0a4407eeef200c075712ee": 1, - "docs/technical/AGENT_DRAFT_RESULT_AUTOSAVE_DB_CHECK_2026-04-21.md\u0000ef564f7b3d27a584f95ce1531cf306145eb0a6bb5f070aa27c2443377dfe0cdb": 1, - "docs/technical/AGENT_DRAFT_RESULT_AUTOSAVE_DB_CHECK_2026-04-21.md\u00001e65b126de084de1dfb7eb73f0b6221a5766fe69aa9523ee14f9873b5439aae3": 1, - "docs/technical/AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md\u0000b8fd8e814f8765cf066614d4992fe65b35aefdaaedcb2d191b8f2e9fafe196d1": 1, - "docs/technical/ALIYUN_NPC_IMAGE_ANIMATION_EXPERIMENT_2026-04-07.md\u0000720d77b397c0bda9401790c07685b92650d2593050f696fc7d86e9271fc66a03": 1, - "docs/technical/ALIYUN_NPC_IMAGE_ANIMATION_EXPERIMENT_2026-04-07.md\u000069783a43d3d2655e32c6c85e981e4e4ff89901840bc59e7f0d50b1230a68322a": 1, - "docs/technical/ALIYUN_NPC_IMAGE_ANIMATION_EXPERIMENT_2026-04-07.md\u0000b8028a259a0ded9a7009d67fb4dc00e7c915711e1af061b9df2032e8ef118493": 1, - "docs/technical/ALIYUN_NPC_IMAGE_ANIMATION_EXPERIMENT_2026-04-07.md\u0000cf06fec5e5e97390b7edcb0b8a7b1c75fdf3a9774909b927c87fe4239c573a9f": 1, - "docs/technical/BIG_FISH_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md\u0000ce70be0f767fa7a9f455ff1ea98fba3d1170ae1ca5ee2a2be96763ea16c35371": 1, - "docs/technical/BIG_FISH_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md\u0000b33b5f77cb5cdd287507b358d7214552b6b5efdca7c2e2aa93881e4fc5beba75": 1, - "docs/technical/BIG_FISH_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md\u0000381b35357273f3b284ef4a2d58b1494de7fb100dd9632f78c0e973f786541c7b": 1, - "docs/technical/BIG_FISH_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md\u0000703142fb7766e070f1e73cdfc7a320ab6422a668c606d03aa7aad849d3f1c541": 1, - "docs/technical/CREATION_CATEGORY_OPENING_TIMEOUT_GUARD_FIX_2026-04-22.md\u0000b31c1da113e546f3cad5d628f7b0a337dcda053f732c27760431661adf38975d": 1, - "docs/technical/CREATION_CATEGORY_OPENING_TIMEOUT_GUARD_FIX_2026-04-22.md\u000074de8056cacf58706518a85dd806346c1c369eb4514c8632b0cebe4601bcdf8e": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u00003b728f13598d2508e672ce982083b36d6802e76df9da8163dd47acf83be20305": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u00008b70d2b13994be37dbd8eb16896b8f287c6e4f4e121a7eb718e17a8dd0739c5c": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u00009f02e534f95c026c0f7148449380bf2273882fb39a4a59e1e51d704bfea49a12": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000f62c4d47406aaab0cab3b2a8ef50b202d7fcaedc53b913a64eb1a39875fcbd41": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000336162c28d48d4420fe04e14e4175e28b5ce381de198f89586179048dd625fa7": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u00003646965c6b19a309d9398877a32e09a59d545ead40f0965ebec7c04c7acdc1c1": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u000049a2244321dee957e526bea1a83c5ae8c0e05a5154d2ffec31542cba89a1cf36": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000ece3bf113d3f34962b7ed6607088da3425202957de1a8dc95e9b4d9d0b16c951": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u000042c6b16ad68a3af96d15ed3a105b64bd2d487c6764d02a083a2df79b878a12d3": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000ddcc5d903513035421c2b066540cce5f523ab1bcde079fbb6231fb77f6299efe": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000c9cddd29f1b52052be35e26fba26bad4701c42a2d87004bafe11aa8fe76eefd3": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u000069aa96282bc1c7b10d10d42387ee4dc8d7dabfbdb489f4fe227e148c96bbcffb": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u00002fc85d05fcdc9438ee20b0adba030bb68d2a489cf072b7c5ae720e07a23c645c": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u000034440dd44b17970b2ad5b0185d910af22780b79c155cd0d2be768d9ab3b80db4": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000230aea5bc00687b8ca2a47390442fff35c0b14831eab92c38bc2155caf2a98b6": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000a91e544a311ed40ba0dfbbb311c3b48d185a82ff024840bf148170c46eb77064": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u00006a059339bde5c4939984eadc448c9535a4aea71d37151a553322aa69d86153c8": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u00005f6a6e8f3161f29b54c920bba36965ed8c906ee0e205859041db012276ca1522": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000cd0f69ef34130c35a1b6eb6d9ae29591b7b8fd10b33140ec5f7070b2876e00f9": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000414850e482dd2524cefec8996be54e419642bb241c4dab1b2d5c9d2ec42747fc": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000eabc77263e78923a5c33a044aba141206323106dee19e14f8553e54bee1b8a4c": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u000028d6036e0370b9cf49ee24f695caa597c5ea9c03c65afc7c9017053f84d533e0": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u00005649c045ff978b6a89d03ebe146b8ad21a83fe653a6ed7da2f50988162c94029": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u000025103268303e57323110daec6e8b9d6734dea7192a1927efd8ed6e5d0c6d2530": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u000046d1739b6fcd519e202743734865ee8eef4dfa8aba9d920aeefe1ad0bdabced9": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000b2c2738f2878def3d0ff0e8617b98f3a34c9b711db8b8f4fbc105e166133fba6": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u00007e1edc37995dc7049200d7279741242cc1092cc065f331d357b814328d4812b8": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000968611a6438c0fbdb80c3269b44726f2dc2b875d9878f22e89ba38b91ef29710": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000e0bbe90697c214800f88f542d25c1210af641d8cd718236ffcba1979eb3c1f63": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u00009bab15755fe8e97a5ef690c44c931d8395658b910590de5d874493b5213056e5": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u000086e06ca3320279485a006e99ddc027b550f64db7abafc8ad4b27ee236fd9d53d": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000e61402b4b32f7427f280dff99fc631f98c1b25444fed7be23d8cdb81107ddda5": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000c1138010ca5d4158494743604150168ffd1c743cd2ee73ff3e626435e4073034": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u000054a8cdd597578a13c6af0fc533f7fa90e09977548a50c6cb7918492c78681572": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000762a252250aa2550c0c083bffb516d2331c7a75f74943f546bb3b59d005825ff": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000c575892291169e985615013c3b06af0cdff31272dc1ed1bb9346fa0e4521da87": 2, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000ffe61a0bf8101805a4811614a439b61625d3315191442873c5de096aa5fbe62f": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u00006ae0c988c3f46910d96c53bfa7ff6fe33e515d4444c7aef9481a4338b67726c5": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u00007eafdb4ad0bc4b461ecaded0683615d95abbbeb750372ecefc9139d354c4b3e4": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000c7592faac641e230e0cc54730386a5c0717cf2e2288ffa8d86aeb4665a3e839e": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000aba470b1d8e0c4d316751be419b56960f7961343eda7cc7e327f5156cb98bc17": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000139307c5834341c8ec3fc0d88d390a0385b8f15f3ea915d5c9c99991dbd4c3f7": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u000034fad8f82c97c3ecf2a95b4a1efc763d7bf773c15ea1bfc0a536a3fc4ada15cd": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u00000d56881fcb2b03449f6c8b4c6c9ee00a04a23bfd09ff79f8fbc12feb8066fcee": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u000035cce40f59f9a5bc8b3647fc100708dc679099200fb01605892117c62b04ecd8": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000f4304b99a5207a8914d62883de7d0773bf86eee2e8324ee6ae84f83358983671": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u00007b73c4c3a1af0bdafdf1fde3504cb033138ad6b9c7e110ae54ca45174fa8e28b": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000a3fe61165f1bca7f73748dcb40d563e78abe68f2211052f08ffcd03522fe6a79": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000785627266eaf1aa9caf888857057740c78fd01f4144bea130944b6e3fdcefd30": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000e612999d54374f3062f1b85e47f1aaf6adeb77dc48f5f1832e539ef79a0dae5a": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000530671ac6777070e4746da7ea8c06f51f16a1e965108b498b4aa6c16e13b49fc": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u00007d8eaffab1c534b072bf311e458f4640617ac8fa7411339b5ea0917bb5a14376": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000acc11a9437cdcd1a76f83e3a569c5db12b41678b1b73a9919358989cf900475b": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000c8f912d94785818f8d7776ca78d264961a218ed0bc159ce9eff92c6f00c9c4db": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u00000a9dd8c72d9179f2608f676eae387710f08d59f81d25a336fda22df81ed1cd99": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u00001c961325f4f801ce357a237067e595b06fdf70f18ced60da4eddc1dd1450c5e9": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u000078ddba9e867fdb2dd01ab4cf69813329c431e7d51b24627bdd5e35dbf6ec8a68": 2, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000381d76b284c2d5fe0a6fcc3e06ce6e48afa255ec73f0dc8ee1660105d029e55a": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u00007e3df586bfce524e0383379880e95b428b96f21449c1db06261561c1e599a188": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000af857ee1d6f8fbe6ca3c988c28b78a04c8d82782262448c073594101f7b04420": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000771bf296bddfa67d4a917a8160ebe271d4b5622f5cb2ef3290c48d2583461fcb": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000f1a370aa40f96f947916e42666964856b7ca5d0c550d1e9a4be0979b4ddc67eb": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000bef4f9af2b2f60e7e50589486db75f822ae8f50c988f2f7951a81ddc706ae0e5": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000c9b5c8cafd8f8a08cd45b3dc92c7fd5ab0511d95577d26ade8f54e416fa65762": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u00005f49c37763eb0316fb3d2d6e9fa736e7bceb1d7bf518d9b4b5a30d76f91ba174": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000194d74cc656ae7ba5a1597916cb1d43b82c288f99cfaf9bbcca61e861a126c9c": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000c43f6c113324ec0cfd2a408c4f0af3afb3f24879f4640ab581023e765820a6c4": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000a18ac4732de4746b8e848272369460bac0a1dd96ecfe700d6d1c2c850476b688": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000ab257fabe5148cdc0e6f56bb9fcaa50b750e822685e13748feb9e7322d941625": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000922a0525e57858f1562e4b97a00bf74297609733cf225b3bb3e2eb85a74bed68": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u000067e508b67fa5c218c07d14cc1818d252bcf26704a9db6dae49ce0ec5121abea9": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u00002646a6adb33dc1a29e2e52bb78db9b3b48d92b61319ff9e696a07edc40f5415b": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u00003c7cdbae6b42ddd73b0beca3da6098951d7c8ed3732655bb480a7ce49e37e9e1": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u000030a57281b06b7444fb578d0aee3a0ad370404acf07a117250a1939f0609a430f": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u00009e941fbbb5c5aedb0161f6037aab66e4e2e4a2dca98ee5058f01ea0c40d0145b": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u00001af13aa7e2c6328ea16cf6f073434097c2397c671a1bcc48dd52eacf0c2d47a7": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u00004bde2d8c3f2ab66b6e178f4afe1f947919dc444f164640de4887670c2f8aad7d": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000012178596b5a0b7ccff88e0fd329cc7c52a17e0773884ce815096e29321c1249": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000aa44e6e4402a69aad6ad64b9a922feb64de250e184bde233be8ddd31336f17ed": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u00000bebad944d4314cf46c264f21eb96e92a5eace9a3b07c3f356ae1da3c757ccd2": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000ecfa4578d99a135521126719616f048d44a2d9d745814c705960f50a788cdc56": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000403e593fb93db40962a43f77ad2ada3ec5f2e25307cbae49d8aa65694f4254c0": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md\u000034440dd44b17970b2ad5b0185d910af22780b79c155cd0d2be768d9ab3b80db4": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md\u000062e4b1ad4f0d4178638737b20638bb94533560aed3436fa0160618f43fb1a977": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md\u00003e1f144aa9b6d5f455cb97d5dfea93898969059db27eb252148ee7cce99075de": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md\u000025284bae1ee260340ef95bca1e03bdd4ebe3fedec52060feb3c274d75c332537": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md\u000092cce25b5bc2495fdeca513f384c2795b2095d74970411b90f107d70d4859bff": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md\u0000ca7e03864434b96ff19b6ee15c7fd05fdb420659be98cf6e6fa9f92b2b36cbc8": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md\u0000432d638fbbc6141d61c331b91e951a5ef46ed9ba3e416d9958b672871d996fd8": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md\u00002046aa3fc03cb801d80604c312268f9bf1138166e370e7539d4e6db3bc027b8e": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_D_PROGRESS_2026-04-21.md\u0000d40f7493bf9cbe975e4ed5129b321deaff92332e13cb9aee91237dd3e010d379": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md\u00007f75128f07efaa6ecd1d787e5b31c1e672ba774a7ddb43e210c2f1eac7702770": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md\u00006f7bbde8cfbd53d0e6463a57f0cb881907f00625464bd3015b148393974c80e3": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md\u00002322b53fffedd08199a24ad42fa72d1448978bbc771a4290a1fda6dfee1d1bde": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md\u000046871555e553a699140bad21a03ef31c88afc05093ea96cd51b63991c1c99de8": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md\u0000b2c2738f2878def3d0ff0e8617b98f3a34c9b711db8b8f4fbc105e166133fba6": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md\u0000c4b39ae21aea067744757a67a581017c40cb2c83e728da21eb263c346d088c5f": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md\u0000294e48ae736ff50a60ff24ba233f3200805cc427a9be69713122f3648e5b47dd": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md\u0000fab7bc29741659428d3f227bba2f9d6be8a6ed1fd92087d03f69c88982a15bfb": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md\u0000e0bbe90697c214800f88f542d25c1210af641d8cd718236ffcba1979eb3c1f63": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md\u00003f93abdf17093347c314f390b1b41d09f131c8ba616ad781991911df05a703c5": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md\u000007c700377b146380ee2ceca43c7b6063f2fb421d097f960f71a83d449c7d7991": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md\u0000c6cc479a4fbb10d31619fee09e8c20a7fd27cfdadc638b4bca0a19b63fc5d7b4": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md\u0000d9be9c7d4ac37a27def7c4d3bfd8cbbe5c7357660956b750a3f1b29ce683c07d": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md\u0000aa004450e3d39f10c139121668b4f810ec58f6bb89b58308479d7f6827f0b2e0": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md\u0000c7c41b09b298eef99305610c598ef2e3f74fb94386f169973bf0800fa010be76": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md\u000046e068e1a01e8715a9e99e22d93842e156e4bf99239bcd96ec5a0c72aef49484": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md\u00003cde3f9782b82f6a22db2df3fe012e528a42c0939f4f0ca12c570394468cc810": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md\u0000adb827259503fc1da94aac99980f86222b7267adee3e0e493954aa58f6ad5d94": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md\u00006307b32b0b76c973d40c3de0c7a9722fde9f1f3caf12f7031a5d5aab17da1705": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md\u00009dc064c8b14cd3259cef59a3da622ddc5ceacae2112867d6e10055cb7f0eac6f": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md\u00003c60115566ff97da566323752fd1ab536782b66b759a86855b5e1982ae86ab9f": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md\u0000d775bcabc86bc07809e91379a00517374051e99a88a3cd2858e0e2c52907711a": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md\u0000aee0fbd458996b469b18ffcc3c84b6b1663732e061906c6902b23460b8bf1cc7": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md\u0000f78e07b3a8b56b54fe5bb1c6ee638a66f31b5f3b98311c4ae1fe19a91875dc9d": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md\u00003608b849b83a47587dd2692a70f82284400f10d5a5837bff8ff4736d92633b50": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md\u00001f727d0e6f4a7b29bb6393919ed796aaa341be9353cd743c9d8fdc3c37426a0d": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md\u00008f7a852bc8016c88078c081f594719e11459ed34141500bccdf329e42c54a30f": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md\u0000ad4a83686d554c8ef6ffbe708141b9aa75bddbfdf851a06f9b5e03ba13886ad2": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md\u0000ba094548a49b8f79995f62b762ea743d49df57428d0b50334ddbc2259a5ac452": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md\u00002503fad68c0b8f9acc6897a4444fd01df07c466240192aed4791d8bcc343a1c6": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md\u0000870966dfbed92d0745787dc94878d46cd078af397f9fc613734a25a9e5c83489": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md\u00009989323f85d38d76cfb1d0972a581fe17d06c31f253b6b9d4347cedad8d135f2": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md\u0000e23392f5645ada3dc4f499007d0174b7b6dda08a0f955b1fb88f403ef0b4677e": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md\u00009a540baf8823666621836d5677912c739d37cd5676f599c25802362e8fad6c86": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md\u0000bc54c585bfc679dcc11dd4810e457e8d309bfabb8631b07d9e469571a2c74ecb": 1, - "docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md\u00000f960e01a596b4ec1bbaf6333720878ed4534530a75bbb84e2f1832318e031ba": 1, - "docs/technical/CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md\u000075b4f2bc05984befd4114f898da6491cbc851e0a7307e8143f0b07d46a95a854": 1, - "docs/technical/CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md\u0000baf1fb4d413de5d616ec0c34ddb39d92a106da3cdb47cb72a1a11be98d134368": 1, - "docs/technical/CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md\u00000f8f3536e3b6ff87b20e7a194283722444d6f3ba6b33a2a0b6bac098bfd4c28e": 1, - "docs/technical/CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md\u00008ebfd3d0b096d18bdd9c68e9f5d0d4ce8ee872d4a627c252a164a9ea0767f78d": 1, - "docs/technical/CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md\u0000db7738669b8bc328e3b5987e24f1cd1c4f6211cb8389487f80c9959f94d5a370": 1, - "docs/technical/CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md\u0000a06894995c5dce3d2d34c151bd298c7f7308f953f4eee4048b9f7762ab5c47ab": 1, - "docs/technical/CUSTOM_WORLD_AGENT_LLM_REPLY_RESTORE_2026-04-22.md\u00003c7153c4b1877e123aa9f0eb0db02db69f2761d628d0ed986f378fac30800374": 1, - "docs/technical/CUSTOM_WORLD_AGENT_LLM_REPLY_RESTORE_2026-04-22.md\u000014bd14efb93d87c13aa4462ffe8a18de0270497603c621842e6323bebd417e21": 1, - "docs/technical/CUSTOM_WORLD_AUTO_ASSET_VISIBILITY_FIX_2026-04-20.md\u000028ea9f6abe602eaf0ea5fbc3b4f8f38cb49e161dc0c8dc02dad6201bb1086576": 1, - "docs/technical/CUSTOM_WORLD_AUTO_ASSET_VISIBILITY_FIX_2026-04-20.md\u0000157f4823dae2a671974214d1e56b99c4e391d58611afa43d300e0dc356879508": 1, - "docs/technical/CUSTOM_WORLD_AUTO_ASSET_VISIBILITY_FIX_2026-04-20.md\u0000251783eca869282972c432bea45421e35ab80c54f4e291620683409d62540005": 1, - "docs/technical/CUSTOM_WORLD_DRAFT_GENERATION_FAILURE_ANALYSIS_AND_FIX_2026-04-20.md\u00004769ca2325077be5cf0d8891c94ce5fa75f8c37693e904f372dfdceded318a81": 1, - "docs/technical/CUSTOM_WORLD_DRAFT_GENERATION_FAILURE_ANALYSIS_AND_FIX_2026-04-20.md\u0000bbd079cd6638b0db39dcf7f6a8a8dc77319a9dd7a06335e1691c2d8cbce2b041": 1, - "docs/technical/CUSTOM_WORLD_DRAFT_GENERATION_FAILURE_ANALYSIS_AND_FIX_2026-04-20.md\u00004056a4bc9c51a449a76fc381167749036f910b7e7b0d3642613d7fa59e3a830f": 1, - "docs/technical/CUSTOM_WORLD_DRAFT_GENERATION_FAILURE_ANALYSIS_AND_FIX_2026-04-20.md\u0000fda1f65d8d83683dea3597b8dbe65cd32618ff4a58471c00b2b96c89e3a961c4": 1, - "docs/technical/CUSTOM_WORLD_DRAFT_GENERATION_FAILURE_ANALYSIS_AND_FIX_2026-04-20.md\u00000d59dc23c8fb7cbc5824e22b40e530f481cf2c4b884f4f15de360ae63952fe06": 1, - "docs/technical/CUSTOM_WORLD_DRAFT_GENERATION_FAILURE_ANALYSIS_AND_FIX_2026-04-20.md\u00005872cb0a5a768543eb9951cf39d5ee6cba814ff56eb4b61917c9c806d14c00ff": 1, - "docs/technical/CUSTOM_WORLD_DRAFT_GENERATION_FAILURE_ANALYSIS_AND_FIX_2026-04-20.md\u000005ec65eaea433162b17d995a0a4ce051f25be5346e1eea4e2eda8348f90a4101": 1, - "docs/technical/CUSTOM_WORLD_PHASE4_COUNT_SEMANTICS_ALIGNMENT_2026-04-20.md\u0000d3c40d649fd3f502751459acf776b86dd29b65ec8848c7009ebdcbc8e2581447": 1, - "docs/technical/CUSTOM_WORLD_PHASE4_COUNT_SEMANTICS_ALIGNMENT_2026-04-20.md\u00001bd2ac5f1b5e9d6b0f6d356aa9e82f959f0384de0c410353d36c8c18a975da3e": 1, - "docs/technical/CUSTOM_WORLD_PHASE4_COUNT_SEMANTICS_ALIGNMENT_2026-04-20.md\u0000ce480e229d180aa081ae739ea85f8d39a528694807568dee11de0141b4869717": 1, - "docs/technical/EDITOR_ASSET_API_MIGRATION_2026-04-08.md\u0000c6571d3ad15791dbfbda2bff043b2deeb6b0f43888134bbda7e39709353755cc": 1, - "docs/technical/EDITOR_ASSET_API_MIGRATION_2026-04-08.md\u00006bfdc3affd0e519cf97262c08d402486945d05929e7c50418a1e2d1a4e13d358": 1, - "docs/technical/EDITOR_ASSET_API_MIGRATION_2026-04-08.md\u0000f9afe7dc078db37d489590d227027b1dd44b974e7f759f67227190a461052eca": 1, - "docs/technical/EDITOR_ASSET_API_MIGRATION_2026-04-08.md\u0000b8028a259a0ded9a7009d67fb4dc00e7c915711e1af061b9df2032e8ef118493": 1, - "docs/technical/EDITOR_ENTRY_CLEANUP_2026-04-21.md\u000069200f40223cc0ddb190ea2bcea8ffeab31b39dac033628dc3b79045e10c259d": 1, - "docs/technical/EDITOR_ENTRY_CLEANUP_2026-04-21.md\u0000243e7c55a0524648219aae21f426a3ea572001a51a695b27711f0dda7d7d2789": 1, - "docs/technical/EDITOR_ENTRY_CLEANUP_2026-04-21.md\u0000e4ae0d2ac489c7ec1d4a15b6c8f4f6be6740d6ad41bd504935fc26ef27882c2c": 1, - "docs/technical/EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md\u0000fefd0412bbd702a787e3b6bc7e770d779f1a8006982e3d5f3796cc18ee7bc3b6": 1, - "docs/technical/EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md\u0000953dbb131ffc4ef6770d000f0bfa23acc7f8058e5d89af9b114a86b12a18b4e3": 1, - "docs/technical/EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md\u0000e4d048462697c6c73db23a8d72783e6a411d2280699e7f851a1da4c14b18aa6d": 1, - "docs/technical/EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md\u0000d9068a2b7360687b20cc86359e95c72ab1b22629b94d777572ebd782d6b8bddc": 1, - "docs/technical/EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md\u0000e4ae0d2ac489c7ec1d4a15b6c8f4f6be6740d6ad41bd504935fc26ef27882c2c": 1, - "docs/technical/EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md\u0000ccb962501910f26ce0cff38bf12b6b5cead2cce029df9dd6e5f2c81812f08b0e": 1, - "docs/technical/EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md\u000006377113a4120ec61ef73bd5b60f9f3a2fcf0013fa0b6fa2f5ba29f29d8de626": 1, - "docs/technical/EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md\u00003e6405ad9cd28f9973e7f96dd04ee3f36df030eef6799feaf833c47ed7952a94": 1, - "docs/technical/EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md\u000015cce913280a7cc9caef937aeaa4d2f00d6e5789326bae1b37a53c62abd36fc7": 1, - "docs/technical/EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md\u0000bb82fdbd53ea452d76b45c7d66e199c7bc39c21f02216b364b7b23d8c3400068": 1, - "docs/technical/EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md\u0000563e73dc07ac4d8a002a6b6aa2660bbfb4525738a72bab3a33ebcd48c85a8fce": 1, - "docs/technical/EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md\u00004b8b4a9dbe0187b43880e206e1454a97c93be589a071a01ab71fd8e1d5b42bb0": 2, - "docs/technical/EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md\u000023783035f19a0852d3050ba28bddbf4ed545db4329fe287fcf4215b792e93c08": 1, - "docs/technical/EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md\u000051981db8f419858931aef81f8570b20431c80d55e2f0f7eb410824c1540754d3": 1, - "docs/technical/EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md\u00002f6d6d6e0d0ebcc39d433f1071bfee461c8fa5d77afdb495f0d874a8102c1e2f": 1, - "docs/technical/EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md\u00003290d363768890c0fc0b9886284d09db03c9f052481c731ebe5891098d2085d2": 1, - "docs/technical/EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md\u00000c7dab32dd8d79f8a55dfe8de52df01da216ca64cb26cfb3fecacc667a6ed80a": 1, - "docs/technical/EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md\u0000798729f12c0ba12bb90570fe70923c9d038661886f5021acecb12fae54511cf4": 1, - "docs/technical/EXPRESS_BACKEND_TASK4_AI_ORCHESTRATION_STATUS_2026-04-08.md\u00003290d363768890c0fc0b9886284d09db03c9f052481c731ebe5891098d2085d2": 1, - "docs/technical/EXPRESS_BACKEND_TASK4_AI_ORCHESTRATION_STATUS_2026-04-08.md\u00000c7dab32dd8d79f8a55dfe8de52df01da216ca64cb26cfb3fecacc667a6ed80a": 1, - "docs/technical/EXPRESS_BACKEND_TASK4_AI_ORCHESTRATION_STATUS_2026-04-08.md\u0000798729f12c0ba12bb90570fe70923c9d038661886f5021acecb12fae54511cf4": 1, - "docs/technical/EXPRESS_BACKEND_TASK4_AI_ORCHESTRATION_STATUS_2026-04-08.md\u0000fafac9e1779d6c47a66f7a1335a44b6dc587238ee29ea7a797ce2037ec4a0d61": 1, - "docs/technical/EXPRESS_BACKEND_TASK4_AI_ORCHESTRATION_STATUS_2026-04-08.md\u0000ed07d1646c752c9e6c18cb80cb4e4a387a2ee96d5ab18661d9c015f95e0cbe5d": 1, - "docs/technical/EXPRESS_BACKEND_TASK4_AI_ORCHESTRATION_STATUS_2026-04-08.md\u000000ee385c5ee19044357b68561d180ff7f494478c7635ece0bef178556eed2435": 1, - "docs/technical/EXPRESS_BACKEND_TASK4_AI_ORCHESTRATION_STATUS_2026-04-08.md\u0000132dddf2b2356bec3b88873a7dcc12b037bf193d49051bbce031390a1fc525bf": 1, - "docs/technical/EXPRESS_BACKEND_TASK4_AI_ORCHESTRATION_STATUS_2026-04-08.md\u00006e50eef4c4860ab72031fdafc2dd40e6680462103d9d9978efad07806a3fa3f9": 1, - "docs/technical/EXPRESS_BACKEND_TASK4_AI_ORCHESTRATION_STATUS_2026-04-08.md\u00002e1d96dd81898ce5968e46c0696a531f2dd067e1d6de887f386af791848bf735": 1, - "docs/technical/EXPRESS_BACKEND_TASK4_AI_ORCHESTRATION_STATUS_2026-04-08.md\u0000093951a59f9d75cdc88f4d976acb685f70a5dbaa653b84a64f36da38ae954eb1": 1, - "docs/technical/EXPRESS_BACKEND_TASK4_AI_ORCHESTRATION_STATUS_2026-04-08.md\u000014e790628f6b5179e828ea908fe97d98be5cd3978144ebe2bac691279e1e95de": 1, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u00007f700b9c4a295919bf26d8422425aad1ca08465e9096f6372295197009dbf8ad": 1, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u0000adda1e60564a4da36932134c6acca8177e9b127616870361a49f16378377675d": 1, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u00001bb74571e117dbe72b92bd8e2c55451a2a4857704b07b10628f33ebc6d7764db": 1, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u00002f6d6d6e0d0ebcc39d433f1071bfee461c8fa5d77afdb495f0d874a8102c1e2f": 2, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u00001d039376c79428854a0f445f35014b5c082902682b5a873e9ad62271ac14b52e": 4, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u00001f65b1bdd071507c14fcc8245a7c74be401084225e77eb13291d523aac62f443": 1, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u00006e4677f0888bbd26f27cffd7d9f6f4a8ac155856ac19f13f058451b6966225bc": 1, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u0000c3108b93db3b377b71c32a93c05ad53851a6cd90a7030599a5ca71aec7d69b39": 1, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u00008b80adbd569e3697f61561a462c1292cb54b3a559cf87235e3ec7c81dc45db09": 1, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u00006a15fb7f60aedf77ebf53834cbd99538eece43d01a10960ede001a4ab558ee2c": 1, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u0000982f74424aa1667b8915397911abbf1b194d3b6e0a7544d664570dde7217d588": 1, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u0000fb431300460d53043db5249ac4b0b03f04c994cffce7257f9b12bcf7165a2e97": 1, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u00003e743aba3de2f27c17a7e54a515a70de25a201e1c87a399c1f3d6504444646b5": 2, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u00006b0737be1e4086ba5261de49311ad7789cbe5058d67ef2289162c16cdf7033b5": 1, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u00004f2f9ae340168d5b0acc05f1fb8af49f75086b5c57935696d6c902f3d8a1fcff": 1, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u0000efc7d8538483e62ff4d461705c5c87d8a8ae5ea444968dd14e9b937ae6a08a20": 1, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u00002ac309e448b9fc2564c582aed8c048d25e2d200a211cafc99aae3ef01c6fe365": 1, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u00006820b3eee6f76a321e34da9867056b43dfd81c6553b9e87740504b948d5adbfb": 1, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u000021ed7f93872d07b918a265b5a73e1d682c722933d34dd85c4b53bfc79748d720": 1, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u0000d76fa4ed0b835fdac9af9dd07b9d1a6ff229c84c03f0ce9b1050a09bb8dfbe69": 1, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u000081937f950b0df5f64e99136b5fc34e291eb74088bd338592bcb98b62a0c6ad4b": 1, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u00005aabc6fe44f770767588b9b903d5e1d6af18c6f6d55dded02e180022209e89e6": 1, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u0000fa5f2f7741dd70d3ff46d1e1a48b7dae6723a00da12e7353076f0f8854aa353f": 1, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u0000993e32e670e6fc8175cb710fb2cb563ec5571340c87b3d5ef0ee98edd6046b20": 1, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u00007ac97395020aef8eeeb2a6800b1abcae12a97f56afe0336dc7758af4554bc200": 1, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u0000b05d01dcd41be0a44f9eb4cca213a6474c9670c15370eaf31a396494304d974d": 1, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u0000ece4348742fd5c9d92af615bbcc94f87000fe66faa3f20ab12961d0733343d07": 1, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u000051981db8f419858931aef81f8570b20431c80d55e2f0f7eb410824c1540754d3": 1, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u000053a305552b9d410a6b506a8bc10ba584a7896b49a4c3691d7a45595ad65d022f": 1, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u00000c7dab32dd8d79f8a55dfe8de52df01da216ca64cb26cfb3fecacc667a6ed80a": 1, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u000064e0ac1a377ff3d7f2c788bcbc10f68acdc83b973e644459f0e308ff9c21a2e0": 2, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u000040abbf8ac963647ab3eee2b842200dc76d91d6a957aec1cd3296a58708f79194": 1, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u000035bab6c60cde211e0fc26a85703ef4fa4afe2b8db7b5a9cc1c3c61a7b834adfd": 1, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u000051da50b7d7d59a7e257dce7c4379f960304c55a03aa03109bc14016414269d19": 1, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u0000f80aca1f5546689458f25dd64d5a651ac9f9aca7eb27b10454998c7a8793b063": 1, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u0000a06ee3b47b5427a96e1a2b84452f3abfdb63b69014db1dbb22e0ccf25a0d1d85": 1, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u0000ab9bfb084c0dd1920484399339343f8a69074fef6245d480a9270e3688c33bb9": 1, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u000007ae0dca2361bdd24040a24edaafae1884f058492640a551acf487806f6dc6d6": 1, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u00008688be1b55165defa6bcac4ebd19e98dd59c0f822b67fc4d88846ac1949ee1c3": 1, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u000092fc2b45aefe19f7cdec001f582acaa4fad0e4b3dfe8f2ed10d29cfa67683cf4": 1, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u0000ddbe9055fce1f875394a654729f4b2c2e2c2e8bf6e279348c1ab920e1d83d9ce": 1, - "docs/technical/EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md\u0000a16387a2480116743883402650c6df05b6dc30e7cd2a705d1e8962473a28b0b5": 1, - "docs/technical/FOUNDATION_DRAFT_OPTIONAL_TEXT_FIELD_GUARD_FIX_2026-04-22.md\u0000d4340afddd112c11a2bbbec50911a5a7ea5931655a6cff8558d9559545af50fd": 1, - "docs/technical/FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md\u0000a4850e5dc56a84b5728492e1ca10a09e78c04a4d5dd64ff367fa82abf721bfaf": 1, - "docs/technical/FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md\u0000a791e35387f52b5ead2b96e76d3be3adac91796567a65f44e37f2ed2b26fc859": 1, - "docs/technical/FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md\u0000dde2a7a524a217acdf4ba76fe624cb658e6f5c2325ffe60b6cb98f2b15cb9527": 1, - "docs/technical/FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md\u00008a024aec291f4a3806149a04e83d86758d0d12d7a5a29f28610c254cad79d004": 1, - "docs/technical/JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md\u000051a02e2c4df9933d43a8632d4125ba2116c5a8ff4dac1fc3ad17ef8c0e0197ef": 1, - "docs/technical/M3_BROWSE_HISTORY_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md\u00002de0e7b45df5480887d8b53dcf511fd818a0aac24d230393deb9144cc7f26fe4": 2, - "docs/technical/M3_BROWSE_HISTORY_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md\u00009f2ca27c4738cfa55712a7b58727e388c2db2e4cd89dcb468face805ada11235": 2, - "docs/technical/M3_PROFILE_DASHBOARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md\u00002de0e7b45df5480887d8b53dcf511fd818a0aac24d230393deb9144cc7f26fe4": 1, - "docs/technical/M3_PROFILE_DASHBOARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md\u00009f2ca27c4738cfa55712a7b58727e388c2db2e4cd89dcb468face805ada11235": 1, - "docs/technical/M3_PROFILE_DASHBOARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md\u0000a2af44495a5201e018798ab6652e6cfb2cc6e7fdf56b6de585c5ad62bc76c272": 1, - "docs/technical/M3_PROFILE_DASHBOARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md\u00003aef2e29d03793e51133dfe1720a6c9c64658f145d8edb6eb51a78ef454a2d47": 1, - "docs/technical/M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md\u00002de0e7b45df5480887d8b53dcf511fd818a0aac24d230393deb9144cc7f26fe4": 2, - "docs/technical/M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md\u00009f2ca27c4738cfa55712a7b58727e388c2db2e4cd89dcb468face805ada11235": 2, - "docs/technical/M3_RUNTIME_SNAPSHOT_SAVE_ARCHIVE_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md\u0000fa466babf5b3a21a9c13006ef7fc7a84707ae2f764ae1234f53e898976390cfe": 1, - "docs/technical/M3_RUNTIME_SNAPSHOT_SAVE_ARCHIVE_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md\u00009f2ca27c4738cfa55712a7b58727e388c2db2e4cd89dcb468face805ada11235": 1, - "docs/technical/M3_RUNTIME_SNAPSHOT_SAVE_ARCHIVE_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md\u0000d4097c649064b761c524ec17e5f2015a2d61b67fab204a463c9931935b396765": 1, - "docs/technical/M4_MODULE_AI_BASELINE_DESIGN_2026-04-21.md\u000042c9d531724d1f37828e40d52f04e0f6be90343a460e2a27d355b736ab77066b": 1, - "docs/technical/M4_MODULE_AI_BASELINE_DESIGN_2026-04-21.md\u00005ad161d0a52c0a7e59c86aaa38ae48dba3bcf3d2d9e9cd55490f867f6e68398b": 1, - "docs/technical/M4_MODULE_AI_BASELINE_DESIGN_2026-04-21.md\u0000c21674a0ad95d1c18f289d1752b5199a42506c310cc1cfa06ce90bc7fe101195": 1, - "docs/technical/M4_RPG_RUNTIME_QUEST_SPACETIMEDB_BASELINE_2026-04-21.md\u00008b07a10a0c48a5faf303f27c9a9ceff62f2b09ca78b6b98d7ed91ba229e73428": 1, - "docs/technical/M4_RPG_RUNTIME_STORY_SPACETIMEDB_BASELINE_2026-04-21.md\u00008b5d0cd4d231b8c7de9036b8ae809d3736ed540fd3065af5391bc615a04fa44d": 1, - "docs/technical/M4_RUNTIME_STORY_COMPAT_STATE_BRIDGE_DESIGN_2026-04-22.md\u000043833db3beac243f4cc2b959624f7716daf435ed7febcc23d7e4f0f1926011d0": 1, - "docs/technical/M4_RUNTIME_STORY_COMPAT_STATE_BRIDGE_DESIGN_2026-04-22.md\u0000fc5fce2cc43156a8c31fcb1aad729a63c81f6827e40d0976a41851583de76c84": 1, - "docs/technical/M4_RUNTIME_STORY_COMPAT_STATE_BRIDGE_DESIGN_2026-04-22.md\u0000c728b783a4374349ac567b64d048f6437ff0b08fa460d3c285aa5cfd4db26dbf": 1, - "docs/technical/M4_RUNTIME_STORY_COMPAT_STATE_BRIDGE_DESIGN_2026-04-22.md\u0000bd14cd2f50104e481d416d5a6fe3e6b0e459dbfc08192464c23e7149c028878f": 1, - "docs/technical/M4_RUNTIME_STORY_COMPAT_STATE_BRIDGE_DESIGN_2026-04-22.md\u0000c317b6a472d8b65e72ee0c3deb534f37acef761187725def4c6e7aae45bfe077": 1, - "docs/technical/M6_CHARACTER_VISUAL_ASSET_EXTERNAL_GENERATION_STAGE2_2026-04-23.md\u000009b903499aeb4f54038fabf2bc253d0329684f2a697e51f6461d78ba2af10c7c": 1, - "docs/technical/M7_TEST_DEPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md\u00009fda322414f95800e08897ec8b9d5748658351b7da26a4d0479cbe161d05d2f4": 1, - "docs/technical/M7_TEST_DEPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md\u0000e412ab4bb79c01728b6ae2e827881e277b2fbf917564a4b19b25a57f2a4f79ae": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u00006ae63ef2f83684656c96bffa00cb456ac8aaec94816eeff35d32474ec0966c83": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u000079eb34556bc6bbd46f708b8bc0fb6eb0c9abb48df72a5bd382e462025abff255": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u00003a1438a263a55dbfd4b9d4d4ecc8af49ea551b323a143798bf39fffebe3137a6": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u0000922b15779c98df15b771d948e81767dda106efc74593d4e222c3d6fd019c4501": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u0000319afb5c4908c0029bc718e3f4a67fa82ccec339f03d4a207ccb4a3d4ea5655b": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u0000f139c329edf0d765c5a7779c004f24a838b9e2e7bf4edb5944f4cbdae8f0c4a4": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u00009e4ec27bb31195af024ddc45fe30775430aebf7bcc7cdf2149cb8a025818fe6b": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u0000e9005621ec18b197e910989c707e676ae8e3b7e483146c4c6c3b42ef73c462b3": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u000095b02cdac3643ef6fba15d3b423c86e14d8efd3c6912989e185f2c32617ad9b2": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u000001db6af13c4c665f00aa7e35e13f6b8f974249c6c60fcc037dab577ce6a66260": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u0000eb232ddf74fdbc7fac08cfe65ef8a3e2dcf450972355934fd33553e152d101c7": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u00001e073ed2b6d8b5c81c94c0eea1324f5efba908e2d25bfe6a71fd470af893217d": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u00000c7dab32dd8d79f8a55dfe8de52df01da216ca64cb26cfb3fecacc667a6ed80a": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u0000798729f12c0ba12bb90570fe70923c9d038661886f5021acecb12fae54511cf4": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u00003290d363768890c0fc0b9886284d09db03c9f052481c731ebe5891098d2085d2": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u0000dcc68b427c593fb6e458db9423cea9e13f906b77aa9dcf1b0f4a64f3bcd64647": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u0000b4ed53f9cecbf30d83e3ee52952bce89c0963ee932ef786ff33c41bc37aa7d02": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u000075ca2112dd827023f724759d253d0e3436b9cd1797c76166c952e53f8499e8b1": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u0000002d71c2c0b1ec3545c5078c2c381ecb41bf8f097b61b19b531787a906435868": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u0000b9d17274aca90c41a86c14cd3b2fc29537f21b226e34c349368653fd714109d2": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u0000cb4952f987bd93c8a30fae653aa83ff34abe78866963276ecf1061e66df1e460": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u0000e8b81499036176f30d5ccc34c14a11f40b2a154549dc945d7345c26c27d6420b": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u00003dee6a92bd57ce2065d81cc59f61f7468c6c0ad97494b6a45ffe20e336795394": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u0000192297c49a4d2ec387cc51e5a8112e35c435379c1c12c391d5155a8bd54e120c": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u00003b67656f3dcabcaed82b6bf1bfb64362814a35c9cd673fa5e89a33fdeb51ad92": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u0000243e7c55a0524648219aae21f426a3ea572001a51a695b27711f0dda7d7d2789": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u0000630da442b0d025844eee25870b646b38fe376ca8ebbec0ca09791bafe40f401b": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u0000d6d80b9548b4f173cddd28a5932d1d86398474d229c10836c672965608660ce7": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u000053d86a2d6bd7523b42448b6f45874604b4d7c4a72bbd9dcc5cf42a435013438a": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u00001ca610284f642694cd5d99f301b0b445cb22ca685395c46289db63226830484f": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u00009b0f7ef1bdf1facec94962810793d4f9964513cf74476e6b6e10ef1906a7fac3": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u0000bb8c60945b2707d8f406be2c1e6b17a5a3d260b304d5e6b58b37135f36d22ff1": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u00005d1324d67b40ceb68a5149ce342416508af3404da7201dab3a6f3de89c0677c1": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u0000b72acc06cf79369fda91607367b253e152af452b76112c1974a5e36ccbca2713": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u0000a95fa1a31e2ab5d9e902673f9d23459d381e0c408af2e66deb901226e6a56de8": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u00000fa946e2e62fadedc2af4cee65a750b031bbebe535f9e873c9145f5191418cca": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u0000fd73f6be0dea5a8fca73b33e27104b8da9685877d9b91c6cab9495dd8a5d46d3": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u00009b449fd956969efe51c0de2326d9f18b7b936c69badf09bfa6e29683a9c6bdbd": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u0000983a7434d67243336c4a732ec606cf84e65681e80491f210125200720beb612c": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u0000d664acac540aaf261bfc0a2a01260ef5312f8df30c109216eeaefe3724d1ae16": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u00006b342c67752cdfdf42f84a327f2c2e14dafcb51c063e49f83b49ac02851b195d": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u0000f2c3f4c0ed0926e8f394a9a6177b5c2221c3ce9fa11d40aa2ccc3adfda0d67be": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u0000d4097c649064b761c524ec17e5f2015a2d61b67fab204a463c9931935b396765": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u00009e9f5fb3939f6c8da5f52bd89bcb0d5fe56b84f26b9de059f9654efb58d29519": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u000052c9763d938a01b9d32bdf08778b37e43900627eb0c86c5775f8a6569cffec40": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u0000602eb871c0abdf186def7c89a090d7189c93bac660ec622675c1f6bdd2a285f8": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u00003c22e30f19a83566f84b708750e3981c85a7dcfa8a5a780afc1517a315907598": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u000065733b9abd44bd6e2b9b71e515f73c7d47d398b15346664122b108e8a8c500ab": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u0000fb3b90692a08a6a53a5df4bc1e93a88b52a910ff469d13247ef259d04e9e753a": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u00006b9118dc7737ab739b3ff82794071c84697b6a25e69d97b365d1580ac1a64d3f": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u0000a8006bc294c8dceb997330b4e4a904853ead4e59653ee6e948db04633896a2e3": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u00004be8ae7cdad8962feed8c1eae698e859d28c8ae2f29dd668b1ad6eac2f228132": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u0000f065e96127c1a6453a117f0c18a4bba3fb53276f0f807299f24193aeb6b185e7": 1, - "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md\u0000174f731e67775927dfd3da433d7c09d60d70caba44e1d95b0cf67aabe52a1d34": 1, - "docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md\u0000a1934e752e760be97eb509c3033a2e53bee993589e51f9b88baff20efc7c49ad": 1, - "docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md\u0000a03e4871d22b22ba755a2112a66bd4a177bd37cf7c3472dc96af093ddc69abd6": 1, - "docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md\u00003ae8342c0e0eafc78bfd75d5dcbe2f9ccf1de5d2e69372242768b0fc887c9a5c": 1, - "docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md\u00009210b004c03483aa48500bc08554dc8833adcdbb1d8068ab8fc314c989afe2a4": 1, - "docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md\u0000b8f769fe65466ab150815e5a9fe3537bcd67448b63e1be948dce3f2c480f3b14": 1, - "docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md\u0000e4ae0d2ac489c7ec1d4a15b6c8f4f6be6740d6ad41bd504935fc26ef27882c2c": 1, - "docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md\u00004aae14cd919ad3f9ee9af23c68ab86bfbad32f03035bcbb9a8dd381ce118576b": 1, - "docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md\u0000d9068a2b7360687b20cc86359e95c72ab1b22629b94d777572ebd782d6b8bddc": 1, - "docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md\u0000243e7c55a0524648219aae21f426a3ea572001a51a695b27711f0dda7d7d2789": 1, - "docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md\u0000b4ed53f9cecbf30d83e3ee52952bce89c0963ee932ef786ff33c41bc37aa7d02": 1, - "docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md\u000075ca2112dd827023f724759d253d0e3436b9cd1797c76166c952e53f8499e8b1": 1, - "docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md\u0000152328ee302e400e62a042df24a009268cc72ac7fdaa6038988958063f14b888": 1, - "docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md\u00009a4654de7ff834f53b4adbda35109e893c0405c2272e096c83fd7de71f084afc": 1, - "docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md\u0000c71e3572f198ca9f842a1f17cc845d8578e686bd48169ffc7456b8d5a43a3a86": 1, - "docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md\u0000e4d048462697c6c73db23a8d72783e6a411d2280699e7f851a1da4c14b18aa6d": 1, - "docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md\u000018044b6302ea7e25a0cae1852203168eec2a662edfdb5ae1974346607039f873": 1, - "docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md\u00009f2ca27c4738cfa55712a7b58727e388c2db2e4cd89dcb468face805ada11235": 1, - "docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md\u000062441bcdee1f836b8b69a325b031b5619252e790ae501ce12a05f6bd5548ed22": 1, - "docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md\u0000791896fdc1b6eee023def6e8a47d57271359e2ad94aa5f72f02b9fbf338ac38d": 1, - "docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md\u0000dae1405ec3bb1f395eab920cc2382ed36b507f576680e1be14d022fae54d571d": 1, - "docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md\u00000db9b9da879ac11eee6a09f2587761d765e4b03031059a325120a60963611105": 1, - "docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md\u0000978ad15e9ffc6e43d7ba26f647b549a7d6e8e4777e01280b110fb38d80daa954": 1, - "docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md\u0000797175db826f084e05d6f228b93c88a44c2df87d34d1c174035bc770e49e9419": 1, - "docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md\u000078b0d432159a5c712dd092c7294eb64c49b2e0160d67ed036347e24a74d5c072": 1, - "docs/technical/NODE_SERVER_TEST_AND_DEPLOY_BASELINE_2026-04-08.md\u0000d4f8759ca28f51a9b1aef65a7eb4486963cf11633bbbef5379833a7736a47fc8": 1, - "docs/technical/NODE_SERVER_TEST_AND_DEPLOY_BASELINE_2026-04-08.md\u0000abb63dcb46c3b47874d2cd713c2239dbb781764a3127910fd6775d0ec9f9ac50": 1, - "docs/technical/NODE_SERVER_TEST_AND_DEPLOY_BASELINE_2026-04-08.md\u00002999493aa6ae7e0753b68c47e93685cbd07ed660aa8cdc2952d6e04219527a5d": 1, - "docs/technical/NODE_SERVER_TEST_AND_DEPLOY_BASELINE_2026-04-08.md\u0000e36fcdd62b637e8ae5a386bc1637e365dea311eb5694dd12cca9c39378541560": 1, - "docs/technical/NODE_SERVER_TEST_AND_DEPLOY_BASELINE_2026-04-08.md\u00009623dbde479c313aef97f2423c59eb0ca44c345a8ac47a13cfa006e24842df23": 2, - "docs/technical/NODE_SERVER_TEST_AND_DEPLOY_BASELINE_2026-04-08.md\u0000807e68ab8a82cdf9ec76e553033af5abd6218b2c35ac9d52ac4a876fed22e0b1": 2, - "docs/technical/NODE_SERVER_TEST_AND_DEPLOY_BASELINE_2026-04-08.md\u0000dd6ef146dc9d71260c0cb7fe7f4db589f51bede47a36b9bfa158e611a9401b1e": 2, - "docs/technical/NODE_SERVER_TEST_AND_DEPLOY_BASELINE_2026-04-08.md\u0000ebad9eded209f541410f210ec3d90982332d2e45e8c8d40d97239e442d1a46d5": 2, - "docs/technical/NODE_SERVER_TEST_AND_DEPLOY_BASELINE_2026-04-08.md\u00002e8695f14a84106c53d5eb4ab79c6ea30029a191e5c2a3c379177cf9f2a3fd18": 1, - "docs/technical/NODE_SERVER_TEST_AND_DEPLOY_BASELINE_2026-04-08.md\u000043093e1215fd72b8820f01cc0592054948f7f04f1134a7d065c3a0e74b79413f": 1, - "docs/technical/NODE_SERVER_TEST_AND_DEPLOY_BASELINE_2026-04-08.md\u00009737be500d23078ba3ce24a160ecd3b684bd66f741f11061efeff219799fc717": 1, - "docs/technical/NODE_SERVER_TEST_AND_DEPLOY_BASELINE_2026-04-08.md\u00004134c896b4e80e39571cd4aab44c2203a8d511dc52ef8288a5c563629ba137a0": 1, - "docs/technical/NODE_SERVER_TEST_AND_DEPLOY_BASELINE_2026-04-08.md\u000069dffa0d1bceb721e7478511310ea3f314da464f66479f46f3b35a33fddffbb1": 1, - "docs/technical/NODE_SERVER_TEST_AND_DEPLOY_BASELINE_2026-04-08.md\u00009683c102bb091fea9248852b9db27fbbe5d2a93223513999d5ae2315a9d94b90": 1, - "docs/technical/NODE_SERVER_TEST_AND_DEPLOY_BASELINE_2026-04-08.md\u0000b00c00698e40000492198a66d93ee72353ec763d43a1a4f5d1a307eb772c8087": 1, - "docs/technical/NODE_SERVER_TEST_AND_DEPLOY_BASELINE_2026-04-08.md\u000044f6966c4fa11bf14d7aef87f231e99d7b37502bbcde3c2a67e31e22d0409707": 1, - "docs/technical/NODE_SERVER_TEST_AND_DEPLOY_BASELINE_2026-04-08.md\u0000990cf374343218408e9d99f4b5f1f852341f8c215b7b9f9e337e3a3fe10033be": 1, - "docs/technical/NODE_SERVER_TEST_AND_DEPLOY_BASELINE_2026-04-08.md\u00007e1184b6e7e1a879da1eb574f84df2e82e4346645f92093ffd6a4f4aa8905002": 1, - "docs/technical/NODE_SERVER_TEST_AND_DEPLOY_BASELINE_2026-04-08.md\u000061221c345e1adf879ec3e377d4ed63f92cd12b0833405919636fd1f4830e3d83": 1, - "docs/technical/NODE_SERVER_TEST_AND_DEPLOY_BASELINE_2026-04-08.md\u00009b8a035c3bd73a413bef72b573a7b8b123e70cca4c3dc4c477d359e82e05fafa": 1, - "docs/technical/NODE_SERVER_TEST_AND_DEPLOY_BASELINE_2026-04-08.md\u00004467e9b932bc91869cfc66a9d0f689005814ecb25c3cdf9204c6d0ef68c10347": 1, - "docs/technical/NODE_SERVER_TEST_AND_DEPLOY_BASELINE_2026-04-08.md\u00000ce00ad15126e9fd2e4ea1a86b80ccdb84d5ba15810c1bf2bc0ccf3567302bb2": 1, - "docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md\u00003a2d7782f0a4f4817583900757cef922d04140a0de0c82492853ed68bf711c6b": 2, - "docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md\u0000a791e35387f52b5ead2b96e76d3be3adac91796567a65f44e37f2ed2b26fc859": 2, - "docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md\u000089b4fd0bf98dfd0877142301d8f0799c6f4359e4c8e1ac493bede72623f3aefd": 1, - "docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md\u0000fc5140d5b04889e05d9d7c01422bd8787904be15e51a3c3f9d12fc4c4709a6a1": 1, - "docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md\u000024a215d39b398d1044e0d1e995c18eed28984e11cc6780e61f15c97957d124e4": 1, - "docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md\u0000a7ead5180417817de91dbee361c9dc2416ed78065a44e6268971c915ee7b5ad2": 1, - "docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md\u0000d349d2c681c850e16f09d21b7484bc1555fb9e5f8be188eb26757259462609a2": 1, - "docs/technical/PHONE_SMS_LOGIN_STAGE_A_IMPLEMENTATION_2026-04-21.md\u0000a4850e5dc56a84b5728492e1ca10a09e78c04a4d5dd64ff367fa82abf721bfaf": 1, - "docs/technical/PHONE_SMS_LOGIN_STAGE_A_IMPLEMENTATION_2026-04-21.md\u00008abab45095a2ee495fd98137bea0a3145a8826a386faaa859251570695913875": 1, - "docs/technical/PHONE_SMS_LOGIN_STAGE_A_IMPLEMENTATION_2026-04-21.md\u0000e621e8b40459a3324a27843263d9ebea854ed3e1ed9806b03f3ba0450e0b727f": 1, - "docs/technical/PHONE_SMS_LOGIN_STAGE_A_IMPLEMENTATION_2026-04-21.md\u0000ed80a6d58958e855c785da46d1997b20615d7b9dd8ed411fa6987952504b55c8": 1, - "docs/technical/PHONE_SMS_LOGIN_STAGE_A_IMPLEMENTATION_2026-04-21.md\u00000b06979366a1d5b697a5d0082114aeda15ec8b6c2740b8943faf8df20dd666a5": 1, - "docs/technical/PHONE_SMS_REAL_PROVIDER_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md\u0000b3d7b6912a297f1466859148d201948e426bf05bf3feb24acc60286c16dfc830": 1, - "docs/technical/PLATFORM_ENTRY_AUTH_GUARD_AND_ASSET_404_DEDUP_FIX_2026-04-22.md\u0000dd2dcecf0eee6ff77508fcdddfce60d383b9afe334b37ccaa9b33ed86b1e0251": 1, - "docs/technical/PLATFORM_LLM_TEXT_GATEWAY_DESIGN_2026-04-21.md\u0000c53d68764fafe84e4ba92945942ef1aeb15b1ede2a0b9dad94e014fc34684f19": 1, - "docs/technical/PLATFORM_LLM_TEXT_GATEWAY_DESIGN_2026-04-21.md\u00003ab9dc54b929e1e29da5526ade699d0e76b69e78b2a0d2ebf312d37eec013cf7": 1, - "docs/technical/PLATFORM_LLM_TEXT_GATEWAY_DESIGN_2026-04-21.md\u0000fa85abf0cdb070e2d7d53b98215fcc6b3fbe7e92b49e534b88c7c91e24bb2d87": 1, - "docs/technical/PLATFORM_LLM_TEXT_GATEWAY_DESIGN_2026-04-21.md\u00000e8c820f7ae87cd41614b6db388dc1bb15fdfeb94e9e8bde02b0bc49301d00a1": 1, - "docs/technical/PLATFORM_LLM_TEXT_GATEWAY_DESIGN_2026-04-21.md\u00000d99115b2f09f7df44decf1f5aca1881bff58a53a5530546acecda959a7870c7": 1, - "docs/technical/PLATFORM_LLM_TEXT_GATEWAY_DESIGN_2026-04-21.md\u000089f42edfd0d635845cc1ecabde04960f53890e6d3aa98f1d5206621d56a37f12": 1, - "docs/technical/PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md\u0000cf06fec5e5e97390b7edcb0b8a7b1c75fdf3a9774909b927c87fe4239c573a9f": 1, - "docs/technical/PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md\u00009eb5b944d06ee75016b2cf39deafd6cee434a4ebad79c5405f0626a7c7260cb5": 1, - "docs/technical/PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md\u0000795c38432ea566a14cfe677ea4ba92cac69bd745d5a0f5399d2f99beea8cdf96": 1, - "docs/technical/PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md\u00008f45353c56526e7282ca9bca563525e03b5da029767f15bc7b422c48fa902d94": 1, - "docs/technical/PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md\u00009bc31d73fba2d0324350698a2d3f6bbf5d7009056cfdeff44be6b191bcd0acd8": 2, - "docs/technical/PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md\u0000b4ed53f9cecbf30d83e3ee52952bce89c0963ee932ef786ff33c41bc37aa7d02": 1, - "docs/technical/PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md\u00005f7e037cbadb3a9ce3352d54b2f6ceeef2546391263e847b67553cf8fe6e0d54": 1, - "docs/technical/PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md\u0000d553a71f74cbed3393b06878acf1fcda835097cd3a6f0100ca8637c682b8d3ae": 1, - "docs/technical/PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md\u0000cb53ceb2a4669a83a5b67fb2134bb718ccd5a958bcc309ada5355f21018b4b64": 1, - "docs/technical/PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md\u0000a1e2b7aeb92fd40ce43e2cff88a47dbcdcb5e6714c469720f946538fce2c3d66": 1, - "docs/technical/PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md\u00007c4362ea636f121392ac683fbda5873ad4ecd5a6f023d64cf9a9c26bc2028b11": 1, - "docs/technical/PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md\u0000a16387a2480116743883402650c6df05b6dc30e7cd2a705d1e8962473a28b0b5": 1, - "docs/technical/PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md\u0000af857ee1d6f8fbe6ca3c988c28b78a04c8d82782262448c073594101f7b04420": 1, - "docs/technical/PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md\u0000195c1131fdbebecae39e32fc3ef16f501103aee57d164740600b09eb33d0a6e1": 1, - "docs/technical/PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md\u000026ef27a5bba638c22ed22fd2ac64c253ede228a16a7a09cc283c0c5ee71fa4b3": 1, - "docs/technical/README.md\u000073dcd2e6f2c467a72eb3010e5e4f5e4ed8409b899c8e7d85147fa28c57e18da6": 1, - "docs/technical/README.md\u00000d46e63be360630a5bbeb7620426b5cdc70cd80d64d5f019e65a853da25904a8": 1, - "docs/technical/REPO_NOISE_CLEANUP_BASELINE_2026-04-19.md\u0000494c45e083234e81930ed248689967ae9f416c6bc97c0d6df86a3061c45aca07": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000a4ee4c04b0af9bca6da7f717605d360c3eb1d6f4bc5d1380f1a7b81e70f95be4": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u00007bdafb42bf0bfe081323c957dbf08aaafaee8c656d31da865a42014dfdaaa7ad": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u00000946fbf7b1b7e883cd4f851f854dddee47dbd99eed30c45606918957cb4df932": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u00001d586886fd98692b97757fd82928d4a81422f5fac596d65a0d74ebdb32713cd3": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000d933230e173f421e1e8342d3679007aef286b4384eaaedaabafba115cdd547c7": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u000041d045f2f55cac56dc584861b07e509afab98b6f6fe54c114fd53d0a0e64c726": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000f582a1936f53f278a2252bd199e526d6249378414549f009839f34bcf489b203": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000d3040fd2e2156c77c68040046b4bca830c5099ed5ab06cd2e8085fc148185f54": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u00007c2c86bf7fa8c5c961c5b6ad3fd3e31af16f826139b03f858d10697aa6f54bac": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000fc24ff4fbcbb26ef8e52af7113c4d78fb868dd6d1c72a3bdee2df3a374a5e60b": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u000069293ea9e90b164a56dec6dadf28bdd917617bc2b74caa89cf0bb420dcff08d2": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u00008fa9ed07c1646bfcec3c9ba0a86c381fae03bcc7dd71ede8f8fceb6ab28442fe": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000ea488107b28d49f96d685e785b506ff4f3c1b95894b3bd127c5139c14e1e7ecd": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u000048c83c3ea5acb5ce6c0fbda2c72b0ab6ff27d2abcb13b461004ccb7441b4f20d": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000e0de048b31f56db7d151a0f9eaeebd88e130f163c1f804f931f85b8663e69a8c": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000019a4b2e5c6da6097103a3b60de4d27745922d1606ff268e697000df39e97791": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000a2af44495a5201e018798ab6652e6cfb2cc6e7fdf56b6de585c5ad62bc76c272": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000a35d970713013a9dd6fac050eecca7e972a3c36a4795bcdeb6a14501e18b239d": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000970c2ad26619c51dda1d0bbf32554bb2d811b1e2ceddc889db009ba56f7b4b82": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000bf33fb174da7cadb9304188f1600b3dc345edf192da00231dfc66568480f0cf0": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000042af89f092b0ce9f8859aff740165eedd2403500f460c3b4873980d92949e31": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000f0851d3243db439ab3073f6aafb8709b69284a25fe0cca588a8d43734e7fe706": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u000045fb386eb6115e2da57065ffa3969df86bc8ca2bcbcc06c15fc938e8a7a14c9a": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u000057e7f25ac023fbc9ddb12051dc780426cdd969ba3ab594293303dfcd261c1308": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md\u0000cc2aa2abe0f8303391b0064d1074086d98a7c4f9a82ec6f2df6d9e1e678cb22d": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_OLD_SCRIPT_REMOVAL_2026-04-21.md\u0000dde2a7a524a217acdf4ba76fe624cb658e6f5c2325ffe60b6cb98f2b15cb9527": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_OLD_SCRIPT_REMOVAL_2026-04-21.md\u00009142bf6a0f901ccdeb54204c0179eb45da1026299b16e2e769d770c52022fd5b": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_OLD_SCRIPT_REMOVAL_2026-04-21.md\u0000480559055abad5a11a3dcfe2ee89acd725d6a36aa544be431105df05e6659398": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_OLD_SCRIPT_REMOVAL_2026-04-21.md\u000047742a683d4b11596b7d59bb42832e298fb74cedb737bba729a5c8a0fc61392a": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_OLD_SCRIPT_REMOVAL_2026-04-21.md\u0000b8ebe80d90b33e1b62310cca1e0eef0e50ff5d1d80d0ae791a949103bdea8a61": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_OLD_SCRIPT_REMOVAL_2026-04-21.md\u0000b789841afefa17a1f488a90ff08e6ff2efd197e3f3a620c6eccdaa15e2124b44": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_OLD_SCRIPT_REMOVAL_2026-04-21.md\u000057da2042b1c55789ee634d380ecdf2faeebb3589e8b0c9ae5a5eeb98378a8874": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_OLD_SCRIPT_REMOVAL_2026-04-21.md\u0000823ded4779cd8ddf5efc6598f7500ff82cdcbe1dde83a42a19d4bc6438d7e3b1": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_OLD_SCRIPT_REMOVAL_2026-04-21.md\u0000832e358425f50391c5ecf1fe50e1cd48f06bc2f5c4f31231d112241058ed91b2": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_OLD_SCRIPT_REMOVAL_2026-04-21.md\u00003e80d7f806cbcb5c936dac5bba9eab3d596ddc8797abf75c1f719ce35b96f7e2": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_OLD_SCRIPT_REMOVAL_2026-04-21.md\u0000938389b39bda2ee20f5fdd79c55a20edd1363fbc59fe418ff4e3e1af35b907d2": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_OLD_SCRIPT_REMOVAL_2026-04-21.md\u0000ba935bc1fae61f152dbe40ad3ab6b267320d066cc326650cac2d02c96e1dc285": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_OLD_SCRIPT_REMOVAL_2026-04-21.md\u00008721d1237d54347318dffc3752910015a4e904e7063242957732a7e5b793dfaa": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_OLD_SCRIPT_REMOVAL_2026-04-21.md\u0000559430b1dbce6998d2f601bddfb9ac431834d73c6bdd7226862a0394980e7dff": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_OLD_SCRIPT_REMOVAL_2026-04-21.md\u00009b6b7e03babbad660b4af41dffeb0764b24b0777e91384c93dff35063846e3c8": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_OLD_SCRIPT_REMOVAL_2026-04-21.md\u000030a57281b06b7444fb578d0aee3a0ad370404acf07a117250a1939f0609a430f": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PARALLEL_BATCH_AUDIT_2026-04-21.md\u0000c1563ef713fdb0b682920fd5ec71d499c320f0b66e33a951c6510a3945430b42": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PARALLEL_BATCH_AUDIT_2026-04-21.md\u0000e848bcc311d2c6bc09fcab3aaafba8276c824bdb3f00ca42a206ca167afaaa3e": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PARALLEL_BATCH_AUDIT_2026-04-21.md\u00009215d86a8e467a98e4198f776a10f471fccec1bedb783e844add021f2a9b9dd8": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PHASE3_CLOSURE_2026-04-21.md\u0000a2af44495a5201e018798ab6652e6cfb2cc6e7fdf56b6de585c5ad62bc76c272": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PHASE3_CLOSURE_2026-04-21.md\u0000a35d970713013a9dd6fac050eecca7e972a3c36a4795bcdeb6a14501e18b239d": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PHASE3_CLOSURE_2026-04-21.md\u00005f62f6d3a6707fc57ac154db48643e21672ff1616870a05051b712b1d8a54f02": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PHASE3_CLOSURE_2026-04-21.md\u0000c728b783a4374349ac567b64d048f6437ff0b08fa460d3c285aa5cfd4db26dbf": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PHASE3_CLOSURE_2026-04-21.md\u0000b4c58b7efb6a58fbb4bb254e17e2b39913f4abd090c4344d82548b27f2956e27": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PHASE3_CLOSURE_2026-04-21.md\u0000c317b6a472d8b65e72ee0c3deb534f37acef761187725def4c6e7aae45bfe077": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PHASE3_CLOSURE_2026-04-21.md\u0000481bfc3691c1b64417d6f03cfb4af67b9c24e7d7eb5e3bba157d59b6c597def9": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PHASE3_CLOSURE_2026-04-21.md\u00005c0a8ec64889e67f4551e76d8229d8e2b7f0dc84ddc9ad6a8e0abee228e42f2b": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PHASE3_CLOSURE_2026-04-21.md\u000035798cf055e995a6aaa4acf3a04f57614994089ebfe8414e02bf8d1587f078e9": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PHASE3_CLOSURE_2026-04-21.md\u0000dde2a7a524a217acdf4ba76fe624cb658e6f5c2325ffe60b6cb98f2b15cb9527": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PHASE3_CLOSURE_2026-04-21.md\u00009142bf6a0f901ccdeb54204c0179eb45da1026299b16e2e769d770c52022fd5b": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PHASE3_CLOSURE_2026-04-21.md\u0000d049ec8a7ed9f0e8a4d651bacc8636c5825d3e17baf999a76f8f11838bae1219": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PHASE3_CLOSURE_2026-04-21.md\u00008ea06440d7b719c83f30c93fedbc97960cb4f8d933c808dc9348e15f3471021b": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md\u0000995f0ff4c0e6f92457752de7dbeb2476730988cdd9f3b7b84a6396fee85af99a": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md\u00008535442c97e6da71bb5539ac0b14fd459a9e2fff41b9b862ac182cbf8954c11c": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md\u00004d517271f66e625dfd7348c7469fd24f69e22a94d532c7df2be63e393cbf5dc5": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md\u0000c6da1c5ae2422714752cf11f167dd82e33ea6cf191dd5dc7695bcfeb83ce2b88": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md\u00000b11b704e54afdedbf5f360fd9abcda4623877c75162335c28918afa0d270de6": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md\u0000248b1e903ba578008d2b1c86d93fd61776a1923bf9705287f82f7439e2652585": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md\u000062f7ceb85e4035a64ae053eec3d1ee195eedc7ee68a8659997c7501ae0432198": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md\u0000c8f0d46727ca9636a76d8815cd73de2b11eccec49424481822b75b528c505565": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md\u00002d3fc0d8b64470523c97eae73d2a90efcc812cf4b4ff30de65c651f4af39e27f": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md\u00003bcf572fbc84e29b1e72d665bbc9fdb9980e69e9998a3bc92925e9f8b6c85449": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md\u0000a2af44495a5201e018798ab6652e6cfb2cc6e7fdf56b6de585c5ad62bc76c272": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md\u0000a35d970713013a9dd6fac050eecca7e972a3c36a4795bcdeb6a14501e18b239d": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md\u0000970c2ad26619c51dda1d0bbf32554bb2d811b1e2ceddc889db009ba56f7b4b82": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md\u0000bf33fb174da7cadb9304188f1600b3dc345edf192da00231dfc66568480f0cf0": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md\u0000042af89f092b0ce9f8859aff740165eedd2403500f460c3b4873980d92949e31": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md\u00009f26f2530a43411d9cec58a9165bed35ac14888a33b80c7630d21a0fa201c74a": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md\u0000fc8ab1297cd7ac2e646d18c91205d039fce0b8f744d483073191fb473288a755": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md\u0000db4ebb886ebea760f2a49f6e95786433f21d9e3a24205e5671f8dd36df848609": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md\u0000ba4e18a6b24aab0cc3295395978144dad815ab7d2432f4e79ab261155b668034": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md\u00008bcaf969fbb62523c9fde1477c94ad8d863ef2fbf8bf046fc3a8bf7b88cbb7b2": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md\u00003acd1b353650cbbc570c7eebcc95007a4e202b7ba6ed9ae8d1d81b1deacbd75a": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md\u00003c42d4ce282edacb4ddf8d5a819ef000c7fa7e3aa53f865208d3a6f1d248b70c": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md\u0000fa192b7f997db5c6c2f2eb689e2834253915b8b7b136baa322df5e4099f7a856": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md\u000078dcb6709b03cb77fcae9d83a5f01a7b8178236b81808e262c91afa0eb34c645": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md\u0000656506090a9fb714adfea825d5859dfad9434afc27f88339e7c43c01fb6c96c4": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md\u00002a527885fb71277e5457541f0142548608b8d5fc5e0fdb4296f27592a13142a2": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md\u00006b6ff38ab10aafcfa34ff28f63413f8e85bd9bdbb1e58720240f602c19ee309f": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md\u0000dc7dbba01aed2482eaa03cb8209c59cd66578d56185f12bdc2fd499561e52b28": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md\u0000ef9a0d46ce8dfa04c178e95cbaa866e7ceb2111650e26c5a8fcd961194e5e82a": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md\u000066f99139e9706c1584006b493a0a0570f96feedf1eeaee309eb1a65095f41cf0": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md\u0000f102cf3dfec7e411652f7a13e822827f6bdc68c7cacd7f0b3923bbecffc7809f": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md\u00002e28c0f8a0df13a1718150a99f443bed57bf78d7f83605fa9e8f166f409f8235": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md\u000065ac3b68e11c29515ca74045094483d56ba90c0dc3344640f900461679a3702e": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md\u000090c6ee38fb333b51a4ab29f44c36cd0408e182d2934c663abac7611d98af6490": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md\u0000906d2030c8180bdf7b9275e431c82822bb057ad533d860ddbcb7d1c1d165c9b5": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md\u00007d031c48db6e80aac5d366d649c65a21374c4740661127518559aca6cee62c85": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md\u000000532658521e6db306703dcb98c679524175a14eda9a3b7d66c5ed76a387d9aa": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md\u00004dc7ceaa226e1696c53dc56e88ec1b31e4853859a6102538f8e7973b6ac59207": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md\u00003cd852241dc72338da38d83c068b6d74067baca2cd05c153380977a7f1c58ea3": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md\u000054cf46839913783bd3794cbb71fcb65f88d62b12bc5631464eb2f33eb187001c": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md\u00008bd382b637b258a1eb861dcc6a1ea8c12a3906ac6a450860e1b8fe3534bfb696": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md\u00003742417c38bb0104ab8a308e92d2bbc30c64c50436a1dda1f0d4ae86e2ea5685": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md\u0000997effefce8502a6086a9c62d6c16324453fcfd52e2cc370c50cfe21689d9ad6": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md\u0000e7e0ca895610293833a0a33bc34281bb54b3d21bce02d01a69054736b2bd1f79": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md\u0000da20f329a6e4126ed7e1b79e98a7079991f2d2a0120e847db076651bbaeafbfb": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md\u0000b336150ba769d710d76039142f11ae6f49b5906fbc18d7b726d63006323b81aa": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md\u00009e941fbbb5c5aedb0161f6037aab66e4e2e4a2dca98ee5058f01ea0c40d0145b": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md\u00002734d011ecdf4640a818d71a857f42b0d9f880af98eaa0b2a87d996d7bd1b567": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md\u00003a72bf11728976b17e975b9d1fa5051d2edd49927cdb3da8e181ec732b1e0ea3": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md\u0000d5ce4695f438602075bc275b1ce50a637d77667d1eec70ada761216a1c988802": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md\u00008c7735e827981f74e75b6399bd4d664160f37d6c349d2ac282e5af4a39f98531": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md\u0000894b0b9d1085468343a1ff74e930894b03b30b05f0cd18b37800f24501ab0d3c": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md\u00008e18a8f6457941f54f86ff92d1152ecab06af0ffe8bae598029c7fbc11a9dff6": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md\u00001c6b6f4e2ffb056f2ea08c113cee6c3c52538c700e0e0115785c346359ec65a6": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md\u0000e6f42e536e540258d5a4cdf340487e03ce1a61ca9e81565b78cbc3bddf606d9e": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md\u0000979d094841a3a22b6f9ba0d8d5e073f72fcbd7dbde0b09d48861596281a39993": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md\u0000da1004ea2cd56b6789231e5f12bafe4730c51e13234ba1c430d267a250df7a4e": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md\u0000359448756c4cf1896b6c21cdbff3f639ea9424f509976a704a40a2a9b5788ddc": 1, - "docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md\u000090a985f544b618fca01c159b15a730b6d90527a160cc30283076c2adfdf28540": 1, - "docs/technical/RUNTIME_STORY_BACKEND_BOUNDARY_MIGRATION_2026-04-19.md\u0000dab988fc46f56d8ddb8bf44cf3235b62945c0d2753da8762cd2aae843f4e6c40": 1, - "docs/technical/RUNTIME_STORY_BACKEND_BOUNDARY_MIGRATION_2026-04-19.md\u00007d379302b9ed1690e31b2731d2833bab81d94c4e8583b6b06dd9da0e38cadd71": 2, - "docs/technical/RUNTIME_STORY_BACKEND_BOUNDARY_MIGRATION_2026-04-19.md\u0000094cafb9aebdfe1afc79dc2389a16247c952f0a20f98aa79361eaac54c6f565c": 3, - "docs/technical/RUNTIME_STORY_BACKEND_BOUNDARY_MIGRATION_2026-04-19.md\u0000bee35a59b96a30d04ff431d6b174d4fa43e90f4547bf4c027a3e5440f3e283ec": 1, - "docs/technical/RUNTIME_STORY_BACKEND_BOUNDARY_MIGRATION_2026-04-19.md\u0000f54e666637fc02ac460c0c664751b058127fbb20465346dc33d7e2c1b07565ad": 1, - "docs/technical/RUNTIME_STORY_BACKEND_BOUNDARY_MIGRATION_2026-04-19.md\u0000f7e0f38ff9567196b22eabc04cb0a4437397a091b73023c22c64e74164e72a08": 1, - "docs/technical/RUNTIME_STORY_BACKEND_BOUNDARY_MIGRATION_2026-04-19.md\u0000e8fbc4d893d18ff497853e5f739b7501a237c881d8bf48cf9c0c0f9498d49258": 1, - "docs/technical/RUNTIME_STORY_BACKEND_BOUNDARY_MIGRATION_2026-04-19.md\u0000a9d05a236c0afba73d609f6be9d3d0011e764af835576b35510a84297af31f23": 1, - "docs/technical/RUNTIME_STORY_BACKEND_BOUNDARY_MIGRATION_2026-04-19.md\u00009638d38b34fe5087ed0b489e041ef1bb05427b5e4bc4c1791d723a0b78103259": 1, - "docs/technical/RUNTIME_STORY_BACKEND_BOUNDARY_MIGRATION_2026-04-19.md\u00003d78c2a1286b9504f7710a515d3e012feebc0737cd7e1ef2b82953a43e3bc504": 1, - "docs/technical/RUNTIME_STORY_BACKEND_BOUNDARY_MIGRATION_2026-04-19.md\u000093ef03c4f6d5506588f912a2fd50da6012e763d8cf386c699ccbeeff73fb2415": 1, - "docs/technical/RUNTIME_STORY_BACKEND_BOUNDARY_MIGRATION_2026-04-19.md\u000006059a4d462cb3619e3e858a49008c01ca2692a5fbd949453cd0f072942c34d9": 1, - "docs/technical/RUNTIME_STORY_BACKEND_BOUNDARY_MIGRATION_2026-04-19.md\u0000496c8786d5c350c769cded8719e739ed8a6f00c21d726c7b5d10fb1438ed4289": 1, - "docs/technical/SCENE_MULTI_ACT_CREATOR_IMPLEMENTATION_PROGRESS_2026-04-20.md\u0000d771ea0e8a6d5f846741b5bab102f14276b4985aecb5efa2ccf35525131a212a": 1, - "docs/technical/SCENE_MULTI_ACT_CREATOR_IMPLEMENTATION_PROGRESS_2026-04-20.md\u000030c0ff968c301b8f298a3a2656d3a5fd20e34f84b9213c8acf7cd6e5d4dd2020": 1, - "docs/technical/SCENE_MULTI_ACT_CREATOR_IMPLEMENTATION_PROGRESS_2026-04-20.md\u0000a3e0913a227731b54bd92cc124ccdf9c9832ea4877b93c8d2dfb496915484e24": 1, - "docs/technical/SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md\u000023c96e2eb4aa2e488188d29ffaf483556848453c1ce35a959cf4302803273d87": 1, - "docs/technical/SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md\u0000f9afe7dc078db37d489590d227027b1dd44b974e7f759f67227190a461052eca": 1, - "docs/technical/SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md\u0000b8028a259a0ded9a7009d67fb4dc00e7c915711e1af061b9df2032e8ef118493": 1, - "docs/technical/SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md\u0000cf06fec5e5e97390b7edcb0b8a7b1c75fdf3a9774909b927c87fe4239c573a9f": 1, - "docs/technical/SPACETIMEDB_AUTH_AUDIT_LOG_TABLE_DESIGN_2026-04-21.md\u00007bfdbda72abae646063f8ad2a4a7fb737923cc5ecb989ea53979a4f6fb5e03af": 1, - "docs/technical/SPACETIMEDB_AUTH_AUDIT_LOG_TABLE_DESIGN_2026-04-21.md\u0000901d66f303c6075d11accfd3508246d0df2929b6b2bd76009a4ad7248a88ce1e": 1, - "docs/technical/SPACETIMEDB_AUTH_AUDIT_LOG_TABLE_DESIGN_2026-04-21.md\u0000c7ddf6fafff73ac9640e0b9a696fdaaea490f456befbbe473c627aa2b086cbd1": 1, - "docs/technical/SPACETIMEDB_AUTH_AUDIT_LOG_TABLE_DESIGN_2026-04-21.md\u0000e2c9fe811ef1aec79cd321de74cf8894e353051bc7df58bb4eed71ad7c7ae981": 1, - "docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md\u0000a4850e5dc56a84b5728492e1ca10a09e78c04a4d5dd64ff367fa82abf721bfaf": 1, - "docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md\u00008abab45095a2ee495fd98137bea0a3145a8826a386faaa859251570695913875": 1, - "docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md\u0000f7d91c78abe6119d84b9a1d6070a7cf952ee7fe67e40c222956b9097511c1e88": 1, - "docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md\u0000e8c045385829978a232e8f8c44a45265ed44d6bd931f54d90618dfbbdf4f6924": 1, - "docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md\u00005469756d43602560d166f0173baa92907b4de9604d75d2e4eb50b00f39174d86": 1, - "docs/technical/SPACETIMEDB_AUTH_RISK_BLOCK_TABLE_DESIGN_2026-04-21.md\u000095fd023685b2a019012ad7edde51d59ae2ba930a5de2b231bef3458f1b949ac9": 1, - "docs/technical/SPACETIMEDB_AUTH_RISK_BLOCK_TABLE_DESIGN_2026-04-21.md\u00008abab45095a2ee495fd98137bea0a3145a8826a386faaa859251570695913875": 1, - "docs/technical/SPACETIMEDB_AUTH_RISK_BLOCK_TABLE_DESIGN_2026-04-21.md\u0000c7ddf6fafff73ac9640e0b9a696fdaaea490f456befbbe473c627aa2b086cbd1": 1, - "docs/technical/SPACETIMEDB_AUTH_RISK_BLOCK_TABLE_DESIGN_2026-04-21.md\u0000e2c9fe811ef1aec79cd321de74cf8894e353051bc7df58bb4eed71ad7c7ae981": 1, - "docs/technical/SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md\u0000a4850e5dc56a84b5728492e1ca10a09e78c04a4d5dd64ff367fa82abf721bfaf": 1, - "docs/technical/SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md\u00008abab45095a2ee495fd98137bea0a3145a8826a386faaa859251570695913875": 1, - "docs/technical/SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md\u000089086dc64a0e1efb2c8228c12bee5a362f87dec9e83cb142e19484122eade2ac": 1, - "docs/technical/SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md\u0000c5701dc0dc7b7e586a0eb19ab5f3b0f5a5cb474714f253c10d752cc579c0e2fe": 1, - "docs/technical/SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md\u000090d76a63db8497c1b32a954246a8546d5ede1ba78166596af2c14721a7f612af": 1, - "docs/technical/SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md\u0000e891975c0eb1d7d82b1a8811429de102757fe53986f7794a4f9dd6a81eb68a09": 1, - "docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md\u000029bc2729237f5ba84c6eade80b69f97ed2649cdd43d23189a42ca1c48df72a83": 1, - "docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md\u000075eee2b06554479b4b2edca9ce80fd1fb1ecc1ab6278c6dc3961e8ddaeeaedca": 1, - "docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md\u0000bfe58e0f337dade72cdda134c420df68e1a7086a715e89d0a2b740fdfbc0462f": 1, - "docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md\u0000b54248b84a2aa172b91f691940cf9857dc2cbb79370aace46b1b86816be782ff": 1, - "docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md\u0000b27f9a9020ac388eefbe0d09e190fc6d40f5f8df1e7a65a98441e0c50abdcaae": 1, - "docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md\u0000a09d32d530c8f425c2b9ae5afca4ea3e4860ae7b8e9f0cc4da3303973e915154": 1, - "docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md\u0000c7ddf6fafff73ac9640e0b9a696fdaaea490f456befbbe473c627aa2b086cbd1": 1, - "docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md\u0000be42e0650928dc34d9b279a4bd3d69e551801e222ebe7374f4fee3a5bc4ac549": 1, - "docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md\u000079f713318f3c399d77ca577793570c998d7fcf48c370e659a3c32bca3ddda183": 1, - "docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md\u0000424960d188b1cacd0c3e65716be8cf5030c20f59f198d270821061b579505853": 1, - "docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md\u00001b215c7f2d091366bd0507d660070ab4dfde0457b207c7b642ed7155d019ec27": 1, - "docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md\u0000a4850e5dc56a84b5728492e1ca10a09e78c04a4d5dd64ff367fa82abf721bfaf": 1, - "docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md\u00008abab45095a2ee495fd98137bea0a3145a8826a386faaa859251570695913875": 1, - "docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md\u000024a215d39b398d1044e0d1e995c18eed28984e11cc6780e61f15c97957d124e4": 1, - "docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md\u000020ce2346079804a0a2b731888b56215b33cd10d0fe5e787237bd62e4d4b15e4c": 1, - "docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md\u0000c42e0d7fd4c14c126ae44f6c7fc544ec2e5f28e3eed3615b3aa1deb6a8401f90": 1, - "docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md\u0000e891975c0eb1d7d82b1a8811429de102757fe53986f7794a4f9dd6a81eb68a09": 1, - "docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md\u000034cb4897e5a43cf66e55e4689433748a751261ce185a037371ba1588691d9edc": 1, - "docs/technical/SPACETIMEDB_SMS_AUTH_EVENT_TABLE_DESIGN_2026-04-21.md\u0000c4f7682b65879b83f0ef1f2c5bdd7b9f07272d781bf8e239bd9e30236726ed80": 1, - "docs/technical/SPACETIMEDB_SMS_AUTH_EVENT_TABLE_DESIGN_2026-04-21.md\u00008abab45095a2ee495fd98137bea0a3145a8826a386faaa859251570695913875": 1, - "docs/technical/SPACETIMEDB_SMS_AUTH_EVENT_TABLE_DESIGN_2026-04-21.md\u00001ba32f8d3b49af3b21b789034a069a3c7587861b0a624634426a84fbace6e211": 1, - "docs/technical/SPACETIMEDB_SMS_AUTH_EVENT_TABLE_DESIGN_2026-04-21.md\u0000fc7600f0fabacfe95bbc71ca97d833bd3dbb4f625053c7ff3ba53fc60a384366": 1, - "docs/technical/SPACETIMEDB_SMS_AUTH_EVENT_TABLE_DESIGN_2026-04-21.md\u00005469756d43602560d166f0173baa92907b4de9604d75d2e4eb50b00f39174d86": 1, - "docs/technical/SPACETIMEDB_WECHAT_AUTH_STATE_TABLE_DESIGN_2026-04-21.md\u0000887cacdced758736b2aebd3ef169c64574ecbb8ca0232f4e4b2a268b756c46ad": 2, - "docs/technical/SPACETIMEDB_WECHAT_AUTH_STATE_TABLE_DESIGN_2026-04-21.md\u00008abab45095a2ee495fd98137bea0a3145a8826a386faaa859251570695913875": 2, - "docs/technical/SPACETIMEDB_WECHAT_AUTH_STATE_TABLE_DESIGN_2026-04-21.md\u0000c7ddf6fafff73ac9640e0b9a696fdaaea490f456befbbe473c627aa2b086cbd1": 2, - "docs/technical/SPACETIMEDB_WECHAT_AUTH_STATE_TABLE_DESIGN_2026-04-21.md\u000035fd82a69796b480f75e35bc0117508e097724d70d31b58b14d743dfdbd9aa44": 1, - "docs/technical/TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md\u00003bfd51469f90a4a5a81de9e94b36546f174e27c4af537aa5eac1db437b250141": 1, - "docs/technical/TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md\u0000310aec205a5c3e9de68292ed624b25572dd487454bd4d10e935fe5c15f0f7d2b": 1, - "docs/technical/TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md\u00005d8ddf4bec4e2dad46b05f07373b1dea9803c7622e56fbd1da2287dbe05f7821": 1, - "docs/technical/TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md\u0000806ba95a8ac023031f861f97375fa66b693d0d6b0380c3e6386fc14a5834bd98": 1, - "docs/technical/TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md\u00006f3779dccea09f6758ea5b1f8a431f5f056858549f8685817f80659f381885f3": 1, - "docs/technical/TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md\u00000137801f26b50e0665de4b494279c7185ede8e4e414b61ca4aa64d23a66b7bfa": 1, - "docs/technical/TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md\u0000dde2a7a524a217acdf4ba76fe624cb658e6f5c2325ffe60b6cb98f2b15cb9527": 1, - "docs/technical/TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md\u0000ad7892fbbeedfca860f48d7bddd80ecdf5d836c28b3d79a384b2a19c5a8717ad": 1, - "docs/technical/TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md\u000042567f5a28a493d571669d443d8fa2f7d173e7ffba037197bd0b58f626427648": 1, - "docs/technical/TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md\u00008045c32279aeb43e89b93769d3182246aa3c8c2b305f1b7959990333aee0a234": 1, - "docs/technical/TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md\u000045eb2014aec2855c232484530cf0cbfe205a2e760931f8ada7604a8fccdf9c88": 1, - "docs/technical/TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md\u0000ff6f2053f9afb63651ba25541b0cf9d8f3a8bc46b21c48e2f9319c7da391a6c6": 1, - "docs/technical/TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md\u0000fcb7de637ed287b102ec934fb6d3e2ab75d11a77b2c2b69884b8ccfd1d5f493f": 1, - "docs/technical/TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md\u0000ba2c025a53fc4fa5ec17de75fc481e7675a1fae9fdd858c39672696f74a69d07": 1, - "docs/technical/TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md\u0000b89c6db56b04c89e2255d02d4b77b798095d4afc7da66ae9bf05f4c1b82c2d85": 1, - "docs/technical/TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md\u0000c208dee3e34e5432782ab2c243aaf69b0b876949a6691bbd62715a0526519b1a": 1, - "package.json\u000010621cbe6c5a62867afa37a12eb07efa3620e09111b46a8fb18b8b0a0b282b7c": 1, - "package.json\u0000b9cef1a954fc23413bfc29f436416c2d81f3932509b3f5aa0331877eabf794bf": 1, - "package.json\u0000605821c2c1595f16dc3bbadce7919a3d4ea4e30b68dc5cdb981667e7b98fff21": 1, - "package.json\u0000ff4ab635930fc3f75d3dc1f131614798b5cba848183b60716aa5e20544255549": 1, - "package.json\u00007b6ba66e038021cee55e15a1b3c2d2e48c1acd9266dd7eb56b6e8a4749c2bad2": 1, - "package.json\u00001b0a207d09cd0b1af466e3385a031f9122a4de986afe0cfacf31e28a2ad3c196": 1, - "package.json\u00004998e6f4142e735d3076dcbc95e9c271801c574acfa88c51e05029f9b28868ec": 1, - "package.json\u00007f0b7766ec9aedefebb88dcc3de99d689c66d878b08234aed060dd141bc273a2": 1, - "package.json\u00003f223245810ecb34d729422db9dec4894f701abae34061c25b62befa5891f429": 1, - "package.json\u0000a4e73139c3b780cff610b8c9b49c3949ada60101d97b6855a8686c0a3318807a": 1, - "package.json\u0000925267acb37acca7e05948d9e12dce3d705938bc4024aa874c413ea6e3e4b33f": 1, - "packages/shared/src/prompts/qwenSprite.ts\u000029b06446c9924c52650bb30de43d1a2c004669edabcec7a15d4f0957b85493b4": 1, - "scripts/dev-node.mjs\u000043f204adc4523e952b9d70c8546760eb7cce07a3738a4da0447c93eec52d582a": 1, - "scripts/dev-node.mjs\u00000efe0f7f677da85884e4c4a13413f07026a09b776a38cec8c9f61b82d00344a6": 1, - "scripts/dev-node.mjs\u0000a369acaec97cd74e23f55928bb610acab32a36947f76919a144b2f341b2a3aec": 1, - "scripts/dev-server/README.md\u0000f9afe7dc078db37d489590d227027b1dd44b974e7f759f67227190a461052eca": 1, - "scripts/dev-server/README.md\u0000b8028a259a0ded9a7009d67fb4dc00e7c915711e1af061b9df2032e8ef118493": 1, - "scripts/smoke-same-origin-stack.ts\u0000883b2587b2ce3fd7271adcb1672f5ca990cd923d0b5d2294608b9d8df7b512b4": 1, - "scripts/smoke-same-origin-stack.ts\u0000a130dae439d509ceb2dbc200491329eff1cbe6d91670285c48a4596c39b99cd2": 1, - "scripts/smoke-same-origin-stack.ts\u0000da69b9580b2f5e8b870cb2f922eef1773ff1d3c1dee7f8c6c33129c51b8a989e": 1, - "scripts/smoke-same-origin-stack.ts\u000073843e2fbad45e046810c5a20d29f8853d4498dcf4e84b49a96ccc9f36252797": 1, - "scripts/smoke-server-node.ts\u0000e859fc71f4ec9cefb495e0ba3502b516336b3b1638180a3c376a533e85db160e": 1, - "scripts/smoke-server-node.ts\u0000c3ff93f22b296350a51a3b45993fe5eb243f71bd7e30e26dcabeaabe34c63ee8": 1, - "scripts/smoke-server-node.ts\u00003e2f6037d519fa6dafff4c9eb01a2d88cdd9d2ab5783c56f419e89684717208e": 1, - "scripts/smoke-server-node.ts\u0000f6d8b46774a5c8f702559cc709ba1cd67ab58e5bcfb0d6b99d5a23209d2e5b0a": 1, - "scripts/smoke-server-node.ts\u00005d8f3fd5dab9195d1f7da8a4484f8a1a1c3972f59b276f5b52ff27b5ec24aa6b": 1, - "scripts/smoke-server-node.ts\u000086a758564aa6d19a0647555ede52a16eb609cce8cea00ff8e4172f23a5fbc5d1": 1, - "scripts/smoke-server-node.ts\u0000cb1e4b5ca72afdf0e5cc561311080ec9b420379bf6da50b8eb97b20c316b7818": 1, - "scripts/smoke-server-node.ts\u000067f98dda4f159374a84f616a5d702fe7c7303135f30bbadbb2534b859b454f1f": 1, - "scripts/smoke-server-node.ts\u00003afd6b6832071bedc9c51bbfc800ba4aee29feb4036708f38b445e2eb2599043": 1, - "scripts/smoke-server-node.ts\u0000d8ce8a0d5925a29dc1c915cf715568e4799b84a19348134de860cdb9b22a71cf": 1, - "scripts/smoke-server-node.ts\u000031fa6b611e2afc123a35e61c2bca932ede53f4ac4023229c4ab3fa84728b592e": 1, - "scripts/smoke-server-node.ts\u00009b70b5aacc3c9ba80a7c281280d35180ca8bb9fe7b2a9811e0544e452670d288": 1, - "scripts/smoke-server-node.ts\u000005cbf4b8976aa5ccbef6611ded46b143c154307caff63f3d01f96932b7e1b2e2": 1, - "scripts/smoke-server-node.ts\u0000acbc764687afa651d026d57f217bb508422c2b51592333fa50e48ec493b63e58": 1, - "scripts/smoke-server-node.ts\u0000cea76761bd15bca049a59a546cc3cf5c78f16e0ee1974cc649b4a461992642cb": 1, - "scripts/smoke-server-node.ts\u00008396f8eaaabdf7513272ef6f6e0a8a839d45069a356d1ff11de25c199cc808ff": 1, - "scripts/smoke-server-node.ts\u0000c10ec2eb966d0441b2aa75ffd3e76055ac41f87aba56ce7dd8456ebb984e5097": 1, - "server-node/manifests/backend-capability-index.json\u000056f27c117ab10020e9677a4af8f751ec5731e5683fd8e6ef22716f470edcae4a": 1, - "server-node/manifests/backend-capability-index.json\u00002ac2001c1e6284a6a41c9a4e1f35f67d3e0255194ac8399f4e62bb650f25daa9": 1, - "server-node/manifests/backend-capability-index.json\u0000a81060f4f5ea87472433c41868238f5fc43d54fa281b9fa1994da1e26008c2d7": 7, - "server-node/manifests/backend-capability-index.json\u0000b9cd6ffc760cf15d07931a58fcdc6612bcb8071cc26358b6329f95785312d5d7": 1, - "server-node/manifests/backend-capability-index.json\u000022e6bb0357290ce4a4640d28132341d9cf47ed7b184c2fc268a873894d9b40eb": 1, - "server-node/manifests/backend-capability-index.json\u0000049e94436d9b8bfa52f26c8cd8e22b549270ca91591f5879de3d2a69a975a575": 1, - "server-node/manifests/backend-capability-index.json\u0000c1780f3391e5e39dbaa4b823be7fb40e6380fa8e3a53cb28ff5e1a02094f3c74": 1, - "server-node/manifests/backend-capability-index.json\u00000dd3a86d2024577b2a3b0dab08d5d66e403b4bbd3a3031f636d2c3da634b6d38": 1, - "server-node/manifests/backend-capability-index.json\u0000d77496a42c7b86eaec2d06084d0482907e719bac8e5459ab79613033a7c7da69": 1, - "server-node/manifests/backend-capability-index.json\u0000ded464b81ba13183305c7863e6234459bb2fba5a3388a3a5278fb9df9d8fdd7f": 1, - "server-node/manifests/backend-capability-index.json\u0000e153da3ff06d5adc74d4460a33285f2df4b4aaf6aa824d1a82d5a636f729f4ef": 1, - "server-node/manifests/backend-capability-index.json\u000031fc4e7268a5e03dc0f22255e7f9a9026d7945dfe5c603bceff4c47d846fd7ab": 1, - "server-node/manifests/backend-capability-index.json\u0000f63c246a0bc1d398266ae86cfd9402e9a1095d1904167a9b9daf4608157b1d9c": 1, - "server-node/manifests/backend-capability-index.json\u0000431a6afee8b07b1e9dbe477218f15266e4c73f85f1b54852dac02b887892b18d": 1, - "server-node/manifests/backend-capability-index.json\u00008915039216bcd7177503b20b15c74efcf11d45c67c99105906ea1cd181cbaf1a": 1, - "server-node/manifests/backend-capability-index.json\u00003e92da71592fed1d24fa90ae609ac8a2368ccfcf968c8866139607d5a2c2a6a2": 1, - "server-node/manifests/backend-capability-index.json\u0000f14d1524892557852e2505df46480552a53bc6124db295e921170073bdc01134": 1, - "server-node/manifests/backend-capability-index.json\u00006400667a6891e6ba78c62541fe0ca6446cb598c852289ffc48075ba1f61b65e6": 1, - "server-node/manifests/backend-capability-index.json\u0000e89282b72a621822189c19c3dd89a89c7021f292d4f70ef88f74651e5badb353": 1, - "server-node/manifests/backend-capability-index.json\u0000b5bc5431132f739b8b1d102d1f94c87140c807bf45dadfc48740c216fcc9968a": 1, - "server-node/manifests/backend-capability-index.json\u00003dc7ea9bd363d82f049c72eef436e05c6c2575810f58ebede984ffcaad3df4b8": 1, - "server-node/manifests/backend-capability-index.json\u00005cde654b28abd69f9733c1478ad2b34ee99808bdd29548ad8ec5499ee66d956b": 1, - "server-node/manifests/backend-capability-index.json\u0000193bfd27c9106a14cfdbcf7cb5f45bc55d5bdd6169f7498b16c4e97036096e59": 1, - "server-node/manifests/backend-capability-index.json\u0000a1a1ea86bf2d08a2cca3339555684c2548f755457b61f164f556386d1f6e5e44": 1, - "server-node/manifests/backend-capability-index.json\u0000b186d8d6aeadc12092a836cc9e56fd1af940ee1d5b66d7d7ea2434efb7f4d5d5": 1, - "server-node/manifests/backend-capability-index.json\u000063d7409e9001d79b0afe27f66c916d1d19c5a4776831804ea0fae80407db9d70": 1, - "server-node/manifests/backend-capability-index.json\u00003e0f9bd72c8761da6067aa9eb8cb631b3293440f14f02b19f543c4c18ca2c14a": 1, - "server-node/manifests/backend-capability-index.json\u00008fedba82625ada5def7573e8f8301bc449d448fd9d8be10b19ca6d39cf0b656e": 1, - "server-node/manifests/backend-capability-index.json\u0000526fff0041bebf96007b8a584a2b27c6889723e3d035fbe0d247aae851d25e70": 1, - "server-node/manifests/backend-capability-index.json\u0000ddcae371a5388f7ada993fb88ec6576d51f074aec30762f1c7c321babe865ce5": 1, - "server-node/manifests/backend-capability-index.json\u000041cfc98243a84ca68f4f454fd915f4a53a0b906fc890d63b94aad90a15be4fee": 1, - "server-node/manifests/backend-capability-index.json\u000099fa07fd7bdb357dfc733daa78d583e49b614dc6befdf841795a6e10ec8b5971": 1, - "server-node/manifests/backend-capability-index.json\u0000fd9f915894e3110326c2c9b12c7c4cc7cecece18f455fbbd5ceebed48e8caaf2": 1, - "server-node/manifests/backend-capability-index.json\u000044a4f808e59ff619b77749edb329dd2e679181f1ff4d554c9dddfac31c5e6326": 1, - "server-node/manifests/backend-capability-index.json\u0000e1aa773771075c9624f86a11630981a5cb55916dce1c37e74c4498975f4e451f": 1, - "server-node/manifests/backend-capability-index.json\u0000bf5b61f4ff8ae0d08666a731e47cdd224792648e213255ce35e2e6580eaaec38": 1, - "server-node/manifests/backend-capability-index.json\u0000606915ce40a9ed8b2541015f552faed7d5d7129be821c8ffdb05b9b91d2813f7": 1, - "server-node/manifests/backend-capability-index.json\u0000f0d3340aa2e699b352f687bcb4c06e3ac255f4bd5d34df337b3083b62c2944ed": 1, - "server-node/manifests/backend-capability-index.json\u0000858f122e260286ea4dc408f781b6bfe1137b6cfaa899397ee01edbc49e588372": 1, - "server-node/manifests/backend-capability-index.json\u000022af6710e5cc63059530627e848f5977dfebb00c0107d192034acb81e447f215": 1, - "server-node/manifests/backend-capability-index.json\u0000294b29a681618e40f20b29cfdc82c6e32cb918983c085da1bcec5fe0bfcbe4b6": 1, - "server-node/manifests/backend-capability-index.json\u0000358d8c7499d2f8880ecd2761a778bdc196e003775df8ade73f40422d7b0c8be1": 1, - "server-node/manifests/backend-capability-index.json\u000090227f9a84cb851917ae8d1ee285d5c7635ba983c1ed5a968c078bab9a327758": 1, - "server-node/manifests/backend-capability-index.json\u0000e09c45381e8a3480904021d0b0c79c974c93a7b32298642441564594cb282e7b": 1, - "server-node/manifests/backend-capability-index.json\u0000f2cb529f32c48821debfb0d2f011dd95c725935d9f221d8e9b7fb1de17b7d1e4": 1, - "server-node/manifests/backend-capability-index.json\u000012821d4d15c4bf28759572b479c61ae2550ae0eb6b20cb2d0cbc6b05f02c66a9": 1, - "server-node/manifests/backend-capability-index.json\u0000c192b0a807f4a2376b3ba6b3adb825061ca22edf6795b494590d137d89e4e6d6": 1, - "server-node/manifests/backend-capability-index.json\u00004e17a017f18227e78f7e2e99a00f7807c1d8d1a6e488140854df6ab22a6c21cd": 10, - "server-node/manifests/backend-capability-index.json\u0000fdd0cac4c593e4bfc0b34f18dd627d3fc2b892086f5f4e416dbfff9982cc475a": 4, - "server-node/manifests/backend-capability-index.json\u00008036d23ecbdf20520dc4c74bd0d52e84e84355e15fd14008eebcf08067f23ffa": 17, - "server-node/manifests/backend-capability-index.json\u000094a2a08f5b155a16100da672499ba3544ab70600bd7e58d008d1104c06b047c7": 52, - "server-node/manifests/backend-capability-index.json\u0000de73c1459d6363502c0c1cc70aa3db53cd673b255ac10d1249a89b6e8beeb155": 3, - "server-node/manifests/backend-capability-index.json\u0000ea839c5a573f5a143557ca68541138a6904988a383977e0ce015bdb083adba4e": 7, - "server-node/manifests/backend-capability-index.json\u000071a2dca73d28a6fee8585eeb9f044560c0ebd575735026d64b3e4fbd87ccd8b8": 2, - "server-node/manifests/backend-capability-index.json\u0000a813474b869285c20d11652750d67f18d10f6352576851f925aa7207b6cc8f4f": 1, - "server-node/manifests/backend-capability-index.json\u0000f8a47fc6daf13aa200addeee99b5fe1246a610639311b53e15ae91676d3bc1eb": 1, - "server-node/package-lock.json\u00009aa85a85f6018efa293f174e5df2f65ed1c766c77fda76388b50e4e40e4bcb48": 2, - "server-node/package.json\u00009aa85a85f6018efa293f174e5df2f65ed1c766c77fda76388b50e4e40e4bcb48": 1, - "server-node/scripts/generateBackendCapabilityArtifacts.ts\u0000ed8deaad538255e55f8b71d3a41e517b301d6d01a323cbfb6d56de02eb1d65e7": 1, - "server-node/scripts/generateBackendCapabilityArtifacts.ts\u00000c0d8f42aa90bc2ff3ab67415c1d66fec5f25dc6db27fde12dd34704ea907b03": 1, - "server-node/scripts/generateBackendCapabilityArtifacts.ts\u00008a4ba48138f6b0d5242b50ea4081db3880ed2c60293833052a303e1a9039dcd5": 1, - "server-node/scripts/generateBackendCapabilityArtifacts.ts\u0000fa1d067c01c81ca0a3326bc169b317aa76ca27e940a9dc232a5c3d6e898dd5a0": 1, - "server-node/sql/schema/README.md\u000057b35b62ecf00e77a6944a18739c3c9d27ceaab17361670ea7ec992b46b2b5e1": 1, - "server-node/src/app.test.ts\u000054022bb1a9aaf2e1624436fa3f18d18ea449935ba3ad546ecd3ed89481c83392": 1, - "server-node/src/app.test.ts\u00003b484de1dbbce3e8933a76da1462943554be4a85bf4c1dc0aad9416d4bd4b7ae": 1, - "server-node/src/config.ts\u0000e0899bb76e5cedb66c6ea5b0582d3d74a8bdfbbbb33cbfe5504eb44601a0ec26": 1, - "server-node/src/config.ts\u00000af84cc03240532ee0044db36236d2b0eacafd7e8b9c097a628fd85568ac4b08": 1, - "server-node/src/config.ts\u00001edd84674f0d251801f6f574082ae9bf848d5f272523014d70933f69a3994498": 1, - "server-node/src/config.ts\u0000e27e58af85c8cfcc7dbef3812fc8c6ed7a05b68059efc474c4f0067096050703": 1, - "server-node/src/db.test.ts\u00007cb4609a0288b2076299342c6537e7d3f88b56e5a0931a1c06c6fbea92171aa5": 1, - "server-node/src/db.test.ts\u0000f79f94c3ddc028f7acfc84eb6fc19d5b26c40d0f8a8a42032b6944e284da8540": 1, - "server-node/src/db.test.ts\u00003b484de1dbbce3e8933a76da1462943554be4a85bf4c1dc0aad9416d4bd4b7ae": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u00002117ae72ffff9502bd361616c91dd9faa833fc24340f663ef2149ea5b3d104d1": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u00008a860aa0a3263b1320e50b38c59ffe1eb6f1a162d09ca2dcb6525327961b7b30": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u00003a0258d73e41cacaa7fd9f249adf22c08071e7ca2ba122cd0b74f34795ad46e2": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u00003652b58a4ee5b20968780f31a28177a659e6a2685ecff40dda212d3284f40354": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u0000bd1a48e3b86180591ae62641df451b2eddc8fa189183ab60f642faed5144ac77": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u0000d146976ee0200295189de04c5a44b173d6ea7fef9b2ea1ecf8911e540f9e60ee": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u00007137032dcf8ef52cf99cfbae31f1f89b8e4a73d16c46baec172b166fdb40ce10": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u0000271276cc9a7ebcf065910bc978522a4b966878c0a2284026ff3df4195d2da05c": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u0000f9b1d51f4f56c2e5a88a8bd7a59cf5db27f57493b5301b0fe1b2782e712c1aa9": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u0000f4993e99a4c745f1059c410c9806c512b189be510c055f3bd6d16de24b9318a2": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u0000dcd4886d8a8766ae6c7a20e70d596baac7053b2a61a8c3f0918ab2a5dcd86b33": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u000003aaf230186f976424549f328898613e49f80f376dad4f48fde0d1d971e00070": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u0000b3057414fb5361086e9aaffe5f667ceccb21addc33092ce9e66544ad814844c9": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u000048f2d22f1046a5baad05a27bae65f6bbaf8fdb56db285ed2399da60a7252987c": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u0000aa527fccb3d32cbbfb3f172799794be4d3eb11437b10267bf835df376b70ac29": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u000051f9799e55f4a76bcc18dc538c6839a9f4409d68a3ec568db3a470a48d4266ea": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u0000787c23d1e6c326028fe8fc3ef1b87031613466cbde44219e72ab020ee2874742": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u00006fb7541bdf06d8712b11969c7d6a6cb232ca86196b182ed4db2d784fe83334f5": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u0000ef056282a8664f16964ed97813e57fa3b1dcef72f80aec7b9c44159f8781bb9c": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u00006e8a117a4f9a08a90260f513e1f0bcb955e70518f68e6c737479b35228bda34b": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u0000fee0b167671ad25b22b35d9bd22f2ff6f4b056fe05dc3628346917a4611bedc3": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u0000ccdf3cf3e90c0049579f245aa56030673f0ca56e711676972a44153b4db7e12a": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u000054c5e24ca3a711a386e636fc1af45e31d196de7476f01ffece05476d8d0a71f6": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u0000d76e524a189a2de21763fdc6bd8efe9f529206919997e1b6e95152a92888c3a0": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u00008d39320b2e2d02cd3874a552dd0a9fce6f7c8883fcb36d763bc1f65555d88530": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u00007586e045dec890cf1ffe81547738bbd8ce6cdb409fb1ee7908951eadeb19c291": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u00008d67513a58aacf34631c11aecfa3f2becafc7e1bea706824d98050b959841f3a": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u0000a13d5ffd13ec05348407a9e86260e5cd38caedfd7e11ac10437b16c2838e6833": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u0000c32b6324ee0061cce0de0510a2386d8b86c3f0811160cdc33caa4772f8c3f1c0": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u00003a2ab053adc5f82f95031cdc71f51fff7f6d931b9d353d4aa270ed7a6b8d7ea8": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u00001b3b9eb7431d02970758fb7578b53464944848a7bf8e9392bf58f0c6a6dc990d": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u0000bf6b32768345af6d8c16b0b68343f1ba31cd441a11d2af65ac077effee33f07c": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u0000dcd06e8e449aeb450de2cb6dcc507933d5f689540ca6f4bbdc55f838997935c9": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u0000d033f743e7a7bb72d5d5a8390f5ef091bd6ebb879c846f9c79e93e706fec6cbb": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u000079d2ef4183a09fffc6b77f70e5db6aaf84ac129ab6ee83670a2d9e2067af470e": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u0000ce9e81664ba29f4e4082c248faeee1e6fbd782fe4e128cdbc2581e2bb8e1f1ed": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u000077a0c85152c5ee21d8bebf3bec0194d67f86b3b5717c8067e19ee5762f4294ee": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u0000f86d313c455762461c3ed8a9de1ffbe1080621fee7ce631b2f868ec0bc6ed722": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u000042a396102c1462abaee22d09239895fcbdcd89c4632ace958231006c7cfb8a7e": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u0000b7592ec748e7346d52c4379bfefcc215e31087092e0a57cff016d4a462b5388e": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u000080b277e7e971303d35c235b741ff80542a852c8e1265a62edce22b0b43c213dd": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u0000e79b92a03c52e1ca1d9e4df6fae7473ed81a2452410ff0080abe3f8d2b8bdbe2": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u0000b8f3d76345b4036ef38fe21a3ec7df5315d68ad77db79ac02b83016f1c683cda": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u00003f8e89474c0e219d2c7a2c8f82708c569e450ccaad7dd04c54013f9e5f279d5e": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u0000c9e8d2598cb57e9392b57f8a4e1498b8a7fd3b202244ac8fdc8b689b4f52abc0": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u0000b4fdd5086292da37aa5742941a83f0c283cb7a5375e4de371a4e90fc59427566": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u0000d7072813c1083dbb1143429c1b20b7f3d3267812c71d7b1d386a7848ae632875": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u00002a5611a783e015c8a3f510906a3a61cef1222ed4baf6fa00a5c38b9f3f2dadc7": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u0000ce792ddd185d82366f9a17b53473058b00e6fea614fe83747cd7197398ee23be": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u0000e1d203df849c33c4a08c73c3635f94e5b6a566d3ab8b8bb83a9539d1ca6563f8": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u0000be705da238160ee2a0a5790ccd73231e8a8d2589243c5b34dddad3e2988774ac": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u000047b52f649640c8ea337ffb133fdc04a98a6dae76513bbb1f9fe081f48abc9549": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u00003a731ad8f2ec46d459163ad3d8e91603ade3c4af8a1bb8fce22343eb64593820": 1, - "server-node/src/manifest/backendCapabilityManifest.ts\u0000a22f5224601961db224b507f83a45e8f221f0237d28d16503aba6a61b684db8a": 1, - "server-node/src/observability.test.ts\u000054022bb1a9aaf2e1624436fa3f18d18ea449935ba3ad546ecd3ed89481c83392": 1, - "server-node/src/observability.test.ts\u00003b484de1dbbce3e8933a76da1462943554be4a85bf4c1dc0aad9416d4bd4b7ae": 1, - "server-node/src/server.ts\u00006a29197840b346af2f6cd87983cf2367f10532ba189467a3da2ecb988b2fb5d2": 1, - "server-node/src/server.ts\u0000eb63882aab4e8308326984c9a628c48c4f61cb57f40e886e491fcafc6f0a572c": 1, - "server-node/test.mjs\u000052a8ac3a4353d6274ffb10a59ef6ea1e5ec4b5dfabfd789d8fea15c96e3a4f2a": 1, - "server-rs/README.md\u0000659f779515099e5bb71a6d3e49b0855d631b2fc9224bbb91fe9dc5a253c8f6d2": 1, - "server-rs/README.md\u00008e2a3a31bda683646449a0a2609928630f514e983712bd817fedcbbb0c14fcad": 1, - "src/data/functionCatalog/flow/storyOpeningCampDialogue.ts\u0000b4414fe223895b2c0082477abbf92b0ab38d14e3826a61f6fde33b87181a0cb8": 1, - "src/data/functionCatalog/npc/npcChatQuestOffer.ts\u0000742efe81d48503c9efa60f444af16a633769082f18fa6468e0ab1739f69452d7": 1, - "src/data/functionCatalog/state/battleAttackBasic.ts\u0000d3f869397b98758f3b9983bde08288b09096829ada0342630f5c57dabf913d3f": 1, - "src/data/functionCatalog/state/battleUseSkill.ts\u0000d3f869397b98758f3b9983bde08288b09096829ada0342630f5c57dabf913d3f": 1, - "view-llm-logs.ps1\u0000cea4f5c3a3b0023d64d44dc1d68dc535b409c0196ac8d996b0f176e481804fdf": 1 - } -} diff --git a/scripts/server-node-frozen.mjs b/scripts/server-node-frozen.mjs deleted file mode 100644 index 12ef34b2..00000000 --- a/scripts/server-node-frozen.mjs +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env node - -const command = process.env.npm_lifecycle_event || 'server-node:*'; - -console.error(`[server-node frozen] ${command} 已冻结。`); -console.error('后端主线已切换到 server-rs(Rust + SpacetimeDB),禁止继续运行或扩展 server-node。'); -console.error('如需开发后端能力,请使用 npm run dev:rust 或 server-rs/scripts/*。'); -process.exit(1); diff --git a/scripts/smoke-same-origin-stack.ts b/scripts/smoke-same-origin-stack.ts deleted file mode 100644 index b7f08795..00000000 --- a/scripts/smoke-same-origin-stack.ts +++ /dev/null @@ -1,436 +0,0 @@ -import assert from 'node:assert/strict'; -import { spawn, type ChildProcess } from 'node:child_process'; -import fs from 'node:fs'; -import http from 'node:http'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { httpRequest } from '../server-node/src/testHttp.ts'; - -const scriptPath = fileURLToPath(import.meta.url); -const repoRoot = path.resolve(path.dirname(scriptPath), '..'); -const bundledNodePath = path.join( - repoRoot, - '.tools', - 'node-v22.22.2-win-x64', - process.platform === 'win32' ? 'node.exe' : 'bin/node', -); -const runtimeNodePath = fs.existsSync(bundledNodePath) - ? bundledNodePath - : process.execPath; -const serverBuildPath = path.join(repoRoot, 'server-node', 'dist', 'server.cjs'); -const webBuildPath = path.join(repoRoot, 'dist', 'index.html'); -const publicRoot = path.join(repoRoot, 'public'); -const proxyPort = 18080; -const nodePort = 18081; -const proxyBaseUrl = `http://127.0.0.1:${proxyPort}`; -const nodeBaseUrl = `http://127.0.0.1:${nodePort}`; - -type ManagedChild = { - name: string; - process: ChildProcess; -}; - -function assertBuildArtifacts() { - if (!fs.existsSync(serverBuildPath)) { - throw new Error( - 'server-node/dist/server.cjs 不存在,请先运行 npm run server-node:build', - ); - } - - if (!fs.existsSync(webBuildPath)) { - throw new Error('dist/index.html 不存在,请先运行 npm run build'); - } -} - -function sleep(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function waitForReady( - label: string, - url: string, - validate: (bodyText: string, status: number) => void, - timeoutMs = 20000, -) { - const startedAt = Date.now(); - let lastError: unknown = null; - - while (Date.now() - startedAt < timeoutMs) { - try { - const response = await httpRequest(url); - const bodyText = await response.text(); - validate(bodyText, response.status); - return; - } catch (error) { - lastError = error; - await sleep(250); - } - } - - throw new Error( - `[smoke:proxy] ${label} 未在 ${timeoutMs}ms 内就绪: ${lastError instanceof Error ? lastError.message : String(lastError)}`, - ); -} - -function spawnManagedChild( - name: string, - command: string, - args: string[], - env: NodeJS.ProcessEnv, -): ManagedChild { - const child = spawn(command, args, { - cwd: repoRoot, - env, - stdio: 'inherit', - shell: false, - }); - - child.on('error', (error) => { - console.error(`[smoke:proxy] ${name} 启动失败`, error); - }); - - return { - name, - process: child, - }; -} - -async function stopChild(child: ManagedChild | null) { - if (!child || child.process.exitCode !== null) { - return; - } - - child.process.kill('SIGTERM'); - - await Promise.race([ - new Promise((resolve) => { - child.process.once('exit', () => resolve()); - }), - sleep(2000), - ]); - - if (child.process.exitCode === null) { - child.process.kill('SIGKILL'); - await new Promise((resolve) => { - child.process.once('exit', () => resolve()); - }); - } -} - -function contentTypeFor(filePath: string) { - if (filePath.endsWith('.html')) { - return 'text/html; charset=utf-8'; - } - if (filePath.endsWith('.js')) { - return 'text/javascript; charset=utf-8'; - } - if (filePath.endsWith('.css')) { - return 'text/css; charset=utf-8'; - } - if (filePath.endsWith('.json')) { - return 'application/json; charset=utf-8'; - } - if (filePath.endsWith('.png')) { - return 'image/png'; - } - if (filePath.endsWith('.jpg') || filePath.endsWith('.jpeg')) { - return 'image/jpeg'; - } - if (filePath.endsWith('.webp')) { - return 'image/webp'; - } - if (filePath.endsWith('.svg')) { - return 'image/svg+xml; charset=utf-8'; - } - - return 'application/octet-stream'; -} - -function resolveStaticFile(urlPath: string) { - const cleanPath = decodeURIComponent(urlPath.split('?')[0] || '/'); - const normalizedPath = cleanPath === '/' ? '/index.html' : cleanPath; - const trimmedRelativePath = normalizedPath.replace(/^\/+/u, ''); - const distRoot = path.resolve(repoRoot, 'dist'); - const publicCandidatePath = path.resolve(publicRoot, trimmedRelativePath); - const distCandidatePath = path.resolve(distRoot, trimmedRelativePath); - - if ( - publicCandidatePath.startsWith(publicRoot) && - fs.existsSync(publicCandidatePath) && - fs.statSync(publicCandidatePath).isFile() - ) { - return publicCandidatePath; - } - - if ( - distCandidatePath.startsWith(distRoot) && - fs.existsSync(distCandidatePath) && - fs.statSync(distCandidatePath).isFile() - ) { - return distCandidatePath; - } - - return webBuildPath; -} - -async function startSameOriginProxy() { - const server = http.createServer((request, response) => { - const requestUrl = request.url || '/'; - - if (requestUrl === '/healthz') { - response.writeHead(200, { - 'Content-Type': 'text/plain; charset=utf-8', - }); - response.end('ok'); - return; - } - - if (requestUrl.startsWith('/api/')) { - const upstream = http.request( - { - hostname: '127.0.0.1', - port: nodePort, - path: requestUrl, - method: request.method, - headers: { - ...request.headers, - host: `127.0.0.1:${nodePort}`, - }, - }, - (upstreamResponse) => { - response.writeHead( - upstreamResponse.statusCode ?? 502, - upstreamResponse.headers, - ); - upstreamResponse.pipe(response); - }, - ); - - upstream.on('error', (error) => { - response.writeHead(502, { - 'Content-Type': 'application/json; charset=utf-8', - }); - response.end( - JSON.stringify({ - error: { - message: - error instanceof Error ? error.message : 'proxy upstream failed', - }, - }), - ); - }); - - request.pipe(upstream); - return; - } - - const filePath = resolveStaticFile(requestUrl); - response.writeHead(200, { - 'Content-Type': contentTypeFor(filePath), - }); - fs.createReadStream(filePath).pipe(response); - }); - - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(proxyPort, '127.0.0.1', () => resolve()); - }); - - return server; -} - -async function stopProxyServer(server: http.Server | null) { - if (!server) { - return; - } - - await new Promise((resolve, reject) => { - server.close((error) => { - if (error) { - reject(error); - return; - } - resolve(); - }); - }); -} - -async function authEntry(baseUrl: string) { - const requestId = 'proxy-smoke-auth-entry'; - const username = `proxy_${Date.now().toString(36)}`; - const response = await httpRequest(`${baseUrl}/api/auth/entry`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Request-Id': requestId, - }, - body: JSON.stringify({ - username, - password: 'proxy-secret-123', - }), - }); - const payload = (await response.json()) as { - token: string; - user: { - id: string; - username: string; - }; - }; - - assert.equal(response.status, 200); - assert.equal(response.headers.get('x-request-id'), requestId); - assert.equal(payload.user.username, username); - assert.ok(payload.token); - - return payload; -} - -async function main() { - assertBuildArtifacts(); - - let serverChild: ManagedChild | null = null; - let proxyServer: http.Server | null = null; - - try { - console.log('[smoke:proxy] starting built node server'); - serverChild = spawnManagedChild( - 'server-node', - runtimeNodePath, - [serverBuildPath], - { - ...process.env, - PROJECT_ROOT: repoRoot, - NODE_ENV: 'test', - NODE_SERVER_ADDR: `:${nodePort}`, - DATABASE_URL: 'pg-mem://genarrative-proxy-smoke', - LOG_LEVEL: 'silent', - JWT_SECRET: 'proxy-smoke-secret', - JWT_ISSUER: 'genarrative-proxy-smoke', - LLM_API_KEY: '', - DASHSCOPE_API_KEY: '', - }, - ); - - await waitForReady( - 'node server', - `${nodeBaseUrl}/healthz`, - (bodyText, status) => { - assert.equal(status, 200); - const payload = JSON.parse(bodyText) as { - ok: boolean; - service: string; - }; - assert.equal(payload.ok, true); - assert.equal(payload.service, 'genarrative-node-server'); - }, - ); - console.log('[smoke:proxy] node server ready'); - - console.log('[smoke:proxy] starting same-origin reverse proxy harness'); - proxyServer = await startSameOriginProxy(); - - await waitForReady( - 'reverse proxy', - `${proxyBaseUrl}/healthz`, - (bodyText, status) => { - assert.equal(status, 200); - assert.equal(bodyText.trim(), 'ok'); - }, - ); - console.log('[smoke:proxy] reverse proxy ready'); - - const homeResponse = await httpRequest(`${proxyBaseUrl}/`); - const homeHtml = await homeResponse.text(); - assert.equal(homeResponse.status, 200); - assert.match(homeHtml, /
<\/div>/u); - console.log('[smoke:proxy] static web entry ok'); - - const entry = await authEntry(proxyBaseUrl); - console.log('[smoke:proxy] proxied auth entry ok'); - - const meResponse = await httpRequest(`${proxyBaseUrl}/api/auth/me`, { - headers: { - Authorization: `Bearer ${entry.token}`, - }, - }); - const mePayload = (await meResponse.json()) as { - user: { - username: string; - }; - }; - assert.equal(meResponse.status, 200); - assert.equal(mePayload.user.username, entry.user.username); - console.log('[smoke:proxy] proxied auth me ok'); - - const saveResponse = await httpRequest( - `${proxyBaseUrl}/api/runtime/save/snapshot`, - { - method: 'PUT', - headers: { - Authorization: `Bearer ${entry.token}`, - 'Content-Type': 'application/json', - 'X-Genarrative-Response-Envelope': 'v1', - }, - body: JSON.stringify({ - gameState: { - worldType: 'WUXIA', - chapter: 2, - }, - bottomTab: 'adventure', - currentStory: { - text: 'proxy smoke story', - }, - }), - }, - ); - const savePayload = (await saveResponse.json()) as { - ok: true; - data: { - gameState: { - chapter: number; - }; - }; - meta: { - requestId: string; - operation: string; - }; - }; - assert.equal(saveResponse.status, 200); - assert.equal(savePayload.ok, true); - assert.equal(savePayload.data.gameState.chapter, 2); - assert.equal(savePayload.meta.operation, 'runtime.snapshot.put'); - assert.ok(savePayload.meta.requestId); - console.log('[smoke:proxy] proxied runtime save ok'); - - const getResponse = await httpRequest( - `${proxyBaseUrl}/api/runtime/save/snapshot`, - { - headers: { - Authorization: `Bearer ${entry.token}`, - }, - }, - ); - const getPayload = (await getResponse.json()) as { - gameState: { - chapter: number; - }; - bottomTab: string; - }; - assert.equal(getResponse.status, 200); - assert.equal(getPayload.gameState.chapter, 2); - assert.equal(getPayload.bottomTab, 'adventure'); - console.log('[smoke:proxy] proxied runtime snapshot read ok'); - - console.log('[smoke:proxy] all checks passed'); - } finally { - await stopProxyServer(proxyServer); - await stopChild(serverChild); - } -} - -void main().catch((error) => { - console.error('[smoke:proxy] failed'); - console.error(error); - process.exit(1); -}); diff --git a/scripts/smoke-server-node.ts b/scripts/smoke-server-node.ts deleted file mode 100644 index a870157e..00000000 --- a/scripts/smoke-server-node.ts +++ /dev/null @@ -1,406 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import type { AddressInfo } from 'node:net'; -import os from 'node:os'; -import path from 'node:path'; - -import { createApp } from '../server-node/src/app.ts'; -import type { AppConfig } from '../server-node/src/config.ts'; -import { createAppContext } from '../server-node/src/server.ts'; -import { httpRequest, type TestRequestInit } from '../server-node/src/testHttp.ts'; - -function createSmokeConfig(): AppConfig { - const tempRoot = fs.mkdtempSync( - path.join(os.tmpdir(), 'genarrative-server-node-smoke-'), - ); - - return { - nodeEnv: 'test', - projectRoot: tempRoot, - publicDir: path.join(tempRoot, 'public'), - logsDir: path.join(tempRoot, 'logs'), - dataDir: path.join(tempRoot, 'data'), - rawEnv: {}, - databaseUrl: 'pg-mem://genarrative-smoke', - serverAddr: ':0', - logLevel: 'silent', - editorApiEnabled: true, - assetsApiEnabled: true, - jwtSecret: 'test-secret', - jwtExpiresIn: '7d', - jwtIssuer: 'genarrative-server-node-smoke', - llm: { - baseUrl: 'https://example.invalid', - apiKey: '', - model: 'test-model', - }, - dashScope: { - baseUrl: 'https://example.invalid', - apiKey: '', - imageModel: 'test-image-model', - requestTimeoutMs: 1000, - }, - smsAuth: { - enabled: true, - provider: 'mock', - endpoint: 'dypnsapi.aliyuncs.com', - accessKeyId: '', - accessKeySecret: '', - signName: 'Test Sign', - templateCode: '100001', - templateParamKey: 'code', - countryCode: '86', - schemeName: '', - codeLength: 6, - codeType: 1, - validTimeSeconds: 300, - intervalSeconds: 60, - duplicatePolicy: 1, - caseAuthPolicy: 1, - returnVerifyCode: false, - mockVerifyCode: '123456', - maxSendPerPhonePerDay: 20, - maxSendPerIpPerHour: 30, - maxVerifyFailuresPerPhonePerHour: 12, - maxVerifyFailuresPerIpPerHour: 24, - captchaTtlSeconds: 180, - captchaTriggerVerifyFailuresPerPhone: 3, - captchaTriggerVerifyFailuresPerIp: 5, - blockPhoneFailureThreshold: 6, - blockIpFailureThreshold: 10, - blockPhoneDurationMinutes: 30, - blockIpDurationMinutes: 30, - }, - wechatAuth: { - enabled: true, - provider: 'mock', - appId: '', - appSecret: '', - authorizeEndpoint: 'https://open.weixin.qq.com/connect/qrconnect', - accessTokenEndpoint: 'https://api.weixin.qq.com/sns/oauth2/access_token', - userInfoEndpoint: 'https://api.weixin.qq.com/sns/userinfo', - callbackPath: '/api/auth/wechat/callback', - defaultRedirectPath: '/', - mockUserId: 'mock_wechat_user', - mockUnionId: 'mock_wechat_union', - mockDisplayName: '微信旅人', - mockAvatarUrl: '', - }, - authSession: { - refreshCookieName: 'genarrative_refresh_session', - refreshSessionTtlDays: 30, - refreshCookieSecure: false, - refreshCookieSameSite: 'Lax', - refreshCookiePath: '/api/auth', - }, - }; -} - -async function withSmokeServer( - run: (options: { baseUrl: string }) => Promise, -) { - const context = await createAppContext(createSmokeConfig()); - const app = createApp(context); - const server = await new Promise((resolve) => { - const nextServer = app.listen(0, '127.0.0.1', () => resolve(nextServer)); - }); - - try { - const address = server.address() as AddressInfo; - return await run({ - baseUrl: `http://127.0.0.1:${address.port}`, - }); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => { - if (error) { - reject(error); - return; - } - resolve(); - }); - }); - await context.db.close(); - } -} - -function withBearer(token: string, init: TestRequestInit = {}) { - return { - ...init, - headers: { - ...(init.headers ?? {}), - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - } satisfies TestRequestInit; -} - -async function authEntry(baseUrl: string) { - const username = `smoke_${Date.now().toString(36)}`; - const response = await httpRequest(`${baseUrl}/api/auth/entry`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - username, - password: 'smoke-secret-123', - }), - }); - const payload = (await response.json()) as { - token: string; - user: { - id: string; - username: string; - }; - }; - - assert.equal(response.status, 200); - assert.ok(payload.token); - assert.equal(payload.user.username, username); - - return payload; -} - -async function sendPhoneCode(baseUrl: string, phone: string) { - const response = await httpRequest(`${baseUrl}/api/auth/phone/send-code`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - phone, - scene: 'login', - }), - }); - const payload = (await response.json()) as { - ok: boolean; - cooldownSeconds: number; - expiresInSeconds: number; - }; - - assert.equal(response.status, 200); - assert.equal(payload.ok, true); - assert.equal(payload.cooldownSeconds, 60); - assert.equal(payload.expiresInSeconds, 300); -} - -async function phoneAuthEntry(baseUrl: string) { - const phone = '13800138000'; - - await sendPhoneCode(baseUrl, phone); - - const response = await httpRequest(`${baseUrl}/api/auth/phone/login`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - phone, - code: '123456', - }), - }); - const payload = (await response.json()) as { - token: string; - user: { - id: string; - username: string; - loginMethod: string; - phoneNumberMasked: string | null; - }; - }; - - assert.equal(response.status, 200); - assert.ok(payload.token); - assert.equal(payload.user.loginMethod, 'phone'); - assert.equal(payload.user.phoneNumberMasked, '138****8000'); - - return payload; -} - -async function main() { - console.log('[server-node:smoke] booting ephemeral Express server'); - - await withSmokeServer(async ({ baseUrl }) => { - const healthzRequestId = 'smoke-healthz-request'; - const healthzResponse = await httpRequest(`${baseUrl}/healthz`, { - headers: { - 'X-Request-Id': healthzRequestId, - }, - }); - const healthzPayload = (await healthzResponse.json()) as { - ok: boolean; - service: string; - }; - - assert.equal(healthzResponse.status, 200); - assert.equal(healthzResponse.headers.get('x-request-id'), healthzRequestId); - assert.equal(healthzPayload.ok, true); - assert.equal(healthzPayload.service, 'genarrative-node-server'); - console.log('[server-node:smoke] healthz ok'); - - const entry = await authEntry(baseUrl); - console.log('[server-node:smoke] password auth entry ok'); - - const phoneEntry = await phoneAuthEntry(baseUrl); - console.log('[server-node:smoke] phone auth entry ok'); - - const meResponse = await httpRequest(`${baseUrl}/api/auth/me`, { - headers: { - Authorization: `Bearer ${phoneEntry.token}`, - }, - }); - const mePayload = (await meResponse.json()) as { - user: { - id: string; - username: string; - loginMethod: string; - }; - }; - - assert.equal(meResponse.status, 200); - assert.equal(mePayload.user.username, phoneEntry.user.username); - assert.equal(mePayload.user.loginMethod, 'phone'); - console.log('[server-node:smoke] auth me ok'); - - const putSnapshotResponse = await httpRequest( - `${baseUrl}/api/runtime/save/snapshot`, - withBearer(phoneEntry.token, { - method: 'PUT', - body: JSON.stringify({ - gameState: { - worldType: 'WUXIA', - chapter: 1, - }, - bottomTab: 'adventure', - currentStory: { - text: 'smoke story', - }, - }), - }), - ); - const putSnapshotPayload = (await putSnapshotResponse.json()) as { - version: number; - bottomTab: string; - gameState: { - chapter: number; - }; - }; - - assert.equal(putSnapshotResponse.status, 200); - assert.equal(putSnapshotPayload.version, 2); - assert.equal(putSnapshotPayload.bottomTab, 'adventure'); - assert.equal(putSnapshotPayload.gameState.chapter, 1); - - const getSnapshotResponse = await httpRequest( - `${baseUrl}/api/runtime/save/snapshot`, - { - headers: { - Authorization: `Bearer ${phoneEntry.token}`, - }, - }, - ); - const getSnapshotPayload = (await getSnapshotResponse.json()) as { - bottomTab: string; - gameState: { - chapter: number; - }; - }; - - assert.equal(getSnapshotResponse.status, 200); - assert.equal(getSnapshotPayload.bottomTab, 'adventure'); - assert.equal(getSnapshotPayload.gameState.chapter, 1); - console.log('[server-node:smoke] runtime snapshot roundtrip ok'); - - const putSettingsResponse = await httpRequest( - `${baseUrl}/api/runtime/settings`, - withBearer(phoneEntry.token, { - method: 'PUT', - body: JSON.stringify({ - musicVolume: 0.3, - platformTheme: 'light', - }), - }), - ); - const putSettingsPayload = (await putSettingsResponse.json()) as { - musicVolume: number; - platformTheme: string; - }; - - assert.equal(putSettingsResponse.status, 200); - assert.equal(putSettingsPayload.musicVolume, 0.3); - assert.equal(putSettingsPayload.platformTheme, 'light'); - - const getSettingsResponse = await httpRequest(`${baseUrl}/api/runtime/settings`, { - headers: { - Authorization: `Bearer ${phoneEntry.token}`, - }, - }); - const getSettingsPayload = (await getSettingsResponse.json()) as { - musicVolume: number; - platformTheme: string; - }; - - assert.equal(getSettingsResponse.status, 200); - assert.equal(getSettingsPayload.musicVolume, 0.3); - assert.equal(getSettingsPayload.platformTheme, 'light'); - console.log('[server-node:smoke] runtime settings roundtrip ok'); - - const deleteSnapshotResponse = await httpRequest( - `${baseUrl}/api/runtime/save/snapshot`, - withBearer(phoneEntry.token, { - method: 'DELETE', - }), - ); - const deleteSnapshotPayload = (await deleteSnapshotResponse.json()) as { - ok: boolean; - }; - - assert.equal(deleteSnapshotResponse.status, 200); - assert.equal(deleteSnapshotPayload.ok, true); - - const emptySnapshotResponse = await httpRequest( - `${baseUrl}/api/runtime/save/snapshot`, - { - headers: { - Authorization: `Bearer ${phoneEntry.token}`, - }, - }, - ); - const emptySnapshotPayload = await emptySnapshotResponse.json(); - - assert.equal(emptySnapshotResponse.status, 200); - assert.equal(emptySnapshotPayload, null); - console.log('[server-node:smoke] runtime snapshot delete ok'); - - const logoutResponse = await httpRequest( - `${baseUrl}/api/auth/logout`, - withBearer(phoneEntry.token, { - method: 'POST', - }), - ); - const logoutPayload = (await logoutResponse.json()) as { - ok: boolean; - }; - - assert.equal(logoutResponse.status, 200); - assert.equal(logoutPayload.ok, true); - - const expiredTokenResponse = await httpRequest(`${baseUrl}/api/auth/me`, { - headers: { - Authorization: `Bearer ${phoneEntry.token}`, - }, - }); - - assert.equal(expiredTokenResponse.status, 401); - console.log('[server-node:smoke] logout invalidation ok'); - }); - - console.log('[server-node:smoke] all checks passed'); -} - -void main().catch((error) => { - console.error('[server-node:smoke] failed'); - console.error(error); - process.exit(1); -}); diff --git a/scripts/update.sh b/scripts/update.sh deleted file mode 100644 index 01310482..00000000 --- a/scripts/update.sh +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -usage() { - cat <<'EOF' -用法: - ./scripts/update.sh - -说明: - 1. 对当前仓库执行 git pull - 2. 只构建前端 - 3. 固定同步前端 dist 到 /work/dist - 4. 固定同步 server-node 到 /work/server-node - -注意: - - server-node 同步时会排除 dist 和 node_modules - - 不会构建后端 - - 不会执行 npm ci - - 不会重启 PM2 -EOF -} - -require_command() { - local command_name="$1" - - if ! command -v "$command_name" >/dev/null 2>&1; then - echo "[update] 缺少命令: $command_name" >&2 - exit 1 - fi -} - -if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then - usage - exit 0 -fi - -require_command git -require_command npm -require_command rsync - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" -CLIENT_TARGET_DIR="/work/dist" -SERVER_TARGET_DIR="/work/server-node" - -echo "[update] 仓库目录: ${REPO_ROOT}" -echo "[update] 前端目标目录: ${CLIENT_TARGET_DIR}" -echo "[update] 后端目标目录: ${SERVER_TARGET_DIR}" - -cd "${REPO_ROOT}" - -# 先拉取当前分支的最新代码。 -echo "[update] 拉取当前分支最新代码" -git pull - -# 只构建前端,不处理后端构建。 -echo "[update] 构建前端" -npm run build - -# 固定创建 /work 下的目标目录。 -echo "[update] 创建目标目录" -mkdir -p "${CLIENT_TARGET_DIR}" "${SERVER_TARGET_DIR}" - -# 同步前端构建产物。 -echo "[update] 同步前端 dist -> ${CLIENT_TARGET_DIR}" -rsync -a --delete "${REPO_ROOT}/dist/" "${CLIENT_TARGET_DIR}/" - -# 同步 server-node 源码和配置,但保留目标目录自己的 dist 和 node_modules。 -echo "[update] 同步 server-node -> ${SERVER_TARGET_DIR}" -rsync -a --delete \ - --exclude 'dist/' \ - --exclude 'node_modules/' \ - "${REPO_ROOT}/server-node/" "${SERVER_TARGET_DIR}/" - -echo "[update] 完成" diff --git a/server-node/build.mjs b/server-node/build.mjs deleted file mode 100644 index 2b8674ac..00000000 --- a/server-node/build.mjs +++ /dev/null @@ -1,16 +0,0 @@ -import esbuild from 'esbuild'; - -await esbuild.build({ - entryPoints: ['src/server.ts'], - bundle: true, - platform: 'node', - format: 'cjs', - target: 'node22', - outfile: 'dist/server.cjs', - sourcemap: true, - packages: 'external', - tsconfig: 'tsconfig.json', - define: { - 'import.meta.url': 'undefined', - }, -}); diff --git a/server-node/ecosystem.config.cjs b/server-node/ecosystem.config.cjs deleted file mode 100644 index bc73b332..00000000 --- a/server-node/ecosystem.config.cjs +++ /dev/null @@ -1,18 +0,0 @@ -module.exports = { - apps: [ - { - name: 'genarrative-server', - script: 'dist/server.cjs', - cwd: __dirname, - instances: 1, - exec_mode: 'fork', - watch: false, - env: { - NODE_ENV: 'production', - }, - error_file: 'logs/error.log', - out_file: 'logs/out.log', - time: true, - }, - ], -}; diff --git a/server-node/manifests/backend-capability-index.json b/server-node/manifests/backend-capability-index.json deleted file mode 100644 index 9097ca17..00000000 --- a/server-node/manifests/backend-capability-index.json +++ /dev/null @@ -1,2250 +0,0 @@ -{ - "generatedAt": "2026-04-20T14:26:38.663Z", - "manifestVersion": "2026-04-20", - "generatedCommand": "npm run server-node:manifest:backend", - "outputTargets": { - "json": "server-node/manifests/backend-capability-index.json", - "markdown": "docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md" - }, - "summary": { - "surfaceCount": 6, - "routeCount": 96, - "moduleCount": 12, - "publicRouteCount": 10, - "jwtRouteCount": 69, - "envSwitchRouteCount": 17, - "streamRouteCount": 6 - }, - "surfaces": [ - { - "id": "assets", - "title": "资产生成工具面", - "mounts": [ - { - "entryFile": "server-node/src/app.ts", - "mountPath": "/api/assets", - "routeFactory": "createCharacterAssetRoutes" - }, - { - "entryFile": "server-node/src/app.ts", - "mountPath": "/api/assets/qwen-sprite", - "routeFactory": "createQwenSpriteRoutes" - } - ], - "responsibilities": [ - "生成角色主形象、动作、动作模板与工作流缓存。", - "承接 Qwen 精灵表主图、整表、修帧与保存链路。", - "把产物发布到 `public/generated-*` 目录并落地局部 manifest。" - ], - "primaryServiceBoundaries": [ - "负责对接 DashScope、Ark 等外部媒体供应商,但不维护 runtime 快照与业务状态。", - "统一受 `ASSETS_API_ENABLED` 开关控制,产物以文件与 JSON manifest 形式落在仓库工作区。" - ], - "relatedModuleIds": [ - "assets" - ], - "routeCount": 14, - "routeIds": [ - "assets.characterAnimationGenerate", - "assets.characterAnimationImportVideo", - "assets.characterAnimationJobGet", - "assets.characterAnimationPublish", - "assets.characterAnimationTemplatesList", - "assets.characterVisualGenerate", - "assets.characterVisualJobGet", - "assets.characterVisualPublish", - "assets.characterWorkflowCacheSave", - "assets.characterWorkflowCacheGet", - "assets.qwenSpriteFrameRepairGenerate", - "assets.qwenSpriteMasterGenerate", - "assets.qwenSpriteAssetSave", - "assets.qwenSpriteSheetGenerate" - ] - }, - { - "id": "auth", - "title": "鉴权与会话面", - "mounts": [ - { - "entryFile": "server-node/src/app.ts", - "mountPath": "/api/auth", - "routeFactory": "createAuthRoutes" - } - ], - "responsibilities": [ - "承接本地账号、短信验证码与微信登录流程。", - "管理 refresh session、用户信息、会话吊销、审计日志与风险拦截。" - ], - "primaryServiceBoundaries": [ - "HTTP 层只做 schema 校验、请求上下文拼装与 Cookie 管理,核心鉴权逻辑统一收口到 `server-node/src/auth/*`。", - "用户、身份、会话、风控与短信事件等持久化职责全部下沉到 repository 层,避免路由直接碰数据库细节。" - ], - "relatedModuleIds": [], - "routeCount": 17, - "routeIds": [ - "auth.auditLogs", - "auth.entry", - "auth.loginOptions", - "auth.logout", - "auth.logoutAll", - "auth.me", - "auth.phoneChange", - "auth.phoneLogin", - "auth.phoneSendCode", - "auth.refresh", - "auth.riskBlocks", - "auth.riskBlocksLift", - "auth.sessions", - "auth.sessionRevoke", - "auth.wechatBindPhone", - "auth.wechatCallback", - "auth.wechatStart" - ] - }, - { - "id": "editor", - "title": "编辑器工具面", - "mounts": [ - { - "entryFile": "server-node/src/app.ts", - "mountPath": "/api/editor", - "routeFactory": "createEditorRoutes" - } - ], - "responsibilities": [ - "读取编辑器资源 JSON。", - "回写编辑器覆盖文件。", - "枚举 `public/Icons` 下的物品图标资源。" - ], - "primaryServiceBoundaries": [ - "只对工作区文件系统与 `public` 目录负责,不参与运行时数据库存储。", - "统一受 `EDITOR_API_ENABLED` 开关控制,生产环境可按需关闭。" - ], - "relatedModuleIds": [ - "editor" - ], - "routeCount": 3, - "routeIds": [ - "editor.catalogItems", - "editor.resourceRead", - "editor.resourceWrite" - ] - }, - { - "id": "health", - "title": "基础健康检查", - "mounts": [ - { - "entryFile": "server-node/src/app.ts", - "mountPath": "/healthz", - "routeFactory": "createApp" - } - ], - "responsibilities": [ - "提供 Node 后端进程级健康探针。", - "给反向代理、部署平台和本地联调提供最小可用状态确认。" - ], - "primaryServiceBoundaries": [ - "只返回服务静态信息,不触达数据库、鉴权或外部模型供应商。" - ], - "relatedModuleIds": [], - "routeCount": 1, - "routeIds": [ - "health.check" - ] - }, - { - "id": "runtime-main", - "title": "运行时主能力面", - "mounts": [ - { - "entryFile": "server-node/src/app.ts", - "mountPath": "/api", - "routeFactory": "createRuntimeRoutes" - }, - { - "entryFile": "server-node/src/routes/runtimeRoutes.ts", - "mountPath": "/runtime/custom-world/agent", - "routeFactory": "createCustomWorldAgentRoutes" - } - ], - "responsibilities": [ - "承接运行时资料库、公开画廊、存档、设置与个人档案接口。", - "承接剧情生成、聊天流、任务生成、运行时物品意图与自定义世界链路。", - "承接 Custom World Agent 会话、消息流和操作回放。" - ], - "primaryServiceBoundaries": [ - "HTTP contract 收口在 `runtimeRoutes.ts`,真正的世界生成、剧情、聊天、任务和资源逻辑继续下沉到 `services/*` 与 `src/modules/*`。", - "除公开画廊外,运行时接口统一走 JWT 鉴权,并依赖 `runtimeRepository`、session store 与 LLM client 执行。" - ], - "relatedModuleIds": [ - "ai", - "custom-world", - "quest", - "runtime", - "runtime-item", - "story" - ], - "routeCount": 59, - "routeIds": [ - "runtime.customWorldCoverImage", - "runtime.customWorldCoverUpload", - "runtime.customWorldEntity.primary", - "runtime.customWorldSceneImage", - "runtime.customWorldSceneNpc.primary", - "runtime.llmChatCompletionsProxy", - "runtime.profileBrowseHistoryDelete.primary", - "runtime.profileBrowseHistoryGet.primary", - "runtime.profileBrowseHistoryPost.primary", - "runtime.profileDashboard.primary", - "runtime.profilePlayStats.primary", - "runtime.profileSaveArchivesList.primary", - "runtime.profileSaveArchivesResume.primary", - "runtime.profileWalletLedger.primary", - "runtime.characterReplyStream", - "runtime.characterSuggestions", - "runtime.characterSummary", - "runtime.npcDialogueStream", - "runtime.npcRecruitStream", - "runtime.npcTurnStream", - "runtime.customWorldGalleryList", - "runtime.customWorldGalleryDetail", - "runtime.customWorldLibraryList", - "runtime.customWorldLibraryDelete", - "runtime.customWorldLibraryUpsert", - "runtime.customWorldLibraryPublish", - "runtime.customWorldLibraryUnpublish", - "runtime.customWorldAgentCreateSession", - "runtime.customWorldAgentGetSession", - "runtime.customWorldAgentExecuteAction", - "runtime.customWorldAgentGetCardDetail", - "runtime.customWorldAgentSendMessage", - "runtime.customWorldAgentStreamMessage", - "runtime.customWorldAgentGetOperation", - "runtime.customWorldEntity.compat", - "runtime.customWorldSceneNpc.compat", - "runtime.customWorldSessionCreate", - "runtime.customWorldSessionGet", - "runtime.customWorldSessionAnswer", - "runtime.customWorldSessionGenerateStream", - "runtime.customWorldWorksList", - "runtime.itemsIntent", - "runtime.profileBrowseHistoryDelete.compat", - "runtime.profileBrowseHistoryGet.compat", - "runtime.profileBrowseHistoryPost.compat", - "runtime.profileDashboard.compat", - "runtime.profilePlayStats.compat", - "runtime.profileSaveArchivesList.compat", - "runtime.profileSaveArchivesResume.compat", - "runtime.profileWalletLedger.compat", - "runtime.questsGenerate", - "runtime.snapshotDelete", - "runtime.snapshotGet", - "runtime.snapshotPut", - "runtime.settingsGet", - "runtime.settingsPut", - "runtime.storyContinue", - "runtime.storyInitial", - "runtime.wsHealth" - ] - }, - { - "id": "runtime-story-action", - "title": "运行时 Story Action 面", - "mounts": [ - { - "entryFile": "server-node/src/app.ts", - "mountPath": "/api/runtime/story", - "routeFactory": "createStoryActionRoutes" - } - ], - "responsibilities": [ - "把前端 story choice 动作解析为新的运行时状态。", - "查询指定 story session 的可恢复状态。" - ], - "primaryServiceBoundaries": [ - "路由层只做鉴权与 schema 校验,真正的动作分发与跨模块协作集中在 `storyActionService.ts`。", - "Story Action 会联动 quest、inventory、runtime-item、npc 等内部模块,但对前端只暴露 story 这一条稳定入口。" - ], - "relatedModuleIds": [ - "story", - "quest", - "inventory", - "runtime-item", - "npc", - "progression", - "combat", - "runtime" - ], - "routeCount": 2, - "routeIds": [ - "storyAction.resolve", - "storyAction.stateGet" - ] - } - ], - "modules": [ - { - "id": "ai", - "title": "AI 编排模块", - "directory": "server-node/src/modules/ai", - "exposedBySurfaceIds": [ - "runtime-main" - ], - "responsibilities": [ - "统一剧情、多轮聊天与自定义世界编排器的 prompt 构造与输出归一化。", - "屏蔽前端对不同 AI 链路的直接拼装细节。" - ], - "primaryServiceBoundaries": [ - "专注提示词与编排,不负责持久化与 HTTP 传输。", - "通过 `services/llmClient.ts` 与外部模型交互,由路由与 service 层决定何时调用。" - ], - "keyFiles": [ - "server-node/src/modules/ai/chatOrchestrator.ts", - "server-node/src/modules/ai/customWorldOrchestrator.ts", - "server-node/src/modules/ai/storyOrchestrator.ts" - ], - "routeCount": 23, - "routeIds": [ - "runtime.customWorldEntity.primary", - "runtime.customWorldSceneNpc.primary", - "runtime.llmChatCompletionsProxy", - "runtime.characterReplyStream", - "runtime.characterSuggestions", - "runtime.characterSummary", - "runtime.npcDialogueStream", - "runtime.npcRecruitStream", - "runtime.npcTurnStream", - "runtime.customWorldAgentCreateSession", - "runtime.customWorldAgentGetSession", - "runtime.customWorldAgentExecuteAction", - "runtime.customWorldAgentGetCardDetail", - "runtime.customWorldAgentSendMessage", - "runtime.customWorldAgentStreamMessage", - "runtime.customWorldAgentGetOperation", - "runtime.customWorldEntity.compat", - "runtime.customWorldSceneNpc.compat", - "runtime.customWorldSessionGenerateStream", - "runtime.itemsIntent", - "runtime.questsGenerate", - "runtime.storyContinue", - "runtime.storyInitial" - ] - }, - { - "id": "assets", - "title": "资产工具模块", - "directory": "server-node/src/modules/assets", - "exposedBySurfaceIds": [ - "assets" - ], - "responsibilities": [ - "承接角色资产与 Qwen 精灵表的生成、查询、发布和保存。", - "维护资产流程需要的缓存、草稿与产物 manifest。" - ], - "primaryServiceBoundaries": [ - "以文件系统和外部媒体模型为主要边界,不碰 runtimeRepository。", - "对外暴露稳定 HTTP 路径,对内通过私有 helper 处理媒体编码、任务轮询与写盘。" - ], - "keyFiles": [ - "server-node/src/modules/assets/characterAssetRoutes.ts", - "server-node/src/modules/assets/qwenSpriteRoutes.ts" - ], - "routeCount": 18, - "routeIds": [ - "assets.characterAnimationGenerate", - "assets.characterAnimationImportVideo", - "assets.characterAnimationJobGet", - "assets.characterAnimationPublish", - "assets.characterAnimationTemplatesList", - "assets.characterVisualGenerate", - "assets.characterVisualJobGet", - "assets.characterVisualPublish", - "assets.characterWorkflowCacheSave", - "assets.characterWorkflowCacheGet", - "assets.qwenSpriteFrameRepairGenerate", - "assets.qwenSpriteMasterGenerate", - "assets.qwenSpriteAssetSave", - "assets.qwenSpriteSheetGenerate", - "runtime.customWorldCoverImage", - "runtime.customWorldCoverUpload", - "runtime.customWorldSceneImage", - "runtime.customWorldAgentExecuteAction" - ] - }, - { - "id": "combat", - "title": "战斗结算模块", - "directory": "server-node/src/modules/combat", - "exposedBySurfaceIds": [ - "runtime-story-action" - ], - "responsibilities": [ - "提供运行时战斗结算与数值变更能力。", - "为 story action 里的战斗型交互提供纯计算服务。" - ], - "primaryServiceBoundaries": [ - "聚焦状态推导与结果计算,不负责 transport 与持久化。" - ], - "keyFiles": [ - "server-node/src/modules/combat/combatResolutionService.ts" - ], - "routeCount": 1, - "routeIds": [ - "storyAction.resolve" - ] - }, - { - "id": "custom-world", - "title": "自定义世界运行时模块", - "directory": "server-node/src/modules/custom-world", - "exposedBySurfaceIds": [ - "runtime-main" - ], - "responsibilities": [ - "规范 creator intent、世界运行时类型与 profile compile。", - "把世界创作输入整理成运行时可消费的数据结构。" - ], - "primaryServiceBoundaries": [ - "偏纯领域建模与 compile,不直接做 HTTP、数据库查询或模型调用。" - ], - "keyFiles": [ - "server-node/src/modules/custom-world/creatorIntentRuntime.ts", - "server-node/src/modules/custom-world/runtimeProfile.ts", - "server-node/src/modules/custom-world/runtimeTypes.ts" - ], - "routeCount": 26, - "routeIds": [ - "runtime.customWorldCoverImage", - "runtime.customWorldCoverUpload", - "runtime.customWorldEntity.primary", - "runtime.customWorldSceneImage", - "runtime.customWorldSceneNpc.primary", - "runtime.customWorldGalleryList", - "runtime.customWorldGalleryDetail", - "runtime.customWorldLibraryList", - "runtime.customWorldLibraryDelete", - "runtime.customWorldLibraryUpsert", - "runtime.customWorldLibraryPublish", - "runtime.customWorldLibraryUnpublish", - "runtime.customWorldAgentCreateSession", - "runtime.customWorldAgentGetSession", - "runtime.customWorldAgentExecuteAction", - "runtime.customWorldAgentGetCardDetail", - "runtime.customWorldAgentSendMessage", - "runtime.customWorldAgentStreamMessage", - "runtime.customWorldAgentGetOperation", - "runtime.customWorldEntity.compat", - "runtime.customWorldSceneNpc.compat", - "runtime.customWorldSessionCreate", - "runtime.customWorldSessionGet", - "runtime.customWorldSessionAnswer", - "runtime.customWorldSessionGenerateStream", - "runtime.customWorldWorksList" - ] - }, - { - "id": "editor", - "title": "编辑器资源模块", - "directory": "server-node/src/modules/editor", - "exposedBySurfaceIds": [ - "editor" - ], - "responsibilities": [ - "提供编辑器资源目录枚举与 JSON 读写入口。" - ], - "primaryServiceBoundaries": [ - "只负责工作区文件输入输出,不参与运行时业务计算。" - ], - "keyFiles": [ - "server-node/src/modules/editor/editorRoutes.ts" - ], - "routeCount": 3, - "routeIds": [ - "editor.catalogItems", - "editor.resourceRead", - "editor.resourceWrite" - ] - }, - { - "id": "inventory", - "title": "背包与物品变更模块", - "directory": "server-node/src/modules/inventory", - "exposedBySurfaceIds": [ - "runtime-story-action" - ], - "responsibilities": [ - "维护背包变更、NPC 背包交互与 story action 里的物品副作用。" - ], - "primaryServiceBoundaries": [ - "对运行时状态做局部变更,不直接暴露 HTTP 路由。" - ], - "keyFiles": [ - "server-node/src/modules/inventory/inventoryMutationService.ts", - "server-node/src/modules/inventory/inventoryStoryActionService.ts", - "server-node/src/modules/inventory/npcInventoryStoryActionService.ts" - ], - "routeCount": 1, - "routeIds": [ - "storyAction.resolve" - ] - }, - { - "id": "npc", - "title": "NPC 交互模块", - "directory": "server-node/src/modules/npc", - "exposedBySurfaceIds": [ - "runtime-story-action", - "runtime-main" - ], - "responsibilities": [ - "维护 NPC 互动规则、任务 primitive 与关系变更逻辑。" - ], - "primaryServiceBoundaries": [ - "专注 NPC 侧状态推导,供 story action 与聊天/任务链路复用。" - ], - "keyFiles": [ - "server-node/src/modules/npc/npcInteractionService.ts", - "server-node/src/modules/npc/npcTask6Primitives.ts" - ], - "routeCount": 6, - "routeIds": [ - "runtime.customWorldSceneNpc.primary", - "runtime.npcDialogueStream", - "runtime.npcRecruitStream", - "runtime.npcTurnStream", - "runtime.customWorldSceneNpc.compat", - "storyAction.resolve" - ] - }, - { - "id": "progression", - "title": "成长与关卡进程模块", - "directory": "server-node/src/modules/progression", - "exposedBySurfaceIds": [ - "runtime-story-action", - "runtime-main" - ], - "responsibilities": [ - "提供角色成长、敌对等级、章节推进与 benchmark 逻辑。" - ], - "primaryServiceBoundaries": [ - "只做成长数值与章节进度计算,由 runtime hydrate 与 story action 复用。" - ], - "keyFiles": [ - "server-node/src/modules/progression/playerProgressionService.ts", - "server-node/src/modules/progression/hostileProgressionService.ts", - "server-node/src/modules/progression/chapterProgressionPlanner.ts" - ], - "routeCount": 3, - "routeIds": [ - "runtime.snapshotGet", - "runtime.snapshotPut", - "storyAction.resolve" - ] - }, - { - "id": "quest", - "title": "任务运行时模块", - "directory": "server-node/src/modules/quest", - "exposedBySurfaceIds": [ - "runtime-main", - "runtime-story-action" - ], - "responsibilities": [ - "生成任务意图、维护任务日志与处理任务进度信号。", - "为运行时 quest 接口与 story action 提供统一任务语义。" - ], - "primaryServiceBoundaries": [ - "领域逻辑以 quest module 为中心,AI 生成只是一种输入来源。", - "不直接处理 HTTP 响应,统一由 routes/service 层调用。" - ], - "keyFiles": [ - "server-node/src/modules/quest/runtimeQuestModule.ts", - "server-node/src/modules/quest/questProgressionService.ts", - "server-node/src/modules/quest/questStoryActionService.ts" - ], - "routeCount": 4, - "routeIds": [ - "runtime.questsGenerate", - "runtime.snapshotGet", - "runtime.snapshotPut", - "storyAction.resolve" - ] - }, - { - "id": "runtime", - "title": "运行时状态基座模块", - "directory": "server-node/src/modules/runtime", - "exposedBySurfaceIds": [ - "runtime-main", - "runtime-story-action" - ], - "responsibilities": [ - "定义运行时状态 primitive、经济与装备规则。", - "负责存档 hydration、兼容迁移与状态归一化。" - ], - "primaryServiceBoundaries": [ - "是 runtimeRepository 与 story action 的共同状态基座,不承担 HTTP 入口职责。" - ], - "keyFiles": [ - "server-node/src/modules/runtime/runtimeSnapshotHydration.ts", - "server-node/src/modules/runtime/runtimeStatePrimitives.ts", - "server-node/src/modules/runtime/runtimeEquipmentModule.ts" - ], - "routeCount": 32, - "routeIds": [ - "runtime.profileBrowseHistoryDelete.primary", - "runtime.profileBrowseHistoryGet.primary", - "runtime.profileBrowseHistoryPost.primary", - "runtime.profileDashboard.primary", - "runtime.profilePlayStats.primary", - "runtime.profileSaveArchivesList.primary", - "runtime.profileSaveArchivesResume.primary", - "runtime.profileWalletLedger.primary", - "runtime.customWorldGalleryList", - "runtime.customWorldGalleryDetail", - "runtime.customWorldLibraryList", - "runtime.customWorldLibraryDelete", - "runtime.customWorldLibraryUpsert", - "runtime.customWorldLibraryPublish", - "runtime.customWorldLibraryUnpublish", - "runtime.customWorldWorksList", - "runtime.profileBrowseHistoryDelete.compat", - "runtime.profileBrowseHistoryGet.compat", - "runtime.profileBrowseHistoryPost.compat", - "runtime.profileDashboard.compat", - "runtime.profilePlayStats.compat", - "runtime.profileSaveArchivesList.compat", - "runtime.profileSaveArchivesResume.compat", - "runtime.profileWalletLedger.compat", - "runtime.snapshotDelete", - "runtime.snapshotGet", - "runtime.snapshotPut", - "runtime.settingsGet", - "runtime.settingsPut", - "storyAction.resolve", - "storyAction.stateGet", - "runtime.wsHealth" - ] - }, - { - "id": "runtime-item", - "title": "运行时物品模块", - "directory": "server-node/src/modules/runtime-item", - "exposedBySurfaceIds": [ - "runtime-main", - "runtime-story-action" - ], - "responsibilities": [ - "生成运行时物品意图、物品奖励与剧情指纹。", - "维护宝藏与物品解析逻辑。" - ], - "primaryServiceBoundaries": [ - "聚焦物品领域编译与奖励拼装,由 route/service 选择具体触发时机。" - ], - "keyFiles": [ - "server-node/src/modules/runtime-item/runtimeItemModule.ts", - "server-node/src/modules/runtime-item/runtimeItemResolutionService.ts", - "server-node/src/modules/runtime-item/treasureStoryActionService.ts" - ], - "routeCount": 2, - "routeIds": [ - "runtime.itemsIntent", - "storyAction.resolve" - ] - }, - { - "id": "story", - "title": "故事会话模块", - "directory": "server-node/src/modules/story", - "exposedBySurfaceIds": [ - "runtime-main", - "runtime-story-action" - ], - "responsibilities": [ - "维护运行时故事会话状态与 action 分发。", - "为 story resolve、story state 查询提供统一入口。" - ], - "primaryServiceBoundaries": [ - "story 模块是 runtime 主循环的编排层,必要时再向 quest、inventory、combat 等领域模块分发。" - ], - "keyFiles": [ - "server-node/src/modules/story/runtimeSession.ts", - "server-node/src/modules/story/storyActionRoutes.ts", - "server-node/src/modules/story/storyActionService.ts" - ], - "routeCount": 10, - "routeIds": [ - "runtime.characterReplyStream", - "runtime.characterSuggestions", - "runtime.characterSummary", - "runtime.npcDialogueStream", - "runtime.npcRecruitStream", - "runtime.npcTurnStream", - "storyAction.resolve", - "runtime.storyContinue", - "runtime.storyInitial", - "storyAction.stateGet" - ] - } - ], - "routes": [ - { - "id": "assets.characterAnimationGenerate", - "group": "assets-character-animation", - "method": "POST", - "path": "/api/assets/character-animation/generate", - "operation": "assets.character.animation.generate", - "access": "开关: ASSETS_API_ENABLED", - "responseMode": "json", - "summary": "生成角色动作草稿。", - "domainModuleIds": [ - "assets" - ], - "sourceHint": "assets.character.animation.generate", - "surfaceId": "assets", - "sourceFile": "server-node/src/modules/assets/characterAssetRoutes.ts" - }, - { - "id": "assets.characterAnimationImportVideo", - "group": "assets-character-animation", - "method": "POST", - "path": "/api/assets/character-animation/import-video", - "operation": "assets.character.animation.importVideo", - "access": "开关: ASSETS_API_ENABLED", - "responseMode": "json", - "summary": "导入动作参考视频并转为可消费素材。", - "domainModuleIds": [ - "assets" - ], - "sourceHint": "assets.character.animation.importVideo", - "surfaceId": "assets", - "sourceFile": "server-node/src/modules/assets/characterAssetRoutes.ts" - }, - { - "id": "assets.characterAnimationJobGet", - "group": "assets-character-animation", - "method": "GET", - "path": "/api/assets/character-animation/jobs/:taskId", - "operation": "assets.character.animation.job.get", - "access": "开关: ASSETS_API_ENABLED", - "responseMode": "json", - "summary": "查询角色动作生成任务状态。", - "domainModuleIds": [ - "assets" - ], - "sourceHint": "assets.character.animation.job.get", - "surfaceId": "assets", - "sourceFile": "server-node/src/modules/assets/characterAssetRoutes.ts" - }, - { - "id": "assets.characterAnimationPublish", - "group": "assets-character-animation", - "method": "POST", - "path": "/api/assets/character-animation/publish", - "operation": "assets.character.animation.publish", - "access": "开关: ASSETS_API_ENABLED", - "responseMode": "json", - "summary": "发布角色动作帧集到 public 目录。", - "domainModuleIds": [ - "assets" - ], - "sourceHint": "assets.character.animation.publish", - "surfaceId": "assets", - "sourceFile": "server-node/src/modules/assets/characterAssetRoutes.ts" - }, - { - "id": "assets.characterAnimationTemplatesList", - "group": "assets-character-animation", - "method": "GET", - "path": "/api/assets/character-animation/templates", - "operation": "assets.character.animation.templates.list", - "access": "开关: ASSETS_API_ENABLED", - "responseMode": "json", - "summary": "列出内置角色动作模板。", - "domainModuleIds": [ - "assets" - ], - "sourceHint": "assets.character.animation.templates.list", - "surfaceId": "assets", - "sourceFile": "server-node/src/modules/assets/characterAssetRoutes.ts" - }, - { - "id": "assets.characterVisualGenerate", - "group": "assets-character-visual", - "method": "POST", - "path": "/api/assets/character-visual/generate", - "operation": "assets.character.visual.generate", - "access": "开关: ASSETS_API_ENABLED", - "responseMode": "json", - "summary": "生成角色主形象候选图。", - "domainModuleIds": [ - "assets" - ], - "sourceHint": "assets.character.visual.generate", - "surfaceId": "assets", - "sourceFile": "server-node/src/modules/assets/characterAssetRoutes.ts" - }, - { - "id": "assets.characterVisualJobGet", - "group": "assets-character-visual", - "method": "GET", - "path": "/api/assets/character-visual/jobs/:taskId", - "operation": "assets.character.visual.job.get", - "access": "开关: ASSETS_API_ENABLED", - "responseMode": "json", - "summary": "查询角色主形象生成任务状态。", - "domainModuleIds": [ - "assets" - ], - "sourceHint": "assets.character.visual.job.get", - "surfaceId": "assets", - "sourceFile": "server-node/src/modules/assets/characterAssetRoutes.ts" - }, - { - "id": "assets.characterVisualPublish", - "group": "assets-character-visual", - "method": "POST", - "path": "/api/assets/character-visual/publish", - "operation": "assets.character.visual.publish", - "access": "开关: ASSETS_API_ENABLED", - "responseMode": "json", - "summary": "发布选中的角色主形象到 public 目录。", - "domainModuleIds": [ - "assets" - ], - "sourceHint": "assets.character.visual.publish", - "surfaceId": "assets", - "sourceFile": "server-node/src/modules/assets/characterAssetRoutes.ts" - }, - { - "id": "assets.characterWorkflowCacheSave", - "group": "assets-character-cache", - "method": "POST", - "path": "/api/assets/character-workflow-cache", - "operation": "assets.character.workflowCache.save", - "access": "开关: ASSETS_API_ENABLED", - "responseMode": "json", - "summary": "保存角色资产工作流缓存。", - "domainModuleIds": [ - "assets" - ], - "sourceHint": "assets.character.workflowCache.save", - "surfaceId": "assets", - "sourceFile": "server-node/src/modules/assets/characterAssetRoutes.ts" - }, - { - "id": "assets.characterWorkflowCacheGet", - "group": "assets-character-cache", - "method": "GET", - "path": "/api/assets/character-workflow-cache/:characterId", - "operation": "assets.character.workflowCache.get", - "access": "开关: ASSETS_API_ENABLED", - "responseMode": "json", - "summary": "按角色读取角色资产工作流缓存。", - "domainModuleIds": [ - "assets" - ], - "sourceHint": "assets.character.workflowCache.get", - "surfaceId": "assets", - "sourceFile": "server-node/src/modules/assets/characterAssetRoutes.ts" - }, - { - "id": "assets.qwenSpriteFrameRepairGenerate", - "group": "assets-qwen", - "method": "POST", - "path": "/api/assets/qwen-sprite/frame-repair", - "operation": "assets.qwenSprite.frameRepair.generate", - "access": "开关: ASSETS_API_ENABLED", - "responseMode": "json", - "summary": "对单帧做 Qwen 修复。", - "domainModuleIds": [ - "assets" - ], - "sourceHint": "assets.qwenSprite.frameRepair.generate", - "surfaceId": "assets", - "sourceFile": "server-node/src/modules/assets/qwenSpriteRoutes.ts" - }, - { - "id": "assets.qwenSpriteMasterGenerate", - "group": "assets-qwen", - "method": "POST", - "path": "/api/assets/qwen-sprite/master", - "operation": "assets.qwenSprite.master.generate", - "access": "开关: ASSETS_API_ENABLED", - "responseMode": "json", - "summary": "生成 Qwen 精灵主图。", - "domainModuleIds": [ - "assets" - ], - "sourceHint": "assets.qwenSprite.master.generate", - "surfaceId": "assets", - "sourceFile": "server-node/src/modules/assets/qwenSpriteRoutes.ts" - }, - { - "id": "assets.qwenSpriteAssetSave", - "group": "assets-qwen", - "method": "POST", - "path": "/api/assets/qwen-sprite/save", - "operation": "assets.qwenSprite.asset.save", - "access": "开关: ASSETS_API_ENABLED", - "responseMode": "json", - "summary": "保存 Qwen 精灵资产到 public 目录。", - "domainModuleIds": [ - "assets" - ], - "sourceHint": "assets.qwenSprite.asset.save", - "surfaceId": "assets", - "sourceFile": "server-node/src/modules/assets/qwenSpriteRoutes.ts" - }, - { - "id": "assets.qwenSpriteSheetGenerate", - "group": "assets-qwen", - "method": "POST", - "path": "/api/assets/qwen-sprite/sheet", - "operation": "assets.qwenSprite.sheet.generate", - "access": "开关: ASSETS_API_ENABLED", - "responseMode": "json", - "summary": "生成 Qwen 精灵表。", - "domainModuleIds": [ - "assets" - ], - "sourceHint": "assets.qwenSprite.sheet.generate", - "surfaceId": "assets", - "sourceFile": "server-node/src/modules/assets/qwenSpriteRoutes.ts" - }, - { - "id": "auth.auditLogs", - "group": "auth-audit", - "method": "GET", - "path": "/api/auth/audit-logs", - "operation": "auth.audit_logs", - "access": "JWT", - "responseMode": "json", - "summary": "查询当前账号的鉴权审计日志。", - "domainModuleIds": [], - "sourceHint": "/audit-logs", - "surfaceId": "auth", - "sourceFile": "server-node/src/routes/authRoutes.ts" - }, - { - "id": "auth.entry", - "group": "auth-entry", - "method": "POST", - "path": "/api/auth/entry", - "operation": "auth.entry", - "access": "公开", - "responseMode": "json", - "summary": "用户名密码登录;不存在则创建本地账号。", - "domainModuleIds": [], - "sourceHint": "/entry", - "surfaceId": "auth", - "sourceFile": "server-node/src/routes/authRoutes.ts" - }, - { - "id": "auth.loginOptions", - "group": "auth-entry", - "method": "GET", - "path": "/api/auth/login-options", - "operation": "auth.login_options", - "access": "公开", - "responseMode": "json", - "summary": "返回当前启用的登录方式与入口配置。", - "domainModuleIds": [], - "sourceHint": "/login-options", - "surfaceId": "auth", - "sourceFile": "server-node/src/routes/authRoutes.ts" - }, - { - "id": "auth.logout", - "group": "auth-session", - "method": "POST", - "path": "/api/auth/logout", - "operation": "auth.logout", - "access": "JWT", - "responseMode": "json", - "summary": "退出当前会话并清理 refresh cookie。", - "domainModuleIds": [], - "sourceHint": "/logout", - "surfaceId": "auth", - "sourceFile": "server-node/src/routes/authRoutes.ts" - }, - { - "id": "auth.logoutAll", - "group": "auth-session", - "method": "POST", - "path": "/api/auth/logout-all", - "operation": "auth.logout_all", - "access": "JWT", - "responseMode": "json", - "summary": "退出当前账号的全部会话。", - "domainModuleIds": [], - "sourceHint": "/logout-all", - "surfaceId": "auth", - "sourceFile": "server-node/src/routes/authRoutes.ts" - }, - { - "id": "auth.me", - "group": "auth-profile", - "method": "GET", - "path": "/api/auth/me", - "operation": "auth.me", - "access": "JWT", - "responseMode": "json", - "summary": "读取当前登录用户的鉴权资料。", - "domainModuleIds": [], - "sourceHint": "/me", - "surfaceId": "auth", - "sourceFile": "server-node/src/routes/authRoutes.ts" - }, - { - "id": "auth.phoneChange", - "group": "auth-phone", - "method": "POST", - "path": "/api/auth/phone/change", - "operation": "auth.phone.change", - "access": "JWT", - "responseMode": "json", - "summary": "已登录用户更换绑定手机号。", - "domainModuleIds": [], - "sourceHint": "/phone/change", - "surfaceId": "auth", - "sourceFile": "server-node/src/routes/authRoutes.ts" - }, - { - "id": "auth.phoneLogin", - "group": "auth-phone", - "method": "POST", - "path": "/api/auth/phone/login", - "operation": "auth.phone.login", - "access": "公开", - "responseMode": "json", - "summary": "手机号验证码登录。", - "domainModuleIds": [], - "sourceHint": "/phone/login", - "surfaceId": "auth", - "sourceFile": "server-node/src/routes/authRoutes.ts" - }, - { - "id": "auth.phoneSendCode", - "group": "auth-phone", - "method": "POST", - "path": "/api/auth/phone/send-code", - "operation": "auth.phone.send_code", - "access": "公开", - "responseMode": "json", - "summary": "发送手机号登录或绑定验证码。", - "domainModuleIds": [], - "sourceHint": "/phone/send-code", - "surfaceId": "auth", - "sourceFile": "server-node/src/routes/authRoutes.ts" - }, - { - "id": "auth.refresh", - "group": "auth-session", - "method": "POST", - "path": "/api/auth/refresh", - "operation": "auth.refresh", - "access": "公开", - "responseMode": "json", - "summary": "使用 refresh session 刷新 JWT。", - "domainModuleIds": [], - "sourceHint": "/refresh", - "surfaceId": "auth", - "sourceFile": "server-node/src/routes/authRoutes.ts" - }, - { - "id": "auth.riskBlocks", - "group": "auth-risk", - "method": "GET", - "path": "/api/auth/risk-blocks", - "operation": "auth.risk_blocks", - "access": "JWT", - "responseMode": "json", - "summary": "查询当前用户命中的风控封禁。", - "domainModuleIds": [], - "sourceHint": "/risk-blocks", - "surfaceId": "auth", - "sourceFile": "server-node/src/routes/authRoutes.ts" - }, - { - "id": "auth.riskBlocksLift", - "group": "auth-risk", - "method": "POST", - "path": "/api/auth/risk-blocks/:scopeType/lift", - "operation": "auth.risk_blocks.lift", - "access": "JWT", - "responseMode": "json", - "summary": "请求解除指定维度的风控拦截。", - "domainModuleIds": [], - "sourceHint": "/risk-blocks/:scopeType/lift", - "surfaceId": "auth", - "sourceFile": "server-node/src/routes/authRoutes.ts" - }, - { - "id": "auth.sessions", - "group": "auth-session", - "method": "GET", - "path": "/api/auth/sessions", - "operation": "auth.sessions", - "access": "JWT", - "responseMode": "json", - "summary": "列出当前账号的活跃会话。", - "domainModuleIds": [], - "sourceHint": "/sessions", - "surfaceId": "auth", - "sourceFile": "server-node/src/routes/authRoutes.ts" - }, - { - "id": "auth.sessionRevoke", - "group": "auth-session", - "method": "POST", - "path": "/api/auth/sessions/:sessionId/revoke", - "operation": "auth.sessions.revoke", - "access": "JWT", - "responseMode": "json", - "summary": "吊销指定会话。", - "domainModuleIds": [], - "sourceHint": "/sessions/:sessionId/revoke", - "surfaceId": "auth", - "sourceFile": "server-node/src/routes/authRoutes.ts" - }, - { - "id": "auth.wechatBindPhone", - "group": "auth-wechat", - "method": "POST", - "path": "/api/auth/wechat/bind-phone", - "operation": "auth.wechat.bind_phone", - "access": "JWT", - "responseMode": "json", - "summary": "为已登录微信账号绑定手机号。", - "domainModuleIds": [], - "sourceHint": "/wechat/bind-phone", - "surfaceId": "auth", - "sourceFile": "server-node/src/routes/authRoutes.ts" - }, - { - "id": "auth.wechatCallback", - "group": "auth-wechat", - "method": "GET", - "path": "/api/auth/wechat/callback", - "operation": "auth.wechat.callback", - "access": "公开", - "responseMode": "redirect", - "summary": "处理微信回调并重定向回前端。", - "domainModuleIds": [], - "sourceHint": "/wechat/callback", - "surfaceId": "auth", - "sourceFile": "server-node/src/routes/authRoutes.ts" - }, - { - "id": "auth.wechatStart", - "group": "auth-wechat", - "method": "GET", - "path": "/api/auth/wechat/start", - "operation": "auth.wechat.start", - "access": "公开", - "responseMode": "json", - "summary": "发起微信登录并返回授权 URL。", - "domainModuleIds": [], - "sourceHint": "/wechat/start", - "surfaceId": "auth", - "sourceFile": "server-node/src/routes/authRoutes.ts" - }, - { - "id": "runtime.customWorldCoverImage", - "group": "runtime-custom-world-assets", - "method": "POST", - "path": "/api/custom-world/cover-image", - "operation": "runtime.customWorld.coverImage", - "access": "JWT", - "responseMode": "json", - "summary": "生成自定义世界封面图。", - "domainModuleIds": [ - "custom-world", - "assets" - ], - "sourceHint": "/custom-world/cover-image", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.customWorldCoverUpload", - "group": "runtime-custom-world-assets", - "method": "POST", - "path": "/api/custom-world/cover-upload", - "operation": "runtime.customWorld.coverUpload", - "access": "JWT", - "responseMode": "json", - "summary": "上传并落地自定义世界封面图。", - "domainModuleIds": [ - "custom-world", - "assets" - ], - "sourceHint": "/custom-world/cover-upload", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.customWorldEntity.primary", - "group": "runtime-custom-world-assets", - "method": "POST", - "path": "/api/custom-world/entity", - "operation": "runtime.customWorld.entity", - "access": "JWT", - "responseMode": "json", - "summary": "按世界 profile 生成单个角色或地标实体。", - "domainModuleIds": [ - "custom-world", - "ai" - ], - "sourceHint": "/custom-world/entity", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.customWorldSceneImage", - "group": "runtime-custom-world-assets", - "method": "POST", - "path": "/api/custom-world/scene-image", - "operation": "runtime.customWorld.sceneImage", - "access": "JWT", - "responseMode": "json", - "summary": "生成自定义世界场景图。", - "domainModuleIds": [ - "custom-world", - "assets" - ], - "sourceHint": "/custom-world/scene-image", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.customWorldSceneNpc.primary", - "group": "runtime-custom-world-assets", - "method": "POST", - "path": "/api/custom-world/scene-npc", - "operation": "runtime.customWorld.sceneNpc", - "access": "JWT", - "responseMode": "json", - "summary": "按地标生成场景 NPC。", - "domainModuleIds": [ - "custom-world", - "ai", - "npc" - ], - "sourceHint": "/custom-world/scene-npc", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "editor.catalogItems", - "group": "editor-catalog", - "method": "GET", - "path": "/api/editor/catalog/items", - "operation": "editor.catalog.items.list", - "access": "开关: EDITOR_API_ENABLED", - "responseMode": "json", - "summary": "列出 `public/Icons` 下的物品图标资源。", - "domainModuleIds": [ - "editor" - ], - "sourceHint": "/api/editor/catalog/items", - "surfaceId": "editor", - "sourceFile": "server-node/src/modules/editor/editorRoutes.ts" - }, - { - "id": "editor.resourceRead", - "group": "editor-json", - "method": "GET", - "path": "/api/editor/json/:resourceId", - "operation": "editor.resource.read", - "access": "开关: EDITOR_API_ENABLED", - "responseMode": "json", - "summary": "读取指定编辑器资源 JSON。", - "domainModuleIds": [ - "editor" - ], - "sourceHint": "/api/editor/json/:resourceId", - "surfaceId": "editor", - "sourceFile": "server-node/src/modules/editor/editorRoutes.ts" - }, - { - "id": "editor.resourceWrite", - "group": "editor-json", - "method": "POST", - "path": "/api/editor/json/:resourceId", - "operation": "editor.resource.write", - "access": "开关: EDITOR_API_ENABLED", - "responseMode": "json", - "summary": "回写指定编辑器资源 JSON。", - "domainModuleIds": [ - "editor" - ], - "sourceHint": "/api/editor/json/:resourceId", - "surfaceId": "editor", - "sourceFile": "server-node/src/modules/editor/editorRoutes.ts" - }, - { - "id": "runtime.llmChatCompletionsProxy", - "group": "runtime-proxy", - "method": "POST", - "path": "/api/llm/chat/completions", - "operation": "runtime.llm.chatCompletionsProxy", - "access": "JWT", - "responseMode": "proxy", - "summary": "把聊天补全请求透传到上游模型。", - "domainModuleIds": [ - "ai" - ], - "sourceHint": "/llm/chat/completions", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.profileBrowseHistoryDelete.primary", - "group": "runtime-profile", - "method": "DELETE", - "path": "/api/profile/browse-history", - "operation": "profile.browseHistory.clear", - "access": "JWT", - "responseMode": "json", - "summary": "清空平台浏览历史。", - "domainModuleIds": [ - "runtime" - ], - "sourceHint": "routeCompatPaths('/profile/browse-history')", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.profileBrowseHistoryGet.primary", - "group": "runtime-profile", - "method": "GET", - "path": "/api/profile/browse-history", - "operation": "profile.browseHistory.list", - "access": "JWT", - "responseMode": "json", - "summary": "读取平台浏览历史。", - "domainModuleIds": [ - "runtime" - ], - "sourceHint": "routeCompatPaths('/profile/browse-history')", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.profileBrowseHistoryPost.primary", - "group": "runtime-profile", - "method": "POST", - "path": "/api/profile/browse-history", - "operation": "profile.browseHistory.upsert", - "access": "JWT", - "responseMode": "json", - "summary": "写入或批量同步平台浏览历史。", - "domainModuleIds": [ - "runtime" - ], - "sourceHint": "routeCompatPaths('/profile/browse-history')", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.profileDashboard.primary", - "group": "runtime-profile", - "method": "GET", - "path": "/api/profile/dashboard", - "operation": "profile.dashboard.get", - "access": "JWT", - "responseMode": "json", - "summary": "读取运行时个人主页汇总。", - "domainModuleIds": [ - "runtime" - ], - "sourceHint": "routeCompatPaths('/profile/dashboard')", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.profilePlayStats.primary", - "group": "runtime-profile", - "method": "GET", - "path": "/api/profile/play-stats", - "operation": "profile.playStats.get", - "access": "JWT", - "responseMode": "json", - "summary": "读取个人游玩统计。", - "domainModuleIds": [ - "runtime" - ], - "sourceHint": "routeCompatPaths('/profile/play-stats')", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.profileSaveArchivesList.primary", - "group": "runtime-save", - "method": "GET", - "path": "/api/profile/save-archives", - "operation": "profile.saveArchives.list", - "access": "JWT", - "responseMode": "json", - "summary": "列出个人存档摘要。", - "domainModuleIds": [ - "runtime" - ], - "sourceHint": "routeCompatPaths('/profile/save-archives')", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.profileSaveArchivesResume.primary", - "group": "runtime-save", - "method": "POST", - "path": "/api/profile/save-archives/:worldKey", - "operation": "profile.saveArchives.resume", - "access": "JWT", - "responseMode": "json", - "summary": "恢复指定世界的最近存档。", - "domainModuleIds": [ - "runtime" - ], - "sourceHint": "'/profile/save-archives/:worldKey'", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.profileWalletLedger.primary", - "group": "runtime-profile", - "method": "GET", - "path": "/api/profile/wallet-ledger", - "operation": "profile.walletLedger.list", - "access": "JWT", - "responseMode": "json", - "summary": "列出个人资产流水。", - "domainModuleIds": [ - "runtime" - ], - "sourceHint": "routeCompatPaths('/profile/wallet-ledger')", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.characterReplyStream", - "group": "runtime-chat", - "method": "POST", - "path": "/api/runtime/chat/character/reply/stream", - "operation": "runtime.chat.character.replyStream", - "access": "JWT", - "responseMode": "stream", - "summary": "流式生成角色回复。", - "domainModuleIds": [ - "ai", - "story" - ], - "sourceHint": "/runtime/chat/character/reply/stream", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.characterSuggestions", - "group": "runtime-chat", - "method": "POST", - "path": "/api/runtime/chat/character/suggestions", - "operation": "runtime.chat.character.suggestions", - "access": "JWT", - "responseMode": "json", - "summary": "生成角色聊天建议语。", - "domainModuleIds": [ - "ai", - "story" - ], - "sourceHint": "/runtime/chat/character/suggestions", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.characterSummary", - "group": "runtime-chat", - "method": "POST", - "path": "/api/runtime/chat/character/summary", - "operation": "runtime.chat.character.summary", - "access": "JWT", - "responseMode": "json", - "summary": "生成角色聊天摘要。", - "domainModuleIds": [ - "ai", - "story" - ], - "sourceHint": "/runtime/chat/character/summary", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.npcDialogueStream", - "group": "runtime-chat", - "method": "POST", - "path": "/api/runtime/chat/npc/dialogue/stream", - "operation": "runtime.chat.npc.dialogueStream", - "access": "JWT", - "responseMode": "stream", - "summary": "流式生成 NPC 对话。", - "domainModuleIds": [ - "ai", - "npc", - "story" - ], - "sourceHint": "/runtime/chat/npc/dialogue/stream", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.npcRecruitStream", - "group": "runtime-chat", - "method": "POST", - "path": "/api/runtime/chat/npc/recruit/stream", - "operation": "runtime.chat.npc.recruitStream", - "access": "JWT", - "responseMode": "stream", - "summary": "流式生成招募 NPC 对话。", - "domainModuleIds": [ - "ai", - "npc", - "story" - ], - "sourceHint": "/runtime/chat/npc/recruit/stream", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.npcTurnStream", - "group": "runtime-chat", - "method": "POST", - "path": "/api/runtime/chat/npc/turn/stream", - "operation": "runtime.chat.npc.turnStream", - "access": "JWT", - "responseMode": "stream", - "summary": "流式生成 NPC 单回合发言。", - "domainModuleIds": [ - "ai", - "npc", - "story" - ], - "sourceHint": "/runtime/chat/npc/turn/stream", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.customWorldGalleryList", - "group": "runtime-gallery", - "method": "GET", - "path": "/api/runtime/custom-world-gallery", - "operation": "runtime.customWorldGallery.list", - "access": "公开", - "responseMode": "json", - "summary": "列出公开的自定义世界画廊。", - "domainModuleIds": [ - "custom-world", - "runtime" - ], - "sourceHint": "/runtime/custom-world-gallery", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.customWorldGalleryDetail", - "group": "runtime-gallery", - "method": "GET", - "path": "/api/runtime/custom-world-gallery/:ownerUserId/:profileId", - "operation": "runtime.customWorldGallery.detail", - "access": "公开", - "responseMode": "json", - "summary": "读取指定公开世界作品详情。", - "domainModuleIds": [ - "custom-world", - "runtime" - ], - "sourceHint": "/runtime/custom-world-gallery/:ownerUserId/:profileId", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.customWorldLibraryList", - "group": "runtime-custom-world-library", - "method": "GET", - "path": "/api/runtime/custom-world-library", - "operation": "runtime.customWorldLibrary.list", - "access": "JWT", - "responseMode": "json", - "summary": "列出当前账号的自定义世界资料库。", - "domainModuleIds": [ - "custom-world", - "runtime" - ], - "sourceHint": "/runtime/custom-world-library", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.customWorldLibraryDelete", - "group": "runtime-custom-world-library", - "method": "DELETE", - "path": "/api/runtime/custom-world-library/:profileId", - "operation": "runtime.customWorldLibrary.delete", - "access": "JWT", - "responseMode": "json", - "summary": "删除指定自定义世界 profile。", - "domainModuleIds": [ - "custom-world", - "runtime" - ], - "sourceHint": "/runtime/custom-world-library/:profileId", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.customWorldLibraryUpsert", - "group": "runtime-custom-world-library", - "method": "PUT", - "path": "/api/runtime/custom-world-library/:profileId", - "operation": "runtime.customWorldLibrary.upsert", - "access": "JWT", - "responseMode": "json", - "summary": "写入或更新指定自定义世界 profile。", - "domainModuleIds": [ - "custom-world", - "runtime" - ], - "sourceHint": "/runtime/custom-world-library/:profileId", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.customWorldLibraryPublish", - "group": "runtime-custom-world-library", - "method": "POST", - "path": "/api/runtime/custom-world-library/:profileId/publish", - "operation": "runtime.customWorldLibrary.publish", - "access": "JWT", - "responseMode": "json", - "summary": "发布指定世界到公开画廊。", - "domainModuleIds": [ - "custom-world", - "runtime" - ], - "sourceHint": "/runtime/custom-world-library/:profileId/publish", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.customWorldLibraryUnpublish", - "group": "runtime-custom-world-library", - "method": "POST", - "path": "/api/runtime/custom-world-library/:profileId/unpublish", - "operation": "runtime.customWorldLibrary.unpublish", - "access": "JWT", - "responseMode": "json", - "summary": "撤回指定世界的公开发布状态。", - "domainModuleIds": [ - "custom-world", - "runtime" - ], - "sourceHint": "/runtime/custom-world-library/:profileId/unpublish", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.customWorldAgentCreateSession", - "group": "runtime-custom-world-agent", - "method": "POST", - "path": "/api/runtime/custom-world/agent/sessions", - "operation": "runtime.customWorldAgent.createSession", - "access": "JWT", - "responseMode": "json", - "summary": "创建 Custom World Agent 会话。", - "domainModuleIds": [ - "custom-world", - "ai" - ], - "sourceHint": "/sessions", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/customWorldAgent.ts" - }, - { - "id": "runtime.customWorldAgentGetSession", - "group": "runtime-custom-world-agent", - "method": "GET", - "path": "/api/runtime/custom-world/agent/sessions/:sessionId", - "operation": "runtime.customWorldAgent.getSession", - "access": "JWT", - "responseMode": "json", - "summary": "读取 Agent 会话快照。", - "domainModuleIds": [ - "custom-world", - "ai" - ], - "sourceHint": "/sessions/:sessionId", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/customWorldAgent.ts" - }, - { - "id": "runtime.customWorldAgentExecuteAction", - "group": "runtime-custom-world-agent", - "method": "POST", - "path": "/api/runtime/custom-world/agent/sessions/:sessionId/actions", - "operation": "runtime.customWorldAgent.executeAction", - "access": "JWT", - "responseMode": "json", - "summary": "执行 Agent 卡片生成、资产同步或发布动作。", - "domainModuleIds": [ - "custom-world", - "ai", - "assets" - ], - "sourceHint": "/sessions/:sessionId/actions", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/customWorldAgent.ts" - }, - { - "id": "runtime.customWorldAgentGetCardDetail", - "group": "runtime-custom-world-agent", - "method": "GET", - "path": "/api/runtime/custom-world/agent/sessions/:sessionId/cards/:cardId", - "operation": "runtime.customWorldAgent.getCardDetail", - "access": "JWT", - "responseMode": "json", - "summary": "读取 Agent 卡片详情。", - "domainModuleIds": [ - "custom-world", - "ai" - ], - "sourceHint": "/sessions/:sessionId/cards/:cardId", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/customWorldAgent.ts" - }, - { - "id": "runtime.customWorldAgentSendMessage", - "group": "runtime-custom-world-agent", - "method": "POST", - "path": "/api/runtime/custom-world/agent/sessions/:sessionId/messages", - "operation": "runtime.customWorldAgent.sendMessage", - "access": "JWT", - "responseMode": "json", - "summary": "向 Agent 会话提交一条创作消息。", - "domainModuleIds": [ - "custom-world", - "ai" - ], - "sourceHint": "/sessions/:sessionId/messages", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/customWorldAgent.ts" - }, - { - "id": "runtime.customWorldAgentStreamMessage", - "group": "runtime-custom-world-agent", - "method": "POST", - "path": "/api/runtime/custom-world/agent/sessions/:sessionId/messages/stream", - "operation": "runtime.customWorldAgent.streamMessage", - "access": "JWT", - "responseMode": "stream", - "summary": "流式提交 Agent 消息并实时接收回执。", - "domainModuleIds": [ - "custom-world", - "ai" - ], - "sourceHint": "/sessions/:sessionId/messages/stream", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/customWorldAgent.ts" - }, - { - "id": "runtime.customWorldAgentGetOperation", - "group": "runtime-custom-world-agent", - "method": "GET", - "path": "/api/runtime/custom-world/agent/sessions/:sessionId/operations/:operationId", - "operation": "runtime.customWorldAgent.getOperation", - "access": "JWT", - "responseMode": "json", - "summary": "查询 Agent 后台操作状态。", - "domainModuleIds": [ - "custom-world", - "ai" - ], - "sourceHint": "/sessions/:sessionId/operations/:operationId", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/customWorldAgent.ts" - }, - { - "id": "runtime.customWorldEntity.compat", - "group": "runtime-custom-world-assets", - "method": "POST", - "path": "/api/runtime/custom-world/entity", - "operation": "runtime.customWorld.entity.compat", - "access": "JWT", - "responseMode": "json", - "summary": "按世界 profile 生成单个角色或地标实体(兼容路径)。", - "domainModuleIds": [ - "custom-world", - "ai" - ], - "sourceHint": "/runtime/custom-world/entity", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.customWorldSceneNpc.compat", - "group": "runtime-custom-world-assets", - "method": "POST", - "path": "/api/runtime/custom-world/scene-npc", - "operation": "runtime.customWorld.sceneNpc.compat", - "access": "JWT", - "responseMode": "json", - "summary": "按地标生成场景 NPC(兼容路径)。", - "domainModuleIds": [ - "custom-world", - "ai", - "npc" - ], - "sourceHint": "/runtime/custom-world/scene-npc", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.customWorldSessionCreate", - "group": "runtime-custom-world-session", - "method": "POST", - "path": "/api/runtime/custom-world/sessions", - "operation": "runtime.customWorldSession.create", - "access": "JWT", - "responseMode": "json", - "summary": "创建传统自定义世界问答会话。", - "domainModuleIds": [ - "custom-world" - ], - "sourceHint": "/runtime/custom-world/sessions", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.customWorldSessionGet", - "group": "runtime-custom-world-session", - "method": "GET", - "path": "/api/runtime/custom-world/sessions/:sessionId", - "operation": "runtime.customWorldSession.get", - "access": "JWT", - "responseMode": "json", - "summary": "读取传统自定义世界问答会话。", - "domainModuleIds": [ - "custom-world" - ], - "sourceHint": "/runtime/custom-world/sessions/:sessionId", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.customWorldSessionAnswer", - "group": "runtime-custom-world-session", - "method": "POST", - "path": "/api/runtime/custom-world/sessions/:sessionId/answers", - "operation": "runtime.customWorldSession.answer", - "access": "JWT", - "responseMode": "json", - "summary": "回答传统自定义世界问答题目。", - "domainModuleIds": [ - "custom-world" - ], - "sourceHint": "/runtime/custom-world/sessions/:sessionId/answers", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.customWorldSessionGenerateStream", - "group": "runtime-custom-world-session", - "method": "GET", - "path": "/api/runtime/custom-world/sessions/:sessionId/generate/stream", - "operation": "runtime.customWorldSession.generateStream", - "access": "JWT", - "responseMode": "stream", - "summary": "流式编译传统自定义世界 profile。", - "domainModuleIds": [ - "custom-world", - "ai" - ], - "sourceHint": "/runtime/custom-world/sessions/:sessionId/generate/stream", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.customWorldWorksList", - "group": "runtime-custom-world-library", - "method": "GET", - "path": "/api/runtime/custom-world/works", - "operation": "runtime.customWorldWorks.list", - "access": "JWT", - "responseMode": "json", - "summary": "列出当前账号的自定义世界作品汇总。", - "domainModuleIds": [ - "custom-world", - "runtime" - ], - "sourceHint": "/runtime/custom-world/works", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.itemsIntent", - "group": "runtime-loot", - "method": "POST", - "path": "/api/runtime/items/runtime-intent", - "operation": "runtime.items.intent", - "access": "JWT", - "responseMode": "json", - "summary": "生成运行时物品意图。", - "domainModuleIds": [ - "runtime-item", - "ai" - ], - "sourceHint": "/runtime/items/runtime-intent", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.profileBrowseHistoryDelete.compat", - "group": "runtime-profile", - "method": "DELETE", - "path": "/api/runtime/profile/browse-history", - "operation": "profile.browseHistory.clear.compat", - "access": "JWT", - "responseMode": "json", - "summary": "清空平台浏览历史。(兼容路径)", - "domainModuleIds": [ - "runtime" - ], - "sourceHint": "routeCompatPaths('/profile/browse-history')", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.profileBrowseHistoryGet.compat", - "group": "runtime-profile", - "method": "GET", - "path": "/api/runtime/profile/browse-history", - "operation": "profile.browseHistory.list.compat", - "access": "JWT", - "responseMode": "json", - "summary": "读取平台浏览历史。(兼容路径)", - "domainModuleIds": [ - "runtime" - ], - "sourceHint": "routeCompatPaths('/profile/browse-history')", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.profileBrowseHistoryPost.compat", - "group": "runtime-profile", - "method": "POST", - "path": "/api/runtime/profile/browse-history", - "operation": "profile.browseHistory.upsert.compat", - "access": "JWT", - "responseMode": "json", - "summary": "写入或批量同步平台浏览历史。(兼容路径)", - "domainModuleIds": [ - "runtime" - ], - "sourceHint": "routeCompatPaths('/profile/browse-history')", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.profileDashboard.compat", - "group": "runtime-profile", - "method": "GET", - "path": "/api/runtime/profile/dashboard", - "operation": "profile.dashboard.get.compat", - "access": "JWT", - "responseMode": "json", - "summary": "读取运行时个人主页汇总。(兼容路径)", - "domainModuleIds": [ - "runtime" - ], - "sourceHint": "routeCompatPaths('/profile/dashboard')", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.profilePlayStats.compat", - "group": "runtime-profile", - "method": "GET", - "path": "/api/runtime/profile/play-stats", - "operation": "profile.playStats.get.compat", - "access": "JWT", - "responseMode": "json", - "summary": "读取个人游玩统计。(兼容路径)", - "domainModuleIds": [ - "runtime" - ], - "sourceHint": "routeCompatPaths('/profile/play-stats')", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.profileSaveArchivesList.compat", - "group": "runtime-save", - "method": "GET", - "path": "/api/runtime/profile/save-archives", - "operation": "profile.saveArchives.list.compat", - "access": "JWT", - "responseMode": "json", - "summary": "列出个人存档摘要。(兼容路径)", - "domainModuleIds": [ - "runtime" - ], - "sourceHint": "routeCompatPaths('/profile/save-archives')", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.profileSaveArchivesResume.compat", - "group": "runtime-save", - "method": "POST", - "path": "/api/runtime/profile/save-archives/:worldKey", - "operation": "profile.saveArchives.resume.compat", - "access": "JWT", - "responseMode": "json", - "summary": "恢复指定世界的最近存档(兼容路径)。", - "domainModuleIds": [ - "runtime" - ], - "sourceHint": "'/runtime/profile/save-archives/:worldKey'", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.profileWalletLedger.compat", - "group": "runtime-profile", - "method": "GET", - "path": "/api/runtime/profile/wallet-ledger", - "operation": "profile.walletLedger.list.compat", - "access": "JWT", - "responseMode": "json", - "summary": "列出个人资产流水。(兼容路径)", - "domainModuleIds": [ - "runtime" - ], - "sourceHint": "routeCompatPaths('/profile/wallet-ledger')", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.questsGenerate", - "group": "runtime-quest", - "method": "POST", - "path": "/api/runtime/quests/generate", - "operation": "runtime.quests.generate", - "access": "JWT", - "responseMode": "json", - "summary": "按当前遭遇生成任务候选。", - "domainModuleIds": [ - "quest", - "ai" - ], - "sourceHint": "/runtime/quests/generate", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.snapshotDelete", - "group": "runtime-save", - "method": "DELETE", - "path": "/api/runtime/save/snapshot", - "operation": "runtime.snapshot.delete", - "access": "JWT", - "responseMode": "json", - "summary": "删除当前用户的运行时存档。", - "domainModuleIds": [ - "runtime" - ], - "sourceHint": "/runtime/save/snapshot", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.snapshotGet", - "group": "runtime-save", - "method": "GET", - "path": "/api/runtime/save/snapshot", - "operation": "runtime.snapshot.get", - "access": "JWT", - "responseMode": "json", - "summary": "读取当前用户的运行时存档。", - "domainModuleIds": [ - "runtime", - "progression", - "quest" - ], - "sourceHint": "/runtime/save/snapshot", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.snapshotPut", - "group": "runtime-save", - "method": "PUT", - "path": "/api/runtime/save/snapshot", - "operation": "runtime.snapshot.put", - "access": "JWT", - "responseMode": "json", - "summary": "保存并归一化当前运行时存档。", - "domainModuleIds": [ - "runtime", - "progression", - "quest" - ], - "sourceHint": "/runtime/save/snapshot", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.settingsGet", - "group": "runtime-settings", - "method": "GET", - "path": "/api/runtime/settings", - "operation": "runtime.settings.get", - "access": "JWT", - "responseMode": "json", - "summary": "读取运行时设置。", - "domainModuleIds": [ - "runtime" - ], - "sourceHint": "/runtime/settings", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.settingsPut", - "group": "runtime-settings", - "method": "PUT", - "path": "/api/runtime/settings", - "operation": "runtime.settings.put", - "access": "JWT", - "responseMode": "json", - "summary": "更新运行时设置。", - "domainModuleIds": [ - "runtime" - ], - "sourceHint": "/runtime/settings", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "storyAction.resolve", - "group": "story-action", - "method": "POST", - "path": "/api/runtime/story/actions/resolve", - "operation": "runtime.story.actions.resolve", - "access": "JWT", - "responseMode": "json", - "summary": "解析前端 story choice 动作为新的运行时结果。", - "domainModuleIds": [ - "story", - "quest", - "inventory", - "runtime-item", - "npc", - "progression", - "combat", - "runtime" - ], - "sourceHint": "/actions/resolve", - "surfaceId": "runtime-story-action", - "sourceFile": "server-node/src/modules/story/storyActionRoutes.ts" - }, - { - "id": "runtime.storyContinue", - "group": "runtime-story-generation", - "method": "POST", - "path": "/api/runtime/story/continue", - "operation": "runtime.story.continue", - "access": "JWT", - "responseMode": "json", - "summary": "生成下一段故事内容。", - "domainModuleIds": [ - "story", - "ai" - ], - "sourceHint": "/runtime/story/continue", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "runtime.storyInitial", - "group": "runtime-story-generation", - "method": "POST", - "path": "/api/runtime/story/initial", - "operation": "runtime.story.initial", - "access": "JWT", - "responseMode": "json", - "summary": "生成首段故事内容。", - "domainModuleIds": [ - "story", - "ai" - ], - "sourceHint": "/runtime/story/initial", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "storyAction.stateGet", - "group": "story-action", - "method": "GET", - "path": "/api/runtime/story/state/:sessionId", - "operation": "runtime.story.state.get", - "access": "JWT", - "responseMode": "json", - "summary": "读取指定 story session 的运行时状态。", - "domainModuleIds": [ - "story", - "runtime" - ], - "sourceHint": "/state/:sessionId", - "surfaceId": "runtime-story-action", - "sourceFile": "server-node/src/modules/story/storyActionRoutes.ts" - }, - { - "id": "runtime.wsHealth", - "group": "runtime-diagnostics", - "method": "GET", - "path": "/api/ws/health", - "operation": "runtime.ws.health", - "access": "JWT", - "responseMode": "json", - "summary": "保留给未来实时链路的占位健康检查。", - "domainModuleIds": [ - "runtime" - ], - "sourceHint": "/ws/health", - "surfaceId": "runtime-main", - "sourceFile": "server-node/src/routes/runtimeRoutes.ts" - }, - { - "id": "health.check", - "surfaceId": "health", - "group": "health", - "method": "GET", - "path": "/healthz", - "operation": "health.check", - "access": "公开", - "responseMode": "json", - "summary": "返回 Node 后端进程健康状态。", - "domainModuleIds": [], - "sourceFile": "server-node/src/app.ts", - "sourceHint": "/healthz" - } - ], - "maintenanceRules": [ - "新增 `server-node/src/modules/*` 目录时,必须先补充 manifest 里的模块说明,再重新生成产物。", - "新增或下线路由时,先更新 manifest 里的路由清单,再运行生成命令同步 JSON 与文档。", - "如果路由来自兼容路径或中间件派生路径,`sourceHint` 需要指向源代码里的真实表达式,确保生成脚本能做最小校验。" - ] -} diff --git a/server-node/package-lock.json b/server-node/package-lock.json deleted file mode 100644 index a190a72c..00000000 --- a/server-node/package-lock.json +++ /dev/null @@ -1,3860 +0,0 @@ -{ - "name": "genarrative-server-node", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "genarrative-server-node", - "version": "0.1.0", - "dependencies": { - "@alicloud/dypnsapi20170525": "^2.0.0", - "@alicloud/openapi-client": "^0.4.15", - "@alicloud/tea-util": "^1.4.11", - "@node-rs/argon2": "^2.0.2", - "cors": "^2.8.5", - "express": "^4.21.2", - "jose": "^6.1.0", - "pg": "^8.16.3", - "pino": "^9.9.5", - "pino-http": "^10.5.0", - "pino-roll": "^3.1.0", - "pngjs": "^7.0.0", - "sharp": "^0.34.5", - "zod": "^4.1.8" - }, - "devDependencies": { - "@types/cors": "^2.8.18", - "@types/express": "^5.0.3", - "@types/node": "^24.6.0", - "@types/pg": "^8.20.0", - "esbuild": "^0.28.0", - "pg-mem": "^3.0.14", - "tsx": "^4.21.0", - "typescript": "^5.9.3" - } - }, - "node_modules/@alicloud/credentials": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/@alicloud/credentials/-/credentials-2.4.4.tgz", - "integrity": "sha512-/eRAGSKcniLIFQ1UCpDhB/IrHUZisQ1sc65ws/c2avxUMpXwH1rWAohb76SVAUJhiF4mwvLzLJM1Mn1XL4Xe/Q==", - "dependencies": { - "@alicloud/tea-typescript": "^1.8.0", - "httpx": "^2.3.3", - "ini": "^1.3.5", - "kitx": "^2.0.0" - } - }, - "node_modules/@alicloud/darabonba-array": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@alicloud/darabonba-array/-/darabonba-array-0.1.2.tgz", - "integrity": "sha512-ZPuQ+bJyjrd8XVVm55kl+ypk7OQoi1ZH/DiToaAEQaGvgEjrTcvQkg71//vUX/6cvbLIF5piQDvhrLb+lUEIPQ==", - "dependencies": { - "@alicloud/tea-typescript": "^1.7.1" - } - }, - "node_modules/@alicloud/darabonba-encode-util": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/@alicloud/darabonba-encode-util/-/darabonba-encode-util-0.0.2.tgz", - "integrity": "sha512-mlsNctkeqmR0RtgE1Rngyeadi5snLOAHBCWEtYf68d7tyKskosXDTNeZ6VCD/UfrUu4N51ItO8zlpfXiOgeg3A==", - "dependencies": { - "moment": "^2.29.1" - } - }, - "node_modules/@alicloud/darabonba-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/@alicloud/darabonba-map/-/darabonba-map-0.0.1.tgz", - "integrity": "sha512-2ep+G3YDvuI+dRYVlmER1LVUQDhf9kEItmVB/bbEu1pgKzelcocCwAc79XZQjTcQGFgjDycf3vH87WLDGLFMlw==", - "dependencies": { - "@alicloud/tea-typescript": "^1.7.1" - } - }, - "node_modules/@alicloud/darabonba-signature-util": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/@alicloud/darabonba-signature-util/-/darabonba-signature-util-0.0.4.tgz", - "integrity": "sha512-I1TtwtAnzLamgqnAaOkN0IGjwkiti//0a7/auyVThdqiC/3kyafSAn6znysWOmzub4mrzac2WiqblZKFcN5NWg==", - "dependencies": { - "@alicloud/darabonba-encode-util": "^0.0.1" - } - }, - "node_modules/@alicloud/darabonba-signature-util/node_modules/@alicloud/darabonba-encode-util": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/@alicloud/darabonba-encode-util/-/darabonba-encode-util-0.0.1.tgz", - "integrity": "sha512-Sl5vCRVAYMqwmvXpJLM9hYoCHOMsQlGxaWSGhGWulpKk/NaUBArtoO1B0yHruJf1C5uHhEJIaylYcM48icFHgw==", - "dependencies": { - "@alicloud/tea-typescript": "^1.7.1", - "moment": "^2.29.1" - } - }, - "node_modules/@alicloud/darabonba-string": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@alicloud/darabonba-string/-/darabonba-string-1.0.3.tgz", - "integrity": "sha512-NyWwrU8cAIesWk3uHL1Q7pTDTqLkCI/0PmJXC4/4A0MFNAZ9Ouq0iFBsRqvfyUujSSM+WhYLuTfakQXiVLkTMA==", - "dependencies": { - "@alicloud/tea-typescript": "^1.5.1" - } - }, - "node_modules/@alicloud/dypnsapi20170525": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@alicloud/dypnsapi20170525/-/dypnsapi20170525-2.0.0.tgz", - "integrity": "sha512-eVh1dJ2HA82bBHt+YZFIBzPEYW80FK+TSpcxSR9o0W+FgfTqBaj6eeIHnN7NFhyDAD/3+HtZ146Pmvr51JEEAg==", - "dependencies": { - "@alicloud/openapi-core": "^1.0.0", - "@darabonba/typescript": "^1.0.0" - } - }, - "node_modules/@alicloud/endpoint-util": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/@alicloud/endpoint-util/-/endpoint-util-0.0.1.tgz", - "integrity": "sha512-+pH7/KEXup84cHzIL6UJAaPqETvln4yXlD9JzlrqioyCSaWxbug5FUobsiI6fuUOpw5WwoB3fWAtGbFnJ1K3Yg==", - "dependencies": { - "@alicloud/tea-typescript": "^1.5.1", - "kitx": "^2.0.0" - } - }, - "node_modules/@alicloud/gateway-pop": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@alicloud/gateway-pop/-/gateway-pop-0.0.6.tgz", - "integrity": "sha512-KF4I+JvfYuLKc3fWeWYIZ7lOVJ9jRW0sQXdXidZn1DKZ978ncfGf7i0LBfONGk4OxvNb/HD3/0yYhkgZgPbKtA==", - "dependencies": { - "@alicloud/credentials": "^2", - "@alicloud/darabonba-array": "^0.1.0", - "@alicloud/darabonba-encode-util": "^0.0.2", - "@alicloud/darabonba-map": "^0.0.1", - "@alicloud/darabonba-signature-util": "^0.0.4", - "@alicloud/darabonba-string": "^1.0.2", - "@alicloud/endpoint-util": "^0.0.1", - "@alicloud/gateway-spi": "^0.0.8", - "@alicloud/openapi-util": "^0.3.2", - "@alicloud/tea-typescript": "^1.7.1", - "@alicloud/tea-util": "^1.4.8" - } - }, - "node_modules/@alicloud/gateway-spi": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/@alicloud/gateway-spi/-/gateway-spi-0.0.8.tgz", - "integrity": "sha512-KM7fu5asjxZPmrz9sJGHJeSU+cNQNOxW+SFmgmAIrITui5hXL2LB+KNRuzWmlwPjnuA2X3/keq9h6++S9jcV5g==", - "dependencies": { - "@alicloud/credentials": "^2", - "@alicloud/tea-typescript": "^1.7.1" - } - }, - "node_modules/@alicloud/openapi-client": { - "version": "0.4.15", - "resolved": "https://registry.npmjs.org/@alicloud/openapi-client/-/openapi-client-0.4.15.tgz", - "integrity": "sha512-4VE0/k5ZdQbAhOSTqniVhuX1k5DUeUMZv74degn3wIWjLY6Bq+hxjaGsaHYlLZ2gA5wUrs8NcI5TE+lIQS3iiA==", - "dependencies": { - "@alicloud/credentials": "^2.4.2", - "@alicloud/gateway-spi": "^0.0.8", - "@alicloud/openapi-util": "^0.3.2", - "@alicloud/tea-typescript": "^1.7.1", - "@alicloud/tea-util": "1.4.9", - "@alicloud/tea-xml": "0.0.3" - } - }, - "node_modules/@alicloud/openapi-client/node_modules/@alicloud/tea-util": { - "version": "1.4.9", - "resolved": "https://registry.npmjs.org/@alicloud/tea-util/-/tea-util-1.4.9.tgz", - "integrity": "sha512-S0wz76rGtoPKskQtRTGqeuqBHFj8BqUn0Vh+glXKun2/9UpaaaWmuJwcmtImk6bJZfLYEShDF/kxDmDJoNYiTw==", - "dependencies": { - "@alicloud/tea-typescript": "^1.5.1", - "kitx": "^2.0.0" - } - }, - "node_modules/@alicloud/openapi-core": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@alicloud/openapi-core/-/openapi-core-1.0.7.tgz", - "integrity": "sha512-I80PQVfmlzRiXGHwutMp2zTpiqUVv8ts30nWAfksfHUSTIapk3nj9IXaPbULMPGNV6xqEyshO2bj2a+pmwc2tQ==", - "hasInstallScript": true, - "dependencies": { - "@alicloud/credentials": "^2.4.2", - "@alicloud/gateway-pop": "0.0.6", - "@alicloud/gateway-spi": "^0.0.8", - "@darabonba/typescript": "^1.0.2" - } - }, - "node_modules/@alicloud/openapi-util": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@alicloud/openapi-util/-/openapi-util-0.3.3.tgz", - "integrity": "sha512-vf0cQ/q8R2U7ZO88X5hDiu1yV3t/WexRj+YycWxRutkH/xVXfkmpRgps8lmNEk7Ar+0xnY8+daN2T+2OyB9F4A==", - "dependencies": { - "@alicloud/tea-typescript": "^1.7.1", - "@alicloud/tea-util": "^1.3.0", - "kitx": "^2.1.0", - "sm3": "^1.0.3" - } - }, - "node_modules/@alicloud/tea-typescript": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@alicloud/tea-typescript/-/tea-typescript-1.8.0.tgz", - "integrity": "sha512-CWXWaquauJf0sW30mgJRVu9aaXyBth5uMBCUc+5vKTK1zlgf3hIqRUjJZbjlwHwQ5y9anwcu18r48nOZb7l2QQ==", - "dependencies": { - "@types/node": "^12.0.2", - "httpx": "^2.2.6" - } - }, - "node_modules/@alicloud/tea-typescript/node_modules/@types/node": { - "version": "12.20.55", - "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz", - "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==" - }, - "node_modules/@alicloud/tea-util": { - "version": "1.4.11", - "resolved": "https://registry.npmjs.org/@alicloud/tea-util/-/tea-util-1.4.11.tgz", - "integrity": "sha512-HyPEEQ8F0WoZegiCp7sVdrdm6eBOB+GCvGl4182u69LDFktxfirGLcAx3WExUr1zFWkq2OSmBroTwKQ4w/+Yww==", - "dependencies": { - "@alicloud/tea-typescript": "^1.5.1", - "@darabonba/typescript": "^1.0.0", - "kitx": "^2.0.0" - } - }, - "node_modules/@alicloud/tea-xml": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/@alicloud/tea-xml/-/tea-xml-0.0.3.tgz", - "integrity": "sha512-+/9GliugjrLglsXVrd1D80EqqKgGpyA0eQ6+1ZdUOYCaRguaSwz44trX3PaxPu/HhIPJg9PsGQQ3cSLXWZjbAA==", - "dependencies": { - "@alicloud/tea-typescript": "^1", - "@types/xml2js": "^0.4.5", - "xml2js": "^0.6.0" - } - }, - "node_modules/@darabonba/typescript": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@darabonba/typescript/-/typescript-1.0.4.tgz", - "integrity": "sha512-icl8RGTw4DiWRpco6dVh21RS0IqrH4s/eEV36TZvz/e1+paogSZjaAgox7ByrlEuvG+bo5d8miq/dRlqiUaL/w==", - "dependencies": { - "@alicloud/tea-typescript": "^1.5.1", - "httpx": "^2.3.2", - "lodash": "^4.17.21", - "moment": "^2.30.1", - "moment-timezone": "^0.5.45", - "xml2js": "^0.6.2" - } - }, - "node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", - "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", - "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", - "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", - "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/colour": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", - "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", - "cpu": [ - "arm" - ], - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", - "cpu": [ - "ppc64" - ], - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", - "cpu": [ - "riscv64" - ], - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", - "cpu": [ - "s390x" - ], - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", - "cpu": [ - "arm64" - ], - "libc": [ - "musl" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", - "cpu": [ - "x64" - ], - "libc": [ - "musl" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", - "cpu": [ - "arm" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", - "cpu": [ - "ppc64" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", - "cpu": [ - "riscv64" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", - "cpu": [ - "s390x" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", - "cpu": [ - "arm64" - ], - "libc": [ - "musl" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", - "cpu": [ - "x64" - ], - "libc": [ - "musl" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", - "cpu": [ - "wasm32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.7.0" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", - "cpu": [ - "ia32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", - "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.10.0" - } - }, - "node_modules/@node-rs/argon2": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2/-/argon2-2.0.2.tgz", - "integrity": "sha512-t64wIsPEtNd4aUPuTAyeL2ubxATCBGmeluaKXEMAFk/8w6AJIVVkeLKMBpgLW6LU2t5cQxT+env/c6jxbtTQBg==", - "license": "MIT", - "engines": { - "node": ">= 10" - }, - "optionalDependencies": { - "@node-rs/argon2-android-arm-eabi": "2.0.2", - "@node-rs/argon2-android-arm64": "2.0.2", - "@node-rs/argon2-darwin-arm64": "2.0.2", - "@node-rs/argon2-darwin-x64": "2.0.2", - "@node-rs/argon2-freebsd-x64": "2.0.2", - "@node-rs/argon2-linux-arm-gnueabihf": "2.0.2", - "@node-rs/argon2-linux-arm64-gnu": "2.0.2", - "@node-rs/argon2-linux-arm64-musl": "2.0.2", - "@node-rs/argon2-linux-x64-gnu": "2.0.2", - "@node-rs/argon2-linux-x64-musl": "2.0.2", - "@node-rs/argon2-wasm32-wasi": "2.0.2", - "@node-rs/argon2-win32-arm64-msvc": "2.0.2", - "@node-rs/argon2-win32-ia32-msvc": "2.0.2", - "@node-rs/argon2-win32-x64-msvc": "2.0.2" - } - }, - "node_modules/@node-rs/argon2-android-arm-eabi": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm-eabi/-/argon2-android-arm-eabi-2.0.2.tgz", - "integrity": "sha512-DV/H8p/jt40lrao5z5g6nM9dPNPGEHL+aK6Iy/og+dbL503Uj0AHLqj1Hk9aVUSCNnsDdUEKp4TVMi0YakDYKw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-android-arm64": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm64/-/argon2-android-arm64-2.0.2.tgz", - "integrity": "sha512-1LKwskau+8O1ktKx7TbK7jx1oMOMt4YEXZOdSNIar1TQKxm6isZ0cRXgHLibPHEcNHgYRsJWDE9zvDGBB17QDg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-darwin-arm64": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-arm64/-/argon2-darwin-arm64-2.0.2.tgz", - "integrity": "sha512-3TTNL/7wbcpNju5YcqUrCgXnXUSbD7ogeAKatzBVHsbpjZQbNb1NDxDjqqrWoTt6XL3z9mJUMGwbAk7zQltHtA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-darwin-x64": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-x64/-/argon2-darwin-x64-2.0.2.tgz", - "integrity": "sha512-vNPfkLj5Ij5111UTiYuwgxMqE7DRbOS2y58O2DIySzSHbcnu+nipmRKg+P0doRq6eKIJStyBK8dQi5Ic8pFyDw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-freebsd-x64": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-freebsd-x64/-/argon2-freebsd-x64-2.0.2.tgz", - "integrity": "sha512-M8vQZk01qojQfCqQU0/O1j1a4zPPrz93zc9fSINY7Q/6RhQRBCYwDw7ltDCZXg5JRGlSaeS8cUXWyhPGar3cGg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-linux-arm-gnueabihf": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm-gnueabihf/-/argon2-linux-arm-gnueabihf-2.0.2.tgz", - "integrity": "sha512-7EmmEPHLzcu0G2GDh30L6G48CH38roFC2dqlQJmtRCxs6no3tTE/pvgBGatTp/o2n2oyOJcfmgndVFcUpwMnww==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-linux-arm64-gnu": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-gnu/-/argon2-linux-arm64-gnu-2.0.2.tgz", - "integrity": "sha512-6lsYh3Ftbk+HAIZ7wNuRF4SZDtxtFTfK+HYFAQQyW7Ig3LHqasqwfUKRXVSV5tJ+xTnxjqgKzvZSUJCAyIfHew==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-linux-arm64-musl": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-musl/-/argon2-linux-arm64-musl-2.0.2.tgz", - "integrity": "sha512-p3YqVMNT/4DNR67tIHTYGbedYmXxW9QlFmF39SkXyEbGQwpgSf6pH457/fyXBIYznTU/smnG9EH+C1uzT5j4hA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-linux-x64-gnu": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-gnu/-/argon2-linux-x64-gnu-2.0.2.tgz", - "integrity": "sha512-ZM3jrHuJ0dKOhvA80gKJqBpBRmTJTFSo2+xVZR+phQcbAKRlDMSZMFDiKbSTnctkfwNFtjgDdh5g1vaEV04AvA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-linux-x64-musl": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-musl/-/argon2-linux-x64-musl-2.0.2.tgz", - "integrity": "sha512-of5uPqk7oCRF/44a89YlWTEfjsftPywyTULwuFDKyD8QtVZoonrJR6ZWvfFE/6jBT68S0okAkAzzMEdBVWdxWw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-wasm32-wasi": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-wasm32-wasi/-/argon2-wasm32-wasi-2.0.2.tgz", - "integrity": "sha512-U3PzLYKSQYzTERstgtHLd4ZTkOF9co57zTXT77r0cVUsleGZOrd6ut7rHzeWwoJSiHOVxxa0OhG1JVQeB7lLoQ==", - "cpu": [ - "wasm32" - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^0.2.5" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@node-rs/argon2-win32-arm64-msvc": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-arm64-msvc/-/argon2-win32-arm64-msvc-2.0.2.tgz", - "integrity": "sha512-Eisd7/NM0m23ijrGr6xI2iMocdOuyl6gO27gfMfya4C5BODbUSP7ljKJ7LrA0teqZMdYHesRDzx36Js++/vhiQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-win32-ia32-msvc": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-ia32-msvc/-/argon2-win32-ia32-msvc-2.0.2.tgz", - "integrity": "sha512-GsE2ezwAYwh72f9gIjbGTZOf4HxEksb5M2eCaj+Y5rGYVwAdt7C12Q2e9H5LRYxWcFvLH4m4jiSZpQQ4upnPAQ==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-win32-x64-msvc": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-x64-msvc/-/argon2-win32-x64-msvc-2.0.2.tgz", - "integrity": "sha512-cJxWXanH4Ew9CfuZ4IAEiafpOBCe97bzoKowHCGk5lG/7kR4WF/eknnBlHW9m8q7t10mKq75kruPLtbSDqgRTw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@pinojs/redact": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", - "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", - "license": "MIT" - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@types/body-parser": { - "version": "1.19.6", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", - "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/cors": { - "version": "2.8.19", - "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", - "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/express": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", - "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^5.0.0", - "@types/serve-static": "^2" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", - "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "node_modules/@types/http-errors": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", - "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "24.12.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", - "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", - "license": "MIT", - "dependencies": { - "undici-types": "~7.16.0" - } - }, - "node_modules/@types/pg": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", - "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", - "dev": true, - "dependencies": { - "@types/node": "*", - "pg-protocol": "*", - "pg-types": "^2.2.0" - } - }, - "node_modules/@types/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", - "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/http-errors": "*", - "@types/node": "*" - } - }, - "node_modules/@types/xml2js": { - "version": "0.4.14", - "resolved": "https://registry.npmjs.org/@types/xml2js/-/xml2js-0.4.14.tgz", - "integrity": "sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, - "node_modules/atomic-sleep": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", - "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.14.0", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "dev": true, - "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" - }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/date-fns": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", - "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/kossnocorp" - } - }, - "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/discontinuous-range": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", - "integrity": "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==", - "dev": true - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/esbuild": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", - "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.3", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.14.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/functional-red-black-tree": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", - "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", - "dev": true - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-tsconfig": { - "version": "4.13.7", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", - "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/httpx": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/httpx/-/httpx-2.3.3.tgz", - "integrity": "sha512-k1qv94u1b6e+XKCxVbLgYlOypVP9MPGpnN5G/vxFf6tDO4V3xpz3d6FUOY/s8NtPgaq5RBVVgSB+7IHpVxMYzw==", - "dependencies": { - "@types/node": "^20", - "debug": "^4.1.1" - } - }, - "node_modules/httpx/node_modules/@types/node": { - "version": "20.19.39", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", - "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/httpx/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/httpx/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, - "node_modules/httpx/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/immutable": { - "version": "4.3.8", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.8.tgz", - "integrity": "sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw==", - "dev": true - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true - }, - "node_modules/jose": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", - "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/json-stable-stringify": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", - "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "isarray": "^2.0.5", - "jsonify": "^0.0.1", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/jsonify": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", - "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/kitx": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/kitx/-/kitx-2.2.0.tgz", - "integrity": "sha512-tBMwe6AALTBQJb0woQDD40734NKzb0Kzi3k7wQj9ar3AbP9oqhoVrdXPh7rk2r00/glIgd0YbToIUJsnxWMiIg==", - "dependencies": { - "@types/node": "^22.5.4" - } - }, - "node_modules/kitx/node_modules/@types/node": { - "version": "22.19.17", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", - "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/kitx/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" - }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==" - }, - "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/moment": { - "version": "2.30.1", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", - "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", - "engines": { - "node": "*" - } - }, - "node_modules/moment-timezone": { - "version": "0.5.48", - "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz", - "integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==", - "dependencies": { - "moment": "^2.29.4" - }, - "engines": { - "node": "*" - } - }, - "node_modules/moo": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.3.tgz", - "integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==", - "dev": true - }, - "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/nearley": { - "version": "2.20.1", - "resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz", - "integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==", - "dev": true, - "dependencies": { - "commander": "^2.19.0", - "moo": "^0.5.0", - "railroad-diagrams": "^1.0.0", - "randexp": "0.4.6" - }, - "bin": { - "nearley-railroad": "bin/nearley-railroad.js", - "nearley-test": "bin/nearley-test.js", - "nearley-unparse": "bin/nearley-unparse.js", - "nearleyc": "bin/nearleyc.js" - }, - "funding": { - "type": "individual", - "url": "https://nearley.js.org/#give-to-nearley" - } - }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-hash": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", - "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/on-exit-leak-free": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", - "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-to-regexp": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", - "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", - "license": "MIT" - }, - "node_modules/pg": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", - "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", - "dependencies": { - "pg-connection-string": "^2.12.0", - "pg-pool": "^3.13.0", - "pg-protocol": "^1.13.0", - "pg-types": "2.2.0", - "pgpass": "1.0.5" - }, - "engines": { - "node": ">= 16.0.0" - }, - "optionalDependencies": { - "pg-cloudflare": "^1.3.0" - }, - "peerDependencies": { - "pg-native": ">=3.0.1" - }, - "peerDependenciesMeta": { - "pg-native": { - "optional": true - } - } - }, - "node_modules/pg-cloudflare": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", - "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", - "optional": true - }, - "node_modules/pg-connection-string": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", - "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==" - }, - "node_modules/pg-int8": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", - "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/pg-mem": { - "version": "3.0.14", - "resolved": "https://registry.npmjs.org/pg-mem/-/pg-mem-3.0.14.tgz", - "integrity": "sha512-G9m8OD0A+YS083smidSUJddTX2dEDPT8mRMG3sQGNiGfS/mkvAgd9Kf1/onD5633bFN7HcQK/Tn2x7qjBMFRUQ==", - "dev": true, - "dependencies": { - "functional-red-black-tree": "^1.0.1", - "immutable": "^4.3.4", - "json-stable-stringify": "^1.0.1", - "lru-cache": "^6.0.0", - "moment": "^2.27.0", - "object-hash": "^2.0.3", - "pgsql-ast-parser": "^12.0.2" - }, - "peerDependencies": { - "@mikro-orm/core": ">=4.5.3", - "@mikro-orm/postgresql": ">=4.5.3", - "knex": ">=0.20", - "kysely": ">=0.26", - "pg-promise": ">=10.8.7", - "pg-server": "^0.1.5", - "postgres": "^3.4.4", - "slonik": ">=23.0.1", - "typeorm": ">=0.2.29" - }, - "peerDependenciesMeta": { - "@mikro-orm/core": { - "optional": true - }, - "@mikro-orm/postgresql": { - "optional": true - }, - "knex": { - "optional": true - }, - "kysely": { - "optional": true - }, - "mikro-orm": { - "optional": true - }, - "pg-promise": { - "optional": true - }, - "pg-server": { - "optional": true - }, - "postgres": { - "optional": true - }, - "slonik": { - "optional": true - }, - "typeorm": { - "optional": true - } - } - }, - "node_modules/pg-pool": { - "version": "3.13.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", - "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", - "peerDependencies": { - "pg": ">=8.0" - } - }, - "node_modules/pg-protocol": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", - "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==" - }, - "node_modules/pg-types": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", - "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", - "dependencies": { - "pg-int8": "1.0.1", - "postgres-array": "~2.0.0", - "postgres-bytea": "~1.0.0", - "postgres-date": "~1.0.4", - "postgres-interval": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/pgpass": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", - "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", - "dependencies": { - "split2": "^4.1.0" - } - }, - "node_modules/pgsql-ast-parser": { - "version": "12.0.2", - "resolved": "https://registry.npmjs.org/pgsql-ast-parser/-/pgsql-ast-parser-12.0.2.tgz", - "integrity": "sha512-1WWa96Sw6h4uv9GLw98EzH/+xoBTC8j2TwV/AMW3E+Ir/fHOu/jLLbj6kPiz3y2bGISTKNYvKWwHoqvQ5FLuAw==", - "dev": true, - "dependencies": { - "moo": "^0.5.1", - "nearley": "^2.19.5" - } - }, - "node_modules/pino": { - "version": "9.14.0", - "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", - "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", - "license": "MIT", - "dependencies": { - "@pinojs/redact": "^0.4.0", - "atomic-sleep": "^1.0.0", - "on-exit-leak-free": "^2.1.0", - "pino-abstract-transport": "^2.0.0", - "pino-std-serializers": "^7.0.0", - "process-warning": "^5.0.0", - "quick-format-unescaped": "^4.0.3", - "real-require": "^0.2.0", - "safe-stable-stringify": "^2.3.1", - "sonic-boom": "^4.0.1", - "thread-stream": "^3.0.0" - }, - "bin": { - "pino": "bin.js" - } - }, - "node_modules/pino-abstract-transport": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", - "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", - "license": "MIT", - "dependencies": { - "split2": "^4.0.0" - } - }, - "node_modules/pino-http": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/pino-http/-/pino-http-10.5.0.tgz", - "integrity": "sha512-hD91XjgaKkSsdn8P7LaebrNzhGTdB086W3pyPihX0EzGPjq5uBJBXo4N5guqNaK6mUjg9aubMF7wDViYek9dRA==", - "license": "MIT", - "dependencies": { - "get-caller-file": "^2.0.5", - "pino": "^9.0.0", - "pino-std-serializers": "^7.0.0", - "process-warning": "^5.0.0" - } - }, - "node_modules/pino-roll": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/pino-roll/-/pino-roll-3.1.0.tgz", - "integrity": "sha512-UimuzDe5FJSqzHZjBOQIgXArc6GE8rcJ7XsmhMkTI37msWqeI8yOqNKdPH3qucrvSxdL+y+GksqPTgQlSFWFEQ==", - "license": "MIT", - "dependencies": { - "date-fns": "^4.1.0", - "sonic-boom": "^4.0.1" - } - }, - "node_modules/pino-std-serializers": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", - "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", - "license": "MIT" - }, - "node_modules/pngjs": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", - "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", - "license": "MIT", - "engines": { - "node": ">=14.19.0" - } - }, - "node_modules/postgres-array": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", - "engines": { - "node": ">=4" - } - }, - "node_modules/postgres-bytea": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", - "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-date": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-interval": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", - "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", - "dependencies": { - "xtend": "^4.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/process-warning": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", - "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/quick-format-unescaped": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", - "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", - "license": "MIT" - }, - "node_modules/railroad-diagrams": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", - "integrity": "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==", - "dev": true - }, - "node_modules/randexp": { - "version": "0.4.6", - "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz", - "integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==", - "dev": true, - "dependencies": { - "discontinuous-range": "1.0.0", - "ret": "~0.1.10" - }, - "engines": { - "node": ">=0.12" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/real-require": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", - "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", - "license": "MIT", - "engines": { - "node": ">= 12.13.0" - } - }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, - "node_modules/ret": { - "version": "0.1.15", - "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", - "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", - "dev": true, - "engines": { - "node": ">=0.12" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safe-stable-stringify": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", - "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/sax": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", - "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", - "engines": { - "node": ">=11.0.0" - } - }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", - "license": "MIT", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@img/colour": "^1.0.0", - "detect-libc": "^2.1.2", - "semver": "^7.7.3" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/sm3": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sm3/-/sm3-1.0.3.tgz", - "integrity": "sha512-KyFkIfr8QBlFG3uc3NaljaXdYcsbRy1KrSfc4tsQV8jW68jAktGeOcifu530Vx/5LC+PULHT0Rv8LiI8Gw+c1g==" - }, - "node_modules/sonic-boom": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", - "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", - "license": "MIT", - "dependencies": { - "atomic-sleep": "^1.0.0" - } - }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "license": "ISC", - "engines": { - "node": ">= 10.x" - } - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/thread-stream": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", - "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", - "license": "MIT", - "dependencies": { - "real-require": "^0.2.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "optional": true - }, - "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" - } - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "license": "MIT" - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/xml2js": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", - "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", - "dependencies": { - "sax": ">=0.6.0", - "xmlbuilder": "~11.0.0" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/xmlbuilder": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", - "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "engines": { - "node": ">=0.4" - } - }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, - "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/server-node/package.json b/server-node/package.json deleted file mode 100644 index d6dcdb60..00000000 --- a/server-node/package.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "name": "genarrative-server-node", - "private": true, - "version": "0.1.0", - "type": "module", - "scripts": { - "dev": "tsx watch src/server.ts", - "build": "node build.mjs", - "start": "node dist/server.cjs", - "test": "node test.mjs", - "db:migrate": "tsx src/migrate.ts", - "manifest:backend": "tsx scripts/generateBackendCapabilityArtifacts.ts" - }, - "dependencies": { - "@alicloud/dypnsapi20170525": "^2.0.0", - "@alicloud/openapi-client": "^0.4.15", - "@alicloud/tea-util": "^1.4.11", - "@node-rs/argon2": "^2.0.2", - "cors": "^2.8.5", - "express": "^4.21.2", - "jose": "^6.1.0", - "pg": "^8.16.3", - "pino": "^9.9.5", - "pino-http": "^10.5.0", - "pino-roll": "^3.1.0", - "pngjs": "^7.0.0", - "sharp": "^0.34.5", - "zod": "^4.1.8" - }, - "devDependencies": { - "@types/cors": "^2.8.18", - "@types/express": "^5.0.3", - "@types/node": "^24.6.0", - "@types/pg": "^8.20.0", - "esbuild": "^0.28.0", - "pg-mem": "^3.0.14", - "tsx": "^4.21.0", - "typescript": "^5.9.3" - } -} diff --git a/server-node/scripts/generateBackendCapabilityArtifacts.ts b/server-node/scripts/generateBackendCapabilityArtifacts.ts deleted file mode 100644 index e534567a..00000000 --- a/server-node/scripts/generateBackendCapabilityArtifacts.ts +++ /dev/null @@ -1,372 +0,0 @@ -import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { - BACKEND_CAPABILITY_MANIFEST, - type BackendCapabilityManifest, - type BackendDomainModule, - type BackendRouteCapability, - type BackendRouteSurface, -} from '../src/manifest/backendCapabilityManifest.js'; - -type GeneratedSummary = { - surfaceCount: number; - routeCount: number; - moduleCount: number; - publicRouteCount: number; - jwtRouteCount: number; - envSwitchRouteCount: number; - streamRouteCount: number; -}; - -type GeneratedArtifact = { - generatedAt: string; - manifestVersion: string; - generatedCommand: string; - outputTargets: BackendCapabilityManifest['outputTargets']; - summary: GeneratedSummary; - surfaces: Array< - BackendRouteSurface & { - routeCount: number; - routeIds: string[]; - } - >; - modules: Array< - BackendDomainModule & { - routeCount: number; - routeIds: string[]; - } - >; - routes: BackendRouteCapability[]; - maintenanceRules: string[]; -}; - -const currentFilePath = fileURLToPath(import.meta.url); -const scriptDirectory = path.dirname(currentFilePath); -const repoRoot = path.resolve(scriptDirectory, '..', '..'); - -/** - * 统一把 repo 相对路径转成绝对路径,避免不同工作目录下解析不一致。 - */ -function resolveRepoPath(relativePath: string) { - return path.resolve(repoRoot, relativePath); -} - -function sortById(items: T[]) { - return [...items].sort((left, right) => left.id.localeCompare(right.id, 'zh-Hans-CN')); -} - -function sortRoutes(routes: BackendRouteCapability[]) { - return [...routes].sort((left, right) => { - if (left.path === right.path) { - return left.method.localeCompare(right.method, 'en-US'); - } - return left.path.localeCompare(right.path, 'en-US'); - }); -} - -/** - * 用最小约束校验 manifest 的唯一性与引用完整性,确保生成结果可维护。 - */ -function assertUniqueIds(items: Array<{ id: string }>, label: string) { - const seen = new Set(); - const duplicates: string[] = []; - - items.forEach((item) => { - if (seen.has(item.id)) { - duplicates.push(item.id); - return; - } - seen.add(item.id); - }); - - if (duplicates.length > 0) { - throw new Error(`${label} 存在重复 id:${duplicates.join('、')}`); - } -} - -async function assertSourceFileContains(params: { - sourceFile: string; - sourceHint: string; - routeId: string; -}) { - const absolutePath = resolveRepoPath(params.sourceFile); - const content = await readFile(absolutePath, 'utf8'); - if (!content.includes(params.sourceHint)) { - throw new Error( - `路由 ${params.routeId} 的 sourceHint 未命中源码:${params.sourceFile} -> ${params.sourceHint}`, - ); - } -} - -async function validateModuleCoverage(modules: BackendDomainModule[]) { - const modulesRoot = resolveRepoPath('server-node/src/modules'); - const directoryEntries = await readdir(modulesRoot, { withFileTypes: true }); - const actualDirectories = directoryEntries - .filter((entry) => entry.isDirectory()) - .map((entry) => `server-node/src/modules/${entry.name}`) - .sort((left, right) => left.localeCompare(right, 'en-US')); - const manifestDirectories = modules - .map((moduleItem) => moduleItem.directory) - .sort((left, right) => left.localeCompare(right, 'en-US')); - - const missingInManifest = actualDirectories.filter( - (directory) => !manifestDirectories.includes(directory), - ); - const staleInManifest = manifestDirectories.filter( - (directory) => !actualDirectories.includes(directory), - ); - - if (missingInManifest.length > 0) { - throw new Error( - `以下模块目录尚未进入能力 manifest:${missingInManifest.join('、')}`, - ); - } - - if (staleInManifest.length > 0) { - throw new Error( - `manifest 中存在已失效的模块目录:${staleInManifest.join('、')}`, - ); - } -} - -async function validateManifest(manifest: BackendCapabilityManifest) { - assertUniqueIds(manifest.surfaces, '挂载面'); - assertUniqueIds(manifest.modules, '内部模块'); - assertUniqueIds(manifest.routes, '路由'); - - const surfaceIds = new Set(manifest.surfaces.map((surface) => surface.id)); - const moduleIds = new Set(manifest.modules.map((moduleItem) => moduleItem.id)); - - for (const surface of manifest.surfaces) { - for (const relatedModuleId of surface.relatedModuleIds) { - if (!moduleIds.has(relatedModuleId)) { - throw new Error( - `挂载面 ${surface.id} 引用了未定义的模块:${relatedModuleId}`, - ); - } - } - - for (const mount of surface.mounts) { - const absoluteEntryPath = resolveRepoPath(mount.entryFile); - const content = await readFile(absoluteEntryPath, 'utf8'); - if (!content.includes(mount.routeFactory)) { - throw new Error( - `挂载面 ${surface.id} 的入口文件缺少工厂引用:${mount.entryFile} -> ${mount.routeFactory}`, - ); - } - } - } - - for (const moduleItem of manifest.modules) { - for (const surfaceId of moduleItem.exposedBySurfaceIds) { - if (!surfaceIds.has(surfaceId)) { - throw new Error( - `模块 ${moduleItem.id} 引用了未定义的挂载面:${surfaceId}`, - ); - } - } - - const absoluteDirectory = resolveRepoPath(moduleItem.directory); - const statsEntries = await readdir(absoluteDirectory); - if (statsEntries.length === 0) { - throw new Error(`模块目录为空,无法作为能力边界:${moduleItem.directory}`); - } - } - - for (const route of manifest.routes) { - if (!surfaceIds.has(route.surfaceId)) { - throw new Error(`路由 ${route.id} 引用了未定义的挂载面:${route.surfaceId}`); - } - - for (const moduleId of route.domainModuleIds) { - if (!moduleIds.has(moduleId)) { - throw new Error(`路由 ${route.id} 引用了未定义的模块:${moduleId}`); - } - } - - await assertSourceFileContains({ - sourceFile: route.sourceFile, - sourceHint: route.sourceHint, - routeId: route.id, - }); - } - - await validateModuleCoverage(manifest.modules); -} - -function buildSummary(routes: BackendRouteCapability[]): GeneratedSummary { - return { - surfaceCount: BACKEND_CAPABILITY_MANIFEST.surfaces.length, - routeCount: routes.length, - moduleCount: BACKEND_CAPABILITY_MANIFEST.modules.length, - publicRouteCount: routes.filter((route) => route.access === '公开').length, - jwtRouteCount: routes.filter((route) => route.access === 'JWT').length, - envSwitchRouteCount: routes.filter((route) => route.access.startsWith('开关:')).length, - streamRouteCount: routes.filter((route) => route.responseMode === 'stream').length, - }; -} - -function buildArtifact(manifest: BackendCapabilityManifest): GeneratedArtifact { - const routes = sortRoutes(manifest.routes); - const summary = buildSummary(routes); - - const surfaces = sortById(manifest.surfaces).map((surface) => { - const surfaceRoutes = routes.filter((route) => route.surfaceId === surface.id); - return { - ...surface, - routeCount: surfaceRoutes.length, - routeIds: surfaceRoutes.map((route) => route.id), - }; - }); - - const modules = sortById(manifest.modules).map((moduleItem) => { - const moduleRoutes = routes.filter((route) => - route.domainModuleIds.includes(moduleItem.id), - ); - return { - ...moduleItem, - routeCount: moduleRoutes.length, - routeIds: moduleRoutes.map((route) => route.id), - }; - }); - - return { - generatedAt: new Date().toISOString(), - manifestVersion: manifest.version, - generatedCommand: manifest.generatedCommand, - outputTargets: manifest.outputTargets, - summary, - surfaces, - modules, - routes, - maintenanceRules: manifest.maintenanceRules, - }; -} - -function renderMarkdown(artifact: GeneratedArtifact) { - const lines: string[] = []; - lines.push('# Node 后端模块与接口索引'); - lines.push(''); - lines.push('> 该文档由 `server-node/src/manifest/backendCapabilityManifest.ts` 自动生成。'); - lines.push(`> 生成命令:\`${artifact.generatedCommand}\``); - lines.push(`> 生成时间:\`${artifact.generatedAt}\``); - lines.push(''); - lines.push('## 总览'); - lines.push(''); - lines.push(`- 对外挂载面:${artifact.summary.surfaceCount} 个`); - lines.push(`- 已登记路由:${artifact.summary.routeCount} 条`); - lines.push(`- 内部模块目录:${artifact.summary.moduleCount} 个`); - lines.push(`- 公开接口:${artifact.summary.publicRouteCount} 条`); - lines.push(`- JWT 接口:${artifact.summary.jwtRouteCount} 条`); - lines.push(`- 受环境开关控制的接口:${artifact.summary.envSwitchRouteCount} 条`); - lines.push(`- 流式接口:${artifact.summary.streamRouteCount} 条`); - lines.push(''); - lines.push('## 产物'); - lines.push(''); - lines.push(`- JSON 清单:\`${artifact.outputTargets.json}\``); - lines.push(`- Markdown 索引:\`${artifact.outputTargets.markdown}\``); - lines.push(`- Manifest 源:\`server-node/src/manifest/backendCapabilityManifest.ts\``); - lines.push(''); - lines.push('## 对外挂载面'); - lines.push(''); - - artifact.surfaces.forEach((surface) => { - lines.push(`### ${surface.title}`); - lines.push(''); - lines.push(`- 标识:\`${surface.id}\``); - lines.push(`- 路由数:${surface.routeCount}`); - lines.push(`- 入口:${surface.mounts.map((mount) => `\`${mount.entryFile} -> ${mount.mountPath} -> ${mount.routeFactory}\``).join(';')}`); - lines.push(`- 关联模块:${surface.relatedModuleIds.length > 0 ? surface.relatedModuleIds.map((moduleId) => `\`${moduleId}\``).join('、') : '无'}`); - lines.push('- 责任:'); - surface.responsibilities.forEach((item) => { - lines.push(` - ${item}`); - }); - lines.push('- 主要服务边界:'); - surface.primaryServiceBoundaries.forEach((item) => { - lines.push(` - ${item}`); - }); - lines.push(''); - }); - - lines.push('## 接口索引'); - lines.push(''); - lines.push('| 方法 | 路径 | 访问 | 响应 | 挂载面 | 内部模块 | 说明 |'); - lines.push('| --- | --- | --- | --- | --- | --- | --- |'); - artifact.routes.forEach((route) => { - const moduleLabel = - route.domainModuleIds.length > 0 - ? route.domainModuleIds.map((moduleId) => `\`${moduleId}\``).join('、') - : '无'; - lines.push( - `| ${route.method} | \`${route.path}\` | ${route.access} | ${route.responseMode} | \`${route.surfaceId}\` | ${moduleLabel} | ${route.summary} |`, - ); - }); - lines.push(''); - - lines.push('## 内部模块边界'); - lines.push(''); - artifact.modules.forEach((moduleItem) => { - lines.push(`### ${moduleItem.title}`); - lines.push(''); - lines.push(`- 标识:\`${moduleItem.id}\``); - lines.push(`- 目录:\`${moduleItem.directory}\``); - lines.push(`- 对外可见面:${moduleItem.exposedBySurfaceIds.map((surfaceId) => `\`${surfaceId}\``).join('、')}`); - lines.push(`- 关联路由数:${moduleItem.routeCount}`); - lines.push('- 职责:'); - moduleItem.responsibilities.forEach((item) => { - lines.push(` - ${item}`); - }); - lines.push('- 主要服务边界:'); - moduleItem.primaryServiceBoundaries.forEach((item) => { - lines.push(` - ${item}`); - }); - lines.push('- 关键文件:'); - moduleItem.keyFiles.forEach((filePath) => { - lines.push(` - \`${filePath}\``); - }); - lines.push(''); - }); - - lines.push('## 维护规则'); - lines.push(''); - artifact.maintenanceRules.forEach((rule) => { - lines.push(`- ${rule}`); - }); - lines.push(''); - - return `${lines.join('\n')}`; -} - -async function writeArtifactFile(relativePath: string, content: string) { - const absolutePath = resolveRepoPath(relativePath); - await mkdir(path.dirname(absolutePath), { recursive: true }); - await writeFile(absolutePath, content, 'utf8'); -} - -async function main() { - await validateManifest(BACKEND_CAPABILITY_MANIFEST); - - const artifact = buildArtifact(BACKEND_CAPABILITY_MANIFEST); - const jsonContent = `${JSON.stringify(artifact, null, 2)}\n`; - const markdownContent = renderMarkdown(artifact); - - await writeArtifactFile(artifact.outputTargets.json, jsonContent); - await writeArtifactFile(artifact.outputTargets.markdown, markdownContent); - - console.log( - [ - `backend capability artifacts generated`, - `json=${artifact.outputTargets.json}`, - `markdown=${artifact.outputTargets.markdown}`, - `routes=${artifact.summary.routeCount}`, - `modules=${artifact.summary.moduleCount}`, - ].join(' | '), - ); -} - -void main().catch((error) => { - console.error(error); - process.exit(1); -}); diff --git a/server-node/sql/schema/00_schema_migrations.sql b/server-node/sql/schema/00_schema_migrations.sql deleted file mode 100644 index 70e5a638..00000000 --- a/server-node/sql/schema/00_schema_migrations.sql +++ /dev/null @@ -1,5 +0,0 @@ -CREATE TABLE IF NOT EXISTS schema_migrations ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - applied_at TEXT NOT NULL -); diff --git a/server-node/sql/schema/01_users.sql b/server-node/sql/schema/01_users.sql deleted file mode 100644 index 71aa0f93..00000000 --- a/server-node/sql/schema/01_users.sql +++ /dev/null @@ -1,16 +0,0 @@ -CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL UNIQUE, - password_hash TEXT NOT NULL, - token_version INTEGER NOT NULL DEFAULT 1, - display_name TEXT NOT NULL, - login_provider TEXT NOT NULL DEFAULT 'password', - account_status TEXT NOT NULL DEFAULT 'active', - phone_number TEXT, - phone_verified_at TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); - -CREATE UNIQUE INDEX IF NOT EXISTS users_phone_number_unique_idx - ON users (phone_number); diff --git a/server-node/sql/schema/02_save_snapshots.sql b/server-node/sql/schema/02_save_snapshots.sql deleted file mode 100644 index 75a4c486..00000000 --- a/server-node/sql/schema/02_save_snapshots.sql +++ /dev/null @@ -1,10 +0,0 @@ -CREATE TABLE IF NOT EXISTS save_snapshots ( - user_id TEXT PRIMARY KEY, - version INTEGER NOT NULL, - saved_at TEXT NOT NULL, - bottom_tab TEXT NOT NULL, - game_state_json JSONB NOT NULL, - current_story_json JSONB, - updated_at TEXT NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -); diff --git a/server-node/sql/schema/03_runtime_settings.sql b/server-node/sql/schema/03_runtime_settings.sql deleted file mode 100644 index 1d5a59e0..00000000 --- a/server-node/sql/schema/03_runtime_settings.sql +++ /dev/null @@ -1,6 +0,0 @@ -CREATE TABLE IF NOT EXISTS runtime_settings ( - user_id TEXT PRIMARY KEY, - music_volume REAL NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -); diff --git a/server-node/sql/schema/04_custom_world_profiles.sql b/server-node/sql/schema/04_custom_world_profiles.sql deleted file mode 100644 index 632db82c..00000000 --- a/server-node/sql/schema/04_custom_world_profiles.sql +++ /dev/null @@ -1,24 +0,0 @@ -CREATE TABLE IF NOT EXISTS custom_world_profiles ( - user_id TEXT NOT NULL, - profile_id TEXT NOT NULL, - payload_json JSONB NOT NULL, - visibility TEXT NOT NULL DEFAULT 'draft', - published_at TEXT, - author_display_name TEXT NOT NULL DEFAULT '玩家', - world_name TEXT NOT NULL DEFAULT '', - subtitle TEXT NOT NULL DEFAULT '', - summary_text TEXT NOT NULL DEFAULT '', - cover_image_src TEXT, - theme_mode TEXT NOT NULL DEFAULT 'mythic', - playable_npc_count INTEGER NOT NULL DEFAULT 0, - landmark_count INTEGER NOT NULL DEFAULT 0, - updated_at TEXT NOT NULL, - PRIMARY KEY (user_id, profile_id), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -); - -CREATE INDEX IF NOT EXISTS custom_world_profiles_user_updated_idx - ON custom_world_profiles (user_id, updated_at DESC); - -CREATE INDEX IF NOT EXISTS custom_world_profiles_published_idx - ON custom_world_profiles (visibility, published_at DESC, updated_at DESC); diff --git a/server-node/sql/schema/05_auth_identities.sql b/server-node/sql/schema/05_auth_identities.sql deleted file mode 100644 index 21854d7b..00000000 --- a/server-node/sql/schema/05_auth_identities.sql +++ /dev/null @@ -1,24 +0,0 @@ -CREATE TABLE IF NOT EXISTS auth_identities ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - provider TEXT NOT NULL, - provider_uid TEXT NOT NULL, - provider_unionid TEXT, - display_name TEXT, - avatar_url TEXT, - is_verified BOOLEAN NOT NULL DEFAULT TRUE, - meta_json JSONB, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -); - -CREATE UNIQUE INDEX IF NOT EXISTS auth_identities_provider_uid_unique_idx - ON auth_identities (provider, provider_uid); - -CREATE UNIQUE INDEX IF NOT EXISTS auth_identities_provider_unionid_unique_idx - ON auth_identities (provider, provider_unionid) - WHERE provider_unionid IS NOT NULL; - -CREATE INDEX IF NOT EXISTS auth_identities_user_idx - ON auth_identities (user_id, provider); diff --git a/server-node/sql/schema/06_user_sessions.sql b/server-node/sql/schema/06_user_sessions.sql deleted file mode 100644 index e980dbe3..00000000 --- a/server-node/sql/schema/06_user_sessions.sql +++ /dev/null @@ -1,17 +0,0 @@ -CREATE TABLE IF NOT EXISTS user_sessions ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - refresh_token_hash TEXT NOT NULL UNIQUE, - client_type TEXT NOT NULL, - user_agent TEXT, - ip TEXT, - expires_at TEXT NOT NULL, - revoked_at TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - last_seen_at TEXT NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -); - -CREATE INDEX IF NOT EXISTS user_sessions_user_idx - ON user_sessions (user_id, expires_at DESC); diff --git a/server-node/sql/schema/07_auth_audit_logs.sql b/server-node/sql/schema/07_auth_audit_logs.sql deleted file mode 100644 index 5e7e9694..00000000 --- a/server-node/sql/schema/07_auth_audit_logs.sql +++ /dev/null @@ -1,14 +0,0 @@ -CREATE TABLE IF NOT EXISTS auth_audit_logs ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - event_type TEXT NOT NULL, - detail TEXT NOT NULL, - ip TEXT, - user_agent TEXT, - meta_json JSONB, - created_at TEXT NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -); - -CREATE INDEX IF NOT EXISTS auth_audit_logs_user_created_idx - ON auth_audit_logs (user_id, created_at DESC); diff --git a/server-node/sql/schema/08_sms_auth_events.sql b/server-node/sql/schema/08_sms_auth_events.sql deleted file mode 100644 index c98af72f..00000000 --- a/server-node/sql/schema/08_sms_auth_events.sql +++ /dev/null @@ -1,29 +0,0 @@ -CREATE TABLE IF NOT EXISTS sms_auth_events ( - id TEXT PRIMARY KEY, - phone_number TEXT NOT NULL, - scene TEXT NOT NULL, - action TEXT NOT NULL, - success BOOLEAN NOT NULL, - ip TEXT, - user_agent TEXT, - provider TEXT, - provider_request_id TEXT, - provider_biz_id TEXT, - provider_out_id TEXT, - delivery_status TEXT NOT NULL DEFAULT 'pending', - delivery_report_raw_json JSONB, - delivery_reported_at TEXT, - created_at TEXT NOT NULL -); - -CREATE INDEX IF NOT EXISTS sms_auth_events_phone_created_idx - ON sms_auth_events (phone_number, created_at DESC); - -CREATE INDEX IF NOT EXISTS sms_auth_events_ip_created_idx - ON sms_auth_events (ip, created_at DESC); - -CREATE INDEX IF NOT EXISTS sms_auth_events_provider_biz_id_idx - ON sms_auth_events (provider_biz_id); - -CREATE INDEX IF NOT EXISTS sms_auth_events_provider_out_id_idx - ON sms_auth_events (provider_out_id); diff --git a/server-node/sql/schema/09_auth_risk_blocks.sql b/server-node/sql/schema/09_auth_risk_blocks.sql deleted file mode 100644 index 0cdbc4e2..00000000 --- a/server-node/sql/schema/09_auth_risk_blocks.sql +++ /dev/null @@ -1,13 +0,0 @@ -CREATE TABLE IF NOT EXISTS auth_risk_blocks ( - id TEXT PRIMARY KEY, - scope_type TEXT NOT NULL, - scope_key TEXT NOT NULL, - reason TEXT NOT NULL, - expires_at TEXT NOT NULL, - lifted_at TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); - -CREATE INDEX IF NOT EXISTS auth_risk_blocks_scope_idx - ON auth_risk_blocks (scope_type, scope_key, expires_at DESC); diff --git a/server-node/sql/schema/README.md b/server-node/sql/schema/README.md deleted file mode 100644 index 68c6c038..00000000 --- a/server-node/sql/schema/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# Final Schema SQL - -This folder contains the final PostgreSQL table definitions, one table per file. - -Notes: -- These files keep only the final schema shape. -- They do not preserve historical migration steps. -- The current runtime migration logic in `server-node/src/db/migrations.ts` is unchanged. diff --git a/server-node/src/app.test.ts b/server-node/src/app.test.ts deleted file mode 100644 index 4ffff19b..00000000 --- a/server-node/src/app.test.ts +++ /dev/null @@ -1,4631 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import type { AddressInfo } from 'node:net'; -import os from 'node:os'; -import path from 'node:path'; -import test from 'node:test'; - -import express from 'express'; - -import { createApp } from './app.ts'; -import type { AppConfig } from './config.ts'; -import { prepareEventStreamResponse } from './http.ts'; -import { requestIdMiddleware } from './middleware/requestId.ts'; -import { createAppContext } from './server.ts'; -import { CustomWorldAgentOrchestrator } from './services/customWorldAgentOrchestrator.js'; -import { createTestCustomWorldAgentSingleTurnLlmClient } from './services/customWorldAgentTestHelpers.js'; -import { httpRequest, type TestRequestInit } from './testHttp.ts'; - -type TestConfigOverrides = Partial< - Omit< - AppConfig, - 'llm' | 'dashScope' | 'smsAuth' | 'wechatAuth' | 'authSession' - > -> & { - llm?: Partial; - dashScope?: Partial; - smsAuth?: Partial; - wechatAuth?: Partial; - authSession?: Partial; -}; - -type TestAppContext = Awaited>; - -function installTestCustomWorldAgentSingleTurnLlm(context: TestAppContext) { - context.customWorldAgentOrchestrator = new CustomWorldAgentOrchestrator( - context.customWorldAgentSessions, - null, - { - singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), - }, - ); -} - -function createTestConfig( - testName: string, - overrides: TestConfigOverrides = {}, -): AppConfig { - const tempRoot = fs.mkdtempSync( - path.join(os.tmpdir(), `genarrative-server-node-${testName}-`), - ); - - const baseConfig: AppConfig = { - nodeEnv: 'test', - projectRoot: tempRoot, - publicDir: path.join(tempRoot, 'public'), - logsDir: path.join(tempRoot, 'logs'), - dataDir: path.join(tempRoot, 'data'), - rawEnv: {}, - databaseUrl: `pg-mem://genarrative-${testName}`, - serverAddr: ':0', - logLevel: 'silent', - editorApiEnabled: true, - assetsApiEnabled: true, - jwtSecret: 'test-secret', - jwtExpiresIn: '7d', - jwtIssuer: 'genarrative-server-node-test', - llm: { - baseUrl: 'https://example.invalid', - apiKey: '', - model: 'test-model', - }, - dashScope: { - baseUrl: 'https://example.invalid', - apiKey: '', - imageModel: 'test-image-model', - requestTimeoutMs: 1000, - }, - smsAuth: { - enabled: true, - provider: 'mock', - endpoint: 'dypnsapi.aliyuncs.com', - accessKeyId: '', - accessKeySecret: '', - signName: 'Test Sign', - templateCode: '100001', - templateParamKey: 'code', - countryCode: '86', - schemeName: '', - codeLength: 6, - codeType: 1, - validTimeSeconds: 300, - intervalSeconds: 60, - duplicatePolicy: 1, - caseAuthPolicy: 1, - returnVerifyCode: false, - mockVerifyCode: '123456', - maxSendPerPhonePerDay: 20, - maxSendPerIpPerHour: 30, - maxVerifyFailuresPerPhonePerHour: 12, - maxVerifyFailuresPerIpPerHour: 24, - captchaTtlSeconds: 180, - captchaTriggerVerifyFailuresPerPhone: 3, - captchaTriggerVerifyFailuresPerIp: 5, - blockPhoneFailureThreshold: 6, - blockIpFailureThreshold: 10, - blockPhoneDurationMinutes: 30, - blockIpDurationMinutes: 30, - }, - wechatAuth: { - enabled: true, - provider: 'mock', - appId: '', - appSecret: '', - authorizeEndpoint: 'https://open.weixin.qq.com/connect/qrconnect', - accessTokenEndpoint: 'https://api.weixin.qq.com/sns/oauth2/access_token', - userInfoEndpoint: 'https://api.weixin.qq.com/sns/userinfo', - callbackPath: '/api/auth/wechat/callback', - defaultRedirectPath: '/', - mockUserId: 'mock_wechat_user', - mockUnionId: 'mock_wechat_union', - mockDisplayName: '微信旅人', - mockAvatarUrl: '', - }, - authSession: { - accessCookieName: 'genarrative_access_session', - accessCookieTtlSeconds: 7200, - accessCookieSecure: false, - accessCookieSameSite: 'Lax', - accessCookiePath: '/', - refreshCookieName: 'genarrative_refresh_session', - refreshSessionTtlDays: 30, - refreshCookieSecure: false, - refreshCookieSameSite: 'Lax', - refreshCookiePath: '/api/auth', - }, - }; - - return { - ...baseConfig, - ...overrides, - llm: { - ...baseConfig.llm, - ...overrides.llm, - }, - dashScope: { - ...baseConfig.dashScope, - ...overrides.dashScope, - }, - smsAuth: { - ...baseConfig.smsAuth, - ...overrides.smsAuth, - }, - wechatAuth: { - ...baseConfig.wechatAuth, - ...overrides.wechatAuth, - }, - authSession: { - ...baseConfig.authSession, - ...overrides.authSession, - }, - }; -} - -async function withTestServer( - testName: string, - run: (options: { baseUrl: string; context: TestAppContext }) => Promise, - overrides: TestConfigOverrides = {}, -) { - const context = await createAppContext(createTestConfig(testName, overrides)); - const app = createApp(context); - const server = await new Promise((resolve) => { - const nextServer = app.listen(0, '127.0.0.1', () => resolve(nextServer)); - }); - - try { - const address = server.address() as AddressInfo; - return await run({ - baseUrl: `http://127.0.0.1:${address.port}`, - context, - }); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => { - if (error) { - reject(error); - return; - } - resolve(); - }); - }); - await context.db.close(); - } -} - -async function authEntry(baseUrl: string, username: string, password: string) { - const response = await httpRequest(`${baseUrl}/api/auth/entry`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - username, - password, - }), - }); - const payload = (await response.json()) as { - token: string; - user: { - id: string; - username: string; - }; - }; - const refreshCookie = buildCookieHeader( - response.headers.get('set-cookie'), - 'genarrative_refresh_session', - ); - - assert.equal(response.status, 200); - assert.ok(payload.token); - return { - ...payload, - refreshCookie, - }; -} - -async function sendPhoneCode( - baseUrl: string, - phone: string, - scene: 'login' | 'bind_phone' | 'change_phone' = 'login', -) { - const response = await httpRequest(`${baseUrl}/api/auth/phone/send-code`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - phone, - scene, - }), - }); - const payload = (await response.json()) as { - ok: true; - cooldownSeconds: number; - expiresInSeconds: number; - providerRequestId: string | null; - }; - - assert.equal(response.status, 200); - assert.equal(payload.ok, true); - return payload; -} - -async function phoneLogin(baseUrl: string, phone: string, code = '123456') { - const response = await httpRequest(`${baseUrl}/api/auth/phone/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - phone, - code, - }), - }); - const payload = (await response.json()) as { - token: string; - user: { - id: string; - username: string; - displayName: string; - phoneNumberMasked: string | null; - loginMethod: 'phone' | 'password' | 'wechat'; - bindingStatus: 'active' | 'pending_bind_phone'; - wechatBound: boolean; - }; - }; - const refreshCookie = buildCookieHeader( - response.headers.get('set-cookie'), - 'genarrative_refresh_session', - ); - - assert.equal(response.status, 200); - assert.ok(payload.token); - return { - ...payload, - refreshCookie, - }; -} - -async function waitForCustomWorldAgentOperation(params: { - baseUrl: string; - token: string; - sessionId: string; - operationId: string; - expectedStatus: 'completed' | 'failed'; -}) { - let operationText = ''; - - for (let attempt = 0; attempt < 24; attempt += 1) { - const operationResponse = await httpRequest( - `${params.baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(params.sessionId)}/operations/${encodeURIComponent(params.operationId)}`, - { - headers: { - Authorization: `Bearer ${params.token}`, - }, - }, - ); - assert.equal(operationResponse.status, 200); - operationText = await operationResponse.text(); - - if ( - new RegExp(`"status":"${params.expectedStatus}"`, 'u').test(operationText) - ) { - return operationText; - } - - await new Promise((resolve) => setTimeout(resolve, 25)); - } - - throw new Error(`operation did not reach ${params.expectedStatus}`); -} - -async function createReadyCustomWorldAgentSession(params: { - baseUrl: string; - token: string; -}) { - const createResponse = await httpRequest( - `${params.baseUrl}/api/runtime/custom-world/agent/sessions`, - withBearer(params.token, { - method: 'POST', - body: JSON.stringify({ - seedText: '一个被潮雾切开的列岛世界。', - }), - }), - ); - const created = (await createResponse.json()) as { - session: { - sessionId: string; - }; - }; - - assert.equal(createResponse.status, 200); - - const messagePayloads = [ - { - clientMessageId: 'phase3-app-1', - text: '玩家是被迫返乡的失职守灯人,开局时正站在即将熄灭的旧灯塔上。', - }, - { - clientMessageId: 'phase3-app-2', - text: '整体主题是海岛悬疑,气质冷峻克制。核心冲突是守灯会与沉船商盟争夺航道解释权。关键人物叫沈砺,是玩家的旧友兼宿敌,其实暗地里在为沉船商盟引路。标志性元素是潮雾钟声、盐火灯塔。', - }, - ]; - - for (const payload of messagePayloads) { - const messageResponse = await httpRequest( - `${params.baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}/messages`, - withBearer(params.token, { - method: 'POST', - body: JSON.stringify({ - ...payload, - focusCardId: null, - selectedCardIds: [], - }), - }), - ); - const messageData = (await messageResponse.json()) as { - operation: { - operationId: string; - }; - }; - - assert.equal(messageResponse.status, 200); - await waitForCustomWorldAgentOperation({ - baseUrl: params.baseUrl, - token: params.token, - sessionId: created.session.sessionId, - operationId: messageData.operation.operationId, - expectedStatus: 'completed', - }); - } - - const sessionResponse = await httpRequest( - `${params.baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}`, - { - headers: { - Authorization: `Bearer ${params.token}`, - }, - }, - ); - const session = (await sessionResponse.json()) as { - sessionId: string; - stage: string; - creatorIntentReadiness: { - isReady: boolean; - }; - }; - - assert.equal(sessionResponse.status, 200); - assert.equal(session.stage, 'foundation_review'); - assert.equal(session.creatorIntentReadiness.isReady, true); - - return session; -} - -async function createObjectRefiningCustomWorldAgentSession(params: { - baseUrl: string; - token: string; -}) { - const readySession = await createReadyCustomWorldAgentSession(params); - - const actionResponse = await httpRequest( - `${params.baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(readySession.sessionId)}/actions`, - withBearer(params.token, { - method: 'POST', - body: JSON.stringify({ - action: 'draft_foundation', - }), - }), - ); - const actionPayload = (await actionResponse.json()) as { - operation: { - operationId: string; - status: string; - }; - }; - - assert.equal(actionResponse.status, 200); - assert.equal(actionPayload.operation.status, 'queued'); - - await waitForCustomWorldAgentOperation({ - baseUrl: params.baseUrl, - token: params.token, - sessionId: readySession.sessionId, - operationId: actionPayload.operation.operationId, - expectedStatus: 'completed', - }); - - const sessionResponse = await httpRequest( - `${params.baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(readySession.sessionId)}`, - { - headers: { - Authorization: `Bearer ${params.token}`, - }, - }, - ); - const session = (await sessionResponse.json()) as { - sessionId: string; - stage: string; - draftCards: Array<{ - id: string; - kind: string; - title: string; - summary: string; - }>; - draftProfile: Record | null; - messages: Array<{ kind: string; text: string }>; - }; - - assert.equal(sessionResponse.status, 200); - assert.equal(session.stage, 'object_refining'); - - return session; -} - -async function markAgentSessionPublishReady(params: { - context: TestAppContext; - userId: string; - sessionId: string; -}) { - const snapshot = await params.context.customWorldAgentOrchestrator.getSessionSnapshot( - params.userId, - params.sessionId, - ); - const draftProfile = snapshot?.draftProfile as Record | null; - const playableNpcs = Array.isArray(draftProfile?.playableNpcs) - ? (draftProfile?.playableNpcs as Array>) - : []; - const storyNpcs = Array.isArray(draftProfile?.storyNpcs) - ? (draftProfile?.storyNpcs as Array>) - : []; - const landmarks = Array.isArray(draftProfile?.landmarks) - ? (draftProfile?.landmarks as Array>) - : []; - const sceneChapters = Array.isArray(draftProfile?.sceneChapters) - ? (draftProfile?.sceneChapters as Array>) - : []; - const camp = - draftProfile?.camp && typeof draftProfile.camp === 'object' - ? (draftProfile.camp as Record) - : null; - const firstPlayableRoleId = - typeof playableNpcs[0]?.id === 'string' && playableNpcs[0]?.id.trim() - ? playableNpcs[0].id.trim() - : null; - const firstStoryRoleId = - typeof storyNpcs[0]?.id === 'string' && storyNpcs[0]?.id.trim() - ? storyNpcs[0].id.trim() - : firstPlayableRoleId; - - assert.ok(snapshot); - assert.ok(draftProfile); - assert.ok(playableNpcs.length > 0); - assert.ok(storyNpcs.length > 0); - assert.ok(landmarks.length > 0); - assert.ok(sceneChapters.length > 0); - assert.ok(firstStoryRoleId); - - await params.context.customWorldAgentSessions.replaceDerivedState( - params.userId, - params.sessionId, - { - stage: 'ready_to_publish', - qualityFindings: [], - draftProfile: { - ...draftProfile, - chapters: - Array.isArray(draftProfile.chapters) && draftProfile.chapters.length > 0 - ? draftProfile.chapters - : [{ id: 'chapter-main-1', title: '主线第一章' }], - camp: { - ...(camp ?? {}), - id: - typeof camp?.id === 'string' && camp.id.trim() - ? camp.id.trim() - : 'camp-home', - name: - typeof camp?.name === 'string' && camp.name.trim() - ? camp.name.trim() - : '归潮营地', - description: - typeof camp?.description === 'string' && camp.description.trim() - ? camp.description.trim() - : '可供玩家整理线索的临时据点。', - imageSrc: - typeof camp?.imageSrc === 'string' && camp.imageSrc.trim() - ? camp.imageSrc.trim() - : '/generated/camp/publish-ready.png', - generatedSceneAssetId: - typeof camp?.generatedSceneAssetId === 'string' && - camp.generatedSceneAssetId.trim() - ? camp.generatedSceneAssetId.trim() - : 'scene-camp-publish-ready', - generatedScenePrompt: - typeof camp?.generatedScenePrompt === 'string' && - camp.generatedScenePrompt.trim() - ? camp.generatedScenePrompt.trim() - : '潮雾营地发布正式图', - generatedSceneModel: - typeof camp?.generatedSceneModel === 'string' && - camp.generatedSceneModel.trim() - ? camp.generatedSceneModel.trim() - : 'test-scene-model', - }, - playableNpcs: playableNpcs.map((entry, index) => ({ - ...entry, - imageSrc: - typeof entry.imageSrc === 'string' && entry.imageSrc.trim() - ? entry.imageSrc.trim() - : `/generated/playable/publish-ready-${index + 1}.png`, - generatedVisualAssetId: - typeof entry.generatedVisualAssetId === 'string' && - entry.generatedVisualAssetId.trim() - ? entry.generatedVisualAssetId.trim() - : `visual-playable-publish-${index + 1}`, - generatedAnimationSetId: - typeof entry.generatedAnimationSetId === 'string' && - entry.generatedAnimationSetId.trim() - ? entry.generatedAnimationSetId.trim() - : `anim-playable-publish-${index + 1}`, - })), - storyNpcs: storyNpcs.map((entry, index) => ({ - ...entry, - imageSrc: - typeof entry.imageSrc === 'string' && entry.imageSrc.trim() - ? entry.imageSrc.trim() - : `/generated/story/publish-ready-${index + 1}.png`, - generatedVisualAssetId: - typeof entry.generatedVisualAssetId === 'string' && - entry.generatedVisualAssetId.trim() - ? entry.generatedVisualAssetId.trim() - : `visual-story-publish-${index + 1}`, - generatedAnimationSetId: - typeof entry.generatedAnimationSetId === 'string' && - entry.generatedAnimationSetId.trim() - ? entry.generatedAnimationSetId.trim() - : `anim-story-publish-${index + 1}`, - })), - landmarks: landmarks.map((entry, index) => ({ - ...entry, - imageSrc: - typeof entry.imageSrc === 'string' && entry.imageSrc.trim() - ? entry.imageSrc.trim() - : `/generated/landmark/publish-ready-${index + 1}.png`, - generatedSceneAssetId: - typeof entry.generatedSceneAssetId === 'string' && - entry.generatedSceneAssetId.trim() - ? entry.generatedSceneAssetId.trim() - : `scene-landmark-publish-${index + 1}`, - generatedScenePrompt: - typeof entry.generatedScenePrompt === 'string' && - entry.generatedScenePrompt.trim() - ? entry.generatedScenePrompt.trim() - : `地点 ${typeof entry.name === 'string' ? entry.name : index + 1} 的正式场景图`, - generatedSceneModel: - typeof entry.generatedSceneModel === 'string' && - entry.generatedSceneModel.trim() - ? entry.generatedSceneModel.trim() - : 'test-scene-model', - })), - sceneChapters: sceneChapters.map((chapter, chapterIndex) => { - const acts = Array.isArray(chapter.acts) - ? (chapter.acts as Array>) - : []; - - return { - ...chapter, - linkedThreadIds: - Array.isArray(chapter.linkedThreadIds) && - chapter.linkedThreadIds.length > 0 - ? chapter.linkedThreadIds - : ['thread-publish-ready'], - acts: acts.map((act, actIndex) => ({ - ...act, - encounterNpcIds: - Array.isArray(act.encounterNpcIds) && act.encounterNpcIds.length > 0 - ? act.encounterNpcIds - : [firstStoryRoleId], - primaryNpcId: - typeof act.primaryNpcId === 'string' && act.primaryNpcId.trim() - ? act.primaryNpcId.trim() - : firstStoryRoleId, - backgroundImageSrc: - typeof act.backgroundImageSrc === 'string' && - act.backgroundImageSrc.trim() - ? act.backgroundImageSrc.trim() - : `/generated/scene/publish-ready-${chapterIndex + 1}-${actIndex + 1}.png`, - backgroundAssetId: - typeof act.backgroundAssetId === 'string' && - act.backgroundAssetId.trim() - ? act.backgroundAssetId.trim() - : `scene-act-publish-${chapterIndex + 1}-${actIndex + 1}`, - })), - }; - }), - }, - }, - ); -} - -function parseRedirectHash(location: string) { - const url = new URL(location, 'http://127.0.0.1'); - return new URLSearchParams( - url.hash.startsWith('#') ? url.hash.slice(1) : url.hash, - ); -} - -function readCookieValue(cookieHeader: string, cookieName: string) { - const match = cookieHeader.match( - new RegExp(`${cookieName}=([^;,\r\n]+)`, 'u'), - ); - return match?.[1] ? decodeURIComponent(match[1]) : ''; -} - -function buildCookieHeader(cookieHeader: string | null | undefined, cookieName: string) { - const value = readCookieValue(cookieHeader || '', cookieName); - return value ? `${cookieName}=${encodeURIComponent(value)}` : ''; -} - -async function startWechatMockFlow(baseUrl: string, redirectPath = '/') { - const startResponse = await httpRequest( - `${baseUrl}/api/auth/wechat/start?redirectPath=${encodeURIComponent(redirectPath)}`, - ); - const startPayload = (await startResponse.json()) as { - authorizationUrl: string; - }; - - assert.equal(startResponse.status, 200); - assert.ok(startPayload.authorizationUrl); - - const callbackResponse = await httpRequest(startPayload.authorizationUrl); - assert.equal(callbackResponse.status, 302); - const location = callbackResponse.headers.get('location') || ''; - assert.ok(location); - const hash = parseRedirectHash(location); - const token = hash.get('auth_token')?.trim() || ''; - assert.ok(token); - - return { - location, - hash, - token, - }; -} - -async function withListeningApp( - app: express.Express, - run: (options: { baseUrl: string }) => Promise, -) { - const server = await new Promise((resolve) => { - const nextServer = app.listen(0, '127.0.0.1', () => resolve(nextServer)); - }); - - try { - const address = server.address() as AddressInfo; - return await run({ - baseUrl: `http://127.0.0.1:${address.port}`, - }); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => { - if (error) { - reject(error); - return; - } - resolve(); - }); - }); - } -} - -function withBearer(token: string, init: TestRequestInit = {}) { - return { - ...init, - headers: { - ...(init.headers ?? {}), - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - } satisfies TestRequestInit; -} - -test('legacy json responses remain compatible and include response metadata headers', async () => { - await withTestServer('legacy-http', async ({ baseUrl }) => { - const requestId = 'req-legacy-http'; - const response = await httpRequest(`${baseUrl}/api/auth/entry`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Request-Id': requestId, - }, - body: JSON.stringify({ - username: 'header_user', - password: 'secret123', - }), - }); - const payload = await response.json<{ - token: string; - user: { - username: string; - }; - }>(); - - assert.equal(response.status, 200); - assert.ok(payload.token); - assert.equal(payload.user.username, 'header_user'); - assert.equal(response.headers.get('x-request-id'), requestId); - assert.equal(response.headers.get('x-api-version'), '2026-04-08'); - assert.equal(response.headers.get('x-route-version'), '2026-04-08'); - assert.ok(Number(response.headers.get('x-response-time-ms')) >= 0); - }); -}); - -test('auth entry auto-registers, me works, logout invalidates old token', async () => { - await withTestServer('auth', async ({ baseUrl }) => { - const entry = await authEntry(baseUrl, 'hero_test', 'secret123'); - assert.ok(entry.refreshCookie); - - const meResponse = await httpRequest(`${baseUrl}/api/auth/me`, { - headers: { - Authorization: `Bearer ${entry.token}`, - }, - }); - const mePayload = (await meResponse.json()) as { - user: { - username: string; - }; - }; - - assert.equal(meResponse.status, 200); - assert.equal(mePayload.user.username, 'hero_test'); - - const logoutResponse = await httpRequest( - `${baseUrl}/api/auth/logout`, - withBearer(entry.token, { method: 'POST' }), - ); - assert.equal(logoutResponse.status, 200); - - const expiredResponse = await httpRequest(`${baseUrl}/api/auth/me`, { - headers: { - Authorization: `Bearer ${entry.token}`, - }, - }); - assert.equal(expiredResponse.status, 401); - }); -}); - -test('auth entry tolerates concurrent creation of the same local account', async () => { - await withTestServer('auth-entry-race', async ({ baseUrl }) => { - const body = JSON.stringify({ - username: 'guest_race_local', - password: 'secret123', - }); - const responses = await Promise.all([ - httpRequest(`${baseUrl}/api/auth/entry`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body, - }), - httpRequest(`${baseUrl}/api/auth/entry`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body, - }), - ]); - const payloads = await Promise.all( - responses.map((response) => - response.json<{ - token: string; - user: { - id: string; - username: string; - }; - }>(), - ), - ); - - assert.deepEqual( - responses.map((response) => response.status), - [200, 200], - ); - assert.ok(payloads[0].token); - assert.ok(payloads[1].token); - assert.equal(payloads[0].user.username, 'guest_race_local'); - assert.equal(payloads[1].user.id, payloads[0].user.id); - }); -}); - -test('login options expose enabled methods without authentication', async () => { - await withTestServer('auth-login-options', async ({ baseUrl }) => { - const response = await httpRequest(`${baseUrl}/api/auth/login-options`); - const payload = (await response.json()) as { - availableLoginMethods: string[]; - }; - - assert.equal(response.status, 200); - assert.deepEqual(payload.availableLoginMethods, ['phone', 'wechat']); - }); -}); - -test('wechat start uses qrconnect for desktop browsers', async () => { - await withTestServer( - 'wechat-start-desktop', - async ({ baseUrl }) => { - const response = await httpRequest( - `${baseUrl}/api/auth/wechat/start?redirectPath=${encodeURIComponent('/')}`, - { - headers: { - 'User-Agent': - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/135.0.0.0 Safari/537.36', - }, - }, - ); - const payload = (await response.json()) as { - authorizationUrl: string; - }; - const authorizationUrl = new URL(payload.authorizationUrl); - - assert.equal(response.status, 200); - assert.equal( - `${authorizationUrl.origin}${authorizationUrl.pathname}`, - 'https://open.weixin.qq.com/connect/qrconnect', - ); - assert.equal(authorizationUrl.searchParams.get('scope'), 'snsapi_login'); - assert.equal(authorizationUrl.hash, '#wechat_redirect'); - }, - { - wechatAuth: { - enabled: true, - provider: 'wechat', - appId: 'wx-test-app-id', - appSecret: 'wx-test-app-secret', - }, - }, - ); -}); - -test('wechat start uses oauth authorize inside wechat browser', async () => { - await withTestServer( - 'wechat-start-in-app', - async ({ baseUrl }) => { - const response = await httpRequest( - `${baseUrl}/api/auth/wechat/start?redirectPath=${encodeURIComponent('/')}`, - { - headers: { - 'User-Agent': - 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 MicroMessenger/8.0.54', - }, - }, - ); - const payload = (await response.json()) as { - authorizationUrl: string; - }; - const authorizationUrl = new URL(payload.authorizationUrl); - - assert.equal(response.status, 200); - assert.equal( - `${authorizationUrl.origin}${authorizationUrl.pathname}`, - 'https://open.weixin.qq.com/connect/oauth2/authorize', - ); - assert.equal( - authorizationUrl.searchParams.get('scope'), - 'snsapi_userinfo', - ); - assert.equal(authorizationUrl.hash, '#wechat_redirect'); - }, - { - wechatAuth: { - enabled: true, - provider: 'wechat', - appId: 'wx-test-app-id', - appSecret: 'wx-test-app-secret', - }, - }, - ); -}); - -test('wechat start rejects unsupported mobile browsers for real provider', async () => { - await withTestServer( - 'wechat-start-mobile-browser', - async ({ baseUrl }) => { - const response = await httpRequest( - `${baseUrl}/api/auth/wechat/start?redirectPath=${encodeURIComponent('/')}`, - { - headers: { - 'User-Agent': - 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Version/18.0 Mobile/15E148 Safari/604.1', - }, - }, - ); - const payload = (await response.json()) as { - error: { - code: string; - message: string; - }; - }; - - assert.equal(response.status, 400); - assert.equal(payload.error.code, 'BAD_REQUEST'); - assert.equal( - payload.error.message, - '当前浏览器请使用手机号登录,或在微信内打开后再使用微信登录', - ); - }, - { - wechatAuth: { - enabled: true, - provider: 'wechat', - appId: 'wx-test-app-id', - appSecret: 'wx-test-app-secret', - }, - }, - ); -}); - -test('phone login sends code, creates a user and returns masked profile info', async () => { - await withTestServer('phone-login', async ({ baseUrl }) => { - const sendResult = await sendPhoneCode(baseUrl, '13800138000'); - assert.equal(sendResult.cooldownSeconds, 60); - assert.equal(sendResult.expiresInSeconds, 300); - - const entry = await phoneLogin(baseUrl, '13800138000'); - assert.equal(entry.user.username, '138****8000'); - assert.equal(entry.user.displayName, '138****8000'); - assert.equal(entry.user.phoneNumberMasked, '138****8000'); - assert.equal(entry.user.loginMethod, 'phone'); - - const meResponse = await httpRequest(`${baseUrl}/api/auth/me`, { - headers: { - Authorization: `Bearer ${entry.token}`, - }, - }); - const mePayload = (await meResponse.json()) as { - user: { - username: string; - phoneNumberMasked: string | null; - loginMethod: string; - }; - }; - - assert.equal(meResponse.status, 200); - assert.equal(mePayload.user.username, '138****8000'); - assert.equal(mePayload.user.phoneNumberMasked, '138****8000'); - assert.equal(mePayload.user.loginMethod, 'phone'); - }); -}); - -test('phone send-code accepts change_phone scene', async () => { - await withTestServer('phone-change-code', async ({ baseUrl }) => { - const sendResult = await sendPhoneCode( - baseUrl, - '13800138001', - 'change_phone', - ); - - assert.equal(sendResult.cooldownSeconds, 60); - assert.equal(sendResult.expiresInSeconds, 300); - }); -}); - -test('phone login reuses the same account for repeated verification', async () => { - await withTestServer('phone-login-reuse', async ({ baseUrl }) => { - await sendPhoneCode(baseUrl, '13900139000'); - const firstEntry = await phoneLogin(baseUrl, '13900139000'); - - await sendPhoneCode(baseUrl, '13900139000'); - const secondEntry = await phoneLogin(baseUrl, '13900139000'); - - assert.equal(firstEntry.user.id, secondEntry.user.id); - }); -}); - -test('mock sms send code records delivered tracking metadata', async () => { - await withTestServer('phone-send-code-mock-tracking', async ({ baseUrl, context }) => { - const sendResult = await sendPhoneCode(baseUrl, '13800138009'); - - assert.equal(sendResult.providerRequestId, 'mock-request-id'); - - const rows = await context.db.query<{ - provider: string | null; - provider_request_id: string | null; - provider_biz_id: string | null; - provider_out_id: string | null; - delivery_status: string; - }>( - `SELECT - provider, - provider_request_id, - provider_biz_id, - provider_out_id, - delivery_status - FROM sms_auth_events - WHERE phone_number = $1 - AND action = 'send_code' - ORDER BY created_at DESC - LIMIT 1`, - ['+8613800138009'], - ); - - assert.equal(rows.rows.length, 1); - assert.equal(rows.rows[0]?.provider, 'mock'); - assert.equal(rows.rows[0]?.provider_request_id, 'mock-request-id'); - assert.equal(rows.rows[0]?.provider_biz_id, null); - assert.equal(rows.rows[0]?.provider_out_id, 'mock-out-id'); - assert.equal(rows.rows[0]?.delivery_status, 'delivered'); - }); -}); - -test('aliyun delivery report updates sms event delivery status', async () => { - await withTestServer( - 'phone-delivery-report-aliyun', - async ({ baseUrl, context }) => { - await context.smsAuthEventRepository.create({ - phoneNumber: '+8613800138007', - scene: 'login', - action: 'send_code', - success: true, - ip: '127.0.0.1', - userAgent: 'test-agent', - provider: 'aliyun', - providerRequestId: 'aliyun-request-id', - providerBizId: 'aliyun-biz-id-report', - providerOutId: 'login_out_id_report', - deliveryStatus: 'pending', - }); - - const response = await httpRequest( - `${baseUrl}/api/auth/phone/delivery-report/aliyun`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams({ - BizId: 'aliyun-biz-id-report', - OutId: 'login_out_id_report', - ReceiveStatus: 'SUCCESS', - }).toString(), - }, - ); - const payload = (await response.json()) as { - ok: true; - matched: boolean; - }; - - assert.equal(response.status, 200); - assert.equal(payload.ok, true); - assert.equal(payload.matched, true); - - const rows = await context.db.query<{ - delivery_status: string; - delivery_report_raw_json: { - BizId?: string; - ReceiveStatus?: string; - } | null; - delivery_reported_at: string | null; - }>( - `SELECT - delivery_status, - delivery_report_raw_json, - delivery_reported_at - FROM sms_auth_events - WHERE provider_biz_id = $1 - ORDER BY created_at DESC - LIMIT 1`, - ['aliyun-biz-id-report'], - ); - - assert.equal(rows.rows.length, 1); - assert.equal(rows.rows[0]?.delivery_status, 'delivered'); - assert.equal( - rows.rows[0]?.delivery_report_raw_json?.BizId, - 'aliyun-biz-id-report', - ); - assert.equal( - rows.rows[0]?.delivery_report_raw_json?.ReceiveStatus, - 'SUCCESS', - ); - assert.ok(rows.rows[0]?.delivery_reported_at); - }, - ); -}); - -test('aliyun-tracked send events can persist pending delivery metadata', async () => { - await withTestServer( - 'phone-send-code-aliyun-tracking', - async ({ context }) => { - await context.smsAuthEventRepository.create({ - phoneNumber: '+8613800138008', - scene: 'login', - action: 'send_code', - success: true, - ip: '127.0.0.1', - userAgent: 'test-agent', - provider: 'aliyun', - providerRequestId: 'aliyun-request-id', - providerBizId: 'aliyun-biz-id', - providerOutId: 'login_out_id_001', - deliveryStatus: 'pending', - }); - - const rows = await context.db.query<{ - provider: string | null; - provider_request_id: string | null; - provider_biz_id: string | null; - provider_out_id: string | null; - delivery_status: string; - }>( - `SELECT - provider, - provider_request_id, - provider_biz_id, - provider_out_id, - delivery_status - FROM sms_auth_events - WHERE phone_number = $1 - AND action = 'send_code' - ORDER BY created_at DESC - LIMIT 1`, - ['+8613800138008'], - ); - - assert.equal(rows.rows.length, 1); - assert.equal(rows.rows[0]?.provider, 'aliyun'); - assert.equal(rows.rows[0]?.provider_request_id, 'aliyun-request-id'); - assert.equal(rows.rows[0]?.provider_biz_id, 'aliyun-biz-id'); - assert.equal(rows.rows[0]?.provider_out_id, 'login_out_id_001'); - assert.equal(rows.rows[0]?.delivery_status, 'pending'); - }, - ); -}); - -test('phone login rejects incorrect verification codes', async () => { - await withTestServer('phone-login-invalid-code', async ({ baseUrl }) => { - await sendPhoneCode(baseUrl, '13700137000'); - - const response = await httpRequest(`${baseUrl}/api/auth/phone/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - phone: '13700137000', - code: '000000', - }), - }); - const payload = (await response.json()) as { - error: { - code: string; - message: string; - }; - }; - - assert.equal(response.status, 401); - assert.equal(payload.error.code, 'UNAUTHORIZED'); - assert.equal(payload.error.message, '验证码错误或已失效'); - }); -}); - -test('captcha challenge is required after repeated verification failures', async () => { - await withTestServer('phone-login-captcha', async ({ baseUrl }) => { - for (let attempt = 0; attempt < 3; attempt += 1) { - const response = await httpRequest(`${baseUrl}/api/auth/phone/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - phone: '13600136000', - code: '000000', - }), - }); - assert.equal(response.status, 401); - } - - const sendCodeResponse = await httpRequest( - `${baseUrl}/api/auth/phone/send-code`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - phone: '13600136000', - scene: 'login', - }), - }, - ); - const sendCodePayload = (await sendCodeResponse.json()) as { - error: { - code: string; - message: string; - details?: { - captchaChallenge?: { - challengeId: string; - imageDataUrl: string; - }; - }; - }; - }; - - assert.equal(sendCodeResponse.status, 403); - assert.equal(sendCodePayload.error.code, 'CAPTCHA_REQUIRED'); - assert.ok(sendCodePayload.error.details?.captchaChallenge?.challengeId); - assert.match( - sendCodePayload.error.details?.captchaChallenge?.imageDataUrl ?? '', - /^data:image\/svg\+xml;base64,/u, - ); - }); -}); - -test('phone number enters temporary protection after repeated failed verifications', async () => { - await withTestServer('phone-risk-block', async ({ baseUrl }) => { - await sendPhoneCode(baseUrl, '13800138000'); - const entry = await phoneLogin(baseUrl, '13800138000'); - - for (let attempt = 0; attempt < 6; attempt += 1) { - const response = await httpRequest(`${baseUrl}/api/auth/phone/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - phone: '13800138000', - code: '000000', - }), - }); - assert.equal(response.status, 401); - } - - const blockedResponse = await httpRequest( - `${baseUrl}/api/auth/phone/send-code`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - phone: '13800138000', - scene: 'login', - }), - }, - ); - const blockedPayload = (await blockedResponse.json()) as { - error: { - code: string; - message: string; - }; - }; - - assert.equal(blockedResponse.status, 429); - assert.equal(blockedPayload.error.code, 'TOO_MANY_REQUESTS'); - - const auditResponse = await httpRequest(`${baseUrl}/api/auth/audit-logs`, { - headers: { - Authorization: `Bearer ${entry.token}`, - Cookie: entry.refreshCookie || '', - }, - }); - const auditPayload = (await auditResponse.json()) as { - logs: Array<{ - eventType: string; - }>; - }; - - assert.ok( - auditPayload.logs.some((log) => log.eventType === 'risk_block_phone'), - ); - }); -}); - -test('ip enters temporary protection after repeated failed verifications across phones', async () => { - await withTestServer('ip-risk-block', async ({ baseUrl }) => { - for (let attempt = 0; attempt < 10; attempt += 1) { - const phone = `13900139${String(attempt).padStart(3, '0')}`; - const response = await httpRequest(`${baseUrl}/api/auth/phone/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - phone, - code: '000000', - }), - }); - assert.equal(response.status, 401); - } - - const blockedResponse = await httpRequest( - `${baseUrl}/api/auth/phone/send-code`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - phone: '13700137000', - scene: 'login', - }), - }, - ); - const blockedPayload = (await blockedResponse.json()) as { - error: { - code: string; - }; - }; - - assert.equal(blockedResponse.status, 429); - assert.equal(blockedPayload.error.code, 'TOO_MANY_REQUESTS'); - }); -}); - -test('risk block endpoint returns active phone protection for the signed-in account', async () => { - await withTestServer('risk-blocks-endpoint', async ({ baseUrl }) => { - await sendPhoneCode(baseUrl, '13800138000'); - const entry = await phoneLogin(baseUrl, '13800138000'); - - for (let attempt = 0; attempt < 6; attempt += 1) { - const response = await httpRequest(`${baseUrl}/api/auth/phone/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - phone: '13800138000', - code: '000000', - }), - }); - assert.equal(response.status, 401); - } - - const blocksResponse = await httpRequest( - `${baseUrl}/api/auth/risk-blocks`, - { - headers: { - Authorization: `Bearer ${entry.token}`, - Cookie: entry.refreshCookie || '', - }, - }, - ); - const blocksPayload = (await blocksResponse.json()) as { - blocks: Array<{ - scopeType: string; - remainingSeconds: number; - }>; - }; - - assert.equal(blocksResponse.status, 200); - assert.ok( - blocksPayload.blocks.some((block) => block.scopeType === 'phone'), - ); - assert.ok((blocksPayload.blocks[0]?.remainingSeconds ?? 0) > 0); - }); -}); - -test('risk block lift endpoint clears current phone protection', async () => { - await withTestServer('risk-block-lift', async ({ baseUrl }) => { - await sendPhoneCode(baseUrl, '13800138000'); - const entry = await phoneLogin(baseUrl, '13800138000'); - - for (let attempt = 0; attempt < 6; attempt += 1) { - const response = await httpRequest(`${baseUrl}/api/auth/phone/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - phone: '13800138000', - code: '000000', - }), - }); - assert.equal(response.status, 401); - } - - const liftResponse = await httpRequest( - `${baseUrl}/api/auth/risk-blocks/phone/lift`, - withBearer(entry.token, { - method: 'POST', - headers: { - Cookie: entry.refreshCookie || '', - }, - }), - ); - const liftPayload = (await liftResponse.json()) as { - ok: true; - }; - - assert.equal(liftResponse.status, 200); - assert.equal(liftPayload.ok, true); - - const blocksResponse = await httpRequest( - `${baseUrl}/api/auth/risk-blocks`, - { - headers: { - Authorization: `Bearer ${entry.token}`, - Cookie: entry.refreshCookie || '', - }, - }, - ); - const blocksPayload = (await blocksResponse.json()) as { - blocks: Array<{ - scopeType: string; - }>; - }; - - assert.equal(blocksResponse.status, 200); - assert.equal( - blocksPayload.blocks.some((block) => block.scopeType === 'phone'), - false, - ); - }); -}); - -test('wechat mock login redirects back with pending bind status and token', async () => { - await withTestServer('wechat-mock-login', async ({ baseUrl }) => { - const result = await startWechatMockFlow(baseUrl, '/'); - - assert.equal(result.hash.get('auth_provider'), 'wechat'); - assert.equal(result.hash.get('auth_binding_status'), 'pending_bind_phone'); - - const meResponse = await httpRequest(`${baseUrl}/api/auth/me`, { - headers: { - Authorization: `Bearer ${result.token}`, - }, - }); - const mePayload = (await meResponse.json()) as { - user: { - loginMethod: 'wechat'; - bindingStatus: 'pending_bind_phone'; - wechatBound: boolean; - phoneNumberMasked: string | null; - }; - availableLoginMethods: string[]; - }; - - assert.equal(meResponse.status, 200); - assert.equal(mePayload.user.loginMethod, 'wechat'); - assert.equal(mePayload.user.bindingStatus, 'pending_bind_phone'); - assert.equal(mePayload.user.wechatBound, true); - assert.equal(mePayload.user.phoneNumberMasked, null); - assert.deepEqual(mePayload.availableLoginMethods, ['phone', 'wechat']); - }); -}); - -test('wechat pending user can bind a new phone number and become active', async () => { - await withTestServer('wechat-bind-phone', async ({ baseUrl }) => { - const wechatSession = await startWechatMockFlow(baseUrl, '/'); - await sendPhoneCode(baseUrl, '13600136000'); - - const bindResponse = await httpRequest( - `${baseUrl}/api/auth/wechat/bind-phone`, - withBearer(wechatSession.token, { - method: 'POST', - body: JSON.stringify({ - phone: '13600136000', - code: '123456', - }), - }), - ); - const bindPayload = (await bindResponse.json()) as { - token: string; - user: { - loginMethod: 'wechat'; - bindingStatus: 'active'; - phoneNumberMasked: string; - wechatBound: boolean; - }; - }; - - assert.equal(bindResponse.status, 200); - assert.ok(bindPayload.token); - assert.equal(bindPayload.user.loginMethod, 'wechat'); - assert.equal(bindPayload.user.bindingStatus, 'active'); - assert.equal(bindPayload.user.phoneNumberMasked, '136****6000'); - assert.equal(bindPayload.user.wechatBound, true); - }); -}); - -test('wechat binding to an existing phone account merges into that account', async () => { - await withTestServer('wechat-bind-existing-phone', async ({ baseUrl }) => { - await sendPhoneCode(baseUrl, '13500135000'); - const phoneAccount = await phoneLogin(baseUrl, '13500135000'); - - const wechatSession = await startWechatMockFlow(baseUrl, '/'); - await sendPhoneCode(baseUrl, '13500135000'); - - const bindResponse = await httpRequest( - `${baseUrl}/api/auth/wechat/bind-phone`, - withBearer(wechatSession.token, { - method: 'POST', - body: JSON.stringify({ - phone: '13500135000', - code: '123456', - }), - }), - ); - const bindPayload = (await bindResponse.json()) as { - token: string; - user: { - id: string; - loginMethod: 'phone' | 'wechat'; - bindingStatus: 'active'; - phoneNumberMasked: string; - wechatBound: boolean; - }; - }; - - assert.equal(bindResponse.status, 200); - assert.equal(bindPayload.user.id, phoneAccount.user.id); - assert.equal(bindPayload.user.bindingStatus, 'active'); - assert.equal(bindPayload.user.phoneNumberMasked, '135****5000'); - assert.equal(bindPayload.user.wechatBound, true); - }); -}); - -test('response envelope can be explicitly enabled without breaking existing routes', async () => { - await withTestServer('response-envelope', async ({ baseUrl }) => { - const entry = await authEntry(baseUrl, 'hero_envelope', 'secret123'); - - const response = await httpRequest(`${baseUrl}/api/auth/me`, { - headers: { - Authorization: `Bearer ${entry.token}`, - 'X-Genarrative-Response-Envelope': 'v1', - }, - }); - const payload = await response.json<{ - ok: true; - data: { - user: { - username: string; - }; - }; - error: null; - meta: { - requestId: string; - apiVersion: string; - routeVersion: string; - operation: string; - latencyMs: number; - timestamp: string; - }; - }>(); - - assert.equal(response.status, 200); - assert.equal(payload.ok, true); - assert.equal(payload.data.user.username, 'hero_envelope'); - assert.equal(payload.error, null); - assert.equal(payload.meta.apiVersion, '2026-04-08'); - assert.equal(payload.meta.routeVersion, '2026-04-08'); - assert.equal(payload.meta.operation, 'auth.me'); - assert.ok(payload.meta.requestId); - assert.ok(payload.meta.latencyMs >= 0); - assert.ok(payload.meta.timestamp); - }); -}); - -test('issued jwt now carries exp and refresh route can mint a new access token', async () => { - await withTestServer('expiring-jwt', async ({ baseUrl }) => { - const entry = await authEntry(baseUrl, 'hero_eternal', 'secret123'); - const tokenParts = entry.token.split('.'); - assert.equal(tokenParts.length, 3); - - const payloadJson = JSON.parse( - Buffer.from(tokenParts[1] || '', 'base64url').toString('utf8'), - ) as { - exp?: number; - sub?: string; - ver?: number; - }; - - assert.equal(typeof payloadJson.sub, 'string'); - assert.equal(typeof payloadJson.ver, 'number'); - assert.equal(typeof payloadJson.exp, 'number'); - assert.ok((payloadJson.exp ?? 0) > 0); - - const meResponse = await httpRequest(`${baseUrl}/api/auth/me`, { - headers: { - Authorization: `Bearer ${entry.token}`, - }, - }); - assert.equal(meResponse.status, 200); - - const refreshResponse = await httpRequest(`${baseUrl}/api/auth/refresh`, { - method: 'POST', - headers: { - Cookie: entry.refreshCookie || '', - }, - }); - const refreshPayload = (await refreshResponse.json()) as { - token: string; - }; - - assert.equal(refreshResponse.status, 200); - assert.ok(refreshPayload.token); - assert.ok(refreshResponse.headers.get('set-cookie')); - - const logoutResponse = await httpRequest( - `${baseUrl}/api/auth/logout`, - withBearer(entry.token, { - method: 'POST', - headers: { - Cookie: entry.refreshCookie || '', - }, - }), - ); - assert.equal(logoutResponse.status, 200); - - const invalidatedResponse = await httpRequest(`${baseUrl}/api/auth/me`, { - headers: { - Authorization: `Bearer ${entry.token}`, - }, - }); - assert.equal(invalidatedResponse.status, 401); - }); -}); - -test('refresh route rejects revoked refresh sessions after logout', async () => { - await withTestServer('refresh-revoked', async ({ baseUrl }) => { - const entry = await authEntry(baseUrl, 'hero_refresh_revoked', 'secret123'); - - const logoutResponse = await httpRequest( - `${baseUrl}/api/auth/logout`, - withBearer(entry.token, { - method: 'POST', - headers: { - Cookie: entry.refreshCookie || '', - }, - }), - ); - assert.equal(logoutResponse.status, 200); - - const refreshResponse = await httpRequest(`${baseUrl}/api/auth/refresh`, { - method: 'POST', - headers: { - Cookie: entry.refreshCookie || '', - }, - }); - const refreshPayload = (await refreshResponse.json()) as { - error: { - code: string; - message: string; - }; - }; - - assert.equal(refreshResponse.status, 401); - assert.equal(refreshPayload.error.code, 'UNAUTHORIZED'); - }); -}); - -test('session list returns current active browser sessions for the user', async () => { - await withTestServer('session-list', async ({ baseUrl }) => { - const entry = await authEntry(baseUrl, 'hero_sessions', 'secret123'); - - const sessionsResponse = await httpRequest(`${baseUrl}/api/auth/sessions`, { - headers: { - Authorization: `Bearer ${entry.token}`, - Cookie: entry.refreshCookie || '', - }, - }); - const sessionsPayload = (await sessionsResponse.json()) as { - sessions: Array<{ - sessionId: string; - clientType: string; - clientLabel: string; - isCurrent: boolean; - userAgent: string | null; - ipMasked: string | null; - }>; - }; - - assert.equal(sessionsResponse.status, 200); - assert.equal(sessionsPayload.sessions.length, 1); - assert.equal(sessionsPayload.sessions[0]?.clientType, 'browser'); - assert.equal(sessionsPayload.sessions[0]?.clientLabel, '网页端浏览器'); - assert.equal(sessionsPayload.sessions[0]?.isCurrent, true); - }); -}); - -test('session revoke removes a remote device but keeps the current session alive', async () => { - await withTestServer('session-revoke', async ({ baseUrl }) => { - const firstEntry = await authEntry( - baseUrl, - 'hero_session_revoke', - 'secret123', - ); - const secondEntry = await authEntry( - baseUrl, - 'hero_session_revoke', - 'secret123', - ); - - const sessionsResponse = await httpRequest(`${baseUrl}/api/auth/sessions`, { - headers: { - Authorization: `Bearer ${secondEntry.token}`, - Cookie: secondEntry.refreshCookie || '', - }, - }); - const sessionsPayload = (await sessionsResponse.json()) as { - sessions: Array<{ - sessionId: string; - isCurrent: boolean; - }>; - }; - const remoteSession = sessionsPayload.sessions.find( - (session) => !session.isCurrent, - ); - assert.ok(remoteSession); - - const revokeResponse = await httpRequest( - `${baseUrl}/api/auth/sessions/${encodeURIComponent(remoteSession?.sessionId || '')}/revoke`, - withBearer(secondEntry.token, { - method: 'POST', - headers: { - Cookie: secondEntry.refreshCookie || '', - }, - }), - ); - const revokePayload = (await revokeResponse.json()) as { - ok: true; - }; - - assert.equal(revokeResponse.status, 200); - assert.equal(revokePayload.ok, true); - - const remoteRefreshResponse = await httpRequest( - `${baseUrl}/api/auth/refresh`, - { - method: 'POST', - headers: { - Cookie: firstEntry.refreshCookie || '', - }, - }, - ); - assert.equal(remoteRefreshResponse.status, 401); - - const currentMeResponse = await httpRequest(`${baseUrl}/api/auth/me`, { - headers: { - Authorization: `Bearer ${secondEntry.token}`, - }, - }); - assert.equal(currentMeResponse.status, 200); - }); -}); - -test('audit log endpoint returns recent auth activities', async () => { - await withTestServer('audit-logs', async ({ baseUrl }) => { - await sendPhoneCode(baseUrl, '13800138000'); - const entry = await phoneLogin(baseUrl, '13800138000'); - await sendPhoneCode(baseUrl, '13900139000'); - - const changeResponse = await httpRequest( - `${baseUrl}/api/auth/phone/change`, - withBearer(entry.token, { - method: 'POST', - headers: { - Cookie: entry.refreshCookie || '', - }, - body: JSON.stringify({ - phone: '13900139000', - code: '123456', - }), - }), - ); - assert.equal(changeResponse.status, 200); - - const logsResponse = await httpRequest(`${baseUrl}/api/auth/audit-logs`, { - headers: { - Authorization: `Bearer ${entry.token}`, - Cookie: entry.refreshCookie || '', - }, - }); - const logsPayload = (await logsResponse.json()) as { - logs: Array<{ - eventType: string; - title: string; - }>; - }; - - assert.equal(logsResponse.status, 200); - assert.ok(logsPayload.logs.length >= 2); - assert.ok(logsPayload.logs.some((log) => log.eventType === 'phone_login')); - assert.ok(logsPayload.logs.some((log) => log.eventType === 'change_phone')); - }); -}); - -test('active account can change phone number after verifying the new phone', async () => { - await withTestServer('change-phone', async ({ baseUrl }) => { - await sendPhoneCode(baseUrl, '13800138000'); - const entry = await phoneLogin(baseUrl, '13800138000'); - - await sendPhoneCode(baseUrl, '13900139000'); - const changeResponse = await httpRequest( - `${baseUrl}/api/auth/phone/change`, - withBearer(entry.token, { - method: 'POST', - headers: { - Cookie: entry.refreshCookie || '', - }, - body: JSON.stringify({ - phone: '13900139000', - code: '123456', - }), - }), - ); - const changePayload = (await changeResponse.json()) as { - user: { - phoneNumberMasked: string; - displayName: string; - }; - }; - - assert.equal(changeResponse.status, 200); - assert.equal(changePayload.user.phoneNumberMasked, '139****9000'); - assert.equal(changePayload.user.displayName, '139****9000'); - - const meResponse = await httpRequest(`${baseUrl}/api/auth/me`, { - headers: { - Authorization: `Bearer ${entry.token}`, - }, - }); - const mePayload = (await meResponse.json()) as { - user: { - phoneNumberMasked: string; - }; - }; - - assert.equal(meResponse.status, 200); - assert.equal(mePayload.user.phoneNumberMasked, '139****9000'); - }); -}); - -test('change phone rejects numbers already bound to another account', async () => { - await withTestServer('change-phone-conflict', async ({ baseUrl }) => { - await sendPhoneCode(baseUrl, '13800138000'); - const sourceEntry = await phoneLogin(baseUrl, '13800138000'); - - await sendPhoneCode(baseUrl, '13900139000'); - await phoneLogin(baseUrl, '13900139000'); - - const changeResponse = await httpRequest( - `${baseUrl}/api/auth/phone/change`, - withBearer(sourceEntry.token, { - method: 'POST', - headers: { - Cookie: sourceEntry.refreshCookie || '', - }, - body: JSON.stringify({ - phone: '13900139000', - code: '123456', - }), - }), - ); - const changePayload = (await changeResponse.json()) as { - error: { - code: string; - message: string; - }; - }; - - assert.equal(changeResponse.status, 409); - assert.equal(changePayload.error.code, 'CONFLICT'); - assert.equal(changePayload.error.message, '该手机号已绑定其他账号'); - }); -}); - -test('logout-all revokes all refresh sessions and invalidates existing access tokens', async () => { - await withTestServer('logout-all', async ({ baseUrl }) => { - const entryA = await authEntry(baseUrl, 'hero_logout_all', 'secret123'); - const refreshResponse = await httpRequest(`${baseUrl}/api/auth/refresh`, { - method: 'POST', - headers: { - Cookie: entryA.refreshCookie || '', - }, - }); - const refreshPayload = (await refreshResponse.json()) as { - token: string; - }; - - assert.equal(refreshResponse.status, 200); - const entryB = { - token: refreshPayload.token, - refreshCookie: buildCookieHeader( - refreshResponse.headers.get('set-cookie'), - 'genarrative_refresh_session', - ), - }; - - const logoutAllResponse = await httpRequest( - `${baseUrl}/api/auth/logout-all`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${entryB.token}`, - Cookie: entryB.refreshCookie, - 'Content-Type': 'application/json', - }, - }, - ); - const logoutAllPayload = (await logoutAllResponse.json()) as { - ok: true; - }; - - assert.equal(logoutAllResponse.status, 200); - assert.equal(logoutAllPayload.ok, true); - - const meAResponse = await httpRequest(`${baseUrl}/api/auth/me`, { - headers: { - Authorization: `Bearer ${entryA.token}`, - }, - }); - assert.equal(meAResponse.status, 401); - - const meBResponse = await httpRequest(`${baseUrl}/api/auth/me`, { - headers: { - Authorization: `Bearer ${entryB.token}`, - }, - }); - assert.equal(meBResponse.status, 401); - - const refreshAfterLogoutAll = await httpRequest( - `${baseUrl}/api/auth/refresh`, - { - method: 'POST', - headers: { - Cookie: entryB.refreshCookie, - }, - }, - ); - assert.equal(refreshAfterLogoutAll.status, 401); - }); -}); - -test('error responses share one structure and preserve request ids', async () => { - await withTestServer('error-envelope', async ({ baseUrl }) => { - const requestId = 'req-error-envelope'; - const response = await httpRequest(`${baseUrl}/api/auth/me`, { - headers: { - 'X-Request-Id': requestId, - }, - }); - const payload = await response.json<{ - error: { - code: string; - message: string; - }; - meta: { - requestId: string; - apiVersion: string; - routeVersion: string; - operation: string; - }; - }>(); - - assert.equal(response.status, 401); - assert.equal(payload.error.code, 'UNAUTHORIZED'); - assert.equal(payload.error.message, '缺少 Authorization Bearer Token'); - assert.equal(payload.meta.requestId, requestId); - assert.equal(payload.meta.apiVersion, '2026-04-08'); - assert.equal(payload.meta.routeVersion, '2026-04-08'); - assert.equal(payload.meta.operation, 'auth.me'); - assert.equal(response.headers.get('x-request-id'), requestId); - }); -}); - -test('validation errors are normalized with code, meta and issue details', async () => { - await withTestServer('invalid-request', async ({ baseUrl }) => { - const response = await httpRequest(`${baseUrl}/api/auth/entry`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({}), - }); - const payload = (await response.json()) as { - error: { - code: string; - message: string; - details?: { - issues?: Array<{ - path: string; - message: string; - code: string; - }>; - }; - }; - meta: { - operation: string; - }; - }; - - assert.equal(response.status, 400); - assert.equal(payload.error.code, 'INVALID_REQUEST'); - assert.equal(payload.error.message, '请求参数不合法'); - assert.equal(payload.meta.operation, 'auth.entry'); - assert.ok(Array.isArray(payload.error.details?.issues)); - assert.ok((payload.error.details?.issues?.length ?? 0) > 0); - }); -}); - -test('malformed json bodies are normalized as bad requests', async () => { - await withTestServer('malformed-json', async ({ baseUrl }) => { - const response = await httpRequest(`${baseUrl}/api/auth/entry`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: '{"username":"broken"', - }); - const payload = (await response.json()) as { - error: { - code: string; - message: string; - }; - meta: { - operation: string; - }; - }; - - assert.equal(response.status, 400); - assert.equal(payload.error.code, 'BAD_REQUEST'); - assert.equal(payload.error.message, 'JSON 请求体格式错误'); - assert.equal(payload.meta.operation, 'POST /api/auth/entry'); - }); -}); - -test('authenticated missing routes return unified not found errors', async () => { - await withTestServer('not-found', async ({ baseUrl }) => { - const entry = await authEntry(baseUrl, 'hero_not_found', 'secret123'); - const response = await httpRequest(`${baseUrl}/api/runtime/unknown-route`, { - headers: { - Authorization: `Bearer ${entry.token}`, - 'X-Genarrative-Response-Envelope': 'v1', - }, - }); - const payload = await response.json<{ - ok: false; - data: null; - error: { - code: string; - message: string; - }; - meta: { - operation: string; - }; - }>(); - - assert.equal(response.status, 404); - assert.equal(payload.ok, false); - assert.equal(payload.data, null); - assert.equal(payload.error.code, 'NOT_FOUND'); - assert.match( - payload.error.message, - /^接口不存在:GET \/api\/runtime\/unknown-route$/u, - ); - assert.equal(payload.meta.operation, 'GET /api/runtime/unknown-route'); - }); -}); - -test('public runtime assets are served from the app root', async () => { - const publicDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'genarrative-public-static-'), - ); - const generatedDir = path.join( - publicDir, - 'generated-character-drafts', - 'test-npc', - 'visual', - 'visual-draft-1', - ); - fs.mkdirSync(generatedDir, { recursive: true }); - const imagePath = path.join(generatedDir, 'candidate-01.png'); - fs.writeFileSync(imagePath, Buffer.from([0x89, 0x50, 0x4e, 0x47])); - - await withTestServer( - 'public-runtime-assets', - async ({ baseUrl }) => { - const response = await httpRequest( - `${baseUrl}/generated-character-drafts/test-npc/visual/visual-draft-1/candidate-01.png`, - ); - - assert.equal(response.status, 200); - assert.equal(response.headers.get('content-type'), 'image/png'); - }, - { - publicDir, - }, - ); -}); - -test('stream responses also carry api version and route metadata headers', async () => { - const app = express(); - app.use(requestIdMiddleware); - app.get('/events', (request, response) => { - prepareEventStreamResponse(request, response, { - routeMeta: { - operation: 'test.events.stream', - }, - }); - response.write('event: ping\n'); - response.write('data: {"ok":true}\n\n'); - response.end(); - }); - - await withListeningApp(app, async ({ baseUrl }) => { - const requestId = 'req-stream-metadata'; - const response = await httpRequest(`${baseUrl}/events`, { - headers: { - 'X-Request-Id': requestId, - }, - }); - const body = await response.text(); - - assert.equal(response.status, 200); - assert.equal(response.headers.get('x-request-id'), requestId); - assert.equal(response.headers.get('x-api-version'), '2026-04-08'); - assert.equal(response.headers.get('x-route-version'), '2026-04-08'); - assert.ok(Number(response.headers.get('x-response-time-ms')) >= 0); - assert.match( - response.headers.get('content-type') ?? '', - /^text\/event-stream/u, - ); - assert.match(body, /event: ping/u); - assert.ok(body.includes('data: {"ok":true}')); - }); -}); - -test('runtime persistence is isolated by user', async () => { - await withTestServer('persistence', async ({ baseUrl }) => { - const userA = await authEntry(baseUrl, 'player_one', 'secret123'); - const userB = await authEntry(baseUrl, 'player_two', 'secret123'); - - const saveResponse = await httpRequest( - `${baseUrl}/api/runtime/save/snapshot`, - withBearer(userA.token, { - method: 'PUT', - body: JSON.stringify({ - gameState: { worldType: 'WUXIA', value: 1 }, - bottomTab: 'adventure', - currentStory: { text: 'story A' }, - }), - }), - ); - assert.equal(saveResponse.status, 200); - - const settingsResponse = await httpRequest( - `${baseUrl}/api/runtime/settings`, - withBearer(userA.token, { - method: 'PUT', - body: JSON.stringify({ - musicVolume: 0.25, - platformTheme: 'dark', - }), - }), - ); - assert.equal(settingsResponse.status, 200); - const settingsPayload = (await settingsResponse.json()) as { - musicVolume: number; - platformTheme: 'light' | 'dark'; - }; - assert.equal(settingsPayload.musicVolume, 0.25); - assert.equal(settingsPayload.platformTheme, 'dark'); - - const libraryResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world-library/world-a`, - withBearer(userA.token, { - method: 'PUT', - body: JSON.stringify({ - profile: { - id: 'world-a', - name: '世界 A', - }, - }), - }), - ); - assert.equal(libraryResponse.status, 200); - - const userASave = await httpRequest( - `${baseUrl}/api/runtime/save/snapshot`, - { - headers: { - Authorization: `Bearer ${userA.token}`, - }, - }, - ); - const userASavePayload = (await userASave.json()) as { - gameState: { - value: number; - }; - }; - assert.equal(userASavePayload.gameState.value, 1); - - const userBSave = await httpRequest( - `${baseUrl}/api/runtime/save/snapshot`, - { - headers: { - Authorization: `Bearer ${userB.token}`, - }, - }, - ); - const userBSavePayload = await userBSave.json(); - assert.equal(userBSavePayload, null); - - const userBSettings = await httpRequest(`${baseUrl}/api/runtime/settings`, { - headers: { - Authorization: `Bearer ${userB.token}`, - }, - }); - const userBSettingsPayload = (await userBSettings.json()) as { - musicVolume: number; - platformTheme: 'light' | 'dark'; - }; - assert.equal(userBSettingsPayload.musicVolume, 0.42); - assert.equal(userBSettingsPayload.platformTheme, 'light'); - - const userBLibrary = await httpRequest( - `${baseUrl}/api/runtime/custom-world-library`, - { - headers: { - Authorization: `Bearer ${userB.token}`, - }, - }, - ); - const userBLibraryPayload = (await userBLibrary.json()) as { - entries: unknown[]; - }; - assert.deepEqual(userBLibraryPayload.entries, []); - }); -}); - -test('profile dashboard aggregates wallet, play time and played works at the account level', async () => { - await withTestServer('profile-dashboard', async ({ baseUrl }) => { - const user = await authEntry(baseUrl, 'dashboard_user', 'secret123'); - - const firstSaveResponse = await httpRequest( - `${baseUrl}/api/runtime/save/snapshot`, - withBearer(user.token, { - method: 'PUT', - body: JSON.stringify({ - savedAt: '2026-04-16T08:00:00.000Z', - bottomTab: 'adventure', - currentStory: null, - gameState: { - worldType: 'CUSTOM', - playerCurrency: 120, - runtimeStats: { - playTimeMs: 5400000, - }, - customWorldProfile: { - id: 'world-aurora', - name: '裂潮边城', - summary: '潮声与城线之间的冷铁边疆。', - }, - }, - }), - }), - ); - assert.equal(firstSaveResponse.status, 200); - - const secondSaveResponse = await httpRequest( - `${baseUrl}/api/runtime/save/snapshot`, - withBearer(user.token, { - method: 'PUT', - body: JSON.stringify({ - savedAt: '2026-04-16T09:30:00.000Z', - bottomTab: 'adventure', - currentStory: null, - gameState: { - worldType: 'CUSTOM', - playerCurrency: 86, - runtimeStats: { - playTimeMs: 7200000, - }, - customWorldProfile: { - id: 'world-aurora', - name: '裂潮边城', - summary: '潮声与城线之间的冷铁边疆。', - }, - }, - }), - }), - ); - assert.equal(secondSaveResponse.status, 200); - - const thirdSaveResponse = await httpRequest( - `${baseUrl}/api/runtime/save/snapshot`, - withBearer(user.token, { - method: 'PUT', - body: JSON.stringify({ - savedAt: '2026-04-16T10:15:00.000Z', - bottomTab: 'adventure', - currentStory: null, - gameState: { - worldType: 'WUXIA', - playerCurrency: 86, - runtimeStats: { - playTimeMs: 900000, - }, - currentScenePreset: { - name: '江湖新章', - }, - }, - }), - }), - ); - assert.equal(thirdSaveResponse.status, 200); - - const dashboardResponse = await httpRequest( - `${baseUrl}/api/runtime/profile/dashboard`, - withBearer(user.token), - ); - const dashboardPayload = (await dashboardResponse.json()) as { - walletBalance: number; - totalPlayTimeMs: number; - playedWorldCount: number; - updatedAt: string | null; - }; - - assert.equal(dashboardResponse.status, 200); - assert.equal(dashboardPayload.walletBalance, 86); - assert.equal(dashboardPayload.totalPlayTimeMs, 8100000); - assert.equal(dashboardPayload.playedWorldCount, 2); - assert.equal(dashboardPayload.updatedAt, '2026-04-16T10:15:00.000Z'); - - const legacyDashboardResponse = await httpRequest( - `${baseUrl}/api/profile/dashboard`, - withBearer(user.token), - ); - const legacyDashboardPayload = (await legacyDashboardResponse.json()) as { - walletBalance: number; - totalPlayTimeMs: number; - playedWorldCount: number; - updatedAt: string | null; - }; - - assert.equal(legacyDashboardResponse.status, 200); - assert.deepEqual(legacyDashboardPayload, dashboardPayload); - - const walletLedgerResponse = await httpRequest( - `${baseUrl}/api/runtime/profile/wallet-ledger`, - withBearer(user.token), - ); - const walletLedgerPayload = (await walletLedgerResponse.json()) as { - entries: Array<{ - amountDelta: number; - balanceAfter: number; - sourceType: string; - }>; - }; - - assert.equal(walletLedgerResponse.status, 200); - assert.equal(walletLedgerPayload.entries.length, 2); - assert.equal(walletLedgerPayload.entries[0]?.amountDelta, -34); - assert.equal(walletLedgerPayload.entries[0]?.balanceAfter, 86); - assert.equal(walletLedgerPayload.entries[0]?.sourceType, 'snapshot_sync'); - assert.equal(walletLedgerPayload.entries[1]?.amountDelta, 120); - - const playStatsResponse = await httpRequest( - `${baseUrl}/api/runtime/profile/play-stats`, - withBearer(user.token), - ); - const playStatsPayload = (await playStatsResponse.json()) as { - totalPlayTimeMs: number; - playedWorks: Array<{ - worldKey: string; - worldTitle: string; - lastObservedPlayTimeMs: number; - }>; - updatedAt: string | null; - }; - - assert.equal(playStatsResponse.status, 200); - assert.equal(playStatsPayload.totalPlayTimeMs, 8100000); - assert.equal(playStatsPayload.updatedAt, '2026-04-16T10:15:00.000Z'); - assert.equal(playStatsPayload.playedWorks.length, 2); - assert.equal(playStatsPayload.playedWorks[0]?.worldKey, 'builtin:WUXIA'); - assert.equal(playStatsPayload.playedWorks[0]?.worldTitle, '江湖新章'); - assert.equal( - playStatsPayload.playedWorks[1]?.worldKey, - 'custom:world-aurora', - ); - assert.equal( - playStatsPayload.playedWorks[1]?.lastObservedPlayTimeMs, - 7200000, - ); - }); -}); - -test('profile save archives list worlds by last played time and can resume a selected archive', async () => { - await withTestServer('profile-save-archives', async ({ baseUrl }) => { - const user = await authEntry(baseUrl, 'archive_user', 'secret123'); - - const firstSaveResponse = await httpRequest( - `${baseUrl}/api/runtime/save/snapshot`, - withBearer(user.token, { - method: 'PUT', - body: JSON.stringify({ - savedAt: '2026-04-19T08:00:00.000Z', - bottomTab: 'adventure', - currentStory: { - text: '潮声还在旧灯塔下回荡。', - options: [], - }, - gameState: { - worldType: 'CUSTOM', - playerCurrency: 120, - runtimeStats: { - playTimeMs: 5400000, - }, - storyEngineMemory: { - continueGameDigest: '回到裂潮边城的旧灯塔继续追查假航灯。', - }, - customWorldProfile: { - id: 'world-aurora', - name: '裂潮边城', - summary: '潮声与城线之间的冷铁边疆。', - }, - }, - }), - }), - ); - assert.equal(firstSaveResponse.status, 200); - - const secondSaveResponse = await httpRequest( - `${baseUrl}/api/runtime/save/snapshot`, - withBearer(user.token, { - method: 'PUT', - body: JSON.stringify({ - savedAt: '2026-04-19T10:15:00.000Z', - bottomTab: 'inventory', - currentStory: { - text: '江湖新章的风雨夜刚刚开始。', - options: [], - }, - gameState: { - worldType: 'WUXIA', - playerCurrency: 86, - runtimeStats: { - playTimeMs: 900000, - }, - currentScenePreset: { - name: '江湖新章', - summary: '雨夜客栈里的新委托。', - }, - }, - }), - }), - ); - assert.equal(secondSaveResponse.status, 200); - - const listResponse = await httpRequest( - `${baseUrl}/api/runtime/profile/save-archives`, - withBearer(user.token), - ); - const listPayload = (await listResponse.json()) as { - entries: Array<{ - worldKey: string; - worldName: string; - summaryText: string; - lastPlayedAt: string; - }>; - }; - - assert.equal(listResponse.status, 200); - assert.deepEqual( - listPayload.entries.map((entry) => entry.worldKey), - ['builtin:WUXIA', 'custom:world-aurora'], - ); - assert.equal(listPayload.entries[0]?.worldName, '江湖新章'); - assert.equal( - listPayload.entries[1]?.summaryText, - '回到裂潮边城的旧灯塔继续追查假航灯。', - ); - assert.equal( - listPayload.entries[0]?.lastPlayedAt, - '2026-04-19T10:15:00.000Z', - ); - - const resumeResponse = await httpRequest( - `${baseUrl}/api/runtime/profile/save-archives/${encodeURIComponent('custom:world-aurora')}`, - withBearer(user.token, { - method: 'POST', - }), - ); - const resumePayload = (await resumeResponse.json()) as { - entry: { - worldKey: string; - }; - snapshot: { - bottomTab: string; - gameState: { - playerCurrency: number; - customWorldProfile: { - id: string; - name: string; - } | null; - }; - }; - }; - - assert.equal(resumeResponse.status, 200); - assert.equal(resumePayload.entry.worldKey, 'custom:world-aurora'); - assert.equal(resumePayload.snapshot.bottomTab, 'adventure'); - assert.equal(resumePayload.snapshot.gameState.playerCurrency, 120); - assert.equal( - resumePayload.snapshot.gameState.customWorldProfile?.id, - 'world-aurora', - ); - - const currentSnapshotResponse = await httpRequest( - `${baseUrl}/api/runtime/save/snapshot`, - withBearer(user.token), - ); - const currentSnapshotPayload = (await currentSnapshotResponse.json()) as { - bottomTab: string; - gameState: { - playerCurrency: number; - customWorldProfile: { - id: string; - } | null; - }; - }; - - assert.equal(currentSnapshotResponse.status, 200); - assert.equal(currentSnapshotPayload.bottomTab, 'adventure'); - assert.equal(currentSnapshotPayload.gameState.playerCurrency, 120); - assert.equal( - currentSnapshotPayload.gameState.customWorldProfile?.id, - 'world-aurora', - ); - - const dashboardResponse = await httpRequest( - `${baseUrl}/api/runtime/profile/dashboard`, - withBearer(user.token), - ); - const dashboardPayload = (await dashboardResponse.json()) as { - walletBalance: number; - totalPlayTimeMs: number; - playedWorldCount: number; - }; - - assert.equal(dashboardResponse.status, 200); - assert.equal(dashboardPayload.walletBalance, 86); - assert.equal(dashboardPayload.totalPlayTimeMs, 6300000); - assert.equal(dashboardPayload.playedWorldCount, 2); - }); -}); - -test('custom worlds stay private until published and then appear in the public gallery', async () => { - await withTestServer('custom-world-gallery', async ({ baseUrl }) => { - const owner = await authEntry(baseUrl, 'gallery_owner', 'secret123'); - - const upsertResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world-library/world-a`, - withBearer(owner.token, { - method: 'PUT', - body: JSON.stringify({ - profile: { - id: 'world-a', - name: '裂桥前线', - subtitle: '边境上空的断层回响', - summary: '围绕裂桥哨线与失序潮汐展开的前线世界。', - tone: '压迫、冷峻、持续失衡', - playerGoal: '在裂桥崩塌前守住归路', - majorFactions: ['裂桥守军'], - coreConflicts: ['断层外压正在逼近城线'], - playableNpcs: [ - { - id: 'role-1', - name: '沈昼', - }, - ], - storyNpcs: [], - landmarks: [ - { - id: 'landmark-1', - name: '裂桥前哨', - description: '裂谷边缘的前线哨卡。', - dangerLevel: '高', - sceneNpcIds: [], - connections: [], - }, - ], - }, - }), - }), - ); - const upsertPayload = (await upsertResponse.json()) as { - entry: { - visibility: 'draft' | 'published'; - authorDisplayName: string; - }; - entries: unknown[]; - }; - - assert.equal(upsertResponse.status, 200); - assert.equal(upsertPayload.entry.visibility, 'draft'); - assert.equal(upsertPayload.entry.authorDisplayName, 'gallery_owner'); - - const galleryBeforePublish = await httpRequest( - `${baseUrl}/api/runtime/custom-world-gallery`, - ); - const galleryBeforePayload = (await galleryBeforePublish.json()) as { - entries: unknown[]; - }; - assert.equal(galleryBeforePublish.status, 200); - assert.deepEqual(galleryBeforePayload.entries, []); - - const publishResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world-library/world-a/publish`, - withBearer(owner.token, { - method: 'POST', - }), - ); - const publishPayload = (await publishResponse.json()) as { - entry: { - visibility: 'draft' | 'published'; - publishedAt: string | null; - }; - }; - - assert.equal(publishResponse.status, 200); - assert.equal(publishPayload.entry.visibility, 'published'); - assert.ok(publishPayload.entry.publishedAt); - - const galleryAfterPublish = await httpRequest( - `${baseUrl}/api/runtime/custom-world-gallery`, - ); - const galleryAfterPayload = (await galleryAfterPublish.json()) as { - entries: Array<{ - ownerUserId: string; - profileId: string; - worldName: string; - authorDisplayName: string; - }>; - }; - - assert.equal(galleryAfterPublish.status, 200); - assert.equal(galleryAfterPayload.entries.length, 1); - assert.equal(galleryAfterPayload.entries[0]?.worldName, '裂桥前线'); - assert.equal( - galleryAfterPayload.entries[0]?.authorDisplayName, - 'gallery_owner', - ); - - const galleryDetail = await httpRequest( - `${baseUrl}/api/runtime/custom-world-gallery/${encodeURIComponent(galleryAfterPayload.entries[0]?.ownerUserId || '')}/${encodeURIComponent(galleryAfterPayload.entries[0]?.profileId || '')}`, - ); - const galleryDetailPayload = (await galleryDetail.json()) as { - entry: { - worldName: string; - profile: { - name: string; - }; - }; - }; - - assert.equal(galleryDetail.status, 200); - assert.equal(galleryDetailPayload.entry.worldName, '裂桥前线'); - assert.equal(galleryDetailPayload.entry.profile.name, '裂桥前线'); - - const unpublishResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world-library/world-a/unpublish`, - withBearer(owner.token, { - method: 'POST', - }), - ); - const unpublishPayload = (await unpublishResponse.json()) as { - entry: { - visibility: 'draft' | 'published'; - }; - }; - - assert.equal(unpublishResponse.status, 200); - assert.equal(unpublishPayload.entry.visibility, 'draft'); - - const galleryAfterUnpublish = await httpRequest( - `${baseUrl}/api/runtime/custom-world-gallery`, - ); - const galleryAfterUnpublishPayload = - (await galleryAfterUnpublish.json()) as { - entries: unknown[]; - }; - assert.deepEqual(galleryAfterUnpublishPayload.entries, []); - }); -}); - -test('deleting a custom world uses soft delete and hides the work from library and gallery', async () => { - await withTestServer( - 'custom-world-soft-delete', - async ({ baseUrl, context }) => { - const owner = await authEntry(baseUrl, 'soft_delete_owner', 'secret123'); - const viewer = await authEntry( - baseUrl, - 'soft_delete_viewer', - 'secret123', - ); - - const upsertResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world-library/world-soft-delete`, - withBearer(owner.token, { - method: 'PUT', - body: JSON.stringify({ - profile: { - id: 'world-soft-delete', - name: '潮雾裂港', - subtitle: '被旧航灯切开的海港', - summary: '用于验证作品删除软删除逻辑的测试世界。', - playableNpcs: [], - storyNpcs: [], - landmarks: [], - }, - }), - }), - ); - - assert.equal(upsertResponse.status, 200); - - const publishResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world-library/world-soft-delete/publish`, - withBearer(owner.token, { - method: 'POST', - }), - ); - assert.equal(publishResponse.status, 200); - - const deleteResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world-library/world-soft-delete`, - withBearer(owner.token, { - method: 'DELETE', - }), - ); - const deletePayload = (await deleteResponse.json()) as { - entries: Array; - }; - - assert.equal(deleteResponse.status, 200); - assert.deepEqual(deletePayload.entries, []); - - const ownerLibraryResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world-library`, - { - headers: { - Authorization: `Bearer ${owner.token}`, - }, - }, - ); - const ownerLibraryPayload = (await ownerLibraryResponse.json()) as { - entries: Array; - }; - - assert.equal(ownerLibraryResponse.status, 200); - assert.deepEqual(ownerLibraryPayload.entries, []); - - const galleryResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world-gallery`, - { - headers: { - Authorization: `Bearer ${viewer.token}`, - }, - }, - ); - const galleryPayload = (await galleryResponse.json()) as { - entries: Array; - }; - - assert.equal(galleryResponse.status, 200); - assert.deepEqual(galleryPayload.entries, []); - - const galleryDetailResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world-gallery/${encodeURIComponent(owner.user.id)}/${encodeURIComponent('world-soft-delete')}`, - { - headers: { - Authorization: `Bearer ${viewer.token}`, - }, - }, - ); - - assert.equal(galleryDetailResponse.status, 404); - - const persistedRows = await context.db.query<{ - profileId: string; - visibility: string; - publishedAt: string | null; - deletedAt: string | null; - payload: { - name?: string; - }; - }>( - `SELECT profile_id AS "profileId", - visibility, - published_at AS "publishedAt", - deleted_at AS "deletedAt", - payload_json AS payload - FROM custom_world_profiles - WHERE user_id = $1 - AND profile_id = $2`, - [owner.user.id, 'world-soft-delete'], - ); - - assert.equal(persistedRows.rows.length, 1); - assert.equal(persistedRows.rows[0]?.profileId, 'world-soft-delete'); - assert.equal(persistedRows.rows[0]?.visibility, 'draft'); - assert.equal(persistedRows.rows[0]?.publishedAt, null); - assert.ok(persistedRows.rows[0]?.deletedAt); - assert.equal(persistedRows.rows[0]?.payload?.name, '潮雾裂港'); - }, - ); -}); - -test('custom world works endpoint returns draft sessions and published worlds together', async () => { - await withTestServer('custom-world-works', async ({ baseUrl }) => { - const entry = await authEntry(baseUrl, 'cw_works', 'secret123'); - - const createSessionResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world/agent/sessions`, - withBearer(entry.token, { - method: 'POST', - body: JSON.stringify({ - seedText: '一个被潮雾切开的列岛世界。', - }), - }), - ); - const createdSession = (await createSessionResponse.json()) as { - session: { - sessionId: string; - stage: string; - }; - }; - - assert.equal(createSessionResponse.status, 200); - assert.equal(createdSession.session.stage, 'clarifying'); - - const publishResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world-library/world-published`, - withBearer(entry.token, { - method: 'PUT', - body: JSON.stringify({ - profile: { - id: 'world-published', - name: '雾潮列岛', - subtitle: '已发布作品', - summary: '灯塔、沉船秘术与旧盟约在雾潮里重新苏醒。', - playableNpcs: [{ id: 'hero', name: '沈灯' }], - landmarks: [{ id: 'port', name: '潮港' }], - }, - }), - }), - ); - - assert.equal(publishResponse.status, 200); - - const publishMutationResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world-library/world-published/publish`, - withBearer(entry.token, { - method: 'POST', - }), - ); - - assert.equal(publishMutationResponse.status, 200); - - const draftOnlyResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world-library/world-draft-only`, - withBearer(entry.token, { - method: 'PUT', - body: JSON.stringify({ - profile: { - id: 'world-draft-only', - name: '旧兼容草稿', - subtitle: '仍保留在作品库,但不再进入创作中心', - summary: '这个条目用来验证阶段三不会继续把 library draft 当主草稿展示。', - playableNpcs: [{ id: 'hero-draft', name: '旧草稿角色' }], - landmarks: [{ id: 'port-draft', name: '旧草稿地点' }], - }, - }), - }), - ); - - assert.equal(draftOnlyResponse.status, 200); - - const worksResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world/works`, - { - headers: { - Authorization: `Bearer ${entry.token}`, - }, - }, - ); - const worksPayload = (await worksResponse.json()) as { - items: Array<{ - status: string; - title: string; - sessionId?: string | null; - profileId?: string | null; - canResume: boolean; - canEnterWorld: boolean; - }>; - }; - - assert.equal(worksResponse.status, 200); - assert.ok( - worksPayload.items.some( - (item) => - item.status === 'draft' && - item.sessionId === createdSession.session.sessionId && - item.canResume === true && - item.canEnterWorld === false, - ), - ); - assert.ok( - worksPayload.items.some( - (item) => - item.status === 'published' && - item.profileId === 'world-published' && - item.title === '雾潮列岛' && - item.canResume === false && - item.canEnterWorld === true, - ), - ); - assert.equal( - worksPayload.items.some((item) => item.profileId === 'world-draft-only'), - false, - ); - }); -}); - -test('custom world agent session accepts messages and exposes completed operations', async () => { - await withTestServer( - 'custom-world-agent-messages', - async ({ baseUrl, context }) => { - installTestCustomWorldAgentSingleTurnLlm(context); - const entry = await authEntry(baseUrl, 'cw_agent', 'secret123'); - - const createResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world/agent/sessions`, - withBearer(entry.token, { - method: 'POST', - body: JSON.stringify({ - seedText: '一个围绕灯塔与沉船秘术的边境世界。', - }), - }), - ); - const created = (await createResponse.json()) as { - session: { - sessionId: string; - messages: Array<{ role: string }>; - }; - }; - - assert.equal(createResponse.status, 200); - assert.equal(created.session.messages[0]?.role, 'assistant'); - - const messageResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}/messages`, - withBearer(entry.token, { - method: 'POST', - body: JSON.stringify({ - clientMessageId: 'client-1', - text: '玩家是一个被迫回到故乡灯塔的失职守望者。', - focusCardId: null, - selectedCardIds: [], - }), - }), - ); - const messagePayload = (await messageResponse.json()) as { - operation: { - operationId: string; - status: string; - progress: number; - }; - }; - - assert.equal(messageResponse.status, 200); - assert.equal(messagePayload.operation.status, 'queued'); - assert.equal(messagePayload.operation.progress, 10); - - let operationText = ''; - - for (let attempt = 0; attempt < 20; attempt += 1) { - const operationResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}/operations/${encodeURIComponent(messagePayload.operation.operationId)}`, - { - headers: { - Authorization: `Bearer ${entry.token}`, - }, - }, - ); - assert.equal(operationResponse.status, 200); - operationText = await operationResponse.text(); - - if (/"status":"completed"/u.test(operationText)) { - break; - } - - await new Promise((resolve) => setTimeout(resolve, 25)); - } - - assert.match(operationText, /"status":"completed"/u); - assert.match(operationText, /"progress":100/u); - - const sessionResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}`, - { - headers: { - Authorization: `Bearer ${entry.token}`, - }, - }, - ); - const sessionPayload = (await sessionResponse.json()) as { - stage: string; - creatorIntent: { - playerPremise?: string | null; - } | null; - messages: Array<{ role: string; text: string }>; - pendingClarifications: Array<{ question: string }>; - }; - - assert.equal(sessionResponse.status, 200); - assert.equal(sessionPayload.stage, 'clarifying'); - assert.ok( - sessionPayload.messages.some((message) => message.role === 'user'), - ); - assert.ok( - sessionPayload.messages.some((message) => message.role === 'assistant'), - ); - assert.match( - sessionPayload.creatorIntent?.playerPremise ?? '', - /玩家|守望者/u, - ); - assert.ok(sessionPayload.pendingClarifications.length > 0); - }, - ); -}); - -test('custom world agent missing session returns 404', async () => { - await withTestServer( - 'custom-world-agent-missing-session', - async ({ baseUrl }) => { - const entry = await authEntry(baseUrl, 'cw_agent_missing', 'secret123'); - - const response = await httpRequest( - `${baseUrl}/api/runtime/custom-world/agent/sessions/unknown-session`, - { - headers: { - Authorization: `Bearer ${entry.token}`, - }, - }, - ); - const payload = (await response.json()) as { - error: { - code: string; - }; - }; - - assert.equal(response.status, 404); - assert.equal(payload.error.code, 'NOT_FOUND'); - }, - ); -}); - -test('custom world agent operation can fail and expose failed status', async () => { - await withTestServer( - 'custom-world-agent-failed-operation', - async ({ baseUrl }) => { - const entry = await authEntry(baseUrl, 'cw_agent_fail', 'secret123'); - - const createResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world/agent/sessions`, - withBearer(entry.token, { - method: 'POST', - body: JSON.stringify({ - seedText: '一个潮雾列岛世界。', - }), - }), - ); - const created = (await createResponse.json()) as { - session: { - sessionId: string; - }; - }; - - assert.equal(createResponse.status, 200); - - const messageResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}/messages`, - withBearer(entry.token, { - method: 'POST', - body: JSON.stringify({ - clientMessageId: 'client-fail', - text: '__phase1_force_fail__', - focusCardId: null, - selectedCardIds: [], - }), - }), - ); - const messagePayload = (await messageResponse.json()) as { - operation: { - operationId: string; - }; - }; - - assert.equal(messageResponse.status, 200); - - let operationText = ''; - - for (let attempt = 0; attempt < 20; attempt += 1) { - const operationResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}/operations/${encodeURIComponent(messagePayload.operation.operationId)}`, - { - headers: { - Authorization: `Bearer ${entry.token}`, - }, - }, - ); - assert.equal(operationResponse.status, 200); - operationText = await operationResponse.text(); - - if (/"status":"failed"/u.test(operationText)) { - break; - } - - await new Promise((resolve) => setTimeout(resolve, 25)); - } - - assert.match(operationText, /"status":"failed"/u); - assert.match(operationText, /forced failure/u); - }, - ); -}); - -test('custom world agent draft_foundation action generates draft cards and card detail over http', async () => { - await withTestServer( - 'custom-world-agent-phase3-http', - async ({ baseUrl, context }) => { - installTestCustomWorldAgentSingleTurnLlm(context); - const entry = await authEntry(baseUrl, 'cw_agent_phase3', 'secret123'); - const readySession = await createReadyCustomWorldAgentSession({ - baseUrl, - token: entry.token, - }); - - const actionResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(readySession.sessionId)}/actions`, - withBearer(entry.token, { - method: 'POST', - body: JSON.stringify({ - action: 'draft_foundation', - }), - }), - ); - const actionPayload = (await actionResponse.json()) as { - operation: { - operationId: string; - status: string; - }; - }; - - assert.equal(actionResponse.status, 200); - assert.equal(actionPayload.operation.status, 'queued'); - - await waitForCustomWorldAgentOperation({ - baseUrl, - token: entry.token, - sessionId: readySession.sessionId, - operationId: actionPayload.operation.operationId, - expectedStatus: 'completed', - }); - - const sessionResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(readySession.sessionId)}`, - { - headers: { - Authorization: `Bearer ${entry.token}`, - }, - }, - ); - const sessionPayload = (await sessionResponse.json()) as { - stage: string; - draftProfile: { - name?: string; - summary?: string; - } | null; - draftCards: Array<{ - id: string; - kind: string; - }>; - }; - - assert.equal(sessionResponse.status, 200); - assert.equal(sessionPayload.stage, 'object_refining'); - assert.ok(sessionPayload.draftProfile?.name); - assert.ok(sessionPayload.draftCards.length > 0); - - const worldCard = sessionPayload.draftCards.find( - (card) => card.kind === 'world', - ); - assert.ok(worldCard); - - const cardDetailResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(readySession.sessionId)}/cards/${encodeURIComponent(worldCard!.id)}`, - { - headers: { - Authorization: `Bearer ${entry.token}`, - }, - }, - ); - const cardDetailPayload = (await cardDetailResponse.json()) as { - card: { - kind: string; - sections: Array<{ - label: string; - value: string; - }>; - }; - }; - - assert.equal(cardDetailResponse.status, 200); - assert.equal(cardDetailPayload.card.kind, 'world'); - assert.ok( - cardDetailPayload.card.sections.some( - (section) => - section.label === '世界一句话' && section.value.length > 0, - ), - ); - }, - ); -}); - -test('custom world agent stream message returns enriched session payload over sse', async () => { - await withTestServer( - 'custom-world-agent-stream-session', - async ({ baseUrl, context }) => { - installTestCustomWorldAgentSingleTurnLlm(context); - const entry = await authEntry(baseUrl, 'cw_agent_stream', 'secret123'); - const readySession = await createReadyCustomWorldAgentSession({ - baseUrl, - token: entry.token, - }); - - const foundationResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(readySession.sessionId)}/actions`, - withBearer(entry.token, { - method: 'POST', - body: JSON.stringify({ - action: 'draft_foundation', - }), - }), - ); - const foundationPayload = (await foundationResponse.json()) as { - operation: { - operationId: string; - }; - }; - - assert.equal(foundationResponse.status, 200); - - await waitForCustomWorldAgentOperation({ - baseUrl, - token: entry.token, - sessionId: readySession.sessionId, - operationId: foundationPayload.operation.operationId, - expectedStatus: 'completed', - }); - - const streamResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(readySession.sessionId)}/messages/stream`, - withBearer(entry.token, { - method: 'POST', - headers: { - Accept: 'text/event-stream', - }, - body: JSON.stringify({ - clientMessageId: 'stream-client-1', - text: '把守灯会的压力再收紧一些,玩家与沈砺的旧案关系也再明显一点。', - focusCardId: null, - selectedCardIds: [], - }), - }), - ); - const streamText = await streamResponse.text(); - const sessionEventMatch = streamText.match(/event: session\s+data: (\{.*\})/u); - - assert.equal(streamResponse.status, 200); - assert.match( - streamResponse.headers.get('content-type') ?? '', - /text\/event-stream/u, - ); - assert.match(streamText, /event: reply_delta/u); - assert.match(streamText, /event: session/u); - assert.match(streamText, /event: done/u); - assert.ok(sessionEventMatch?.[1]); - - const sessionEvent = JSON.parse(sessionEventMatch![1]) as { - session: { - stage: string; - supportedActions?: Array<{ action: string; enabled: boolean }>; - resultPreview?: { - source: string; - preview: { name?: string }; - } | null; - }; - }; - - assert.equal(sessionEvent.session.stage, 'object_refining'); - assert.equal( - sessionEvent.session.supportedActions?.some( - (entry) => - entry.action === 'update_draft_card' && entry.enabled === true, - ), - true, - ); - assert.equal( - sessionEvent.session.resultPreview?.source, - 'session_preview', - ); - assert.ok(sessionEvent.session.resultPreview?.preview?.name); - }, - ); -}); - -test('custom world agent draft_foundation action rejects not-ready sessions over http', async () => { - await withTestServer( - 'custom-world-agent-phase3-http-not-ready', - async ({ baseUrl }) => { - const entry = await authEntry(baseUrl, 'cw_agent_p3_nr', 'secret123'); - - const createResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world/agent/sessions`, - withBearer(entry.token, { - method: 'POST', - body: JSON.stringify({ - seedText: '一个被潮雾切开的列岛世界。', - }), - }), - ); - const created = (await createResponse.json()) as { - session: { - sessionId: string; - }; - }; - - assert.equal(createResponse.status, 200); - - const actionResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}/actions`, - withBearer(entry.token, { - method: 'POST', - body: JSON.stringify({ - action: 'draft_foundation', - }), - }), - ); - const actionPayload = (await actionResponse.json()) as { - error: { - code: string; - message: string; - }; - }; - - assert.equal(actionResponse.status, 400); - assert.equal(actionPayload.error.code, 'BAD_REQUEST'); - assert.match( - actionPayload.error.message, - /progressPercent >= 100|draft_foundation/u, - ); - }, - ); -}); - -test('custom world agent update_draft_card action updates draft profile and cards over http', async () => { - await withTestServer( - 'custom-world-agent-phase4-update-http', - async ({ baseUrl, context }) => { - installTestCustomWorldAgentSingleTurnLlm(context); - const entry = await authEntry( - baseUrl, - 'cw_agent_phase4_update', - 'secret123', - ); - const session = await createObjectRefiningCustomWorldAgentSession({ - baseUrl, - token: entry.token, - }); - const worldCard = session.draftCards.find( - (card) => card.kind === 'world', - ); - - assert.ok(worldCard); - - const actionResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(session.sessionId)}/actions`, - withBearer(entry.token, { - method: 'POST', - body: JSON.stringify({ - action: 'update_draft_card', - cardId: worldCard!.id, - sections: [ - { - sectionId: 'title', - value: '潮雾列岛·回潮版', - }, - { - sectionId: 'summary', - value: '世界总卡和主要对象已经继续往回潮暗线收紧。', - }, - ], - }), - }), - ); - const actionPayload = (await actionResponse.json()) as { - operation: { - operationId: string; - status: string; - }; - }; - - assert.equal(actionResponse.status, 200); - assert.equal(actionPayload.operation.status, 'queued'); - - await waitForCustomWorldAgentOperation({ - baseUrl, - token: entry.token, - sessionId: session.sessionId, - operationId: actionPayload.operation.operationId, - expectedStatus: 'completed', - }); - - const sessionResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(session.sessionId)}`, - { - headers: { - Authorization: `Bearer ${entry.token}`, - }, - }, - ); - const sessionPayload = (await sessionResponse.json()) as { - draftProfile: { - name?: string; - summary?: string; - } | null; - draftCards: Array<{ - id: string; - kind: string; - title: string; - summary: string; - }>; - messages: Array<{ - kind: string; - text: string; - }>; - }; - - assert.equal(sessionResponse.status, 200); - assert.equal(sessionPayload.draftProfile?.name, '潮雾列岛·回潮版'); - assert.equal( - sessionPayload.draftProfile?.summary, - '世界总卡和主要对象已经继续往回潮暗线收紧。', - ); - assert.ok( - sessionPayload.draftCards.some( - (card) => - card.id === worldCard!.id && - card.title === '潮雾列岛·回潮版' && - card.summary === '世界总卡和主要对象已经继续往回潮暗线收紧。', - ), - ); - assert.ok( - sessionPayload.messages.some( - (message) => - message.kind === 'action_result' && message.text.includes('已更新'), - ), - ); - - const cardDetailResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(session.sessionId)}/cards/${encodeURIComponent(worldCard!.id)}`, - { - headers: { - Authorization: `Bearer ${entry.token}`, - }, - }, - ); - const cardDetailPayload = (await cardDetailResponse.json()) as { - card: { - sections: Array<{ - label: string; - value: string; - }>; - }; - }; - - assert.equal(cardDetailResponse.status, 200); - assert.ok( - cardDetailPayload.card.sections.some( - (section) => - section.label === '标题' && section.value === '潮雾列岛·回潮版', - ), - ); - - const sessionRecord = await context.customWorldAgentSessions.get( - entry.user.id, - session.sessionId, - ); - assert.ok( - sessionRecord?.checkpoints.some((checkpoint) => - checkpoint.label.includes('编辑'), - ), - ); - }, - ); -}); - -test('custom world agent sync_result_profile action writes result snapshot back over http', async () => { - await withTestServer( - 'custom-world-agent-sync-result-profile-http', - async ({ baseUrl, context }) => { - installTestCustomWorldAgentSingleTurnLlm(context); - const entry = await authEntry( - baseUrl, - 'cw_agent_sync_result', - 'secret123', - ); - const session = await createObjectRefiningCustomWorldAgentSession({ - baseUrl, - token: entry.token, - }); - - const actionResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(session.sessionId)}/actions`, - withBearer(entry.token, { - method: 'POST', - body: JSON.stringify({ - action: 'sync_result_profile', - profile: { - id: `agent-draft-${session.sessionId}`, - settingText: '被海雾吞没的旧航路群岛', - name: '潮雾列岛·结果页回写版', - subtitle: '旧灯塔与失控航路', - summary: '结果页里的最新世界概述已经回写到当前草稿。', - tone: '压抑、潮湿、悬疑', - playerGoal: '查清沉船夜与假航灯背后的操盘链。', - templateWorldType: 'WUXIA', - majorFactions: ['守灯会', '航运公会'], - coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], - attributeSchema: { - id: 'schema:test', - worldId: 'CUSTOM', - schemaVersion: 1, - schemaName: '测试', - generatedFrom: { - worldType: 'CUSTOM', - worldName: '潮雾列岛·结果页回写版', - settingSummary: '测试', - tone: '测试', - conflictCore: '测试', - }, - slots: [], - }, - playableNpcs: [], - storyNpcs: [], - items: [], - landmarks: [], - generationMode: 'full', - generationStatus: 'complete', - }, - }), - }), - ); - const actionPayload = (await actionResponse.json()) as { - operation: { - operationId: string; - status: string; - }; - }; - - assert.equal(actionResponse.status, 200); - assert.equal(actionPayload.operation.status, 'queued'); - - await waitForCustomWorldAgentOperation({ - baseUrl, - token: entry.token, - sessionId: session.sessionId, - operationId: actionPayload.operation.operationId, - expectedStatus: 'completed', - }); - - const sessionResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(session.sessionId)}`, - { - headers: { - Authorization: `Bearer ${entry.token}`, - }, - }, - ); - const sessionPayload = (await sessionResponse.json()) as { - draftProfile: { - name?: string; - summary?: string; - legacyResultProfile?: { - name?: string; - playerGoal?: string; - }; - } | null; - }; - - assert.equal(sessionResponse.status, 200); - assert.equal(sessionPayload.draftProfile?.name, '潮雾列岛·结果页回写版'); - assert.equal( - sessionPayload.draftProfile?.summary, - '结果页里的最新世界概述已经回写到当前草稿。', - ); - assert.equal( - sessionPayload.draftProfile?.legacyResultProfile?.name, - '潮雾列岛·结果页回写版', - ); - assert.equal( - sessionPayload.draftProfile?.legacyResultProfile?.playerGoal, - '查清沉船夜与假航灯背后的操盘链。', - ); - }, - ); -}); - -test('library publish for agent-backed draft returns explicit blocker message when Phase4 gate is not ready', async () => { - await withTestServer( - 'custom-world-library-agent-publish-blocked', - async ({ baseUrl, context }) => { - installTestCustomWorldAgentSingleTurnLlm(context); - const entry = await authEntry( - baseUrl, - 'cw_library_agent_blocked', - 'secret123', - ); - const session = await createObjectRefiningCustomWorldAgentSession({ - baseUrl, - token: entry.token, - }); - const profileId = `agent-draft-${session.sessionId}`; - - const publishResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world-library/${encodeURIComponent(profileId)}/publish`, - withBearer(entry.token, { - method: 'POST', - }), - ); - const publishPayload = (await publishResponse.json()) as { - error: { - code: string; - message: string; - }; - }; - const sessionAfterPublishAttempt = - await context.customWorldAgentOrchestrator.getSessionSnapshot( - entry.user.id, - session.sessionId, - ); - - assert.equal(publishResponse.status, 409); - assert.equal(publishPayload.error.code, 'CONFLICT'); - assert.match( - publishPayload.error.message, - /当前世界仍有 \d+ 个 blocker/u, - ); - assert.match( - publishPayload.error.message, - /缺少正式主图|缺少正式场景图|主线第一幕/u, - ); - assert.notEqual(sessionAfterPublishAttempt?.stage, 'published'); - }, - ); -}); - -test('library publish for agent-backed draft reuses Phase4 gate and syncs session into published stage', async () => { - await withTestServer( - 'custom-world-library-agent-publish-success', - async ({ baseUrl, context }) => { - installTestCustomWorldAgentSingleTurnLlm(context); - const entry = await authEntry( - baseUrl, - 'cw_library_agent_success', - 'secret123', - ); - const session = await createObjectRefiningCustomWorldAgentSession({ - baseUrl, - token: entry.token, - }); - const profileId = `agent-draft-${session.sessionId}`; - - await markAgentSessionPublishReady({ - context, - userId: entry.user.id, - sessionId: session.sessionId, - }); - - const publishResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world-library/${encodeURIComponent(profileId)}/publish`, - withBearer(entry.token, { - method: 'POST', - }), - ); - const publishPayload = (await publishResponse.json()) as { - entry: { - profileId: string; - visibility: 'draft' | 'published'; - }; - }; - const libraryResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world-library`, - withBearer(entry.token), - ); - const libraryPayload = (await libraryResponse.json()) as { - entries: Array<{ - profileId: string; - visibility: 'draft' | 'published'; - }>; - }; - const sessionAfterPublish = - await context.customWorldAgentOrchestrator.getSessionSnapshot( - entry.user.id, - session.sessionId, - ); - - assert.equal(publishResponse.status, 200); - assert.equal(publishPayload.entry.profileId, profileId); - assert.equal(publishPayload.entry.visibility, 'published'); - assert.equal(libraryResponse.status, 200); - assert.equal( - libraryPayload.entries.find((item) => item.profileId === profileId) - ?.visibility, - 'published', - ); - assert.equal(sessionAfterPublish?.stage, 'published'); - assert.equal(sessionAfterPublish?.resultPreview?.publishReady, true); - assert.equal(sessionAfterPublish?.resultPreview?.canEnterWorld, true); - assert.deepEqual(sessionAfterPublish?.resultPreview?.blockers ?? [], []); - assert.ok( - sessionAfterPublish?.messages.some( - (message) => - message.kind === 'action_result' && - message.text.includes('已正式发布'), - ), - ); - }, - ); -}); - -test('custom world agent generate_characters action appends character cards over http', async () => { - await withTestServer( - 'custom-world-agent-phase4-generate-characters-http', - async ({ baseUrl, context }) => { - installTestCustomWorldAgentSingleTurnLlm(context); - const entry = await authEntry(baseUrl, 'cw_agent_p4_ch', 'secret123'); - const session = await createObjectRefiningCustomWorldAgentSession({ - baseUrl, - token: entry.token, - }); - const baselineCharacterCount = session.draftCards.filter( - (card) => card.kind === 'character', - ).length; - const anchorCardId = - session.draftCards.find((card) => card.kind === 'character')?.id ?? - session.draftCards.find((card) => card.kind === 'thread')?.id; - - assert.ok(anchorCardId); - - const actionResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(session.sessionId)}/actions`, - withBearer(entry.token, { - method: 'POST', - body: JSON.stringify({ - action: 'generate_characters', - count: 2, - promptText: '补两位更贴近旧航道线的边缘角色。', - anchorCardIds: [anchorCardId], - }), - }), - ); - const actionPayload = (await actionResponse.json()) as { - operation: { - operationId: string; - status: string; - }; - }; - - assert.equal(actionResponse.status, 200); - assert.equal(actionPayload.operation.status, 'queued'); - - await waitForCustomWorldAgentOperation({ - baseUrl, - token: entry.token, - sessionId: session.sessionId, - operationId: actionPayload.operation.operationId, - expectedStatus: 'completed', - }); - - const sessionResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(session.sessionId)}`, - { - headers: { - Authorization: `Bearer ${entry.token}`, - }, - }, - ); - const sessionPayload = (await sessionResponse.json()) as { - focusCardId: string | null; - draftProfile: { - storyNpcs?: Array<{ id: string }>; - } | null; - draftCards: Array<{ - kind: string; - title: string; - }>; - messages: Array<{ - kind: string; - text: string; - }>; - }; - - assert.equal(sessionResponse.status, 200); - assert.ok((sessionPayload.draftProfile?.storyNpcs?.length ?? 0) >= 2); - assert.ok( - sessionPayload.draftCards.filter((card) => card.kind === 'character') - .length >= - baselineCharacterCount + 2, - ); - assert.ok(sessionPayload.focusCardId); - assert.ok( - sessionPayload.messages.some( - (message) => - message.kind === 'action_result' && message.text.includes('新角色'), - ), - ); - - const sessionRecord = await context.customWorldAgentSessions.get( - entry.user.id, - session.sessionId, - ); - assert.ok( - sessionRecord?.checkpoints.some((checkpoint) => - checkpoint.label.includes('新增角色'), - ), - ); - }, - ); -}); - -test('custom world agent generate_landmarks action appends landmark cards over http', async () => { - await withTestServer( - 'custom-world-agent-phase4-generate-landmarks-http', - async ({ baseUrl, context }) => { - installTestCustomWorldAgentSingleTurnLlm(context); - const entry = await authEntry(baseUrl, 'cw_agent_p4_lm', 'secret123'); - const session = await createObjectRefiningCustomWorldAgentSession({ - baseUrl, - token: entry.token, - }); - const baselineLandmarkCount = - session.draftProfile?.landmarks?.length ?? - session.draftCards.filter((card) => card.kind === 'landmark').length; - const anchorCardId = - session.draftCards.find((card) => card.kind === 'character')?.id ?? - session.draftCards.find((card) => card.kind === 'thread')?.id; - - assert.ok(anchorCardId); - - const actionResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(session.sessionId)}/actions`, - withBearer(entry.token, { - method: 'POST', - body: JSON.stringify({ - action: 'generate_landmarks', - count: 2, - promptText: '补两个适合藏旧航道秘密的地点。', - anchorCardIds: [anchorCardId], - }), - }), - ); - const actionPayload = (await actionResponse.json()) as { - operation: { - operationId: string; - status: string; - }; - }; - - assert.equal(actionResponse.status, 200); - assert.equal(actionPayload.operation.status, 'queued'); - - await waitForCustomWorldAgentOperation({ - baseUrl, - token: entry.token, - sessionId: session.sessionId, - operationId: actionPayload.operation.operationId, - expectedStatus: 'completed', - }); - - const sessionResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(session.sessionId)}`, - { - headers: { - Authorization: `Bearer ${entry.token}`, - }, - }, - ); - const sessionPayload = (await sessionResponse.json()) as { - focusCardId: string | null; - draftProfile: { - landmarks?: Array<{ id: string }>; - } | null; - draftCards: Array<{ - kind: string; - title: string; - }>; - messages: Array<{ - kind: string; - text: string; - }>; - }; - - assert.equal(sessionResponse.status, 200); - assert.ok( - (sessionPayload.draftProfile?.landmarks?.length ?? 0) >= - baselineLandmarkCount + 2, - ); - assert.ok( - sessionPayload.draftCards.filter((card) => card.kind === 'landmark') - .length >= - baselineLandmarkCount + 2, - ); - assert.ok(sessionPayload.focusCardId); - assert.ok( - sessionPayload.messages.some( - (message) => - message.kind === 'action_result' && message.text.includes('新地点'), - ), - ); - - const sessionRecord = await context.customWorldAgentSessions.get( - entry.user.id, - session.sessionId, - ); - assert.ok( - sessionRecord?.checkpoints.some((checkpoint) => - checkpoint.label.includes('新增地点'), - ), - ); - }, - ); -}); - -test('runtime snapshot persistence accepts null currentStory payloads', async () => { - await withTestServer('persistence-null-story', async ({ baseUrl }) => { - const entry = await authEntry(baseUrl, 'player_null_story', 'secret123'); - - const saveResponse = await httpRequest( - `${baseUrl}/api/runtime/save/snapshot`, - withBearer(entry.token, { - method: 'PUT', - body: JSON.stringify({ - gameState: { - worldType: 'WUXIA', - currentScene: 'Story', - }, - bottomTab: 'adventure', - currentStory: null, - }), - }), - ); - const savePayload = (await saveResponse.json()) as { - currentStory: null; - }; - - assert.equal(saveResponse.status, 200); - assert.equal(savePayload.currentStory, null); - - const loadResponse = await httpRequest( - `${baseUrl}/api/runtime/save/snapshot`, - { - headers: { - Authorization: `Bearer ${entry.token}`, - }, - }, - ); - const loadPayload = (await loadResponse.json()) as { - currentStory: null; - }; - - assert.equal(loadResponse.status, 200); - assert.equal(loadPayload.currentStory, null); - }); -}); - -test('runtime snapshot persistence syncs custom world asset configs into snapshot and profile storage', async () => { - await withTestServer( - 'persistence-custom-world-assets', - async ({ baseUrl, context }) => { - const entry = await authEntry( - baseUrl, - 'playercustomworldassets', - 'secret123', - ); - - const saveResponse = await httpRequest( - `${baseUrl}/api/runtime/save/snapshot`, - withBearer(entry.token, { - method: 'PUT', - body: JSON.stringify({ - gameState: { - currentScene: 'Story', - worldType: 'CUSTOM', - playerCharacter: { - id: 'playable-asset-role', - portrait: - '/generated-characters/playable-asset-role/visual/visual-1/master.png', - generatedVisualAssetId: 'visual-1', - generatedAnimationSetId: 'animation-set-1', - animationMap: { - idle: { - folder: 'idle', - prefix: 'Idle', - frames: 4, - basePath: - '/generated-animations/playable-asset-role/animation-set-1/idle', - }, - }, - }, - currentScenePreset: { - id: 'custom-scene-landmark-1', - name: '潮声断桥', - description: '旧桥横在潮雾之上。', - imageSrc: - '/generated-custom-world-scenes/cw-profile-asset/landmark-1/scene.png', - }, - customWorldProfile: { - id: 'cw-profile-asset', - name: '潮雾裂港', - subtitle: '退潮时响起旧讯号', - summary: '雾与潮共同切开港湾边境。', - tone: '冷潮压城,旧案未散', - playerGoal: '追出失落讯标的去向', - settingText: '一座被潮雾与旧讯号撕开的港湾世界。', - templateWorldType: 'WUXIA', - compatibilityTemplateWorldType: 'WUXIA', - majorFactions: ['潮关守备'], - coreConflicts: ['讯标争夺'], - playableNpcs: [ - { - id: 'playable-asset-role', - name: '沈潮', - title: '归港行者', - role: '可扮演角色', - description: '总盯着退潮后的暗线。', - backstory: '他从失讯后的航路里活着回来。', - personality: '谨慎克制', - motivation: '找回失落讯标', - combatStyle: '借潮势游走压制', - initialAffinity: 18, - relationshipHooks: ['识得旧港规矩'], - tags: ['潮港', '追迹'], - backstoryReveal: { - publicSummary: '他像一直在等潮声回信。', - chapters: [], - }, - skills: [], - initialItems: [], - }, - ], - storyNpcs: [], - items: [], - camp: { - name: '归潮居', - description: '退潮后还能落脚的旧屋。', - dangerLevel: 'low', - }, - landmarks: [ - { - id: 'landmark-1', - name: '潮声断桥', - description: '旧桥横在潮雾之上。', - dangerLevel: 'medium', - sceneNpcIds: [], - connections: [], - }, - ], - attributeSchema: { - slots: [], - }, - }, - }, - bottomTab: 'adventure', - currentStory: { - text: '潮声还在桥下回荡。', - options: [], - }, - }), - }), - ); - - const savePayload = (await saveResponse.json()) as { - gameState: { - customWorldProfile: { - playableNpcs: Array<{ - imageSrc?: string; - generatedVisualAssetId?: string; - generatedAnimationSetId?: string; - animationMap?: Record; - }>; - landmarks: Array<{ - imageSrc?: string; - }>; - } | null; - }; - }; - - assert.equal(saveResponse.status, 200); - assert.equal( - savePayload.gameState.customWorldProfile?.playableNpcs[0]?.imageSrc, - '/generated-characters/playable-asset-role/visual/visual-1/master.png', - ); - assert.equal( - savePayload.gameState.customWorldProfile?.playableNpcs[0] - ?.generatedVisualAssetId, - 'visual-1', - ); - assert.equal( - savePayload.gameState.customWorldProfile?.playableNpcs[0] - ?.generatedAnimationSetId, - 'animation-set-1', - ); - assert.equal( - savePayload.gameState.customWorldProfile?.playableNpcs[0]?.animationMap - ?.idle?.basePath, - '/generated-animations/playable-asset-role/animation-set-1/idle', - ); - assert.equal( - savePayload.gameState.customWorldProfile?.landmarks[0]?.imageSrc, - '/generated-custom-world-scenes/cw-profile-asset/landmark-1/scene.png', - ); - - const persistedRows = await context.db.query<{ - payload: { - playableNpcs?: Array<{ - imageSrc?: string; - generatedVisualAssetId?: string; - generatedAnimationSetId?: string; - animationMap?: Record; - }>; - landmarks?: Array<{ - imageSrc?: string; - }>; - }; - }>( - `SELECT payload_json AS payload - FROM custom_world_profiles - WHERE user_id = $1 - AND profile_id = $2`, - [entry.user.id, 'cw-profile-asset'], - ); - - assert.equal(persistedRows.rows.length, 1); - assert.equal( - persistedRows.rows[0]?.payload?.playableNpcs?.[0]?.imageSrc, - '/generated-characters/playable-asset-role/visual/visual-1/master.png', - ); - assert.equal( - persistedRows.rows[0]?.payload?.playableNpcs?.[0] - ?.generatedAnimationSetId, - 'animation-set-1', - ); - assert.equal( - persistedRows.rows[0]?.payload?.playableNpcs?.[0]?.animationMap?.idle - ?.basePath, - '/generated-animations/playable-asset-role/animation-set-1/idle', - ); - assert.equal( - persistedRows.rows[0]?.payload?.landmarks?.[0]?.imageSrc, - '/generated-custom-world-scenes/cw-profile-asset/landmark-1/scene.png', - ); - }, - ); -}); - -test('runtime snapshot persistence returns hydrated snapshots for frontend restore flows', async () => { - await withTestServer('persistence-hydrated-snapshot', async ({ baseUrl }) => { - const entry = await authEntry( - baseUrl, - 'player_hydrated_snapshot', - 'secret123', - ); - - const saveResponse = await httpRequest( - `${baseUrl}/api/runtime/save/snapshot`, - withBearer(entry.token, { - method: 'PUT', - body: JSON.stringify({ - gameState: { - currentScene: 'Story', - worldType: 'WUXIA', - playerCharacter: { - id: 'hero', - title: '试剑客', - description: '在风里试探局势的人。', - personality: '谨慎而果断', - attributes: { - strength: 8, - spirit: 6, - }, - skills: [], - }, - playerHp: 140, - playerMaxHp: 140, - playerMana: 60, - playerMaxMana: 60, - }, - bottomTab: 'unknown-tab', - currentStory: { - text: '恢复中的故事', - options: [], - streaming: true, - }, - }), - }), - ); - const savePayload = (await saveResponse.json()) as { - bottomTab: string; - currentStory: { - streaming: boolean; - }; - gameState: { - storyEngineMemory: { - saveMigrationManifest?: { - version: string; - } | null; - }; - playerMaxHp: number; - playerMaxMana: number; - playerEquipment: { - weapon: { id: string } | null; - armor: { id: string } | null; - relic: { id: string } | null; - }; - }; - }; - - assert.equal(saveResponse.status, 200); - assert.equal(savePayload.bottomTab, 'adventure'); - assert.equal(savePayload.currentStory.streaming, false); - assert.equal( - savePayload.gameState.storyEngineMemory.saveMigrationManifest?.version, - 'story-engine-v5', - ); - assert.equal(savePayload.gameState.playerMaxHp, 208); - assert.equal(savePayload.gameState.playerMaxMana, 1009); - assert.equal( - savePayload.gameState.playerEquipment.weapon?.id, - 'starter:hero:weapon', - ); - assert.equal( - savePayload.gameState.playerEquipment.armor?.id, - 'starter:hero:armor', - ); - assert.equal( - savePayload.gameState.playerEquipment.relic?.id, - 'starter:hero:relic', - ); - - const loadResponse = await httpRequest( - `${baseUrl}/api/runtime/save/snapshot`, - { - headers: { - Authorization: `Bearer ${entry.token}`, - }, - }, - ); - const loadPayload = (await loadResponse.json()) as typeof savePayload; - - assert.equal(loadResponse.status, 200); - assert.equal(loadPayload.bottomTab, 'adventure'); - assert.equal(loadPayload.currentStory.streaming, false); - assert.equal( - loadPayload.gameState.storyEngineMemory.saveMigrationManifest?.version, - 'story-engine-v5', - ); - assert.equal( - loadPayload.gameState.playerEquipment.weapon?.id, - 'starter:hero:weapon', - ); - assert.equal( - loadPayload.gameState.playerEquipment.armor?.id, - 'starter:hero:armor', - ); - assert.equal( - loadPayload.gameState.playerEquipment.relic?.id, - 'starter:hero:relic', - ); - }); -}); - -test('runtime snapshot persistence returns hydrated snapshots for frontend restore flows', async () => { - await withTestServer('persistence-hydrated-snapshot', async ({ baseUrl }) => { - const entry = await authEntry( - baseUrl, - 'player_hydrated_story', - 'secret123', - ); - - const saveResponse = await httpRequest( - `${baseUrl}/api/runtime/save/snapshot`, - withBearer(entry.token, { - method: 'PUT', - body: JSON.stringify({ - gameState: { - currentScene: 'Story', - worldType: 'WUXIA', - playerCharacter: { - id: 'hero', - title: '试剑客', - description: '在风里试探局势的人。', - personality: '谨慎而果断', - attributes: { - strength: 8, - spirit: 6, - }, - skills: [{ id: 'skill-1' }], - resourceProfile: { - maxHp: 150, - maxMana: 80, - }, - }, - playerHp: 80, - playerMaxHp: 70, - playerMana: 90, - playerMaxMana: 18, - playerEquipment: { - weapon: null, - armor: { - id: 'armor-1', - category: '护甲', - name: '试炼轻甲', - quantity: 1, - rarity: 'rare', - tags: ['armor'], - statProfile: { - maxHpBonus: 20, - }, - }, - relic: { - id: 'relic-1', - category: '饰品', - name: '回气坠', - quantity: 1, - rarity: 'rare', - tags: ['relic'], - statProfile: { - maxManaBonus: 15, - }, - }, - }, - }, - bottomTab: 'unknown-tab', - currentStory: { - text: '服务端恢复故事', - options: [], - streaming: true, - }, - }), - }), - ); - const savePayload = (await saveResponse.json()) as { - bottomTab: string; - currentStory: { - streaming: boolean; - }; - gameState: { - runtimeActionVersion: number; - storyEngineMemory: { - activeThreadIds: string[]; - saveMigrationManifest?: { - version: string; - } | null; - }; - runtimeStats: { - itemsUsed: number; - }; - playerEquipment: { - weapon: null; - }; - playerHp: number; - playerMaxHp: number; - playerMana: number; - playerMaxMana: number; - }; - }; - - assert.equal(saveResponse.status, 200); - assert.equal(savePayload.bottomTab, 'adventure'); - assert.equal(savePayload.currentStory.streaming, false); - assert.equal(savePayload.gameState.runtimeActionVersion, 0); - assert.deepEqual( - savePayload.gameState.storyEngineMemory.activeThreadIds, - [], - ); - assert.equal( - savePayload.gameState.storyEngineMemory.saveMigrationManifest?.version, - 'story-engine-v5', - ); - assert.equal(savePayload.gameState.runtimeStats.itemsUsed, 0); - assert.equal(savePayload.gameState.playerEquipment.weapon, null); - assert.equal(savePayload.gameState.playerHp, 80); - assert.equal(savePayload.gameState.playerMaxHp, 170); - assert.equal(savePayload.gameState.playerMana, 90); - assert.equal(savePayload.gameState.playerMaxMana, 95); - - const loadResponse = await httpRequest( - `${baseUrl}/api/runtime/save/snapshot`, - { - headers: { - Authorization: `Bearer ${entry.token}`, - }, - }, - ); - const loadPayload = (await loadResponse.json()) as { - bottomTab: string; - currentStory: { - streaming: boolean; - }; - gameState: { - storyEngineMemory: { - saveMigrationManifest?: { - version: string; - } | null; - }; - playerMaxHp: number; - }; - }; - - assert.equal(loadResponse.status, 200); - assert.equal(loadPayload.bottomTab, 'adventure'); - assert.equal(loadPayload.currentStory.streaming, false); - assert.equal( - loadPayload.gameState.storyEngineMemory.saveMigrationManifest?.version, - 'story-engine-v5', - ); - assert.equal(loadPayload.gameState.playerMaxHp, 170); - }); -}); - -test('profile browse history supports batch sync, dedupe ordering, isolation and clear', async () => { - await withTestServer('profile-browse-history', async ({ baseUrl }) => { - const viewer = await authEntry(baseUrl, 'browse_viewer', 'secret123'); - const author = await authEntry(baseUrl, 'browse_author', 'secret123'); - const browseHistoryUrl = `${baseUrl}/api/runtime/profile/browse-history`; - - const createResponse = await httpRequest( - browseHistoryUrl, - withBearer(viewer.token, { - method: 'POST', - body: JSON.stringify({ - ownerUserId: author.user.id, - profileId: 'world-1', - worldName: '潮雾列岛', - subtitle: '旧灯塔与失控航路', - summaryText: '第一次浏览记录', - coverImageSrc: '/covers/world-1.png', - themeMode: 'tide', - authorDisplayName: '潮汐作者', - visitedAt: '2026-04-16T10:00:00.000Z', - }), - }), - ); - const createPayload = (await createResponse.json()) as { - entries: Array<{ - profileId: string; - }>; - }; - - assert.equal(createResponse.status, 200); - assert.deepEqual( - createPayload.entries.map((entry) => entry.profileId), - ['world-1'], - ); - - const batchResponse = await httpRequest( - browseHistoryUrl, - withBearer(viewer.token, { - method: 'POST', - body: JSON.stringify({ - entries: [ - { - ownerUserId: author.user.id, - profileId: 'world-2', - worldName: '灰潮港', - subtitle: '海雾中的残灯码头', - summaryText: '第二条浏览记录', - coverImageSrc: '/covers/world-2.png', - themeMode: 'mythic', - authorDisplayName: '潮汐作者', - visitedAt: '2026-04-16T11:00:00.000Z', - }, - { - ownerUserId: author.user.id, - profileId: 'world-1', - worldName: '潮雾列岛', - subtitle: '旧灯塔与失控航路', - summaryText: '第二次浏览后更新', - coverImageSrc: '/covers/world-1-updated.png', - themeMode: 'tide', - authorDisplayName: '潮汐作者', - visitedAt: '2026-04-16T12:00:00.000Z', - }, - ], - }), - }), - ); - const batchPayload = (await batchResponse.json()) as { - entries: Array<{ - profileId: string; - summaryText: string; - visitedAt: string; - }>; - }; - - assert.equal(batchResponse.status, 200); - assert.deepEqual( - batchPayload.entries.map((entry) => entry.profileId), - ['world-1', 'world-2'], - ); - assert.equal(batchPayload.entries[0]?.summaryText, '第二次浏览后更新'); - assert.equal( - batchPayload.entries[0]?.visitedAt, - '2026-04-16T12:00:00.000Z', - ); - - const viewerHistoryResponse = await httpRequest(browseHistoryUrl, { - headers: { - Authorization: `Bearer ${viewer.token}`, - }, - }); - const viewerHistoryPayload = (await viewerHistoryResponse.json()) as { - entries: Array<{ - profileId: string; - }>; - }; - - assert.equal(viewerHistoryResponse.status, 200); - assert.deepEqual( - viewerHistoryPayload.entries.map((entry) => entry.profileId), - ['world-1', 'world-2'], - ); - - const legacyViewerHistoryResponse = await httpRequest( - `${baseUrl}/api/profile/browse-history`, - { - headers: { - Authorization: `Bearer ${viewer.token}`, - }, - }, - ); - const legacyViewerHistoryPayload = - (await legacyViewerHistoryResponse.json()) as { - entries: Array<{ - profileId: string; - }>; - }; - - assert.equal(legacyViewerHistoryResponse.status, 200); - assert.deepEqual( - legacyViewerHistoryPayload.entries.map((entry) => entry.profileId), - ['world-1', 'world-2'], - ); - - const authorHistoryResponse = await httpRequest(browseHistoryUrl, { - headers: { - Authorization: `Bearer ${author.token}`, - }, - }); - const authorHistoryPayload = (await authorHistoryResponse.json()) as { - entries: Array; - }; - - assert.equal(authorHistoryResponse.status, 200); - assert.deepEqual(authorHistoryPayload.entries, []); - - const clearResponse = await httpRequest( - browseHistoryUrl, - withBearer(viewer.token, { - method: 'DELETE', - }), - ); - const clearPayload = (await clearResponse.json()) as { - entries: Array; - }; - - assert.equal(clearResponse.status, 200); - assert.deepEqual(clearPayload.entries, []); - - const clearedHistoryResponse = await httpRequest(browseHistoryUrl, { - headers: { - Authorization: `Bearer ${viewer.token}`, - }, - }); - const clearedHistoryPayload = (await clearedHistoryResponse.json()) as { - entries: Array; - }; - - assert.equal(clearedHistoryResponse.status, 200); - assert.deepEqual(clearedHistoryPayload.entries, []); - }); -}); diff --git a/server-node/src/app.ts b/server-node/src/app.ts deleted file mode 100644 index 5c0b0be0..00000000 --- a/server-node/src/app.ts +++ /dev/null @@ -1,240 +0,0 @@ -import express from 'express'; -import pinoHttp from 'pino-http'; - -import type { AppContext } from './context.js'; -import { notFound } from './errors.js'; -import { buildApiLogContext, withRouteMeta } from './http.js'; -import { errorHandler } from './middleware/errorHandler.js'; -import { requestIdMiddleware } from './middleware/requestId.js'; -import { responseEnvelopeMiddleware } from './middleware/responseEnvelope.js'; -import { createCharacterAssetRoutes } from './modules/assets/characterAssetRoutes.js'; -import { createEditorRoutes } from './modules/editor/editorRoutes.js'; -import { createAuthRoutes } from './routes/authRoutes.js'; -import { createBigFishProxyRoutes } from './routes/bigFishProxyRoutes.js'; -import { createCustomWorldAgentRoutes } from './routes/customWorldAgent.js'; -import { createPuzzleProxyRoutes } from './routes/puzzleProxyRoutes.js'; -import { createRpgEntrySaveRoutes } from './routes/rpg-entry/rpgEntrySaveRoutes.js'; -import { createRpgWorldLibraryRoutes } from './routes/rpg-entry/rpgWorldLibraryRoutes.js'; -import { createRpgProfileRoutes } from './routes/rpg-profile/rpgProfileRoutes.js'; -import { createRpgRuntimeAiAssistRoutes } from './routes/rpg-runtime/rpgRuntimeAiAssistRoutes.js'; -import { createRpgRuntimeStoryRoutes } from './routes/rpg-runtime/rpgRuntimeStoryRoutes.js'; - -function matchesRoutePrefix( - request: express.Request, - prefixes: readonly string[], -) { - const requestPath = request.path || request.originalUrl || request.url || '/'; - - return prefixes.some((prefix) => { - const normalizedPrefix = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix; - return ( - requestPath === normalizedPrefix || - requestPath.startsWith(`${normalizedPrefix}/`) - ); - }); -} - -function scopeToPrefixes( - prefixes: readonly string[], - handler: express.RequestHandler, -): express.RequestHandler { - return (request, response, next) => { - if (!matchesRoutePrefix(request, prefixes)) { - next(); - return; - } - - handler(request, response, next); - }; -} - -export function createApp(context: AppContext) { - const app = express(); - const createHttpLogger = pinoHttp as unknown as ( - options: Record, - ) => express.RequestHandler; - - app.disable('x-powered-by'); - - app.use(requestIdMiddleware); - app.use( - createHttpLogger({ - logger: context.logger, - genReqId: (request: express.Request) => request.requestId, - customSuccessObject: ( - request: express.Request, - response: express.Response, - baseObject: Record & { responseTime?: number }, - ) => ({ - ...baseObject, - ...buildApiLogContext(request, response), - user_id: request.userId ?? null, - method: request.method, - path: request.url, - status: response.statusCode, - latency_ms: baseObject.responseTime, - }), - customErrorObject: ( - request: express.Request, - response: express.Response, - error: unknown, - baseObject: Record & { responseTime?: number }, - ) => ({ - ...baseObject, - ...buildApiLogContext(request, response), - user_id: request.userId ?? null, - method: request.method, - path: request.url, - status: response.statusCode, - latency_ms: baseObject.responseTime, - err: error, - }), - }), - ); - app.use(express.json({ limit: '10mb' })); - app.use(responseEnvelopeMiddleware); - - app.get( - '/healthz', - withRouteMeta({ operation: 'health.check' }), - (_request, response) => { - response.json({ - ok: true, - service: 'genarrative-node-server', - }); - }, - ); - - app.use( - scopeToPrefixes( - ['/api/editor'], - withRouteMeta({ routeVersion: '2026-04-08', operation: 'editor.api' }), - ), - ); - app.use(scopeToPrefixes(['/api/editor'], createEditorRoutes(context.config))); - app.use( - scopeToPrefixes( - ['/api/assets'], - withRouteMeta({ routeVersion: '2026-04-08', operation: 'assets.api' }), - ), - ); - app.use( - scopeToPrefixes( - ['/api/assets'], - createCharacterAssetRoutes(context.config, context.llmClient), - ), - ); - app.use( - '/api', - scopeToPrefixes( - ['/runtime/profile', '/profile', '/runtime/settings'], - withRouteMeta({ routeVersion: '2026-04-21', operation: 'rpg.profile.api' }), - ), - createRpgProfileRoutes(context), - ); - app.use( - '/api', - scopeToPrefixes( - ['/runtime/save', '/runtime/profile/save-archives', '/profile/save-archives'], - withRouteMeta({ routeVersion: '2026-04-21', operation: 'rpg.entry.save.api' }), - ), - createRpgEntrySaveRoutes(context), - ); - app.use( - '/api', - scopeToPrefixes( - ['/runtime/custom-world-gallery', '/runtime/custom-world/works', '/runtime/custom-world-library'], - withRouteMeta({ - routeVersion: '2026-04-21', - operation: 'rpg.entry.worldLibrary.api', - }), - ), - createRpgWorldLibraryRoutes(context), - ); - app.use( - '/api/auth', - withRouteMeta({ routeVersion: '2026-04-08' }), - createAuthRoutes(context), - ); - app.use( - '/api/runtime/story', - withRouteMeta({ routeVersion: '2026-04-21' }), - createRpgRuntimeStoryRoutes(context), - ); - app.use( - scopeToPrefixes( - [ - '/llm/chat/completions', - '/custom-world/cover-image', - '/custom-world/cover-upload', - '/custom-world/scene-image', - '/custom-world/entity', - '/custom-world/scene-npc', - '/runtime/custom-world/entity', - '/runtime/custom-world/scene-npc', - '/runtime/custom-world/profile', - '/runtime/story/initial', - '/runtime/story/continue', - '/runtime/chat', - '/runtime/items', - '/runtime/quests', - '/ws/health', - ], - withRouteMeta({ - routeVersion: '2026-04-21', - operation: 'rpg.runtime.aiAssist.api', - }), - ), - ); - app.use( - '/api', - scopeToPrefixes( - [ - '/llm/chat/completions', - '/custom-world/cover-image', - '/custom-world/cover-upload', - '/custom-world/scene-image', - '/custom-world/entity', - '/custom-world/scene-npc', - '/runtime/custom-world/entity', - '/runtime/custom-world/scene-npc', - '/runtime/custom-world/profile', - '/runtime/story/initial', - '/runtime/story/continue', - '/runtime/chat', - '/runtime/items', - '/runtime/quests', - '/ws/health', - ], - createRpgRuntimeAiAssistRoutes(context), - ), - ); - app.use( - '/api/runtime/custom-world/agent', - withRouteMeta({ routeVersion: '2026-04-21', operation: 'rpg.creation.agent.api' }), - createCustomWorldAgentRoutes(context), - ); - app.use( - '/api/runtime/big-fish', - withRouteMeta({ routeVersion: '2026-04-22', operation: 'bigFish.runtime.proxy.api' }), - createBigFishProxyRoutes(context), - ); - app.use( - '/api/runtime/puzzle', - withRouteMeta({ routeVersion: '2026-04-22', operation: 'puzzle.runtime.proxy.api' }), - createPuzzleProxyRoutes(context), - ); - app.use( - express.static(context.config.publicDir, { - fallthrough: true, - index: false, - }), - ); - - app.use((request, _response, next) => { - next(notFound(`接口不存在:${request.method} ${request.originalUrl}`)); - }); - - app.use(errorHandler); - return app; -} diff --git a/server-node/src/auth/authRequestContext.ts b/server-node/src/auth/authRequestContext.ts deleted file mode 100644 index bf5f19d4..00000000 --- a/server-node/src/auth/authRequestContext.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { Request } from 'express'; - -export type AuthRequestContext = { - clientType: string; - userAgent: string | null; - ip: string | null; -}; - -export function buildAuthRequestContext(request: Request): AuthRequestContext { - return { - clientType: 'browser', - userAgent: request.header('user-agent')?.trim() || null, - ip: request.ip || null, - }; -} diff --git a/server-node/src/auth/authService.ts b/server-node/src/auth/authService.ts deleted file mode 100644 index 2fd14b73..00000000 --- a/server-node/src/auth/authService.ts +++ /dev/null @@ -1,1537 +0,0 @@ -import crypto from 'node:crypto'; - -import type { - AuthAuditLogEntry, - AuthAuditLogEventType, - AuthAuditLogsResponse, - AuthBindingStatus, - AuthEntryResponse, - AuthLoginOptionsResponse, - AuthLiftRiskBlockResponse, - AuthLoginMethod, - AuthLogoutAllResponse, - AuthMeResponse, - AuthPhoneChangeResponse, - AuthPhoneLoginResponse, - AuthPhoneSendCodeResponse, - AuthRefreshResponse, - AuthRevokeSessionResponse, - AuthRiskBlocksResponse, - AuthRiskBlockSummary, - AuthSessionsResponse, - AuthSessionSummary, - AuthUser, - AuthWechatBindPhoneResponse, - AuthWechatStartResponse, - LogoutResponse, -} from '../../../packages/shared/src/contracts/auth.js'; -import type { AppContext } from '../context.js'; -import { - badRequest, - captchaRequired, - conflict, - tooManyRequests, - unauthorized, -} from '../errors.js'; -import type { UserRecord } from '../repositories/userRepository.js'; -import { hashPassword, verifyPassword } from './password.js'; -import { - normalizeMainlandChinaPhoneNumber, - validateSmsVerifyCode, -} from './phoneNumber.js'; -import { - createRefreshSessionToken, - hashRefreshSessionToken, - type RefreshSessionRequestContext, -} from './refreshSessionCookie.js'; -import { signAccessToken } from './token.js'; - -const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,24}$/u; - -function normalizeUsername(username: string) { - return username.trim(); -} - -function validateCredentials(username: string, password: string) { - if (!USERNAME_PATTERN.test(username)) { - throw badRequest('用户名只允许 3 到 24 位字母、数字、下划线'); - } - if (password.length < 6 || password.length > 128) { - throw badRequest('密码长度需要在 6 到 128 位之间'); - } -} - -function isUniqueViolationError(error: unknown) { - return ( - typeof error === 'object' && - error !== null && - 'code' in error && - (error as { code?: unknown }).code === '23505' - ); -} - -function buildMaskedPhoneDisplay(phoneNumber: string) { - const normalizedPhone = normalizeMainlandChinaPhoneNumber(phoneNumber); - return normalizedPhone.maskedNationalNumber; -} - -function buildSystemUsername(prefix: string) { - return `${prefix}_${crypto.randomBytes(10).toString('hex')}`; -} - -function buildRandomPasswordSeed() { - return crypto.randomBytes(24).toString('hex'); -} - -function mapAccountStatusToBindingStatus( - status: UserRecord['accountStatus'], -): AuthBindingStatus { - return status === 'pending_bind_phone' ? 'pending_bind_phone' : 'active'; -} - -function resolveDisplayName(user: { - displayName?: string | null; - username?: string | null; - phoneNumber?: string | null; -}) { - return ( - user.displayName?.trim() || - (user.phoneNumber ? buildMaskedPhoneDisplay(user.phoneNumber) : '') || - user.username?.trim() || - '玩家' - ); -} - -function resolveDisplayNameAfterPhoneChange(user: UserRecord, nextPhoneNumber: string) { - const nextMaskedPhone = buildMaskedPhoneDisplay(nextPhoneNumber); - const currentMaskedPhone = user.phoneNumber - ? buildMaskedPhoneDisplay(user.phoneNumber) - : ''; - - if ( - user.loginProvider === 'phone' || - !user.displayName?.trim() || - user.displayName.trim() === currentMaskedPhone - ) { - return nextMaskedPhone; - } - - return user.displayName; -} - -function resolveAvailableLoginMethods(context: AppContext): AuthLoginMethod[] { - const methods: AuthLoginMethod[] = []; - if (context.config.smsAuth.enabled) { - methods.push('phone'); - } - if (context.config.wechatAuth.enabled) { - methods.push('wechat'); - } - return methods; -} - -async function toAuthUser( - context: AppContext, - user: UserRecord, -): Promise { - const identities = await context.authIdentityRepository.listByUserId(user.id); - const wechatBound = identities.some((identity) => identity.provider === 'wechat'); - const displayName = resolveDisplayName(user); - - return { - id: user.id, - username: displayName, - displayName, - phoneNumberMasked: user.phoneNumber - ? buildMaskedPhoneDisplay(user.phoneNumber) - : null, - loginMethod: user.loginProvider, - bindingStatus: mapAccountStatusToBindingStatus(user.accountStatus), - wechatBound, - }; -} - -export async function buildAuthMeResponse( - context: AppContext, - user: UserRecord | null, -): Promise { - return { - user: user ? await toAuthUser(context, user) : null, - availableLoginMethods: resolveAvailableLoginMethods(context), - }; -} - -export function buildAuthLoginOptionsResponse( - context: AppContext, -): AuthLoginOptionsResponse { - return { - availableLoginMethods: resolveAvailableLoginMethods(context), - }; -} - -async function signUserAuthPayload( - context: AppContext, - user: UserRecord, -) { - const token = await signAccessToken( - { - userId: user.id, - tokenVersion: user.tokenVersion, - }, - context.config, - ); - - return { - token, - user: await toAuthUser(context, user), - }; -} - -function buildRefreshSessionExpiry(config: AppContext['config']) { - const expiresAt = new Date(); - expiresAt.setDate( - expiresAt.getDate() + Math.max(1, config.authSession.refreshSessionTtlDays), - ); - return expiresAt.toISOString(); -} - -function buildRelativeTimeIso(params: { - hours?: number; - days?: number; -}) { - const date = new Date(); - if (params.hours) { - date.setHours(date.getHours() - params.hours); - } - if (params.days) { - date.setDate(date.getDate() - params.days); - } - return date.toISOString(); -} - -function buildCaptchaScopeKey(params: { - scene: 'login' | 'bind_phone' | 'change_phone'; - phoneNumber: string; - ip: string | null; -}) { - return `${params.scene}:${params.phoneNumber}:${params.ip ?? 'no-ip'}`; -} - -function buildFutureTimeIso(params: { - minutes: number; -}) { - const date = new Date(); - date.setMinutes(date.getMinutes() + Math.max(1, params.minutes)); - return date.toISOString(); -} - -function maskIpAddress(ip: string | null) { - if (!ip) { - return null; - } - - if (ip.includes(':')) { - const parts = ip.split(':').filter(Boolean); - if (parts.length <= 2) { - return ip; - } - return `${parts.slice(0, 2).join(':')}::*`; - } - - const parts = ip.split('.'); - if (parts.length !== 4) { - return ip; - } - return `${parts[0]}.${parts[1]}.*.*`; -} - -function buildSessionClientLabel(session: { - clientType: string; - userAgent: string | null; -}) { - const userAgent = session.userAgent?.toLowerCase() || ''; - if (userAgent.includes('mobile') || userAgent.includes('android') || userAgent.includes('iphone')) { - return '移动端浏览器'; - } - if (session.clientType === 'browser') { - return '网页端浏览器'; - } - return session.clientType || '未知设备'; -} - -function buildAuditLogTitle(eventType: AuthAuditLogEventType) { - switch (eventType) { - case 'password_login': - return '账号密码登录'; - case 'phone_login': - return '手机号登录'; - case 'wechat_login': - return '微信登录'; - case 'wechat_bind_phone': - return '绑定手机号'; - case 'change_phone': - return '更换手机号'; - case 'captcha_required': - return '需要图形验证码'; - case 'logout': - return '退出当前设备'; - case 'logout_all': - return '退出全部设备'; - case 'revoke_session': - return '移除登录设备'; - case 'risk_block_phone': - return '手机号临时保护'; - case 'risk_block_ip': - return '网络临时保护'; - case 'risk_unblock_phone': - return '解除手机号保护'; - case 'risk_unblock_ip': - return '解除网络保护'; - default: - return '账号操作'; - } -} - -async function writeAuthAuditLog( - context: AppContext, - input: { - userId: string; - eventType: AuthAuditLogEventType; - detail: string; - ip: string | null; - userAgent: string | null; - metaJson?: Record | null; - }, -) { - await context.authAuditLogRepository.create(input); -} - -async function recordSmsAuthEvent( - context: AppContext, - input: { - phoneNumber: string; - scene: 'login' | 'bind_phone' | 'change_phone'; - action: 'send_code' | 'verify_code'; - success: boolean; - requestContext: RefreshSessionRequestContext | null; - provider?: string | null; - providerRequestId?: string | null; - providerBizId?: string | null; - providerOutId?: string | null; - deliveryStatus?: 'pending' | 'delivered' | 'failed' | 'unknown'; - deliveryReportRawJson?: Record | null; - deliveryReportedAt?: string | null; - }, -) { - return context.smsAuthEventRepository.create({ - phoneNumber: input.phoneNumber, - scene: input.scene, - action: input.action, - success: input.success, - ip: input.requestContext?.ip ?? null, - userAgent: input.requestContext?.userAgent ?? null, - provider: input.provider ?? null, - providerRequestId: input.providerRequestId ?? null, - providerBizId: input.providerBizId ?? null, - providerOutId: input.providerOutId ?? null, - deliveryStatus: input.deliveryStatus, - deliveryReportRawJson: input.deliveryReportRawJson ?? null, - deliveryReportedAt: input.deliveryReportedAt ?? null, - }); -} - -function buildCaptchaRequiredError( - context: AppContext, - params: { - scene: 'login' | 'bind_phone' | 'change_phone'; - phoneNumber: string; - requestContext: RefreshSessionRequestContext | null; - message?: string; - }, -) { - const challenge = context.captchaChallenges.create( - buildCaptchaScopeKey({ - scene: params.scene, - phoneNumber: params.phoneNumber, - ip: params.requestContext?.ip ?? null, - }), - context.config.smsAuth.captchaTtlSeconds, - ); - - return captchaRequired( - params.message ?? '当前操作需要完成人机校验', - { - captchaChallenge: challenge, - }, - ); -} - -async function enforceSmsSendRateLimit( - context: AppContext, - phoneNumber: string, - requestContext: RefreshSessionRequestContext | null, -) { - const phoneSendCount = await context.smsAuthEventRepository.countSinceByPhone({ - phoneNumber, - action: 'send_code', - since: buildRelativeTimeIso({ days: 1 }), - }); - if (phoneSendCount >= context.config.smsAuth.maxSendPerPhonePerDay) { - throw tooManyRequests('该手机号今日验证码发送次数已达上限,请明天再试'); - } - - const ipSendCount = await context.smsAuthEventRepository.countSinceByIp({ - ip: requestContext?.ip ?? null, - action: 'send_code', - since: buildRelativeTimeIso({ hours: 1 }), - }); - if (ipSendCount >= context.config.smsAuth.maxSendPerIpPerHour) { - throw tooManyRequests('当前网络请求验证码过于频繁,请稍后再试'); - } -} - -async function enforceSmsVerifyFailureLimit( - context: AppContext, - phoneNumber: string, - requestContext: RefreshSessionRequestContext | null, -) { - const phoneFailureCount = await context.smsAuthEventRepository.countSinceByPhone({ - phoneNumber, - action: 'verify_code', - success: false, - since: buildRelativeTimeIso({ hours: 1 }), - }); - if ( - phoneFailureCount >= - context.config.smsAuth.maxVerifyFailuresPerPhonePerHour - ) { - throw tooManyRequests('该手机号验证码尝试次数过多,请稍后再试'); - } - - const ipFailureCount = await context.smsAuthEventRepository.countSinceByIp({ - ip: requestContext?.ip ?? null, - action: 'verify_code', - success: false, - since: buildRelativeTimeIso({ hours: 1 }), - }); - if ( - ipFailureCount >= context.config.smsAuth.maxVerifyFailuresPerIpPerHour - ) { - throw tooManyRequests('当前网络验证码尝试次数过多,请稍后再试'); - } -} - -function buildRiskBlockMessage(scopeType: 'phone' | 'ip', expiresAt: string) { - const expiresDate = new Date(expiresAt); - const remainingMinutes = Math.max( - 1, - Math.ceil((expiresDate.getTime() - Date.now()) / 60000), - ); - - if (scopeType === 'phone') { - return `该手机号因异常尝试已被临时保护,请约 ${remainingMinutes} 分钟后再试`; - } - - return `当前网络因异常尝试已被临时保护,请约 ${remainingMinutes} 分钟后再试`; -} - -function toAuthRiskBlockSummary(block: { - scopeType: 'phone' | 'ip'; - expiresAt: string; -}): AuthRiskBlockSummary { - const remainingSeconds = Math.max( - 0, - Math.floor((new Date(block.expiresAt).getTime() - Date.now()) / 1000), - ); - - return { - scopeType: block.scopeType, - title: block.scopeType === 'phone' ? '手机号保护中' : '当前网络保护中', - detail: buildRiskBlockMessage(block.scopeType, block.expiresAt), - expiresAt: block.expiresAt, - remainingSeconds, - }; -} - -async function enforceNoActiveRiskBlocks( - context: AppContext, - params: { - phoneNumber: string; - requestContext: RefreshSessionRequestContext | null; - }, -) { - const phoneBlock = await context.authRiskBlockRepository.findActive( - 'phone', - params.phoneNumber, - ); - if (phoneBlock) { - throw tooManyRequests( - buildRiskBlockMessage('phone', phoneBlock.expiresAt), - { - blockExpiresAt: phoneBlock.expiresAt, - scopeType: 'phone', - }, - ); - } - - const ip = params.requestContext?.ip ?? null; - if (!ip) { - return; - } - - const ipBlock = await context.authRiskBlockRepository.findActive('ip', ip); - if (ipBlock) { - throw tooManyRequests( - buildRiskBlockMessage('ip', ipBlock.expiresAt), - { - blockExpiresAt: ipBlock.expiresAt, - scopeType: 'ip', - }, - ); - } -} - -async function applyRiskBlocksIfNeeded( - context: AppContext, - params: { - phoneNumber: string; - requestContext: RefreshSessionRequestContext | null; - }, -) { - const phoneFailureCount = await context.smsAuthEventRepository.countSinceByPhone({ - phoneNumber: params.phoneNumber, - action: 'verify_code', - success: false, - since: buildRelativeTimeIso({ hours: 1 }), - }); - if ( - phoneFailureCount >= context.config.smsAuth.blockPhoneFailureThreshold - ) { - const expiresAt = buildFutureTimeIso({ - minutes: context.config.smsAuth.blockPhoneDurationMinutes, - }); - const block = await context.authRiskBlockRepository.createOrRefresh({ - scopeType: 'phone', - scopeKey: params.phoneNumber, - reason: 'sms_verify_failures', - expiresAt, - }); - const existingUser = await context.userRepository.findByPhoneNumber( - params.phoneNumber, - ); - if (existingUser) { - await writeAuthAuditLog(context, { - userId: existingUser.id, - eventType: 'risk_block_phone', - detail: `手机号 ${buildMaskedPhoneDisplay(params.phoneNumber)} 已被临时保护`, - ip: params.requestContext?.ip ?? null, - userAgent: params.requestContext?.userAgent ?? null, - metaJson: { - scopeKey: params.phoneNumber, - expiresAt: block?.expiresAt ?? expiresAt, - }, - }); - } - } - - const ip = params.requestContext?.ip ?? null; - if (!ip) { - return; - } - - const ipFailureCount = await context.smsAuthEventRepository.countSinceByIp({ - ip, - action: 'verify_code', - success: false, - since: buildRelativeTimeIso({ hours: 1 }), - }); - if (ipFailureCount >= context.config.smsAuth.blockIpFailureThreshold) { - const expiresAt = buildFutureTimeIso({ - minutes: context.config.smsAuth.blockIpDurationMinutes, - }); - await context.authRiskBlockRepository.createOrRefresh({ - scopeType: 'ip', - scopeKey: ip, - reason: 'sms_verify_failures', - expiresAt, - }); - } -} - -async function enforceCaptchaRequirement( - context: AppContext, - params: { - scene: 'login' | 'bind_phone' | 'change_phone'; - phoneNumber: string; - requestContext: RefreshSessionRequestContext | null; - captchaChallengeId?: string | null; - captchaAnswer?: string | null; - }, -) { - const phoneFailureCount = await context.smsAuthEventRepository.countSinceByPhone({ - phoneNumber: params.phoneNumber, - action: 'verify_code', - success: false, - since: buildRelativeTimeIso({ hours: 1 }), - }); - const ipFailureCount = await context.smsAuthEventRepository.countSinceByIp({ - ip: params.requestContext?.ip ?? null, - action: 'verify_code', - success: false, - since: buildRelativeTimeIso({ hours: 1 }), - }); - - const requiresCaptcha = - phoneFailureCount >= - context.config.smsAuth.captchaTriggerVerifyFailuresPerPhone || - ipFailureCount >= context.config.smsAuth.captchaTriggerVerifyFailuresPerIp; - - if (!requiresCaptcha) { - return; - } - - const challengeId = params.captchaChallengeId?.trim() || ''; - const captchaAnswer = params.captchaAnswer?.trim() || ''; - if (!challengeId || !captchaAnswer) { - const existingUser = await context.userRepository.findByPhoneNumber( - params.phoneNumber, - ); - if (existingUser) { - await writeAuthAuditLog(context, { - userId: existingUser.id, - eventType: 'captcha_required', - detail: `手机号 ${buildMaskedPhoneDisplay(params.phoneNumber)} 需要图形验证码`, - ip: params.requestContext?.ip ?? null, - userAgent: params.requestContext?.userAgent ?? null, - }); - } - throw buildCaptchaRequiredError(context, params); - } - - const isValid = context.captchaChallenges.verify({ - challengeId, - scopeKey: buildCaptchaScopeKey({ - scene: params.scene, - phoneNumber: params.phoneNumber, - ip: params.requestContext?.ip ?? null, - }), - answer: captchaAnswer, - }); - if (!isValid) { - const existingUser = await context.userRepository.findByPhoneNumber( - params.phoneNumber, - ); - if (existingUser) { - await writeAuthAuditLog(context, { - userId: existingUser.id, - eventType: 'captcha_required', - detail: `手机号 ${buildMaskedPhoneDisplay(params.phoneNumber)} 图形验证码错误`, - ip: params.requestContext?.ip ?? null, - userAgent: params.requestContext?.userAgent ?? null, - }); - } - throw buildCaptchaRequiredError(context, { - ...params, - message: '图形验证码错误或已过期,请重新输入', - }); - } -} - -function toAuthAuditLogEntry(log: { - id: string; - eventType: AuthAuditLogEventType; - detail: string; - ip: string | null; - userAgent: string | null; - createdAt: string; -}): AuthAuditLogEntry { - return { - id: log.id, - eventType: log.eventType, - title: buildAuditLogTitle(log.eventType), - detail: log.detail, - ipMasked: maskIpAddress(log.ip), - userAgent: log.userAgent, - createdAt: log.createdAt, - }; -} - -export async function createRefreshSession( - context: AppContext, - user: UserRecord, - sessionContext: RefreshSessionRequestContext, -) { - const refreshToken = createRefreshSessionToken(); - const refreshTokenHash = hashRefreshSessionToken(refreshToken); - const expiresAt = buildRefreshSessionExpiry(context.config); - - await context.userSessionRepository.create({ - userId: user.id, - refreshTokenHash, - clientType: sessionContext.clientType, - userAgent: sessionContext.userAgent, - ip: sessionContext.ip, - expiresAt, - }); - - return { - refreshToken, - expiresAt, - }; -} - -export async function listAuthAuditLogs( - context: AppContext, - userId: string, -): Promise { - const logs = await context.authAuditLogRepository.listRecentByUserId(userId, 20); - return { - logs: logs.map((log) => toAuthAuditLogEntry(log)), - }; -} - -export async function listActiveRiskBlocks( - context: AppContext, - user: UserRecord, - requestContext: RefreshSessionRequestContext | null, -): Promise { - const blocks: AuthRiskBlockSummary[] = []; - - if (user.phoneNumber) { - const phoneBlock = await context.authRiskBlockRepository.findActive( - 'phone', - user.phoneNumber, - ); - if (phoneBlock) { - blocks.push(toAuthRiskBlockSummary(phoneBlock)); - } - } - - const ip = requestContext?.ip ?? null; - if (ip) { - const ipBlock = await context.authRiskBlockRepository.findActive('ip', ip); - if (ipBlock) { - blocks.push(toAuthRiskBlockSummary(ipBlock)); - } - } - - return { - blocks, - }; -} - -export async function liftRiskBlock( - context: AppContext, - user: UserRecord, - requestContext: RefreshSessionRequestContext | null, - scopeType: 'phone' | 'ip', -): Promise { - if (scopeType === 'phone') { - if (!user.phoneNumber) { - throw badRequest('当前账号没有可解除的手机号保护'); - } - - const liftedBlocks = await context.authRiskBlockRepository.liftActive( - 'phone', - user.phoneNumber, - ); - if (liftedBlocks.length === 0) { - throw badRequest('当前没有生效中的手机号保护'); - } - - await writeAuthAuditLog(context, { - userId: user.id, - eventType: 'risk_unblock_phone', - detail: `已手动解除手机号 ${buildMaskedPhoneDisplay(user.phoneNumber)} 的保护`, - ip: requestContext?.ip ?? null, - userAgent: requestContext?.userAgent ?? null, - }); - - return { - ok: true, - }; - } - - const ip = requestContext?.ip ?? null; - if (!ip) { - throw badRequest('当前网络没有可解除的保护'); - } - - const liftedBlocks = await context.authRiskBlockRepository.liftActive('ip', ip); - if (liftedBlocks.length === 0) { - throw badRequest('当前没有生效中的网络保护'); - } - - await writeAuthAuditLog(context, { - userId: user.id, - eventType: 'risk_unblock_ip', - detail: '已手动解除当前网络保护', - ip, - userAgent: requestContext?.userAgent ?? null, - }); - - return { - ok: true, - }; -} - -export async function refreshAuthSession( - context: AppContext, - rawRefreshToken: string, -): Promise< - AuthRefreshResponse & { - refreshToken: string; - refreshExpiresAt: string; - } -> { - const refreshToken = rawRefreshToken.trim(); - if (!refreshToken) { - throw unauthorized('缺少刷新会话'); - } - - const refreshTokenHash = hashRefreshSessionToken(refreshToken); - const session = await context.userSessionRepository.findActiveByRefreshTokenHash( - refreshTokenHash, - ); - if (!session || session.revokedAt) { - throw unauthorized('刷新会话已失效,请重新登录'); - } - if (new Date(session.expiresAt).getTime() <= Date.now()) { - throw unauthorized('刷新会话已过期,请重新登录'); - } - - const user = await context.userRepository.findById(session.userId); - if (!user) { - throw unauthorized('用户不存在'); - } - if (user.accountStatus === 'disabled') { - throw unauthorized('账号已被禁用'); - } - - const nextRefreshToken = createRefreshSessionToken(); - const nextRefreshTokenHash = hashRefreshSessionToken(nextRefreshToken); - const nextExpiresAt = buildRefreshSessionExpiry(context.config); - const lastSeenAt = new Date().toISOString(); - - await context.userSessionRepository.rotate(session.id, { - refreshTokenHash: nextRefreshTokenHash, - expiresAt: nextExpiresAt, - lastSeenAt, - }); - - const accessPayload = await signUserAuthPayload(context, user); - return { - token: accessPayload.token, - refreshToken: nextRefreshToken, - refreshExpiresAt: nextExpiresAt, - }; -} - -export async function listUserSessions( - context: AppContext, - userId: string, - rawRefreshToken: string, -): Promise { - const sessions = await context.userSessionRepository.listActiveByUserId(userId); - const currentRefreshTokenHash = rawRefreshToken.trim() - ? hashRefreshSessionToken(rawRefreshToken.trim()) - : ''; - - return { - sessions: sessions.map( - (session) => - ({ - sessionId: session.id, - clientType: session.clientType, - clientLabel: buildSessionClientLabel(session), - userAgent: session.userAgent, - ipMasked: maskIpAddress(session.ip), - isCurrent: - Boolean(currentRefreshTokenHash) && - session.refreshTokenHash === currentRefreshTokenHash, - createdAt: session.createdAt, - lastSeenAt: session.lastSeenAt, - expiresAt: session.expiresAt, - }) satisfies AuthSessionSummary, - ), - }; -} - -export async function revokeRefreshSession( - context: AppContext, - rawRefreshToken: string, -) { - const refreshToken = rawRefreshToken.trim(); - if (!refreshToken) { - return; - } - - const refreshTokenHash = hashRefreshSessionToken(refreshToken); - const session = await context.userSessionRepository.findActiveByRefreshTokenHash( - refreshTokenHash, - ); - if (!session || session.revokedAt) { - return; - } - - await context.userSessionRepository.revoke(session.id); -} - -export async function revokeUserSession( - context: AppContext, - userId: string, - sessionId: string, - rawRefreshToken: string, - requestContext: RefreshSessionRequestContext | null = null, -): Promise { - const targetSession = await context.userSessionRepository.findById(sessionId); - if (!targetSession || targetSession.userId !== userId || targetSession.revokedAt) { - throw badRequest('目标登录设备不存在或已失效'); - } - - const currentRefreshTokenHash = rawRefreshToken.trim() - ? hashRefreshSessionToken(rawRefreshToken.trim()) - : ''; - if ( - currentRefreshTokenHash && - targetSession.refreshTokenHash === currentRefreshTokenHash - ) { - throw badRequest('当前设备请直接使用退出登录'); - } - - const revokedSession = await context.userSessionRepository.revokeByUserIdAndSessionId( - userId, - sessionId, - ); - if (!revokedSession) { - throw badRequest('目标登录设备不存在或已失效'); - } - - await writeAuthAuditLog(context, { - userId, - eventType: 'revoke_session', - detail: `已移除设备:${buildSessionClientLabel(revokedSession)}`, - ip: requestContext?.ip ?? null, - userAgent: requestContext?.userAgent ?? null, - metaJson: { - sessionId: revokedSession.id, - targetIp: revokedSession.ip, - targetUserAgent: revokedSession.userAgent, - }, - }); - - return { - ok: true, - }; -} - -export async function logoutAllUserSessions( - context: AppContext, - userId: string, - requestContext: RefreshSessionRequestContext | null = null, -): Promise { - const user = await context.userRepository.incrementTokenVersion(userId); - if (!user) { - throw unauthorized('用户不存在'); - } - - await context.userSessionRepository.revokeAllByUserId(userId); - await writeAuthAuditLog(context, { - userId, - eventType: 'logout_all', - detail: '已退出全部设备登录', - ip: requestContext?.ip ?? null, - userAgent: requestContext?.userAgent ?? null, - }); - return { - ok: true, - }; -} - -export async function entryWithPassword( - context: AppContext, - usernameInput: string, - password: string, - requestContext: RefreshSessionRequestContext | null = null, -): Promise { - const username = normalizeUsername(usernameInput); - validateCredentials(username, password); - - let user = await context.userRepository.findByUsername(username); - let shouldVerifyExistingPassword = Boolean(user); - if (!user) { - const passwordHash = await hashPassword(password); - try { - user = await context.userRepository.create(username, passwordHash); - shouldVerifyExistingPassword = false; - } catch (error) { - if (!isUniqueViolationError(error)) { - throw error; - } - user = await context.userRepository.findByUsername(username); - shouldVerifyExistingPassword = true; - if (!user) { - throw error; - } - } - } - - if (!user) { - throw new Error('failed to resolve user after auth entry'); - } - - if (shouldVerifyExistingPassword) { - const isValid = await verifyPassword(user.passwordHash, password); - if (!isValid) { - throw unauthorized('用户名或密码错误'); - } - } - - await writeAuthAuditLog(context, { - userId: user.id, - eventType: 'password_login', - detail: '使用账号密码完成登录', - ip: requestContext?.ip ?? null, - userAgent: requestContext?.userAgent ?? null, - }); - - return signUserAuthPayload(context, user); -} - -export async function sendPhoneLoginCode( - context: AppContext, - phoneInput: string, - scene: 'login' | 'bind_phone' | 'change_phone' = 'login', - requestContext: RefreshSessionRequestContext | null = null, - captchaParams: { - captchaChallengeId?: string | null; - captchaAnswer?: string | null; - } = {}, -): Promise { - const normalizedPhone = normalizeMainlandChinaPhoneNumber(phoneInput); - await enforceNoActiveRiskBlocks(context, { - phoneNumber: normalizedPhone.e164, - requestContext, - }); - await enforceCaptchaRequirement(context, { - scene, - phoneNumber: normalizedPhone.e164, - requestContext, - captchaChallengeId: captchaParams.captchaChallengeId, - captchaAnswer: captchaParams.captchaAnswer, - }); - await enforceSmsSendRateLimit( - context, - normalizedPhone.e164, - requestContext, - ); - const result = await context.smsVerificationService.sendLoginCode( - normalizedPhone, - ); - const smsEvent = await recordSmsAuthEvent(context, { - phoneNumber: normalizedPhone.e164, - scene, - action: 'send_code', - success: true, - requestContext, - provider: result.provider, - providerRequestId: result.providerRequestId, - providerBizId: result.providerBizId, - providerOutId: result.providerOutId, - deliveryStatus: result.deliveryStatus, - }); - context.logger.info( - { - phone_suffix: normalizedPhone.nationalNumber.slice(-4), - sms_event_id: smsEvent?.id ?? null, - provider: result.provider, - provider_request_id: result.providerRequestId, - provider_biz_id: result.providerBizId, - provider_out_id: result.providerOutId, - delivery_status: result.deliveryStatus, - }, - 'sms verify code accepted by provider', - ); - - return { - ok: true, - cooldownSeconds: result.cooldownSeconds, - expiresInSeconds: result.expiresInSeconds, - providerRequestId: result.providerRequestId, - }; -} - -function normalizeAliyunDeliveryStatus(rawValue: string) { - const normalizedValue = rawValue.trim().toLowerCase(); - if (!normalizedValue) { - return 'unknown' as const; - } - - if ( - normalizedValue === 'success' || - normalizedValue === 'delivered' || - normalizedValue === 'pass' || - normalizedValue === 'ok' - ) { - return 'delivered' as const; - } - - if ( - normalizedValue === 'fail' || - normalizedValue === 'failed' || - normalizedValue === 'failure' || - normalizedValue === 'undelivered' - ) { - return 'failed' as const; - } - - return 'unknown' as const; -} - -export async function handleAliyunSmsDeliveryReport( - context: AppContext, - reportPayload: Record, -) { - const bizId = - typeof reportPayload.BizId === 'string' ? reportPayload.BizId.trim() : ''; - const outId = - typeof reportPayload.OutId === 'string' ? reportPayload.OutId.trim() : ''; - const deliveryStatus = normalizeAliyunDeliveryStatus( - typeof reportPayload.ReceiveStatus === 'string' - ? reportPayload.ReceiveStatus - : typeof reportPayload.Status === 'string' - ? reportPayload.Status - : '', - ); - - const matchedEvent = bizId - ? await context.smsAuthEventRepository.findLatestByProviderBizId(bizId) - : outId - ? await context.smsAuthEventRepository.findLatestByProviderOutId(outId) - : null; - - if (!matchedEvent) { - context.logger.warn( - { - provider: 'aliyun', - provider_biz_id: bizId || null, - provider_out_id: outId || null, - delivery_status: deliveryStatus, - report_payload: reportPayload, - }, - 'aliyun sms delivery report did not match any local event', - ); - - return { - ok: true as const, - matched: false as const, - }; - } - - const reportedAt = new Date().toISOString(); - const updatedEvent = await context.smsAuthEventRepository.updateDeliveryStatus({ - id: matchedEvent.id, - deliveryStatus, - deliveryReportRawJson: reportPayload, - deliveryReportedAt: reportedAt, - }); - - context.logger.info( - { - sms_event_id: matchedEvent.id, - provider: matchedEvent.provider, - provider_biz_id: matchedEvent.providerBizId, - provider_out_id: matchedEvent.providerOutId, - delivery_status: updatedEvent?.deliveryStatus ?? deliveryStatus, - }, - 'aliyun sms delivery report applied', - ); - - return { - ok: true as const, - matched: true as const, - }; -} - -export async function entryWithPhoneCode( - context: AppContext, - phoneInput: string, - verifyCodeInput: string, - requestContext: RefreshSessionRequestContext | null = null, -): Promise { - const normalizedPhone = normalizeMainlandChinaPhoneNumber(phoneInput); - const verifyCode = validateSmsVerifyCode(verifyCodeInput); - - await enforceNoActiveRiskBlocks(context, { - phoneNumber: normalizedPhone.e164, - requestContext, - }); - await enforceSmsVerifyFailureLimit( - context, - normalizedPhone.e164, - requestContext, - ); - - try { - await context.smsVerificationService.verifyLoginCode( - normalizedPhone, - verifyCode, - ); - await recordSmsAuthEvent(context, { - phoneNumber: normalizedPhone.e164, - scene: 'login', - action: 'verify_code', - success: true, - requestContext, - }); - } catch (error) { - await recordSmsAuthEvent(context, { - phoneNumber: normalizedPhone.e164, - scene: 'login', - action: 'verify_code', - success: false, - requestContext, - }); - await applyRiskBlocksIfNeeded(context, { - phoneNumber: normalizedPhone.e164, - requestContext, - }); - throw error; - } - - let user = await context.userRepository.findByPhoneNumber( - normalizedPhone.e164, - ); - - if (!user) { - const passwordHash = await hashPassword(buildRandomPasswordSeed()); - user = await context.userRepository.createPhoneUser({ - username: buildSystemUsername('phone'), - passwordHash, - displayName: normalizedPhone.maskedNationalNumber, - phoneNumber: normalizedPhone.e164, - phoneVerifiedAt: new Date().toISOString(), - }); - } - - if (!user) { - throw new Error('failed to resolve user after phone auth entry'); - } - - await writeAuthAuditLog(context, { - userId: user.id, - eventType: 'phone_login', - detail: `使用手机号 ${normalizedPhone.maskedNationalNumber} 完成登录`, - ip: requestContext?.ip ?? null, - userAgent: requestContext?.userAgent ?? null, - }); - - return signUserAuthPayload(context, user); -} - -export async function startWechatLogin( - context: AppContext, - callbackUrl: string, - redirectPath: string, - requestContext: RefreshSessionRequestContext | null = null, -): Promise { - const stateRecord = context.wechatAuthStates.create(redirectPath); - return { - authorizationUrl: context.wechatAuthService.buildAuthorizationUrl({ - callbackUrl, - state: stateRecord.state, - userAgent: requestContext?.userAgent ?? null, - }), - }; -} - -export async function resolveWechatCallback( - context: AppContext, - params: { - code?: string | null; - mockCode?: string | null; - }, - requestContext: RefreshSessionRequestContext | null = null, -) { - const profile = await context.wechatAuthService.resolveCallbackProfile(params); - - let identity = await context.authIdentityRepository.findWechatIdentityByProfile( - { - providerUid: profile.providerUid, - providerUnionId: profile.providerUnionId, - }, - ); - let user = identity - ? await context.userRepository.findById(identity.userId) - : null; - - if (!user) { - const passwordHash = await hashPassword(buildRandomPasswordSeed()); - user = await context.userRepository.createWechatPendingUser({ - username: buildSystemUsername('wechat'), - passwordHash, - displayName: profile.displayName?.trim() || '微信旅人', - }); - if (!user) { - throw new Error('failed to create pending wechat user'); - } - - identity = await context.authIdentityRepository.createWechatIdentity({ - userId: user.id, - providerUid: profile.providerUid, - providerUnionId: profile.providerUnionId, - displayName: profile.displayName, - avatarUrl: profile.avatarUrl, - metaJson: profile.metaJson, - }); - } - - if (!identity || !user) { - throw new Error('failed to resolve wechat auth identity'); - } - - await writeAuthAuditLog(context, { - userId: user.id, - eventType: 'wechat_login', - detail: '使用微信身份完成登录', - ip: requestContext?.ip ?? null, - userAgent: requestContext?.userAgent ?? null, - }); - - return signUserAuthPayload(context, user); -} - -export async function bindWechatPhone( - context: AppContext, - userId: string, - phoneInput: string, - verifyCodeInput: string, - requestContext: RefreshSessionRequestContext | null = null, -): Promise { - const currentUser = await context.userRepository.findById(userId); - if (!currentUser) { - throw unauthorized('用户不存在'); - } - if (currentUser.accountStatus !== 'pending_bind_phone') { - throw badRequest('当前账号无需绑定手机号'); - } - - const identities = await context.authIdentityRepository.listByUserId(userId); - const hasWechatIdentity = identities.some((identity) => identity.provider === 'wechat'); - if (!hasWechatIdentity) { - throw badRequest('当前账号缺少微信身份,无法绑定手机号'); - } - - const normalizedPhone = normalizeMainlandChinaPhoneNumber(phoneInput); - const verifyCode = validateSmsVerifyCode(verifyCodeInput); - - await enforceNoActiveRiskBlocks(context, { - phoneNumber: normalizedPhone.e164, - requestContext, - }); - await enforceSmsVerifyFailureLimit( - context, - normalizedPhone.e164, - requestContext, - ); - - try { - await context.smsVerificationService.verifyLoginCode( - normalizedPhone, - verifyCode, - ); - await recordSmsAuthEvent(context, { - phoneNumber: normalizedPhone.e164, - scene: 'bind_phone', - action: 'verify_code', - success: true, - requestContext, - }); - } catch (error) { - await recordSmsAuthEvent(context, { - phoneNumber: normalizedPhone.e164, - scene: 'bind_phone', - action: 'verify_code', - success: false, - requestContext, - }); - await applyRiskBlocksIfNeeded(context, { - phoneNumber: normalizedPhone.e164, - requestContext, - }); - throw error; - } - - const existingPhoneUser = await context.userRepository.findByPhoneNumber( - normalizedPhone.e164, - ); - - if (existingPhoneUser && existingPhoneUser.id !== currentUser.id) { - await context.db.query('BEGIN'); - try { - await context.authIdentityRepository.moveWechatIdentitiesToUser( - currentUser.id, - existingPhoneUser.id, - ); - await context.userRepository.deleteUser(currentUser.id); - await context.db.query('COMMIT'); - } catch (error) { - await context.db.query('ROLLBACK'); - throw error; - } - - const mergedUser = await context.userRepository.findById(existingPhoneUser.id); - if (!mergedUser) { - throw new Error('failed to resolve merged phone user'); - } - - await writeAuthAuditLog(context, { - userId: mergedUser.id, - eventType: 'wechat_bind_phone', - detail: `已将微信身份绑定到手机号 ${normalizedPhone.maskedNationalNumber}`, - ip: requestContext?.ip ?? null, - userAgent: requestContext?.userAgent ?? null, - }); - - return signUserAuthPayload(context, mergedUser); - } - - const activatedUser = await context.userRepository.activatePendingWechatUser( - currentUser.id, - { - displayName: - currentUser.displayName?.trim() || normalizedPhone.maskedNationalNumber, - phoneNumber: normalizedPhone.e164, - phoneVerifiedAt: new Date().toISOString(), - }, - ); - - if (!activatedUser) { - throw new Error('failed to activate pending wechat user'); - } - - await writeAuthAuditLog(context, { - userId: activatedUser.id, - eventType: 'wechat_bind_phone', - detail: `已绑定手机号 ${normalizedPhone.maskedNationalNumber}`, - ip: requestContext?.ip ?? null, - userAgent: requestContext?.userAgent ?? null, - }); - - return signUserAuthPayload(context, activatedUser); -} - -export async function changeUserPhone( - context: AppContext, - userId: string, - phoneInput: string, - verifyCodeInput: string, - requestContext: RefreshSessionRequestContext | null = null, -): Promise { - const currentUser = await context.userRepository.findById(userId); - if (!currentUser) { - throw unauthorized('用户不存在'); - } - if (currentUser.accountStatus !== 'active') { - throw badRequest('当前账号状态不允许更换手机号'); - } - - const normalizedPhone = normalizeMainlandChinaPhoneNumber(phoneInput); - const verifyCode = validateSmsVerifyCode(verifyCodeInput); - - if (currentUser.phoneNumber === normalizedPhone.e164) { - throw badRequest('新手机号不能与当前手机号相同'); - } - - const existingPhoneUser = await context.userRepository.findByPhoneNumber( - normalizedPhone.e164, - ); - if (existingPhoneUser && existingPhoneUser.id !== currentUser.id) { - throw conflict('该手机号已绑定其他账号'); - } - - await enforceNoActiveRiskBlocks(context, { - phoneNumber: normalizedPhone.e164, - requestContext, - }); - await enforceSmsVerifyFailureLimit( - context, - normalizedPhone.e164, - requestContext, - ); - - try { - await context.smsVerificationService.verifyLoginCode( - normalizedPhone, - verifyCode, - ); - await recordSmsAuthEvent(context, { - phoneNumber: normalizedPhone.e164, - scene: 'change_phone', - action: 'verify_code', - success: true, - requestContext, - }); - } catch (error) { - await recordSmsAuthEvent(context, { - phoneNumber: normalizedPhone.e164, - scene: 'change_phone', - action: 'verify_code', - success: false, - requestContext, - }); - await applyRiskBlocksIfNeeded(context, { - phoneNumber: normalizedPhone.e164, - requestContext, - }); - throw error; - } - - const updatedUser = await context.userRepository.updatePhoneInfo(userId, { - phoneNumber: normalizedPhone.e164, - phoneVerifiedAt: new Date().toISOString(), - displayName: resolveDisplayNameAfterPhoneChange( - currentUser, - normalizedPhone.e164, - ), - }); - - if (!updatedUser) { - throw new Error('failed to update user phone'); - } - - await writeAuthAuditLog(context, { - userId: updatedUser.id, - eventType: 'change_phone', - detail: `已更换手机号为 ${normalizedPhone.maskedNationalNumber}`, - ip: requestContext?.ip ?? null, - userAgent: requestContext?.userAgent ?? null, - }); - - return { - user: await toAuthUser(context, updatedUser), - }; -} - -export async function logoutUser( - context: AppContext, - userId: string, - requestContext: RefreshSessionRequestContext | null = null, -): Promise { - const user = await context.userRepository.incrementTokenVersion(userId); - if (!user) { - throw unauthorized('用户不存在'); - } - - await writeAuthAuditLog(context, { - userId, - eventType: 'logout', - detail: '已退出当前设备登录', - ip: requestContext?.ip ?? null, - userAgent: requestContext?.userAgent ?? null, - }); - - return { - ok: true as const, - }; -} diff --git a/server-node/src/auth/password.ts b/server-node/src/auth/password.ts deleted file mode 100644 index e91ad827..00000000 --- a/server-node/src/auth/password.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Algorithm, hash, verify } from '@node-rs/argon2'; - -export async function hashPassword(password: string) { - return hash(password, { - algorithm: Algorithm.Argon2id, - memoryCost: 19456, - timeCost: 2, - parallelism: 1, - }); -} - -export async function verifyPassword(passwordHash: string, password: string) { - return verify(passwordHash, password, { - algorithm: Algorithm.Argon2id, - }); -} diff --git a/server-node/src/auth/phoneNumber.ts b/server-node/src/auth/phoneNumber.ts deleted file mode 100644 index 54dca8a2..00000000 --- a/server-node/src/auth/phoneNumber.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { badRequest } from '../errors.js'; - -export type NormalizedPhoneNumber = { - countryCode: string; - nationalNumber: string; - e164: string; - maskedNationalNumber: string; -}; - -function stripPhoneInput(input: string) { - return input.replace(/[^\d+]/gu, '').trim(); -} - -export function maskNationalPhoneNumber(phoneNumber: string) { - if (phoneNumber.length < 7) { - return phoneNumber; - } - - return `${phoneNumber.slice(0, 3)}****${phoneNumber.slice(-4)}`; -} - -export function normalizeMainlandChinaPhoneNumber( - phoneInput: string, -): NormalizedPhoneNumber { - const trimmed = stripPhoneInput(phoneInput); - if (!trimmed) { - throw badRequest('请输入手机号'); - } - - let nationalNumber = trimmed; - if (nationalNumber.startsWith('+86')) { - nationalNumber = nationalNumber.slice(3); - } else if (nationalNumber.startsWith('86') && nationalNumber.length === 13) { - nationalNumber = nationalNumber.slice(2); - } - - if (!/^1\d{10}$/u.test(nationalNumber)) { - throw badRequest('请输入正确的中国大陆手机号'); - } - - return { - countryCode: '86', - nationalNumber, - e164: `+86${nationalNumber}`, - maskedNationalNumber: maskNationalPhoneNumber(nationalNumber), - }; -} - -export function validateSmsVerifyCode(verifyCode: string) { - const normalizedVerifyCode = verifyCode.trim(); - if (!/^[A-Za-z0-9]{4,8}$/u.test(normalizedVerifyCode)) { - throw badRequest('请输入正确的验证码'); - } - return normalizedVerifyCode; -} diff --git a/server-node/src/auth/refreshSessionCookie.ts b/server-node/src/auth/refreshSessionCookie.ts deleted file mode 100644 index 1da7a543..00000000 --- a/server-node/src/auth/refreshSessionCookie.ts +++ /dev/null @@ -1,111 +0,0 @@ -import crypto from 'node:crypto'; - -import type { Request, Response } from 'express'; - -import type { AppConfig } from '../config.js'; - -export type RefreshSessionRequestContext = { - clientType: string; - userAgent: string | null; - ip: string | null; -}; - -function buildCookieParts( - config: AppConfig, - value: string, - options: { - maxAgeSeconds: number; - }, -) { - const parts = [ - `${config.authSession.refreshCookieName}=${encodeURIComponent(value)}`, - `Path=${config.authSession.refreshCookiePath}`, - 'HttpOnly', - `SameSite=${config.authSession.refreshCookieSameSite}`, - `Max-Age=${Math.max(0, Math.floor(options.maxAgeSeconds))}`, - ]; - - if (config.authSession.refreshCookieSecure) { - parts.push('Secure'); - } - - return parts.join('; '); -} - -function appendSetCookieHeader(response: Response, cookieValue: string) { - const currentHeader = response.getHeader('Set-Cookie'); - if (!currentHeader) { - response.setHeader('Set-Cookie', cookieValue); - return; - } - - if (Array.isArray(currentHeader)) { - response.setHeader('Set-Cookie', [...currentHeader, cookieValue]); - return; - } - - response.setHeader('Set-Cookie', [String(currentHeader), cookieValue]); -} - -export function hashRefreshSessionToken(token: string) { - return crypto.createHash('sha256').update(token).digest('hex'); -} - -export function createRefreshSessionToken() { - return crypto.randomBytes(32).toString('base64url'); -} - -export function setRefreshSessionCookie( - response: Response, - config: AppConfig, - token: string, - maxAgeSeconds: number, -) { - appendSetCookieHeader( - response, - buildCookieParts(config, token, { - maxAgeSeconds, - }), - ); -} - -export function clearRefreshSessionCookie(response: Response, config: AppConfig) { - appendSetCookieHeader( - response, - buildCookieParts(config, '', { - maxAgeSeconds: 0, - }), - ); -} - -export function readRefreshSessionToken(request: Request, config: AppConfig) { - const cookieHeader = request.header('cookie')?.trim() || ''; - if (!cookieHeader) { - return ''; - } - - const cookieEntries = cookieHeader.split(';'); - for (const entry of cookieEntries) { - const [rawName, ...valueParts] = entry.split('='); - const name = rawName?.trim(); - if (name !== config.authSession.refreshCookieName) { - continue; - } - - const rawValue = valueParts.join('=').trim(); - return rawValue ? decodeURIComponent(rawValue) : ''; - } - - return ''; -} - -export function buildRefreshSessionRequestContext( - request: Request, -): RefreshSessionRequestContext { - const userAgent = request.header('user-agent')?.trim() || null; - return { - clientType: 'browser', - userAgent, - ip: request.ip || null, - }; -} diff --git a/server-node/src/auth/token.ts b/server-node/src/auth/token.ts deleted file mode 100644 index 5033f6d4..00000000 --- a/server-node/src/auth/token.ts +++ /dev/null @@ -1,63 +0,0 @@ -import crypto from 'node:crypto'; - -import { jwtVerify, SignJWT } from 'jose'; - -import type { AppConfig } from '../config.js'; -import { unauthorized } from '../errors.js'; - -if (!globalThis.crypto?.subtle) { - Object.assign(globalThis, { - crypto: crypto.webcrypto, - }); -} - -if (typeof globalThis.structuredClone !== 'function') { - Object.assign(globalThis, { - structuredClone(value: T) { - return JSON.parse(JSON.stringify(value)) as T; - }, - }); -} - -export type AccessTokenClaims = { - userId: string; - tokenVersion: number; -}; - -function getSecret(config: AppConfig) { - return new TextEncoder().encode(config.jwtSecret); -} - -export async function signAccessToken( - claims: AccessTokenClaims, - config: AppConfig, -) { - return new SignJWT({ ver: claims.tokenVersion }) - .setProtectedHeader({ alg: 'HS256', typ: 'JWT' }) - .setSubject(claims.userId) - .setIssuer(config.jwtIssuer) - .setIssuedAt() - .setExpirationTime(config.jwtExpiresIn) - .sign(getSecret(config)); -} - -export async function verifyAccessToken(token: string, config: AppConfig) { - try { - const { payload } = await jwtVerify(token, getSecret(config), { - issuer: config.jwtIssuer, - }); - const userId = typeof payload.sub === 'string' ? payload.sub : ''; - const tokenVersion = typeof payload.ver === 'number' ? payload.ver : NaN; - - if (!userId || !Number.isFinite(tokenVersion)) { - throw unauthorized('JWT 内容无效'); - } - - return { - userId, - tokenVersion, - } satisfies AccessTokenClaims; - } catch (error) { - throw unauthorized('JWT 校验失败'); - } -} diff --git a/server-node/src/bridges/legacyInventoryRuntimeBridge.ts b/server-node/src/bridges/legacyInventoryRuntimeBridge.ts deleted file mode 100644 index 3c9e1cbb..00000000 --- a/server-node/src/bridges/legacyInventoryRuntimeBridge.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Temporary bridge for legacy pure inventory/build mutation logic from src/**. -export { appendBuildBuffs } from '../modules/runtime/runtimeBuildModule.js'; -export { - applyEquipmentLoadoutToState, - getEquipmentSlotFromItem, - getEquipmentSlotLabel, -} from '../modules/runtime/runtimeEquipmentModule.js'; -export { - buildForgeSuccessText, - executeDismantleItem, - executeForgeRecipe, - executeReforgeItem, - getForgeRecipeViews, - getReforgeCostView, -} from '../modules/runtime/runtimeForgeModule.js'; -export { - buildInventoryUseResultText, - isInventoryItemUsable, - resolveInventoryItemUseEffect, -} from '../modules/runtime/runtimeInventoryEffectsModule.js'; -export { - addInventoryItems, - incrementGameRuntimeStats, - removeInventoryItem, -} from '../modules/runtime/runtimeStatePrimitives.js'; diff --git a/server-node/src/bridges/legacyNpcTask6Bridge.ts b/server-node/src/bridges/legacyNpcTask6Bridge.ts deleted file mode 100644 index 697ebc56..00000000 --- a/server-node/src/bridges/legacyNpcTask6Bridge.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Temporary bridge for legacy pure NPC inventory/task6 logic from src/**. -export { buildRelationState } from '../modules/runtime/runtimeStatePrimitives.js'; -export { - formatCurrency, - getNpcBuybackPrice, - getNpcPurchasePrice, -} from '../modules/runtime/runtimeEconomyPrimitives.js'; -export { - applyStoryChoiceToStanceProfile, - buildInitialNpcState, - buildNpcGiftCommitActionText, - buildNpcGiftResultText, - buildNpcTradeTransactionActionText, - buildNpcTradeTransactionResultText, - getGiftCandidates, - syncNpcTradeInventory, -} from '../modules/npc/npcTask6Primitives.js'; -export { - markNpcFirstMeaningfulContactResolved, - normalizeNpcPersistentState, -} from '../modules/runtime/runtimeNpcStatePrimitives.js'; -export { appendStoryEngineCarrierMemory } from '../modules/runtime/runtimeNarrativeMemory.js'; -export { - addInventoryItems, - removeInventoryItem, -} from '../modules/runtime/runtimeStatePrimitives.js'; diff --git a/server-node/src/bridges/legacyQuestProgressBridge.ts b/server-node/src/bridges/legacyQuestProgressBridge.ts deleted file mode 100644 index 2a62f1bb..00000000 --- a/server-node/src/bridges/legacyQuestProgressBridge.ts +++ /dev/null @@ -1,15 +0,0 @@ -// Temporary bridge for legacy pure quest progression logic from src/**. -export { - acceptQuest, - buildQuestAcceptResultText, - buildQuestForEncounter, - buildQuestTurnInResultText, - applyQuestProgressSignal, - getQuestForIssuer, - buildChapterQuestForScene, - findQuestById, - isQuestReadyToClaim, - markQuestCompletionNotified, - markQuestTurnedIn, - normalizeQuestLogEntries, -} from '../modules/quest/runtimeQuestModule.js'; diff --git a/server-node/src/bridges/legacyQuestRuntimeBridge.ts b/server-node/src/bridges/legacyQuestRuntimeBridge.ts deleted file mode 100644 index 1564f469..00000000 --- a/server-node/src/bridges/legacyQuestRuntimeBridge.ts +++ /dev/null @@ -1,9 +0,0 @@ -// Temporary bridge for legacy pure quest runtime composition from src/**. -export { - buildFallbackQuestIntent, - compileQuestIntentToQuest, - evaluateQuestOpportunity, - buildQuestIntentPrompt, - buildQuestGenerationContextFromState, - QUEST_INTENT_SYSTEM_PROMPT, -} from '../modules/quest/runtimeQuestModule.js'; diff --git a/server-node/src/bridges/legacyRuntimeItemBridge.ts b/server-node/src/bridges/legacyRuntimeItemBridge.ts deleted file mode 100644 index 235917b8..00000000 --- a/server-node/src/bridges/legacyRuntimeItemBridge.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Temporary bridge for legacy pure runtime item composition from src/**. -export { - buildRuntimeItemAiIntent, - buildRuntimeItemIntentPrompt, - RUNTIME_ITEM_INTENT_SYSTEM_PROMPT, -} from '../modules/runtime-item/runtimeItemModule.js'; diff --git a/server-node/src/bridges/legacyTreasureRuntimeBridge.ts b/server-node/src/bridges/legacyTreasureRuntimeBridge.ts deleted file mode 100644 index 01d15efa..00000000 --- a/server-node/src/bridges/legacyTreasureRuntimeBridge.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Temporary bridge for legacy pure treasure/runtime item logic from src/**. -export { buildTreasureResultText } from '../modules/runtime/runtimeTreasureTexts.js'; -export { resolveTreasureReward } from '../modules/runtime-item/runtimeTreasureModule.js'; diff --git a/server-node/src/config.test.ts b/server-node/src/config.test.ts deleted file mode 100644 index 48d8fdb2..00000000 --- a/server-node/src/config.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import test from 'node:test'; - -import { loadConfig } from './config.ts'; - -function createTempProjectRoot(prefix: string) { - return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); -} - -test('development config auto-enables aliyun sms auth when local credentials are provided', () => { - const projectRoot = createTempProjectRoot('genarrative-config-dev-'); - fs.writeFileSync( - path.join(projectRoot, '.env.example'), - 'SMS_AUTH_ENABLED=\"false\"\nSMS_AUTH_PROVIDER=\"aliyun\"\n', - 'utf8', - ); - fs.writeFileSync( - path.join(projectRoot, '.env.local'), - 'ALIYUN_SMS_ACCESS_KEY_ID=\"test-ak\"\nALIYUN_SMS_ACCESS_KEY_SECRET=\"test-sk\"\n', - 'utf8', - ); - - const config = loadConfig({ - projectRoot, - env: { - NODE_ENV: 'development', - }, - }); - - assert.equal(config.smsAuth.enabled, true); - assert.equal(config.smsAuth.provider, 'aliyun'); - assert.equal(config.smsAuth.accessKeyId, 'test-ak'); - assert.equal(config.smsAuth.accessKeySecret, 'test-sk'); -}); - -test('development config respects explicit local sms auth overrides', () => { - const projectRoot = createTempProjectRoot('genarrative-config-local-'); - fs.writeFileSync( - path.join(projectRoot, '.env.example'), - 'SMS_AUTH_ENABLED=\"false\"\nSMS_AUTH_PROVIDER=\"aliyun\"\n', - 'utf8', - ); - fs.writeFileSync( - path.join(projectRoot, '.env.local'), - 'SMS_AUTH_ENABLED=\"false\"\nSMS_AUTH_PROVIDER=\"aliyun\"\nALIYUN_SMS_ACCESS_KEY_ID=\"test-ak\"\nALIYUN_SMS_ACCESS_KEY_SECRET=\"test-sk\"\n', - 'utf8', - ); - - const config = loadConfig({ - projectRoot, - env: { - NODE_ENV: 'development', - }, - }); - - assert.equal(config.smsAuth.enabled, false); - assert.equal(config.smsAuth.provider, 'aliyun'); -}); diff --git a/server-node/src/config.ts b/server-node/src/config.ts deleted file mode 100644 index 62aa943e..00000000 --- a/server-node/src/config.ts +++ /dev/null @@ -1,547 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; - -export type AppConfig = { - nodeEnv: string; - projectRoot: string; - publicDir: string; - logsDir: string; - dataDir: string; - rawEnv: Record; - databaseUrl: string; - serverAddr: string; - logLevel: 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace' | 'silent'; - editorApiEnabled: boolean; - assetsApiEnabled: boolean; - jwtSecret: string; - jwtExpiresIn: string; - jwtIssuer: string; - llm: { - baseUrl: string; - apiKey: string; - model: string; - }; - dashScope: { - baseUrl: string; - apiKey: string; - imageModel: string; - requestTimeoutMs: number; - }; - smsAuth: { - enabled: boolean; - provider: 'aliyun' | 'mock'; - endpoint: string; - accessKeyId: string; - accessKeySecret: string; - signName: string; - templateCode: string; - templateParamKey: string; - countryCode: string; - schemeName: string; - codeLength: number; - codeType: number; - validTimeSeconds: number; - intervalSeconds: number; - duplicatePolicy: number; - caseAuthPolicy: number; - returnVerifyCode: boolean; - mockVerifyCode: string; - maxSendPerPhonePerDay: number; - maxSendPerIpPerHour: number; - maxVerifyFailuresPerPhonePerHour: number; - maxVerifyFailuresPerIpPerHour: number; - captchaTtlSeconds: number; - captchaTriggerVerifyFailuresPerPhone: number; - captchaTriggerVerifyFailuresPerIp: number; - blockPhoneFailureThreshold: number; - blockIpFailureThreshold: number; - blockPhoneDurationMinutes: number; - blockIpDurationMinutes: number; - }; - wechatAuth: { - enabled: boolean; - provider: 'wechat' | 'mock'; - appId: string; - appSecret: string; - authorizeEndpoint: string; - accessTokenEndpoint: string; - userInfoEndpoint: string; - callbackPath: string; - defaultRedirectPath: string; - mockUserId: string; - mockUnionId: string; - mockDisplayName: string; - mockAvatarUrl: string; - }; - authSession: { - accessCookieName: string; - accessCookieTtlSeconds: number; - accessCookieSecure: boolean; - accessCookieSameSite: 'Lax' | 'Strict' | 'None'; - accessCookiePath: string; - refreshCookieName: string; - refreshSessionTtlDays: number; - refreshCookieSecure: boolean; - refreshCookieSameSite: 'Lax' | 'Strict' | 'None'; - refreshCookiePath: string; - }; -}; - -type LoadConfigOptions = { - env?: NodeJS.ProcessEnv; - projectRoot?: string; -}; - -function parseEnvContents(contents: string) { - return contents - .split(/\r?\n/u) - .reduce>((envMap, rawLine) => { - const line = rawLine.trim(); - if (!line || line.startsWith('#')) { - return envMap; - } - - const separatorIndex = line.indexOf('='); - if (separatorIndex < 0) { - return envMap; - } - - const key = line.slice(0, separatorIndex).trim(); - let value = line.slice(separatorIndex + 1).trim(); - - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - - envMap[key] = value; - return envMap; - }, {}); -} - -function readEnvFile(filePath: string) { - if (!fs.existsSync(filePath)) { - return {}; - } - - return parseEnvContents(fs.readFileSync(filePath, 'utf8')); -} - -function resolveDefaultProjectRoot() { - const cwd = process.cwd(); - return path.basename(cwd) === 'server-node' - ? path.resolve(cwd, '..') - : cwd; -} - -function readMergedEnv( - exampleEnv: Record, - localEnv: Record, - processEnv: NodeJS.ProcessEnv, -) { - return { - ...exampleEnv, - ...localEnv, - ...processEnv, - }; -} - -function hasOwnEnvKey( - env: Record, - key: string, -) { - return Object.prototype.hasOwnProperty.call(env, key); -} - -function readBooleanOverride( - env: Record, - overrideSources: Array>, - key: string, - fallback: boolean, -) { - const hasOverride = overrideSources.some((source) => hasOwnEnvKey(source, key)); - if (!hasOverride) { - return fallback; - } - - return readBoolean(env, key, fallback); -} - -function readString( - env: Record, - key: string, - fallback: string, -) { - const value = env[key]?.trim(); - return value ? value : fallback; -} - -function readPositiveInt( - env: Record, - key: string, - fallback: number, -) { - const parsed = Number(env[key]); - return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : fallback; -} - -function readIntInRange( - env: Record, - key: string, - fallback: number, - options: { - min: number; - max: number; - }, -) { - const parsed = Number(env[key]); - if (!Number.isFinite(parsed)) { - return fallback; - } - - const rounded = Math.round(parsed); - if (rounded < options.min || rounded > options.max) { - return fallback; - } - - return rounded; -} - -function readBoolean( - env: Record, - key: string, - fallback: boolean, -) { - const value = env[key]?.trim().toLowerCase(); - if (!value) { - return fallback; - } - if (value === '1' || value === 'true' || value === 'yes' || value === 'on') { - return true; - } - if (value === '0' || value === 'false' || value === 'no' || value === 'off') { - return false; - } - return fallback; -} - -export function loadConfig(options: LoadConfigOptions = {}): AppConfig { - const projectRoot = options.projectRoot ?? resolveDefaultProjectRoot(); - const exampleEnv = readEnvFile(path.join(projectRoot, '.env.example')); - const localEnv = readEnvFile(path.join(projectRoot, '.env.local')); - const processEnv = options.env ?? process.env; - const env = readMergedEnv(exampleEnv, localEnv, processEnv); - const logsDir = path.join(projectRoot, 'server-node', 'logs'); - const dataDir = path.join(projectRoot, 'server-node', 'data'); - const nodeEnv = readString(env, 'NODE_ENV', 'development'); - const defaultEditorApiEnabled = nodeEnv !== 'production'; - const editorApiEnabled = readBoolean( - env, - 'EDITOR_API_ENABLED', - defaultEditorApiEnabled, - ); - const smsProviderFromEnv = readString( - env, - 'SMS_AUTH_PROVIDER', - nodeEnv === 'test' ? 'mock' : 'aliyun', - ) as AppConfig['smsAuth']['provider']; - const smsAccessKeyId = readString(env, 'ALIYUN_SMS_ACCESS_KEY_ID', ''); - const smsAccessKeySecret = readString(env, 'ALIYUN_SMS_ACCESS_KEY_SECRET', ''); - const smsProvider = smsProviderFromEnv; - const defaultSmsEnabled = - smsProvider === 'mock' || - Boolean(smsAccessKeyId && smsAccessKeySecret); - const smsEnabled = readBooleanOverride( - env, - [localEnv, processEnv], - 'SMS_AUTH_ENABLED', - defaultSmsEnabled, - ); - const wechatProvider = readString( - env, - 'WECHAT_AUTH_PROVIDER', - nodeEnv === 'test' ? 'mock' : 'wechat', - ) as AppConfig['wechatAuth']['provider']; - const wechatAppId = readString(env, 'WECHAT_APP_ID', ''); - const wechatAppSecret = readString(env, 'WECHAT_APP_SECRET', ''); - const defaultWechatEnabled = - wechatProvider === 'mock' || Boolean(wechatAppId && wechatAppSecret); - const wechatEnabled = readBooleanOverride( - env, - [localEnv, processEnv], - 'WECHAT_AUTH_ENABLED', - defaultWechatEnabled, - ); - const refreshSameSite = readString( - env, - 'AUTH_REFRESH_COOKIE_SAME_SITE', - 'Lax', - ); - const accessSameSite = readString( - env, - 'AUTH_ACCESS_COOKIE_SAME_SITE', - 'Lax', - ); - - return { - nodeEnv, - projectRoot, - publicDir: path.join(projectRoot, 'public'), - logsDir, - dataDir, - rawEnv: Object.fromEntries( - Object.entries(env).flatMap(([key, value]) => - typeof value === 'string' ? [[key, value]] : [], - ), - ), - databaseUrl: readString( - env, - 'DATABASE_URL', - 'postgresql://postgres:postgres@127.0.0.1:5432/genarrative', - ), - serverAddr: readString(env, 'NODE_SERVER_ADDR', ':8081'), - logLevel: readString(env, 'LOG_LEVEL', 'info') as AppConfig['logLevel'], - editorApiEnabled, - assetsApiEnabled: readBoolean( - env, - 'ASSETS_API_ENABLED', - editorApiEnabled, - ), - jwtSecret: readString(env, 'JWT_SECRET', 'genarrative-dev-secret'), - jwtExpiresIn: readString(env, 'JWT_EXPIRES_IN', '2h'), - jwtIssuer: readString(env, 'JWT_ISSUER', 'genarrative-server-node'), - llm: { - baseUrl: readString( - env, - 'LLM_BASE_URL', - 'https://ark.cn-beijing.volces.com/api/v3', - ), - apiKey: - env.LLM_API_KEY?.trim() || - env.ARK_API_KEY?.trim() || - env.VITE_LLM_API_KEY?.trim() || - '', - model: readString( - env, - 'LLM_MODEL', - readString( - env, - 'VITE_LLM_MODEL', - 'doubao-1-5-pro-32k-character-250715', - ), - ), - }, - dashScope: { - baseUrl: readString( - env, - 'DASHSCOPE_BASE_URL', - 'https://dashscope.aliyuncs.com/api/v1', - ), - apiKey: env.DASHSCOPE_API_KEY?.trim() || '', - imageModel: readString(env, 'DASHSCOPE_IMAGE_MODEL', 'wan2.2-t2i-flash'), - requestTimeoutMs: readPositiveInt( - env, - 'DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS', - 150000, - ), - }, - smsAuth: { - enabled: smsEnabled, - provider: smsProvider, - endpoint: readString( - env, - 'ALIYUN_SMS_ENDPOINT', - 'dypnsapi.aliyuncs.com', - ), - accessKeyId: smsAccessKeyId, - accessKeySecret: smsAccessKeySecret, - signName: readString( - env, - 'ALIYUN_SMS_SIGN_NAME', - '速通互联验证码', - ), - templateCode: readString(env, 'ALIYUN_SMS_TEMPLATE_CODE', '100001'), - templateParamKey: readString( - env, - 'ALIYUN_SMS_TEMPLATE_PARAM_KEY', - 'code', - ), - countryCode: readString(env, 'ALIYUN_SMS_COUNTRY_CODE', '86'), - schemeName: readString(env, 'ALIYUN_SMS_SCHEME_NAME', ''), - codeLength: readIntInRange(env, 'ALIYUN_SMS_CODE_LENGTH', 6, { - min: 4, - max: 8, - }), - codeType: readIntInRange(env, 'ALIYUN_SMS_CODE_TYPE', 1, { - min: 1, - max: 7, - }), - validTimeSeconds: readPositiveInt( - env, - 'ALIYUN_SMS_VALID_TIME_SECONDS', - 300, - ), - intervalSeconds: readPositiveInt( - env, - 'ALIYUN_SMS_INTERVAL_SECONDS', - 60, - ), - duplicatePolicy: readIntInRange( - env, - 'ALIYUN_SMS_DUPLICATE_POLICY', - 1, - { min: 1, max: 2 }, - ), - caseAuthPolicy: readIntInRange( - env, - 'ALIYUN_SMS_CASE_AUTH_POLICY', - 1, - { min: 1, max: 2 }, - ), - returnVerifyCode: readBoolean( - env, - 'ALIYUN_SMS_RETURN_VERIFY_CODE', - false, - ), - mockVerifyCode: readString(env, 'SMS_AUTH_MOCK_VERIFY_CODE', '123456'), - maxSendPerPhonePerDay: readPositiveInt( - env, - 'SMS_AUTH_MAX_SEND_PER_PHONE_PER_DAY', - 20, - ), - maxSendPerIpPerHour: readPositiveInt( - env, - 'SMS_AUTH_MAX_SEND_PER_IP_PER_HOUR', - 30, - ), - maxVerifyFailuresPerPhonePerHour: readPositiveInt( - env, - 'SMS_AUTH_MAX_VERIFY_FAILURES_PER_PHONE_PER_HOUR', - 12, - ), - maxVerifyFailuresPerIpPerHour: readPositiveInt( - env, - 'SMS_AUTH_MAX_VERIFY_FAILURES_PER_IP_PER_HOUR', - 24, - ), - captchaTtlSeconds: readPositiveInt( - env, - 'SMS_AUTH_CAPTCHA_TTL_SECONDS', - 180, - ), - captchaTriggerVerifyFailuresPerPhone: readPositiveInt( - env, - 'SMS_AUTH_CAPTCHA_TRIGGER_VERIFY_FAILURES_PER_PHONE', - 3, - ), - captchaTriggerVerifyFailuresPerIp: readPositiveInt( - env, - 'SMS_AUTH_CAPTCHA_TRIGGER_VERIFY_FAILURES_PER_IP', - 5, - ), - blockPhoneFailureThreshold: readPositiveInt( - env, - 'SMS_AUTH_BLOCK_PHONE_FAILURE_THRESHOLD', - 6, - ), - blockIpFailureThreshold: readPositiveInt( - env, - 'SMS_AUTH_BLOCK_IP_FAILURE_THRESHOLD', - 10, - ), - blockPhoneDurationMinutes: readPositiveInt( - env, - 'SMS_AUTH_BLOCK_PHONE_DURATION_MINUTES', - 30, - ), - blockIpDurationMinutes: readPositiveInt( - env, - 'SMS_AUTH_BLOCK_IP_DURATION_MINUTES', - 30, - ), - }, - wechatAuth: { - enabled: wechatEnabled, - provider: wechatProvider, - appId: wechatAppId, - appSecret: wechatAppSecret, - authorizeEndpoint: readString( - env, - 'WECHAT_AUTHORIZE_ENDPOINT', - 'https://open.weixin.qq.com/connect/qrconnect', - ), - accessTokenEndpoint: readString( - env, - 'WECHAT_ACCESS_TOKEN_ENDPOINT', - 'https://api.weixin.qq.com/sns/oauth2/access_token', - ), - userInfoEndpoint: readString( - env, - 'WECHAT_USER_INFO_ENDPOINT', - 'https://api.weixin.qq.com/sns/userinfo', - ), - callbackPath: readString( - env, - 'WECHAT_CALLBACK_PATH', - '/api/auth/wechat/callback', - ), - defaultRedirectPath: readString(env, 'WECHAT_REDIRECT_PATH', '/'), - mockUserId: readString(env, 'WECHAT_MOCK_USER_ID', 'mock_wechat_user'), - mockUnionId: readString(env, 'WECHAT_MOCK_UNION_ID', 'mock_wechat_union'), - mockDisplayName: readString(env, 'WECHAT_MOCK_DISPLAY_NAME', '微信旅人'), - mockAvatarUrl: readString(env, 'WECHAT_MOCK_AVATAR_URL', ''), - }, - authSession: { - accessCookieName: readString( - env, - 'AUTH_ACCESS_COOKIE_NAME', - 'genarrative_access_session', - ), - accessCookieTtlSeconds: readPositiveInt( - env, - 'AUTH_ACCESS_COOKIE_TTL_SECONDS', - 7200, - ), - accessCookieSecure: readBoolean( - env, - 'AUTH_ACCESS_COOKIE_SECURE', - readString(env, 'NODE_ENV', 'development') === 'production', - ), - accessCookieSameSite: - accessSameSite === 'None' || accessSameSite === 'Strict' - ? (accessSameSite as AppConfig['authSession']['accessCookieSameSite']) - : 'Lax', - accessCookiePath: readString( - env, - 'AUTH_ACCESS_COOKIE_PATH', - '/', - ), - refreshCookieName: readString( - env, - 'AUTH_REFRESH_COOKIE_NAME', - 'genarrative_refresh_session', - ), - refreshSessionTtlDays: readPositiveInt( - env, - 'AUTH_REFRESH_SESSION_TTL_DAYS', - 30, - ), - refreshCookieSecure: readBoolean( - env, - 'AUTH_REFRESH_COOKIE_SECURE', - readString(env, 'NODE_ENV', 'development') === 'production', - ), - refreshCookieSameSite: - refreshSameSite === 'None' || refreshSameSite === 'Strict' - ? (refreshSameSite as AppConfig['authSession']['refreshCookieSameSite']) - : 'Lax', - refreshCookiePath: readString( - env, - 'AUTH_REFRESH_COOKIE_PATH', - '/api/auth', - ), - }, - }; -} diff --git a/server-node/src/context.ts b/server-node/src/context.ts deleted file mode 100644 index 01b0503d..00000000 --- a/server-node/src/context.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { Logger } from 'pino'; - -import type { AppConfig } from './config.js'; -import type { AppDatabase } from './db.js'; -import { AuthAuditLogRepository } from './repositories/authAuditLogRepository.js'; -import { AuthIdentityRepository } from './repositories/authIdentityRepository.js'; -import { AuthRiskBlockRepository } from './repositories/authRiskBlockRepository.js'; -import type { RpgAgentSessionRepository } from './repositories/RpgAgentSessionRepository.js'; -import type { RpgSaveArchiveRepository } from './repositories/rpg-entry/RpgSaveArchiveRepository.js'; -import type { RpgWorldLibraryRepository } from './repositories/rpg-entry/RpgWorldLibraryRepository.js'; -import type { RpgBrowseHistoryRepository } from './repositories/rpg-profile/RpgBrowseHistoryRepository.js'; -import type { RpgProfileDashboardRepository } from './repositories/rpg-profile/RpgProfileDashboardRepository.js'; -import type { RpgRuntimeSnapshotRepository } from './repositories/rpg-runtime/RpgRuntimeSnapshotRepository.js'; -import type { RpgWorldProfileRepository } from './repositories/RpgWorldProfileRepository.js'; -import { RuntimeRepository } from './repositories/runtimeRepository.js'; -import { SmsAuthEventRepository } from './repositories/smsAuthEventRepository.js'; -import { UserRepository } from './repositories/userRepository.js'; -import { UserSessionRepository } from './repositories/userSessionRepository.js'; -import { CaptchaChallengeStore } from './services/captchaChallengeStore.js'; -import { CustomWorldAgentOrchestrator } from './services/customWorldAgentOrchestrator.js'; -import { CustomWorldAgentSessionStore } from './services/customWorldAgentSessionStore.js'; -import { UpstreamLlmClient } from './services/llmClient.js'; -import type { RpgWorldWorkSummaryService } from './services/RpgWorldWorkSummaryService.js'; -import type { SmsVerificationService } from './services/smsVerificationService.js'; -import type { WechatAuthService } from './services/wechatAuthService.js'; -import { WechatAuthStateStore } from './services/wechatAuthStateStore.js'; - -export type AppContext = { - config: AppConfig; - logger: Logger; - db: AppDatabase; - userRepository: UserRepository; - authIdentityRepository: AuthIdentityRepository; - authAuditLogRepository: AuthAuditLogRepository; - authRiskBlockRepository: AuthRiskBlockRepository; - smsAuthEventRepository: SmsAuthEventRepository; - userSessionRepository: UserSessionRepository; - rpgAgentSessionRepository: RpgAgentSessionRepository; - rpgWorldProfileRepository: RpgWorldProfileRepository; - rpgProfileDashboardRepository: RpgProfileDashboardRepository; - rpgBrowseHistoryRepository: RpgBrowseHistoryRepository; - rpgSaveArchiveRepository: RpgSaveArchiveRepository; - rpgWorldLibraryRepository: RpgWorldLibraryRepository; - rpgRuntimeSnapshotRepository: RpgRuntimeSnapshotRepository; - runtimeRepository: RuntimeRepository; - llmClient: UpstreamLlmClient; - customWorldAgentSessions: CustomWorldAgentSessionStore; - customWorldAgentOrchestrator: CustomWorldAgentOrchestrator; - rpgWorldWorkSummaryService: RpgWorldWorkSummaryService; - smsVerificationService: SmsVerificationService; - wechatAuthService: WechatAuthService; - wechatAuthStates: WechatAuthStateStore; - captchaChallenges: CaptchaChallengeStore; -}; diff --git a/server-node/src/db.test.ts b/server-node/src/db.test.ts deleted file mode 100644 index 65aa7109..00000000 --- a/server-node/src/db.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -import assert from 'node:assert/strict'; -import path from 'node:path'; -import test from 'node:test'; - -import type { AppConfig } from './config.js'; -import { createDatabase, listAppliedMigrations } from './db.js'; - -function createTestConfig(databaseUrl: string): AppConfig { - const projectRoot = path.resolve(process.cwd(), '..'); - - return { - nodeEnv: 'test', - projectRoot, - publicDir: path.join(projectRoot, 'public'), - logsDir: path.join(projectRoot, 'server-node', 'logs'), - dataDir: path.join(projectRoot, 'server-node', 'data'), - rawEnv: {}, - databaseUrl, - serverAddr: ':0', - logLevel: 'silent', - editorApiEnabled: true, - assetsApiEnabled: true, - jwtSecret: 'test-secret', - jwtExpiresIn: '7d', - jwtIssuer: 'genarrative-server-node-test', - llm: { - baseUrl: 'https://example.invalid', - apiKey: '', - model: 'test-model', - }, - dashScope: { - baseUrl: 'https://example.invalid', - apiKey: '', - imageModel: 'test-image-model', - requestTimeoutMs: 1000, - }, - smsAuth: { - enabled: true, - provider: 'mock', - endpoint: 'dypnsapi.aliyuncs.com', - accessKeyId: '', - accessKeySecret: '', - signName: 'Test Sign', - templateCode: '100001', - templateParamKey: 'code', - countryCode: '86', - schemeName: '', - codeLength: 6, - codeType: 1, - validTimeSeconds: 300, - intervalSeconds: 60, - duplicatePolicy: 1, - caseAuthPolicy: 1, - returnVerifyCode: false, - mockVerifyCode: '123456', - maxSendPerPhonePerDay: 20, - maxSendPerIpPerHour: 30, - maxVerifyFailuresPerPhonePerHour: 12, - maxVerifyFailuresPerIpPerHour: 24, - captchaTtlSeconds: 180, - captchaTriggerVerifyFailuresPerPhone: 3, - captchaTriggerVerifyFailuresPerIp: 5, - blockPhoneFailureThreshold: 6, - blockIpFailureThreshold: 10, - blockPhoneDurationMinutes: 30, - blockIpDurationMinutes: 30, - }, - wechatAuth: { - enabled: true, - provider: 'mock', - appId: '', - appSecret: '', - authorizeEndpoint: 'https://open.weixin.qq.com/connect/qrconnect', - accessTokenEndpoint: 'https://api.weixin.qq.com/sns/oauth2/access_token', - userInfoEndpoint: 'https://api.weixin.qq.com/sns/userinfo', - callbackPath: '/api/auth/wechat/callback', - defaultRedirectPath: '/', - mockUserId: 'mock_wechat_user', - mockUnionId: 'mock_wechat_union', - mockDisplayName: '微信旅人', - mockAvatarUrl: '', - }, - authSession: { - accessCookieName: 'genarrative_access_session', - accessCookieTtlSeconds: 7200, - accessCookieSecure: false, - accessCookieSameSite: 'Lax', - accessCookiePath: '/', - refreshCookieName: 'genarrative_refresh_session', - refreshSessionTtlDays: 30, - refreshCookieSecure: false, - refreshCookieSameSite: 'Lax', - refreshCookiePath: '/api/auth', - }, - }; -} - -test('createDatabase applies runtime baseline migrations for pg-mem', async () => { - const db = await createDatabase( - createTestConfig('pg-mem://genarrative-db-test'), - ); - - try { - const migrations = await listAppliedMigrations(db); - assert.deepEqual( - migrations.map((migration) => migration.id), - [ - '20260408_001_runtime_postgres_baseline', - '20260408_002_allow_null_current_story_snapshot', - '20260409_003_phone_auth_user_extensions', - '20260409_004_auth_identities_and_account_status', - '20260409_005_user_sessions', - '20260409_006_auth_audit_logs', - '20260409_007_sms_auth_events', - '20260409_008_auth_risk_blocks', - '20260413_009_custom_world_sessions', - '20260414_010_custom_world_gallery_metadata', - '20260416_011_profile_dashboard_tables', - '20260416_012_user_browse_history', - '20260417_013_custom_world_profile_soft_delete', - '20260419_014_profile_save_archives', - '20260419_015_runtime_settings_platform_theme', - '20260422_016_sms_auth_delivery_tracking', - ], - ); - - const tablesResult = await db.query<{ tableName: string }>( - `SELECT table_name AS "tableName" - FROM information_schema.tables - WHERE table_schema = 'public' - AND table_name IN ( - 'schema_migrations', - 'users', - 'auth_identities', - 'auth_audit_logs', - 'auth_risk_blocks', - 'sms_auth_events', - 'user_sessions', - 'custom_world_sessions', - 'profile_dashboard_state', - 'profile_played_worlds', - 'profile_save_archives', - 'profile_wallet_ledger', - 'save_snapshots', - 'runtime_settings', - 'user_browse_history', - 'custom_world_profiles' - ) - ORDER BY table_name`, - ); - - assert.deepEqual( - tablesResult.rows.map((row) => row.tableName), - [ - 'auth_audit_logs', - 'auth_identities', - 'auth_risk_blocks', - 'custom_world_profiles', - 'custom_world_sessions', - 'profile_dashboard_state', - 'profile_played_worlds', - 'profile_save_archives', - 'profile_wallet_ledger', - 'runtime_settings', - 'save_snapshots', - 'schema_migrations', - 'sms_auth_events', - 'user_browse_history', - 'user_sessions', - 'users', - ], - ); - } finally { - await db.close(); - } -}); - -test('createDatabase rejects non-postgresql database urls', async () => { - await assert.rejects( - () => - createDatabase( - createTestConfig('mysql://root:root@127.0.0.1:3306/genarrative'), - ), - /DATABASE_URL 只支持 PostgreSQL 连接串或 pg-mem 测试连接/u, - ); -}); diff --git a/server-node/src/db.ts b/server-node/src/db.ts deleted file mode 100644 index c8ddc464..00000000 --- a/server-node/src/db.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { Pool, type QueryResult, type QueryResultRow } from 'pg'; - -import type { AppConfig } from './config.js'; -import { databaseMigrations } from './db/migrations.js'; - -const migrationTableSql = ` -CREATE TABLE IF NOT EXISTS schema_migrations ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - applied_at TEXT NOT NULL -) -`; - -type MigrationRow = QueryResultRow & { - id: string; - name: string; - appliedAt: string; -}; - -export type AppDatabase = { - query( - text: string, - params?: readonly unknown[], - ): Promise>; - close(): Promise; -}; - -type QueryablePool = Pick; - -function wrapPool(pool: QueryablePool): AppDatabase { - return { - query( - text: string, - params: readonly unknown[] = [], - ) { - return pool.query(text, [...params]); - }, - async close() { - await pool.end(); - }, - }; -} - -function validateDatabaseUrl(databaseUrl: string) { - const trimmed = databaseUrl.trim(); - - if (!trimmed) { - throw new Error('DATABASE_URL 不能为空'); - } - - if (trimmed.startsWith('pg-mem://')) { - return; - } - - let protocol = ''; - try { - protocol = new URL(trimmed).protocol; - } catch { - throw new Error( - 'DATABASE_URL 只支持 PostgreSQL 连接串或 pg-mem 测试连接', - ); - } - - if (protocol !== 'postgresql:' && protocol !== 'postgres:') { - throw new Error( - 'DATABASE_URL 只支持 PostgreSQL 连接串或 pg-mem 测试连接', - ); - } -} - -export function summarizeDatabaseTarget(databaseUrl: string) { - const trimmed = databaseUrl.trim(); - if (!trimmed) { - return '[missing]'; - } - - if (trimmed.startsWith('pg-mem://')) { - return trimmed; - } - - try { - const url = new URL(trimmed); - const databaseName = url.pathname.replace(/^\/+/u, '') || 'postgres'; - const portSuffix = url.port ? `:${url.port}` : ''; - return `${url.protocol}//${url.hostname}${portSuffix}/${databaseName}`; - } catch { - return '[configured]'; - } -} - -async function ensureMigrationTable(db: AppDatabase) { - await db.query(migrationTableSql); -} - -export async function listAppliedMigrations(db: AppDatabase) { - await ensureMigrationTable(db); - const result = await db.query( - `SELECT id, name, applied_at AS "appliedAt" - FROM schema_migrations - ORDER BY id`, - ); - - return result.rows.map((row) => ({ - id: row.id, - name: row.name, - appliedAt: row.appliedAt, - })); -} - -async function runMigrations(db: AppDatabase) { - await ensureMigrationTable(db); - const appliedMigrations = new Set( - (await listAppliedMigrations(db)).map((migration) => migration.id), - ); - - for (const migration of databaseMigrations) { - if (appliedMigrations.has(migration.id)) { - continue; - } - - await db.query('BEGIN'); - try { - for (const statement of migration.statements) { - await db.query(statement); - } - await db.query( - `INSERT INTO schema_migrations (id, name, applied_at) - VALUES ($1, $2, $3)`, - [migration.id, migration.name, new Date().toISOString()], - ); - await db.query('COMMIT'); - } catch (error) { - await db.query('ROLLBACK'); - throw new Error( - `failed to apply database migration ${migration.id}: ${error instanceof Error ? error.message : 'unknown error'}`, - ); - } - } -} - -async function createInMemoryDatabase() { - const { newDb } = await import('pg-mem'); - const memoryDb = newDb({ - autoCreateForeignKeyIndices: true, - noAstCoverageCheck: true, - }); - const adapter = memoryDb.adapters.createPg(); - const pool = new adapter.Pool() as unknown as QueryablePool; - const db = wrapPool(pool); - await runMigrations(db); - return db; -} - -export async function createDatabase(config: AppConfig) { - validateDatabaseUrl(config.databaseUrl); - - if (config.databaseUrl.startsWith('pg-mem://')) { - return createInMemoryDatabase(); - } - - const pool = new Pool({ - connectionString: config.databaseUrl, - }); - const db = wrapPool(pool); - await db.query('SELECT 1'); - await runMigrations(db); - return db; -} diff --git a/server-node/src/db/migrations.ts b/server-node/src/db/migrations.ts deleted file mode 100644 index 47717cca..00000000 --- a/server-node/src/db/migrations.ts +++ /dev/null @@ -1,388 +0,0 @@ -export type DatabaseMigration = { - id: string; - name: string; - statements: readonly string[]; -}; - -export const databaseMigrations: readonly DatabaseMigration[] = [ - { - id: '20260408_001_runtime_postgres_baseline', - name: 'runtime postgres baseline', - statements: [ - `CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL UNIQUE, - password_hash TEXT NOT NULL, - token_version INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - )`, - `CREATE TABLE IF NOT EXISTS save_snapshots ( - user_id TEXT PRIMARY KEY, - version INTEGER NOT NULL, - saved_at TEXT NOT NULL, - bottom_tab TEXT NOT NULL, - game_state_json JSONB NOT NULL, - current_story_json JSONB NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - )`, - `CREATE TABLE IF NOT EXISTS runtime_settings ( - user_id TEXT PRIMARY KEY, - music_volume REAL NOT NULL, - platform_theme TEXT NOT NULL DEFAULT 'light', - updated_at TEXT NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - )`, - `CREATE TABLE IF NOT EXISTS custom_world_profiles ( - user_id TEXT NOT NULL, - profile_id TEXT NOT NULL, - payload_json JSONB NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (user_id, profile_id), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - )`, - `CREATE INDEX IF NOT EXISTS custom_world_profiles_user_updated_idx - ON custom_world_profiles (user_id, updated_at DESC)`, - ], - }, - { - id: '20260408_002_allow_null_current_story_snapshot', - name: 'allow null current story snapshot', - statements: [ - `ALTER TABLE save_snapshots - ALTER COLUMN current_story_json DROP NOT NULL`, - ], - }, - { - id: '20260409_003_phone_auth_user_extensions', - name: 'phone auth user extensions', - statements: [ - `ALTER TABLE users - ADD COLUMN IF NOT EXISTS display_name TEXT`, - `UPDATE users - SET display_name = COALESCE( - CASE - WHEN display_name = '' THEN NULL - ELSE display_name - END, - username, - '玩家' - ) - WHERE display_name IS NULL OR display_name = ''`, - `ALTER TABLE users - ALTER COLUMN display_name SET NOT NULL`, - `ALTER TABLE users - ADD COLUMN IF NOT EXISTS login_provider TEXT NOT NULL DEFAULT 'password'`, - `ALTER TABLE users - ADD COLUMN IF NOT EXISTS phone_number TEXT`, - `ALTER TABLE users - ADD COLUMN IF NOT EXISTS phone_verified_at TEXT`, - `CREATE UNIQUE INDEX IF NOT EXISTS users_phone_number_unique_idx - ON users (phone_number)`, - ], - }, - { - id: '20260409_004_auth_identities_and_account_status', - name: 'auth identities and account status', - statements: [ - `ALTER TABLE users - ADD COLUMN IF NOT EXISTS account_status TEXT NOT NULL DEFAULT 'active'`, - `CREATE TABLE IF NOT EXISTS auth_identities ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - provider TEXT NOT NULL, - provider_uid TEXT NOT NULL, - provider_unionid TEXT, - display_name TEXT, - avatar_url TEXT, - is_verified BOOLEAN NOT NULL DEFAULT TRUE, - meta_json JSONB, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - )`, - `CREATE UNIQUE INDEX IF NOT EXISTS auth_identities_provider_uid_unique_idx - ON auth_identities (provider, provider_uid)`, - `CREATE UNIQUE INDEX IF NOT EXISTS auth_identities_provider_unionid_unique_idx - ON auth_identities (provider, provider_unionid) - WHERE provider_unionid IS NOT NULL`, - `CREATE INDEX IF NOT EXISTS auth_identities_user_idx - ON auth_identities (user_id, provider)`, - ], - }, - { - id: '20260409_005_user_sessions', - name: 'user sessions', - statements: [ - `CREATE TABLE IF NOT EXISTS user_sessions ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - refresh_token_hash TEXT NOT NULL UNIQUE, - client_type TEXT NOT NULL, - user_agent TEXT, - ip TEXT, - expires_at TEXT NOT NULL, - revoked_at TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - last_seen_at TEXT NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - )`, - `CREATE INDEX IF NOT EXISTS user_sessions_user_idx - ON user_sessions (user_id, expires_at DESC)`, - ], - }, - { - id: '20260409_006_auth_audit_logs', - name: 'auth audit logs', - statements: [ - `CREATE TABLE IF NOT EXISTS auth_audit_logs ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - event_type TEXT NOT NULL, - detail TEXT NOT NULL, - ip TEXT, - user_agent TEXT, - meta_json JSONB, - created_at TEXT NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - )`, - `CREATE INDEX IF NOT EXISTS auth_audit_logs_user_created_idx - ON auth_audit_logs (user_id, created_at DESC)`, - ], - }, - { - id: '20260409_007_sms_auth_events', - name: 'sms auth events', - statements: [ - `CREATE TABLE IF NOT EXISTS sms_auth_events ( - id TEXT PRIMARY KEY, - phone_number TEXT NOT NULL, - scene TEXT NOT NULL, - action TEXT NOT NULL, - success BOOLEAN NOT NULL, - ip TEXT, - user_agent TEXT, - created_at TEXT NOT NULL - )`, - `CREATE INDEX IF NOT EXISTS sms_auth_events_phone_created_idx - ON sms_auth_events (phone_number, created_at DESC)`, - `CREATE INDEX IF NOT EXISTS sms_auth_events_ip_created_idx - ON sms_auth_events (ip, created_at DESC)`, - ], - }, - { - id: '20260409_008_auth_risk_blocks', - name: 'auth risk blocks', - statements: [ - `CREATE TABLE IF NOT EXISTS auth_risk_blocks ( - id TEXT PRIMARY KEY, - scope_type TEXT NOT NULL, - scope_key TEXT NOT NULL, - reason TEXT NOT NULL, - expires_at TEXT NOT NULL, - lifted_at TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - )`, - `CREATE INDEX IF NOT EXISTS auth_risk_blocks_scope_idx - ON auth_risk_blocks (scope_type, scope_key, expires_at DESC)`, - ], - }, - { - id: '20260413_009_custom_world_sessions', - name: 'custom world sessions', - statements: [ - `CREATE TABLE IF NOT EXISTS custom_world_sessions ( - user_id TEXT NOT NULL, - session_id TEXT NOT NULL, - payload_json JSONB NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (user_id, session_id), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - )`, - `CREATE INDEX IF NOT EXISTS custom_world_sessions_user_updated_idx - ON custom_world_sessions (user_id, updated_at DESC)`, - ], - }, - { - id: '20260414_010_custom_world_gallery_metadata', - name: 'custom world gallery metadata', - statements: [ - `ALTER TABLE custom_world_profiles - ADD COLUMN IF NOT EXISTS visibility TEXT NOT NULL DEFAULT 'draft'`, - `ALTER TABLE custom_world_profiles - ADD COLUMN IF NOT EXISTS published_at TEXT`, - `ALTER TABLE custom_world_profiles - ADD COLUMN IF NOT EXISTS author_display_name TEXT NOT NULL DEFAULT '玩家'`, - `ALTER TABLE custom_world_profiles - ADD COLUMN IF NOT EXISTS world_name TEXT NOT NULL DEFAULT ''`, - `ALTER TABLE custom_world_profiles - ADD COLUMN IF NOT EXISTS subtitle TEXT NOT NULL DEFAULT ''`, - `ALTER TABLE custom_world_profiles - ADD COLUMN IF NOT EXISTS summary_text TEXT NOT NULL DEFAULT ''`, - `ALTER TABLE custom_world_profiles - ADD COLUMN IF NOT EXISTS cover_image_src TEXT`, - `ALTER TABLE custom_world_profiles - ADD COLUMN IF NOT EXISTS theme_mode TEXT NOT NULL DEFAULT 'mythic'`, - `ALTER TABLE custom_world_profiles - ADD COLUMN IF NOT EXISTS playable_npc_count INTEGER NOT NULL DEFAULT 0`, - `ALTER TABLE custom_world_profiles - ADD COLUMN IF NOT EXISTS landmark_count INTEGER NOT NULL DEFAULT 0`, - `CREATE INDEX IF NOT EXISTS custom_world_profiles_published_idx - ON custom_world_profiles (visibility, published_at DESC, updated_at DESC)`, - ], - }, - { - id: '20260416_011_profile_dashboard_tables', - name: 'profile dashboard tables', - statements: [ - `CREATE TABLE IF NOT EXISTS profile_dashboard_state ( - user_id TEXT PRIMARY KEY, - wallet_balance INTEGER NOT NULL DEFAULT 0, - total_play_time_ms BIGINT NOT NULL DEFAULT 0, - updated_at TEXT NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - )`, - `CREATE TABLE IF NOT EXISTS profile_wallet_ledger ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - amount_delta INTEGER NOT NULL, - balance_after INTEGER NOT NULL, - source_type TEXT NOT NULL, - source_key TEXT NOT NULL, - created_at TEXT NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - )`, - `CREATE UNIQUE INDEX IF NOT EXISTS profile_wallet_ledger_user_source_key_idx - ON profile_wallet_ledger (user_id, source_key)`, - `CREATE INDEX IF NOT EXISTS profile_wallet_ledger_user_created_idx - ON profile_wallet_ledger (user_id, created_at DESC)`, - `CREATE TABLE IF NOT EXISTS profile_played_worlds ( - user_id TEXT NOT NULL, - world_key TEXT NOT NULL, - owner_user_id TEXT, - profile_id TEXT, - world_type TEXT, - world_title TEXT NOT NULL DEFAULT '', - world_subtitle TEXT NOT NULL DEFAULT '', - first_played_at TEXT NOT NULL, - last_played_at TEXT NOT NULL, - last_observed_play_time_ms BIGINT NOT NULL DEFAULT 0, - PRIMARY KEY (user_id, world_key), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - )`, - `CREATE INDEX IF NOT EXISTS profile_played_worlds_user_last_played_idx - ON profile_played_worlds (user_id, last_played_at DESC)`, - ], - }, - { - id: '20260416_012_user_browse_history', - name: 'user browse history', - statements: [ - `CREATE TABLE IF NOT EXISTS user_browse_history ( - user_id TEXT NOT NULL, - owner_user_id TEXT NOT NULL, - profile_id TEXT NOT NULL, - world_name TEXT NOT NULL DEFAULT '', - subtitle TEXT NOT NULL DEFAULT '', - summary_text TEXT NOT NULL DEFAULT '', - cover_image_src TEXT, - theme_mode TEXT NOT NULL DEFAULT 'mythic', - author_display_name TEXT NOT NULL DEFAULT '玩家', - visited_at TEXT NOT NULL, - PRIMARY KEY (user_id, owner_user_id, profile_id), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - )`, - `CREATE INDEX IF NOT EXISTS user_browse_history_user_visited_idx - ON user_browse_history (user_id, visited_at DESC)`, - ], - }, - { - id: '20260417_013_custom_world_profile_soft_delete', - name: 'custom world profile soft delete', - statements: [ - `ALTER TABLE custom_world_profiles - ADD COLUMN IF NOT EXISTS deleted_at TEXT`, - `CREATE INDEX IF NOT EXISTS custom_world_profiles_user_deleted_updated_idx - ON custom_world_profiles (user_id, deleted_at, updated_at DESC)`, - `CREATE INDEX IF NOT EXISTS custom_world_profiles_gallery_live_idx - ON custom_world_profiles ( - visibility, - deleted_at, - published_at DESC, - updated_at DESC - )`, - ], - }, - { - id: '20260419_014_profile_save_archives', - name: 'profile save archives', - statements: [ - `CREATE TABLE IF NOT EXISTS profile_save_archives ( - user_id TEXT NOT NULL, - world_key TEXT NOT NULL, - owner_user_id TEXT, - profile_id TEXT, - world_type TEXT, - world_name TEXT NOT NULL DEFAULT '', - world_subtitle TEXT NOT NULL DEFAULT '', - summary_text TEXT NOT NULL DEFAULT '', - cover_image_src TEXT, - saved_at TEXT NOT NULL, - bottom_tab TEXT NOT NULL, - game_state_json JSONB NOT NULL, - current_story_json JSONB, - updated_at TEXT NOT NULL, - PRIMARY KEY (user_id, world_key), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - )`, - `CREATE INDEX IF NOT EXISTS profile_save_archives_user_saved_idx - ON profile_save_archives (user_id, saved_at DESC)`, - ], - }, - { - id: '20260419_015_runtime_settings_platform_theme', - name: 'runtime settings platform theme', - statements: [ - `ALTER TABLE runtime_settings - ADD COLUMN IF NOT EXISTS platform_theme TEXT NOT NULL DEFAULT 'light'`, - ], - }, - { - id: '20260422_016_sms_auth_delivery_tracking', - name: 'sms auth delivery tracking', - statements: [ - `ALTER TABLE sms_auth_events - ADD COLUMN IF NOT EXISTS provider TEXT`, - `ALTER TABLE sms_auth_events - ADD COLUMN IF NOT EXISTS provider_request_id TEXT`, - `ALTER TABLE sms_auth_events - ADD COLUMN IF NOT EXISTS provider_biz_id TEXT`, - `ALTER TABLE sms_auth_events - ADD COLUMN IF NOT EXISTS provider_out_id TEXT`, - `ALTER TABLE sms_auth_events - ADD COLUMN IF NOT EXISTS delivery_status TEXT NOT NULL DEFAULT 'pending'`, - `ALTER TABLE sms_auth_events - ADD COLUMN IF NOT EXISTS delivery_report_raw_json JSONB`, - `ALTER TABLE sms_auth_events - ADD COLUMN IF NOT EXISTS delivery_reported_at TEXT`, - `CREATE INDEX IF NOT EXISTS sms_auth_events_provider_biz_id_idx - ON sms_auth_events (provider_biz_id)`, - `CREATE INDEX IF NOT EXISTS sms_auth_events_provider_out_id_idx - ON sms_auth_events (provider_out_id)`, - `UPDATE sms_auth_events - SET provider = COALESCE(provider, 'mock'), - delivery_status = CASE - WHEN delivery_status IS NOT NULL AND delivery_status <> '' THEN delivery_status - WHEN success THEN 'delivered' - ELSE 'unknown' - END - WHERE provider IS NULL - OR delivery_status IS NULL - OR delivery_status = ''`, - ], - }, -]; diff --git a/server-node/src/errors.ts b/server-node/src/errors.ts deleted file mode 100644 index c40fae85..00000000 --- a/server-node/src/errors.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { ZodError } from 'zod'; - -type HttpErrorOptions = { - code?: string; - details?: unknown; - expose?: boolean; -}; - -type JsonBodyParseError = SyntaxError & { - status?: number; - type?: string; -}; - -export function resolveHttpErrorCode(statusCode: number) { - switch (statusCode) { - case 400: - return 'BAD_REQUEST'; - case 401: - return 'UNAUTHORIZED'; - case 429: - return 'TOO_MANY_REQUESTS'; - case 403: - return 'FORBIDDEN'; - case 404: - return 'NOT_FOUND'; - case 409: - return 'CONFLICT'; - case 502: - return 'UPSTREAM_ERROR'; - default: - return 'INTERNAL_SERVER_ERROR'; - } -} - -export function resolveHttpErrorMessage(statusCode: number) { - switch (statusCode) { - case 400: - return '请求参数不合法'; - case 401: - return '未授权访问'; - case 429: - return '请求过于频繁'; - case 403: - return '禁止访问'; - case 404: - return '资源不存在'; - case 409: - return '请求冲突'; - case 502: - return '上游服务请求失败'; - default: - return '服务器内部错误'; - } -} - -function isJsonBodyParseError(error: unknown): error is JsonBodyParseError { - return ( - error instanceof SyntaxError && - typeof error === 'object' && - 'status' in error && - 'type' in error && - (error.status === 400 || error.type === 'entity.parse.failed') - ); -} - -function serializeZodIssues(error: ZodError) { - return error.issues.map((issue) => ({ - path: issue.path.join('.'), - message: issue.message, - code: issue.code, - })); -} - -export class HttpError extends Error { - statusCode: number; - expose: boolean; - code: string; - details?: unknown; - - constructor( - statusCode: number, - message: string, - options: HttpErrorOptions = {}, - ) { - super(message); - this.name = 'HttpError'; - this.statusCode = statusCode; - this.expose = options.expose ?? statusCode < 500; - this.code = options.code ?? resolveHttpErrorCode(statusCode); - this.details = options.details; - } -} - -export function badRequest(message: string, details?: unknown) { - return new HttpError(400, message, { - code: 'BAD_REQUEST', - details, - }); -} - -export function invalidRequest(message = '请求参数不合法', details?: unknown) { - return new HttpError(400, message, { - code: 'INVALID_REQUEST', - details, - }); -} - -export function unauthorized(message = '未授权访问') { - return new HttpError(401, message, { - code: 'UNAUTHORIZED', - }); -} - -export function forbidden(message = '禁止访问') { - return new HttpError(403, message, { - code: 'FORBIDDEN', - }); -} - -export function tooManyRequests(message = '请求过于频繁', details?: unknown) { - return new HttpError(429, message, { - code: 'TOO_MANY_REQUESTS', - details, - }); -} - -export function captchaRequired(message = '需要完成人机校验', details?: unknown) { - return new HttpError(403, message, { - code: 'CAPTCHA_REQUIRED', - details, - }); -} - -export function notFound(message = '资源不存在') { - return new HttpError(404, message, { - code: 'NOT_FOUND', - }); -} - -export function conflict(message: string, details?: unknown) { - return new HttpError(409, message, { - code: 'CONFLICT', - details, - }); -} - -export function upstreamError(message: string, details?: unknown) { - return new HttpError(502, message, { - code: 'UPSTREAM_ERROR', - details, - }); -} - -export function toHttpError(error: unknown) { - if (error instanceof HttpError) { - return error; - } - - if (error instanceof ZodError) { - return invalidRequest('请求参数不合法', { - issues: serializeZodIssues(error), - }); - } - - if (isJsonBodyParseError(error)) { - return badRequest('JSON 请求体格式错误'); - } - - return new HttpError(500, '服务器内部错误', { - code: 'INTERNAL_SERVER_ERROR', - expose: false, - }); -} diff --git a/server-node/src/http.ts b/server-node/src/http.ts deleted file mode 100644 index 9c731190..00000000 --- a/server-node/src/http.ts +++ /dev/null @@ -1,318 +0,0 @@ -import type { NextFunction, Request, RequestHandler, Response } from 'express'; - -import { - API_VERSION, - createApiError, - createApiSuccess, - parseApiErrorMessage, -} from '../../packages/shared/src/http.js'; -import { resolveHttpErrorCode, resolveHttpErrorMessage } from './errors.js'; - -export const API_RESPONSE_ENVELOPE_HEADER = 'x-genarrative-response-envelope'; -export const API_VERSION_HEADER = 'x-api-version'; -export const ROUTE_VERSION_HEADER = 'x-route-version'; -export const RESPONSE_TIME_HEADER = 'x-response-time-ms'; - -const DEFAULT_API_VERSION = API_VERSION; -const DEFAULT_ROUTE_VERSION = DEFAULT_API_VERSION; - -export type ApiRouteMeta = { - operation?: string; - routeVersion?: string; -}; - -export type ApiResponseMeta = { - requestId: string; - apiVersion: string; - routeVersion: string; - operation: string | null; - latencyMs: number; - timestamp: string; -}; - -export type ApiSuccessEnvelope = { - ok: true; - data: T; - error: null; - meta: ApiResponseMeta; -}; - -type LegacyApiErrorBody = { - error?: { - code?: string; - message?: string; - details?: unknown; - } | null; - message?: string; - code?: string; - details?: unknown; - meta?: unknown; -}; - -function readRouteMeta(response: Response): ApiRouteMeta { - const routeMeta = response.locals.apiRouteMeta; - if (!routeMeta || typeof routeMeta !== 'object') { - return {}; - } - - return routeMeta as ApiRouteMeta; -} - -function inferOperation(request: Request) { - if (request.originalUrl) { - return `${request.method} ${request.originalUrl}`; - } - - if (request.route?.path) { - return `${request.method} ${request.baseUrl}${request.route.path}`; - } - - return `${request.method} ${request.originalUrl || request.url}`; -} - -export function setRouteMeta(response: Response, meta: ApiRouteMeta) { - response.locals.apiRouteMeta = { - ...readRouteMeta(response), - ...meta, - }; -} - -export function withRouteMeta(meta: ApiRouteMeta): RequestHandler { - return (_request, response, next) => { - setRouteMeta(response, meta); - next(); - }; -} - -export function wantsApiEnvelope(request: Request) { - const value = - request.header(API_RESPONSE_ENVELOPE_HEADER)?.trim().toLowerCase() || ''; - - return ( - value === '1' || value === 'true' || value === 'v1' || value === 'envelope' - ); -} - -export function buildApiResponseMeta( - request: Request, - response: Response, -): ApiResponseMeta { - const routeMeta = readRouteMeta(response); - - return { - requestId: request.requestId, - apiVersion: DEFAULT_API_VERSION, - routeVersion: routeMeta.routeVersion || DEFAULT_ROUTE_VERSION, - operation: routeMeta.operation || inferOperation(request), - latencyMs: Math.max(0, Date.now() - request.requestStartedAt), - timestamp: new Date().toISOString(), - }; -} - -export function applyApiResponseHeaders(request: Request, response: Response) { - const meta = buildApiResponseMeta(request, response); - - response.setHeader('X-Request-Id', meta.requestId); - response.setHeader(API_VERSION_HEADER, meta.apiVersion); - response.setHeader(ROUTE_VERSION_HEADER, meta.routeVersion); - response.setHeader(RESPONSE_TIME_HEADER, String(meta.latencyMs)); - - return meta; -} - -function buildSharedApiMeta(meta: ApiResponseMeta) { - return { - requestId: meta.requestId, - apiVersion: meta.apiVersion, - routeVersion: meta.routeVersion, - operation: meta.operation, - latencyMs: meta.latencyMs, - timestamp: meta.timestamp, - }; -} - -export function buildApiLogContext(request: Request, response: Response) { - const meta = buildApiResponseMeta(request, response); - - return { - request_id: meta.requestId, - api_version: meta.apiVersion, - route_version: meta.routeVersion, - operation: meta.operation, - }; -} - -export function toApiSuccessBody( - request: Request, - response: Response, - data: T, -): T | ApiSuccessEnvelope { - const meta = applyApiResponseHeaders(request, response); - - if (!wantsApiEnvelope(request)) { - return data; - } - - return createApiSuccess(data, buildSharedApiMeta(meta)); -} - -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); -} - -export function isStandardApiSuccessEnvelope( - body: unknown, -): body is ApiSuccessEnvelope { - return ( - isRecord(body) && - body.ok === true && - 'data' in body && - 'error' in body && - body.error === null && - isRecord(body.meta) && - typeof body.meta.apiVersion === 'string' - ); -} - -export function isStandardApiErrorResponse(body: unknown) { - if ( - !isRecord(body) || - !isRecord(body.meta) || - typeof body.meta.apiVersion !== 'string' - ) { - return false; - } - - if (body.ok === false) { - return ( - body.data === null && - isRecord(body.error) && - typeof body.error.code === 'string' && - typeof body.error.message === 'string' - ); - } - - return ( - 'error' in body && - isRecord(body.error) && - typeof body.error.code === 'string' && - typeof body.error.message === 'string' - ); -} - -function normalizeLegacyApiErrorBody(body: unknown, statusCode: number) { - const legacyBody = isRecord(body) ? (body as LegacyApiErrorBody) : {}; - const nestedError = isRecord(legacyBody.error) ? legacyBody.error : null; - const code = - (typeof nestedError?.code === 'string' && nestedError.code.trim()) || - (typeof legacyBody.code === 'string' && legacyBody.code.trim()) || - resolveHttpErrorCode(statusCode); - const message = - (typeof nestedError?.message === 'string' && nestedError.message.trim()) || - (typeof legacyBody.message === 'string' && legacyBody.message.trim()) || - resolveHttpErrorMessage(statusCode); - const details = nestedError?.details ?? legacyBody.details; - - return { - code, - message, - ...(details !== undefined ? { details } : {}), - }; -} - -export function toApiErrorBody( - request: Request, - response: Response, - body: unknown, -) { - const meta = applyApiResponseHeaders(request, response); - const error = normalizeLegacyApiErrorBody(body, response.statusCode || 500); - - if (wantsApiEnvelope(request)) { - return createApiError(error, buildSharedApiMeta(meta)); - } - - return { - error, - meta, - }; -} - -export function sendApiResponse( - response: Response, - data: T, - statusCode = 200, -) { - response.status(statusCode); - response.json(data); -} - -export function prepareApiResponse( - request: Request, - response: Response, - options: { - statusCode?: number; - headers?: Record; - routeMeta?: ApiRouteMeta; - } = {}, -) { - if (options.routeMeta) { - setRouteMeta(response, options.routeMeta); - } - if (typeof options.statusCode === 'number') { - response.status(options.statusCode); - } - - const meta = applyApiResponseHeaders(request, response); - - for (const [name, value] of Object.entries(options.headers ?? {})) { - response.setHeader(name, value); - } - - return meta; -} - -export function prepareEventStreamResponse( - request: Request, - response: Response, - options: { - statusCode?: number; - routeMeta?: ApiRouteMeta; - headers?: Record; - } = {}, -) { - return prepareApiResponse(request, response, { - statusCode: options.statusCode ?? 200, - routeMeta: options.routeMeta, - headers: { - 'Content-Type': 'text/event-stream; charset=utf-8', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - 'X-Accel-Buffering': 'no', - ...(options.headers ?? {}), - }, - }); -} - -export function asyncHandler( - handler: ( - request: Request, - response: Response, - next: NextFunction, - ) => Promise | unknown, -): RequestHandler { - return (request, response, next) => { - Promise.resolve(handler(request, response, next)).catch(next); - }; -} - -export function extractApiErrorMessage( - rawText: string, - fallbackMessage: string, -) { - return parseApiErrorMessage(rawText, fallbackMessage); -} - -export function jsonClone(value: T): T { - return JSON.parse(JSON.stringify(value)) as T; -} diff --git a/server-node/src/logging.ts b/server-node/src/logging.ts deleted file mode 100644 index a1de9197..00000000 --- a/server-node/src/logging.ts +++ /dev/null @@ -1,77 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; - -import pino, { type Logger } from 'pino'; - -import type { AppConfig } from './config.js'; - -const LOG_RETENTION_DAYS = 7; - -function shouldUseTransport(config: AppConfig) { - return config.nodeEnv !== 'test' && config.logLevel !== 'silent'; -} - -function cleanupExpiredLogs(logsDir: string) { - if (!fs.existsSync(logsDir)) { - return; - } - - const expiryTime = Date.now() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000; - - for (const entry of fs.readdirSync(logsDir, { withFileTypes: true })) { - if (!entry.isFile() || !entry.name.startsWith('server.log')) { - continue; - } - - const fullPath = path.join(logsDir, entry.name); - const stats = fs.statSync(fullPath); - if (stats.mtimeMs < expiryTime) { - fs.rmSync(fullPath, { force: true }); - } - } -} - -export function createLogger(config: AppConfig): Logger { - if (!shouldUseTransport(config)) { - return pino({ - level: config.logLevel, - timestamp: pino.stdTimeFunctions.isoTime, - base: undefined, - }); - } - - fs.mkdirSync(config.logsDir, { recursive: true }); - cleanupExpiredLogs(config.logsDir); - - const transport = pino.transport({ - targets: [ - { - target: 'pino-roll', - level: config.logLevel, - options: { - file: path.join(config.logsDir, 'server.log'), - mkdir: true, - size: '10m', - frequency: 'daily', - dateFormat: 'yyyy-MM-dd', - }, - }, - { - target: 'pino/file', - level: config.logLevel, - options: { - destination: 1, - }, - }, - ], - }); - - return pino( - { - level: config.logLevel, - timestamp: pino.stdTimeFunctions.isoTime, - base: undefined, - }, - transport, - ); -} diff --git a/server-node/src/manifest/backendCapabilityManifest.ts b/server-node/src/manifest/backendCapabilityManifest.ts deleted file mode 100644 index d71c8898..00000000 --- a/server-node/src/manifest/backendCapabilityManifest.ts +++ /dev/null @@ -1,1696 +0,0 @@ -/** - * Node 后端能力清单的单一描述源。 - * - * 设计目标: - * 1. 把当前已经挂载的 HTTP 能力按“挂载面 / 路由 / 内部模块”三层收口。 - * 2. 让 JSON 清单与技术文档都从同一份 manifest 生成,避免重复维护。 - * 3. 当新增 `src/modules/*` 目录或新增对外接口时,生成脚本可以第一时间提示缺口。 - */ - -export type BackendRouteMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'; - -export type BackendRouteResponseMode = - | 'json' - | 'stream' - | 'redirect' - | 'proxy'; - -export type BackendRouteMount = { - entryFile: string; - mountPath: string; - routeFactory: string; -}; - -export type BackendRouteSurface = { - id: string; - title: string; - mounts: BackendRouteMount[]; - responsibilities: string[]; - primaryServiceBoundaries: string[]; - relatedModuleIds: string[]; -}; - -export type BackendDomainModule = { - id: string; - title: string; - directory: string; - exposedBySurfaceIds: string[]; - responsibilities: string[]; - primaryServiceBoundaries: string[]; - keyFiles: string[]; -}; - -export type BackendRouteCapability = { - id: string; - surfaceId: string; - group: string; - method: BackendRouteMethod; - path: string; - operation: string; - access: string; - responseMode: BackendRouteResponseMode; - summary: string; - domainModuleIds: string[]; - sourceFile: string; - sourceHint: string; -}; - -export type BackendCapabilityManifest = { - version: string; - generatedCommand: string; - outputTargets: { - json: string; - markdown: string; - }; - maintenanceRules: string[]; - surfaces: BackendRouteSurface[]; - modules: BackendDomainModule[]; - routes: BackendRouteCapability[]; -}; - -const APP_SOURCE_FILE = 'server-node/src/app.ts'; -const AUTH_SOURCE_FILE = 'server-node/src/routes/authRoutes.ts'; -const RUNTIME_SOURCE_FILE = 'server-node/src/routes/runtimeRoutes.ts'; -const CUSTOM_WORLD_AGENT_SOURCE_FILE = - 'server-node/src/routes/customWorldAgent.ts'; -const STORY_ACTION_SOURCE_FILE = - 'server-node/src/modules/story/storyActionRoutes.ts'; -const EDITOR_SOURCE_FILE = 'server-node/src/modules/editor/editorRoutes.ts'; -const CHARACTER_ASSET_SOURCE_FILE = - 'server-node/src/modules/assets/characterAssetRoutes.ts'; -const QWEN_SPRITE_SOURCE_FILE = - 'server-node/src/modules/assets/qwenSpriteRoutes.ts'; - -/** - * 统一生成规范化路径,避免手写时出现重复斜杠。 - */ -function joinRoutePath(prefix: string, pathname: string) { - const normalizedPrefix = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix; - const normalizedPath = pathname.startsWith('/') ? pathname : `/${pathname}`; - return `${normalizedPrefix}${normalizedPath}`; -} - -/** - * 让路由清单的创建保持扁平,后续生成脚本也能直接消费。 - */ -function defineRoute(route: BackendRouteCapability) { - return route; -} - -function defineAuthRoute( - route: Omit & { - path: string; - }, -) { - return defineRoute({ - ...route, - surfaceId: 'auth', - path: joinRoutePath('/api/auth', route.path), - sourceFile: AUTH_SOURCE_FILE, - }); -} - -function defineEditorRoute( - route: Omit, -) { - return defineRoute({ - ...route, - surfaceId: 'editor', - sourceFile: EDITOR_SOURCE_FILE, - }); -} - -function defineAssetRoute( - route: Omit, -) { - return defineRoute({ - ...route, - surfaceId: 'assets', - sourceFile: CHARACTER_ASSET_SOURCE_FILE, - }); -} - -function defineQwenAssetRoute( - route: Omit, -) { - return defineRoute({ - ...route, - surfaceId: 'assets', - sourceFile: QWEN_SPRITE_SOURCE_FILE, - }); -} - -function defineRuntimeRoute( - route: Omit & { - path: string; - }, -) { - return defineRoute({ - ...route, - surfaceId: 'runtime-main', - path: joinRoutePath('/api', route.path), - sourceFile: RUNTIME_SOURCE_FILE, - }); -} - -function defineRuntimeAgentRoute( - route: Omit & { - path: string; - }, -) { - return defineRoute({ - ...route, - surfaceId: 'runtime-main', - path: joinRoutePath('/api', route.path), - sourceFile: CUSTOM_WORLD_AGENT_SOURCE_FILE, - }); -} - -function defineStoryActionRoute( - route: Omit & { - path: string; - }, -) { - return defineRoute({ - ...route, - surfaceId: 'runtime-story-action', - path: joinRoutePath('/api/runtime/story', route.path), - sourceFile: STORY_ACTION_SOURCE_FILE, - }); -} - -/** - * runtime profile 仍然保留 `/api/profile/*` 与 `/api/runtime/profile/*` - * 两组路径,这里统一展开成两条清单记录,避免文档遗漏兼容入口。 - */ -function defineRuntimeCompatRoutes(params: { - idPrefix: string; - method: BackendRouteMethod; - basePath: string; - operation: string; - group: string; - access: string; - summary: string; - responseMode: BackendRouteResponseMode; - domainModuleIds: string[]; - sourceHint: string; -}) { - return [ - defineRuntimeRoute({ - id: `${params.idPrefix}.primary`, - group: params.group, - method: params.method, - path: params.basePath, - operation: params.operation, - access: params.access, - responseMode: params.responseMode, - summary: params.summary, - domainModuleIds: params.domainModuleIds, - sourceHint: params.sourceHint, - }), - defineRuntimeRoute({ - id: `${params.idPrefix}.compat`, - group: params.group, - method: params.method, - path: `/runtime${params.basePath}`, - operation: `${params.operation}.compat`, - access: params.access, - responseMode: params.responseMode, - summary: `${params.summary}(兼容路径)`, - domainModuleIds: params.domainModuleIds, - sourceHint: params.sourceHint, - }), - ] satisfies BackendRouteCapability[]; -} - -export const BACKEND_ROUTE_SURFACES: BackendRouteSurface[] = [ - { - id: 'health', - title: '基础健康检查', - mounts: [ - { - entryFile: APP_SOURCE_FILE, - mountPath: '/healthz', - routeFactory: 'createApp', - }, - ], - responsibilities: [ - '提供 Node 后端进程级健康探针。', - '给反向代理、部署平台和本地联调提供最小可用状态确认。', - ], - primaryServiceBoundaries: [ - '只返回服务静态信息,不触达数据库、鉴权或外部模型供应商。', - ], - relatedModuleIds: [], - }, - { - id: 'auth', - title: '鉴权与会话面', - mounts: [ - { - entryFile: APP_SOURCE_FILE, - mountPath: '/api/auth', - routeFactory: 'createAuthRoutes', - }, - ], - responsibilities: [ - '承接本地账号、短信验证码与微信登录流程。', - '管理 refresh session、用户信息、会话吊销、审计日志与风险拦截。', - ], - primaryServiceBoundaries: [ - 'HTTP 层只做 schema 校验、请求上下文拼装与 Cookie 管理,核心鉴权逻辑统一收口到 `server-node/src/auth/*`。', - '用户、身份、会话、风控与短信事件等持久化职责全部下沉到 repository 层,避免路由直接碰数据库细节。', - ], - relatedModuleIds: [], - }, - { - id: 'editor', - title: '编辑器工具面', - mounts: [ - { - entryFile: APP_SOURCE_FILE, - mountPath: '/api/editor', - routeFactory: 'createEditorRoutes', - }, - ], - responsibilities: [ - '读取编辑器资源 JSON。', - '回写编辑器覆盖文件。', - '枚举 `public/Icons` 下的物品图标资源。', - ], - primaryServiceBoundaries: [ - '只对工作区文件系统与 `public` 目录负责,不参与运行时数据库存储。', - '统一受 `EDITOR_API_ENABLED` 开关控制,生产环境可按需关闭。', - ], - relatedModuleIds: ['editor'], - }, - { - id: 'assets', - title: '资产生成工具面', - mounts: [ - { - entryFile: APP_SOURCE_FILE, - mountPath: '/api/assets', - routeFactory: 'createCharacterAssetRoutes', - }, - { - entryFile: APP_SOURCE_FILE, - mountPath: '/api/assets/qwen-sprite', - routeFactory: 'createQwenSpriteRoutes', - }, - ], - responsibilities: [ - '生成角色主形象、动作、动作模板与工作流缓存。', - '承接 Qwen 精灵表主图、整表、修帧与保存链路。', - '把产物发布到 `public/generated-*` 目录并落地局部 manifest。', - ], - primaryServiceBoundaries: [ - '负责对接 DashScope、Ark 等外部媒体供应商,但不维护 runtime 快照与业务状态。', - '统一受 `ASSETS_API_ENABLED` 开关控制,产物以文件与 JSON manifest 形式落在仓库工作区。', - ], - relatedModuleIds: ['assets'], - }, - { - id: 'runtime-main', - title: '运行时主能力面', - mounts: [ - { - entryFile: APP_SOURCE_FILE, - mountPath: '/api', - routeFactory: 'createRuntimeRoutes', - }, - { - entryFile: RUNTIME_SOURCE_FILE, - mountPath: '/runtime/custom-world/agent', - routeFactory: 'createCustomWorldAgentRoutes', - }, - ], - responsibilities: [ - '承接运行时资料库、公开画廊、存档、设置与个人档案接口。', - '承接剧情生成、聊天流、任务生成、运行时物品意图与自定义世界链路。', - '承接 Custom World Agent 会话、消息流和操作回放。', - ], - primaryServiceBoundaries: [ - 'HTTP contract 收口在 `runtimeRoutes.ts`,真正的世界生成、剧情、聊天、任务和资源逻辑继续下沉到 `services/*` 与 `src/modules/*`。', - '除公开画廊外,运行时接口统一走 JWT 鉴权,并依赖 `runtimeRepository`、session store 与 LLM client 执行。', - ], - relatedModuleIds: [ - 'ai', - 'custom-world', - 'quest', - 'runtime', - 'runtime-item', - 'story', - ], - }, - { - id: 'runtime-story-action', - title: '运行时 Story Action 面', - mounts: [ - { - entryFile: APP_SOURCE_FILE, - mountPath: '/api/runtime/story', - routeFactory: 'createStoryActionRoutes', - }, - ], - responsibilities: [ - '把前端 story choice 动作解析为新的运行时状态。', - '查询指定 story session 的可恢复状态。', - ], - primaryServiceBoundaries: [ - '路由层只做鉴权与 schema 校验,真正的动作分发与跨模块协作集中在 `storyActionService.ts`。', - 'Story Action 会联动 quest、inventory、runtime-item、npc 等内部模块,但对前端只暴露 story 这一条稳定入口。', - ], - relatedModuleIds: [ - 'story', - 'quest', - 'inventory', - 'runtime-item', - 'npc', - 'progression', - 'combat', - 'runtime', - ], - }, -]; - -export const BACKEND_DOMAIN_MODULES: BackendDomainModule[] = [ - { - id: 'ai', - title: 'AI 编排模块', - directory: 'server-node/src/modules/ai', - exposedBySurfaceIds: ['runtime-main'], - responsibilities: [ - '统一剧情、多轮聊天与自定义世界编排器的 prompt 构造与输出归一化。', - '屏蔽前端对不同 AI 链路的直接拼装细节。', - ], - primaryServiceBoundaries: [ - '专注提示词与编排,不负责持久化与 HTTP 传输。', - '通过 `services/llmClient.ts` 与外部模型交互,由路由与 service 层决定何时调用。', - ], - keyFiles: [ - 'server-node/src/modules/ai/chatOrchestrator.ts', - 'server-node/src/modules/ai/customWorldOrchestrator.ts', - 'server-node/src/modules/ai/storyOrchestrator.ts', - ], - }, - { - id: 'assets', - title: '资产工具模块', - directory: 'server-node/src/modules/assets', - exposedBySurfaceIds: ['assets'], - responsibilities: [ - '承接角色资产与 Qwen 精灵表的生成、查询、发布和保存。', - '维护资产流程需要的缓存、草稿与产物 manifest。', - ], - primaryServiceBoundaries: [ - '以文件系统和外部媒体模型为主要边界,不碰 runtimeRepository。', - '对外暴露稳定 HTTP 路径,对内通过私有 helper 处理媒体编码、任务轮询与写盘。', - ], - keyFiles: [ - 'server-node/src/modules/assets/characterAssetRoutes.ts', - 'server-node/src/modules/assets/qwenSpriteRoutes.ts', - ], - }, - { - id: 'combat', - title: '战斗结算模块', - directory: 'server-node/src/modules/combat', - exposedBySurfaceIds: ['runtime-story-action'], - responsibilities: [ - '提供运行时战斗结算与数值变更能力。', - '为 story action 里的战斗型交互提供纯计算服务。', - ], - primaryServiceBoundaries: [ - '聚焦状态推导与结果计算,不负责 transport 与持久化。', - ], - keyFiles: [ - 'server-node/src/modules/combat/combatResolutionService.ts', - ], - }, - { - id: 'custom-world', - title: '自定义世界运行时模块', - directory: 'server-node/src/modules/custom-world', - exposedBySurfaceIds: ['runtime-main'], - responsibilities: [ - '规范 creator intent、世界运行时类型与 profile compile。', - '把世界创作输入整理成运行时可消费的数据结构。', - ], - primaryServiceBoundaries: [ - '偏纯领域建模与 compile,不直接做 HTTP、数据库查询或模型调用。', - ], - keyFiles: [ - 'server-node/src/modules/custom-world/creatorIntentRuntime.ts', - 'server-node/src/modules/custom-world/runtimeProfile.ts', - 'server-node/src/modules/custom-world/runtimeTypes.ts', - ], - }, - { - id: 'editor', - title: '编辑器资源模块', - directory: 'server-node/src/modules/editor', - exposedBySurfaceIds: ['editor'], - responsibilities: [ - '提供编辑器资源目录枚举与 JSON 读写入口。', - ], - primaryServiceBoundaries: [ - '只负责工作区文件输入输出,不参与运行时业务计算。', - ], - keyFiles: ['server-node/src/modules/editor/editorRoutes.ts'], - }, - { - id: 'inventory', - title: '背包与物品变更模块', - directory: 'server-node/src/modules/inventory', - exposedBySurfaceIds: ['runtime-story-action'], - responsibilities: [ - '维护背包变更、NPC 背包交互与 story action 里的物品副作用。', - ], - primaryServiceBoundaries: [ - '对运行时状态做局部变更,不直接暴露 HTTP 路由。', - ], - keyFiles: [ - 'server-node/src/modules/inventory/inventoryMutationService.ts', - 'server-node/src/modules/inventory/inventoryStoryActionService.ts', - 'server-node/src/modules/inventory/npcInventoryStoryActionService.ts', - ], - }, - { - id: 'npc', - title: 'NPC 交互模块', - directory: 'server-node/src/modules/npc', - exposedBySurfaceIds: ['runtime-story-action', 'runtime-main'], - responsibilities: [ - '维护 NPC 互动规则、任务 primitive 与关系变更逻辑。', - ], - primaryServiceBoundaries: [ - '专注 NPC 侧状态推导,供 story action 与聊天/任务链路复用。', - ], - keyFiles: [ - 'server-node/src/modules/npc/npcInteractionService.ts', - 'server-node/src/modules/npc/npcTask6Primitives.ts', - ], - }, - { - id: 'progression', - title: '成长与关卡进程模块', - directory: 'server-node/src/modules/progression', - exposedBySurfaceIds: ['runtime-story-action', 'runtime-main'], - responsibilities: [ - '提供角色成长、敌对等级、章节推进与 benchmark 逻辑。', - ], - primaryServiceBoundaries: [ - '只做成长数值与章节进度计算,由 runtime hydrate 与 story action 复用。', - ], - keyFiles: [ - 'server-node/src/modules/progression/playerProgressionService.ts', - 'server-node/src/modules/progression/hostileProgressionService.ts', - 'server-node/src/modules/progression/chapterProgressionPlanner.ts', - ], - }, - { - id: 'quest', - title: '任务运行时模块', - directory: 'server-node/src/modules/quest', - exposedBySurfaceIds: ['runtime-main', 'runtime-story-action'], - responsibilities: [ - '生成任务意图、维护任务日志与处理任务进度信号。', - '为运行时 quest 接口与 story action 提供统一任务语义。', - ], - primaryServiceBoundaries: [ - '领域逻辑以 quest module 为中心,AI 生成只是一种输入来源。', - '不直接处理 HTTP 响应,统一由 routes/service 层调用。', - ], - keyFiles: [ - 'server-node/src/modules/quest/runtimeQuestModule.ts', - 'server-node/src/modules/quest/questProgressionService.ts', - 'server-node/src/modules/quest/questStoryActionService.ts', - ], - }, - { - id: 'runtime', - title: '运行时状态基座模块', - directory: 'server-node/src/modules/runtime', - exposedBySurfaceIds: ['runtime-main', 'runtime-story-action'], - responsibilities: [ - '定义运行时状态 primitive、经济与装备规则。', - '负责存档 hydration、兼容迁移与状态归一化。', - ], - primaryServiceBoundaries: [ - '是 runtimeRepository 与 story action 的共同状态基座,不承担 HTTP 入口职责。', - ], - keyFiles: [ - 'server-node/src/modules/runtime/runtimeSnapshotHydration.ts', - 'server-node/src/modules/runtime/runtimeStatePrimitives.ts', - 'server-node/src/modules/runtime/runtimeEquipmentModule.ts', - ], - }, - { - id: 'runtime-item', - title: '运行时物品模块', - directory: 'server-node/src/modules/runtime-item', - exposedBySurfaceIds: ['runtime-main', 'runtime-story-action'], - responsibilities: [ - '生成运行时物品意图、物品奖励与剧情指纹。', - '维护宝藏与物品解析逻辑。', - ], - primaryServiceBoundaries: [ - '聚焦物品领域编译与奖励拼装,由 route/service 选择具体触发时机。', - ], - keyFiles: [ - 'server-node/src/modules/runtime-item/runtimeItemModule.ts', - 'server-node/src/modules/runtime-item/runtimeItemResolutionService.ts', - 'server-node/src/modules/runtime-item/treasureStoryActionService.ts', - ], - }, - { - id: 'story', - title: '故事会话模块', - directory: 'server-node/src/modules/story', - exposedBySurfaceIds: ['runtime-main', 'runtime-story-action'], - responsibilities: [ - '维护运行时故事会话状态与 action 分发。', - '为 story resolve、story state 查询提供统一入口。', - ], - primaryServiceBoundaries: [ - 'story 模块是 runtime 主循环的编排层,必要时再向 quest、inventory、combat 等领域模块分发。', - ], - keyFiles: [ - 'server-node/src/modules/story/runtimeSession.ts', - 'server-node/src/modules/story/storyActionRoutes.ts', - 'server-node/src/modules/story/storyActionService.ts', - ], - }, -]; - -export const BACKEND_ROUTE_CAPABILITIES: BackendRouteCapability[] = [ - defineRoute({ - id: 'health.check', - surfaceId: 'health', - group: 'health', - method: 'GET', - path: '/healthz', - operation: 'health.check', - access: '公开', - responseMode: 'json', - summary: '返回 Node 后端进程健康状态。', - domainModuleIds: [], - sourceFile: APP_SOURCE_FILE, - sourceHint: '/healthz', - }), - - defineAuthRoute({ - id: 'auth.loginOptions', - group: 'auth-entry', - method: 'GET', - path: '/login-options', - operation: 'auth.login_options', - access: '公开', - responseMode: 'json', - summary: '返回当前启用的登录方式与入口配置。', - domainModuleIds: [], - sourceHint: '/login-options', - }), - defineAuthRoute({ - id: 'auth.entry', - group: 'auth-entry', - method: 'POST', - path: '/entry', - operation: 'auth.entry', - access: '公开', - responseMode: 'json', - summary: '用户名密码登录;不存在则创建本地账号。', - domainModuleIds: [], - sourceHint: '/entry', - }), - defineAuthRoute({ - id: 'auth.phoneSendCode', - group: 'auth-phone', - method: 'POST', - path: '/phone/send-code', - operation: 'auth.phone.send_code', - access: '公开', - responseMode: 'json', - summary: '发送手机号登录或绑定验证码。', - domainModuleIds: [], - sourceHint: '/phone/send-code', - }), - defineAuthRoute({ - id: 'auth.phoneChange', - group: 'auth-phone', - method: 'POST', - path: '/phone/change', - operation: 'auth.phone.change', - access: 'JWT', - responseMode: 'json', - summary: '已登录用户更换绑定手机号。', - domainModuleIds: [], - sourceHint: '/phone/change', - }), - defineAuthRoute({ - id: 'auth.phoneLogin', - group: 'auth-phone', - method: 'POST', - path: '/phone/login', - operation: 'auth.phone.login', - access: '公开', - responseMode: 'json', - summary: '手机号验证码登录。', - domainModuleIds: [], - sourceHint: '/phone/login', - }), - defineAuthRoute({ - id: 'auth.wechatStart', - group: 'auth-wechat', - method: 'GET', - path: '/wechat/start', - operation: 'auth.wechat.start', - access: '公开', - responseMode: 'json', - summary: '发起微信登录并返回授权 URL。', - domainModuleIds: [], - sourceHint: '/wechat/start', - }), - defineAuthRoute({ - id: 'auth.wechatCallback', - group: 'auth-wechat', - method: 'GET', - path: '/wechat/callback', - operation: 'auth.wechat.callback', - access: '公开', - responseMode: 'redirect', - summary: '处理微信回调并重定向回前端。', - domainModuleIds: [], - sourceHint: '/wechat/callback', - }), - defineAuthRoute({ - id: 'auth.wechatBindPhone', - group: 'auth-wechat', - method: 'POST', - path: '/wechat/bind-phone', - operation: 'auth.wechat.bind_phone', - access: 'JWT', - responseMode: 'json', - summary: '为已登录微信账号绑定手机号。', - domainModuleIds: [], - sourceHint: '/wechat/bind-phone', - }), - defineAuthRoute({ - id: 'auth.refresh', - group: 'auth-session', - method: 'POST', - path: '/refresh', - operation: 'auth.refresh', - access: '公开', - responseMode: 'json', - summary: '使用 refresh session 刷新 JWT。', - domainModuleIds: [], - sourceHint: '/refresh', - }), - defineAuthRoute({ - id: 'auth.riskBlocks', - group: 'auth-risk', - method: 'GET', - path: '/risk-blocks', - operation: 'auth.risk_blocks', - access: 'JWT', - responseMode: 'json', - summary: '查询当前用户命中的风控封禁。', - domainModuleIds: [], - sourceHint: '/risk-blocks', - }), - defineAuthRoute({ - id: 'auth.riskBlocksLift', - group: 'auth-risk', - method: 'POST', - path: '/risk-blocks/:scopeType/lift', - operation: 'auth.risk_blocks.lift', - access: 'JWT', - responseMode: 'json', - summary: '请求解除指定维度的风控拦截。', - domainModuleIds: [], - sourceHint: '/risk-blocks/:scopeType/lift', - }), - defineAuthRoute({ - id: 'auth.sessions', - group: 'auth-session', - method: 'GET', - path: '/sessions', - operation: 'auth.sessions', - access: 'JWT', - responseMode: 'json', - summary: '列出当前账号的活跃会话。', - domainModuleIds: [], - sourceHint: '/sessions', - }), - defineAuthRoute({ - id: 'auth.sessionRevoke', - group: 'auth-session', - method: 'POST', - path: '/sessions/:sessionId/revoke', - operation: 'auth.sessions.revoke', - access: 'JWT', - responseMode: 'json', - summary: '吊销指定会话。', - domainModuleIds: [], - sourceHint: '/sessions/:sessionId/revoke', - }), - defineAuthRoute({ - id: 'auth.auditLogs', - group: 'auth-audit', - method: 'GET', - path: '/audit-logs', - operation: 'auth.audit_logs', - access: 'JWT', - responseMode: 'json', - summary: '查询当前账号的鉴权审计日志。', - domainModuleIds: [], - sourceHint: '/audit-logs', - }), - defineAuthRoute({ - id: 'auth.me', - group: 'auth-profile', - method: 'GET', - path: '/me', - operation: 'auth.me', - access: 'JWT', - responseMode: 'json', - summary: '读取当前登录用户的鉴权资料。', - domainModuleIds: [], - sourceHint: '/me', - }), - defineAuthRoute({ - id: 'auth.logoutAll', - group: 'auth-session', - method: 'POST', - path: '/logout-all', - operation: 'auth.logout_all', - access: 'JWT', - responseMode: 'json', - summary: '退出当前账号的全部会话。', - domainModuleIds: [], - sourceHint: '/logout-all', - }), - defineAuthRoute({ - id: 'auth.logout', - group: 'auth-session', - method: 'POST', - path: '/logout', - operation: 'auth.logout', - access: 'JWT', - responseMode: 'json', - summary: '退出当前会话并清理 refresh cookie。', - domainModuleIds: [], - sourceHint: '/logout', - }), - - defineEditorRoute({ - id: 'editor.catalogItems', - group: 'editor-catalog', - method: 'GET', - path: '/api/editor/catalog/items', - operation: 'editor.catalog.items.list', - access: '开关: EDITOR_API_ENABLED', - responseMode: 'json', - summary: '列出 `public/Icons` 下的物品图标资源。', - domainModuleIds: ['editor'], - sourceHint: '/api/editor/catalog/items', - }), - defineEditorRoute({ - id: 'editor.resourceRead', - group: 'editor-json', - method: 'GET', - path: '/api/editor/json/:resourceId', - operation: 'editor.resource.read', - access: '开关: EDITOR_API_ENABLED', - responseMode: 'json', - summary: '读取指定编辑器资源 JSON。', - domainModuleIds: ['editor'], - sourceHint: '/api/editor/json/:resourceId', - }), - defineEditorRoute({ - id: 'editor.resourceWrite', - group: 'editor-json', - method: 'POST', - path: '/api/editor/json/:resourceId', - operation: 'editor.resource.write', - access: '开关: EDITOR_API_ENABLED', - responseMode: 'json', - summary: '回写指定编辑器资源 JSON。', - domainModuleIds: ['editor'], - sourceHint: '/api/editor/json/:resourceId', - }), - - defineAssetRoute({ - id: 'assets.characterWorkflowCacheSave', - group: 'assets-character-cache', - method: 'POST', - path: '/api/assets/character-workflow-cache', - operation: 'assets.character.workflowCache.save', - access: '开关: ASSETS_API_ENABLED', - responseMode: 'json', - summary: '保存角色资产工作流缓存。', - domainModuleIds: ['assets'], - sourceHint: 'assets.character.workflowCache.save', - }), - defineAssetRoute({ - id: 'assets.characterWorkflowCacheGet', - group: 'assets-character-cache', - method: 'GET', - path: '/api/assets/character-workflow-cache/:characterId', - operation: 'assets.character.workflowCache.get', - access: '开关: ASSETS_API_ENABLED', - responseMode: 'json', - summary: '按角色读取角色资产工作流缓存。', - domainModuleIds: ['assets'], - sourceHint: 'assets.character.workflowCache.get', - }), - defineAssetRoute({ - id: 'assets.characterVisualGenerate', - group: 'assets-character-visual', - method: 'POST', - path: '/api/assets/character-visual/generate', - operation: 'assets.character.visual.generate', - access: '开关: ASSETS_API_ENABLED', - responseMode: 'json', - summary: '生成角色主形象候选图。', - domainModuleIds: ['assets'], - sourceHint: 'assets.character.visual.generate', - }), - defineAssetRoute({ - id: 'assets.characterVisualPublish', - group: 'assets-character-visual', - method: 'POST', - path: '/api/assets/character-visual/publish', - operation: 'assets.character.visual.publish', - access: '开关: ASSETS_API_ENABLED', - responseMode: 'json', - summary: '发布选中的角色主形象到 public 目录。', - domainModuleIds: ['assets'], - sourceHint: 'assets.character.visual.publish', - }), - defineAssetRoute({ - id: 'assets.characterVisualJobGet', - group: 'assets-character-visual', - method: 'GET', - path: '/api/assets/character-visual/jobs/:taskId', - operation: 'assets.character.visual.job.get', - access: '开关: ASSETS_API_ENABLED', - responseMode: 'json', - summary: '查询角色主形象生成任务状态。', - domainModuleIds: ['assets'], - sourceHint: 'assets.character.visual.job.get', - }), - defineAssetRoute({ - id: 'assets.characterAnimationGenerate', - group: 'assets-character-animation', - method: 'POST', - path: '/api/assets/character-animation/generate', - operation: 'assets.character.animation.generate', - access: '开关: ASSETS_API_ENABLED', - responseMode: 'json', - summary: '生成角色动作草稿。', - domainModuleIds: ['assets'], - sourceHint: 'assets.character.animation.generate', - }), - defineAssetRoute({ - id: 'assets.characterAnimationPublish', - group: 'assets-character-animation', - method: 'POST', - path: '/api/assets/character-animation/publish', - operation: 'assets.character.animation.publish', - access: '开关: ASSETS_API_ENABLED', - responseMode: 'json', - summary: '发布角色动作帧集到 public 目录。', - domainModuleIds: ['assets'], - sourceHint: 'assets.character.animation.publish', - }), - defineAssetRoute({ - id: 'assets.characterAnimationJobGet', - group: 'assets-character-animation', - method: 'GET', - path: '/api/assets/character-animation/jobs/:taskId', - operation: 'assets.character.animation.job.get', - access: '开关: ASSETS_API_ENABLED', - responseMode: 'json', - summary: '查询角色动作生成任务状态。', - domainModuleIds: ['assets'], - sourceHint: 'assets.character.animation.job.get', - }), - defineAssetRoute({ - id: 'assets.characterAnimationImportVideo', - group: 'assets-character-animation', - method: 'POST', - path: '/api/assets/character-animation/import-video', - operation: 'assets.character.animation.importVideo', - access: '开关: ASSETS_API_ENABLED', - responseMode: 'json', - summary: '导入动作参考视频并转为可消费素材。', - domainModuleIds: ['assets'], - sourceHint: 'assets.character.animation.importVideo', - }), - defineAssetRoute({ - id: 'assets.characterAnimationTemplatesList', - group: 'assets-character-animation', - method: 'GET', - path: '/api/assets/character-animation/templates', - operation: 'assets.character.animation.templates.list', - access: '开关: ASSETS_API_ENABLED', - responseMode: 'json', - summary: '列出内置角色动作模板。', - domainModuleIds: ['assets'], - sourceHint: 'assets.character.animation.templates.list', - }), - defineQwenAssetRoute({ - id: 'assets.qwenSpriteMasterGenerate', - group: 'assets-qwen', - method: 'POST', - path: '/api/assets/qwen-sprite/master', - operation: 'assets.qwenSprite.master.generate', - access: '开关: ASSETS_API_ENABLED', - responseMode: 'json', - summary: '生成 Qwen 精灵主图。', - domainModuleIds: ['assets'], - sourceHint: 'assets.qwenSprite.master.generate', - }), - defineQwenAssetRoute({ - id: 'assets.qwenSpriteSheetGenerate', - group: 'assets-qwen', - method: 'POST', - path: '/api/assets/qwen-sprite/sheet', - operation: 'assets.qwenSprite.sheet.generate', - access: '开关: ASSETS_API_ENABLED', - responseMode: 'json', - summary: '生成 Qwen 精灵表。', - domainModuleIds: ['assets'], - sourceHint: 'assets.qwenSprite.sheet.generate', - }), - defineQwenAssetRoute({ - id: 'assets.qwenSpriteFrameRepairGenerate', - group: 'assets-qwen', - method: 'POST', - path: '/api/assets/qwen-sprite/frame-repair', - operation: 'assets.qwenSprite.frameRepair.generate', - access: '开关: ASSETS_API_ENABLED', - responseMode: 'json', - summary: '对单帧做 Qwen 修复。', - domainModuleIds: ['assets'], - sourceHint: 'assets.qwenSprite.frameRepair.generate', - }), - defineQwenAssetRoute({ - id: 'assets.qwenSpriteAssetSave', - group: 'assets-qwen', - method: 'POST', - path: '/api/assets/qwen-sprite/save', - operation: 'assets.qwenSprite.asset.save', - access: '开关: ASSETS_API_ENABLED', - responseMode: 'json', - summary: '保存 Qwen 精灵资产到 public 目录。', - domainModuleIds: ['assets'], - sourceHint: 'assets.qwenSprite.asset.save', - }), - - defineStoryActionRoute({ - id: 'storyAction.resolve', - group: 'story-action', - method: 'POST', - path: '/actions/resolve', - operation: 'runtime.story.actions.resolve', - access: 'JWT', - responseMode: 'json', - summary: '解析前端 story choice 动作为新的运行时结果。', - domainModuleIds: [ - 'story', - 'quest', - 'inventory', - 'runtime-item', - 'npc', - 'progression', - 'combat', - 'runtime', - ], - sourceHint: '/actions/resolve', - }), - defineStoryActionRoute({ - id: 'storyAction.stateGet', - group: 'story-action', - method: 'GET', - path: '/state/:sessionId', - operation: 'runtime.story.state.get', - access: 'JWT', - responseMode: 'json', - summary: '读取指定 story session 的运行时状态。', - domainModuleIds: ['story', 'runtime'], - sourceHint: '/state/:sessionId', - }), - - defineRuntimeRoute({ - id: 'runtime.customWorldGalleryList', - group: 'runtime-gallery', - method: 'GET', - path: '/runtime/custom-world-gallery', - operation: 'runtime.customWorldGallery.list', - access: '公开', - responseMode: 'json', - summary: '列出公开的自定义世界画廊。', - domainModuleIds: ['custom-world', 'runtime'], - sourceHint: '/runtime/custom-world-gallery', - }), - defineRuntimeRoute({ - id: 'runtime.customWorldGalleryDetail', - group: 'runtime-gallery', - method: 'GET', - path: '/runtime/custom-world-gallery/:ownerUserId/:profileId', - operation: 'runtime.customWorldGallery.detail', - access: '公开', - responseMode: 'json', - summary: '读取指定公开世界作品详情。', - domainModuleIds: ['custom-world', 'runtime'], - sourceHint: '/runtime/custom-world-gallery/:ownerUserId/:profileId', - }), - - defineRuntimeAgentRoute({ - id: 'runtime.customWorldAgentCreateSession', - group: 'runtime-custom-world-agent', - method: 'POST', - path: '/runtime/custom-world/agent/sessions', - operation: 'runtime.customWorldAgent.createSession', - access: 'JWT', - responseMode: 'json', - summary: '创建 Custom World Agent 会话。', - domainModuleIds: ['custom-world', 'ai'], - sourceHint: '/sessions', - }), - defineRuntimeAgentRoute({ - id: 'runtime.customWorldAgentGetSession', - group: 'runtime-custom-world-agent', - method: 'GET', - path: '/runtime/custom-world/agent/sessions/:sessionId', - operation: 'runtime.customWorldAgent.getSession', - access: 'JWT', - responseMode: 'json', - summary: '读取 Agent 会话快照。', - domainModuleIds: ['custom-world', 'ai'], - sourceHint: '/sessions/:sessionId', - }), - defineRuntimeAgentRoute({ - id: 'runtime.customWorldAgentSendMessage', - group: 'runtime-custom-world-agent', - method: 'POST', - path: '/runtime/custom-world/agent/sessions/:sessionId/messages', - operation: 'runtime.customWorldAgent.sendMessage', - access: 'JWT', - responseMode: 'json', - summary: '向 Agent 会话提交一条创作消息。', - domainModuleIds: ['custom-world', 'ai'], - sourceHint: '/sessions/:sessionId/messages', - }), - defineRuntimeAgentRoute({ - id: 'runtime.customWorldAgentStreamMessage', - group: 'runtime-custom-world-agent', - method: 'POST', - path: '/runtime/custom-world/agent/sessions/:sessionId/messages/stream', - operation: 'runtime.customWorldAgent.streamMessage', - access: 'JWT', - responseMode: 'stream', - summary: '流式提交 Agent 消息并实时接收回执。', - domainModuleIds: ['custom-world', 'ai'], - sourceHint: '/sessions/:sessionId/messages/stream', - }), - defineRuntimeAgentRoute({ - id: 'runtime.customWorldAgentExecuteAction', - group: 'runtime-custom-world-agent', - method: 'POST', - path: '/runtime/custom-world/agent/sessions/:sessionId/actions', - operation: 'runtime.customWorldAgent.executeAction', - access: 'JWT', - responseMode: 'json', - summary: '执行 Agent 卡片生成、资产同步或发布动作。', - domainModuleIds: ['custom-world', 'ai', 'assets'], - sourceHint: '/sessions/:sessionId/actions', - }), - defineRuntimeAgentRoute({ - id: 'runtime.customWorldAgentGetOperation', - group: 'runtime-custom-world-agent', - method: 'GET', - path: '/runtime/custom-world/agent/sessions/:sessionId/operations/:operationId', - operation: 'runtime.customWorldAgent.getOperation', - access: 'JWT', - responseMode: 'json', - summary: '查询 Agent 后台操作状态。', - domainModuleIds: ['custom-world', 'ai'], - sourceHint: '/sessions/:sessionId/operations/:operationId', - }), - defineRuntimeAgentRoute({ - id: 'runtime.customWorldAgentGetCardDetail', - group: 'runtime-custom-world-agent', - method: 'GET', - path: '/runtime/custom-world/agent/sessions/:sessionId/cards/:cardId', - operation: 'runtime.customWorldAgent.getCardDetail', - access: 'JWT', - responseMode: 'json', - summary: '读取 Agent 卡片详情。', - domainModuleIds: ['custom-world', 'ai'], - sourceHint: '/sessions/:sessionId/cards/:cardId', - }), - - ...defineRuntimeCompatRoutes({ - idPrefix: 'runtime.profileDashboard', - method: 'GET', - basePath: '/profile/dashboard', - operation: 'profile.dashboard.get', - group: 'runtime-profile', - access: 'JWT', - responseMode: 'json', - summary: '读取运行时个人主页汇总。', - domainModuleIds: ['runtime'], - sourceHint: "routeCompatPaths('/profile/dashboard')", - }), - ...defineRuntimeCompatRoutes({ - idPrefix: 'runtime.profileWalletLedger', - method: 'GET', - basePath: '/profile/wallet-ledger', - operation: 'profile.walletLedger.list', - group: 'runtime-profile', - access: 'JWT', - responseMode: 'json', - summary: '列出个人资产流水。', - domainModuleIds: ['runtime'], - sourceHint: "routeCompatPaths('/profile/wallet-ledger')", - }), - ...defineRuntimeCompatRoutes({ - idPrefix: 'runtime.profilePlayStats', - method: 'GET', - basePath: '/profile/play-stats', - operation: 'profile.playStats.get', - group: 'runtime-profile', - access: 'JWT', - responseMode: 'json', - summary: '读取个人游玩统计。', - domainModuleIds: ['runtime'], - sourceHint: "routeCompatPaths('/profile/play-stats')", - }), - ...defineRuntimeCompatRoutes({ - idPrefix: 'runtime.profileBrowseHistoryGet', - method: 'GET', - basePath: '/profile/browse-history', - operation: 'profile.browseHistory.list', - group: 'runtime-profile', - access: 'JWT', - responseMode: 'json', - summary: '读取平台浏览历史。', - domainModuleIds: ['runtime'], - sourceHint: "routeCompatPaths('/profile/browse-history')", - }), - ...defineRuntimeCompatRoutes({ - idPrefix: 'runtime.profileBrowseHistoryPost', - method: 'POST', - basePath: '/profile/browse-history', - operation: 'profile.browseHistory.upsert', - group: 'runtime-profile', - access: 'JWT', - responseMode: 'json', - summary: '写入或批量同步平台浏览历史。', - domainModuleIds: ['runtime'], - sourceHint: "routeCompatPaths('/profile/browse-history')", - }), - ...defineRuntimeCompatRoutes({ - idPrefix: 'runtime.profileBrowseHistoryDelete', - method: 'DELETE', - basePath: '/profile/browse-history', - operation: 'profile.browseHistory.clear', - group: 'runtime-profile', - access: 'JWT', - responseMode: 'json', - summary: '清空平台浏览历史。', - domainModuleIds: ['runtime'], - sourceHint: "routeCompatPaths('/profile/browse-history')", - }), - ...defineRuntimeCompatRoutes({ - idPrefix: 'runtime.profileSaveArchivesList', - method: 'GET', - basePath: '/profile/save-archives', - operation: 'profile.saveArchives.list', - group: 'runtime-save', - access: 'JWT', - responseMode: 'json', - summary: '列出个人存档摘要。', - domainModuleIds: ['runtime'], - sourceHint: "routeCompatPaths('/profile/save-archives')", - }), - defineRuntimeRoute({ - id: 'runtime.profileSaveArchivesResume.primary', - group: 'runtime-save', - method: 'POST', - path: '/profile/save-archives/:worldKey', - operation: 'profile.saveArchives.resume', - access: 'JWT', - responseMode: 'json', - summary: '恢复指定世界的最近存档。', - domainModuleIds: ['runtime'], - sourceHint: "'/profile/save-archives/:worldKey'", - }), - defineRuntimeRoute({ - id: 'runtime.profileSaveArchivesResume.compat', - group: 'runtime-save', - method: 'POST', - path: '/runtime/profile/save-archives/:worldKey', - operation: 'profile.saveArchives.resume.compat', - access: 'JWT', - responseMode: 'json', - summary: '恢复指定世界的最近存档(兼容路径)。', - domainModuleIds: ['runtime'], - sourceHint: "'/runtime/profile/save-archives/:worldKey'", - }), - - defineRuntimeRoute({ - id: 'runtime.llmChatCompletionsProxy', - group: 'runtime-proxy', - method: 'POST', - path: '/llm/chat/completions', - operation: 'runtime.llm.chatCompletionsProxy', - access: 'JWT', - responseMode: 'proxy', - summary: '把聊天补全请求透传到上游模型。', - domainModuleIds: ['ai'], - sourceHint: '/llm/chat/completions', - }), - defineRuntimeRoute({ - id: 'runtime.customWorldCoverImage', - group: 'runtime-custom-world-assets', - method: 'POST', - path: '/custom-world/cover-image', - operation: 'runtime.customWorld.coverImage', - access: 'JWT', - responseMode: 'json', - summary: '生成自定义世界封面图。', - domainModuleIds: ['custom-world', 'assets'], - sourceHint: '/custom-world/cover-image', - }), - defineRuntimeRoute({ - id: 'runtime.customWorldCoverUpload', - group: 'runtime-custom-world-assets', - method: 'POST', - path: '/custom-world/cover-upload', - operation: 'runtime.customWorld.coverUpload', - access: 'JWT', - responseMode: 'json', - summary: '上传并落地自定义世界封面图。', - domainModuleIds: ['custom-world', 'assets'], - sourceHint: '/custom-world/cover-upload', - }), - defineRuntimeRoute({ - id: 'runtime.customWorldSceneImage', - group: 'runtime-custom-world-assets', - method: 'POST', - path: '/custom-world/scene-image', - operation: 'runtime.customWorld.sceneImage', - access: 'JWT', - responseMode: 'json', - summary: '生成自定义世界场景图。', - domainModuleIds: ['custom-world', 'assets'], - sourceHint: '/custom-world/scene-image', - }), - defineRuntimeRoute({ - id: 'runtime.customWorldEntity.primary', - group: 'runtime-custom-world-assets', - method: 'POST', - path: '/custom-world/entity', - operation: 'runtime.customWorld.entity', - access: 'JWT', - responseMode: 'json', - summary: '按世界 profile 生成单个角色或地标实体。', - domainModuleIds: ['custom-world', 'ai'], - sourceHint: '/custom-world/entity', - }), - defineRuntimeRoute({ - id: 'runtime.customWorldEntity.compat', - group: 'runtime-custom-world-assets', - method: 'POST', - path: '/runtime/custom-world/entity', - operation: 'runtime.customWorld.entity.compat', - access: 'JWT', - responseMode: 'json', - summary: '按世界 profile 生成单个角色或地标实体(兼容路径)。', - domainModuleIds: ['custom-world', 'ai'], - sourceHint: '/runtime/custom-world/entity', - }), - defineRuntimeRoute({ - id: 'runtime.customWorldSceneNpc.primary', - group: 'runtime-custom-world-assets', - method: 'POST', - path: '/custom-world/scene-npc', - operation: 'runtime.customWorld.sceneNpc', - access: 'JWT', - responseMode: 'json', - summary: '按地标生成场景 NPC。', - domainModuleIds: ['custom-world', 'ai', 'npc'], - sourceHint: '/custom-world/scene-npc', - }), - defineRuntimeRoute({ - id: 'runtime.customWorldSceneNpc.compat', - group: 'runtime-custom-world-assets', - method: 'POST', - path: '/runtime/custom-world/scene-npc', - operation: 'runtime.customWorld.sceneNpc.compat', - access: 'JWT', - responseMode: 'json', - summary: '按地标生成场景 NPC(兼容路径)。', - domainModuleIds: ['custom-world', 'ai', 'npc'], - sourceHint: '/runtime/custom-world/scene-npc', - }), - - defineRuntimeRoute({ - id: 'runtime.snapshotGet', - group: 'runtime-save', - method: 'GET', - path: '/runtime/save/snapshot', - operation: 'runtime.snapshot.get', - access: 'JWT', - responseMode: 'json', - summary: '读取当前用户的运行时存档。', - domainModuleIds: ['runtime', 'progression', 'quest'], - sourceHint: '/runtime/save/snapshot', - }), - defineRuntimeRoute({ - id: 'runtime.snapshotPut', - group: 'runtime-save', - method: 'PUT', - path: '/runtime/save/snapshot', - operation: 'runtime.snapshot.put', - access: 'JWT', - responseMode: 'json', - summary: '保存并归一化当前运行时存档。', - domainModuleIds: ['runtime', 'progression', 'quest'], - sourceHint: '/runtime/save/snapshot', - }), - defineRuntimeRoute({ - id: 'runtime.snapshotDelete', - group: 'runtime-save', - method: 'DELETE', - path: '/runtime/save/snapshot', - operation: 'runtime.snapshot.delete', - access: 'JWT', - responseMode: 'json', - summary: '删除当前用户的运行时存档。', - domainModuleIds: ['runtime'], - sourceHint: '/runtime/save/snapshot', - }), - defineRuntimeRoute({ - id: 'runtime.settingsGet', - group: 'runtime-settings', - method: 'GET', - path: '/runtime/settings', - operation: 'runtime.settings.get', - access: 'JWT', - responseMode: 'json', - summary: '读取运行时设置。', - domainModuleIds: ['runtime'], - sourceHint: '/runtime/settings', - }), - defineRuntimeRoute({ - id: 'runtime.settingsPut', - group: 'runtime-settings', - method: 'PUT', - path: '/runtime/settings', - operation: 'runtime.settings.put', - access: 'JWT', - responseMode: 'json', - summary: '更新运行时设置。', - domainModuleIds: ['runtime'], - sourceHint: '/runtime/settings', - }), - defineRuntimeRoute({ - id: 'runtime.customWorldWorksList', - group: 'runtime-custom-world-library', - method: 'GET', - path: '/runtime/custom-world/works', - operation: 'runtime.customWorldWorks.list', - access: 'JWT', - responseMode: 'json', - summary: '列出当前账号的自定义世界作品汇总。', - domainModuleIds: ['custom-world', 'runtime'], - sourceHint: '/runtime/custom-world/works', - }), - defineRuntimeRoute({ - id: 'runtime.customWorldLibraryList', - group: 'runtime-custom-world-library', - method: 'GET', - path: '/runtime/custom-world-library', - operation: 'runtime.customWorldLibrary.list', - access: 'JWT', - responseMode: 'json', - summary: '列出当前账号的自定义世界资料库。', - domainModuleIds: ['custom-world', 'runtime'], - sourceHint: '/runtime/custom-world-library', - }), - defineRuntimeRoute({ - id: 'runtime.customWorldLibraryUpsert', - group: 'runtime-custom-world-library', - method: 'PUT', - path: '/runtime/custom-world-library/:profileId', - operation: 'runtime.customWorldLibrary.upsert', - access: 'JWT', - responseMode: 'json', - summary: '写入或更新指定自定义世界 profile。', - domainModuleIds: ['custom-world', 'runtime'], - sourceHint: '/runtime/custom-world-library/:profileId', - }), - defineRuntimeRoute({ - id: 'runtime.customWorldLibraryDelete', - group: 'runtime-custom-world-library', - method: 'DELETE', - path: '/runtime/custom-world-library/:profileId', - operation: 'runtime.customWorldLibrary.delete', - access: 'JWT', - responseMode: 'json', - summary: '删除指定自定义世界 profile。', - domainModuleIds: ['custom-world', 'runtime'], - sourceHint: '/runtime/custom-world-library/:profileId', - }), - defineRuntimeRoute({ - id: 'runtime.customWorldLibraryPublish', - group: 'runtime-custom-world-library', - method: 'POST', - path: '/runtime/custom-world-library/:profileId/publish', - operation: 'runtime.customWorldLibrary.publish', - access: 'JWT', - responseMode: 'json', - summary: '发布指定世界到公开画廊。', - domainModuleIds: ['custom-world', 'runtime'], - sourceHint: '/runtime/custom-world-library/:profileId/publish', - }), - defineRuntimeRoute({ - id: 'runtime.customWorldLibraryUnpublish', - group: 'runtime-custom-world-library', - method: 'POST', - path: '/runtime/custom-world-library/:profileId/unpublish', - operation: 'runtime.customWorldLibrary.unpublish', - access: 'JWT', - responseMode: 'json', - summary: '撤回指定世界的公开发布状态。', - domainModuleIds: ['custom-world', 'runtime'], - sourceHint: '/runtime/custom-world-library/:profileId/unpublish', - }), - - defineRuntimeRoute({ - id: 'runtime.storyInitial', - group: 'runtime-story-generation', - method: 'POST', - path: '/runtime/story/initial', - operation: 'runtime.story.initial', - access: 'JWT', - responseMode: 'json', - summary: '生成首段故事内容。', - domainModuleIds: ['story', 'ai'], - sourceHint: '/runtime/story/initial', - }), - defineRuntimeRoute({ - id: 'runtime.storyContinue', - group: 'runtime-story-generation', - method: 'POST', - path: '/runtime/story/continue', - operation: 'runtime.story.continue', - access: 'JWT', - responseMode: 'json', - summary: '生成下一段故事内容。', - domainModuleIds: ['story', 'ai'], - sourceHint: '/runtime/story/continue', - }), - defineRuntimeRoute({ - id: 'runtime.characterSuggestions', - group: 'runtime-chat', - method: 'POST', - path: '/runtime/chat/character/suggestions', - operation: 'runtime.chat.character.suggestions', - access: 'JWT', - responseMode: 'json', - summary: '生成角色聊天建议语。', - domainModuleIds: ['ai', 'story'], - sourceHint: '/runtime/chat/character/suggestions', - }), - defineRuntimeRoute({ - id: 'runtime.characterSummary', - group: 'runtime-chat', - method: 'POST', - path: '/runtime/chat/character/summary', - operation: 'runtime.chat.character.summary', - access: 'JWT', - responseMode: 'json', - summary: '生成角色聊天摘要。', - domainModuleIds: ['ai', 'story'], - sourceHint: '/runtime/chat/character/summary', - }), - defineRuntimeRoute({ - id: 'runtime.characterReplyStream', - group: 'runtime-chat', - method: 'POST', - path: '/runtime/chat/character/reply/stream', - operation: 'runtime.chat.character.replyStream', - access: 'JWT', - responseMode: 'stream', - summary: '流式生成角色回复。', - domainModuleIds: ['ai', 'story'], - sourceHint: '/runtime/chat/character/reply/stream', - }), - defineRuntimeRoute({ - id: 'runtime.npcDialogueStream', - group: 'runtime-chat', - method: 'POST', - path: '/runtime/chat/npc/dialogue/stream', - operation: 'runtime.chat.npc.dialogueStream', - access: 'JWT', - responseMode: 'stream', - summary: '流式生成 NPC 对话。', - domainModuleIds: ['ai', 'npc', 'story'], - sourceHint: '/runtime/chat/npc/dialogue/stream', - }), - defineRuntimeRoute({ - id: 'runtime.npcTurnStream', - group: 'runtime-chat', - method: 'POST', - path: '/runtime/chat/npc/turn/stream', - operation: 'runtime.chat.npc.turnStream', - access: 'JWT', - responseMode: 'stream', - summary: '流式生成 NPC 单回合发言。', - domainModuleIds: ['ai', 'npc', 'story'], - sourceHint: '/runtime/chat/npc/turn/stream', - }), - defineRuntimeRoute({ - id: 'runtime.npcRecruitStream', - group: 'runtime-chat', - method: 'POST', - path: '/runtime/chat/npc/recruit/stream', - operation: 'runtime.chat.npc.recruitStream', - access: 'JWT', - responseMode: 'stream', - summary: '流式生成招募 NPC 对话。', - domainModuleIds: ['ai', 'npc', 'story'], - sourceHint: '/runtime/chat/npc/recruit/stream', - }), - - defineRuntimeRoute({ - id: 'runtime.customWorldSessionCreate', - group: 'runtime-custom-world-session', - method: 'POST', - path: '/runtime/custom-world/sessions', - operation: 'runtime.customWorldSession.create', - access: 'JWT', - responseMode: 'json', - summary: '创建传统自定义世界问答会话。', - domainModuleIds: ['custom-world'], - sourceHint: '/runtime/custom-world/sessions', - }), - defineRuntimeRoute({ - id: 'runtime.customWorldSessionGet', - group: 'runtime-custom-world-session', - method: 'GET', - path: '/runtime/custom-world/sessions/:sessionId', - operation: 'runtime.customWorldSession.get', - access: 'JWT', - responseMode: 'json', - summary: '读取传统自定义世界问答会话。', - domainModuleIds: ['custom-world'], - sourceHint: '/runtime/custom-world/sessions/:sessionId', - }), - defineRuntimeRoute({ - id: 'runtime.customWorldSessionAnswer', - group: 'runtime-custom-world-session', - method: 'POST', - path: '/runtime/custom-world/sessions/:sessionId/answers', - operation: 'runtime.customWorldSession.answer', - access: 'JWT', - responseMode: 'json', - summary: '回答传统自定义世界问答题目。', - domainModuleIds: ['custom-world'], - sourceHint: '/runtime/custom-world/sessions/:sessionId/answers', - }), - defineRuntimeRoute({ - id: 'runtime.customWorldSessionGenerateStream', - group: 'runtime-custom-world-session', - method: 'GET', - path: '/runtime/custom-world/sessions/:sessionId/generate/stream', - operation: 'runtime.customWorldSession.generateStream', - access: 'JWT', - responseMode: 'stream', - summary: '流式编译传统自定义世界 profile。', - domainModuleIds: ['custom-world', 'ai'], - sourceHint: '/runtime/custom-world/sessions/:sessionId/generate/stream', - }), - - defineRuntimeRoute({ - id: 'runtime.itemsIntent', - group: 'runtime-loot', - method: 'POST', - path: '/runtime/items/runtime-intent', - operation: 'runtime.items.intent', - access: 'JWT', - responseMode: 'json', - summary: '生成运行时物品意图。', - domainModuleIds: ['runtime-item', 'ai'], - sourceHint: '/runtime/items/runtime-intent', - }), - defineRuntimeRoute({ - id: 'runtime.questsGenerate', - group: 'runtime-quest', - method: 'POST', - path: '/runtime/quests/generate', - operation: 'runtime.quests.generate', - access: 'JWT', - responseMode: 'json', - summary: '按当前遭遇生成任务候选。', - domainModuleIds: ['quest', 'ai'], - sourceHint: '/runtime/quests/generate', - }), - defineRuntimeRoute({ - id: 'runtime.wsHealth', - group: 'runtime-diagnostics', - method: 'GET', - path: '/ws/health', - operation: 'runtime.ws.health', - access: 'JWT', - responseMode: 'json', - summary: '保留给未来实时链路的占位健康检查。', - domainModuleIds: ['runtime'], - sourceHint: '/ws/health', - }), -]; - -export const BACKEND_CAPABILITY_MANIFEST: BackendCapabilityManifest = { - version: '2026-04-20', - generatedCommand: 'npm run server-node:manifest:backend', - outputTargets: { - json: 'server-node/manifests/backend-capability-index.json', - markdown: 'docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md', - }, - maintenanceRules: [ - '新增 `server-node/src/modules/*` 目录时,必须先补充 manifest 里的模块说明,再重新生成产物。', - '新增或下线路由时,先更新 manifest 里的路由清单,再运行生成命令同步 JSON 与文档。', - '如果路由来自兼容路径或中间件派生路径,`sourceHint` 需要指向源代码里的真实表达式,确保生成脚本能做最小校验。', - ], - surfaces: BACKEND_ROUTE_SURFACES, - modules: BACKEND_DOMAIN_MODULES, - routes: BACKEND_ROUTE_CAPABILITIES, -}; diff --git a/server-node/src/middleware/auth.ts b/server-node/src/middleware/auth.ts deleted file mode 100644 index a67a415a..00000000 --- a/server-node/src/middleware/auth.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { NextFunction, Request, Response } from 'express'; - -import { verifyAccessToken } from '../auth/token.js'; -import type { AppConfig } from '../config.js'; -import { unauthorized } from '../errors.js'; -import { type UserRepository } from '../repositories/userRepository.js'; - -function readBearerToken(request: Request) { - const authorization = request.header('authorization')?.trim() || ''; - if (!authorization.startsWith('Bearer ')) { - return ''; - } - return authorization.slice('Bearer '.length).trim(); -} - -export function requireJwtAuth(config: AppConfig, userRepository: UserRepository) { - return async (request: Request, _response: Response, next: NextFunction) => { - try { - const token = readBearerToken(request); - if (!token) { - throw unauthorized('缺少 Authorization Bearer Token'); - } - - const claims = await verifyAccessToken(token, config); - const user = await userRepository.findById(claims.userId); - if (!user) { - throw unauthorized('用户不存在'); - } - if (user.accountStatus === 'disabled') { - throw unauthorized('账号已被禁用'); - } - if (user.tokenVersion !== claims.tokenVersion) { - throw unauthorized('登录状态已失效,请重新登录'); - } - - request.auth = claims; - request.userId = claims.userId; - next(); - } catch (error) { - next(error); - } - }; -} diff --git a/server-node/src/middleware/errorHandler.ts b/server-node/src/middleware/errorHandler.ts deleted file mode 100644 index 2f4e6277..00000000 --- a/server-node/src/middleware/errorHandler.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { ErrorRequestHandler } from 'express'; - -import { toHttpError } from '../errors.js'; -import { - applyApiResponseHeaders, - buildApiLogContext, - wantsApiEnvelope, -} from '../http.js'; - -export const errorHandler: ErrorRequestHandler = ( - error, - request, - response, - _next, -) => { - const normalizedError = toHttpError(error); - const meta = applyApiResponseHeaders(request, response); - - request.log?.error( - { - err: error, - ...buildApiLogContext(request, response), - user_id: request.userId ?? null, - status: normalizedError.statusCode, - error_code: normalizedError.code, - }, - 'request failed', - ); - - response.status(normalizedError.statusCode); - - const errorPayload = { - code: normalizedError.code, - message: normalizedError.message, - ...(normalizedError.expose && normalizedError.details !== undefined - ? { details: normalizedError.details } - : {}), - }; - - if (wantsApiEnvelope(request)) { - response.json({ - ok: false, - data: null, - error: errorPayload, - meta, - }); - return; - } - - response.json({ - error: errorPayload, - meta, - }); -}; diff --git a/server-node/src/middleware/requestId.ts b/server-node/src/middleware/requestId.ts deleted file mode 100644 index 66ebab5c..00000000 --- a/server-node/src/middleware/requestId.ts +++ /dev/null @@ -1,16 +0,0 @@ -import crypto from 'node:crypto'; - -import type { RequestHandler } from 'express'; - -export const requestIdMiddleware: RequestHandler = ( - request, - response, - next, -) => { - const requestId = - request.header('x-request-id')?.trim() || crypto.randomUUID(); - request.requestId = requestId; - request.requestStartedAt = Date.now(); - response.setHeader('x-request-id', requestId); - next(); -}; diff --git a/server-node/src/middleware/responseEnvelope.ts b/server-node/src/middleware/responseEnvelope.ts deleted file mode 100644 index 83ed6f61..00000000 --- a/server-node/src/middleware/responseEnvelope.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { RequestHandler, Response } from 'express'; - -import { - applyApiResponseHeaders, - isStandardApiErrorResponse, - isStandardApiSuccessEnvelope, - toApiErrorBody, - toApiSuccessBody, -} from '../http.js'; - -function isLegacyApiErrorBody(body: unknown) { - if (!body || typeof body !== 'object' || Array.isArray(body)) { - return false; - } - - return ( - ('error' in body || 'message' in body || 'code' in body) && - !('meta' in body && 'ok' in body) - ); -} - -function patchJsonResponse(response: Response) { - const originalJson = response.json.bind(response); - - response.json = ((body: unknown) => { - if ( - isStandardApiSuccessEnvelope(body) || - isStandardApiErrorResponse(body) - ) { - applyApiResponseHeaders(response.req, response); - return originalJson(body); - } - - if (response.statusCode >= 400 || isLegacyApiErrorBody(body)) { - return originalJson(toApiErrorBody(response.req, response, body)); - } - - return originalJson(toApiSuccessBody(response.req, response, body)); - }) as Response['json']; -} - -export const responseEnvelopeMiddleware: RequestHandler = ( - _request, - response, - next, -) => { - patchJsonResponse(response); - next(); -}; diff --git a/server-node/src/middleware/routeMeta.ts b/server-node/src/middleware/routeMeta.ts deleted file mode 100644 index 85178850..00000000 --- a/server-node/src/middleware/routeMeta.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { RequestHandler } from 'express'; - -import { setRouteMeta, type ApiRouteMeta } from '../http.js'; - -export function routeMeta(meta: ApiRouteMeta): RequestHandler { - return (_request, response, next) => { - setRouteMeta(response, meta); - next(); - }; -} diff --git a/server-node/src/migrate.ts b/server-node/src/migrate.ts deleted file mode 100644 index cc4b1331..00000000 --- a/server-node/src/migrate.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { loadConfig } from './config.js'; -import { - createDatabase, - listAppliedMigrations, - summarizeDatabaseTarget, -} from './db.js'; - -async function main() { - const config = loadConfig(); - const db = await createDatabase(config); - - try { - const migrations = await listAppliedMigrations(db); - console.log( - `[db:migrate] database=${summarizeDatabaseTarget(config.databaseUrl)}`, - ); - console.log(`[db:migrate] applied migrations=${migrations.length}`); - - for (const migration of migrations) { - console.log(`[db:migrate] ${migration.id} ${migration.name}`); - } - } finally { - await db.close(); - } -} - -void main().catch((error) => { - console.error('[db:migrate] failed', error); - process.exit(1); -}); diff --git a/server-node/src/modules/ai/chatOrchestrator.ts b/server-node/src/modules/ai/chatOrchestrator.ts deleted file mode 100644 index 124cb35d..00000000 --- a/server-node/src/modules/ai/chatOrchestrator.ts +++ /dev/null @@ -1,420 +0,0 @@ -import type { Request, Response } from 'express'; - -import type { - CharacterChatReplyRequest, - CharacterChatSuggestionsRequest, - CharacterChatSummaryRequest, - NpcChatDialogueRequest, - type NpcChatPendingQuestOffer, - type NpcChatTurnCompletionDirective, - NpcChatTurnRequest, - NpcRecruitDialogueRequest, -} from '../../../../packages/shared/src/contracts/rpgRuntimeChat.js'; -import { parseLineListContent } from '../../../../packages/shared/src/llm/parsers.js'; -import { prepareEventStreamResponse } from '../../http.js'; -import type { UpstreamLlmClient } from '../../services/llmClient.js'; -import { generateQuestForNpcEncounter } from '../../services/questService.js'; -import { - applyQuestSignal, - getQuestForIssuer, -} from '../quest/questProgressionService.js'; -import { - buildCharacterPanelChatPrompt, - buildCharacterPanelChatSuggestionPrompt, - buildCharacterPanelChatSummaryPrompt, - buildNpcChatTurnReplyPrompt, - buildNpcChatTurnSuggestionPrompt, - buildNpcRecruitDialoguePrompt, - buildStrictNpcChatDialoguePrompt, - CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT, - CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT, - CHARACTER_PANEL_CHAT_SYSTEM_PROMPT, - NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT, - NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT, - NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT, - NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT, -} from './chatPromptBuilders.js'; - -function writeSseEvent( - response: Response, - event: string, - payload: Record, -) { - response.write(`event: ${event}\n`); - response.write(`data: ${JSON.stringify(payload)}\n\n`); -} - -function readRecord(value: unknown) { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : null; -} - -function readNumber(value: unknown, fallback = 0) { - return typeof value === 'number' && Number.isFinite(value) ? value : fallback; -} - -function readBoolean(value: unknown, fallback = false) { - return typeof value === 'boolean' ? value : fallback; -} - -function readString(value: unknown) { - return typeof value === 'string' && value.trim() ? value.trim() : ''; -} - -function readArray(value: unknown) { - return Array.isArray(value) ? value : []; -} - -function countKeywordMatches(text: string, keywords: string[]) { - return keywords.reduce( - (count, keyword) => (text.includes(keyword) ? count + 1 : count), - 0, - ); -} - -function clampAffinityDelta(value: number) { - return Math.max(-3, Math.min(3, value)); -} - -function computeNpcChatAffinityDelta(params: { - playerMessage: string; - npcReply: string; - chattedCount: number; -}) { - const playerMessage = params.playerMessage.trim(); - const npcReply = params.npcReply.trim(); - const positiveKeywords = [ - '谢谢', - '辛苦', - '抱歉', - '理解', - '相信', - '放心', - '一起', - '帮你', - '在意', - '关心', - ]; - const negativeKeywords = [ - '闭嘴', - '滚', - '少废话', - '威胁', - '骗', - '不信', - '别装', - '快说', - '审问', - '怀疑', - ]; - const warmReplyKeywords = ['可以', '愿意', '放心', '谢谢', '明白', '好']; - const coldReplyKeywords = ['没必要', '不想', '别问', '与你无关', '算了', '住口']; - - const positiveScore = - countKeywordMatches(playerMessage, positiveKeywords) + - countKeywordMatches(npcReply, warmReplyKeywords); - const negativeScore = - countKeywordMatches(playerMessage, negativeKeywords) + - countKeywordMatches(npcReply, coldReplyKeywords); - - if (positiveScore === 0 && negativeScore === 0) { - return params.chattedCount === 0 ? 1 : 0; - } - - if (positiveScore > negativeScore) { - const baseDelta = - positiveScore - negativeScore + (params.chattedCount <= 1 ? 1 : 0); - return clampAffinityDelta(baseDelta); - } - - if (negativeScore > positiveScore) { - return clampAffinityDelta(positiveScore - negativeScore); - } - - return 0; -} - -function describeAffinityShift(affinityDelta: number) { - if (affinityDelta >= 8) return '态度明显软化了下来。'; - if (affinityDelta >= 5) return '态度比刚才亲近了一些。'; - if (affinityDelta > 0) return '对话气氛稍微松动了一点。'; - if (affinityDelta < 0) return '这轮对话让气氛变得更紧了一些。'; - return '这轮对话暂时没有带来明显关系变化。'; -} - -function buildFallbackNpcChatSuggestions(playerMessage: string) { - const topic = Array.from(playerMessage.trim() || '刚才那句') - .slice(0, 8) - .join(''); - return [ - '你刚才那句是什么意思', - `这事和${topic}有关吗`, - '你愿意再说清楚点吗', - ]; -} - -function buildQuestOfferDialogueText( - npcName: string, - quest: Record, -) { - const summaryText = - readString(quest.summary) || readString(quest.description); - - return `${npcName}沉吟了片刻,像是终于把真正想托付的事说了出来。${ - summaryText - ? `如果你愿意,我想把这件事正式交给你:${summaryText}` - : '如果你愿意,我想把眼前这件事正式交给你。' - }`; -} - -async function maybeBuildPendingNpcQuestOffer( - llmClient: UpstreamLlmClient, - payload: NpcChatTurnRequest, - affinityDelta: number, -): Promise { - const chatDirective = readRecord(payload.chatDirective); - if ( - readString(chatDirective?.limitReason) === 'negative_affinity' || - readBoolean(chatDirective?.forceExitAfterTurn, false) - ) { - return null; - } - - const questOfferContext = readRecord(payload.questOfferContext); - const state = readRecord(questOfferContext?.state); - const encounter = readRecord(questOfferContext?.encounter); - if (!state || !encounter) { - return null; - } - - const npcId = readString(encounter.id) || readString(encounter.npcName); - const npcName = readString(encounter.npcName); - const characterId = readString(encounter.characterId); - if (!npcId || !npcName || !characterId) { - return null; - } - - const turnCount = Math.max( - 0, - Math.round(readNumber(questOfferContext?.turnCount, 0)), - ); - const npcStates = readRecord(state.npcStates) ?? {}; - const currentNpcState = readRecord(npcStates[npcId]) ?? {}; - const currentQuests = readArray(state.quests) as Parameters< - typeof getQuestForIssuer - >[0]; - const questsAfterTalk = applyQuestSignal(currentQuests, { - kind: 'npc_talk_completed', - npcId, - }).nextQuests; - const nextAffinity = readNumber(currentNpcState.affinity, 0) + affinityDelta; - const warmupTurns = nextAffinity >= 30 ? 1 : 2; - - if (nextAffinity <= 0 || turnCount < warmupTurns) { - return null; - } - - if (getQuestForIssuer(questsAfterTalk, npcId)) { - return null; - } - - const nextState = { - ...state, - quests: questsAfterTalk, - npcStates: { - ...npcStates, - [npcId]: { - ...currentNpcState, - affinity: nextAffinity, - chattedCount: Math.max( - 0, - Math.round(readNumber(currentNpcState.chattedCount, 0)), - ) + 1, - firstMeaningfulContactResolved: true, - }, - }, - }; - - const quest = await generateQuestForNpcEncounter(llmClient, { - state: nextState as never, - encounter: encounter as never, - }); - - if (!quest || typeof quest !== 'object') { - return null; - } - - return { - quest, - introText: buildQuestOfferDialogueText( - npcName, - quest as Record, - ), - }; -} - -export async function generateCharacterChatSuggestionsFromOrchestrator( - llmClient: UpstreamLlmClient, - payload: CharacterChatSuggestionsRequest, -) { - return llmClient.requestMessageContent({ - systemPrompt: CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT, - userPrompt: buildCharacterPanelChatSuggestionPrompt(payload), - }); -} - -export async function generateCharacterChatSummaryFromOrchestrator( - llmClient: UpstreamLlmClient, - payload: CharacterChatSummaryRequest, -) { - return llmClient.requestMessageContent({ - systemPrompt: CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT, - userPrompt: buildCharacterPanelChatSummaryPrompt(payload), - }); -} - -export async function streamCharacterChatReplyFromOrchestrator( - llmClient: UpstreamLlmClient, - params: { - request: Request; - response: Response; - payload: CharacterChatReplyRequest; - }, -) { - await llmClient.forwardSseText({ - request: params.request, - response: params.response, - systemPrompt: CHARACTER_PANEL_CHAT_SYSTEM_PROMPT, - userPrompt: buildCharacterPanelChatPrompt(params.payload), - }); -} - -export async function streamNpcChatDialogueFromOrchestrator( - llmClient: UpstreamLlmClient, - params: { - request: Request; - response: Response; - payload: NpcChatDialogueRequest; - }, -) { - await llmClient.forwardSseText({ - request: params.request, - response: params.response, - systemPrompt: NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT, - userPrompt: buildStrictNpcChatDialoguePrompt(params.payload), - }); -} - -export async function streamNpcChatTurnFromOrchestrator( - llmClient: UpstreamLlmClient, - params: { - request: Request; - response: Response; - payload: NpcChatTurnRequest; - }, -) { - prepareEventStreamResponse(params.request, params.response); - - try { - let streamedReply = ''; - const chatDirective = readRecord(params.payload.chatDirective); - const closingMode = - readString(chatDirective?.closingMode) === 'foreshadow_close' - ? 'foreshadow_close' - : 'free'; - const turnLimit = Math.max(0, readNumber(chatDirective?.turnLimit, 0)); - const remainingTurns = Math.max( - 0, - readNumber(chatDirective?.remainingTurns, 0), - ); - const forceExit = - closingMode === 'foreshadow_close' || - readBoolean(chatDirective?.forceExitAfterTurn, false); - - const npcReply = ( - await llmClient.streamMessageContent({ - systemPrompt: NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT, - userPrompt: buildNpcChatTurnReplyPrompt(params.payload), - debugLabel: 'runtime.npc_chat.turn.reply', - onUpdate: (text) => { - streamedReply = text; - writeSseEvent(params.response, 'reply_delta', { text }); - }, - }) - ).trim(); - - let suggestions: string[] = []; - if (!forceExit) { - const suggestionText = await llmClient.requestMessageContent({ - systemPrompt: NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT, - userPrompt: buildNpcChatTurnSuggestionPrompt( - params.payload, - npcReply || streamedReply, - ), - debugLabel: 'runtime.npc_chat.turn.suggestions', - }); - - suggestions = parseLineListContent(suggestionText, 3); - } - const npcState = readRecord(params.payload.npcState); - const chattedCount = readNumber(npcState?.chattedCount, 0); - const affinityDelta = computeNpcChatAffinityDelta({ - playerMessage: params.payload.playerMessage, - npcReply: npcReply || streamedReply, - chattedCount, - }); - const pendingQuestOffer = forceExit - ? null - : await maybeBuildPendingNpcQuestOffer( - llmClient, - params.payload, - affinityDelta, - ); - const completionDirective: NpcChatTurnCompletionDirective | null = - chatDirective - ? { - turnLimit: turnLimit > 0 ? turnLimit : null, - remainingTurns, - forceExit, - closingMode, - } - : null; - - writeSseEvent(params.response, 'complete', { - npcReply: npcReply || streamedReply, - affinityDelta, - affinityText: describeAffinityShift(affinityDelta), - suggestions: forceExit - ? [] - : suggestions.length === 3 - ? suggestions - : buildFallbackNpcChatSuggestions(params.payload.playerMessage), - pendingQuestOffer, - chatDirective: completionDirective, - }); - params.response.write('data: [DONE]\n\n'); - params.response.end(); - } catch (error) { - const message = - error instanceof Error ? error.message : 'NPC 单轮聊天流式生成失败'; - writeSseEvent(params.response, 'error', { message }); - params.response.end(); - } -} - -export async function streamNpcRecruitDialogueFromOrchestrator( - llmClient: UpstreamLlmClient, - params: { - request: Request; - response: Response; - payload: NpcRecruitDialogueRequest; - }, -) { - await llmClient.forwardSseText({ - request: params.request, - response: params.response, - systemPrompt: NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT, - userPrompt: buildNpcRecruitDialoguePrompt(params.payload), - }); -} diff --git a/server-node/src/modules/ai/chatPromptBuilders.ts b/server-node/src/modules/ai/chatPromptBuilders.ts deleted file mode 100644 index 86bd2775..00000000 --- a/server-node/src/modules/ai/chatPromptBuilders.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '../../prompts/chatPromptBuilders.js'; diff --git a/server-node/src/modules/ai/customWorldOrchestrator.ts b/server-node/src/modules/ai/customWorldOrchestrator.ts deleted file mode 100644 index 8294e443..00000000 --- a/server-node/src/modules/ai/customWorldOrchestrator.ts +++ /dev/null @@ -1,440 +0,0 @@ -import type { - CustomWorldGenerationMode, - CustomWorldGenerationProgress, - GenerateCustomWorldProfileInput, -} from '../../../../packages/shared/src/contracts/runtime.js'; -import { parseJsonResponseText } from '../../../../packages/shared/src/llm/parsers.js'; -import { - buildCompiledCustomWorldProfile, - MIN_CUSTOM_WORLD_LANDMARK_COUNT, - MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT, - MIN_CUSTOM_WORLD_STORY_NPC_COUNT, - validateGeneratedCustomWorldProfile, -} from '../custom-world/runtimeProfile.js'; -import { - buildCustomWorldAnchorPackFromIntent, - buildCustomWorldCreatorIntentGenerationText, - deriveCustomWorldLockStateFromIntent, - hasMeaningfulCustomWorldCreatorIntent, - normalizeCustomWorldCreatorIntent, -} from '../custom-world/creatorIntentRuntime.js'; -import type { - CustomWorldCreatorIntent, - CustomWorldProfile, -} from '../custom-world/runtimeTypes.js'; -import { - buildCustomWorldProfilePrompt, - buildCustomWorldProfileRepairPrompt, - CUSTOM_WORLD_GENERATION_JSON_ONLY_SYSTEM_PROMPT, - CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT, -} from '../../prompts/customWorldOrchestratorPrompts.js'; -import type { UpstreamLlmClient } from '../../services/llmClient.js'; - -type GeneratedProfile = Record; -const CUSTOM_WORLD_REQUEST_TIMEOUT_MS = 180000; - -const FAST_CUSTOM_WORLD_PLAYABLE_COUNT = 3; -const FAST_CUSTOM_WORLD_STORY_COUNT = 8; -const FAST_CUSTOM_WORLD_LANDMARK_COUNT = 4; - -const CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS = [ - { - id: 'prepare', - label: '整理设定', - detail: '整理创作者输入,准备模型推理上下文。', - total: 1, - weight: 1, - }, - { - id: 'llm-profile', - label: '大模型推理', - detail: '正在请求模型生成世界档案、角色群像与场景网络。', - total: 1, - weight: 8, - }, - { - id: 'normalize', - label: '系统编译', - detail: '正在把模型结果归一成运行时可用结构。', - total: 1, - weight: 2, - }, - { - id: 'finalize', - label: '归档世界', - detail: '整理最终世界档案并做完整性校验。', - total: 1, - weight: 1, - }, -] as const; - -type CustomWorldGenerationStageId = - (typeof CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS)[number]['id']; - -class CustomWorldGenerationAbortedError extends Error { - constructor(message = '世界生成已中断。') { - super(message); - this.name = 'CustomWorldGenerationAbortedError'; - } -} - -function nowMs() { - return Date.now(); -} - -function throwIfCustomWorldGenerationAborted(signal?: AbortSignal) { - if (!signal?.aborted) { - return; - } - - throw signal.reason instanceof Error - ? signal.reason - : new CustomWorldGenerationAbortedError(); -} - -function isCustomWorldGenerationAbortLikeError(error: unknown) { - return ( - error instanceof CustomWorldGenerationAbortedError || - (typeof DOMException !== 'undefined' && - error instanceof DOMException && - error.name === 'AbortError') || - (error instanceof Error && error.name === 'AbortError') - ); -} - -function sanitizeJsonLikeText(text: string) { - const trimmed = text.trim(); - if (!trimmed) { - return ''; - } - - const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/iu); - const unfenced = fencedMatch?.[1]?.trim() || trimmed; - const firstBrace = unfenced.indexOf('{'); - const lastBrace = unfenced.lastIndexOf('}'); - const extracted = - firstBrace >= 0 && lastBrace > firstBrace - ? unfenced.slice(firstBrace, lastBrace + 1) - : unfenced; - - return extracted - .replace(/^\uFEFF/u, '') - .replace(/[\u201C\u201D]/gu, '"') - .replace(/[\u2018\u2019]/gu, "'") - .replace(/\u00A0/gu, ' ') - .replace(/,\s*([}\]])/gu, '$1') - .trim(); -} - -function resolveCustomWorldGenerationInput( - input: GenerateCustomWorldProfileInput, -): { - settingText: string; - generationSeedText: string; - creatorIntent: CustomWorldCreatorIntent | null; - generationMode: CustomWorldGenerationMode; -} { - const settingText = input.settingText.trim(); - const creatorIntent = normalizeCustomWorldCreatorIntent(input.creatorIntent); - const generationSeedText = - creatorIntent && hasMeaningfulCustomWorldCreatorIntent(creatorIntent) - ? buildCustomWorldCreatorIntentGenerationText(creatorIntent) - : settingText; - - return { - settingText, - generationSeedText: generationSeedText.trim() || settingText, - creatorIntent, - generationMode: input.generationMode === 'fast' ? 'fast' : 'full', - }; -} - -function getCustomWorldGenerationTargets( - generationMode: CustomWorldGenerationMode, -) { - if (generationMode === 'fast') { - return { - playableCount: FAST_CUSTOM_WORLD_PLAYABLE_COUNT, - storyCount: FAST_CUSTOM_WORLD_STORY_COUNT, - landmarkCount: FAST_CUSTOM_WORLD_LANDMARK_COUNT, - generationStatus: 'key_only' as const, - }; - } - - return { - playableCount: MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT, - storyCount: MIN_CUSTOM_WORLD_STORY_NPC_COUNT, - landmarkCount: MIN_CUSTOM_WORLD_LANDMARK_COUNT, - generationStatus: 'complete' as const, - }; -} - -function createCustomWorldGenerationReporter( - onProgress?: (progress: CustomWorldGenerationProgress) => void, -) { - const startedAt = nowMs(); - const completedByStage = Object.fromEntries( - CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.map((stage) => [stage.id, 0]), - ) as Record; - const totalWeight = CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.reduce( - (sum, stage) => sum + stage.weight, - 0, - ); - - const emit = ( - stageId: CustomWorldGenerationStageId, - options: Partial<{ - completed: number; - phaseDetail: string; - }> = {}, - ) => { - const stage = CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.find( - (item) => item.id === stageId, - ); - if (!stage) { - return; - } - - if (typeof options.completed === 'number') { - completedByStage[stageId] = Math.max( - 0, - Math.min(stage.total, options.completed), - ); - } - - const steps = CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.map((item) => { - const completed = Math.max( - 0, - Math.min(item.total, completedByStage[item.id]), - ); - return { - id: item.id, - label: item.label, - detail: item.detail, - completed, - total: item.total, - status: - completed >= item.total - ? 'completed' - : item.id === stageId - ? 'active' - : 'pending', - } satisfies CustomWorldGenerationProgress['steps'][number]; - }); - const completedWeight = CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.reduce( - (sum, item) => - sum + (completedByStage[item.id] / item.total || 0) * item.weight, - 0, - ); - const progressFraction = totalWeight > 0 ? completedWeight / totalWeight : 0; - const elapsedMs = Math.max(0, nowMs() - startedAt); - const estimatedRemainingMs = - progressFraction > 0 && progressFraction < 1 - ? Math.max(0, Math.round(elapsedMs / progressFraction - elapsedMs)) - : progressFraction >= 1 - ? 0 - : null; - - onProgress?.({ - phaseId: stage.id, - phaseLabel: stage.label, - phaseDetail: options.phaseDetail ?? stage.detail, - overallProgress: Math.max( - 0, - Math.min(100, Math.round(progressFraction * 100)), - ), - completedWeight, - totalWeight, - elapsedMs, - estimatedRemainingMs, - activeStepIndex: CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.findIndex( - (item) => item.id === stage.id, - ), - steps, - }); - }; - - return { - begin(stageId: CustomWorldGenerationStageId, phaseDetail?: string) { - emit(stageId, { - completed: completedByStage[stageId], - phaseDetail, - }); - }, - complete(stageId: CustomWorldGenerationStageId, phaseDetail?: string) { - const stage = CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.find( - (item) => item.id === stageId, - ); - if (!stage) { - return; - } - emit(stageId, { - completed: stage.total, - phaseDetail, - }); - }, - }; -} - -async function parseCustomWorldJsonStage(params: { - llmClient: UpstreamLlmClient; - responseText: string; - signal?: AbortSignal; -}) { - throwIfCustomWorldGenerationAborted(params.signal); - try { - return parseJsonResponseText(params.responseText); - } catch { - const sanitized = sanitizeJsonLikeText(params.responseText); - if (sanitized && sanitized !== params.responseText.trim()) { - try { - return parseJsonResponseText(sanitized); - } catch { - // Fall through to model-assisted repair. - } - } - - const repairedText = await params.llmClient.requestMessageContent({ - systemPrompt: CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT, - userPrompt: buildCustomWorldProfileRepairPrompt(params.responseText), - signal: params.signal, - timeoutMs: 90000, - debugLabel: 'custom-world-profile-json-repair', - }); - - throwIfCustomWorldGenerationAborted(params.signal); - return parseJsonResponseText(sanitizeJsonLikeText(repairedText) || repairedText); - } -} - -async function requestCustomWorldProfileJson(params: { - llmClient: UpstreamLlmClient; - userPrompt: string; - signal?: AbortSignal; -}) { - const responseText = await params.llmClient.requestMessageContent({ - systemPrompt: CUSTOM_WORLD_GENERATION_JSON_ONLY_SYSTEM_PROMPT, - userPrompt: params.userPrompt, - signal: params.signal, - timeoutMs: CUSTOM_WORLD_REQUEST_TIMEOUT_MS, - debugLabel: 'custom-world-profile', - }); - - if (!responseText.trim()) { - throw new Error('自定义世界生成失败:模型没有返回有效内容。'); - } - - return parseCustomWorldJsonStage({ - llmClient: params.llmClient, - responseText, - signal: params.signal, - }); -} - -function attachRuntimeGenerationMetadata(params: { - profile: CustomWorldProfile; - settingText: string; - creatorIntent: CustomWorldCreatorIntent | null; - generationMode: CustomWorldGenerationMode; -}) { - const targets = getCustomWorldGenerationTargets(params.generationMode); - - return { - ...params.profile, - settingText: params.settingText || params.profile.settingText, - creatorIntent: params.creatorIntent, - anchorPack: - params.profile.anchorPack ?? - buildCustomWorldAnchorPackFromIntent(params.creatorIntent), - lockState: - params.profile.lockState ?? - deriveCustomWorldLockStateFromIntent(params.creatorIntent), - generationMode: params.generationMode, - generationStatus: targets.generationStatus, - items: [], - } satisfies CustomWorldProfile; -} - -export async function generateCustomWorldProfileFromOrchestrator( - llmClient: UpstreamLlmClient, - input: GenerateCustomWorldProfileInput, - options: { - onProgress?: (progress: CustomWorldGenerationProgress) => void; - signal?: AbortSignal; - } = {}, -) { - const { - settingText, - generationSeedText, - creatorIntent, - generationMode, - } = resolveCustomWorldGenerationInput(input); - const targets = getCustomWorldGenerationTargets(generationMode); - const creatorIntentText = - creatorIntent && hasMeaningfulCustomWorldCreatorIntent(creatorIntent) - ? buildCustomWorldCreatorIntentGenerationText(creatorIntent) - : ''; - const reporter = createCustomWorldGenerationReporter(options.onProgress); - - try { - throwIfCustomWorldGenerationAborted(options.signal); - reporter.begin('prepare', '正在整理创作者输入与结构化锚点。'); - const userPrompt = buildCustomWorldProfilePrompt({ - generationSeedText, - generationMode, - creatorIntentText, - targets, - }); - reporter.complete('prepare', '设定上下文已整理,开始请求大模型推理。'); - - reporter.begin('llm-profile', '正在请求模型生成世界档案、角色群像与场景网络。'); - const rawProfile = await requestCustomWorldProfileJson({ - llmClient, - userPrompt, - signal: options.signal, - }); - reporter.complete('llm-profile', '模型已返回世界档案,开始系统归一与运行时编译。'); - - reporter.begin('normalize', '正在把模型 JSON 归一为可运行的自定义世界结构。'); - const expandedProfile = buildCompiledCustomWorldProfile( - { - ...(rawProfile as GeneratedProfile), - settingText, - creatorIntent, - generationMode, - generationStatus: getCustomWorldGenerationTargets(generationMode) - .generationStatus, - }, - generationSeedText, - ); - const profile = attachRuntimeGenerationMetadata({ - profile: expandedProfile, - settingText, - creatorIntent, - generationMode, - }); - reporter.complete('normalize', '模型结果已完成运行时结构编译。'); - - reporter.begin('finalize', '正在做最终完整性校验。'); - if (generationMode === 'full') { - validateGeneratedCustomWorldProfile(profile); - } - reporter.complete('finalize', `世界“${profile.name}”已完成归档。`); - - return profile as unknown as GeneratedProfile; - } catch (error) { - if (isCustomWorldGenerationAbortLikeError(error) || options.signal?.aborted) { - throw error instanceof Error - ? error - : new CustomWorldGenerationAbortedError(); - } - - if (error instanceof SyntaxError) { - throw new Error( - '自定义世界生成失败:模型返回了非严格 JSON,且自动修复仍未成功,请稍后重试。', - ); - } - - throw error; - } -} diff --git a/server-node/src/modules/ai/orchestrator.test.ts b/server-node/src/modules/ai/orchestrator.test.ts deleted file mode 100644 index 8bea50ce..00000000 --- a/server-node/src/modules/ai/orchestrator.test.ts +++ /dev/null @@ -1,803 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import type { - CharacterChatSuggestionsRequest, - NpcChatTurnRequest, -} from '../../../../packages/shared/src/contracts/rpgRuntimeChat.js'; -import { createTestPlayerCharacter } from '../../testFixtures/runtimeCharacter.js'; -import { - generateCharacterChatSuggestionsFromOrchestrator, - streamNpcChatTurnFromOrchestrator, -} from './chatOrchestrator.js'; -import { CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT } from './chatPromptBuilders.js'; -import { - generateCustomWorldProfileFromOrchestrator, -} from './customWorldOrchestrator.js'; -import { generateInitialStoryFromOrchestrator } from './storyOrchestrator.js'; -import { SYSTEM_PROMPT } from './storyPromptBuilders.js'; - -type TestStoryContext = Parameters[4]; -type TestStoryOption = Awaited< - ReturnType ->['options'][number]; -const TEST_WORLD = 'WUXIA' as Parameters< - typeof generateInitialStoryFromOrchestrator ->[1]; -type TestCharacter = Parameters[2]; - -function createTestCharacter(overrides: Partial = {}) { - return { - ...createTestPlayerCharacter(), - ...overrides, - }; -} - -function createStoryContext(): TestStoryContext { - return { - playerHp: 120, - playerMaxHp: 120, - playerMana: 40, - playerMaxMana: 40, - inBattle: false, - playerX: 320, - playerFacing: 'right', - playerAnimation: 'idle', - skillCooldowns: {}, - sceneId: 'inn_room', - sceneName: '客栈内室', - sceneDescription: '昏黄灯火照着刚刚停下脚步的木桌。', - pendingSceneEncounter: false, - }; -} - -function createAvailableOptions(context: TestStoryContext) { - void context; - return [ - { - functionId: 'idle_explore_forward', - actionText: '继续向前探索前路', - text: '继续向前探索前路', - visuals: { - playerAnimation: 'idle', - playerMoveMeters: 0, - playerOffsetY: 0, - playerFacing: 'right', - scrollWorld: false, - monsterChanges: [], - }, - }, - { - functionId: 'idle_observe_signs', - actionText: '停步观察附近的风吹草动', - text: '停步观察附近的风吹草动', - visuals: { - playerAnimation: 'idle', - playerMoveMeters: 0, - playerOffsetY: 0, - playerFacing: 'right', - scrollWorld: false, - monsterChanges: [], - }, - }, - ] as TestStoryOption[]; -} - -test('story orchestrator repairs mixed-language narrative on the server side', async () => { - const context = createStoryContext(); - const availableOptions = createAvailableOptions(context); - const capturedPrompts: Array<{ systemPrompt: string; userPrompt: string }> = []; - const llmClient = { - requestMessageContent: async ({ - systemPrompt, - userPrompt, - }: { - systemPrompt: string; - userPrompt: string; - }) => { - capturedPrompts.push({ systemPrompt, userPrompt }); - - if (capturedPrompts.length === 1) { - return JSON.stringify({ - storyText: 'The room falls quiet for a moment.', - encounter: null, - options: availableOptions.map((option) => ({ - functionId: option.functionId, - actionText: option.actionText, - })), - }); - } - - return JSON.stringify({ - storyText: '房间里短暂安静了一瞬,你能听见灯火轻轻噼啪作响。', - encounter: null, - options: availableOptions.map((option) => ({ - functionId: option.functionId, - actionText: option.actionText, - })), - }); - }, - } as const; - - const response = await generateInitialStoryFromOrchestrator( - llmClient as never, - TEST_WORLD, - createTestCharacter(), - [], - context, - { - availableOptions, - }, - ); - - assert.equal(capturedPrompts.length, 2); - assert.equal(capturedPrompts[0]?.systemPrompt, SYSTEM_PROMPT); - assert.match(capturedPrompts[0]?.userPrompt ?? '', /客栈内室/u); - assert.equal( - response.storyText, - '房间里短暂安静了一瞬,你能听见灯火轻轻噼啪作响。', - ); - assert.deepEqual( - response.options.map((option) => option.functionId), - availableOptions.map((option) => option.functionId), - ); -}); - -test('chat orchestrator builds character suggestion prompts on the server side', async () => { - const payload = { - worldType: TEST_WORLD, - playerCharacter: createTestCharacter(), - targetCharacter: createTestCharacter({ - id: 'test-companion', - name: '测试同伴', - title: '听风客', - }), - storyHistory: [], - context: createStoryContext(), - conversationHistory: [ - { speaker: 'player', text: '刚才那阵风是不是也不太对劲?' }, - { speaker: 'character', text: '像是有人故意把门帘掀起来了一样。' }, - ], - conversationSummary: '两人刚在客栈里察觉到不寻常的动静。', - targetStatus: { - roleLabel: '同行角色', - hp: 95, - maxHp: 120, - mana: 28, - maxMana: 40, - affinity: 18, - }, - } satisfies CharacterChatSuggestionsRequest; - const capturedPrompts: Array<{ systemPrompt: string; userPrompt: string }> = []; - const llmClient = { - requestMessageContent: async ({ - systemPrompt, - userPrompt, - }: { - systemPrompt: string; - userPrompt: string; - }) => { - capturedPrompts.push({ systemPrompt, userPrompt }); - return '先别急,我们再听一轮。\n你刚才看见谁动门帘了吗?\n要不我先去门边探一眼。'; - }, - } as const; - - const text = await generateCharacterChatSuggestionsFromOrchestrator( - llmClient as never, - payload, - ); - - assert.equal(text.split('\n').length, 3); - assert.equal( - capturedPrompts[0]?.systemPrompt, - CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT, - ); - assert.match(capturedPrompts[0]?.userPrompt ?? '', /客栈/u); - assert.match(capturedPrompts[0]?.userPrompt ?? '', /两人刚在客栈里察觉到不寻常的动静/u); - assert.match(capturedPrompts[0]?.userPrompt ?? '', new RegExp(payload.targetCharacter.name, 'u')); -}); - -test('chat orchestrator returns pending npc quest offers from the server side', async () => { - const encounter = { - kind: 'npc', - id: 'npc_scout_01', - npcName: '巡路人', - npcDescription: '熟悉桥口风向的探子', - context: '巡路人', - characterId: 'scout-quest', - } as const; - const requestPayload = { - worldType: TEST_WORLD, - character: createTestCharacter(), - player: createTestCharacter(), - encounter, - monsters: [], - history: [], - context: createStoryContext(), - conversationHistory: [ - { speaker: 'player', text: '你像是还有别的话想说。' }, - { speaker: 'npc', text: '这地方最近确实不太平。' }, - ], - dialogue: [ - { speaker: 'player', text: '你像是还有别的话想说。' }, - { speaker: 'npc', text: '这地方最近确实不太平。' }, - ], - playerMessage: '如果你愿意,我可以继续追下去。', - npcState: { - affinity: 28, - chattedCount: 1, - }, - questOfferContext: { - turnCount: 2, - encounter, - state: { - worldType: TEST_WORLD, - currentScenePreset: { - id: 'quest-bridge', - name: '断桥口', - description: '桥口被风和旧账压得很紧。', - npcs: [ - { - id: 'npc_bandit_01', - name: '断桥匪首', - hostile: true, - monsterPresetId: 'npc_bandit_01', - }, - ], - treasureHints: [], - }, - currentEncounter: encounter, - storyHistory: [], - customWorldProfile: null, - storyEngineMemory: null, - playerCharacter: createTestCharacter(), - playerHp: 32, - playerMaxHp: 40, - playerMana: 12, - playerMaxMana: 16, - playerInventory: [], - playerEquipment: { - weapon: null, - armor: null, - relic: null, - }, - companions: [], - roster: [], - quests: [], - npcStates: { - npc_scout_01: { - affinity: 28, - chattedCount: 1, - helpUsed: false, - giftsGiven: 0, - inventory: [], - recruited: false, - }, - }, - }, - }, - } satisfies NpcChatTurnRequest; - const responseChunks: string[] = []; - let requestCount = 0; - const llmClient = { - streamMessageContent: async ({ - onUpdate, - }: { - onUpdate?: (text: string) => void; - }) => { - const reply = '如果你真愿意追查,我这里确实有件事想托给你。'; - onUpdate?.(reply); - return reply; - }, - requestMessageContent: async () => { - requestCount += 1; - if (requestCount === 1) { - return '这件事最早是从什么时候开始的\n桥口最近到底少了什么人\n你想让我先盯哪条线'; - } - - return JSON.stringify({ - intent: { - title: '断桥巡线', - description: '巡路人希望你去断桥口查清最近被人故意压下的风声。', - summary: '去断桥口查清最近被人故意压下的风声。', - narrativeType: 'investigation', - dramaticNeed: '有人在桥口刻意遮掩痕迹。', - issuerGoal: '确认是谁在桥口截断消息。', - playerHook: '你可以顺着桥口的异常把事情继续追下去。', - worldReason: '桥口异动会影响接下来整段路的安全。', - recommendedObjectiveKinds: ['defeat_hostile_npc', 'talk_to_npc'], - urgency: 'medium', - intimacy: 'cooperative', - rewardTheme: 'currency', - followupHooks: ['巡路人还藏着半句没说完的话。'], - }, - }); - }, - } as const; - const request = { - method: 'POST', - originalUrl: '/api/runtime/chat/npc/turn/stream', - requestId: 'test-request', - requestStartedAt: Date.now(), - header: () => '', - on: () => request, - } as never; - const response = { - locals: {}, - statusCode: 200, - setHeader: () => undefined, - status(code: number) { - this.statusCode = code; - return this; - }, - write(chunk: string) { - responseChunks.push(chunk); - return true; - }, - end(chunk?: string) { - if (chunk) { - responseChunks.push(chunk); - } - return this; - }, - } as never; - - await streamNpcChatTurnFromOrchestrator(llmClient as never, { - request, - response, - payload: requestPayload, - }); - - const eventText = responseChunks.join(''); - const completeBlock = eventText - .split('\n\n') - .find((block) => block.includes('event: complete')); - assert.ok(completeBlock); - const completeLine = completeBlock - ?.split('\n') - .find((line) => line.startsWith('data:')); - assert.ok(completeLine); - const payload = JSON.parse(completeLine!.slice(5).trim()) as { - pendingQuestOffer?: { - quest?: { - issuerNpcId?: string; - }; - introText?: string; - } | null; - }; - - assert.equal(payload.pendingQuestOffer?.quest?.issuerNpcId, 'npc_scout_01'); - assert.match(payload.pendingQuestOffer?.introText ?? '', /正式交给你/u); -}); - -test('chat orchestrator adds first-contact greeting constraints to the first npc turn prompt', async () => { - const encounter = { - kind: 'npc', - id: 'npc_first_contact_01', - npcName: '林晓峰', - npcDescription: '初次照面的试探者', - context: '雨夜桥口', - characterId: 'first-contact-npc', - } as const; - const requestPayload = { - worldType: TEST_WORLD, - character: createTestCharacter(), - player: createTestCharacter(), - encounter, - monsters: [], - history: [], - context: { - ...createStoryContext(), - isFirstMeaningfulContact: true, - firstContactRelationStance: 'guarded', - encounterAllowedTopics: ['眼前动静', '来意试探'], - encounterBlockedTopics: ['完整来历', '真正目标'], - }, - conversationHistory: [], - dialogue: [], - playerMessage: '你刚才一直在看哪边?', - npcState: { - affinity: 8, - chattedCount: 0, - }, - } satisfies NpcChatTurnRequest; - const responseChunks: string[] = []; - const capturedReplyPrompts: string[] = []; - let requestMessageCount = 0; - const llmClient = { - streamMessageContent: async ({ - userPrompt, - onUpdate, - }: { - userPrompt: string; - onUpdate?: (text: string) => void; - }) => { - capturedReplyPrompts.push(userPrompt); - const reply = '先打个招呼。你先别急着往前,再看一眼桥那边的风。'; - onUpdate?.(reply); - return reply; - }, - requestMessageContent: async () => { - requestMessageCount += 1; - return '你是在提醒我还是拦我\n桥那边到底出了什么事\n你刚才看见谁了'; - }, - } as const; - const request = { - method: 'POST', - originalUrl: '/api/runtime/chat/npc/turn/stream', - requestId: 'test-request', - requestStartedAt: Date.now(), - header: () => '', - on: () => request, - } as never; - const response = { - locals: {}, - statusCode: 200, - setHeader: () => undefined, - status(code: number) { - this.statusCode = code; - return this; - }, - write(chunk: string) { - responseChunks.push(chunk); - return true; - }, - end(chunk?: string) { - if (chunk) { - responseChunks.push(chunk); - } - return this; - }, - } as never; - - await streamNpcChatTurnFromOrchestrator(llmClient as never, { - request, - response, - payload: requestPayload, - }); - - assert.equal(requestMessageCount, 1); - assert.match(capturedReplyPrompts[0] ?? '', /第一次真正接触/u); - assert.match( - capturedReplyPrompts[0] ?? '', - /第一句必须先用一句自然招呼或开场判断起手/u, - ); - assert.match( - capturedReplyPrompts[0] ?? '', - /某人看着你,像是在等你把话接下去/u, - ); -}); - -test('chat orchestrator marks npc-initiated first contact openings in the first npc turn prompt', async () => { - const encounter = { - kind: 'npc', - id: 'npc_first_contact_opening', - npcName: '沈雁回', - npcDescription: '在风口先观察来意的人', - context: '桥头对峙', - characterId: 'npc-first-open', - } as const; - const requestPayload = { - worldType: TEST_WORLD, - character: createTestCharacter(), - player: createTestCharacter(), - encounter, - monsters: [], - history: [], - context: { - ...createStoryContext(), - isFirstMeaningfulContact: true, - firstContactRelationStance: 'neutral', - encounterAllowedTopics: ['眼前局势', '来意试探'], - encounterBlockedTopics: ['完整旧事'], - }, - conversationHistory: [], - dialogue: [], - playerMessage: '【NPC 主动开场】', - npcState: { - affinity: 18, - chattedCount: 0, - }, - npcInitiatesConversation: true, - } satisfies NpcChatTurnRequest; - const capturedReplyPrompts: string[] = []; - const llmClient = { - streamMessageContent: async ({ - userPrompt, - onUpdate, - }: { - userPrompt: string; - onUpdate?: (text: string) => void; - }) => { - capturedReplyPrompts.push(userPrompt); - const reply = '先站住。你带着这身风尘过来,总不会只是为了看看桥景。'; - onUpdate?.(reply); - return reply; - }, - requestMessageContent: async () => - '我先听你说桥上出了什么事\n你先说你在防谁\n我不是来翻旧账的', - } as const; - const request = { - method: 'POST', - originalUrl: '/api/runtime/chat/npc/turn/stream', - requestId: 'test-request', - requestStartedAt: Date.now(), - header: () => '', - on: () => request, - } as never; - const response = { - locals: {}, - statusCode: 200, - setHeader: () => undefined, - status(code: number) { - this.statusCode = code; - return this; - }, - write() { - return true; - }, - end() { - return this; - }, - } as never; - - await streamNpcChatTurnFromOrchestrator(llmClient as never, { - request, - response, - payload: requestPayload, - }); - - assert.match( - capturedReplyPrompts[0] ?? '', - /主动开口的第一句/u, - ); - assert.match( - capturedReplyPrompts[0] ?? '', - /不要假装玩家已经先说过话/u, - ); - assert.match( - capturedReplyPrompts[0] ?? '', - /主动开口时会说的话/u, - ); -}); - -test('chat orchestrator force closes the fifth hostile primary-npc turn with foreshadowing', async () => { - const encounter = { - kind: 'npc', - id: 'npc_bridge_rival', - npcName: '断桥客', - npcDescription: '守着旧桥的冷面旧敌', - context: '断桥旧案', - characterId: 'bridge-rival', - } as const; - const requestPayload = { - worldType: TEST_WORLD, - character: createTestCharacter(), - player: createTestCharacter(), - encounter, - monsters: [], - history: [], - context: createStoryContext(), - combatContext: { - summary: '你刚在断桥口压住了断桥客的刀势,逼得他不得不重新开口。', - logLines: [ - '你先一步抢进桥心,逼开了对方的起手。', - '断桥客被逼退到桥栏边,终于没有再出下一刀。', - ], - battleOutcome: 'victory', - }, - conversationHistory: [ - { speaker: 'player', text: '你一直躲着不说完。' }, - { speaker: 'npc', text: '有些话说完了,人也就该死了。' }, - ], - dialogue: [ - { speaker: 'player', text: '你一直躲着不说完。' }, - { speaker: 'npc', text: '有些话说完了,人也就该死了。' }, - ], - playerMessage: '那你至少告诉我,接下来该去哪里找答案。', - npcState: { - affinity: -12, - chattedCount: 4, - }, - chatDirective: { - sceneActId: 'scene-bridge-act-1', - turnLimit: 5, - remainingTurns: 0, - limitReason: 'negative_affinity', - closingMode: 'foreshadow_close', - forceExitAfterTurn: true, - }, - } satisfies NpcChatTurnRequest; - const responseChunks: string[] = []; - const capturedReplyPrompts: string[] = []; - let requestMessageCount = 0; - const llmClient = { - streamMessageContent: async ({ - userPrompt, - onUpdate, - }: { - userPrompt: string; - onUpdate?: (text: string) => void; - }) => { - capturedReplyPrompts.push(userPrompt); - const reply = '去城西废桥下找那盏没灭的灯。等你看见它,再来问我剩下那半句。'; - onUpdate?.(reply); - return reply; - }, - requestMessageContent: async () => { - requestMessageCount += 1; - return '这条回复不该被调用'; - }, - } as const; - const request = { - method: 'POST', - originalUrl: '/api/runtime/chat/npc/turn/stream', - requestId: 'test-request', - requestStartedAt: Date.now(), - header: () => '', - on: () => request, - } as never; - const response = { - locals: {}, - statusCode: 200, - setHeader: () => undefined, - status(code: number) { - this.statusCode = code; - return this; - }, - write(chunk: string) { - responseChunks.push(chunk); - return true; - }, - end(chunk?: string) { - if (chunk) { - responseChunks.push(chunk); - } - return this; - }, - } as never; - - await streamNpcChatTurnFromOrchestrator(llmClient as never, { - request, - response, - payload: requestPayload, - }); - - assert.equal(requestMessageCount, 0); - assert.match(capturedReplyPrompts[0] ?? '', /最后一轮/u); - assert.match(capturedReplyPrompts[0] ?? '', /推动后续剧情/u); - assert.match(capturedReplyPrompts[0] ?? '', /刚刚结束的交锋/u); - assert.match( - capturedReplyPrompts[0] ?? '', - /你刚在断桥口压住了断桥客的刀势,逼得他不得不重新开口/u, - ); - assert.match( - capturedReplyPrompts[0] ?? '', - /你先一步抢进桥心,逼开了对方的起手/u, - ); - - const eventText = responseChunks.join(''); - const completeBlock = eventText - .split('\n\n') - .find((block) => block.includes('event: complete')); - assert.ok(completeBlock); - const completeLine = completeBlock - ?.split('\n') - .find((line) => line.startsWith('data:')); - assert.ok(completeLine); - const payload = JSON.parse(completeLine!.slice(5).trim()) as { - suggestions?: string[]; - chatDirective?: { - forceExit?: boolean; - remainingTurns?: number | null; - closingMode?: string; - } | null; - }; - - assert.deepEqual(payload.suggestions, []); - assert.equal(payload.chatDirective?.forceExit, true); - assert.equal(payload.chatDirective?.remainingTurns, 0); - assert.equal(payload.chatDirective?.closingMode, 'foreshadow_close'); -}); - -test('custom world orchestrator requests LLM content before compiling the profile', async () => { - const capturedPrompts: Array<{ systemPrompt: string; userPrompt: string }> = []; - const storyNpcNames = Array.from( - { length: 8 }, - (_, index) => `潮灯见证者${index + 1}`, - ); - const llmClient = { - requestMessageContent: async ({ - systemPrompt, - userPrompt, - }: { - systemPrompt: string; - userPrompt: string; - }) => { - capturedPrompts.push({ systemPrompt, userPrompt }); - return JSON.stringify({ - name: '潮灯列岛', - subtitle: '雾潮之下', - summary: '旧灯塔、潮雾与沉船盟约纠缠出的列岛冒险。', - tone: '潮湿、悬疑、克制', - playerGoal: '查明潮雾为何吞掉守灯人的名字', - templateWorldType: 'WUXIA', - majorFactions: ['守灯会', '沉船商盟', '潮雾祭司'], - coreConflicts: ['守灯会与沉船商盟争夺航道解释权'], - camp: { - name: '旧灯塔下层', - description: '潮水退去时才露出的临时据点。', - dangerLevel: 'low', - }, - playableNpcs: Array.from({ length: 3 }, (_, index) => ({ - name: `守灯旅人${index + 1}`, - title: `第${index + 1}盏灯`, - role: '守灯同行者', - description: '在潮雾边缘辨认灯火与人声。', - backstory: '曾经守过一座被除名的灯塔。', - personality: '谨慎、沉静、记仇', - motivation: '找回被潮雾吞掉的名字。', - combatStyle: '短刃牵制后借灯火逼退敌人。', - initialAffinity: 18, - relationshipHooks: ['守灯', '旧名'], - tags: ['潮雾', '灯塔'], - })), - storyNpcs: storyNpcNames.map((name, index) => ({ - name, - title: `第${index + 1}位见证者`, - role: '潮雾见证者', - description: '知道一段被潮水洗掉的航线传闻。', - backstory: '在沉船夜里听见过不该出现的钟声。', - personality: '警觉、克制', - motivation: '确认下一次潮雾会带走谁。', - combatStyle: '先试探再撤入雾中。', - initialAffinity: 6, - relationshipHooks: ['沉船夜', '钟声'], - tags: ['潮雾', '线索'], - })), - landmarks: Array.from({ length: 4 }, (_, index) => ({ - name: `潮灯地标${index + 1}`, - description: '潮雾会在这里折回,留下盐痕和旧灯影。', - dangerLevel: index === 0 ? 'medium' : 'high', - sceneNpcNames: storyNpcNames.slice(index, index + 3), - connections: [ - { - targetLandmarkName: `潮灯地标${(index + 1) % 4 + 1}`, - relativePosition: 'forward', - summary: '沿潮痕继续前行即可抵达下一处灯影。', - }, - ], - })), - items: [], - }); - }, - } as const; - const progressEvents: Array<{ phaseId: string; overallProgress: number }> = []; - - const profile = await generateCustomWorldProfileFromOrchestrator( - llmClient as never, - { - settingText: '一个被潮雾与失落列岛切碎的边境世界。', - generationMode: 'fast', - }, - { - onProgress: (progress) => { - progressEvents.push({ - phaseId: progress.phaseId, - overallProgress: progress.overallProgress, - }); - }, - }, - ); - - assert.equal(capturedPrompts.length, 1); - assert.match(capturedPrompts[0]?.systemPrompt ?? '', /JSON 生成器/u); - assert.match(capturedPrompts[0]?.userPrompt ?? '', /生成模式:fast/u); - assert.match(capturedPrompts[0]?.userPrompt ?? '', /潮雾与失落列岛/u); - assert.equal(profile.name, '潮灯列岛'); - assert.equal(profile.generationMode, 'fast'); - assert.equal(profile.generationStatus, 'key_only'); - assert.equal((profile.playableNpcs as unknown[]).length, 3); - assert.ok(progressEvents.some((event) => event.phaseId === 'llm-profile')); - assert.equal(progressEvents.at(-1)?.overallProgress, 100); -}); diff --git a/server-node/src/modules/ai/storyOrchestrator.ts b/server-node/src/modules/ai/storyOrchestrator.ts deleted file mode 100644 index b29e97c7..00000000 --- a/server-node/src/modules/ai/storyOrchestrator.ts +++ /dev/null @@ -1,594 +0,0 @@ -import { hasMixedNarrativeLanguage } from '../../../../packages/shared/src/llm/narrativeLanguage.js'; -import { parseJsonResponseText } from '../../../../packages/shared/src/llm/parsers.js'; -import type { UpstreamLlmClient } from '../../services/llmClient.js'; -import { - buildStoryLanguageRepairPrompt, - STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT, -} from '../../prompts/storyOrchestratorPrompts.js'; -import { buildUserPrompt, SYSTEM_PROMPT } from './storyPromptBuilders.js'; - -type JsonRecord = Record; -type PromptWorldType = string; -type PromptCharacter = JsonRecord; -type PromptMonster = JsonRecord; -type PromptMonsters = PromptMonster[]; -type PromptStoryMoment = JsonRecord; -type PromptHistory = PromptStoryMoment[]; -type PromptContext = JsonRecord; -type PromptStoryOption = { - functionId: string; - actionText: string; - text?: string; - detailText?: string; - priority?: number; - visuals: { - playerAnimation: 'idle' | 'attack' | 'run' | 'hurt' | 'jump' | 'dash'; - playerMoveMeters: number; - playerOffsetY: number; - playerFacing: 'left' | 'right'; - scrollWorld: boolean; - monsterChanges: Array<{ - id: string; - action: string; - animation: 'idle' | 'move' | 'attack'; - moveMeters?: number; - yOffset?: number; - }>; - }; - interaction?: { - kind: 'npc' | 'treasure'; - npcId?: string; - action?: string; - }; - skillProbabilities?: Record; - goalAffordance?: { - goalId: string; - relation: 'advance' | 'support' | 'detour'; - label: string; - } | null; -}; -type PromptAvailableOptions = PromptStoryOption[]; -type PromptOptionCatalog = PromptStoryOption[]; -type StoryRequestOptions = { - availableOptions?: PromptAvailableOptions; - optionCatalog?: PromptOptionCatalog; -}; -type SceneEncounterResult = - | { kind: 'none' } - | { kind: 'npc'; npcId?: string } - | { kind: 'treasure'; treasureText?: string }; -type AIResponse = { - storyText: string; - options: PromptStoryOption[]; - encounter?: SceneEncounterResult; -}; - -type RawOptionItem = { - functionId: string; - actionText?: string; -}; - -const DEFAULT_VISUALS = { - playerAnimation: 'idle' as const, - playerMoveMeters: 0, - playerOffsetY: 0, - playerFacing: 'right' as const, - scrollWorld: false, - monsterChanges: [], -}; - -const STATIC_FALLBACK_OPTION_MAP: Record< - string, - Partial & { actionText: string } -> = { - battle_attack_basic: { actionText: '普通攻击' }, - battle_use_skill: { actionText: '释放技能' }, - battle_all_in_crush: { actionText: '正面强压敌人' }, - battle_escape_breakout: { actionText: '先脱离眼前追杀' }, - battle_feint_step: { actionText: '借假动作切进身位' }, - battle_finisher_window: { actionText: '抓住破绽补上终结一击' }, - battle_guard_break: { actionText: '重击破开对手架势' }, - battle_probe_pressure: { actionText: '稳扎稳打继续试探' }, - battle_recover_breath: { actionText: '边守边调息稳住节奏' }, - idle_call_out: { actionText: '朝前方主动出声试探' }, - idle_explore_forward: { actionText: '继续向前探索前路' }, - idle_observe_signs: { actionText: '停步观察附近的风吹草动' }, - idle_rest_focus: { actionText: '原地调息整理状态' }, - idle_travel_next_scene: { actionText: '前往相邻场景' }, - npc_chat: { - actionText: '继续交谈', - interaction: { kind: 'npc', action: 'chat' }, - }, - npc_help: { - actionText: '请求援手', - interaction: { kind: 'npc', action: 'help' }, - }, - npc_fight: { - actionText: '直接开战', - interaction: { kind: 'npc', action: 'fight' }, - }, - npc_leave: { - actionText: '先拉开距离', - interaction: { kind: 'npc', action: 'leave' }, - }, - npc_preview_talk: { - actionText: '先试着接一句话', - interaction: { kind: 'npc', action: 'chat' }, - }, - npc_recruit: { - actionText: '正式邀请同行', - interaction: { kind: 'npc', action: 'recruit' }, - }, - npc_spar: { - actionText: '点到为止地切磋', - interaction: { kind: 'npc', action: 'spar' }, - }, - npc_trade: { - actionText: '看看能交换什么', - interaction: { kind: 'npc', action: 'trade' }, - }, - npc_gift: { - actionText: '送上一份礼物', - interaction: { kind: 'npc', action: 'gift' }, - }, - npc_quest_accept: { - actionText: '接下这份委托', - interaction: { kind: 'npc', action: 'quest_accept' }, - }, - npc_quest_turn_in: { - actionText: '交付已经完成的委托', - interaction: { kind: 'npc', action: 'quest_turn_in' }, - }, - treasure_inspect: { - actionText: '仔细检查', - interaction: { kind: 'treasure', action: 'inspect' }, - }, - treasure_leave: { - actionText: '先记下位置', - interaction: { kind: 'treasure', action: 'leave' }, - }, - treasure_secure: { - actionText: '直接收取', - interaction: { kind: 'treasure', action: 'secure' }, - }, -}; - -function readString(value: unknown) { - return typeof value === 'string' && value.trim() ? value.trim() : ''; -} - -function inferNpcId(context: PromptContext, encounter?: SceneEncounterResult) { - if (encounter?.kind === 'npc' && encounter.npcId) { - return encounter.npcId; - } - - return readString(context.encounterId) || readString(context.encounterName); -} - -function createGenericOption(params: { - functionId: string; - actionText?: string; - context: PromptContext; - encounter?: SceneEncounterResult; -}) { - const functionId = params.functionId; - const preset = STATIC_FALLBACK_OPTION_MAP[functionId]; - const npcId = inferNpcId(params.context, params.encounter); - const interaction = - preset?.interaction?.kind === 'npc' && npcId - ? { - ...preset.interaction, - npcId, - } - : preset?.interaction; - - return { - functionId, - actionText: readString(params.actionText) || preset?.actionText || functionId, - text: readString(params.actionText) || preset?.actionText || functionId, - visuals: DEFAULT_VISUALS, - interaction, - } satisfies PromptStoryOption; -} - -function cloneStoryOption(option: PromptStoryOption): PromptStoryOption { - return { - ...option, - visuals: { - ...DEFAULT_VISUALS, - ...option.visuals, - monsterChanges: option.visuals?.monsterChanges?.map((change) => ({ - ...change, - })) ?? [], - }, - interaction: option.interaction ? { ...option.interaction } : undefined, - skillProbabilities: option.skillProbabilities - ? { ...option.skillProbabilities } - : undefined, - goalAffordance: option.goalAffordance ? { ...option.goalAffordance } : option.goalAffordance, - }; -} - -function normalizeEncounterResult( - raw: unknown, - context: PromptContext, -): SceneEncounterResult | undefined { - if (!context.pendingSceneEncounter) { - return undefined; - } - - if (!raw || typeof raw !== 'object') { - return { kind: 'none' }; - } - - const item = raw as Record; - const kind = readString(item.kind); - - if (kind === 'npc' || kind === 'monster') { - return { - kind: 'npc', - npcId: readString(item.npcId) || readString(context.encounterId) || undefined, - }; - } - - if (kind === 'treasure') { - return { - kind: 'treasure', - treasureText: readString(item.treasureText) || undefined, - }; - } - - return { kind: 'none' }; -} - -function resolveSafeGeneratedActionText(actionText: string | undefined) { - const trimmed = actionText?.trim(); - if (!trimmed || hasMixedNarrativeLanguage(trimmed)) { - return undefined; - } - - return trimmed; -} - -function resolveOptionsFromProvidedOptions( - items: RawOptionItem[], - availableOptions: PromptAvailableOptions, -) { - if (items.length === 0) { - return availableOptions.map(cloneStoryOption); - } - - const optionBuckets = new Map(); - const consumedOptions = new Set(); - availableOptions.forEach((option) => { - const bucket = optionBuckets.get(option.functionId) ?? []; - bucket.push(option); - optionBuckets.set(option.functionId, bucket); - }); - - const resolved: PromptStoryOption[] = []; - items.forEach((item) => { - const bucket = optionBuckets.get(item.functionId); - const matchedOption = bucket?.shift(); - if (!matchedOption) { - return; - } - consumedOptions.add(matchedOption); - - const rewrittenText = resolveSafeGeneratedActionText(item.actionText); - resolved.push({ - ...cloneStoryOption(matchedOption), - actionText: rewrittenText || matchedOption.actionText, - text: rewrittenText || matchedOption.text || matchedOption.actionText, - }); - }); - - if (resolved.length === availableOptions.length) { - return resolved; - } - - const remainingOptions = availableOptions.filter( - (option) => !consumedOptions.has(option), - ); - return [...resolved, ...remainingOptions.map(cloneStoryOption)]; -} - -function resolveOptionsFromOptionCatalog( - items: RawOptionItem[], - optionCatalog: PromptOptionCatalog, - context: PromptContext, - encounter?: SceneEncounterResult, -) { - if (items.length === 0) { - return optionCatalog.map(cloneStoryOption); - } - - const optionBuckets = new Map(); - optionCatalog.forEach((option) => { - const bucket = optionBuckets.get(option.functionId) ?? []; - bucket.push(option); - optionBuckets.set(option.functionId, bucket); - }); - - return items.map((item) => { - const bucket = optionBuckets.get(item.functionId); - const matchedOption = bucket?.shift(); - if (!matchedOption) { - return createGenericOption({ - functionId: item.functionId, - actionText: item.actionText, - context, - encounter, - }); - } - - const rewrittenText = resolveSafeGeneratedActionText(item.actionText); - return { - ...cloneStoryOption(matchedOption), - actionText: rewrittenText || matchedOption.actionText, - text: rewrittenText || matchedOption.text || matchedOption.actionText, - }; - }); -} - -function getFallbackFunctionIds(context: PromptContext, encounter?: SceneEncounterResult) { - if (context.inBattle === true) { - return [ - 'battle_attack_basic', - 'battle_recover_breath', - 'battle_use_skill', - 'battle_escape_breakout', - ]; - } - - if (encounter?.kind === 'npc') { - return [ - 'npc_chat', - 'npc_help', - 'npc_trade', - 'npc_gift', - 'npc_recruit', - 'npc_leave', - ]; - } - - if (encounter?.kind === 'treasure') { - return ['treasure_inspect', 'treasure_secure', 'treasure_leave']; - } - - return [ - 'idle_explore_forward', - 'idle_call_out', - 'idle_observe_signs', - 'idle_rest_focus', - 'idle_travel_next_scene', - 'idle_explore_forward', - ]; -} - -function getFallbackOptions( - context: PromptContext, - encounter?: SceneEncounterResult, -) { - return getFallbackFunctionIds(context, encounter).map((functionId, index) => - createGenericOption({ - functionId: functionId === 'idle_explore_forward' && index > 0 ? `idle_explore_forward` : functionId, - context, - encounter, - }), - ); -} - -function needsStoryLanguageRepair(response: AIResponse) { - return hasMixedNarrativeLanguage(response.storyText); -} - -function buildStoryLanguageFallbackText(context: PromptContext) { - if (context.inBattle === true) { - return '敌意仍压在眼前,战斗局势还没有真正松开。'; - } - - if (readString(context.encounterName)) { - return `${readString(context.encounterName)}的态度与周围气氛都出现了新的变化,你需要立刻判断接下来如何应对。`; - } - - return `${readString(context.sceneName) || '眼前区域'}里的气氛又有了新的变化,你需要继续判断下一步。`; -} - -function finalizeStoryNarrativeLanguage( - response: AIResponse, - context: PromptContext, -): AIResponse { - if (!needsStoryLanguageRepair(response)) { - return response; - } - - return { - ...response, - storyText: buildStoryLanguageFallbackText(context), - }; -} - -function normalizeResponse( - raw: unknown, - context: PromptContext, - requestOptions: StoryRequestOptions = {}, -): AIResponse { - const parsedEncounter = normalizeEncounterResult( - (raw as Record | null)?.encounter, - context, - ); - const fallbackOptions = - requestOptions.availableOptions?.map(cloneStoryOption) ?? - requestOptions.optionCatalog?.map(cloneStoryOption) ?? - getFallbackOptions(context, parsedEncounter); - - if (!raw || typeof raw !== 'object') { - return { - storyText: - context.inBattle === true - ? '前方敌意仍在持续逼近,局势只允许继续交锋或抽身脱离。' - : '周围暂时平静下来,你可以继续探索或前往别处。', - options: fallbackOptions, - encounter: parsedEncounter, - }; - } - - const data = raw as Record; - const rawOptions = Array.isArray(data.options) ? data.options : []; - const optionItems = rawOptions - .map((option) => { - if (!option || typeof option !== 'object') { - return null; - } - const item = option as Record; - const functionId = readString(item.functionId); - if (!functionId) { - return null; - } - return { - functionId, - actionText: readString(item.actionText) || undefined, - } satisfies RawOptionItem; - }) - .filter(Boolean) as RawOptionItem[]; - - const options = requestOptions.availableOptions - ? resolveOptionsFromProvidedOptions(optionItems, requestOptions.availableOptions) - : requestOptions.optionCatalog - ? resolveOptionsFromOptionCatalog( - optionItems, - requestOptions.optionCatalog, - context, - parsedEncounter, - ) - : optionItems.length > 0 - ? optionItems.map((item) => - createGenericOption({ - functionId: item.functionId, - actionText: item.actionText, - context, - encounter: parsedEncounter, - }), - ) - : fallbackOptions; - - return { - storyText: - readString(data.storyText) || - (context.inBattle === true - ? '敌人仍在前方压迫而来,战斗还没有结束。' - : '前路重新安静下来,可以继续决定接下来的探索方向。'), - options: options.length > 0 ? options : fallbackOptions, - encounter: parsedEncounter, - }; -} - -async function repairStoryNarrativeLanguage( - llmClient: UpstreamLlmClient, - response: AIResponse, - context: PromptContext, - requestOptions: StoryRequestOptions, -) { - if (!needsStoryLanguageRepair(response)) { - return finalizeStoryNarrativeLanguage(response, context); - } - - try { - const repairedContent = await llmClient.requestMessageContent({ - systemPrompt: STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT, - userPrompt: buildStoryLanguageRepairPrompt(response), - }); - const repairedResponse = normalizeResponse( - parseJsonResponseText(repairedContent), - context, - requestOptions, - ); - return finalizeStoryNarrativeLanguage(repairedResponse, context); - } catch (error) { - llmClient.logger.warn( - { - err: error, - }, - 'story narrative language repair failed', - ); - return finalizeStoryNarrativeLanguage(response, context); - } -} - -async function requestStoryCompletion( - llmClient: UpstreamLlmClient, - params: { - worldType: PromptWorldType; - character: PromptCharacter; - monsters: PromptMonsters; - history: PromptHistory; - choice?: string; - context: PromptContext; - requestOptions?: StoryRequestOptions; - }, -) { - const content = await llmClient.requestMessageContent({ - systemPrompt: SYSTEM_PROMPT, - userPrompt: buildUserPrompt({ - worldType: params.worldType, - character: params.character, - monsters: params.monsters, - history: params.history, - context: params.context, - choice: params.choice, - requestOptions: params.requestOptions, - }), - }); - const response = normalizeResponse( - parseJsonResponseText(content), - params.context, - params.requestOptions, - ); - - return repairStoryNarrativeLanguage( - llmClient, - response, - params.context, - params.requestOptions ?? {}, - ); -} - -export async function generateInitialStoryFromOrchestrator( - llmClient: UpstreamLlmClient, - worldType: PromptWorldType, - character: PromptCharacter, - monsters: PromptMonsters, - context: PromptContext, - requestOptions: StoryRequestOptions = {}, -) { - return requestStoryCompletion(llmClient, { - worldType, - character, - monsters, - history: [], - context, - requestOptions, - }); -} - -export async function generateNextStoryFromOrchestrator( - llmClient: UpstreamLlmClient, - worldType: PromptWorldType, - character: PromptCharacter, - monsters: PromptMonsters, - history: PromptHistory, - choice: string, - context: PromptContext, - requestOptions: StoryRequestOptions = {}, -) { - return requestStoryCompletion(llmClient, { - worldType, - character, - monsters, - history, - choice, - context, - requestOptions, - }); -} diff --git a/server-node/src/modules/ai/storyPromptBuilders.test.ts b/server-node/src/modules/ai/storyPromptBuilders.test.ts deleted file mode 100644 index c871fb2b..00000000 --- a/server-node/src/modules/ai/storyPromptBuilders.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { buildUserPrompt } from './storyPromptBuilders.js'; - -test('buildUserPrompt adds post-chat reevaluation guidance for npc option catalogs', () => { - const prompt = buildUserPrompt({ - worldType: 'WUXIA', - character: { - name: '沈行', - title: '试剑客', - description: '测试角色', - personality: '谨慎', - }, - monsters: [], - history: [ - { text: '你:刚才那句话是什么意思?' }, - { text: '山道客:你最好别继续深究。' }, - ], - context: { - sceneName: '山道', - sceneDescription: '风声贴着碎石一路往前卷。', - encounterName: '山道客', - playerHp: 100, - playerMaxHp: 100, - playerMana: 30, - playerMaxMana: 30, - inBattle: false, - pendingSceneEncounter: false, - lastFunctionId: 'npc_chat', - }, - choice: '结束与山道客的这轮交谈,重新观察当前局势', - requestOptions: { - optionCatalog: [ - { - functionId: 'npc_chat', - actionText: '继续交谈', - }, - { - functionId: 'npc_help', - actionText: '请求援手', - }, - { - functionId: 'npc_trade', - actionText: '看看能交换什么', - }, - ], - }, - }); - - assert.match(prompt, /刚结束一轮 NPC 交谈后/u); - assert.match(prompt, /不要退回/u); - assert.match(prompt, /目录只是合法 function 范围/u); -}); diff --git a/server-node/src/modules/ai/storyPromptBuilders.ts b/server-node/src/modules/ai/storyPromptBuilders.ts deleted file mode 100644 index b13bba4d..00000000 --- a/server-node/src/modules/ai/storyPromptBuilders.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '../../prompts/storyPromptBuilders.js'; diff --git a/server-node/src/modules/assets/characterAssetRoutes.test.ts b/server-node/src/modules/assets/characterAssetRoutes.test.ts deleted file mode 100644 index fabe9542..00000000 --- a/server-node/src/modules/assets/characterAssetRoutes.test.ts +++ /dev/null @@ -1,1358 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import { - createServer, - type IncomingMessage, - type ServerResponse, -} from 'node:http'; -import os from 'node:os'; -import path from 'node:path'; -import test from 'node:test'; - -import express from 'express'; -import { PNG } from 'pngjs'; - -import { removeBackgroundFromRgba } from '../../../../packages/shared/src/assets/chromaKey.js'; -import type { AppConfig } from '../../config.js'; -import { createCharacterAssetRoutes } from './characterAssetRoutes.js'; - -const PNG_BUFFER = Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7+7aQAAAAASUVORK5CYII=', - 'base64', -); -const MP4_BUFFER = Buffer.from('mock-video'); - -function createGreenScreenFixturePngBuffer() { - const png = new PNG({ width: 2, height: 1 }); - - png.data[0] = 0; - png.data[1] = 255; - png.data[2] = 0; - png.data[3] = 255; - - png.data[4] = 220; - png.data[5] = 48; - png.data[6] = 72; - png.data[7] = 255; - - return PNG.sync.write(png); -} - -function setPngPixel( - png: PNG, - x: number, - y: number, - rgba: [number, number, number, number], -) { - const offset = (y * png.width + x) * 4; - png.data[offset] = rgba[0]; - png.data[offset + 1] = rgba[1]; - png.data[offset + 2] = rgba[2]; - png.data[offset + 3] = rgba[3]; -} - -function createWhiteBackdropFixturePngBuffer() { - const png = new PNG({ width: 5, height: 5 }); - - for (let y = 0; y < png.height; y += 1) { - for (let x = 0; x < png.width; x += 1) { - setPngPixel(png, x, y, [255, 255, 255, 255]); - } - } - - for (let y = 1; y <= 3; y += 1) { - for (let x = 1; x <= 3; x += 1) { - setPngPixel(png, x, y, [220, 62, 86, 255]); - } - } - - setPngPixel(png, 2, 2, [244, 244, 244, 255]); - - return PNG.sync.write(png); -} - -function createGreenHaloFixturePngBuffer() { - const png = new PNG({ width: 5, height: 5 }); - - for (let y = 0; y < png.height; y += 1) { - for (let x = 0; x < png.width; x += 1) { - setPngPixel(png, x, y, [0, 255, 0, 255]); - } - } - - for (let y = 1; y <= 3; y += 1) { - setPngPixel(png, 1, y, [164, 186, 126, 255]); - setPngPixel(png, 2, y, [220, 60, 82, 255]); - setPngPixel(png, 3, y, [208, 52, 76, 255]); - } - - return PNG.sync.write(png); -} - -function readPngAlphaValues(buffer: Buffer) { - const png = PNG.sync.read(buffer); - return Array.from({ length: png.width * png.height }, (_, index) => { - return png.data[index * 4 + 3] ?? 0; - }); -} - -function readPngPixel( - buffer: Buffer, - x: number, - y: number, -): { red: number; green: number; blue: number; alpha: number } { - const png = PNG.sync.read(buffer); - const offset = (y * png.width + x) * 4; - - return { - red: png.data[offset] ?? 0, - green: png.data[offset + 1] ?? 0, - blue: png.data[offset + 2] ?? 0, - alpha: png.data[offset + 3] ?? 0, - }; -} - -const GREEN_SCREEN_PNG_BUFFER = createGreenScreenFixturePngBuffer(); -const WHITE_BACKDROP_PNG_BUFFER = createWhiteBackdropFixturePngBuffer(); -const GREEN_HALO_PNG_BUFFER = createGreenHaloFixturePngBuffer(); - -function createTestConfig( - projectRoot: string, - upstreamBaseUrl: string, -): AppConfig { - return { - projectRoot, - assetsApiEnabled: true, - rawEnv: { - DASHSCOPE_BASE_URL: upstreamBaseUrl, - DASHSCOPE_API_KEY: 'test-dashscope-key', - ARK_BASE_URL: upstreamBaseUrl, - ARK_API_KEY: 'test-ark-key', - }, - } as AppConfig; -} - -function readRequestBody(req: IncomingMessage) { - return new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - req.on('data', (chunk) => { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - }); - req.on('end', () => resolve(Buffer.concat(chunks))); - req.on('error', reject); - }); -} - -function sendJson(res: ServerResponse, payload: unknown) { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json; charset=utf-8'); - res.end(JSON.stringify(payload)); -} - -async function withHttpServer( - buildHandler: ( - baseUrl: string, - ) => (req: IncomingMessage, res: ServerResponse) => void | Promise, - run: (baseUrl: string) => Promise, -) { - let handler: ( - req: IncomingMessage, - res: ServerResponse, - ) => void | Promise = () => undefined; - const server = createServer((req, res) => { - Promise.resolve(handler(req, res)).catch((error) => { - res.statusCode = 500; - res.end(error instanceof Error ? error.stack : String(error)); - }); - }); - - await new Promise((resolve) => { - server.listen(0, '127.0.0.1', () => resolve()); - }); - - const address = server.address(); - if (!address || typeof address === 'string') { - throw new Error('failed to resolve test server address'); - } - - const baseUrl = `http://127.0.0.1:${address.port}`; - handler = buildHandler(baseUrl); - - try { - return await run(baseUrl); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => { - if (error) { - reject(error); - return; - } - resolve(); - }); - }); - } -} - -async function withAssetRouteServer( - config: AppConfig, - run: (baseUrl: string) => Promise, -) { - const app = express(); - app.use(express.json({ limit: '25mb' })); - app.use(createCharacterAssetRoutes(config)); - - const server = await new Promise((resolve) => { - const nextServer = app.listen(0, '127.0.0.1', () => resolve(nextServer)); - }); - - try { - const address = server.address(); - if (!address || typeof address === 'string') { - throw new Error('failed to resolve asset route server address'); - } - - return await run(`http://127.0.0.1:${address.port}`); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => { - if (error) { - reject(error); - return; - } - resolve(); - }); - }); - } -} - -test('removeBackgroundFromRgba strips border-connected white background and keeps enclosed white highlights', () => { - const png = PNG.sync.read(WHITE_BACKDROP_PNG_BUFFER); - - const changed = removeBackgroundFromRgba(png.data, png.width, png.height); - - assert.equal(changed, true); - assert.equal(png.data[3] ?? 255, 0); - assert.equal(png.data[(2 * png.width + 2) * 4 + 3] ?? 0, 255); -}); - -test('removeBackgroundFromRgba reduces green spill on edge pixels without eroding the foreground core', () => { - const cleanedBuffer = (() => { - const png = PNG.sync.read(GREEN_HALO_PNG_BUFFER); - removeBackgroundFromRgba(png.data, png.width, png.height); - return PNG.sync.write(png); - })(); - - const haloPixel = readPngPixel(cleanedBuffer, 1, 2); - const corePixel = readPngPixel(cleanedBuffer, 2, 2); - - assert.equal(corePixel.alpha, 255); - assert.equal(corePixel.red > corePixel.green, true); - assert.equal( - haloPixel.alpha < 120 || haloPixel.green <= haloPixel.red + 12, - true, - ); -}); - -test('character visual generation converts public reference images into data urls before calling DashScope', async () => { - const tempRoot = fs.mkdtempSync( - path.join(os.tmpdir(), 'genarrative-character-visual-'), - ); - const publicDir = path.join(tempRoot, 'public'); - fs.mkdirSync(publicDir, { recursive: true }); - fs.writeFileSync(path.join(publicDir, 'reference.png'), PNG_BUFFER); - - let createPayloadText = ''; - - await withHttpServer( - (dashScopeBaseUrl) => async (req, res) => { - const url = new URL(req.url || '/', dashScopeBaseUrl); - if ( - req.method === 'POST' && - url.pathname === '/api/v1/services/aigc/image-generation/generation' - ) { - createPayloadText = (await readRequestBody(req)).toString('utf8'); - sendJson(res, { - output: { - task_id: 'visual-task-1', - }, - }); - return; - } - - if ( - req.method === 'GET' && - url.pathname === '/api/v1/tasks/visual-task-1' - ) { - sendJson(res, { - output: { - task_status: 'SUCCEEDED', - results: [ - { - url: `${dashScopeBaseUrl}/downloads/visual.png`, - }, - ], - }, - }); - return; - } - - if (req.method === 'GET' && url.pathname === '/downloads/visual.png') { - res.statusCode = 200; - res.setHeader('Content-Type', 'image/png'); - res.end(GREEN_SCREEN_PNG_BUFFER); - return; - } - - res.statusCode = 404; - res.end('not found'); - }, - async (dashScopeBaseUrl) => { - const config = createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`); - await withAssetRouteServer(config, async (assetBaseUrl) => { - const response = await fetch( - `${assetBaseUrl}/api/assets/character-visual/generate`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - characterId: 'harbor-guide', - sourceMode: 'image-to-image', - promptText: '潮雾港向导', - characterBriefText: '旧港守望者', - referenceImageDataUrls: ['/reference.png'], - candidateCount: 1, - imageModel: 'wan2.7-image-pro', - size: '1024*1536', - }), - }, - ); - - assert.equal(response.status, 200); - const payload = (await response.json()) as { - drafts: Array<{ imageSrc: string }>; - }; - assert.equal(payload.drafts.length, 1); - - const createPayload = JSON.parse(createPayloadText) as { - input: { - messages: Array<{ - content: Array<{ text?: string; image?: string }>; - }>; - }; - parameters: { - negative_prompt?: string; - }; - }; - const content = createPayload.input.messages[0]?.content ?? []; - assert.match(content[0]?.text ?? '', /右向斜侧身/u); - assert.match(content[0]?.text ?? '', /纯绿色绿幕/u); - assert.match(content[0]?.text ?? '', /1:1 正方形画幅|1:1 正方形画布/u); - assert.match(content[0]?.text ?? '', /3 到 4 头身/u); - assert.match(content[0]?.text ?? '', /像素动作角色/u); - assert.match(content[0]?.text ?? '', /不要退化成软萌 Q版大头贴/u); - assert.match(content[0]?.text ?? '', /不要把主题词自动扩写成背景建筑/u); - assert.doesNotMatch(content[0]?.text ?? '', /水母国王/u); - assert.match( - createPayload.parameters.negative_prompt ?? '', - /软萌 Q版大头贴/u, - ); - assert.match(content[1]?.image ?? '', /^data:image\/png;base64,/u); - - const savedDraftPath = path.join( - tempRoot, - 'public', - payload.drafts[0]!.imageSrc.slice(1), - ); - assert.equal(fs.existsSync(savedDraftPath), true); - assert.deepEqual( - readPngAlphaValues(fs.readFileSync(savedDraftPath)), - [0, 255], - ); - }); - }, - ); -}); - -test('character workflow cache persists unsaved studio state', async () => { - const tempRoot = fs.mkdtempSync( - path.join(os.tmpdir(), 'genarrative-character-workflow-cache-'), - ); - - await withAssetRouteServer( - createTestConfig(tempRoot, 'http://127.0.0.1:9/api/v1'), - async (assetBaseUrl) => { - const saveResponse = await fetch( - `${assetBaseUrl}/api/assets/character-workflow-cache`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - characterId: 'harbor-guide', - visualPromptText: '潮雾港守望者', - animationPromptText: '短刀起手,收招利落', - visualDrafts: [ - { - id: 'draft-1', - label: '候选 1', - imageSrc: - '/generated-character-drafts/harbor-guide/draft-1.png', - width: 1024, - height: 1536, - }, - ], - selectedVisualDraftId: 'draft-1', - selectedAnimation: 'idle', - imageSrc: - '/generated-characters/harbor-guide/visual/visual-1/master.png', - generatedVisualAssetId: 'visual-1', - generatedAnimationSetId: 'animation-set-1', - animationMap: { - idle: { - basePath: - '/generated-animations/harbor-guide/animation-set-1/idle', - }, - }, - }), - }, - ); - - assert.equal(saveResponse.status, 200); - - const readResponse = await fetch( - `${assetBaseUrl}/api/assets/character-workflow-cache/harbor-guide`, - ); - assert.equal(readResponse.status, 200); - - const payload = (await readResponse.json()) as { - cache: { - characterId: string; - selectedVisualDraftId: string; - generatedVisualAssetId?: string; - animationMap?: Record; - } | null; - }; - - assert.equal(payload.cache?.characterId, 'harbor-guide'); - assert.equal(payload.cache?.selectedVisualDraftId, 'draft-1'); - assert.equal(payload.cache?.generatedVisualAssetId, 'visual-1'); - assert.equal( - payload.cache?.animationMap?.idle?.basePath, - '/generated-animations/harbor-guide/animation-set-1/idle', - ); - }, - ); -}); - -test('character workflow cache skips rewriting unchanged payloads', async () => { - const tempRoot = fs.mkdtempSync( - path.join(os.tmpdir(), 'genarrative-character-workflow-cache-stable-'), - ); - - await withAssetRouteServer( - createTestConfig(tempRoot, 'http://127.0.0.1:9/api/v1'), - async (assetBaseUrl) => { - const payload = { - characterId: 'harbor-guide', - visualPromptText: '潮雾港守望者', - animationPromptText: '短刀起手,收招利落', - visualDrafts: [ - { - id: 'draft-1', - label: '候选 1', - imageSrc: '/generated-character-drafts/harbor-guide/draft-1.png', - width: 1024, - height: 1024, - }, - ], - selectedVisualDraftId: 'draft-1', - selectedAnimation: 'idle', - imageSrc: - '/generated-characters/harbor-guide/visual/visual-1/master.png', - generatedVisualAssetId: 'visual-1', - generatedAnimationSetId: 'animation-set-1', - animationMap: { - idle: { - basePath: '/generated-animations/harbor-guide/animation-set-1/idle', - }, - }, - }; - - const firstSaveResponse = await fetch( - `${assetBaseUrl}/api/assets/character-workflow-cache`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }, - ); - assert.equal(firstSaveResponse.status, 200); - const firstSavePayload = (await firstSaveResponse.json()) as { - cache: { - updatedAt: string; - }; - }; - - const secondSaveResponse = await fetch( - `${assetBaseUrl}/api/assets/character-workflow-cache`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }, - ); - assert.equal(secondSaveResponse.status, 200); - const secondSavePayload = (await secondSaveResponse.json()) as { - saveMessage: string; - cache: { - updatedAt: string; - }; - }; - - assert.equal(secondSavePayload.saveMessage, '角色形象生成缓存无变化。'); - assert.equal( - secondSavePayload.cache.updatedAt, - firstSavePayload.cache.updatedAt, - ); - }, - ); -}); - -test('character workflow cache stays isolated for different character ids', async () => { - const tempRoot = fs.mkdtempSync( - path.join(os.tmpdir(), 'genarrative-character-workflow-cache-isolated-'), - ); - - await withAssetRouteServer( - createTestConfig(tempRoot, 'http://127.0.0.1:9/api/v1'), - async (assetBaseUrl) => { - const firstPayload = { - characterId: '巡海夜灯', - visualPromptText: '夜灯守望者', - animationPromptText: '短刀前压,动作克制', - visualDrafts: [ - { - id: 'draft-1', - label: '候选 1', - imageSrc: '/generated-character-drafts/sea-lantern/draft-1.png', - width: 1024, - height: 1024, - }, - ], - selectedVisualDraftId: 'draft-1', - selectedAnimation: 'idle', - }; - const secondPayload = { - characterId: '雾港引路人', - visualPromptText: '雾港引路者', - animationPromptText: '提灯侧身,站姿稳定', - visualDrafts: [ - { - id: 'draft-2', - label: '候选 2', - imageSrc: '/generated-character-drafts/fog-guide/draft-2.png', - width: 1024, - height: 1024, - }, - ], - selectedVisualDraftId: 'draft-2', - selectedAnimation: 'run', - }; - - const firstSaveResponse = await fetch( - `${assetBaseUrl}/api/assets/character-workflow-cache`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(firstPayload), - }, - ); - assert.equal(firstSaveResponse.status, 200); - - const secondSaveResponse = await fetch( - `${assetBaseUrl}/api/assets/character-workflow-cache`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(secondPayload), - }, - ); - assert.equal(secondSaveResponse.status, 200); - - const firstReadResponse = await fetch( - `${assetBaseUrl}/api/assets/character-workflow-cache/${encodeURIComponent(firstPayload.characterId)}`, - ); - assert.equal(firstReadResponse.status, 200); - const firstReadPayload = (await firstReadResponse.json()) as { - cache: { - characterId: string; - visualPromptText: string; - } | null; - }; - - const secondReadResponse = await fetch( - `${assetBaseUrl}/api/assets/character-workflow-cache/${encodeURIComponent(secondPayload.characterId)}`, - ); - assert.equal(secondReadResponse.status, 200); - const secondReadPayload = (await secondReadResponse.json()) as { - cache: { - characterId: string; - visualPromptText: string; - } | null; - }; - - assert.equal( - firstReadPayload.cache?.characterId, - firstPayload.characterId, - ); - assert.equal( - firstReadPayload.cache?.visualPromptText, - firstPayload.visualPromptText, - ); - assert.equal( - secondReadPayload.cache?.characterId, - secondPayload.characterId, - ); - assert.equal( - secondReadPayload.cache?.visualPromptText, - secondPayload.visualPromptText, - ); - }, - ); -}); - -test('character animation publish returns frame dimensions in animation map', async () => { - const tempRoot = fs.mkdtempSync( - path.join(os.tmpdir(), 'genarrative-character-animation-publish-'), - ); - - await withAssetRouteServer( - createTestConfig(tempRoot, 'http://127.0.0.1:9/api/v1'), - async (assetBaseUrl) => { - const response = await fetch( - `${assetBaseUrl}/api/assets/character-animation/publish`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - characterId: 'harbor-guide', - visualAssetId: 'visual-1', - updateCharacterOverride: false, - animations: { - run: { - framesDataUrls: [ - `data:image/png;base64,${PNG_BUFFER.toString('base64')}`, - ], - fps: 12, - loop: true, - frameWidth: 144, - frameHeight: 192, - previewVideoPath: - '/generated-character-drafts/harbor-guide/animation/run/preview.mp4', - }, - }, - }), - }, - ); - - assert.equal(response.status, 200); - const payload = (await response.json()) as { - animationMap: Record< - string, - { - frameWidth?: number; - frameHeight?: number; - fps?: number; - loop?: boolean; - previewVideoPath?: string; - } - >; - }; - - assert.equal(payload.animationMap.run?.frameWidth, 144); - assert.equal(payload.animationMap.run?.frameHeight, 192); - assert.equal(payload.animationMap.run?.fps, 12); - assert.equal(payload.animationMap.run?.loop, true); - assert.equal( - payload.animationMap.run?.previewVideoPath, - '/generated-character-drafts/harbor-guide/animation/run/preview.mp4', - ); - }, - ); -}); - -test('character visual publish removes green screen before saving master and previews', async () => { - const tempRoot = fs.mkdtempSync( - path.join(os.tmpdir(), 'genarrative-character-visual-publish-'), - ); - const publicDir = path.join(tempRoot, 'public'); - fs.mkdirSync(publicDir, { recursive: true }); - fs.writeFileSync(path.join(publicDir, 'draft.png'), GREEN_SCREEN_PNG_BUFFER); - - await withAssetRouteServer( - createTestConfig(tempRoot, 'http://127.0.0.1:9/api/v1'), - async (assetBaseUrl) => { - const response = await fetch( - `${assetBaseUrl}/api/assets/character-visual/publish`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - characterId: 'harbor-guide', - sourceMode: 'image-to-image', - promptText: '潮雾港向导', - selectedPreviewSource: '/draft.png', - previewSources: ['/draft.png'], - width: 1024, - height: 1024, - updateCharacterOverride: false, - }), - }, - ); - - assert.equal(response.status, 200); - const payload = (await response.json()) as { - portraitPath: string; - }; - - const savedMasterPath = path.join( - tempRoot, - 'public', - payload.portraitPath.slice(1), - ); - const savedPreviewPath = path.join( - tempRoot, - 'public', - 'generated-characters', - 'harbor-guide', - 'visual', - path.basename(path.dirname(savedMasterPath)), - 'preview-1.png', - ); - - assert.equal(fs.existsSync(savedMasterPath), true); - assert.equal(fs.existsSync(savedPreviewPath), true); - assert.deepEqual( - readPngAlphaValues(fs.readFileSync(savedMasterPath)), - [0, 255], - ); - assert.deepEqual( - readPngAlphaValues(fs.readFileSync(savedPreviewPath)), - [0, 255], - ); - }, - ); -}); - -test('character animation image-to-video flow sends first and last frame data urls to Ark seedance with fixed params', async () => { - const tempRoot = fs.mkdtempSync( - path.join(os.tmpdir(), 'genarrative-character-video-'), - ); - const publicDir = path.join(tempRoot, 'public'); - fs.mkdirSync(publicDir, { recursive: true }); - fs.writeFileSync(path.join(publicDir, 'visual.png'), PNG_BUFFER); - - let videoSynthesisPayloadText = ''; - - await withHttpServer( - (arkBaseUrl) => async (req, res) => { - const url = new URL(req.url || '/', arkBaseUrl); - - if (req.method === 'POST' && url.pathname === '/api/v3/contents/generations/tasks') { - videoSynthesisPayloadText = (await readRequestBody(req)).toString( - 'utf8', - ); - sendJson(res, { - id: 'ark-video-task-1', - status: 'queued', - }); - return; - } - - if ( - req.method === 'GET' && - url.pathname === '/api/v3/contents/generations/tasks/ark-video-task-1' - ) { - sendJson(res, { - id: 'ark-video-task-1', - status: 'succeeded', - content: { - video_url: `${arkBaseUrl}/downloads/preview.mp4`, - }, - }); - return; - } - - if (req.method === 'GET' && url.pathname === '/downloads/preview.mp4') { - res.statusCode = 200; - res.setHeader('Content-Type', 'video/mp4'); - res.end(MP4_BUFFER); - return; - } - - res.statusCode = 404; - res.end('not found'); - }, - async (arkBaseUrl) => { - const config = createTestConfig(tempRoot, `${arkBaseUrl}/api/v3`); - await withAssetRouteServer(config, async (assetBaseUrl) => { - const response = await fetch( - `${assetBaseUrl}/api/assets/character-animation/generate`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - characterId: 'harbor-guide', - strategy: 'image-to-video', - animation: 'idle', - promptText: '站立观察海面', - characterBriefText: '旧港守望者', - visualSource: '/visual.png', - referenceImageDataUrls: [], - referenceVideoDataUrls: [], - frameCount: 8, - fps: 8, - durationSeconds: 7, - loop: true, - useChromaKey: true, - resolution: '720P', - ratio: '16:9', - imageSequenceModel: 'wan2.7-image-pro', - videoModel: 'doubao-seedance-2-0-fast-260128', - referenceVideoModel: 'wan2.7-r2v', - motionTransferModel: 'wan2.2-animate-move', - }), - }, - ); - - assert.equal(response.status, 200); - const payload = (await response.json()) as { - previewVideoPath: string; - }; - - const videoPayload = JSON.parse(videoSynthesisPayloadText) as { - resolution?: string; - ratio?: string; - duration?: number; - content: Array<{ - type: string; - role?: string; - image_url?: { url?: string }; - }>; - }; - assert.equal(videoPayload.resolution, '480p'); - assert.equal(videoPayload.ratio, '1:1'); - assert.equal(videoPayload.duration, 4); - assert.equal(videoPayload.content[1]?.type, 'image_url'); - assert.equal(videoPayload.content[1]?.role, 'first_frame'); - assert.match( - videoPayload.content[1]?.image_url?.url ?? '', - /^data:image\/png;base64,/u, - ); - assert.equal(videoPayload.content[2]?.type, 'image_url'); - assert.equal(videoPayload.content[2]?.role, 'last_frame'); - assert.match( - videoPayload.content[2]?.image_url?.url ?? '', - /^data:image\/png;base64,/u, - ); - - const savedVideoPath = path.join( - tempRoot, - 'public', - payload.previewVideoPath.slice(1), - ); - assert.equal(fs.existsSync(savedVideoPath), true); - }); - }, - ); -}); - -test('character animation non-loop image-to-video keeps first and last reference images in Ark request', async () => { - const tempRoot = fs.mkdtempSync( - path.join(os.tmpdir(), 'genarrative-character-kf2v-'), - ); - const publicDir = path.join(tempRoot, 'public'); - fs.mkdirSync(publicDir, { recursive: true }); - fs.writeFileSync(path.join(publicDir, 'visual.png'), PNG_BUFFER); - - let videoSynthesisPayloadText = ''; - - await withHttpServer( - (arkBaseUrl) => async (req, res) => { - const url = new URL(req.url || '/', arkBaseUrl); - - if ( - req.method === 'POST' && - url.pathname === '/api/v3/contents/generations/tasks' - ) { - videoSynthesisPayloadText = (await readRequestBody(req)).toString( - 'utf8', - ); - sendJson(res, { - id: 'ark-video-task-kf2v-1', - status: 'queued', - }); - return; - } - - if ( - req.method === 'GET' && - url.pathname === '/api/v3/contents/generations/tasks/ark-video-task-kf2v-1' - ) { - sendJson(res, { - id: 'ark-video-task-kf2v-1', - status: 'succeeded', - content: { - video_url: `${arkBaseUrl}/downloads/preview.mp4`, - }, - }); - return; - } - - if (req.method === 'GET' && url.pathname === '/downloads/preview.mp4') { - res.statusCode = 200; - res.setHeader('Content-Type', 'video/mp4'); - res.end(MP4_BUFFER); - return; - } - - res.statusCode = 404; - res.end('not found'); - }, - async (arkBaseUrl) => { - const config = createTestConfig(tempRoot, `${arkBaseUrl}/api/v3`); - await withAssetRouteServer(config, async (assetBaseUrl) => { - const response = await fetch( - `${assetBaseUrl}/api/assets/character-animation/generate`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - characterId: 'harbor-guide', - strategy: 'image-to-video', - animation: 'attack', - promptText: '短促挥击后收招', - characterBriefText: '旧港守望者', - visualSource: '/visual.png', - referenceImageDataUrls: [], - referenceVideoDataUrls: [], - frameCount: 8, - fps: 8, - durationSeconds: 4, - loop: false, - useChromaKey: true, - resolution: '480p', - ratio: '1:1', - imageSequenceModel: 'wan2.7-image-pro', - videoModel: 'doubao-seedance-2-0-fast-260128', - referenceVideoModel: 'wan2.7-r2v', - motionTransferModel: 'wan2.2-animate-move', - }), - }, - ); - - assert.equal(response.status, 200); - - const videoPayload = JSON.parse(videoSynthesisPayloadText) as { - model: string; - content: Array<{ - type: string; - role?: string; - image_url?: { url?: string }; - }>; - resolution?: string; - }; - assert.equal(videoPayload.model, 'doubao-seedance-2-0-fast-260128'); - assert.equal(videoPayload.content[1]?.role, 'first_frame'); - assert.match( - videoPayload.content[1]?.image_url?.url ?? '', - /^data:image\/png;base64,/u, - ); - assert.equal(videoPayload.content[2]?.role, 'last_frame'); - assert.match( - videoPayload.content[2]?.image_url?.url ?? '', - /^data:image\/png;base64,/u, - ); - assert.equal(videoPayload.resolution, '480p'); - }); - }, - ); -}); - -test('character animation die image-to-video still uses Ark first and last frame references', async () => { - const tempRoot = fs.mkdtempSync( - path.join(os.tmpdir(), 'genarrative-character-kf2v-die-'), - ); - const publicDir = path.join(tempRoot, 'public'); - fs.mkdirSync(publicDir, { recursive: true }); - fs.writeFileSync(path.join(publicDir, 'visual.png'), PNG_BUFFER); - - let videoSynthesisPayloadText = ''; - - await withHttpServer( - (arkBaseUrl) => async (req, res) => { - const url = new URL(req.url || '/', arkBaseUrl); - - if ( - req.method === 'POST' && - url.pathname === '/api/v3/contents/generations/tasks' - ) { - videoSynthesisPayloadText = (await readRequestBody(req)).toString( - 'utf8', - ); - sendJson(res, { - id: 'ark-video-task-die-1', - status: 'queued', - }); - return; - } - - if ( - req.method === 'GET' && - url.pathname === '/api/v3/contents/generations/tasks/ark-video-task-die-1' - ) { - sendJson(res, { - id: 'ark-video-task-die-1', - status: 'succeeded', - content: { - video_url: `${arkBaseUrl}/downloads/preview.mp4`, - }, - }); - return; - } - - if (req.method === 'GET' && url.pathname === '/downloads/preview.mp4') { - res.statusCode = 200; - res.setHeader('Content-Type', 'video/mp4'); - res.end(MP4_BUFFER); - return; - } - - res.statusCode = 404; - res.end('not found'); - }, - async (arkBaseUrl) => { - const config = createTestConfig(tempRoot, `${arkBaseUrl}/api/v3`); - await withAssetRouteServer(config, async (assetBaseUrl) => { - const response = await fetch( - `${assetBaseUrl}/api/assets/character-animation/generate`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - characterId: 'harbor-guide', - strategy: 'image-to-video', - animation: 'die', - promptText: '倒地后停在终止姿态', - characterBriefText: '旧港守望者', - visualSource: '/visual.png', - referenceImageDataUrls: [], - referenceVideoDataUrls: [], - frameCount: 8, - fps: 8, - durationSeconds: 4, - loop: false, - useChromaKey: true, - resolution: '480p', - ratio: '1:1', - imageSequenceModel: 'wan2.7-image-pro', - videoModel: 'doubao-seedance-2-0-fast-260128', - referenceVideoModel: 'wan2.7-r2v', - motionTransferModel: 'wan2.2-animate-move', - }), - }, - ); - - assert.equal(response.status, 200); - - const videoPayload = JSON.parse(videoSynthesisPayloadText) as { - model: string; - content: Array<{ - type: string; - role?: string; - image_url?: { url?: string }; - text?: string; - }>; - }; - assert.equal(videoPayload.model, 'doubao-seedance-2-0-fast-260128'); - assert.equal(videoPayload.content[1]?.role, 'first_frame'); - assert.match( - videoPayload.content[1]?.image_url?.url ?? '', - /^data:image\/png;base64,/u, - ); - assert.equal(videoPayload.content[2]?.role, 'last_frame'); - assert.match( - videoPayload.content[2]?.image_url?.url ?? '', - /^data:image\/png;base64,/u, - ); - assert.match( - videoPayload.content[0]?.text ?? '', - /动作英文名:die|动作英文名是 die/u, - ); - }); - }, - ); -}); - -test('character animation loop image-to-video uses Ark seedance fixed params and keeps two reference images', async () => { - const tempRoot = fs.mkdtempSync( - path.join(os.tmpdir(), 'genarrative-character-i2v-loop-'), - ); - const publicDir = path.join(tempRoot, 'public'); - fs.mkdirSync(publicDir, { recursive: true }); - fs.writeFileSync(path.join(publicDir, 'visual.png'), PNG_BUFFER); - - let videoSynthesisPayloadText = ''; - - await withHttpServer( - (arkBaseUrl) => async (req, res) => { - const url = new URL(req.url || '/', arkBaseUrl); - - if ( - req.method === 'POST' && - url.pathname === '/api/v3/contents/generations/tasks' - ) { - videoSynthesisPayloadText = (await readRequestBody(req)).toString( - 'utf8', - ); - sendJson(res, { - id: 'ark-video-task-loop-1', - status: 'queued', - }); - return; - } - - if ( - req.method === 'GET' && - url.pathname === '/api/v3/contents/generations/tasks/ark-video-task-loop-1' - ) { - sendJson(res, { - id: 'ark-video-task-loop-1', - status: 'succeeded', - content: { - video_url: `${arkBaseUrl}/downloads/preview.mp4`, - }, - }); - return; - } - - if (req.method === 'GET' && url.pathname === '/downloads/preview.mp4') { - res.statusCode = 200; - res.setHeader('Content-Type', 'video/mp4'); - res.end(MP4_BUFFER); - return; - } - - res.statusCode = 404; - res.end('not found'); - }, - async (arkBaseUrl) => { - const config = createTestConfig(tempRoot, `${arkBaseUrl}/api/v3`); - await withAssetRouteServer(config, async (assetBaseUrl) => { - const response = await fetch( - `${assetBaseUrl}/api/assets/character-animation/generate`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - characterId: 'harbor-guide', - strategy: 'image-to-video', - animation: 'run', - promptText: '稳定循环奔跑', - characterBriefText: '旧港守望者', - visualSource: '/visual.png', - referenceImageDataUrls: [], - referenceVideoDataUrls: [], - frameCount: 8, - fps: 8, - durationSeconds: 4, - loop: true, - useChromaKey: true, - resolution: '480p', - ratio: '1:1', - imageSequenceModel: 'wan2.7-image-pro', - videoModel: 'doubao-seedance-2-0-fast-260128', - referenceVideoModel: 'wan2.7-r2v', - motionTransferModel: 'wan2.2-animate-move', - }), - }, - ); - - assert.equal(response.status, 200); - - const videoPayload = JSON.parse(videoSynthesisPayloadText) as { - model: string; - content: Array<{ - type: string; - role?: string; - image_url?: { url?: string }; - }>; - resolution?: string; - ratio?: string; - duration?: number; - }; - assert.equal(videoPayload.model, 'doubao-seedance-2-0-fast-260128'); - assert.equal(videoPayload.content[1]?.role, 'first_frame'); - assert.match( - videoPayload.content[1]?.image_url?.url ?? '', - /^data:image\/png;base64,/u, - ); - assert.equal(videoPayload.content[2]?.role, 'last_frame'); - assert.match( - videoPayload.content[2]?.image_url?.url ?? '', - /^data:image\/png;base64,/u, - ); - assert.equal(videoPayload.resolution, '480p'); - assert.equal(videoPayload.ratio, '1:1'); - assert.equal(videoPayload.duration, 4); - }); - }, - ); -}); - -test('character animation reference-to-video can use only reference image media', async () => { - const tempRoot = fs.mkdtempSync( - path.join(os.tmpdir(), 'genarrative-character-r2v-'), - ); - const publicDir = path.join(tempRoot, 'public'); - fs.mkdirSync(publicDir, { recursive: true }); - fs.writeFileSync(path.join(publicDir, 'visual.png'), PNG_BUFFER); - - let videoSynthesisPayloadText = ''; - - await withHttpServer( - (dashScopeBaseUrl) => async (req, res) => { - const url = new URL(req.url || '/', dashScopeBaseUrl); - - if (req.method === 'GET' && url.pathname === '/api/v1/uploads') { - sendJson(res, { - data: { - upload_host: `${dashScopeBaseUrl}/upload`, - upload_dir: 'uploads/test-dir', - policy: 'policy', - signature: 'signature', - oss_access_key_id: 'oss-key', - }, - }); - return; - } - - if (req.method === 'POST' && url.pathname === '/upload') { - await readRequestBody(req); - res.statusCode = 200; - res.end('ok'); - return; - } - - if ( - req.method === 'POST' && - url.pathname === - '/api/v1/services/aigc/video-generation/video-synthesis' - ) { - videoSynthesisPayloadText = (await readRequestBody(req)).toString( - 'utf8', - ); - sendJson(res, { - output: { - task_id: 'video-task-r2v-1', - }, - }); - return; - } - - if ( - req.method === 'GET' && - url.pathname === '/api/v1/tasks/video-task-r2v-1' - ) { - sendJson(res, { - output: { - task_status: 'SUCCEEDED', - video_url: `${dashScopeBaseUrl}/downloads/preview.mp4`, - }, - }); - return; - } - - if (req.method === 'GET' && url.pathname === '/downloads/preview.mp4') { - res.statusCode = 200; - res.setHeader('Content-Type', 'video/mp4'); - res.end(MP4_BUFFER); - return; - } - - res.statusCode = 404; - res.end('not found'); - }, - async (dashScopeBaseUrl) => { - const config = createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`); - await withAssetRouteServer(config, async (assetBaseUrl) => { - const response = await fetch( - `${assetBaseUrl}/api/assets/character-animation/generate`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - characterId: 'harbor-guide', - strategy: 'reference-to-video', - animation: 'run', - promptText: '稳定循环奔跑', - characterBriefText: '旧港守望者', - visualSource: '/visual.png', - referenceImageDataUrls: [], - referenceVideoDataUrls: [], - frameCount: 8, - fps: 8, - durationSeconds: 4, - loop: true, - useChromaKey: true, - resolution: '720P', - imageSequenceModel: 'wan2.7-image-pro', - videoModel: 'wan2.7-i2v', - referenceVideoModel: 'wan2.7-r2v', - motionTransferModel: 'wan2.2-animate-move', - }), - }, - ); - - assert.equal(response.status, 200); - - const videoPayload = JSON.parse(videoSynthesisPayloadText) as { - input: { - media: Array<{ type: string; url: string }>; - }; - }; - assert.equal(videoPayload.input.media[0]?.type, 'reference_image'); - assert.match( - videoPayload.input.media[0]?.url ?? '', - /^oss:\/\/uploads\/test-dir\//u, - ); - assert.equal(videoPayload.input.media.length, 1); - }); - }, - ); -}); diff --git a/server-node/src/modules/assets/characterAssetRoutes.ts b/server-node/src/modules/assets/characterAssetRoutes.ts deleted file mode 100644 index 994120e8..00000000 --- a/server-node/src/modules/assets/characterAssetRoutes.ts +++ /dev/null @@ -1,3084 +0,0 @@ -import { createHash } from 'node:crypto'; -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 { - type NextFunction, - type Request, - type Response, - Router, -} from 'express'; -import { PNG } from 'pngjs'; - -import { removeBackgroundFromRgba } from '../../../../packages/shared/src/assets/chromaKey.js'; -import { parseApiErrorMessage } from '../../../../packages/shared/src/http.js'; -import type { AppConfig } from '../../config.js'; -import { routeMeta } from '../../middleware/routeMeta.js'; -import { - buildArkCharacterAnimationPrompt, - buildFallbackModerationSafeAnimationPrompt, - buildImageSequencePrompt, - buildNpcAnimationPrompt, - buildNpcVisualNegativePrompt, - buildNpcVisualPrompt, -} from '../../prompts/characterAssetPrompts.js'; -import type { UpstreamLlmClient } from '../../services/llmClient.js'; - -const CHARACTER_WORKFLOW_CACHE_PATH = '/api/assets/character-workflow-cache'; -const CHARACTER_WORKFLOW_CACHE_DETAIL_PATH = - '/api/assets/character-workflow-cache/:characterId'; -const CHARACTER_VISUAL_GENERATE_PATH = '/api/assets/character-visual/generate'; -const CHARACTER_VISUAL_PUBLISH_PATH = '/api/assets/character-visual/publish'; -const CHARACTER_VISUAL_JOB_DETAIL_PATH = - '/api/assets/character-visual/jobs/:taskId'; -const CHARACTER_ANIMATION_GENERATE_PATH = - '/api/assets/character-animation/generate'; -const CHARACTER_ANIMATION_PUBLISH_PATH = - '/api/assets/character-animation/publish'; -const CHARACTER_ANIMATION_JOB_DETAIL_PATH = - '/api/assets/character-animation/jobs/:taskId'; -const CHARACTER_ANIMATION_IMPORT_VIDEO_PATH = - '/api/assets/character-animation/import-video'; -const CHARACTER_ANIMATION_TEMPLATES_PATH = - '/api/assets/character-animation/templates'; -const DEFAULT_ARK_BASE_URL = 'https://ark.cn-beijing.volces.com/api/v3'; -const DEFAULT_DASHSCOPE_BASE_URL = 'https://dashscope.aliyuncs.com/api/v1'; -const DEFAULT_CHARACTER_VISUAL_MODEL = 'wan2.7-image-pro'; -const DEFAULT_CHARACTER_VIDEO_MODEL = 'doubao-seedance-2-0-fast-260128'; -const DEFAULT_CHARACTER_REFERENCE_VIDEO_MODEL = 'wan2.7-r2v'; -const DEFAULT_CHARACTER_MOTION_TRANSFER_MODEL = 'wan2.2-animate-move'; -const FIXED_ARK_CHARACTER_VIDEO_RESOLUTION = '480p'; -const FIXED_ARK_CHARACTER_VIDEO_RATIO = '1:1'; -const FIXED_ARK_CHARACTER_VIDEO_DURATION_SECONDS = 4; -const DASHSCOPE_IMAGE_TASK_POLL_INTERVAL_MS = 2500; -const DASHSCOPE_VIDEO_TASK_POLL_INTERVAL_MS = 15000; -const DASHSCOPE_IMAGE_TASK_TIMEOUT_MS = 180000; -const DASHSCOPE_VIDEO_TASK_TIMEOUT_MS = 420000; -const ARK_VIDEO_TASK_POLL_INTERVAL_MS = 5000; - -const BUILT_IN_MOTION_TEMPLATES = [ - { - id: 'idle_loop', - label: '待机循环', - animation: 'idle', - promptSuffix: '保持呼吸感和轻微重心起伏。', - notes: '适合方案三的默认待机模板。', - }, - { - id: 'run_side', - label: '奔跑侧移', - animation: 'run', - promptSuffix: '保持平稳横向移动,脚步连续。', - notes: '适合横版角色的标准奔跑模板。', - }, - { - id: 'attack_slash', - label: '横斩攻击', - animation: 'attack', - promptSuffix: '短促前踏后横斩,收招干净。', - notes: '适合近战角色的基础攻击模板。', - }, - { - id: 'die_fall', - label: '倒地死亡', - animation: 'die', - promptSuffix: '失衡倒地,动作完整结束。', - notes: '适合终结动作模板。', - }, -] as const; - -type RequestResponse = { - statusCode: number; - headers: Record; - body: Buffer; -}; - -type DecodedMediaPayload = { - buffer: Buffer; - mimeType: string; - extension: string; -}; - -function applyGreenScreenAlphaToPngBuffer(buffer: Buffer) { - try { - const png = PNG.sync.read(buffer); - const changed = removeBackgroundFromRgba(png.data, png.width, png.height); - - return changed ? PNG.sync.write(png) : buffer; - } catch { - return buffer; - } -} - -function applyChromaKeyToMediaPayload(payload: DecodedMediaPayload) { - if (payload.mimeType !== 'image/png' && payload.extension !== 'png') { - return payload; - } - - return { - ...payload, - buffer: applyGreenScreenAlphaToPngBuffer(payload.buffer), - mimeType: 'image/png', - extension: 'png', - } satisfies DecodedMediaPayload; -} - -type CharacterAssetWorkflowCacheRecord = { - characterId: string; - visualPromptText: string; - animationPromptText: string; - visualDrafts: Array<{ - id: string; - label: string; - imageSrc: string; - width: number; - height: number; - }>; - selectedVisualDraftId: string; - selectedAnimation: string; - imageSrc?: string; - generatedVisualAssetId?: string; - generatedAnimationSetId?: string; - animationMap?: Record | null; - updatedAt: string; -}; - -function serializeWorkflowCacheComparableValue( - value: CharacterAssetWorkflowCacheRecord | Record, -) { - const visualDrafts = Array.isArray(value.visualDrafts) - ? value.visualDrafts - .map((item) => { - if (!isRecordValue(item)) { - return null; - } - - return { - id: typeof item.id === 'string' ? item.id : '', - label: typeof item.label === 'string' ? item.label : '', - imageSrc: typeof item.imageSrc === 'string' ? item.imageSrc : '', - width: typeof item.width === 'number' ? item.width : 0, - height: typeof item.height === 'number' ? item.height : 0, - }; - }) - .filter(Boolean) - : []; - - return JSON.stringify({ - characterId: typeof value.characterId === 'string' ? value.characterId : '', - visualPromptText: - typeof value.visualPromptText === 'string' ? value.visualPromptText : '', - animationPromptText: - typeof value.animationPromptText === 'string' - ? value.animationPromptText - : '', - visualDrafts, - selectedVisualDraftId: - typeof value.selectedVisualDraftId === 'string' - ? value.selectedVisualDraftId - : '', - selectedAnimation: - typeof value.selectedAnimation === 'string' - ? value.selectedAnimation - : '', - imageSrc: typeof value.imageSrc === 'string' ? value.imageSrc : '', - generatedVisualAssetId: - typeof value.generatedVisualAssetId === 'string' - ? value.generatedVisualAssetId - : '', - generatedAnimationSetId: - typeof value.generatedAnimationSetId === 'string' - ? value.generatedAnimationSetId - : '', - animationMap: isRecordValue(value.animationMap) ? value.animationMap : null, - }); -} - -function readJsonBody(req: IncomingMessage & { body?: unknown }) { - const parsedBody = req.body; - if ( - parsedBody && - typeof parsedBody === 'object' && - !Array.isArray(parsedBody) - ) { - return Promise.resolve(parsedBody as Record); - } - - return new Promise>((resolve, reject) => { - const chunks: Buffer[] = []; - req.on('data', (chunk) => { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - }); - req.on('end', () => { - try { - const raw = Buffer.concat(chunks).toString('utf8') || '{}'; - resolve(JSON.parse(raw)); - } catch (error) { - reject(error); - } - }); - req.on('error', reject); - }); -} - -function sendJson(res: ServerResponse, statusCode: number, payload: unknown) { - res.statusCode = statusCode; - res.setHeader('Content-Type', 'application/json; charset=utf-8'); - res.end(JSON.stringify(payload)); -} - -function isRecordValue(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); -} - -function isStringArray(value: unknown): value is string[] { - return ( - Array.isArray(value) && - value.every((item) => typeof item === 'string' && item.trim().length > 0) - ); -} - -function sleep(ms: number) { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - -function normalizeDashScopeBaseUrl(value: string) { - return value.replace(/\/$/u, ''); -} - -function normalizeArkBaseUrl(value: string) { - return value.replace(/\/$/u, ''); -} - -function resolveRuntimeEnv(config: AppConfig) { - return config.rawEnv; -} - -function extractApiErrorMessage(responseText: string, fallbackMessage: string) { - return parseApiErrorMessage(responseText, fallbackMessage); -} - -function sanitizePathSegment(value: string) { - const normalized = value - .trim() - .toLowerCase() - .replace(/[^a-z0-9-_]+/gu, '-') - .replace(/-+/gu, '-') - .replace(/^-|-$/gu, ''); - - return normalized || 'asset'; -} - -function clampPromptSeedText(value: unknown, maxLength: number) { - if (typeof value !== 'string') { - return ''; - } - - return value.replace(/\s+/gu, ' ').trim().slice(0, maxLength); -} - -function isInappropriateContentMessage(value: string) { - return /finappropriate-content|inappropriate content|不适当内容|违规内容/iu.test( - value, - ); -} - -async function proxyJsonRequestWithPromptFallback(params: { - urlString: string; - apiKey: string; - buildBody: (prompt: string) => Record; - primaryPrompt: string; - fallbackPrompt?: string; - extraHeaders?: Record; -}) { - const firstResponse = await proxyJsonRequest( - params.urlString, - params.apiKey, - params.buildBody(params.primaryPrompt), - params.extraHeaders, - ); - - if (firstResponse.statusCode >= 200 && firstResponse.statusCode < 300) { - return { - response: firstResponse, - prompt: params.primaryPrompt, - moderationFallbackApplied: false, - }; - } - - const fallbackPrompt = params.fallbackPrompt?.trim() ?? ''; - const errorMessage = extractApiErrorMessage( - firstResponse.bodyText, - '视频生成请求失败。', - ); - - if ( - !fallbackPrompt || - fallbackPrompt === params.primaryPrompt || - !isInappropriateContentMessage(errorMessage) - ) { - return { - response: firstResponse, - prompt: params.primaryPrompt, - moderationFallbackApplied: false, - }; - } - - const secondResponse = await proxyJsonRequest( - params.urlString, - params.apiKey, - params.buildBody(fallbackPrompt), - params.extraHeaders, - ); - - return { - response: secondResponse, - prompt: fallbackPrompt, - moderationFallbackApplied: true, - }; -} - -function createTimestampId(prefix: string) { - return `${prefix}-${Date.now()}`; -} - -function getJobRecordPath( - rootDir: string, - kind: 'visual' | 'animation', - taskId: string, -) { - return path.resolve( - rootDir, - 'public', - 'generated-character-drafts', - '_jobs', - kind, - `${sanitizePathSegment(taskId)}.json`, - ); -} - -function getCharacterWorkflowCachePath(rootDir: string, characterId: string) { - const readableSegment = sanitizePathSegment(characterId); - const characterCacheKey = createHash('sha256') - .update(characterId, 'utf8') - .digest('hex') - .slice(0, 24); - - return path.resolve( - rootDir, - 'public', - 'generated-character-drafts', - `${readableSegment}-${characterCacheKey}`, - 'workflow-cache.json', - ); -} - -async function writeJobRecord( - rootDir: string, - kind: 'visual' | 'animation', - taskId: string, - payload: Record, -) { - const filePath = getJobRecordPath(rootDir, kind, taskId); - await mkdir(path.dirname(filePath), { recursive: true }); - await writeFile(filePath, JSON.stringify(payload, null, 2) + '\n', 'utf8'); -} - -async function readJobRecord( - rootDir: string, - kind: 'visual' | 'animation', - taskId: string, -) { - const filePath = getJobRecordPath(rootDir, kind, taskId); - const raw = await readFile(filePath, 'utf8'); - return JSON.parse(raw) as Record; -} - -async function readJsonObjectFile(filePath: string) { - try { - const content = await readFile(filePath, 'utf8'); - const parsed = JSON.parse(content); - return isRecordValue(parsed) ? parsed : {}; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return {}; - } - throw error; - } -} - -async function writeJsonObjectFile( - filePath: string, - payload: Record, -) { - await mkdir(path.dirname(filePath), { recursive: true }); - await writeFile(filePath, JSON.stringify(payload, null, 2) + '\n', 'utf8'); -} - -function decodeMediaDataUrl(dataUrl: string): DecodedMediaPayload { - const matched = /^data:([^,]+),(.+)$/u.exec(dataUrl); - if (!matched) { - throw new Error('不支持的媒体数据,要求使用 Base64 Data URL。'); - } - - const metadata = matched[1]; - const base64Payload = matched[2]; - const metadataParts = metadata - .split(';') - .map((item) => item.trim()) - .filter(Boolean); - const mimeType = metadataParts[0] ?? 'application/octet-stream'; - const isBase64 = metadataParts.some( - (item) => item.toLowerCase() === 'base64', - ); - - if (!isBase64) { - throw new Error('不支持的媒体数据,要求使用 Base64 Data URL。'); - } - - const extension = (() => { - switch (mimeType) { - case 'image/jpeg': - return 'jpg'; - case 'image/png': - return 'png'; - case 'image/webp': - return 'webp'; - case 'video/mp4': - return 'mp4'; - case 'video/quicktime': - return 'mov'; - case 'video/x-msvideo': - return 'avi'; - case 'video/webm': - return 'webm'; - default: - return mimeType.split('/')[1] ?? 'bin'; - } - })(); - - return { - buffer: Buffer.from(base64Payload, 'base64'), - mimeType, - extension, - }; -} - -async function resolveMediaSourcePayload( - rootDir: string, - source: string, -): Promise { - const dataUrlMatch = /^data:/u.test(source); - if (dataUrlMatch) { - return decodeMediaDataUrl(source); - } - - if (!source.startsWith('/')) { - throw new Error('媒体来源必须是 Data URL 或 public 目录下的 URL。'); - } - - const normalizedSource = path.posix.normalize(source).replace(/^\/+/u, ''); - const absolutePath = path.resolve( - rootDir, - 'public', - ...normalizedSource.split('/'), - ); - const publicRoot = path.resolve(rootDir, 'public'); - if (!absolutePath.startsWith(publicRoot)) { - throw new Error('媒体来源路径越界。'); - } - - const buffer = await readFile(absolutePath); - const extension = path - .extname(absolutePath) - .replace(/^\./u, '') - .toLowerCase(); - const mimeType = (() => { - switch (extension) { - case 'jpg': - case 'jpeg': - return 'image/jpeg'; - case 'png': - return 'image/png'; - case 'webp': - return 'image/webp'; - case 'mp4': - return 'video/mp4'; - case 'mov': - return 'video/quicktime'; - case 'avi': - return 'video/x-msvideo'; - default: - return 'application/octet-stream'; - } - })(); - - return { - buffer, - mimeType, - extension: extension || 'bin', - }; -} - -async function resolveCharacterVisualPayload( - rootDir: string, - source: string, -): Promise { - return applyChromaKeyToMediaPayload( - await resolveMediaSourcePayload(rootDir, source), - ); -} - -async function resolveMediaSourceAsDataUrl(rootDir: string, source: string) { - if (/^data:/u.test(source)) { - return source; - } - - const payload = await resolveMediaSourcePayload(rootDir, source); - return `data:${payload.mimeType};base64,${payload.buffer.toString('base64')}`; -} - -async function resolveCharacterVisualAsDataUrl(rootDir: string, source: string) { - const payload = await resolveCharacterVisualPayload(rootDir, source); - return `data:${payload.mimeType};base64,${payload.buffer.toString('base64')}`; -} - -function requestResponse( - urlString: string, - options: { - method?: string; - headers?: Record; - body?: Buffer | string; - } = {}, -) { - return new Promise((resolve, reject) => { - const url = new URL(urlString); - const transport = url.protocol === 'https:' ? https : http; - const payload = - typeof options.body === 'string' - ? Buffer.from(options.body) - : options.body; - const requestOptions: RequestOptions = { - protocol: url.protocol, - hostname: url.hostname, - port: url.port ? Number(url.port) : undefined, - path: `${url.pathname}${url.search}`, - method: options.method ?? 'GET', - headers: { - ...(options.headers ?? {}), - ...(payload ? { 'Content-Length': String(payload.byteLength) } : {}), - }, - }; - - const request = transport.request(requestOptions, (upstreamRes) => { - const chunks: Buffer[] = []; - upstreamRes.on('data', (chunk) => { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - }); - upstreamRes.on('end', () => { - resolve({ - statusCode: upstreamRes.statusCode ?? 502, - headers: upstreamRes.headers, - body: Buffer.concat(chunks), - }); - }); - upstreamRes.on('error', reject); - }); - - request.on('error', reject); - if (payload) { - request.write(payload); - } - request.end(); - }); -} - -function getRequestPathname(req: IncomingMessage & { originalUrl?: string }) { - return new URL(req.originalUrl || req.url || '/', 'http://localhost') - .pathname; -} - -function requestTextResponse( - urlString: string, - options: { - method?: string; - headers?: Record; - body?: Buffer | string; - } = {}, -) { - return requestResponse(urlString, options).then((response) => ({ - ...response, - bodyText: response.body.toString('utf8'), - })); -} - -function requestBinaryResponse( - urlString: string, - options: { - method?: string; - headers?: Record; - } = {}, -) { - return requestResponse(urlString, options); -} - -function proxyJsonRequest( - urlString: string, - apiKey: string, - body: Record, - extraHeaders: Record = {}, -) { - return requestTextResponse(urlString, { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - ...extraHeaders, - }, - body: JSON.stringify(body), - }); -} - -function buildMultipartBody( - fields: Array<{ name: string; value: string }>, - file: { - fieldName: string; - fileName: string; - contentType: string; - buffer: Buffer; - }, -) { - const boundary = `----GenarrativeBoundary${Date.now().toString(16)}`; - const chunks: Buffer[] = []; - - fields.forEach((field) => { - chunks.push( - Buffer.from( - `--${boundary}\r\nContent-Disposition: form-data; name="${field.name}"\r\n\r\n${field.value}\r\n`, - ), - ); - }); - - chunks.push( - Buffer.from( - `--${boundary}\r\nContent-Disposition: form-data; name="${file.fieldName}"; filename="${file.fileName}"\r\nContent-Type: ${file.contentType}\r\n\r\n`, - ), - ); - chunks.push(file.buffer); - chunks.push(Buffer.from(`\r\n--${boundary}--\r\n`)); - - return { - boundary, - body: Buffer.concat(chunks), - }; -} - -async function uploadFileToDashScope( - baseUrl: string, - apiKey: string, - model: string, - fileName: string, - payload: DecodedMediaPayload, -) { - const policyResponse = await requestTextResponse( - `${baseUrl}/uploads?action=getPolicy&model=${encodeURIComponent(model)}`, - { - method: 'GET', - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }, - ); - - if (policyResponse.statusCode < 200 || policyResponse.statusCode >= 300) { - throw new Error( - extractApiErrorMessage( - policyResponse.bodyText, - '获取阿里云临时上传策略失败。', - ), - ); - } - - const policyResponsePayload = JSON.parse(policyResponse.bodyText) as { - data?: { - upload_host?: string; - upload_dir?: string; - policy?: string; - signature?: string; - oss_access_key_id?: string; - x_oss_object_acl?: string; - x_oss_content_type?: string; - x_oss_forbid_overwrite?: string; - 'x-oss-object-acl'?: string; - 'x-oss-content-type'?: string; - 'x-oss-forbid-overwrite'?: string; - }; - }; - const policyPayload = policyResponsePayload.data ?? {}; - - if ( - !policyPayload.upload_host || - !policyPayload.upload_dir || - !policyPayload.policy || - !policyPayload.signature || - !policyPayload.oss_access_key_id - ) { - throw new Error('阿里云临时上传策略返回不完整。'); - } - - const objectKey = `${policyPayload.upload_dir.replace(/\/+$/u, '')}/${sanitizePathSegment(fileName)}.${payload.extension}`; - const multipart = buildMultipartBody( - [ - { name: 'key', value: objectKey }, - { name: 'OSSAccessKeyId', value: policyPayload.oss_access_key_id }, - { name: 'policy', value: policyPayload.policy }, - { name: 'Signature', value: policyPayload.signature }, - { name: 'success_action_status', value: '200' }, - ...(policyPayload.x_oss_object_acl || policyPayload['x-oss-object-acl'] - ? [ - { - name: 'x-oss-object-acl', - value: - policyPayload.x_oss_object_acl || - policyPayload['x-oss-object-acl'] || - '', - }, - ] - : []), - ...(policyPayload.x_oss_forbid_overwrite || - policyPayload['x-oss-forbid-overwrite'] - ? [ - { - name: 'x-oss-forbid-overwrite', - value: - policyPayload.x_oss_forbid_overwrite || - policyPayload['x-oss-forbid-overwrite'] || - '', - }, - ] - : []), - ...(policyPayload.x_oss_content_type || - policyPayload['x-oss-content-type'] - ? [ - { - name: 'x-oss-content-type', - value: - policyPayload.x_oss_content_type || - policyPayload['x-oss-content-type'] || - '', - }, - ] - : []), - ], - { - fieldName: 'file', - fileName, - contentType: payload.mimeType, - buffer: payload.buffer, - }, - ); - - const uploadResponse = await requestTextResponse(policyPayload.upload_host, { - method: 'POST', - headers: { - 'Content-Type': `multipart/form-data; boundary=${multipart.boundary}`, - }, - body: multipart.body, - }); - - if (uploadResponse.statusCode < 200 || uploadResponse.statusCode >= 300) { - throw new Error( - extractApiErrorMessage(uploadResponse.bodyText, '上传媒体文件失败。'), - ); - } - - return `oss://${objectKey}`; -} - -async function waitForDashScopeTask( - baseUrl: string, - apiKey: string, - taskId: string, - options: { - timeoutMs: number; - intervalMs: number; - }, -) { - const deadline = Date.now() + options.timeoutMs; - - while (Date.now() < deadline) { - const response = await requestTextResponse(`${baseUrl}/tasks/${taskId}`, { - method: 'GET', - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }); - - if (response.statusCode < 200 || response.statusCode >= 300) { - throw new Error( - extractApiErrorMessage( - response.bodyText, - `查询任务失败(${response.statusCode})。`, - ), - ); - } - - const parsed = JSON.parse(response.bodyText) as Record; - const output = isRecordValue(parsed.output) ? parsed.output : null; - const taskStatus = - output && typeof output.task_status === 'string' - ? output.task_status - : ''; - - if (taskStatus === 'SUCCEEDED') { - return parsed; - } - - if (taskStatus === 'FAILED' || taskStatus === 'CANCELED') { - throw new Error( - extractApiErrorMessage(response.bodyText, '任务执行失败。'), - ); - } - - if (taskStatus === 'UNKNOWN') { - throw new Error('任务状态未知,可能已过期。'); - } - - await sleep(options.intervalMs); - } - - throw new Error('任务执行超时,请稍后重试。'); -} - -function normalizeGenerationTaskStatus(value: string) { - return value.trim().toLowerCase().replace(/\s+/gu, '_'); -} - -function extractGenerationTaskStatus(payload: Record) { - const topLevelStatus = - typeof payload.status === 'string' ? payload.status.trim() : ''; - const output = isRecordValue(payload.output) ? payload.output : null; - const outputStatus = - output && typeof output.task_status === 'string' - ? output.task_status.trim() - : ''; - const nestedTaskStatus = findFirstStringByKey(payload, 'task_status') ?? ''; - const nestedStatus = findFirstStringByKey(payload, 'status') ?? ''; - - return normalizeGenerationTaskStatus( - topLevelStatus || outputStatus || nestedTaskStatus || nestedStatus, - ); -} - -function isCompletedGenerationTaskStatus(status: string) { - return [ - 'completed', - 'complete', - 'done', - 'finished', - 'success', - 'succeeded', - 'succeed', - ].includes(status); -} - -function isFailedGenerationTaskStatus(status: string) { - return [ - 'failed', - 'canceled', - 'cancelled', - 'error', - 'aborted', - 'rejected', - 'expired', - 'unknown', - ].includes(status); -} - -async function waitForArkContentGenerationTask( - baseUrl: string, - apiKey: string, - taskId: string, - options: { - timeoutMs: number; - intervalMs: number; - }, -) { - const deadline = Date.now() + options.timeoutMs; - - while (Date.now() < deadline) { - const response = await requestTextResponse( - `${baseUrl}/contents/generations/tasks/${encodeURIComponent(taskId)}`, - { - method: 'GET', - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }, - ); - - if (response.statusCode < 200 || response.statusCode >= 300) { - throw new Error( - extractApiErrorMessage( - response.bodyText, - `查询视频生成任务失败(${response.statusCode})。`, - ), - ); - } - - const parsed = JSON.parse(response.bodyText) as Record; - const taskStatus = extractGenerationTaskStatus(parsed); - - if (extractVideoUrl(parsed) || isCompletedGenerationTaskStatus(taskStatus)) { - return parsed; - } - - if (isFailedGenerationTaskStatus(taskStatus)) { - throw new Error( - extractApiErrorMessage(response.bodyText, '视频生成任务执行失败。'), - ); - } - - await sleep(options.intervalMs); - } - - throw new Error('视频生成任务执行超时,请稍后重试。'); -} - -function findFirstStringByKey( - value: unknown, - targetKey: string, -): string | null { - if (Array.isArray(value)) { - for (const item of value) { - const candidate = findFirstStringByKey(item, targetKey); - if (candidate) { - return candidate; - } - } - return null; - } - - if (!isRecordValue(value)) { - return null; - } - - const directValue = value[targetKey]; - if (typeof directValue === 'string' && directValue.trim()) { - return directValue.trim(); - } - - for (const nestedValue of Object.values(value)) { - const candidate = findFirstStringByKey(nestedValue, targetKey); - if (candidate) { - return candidate; - } - } - - return null; -} - -function collectStringsByKey( - value: unknown, - targetKey: string, - results: string[], -) { - if (Array.isArray(value)) { - value.forEach((item) => collectStringsByKey(item, targetKey, results)); - return; - } - - if (!isRecordValue(value)) { - return; - } - - const directValue = value[targetKey]; - if (typeof directValue === 'string' && directValue.trim()) { - results.push(directValue.trim()); - } - - Object.values(value).forEach((nestedValue) => - collectStringsByKey(nestedValue, targetKey, results), - ); -} - -function extractTaskId(payload: Record) { - const topLevelId = - typeof payload.id === 'string' && payload.id.trim() ? payload.id.trim() : ''; - - return topLevelId || (findFirstStringByKey(payload, 'task_id') ?? ''); -} - -function extractVideoUrl(payload: Record) { - return ( - findFirstStringByKey(payload, 'video_url') ?? - findFirstStringByKey(payload, 'url') ?? - '' - ); -} - -function extractImageUrls(payload: Record) { - const urls: string[] = []; - collectStringsByKey(payload, 'image', urls); - collectStringsByKey(payload, 'url', urls); - return [...new Set(urls)]; -} - -function getLowestSupportedVideoResolution(model: string, fallback: string) { - switch (model) { - case 'wan2.6-i2v-flash': - case 'wan2.6-i2v': - case 'wan2.6-i2v-us': - return '720P'; - case 'wan2.2-kf2v-flash': - case 'wan2.2-i2v-flash': - case 'wan2.5-i2v-preview': - return '480P'; - default: - return fallback; - } -} - -async function writeDraftBinaryFile( - rootDir: string, - relativePath: string, - buffer: Buffer, -) { - const absolutePath = path.resolve( - rootDir, - 'public', - ...relativePath.split('/'), - ); - await mkdir(path.dirname(absolutePath), { recursive: true }); - await writeFile(absolutePath, buffer); - return `/${relativePath}`; -} - -async function handleGenerateCharacterVisuals( - config: AppConfig, - req: IncomingMessage & { body?: unknown }, - res: ServerResponse, -) { - if (req.method !== 'POST') { - sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); - return; - } - - const rootDir = config.projectRoot; - const runtimeEnv = resolveRuntimeEnv(config); - const baseUrl = normalizeDashScopeBaseUrl( - runtimeEnv.DASHSCOPE_BASE_URL || DEFAULT_DASHSCOPE_BASE_URL, - ); - const apiKey = runtimeEnv.DASHSCOPE_API_KEY || ''; - const timeoutMs = Number( - runtimeEnv.DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS || - DASHSCOPE_IMAGE_TASK_TIMEOUT_MS, - ); - - if (!apiKey) { - sendJson(res, 500, { - error: { message: '缺少 DASHSCOPE_API_KEY,无法生成角色主形象。' }, - }); - return; - } - - let body: Record; - try { - body = await readJsonBody(req); - } catch { - sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); - return; - } - - const characterId = - typeof body.characterId === 'string' - ? body.characterId.trim() - : 'character'; - const sourceMode = - typeof body.sourceMode === 'string' ? body.sourceMode.trim() : ''; - const promptText = - typeof body.promptText === 'string' ? body.promptText.trim() : ''; - const characterBriefText = - typeof body.characterBriefText === 'string' - ? body.characterBriefText.trim() - : ''; - const referenceImageDataUrls = isStringArray(body.referenceImageDataUrls) - ? body.referenceImageDataUrls.slice(0, 4) - : []; - const candidateCountRaw = - typeof body.candidateCount === 'number' ? body.candidateCount : 3; - const candidateCount = Math.max( - 1, - Math.min(4, Math.round(candidateCountRaw)), - ); - const model = - typeof body.imageModel === 'string' && body.imageModel.trim() - ? body.imageModel.trim() - : runtimeEnv.DASHSCOPE_CHARACTER_VISUAL_MODEL || - runtimeEnv.DASHSCOPE_IMAGE_MODEL || - DEFAULT_CHARACTER_VISUAL_MODEL; - const size = - typeof body.size === 'string' && body.size.trim() - ? body.size.trim() - : '1024*1024'; - - if (sourceMode === 'image-to-image' && referenceImageDataUrls.length === 0) { - sendJson(res, 400, { - error: { message: '图生主形象至少需要一张参考图。' }, - }); - return; - } - - if (!promptText && !characterBriefText && sourceMode === 'text-to-image') { - sendJson(res, 400, { - error: { message: '文生主形象需要填写角色设定。' }, - }); - return; - } - - let activeTaskId = ''; - let activePrompt = ''; - try { - const finalPrompt = buildNpcVisualPrompt(promptText, characterBriefText); - const normalizedReferenceImages = await Promise.all( - referenceImageDataUrls.map((image) => - resolveMediaSourceAsDataUrl(rootDir, image), - ), - ); - activePrompt = finalPrompt; - const content = [ - { text: finalPrompt }, - ...normalizedReferenceImages.map((image) => ({ image })), - ]; - const createTaskResponse = await proxyJsonRequest( - `${baseUrl}/services/aigc/image-generation/generation`, - apiKey, - { - model, - input: { - messages: [ - { - role: 'user', - content, - }, - ], - }, - parameters: { - n: candidateCount, - size, - negative_prompt: buildNpcVisualNegativePrompt(), - prompt_extend: true, - watermark: false, - }, - }, - { - 'X-DashScope-Async': 'enable', - }, - ); - - if ( - createTaskResponse.statusCode < 200 || - createTaskResponse.statusCode >= 300 - ) { - sendJson(res, createTaskResponse.statusCode, { - error: { - message: extractApiErrorMessage( - createTaskResponse.bodyText, - '创建角色主形象任务失败。', - ), - }, - }); - return; - } - - const taskPayload = JSON.parse(createTaskResponse.bodyText) as Record< - string, - unknown - >; - const taskId = extractTaskId(taskPayload); - activeTaskId = taskId; - - if (!taskId) { - throw new Error('角色主形象任务未返回 task_id。'); - } - - const createdAt = new Date().toISOString(); - await writeJobRecord(rootDir, 'visual', taskId, { - taskId, - kind: 'visual', - status: 'running', - characterId, - model, - prompt: finalPrompt, - createdAt, - updatedAt: createdAt, - }); - - const taskResult = await waitForDashScopeTask(baseUrl, apiKey, taskId, { - timeoutMs: - Number.isFinite(timeoutMs) && timeoutMs > 0 - ? timeoutMs - : DASHSCOPE_IMAGE_TASK_TIMEOUT_MS, - intervalMs: DASHSCOPE_IMAGE_TASK_POLL_INTERVAL_MS, - }); - const imageUrls = extractImageUrls(taskResult).slice(0, candidateCount); - - if (imageUrls.length === 0) { - throw new Error('角色主形象生成成功,但没有返回可下载图片。'); - } - - const jobId = createTimestampId('visual-draft'); - const draftRelativeDir = path.posix.join( - 'generated-character-drafts', - sanitizePathSegment(characterId), - 'visual', - jobId, - ); - const drafts = await Promise.all( - imageUrls.map(async (imageUrl, index) => { - const imageResponse = await requestBinaryResponse(imageUrl); - - if (imageResponse.statusCode < 200 || imageResponse.statusCode >= 300) { - throw new Error( - `下载主形象候选失败(${imageResponse.statusCode})。`, - ); - } - - const fileName = `candidate-${String(index + 1).padStart(2, '0')}.png`; - const imageSrc = await writeDraftBinaryFile( - rootDir, - path.posix.join(draftRelativeDir, fileName), - applyGreenScreenAlphaToPngBuffer(imageResponse.body), - ); - - return { - id: `candidate-${index + 1}`, - label: `候选 ${index + 1}`, - imageSrc, - width: 1024, - height: 1024, - }; - }), - ); - - await writeFile( - path.resolve( - rootDir, - 'public', - ...draftRelativeDir.split('/'), - 'job.json', - ), - JSON.stringify( - { - taskId, - model, - prompt: finalPrompt, - sourceMode, - createdAt: new Date().toISOString(), - imageUrls, - }, - null, - 2, - ) + '\n', - 'utf8', - ); - - await writeJobRecord(rootDir, 'visual', taskId, { - taskId, - kind: 'visual', - status: 'completed', - characterId, - model, - prompt: finalPrompt, - createdAt, - updatedAt: new Date().toISOString(), - result: { - drafts, - draftRelativeDir, - }, - }); - - sendJson(res, 200, { - ok: true, - taskId, - model, - prompt: finalPrompt, - drafts, - }); - } catch (error) { - if (activeTaskId) { - await writeJobRecord(rootDir, 'visual', activeTaskId, { - taskId: activeTaskId, - kind: 'visual', - status: 'failed', - characterId, - model, - prompt: activePrompt, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - errorMessage: - error instanceof Error ? error.message : '生成角色主形象失败。', - }); - } - sendJson(res, 500, { - error: { - message: - error instanceof Error ? error.message : '生成角色主形象候选失败。', - }, - }); - } -} - -async function handleGenerateCharacterAnimation( - config: AppConfig, - req: IncomingMessage & { body?: unknown }, - res: ServerResponse, -) { - if (req.method !== 'POST') { - sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); - return; - } - - const rootDir = config.projectRoot; - const runtimeEnv = resolveRuntimeEnv(config); - const baseUrl = normalizeDashScopeBaseUrl( - runtimeEnv.DASHSCOPE_BASE_URL || DEFAULT_DASHSCOPE_BASE_URL, - ); - const apiKey = runtimeEnv.DASHSCOPE_API_KEY || ''; - const dashScopeTimeoutMs = Number( - runtimeEnv.DASHSCOPE_CHARACTER_VIDEO_REQUEST_TIMEOUT_MS || - runtimeEnv.DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS || - DASHSCOPE_VIDEO_TASK_TIMEOUT_MS, - ); - - let body: Record; - try { - body = await readJsonBody(req); - } catch { - sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); - return; - } - - const characterId = - typeof body.characterId === 'string' - ? body.characterId.trim() - : 'character'; - const strategy = - typeof body.strategy === 'string' ? body.strategy.trim() : ''; - const animation = - typeof body.animation === 'string' ? body.animation.trim() : 'idle'; - const promptText = - typeof body.promptText === 'string' ? body.promptText.trim() : ''; - const characterBriefText = - typeof body.characterBriefText === 'string' - ? body.characterBriefText.trim() - : ''; - const actionTemplateId = - typeof body.actionTemplateId === 'string' - ? body.actionTemplateId.trim() - : ''; - const visualSource = - typeof body.visualSource === 'string' ? body.visualSource.trim() : ''; - const referenceImageDataUrls = isStringArray(body.referenceImageDataUrls) - ? body.referenceImageDataUrls.slice(0, 6) - : []; - const referenceVideoDataUrls = isStringArray(body.referenceVideoDataUrls) - ? body.referenceVideoDataUrls.slice(0, 2) - : []; - const lastFrameImageDataUrl = - typeof body.lastFrameImageDataUrl === 'string' && - body.lastFrameImageDataUrl.trim() - ? body.lastFrameImageDataUrl.trim() - : ''; - const frameCount = - typeof body.frameCount === 'number' && Number.isFinite(body.frameCount) - ? Math.max(2, Math.min(16, Math.round(body.frameCount))) - : 8; - const requestedDurationSeconds = - typeof body.durationSeconds === 'number' && - Number.isFinite(body.durationSeconds) - ? Math.max(1, Math.min(8, Math.round(body.durationSeconds))) - : 4; - const loop = body.loop === true; - const useChromaKey = body.useChromaKey !== false; - const resolution = - typeof body.resolution === 'string' && body.resolution.trim() - ? body.resolution.trim() - : '480p'; - const imageSequenceModel = - typeof body.imageSequenceModel === 'string' && - body.imageSequenceModel.trim() - ? body.imageSequenceModel.trim() - : runtimeEnv.DASHSCOPE_CHARACTER_IMAGE_SEQUENCE_MODEL || - runtimeEnv.DASHSCOPE_CHARACTER_VISUAL_MODEL || - DEFAULT_CHARACTER_VISUAL_MODEL; - const requestedVideoModel = - typeof body.videoModel === 'string' && body.videoModel.trim() - ? body.videoModel.trim() - : runtimeEnv.ARK_CHARACTER_VIDEO_MODEL || - runtimeEnv.DASHSCOPE_CHARACTER_VIDEO_MODEL || - DEFAULT_CHARACTER_VIDEO_MODEL; - const videoModel = requestedVideoModel; - const durationSeconds = requestedDurationSeconds; - const referenceVideoModel = - typeof body.referenceVideoModel === 'string' && - body.referenceVideoModel.trim() - ? body.referenceVideoModel.trim() - : runtimeEnv.DASHSCOPE_CHARACTER_REFERENCE_VIDEO_MODEL || - DEFAULT_CHARACTER_REFERENCE_VIDEO_MODEL; - const motionTransferModel = - typeof body.motionTransferModel === 'string' && - body.motionTransferModel.trim() - ? body.motionTransferModel.trim() - : runtimeEnv.DASHSCOPE_CHARACTER_MOTION_TRANSFER_MODEL || - DEFAULT_CHARACTER_MOTION_TRANSFER_MODEL; - const arkBaseUrl = normalizeArkBaseUrl( - runtimeEnv.ARK_CHARACTER_VIDEO_BASE_URL || - runtimeEnv.ARK_BASE_URL || - runtimeEnv.LLM_BASE_URL || - DEFAULT_ARK_BASE_URL, - ); - const arkApiKey = - runtimeEnv.ARK_CHARACTER_VIDEO_API_KEY || - runtimeEnv.ARK_API_KEY || - runtimeEnv.LLM_API_KEY || - ''; - const arkTimeoutMs = Number( - runtimeEnv.ARK_CHARACTER_VIDEO_REQUEST_TIMEOUT_MS || - runtimeEnv.DASHSCOPE_CHARACTER_VIDEO_REQUEST_TIMEOUT_MS || - DASHSCOPE_VIDEO_TASK_TIMEOUT_MS, - ); - const normalizedArkResolution = FIXED_ARK_CHARACTER_VIDEO_RESOLUTION; - const normalizedArkRatio = FIXED_ARK_CHARACTER_VIDEO_RATIO; - - if (!visualSource) { - sendJson(res, 400, { - error: { message: '请先准备主形象,再生成动作。' }, - }); - return; - } - - if (strategy === 'image-to-video' && !arkApiKey) { - sendJson(res, 500, { - error: { message: '缺少 ARK_API_KEY,无法生成角色动作。' }, - }); - return; - } - - if (strategy !== 'image-to-video' && !apiKey) { - sendJson(res, 500, { - error: { message: '缺少 DASHSCOPE_API_KEY,无法生成角色动作。' }, - }); - return; - } - - let activeTaskId = ''; - let activePrompt = ''; - let activeModel = ''; - try { - if (strategy === 'image-sequence') { - const finalPrompt = buildImageSequencePrompt( - animation, - promptText, - frameCount, - useChromaKey, - ); - const normalizedVisualSource = await resolveMediaSourceAsDataUrl( - rootDir, - visualSource, - ); - const normalizedReferenceImages = await Promise.all( - referenceImageDataUrls.map((image) => - resolveMediaSourceAsDataUrl(rootDir, image), - ), - ); - activePrompt = finalPrompt; - activeModel = imageSequenceModel; - const createTaskResponse = await proxyJsonRequest( - `${baseUrl}/services/aigc/image-generation/generation`, - apiKey, - { - model: imageSequenceModel, - input: { - messages: [ - { - role: 'user', - content: [ - { text: finalPrompt }, - { image: normalizedVisualSource }, - ...normalizedReferenceImages.map((image) => ({ image })), - ], - }, - ], - }, - parameters: { - n: frameCount, - size: '768*1024', - enable_sequential: true, - prompt_extend: true, - watermark: false, - }, - }, - { - 'X-DashScope-Async': 'enable', - }, - ); - - if ( - createTaskResponse.statusCode < 200 || - createTaskResponse.statusCode >= 300 - ) { - sendJson(res, createTaskResponse.statusCode, { - error: { - message: extractApiErrorMessage( - createTaskResponse.bodyText, - '创建动作序列帧任务失败。', - ), - }, - }); - return; - } - - const taskPayload = JSON.parse(createTaskResponse.bodyText) as Record< - string, - unknown - >; - const taskId = extractTaskId(taskPayload); - activeTaskId = taskId; - - if (!taskId) { - throw new Error('动作序列帧任务未返回 task_id。'); - } - - const createdAt = new Date().toISOString(); - await writeJobRecord(rootDir, 'animation', taskId, { - taskId, - kind: 'animation', - status: 'running', - characterId, - animation, - strategy, - model: imageSequenceModel, - prompt: finalPrompt, - createdAt, - updatedAt: createdAt, - }); - - const taskResult = await waitForDashScopeTask(baseUrl, apiKey, taskId, { - timeoutMs: - Number.isFinite(dashScopeTimeoutMs) && dashScopeTimeoutMs > 0 - ? dashScopeTimeoutMs - : DASHSCOPE_IMAGE_TASK_TIMEOUT_MS, - intervalMs: DASHSCOPE_IMAGE_TASK_POLL_INTERVAL_MS, - }); - const imageUrls = extractImageUrls(taskResult).slice(0, frameCount); - - if (imageUrls.length === 0) { - throw new Error('动作序列帧生成成功,但没有返回图片。'); - } - - const jobId = createTimestampId('animation-seq'); - const draftRelativeDir = path.posix.join( - 'generated-character-drafts', - sanitizePathSegment(characterId), - 'animation', - sanitizePathSegment(animation), - jobId, - ); - const imageSources = await Promise.all( - imageUrls.map(async (imageUrl, index) => { - const imageResponse = await requestBinaryResponse(imageUrl); - - if ( - imageResponse.statusCode < 200 || - imageResponse.statusCode >= 300 - ) { - throw new Error(`下载动作帧失败(${imageResponse.statusCode})。`); - } - - return writeDraftBinaryFile( - rootDir, - path.posix.join( - draftRelativeDir, - `frame-${String(index + 1).padStart(2, '0')}.png`, - ), - imageResponse.body, - ); - }), - ); - - await writeFile( - path.resolve( - rootDir, - 'public', - ...draftRelativeDir.split('/'), - 'job.json', - ), - JSON.stringify( - { - taskId, - model: imageSequenceModel, - strategy, - animation, - prompt: finalPrompt, - createdAt: new Date().toISOString(), - imageUrls, - }, - null, - 2, - ) + '\n', - 'utf8', - ); - - await writeJobRecord(rootDir, 'animation', taskId, { - taskId, - kind: 'animation', - status: 'completed', - characterId, - animation, - strategy, - model: imageSequenceModel, - prompt: finalPrompt, - createdAt, - updatedAt: new Date().toISOString(), - result: { - imageSources, - draftRelativeDir, - }, - }); - - sendJson(res, 200, { - ok: true, - taskId, - strategy: 'image-sequence', - model: imageSequenceModel, - prompt: finalPrompt, - imageSources, - }); - return; - } - - if (strategy === 'image-to-video') { - const finalPrompt = buildArkCharacterAnimationPrompt({ - animation, - promptText, - useChromaKey, - loop, - characterBriefText, - actionTemplateId, - }); - const fallbackPrompt = buildFallbackModerationSafeAnimationPrompt({ - animation, - loop, - useChromaKey, - }); - activePrompt = finalPrompt; - activeModel = videoModel; - const visualInputRef = await resolveCharacterVisualAsDataUrl( - rootDir, - visualSource, - ); - const resolvedLastFrameSource = lastFrameImageDataUrl || visualSource; - const lastFrameRef = await resolveCharacterVisualAsDataUrl( - rootDir, - resolvedLastFrameSource, - ); - const createVideoRequestBody = (prompt: string) => ({ - model: videoModel, - content: [ - { - type: 'text', - text: prompt, - }, - { - type: 'image_url', - image_url: { - url: visualInputRef, - }, - role: 'first_frame', - }, - { - type: 'image_url', - image_url: { - url: lastFrameRef, - }, - role: 'last_frame', - }, - ], - resolution: normalizedArkResolution, - ratio: normalizedArkRatio, - duration: FIXED_ARK_CHARACTER_VIDEO_DURATION_SECONDS, - watermark: false, - }); - - const { response: createTaskResponse, prompt: submittedPrompt } = - await proxyJsonRequestWithPromptFallback({ - urlString: `${arkBaseUrl}/contents/generations/tasks`, - apiKey: arkApiKey, - buildBody: createVideoRequestBody, - primaryPrompt: finalPrompt, - fallbackPrompt, - }); - - activePrompt = submittedPrompt; - - if ( - createTaskResponse.statusCode < 200 || - createTaskResponse.statusCode >= 300 - ) { - sendJson(res, createTaskResponse.statusCode, { - error: { - message: extractApiErrorMessage( - createTaskResponse.bodyText, - '创建图生视频任务失败。', - ), - }, - }); - return; - } - - const taskPayload = JSON.parse(createTaskResponse.bodyText) as Record< - string, - unknown - >; - const taskId = extractTaskId(taskPayload); - activeTaskId = taskId; - - if (!taskId) { - throw new Error('角色动作视频任务未返回 id。'); - } - - const createdAt = new Date().toISOString(); - await writeJobRecord(rootDir, 'animation', taskId, { - taskId, - kind: 'animation', - status: 'running', - characterId, - animation, - strategy, - model: videoModel, - prompt: submittedPrompt, - createdAt, - updatedAt: createdAt, - }); - - const taskResult = await waitForArkContentGenerationTask( - arkBaseUrl, - arkApiKey, - taskId, - { - timeoutMs: - Number.isFinite(arkTimeoutMs) && arkTimeoutMs > 0 - ? arkTimeoutMs - : DASHSCOPE_VIDEO_TASK_TIMEOUT_MS, - intervalMs: ARK_VIDEO_TASK_POLL_INTERVAL_MS, - }, - ); - const videoUrl = extractVideoUrl(taskResult); - - if (!videoUrl) { - throw new Error('图生视频成功,但没有返回视频链接。'); - } - - const videoResponse = await requestBinaryResponse(videoUrl); - if (videoResponse.statusCode < 200 || videoResponse.statusCode >= 300) { - throw new Error(`下载图生视频失败(${videoResponse.statusCode})。`); - } - - const jobId = createTimestampId('animation-video'); - const draftRelativeDir = path.posix.join( - 'generated-character-drafts', - sanitizePathSegment(characterId), - 'animation', - sanitizePathSegment(animation), - jobId, - ); - const previewVideoPath = await writeDraftBinaryFile( - rootDir, - path.posix.join(draftRelativeDir, 'preview.mp4'), - videoResponse.body, - ); - - await writeFile( - path.resolve( - rootDir, - 'public', - ...draftRelativeDir.split('/'), - 'job.json', - ), - JSON.stringify( - { - taskId, - model: videoModel, - strategy, - animation, - prompt: submittedPrompt, - createdAt: new Date().toISOString(), - videoUrl, - }, - null, - 2, - ) + '\n', - 'utf8', - ); - - await writeJobRecord(rootDir, 'animation', taskId, { - taskId, - kind: 'animation', - status: 'completed', - characterId, - animation, - strategy, - model: videoModel, - prompt: submittedPrompt, - createdAt, - updatedAt: new Date().toISOString(), - result: { - previewVideoPath, - draftRelativeDir, - }, - }); - - sendJson(res, 200, { - ok: true, - taskId, - strategy: 'image-to-video', - model: videoModel, - prompt: submittedPrompt, - previewVideoPath, - }); - return; - } - - const modelForVisualUpload = - strategy === 'reference-to-video' - ? referenceVideoModel - : strategy === 'motion-transfer' - ? motionTransferModel - : videoModel; - const visualUrl = await uploadFileToDashScope( - baseUrl, - apiKey, - modelForVisualUpload, - `${characterId}-${animation}-visual`, - await resolveMediaSourcePayload(rootDir, visualSource), - ); - - if (strategy === 'motion-transfer') { - if (referenceVideoDataUrls.length === 0) { - sendJson(res, 400, { - error: { message: '动作模板驱动至少需要一段参考视频。' }, - }); - return; - } - - const finalPrompt = buildNpcAnimationPrompt({ - animation, - promptText, - useChromaKey, - loop, - characterBriefText, - }); - activePrompt = finalPrompt; - activeModel = motionTransferModel; - const referenceVideoUrl = await uploadFileToDashScope( - baseUrl, - apiKey, - motionTransferModel, - `${characterId}-${animation}-reference-video`, - await resolveMediaSourcePayload(rootDir, referenceVideoDataUrls[0]), - ); - const createTaskResponse = await proxyJsonRequest( - `${baseUrl}/services/aigc/image2video/video-synthesis`, - apiKey, - { - model: motionTransferModel, - input: { - prompt: finalPrompt, - image_url: visualUrl, - video_url: referenceVideoUrl, - watermark: false, - }, - parameters: { - mode: 'wan-std', - }, - }, - { - 'X-DashScope-Async': 'enable', - 'X-DashScope-OssResourceResolve': 'enable', - }, - ); - - if ( - createTaskResponse.statusCode < 200 || - createTaskResponse.statusCode >= 300 - ) { - sendJson(res, createTaskResponse.statusCode, { - error: { - message: extractApiErrorMessage( - createTaskResponse.bodyText, - '创建动作模板迁移任务失败。', - ), - }, - }); - return; - } - - const taskPayload = JSON.parse(createTaskResponse.bodyText) as Record< - string, - unknown - >; - const taskId = extractTaskId(taskPayload); - activeTaskId = taskId; - - if (!taskId) { - throw new Error('动作模板迁移任务未返回 task_id。'); - } - - const createdAt = new Date().toISOString(); - await writeJobRecord(rootDir, 'animation', taskId, { - taskId, - kind: 'animation', - status: 'running', - characterId, - animation, - strategy, - model: motionTransferModel, - prompt: finalPrompt, - createdAt, - updatedAt: createdAt, - }); - - const taskResult = await waitForDashScopeTask(baseUrl, apiKey, taskId, { - timeoutMs: - Number.isFinite(dashScopeTimeoutMs) && dashScopeTimeoutMs > 0 - ? dashScopeTimeoutMs - : DASHSCOPE_VIDEO_TASK_TIMEOUT_MS, - intervalMs: DASHSCOPE_VIDEO_TASK_POLL_INTERVAL_MS, - }); - const videoUrl = extractVideoUrl(taskResult); - - if (!videoUrl) { - throw new Error('动作模板迁移成功,但没有返回视频链接。'); - } - - const videoResponse = await requestBinaryResponse(videoUrl); - if (videoResponse.statusCode < 200 || videoResponse.statusCode >= 300) { - throw new Error( - `下载动作模板视频失败(${videoResponse.statusCode})。`, - ); - } - - const jobId = createTimestampId('animation-motion'); - const draftRelativeDir = path.posix.join( - 'generated-character-drafts', - sanitizePathSegment(characterId), - 'animation', - sanitizePathSegment(animation), - jobId, - ); - const previewVideoPath = await writeDraftBinaryFile( - rootDir, - path.posix.join(draftRelativeDir, 'preview.mp4'), - videoResponse.body, - ); - - await writeFile( - path.resolve( - rootDir, - 'public', - ...draftRelativeDir.split('/'), - 'job.json', - ), - JSON.stringify( - { - taskId, - model: motionTransferModel, - strategy, - animation, - prompt: finalPrompt, - createdAt: new Date().toISOString(), - videoUrl, - }, - null, - 2, - ) + '\n', - 'utf8', - ); - - await writeJobRecord(rootDir, 'animation', taskId, { - taskId, - kind: 'animation', - status: 'completed', - characterId, - animation, - strategy, - model: motionTransferModel, - prompt: finalPrompt, - createdAt, - updatedAt: new Date().toISOString(), - result: { - previewVideoPath, - draftRelativeDir, - }, - }); - - sendJson(res, 200, { - ok: true, - taskId, - strategy: 'motion-transfer', - model: motionTransferModel, - prompt: finalPrompt, - previewVideoPath, - }); - return; - } - - if (strategy === 'reference-to-video') { - const uploadedReferenceImages = await Promise.all( - referenceImageDataUrls.map(async (source, index) => - uploadFileToDashScope( - baseUrl, - apiKey, - referenceVideoModel, - `${characterId}-${animation}-reference-image-${index + 1}`, - await resolveMediaSourcePayload(rootDir, source), - ), - ), - ); - const uploadedReferenceVideos = await Promise.all( - referenceVideoDataUrls.map(async (source, index) => - uploadFileToDashScope( - baseUrl, - apiKey, - referenceVideoModel, - `${characterId}-${animation}-reference-video-${index + 1}`, - await resolveMediaSourcePayload(rootDir, source), - ), - ), - ); - - if ( - !visualUrl && - uploadedReferenceImages.length === 0 && - uploadedReferenceVideos.length === 0 - ) { - sendJson(res, 400, { - error: { message: '参考生视频至少需要一张参考图或一段参考视频。' }, - }); - return; - } - - const finalPrompt = buildNpcAnimationPrompt({ - animation, - promptText, - useChromaKey, - loop, - characterBriefText, - }); - activePrompt = finalPrompt; - activeModel = referenceVideoModel; - const createTaskResponse = await proxyJsonRequest( - `${baseUrl}/services/aigc/video-generation/video-synthesis`, - apiKey, - { - model: referenceVideoModel, - input: { - prompt: finalPrompt, - media: [ - { type: 'reference_image', url: visualUrl }, - ...uploadedReferenceImages.map((url) => ({ - type: 'reference_image' as const, - url, - })), - ...uploadedReferenceVideos.map((url) => ({ - type: 'reference_video' as const, - url, - })), - ], - }, - parameters: { - duration: durationSeconds, - resolution: getLowestSupportedVideoResolution( - referenceVideoModel, - resolution, - ), - prompt_optimizer: true, - }, - }, - { - 'X-DashScope-Async': 'enable', - 'X-DashScope-OssResourceResolve': 'enable', - }, - ); - - if ( - createTaskResponse.statusCode < 200 || - createTaskResponse.statusCode >= 300 - ) { - sendJson(res, createTaskResponse.statusCode, { - error: { - message: extractApiErrorMessage( - createTaskResponse.bodyText, - '创建参考生视频任务失败。', - ), - }, - }); - return; - } - - const taskPayload = JSON.parse(createTaskResponse.bodyText) as Record< - string, - unknown - >; - const taskId = extractTaskId(taskPayload); - activeTaskId = taskId; - - if (!taskId) { - throw new Error('参考生视频任务未返回 task_id。'); - } - - const createdAt = new Date().toISOString(); - await writeJobRecord(rootDir, 'animation', taskId, { - taskId, - kind: 'animation', - status: 'running', - characterId, - animation, - strategy, - model: referenceVideoModel, - prompt: finalPrompt, - createdAt, - updatedAt: createdAt, - }); - - const taskResult = await waitForDashScopeTask(baseUrl, apiKey, taskId, { - timeoutMs: - Number.isFinite(dashScopeTimeoutMs) && dashScopeTimeoutMs > 0 - ? dashScopeTimeoutMs - : DASHSCOPE_VIDEO_TASK_TIMEOUT_MS, - intervalMs: DASHSCOPE_VIDEO_TASK_POLL_INTERVAL_MS, - }); - const videoUrl = extractVideoUrl(taskResult); - - if (!videoUrl) { - throw new Error('参考生视频成功,但没有返回视频链接。'); - } - - const videoResponse = await requestBinaryResponse(videoUrl); - if (videoResponse.statusCode < 200 || videoResponse.statusCode >= 300) { - throw new Error(`下载参考生视频失败(${videoResponse.statusCode})。`); - } - - const jobId = createTimestampId('animation-reference'); - const draftRelativeDir = path.posix.join( - 'generated-character-drafts', - sanitizePathSegment(characterId), - 'animation', - sanitizePathSegment(animation), - jobId, - ); - const previewVideoPath = await writeDraftBinaryFile( - rootDir, - path.posix.join(draftRelativeDir, 'preview.mp4'), - videoResponse.body, - ); - - await writeFile( - path.resolve( - rootDir, - 'public', - ...draftRelativeDir.split('/'), - 'job.json', - ), - JSON.stringify( - { - taskId, - model: referenceVideoModel, - strategy, - animation, - prompt: finalPrompt, - createdAt: new Date().toISOString(), - videoUrl, - }, - null, - 2, - ) + '\n', - 'utf8', - ); - - await writeJobRecord(rootDir, 'animation', taskId, { - taskId, - kind: 'animation', - status: 'completed', - characterId, - animation, - strategy, - model: referenceVideoModel, - prompt: finalPrompt, - createdAt, - updatedAt: new Date().toISOString(), - result: { - previewVideoPath, - draftRelativeDir, - }, - }); - - sendJson(res, 200, { - ok: true, - taskId, - strategy: 'reference-to-video', - model: referenceVideoModel, - prompt: finalPrompt, - previewVideoPath, - }); - return; - } - - sendJson(res, 400, { - error: { message: `不支持的动作生成策略:${strategy || 'unknown'}` }, - }); - } catch (error) { - if (activeTaskId) { - await writeJobRecord(rootDir, 'animation', activeTaskId, { - taskId: activeTaskId, - kind: 'animation', - status: 'failed', - characterId, - animation, - strategy, - model: activeModel, - prompt: activePrompt, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - errorMessage: - error instanceof Error ? error.message : '生成角色动作失败。', - }); - } - sendJson(res, 500, { - error: { - message: error instanceof Error ? error.message : '生成角色动作失败。', - }, - }); - } -} - -async function handleReadCharacterJobStatus( - rootDir: string, - req: IncomingMessage & { originalUrl?: string }, - res: ServerResponse, - kind: 'visual' | 'animation', -) { - if (req.method !== 'GET') { - sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); - return; - } - - const pathname = getRequestPathname(req); - const prefix = - kind === 'visual' - ? CHARACTER_VISUAL_JOBS_PATH - : CHARACTER_ANIMATION_JOBS_PATH; - const taskId = decodeURIComponent(pathname.slice(prefix.length)).trim(); - - if (!taskId) { - sendJson(res, 400, { error: { message: 'taskId is required.' } }); - return; - } - - try { - const record = await readJobRecord(rootDir, kind, taskId); - sendJson(res, 200, record); - } catch (error) { - sendJson(res, 404, { - error: { - message: - error instanceof Error ? error.message : '未找到对应的任务记录。', - }, - }); - } -} - -async function handleImportCharacterAnimationVideo( - rootDir: string, - req: IncomingMessage & { body?: unknown }, - res: ServerResponse, -) { - if (req.method !== 'POST') { - sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); - return; - } - - let body: Record; - try { - body = await readJsonBody(req); - } catch { - sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); - return; - } - - const characterId = - typeof body.characterId === 'string' - ? body.characterId.trim() - : 'character'; - const animation = - typeof body.animation === 'string' ? body.animation.trim() : 'clip'; - const videoSource = - typeof body.videoSource === 'string' ? body.videoSource.trim() : ''; - const sourceLabel = - typeof body.sourceLabel === 'string' && body.sourceLabel.trim() - ? body.sourceLabel.trim() - : 'imported-video'; - - if (!videoSource) { - sendJson(res, 400, { error: { message: 'videoSource is required.' } }); - return; - } - - try { - const payload = await resolveMediaSourcePayload(rootDir, videoSource); - const draftId = createTimestampId('animation-import'); - const relativeDir = path.posix.join( - 'generated-character-drafts', - sanitizePathSegment(characterId), - 'animation', - sanitizePathSegment(animation), - draftId, - ); - const fileName = `${sanitizePathSegment(sourceLabel)}.${payload.extension}`; - const importedVideoPath = await writeDraftBinaryFile( - rootDir, - path.posix.join(relativeDir, fileName), - payload.buffer, - ); - - await writeFile( - path.resolve(rootDir, 'public', ...relativeDir.split('/'), 'import.json'), - JSON.stringify( - { - characterId, - animation, - sourceLabel, - importedVideoPath, - createdAt: new Date().toISOString(), - }, - null, - 2, - ) + '\n', - 'utf8', - ); - - sendJson(res, 200, { - ok: true, - importedVideoPath, - draftId, - saveMessage: '参考视频已导入到本地草稿目录。', - }); - } catch (error) { - sendJson(res, 500, { - error: { - message: error instanceof Error ? error.message : '导入动作视频失败。', - }, - }); - } -} - -function handleListAnimationTemplates( - _config: AppConfig, - req: IncomingMessage, - res: ServerResponse, -) { - if (req.method !== 'GET') { - sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); - return; - } - - sendJson(res, 200, { - ok: true, - templates: BUILT_IN_MOTION_TEMPLATES, - }); -} - -async function handleGetCharacterWorkflowCache( - config: AppConfig, - req: IncomingMessage & { originalUrl?: string }, - res: ServerResponse, -) { - if (req.method !== 'GET') { - sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); - return; - } - - const rawUrl = req.originalUrl ?? req.url ?? ''; - const characterId = decodeURIComponent( - rawUrl.slice(rawUrl.lastIndexOf('/') + 1), - ).trim(); - - if (!characterId) { - sendJson(res, 400, { error: { message: 'characterId is required.' } }); - return; - } - - try { - const cache = (await readJsonObjectFile( - getCharacterWorkflowCachePath(config.projectRoot, characterId), - )) as CharacterAssetWorkflowCacheRecord | Record; - - sendJson(res, 200, { - ok: true, - cache: - isRecordValue(cache) && - typeof cache.characterId === 'string' && - cache.characterId === characterId - ? cache - : null, - }); - } catch (error) { - sendJson(res, 500, { - error: { - message: - error instanceof Error ? error.message : '读取角色形象生成缓存失败。', - }, - }); - } -} - -async function handleSaveCharacterWorkflowCache( - config: AppConfig, - req: IncomingMessage & { body?: unknown }, - res: ServerResponse, -) { - if (req.method !== 'POST') { - sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); - return; - } - - let body: Record; - try { - body = await readJsonBody(req); - } catch { - sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); - return; - } - - const characterId = - typeof body.characterId === 'string' ? body.characterId.trim() : ''; - if (!characterId) { - sendJson(res, 400, { error: { message: 'characterId is required.' } }); - return; - } - - const visualDrafts = Array.isArray(body.visualDrafts) - ? body.visualDrafts - .map((item, index) => { - if (!isRecordValue(item)) { - return null; - } - - const imageSrc = - typeof item.imageSrc === 'string' ? item.imageSrc.trim() : ''; - if (!imageSrc) { - return null; - } - - const id = - typeof item.id === 'string' && item.id.trim() - ? item.id.trim() - : `${characterId}-draft-${index + 1}`; - const label = - typeof item.label === 'string' && item.label.trim() - ? item.label.trim() - : `候选 ${index + 1}`; - - return { - id, - label, - imageSrc, - width: - typeof item.width === 'number' && Number.isFinite(item.width) - ? item.width - : 1024, - height: - typeof item.height === 'number' && Number.isFinite(item.height) - ? item.height - : 1536, - }; - }) - .filter( - ( - item, - ): item is CharacterAssetWorkflowCacheRecord['visualDrafts'][number] => - Boolean(item), - ) - : []; - - const cacheFilePath = getCharacterWorkflowCachePath( - config.projectRoot, - characterId, - ); - const payloadBase = { - characterId, - visualPromptText: clampPromptSeedText(body.visualPromptText, 280), - animationPromptText: clampPromptSeedText(body.animationPromptText, 280), - visualDrafts, - selectedVisualDraftId: - typeof body.selectedVisualDraftId === 'string' - ? body.selectedVisualDraftId.trim() - : '', - selectedAnimation: - typeof body.selectedAnimation === 'string' - ? body.selectedAnimation.trim() - : 'idle', - imageSrc: - typeof body.imageSrc === 'string' && body.imageSrc.trim() - ? body.imageSrc.trim() - : undefined, - generatedVisualAssetId: - typeof body.generatedVisualAssetId === 'string' && - body.generatedVisualAssetId.trim() - ? body.generatedVisualAssetId.trim() - : undefined, - generatedAnimationSetId: - typeof body.generatedAnimationSetId === 'string' && - body.generatedAnimationSetId.trim() - ? body.generatedAnimationSetId.trim() - : undefined, - animationMap: isRecordValue(body.animationMap) ? body.animationMap : null, - }; - - try { - const existingCache = (await readJsonObjectFile(cacheFilePath)) as - | CharacterAssetWorkflowCacheRecord - | Record; - const comparablePayload = - serializeWorkflowCacheComparableValue(payloadBase); - const comparableExisting = - serializeWorkflowCacheComparableValue(existingCache); - - if ( - isRecordValue(existingCache) && - typeof existingCache.characterId === 'string' && - comparableExisting === comparablePayload - ) { - sendJson(res, 200, { - ok: true, - cache: existingCache, - saveMessage: '角色形象生成缓存无变化。', - }); - return; - } - - const payload: CharacterAssetWorkflowCacheRecord = { - ...payloadBase, - updatedAt: new Date().toISOString(), - }; - - await writeJsonObjectFile( - cacheFilePath, - payload as unknown as Record, - ); - - sendJson(res, 200, { - ok: true, - cache: payload, - saveMessage: '角色形象生成缓存已更新。', - }); - } catch (error) { - sendJson(res, 500, { - error: { - message: - error instanceof Error ? error.message : '保存角色形象生成缓存失败。', - }, - }); - } -} - -async function handlePublishCharacterVisual( - config: AppConfig, - req: IncomingMessage & { body?: unknown }, - res: ServerResponse, -) { - if (req.method !== 'POST') { - sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); - return; - } - - let body: Record; - try { - body = await readJsonBody(req); - } catch { - sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); - return; - } - - const rootDir = config.projectRoot; - const characterOverridesFilePath = path.resolve( - rootDir, - 'src/data/characterOverrides.json', - ); - const characterId = - typeof body.characterId === 'string' ? body.characterId.trim() : ''; - const sourceMode = - typeof body.sourceMode === 'string' ? body.sourceMode.trim() : 'upload'; - const promptText = - typeof body.promptText === 'string' && body.promptText.trim() - ? body.promptText.trim() - : undefined; - const selectedPreviewSource = - typeof body.selectedPreviewSource === 'string' - ? body.selectedPreviewSource - : ''; - const previewSources = isStringArray(body.previewSources) - ? body.previewSources - : []; - const width = - typeof body.width === 'number' && Number.isFinite(body.width) - ? body.width - : 1024; - const height = - typeof body.height === 'number' && Number.isFinite(body.height) - ? body.height - : 1024; - const updateCharacterOverride = body.updateCharacterOverride !== false; - - if (!characterId) { - sendJson(res, 400, { error: { message: 'characterId is required.' } }); - return; - } - - if (!selectedPreviewSource) { - sendJson(res, 400, { - error: { message: 'selectedPreviewSource is required.' }, - }); - return; - } - - try { - const assetId = createTimestampId('visual'); - const visualDir = path.resolve( - rootDir, - 'public/generated-characters', - sanitizePathSegment(characterId), - 'visual', - assetId, - ); - await mkdir(visualDir, { recursive: true }); - - const masterPayload = await resolveCharacterVisualPayload( - rootDir, - selectedPreviewSource, - ); - const masterFileName = `master.${masterPayload.extension}`; - await writeFile(path.join(visualDir, masterFileName), masterPayload.buffer); - - const previewImagePaths: string[] = []; - for (let index = 0; index < previewSources.length; index += 1) { - const previewPayload = await resolveCharacterVisualPayload( - rootDir, - previewSources[index] ?? '', - ); - const previewFileName = `preview-${index + 1}.${previewPayload.extension}`; - await writeFile( - path.join(visualDir, previewFileName), - previewPayload.buffer, - ); - previewImagePaths.push( - `/generated-characters/${sanitizePathSegment(characterId)}/visual/${assetId}/${previewFileName}`, - ); - } - - const masterImagePath = `/generated-characters/${sanitizePathSegment(characterId)}/visual/${assetId}/${masterFileName}`; - const manifest: PublishedVisualManifest = { - id: assetId, - characterId, - sourceMode, - promptText, - masterImagePath, - previewImagePaths, - width, - height, - facing: 'right', - locked: true, - }; - - await writeFile( - path.join(visualDir, 'visual-manifest.json'), - JSON.stringify(manifest, null, 2) + '\n', - 'utf8', - ); - - let overrideMap: Record = {}; - if (updateCharacterOverride) { - overrideMap = await readJsonObjectFile(characterOverridesFilePath); - const existingOverride = overrideMap[characterId]; - const nextOverride = isRecordValue(existingOverride) - ? { ...existingOverride } - : {}; - nextOverride.generatedVisualAssetId = assetId; - nextOverride.portrait = masterImagePath; - overrideMap[characterId] = nextOverride; - await writeJsonObjectFile(characterOverridesFilePath, overrideMap); - } - - sendJson(res, 200, { - ok: true, - assetId, - portraitPath: masterImagePath, - overrideMap, - saveMessage: updateCharacterOverride - ? '主形象已发布到 public/generated-characters,并更新角色覆盖。' - : '主形象已保存到 public/generated-characters,可直接写回当前自定义世界角色。', - }); - } catch (error) { - sendJson(res, 500, { - error: { - message: - error instanceof Error ? error.message : '发布角色主形象失败。', - }, - }); - } -} - -async function handlePublishCharacterAnimation( - config: AppConfig, - req: IncomingMessage & { body?: unknown }, - res: ServerResponse, -) { - if (req.method !== 'POST') { - sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); - return; - } - - let body: Record; - try { - body = await readJsonBody(req); - } catch { - sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); - return; - } - - const rootDir = config.projectRoot; - const characterOverridesFilePath = path.resolve( - rootDir, - 'src/data/characterOverrides.json', - ); - const characterId = - typeof body.characterId === 'string' ? body.characterId.trim() : ''; - const visualAssetId = - typeof body.visualAssetId === 'string' ? body.visualAssetId.trim() : ''; - const animations = isRecordValue(body.animations) ? body.animations : null; - const updateCharacterOverride = body.updateCharacterOverride !== false; - - if (!characterId) { - sendJson(res, 400, { error: { message: 'characterId is required.' } }); - return; - } - - if (!visualAssetId) { - sendJson(res, 400, { error: { message: 'visualAssetId is required.' } }); - return; - } - - if (!animations || Object.keys(animations).length === 0) { - sendJson(res, 400, { error: { message: 'animations is required.' } }); - return; - } - - try { - const animationSetId = createTimestampId('animation-set'); - const baseAnimationDir = path.resolve( - rootDir, - 'public/generated-animations', - sanitizePathSegment(characterId), - animationSetId, - ); - await mkdir(baseAnimationDir, { recursive: true }); - - const actionManifests: PublishedAnimationManifest[] = []; - const nextAnimationMap: Record> = {}; - - for (const [action, rawAnimation] of Object.entries(animations)) { - if (!isRecordValue(rawAnimation)) { - continue; - } - - const framesDataUrls = isStringArray(rawAnimation.framesDataUrls) - ? rawAnimation.framesDataUrls - : []; - if (framesDataUrls.length === 0) { - continue; - } - - const fps = - typeof rawAnimation.fps === 'number' && - Number.isFinite(rawAnimation.fps) - ? rawAnimation.fps - : 8; - const loop = rawAnimation.loop === true; - const frameWidth = - typeof rawAnimation.frameWidth === 'number' && - Number.isFinite(rawAnimation.frameWidth) - ? rawAnimation.frameWidth - : 192; - const frameHeight = - typeof rawAnimation.frameHeight === 'number' && - Number.isFinite(rawAnimation.frameHeight) - ? rawAnimation.frameHeight - : 256; - const actionKey = sanitizePathSegment(action); - const actionDir = path.join(baseAnimationDir, actionKey); - await mkdir(actionDir, { recursive: true }); - - const framePaths: string[] = []; - let frameExtension = 'png'; - for (let index = 0; index < framesDataUrls.length; index += 1) { - const framePayload = await resolveMediaSourcePayload( - rootDir, - framesDataUrls[index] ?? '', - ); - frameExtension = framePayload.extension; - const frameFileName = `frame${String(index + 1).padStart(2, '0')}.${framePayload.extension}`; - await writeFile( - path.join(actionDir, frameFileName), - framePayload.buffer, - ); - framePaths.push( - `/generated-animations/${sanitizePathSegment(characterId)}/${animationSetId}/${actionKey}/${frameFileName}`, - ); - } - - const basePath = `/generated-animations/${sanitizePathSegment(characterId)}/${animationSetId}/${actionKey}`; - const previewVideoPath = - typeof rawAnimation.previewVideoPath === 'string' && - rawAnimation.previewVideoPath.trim() - ? rawAnimation.previewVideoPath.trim() - : undefined; - const manifest: PublishedAnimationManifest = { - id: `${animationSetId}-${actionKey}`, - animationSetId, - characterId, - visualAssetId, - action, - frameCount: framePaths.length, - fps, - loop, - frameWidth, - frameHeight, - previewVideoPath, - framePaths, - }; - - await writeFile( - path.join(actionDir, 'manifest.json'), - JSON.stringify(manifest, null, 2) + '\n', - 'utf8', - ); - - actionManifests.push(manifest); - nextAnimationMap[action] = { - folder: action, - prefix: 'frame', - frames: framePaths.length, - startFrame: 1, - extension: frameExtension, - basePath, - frameWidth, - frameHeight, - fps, - loop, - ...(previewVideoPath ? { previewVideoPath } : {}), - }; - } - - await writeFile( - path.join(baseAnimationDir, 'manifest.json'), - JSON.stringify( - { - animationSetId, - characterId, - visualAssetId, - actions: actionManifests, - }, - null, - 2, - ) + '\n', - 'utf8', - ); - - let overrideMap: Record = {}; - if (updateCharacterOverride) { - overrideMap = await readJsonObjectFile(characterOverridesFilePath); - const existingOverride = overrideMap[characterId]; - const nextOverride = isRecordValue(existingOverride) - ? { ...existingOverride } - : {}; - const existingAnimationMap = isRecordValue(nextOverride.animationMap) - ? nextOverride.animationMap - : {}; - nextOverride.generatedAnimationSetId = animationSetId; - nextOverride.generatedVisualAssetId = visualAssetId; - nextOverride.animationMap = { - ...existingAnimationMap, - ...nextAnimationMap, - }; - overrideMap[characterId] = nextOverride; - await writeJsonObjectFile(characterOverridesFilePath, overrideMap); - } - - sendJson(res, 200, { - ok: true, - animationSetId, - overrideMap, - animationMap: nextAnimationMap, - saveMessage: updateCharacterOverride - ? '基础动作资源已发布到 public/generated-animations,并更新角色覆盖。' - : '基础动作资源已保存到 public/generated-animations,可直接写回当前自定义世界角色。', - }); - } catch (error) { - sendJson(res, 500, { - error: { - message: - error instanceof Error ? error.message : '发布角色基础动作失败。', - }, - }); - } -} - -function toExpressHandler( - handler: ( - request: IncomingMessage & { body?: unknown; originalUrl?: string }, - response: ServerResponse, - ) => Promise | void, -) { - return (request: Request, response: Response, next: NextFunction) => { - Promise.resolve( - handler( - request as Request & - IncomingMessage & { body?: unknown; originalUrl?: string }, - response as Response & ServerResponse, - ), - ).catch(next); - }; -} - -export function createCharacterAssetRoutes( - config: AppConfig, - llmClient?: UpstreamLlmClient | null, -) { - 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.post( - CHARACTER_WORKFLOW_CACHE_PATH, - routeMeta({ operation: 'assets.character.workflowCache.save' }), - toExpressHandler((request, response) => { - return handleSaveCharacterWorkflowCache(config, request, response); - }), - ); - router.get( - CHARACTER_WORKFLOW_CACHE_DETAIL_PATH, - routeMeta({ operation: 'assets.character.workflowCache.get' }), - toExpressHandler((request, response) => - handleGetCharacterWorkflowCache(config, request, response), - ), - ); - router.post( - CHARACTER_VISUAL_GENERATE_PATH, - routeMeta({ operation: 'assets.character.visual.generate' }), - toExpressHandler((request, response) => - handleGenerateCharacterVisuals(config, request, response), - ), - ); - router.post( - CHARACTER_VISUAL_PUBLISH_PATH, - routeMeta({ operation: 'assets.character.visual.publish' }), - toExpressHandler((request, response) => - handlePublishCharacterVisual(config, request, response), - ), - ); - router.get( - CHARACTER_VISUAL_JOB_DETAIL_PATH, - routeMeta({ operation: 'assets.character.visual.job.get' }), - toExpressHandler((request, response) => - handleReadCharacterJobStatus( - config.projectRoot, - request, - response, - 'visual', - ), - ), - ); - router.post( - CHARACTER_ANIMATION_GENERATE_PATH, - routeMeta({ operation: 'assets.character.animation.generate' }), - toExpressHandler((request, response) => - handleGenerateCharacterAnimation(config, request, response), - ), - ); - router.post( - CHARACTER_ANIMATION_PUBLISH_PATH, - routeMeta({ operation: 'assets.character.animation.publish' }), - toExpressHandler((request, response) => - handlePublishCharacterAnimation(config, request, response), - ), - ); - router.get( - CHARACTER_ANIMATION_JOB_DETAIL_PATH, - routeMeta({ operation: 'assets.character.animation.job.get' }), - toExpressHandler((request, response) => - handleReadCharacterJobStatus( - config.projectRoot, - request, - response, - 'animation', - ), - ), - ); - router.post( - CHARACTER_ANIMATION_IMPORT_VIDEO_PATH, - routeMeta({ operation: 'assets.character.animation.importVideo' }), - toExpressHandler((request, response) => - handleImportCharacterAnimationVideo( - config.projectRoot, - request, - response, - ), - ), - ); - router.get( - CHARACTER_ANIMATION_TEMPLATES_PATH, - routeMeta({ operation: 'assets.character.animation.templates.list' }), - toExpressHandler((request, response) => - handleListAnimationTemplates(config, request, response), - ), - ); - - return router; -} diff --git a/server-node/src/modules/combat/combatResolutionService.ts b/server-node/src/modules/combat/combatResolutionService.ts deleted file mode 100644 index a2117843..00000000 --- a/server-node/src/modules/combat/combatResolutionService.ts +++ /dev/null @@ -1,635 +0,0 @@ -import type { - RuntimeBattlePresentation, - RuntimeStoryPatch, -} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js'; -import type { - RuntimeStoryChoicePayload, -} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryAction.js'; -import { - buildInventoryUseResultText, - incrementGameRuntimeStats, - isInventoryItemUsable, - removeInventoryItem, - resolveInventoryItemUseEffect, -} from '../../bridges/legacyInventoryRuntimeBridge.js'; -import { conflict } from '../../errors.js'; -import { - buildExperienceGrantResultText, - grantPlayerExperience, -} from '../progression/playerProgressionService.js'; -import { resolveHostileBattleProfile } from '../progression/hostileProgressionService.js'; -import { - appendBuildBuffs, - resolvePlayerOutgoingDamageResult, -} from '../runtime/runtimeBuildModule.js'; -import { - getEncounterNpcState, - getPlayerCharacter, - getPlayerSkillCooldowns, - setEncounterNpcState, - type RuntimeSession, -} from '../rpg-runtime-story/RpgRuntimeSessionPrimitives.js'; - -type CombatActionConfig = { - actionText: string; - manaCost: number; - baseDamage: number; - counterMultiplier: number; - heal?: number; - manaRestore?: number; - cooldownBonus?: number; - selectedSkillId?: string | null; - appliedCooldownTurns?: number; - buildBuffs?: Array<{ - id: string; - name: string; - tags: string[]; - durationTurns: number; - }>; - consumedItemId?: string | null; - usedItem?: RuntimeCombatInventoryItem | null; - itemEffect?: NonNullable< - ReturnType - > | null; -}; - -export type CombatResolution = { - actionText: string; - resultText: string; - battle: RuntimeBattlePresentation; - patches: RuntimeStoryPatch[]; - storyText?: string; -}; - -const LEGACY_ATTACK_FUNCTION_IDS = new Set([ - 'battle_all_in_crush', - 'battle_guard_break', - 'battle_probe_pressure', - 'battle_feint_step', - 'battle_finisher_window', -]); - -type RuntimeCombatInventoryItem = Parameters< - typeof resolveInventoryItemUseEffect ->[0] & { - id: string; - quantity: number; -}; - -function isObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function readString(value: unknown) { - return typeof value === 'string' && value.trim() ? value.trim() : ''; -} - -function readNumber(value: unknown, fallback = 0) { - return typeof value === 'number' && Number.isFinite(value) ? value : fallback; -} - -function readArray(value: unknown) { - return Array.isArray(value) ? value : []; -} - -function getAliveTarget(session: RuntimeSession) { - return session.sceneHostileNpcs.find((npc) => npc.hp > 0) ?? null; -} - -function getVictoryResolvedTargets( - session: RuntimeSession, - primaryTargetId: string, -) { - return session.sceneHostileNpcs.filter( - (npc) => npc.id === primaryTargetId || npc.hp > 0, - ); -} - -function getCombatInventoryItem( - session: RuntimeSession, - itemId: string, -): RuntimeCombatInventoryItem | null { - const rawItem = readArray(session.rawGameState.playerInventory).find( - (candidate) => isObject(candidate) && readString(candidate.id) === itemId, - ); - if (!rawItem || !isObject(rawItem)) { - return null; - } - - const name = readString(rawItem.name, itemId); - if (!name) { - return null; - } - - const rarity = readString(rawItem.rarity, 'common'); - const normalizedRarity = - rarity === 'legendary' || - rarity === 'epic' || - rarity === 'rare' || - rarity === 'uncommon' - ? rarity - : 'common'; - - return { - id: itemId, - name, - quantity: Math.max(0, Math.round(readNumber(rawItem.quantity, 0))), - rarity: normalizedRarity, - tags: readArray(rawItem.tags).filter( - (tag): tag is string => typeof tag === 'string' && tag.trim().length > 0, - ), - useProfile: isObject(rawItem.useProfile) - ? (rawItem.useProfile as RuntimeCombatInventoryItem['useProfile']) - : undefined, - }; -} - -function applySparAffinityReward(session: RuntimeSession) { - const npcState = getEncounterNpcState(session); - const encounter = session.currentEncounter; - if (!npcState || !encounter || encounter.kind !== 'npc') { - return null; - } - - const nextAffinity = npcState.affinity + 3; - setEncounterNpcState(session, { - ...npcState, - affinity: nextAffinity, - }); - - return { - npcId: encounter.id, - previousAffinity: npcState.affinity, - nextAffinity, - } satisfies Extract; -} - -function clampPlayerVitals(session: RuntimeSession) { - session.playerHp = Math.max( - 0, - Math.min(session.playerHp, session.playerMaxHp), - ); - session.playerMana = Math.max( - 0, - Math.min(session.playerMana, session.playerMaxMana), - ); -} - -function applyHostileVictoryRewards( - session: RuntimeSession, - resolvedTargets: RuntimeSession['sceneHostileNpcs'], -) { - if (resolvedTargets.length <= 0) { - return ''; - } - - const grantedXp = resolvedTargets.reduce((sum, hostileNpc) => { - const battleProfile = resolveHostileBattleProfile({ - playerProgression: session.rawGameState.playerProgression, - encounter: { - hostile: true, - monsterPresetId: hostileNpc.id, - levelProfile: hostileNpc.levelProfile, - experienceReward: hostileNpc.experienceReward, - }, - battleMode: 'fight', - }); - - return sum + battleProfile.experienceReward; - }, 0); - const experienceGrant = grantPlayerExperience( - session.rawGameState.playerProgression, - grantedXp, - { - source: 'hostile_npc', - }, - ); - - session.rawGameState.playerProgression = experienceGrant.state; - session.rawGameState.runtimeStats = incrementGameRuntimeStats( - (isObject(session.rawGameState.runtimeStats) - ? session.rawGameState.runtimeStats - : { - hostileNpcsDefeated: 0, - questsAccepted: 0, - itemsUsed: 0, - scenesTraveled: 0, - }) as Parameters[0], - { - hostileNpcsDefeated: resolvedTargets.length, - }, - ); - - return buildExperienceGrantResultText(experienceGrant); -} - -function finishBattle( - session: RuntimeSession, - outcome: RuntimeBattlePresentation['outcome'], -) { - session.inBattle = false; - session.sceneHostileNpcs = []; - session.currentNpcBattleMode = null; - session.currentNpcBattleOutcome = - outcome === 'spar_complete' - ? 'spar_complete' - : outcome === 'victory' - ? 'fight_victory' - : null; - - if (outcome === 'victory' || outcome === 'escaped') { - session.currentEncounter = null; - session.npcInteractionActive = false; - return; - } - - if (session.currentEncounter?.kind === 'npc') { - session.npcInteractionActive = true; - } -} - -function buildBasicAttackBaseDamage(session: RuntimeSession) { - const character = getPlayerCharacter(session); - if (!character) { - return 10; - } - - return Math.max( - 8, - Math.round( - character.attributes.strength * 0.85 + - character.attributes.agility * 0.45, - ), - ); -} - -function tickCooldownMap(cooldowns: Record, turns: number) { - let nextCooldowns = cooldowns; - - for (let index = 0; index < Math.max(0, Math.floor(turns)); index += 1) { - nextCooldowns = Object.fromEntries( - Object.entries(nextCooldowns).map(([skillId, value]) => [ - skillId, - Math.max(0, Math.floor(value) - 1), - ]), - ); - } - - return nextCooldowns; -} - -function resolveCombatActionConfig(params: { - session: RuntimeSession; - functionId: string; - payload?: RuntimeStoryChoicePayload; -}) { - const { session, functionId, payload } = params; - - if (functionId === 'battle_recover_breath') { - return { - actionText: '恢复', - manaCost: 0, - baseDamage: 0, - counterMultiplier: 0.55, - heal: 12, - manaRestore: 9, - cooldownBonus: 1, - selectedSkillId: null, - } satisfies CombatActionConfig; - } - - if ( - functionId === 'battle_attack_basic' || - LEGACY_ATTACK_FUNCTION_IDS.has(functionId) - ) { - return { - actionText: '普通攻击', - manaCost: 0, - baseDamage: buildBasicAttackBaseDamage(session), - counterMultiplier: 1, - selectedSkillId: null, - } satisfies CombatActionConfig; - } - - if (functionId === 'battle_use_skill') { - const character = getPlayerCharacter(session); - if (!character) { - throw conflict('缺少玩家角色,无法结算技能动作'); - } - - const skillId = readString(isObject(payload) ? payload.skillId : ''); - if (!skillId) { - throw conflict('battle_use_skill 缺少 skillId'); - } - - const skill = character.skills.find( - (candidate) => candidate.id === skillId, - ); - if (!skill) { - throw conflict(`未找到技能:${skillId}`); - } - - const cooldowns = getPlayerSkillCooldowns(session); - if ((cooldowns[skill.id] ?? 0) > 0) { - throw conflict(`${skill.name} 仍在冷却中`); - } - - return { - actionText: skill.name, - manaCost: skill.manaCost, - baseDamage: skill.damage, - counterMultiplier: 0.95, - selectedSkillId: skill.id, - appliedCooldownTurns: skill.cooldownTurns, - buildBuffs: skill.buildBuffs ?? [], - } satisfies CombatActionConfig; - } - - if (functionId === 'inventory_use') { - const character = getPlayerCharacter(session); - if (!character) { - throw conflict('缺少玩家角色,无法结算战斗物品动作'); - } - - const itemId = readString(isObject(payload) ? payload.itemId : ''); - if (!itemId) { - throw conflict('inventory_use 缺少 itemId'); - } - - const item = getCombatInventoryItem(session, itemId); - if (!item || item.quantity <= 0) { - throw conflict('未找到可用于战斗结算的物品'); - } - - if (!isInventoryItemUsable(item)) { - throw conflict(`${item.name} 当前不可在战斗中直接使用`); - } - - const effect = resolveInventoryItemUseEffect(item, character); - if ( - !effect || - ((effect.hpRestore ?? 0) <= 0 && - (effect.manaRestore ?? 0) <= 0 && - (effect.cooldownReduction ?? 0) <= 0 && - (effect.buildBuffs?.length ?? 0) <= 0) - ) { - throw conflict(`${item.name} 当前没有可直接结算的战斗效果`); - } - - return { - actionText: `使用${item.name}`, - manaCost: 0, - baseDamage: 0, - counterMultiplier: 0.72, - heal: effect.hpRestore, - manaRestore: effect.manaRestore, - cooldownBonus: effect.cooldownReduction, - selectedSkillId: null, - buildBuffs: effect.buildBuffs, - consumedItemId: item.id, - usedItem: item, - itemEffect: effect, - } satisfies CombatActionConfig; - } - - throw conflict(`暂不支持的战斗动作:${functionId}`); -} - -export function resolveCombatAction( - session: RuntimeSession, - params: { - functionId: string; - payload?: RuntimeStoryChoicePayload; - }, -): CombatResolution { - const target = getAliveTarget(session); - if (!session.inBattle || !target) { - throw conflict('当前不在可结算战斗态,不能执行该战斗动作'); - } - - if (params.functionId === 'battle_escape_breakout') { - finishBattle(session, 'escaped'); - return { - actionText: '逃跑', - resultText: `你抓住空当摆脱了${target.name}的压制,先把这轮正面冲突拉开了。`, - battle: { - targetId: target.id, - targetName: target.name, - outcome: 'escaped', - }, - patches: [ - { - type: 'battle_resolved', - functionId: params.functionId, - targetId: target.id, - outcome: 'escaped', - }, - { - type: 'status_changed', - inBattle: session.inBattle, - npcInteractionActive: session.npcInteractionActive, - currentNpcBattleMode: session.currentNpcBattleMode, - currentNpcBattleOutcome: session.currentNpcBattleOutcome, - }, - { - type: 'encounter_changed', - encounterId: session.currentEncounter?.id ?? null, - }, - ], - }; - } - - const action = resolveCombatActionConfig({ - session, - functionId: params.functionId, - payload: params.payload, - }); - if (action.manaCost > session.playerMana) { - throw conflict('当前灵力不足,无法执行这个战斗动作'); - } - - const character = getPlayerCharacter(session); - if (!character) { - throw conflict('缺少玩家角色,无法结算战斗动作'); - } - - const isSpar = session.currentNpcBattleMode === 'spar'; - const damageResult = - action.baseDamage > 0 - ? resolvePlayerOutgoingDamageResult( - session.rawGameState as Parameters< - typeof resolvePlayerOutgoingDamageResult - >[0], - character, - action.baseDamage, - 1, - `${params.functionId}:${action.selectedSkillId ?? 'default'}:${target.id}:${session.runtimeVersion}`, - ) - : null; - const damageDealt = isSpar - ? action.baseDamage > 0 - ? 1 - : 0 - : (damageResult?.damage ?? 0); - - session.playerMana -= action.manaCost; - session.playerHp += action.heal ?? 0; - session.playerMana += action.manaRestore ?? 0; - - let nextCooldowns = tickCooldownMap(getPlayerSkillCooldowns(session), 1); - if ((action.cooldownBonus ?? 0) > 0) { - nextCooldowns = tickCooldownMap(nextCooldowns, action.cooldownBonus ?? 0); - } - if (action.selectedSkillId && (action.appliedCooldownTurns ?? 0) > 0) { - nextCooldowns = { - ...nextCooldowns, - [action.selectedSkillId]: action.appliedCooldownTurns, - }; - } - session.rawGameState.playerSkillCooldowns = nextCooldowns; - - if (action.consumedItemId) { - session.rawGameState.playerInventory = removeInventoryItem( - session.rawGameState.playerInventory as Parameters< - typeof removeInventoryItem - >[0], - action.consumedItemId, - 1, - ); - session.rawGameState.runtimeStats = incrementGameRuntimeStats( - (isObject(session.rawGameState.runtimeStats) - ? session.rawGameState.runtimeStats - : { - hostileNpcsDefeated: 0, - questsAccepted: 0, - itemsUsed: 0, - scenesTraveled: 0, - }) as Parameters[0], - { itemsUsed: 1 }, - ); - } - - if (action.buildBuffs?.length) { - session.rawGameState.activeBuildBuffs = appendBuildBuffs( - (session.rawGameState.activeBuildBuffs as Parameters< - typeof appendBuildBuffs - >[0]) ?? [], - action.buildBuffs as Parameters[1], - ); - } - - clampPlayerVitals(session); - - if (damageDealt > 0) { - target.hp = Math.max(isSpar ? 1 : 0, target.hp - damageDealt); - } - - const patches: RuntimeStoryPatch[] = []; - let resultText = ''; - let outcome: RuntimeBattlePresentation['outcome'] = 'ongoing'; - let damageTaken = 0; - - if ((isSpar && target.hp <= 1) || (!isSpar && target.hp <= 0)) { - if (isSpar) { - const affinityPatch = applySparAffinityReward(session); - finishBattle(session, 'spar_complete'); - if (affinityPatch) { - patches.push(affinityPatch); - } - outcome = 'spar_complete'; - resultText = `你和${target.name}这轮过招已经分出高下,对方也承认了你的身手。`; - } else { - const resolvedTargets = getVictoryResolvedTargets(session, target.id); - const experienceText = applyHostileVictoryRewards( - session, - resolvedTargets, - ); - finishBattle(session, 'victory'); - outcome = 'victory'; - resultText = experienceText - ? `你这一手彻底压垮了${target.name},眼前战斗已经正式结束。 ${experienceText}` - : `你这一手彻底压垮了${target.name},眼前战斗已经正式结束。`; - } - } else { - const baseCounter = isSpar - ? 1 - : Math.max(4, Math.round(target.maxHp * 0.14 * action.counterMultiplier)); - damageTaken = baseCounter; - session.playerHp = Math.max(isSpar ? 1 : 0, session.playerHp - damageTaken); - - if (isSpar && session.playerHp <= 1) { - const affinityPatch = applySparAffinityReward(session); - finishBattle(session, 'spar_complete'); - if (affinityPatch) { - patches.push(affinityPatch); - } - outcome = 'spar_complete'; - resultText = - params.functionId === 'inventory_use' && action.usedItem - ? `你刚用下${action.usedItem.name}稳住一口气,但${target.name}还是把你逼到了极限,这场切磋点到为止。` - : `${target.name}也把你逼到了极限,这场切磋点到为止,双方都默认收手。`; - } else if (!isSpar && session.playerHp <= 0) { - session.playerHp = 0; - session.inBattle = false; - session.sceneHostileNpcs = []; - session.currentNpcBattleMode = null; - session.npcInteractionActive = false; - session.currentEncounter = null; - outcome = 'escaped'; - resultText = - params.functionId === 'inventory_use' && action.usedItem - ? `你刚把${action.usedItem.name}用下去,却还是被${target.name}压到失去战斗能力,这轮正面冲突只能先断开。` - : `你在和${target.name}的交锋里被压到失去战斗能力,这轮正面冲突只能先断开。`; - } else if (params.functionId === 'battle_recover_breath') { - resultText = `你先把伤势和气息稳住了一轮,但${target.name}仍在持续逼近。`; - } else if ( - params.functionId === 'inventory_use' && - action.usedItem && - action.itemEffect - ) { - resultText = `${buildInventoryUseResultText( - action.usedItem, - action.itemEffect, - ).replace(/。$/u, '')},但${target.name}仍在持续逼近。`; - } else if (params.functionId === 'battle_use_skill') { - resultText = `${action.actionText}命中了${target.name},这一轮技能效果已经直接结算。`; - } else { - resultText = `${action.actionText}命中了${target.name},本次攻击已经完成结算。`; - } - } - - patches.push( - { - type: 'battle_resolved', - functionId: params.functionId, - targetId: target.id, - damageDealt, - damageTaken, - outcome, - }, - { - type: 'status_changed', - inBattle: session.inBattle, - npcInteractionActive: session.npcInteractionActive, - currentNpcBattleMode: session.currentNpcBattleMode, - currentNpcBattleOutcome: session.currentNpcBattleOutcome, - }, - { - type: 'encounter_changed', - encounterId: session.currentEncounter?.id ?? null, - }, - ); - - return { - actionText: action.actionText, - resultText, - battle: { - targetId: target.id, - targetName: target.name, - damageDealt, - damageTaken, - outcome, - }, - patches, - }; -} diff --git a/server-node/src/modules/custom-world/creatorIntentRuntime.ts b/server-node/src/modules/custom-world/creatorIntentRuntime.ts deleted file mode 100644 index 86806f5a..00000000 --- a/server-node/src/modules/custom-world/creatorIntentRuntime.ts +++ /dev/null @@ -1,528 +0,0 @@ -import type { - ActorAnchor, - CreatorCharacterSeed, - CreatorFactionSeed, - CreatorLandmarkSeed, - CustomWorldAnchorPack, - CustomWorldCreatorIntent, - CustomWorldLockState, - LandmarkAnchor, -} from './runtimeTypes.js'; - -function toText(value: unknown) { - return typeof value === 'string' ? value.trim() : ''; -} - -function toStringArray(value: unknown, maxCount = 8) { - if (!Array.isArray(value)) { - return []; - } - - return [...new Set(value.map((item) => toText(item)).filter(Boolean))].slice( - 0, - maxCount, - ); -} - -function slugify(value: string) { - const normalized = value - .trim() - .toLowerCase() - .replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-') - .replace(/^-+|-+$/g, ''); - - return normalized || 'entry'; -} - -function createSeedId(prefix: string, label: string, index: number) { - return `${prefix}-${slugify(label || `${prefix}-${index + 1}`)}-${index + 1}`; -} - -function clampText(value: string, maxLength: number) { - const normalized = value.trim().replace(/\s+/g, ' '); - if (!normalized) { - return ''; - } - if (normalized.length <= maxLength) { - return normalized; - } - return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}…`; -} - -function normalizeCreatorFactionSeed( - value: unknown, - index: number, -): CreatorFactionSeed | null { - if (!value || typeof value !== 'object') { - return null; - } - - const item = value as Record; - const name = toText(item.name); - const publicGoal = toText(item.publicGoal); - const tension = toText(item.tension); - const notes = toText(item.notes); - - if (!name && !publicGoal && !tension && !notes) { - return null; - } - - return { - id: - toText(item.id) || - createSeedId('creator-faction', name || publicGoal, index), - name, - publicGoal, - tension, - notes, - locked: Boolean(item.locked), - }; -} - -function normalizeCreatorCharacterSeed( - value: unknown, - index: number, -): CreatorCharacterSeed | null { - if (!value || typeof value !== 'object') { - return null; - } - - const item = value as Record; - const name = toText(item.name); - const role = toText(item.role); - const publicMask = toText(item.publicMask); - const hiddenHook = toText(item.hiddenHook); - const relationToPlayer = toText(item.relationToPlayer); - const notes = toText(item.notes); - - if ( - !name && - !role && - !publicMask && - !hiddenHook && - !relationToPlayer && - !notes - ) { - return null; - } - - return { - id: - toText(item.id) || - createSeedId('creator-character', name || role || publicMask, index), - name, - role, - publicMask, - hiddenHook, - relationToPlayer, - notes, - locked: Boolean(item.locked), - }; -} - -function normalizeCreatorLandmarkSeed( - value: unknown, - index: number, -): CreatorLandmarkSeed | null { - if (!value || typeof value !== 'object') { - return null; - } - - const item = value as Record; - const name = toText(item.name); - const purpose = toText(item.purpose); - const mood = toText(item.mood); - const secret = toText(item.secret); - - if (!name && !purpose && !mood && !secret) { - return null; - } - - return { - id: - toText(item.id) || - createSeedId('creator-landmark', name || purpose || mood, index), - name, - purpose, - mood, - secret, - locked: Boolean(item.locked), - }; -} - -function normalizeAnchorArray( - value: unknown, - normalizer: (value: unknown, index: number) => T | null, - maxCount: number, -) { - if (!Array.isArray(value)) { - return []; - } - - return value - .map((item, index) => normalizer(item, index)) - .filter((item): item is T => Boolean(item)) - .slice(0, maxCount); -} - -export function normalizeCustomWorldCreatorIntent( - value: unknown, - fallbackMode: CustomWorldCreatorIntent['sourceMode'] = 'freeform', -): CustomWorldCreatorIntent | null { - if (!value || typeof value !== 'object') { - return null; - } - - const item = value as Record; - const sourceMode = - item.sourceMode === 'card' || item.sourceMode === 'freeform' - ? item.sourceMode - : fallbackMode; - const rawSettingText = toText(item.rawSettingText); - const worldHook = toText(item.worldHook); - const playerPremise = toText(item.playerPremise); - const openingSituation = toText(item.openingSituation); - const themeKeywords = toStringArray(item.themeKeywords, 8); - const toneDirectives = toStringArray(item.toneDirectives, 8); - const coreConflicts = toStringArray(item.coreConflicts, 6); - const iconicElements = toStringArray(item.iconicElements, 8); - const forbiddenDirectives = toStringArray(item.forbiddenDirectives, 8); - const keyFactions = normalizeAnchorArray( - item.keyFactions, - normalizeCreatorFactionSeed, - 6, - ); - const keyCharacters = normalizeAnchorArray( - item.keyCharacters, - normalizeCreatorCharacterSeed, - 8, - ); - const keyLandmarks = normalizeAnchorArray( - item.keyLandmarks, - normalizeCreatorLandmarkSeed, - 8, - ); - - if ( - !rawSettingText && - !worldHook && - themeKeywords.length === 0 && - toneDirectives.length === 0 && - !playerPremise && - !openingSituation && - coreConflicts.length === 0 && - keyFactions.length === 0 && - keyCharacters.length === 0 && - keyLandmarks.length === 0 && - iconicElements.length === 0 && - forbiddenDirectives.length === 0 - ) { - return null; - } - - return { - sourceMode, - rawSettingText, - worldHook, - themeKeywords, - toneDirectives, - playerPremise, - openingSituation, - coreConflicts, - keyFactions, - keyCharacters, - keyLandmarks, - iconicElements, - forbiddenDirectives, - }; -} - -export function normalizeCustomWorldLockState( - value: unknown, -): CustomWorldLockState { - if (!value || typeof value !== 'object') { - return { - worldLockedFields: [], - lockedCharacterIds: [], - lockedLandmarkIds: [], - lockedConflictIds: [], - lockedFactionIds: [], - }; - } - - const item = value as Record; - return { - worldLockedFields: toStringArray(item.worldLockedFields, 12), - lockedCharacterIds: toStringArray(item.lockedCharacterIds, 20), - lockedLandmarkIds: toStringArray(item.lockedLandmarkIds, 20), - lockedConflictIds: toStringArray(item.lockedConflictIds, 20), - lockedFactionIds: toStringArray(item.lockedFactionIds, 20), - }; -} - -export function deriveCustomWorldLockStateFromIntent( - intent: CustomWorldCreatorIntent | null | undefined, -): CustomWorldLockState { - return { - worldLockedFields: [], - lockedCharacterIds: - intent?.keyCharacters - .filter((entry) => entry.locked) - .map((entry) => entry.id) ?? [], - lockedLandmarkIds: - intent?.keyLandmarks - .filter((entry) => entry.locked) - .map((entry) => entry.id) ?? [], - lockedConflictIds: [], - lockedFactionIds: - intent?.keyFactions - .filter((entry) => entry.locked) - .map((entry) => entry.id) ?? [], - }; -} - -export function hasMeaningfulCustomWorldCreatorIntent( - intent: CustomWorldCreatorIntent | null | undefined, -) { - return Boolean( - intent && - (intent.rawSettingText || - intent.worldHook || - intent.themeKeywords.length > 0 || - intent.toneDirectives.length > 0 || - intent.playerPremise || - intent.openingSituation || - intent.coreConflicts.length > 0 || - intent.keyFactions.length > 0 || - intent.keyCharacters.length > 0 || - intent.keyLandmarks.length > 0 || - intent.iconicElements.length > 0 || - intent.forbiddenDirectives.length > 0), - ); -} - -function buildAnchorLine(label: string, content: string) { - return content ? `${label}:${content}` : ''; -} - -function buildCustomWorldCreatorIntentDisplayText( - intent: CustomWorldCreatorIntent | null | undefined, -) { - if (!hasMeaningfulCustomWorldCreatorIntent(intent)) { - return ''; - } - - const lines = [ - intent?.worldHook ? `世界一句话:${intent.worldHook}` : '', - intent?.rawSettingText ? `补充设定:${intent.rawSettingText}` : '', - buildAnchorLine('主题关键词', intent?.themeKeywords.join('、') || ''), - buildAnchorLine('世界气质', intent?.toneDirectives.join('、') || ''), - buildAnchorLine('玩家是谁', intent?.playerPremise || ''), - buildAnchorLine('开局处境', intent?.openingSituation || ''), - buildAnchorLine('核心冲突', intent?.coreConflicts.join('、') || ''), - buildAnchorLine( - '关键势力', - intent?.keyFactions - .map((entry) => - [entry.name, entry.publicGoal, entry.tension] - .filter(Boolean) - .join(' / '), - ) - .filter(Boolean) - .join(';') || '', - ), - buildAnchorLine( - '关键角色', - intent?.keyCharacters - .map((entry) => - [ - entry.name, - entry.role, - entry.publicMask, - entry.hiddenHook ? `暗线 ${entry.hiddenHook}` : '', - ] - .filter(Boolean) - .join(' / '), - ) - .filter(Boolean) - .join(';') || '', - ), - buildAnchorLine( - '关键地点', - intent?.keyLandmarks - .map((entry) => - [entry.name, entry.purpose, entry.mood].filter(Boolean).join(' / '), - ) - .filter(Boolean) - .join(';') || '', - ), - buildAnchorLine('标志性要素', intent?.iconicElements.join('、') || ''), - buildAnchorLine('禁止事项', intent?.forbiddenDirectives.join('、') || ''), - ].filter(Boolean); - - return lines.join('\n'); -} - -export function buildCustomWorldCreatorIntentGenerationText( - intent: CustomWorldCreatorIntent | null | undefined, -) { - if (!hasMeaningfulCustomWorldCreatorIntent(intent)) { - return ''; - } - - const sections = [ - buildAnchorLine('世界核心命题', intent?.worldHook || ''), - buildAnchorLine('补充设定原文', intent?.rawSettingText || ''), - buildAnchorLine('主题关键词', intent?.themeKeywords.join('、') || ''), - buildAnchorLine('情绪与气质', intent?.toneDirectives.join('、') || ''), - buildAnchorLine('玩家身份', intent?.playerPremise || ''), - buildAnchorLine('开局处境', intent?.openingSituation || ''), - buildAnchorLine('核心冲突', intent?.coreConflicts.join('、') || ''), - buildAnchorLine( - '关键势力锚点', - intent?.keyFactions - .map((entry) => - [ - entry.name, - entry.publicGoal ? `目标 ${entry.publicGoal}` : '', - entry.tension ? `张力 ${entry.tension}` : '', - entry.notes ? `补充 ${entry.notes}` : '', - ] - .filter(Boolean) - .join(';'), - ) - .filter(Boolean) - .join('\n') || '', - ), - buildAnchorLine( - '关键角色锚点', - intent?.keyCharacters - .map((entry) => - [ - entry.name, - entry.role ? `身份 ${entry.role}` : '', - entry.publicMask ? `表面 ${entry.publicMask}` : '', - entry.hiddenHook ? `暗线 ${entry.hiddenHook}` : '', - entry.relationToPlayer ? `与玩家 ${entry.relationToPlayer}` : '', - entry.notes ? `补充 ${entry.notes}` : '', - ] - .filter(Boolean) - .join(';'), - ) - .filter(Boolean) - .join('\n') || '', - ), - buildAnchorLine( - '关键地点锚点', - intent?.keyLandmarks - .map((entry) => - [ - entry.name, - entry.purpose ? `作用 ${entry.purpose}` : '', - entry.mood ? `氛围 ${entry.mood}` : '', - entry.secret ? `秘密 ${entry.secret}` : '', - ] - .filter(Boolean) - .join(';'), - ) - .filter(Boolean) - .join('\n') || '', - ), - buildAnchorLine('标志性要素', intent?.iconicElements.join('、') || ''), - buildAnchorLine('禁止事项', intent?.forbiddenDirectives.join('、') || ''), - ].filter(Boolean); - - return sections.join('\n\n'); -} - -function buildCharacterAnchorSummary(entry: CreatorCharacterSeed): ActorAnchor { - const summary = clampText( - [ - entry.role, - entry.publicMask, - entry.hiddenHook ? `暗线 ${entry.hiddenHook}` : '', - entry.relationToPlayer ? `与玩家 ${entry.relationToPlayer}` : '', - ] - .filter(Boolean) - .join(';'), - 72, - ); - - return { - id: entry.id, - name: entry.name || '未命名关键角色', - summary, - }; -} - -function buildLandmarkAnchorSummary( - entry: CreatorLandmarkSeed, -): LandmarkAnchor { - const summary = clampText( - [entry.purpose, entry.mood, entry.secret ? `秘密 ${entry.secret}` : ''] - .filter(Boolean) - .join(';'), - 72, - ); - - return { - id: entry.id, - name: entry.name || '未命名关键地点', - summary, - }; -} - -export function buildCustomWorldAnchorPackFromIntent( - intent: CustomWorldCreatorIntent | null | undefined, -): CustomWorldAnchorPack | null { - if (!hasMeaningfulCustomWorldCreatorIntent(intent)) { - return null; - } - - const lockedAnchorIds = [ - ...(intent?.keyCharacters - .filter((entry) => entry.locked) - .map((entry) => entry.id) ?? []), - ...(intent?.keyLandmarks - .filter((entry) => entry.locked) - .map((entry) => entry.id) ?? []), - ...(intent?.keyFactions - .filter((entry) => entry.locked) - .map((entry) => entry.id) ?? []), - ]; - - return { - worldSummary: clampText( - intent?.worldHook || intent?.rawSettingText || '', - 96, - ), - creatorIntentSummary: clampText( - buildCustomWorldCreatorIntentDisplayText(intent), - 240, - ), - lockedAnchorIds, - keyConflictSummaries: - intent?.coreConflicts.map((entry) => clampText(entry, 48)) ?? [], - keyFactionSummaries: - intent?.keyFactions.map((entry) => - clampText( - [entry.name, entry.publicGoal, entry.tension] - .filter(Boolean) - .join(';'), - 72, - ), - ) ?? [], - keyCharacterAnchors: - intent?.keyCharacters.map((entry) => - buildCharacterAnchorSummary(entry), - ) ?? [], - keyLandmarkAnchors: - intent?.keyLandmarks.map((entry) => buildLandmarkAnchorSummary(entry)) ?? - [], - motifDirectives: [ - ...(intent?.themeKeywords ?? []), - ...(intent?.toneDirectives ?? []), - ...(intent?.iconicElements ?? []), - ].slice(0, 12), - }; -} diff --git a/server-node/src/modules/custom-world/runtime-profile/buildAttributeSchema.ts b/server-node/src/modules/custom-world/runtime-profile/buildAttributeSchema.ts deleted file mode 100644 index 0301a2fb..00000000 --- a/server-node/src/modules/custom-world/runtime-profile/buildAttributeSchema.ts +++ /dev/null @@ -1,365 +0,0 @@ -import type { - AttributeVector, - CustomWorldNpc, - CustomWorldPlayableNpc, - RoleAttributeProfile, - WorldAttributeSchema, - WorldAttributeSlot, - WorldType, -} from '../runtimeTypes.js'; -import { inferWorldTypeFromSetting } from './creatorIntentBridge.js'; -import { slugify } from './normalizeShared.js'; - -/** - * 工作包 G: - * 把 attribute schema 构建和角色属性画像编译从主 runtime compiler 中抽离, - * 让结果预览编译、世界基础 profile 归一和角色属性推导有清晰边界。 - */ - -const WORLD_ATTRIBUTE_SLOT_IDS = [ - 'axis_a', - 'axis_b', - 'axis_c', - 'axis_d', - 'axis_e', - 'axis_f', -] as const; - -const AXIS_KEYWORD_RULES: Array<{ - slotId: string; - patterns: RegExp[]; - weight: number; -}> = [ - { slotId: 'axis_a', patterns: [/骨|甲|壳|岩|重|守|镇|顶|硬|锋|体/u], weight: 16 }, - { slotId: 'axis_b', patterns: [/身法|迅|影|风|闪|游|机动|追|步|轻灵/u], weight: 16 }, - { slotId: 'axis_c', patterns: [/识|眼|谋|算|阵|符|术|察|局|禁制/u], weight: 16 }, - { slotId: 'axis_d', patterns: [/心|焰|胆|威|压|怒|决|破|强推|意志/u], weight: 16 }, - { slotId: 'axis_e', patterns: [/缘|契|情|盟|商|医|助|信|交|誓/u], weight: 16 }, - { slotId: 'axis_f', patterns: [/息|稳|续|守|调|回|养|持久|回复|韧/u], weight: 16 }, -]; - -export function buildTemplateWorldAttributeSchema( - worldType: Exclude, -) { - const common = { - schemaVersion: 1, - generatedFrom: - worldType === 'XIANXIA' - ? { - worldType: 'XIANXIA' as const, - worldName: '仙侠', - settingSummary: '灵潮、宗门、禁制、秘境与道途交织。', - tone: '空灵、危险、带着灾变与大道压迫。', - conflictCore: '在裂变与因果之间稳住自我与道途。', - } - : { - worldType: 'WUXIA' as const, - worldName: '武侠', - settingSummary: '江湖、门派、旧案与人情纠葛并存。', - tone: '克制、紧张、讲究局势与心气。', - conflictCore: '在人情、威压与旧案之间立住自身。', - }, - }; - - if (worldType === 'XIANXIA') { - return { - id: 'schema:xianxia:v1', - worldId: 'XIANXIA', - schemaName: '灵界六轴', - ...common, - slots: [ - { - slotId: 'axis_a', - name: '道骨', - definition: '承载道压与高强度冲击的底子。', - positiveSignals: ['承压', '根基稳', '扛得住'], - negativeSignals: ['根基浅', '易溃', '承载不足'], - combatUseText: '扛住灵压、正面承受高强度对撞。', - socialUseText: '让人感到根基扎实,值得托付重事。', - explorationUseText: '承受秘境、禁制与裂隙带来的压迫。', - }, - { - slotId: 'axis_b', - name: '灵行', - definition: '位移、御空、转场、抢占天时地利的能力。', - positiveSignals: ['位移', '御空', '机动'], - negativeSignals: ['迟滞', '失位', '转场慢'], - combatUseText: '抢位、御空、快速重整战场位置。', - socialUseText: '反应轻快,擅长顺势接住局面的变化。', - explorationUseText: '穿越高危地形、裂隙、云海与复杂禁区。', - }, - { - slotId: 'axis_c', - name: '识海', - definition: '解析禁制、洞察因果、识破虚实的能力。', - positiveSignals: ['洞察', '解构', '看破'], - negativeSignals: ['迷失', '误判', '看不清'], - combatUseText: '识破术理、找出因果节点与破绽。', - socialUseText: '更容易辨认真话、虚言与隐藏动机。', - explorationUseText: '解读阵纹、禁制、旧史与环境异象。', - }, - { - slotId: 'axis_d', - name: '劫纹', - definition: '在高危变化中强行推进、改写局势的能力。', - positiveSignals: ['强推', '决断', '逆转'], - negativeSignals: ['畏缩', '迟疑', '不敢碰变局'], - combatUseText: '在高压窗口里压上去,逼出变化与突破。', - socialUseText: '在关键谈判中拍板,推动他人表态。', - explorationUseText: '面对异变与风险时敢于推进关键节点。', - }, - { - slotId: 'axis_e', - name: '心契', - definition: '与他者、器物、灵兽、誓约建立共鸣的能力。', - positiveSignals: ['共鸣', '结契', '安抚'], - negativeSignals: ['隔阂', '生硬', '难以共振'], - combatUseText: '与器物、灵兽、同伴形成协同与共鸣。', - socialUseText: '建立信任、誓约与更深层的关系连结。', - explorationUseText: '借由共鸣打开封印、回应遗物或安抚异兽。', - }, - { - slotId: 'axis_f', - name: '玄息', - definition: '循环灵息、稳住心神、让自身持续在线的能力。', - positiveSignals: ['稳态', '回转', '续航'], - negativeSignals: ['紊乱', '枯竭', '失衡'], - combatUseText: '维持灵息循环、拖住长线压力与消耗。', - socialUseText: '气息沉稳,不轻易乱阵脚或露出破绽。', - explorationUseText: '在漫长探索与灵潮侵蚀中维持可行动状态。', - }, - ] satisfies WorldAttributeSlot[], - } satisfies WorldAttributeSchema; - } - - return { - id: 'schema:wuxia:v1', - worldId: 'WUXIA', - schemaVersion: 1, - schemaName: '江湖六脉', - generatedFrom: common.generatedFrom, - slots: [ - { - slotId: 'axis_a', - name: '骨势', - definition: '扛压、顶冲、硬吃风险也不退的势头。', - positiveSignals: ['扛压', '硬桥硬马', '稳住正面'], - negativeSignals: ['虚浮', '怯退', '一碰就散'], - combatUseText: '顶住正面压力、换伤不退、撑住阵线。', - socialUseText: '在强压场面里不露怯,给人可靠或强硬之感。', - explorationUseText: '穿越险路、硬顶机关、承受高压环境。', - }, - { - slotId: 'axis_b', - name: '身法', - definition: '腾挪、抢位、换线、把握出手节奏的能力。', - positiveSignals: ['快', '轻灵', '抢位'], - negativeSignals: ['迟缓', '失位', '笨重'], - combatUseText: '切线换位、闪转腾挪、争夺先手。', - socialUseText: '应变快,擅长观察气口并顺势接话。', - explorationUseText: '攀越、潜入、追踪与复杂地形穿行。', - }, - { - slotId: 'axis_c', - name: '眼脉', - definition: '看破破绽、拆招、识局、看穿人心的能力。', - positiveSignals: ['识局', '洞察', '拆招'], - negativeSignals: ['迟钝', '误判', '看不透'], - combatUseText: '抓破绽、拆套路、找出最该切入的位置。', - socialUseText: '判断弦外之音、试探真假、识别来意。', - explorationUseText: '识破机关、辨认痕迹、看懂异状。', - }, - { - slotId: 'axis_d', - name: '心焰', - definition: '决断、压迫、胆气、在局面中立住自身意志的能力。', - positiveSignals: ['胆气', '决断', '压迫'], - negativeSignals: ['犹疑', '软弱', '易被动摇'], - combatUseText: '逼迫对手、强行推进、在关键时刻拍板。', - socialUseText: '立威、定调、在谈判里压住场子。', - explorationUseText: '在未知风险前保持决断,不被局势拖死。', - }, - { - slotId: 'axis_e', - name: '尘缘', - definition: '与人事、情面、承诺、牵引关系打交道的能力。', - positiveSignals: ['通人情', '会安抚', '懂交换'], - negativeSignals: ['生硬', '失礼', '不近人情'], - combatUseText: '借势协同、读懂同伴与对手的关系脉络。', - socialUseText: '安抚、求助、结盟、维系承诺与信任。', - explorationUseText: '从传闻、人脉和地方关系里打开线索。', - }, - { - slotId: 'axis_f', - name: '玄息', - definition: '调息、稳态、久战、把自身维持在可用状态的能力。', - positiveSignals: ['稳', '续战', '调息'], - negativeSignals: ['紊乱', '易崩', '续不上'], - combatUseText: '续战、回气、稳住节奏与状态。', - socialUseText: '遇事不乱,语气和姿态都更沉稳可信。', - explorationUseText: '长线跋涉、在恶劣环境下维持专注与状态。', - }, - ] satisfies WorldAttributeSlot[], - } satisfies WorldAttributeSchema; -} - -export function generateWorldAttributeSchema(input: { - worldName: string; - settingText: string; - summary: string; - tone: string; - playerGoal: string; -}) { - const inferredWorldType = inferWorldTypeFromSetting(input.settingText); - const template = buildTemplateWorldAttributeSchema( - inferredWorldType === 'XIANXIA' ? 'XIANXIA' : 'WUXIA', - ); - - return { - ...template, - id: `schema:custom:${slugify(input.worldName)}`, - worldId: `custom:${input.worldName}`, - generatedFrom: { - worldType: 'CUSTOM', - worldName: input.worldName, - settingSummary: input.summary, - tone: input.tone, - conflictCore: input.playerGoal, - }, - } satisfies WorldAttributeSchema; -} - -function normalizeAttributeValues( - values: AttributeVector, - slotIds: readonly string[], - targetTotal = 360, -) { - const positiveValues = slotIds.map((slotId) => Math.max(0, values[slotId] ?? 0)); - const rawTotal = positiveValues.reduce((sum, value) => sum + value, 0); - const normalized = - rawTotal > 0 - ? positiveValues.map((value) => (value / rawTotal) * targetTotal) - : slotIds.map(() => targetTotal / Math.max(slotIds.length, 1)); - const rounded = normalized.map((value) => Math.max(0, Math.min(100, Math.round(value)))); - return Object.fromEntries( - slotIds.map((slotId, index) => [slotId, rounded[index] ?? 0]), - ) as AttributeVector; -} - -function ensureRoleAttributeProfile( - profile: Partial | null | undefined, - schema: WorldAttributeSchema, - fallbackValues: AttributeVector, -): RoleAttributeProfile { - const slotIds = schema.slots.map((slot) => slot.slotId); - const values = normalizeAttributeValues( - { - ...fallbackValues, - ...(profile?.values ?? {}), - }, - slotIds, - ); - const sortedSlots = [...schema.slots] - .map((slot) => ({ - slot, - value: values[slot.slotId] ?? 0, - })) - .sort((left, right) => right.value - left.value); - - return { - schemaId: profile?.schemaId ?? schema.id, - values, - topTraits: sortedSlots.slice(0, 2).map((entry) => entry.slot.name), - hiddenTraits: profile?.hiddenTraits ? [...profile.hiddenTraits] : undefined, - evidence: - profile?.evidence?.length - ? [...profile.evidence] - : sortedSlots.slice(0, 3).map((entry) => ({ - slotId: entry.slot.slotId, - reason: `${entry.slot.name}在当前画像中最突出。`, - })), - }; -} - -function buildDefaultAxisVector( - overrides: Partial>, -) { - return WORLD_ATTRIBUTE_SLOT_IDS.reduce((result, slotId) => { - result[slotId] = overrides[slotId] ?? 0; - return result; - }, {}); -} - -function buildRoleAttributeProfileFromTexts(params: { - schema: WorldAttributeSchema; - textBlocks: Array; -}) { - const sourceText = params.textBlocks.filter(Boolean).join(' '); - const seed = buildDefaultAxisVector({ - axis_a: 58, - axis_b: 58, - axis_c: 58, - axis_d: 58, - axis_e: 58, - axis_f: 58, - }); - - AXIS_KEYWORD_RULES.forEach((rule) => { - const matches = rule.patterns.reduce( - (count, pattern) => count + (pattern.test(sourceText) ? 1 : 0), - 0, - ); - if (matches <= 0) { - return; - } - seed[rule.slotId] = (seed[rule.slotId] ?? 0) + rule.weight * matches; - }); - - return ensureRoleAttributeProfile( - { - schemaId: params.schema.id, - }, - params.schema, - seed, - ); -} - -export function buildCustomWorldPlayableNpcAttributeProfile( - npc: CustomWorldPlayableNpc, - schema: WorldAttributeSchema, -) { - return buildRoleAttributeProfileFromTexts({ - schema, - textBlocks: [ - npc.title, - npc.role, - npc.description, - npc.backstory, - npc.personality, - npc.motivation, - npc.combatStyle, - ...(npc.relationshipHooks ?? []), - ...(npc.tags ?? []), - ], - }); -} - -export function buildCustomWorldStoryNpcAttributeProfile( - npc: CustomWorldNpc, - schema: WorldAttributeSchema, -) { - return buildRoleAttributeProfileFromTexts({ - schema, - textBlocks: [ - npc.title, - npc.role, - npc.description, - npc.backstory, - npc.personality, - npc.motivation, - npc.combatStyle, - ...(npc.relationshipHooks ?? []), - ...(npc.tags ?? []), - ], - }); -} diff --git a/server-node/src/modules/custom-world/runtime-profile/buildCompiledProfile.ts b/server-node/src/modules/custom-world/runtime-profile/buildCompiledProfile.ts deleted file mode 100644 index f9965b6f..00000000 --- a/server-node/src/modules/custom-world/runtime-profile/buildCompiledProfile.ts +++ /dev/null @@ -1,410 +0,0 @@ -import type { - CustomWorldGenerationFramework, - CustomWorldProfile, -} from '../runtimeTypes.js'; -import { - buildCustomWorldPlayableNpcAttributeProfile, - buildCustomWorldStoryNpcAttributeProfile, - generateWorldAttributeSchema, -} from './buildAttributeSchema.js'; -import { - buildWorldName, - inferWorldTypeFromSetting, - normalizeWorldType, - normalizeCustomWorldCreatorIntent, - normalizeCustomWorldLockState, - resolveCustomWorldRuntimeIntentBridge, -} from './creatorIntentBridge.js'; -import { - buildFallbackCustomWorldCampScene, - normalizeCampOutline, - normalizeCampScene, -} from './normalizeCamp.js'; -import { - buildCustomWorldRawProfileLandmarksFromFramework, - normalizeLandmarkOutlineList, - normalizeLandmarks, -} from './normalizeLandmark.js'; -import { - buildCustomWorldRawProfileRolesFromFramework, - normalizeCustomWorldGenerationFrameworkRoles, - normalizePlayableNpcList, - normalizeStoryNpcList, -} from './normalizeRole.js'; -import { - buildDefaultCustomWorldCover, - MIN_CUSTOM_WORLD_LANDMARK_COUNT, - MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT, - normalizeCustomWorldCover, - normalizeItemList, - normalizeTags, - PLAYABLE_TEMPLATE_CHARACTER_IDS, - slugify, - toRecordArray, - toText, -} from './normalizeShared.js'; -import { normalizeSceneChapterBlueprints } from './normalizeSceneChapter.js'; - -/** - * 工作包 G: - * 让 runtime profile 真正由“主编译入口 + 目录化 normalize/build 子模块”组成。 - */ - -function buildBaseCustomWorldProfile(settingText: string): CustomWorldProfile { - const templateWorldType = inferWorldTypeFromSetting(settingText); - const name = buildWorldName(settingText, templateWorldType); - const subtitle = '前路未明'; - const summary = settingText.trim() - ? `这个世界围绕“${settingText.trim().slice(0, 28)}”展开。` - : '一个仍待展开的独立世界正在成形。'; - const tone = '未知、紧绷、仍在展开'; - const playerGoal = '查清眼前局势的关键矛盾,并守住仍值得相信的人与事'; - const camp = buildFallbackCustomWorldCampScene({ - name, - summary, - tone, - playerGoal, - settingText: settingText.trim(), - }); - - return { - id: `custom-world-${Date.now().toString(36)}-${slugify(name)}`, - settingText: settingText.trim(), - name, - subtitle, - summary, - tone, - playerGoal, - cover: buildDefaultCustomWorldCover([]), - templateWorldType, - compatibilityTemplateWorldType: templateWorldType, - majorFactions: [], - coreConflicts: [summary], - attributeSchema: generateWorldAttributeSchema({ - worldName: name, - settingText: settingText.trim(), - summary, - tone, - playerGoal, - }), - playableNpcs: [], - storyNpcs: [], - items: [], - camp, - landmarks: [], - themePack: null, - storyGraph: null, - creatorIntent: null, - anchorPack: null, - lockState: normalizeCustomWorldLockState(null), - generationMode: 'full', - generationStatus: 'complete', - ownedSettingLayers: null, - scenarioPackId: null, - campaignPackId: null, - }; -} - -export function normalizeCustomWorldGenerationFramework( - raw: unknown, - settingText: string, -): CustomWorldGenerationFramework { - const fallback = buildBaseCustomWorldProfile(settingText); - if (!raw || typeof raw !== 'object') { - return { - settingText: fallback.settingText, - name: fallback.name, - subtitle: fallback.subtitle, - summary: fallback.summary, - tone: fallback.tone, - playerGoal: fallback.playerGoal, - templateWorldType: fallback.templateWorldType, - compatibilityTemplateWorldType: - fallback.compatibilityTemplateWorldType ?? fallback.templateWorldType, - majorFactions: [], - coreConflicts: [fallback.summary], - camp: { - name: fallback.camp?.name ?? '归舍', - description: fallback.camp?.description ?? '', - dangerLevel: fallback.camp?.dangerLevel ?? 'low', - }, - playableNpcs: [], - storyNpcs: [], - landmarks: [], - }; - } - - const item = raw as Record; - const roleState = normalizeCustomWorldGenerationFrameworkRoles({ - raw: item, - fallback, - settingText, - }); - - return { - settingText: settingText.trim(), - name: roleState.name, - subtitle: toText(item.subtitle) || fallback.subtitle, - summary: toText(item.summary) || fallback.summary, - tone: toText(item.tone) || fallback.tone, - playerGoal: toText(item.playerGoal) || fallback.playerGoal, - templateWorldType: roleState.templateWorldType, - compatibilityTemplateWorldType: roleState.templateWorldType, - majorFactions: normalizeTags(item.majorFactions, []), - coreConflicts: normalizeTags(item.coreConflicts, [fallback.summary]), - camp: { - name: normalizeCampOutline(item.camp, roleState.campFallbackProfile).name, - description: normalizeCampOutline(item.camp, roleState.campFallbackProfile) - .description, - dangerLevel: normalizeCampOutline(item.camp, roleState.campFallbackProfile) - .dangerLevel, - }, - playableNpcs: roleState.playableNpcs, - storyNpcs: roleState.storyNpcs, - landmarks: normalizeLandmarkOutlineList(item.landmarks), - }; -} - -export function buildCustomWorldRawProfileFromFramework( - framework: CustomWorldGenerationFramework, -) { - return { - name: framework.name, - subtitle: framework.subtitle, - summary: framework.summary, - tone: framework.tone, - playerGoal: framework.playerGoal, - templateWorldType: framework.templateWorldType, - compatibilityTemplateWorldType: framework.compatibilityTemplateWorldType, - majorFactions: framework.majorFactions, - coreConflicts: framework.coreConflicts, - camp: { - name: framework.camp.name, - description: framework.camp.description, - dangerLevel: framework.camp.dangerLevel, - }, - ...buildCustomWorldRawProfileRolesFromFramework(framework), - landmarks: buildCustomWorldRawProfileLandmarksFromFramework(framework), - }; -} - -function pickCyclic(items: readonly T[], index: number, label: string): T { - const item = items[index % items.length]; - if (item === undefined) { - throw new Error(`Missing ${label}`); - } - return item; -} - -export function normalizeCustomWorldProfile( - raw: unknown, - settingText: string, -): CustomWorldProfile { - const fallback = buildBaseCustomWorldProfile(settingText); - if (!raw || typeof raw !== 'object') { - return fallback; - } - - const item = raw as Record; - const worldSignalText = [ - settingText, - toText(item.subtitle), - toText(item.summary), - toText(item.tone), - toText(item.playerGoal), - ].join(' '); - const templateWorldType = normalizeWorldType( - item.templateWorldType, - worldSignalText, - ); - const name = - toText(item.name) || buildWorldName(settingText, templateWorldType); - const summary = toText(item.summary) || fallback.summary; - const tone = toText(item.tone) || fallback.tone; - const playerGoal = toText(item.playerGoal) || fallback.playerGoal; - const generatedAttributeSchema = generateWorldAttributeSchema({ - worldName: name, - settingText: settingText.trim(), - summary, - tone, - playerGoal, - }); - const playableNpcs = normalizePlayableNpcList(item.playableNpcs); - const storyNpcs = normalizeStoryNpcList(item.storyNpcs); - const landmarkDrafts = toRecordArray(item.landmarks); - const camp = normalizeCampScene(item.camp, { - name, - summary, - tone, - playerGoal, - settingText: settingText.trim(), - }); - const runtimeBridge = resolveCustomWorldRuntimeIntentBridge(item); - - return { - id: - toText(item.id) || `custom-world-${Date.now().toString(36)}-${slugify(name)}`, - settingText: settingText.trim(), - name, - subtitle: toText(item.subtitle) || fallback.subtitle, - summary, - tone, - playerGoal, - cover: normalizeCustomWorldCover(item.cover, playableNpcs), - templateWorldType, - compatibilityTemplateWorldType: templateWorldType, - majorFactions: normalizeTags(item.majorFactions, []), - coreConflicts: normalizeTags(item.coreConflicts, [summary]), - attributeSchema: - item.attributeSchema && typeof item.attributeSchema === 'object' - ? generatedAttributeSchema - : generatedAttributeSchema, - playableNpcs, - storyNpcs, - items: normalizeItemList(item.items), - camp, - landmarks: normalizeLandmarks({ - landmarks: landmarkDrafts, - storyNpcs, - }), - themePack: - item.themePack && typeof item.themePack === 'object' - ? (item.themePack as CustomWorldProfile['themePack']) - : null, - storyGraph: - item.storyGraph && typeof item.storyGraph === 'object' - ? (item.storyGraph as CustomWorldProfile['storyGraph']) - : null, - anchorContent: - item.anchorContent && typeof item.anchorContent === 'object' - ? (item.anchorContent as Record) - : null, - creatorIntent: runtimeBridge.creatorIntent, - anchorPack: runtimeBridge.anchorPack, - lockState: runtimeBridge.lockState, - generationMode: - item.generationMode === 'fast' || item.generationMode === 'full' - ? item.generationMode - : fallback.generationMode, - generationStatus: - item.generationStatus === 'key_only' || item.generationStatus === 'complete' - ? item.generationStatus - : fallback.generationStatus, - ownedSettingLayers: - item.ownedSettingLayers && typeof item.ownedSettingLayers === 'object' - ? (item.ownedSettingLayers as Record) - : null, - knowledgeFacts: - Array.isArray(item.knowledgeFacts) - ? (item.knowledgeFacts as Array>) - : null, - threadContracts: - Array.isArray(item.threadContracts) - ? (item.threadContracts as Array>) - : null, - sceneChapterBlueprints: normalizeSceneChapterBlueprints( - item.sceneChapterBlueprints, - ), - scenarioPackId: toText(item.scenarioPackId) || null, - campaignPackId: toText(item.campaignPackId) || null, - }; -} - -export function buildCompiledCustomWorldProfile( - raw: unknown, - settingText: string, -): CustomWorldProfile { - const profile = normalizeCustomWorldProfile(raw, settingText); - const playableNpcs = profile.playableNpcs.map((npc, index) => { - const templateCharacterId = - npc.templateCharacterId ?? - pickCyclic( - PLAYABLE_TEMPLATE_CHARACTER_IDS, - index, - 'playable template character id', - ); - - return { - ...npc, - templateCharacterId, - attributeProfile: - npc.attributeProfile ?? - buildCustomWorldPlayableNpcAttributeProfile( - { - ...npc, - templateCharacterId, - }, - profile.attributeSchema, - ), - }; - }); - - const storyNpcs = profile.storyNpcs.map((npc) => ({ - ...npc, - attributeProfile: - npc.attributeProfile ?? - buildCustomWorldStoryNpcAttributeProfile(npc, profile.attributeSchema), - })); - - return { - ...profile, - playableNpcs, - storyNpcs, - scenarioPackId: - profile.scenarioPackId ?? `scenario-pack:${slugify(profile.name)}`, - campaignPackId: - profile.campaignPackId ?? `campaign-pack:${slugify(profile.name)}`, - }; -} - -function countUniqueNames(items: Array<{ name: string }>) { - return new Set(items.map((item) => item.name.trim()).filter(Boolean)).size; -} - -export function validateGeneratedCustomWorldProfile( - profile: CustomWorldProfile, -) { - const playableCount = countUniqueNames(profile.playableNpcs); - const landmarkCount = countUniqueNames(profile.landmarks); - - if (playableCount < MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT) { - throw new Error( - `自定义世界生成要求模型至少产出 ${MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT} 名可扮演角色。`, - ); - } - - if (landmarkCount < MIN_CUSTOM_WORLD_LANDMARK_COUNT) { - throw new Error( - `自定义世界生成要求至少产出 ${MIN_CUSTOM_WORLD_LANDMARK_COUNT} 个场景,当前仅返回 ${landmarkCount} 个。`, - ); - } - - const validStoryNpcIds = new Set(profile.storyNpcs.map((npc) => npc.id)); - const validLandmarkIds = new Set( - profile.landmarks.map((landmark) => landmark.id), - ); - - profile.landmarks.forEach((landmark) => { - const uniqueSceneNpcIds = [...new Set(landmark.sceneNpcIds)]; - if (uniqueSceneNpcIds.length < 3) { - throw new Error( - `场景「${landmark.name}」至少需要关联 3 个场景角色,当前仅有 ${uniqueSceneNpcIds.length} 个。`, - ); - } - if (uniqueSceneNpcIds.some((npcId) => !validStoryNpcIds.has(npcId))) { - throw new Error(`场景「${landmark.name}」引用了不存在的场景角色。`); - } - if (landmark.connections.length === 0) { - throw new Error(`场景「${landmark.name}」缺少可用的场景连接关系。`); - } - if ( - landmark.connections.some( - (connection) => - connection.targetLandmarkId === landmark.id || - !validLandmarkIds.has(connection.targetLandmarkId), - ) - ) { - throw new Error(`场景「${landmark.name}」存在无效的目标场景连接。`); - } - }); -} diff --git a/server-node/src/modules/custom-world/runtime-profile/creatorIntentBridge.ts b/server-node/src/modules/custom-world/runtime-profile/creatorIntentBridge.ts deleted file mode 100644 index e194eae5..00000000 --- a/server-node/src/modules/custom-world/runtime-profile/creatorIntentBridge.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { - buildCustomWorldAnchorPackFromIntent, - deriveCustomWorldLockStateFromIntent, - normalizeCustomWorldCreatorIntent, - normalizeCustomWorldLockState, -} from '../creatorIntentRuntime.js'; -import type { - CustomWorldCreatorIntent, - CustomWorldProfile, - WorldType, -} from '../runtimeTypes.js'; -import { toText } from './normalizeShared.js'; - -/** - * 工作包 G: - * 统一 runtime profile 对 creator intent、anchor pack 和 lock state 的桥接入口, - * 避免主编译器继续直接拼装这些兼容字段。 - */ - -export function inferWorldTypeFromSetting(settingText: string): WorldType { - return /[仙灵修真飞升灵脉宗门法器天宫秘境道骨星舰]/u.test(settingText) - ? 'XIANXIA' - : 'WUXIA'; -} - -export function normalizeWorldType(value: unknown, sourceText: string): WorldType { - const worldType = toText(value).toUpperCase(); - if (worldType === 'WUXIA' || worldType === 'XIANXIA') { - return worldType; - } - return inferWorldTypeFromSetting(sourceText); -} - -export function buildSeedPhrase(settingText: string, fallback: string) { - const compact = settingText.replace(/\s+/g, '').trim(); - return compact ? compact.slice(0, 10) : fallback; -} - -export function buildWorldName(settingText: string, worldType: WorldType) { - const seed = buildSeedPhrase(settingText, '新旅'); - const suffix = worldType === 'XIANXIA' ? '境' : '域'; - return `${seed}${suffix}`; -} - -export { - normalizeCustomWorldCreatorIntent, - normalizeCustomWorldLockState, -}; - -export function buildEmptyCustomWorldRuntimeBridge() { - return { - creatorIntent: null, - anchorPack: null, - lockState: normalizeCustomWorldLockState(null), - } satisfies { - creatorIntent: CustomWorldCreatorIntent | null; - anchorPack: CustomWorldProfile['anchorPack']; - lockState: CustomWorldProfile['lockState']; - }; -} - -export function resolveCustomWorldRuntimeIntentBridge( - raw: Record, -) { - const creatorIntent = normalizeCustomWorldCreatorIntent(raw.creatorIntent); - - return { - creatorIntent, - anchorPack: - raw.anchorPack && typeof raw.anchorPack === 'object' - ? (raw.anchorPack as CustomWorldProfile['anchorPack']) - : buildCustomWorldAnchorPackFromIntent(creatorIntent), - lockState: - raw.lockState && typeof raw.lockState === 'object' - ? normalizeCustomWorldLockState(raw.lockState) - : deriveCustomWorldLockStateFromIntent(creatorIntent), - } satisfies { - creatorIntent: CustomWorldCreatorIntent | null; - anchorPack: CustomWorldProfile['anchorPack']; - lockState: CustomWorldProfile['lockState']; - }; -} diff --git a/server-node/src/modules/custom-world/runtime-profile/index.ts b/server-node/src/modules/custom-world/runtime-profile/index.ts deleted file mode 100644 index 60911cc7..00000000 --- a/server-node/src/modules/custom-world/runtime-profile/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * 工作包 G: - * custom world runtime profile 的主入口统一收口到目录化模块。 - * 主编译逻辑和各类 normalize/build 子模块已经物理拆分,旧 compiler 文件只保留兼容转发。 - */ -export * from './buildAttributeSchema.js'; -export * from './buildCompiledProfile.js'; -export * from './creatorIntentBridge.js'; -export * from './normalizeCamp.js'; -export * from './normalizeLandmark.js'; -export * from './normalizeRole.js'; -export * from './normalizeSceneChapter.js'; -export * from './normalizeShared.js'; diff --git a/server-node/src/modules/custom-world/runtime-profile/normalizeCamp.ts b/server-node/src/modules/custom-world/runtime-profile/normalizeCamp.ts deleted file mode 100644 index efdf5d30..00000000 --- a/server-node/src/modules/custom-world/runtime-profile/normalizeCamp.ts +++ /dev/null @@ -1,178 +0,0 @@ -import type { - CustomWorldCampScene, - CustomWorldGenerationCampOutline, -} from '../runtimeTypes.js'; -import { - clampText, - toRecordArray, - toStringArray, - toText, -} from './normalizeShared.js'; - -/** - * 工作包 G: - * 营地 fallback、outline 归一和 runtime 场景归一单独收口, - * 避免主编译器继续混合 UI 展示语义和营地领域默认值。 - */ - -export type CustomWorldCampFallbackProfile = { - name: string; - summary: string; - tone: string; - playerGoal: string; - settingText: string; -}; - -function detectCustomWorldThemeMode(profile: { - settingText: string; - summary: string; - tone: string; - playerGoal: string; -}) { - const source = [ - profile.settingText, - profile.summary, - profile.tone, - profile.playerGoal, - ].join(' '); - - if (/[机关蒸汽齿轮工坊机巧城轨炮舰]/u.test(source)) return 'machina'; - if (/[海潮港湾船舟湖泊澜雾湾]/u.test(source)) return 'tide'; - if (/[裂缝裂界边境前线断层界桥灰域]/u.test(source)) return 'rift'; - if (/[修真仙灵宗门法器道脉秘境云阙]/u.test(source)) return 'arcane'; - if (/[江湖门派镖局朝廷刀剑侠客旧案]/u.test(source)) return 'martial'; - return 'mythic'; -} - -function sanitizeCampSeed(name: string) { - const normalized = name.trim().replace(/\s+/g, ''); - if (!normalized) { - return ''; - } - - const stripped = normalized.replace( - /(世界|江湖|边城|仙洲|仙境|灵境|界|录|域|境)$/u, - '', - ); - const seed = stripped || normalized; - - return seed.slice(0, Math.min(seed.length, 4)); -} - -function buildFallbackCampName(profile: CustomWorldCampFallbackProfile) { - const seed = sanitizeCampSeed(profile.name) || '归途'; - const themeMode = detectCustomWorldThemeMode(profile); - - const suffixByMode = { - mythic: '归舍', - martial: '归舍', - arcane: '栖居', - machina: '整备居', - tide: '潮居', - rift: '界隙居所', - } as const; - - return `${seed}${suffixByMode[themeMode]}`; -} - -export function buildFallbackCustomWorldCampScene( - profile: CustomWorldCampFallbackProfile, -): CustomWorldCampScene { - const fallbackName = buildFallbackCampName(profile); - const summaryLead = clampText(profile.summary, 24) || '前路尚未明朗'; - const goalLead = clampText(profile.playerGoal, 24) || '继续整理线索'; - const themeMode = detectCustomWorldThemeMode(profile); - - const descriptionByMode = { - mythic: `${fallbackName}是你在${profile.name}暂时安顿的归处,能整备行装、交换判断,并围绕“${goalLead}”继续启程。`, - martial: `${fallbackName}是你在${profile.name}暂时落脚的归处,能整备行装、收拢同伴,并朝“${goalLead}”继续动身。`, - arcane: `${fallbackName}是你在${profile.name}暂时栖身的居所,适合调息、整理法器,并围绕“${summaryLead}”交换判断。`, - machina: `${fallbackName}是你在${profile.name}的临时居所,能检修装备、补齐物资,并为下一段行动重新校准节奏。`, - tide: `${fallbackName}是你在${profile.name}靠潮歇息的住处,能安顿队伍、整理补给,并顺着“${goalLead}”继续前探。`, - rift: `${fallbackName}是你在${profile.name}勉强守住的一处居所,既能短暂停步,也能围绕“${summaryLead}”商量下一步去向。`, - } as const; - - return { - id: 'custom-scene-camp', - name: fallbackName, - description: descriptionByMode[themeMode], - dangerLevel: 'low', - sceneNpcIds: [], - connections: [], - narrativeResidues: null, - }; -} - -export function normalizeCampOutline( - value: unknown, - fallbackProfile: CustomWorldCampFallbackProfile, -) { - const fallback = buildFallbackCustomWorldCampScene(fallbackProfile); - const item = - value && typeof value === 'object' - ? (value as Record) - : {}; - - return { - id: toText(item.id) || fallback.id, - name: toText(item.name) || fallback.name, - description: toText(item.description) || fallback.description, - visualDescription: toText(item.visualDescription) || undefined, - dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel, - imageSrc: toText(item.imageSrc) || undefined, - sceneNpcIds: toStringArray(item.sceneNpcIds), - connections: toRecordArray(item.connections) - .map((connection) => ({ - targetLandmarkName: - toText(connection.targetLandmarkName) || - toText(connection.target) || - toText(connection.sceneName), - relativePosition: - toText(connection.relativePosition) || - toText(connection.position) || - 'forward', - summary: toText(connection.summary) || toText(connection.description), - })) - .filter((connection) => connection.targetLandmarkName), - } satisfies CustomWorldGenerationCampOutline & { - id: string; - visualDescription?: string; - imageSrc?: string; - sceneNpcIds: string[]; - connections: Array<{ - targetLandmarkName: string; - relativePosition: string; - summary: string; - }>; - }; -} - -export function normalizeCampScene( - value: unknown, - fallbackProfile: CustomWorldCampFallbackProfile, -): CustomWorldCampScene { - const fallback = buildFallbackCustomWorldCampScene(fallbackProfile); - const item = - value && typeof value === 'object' - ? (value as Record) - : {}; - - return { - id: toText(item.id) || fallback.id, - name: toText(item.name) || fallback.name, - description: toText(item.description) || fallback.description, - visualDescription: toText(item.visualDescription) || undefined, - dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel, - imageSrc: toText(item.imageSrc) || undefined, - sceneNpcIds: toStringArray(item.sceneNpcIds), - connections: toRecordArray(item.connections) - .map((connection) => ({ - targetLandmarkId: toText(connection.targetLandmarkId), - relativePosition: - toText(connection.relativePosition) || toText(connection.position) || 'forward', - summary: toText(connection.summary) || toText(connection.description), - })) - .filter((connection) => connection.targetLandmarkId), - narrativeResidues: null, - }; -} diff --git a/server-node/src/modules/custom-world/runtime-profile/normalizeLandmark.ts b/server-node/src/modules/custom-world/runtime-profile/normalizeLandmark.ts deleted file mode 100644 index ee7f5041..00000000 --- a/server-node/src/modules/custom-world/runtime-profile/normalizeLandmark.ts +++ /dev/null @@ -1,151 +0,0 @@ -import type { - CustomWorldGenerationFramework, - CustomWorldGenerationLandmarkOutline, - CustomWorldNpc, -} from '../runtimeTypes.js'; -import { - clampText, - createEntryId, - MIN_CUSTOM_WORLD_LANDMARK_COUNT, - toRecordArray, - toStringArray, - toText, -} from './normalizeShared.js'; - -/** - * 工作包 G: - * 世界地点 outline/runtime 归一独立收口,避免地点网络解析继续和角色、场景章节逻辑混在一个文件里。 - */ - -export function normalizeLandmarkOutlineList(value: unknown) { - return toRecordArray(value) - .map((item) => { - const name = toText(item.name); - return { - name, - description: - toText(item.description) || - clampText(`${name}暗藏新的局势变化。`, 40), - visualDescription: toText(item.visualDescription) || undefined, - dangerLevel: toText(item.dangerLevel) || 'medium', - sceneNpcNames: [ - ...toStringArray(item.sceneNpcNames), - ...toStringArray(item.npcs, 'name'), - ...toStringArray(item.sceneNpcs, 'name'), - ...toStringArray(item.npcNames), - ], - connections: toRecordArray(item.connections) - .map((connection) => ({ - targetLandmarkName: - toText(connection.targetLandmarkName) || - toText(connection.target) || - toText(connection.sceneName), - relativePosition: - toText(connection.relativePosition) || - toText(connection.position) || - 'forward', - summary: - toText(connection.summary) || toText(connection.description), - })) - .filter((connection) => connection.targetLandmarkName), - } satisfies CustomWorldGenerationLandmarkOutline; - }) - .filter((entry) => entry.name) - .slice(0, MIN_CUSTOM_WORLD_LANDMARK_COUNT); -} - -export function normalizeCustomWorldGenerationLandmarkOutlineBatch(raw: unknown) { - const item = - raw && typeof raw === 'object' ? (raw as Record) : {}; - return normalizeLandmarkOutlineList(item.landmarks); -} - -export function buildCustomWorldRawProfileLandmarksFromFramework( - framework: CustomWorldGenerationFramework, -) { - return framework.landmarks.map((landmark) => ({ - name: landmark.name, - description: landmark.description, - visualDescription: landmark.visualDescription, - dangerLevel: landmark.dangerLevel, - sceneNpcNames: [...landmark.sceneNpcNames], - connections: landmark.connections.map((connection) => ({ - targetLandmarkName: connection.targetLandmarkName, - relativePosition: connection.relativePosition, - summary: connection.summary, - })), - })); -} - -export function normalizeLandmarks(params: { - landmarks: Array>; - storyNpcs: CustomWorldNpc[]; -}) { - const storyNpcIdByName = new Map( - params.storyNpcs.map((npc) => [npc.name.trim(), npc.id] as const), - ); - const landmarkEntries = params.landmarks - .map((item, index) => ({ - id: toText(item.id) || createEntryId('landmark', toText(item.name), index), - name: toText(item.name), - description: toText(item.description), - visualDescription: toText(item.visualDescription) || undefined, - dangerLevel: toText(item.dangerLevel) || 'medium', - imageSrc: toText(item.imageSrc) || undefined, - sceneNpcIds: toStringArray(item.sceneNpcIds), - sceneNpcNames: [ - ...toStringArray(item.sceneNpcNames), - ...toStringArray(item.npcs, 'name'), - ...toStringArray(item.sceneNpcs, 'name'), - ...toStringArray(item.npcNames), - ], - connections: toRecordArray(item.connections).map((connection) => ({ - targetLandmarkId: toText(connection.targetLandmarkId), - targetLandmarkName: - toText(connection.targetLandmarkName) || - toText(connection.target) || - toText(connection.sceneName), - relativePosition: - toText(connection.relativePosition) || toText(connection.position), - summary: toText(connection.summary) || toText(connection.description), - })), - })) - .filter((entry) => entry.name); - - const landmarkIdByName = new Map( - landmarkEntries.map((landmark) => [landmark.name.trim(), landmark.id] as const), - ); - - return landmarkEntries.map((landmark) => { - const resolvedSceneNpcIds = [ - ...new Set( - [ - ...landmark.sceneNpcIds, - ...landmark.sceneNpcNames - .map((name) => storyNpcIdByName.get(name.trim()) ?? '') - .filter(Boolean), - ].filter(Boolean), - ), - ]; - - return { - id: landmark.id, - name: landmark.name, - description: landmark.description, - visualDescription: landmark.visualDescription, - dangerLevel: landmark.dangerLevel, - imageSrc: landmark.imageSrc, - sceneNpcIds: resolvedSceneNpcIds, - connections: landmark.connections - .map((connection) => ({ - targetLandmarkId: - connection.targetLandmarkId || - landmarkIdByName.get(connection.targetLandmarkName.trim()) || - '', - relativePosition: connection.relativePosition || 'forward', - summary: connection.summary, - })) - .filter((connection) => connection.targetLandmarkId), - }; - }); -} diff --git a/server-node/src/modules/custom-world/runtime-profile/normalizeRole.ts b/server-node/src/modules/custom-world/runtime-profile/normalizeRole.ts deleted file mode 100644 index ff8e5e92..00000000 --- a/server-node/src/modules/custom-world/runtime-profile/normalizeRole.ts +++ /dev/null @@ -1,541 +0,0 @@ -import type { - CharacterBackstoryChapter, - CharacterBackstoryRevealConfig, - CustomWorldGenerationFramework, - CustomWorldGenerationRoleBatchType, - CustomWorldGenerationRoleOutline, - CustomWorldNpc, - CustomWorldPlayableNpc, - CustomWorldProfile, - CustomWorldRoleInitialItem, - CustomWorldRoleProfile, - CustomWorldRoleSkill, -} from '../runtimeTypes.js'; -import { - buildWorldName, - normalizeWorldType, -} from './creatorIntentBridge.js'; -import { - clampCustomWorldAffinity, - clampText, - createEntryId, - MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT, - MIN_CUSTOM_WORLD_STORY_NPC_COUNT, - normalizeInitialAffinity, - normalizeRarity, - normalizeRoleItemCategory, - normalizeTags, - toRecordArray, - toText, -} from './normalizeShared.js'; - -/** - * 工作包 G: - * 把角色相关的 outline/runtime 归一、背景揭示、初始技能与物品 fallback 统一收口, - * 让主编译器只负责装配,不继续内嵌角色画像细节。 - */ - -const AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS = [15, 30, 60, 90] as const; -const DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY = 60; -const DEFAULT_PLAYABLE_INITIAL_AFFINITY = 18; -const DEFAULT_STORY_NPC_INITIAL_AFFINITY = 6; -const DEFAULT_CUSTOM_WORLD_ROLE_SKILL_COUNT = 3; -const DEFAULT_CUSTOM_WORLD_ROLE_INITIAL_ITEM_COUNT = 3; -const CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES = [ - '表层来意', - '旧事裂痕', - '隐藏执念', - '最终底牌', -] as const; - -type CustomWorldRoleFallbackSource = Pick< - CustomWorldRoleProfile, - | 'name' - | 'title' - | 'role' - | 'description' - | 'backstory' - | 'personality' - | 'motivation' - | 'combatStyle' - | 'relationshipHooks' - | 'tags' ->; - -function splitNarrativeSentences(text: string) { - const normalized = text.replace(/\s+/g, ' ').trim(); - if (!normalized) { - return []; - } - const matches = normalized.match(/[^。!?!?]+[。!?!?]?/gu); - return (matches ?? [normalized]).map((item) => item.trim()).filter(Boolean); -} - -function buildFallbackBackstoryReveal( - source: CustomWorldRoleFallbackSource, -): CharacterBackstoryRevealConfig { - const normalizedBackstory = - source.backstory.trim() || `${source.name}对自己的过去始终有所保留。`; - const backstorySentences = splitNarrativeSentences(normalizedBackstory); - const backstoryLead = backstorySentences[0] ?? normalizedBackstory; - const backstoryDetail = - backstorySentences.slice(0, 2).join('') || normalizedBackstory; - const publicSummary = - source.description.trim() || clampText(normalizedBackstory, 42); - const fallbackContents = [ - source.description.trim() || backstoryLead, - backstoryDetail, - source.motivation.trim() - ? `${source.name}真正挂念的,是:${source.motivation.trim()}` - : `${source.name}的选择与“${clampText(backstoryLead, 24)}”直接相关。`, - source.personality.trim() - ? `${source.name}不会轻易说出的底色,是:${source.personality.trim()}` - : `${source.name}仍把最深的筹码藏在过去之中。`, - ]; - - return { - publicSummary, - privateChatUnlockAffinity: DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY, - chapters: AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS.map( - (affinityRequired, index) => - ({ - id: createEntryId( - 'backstory-chapter', - `${source.name}-${CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES[index] ?? index + 1}`, - index, - ), - title: - CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES[index] ?? - `背景片段${index + 1}`, - affinityRequired, - teaser: clampText( - fallbackContents[index] ?? normalizedBackstory, - 22, - ), - content: clampText( - fallbackContents[index] ?? normalizedBackstory, - 72, - ), - contextSnippet: clampText( - `${source.name}的背景正在向“${fallbackContents[index] ?? normalizedBackstory}”这条线索展开。`, - 48, - ), - }) satisfies CharacterBackstoryChapter, - ), - }; -} - -function normalizeBackstoryReveal( - value: unknown, - fallbackSource: CustomWorldRoleFallbackSource, -) { - const fallback = buildFallbackBackstoryReveal(fallbackSource); - if (!value || typeof value !== 'object') { - return fallback; - } - - const item = value as Record; - const rawChapters = toRecordArray(item.chapters); - - return { - publicSummary: toText(item.publicSummary) || fallback.publicSummary, - privateChatUnlockAffinity: - typeof item.privateChatUnlockAffinity === 'number' && - Number.isFinite(item.privateChatUnlockAffinity) - ? clampCustomWorldAffinity(item.privateChatUnlockAffinity) - : fallback.privateChatUnlockAffinity, - chapters: AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS.map( - (defaultAffinity, index) => { - const fallbackChapter = fallback.chapters[index]; - const rawChapter = rawChapters[index]; - return { - id: - (rawChapter && toText(rawChapter.id)) || - fallbackChapter?.id || - `backstory-chapter-${index + 1}`, - title: - (rawChapter && toText(rawChapter.title)) || - fallbackChapter?.title || - `背景片段${index + 1}`, - affinityRequired: - fallbackChapter?.affinityRequired ?? defaultAffinity, - teaser: - (rawChapter && toText(rawChapter.teaser)) || - fallbackChapter?.teaser || - '', - content: - (rawChapter && toText(rawChapter.content)) || - fallbackChapter?.content || - '', - contextSnippet: - (rawChapter && toText(rawChapter.contextSnippet)) || - fallbackChapter?.contextSnippet || - '', - } satisfies CharacterBackstoryChapter; - }, - ), - } satisfies CharacterBackstoryRevealConfig; -} - -function buildFallbackRoleSkills(source: CustomWorldRoleFallbackSource) { - const skillNameSeed = source.title || source.role || source.name || '角色'; - const skillSummarySeed = - source.combatStyle || source.description || `${source.name}善于把握局势。`; - const motivationSeed = - source.motivation || source.personality || source.backstory; - - return [ - { - id: createEntryId('role-skill', `${skillNameSeed}-起手`, 0), - name: `${skillNameSeed}起手`, - summary: clampText(skillSummarySeed, 36), - style: '起手压制', - }, - { - id: createEntryId('role-skill', `${skillNameSeed}-机动`, 1), - name: `${skillNameSeed}变招`, - summary: clampText( - source.personality || `${source.name}习惯在试探中寻找破绽。`, - 36, - ), - style: '机动周旋', - }, - { - id: createEntryId('role-skill', `${skillNameSeed}-底牌`, 2), - name: `${skillNameSeed}底牌`, - summary: clampText( - motivationSeed || `${source.name}会在关键时刻亮出压箱手段。`, - 36, - ), - style: '爆发终结', - }, - ] satisfies CustomWorldRoleSkill[]; -} - -function normalizeRoleSkillList( - value: unknown, - fallbackSource: CustomWorldRoleFallbackSource, -) { - const normalized = toRecordArray(value) - .map((item, index) => { - const name = toText(item.name); - const summary = toText(item.summary) || toText(item.description); - const style = toText(item.style) || toText(item.category) || '常用'; - - return { - id: createEntryId('role-skill', name || style, index), - name, - summary, - style, - } satisfies CustomWorldRoleSkill; - }) - .filter((entry) => entry.name) - .slice(0, DEFAULT_CUSTOM_WORLD_ROLE_SKILL_COUNT); - - return normalized.length > 0 - ? normalized - : buildFallbackRoleSkills(fallbackSource); -} - -function buildFallbackRoleInitialItems(source: CustomWorldRoleFallbackSource) { - const itemNameSeed = source.title || source.role || source.name || '角色'; - return [ - { - id: createEntryId('role-item', `${itemNameSeed}-1`, 0), - name: `${itemNameSeed}常备武具`, - category: '武器', - quantity: 1, - rarity: 'rare', - description: clampText( - source.combatStyle || `${source.name}随身携带的主要作战物件。`, - 36, - ), - tags: normalizeTags(source.tags, ['战斗', '随身']), - }, - { - id: createEntryId('role-item', `${itemNameSeed}-2`, 1), - name: `${itemNameSeed}补给包`, - category: '消耗品', - quantity: 2, - rarity: 'uncommon', - description: clampText( - source.personality || `${source.name}为了长期行动准备的基础补给。`, - 36, - ), - tags: normalizeTags(source.relationshipHooks, ['补给', '行动']), - }, - { - id: createEntryId('role-item', `${itemNameSeed}-3`, 2), - name: `${itemNameSeed}私人物件`, - category: '专属物品', - quantity: 1, - rarity: 'rare', - description: clampText( - source.backstory || - source.motivation || - `${source.name}不愿随意交出的信物。`, - 36, - ), - tags: normalizeTags( - [...source.tags, ...source.relationshipHooks], - ['信物', '线索'], - ), - }, - ] satisfies CustomWorldRoleInitialItem[]; -} - -function normalizeRoleInitialItemList( - value: unknown, - fallbackSource: CustomWorldRoleFallbackSource, -) { - const normalized = toRecordArray(value) - .map((item, index) => { - const name = toText(item.name); - return { - id: createEntryId('role-item', name, index), - name, - category: normalizeRoleItemCategory(item.category), - quantity: - typeof item.quantity === 'number' && Number.isFinite(item.quantity) - ? Math.max(1, Math.min(99, Math.round(item.quantity))) - : 1, - rarity: normalizeRarity(item.rarity, 'rare'), - description: toText(item.description), - tags: normalizeTags(item.tags), - } satisfies CustomWorldRoleInitialItem; - }) - .filter((entry) => entry.name) - .slice(0, DEFAULT_CUSTOM_WORLD_ROLE_INITIAL_ITEM_COUNT); - - return normalized.length > 0 - ? normalized - : buildFallbackRoleInitialItems(fallbackSource); -} - -function normalizeRoleOutlineList( - value: unknown, - options: { - titleFallback: string; - defaultAffinity: number; - maxCount?: number; - }, -) { - const normalized = toRecordArray(value) - .map((item) => { - const name = toText(item.name); - const title = - toText(item.title) || toText(item.role) || options.titleFallback; - const role = toText(item.role) || title; - const relationshipHooks = normalizeTags( - item.relationshipHooks, - normalizeTags(item.tags), - ); - - return { - name, - title, - role, - description: - toText(item.description) || - clampText(`${name || title}在世界中以${role}身份活动。`, 36), - visualDescription: toText(item.visualDescription) || undefined, - actionDescription: toText(item.actionDescription) || undefined, - sceneVisualDescription: toText(item.sceneVisualDescription) || undefined, - initialAffinity: normalizeInitialAffinity( - item.initialAffinity, - options.defaultAffinity, - ), - relationshipHooks, - tags: normalizeTags(item.tags, relationshipHooks), - } satisfies CustomWorldGenerationRoleOutline; - }) - .filter((entry) => entry.name); - - return typeof options.maxCount === 'number' - ? normalized.slice(0, options.maxCount) - : normalized; -} - -export function normalizeCustomWorldGenerationRoleOutlineBatch( - raw: unknown, - roleType: CustomWorldGenerationRoleBatchType, -) { - const item = - raw && typeof raw === 'object' ? (raw as Record) : {}; - const key = roleType === 'playable' ? 'playableNpcs' : 'storyNpcs'; - - return normalizeRoleOutlineList(item[key], { - titleFallback: '未定称号', - defaultAffinity: - roleType === 'playable' - ? DEFAULT_PLAYABLE_INITIAL_AFFINITY - : DEFAULT_STORY_NPC_INITIAL_AFFINITY, - }); -} - -export function normalizeCustomWorldGenerationFrameworkRoles(params: { - raw: Record; - fallback: CustomWorldProfile; - settingText: string; -}) { - const worldSignalText = [ - params.settingText, - toText(params.raw.subtitle), - toText(params.raw.summary), - toText(params.raw.tone), - toText(params.raw.playerGoal), - ].join(' '); - const templateWorldType = normalizeWorldType( - params.raw.templateWorldType, - worldSignalText, - ); - const name = - toText(params.raw.name) || buildWorldName(params.settingText, templateWorldType); - - return { - name, - templateWorldType, - playableNpcs: normalizeRoleOutlineList(params.raw.playableNpcs, { - titleFallback: '未定称号', - defaultAffinity: DEFAULT_PLAYABLE_INITIAL_AFFINITY, - maxCount: MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT, - }), - storyNpcs: normalizeRoleOutlineList(params.raw.storyNpcs, { - titleFallback: '未定称号', - defaultAffinity: DEFAULT_STORY_NPC_INITIAL_AFFINITY, - maxCount: MIN_CUSTOM_WORLD_STORY_NPC_COUNT, - }), - campFallbackProfile: { - name, - summary: toText(params.raw.summary) || params.fallback.summary, - tone: toText(params.raw.tone) || params.fallback.tone, - playerGoal: toText(params.raw.playerGoal) || params.fallback.playerGoal, - settingText: params.settingText.trim(), - }, - }; -} - -export function buildCustomWorldRawProfileRolesFromFramework( - framework: CustomWorldGenerationFramework, -) { - return { - playableNpcs: framework.playableNpcs.map((npc) => ({ - name: npc.name, - title: npc.title, - role: npc.role, - description: npc.description, - visualDescription: npc.visualDescription, - actionDescription: npc.actionDescription, - sceneVisualDescription: npc.sceneVisualDescription, - initialAffinity: npc.initialAffinity, - relationshipHooks: [...npc.relationshipHooks], - tags: [...npc.tags], - })), - storyNpcs: framework.storyNpcs.map((npc) => ({ - name: npc.name, - title: npc.title, - role: npc.role, - description: npc.description, - visualDescription: npc.visualDescription, - actionDescription: npc.actionDescription, - sceneVisualDescription: npc.sceneVisualDescription, - initialAffinity: npc.initialAffinity, - relationshipHooks: [...npc.relationshipHooks], - tags: [...npc.tags], - })), - }; -} - -function normalizeRoleProfile( - item: Record, - index: number, - options: { - idPrefix: 'playable-npc' | 'story-npc'; - titleFallback: string; - defaultAffinity: number; - }, -) { - const name = toText(item.name); - const title = - toText(item.title) || toText(item.role) || options.titleFallback; - const role = toText(item.role) || title; - const relationshipHooks = normalizeTags( - item.relationshipHooks, - normalizeTags(item.tags), - ); - const normalizedRole = { - id: toText(item.id) || createEntryId(options.idPrefix, name, index), - name, - title, - role, - description: toText(item.description), - visualDescription: toText(item.visualDescription) || undefined, - actionDescription: toText(item.actionDescription) || undefined, - sceneVisualDescription: toText(item.sceneVisualDescription) || undefined, - backstory: toText(item.backstory), - personality: toText(item.personality), - motivation: toText(item.motivation) || toText(item.description), - combatStyle: toText(item.combatStyle), - initialAffinity: normalizeInitialAffinity( - item.initialAffinity, - options.defaultAffinity, - ), - relationshipHooks, - tags: normalizeTags(item.tags, relationshipHooks), - }; - - return { - ...normalizedRole, - backstoryReveal: normalizeBackstoryReveal( - item.backstoryReveal, - normalizedRole, - ), - skills: normalizeRoleSkillList(item.skills, normalizedRole), - initialItems: normalizeRoleInitialItemList(item.initialItems, normalizedRole), - imageSrc: toText(item.imageSrc) || undefined, - generatedVisualAssetId: toText(item.generatedVisualAssetId) || undefined, - generatedAnimationSetId: - toText(item.generatedAnimationSetId) || undefined, - animationMap: - item.animationMap && typeof item.animationMap === 'object' - ? (item.animationMap as Record) - : undefined, - narrativeProfile: - item.narrativeProfile && typeof item.narrativeProfile === 'object' - ? (item.narrativeProfile as CustomWorldRoleProfile['narrativeProfile']) - : null, - }; -} - -export function normalizePlayableNpcList(value: unknown) { - return toRecordArray(value) - .map((item, index) => ({ - ...normalizeRoleProfile(item, index, { - idPrefix: 'playable-npc', - titleFallback: '未定称号', - defaultAffinity: DEFAULT_PLAYABLE_INITIAL_AFFINITY, - }), - templateCharacterId: toText(item.templateCharacterId) || undefined, - })) - .filter((entry) => entry.name) - .slice(0, MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT); -} - -export function normalizeStoryNpcList(value: unknown) { - return toRecordArray(value) - .map( - (item, index) => - ({ - ...normalizeRoleProfile(item, index, { - idPrefix: 'story-npc', - titleFallback: '未定称号', - defaultAffinity: DEFAULT_STORY_NPC_INITIAL_AFFINITY, - }), - visual: - item.visual && typeof item.visual === 'object' - ? (item.visual as Record) - : undefined, - }) satisfies CustomWorldNpc, - ) - .filter((entry) => entry.name); -} diff --git a/server-node/src/modules/custom-world/runtime-profile/normalizeSceneChapter.ts b/server-node/src/modules/custom-world/runtime-profile/normalizeSceneChapter.ts deleted file mode 100644 index 08d1bad3..00000000 --- a/server-node/src/modules/custom-world/runtime-profile/normalizeSceneChapter.ts +++ /dev/null @@ -1,123 +0,0 @@ -import type { SceneActBlueprint, SceneChapterBlueprint } from '../runtimeTypes.js'; -import { createEntryId, toRecordArray, toStringArray, toText } from './normalizeShared.js'; - -/** - * 工作包 G: - * 分幕与场景章节 blueprint 归一独立收口,让结果预览编译层只消费稳定的章节结构。 - */ - -const SCENE_ACT_STAGES = new Set([ - 'opening', - 'expansion', - 'turning_point', - 'climax', - 'aftermath', -]); -const SCENE_ACT_ADVANCE_RULES = new Set([ - 'after_primary_contact', - 'after_active_step_complete', - 'after_chapter_resolution', -]); - -function normalizeSceneActStageCoverage(value: unknown) { - const stageCoverage = Array.isArray(value) - ? value - .filter((entry): entry is string => typeof entry === 'string') - .map((entry) => entry.trim()) - .filter((entry): entry is SceneActBlueprint['stageCoverage'][number] => - SCENE_ACT_STAGES.has(entry as never), - ) - : []; - - return [...new Set(stageCoverage)]; -} - -function normalizeSceneActBlueprint( - value: unknown, - index: number, - sceneId: string, -): SceneActBlueprint | null { - const item = - value && typeof value === 'object' - ? (value as Record) - : null; - if (!item) { - return null; - } - - const encounterNpcIds = toStringArray(item.encounterNpcIds); - const stageCoverage = normalizeSceneActStageCoverage(item.stageCoverage); - const advanceRule = toText(item.advanceRule); - const title = toText(item.title); - const summary = toText(item.summary); - - if (!title && !summary && encounterNpcIds.length === 0) { - return null; - } - - return { - id: - toText(item.id) || - createEntryId(`saved-scene-act-${sceneId}`, title || sceneId, index), - sceneId, - title: title || `第 ${index + 1} 幕`, - summary: summary || title || `围绕${sceneId}继续推进`, - stageCoverage: - stageCoverage.length > 0 - ? stageCoverage - : index === 0 - ? ['opening'] - : ['climax', 'aftermath'], - backgroundImageSrc: toText(item.backgroundImageSrc) || undefined, - backgroundAssetId: toText(item.backgroundAssetId) || undefined, - encounterNpcIds, - primaryNpcId: toText(item.primaryNpcId) || encounterNpcIds[0] || '', - linkedThreadIds: toStringArray(item.linkedThreadIds), - advanceRule: SCENE_ACT_ADVANCE_RULES.has(advanceRule as never) - ? (advanceRule as SceneActBlueprint['advanceRule']) - : 'after_active_step_complete', - actGoal: toText(item.actGoal), - transitionHook: toText(item.transitionHook), - }; -} - -export function normalizeSceneChapterBlueprints(value: unknown) { - if (!Array.isArray(value)) { - return null; - } - - const normalized = value - .filter( - (entry): entry is Record => - Boolean(entry) && typeof entry === 'object' && !Array.isArray(entry), - ) - .map((entry, index) => { - const sceneId = toText(entry.sceneId); - if (!sceneId) { - return null; - } - - const acts = Array.isArray(entry.acts) - ? entry.acts - .map((act, actIndex) => - normalizeSceneActBlueprint(act, actIndex, sceneId), - ) - .filter((act): act is SceneActBlueprint => Boolean(act)) - : []; - - return { - id: - toText(entry.id) || - createEntryId('saved-scene-chapter', sceneId, index), - sceneId, - title: toText(entry.title) || toText(entry.sceneName) || sceneId, - summary: toText(entry.summary), - linkedThreadIds: toStringArray(entry.linkedThreadIds), - linkedLandmarkIds: toStringArray(entry.linkedLandmarkIds), - acts, - } satisfies SceneChapterBlueprint; - }) - .filter((entry): entry is SceneChapterBlueprint => Boolean(entry)); - - return normalized.length > 0 ? normalized : null; -} diff --git a/server-node/src/modules/custom-world/runtime-profile/normalizeShared.ts b/server-node/src/modules/custom-world/runtime-profile/normalizeShared.ts deleted file mode 100644 index a67f613c..00000000 --- a/server-node/src/modules/custom-world/runtime-profile/normalizeShared.ts +++ /dev/null @@ -1,248 +0,0 @@ -import type { - CustomWorldCoverProfile, - CustomWorldCoverSourceType, - CustomWorldItem, - CustomWorldPlayableNpc, -} from '../runtimeTypes.js'; - -/** - * 工作包 G: - * 把 runtime profile 编译流程里的通用常量、基础文本归一和通用小型归一逻辑收口到共享模块, - * 让角色、地点、场景章节和主编译入口都不再重复维护这些基础能力。 - */ - -const MIN_CUSTOM_WORLD_AFFINITY = -40; -const MAX_CUSTOM_WORLD_AFFINITY = 90; -const CUSTOM_WORLD_RARITIES = [ - 'common', - 'uncommon', - 'rare', - 'epic', - 'legendary', -] as const; -const CUSTOM_WORLD_ROLE_ITEM_CATEGORIES = [ - '武器', - '护甲', - '饰品', - '消耗品', - '材料', - '稀有品', - '专属物品', - '专属物', -] as const; - -export const MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT = 5; -export const MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT = 30; -export const MIN_CUSTOM_WORLD_LANDMARK_COUNT = 10; -export const MIN_CUSTOM_WORLD_STORY_NPC_COUNT = Math.max( - 0, - MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT - MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT, -); - -export const PLAYABLE_TEMPLATE_CHARACTER_IDS = [ - 'sword-princess', - 'archer-hero', - 'girl-hero', - 'punch-hero', - 'fighter-4', -] as const; - -export function toText(value: unknown) { - return typeof value === 'string' ? value.trim() : ''; -} - -export function toFiniteInteger(value: unknown) { - return typeof value === 'number' && Number.isFinite(value) - ? Math.round(value) - : undefined; -} - -export function toRecord(value: unknown) { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : null; -} - -export function toRecordArray(value: unknown) { - return Array.isArray(value) - ? (value.filter((item) => item && typeof item === 'object') as Array< - Record - >) - : []; -} - -export function toStringArray(value: unknown, nestedKey?: string) { - if (!Array.isArray(value)) { - return []; - } - - return value - .map((item) => { - if (typeof item === 'string') { - return item.trim(); - } - if (nestedKey && item && typeof item === 'object') { - return toText((item as Record)[nestedKey]); - } - return ''; - }) - .filter(Boolean); -} - -export function normalizeTags(value: unknown, fallbackTags: string[] = []) { - const tags = Array.isArray(value) - ? value.map((item) => toText(item)).filter(Boolean) - : []; - return [ - ...new Set((tags.length > 0 ? tags : fallbackTags).filter(Boolean)), - ].slice(0, 5); -} - -export function clampText(value: unknown, maxLength: number) { - const normalized = toText(value).replace(/\s+/g, ' '); - if (!normalized) { - return ''; - } - if (normalized.length <= maxLength) { - return normalized; - } - return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}…`; -} - -export function slugify(value: string) { - const ascii = value - .toLowerCase() - .replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-') - .replace(/^-+|-+$/g, ''); - - return ascii ? ascii.slice(0, 24) : 'entry'; -} - -export function createEntryId(prefix: string, label: string, index: number) { - return `${prefix}-${slugify(label || `${prefix}-${index + 1}`)}-${index + 1}`; -} - -export function clampCustomWorldAffinity(value: number) { - return Math.max( - MIN_CUSTOM_WORLD_AFFINITY, - Math.min(MAX_CUSTOM_WORLD_AFFINITY, Math.round(value)), - ); -} - -export function normalizeInitialAffinity(value: unknown, fallback: number) { - return typeof value === 'number' && Number.isFinite(value) - ? clampCustomWorldAffinity(value) - : fallback; -} - -export function normalizeRarity( - value: unknown, - fallback: (typeof CUSTOM_WORLD_RARITIES)[number] = 'rare', -) { - const rarity = toText(value).toLowerCase(); - return CUSTOM_WORLD_RARITIES.includes( - rarity as (typeof CUSTOM_WORLD_RARITIES)[number], - ) - ? (rarity as (typeof CUSTOM_WORLD_RARITIES)[number]) - : fallback; -} - -export function normalizeRoleItemCategory(value: unknown, fallback = '材料') { - const category = toText(value); - if ( - (CUSTOM_WORLD_ROLE_ITEM_CATEGORIES as readonly string[]).includes(category) - ) { - return category === '专属物' ? '专属物品' : category; - } - if (/武器|刀|剑|弓|枪|炮|锤/u.test(category)) return '武器'; - if (/甲|护|盾|衣|袍/u.test(category)) return '护甲'; - if (/饰|符|佩|坠|珠|印/u.test(category)) return '饰品'; - if (/药|食|补给|瓶|卷轴/u.test(category)) return '消耗品'; - if (/材|矿|木|石|鳞|骨/u.test(category)) return '材料'; - if (/秘|钥|图|册|录|卷/u.test(category)) return '稀有品'; - if (/信物|遗物|核心|母印|真符/u.test(category)) return '专属物品'; - return fallback; -} - -export function normalizeCustomWorldCoverCharacterRoleIds( - value: unknown, - playableNpcs: Array>, -) { - const availableIds = new Set( - playableNpcs.map((entry) => entry.id.trim()).filter(Boolean), - ); - const selectedIds = Array.isArray(value) - ? [ - ...new Set( - value - .map((entry) => toText(entry)) - .filter((entry) => entry && availableIds.has(entry)), - ), - ].slice(0, 3) - : []; - - if (selectedIds.length > 0) { - return selectedIds; - } - - return playableNpcs - .map((entry) => entry.id.trim()) - .filter(Boolean) - .slice(0, 3); -} - -export function buildDefaultCustomWorldCover( - playableNpcs: Array>, -): CustomWorldCoverProfile { - return { - sourceType: 'default' as const, - imageSrc: null, - characterRoleIds: normalizeCustomWorldCoverCharacterRoleIds( - undefined, - playableNpcs, - ), - }; -} - -export function normalizeCustomWorldCover( - value: unknown, - playableNpcs: Array>, -): CustomWorldCoverProfile { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return buildDefaultCustomWorldCover(playableNpcs); - } - - const item = value as Record; - const sourceType: CustomWorldCoverSourceType = - item.sourceType === 'uploaded' || item.sourceType === 'generated' - ? item.sourceType - : 'default'; - const imageSrc = toText(item.imageSrc) || null; - - if (sourceType !== 'default' && imageSrc) { - return { - sourceType, - imageSrc, - characterRoleIds: [], - }; - } - - return buildDefaultCustomWorldCover(playableNpcs); -} - -export function normalizeItemList(value: unknown) { - return toRecordArray(value) - .map((item, index) => { - const name = toText(item.name); - const category = toText(item.category); - return { - id: toText(item.id) || createEntryId('item', name, index), - name, - category, - rarity: normalizeRarity(item.rarity, 'rare'), - description: toText(item.description), - tags: normalizeTags(item.tags), - } satisfies CustomWorldItem; - }) - .filter((entry) => entry.name && entry.category); -} diff --git a/server-node/src/modules/custom-world/runtimeProfile.test.ts b/server-node/src/modules/custom-world/runtimeProfile.test.ts deleted file mode 100644 index d39bf690..00000000 --- a/server-node/src/modules/custom-world/runtimeProfile.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { - buildCompiledCustomWorldProfile, - validateGeneratedCustomWorldProfile, -} from './runtimeProfile.js'; - -function createPlayableNpc(index: number) { - return { - name: `角色${index + 1}`, - title: `称号${index + 1}`, - role: `身份${index + 1}`, - description: `角色描述${index + 1}`, - backstory: `角色背景${index + 1}`, - personality: `角色性格${index + 1}`, - motivation: `角色动机${index + 1}`, - combatStyle: `战斗风格${index + 1}`, - initialAffinity: 18, - relationshipHooks: [`接触点${index + 1}`], - tags: [`标签${index + 1}`], - }; -} - -function createStoryNpc(index: number) { - return { - name: `场景角色${index + 1}`, - title: `头衔${index + 1}`, - role: `职责${index + 1}`, - description: `场景角色描述${index + 1}`, - backstory: `场景角色背景${index + 1}`, - personality: `场景角色性格${index + 1}`, - motivation: `场景角色动机${index + 1}`, - combatStyle: `场景角色战斗风格${index + 1}`, - initialAffinity: index % 4 === 0 ? -10 : 6, - relationshipHooks: [`关系${index + 1}`], - tags: [`线索${index + 1}`], - }; -} - -function createLandmark(index: number, storyNpcNames: string[]) { - return { - name: `场景${index + 1}`, - description: `场景描述${index + 1}`, - dangerLevel: 'medium', - sceneNpcNames: storyNpcNames, - connections: [ - { - targetLandmarkName: `场景${((index + 1) % 10) + 1}`, - relativePosition: 'forward', - summary: '沿主路前行', - }, - { - targetLandmarkName: `场景${((index + 9) % 10) + 1}`, - relativePosition: 'back', - summary: '回身可返', - }, - ], - }; -} - -test('buildCompiledCustomWorldProfile preserves runtime-critical generated fields on the server', () => { - const storyNpcs = Array.from({ length: 25 }, (_, index) => - createStoryNpc(index), - ); - const profile = buildCompiledCustomWorldProfile( - { - id: 'generated-world', - name: '测试世界', - subtitle: '副标题', - summary: '概述', - tone: '紧张、潮湿', - playerGoal: '先站稳,再查明真相', - templateWorldType: 'WUXIA', - majorFactions: ['守灯会', '沉船商盟'], - coreConflicts: ['航道解释权正在争夺'], - creatorIntent: { - sourceMode: 'card', - rawSettingText: '', - worldHook: '一个被潮雾反复切开的边境世界。', - themeKeywords: ['潮雾', '边境'], - toneDirectives: ['紧张', '潮湿'], - playerPremise: '玩家是前巡夜人。', - openingSituation: '刚进城就卷入旧案。', - coreConflicts: ['旧案名单再次出现'], - keyFactions: [], - keyCharacters: [ - { - id: 'creator-character-1', - name: '沈砺', - role: '灰炬向导', - publicMask: '看起来只是个带路人', - hiddenHook: '一直在查旧撤离线', - relationToPlayer: '会先怀疑玩家身份', - notes: '', - locked: true, - }, - ], - keyLandmarks: [], - iconicElements: ['裂潮灯塔'], - forbiddenDirectives: ['不要出现现代枪械'], - }, - playableNpcs: Array.from({ length: 5 }, (_, index) => - createPlayableNpc(index), - ), - storyNpcs, - landmarks: Array.from({ length: 10 }, (_, index) => - createLandmark(index, [ - storyNpcs[index % storyNpcs.length]?.name ?? `场景角色${index + 1}`, - storyNpcs[(index + 1) % storyNpcs.length]?.name ?? - `场景角色${index + 2}`, - storyNpcs[(index + 2) % storyNpcs.length]?.name ?? - `场景角色${index + 3}`, - ]), - ), - }, - '一个被潮雾反复切开的边境世界。', - ); - - assert.equal(profile.playableNpcs.length, 5); - assert.equal(profile.storyNpcs.length, 25); - assert.equal(profile.landmarks.length, 10); - assert.equal(profile.playableNpcs[0]?.templateCharacterId, 'sword-princess'); - assert.ok(profile.playableNpcs[0]?.attributeProfile); - assert.ok(profile.storyNpcs[0]?.attributeProfile); - assert.equal(profile.scenarioPackId, 'scenario-pack:测试世界'); - assert.equal(profile.campaignPackId, 'campaign-pack:测试世界'); - assert.equal(profile.creatorIntent?.keyCharacters[0]?.name, '沈砺'); - assert.ok(profile.anchorPack?.lockedAnchorIds.includes('creator-character-1')); - assert.ok(profile.landmarks.every((landmark) => landmark.sceneNpcIds.length >= 3)); - - validateGeneratedCustomWorldProfile(profile); -}); diff --git a/server-node/src/modules/custom-world/runtimeProfile.ts b/server-node/src/modules/custom-world/runtimeProfile.ts deleted file mode 100644 index f5debeb6..00000000 --- a/server-node/src/modules/custom-world/runtimeProfile.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * 兼容期 façade: - * 旧调用暂时继续从 runtimeProfile.ts 导入,避免在工作包 G 首轮落地时放大迁移范围。 - * 新代码应逐步改走 runtime-profile/ 目录入口。 - */ -export * from './runtime-profile/index.js'; diff --git a/server-node/src/modules/custom-world/runtimeTypes.ts b/server-node/src/modules/custom-world/runtimeTypes.ts deleted file mode 100644 index 241e5ef4..00000000 --- a/server-node/src/modules/custom-world/runtimeTypes.ts +++ /dev/null @@ -1,439 +0,0 @@ -export type WorldType = 'WUXIA' | 'XIANXIA' | 'CUSTOM'; - -export type CustomWorldGenerationMode = 'fast' | 'full'; -export type CustomWorldGenerationStatus = 'key_only' | 'complete'; -export type CustomWorldCoverSourceType = 'default' | 'uploaded' | 'generated'; - -export interface CustomWorldCoverProfile { - sourceType: CustomWorldCoverSourceType; - imageSrc?: string | null; - characterRoleIds?: string[]; -} - -export interface CreatorFactionSeed { - id: string; - name: string; - publicGoal: string; - tension: string; - notes: string; - locked?: boolean; -} - -export interface CreatorCharacterSeed { - id: string; - name: string; - role: string; - publicMask: string; - hiddenHook: string; - relationToPlayer: string; - notes: string; - locked?: boolean; -} - -export interface CreatorLandmarkSeed { - id: string; - name: string; - purpose: string; - mood: string; - secret: string; - locked?: boolean; -} - -export interface ActorAnchor { - id: string; - name: string; - summary: string; -} - -export interface LandmarkAnchor { - id: string; - name: string; - summary: string; -} - -export interface CustomWorldCreatorIntent { - sourceMode: 'freeform' | 'card'; - rawSettingText: string; - worldHook: string; - themeKeywords: string[]; - toneDirectives: string[]; - playerPremise: string; - openingSituation: string; - coreConflicts: string[]; - keyFactions: CreatorFactionSeed[]; - keyCharacters: CreatorCharacterSeed[]; - keyLandmarks: CreatorLandmarkSeed[]; - iconicElements: string[]; - forbiddenDirectives: string[]; -} - -export interface CustomWorldAnchorPack { - worldSummary: string; - creatorIntentSummary: string; - lockedAnchorIds: string[]; - keyConflictSummaries: string[]; - keyFactionSummaries: string[]; - keyCharacterAnchors: ActorAnchor[]; - keyLandmarkAnchors: LandmarkAnchor[]; - motifDirectives: string[]; -} - -export interface CustomWorldLockState { - worldLockedFields: string[]; - lockedCharacterIds: string[]; - lockedLandmarkIds: string[]; - lockedConflictIds: string[]; - lockedFactionIds: string[]; -} - -export interface WorldAttributeSlot { - slotId: string; - name: string; - definition: string; - positiveSignals: string[]; - negativeSignals: string[]; - combatUseText: string; - socialUseText: string; - explorationUseText: string; -} - -export interface WorldAttributeSchema { - id: string; - worldId: string; - schemaVersion: number; - schemaName: string; - generatedFrom: { - worldType: WorldType; - worldName: string; - settingSummary: string; - tone: string; - conflictCore: string; - }; - slots: WorldAttributeSlot[]; -} - -export type AttributeVector = Record; - -export interface RoleAttributeEvidence { - slotId: string; - reason: string; -} - -export interface RoleAttributeProfile { - schemaId: string; - values: AttributeVector; - topTraits: string[]; - hiddenTraits?: string[]; - evidence: RoleAttributeEvidence[]; -} - -export interface CharacterBackstoryChapter { - id: string; - title: string; - affinityRequired: number; - teaser: string; - content: string; - contextSnippet: string; -} - -export interface CharacterBackstoryRevealConfig { - publicSummary: string; - privateChatUnlockAffinity: number; - chapters: CharacterBackstoryChapter[]; -} - -export interface ActorNarrativeProfile { - publicMask: string; - firstContactMask: string; - visibleLine: string; - hiddenLine: string; - contradiction: string; - debtOrBurden: string; - taboo: string; - immediatePressure: string; - relatedThreadIds: string[]; - relatedScarIds: string[]; - reactionHooks: string[]; -} - -export interface CustomWorldRoleSkill { - id: string; - name: string; - summary: string; - style: string; - actionPromptText?: string; - actionPreviewConfig?: Record; -} - -export interface CustomWorldRoleInitialItem { - id: string; - name: string; - category: string; - quantity: number; - rarity: string; - description: string; - tags: string[]; -} - -export interface CustomWorldRoleProfile { - id: string; - name: string; - title: string; - role: string; - description: string; - visualDescription?: string; - actionDescription?: string; - sceneVisualDescription?: string; - backstory: string; - personality: string; - motivation: string; - combatStyle: string; - initialAffinity: number; - relationshipHooks: string[]; - tags: string[]; - backstoryReveal: CharacterBackstoryRevealConfig; - skills: CustomWorldRoleSkill[]; - initialItems: CustomWorldRoleInitialItem[]; - imageSrc?: string; - generatedVisualAssetId?: string; - generatedAnimationSetId?: string; - animationMap?: Record; - attributeProfile?: RoleAttributeProfile; - narrativeProfile?: ActorNarrativeProfile | null; -} - -export interface CustomWorldPlayableNpc extends CustomWorldRoleProfile { - templateCharacterId?: string; -} - -export interface CustomWorldNpc extends CustomWorldRoleProfile { - visual?: Record; -} - -export interface CustomWorldItem { - id: string; - name: string; - category: string; - rarity: string; - description: string; - tags: string[]; -} - -export interface CustomWorldSceneConnection { - targetLandmarkId: string; - relativePosition: string; - summary: string; -} - -export type SceneActStage = - | 'opening' - | 'expansion' - | 'turning_point' - | 'climax' - | 'aftermath'; - -export type SceneActAdvanceRule = - | 'after_primary_contact' - | 'after_active_step_complete' - | 'after_chapter_resolution'; - -export interface SceneActBlueprint { - id: string; - sceneId: string; - title: string; - summary: string; - stageCoverage: SceneActStage[]; - backgroundImageSrc?: string | null; - backgroundAssetId?: string | null; - encounterNpcIds: string[]; - primaryNpcId: string; - linkedThreadIds: string[]; - advanceRule: SceneActAdvanceRule; - actGoal: string; - transitionHook: string; -} - -export interface SceneChapterBlueprint { - id: string; - sceneId: string; - title: string; - summary: string; - linkedThreadIds: string[]; - linkedLandmarkIds: string[]; - acts: SceneActBlueprint[]; -} - -export interface CustomWorldCampScene { - id: string; - name: string; - description: string; - visualDescription?: string; - dangerLevel: string; - imageSrc?: string; - sceneNpcIds: string[]; - connections: CustomWorldSceneConnection[]; - narrativeResidues?: - | Array<{ - summary?: string; - changeHint?: string; - hiddenTruth?: string; - }> - | null; -} - -export interface CustomWorldLandmark { - id: string; - name: string; - description: string; - visualDescription?: string; - dangerLevel: string; - imageSrc?: string; - sceneNpcIds: string[]; - connections: CustomWorldSceneConnection[]; - narrativeResidues?: - | Array<{ - summary?: string; - changeHint?: string; - hiddenTruth?: string; - }> - | null; -} - -export interface ThemePack { - id: string; - displayName: string; - toneRange: string[]; - institutionLexicon: string[]; - tabooLexicon: string[]; - artifactClasses: string[]; - actorArchetypes: string[]; - conflictForms: string[]; - clueForms: string[]; - namingPatterns: string[]; - revealStyles: string[]; -} - -export interface StoryThread { - id: string; - title: string; - visibility: 'visible' | 'hidden'; - summary: string; - conflictType: string; - stakes: string; - involvedFactionIds: string[]; - involvedActorIds: string[]; - relatedLocationIds: string[]; -} - -export interface StoryScar { - id: string; - title: string; - pastEvent: string; - publicResidue: string; - hiddenTruth: string; - relatedActorIds: string[]; - relatedLocationIds: string[]; -} - -export interface StoryMotif { - id: string; - label: string; - semanticRole: string; - lexicalHints: string[]; -} - -export interface WorldStoryGraph { - visibleThreads: StoryThread[]; - hiddenThreads: StoryThread[]; - scars: StoryScar[]; - motifs: StoryMotif[]; -} - -export interface CustomWorldProfile { - id: string; - settingText: string; - name: string; - subtitle: string; - summary: string; - tone: string; - playerGoal: string; - cover?: CustomWorldCoverProfile | null; - templateWorldType: WorldType; - compatibilityTemplateWorldType?: WorldType | null; - majorFactions: string[]; - coreConflicts: string[]; - attributeSchema: WorldAttributeSchema; - playableNpcs: CustomWorldPlayableNpc[]; - storyNpcs: CustomWorldNpc[]; - items: CustomWorldItem[]; - camp?: CustomWorldCampScene | null; - landmarks: CustomWorldLandmark[]; - themePack?: ThemePack | null; - storyGraph?: WorldStoryGraph | null; - knowledgeFacts?: Array> | null; - threadContracts?: Array> | null; - sceneChapterBlueprints?: SceneChapterBlueprint[] | null; - anchorContent?: Record | null; - creatorIntent?: CustomWorldCreatorIntent | null; - anchorPack?: CustomWorldAnchorPack | null; - lockState?: CustomWorldLockState | null; - ownedSettingLayers?: Record | null; - generationMode?: CustomWorldGenerationMode | null; - generationStatus?: CustomWorldGenerationStatus | null; - scenarioPackId?: string | null; - campaignPackId?: string | null; -} - -export interface CustomWorldGenerationRoleOutline { - name: string; - title: string; - role: string; - description: string; - visualDescription?: string; - actionDescription?: string; - sceneVisualDescription?: string; - initialAffinity: number; - relationshipHooks: string[]; - tags: string[]; -} - -export interface CustomWorldGenerationLandmarkConnectionOutline { - targetLandmarkName: string; - relativePosition: string; - summary: string; -} - -export interface CustomWorldGenerationLandmarkOutline { - name: string; - description: string; - visualDescription?: string; - dangerLevel: string; - sceneNpcNames: string[]; - connections: CustomWorldGenerationLandmarkConnectionOutline[]; -} - -export interface CustomWorldGenerationCampOutline { - name: string; - description: string; - dangerLevel: string; -} - -export interface CustomWorldGenerationFramework { - settingText: string; - name: string; - subtitle: string; - summary: string; - tone: string; - playerGoal: string; - templateWorldType: WorldType; - compatibilityTemplateWorldType: WorldType; - majorFactions: string[]; - coreConflicts: string[]; - camp: CustomWorldGenerationCampOutline; - playableNpcs: CustomWorldGenerationRoleOutline[]; - storyNpcs: CustomWorldGenerationRoleOutline[]; - landmarks: CustomWorldGenerationLandmarkOutline[]; -} - -export type CustomWorldGenerationRoleBatchType = 'playable' | 'story'; -export type CustomWorldGenerationRoleBatchStage = 'narrative' | 'dossier'; diff --git a/server-node/src/modules/editor/editorRoutes.ts b/server-node/src/modules/editor/editorRoutes.ts deleted file mode 100644 index 7108258f..00000000 --- a/server-node/src/modules/editor/editorRoutes.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'; -import path from 'node:path'; - -import { Router } from 'express'; - -import type { AppConfig } from '../../config.js'; -import { badRequest, notFound } from '../../errors.js'; -import { asyncHandler } from '../../http.js'; -import { routeMeta } from '../../middleware/routeMeta.js'; - -const EDITOR_JSON_RESOURCE_FILES = { - 'item-overrides': 'src/data/itemOverrides.json', - 'npc-visual-overrides': 'src/data/npcVisualOverrides.json', - 'npc-layout-config': 'src/data/npcLayoutConfig.json', - 'character-overrides': 'src/data/characterOverrides.json', - 'monster-overrides': 'src/data/monsterOverrides.json', - 'scene-overrides': 'src/data/sceneOverrides.json', - 'scene-npc-overrides': 'src/data/sceneNpcOverrides.json', - 'state-function-overrides': 'src/data/stateFunctionOverrides.json', -} as const; - -type EditorJsonResourceId = keyof typeof EDITOR_JSON_RESOURCE_FILES; - -function isEditorJsonPayload(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); -} - -function resolveEditorJsonFile( - config: AppConfig, - resourceId: string, -) { - const relativePath = - EDITOR_JSON_RESOURCE_FILES[ - resourceId as EditorJsonResourceId - ]; - if (!relativePath) { - throw notFound('未知的编辑器资源。'); - } - - return path.resolve(config.projectRoot, relativePath); -} - -async function readEditorJsonFile(filePath: string) { - try { - const content = await readFile(filePath, 'utf8'); - return JSON.parse(content) as Record; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return {}; - } - throw error; - } -} - -async function collectPngAssetPaths( - rootDir: string, - relativeDir = 'Icons', -): Promise { - const entries = await readdir(rootDir, { withFileTypes: true }); - const collected: string[] = []; - - for (const entry of entries) { - const absolutePath = path.join(rootDir, entry.name); - const relativePath = `${relativeDir}/${entry.name}`.replace(/\\/g, '/'); - - if (entry.isDirectory()) { - collected.push( - ...(await collectPngAssetPaths(absolutePath, relativePath)), - ); - continue; - } - - if (entry.isFile() && entry.name.toLowerCase().endsWith('.png')) { - collected.push(relativePath); - } - } - - return collected.sort((left, right) => left.localeCompare(right)); -} - -export function createEditorRoutes(config: AppConfig) { - const router = Router(); - - router.use((request, response, next) => { - if ( - request.path !== '/api/editor' && - !request.path.startsWith('/api/editor/') - ) { - next(); - return; - } - - if (!config.editorApiEnabled) { - response.status(403).json({ - error: { - message: '编辑器接口当前未启用。', - }, - }); - return; - } - next(); - }); - - router.get( - '/api/editor/catalog/items', - routeMeta({ operation: 'editor.catalog.items.list' }), - asyncHandler(async (_request, response) => { - response.json({ - assetPaths: await collectPngAssetPaths( - path.resolve(config.projectRoot, 'public/Icons'), - ), - }); - }), - ); - - router.get( - '/api/editor/json/:resourceId', - routeMeta({ operation: 'editor.resource.read' }), - asyncHandler(async (request, response) => { - const filePath = resolveEditorJsonFile(config, request.params.resourceId); - response.json(await readEditorJsonFile(filePath)); - }), - ); - - router.post( - '/api/editor/json/:resourceId', - routeMeta({ operation: 'editor.resource.write' }), - asyncHandler(async (request, response) => { - if (!isEditorJsonPayload(request.body)) { - throw badRequest('编辑器保存请求必须是 JSON 对象。'); - } - - const filePath = resolveEditorJsonFile(config, request.params.resourceId); - await mkdir(path.dirname(filePath), { recursive: true }); - await writeFile( - filePath, - JSON.stringify(request.body, null, 2) + '\n', - 'utf8', - ); - response.json({ ok: true }); - }), - ); - - return router; -} diff --git a/server-node/src/modules/inventory/inventoryMutationService.test.ts b/server-node/src/modules/inventory/inventoryMutationService.test.ts deleted file mode 100644 index 442d0120..00000000 --- a/server-node/src/modules/inventory/inventoryMutationService.test.ts +++ /dev/null @@ -1,230 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { createTestPlayerCharacter } from '../../testFixtures/runtimeCharacter.js'; -import { - craftForgeRecipe, - equipInventoryItem, - useInventoryItem, - type RuntimeGameState, - type RuntimeInventoryItem, -} from './inventoryMutationService.js'; - -const TEST_WORLD = 'WUXIA' as RuntimeGameState['worldType']; -const TEST_IDLE_ANIMATION = 'idle' as RuntimeGameState['animationState']; - -function requireCharacter() { - return createTestPlayerCharacter< - NonNullable - >(); -} - -function buildItem( - overrides: Partial & - Pick, -): RuntimeInventoryItem { - return { - quantity: 1, - rarity: 'common', - tags: [], - ...overrides, - }; -} - -function createState(overrides: Partial = {}): RuntimeGameState { - return { - worldType: TEST_WORLD, - customWorldProfile: null, - playerCharacter: requireCharacter(), - runtimeStats: { - playTimeMs: 0, - lastPlayTickAt: null, - hostileNpcsDefeated: 0, - questsAccepted: 0, - itemsUsed: 0, - scenesTraveled: 0, - }, - currentScene: 'test-scene', - storyHistory: [], - characterChats: {}, - animationState: TEST_IDLE_ANIMATION, - currentEncounter: null, - npcInteractionActive: false, - currentScenePreset: null, - sceneHostileNpcs: [], - playerX: 0, - playerOffsetY: 0, - playerFacing: 'right', - playerActionMode: 'melee', - scrollWorld: false, - inBattle: false, - playerHp: 64, - playerMaxHp: 100, - playerMana: 18, - playerMaxMana: 60, - playerSkillCooldowns: { - slash: 2, - }, - activeBuildBuffs: [], - activeCombatEffects: [], - playerCurrency: 120, - playerInventory: [], - playerEquipment: { - weapon: null, - armor: null, - relic: null, - }, - npcStates: {}, - quests: [], - roster: [], - companions: [], - currentBattleNpcId: null, - currentNpcBattleMode: null, - currentNpcBattleOutcome: null, - sparReturnEncounter: null, - sparPlayerHpBefore: null, - sparPlayerMaxHpBefore: null, - sparStoryHistoryBefore: null, - ...overrides, - } satisfies RuntimeGameState; -} - -test('useInventoryItem applies recovery, cooldown推进 and buff mutation', () => { - const state = createState({ - playerInventory: [ - buildItem({ - id: 'focus-tonic', - category: '消耗品', - name: '凝神灵液', - rarity: 'rare', - tags: ['healing', 'mana'], - useProfile: { - hpRestore: 22, - manaRestore: 16, - cooldownReduction: 1, - buildBuffs: [ - { - id: 'focus-tonic:buff', - sourceType: 'item', - sourceId: 'focus-tonic', - name: '凝神增益', - tags: ['快剑'], - durationTurns: 2, - }, - ], - }, - }), - ], - }); - - const result = useInventoryItem(state, 'focus-tonic'); - assert.equal(result.ok, true); - if (!result.ok) { - return; - } - - assert.equal(result.mutation, 'use'); - assert.equal(result.nextState.playerHp, 86); - assert.equal(result.nextState.playerMana, 34); - assert.equal(result.nextState.playerSkillCooldowns.slash, 1); - assert.equal(result.nextState.playerInventory.length, 0); - assert.equal(result.nextState.runtimeStats.itemsUsed, 1); - assert.equal(result.nextState.activeBuildBuffs[0]?.id, 'focus-tonic:buff'); -}); - -test('equipInventoryItem swaps loadout and returns replaced gear to inventory', () => { - const oldWeapon = buildItem({ - id: 'starter-blade', - category: '武器', - name: '旧佩剑', - rarity: 'common', - tags: ['weapon', '快剑'], - equipmentSlotId: 'weapon', - statProfile: { - outgoingDamageBonus: 0.04, - }, - buildProfile: { - role: '快剑', - tags: ['快剑'], - synergy: ['快剑'], - forgeRank: 0, - }, - }); - const nextWeapon = buildItem({ - id: 'storm-blade', - category: '武器', - name: '逐风短剑', - rarity: 'rare', - tags: ['weapon', '快剑', '突进'], - equipmentSlotId: 'weapon', - statProfile: { - outgoingDamageBonus: 0.12, - }, - buildProfile: { - role: '快剑', - tags: ['快剑', '突进'], - synergy: ['快剑', '突进'], - forgeRank: 0, - }, - }); - const state = createState({ - playerInventory: [nextWeapon], - playerEquipment: { - weapon: oldWeapon, - armor: null, - relic: null, - }, - }); - - const result = equipInventoryItem(state, 'storm-blade'); - assert.equal(result.ok, true); - if (!result.ok) { - return; - } - - assert.equal(result.mutation, 'equip'); - assert.equal(result.slot, 'weapon'); - assert.equal(result.nextState.playerEquipment.weapon?.name, '逐风短剑'); - assert.equal( - result.nextState.playerInventory.some((item) => item.id === 'starter-blade'), - true, - ); - assert.equal( - result.nextState.playerInventory.some((item) => item.id === 'storm-blade'), - false, - ); -}); - -test('craftForgeRecipe consumes materials and produces forged output on the server side', () => { - const state = createState({ - playerCurrency: 40, - playerInventory: [ - buildItem({ - id: 'scrap-iron', - category: '材料', - name: '残铁碎片', - quantity: 3, - rarity: 'common', - tags: ['material'], - }), - ], - }); - - const result = craftForgeRecipe(state, 'synthesis-refined-ingot'); - assert.equal(result.ok, true); - if (!result.ok) { - return; - } - - assert.equal(result.mutation, 'craft'); - assert.equal(result.nextState.playerCurrency, 22); - assert.equal(result.createdItem?.name, '精炼锭材'); - assert.equal( - result.nextState.playerInventory.some((item) => item.name === '精炼锭材'), - true, - ); - assert.equal( - result.nextState.playerInventory.some((item) => item.id === 'scrap-iron'), - false, - ); -}); diff --git a/server-node/src/modules/inventory/inventoryMutationService.ts b/server-node/src/modules/inventory/inventoryMutationService.ts deleted file mode 100644 index 2afd452e..00000000 --- a/server-node/src/modules/inventory/inventoryMutationService.ts +++ /dev/null @@ -1,458 +0,0 @@ -import { - addInventoryItems, - appendBuildBuffs, - applyEquipmentLoadoutToState, - buildForgeSuccessText, - buildInventoryUseResultText, - executeDismantleItem, - executeForgeRecipe, - executeReforgeItem, - getEquipmentSlotFromItem, - getEquipmentSlotLabel, - getForgeRecipeViews, - getReforgeCostView, - incrementGameRuntimeStats, - isInventoryItemUsable, - removeInventoryItem, - resolveInventoryItemUseEffect, -} from '../../bridges/legacyInventoryRuntimeBridge.js'; - -export type RuntimeGameState = Parameters< - typeof applyEquipmentLoadoutToState ->[0]; -export type RuntimeInventoryItem = Parameters< - typeof getEquipmentSlotFromItem ->[0]; -export type RuntimeEquipmentSlotId = Exclude< - ReturnType, - null ->; -export type RuntimeInventoryUseEffect = Exclude< - ReturnType, - null ->; -export type RuntimeForgeRecipeView = ReturnType< - typeof getForgeRecipeViews ->[number]; -export type RuntimeReforgeCostView = ReturnType; - -type InventoryMutationKind = - | 'use' - | 'equip' - | 'unequip' - | 'craft' - | 'dismantle' - | 'reforge'; - -type InventoryMutationFailureCode = - | 'missing_player_character' - | 'battle_locked' - | 'item_not_found' - | 'item_not_usable' - | 'item_not_equippable' - | 'slot_empty' - | 'recipe_not_available' - | 'mutation_not_available'; - -export type InventoryMutationFailure = { - ok: false; - code: InventoryMutationFailureCode; - message: string; -}; - -export type InventoryMutationSuccess = { - ok: true; - mutation: InventoryMutationKind; - nextState: RuntimeGameState; - actionText: string; - detailText: string; - item?: RuntimeInventoryItem; - slot?: RuntimeEquipmentSlotId; - replacedItem?: RuntimeInventoryItem | null; - createdItem?: RuntimeInventoryItem | null; - outputs?: RuntimeInventoryItem[]; - effect?: RuntimeInventoryUseEffect; - reforgeCost?: RuntimeReforgeCostView; -}; - -export type InventoryMutationResult = - | InventoryMutationFailure - | InventoryMutationSuccess; - -function createFailure( - code: InventoryMutationFailureCode, - message: string, -): InventoryMutationFailure { - return { - ok: false, - code, - message, - }; -} - -function tickCooldownMap( - cooldowns: RuntimeGameState['playerSkillCooldowns'], - turns: number, -) { - let nextCooldowns = cooldowns; - const totalTurns = Math.max(0, Math.floor(turns)); - - for (let index = 0; index < totalTurns; index += 1) { - nextCooldowns = Object.fromEntries( - Object.entries(nextCooldowns).map(([skillId, value]) => [ - skillId, - Math.max(0, Math.floor(value) - 1), - ]), - ); - } - - return nextCooldowns; -} - -function normalizeEquippedItem(item: RuntimeInventoryItem): RuntimeInventoryItem { - return { - ...item, - quantity: 1, - }; -} - -function buildEquipResultText( - item: RuntimeInventoryItem, - slot: RuntimeEquipmentSlotId, - replacedItem?: RuntimeInventoryItem | null, -) { - return replacedItem - ? `你将${replacedItem.name}从${getEquipmentSlotLabel(slot)}位上换下,改为装备${item.name}。` - : `你将${item.name}装备在${getEquipmentSlotLabel(slot)}位上。`; -} - -function buildUnequipResultText(item: RuntimeInventoryItem) { - return `你卸下了${item.name},暂时收回背包。`; -} - -export function getForgeRecipeCatalog( - state: RuntimeGameState, -): RuntimeForgeRecipeView[] { - return getForgeRecipeViews( - state.playerInventory, - state.playerCurrency, - state.worldType, - ); -} - -export function useInventoryItem( - state: RuntimeGameState, - itemId: string, -): InventoryMutationResult { - const playerCharacter = state.playerCharacter; - if (!playerCharacter) { - return createFailure( - 'missing_player_character', - '缺少玩家角色,无法使用背包物品。', - ); - } - - const item = state.playerInventory.find((candidate) => candidate.id === itemId); - if (!item || item.quantity <= 0) { - return createFailure('item_not_found', '未找到可使用的背包物品。'); - } - - if (!isInventoryItemUsable(item)) { - return createFailure('item_not_usable', `${item.name} 当前不可直接使用。`); - } - - const effect = resolveInventoryItemUseEffect(item, playerCharacter); - if ( - !effect || - (effect.hpRestore ?? 0) <= 0 && - (effect.manaRestore ?? 0) <= 0 && - (effect.cooldownReduction ?? 0) <= 0 && - (effect.buildBuffs?.length ?? 0) <= 0 - ) { - return createFailure( - 'item_not_usable', - `${item.name} 当前没有可结算的使用效果。`, - ); - } - - const nextState = { - ...state, - playerHp: Math.min(state.playerMaxHp, state.playerHp + effect.hpRestore), - playerMana: Math.min( - state.playerMaxMana, - state.playerMana + effect.manaRestore, - ), - playerSkillCooldowns: tickCooldownMap( - state.playerSkillCooldowns, - effect.cooldownReduction, - ), - activeBuildBuffs: appendBuildBuffs( - state.activeBuildBuffs, - effect.buildBuffs, - ), - playerInventory: removeInventoryItem(state.playerInventory, item.id, 1), - runtimeStats: incrementGameRuntimeStats(state.runtimeStats, { - itemsUsed: 1, - }), - } satisfies RuntimeGameState; - - return { - ok: true, - mutation: 'use', - nextState, - actionText: `使用${item.name}`, - detailText: buildInventoryUseResultText(item, effect), - item, - effect, - }; -} - -export function equipInventoryItem( - state: RuntimeGameState, - itemId: string, -): InventoryMutationResult { - if (!state.playerCharacter) { - return createFailure( - 'missing_player_character', - '缺少玩家角色,无法调整装备。', - ); - } - - if (state.inBattle) { - return createFailure('battle_locked', '战斗中无法调整装备。'); - } - - const item = state.playerInventory.find((candidate) => candidate.id === itemId); - if (!item || item.quantity <= 0) { - return createFailure('item_not_found', '背包里没有这件装备。'); - } - - const slot = getEquipmentSlotFromItem(item); - if (!slot) { - return createFailure('item_not_equippable', `${item.name} 不是可装备物品。`); - } - - const replacedItem = state.playerEquipment[slot]; - const nextEquipment = { - ...state.playerEquipment, - [slot]: normalizeEquippedItem(item), - }; - - let nextInventory = removeInventoryItem(state.playerInventory, item.id, 1); - if (replacedItem) { - nextInventory = addInventoryItems(nextInventory, [replacedItem]); - } - - const nextState = applyEquipmentLoadoutToState( - { - ...state, - playerInventory: nextInventory, - }, - nextEquipment, - ); - - return { - ok: true, - mutation: 'equip', - nextState, - actionText: `装备${item.name}`, - detailText: buildEquipResultText(item, slot, replacedItem), - item, - slot, - replacedItem, - }; -} - -export function unequipInventoryItem( - state: RuntimeGameState, - slot: RuntimeEquipmentSlotId, -): InventoryMutationResult { - if (!state.playerCharacter) { - return createFailure( - 'missing_player_character', - '缺少玩家角色,无法卸下装备。', - ); - } - - if (state.inBattle) { - return createFailure('battle_locked', '战斗中无法卸下装备。'); - } - - const equippedItem = state.playerEquipment[slot]; - if (!equippedItem) { - return createFailure('slot_empty', `${getEquipmentSlotLabel(slot)}位当前没有装备。`); - } - - const nextEquipment = { - ...state.playerEquipment, - [slot]: null, - }; - const nextState = applyEquipmentLoadoutToState( - { - ...state, - playerInventory: addInventoryItems(state.playerInventory, [equippedItem]), - }, - nextEquipment, - ); - - return { - ok: true, - mutation: 'unequip', - nextState, - actionText: `卸下${equippedItem.name}`, - detailText: buildUnequipResultText(equippedItem), - item: equippedItem, - slot, - }; -} - -export function craftForgeRecipe( - state: RuntimeGameState, - recipeId: string, -): InventoryMutationResult { - if (!state.playerCharacter) { - return createFailure( - 'missing_player_character', - '缺少玩家角色,无法执行锻造配方。', - ); - } - - if (state.inBattle) { - return createFailure('battle_locked', '战斗中无法使用工坊。'); - } - - const recipe = getForgeRecipeCatalog(state).find( - (candidate) => candidate.id === recipeId, - ); - if (!recipe) { - return createFailure('recipe_not_available', '未找到目标锻造配方。'); - } - - const result = executeForgeRecipe( - state.playerInventory, - recipeId, - state.worldType, - state.playerCurrency, - ); - if (!result) { - return createFailure( - 'mutation_not_available', - `${recipe.name} 当前材料或货币不足。`, - ); - } - - return { - ok: true, - mutation: 'craft', - nextState: { - ...state, - playerCurrency: result.currency, - playerInventory: result.inventory, - }, - actionText: `制作${result.createdItem.name}`, - detailText: buildForgeSuccessText('craft', { - recipeName: recipe.name, - createdItemName: result.createdItem.name, - currencyText: recipe.currencyText, - }), - createdItem: result.createdItem, - }; -} - -export function dismantleInventoryItem( - state: RuntimeGameState, - itemId: string, -): InventoryMutationResult { - if (!state.playerCharacter) { - return createFailure( - 'missing_player_character', - '缺少玩家角色,无法执行拆解。', - ); - } - - if (state.inBattle) { - return createFailure('battle_locked', '战斗中无法执行拆解。'); - } - - const item = state.playerInventory.find((candidate) => candidate.id === itemId); - if (!item || item.quantity <= 0) { - return createFailure('item_not_found', '未找到可拆解的物品。'); - } - - const result = executeDismantleItem(state.playerInventory, itemId); - if (!result) { - return createFailure( - 'mutation_not_available', - `${item.name} 当前不支持拆解。`, - ); - } - - return { - ok: true, - mutation: 'dismantle', - nextState: { - ...state, - playerInventory: result.inventory, - }, - actionText: `拆解${item.name}`, - detailText: buildForgeSuccessText('dismantle', { - sourceItemName: item.name, - outputNames: result.outputs.map((output) => output.name), - }), - item, - outputs: result.outputs, - }; -} - -export function reforgeInventoryItem( - state: RuntimeGameState, - itemId: string, -): InventoryMutationResult { - if (!state.playerCharacter) { - return createFailure( - 'missing_player_character', - '缺少玩家角色,无法执行重铸。', - ); - } - - if (state.inBattle) { - return createFailure('battle_locked', '战斗中无法执行重铸。'); - } - - const item = state.playerInventory.find((candidate) => candidate.id === itemId); - if (!item || item.quantity <= 0) { - return createFailure('item_not_found', '未找到可重铸的物品。'); - } - - const reforgeCost = getReforgeCostView(item, state.worldType); - const result = executeReforgeItem( - state.playerInventory, - itemId, - state.playerCurrency, - ); - if (!result) { - return createFailure( - 'mutation_not_available', - `${item.name} 当前不满足重铸条件。`, - ); - } - - return { - ok: true, - mutation: 'reforge', - nextState: { - ...state, - playerCurrency: Math.max(0, state.playerCurrency - result.currencyCost), - playerInventory: result.inventory, - }, - actionText: `重铸${item.name}`, - detailText: buildForgeSuccessText('reforge', { - sourceItemName: item.name, - createdItemName: result.reforgedItem.name, - currencyText: reforgeCost.currencyText, - }), - item, - createdItem: result.reforgedItem, - reforgeCost, - }; -} diff --git a/server-node/src/modules/inventory/inventoryStoryActionService.ts b/server-node/src/modules/inventory/inventoryStoryActionService.ts deleted file mode 100644 index 9e86d417..00000000 --- a/server-node/src/modules/inventory/inventoryStoryActionService.ts +++ /dev/null @@ -1,195 +0,0 @@ -import type { - RuntimeStoryActionRequest, - RuntimeStoryPatch, -} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js'; -import { conflict, invalidRequest } from '../../errors.js'; -import { - getPlayerBuildDamageBreakdown, -} from '../runtime/runtimeBuildModule.js'; -import { - craftForgeRecipe, - dismantleInventoryItem, - equipInventoryItem, - reforgeInventoryItem, - unequipInventoryItem, - useInventoryItem, - type InventoryMutationFailure, - type InventoryMutationSuccess, - type RuntimeGameState as InventoryRuntimeGameState, -} from './inventoryMutationService.js'; -import { - replaceRuntimeSessionRawGameState, -} from '../rpg-runtime-story/RpgRuntimeSnapshotSync.js'; -import type { RuntimeSession } from '../rpg-runtime-story/RpgRuntimeSessionLoader.js'; - -const SUPPORTED_INVENTORY_STORY_FUNCTION_IDS = new Set([ - 'equipment_equip', - 'equipment_unequip', - 'forge_craft', - 'forge_dismantle', - 'forge_reforge', - 'inventory_use', -]); - -type InventoryStoryResolution = { - actionText: string; - resultText: string; - patches: RuntimeStoryPatch[]; - toast?: string | null; -}; - -type JsonRecord = Record; - -function isObject(value: unknown): value is JsonRecord { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function readPayload(request: RuntimeStoryActionRequest) { - return isObject(request.action.payload) ? request.action.payload : {}; -} - -function readString(value: unknown) { - return typeof value === 'string' && value.trim() ? value.trim() : ''; -} - -function readItemId(request: RuntimeStoryActionRequest) { - const payload = readPayload(request); - return ( - readString(payload.itemId) || - readString(payload.targetId) || - readString(request.action.targetId) - ); -} - -function readRecipeId(request: RuntimeStoryActionRequest) { - const payload = readPayload(request); - return ( - readString(payload.recipeId) || - readString(payload.targetId) || - readString(request.action.targetId) - ); -} - -function readEquipmentSlotId(request: RuntimeStoryActionRequest) { - const payload = readPayload(request); - const slotId = - readString(payload.slotId) || readString(request.action.targetId); - - if (slotId === 'weapon' || slotId === 'armor' || slotId === 'relic') { - return slotId; - } - - return ''; -} - -function refreshSessionFromGameState( - session: RuntimeSession, - nextGameState: InventoryMutationSuccess['nextState'], -) { - replaceRuntimeSessionRawGameState( - session, - nextGameState as unknown as JsonRecord, - ); -} - -export function buildBuildToast( - nextState: InventoryMutationSuccess['nextState'], -) { - if (!nextState.playerCharacter) { - return null; - } - - const buildMultiplier = getPlayerBuildDamageBreakdown( - nextState, - nextState.playerCharacter, - ).buildDamageMultiplier.toFixed(2); - return `当前 Build 倍率 x${buildMultiplier}`; -} - -function throwMutationFailure(error: InventoryMutationFailure): never { - switch (error.code) { - case 'item_not_equippable': - case 'recipe_not_available': - throw invalidRequest(error.message); - default: - throw conflict(error.message); - } -} - -function resolveMutation( - request: RuntimeStoryActionRequest, - state: InventoryRuntimeGameState, -) { - switch (request.action.functionId) { - case 'inventory_use': { - const itemId = readItemId(request); - if (!itemId) { - throw invalidRequest('inventory_use 缺少 itemId'); - } - return useInventoryItem(state, itemId); - } - case 'equipment_equip': { - const itemId = readItemId(request); - if (!itemId) { - throw invalidRequest('equipment_equip 缺少 itemId'); - } - return equipInventoryItem(state, itemId); - } - case 'equipment_unequip': { - const slotId = readEquipmentSlotId(request); - if (!slotId) { - throw invalidRequest('equipment_unequip 缺少合法 slotId'); - } - return unequipInventoryItem(state, slotId); - } - case 'forge_craft': { - const recipeId = readRecipeId(request); - if (!recipeId) { - throw invalidRequest('forge_craft 缺少 recipeId'); - } - return craftForgeRecipe(state, recipeId); - } - case 'forge_dismantle': { - const itemId = readItemId(request); - if (!itemId) { - throw invalidRequest('forge_dismantle 缺少 itemId'); - } - return dismantleInventoryItem(state, itemId); - } - case 'forge_reforge': { - const itemId = readItemId(request); - if (!itemId) { - throw invalidRequest('forge_reforge 缺少 itemId'); - } - return reforgeInventoryItem(state, itemId); - } - default: - throw invalidRequest(`暂不支持的 Inventory 动作:${request.action.functionId}`); - } -} - -export function isSupportedInventoryStoryFunctionId(functionId: string) { - return SUPPORTED_INVENTORY_STORY_FUNCTION_IDS.has(functionId); -} - -export function resolveInventoryStoryAction( - session: RuntimeSession, - request: RuntimeStoryActionRequest, -): InventoryStoryResolution { - const mutation = resolveMutation( - request, - session.rawGameState as InventoryRuntimeGameState, - ); - if (!mutation.ok) { - throwMutationFailure(mutation); - } - - refreshSessionFromGameState(session, mutation.nextState); - - return { - actionText: mutation.actionText, - resultText: mutation.detailText, - patches: [], - toast: buildBuildToast(mutation.nextState), - }; -} diff --git a/server-node/src/modules/inventory/npcInventoryStoryActionService.ts b/server-node/src/modules/inventory/npcInventoryStoryActionService.ts deleted file mode 100644 index 0b093731..00000000 --- a/server-node/src/modules/inventory/npcInventoryStoryActionService.ts +++ /dev/null @@ -1,386 +0,0 @@ -import type { - RuntimeStoryActionRequest, - RuntimeStoryPatch, -} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js'; -import { conflict, invalidRequest } from '../../errors.js'; -import { - addInventoryItems, - appendStoryEngineCarrierMemory, - applyStoryChoiceToStanceProfile, - buildInitialNpcState, - buildNpcGiftCommitActionText, - buildNpcGiftResultText, - buildNpcTradeTransactionActionText, - buildNpcTradeTransactionResultText, - buildRelationState, - getGiftCandidates, - getNpcBuybackPrice, - getNpcPurchasePrice, - markNpcFirstMeaningfulContactResolved, - normalizeNpcPersistentState, - removeInventoryItem, - syncNpcTradeInventory, -} from '../../bridges/legacyNpcTask6Bridge.js'; -import { - replaceRuntimeSessionRawGameState, -} from '../rpg-runtime-story/RpgRuntimeSnapshotSync.js'; -import type { RuntimeSession } from '../rpg-runtime-story/RpgRuntimeSessionLoader.js'; - -const SUPPORTED_NPC_INVENTORY_STORY_FUNCTION_IDS = new Set([ - 'npc_gift', - 'npc_trade', -]); - -type NpcInventoryStoryResolution = { - actionText: string; - resultText: string; - patches: RuntimeStoryPatch[]; -}; - -type JsonRecord = Record; -type RuntimeInventoryItem = Parameters[1][number]; -type RuntimeGameState = Parameters[0]; -type RuntimeEncounter = Parameters[0]; - -function isObject(value: unknown): value is JsonRecord { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function readPayload(request: RuntimeStoryActionRequest) { - return isObject(request.action.payload) ? request.action.payload : {}; -} - -function readString(value: unknown) { - return typeof value === 'string' && value.trim() ? value.trim() : ''; -} - -function readPositiveInteger(value: unknown, fallback = 1) { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return fallback; - } - - return Math.max(1, Math.floor(value)); -} - -function cloneInventoryItemForOwner( - item: RuntimeInventoryItem, - owner: 'player' | 'npc', - quantity = 1, -): RuntimeInventoryItem { - const preserveIdentity = Boolean( - item.runtimeMetadata || - item.buildProfile || - item.equipmentSlotId || - item.statProfile || - item.attributeResonance, - ); - - return { - ...item, - id: preserveIdentity - ? `${owner}:${item.id}:${quantity}` - : `${owner}:${encodeURIComponent(`${item.category}-${item.name}`)}`, - quantity, - runtimeMetadata: item.runtimeMetadata - ? { - ...item.runtimeMetadata, - seedKey: `${item.runtimeMetadata.seedKey}:${owner}`, - } - : item.runtimeMetadata, - }; -} - -function getNpcEncounterKey(encounter: RuntimeEncounter) { - return encounter.id?.trim() || encounter.npcName; -} - -function getNpcEncounter( - session: RuntimeSession, - state: RuntimeGameState, -): RuntimeEncounter | null { - const rawEncounter = state.currentEncounter; - if (!rawEncounter || rawEncounter.kind !== 'npc') { - return null; - } - - return { - npcAvatar: '', - hostile: false, - ...rawEncounter, - id: rawEncounter.id ?? session.currentEncounter?.id ?? rawEncounter.npcName, - } satisfies RuntimeEncounter; -} - -export function ensureNpcInventorySessionState(session: RuntimeSession) { - const state = session.rawGameState as unknown as RuntimeGameState; - const encounter = getNpcEncounter(session, state); - if (!encounter) { - return; - } - - const npcKey = getNpcEncounterKey(encounter); - const baseNpcState = - state.npcStates?.[npcKey] ?? - buildInitialNpcState(encounter, state.worldType, state); - const normalizedNpcState = normalizeNpcPersistentState(baseNpcState); - const syncedNpcState = syncNpcTradeInventory(state, encounter, normalizedNpcState); - - const nextState = { - ...state, - npcStates: { - ...(state.npcStates ?? {}), - [npcKey]: syncedNpcState, - }, - } satisfies RuntimeGameState; - - replaceRuntimeSessionRawGameState( - session, - nextState as unknown as JsonRecord, - ); -} - -function getCurrentNpcState(session: RuntimeSession) { - const state = session.rawGameState as unknown as RuntimeGameState; - const encounter = getNpcEncounter(session, state); - if (!encounter) { - throw conflict('当前不在可结算的 NPC 交互态,无法执行交易或赠礼。'); - } - - const npcKey = getNpcEncounterKey(encounter); - const npcState = state.npcStates?.[npcKey]; - if (!npcState) { - throw conflict('当前 NPC 状态不存在,无法继续结算。'); - } - - return { - state, - encounter, - npcKey, - npcState, - }; -} - -function resolveTradeMode(request: RuntimeStoryActionRequest) { - const mode = readString(readPayload(request).mode); - if (mode === 'buy' || mode === 'sell') { - return mode; - } - - throw invalidRequest('npc_trade 缺少合法 mode,需为 buy 或 sell'); -} - -function readTradeItemId(request: RuntimeStoryActionRequest) { - const payload = readPayload(request); - return ( - readString(payload.itemId) || - readString(payload.selectedNpcItemId) || - readString(payload.selectedPlayerItemId) || - readString(request.action.targetId) - ); -} - -function readTradeQuantity(request: RuntimeStoryActionRequest) { - return readPositiveInteger(readPayload(request).quantity, 1); -} - -function resolveNpcTradeAction( - session: RuntimeSession, - request: RuntimeStoryActionRequest, -): NpcInventoryStoryResolution { - ensureNpcInventorySessionState(session); - const { state, encounter, npcKey, npcState } = getCurrentNpcState(session); - const mode = resolveTradeMode(request); - const itemId = readTradeItemId(request); - const quantity = readTradeQuantity(request); - - if (!itemId) { - throw invalidRequest('npc_trade 缺少 itemId'); - } - - if (mode === 'buy') { - const npcItem = npcState.inventory.find((item) => item.id === itemId); - if (!npcItem || npcItem.quantity < quantity) { - throw conflict('目标商品不存在或库存不足。'); - } - - const totalPrice = getNpcPurchasePrice(npcItem, npcState.affinity) * quantity; - if (state.playerCurrency < totalPrice) { - throw conflict('当前钱币不足,无法完成购买。'); - } - - const acquiredItem = cloneInventoryItemForOwner(npcItem, 'player', quantity); - let nextState = { - ...state, - playerCurrency: state.playerCurrency - totalPrice, - playerInventory: addInventoryItems(state.playerInventory, [acquiredItem]), - npcStates: { - ...state.npcStates, - [npcKey]: { - ...markNpcFirstMeaningfulContactResolved(npcState), - inventory: removeInventoryItem(npcState.inventory, npcItem.id, quantity), - }, - }, - } satisfies RuntimeGameState; - nextState = appendStoryEngineCarrierMemory(nextState, [acquiredItem]); - - replaceRuntimeSessionRawGameState( - session, - nextState as unknown as JsonRecord, - ); - - return { - actionText: buildNpcTradeTransactionActionText({ - encounter, - mode: 'buy', - item: npcItem, - quantity, - }), - resultText: buildNpcTradeTransactionResultText({ - encounter, - mode: 'buy', - item: npcItem, - quantity, - totalPrice, - worldType: state.worldType, - }), - patches: [], - }; - } - - const playerItem = state.playerInventory.find((item) => item.id === itemId); - if (!playerItem || playerItem.quantity < quantity) { - throw conflict('背包里没有足够数量的目标物品。'); - } - - const totalPrice = getNpcBuybackPrice(playerItem, npcState.affinity) * quantity; - const soldItem = cloneInventoryItemForOwner(playerItem, 'npc', quantity); - const nextState = { - ...state, - playerCurrency: state.playerCurrency + totalPrice, - playerInventory: removeInventoryItem(state.playerInventory, playerItem.id, quantity), - npcStates: { - ...state.npcStates, - [npcKey]: { - ...markNpcFirstMeaningfulContactResolved(npcState), - inventory: addInventoryItems(npcState.inventory, [soldItem]), - }, - }, - } satisfies RuntimeGameState; - - replaceRuntimeSessionRawGameState( - session, - nextState as unknown as JsonRecord, - ); - - return { - actionText: buildNpcTradeTransactionActionText({ - encounter, - mode: 'sell', - item: playerItem, - quantity, - }), - resultText: buildNpcTradeTransactionResultText({ - encounter, - mode: 'sell', - item: playerItem, - quantity, - totalPrice, - worldType: state.worldType, - }), - patches: [], - }; -} - -function resolveNpcGiftAction( - session: RuntimeSession, - request: RuntimeStoryActionRequest, -): NpcInventoryStoryResolution { - ensureNpcInventorySessionState(session); - const { state, encounter, npcKey, npcState } = getCurrentNpcState(session); - const itemId = - readString(readPayload(request).itemId) || readString(request.action.targetId); - - if (!itemId) { - throw invalidRequest('npc_gift 缺少 itemId'); - } - - const giftItem = state.playerInventory.find((item) => item.id === itemId); - if (!giftItem || giftItem.quantity <= 0) { - throw conflict('背包里没有这件可赠送的物品。'); - } - - const giftCandidate = getGiftCandidates(state.playerInventory, encounter, { - worldType: state.worldType, - customWorldProfile: state.customWorldProfile, - }).find((candidate) => candidate.item.id === giftItem.id); - const affinityGain = giftCandidate?.affinityGain ?? 0; - const attributeSummary = giftCandidate?.attributeInsight?.reasonText ?? undefined; - const nextAffinity = npcState.affinity + affinityGain; - const nextNpcState = { - ...markNpcFirstMeaningfulContactResolved(npcState), - affinity: nextAffinity, - relationState: buildRelationState(nextAffinity), - giftsGiven: (npcState.giftsGiven ?? 0) + 1, - stanceProfile: applyStoryChoiceToStanceProfile( - npcState.stanceProfile, - 'npc_gift', - { affinityGain }, - ), - inventory: addInventoryItems(npcState.inventory, [ - cloneInventoryItemForOwner(giftItem, 'npc'), - ]), - }; - - const nextState = { - ...state, - playerInventory: removeInventoryItem(state.playerInventory, giftItem.id, 1), - npcStates: { - ...state.npcStates, - [npcKey]: nextNpcState, - }, - } satisfies RuntimeGameState; - - replaceRuntimeSessionRawGameState( - session, - nextState as unknown as JsonRecord, - ); - - return { - actionText: buildNpcGiftCommitActionText(encounter, giftItem), - resultText: buildNpcGiftResultText( - encounter, - giftItem, - affinityGain, - nextAffinity, - attributeSummary, - ), - patches: [ - { - type: 'npc_affinity_changed', - npcId: npcKey, - previousAffinity: npcState.affinity, - nextAffinity, - }, - ], - }; -} - -export function isSupportedNpcInventoryStoryFunctionId(functionId: string) { - return SUPPORTED_NPC_INVENTORY_STORY_FUNCTION_IDS.has(functionId); -} - -export function resolveNpcInventoryStoryAction( - session: RuntimeSession, - request: RuntimeStoryActionRequest, -): NpcInventoryStoryResolution { - switch (request.action.functionId) { - case 'npc_trade': - return resolveNpcTradeAction(session, request); - case 'npc_gift': - return resolveNpcGiftAction(session, request); - default: - throw invalidRequest( - `暂不支持的 NPC Inventory 动作:${request.action.functionId}`, - ); - } -} diff --git a/server-node/src/modules/npc/npcInteractionService.ts b/server-node/src/modules/npc/npcInteractionService.ts deleted file mode 100644 index 3e1acbca..00000000 --- a/server-node/src/modules/npc/npcInteractionService.ts +++ /dev/null @@ -1,458 +0,0 @@ -import type { RuntimeStoryPatch } from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js'; -import { conflict } from '../../errors.js'; -import { resolveHostileBattleProfile } from '../progression/hostileProgressionService.js'; -import { - applyStoryChoiceToStanceProfile, -} from './npcTask6Primitives.js'; -import { markNpcFirstMeaningfulContactResolved } from '../runtime/runtimeNpcStatePrimitives.js'; -import { - MAX_TASK5_COMPANIONS, - getEncounterNpcState, - setEncounterNpcState, - type RuntimeEncounter, - type RuntimeNpcState, - type RuntimeSession, -} from '../rpg-runtime-story/RpgRuntimeSessionPrimitives.js'; - -type JsonRecord = Record; - -export type NpcInteractionResolution = { - actionText: string; - resultText: string; - patches: RuntimeStoryPatch[]; - storyText?: string; - toast?: string | null; -}; - -function requireNpcEncounter(session: RuntimeSession) { - if (!session.currentEncounter || session.currentEncounter.kind !== 'npc') { - throw conflict('当前没有可结算的 NPC 交互对象'); - } - - return session.currentEncounter; -} - -function requireNpcState( - session: RuntimeSession, - encounter: RuntimeEncounter, -): RuntimeNpcState { - const npcState = getEncounterNpcState(session); - if (!npcState) { - throw conflict(`未找到 ${encounter.npcName} 的运行时关系状态`); - } - - return npcState; -} - -function buildAffinityPatch( - encounter: RuntimeEncounter, - previousAffinity: number, - nextAffinity: number, -) { - return { - type: 'npc_affinity_changed', - npcId: encounter.id, - previousAffinity, - nextAffinity, - } satisfies RuntimeStoryPatch; -} - -function isRecord(value: unknown): value is JsonRecord { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function readString(value: unknown) { - return typeof value === 'string' && value.trim() ? value.trim() : ''; -} - -function buildRecruitedCompanion( - session: RuntimeSession, - encounter: RuntimeEncounter, - npcState: RuntimeNpcState, -) { - const rawCompanionSource = isRecord(session.rawGameState.currentEncounter) - ? session.rawGameState.currentEncounter - : {}; - const maxHp = Math.max( - 1, - Math.round( - typeof rawCompanionSource.maxHp === 'number' && - Number.isFinite(rawCompanionSource.maxHp) - ? rawCompanionSource.maxHp - : 180, - ), - ); - const maxMana = Math.max( - 1, - Math.round( - typeof rawCompanionSource.maxMana === 'number' && - Number.isFinite(rawCompanionSource.maxMana) - ? rawCompanionSource.maxMana - : 999, - ), - ); - const skillCooldowns = Object.fromEntries( - Object.entries( - isRecord(rawCompanionSource.skillCooldowns) - ? rawCompanionSource.skillCooldowns - : {}, - ).map(([skillId, turns]) => [ - skillId, - typeof turns === 'number' && Number.isFinite(turns) - ? Math.max(0, Math.round(turns)) - : 0, - ]), - ); - - return { - npcId: encounter.id, - characterId: encounter.characterId ?? '', - joinedAtAffinity: npcState.affinity, - hp: maxHp, - maxHp, - mana: maxMana, - maxMana, - skillCooldowns, - animationState: readString(rawCompanionSource.animationState) || 'idle', - actionMode: readString(rawCompanionSource.actionMode) || 'idle', - offsetX: - typeof rawCompanionSource.offsetX === 'number' && - Number.isFinite(rawCompanionSource.offsetX) - ? rawCompanionSource.offsetX - : 0, - offsetY: - typeof rawCompanionSource.offsetY === 'number' && - Number.isFinite(rawCompanionSource.offsetY) - ? rawCompanionSource.offsetY - : 0, - transitionMs: - typeof rawCompanionSource.transitionMs === 'number' && - Number.isFinite(rawCompanionSource.transitionMs) - ? Math.max(0, Math.round(rawCompanionSource.transitionMs)) - : 0, - }; -} - -function upsertCompanion( - list: RuntimeSession['companions'], - companion: RuntimeSession['companions'][number], -) { - const next = [...list]; - const existingIndex = next.findIndex((item) => item.npcId === companion.npcId); - if (existingIndex >= 0) { - next[existingIndex] = companion; - return next; - } - - next.push(companion); - return next; -} - -function removeCompanion( - list: RuntimeSession['companions'], - npcId: string, -) { - return list.filter((item) => item.npcId !== npcId); -} - -function normalizeRoster( - roster: RuntimeSession['roster'], - activeCompanions: RuntimeSession['companions'], -) { - const activeIds = new Set(activeCompanions.map((companion) => companion.npcId)); - return roster.filter((companion) => !activeIds.has(companion.npcId)); -} - -function recruitCompanionToParty(params: { - session: RuntimeSession; - companion: RuntimeSession['companions'][number]; - releaseNpcId?: string | null; -}) { - const nextRosterWithoutRecruit = removeCompanion( - params.session.roster, - params.companion.npcId, - ); - - if ( - !params.releaseNpcId && - params.session.companions.length < MAX_TASK5_COMPANIONS - ) { - return { - companions: [...params.session.companions, params.companion], - roster: nextRosterWithoutRecruit, - releasedCompanion: null, - }; - } - - if (!params.releaseNpcId) { - throw conflict('队伍已满时必须明确指定一名离队同伴'); - } - - const replaceIndex = params.session.companions.findIndex( - (item) => item.npcId === params.releaseNpcId, - ); - if (replaceIndex < 0) { - throw conflict('指定的离队同伴不存在,无法完成换队招募'); - } - - const releasedCompanion = params.session.companions[replaceIndex]; - if (!releasedCompanion) { - throw conflict('指定的离队同伴不存在,无法完成换队招募'); - } - - const nextCompanions = [...params.session.companions]; - nextCompanions[replaceIndex] = params.companion; - - return { - companions: nextCompanions, - roster: normalizeRoster( - upsertCompanion(nextRosterWithoutRecruit, releasedCompanion), - nextCompanions, - ), - releasedCompanion, - }; -} - -function buildBattleTarget( - encounter: RuntimeEncounter, - rawGameState: JsonRecord, - playerProgression: unknown, - mode: 'fight' | 'spar', -) { - const currentScenePreset = isRecord(rawGameState.currentScenePreset) - ? rawGameState.currentScenePreset - : null; - const battleProfile = resolveHostileBattleProfile({ - playerProgression, - encounter, - battleMode: mode, - customWorldProfile: rawGameState.customWorldProfile, - sceneId: - (typeof currentScenePreset?.id === 'string' && currentScenePreset.id) || - null, - chapterState: rawGameState.chapterState, - storyEngineMemory: rawGameState.storyEngineMemory, - }); - - return { - id: encounter.id, - name: encounter.npcName, - hp: battleProfile.battleMaxHp, - maxHp: battleProfile.battleMaxHp, - description: encounter.npcDescription, - levelProfile: battleProfile.levelProfile, - experienceReward: battleProfile.experienceReward, - }; -} - -export function resolveNpcInteraction( - session: RuntimeSession, - functionId: string, - payload?: JsonRecord, -): NpcInteractionResolution { - const encounter = requireNpcEncounter(session); - const npcState = requireNpcState(session, encounter); - - switch (functionId) { - case 'npc_preview_talk': { - session.npcInteractionActive = true; - return { - actionText: `转向${encounter.npcName}`, - resultText: `你把注意力真正收回到${encounter.npcName}身上,接下来可以围绕这名角色做正式交互了。`, - patches: [ - { - type: 'status_changed', - inBattle: session.inBattle, - npcInteractionActive: session.npcInteractionActive, - currentNpcBattleMode: session.currentNpcBattleMode, - currentNpcBattleOutcome: session.currentNpcBattleOutcome, - }, - ], - }; - } - case 'npc_chat': { - session.npcInteractionActive = true; - const affinityGain = Math.max(2, 6 - npcState.chattedCount); - const nextAffinity = npcState.affinity + affinityGain; - setEncounterNpcState(session, { - ...npcState, - affinity: nextAffinity, - chattedCount: npcState.chattedCount + 1, - firstMeaningfulContactResolved: true, - }); - - return { - actionText: `继续和${encounter.npcName}交谈`, - resultText: `${encounter.npcName}愿意把话接下去,态度比刚才明显松动了一些。当前关系推进了 ${affinityGain} 点。`, - patches: [ - buildAffinityPatch(encounter, npcState.affinity, nextAffinity), - { - type: 'status_changed', - inBattle: session.inBattle, - npcInteractionActive: session.npcInteractionActive, - currentNpcBattleMode: session.currentNpcBattleMode, - currentNpcBattleOutcome: session.currentNpcBattleOutcome, - }, - ], - }; - } - case 'npc_help': { - if (npcState.helpUsed) { - throw conflict('当前 NPC 的一次性援手已经用完了'); - } - - const previousAffinity = npcState.affinity; - const nextAffinity = previousAffinity + 4; - session.playerHp = Math.min(session.playerMaxHp, session.playerHp + 10); - session.playerMana = Math.min( - session.playerMaxMana, - session.playerMana + 8, - ); - setEncounterNpcState(session, { - ...npcState, - affinity: nextAffinity, - helpUsed: true, - }); - - return { - actionText: `向${encounter.npcName}请求援手`, - resultText: `${encounter.npcName}给了你一次及时支援,你的状态暂时稳住了,关系也顺势拉近了一点。`, - patches: [ - buildAffinityPatch(encounter, previousAffinity, nextAffinity), - { - type: 'status_changed', - inBattle: session.inBattle, - npcInteractionActive: session.npcInteractionActive, - currentNpcBattleMode: session.currentNpcBattleMode, - currentNpcBattleOutcome: session.currentNpcBattleOutcome, - }, - ], - }; - } - case 'npc_recruit': { - if (npcState.recruited) { - throw conflict('当前 NPC 已经处于已招募状态'); - } - if (npcState.affinity < 60) { - throw conflict('当前关系还没达到招募阈值,暂时不能邀请入队'); - } - const releaseNpcId = readString(payload?.releaseNpcId) || null; - const recruitedCompanion = buildRecruitedCompanion( - session, - encounter, - npcState, - ); - const recruitmentResult = recruitCompanionToParty({ - session, - companion: recruitedCompanion, - releaseNpcId, - }); - const nextNpcState = { - ...markNpcFirstMeaningfulContactResolved(npcState), - recruited: true, - stanceProfile: applyStoryChoiceToStanceProfile( - npcState.stanceProfile, - 'npc_recruit', - { recruited: true }, - ), - }; - setEncounterNpcState(session, nextNpcState); - session.companions = recruitmentResult.companions; - session.roster = recruitmentResult.roster; - session.currentEncounter = null; - session.npcInteractionActive = false; - session.currentNpcBattleMode = null; - session.currentNpcBattleOutcome = null; - session.inBattle = false; - session.sceneHostileNpcs = []; - - return { - actionText: `邀请${encounter.npcName}加入队伍`, - resultText: recruitmentResult.releasedCompanion - ? `${encounter.npcName}接受了你的邀请,你先让一名当前同行暂时离队,把位置腾给了新的同行者。` - : `${encounter.npcName}接受了你的邀请,正式进入了同行队伍。`, - patches: [ - { - type: 'status_changed', - inBattle: session.inBattle, - npcInteractionActive: session.npcInteractionActive, - currentNpcBattleMode: session.currentNpcBattleMode, - currentNpcBattleOutcome: session.currentNpcBattleOutcome, - }, - { - type: 'encounter_changed', - encounterId: null, - }, - ], - }; - } - case 'npc_fight': - case 'npc_spar': { - const battleTarget = buildBattleTarget( - encounter, - session.rawGameState, - session.rawGameState.playerProgression, - functionId === 'npc_spar' ? 'spar' : 'fight', - ); - session.npcInteractionActive = false; - session.inBattle = true; - session.currentNpcBattleMode = - functionId === 'npc_spar' ? 'spar' : 'fight'; - session.currentNpcBattleOutcome = null; - session.currentEncounter = { - ...encounter, - levelProfile: battleTarget.levelProfile, - experienceReward: battleTarget.experienceReward, - }; - session.sceneHostileNpcs = [battleTarget]; - - return { - actionText: - functionId === 'npc_spar' - ? `与${encounter.npcName}点到为止切磋` - : `与${encounter.npcName}正面开战`, - resultText: - functionId === 'npc_spar' - ? `${encounter.npcName}摆开架势,准备和你来一场点到为止的切磋。` - : `${encounter.npcName}已经不再保留余地,当前冲突正式转入战斗结算。`, - patches: [ - { - type: 'status_changed', - inBattle: session.inBattle, - npcInteractionActive: session.npcInteractionActive, - currentNpcBattleMode: session.currentNpcBattleMode, - currentNpcBattleOutcome: session.currentNpcBattleOutcome, - }, - ], - }; - } - case 'npc_leave': { - session.currentEncounter = null; - session.npcInteractionActive = false; - session.currentNpcBattleMode = null; - session.currentNpcBattleOutcome = null; - session.sceneHostileNpcs = []; - session.inBattle = false; - - return { - actionText: `离开${encounter.npcName}`, - resultText: `你暂时没有继续和${encounter.npcName}纠缠,把注意力重新拉回了前路。`, - patches: [ - { - type: 'status_changed', - inBattle: session.inBattle, - npcInteractionActive: session.npcInteractionActive, - currentNpcBattleMode: session.currentNpcBattleMode, - currentNpcBattleOutcome: session.currentNpcBattleOutcome, - }, - { - type: 'encounter_changed', - encounterId: null, - }, - ], - }; - } - default: - throw conflict(`暂不支持的 NPC 动作:${functionId}`); - } -} diff --git a/server-node/src/modules/npc/npcTask6Primitives.test.ts b/server-node/src/modules/npc/npcTask6Primitives.test.ts deleted file mode 100644 index 3ec857df..00000000 --- a/server-node/src/modules/npc/npcTask6Primitives.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { createTestPlayerCharacter } from '../../testFixtures/runtimeCharacter.js'; -import { - buildInitialNpcState, - getGiftCandidates, - syncNpcTradeInventory, -} from './npcTask6Primitives.js'; - -function createState(overrides: Record = {}) { - return { - worldType: 'WUXIA', - customWorldProfile: null, - currentScenePreset: { - id: 'market-street', - name: '桥市', - description: '桥下的临时市集还没有散。', - treasureHints: [], - }, - storyHistory: [ - { - text: '你刚从桥口撤下来,正准备补足补给。', - }, - ], - playerCharacter: createTestPlayerCharacter<{ id: string }>(), - playerEquipment: { - weapon: { - tags: ['weapon', '快剑'], - buildProfile: { - role: '快剑', - tags: ['快剑', '突进'], - }, - }, - armor: null, - relic: null, - }, - activeBuildBuffs: [ - { - tags: ['续战'], - }, - ], - ...overrides, - }; -} - -test('buildInitialNpcState generates deterministic trade stock for runtime role npc', () => { - const state = createState(); - const npcState = buildInitialNpcState( - { - id: 'npc_vendor_01', - npcName: '桥市货郎', - npcDescription: '背着木箱沿街兜售补给的行脚货郎。', - context: '沿街商贩', - }, - 'WUXIA', - state, - ); - - assert.equal(npcState.affinity, 6); - assert.equal(typeof npcState.tradeStockSignature, 'string'); - assert.ok((npcState.tradeStockSignature ?? '').includes('npc_vendor_01')); - assert.ok(npcState.inventory.length > 0); - assert.ok( - npcState.inventory.every( - (item) => item.runtimeMetadata?.generationChannel === 'npc_trade', - ), - ); -}); - -test('syncNpcTradeInventory keeps non-trade items while refreshing generated stock', () => { - const state = createState({ - activeBuildBuffs: [{ tags: ['爆发'] }], - }); - const nextState = syncNpcTradeInventory( - state, - { - id: 'npc_vendor_02', - npcName: '药铺掌柜', - npcDescription: '一边记账一边看着药炉火候。', - context: '药商', - }, - { - affinity: 14, - helpUsed: false, - chattedCount: 0, - giftsGiven: 1, - inventory: [ - { - id: 'gift-token', - category: '信物', - name: '旧铜铃', - quantity: 1, - rarity: 'rare', - tags: ['relic'], - runtimeMetadata: { - generationChannel: 'npc_gift', - }, - }, - ], - recruited: false, - tradeStockSignature: 'outdated-signature', - firstMeaningfulContactResolved: true, - knownAttributeRumors: [], - revealedFacts: [], - seenBackstoryChapterIds: [], - }, - ); - - assert.ok(nextState.inventory.some((item) => item.id === 'gift-token')); - assert.ok( - nextState.inventory.some( - (item) => item.runtimeMetadata?.generationChannel === 'npc_trade', - ), - ); - assert.notEqual(nextState.tradeStockSignature, 'outdated-signature'); -}); - -test('getGiftCandidates prefers gifts that match npc role tags', () => { - const candidates = getGiftCandidates( - [ - { - id: 'mana-herb', - category: '材料', - name: '暖息草', - quantity: 1, - rarity: 'rare', - tags: ['material', 'mana'], - }, - { - id: 'plain-stone', - category: '材料', - name: '碎石', - quantity: 1, - rarity: 'common', - tags: ['material'], - }, - ], - { - id: 'npc_vendor_03', - npcName: '药行掌柜', - npcDescription: '对药性和回气补给都很熟。', - context: '药商', - }, - ); - - assert.equal(candidates[0]?.item.id, 'mana-herb'); - assert.ok((candidates[0]?.affinityGain ?? 0) > (candidates[1]?.affinityGain ?? 0)); - assert.match(candidates[0]?.attributeInsight?.reasonText ?? '', /回气|补给/u); -}); diff --git a/server-node/src/modules/npc/npcTask6Primitives.ts b/server-node/src/modules/npc/npcTask6Primitives.ts deleted file mode 100644 index 0218bdfd..00000000 --- a/server-node/src/modules/npc/npcTask6Primitives.ts +++ /dev/null @@ -1,411 +0,0 @@ -import { formatCurrency } from '../runtime/runtimeEconomyPrimitives.js'; -import { buildRelationState, sortInventoryItems } from '../runtime/runtimeStatePrimitives.js'; -import { - buildLooseRuntimeItemGenerationContext, - buildRuntimeInventoryStock, -} from '../runtime-item/runtimeItemModule.js'; -import { normalizeNpcPersistentState } from '../runtime/runtimeNpcStatePrimitives.js'; - -type RuntimeInventoryItem = { - id: string; - category: string; - name: string; - quantity: number; - rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'; - tags: string[]; - runtimeMetadata?: { - generationChannel?: string; - } | null; -}; - -type RuntimeEncounter = { - id?: string; - npcName: string; - context: string; - characterId?: string | null; - monsterPresetId?: string | null; - initialAffinity?: number; -}; - -type RuntimeNpcState = { - affinity: number; - helpUsed: boolean; - chattedCount: number; - giftsGiven: number; - inventory: RuntimeInventoryItem[]; - recruited: boolean; - relationState?: ReturnType; - revealedFacts?: string[]; - knownAttributeRumors?: string[]; - firstMeaningfulContactResolved?: boolean; - seenBackstoryChapterIds?: string[]; - tradeStockSignature?: string | null; - stanceProfile?: { - trust?: number; - warmth?: number; - ideologicalFit?: number; - fearOrGuard?: number; - loyalty?: number; - currentConflictTag?: string | null; - recentApprovals?: string[]; - recentDisapprovals?: string[]; - } | null; -}; - -function clampStanceMetric(value: number) { - return Math.max(0, Math.min(100, Math.round(value))); -} - -function buildInitialStanceProfile( - affinity: number, - options: { - recruited?: boolean; - hostile?: boolean; - roleText?: string | null; - } = {}, -) { - const recruitedBonus = options.recruited ? 14 : 0; - const hostilePenalty = options.hostile ? 18 : 0; - const roleText = options.roleText ?? ''; - const currentConflictTag = - /旧案|调查|追查/u.test(roleText) - ? '旧案' - : /守|卫|巡/u.test(roleText) - ? '守线' - : /商|摊|军需/u.test(roleText) - ? '交易' - : null; - - return { - trust: clampStanceMetric(42 + affinity * 0.55 + recruitedBonus - hostilePenalty), - warmth: clampStanceMetric(36 + affinity * 0.5 + recruitedBonus), - ideologicalFit: clampStanceMetric(48 + affinity * 0.25), - fearOrGuard: clampStanceMetric(62 - affinity * 0.55 + hostilePenalty), - loyalty: clampStanceMetric(24 + affinity * 0.35 + (options.recruited ? 26 : 0)), - currentConflictTag, - recentApprovals: [], - recentDisapprovals: [], - }; -} - -function getRarityScore(rarity: RuntimeInventoryItem['rarity']) { - switch (rarity) { - case 'legendary': - return 5; - case 'epic': - return 4; - case 'rare': - return 3; - case 'uncommon': - return 2; - default: - return 1; - } -} - -function describeAffinityShift(affinityGain: number) { - if (affinityGain >= 12) return '态度一下子软化了许多'; - if (affinityGain >= 8) return '态度明显和缓下来'; - if (affinityGain >= 5) return '态度比先前亲近了一些'; - return '态度略微放松了些'; -} - -function describeNpcAffinityInWords( - affinity: number, - options: { recruited?: boolean } = {}, -) { - if (options.recruited) { - return '已经把你视为并肩而行的同伴,交流时天然站在你这一边。'; - } - if (affinity >= 90) return '对你高度信赖,言谈间明显亲近,几乎已经把你当成自己人。'; - if (affinity >= 60) return '对你已经建立起稳固信任,愿意进一步合作。'; - if (affinity >= 30) return '对你的态度明显友善了许多,也更愿意正常交流。'; - if (affinity >= 15) return '戒备开始松动,愿意试探性地配合你的节奏。'; - if (affinity >= 0) return '仍保持明显距离,只会给出谨慎而有限的回应。'; - return '关系已经降到冰点,对你几乎不再保留善意。'; -} - -function isRuntimeTradeDrivenRoleNpc(encounter: RuntimeEncounter) { - return !encounter.characterId && !encounter.monsterPresetId; -} - -export function applyStoryChoiceToStanceProfile( - stanceProfile: RuntimeNpcState['stanceProfile'], - action: 'npc_chat' | 'npc_help' | 'npc_gift' | 'npc_recruit' | 'npc_quest_accept', - options: { - affinityGain?: number; - recruited?: boolean; - } = {}, -) { - const base = - stanceProfile ?? - buildInitialStanceProfile(0, { - recruited: options.recruited, - }); - const affinityGain = options.affinityGain ?? 0; - const approvalNotes = [...(base.recentApprovals ?? [])]; - const disapprovalNotes = [...(base.recentDisapprovals ?? [])]; - - const applyApproval = (note: string) => { - approvalNotes.push(note); - while (approvalNotes.length > 3) approvalNotes.shift(); - }; - const applyDisapproval = (note: string) => { - disapprovalNotes.push(note); - while (disapprovalNotes.length > 3) disapprovalNotes.shift(); - }; - - const next = { - ...base, - trust: base.trust ?? 40, - warmth: base.warmth ?? 35, - ideologicalFit: base.ideologicalFit ?? 45, - fearOrGuard: base.fearOrGuard ?? 55, - loyalty: base.loyalty ?? 20, - }; - - switch (action) { - case 'npc_chat': - next.trust += 6 + affinityGain * 2; - next.warmth += 4 + affinityGain * 2; - next.fearOrGuard -= 5 + affinityGain; - if (affinityGain >= 0) { - applyApproval('你愿意先从眼前局势和试探开始说话。'); - } else { - applyDisapproval('这轮交流没能真正对上节奏。'); - } - break; - case 'npc_help': - next.trust += 12; - next.warmth += 6; - next.fearOrGuard -= 8; - applyApproval('你在对方需要的时候搭了手。'); - break; - case 'npc_gift': - next.trust += 6 + affinityGain; - next.warmth += 10 + affinityGain * 2; - next.fearOrGuard -= 4; - applyApproval('你给出的东西回应了对方眼下的处境。'); - break; - case 'npc_recruit': - next.trust += 8; - next.warmth += 6; - next.loyalty += 18; - next.fearOrGuard -= 10; - applyApproval('你正式把对方纳入了同行关系。'); - break; - case 'npc_quest_accept': - next.trust += 7; - next.ideologicalFit += 5; - next.loyalty += 4; - applyApproval('你接住了对方主动交出来的事。'); - break; - } - - return { - ...next, - trust: clampStanceMetric(next.trust), - warmth: clampStanceMetric(next.warmth), - ideologicalFit: clampStanceMetric(next.ideologicalFit), - fearOrGuard: clampStanceMetric(next.fearOrGuard), - loyalty: clampStanceMetric(next.loyalty), - recentApprovals: approvalNotes, - recentDisapprovals: disapprovalNotes, - }; -} - -export function buildInitialNpcState( - encounter: RuntimeEncounter, - worldType: string | null | undefined, - state?: { - currentScenePreset?: { - id: string; - name: string; - description?: string; - treasureHints?: string[]; - } | null; - playerCharacter?: { - id: string; - } | null; - } | null, -) { - const initialAffinity = - encounter.initialAffinity ?? - (encounter.monsterPresetId ? -40 : encounter.characterId ? 18 : 6); - const baseState = normalizeNpcPersistentState({ - affinity: initialAffinity, - relationState: buildRelationState(initialAffinity), - helpUsed: false, - chattedCount: 0, - giftsGiven: 0, - inventory: [] as RuntimeInventoryItem[], - tradeStockSignature: null, - recruited: false, - revealedFacts: [], - knownAttributeRumors: [], - firstMeaningfulContactResolved: false, - seenBackstoryChapterIds: [], - stanceProfile: buildInitialStanceProfile(initialAffinity, { - recruited: false, - hostile: Boolean(encounter.monsterPresetId) || initialAffinity < 0, - roleText: encounter.context, - }), - }); - - if (state && isRuntimeTradeDrivenRoleNpc(encounter)) { - return syncNpcTradeInventory( - { - worldType, - currentScenePreset: state.currentScenePreset ?? null, - playerCharacter: state.playerCharacter ?? null, - }, - encounter, - baseState, - ); - } - - return baseState; -} - -export function getGiftCandidates( - playerInventory: RuntimeInventoryItem[], - _encounter: RuntimeEncounter, -) { - return [...playerInventory] - .filter((item) => item.quantity > 0) - .map((item) => ({ - item, - affinityGain: - Math.min( - 24, - 4 + - getRarityScore(item.rarity) * 3 + - (item.tags.includes('mana') ? 3 : 0) + - (item.tags.includes('healing') ? 3 : 0), - ), - attributeInsight: { - reasonText: item.tags.includes('mana') - ? '这份礼物明显更适合对方当前的回气与补给需求。' - : item.tags.includes('healing') - ? '这份礼物更像是在照顾对方眼下的补给处境。' - : '这份礼物至少表达了你愿意先拿出诚意。', - }, - })) - .sort((left, right) => { - const diff = right.affinityGain - left.affinityGain; - if (diff !== 0) return diff; - return getRarityScore(right.item.rarity) - getRarityScore(left.item.rarity); - }); -} - -export function buildNpcGiftResultText( - encounter: RuntimeEncounter, - item: RuntimeInventoryItem, - affinityGain: number, - nextAffinity: number, - attributeSummary?: string, -) { - const summaryText = attributeSummary ? `你感到:${attributeSummary}` : ''; - return `${encounter.npcName}收下了${item.name},${describeAffinityShift(affinityGain)}。${describeNpcAffinityInWords(nextAffinity)}${summaryText}`; -} - -export function buildNpcGiftCommitActionText( - encounter: RuntimeEncounter, - item: RuntimeInventoryItem, -) { - return `把${item.name}赠给${encounter.npcName}`; -} - -export function buildNpcTradeTransactionResultText(params: { - encounter: RuntimeEncounter; - mode: 'buy' | 'sell'; - item: RuntimeInventoryItem; - quantity: number; - totalPrice: number; - worldType: string | null | undefined; -}) { - const quantityText = - params.quantity > 1 ? `${params.item.name} x${params.quantity}` : params.item.name; - - if (params.mode === 'sell') { - return `${params.encounter.npcName}收下了${quantityText},付给你${formatCurrency(params.totalPrice, params.worldType)}。`; - } - - return `${params.encounter.npcName}收下了${formatCurrency(params.totalPrice, params.worldType)},把${quantityText}卖给了你。`; -} - -export function buildNpcTradeTransactionActionText(params: { - encounter: RuntimeEncounter; - mode: 'buy' | 'sell'; - item: RuntimeInventoryItem; - quantity: number; -}) { - const quantityText = - params.quantity > 1 ? `${params.item.name} x${params.quantity}` : params.item.name; - - if (params.mode === 'sell') { - return `把${quantityText}卖给${params.encounter.npcName}`; - } - - return `从${params.encounter.npcName}手里买下${quantityText}`; -} - -export function syncNpcTradeInventory( - state: { - worldType: string | null | undefined; - currentScenePreset?: { - id: string; - name: string; - description?: string; - treasureHints?: string[]; - } | null; - playerCharacter?: { - id: string; - } | null; - }, - encounter: RuntimeEncounter, - npcState: RuntimeNpcState, -) { - if (!isRuntimeTradeDrivenRoleNpc(encounter)) { - return npcState; - } - - const tradeStockSignature = `${encounter.id ?? encounter.npcName}:${state.currentScenePreset?.id ?? 'scene'}:${state.worldType ?? 'world'}`; - if (npcState.tradeStockSignature === tradeStockSignature) { - return npcState; - } - - const runtimeStock = buildRuntimeInventoryStock( - buildLooseRuntimeItemGenerationContext({ - worldType: state.worldType, - scene: state.currentScenePreset ?? null, - encounter: { - ...encounter, - kind: 'npc', - npcDescription: encounter.context, - npcAvatar: '', - context: encounter.context, - }, - playerCharacterId: state.playerCharacter?.id ?? 'npc-trade-preview', - generationChannel: 'npc_trade', - }), - { - seedKey: `npc-trade:${encounter.id ?? encounter.npcName}`, - itemCount: 4, - fixedKinds: ['consumable', 'material', 'relic', 'equipment'], - fixedPermanence: ['timed', 'resource', 'permanent', 'permanent'], - } as Parameters[1], - ); - - const preservedInventory = npcState.tradeStockSignature - ? npcState.inventory.filter( - (item) => item.runtimeMetadata?.generationChannel !== 'npc_trade', - ) - : []; - - return normalizeNpcPersistentState({ - ...npcState, - inventory: sortInventoryItems([...preservedInventory, ...runtimeStock]), - tradeStockSignature, - }); -} diff --git a/server-node/src/modules/progression/chapterProgressionPlanner.test.ts b/server-node/src/modules/progression/chapterProgressionPlanner.test.ts deleted file mode 100644 index e7f0144c..00000000 --- a/server-node/src/modules/progression/chapterProgressionPlanner.test.ts +++ /dev/null @@ -1,225 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import type { CustomWorldProfile } from '../custom-world/runtimeTypes.js'; -import { - buildChapterProgressionPlans, - resolveCurrentChapterProgressionContext, -} from './chapterProgressionPlanner.js'; - -function createProgressionProfile() { - return { - id: 'custom-world-progression', - settingText: '测试世界', - name: '测试世界', - subtitle: '章节成长测试', - summary: '用于章节成长规划测试。', - tone: '紧张', - playerGoal: '推进章节', - templateWorldType: 'CUSTOM', - majorFactions: [], - coreConflicts: [], - attributeSchema: { - id: 'schema-1', - worldId: 'custom-world-progression', - schemaVersion: 1, - schemaName: '测试属性', - generatedFrom: { - worldType: 'CUSTOM', - worldName: '测试世界', - settingSummary: '测试', - tone: '紧张', - conflictCore: '推进', - }, - slots: [], - }, - playableNpcs: [], - storyNpcs: [ - { - id: 'npc_chapter_1_raider', - name: '谷口匪徒', - title: '匪徒', - role: '敌对角色', - description: '盘踞谷口的劫匪', - backstory: '', - personality: '', - motivation: '', - combatStyle: '近战', - initialAffinity: -30, - relationshipHooks: [], - tags: ['hostile'], - backstoryReveal: { - publicSummary: '', - privateChatUnlockAffinity: 0, - chapters: [], - }, - skills: [], - initialItems: [], - }, - { - id: 'npc_chapter_2_hunter', - name: '林地猎手', - title: '追猎者', - role: '敌对角色', - description: '在林间追猎闯入者', - backstory: '', - personality: '', - motivation: '', - combatStyle: '远程', - initialAffinity: -24, - relationshipHooks: [], - tags: ['hostile'], - backstoryReveal: { - publicSummary: '', - privateChatUnlockAffinity: 0, - chapters: [], - }, - skills: [], - initialItems: [], - }, - { - id: 'npc_chapter_3_lord', - name: '祭坛领主', - title: '镇守者', - role: '敌对首领', - description: '守在祭坛深处的最终敌人', - backstory: '', - personality: '', - motivation: '', - combatStyle: '重压', - initialAffinity: -40, - relationshipHooks: [], - tags: ['hostile', 'boss'], - backstoryReveal: { - publicSummary: '', - privateChatUnlockAffinity: 0, - chapters: [], - }, - skills: [], - initialItems: [], - }, - ], - items: [], - landmarks: [], - sceneChapterBlueprints: [ - { - id: 'chapter-1', - sceneId: 'scene-1', - title: '第一章', - summary: '谷口起势', - linkedThreadIds: [], - linkedLandmarkIds: [], - acts: [ - { - id: 'act-1-open', - sceneId: 'scene-1', - title: '谷口相撞', - summary: '初次冲突', - stageCoverage: ['opening'], - encounterNpcIds: ['npc_chapter_1_raider'], - primaryNpcId: 'npc_chapter_1_raider', - linkedThreadIds: [], - advanceRule: 'after_primary_contact', - actGoal: '打开局面', - transitionHook: '继续深入', - }, - ], - }, - { - id: 'chapter-2', - sceneId: 'scene-2', - title: '第二章', - summary: '林地围猎', - linkedThreadIds: [], - linkedLandmarkIds: [], - acts: [ - { - id: 'act-2-mid', - sceneId: 'scene-2', - title: '林地追击', - summary: '压力上升', - stageCoverage: ['expansion', 'turning_point'], - encounterNpcIds: ['npc_chapter_2_hunter'], - primaryNpcId: 'npc_chapter_2_hunter', - linkedThreadIds: [], - advanceRule: 'after_active_step_complete', - actGoal: '逼近真相', - transitionHook: '抵达深处', - }, - ], - }, - { - id: 'chapter-3', - sceneId: 'scene-3', - title: '第三章', - summary: '祭坛对决', - linkedThreadIds: [], - linkedLandmarkIds: [], - acts: [ - { - id: 'act-3-final', - sceneId: 'scene-3', - title: '祭坛收束', - summary: '正面收口', - stageCoverage: ['climax'], - encounterNpcIds: ['npc_chapter_3_lord'], - primaryNpcId: 'npc_chapter_3_lord', - linkedThreadIds: [], - advanceRule: 'after_chapter_resolution', - actGoal: '击败首领', - transitionHook: '收束余波', - }, - ], - }, - ], - } as unknown as CustomWorldProfile; -} - -test('buildChapterProgressionPlans builds increasing chapter budgets from blueprints', () => { - const plans = buildChapterProgressionPlans(createProgressionProfile()); - - assert.equal(plans.length, 3); - assert.deepEqual( - plans.map((plan) => plan.chapterIndex), - [1, 2, 3], - ); - assert.ok(plans[1]!.entryPseudoLevel > plans[0]!.entryPseudoLevel); - assert.ok(plans[2]!.exitPseudoLevel > plans[1]!.exitPseudoLevel); - assert.equal( - plans[0]!.questXpBudget + plans[0]!.hostileXpBudget, - plans[0]!.totalXpBudget, - ); - assert.ok(plans[2]!.totalXpBudget >= plans[0]!.totalXpBudget); - assert.ok(plans[2]!.hostileXpBudget >= plans[0]!.hostileXpBudget); -}); - -test('resolveCurrentChapterProgressionContext follows the current act and explicit stage', () => { - const context = resolveCurrentChapterProgressionContext({ - customWorldProfile: createProgressionProfile(), - sceneId: 'scene-2', - chapterState: { - id: 'chapter-2', - stage: 'turning_point', - sceneId: 'scene-2', - }, - storyEngineMemory: { - currentChapter: { - id: 'chapter-2', - stage: 'turning_point', - sceneId: 'scene-2', - }, - currentSceneActState: { - sceneId: 'scene-2', - chapterId: 'chapter-2', - currentActId: 'act-2-mid', - currentActIndex: 0, - }, - }, - }); - - assert.ok(context); - assert.equal(context?.plan.chapterId, 'chapter-2'); - assert.equal(context?.plan.chapterIndex, 2); - assert.equal(context?.activeAct?.id, 'act-2-mid'); - assert.equal(context?.stage, 'turning_point'); -}); diff --git a/server-node/src/modules/progression/chapterProgressionPlanner.ts b/server-node/src/modules/progression/chapterProgressionPlanner.ts deleted file mode 100644 index 4815d30e..00000000 --- a/server-node/src/modules/progression/chapterProgressionPlanner.ts +++ /dev/null @@ -1,480 +0,0 @@ -import type { - CustomWorldProfile, - SceneActBlueprint, - SceneActStage, - SceneChapterBlueprint, -} from '../custom-world/runtimeTypes.js'; - -type JsonRecord = Record; - -type ChapterStateLike = { - id: string; - stage: SceneActStage; - sceneId: string | null; -}; - -type SceneActRuntimeStateLike = { - sceneId: string; - chapterId: string; - currentActId: string; - currentActIndex: number; -}; - -export type ChapterPaceBand = - | 'opening_fast' - | 'steady' - | 'pressure' - | 'finale_dense'; - -export interface ChapterProgressionPlan { - chapterId: string; - chapterIndex: number; - totalChapters: number; - entryPseudoLevel: number; - exitPseudoLevel: number; - entryLevel: number; - exitLevel: number; - totalXpBudget: number; - questXpBudget: number; - hostileXpBudget: number; - expectedHostileDefeatCount: number; - paceBand: ChapterPaceBand; -} - -export interface ChapterProgressionContext { - plan: ChapterProgressionPlan; - activeChapter: SceneChapterBlueprint; - activeAct: SceneActBlueprint | null; - stage: SceneActStage; -} - -const DEFAULT_STAGE: SceneActStage = 'opening'; -const DEFAULT_TERMINAL_STORY_LEVEL = 15; -const MIN_TERMINAL_STORY_LEVEL = 5; -const PSEUDO_LEVEL_CURVE_EXPONENT = 0.92; - -function isRecord(value: unknown): value is JsonRecord { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function readString(value: unknown) { - return typeof value === 'string' && value.trim() ? value.trim() : ''; -} - -function readNumber(value: unknown) { - return typeof value === 'number' && Number.isFinite(value) ? value : null; -} - -function roundToNearestFive(value: number) { - return Math.round(value / 5) * 5; -} - -function normalizeStage(value: unknown): SceneActStage | null { - return value === 'opening' || - value === 'expansion' || - value === 'turning_point' || - value === 'climax' || - value === 'aftermath' - ? value - : null; -} - -function readChapterState(value: unknown): ChapterStateLike | null { - if (!isRecord(value)) { - return null; - } - - const id = readString(value.id); - const stage = normalizeStage(value.stage); - if (!id || !stage) { - return null; - } - - return { - id, - stage, - sceneId: readString(value.sceneId) || null, - }; -} - -function readSceneActRuntimeState(value: unknown): SceneActRuntimeStateLike | null { - if (!isRecord(value)) { - return null; - } - - const sceneId = readString(value.sceneId); - const chapterId = readString(value.chapterId); - const currentActId = readString(value.currentActId); - const currentActIndex = readNumber(value.currentActIndex); - if (!sceneId || !chapterId || !currentActId || currentActIndex === null) { - return null; - } - - return { - sceneId, - chapterId, - currentActId, - currentActIndex: Math.max(0, Math.round(currentActIndex)), - }; -} - -function readStoryEngineMemoryChapter(value: unknown) { - return readChapterState(isRecord(value) ? value.currentChapter : null); -} - -function readStoryEngineMemoryActState(value: unknown) { - return readSceneActRuntimeState( - isRecord(value) ? value.currentSceneActState : null, - ); -} - -function getChapterBlueprints( - profile: CustomWorldProfile | null | undefined, -) { - return (profile?.sceneChapterBlueprints ?? []).filter( - (entry): entry is SceneChapterBlueprint => - Boolean(entry?.id && entry.sceneId && Array.isArray(entry.acts)), - ); -} - -function resolveExplicitStage(params: { - chapterState?: unknown; - storyEngineMemory?: unknown; -}) { - return ( - readChapterState(params.chapterState)?.stage ?? - readStoryEngineMemoryChapter(params.storyEngineMemory)?.stage ?? - null - ); -} - -function pickActStage(act: SceneActBlueprint | null) { - if (!act) { - return null; - } - - return act.stageCoverage - .map((stage) => normalizeStage(stage)) - .find((stage): stage is SceneActStage => Boolean(stage)) ?? null; -} - -function resolveActiveChapterBlueprint(params: { - customWorldProfile?: CustomWorldProfile | null; - sceneId?: string | null; - chapterState?: unknown; - storyEngineMemory?: unknown; -}) { - const chapters = getChapterBlueprints(params.customWorldProfile); - if (chapters.length <= 0) { - return null; - } - - const runtimeActState = readStoryEngineMemoryActState(params.storyEngineMemory); - if (runtimeActState) { - const matchedByActState = chapters.find( - (chapter) => - chapter.id === runtimeActState.chapterId && - chapter.sceneId === runtimeActState.sceneId, - ); - if (matchedByActState) { - return matchedByActState; - } - } - - const requestedSceneId = - readString(params.sceneId) || - readChapterState(params.chapterState)?.sceneId || - readStoryEngineMemoryChapter(params.storyEngineMemory)?.sceneId || - ''; - if (requestedSceneId) { - const matchedByScene = chapters.find( - (chapter) => - chapter.sceneId === requestedSceneId || - chapter.linkedLandmarkIds.includes(requestedSceneId), - ); - if (matchedByScene) { - return matchedByScene; - } - } - - const explicitChapterId = - readChapterState(params.chapterState)?.id || - readStoryEngineMemoryChapter(params.storyEngineMemory)?.id || - ''; - if (explicitChapterId) { - const matchedById = chapters.find((chapter) => chapter.id === explicitChapterId); - if (matchedById) { - return matchedById; - } - } - - return chapters[0] ?? null; -} - -function resolveActiveActBlueprint(params: { - activeChapter: SceneChapterBlueprint; - explicitStage?: SceneActStage | null; - storyEngineMemory?: unknown; -}) { - const runtimeActState = readStoryEngineMemoryActState(params.storyEngineMemory); - if ( - runtimeActState && - runtimeActState.chapterId === params.activeChapter.id && - runtimeActState.sceneId === params.activeChapter.sceneId - ) { - const matchedById = params.activeChapter.acts.find( - (act) => act.id === runtimeActState.currentActId, - ); - if (matchedById) { - return matchedById; - } - - const matchedByIndex = params.activeChapter.acts[runtimeActState.currentActIndex]; - if (matchedByIndex) { - return matchedByIndex; - } - } - - if (params.explicitStage) { - const matchedByStage = params.activeChapter.acts.find((act) => - act.stageCoverage.includes(params.explicitStage!), - ); - if (matchedByStage) { - return matchedByStage; - } - } - - return params.activeChapter.acts[0] ?? null; -} - -function resolveTerminalStoryLevel(totalChapters: number) { - return Math.max( - MIN_TERMINAL_STORY_LEVEL, - Math.min( - DEFAULT_TERMINAL_STORY_LEVEL, - Math.round(3 + Math.max(1, totalChapters) * 2.4), - ), - ); -} - -function computeXpToNextLevel(level: number) { - const scale = Math.max(0, level - 1); - return 60 + 20 * scale + 8 * scale * scale; -} - -function resolvePseudoLevelXp(pseudoLevel: number) { - const normalizedLevel = Math.max(1, pseudoLevel); - const lowerLevel = Math.floor(normalizedLevel); - let lowerLevelXp = 0; - - for (let level = 1; level < lowerLevel; level += 1) { - lowerLevelXp += computeXpToNextLevel(level); - } - - return ( - lowerLevelXp + - computeXpToNextLevel(lowerLevel) * (normalizedLevel - lowerLevel) - ); -} - -function resolveChapterBoundaryPseudoLevel(params: { - boundaryIndex: number; - totalChapters: number; -}) { - if (params.boundaryIndex <= 0 || params.totalChapters <= 0) { - return 1; - } - - const progress = Math.min( - 1, - Math.max(0, params.boundaryIndex / params.totalChapters), - ); - const terminalStoryLevel = resolveTerminalStoryLevel(params.totalChapters); - - return ( - 1 + - Math.pow(progress, PSEUDO_LEVEL_CURVE_EXPONENT) * - Math.max(0, terminalStoryLevel - 1) - ); -} - -function resolveEncounterNpcIds(chapter: SceneChapterBlueprint) { - return [...new Set(chapter.acts.flatMap((act) => act.encounterNpcIds))]; -} - -function isLikelyHostileNpc( - profile: CustomWorldProfile, - npcId: string, -) { - const matchedNpc = profile.storyNpcs.find((npc) => npc.id === npcId); - if (!matchedNpc) { - return /hostile|enemy|monster|bandit|boss|elite|敌|怪|匪|兽/u.test(npcId); - } - - if (matchedNpc.initialAffinity < 0) { - return true; - } - - const fingerprint = [ - matchedNpc.role, - matchedNpc.name, - matchedNpc.title, - matchedNpc.description, - ...matchedNpc.tags, - ].join(' '); - - return /hostile|enemy|monster|bandit|boss|elite|敌|怪|匪|兽|袭击|追猎/u.test( - fingerprint, - ); -} - -function resolveHostileShare(params: { - totalEncounterCount: number; - hostileEncounterCount: number; -}) { - if (params.hostileEncounterCount <= 0) { - return 0; - } - - const hostileRatio = - params.hostileEncounterCount / Math.max(1, params.totalEncounterCount); - - if (hostileRatio >= 0.55) { - return 0.45; - } - - if (hostileRatio <= 0.2) { - return 0.25; - } - - return 0.35; -} - -function resolveChapterPaceBand(params: { - chapterIndex: number; - totalChapters: number; - hostileShare: number; -}) { - if (params.chapterIndex <= 1) { - return 'opening_fast' as const; - } - - if (params.chapterIndex >= params.totalChapters) { - return 'finale_dense' as const; - } - - if (params.hostileShare >= 0.45) { - return 'pressure' as const; - } - - return 'steady' as const; -} - -function buildChapterPlan(params: { - profile: CustomWorldProfile; - chapter: SceneChapterBlueprint; - chapterIndex: number; - totalChapters: number; -}) { - const entryPseudoLevel = resolveChapterBoundaryPseudoLevel({ - boundaryIndex: params.chapterIndex - 1, - totalChapters: params.totalChapters, - }); - const exitPseudoLevel = resolveChapterBoundaryPseudoLevel({ - boundaryIndex: params.chapterIndex, - totalChapters: params.totalChapters, - }); - const totalXpBudget = Math.max( - 40, - roundToNearestFive( - resolvePseudoLevelXp(exitPseudoLevel) - - resolvePseudoLevelXp(entryPseudoLevel), - ), - ); - const encounterNpcIds = resolveEncounterNpcIds(params.chapter); - const hostileEncounterCount = encounterNpcIds.filter((npcId) => - isLikelyHostileNpc(params.profile, npcId), - ).length; - const hostileShare = resolveHostileShare({ - totalEncounterCount: encounterNpcIds.length, - hostileEncounterCount, - }); - const expectedHostileDefeatCount = - hostileEncounterCount > 0 - ? Math.max(hostileEncounterCount, Math.min(encounterNpcIds.length, 3)) - : 0; - const hostileXpBudget = - expectedHostileDefeatCount > 0 - ? Math.max(5, roundToNearestFive(totalXpBudget * hostileShare)) - : 0; - const questXpBudget = Math.max(0, totalXpBudget - hostileXpBudget); - - return { - chapterId: params.chapter.id, - chapterIndex: params.chapterIndex, - totalChapters: params.totalChapters, - entryPseudoLevel: Number(entryPseudoLevel.toFixed(3)), - exitPseudoLevel: Number(exitPseudoLevel.toFixed(3)), - entryLevel: Math.max(1, Math.floor(entryPseudoLevel)), - exitLevel: Math.max(1, Math.round(exitPseudoLevel)), - totalXpBudget, - questXpBudget, - hostileXpBudget, - expectedHostileDefeatCount, - paceBand: resolveChapterPaceBand({ - chapterIndex: params.chapterIndex, - totalChapters: params.totalChapters, - hostileShare, - }), - } satisfies ChapterProgressionPlan; -} - -export function buildChapterProgressionPlans( - customWorldProfile: CustomWorldProfile | null | undefined, -) { - const chapters = getChapterBlueprints(customWorldProfile); - if (!customWorldProfile || chapters.length <= 0) { - return []; - } - - return chapters.map((chapter, index) => - buildChapterPlan({ - profile: customWorldProfile, - chapter, - chapterIndex: index + 1, - totalChapters: chapters.length, - }), - ); -} - -export function resolveCurrentChapterProgressionContext(params: { - customWorldProfile?: CustomWorldProfile | null; - sceneId?: string | null; - chapterState?: unknown; - storyEngineMemory?: unknown; -}) { - const activeChapter = resolveActiveChapterBlueprint(params); - if (!activeChapter || !params.customWorldProfile) { - return null; - } - - const plans = buildChapterProgressionPlans(params.customWorldProfile); - const plan = plans.find((entry) => entry.chapterId === activeChapter.id); - if (!plan) { - return null; - } - - const explicitStage = resolveExplicitStage(params); - const activeAct = resolveActiveActBlueprint({ - activeChapter, - explicitStage, - storyEngineMemory: params.storyEngineMemory, - }); - - return { - plan, - activeChapter, - activeAct, - stage: explicitStage ?? pickActStage(activeAct) ?? DEFAULT_STAGE, - } satisfies ChapterProgressionContext; -} diff --git a/server-node/src/modules/progression/hostileProgressionService.test.ts b/server-node/src/modules/progression/hostileProgressionService.test.ts deleted file mode 100644 index 031cf445..00000000 --- a/server-node/src/modules/progression/hostileProgressionService.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import type { CustomWorldProfile } from '../custom-world/runtimeTypes.js'; -import { resolveHostileBattleProfile } from './hostileProgressionService.js'; - -function createAutoScaledProfile() { - return { - id: 'custom-world-auto-level', - settingText: '测试世界', - name: '测试世界', - subtitle: '自动定级', - summary: '用于 hostile 自动定级测试。', - tone: '压迫', - playerGoal: '推进终章', - templateWorldType: 'CUSTOM', - majorFactions: [], - coreConflicts: [], - attributeSchema: { - id: 'schema-1', - worldId: 'custom-world-auto-level', - schemaVersion: 1, - schemaName: '测试属性', - generatedFrom: { - worldType: 'CUSTOM', - worldName: '测试世界', - settingSummary: '测试', - tone: '压迫', - conflictCore: '推进', - }, - slots: [], - }, - playableNpcs: [], - storyNpcs: [ - { - id: 'npc_chapter_final', - name: '祭坛领主', - title: '镇守者', - role: '敌对首领', - description: '最终守关者', - backstory: '', - personality: '', - motivation: '', - combatStyle: '重压', - initialAffinity: -40, - relationshipHooks: [], - tags: ['hostile', 'boss'], - backstoryReveal: { - publicSummary: '', - privateChatUnlockAffinity: 0, - chapters: [], - }, - skills: [], - initialItems: [], - }, - ], - items: [], - landmarks: [], - sceneChapterBlueprints: [ - { - id: 'chapter-final', - sceneId: 'scene-final', - title: '终章', - summary: '最终对决', - linkedThreadIds: [], - linkedLandmarkIds: [], - acts: [ - { - id: 'act-final', - sceneId: 'scene-final', - title: '祭坛收束', - summary: '最终收口', - stageCoverage: ['climax'], - encounterNpcIds: ['npc_chapter_final'], - primaryNpcId: 'npc_chapter_final', - linkedThreadIds: [], - advanceRule: 'after_chapter_resolution', - actGoal: '击败首领', - transitionHook: '进入余波', - }, - ], - }, - ], - } as unknown as CustomWorldProfile; -} - -test('resolveHostileBattleProfile falls back to the current player level for standard hostiles', () => { - const profile = resolveHostileBattleProfile({ - playerProgression: { - level: 5, - currentLevelXp: 0, - totalXp: 472, - xpToNextLevel: 268, - }, - encounter: { - hostile: true, - monsterPresetId: 'monster-01', - }, - battleMode: 'fight', - }); - - assert.equal(profile.levelProfile.level, 5); - assert.equal(profile.levelProfile.progressionRole, 'hostile_standard'); - assert.equal(profile.levelProfile.referenceStrength, 260); - assert.equal(profile.experienceReward, 20); - assert.equal(profile.battleMaxHp, 48); -}); - -test('resolveHostileBattleProfile preserves explicit level metadata and rewards', () => { - const profile = resolveHostileBattleProfile({ - playerProgression: { - level: 4, - currentLevelXp: 0, - totalXp: 280, - xpToNextLevel: 192, - }, - encounter: { - hostile: true, - levelProfile: { - level: 7, - referenceStrength: 412, - progressionRole: 'hostile_elite', - source: 'chapter_auto', - chapterId: 'chapter-03', - }, - experienceReward: 55, - }, - battleMode: 'fight', - }); - - assert.equal(profile.levelProfile.level, 7); - assert.equal(profile.levelProfile.referenceStrength, 412); - assert.equal(profile.levelProfile.progressionRole, 'hostile_elite'); - assert.equal(profile.levelProfile.source, 'chapter_auto'); - assert.equal(profile.levelProfile.chapterId, 'chapter-03'); - assert.equal(profile.experienceReward, 55); - assert.equal(profile.battleMaxHp, 86); -}); - -test('resolveHostileBattleProfile prefers chapter auto scaling over player fallback when chapter context exists', () => { - const profile = resolveHostileBattleProfile({ - playerProgression: { - level: 2, - currentLevelXp: 0, - totalXp: 80, - xpToNextLevel: 88, - }, - encounter: { - id: 'npc_chapter_final', - hostile: true, - monsterPresetId: 'final-lord', - }, - battleMode: 'fight', - customWorldProfile: createAutoScaledProfile(), - sceneId: 'scene-final', - chapterState: { - id: 'chapter-final', - stage: 'climax', - sceneId: 'scene-final', - }, - storyEngineMemory: { - currentChapter: { - id: 'chapter-final', - stage: 'climax', - sceneId: 'scene-final', - }, - currentSceneActState: { - sceneId: 'scene-final', - chapterId: 'chapter-final', - currentActId: 'act-final', - currentActIndex: 0, - }, - }, - }); - - assert.equal(profile.levelProfile.source, 'chapter_auto'); - assert.equal(profile.levelProfile.chapterId, 'chapter-final'); - assert.equal(profile.levelProfile.chapterIndex, 1); - assert.equal(profile.levelProfile.progressionRole, 'hostile_boss'); - assert.ok(profile.levelProfile.level > 2); - assert.ok(profile.experienceReward > 0); -}); diff --git a/server-node/src/modules/progression/hostileProgressionService.ts b/server-node/src/modules/progression/hostileProgressionService.ts deleted file mode 100644 index 9bf75b3d..00000000 --- a/server-node/src/modules/progression/hostileProgressionService.ts +++ /dev/null @@ -1,353 +0,0 @@ -import { getLevelBenchmark, MAX_PLAYER_LEVEL } from './levelBenchmarks.js'; -import { normalizePlayerProgressionState } from './playerProgressionService.js'; -import type { CustomWorldProfile, SceneActStage } from '../custom-world/runtimeTypes.js'; -import { - resolveCurrentChapterProgressionContext, - type ChapterProgressionContext, -} from './chapterProgressionPlanner.js'; -import { resolveChapterAutoLevelProfile } from './npcLevelResolver.js'; - -type JsonRecord = Record; - -export type ProgressionRole = - | 'guide' - | 'ambient' - | 'support' - | 'hostile_standard' - | 'hostile_elite' - | 'hostile_boss' - | 'rival'; - -export interface RuntimeEntityLevelProfile { - level: number; - referenceStrength: number; - chapterId?: string | null; - chapterIndex?: number | null; - progressionRole: ProgressionRole; - source: 'chapter_auto' | 'preset_override' | 'manual'; -} - -export interface RuntimeHostileEncounterSeed { - id?: string | null; - hostile?: boolean; - monsterPresetId?: string | null; - levelProfile?: unknown; - experienceReward?: unknown; -} - -export interface ResolvedHostileBattleProfile { - levelProfile: RuntimeEntityLevelProfile; - experienceReward: number; - battleMaxHp: number; -} - -const ROLE_HP_BONUS: Record = { - guide: 0, - ambient: 0, - support: 0, - hostile_standard: 0, - hostile_elite: 10, - hostile_boss: 24, - rival: 6, -}; - -const ROLE_XP_MULTIPLIER: Record = { - guide: 0, - ambient: 0, - support: 0, - hostile_standard: 1, - hostile_elite: 1.15, - hostile_boss: 1.3, - rival: 1, -}; - -function isRecord(value: unknown): value is JsonRecord { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function readNumber(value: unknown) { - return typeof value === 'number' && Number.isFinite(value) ? value : null; -} - -function readString(value: unknown) { - return typeof value === 'string' && value.trim() ? value.trim() : ''; -} - -function clampLevel(value: unknown) { - const parsed = - typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 1; - return Math.min(MAX_PLAYER_LEVEL, Math.max(1, parsed)); -} - -function clampNonNegativeInteger(value: unknown) { - const parsed = - typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 0; - return Math.max(0, parsed); -} - -function roundToNearestFive(value: number) { - return Math.round(value / 5) * 5; -} - -function normalizeProgressionRole( - value: unknown, - fallback: ProgressionRole, -): ProgressionRole { - return value === 'guide' || - value === 'ambient' || - value === 'support' || - value === 'hostile_standard' || - value === 'hostile_elite' || - value === 'hostile_boss' || - value === 'rival' - ? value - : fallback; -} - -function normalizeLevelProfileSource( - value: unknown, - fallback: RuntimeEntityLevelProfile['source'], -) { - return value === 'chapter_auto' || - value === 'preset_override' || - value === 'manual' - ? value - : fallback; -} - -function resolveDefaultRole(params: { - encounter?: RuntimeHostileEncounterSeed | null; - battleMode: 'fight' | 'spar'; -}): ProgressionRole { - if (params.battleMode === 'spar') { - return 'rival'; - } - - if ( - params.encounter?.hostile === true || - readString(params.encounter?.monsterPresetId).length > 0 - ) { - return 'hostile_standard'; - } - - return 'rival'; -} - -function resolveLevelDeltaMultiplier(playerLevel: number, targetLevel: number) { - const delta = targetLevel - playerLevel; - - if (delta <= -4) { - return 0.3; - } - - if (delta <= -2) { - return 0.7; - } - - if (delta >= 2) { - return 1.15; - } - - return 1; -} - -function resolveChapterStageMultiplier(stage: SceneActStage | null | undefined) { - switch (stage) { - case 'opening': - return 0.9; - case 'turning_point': - return 1.05; - case 'climax': - return 1.15; - case 'aftermath': - return 0.8; - case 'expansion': - default: - return 1; - } -} - -function resolveCustomWorldProfile(value: unknown) { - return isRecord(value) ? (value as CustomWorldProfile) : null; -} - -function resolveChapterBudgetedBaseXp( - context: ChapterProgressionContext | null, -) { - if (!context || context.plan.expectedHostileDefeatCount <= 0) { - return null; - } - - return ( - context.plan.hostileXpBudget / context.plan.expectedHostileDefeatCount - ); -} - -export function normalizeRuntimeEntityLevelProfile( - value: unknown, - fallbackRole: ProgressionRole = 'hostile_standard', -): RuntimeEntityLevelProfile | null { - if (!isRecord(value)) { - return null; - } - - const levelMetric = readNumber(value.level); - if (levelMetric === null) { - return null; - } - - const level = clampLevel(levelMetric); - const benchmark = getLevelBenchmark(level); - const referenceStrength = readNumber(value.referenceStrength); - - return { - level, - referenceStrength: - referenceStrength !== null && referenceStrength > 0 - ? Math.round(referenceStrength) - : benchmark.referenceStrength, - chapterId: readString(value.chapterId) || null, - chapterIndex: - typeof value.chapterIndex === 'number' && - Number.isFinite(value.chapterIndex) - ? Math.max(0, Math.round(value.chapterIndex)) - : null, - progressionRole: normalizeProgressionRole( - value.progressionRole, - fallbackRole, - ), - source: normalizeLevelProfileSource(value.source, 'manual'), - }; -} - -export function buildHostileExperienceReward(params: { - explicitExperienceReward?: unknown; - levelProfile: RuntimeEntityLevelProfile; - playerProgression?: unknown; - battleMode: 'fight' | 'spar'; - chapterStage?: SceneActStage | null; - budgetedBaseXp?: number | null; -}) { - if (params.battleMode === 'spar') { - return 0; - } - - const explicitReward = clampNonNegativeInteger( - params.explicitExperienceReward, - ); - if (explicitReward > 0) { - return explicitReward; - } - - const playerLevel = normalizePlayerProgressionState( - params.playerProgression, - ).level; - const benchmark = getLevelBenchmark(params.levelProfile.level); - const baseKillXp = - typeof params.budgetedBaseXp === 'number' && - Number.isFinite(params.budgetedBaseXp) && - params.budgetedBaseXp > 0 - ? params.budgetedBaseXp - : benchmark.xpToNextLevel * 0.08; - const scaledReward = - baseKillXp * - resolveChapterStageMultiplier(params.chapterStage) * - resolveLevelDeltaMultiplier(playerLevel, params.levelProfile.level) * - ROLE_XP_MULTIPLIER[params.levelProfile.progressionRole]; - - return Math.max(5, roundToNearestFive(scaledReward)); -} - -export function buildHostileBattleMaxHp(params: { - levelProfile: RuntimeEntityLevelProfile; - battleMode: 'fight' | 'spar'; -}) { - if (params.battleMode === 'spar') { - return Math.max( - 8, - Math.min(16, 8 + Math.floor((params.levelProfile.level - 1) / 2)), - ); - } - - const benchmark = getLevelBenchmark(params.levelProfile.level); - return Math.max( - 32, - Math.round(benchmark.baseHp / 9) + - ROLE_HP_BONUS[params.levelProfile.progressionRole], - ); -} - -export function resolveHostileBattleProfile(params: { - playerProgression?: unknown; - encounter?: RuntimeHostileEncounterSeed | null; - battleMode: 'fight' | 'spar'; - customWorldProfile?: unknown; - sceneId?: string | null; - chapterState?: unknown; - storyEngineMemory?: unknown; -}): ResolvedHostileBattleProfile { - const fallbackRole = resolveDefaultRole({ - encounter: params.encounter, - battleMode: params.battleMode, - }); - const normalizedPlayerProgression = normalizePlayerProgressionState( - params.playerProgression, - ); - const explicitLevelProfile = normalizeRuntimeEntityLevelProfile( - params.encounter?.levelProfile, - fallbackRole, - ); - const chapterContext = - explicitLevelProfile?.source === 'chapter_auto' - ? null - : resolveCurrentChapterProgressionContext({ - customWorldProfile: resolveCustomWorldProfile( - params.customWorldProfile, - ), - sceneId: params.sceneId, - chapterState: params.chapterState, - storyEngineMemory: params.storyEngineMemory, - }); - const chapterAutoLevelProfile = - explicitLevelProfile || !chapterContext - ? null - : resolveChapterAutoLevelProfile({ - plan: chapterContext.plan, - stage: chapterContext.stage, - encounter: params.encounter, - battleMode: params.battleMode, - primaryNpcId: chapterContext.activeAct?.primaryNpcId ?? null, - }); - const level = - explicitLevelProfile?.level ?? - chapterAutoLevelProfile?.level ?? - clampLevel(normalizedPlayerProgression.level); - const benchmark = getLevelBenchmark(level); - const levelProfile = - explicitLevelProfile ?? - chapterAutoLevelProfile ?? - ({ - level, - referenceStrength: benchmark.referenceStrength, - chapterId: null, - chapterIndex: null, - progressionRole: fallbackRole, - source: 'manual', - } satisfies RuntimeEntityLevelProfile); - - return { - levelProfile, - experienceReward: buildHostileExperienceReward({ - explicitExperienceReward: params.encounter?.experienceReward, - levelProfile, - playerProgression: normalizedPlayerProgression, - battleMode: params.battleMode, - chapterStage: chapterContext?.stage ?? null, - budgetedBaseXp: resolveChapterBudgetedBaseXp(chapterContext), - }), - battleMaxHp: buildHostileBattleMaxHp({ - levelProfile, - battleMode: params.battleMode, - }), - }; -} diff --git a/server-node/src/modules/progression/levelBenchmarks.ts b/server-node/src/modules/progression/levelBenchmarks.ts deleted file mode 100644 index a0bebb88..00000000 --- a/server-node/src/modules/progression/levelBenchmarks.ts +++ /dev/null @@ -1,63 +0,0 @@ -export interface LevelBenchmark { - level: number; - xpToNextLevel: number; - cumulativeXpRequired: number; - referenceStrength: number; - baseHp: number; - baseMana: number; - baselineDamageScale: number; -} - -export const MAX_PLAYER_LEVEL = 20; - -function clampLevel(value: unknown) { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return 1; - } - - return Math.min(MAX_PLAYER_LEVEL, Math.max(1, Math.floor(value))); -} - -function roundMetric(value: number, digits = 3) { - return Number(value.toFixed(digits)); -} - -function computeXpToNextLevel(level: number) { - const scale = Math.max(0, level - 1); - return 60 + 20 * scale + 8 * scale * scale; -} - -function buildLevelBenchmarks(maxLevel: number) { - const benchmarks: LevelBenchmark[] = []; - let cumulativeXpRequired = 0; - - for (let level = 1; level <= maxLevel; level += 1) { - const scale = level - 1; - const xpToNextLevel = level >= maxLevel ? 0 : computeXpToNextLevel(level); - - benchmarks.push({ - level, - xpToNextLevel, - cumulativeXpRequired, - referenceStrength: 100 + 16 * scale + 6 * scale * scale, - baseHp: 180 + 24 * scale + 10 * scale * scale, - baseMana: 80 + 14 * scale + 6 * scale * scale, - baselineDamageScale: roundMetric(1 + 0.12 * scale + 0.03 * scale * scale), - }); - - cumulativeXpRequired += xpToNextLevel; - } - - return benchmarks; -} - -const LEVEL_BENCHMARKS = buildLevelBenchmarks(MAX_PLAYER_LEVEL); -const LEVEL_BENCHMARKS_BY_LEVEL = new Map( - LEVEL_BENCHMARKS.map((benchmark) => [benchmark.level, benchmark]), -); - -export function getLevelBenchmark(level: number) { - return ( - LEVEL_BENCHMARKS_BY_LEVEL.get(clampLevel(level)) ?? LEVEL_BENCHMARKS[0]! - ); -} diff --git a/server-node/src/modules/progression/npcLevelResolver.test.ts b/server-node/src/modules/progression/npcLevelResolver.test.ts deleted file mode 100644 index f346ac19..00000000 --- a/server-node/src/modules/progression/npcLevelResolver.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import type { ChapterProgressionPlan } from './chapterProgressionPlanner.js'; -import { - resolveAutoProgressionRole, - resolveChapterAutoLevelProfile, -} from './npcLevelResolver.js'; - -const TEST_PLAN: ChapterProgressionPlan = { - chapterId: 'chapter-3', - chapterIndex: 3, - totalChapters: 4, - entryPseudoLevel: 6.2, - exitPseudoLevel: 8.8, - entryLevel: 6, - exitLevel: 9, - totalXpBudget: 560, - questXpBudget: 360, - hostileXpBudget: 200, - expectedHostileDefeatCount: 3, - paceBand: 'pressure', -}; - -test('resolveAutoProgressionRole upgrades current act hostile primary npc to boss in climax', () => { - assert.equal( - resolveAutoProgressionRole({ - encounter: { - id: 'npc_final_lord', - hostile: true, - monsterPresetId: 'final-lord', - }, - battleMode: 'fight', - stage: 'climax', - primaryNpcId: 'npc_final_lord', - }), - 'hostile_boss', - ); - assert.equal( - resolveAutoProgressionRole({ - encounter: { - id: 'npc_final_lord', - }, - battleMode: 'spar', - stage: 'climax', - primaryNpcId: 'npc_final_lord', - }), - 'rival', - ); -}); - -test('resolveChapterAutoLevelProfile applies role offsets on top of chapter stage anchor', () => { - const standard = resolveChapterAutoLevelProfile({ - plan: TEST_PLAN, - stage: 'climax', - encounter: { - id: 'npc_guard_01', - hostile: true, - monsterPresetId: 'guard', - }, - battleMode: 'fight', - primaryNpcId: 'npc_final_lord', - }); - const boss = resolveChapterAutoLevelProfile({ - plan: TEST_PLAN, - stage: 'climax', - encounter: { - id: 'npc_final_lord', - hostile: true, - monsterPresetId: 'final-lord', - }, - battleMode: 'fight', - primaryNpcId: 'npc_final_lord', - }); - - assert.equal(standard.progressionRole, 'hostile_standard'); - assert.equal(boss.progressionRole, 'hostile_boss'); - assert.ok(boss.level >= standard.level + 2); - assert.equal(boss.chapterId, 'chapter-3'); - assert.equal(boss.chapterIndex, 3); - assert.equal(boss.source, 'chapter_auto'); -}); diff --git a/server-node/src/modules/progression/npcLevelResolver.ts b/server-node/src/modules/progression/npcLevelResolver.ts deleted file mode 100644 index 252afd01..00000000 --- a/server-node/src/modules/progression/npcLevelResolver.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { SceneActStage } from '../custom-world/runtimeTypes.js'; -import { getLevelBenchmark, MAX_PLAYER_LEVEL } from './levelBenchmarks.js'; -import type { ChapterProgressionPlan } from './chapterProgressionPlanner.js'; -import type { - ProgressionRole, - RuntimeEntityLevelProfile, - RuntimeHostileEncounterSeed, -} from './hostileProgressionService.js'; - -const ROLE_LEVEL_OFFSETS: Record = { - guide: 0, - ambient: -1, - support: 0, - hostile_standard: 0, - hostile_elite: 1, - hostile_boss: 2, - rival: 0, -}; - -function clampLevel(value: number) { - return Math.min(MAX_PLAYER_LEVEL, Math.max(1, Math.round(value))); -} - -function interpolate(min: number, max: number, progress: number) { - return min + (max - min) * progress; -} - -function resolveStageProgress(stage: SceneActStage) { - switch (stage) { - case 'opening': - return 0; - case 'expansion': - return 0.4; - case 'turning_point': - return 0.72; - case 'climax': - return 1; - case 'aftermath': - return 0.82; - default: - return 0; - } -} - -function readString(value: unknown) { - return typeof value === 'string' && value.trim() ? value.trim() : ''; -} - -export function resolveAutoProgressionRole(params: { - encounter?: RuntimeHostileEncounterSeed | null; - battleMode: 'fight' | 'spar'; - stage: SceneActStage; - primaryNpcId?: string | null; -}): ProgressionRole { - if (params.battleMode === 'spar') { - return 'rival'; - } - - const encounterId = readString(params.encounter?.id); - const primaryNpcId = readString(params.primaryNpcId); - const isHostile = - params.encounter?.hostile === true || - readString(params.encounter?.monsterPresetId).length > 0; - - if (!isHostile) { - return primaryNpcId && encounterId === primaryNpcId ? 'rival' : 'support'; - } - - if (primaryNpcId && encounterId === primaryNpcId) { - return params.stage === 'climax' ? 'hostile_boss' : 'hostile_elite'; - } - - return 'hostile_standard'; -} - -export function resolveChapterAutoLevelProfile(params: { - plan: ChapterProgressionPlan; - stage: SceneActStage; - encounter?: RuntimeHostileEncounterSeed | null; - battleMode: 'fight' | 'spar'; - primaryNpcId?: string | null; -}): RuntimeEntityLevelProfile { - const progressionRole = resolveAutoProgressionRole({ - encounter: params.encounter, - battleMode: params.battleMode, - stage: params.stage, - primaryNpcId: params.primaryNpcId, - }); - const baseStageLevel = interpolate( - params.plan.entryPseudoLevel, - params.plan.exitPseudoLevel, - resolveStageProgress(params.stage), - ); - const level = clampLevel( - baseStageLevel + ROLE_LEVEL_OFFSETS[progressionRole], - ); - - return { - level, - referenceStrength: getLevelBenchmark(level).referenceStrength, - chapterId: params.plan.chapterId, - chapterIndex: params.plan.chapterIndex, - progressionRole, - source: 'chapter_auto', - }; -} diff --git a/server-node/src/modules/progression/playerProgressionService.test.ts b/server-node/src/modules/progression/playerProgressionService.test.ts deleted file mode 100644 index 26a717da..00000000 --- a/server-node/src/modules/progression/playerProgressionService.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { - createInitialPlayerProgressionState, - grantPlayerExperience, - normalizePlayerProgressionState, -} from './playerProgressionService.js'; - -test('player progression starts at level 1 with the first upgrade threshold', () => { - const initialState = createInitialPlayerProgressionState(); - - assert.deepEqual(initialState, { - level: 1, - currentLevelXp: 0, - totalXp: 0, - xpToNextLevel: 60, - pendingLevelUps: 0, - lastGrantedSource: null, - }); -}); - -test('grantPlayerExperience upgrades level state from quest rewards', () => { - const result = grantPlayerExperience( - { - level: 1, - currentLevelXp: 50, - totalXp: 50, - xpToNextLevel: 60, - }, - 40, - { - source: 'quest', - }, - ); - - assert.equal(result.grantedXp, 40); - assert.equal(result.previousLevel, 1); - assert.equal(result.nextLevel, 2); - assert.equal(result.levelUps, 1); - assert.equal(result.state.level, 2); - assert.equal(result.state.currentLevelXp, 30); - assert.equal(result.state.totalXp, 90); - assert.equal(result.state.xpToNextLevel, 88); - assert.equal(result.state.lastGrantedSource, 'quest'); -}); - -test('normalizePlayerProgressionState backfills legacy partial progression payloads', () => { - const normalized = normalizePlayerProgressionState({ - level: 3, - currentLevelXp: 15, - }); - - assert.equal(normalized.level, 3); - assert.equal(normalized.currentLevelXp, 15); - assert.equal(normalized.totalXp, 163); - assert.equal(normalized.xpToNextLevel, 132); -}); diff --git a/server-node/src/modules/progression/playerProgressionService.ts b/server-node/src/modules/progression/playerProgressionService.ts deleted file mode 100644 index 2e66174f..00000000 --- a/server-node/src/modules/progression/playerProgressionService.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { getLevelBenchmark, MAX_PLAYER_LEVEL } from './levelBenchmarks.js'; - -type JsonRecord = Record; - -export type PlayerProgressionGrantSource = 'quest' | 'hostile_npc'; - -export interface PlayerProgressionState { - level: number; - currentLevelXp: number; - totalXp: number; - xpToNextLevel: number; - pendingLevelUps?: number; - lastGrantedSource?: PlayerProgressionGrantSource | null; -} - -export interface PlayerExperienceGrantResult { - state: PlayerProgressionState; - grantedXp: number; - previousLevel: number; - nextLevel: number; - levelUps: number; - leveledUp: boolean; - reachedMaxLevel: boolean; -} - -function isRecord(value: unknown): value is JsonRecord { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function clampNonNegativeInteger(value: unknown) { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return 0; - } - - return Math.max(0, Math.floor(value)); -} - -function clampLevel(value: unknown) { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return 1; - } - - return Math.min(MAX_PLAYER_LEVEL, Math.max(1, Math.floor(value))); -} - -function normalizeLastGrantedSource(value: unknown) { - return value === 'quest' || value === 'hostile_npc' ? value : null; -} - -function resolveLevelFromTotalXp(totalXp: number) { - let resolvedLevel = 1; - - for (let level = 2; level <= MAX_PLAYER_LEVEL; level += 1) { - if (totalXp < getLevelBenchmark(level).cumulativeXpRequired) { - break; - } - - resolvedLevel = level; - } - - return resolvedLevel; -} - -function buildProgressionStateFromTotalXp( - totalXp: number, - lastGrantedSource: PlayerProgressionGrantSource | null = null, -): PlayerProgressionState { - const normalizedTotalXp = clampNonNegativeInteger(totalXp); - const level = resolveLevelFromTotalXp(normalizedTotalXp); - const benchmark = getLevelBenchmark(level); - - if (level >= MAX_PLAYER_LEVEL) { - return { - level, - currentLevelXp: 0, - totalXp: normalizedTotalXp, - xpToNextLevel: 0, - pendingLevelUps: 0, - lastGrantedSource, - }; - } - - return { - level, - currentLevelXp: Math.max( - 0, - normalizedTotalXp - benchmark.cumulativeXpRequired, - ), - totalXp: normalizedTotalXp, - xpToNextLevel: benchmark.xpToNextLevel, - pendingLevelUps: 0, - lastGrantedSource, - }; -} - -export function createInitialPlayerProgressionState(): PlayerProgressionState { - return buildProgressionStateFromTotalXp(0); -} - -export function normalizePlayerProgressionState( - value: unknown, -): PlayerProgressionState { - if (!isRecord(value)) { - return createInitialPlayerProgressionState(); - } - - const explicitLevel = clampLevel(value.level); - const explicitCurrentLevelXp = clampNonNegativeInteger(value.currentLevelXp); - const totalXp = clampNonNegativeInteger(value.totalXp); - const hasExplicitProgress = explicitLevel > 1 || explicitCurrentLevelXp > 0; - const derivedTotalXp = - totalXp > 0 || !hasExplicitProgress - ? totalXp - : getLevelBenchmark(explicitLevel).cumulativeXpRequired + - Math.min( - explicitCurrentLevelXp, - getLevelBenchmark(explicitLevel).xpToNextLevel, - ); - - return { - ...buildProgressionStateFromTotalXp( - derivedTotalXp, - normalizeLastGrantedSource(value.lastGrantedSource), - ), - pendingLevelUps: clampNonNegativeInteger(value.pendingLevelUps), - }; -} - -export function grantPlayerExperience( - value: unknown, - amount: number, - options: { - source: PlayerProgressionGrantSource; - }, -): PlayerExperienceGrantResult { - const currentState = normalizePlayerProgressionState(value); - const grantedXp = clampNonNegativeInteger(amount); - - if (grantedXp <= 0) { - return { - state: { - ...currentState, - pendingLevelUps: 0, - }, - grantedXp: 0, - previousLevel: currentState.level, - nextLevel: currentState.level, - levelUps: 0, - leveledUp: false, - reachedMaxLevel: currentState.level >= MAX_PLAYER_LEVEL, - }; - } - - const nextState = buildProgressionStateFromTotalXp( - currentState.totalXp + grantedXp, - options.source, - ); - const levelUps = Math.max(0, nextState.level - currentState.level); - - return { - state: { - ...nextState, - pendingLevelUps: 0, - }, - grantedXp, - previousLevel: currentState.level, - nextLevel: nextState.level, - levelUps, - leveledUp: levelUps > 0, - reachedMaxLevel: nextState.level >= MAX_PLAYER_LEVEL, - }; -} - -export function buildExperienceGrantResultText( - result: PlayerExperienceGrantResult, -) { - if (result.grantedXp <= 0) { - return ''; - } - - const parts = [`经验 +${result.grantedXp}`]; - - if (result.leveledUp) { - parts.push( - result.levelUps > 1 - ? `连升 ${result.levelUps} 级,达到 Lv.${result.nextLevel}` - : `升至 Lv.${result.nextLevel}`, - ); - } - - return `${parts.join(',')}。`; -} diff --git a/server-node/src/modules/quest/questProgressionService.test.ts b/server-node/src/modules/quest/questProgressionService.test.ts deleted file mode 100644 index 83d2ab28..00000000 --- a/server-node/src/modules/quest/questProgressionService.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { buildQuestForEncounter } from '../../bridges/legacyQuestProgressBridge.js'; -import { - acknowledgeQuestCompletion, - applyQuestSignal, - turnInQuest, -} from './questProgressionService.js'; - -const TEST_WORLD = 'WUXIA' as Parameters[0]['worldType']; - -const TEST_SCENE = { - id: 'forest_path', - name: 'Forest Path', - description: 'A narrow trail with fresh claw marks.', - npcs: [ - { - id: 'hostile-wolf-alpha', - name: '狼王', - description: 'A hostile wolf alpha.', - avatar: '狼', - role: '敌对角色', - monsterPresetId: 'wolf_alpha', - initialAffinity: -40, - hostile: true, - }, - ], - treasureHints: [], -}; - -function createQuest() { - const quest = buildQuestForEncounter({ - issuerNpcId: 'npc_scout', - issuerNpcName: 'Scout Lin', - roleText: 'tracker', - scene: TEST_SCENE, - worldType: TEST_WORLD, - currentQuests: [], - }); - assert.ok(quest); - return quest; -} - -test('applyQuestSignal advances quest steps on the server side', () => { - const quest = createQuest(); - const result = applyQuestSignal([quest], { - kind: 'hostile_npc_defeated', - sceneId: TEST_SCENE.id, - hostileNpcId: 'wolf_alpha', - }); - - assert.equal(result.updatedQuestIds.length, 1); - assert.equal(result.updatedQuestIds[0], quest.id); - assert.equal(result.updatedQuests[0]?.objective.kind, 'talk_to_npc'); - assert.equal(result.updatedQuests[0]?.status, 'active'); -}); - -test('turnInQuest rejects unfinished quests before reward-ready state', () => { - const quest = createQuest(); - const result = turnInQuest([quest], quest.id); - - assert.equal(result.ok, false); - if (result.ok) { - return; - } - - assert.equal(result.code, 'quest_not_ready_to_turn_in'); -}); - -test('turnInQuest marks ready quests as turned in after signal progression', () => { - const quest = createQuest(); - const afterBattle = applyQuestSignal([quest], { - kind: 'hostile_npc_defeated', - sceneId: TEST_SCENE.id, - hostileNpcId: 'wolf_alpha', - }); - const afterTalk = applyQuestSignal(afterBattle.nextQuests, { - kind: 'npc_talk_completed', - npcId: 'npc_scout', - }); - const turnInResult = turnInQuest(afterTalk.nextQuests, quest.id); - - assert.equal(turnInResult.ok, true); - if (!turnInResult.ok) { - return; - } - - assert.equal(turnInResult.updatedQuests[0]?.status, 'turned_in'); - assert.equal(turnInResult.updatedQuests[0]?.completionNotified, true); -}); - -test('acknowledgeQuestCompletion updates completion notification flag independently', () => { - const quest = createQuest(); - const result = acknowledgeQuestCompletion([quest], quest.id); - - assert.equal(result.ok, true); - if (!result.ok) { - return; - } - - assert.equal(result.updatedQuests[0]?.completionNotified, true); -}); diff --git a/server-node/src/modules/quest/questProgressionService.ts b/server-node/src/modules/quest/questProgressionService.ts deleted file mode 100644 index 8e6ef575..00000000 --- a/server-node/src/modules/quest/questProgressionService.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { - applyQuestProgressSignal, - normalizeQuestLogEntries, -} from '../../bridges/legacyQuestProgressBridge.js'; - -export type QuestLogEntry = Parameters< - typeof normalizeQuestLogEntries ->[0][number]; -export type QuestProgressSignal = Parameters< - typeof applyQuestProgressSignal ->[1]; - -type QuestMutationFailureCode = - | 'quest_not_found' - | 'quest_not_ready_to_turn_in'; - -export type QuestMutationFailure = { - ok: false; - code: QuestMutationFailureCode; - message: string; -}; - -export type QuestMutationSuccess = { - ok: true; - nextQuests: QuestLogEntry[]; - updatedQuestIds: string[]; - updatedQuests: QuestLogEntry[]; -}; - -export type QuestMutationResult = QuestMutationFailure | QuestMutationSuccess; - -function createFailure( - code: QuestMutationFailureCode, - message: string, -): QuestMutationFailure { - return { - ok: false, - code, - message, - }; -} - -function collectUpdatedQuestIds( - previous: QuestLogEntry[], - next: QuestLogEntry[], -): string[] { - const previousById = new Map(previous.map((quest) => [quest.id, quest])); - - return next - .filter((quest) => { - const previousQuest = previousById.get(quest.id); - return JSON.stringify(previousQuest) !== JSON.stringify(quest); - }) - .map((quest) => quest.id); -} - -function buildSuccess( - previous: QuestLogEntry[], - next: QuestLogEntry[], -): QuestMutationSuccess { - const updatedQuestIds = collectUpdatedQuestIds(previous, next); - return { - ok: true, - nextQuests: next, - updatedQuestIds, - updatedQuests: next.filter((quest) => updatedQuestIds.includes(quest.id)), - }; -} - -export function normalizeQuestEntries( - quests: QuestLogEntry[], -): QuestLogEntry[] { - return normalizeQuestLogEntries(quests); -} - -function getQuestActiveStep(quest: QuestLogEntry) { - if (!quest.steps?.length) { - return null; - } - - if (quest.activeStepId) { - return quest.steps.find((step) => step.id === quest.activeStepId) ?? null; - } - - return quest.steps.find((step) => step.progress < step.requiredCount) ?? null; -} - -export function applyQuestSignal( - quests: QuestLogEntry[], - signal: QuestProgressSignal, -): QuestMutationSuccess { - const normalizedQuests = normalizeQuestEntries(quests); - const nextQuests = applyQuestProgressSignal(normalizedQuests, signal); - return buildSuccess(normalizedQuests, nextQuests); -} - -export function acknowledgeQuestCompletion( - quests: QuestLogEntry[], - questId: string, -): QuestMutationResult { - const normalizedQuests = normalizeQuestEntries(quests); - const quest = findQuestById(normalizedQuests, questId); - if (!quest) { - return createFailure('quest_not_found', '未找到目标委托。'); - } - - const nextQuests = markQuestCompletionNotified(normalizedQuests, questId); - return buildSuccess(normalizedQuests, nextQuests); -} - -export function findQuestById(quests: QuestLogEntry[], questId: string) { - return quests.find((quest) => quest.id === questId) ?? null; -} - -export function getQuestForIssuer( - quests: QuestLogEntry[], - issuerNpcId: string, -) { - return ( - normalizeQuestEntries(quests).find( - (quest) => - quest.issuerNpcId === issuerNpcId && quest.status !== 'turned_in', - ) ?? null - ); -} - -export function acceptQuest(quests: QuestLogEntry[], quest: QuestLogEntry) { - const normalizedQuests = normalizeQuestEntries(quests); - if (findQuestById(normalizedQuests, quest.id)) { - return normalizedQuests; - } - - return [...normalizedQuests, normalizeQuestEntries([quest])[0]!]; -} - -export function buildQuestAcceptResultText(quest: QuestLogEntry) { - const normalizedQuest = normalizeQuestEntries([quest])[0]!; - const activeStep = getQuestActiveStep(normalizedQuest); - return `${normalizedQuest.issuerNpcName} 正式把委托交到了你手上。${ - activeStep?.revealText ?? normalizedQuest.summary - }`; -} - -export function buildQuestTurnInResultText( - quest: QuestLogEntry, - options: { - experienceText?: string | null; - } = {}, -) { - const normalizedQuest = normalizeQuestEntries([quest])[0]!; - const itemText = - normalizedQuest.reward.items.map((item) => item.name).join('、') || '补给'; - const intelText = normalizedQuest.reward.intel?.rumorText - ? `,并额外告诉了你一条消息:${normalizedQuest.reward.intel.rumorText}` - : ''; - const storyHintText = normalizedQuest.reward.storyHint - ? ` ${normalizedQuest.reward.storyHint}` - : ''; - const experienceText = options.experienceText?.trim() - ? ` ${options.experienceText.trim()}` - : ''; - - return `${normalizedQuest.issuerNpcName} 确认你已经完成委托,交给了你 ${normalizedQuest.reward.currency} 赏金和${itemText}${intelText}。${experienceText}${storyHintText}`; -} - -export function isQuestReadyToClaim(quest: QuestLogEntry) { - const status = normalizeQuestEntries([quest])[0]!.status; - return status === 'ready_to_turn_in' || status === 'completed'; -} - -export function markQuestTurnedIn(quests: QuestLogEntry[], questId: string) { - return quests.map((quest) => - quest.id === questId - ? normalizeQuestEntries([ - { - ...quest, - status: 'turned_in', - completionNotified: true, - steps: quest.steps?.map((step) => ({ - ...step, - progress: step.requiredCount, - })), - }, - ])[0]! - : normalizeQuestEntries([quest])[0]!, - ); -} - -export function markQuestCompletionNotified( - quests: QuestLogEntry[], - questId: string, -) { - return quests.map((quest) => - quest.id === questId - ? normalizeQuestEntries([ - { - ...quest, - completionNotified: true, - }, - ])[0]! - : normalizeQuestEntries([quest])[0]!, - ); -} - -export function turnInQuest( - quests: QuestLogEntry[], - questId: string, -): QuestMutationResult { - const normalizedQuests = normalizeQuestEntries(quests); - const quest = findQuestById(normalizedQuests, questId); - if (!quest) { - return createFailure('quest_not_found', '未找到目标委托。'); - } - - if (!isQuestReadyToClaim(quest)) { - return createFailure( - 'quest_not_ready_to_turn_in', - `${quest.title} 当前还不能交付结算。`, - ); - } - - const nextQuests = markQuestTurnedIn(normalizedQuests, questId); - return buildSuccess(normalizedQuests, nextQuests); -} diff --git a/server-node/src/modules/quest/questRuntimeSignalService.ts b/server-node/src/modules/quest/questRuntimeSignalService.ts deleted file mode 100644 index 2ebec2c9..00000000 --- a/server-node/src/modules/quest/questRuntimeSignalService.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { RuntimeBattlePresentation } from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js'; -import { - applyQuestSignal, - normalizeQuestEntries, -} from './questProgressionService.js'; -import { - replaceRuntimeSessionRawGameState, -} from '../rpg-runtime-story/RpgRuntimeSnapshotSync.js'; -import type { RuntimeSession } from '../rpg-runtime-story/RpgRuntimeSessionLoader.js'; - -type JsonRecord = Record; -type RuntimeGameState = { - currentScenePreset?: { - id?: string | null; - } | null; - quests?: unknown[]; -}; - -function readSceneId(state: RuntimeGameState) { - return state.currentScenePreset?.id ?? null; -} - -export function applyQuestSignalsForResolvedAction(params: { - session: RuntimeSession; - functionId: string; - previousEncounter: RuntimeSession['currentEncounter']; - battle?: RuntimeBattlePresentation | null; -}) { - const state = params.session.rawGameState as unknown as RuntimeGameState; - const quests = normalizeQuestEntries(Array.isArray(state.quests) ? state.quests : []); - if (quests.length <= 0) { - return; - } - - let mutation = null; - - if ( - params.functionId === 'npc_chat' && - params.previousEncounter?.kind === 'npc' - ) { - mutation = applyQuestSignal(quests, { - kind: 'npc_talk_completed', - npcId: params.previousEncounter.id, - }); - } else if ( - params.battle?.outcome === 'victory' && - typeof params.battle.targetId === 'string' && - params.battle.targetId.trim() - ) { - mutation = applyQuestSignal(quests, { - kind: 'hostile_npc_defeated', - sceneId: readSceneId(state), - hostileNpcId: params.battle.targetId, - }); - } else if ( - params.battle?.outcome === 'spar_complete' && - params.previousEncounter?.kind === 'npc' - ) { - mutation = applyQuestSignal(quests, { - kind: 'npc_spar_completed', - npcId: params.previousEncounter.id, - }); - } else if ( - params.functionId === 'treasure_inspect' || - params.functionId === 'treasure_secure' - ) { - mutation = applyQuestSignal(quests, { - kind: 'treasure_inspected', - sceneId: readSceneId(state), - }); - } - - if (!mutation || mutation.updatedQuestIds.length <= 0) { - return; - } - - replaceRuntimeSessionRawGameState( - params.session, - { - ...state, - quests: mutation.nextQuests, - } as unknown as JsonRecord, - ); -} diff --git a/server-node/src/modules/quest/questStoryActionService.ts b/server-node/src/modules/quest/questStoryActionService.ts deleted file mode 100644 index 10098622..00000000 --- a/server-node/src/modules/quest/questStoryActionService.ts +++ /dev/null @@ -1,639 +0,0 @@ -import type { - RuntimeStoryOptionView, - RuntimeStoryActionRequest, - RuntimeStoryPatch, -} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js'; -import { - buildExperienceGrantResultText, - grantPlayerExperience, -} from '../progression/playerProgressionService.js'; -import { conflict, invalidRequest } from '../../errors.js'; -import { - appendStoryEngineCarrierMemory, - markNpcFirstMeaningfulContactResolved, -} from '../../bridges/legacyNpcTask6Bridge.js'; -import { - acceptQuest, - addInventoryItems, - buildQuestAcceptResultText, - buildQuestForEncounter, - buildQuestTurnInResultText, - buildRelationState, - getQuestForIssuer, - incrementGameRuntimeStats, - isQuestReadyToClaim, - turnInQuest, -} from './questTask6Bridge.js'; -import { - replaceRuntimeSessionRawGameState, -} from '../rpg-runtime-story/RpgRuntimeSnapshotSync.js'; -import type { RuntimeSession } from '../rpg-runtime-story/RpgRuntimeSessionLoader.js'; - -const SUPPORTED_QUEST_STORY_FUNCTION_IDS = new Set([ - 'npc_chat_quest_offer_abandon', - 'npc_chat_quest_offer_replace', - 'npc_chat_quest_offer_view', - 'npc_quest_accept', - 'npc_quest_turn_in', -]); - -type QuestStoryResolution = { - actionText: string; - resultText: string; - patches: RuntimeStoryPatch[]; - storyText?: string; - presentationOptions?: RuntimeStoryOptionView[]; - savedCurrentStory?: JsonRecord; -}; - -type JsonRecord = Record; -type RuntimeGameState = Parameters[0]; -type RuntimeQuestLogEntry = NonNullable< - ReturnType ->; -type RuntimeNpcState = Parameters< - typeof markNpcFirstMeaningfulContactResolved ->[0]; -type RuntimeEncounter = { - id?: string; - kind?: 'npc' | 'treasure'; - npcAvatar?: string; - npcName: string; - npcDescription: string; - context: string; - hostile?: boolean; - characterId?: string | null; - monsterPresetId?: string | null; -}; - -function getNpcEncounter( - session: RuntimeSession, - state: RuntimeGameState, -): RuntimeEncounter | null { - const rawEncounter = state.currentEncounter; - if (!rawEncounter || rawEncounter.kind !== 'npc') { - return null; - } - - return { - npcAvatar: '', - hostile: false, - ...rawEncounter, - id: rawEncounter.id ?? session.currentEncounter?.id ?? rawEncounter.npcName, - } satisfies RuntimeEncounter; -} - -function getNpcEncounterKey(encounter: RuntimeEncounter) { - return encounter.id?.trim() || encounter.npcName; -} - -function readPayload(request: RuntimeStoryActionRequest) { - return typeof request.action.payload === 'object' && request.action.payload - ? (request.action.payload as JsonRecord) - : {}; -} - -function readString(value: unknown) { - return typeof value === 'string' && value.trim() ? value.trim() : ''; -} - -function isObject(value: unknown): value is JsonRecord { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function readQuestId(request: RuntimeStoryActionRequest) { - const payload = readPayload(request); - return readString(payload.questId) || readString(request.action.targetId); -} - -function readPendingQuestOffer( - currentStory: unknown, - npcKey: string, -): RuntimeQuestLogEntry | null { - if (!isObject(currentStory)) { - return null; - } - - const npcChatState = isObject(currentStory.npcChatState) - ? currentStory.npcChatState - : null; - const pendingQuestOffer = isObject(npcChatState?.pendingQuestOffer) - ? npcChatState.pendingQuestOffer - : null; - const quest = isObject(pendingQuestOffer?.quest) - ? pendingQuestOffer.quest - : null; - - if (!quest) { - return null; - } - - const pendingNpcId = readString(npcChatState?.npcId); - const questId = readString(quest.id); - const issuerNpcId = readString(quest.issuerNpcId); - - if (!questId) { - return null; - } - - if (pendingNpcId && pendingNpcId !== npcKey) { - return null; - } - - if (issuerNpcId && issuerNpcId !== npcKey) { - return null; - } - - return quest as RuntimeQuestLogEntry; -} - -function readPendingQuestOfferContext( - currentStory: unknown, - npcKey: string, -) { - if (!isObject(currentStory)) { - return null; - } - - const npcChatState = isObject(currentStory.npcChatState) - ? currentStory.npcChatState - : null; - const pendingQuestOffer = isObject(npcChatState?.pendingQuestOffer) - ? npcChatState.pendingQuestOffer - : null; - const quest = readPendingQuestOffer(currentStory, npcKey); - - if (!quest) { - return null; - } - - const dialogue = Array.isArray(currentStory.dialogue) - ? currentStory.dialogue - .filter((entry) => isObject(entry)) - .map((entry) => ({ ...entry })) - : []; - const turnCount = - typeof npcChatState?.turnCount === 'number' && - Number.isFinite(npcChatState.turnCount) - ? Math.max(0, Math.round(npcChatState.turnCount)) - : 0; - const customInputPlaceholder = - readString(npcChatState?.customInputPlaceholder) || '输入你想对 TA 说的话'; - - return { - dialogue, - turnCount, - customInputPlaceholder, - quest, - introText: readString(pendingQuestOffer?.introText), - }; -} - -function buildNpcChatOption( - encounter: RuntimeEncounter, - actionText: string, -) { - return { - functionId: 'npc_chat', - actionText, - text: actionText, - detailText: '', - interaction: { - kind: 'npc', - npcId: encounter.id ?? encounter.npcName, - action: 'chat', - }, - visuals: { - playerAnimation: 'idle', - playerMoveMeters: 0, - playerOffsetY: 0, - playerFacing: 'right', - scrollWorld: false, - monsterChanges: [], - }, - } satisfies JsonRecord; -} - -function buildPendingQuestOfferOptions(encounter: RuntimeEncounter) { - const npcId = encounter.id ?? encounter.npcName; - const buildOption = ( - functionId: - | 'npc_chat_quest_offer_view' - | 'npc_chat_quest_offer_replace' - | 'npc_chat_quest_offer_abandon', - actionText: string, - action: 'quest_offer_view' | 'quest_offer_replace' | 'quest_offer_abandon', - ) => - ({ - functionId, - actionText, - text: actionText, - detailText: '', - interaction: { - kind: 'npc', - npcId, - action, - }, - visuals: { - playerAnimation: 'idle', - playerMoveMeters: 0, - playerOffsetY: 0, - playerFacing: 'right', - scrollWorld: false, - monsterChanges: [], - }, - runtimePayload: - functionId === 'npc_chat_quest_offer_view' - ? { npcChatQuestOfferAction: 'view' } - : functionId === 'npc_chat_quest_offer_replace' - ? { npcChatQuestOfferAction: 'replace' } - : { npcChatQuestOfferAction: 'abandon' }, - }) satisfies JsonRecord; - - return [ - buildOption('npc_chat_quest_offer_view', '查看任务', 'quest_offer_view'), - buildOption( - 'npc_chat_quest_offer_replace', - '更换任务', - 'quest_offer_replace', - ), - buildOption( - 'npc_chat_quest_offer_abandon', - '放弃任务', - 'quest_offer_abandon', - ), - ]; -} - -function buildPostQuestOfferChatOptions(encounter: RuntimeEncounter) { - return [ - '那先继续聊聊你刚才没说完的部分', - '除了委托,你对眼前局势还有什么判断', - '先把这附近真正危险的地方说清楚', - ].map((actionText) => buildNpcChatOption(encounter, actionText)); -} - -function buildQuestOfferDialogueText( - encounter: RuntimeEncounter, - quest: RuntimeQuestLogEntry, -) { - const summaryText = readString(quest.summary) || readString(quest.description); - return `${encounter.npcName}沉吟了片刻,像是终于把真正想托付的事说了出来。${ - summaryText - ? `如果你愿意,我想把这件事正式交给你:${summaryText}` - : '如果你愿意,我想把眼前这件事正式交给你。' - }`; -} - -function ensureEncounterQuestContext(session: RuntimeSession) { - const state = session.rawGameState as unknown as RuntimeGameState; - const encounter = getNpcEncounter(session, state); - if (!encounter) { - throw conflict('当前不在可结算的 NPC 委托态。'); - } - - const npcKey = getNpcEncounterKey(encounter); - const npcState = state.npcStates?.[npcKey]; - if (!npcState) { - throw conflict('当前 NPC 状态不存在,无法处理委托。'); - } - - return { - state, - encounter, - npcKey, - npcState, - }; -} - -function resolveQuestAcceptAction( - session: RuntimeSession, - currentStory?: unknown, -): QuestStoryResolution { - const { state, encounter, npcKey, npcState } = - ensureEncounterQuestContext(session); - const quests = Array.isArray(state.quests) ? state.quests : []; - const existingQuest = getQuestForIssuer(quests, npcKey); - if (existingQuest) { - throw conflict('当前角色已经有未结清的委托。'); - } - - const quest = - readPendingQuestOffer(currentStory, npcKey) ?? - buildQuestForEncounter({ - issuerNpcId: npcKey, - issuerNpcName: encounter.npcName, - roleText: encounter.context, - scene: state.currentScenePreset, - worldType: state.worldType, - context: { - worldType: state.worldType, - recentStoryMoments: Array.isArray(state.storyHistory) - ? state.storyHistory.slice(-6) - : [], - playerCharacter: state.playerCharacter ?? null, - playerProgression: state.playerProgression ?? null, - }, - currentQuests: quests.map((item) => ({ - id: item.id, - issuerNpcId: item.issuerNpcId, - status: item.status, - })), - }); - if (!quest) { - throw conflict('当前场景缺少可落地的委托抓手,暂时无法接取任务。'); - } - - const nextState = { - ...state, - quests: acceptQuest(quests, quest), - runtimeStats: incrementGameRuntimeStats(state.runtimeStats, { - questsAccepted: 1, - }), - npcStates: { - ...state.npcStates, - [npcKey]: { - ...markNpcFirstMeaningfulContactResolved(npcState), - }, - }, - } satisfies RuntimeGameState; - - replaceRuntimeSessionRawGameState( - session, - nextState as unknown as JsonRecord, - ); - - return { - actionText: `接下${encounter.npcName}的委托`, - resultText: buildQuestAcceptResultText(quest), - patches: [], - }; -} - -function resolveQuestOfferViewAction( - session: RuntimeSession, - currentStory?: unknown, -): QuestStoryResolution { - const { encounter, npcKey } = ensureEncounterQuestContext(session); - const pendingOffer = readPendingQuestOfferContext(currentStory, npcKey); - if (!pendingOffer) { - throw conflict('当前没有待处理的委托可查看。'); - } - - return { - actionText: `查看${encounter.npcName}提出的委托`, - resultText: readString(pendingOffer.introText) || buildQuestOfferDialogueText(encounter, pendingOffer.quest), - patches: [], - }; -} - -function resolveQuestOfferReplaceAction( - session: RuntimeSession, - currentStory?: unknown, -): QuestStoryResolution { - const { state, encounter, npcKey } = ensureEncounterQuestContext(session); - const pendingOffer = readPendingQuestOfferContext(currentStory, npcKey); - if (!pendingOffer) { - throw conflict('当前没有待处理的委托可更换。'); - } - - const nextQuest = buildQuestForEncounter({ - issuerNpcId: npcKey, - issuerNpcName: encounter.npcName, - roleText: encounter.context, - scene: state.currentScenePreset, - worldType: state.worldType, - context: { - worldType: state.worldType, - recentStoryMoments: Array.isArray(state.storyHistory) - ? state.storyHistory.slice(-6) - : [], - playerCharacter: state.playerCharacter ?? null, - playerProgression: state.playerProgression ?? null, - }, - currentQuests: (Array.isArray(state.quests) ? state.quests : []).map((item) => ({ - id: item.id, - issuerNpcId: item.issuerNpcId, - status: item.status, - })), - }); - - if (!nextQuest) { - throw conflict('当前没有更合适的委托可供更换。'); - } - - const dialogue = [ - ...pendingOffer.dialogue, - { - speaker: 'player', - text: '能不能换一份更适合眼下局势的委托?', - }, - { - speaker: 'npc', - speakerName: encounter.npcName, - text: buildQuestOfferDialogueText(encounter, nextQuest), - }, - ]; - - return { - actionText: `请${encounter.npcName}更换委托`, - resultText: buildQuestOfferDialogueText(encounter, nextQuest), - storyText: buildQuestOfferDialogueText(encounter, nextQuest), - savedCurrentStory: { - text: dialogue - .map((entry) => readString(entry.text)) - .filter(Boolean) - .join('\n'), - options: buildPendingQuestOfferOptions(encounter), - displayMode: 'dialogue', - dialogue, - streaming: false, - npcChatState: { - npcId: npcKey, - npcName: encounter.npcName, - turnCount: pendingOffer.turnCount, - customInputPlaceholder: pendingOffer.customInputPlaceholder, - pendingQuestOffer: { - quest: nextQuest, - }, - }, - }, - presentationOptions: buildPendingQuestOfferOptions(encounter).map((option) => ({ - functionId: readString(option.functionId), - actionText: readString(option.actionText), - detailText: '', - scope: 'npc', - interaction: isObject(option.interaction) - ? (option.interaction as RuntimeStoryOptionView['interaction']) - : undefined, - payload: isObject(option.runtimePayload) - ? (option.runtimePayload as Record) - : undefined, - })), - patches: [], - }; -} - -function resolveQuestOfferAbandonAction( - session: RuntimeSession, - currentStory?: unknown, -): QuestStoryResolution { - const { encounter, npcKey } = ensureEncounterQuestContext(session); - const pendingOffer = readPendingQuestOfferContext(currentStory, npcKey); - if (!pendingOffer) { - throw conflict('当前没有待处理的委托可放弃。'); - } - - const npcReply = `${encounter.npcName}点了点头,没有继续强求,只把这份委托暂时收了回去。`; - const dialogue = [ - ...pendingOffer.dialogue, - { - speaker: 'player', - text: '这件事我先不接,咱们还是先聊别的。', - }, - { - speaker: 'npc', - speakerName: encounter.npcName, - text: npcReply, - }, - ]; - - return { - actionText: `暂不接受${encounter.npcName}的委托`, - resultText: npcReply, - storyText: npcReply, - savedCurrentStory: { - text: dialogue - .map((entry) => readString(entry.text)) - .filter(Boolean) - .join('\n'), - options: buildPostQuestOfferChatOptions(encounter), - displayMode: 'dialogue', - dialogue, - streaming: false, - npcChatState: { - npcId: npcKey, - npcName: encounter.npcName, - turnCount: pendingOffer.turnCount, - customInputPlaceholder: pendingOffer.customInputPlaceholder, - pendingQuestOffer: null, - }, - }, - presentationOptions: buildPostQuestOfferChatOptions(encounter).map((option) => ({ - functionId: readString(option.functionId), - actionText: readString(option.actionText), - detailText: '', - scope: 'npc', - interaction: isObject(option.interaction) - ? (option.interaction as RuntimeStoryOptionView['interaction']) - : undefined, - payload: isObject(option.runtimePayload) - ? (option.runtimePayload as Record) - : undefined, - })), - patches: [], - }; -} - -function resolveQuestTurnInAction( - session: RuntimeSession, - request: RuntimeStoryActionRequest, -): QuestStoryResolution { - const { state, encounter, npcKey, npcState } = - ensureEncounterQuestContext(session); - const quests = Array.isArray(state.quests) ? state.quests : []; - const questId = readQuestId(request); - const quest = - (questId ? quests.find((item) => item.id === questId) : null) ?? - getQuestForIssuer(quests, npcKey); - - if (!quest) { - throw conflict('当前没有可交付的委托。'); - } - - if (!isQuestReadyToClaim(quest)) { - throw conflict('这份委托还没有达到可交付状态。'); - } - - const turnInResult = turnInQuest(quests, quest.id); - if (!turnInResult.ok) { - throw conflict(turnInResult.message); - } - - const nextAffinity = npcState.affinity + quest.reward.affinityBonus; - const experienceGrant = grantPlayerExperience( - state.playerProgression, - quest.reward.experience ?? 0, - { - source: 'quest', - }, - ); - let nextState = { - ...state, - quests: turnInResult.nextQuests, - playerProgression: experienceGrant.state, - playerCurrency: state.playerCurrency + quest.reward.currency, - playerInventory: addInventoryItems( - state.playerInventory, - quest.reward.items, - ), - npcStates: { - ...state.npcStates, - [npcKey]: { - ...markNpcFirstMeaningfulContactResolved(npcState), - affinity: nextAffinity, - relationState: buildRelationState(nextAffinity), - }, - }, - } satisfies RuntimeGameState; - nextState = appendStoryEngineCarrierMemory(nextState, quest.reward.items); - - replaceRuntimeSessionRawGameState( - session, - nextState as unknown as JsonRecord, - ); - - return { - actionText: `向${encounter.npcName}交付委托`, - resultText: buildQuestTurnInResultText(quest, { - experienceText: buildExperienceGrantResultText(experienceGrant), - }), - patches: [ - { - type: 'npc_affinity_changed', - npcId: npcKey, - previousAffinity: npcState.affinity, - nextAffinity, - }, - ], - }; -} - -export function isSupportedQuestStoryFunctionId(functionId: string) { - return SUPPORTED_QUEST_STORY_FUNCTION_IDS.has(functionId); -} - -export function resolveQuestStoryAction( - session: RuntimeSession, - request: RuntimeStoryActionRequest, - options: { - currentStory?: unknown; - } = {}, -): QuestStoryResolution { - switch (request.action.functionId) { - case 'npc_chat_quest_offer_view': - return resolveQuestOfferViewAction(session, options.currentStory); - case 'npc_chat_quest_offer_replace': - return resolveQuestOfferReplaceAction(session, options.currentStory); - case 'npc_chat_quest_offer_abandon': - return resolveQuestOfferAbandonAction(session, options.currentStory); - case 'npc_quest_accept': - return resolveQuestAcceptAction(session, options.currentStory); - case 'npc_quest_turn_in': - return resolveQuestTurnInAction(session, request); - default: - throw invalidRequest( - `暂不支持的 Quest 动作:${request.action.functionId}`, - ); - } -} diff --git a/server-node/src/modules/quest/questTask6Bridge.ts b/server-node/src/modules/quest/questTask6Bridge.ts deleted file mode 100644 index 94f2196e..00000000 --- a/server-node/src/modules/quest/questTask6Bridge.ts +++ /dev/null @@ -1,17 +0,0 @@ -// Temporary bridge for legacy pure quest task6 action logic from src/**. -export { - addInventoryItems, - buildRelationState, - incrementGameRuntimeStats, -} from '../runtime/runtimeStatePrimitives.js'; -export { - buildQuestForEncounter, -} from '../../bridges/legacyQuestProgressBridge.js'; -export { - acceptQuest, - buildQuestAcceptResultText, - buildQuestTurnInResultText, - getQuestForIssuer, - isQuestReadyToClaim, - turnInQuest, -} from './questProgressionService.js'; diff --git a/server-node/src/modules/quest/runtimeQuestModule.ts b/server-node/src/modules/quest/runtimeQuestModule.ts deleted file mode 100644 index 2d38377b..00000000 --- a/server-node/src/modules/quest/runtimeQuestModule.ts +++ /dev/null @@ -1,1201 +0,0 @@ -import { - QUEST_INTIMACY_LEVELS, - QUEST_NARRATIVE_TYPES, - QUEST_OBJECTIVE_KINDS, - QUEST_REWARD_THEMES, - QUEST_URGENCY_LEVELS, -} from '../../../../packages/shared/src/contracts/rpgRuntimeQuestAssist.js'; -import { - buildQuestIntentPrompt, - QUEST_INTENT_SYSTEM_PROMPT, -} from '../../prompts/questPrompts.js'; -import { formatCurrency } from '../runtime/runtimeEconomyPrimitives.js'; - -export { buildQuestIntentPrompt, QUEST_INTENT_SYSTEM_PROMPT }; - -export type QuestNarrativeType = (typeof QUEST_NARRATIVE_TYPES)[number]; -export type QuestObjectiveKind = (typeof QUEST_OBJECTIVE_KINDS)[number]; -export type QuestUrgency = (typeof QUEST_URGENCY_LEVELS)[number]; -export type QuestIntimacy = (typeof QUEST_INTIMACY_LEVELS)[number]; -export type QuestRewardTheme = (typeof QUEST_REWARD_THEMES)[number]; -export type QuestStatus = - | 'active' - | 'ready_to_turn_in' - | 'completed' - | 'turned_in' - | 'failed' - | 'expired'; - -export type QuestRewardItem = { - id: string; - category: string; - name: string; - quantity: number; - rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'; - tags: string[]; -}; - -export type QuestReward = { - affinityBonus: number; - currency: number; - experience?: number; - items: QuestRewardItem[]; - intel?: { - rumorText: string; - unlockedSceneId?: string | null; - }; - storyHint?: string; -}; - -export type QuestStep = { - id: string; - kind: QuestObjectiveKind; - targetHostileNpcId?: string; - targetNpcId?: string; - targetSceneId?: string; - targetItemId?: string; - requiredCount: number; - progress: number; - title: string; - revealText: string; - completeText: string; -}; - -export type QuestObjective = { - kind: QuestObjectiveKind; - targetHostileNpcId?: string; - targetNpcId?: string; - targetSceneId?: string; - targetItemId?: string; - requiredCount?: number; -}; - -export type QuestNarrativeBinding = { - origin: 'ai_compiled' | 'fallback_builder'; - narrativeType: QuestNarrativeType; - dramaticNeed: string; - issuerGoal: string; - playerHook: string; - worldReason: string; - followupHooks: string[]; -}; - -export type QuestLogEntry = { - id: string; - issuerNpcId: string; - issuerNpcName: string; - sceneId: string | null; - chapterId?: string | null; - actId?: string | null; - threadId?: string | null; - contractId?: string | null; - title: string; - description: string; - summary: string; - objective: QuestObjective; - progress: number; - status: QuestStatus; - completionNotified: boolean; - reward: QuestReward; - rewardText: string; - narrativeBinding: QuestNarrativeBinding; - steps?: QuestStep[]; - activeStepId?: string | null; - visibleStage?: number; - hiddenFlags?: string[]; - discoveredFactIds?: string[]; - relatedCarrierIds?: string[]; - consequenceIds?: string[]; -}; - -export type QuestSceneNpcSnapshot = { - id: string; - name: string; - description?: string; - avatar?: string; - role?: string; - monsterPresetId?: string | null; - initialAffinity?: number; - hostile?: boolean; -}; - -export type QuestSceneSnapshot = { - id: string; - name: string; - description?: string; - npcs: QuestSceneNpcSnapshot[]; - treasureHints: string[]; -}; - -export type QuestIntent = { - title: string; - description: string; - summary: string; - narrativeType: QuestNarrativeType; - dramaticNeed: string; - issuerGoal: string; - playerHook: string; - worldReason: string; - recommendedObjectiveKinds: QuestObjectiveKind[]; - urgency: QuestUrgency; - intimacy: QuestIntimacy; - rewardTheme: QuestRewardTheme; - followupHooks: string[]; -}; - -export type QuestOpportunity = { - shouldOffer: boolean; - reason: string; - suggestedIssuerNpcId?: string; - suggestedThreatType?: 'hostile_npc' | 'treasure' | 'relationship' | 'travel'; -}; - -export type QuestProgressSignal = - | { - kind: 'hostile_npc_defeated'; - sceneId?: string | null; - hostileNpcId: string; - } - | { kind: 'treasure_inspected'; sceneId?: string | null } - | { kind: 'npc_spar_completed'; npcId: string } - | { kind: 'npc_talk_completed'; npcId: string } - | { kind: 'scene_reached'; sceneId: string } - | { kind: 'item_delivered'; npcId: string; itemId: string; quantity: number }; - -export type QuestGenerationContext = { - worldType: string | null | undefined; - customWorldProfile?: { - name?: string; - summary?: string; - } | null; - actState?: { id?: string | null } | null; - currentSceneId?: string | null; - currentSceneName?: string | null; - currentSceneDescription?: string | null; - issuerNpcId: string; - issuerNpcName: string; - issuerNpcContext: string; - issuerAffinity: number; - issuerNarrativeProfile?: { - publicMask?: string; - visibleLine?: string; - immediatePressure?: string; - reactionHooks?: string[]; - } | null; - issuerDisclosureStage?: string | null; - issuerWarmthStage?: string | null; - activeThreadIds: string[]; - encounterKind?: string | null; - currentSceneTreasureHintCount: number; - currentSceneHostileNpcIds: string[]; - recentStoryMoments: Array<{ text: string }>; - playerCharacter?: { - id: string; - name?: string; - title?: string; - } | null; - playerProgression?: { - level?: number; - currentLevelXp?: number; - totalXp?: number; - xpToNextLevel?: number; - } | null; - playerHp?: number; - playerMaxHp?: number; - playerMana?: number; - playerMaxMana?: number; - playerInventory?: Array<{ name: string }>; - playerEquipment?: unknown; - activeCompanions?: Array<{ characterId: string }>; - rosterCompanions?: Array<{ characterId: string }>; - currentQuestSummary?: Array<{ - id: string; - title: string; - status: QuestStatus; - issuerNpcId: string; - }>; -}; - -export type QuestCompilationRequest = { - issuerNpcId: string; - issuerNpcName: string; - roleText: string; - scene: QuestSceneSnapshot | null; - worldType: string | null | undefined; - context?: QuestGenerationContext; - origin?: QuestNarrativeBinding['origin']; -}; - -export type QuestPreviewRequest = QuestCompilationRequest & { - currentQuests?: Array<{ - id: string; - issuerNpcId: string; - status: QuestStatus; - }>; -}; - -type RuntimeEncounterLike = { - id?: string; - kind?: string; - npcName: string; - context: string; - narrativeProfile?: QuestGenerationContext['issuerNarrativeProfile']; -}; - -type RuntimeNpcStateLike = { - affinity?: number; - recruited?: boolean; -}; - -type RuntimeSceneLike = { - id: string; - name: string; - description?: string; - npcs?: QuestSceneNpcSnapshot[]; - treasureHints?: string[]; -}; - -type RuntimeStateLike = { - worldType: string | null | undefined; - customWorldProfile?: QuestGenerationContext['customWorldProfile']; - storyEngineMemory?: { - actState?: { id?: string | null } | null; - activeThreadIds?: string[]; - } | null; - currentScenePreset?: RuntimeSceneLike | null; - storyHistory: Array<{ text: string }>; - playerCharacter?: QuestGenerationContext['playerCharacter']; - playerProgression?: QuestGenerationContext['playerProgression']; - playerHp?: number; - playerMaxHp?: number; - playerMana?: number; - playerMaxMana?: number; - playerInventory?: Array<{ name: string }>; - playerEquipment?: unknown; - companions?: Array<{ characterId: string }>; - roster?: Array<{ characterId: string }>; - quests: QuestLogEntry[]; - npcStates: Record; -}; - -const REWARD_READY_STATUSES: QuestStatus[] = ['ready_to_turn_in', 'completed']; -const TERMINAL_QUEST_STATUSES: QuestStatus[] = [ - 'turned_in', - 'failed', - 'expired', -]; - -function clampProgress(progress: number | undefined, requiredCount: number) { - return Math.max(0, Math.min(requiredCount, Math.round(progress ?? 0))); -} - -function compactQuestLabel(label: string, maxLength = 6) { - const trimmed = label.trim(); - return trimmed.length > maxLength ? trimmed.slice(0, maxLength) : trimmed; -} - -function buildQuestId( - issuerNpcId: string, - kind: QuestObjectiveKind, - targetKey: string, -) { - return `quest:${issuerNpcId}:${kind}:${targetKey}`; -} - -function isRewardReadyStatus(status: QuestStatus) { - return REWARD_READY_STATUSES.includes(status); -} - -function isTerminalStatus(status: QuestStatus) { - return TERMINAL_QUEST_STATUSES.includes(status); -} - -function getNpcDisclosureStage( - affinity: number, - options: { recruited?: boolean } = {}, -) { - if (options.recruited || affinity >= 50) return 'deep'; - if (affinity >= 30) return 'honest'; - if (affinity >= 15) return 'partial'; - return 'guarded'; -} - -function getNpcWarmthStage( - affinity: number, - options: { recruited?: boolean } = {}, -) { - if (options.recruited || affinity >= 50) return 'warm'; - if (affinity >= 30) return 'cooperative'; - if (affinity >= 15) return 'neutral'; - return 'distant'; -} - -type SceneQuestThreat = - | { - kind: 'defeat_hostile_npc'; - targetHostileNpcId: string; - targetHostileNpcName: string; - targetSceneId: string; - suggestedThreatType: 'hostile_npc'; - } - | { - kind: 'inspect_treasure'; - targetSceneId: string; - targetSceneName: string; - suggestedThreatType: 'treasure'; - } - | { - kind: 'spar_with_npc'; - suggestedThreatType: 'relationship'; - }; - -function getScenePrimaryThreat( - scene: QuestSceneSnapshot | null, -): SceneQuestThreat | null { - if (!scene) { - return null; - } - - const hostileNpc = - scene.npcs.find((npc) => Boolean(npc.hostile || npc.monsterPresetId)) ?? - null; - if (hostileNpc) { - const targetHostileNpcId = hostileNpc.monsterPresetId ?? hostileNpc.id; - return { - kind: 'defeat_hostile_npc', - targetHostileNpcId, - targetHostileNpcName: hostileNpc.name || targetHostileNpcId, - targetSceneId: scene.id, - suggestedThreatType: 'hostile_npc', - }; - } - - if ((scene.treasureHints?.length ?? 0) > 0) { - return { - kind: 'inspect_treasure', - targetSceneId: scene.id, - targetSceneName: scene.name, - suggestedThreatType: 'treasure', - }; - } - - return { - kind: 'spar_with_npc', - suggestedThreatType: 'relationship', - }; -} - -function buildRewardItems(params: { - issuerNpcId: string; - worldType: string | null | undefined; - rewardTheme: QuestRewardTheme; -}) { - const prefix = `quest-reward:${params.issuerNpcId}`; - - switch (params.rewardTheme) { - case 'intel': - return [ - { - id: `${prefix}:intel-record`, - category: '线索', - name: '旧案残页', - quantity: 1, - rarity: 'rare', - tags: ['relic', 'intel'], - }, - ] satisfies QuestRewardItem[]; - case 'relationship': - return [ - { - id: `${prefix}:bond-token`, - category: '信物', - name: '同行信符', - quantity: 1, - rarity: 'rare', - tags: ['relic', 'bond'], - }, - ] satisfies QuestRewardItem[]; - case 'rare_item': - return [ - { - id: `${prefix}:rare-gear`, - category: '装备', - name: params.worldType === 'XIANXIA' ? '灵纹佩' : '断桥佩刃', - quantity: 1, - rarity: 'epic', - tags: ['equipment', 'relic'], - }, - ] satisfies QuestRewardItem[]; - case 'resource': - return [ - { - id: `${prefix}:supply-pack`, - category: '补给', - name: params.worldType === 'XIANXIA' ? '回灵散' : '疗伤丹', - quantity: 2, - rarity: 'uncommon', - tags: ['healing', 'mana'], - }, - ] satisfies QuestRewardItem[]; - default: - return [ - { - id: `${prefix}:field-kit`, - category: '补给', - name: params.worldType === 'XIANXIA' ? '护心符' : '常备药包', - quantity: 1, - rarity: 'rare', - tags: ['healing', 'material'], - }, - ] satisfies QuestRewardItem[]; - } -} - -function computeXpToNextLevel(level: number) { - const scale = Math.max(0, level - 1); - return 60 + 20 * scale + 8 * scale * scale; -} - -function resolveQuestTargetLevel(context?: QuestGenerationContext) { - const level = context?.playerProgression?.level; - if (typeof level !== 'number' || !Number.isFinite(level)) { - return 1; - } - - return Math.max(1, Math.floor(level)); -} - -function resolveQuestStepCountMultiplier(stepCount: number) { - if (stepCount <= 1) { - return 0.85; - } - - if (stepCount === 2) { - return 1; - } - - return 1.12; -} - -function resolveQuestNarrativeXpMultiplier(narrativeType: QuestNarrativeType) { - return narrativeType === 'trial' || narrativeType === 'bounty' ? 1.08 : 1; -} - -function resolveQuestUrgencyXpMultiplier(urgency: QuestUrgency) { - return urgency === 'high' ? 1.05 : 1; -} - -function buildQuestExperienceReward(params: { - context?: QuestGenerationContext; - narrativeType: QuestNarrativeType; - urgency: QuestUrgency; - stepCount: number; -}) { - const baseQuestXp = - computeXpToNextLevel(resolveQuestTargetLevel(params.context)) * 0.45; - - return Math.max( - 5, - Math.round( - (baseQuestXp * - resolveQuestStepCountMultiplier(params.stepCount) * - resolveQuestNarrativeXpMultiplier(params.narrativeType) * - resolveQuestUrgencyXpMultiplier(params.urgency)) / - 5, - ) * 5, - ); -} - -function buildQuestReward(params: { - issuerNpcId: string; - issuerNpcName: string; - worldType: string | null | undefined; - rewardTheme: QuestRewardTheme; - narrativeType: QuestNarrativeType; - urgency: QuestUrgency; - stepCount: number; - scene: QuestSceneSnapshot | null; - context?: QuestGenerationContext; -}) { - const baseCurrency = - params.rewardTheme === 'intel' - ? params.worldType === 'XIANXIA' - ? 40 - : 58 - : params.worldType === 'XIANXIA' - ? 54 - : 72; - - const reward: QuestReward = { - affinityBonus: - params.narrativeType === 'relationship' || - params.narrativeType === 'trial' - ? 14 - : 12, - currency: baseCurrency, - experience: buildQuestExperienceReward({ - context: params.context, - narrativeType: params.narrativeType, - urgency: params.urgency, - stepCount: params.stepCount, - }), - items: buildRewardItems(params), - storyHint: `${params.issuerNpcName}把和眼前局势最相关的收获留给了你。`, - }; - - if (params.rewardTheme === 'intel') { - reward.intel = { - rumorText: params.scene - ? `${params.scene.name} 一带还压着更深一层没说透的旧线索。` - : '对方愿意把一条尚未外传的消息托付给你。', - unlockedSceneId: params.scene?.id ?? null, - }; - } - - return reward; -} - -function buildRewardText( - reward: QuestReward, - worldType: string | null | undefined, -) { - const itemText = - reward.items.map((item) => item.name).join('、') || '当前局势相关的补给'; - const experienceText = - (reward.experience ?? 0) > 0 ? `、经验 +${reward.experience}` : ''; - const intelText = reward.intel?.rumorText - ? `,以及情报“${reward.intel.rumorText}”` - : ''; - return `完成后可获得好感 +${reward.affinityBonus}${experienceText}、${formatCurrency( - reward.currency, - worldType, - )}、${itemText}${intelText}。`; -} - -function buildTalkBackStep( - issuerNpcId: string, - issuerNpcName: string, -): QuestStep { - return { - id: 'step_report_back', - kind: 'talk_to_npc', - targetNpcId: issuerNpcId, - requiredCount: 1, - progress: 0, - title: `回去找${issuerNpcName}`, - revealText: `回去和 ${issuerNpcName} 把这次委托的结果说明白。`, - completeText: `你已经和 ${issuerNpcName} 交代清楚,现在可以正式领取报酬。`, - }; -} - -function buildPrimaryQuestStep(params: { - issuerNpcId: string; - issuerNpcName: string; - scene: QuestSceneSnapshot | null; - intent: QuestIntent; -}): QuestStep | null { - const { issuerNpcId, issuerNpcName, scene, intent } = params; - const threat = getScenePrimaryThreat(scene); - if (!threat) { - return null; - } - - const preferredKinds = - intent.recommendedObjectiveKinds.length > 0 - ? intent.recommendedObjectiveKinds - : [threat.kind]; - const chosenKind = preferredKinds.includes(threat.kind) - ? threat.kind - : (preferredKinds[0] ?? threat.kind); - - if (chosenKind === 'inspect_treasure' && scene) { - return { - id: 'step_primary', - kind: 'inspect_treasure', - targetSceneId: scene.id, - requiredCount: 1, - progress: 0, - title: `调查${compactQuestLabel(scene.name, 8)}`, - revealText: `${issuerNpcName} 想确认 ${scene.name} 一带留下的异常究竟是真是假。`, - completeText: `${scene.name} 的情况已经查明,可以回去和 ${issuerNpcName} 对情报了。`, - }; - } - - if (chosenKind === 'spar_with_npc') { - return { - id: 'step_primary', - kind: 'spar_with_npc', - targetNpcId: issuerNpcId, - requiredCount: 1, - progress: 0, - title: `与${issuerNpcName}切磋`, - revealText: `${issuerNpcName} 想先亲自试一试你的成色,再决定后续是否继续合作。`, - completeText: `这场切磋已经结束,${issuerNpcName} 对你的判断也有了变化。`, - }; - } - - if (threat.kind === 'defeat_hostile_npc') { - return { - id: 'step_primary', - kind: 'defeat_hostile_npc', - targetHostileNpcId: threat.targetHostileNpcId, - targetSceneId: threat.targetSceneId, - requiredCount: 1, - progress: 0, - title: `压制${compactQuestLabel(threat.targetHostileNpcName, 8)}`, - revealText: `${issuerNpcName} 希望你先压制 ${threat.targetHostileNpcName},再回来说明局势。`, - completeText: `${threat.targetHostileNpcName} 已被压制,回去向 ${issuerNpcName} 汇报吧。`, - }; - } - - return null; -} - -function deriveObjectiveFromStep(step: QuestStep | null): QuestObjective { - if (!step) { - return { - kind: 'talk_to_npc', - }; - } - - return { - kind: step.kind, - targetHostileNpcId: step.targetHostileNpcId, - targetNpcId: step.targetNpcId, - targetSceneId: step.targetSceneId, - targetItemId: step.targetItemId, - requiredCount: step.requiredCount, - }; -} - -function getQuestActiveStep(quest: QuestLogEntry) { - if (!quest.steps?.length) { - return null; - } - - if (quest.activeStepId) { - return quest.steps.find((step) => step.id === quest.activeStepId) ?? null; - } - - return quest.steps.find((step) => step.progress < step.requiredCount) ?? null; -} - -function normalizeQuestTitle(rawTitle: string, fallbackTitle: string) { - const title = rawTitle - .replace(/[《》「」“”"']/gu, '') - .replace(/[,。!?;:,.!?;:].*$/u, '') - .trim(); - - if (title && title.length <= 12) { - return title; - } - - return fallbackTitle.length <= 12 - ? fallbackTitle - : fallbackTitle.slice(0, 10); -} - -function normalizeQuestLogEntry(quest: QuestLogEntry): QuestLogEntry { - const steps = (quest.steps ?? []).map((step) => ({ - ...step, - requiredCount: Math.max(1, Math.round(step.requiredCount ?? 1)), - progress: clampProgress( - step.progress, - Math.max(1, Math.round(step.requiredCount ?? 1)), - ), - })); - const activeStep = - steps.find((step) => step.progress < step.requiredCount) ?? null; - const terminal = isTerminalStatus(quest.status); - const rewardReady = !terminal && !activeStep ? 'completed' : quest.status; - - return { - ...quest, - title: normalizeQuestTitle(quest.title, quest.title), - summary: quest.summary.trim() || quest.description.trim(), - progress: - activeStep?.progress ?? steps[steps.length - 1]?.requiredCount ?? 0, - objective: deriveObjectiveFromStep( - activeStep ?? steps[steps.length - 1] ?? null, - ), - status: terminal ? quest.status : rewardReady, - completionNotified: quest.completionNotified ?? false, - reward: { - affinityBonus: Math.round(quest.reward.affinityBonus ?? 0), - currency: Math.max(0, Math.round(quest.reward.currency ?? 0)), - experience: Math.max(0, Math.round(quest.reward.experience ?? 0)), - items: quest.reward.items ?? [], - intel: quest.reward.intel, - storyHint: quest.reward.storyHint, - }, - rewardText: quest.rewardText.trim(), - steps, - activeStepId: activeStep?.id ?? null, - visibleStage: quest.visibleStage ?? 0, - hiddenFlags: quest.hiddenFlags ?? [], - discoveredFactIds: quest.discoveredFactIds ?? [], - relatedCarrierIds: quest.relatedCarrierIds ?? [], - consequenceIds: quest.consequenceIds ?? [], - }; -} - -function stepMatchesSignal(step: QuestStep, signal: QuestProgressSignal) { - switch (signal.kind) { - case 'hostile_npc_defeated': - return ( - step.kind === 'defeat_hostile_npc' && - (!step.targetSceneId || step.targetSceneId === signal.sceneId) && - step.targetHostileNpcId === signal.hostileNpcId - ); - case 'treasure_inspected': - return ( - step.kind === 'inspect_treasure' && - (!step.targetSceneId || step.targetSceneId === signal.sceneId) - ); - case 'npc_spar_completed': - return step.kind === 'spar_with_npc' && step.targetNpcId === signal.npcId; - case 'npc_talk_completed': - return step.kind === 'talk_to_npc' && step.targetNpcId === signal.npcId; - case 'scene_reached': - return ( - step.kind === 'reach_scene' && step.targetSceneId === signal.sceneId - ); - case 'item_delivered': - return ( - step.kind === 'deliver_item' && - step.targetNpcId === signal.npcId && - step.targetItemId === signal.itemId - ); - default: - return false; - } -} - -function getSignalProgressIncrement(signal: QuestProgressSignal) { - return signal.kind === 'item_delivered' ? Math.max(1, signal.quantity) : 1; -} - -export function buildQuestGenerationContextFromState(params: { - state: RuntimeStateLike; - encounter: RuntimeEncounterLike; -}) { - const { state, encounter } = params; - const issuerNpcId = encounter.id ?? encounter.npcName; - const issuerState = state.npcStates[issuerNpcId]; - - return { - worldType: state.worldType, - customWorldProfile: state.customWorldProfile ?? null, - actState: state.storyEngineMemory?.actState ?? null, - currentSceneId: state.currentScenePreset?.id ?? null, - currentSceneName: state.currentScenePreset?.name ?? null, - currentSceneDescription: state.currentScenePreset?.description ?? null, - issuerNpcId, - issuerNpcName: encounter.npcName, - issuerNpcContext: encounter.context, - issuerAffinity: issuerState?.affinity ?? 0, - issuerNarrativeProfile: encounter.narrativeProfile ?? null, - issuerDisclosureStage: getNpcDisclosureStage(issuerState?.affinity ?? 0, { - recruited: issuerState?.recruited, - }), - issuerWarmthStage: getNpcWarmthStage(issuerState?.affinity ?? 0, { - recruited: issuerState?.recruited, - }), - activeThreadIds: - state.storyEngineMemory?.activeThreadIds?.slice(0, 4) ?? [], - encounterKind: encounter.kind ?? 'npc', - currentSceneTreasureHintCount: - state.currentScenePreset?.treasureHints?.length ?? 0, - currentSceneHostileNpcIds: (state.currentScenePreset?.npcs ?? []) - .filter((npc) => Boolean(npc.hostile || npc.monsterPresetId)) - .map((npc) => npc.monsterPresetId ?? npc.id), - recentStoryMoments: state.storyHistory.slice(-6), - playerCharacter: state.playerCharacter ?? null, - playerProgression: state.playerProgression ?? null, - playerHp: state.playerHp, - playerMaxHp: state.playerMaxHp, - playerMana: state.playerMana, - playerMaxMana: state.playerMaxMana, - playerInventory: state.playerInventory ?? [], - playerEquipment: state.playerEquipment, - activeCompanions: state.companions ?? [], - rosterCompanions: state.roster ?? [], - currentQuestSummary: state.quests.map((quest) => ({ - id: quest.id, - title: quest.title, - status: quest.status, - issuerNpcId: quest.issuerNpcId, - })), - } satisfies QuestGenerationContext; -} - -export function findQuestById(quests: QuestLogEntry[], questId: string) { - return quests.find((quest) => quest.id === questId) ?? null; -} - -export function getQuestForIssuer( - quests: QuestLogEntry[], - issuerNpcId: string, -) { - return ( - normalizeQuestLogEntries(quests).find( - (quest) => - quest.issuerNpcId === issuerNpcId && quest.status !== 'turned_in', - ) ?? null - ); -} - -export function evaluateQuestOpportunity( - params: QuestPreviewRequest, -): QuestOpportunity { - const { issuerNpcId, scene, currentQuests = [] } = params; - if (!scene) { - return { - shouldOffer: false, - reason: '当前缺少可落地的场景信息,暂时不适合生成委托。', - }; - } - - if ( - currentQuests.some( - (quest) => - quest.issuerNpcId === issuerNpcId && quest.status !== 'turned_in', - ) - ) { - return { - shouldOffer: false, - reason: '这名角色还有尚未结清的委托。', - suggestedIssuerNpcId: issuerNpcId, - }; - } - - const liveQuestCount = currentQuests.filter( - (quest) => !isTerminalStatus(quest.status), - ).length; - if (liveQuestCount >= 4) { - return { - shouldOffer: false, - reason: '当前未完成委托已经偏多,不再继续塞入新的任务机会。', - suggestedIssuerNpcId: issuerNpcId, - }; - } - - const threat = getScenePrimaryThreat(scene); - if (!threat) { - return { - shouldOffer: false, - reason: '当前场景里缺少足够明确的任务抓手。', - suggestedIssuerNpcId: issuerNpcId, - }; - } - - return { - shouldOffer: true, - reason: - threat.kind === 'inspect_treasure' - ? `${scene.name} 附近出现了值得调查的异常。` - : threat.kind === 'spar_with_npc' - ? `${params.issuerNpcName} 更适合给出一份关系驱动的试炼型委托。` - : `${scene.name} 附近存在可以被明确指向的敌对角色威胁。`, - suggestedIssuerNpcId: issuerNpcId, - suggestedThreatType: threat.suggestedThreatType, - }; -} - -export function buildFallbackQuestIntent( - params: QuestCompilationRequest, -): QuestIntent { - const { issuerNpcName, scene } = params; - const threat = getScenePrimaryThreat(scene); - - if (threat?.kind === 'defeat_hostile_npc') { - return { - title: `压制${compactQuestLabel(threat.targetHostileNpcName, 8)}`, - description: `${issuerNpcName} 希望你先处理掉 ${ - scene?.name ?? '前方区域' - } 徘徊的 ${threat.targetHostileNpcName},再回来交换后续情报。`, - summary: `击退 ${threat.targetHostileNpcName},然后回去和 ${issuerNpcName} 交谈`, - narrativeType: 'bounty', - dramaticNeed: `${scene?.name ?? '前方区域'} 的危险已经影响到 ${issuerNpcName} 的下一步行动。`, - issuerGoal: `先压下 ${threat.targetHostileNpcName} 带来的威胁,再确认局势是否稳定。`, - playerHook: '你正好位于现场,也最适合先去验证这一层风险。', - worldReason: `${scene?.name ?? '这一区域'} 的局势还没有真正安定下来。`, - recommendedObjectiveKinds: ['defeat_hostile_npc', 'talk_to_npc'], - urgency: 'medium', - intimacy: 'cooperative', - rewardTheme: 'resource', - followupHooks: [ - `${issuerNpcName} 手里还握着没完全说开的后续线索。`, - '这份委托背后还有更深一层的局势变化。', - ], - }; - } - - if (threat?.kind === 'inspect_treasure' && scene) { - return { - title: `${compactQuestLabel(scene.name)}异动`, - description: `${issuerNpcName} 不确定 ${scene.name} 一带出现的异动是真是假,想让你先去看清楚,再回来对一遍情报。`, - summary: `调查 ${scene.name} 的异常,然后回去向 ${issuerNpcName} 汇报`, - narrativeType: 'investigation', - dramaticNeed: `${issuerNpcName} 想知道这条线索值不值得继续深挖。`, - issuerGoal: `确认 ${scene.name} 一带究竟藏着什么。`, - playerHook: '你已经身在局中,最适合把这层异常先摸清。', - worldReason: `${scene.name} 周围留下了还没有被说清的痕迹。`, - recommendedObjectiveKinds: ['inspect_treasure', 'talk_to_npc'], - urgency: 'medium', - intimacy: 'cooperative', - rewardTheme: 'intel', - followupHooks: [ - `${scene.name} 的异常可能还连着另一处更深的地点。`, - `${issuerNpcName} 对这里并不是完全陌生。`, - ], - }; - } - - return { - title: `${compactQuestLabel(issuerNpcName)}试炼`, - description: `${issuerNpcName} 想先亲自试一试你的成色,再决定要不要把更关键的事继续交给你。`, - summary: `和 ${issuerNpcName} 切磋一场,然后回来把话说透`, - narrativeType: 'trial', - dramaticNeed: `${issuerNpcName} 还没完全确认你值不值得信任。`, - issuerGoal: '通过切磋判断你的实力和态度。', - playerHook: '你只需要接住这场试探,就能让关系往前推一步。', - worldReason: '在这种局势里,口头承诺往往不如当面试一试来得直接。', - recommendedObjectiveKinds: ['spar_with_npc', 'talk_to_npc'], - urgency: 'low', - intimacy: 'trust_based', - rewardTheme: 'relationship', - followupHooks: [ - `${issuerNpcName} 会根据这次试探重新判断和你的距离。`, - '这次切磋很可能会牵出下一轮更正式的合作。', - ], - }; -} - -export function compileQuestIntentToQuest( - params: QuestCompilationRequest, - intent: QuestIntent, -): QuestLogEntry | null { - const fallbackIntent = buildFallbackQuestIntent(params); - const primaryStep = buildPrimaryQuestStep({ - issuerNpcId: params.issuerNpcId, - issuerNpcName: params.issuerNpcName, - scene: params.scene, - intent, - }); - if (!primaryStep) { - return null; - } - - const steps = [ - primaryStep, - buildTalkBackStep(params.issuerNpcId, params.issuerNpcName), - ]; - const reward = buildQuestReward({ - issuerNpcId: params.issuerNpcId, - issuerNpcName: params.issuerNpcName, - worldType: params.worldType, - rewardTheme: intent.rewardTheme, - narrativeType: intent.narrativeType, - urgency: intent.urgency, - stepCount: steps.length, - scene: params.scene, - context: params.context, - }); - const rewardText = buildRewardText(reward, params.worldType); - - return normalizeQuestLogEntry({ - id: buildQuestId( - params.issuerNpcId, - primaryStep.kind, - primaryStep.targetHostileNpcId ?? - primaryStep.targetSceneId ?? - primaryStep.targetNpcId ?? - params.scene?.id ?? - primaryStep.id, - ), - issuerNpcId: params.issuerNpcId, - issuerNpcName: params.issuerNpcName, - sceneId: params.scene?.id ?? null, - chapterId: null, - actId: params.context?.actState?.id ?? null, - threadId: params.context?.activeThreadIds?.[0] ?? null, - contractId: null, - title: normalizeQuestTitle(intent.title, fallbackIntent.title), - description: intent.description.trim() || fallbackIntent.description, - summary: intent.summary.trim() || fallbackIntent.summary, - objective: deriveObjectiveFromStep(primaryStep), - progress: 0, - status: 'active', - completionNotified: false, - reward, - rewardText, - narrativeBinding: { - origin: params.origin ?? 'fallback_builder', - narrativeType: intent.narrativeType, - dramaticNeed: intent.dramaticNeed, - issuerGoal: intent.issuerGoal, - playerHook: intent.playerHook, - worldReason: intent.worldReason, - followupHooks: intent.followupHooks, - }, - steps, - activeStepId: steps[0]?.id ?? null, - visibleStage: 0, - hiddenFlags: [], - discoveredFactIds: [], - relatedCarrierIds: [], - consequenceIds: [], - }); -} - -export function buildQuestForEncounter(params: QuestPreviewRequest) { - const opportunity = evaluateQuestOpportunity(params); - if (!opportunity.shouldOffer) { - return null; - } - - return compileQuestIntentToQuest( - { - ...params, - origin: 'fallback_builder', - }, - buildFallbackQuestIntent(params), - ); -} - -export function buildChapterQuestForScene(params: { - scene: QuestSceneSnapshot | null; - worldType: string | null | undefined; - context?: QuestGenerationContext; -}) { - const { scene, worldType, context } = params; - if (!scene) { - return null; - } - - const guideNpc = - scene.npcs.find((npc) => !npc.hostile && !npc.monsterPresetId) ?? null; - return buildQuestForEncounter({ - issuerNpcId: guideNpc?.id ?? `scene-guide:${scene.id}`, - issuerNpcName: guideNpc?.name ?? `${compactQuestLabel(scene.name)}引路人`, - roleText: guideNpc?.role ?? scene.description ?? scene.name, - scene, - worldType, - currentQuests: [], - context, - origin: 'fallback_builder', - }); -} - -export function normalizeQuestLogEntries(quests: QuestLogEntry[]) { - return quests.map((quest) => normalizeQuestLogEntry(quest)); -} - -export function buildQuestAcceptResultText(quest: QuestLogEntry) { - const normalizedQuest = normalizeQuestLogEntry(quest); - const activeStep = getQuestActiveStep(normalizedQuest); - return `${normalizedQuest.issuerNpcName} 正式把委托交到了你手上。${ - activeStep?.revealText ?? normalizedQuest.summary - }`; -} - -export function buildQuestTurnInResultText(quest: QuestLogEntry) { - const normalizedQuest = normalizeQuestLogEntry(quest); - const itemText = - normalizedQuest.reward.items.map((item) => item.name).join('、') || '补给'; - const intelText = normalizedQuest.reward.intel?.rumorText - ? `,并额外告诉了你一条消息:${normalizedQuest.reward.intel.rumorText}` - : ''; - const storyHintText = normalizedQuest.reward.storyHint - ? ` ${normalizedQuest.reward.storyHint}` - : ''; - - return `${normalizedQuest.issuerNpcName} 确认你已经完成委托,交给了你 ${ - normalizedQuest.reward.currency - } 赏金和 ${itemText}${intelText}。${storyHintText}`; -} - -export function acceptQuest(quests: QuestLogEntry[], quest: QuestLogEntry) { - const normalizedQuests = normalizeQuestLogEntries(quests); - if (findQuestById(normalizedQuests, quest.id)) { - return normalizedQuests; - } - - return [...normalizedQuests, normalizeQuestLogEntries([quest])[0]!]; -} - -export function markQuestTurnedIn(quests: QuestLogEntry[], questId: string) { - return quests.map((quest) => - quest.id === questId - ? normalizeQuestLogEntries([ - { - ...quest, - status: 'turned_in', - completionNotified: true, - steps: quest.steps?.map((step) => ({ - ...step, - progress: step.requiredCount, - })), - }, - ])[0]! - : normalizeQuestLogEntries([quest])[0]!, - ); -} - -export function markQuestCompletionNotified( - quests: QuestLogEntry[], - questId: string, -) { - return quests.map((quest) => - quest.id === questId - ? normalizeQuestLogEntries([ - { - ...quest, - completionNotified: true, - }, - ])[0]! - : normalizeQuestLogEntries([quest])[0]!, - ); -} - -export function isQuestReadyToClaim(quest: QuestLogEntry) { - const status = normalizeQuestLogEntries([quest])[0]!.status; - return status === 'ready_to_turn_in' || status === 'completed'; -} - -export function applyQuestProgressSignal( - quests: QuestLogEntry[], - signal: QuestProgressSignal, -) { - return quests.map((quest) => { - const normalizedQuest = normalizeQuestLogEntry(quest); - if ( - isTerminalStatus(normalizedQuest.status) || - isRewardReadyStatus(normalizedQuest.status) - ) { - return normalizedQuest; - } - - const activeStep = getQuestActiveStep(normalizedQuest); - if (!activeStep || !stepMatchesSignal(activeStep, signal)) { - return normalizedQuest; - } - - const increment = getSignalProgressIncrement(signal); - const nextSteps = normalizedQuest.steps!.map((step) => - step.id === activeStep.id - ? { - ...step, - progress: Math.min(step.requiredCount, step.progress + increment), - } - : step, - ); - - return normalizeQuestLogEntry({ - ...normalizedQuest, - steps: nextSteps, - completionNotified: false, - }); - }); -} diff --git a/server-node/src/modules/rpg-runtime-story/RpgRuntimeOptionCompiler.ts b/server-node/src/modules/rpg-runtime-story/RpgRuntimeOptionCompiler.ts deleted file mode 100644 index a327b94c..00000000 --- a/server-node/src/modules/rpg-runtime-story/RpgRuntimeOptionCompiler.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { - buildAvailableOptions, - buildLegacyCurrentStory, - buildRuntimeViewModel, -} from './RpgRuntimeSessionDomain.js'; - -/** - * RPG runtime option / view model 编译入口。 - * 工作包 G 后所有可见 option 与 view model 都从新域目录输出。 - */ -export { buildAvailableOptions, buildRuntimeViewModel }; -export const buildRpgRuntimeAvailableOptions = buildAvailableOptions; -export const buildRpgRuntimeViewModel = buildRuntimeViewModel; -export const buildRpgRuntimeLegacyCurrentStory = buildLegacyCurrentStory; diff --git a/server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionDomain.test.ts b/server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionDomain.test.ts deleted file mode 100644 index 87cba5c2..00000000 --- a/server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionDomain.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { - buildAvailableOptions, -} from './RpgRuntimeOptionCompiler.js'; -import { - buildLegacyCurrentStory, -} from './RpgRuntimeStoryPresentationCompiler.js'; -import { - loadRuntimeSession, -} from './RpgRuntimeSessionLoader.js'; - -function createNpcSnapshot() { - return { - version: 2, - savedAt: '2026-04-19T00:00:00.000Z', - bottomTab: 'adventure', - currentStory: null, - gameState: { - worldType: 'WUXIA', - storyHistory: [], - currentEncounter: { - kind: 'npc', - id: 'npc_merchant_01', - npcName: '沈七', - npcDescription: '腰间挂着药囊的行商', - context: '受伤行商', - }, - npcInteractionActive: true, - sceneHostileNpcs: [], - inBattle: false, - playerHp: 31, - playerMaxHp: 40, - playerMana: 9, - playerMaxMana: 16, - npcStates: { - npc_merchant_01: { - affinity: 46, - chattedCount: 1, - helpUsed: false, - giftsGiven: 0, - inventory: [], - recruited: false, - }, - }, - companions: [], - currentNpcBattleMode: null, - currentNpcBattleOutcome: null, - quests: [], - playerInventory: [], - }, - }; -} - -test('buildAvailableOptions attaches npc interaction metadata from the server runtime session', () => { - const session = loadRuntimeSession( - createNpcSnapshot() as Parameters[0], - 'runtime-main', - ); - - const options = buildAvailableOptions(session); - - assert.deepEqual( - options.find((option) => option.functionId === 'npc_chat')?.interaction, - { - kind: 'npc', - npcId: 'npc_merchant_01', - action: 'chat', - }, - ); - assert.deepEqual( - options.find((option) => option.functionId === 'npc_help')?.interaction, - { - kind: 'npc', - npcId: 'npc_merchant_01', - action: 'help', - }, - ); -}); - -test('buildLegacyCurrentStory preserves runtime interaction metadata on projected options', () => { - const session = loadRuntimeSession( - createNpcSnapshot() as Parameters[0], - 'runtime-main', - ); - const options = buildAvailableOptions(session); - - const currentStory = buildLegacyCurrentStory('服务端已经生成了当前故事。', options); - - assert.deepEqual( - currentStory.options.find((option) => option.functionId === 'npc_leave') - ?.interaction, - { - kind: 'npc', - npcId: 'npc_merchant_01', - action: 'leave', - }, - ); -}); diff --git a/server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionDomain.ts b/server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionDomain.ts deleted file mode 100644 index 2e20ea51..00000000 --- a/server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionDomain.ts +++ /dev/null @@ -1,1440 +0,0 @@ -/** - * RPG runtime session 编译主实现。 - * 工作包 G 把旧 `runtimeSession.ts` 的真实逻辑迁到这里,旧文件后续只保留兼容职责。 - */ -import type { - RuntimeStoryChoicePayload, - RuntimeStoryEncounterViewModel, - RuntimeStoryOptionInteraction, - RuntimeStoryOptionView, - RuntimeStoryViewModel, - Task5RuntimeOptionScope, -} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js'; -import { TASK6_RUNTIME_FUNCTION_IDS } from '../../../../packages/shared/src/contracts/rpgRuntimeStoryAction.js'; -import type { RpgRuntimeSavedSnapshot } from '../../repositories/rpg-runtime/RpgRuntimeSnapshotRepository.js'; -import { - normalizeRuntimeEntityLevelProfile, - type RuntimeEntityLevelProfile, -} from '../progression/hostileProgressionService.js'; -import { resolvePlayerOutgoingDamageResult } from '../runtime/runtimeBuildModule.js'; -import { - isInventoryItemUsable, - resolveInventoryItemUseEffect, -} from '../runtime/runtimeInventoryEffectsModule.js'; - -type JsonRecord = Record; -type StoryHistoryRole = 'action' | 'result'; - -type FunctionDefinition = { - actionText: string; - detailText: string; - scope: Task5RuntimeOptionScope; -}; - -export type RuntimeStoryHistoryEntry = { - text: string; - historyRole: StoryHistoryRole; -}; - -export type RuntimeNpcState = { - affinity: number; - chattedCount: number; - helpUsed: boolean; - giftsGiven: number; - inventory: unknown[]; - recruited: boolean; - firstMeaningfulContactResolved: boolean; - relationState: JsonRecord | null; - stanceProfile: JsonRecord | null; - tradeStockSignature?: string | null; - revealedFacts?: string[]; - knownAttributeRumors?: string[]; - seenBackstoryChapterIds?: string[]; -}; - -export type RuntimeEncounter = { - id: string; - kind: 'npc' | 'treasure'; - npcName: string; - npcDescription: string; - context: string; - hostile: boolean; - characterId: string | null; - monsterPresetId: string | null; - levelProfile?: RuntimeEntityLevelProfile; - experienceReward?: number; -}; - -export type RuntimeHostileNpc = { - id: string; - name: string; - hp: number; - maxHp: number; - description: string; - levelProfile?: RuntimeEntityLevelProfile; - experienceReward?: number; -}; - -export type RuntimeCompanion = { - npcId: string; - characterId: string; - joinedAtAffinity: number; - hp: number; - maxHp: number; - mana: number; - maxMana: number; - skillCooldowns: Record; - animationState?: string; - actionMode?: string; - offsetX?: number; - offsetY?: number; - transitionMs?: number; -}; - -type RuntimePlayerAttributes = { - strength: number; - agility: number; - intelligence: number; - spirit: number; -}; - -type RuntimePlayerSkill = { - id: string; - name: string; - damage: number; - manaCost: number; - cooldownTurns: number; - buildBuffs?: Array<{ - id: string; - sourceType: 'skill' | 'item' | 'forge'; - sourceId: string; - name: string; - tags: string[]; - durationTurns: number; - maxStacks?: number; - }>; -}; - -type RuntimePlayerCharacter = { - attributes: RuntimePlayerAttributes; - skills: RuntimePlayerSkill[]; -}; - -type RuntimeBattleItemUseProfile = { - hpRestore?: number; - manaRestore?: number; - cooldownReduction?: number; - buildBuffs?: Array<{ - id: string; - sourceType: 'item'; - sourceId: string; - name: string; - tags: string[]; - durationTurns: number; - }>; -}; - -type RuntimeBattleInventoryItem = { - id: string; - name: string; - quantity: number; - rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'; - tags: string[]; - useProfile?: RuntimeBattleItemUseProfile; -}; - -export type RuntimeSession = { - sessionId: string; - runtimeVersion: number; - snapshotBottomTab: string; - rawGameState: JsonRecord; - worldType: string | null; - storyHistory: RuntimeStoryHistoryEntry[]; - currentEncounter: RuntimeEncounter | null; - npcInteractionActive: boolean; - sceneHostileNpcs: RuntimeHostileNpc[]; - inBattle: boolean; - playerHp: number; - playerMaxHp: number; - playerMana: number; - playerMaxMana: number; - npcStates: Record; - companions: RuntimeCompanion[]; - roster: RuntimeCompanion[]; - currentNpcBattleMode: 'fight' | 'spar' | null; - currentNpcBattleOutcome: 'fight_victory' | 'spar_complete' | null; -}; - -export const MAX_TASK5_COMPANIONS = 2; - -const STORY_FUNCTION_IDS = new Set([ - 'story_continue_adventure', - 'story_opening_camp_dialogue', - 'camp_travel_home_scene', - 'idle_call_out', - 'idle_explore_forward', - 'idle_observe_signs', - 'idle_rest_focus', - 'idle_travel_next_scene', -]); - -const COMBAT_FUNCTION_IDS = new Set([ - 'battle_attack_basic', - 'battle_use_skill', - 'battle_all_in_crush', - 'battle_escape_breakout', - 'battle_feint_step', - 'battle_finisher_window', - 'battle_guard_break', - 'battle_probe_pressure', - 'battle_recover_breath', - 'inventory_use', -]); - -const NPC_FUNCTION_IDS = new Set([ - 'npc_chat', - 'npc_fight', - 'npc_help', - 'npc_leave', - 'npc_preview_talk', - 'npc_recruit', - 'npc_spar', -]); - -const TASK6_RUNTIME_FUNCTION_ID_SET = new Set( - TASK6_RUNTIME_FUNCTION_IDS, -); - -export const TASK6_DEFERRED_FUNCTION_IDS = new Set([]); - -const FUNCTION_DEFINITIONS: Record = { - story_continue_adventure: { - actionText: '继续推进冒险', - detailText: '让后端基于当前快照继续推进当前故事状态。', - scope: 'story', - }, - story_opening_camp_dialogue: { - actionText: '交换开场判断', - detailText: '把当前营地里的第一次正式对话切进服务端交互态。', - scope: 'story', - }, - camp_travel_home_scene: { - actionText: '返回营地', - detailText: '结束当前遭遇,把流程带回安全的营地状态。', - scope: 'story', - }, - idle_call_out: { - actionText: '主动出声试探', - detailText: '对前路喊话,逼迫附近的动静更快浮出水面。', - scope: 'story', - }, - idle_explore_forward: { - actionText: '继续向前探索', - detailText: '继续沿当前路径深入,把新遭遇交给后端推进。', - scope: 'story', - }, - idle_observe_signs: { - actionText: '观察周围迹象', - detailText: '先读环境,再决定下一轮要不要靠近或出手。', - scope: 'story', - }, - idle_rest_focus: { - actionText: '原地调息', - detailText: '恢复少量生命与灵力,稳住下一轮节奏。', - scope: 'story', - }, - idle_travel_next_scene: { - actionText: '前往相邻场景', - detailText: '收束当前遭遇并切往下一段场景流程。', - scope: 'story', - }, - battle_attack_basic: { - actionText: '普通攻击', - detailText: '本回合执行一次不耗蓝的基础攻击。', - scope: 'combat', - }, - battle_use_skill: { - actionText: '释放技能', - detailText: '直接执行一个具体技能,不再包装成抽象战术动作。', - scope: 'combat', - }, - battle_all_in_crush: { - actionText: '正面强压', - detailText: '高消耗高压制,适合趁敌人还没稳住时硬顶上去。', - scope: 'combat', - }, - battle_escape_breakout: { - actionText: '强行脱离战斗', - detailText: '打断当前战斗,把状态切回探索或脱身结果。', - scope: 'combat', - }, - battle_feint_step: { - actionText: '虚晃切步', - detailText: '用更轻的代价制造伤害,同时压低敌方反击力度。', - scope: 'combat', - }, - battle_finisher_window: { - actionText: '抓破绽终结', - detailText: '对残血目标有额外收益,适合收尾。', - scope: 'combat', - }, - battle_guard_break: { - actionText: '破架重击', - detailText: '偏稳定的伤害动作,能打断对方的站稳节奏。', - scope: 'combat', - }, - battle_probe_pressure: { - actionText: '稳步试探', - detailText: '低风险压迫,兼顾伤害和节奏控制。', - scope: 'combat', - }, - battle_recover_breath: { - actionText: '恢复', - detailText: '直接恢复资源,并推进本回合冷却。', - scope: 'combat', - }, - inventory_use: { - actionText: '使用物品', - detailText: '战斗中优先执行一个可立即结算的消耗品。', - scope: 'combat', - }, - npc_chat: { - actionText: '继续交谈', - detailText: '围绕当前话题延续对话,推进好感与关系判断。', - scope: 'npc', - }, - npc_fight: { - actionText: '与对方战斗', - detailText: '把当前 NPC 交互直接切进正式战斗结算。', - scope: 'npc', - }, - npc_help: { - actionText: '请求援手', - detailText: '向当前 NPC 请求一次性支援,恢复部分状态。', - scope: 'npc', - }, - npc_leave: { - actionText: '离开当前角色', - detailText: '结束当前 NPC 交互,重新回到探索态。', - scope: 'npc', - }, - npc_preview_talk: { - actionText: '转向眼前角色', - detailText: '从遭遇预览切进正式 NPC 互动菜单。', - scope: 'npc', - }, - npc_recruit: { - actionText: '邀请加入队伍', - detailText: '关系达标后可以直接把当前 NPC 收进同行队伍。', - scope: 'npc', - }, - npc_spar: { - actionText: '点到为止切磋', - detailText: '用 spar 模式进入轻量战斗,结果会回流到关系状态。', - scope: 'npc', - }, - npc_trade: { - actionText: '交易', - detailText: '查看库存并执行买入或卖出。', - scope: 'npc', - }, - npc_gift: { - actionText: '赠送礼物', - detailText: '把背包里的物品正式交给当前角色。', - scope: 'npc', - }, - npc_quest_accept: { - actionText: '接下委托', - detailText: '把当前角色的委托正式收进任务日志。', - scope: 'npc', - }, - npc_quest_turn_in: { - actionText: '交付委托', - detailText: '向当前角色结算已经完成的委托奖励。', - scope: 'npc', - }, - treasure_secure: { - actionText: '直接收取', - detailText: '不再拖延,直接把眼前最关键的收获带走。', - scope: 'story', - }, - treasure_inspect: { - actionText: '仔细检查', - detailText: '多花些时间拆开机关、痕迹和伪装。', - scope: 'story', - }, - treasure_leave: { - actionText: '先记下位置', - detailText: '暂时不碰它,只把异常位置和痕迹记住。', - scope: 'story', - }, -}; - -function cloneJson(value: T): T { - return JSON.parse(JSON.stringify(value)) as T; -} - -function isObject(value: unknown): value is JsonRecord { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function readString(value: unknown, fallback = '') { - return typeof value === 'string' && value.trim() ? value.trim() : fallback; -} - -function readNumber(value: unknown, fallback = 0) { - return typeof value === 'number' && Number.isFinite(value) ? value : fallback; -} - -function readBoolean(value: unknown, fallback = false) { - return typeof value === 'boolean' ? value : fallback; -} - -function readArray(value: unknown) { - return Array.isArray(value) ? value : []; -} - -function clampNonNegativeInteger(value: unknown) { - return Math.max(0, Math.round(readNumber(value, 0))); -} - -function normalizeStoryHistory(value: unknown) { - return readArray(value) - .map((entry) => { - const rawEntry = isObject(entry) ? entry : {}; - const historyRole = - rawEntry.historyRole === 'action' ? 'action' : 'result'; - - return { - text: readString(rawEntry.text), - historyRole, - } satisfies RuntimeStoryHistoryEntry; - }) - .filter((entry) => entry.text); -} - -function normalizeNpcState(value: unknown): RuntimeNpcState { - const rawState = isObject(value) ? value : {}; - - return { - affinity: Math.round(readNumber(rawState.affinity, 0)), - chattedCount: Math.max(0, Math.round(readNumber(rawState.chattedCount, 0))), - helpUsed: readBoolean(rawState.helpUsed), - giftsGiven: Math.max(0, Math.round(readNumber(rawState.giftsGiven, 0))), - inventory: cloneJson(readArray(rawState.inventory)), - recruited: readBoolean(rawState.recruited), - firstMeaningfulContactResolved: readBoolean( - rawState.firstMeaningfulContactResolved, - ), - tradeStockSignature: readString(rawState.tradeStockSignature) || null, - relationState: isObject(rawState.relationState) - ? cloneJson(rawState.relationState) - : null, - stanceProfile: isObject(rawState.stanceProfile) - ? cloneJson(rawState.stanceProfile) - : null, - revealedFacts: readArray(rawState.revealedFacts).filter( - (item): item is string => - typeof item === 'string' && item.trim().length > 0, - ), - knownAttributeRumors: readArray(rawState.knownAttributeRumors).filter( - (item): item is string => - typeof item === 'string' && item.trim().length > 0, - ), - seenBackstoryChapterIds: readArray(rawState.seenBackstoryChapterIds).filter( - (item): item is string => - typeof item === 'string' && item.trim().length > 0, - ), - }; -} - -function normalizeEncounter(value: unknown): RuntimeEncounter | null { - const rawEncounter = isObject(value) ? value : null; - if (!rawEncounter) { - return null; - } - - const kind = rawEncounter.kind === 'treasure' ? 'treasure' : 'npc'; - const npcName = readString(rawEncounter.npcName); - if (!npcName) { - return null; - } - - return { - id: readString(rawEncounter.id, npcName), - kind, - npcName, - npcDescription: readString(rawEncounter.npcDescription), - context: readString(rawEncounter.context), - hostile: - readBoolean(rawEncounter.hostile) || - Boolean(readString(rawEncounter.monsterPresetId)), - characterId: readString(rawEncounter.characterId) || null, - monsterPresetId: readString(rawEncounter.monsterPresetId) || null, - levelProfile: - normalizeRuntimeEntityLevelProfile(rawEncounter.levelProfile, 'rival') ?? - undefined, - experienceReward: clampNonNegativeInteger(rawEncounter.experienceReward), - }; -} - -function normalizeHostileNpc(value: unknown): RuntimeHostileNpc | null { - const rawNpc = isObject(value) ? value : null; - if (!rawNpc) { - return null; - } - - const id = readString(rawNpc.id); - const name = readString(rawNpc.name, id); - if (!id || !name) { - return null; - } - - const maxHp = Math.max(1, Math.round(readNumber(rawNpc.maxHp, 1))); - const hp = Math.max( - 0, - Math.min(maxHp, Math.round(readNumber(rawNpc.hp, maxHp))), - ); - - return { - id, - name, - hp, - maxHp, - description: readString(rawNpc.description), - levelProfile: - normalizeRuntimeEntityLevelProfile( - rawNpc.levelProfile, - 'hostile_standard', - ) ?? undefined, - experienceReward: clampNonNegativeInteger(rawNpc.experienceReward), - }; -} - -function normalizeCompanion(value: unknown): RuntimeCompanion | null { - const rawCompanion = isObject(value) ? value : null; - if (!rawCompanion) { - return null; - } - - const npcId = readString(rawCompanion.npcId); - if (!npcId) { - return null; - } - - return { - npcId, - characterId: readString(rawCompanion.characterId), - joinedAtAffinity: Math.round(readNumber(rawCompanion.joinedAtAffinity, 0)), - hp: Math.max( - 0, - Math.round( - readNumber( - rawCompanion.hp, - readNumber(rawCompanion.maxHp, 1), - ), - ), - ), - maxHp: Math.max(1, Math.round(readNumber(rawCompanion.maxHp, 1))), - mana: Math.max( - 0, - Math.round( - readNumber( - rawCompanion.mana, - readNumber(rawCompanion.maxMana, 1), - ), - ), - ), - maxMana: Math.max(1, Math.round(readNumber(rawCompanion.maxMana, 1))), - skillCooldowns: Object.fromEntries( - Object.entries( - isObject(rawCompanion.skillCooldowns) - ? rawCompanion.skillCooldowns - : {}, - ).map(([skillId, turns]) => [ - skillId, - Math.max(0, Math.round(readNumber(turns, 0))), - ]), - ), - animationState: readString(rawCompanion.animationState) || undefined, - actionMode: readString(rawCompanion.actionMode) || undefined, - offsetX: - typeof rawCompanion.offsetX === 'number' && - Number.isFinite(rawCompanion.offsetX) - ? rawCompanion.offsetX - : undefined, - offsetY: - typeof rawCompanion.offsetY === 'number' && - Number.isFinite(rawCompanion.offsetY) - ? rawCompanion.offsetY - : undefined, - transitionMs: - typeof rawCompanion.transitionMs === 'number' && - Number.isFinite(rawCompanion.transitionMs) - ? Math.max(0, Math.round(rawCompanion.transitionMs)) - : undefined, - }; -} - -function normalizeNpcStates(value: unknown) { - const rawStates = isObject(value) ? value : {}; - - return Object.fromEntries( - Object.entries(rawStates).map(([key, state]) => [ - key, - normalizeNpcState(state), - ]), - ) as Record; -} - -function normalizeCompanions(value: unknown) { - return readArray(value) - .map((entry) => normalizeCompanion(entry)) - .filter((entry): entry is RuntimeCompanion => Boolean(entry)); -} - -function normalizeRoster( - roster: RuntimeCompanion[], - companions: RuntimeCompanion[], -) { - const activeNpcIds = new Set(companions.map((companion) => companion.npcId)); - - return roster.filter((companion) => !activeNpcIds.has(companion.npcId)); -} - -function normalizeHostileNpcs(value: unknown) { - return readArray(value) - .map((entry) => normalizeHostileNpc(entry)) - .filter((entry): entry is RuntimeHostileNpc => Boolean(entry)); -} - -function normalizePlayerSkill(value: unknown): RuntimePlayerSkill | null { - const rawSkill = isObject(value) ? value : null; - if (!rawSkill) { - return null; - } - - const id = readString(rawSkill.id); - const name = readString(rawSkill.name, id); - if (!id || !name) { - return null; - } - - return { - id, - name, - damage: Math.max(1, Math.round(readNumber(rawSkill.damage, 1))), - manaCost: Math.max(0, Math.round(readNumber(rawSkill.manaCost, 0))), - cooldownTurns: Math.max( - 0, - Math.round(readNumber(rawSkill.cooldownTurns, 0)), - ), - buildBuffs: readArray(rawSkill.buildBuffs) - .map((entry) => { - const rawBuff = isObject(entry) ? entry : null; - if (!rawBuff) { - return null; - } - - const buffId = readString(rawBuff.id); - const sourceId = readString(rawBuff.sourceId); - const name = readString(rawBuff.name, buffId); - if (!buffId || !sourceId || !name) { - return null; - } - - const sourceType = readString(rawBuff.sourceType, 'skill'); - return { - id: buffId, - sourceType: - sourceType === 'item' || sourceType === 'forge' - ? sourceType - : 'skill', - sourceId, - name, - tags: readArray(rawBuff.tags).filter( - (tag): tag is string => - typeof tag === 'string' && tag.trim().length > 0, - ), - durationTurns: Math.max( - 1, - Math.round(readNumber(rawBuff.durationTurns, 1)), - ), - maxStacks: - typeof rawBuff.maxStacks === 'number' && - Number.isFinite(rawBuff.maxStacks) - ? Math.max(1, Math.round(rawBuff.maxStacks)) - : undefined, - } satisfies NonNullable[number]; - }) - .filter( - ( - entry, - ): entry is NonNullable[number] => - Boolean(entry), - ), - }; -} - -function normalizePlayerCharacter( - value: unknown, -): RuntimePlayerCharacter | null { - const rawCharacter = isObject(value) ? value : null; - const rawAttributes = isObject(rawCharacter?.attributes) - ? rawCharacter.attributes - : null; - if (!rawCharacter || !rawAttributes) { - return null; - } - - return { - attributes: { - strength: Math.max(0, Math.round(readNumber(rawAttributes.strength, 0))), - agility: Math.max(0, Math.round(readNumber(rawAttributes.agility, 0))), - intelligence: Math.max( - 0, - Math.round(readNumber(rawAttributes.intelligence, 0)), - ), - spirit: Math.max(0, Math.round(readNumber(rawAttributes.spirit, 0))), - }, - skills: readArray(rawCharacter.skills) - .map((entry) => normalizePlayerSkill(entry)) - .filter((entry): entry is RuntimePlayerSkill => Boolean(entry)), - }; -} - -function normalizeBattleInventoryItem( - value: unknown, -): RuntimeBattleInventoryItem | null { - const rawItem = isObject(value) ? value : null; - if (!rawItem) { - return null; - } - - const id = readString(rawItem.id); - const name = readString(rawItem.name, id); - if (!id || !name) { - return null; - } - - const rarity = readString(rawItem.rarity, 'common'); - const normalizedRarity = - rarity === 'legendary' || - rarity === 'epic' || - rarity === 'rare' || - rarity === 'uncommon' - ? rarity - : 'common'; - const useProfile = isObject(rawItem.useProfile) - ? (cloneJson(rawItem.useProfile) as RuntimeBattleItemUseProfile) - : undefined; - - return { - id, - name, - quantity: Math.max(0, Math.round(readNumber(rawItem.quantity, 0))), - rarity: normalizedRarity, - tags: readArray(rawItem.tags).filter( - (tag): tag is string => typeof tag === 'string' && tag.trim().length > 0, - ), - useProfile, - }; -} - -export function getPlayerCharacter(session: RuntimeSession) { - return normalizePlayerCharacter(session.rawGameState.playerCharacter); -} - -export function getPlayerSkillCooldowns(session: RuntimeSession) { - const rawCooldowns = isObject(session.rawGameState.playerSkillCooldowns) - ? session.rawGameState.playerSkillCooldowns - : {}; - - return Object.fromEntries( - Object.entries(rawCooldowns).map(([skillId, turns]) => [ - skillId, - Math.max(0, Math.round(readNumber(turns, 0))), - ]), - ) as Record; -} - -function getBattleInventoryItems(session: RuntimeSession) { - return readArray(session.rawGameState.playerInventory) - .map((entry) => normalizeBattleInventoryItem(entry)) - .filter((entry): entry is RuntimeBattleInventoryItem => Boolean(entry)); -} - -function buildBasicAttackBaseDamage(character: RuntimePlayerCharacter) { - return Math.max( - 8, - Math.round( - character.attributes.strength * 0.85 + - character.attributes.agility * 0.45, - ), - ); -} - -function buildBattleDisabledOption(params: { - session: RuntimeSession; - functionId: string; - actionText?: string; - detailText?: string; - reason: string; - payload?: RuntimeStoryChoicePayload; -}) { - return buildOptionView(params.session, params.functionId, { - actionText: params.actionText, - detailText: params.detailText, - payload: params.payload, - disabled: true, - reason: params.reason, - }); -} - -function buildOptionInteraction( - session: RuntimeSession, - functionId: string, -): RuntimeStoryOptionInteraction | undefined { - const encounter = session.currentEncounter; - - if (encounter?.kind === 'npc') { - const npcId = getEncounterKey(encounter); - const npcActionMap: Record = { - npc_chat: { kind: 'npc', npcId, action: 'chat' }, - npc_fight: { kind: 'npc', npcId, action: 'fight' }, - npc_help: { kind: 'npc', npcId, action: 'help' }, - npc_leave: { kind: 'npc', npcId, action: 'leave' }, - npc_preview_talk: { kind: 'npc', npcId, action: 'chat' }, - npc_recruit: { kind: 'npc', npcId, action: 'recruit' }, - npc_spar: { kind: 'npc', npcId, action: 'spar' }, - npc_trade: { kind: 'npc', npcId, action: 'trade' }, - npc_gift: { kind: 'npc', npcId, action: 'gift' }, - npc_chat_quest_offer_view: { - kind: 'npc', - npcId, - action: 'quest_offer_view', - }, - npc_chat_quest_offer_replace: { - kind: 'npc', - npcId, - action: 'quest_offer_replace', - }, - npc_chat_quest_offer_abandon: { - kind: 'npc', - npcId, - action: 'quest_offer_abandon', - }, - npc_quest_accept: { kind: 'npc', npcId, action: 'quest_accept' }, - npc_quest_turn_in: { kind: 'npc', npcId, action: 'quest_turn_in' }, - }; - - return npcActionMap[functionId]; - } - - if (encounter?.kind === 'treasure') { - const treasureActionMap: Record = { - treasure_secure: { kind: 'treasure', action: 'secure' }, - treasure_inspect: { kind: 'treasure', action: 'inspect' }, - treasure_leave: { kind: 'treasure', action: 'leave' }, - }; - - return treasureActionMap[functionId]; - } - - return undefined; -} - -function buildBattleItemSummary( - effect: NonNullable>, -) { - const parts = [ - effect.hpRestore > 0 ? `回血 ${effect.hpRestore}` : null, - effect.manaRestore > 0 ? `回蓝 ${effect.manaRestore}` : null, - effect.cooldownReduction > 0 ? `冷却 -${effect.cooldownReduction}` : null, - effect.buildBuffs.length > 0 - ? `增益 ${effect.buildBuffs.map((buff) => buff.name).join('、')}` - : null, - ].filter(Boolean); - - return parts.join(' / ') || '立即结算一次物品效果'; -} - -function pickPreferredBattleItem(session: RuntimeSession) { - const character = getPlayerCharacter(session); - if (!character) { - return null; - } - - const cooldowns = getPlayerSkillCooldowns(session); - const hasCoolingSkill = Object.values(cooldowns).some((turns) => turns > 0); - const playerHpRatio = session.playerHp / Math.max(session.playerMaxHp, 1); - const playerManaRatio = - session.playerMana / Math.max(session.playerMaxMana, 1); - - return ( - getBattleInventoryItems(session) - .filter((item) => item.quantity > 0 && isInventoryItemUsable(item)) - .map((item) => { - const effect = resolveInventoryItemUseEffect(item, character); - if (!effect) { - return null; - } - - const score = - effect.hpRestore * (playerHpRatio <= 0.45 ? 2.6 : 1.2) + - effect.manaRestore * (playerManaRatio <= 0.45 ? 2.2 : 0.9) + - effect.cooldownReduction * (hasCoolingSkill ? 18 : 6) + - effect.buildBuffs.length * 8; - - return { - item, - effect, - score, - }; - }) - .filter( - ( - candidate, - ): candidate is { - item: RuntimeBattleInventoryItem; - effect: NonNullable>; - score: number; - } => Boolean(candidate), - ) - .sort( - (left, right) => - right.score - left.score || - right.effect.hpRestore - left.effect.hpRestore || - right.effect.manaRestore - left.effect.manaRestore || - left.item.name.localeCompare(right.item.name, 'zh-CN'), - )[0] ?? null - ); -} - -function buildBattleSkillOptions(session: RuntimeSession) { - const character = getPlayerCharacter(session); - if (!character) { - return []; - } - - const cooldowns = getPlayerSkillCooldowns(session); - - return character.skills.map((skill) => { - const remainingCooldown = cooldowns[skill.id] ?? 0; - const damage = resolvePlayerOutgoingDamageResult( - session.rawGameState as Parameters< - typeof resolvePlayerOutgoingDamageResult - >[0], - character, - skill.damage, - 1, - `runtime-skill-preview:${skill.id}`, - ).damage; - const detailText = [ - `耗蓝 ${skill.manaCost}`, - `伤害 ${damage}`, - `冷却 ${skill.cooldownTurns}`, - ].join(' / '); - - if (remainingCooldown > 0) { - return buildBattleDisabledOption({ - session, - functionId: 'battle_use_skill', - actionText: skill.name, - detailText, - payload: { skillId: skill.id }, - reason: `冷却中,还需 ${remainingCooldown} 回合`, - }); - } - - if (skill.manaCost > session.playerMana) { - return buildBattleDisabledOption({ - session, - functionId: 'battle_use_skill', - actionText: skill.name, - detailText, - payload: { skillId: skill.id }, - reason: '灵力不足', - }); - } - - return buildOptionView(session, 'battle_use_skill', { - actionText: skill.name, - detailText, - payload: { skillId: skill.id }, - }); - }); -} - -function buildBattleActionOptions(session: RuntimeSession) { - const character = getPlayerCharacter(session); - const itemCandidate = pickPreferredBattleItem(session); - const basicAttackDamage = character - ? resolvePlayerOutgoingDamageResult( - session.rawGameState as Parameters< - typeof resolvePlayerOutgoingDamageResult - >[0], - character, - buildBasicAttackBaseDamage(character), - 1, - 'runtime-basic-attack-preview', - ).damage - : 0; - - return [ - buildOptionView(session, 'battle_attack_basic', { - detailText: - basicAttackDamage > 0 - ? `不耗蓝 / 伤害 ${basicAttackDamage}` - : '不耗蓝的基础攻击', - }), - buildOptionView(session, 'battle_recover_breath', { - actionText: '恢复', - detailText: '回血 12 / 回蓝 9 / 冷却 -1', - }), - itemCandidate - ? buildOptionView(session, 'inventory_use', { - actionText: `使用物品:${itemCandidate.item.name}`, - detailText: buildBattleItemSummary(itemCandidate.effect), - payload: { itemId: itemCandidate.item.id }, - }) - : buildBattleDisabledOption({ - session, - functionId: 'inventory_use', - actionText: '使用物品', - detailText: '当前没有可直接结算的战斗消耗品', - reason: '暂无可用物品', - }), - ...buildBattleSkillOptions(session), - buildOptionView(session, 'battle_escape_breakout'), - ] satisfies RuntimeStoryOptionView[]; -} - -export function getEncounterKey(encounter: RuntimeEncounter) { - return encounter.id || encounter.npcName; -} - -export function loadRuntimeSession( - snapshot: RpgRuntimeSavedSnapshot, - requestedSessionId: string, -): RuntimeSession { - const rawGameState = isObject(snapshot.gameState) - ? cloneJson(snapshot.gameState) - : {}; - const currentEncounter = normalizeEncounter(rawGameState.currentEncounter); - const sceneHostileNpcs = normalizeHostileNpcs(rawGameState.sceneHostileNpcs); - const inBattle = - readBoolean(rawGameState.inBattle) && - sceneHostileNpcs.some((npc) => npc.hp > 0); - - return { - sessionId: readString(rawGameState.runtimeSessionId, requestedSessionId), - runtimeVersion: Math.max( - 0, - Math.round(readNumber(rawGameState.runtimeActionVersion, 0)), - ), - snapshotBottomTab: readString(snapshot.bottomTab, 'adventure'), - rawGameState, - worldType: readString(rawGameState.worldType) || null, - storyHistory: normalizeStoryHistory(rawGameState.storyHistory), - currentEncounter, - npcInteractionActive: readBoolean(rawGameState.npcInteractionActive), - sceneHostileNpcs, - inBattle, - playerHp: Math.max(0, Math.round(readNumber(rawGameState.playerHp, 0))), - playerMaxHp: Math.max( - 1, - Math.round(readNumber(rawGameState.playerMaxHp, 1)), - ), - playerMana: Math.max(0, Math.round(readNumber(rawGameState.playerMana, 0))), - playerMaxMana: Math.max( - 1, - Math.round(readNumber(rawGameState.playerMaxMana, 1)), - ), - npcStates: normalizeNpcStates(rawGameState.npcStates), - companions: normalizeCompanions(rawGameState.companions), - roster: normalizeRoster( - normalizeCompanions(rawGameState.roster), - normalizeCompanions(rawGameState.companions), - ), - currentNpcBattleMode: - rawGameState.currentNpcBattleMode === 'fight' || - rawGameState.currentNpcBattleMode === 'spar' - ? rawGameState.currentNpcBattleMode - : null, - currentNpcBattleOutcome: - rawGameState.currentNpcBattleOutcome === 'fight_victory' || - rawGameState.currentNpcBattleOutcome === 'spar_complete' - ? rawGameState.currentNpcBattleOutcome - : null, - }; -} - -export function isStoryFunctionId(functionId: string) { - return STORY_FUNCTION_IDS.has(functionId); -} - -export function isCombatFunctionId(functionId: string) { - return COMBAT_FUNCTION_IDS.has(functionId); -} - -export function isNpcFunctionId(functionId: string) { - return NPC_FUNCTION_IDS.has(functionId); -} - -export function isTask5FunctionId(functionId: string) { - return ( - isStoryFunctionId(functionId) || - isCombatFunctionId(functionId) || - isNpcFunctionId(functionId) - ); -} - -export function isTask6RuntimeFunctionId(functionId: string) { - return TASK6_RUNTIME_FUNCTION_ID_SET.has(functionId); -} - -export function getEncounterNpcState(session: RuntimeSession) { - if (!session.currentEncounter || session.currentEncounter.kind !== 'npc') { - return null; - } - - const key = getEncounterKey(session.currentEncounter); - return ( - session.npcStates[key] ?? { - affinity: 0, - chattedCount: 0, - helpUsed: false, - giftsGiven: 0, - inventory: [], - recruited: false, - firstMeaningfulContactResolved: false, - relationState: null, - stanceProfile: null, - } - ); -} - -export function setEncounterNpcState( - session: RuntimeSession, - npcState: RuntimeNpcState, -) { - if (!session.currentEncounter || session.currentEncounter.kind !== 'npc') { - return; - } - - session.npcStates[getEncounterKey(session.currentEncounter)] = npcState; -} - -function buildOptionView( - session: RuntimeSession, - functionId: string, - overrides: Partial = {}, -): RuntimeStoryOptionView { - const definition = FUNCTION_DEFINITIONS[functionId]; - if (!definition) { - return { - functionId, - actionText: functionId, - detailText: '', - scope: 'story', - interaction: buildOptionInteraction(session, functionId), - ...overrides, - }; - } - - return { - functionId, - actionText: definition.actionText, - detailText: definition.detailText, - scope: definition.scope, - interaction: buildOptionInteraction(session, functionId), - ...overrides, - }; -} - -type RuntimeQuestPreview = { - id: string; - issuerNpcId: string; - status: string; -}; - -function readQuestPreviews(session: RuntimeSession): RuntimeQuestPreview[] { - return readArray(session.rawGameState.quests) - .map((quest) => { - const rawQuest = isObject(quest) ? quest : {}; - const id = readString(rawQuest.id); - const issuerNpcId = readString(rawQuest.issuerNpcId); - const status = readString(rawQuest.status); - - if (!id || !issuerNpcId || !status) { - return null; - } - - return { - id, - issuerNpcId, - status, - } satisfies RuntimeQuestPreview; - }) - .filter((quest): quest is RuntimeQuestPreview => Boolean(quest)); -} - -function getActiveEncounterQuest(session: RuntimeSession) { - if (!session.currentEncounter || session.currentEncounter.kind !== 'npc') { - return null; - } - - return ( - readQuestPreviews(session).find( - (quest) => - quest.issuerNpcId === session.currentEncounter?.id && - quest.status !== 'turned_in', - ) ?? null - ); -} - -function hasGiftablePlayerInventory(session: RuntimeSession) { - return readArray(session.rawGameState.playerInventory).some((item) => { - const rawItem = isObject(item) ? item : {}; - return readNumber(rawItem.quantity, 0) > 0; - }); -} - -export function buildAvailableOptions(session: RuntimeSession) { - if (session.inBattle) { - return buildBattleActionOptions(session); - } - - if (session.currentEncounter?.kind === 'npc') { - const npcState = getEncounterNpcState(session); - if (session.currentEncounter.hostile) { - return [ - buildOptionView(session, 'npc_fight'), - buildOptionView(session, 'npc_leave'), - ]; - } - - if (!session.npcInteractionActive) { - return [ - buildOptionView(session, 'npc_preview_talk'), - buildOptionView(session, 'npc_fight'), - buildOptionView(session, 'npc_leave'), - ]; - } - - const activeQuest = getActiveEncounterQuest(session); - const options = [ - buildOptionView(session, 'npc_chat'), - buildOptionView( - session, - 'npc_help', - npcState?.helpUsed - ? { - disabled: true, - reason: '当前 NPC 的一次性援手已经用完了。', - } - : {}, - ), - buildOptionView(session, 'npc_spar'), - buildOptionView(session, 'npc_fight'), - ]; - - if ((npcState?.inventory?.length ?? 0) > 0) { - options.push(buildOptionView(session, 'npc_trade')); - } - - if (hasGiftablePlayerInventory(session)) { - options.push(buildOptionView(session, 'npc_gift')); - } - - if ( - activeQuest && - (activeQuest.status === 'completed' || - activeQuest.status === 'ready_to_turn_in') - ) { - options.push(buildOptionView(session, 'npc_quest_turn_in')); - } else if (!activeQuest) { - options.push(buildOptionView(session, 'npc_quest_accept')); - } - - if (npcState && !npcState.recruited && npcState.affinity >= 60) { - options.push( - buildOptionView(session, 'npc_recruit'), - ); - } - - options.push(buildOptionView(session, 'npc_leave')); - return options; - } - - if (session.currentEncounter?.kind === 'treasure') { - return [ - buildOptionView(session, 'treasure_secure'), - buildOptionView(session, 'treasure_inspect'), - buildOptionView(session, 'treasure_leave'), - ]; - } - - return [ - 'idle_observe_signs', - 'idle_call_out', - 'idle_rest_focus', - 'idle_explore_forward', - 'idle_travel_next_scene', - 'story_continue_adventure', - ].map((functionId) => buildOptionView(session, functionId)); -} - -function buildEncounterViewModel( - session: RuntimeSession, -): RuntimeStoryEncounterViewModel | null { - if (!session.currentEncounter) { - return null; - } - - const npcState = getEncounterNpcState(session); - return { - id: session.currentEncounter.id, - kind: session.currentEncounter.kind, - npcName: session.currentEncounter.npcName, - hostile: session.currentEncounter.hostile, - affinity: npcState?.affinity, - recruited: npcState?.recruited, - interactionActive: session.npcInteractionActive, - battleMode: session.currentNpcBattleMode, - }; -} - -export function buildRuntimeViewModel( - session: RuntimeSession, - options = buildAvailableOptions(session), -): RuntimeStoryViewModel { - return { - player: { - hp: session.playerHp, - maxHp: session.playerMaxHp, - mana: session.playerMana, - maxMana: session.playerMaxMana, - }, - encounter: buildEncounterViewModel(session), - companions: session.companions.map((companion) => ({ - npcId: companion.npcId, - characterId: companion.characterId || undefined, - joinedAtAffinity: companion.joinedAtAffinity, - })), - availableOptions: options, - status: { - inBattle: session.inBattle, - npcInteractionActive: session.npcInteractionActive, - currentNpcBattleMode: session.currentNpcBattleMode, - currentNpcBattleOutcome: session.currentNpcBattleOutcome, - }, - }; -} - -export function appendStoryHistory( - session: RuntimeSession, - actionText: string, - resultText: string, -) { - session.storyHistory.push( - { - text: actionText, - historyRole: 'action', - }, - { - text: resultText, - historyRole: 'result', - }, - ); -} - -export function buildLegacyCurrentStory( - storyText: string, - options: RuntimeStoryOptionView[], -) { - return { - text: storyText, - options: options.map((option) => ({ - functionId: option.functionId, - actionText: option.actionText, - text: option.actionText, - detailText: option.detailText, - priority: option.scope === 'npc' ? 3 : option.scope === 'combat' ? 2 : 1, - interaction: option.interaction, - runtimePayload: option.payload, - disabled: option.disabled, - disabledReason: option.reason, - visuals: { - playerAnimation: 'idle', - playerMoveMeters: 0, - playerOffsetY: 0, - playerFacing: 'right', - scrollWorld: false, - monsterChanges: [], - }, - })), - }; -} - -export function syncRawGameState(session: RuntimeSession) { - session.rawGameState.runtimeSessionId = session.sessionId; - session.rawGameState.runtimeActionVersion = session.runtimeVersion; - session.rawGameState.storyHistory = cloneJson(session.storyHistory); - session.rawGameState.currentEncounter = session.currentEncounter - ? cloneJson(session.currentEncounter) - : null; - session.rawGameState.npcInteractionActive = session.npcInteractionActive; - session.rawGameState.sceneHostileNpcs = cloneJson(session.sceneHostileNpcs); - session.rawGameState.inBattle = session.inBattle; - session.rawGameState.playerHp = session.playerHp; - session.rawGameState.playerMaxHp = session.playerMaxHp; - session.rawGameState.playerMana = session.playerMana; - session.rawGameState.playerMaxMana = session.playerMaxMana; - session.rawGameState.npcStates = cloneJson(session.npcStates); - session.rawGameState.companions = cloneJson(session.companions); - session.rawGameState.roster = cloneJson(session.roster); - session.rawGameState.currentNpcBattleMode = session.currentNpcBattleMode; - session.rawGameState.currentNpcBattleOutcome = - session.currentNpcBattleOutcome; - session.rawGameState.currentBattleNpcId = - session.currentEncounter?.id ?? null; - session.rawGameState.activeCombatEffects = []; - session.rawGameState.playerActionMode = 'idle'; - session.rawGameState.scrollWorld = false; - session.rawGameState.animationState = 'idle'; -} - -export function replaceRuntimeSessionRawGameState( - session: RuntimeSession, - nextGameState: JsonRecord, -) { - session.rawGameState = cloneJson(nextGameState); - const refreshed = loadRuntimeSession( - { - version: 2, - savedAt: '', - bottomTab: session.snapshotBottomTab, - gameState: session.rawGameState, - currentStory: null, - }, - session.sessionId, - ); - - session.worldType = refreshed.worldType; - session.storyHistory = refreshed.storyHistory; - session.currentEncounter = refreshed.currentEncounter; - session.npcInteractionActive = refreshed.npcInteractionActive; - session.sceneHostileNpcs = refreshed.sceneHostileNpcs; - session.inBattle = refreshed.inBattle; - session.playerHp = refreshed.playerHp; - session.playerMaxHp = refreshed.playerMaxHp; - session.playerMana = refreshed.playerMana; - session.playerMaxMana = refreshed.playerMaxMana; - session.npcStates = refreshed.npcStates; - session.companions = refreshed.companions; - session.roster = refreshed.roster; - session.currentNpcBattleMode = refreshed.currentNpcBattleMode; - session.currentNpcBattleOutcome = refreshed.currentNpcBattleOutcome; -} diff --git a/server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionLoader.ts b/server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionLoader.ts deleted file mode 100644 index 581774b6..00000000 --- a/server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionLoader.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { - loadRuntimeSession, - type RuntimeSession, -} from './RpgRuntimeSessionDomain.js'; - -export type { RuntimeSession }; - -/** - * RPG runtime session loader 的主入口。 - * 工作包 G 把旧 `runtimeSession.ts` 的真实实现迁入新域后,这里负责承接稳定命名。 - */ -export { loadRuntimeSession }; -export const loadRpgRuntimeSession = loadRuntimeSession; diff --git a/server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionPrimitives.ts b/server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionPrimitives.ts deleted file mode 100644 index 2fccbcc9..00000000 --- a/server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionPrimitives.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * RPG runtime session 原子能力导出。 - * 这里集中输出运行时动作链直接依赖的 session 原语,避免再次回到旧热点文件取用。 - */ -export { - appendStoryHistory, - getEncounterKey, - getEncounterNpcState, - getPlayerCharacter, - getPlayerSkillCooldowns, - isCombatFunctionId, - isNpcFunctionId, - isStoryFunctionId, - isTask5FunctionId, - isTask6RuntimeFunctionId, - MAX_TASK5_COMPANIONS, - setEncounterNpcState, - syncRawGameState, - TASK6_DEFERRED_FUNCTION_IDS, -} from './RpgRuntimeSessionDomain.js'; - -export type { - RuntimeCompanion, - RuntimeEncounter, - RuntimeHostileNpc, - RuntimeNpcState, - RuntimeSession, - RuntimeStoryHistoryEntry, -} from './RpgRuntimeSessionDomain.js'; diff --git a/server-node/src/modules/rpg-runtime-story/RpgRuntimeSnapshotSync.ts b/server-node/src/modules/rpg-runtime-story/RpgRuntimeSnapshotSync.ts deleted file mode 100644 index 282dae39..00000000 --- a/server-node/src/modules/rpg-runtime-story/RpgRuntimeSnapshotSync.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { - replaceRuntimeSessionRawGameState, - syncRawGameState, -} from './RpgRuntimeSessionDomain.js'; - -/** - * RPG runtime snapshot 同步入口。 - * 工作包 G 后 rawGameState 的回写与替换都统一从新域目录输出。 - */ -export { replaceRuntimeSessionRawGameState, syncRawGameState }; -export const syncRpgRuntimeSnapshot = syncRawGameState; -export const replaceRpgRuntimeSessionRawGameState = - replaceRuntimeSessionRawGameState; diff --git a/server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryActionDomain.ts b/server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryActionDomain.ts deleted file mode 100644 index b83dbb89..00000000 --- a/server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryActionDomain.ts +++ /dev/null @@ -1,1176 +0,0 @@ -/** - * RPG runtime story 主链迁移后的真实动作/状态实现。 - * 工作包 G 完成后,运行时动作解析直接落在 RPG runtime story 新域。 - */ -import type { - RuntimeBattlePresentation, - RuntimeStoryActionRequest, - RuntimeStoryActionResponse, - RuntimeStoryOptionView, - RuntimeStoryPatch, - RuntimeStoryStateRequest, -} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js'; -import { conflict, invalidRequest } from '../../errors.js'; -import type { RpgRuntimeSnapshotRepositoryPort } from '../../repositories/rpg-runtime/RpgRuntimeSnapshotRepository.js'; -import type { UpstreamLlmClient } from '../../services/llmClient.js'; -import { - buildStrictNpcChatDialoguePrompt, - NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT, -} from '../ai/chatPromptBuilders.js'; -import { generateNextStoryFromOrchestrator } from '../ai/storyOrchestrator.js'; -import { resolveCombatAction } from '../combat/combatResolutionService.js'; -import { - isSupportedInventoryStoryFunctionId, - resolveInventoryStoryAction, -} from '../inventory/inventoryStoryActionService.js'; -import { - ensureNpcInventorySessionState, - isSupportedNpcInventoryStoryFunctionId, - resolveNpcInventoryStoryAction, -} from '../inventory/npcInventoryStoryActionService.js'; -import { resolveNpcInteraction } from '../npc/npcInteractionService.js'; -import { applyQuestSignalsForResolvedAction } from '../quest/questRuntimeSignalService.js'; -import { - isSupportedQuestStoryFunctionId, - resolveQuestStoryAction, -} from '../quest/questStoryActionService.js'; -import { - hydrateSavedSnapshot, - normalizeSavedSnapshotPayload, -} from '../runtime/runtimeSnapshotHydration.js'; -import { - isSupportedTreasureStoryFunctionId, - resolveTreasureStoryAction, -} from '../runtime-item/treasureStoryActionService.js'; -import { - buildAvailableOptions, - buildRuntimeViewModel, -} from './RpgRuntimeOptionCompiler.js'; -import { - appendStoryHistory, - getEncounterNpcState, - isCombatFunctionId, - isNpcFunctionId, - isStoryFunctionId, - isTask5FunctionId, - setEncounterNpcState, - syncRawGameState, - TASK6_DEFERRED_FUNCTION_IDS, -} from './RpgRuntimeSessionPrimitives.js'; -import { - buildLegacyCurrentStory, -} from './RpgRuntimeStoryPresentationCompiler.js'; -import { - loadRuntimeSession, - type RuntimeSession, -} from './RpgRuntimeSessionLoader.js'; - -type StoryResolution = { - actionText: string; - resultText: string; - patches: RuntimeStoryPatch[]; - storyText?: string; - presentationOptions?: RuntimeStoryOptionView[]; - savedCurrentStory?: JsonRecord; - battle?: RuntimeBattlePresentation | null; - toast?: string | null; -}; - -type JsonRecord = Record; - -type StoryOptionLike = { - functionId: string; - actionText: string; -}; - -type GeneratedStoryPayload = { - storyText: string; - historyResultText: string; - presentationOptions: RuntimeStoryOptionView[]; - savedCurrentStory: JsonRecord; -}; - -const CONTINUE_ADVENTURE_OPTION = { - functionId: 'story_continue_adventure', - actionText: '继续冒险', - detailText: '展开刚刚已经准备好的后续选项。', - scope: 'story', -} satisfies RuntimeStoryOptionView; - -const DEFAULT_STORY_OPTION_VISUALS = { - playerAnimation: 'idle', - playerMoveMeters: 0, - playerOffsetY: 0, - playerFacing: 'right', - scrollWorld: false, - monsterChanges: [], -} as const; - -function resolveActionText( - defaultText: string, - request: RuntimeStoryActionRequest, -) { - const payload = request.action.payload; - const optionText = - payload && typeof payload.optionText === 'string' - ? payload.optionText.trim() - : ''; - - return optionText || defaultText; -} - -function isObject(value: unknown): value is JsonRecord { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function readString(value: unknown) { - return typeof value === 'string' && value.trim() ? value.trim() : ''; -} - -function readArray(value: unknown) { - return Array.isArray(value) ? value : []; -} - -function buildStoryOptionFromRuntimeOption(option: RuntimeStoryOptionView) { - return { - functionId: option.functionId, - actionText: option.actionText, - text: option.actionText, - detailText: option.detailText, - visuals: DEFAULT_STORY_OPTION_VISUALS, - interaction: option.interaction, - runtimePayload: option.payload, - disabled: option.disabled, - disabledReason: option.reason, - } satisfies JsonRecord; -} - -function buildStoryOptionsFromRuntimeOptions( - _session: RuntimeSession, - options: RuntimeStoryOptionView[], -) { - return options.map((option) => buildStoryOptionFromRuntimeOption(option)); -} - -function escapeRegExp(value: string) { - return value.replace(/[\\^$*+?.()|[\]{}]/g, '\\$&'); -} - -function normalizeDialogueSpeakerName(rawSpeakerName: string) { - return rawSpeakerName - .trim() - .replace( - /^[[\]{}()<>\u300a\u300b\u300c\u300d\u300e\u300f\u3010\u3011\uFF08\uFF09]+/u, - '', - ) - .replace( - /[[\]{}()<>\u300a\u300b\u300c\u300d\u300e\u300f\u3010\u3011\uFF08\uFF09]+$/u, - '', - ) - .replace(/^(?:\u540c\u4f34|\u961f\u53cb)\s*/u, '') - .trim(); -} - -function parseDialogueTurns(text: string, npcName: string) { - const turns: JsonRecord[] = []; - const dialogueColonPattern = '(?:\\uFF1A|:)'; - const playerPrefixPattern = new RegExp( - '^(?:\\\\u4f60|\\\\u73a9\\\\u5bb6|\\\\u4e3b\\\\u89d2)\\\\s*' + - dialogueColonPattern + - '\\\\s*(.+)$', - 'u', - ); - const npcPrefixPattern = new RegExp( - '^' + - escapeRegExp(npcName) + - '\\\\s*' + - dialogueColonPattern + - '\\\\s*(.+)$', - 'u', - ); - const namedSpeakerPattern = new RegExp( - '^(.{1,24}?)\\\\s*' + dialogueColonPattern + '\\\\s*(.+)$', - 'u', - ); - const lines = text - .replace(/\r/g, '') - .split('\n') - .map((line) => line.trim()) - .filter(Boolean); - - for (const line of lines) { - const playerMatch = line.match(playerPrefixPattern); - const playerText = playerMatch?.[1]?.trim(); - if (playerText) { - turns.push({ speaker: 'player', text: playerText }); - continue; - } - - const npcMatch = line.match(npcPrefixPattern); - const npcText = npcMatch?.[1]?.trim(); - if (npcText) { - turns.push({ speaker: 'npc', speakerName: npcName, text: npcText }); - continue; - } - - const namedSpeakerMatch = line.match(namedSpeakerPattern); - if (namedSpeakerMatch?.[1] && namedSpeakerMatch[2]) { - const speakerName = normalizeDialogueSpeakerName(namedSpeakerMatch[1]); - const speakerText = namedSpeakerMatch[2].trim(); - - if (speakerName && speakerText) { - turns.push({ - speaker: speakerName === npcName ? 'npc' : 'companion', - speakerName, - text: speakerText, - }); - continue; - } - } - - if (line.startsWith('你:') || line.startsWith('你:')) { - turns.push({ speaker: 'player', text: line.slice(2).trim() }); - continue; - } - - if (line.startsWith(npcName + ':') || line.startsWith(npcName + ':')) { - turns.push({ - speaker: 'npc', - speakerName: npcName, - text: line.slice(npcName.length + 1).trim(), - }); - continue; - } - - const lastTurn = turns[turns.length - 1]; - if (lastTurn && typeof lastTurn.text === 'string') { - lastTurn.text += line; - } - } - - return turns.filter( - (turn) => typeof turn.text === 'string' && turn.text.length > 0, - ); -} - -function buildDialogueCurrentStory(params: { - session: RuntimeSession; - npcName: string; - text: string; - deferredOptions: RuntimeStoryOptionView[]; -}) { - return { - text: params.text, - options: buildStoryOptionsFromRuntimeOptions(params.session, [ - CONTINUE_ADVENTURE_OPTION, - ]), - displayMode: 'dialogue', - dialogue: parseDialogueTurns(params.text, params.npcName), - streaming: false, - deferredOptions: buildStoryOptionsFromRuntimeOptions( - params.session, - params.deferredOptions, - ), - } satisfies JsonRecord; -} - -function readDialogueTurns(currentStory: unknown) { - if (!isObject(currentStory) || !Array.isArray(currentStory.dialogue)) { - return []; - } - - return currentStory.dialogue.filter(isObject).map((turn) => ({ ...turn })); -} - -function readPendingQuestOfferContext(params: { - currentStory: unknown; - encounterId: string; -}) { - if (!isObject(params.currentStory)) { - return null; - } - - const npcChatState = isObject(params.currentStory.npcChatState) - ? params.currentStory.npcChatState - : null; - const pendingQuestOffer = isObject(npcChatState?.pendingQuestOffer) - ? npcChatState.pendingQuestOffer - : null; - const quest = isObject(pendingQuestOffer?.quest) - ? pendingQuestOffer.quest - : null; - - if (!quest) { - return null; - } - - const npcId = readString(npcChatState?.npcId); - if (npcId && npcId !== params.encounterId) { - return null; - } - - return { - dialogue: readDialogueTurns(params.currentStory), - turnCount: - typeof npcChatState?.turnCount === 'number' && - Number.isFinite(npcChatState.turnCount) - ? Math.max(0, Math.round(npcChatState.turnCount)) - : 0, - customInputPlaceholder: - readString(npcChatState?.customInputPlaceholder) || - '输入你想对 TA 说的话', - quest, - }; -} - -function buildNpcChatSuggestionOption( - encounter: RuntimeSession['currentEncounter'] & { kind: 'npc' }, - actionText: string, -) { - return buildStoryOptionFromRuntimeOption({ - functionId: 'npc_chat', - actionText, - detailText: '', - scope: 'npc', - interaction: { - kind: 'npc', - npcId: encounter.id, - action: 'chat', - }, - }); -} - -function buildQuestAcceptedNpcReplyText(quest: JsonRecord) { - const activeStepId = readString(quest.activeStepId); - const activeStep = readArray(quest.steps) - .filter(isObject) - .find((step) => readString(step.id) === activeStepId); - const revealText = readString(activeStep?.revealText); - const summary = readString(quest.summary); - - if (revealText) { - return `那就拜托你了。${revealText}`; - } - - return `那就拜托你了。${summary || '这份委托的关键要点我已经交给你。'}`; -} - -function buildQuestAcceptedSuggestionOptions( - encounter: RuntimeSession['currentEncounter'] & { kind: 'npc' }, -) { - return [ - '这件事里你最担心哪一步', - '我回来时你最想先知道什么', - '除了这份委托,你还想提醒我什么', - ].map((actionText) => buildNpcChatSuggestionOption(encounter, actionText)); -} - -function buildPendingQuestAcceptedCurrentStory(params: { - session: RuntimeSession; - currentStory: unknown; -}) { - const encounter = params.session.currentEncounter; - if (!encounter || encounter.kind !== 'npc') { - return null; - } - - const pendingOffer = readPendingQuestOfferContext({ - currentStory: params.currentStory, - encounterId: encounter.id, - }); - if (!pendingOffer) { - return null; - } - - const dialogue = [ - ...pendingOffer.dialogue, - { - speaker: 'player', - text: '这件事我愿意接下,你把关键要点交给我。', - }, - { - speaker: 'npc', - speakerName: encounter.npcName, - text: buildQuestAcceptedNpcReplyText(pendingOffer.quest), - }, - ]; - - return { - text: dialogue - .map((turn) => readString(turn.text)) - .filter(Boolean) - .join('\n'), - options: buildQuestAcceptedSuggestionOptions(encounter), - displayMode: 'dialogue', - dialogue, - streaming: false, - npcChatState: { - npcId: encounter.id, - npcName: encounter.npcName, - turnCount: pendingOffer.turnCount, - customInputPlaceholder: pendingOffer.customInputPlaceholder, - pendingQuestOffer: null, - }, - } satisfies JsonRecord; -} - -function buildStoryPromptContext( - session: RuntimeSession, - extras: JsonRecord = {}, -) { - const scenePreset = isObject(session.rawGameState.currentScenePreset) - ? session.rawGameState.currentScenePreset - : null; - - return { - sceneName: - readString(scenePreset?.name) || - readString(session.rawGameState.currentScene) || - '当前区域', - sceneDescription: - readString(scenePreset?.description) || - readString(session.rawGameState.sceneDescription) || - '周围气氛仍在继续变化。', - encounterName: session.currentEncounter?.npcName || null, - encounterId: session.currentEncounter?.id || null, - playerHp: session.playerHp, - playerMaxHp: session.playerMaxHp, - playerMana: session.playerMana, - playerMaxMana: session.playerMaxMana, - inBattle: session.inBattle, - pendingSceneEncounter: false, - ...extras, - } satisfies JsonRecord; -} - -function buildHistoryMoments( - session: RuntimeSession, - appendedEntries: Array<{ text: string; historyRole: 'action' | 'result' }>, -) { - return [ - ...session.storyHistory.map((entry) => ({ - text: entry.text, - historyRole: entry.historyRole, - })), - ...appendedEntries, - ]; -} - -function buildPromptOptions(options: RuntimeStoryOptionView[]) { - return options - .filter((option) => !option.disabled) - .map((option) => ({ - functionId: option.functionId, - actionText: option.actionText, - text: option.actionText, - })); -} - -function mergeGeneratedRuntimeOptions( - baseOptions: RuntimeStoryOptionView[], - generatedOptions: StoryOptionLike[], -) { - if (generatedOptions.length === 0) { - return baseOptions; - } - - const buckets = new Map(); - baseOptions.forEach((option) => { - const bucket = buckets.get(option.functionId) ?? []; - bucket.push(option); - buckets.set(option.functionId, bucket); - }); - - const resolved: RuntimeStoryOptionView[] = []; - const consumed = new Set(); - generatedOptions.forEach((option) => { - const bucket = buckets.get(option.functionId); - const matched = bucket?.shift(); - if (!matched) { - return; - } - - consumed.add(matched); - resolved.push({ - ...matched, - actionText: readString(option.actionText) || matched.actionText, - }); - }); - - if (resolved.length === 0) { - return baseOptions; - } - - const remaining = baseOptions.filter((option) => !consumed.has(option)); - return [...resolved, ...remaining]; -} - -function buildOpeningCampChatContext(session: RuntimeSession) { - const encounter = session.currentEncounter; - if (!encounter || encounter.kind !== 'npc') { - return {}; - } - - const rawEncounter = isObject(session.rawGameState.currentEncounter) - ? session.rawGameState.currentEncounter - : null; - if (readString(rawEncounter?.specialBehavior) !== 'camp_companion') { - return {}; - } - - const npcState = getEncounterNpcState(session); - if (!npcState || npcState.chattedCount > 2) { - return {}; - } - - const openingActionText = `在营地与 ${encounter.npcName} 交换开场判断`; - let openingDialogue = ''; - - for (let index = 0; index < session.storyHistory.length - 1; index += 1) { - const entry = session.storyHistory[index]; - if ( - !entry || - entry.historyRole !== 'action' || - entry.text !== openingActionText - ) { - continue; - } - - for ( - let nextIndex = index + 1; - nextIndex < session.storyHistory.length; - nextIndex += 1 - ) { - const nextEntry = session.storyHistory[nextIndex]; - if (!nextEntry) { - continue; - } - if (nextEntry.historyRole === 'action') { - break; - } - if (nextEntry.text.trim()) { - openingDialogue = nextEntry.text.trim(); - break; - } - } - - if (openingDialogue) { - break; - } - } - - if (!openingDialogue) { - return {}; - } - - const playerCharacter = isObject(session.rawGameState.playerCharacter) - ? session.rawGameState.playerCharacter - : null; - const playerName = readString(playerCharacter?.name) || '你'; - - return { - openingCampBackground: `${playerName} 在营地里和 ${encounter.npcName} 终于真正把注意力放回彼此身上,周围暂时没有新的打扰。`, - openingCampDialogue: openingDialogue, - }; -} - -function normalizeStatusPatch(session: RuntimeSession) { - return { - type: 'status_changed', - inBattle: session.inBattle, - npcInteractionActive: session.npcInteractionActive, - currentNpcBattleMode: session.currentNpcBattleMode, - currentNpcBattleOutcome: session.currentNpcBattleOutcome, - } satisfies RuntimeStoryPatch; -} - -function shouldGenerateReasonedCombatStory( - resolution: StoryResolution, -) { - const outcome = resolution.battle?.outcome; - return ( - outcome === 'victory' || - outcome === 'spar_complete' || - outcome === 'escaped' - ); -} - -function clearEncounterState(session: RuntimeSession) { - session.currentEncounter = null; - session.npcInteractionActive = false; - session.sceneHostileNpcs = []; - session.inBattle = false; - session.currentNpcBattleMode = null; -} - -function readSavedStoryText(currentStory: unknown) { - if ( - currentStory && - typeof currentStory === 'object' && - 'text' in currentStory && - typeof currentStory.text === 'string' && - currentStory.text.trim() - ) { - return currentStory.text.trim(); - } - - return ''; -} - -function normalizeIncomingSnapshot(snapshot: unknown) { - if (!isObject(snapshot)) { - return null; - } - - const gameState = 'gameState' in snapshot ? snapshot.gameState : null; - const bottomTab = readString(snapshot.bottomTab) || 'adventure'; - const currentStory = 'currentStory' in snapshot ? snapshot.currentStory : null; - const savedAt = readString(snapshot.savedAt) || new Date().toISOString(); - - if (!gameState || !isObject(gameState)) { - return null; - } - - return normalizeSavedSnapshotPayload({ - savedAt, - bottomTab, - gameState, - currentStory: currentStory ?? null, - }); -} - -async function resolveSnapshotForRequest(params: { - snapshotRepository: RpgRuntimeSnapshotRepositoryPort; - userId: string; - snapshot?: unknown; -}) { - const incomingSnapshot = normalizeIncomingSnapshot(params.snapshot); - if (incomingSnapshot) { - return hydrateSavedSnapshot( - await params.snapshotRepository.putSnapshot( - params.userId, - incomingSnapshot, - ), - )!; - } - - const persistedSnapshot = await params.snapshotRepository.getSnapshot( - params.userId, - ); - if (!persistedSnapshot) { - throw conflict('运行时快照不存在,请先初始化并保存一次游戏'); - } - - return hydrateSavedSnapshot(persistedSnapshot)!; -} - -function buildFallbackStoryText(session: RuntimeSession) { - if (session.inBattle && session.sceneHostileNpcs.length > 0) { - return `眼前的冲突还没结束,${session.sceneHostileNpcs[0]!.name}仍在逼你立刻做出下一步判断。`; - } - - if (session.currentEncounter?.kind === 'npc') { - return `${session.currentEncounter.npcName}正在等你表态,接下来这一轮该怎么回应,由服务端规则来继续收口。`; - } - - return '当前故事状态已经同步到后端,接下来可以继续推进这一轮运行时动作。'; -} - -async function generateNpcDialoguePayload(params: { - llmClient: UpstreamLlmClient; - session: RuntimeSession; - actionText: string; - resultText: string; -}): Promise { - const encounter = params.session.currentEncounter; - const playerCharacter = isObject(params.session.rawGameState.playerCharacter) - ? params.session.rawGameState.playerCharacter - : null; - if ( - !encounter || - encounter.kind !== 'npc' || - !playerCharacter || - !params.session.worldType - ) { - return null; - } - - const history = buildHistoryMoments(params.session, [ - { text: params.actionText, historyRole: 'action' }, - { text: params.resultText, historyRole: 'result' }, - ]); - const availableOptions = buildAvailableOptions(params.session); - const dialogueText = ( - await params.llmClient.requestMessageContent({ - systemPrompt: NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT, - userPrompt: buildStrictNpcChatDialoguePrompt({ - worldType: params.session.worldType, - character: playerCharacter, - encounter: params.session.rawGameState.currentEncounter ?? {}, - monsters: readArray( - params.session.rawGameState.sceneHostileNpcs, - ).filter(isObject), - history, - context: buildStoryPromptContext(params.session, { - ...buildOpeningCampChatContext(params.session), - }), - topic: params.actionText, - resultSummary: params.resultText, - }), - debugLabel: 'runtime.npc_chat.dialogue', - }) - ).trim(); - - const finalDialogueText = dialogueText || params.resultText; - let deferredOptions = availableOptions; - - try { - const nextStory = await generateNextStoryFromOrchestrator( - params.llmClient, - params.session.worldType, - playerCharacter, - readArray(params.session.rawGameState.sceneHostileNpcs).filter(isObject), - history, - params.actionText, - buildStoryPromptContext(params.session, { - lastFunctionId: 'npc_chat', - ...buildOpeningCampChatContext(params.session), - }), - { - availableOptions: buildPromptOptions(availableOptions), - }, - ); - deferredOptions = mergeGeneratedRuntimeOptions( - availableOptions, - nextStory.options as StoryOptionLike[], - ); - } catch { - deferredOptions = availableOptions; - } - - return { - storyText: finalDialogueText, - historyResultText: finalDialogueText, - presentationOptions: [CONTINUE_ADVENTURE_OPTION], - savedCurrentStory: buildDialogueCurrentStory({ - session: params.session, - npcName: encounter.npcName, - text: finalDialogueText, - deferredOptions, - }), - }; -} - -async function generateReasonedStoryPayload(params: { - llmClient: UpstreamLlmClient; - session: RuntimeSession; - actionText: string; - resultText: string; -}): Promise { - const playerCharacter = isObject(params.session.rawGameState.playerCharacter) - ? params.session.rawGameState.playerCharacter - : null; - if (!playerCharacter || !params.session.worldType) { - return null; - } - - const availableOptions = buildAvailableOptions(params.session); - const history = buildHistoryMoments(params.session, [ - { text: params.actionText, historyRole: 'action' }, - { text: params.resultText, historyRole: 'result' }, - ]); - const nextStory = await generateNextStoryFromOrchestrator( - params.llmClient, - params.session.worldType, - playerCharacter, - readArray(params.session.rawGameState.sceneHostileNpcs).filter(isObject), - history, - params.actionText, - buildStoryPromptContext(params.session), - { - availableOptions: buildPromptOptions(availableOptions), - }, - ); - const resolvedOptions = mergeGeneratedRuntimeOptions( - availableOptions, - nextStory.options as StoryOptionLike[], - ); - const storyText = readString(nextStory.storyText) || params.resultText; - - return { - storyText, - historyResultText: storyText, - presentationOptions: resolvedOptions, - savedCurrentStory: buildLegacyCurrentStory(storyText, resolvedOptions), - }; -} - -function resolveStoryFlowAction( - session: RuntimeSession, - functionId: string, -): StoryResolution { - switch (functionId) { - case 'story_continue_adventure': - return { - actionText: '继续推进冒险', - resultText: - '你没有把节奏停下来,而是顺着当前局势继续向前推进了这一段故事。', - patches: [normalizeStatusPatch(session)], - }; - case 'story_opening_camp_dialogue': { - const encounter = session.currentEncounter; - const npcState = getEncounterNpcState(session); - if (encounter && npcState) { - const nextAffinity = npcState.affinity + 2; - setEncounterNpcState(session, { - ...npcState, - affinity: nextAffinity, - firstMeaningfulContactResolved: true, - }); - session.npcInteractionActive = true; - - return { - actionText: `与${encounter.npcName}交换开场判断`, - resultText: `${encounter.npcName}终于愿意把营地里的第一轮判断说出口,彼此的警惕也略微放下了一点。`, - patches: [ - { - type: 'npc_affinity_changed', - npcId: encounter.id, - previousAffinity: npcState.affinity, - nextAffinity, - }, - normalizeStatusPatch(session), - ], - }; - } - - return { - actionText: '交换开场判断', - resultText: - '你先把眼前局势梳理了一遍,为后续真正推进对话和行动留出了空间。', - patches: [normalizeStatusPatch(session)], - }; - } - case 'camp_travel_home_scene': - clearEncounterState(session); - return { - actionText: '返回营地', - resultText: - '你主动结束了当前遭遇,把这轮流程带回了更安全的营地节奏里。', - patches: [ - normalizeStatusPatch(session), - { - type: 'encounter_changed', - encounterId: null, - }, - ], - }; - case 'idle_call_out': - return { - actionText: '主动出声试探', - resultText: - '你的喊话打破了当前的静场,周围潜着的动静也因此更难继续藏着。', - patches: [normalizeStatusPatch(session)], - }; - case 'idle_explore_forward': - return { - actionText: '继续向前探索', - resultText: - '你没有停在原地,而是继续往前压,把下一段遭遇主动推到了自己面前。', - patches: [normalizeStatusPatch(session)], - }; - case 'idle_observe_signs': - return { - actionText: '观察周围迹象', - resultText: '你先压住动作,把风向、脚印和气味这些细节重新读了一遍。', - patches: [normalizeStatusPatch(session)], - }; - case 'idle_rest_focus': - session.playerHp = Math.min(session.playerMaxHp, session.playerHp + 8); - session.playerMana = Math.min( - session.playerMaxMana, - session.playerMana + 6, - ); - return { - actionText: '原地调息', - resultText: '你把呼吸慢下来重新稳住节奏,生命和灵力都回上来一点。', - patches: [normalizeStatusPatch(session)], - }; - case 'idle_travel_next_scene': - clearEncounterState(session); - return { - actionText: '前往相邻场景', - resultText: '你收束了这一段遭遇,顺着路线把故事推进到新的场景段落。', - patches: [ - normalizeStatusPatch(session), - { - type: 'encounter_changed', - encounterId: null, - }, - ], - }; - default: - throw invalidRequest(`暂不支持的 story action:${functionId}`); - } -} - -export async function resolveRuntimeStoryAction(params: { - snapshotRepository: RpgRuntimeSnapshotRepositoryPort; - llmClient?: UpstreamLlmClient; - userId: string; - request: RuntimeStoryActionRequest; -}) { - const hydratedSnapshot = await resolveSnapshotForRequest({ - snapshotRepository: params.snapshotRepository, - userId: params.userId, - snapshot: params.request.snapshot, - }); - - const functionId = - typeof params.request.action.functionId === 'string' - ? params.request.action.functionId.trim() - : ''; - if (!functionId) { - throw invalidRequest('functionId 不能为空'); - } - - if ( - !isSupportedInventoryStoryFunctionId(functionId) && - !isSupportedNpcInventoryStoryFunctionId(functionId) && - !isSupportedQuestStoryFunctionId(functionId) && - !isSupportedTreasureStoryFunctionId(functionId) && - TASK6_DEFERRED_FUNCTION_IDS.has(functionId) - ) { - throw conflict( - `动作 ${functionId} 属于任务6的 Inventory / Quest / Build 范围,本轮任务5接口暂未承接`, - ); - } - - if ( - !isSupportedInventoryStoryFunctionId(functionId) && - !isSupportedNpcInventoryStoryFunctionId(functionId) && - !isSupportedQuestStoryFunctionId(functionId) && - !isSupportedTreasureStoryFunctionId(functionId) && - !isTask5FunctionId(functionId) - ) { - throw invalidRequest(`暂不支持的 runtime action:${functionId}`); - } - - const session = loadRuntimeSession( - hydratedSnapshot, - params.request.sessionId, - ); - if ( - typeof params.request.clientVersion === 'number' && - params.request.clientVersion !== session.runtimeVersion - ) { - throw conflict('运行时版本已变化,请先同步最新快照后再提交动作', { - clientVersion: params.request.clientVersion, - serverVersion: session.runtimeVersion, - }); - } - - let resolution: StoryResolution; - const previousEncounter = session.currentEncounter - ? { ...session.currentEncounter } - : null; - const shouldResolveAsCombat = - functionId === 'inventory_use' ? session.inBattle : isCombatFunctionId(functionId); - if (shouldResolveAsCombat) { - resolution = resolveCombatAction(session, { - functionId, - payload: isObject(params.request.action.payload) - ? params.request.action.payload - : undefined, - }); - } else if (isNpcFunctionId(functionId)) { - resolution = resolveNpcInteraction( - session, - functionId, - isObject(params.request.action.payload) - ? params.request.action.payload - : undefined, - ); - } else if (isSupportedInventoryStoryFunctionId(functionId)) { - resolution = resolveInventoryStoryAction(session, params.request); - } else if (isSupportedNpcInventoryStoryFunctionId(functionId)) { - resolution = resolveNpcInventoryStoryAction(session, params.request); - } else if (isSupportedQuestStoryFunctionId(functionId)) { - resolution = resolveQuestStoryAction(session, params.request, { - currentStory: hydratedSnapshot.currentStory, - }); - } else if (isSupportedTreasureStoryFunctionId(functionId)) { - resolution = resolveTreasureStoryAction(session, params.request); - } else if (isStoryFunctionId(functionId)) { - resolution = resolveStoryFlowAction(session, functionId); - } else { - throw invalidRequest(`当前动作没有可用的后端执行器:${functionId}`); - } - - syncRawGameState(session); - applyQuestSignalsForResolvedAction({ - session, - functionId, - previousEncounter, - battle: resolution.battle ?? null, - }); - - let actionText = resolveActionText(resolution.actionText, params.request); - if ( - functionId === 'story_opening_camp_dialogue' && - session.currentEncounter?.kind === 'npc' - ) { - actionText = `在营地与 ${session.currentEncounter.npcName} 交换开场判断`; - } - let storyText = resolution.storyText ?? resolution.resultText; - let historyResultText = resolution.resultText; - session.runtimeVersion += 1; - session.sessionId = params.request.sessionId; - - syncRawGameState(session); - ensureNpcInventorySessionState(session); - let options = buildAvailableOptions(session); - let savedCurrentStory: JsonRecord = buildLegacyCurrentStory( - storyText, - options, - ); - if (resolution.presentationOptions?.length) { - options = resolution.presentationOptions; - } - if (resolution.savedCurrentStory) { - savedCurrentStory = resolution.savedCurrentStory; - } - const pendingQuestAcceptedCurrentStory = - functionId === 'npc_quest_accept' - ? buildPendingQuestAcceptedCurrentStory({ - session, - currentStory: hydratedSnapshot.currentStory, - }) - : null; - - if (pendingQuestAcceptedCurrentStory) { - savedCurrentStory = pendingQuestAcceptedCurrentStory; - } else if ( - params.llmClient && - (functionId === 'npc_chat' || functionId === 'story_opening_camp_dialogue') - ) { - try { - const generatedPayload = await generateNpcDialoguePayload({ - llmClient: params.llmClient, - session, - actionText, - resultText: resolution.resultText, - }); - if (generatedPayload) { - storyText = generatedPayload.storyText; - historyResultText = generatedPayload.historyResultText; - options = generatedPayload.presentationOptions; - savedCurrentStory = generatedPayload.savedCurrentStory; - } - } catch { - savedCurrentStory = buildLegacyCurrentStory(storyText, options); - } - } else if ( - params.llmClient && - shouldGenerateReasonedCombatStory(resolution) - ) { - try { - const generatedPayload = await generateReasonedStoryPayload({ - llmClient: params.llmClient, - session, - actionText, - resultText: resolution.resultText, - }); - if (generatedPayload) { - storyText = generatedPayload.storyText; - historyResultText = generatedPayload.historyResultText; - options = generatedPayload.presentationOptions; - savedCurrentStory = generatedPayload.savedCurrentStory; - } - } catch { - savedCurrentStory = buildLegacyCurrentStory(storyText, options); - } - } - - appendStoryHistory(session, actionText, historyResultText); - syncRawGameState(session); - - const persistedSnapshot = await params.snapshotRepository.putSnapshot( - params.userId, - normalizeSavedSnapshotPayload({ - savedAt: new Date().toISOString(), - bottomTab: session.snapshotBottomTab, - gameState: session.rawGameState, - currentStory: savedCurrentStory, - }), - ); - - return { - sessionId: session.sessionId, - serverVersion: session.runtimeVersion, - viewModel: buildRuntimeViewModel(session, options), - presentation: { - actionText, - resultText: resolution.resultText, - storyText, - options, - toast: resolution.toast ?? null, - battle: resolution.battle ?? null, - }, - patches: [ - { - type: 'story_history_append', - actionText, - resultText: historyResultText, - }, - ...resolution.patches, - ], - snapshot: hydrateSavedSnapshot(persistedSnapshot)!, - } satisfies RuntimeStoryActionResponse; -} - -export async function getRuntimeStoryState(params: { - snapshotRepository: RpgRuntimeSnapshotRepositoryPort; - userId: string; - sessionId: string; - clientVersion?: number; - snapshot?: RuntimeStoryStateRequest['snapshot']; -}) { - const hydratedSnapshot = await resolveSnapshotForRequest({ - snapshotRepository: params.snapshotRepository, - userId: params.userId, - snapshot: params.snapshot, - }); - - const session = loadRuntimeSession(hydratedSnapshot, params.sessionId); - if ( - typeof params.clientVersion === 'number' && - params.clientVersion !== session.runtimeVersion - ) { - throw conflict('运行时版本已变化,请先同步最新快照后再读取状态', { - clientVersion: params.clientVersion, - serverVersion: session.runtimeVersion, - }); - } - ensureNpcInventorySessionState(session); - const options = buildAvailableOptions(session); - const storyText = - readSavedStoryText(hydratedSnapshot.currentStory) || - buildFallbackStoryText(session); - - return { - sessionId: session.sessionId, - serverVersion: session.runtimeVersion, - viewModel: buildRuntimeViewModel(session, options), - presentation: { - actionText: '', - resultText: '', - storyText, - options, - toast: null, - battle: null, - }, - patches: [], - snapshot: hydratedSnapshot, - } satisfies RuntimeStoryActionResponse; -} diff --git a/server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryActionService.ts b/server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryActionService.ts deleted file mode 100644 index 475dee89..00000000 --- a/server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryActionService.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { - resolveRuntimeStoryAction, -} from './RpgRuntimeStoryActionDomain.js'; - -/** - * RPG runtime story 动作服务入口。 - * 工作包 G 后 runtime action 主链直接落到新域实现,不再继续桥接旧热点文件。 - */ -export { resolveRuntimeStoryAction }; -export const resolveRpgRuntimeStoryAction = resolveRuntimeStoryAction; diff --git a/server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryPresentationCompiler.ts b/server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryPresentationCompiler.ts deleted file mode 100644 index fd661f97..00000000 --- a/server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryPresentationCompiler.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { buildLegacyCurrentStory } from './RpgRuntimeSessionDomain.js'; - -/** - * RPG runtime story 展示兼容编译器。 - * 当前仍复用 legacy currentStory 投影规则,但真实落点已经切到新域目录。 - */ -export { buildLegacyCurrentStory }; -export const buildRpgRuntimeLegacyCurrentStory = buildLegacyCurrentStory; diff --git a/server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryStateService.ts b/server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryStateService.ts deleted file mode 100644 index 71a1f8c0..00000000 --- a/server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryStateService.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { getRuntimeStoryState } from './RpgRuntimeStoryActionDomain.js'; - -/** - * RPG runtime story 状态读取入口。 - * 工作包 G 先把状态读取从旧热点文件迁到新域命名,再保持动作与状态的物理落点分离。 - */ -export { getRuntimeStoryState }; -export const getRpgRuntimeStoryState = getRuntimeStoryState; diff --git a/server-node/src/modules/runtime-item/runtimeItemModule.ts b/server-node/src/modules/runtime-item/runtimeItemModule.ts deleted file mode 100644 index f3d223a3..00000000 --- a/server-node/src/modules/runtime-item/runtimeItemModule.ts +++ /dev/null @@ -1,758 +0,0 @@ -import { - RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES, - RUNTIME_ITEM_TONE_VALUES, -} from '../../../../packages/shared/src/contracts/rpgRuntimeQuestAssist.js'; -import { - buildRuntimeItemIntentPromptText, - RUNTIME_ITEM_INTENT_SYSTEM_PROMPT, -} from '../../prompts/runtimeItemPrompts.js'; - -export { RUNTIME_ITEM_INTENT_SYSTEM_PROMPT }; - -export type RuntimeItemFunctionalBias = - (typeof RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES)[number]; -export type RuntimeItemTone = (typeof RUNTIME_ITEM_TONE_VALUES)[number]; - -export type RuntimeRelationAnchor = - | { type: 'npc'; npcName: string } - | { type: 'scene'; sceneName: string } - | { type: 'monster'; monsterName: string } - | { type: 'quest'; questName: string } - | { type: 'faction'; factionName: string } - | { type: 'landmark'; landmarkName: string }; - -export type RuntimeItemPlan = { - slot: string; - itemKind: 'equipment' | 'consumable' | 'material' | 'relic' | 'quest'; - permanence: 'permanent' | 'timed' | 'resource'; - relationAnchor: RuntimeRelationAnchor; - targetBuildDirection: string[]; -}; - -export type RuntimeItemAiPromptInput = { - worldSummary: string; - sceneSummary: string; - encounterSummary: string; - relatedNpcSummary: string; - recentStorySummary: string; - activeThreadSummary: string; - generationChannel: string; - playerBuildDirection: string[]; - playerBuildGaps: string[]; - desiredItemKind: RuntimeItemPlan['itemKind']; - permanence: RuntimeItemPlan['permanence']; -}; - -export type RuntimeItemAiIntent = { - shortNameSeed: string; - sourcePhrase: string; - reasonToAppear: string; - relationHooks: string[]; - desiredBuildTags: string[]; - desiredFunctionalBias: RuntimeItemFunctionalBias[]; - tone: RuntimeItemTone; - visibleClue: string; - witnessMark: string; - unfinishedBusiness: string; - hiddenHook: string; - reactionHooks: string[]; - namingPattern: string; -}; - -export type RuntimeItemStoryFingerprint = { - relatedScarIds: string[]; - relatedThreadIds: string[]; - visibleClue: string; - witnessMark: string; - unresolvedQuestion: string; -}; - -export type RuntimeItemInventory = { - id: string; - category: string; - name: string; - description: string; - quantity: number; - rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'; - tags: string[]; - equipmentSlotId?: string; - buildProfile?: { - role: string; - tags: string[]; - synergy: string[]; - forgeRank: number; - }; - statProfile?: { - maxHpBonus?: number; - outgoingDamageBonus?: number; - incomingDamageMultiplier?: number; - }; - useProfile?: { - hpRestore: number; - manaRestore: number; - cooldownReduction: number; - buildBuffs: Array<{ - id: string; - sourceType: 'item'; - sourceId: string; - name: string; - tags: string[]; - durationTurns: number; - }>; - }; - runtimeMetadata?: { - origin: 'ai_compiled' | 'procedural'; - generationChannel: string; - seedKey: string; - sourceReason: string; - storyFingerprint: RuntimeItemStoryFingerprint; - }; -}; - -export type DirectedRuntimeReward = { - primaryItem: RuntimeItemInventory | null; - supportItems: RuntimeItemInventory[]; - hp?: number; - mana?: number; - currency?: number; - storyHint?: string; -}; - -export type RuntimeItemGenerationContext = { - worldType: string | null | undefined; - customWorldProfile?: { - name?: string; - summary?: string; - } | null; - sceneId: string | null; - sceneName: string | null; - sceneDescription: string | null; - treasureHints: string[]; - encounter: { - id?: string; - kind?: string; - npcName: string; - npcDescription?: string; - npcAvatar?: string; - context?: string; - } | null; - encounterNpcId: string | null; - encounterNpcName: string | null; - encounterContextText: string | null; - relatedNpcState: { - affinity?: number; - } | null; - relatedNpcNarrativeProfile: { - publicMask?: string; - visibleLine?: string; - immediatePressure?: string; - debtOrBurden?: string; - contradiction?: string; - taboo?: string; - reactionHooks?: string[]; - relatedThreadIds?: string[]; - } | null; - relatedScene: { - id: string; - name: string; - description?: string; - treasureHints?: string[]; - } | null; - recentStorySummary: string; - recentActions: string[]; - activeThreadIds: string[]; - playerCharacterId: string; - playerBuildTags: string[]; - playerBuildGaps: string[]; - playerEquipmentTags: string[]; - generationChannel: string; -}; - -type LooseContextInput = { - worldType: string | null | undefined; - customWorldProfile?: RuntimeItemGenerationContext['customWorldProfile']; - scene?: RuntimeItemGenerationContext['relatedScene']; - encounter?: RuntimeItemGenerationContext['encounter']; - relatedNpcState?: RuntimeItemGenerationContext['relatedNpcState']; - storyHistory?: Array<{ text: string }>; - playerCharacterId?: string; - playerBuildTags?: string[]; - playerEquipmentTags?: string[]; - generationChannel: string; -}; - -function dedupeStrings(values: Array) { - return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]; -} - -function sanitizeFragment(value: string | null | undefined, maxLength = 4) { - return (value ?? '') - .replace(/[^\u4e00-\u9fa5a-z0-9]/giu, '') - .slice(0, maxLength); -} - -function resolveAnchorLabel(anchor: RuntimeRelationAnchor) { - switch (anchor.type) { - case 'npc': - return anchor.npcName; - case 'scene': - return anchor.sceneName; - case 'monster': - return anchor.monsterName; - case 'quest': - return anchor.questName; - case 'faction': - return anchor.factionName; - default: - return anchor.landmarkName; - } -} - -function buildRecentStoryLines(storyHistory: Array<{ text: string }> = []) { - return storyHistory - .slice(-4) - .map((moment) => moment.text.trim()) - .filter(Boolean) - .slice(-3); -} - -function buildRecentStorySummary(lines: string[]) { - return lines.length > 0 ? lines.join(' / ') : '最近没有形成稳定的事件线索。'; -} - -function derivePlayerBuildGaps(playerBuildTags: string[]) { - const gapChecks = [ - { id: 'survival_gap', tags: ['守御', '护体', '回复', '续战'] }, - { id: 'mana_gap', tags: ['法力', '冷却', '护持', '过载'] }, - { id: 'finisher_gap', tags: ['爆发', '重击', '追击', '压制'] }, - ]; - - const tagSet = new Set(playerBuildTags); - return gapChecks - .filter((definition) => definition.tags.every((tag) => !tagSet.has(tag))) - .map((definition) => definition.id) - .slice(0, 3); -} - -function buildRuntimeItemStoryFingerprint(params: { - context: RuntimeItemGenerationContext; - plan: RuntimeItemPlan; - intent: RuntimeItemAiIntent; -}) { - const anchorKey = sanitizeFragment(resolveAnchorLabel(params.plan.relationAnchor), 6) || '旧痕'; - return { - relatedScarIds: [`scar:${params.context.generationChannel}:${anchorKey}`], - relatedThreadIds: params.context.activeThreadIds.slice(0, 2), - visibleClue: params.intent.visibleClue, - witnessMark: params.intent.witnessMark, - unresolvedQuestion: params.intent.hiddenHook || params.intent.unfinishedBusiness, - } satisfies RuntimeItemStoryFingerprint; -} - -function buildNarrativeName( - plan: RuntimeItemPlan, - intent: RuntimeItemAiIntent, - index: number, -) { - const seed = intent.shortNameSeed || '旧痕'; - switch (plan.itemKind) { - case 'equipment': - return `${seed}${index === 0 ? '战符' : '护具'}`; - case 'consumable': - return `${seed}${intent.desiredFunctionalBias.includes('mana') ? '回息散' : '疗伤散'}`; - case 'material': - return `${seed}残材`; - case 'quest': - return `${seed}凭证`; - default: - return `${seed}遗物`; - } -} - -function buildNarrativeDescription(params: { - context: RuntimeItemGenerationContext; - plan: RuntimeItemPlan; - intent: RuntimeItemAiIntent; -}) { - const buildText = params.context.playerBuildTags.join('、') || '当前构筑'; - const anchorText = resolveAnchorLabel(params.plan.relationAnchor); - return `${anchorText}把这件物件推到了你面前。它会围绕你的构筑 ${buildText} 发挥作用,原因是:${params.intent.reasonToAppear}`; -} - -function createRelationAnchor( - context: RuntimeItemGenerationContext, - index = 0, -): RuntimeRelationAnchor { - if (context.encounterNpcName) { - return { - type: 'npc', - npcName: context.encounterNpcName, - }; - } - - if (context.sceneName) { - return { - type: 'scene', - sceneName: context.sceneName, - }; - } - - return { - type: 'landmark', - landmarkName: `遗址${index + 1}`, - }; -} - -function buildPlanFromOptions(params: { - context: RuntimeItemGenerationContext; - index: number; - fixedKinds?: RuntimeItemPlan['itemKind'][]; - fixedPermanence?: RuntimeItemPlan['permanence'][]; -}) { - return { - slot: `slot_${params.index + 1}`, - itemKind: params.fixedKinds?.[params.index] ?? 'relic', - permanence: params.fixedPermanence?.[params.index] ?? 'permanent', - relationAnchor: createRelationAnchor(params.context, params.index), - targetBuildDirection: params.context.playerBuildTags.slice(0, 3), - } satisfies RuntimeItemPlan; -} - -function buildItemRarity(plan: RuntimeItemPlan) { - if (plan.itemKind === 'equipment' || plan.itemKind === 'relic') { - return 'rare' as const; - } - if (plan.itemKind === 'quest') { - return 'epic' as const; - } - return 'uncommon' as const; -} - -function buildItemTags( - plan: RuntimeItemPlan, - intent: RuntimeItemAiIntent, - context: RuntimeItemGenerationContext, -) { - return dedupeStrings([ - plan.itemKind, - ...intent.desiredBuildTags, - ...context.playerBuildTags.slice(0, 2), - ...intent.desiredFunctionalBias, - ]); -} - -function buildItemProfiles( - itemId: string, - plan: RuntimeItemPlan, - intent: RuntimeItemAiIntent, - context: RuntimeItemGenerationContext, -) { - if (plan.itemKind === 'equipment') { - return { - equipmentSlotId: 'weapon', - buildProfile: { - role: context.playerBuildTags[0] ?? '均衡', - tags: buildItemTags(plan, intent, context).slice(0, 3), - synergy: buildItemTags(plan, intent, context).slice(0, 3), - forgeRank: 0, - }, - statProfile: { - maxHpBonus: intent.desiredFunctionalBias.includes('guard') ? 16 : 8, - outgoingDamageBonus: intent.desiredFunctionalBias.includes('damage') - ? 0.12 - : 0.05, - incomingDamageMultiplier: intent.desiredFunctionalBias.includes('guard') - ? 0.9 - : 0.96, - }, - }; - } - - if (plan.itemKind === 'consumable') { - return { - useProfile: { - hpRestore: intent.desiredFunctionalBias.includes('heal') ? 12 : 0, - manaRestore: intent.desiredFunctionalBias.includes('mana') ? 10 : 0, - cooldownReduction: intent.desiredFunctionalBias.includes('cooldown') ? 1 : 0, - buildBuffs: [ - { - id: `${itemId}:buff`, - sourceType: 'item' as const, - sourceId: itemId, - name: `${intent.shortNameSeed || '旧痕'}增益`, - tags: buildItemTags(plan, intent, context).slice(0, 2), - durationTurns: 2, - }, - ], - }, - }; - } - - return {}; -} - -function buildRuntimeInventoryItem(params: { - context: RuntimeItemGenerationContext; - plan: RuntimeItemPlan; - intent: RuntimeItemAiIntent; - seedKey: string; - index: number; -}) { - const itemId = `${params.seedKey}:${params.index + 1}`; - const storyFingerprint = buildRuntimeItemStoryFingerprint(params); - const name = buildNarrativeName(params.plan, params.intent, params.index); - - return { - id: itemId, - category: - params.plan.itemKind === 'equipment' - ? '装备' - : params.plan.itemKind === 'consumable' - ? '消耗品' - : params.plan.itemKind === 'material' - ? '材料' - : params.plan.itemKind === 'quest' - ? '凭证' - : '遗物', - name, - description: buildNarrativeDescription(params), - quantity: 1, - rarity: buildItemRarity(params.plan), - tags: buildItemTags(params.plan, params.intent, params.context), - runtimeMetadata: { - origin: 'ai_compiled' as const, - generationChannel: params.context.generationChannel, - seedKey: itemId, - sourceReason: params.intent.reasonToAppear, - storyFingerprint, - }, - ...buildItemProfiles(itemId, params.plan, params.intent, params.context), - } satisfies RuntimeItemInventory; -} - -export function buildRuntimeItemAiPromptInput( - context: RuntimeItemGenerationContext, - plan: RuntimeItemPlan, -) { - return { - worldSummary: - context.customWorldProfile?.summary ?? context.worldType ?? '未知世界', - sceneSummary: [context.sceneName, context.sceneDescription].filter(Boolean).join(' / '), - encounterSummary: [context.encounterNpcName, context.encounterContextText] - .filter(Boolean) - .join(' / '), - relatedNpcSummary: context.relatedNpcNarrativeProfile - ? `${context.encounterNpcName ?? '相关人物'}:公开面 ${ - context.relatedNpcNarrativeProfile.publicMask ?? '暂无' - };当前压力 ${context.relatedNpcNarrativeProfile.immediatePressure ?? '暂无'}` - : context.relatedNpcState - ? `${context.encounterNpcName ?? '相关人物'} 当前好感 ${context.relatedNpcState.affinity ?? 0}` - : '暂无明确人物关系', - recentStorySummary: context.recentStorySummary, - activeThreadSummary: context.activeThreadIds.join('、'), - generationChannel: context.generationChannel, - playerBuildDirection: context.playerBuildTags, - playerBuildGaps: context.playerBuildGaps, - desiredItemKind: plan.itemKind, - permanence: plan.permanence, - } satisfies RuntimeItemAiPromptInput; -} - -export function buildRuntimeItemAiIntent( - context: RuntimeItemGenerationContext, - plan: RuntimeItemPlan, -) { - const anchorLabel = resolveAnchorLabel(plan.relationAnchor); - const sourceSeed = - sanitizeFragment(context.sceneName, 4) || - sanitizeFragment(context.customWorldProfile?.name, 4) || - sanitizeFragment(anchorLabel, 4) || - '旧誓'; - const functionalBias: RuntimeItemFunctionalBias[] = []; - - if (plan.permanence === 'timed') { - functionalBias.push( - context.playerBuildGaps.includes('survival_gap') ? 'heal' : 'cooldown', - ); - } - if (context.playerBuildGaps.includes('mana_gap')) functionalBias.push('mana'); - if (context.playerBuildGaps.includes('survival_gap')) functionalBias.push('guard'); - if ( - functionalBias.length <= 0 || - context.playerBuildGaps.includes('finisher_gap') || - plan.itemKind === 'equipment' - ) { - functionalBias.push('damage'); - } - - return { - shortNameSeed: sourceSeed, - sourcePhrase: anchorLabel, - reasonToAppear: - context.generationChannel === 'monster_drop' - ? `${anchorLabel}倒下后,${context.sceneName ?? '这片战场'}里最值得带走的残留被翻了出来。` - : `${anchorLabel}与最近局势把它推到了你面前。`, - relationHooks: [context.encounterContextText ?? context.sceneName ?? anchorLabel, ...context.recentActions] - .filter(Boolean) - .slice(0, 2) as string[], - desiredBuildTags: dedupeStrings([ - ...plan.targetBuildDirection, - ...context.playerBuildTags.slice(0, 2), - ]).slice(0, 3), - desiredFunctionalBias: [...new Set(functionalBias)].slice(0, 2), - tone: - context.generationChannel === 'monster_drop' - ? 'grim' - : context.generationChannel === 'quest_reward' - ? 'ritual' - : context.playerBuildGaps.includes('survival_gap') - ? 'survival' - : 'martial', - visibleClue: - context.relatedNpcNarrativeProfile?.visibleLine ?? - `${anchorLabel}身上留下的旧痕`, - witnessMark: - context.relatedNpcNarrativeProfile?.debtOrBurden ?? - `${anchorLabel}尚未散尽的使用痕`, - unfinishedBusiness: - context.relatedNpcNarrativeProfile?.contradiction ?? - `${anchorLabel}背后还有没说完的问题`, - hiddenHook: - context.relatedNpcNarrativeProfile?.taboo ?? - `${anchorLabel}为什么会在此刻重新出现`, - reactionHooks: [ - ...(context.relatedNpcNarrativeProfile?.reactionHooks ?? []), - ...(context.activeThreadIds ?? []), - ].slice(0, 4), - namingPattern: - plan.itemKind === 'quest' - ? 'quest_evidence' - : plan.itemKind === 'material' - ? 'scene_relic' - : plan.relationAnchor.type === 'monster' - ? 'monster_trophy' - : plan.relationAnchor.type === 'npc' - ? 'npc_relic' - : 'faction_issue', - } satisfies RuntimeItemAiIntent; -} - -function describeRelationAnchor(anchor: RuntimeRelationAnchor) { - switch (anchor.type) { - case 'npc': - return `NPC:${anchor.npcName}`; - case 'scene': - return `场景:${anchor.sceneName}`; - case 'monster': - return `怪物:${anchor.monsterName}`; - case 'quest': - return `任务:${anchor.questName}`; - case 'faction': - return `势力:${anchor.factionName}`; - default: - return `地标:${anchor.landmarkName}`; - } -} - -function describePlan( - context: RuntimeItemGenerationContext, - plan: RuntimeItemPlan, - index: number, -) { - const promptInput = buildRuntimeItemAiPromptInput(context, plan); - - return [ - `物品 ${index + 1}`, - `- slot: ${plan.slot}`, - `- 物品类型: ${promptInput.desiredItemKind}`, - `- 持续性: ${promptInput.permanence}`, - `- 关系锚点: ${describeRelationAnchor(plan.relationAnchor)}`, - `- 世界摘要: ${promptInput.worldSummary}`, - `- 场景摘要: ${promptInput.sceneSummary || '无'}`, - `- 遭遇摘要: ${promptInput.encounterSummary || '无'}`, - `- 相关人物: ${promptInput.relatedNpcSummary}`, - `- 当前激活线程: ${promptInput.activeThreadSummary || '暂无'}`, - `- 近期剧情: ${promptInput.recentStorySummary}`, - `- 玩家当前 build: ${promptInput.playerBuildDirection.join('、') || '均衡'}`, - `- 玩家待补缺口: ${promptInput.playerBuildGaps.join('、') || '无明显缺口'}`, - `- 本次目标方向: ${plan.targetBuildDirection.join('、') || '均衡'}`, - ].join('\n'); -} - -export function buildRuntimeItemIntentPrompt(params: { - context: RuntimeItemGenerationContext; - plans: RuntimeItemPlan[]; -}) { - return buildRuntimeItemIntentPromptText({ - generationChannel: params.context.generationChannel, - planBlocks: params.plans.map((plan, index) => - describePlan(params.context, plan, index), - ), - }); -} - -function buildBaseRuntimeContext(params: { - worldType: string | null | undefined; - customWorldProfile?: RuntimeItemGenerationContext['customWorldProfile']; - scene?: RuntimeItemGenerationContext['relatedScene']; - encounter?: RuntimeItemGenerationContext['encounter']; - relatedNpcState?: RuntimeItemGenerationContext['relatedNpcState']; - storyHistory?: Array<{ text: string }>; - playerCharacterId?: string; - playerBuildTags?: string[]; - playerEquipmentTags?: string[]; - generationChannel: string; -}) { - const recentStoryLines = buildRecentStoryLines(params.storyHistory); - const activeThreadIds = dedupeStrings( - params.encounter?.kind === 'npc' && params.encounter?.id - ? [`thread:${params.encounter.id}`] - : params.scene?.id - ? [`thread:${params.scene.id}`] - : [], - ).slice(0, 3); - - return { - worldType: params.worldType, - customWorldProfile: params.customWorldProfile ?? null, - sceneId: params.scene?.id ?? null, - sceneName: params.scene?.name ?? null, - sceneDescription: params.scene?.description ?? null, - treasureHints: [...(params.scene?.treasureHints ?? [])], - encounter: params.encounter ?? null, - encounterNpcId: - params.encounter?.id ?? params.encounter?.npcName ?? null, - encounterNpcName: params.encounter?.npcName ?? null, - encounterContextText: params.encounter?.context ?? null, - relatedNpcState: params.relatedNpcState ?? null, - relatedNpcNarrativeProfile: null, - relatedScene: params.scene ?? null, - recentStorySummary: buildRecentStorySummary(recentStoryLines), - recentActions: recentStoryLines, - activeThreadIds, - playerCharacterId: params.playerCharacterId ?? 'runtime-loose-player', - playerBuildTags: params.playerBuildTags ?? [], - playerBuildGaps: derivePlayerBuildGaps(params.playerBuildTags ?? []), - playerEquipmentTags: params.playerEquipmentTags ?? [], - generationChannel: params.generationChannel, - } satisfies RuntimeItemGenerationContext; -} - -export function buildLooseRuntimeItemGenerationContext(params: LooseContextInput) { - return buildBaseRuntimeContext(params); -} - -export function buildQuestRuntimeItemGenerationContext(params: { - context: { - worldType?: string | null; - customWorldProfile?: RuntimeItemGenerationContext['customWorldProfile']; - currentSceneId?: string | null; - currentSceneName?: string | null; - currentSceneDescription?: string | null; - issuerAffinity?: number | null; - recentStoryMoments?: Array<{ text: string }>; - playerCharacter?: { id: string } | null; - }; - generationChannel?: string; - issuerNpcId: string; - issuerNpcName: string; - roleText: string; - scene?: RuntimeItemGenerationContext['relatedScene']; -}) { - const { context, issuerNpcId, issuerNpcName, roleText } = params; - - return buildBaseRuntimeContext({ - worldType: context.worldType ?? null, - customWorldProfile: context.customWorldProfile ?? null, - scene: - params.scene ?? - (context.currentSceneName - ? { - id: context.currentSceneId ?? '', - name: context.currentSceneName, - description: context.currentSceneDescription ?? '', - treasureHints: [], - } - : null), - encounter: { - id: issuerNpcId, - kind: 'npc', - npcName: issuerNpcName, - npcDescription: roleText, - npcAvatar: '', - context: roleText, - }, - relatedNpcState: - context.issuerAffinity == null - ? null - : { - affinity: context.issuerAffinity, - }, - storyHistory: context.recentStoryMoments ?? [], - playerCharacterId: context.playerCharacter?.id ?? 'quest-player', - generationChannel: params.generationChannel ?? 'quest_reward', - }); -} - -export function buildDirectedRuntimeReward( - context: RuntimeItemGenerationContext, - options: { - seedKey: string; - itemCount?: number; - fixedKinds?: RuntimeItemPlan['itemKind'][]; - fixedPermanence?: RuntimeItemPlan['permanence'][]; - baseHp?: number; - baseMana?: number; - baseCurrency?: number; - storyHint?: string; - }, -) { - const itemCount = Math.max(1, options.itemCount ?? 2); - const items = Array.from({ length: itemCount }, (_, index) => { - const plan = buildPlanFromOptions({ - context, - index, - fixedKinds: options.fixedKinds, - fixedPermanence: options.fixedPermanence, - }); - const intent = buildRuntimeItemAiIntent(context, plan); - return buildRuntimeInventoryItem({ - context, - plan, - intent, - seedKey: options.seedKey, - index, - }); - }); - - return { - primaryItem: items[0] ?? null, - supportItems: items.slice(1), - hp: options.baseHp ?? 0, - mana: options.baseMana ?? 0, - currency: options.baseCurrency ?? 0, - storyHint: - options.storyHint ?? - (items[0] - ? `${items[0].name} 先露出的是“${ - items[0].runtimeMetadata?.storyFingerprint.visibleClue ?? '旧痕' - }”。` - : '你得到了一件与当前局势相关的物品。'), - } satisfies DirectedRuntimeReward; -} - -export function flattenDirectedRuntimeRewardItems(reward: DirectedRuntimeReward) { - return [ - ...(reward.primaryItem ? [reward.primaryItem] : []), - ...reward.supportItems, - ]; -} - -export function buildRuntimeInventoryStock( - context: RuntimeItemGenerationContext, - options: Parameters[1], -) { - return flattenDirectedRuntimeRewardItems( - buildDirectedRuntimeReward(context, options), - ); -} diff --git a/server-node/src/modules/runtime-item/runtimeTreasureModule.ts b/server-node/src/modules/runtime-item/runtimeTreasureModule.ts deleted file mode 100644 index aab1ddfe..00000000 --- a/server-node/src/modules/runtime-item/runtimeTreasureModule.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { - buildDirectedRuntimeReward, - buildLooseRuntimeItemGenerationContext, - flattenDirectedRuntimeRewardItems, -} from './runtimeItemModule.js'; - -type TreasureInteractionAction = 'inspect' | 'leave' | 'secure'; - -type RuntimeStateLike = { - worldType: string | null | undefined; - currentScenePreset?: { - id: string; - name: string; - description?: string; - treasureHints?: string[]; - } | null; - currentEncounter?: { - id?: string; - kind?: string; - npcName: string; - npcDescription?: string; - npcAvatar?: string; - context?: string; - } | null; - playerCharacter?: { - id: string; - } | null; -}; - -type RuntimeEncounterLike = NonNullable; - -export type TreasureReward = { - items: ReturnType; - hp: number; - mana: number; - currency: number; - storyHint?: string; -}; - -export function resolveTreasureReward( - state: RuntimeStateLike, - encounter: RuntimeEncounterLike, - action: TreasureInteractionAction, -) { - const context = buildLooseRuntimeItemGenerationContext({ - worldType: state.worldType, - scene: state.currentScenePreset ?? null, - encounter, - playerCharacterId: state.playerCharacter?.id ?? 'treasure-player', - generationChannel: 'treasure', - }); - const directed = buildDirectedRuntimeReward(context, { - seedKey: `treasure:${encounter.id ?? encounter.npcName}:${action}`, - variant: action, - itemCount: 2, - fixedKinds: - action === 'inspect' ? ['relic', 'consumable'] : ['relic', 'material'], - fixedPermanence: - action === 'inspect' ? ['permanent', 'timed'] : ['permanent', 'resource'], - baseHp: action === 'inspect' ? 10 : 0, - baseMana: action === 'inspect' ? 12 : 0, - baseCurrency: - action === 'inspect' - ? state.worldType === 'XIANXIA' - ? 34 - : 48 - : state.worldType === 'XIANXIA' - ? 22 - : 30, - storyHint: `${encounter.npcName}里藏着与你当前构筑和现场线索贴合的战利品。`, - } as Parameters[1]); - - return { - items: flattenDirectedRuntimeRewardItems(directed), - hp: directed.hp ?? 0, - mana: directed.mana ?? 0, - currency: directed.currency ?? 0, - storyHint: directed.storyHint, - } satisfies TreasureReward; -} diff --git a/server-node/src/modules/runtime-item/treasureStoryActionService.ts b/server-node/src/modules/runtime-item/treasureStoryActionService.ts deleted file mode 100644 index 4160fcef..00000000 --- a/server-node/src/modules/runtime-item/treasureStoryActionService.ts +++ /dev/null @@ -1,140 +0,0 @@ -import type { - RuntimeStoryActionRequest, - RuntimeStoryPatch, -} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js'; -import { conflict, invalidRequest } from '../../errors.js'; -import { - addInventoryItems, - appendStoryEngineCarrierMemory, -} from '../../bridges/legacyNpcTask6Bridge.js'; -import { - buildTreasureResultText, - resolveTreasureReward, -} from '../../bridges/legacyTreasureRuntimeBridge.js'; -import { buildBuildToast } from '../inventory/inventoryStoryActionService.js'; -import { - replaceRuntimeSessionRawGameState, -} from '../rpg-runtime-story/RpgRuntimeSnapshotSync.js'; -import type { RuntimeSession } from '../rpg-runtime-story/RpgRuntimeSessionLoader.js'; - -const SUPPORTED_TREASURE_STORY_FUNCTION_IDS = new Set([ - 'treasure_inspect', - 'treasure_leave', - 'treasure_secure', -]); - -type TreasureStoryResolution = { - actionText: string; - resultText: string; - patches: RuntimeStoryPatch[]; - toast?: string | null; -}; - -type JsonRecord = Record; -type RuntimeGameState = Parameters[0]; -type RuntimeEncounter = Parameters[1]; - -function resolveTreasureAction(functionId: string) { - switch (functionId) { - case 'treasure_secure': - return 'secure'; - case 'treasure_inspect': - return 'inspect'; - case 'treasure_leave': - return 'leave'; - default: - throw invalidRequest(`暂不支持的 Treasure 动作:${functionId}`); - } -} - -function getTreasureEncounter( - session: RuntimeSession, - state: RuntimeGameState, -): RuntimeEncounter | null { - const rawEncounter = state.currentEncounter; - if (!rawEncounter || rawEncounter.kind !== 'treasure') { - return null; - } - - return { - npcAvatar: '', - hostile: false, - ...rawEncounter, - id: rawEncounter.id ?? session.currentEncounter?.id ?? rawEncounter.npcName, - } satisfies RuntimeEncounter; -} - -export function isSupportedTreasureStoryFunctionId(functionId: string) { - return SUPPORTED_TREASURE_STORY_FUNCTION_IDS.has(functionId); -} - -export function resolveTreasureStoryAction( - session: RuntimeSession, - request: RuntimeStoryActionRequest, -): TreasureStoryResolution { - const state = session.rawGameState as unknown as RuntimeGameState; - const encounter = getTreasureEncounter(session, state); - if (!encounter) { - throw conflict('当前没有可结算的宝藏遭遇。'); - } - - const action = resolveTreasureAction(request.action.functionId); - const reward = - action === 'leave' ? null : resolveTreasureReward(state, encounter, action); - - let nextState = { - ...state, - currentEncounter: null, - npcInteractionActive: false, - sceneHostileNpcs: [], - playerX: 0, - playerFacing: 'right' as const, - animationState: state.animationState, - scrollWorld: false, - inBattle: false, - playerHp: reward - ? Math.min(state.playerMaxHp, state.playerHp + reward.hp) - : state.playerHp, - playerMana: reward - ? Math.min(state.playerMaxMana, state.playerMana + reward.mana) - : state.playerMana, - playerCurrency: reward - ? state.playerCurrency + reward.currency - : state.playerCurrency, - playerInventory: reward - ? addInventoryItems(state.playerInventory, reward.items) - : state.playerInventory, - currentBattleNpcId: null, - currentNpcBattleMode: null, - currentNpcBattleOutcome: null, - sparReturnEncounter: null, - sparPlayerHpBefore: null, - sparPlayerMaxHpBefore: null, - sparStoryHistoryBefore: null, - } satisfies RuntimeGameState; - if (reward) { - nextState = appendStoryEngineCarrierMemory(nextState, reward.items); - } - - replaceRuntimeSessionRawGameState( - session, - nextState as unknown as JsonRecord, - ); - - return { - actionText: - action === 'leave' - ? '先记下位置' - : action === 'inspect' - ? '仔细检查' - : '直接收取', - resultText: buildTreasureResultText( - encounter, - action, - reward ?? undefined, - state.worldType, - ), - patches: [], - toast: reward ? buildBuildToast(nextState) : null, - }; -} diff --git a/server-node/src/modules/runtime/runtimeBuildModule.ts b/server-node/src/modules/runtime/runtimeBuildModule.ts deleted file mode 100644 index fbc1ee33..00000000 --- a/server-node/src/modules/runtime/runtimeBuildModule.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { - getEquipmentBonuses, - type RuntimeEquipmentLoadout, -} from './runtimeEquipmentModule.js'; - -type RuntimeCharacterLike = { - attributes: { - strength: number; - agility: number; - intelligence: number; - spirit: number; - }; -}; - -type RuntimeBuildBuff = { - id: string; - name: string; - tags: string[]; - durationTurns: number; -}; - -type RuntimeInventoryItemLike = { - buildProfile?: { - role: string; - tags: string[]; - synergy: string[]; - forgeRank: number; - }; -}; - -type RuntimeGameStateLike = { - playerEquipment: RuntimeEquipmentLoadout; - activeBuildBuffs?: RuntimeBuildBuff[]; - playerCharacter?: RuntimeCharacterLike | null; -}; - -export type BuildContributionRow = { - label: string; - source: 'buff' | 'weapon' | 'armor' | 'relic' | 'character'; - fitScore: number; - sourceCoefficient: number; - bonusDelta: number; - attributeSimilarities: Record; - attributeWeights: Record; - attributeContributions: Record; - attributeModifierDeltas: Record; -}; - -export type BuildDamageBreakdown = { - tags: string[]; - baseTagCount: number; - buildDamageBonus: number; - buildDamageMultiplier: number; - rows: BuildContributionRow[]; -}; - -export type OutgoingDamageResult = { - damage: number; - isCritical: boolean; - critChance: number; - critDamageMultiplier: number; - attackPowerMultiplier: number; -}; - -function roundNumber(value: number, digits = 4) { - const factor = 10 ** digits; - return Math.round(value * factor) / factor; -} - -function clamp(value: number, min: number, max: number) { - return Math.min(max, Math.max(min, value)); -} - -function hashSeed(seed: string) { - let hash = 0; - for (let index = 0; index < seed.length; index += 1) { - hash = (hash * 31 + seed.charCodeAt(index)) >>> 0; - } - return hash; -} - -export function appendBuildBuffs( - baseBuffs: TBuff[] | null | undefined, - additions: TBuff[] | null | undefined, -) { - const merged = new Map(); - - [...(baseBuffs ?? []), ...(additions ?? [])].forEach((buff) => { - const existing = merged.get(buff.id); - if (!existing || (buff.durationTurns ?? 0) >= (existing.durationTurns ?? 0)) { - merged.set(buff.id, { - ...buff, - tags: [...new Set(buff.tags.map((tag) => tag.trim()).filter(Boolean))], - }); - } - }); - - return [...merged.values()].filter( - (buff) => buff.tags.length > 0 && buff.durationTurns > 0, - ); -} - -function collectBuildTags( - state: RuntimeGameStateLike, - character: RuntimeCharacterLike, -) { - const tags = new Set(); - state.activeBuildBuffs - ?.filter((buff) => (buff.durationTurns ?? 0) > 0) - .forEach((buff) => buff.tags.forEach((tag) => tags.add(tag))); - - (['weapon', 'armor', 'relic'] as const).forEach((slot) => { - const item = state.playerEquipment[slot]; - item?.buildProfile?.tags?.forEach((tag) => tags.add(tag)); - if (item?.buildProfile?.role) { - tags.add(item.buildProfile.role); - } - }); - - if (character.attributes.agility >= 10) tags.add('快剑'); - if (character.attributes.strength >= 10) tags.add('重击'); - if (character.attributes.spirit >= 10) tags.add('续战'); - if (character.attributes.intelligence >= 8) tags.add('法力'); - - return [...tags].filter(Boolean).slice(0, 8); -} - -export function getPlayerBuildDamageBreakdown< - TState extends RuntimeGameStateLike, - TItem extends RuntimeInventoryItemLike, ->(state: TState, character: RuntimeCharacterLike) { - const tags = collectBuildTags(state, character); - const rows = tags.map((tag, index) => { - const bonusDelta = roundNumber(0.03 + Math.min(index, 3) * 0.01, 4); - return { - label: tag, - source: index === 0 ? 'buff' : 'weapon', - fitScore: roundNumber(0.6 + Math.max(0, 3 - index) * 0.08, 4), - sourceCoefficient: 1, - bonusDelta, - attributeSimilarities: {}, - attributeWeights: {}, - attributeContributions: {}, - attributeModifierDeltas: {}, - } satisfies BuildContributionRow; - }); - - const buildDamageBonus = roundNumber( - clamp(rows.reduce((sum, row) => sum + row.bonusDelta, 0), 0, 0.6), - 4, - ); - - return { - tags, - baseTagCount: tags.length, - buildDamageBonus, - buildDamageMultiplier: roundNumber(1 + buildDamageBonus, 4), - rows, - } satisfies BuildDamageBreakdown; -} - -export function resolvePlayerOutgoingDamageResult< - TState extends RuntimeGameStateLike, - TItem extends RuntimeInventoryItemLike, ->( - state: TState, - character: RuntimeCharacterLike, - baseDamage: number, - functionMultiplier = 1, - critRollSeed?: string, -) { - const buildBreakdown = getPlayerBuildDamageBreakdown(state, character); - const equipmentBonuses = getEquipmentBonuses(state.playerEquipment); - const attackPowerMultiplier = roundNumber( - 1 + - (character.attributes.strength * 0.01 + - character.attributes.agility * 0.006 + - character.attributes.spirit * 0.004), - 4, - ); - const critChance = roundNumber( - clamp(0.08 + character.attributes.agility * 0.01, 0.08, 0.45), - 4, - ); - const critDamageMultiplier = roundNumber( - 1.45 + character.attributes.strength * 0.01, - 4, - ); - const roll = critRollSeed ? (hashSeed(critRollSeed) % 1000) / 1000 : 1; - const isCritical = roll < critChance; - - const damage = Math.max( - 1, - Math.round( - baseDamage * - functionMultiplier * - equipmentBonuses.outgoingDamageMultiplier * - buildBreakdown.buildDamageMultiplier * - attackPowerMultiplier * - (isCritical ? critDamageMultiplier : 1), - ), - ); - - return { - damage, - isCritical, - critChance, - critDamageMultiplier, - attackPowerMultiplier, - } satisfies OutgoingDamageResult; -} diff --git a/server-node/src/modules/runtime/runtimeEconomyPrimitives.test.ts b/server-node/src/modules/runtime/runtimeEconomyPrimitives.test.ts deleted file mode 100644 index b9d69388..00000000 --- a/server-node/src/modules/runtime/runtimeEconomyPrimitives.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; - -import { - formatCurrency, - getCurrencyName, - getInventoryItemValue, - getNpcBuybackPrice, - getNpcPurchasePrice, -} from './runtimeEconomyPrimitives.js'; -import { buildTreasureResultText } from './runtimeTreasureTexts.js'; - -test('runtime economy primitives calculate trade prices on the server without src/data/economy', () => { - const item = { - category: '专属物品', - name: '青铜令牌', - rarity: 'epic' as const, - tags: ['relic'], - }; - - assert.equal(getCurrencyName('WUXIA'), '铜钱'); - assert.equal(getCurrencyName('XIANXIA'), '灵石'); - assert.equal(formatCurrency(48, 'WUXIA'), '48 铜钱'); - assert.equal(getInventoryItemValue(item), 118); - assert.equal(getNpcPurchasePrice(item, 0), 118); - assert.equal(getNpcPurchasePrice(item, 65), 99); - assert.equal(getNpcBuybackPrice(item, 95), 68); -}); - -test('runtime treasure text uses server-side currency formatting and reward summaries', () => { - const text = buildTreasureResultText( - { - npcName: '古旧木匣', - }, - 'inspect', - { - items: [{ name: '残卷' }, { name: '灵药' }], - hp: 10, - mana: 12, - currency: 34, - storyHint: '你察觉这批东西与当前线索彼此呼应。', - }, - 'XIANXIA', - ); - - assert.match(text, /34 灵石/); - assert.match(text, /残卷、灵药/); - assert.match(text, /气血 \+10/); - assert.match(text, /灵力 \+12/); -}); diff --git a/server-node/src/modules/runtime/runtimeEconomyPrimitives.ts b/server-node/src/modules/runtime/runtimeEconomyPrimitives.ts deleted file mode 100644 index 01c3a3de..00000000 --- a/server-node/src/modules/runtime/runtimeEconomyPrimitives.ts +++ /dev/null @@ -1,75 +0,0 @@ -type RuntimeInventoryItemLike = { - category: string; - name: string; - rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'; - tags: string[]; - value?: number; -}; - -const RARITY_BASE_VALUES: Record = { - common: 12, - uncommon: 24, - rare: 48, - epic: 92, - legendary: 168, -}; - -export function getCurrencyName(worldType: string | null | undefined) { - if (worldType === 'XIANXIA') { - return '灵石'; - } - if (worldType === 'WUXIA') { - return '铜钱'; - } - return '钱币'; -} - -export function formatCurrency( - value: number, - worldType: string | null | undefined, -) { - return `${value} ${getCurrencyName(worldType)}`; -} - -export function getDiscountTierForAffinity(affinity: number) { - if (affinity >= 90) return 3; - if (affinity >= 60) return 2; - if (affinity >= 30) return 1; - return 0; -} - -export function getInventoryItemValue(item: RuntimeInventoryItemLike) { - if (typeof item.value === 'number' && Number.isFinite(item.value)) { - return Math.max(8, Math.round(item.value)); - } - - let value = RARITY_BASE_VALUES[item.rarity]; - - if (item.tags.includes('weapon')) value += 14; - if (item.tags.includes('armor')) value += 12; - if (item.tags.includes('relic')) value += 16; - if (item.tags.includes('mana')) value += 8; - if (item.tags.includes('healing')) value += 8; - if (item.tags.includes('material')) value += 4; - if (item.category.includes('专属')) value += 10; - - return Math.max(8, value); -} - -export function getNpcPurchasePrice( - item: RuntimeInventoryItemLike, - affinity: number, -) { - const discountTier = getDiscountTierForAffinity(affinity); - const discountMultiplier = 1 - discountTier * 0.08; - return Math.max(6, Math.round(getInventoryItemValue(item) * discountMultiplier)); -} - -export function getNpcBuybackPrice( - item: RuntimeInventoryItemLike, - affinity: number, -) { - const discountTier = getDiscountTierForAffinity(affinity); - const buybackMultiplier = 0.4 + discountTier * 0.06; - return Math.max(4, Math.round(getInventoryItemValue(item) * buybackMultiplier)); -} diff --git a/server-node/src/modules/runtime/runtimeEquipmentModule.ts b/server-node/src/modules/runtime/runtimeEquipmentModule.ts deleted file mode 100644 index f683ec63..00000000 --- a/server-node/src/modules/runtime/runtimeEquipmentModule.ts +++ /dev/null @@ -1,211 +0,0 @@ -type ItemRarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'; - -type RuntimeInventoryItemLike = { - id: string; - category: string; - name: string; - quantity: number; - rarity: ItemRarity; - tags: string[]; - equipmentSlotId?: RuntimeEquipmentSlotId; - statProfile?: { - maxHpBonus?: number; - maxManaBonus?: number; - outgoingDamageBonus?: number; - incomingDamageMultiplier?: number; - }; - buildProfile?: { - role: string; - tags: string[]; - synergy: string[]; - forgeRank: number; - }; -}; - -export type RuntimeEquipmentSlotId = 'weapon' | 'armor' | 'relic'; - -export type RuntimeEquipmentLoadout = { - weapon: TItem | null; - armor: TItem | null; - relic: TItem | null; -}; - -export type EquipmentBonuses = { - maxHpBonus: number; - maxManaBonus: number; - outgoingDamageMultiplier: number; - incomingDamageMultiplier: number; -}; - -const EQUIPMENT_SLOTS: RuntimeEquipmentSlotId[] = ['weapon', 'armor', 'relic']; - -const WEAPON_DAMAGE_BONUS: Record = { - common: 0.06, - uncommon: 0.1, - rare: 0.14, - epic: 0.2, - legendary: 0.28, -}; - -const ARMOR_HP_BONUS: Record = { - common: 14, - uncommon: 22, - rare: 32, - epic: 44, - legendary: 58, -}; - -const ARMOR_DAMAGE_MULTIPLIER: Record = { - common: 0.97, - uncommon: 0.94, - rare: 0.9, - epic: 0.86, - legendary: 0.8, -}; - -const RELIC_MANA_BONUS: Record = { - common: 10, - uncommon: 18, - rare: 28, - epic: 40, - legendary: 54, -}; - -const RELIC_DAMAGE_BONUS: Record = { - common: 0.02, - uncommon: 0.04, - rare: 0.06, - epic: 0.09, - legendary: 0.12, -}; - -export function createEmptyEquipmentLoadout(): RuntimeEquipmentLoadout { - return { - weapon: null, - armor: null, - relic: null, - }; -} - -export function getEquipmentSlotLabel(slot: RuntimeEquipmentSlotId) { - return { - weapon: '武器', - armor: '护甲', - relic: '饰品', - }[slot]; -} - -function inferSlotFromText(value: string) { - if (/武器|剑|弓|刀|拳套|战刃|佩刀|枪|刃/u.test(value)) return 'weapon' as const; - if (/护甲|甲|护臂|衣|袍|铠/u.test(value)) return 'armor' as const; - if (/饰品|护符|徽章|玉|珠|坠|铃|盘|令|匣/u.test(value)) return 'relic' as const; - return null; -} - -export function getEquipmentSlotFromItem( - item: RuntimeInventoryItemLike, -): RuntimeEquipmentSlotId | null { - if (item.equipmentSlotId) return item.equipmentSlotId; - if (item.tags.includes('weapon')) return 'weapon'; - if (item.tags.includes('armor')) return 'armor'; - if (item.tags.includes('relic')) return 'relic'; - - return inferSlotFromText(`${item.category} ${item.name}`); -} - -function getFallbackBonusesForItem(slot: RuntimeEquipmentSlotId, rarity: ItemRarity) { - if (slot === 'weapon') { - return { - maxHpBonus: 0, - maxManaBonus: 0, - outgoingDamageBonus: WEAPON_DAMAGE_BONUS[rarity], - incomingDamageMultiplier: 1, - }; - } - - if (slot === 'armor') { - return { - maxHpBonus: ARMOR_HP_BONUS[rarity], - maxManaBonus: 0, - outgoingDamageBonus: 0, - incomingDamageMultiplier: ARMOR_DAMAGE_MULTIPLIER[rarity], - }; - } - - return { - maxHpBonus: 0, - maxManaBonus: RELIC_MANA_BONUS[rarity], - outgoingDamageBonus: RELIC_DAMAGE_BONUS[rarity], - incomingDamageMultiplier: 1, - }; -} - -function getItemEquipmentBonuses( - item: RuntimeInventoryItemLike, - slot: RuntimeEquipmentSlotId, -) { - const fallback = getFallbackBonusesForItem(slot, item.rarity); - - return { - maxHpBonus: item.statProfile?.maxHpBonus ?? fallback.maxHpBonus, - maxManaBonus: item.statProfile?.maxManaBonus ?? fallback.maxManaBonus, - outgoingDamageBonus: - item.statProfile?.outgoingDamageBonus ?? fallback.outgoingDamageBonus, - incomingDamageMultiplier: - item.statProfile?.incomingDamageMultiplier ?? fallback.incomingDamageMultiplier, - }; -} - -export function getEquipmentBonuses( - loadout: RuntimeEquipmentLoadout, -): EquipmentBonuses { - let maxHpBonus = 0; - let maxManaBonus = 0; - let outgoingDamageBonus = 0; - let incomingDamageMultiplier = 1; - - EQUIPMENT_SLOTS.forEach((slot) => { - const item = loadout[slot]; - if (!item) return; - - const itemBonuses = getItemEquipmentBonuses(item, slot); - maxHpBonus += itemBonuses.maxHpBonus; - maxManaBonus += itemBonuses.maxManaBonus; - outgoingDamageBonus += itemBonuses.outgoingDamageBonus; - incomingDamageMultiplier *= itemBonuses.incomingDamageMultiplier; - }); - - return { - maxHpBonus, - maxManaBonus, - outgoingDamageMultiplier: Number((1 + outgoingDamageBonus).toFixed(4)), - incomingDamageMultiplier: Number(incomingDamageMultiplier.toFixed(4)), - }; -} - -export function applyEquipmentLoadoutToState< - TState extends { - playerMaxHp: number; - playerHp: number; - playerMaxMana: number; - playerMana: number; - playerEquipment: RuntimeEquipmentLoadout; - }, - TItem extends RuntimeInventoryItemLike, ->(state: TState, nextEquipment: RuntimeEquipmentLoadout) { - const previousBonuses = getEquipmentBonuses(state.playerEquipment); - const nextBonuses = getEquipmentBonuses(nextEquipment); - const baseMaxHp = Math.max(1, state.playerMaxHp - previousBonuses.maxHpBonus); - const baseMaxMana = Math.max(1, state.playerMaxMana - previousBonuses.maxManaBonus); - const nextMaxHp = baseMaxHp + nextBonuses.maxHpBonus; - const nextMaxMana = baseMaxMana + nextBonuses.maxManaBonus; - - return { - ...state, - playerMaxHp: nextMaxHp, - playerHp: Math.min(nextMaxHp, state.playerHp), - playerMaxMana: nextMaxMana, - playerMana: nextMaxMana, - playerEquipment: nextEquipment, - }; -} diff --git a/server-node/src/modules/runtime/runtimeForgeModule.ts b/server-node/src/modules/runtime/runtimeForgeModule.ts deleted file mode 100644 index a8817994..00000000 --- a/server-node/src/modules/runtime/runtimeForgeModule.ts +++ /dev/null @@ -1,468 +0,0 @@ -import { formatCurrency } from './runtimeEconomyPrimitives.js'; -import { - addInventoryItems, - removeInventoryItem, -} from './runtimeStatePrimitives.js'; -import { - getEquipmentSlotFromItem, - getEquipmentSlotLabel, - type RuntimeEquipmentSlotId, -} from './runtimeEquipmentModule.js'; - -type RuntimeInventoryItemLike = { - id: string; - category: string; - name: string; - quantity: number; - rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'; - tags: string[]; - equipmentSlotId?: RuntimeEquipmentSlotId; - statProfile?: { - maxHpBonus?: number; - maxManaBonus?: number; - outgoingDamageBonus?: number; - incomingDamageMultiplier?: number; - }; - buildProfile?: { - role: string; - tags: string[]; - synergy: string[]; - forgeRank: number; - }; -}; - -type ForgeRequirement = { - id: string; - label: string; - quantity: number; - matches: (item: TItem) => boolean; -}; - -type ForgeRecipeDefinition = { - id: string; - name: string; - kind: 'synthesis' | 'forge'; - description: string; - resultLabel: string; - currencyCost: number; - requirements: ForgeRequirement[]; - createResult: (worldType: string | null | undefined) => TItem; -}; - -function createItemId(prefix: string) { - return `${prefix}:${Date.now().toString(36)}:${Math.random() - .toString(36) - .slice(2, 8)}`; -} - -function normalizeBuildTags(tags: string[]) { - return [...new Set(tags.map((tag) => tag.trim()).filter(Boolean))]; -} - -function buildMaterialItem( - name: string, - quantity: number, - tags: string[], - rarity: RuntimeInventoryItemLike['rarity'] = 'uncommon', - description?: string, -) { - return { - id: createItemId(`forge-material:${name}`), - category: '材料', - name, - quantity: Math.max(1, Math.floor(quantity)), - rarity, - tags: ['material', ...normalizeBuildTags(tags)], - description, - buildProfile: { - role: '工巧', - tags: normalizeBuildTags(tags), - synergy: normalizeBuildTags(tags), - forgeRank: 0, - }, - } satisfies RuntimeInventoryItemLike; -} - -function buildEquipmentItem(params: { - name: string; - slot: RuntimeEquipmentSlotId; - rarity: RuntimeInventoryItemLike['rarity']; - description: string; - role: string; - tags: string[]; - synergy: string[]; - statProfile: NonNullable; -}) { - return { - id: createItemId(`forge-equip:${params.name}`), - category: getEquipmentSlotLabel(params.slot), - name: params.name, - quantity: 1, - rarity: params.rarity, - tags: [ - params.slot === 'weapon' ? 'weapon' : params.slot === 'armor' ? 'armor' : 'relic', - ...normalizeBuildTags(params.tags), - ], - description: params.description, - equipmentSlotId: params.slot, - statProfile: params.statProfile, - buildProfile: { - role: params.role, - tags: normalizeBuildTags(params.tags), - synergy: normalizeBuildTags(params.synergy), - forgeRank: 1, - }, - } satisfies RuntimeInventoryItemLike; -} - -function buildNamedMaterialRequirement( - name: string, - quantity: number, -): ForgeRequirement { - return { - id: `name:${name}`, - label: name, - quantity, - matches: (item) => item.name === name, - }; -} - -function buildAnyMaterialRequirement( - id: string, - label: string, - quantity: number, -): ForgeRequirement { - return { - id, - label, - quantity, - matches: (item) => item.tags.includes('material') || item.category.includes('材料'), - }; -} - -function buildForgeRecipes() { - return [ - { - id: 'synthesis-refined-ingot', - name: '压炼锭材', - kind: 'synthesis', - description: '把零散残片和基础材料压成稳定可用的金属锭材。', - resultLabel: '精炼锭材', - currencyCost: 18, - requirements: [buildAnyMaterialRequirement('material:any', '任意材料', 3)], - createResult: () => - buildMaterialItem('精炼锭材', 1, ['工巧', '守御'], 'rare') as TItem, - }, - { - id: 'forge-duelist-blade', - name: '锻造 百炼追风剑', - kind: 'forge', - description: '围绕快剑、突进、追击构筑的轻灵主武器。', - resultLabel: '百炼追风剑', - currencyCost: 72, - requirements: [ - buildNamedMaterialRequirement('精炼锭材', 2), - buildNamedMaterialRequirement('快剑精粹', 1), - ], - createResult: () => - buildEquipmentItem({ - name: '百炼追风剑', - slot: 'weapon', - rarity: 'epic', - description: '为快剑与追身构筑准备的锻造兵刃。', - role: '快剑', - tags: ['快剑', '突进', '追击'], - synergy: ['快剑', '突进', '追击'], - statProfile: { - maxManaBonus: 10, - outgoingDamageBonus: 0.2, - }, - }) as TItem, - }, - ] satisfies ForgeRecipeDefinition[]; -} - -type ForgeRecipeView = { - id: string; - name: string; - kind: 'synthesis' | 'forge'; - description: string; - resultLabel: string; - currencyCost: number; - currencyText: string; - requirements: Array<{ - id: string; - label: string; - quantity: number; - owned: number; - }>; - canCraft: boolean; -}; - -function countMatchingItems( - inventory: TItem[], - requirement: ForgeRequirement, -) { - return inventory - .filter((item) => requirement.matches(item)) - .reduce((sum, item) => sum + item.quantity, 0); -} - -function consumeRequirement( - inventory: TItem[], - requirement: ForgeRequirement, -) { - let remaining = requirement.quantity; - let nextInventory = [...inventory]; - - for (const item of inventory) { - if (remaining <= 0) break; - if (!requirement.matches(item)) continue; - - const consumed = Math.min(item.quantity, remaining); - nextInventory = removeInventoryItem(nextInventory, item.id, consumed); - remaining -= consumed; - } - - return remaining === 0 ? nextInventory : null; -} - -function applyRequirementsIfPossible( - inventory: TItem[], - requirements: ForgeRequirement[], -) { - let nextInventory = [...inventory]; - for (const requirement of requirements) { - const consumedInventory = consumeRequirement(nextInventory, requirement); - if (!consumedInventory) return null; - nextInventory = consumedInventory; - } - return nextInventory; -} - -function buildTagEssence(tag: string) { - return buildMaterialItem(`${tag}精粹`, 1, [tag, '工巧'], 'rare'); -} - -function buildDismantleBaseMaterials( - item: RuntimeInventoryItemLike, - slot: RuntimeEquipmentSlotId | null, -) { - const rarityScale: Record = { - common: 1, - uncommon: 2, - rare: 3, - epic: 4, - legendary: 5, - }; - - const amount = rarityScale[item.rarity]; - if (slot === 'weapon') { - return [buildMaterialItem('武器残片', amount, ['工巧', '重击'])]; - } - if (slot === 'armor') { - return [buildMaterialItem('甲片', amount, ['工巧', '守御'])]; - } - if (slot === 'relic') { - return [buildMaterialItem('灵饰碎片', amount, ['工巧', '法力'])]; - } - - return [buildMaterialItem('零散材料', Math.max(1, Math.ceil(amount / 2)), ['工巧'])]; -} - -function buildDismantleEssences(item: RuntimeInventoryItemLike) { - const buildTags = normalizeBuildTags([ - ...(item.buildProfile?.tags ?? []), - item.buildProfile?.role ?? '', - ]).slice(0, item.rarity === 'legendary' ? 3 : 2); - - return buildTags.map((tag) => buildTagEssence(tag)); -} - -function getReforgeCost( - slot: RuntimeEquipmentSlotId | null, -) { - if (slot === 'relic') { - return { - requirements: [buildNamedMaterialRequirement('凝光纱', 1)], - currencyCost: 52, - }; - } - - return { - requirements: [buildNamedMaterialRequirement('精炼锭材', 1)], - currencyCost: 46, - }; -} - -function buildReforgedItem(item: RuntimeInventoryItemLike) { - const slot = getEquipmentSlotFromItem(item); - if (!slot || !item.buildProfile) return null; - - const nextTags = normalizeBuildTags([ - ...item.buildProfile.tags, - slot === 'weapon' ? '追击' : slot === 'armor' ? '护体' : '法力', - ]).slice(0, 3); - - return { - ...item, - id: createItemId(`reforge:${item.name}`), - name: item.name.includes('重铸') ? item.name : `${item.name}·重铸`, - statProfile: { - ...item.statProfile, - maxHpBonus: (item.statProfile?.maxHpBonus ?? 0) + (slot === 'armor' ? 10 : 4), - maxManaBonus: (item.statProfile?.maxManaBonus ?? 0) + (slot === 'relic' ? 10 : 4), - outgoingDamageBonus: Number( - (((item.statProfile?.outgoingDamageBonus ?? 0) + 0.03)).toFixed(3), - ), - incomingDamageMultiplier: - typeof item.statProfile?.incomingDamageMultiplier === 'number' - ? Number(Math.max(0.72, item.statProfile.incomingDamageMultiplier - 0.03).toFixed(3)) - : slot === 'armor' - ? 0.94 - : 0.97, - }, - buildProfile: { - ...item.buildProfile, - tags: nextTags, - synergy: nextTags, - forgeRank: (item.buildProfile.forgeRank ?? 0) + 1, - }, - } satisfies RuntimeInventoryItemLike; -} - -export function getForgeRecipeViews( - inventory: TItem[], - playerCurrency = 0, - worldType: string | null | undefined = null, -) { - return buildForgeRecipes().map((recipe) => ({ - id: recipe.id, - name: recipe.name, - kind: recipe.kind, - description: recipe.description, - resultLabel: recipe.resultLabel, - currencyCost: recipe.currencyCost, - currencyText: formatCurrency(recipe.currencyCost, worldType), - requirements: recipe.requirements.map((requirement) => ({ - id: requirement.id, - label: requirement.label, - quantity: requirement.quantity, - owned: countMatchingItems(inventory, requirement), - })), - canCraft: - playerCurrency >= recipe.currencyCost && - recipe.requirements.every( - (requirement) => countMatchingItems(inventory, requirement) >= requirement.quantity, - ), - })) satisfies ForgeRecipeView[]; -} - -export function executeForgeRecipe( - inventory: TItem[], - recipeId: string, - worldType: string | null | undefined, - playerCurrency: number, -) { - const recipe = buildForgeRecipes().find((candidate) => candidate.id === recipeId); - if (!recipe || playerCurrency < recipe.currencyCost) return null; - - const consumedInventory = applyRequirementsIfPossible(inventory, recipe.requirements); - if (!consumedInventory) return null; - - const createdItem = recipe.createResult(worldType); - return { - inventory: addInventoryItems(consumedInventory, [createdItem]), - currency: playerCurrency - recipe.currencyCost, - createdItem, - }; -} - -export function executeDismantleItem( - inventory: TItem[], - itemId: string, -) { - const targetItem = inventory.find((item) => item.id === itemId); - if (!targetItem || targetItem.quantity <= 0) return null; - - const slot = getEquipmentSlotFromItem(targetItem); - if (!slot && !targetItem.buildProfile) return null; - - const outputs = [ - ...buildDismantleBaseMaterials(targetItem, slot), - ...buildDismantleEssences(targetItem), - ] as TItem[]; - - return { - inventory: addInventoryItems(removeInventoryItem(inventory, itemId, 1), outputs), - outputs, - }; -} - -export function executeReforgeItem( - inventory: TItem[], - itemId: string, - playerCurrency: number, -) { - const targetItem = inventory.find((item) => item.id === itemId); - if (!targetItem || targetItem.quantity <= 0) return null; - - const slot = getEquipmentSlotFromItem(targetItem); - const reforgedItem = buildReforgedItem(targetItem) as TItem | null; - const reforgeCost = getReforgeCost(slot); - if (!reforgedItem || playerCurrency < reforgeCost.currencyCost) return null; - - const consumedInventory = applyRequirementsIfPossible( - removeInventoryItem(inventory, itemId, 1), - reforgeCost.requirements, - ); - if (!consumedInventory) return null; - - return { - inventory: addInventoryItems(consumedInventory, [reforgedItem]), - reforgedItem, - currencyCost: reforgeCost.currencyCost, - }; -} - -export function getReforgeCostView( - item: TItem, - worldType: string | null | undefined, -) { - const slot = getEquipmentSlotFromItem(item); - const cost = getReforgeCost(slot); - return { - currencyCost: cost.currencyCost, - currencyText: formatCurrency(cost.currencyCost, worldType), - requirements: cost.requirements.map((requirement) => ({ - id: requirement.id, - label: requirement.label, - quantity: requirement.quantity, - })), - }; -} - -export function buildForgeSuccessText( - action: 'craft' | 'dismantle' | 'reforge', - params: { - sourceItemName?: string; - recipeName?: string; - createdItemName?: string; - outputNames?: string[]; - currencyText?: string; - }, -) { - if (action === 'craft') { - return `你在工坊中完成了${params.recipeName},获得了${params.createdItemName}${ - params.currencyText ? `,并支付了${params.currencyText}` : '' - }。`; - } - - if (action === 'reforge') { - return `你消耗材料重新淬炼了${params.sourceItemName},最终得到${params.createdItemName}${ - params.currencyText ? `,并支付了${params.currencyText}` : '' - }。`; - } - - return `你拆解了${params.sourceItemName},回收出${(params.outputNames ?? []).join('、')}。`; -} diff --git a/server-node/src/modules/runtime/runtimeInventoryEffectsModule.ts b/server-node/src/modules/runtime/runtimeInventoryEffectsModule.ts deleted file mode 100644 index d39a048a..00000000 --- a/server-node/src/modules/runtime/runtimeInventoryEffectsModule.ts +++ /dev/null @@ -1,130 +0,0 @@ -type RuntimeCharacterLike = { - attributes: { - strength: number; - agility: number; - intelligence: number; - spirit: number; - }; -}; - -type RuntimeBuildBuff = { - id: string; - sourceType: 'item'; - sourceId: string; - name: string; - tags: string[]; - durationTurns: number; -}; - -type RuntimeInventoryItemLike = { - name: string; - rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'; - tags: string[]; - useProfile?: { - hpRestore?: number; - manaRestore?: number; - cooldownReduction?: number; - buildBuffs?: RuntimeBuildBuff[]; - }; -}; - -export type InventoryUseEffect = { - hpRestore: number; - manaRestore: number; - cooldownReduction: number; - buildBuffs: RuntimeBuildBuff[]; -}; - -function getRarityMultiplier(rarity: RuntimeInventoryItemLike['rarity']) { - switch (rarity) { - case 'legendary': - return 2.4; - case 'epic': - return 1.9; - case 'rare': - return 1.55; - case 'uncommon': - return 1.2; - default: - return 1; - } -} - -export function isInventoryItemUsable(item: RuntimeInventoryItemLike) { - return ( - Boolean(item.useProfile) || - item.tags.includes('healing') || - item.tags.includes('mana') - ); -} - -export function resolveInventoryItemUseEffect( - item: RuntimeInventoryItemLike, - character: RuntimeCharacterLike, -): InventoryUseEffect | null { - if (!isInventoryItemUsable(item)) return null; - - if (item.useProfile) { - return { - hpRestore: item.useProfile.hpRestore ?? 0, - manaRestore: item.useProfile.manaRestore ?? 0, - cooldownReduction: item.useProfile.cooldownReduction ?? 0, - buildBuffs: item.useProfile.buildBuffs ?? [], - }; - } - - const rarityMultiplier = getRarityMultiplier(item.rarity); - const hasHealing = - item.tags.includes('healing') || - /药|包|补给|恢复|疗伤|meat|apple|mushroom|water/i.test(item.name); - const hasMana = - item.tags.includes('mana') || - /灵液|法力|mana|crystal|essence|spirit/i.test(item.name); - - const hpRestore = hasHealing - ? Math.max( - 10, - Math.round((14 + character.attributes.spirit * 1.4) * rarityMultiplier), - ) - : 0; - const manaRestore = hasMana - ? Math.max( - 8, - Math.round( - (12 + character.attributes.intelligence * 1.4) * rarityMultiplier, - ), - ) - : 0; - const cooldownReduction = /凝神|回气|醒神|booster|essence/i.test(item.name) - ? 1 - : 0; - - if (hpRestore <= 0 && manaRestore <= 0 && cooldownReduction <= 0) { - return null; - } - - return { - hpRestore, - manaRestore, - cooldownReduction, - buildBuffs: [], - }; -} - -export function buildInventoryUseResultText( - item: RuntimeInventoryItemLike, - effect: InventoryUseEffect, -) { - const parts = [ - effect.hpRestore > 0 ? `恢复 ${effect.hpRestore} 点气血` : null, - effect.manaRestore > 0 ? `恢复 ${effect.manaRestore} 点灵力` : null, - effect.cooldownReduction > 0 - ? `额外推进 ${effect.cooldownReduction} 回合冷却` - : null, - effect.buildBuffs.length > 0 - ? `获得 ${effect.buildBuffs.map((buff) => buff.name).join('、')}` - : null, - ].filter(Boolean); - - return `你取出${item.name}立刻使用,${parts.join(',')}。`; -} diff --git a/server-node/src/modules/runtime/runtimeNarrativeMemory.ts b/server-node/src/modules/runtime/runtimeNarrativeMemory.ts deleted file mode 100644 index c7560d0a..00000000 --- a/server-node/src/modules/runtime/runtimeNarrativeMemory.ts +++ /dev/null @@ -1,88 +0,0 @@ -function dedupeStrings(values: Array, limit = 16) { - return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))] - .slice(-limit); -} - -type RuntimeStoryFingerprint = { - relatedScarIds?: string[]; - relatedThreadIds?: string[]; - visibleClue?: string | null; -}; - -type RuntimeInventoryItemLike = { - id: string; - runtimeMetadata?: { - storyFingerprint?: RuntimeStoryFingerprint | null; - } | null; -}; - -type RuntimeStoryEngineMemoryLike = { - discoveredFactIds: string[]; - inferredFactIds?: string[]; - activeThreadIds: string[]; - resolvedScarIds: string[]; - recentCarrierIds: string[]; -}; - -type RuntimeGameStateLike = { - storyEngineMemory?: RuntimeStoryEngineMemoryLike | null; -}; - -function createEmptyStoryEngineMemoryState(): RuntimeStoryEngineMemoryLike { - return { - discoveredFactIds: [], - activeThreadIds: [], - resolvedScarIds: [], - recentCarrierIds: [], - }; -} - -export function appendStoryEngineCarrierMemory< - TState extends RuntimeGameStateLike, - TItem extends RuntimeInventoryItemLike, ->(state: TState, items: TItem[]) { - const storyEngineMemory = - state.storyEngineMemory ?? createEmptyStoryEngineMemoryState(); - const carriers = items.filter((item) => item.runtimeMetadata?.storyFingerprint); - if (carriers.length <= 0) { - return { - ...state, - storyEngineMemory, - }; - } - - const recentCarrierIds = dedupeStrings( - [...storyEngineMemory.recentCarrierIds, ...carriers.map((item) => item.id)], - 8, - ); - const scarIds = carriers.flatMap( - (item) => item.runtimeMetadata?.storyFingerprint?.relatedScarIds ?? [], - ); - const threadIds = carriers.flatMap( - (item) => item.runtimeMetadata?.storyFingerprint?.relatedThreadIds ?? [], - ); - const visibleClues = carriers.flatMap((item) => { - const clue = item.runtimeMetadata?.storyFingerprint?.visibleClue; - return clue ? [clue] : []; - }); - - return { - ...state, - storyEngineMemory: { - ...storyEngineMemory, - recentCarrierIds, - resolvedScarIds: dedupeStrings( - [...storyEngineMemory.resolvedScarIds, ...scarIds], - 10, - ), - activeThreadIds: dedupeStrings( - [...storyEngineMemory.activeThreadIds, ...threadIds], - 8, - ), - discoveredFactIds: dedupeStrings( - [...storyEngineMemory.discoveredFactIds, ...visibleClues], - 24, - ), - }, - }; -} diff --git a/server-node/src/modules/runtime/runtimeNpcStatePrimitives.test.ts b/server-node/src/modules/runtime/runtimeNpcStatePrimitives.test.ts deleted file mode 100644 index d0e22424..00000000 --- a/server-node/src/modules/runtime/runtimeNpcStatePrimitives.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; - -import { - markNpcFirstMeaningfulContactResolved, - normalizeNpcPersistentState, -} from './runtimeNpcStatePrimitives.js'; -import { appendStoryEngineCarrierMemory } from './runtimeNarrativeMemory.js'; - -test('runtime npc state primitives normalize arrays, relation state and stance defaults on the server', () => { - const normalized = normalizeNpcPersistentState({ - affinity: 18, - recruited: false, - revealedFacts: ['thread:a', 1, null], - knownAttributeRumors: ['力量偏盛', false], - seenBackstoryChapterIds: ['past-1', 2], - stanceProfile: null, - }); - - assert.equal(normalized.relationState.stance, 'neutral'); - assert.deepEqual(normalized.revealedFacts, ['thread:a']); - assert.deepEqual(normalized.knownAttributeRumors, ['力量偏盛']); - assert.deepEqual(normalized.seenBackstoryChapterIds, ['past-1']); - assert.equal(normalized.firstMeaningfulContactResolved, false); - assert.equal(normalized.stanceProfile.currentConflictTag, null); -}); - -test('runtime npc state primitives can mark first meaningful contact as resolved locally on the server', () => { - const nextState = markNpcFirstMeaningfulContactResolved({ - affinity: 64, - recruited: false, - revealedFacts: [], - knownAttributeRumors: [], - seenBackstoryChapterIds: [], - firstMeaningfulContactResolved: false, - }); - - assert.equal(nextState.firstMeaningfulContactResolved, true); - assert.equal(nextState.relationState.stance, 'bonded'); -}); - -test('runtime narrative memory appends carrier facts without depending on src/services/storyEngine/echoMemory', () => { - const nextState = appendStoryEngineCarrierMemory( - { - storyEngineMemory: { - discoveredFactIds: ['clue:old'], - activeThreadIds: ['thread:old'], - resolvedScarIds: [], - recentCarrierIds: [], - }, - }, - [ - { - id: 'carrier-1', - runtimeMetadata: { - storyFingerprint: { - relatedScarIds: ['scar:one'], - relatedThreadIds: ['thread:new'], - visibleClue: 'clue:new', - }, - }, - }, - ], - ); - - assert.deepEqual(nextState.storyEngineMemory.recentCarrierIds, ['carrier-1']); - assert.deepEqual(nextState.storyEngineMemory.resolvedScarIds, ['scar:one']); - assert.deepEqual(nextState.storyEngineMemory.activeThreadIds, [ - 'thread:old', - 'thread:new', - ]); - assert.deepEqual(nextState.storyEngineMemory.discoveredFactIds, [ - 'clue:old', - 'clue:new', - ]); -}); diff --git a/server-node/src/modules/runtime/runtimeNpcStatePrimitives.ts b/server-node/src/modules/runtime/runtimeNpcStatePrimitives.ts deleted file mode 100644 index 26ccbb2b..00000000 --- a/server-node/src/modules/runtime/runtimeNpcStatePrimitives.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { buildRelationState } from './runtimeStatePrimitives.js'; - -type RuntimeNpcStanceProfile = { - trust?: number; - warmth?: number; - ideologicalFit?: number; - fearOrGuard?: number; - loyalty?: number; - currentConflictTag?: string | null; - recentApprovals?: unknown; - recentDisapprovals?: unknown; -}; - -type RuntimeNpcPersistentStateLike = { - affinity: number; - recruited?: boolean; - relationState?: unknown; - revealedFacts?: unknown; - knownAttributeRumors?: unknown; - tradeStockSignature?: string | null; - firstMeaningfulContactResolved?: boolean; - seenBackstoryChapterIds?: unknown; - stanceProfile?: RuntimeNpcStanceProfile | null; -}; - -function clampStanceMetric(value: number) { - return Math.max(0, Math.min(100, Math.round(value))); -} - -function normalizeRecentStanceNotes(value: unknown) { - return Array.isArray(value) - ? value - .filter( - (item): item is string => typeof item === 'string' && item.trim().length > 0, - ) - .slice(-3) - : []; -} - -function buildInitialStanceProfile( - affinity: number, - options: { - recruited?: boolean; - } = {}, -) { - const recruitedBonus = options.recruited ? 14 : 0; - - return { - trust: clampStanceMetric(42 + affinity * 0.55 + recruitedBonus), - warmth: clampStanceMetric(36 + affinity * 0.5 + recruitedBonus), - ideologicalFit: clampStanceMetric(48 + affinity * 0.25), - fearOrGuard: clampStanceMetric(62 - affinity * 0.55), - loyalty: clampStanceMetric(24 + affinity * 0.35 + (options.recruited ? 26 : 0)), - currentConflictTag: null, - recentApprovals: [], - recentDisapprovals: [], - }; -} - -function normalizeStanceProfile( - stanceProfile: RuntimeNpcPersistentStateLike['stanceProfile'], - npcState: RuntimeNpcPersistentStateLike, -) { - if (!stanceProfile) { - return buildInitialStanceProfile(npcState.affinity, { - recruited: npcState.recruited, - }); - } - - return { - trust: clampStanceMetric(stanceProfile.trust ?? 40), - warmth: clampStanceMetric(stanceProfile.warmth ?? 35), - ideologicalFit: clampStanceMetric(stanceProfile.ideologicalFit ?? 45), - fearOrGuard: clampStanceMetric(stanceProfile.fearOrGuard ?? 55), - loyalty: clampStanceMetric(stanceProfile.loyalty ?? 20), - currentConflictTag: stanceProfile.currentConflictTag ?? null, - recentApprovals: normalizeRecentStanceNotes(stanceProfile.recentApprovals), - recentDisapprovals: normalizeRecentStanceNotes(stanceProfile.recentDisapprovals), - }; -} - -export function normalizeNpcPersistentState< - TNpcState extends RuntimeNpcPersistentStateLike, ->(npcState: TNpcState) { - return { - ...npcState, - relationState: buildRelationState(npcState.affinity), - revealedFacts: Array.isArray(npcState.revealedFacts) - ? npcState.revealedFacts.filter( - (fact): fact is string => typeof fact === 'string', - ) - : [], - knownAttributeRumors: Array.isArray(npcState.knownAttributeRumors) - ? npcState.knownAttributeRumors.filter( - (fact): fact is string => typeof fact === 'string', - ) - : [], - tradeStockSignature: npcState.tradeStockSignature ?? null, - firstMeaningfulContactResolved: - npcState.firstMeaningfulContactResolved ?? false, - seenBackstoryChapterIds: Array.isArray(npcState.seenBackstoryChapterIds) - ? npcState.seenBackstoryChapterIds.filter( - (fact): fact is string => typeof fact === 'string', - ) - : [], - stanceProfile: normalizeStanceProfile(npcState.stanceProfile, npcState), - }; -} - -export function markNpcFirstMeaningfulContactResolved< - TNpcState extends RuntimeNpcPersistentStateLike, ->(npcState: TNpcState) { - return normalizeNpcPersistentState({ - ...npcState, - firstMeaningfulContactResolved: true, - }); -} diff --git a/server-node/src/modules/runtime/runtimeSnapshotHydration.test.ts b/server-node/src/modules/runtime/runtimeSnapshotHydration.test.ts deleted file mode 100644 index 577e50e0..00000000 --- a/server-node/src/modules/runtime/runtimeSnapshotHydration.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { hydrateSavedSnapshot } from './runtimeSnapshotHydration.js'; - -test('runtime snapshot hydration normalizes server snapshots for frontend restore flows', () => { - const snapshot = hydrateSavedSnapshot({ - version: 2, - savedAt: '2026-04-09T00:00:00.000Z', - bottomTab: 'unknown-tab', - gameState: { - currentScene: 'Story', - worldType: 'WUXIA', - playerCharacter: { - id: 'hero', - title: '试剑客', - description: '在风里试探局势的人。', - personality: '谨慎而果断', - attributes: { - strength: 8, - spirit: 6, - }, - skills: [{ id: 'skill-1' }], - resourceProfile: { - maxHp: 150, - maxMana: 80, - }, - }, - playerHp: 180, - playerMaxHp: 120, - playerMana: 22, - playerMaxMana: 18, - playerEquipment: { - weapon: null, - armor: { - id: 'armor-1', - category: '护甲', - name: '试炼轻甲', - quantity: 1, - rarity: 'rare', - tags: ['armor'], - statProfile: { - maxHpBonus: 20, - }, - }, - relic: { - id: 'relic-1', - category: '饰品', - name: '回气坠', - quantity: 1, - rarity: 'rare', - tags: ['relic'], - statProfile: { - maxManaBonus: 15, - }, - }, - }, - quests: [ - { - id: 'quest-1', - title: '试炼委托', - summary: '完成一轮测试', - description: '完成一轮测试', - issuerNpcId: 'npc-1', - issuerNpcName: '引路人', - status: 'active', - rewardText: '完成后可领取测试奖励。', - reward: { - currency: 10, - experience: 0, - items: [], - }, - steps: [ - { - id: 'quest-1-step-1', - title: '完成一轮测试', - detail: '推进这条测试委托。', - kind: 'reach_scene', - targetSceneId: 'test-scene', - requiredCount: 1, - progress: 0, - }, - ], - }, - ], - roster: [ - { - npcId: 'npc-companion', - characterId: 'companion-a', - joinedAtAffinity: 8, - }, - ], - companions: [ - { - npcId: 'npc-companion', - characterId: 'companion-a', - joinedAtAffinity: 8, - }, - ], - npcStates: { - npc_guard: { - affinity: 12, - revealedFacts: ['fact:a', 1], - }, - }, - characterChats: { - companion_a: { - history: [ - { speaker: 'player', text: '最近风声不对。' }, - { speaker: 'npc', text: '这条不该留下。' }, - ], - summary: '已经建立起初步信任。', - }, - }, - }, - currentStory: { - text: '恢复中的故事', - options: [], - streaming: true, - }, - }); - - assert.ok(snapshot); - assert.equal(snapshot.bottomTab, 'adventure'); - assert.equal(snapshot.currentStory?.streaming, false); - assert.equal(snapshot.gameState.runtimeActionVersion, 0); - assert.equal(snapshot.gameState.playerMaxHp, 170); - assert.equal(snapshot.gameState.playerHp, 170); - assert.equal(snapshot.gameState.playerMaxMana, 95); - assert.equal(snapshot.gameState.playerMana, 22); - assert.equal(snapshot.gameState.playerCurrency, 160); - assert.equal(snapshot.gameState.playerProgression.level, 1); - assert.equal(snapshot.gameState.playerProgression.totalXp, 0); - assert.deepEqual(snapshot.gameState.roster, []); - assert.deepEqual(snapshot.gameState.storyEngineMemory.activeThreadIds, []); - assert.equal( - snapshot.gameState.storyEngineMemory.saveMigrationManifest?.version, - 'story-engine-v5', - ); - assert.deepEqual(snapshot.gameState.npcStates.npc_guard.revealedFacts, [ - 'fact:a', - ]); - assert.deepEqual(snapshot.gameState.characterChats.companion_a.history, [ - { - speaker: 'player', - text: '最近风声不对。', - }, - ]); -}); - -test('runtime snapshot hydration keeps custom world economy defaults on the server', () => { - const snapshot = hydrateSavedSnapshot({ - version: 2, - savedAt: '2026-04-09T00:00:00.000Z', - bottomTab: 'inventory', - gameState: { - worldType: 'CUSTOM', - customWorldProfile: { - ownedSettingLayers: { - ruleProfile: { - economyProfile: { - initialCurrency: 228, - }, - }, - }, - }, - }, - currentStory: null, - }); - - assert.ok(snapshot); - assert.equal(snapshot.bottomTab, 'inventory'); - assert.equal(snapshot.gameState.playerCurrency, 228); -}); - -test('runtime snapshot hydration backfills starter loadout when legacy saves omitted playerEquipment', () => { - const snapshot = hydrateSavedSnapshot({ - version: 2, - savedAt: '2026-04-09T00:00:00.000Z', - bottomTab: 'adventure', - gameState: { - currentScene: 'Story', - worldType: 'WUXIA', - playerCharacter: { - id: 'hero', - title: '试剑客', - description: '在风里试探局势的人。', - personality: '谨慎而果断', - attributes: { - strength: 8, - spirit: 6, - }, - skills: [], - }, - playerHp: 140, - playerMaxHp: 140, - playerMana: 60, - playerMaxMana: 60, - }, - currentStory: null, - }); - - assert.ok(snapshot); - assert.equal(snapshot.gameState.playerMaxHp, 208); - assert.equal(snapshot.gameState.playerMaxMana, 1009); - assert.equal( - snapshot.gameState.playerEquipment.weapon?.id, - 'starter:hero:weapon', - ); - assert.equal( - snapshot.gameState.playerEquipment.armor?.id, - 'starter:hero:armor', - ); - assert.equal( - snapshot.gameState.playerEquipment.relic?.id, - 'starter:hero:relic', - ); -}); diff --git a/server-node/src/modules/runtime/runtimeSnapshotHydration.ts b/server-node/src/modules/runtime/runtimeSnapshotHydration.ts deleted file mode 100644 index 7e81f92d..00000000 --- a/server-node/src/modules/runtime/runtimeSnapshotHydration.ts +++ /dev/null @@ -1,657 +0,0 @@ -import { jsonClone } from '../../http.js'; -import type { SavedSnapshot } from '../../repositories/runtimeRepository.js'; -import { normalizePlayerProgressionState } from '../progression/playerProgressionService.js'; -import { normalizeQuestEntries } from '../quest/questProgressionService.js'; -import { - createEmptyEquipmentLoadout, - getEquipmentBonuses, - getEquipmentSlotLabel, -} from './runtimeEquipmentModule.js'; -import { normalizeNpcPersistentState } from './runtimeNpcStatePrimitives.js'; - -type JsonRecord = Record; -type SnapshotShape = { - savedAt: string; - bottomTab: unknown; - gameState: unknown; - currentStory: unknown; -}; - -const STORY_ENGINE_MIGRATION_VERSION = 'story-engine-v5'; -const STORY_ENGINE_REQUIRED_TRANSFORMS = [ - 'ensure_story_engine_memory', - 'ensure_campaign_state', - 'ensure_player_style_profile', -]; -const UNIVERSAL_MAX_MANA = 999; -const EQUIPMENT_SLOTS = ['weapon', 'armor', 'relic'] as const; - -type RuntimeEquipmentSlotId = (typeof EQUIPMENT_SLOTS)[number]; -type LegacyCharacterEquipmentItem = { - slot: string; - item: string; - rarity?: string; -}; - -function isRecord(value: unknown): value is JsonRecord { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function readString(value: unknown, fallback = '') { - return typeof value === 'string' && value.trim() ? value.trim() : fallback; -} - -function readNumber(value: unknown, fallback = 0) { - return typeof value === 'number' && Number.isFinite(value) ? value : fallback; -} - -function readBoolean(value: unknown, fallback = false) { - return typeof value === 'boolean' ? value : fallback; -} - -function readArray(value: unknown) { - return Array.isArray(value) ? value : []; -} - -function clampNonNegativeInteger(value: unknown) { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return 0; - } - - return Math.max(0, Math.floor(value)); -} - -function normalizeBottomTab(value: unknown) { - return value === 'character' || value === 'inventory' ? value : 'adventure'; -} - -function buildSaveMigrationManifest() { - return { - version: 'story-engine-v5', - requiredTransforms: [ - 'ensure_story_engine_memory', - 'ensure_campaign_state', - 'ensure_player_style_profile', - ], - backwardCompatible: true, - }; -} - -function createEmptyStoryEngineMemoryState() { - return { - discoveredFactIds: [], - inferredFactIds: [], - activeThreadIds: [], - resolvedScarIds: [], - recentCarrierIds: [], - openedSceneChapterIds: [], - recentSignalIds: [], - recentCompanionReactions: [], - currentChapter: null, - currentJourneyBeatId: null, - currentJourneyBeat: null, - companionArcStates: [], - worldMutations: [], - chronicle: [], - factionTensionStates: [], - currentCampEvent: null, - currentSetpieceDirective: null, - continueGameDigest: null, - campaignState: null, - actState: null, - consequenceLedger: [], - companionResolutions: [], - endingState: null, - authorialConstraintPack: null, - branchBudgetStatus: null, - narrativeQaReport: null, - narrativeCodex: [], - playerStyleProfile: null, - simulationRunResults: [], - releaseGateReport: null, - saveMigrationManifest: { - version: STORY_ENGINE_MIGRATION_VERSION, - requiredTransforms: STORY_ENGINE_REQUIRED_TRANSFORMS, - backwardCompatible: true, - }, - }; -} - -function normalizeRuntimeStats( - stats: unknown, - options: { - isActiveRun?: boolean; - now?: number; - } = {}, -) { - const now = options.now ?? Date.now(); - const rawStats = isRecord(stats) ? stats : {}; - - return { - playTimeMs: - typeof rawStats.playTimeMs === 'number' && - Number.isFinite(rawStats.playTimeMs) - ? Math.max(0, rawStats.playTimeMs) - : 0, - lastPlayTickAt: options.isActiveRun ? new Date(now).toISOString() : null, - hostileNpcsDefeated: clampNonNegativeInteger(rawStats.hostileNpcsDefeated), - questsAccepted: clampNonNegativeInteger(rawStats.questsAccepted), - itemsUsed: clampNonNegativeInteger(rawStats.itemsUsed), - scenesTraveled: clampNonNegativeInteger(rawStats.scenesTraveled), - }; -} - -function normalizeCharacterChats(value: unknown) { - return Object.fromEntries( - Object.entries(isRecord(value) ? value : {}).map( - ([characterId, record]) => { - const rawRecord = isRecord(record) ? record : {}; - - return [ - characterId, - { - history: readArray(rawRecord.history) - .filter( - (turn) => - isRecord(turn) && - typeof turn.text === 'string' && - (turn.speaker === 'player' || turn.speaker === 'character'), - ) - .map((turn) => ({ - speaker: turn.speaker, - text: turn.text, - })), - summary: readString(rawRecord.summary), - updatedAt: readString(rawRecord.updatedAt) || null, - }, - ]; - }, - ), - ); -} - -function normalizeCompanionState(value: unknown) { - if (!isRecord(value)) { - return null; - } - - const npcId = readString(value.npcId); - if (!npcId) { - return null; - } - - return { - ...jsonClone(value), - npcId, - characterId: readString(value.characterId), - joinedAtAffinity: Math.round(readNumber(value.joinedAtAffinity, 0)), - }; -} - -function dedupeCompanions(value: unknown) { - const seenNpcIds = new Set(); - - return readArray(value) - .map((entry) => normalizeCompanionState(entry)) - .filter( - ( - entry, - ): entry is NonNullable> => { - if (!entry || seenNpcIds.has(entry.npcId)) { - return false; - } - - seenNpcIds.add(entry.npcId); - return true; - }, - ); -} - -function normalizeRoster( - roster: ReturnType, - companions: ReturnType, -) { - const activeNpcIds = new Set(companions.map((companion) => companion.npcId)); - - return roster.filter((companion) => !activeNpcIds.has(companion.npcId)); -} - -function normalizeNpcStates(value: unknown) { - return Object.fromEntries( - Object.entries(isRecord(value) ? value : {}).map(([npcId, npcState]) => { - const rawState = isRecord(npcState) ? npcState : {}; - - return [ - npcId, - normalizeNpcPersistentState({ - ...jsonClone(rawState), - affinity: Math.round(readNumber(rawState.affinity, 0)), - chattedCount: Math.max( - 0, - Math.round(readNumber(rawState.chattedCount, 0)), - ), - helpUsed: readBoolean(rawState.helpUsed), - giftsGiven: Math.max( - 0, - Math.round(readNumber(rawState.giftsGiven, 0)), - ), - inventory: jsonClone(readArray(rawState.inventory)), - recruited: readBoolean(rawState.recruited), - }), - ]; - }), - ); -} - -function resolveInitialPlayerCurrency(gameState: JsonRecord) { - const customWorldProfile = isRecord(gameState.customWorldProfile) - ? gameState.customWorldProfile - : null; - const customWorldInitialCurrency = readNumber( - (customWorldProfile?.ownedSettingLayers as JsonRecord | undefined) - ?.ruleProfile && - isRecord( - (customWorldProfile.ownedSettingLayers as JsonRecord).ruleProfile, - ) && - isRecord( - ( - (customWorldProfile.ownedSettingLayers as JsonRecord) - .ruleProfile as JsonRecord - ).economyProfile, - ) - ? ( - ( - (customWorldProfile.ownedSettingLayers as JsonRecord) - .ruleProfile as JsonRecord - ).economyProfile as JsonRecord - ).initialCurrency - : undefined, - Number.NaN, - ); - if (Number.isFinite(customWorldInitialCurrency)) { - return Math.max(0, Math.round(customWorldInitialCurrency)); - } - - return readString(gameState.worldType).toUpperCase() === 'XIANXIA' - ? 140 - : 160; -} - -function normalizeEquipmentLoadout(value: unknown) { - if (!isRecord(value)) { - return null; - } - - return { - weapon: isRecord(value.weapon) ? jsonClone(value.weapon) : null, - armor: isRecord(value.armor) ? jsonClone(value.armor) : null, - relic: isRecord(value.relic) ? jsonClone(value.relic) : null, - }; -} - -function normalizePresetRarity(rarityText: string | undefined) { - if (!rarityText) return 'common' as const; - if (/传说|legendary/iu.test(rarityText)) return 'legendary' as const; - if (/史诗|epic/iu.test(rarityText)) return 'epic' as const; - if (/稀有|rare/iu.test(rarityText)) return 'rare' as const; - if (/优秀|uncommon/iu.test(rarityText)) return 'uncommon' as const; - return 'common' as const; -} - -function inferEquipmentSlot(value: string) { - if (/武器|剑|弓|刀|拳套|战刃|佩刀|枪|刃/u.test(value)) { - return 'weapon' as const; - } - if (/护甲|甲|护臂|衣|袍|铠/u.test(value)) { - return 'armor' as const; - } - if (/饰品|护符|徽章|玉|珠|坠|铃|盘|令|匣/u.test(value)) { - return 'relic' as const; - } - return null; -} - -function inferEquipmentTags(slot: RuntimeEquipmentSlotId, name: string) { - const tags = new Set([slot]); - - if (/灵|气|符|珠|盘|玉/u.test(name)) tags.add('mana'); - if (/护|守|甲|铠/u.test(name)) tags.add('armor'); - if (/刃|剑|弓|刀|拳/u.test(name)) tags.add('weapon'); - if (/徽章|护符|坠|铃|盘|令/u.test(name)) tags.add('relic'); - if (/疗|愈|血/u.test(name)) tags.add('healing'); - - return [...tags]; -} - -function getLegacyCharacterEquipment( - character: JsonRecord, -): LegacyCharacterEquipmentItem[] { - const equipmentById: Record = { - 'sword-princess': [ - { slot: '武器', item: '王庭剑', rarity: '稀有' }, - { slot: '护甲', item: '王庭轻甲', rarity: '稀有' }, - { slot: '饰品', item: '皇室徽章', rarity: '史诗' }, - ], - 'archer-hero': [ - { slot: '武器', item: '流风弓', rarity: '稀有' }, - { slot: '护甲', item: '风行者皮甲', rarity: '稀有' }, - { slot: '饰品', item: '鹰眼石', rarity: '史诗' }, - ], - 'girl-hero': [ - { slot: '武器', item: '双影刃', rarity: '稀有' }, - { slot: '护甲', item: '疾影轻甲', rarity: '稀有' }, - { slot: '饰品', item: '敏捷徽章', rarity: '史诗' }, - ], - 'punch-hero': [ - { slot: '武器', item: '破军拳套', rarity: '稀有' }, - { slot: '护甲', item: '刚岩护甲', rarity: '稀有' }, - { slot: '饰品', item: '力量护符', rarity: '史诗' }, - ], - 'fighter-4': [ - { slot: '武器', item: '玄甲战刃', rarity: '稀有' }, - { slot: '护甲', item: '玄铁甲', rarity: '稀有' }, - { slot: '饰品', item: '守护徽章', rarity: '史诗' }, - ], - }; - - const characterId = readString(character.id); - if (equipmentById[characterId]) { - return equipmentById[characterId]; - } - - const characterName = readString(character.name, '旅人'); - return EQUIPMENT_SLOTS.map((slot) => ({ - slot: getEquipmentSlotLabel(slot), - item: { - weapon: `${characterName}的主手器`, - armor: `${characterName}的护身装`, - relic: `${characterName}的随身符`, - }[slot], - rarity: '普通', - })); -} - -function buildLegacyStarterEquipmentLoadout(character: JsonRecord) { - const characterId = readString(character.id, 'unknown'); - const loadout = createEmptyEquipmentLoadout(); - const starterEquipment = getLegacyCharacterEquipment(character); - - starterEquipment.forEach((equipmentItem, index) => { - const slot = - inferEquipmentSlot(`${equipmentItem.slot} ${equipmentItem.item}`) ?? - EQUIPMENT_SLOTS[index] ?? - null; - if (!slot || loadout[slot]) { - return; - } - - loadout[slot] = { - id: `starter:${characterId}:${slot}`, - category: getEquipmentSlotLabel(slot), - name: equipmentItem.item, - quantity: 1, - rarity: normalizePresetRarity(equipmentItem.rarity), - tags: inferEquipmentTags(slot, equipmentItem.item), - equipmentSlotId: slot, - }; - }); - - return loadout; -} - -function hasEquippedItems( - equipment: ReturnType, -) { - return Boolean(equipment?.weapon || equipment?.armor || equipment?.relic); -} - -function readCharacterAttributes(character: JsonRecord) { - return isRecord(character.attributes) ? character.attributes : {}; -} - -function getLegacyCharacterBaseMaxHp(character: JsonRecord) { - const attributes = readCharacterAttributes(character); - - return Math.max( - 120, - 90 + - readNumber(attributes.strength, 0) * 10 + - readNumber(attributes.spirit, 0) * 4, - ); -} - -function buildCharacterResourceProfile(character: JsonRecord) { - const resourceProfile = isRecord(character.resourceProfile) - ? character.resourceProfile - : null; - if ( - resourceProfile && - Number.isFinite(resourceProfile.maxHp) && - Number.isFinite(resourceProfile.maxMana) - ) { - return { - maxHp: Math.max(1, Math.round(readNumber(resourceProfile.maxHp, 1))), - maxMana: Math.max( - 1, - Math.round(readNumber(resourceProfile.maxMana, UNIVERSAL_MAX_MANA)), - ), - }; - } - - const source = [ - readString(character.title), - readString(character.description), - readString(character.personality), - ...readArray(character.combatTags).filter( - (tag): tag is string => typeof tag === 'string' && tag.trim().length > 0, - ), - ].join(' '); - const skills = readArray(character.skills); - const baseHp = /守|甲|拳|先锋|重击|护体/u.test(source) - ? 210 - : /远射|机动|快袭|游击/u.test(source) - ? 168 - : /法|符|阵|灵|术/u.test(source) - ? 176 - : 188; - - return { - maxHp: Math.max( - getLegacyCharacterBaseMaxHp(character), - baseHp + Math.min(18, skills.length * 4), - ), - maxMana: UNIVERSAL_MAX_MANA, - }; -} - -function normalizeSavedStory(currentStory: unknown) { - if (!isRecord(currentStory)) { - return null; - } - - return { - ...jsonClone(currentStory), - streaming: false, - }; -} - -function normalizeGameState(gameState: unknown) { - const rawState = isRecord(gameState) ? jsonClone(gameState) : {}; - const { playerEquipment: _rawPlayerEquipment, ...rawStateWithoutEquipment } = - rawState; - const playerCharacter = isRecord(rawState.playerCharacter) - ? rawState.playerCharacter - : null; - const companions = dedupeCompanions(rawState.companions); - const roster = normalizeRoster(dedupeCompanions(rawState.roster), companions); - const storyEngineMemory = { - ...createEmptyStoryEngineMemoryState(), - ...(isRecord(rawState.storyEngineMemory) - ? jsonClone(rawState.storyEngineMemory) - : {}), - saveMigrationManifest: buildSaveMigrationManifest(), - }; - const savedPlayerMaxHp = Math.max( - 1, - Math.round(readNumber(rawState.playerMaxHp, 1)), - ); - const savedPlayerMaxMana = Math.max( - 1, - Math.round(readNumber(rawState.playerMaxMana, 1)), - ); - const resolvedEquipment = - normalizeEquipmentLoadout(rawState.playerEquipment) ?? - (playerCharacter - ? buildLegacyStarterEquipmentLoadout(playerCharacter) - : null); - const baseResourceProfile = playerCharacter - ? buildCharacterResourceProfile(playerCharacter) - : null; - const basePlayerMaxHp = baseResourceProfile - ? hasEquippedItems(resolvedEquipment) - ? baseResourceProfile.maxHp - : Math.max(baseResourceProfile.maxHp, savedPlayerMaxHp) - : savedPlayerMaxHp; - const basePlayerMaxMana = baseResourceProfile - ? hasEquippedItems(resolvedEquipment) - ? baseResourceProfile.maxMana - : Math.max(baseResourceProfile.maxMana, savedPlayerMaxMana) - : savedPlayerMaxMana; - const normalizedCommonState = { - ...rawStateWithoutEquipment, - customWorldProfile: - isRecord(rawState.customWorldProfile) || - rawState.customWorldProfile === null - ? (rawState.customWorldProfile ?? null) - : null, - runtimeStats: normalizeRuntimeStats(rawState.runtimeStats, { - isActiveRun: Boolean( - rawState.playerCharacter && rawState.currentScene === 'Story', - ), - }), - playerProgression: normalizePlayerProgressionState( - rawState.playerProgression, - ), - storyEngineMemory, - chapterState: - rawState.chapterState ?? - (isRecord(storyEngineMemory.currentChapter) - ? storyEngineMemory.currentChapter - : null), - campaignState: - rawState.campaignState ?? - (isRecord(storyEngineMemory.campaignState) - ? storyEngineMemory.campaignState - : (storyEngineMemory.campaignState ?? null)), - activeScenarioPackId: - readString(rawState.activeScenarioPackId) || - readString( - (rawState.customWorldProfile as JsonRecord | null)?.scenarioPackId, - ) || - null, - activeCampaignPackId: - readString(rawState.activeCampaignPackId) || - readString( - (rawState.customWorldProfile as JsonRecord | null)?.campaignPackId, - ) || - null, - npcInteractionActive: readBoolean(rawState.npcInteractionActive), - playerCurrency: - typeof rawState.playerCurrency === 'number' && - Number.isFinite(rawState.playerCurrency) - ? Math.round(rawState.playerCurrency) - : resolveInitialPlayerCurrency(rawState), - quests: normalizeQuestEntries( - jsonClone(readArray(rawState.quests)) as Parameters< - typeof normalizeQuestEntries - >[0], - ), - roster, - companions, - npcStates: normalizeNpcStates(rawState.npcStates), - characterChats: normalizeCharacterChats(rawState.characterChats), - activeBuildBuffs: jsonClone(readArray(rawState.activeBuildBuffs)), - runtimeSessionId: readString(rawState.runtimeSessionId) || null, - runtimeActionVersion: - typeof rawState.runtimeActionVersion === 'number' && - Number.isFinite(rawState.runtimeActionVersion) - ? Math.round(rawState.runtimeActionVersion) - : 0, - }; - - if (!playerCharacter) { - return { - ...normalizedCommonState, - playerEquipment: createEmptyEquipmentLoadout(), - playerMaxHp: savedPlayerMaxHp, - playerHp: Math.max( - 0, - Math.min( - savedPlayerMaxHp, - Math.round(readNumber(rawState.playerHp, savedPlayerMaxHp)), - ), - ), - playerMaxMana: savedPlayerMaxMana, - playerMana: Math.max( - 0, - Math.min( - savedPlayerMaxMana, - Math.round(readNumber(rawState.playerMana, savedPlayerMaxMana)), - ), - ), - }; - } - - const stateWithResourceCaps = { - ...normalizedCommonState, - playerCharacter, - playerMaxHp: basePlayerMaxHp, - playerHp: Math.max( - 0, - Math.round(readNumber(rawState.playerHp, basePlayerMaxHp)), - ), - playerMaxMana: basePlayerMaxMana, - playerMana: Math.max( - 0, - Math.round(readNumber(rawState.playerMana, basePlayerMaxMana)), - ), - }; - - if (!resolvedEquipment) { - return stateWithResourceCaps; - } - - const equipmentBonuses = getEquipmentBonuses(resolvedEquipment); - const nextPlayerMaxHp = basePlayerMaxHp + equipmentBonuses.maxHpBonus; - const nextPlayerMaxMana = basePlayerMaxMana + equipmentBonuses.maxManaBonus; - - return { - ...stateWithResourceCaps, - playerEquipment: resolvedEquipment, - playerMaxHp: nextPlayerMaxHp, - playerHp: Math.min(nextPlayerMaxHp, stateWithResourceCaps.playerHp), - playerMaxMana: nextPlayerMaxMana, - playerMana: Math.min(nextPlayerMaxMana, stateWithResourceCaps.playerMana), - }; -} - -export function normalizeSavedSnapshotPayload( - snapshot: T, -) { - return { - ...snapshot, - bottomTab: normalizeBottomTab(snapshot.bottomTab), - gameState: normalizeGameState(snapshot.gameState), - currentStory: normalizeSavedStory(snapshot.currentStory), - }; -} - -export function hydrateSavedSnapshot( - snapshot: SavedSnapshot | null, -): SavedSnapshot | null { - if (!snapshot) { - return null; - } - - return normalizeSavedSnapshotPayload(snapshot); -} diff --git a/server-node/src/modules/runtime/runtimeStatePrimitives.test.ts b/server-node/src/modules/runtime/runtimeStatePrimitives.test.ts deleted file mode 100644 index 29a01472..00000000 --- a/server-node/src/modules/runtime/runtimeStatePrimitives.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; - -import { - addInventoryItems, - buildRelationState, - incrementGameRuntimeStats, - removeInventoryItem, -} from './runtimeStatePrimitives.js'; - -test('runtime state primitives merge stackable inventory items but preserve identity-sensitive items', () => { - const merged = addInventoryItems( - [ - { - id: 'potion-1', - category: '消耗品', - name: '疗伤丹', - quantity: 1, - rarity: 'uncommon', - tags: ['healing'], - }, - { - id: 'relic-1', - category: '专属物品', - name: '青铜令牌', - quantity: 1, - rarity: 'epic', - tags: ['relic'], - }, - ], - [ - { - id: 'potion-2', - category: '消耗品', - name: '疗伤丹', - quantity: 2, - rarity: 'uncommon', - tags: ['healing'], - }, - { - id: 'relic-2', - category: '专属物品', - name: '青铜令牌', - quantity: 1, - rarity: 'epic', - tags: ['relic'], - }, - ], - ); - - assert.equal( - merged.find((item) => item.name === '疗伤丹')?.quantity, - 3, - ); - assert.equal( - merged.filter((item) => item.name === '青铜令牌').length, - 2, - ); -}); - -test('runtime state primitives remove inventory quantity without leaving zero-count entries', () => { - const nextInventory = removeInventoryItem( - [ - { - id: 'potion-1', - category: '消耗品', - name: '疗伤丹', - quantity: 2, - rarity: 'uncommon', - tags: ['healing'], - }, - ], - 'potion-1', - 2, - ); - - assert.deepEqual(nextInventory, []); -}); - -test('runtime state primitives increment stats and resolve relation stances locally on the server', () => { - const nextStats = incrementGameRuntimeStats( - { - hostileNpcsDefeated: 1, - questsAccepted: 0, - itemsUsed: 2, - scenesTraveled: 3, - }, - { - questsAccepted: 2, - itemsUsed: -1, - scenesTraveled: 4, - }, - ); - - assert.deepEqual(nextStats, { - hostileNpcsDefeated: 1, - questsAccepted: 2, - itemsUsed: 2, - scenesTraveled: 7, - }); - assert.deepEqual(buildRelationState(-5), { - affinity: -5, - stance: 'hostile', - }); - assert.deepEqual(buildRelationState(18), { - affinity: 18, - stance: 'neutral', - }); - assert.deepEqual(buildRelationState(72), { - affinity: 72, - stance: 'bonded', - }); -}); diff --git a/server-node/src/modules/runtime/runtimeStatePrimitives.ts b/server-node/src/modules/runtime/runtimeStatePrimitives.ts deleted file mode 100644 index 4da90fef..00000000 --- a/server-node/src/modules/runtime/runtimeStatePrimitives.ts +++ /dev/null @@ -1,221 +0,0 @@ -type RuntimeInventoryBuildBuff = { - name: string; - durationTurns: number; - tags: string[]; -}; - -type RuntimeInventoryUseProfile = { - hpRestore?: number; - manaRestore?: number; - cooldownReduction?: number; - buildBuffs?: RuntimeInventoryBuildBuff[]; -}; - -type RuntimeInventoryItemLike = { - id: string; - category: string; - name: string; - quantity: number; - rarity?: string | null; - tags: string[]; - runtimeMetadata?: unknown; - equipmentSlotId?: unknown; - buildProfile?: unknown; - statProfile?: unknown; - attributeResonance?: unknown; - useProfile?: RuntimeInventoryUseProfile | null; -}; - -type RuntimeStatsLike = { - hostileNpcsDefeated: number; - questsAccepted: number; - itemsUsed: number; - scenesTraveled: number; -}; - -type RuntimeRelationState = { - affinity: number; - stance: 'hostile' | 'guarded' | 'neutral' | 'cooperative' | 'bonded'; -}; - -const RARITY_SCORES: Record = { - common: 1, - uncommon: 2, - rare: 3, - epic: 4, - legendary: 5, -}; - -function clampNonNegativeInteger(value: unknown) { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return 0; - } - - return Math.max(0, Math.floor(value)); -} - -function getRarityScore(rarity: string | null | undefined) { - if (!rarity) { - return 0; - } - - return RARITY_SCORES[rarity] ?? 0; -} - -function isIdentitySensitiveInventoryItem(item: RuntimeInventoryItemLike) { - return Boolean( - item.runtimeMetadata || - item.equipmentSlotId || - item.buildProfile || - item.statProfile || - item.attributeResonance || - item.category.includes('专属') || - item.rarity === 'epic' || - item.rarity === 'legendary', - ); -} - -function buildInventoryMergeKey(item: RuntimeInventoryItemLike) { - if (isIdentitySensitiveInventoryItem(item)) { - return `identity:${item.id}`; - } - - const buildBuffKey = (item.useProfile?.buildBuffs ?? []) - .map( - (buff) => - `${buff.name}:${buff.durationTurns}:${(buff.tags ?? []).join('|')}`, - ) - .join(','); - - return [ - item.category, - item.name, - item.rarity ?? '', - [...(item.tags ?? [])].sort().join('|'), - item.useProfile?.hpRestore ?? 0, - item.useProfile?.manaRestore ?? 0, - item.useProfile?.cooldownReduction ?? 0, - buildBuffKey, - ].join('::'); -} - -function mergeInventory(items: TItem[]) { - const merged = new Map(); - - for (const item of items) { - const key = buildInventoryMergeKey(item); - const existing = merged.get(key); - if (existing) { - merged.set(key, { - ...existing, - quantity: existing.quantity + item.quantity, - tags: [...new Set([...(existing.tags ?? []), ...(item.tags ?? [])])], - runtimeMetadata: - existing.runtimeMetadata ?? item.runtimeMetadata ?? null, - }); - continue; - } - - merged.set(key, { - ...item, - tags: [...new Set(item.tags ?? [])], - }); - } - - return [...merged.values()]; -} - -export function sortInventoryItems( - items: TItem[], -) { - return [...items].sort((left, right) => { - const rarityDiff = getRarityScore(right.rarity) - getRarityScore(left.rarity); - if (rarityDiff !== 0) { - return rarityDiff; - } - - const categoryDiff = left.category.localeCompare( - right.category, - 'zh-Hans-CN', - ); - if (categoryDiff !== 0) { - return categoryDiff; - } - - return left.name.localeCompare(right.name, 'zh-Hans-CN'); - }); -} - -export function addInventoryItems( - base: TItem[], - additions: TItem[], -) { - return sortInventoryItems(mergeInventory([...base, ...additions])); -} - -export function removeInventoryItem( - base: TItem[], - itemId: string, - quantity = 1, -) { - return sortInventoryItems( - base - .map((item) => - item.id === itemId - ? { - ...item, - quantity: Math.max(0, item.quantity - quantity), - } - : item, - ) - .filter((item) => item.quantity > 0), - ); -} - -export function incrementGameRuntimeStats( - stats: TStats, - increments: Partial< - Pick< - RuntimeStatsLike, - 'hostileNpcsDefeated' | 'questsAccepted' | 'itemsUsed' | 'scenesTraveled' - > - >, -) { - return { - ...stats, - hostileNpcsDefeated: - stats.hostileNpcsDefeated + - clampNonNegativeInteger(increments.hostileNpcsDefeated), - questsAccepted: - stats.questsAccepted + clampNonNegativeInteger(increments.questsAccepted), - itemsUsed: stats.itemsUsed + clampNonNegativeInteger(increments.itemsUsed), - scenesTraveled: - stats.scenesTraveled + - clampNonNegativeInteger(increments.scenesTraveled), - }; -} - -export function resolveRelationStance( - affinity: number, -): RuntimeRelationState['stance'] { - if (affinity < 0) { - return 'hostile'; - } - if (affinity < 15) { - return 'guarded'; - } - if (affinity < 30) { - return 'neutral'; - } - if (affinity < 60) { - return 'cooperative'; - } - return 'bonded'; -} - -export function buildRelationState(affinity: number): RuntimeRelationState { - return { - affinity, - stance: resolveRelationStance(affinity), - }; -} diff --git a/server-node/src/modules/runtime/runtimeTreasureTexts.ts b/server-node/src/modules/runtime/runtimeTreasureTexts.ts deleted file mode 100644 index 3486a50b..00000000 --- a/server-node/src/modules/runtime/runtimeTreasureTexts.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { formatCurrency } from './runtimeEconomyPrimitives.js'; - -type TreasureRewardItem = { - name: string; -}; - -type TreasureRewardLike = { - items: TreasureRewardItem[]; - hp: number; - mana: number; - currency: number; - storyHint?: string; -}; - -type TreasureEncounterLike = { - npcName: string; -}; - -type TreasureInteractionAction = 'inspect' | 'leave' | 'secure'; - -export function buildTreasureResultText( - encounter: TreasureEncounterLike, - action: TreasureInteractionAction, - reward?: TreasureRewardLike, - worldType?: string | null, -) { - if (action === 'leave') { - return `你暂时没有触碰 ${encounter.npcName},只是把它的异常位置和痕迹牢牢记下。`; - } - - const itemText = - reward?.items.length ? reward.items.map((item) => item.name).join('、') : '零散战利品'; - const restoreParts = [ - (reward?.hp ?? 0) > 0 ? `气血 +${reward?.hp ?? 0}` : null, - (reward?.mana ?? 0) > 0 ? `灵力 +${reward?.mana ?? 0}` : null, - ].filter(Boolean); - const restoreText = - restoreParts.length > 0 ? `,并恢复 ${restoreParts.join('、')}` : ''; - const currencyText = reward - ? `,另得 ${formatCurrency(reward.currency, worldType ?? null)}` - : ''; - const storyHint = reward?.storyHint ? ` ${reward.storyHint}` : ''; - - if (action === 'inspect') { - return `你仔细检查了 ${encounter.npcName},顺着现场痕迹拆开机关与伪装,最终收回 ${itemText}${currencyText}${restoreText}。${storyHint}`; - } - - return `你迅速收下了 ${encounter.npcName} 中最关键的收获:${itemText}${currencyText}。${storyHint}`; -} diff --git a/server-node/src/observability.test.ts b/server-node/src/observability.test.ts deleted file mode 100644 index 3741ecff..00000000 --- a/server-node/src/observability.test.ts +++ /dev/null @@ -1,286 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import type { AddressInfo } from 'node:net'; -import os from 'node:os'; -import path from 'node:path'; -import { Writable } from 'node:stream'; -import test from 'node:test'; - -import pino, { type Logger } from 'pino'; - -import { createApp } from './app.ts'; -import type { AppConfig } from './config.ts'; -import { createAppContext } from './server.ts'; -import { httpRequest } from './testHttp.ts'; - -type LogRecord = Record; - -function createTestConfig(testName: string): AppConfig { - const tempRoot = fs.mkdtempSync( - path.join(os.tmpdir(), `genarrative-server-node-${testName}-`), - ); - - return { - nodeEnv: 'test', - projectRoot: tempRoot, - publicDir: path.join(tempRoot, 'public'), - logsDir: path.join(tempRoot, 'logs'), - dataDir: path.join(tempRoot, 'data'), - rawEnv: {}, - databaseUrl: `pg-mem://genarrative-${testName}`, - serverAddr: ':0', - logLevel: 'silent', - editorApiEnabled: true, - assetsApiEnabled: true, - jwtSecret: 'test-secret', - jwtExpiresIn: '7d', - jwtIssuer: 'genarrative-server-node-test', - llm: { - baseUrl: 'https://example.invalid', - apiKey: '', - model: 'test-model', - }, - dashScope: { - baseUrl: 'https://example.invalid', - apiKey: '', - imageModel: 'test-image-model', - requestTimeoutMs: 1000, - }, - smsAuth: { - enabled: true, - provider: 'mock', - endpoint: 'dypnsapi.aliyuncs.com', - accessKeyId: '', - accessKeySecret: '', - signName: 'Test Sign', - templateCode: '100001', - templateParamKey: 'code', - countryCode: '86', - schemeName: '', - codeLength: 6, - codeType: 1, - validTimeSeconds: 300, - intervalSeconds: 60, - duplicatePolicy: 1, - caseAuthPolicy: 1, - returnVerifyCode: false, - mockVerifyCode: '123456', - maxSendPerPhonePerDay: 20, - maxSendPerIpPerHour: 30, - maxVerifyFailuresPerPhonePerHour: 12, - maxVerifyFailuresPerIpPerHour: 24, - captchaTtlSeconds: 180, - captchaTriggerVerifyFailuresPerPhone: 3, - captchaTriggerVerifyFailuresPerIp: 5, - blockPhoneFailureThreshold: 6, - blockIpFailureThreshold: 10, - blockPhoneDurationMinutes: 30, - blockIpDurationMinutes: 30, - }, - wechatAuth: { - enabled: true, - provider: 'mock', - appId: '', - appSecret: '', - authorizeEndpoint: 'https://open.weixin.qq.com/connect/qrconnect', - accessTokenEndpoint: 'https://api.weixin.qq.com/sns/oauth2/access_token', - userInfoEndpoint: 'https://api.weixin.qq.com/sns/userinfo', - callbackPath: '/api/auth/wechat/callback', - defaultRedirectPath: '/', - mockUserId: 'mock_wechat_user', - mockUnionId: 'mock_wechat_union', - mockDisplayName: '微信旅人', - mockAvatarUrl: '', - }, - authSession: { - accessCookieName: 'genarrative_access_session', - accessCookieTtlSeconds: 7200, - accessCookieSecure: false, - accessCookieSameSite: 'Lax', - accessCookiePath: '/', - refreshCookieName: 'genarrative_refresh_session', - refreshSessionTtlDays: 30, - refreshCookieSecure: false, - refreshCookieSameSite: 'Lax', - refreshCookiePath: '/api/auth', - }, - }; -} - -function createLogCollector() { - const records: LogRecord[] = []; - let buffer = ''; - - const destination = new Writable({ - write(chunk, _encoding, callback) { - buffer += chunk.toString('utf8'); - - let newlineIndex = buffer.indexOf('\n'); - while (newlineIndex >= 0) { - const line = buffer.slice(0, newlineIndex).trim(); - buffer = buffer.slice(newlineIndex + 1); - - if (line) { - records.push(JSON.parse(line) as LogRecord); - } - - newlineIndex = buffer.indexOf('\n'); - } - - callback(); - }, - }); - - return { - logger: pino( - { - level: 'info', - base: undefined, - timestamp: false, - }, - destination, - ) as Logger, - records, - }; -} - -async function withTestServer( - testName: string, - logger: Logger, - run: (options: { baseUrl: string }) => Promise, -) { - const context = await createAppContext(createTestConfig(testName)); - context.logger = logger; - const app = createApp(context); - const server = await new Promise((resolve) => { - const nextServer = app.listen(0, '127.0.0.1', () => resolve(nextServer)); - }); - - try { - const address = server.address() as AddressInfo; - return await run({ - baseUrl: `http://127.0.0.1:${address.port}`, - }); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => { - if (error) { - reject(error); - return; - } - resolve(); - }); - }); - await context.db.close(); - } -} - -async function waitForRecord( - records: LogRecord[], - predicate: (record: LogRecord) => boolean, - timeoutMs = 2000, -) { - const deadline = Date.now() + timeoutMs; - - while (Date.now() < deadline) { - const match = records.find(predicate); - if (match) { - return match; - } - await new Promise((resolve) => setTimeout(resolve, 20)); - } - - assert.fail('Timed out waiting for log record'); -} - -test('healthz echoes x-request-id and writes access log fields', async () => { - const { logger, records } = createLogCollector(); - - await withTestServer('observability-healthz', logger, async ({ baseUrl }) => { - const requestId = 'obs-healthz-request'; - const response = await httpRequest(`${baseUrl}/healthz`, { - headers: { - 'X-Request-Id': requestId, - }, - }); - const payload = (await response.json()) as { - ok: boolean; - service: string; - }; - - assert.equal(response.status, 200); - assert.equal(response.headers.get('x-request-id'), requestId); - assert.equal(payload.ok, true); - assert.equal(payload.service, 'genarrative-node-server'); - - const accessLog = await waitForRecord( - records, - (record) => - record.request_id === requestId && - record.path === '/healthz' && - record.status === 200, - ); - - assert.equal(accessLog.method, 'GET'); - assert.equal(accessLog.user_id, null); - assert.equal(accessLog.api_version, '2026-04-08'); - assert.equal(accessLog.route_version, '2026-04-08'); - assert.equal(accessLog.operation, 'health.check'); - assert.equal(typeof accessLog.latency_ms, 'number'); - }); -}); - -test('unauthorized request keeps request trace in error log and response header', async () => { - const { logger, records } = createLogCollector(); - - await withTestServer( - 'observability-unauthorized', - logger, - async ({ baseUrl }) => { - const requestId = 'obs-unauthorized-request'; - const response = await httpRequest(`${baseUrl}/api/auth/me`, { - headers: { - 'X-Request-Id': requestId, - }, - }); - const payload = (await response.json()) as { - error: { - message: string; - }; - }; - - assert.equal(response.status, 401); - assert.equal(response.headers.get('x-request-id'), requestId); - assert.equal(payload.error.message, '缺少 Authorization Bearer Token'); - - const errorLog = await waitForRecord( - records, - (record) => - record.msg === 'request failed' && record.request_id === requestId, - ); - - assert.equal(errorLog.user_id, null); - assert.equal( - (errorLog.err as { message?: string } | undefined)?.message, - '缺少 Authorization Bearer Token', - ); - assert.equal(errorLog.api_version, '2026-04-08'); - assert.equal(errorLog.route_version, '2026-04-08'); - assert.equal(errorLog.operation, 'auth.me'); - - const accessLog = await waitForRecord( - records, - (record) => - record.request_id === requestId && - record.path === '/api/auth/me' && - record.status === 401, - ); - - assert.equal(accessLog.method, 'GET'); - assert.equal(accessLog.api_version, '2026-04-08'); - assert.equal(accessLog.route_version, '2026-04-08'); - assert.equal(accessLog.operation, 'auth.me'); - assert.equal(typeof accessLog.latency_ms, 'number'); - }, - ); -}); diff --git a/server-node/src/prompts/characterAssetPrompts.ts b/server-node/src/prompts/characterAssetPrompts.ts deleted file mode 100644 index f0f608a9..00000000 --- a/server-node/src/prompts/characterAssetPrompts.ts +++ /dev/null @@ -1,279 +0,0 @@ -import { - buildMasterPrompt, - buildVideoActionPrompt, - getActionTemplateById, -} from '../../../packages/shared/src/prompts/qwenSprite.js'; - -/** - * 角色资产正式 prompt 主源。 - * - * 这份脚本当前只承担“正式模型 prompt 层”职责: - * - buildNpcVisualPrompt - * - buildNpcAnimationPrompt - * - buildArkCharacterAnimationPrompt - * - buildImageSequencePrompt - * - * 当前仓库状态需要特别区分: - * - 当前自定义世界角色资产工坊默认输入框,实际直接使用前端 - * src/prompts/customWorldRolePromptDefaults.ts - * - 默认描述文本的唯一主源已经统一为前端本地映射, - * 不再保留后端独立 bundle 编译接口 - * - 当前正式角色主图与动作生成,仍然走本文件里的正式 prompt builder - */ -function clampPromptSeedText(value: unknown, maxLength: number) { - if (typeof value !== 'string') { - return ''; - } - - return value.replace(/\s+/gu, ' ').trim().slice(0, maxLength); -} - -function sanitizeAnimationPromptText(value: string, maxLength: number) { - return value - .replace(/\s+/gu, ' ') - .replace(/血浆|喷血|鲜血|断肢|斩首|裸体|裸露|色情|性交/gu, '') - .replace(/死亡|死去|击杀/gu, '倒地结束') - .replace(/受击|受伤/gu, '失衡') - .replace(/砍杀|斩击/gu, '挥击') - .trim() - .slice(0, maxLength); -} - -function buildCompactAnimationCharacterBrief(value: string) { - const normalized = sanitizeAnimationPromptText(value, 160); - if (!normalized) { - return ''; - } - - return normalized - .split(/[/|\n,,。;;]+/u) - .map((item) => item.trim()) - .filter(Boolean) - .slice(0, 4) - .join(','); -} - - -/** - * 正式角色主图 prompt 编译入口。 - * - * 输入的 promptText 通常是资产工坊输入框里的“形象描述”文本; - * 这里会把它和角色摘要合并,再交给共享层 buildMasterPrompt, - * 产出真正发给图像模型的正式 prompt。 - * - * 因此: - * - promptText = 默认描述文本层 - * - buildNpcVisualPrompt 返回值 = 正式图像生成 prompt 层 - */ -export function buildNpcVisualPrompt(promptText: string, characterBriefText = '') { - const mergedBrief = [characterBriefText.trim(), promptText.trim()] - .filter(Boolean) - .join('\n'); - - return buildMasterPrompt( - mergedBrief || '自定义世界角色,服装完整,姿态自然。', - ); -} - -/** - * 正式角色主图生成的负向提示词。 - * - * 只服务于图像生成请求,不参与默认描述文本生成。 - */ -export function buildNpcVisualNegativePrompt() { - return [ - '正面视角', - '左朝向', - '完全 90 度纯右视图', - '镜头透视', - '半身像', - '脚被裁切', - '头顶被裁切', - '多角色', - '复杂背景', - '建筑场景', - '漂浮物', - '烟雾环境', - '武器消失', - '武器换手', - '额外手臂', - '额外腿', - '服装变化', - '脸部变化', - '模糊', - '运动模糊', - '文字', - '水印', - 'UI 元素', - '软萌 Q版大头贴', - '儿童绘本风', - '厚涂插画感', - '低对比柔边', - ].join(','); -} - -/** - * 连续序列帧方案的正式动作 prompt。 - * - * 这是“图像序列帧”动作生成链路使用的正式 prompt, - * 不属于默认描述文本层。 - */ -export function buildImageSequencePrompt( - animation: string, - promptText: string, - frameCount: number, - useChromaKey: boolean, -) { - return [ - `同一角色连续 ${frameCount} 帧动作序列,动作主题是 ${animation}。`, - '固定机位,单人,全身,侧身朝右,保持同一套服装、发型、武器和体型。', - '帧间动作连续,姿态逐步推进,不要换人,不要跳变,不要多余物体。', - useChromaKey - ? '纯绿色背景,无地面装饰,方便后期抠像。' - : '背景尽量纯净,避免复杂场景。', - promptText.trim(), - ] - .filter(Boolean) - .join(' '); -} - -/** - * 通用动作视频方案的正式动作 prompt。 - * - * 输入的 promptText 是动作描述文本; - * 输出的是可以直接提交给动作模型的视频 prompt。 - * - * 当前仓库里它主要服务于非 Ark 的动作视频链路, - * 以及某些保留的动作生成策略。 - */ -export function buildNpcAnimationPrompt(options: { - animation: string; - promptText: string; - useChromaKey: boolean; - loop: boolean; - characterBriefText?: string; - actionTemplateId?: string; -}) { - const characterBrief = buildCompactAnimationCharacterBrief( - options.characterBriefText ?? '', - ); - const actionDetailText = sanitizeAnimationPromptText(options.promptText, 140); - const loopRule = options.loop - ? '这是循环动作,直接进入动作循环中段,不要开场静止站桩,不要把主参考图原样作为第一帧。' - : options.animation === 'die' - ? '这是死亡终结动作,首帧参考主图角色形象即可,尾帧停在死亡结束姿态,不要回到主图形象。' - : '这是非循环动作,首帧和尾帧都要回到参考主图角色形象,中段完成动作变化。'; - - if (options.actionTemplateId) { - return [ - buildVideoActionPrompt({ - actionTemplate: getActionTemplateById( - options.actionTemplateId as Parameters< - typeof getActionTemplateById - >[0], - ), - actionDetailText, - useChromaKey: options.useChromaKey, - characterBrief: characterBrief || `${options.animation} 动作角色`, - }), - loopRule, - ] - .filter(Boolean) - .join(' '); - } - - return [ - `单人 NPC 全身动作视频,动作主题是 ${options.animation}。`, - '角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。', - '动作连贯,避免服装、发型、面部、武器随机漂移。', - options.useChromaKey - ? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。' - : '背景简洁纯净,无复杂场景。', - characterBrief ? `角色设定:${characterBrief}` : '', - actionDetailText, - loopRule, - ] - .filter(Boolean) - .join(' '); -} - -/** - * Ark 图生视频动作链路的正式动作 prompt。 - * - * 当前自定义世界角色资产工坊的主动作生成流程, - * 最终会走到这个 builder。它会在共享模板的基础上, - * 叠加 Ark 所需的首帧 / 尾帧约束与动作英文名约束。 - */ -export function buildArkCharacterAnimationPrompt(options: { - animation: string; - promptText: string; - useChromaKey: boolean; - loop: boolean; - characterBriefText?: string; - actionTemplateId?: string; -}) { - const normalizedAnimationName = - options.animation.trim().replace(/\s+/gu, '_') || 'idle'; - const characterBrief = buildCompactAnimationCharacterBrief( - options.characterBriefText ?? '', - ); - const actionDetailText = sanitizeAnimationPromptText(options.promptText, 140); - const frameRule = options.loop - ? '首帧严格使用图片1,尾帧严格使用图片2,循环动作必须自然闭环,不要静止开场。' - : '首帧严格使用图片1,尾帧严格使用图片2,中段完成完整动作变化,收束干净。'; - - if (options.actionTemplateId) { - return [ - buildVideoActionPrompt({ - actionTemplate: getActionTemplateById( - options.actionTemplateId as Parameters[0], - ), - actionDetailText, - useChromaKey: options.useChromaKey, - characterBrief: characterBrief || `${normalizedAnimationName} action role`, - }), - `动作英文名:${normalizedAnimationName}。`, - frameRule, - ] - .filter(Boolean) - .join(' '); - } - - return [ - `单人 NPC 全身动作视频,动作英文名是 ${normalizedAnimationName}。`, - '角色固定为图片1和图片2中的同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。', - '动作连贯,避免服装、发型、面部、武器随机漂移,不要多角色,不要镜头切换。', - options.useChromaKey - ? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。' - : '背景简洁纯净,无复杂场景。', - characterBrief ? `角色设定:${characterBrief}` : '', - actionDetailText ? `动作细节:${actionDetailText}` : '', - frameRule, - ] - .filter(Boolean) - .join(' '); -} - -/** - * 当正式动作 prompt 触发审核或兼容问题时的保守兜底动作 prompt。 - * - * 这条链路是正式动作生成的降级方案,不参与默认描述文本生成。 - */ -export function buildFallbackModerationSafeAnimationPrompt(options: { - animation: string; - loop: boolean; - useChromaKey: boolean; -}) { - return [ - `单人全身角色动作视频,动作主题是 ${options.animation}。`, - '角色固定为同一人,右向斜侧身,镜头稳定,轮廓清楚。', - options.loop - ? '循环动作直接进入稳定循环,不要静止开场,不要定格首帧。' - : '非循环动作首尾回到角色标准站姿,中段完成动作变化。', - options.useChromaKey - ? '背景为纯绿色绿幕,无其他人物和场景元素。' - : '背景简洁纯净。', - ] - .filter(Boolean) - .join(' '); -} diff --git a/server-node/src/prompts/chatPromptBuilders.ts b/server-node/src/prompts/chatPromptBuilders.ts deleted file mode 100644 index 3dab3f01..00000000 --- a/server-node/src/prompts/chatPromptBuilders.ts +++ /dev/null @@ -1,621 +0,0 @@ -import type { - CharacterChatReplyRequest, - CharacterChatSuggestionsRequest, - CharacterChatSummaryRequest, - NpcChatDialogueRequest, - NpcChatTurnRequest, - NpcRecruitDialogueRequest, -} from '../../../packages/shared/src/contracts/rpgRuntimeChat.js'; - -type JsonRecord = Record; - -export const CHARACTER_PANEL_CHAT_SYSTEM_PROMPT = `你是像素动作 RPG 里的同行角色。 -只回复这名角色此刻会对玩家说的话。 -不要输出角色名、引号、旁白、动作提示、Markdown、JSON 或解释。 -保持人设,结合最近剧情和关系变化,回复简洁自然。`; - -export const CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT = `生成恰好 3 条玩家回复建议。 -只输出纯文本,共 3 行,每行一条。 -不要加编号、项目符号、Markdown 或额外说明。 -三条建议语气要有区分:关心、追问、轻松或拉近关系。`; - -export const CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT = `总结玩家与这名角色之间不断变化的关系。 -只输出一段简洁文字。 -包含当前关系气氛、态度变化,以及最近聊天里最重要的新信息、承诺、担忧或线索。`; - -export const NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT = `你是角色扮演 RPG 的角色对话编剧。 -你只能输出纯中文对话正文,不能输出解释、代码、markdown、JSON 或额外说明。 - -硬性规则: -- 每一行都必须严格以“你:”或“角色名字:”开头。 -- 总行数控制在 4 到 6 行。 -- 玩家和对方至少各说 2 次。 -- 这段内容只是聊天,不是做决定。 -- 如果当前要求是“由 NPC 主动开口”,第一行必须是“角色名字:”开头,且第一句先是自然招呼或开场判断。 -- 如果当前不是“由 NPC 主动开口”,第一行必须是“你:”开头。 -- 如果这是双方第一次真正接触,对方第一次开口必须先是自然招呼或开场判断,不能写成第三人称占位旁白。 -- 禁止在聊天里主动引导、建议、安排或预告交易、招募、切磋、战斗、送礼、求助、离开、继续前进、切换场景等其他 function。 -- 禁止把情报直接写成对玩家的指令。 -- 结束时要让玩家感觉到气氛、情报或关系发生了变化,但变化仍停留在聊天层面。`; - -export const NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT = `你是角色扮演 RPG 的招募剧情对话编剧。 -你只能输出纯中文对话正文,不能输出解释、代码、markdown、JSON 或额外说明。 - -硬性规则: -- 每一行都必须严格以“你:”或“角色名字:”开头。 -- 第一行必须是“你:”开头。 -- 总行数控制在 4 到 6 行。 -- 玩家和对方至少各说 2 次。 -- 这段对话的目标是把“邀请对方入队”自然谈成。 -- 不允许出现拒绝入队、继续观望、以后再说、条件未满足等结果。 -- 不允许出现“我不能答应”“我还没想好”“再让我考虑”“暂时不行”“以后再说”这类拒绝或拖延表述。 -- 最后一行必须由对方明确答应加入队伍。`; - -export const NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT = `你是角色扮演 RPG 里的当前 NPC。 -你只输出这名 NPC 此刻会对玩家说的一轮回复。 -只输出纯中文口语回复正文,不要输出角色名、引号、旁白、动作描写、Markdown、JSON 或解释。 -- 如果这是第一次真正接触中的首轮回复,第一句必须先用自然招呼或开场判断起手,不能写成第三人称占位旁白。 -回复长度控制在 1 到 3 句,必须紧接玩家刚说的话,自然推进气氛、情报或关系。`; - -export const NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT = `你要为玩家生成下一轮可直接点击的 3 条聊天续写候选。 -只输出纯文本,共 3 行,每行 1 条。 -不要加编号、项目符号、Markdown、JSON 或额外说明。 -三条候选必须明显不同,分别体现继续追问、表达态度、轻微拉近关系这三种不同方向。`; - -function asRecord(value: unknown): JsonRecord | null { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as JsonRecord) - : null; -} - -function readString(value: unknown) { - return typeof value === 'string' && value.trim() ? value.trim() : null; -} - -function readNumber(value: unknown, fallback = 0) { - return typeof value === 'number' && Number.isFinite(value) ? value : fallback; -} - -function readBoolean(value: unknown, fallback = false) { - return typeof value === 'boolean' ? value : fallback; -} - -function readStringArray(value: unknown) { - return Array.isArray(value) - ? value - .map((item) => readString(item)) - .filter((item): item is string => Boolean(item)) - : []; -} - -function describeFirstContactRelationStance(value: unknown) { - const stance = readString(value); - switch (stance) { - case 'guarded': - return '戒备试探'; - case 'neutral': - return '正常交流但仍不熟'; - case 'cooperative': - return '已有善意,先确认合作节奏'; - case 'bonded': - return '明显信任,但仍是第一次正式对上人'; - default: - return '第一次真正接触'; - } -} - -function describeWorld(worldType: string) { - switch (worldType) { - case 'WUXIA': - return '边城模板'; - case 'XIANXIA': - return '灵潮模板'; - case 'CUSTOM': - return '自定义世界'; - default: - return worldType || '未知世界'; - } -} - -function describeStats(label: string, record: JsonRecord | null) { - const hp = readNumber(record?.hp); - const maxHp = Math.max(1, readNumber(record?.maxHp, hp)); - const mana = readNumber(record?.mana); - const maxMana = Math.max(1, readNumber(record?.maxMana, mana)); - - return `${label}生命 ${hp}/${maxHp},灵力 ${mana}/${maxMana}`; -} - -function describeCharacter(label: string, value: unknown) { - const record = asRecord(value); - const name = readString(record?.name) ?? '未知角色'; - const title = readString(record?.title) ?? '未知称号'; - const description = readString(record?.description) ?? '暂无额外描述'; - const personality = readString(record?.personality) ?? '性格信息未显式提供'; - - return [ - `${label}姓名:${name}`, - `${label}称号:${title}`, - `${label}描述:${description}`, - `${label}性格:${personality}`, - ].join('\n'); -} - -function describeStoryHistory(history: unknown) { - if (!Array.isArray(history) || history.length === 0) { - return '近期剧情:暂无。'; - } - - const lines = history - .slice(-4) - .map((item) => readString(asRecord(item)?.text)) - .filter((item): item is string => Boolean(item)); - - return lines.length > 0 - ? ['近期剧情:', ...lines.map((line) => `- ${line}`)].join('\n') - : '近期剧情:暂无。'; -} - -function describeConversationHistory(history: unknown) { - if (!Array.isArray(history) || history.length === 0) { - return '聊天记录:暂无。'; - } - - const lines = history - .slice(-12) - .map((item) => { - const record = asRecord(item); - const speaker = readString(record?.speaker) === 'player' ? '玩家' : '角色'; - const text = readString(record?.text); - - return text ? `- ${speaker}:${text}` : null; - }) - .filter((item): item is string => Boolean(item)); - - return lines.length > 0 - ? ['聊天记录:', ...lines].join('\n') - : '聊天记录:暂无。'; -} - -function describeNpcConversationHistory(history: unknown, npcName: string) { - if (!Array.isArray(history) || history.length === 0) { - return '当前聊天记录:暂无。'; - } - - const lines = history - .slice(-10) - .map((item) => { - const record = asRecord(item); - const speaker = readString(record?.speaker); - const speakerName = readString(record?.speakerName); - const text = readString(record?.text); - if (!text) return null; - - if (speaker === 'player') { - return `- 玩家:${text}`; - } - - if (speaker === 'npc') { - return `- ${speakerName ?? npcName}:${text}`; - } - - if (speaker === 'system') { - return `- 系统提示:${text}`; - } - - return `- ${speakerName ?? '同伴'}:${text}`; - }) - .filter((item): item is string => Boolean(item)); - - return lines.length > 0 - ? ['当前聊天记录:', ...lines].join('\n') - : '当前聊天记录:暂无。'; -} - -function describeNpcCombatContext(combatContext: unknown) { - const record = asRecord(combatContext); - const summary = readString(record?.summary); - const battleOutcome = readString(record?.battleOutcome); - const logLines = readStringArray(record?.logLines).slice(0, 6); - if (!summary && logLines.length === 0) { - return null; - } - - const outcomeText = - battleOutcome === 'spar_complete' - ? '切磋刚刚结束。' - : battleOutcome === 'victory' - ? '战斗刚刚分出胜负。' - : null; - - return [ - '刚刚结束的交锋:', - outcomeText, - summary ? `- 结果摘要:${summary}` : null, - ...(logLines.length > 0 - ? ['- 战斗日志:', ...logLines.map((line) => ` - ${line}`)] - : []), - ] - .filter(Boolean) - .join('\n'); -} - -function describeSceneContext(context: unknown) { - const record = asRecord(context); - const sceneName = readString(record?.sceneName) ?? '当前区域'; - const sceneDescription = - readString(record?.sceneDescription) ?? '周围气氛仍未完全安定。'; - const inBattle = record?.inBattle === true ? '战斗中' : '非战斗'; - const customWorldProfile = asRecord(record?.customWorldProfile); - const customWorldName = readString(customWorldProfile?.name); - const customWorldSummary = readString(customWorldProfile?.summary); - - return [ - `世界补充:${customWorldName ?? '无'}`, - customWorldSummary ? `世界摘要:${customWorldSummary}` : null, - `场景:${sceneName}`, - `场景描述:${sceneDescription}`, - `当前状态:${inBattle}`, - describeStats('玩家', record), - ] - .filter(Boolean) - .join('\n'); -} - -function describeTargetStatus(status: unknown) { - const record = asRecord(status); - const roleLabel = readString(record?.roleLabel) ?? '同行角色'; - const affinity = record?.affinity; - - return [ - `对方身份:${roleLabel}`, - describeStats('对方', record), - typeof affinity === 'number' ? `当前好感:${affinity}` : null, - ] - .filter(Boolean) - .join('\n'); -} - -function describeEncounter(encounter: unknown) { - const record = asRecord(encounter); - const npcName = readString(record?.npcName) ?? '眼前角色'; - const contextText = - readString(record?.context) ?? - readString(record?.npcDescription) ?? - '你们正在当前遭遇里继续对话。'; - - return { - npcName, - block: [`当前对象:${npcName}`, `对象背景:${contextText}`].join('\n'), - }; -} - -function describeMonsters(monsters: unknown) { - if (!Array.isArray(monsters) || monsters.length === 0) { - return '当前敌对目标:无。'; - } - - const lines = monsters - .slice(0, 4) - .map((item) => { - const record = asRecord(item); - const name = - readString(record?.name) ?? - readString(record?.npcName) ?? - readString(record?.id); - const hp = readNumber(record?.hp); - const maxHp = Math.max(1, readNumber(record?.maxHp, hp)); - - return name ? `- ${name}(生命 ${hp}/${maxHp})` : null; - }) - .filter((item): item is string => Boolean(item)); - - return lines.length > 0 - ? ['当前敌对目标:', ...lines].join('\n') - : '当前敌对目标:无。'; -} - -function describeTargetCharacterName(payload: { - targetCharacter?: unknown; - encounter?: unknown; -}) { - return ( - readString(asRecord(payload.targetCharacter)?.name) ?? - readString(asRecord(payload.encounter)?.npcName) ?? - '对方' - ); -} - -export function buildCharacterPanelChatPrompt( - payload: CharacterChatReplyRequest, -) { - const targetName = describeTargetCharacterName(payload); - - return [ - `世界:${describeWorld(payload.worldType)}`, - describeSceneContext(payload.context), - describeCharacter('玩家 / ', payload.playerCharacter), - describeCharacter('对方 / ', payload.targetCharacter), - describeTargetStatus(payload.targetStatus), - describeStoryHistory(payload.storyHistory), - payload.conversationSummary - ? `之前聊天摘要:${payload.conversationSummary}` - : '之前聊天摘要:暂无。', - describeConversationHistory(payload.conversationHistory), - `玩家刚刚对 ${targetName} 说:${payload.playerMessage}`, - `现在请以 ${targetName} 的身份,直接回复玩家。`, - ] - .filter(Boolean) - .join('\n\n'); -} - -export function buildCharacterPanelChatSuggestionPrompt( - payload: CharacterChatSuggestionsRequest, -) { - const targetName = describeTargetCharacterName(payload); - const latestCharacterReply = Array.isArray(payload.conversationHistory) - ? [...payload.conversationHistory] - .reverse() - .map((item) => asRecord(item)) - .find((record) => readString(record?.speaker) === 'character') - : null; - const latestReplyText = readString(latestCharacterReply?.text); - - return [ - `世界:${describeWorld(payload.worldType)}`, - describeSceneContext(payload.context), - describeCharacter('玩家 / ', payload.playerCharacter), - describeCharacter('对方 / ', payload.targetCharacter), - describeTargetStatus(payload.targetStatus), - describeStoryHistory(payload.storyHistory), - payload.conversationSummary - ? `之前聊天摘要:${payload.conversationSummary}` - : '之前聊天摘要:暂无。', - describeConversationHistory(payload.conversationHistory), - latestReplyText - ? `角色刚刚的回复:${latestReplyText}` - : `玩家正准备与 ${targetName} 开始一段新的私聊。`, - `请围绕当前气氛,为玩家生成 3 条可以直接发送给 ${targetName} 的简短回复候选。`, - ] - .filter(Boolean) - .join('\n\n'); -} - -export function buildCharacterPanelChatSummaryPrompt( - payload: CharacterChatSummaryRequest, -) { - const targetName = describeTargetCharacterName(payload); - - return [ - `世界:${describeWorld(payload.worldType)}`, - describeSceneContext(payload.context), - describeCharacter('玩家 / ', payload.playerCharacter), - describeCharacter('对方 / ', payload.targetCharacter), - describeTargetStatus(payload.targetStatus), - describeStoryHistory(payload.storyHistory), - payload.previousSummary - ? `旧摘要:${payload.previousSummary}` - : '旧摘要:暂无。', - describeConversationHistory(payload.conversationHistory), - `请把玩家与 ${targetName} 的旧摘要和最新聊天整理成一段更新后的关系摘要。`, - ] - .filter(Boolean) - .join('\n\n'); -} - -function buildNpcDialoguePromptBase( - payload: NpcChatDialogueRequest | NpcChatTurnRequest | NpcRecruitDialogueRequest, -) { - const encounter = describeEncounter(payload.encounter); - const character = - (payload as NpcChatTurnRequest).character ?? - (payload as NpcChatTurnRequest).player; - if (!(payload as NpcChatTurnRequest).character && character) { - (payload as NpcChatTurnRequest).character = character; - } - - return [ - `世界:${describeWorld(payload.worldType)}`, - describeSceneContext(payload.context), - describeCharacter('玩家 / ', payload.character), - encounter.block, - describeMonsters(payload.monsters), - describeStoryHistory(payload.history), - ] - .filter(Boolean) - .join('\n\n'); -} - -export function buildStrictNpcChatDialoguePrompt( - payload: NpcChatDialogueRequest, -) { - const encounter = describeEncounter(payload.encounter); - const context = asRecord(payload.context); - const openingCampBackground = readString(context?.openingCampBackground); - const openingCampDialogue = readString(context?.openingCampDialogue); - const allowedTopics = readStringArray(context?.encounterAllowedTopics); - const blockedTopics = readStringArray(context?.encounterBlockedTopics); - const isFirstMeaningfulContact = readBoolean( - context?.isFirstMeaningfulContact, - false, - ); - const npcInitiatesConversation = readBoolean( - payload.npcInitiatesConversation, - false, - ); - const firstContactRelationStance = describeFirstContactRelationStance( - context?.firstContactRelationStance, - ); - - return [ - buildNpcDialoguePromptBase(payload), - openingCampBackground ? `营地开场背景:${openingCampBackground}` : null, - openingCampDialogue ? `刚刚发生的第一段对话:${openingCampDialogue}` : null, - isFirstMeaningfulContact - ? `当前接触阶段:第一次真正接触(${firstContactRelationStance})。对方第一次开口必须先给一句自然招呼或开场判断,再进入眼前话题。` - : null, - isFirstMeaningfulContact - ? '禁止写成“某人看着你,像是在等你把话接下去”这类第三人称占位旁白,也不要用系统说明代替对白。' - : null, - npcInitiatesConversation - ? `当前要求:由 ${encounter.npcName} 主动开口。第一行必须是“${encounter.npcName}:”,不要先替玩家说话。` - : '当前要求:玩家先挑起这段话,第一行必须是“你:”。', - allowedTopics.length > 0 - ? `当前更适合谈的内容:${allowedTopics.join('、')}` - : null, - blockedTopics.length > 0 - ? `当前避免直接说破:${blockedTopics.join('、')}` - : null, - `当前聊天主题:${payload.topic}`, - payload.resultSummary - ? `这段聊天希望带来的变化:${payload.resultSummary}` - : '这段聊天要让气氛、情报或关系出现一层新的变化。', - `请围绕“${payload.topic}”写一段刚刚发生的对话。必须只输出对白正文,每一行都必须以“你:”或“${encounter.npcName}:”开头。`, - ] - .filter(Boolean) - .join('\n\n'); -} - -export function buildNpcRecruitDialoguePrompt( - payload: NpcRecruitDialogueRequest, -) { - const encounter = describeEncounter(payload.encounter); - - return [ - buildNpcDialoguePromptBase(payload), - `玩家邀请:${payload.invitationText}`, - payload.recruitSummary - ? `招募补充条件:${payload.recruitSummary}` - : '这轮对话已经具备自然邀请对方入队的条件。', - '这是一段“邀请对方入队”的对话。请让几轮交流逐步导向成功加入队伍,不要写出拒绝、观望或延期答复。', - `最后一行必须由 ${encounter.npcName} 明确答应加入队伍。`, - ] - .filter(Boolean) - .join('\n\n'); -} - -export function buildNpcChatTurnReplyPrompt( - payload: NpcChatTurnRequest, -) { - const encounter = describeEncounter(payload.encounter); - const context = asRecord(payload.context); - const npcState = asRecord(payload.npcState); - const chatDirective = asRecord(payload.chatDirective); - const conversationHistory = - Array.isArray(payload.conversationHistory) && payload.conversationHistory.length > 0 - ? payload.conversationHistory - : payload.dialogue ?? payload.conversationHistory ?? []; - const openingCampBackground = readString(context?.openingCampBackground); - const openingCampDialogue = readString(context?.openingCampDialogue); - const allowedTopics = readStringArray(context?.encounterAllowedTopics); - const blockedTopics = readStringArray(context?.encounterBlockedTopics); - const isFirstMeaningfulContact = readBoolean( - context?.isFirstMeaningfulContact, - false, - ); - const affinity = readNumber(npcState?.affinity, 0); - const chattedCount = readNumber(npcState?.chattedCount, 0); - const limitReason = readString(chatDirective?.limitReason); - const turnLimit = Math.max(0, readNumber(chatDirective?.turnLimit, 0)); - const remainingTurns = Math.max(0, readNumber(chatDirective?.remainingTurns, 0)); - const closingMode = readString(chatDirective?.closingMode); - const isLimitedNegativeAffinityChat = - limitReason === 'negative_affinity' && turnLimit > 0; - const isForeshadowCloseTurn = - closingMode === 'foreshadow_close' || - readBoolean(chatDirective?.forceExitAfterTurn, false); - const hasNpcReplyInHistory = conversationHistory.some((item) => { - const turn = asRecord(item); - return readString(turn?.speaker) === 'npc'; - }); - const npcInitiatesConversation = readBoolean( - payload.npcInitiatesConversation, - false, - ); - const isFirstNpcSpokenTurn = - isFirstMeaningfulContact && !hasNpcReplyInHistory && chattedCount <= 0; - const firstContactRelationStance = describeFirstContactRelationStance( - context?.firstContactRelationStance, - ); - const playerMessage = payload.playerMessage.trim(); - const combatContextBlock = describeNpcCombatContext(payload.combatContext); - - return [ - buildNpcDialoguePromptBase(payload), - describeNpcConversationHistory(conversationHistory, encounter.npcName), - combatContextBlock, - openingCampBackground ? `营地开场背景:${openingCampBackground}` : null, - openingCampDialogue ? `刚刚发生的第一段对话:${openingCampDialogue}` : null, - `当前关系值:${affinity}`, - `已聊天轮次:${chattedCount}`, - isFirstNpcSpokenTurn - ? `当前接触阶段:第一次真正接触(${firstContactRelationStance})。这是这次聊天里 ${encounter.npcName} 第一次真正对玩家开口。` - : null, - isFirstNpcSpokenTurn - ? '第一句必须先用一句自然招呼或开场判断起手,再顺着玩家刚刚的话往下接。' - : null, - isFirstNpcSpokenTurn - ? '不要写成“某人看着你,像是在等你把话接下去”这类第三人称占位旁白,也不要把整轮写成设定说明。' - : null, - npcInitiatesConversation - ? `当前要求:这是 ${encounter.npcName} 主动开口的第一句,不要假装玩家已经先说过话。` - : null, - allowedTopics.length > 0 - ? `当前更适合先谈:${allowedTopics.join('、')}` - : null, - blockedTopics.length > 0 - ? `当前避免直接说破:${blockedTopics.join('、')}` - : null, - isLimitedNegativeAffinityChat - ? `当前相遇属于负好感主角色有限聊天,本次总上限 ${turnLimit} 轮。` - : null, - isLimitedNegativeAffinityChat - ? `在你回复完这一轮之后,还剩 ${remainingTurns} 轮可以继续聊。` - : null, - isLimitedNegativeAffinityChat && !isForeshadowCloseTurn - ? '语气可以戒备、冷淡、带刺,但不要立刻转成开战,也不要把对话硬掐死。' - : null, - isForeshadowCloseTurn - ? '这是最后一轮回复。必须带有收束感,但不能只用“别问了”“滚开”之类的话把聊天粗暴截断。' - : null, - isForeshadowCloseTurn - ? '最后一轮必须抛出能推动后续剧情的明确铺垫,例如威胁、线索、条件、去处、人物、未说完的真相或下一步悬念。' - : null, - isForeshadowCloseTurn - ? '回复后这轮聊天会结束,所以不要邀请继续闲聊,也不要直接宣布已经开战。' - : null, - npcInitiatesConversation - ? '玩家此刻还没有先说话,请直接写 NPC 主动开口时会说的第一轮回复。' - : `玩家刚刚说:${playerMessage}`, - npcInitiatesConversation - ? `现在请只写 ${encounter.npcName} 主动开口时会说的话。` - : `现在请只写 ${encounter.npcName} 这一轮会回复玩家的话。`, - ] - .filter(Boolean) - .join('\n\n'); -} - -export function buildNpcChatTurnSuggestionPrompt( - payload: NpcChatTurnRequest, - npcReply: string, -) { - const encounter = describeEncounter(payload.encounter); - const conversationHistory = - Array.isArray(payload.conversationHistory) && payload.conversationHistory.length > 0 - ? payload.conversationHistory - : payload.dialogue ?? payload.conversationHistory ?? []; - const combatContextBlock = describeNpcCombatContext(payload.combatContext); - - return [ - buildNpcDialoguePromptBase(payload), - describeNpcConversationHistory(conversationHistory, encounter.npcName), - combatContextBlock, - `玩家刚刚说:${payload.playerMessage}`, - `NPC 刚刚回复:${npcReply}`, - `请围绕刚刚这轮对话,为玩家生成 3 条下一轮可以直接说出口的中文接话短句。`, - '每条都必须像玩家台词,不能写成行为描述、语气说明或策略建议。', - '每条都必须控制在 20 个字以内,不要加序号、引号、括号或解释。', - ] - .filter(Boolean) - .join('\n\n'); -} diff --git a/server-node/src/prompts/customWorldAgentPrompts.ts b/server-node/src/prompts/customWorldAgentPrompts.ts deleted file mode 100644 index ce20fcd7..00000000 --- a/server-node/src/prompts/customWorldAgentPrompts.ts +++ /dev/null @@ -1,57 +0,0 @@ -export const FOUNDATION_JSON_ONLY_SYSTEM_PROMPT = `你是严格的世界草稿 JSON 生成器。 -只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。`; - -export const FOUNDATION_JSON_REPAIR_SYSTEM_PROMPT = `你是 JSON 修复器。 -你会收到一段本应为单个 JSON 对象的文本。 -你的唯一任务是把它修复成能被 JSON.parse 直接解析的单个 JSON 对象。 -不要输出 Markdown、代码块、解释、注释或额外文字。`; - -export const CUSTOM_WORLD_AGENT_CHARACTER_EXPANSION_SYSTEM_PROMPT = - '你负责为当前游戏世界底稿补 1 到 3 个新角色。只能输出 JSON 数组,不要输出任何额外说明。'; - -export const CUSTOM_WORLD_AGENT_LANDMARK_EXPANSION_SYSTEM_PROMPT = - '你负责为当前游戏世界底稿补 1 到 3 个新地点。只能输出 JSON 数组,不要输出任何额外说明。'; - -export function buildCustomWorldAgentCharacterExpansionPrompt(params: { - worldName: string; - worldSummary: string; - creatorIntentSummary: string; - anchorSummary: string; - existingNames: string[]; - count: number; - promptSeed: string; -}) { - return [ - `当前世界:${params.worldName}`, - `世界摘要:${params.worldSummary}`, - `创作意图摘要:${params.creatorIntentSummary}`, - `参考锚点:${params.anchorSummary}`, - `已有角色:${params.existingNames.join('、') || '暂无'}`, - `数量:${params.count}`, - `补充要求:${params.promptSeed || '没有额外要求,围绕当前底稿自然扩展。'}`, - '返回 JSON 数组。每个对象字段只允许包含:name, role, publicMask, hiddenHook, relationToPlayer, summary, threadIds。', - 'threadIds 必须优先引用现有线程 id。', - ].join('\n'); -} - -export function buildCustomWorldAgentLandmarkExpansionPrompt(params: { - worldName: string; - worldSummary: string; - creatorIntentSummary: string; - anchorSummary: string; - existingNames: string[]; - count: number; - promptSeed: string; -}) { - return [ - `当前世界:${params.worldName}`, - `世界摘要:${params.worldSummary}`, - `创作意图摘要:${params.creatorIntentSummary}`, - `参考锚点:${params.anchorSummary}`, - `已有地点:${params.existingNames.join('、') || '暂无'}`, - `数量:${params.count}`, - `补充要求:${params.promptSeed || '没有额外要求,围绕当前底稿自然扩展。'}`, - '返回 JSON 数组。每个对象字段只允许包含:name, purpose, mood, dangerLevel, secret, summary, threadIds, characterIds。', - 'threadIds / characterIds 必须优先引用现有对象 id。', - ].join('\n'); -} diff --git a/server-node/src/prompts/customWorldEntityPrompts.ts b/server-node/src/prompts/customWorldEntityPrompts.ts deleted file mode 100644 index 0d910e5c..00000000 --- a/server-node/src/prompts/customWorldEntityPrompts.ts +++ /dev/null @@ -1,249 +0,0 @@ -type ParsedRole = { - id: string; - name: string; - title: string; - role: string; - description: string; - visualDescription: string; - actionDescription: string; - sceneVisualDescription: string; - backstory: string; - personality: string; - motivation: string; - tags: string[]; -}; - -type ParsedLandmarkConnection = { - targetLandmarkId: string; - summary: string; - relativePosition: string; -}; - -type ParsedLandmark = { - id: string; - name: string; - description: string; - visualDescription: string; - dangerLevel: string; - sceneNpcIds: string[]; - connections: ParsedLandmarkConnection[]; -}; - -type ParsedProfile = { - name: string; - settingText: string; - summary: string; - tone: string; - playerGoal: string; - playableNpcs: ParsedRole[]; - storyNpcs: ParsedRole[]; - landmarks: ParsedLandmark[]; -}; - -export const CUSTOM_WORLD_ENTITY_GENERATOR_SYSTEM_PROMPT = - '你是游戏世界编辑器的实体生成器。你必须只返回可解析 JSON,不要输出解释、前言或 Markdown。'; - -function buildRoleReferenceText(roles: ParsedRole[], emptyText: string) { - if (roles.length === 0) { - return emptyText; - } - - return roles - .slice(0, 12) - .map( - (role, index) => - `${index + 1}. ${role.name} / ${role.title || role.role} / 身份:${ - role.role || '未写' - } / 描述:${role.description || '未写'} / 背景:${ - role.backstory || '未写' - } / 性格:${role.personality || '未写'} / 动机:${ - role.motivation || '未写' - } / 形象:${role.visualDescription || '未写'} / 动作表现:${ - role.actionDescription || '未写' - } / 场景画面:${role.sceneVisualDescription || '未写'} / 标签:${ - role.tags.join('、') || '暂无' - }`, - ) - .join('\n'); -} - -function buildLandmarkReferenceText(profile: ParsedProfile) { - if (profile.landmarks.length === 0) { - return '当前还没有场景设定。'; - } - - const storyNpcById = new Map(profile.storyNpcs.map((npc) => [npc.id, npc])); - const landmarkById = new Map( - profile.landmarks.map((landmark) => [landmark.id, landmark]), - ); - - return profile.landmarks - .slice(0, 12) - .map((landmark, index) => { - const sceneNpcNames = landmark.sceneNpcIds - .map((npcId) => storyNpcById.get(npcId)?.name ?? '') - .filter(Boolean) - .join('、'); - const connectionNames = landmark.connections - .map((connection) => { - const targetName = - landmarkById.get(connection.targetLandmarkId)?.name || - connection.targetLandmarkId; - return `${targetName}(${connection.relativePosition} / ${ - connection.summary || '无说明' - })`; - }) - .join('、'); - - return `${index + 1}. ${landmark.name} / 危险度:${ - landmark.dangerLevel || 'medium' - } / 描述:${landmark.description || '未写'} / 画面:${ - landmark.visualDescription || '未写' - } / 场景角色:${ - sceneNpcNames || '暂无' - } / 连接:${connectionNames || '暂无'}`; - }) - .join('\n'); -} - -export function buildPlayablePrompt(profile: ParsedProfile) { - return [ - `世界名:${profile.name}`, - `当前世界设定:${profile.settingText || profile.summary || '未提供额外设定。'}`, - `世界摘要:${profile.summary || '未填写'}`, - `世界基调:${profile.tone || '未填写'}`, - `玩家主线目标:${profile.playerGoal || '未填写'}`, - `当前可扮演角色设定:\n${buildRoleReferenceText(profile.playableNpcs, '当前还没有可扮演角色。')}`, - `当前场景角色设定:\n${buildRoleReferenceText(profile.storyNpcs, '当前还没有场景角色。')}`, - `当前场景设定:\n${buildLandmarkReferenceText(profile)}`, - '请基于上面全部上下文,生成 1 名新的“可扮演角色”。', - '要求:', - '- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复定位。', - '- 必须保留明确的协作价值、成长空间和入队理由。', - '- 不要生成泛用模板角色,必须让角色与当前世界的具体势力、地点、冲突或禁忌发生绑定。', - '- visualDescription 只写与角色设定相关的外形、服装、材质、武器、体态、色彩和识别特征,禁止写角色以外的周边环境等与角色不想管的设定。', - '- actionDescription 只写这个角色的动作表现与战斗气质,不要写镜头切换或参数。', - '- sceneVisualDescription 只写该角色首次登场或主要活动区域的场景画面感,不要写“提示词”字样。', - '- 只返回 JSON,不要输出解释或 Markdown。', - 'JSON 结构:', - '{', - ' "playableNpc": {', - ' "name": "角色名",', - ' "title": "称号",', - ' "role": "身份",', - ' "description": "一句到两句定位描述",', - ' "visualDescription": "角色形象描述",', - ' "actionDescription": "动作表现描述",', - ' "sceneVisualDescription": "角色关联场景画面描述",', - ' "backstory": "背景经历",', - ' "personality": "性格特点",', - ' "motivation": "当前动机",', - ' "combatStyle": "战斗风格",', - ' "initialAffinity": 22,', - ' "relationshipHooks": ["关系钩子1", "关系钩子2", "关系钩子3"],', - ' "tags": ["标签1", "标签2", "标签3"],', - ' "publicSummary": "公开背景摘要",', - ' "chapterTeasers": ["表层来意", "旧事裂痕", "隐藏执念", "最终底牌"],', - ' "chapterContents": ["对应正文1", "对应正文2", "对应正文3", "对应正文4"],', - ' "skills": [', - ' { "name": "技能1", "summary": "说明", "style": "风格" },', - ' { "name": "技能2", "summary": "说明", "style": "风格" },', - ' { "name": "技能3", "summary": "说明", "style": "风格" }', - ' ],', - ' "initialItems": [', - ' { "name": "物品1", "category": "武器", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },', - ' { "name": "物品2", "category": "专属物品", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },', - ' { "name": "物品3", "category": "消耗品", "quantity": 2, "rarity": "uncommon", "description": "说明", "tags": ["标签"] }', - ' ]', - ' }', - '}', - ].join('\n'); -} - -export function buildStoryPrompt(profile: ParsedProfile) { - return [ - `世界名:${profile.name}`, - `当前世界设定:${profile.settingText || profile.summary || '未提供额外设定。'}`, - `世界摘要:${profile.summary || '未填写'}`, - `世界基调:${profile.tone || '未填写'}`, - `玩家主线目标:${profile.playerGoal || '未填写'}`, - `当前可扮演角色设定:\n${buildRoleReferenceText(profile.playableNpcs, '当前还没有可扮演角色。')}`, - `当前场景角色设定:\n${buildRoleReferenceText(profile.storyNpcs, '当前还没有场景角色。')}`, - `当前场景设定:\n${buildLandmarkReferenceText(profile)}`, - '请基于上面全部上下文,生成 1 名新的“场景角色”。', - '要求:', - '- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复定位。', - '- 必须像能直接进入游戏的场景角色,而不是抽象设定条目。', - '- 角色应与具体场景、关系链或局势变化发生绑定。', - '- visualDescription 只写与角色设定匹配的外形、服装、材质、武器、体态、色彩和识别特征,不要写“提示词”、镜头参数或构图规则。', - '- actionDescription 只写这个角色的动作表现与战斗气质,不要写镜头切换或参数。', - '- sceneVisualDescription 只写该角色首次登场或主要活动区域的场景画面感,不要写“提示词”字样。', - '- 只返回 JSON,不要输出解释或 Markdown。', - 'JSON 结构:', - '{', - ' "storyNpc": {', - ' "name": "角色名",', - ' "title": "称号",', - ' "role": "身份",', - ' "description": "一句到两句定位描述",', - ' "visualDescription": "角色形象描述",', - ' "actionDescription": "动作表现描述",', - ' "sceneVisualDescription": "角色关联场景画面描述",', - ' "backstory": "背景经历",', - ' "personality": "性格特点",', - ' "motivation": "当前动机",', - ' "combatStyle": "战斗风格",', - ' "initialAffinity": 6,', - ' "relationshipHooks": ["关系钩子1", "关系钩子2", "关系钩子3"],', - ' "tags": ["标签1", "标签2", "标签3"],', - ' "publicSummary": "公开背景摘要",', - ' "chapterTeasers": ["表层来意", "旧事裂痕", "隐藏执念", "最终底牌"],', - ' "chapterContents": ["对应正文1", "对应正文2", "对应正文3", "对应正文4"],', - ' "skills": [', - ' { "name": "技能1", "summary": "说明", "style": "风格" },', - ' { "name": "技能2", "summary": "说明", "style": "风格" },', - ' { "name": "技能3", "summary": "说明", "style": "风格" }', - ' ],', - ' "initialItems": [', - ' { "name": "物品1", "category": "武器", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },', - ' { "name": "物品2", "category": "专属物品", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },', - ' { "name": "物品3", "category": "消耗品", "quantity": 2, "rarity": "uncommon", "description": "说明", "tags": ["标签"] }', - ' ]', - ' }', - '}', - ].join('\n'); -} - -export function buildLandmarkPrompt(profile: ParsedProfile) { - return [ - `世界名:${profile.name}`, - `当前世界设定:${profile.settingText || profile.summary || '未提供额外设定。'}`, - `世界摘要:${profile.summary || '未填写'}`, - `世界基调:${profile.tone || '未填写'}`, - `玩家主线目标:${profile.playerGoal || '未填写'}`, - `当前可扮演角色设定:\n${buildRoleReferenceText(profile.playableNpcs, '当前还没有可扮演角色。')}`, - `当前场景角色设定:\n${buildRoleReferenceText(profile.storyNpcs, '当前还没有场景角色。')}`, - `当前场景设定:\n${buildLandmarkReferenceText(profile)}`, - '请基于上面全部上下文,生成 1 个新的“场景”。', - '要求:', - '- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复。', - '- 必须给出适合出现在这个新场景里的 sceneNpcNames,且只能从已有场景角色里选择至少 3 个名字。', - '- 必须给出 connections,且 targetLandmarkName 只能引用已有场景名,不要连向自己。', - '- visualDescription 只写这个场景的空间层次、地面、主体建筑或自然景观、氛围、色彩和可见装置,不要写“提示词”、镜头参数或构图规则。', - '- 只返回 JSON,不要输出解释或 Markdown。', - 'JSON 结构:', - '{', - ' "landmark": {', - ' "name": "场景名",', - ' "description": "场景描述",', - ' "visualDescription": "场景画面描述",', - ' "dangerLevel": "low|medium|high|extreme",', - ' "sceneNpcNames": ["场景角色1", "场景角色2", "场景角色3"],', - ' "connections": [', - ' { "targetLandmarkName": "已有场景名", "relativePosition": "forward", "summary": "通路说明" },', - ' { "targetLandmarkName": "已有场景名", "relativePosition": "inside", "summary": "通路说明" }', - ' ]', - ' }', - '}', - ].join('\n'); -} diff --git a/server-node/src/prompts/customWorldOrchestratorPrompts.ts b/server-node/src/prompts/customWorldOrchestratorPrompts.ts deleted file mode 100644 index e2f99a32..00000000 --- a/server-node/src/prompts/customWorldOrchestratorPrompts.ts +++ /dev/null @@ -1,61 +0,0 @@ -export const CUSTOM_WORLD_GENERATION_JSON_ONLY_SYSTEM_PROMPT = `你是严格的自定义世界 JSON 生成器。 -只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。`; - -export const CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT = `你是 JSON 修复器。 -你会收到一段本应为单个 JSON 对象的文本。 -你的唯一任务是把它修复成能被 JSON.parse 直接解析的单个 JSON 对象。 -不要输出 Markdown、代码块、解释、注释或额外文字。 -尽量保留原始语义,只修复格式问题;必要时可以补齐缺失的引号、逗号、括号、数组闭合或缺失字段。`; - -export function buildCustomWorldProfilePrompt(params: { - generationSeedText: string; - creatorIntentText?: string; - generationMode: string; - targets: { - playableCount: number; - storyCount: number; - landmarkCount: number; - }; -}) { - return [ - '请根据创作者输入,生成一个可直接进入游戏的自定义世界档案 JSON。', - '必须严格输出单个 JSON 对象,不要 Markdown,不要解释。', - '', - `生成模式:${params.generationMode}`, - `可扮演角色数量:${params.targets.playableCount}`, - `场景角色数量:${params.targets.storyCount}`, - `关键场景数量:${params.targets.landmarkCount}`, - '', - '创作者输入:', - params.generationSeedText, - params.creatorIntentText ? `\n结构化创作锚点:\n${params.creatorIntentText}` : '', - '', - '输出 JSON 字段要求:', - '- name, subtitle, summary, tone, playerGoal, templateWorldType', - '- majorFactions: string[],coreConflicts: string[]', - '- camp: { name, description, dangerLevel }', - '- playableNpcs: 每项包含 name,title,role,description,backstory,personality,motivation,combatStyle,initialAffinity,relationshipHooks,tags', - '- storyNpcs: 每项包含 name,title,role,description,backstory,personality,motivation,combatStyle,initialAffinity,relationshipHooks,tags', - '- landmarks: 每项包含 name,description,dangerLevel,sceneNpcNames,connections', - '- connections 每项包含 targetLandmarkName, relativePosition, summary,targetLandmarkName 必须指向本次输出的其他场景名', - '', - '约束:', - '- 所有世界观、角色、场景必须贴合创作者输入,不要套用通用武侠模板。', - '- 角色名字、势力名、场景名必须互相区分,避免重复。', - '- sceneNpcNames 只能引用 storyNpcs 中已经输出的 name。', - '- templateWorldType 只能是 WUXIA 或 XIANXIA。', - '- dangerLevel 使用 low、medium、high、extreme 之一。', - '- relativePosition 使用 forward、back、left、right、up、down、inside、outside 之一。', - '- 不要预生成物品档案;items 如需输出,必须为空数组。', - ] - .filter(Boolean) - .join('\n'); -} - -export function buildCustomWorldProfileRepairPrompt(responseText: string) { - return [ - '请修复下面的自定义世界 JSON。', - '只输出能被 JSON.parse 直接解析的单个 JSON 对象,不要解释。', - responseText, - ].join('\n\n'); -} diff --git a/server-node/src/prompts/customWorldPrompts.ts b/server-node/src/prompts/customWorldPrompts.ts deleted file mode 100644 index d1d4eb29..00000000 --- a/server-node/src/prompts/customWorldPrompts.ts +++ /dev/null @@ -1,645 +0,0 @@ -import type { - CustomWorldGenerationFramework, - CustomWorldGenerationLandmarkOutline, - CustomWorldGenerationRoleBatchStage, - CustomWorldGenerationRoleBatchType, - CustomWorldGenerationRoleOutline, - CustomWorldLandmark, - CustomWorldProfile, -} from '../modules/custom-world/runtimeTypes.js'; - -const MIN_CUSTOM_WORLD_LANDMARK_COUNT = 10; -const CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES = [15, 30, 60, 90] as const; -const CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS = [ - 'forward', - 'back', - 'left', - 'right', - 'north', - 'south', - 'east', - 'west', - 'up', - 'down', - 'inside', - 'outside', - 'portal', -] as const; - -function buildFrameworkSummaryText( - framework: CustomWorldGenerationFramework, - options: { - maxLandmarks?: number; - } = {}, -) { - const maxLandmarks = options.maxLandmarks ?? MIN_CUSTOM_WORLD_LANDMARK_COUNT; - const landmarkText = framework.landmarks - .slice(0, maxLandmarks) - .map( - (landmark) => - `${landmark.name}(${landmark.dangerLevel},${landmark.description})`, - ) - .join('、'); - - return [ - `世界:${framework.name}`, - `副标题:${framework.subtitle}`, - `世界概述:${framework.summary}`, - `世界基调:${framework.tone}`, - `玩家核心目标:${framework.playerGoal}`, - framework.majorFactions.length > 0 - ? `主要势力:${framework.majorFactions.join('、')}` - : '', - framework.coreConflicts.length > 0 - ? `核心冲突:${framework.coreConflicts.join('、')}` - : '', - `开局归处:${framework.camp.name}(${framework.camp.description})`, - landmarkText ? `关键场景:${landmarkText}` : '', - ] - .filter(Boolean) - .join('\n'); -} - -function buildLandmarkAppearanceLookup( - framework: CustomWorldGenerationFramework, -) { - const lookup = new Map(); - - framework.landmarks.forEach((landmark) => { - landmark.sceneNpcNames.forEach((npcName) => { - const key = npcName.trim(); - if (!key) { - return; - } - const current = lookup.get(key) ?? []; - if (!current.includes(landmark.name)) { - current.push(landmark.name); - } - lookup.set(key, current); - }); - }); - - return lookup; -} - -function buildRoleOutlinePromptLines( - roleBatch: CustomWorldGenerationRoleOutline[], - options: { - framework: CustomWorldGenerationFramework; - roleType: CustomWorldGenerationRoleBatchType; - }, -) { - const appearanceLookup = - options.roleType === 'story' - ? buildLandmarkAppearanceLookup(options.framework) - : new Map(); - - return roleBatch - .map((role) => { - const appearanceText = - options.roleType === 'story' - ? (appearanceLookup.get(role.name)?.join('、') ?? '未指定') - : ''; - return [ - `- ${role.name} / ${role.title}`, - `身份:${role.role}`, - `框架描述:${role.description}`, - `预设好感:${role.initialAffinity}`, - role.relationshipHooks.length > 0 - ? `关系切入口:${role.relationshipHooks.join('、')}` - : '', - role.tags.length > 0 ? `标签:${role.tags.join('、')}` : '', - appearanceText ? `出现场景:${appearanceText}` : '', - ] - .filter(Boolean) - .join(';'); - }) - .join('\n'); -} - -export function buildCustomWorldFrameworkPrompt(settingText: string) { - return [ - '请先根据下面的玩家设定创建一份“世界核心骨架”,后续我会分步骤生成角色名单、场景名单和详细档案。', - '你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。', - '这一步只保留世界顶层信息与一个开局归处场景,不要输出 playableNpcs、storyNpcs、landmarks,也不要展开人物和地图细节。', - '玩家设定:', - settingText.trim(), - '', - '输出 JSON 模板:', - '{', - ' "name": "世界名称",', - ' "subtitle": "世界副标题",', - ' "summary": "世界概述",', - ' "tone": "世界基调",', - ' "playerGoal": "玩家核心目标",', - ' "templateWorldType": "WUXIA|XIANXIA",', - ' "majorFactions": ["势力甲", "势力乙"],', - ' "coreConflicts": ["冲突甲", "冲突乙"],', - ' "camp": {', - ' "name": "开局归处名称",', - ' "description": "这是玩家进入世界后的第一处落脚点描述",', - ' "dangerLevel": "low|medium|high|extreme"', - ' }', - '}', - '', - '要求:', - '- 所有生成文本都必须使用中文。', - '- 这一步只输出顶层 9 个字段:name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。', - '- 这是一个完全独立的自定义世界;不要在任何正文里直接写出“武侠世界”“仙侠世界”等现成世界名。', - '- templateWorldType 只是系统兼容字段,不代表正文应当引用的世界名称。', - '- camp 必须表示玩家开局时的落脚处,名字不要直接写成“某某营地”,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念。', - '- 不要输出 playableNpcs、storyNpcs、landmarks、items,也不要输出任何角色和地图细节。', - '- majorFactions 保持 2 到 3 个,coreConflicts 保持 2 到 3 个。', - '- 世界设定必须直接源自玩家输入,不要脱离主题乱扩写。', - '- 每个字符串尽量简洁:subtitle 控制在 8 到 18 个汉字内,summary 控制在 16 到 32 个汉字内,tone 控制在 6 到 16 个汉字内,playerGoal 控制在 16 到 32 个汉字内,camp.description 控制在 18 到 40 个汉字内。', - '- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。', - ].join('\n'); -} - -export function buildCustomWorldFrameworkJsonRepairPrompt( - responseText: string, -) { - return [ - '下面这段文本本应是自定义世界核心骨架的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。', - '请只输出修复后的 JSON 对象。', - '顶层必须只包含:name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。', - '不要输出 playableNpcs、storyNpcs、landmarks、items 或任何其他字段。', - 'majorFactions 与 coreConflicts 必须是字符串数组。', - 'camp 必须是对象,且包含:name、description、dangerLevel。', - '原始文本:', - responseText.trim(), - ].join('\n'); -} - -export function buildCustomWorldRoleOutlineBatchPrompt(params: { - framework: CustomWorldGenerationFramework; - roleType: CustomWorldGenerationRoleBatchType; - batchCount: number; - forbiddenNames?: string[]; -}) { - const { framework, roleType, batchCount, forbiddenNames = [] } = params; - const key = roleType === 'playable' ? 'playableNpcs' : 'storyNpcs'; - const label = roleType === 'playable' ? '可扮演角色' : '场景角色'; - - return [ - `请根据下面的世界核心信息,生成一批${label}框架名单。`, - '后续我会继续补全人物档案,所以这一步每个角色只保留最少字段。', - '你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。', - '世界核心信息:', - buildFrameworkSummaryText(framework, { maxLandmarks: 0 }), - forbiddenNames.length > 0 - ? `这些名字已经生成,禁止重复:${forbiddenNames.join('、')}` - : '', - '', - '输出 JSON 模板:', - '{', - ` "${key}": [`, - ' {', - ' "name": "角色名称",', - ' "title": "称号",', - ' "role": "身份",', - ' "description": "极简定位描述",', - ' "initialAffinity": 18,', - ' "relationshipHooks": ["一个关系切入口"],', - ' "tags": ["标签1", "标签2"]', - ' }', - ' ]', - '}', - '', - '要求:', - `- 必须生成恰好 ${batchCount} 个${label}。`, - '- 这是一个完全独立的自定义世界;不要把角色写成来自“武侠世界”“仙侠世界”等现成世界。', - '- 名称必须具体且互不重复,不要使用 角色1、NPC1、场景角色1 之类的占位名。', - '- 只保留:name、title、role、description、initialAffinity、relationshipHooks、tags。', - '- relationshipHooks 最多 1 条;tags 保持 1 到 2 个。', - '- description 控制在 8 到 18 个汉字内,title 和 role 也尽量短。', - '- initialAffinity 必须是 -40 到 90 的整数。', - roleType === 'playable' - ? '- 可扮演角色的定位必须明显不同,通常使用 18 到 40 的初始好感。' - : '- 场景角色要覆盖势力成员、居民、异类或怪物,不要全是同一种身份;敌对或怪物型角色可以使用负好感。', - '- 所有生成文本都必须使用中文。', - '- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。', - ] - .filter(Boolean) - .join('\n'); -} - -export function buildCustomWorldRoleOutlineBatchJsonRepairPrompt(params: { - responseText: string; - roleType: CustomWorldGenerationRoleBatchType; - expectedCount: number; - forbiddenNames?: string[]; -}) { - const { responseText, roleType, expectedCount, forbiddenNames = [] } = params; - const key = roleType === 'playable' ? 'playableNpcs' : 'storyNpcs'; - - return [ - `下面这段文本本应是自定义世界${roleType === 'playable' ? '可扮演角色' : '场景角色'}框架名单批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。`, - '请只输出修复后的 JSON 对象。', - `顶层必须只包含一个 ${key} 数组。`, - `必须保留恰好 ${expectedCount} 个角色对象。`, - forbiddenNames.length > 0 - ? `禁止使用这些重复名:${forbiddenNames.join('、')}。` - : '', - '每个角色只包含:name、title、role、description、initialAffinity、relationshipHooks、tags。', - '如果缺少字段:字符串补空字符串,relationshipHooks 和 tags 补空数组,initialAffinity 补默认整数。', - '不要输出 backstory、skills、landmarks 或任何其他字段。', - '原始文本:', - responseText.trim(), - ] - .filter(Boolean) - .join('\n'); -} - -export function buildCustomWorldLandmarkSeedBatchPrompt(params: { - framework: CustomWorldGenerationFramework; - batchCount: number; - forbiddenNames?: string[]; -}) { - const { framework, batchCount, forbiddenNames = [] } = params; - - return [ - '请根据下面的世界核心信息,生成一批场景地标骨架。', - '后续我会继续补全场景角色分布和连接关系,所以这一步只保留最少字段。', - '你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。', - '世界核心信息:', - buildFrameworkSummaryText(framework, { maxLandmarks: 0 }), - forbiddenNames.length > 0 - ? `这些场景名已经生成,禁止重复:${forbiddenNames.join('、')}` - : '', - '', - '输出 JSON 模板:', - '{', - ' "landmarks": [', - ' {', - ' "name": "场景名称",', - ' "description": "极简场景描述",', - ' "dangerLevel": "low|medium|high|extreme"', - ' }', - ' ]', - '}', - '', - '要求:', - `- 必须生成恰好 ${batchCount} 个 landmarks。`, - '- 这是一个完全独立的自定义世界;不要在场景描述里直接写“武侠世界”“仙侠世界”等现成世界名。', - '- 这一步只保留:name、description、dangerLevel。', - '- 不要输出 sceneNpcNames、connections、imageSrc 或其他字段。', - '- 名称必须具体且互不重复,不要使用 场景1、地点1 之类的占位名。', - '- description 控制在 8 到 18 个汉字内。', - '- 所有生成文本都必须使用中文。', - '- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。', - ] - .filter(Boolean) - .join('\n'); -} - -export function buildCustomWorldLandmarkSeedBatchJsonRepairPrompt(params: { - responseText: string; - expectedCount: number; - forbiddenNames?: string[]; -}) { - const { responseText, expectedCount, forbiddenNames = [] } = params; - - return [ - '下面这段文本本应是自定义世界场景地标骨架批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。', - '请只输出修复后的 JSON 对象。', - '顶层必须只包含一个 landmarks 数组。', - `必须保留恰好 ${expectedCount} 个地标对象。`, - forbiddenNames.length > 0 - ? `禁止使用这些重复场景名:${forbiddenNames.join('、')}。` - : '', - '每个地标只包含:name、description、dangerLevel。', - '不要输出 sceneNpcNames、connections 或其他字段。', - '原始文本:', - responseText.trim(), - ] - .filter(Boolean) - .join('\n'); -} - -export function buildCustomWorldLandmarkNetworkBatchPrompt(params: { - framework: CustomWorldGenerationFramework; - landmarkBatch: CustomWorldGenerationLandmarkOutline[]; - storyNpcs: CustomWorldGenerationRoleOutline[]; -}) { - const { framework, landmarkBatch, storyNpcs } = params; - const relativePositionValues = - CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS.join('|'); - const allLandmarkNames = framework.landmarks.map((landmark) => landmark.name); - const storyNpcNames = storyNpcs.map((npc) => npc.name); - - return [ - '请根据下面的世界信息,为这一批场景补全“出现场景角色”和“地图连接关系”。', - '你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。', - '世界核心信息:', - buildFrameworkSummaryText(framework, { maxLandmarks: 0 }), - `全部场景名:${allLandmarkNames.join('、')}`, - `可用场景角色名:${storyNpcNames.join('、')}`, - '本批次场景骨架:', - landmarkBatch - .map( - (landmark) => - `- ${landmark.name} / 危险度:${landmark.dangerLevel} / 描述:${landmark.description}`, - ) - .join('\n'), - '', - '输出 JSON 模板:', - '{', - ' "landmarks": [', - ' {', - ' "name": "场景名称",', - ' "sceneNpcNames": ["场景角色1", "场景角色2", "场景角色3"],', - ' "connections": [', - ' {', - ' "targetLandmarkName": "其他场景名称",', - ` "relativePosition": "${CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS[0] ?? 'forward'}",`, - ' "summary": "极简通路说明"', - ' }', - ' ]', - ' }', - ' ]', - '}', - '', - '要求:', - `- 只输出这批 ${landmarkBatch.length} 个场景,不要输出其他场景。`, - '- 这是一个完全独立的自定义世界;summary 不要带入“武侠”“仙侠”等现成世界名称。', - '- 名称必须与本批次场景骨架完全一致,不得改名。', - '- 每个场景必须提供恰好 3 个唯一 sceneNpcNames,且只能从可用场景角色名里选择。', - `- 每个场景必须提供恰好 2 条 connections;relativePosition 只能使用:${relativePositionValues}。`, - '- targetLandmarkName 必须来自全部场景名,且不能连接自己,两个目标场景不能重复。', - '- summary 控制在 4 到 10 个汉字内。', - '- 不要输出 description、dangerLevel、backstory 或其他字段。', - '- 所有生成文本都必须使用中文。', - '- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。', - ].join('\n'); -} - -export function buildCustomWorldLandmarkNetworkBatchJsonRepairPrompt(params: { - responseText: string; - expectedNames: string[]; -}) { - const { responseText, expectedNames } = params; - - return [ - '下面这段文本本应是自定义世界场景连接补全批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。', - '请只输出修复后的 JSON 对象。', - '顶层必须只包含一个 landmarks 数组。', - `landmarks 里只能保留这些场景名:${expectedNames.join('、')}。`, - '每个场景对象只包含:name、sceneNpcNames、connections。', - 'connections 里的每个对象必须包含:targetLandmarkName、relativePosition、summary。', - '不要输出 description、dangerLevel 或其他字段。', - '原始文本:', - responseText.trim(), - ].join('\n'); -} - -export function buildCustomWorldRoleBatchPrompt(params: { - framework: CustomWorldGenerationFramework; - roleType: CustomWorldGenerationRoleBatchType; - roleBatch: CustomWorldGenerationRoleOutline[]; - stage: CustomWorldGenerationRoleBatchStage; -}) { - const { framework, roleType, roleBatch, stage } = params; - const key = roleType === 'playable' ? 'playableNpcs' : 'storyNpcs'; - const label = roleType === 'playable' ? '可扮演角色' : '场景角色'; - const roleOutlineText = buildRoleOutlinePromptLines(roleBatch, { - framework, - roleType, - }); - - if (stage === 'narrative') { - return [ - `请根据下面的世界框架,补全这一批${label}的叙事基础设定。`, - '你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。', - '玩家原始设定:', - framework.settingText, - '', - '世界框架摘要:', - buildFrameworkSummaryText(framework, { maxLandmarks: 8 }), - '', - `本批次需要补全的${label}(名称必须原样保留):`, - roleOutlineText, - '', - '输出 JSON 模板:', - '{', - ` "${key}": [`, - ' {', - ' "name": "角色名称",', - ' "backstory": "背景经历",', - ' "personality": "性格特点",', - ' "motivation": "当前动机",', - ' "combatStyle": "战斗风格"', - ' }', - ' ]', - '}', - '', - '要求:', - `- 只输出这批${label},不要输出其他角色、场景或额外顶层字段。`, - '- 这是一个完全独立的自定义世界;不要在角色背景、性格、动机或战斗风格里直接写“武侠世界”“仙侠世界”等现成世界名。', - `- ${key} 的数量必须与本批次名单完全一致。`, - '- 名称必须与批次名单完全一致,不得增删改名。', - '- 只补全 backstory、personality、motivation、combatStyle 这 4 个字段,不要输出 backstoryReveal、skills、initialItems。', - '- 必须严格沿用框架中的 title、role、description、initialAffinity、relationshipHooks、tags 所表达的角色定位,不要改名,不要改阵营。', - '- backstory 必须写出角色和当前世界的具体关系,至少落到一个势力、一个地点、一个正在发生的局势变化,不要只写抽象气质或泛泛成长史。', - '- personality 不能只写单个形容词,要体现角色在这个世界里的处事习惯、应对压力的方式和与人相处的锋面。', - '- motivation 必须是“此刻正在推动角色行动”的现实目标,而不是空泛理想;它要和玩家目标、核心冲突或开局处境形成直接拉扯。', - '- combatStyle 要体现角色为什么会这样战斗,它最好能反映其身份、经历、所属势力或长期栖身的场景环境。', - roleType === 'story' - ? '- 怪物型场景角色要在 backstory 或 combatStyle 中直接写出怪物特征、栖息环境或攻击方式。' - : '- 可扮演角色要体现成长空间、协作价值和明确的入队理由。', - '- 所有生成文本都必须使用中文。', - '- 每个字符串尽量简洁但不能空泛:backstory/personality/motivation/combatStyle 控制在 18 到 56 个汉字内。', - '- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。', - ].join('\n'); - } - - return [ - `请根据下面的世界框架,补全这一批${label}的背景章节、技能和初始物品。`, - '你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。', - '玩家原始设定:', - framework.settingText, - '', - '世界框架摘要:', - buildFrameworkSummaryText(framework, { maxLandmarks: 8 }), - '', - `本批次需要补全的${label}(名称必须原样保留):`, - roleOutlineText, - '', - '输出 JSON 模板:', - '{', - ` "${key}": [`, - ' {', - ' "name": "角色名称",', - ' "backstoryReveal": {', - ' "publicSummary": "公开可见的背景摘要",', - ' "chapters": [', - ` { "id": "surface", "title": "表层来意", "affinityRequired": ${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[0]}, "teaser": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[0]}好感可见的提示", "content": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[0]}好感时解锁的背景内容", "contextSnippet": "可供后续剧情引用的摘要" },`, - ` { "id": "scar", "title": "旧事裂痕", "affinityRequired": ${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[1]}, "teaser": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[1]}好感可见的提示", "content": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[1]}好感时解锁的背景内容", "contextSnippet": "可供后续剧情引用的摘要" },`, - ` { "id": "hidden", "title": "隐藏执念", "affinityRequired": ${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[2]}, "teaser": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[2]}好感可见的提示", "content": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[2]}好感时解锁的背景内容", "contextSnippet": "可供后续剧情引用的摘要" },`, - ` { "id": "final", "title": "最终底牌", "affinityRequired": ${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[3]}, "teaser": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[3]}好感可见的提示", "content": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[3]}好感时解锁的背景内容", "contextSnippet": "可供后续剧情引用的摘要" }`, - ' ]', - ' },', - ' "skills": [', - ' { "name": "技能1", "summary": "技能说明", "style": "起手压制" },', - ' { "name": "技能2", "summary": "技能说明", "style": "机动周旋" },', - ' { "name": "技能3", "summary": "技能说明", "style": "爆发终结" }', - ' ],', - ' "initialItems": [', - ' { "name": "初始物品1", "category": "武器", "quantity": 1, "rarity": "rare", "description": "物品说明", "tags": ["标签1", "标签2"] },', - ' { "name": "初始物品2", "category": "消耗品", "quantity": 2, "rarity": "uncommon", "description": "物品说明", "tags": ["标签1", "标签2"] },', - ' { "name": "初始物品3", "category": "专属物品", "quantity": 1, "rarity": "rare", "description": "物品说明", "tags": ["标签1", "标签2"] }', - ' ]', - ' }', - ' ]', - '}', - '', - '要求:', - `- 只输出这批${label},不要输出其他角色、场景或额外顶层字段。`, - '- 这是一个完全独立的自定义世界;不要在公开背景、技能名、物品名或说明里直接带入“武侠”“仙侠”等现成世界名。', - `- ${key} 的数量必须与本批次名单完全一致。`, - '- 名称必须与批次名单完全一致,不得增删改名。', - '- 这一阶段只补全 backstoryReveal、skills、initialItems,不要重复输出 title、role、description、backstory、personality、motivation、combatStyle、initialAffinity、relationshipHooks、tags。', - '- 背景章节、技能和初始物品必须严格围绕框架中的角色定位来写,不要改变阵营、身份或出现场景。', - '- backstoryReveal 的 4 章必须形成明显递进:第 1 章写表层来意与第一印象,第 2 章写旧伤或代价,第 3 章写角色真正隐瞒的线索,第 4 章写最终底牌或不可回避的真相。', - '- 每一章都必须紧贴当前世界设定,至少落到具体势力、地点、事件、制度、禁忌或关系链中的一项,不要写成可套用到任何世界的空泛心情。', - '- teaser 必须像“继续相处后能戳到的钩子”,content 必须像“真正解锁后得到的新信息”,contextSnippet 必须可直接被后续剧情复用,三者不要只是同一句话改写。', - '- skills 不只是职业标签,要体现角色的个人经历、所属阵营、地理环境或禁忌系统影响,尽量写出这个世界独有的招式语感。', - '- initialItems 不只是常规装备清单,至少要有一件能反映角色背景、关系或任务压力的私人物件。', - `- backstoryReveal.chapters 必须恰好 4 章,affinityRequired 固定使用 ${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.join('、')}。`, - '- 每个角色必须提供恰好 3 个 skills 和恰好 3 个 initialItems。', - '- initialItems.category 只能使用:武器、护甲、饰品、消耗品、材料、稀有品、专属物品。', - roleType === 'story' - ? '- 怪物型角色仍然放进 storyNpcs,并在 role、description、backstory、combatStyle、tags、backstoryReveal、skills 或 initialItems 中明确写出怪物特征、栖息环境、攻击方式或异形外观。' - : '- 可扮演角色要保持明确的成长空间、协作价值和入队理由。', - '- 所有生成文本都必须使用中文。', - '- 每个字符串尽量简洁但要有信息量:backstoryReveal.publicSummary 控制在 14 到 36 个汉字内,backstoryReveal.teaser 控制在 12 到 28 个汉字内,backstoryReveal.content 控制在 20 到 64 个汉字内,contextSnippet 控制在 12 到 36 个汉字内,skills.summary 和 initialItems.description 控制在 12 到 32 个汉字内。', - '- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。', - ].join('\n'); -} - -export function buildCustomWorldRoleBatchJsonRepairPrompt(params: { - responseText: string; - roleType: CustomWorldGenerationRoleBatchType; - expectedNames: string[]; - stage: CustomWorldGenerationRoleBatchStage; -}) { - const { responseText, roleType, expectedNames, stage } = params; - const key = roleType === 'playable' ? 'playableNpcs' : 'storyNpcs'; - - if (stage === 'narrative') { - return [ - `下面这段文本本应是自定义世界${roleType === 'playable' ? '可扮演角色' : '场景角色'}叙事设定批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。`, - '请只输出修复后的 JSON 对象。', - `顶层必须只包含一个 ${key} 数组。`, - `这个数组里只能保留这些角色名:${expectedNames.join('、')}。`, - '名称必须与名单完全一致,不得增删改名;如果原文遗漏,可按名单顺序补齐占位对象。', - '每个角色都必须包含:name、backstory、personality、motivation、combatStyle。', - '如果缺少字段:字符串补空字符串。', - '不要输出 backstoryReveal、skills、initialItems,也不要新增名单外的角色。', - '原始文本:', - responseText.trim(), - ].join('\n'); - } - - return [ - `下面这段文本本应是自定义世界${roleType === 'playable' ? '可扮演角色' : '场景角色'}档案补全批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。`, - '请只输出修复后的 JSON 对象。', - `顶层必须只包含一个 ${key} 数组。`, - `这个数组里只能保留这些角色名:${expectedNames.join('、')}。`, - '名称必须与名单完全一致,不得增删改名;如果原文遗漏,可按名单顺序补齐占位对象。', - '每个角色都必须包含:name、backstoryReveal、skills、initialItems。', - `backstoryReveal 必须包含 publicSummary 和 4 个 chapters,chapters.affinityRequired 固定为 ${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.join('、')}。`, - 'skills 默认补成 3 个对象,每个对象包含 name、summary、style;initialItems 默认补成 3 个对象,每个对象包含 name、category、quantity、rarity、description、tags。', - '不要输出 backstory、personality、motivation、combatStyle、landmarks,也不要新增名单外的角色。', - '原始文本:', - responseText.trim(), - ].join('\n'); -} - -function clampSceneImageText(value: string, maxLength: number) { - const normalized = value.trim().replace(/\s+/g, ' '); - if (!normalized) { - return ''; - } - if (normalized.length <= maxLength) { - return normalized; - } - return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}…`; -} - -function describeDangerLevel(dangerLevel: string) { - const normalized = dangerLevel.trim().toLowerCase(); - if (normalized === 'low' || normalized === '低') - return '气氛相对平静,但暗藏细节张力'; - if (normalized === 'medium' || normalized === '中') - return '带有明确的探索压力与潜在威胁'; - if (normalized === 'high' || normalized === '高') - return '危险感强烈,空间中有明显压迫感'; - if (normalized === 'extreme' || normalized === '极高') - return '极端危险,环境本身就像会吞没闯入者'; - return dangerLevel.trim() - ? `危险氛围:${dangerLevel.trim()}` - : '危险气质保持克制但不可忽视'; -} - -export const DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT = [ - '文字', - '水印', - 'logo', - 'UI界面', - '对话框', - '边框', - '人物近景特写', - '多人合照', - '模糊', - '低清晰度', - '畸形建筑', - '现代车辆', - '监控摄像头', -].join(','); - -export function buildCustomWorldSceneImagePrompt( - profile: Pick< - CustomWorldProfile, - 'name' | 'subtitle' | 'summary' | 'tone' | 'playerGoal' | 'settingText' - >, - landmark: Pick, - userPrompt = '', - options: { - hasReferenceImage?: boolean; - } = {}, -) { - const worldName = clampSceneImageText(profile.name, 18) || '未命名世界'; - const worldSubtitle = clampSceneImageText(profile.subtitle, 18); - const worldTone = clampSceneImageText(profile.tone, 48); - const worldGoal = clampSceneImageText(profile.playerGoal, 48); - const worldSummary = clampSceneImageText(profile.summary, 72); - const worldSetting = clampSceneImageText(profile.settingText, 72); - const landmarkName = clampSceneImageText(landmark.name, 18) || '未命名场景'; - const landmarkDescription = clampSceneImageText(landmark.description, 96); - const requestedVisual = clampSceneImageText(userPrompt, 120); - const dangerMood = describeDangerLevel(landmark.dangerLevel); - - return [ - '为横版 16:9 2D RPG 生成高完成度像素风场景背景,适合作为剧情探索与战斗底图。', - '画面构图必须严格按上下 1:1 分区:上半部分严格控制在整张图的 1/2 高度内,只描绘场景远景与中远景轮廓,不要让背景内容向下侵占超过半屏。', - '下半部分严格占据整张图的 1/2 高度,用于玩家角色站位与展示,必须是模拟 3D 游戏视角的地面近景,有明确的透视延伸和近大远小关系,不是平铺的 2D 侧视地面。', - '下半部分的内容必须是明确可站立的地面本体,例如道路、石板、平台、广场、甲板、沙地或草地,要有连续、稳定、可落脚的站位逻辑,不能只是装饰性前景、坑洞、障碍堆、栏杆带或不可通行的景物。', - '下半部分地面近景要保持相对简洁、低细节、轮廓清楚、便于角色站立,不要堆满道具、植被、碎石、栏杆或复杂装饰。', - options.hasReferenceImage - ? '已提供一张自定义参考图,请沿用其构图、镜头或氛围线索,同时继续满足本次场景需求。' - : '', - `世界:${worldName}${worldSubtitle ? `,${worldSubtitle}` : ''}。`, - worldSetting ? `玩家设定:${worldSetting}。` : '', - worldSummary ? `世界概述:${worldSummary}。` : '', - worldTone ? `整体基调:${worldTone}。` : '', - worldGoal ? `玩家目标关联:${worldGoal}。` : '', - `场景名称:${landmarkName}。`, - landmarkDescription ? `场景描述:${landmarkDescription}。` : '', - requestedVisual ? `本次想要生成的画面内容:${requestedVisual}。` : '', - `${dangerMood}。`, - '不要出现 UI、字幕、文字、水印、logo 或装饰边框,人物仅可作为很小的远景剪影,画面重点放在场景本身,不要遮挡下半部分的角色展示区域。', - ] - .filter(Boolean) - .join(''); -} diff --git a/server-node/src/prompts/customWorldSceneNpcPrompts.ts b/server-node/src/prompts/customWorldSceneNpcPrompts.ts deleted file mode 100644 index cf433c88..00000000 --- a/server-node/src/prompts/customWorldSceneNpcPrompts.ts +++ /dev/null @@ -1,104 +0,0 @@ -type ParsedStoryNpc = { - name: string; - title: string; - role: string; - description: string; - personality: string; - motivation: string; -}; - -type ParsedLandmark = { - name: string; - description: string; - dangerLevel: string; -}; - -type ParsedProfile = { - name: string; - settingText: string; - storyNpcs: ParsedStoryNpc[]; - landmarks: ParsedLandmark[]; -}; - -export const CUSTOM_WORLD_SCENE_NPC_SYSTEM_PROMPT = - '你是游戏世界编辑器的场景 NPC 生成器。你必须只返回可解析 JSON,不要输出解释、前言或 Markdown。'; - -export function buildCustomWorldSceneNpcPrompt( - profile: ParsedProfile, - landmark: ParsedLandmark, - sceneNpcs: ParsedStoryNpc[], - otherNpcs: ParsedStoryNpc[], -) { - const sceneNpcSummary = sceneNpcs.length - ? sceneNpcs - .map( - (npc, index) => - `${index + 1}. ${npc.name} / ${npc.title || npc.role} / ${npc.description || '无描述'} / 性格:${npc.personality || '未写'} / 动机:${npc.motivation || '未写'}`, - ) - .join('\n') - : '当前场景还没有已加入 NPC。'; - - const reserveNpcSummary = otherNpcs.length - ? otherNpcs - .slice(0, 8) - .map( - (npc, index) => - `${index + 1}. ${npc.name} / ${npc.title || npc.role} / ${npc.description || '无描述'}`, - ) - .join('\n') - : '暂无其他场景角色参考。'; - - const landmarkSummary = profile.landmarks - .slice(0, 10) - .map( - (entry, index) => - `${index + 1}. ${entry.name} / 危险度:${entry.dangerLevel || '中'} / ${entry.description || '无描述'}`, - ) - .join('\n'); - - return [ - `世界名:${profile.name}`, - `世界设定:${profile.settingText || '未提供额外设定文本。'}`, - `当前目标场景:${landmark.name}`, - `场景描述:${landmark.description || '未填写'}`, - `危险度:${landmark.dangerLevel || '中'}`, - `当前场景已加入 NPC:\n${sceneNpcSummary}`, - `其他可参考 NPC:\n${reserveNpcSummary}`, - `世界内其他场景概览:\n${landmarkSummary}`, - '请生成 1 名适合加入当前场景的新 NPC。', - '要求:', - '- 必须与当前场景气质、危险度、已有 NPC 分工互补,不要和已有 NPC 重复。', - '- 角色要像真正可落地到游戏里的场景角色,不要写成抽象设定。', - '- 关系钩子、技能、初始物品都要可直接进入编辑器。', - '- 返回 JSON,不要额外解释。', - 'JSON 结构:', - '{', - ' "npc": {', - ' "name": "角色名",', - ' "title": "头衔",', - ' "role": "身份",', - ' "description": "一句到两句角色描述",', - ' "backstory": "背景",', - ' "personality": "性格",', - ' "motivation": "动机",', - ' "combatStyle": "战斗风格",', - ' "initialAffinity": 6,', - ' "relationshipHooks": ["关系钩子1", "关系钩子2", "关系钩子3"],', - ' "tags": ["标签1", "标签2", "标签3"],', - ' "publicSummary": "公开背景摘要",', - ' "chapterTeasers": ["表层来意", "旧事裂痕", "隐藏执念", "最终底牌"],', - ' "chapterContents": ["对应正文1", "对应正文2", "对应正文3", "对应正文4"],', - ' "skills": [', - ' { "name": "技能1", "summary": "说明", "style": "风格" },', - ' { "name": "技能2", "summary": "说明", "style": "风格" },', - ' { "name": "技能3", "summary": "说明", "style": "风格" }', - ' ],', - ' "initialItems": [', - ' { "name": "物品1", "category": "分类", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },', - ' { "name": "物品2", "category": "分类", "quantity": 1, "rarity": "uncommon", "description": "说明", "tags": ["标签"] },', - ' { "name": "物品3", "category": "分类", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] }', - ' ]', - ' }', - '}', - ].join('\n'); -} diff --git a/server-node/src/prompts/eightAnchorPrompts.ts b/server-node/src/prompts/eightAnchorPrompts.ts deleted file mode 100644 index 777d7666..00000000 --- a/server-node/src/prompts/eightAnchorPrompts.ts +++ /dev/null @@ -1,784 +0,0 @@ -import type { - EightAnchorContent, - HiddenLineValue, - IconicElementValue, - KeyRelationshipValue, - ThemeBoundaryValue, -} from '../../../packages/shared/src/contracts/customWorldAgent.js'; -import { - createEmptyEightAnchorContent, - normalizeEightAnchorContent, -} from '../services/eightAnchorCompatibilityService.js'; - -export type PromptUserInputSignal = - | 'rich' - | 'normal' - | 'sparse' - | 'correction' - | 'delegate'; - -export type PromptDriftRisk = 'low' | 'medium' | 'high'; - -export type PromptConversationMode = - | 'bootstrap' - | 'expand' - | 'compress' - | 'repair_direction' - | 'force_complete' - | 'closing'; - -export type PromptDynamicState = { - currentTurn: number; - progressPercent: number; - userInputSignal: PromptUserInputSignal; - driftRisk: PromptDriftRisk; - quickFillRequested: boolean; - conversationMode: PromptConversationMode; - judgementSummary: string; -}; - -export type PromptDynamicStateInference = { - userInputSignal?: unknown; - driftRisk?: unknown; - conversationMode?: unknown; - judgementSummary?: unknown; -}; - -const BASE_SYSTEM_PROMPT = `你是一个负责共创游戏世界设定的专业策划。 - -你正在和用户一起共创一个游戏世界。每一轮你都必须读取: -1. 当前完整设定结构 -2. 用户聊天记录 - -然后输出: -1. 一版新的完整设定结构 -2. 当前 progress 百分比 -3. 一段直接回复用户的话 - -你必须把“新的完整设定结构”视为下一轮的唯一有效版本。 -你的输出会直接覆盖上一版设定结构。 - -你不是在做局部 patch。 -你不是在做解释报告。 -你不是在给开发者写分析。 -你是在同时完成: -1. 世界设定更新 -2. 当前推进程度判断 -3. 对用户的共创回复`; - -const GLOBAL_HARD_RULES = `全局硬约束: - -1. 必须输出完整的设定结构,而不是只输出变化部分。 -2. 新的设定结构会直接覆盖旧内容,因此不得随意丢失仍然成立的重要信息。 -3. 如果用户明确修正旧设定,必须在新的设定结构中直接体现修正结果。 -4. 如果用户输入信息不足,可以保留上一版中仍然成立的内容。 -5. progressPercent 最低为 0,不允许为负数。 -6. replyText 会直接发送给用户,因此要自然、直接、可继续聊天。 -7. 不要输出额外解释,不要输出 markdown 代码块,不要输出开发备注。 -8. replyText 不要写成长篇策划文,不要展开大段世界观百科。 -9. replyText 默认只推进当前最关键的一步,不要同时抛出很多话题。 -10. replyText 不要提及“八锚点”“锚点”“结构字段”“框架字段”等内部概念词。 -11. 你输出的 JSON 必须可以被直接解析。 -12. 输出字段顺序必须固定为:replyText、progressPercent、nextAnchorContent。`; - -const MODE_RULES: Record = { - bootstrap: `当前模式:bootstrap - -目标: -1. 先把世界的基本方向抓住 -2. 不要一次塞太多新设定 -3. 回复要降低用户开口压力 - -本轮行为要求: -1. 优先从用户输入里抓世界方向、玩家视角、主题边界的线索 -2. 如果用户信息很少,不要强行把整套结构一次补满 -3. replyText 要像共创搭档,而不是像审问 -4. 默认只推进一个最关键的问题方向 -5. 如果用户刚开口,优先给“被理解感”,再轻轻推进下一步 -6. 可以用一句很短的话先确认你抓到的核心方向,再提一个最好回答的问题 -7. 不要把问题问得像表单采集,不要一口气追问多个维度 - -用户体验要求: -1. 让用户觉得“现在很容易继续往下说” -2. 不要制造被考试、被拷问、被策划问卷追着跑的感觉 -3. replyText 最好短、稳、可接话 -4. 如果用户信息很少,也不要显得冷淡或机械`, - expand: `当前模式:expand - -目标: -1. 在保持现有方向的前提下,把设定结构逐步补全 -2. 尽量让一轮输入覆盖多个关键维度 - -本轮行为要求: -1. 继续保留上一版里仍成立的设定 -2. 优先把用户本轮输入映射进多个关键维度,而不是只更新一个字段 -3. replyText 要明确体现“你已经理解了哪些内容” -4. 不要突然大幅改写已经成形的世界 -5. 如果用户这一轮给了多条有效信息,replyText 应先把这些信息自然串起来,再决定下一步 -6. 可以适度替用户整理,但不要把回复写成总结报告 -7. 默认继续往前推一步,不要在还没必要时突然收束或突然跳到成稿感 - -用户体验要求: -1. 让用户感到“我刚说的内容都被接住了” -2. 回复里可以带一点顺势整理感,但不要太像会议纪要 -3. 不要无视用户刚提供的高价值细节 -4. 不要让用户觉得系统在自顾自重写世界`, - compress: `当前模式:compress - -目标: -1. 开始收束当前设定 -2. 减少无效发散 -3. 让 progress 更接近可进入下一阶段 - -本轮行为要求: -1. 新的设定结构优先保留稳定内容,不要无端重写 -2. 对用户本轮输入做高密度吸收 -3. replyText 要更聚焦,不要绕圈 -4. 默认只推进当前最影响 completion 的一步 -5. 如果用户还在补细节,优先把细节挂回现有骨架,而不是继续开新分支 -6. 可以适度提醒“还差哪类关键空位”,但不要把回复写成 checklist -7. 如果已有信息足够,replyText 可以更像“确认并收束”,少一点继续发散式追问 - -用户体验要求: -1. 让用户感觉世界正在变得更稳,而不是越来越散 -2. 让推进感更明确,但不要显得催促 -3. 回复语气应更笃定一些,减少反复横跳 -4. 不要把用户刚补进来的细节又冲淡掉`, - repair_direction: `当前模式:repair_direction - -目标: -1. 处理用户对既有设定的修正 -2. 避免世界方向飘散或自相矛盾 - -本轮行为要求: -1. 如果用户明确改口,新的设定结构必须体现修正后的方向 -2. 对已经不再成立的旧设定,不要机械保留 -3. progressPercent 可以停滞,也可以小幅回落,但不能为负 -4. replyText 要承认用户的修正,并顺着修正后的方向继续聊 -5. 先处理“改掉什么”,再决定“往哪里继续推” -6. 不要一边口头承认用户修正,一边在设定结构里偷偷留住旧方向 -7. 如果修正幅度很大,replyText 可以帮助用户确认新方向已经接管当前语境 - -用户体验要求: -1. 让用户感到“我刚刚的纠偏真的生效了” -2. 不要和用户辩论旧方案为什么也行 -3. 不要表现出对修正的不情愿 -4. 回复要体现重心已经切到新方向,而不是停留在旧世界观惯性里`, - force_complete: `当前模式:force_complete - -目标: -1. 基于当前方向直接补齐剩余设定 -2. 生成一版尽量完整、可进入下一阶段的设定结构 -3. 结束当前收集阶段 - -本轮行为要求: -1. 尽量保留已经形成的世界方向 -2. 对明显缺失的关键维度进行合理补全 -3. 不要继续拉长聊天,不要再追问用户 -4. progressPercent 直接输出为 100 -5. replyText 要自然引导用户点击“生成游戏设定草稿” -6. 补全时要优先做“顺着已有方向补齐”,而不是突然换题材、换气质、换主冲突 -7. 可以让结果更完整,但不要补得过满、过死、过像定稿圣经 -8. replyText 更像阶段完成提示,不再像继续采集信息的对话 - -用户体验要求: -1. 让用户感到“系统已经帮我把能补的补好了” -2. 不要在这一步突然冒出很多陌生设定把用户吓出戏 -3. 回复要有完成感,但不要太官话 -4. 清楚告诉用户下一步可以做什么`, - closing: `当前模式:closing - -目标: -1. 尽量形成一版可用的设定底子 -2. 不再继续发散新世界观 - -本轮行为要求: -1. 优先收束,而不是扩写 -2. 不要大改已经成形的核心设定 -3. progressPercent 接近完成时,replyText 要更像确认与推进 -4. 如果用户没有大改方向,尽量让下一版内容更稳定 -5. 可以轻微补足缺口,但不要再大开新支线 -6. replyText 应减少探索式措辞,增加“已经基本成形”的稳定感 -7. 如果只差少量空位,优先把这些空位自然补平,而不是重新打开大话题 - -用户体验要求: -1. 让用户感觉作品已经快成了,而不是还在无穷试探 -2. 回复可以更像确认和轻推,不要继续像前期那样频繁试探 -3. 保持留白感,不要把所有东西都一次说死 -4. 让用户自然过渡到下一阶段,而不是突然被切断对话`, -}; - -const USER_SIGNAL_RULES: Record = { - rich: `本轮用户输入信息密度高。 -请尽量从这一轮里提取多个锚点,不要只更新单一方向。 -如果一条输入同时影响世界方向、冲突和关系,请在新的完整设定结构中一起体现。`, - normal: `本轮用户输入为正常补充。 -请优先顺着当前方向稳定更新,不要主动扩写太多新设定。`, - sparse: `本轮用户输入较少或较虚。 -请保留上一版中仍然成立的内容,不要为了凑完整度而强行发明过多新设定。 -replyText 要让用户容易继续往下说。`, - correction: `本轮用户在修正或推翻旧设定。 -请优先吸收修正,不要机械复读旧版本。 -新的完整设定结构必须以修正后的方向为准。`, - delegate: `本轮用户把部分决定权交给你。 -你可以在 replyText 中给出有限度的建议,但不要突然补满整套设定。 -新的完整设定结构仍应尽量建立在已有世界方向上,而不是完全重做。`, -}; - -const QUICK_FILL_EXTRA_RULES = `用户刚刚主动要求你自动补全剩余设定。 - -这表示用户接受你基于当前方向自动补完剩余设定。 - -本轮要求: -1. 不要再继续提问 -2. 直接输出一版尽量完整的设定结构 -3. progressPercent 直接输出为 100 -4. replyText 要告诉用户现在可以进入“生成游戏设定草稿”`; - -const STATE_INFERENCE_SYSTEM_PROMPT = `你是正式生成世界设定前的一步“创作状态识别器”。 -你的职责不是直接生成新设定,而是先判断:下一轮正式生成应该用什么推进策略,尤其要判断 replyText 应该更偏确认、吸收、收束、纠偏,还是启发式提问。 - -你必须综合以下信息判断: -1. 当前轮次 currentTurn -2. 当前完成度 progressPercent -3. 用户是否要求自动补全 quickFillRequested -4. 当前完整设定结构 -5. 最近聊天记录,尤其是最近 1 到 3 轮用户消息 - -你需要输出 4 个字段: -1. userInputSignal:只能是 rich / normal / sparse / correction / delegate -2. driftRisk:只能是 low / medium / high -3. conversationMode:只能是 bootstrap / expand / compress / repair_direction / force_complete / closing -4. judgementSummary:1 到 2 句中文,概括你为什么这样判断,以及正式生成时最该注意什么 - -请按下面的语义判断。 - -一、userInputSignal 定义 -1. rich -- 用户这一轮给了多条可直接落地的有效信息 -- 这些信息可能同时覆盖世界方向、玩家处境、开局事件、冲突、关系、标志元素中的多个 -- 正式生成时应优先高密度吸收,不要只更新一个点 - -2. normal -- 用户在顺着当前方向做正常补充 -- 信息量中等,有明确新增内容,但没有明显推翻旧方向,也没有把决定权交给系统 -- 正式生成时应稳定推进并自然接住用户内容 - -3. sparse -- 用户输入很短、很虚、很笼统,或几乎没有新增有效事实 -- 例如只有一个题材词、一个气质词、一句很概括的话、一个很短的倾向表达 -- 这种情况下,正式生成阶段的 replyText 应优先采用启发式提问 -- 启发式提问的要求是:只问一个最容易回答、最能推动落地设计的问题 - -4. correction -- 用户这轮核心动作是在修正、替换、推翻、重定向旧设定 -- 即使文字不长,只要主意图是“之前那个不对,现在改成这个”,也应优先判为 correction -- correction 的优先级高于 rich 和 normal - -5. delegate -- 用户把部分决定权交给系统 -- 例如“你来定”“你帮我补”“按你觉得合理的来”“先给我一个默认方案” -- delegate 关注的是授权关系,不只是信息多寡 - -二、driftRisk 定义 -1. low -- 当前轮输入与已有方向基本一致 -- 没有明显改口或冲突 - -2. medium -- 当前轮带来一定方向变化或扩张 -- 还没有明显推翻旧方向,但如果处理不好,容易让设定开始发散 - -3. high -- 用户明确纠偏、改口、替换方向,或最近多轮反复修正 -- 这时最重要的是防止旧方向重新回流到正式生成结果里 - -三、conversationMode 选择原则 -1. bootstrap -- 适用于前期、信息少、核心方向未稳定 -- replyText 更适合低压力确认和单点启发 - -2. expand -- 适用于方向已成形,正在顺着现有路线继续补充 -- replyText 更适合总结已接住的内容并往前推一步 - -3. compress -- 适用于中后段,已有骨架,需要开始收束 -- replyText 更适合聚焦最关键缺口,而不是继续开支线 - -4. repair_direction -- 适用于用户正在纠偏 -- replyText 更适合先承认修正,再沿修正后的方向继续推进 - -5. force_complete -- 适用于用户明确要求自动补全 -- replyText 不再提问,而应给出完成感和下一步引导 - -6. closing -- 适用于接近完成但并非强制一键补全 -- replyText 更像确认与收束,而不是前期式探索 - -四、优先级规则 -1. 如果 quickFillRequested 为 true,conversationMode 必须优先判为 force_complete -2. 如果用户核心意图是修正旧方向,userInputSignal 优先判为 correction,conversationMode 通常优先考虑 repair_direction -3. 如果用户核心意图是授权系统替他补完,userInputSignal 优先判为 delegate -4. 只有在没有明显纠偏、也没有明确自动补全要求时,才主要依据 currentTurn、progressPercent 和信息密度,在 bootstrap / expand / compress / closing 之间选择 - -五、关于 replyText 风格的专门判断要求 -1. 如果用户输入较少、较虚或不够落地,正式生成阶段的 replyText 应采用启发式提问 -2. 启发式提问一次最多只能提 1 个问题,不能连问两个或更多 -3. 启发式提问必须问“最能推动当前设计落地”的那个问题,而不是泛泛而谈 -4. 如果用户输入已经足够 rich,就不要再机械提问,优先吸收和推进 -5. 如果用户在 correction 或 delegate 状态下,replyText 是否提问要服从更高目标:纠偏生效或代为补全,不要机械套 sparse 的问法 - -六、关于 replyText 用语的硬约束 -1. replyText 禁止提及内部结构名、锚点名、字段名、schema 名、框架词 -2. 禁止出现这类内部表达:世界承诺、玩家幻想、主题边界、玩家入口、核心冲突、关键关系、隐藏线、标志元素、字段、结构、模块、八锚点 -3. replyText 只能用通俗、直接、面向创作沟通的语言回应用户 -4. replyText 应该围绕用户正在讨论的具体内容来落地,比如身份、开场处境、冲突、人物关系、地点、规则、气质,而不是抽象谈结构 -5. judgementSummary 可以简洁提到“这轮更适合启发式提问”或“这轮应优先吸收修正”,但也不要堆内部术语 - -七、关于 judgementSummary 的写法 -1. 必须简洁,不要写成长篇分析 -2. 必须直接服务于下一轮正式生成 -3. 最好同时包含两层信息: -- 为什么这么判断 -- 正式生成时最该优先做什么,或最该避免什么 - -八、硬性约束 -1. 只能输出 JSON,不能输出解释、代码块或额外说明 -2. 不能发明上下文里不存在的设定事实 -3. 你的任务是“判断生成策略”,不是“代替正式生成直接写新设定” -4. 即使信息不完全,也必须在给定枚举里选出最合理的一组状态 -5. judgementSummary 必须是中文 -6. 输出值必须严格落在给定枚举中`; - -const STATE_INFERENCE_OUTPUT_CONTRACT = `请严格按以下 JSON 结构输出,不要输出其他文字: -{ - "userInputSignal": "normal", - "driftRisk": "low", - "conversationMode": "expand", - "judgementSummary": "" -}`; - -const OUTPUT_CONTRACT_REMINDER = `请严格按以下 JSON 结构输出,不要输出其他文字: -{ - "replyText": "", - "progressPercent": 0, - "nextAnchorContent": { - "worldPromise": { - "hook": "", - "differentiator": "", - "desiredExperience": "" - }, - "playerFantasy": { - "playerRole": "", - "corePursuit": "", - "fearOfLoss": "" - }, - "themeBoundary": { - "toneKeywords": [], - "aestheticDirectives": [], - "forbiddenDirectives": [] - }, - "playerEntryPoint": { - "openingIdentity": "", - "openingProblem": "", - "entryMotivation": "" - }, - "coreConflict": { - "surfaceConflicts": [], - "hiddenCrisis": "", - "firstTouchedConflict": "" - }, - "keyRelationships": [ - { - "pairs": "", - "relationshipType": "", - "secretOrCost": "" - } - ], - "hiddenLines": { - "hiddenTruths": [], - "misdirectionHints": [], - "revealPacing": "" - }, - "iconicElements": { - "iconicMotifs": [], - "institutionsOrArtifacts": [], - "hardRules": [] - } - } -}`; - -function toJson(value: unknown) { - return JSON.stringify(value, null, 2); -} - -function toText(value: unknown) { - return typeof value === 'string' ? value.trim() : ''; -} - -function getLatestUserText( - chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>, -) { - return ( - [...chatHistory] - .reverse() - .find((entry) => entry.role === 'user' && entry.content.trim())?.content ?? - '' - ); -} - -function includesAny(text: string, patterns: RegExp[]) { - return patterns.some((pattern) => pattern.test(text)); -} - -function isPromptUserInputSignal( - value: unknown, -): value is PromptUserInputSignal { - return ( - value === 'rich' || - value === 'normal' || - value === 'sparse' || - value === 'correction' || - value === 'delegate' - ); -} - -function isPromptDriftRisk(value: unknown): value is PromptDriftRisk { - return value === 'low' || value === 'medium' || value === 'high'; -} - -function isPromptConversationMode( - value: unknown, -): value is PromptConversationMode { - return ( - value === 'bootstrap' || - value === 'expand' || - value === 'compress' || - value === 'repair_direction' || - value === 'force_complete' || - value === 'closing' - ); -} - -export function detectUserInputSignal( - chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>, -): PromptUserInputSignal { - const latestUserText = getLatestUserText(chatHistory).trim(); - - if (!latestUserText) { - return 'sparse'; - } - - if (includesAny(latestUserText, [/(不是|改成|改为|换成|重来|推翻|修正)/u])) { - return 'correction'; - } - - if (includesAny(latestUserText, [/(你帮我想|你来定|你决定|你补完)/u])) { - return 'delegate'; - } - - const segments = latestUserText - .split(/[。!?;\n]/u) - .map((item) => item.trim()) - .filter(Boolean); - - if (latestUserText.length <= 10 || segments.length <= 1) { - return 'sparse'; - } - - if (segments.length >= 3 || latestUserText.length >= 60) { - return 'rich'; - } - - return 'normal'; -} - -function summarizeDynamicState( - state: Pick< - PromptDynamicState, - 'userInputSignal' | 'driftRisk' | 'conversationMode' - >, -) { - return `输入信号=${state.userInputSignal},漂移风险=${state.driftRisk},本轮模式=${state.conversationMode}。正式生成时按这组状态执行。`; -} - -function isThemeBoundaryFilled(value: ThemeBoundaryValue | null) { - return Boolean( - value && - (value.toneKeywords.length > 0 || - value.aestheticDirectives.length > 0 || - value.forbiddenDirectives.length > 0), - ); -} - -function isRelationshipsFilled(value: KeyRelationshipValue[]) { - return value.length > 0; -} - -function isHiddenLinesFilled(value: HiddenLineValue | null) { - return Boolean( - value && - (value.hiddenTruths.length > 0 || - value.misdirectionHints.length > 0 || - value.revealPacing), - ); -} - -function isIconicElementsFilled(value: IconicElementValue | null) { - return Boolean( - value && - (value.iconicMotifs.length > 0 || - value.institutionsOrArtifacts.length > 0 || - value.hardRules.length > 0), - ); -} - -export function detectDriftRisk(params: { - chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>; - anchorContent: EightAnchorContent; - progressPercent: number; -}) { - const latestUserText = getLatestUserText(params.chatHistory).trim(); - const recentUserMessages = params.chatHistory - .filter((entry) => entry.role === 'user') - .slice(-3) - .map((entry) => entry.content.trim()) - .filter(Boolean); - - const correctionCount = recentUserMessages.filter((entry) => - /(不是|改成|改为|换成|推翻|重来|修正)/u.test(entry), - ).length; - - if ( - correctionCount >= 2 || - (params.progressPercent >= 65 && - /(不是|改成|改为|换成|重来|推翻)/u.test(latestUserText)) - ) { - return 'high' as const; - } - - const normalizedContent = normalizeEightAnchorContent(params.anchorContent); - const filledCount = [ - Boolean(normalizedContent.worldPromise), - Boolean(normalizedContent.playerFantasy), - isThemeBoundaryFilled(normalizedContent.themeBoundary), - Boolean(normalizedContent.playerEntryPoint), - Boolean(normalizedContent.coreConflict), - isRelationshipsFilled(normalizedContent.keyRelationships), - isHiddenLinesFilled(normalizedContent.hiddenLines), - isIconicElementsFilled(normalizedContent.iconicElements), - ].filter(Boolean).length; - - if (filledCount >= 3 && latestUserText.length >= 40) { - return 'medium' as const; - } - - return 'low' as const; -} - -export function pickConversationMode(params: { - currentTurn: number; - progressPercent: number; - userInputSignal: PromptUserInputSignal; - driftRisk: PromptDriftRisk; - quickFillRequested: boolean; -}) { - if (params.quickFillRequested) { - return 'force_complete' as const; - } - - if ( - params.userInputSignal === 'correction' || - params.driftRisk === 'high' - ) { - return 'repair_direction' as const; - } - - if (params.progressPercent >= 85 || params.currentTurn >= 15) { - return 'closing' as const; - } - - if (params.currentTurn > 10 || params.progressPercent >= 65) { - return 'compress' as const; - } - - if (params.currentTurn <= 10 && params.progressPercent < 65) { - return 'expand' as const; - } - - return 'bootstrap' as const; -} - -function buildRuleBasedPromptDynamicState(input: { - currentTurn: number; - progressPercent: number; - quickFillRequested: boolean; - currentAnchorContent: EightAnchorContent; - chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>; -}): PromptDynamicState { - const userInputSignal = detectUserInputSignal(input.chatHistory); - const driftRisk = detectDriftRisk({ - chatHistory: input.chatHistory, - anchorContent: input.currentAnchorContent, - progressPercent: input.progressPercent, - }); - - const conversationMode = pickConversationMode({ - currentTurn: input.currentTurn, - progressPercent: input.progressPercent, - userInputSignal, - driftRisk, - quickFillRequested: input.quickFillRequested, - }); - - return { - currentTurn: input.currentTurn, - progressPercent: input.progressPercent, - userInputSignal, - driftRisk, - quickFillRequested: input.quickFillRequested, - conversationMode, - judgementSummary: summarizeDynamicState({ - userInputSignal, - driftRisk, - conversationMode, - }), - }; -} - -export function buildPromptDynamicState(input: { - currentTurn: number; - progressPercent: number; - quickFillRequested: boolean; - currentAnchorContent: EightAnchorContent; - chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>; -}, inference?: PromptDynamicStateInference | null): PromptDynamicState { - const fallbackState = buildRuleBasedPromptDynamicState(input); - - if (!inference) { - return fallbackState; - } - - const userInputSignal = isPromptUserInputSignal(inference.userInputSignal) - ? inference.userInputSignal - : fallbackState.userInputSignal; - const driftRisk = isPromptDriftRisk(inference.driftRisk) - ? inference.driftRisk - : fallbackState.driftRisk; - const conversationMode = isPromptConversationMode(inference.conversationMode) - ? inference.conversationMode - : fallbackState.conversationMode; - const judgementSummary = - toText(inference.judgementSummary) || - summarizeDynamicState({ - userInputSignal, - driftRisk, - conversationMode, - }); - - return { - currentTurn: input.currentTurn, - progressPercent: input.progressPercent, - userInputSignal, - driftRisk, - quickFillRequested: input.quickFillRequested, - conversationMode, - judgementSummary, - }; -} - -export function buildPromptDynamicStateInferencePrompt(input: { - currentTurn: number; - progressPercent: number; - quickFillRequested: boolean; - currentAnchorContent: EightAnchorContent; - chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>; -}) { - const currentAnchorContent = - normalizeEightAnchorContent(input.currentAnchorContent) ?? - createEmptyEightAnchorContent(); - - return { - systemPrompt: [ - STATE_INFERENCE_SYSTEM_PROMPT, - STATE_INFERENCE_OUTPUT_CONTRACT, - ].join('\n\n'), - userPrompt: [ - `当前轮次:${input.currentTurn}`, - `当前完成度:${input.progressPercent}`, - `是否要求自动补全:${input.quickFillRequested ? '是' : '否'}`, - renderCurrentAnchorContext(currentAnchorContent), - renderChatHistoryContext(input.chatHistory), - ].join('\n\n'), - }; -} - -function renderDynamicStateContext(dynamicState: PromptDynamicState) { - return `上一轮预判得到的创作状态如下。 -正式生成时必须把它作为本轮策略输入直接执行,不要重新另起一套判断。 - -创作状态: -- userInputSignal: ${dynamicState.userInputSignal} -- driftRisk: ${dynamicState.driftRisk} -- conversationMode: ${dynamicState.conversationMode} -- judgementSummary: ${dynamicState.judgementSummary}`; -} - -function renderCurrentAnchorContext(anchorContent: EightAnchorContent) { - return `当前完整设定结构如下。 -你必须把它视为上一版有效世界底子。 - -如果用户没有否定其中某部分内容,且该部分仍然成立,可以继续保留。 -如果用户明确修正了某部分内容,新的完整设定结构必须体现修正后的版本。 - -当前完整设定结构: -${toJson(normalizeEightAnchorContent(anchorContent))}`; -} - -function renderChatHistoryContext( - chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>, -) { - return `以下是用户聊天记录。 -请重点理解最近几轮里用户新增、修正、强调的设定信息。 -不要把早期已经被用户否定的内容继续当成最终结论。 - -用户聊天记录: -${toJson(chatHistory)}`; -} - -export function buildEightAnchorSingleTurnPrompt(input: { - currentTurn: number; - progressPercent: number; - quickFillRequested: boolean; - currentAnchorContent: EightAnchorContent; - chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>; - dynamicState?: PromptDynamicStateInference | PromptDynamicState | null; -}) { - const currentAnchorContent = - normalizeEightAnchorContent(input.currentAnchorContent) ?? - createEmptyEightAnchorContent(); - const dynamicState = buildPromptDynamicState({ - ...input, - currentAnchorContent, - }, input.dynamicState); - - return { - prompt: [ - BASE_SYSTEM_PROMPT, - GLOBAL_HARD_RULES, - MODE_RULES[dynamicState.conversationMode], - USER_SIGNAL_RULES[dynamicState.userInputSignal], - dynamicState.quickFillRequested ? QUICK_FILL_EXTRA_RULES : null, - renderDynamicStateContext(dynamicState), - renderCurrentAnchorContext(currentAnchorContent), - renderChatHistoryContext(input.chatHistory), - OUTPUT_CONTRACT_REMINDER, - ] - .filter(Boolean) - .join('\n\n'), - dynamicState, - }; -} diff --git a/server-node/src/prompts/questPrompts.ts b/server-node/src/prompts/questPrompts.ts deleted file mode 100644 index c3233597..00000000 --- a/server-node/src/prompts/questPrompts.ts +++ /dev/null @@ -1,168 +0,0 @@ -import type { - QuestGenerationContext, - QuestOpportunity, - QuestSceneSnapshot, -} from '../modules/quest/runtimeQuestModule.js'; - -function summarizeRecentStoryMoments(context: QuestGenerationContext) { - const moments = context.recentStoryMoments - .slice(-4) - .map((moment) => `- ${moment.text}`) - .join('\n'); - - return moments || '- 暂无近期剧情记录'; -} - -function summarizeCurrentQuests(context: QuestGenerationContext) { - const summary = context.currentQuestSummary - ?.map( - (quest) => - `- ${quest.title}(${quest.status}),发布者 ${quest.issuerNpcId}`, - ) - .join('\n'); - - return summary || '- 当前没有进行中的任务'; -} - -function summarizeCompanions(context: QuestGenerationContext) { - const active = - context.activeCompanions?.map((companion) => companion.characterId).join('、') || - '无'; - const roster = - context.rosterCompanions?.map((companion) => companion.characterId).join('、') || - '无'; - return `当前同行角色:${active}\n队伍名册:${roster}`; -} - -function summarizePlayerState(context: QuestGenerationContext) { - const playerName = context.playerCharacter?.name ?? '未知角色'; - const playerTitle = context.playerCharacter?.title ?? '未知称号'; - const hp = `${context.playerHp ?? 0}/${context.playerMaxHp ?? 0}`; - const mana = `${context.playerMana ?? 0}/${context.playerMaxMana ?? 0}`; - const inventory = - context.playerInventory?.slice(0, 8).map((item) => item.name).join('、') || '无'; - - return [ - `玩家:${playerName}(${playerTitle})`, - `生命:${hp}`, - `灵力:${mana}`, - `背包快照:${inventory}`, - ].join('\n'); -} - -function summarizeScene( - scene: QuestSceneSnapshot | null, - context: QuestGenerationContext, -) { - const hostileNpcIds = context.currentSceneHostileNpcIds?.join('、') || '无'; - const treasureHintCount = context.currentSceneTreasureHintCount ?? 0; - - return [ - `场景:${scene?.name ?? context.currentSceneName ?? '未知区域'}`, - `场景描述:${scene?.description ?? context.currentSceneDescription ?? '暂无'}`, - `敌对角色 ID:${hostileNpcIds}`, - `宝藏线索数量:${treasureHintCount}`, - ].join('\n'); -} - -function summarizeActiveThreads(context: QuestGenerationContext) { - return context.activeThreadIds?.length - ? context.activeThreadIds.join('、') - : '暂无明确激活线程'; -} - -function summarizeIssuerNarrativeProfile(context: QuestGenerationContext) { - const profile = context.issuerNarrativeProfile; - if (!profile) { - return '暂无额外叙事档案'; - } - - return [ - `公开面:${profile.publicMask ?? '暂无'}`, - `表层线:${profile.visibleLine ?? '暂无'}`, - `当前压力:${profile.immediatePressure ?? '暂无'}`, - profile.reactionHooks?.length - ? `反应钩子:${profile.reactionHooks.join('、')}` - : null, - ] - .filter(Boolean) - .join('\n'); -} - -function describeWorld(worldType: QuestGenerationContext['worldType']) { - switch (worldType) { - case 'WUXIA': - return '边城模板'; - case 'XIANXIA': - return '灵潮模板'; - case 'CUSTOM': - return '自定义世界'; - default: - return '未知世界'; - } -} - -export const QUEST_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的任务导演。 -只返回 JSON,不要输出 Markdown。 - -输出结构: -{ - "intent": { - "title": "中文任务标题", - "description": "中文任务描述", - "summary": "中文短摘要", - "narrativeType": "bounty|escort|investigation|retrieval|relationship|trial", - "dramaticNeed": "string", - "issuerGoal": "string", - "playerHook": "string", - "worldReason": "string", - "recommendedObjectiveKinds": ["defeat_hostile_npc" | "inspect_treasure" | "spar_with_npc" | "talk_to_npc" | "reach_scene" | "deliver_item"], - "urgency": "low|medium|high", - "intimacy": "transactional|cooperative|trust_based", - "rewardTheme": "currency|resource|relationship|intel|rare_item", - "followupHooks": ["string"] - } -} - -规则: -- 所有自然语言字段都必须使用中文。 -- 任务必须扎根于当前场景、发布者和近期剧情。 -- 不要编造奖励、ID、数量、状态或不受支持的规则变化。 -- recommendedObjectiveKinds 只是语义建议,不是硬编码结果。 -- 优先给出简洁、可玩的任务框架,不要堆砌华丽辞藻。 -- title 必须是 4 到 10 个中文字符左右的短任务名,不要写成长句。 -- description 解释任务为什么在当前剧情里成立,避免纯规则说明。 -- summary 必须是清晰达成条件,优先使用“击败 / 调查 / 返回 / 前往 / 交付 / 切磋”等明确动词,不要写“处理一下”“看看情况”这类模糊表达。`; - -export function buildQuestIntentPrompt(params: { - context: QuestGenerationContext; - scene: QuestSceneSnapshot | null; - opportunity: QuestOpportunity; -}) { - const { context, scene, opportunity } = params; - const customWorldSummary = context.customWorldProfile - ? `${context.customWorldProfile.name ?? '自定义世界'}: ${ - context.customWorldProfile.summary ?? '暂无摘要' - }` - : '无'; - - return [ - `世界:${describeWorld(context.worldType)}`, - `自定义世界摘要:${customWorldSummary}`, - `发布角色:${context.issuerNpcName}(${context.issuerNpcId})`, - `发布者身份:${context.issuerNpcContext || '暂无'}`, - `发布者好感:${context.issuerAffinity ?? 0}`, - `发布者信息揭示阶段:${context.issuerDisclosureStage ?? '未知'}`, - `发布者亲疏阶段:${context.issuerWarmthStage ?? '未知'}`, - `当前激活线程:${summarizeActiveThreads(context)}`, - `发布者叙事档案:\n${summarizeIssuerNarrativeProfile(context)}`, - `当前遭遇类型:${context.encounterKind ?? '无'}`, - summarizeScene(scene, context), - summarizePlayerState(context), - summarizeCompanions(context), - `当前任务机会:${opportunity.reason}`, - `当前任务列表:\n${summarizeCurrentQuests(context)}`, - `近期剧情片段:\n${summarizeRecentStoryMoments(context)}`, - '现在请基于这次具体局势,生成一个自然生长出来的任务意图。', - ].join('\n\n'); -} diff --git a/server-node/src/prompts/runtimeItemPrompts.ts b/server-node/src/prompts/runtimeItemPrompts.ts deleted file mode 100644 index e41521fe..00000000 --- a/server-node/src/prompts/runtimeItemPrompts.ts +++ /dev/null @@ -1,43 +0,0 @@ -export const RUNTIME_ITEM_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的运行时物品导演。 -你只返回 JSON,不要输出 Markdown、解释或代码块。 - -输出结构: -{ - "intents": [ - { - "shortNameSeed": "中文短种子", - "sourcePhrase": "中文来源短语", - "reasonToAppear": "中文出现理由", - "relationHooks": ["中文关系钩子"], - "desiredBuildTags": ["中文 build 标签"], - "desiredFunctionalBias": ["heal|mana|cooldown|guard|damage"], - "tone": "grim|mysterious|martial|ritual|survival", - "visibleClue": "玩家第一眼能抓到的痕迹", - "witnessMark": "它见证过什么的使用痕", - "unfinishedBusiness": "背后仍未结清的问题", - "hiddenHook": "更深一层但别直接讲穿的钩子", - "reactionHooks": ["以后谁会对它起反应"], - "namingPattern": "命名范式建议" - } - ] -} - -规则: -- intents 数量必须与输入物品数量完全一致,顺序也必须一致。 -- 所有自然语言字段都必须使用中文。 -- 物品意图必须贴合当前场景、关系锚点、近期剧情和玩家当前 build。 -- desiredBuildTags 要优先围绕玩家当前 build 与待补缺口,不要写空数组。 -- desiredFunctionalBias 只能从给定枚举里选 1 到 2 个。 -- reasonToAppear 必须解释为什么这件东西会在当前局势里出现,而不是泛泛描述。`; - -export function buildRuntimeItemIntentPromptText(params: { - generationChannel: string; - planBlocks: string[]; -}) { - return [ - `生成渠道:${params.generationChannel}`, - '以下每个物品都需要给出一条可编译的运行时物品意图。', - ...params.planBlocks, - '请严格返回 JSON。', - ].join('\n\n'); -} diff --git a/server-node/src/prompts/storyOrchestratorPrompts.ts b/server-node/src/prompts/storyOrchestratorPrompts.ts deleted file mode 100644 index bb28a158..00000000 --- a/server-node/src/prompts/storyOrchestratorPrompts.ts +++ /dev/null @@ -1,33 +0,0 @@ -type StoryRepairResponse = { - storyText: string; - encounter?: unknown; - options: Array<{ - functionId: string; - actionText: string; - }>; -}; - -export const STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT = `你是 RPG 中文叙事文本修复器。 -你会收到一个已经解析过的剧情 JSON 对象。 -你的唯一任务是把 storyText 和 options[].actionText 中的英文句子、中英混杂句式、英文解释改写成自然中文。 -必须保持 JSON 结构、encounter、options 数量、options 顺序以及每个 functionId 完全不变。 -只输出一个 JSON 对象,不要输出 Markdown、代码块、解释、注释或额外文字。`; - -export function buildStoryLanguageRepairPrompt(response: StoryRepairResponse) { - return [ - '请把下面 JSON 中的 storyText 与 options[].actionText 修复为自然中文。', - '只改写叙事和选项文案,不要改变 encounter、options 数量、顺序或 functionId。', - JSON.stringify( - { - storyText: response.storyText, - encounter: response.encounter ?? null, - options: response.options.map((option) => ({ - functionId: option.functionId, - actionText: option.actionText, - })), - }, - null, - 2, - ), - ].join('\n\n'); -} diff --git a/server-node/src/prompts/storyPromptBuilders.ts b/server-node/src/prompts/storyPromptBuilders.ts deleted file mode 100644 index 346a8fac..00000000 --- a/server-node/src/prompts/storyPromptBuilders.ts +++ /dev/null @@ -1,197 +0,0 @@ -type JsonRecord = Record; - -function readString(value: unknown) { - return typeof value === 'string' && value.trim() ? value.trim() : null; -} - -function readNumber(value: unknown, fallback = 0) { - return typeof value === 'number' && Number.isFinite(value) ? value : fallback; -} - -function describeWorld(worldType: string) { - switch (worldType) { - case 'WUXIA': - return '边城模板'; - case 'XIANXIA': - return '灵潮模板'; - case 'CUSTOM': - return '自定义世界'; - default: - return worldType || '未知世界'; - } -} - -function describeCharacter(character: JsonRecord) { - return [ - `主角:${readString(character.name) ?? '未知角色'}`, - `称号:${readString(character.title) ?? '未知称号'}`, - `描述:${readString(character.description) ?? '暂无'}`, - `性格:${readString(character.personality) ?? '未显式提供'}`, - ].join('\n'); -} - -function describeMonsters(monsters: JsonRecord[]) { - if (monsters.length <= 0) { - return '当前敌对目标:无。'; - } - - return [ - '当前敌对目标:', - ...monsters.slice(0, 4).map((monster) => { - const name = readString(monster.name) ?? readString(monster.id) ?? '未知目标'; - const hp = readNumber(monster.hp); - const maxHp = Math.max(1, readNumber(monster.maxHp, hp)); - return `- ${name}(生命 ${hp}/${maxHp})`; - }), - ].join('\n'); -} - -function describeStoryHistory(history: JsonRecord[]) { - if (history.length <= 0) { - return '近期剧情:暂无。'; - } - - return [ - '近期剧情:', - ...history.slice(-4).map((moment) => `- ${readString(moment.text) ?? '(空白)'}`), - ].join('\n'); -} - -function describeRequestOptions(options: { - availableOptions?: Array>; - optionCatalog?: Array>; -}) { - const available = options.availableOptions ?? []; - const catalog = options.optionCatalog ?? []; - - if (available.length > 0) { - return [ - '固定可选项列表:', - ...available.map((option, index) => { - const functionId = readString(option.functionId) ?? 'unknown'; - const actionText = - readString(option.actionText) ?? - readString(option.text) ?? - '未提供文案'; - return `- 第 ${index + 1} 项 / ${functionId}:${actionText}`; - }), - '必须保持数量不变,functionId 不变,可以重写 actionText。'.trim(), - ].join('\n'); - } - - if (catalog.length > 0) { - return [ - '当前局面可调用的交互选项目录:', - ...catalog.map((option, index) => { - const functionId = readString(option.functionId) ?? 'unknown'; - const actionText = - readString(option.actionText) ?? - readString(option.text) ?? - '未提供文案'; - return `- 第 ${index + 1} 项 / ${functionId}:${actionText}`; - }), - 'functionId 只能从上面目录里选择。'.trim(), - ].join('\n'); - } - - return '当前没有固定目录,请根据局势生成合理选项。'; -} - -function hasNpcOptionCatalog(options: { - availableOptions?: Array>; - optionCatalog?: Array>; -}) { - return (options.optionCatalog ?? []).some((option) => - (readString(option.functionId) ?? '').startsWith('npc_'), - ); -} - -function isPostNpcChatReevaluation(params: { - choice?: string; - context: JsonRecord; - requestOptions?: { - availableOptions?: Array>; - optionCatalog?: Array>; - }; -}) { - return ( - readString(params.context.lastFunctionId) === 'npc_chat' && - hasNpcOptionCatalog(params.requestOptions ?? {}) && - Boolean(readString(params.choice)) - ); -} - -export const SYSTEM_PROMPT = `你是角色扮演 RPG 的剧情推进者,只能返回 JSON 对象,不能输出解释、markdown 或代码块。 -输出格式必须严格符合: -{ - "storyText": "剧情文本", - "encounter": null, - "options": [ - { - "functionId": "预定义功能ID", - "actionText": "选项显示文本" - } - ] -} - -严格规则: -- 所有文本必须是中文。 -- 如果提示语给出了固定可选项或可选目录,必须遵守给定的 functionId 范围。 -- storyText 必须直接承接当前场景、最近剧情和玩家刚刚的动作。 -- options 只允许输出 functionId 和 actionText。 -- 如果当前不是“继续推进后下一刻会遇到什么”的场景,encounter 必须保持为 null。`; - -export function buildUserPrompt(params: { - worldType: string; - character: JsonRecord; - monsters: JsonRecord[]; - history: JsonRecord[]; - context: JsonRecord; - choice?: string; - requestOptions?: { - availableOptions?: Array>; - optionCatalog?: Array>; - }; -}) { - const sceneName = readString(params.context.sceneName) ?? '当前区域'; - const sceneDescription = readString(params.context.sceneDescription) ?? '暂无场景附加描述。'; - const encounterName = readString(params.context.encounterName); - const playerHp = readNumber(params.context.playerHp); - const playerMaxHp = Math.max(1, readNumber(params.context.playerMaxHp, playerHp)); - const playerMana = readNumber(params.context.playerMana); - const playerMaxMana = Math.max(1, readNumber(params.context.playerMaxMana, playerMana)); - const inBattle = params.context.inBattle === true ? '战斗中' : '非战斗'; - const pendingSceneEncounter = - params.context.pendingSceneEncounter === true ? '是' : '否'; - const postNpcChatReevaluation = isPostNpcChatReevaluation(params); - - return [ - `世界:${describeWorld(params.worldType)}`, - `场景:${sceneName}`, - `场景描述:${sceneDescription}`, - encounterName ? `当前面前对象:${encounterName}` : null, - `当前状态:${inBattle}`, - `玩家生命:${playerHp}/${playerMaxHp}`, - `玩家灵力:${playerMana}/${playerMaxMana}`, - `是否需要判断下一刻遭遇:${pendingSceneEncounter}`, - describeCharacter(params.character), - describeMonsters(params.monsters), - describeStoryHistory(params.history), - params.choice ? `玩家刚刚选择:${params.choice}` : '玩家刚进入当前局面。', - describeRequestOptions(params.requestOptions ?? {}), - postNpcChatReevaluation - ? '当前这一步是刚结束一轮 NPC 交谈后,对眼前局势的再次判断。storyText 必须先落出刚才那段聊天带来的态度变化、气氛变化或新暴露的信息,再进入下一步局势。' - : null, - postNpcChatReevaluation - ? '如果输出 npc_ 开头的选项,这些 actionText 必须直接承接刚才聊到的话题、关系变化或对方态度,写成此刻自然浮现的回应,不要退回“继续交谈”“请求援手”“看看能交换什么”这类通用模板。' - : null, - postNpcChatReevaluation - ? '当前目录只是合法 function 范围,不代表都要出现;只保留此刻真正自然浮现、和刚才聊天结果有关的选项。' - : null, - params.context.pendingSceneEncounter === true - ? '如果这一轮明确是在判断继续推进后下一刻会遇到什么,可以填写 encounter;否则 encounter 必须为 null。' - : '当前这一步不是新的遭遇生成流程,encounter 必须为 null。', - ] - .filter(Boolean) - .join('\n\n'); -} diff --git a/server-node/src/repositories/RpgAgentSessionRepository.ts b/server-node/src/repositories/RpgAgentSessionRepository.ts deleted file mode 100644 index b75f66b6..00000000 --- a/server-node/src/repositories/RpgAgentSessionRepository.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js'; -import type { AppDatabase } from '../db.js'; -import { - type RpgAgentSessionRow, -} from './rpgWorldRepositoryShared.js'; - -/** - * RPG Agent session 仓储最小读写接口。 - * 工作包 F 后续所有 session store / test stub 都应优先依赖这个领域端口。 - */ -export type RpgAgentSessionRepositoryPort = { - listSessions(userId: string): Promise; - getSession( - userId: string, - sessionId: string, - ): Promise; - upsertSession( - userId: string, - sessionId: string, - session: CustomWorldSessionRecord, - ): Promise; -}; - -/** - * RPG Agent session 仓储只负责 session 表读写,不再承担兼容补齐与快照派生。 - */ -export class RpgAgentSessionRepository implements RpgAgentSessionRepositoryPort { - constructor(private readonly db: AppDatabase) {} - - async listSessions(userId: string) { - const result = await this.db.query( - `SELECT payload_json AS payload, - created_at AS "createdAt", - updated_at AS "updatedAt" - FROM custom_world_sessions - WHERE user_id = $1 - ORDER BY updated_at DESC`, - [userId], - ); - - return result.rows.map((row) => ({ - ...row.payload, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - })); - } - - async getSession(userId: string, sessionId: string) { - const result = await this.db.query( - `SELECT payload_json AS payload, - created_at AS "createdAt", - updated_at AS "updatedAt" - FROM custom_world_sessions - WHERE user_id = $1 AND session_id = $2`, - [userId, sessionId], - ); - const row = result.rows[0]; - - if (!row) { - return null; - } - - return { - ...row.payload, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }; - } - - async upsertSession( - userId: string, - sessionId: string, - session: CustomWorldSessionRecord, - ) { - const payload = { - ...session, - sessionId, - } satisfies CustomWorldSessionRecord; - - await this.db.query( - `INSERT INTO custom_world_sessions ( - user_id, - session_id, - payload_json, - created_at, - updated_at - ) VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (user_id, session_id) DO UPDATE SET - payload_json = EXCLUDED.payload_json, - updated_at = EXCLUDED.updated_at`, - [userId, sessionId, payload, session.createdAt, session.updatedAt], - ); - - return { - ...payload, - createdAt: session.createdAt, - updatedAt: session.updatedAt, - }; - } -} diff --git a/server-node/src/repositories/RpgWorldProfileRepository.ts b/server-node/src/repositories/RpgWorldProfileRepository.ts deleted file mode 100644 index 5fa2588d..00000000 --- a/server-node/src/repositories/RpgWorldProfileRepository.ts +++ /dev/null @@ -1,433 +0,0 @@ -import type { - CustomWorldGalleryCard, - CustomWorldLibraryEntry, - CustomWorldProfileRecord, -} from '../../../packages/shared/src/contracts/runtime.js'; -import { extractCustomWorldLibraryMetadata } from './customWorldLibraryMetadata.js'; -import type { AppDatabase } from '../db.js'; -import { - MAX_RPG_WORLD_GALLERY_ENTRIES, - MAX_RPG_WORLD_PROFILE_ENTRIES, - normalizeStoredRpgWorldProfile, - toRpgWorldGalleryCard, - toRpgWorldLibraryEntry, - type RpgWorldGalleryRow, - type RpgWorldProfileRow, -} from './rpgWorldRepositoryShared.js'; - -/** - * RPG 世界 profile 领域端口。 - * works、library、gallery、脚本同步等链路后续统一依赖这个接口,而不是 RuntimeRepositoryPort。 - */ -export type RpgWorldProfileRepositoryPort = { - listOwnProfiles( - userId: string, - ): Promise[]>; - upsertOwnProfile( - userId: string, - profileId: string, - profile: Record, - authorDisplayName: string, - ): Promise<{ - entry: CustomWorldLibraryEntry; - entries: CustomWorldLibraryEntry[]; - }>; - syncProfileFromSnapshot( - userId: string, - profileId: string, - profile: Record, - syncedAt: string, - ): Promise; - softDeleteOwnProfile( - userId: string, - profileId: string, - ): Promise[]>; - publishOwnProfile( - userId: string, - profileId: string, - authorDisplayName: string, - ): Promise<{ - entry: CustomWorldLibraryEntry; - entries: CustomWorldLibraryEntry[]; - } | null>; - unpublishOwnProfile( - userId: string, - profileId: string, - authorDisplayName: string, - ): Promise<{ - entry: CustomWorldLibraryEntry; - entries: CustomWorldLibraryEntry[]; - } | null>; - listPublishedGallery(): Promise; - getPublishedGalleryDetail( - ownerUserId: string, - profileId: string, - ): Promise | null>; -}; - -/** - * RPG 世界 profile 仓储统一负责作品库、发布态与画廊读写。 - */ -export class RpgWorldProfileRepository implements RpgWorldProfileRepositoryPort { - constructor(private readonly db: AppDatabase) {} - - private async findOwnProfileEntry(userId: string, profileId: string) { - const result = await this.db.query( - `SELECT user_id AS "ownerUserId", - profile_id AS "profileId", - payload_json AS payload, - visibility, - published_at AS "publishedAt", - updated_at AS "updatedAt", - author_display_name AS "authorDisplayName", - world_name AS "worldName", - subtitle, - summary_text AS "summaryText", - cover_image_src AS "coverImageSrc", - theme_mode AS "themeMode", - playable_npc_count AS "playableNpcCount", - landmark_count AS "landmarkCount" - FROM custom_world_profiles - WHERE user_id = $1 - AND profile_id = $2 - AND deleted_at IS NULL`, - [userId, profileId], - ); - - const row = result.rows[0]; - return row ? toRpgWorldLibraryEntry(row) : null; - } - - async listOwnProfiles(userId: string) { - const result = await this.db.query( - `SELECT user_id AS "ownerUserId", - profile_id AS "profileId", - payload_json AS payload, - visibility, - published_at AS "publishedAt", - updated_at AS "updatedAt", - author_display_name AS "authorDisplayName", - world_name AS "worldName", - subtitle, - summary_text AS "summaryText", - cover_image_src AS "coverImageSrc", - theme_mode AS "themeMode", - playable_npc_count AS "playableNpcCount", - landmark_count AS "landmarkCount" - FROM custom_world_profiles - WHERE user_id = $1 - AND deleted_at IS NULL - ORDER BY updated_at DESC - LIMIT $2`, - [userId, MAX_RPG_WORLD_PROFILE_ENTRIES], - ); - - return result.rows.map((row) => toRpgWorldLibraryEntry(row)); - } - - async upsertOwnProfile( - userId: string, - profileId: string, - profile: Record, - authorDisplayName: string, - ) { - const payload = normalizeStoredRpgWorldProfile(profileId, profile); - const metadata = extractCustomWorldLibraryMetadata(payload); - const now = new Date().toISOString(); - - await this.db.query( - `INSERT INTO custom_world_profiles ( - user_id, - profile_id, - payload_json, - updated_at, - author_display_name, - world_name, - subtitle, - summary_text, - cover_image_src, - theme_mode, - playable_npc_count, - landmark_count - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) - ON CONFLICT (user_id, profile_id) DO UPDATE SET - payload_json = EXCLUDED.payload_json, - updated_at = EXCLUDED.updated_at, - deleted_at = NULL, - author_display_name = EXCLUDED.author_display_name, - world_name = EXCLUDED.world_name, - subtitle = EXCLUDED.subtitle, - summary_text = EXCLUDED.summary_text, - cover_image_src = EXCLUDED.cover_image_src, - theme_mode = EXCLUDED.theme_mode, - playable_npc_count = EXCLUDED.playable_npc_count, - landmark_count = EXCLUDED.landmark_count`, - [ - userId, - profileId, - payload, - now, - authorDisplayName || '玩家', - metadata.worldName, - metadata.subtitle, - metadata.summaryText, - metadata.coverImageSrc, - metadata.themeMode, - metadata.playableNpcCount, - metadata.landmarkCount, - ], - ); - - const entry = await this.findOwnProfileEntry(userId, profileId); - if (!entry) { - throw new Error('failed to resolve custom world after upsert'); - } - - return { - entry, - entries: await this.listOwnProfiles(userId), - }; - } - - async syncProfileFromSnapshot( - userId: string, - profileId: string, - profile: Record, - syncedAt: string, - ) { - const payload = normalizeStoredRpgWorldProfile(profileId, profile); - const metadata = extractCustomWorldLibraryMetadata(payload); - - await this.db.query( - `INSERT INTO custom_world_profiles ( - user_id, - profile_id, - payload_json, - updated_at, - author_display_name, - world_name, - subtitle, - summary_text, - cover_image_src, - theme_mode, - playable_npc_count, - landmark_count, - deleted_at - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NULL) - ON CONFLICT (user_id, profile_id) DO UPDATE SET - payload_json = EXCLUDED.payload_json, - updated_at = EXCLUDED.updated_at, - deleted_at = NULL, - world_name = EXCLUDED.world_name, - subtitle = EXCLUDED.subtitle, - summary_text = EXCLUDED.summary_text, - cover_image_src = EXCLUDED.cover_image_src, - theme_mode = EXCLUDED.theme_mode, - playable_npc_count = EXCLUDED.playable_npc_count, - landmark_count = EXCLUDED.landmark_count`, - [ - userId, - profileId, - payload, - syncedAt, - '玩家', - metadata.worldName, - metadata.subtitle, - metadata.summaryText, - metadata.coverImageSrc, - metadata.themeMode, - metadata.playableNpcCount, - metadata.landmarkCount, - ], - ); - } - - async softDeleteOwnProfile(userId: string, profileId: string) { - const deletedAt = new Date().toISOString(); - await this.db.query( - `UPDATE custom_world_profiles - SET deleted_at = $1, - updated_at = $1, - visibility = 'draft', - published_at = NULL - WHERE user_id = $2 - AND profile_id = $3 - AND deleted_at IS NULL`, - [deletedAt, userId, profileId], - ); - - return this.listOwnProfiles(userId); - } - - async publishOwnProfile( - userId: string, - profileId: string, - authorDisplayName: string, - ) { - const existingEntry = await this.findOwnProfileEntry(userId, profileId); - if (!existingEntry) { - return null; - } - - const payload = normalizeStoredRpgWorldProfile( - profileId, - existingEntry.profile, - ); - const metadata = extractCustomWorldLibraryMetadata(payload); - const now = new Date().toISOString(); - - await this.db.query( - `UPDATE custom_world_profiles - SET visibility = 'published', - published_at = $1, - updated_at = $1, - author_display_name = $2, - world_name = $3, - subtitle = $4, - summary_text = $5, - cover_image_src = $6, - theme_mode = $7, - playable_npc_count = $8, - landmark_count = $9 - WHERE user_id = $10 - AND profile_id = $11`, - [ - now, - authorDisplayName || '玩家', - metadata.worldName, - metadata.subtitle, - metadata.summaryText, - metadata.coverImageSrc, - metadata.themeMode, - metadata.playableNpcCount, - metadata.landmarkCount, - userId, - profileId, - ], - ); - - const entry = await this.findOwnProfileEntry(userId, profileId); - if (!entry) { - throw new Error('failed to resolve custom world after publish'); - } - - return { - entry, - entries: await this.listOwnProfiles(userId), - }; - } - - async unpublishOwnProfile( - userId: string, - profileId: string, - authorDisplayName: string, - ) { - const existingEntry = await this.findOwnProfileEntry(userId, profileId); - if (!existingEntry) { - return null; - } - - const payload = normalizeStoredRpgWorldProfile( - profileId, - existingEntry.profile, - ); - const metadata = extractCustomWorldLibraryMetadata(payload); - const now = new Date().toISOString(); - - await this.db.query( - `UPDATE custom_world_profiles - SET visibility = 'draft', - published_at = NULL, - updated_at = $1, - author_display_name = $2, - world_name = $3, - subtitle = $4, - summary_text = $5, - cover_image_src = $6, - theme_mode = $7, - playable_npc_count = $8, - landmark_count = $9 - WHERE user_id = $10 - AND profile_id = $11`, - [ - now, - authorDisplayName || '玩家', - metadata.worldName, - metadata.subtitle, - metadata.summaryText, - metadata.coverImageSrc, - metadata.themeMode, - metadata.playableNpcCount, - metadata.landmarkCount, - userId, - profileId, - ], - ); - - const entry = await this.findOwnProfileEntry(userId, profileId); - if (!entry) { - throw new Error('failed to resolve custom world after unpublish'); - } - - return { - entry, - entries: await this.listOwnProfiles(userId), - }; - } - - async listPublishedGallery() { - const result = await this.db.query( - `SELECT user_id AS "ownerUserId", - profile_id AS "profileId", - visibility, - published_at AS "publishedAt", - updated_at AS "updatedAt", - author_display_name AS "authorDisplayName", - world_name AS "worldName", - subtitle, - summary_text AS "summaryText", - cover_image_src AS "coverImageSrc", - theme_mode AS "themeMode", - playable_npc_count AS "playableNpcCount", - landmark_count AS "landmarkCount" - FROM custom_world_profiles - WHERE visibility = 'published' - AND deleted_at IS NULL - ORDER BY published_at DESC, updated_at DESC - LIMIT $1`, - [MAX_RPG_WORLD_GALLERY_ENTRIES], - ); - - return result.rows.map((row) => toRpgWorldGalleryCard(row)); - } - - async getPublishedGalleryDetail(ownerUserId: string, profileId: string) { - const result = await this.db.query( - `SELECT user_id AS "ownerUserId", - profile_id AS "profileId", - payload_json AS payload, - visibility, - published_at AS "publishedAt", - updated_at AS "updatedAt", - author_display_name AS "authorDisplayName", - world_name AS "worldName", - subtitle, - summary_text AS "summaryText", - cover_image_src AS "coverImageSrc", - theme_mode AS "themeMode", - playable_npc_count AS "playableNpcCount", - landmark_count AS "landmarkCount" - FROM custom_world_profiles - WHERE user_id = $1 - AND profile_id = $2 - AND visibility = 'published' - AND deleted_at IS NULL`, - [ownerUserId, profileId], - ); - - const row = result.rows[0]; - return row ? toRpgWorldLibraryEntry(row) : null; - } -} diff --git a/server-node/src/repositories/authAuditLogRepository.ts b/server-node/src/repositories/authAuditLogRepository.ts deleted file mode 100644 index 5fffe14d..00000000 --- a/server-node/src/repositories/authAuditLogRepository.ts +++ /dev/null @@ -1,105 +0,0 @@ -import crypto from 'node:crypto'; - -import type { QueryResultRow } from 'pg'; - -import type { AuthAuditLogEventType } from '../../../packages/shared/src/contracts/auth.js'; -import type { AppDatabase } from '../db.js'; - -export type AuthAuditLogRecord = { - id: string; - userId: string; - eventType: AuthAuditLogEventType; - detail: string; - ip: string | null; - userAgent: string | null; - metaJson: Record | null; - createdAt: string; -}; - -type AuthAuditLogRow = QueryResultRow & { - id: string; - user_id: string; - event_type: AuthAuditLogEventType; - detail: string; - ip: string | null; - user_agent: string | null; - meta_json: Record | null; - created_at: string; -}; - -function toAuthAuditLogRecord( - row: AuthAuditLogRow | undefined, -): AuthAuditLogRecord | null { - if (!row) { - return null; - } - - return { - id: row.id, - userId: row.user_id, - eventType: row.event_type, - detail: row.detail, - ip: row.ip, - userAgent: row.user_agent, - metaJson: row.meta_json, - createdAt: row.created_at, - }; -} - -export class AuthAuditLogRepository { - constructor(private readonly db: AppDatabase) {} - - async create(input: { - userId: string; - eventType: AuthAuditLogEventType; - detail: string; - ip: string | null; - userAgent: string | null; - metaJson?: Record | null; - }) { - const id = `audit_${crypto.randomBytes(16).toString('hex')}`; - const createdAt = new Date().toISOString(); - - const result = await this.db.query( - `INSERT INTO auth_audit_logs ( - id, - user_id, - event_type, - detail, - ip, - user_agent, - meta_json, - created_at - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - RETURNING id, user_id, event_type, detail, ip, user_agent, meta_json, created_at`, - [ - id, - input.userId, - input.eventType, - input.detail, - input.ip, - input.userAgent, - input.metaJson ?? null, - createdAt, - ], - ); - - return toAuthAuditLogRecord(result.rows[0]); - } - - async listRecentByUserId(userId: string, limit = 20) { - const result = await this.db.query( - `SELECT id, user_id, event_type, detail, ip, user_agent, meta_json, created_at - FROM auth_audit_logs - WHERE user_id = $1 - ORDER BY created_at DESC - LIMIT $2`, - [userId, limit], - ); - - return result.rows - .map((row) => toAuthAuditLogRecord(row)) - .filter((row): row is AuthAuditLogRecord => Boolean(row)); - } -} diff --git a/server-node/src/repositories/authIdentityRepository.ts b/server-node/src/repositories/authIdentityRepository.ts deleted file mode 100644 index 1ced36e5..00000000 --- a/server-node/src/repositories/authIdentityRepository.ts +++ /dev/null @@ -1,156 +0,0 @@ -import crypto from 'node:crypto'; - -import type { QueryResultRow } from 'pg'; - -import type { AppDatabase } from '../db.js'; - -export type AuthIdentityProvider = 'wechat'; - -export type AuthIdentityRecord = { - id: string; - userId: string; - provider: AuthIdentityProvider; - providerUid: string; - providerUnionId: string | null; - displayName: string | null; - avatarUrl: string | null; - isVerified: boolean; - metaJson: Record | null; - createdAt: string; - updatedAt: string; -}; - -type AuthIdentityRow = QueryResultRow & { - id: string; - user_id: string; - provider: AuthIdentityProvider; - provider_uid: string; - provider_unionid: string | null; - display_name: string | null; - avatar_url: string | null; - is_verified: boolean; - meta_json: Record | null; - created_at: string; - updated_at: string; -}; - -function toAuthIdentityRecord( - row: AuthIdentityRow | undefined, -): AuthIdentityRecord | null { - if (!row) { - return null; - } - - return { - id: row.id, - userId: row.user_id, - provider: row.provider, - providerUid: row.provider_uid, - providerUnionId: row.provider_unionid, - displayName: row.display_name, - avatarUrl: row.avatar_url, - isVerified: row.is_verified, - metaJson: row.meta_json, - createdAt: row.created_at, - updatedAt: row.updated_at, - }; -} - -export type CreateWechatIdentityInput = { - userId: string; - providerUid: string; - providerUnionId: string | null; - displayName: string | null; - avatarUrl: string | null; - metaJson?: Record | null; -}; - -export class AuthIdentityRepository { - constructor(private readonly db: AppDatabase) {} - - async findWechatIdentityByProfile(params: { - providerUid: string; - providerUnionId: string | null; - }) { - const result = params.providerUnionId - ? await this.db.query( - `SELECT id, user_id, provider, provider_uid, provider_unionid, display_name, avatar_url, is_verified, meta_json, created_at, updated_at - FROM auth_identities - WHERE provider = 'wechat' - AND (provider_unionid = $1 OR provider_uid = $2) - ORDER BY - CASE WHEN provider_unionid = $1 THEN 0 ELSE 1 END - LIMIT 1`, - [params.providerUnionId, params.providerUid], - ) - : await this.db.query( - `SELECT id, user_id, provider, provider_uid, provider_unionid, display_name, avatar_url, is_verified, meta_json, created_at, updated_at - FROM auth_identities - WHERE provider = 'wechat' - AND provider_uid = $1 - LIMIT 1`, - [params.providerUid], - ); - - return toAuthIdentityRecord(result.rows[0]); - } - - async listByUserId(userId: string) { - const result = await this.db.query( - `SELECT id, user_id, provider, provider_uid, provider_unionid, display_name, avatar_url, is_verified, meta_json, created_at, updated_at - FROM auth_identities - WHERE user_id = $1 - ORDER BY provider, created_at`, - [userId], - ); - - return result.rows - .map((row) => toAuthIdentityRecord(row)) - .filter((row): row is AuthIdentityRecord => Boolean(row)); - } - - async createWechatIdentity(input: CreateWechatIdentityInput) { - const now = new Date().toISOString(); - const identityId = `authi_${crypto.randomBytes(16).toString('hex')}`; - const result = await this.db.query( - `INSERT INTO auth_identities ( - id, - user_id, - provider, - provider_uid, - provider_unionid, - display_name, - avatar_url, - is_verified, - meta_json, - created_at, - updated_at - ) - VALUES ($1, $2, 'wechat', $3, $4, $5, $6, TRUE, $7, $8, $9) - RETURNING id, user_id, provider, provider_uid, provider_unionid, display_name, avatar_url, is_verified, meta_json, created_at, updated_at`, - [ - identityId, - input.userId, - input.providerUid, - input.providerUnionId, - input.displayName, - input.avatarUrl, - input.metaJson ?? null, - now, - now, - ], - ); - - return toAuthIdentityRecord(result.rows[0]); - } - - async moveWechatIdentitiesToUser(sourceUserId: string, targetUserId: string) { - await this.db.query( - `UPDATE auth_identities - SET user_id = $1, updated_at = $2 - WHERE user_id = $3 - AND provider = 'wechat'`, - [targetUserId, new Date().toISOString(), sourceUserId], - ); - } -} diff --git a/server-node/src/repositories/authRiskBlockRepository.ts b/server-node/src/repositories/authRiskBlockRepository.ts deleted file mode 100644 index 45ccf928..00000000 --- a/server-node/src/repositories/authRiskBlockRepository.ts +++ /dev/null @@ -1,128 +0,0 @@ -import crypto from 'node:crypto'; - -import type { QueryResultRow } from 'pg'; - -import type { AppDatabase } from '../db.js'; - -export type AuthRiskBlockScopeType = 'phone' | 'ip'; - -export type AuthRiskBlockRecord = { - id: string; - scopeType: AuthRiskBlockScopeType; - scopeKey: string; - reason: string; - expiresAt: string; - liftedAt: string | null; - createdAt: string; - updatedAt: string; -}; - -type AuthRiskBlockRow = QueryResultRow & { - id: string; - scope_type: AuthRiskBlockScopeType; - scope_key: string; - reason: string; - expires_at: string; - lifted_at: string | null; - created_at: string; - updated_at: string; -}; - -function toAuthRiskBlockRecord( - row: AuthRiskBlockRow | undefined, -): AuthRiskBlockRecord | null { - if (!row) { - return null; - } - - return { - id: row.id, - scopeType: row.scope_type, - scopeKey: row.scope_key, - reason: row.reason, - expiresAt: row.expires_at, - liftedAt: row.lifted_at, - createdAt: row.created_at, - updatedAt: row.updated_at, - }; -} - -export class AuthRiskBlockRepository { - constructor(private readonly db: AppDatabase) {} - - async findActive(scopeType: AuthRiskBlockScopeType, scopeKey: string) { - const result = await this.db.query( - `SELECT id, scope_type, scope_key, reason, expires_at, lifted_at, created_at, updated_at - FROM auth_risk_blocks - WHERE scope_type = $1 - AND scope_key = $2 - AND lifted_at IS NULL - AND expires_at > $3 - ORDER BY expires_at DESC - LIMIT 1`, - [scopeType, scopeKey, new Date().toISOString()], - ); - - return toAuthRiskBlockRecord(result.rows[0]); - } - - async createOrRefresh(input: { - scopeType: AuthRiskBlockScopeType; - scopeKey: string; - reason: string; - expiresAt: string; - }) { - const existing = await this.findActive(input.scopeType, input.scopeKey); - if (existing) { - const result = await this.db.query( - `UPDATE auth_risk_blocks - SET reason = $1, - expires_at = $2, - updated_at = $3 - WHERE id = $4 - RETURNING id, scope_type, scope_key, reason, expires_at, lifted_at, created_at, updated_at`, - [input.reason, input.expiresAt, new Date().toISOString(), existing.id], - ); - return toAuthRiskBlockRecord(result.rows[0]); - } - - const id = `risk_${crypto.randomBytes(16).toString('hex')}`; - const now = new Date().toISOString(); - const result = await this.db.query( - `INSERT INTO auth_risk_blocks ( - id, - scope_type, - scope_key, - reason, - expires_at, - lifted_at, - created_at, - updated_at - ) - VALUES ($1, $2, $3, $4, $5, NULL, $6, $7) - RETURNING id, scope_type, scope_key, reason, expires_at, lifted_at, created_at, updated_at`, - [id, input.scopeType, input.scopeKey, input.reason, input.expiresAt, now, now], - ); - - return toAuthRiskBlockRecord(result.rows[0]); - } - - async liftActive(scopeType: AuthRiskBlockScopeType, scopeKey: string) { - const now = new Date().toISOString(); - const result = await this.db.query( - `UPDATE auth_risk_blocks - SET lifted_at = $1, - updated_at = $2 - WHERE scope_type = $3 - AND scope_key = $4 - AND lifted_at IS NULL - AND expires_at > $5 - RETURNING id, scope_type, scope_key, reason, expires_at, lifted_at, created_at, updated_at`, - [now, now, scopeType, scopeKey, now], - ); - - return result.rows - .map((row) => toAuthRiskBlockRecord(row)) - .filter((row): row is AuthRiskBlockRecord => Boolean(row)); - } -} diff --git a/server-node/src/repositories/customWorldLibraryMetadata.test.ts b/server-node/src/repositories/customWorldLibraryMetadata.test.ts deleted file mode 100644 index 6aa3913a..00000000 --- a/server-node/src/repositories/customWorldLibraryMetadata.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { buildCustomWorldCoverImageSrc, resolveCustomWorldCoverPresentation } from './customWorldLibraryMetadata.js'; - -function createProfile() { - return { - id: 'profile-cover-test', - name: '潮雾群岛', - subtitle: '封面规则测试', - summary: '验证作品库封面优先级。', - tone: '潮湿、压抑', - playerGoal: '查明旧航道真相。', - playableNpcs: [ - { - id: 'playable-1', - name: '林潮', - imageSrc: '/images/roles/linchao.webp', - }, - ], - camp: { - imageSrc: '/images/camp/camp.webp', - }, - landmarks: [ - { - imageSrc: '/images/landmark/docks.webp', - }, - ], - sceneChapterBlueprints: [ - { - id: 'scene-chapter-1', - acts: [ - { - id: 'act-1', - backgroundImageSrc: '/images/scene/act-1.webp', - backgroundAssetId: 'asset-scene-act-1', - }, - ], - }, - ], - }; -} - -test('resolveCustomWorldCoverPresentation 优先使用开局场景第一幕图片', () => { - const profile = createProfile(); - - const result = resolveCustomWorldCoverPresentation(profile); - - assert.equal(result.imageSrc, '/images/scene/act-1.webp'); - assert.equal(result.renderMode, 'scene_with_roles'); - assert.deepEqual(result.characterImageSrcs, ['/images/roles/linchao.webp']); -}); - -test('buildCustomWorldCoverImageSrc 在第一幕图片缺失时按营地图与地标图回退', () => { - const profile = createProfile(); - profile.sceneChapterBlueprints = [ - { - id: 'scene-chapter-1', - acts: [ - { - id: 'act-1', - backgroundImageSrc: '', - backgroundAssetId: '', - }, - ], - }, - ]; - - assert.equal(buildCustomWorldCoverImageSrc(profile), '/images/camp/camp.webp'); - - profile.camp = { - imageSrc: '', - }; - - assert.equal(buildCustomWorldCoverImageSrc(profile), '/images/landmark/docks.webp'); -}); diff --git a/server-node/src/repositories/customWorldLibraryMetadata.ts b/server-node/src/repositories/customWorldLibraryMetadata.ts deleted file mode 100644 index 8e6d632b..00000000 --- a/server-node/src/repositories/customWorldLibraryMetadata.ts +++ /dev/null @@ -1,204 +0,0 @@ -import type { - CustomWorldProfileRecord, - CustomWorldThemeMode, -} from '../../../packages/shared/src/contracts/runtime.js'; - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function readString(value: unknown, fallback = '') { - return typeof value === 'string' && value.trim() ? value.trim() : fallback; -} - -function readArray(value: unknown) { - return Array.isArray(value) ? value : []; -} - -function readImageSrc(value: unknown) { - return readString(value) || null; -} - -type CustomWorldCoverRenderMode = 'image' | 'scene_with_roles'; - -function normalizeCoverCharacterRoleIds( - value: unknown, - playableRoles: Record[], -) { - const availableIds = new Set( - playableRoles.map((role) => readString(role.id)).filter(Boolean), - ); - const selectedIds = readArray(value) - .map((entry) => readString(entry)) - .filter((entry) => entry && availableIds.has(entry)); - - if (selectedIds.length > 0) { - return [...new Set(selectedIds)].slice(0, 3); - } - - return [...availableIds].slice(0, 3); -} - -function resolveOpeningSceneFirstActImageSrc(profile: CustomWorldProfileRecord) { - const sceneChapters = readArray(profile.sceneChapterBlueprints); - const firstSceneChapter = sceneChapters.find(isRecord) ?? null; - const firstAct = firstSceneChapter - ? readArray(firstSceneChapter.acts).find(isRecord) ?? null - : null; - - return firstAct ? readImageSrc(firstAct.backgroundImageSrc) : null; -} - -function resolveOpeningSceneImageSrc(profile: CustomWorldProfileRecord) { - // 默认封面优先取开局场景第一幕图,保证创作草稿、作品库和正式结果页看到的是同一张“开场镜头”。 - const firstActImage = resolveOpeningSceneFirstActImageSrc(profile); - if (firstActImage) { - return firstActImage; - } - - const campImage = isRecord(profile.camp) - ? readImageSrc(profile.camp.imageSrc) - : null; - if (campImage) { - return campImage; - } - - return ( - readArray(profile.landmarks) - .map((landmark) => - isRecord(landmark) ? readImageSrc(landmark.imageSrc) : null, - ) - .find(Boolean) || null - ); -} - -function resolveLeadPlayableImageSrc(playableRoles: Record[]) { - return ( - playableRoles - .map((role) => readImageSrc(role.imageSrc)) - .find(Boolean) || null - ); -} - -export function resolveCustomWorldCoverPresentation( - profile: CustomWorldProfileRecord, -): { - imageSrc: string | null; - renderMode: CustomWorldCoverRenderMode; - characterImageSrcs: string[]; - sourceType: 'default' | 'uploaded' | 'generated'; -} { - const playableRoles = readArray(profile.playableNpcs).filter(isRecord); - const cover = isRecord(profile.cover) ? profile.cover : null; - const requestedSourceType = readString(cover?.sourceType); - const sourceType = - requestedSourceType === 'uploaded' || - requestedSourceType === 'generated' - ? requestedSourceType - : 'default'; - - if (sourceType !== 'default') { - const explicitImageSrc = readImageSrc(cover?.imageSrc); - if (explicitImageSrc) { - return { - imageSrc: explicitImageSrc, - renderMode: 'image', - characterImageSrcs: [], - sourceType, - }; - } - } - - const openingSceneImageSrc = resolveOpeningSceneImageSrc(profile); - const roleById = new Map( - playableRoles.map((role) => [readString(role.id), role] as const), - ); - const characterImageSrcs = normalizeCoverCharacterRoleIds( - cover?.characterRoleIds, - playableRoles, - ) - .map((roleId) => readImageSrc(roleById.get(roleId)?.imageSrc)) - .filter((imageSrc): imageSrc is string => Boolean(imageSrc)); - const leadPlayableImageSrc = resolveLeadPlayableImageSrc(playableRoles); - - return { - imageSrc: openingSceneImageSrc || leadPlayableImageSrc, - renderMode: - openingSceneImageSrc && characterImageSrcs.length > 0 - ? 'scene_with_roles' - : 'image', - characterImageSrcs: - openingSceneImageSrc && characterImageSrcs.length > 0 - ? characterImageSrcs - : [], - sourceType: 'default', - }; -} - -function detectThemeMode( - profile: Pick< - CustomWorldProfileRecord, - | 'settingText' - | 'summary' - | 'tone' - | 'playerGoal' - | 'templateWorldType' - | 'compatibilityTemplateWorldType' - | 'ownedSettingLayers' - >, -): CustomWorldThemeMode { - const semanticAnchor = isRecord(profile.ownedSettingLayers) - && isRecord(profile.ownedSettingLayers.semanticAnchor) - ? profile.ownedSettingLayers.semanticAnchor - : null; - const expressionProfile = isRecord(profile.ownedSettingLayers) - && isRecord(profile.ownedSettingLayers.expressionProfile) - ? profile.ownedSettingLayers.expressionProfile - : null; - const source = [ - readString(profile.settingText), - readString(profile.summary), - readString(profile.tone), - readString(profile.playerGoal), - ...readArray(semanticAnchor?.genreSignals).map((value) => readString(value)), - ...readArray(semanticAnchor?.conflictForms).map((value) => readString(value)), - ...readArray(semanticAnchor?.institutionTypes).map((value) => readString(value)), - ...readArray(semanticAnchor?.tabooTypes).map((value) => readString(value)), - ...readArray(semanticAnchor?.carrierTypes).map((value) => readString(value)), - ...readArray(semanticAnchor?.forceSystemTypes).map((value) => readString(value)), - ...readArray(semanticAnchor?.atmosphereTags).map((value) => readString(value)), - ...readArray(expressionProfile?.presentationTone).map((value) => readString(value)), - ].join(' '); - - if (/[机关蒸汽齿轮工坊机巧城轨炮舰]/u.test(source)) return 'machina'; - if (/[海潮港湾船舟湖泊澜雾湾]/u.test(source)) return 'tide'; - if (/[裂缝裂界边境前线断层界桥灰域]/u.test(source)) return 'rift'; - if (/[修真仙灵宗门法器道脉秘境云阙]/u.test(source)) return 'arcane'; - if (/[江湖门派镖局朝廷刀剑侠客旧案]/u.test(source)) return 'martial'; - - return 'mythic'; -} - -export function buildCustomWorldCoverImageSrc(profile: CustomWorldProfileRecord) { - return resolveCustomWorldCoverPresentation(profile).imageSrc; -} - -export function extractCustomWorldLibraryMetadata(profile: CustomWorldProfileRecord) { - return { - worldName: readString(profile.name, '未命名世界'), - subtitle: readString(profile.subtitle), - summaryText: readString(profile.summary), - coverImageSrc: buildCustomWorldCoverImageSrc(profile), - themeMode: detectThemeMode({ - settingText: profile.settingText, - summary: profile.summary, - tone: profile.tone, - playerGoal: profile.playerGoal, - templateWorldType: profile.templateWorldType, - compatibilityTemplateWorldType: profile.compatibilityTemplateWorldType, - ownedSettingLayers: profile.ownedSettingLayers, - }), - playableNpcCount: readArray(profile.playableNpcs).length, - landmarkCount: readArray(profile.landmarks).length, - }; -} diff --git a/server-node/src/repositories/rpg-entry/RpgEntryRepositories.test.ts b/server-node/src/repositories/rpg-entry/RpgEntryRepositories.test.ts deleted file mode 100644 index e2237114..00000000 --- a/server-node/src/repositories/rpg-entry/RpgEntryRepositories.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import type { RuntimeRepositoryPort } from '../runtimeRepository.js'; -import { RpgSaveArchiveRepository } from './RpgSaveArchiveRepository.js'; -import { RpgWorldLibraryRepository } from './RpgWorldLibraryRepository.js'; - -function createRuntimeRepositoryStub(): RuntimeRepositoryPort { - return { - async getSnapshot() { - return null; - }, - async putSnapshot(_userId, payload) { - return { - version: 1, - ...payload, - }; - }, - async getProfileDashboard() { - return { - walletBalance: 0, - totalPlayTimeMs: 0, - playedWorldCount: 0, - updatedAt: '2026-04-21T00:00:00.000Z', - }; - }, - async listProfileWalletLedger() { - return []; - }, - async getProfilePlayStats() { - return { - totalPlayTimeMs: 0, - playedWorks: [], - updatedAt: '2026-04-21T00:00:00.000Z', - }; - }, - async listProfileSaveArchives() { - return [ - { - worldKey: 'world-1', - ownerUserId: 'owner-1', - profileId: 'profile-1', - worldType: 'custom', - worldName: '潮影群岛', - subtitle: '港雾与旧航道', - summaryText: '最近一次继续游戏入口', - coverImageSrc: null, - lastPlayedAt: '2026-04-20T23:59:59.000Z', - }, - ]; - }, - async resumeProfileSaveArchive() { - return { - entry: { - worldKey: 'world-1', - ownerUserId: 'owner-1', - profileId: 'profile-1', - worldType: 'custom', - worldName: '潮影群岛', - subtitle: '港雾与旧航道', - summaryText: '最近一次继续游戏入口', - coverImageSrc: null, - lastPlayedAt: '2026-04-20T23:59:59.000Z', - }, - snapshot: { - version: 1, - savedAt: '2026-04-20T23:59:59.000Z', - bottomTab: 'adventure', - gameState: { currentScene: '潮影港' }, - currentStory: null, - }, - }; - }, - async deleteSnapshot() {}, - async getSettings() { - return { - musicVolume: 0.42, - platformTheme: 'light', - }; - }, - async putSettings(_userId, settings) { - return settings; - }, - async listCustomWorldProfiles() { - return [ - { - ownerUserId: 'owner-1', - profileId: 'profile-1', - profile: { - id: 'profile-1', - }, - visibility: 'published', - publishedAt: '2026-04-20T08:00:00.000Z', - updatedAt: '2026-04-20T08:00:00.000Z', - authorDisplayName: '造物者', - worldName: '潮影群岛', - subtitle: '港雾与旧航道', - summaryText: '一座在潮汐中漂移的群岛。', - coverImageSrc: '/covers/tide.png', - themeMode: 'tide', - playableNpcCount: 2, - landmarkCount: 3, - }, - ]; - }, - async listPlatformBrowseHistory() { - return []; - }, - async upsertPlatformBrowseHistoryEntries() { - return []; - }, - async clearPlatformBrowseHistory() {}, - async upsertCustomWorldProfile() { - return { - entry: { - ownerUserId: 'owner-1', - profileId: 'profile-1', - profile: { - id: 'profile-1', - }, - visibility: 'draft', - publishedAt: null, - updatedAt: '2026-04-20T08:00:00.000Z', - authorDisplayName: '造物者', - worldName: '潮影群岛', - subtitle: '港雾与旧航道', - summaryText: '一座在潮汐中漂移的群岛。', - coverImageSrc: '/covers/tide.png', - themeMode: 'tide', - playableNpcCount: 2, - landmarkCount: 3, - }, - entries: [], - }; - }, - async deleteCustomWorldProfile() { - return []; - }, - async listCustomWorldSessions() { - return []; - }, - async getCustomWorldSession() { - return null; - }, - async upsertCustomWorldSession(_userId, _sessionId, session) { - return session; - }, - async publishCustomWorldProfile() { - return { - entry: { - ownerUserId: 'owner-1', - profileId: 'profile-1', - profile: { - id: 'profile-1', - }, - visibility: 'published', - publishedAt: '2026-04-20T08:00:00.000Z', - updatedAt: '2026-04-20T08:00:00.000Z', - authorDisplayName: '造物者', - worldName: '潮影群岛', - subtitle: '港雾与旧航道', - summaryText: '一座在潮汐中漂移的群岛。', - coverImageSrc: '/covers/tide.png', - themeMode: 'tide', - playableNpcCount: 2, - landmarkCount: 3, - }, - entries: [], - }; - }, - async unpublishCustomWorldProfile() { - return null; - }, - async listPublishedCustomWorldGallery() { - return [ - { - ownerUserId: 'owner-1', - profileId: 'profile-1', - visibility: 'published', - publishedAt: '2026-04-20T08:00:00.000Z', - updatedAt: '2026-04-20T08:00:00.000Z', - authorDisplayName: '造物者', - worldName: '潮影群岛', - subtitle: '港雾与旧航道', - summaryText: '一座在潮汐中漂移的群岛。', - coverImageSrc: '/covers/tide.png', - themeMode: 'tide', - playableNpcCount: 2, - landmarkCount: 3, - }, - ]; - }, - async getPublishedCustomWorldGalleryDetail() { - return { - ownerUserId: 'owner-1', - profileId: 'profile-1', - profile: { - id: 'profile-1', - }, - visibility: 'published', - publishedAt: '2026-04-20T08:00:00.000Z', - updatedAt: '2026-04-20T08:00:00.000Z', - authorDisplayName: '造物者', - worldName: '潮影群岛', - subtitle: '港雾与旧航道', - summaryText: '一座在潮汐中漂移的群岛。', - coverImageSrc: '/covers/tide.png', - themeMode: 'tide', - playableNpcCount: 2, - landmarkCount: 3, - }; - }, - }; -} - -test('RpgSaveArchiveRepository 只承接继续游戏归档读取职责', async () => { - const repository = new RpgSaveArchiveRepository(createRuntimeRepositoryStub()); - - const archives = await repository.listProfileSaveArchives('user-1'); - const resumed = await repository.resumeProfileSaveArchive('user-1', 'world-1'); - - assert.equal(archives[0]?.worldName, '潮影群岛'); - assert.equal(resumed?.snapshot.bottomTab, 'adventure'); - assert.equal('getSnapshot' in repository, false); -}); - -test('RpgWorldLibraryRepository 独立承接作品库与广场读取职责', async () => { - const repository = new RpgWorldLibraryRepository(createRuntimeRepositoryStub()); - - const profiles = await repository.listCustomWorldProfiles('user-1'); - const gallery = await repository.listPublishedCustomWorldGallery(); - const detail = await repository.getPublishedCustomWorldGalleryDetail( - 'owner-1', - 'profile-1', - ); - - assert.equal(profiles[0]?.worldName, '潮影群岛'); - assert.equal(gallery[0]?.themeMode, 'tide'); - assert.equal(detail?.profileId, 'profile-1'); - assert.equal('listProfileSaveArchives' in repository, false); -}); diff --git a/server-node/src/repositories/rpg-entry/RpgSaveArchiveRepository.ts b/server-node/src/repositories/rpg-entry/RpgSaveArchiveRepository.ts deleted file mode 100644 index 4b79c559..00000000 --- a/server-node/src/repositories/rpg-entry/RpgSaveArchiveRepository.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { - RuntimeRepositoryPort, - SavedSnapshot, -} from '../runtimeRepository.js'; -import type { ProfileSaveArchiveSummary } from '../../../../packages/shared/src/contracts/runtime.js'; - -/** - * RPG 继续游戏归档仓储端口。 - * 当前仍由 runtimeRepository 提供真实实现,本文件只建立按领域命名的兼容入口。 - */ -export type RpgSaveArchiveRepositoryPort = Pick< - RuntimeRepositoryPort, - 'listProfileSaveArchives' | 'resumeProfileSaveArchive' ->; -export type RpgSaveArchiveSnapshot = SavedSnapshot; - -export class RpgSaveArchiveRepository implements RpgSaveArchiveRepositoryPort { - constructor(private readonly runtimeRepository: RuntimeRepositoryPort) {} - - listProfileSaveArchives(userId: string): Promise { - return this.runtimeRepository.listProfileSaveArchives(userId); - } - - resumeProfileSaveArchive( - userId: string, - worldKey: string, - ): Promise< - | { - entry: ProfileSaveArchiveSummary; - snapshot: RpgSaveArchiveSnapshot; - } - | null - > { - return this.runtimeRepository.resumeProfileSaveArchive(userId, worldKey); - } -} diff --git a/server-node/src/repositories/rpg-entry/RpgWorldLibraryRepository.ts b/server-node/src/repositories/rpg-entry/RpgWorldLibraryRepository.ts deleted file mode 100644 index e22ef70e..00000000 --- a/server-node/src/repositories/rpg-entry/RpgWorldLibraryRepository.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { - CustomWorldGalleryCard, - CustomWorldLibraryEntry, - CustomWorldProfileRecord, -} from '../../../../packages/shared/src/contracts/runtime.js'; -import type { RuntimeRepositoryPort } from '../runtimeRepository.js'; - -/** - * RPG 世界库仓储端口。 - * 当前先桥接旧 runtimeRepository 中的世界库与广场读写,为工作包 H 的按域拆仓储提供命名骨架。 - */ -export type RpgWorldLibraryRepositoryPort = Pick< - RuntimeRepositoryPort, - | 'deleteCustomWorldProfile' - | 'getPublishedCustomWorldGalleryDetail' - | 'listCustomWorldProfiles' - | 'listPublishedCustomWorldGallery' - | 'publishCustomWorldProfile' - | 'unpublishCustomWorldProfile' - | 'upsertCustomWorldProfile' ->; - -export class RpgWorldLibraryRepository - implements RpgWorldLibraryRepositoryPort -{ - constructor(private readonly runtimeRepository: RuntimeRepositoryPort) {} - - listCustomWorldProfiles( - userId: string, - ): Promise[]> { - return this.runtimeRepository.listCustomWorldProfiles(userId); - } - - upsertCustomWorldProfile( - userId: string, - profileId: string, - profile: Record, - authorDisplayName: string, - ) { - return this.runtimeRepository.upsertCustomWorldProfile( - userId, - profileId, - profile, - authorDisplayName, - ); - } - - deleteCustomWorldProfile( - userId: string, - profileId: string, - ): Promise[]> { - return this.runtimeRepository.deleteCustomWorldProfile(userId, profileId); - } - - publishCustomWorldProfile( - userId: string, - profileId: string, - authorDisplayName: string, - ) { - return this.runtimeRepository.publishCustomWorldProfile( - userId, - profileId, - authorDisplayName, - ); - } - - unpublishCustomWorldProfile( - userId: string, - profileId: string, - authorDisplayName: string, - ) { - return this.runtimeRepository.unpublishCustomWorldProfile( - userId, - profileId, - authorDisplayName, - ); - } - - listPublishedCustomWorldGallery(): Promise { - return this.runtimeRepository.listPublishedCustomWorldGallery(); - } - - getPublishedCustomWorldGalleryDetail( - ownerUserId: string, - profileId: string, - ): Promise | null> { - return this.runtimeRepository.getPublishedCustomWorldGalleryDetail( - ownerUserId, - profileId, - ); - } -} diff --git a/server-node/src/repositories/rpg-profile/RpgBrowseHistoryRepository.ts b/server-node/src/repositories/rpg-profile/RpgBrowseHistoryRepository.ts deleted file mode 100644 index 44a5e773..00000000 --- a/server-node/src/repositories/rpg-profile/RpgBrowseHistoryRepository.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { - PlatformBrowseHistoryEntry, - PlatformBrowseHistoryWriteEntry, -} from '../../../../packages/shared/src/contracts/runtime.js'; -import type { RuntimeRepositoryPort } from '../runtimeRepository.js'; - -/** - * RPG 浏览历史仓储端口。 - * 将继续游戏与平台浏览足迹独立命名,避免继续堆叠在资料看板仓储内。 - */ -export type RpgBrowseHistoryRepositoryPort = Pick< - RuntimeRepositoryPort, - | 'clearPlatformBrowseHistory' - | 'listPlatformBrowseHistory' - | 'upsertPlatformBrowseHistoryEntries' ->; - -export class RpgBrowseHistoryRepository - implements RpgBrowseHistoryRepositoryPort -{ - constructor(private readonly runtimeRepository: RuntimeRepositoryPort) {} - - listPlatformBrowseHistory( - userId: string, - ): Promise { - return this.runtimeRepository.listPlatformBrowseHistory(userId); - } - - upsertPlatformBrowseHistoryEntries( - userId: string, - entries: PlatformBrowseHistoryWriteEntry[], - ): Promise { - return this.runtimeRepository.upsertPlatformBrowseHistoryEntries( - userId, - entries, - ); - } - - clearPlatformBrowseHistory(userId: string): Promise { - return this.runtimeRepository.clearPlatformBrowseHistory(userId); - } -} diff --git a/server-node/src/repositories/rpg-profile/RpgProfileDashboardRepository.ts b/server-node/src/repositories/rpg-profile/RpgProfileDashboardRepository.ts deleted file mode 100644 index cc5ff5be..00000000 --- a/server-node/src/repositories/rpg-profile/RpgProfileDashboardRepository.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { - ProfileDashboardSummary, - ProfilePlayStatsResponse, - ProfileWalletLedgerEntry, - RuntimeSettings, -} from '../../../../packages/shared/src/contracts/runtime.js'; -import type { RuntimeRepositoryPort } from '../runtimeRepository.js'; - -/** - * RPG profile 域仓储端口。 - * 当前以委托方式桥接旧 runtimeRepository,给后续按域仓储拆分保留稳定依赖面。 - */ -export type RpgProfileDashboardRepositoryPort = Pick< - RuntimeRepositoryPort, - | 'getProfileDashboard' - | 'getProfilePlayStats' - | 'getSettings' - | 'listProfileWalletLedger' - | 'putSettings' ->; - -export class RpgProfileDashboardRepository - implements RpgProfileDashboardRepositoryPort -{ - constructor(private readonly runtimeRepository: RuntimeRepositoryPort) {} - - getProfileDashboard(userId: string): Promise { - return this.runtimeRepository.getProfileDashboard(userId); - } - - listProfileWalletLedger(userId: string): Promise { - return this.runtimeRepository.listProfileWalletLedger(userId); - } - - getProfilePlayStats(userId: string): Promise { - return this.runtimeRepository.getProfilePlayStats(userId); - } - - getSettings(userId: string): Promise { - return this.runtimeRepository.getSettings(userId); - } - - putSettings( - userId: string, - settings: RuntimeSettings, - ): Promise { - return this.runtimeRepository.putSettings(userId, settings); - } -} diff --git a/server-node/src/repositories/rpg-profile/RpgProfileRepositories.test.ts b/server-node/src/repositories/rpg-profile/RpgProfileRepositories.test.ts deleted file mode 100644 index bbb4cf3f..00000000 --- a/server-node/src/repositories/rpg-profile/RpgProfileRepositories.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import type { RuntimeRepositoryPort } from '../runtimeRepository.js'; -import { RpgBrowseHistoryRepository } from './RpgBrowseHistoryRepository.js'; -import { RpgProfileDashboardRepository } from './RpgProfileDashboardRepository.js'; - -function createRuntimeRepositoryStub(): RuntimeRepositoryPort { - return { - async getSnapshot() { - return null; - }, - async putSnapshot(_userId, payload) { - return { - version: 1, - ...payload, - }; - }, - async getProfileDashboard() { - return { - walletBalance: 0, - totalPlayTimeMs: 0, - playedWorldCount: 0, - updatedAt: '2026-04-21T00:00:00.000Z', - }; - }, - async listProfileWalletLedger() { - return []; - }, - async getProfilePlayStats() { - return { - totalPlayTimeMs: 0, - playedWorks: [], - updatedAt: '2026-04-21T00:00:00.000Z', - }; - }, - async listProfileSaveArchives() { - return []; - }, - async resumeProfileSaveArchive() { - return null; - }, - async deleteSnapshot() {}, - async getSettings() { - return { - musicVolume: 0.5, - platformTheme: 'light', - }; - }, - async putSettings(_userId, settings) { - return settings; - }, - async listCustomWorldProfiles() { - return []; - }, - async listPlatformBrowseHistory() { - return [ - { - ownerUserId: 'owner-1', - profileId: 'profile-1', - worldName: '雾港', - subtitle: '沿海试炼', - summaryText: '最近访问', - coverImageSrc: null, - themeMode: 'mythic', - authorDisplayName: '测试者', - visitedAt: '2026-04-21T00:00:00.000Z', - }, - ]; - }, - async upsertPlatformBrowseHistoryEntries(_userId, entries) { - return entries.map((entry) => ({ - ownerUserId: entry.ownerUserId, - profileId: entry.profileId, - worldName: entry.worldName, - subtitle: entry.subtitle ?? '', - summaryText: entry.summaryText ?? '', - coverImageSrc: entry.coverImageSrc ?? null, - themeMode: entry.themeMode ?? 'mythic', - authorDisplayName: entry.authorDisplayName ?? '玩家', - visitedAt: entry.visitedAt ?? '2026-04-21T00:00:00.000Z', - })); - }, - async clearPlatformBrowseHistory() {}, - async upsertCustomWorldProfile() { - return { - entry: {} as never, - entries: [], - }; - }, - async deleteCustomWorldProfile() { - return []; - }, - async listCustomWorldSessions() { - return []; - }, - async getCustomWorldSession() { - return null; - }, - async upsertCustomWorldSession(_userId, _sessionId, session) { - return session; - }, - async publishCustomWorldProfile() { - return null; - }, - async unpublishCustomWorldProfile() { - return null; - }, - async listPublishedCustomWorldGallery() { - return []; - }, - async getPublishedCustomWorldGalleryDetail() { - return null; - }, - }; -} - -test('RpgProfileDashboardRepository 只暴露资料看板域方法', async () => { - const repository = new RpgProfileDashboardRepository( - createRuntimeRepositoryStub(), - ); - - const dashboard = await repository.getProfileDashboard('user-1'); - const playStats = await repository.getProfilePlayStats('user-1'); - const settings = await repository.getSettings('user-1'); - - assert.equal(dashboard.playedWorldCount, 0); - assert.equal(playStats.playedWorks.length, 0); - assert.equal(settings.platformTheme, 'light'); - assert.equal('listPlatformBrowseHistory' in repository, false); -}); - -test('RpgBrowseHistoryRepository 独立承接浏览历史读写,不再混入资料看板仓储', async () => { - const repository = new RpgBrowseHistoryRepository(createRuntimeRepositoryStub()); - - const history = await repository.listPlatformBrowseHistory('user-1'); - const updated = await repository.upsertPlatformBrowseHistoryEntries('user-1', [ - { - ownerUserId: 'owner-2', - profileId: 'profile-2', - worldName: '盐雾镇', - subtitle: '盐路补给点', - summaryText: '测试写入浏览历史', - coverImageSrc: null, - themeMode: 'mythic', - authorDisplayName: '测试者二号', - visitedAt: '2026-04-21T01:00:00.000Z', - }, - ]); - - assert.equal(history[0]?.worldName, '雾港'); - assert.equal(updated[0]?.profileId, 'profile-2'); - assert.equal('getProfileDashboard' in repository, false); -}); diff --git a/server-node/src/repositories/rpg-runtime/RpgRuntimeSnapshotRepository.test.ts b/server-node/src/repositories/rpg-runtime/RpgRuntimeSnapshotRepository.test.ts deleted file mode 100644 index f1d01b08..00000000 --- a/server-node/src/repositories/rpg-runtime/RpgRuntimeSnapshotRepository.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import type { RuntimeRepositoryPort } from '../runtimeRepository.js'; -import { RpgRuntimeSnapshotRepository } from './RpgRuntimeSnapshotRepository.js'; - -function createRuntimeRepositoryStub(): RuntimeRepositoryPort { - const deletedUserIds: string[] = []; - - return { - async getSnapshot(userId) { - return { - version: 2, - savedAt: '2026-04-21T00:00:00.000Z', - bottomTab: 'adventure', - gameState: { - owner: userId, - }, - currentStory: null, - }; - }, - async putSnapshot(_userId, payload) { - return { - version: 2, - ...payload, - }; - }, - async getProfileDashboard() { - return { - walletBalance: 0, - totalPlayTimeMs: 0, - playedWorldCount: 0, - updatedAt: '2026-04-21T00:00:00.000Z', - }; - }, - async listProfileWalletLedger() { - return []; - }, - async getProfilePlayStats() { - return { - totalPlayTimeMs: 0, - playedWorks: [], - updatedAt: '2026-04-21T00:00:00.000Z', - }; - }, - async listProfileSaveArchives() { - return []; - }, - async resumeProfileSaveArchive() { - return null; - }, - async deleteSnapshot(userId) { - deletedUserIds.push(userId); - }, - async getSettings() { - return { - musicVolume: 0.42, - platformTheme: 'light', - }; - }, - async putSettings(_userId, settings) { - return settings; - }, - async listCustomWorldProfiles() { - return []; - }, - async listPlatformBrowseHistory() { - return []; - }, - async upsertPlatformBrowseHistoryEntries() { - return []; - }, - async clearPlatformBrowseHistory() {}, - async upsertCustomWorldProfile() { - return { - entry: {} as never, - entries: [], - }; - }, - async deleteCustomWorldProfile() { - return []; - }, - async listCustomWorldSessions() { - return []; - }, - async getCustomWorldSession() { - return null; - }, - async upsertCustomWorldSession(_userId, _sessionId, session) { - return session; - }, - async publishCustomWorldProfile() { - return null; - }, - async unpublishCustomWorldProfile() { - return null; - }, - async listPublishedCustomWorldGallery() { - return []; - }, - async getPublishedCustomWorldGalleryDetail() { - return null; - }, - }; -} - -test('RpgRuntimeSnapshotRepository 独立承接 snapshot 读写与删除职责', async () => { - const runtimeRepository = createRuntimeRepositoryStub(); - const repository = new RpgRuntimeSnapshotRepository(runtimeRepository); - - const snapshot = await repository.getSnapshot('user-7'); - const saved = await repository.putSnapshot('user-7', { - savedAt: '2026-04-21T01:00:00.000Z', - bottomTab: 'inventory', - gameState: { - owner: 'user-7', - currentScene: '雾港', - }, - currentStory: null, - }); - await repository.deleteSnapshot('user-7'); - - assert.equal(snapshot?.gameState.owner, 'user-7'); - assert.equal(saved.bottomTab, 'inventory'); - assert.equal('listProfileSaveArchives' in repository, false); -}); diff --git a/server-node/src/repositories/rpg-runtime/RpgRuntimeSnapshotRepository.ts b/server-node/src/repositories/rpg-runtime/RpgRuntimeSnapshotRepository.ts deleted file mode 100644 index 73c6fffb..00000000 --- a/server-node/src/repositories/rpg-runtime/RpgRuntimeSnapshotRepository.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { - RuntimeRepositoryPort, - SavedSnapshot, -} from '../runtimeRepository.js'; - -/** - * RPG runtime 快照仓储端口。 - * 工作包 A 先把 snapshot 领域从大仓储中抽出独立命名入口,真实读写仍委托现有 runtimeRepository。 - */ -export type RpgRuntimeSnapshotRepositoryPort = Pick< - RuntimeRepositoryPort, - 'deleteSnapshot' | 'getSnapshot' | 'putSnapshot' ->; -export type RpgRuntimeSavedSnapshot = SavedSnapshot; - -export class RpgRuntimeSnapshotRepository - implements RpgRuntimeSnapshotRepositoryPort -{ - constructor(private readonly runtimeRepository: RuntimeRepositoryPort) {} - - getSnapshot(userId: string): Promise { - return this.runtimeRepository.getSnapshot(userId); - } - - putSnapshot( - userId: string, - payload: Omit, - ): Promise { - return this.runtimeRepository.putSnapshot(userId, payload); - } - - deleteSnapshot(userId: string): Promise { - return this.runtimeRepository.deleteSnapshot(userId); - } -} diff --git a/server-node/src/repositories/rpgWorldRepositoryShared.ts b/server-node/src/repositories/rpgWorldRepositoryShared.ts deleted file mode 100644 index 639255f1..00000000 --- a/server-node/src/repositories/rpgWorldRepositoryShared.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { QueryResultRow } from 'pg'; - -import type { - CustomWorldProfileRecord, -} from '../../../packages/shared/src/contracts/runtime.js'; -import { - type CustomWorldGalleryCard, - type CustomWorldLibraryEntry, - type CustomWorldPublicationStatus, - type CustomWorldSessionRecord, -} from '../../../packages/shared/src/contracts/runtime.js'; -import { extractCustomWorldLibraryMetadata } from './customWorldLibraryMetadata.js'; - -export const MAX_RPG_WORLD_PROFILE_ENTRIES = 12; -export const MAX_RPG_WORLD_GALLERY_ENTRIES = 36; - -export type RpgWorldProfileRow = QueryResultRow & { - ownerUserId: string; - profileId: string; - payload: CustomWorldProfileRecord; - visibility: CustomWorldPublicationStatus; - publishedAt: string | null; - updatedAt: string; - authorDisplayName: string; - worldName: string; - subtitle: string; - summaryText: string; - coverImageSrc: string | null; - themeMode: CustomWorldLibraryEntry['themeMode']; - playableNpcCount: number; - landmarkCount: number; -}; - -export type RpgAgentSessionRow = QueryResultRow & { - payload: CustomWorldSessionRecord; - createdAt: string; - updatedAt: string; -}; - -export type RpgWorldGalleryRow = QueryResultRow & { - ownerUserId: string; - profileId: string; - visibility: CustomWorldPublicationStatus; - publishedAt: string | null; - updatedAt: string; - authorDisplayName: string; - worldName: string; - subtitle: string; - summaryText: string; - coverImageSrc: string | null; - themeMode: CustomWorldGalleryCard['themeMode']; - playableNpcCount: number; - landmarkCount: number; -}; - -/** - * 落库前统一补齐 profileId,避免不同入口写入时出现同一世界两个 id 口径。 - */ -export function normalizeStoredRpgWorldProfile( - profileId: string, - profile: Record, -): CustomWorldProfileRecord { - return { - ...profile, - id: profileId, - }; -} - -export function toRpgWorldLibraryEntry( - row: RpgWorldProfileRow, -): CustomWorldLibraryEntry { - const fallbackMetadata = extractCustomWorldLibraryMetadata(row.payload); - - return { - ownerUserId: row.ownerUserId, - profileId: row.profileId, - profile: row.payload, - visibility: row.visibility, - publishedAt: row.publishedAt, - updatedAt: row.updatedAt, - authorDisplayName: row.authorDisplayName || '玩家', - worldName: row.worldName || fallbackMetadata.worldName, - subtitle: row.subtitle || fallbackMetadata.subtitle, - summaryText: row.summaryText || fallbackMetadata.summaryText, - coverImageSrc: row.coverImageSrc || fallbackMetadata.coverImageSrc, - themeMode: row.themeMode || fallbackMetadata.themeMode, - playableNpcCount: - row.playableNpcCount > 0 - ? row.playableNpcCount - : fallbackMetadata.playableNpcCount, - landmarkCount: - row.landmarkCount > 0 - ? row.landmarkCount - : fallbackMetadata.landmarkCount, - }; -} - -export function toRpgWorldGalleryCard( - row: RpgWorldGalleryRow, -): CustomWorldGalleryCard { - return { - ownerUserId: row.ownerUserId, - profileId: row.profileId, - visibility: row.visibility, - publishedAt: row.publishedAt, - updatedAt: row.updatedAt, - authorDisplayName: row.authorDisplayName || '玩家', - worldName: row.worldName || '未命名世界', - subtitle: row.subtitle || '', - summaryText: row.summaryText || '', - coverImageSrc: row.coverImageSrc || null, - themeMode: row.themeMode || 'mythic', - playableNpcCount: row.playableNpcCount, - landmarkCount: row.landmarkCount, - }; -} diff --git a/server-node/src/repositories/runtimeRepository.ts b/server-node/src/repositories/runtimeRepository.ts deleted file mode 100644 index aed72c0e..00000000 --- a/server-node/src/repositories/runtimeRepository.ts +++ /dev/null @@ -1,1320 +0,0 @@ -import { randomUUID } from 'node:crypto'; - -import type { QueryResultRow } from 'pg'; - -import type { - CustomWorldProfileRecord, - PlatformBrowseHistoryEntry, - PlatformBrowseHistoryWriteEntry, - ProfileDashboardSummary, - ProfilePlayedWorkSummary, - ProfilePlayStatsResponse, - ProfileSaveArchiveSummary, - ProfileWalletLedgerEntry, - RuntimeSettings, - SavedGameSnapshot, -} from '../../../packages/shared/src/contracts/runtime.js'; -import { - type CustomWorldGalleryCard, - type CustomWorldLibraryEntry, - type CustomWorldPublicationStatus, - type CustomWorldSessionRecord, - DEFAULT_MUSIC_VOLUME, - DEFAULT_PLATFORM_THEME, - SAVE_SNAPSHOT_VERSION, -} from '../../../packages/shared/src/contracts/runtime.js'; -import type { AppDatabase } from '../db.js'; -import { extractCustomWorldLibraryMetadata } from './customWorldLibraryMetadata.js'; -import { RpgAgentSessionRepository } from './RpgAgentSessionRepository.js'; -import { RpgWorldProfileRepository } from './RpgWorldProfileRepository.js'; -import { normalizeStoredRpgWorldProfile } from './rpgWorldRepositoryShared.js'; - -export type SavedSnapshot = SavedGameSnapshot; - -type SnapshotRow = QueryResultRow & { - version: number; - savedAt: string; - gameState: unknown; - bottomTab: string; - currentStory: unknown; -}; - -type SettingsRow = QueryResultRow & { - musicVolume: number; - platformTheme: RuntimeSettings['platformTheme']; -}; - -type PlatformBrowseHistoryRow = QueryResultRow & { - ownerUserId: string; - profileId: string; - worldName: string; - subtitle: string; - summaryText: string; - coverImageSrc: string | null; - themeMode: PlatformBrowseHistoryEntry['themeMode']; - authorDisplayName: string; - visitedAt: string; -}; - -type ProfileDashboardStateRow = QueryResultRow & { - walletBalance: number; - totalPlayTimeMs: number | string; - updatedAt: string; -}; - -type ProfileWalletLedgerRow = QueryResultRow & { - id: string; - amountDelta: number; - balanceAfter: number; - sourceType: ProfileWalletLedgerEntry['sourceType']; - createdAt: string; -}; - -type ProfilePlayedWorldRow = QueryResultRow & { - worldKey: string; - ownerUserId: string | null; - profileId: string | null; - worldType: string | null; - worldTitle: string; - worldSubtitle: string; - firstPlayedAt: string; - lastPlayedAt: string; - lastObservedPlayTimeMs: number | string; -}; - -type ProfileWorldSnapshotMeta = { - worldKey: string; - ownerUserId: string | null; - profileId: string | null; - worldType: string | null; - worldTitle: string; - worldSubtitle: string; -}; - -type ProfileSaveArchiveRow = QueryResultRow & { - worldKey: string; - ownerUserId: string | null; - profileId: string | null; - worldType: string | null; - worldName: string; - subtitle: string; - summaryText: string; - coverImageSrc: string | null; - savedAt: string; - bottomTab: string; - gameState: unknown; - currentStory: unknown; -}; - -type ProfileSaveArchiveMeta = Omit; - -export type RuntimeRepositoryPort = { - getSnapshot(userId: string): Promise; - putSnapshot( - userId: string, - payload: Omit, - ): Promise; - getProfileDashboard(userId: string): Promise; - listProfileWalletLedger(userId: string): Promise; - getProfilePlayStats(userId: string): Promise; - listProfileSaveArchives(userId: string): Promise; - resumeProfileSaveArchive( - userId: string, - worldKey: string, - ): Promise<{ - entry: ProfileSaveArchiveSummary; - snapshot: SavedSnapshot; - } | null>; - deleteSnapshot(userId: string): Promise; - getSettings(userId: string): Promise; - putSettings( - userId: string, - settings: RuntimeSettings, - ): Promise; - listCustomWorldProfiles( - userId: string, - ): Promise[]>; - listPlatformBrowseHistory( - userId: string, - ): Promise; - upsertPlatformBrowseHistoryEntries( - userId: string, - entries: PlatformBrowseHistoryWriteEntry[], - ): Promise; - clearPlatformBrowseHistory(userId: string): Promise; - upsertCustomWorldProfile( - userId: string, - profileId: string, - profile: Record, - authorDisplayName: string, - ): Promise<{ - entry: CustomWorldLibraryEntry; - entries: CustomWorldLibraryEntry[]; - }>; - deleteCustomWorldProfile( - userId: string, - profileId: string, - ): Promise[]>; - listCustomWorldSessions(userId: string): Promise; - getCustomWorldSession( - userId: string, - sessionId: string, - ): Promise; - upsertCustomWorldSession( - userId: string, - sessionId: string, - session: CustomWorldSessionRecord, - ): Promise; - publishCustomWorldProfile( - userId: string, - profileId: string, - authorDisplayName: string, - ): Promise<{ - entry: CustomWorldLibraryEntry; - entries: CustomWorldLibraryEntry[]; - } | null>; - unpublishCustomWorldProfile( - userId: string, - profileId: string, - authorDisplayName: string, - ): Promise<{ - entry: CustomWorldLibraryEntry; - entries: CustomWorldLibraryEntry[]; - } | null>; - listPublishedCustomWorldGallery(): Promise; - getPublishedCustomWorldGalleryDetail( - ownerUserId: string, - profileId: string, - ): Promise | null>; -}; - -function toPlatformBrowseHistoryEntry( - row: PlatformBrowseHistoryRow, -): PlatformBrowseHistoryEntry { - return { - ownerUserId: row.ownerUserId, - profileId: row.profileId, - worldName: row.worldName || '未命名世界', - subtitle: row.subtitle || '', - summaryText: row.summaryText || '', - coverImageSrc: row.coverImageSrc || null, - themeMode: row.themeMode || 'mythic', - authorDisplayName: row.authorDisplayName || '玩家', - visitedAt: row.visitedAt, - }; -} - -function asRecord(value: unknown): Record | null { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : null; -} - -function readString(value: unknown) { - return typeof value === 'string' ? value.trim() : ''; -} - -function normalizePlatformBrowseHistoryWriteEntry( - entry: PlatformBrowseHistoryWriteEntry, -): PlatformBrowseHistoryEntry | null { - const ownerUserId = readString(entry.ownerUserId); - const profileId = readString(entry.profileId); - const worldName = readString(entry.worldName); - - if (!ownerUserId || !profileId || !worldName) { - return null; - } - - const visitedAt = readString(entry.visitedAt) || new Date().toISOString(); - - return { - ownerUserId, - profileId, - worldName, - subtitle: readString(entry.subtitle), - summaryText: readString(entry.summaryText), - coverImageSrc: readString(entry.coverImageSrc) || null, - themeMode: - (readString( - entry.themeMode, - ) as PlatformBrowseHistoryEntry['themeMode']) || 'mythic', - authorDisplayName: readString(entry.authorDisplayName) || '玩家', - visitedAt, - }; -} - -function readSavedStoryText(value: unknown) { - return readString(asRecord(value)?.text); -} - -function readFiniteNumber(value: unknown) { - if (typeof value === 'number' && Number.isFinite(value)) { - return value; - } - if (typeof value === 'string' && value.trim()) { - const parsedValue = Number(value); - return Number.isFinite(parsedValue) ? parsedValue : 0; - } - - return 0; -} - -function normalizeDashboardNumber(value: unknown) { - return Math.max(0, Math.round(readFiniteNumber(value))); -} - -function buildBuiltinWorldTitle(worldType: string) { - switch (worldType) { - case 'WUXIA': - return '武侠世界'; - case 'XIANXIA': - return '仙侠世界'; - default: - return '叙事世界'; - } -} - -function looksLikeGeneratedAssetPath(value: string) { - return /^\/generated-/u.test(value); -} - -function mergeSnapshotRoleAssets( - role: Record, - assets: { - imageSrc?: string | null; - generatedVisualAssetId?: string | null; - generatedAnimationSetId?: string | null; - animationMap?: Record | null; - }, -) { - let changed = false; - const nextRole: Record = { ...role }; - const nextImageSrc = readString(assets.imageSrc); - const nextGeneratedVisualAssetId = readString(assets.generatedVisualAssetId); - const nextGeneratedAnimationSetId = readString( - assets.generatedAnimationSetId, - ); - const nextAnimationMap = asRecord(assets.animationMap); - - if (nextImageSrc && readString(role.imageSrc) !== nextImageSrc) { - nextRole.imageSrc = nextImageSrc; - changed = true; - } - - if ( - nextGeneratedVisualAssetId && - readString(role.generatedVisualAssetId) !== nextGeneratedVisualAssetId - ) { - nextRole.generatedVisualAssetId = nextGeneratedVisualAssetId; - changed = true; - } - - if ( - nextGeneratedAnimationSetId && - readString(role.generatedAnimationSetId) !== nextGeneratedAnimationSetId - ) { - nextRole.generatedAnimationSetId = nextGeneratedAnimationSetId; - changed = true; - } - - if (nextAnimationMap && Object.keys(nextAnimationMap).length > 0) { - nextRole.animationMap = { - ...(asRecord(role.animationMap) ?? {}), - ...nextAnimationMap, - }; - changed = true; - } - - return changed ? nextRole : role; -} - -function syncSnapshotRoleAssetsIntoProfile( - profile: Record, - roleId: string, - assets: Parameters[1], -) { - if (!roleId) { - return profile; - } - - let changed = false; - const syncRoleArray = (value: unknown) => { - if (!Array.isArray(value)) { - return value; - } - - return value.map((entry) => { - if (!asRecord(entry) || readString(entry.id) !== roleId) { - return entry; - } - - const nextEntry = mergeSnapshotRoleAssets(entry, assets); - if (nextEntry !== entry) { - changed = true; - } - return nextEntry; - }); - }; - - const nextPlayableNpcs = syncRoleArray(profile.playableNpcs); - const nextStoryNpcs = syncRoleArray(profile.storyNpcs); - - return changed - ? { - ...profile, - playableNpcs: nextPlayableNpcs, - storyNpcs: nextStoryNpcs, - } - : profile; -} - -function syncSnapshotSceneImageIntoProfile( - profile: Record, - sceneId: string, - imageSrc: string, -) { - if (!sceneId || !imageSrc) { - return profile; - } - - if (sceneId === 'custom-scene-camp') { - const currentCamp = asRecord(profile.camp) ?? {}; - if (readString(currentCamp.imageSrc) === imageSrc) { - return profile; - } - - return { - ...profile, - camp: { - ...currentCamp, - imageSrc, - }, - }; - } - - const landmarkMatch = /^custom-scene-landmark-(\d+)$/u.exec(sceneId); - if (!landmarkMatch || !Array.isArray(profile.landmarks)) { - return profile; - } - - const landmarkIndex = Number.parseInt(landmarkMatch[1] ?? '', 10) - 1; - if ( - !Number.isInteger(landmarkIndex) || - landmarkIndex < 0 || - landmarkIndex >= profile.landmarks.length - ) { - return profile; - } - - const currentLandmark = asRecord(profile.landmarks[landmarkIndex]); - if (!currentLandmark || readString(currentLandmark.imageSrc) === imageSrc) { - return profile; - } - - const nextLandmarks = [...profile.landmarks]; - nextLandmarks[landmarkIndex] = { - ...currentLandmark, - imageSrc, - }; - - return { - ...profile, - landmarks: nextLandmarks, - }; -} - -function syncSnapshotCustomWorldProfile(gameState: unknown) { - const currentGameState = asRecord(gameState); - const currentProfile = asRecord(currentGameState?.customWorldProfile); - if (!currentGameState || !currentProfile) { - return gameState; - } - - let nextProfile = currentProfile; - const playerCharacter = asRecord(currentGameState.playerCharacter); - const playerCharacterId = readString(playerCharacter?.id); - const playerPortrait = readString(playerCharacter?.portrait); - const playerAnimationMap = asRecord(playerCharacter?.animationMap); - const playerHasGeneratedAssets = - Boolean(readString(playerCharacter?.generatedVisualAssetId)) || - Boolean(readString(playerCharacter?.generatedAnimationSetId)) || - Boolean(playerAnimationMap && Object.keys(playerAnimationMap).length > 0) || - looksLikeGeneratedAssetPath(playerPortrait); - - nextProfile = syncSnapshotRoleAssetsIntoProfile(nextProfile, playerCharacterId, { - imageSrc: playerHasGeneratedAssets ? playerPortrait : null, - generatedVisualAssetId: - readString(playerCharacter?.generatedVisualAssetId) || null, - generatedAnimationSetId: - readString(playerCharacter?.generatedAnimationSetId) || null, - animationMap: playerAnimationMap, - }); - - const currentScenePreset = asRecord(currentGameState.currentScenePreset); - nextProfile = syncSnapshotSceneImageIntoProfile( - nextProfile, - readString(currentScenePreset?.id), - readString(currentScenePreset?.imageSrc), - ); - - if (nextProfile === currentProfile) { - return currentGameState; - } - - return { - ...currentGameState, - customWorldProfile: nextProfile, - }; -} - -function resolveProfileWorldSnapshotMeta( - snapshot: SavedSnapshot, -): ProfileWorldSnapshotMeta | null { - const gameState = asRecord(snapshot.gameState); - if (!gameState) { - return null; - } - - const customWorldProfile = asRecord(gameState.customWorldProfile); - if (customWorldProfile) { - const profileId = readString(customWorldProfile.id); - const worldTitle = - readString(customWorldProfile.name) || - readString(customWorldProfile.title); - if (profileId || worldTitle) { - return { - worldKey: profileId ? `custom:${profileId}` : `custom:${worldTitle}`, - ownerUserId: null, - profileId: profileId || null, - worldType: 'CUSTOM', - worldTitle: worldTitle || '自定义世界', - worldSubtitle: - readString(customWorldProfile.summary) || - readString(customWorldProfile.settingText), - }; - } - } - - const worldType = readString(gameState.worldType); - if (!worldType) { - return null; - } - - const currentScenePreset = asRecord(gameState.currentScenePreset); - const worldTitle = - readString(currentScenePreset?.name) || buildBuiltinWorldTitle(worldType); - - return { - worldKey: `builtin:${worldType}`, - ownerUserId: null, - profileId: null, - worldType, - worldTitle, - worldSubtitle: - readString(currentScenePreset?.summary) || - readString(currentScenePreset?.description), - }; -} - -function toProfilePlayedWorkSummary( - row: ProfilePlayedWorldRow, -): ProfilePlayedWorkSummary { - return { - worldKey: row.worldKey, - ownerUserId: row.ownerUserId, - profileId: row.profileId, - worldType: row.worldType, - worldTitle: row.worldTitle, - worldSubtitle: row.worldSubtitle, - firstPlayedAt: row.firstPlayedAt, - lastPlayedAt: row.lastPlayedAt, - lastObservedPlayTimeMs: normalizeDashboardNumber( - row.lastObservedPlayTimeMs, - ), - }; -} - -function toProfileSaveArchiveSummary( - row: Pick< - ProfileSaveArchiveRow, - | 'worldKey' - | 'ownerUserId' - | 'profileId' - | 'worldType' - | 'worldName' - | 'subtitle' - | 'summaryText' - | 'coverImageSrc' - | 'savedAt' - >, -): ProfileSaveArchiveSummary { - const subtitle = row.subtitle || ''; - return { - worldKey: row.worldKey, - ownerUserId: row.ownerUserId, - profileId: row.profileId, - worldType: row.worldType, - worldName: row.worldName || '未命名游戏', - subtitle, - summaryText: row.summaryText || subtitle || '继续推进上一次保存的故事。', - coverImageSrc: row.coverImageSrc || null, - lastPlayedAt: row.savedAt, - }; -} - -function resolveProfileSaveArchiveMeta( - snapshot: SavedSnapshot, -): ProfileSaveArchiveMeta | null { - const worldMeta = resolveProfileWorldSnapshotMeta(snapshot); - if (!worldMeta) { - return null; - } - - const gameState = asRecord(snapshot.gameState); - const continueGameDigest = readString( - asRecord(gameState?.storyEngineMemory)?.continueGameDigest, - ); - const currentStoryText = readSavedStoryText(snapshot.currentStory); - const customWorldProfile = asRecord(gameState?.customWorldProfile); - - if (customWorldProfile) { - const profileId = readString(customWorldProfile.id) || 'custom-world'; - const metadata = extractCustomWorldLibraryMetadata( - normalizeStoredRpgWorldProfile(profileId, customWorldProfile), - ); - - return { - worldKey: worldMeta.worldKey, - ownerUserId: worldMeta.ownerUserId, - profileId: worldMeta.profileId, - worldType: worldMeta.worldType, - worldName: worldMeta.worldTitle || metadata.worldName || '自定义世界', - subtitle: metadata.subtitle || worldMeta.worldSubtitle || '', - summaryText: - continueGameDigest || - currentStoryText || - metadata.summaryText || - worldMeta.worldSubtitle || - '继续推进上一次保存的故事。', - coverImageSrc: metadata.coverImageSrc, - }; - } - - const currentScenePreset = asRecord(gameState?.currentScenePreset); - - return { - worldKey: worldMeta.worldKey, - ownerUserId: worldMeta.ownerUserId, - profileId: worldMeta.profileId, - worldType: worldMeta.worldType, - worldName: worldMeta.worldTitle || '未命名游戏', - subtitle: worldMeta.worldSubtitle || '', - summaryText: - continueGameDigest || - currentStoryText || - worldMeta.worldSubtitle || - '继续推进上一次保存的故事。', - coverImageSrc: readString(currentScenePreset?.imageSrc) || null, - }; -} - -export class RuntimeRepository implements RuntimeRepositoryPort { - private readonly rpgAgentSessionRepository: RpgAgentSessionRepository; - private readonly rpgWorldProfileRepository: RpgWorldProfileRepository; - - constructor(private readonly db: AppDatabase) { - this.rpgAgentSessionRepository = new RpgAgentSessionRepository(db); - this.rpgWorldProfileRepository = new RpgWorldProfileRepository(db); - } - - private async getProfileDashboardState(userId: string) { - const result = await this.db.query( - `SELECT wallet_balance AS "walletBalance", - total_play_time_ms AS "totalPlayTimeMs", - updated_at AS "updatedAt" - FROM profile_dashboard_state - WHERE user_id = $1`, - [userId], - ); - - return result.rows[0] ?? null; - } - - private async findProfilePlayedWorld(userId: string, worldKey: string) { - const result = await this.db.query( - `SELECT world_key AS "worldKey", - owner_user_id AS "ownerUserId", - profile_id AS "profileId", - world_type AS "worldType", - world_title AS "worldTitle", - world_subtitle AS "worldSubtitle", - first_played_at AS "firstPlayedAt", - last_played_at AS "lastPlayedAt", - last_observed_play_time_ms AS "lastObservedPlayTimeMs" - FROM profile_played_worlds - WHERE user_id = $1 - AND world_key = $2`, - [userId, worldKey], - ); - - return result.rows[0] ?? null; - } - - private async findProfileSaveArchive(userId: string, worldKey: string) { - const result = await this.db.query( - `SELECT world_key AS "worldKey", - owner_user_id AS "ownerUserId", - profile_id AS "profileId", - world_type AS "worldType", - world_name AS "worldName", - world_subtitle AS subtitle, - summary_text AS "summaryText", - cover_image_src AS "coverImageSrc", - saved_at AS "savedAt", - bottom_tab AS "bottomTab", - game_state_json AS "gameState", - current_story_json AS "currentStory" - FROM profile_save_archives - WHERE user_id = $1 - AND world_key = $2`, - [userId, worldKey], - ); - - return result.rows[0] ?? null; - } - - private async upsertProfileDashboardState( - userId: string, - state: { - walletBalance: number; - totalPlayTimeMs: number; - updatedAt: string; - }, - ) { - await this.db.query( - `INSERT INTO profile_dashboard_state ( - user_id, - wallet_balance, - total_play_time_ms, - updated_at - ) VALUES ($1, $2, $3, $4) - ON CONFLICT (user_id) DO UPDATE SET - wallet_balance = EXCLUDED.wallet_balance, - total_play_time_ms = EXCLUDED.total_play_time_ms, - updated_at = EXCLUDED.updated_at`, - [userId, state.walletBalance, state.totalPlayTimeMs, state.updatedAt], - ); - } - - private async upsertCurrentSnapshot( - userId: string, - snapshot: SavedSnapshot, - ) { - const now = new Date().toISOString(); - const result = await this.db.query( - `INSERT INTO save_snapshots ( - user_id, version, saved_at, bottom_tab, game_state_json, current_story_json, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7) - ON CONFLICT (user_id) DO UPDATE SET - version = EXCLUDED.version, - saved_at = EXCLUDED.saved_at, - bottom_tab = EXCLUDED.bottom_tab, - game_state_json = EXCLUDED.game_state_json, - current_story_json = EXCLUDED.current_story_json, - updated_at = EXCLUDED.updated_at - RETURNING version, - saved_at AS "savedAt", - game_state_json AS "gameState", - bottom_tab AS "bottomTab", - current_story_json AS "currentStory"`, - [ - userId, - snapshot.version, - snapshot.savedAt, - snapshot.bottomTab, - snapshot.gameState, - snapshot.currentStory, - now, - ], - ); - - const row = result.rows[0]; - - return { - version: row.version, - savedAt: row.savedAt, - gameState: row.gameState, - bottomTab: row.bottomTab, - currentStory: row.currentStory, - } satisfies SavedSnapshot; - } - - private async syncProfileDashboardFromSnapshot( - userId: string, - snapshot: SavedSnapshot, - ) { - const state = (await this.getProfileDashboardState(userId)) ?? { - walletBalance: 0, - totalPlayTimeMs: 0, - updatedAt: snapshot.savedAt, - }; - const syncedAt = snapshot.savedAt || new Date().toISOString(); - const gameState = asRecord(snapshot.gameState); - const nextWalletBalance = normalizeDashboardNumber( - gameState?.playerCurrency, - ); - let nextTotalPlayTimeMs = normalizeDashboardNumber(state.totalPlayTimeMs); - - if (nextWalletBalance !== state.walletBalance) { - const amountDelta = nextWalletBalance - state.walletBalance; - await this.db.query( - `INSERT INTO profile_wallet_ledger ( - id, - user_id, - amount_delta, - balance_after, - source_type, - source_key, - created_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7) - ON CONFLICT (user_id, source_key) DO NOTHING`, - [ - randomUUID(), - userId, - amountDelta, - nextWalletBalance, - 'snapshot_sync', - `snapshot:${syncedAt}:wallet:${nextWalletBalance}`, - syncedAt, - ], - ); - } - - const worldMeta = resolveProfileWorldSnapshotMeta(snapshot); - if (worldMeta) { - const currentPlayTimeMs = normalizeDashboardNumber( - asRecord(gameState?.runtimeStats)?.playTimeMs, - ); - const currentWorld = await this.findProfilePlayedWorld( - userId, - worldMeta.worldKey, - ); - const incrementalPlayTimeMs = Math.max( - 0, - currentPlayTimeMs - - normalizeDashboardNumber(currentWorld?.lastObservedPlayTimeMs ?? 0), - ); - - nextTotalPlayTimeMs += incrementalPlayTimeMs; - await this.db.query( - `INSERT INTO profile_played_worlds ( - user_id, - world_key, - owner_user_id, - profile_id, - world_type, - world_title, - world_subtitle, - first_played_at, - last_played_at, - last_observed_play_time_ms - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8, $9) - ON CONFLICT (user_id, world_key) DO UPDATE SET - owner_user_id = EXCLUDED.owner_user_id, - profile_id = EXCLUDED.profile_id, - world_type = EXCLUDED.world_type, - world_title = EXCLUDED.world_title, - world_subtitle = EXCLUDED.world_subtitle, - last_played_at = EXCLUDED.last_played_at, - last_observed_play_time_ms = GREATEST( - profile_played_worlds.last_observed_play_time_ms, - EXCLUDED.last_observed_play_time_ms - )`, - [ - userId, - worldMeta.worldKey, - worldMeta.ownerUserId, - worldMeta.profileId, - worldMeta.worldType, - worldMeta.worldTitle, - worldMeta.worldSubtitle, - syncedAt, - currentPlayTimeMs, - ], - ); - } - - await this.upsertProfileDashboardState(userId, { - walletBalance: nextWalletBalance, - totalPlayTimeMs: nextTotalPlayTimeMs, - updatedAt: syncedAt, - }); - } - - private async syncProfileSaveArchiveFromSnapshot( - userId: string, - snapshot: SavedSnapshot, - ) { - const archiveMeta = resolveProfileSaveArchiveMeta(snapshot); - if (!archiveMeta) { - return; - } - - const syncedAt = snapshot.savedAt || new Date().toISOString(); - - await this.db.query( - `INSERT INTO profile_save_archives ( - user_id, - world_key, - owner_user_id, - profile_id, - world_type, - world_name, - world_subtitle, - summary_text, - cover_image_src, - saved_at, - bottom_tab, - game_state_json, - current_story_json, - updated_at - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) - ON CONFLICT (user_id, world_key) DO UPDATE SET - owner_user_id = EXCLUDED.owner_user_id, - profile_id = EXCLUDED.profile_id, - world_type = EXCLUDED.world_type, - world_name = EXCLUDED.world_name, - world_subtitle = EXCLUDED.world_subtitle, - summary_text = EXCLUDED.summary_text, - cover_image_src = EXCLUDED.cover_image_src, - saved_at = EXCLUDED.saved_at, - bottom_tab = EXCLUDED.bottom_tab, - game_state_json = EXCLUDED.game_state_json, - current_story_json = EXCLUDED.current_story_json, - updated_at = EXCLUDED.updated_at`, - [ - userId, - archiveMeta.worldKey, - archiveMeta.ownerUserId, - archiveMeta.profileId, - archiveMeta.worldType, - archiveMeta.worldName, - archiveMeta.subtitle, - archiveMeta.summaryText, - archiveMeta.coverImageSrc, - syncedAt, - snapshot.bottomTab, - snapshot.gameState, - snapshot.currentStory, - syncedAt, - ], - ); - } - - private async syncCustomWorldProfileFromSnapshot( - userId: string, - snapshot: SavedSnapshot, - ) { - const gameState = asRecord(snapshot.gameState); - const customWorldProfile = asRecord(gameState?.customWorldProfile); - const profileId = readString(customWorldProfile?.id); - - if (!customWorldProfile || !profileId) { - return; - } - - const syncedAt = snapshot.savedAt || new Date().toISOString(); - - await this.rpgWorldProfileRepository.syncProfileFromSnapshot( - userId, - profileId, - customWorldProfile, - syncedAt, - ); - } - - async getSnapshot(userId: string) { - const result = await this.db.query( - `SELECT version, - saved_at AS "savedAt", - game_state_json AS "gameState", - bottom_tab AS "bottomTab", - current_story_json AS "currentStory" - FROM save_snapshots - WHERE user_id = $1`, - [userId], - ); - const row = result.rows[0]; - - if (!row) { - return null; - } - - return { - version: row.version, - savedAt: row.savedAt, - gameState: row.gameState, - bottomTab: row.bottomTab, - currentStory: row.currentStory, - } satisfies SavedSnapshot; - } - - async putSnapshot(userId: string, payload: Omit) { - const snapshot = { - version: SAVE_SNAPSHOT_VERSION, - savedAt: payload.savedAt, - gameState: syncSnapshotCustomWorldProfile(payload.gameState), - bottomTab: payload.bottomTab, - currentStory: payload.currentStory, - } satisfies SavedSnapshot; - const persistedSnapshot = await this.upsertCurrentSnapshot(userId, snapshot); - - await this.syncProfileDashboardFromSnapshot(userId, persistedSnapshot); - await this.syncProfileSaveArchiveFromSnapshot(userId, persistedSnapshot); - await this.syncCustomWorldProfileFromSnapshot(userId, persistedSnapshot); - - return persistedSnapshot; - } - - async getProfileDashboard(userId: string) { - const state = await this.getProfileDashboardState(userId); - const playedWorldsResult = await this.db.query<{ count: string }>( - `SELECT COUNT(*)::text AS count - FROM profile_played_worlds - WHERE user_id = $1`, - [userId], - ); - - return { - walletBalance: normalizeDashboardNumber(state?.walletBalance ?? 0), - totalPlayTimeMs: normalizeDashboardNumber(state?.totalPlayTimeMs ?? 0), - playedWorldCount: - Number.parseInt(playedWorldsResult.rows[0]?.count ?? '0', 10) || 0, - updatedAt: state?.updatedAt ?? null, - } satisfies ProfileDashboardSummary; - } - - async listProfileWalletLedger(userId: string) { - const result = await this.db.query( - `SELECT id, - amount_delta AS "amountDelta", - balance_after AS "balanceAfter", - source_type AS "sourceType", - created_at AS "createdAt" - FROM profile_wallet_ledger - WHERE user_id = $1 - ORDER BY created_at DESC - LIMIT 50`, - [userId], - ); - - return result.rows.map((row) => ({ - id: row.id, - amountDelta: row.amountDelta, - balanceAfter: row.balanceAfter, - sourceType: row.sourceType, - createdAt: row.createdAt, - })); - } - - async getProfilePlayStats(userId: string) { - const state = await this.getProfileDashboardState(userId); - const result = await this.db.query( - `SELECT world_key AS "worldKey", - owner_user_id AS "ownerUserId", - profile_id AS "profileId", - world_type AS "worldType", - world_title AS "worldTitle", - world_subtitle AS "worldSubtitle", - first_played_at AS "firstPlayedAt", - last_played_at AS "lastPlayedAt", - last_observed_play_time_ms AS "lastObservedPlayTimeMs" - FROM profile_played_worlds - WHERE user_id = $1 - ORDER BY last_played_at DESC`, - [userId], - ); - - return { - totalPlayTimeMs: normalizeDashboardNumber(state?.totalPlayTimeMs ?? 0), - playedWorks: result.rows.map((row) => toProfilePlayedWorkSummary(row)), - updatedAt: state?.updatedAt ?? null, - } satisfies ProfilePlayStatsResponse; - } - - async listProfileSaveArchives(userId: string) { - const result = await this.db.query( - `SELECT world_key AS "worldKey", - owner_user_id AS "ownerUserId", - profile_id AS "profileId", - world_type AS "worldType", - world_name AS "worldName", - world_subtitle AS subtitle, - summary_text AS "summaryText", - cover_image_src AS "coverImageSrc", - saved_at AS "savedAt", - bottom_tab AS "bottomTab", - game_state_json AS "gameState", - current_story_json AS "currentStory" - FROM profile_save_archives - WHERE user_id = $1 - ORDER BY saved_at DESC`, - [userId], - ); - - return result.rows.map((row) => toProfileSaveArchiveSummary(row)); - } - - async resumeProfileSaveArchive(userId: string, worldKey: string) { - const archive = await this.findProfileSaveArchive(userId, worldKey); - if (!archive) { - return null; - } - - const snapshot = { - version: SAVE_SNAPSHOT_VERSION, - savedAt: archive.savedAt, - gameState: archive.gameState, - bottomTab: archive.bottomTab, - currentStory: archive.currentStory, - } satisfies SavedSnapshot; - const persistedSnapshot = await this.upsertCurrentSnapshot(userId, snapshot); - - return { - entry: toProfileSaveArchiveSummary(archive), - snapshot: persistedSnapshot, - }; - } - - async deleteSnapshot(userId: string) { - await this.db.query(`DELETE FROM save_snapshots WHERE user_id = $1`, [ - userId, - ]); - } - - async getSettings(userId: string) { - const result = await this.db.query( - `SELECT music_volume AS "musicVolume", - platform_theme AS "platformTheme" - FROM runtime_settings - WHERE user_id = $1`, - [userId], - ); - const row = result.rows[0]; - - return { - musicVolume: - typeof row?.musicVolume === 'number' - ? row.musicVolume - : DEFAULT_MUSIC_VOLUME, - platformTheme: - row?.platformTheme === 'dark' - ? 'dark' - : DEFAULT_PLATFORM_THEME, - } satisfies RuntimeSettings; - } - - async putSettings(userId: string, settings: RuntimeSettings) { - const nextSettings = { - musicVolume: Math.max(0, Math.min(1, settings.musicVolume)), - platformTheme: - settings.platformTheme === 'dark' ? 'dark' : DEFAULT_PLATFORM_THEME, - } satisfies RuntimeSettings; - - const result = await this.db.query( - `INSERT INTO runtime_settings (user_id, music_volume, platform_theme, updated_at) - VALUES ($1, $2, $3, $4) - ON CONFLICT (user_id) DO UPDATE SET - music_volume = EXCLUDED.music_volume, - platform_theme = EXCLUDED.platform_theme, - updated_at = EXCLUDED.updated_at - RETURNING music_volume AS "musicVolume", - platform_theme AS "platformTheme"`, - [ - userId, - nextSettings.musicVolume, - nextSettings.platformTheme, - new Date().toISOString(), - ], - ); - - return { - musicVolume: result.rows[0]?.musicVolume ?? nextSettings.musicVolume, - platformTheme: - result.rows[0]?.platformTheme ?? nextSettings.platformTheme, - } satisfies RuntimeSettings; - } - - async listPlatformBrowseHistory(userId: string) { - const result = await this.db.query( - `SELECT owner_user_id AS "ownerUserId", - profile_id AS "profileId", - world_name AS "worldName", - subtitle, - summary_text AS "summaryText", - cover_image_src AS "coverImageSrc", - theme_mode AS "themeMode", - author_display_name AS "authorDisplayName", - visited_at AS "visitedAt" - FROM user_browse_history - WHERE user_id = $1 - ORDER BY visited_at DESC`, - [userId], - ); - - return result.rows.map((row) => toPlatformBrowseHistoryEntry(row)); - } - - async upsertPlatformBrowseHistoryEntries( - userId: string, - entries: PlatformBrowseHistoryWriteEntry[], - ) { - const dedupedEntries = [ - ...new Map( - entries - .map((entry) => normalizePlatformBrowseHistoryWriteEntry(entry)) - .filter((entry): entry is PlatformBrowseHistoryEntry => - Boolean(entry), - ) - .sort( - (left, right) => - new Date(right.visitedAt).getTime() - - new Date(left.visitedAt).getTime(), - ) - .map( - (entry) => - [`${entry.ownerUserId}:${entry.profileId}`, entry] as const, - ), - ).values(), - ]; - - for (const entry of dedupedEntries) { - await this.db.query( - `INSERT INTO user_browse_history ( - user_id, - owner_user_id, - profile_id, - world_name, - subtitle, - summary_text, - cover_image_src, - theme_mode, - author_display_name, - visited_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - ON CONFLICT (user_id, owner_user_id, profile_id) DO UPDATE SET - world_name = EXCLUDED.world_name, - subtitle = EXCLUDED.subtitle, - summary_text = EXCLUDED.summary_text, - cover_image_src = EXCLUDED.cover_image_src, - theme_mode = EXCLUDED.theme_mode, - author_display_name = EXCLUDED.author_display_name, - visited_at = EXCLUDED.visited_at`, - [ - userId, - entry.ownerUserId, - entry.profileId, - entry.worldName, - entry.subtitle, - entry.summaryText, - entry.coverImageSrc, - entry.themeMode, - entry.authorDisplayName, - entry.visitedAt, - ], - ); - } - - return this.listPlatformBrowseHistory(userId); - } - - async clearPlatformBrowseHistory(userId: string) { - await this.db.query(`DELETE FROM user_browse_history WHERE user_id = $1`, [ - userId, - ]); - } - - async listCustomWorldProfiles(userId: string) { - return this.rpgWorldProfileRepository.listOwnProfiles(userId); - } - - async upsertCustomWorldProfile( - userId: string, - profileId: string, - profile: Record, - authorDisplayName: string, - ) { - return this.rpgWorldProfileRepository.upsertOwnProfile( - userId, - profileId, - profile, - authorDisplayName, - ); - } - - async deleteCustomWorldProfile(userId: string, profileId: string) { - return this.rpgWorldProfileRepository.softDeleteOwnProfile( - userId, - profileId, - ); - } - - async listCustomWorldSessions(userId: string) { - return this.rpgAgentSessionRepository.listSessions(userId); - } - - async getCustomWorldSession(userId: string, sessionId: string) { - return this.rpgAgentSessionRepository.getSession(userId, sessionId); - } - - async upsertCustomWorldSession( - userId: string, - sessionId: string, - session: CustomWorldSessionRecord, - ) { - return this.rpgAgentSessionRepository.upsertSession( - userId, - sessionId, - session, - ); - } - - async publishCustomWorldProfile( - userId: string, - profileId: string, - authorDisplayName: string, - ) { - return this.rpgWorldProfileRepository.publishOwnProfile( - userId, - profileId, - authorDisplayName, - ); - } - - async unpublishCustomWorldProfile( - userId: string, - profileId: string, - authorDisplayName: string, - ) { - return this.rpgWorldProfileRepository.unpublishOwnProfile( - userId, - profileId, - authorDisplayName, - ); - } - - async listPublishedCustomWorldGallery() { - return this.rpgWorldProfileRepository.listPublishedGallery(); - } - - async getPublishedCustomWorldGalleryDetail( - ownerUserId: string, - profileId: string, - ) { - return this.rpgWorldProfileRepository.getPublishedGalleryDetail( - ownerUserId, - profileId, - ); - } -} diff --git a/server-node/src/repositories/smsAuthEventRepository.ts b/server-node/src/repositories/smsAuthEventRepository.ts deleted file mode 100644 index 70f90699..00000000 --- a/server-node/src/repositories/smsAuthEventRepository.ts +++ /dev/null @@ -1,302 +0,0 @@ -import crypto from 'node:crypto'; - -import type { QueryResultRow } from 'pg'; - -import type { AppDatabase } from '../db.js'; - -export type SmsAuthScene = 'login' | 'bind_phone' | 'change_phone'; -export type SmsAuthAction = 'send_code' | 'verify_code'; -export type SmsDeliveryStatus = 'pending' | 'delivered' | 'failed' | 'unknown'; - -export type SmsAuthEventRecord = { - id: string; - phoneNumber: string; - scene: SmsAuthScene; - action: SmsAuthAction; - success: boolean; - ip: string | null; - userAgent: string | null; - provider: string | null; - providerRequestId: string | null; - providerBizId: string | null; - providerOutId: string | null; - deliveryStatus: SmsDeliveryStatus; - deliveryReportRawJson: Record | null; - deliveryReportedAt: string | null; - createdAt: string; -}; - -type SmsAuthEventRow = QueryResultRow & { - id: string; - phone_number: string; - scene: SmsAuthScene; - action: SmsAuthAction; - success: boolean; - ip: string | null; - user_agent: string | null; - provider: string | null; - provider_request_id: string | null; - provider_biz_id: string | null; - provider_out_id: string | null; - delivery_status: SmsDeliveryStatus; - delivery_report_raw_json: Record | null; - delivery_reported_at: string | null; - created_at: string; - total: number; -}; - -function toSmsAuthEventRecord( - row: SmsAuthEventRow | undefined, -): SmsAuthEventRecord | null { - if (!row) { - return null; - } - - return { - id: row.id, - phoneNumber: row.phone_number, - scene: row.scene, - action: row.action, - success: row.success, - ip: row.ip, - userAgent: row.user_agent, - provider: row.provider, - providerRequestId: row.provider_request_id, - providerBizId: row.provider_biz_id, - providerOutId: row.provider_out_id, - deliveryStatus: row.delivery_status, - deliveryReportRawJson: row.delivery_report_raw_json, - deliveryReportedAt: row.delivery_reported_at, - createdAt: row.created_at, - }; -} - -export class SmsAuthEventRepository { - constructor(private readonly db: AppDatabase) {} - - async create(input: { - phoneNumber: string; - scene: SmsAuthScene; - action: SmsAuthAction; - success: boolean; - ip: string | null; - userAgent: string | null; - provider?: string | null; - providerRequestId?: string | null; - providerBizId?: string | null; - providerOutId?: string | null; - deliveryStatus?: SmsDeliveryStatus; - deliveryReportRawJson?: Record | null; - deliveryReportedAt?: string | null; - }) { - const id = `smsev_${crypto.randomBytes(16).toString('hex')}`; - const createdAt = new Date().toISOString(); - const result = await this.db.query( - `INSERT INTO sms_auth_events ( - id, - phone_number, - scene, - action, - success, - ip, - user_agent, - provider, - provider_request_id, - provider_biz_id, - provider_out_id, - delivery_status, - delivery_report_raw_json, - delivery_reported_at, - created_at - ) - VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15 - ) - RETURNING - id, - phone_number, - scene, - action, - success, - ip, - user_agent, - provider, - provider_request_id, - provider_biz_id, - provider_out_id, - delivery_status, - delivery_report_raw_json, - delivery_reported_at, - created_at`, - [ - id, - input.phoneNumber, - input.scene, - input.action, - input.success, - input.ip, - input.userAgent, - input.provider ?? null, - input.providerRequestId ?? null, - input.providerBizId ?? null, - input.providerOutId ?? null, - input.deliveryStatus ?? 'pending', - input.deliveryReportRawJson ?? null, - input.deliveryReportedAt ?? null, - createdAt, - ], - ); - - return toSmsAuthEventRecord(result.rows[0]); - } - - async countSinceByPhone(params: { - phoneNumber: string; - action: SmsAuthAction; - success?: boolean; - since: string; - }) { - const result = await this.db.query( - `SELECT COUNT(*)::int AS total - FROM sms_auth_events - WHERE phone_number = $1 - AND action = $2 - AND ($3::boolean IS NULL OR success = $3) - AND created_at >= $4`, - [ - params.phoneNumber, - params.action, - params.success ?? null, - params.since, - ], - ); - - return result.rows[0]?.total ?? 0; - } - - async countSinceByIp(params: { - ip: string | null; - action: SmsAuthAction; - success?: boolean; - since: string; - }) { - if (!params.ip) { - return 0; - } - - const result = await this.db.query( - `SELECT COUNT(*)::int AS total - FROM sms_auth_events - WHERE ip = $1 - AND action = $2 - AND ($3::boolean IS NULL OR success = $3) - AND created_at >= $4`, - [ - params.ip, - params.action, - params.success ?? null, - params.since, - ], - ); - - return result.rows[0]?.total ?? 0; - } - - async findLatestByProviderBizId(providerBizId: string) { - const result = await this.db.query( - `SELECT - id, - phone_number, - scene, - action, - success, - ip, - user_agent, - provider, - provider_request_id, - provider_biz_id, - provider_out_id, - delivery_status, - delivery_report_raw_json, - delivery_reported_at, - created_at, - 0::int AS total - FROM sms_auth_events - WHERE provider_biz_id = $1 - ORDER BY created_at DESC - LIMIT 1`, - [providerBizId], - ); - - return toSmsAuthEventRecord(result.rows[0]); - } - - async findLatestByProviderOutId(providerOutId: string) { - const result = await this.db.query( - `SELECT - id, - phone_number, - scene, - action, - success, - ip, - user_agent, - provider, - provider_request_id, - provider_biz_id, - provider_out_id, - delivery_status, - delivery_report_raw_json, - delivery_reported_at, - created_at, - 0::int AS total - FROM sms_auth_events - WHERE provider_out_id = $1 - ORDER BY created_at DESC - LIMIT 1`, - [providerOutId], - ); - - return toSmsAuthEventRecord(result.rows[0]); - } - - async updateDeliveryStatus(params: { - id: string; - deliveryStatus: SmsDeliveryStatus; - deliveryReportRawJson: Record | null; - deliveryReportedAt: string; - }) { - const result = await this.db.query( - `UPDATE sms_auth_events - SET delivery_status = $1, - delivery_report_raw_json = $2, - delivery_reported_at = $3 - WHERE id = $4 - RETURNING - id, - phone_number, - scene, - action, - success, - ip, - user_agent, - provider, - provider_request_id, - provider_biz_id, - provider_out_id, - delivery_status, - delivery_report_raw_json, - delivery_reported_at, - created_at, - 0::int AS total`, - [ - params.deliveryStatus, - params.deliveryReportRawJson, - params.deliveryReportedAt, - params.id, - ], - ); - - return toSmsAuthEventRecord(result.rows[0]); - } -} diff --git a/server-node/src/repositories/userRepository.ts b/server-node/src/repositories/userRepository.ts deleted file mode 100644 index 9d10fa21..00000000 --- a/server-node/src/repositories/userRepository.ts +++ /dev/null @@ -1,290 +0,0 @@ -import crypto from 'node:crypto'; - -import type { QueryResultRow } from 'pg'; - -import type { AppDatabase } from '../db.js'; - -export type UserRecord = { - id: string; - username: string | null; - passwordHash: string; - tokenVersion: number; - displayName: string; - loginProvider: 'password' | 'phone' | 'wechat'; - accountStatus: 'active' | 'pending_bind_phone' | 'disabled'; - phoneNumber: string | null; - phoneVerifiedAt: string | null; - createdAt: string; - updatedAt: string; -}; - -type UserRow = QueryResultRow & { - id: string; - username: string | null; - password_hash: string; - token_version: number; - display_name: string; - login_provider: 'password' | 'phone' | 'wechat'; - account_status: 'active' | 'pending_bind_phone' | 'disabled'; - phone_number: string | null; - phone_verified_at: string | null; - created_at: string; - updated_at: string; -}; - -function toUserRecord(row: UserRow | undefined): UserRecord | null { - if (!row) { - return null; - } - - return { - id: row.id, - username: row.username, - passwordHash: row.password_hash, - tokenVersion: row.token_version, - displayName: row.display_name, - loginProvider: row.login_provider, - accountStatus: row.account_status, - phoneNumber: row.phone_number, - phoneVerifiedAt: row.phone_verified_at, - createdAt: row.created_at, - updatedAt: row.updated_at, - }; -} - -export type CreatePhoneUserInput = { - username: string; - passwordHash: string; - displayName: string; - phoneNumber: string; - phoneVerifiedAt: string; -}; - -export type CreateWechatPendingUserInput = { - username: string; - passwordHash: string; - displayName: string; -}; - -export type UserRepositoryPort = { - findByUsername(username: string): Promise; - findByPhoneNumber(phoneNumber: string): Promise; - findById(userId: string): Promise; - create(username: string, passwordHash: string): Promise; - createPhoneUser(input: CreatePhoneUserInput): Promise; - createWechatPendingUser( - input: CreateWechatPendingUserInput, - ): Promise; - activatePendingWechatUser( - userId: string, - params: { - displayName: string; - phoneNumber: string; - phoneVerifiedAt: string; - }, - ): Promise; - updatePhoneInfo( - userId: string, - params: { - phoneNumber: string; - phoneVerifiedAt: string; - displayName?: string; - }, - ): Promise; - deleteUser(userId: string): Promise; - incrementTokenVersion(userId: string): Promise; -}; - -export class UserRepository implements UserRepositoryPort { - constructor(private readonly db: AppDatabase) {} - - async findByUsername(username: string) { - const result = await this.db.query( - `SELECT id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at - FROM users - WHERE username = $1`, - [username], - ); - return toUserRecord(result.rows[0]); - } - - async findByPhoneNumber(phoneNumber: string) { - const result = await this.db.query( - `SELECT id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at - FROM users - WHERE phone_number = $1`, - [phoneNumber], - ); - return toUserRecord(result.rows[0]); - } - - async findById(userId: string) { - const result = await this.db.query( - `SELECT id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at - FROM users - WHERE id = $1`, - [userId], - ); - return toUserRecord(result.rows[0]); - } - - async create(username: string, passwordHash: string) { - const now = new Date().toISOString(); - const id = `user_${crypto.randomBytes(16).toString('hex')}`; - - const result = await this.db.query( - `INSERT INTO users ( - id, - username, - password_hash, - token_version, - display_name, - login_provider, - account_status, - created_at, - updated_at - ) - VALUES ($1, $2, $3, 1, $4, 'password', 'active', $5, $6) - RETURNING id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at`, - [id, username, passwordHash, username, now, now], - ); - - return toUserRecord(result.rows[0]); - } - - async createPhoneUser(input: CreatePhoneUserInput) { - const now = new Date().toISOString(); - const id = `user_${crypto.randomBytes(16).toString('hex')}`; - - const result = await this.db.query( - `INSERT INTO users ( - id, - username, - password_hash, - token_version, - display_name, - login_provider, - account_status, - phone_number, - phone_verified_at, - created_at, - updated_at - ) - VALUES ($1, $2, $3, 1, $4, 'phone', 'active', $5, $6, $7, $8) - RETURNING id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at`, - [ - id, - input.username, - input.passwordHash, - input.displayName, - input.phoneNumber, - input.phoneVerifiedAt, - now, - now, - ], - ); - - return toUserRecord(result.rows[0]); - } - - async createWechatPendingUser(input: CreateWechatPendingUserInput) { - const now = new Date().toISOString(); - const id = `user_${crypto.randomBytes(16).toString('hex')}`; - - const result = await this.db.query( - `INSERT INTO users ( - id, - username, - password_hash, - token_version, - display_name, - login_provider, - account_status, - created_at, - updated_at - ) - VALUES ($1, $2, $3, 1, $4, 'wechat', 'pending_bind_phone', $5, $6) - RETURNING id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at`, - [id, input.username, input.passwordHash, input.displayName, now, now], - ); - - return toUserRecord(result.rows[0]); - } - - async activatePendingWechatUser( - userId: string, - params: { - displayName: string; - phoneNumber: string; - phoneVerifiedAt: string; - }, - ) { - const result = await this.db.query( - `UPDATE users - SET account_status = 'active', - phone_number = $1, - phone_verified_at = $2, - display_name = $3, - updated_at = $4 - WHERE id = $5 - RETURNING id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at`, - [ - params.phoneNumber, - params.phoneVerifiedAt, - params.displayName, - new Date().toISOString(), - userId, - ], - ); - - return toUserRecord(result.rows[0]); - } - - async updatePhoneInfo( - userId: string, - params: { - phoneNumber: string; - phoneVerifiedAt: string; - displayName?: string; - }, - ) { - const result = await this.db.query( - `UPDATE users - SET phone_number = $1, - phone_verified_at = $2, - display_name = COALESCE($3, display_name), - updated_at = $4 - WHERE id = $5 - RETURNING id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at`, - [ - params.phoneNumber, - params.phoneVerifiedAt, - params.displayName ?? null, - new Date().toISOString(), - userId, - ], - ); - - return toUserRecord(result.rows[0]); - } - - async deleteUser(userId: string) { - await this.db.query( - `DELETE FROM users - WHERE id = $1`, - [userId], - ); - } - - async incrementTokenVersion(userId: string) { - const result = await this.db.query( - `UPDATE users - SET token_version = token_version + 1, updated_at = $1 - WHERE id = $2 - RETURNING id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at`, - [new Date().toISOString(), userId], - ); - - return toUserRecord(result.rows[0]); - } -} diff --git a/server-node/src/repositories/userSessionRepository.ts b/server-node/src/repositories/userSessionRepository.ts deleted file mode 100644 index 50d4a46e..00000000 --- a/server-node/src/repositories/userSessionRepository.ts +++ /dev/null @@ -1,214 +0,0 @@ -import crypto from 'node:crypto'; - -import type { QueryResultRow } from 'pg'; - -import type { AppDatabase } from '../db.js'; - -export type UserSessionRecord = { - id: string; - userId: string; - refreshTokenHash: string; - clientType: string; - userAgent: string | null; - ip: string | null; - expiresAt: string; - revokedAt: string | null; - createdAt: string; - updatedAt: string; - lastSeenAt: string; -}; - -type UserSessionRow = QueryResultRow & { - id: string; - user_id: string; - refresh_token_hash: string; - client_type: string; - user_agent: string | null; - ip: string | null; - expires_at: string; - revoked_at: string | null; - created_at: string; - updated_at: string; - last_seen_at: string; -}; - -function toUserSessionRecord( - row: UserSessionRow | undefined, -): UserSessionRecord | null { - if (!row) { - return null; - } - - return { - id: row.id, - userId: row.user_id, - refreshTokenHash: row.refresh_token_hash, - clientType: row.client_type, - userAgent: row.user_agent, - ip: row.ip, - expiresAt: row.expires_at, - revokedAt: row.revoked_at, - createdAt: row.created_at, - updatedAt: row.updated_at, - lastSeenAt: row.last_seen_at, - }; -} - -export type CreateUserSessionInput = { - userId: string; - refreshTokenHash: string; - clientType: string; - userAgent: string | null; - ip: string | null; - expiresAt: string; -}; - -export class UserSessionRepository { - constructor(private readonly db: AppDatabase) {} - - async create(input: CreateUserSessionInput) { - const now = new Date().toISOString(); - const sessionId = `usess_${crypto.randomBytes(16).toString('hex')}`; - - const result = await this.db.query( - `INSERT INTO user_sessions ( - id, - user_id, - refresh_token_hash, - client_type, - user_agent, - ip, - expires_at, - revoked_at, - created_at, - updated_at, - last_seen_at - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, NULL, $8, $9, $10) - RETURNING id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at`, - [ - sessionId, - input.userId, - input.refreshTokenHash, - input.clientType, - input.userAgent, - input.ip, - input.expiresAt, - now, - now, - now, - ], - ); - - return toUserSessionRecord(result.rows[0]); - } - - async findActiveByRefreshTokenHash(refreshTokenHash: string) { - const result = await this.db.query( - `SELECT id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at - FROM user_sessions - WHERE refresh_token_hash = $1 - LIMIT 1`, - [refreshTokenHash], - ); - - return toUserSessionRecord(result.rows[0]); - } - - async rotate( - sessionId: string, - input: { - refreshTokenHash: string; - expiresAt: string; - lastSeenAt: string; - }, - ) { - const result = await this.db.query( - `UPDATE user_sessions - SET refresh_token_hash = $1, - expires_at = $2, - last_seen_at = $3, - updated_at = $4 - WHERE id = $5 - RETURNING id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at`, - [ - input.refreshTokenHash, - input.expiresAt, - input.lastSeenAt, - new Date().toISOString(), - sessionId, - ], - ); - - return toUserSessionRecord(result.rows[0]); - } - - async revoke(sessionId: string) { - const now = new Date().toISOString(); - const result = await this.db.query( - `UPDATE user_sessions - SET revoked_at = $1, - updated_at = $2 - WHERE id = $3 - RETURNING id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at`, - [now, now, sessionId], - ); - - return toUserSessionRecord(result.rows[0]); - } - - async findById(sessionId: string) { - const result = await this.db.query( - `SELECT id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at - FROM user_sessions - WHERE id = $1 - LIMIT 1`, - [sessionId], - ); - - return toUserSessionRecord(result.rows[0]); - } - - async listActiveByUserId(userId: string) { - const result = await this.db.query( - `SELECT id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at - FROM user_sessions - WHERE user_id = $1 - AND revoked_at IS NULL - ORDER BY last_seen_at DESC, created_at DESC`, - [userId], - ); - - return result.rows - .map((row) => toUserSessionRecord(row)) - .filter((row): row is UserSessionRecord => Boolean(row)); - } - - async revokeAllByUserId(userId: string) { - const now = new Date().toISOString(); - await this.db.query( - `UPDATE user_sessions - SET revoked_at = $1, - updated_at = $2 - WHERE user_id = $3 - AND revoked_at IS NULL`, - [now, now, userId], - ); - } - - async revokeByUserIdAndSessionId(userId: string, sessionId: string) { - const now = new Date().toISOString(); - const result = await this.db.query( - `UPDATE user_sessions - SET revoked_at = $1, - updated_at = $2 - WHERE user_id = $3 - AND id = $4 - AND revoked_at IS NULL - RETURNING id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at`, - [now, now, userId, sessionId], - ); - - return toUserSessionRecord(result.rows[0]); - } -} diff --git a/server-node/src/routes/authRoutes.ts b/server-node/src/routes/authRoutes.ts deleted file mode 100644 index 8e2f6cd3..00000000 --- a/server-node/src/routes/authRoutes.ts +++ /dev/null @@ -1,534 +0,0 @@ -import express, { type Request, type Response, Router } from 'express'; -import { z } from 'zod'; - -import type { - AuthEntryRequest, - AuthPhoneChangeRequest, - AuthPhoneLoginRequest, - AuthPhoneSendCodeRequest, - AuthWechatBindPhoneRequest, -} from '../../../packages/shared/src/contracts/auth.js'; -import { buildAuthRequestContext } from '../auth/authRequestContext.js'; -import { - bindWechatPhone, - buildAuthLoginOptionsResponse, - buildAuthMeResponse, - changeUserPhone, - createRefreshSession, - entryWithPassword, - entryWithPhoneCode, - liftRiskBlock, - listActiveRiskBlocks, - listAuthAuditLogs, - listUserSessions, - logoutAllUserSessions, - logoutUser, - refreshAuthSession, - resolveWechatCallback, - revokeRefreshSession, - revokeUserSession, - sendPhoneLoginCode, - handleAliyunSmsDeliveryReport, - startWechatLogin, -} from '../auth/authService.js'; -import { - clearRefreshSessionCookie, - readRefreshSessionToken, - setRefreshSessionCookie, -} from '../auth/refreshSessionCookie.js'; -import type { AppContext } from '../context.js'; -import { asyncHandler, sendApiResponse } from '../http.js'; -import { requireJwtAuth } from '../middleware/auth.js'; -import { routeMeta } from '../middleware/routeMeta.js'; - -const authEntrySchema = z.object({ - username: z.string(), - password: z.string(), -}); - -const authPhoneSendCodeSchema = z.object({ - phone: z.string(), - scene: z.enum(['login', 'bind_phone', 'change_phone']).optional(), - captchaChallengeId: z.string().optional(), - captchaAnswer: z.string().optional(), -}); - -const authPhoneLoginSchema = z.object({ - phone: z.string(), - code: z.string(), -}); - -const authPhoneChangeSchema = z.object({ - phone: z.string(), - code: z.string(), -}); - -const authWechatBindPhoneSchema = z.object({ - phone: z.string(), - code: z.string(), -}); - -function resolveRequestOrigin(request: Request) { - const forwardedProto = request.header('x-forwarded-proto')?.split(',')[0]?.trim(); - const forwardedHost = request.header('x-forwarded-host')?.split(',')[0]?.trim(); - const protocol = forwardedProto || request.protocol || 'http'; - const host = forwardedHost || request.header('host') || '127.0.0.1:8081'; - return `${protocol}://${host}`; -} - -function normalizeRedirectPath(rawValue: unknown, fallback: string) { - if (typeof rawValue !== 'string' || !rawValue.trim()) { - return fallback; - } - - const value = rawValue.trim(); - if (value.startsWith('/')) { - return value; - } - - try { - const url = new URL(value); - return `${url.pathname}${url.search}${url.hash}`; - } catch { - return fallback; - } -} - -function buildAuthResultRedirectUrl( - redirectPath: string, - params: Record, -) { - const hash = new URLSearchParams(params).toString(); - const [pathWithoutHash] = redirectPath.split('#'); - return `${pathWithoutHash || '/'}#${hash}`; -} - -function buildRefreshCookieLifetimeSeconds( - context: AppContext, - expiresAt: string, -) { - return Math.max( - 0, - Math.floor((new Date(expiresAt).getTime() - Date.now()) / 1000), - ); -} - -export function createAuthRoutes(context: AppContext) { - const router = Router(); - const requireAuth = requireJwtAuth(context.config, context.userRepository); - - router.use('/phone/delivery-report/aliyun', express.urlencoded({ extended: false })); - - router.get( - '/login-options', - routeMeta({ operation: 'auth.login_options' }), - asyncHandler(async (_request, response) => { - sendApiResponse(response, buildAuthLoginOptionsResponse(context)); - }), - ); - - router.post( - '/entry', - routeMeta({ operation: 'auth.entry' }), - asyncHandler(async (request, response) => { - const payload = authEntrySchema.parse(request.body) as AuthEntryRequest; - const requestContext = buildAuthRequestContext(request); - const result = await entryWithPassword( - context, - payload.username, - payload.password, - requestContext, - ); - const user = await context.userRepository.findById(result.user.id); - if (!user) { - throw new Error('failed to resolve auth user after password entry'); - } - const refreshSession = await createRefreshSession( - context, - user, - requestContext, - ); - setRefreshSessionCookie( - response, - context.config, - refreshSession.refreshToken, - buildRefreshCookieLifetimeSeconds(context, refreshSession.expiresAt), - ); - sendApiResponse(response, result); - }), - ); - - router.post( - '/phone/send-code', - routeMeta({ operation: 'auth.phone.send_code' }), - asyncHandler(async (request, response) => { - const payload = authPhoneSendCodeSchema.parse( - request.body, - ) as AuthPhoneSendCodeRequest; - sendApiResponse( - response, - await sendPhoneLoginCode( - context, - payload.phone, - payload.scene, - buildAuthRequestContext(request), - { - captchaChallengeId: payload.captchaChallengeId, - captchaAnswer: payload.captchaAnswer, - }, - ), - ); - }), - ); - - router.post( - '/phone/delivery-report/aliyun', - routeMeta({ operation: 'auth.phone.delivery_report.aliyun' }), - asyncHandler(async (request, response) => { - const payload = - request.body && typeof request.body === 'object' - ? (request.body as Record) - : {}; - - sendApiResponse( - response, - await handleAliyunSmsDeliveryReport(context, payload), - ); - }), - ); - - router.post( - '/phone/change', - routeMeta({ operation: 'auth.phone.change' }), - requireAuth, - asyncHandler(async (request, response) => { - const payload = authPhoneChangeSchema.parse( - request.body, - ) as AuthPhoneChangeRequest; - const requestContext = buildAuthRequestContext(request); - sendApiResponse( - response, - await changeUserPhone( - context, - request.userId!, - payload.phone, - payload.code, - requestContext, - ), - ); - }), - ); - - router.post( - '/phone/login', - routeMeta({ operation: 'auth.phone.login' }), - asyncHandler(async (request, response) => { - const payload = authPhoneLoginSchema.parse( - request.body, - ) as AuthPhoneLoginRequest; - const requestContext = buildAuthRequestContext(request); - const result = await entryWithPhoneCode( - context, - payload.phone, - payload.code, - requestContext, - ); - const user = await context.userRepository.findById(result.user.id); - if (!user) { - throw new Error('failed to resolve auth user after phone entry'); - } - const refreshSession = await createRefreshSession( - context, - user, - requestContext, - ); - setRefreshSessionCookie( - response, - context.config, - refreshSession.refreshToken, - buildRefreshCookieLifetimeSeconds(context, refreshSession.expiresAt), - ); - sendApiResponse(response, result); - }), - ); - - router.get( - '/wechat/start', - routeMeta({ operation: 'auth.wechat.start' }), - asyncHandler(async (request, response) => { - const redirectPath = normalizeRedirectPath( - request.query.redirectPath, - context.config.wechatAuth.defaultRedirectPath, - ); - const requestContext = buildAuthRequestContext(request); - const callbackUrl = new URL( - context.config.wechatAuth.callbackPath, - resolveRequestOrigin(request), - ).toString(); - - sendApiResponse( - response, - await startWechatLogin( - context, - callbackUrl, - redirectPath, - requestContext, - ), - ); - }), - ); - - router.get( - '/wechat/callback', - routeMeta({ operation: 'auth.wechat.callback' }), - asyncHandler(async (request, response) => { - const state = - typeof request.query.state === 'string' ? request.query.state.trim() : ''; - const stateRecord = context.wechatAuthStates.consume(state); - const redirectPath = - stateRecord?.redirectPath ?? context.config.wechatAuth.defaultRedirectPath; - - if (!stateRecord) { - response.redirect( - 302, - buildAuthResultRedirectUrl(redirectPath, { - auth_provider: 'wechat', - auth_error: '微信登录状态已失效,请重新发起登录。', - }), - ); - return; - } - - try { - const requestContext = buildAuthRequestContext(request); - const result = await resolveWechatCallback(context, { - code: typeof request.query.code === 'string' ? request.query.code : null, - mockCode: - typeof request.query.mock_code === 'string' - ? request.query.mock_code - : null, - }, requestContext); - const user = await context.userRepository.findById(result.user.id); - if (!user) { - throw new Error('failed to resolve auth user after wechat callback'); - } - const refreshSession = await createRefreshSession( - context, - user, - requestContext, - ); - setRefreshSessionCookie( - response, - context.config, - refreshSession.refreshToken, - buildRefreshCookieLifetimeSeconds(context, refreshSession.expiresAt), - ); - - response.redirect( - 302, - buildAuthResultRedirectUrl(redirectPath, { - auth_provider: 'wechat', - auth_token: result.token, - auth_binding_status: result.user.bindingStatus, - }), - ); - } catch (error) { - const message = - error instanceof Error ? error.message : '微信登录失败,请稍后再试。'; - response.redirect( - 302, - buildAuthResultRedirectUrl(redirectPath, { - auth_provider: 'wechat', - auth_error: message, - }), - ); - } - }), - ); - - router.post( - '/wechat/bind-phone', - routeMeta({ operation: 'auth.wechat.bind_phone' }), - requireAuth, - asyncHandler(async (request, response) => { - const payload = authWechatBindPhoneSchema.parse( - request.body, - ) as AuthWechatBindPhoneRequest; - const requestContext = buildAuthRequestContext(request); - const result = await bindWechatPhone( - context, - request.userId!, - payload.phone, - payload.code, - requestContext, - ); - const user = await context.userRepository.findById(result.user.id); - if (!user) { - throw new Error('failed to resolve auth user after wechat bind'); - } - const refreshSession = await createRefreshSession( - context, - user, - requestContext, - ); - setRefreshSessionCookie( - response, - context.config, - refreshSession.refreshToken, - buildRefreshCookieLifetimeSeconds(context, refreshSession.expiresAt), - ); - sendApiResponse(response, result); - }), - ); - - router.post( - '/refresh', - routeMeta({ operation: 'auth.refresh' }), - asyncHandler(async (request, response) => { - const refreshToken = readRefreshSessionToken(request, context.config); - try { - const result = await refreshAuthSession(context, refreshToken); - setRefreshSessionCookie( - response, - context.config, - result.refreshToken, - buildRefreshCookieLifetimeSeconds(context, result.refreshExpiresAt), - ); - sendApiResponse(response, { - ok: true, - token: result.token, - }); - } catch (error) { - clearRefreshSessionCookie(response, context.config); - throw error; - } - }), - ); - - router.get( - '/risk-blocks', - routeMeta({ operation: 'auth.risk_blocks' }), - requireAuth, - asyncHandler(async (request, response) => { - const user = await context.userRepository.findById(request.userId!); - sendApiResponse( - response, - await listActiveRiskBlocks( - context, - user!, - buildAuthRequestContext(request), - ), - ); - }), - ); - - router.post( - '/risk-blocks/:scopeType/lift', - routeMeta({ operation: 'auth.risk_blocks.lift' }), - requireAuth, - asyncHandler(async (request, response) => { - const user = await context.userRepository.findById(request.userId!); - sendApiResponse( - response, - await liftRiskBlock( - context, - user!, - buildAuthRequestContext(request), - request.params.scopeType === 'phone' ? 'phone' : 'ip', - ), - ); - }), - ); - - router.get( - '/sessions', - routeMeta({ operation: 'auth.sessions' }), - requireAuth, - asyncHandler(async (request, response) => { - const refreshToken = readRefreshSessionToken(request, context.config); - sendApiResponse( - response, - await listUserSessions(context, request.userId!, refreshToken), - ); - }), - ); - - router.post( - '/sessions/:sessionId/revoke', - routeMeta({ operation: 'auth.sessions.revoke' }), - requireAuth, - asyncHandler(async (request, response) => { - const refreshToken = readRefreshSessionToken(request, context.config); - sendApiResponse( - response, - await revokeUserSession( - context, - request.userId!, - request.params.sessionId, - refreshToken, - buildAuthRequestContext(request), - ), - ); - }), - ); - - router.get( - '/audit-logs', - routeMeta({ operation: 'auth.audit_logs' }), - requireAuth, - asyncHandler(async (request, response) => { - sendApiResponse( - response, - await listAuthAuditLogs(context, request.userId!), - ); - }), - ); - - router.get( - '/me', - routeMeta({ operation: 'auth.me' }), - requireAuth, - asyncHandler(async (request, response) => { - const user = await context.userRepository.findById(request.userId!); - sendApiResponse(response, await buildAuthMeResponse(context, user)); - }), - ); - - router.post( - '/logout-all', - routeMeta({ operation: 'auth.logout_all' }), - requireAuth, - asyncHandler(async (request, response) => { - clearRefreshSessionCookie(response, context.config); - sendApiResponse( - response, - await logoutAllUserSessions( - context, - request.userId!, - buildAuthRequestContext(request), - ), - ); - }), - ); - - router.post( - '/logout', - routeMeta({ operation: 'auth.logout' }), - requireAuth, - asyncHandler(async (request, response) => { - const refreshToken = readRefreshSessionToken(request, context.config); - await revokeRefreshSession(context, refreshToken); - clearRefreshSessionCookie(response, context.config); - sendApiResponse( - response, - await logoutUser( - context, - request.userId!, - buildAuthRequestContext(request), - ), - ); - }), - ); - - return router; -} diff --git a/server-node/src/routes/bigFishProxyRoutes.ts b/server-node/src/routes/bigFishProxyRoutes.ts deleted file mode 100644 index 056ee721..00000000 --- a/server-node/src/routes/bigFishProxyRoutes.ts +++ /dev/null @@ -1,341 +0,0 @@ -import { Readable } from 'node:stream'; - -import { type Request, type Response,Router } from 'express'; - -import type { AppContext } from '../context.js'; -import { badRequest, upstreamError } from '../errors.js'; -import { - API_RESPONSE_ENVELOPE_HEADER, - API_VERSION_HEADER, - asyncHandler, - prepareApiResponse, - RESPONSE_TIME_HEADER, - ROUTE_VERSION_HEADER, -} from '../http.js'; -import { requireJwtAuth } from '../middleware/auth.js'; -import { routeMeta } from '../middleware/routeMeta.js'; - -const BIG_FISH_ROUTE_VERSION = '2026-04-22'; -const DEFAULT_RUST_API_TARGET = 'http://127.0.0.1:3100'; -const DEFAULT_INTERNAL_API_SECRET = 'genarrative-dev-internal-bridge'; -const BIG_FISH_UPSTREAM_TIMEOUT_MS = 15000; -const INTERNAL_USER_HEADER = 'x-genarrative-authenticated-user-id'; -const INTERNAL_SECRET_HEADER = 'x-genarrative-internal-api-secret'; - -function resolveRustApiTarget(context: AppContext) { - const configured = - context.config.rawEnv.GENARRATIVE_API_TARGET?.trim() || - context.config.rawEnv.RUST_API_SERVER_TARGET?.trim() || - ''; - return configured || DEFAULT_RUST_API_TARGET; -} - -function resolveInternalApiSecret(context: AppContext) { - return ( - context.config.rawEnv.GENARRATIVE_INTERNAL_API_SECRET?.trim() || - DEFAULT_INTERNAL_API_SECRET - ); -} - -function normalizeRouteSuffix(path: string) { - const normalized = path.startsWith('/') ? path : `/${path}`; - return normalized.replace(/\/+$/u, ''); -} - -function buildUpstreamUrl(context: AppContext, pathSuffix: string) { - const baseUrl = resolveRustApiTarget(context).replace(/\/+$/u, ''); - return `${baseUrl}${normalizeRouteSuffix(pathSuffix)}`; -} - -function pickForwardHeaders( - request: Request, - context: AppContext, - userId: string, -) { - const forwardedHeaders = new Headers(); - - const contentType = request.header('content-type')?.trim(); - if (contentType) { - forwardedHeaders.set('content-type', contentType); - } - - const accept = request.header('accept')?.trim(); - if (accept) { - forwardedHeaders.set('accept', accept); - } - - const requestId = request.requestId?.trim(); - if (requestId) { - forwardedHeaders.set('x-request-id', requestId); - } - - const envelope = request.header(API_RESPONSE_ENVELOPE_HEADER)?.trim(); - if (envelope) { - forwardedHeaders.set(API_RESPONSE_ENVELOPE_HEADER, envelope); - } - - forwardedHeaders.set(INTERNAL_USER_HEADER, userId); - const internalSecret = resolveInternalApiSecret(context); - if (internalSecret) { - forwardedHeaders.set(INTERNAL_SECRET_HEADER, internalSecret); - } - return forwardedHeaders; -} - -function readBodyAllowed(method: string) { - return !['GET', 'HEAD'].includes(method.toUpperCase()); -} - -async function proxyBigFishRequest(params: { - context: AppContext; - request: Request; - response: Response; - pathSuffix: string; - streamBody?: boolean; -}) { - const { context, request, response, pathSuffix, streamBody = false } = params; - const userId = request.userId?.trim(); - if (!userId) { - throw badRequest('缺少已认证用户上下文'); - } - - const upstreamUrl = buildUpstreamUrl(context, pathSuffix); - const method = request.method.toUpperCase(); - const body = - readBodyAllowed(method) && request.body !== undefined - ? JSON.stringify(request.body) - : undefined; - - let upstreamResponse: globalThis.Response; - const controller = new AbortController(); - const timeoutId = setTimeout(() => { - controller.abort(); - }, BIG_FISH_UPSTREAM_TIMEOUT_MS); - try { - upstreamResponse = await fetch(upstreamUrl, { - method, - // 这里显式转发“已通过 Node 校验的用户身份”,让 Big Fish 继续由 Rust 真相后端处理。 - headers: pickForwardHeaders(request, context, userId), - body, - signal: controller.signal, - }); - } catch (error) { - request.log?.error( - { - err: error, - user_id: userId, - upstream_url: upstreamUrl, - }, - 'big fish upstream request failed', - ); - throw upstreamError( - error instanceof Error && error.name === 'AbortError' - ? '大鱼吃小鱼后端响应超时' - : '大鱼吃小鱼后端暂时不可用', - ); - } finally { - clearTimeout(timeoutId); - } - - prepareApiResponse(request, response, { - statusCode: upstreamResponse.status, - headers: { - 'Content-Type': - upstreamResponse.headers.get('content-type') || - 'application/json; charset=utf-8', - 'Cache-Control': - upstreamResponse.headers.get('cache-control') || 'no-cache', - }, - routeMeta: { - routeVersion: BIG_FISH_ROUTE_VERSION, - }, - }); - - const upstreamRequestId = upstreamResponse.headers.get('x-request-id'); - if (upstreamRequestId) { - response.setHeader('x-upstream-request-id', upstreamRequestId); - } - - const upstreamRouteVersion = upstreamResponse.headers.get(ROUTE_VERSION_HEADER); - if (upstreamRouteVersion) { - response.setHeader('x-upstream-route-version', upstreamRouteVersion); - } - - const upstreamApiVersion = upstreamResponse.headers.get(API_VERSION_HEADER); - if (upstreamApiVersion) { - response.setHeader('x-upstream-api-version', upstreamApiVersion); - } - - const upstreamLatency = upstreamResponse.headers.get(RESPONSE_TIME_HEADER); - if (upstreamLatency) { - response.setHeader('x-upstream-response-time-ms', upstreamLatency); - } - - if (streamBody) { - if (!upstreamResponse.body) { - throw upstreamError('大鱼吃小鱼流式响应不可用'); - } - - response.flushHeaders?.(); - await Readable.fromWeb(upstreamResponse.body as never).pipe(response); - return; - } - - response.end(await upstreamResponse.text()); -} - -function readParam(value: string | string[] | undefined) { - return Array.isArray(value) ? value[0]?.trim() || '' : value?.trim() || ''; -} - -export function createBigFishProxyRoutes(context: AppContext) { - const router = Router(); - const requireAuth = requireJwtAuth(context.config, context.userRepository); - - router.use(requireAuth); - - router.post( - '/agent/sessions', - routeMeta({ operation: 'runtime.bigFish.createSession', routeVersion: BIG_FISH_ROUTE_VERSION }), - asyncHandler(async (request, response) => { - await proxyBigFishRequest({ - context, - request, - response, - pathSuffix: '/api/runtime/big-fish/agent/sessions', - }); - }), - ); - - router.get( - '/agent/sessions/:sessionId', - routeMeta({ operation: 'runtime.bigFish.getSession', routeVersion: BIG_FISH_ROUTE_VERSION }), - asyncHandler(async (request, response) => { - const sessionId = readParam(request.params.sessionId); - if (!sessionId) { - throw badRequest('sessionId is required'); - } - - await proxyBigFishRequest({ - context, - request, - response, - pathSuffix: `/api/runtime/big-fish/agent/sessions/${encodeURIComponent(sessionId)}`, - }); - }), - ); - - router.post( - '/agent/sessions/:sessionId/messages', - routeMeta({ operation: 'runtime.bigFish.sendMessage', routeVersion: BIG_FISH_ROUTE_VERSION }), - asyncHandler(async (request, response) => { - const sessionId = readParam(request.params.sessionId); - if (!sessionId) { - throw badRequest('sessionId is required'); - } - - await proxyBigFishRequest({ - context, - request, - response, - pathSuffix: `/api/runtime/big-fish/agent/sessions/${encodeURIComponent(sessionId)}/messages`, - }); - }), - ); - - router.post( - '/agent/sessions/:sessionId/messages/stream', - routeMeta({ - operation: 'runtime.bigFish.streamMessage', - routeVersion: BIG_FISH_ROUTE_VERSION, - }), - asyncHandler(async (request, response) => { - const sessionId = readParam(request.params.sessionId); - if (!sessionId) { - throw badRequest('sessionId is required'); - } - - await proxyBigFishRequest({ - context, - request, - response, - pathSuffix: `/api/runtime/big-fish/agent/sessions/${encodeURIComponent(sessionId)}/messages/stream`, - streamBody: true, - }); - }), - ); - - router.post( - '/agent/sessions/:sessionId/actions', - routeMeta({ operation: 'runtime.bigFish.executeAction', routeVersion: BIG_FISH_ROUTE_VERSION }), - asyncHandler(async (request, response) => { - const sessionId = readParam(request.params.sessionId); - if (!sessionId) { - throw badRequest('sessionId is required'); - } - - await proxyBigFishRequest({ - context, - request, - response, - pathSuffix: `/api/runtime/big-fish/agent/sessions/${encodeURIComponent(sessionId)}/actions`, - }); - }), - ); - - router.post( - '/sessions/:sessionId/runs', - routeMeta({ operation: 'runtime.bigFish.startRun', routeVersion: BIG_FISH_ROUTE_VERSION }), - asyncHandler(async (request, response) => { - const sessionId = readParam(request.params.sessionId); - if (!sessionId) { - throw badRequest('sessionId is required'); - } - - await proxyBigFishRequest({ - context, - request, - response, - pathSuffix: `/api/runtime/big-fish/sessions/${encodeURIComponent(sessionId)}/runs`, - }); - }), - ); - - router.get( - '/runs/:runId', - routeMeta({ operation: 'runtime.bigFish.getRun', routeVersion: BIG_FISH_ROUTE_VERSION }), - asyncHandler(async (request, response) => { - const runId = readParam(request.params.runId); - if (!runId) { - throw badRequest('runId is required'); - } - - await proxyBigFishRequest({ - context, - request, - response, - pathSuffix: `/api/runtime/big-fish/runs/${encodeURIComponent(runId)}`, - }); - }), - ); - - router.post( - '/runs/:runId/input', - routeMeta({ operation: 'runtime.bigFish.submitInput', routeVersion: BIG_FISH_ROUTE_VERSION }), - asyncHandler(async (request, response) => { - const runId = readParam(request.params.runId); - if (!runId) { - throw badRequest('runId is required'); - } - - await proxyBigFishRequest({ - context, - request, - response, - pathSuffix: `/api/runtime/big-fish/runs/${encodeURIComponent(runId)}/input`, - }); - }), - ); - - return router; -} diff --git a/server-node/src/routes/customWorldAgent.ts b/server-node/src/routes/customWorldAgent.ts deleted file mode 100644 index b862452c..00000000 --- a/server-node/src/routes/customWorldAgent.ts +++ /dev/null @@ -1,272 +0,0 @@ -import { Router } from 'express'; -import { z } from 'zod'; - -import type { - CreateCustomWorldAgentSessionRequest, - CustomWorldAgentActionRequest, - SendCustomWorldAgentMessageRequest, -} from '../../../packages/shared/src/contracts/customWorldAgent.js'; -import type { AppContext } from '../context.js'; -import { badRequest, notFound } from '../errors.js'; -import { asyncHandler, prepareApiResponse, sendApiResponse } from '../http.js'; -import { requireJwtAuth } from '../middleware/auth.js'; -import { routeMeta } from '../middleware/routeMeta.js'; - -const createSessionSchema = z.object({ - seedText: z.string().trim().optional().default(''), -}); - -const sendMessageSchema = z.object({ - clientMessageId: z.string().trim().min(1), - text: z.string().trim().min(1), - quickFillRequested: z.boolean().optional().default(false), - focusCardId: z.string().trim().nullable().optional().default(null), - selectedCardIds: z.array(z.string().trim().min(1)).optional().default([]), -}); - -const actionSchema = z.discriminatedUnion('action', [ - z.object({ - action: z.literal('draft_foundation'), - }), - z.object({ - action: z.literal('update_draft_card'), - cardId: z.string().trim().min(1), - sections: z - .array( - z.object({ - sectionId: z.string().trim().min(1), - value: z.string(), - }), - ) - .min(1), - }), - z.object({ - action: z.literal('sync_result_profile'), - profile: z.record(z.string(), z.unknown()), - }), - z.object({ - action: z.literal('generate_characters'), - count: z.number().int().min(1).max(3), - promptText: z.string().trim().nullable().optional().default(null), - anchorCardIds: z.array(z.string().trim().min(1)).optional().default([]), - }), - z.object({ - action: z.literal('generate_landmarks'), - count: z.number().int().min(1).max(3), - promptText: z.string().trim().nullable().optional().default(null), - anchorCardIds: z.array(z.string().trim().min(1)).optional().default([]), - }), - z.object({ - action: z.literal('generate_role_assets'), - roleIds: z.array(z.string().trim().min(1)).min(1), - }), - z.object({ - action: z.literal('sync_role_assets'), - roleId: z.string().trim().min(1), - portraitPath: z.string().trim().min(1), - generatedVisualAssetId: z.string().trim().min(1), - generatedAnimationSetId: z.string().trim().nullable().optional(), - animationMap: z.record(z.string(), z.unknown()).nullable().optional(), - }), - z.object({ - action: z.literal('generate_scene_assets'), - sceneIds: z.array(z.string().trim().min(1)).min(1), - }), - z.object({ - action: z.literal('sync_scene_assets'), - sceneId: z.string().trim().min(1), - sceneKind: z.enum(['camp', 'landmark']), - imageSrc: z.string().trim().min(1), - generatedSceneAssetId: z.string().trim().min(1), - generatedScenePrompt: z.string().trim().nullable().optional(), - generatedSceneModel: z.string().trim().nullable().optional(), - }), - z.object({ - action: z.literal('expand_long_tail'), - }), - z.object({ - action: z.literal('publish_world'), - }), - z.object({ - action: z.literal('revert_checkpoint'), - checkpointId: z.string().trim().min(1), - }), -]); - -function readParam(param: string | string[] | undefined) { - return Array.isArray(param) ? param[0]?.trim() || '' : param?.trim() || ''; -} - -export function createCustomWorldAgentRoutes(context: AppContext) { - const router = Router(); - const requireAuth = requireJwtAuth(context.config, context.userRepository); - - router.use(requireAuth); - - router.post( - '/sessions', - routeMeta({ operation: 'runtime.customWorldAgent.createSession' }), - asyncHandler(async (request, response) => { - const payload = createSessionSchema.parse( - request.body, - ) as CreateCustomWorldAgentSessionRequest; - sendApiResponse(response, { - session: await context.customWorldAgentOrchestrator.createSession( - request.userId!, - payload, - ), - }); - }), - ); - - router.get( - '/sessions/:sessionId', - routeMeta({ operation: 'runtime.customWorldAgent.getSession' }), - asyncHandler(async (request, response) => { - const sessionId = readParam(request.params.sessionId); - if (!sessionId) { - throw badRequest('sessionId is required'); - } - - const session = await context.customWorldAgentOrchestrator.getSessionSnapshot( - request.userId!, - sessionId, - ); - if (!session) { - throw notFound('custom world agent session not found'); - } - - sendApiResponse(response, session); - }), - ); - - router.post( - '/sessions/:sessionId/messages', - routeMeta({ operation: 'runtime.customWorldAgent.sendMessage' }), - asyncHandler(async (request, response) => { - const sessionId = readParam(request.params.sessionId); - if (!sessionId) { - throw badRequest('sessionId is required'); - } - - const payload = sendMessageSchema.parse( - request.body, - ) as SendCustomWorldAgentMessageRequest; - sendApiResponse( - response, - await context.customWorldAgentOrchestrator.submitMessage( - request.userId!, - sessionId, - payload, - ), - ); - }), - ); - - router.post( - '/sessions/:sessionId/messages/stream', - routeMeta({ operation: 'runtime.customWorldAgent.streamMessage' }), - asyncHandler(async (request, response) => { - const sessionId = readParam(request.params.sessionId); - if (!sessionId) { - throw badRequest('sessionId is required'); - } - - const payload = sendMessageSchema.parse( - request.body, - ) as SendCustomWorldAgentMessageRequest; - await context.customWorldAgentOrchestrator.streamMessage({ - request, - response, - userId: request.userId!, - sessionId, - payload, - }); - }), - ); - - router.post( - '/sessions/:sessionId/actions', - routeMeta({ operation: 'runtime.customWorldAgent.executeAction' }), - asyncHandler(async (request, response) => { - const sessionId = readParam(request.params.sessionId); - if (!sessionId) { - throw badRequest('sessionId is required'); - } - - const payload = actionSchema.parse( - request.body, - ) as CustomWorldAgentActionRequest; - sendApiResponse( - response, - await context.customWorldAgentOrchestrator.executeAction( - request.userId!, - sessionId, - payload, - ), - ); - }), - ); - - router.get( - '/sessions/:sessionId/operations/:operationId', - routeMeta({ operation: 'runtime.customWorldAgent.getOperation' }), - asyncHandler(async (request, response) => { - const sessionId = readParam(request.params.sessionId); - const operationId = readParam(request.params.operationId); - if (!sessionId) { - throw badRequest('sessionId is required'); - } - if (!operationId) { - throw badRequest('operationId is required'); - } - - const operation = await context.customWorldAgentOrchestrator.getOperation( - request.userId!, - sessionId, - operationId, - ); - if (!operation) { - throw notFound('custom world agent operation not found'); - } - - prepareApiResponse(request, response, { - statusCode: 200, - headers: { - 'Content-Type': 'application/json; charset=utf-8', - }, - }); - response.end(JSON.stringify({ operation })); - }), - ); - - router.get( - '/sessions/:sessionId/cards/:cardId', - routeMeta({ operation: 'runtime.customWorldAgent.getCardDetail' }), - asyncHandler(async (request, response) => { - const sessionId = readParam(request.params.sessionId); - const cardId = readParam(request.params.cardId); - if (!sessionId) { - throw badRequest('sessionId is required'); - } - if (!cardId) { - throw badRequest('cardId is required'); - } - - const card = await context.customWorldAgentOrchestrator.getCardDetail( - request.userId!, - sessionId, - cardId, - ); - if (!card) { - throw notFound('custom world agent card not found'); - } - - sendApiResponse(response, { - card, - }); - }), - ); - - return router; -} diff --git a/server-node/src/routes/puzzleProxyRoutes.ts b/server-node/src/routes/puzzleProxyRoutes.ts deleted file mode 100644 index 2574028e..00000000 --- a/server-node/src/routes/puzzleProxyRoutes.ts +++ /dev/null @@ -1,451 +0,0 @@ -import { Readable } from 'node:stream'; - -import { type Request, type Response, Router } from 'express'; - -import type { AppContext } from '../context.js'; -import { badRequest, upstreamError } from '../errors.js'; -import { - API_RESPONSE_ENVELOPE_HEADER, - API_VERSION_HEADER, - asyncHandler, - prepareApiResponse, - RESPONSE_TIME_HEADER, - ROUTE_VERSION_HEADER, -} from '../http.js'; -import { requireJwtAuth } from '../middleware/auth.js'; -import { routeMeta } from '../middleware/routeMeta.js'; - -const PUZZLE_ROUTE_VERSION = '2026-04-22'; -const DEFAULT_RUST_API_TARGET = 'http://127.0.0.1:3100'; -const DEFAULT_INTERNAL_API_SECRET = 'genarrative-dev-internal-bridge'; -const PUZZLE_UPSTREAM_TIMEOUT_MS = 15000; -const INTERNAL_USER_HEADER = 'x-genarrative-authenticated-user-id'; -const INTERNAL_SECRET_HEADER = 'x-genarrative-internal-api-secret'; - -function resolveRustApiTarget(context: AppContext) { - const configured = - context.config.rawEnv.GENARRATIVE_API_TARGET?.trim() || - context.config.rawEnv.RUST_API_SERVER_TARGET?.trim() || - ''; - return configured || DEFAULT_RUST_API_TARGET; -} - -function resolveInternalApiSecret(context: AppContext) { - return ( - context.config.rawEnv.GENARRATIVE_INTERNAL_API_SECRET?.trim() || - DEFAULT_INTERNAL_API_SECRET - ); -} - -function normalizeRouteSuffix(path: string) { - const normalized = path.startsWith('/') ? path : `/${path}`; - return normalized.replace(/\/+$/u, ''); -} - -function buildUpstreamUrl(context: AppContext, pathSuffix: string) { - const baseUrl = resolveRustApiTarget(context).replace(/\/+$/u, ''); - return `${baseUrl}${normalizeRouteSuffix(pathSuffix)}`; -} - -function pickForwardHeaders( - request: Request, - context: AppContext, - userId: string, -) { - const forwardedHeaders = new Headers(); - - const contentType = request.header('content-type')?.trim(); - if (contentType) { - forwardedHeaders.set('content-type', contentType); - } - - const accept = request.header('accept')?.trim(); - if (accept) { - forwardedHeaders.set('accept', accept); - } - - const requestId = request.requestId?.trim(); - if (requestId) { - forwardedHeaders.set('x-request-id', requestId); - } - - const envelope = request.header(API_RESPONSE_ENVELOPE_HEADER)?.trim(); - if (envelope) { - forwardedHeaders.set(API_RESPONSE_ENVELOPE_HEADER, envelope); - } - - forwardedHeaders.set(INTERNAL_USER_HEADER, userId); - const internalSecret = resolveInternalApiSecret(context); - if (internalSecret) { - forwardedHeaders.set(INTERNAL_SECRET_HEADER, internalSecret); - } - return forwardedHeaders; -} - -function readBodyAllowed(method: string) { - return !['GET', 'HEAD'].includes(method.toUpperCase()); -} - -async function proxyPuzzleRequest(params: { - context: AppContext; - request: Request; - response: Response; - pathSuffix: string; - streamBody?: boolean; -}) { - const { context, request, response, pathSuffix, streamBody = false } = params; - const userId = request.userId?.trim(); - if (!userId) { - throw badRequest('缺少已认证用户上下文'); - } - - const upstreamUrl = buildUpstreamUrl(context, pathSuffix); - const method = request.method.toUpperCase(); - const body = - readBodyAllowed(method) && request.body !== undefined - ? JSON.stringify(request.body) - : undefined; - - let upstreamResponse: globalThis.Response; - const controller = new AbortController(); - const timeoutId = setTimeout(() => { - controller.abort(); - }, PUZZLE_UPSTREAM_TIMEOUT_MS); - try { - upstreamResponse = await fetch(upstreamUrl, { - method, - headers: pickForwardHeaders(request, context, userId), - body, - signal: controller.signal, - }); - } catch (error) { - request.log?.error( - { - err: error, - user_id: userId, - upstream_url: upstreamUrl, - }, - 'puzzle upstream request failed', - ); - throw upstreamError( - error instanceof Error && error.name === 'AbortError' - ? '拼图后端响应超时' - : '拼图后端暂时不可用', - ); - } finally { - clearTimeout(timeoutId); - } - - prepareApiResponse(request, response, { - statusCode: upstreamResponse.status, - headers: { - 'Content-Type': - upstreamResponse.headers.get('content-type') || - 'application/json; charset=utf-8', - 'Cache-Control': - upstreamResponse.headers.get('cache-control') || 'no-cache', - }, - routeMeta: { - routeVersion: PUZZLE_ROUTE_VERSION, - }, - }); - - const upstreamRequestId = upstreamResponse.headers.get('x-request-id'); - if (upstreamRequestId) { - response.setHeader('x-upstream-request-id', upstreamRequestId); - } - - const upstreamRouteVersion = upstreamResponse.headers.get(ROUTE_VERSION_HEADER); - if (upstreamRouteVersion) { - response.setHeader('x-upstream-route-version', upstreamRouteVersion); - } - - const upstreamApiVersion = upstreamResponse.headers.get(API_VERSION_HEADER); - if (upstreamApiVersion) { - response.setHeader('x-upstream-api-version', upstreamApiVersion); - } - - const upstreamLatency = upstreamResponse.headers.get(RESPONSE_TIME_HEADER); - if (upstreamLatency) { - response.setHeader('x-upstream-response-time-ms', upstreamLatency); - } - - if (streamBody) { - if (!upstreamResponse.body) { - throw upstreamError('拼图流式响应不可用'); - } - - response.flushHeaders?.(); - await Readable.fromWeb(upstreamResponse.body as never).pipe(response); - return; - } - - response.end(await upstreamResponse.text()); -} - -function readParam(value: string | string[] | undefined) { - return Array.isArray(value) ? value[0]?.trim() || '' : value?.trim() || ''; -} - -export function createPuzzleProxyRoutes(context: AppContext) { - const router = Router(); - const requireAuth = requireJwtAuth(context.config, context.userRepository); - - router.use(requireAuth); - - router.post( - '/agent/sessions', - routeMeta({ operation: 'runtime.puzzle.createSession', routeVersion: PUZZLE_ROUTE_VERSION }), - asyncHandler(async (request, response) => { - await proxyPuzzleRequest({ - context, - request, - response, - pathSuffix: '/api/runtime/puzzle/agent/sessions', - }); - }), - ); - - router.get( - '/agent/sessions/:sessionId', - routeMeta({ operation: 'runtime.puzzle.getSession', routeVersion: PUZZLE_ROUTE_VERSION }), - asyncHandler(async (request, response) => { - const sessionId = readParam(request.params.sessionId); - if (!sessionId) { - throw badRequest('sessionId is required'); - } - - await proxyPuzzleRequest({ - context, - request, - response, - pathSuffix: `/api/runtime/puzzle/agent/sessions/${encodeURIComponent(sessionId)}`, - }); - }), - ); - - router.post( - '/agent/sessions/:sessionId/messages', - routeMeta({ operation: 'runtime.puzzle.sendMessage', routeVersion: PUZZLE_ROUTE_VERSION }), - asyncHandler(async (request, response) => { - const sessionId = readParam(request.params.sessionId); - if (!sessionId) { - throw badRequest('sessionId is required'); - } - - await proxyPuzzleRequest({ - context, - request, - response, - pathSuffix: `/api/runtime/puzzle/agent/sessions/${encodeURIComponent(sessionId)}/messages`, - }); - }), - ); - - router.post( - '/agent/sessions/:sessionId/messages/stream', - routeMeta({ - operation: 'runtime.puzzle.streamMessage', - routeVersion: PUZZLE_ROUTE_VERSION, - }), - asyncHandler(async (request, response) => { - const sessionId = readParam(request.params.sessionId); - if (!sessionId) { - throw badRequest('sessionId is required'); - } - - await proxyPuzzleRequest({ - context, - request, - response, - pathSuffix: `/api/runtime/puzzle/agent/sessions/${encodeURIComponent(sessionId)}/messages/stream`, - streamBody: true, - }); - }), - ); - - router.post( - '/agent/sessions/:sessionId/actions', - routeMeta({ operation: 'runtime.puzzle.executeAction', routeVersion: PUZZLE_ROUTE_VERSION }), - asyncHandler(async (request, response) => { - const sessionId = readParam(request.params.sessionId); - if (!sessionId) { - throw badRequest('sessionId is required'); - } - - await proxyPuzzleRequest({ - context, - request, - response, - pathSuffix: `/api/runtime/puzzle/agent/sessions/${encodeURIComponent(sessionId)}/actions`, - }); - }), - ); - - router.get( - '/works', - routeMeta({ operation: 'runtime.puzzle.listWorks', routeVersion: PUZZLE_ROUTE_VERSION }), - asyncHandler(async (request, response) => { - await proxyPuzzleRequest({ - context, - request, - response, - pathSuffix: '/api/runtime/puzzle/works', - }); - }), - ); - - router.get( - '/works/:profileId', - routeMeta({ operation: 'runtime.puzzle.getWorkDetail', routeVersion: PUZZLE_ROUTE_VERSION }), - asyncHandler(async (request, response) => { - const profileId = readParam(request.params.profileId); - if (!profileId) { - throw badRequest('profileId is required'); - } - - await proxyPuzzleRequest({ - context, - request, - response, - pathSuffix: `/api/runtime/puzzle/works/${encodeURIComponent(profileId)}`, - }); - }), - ); - - router.put( - '/works/:profileId', - routeMeta({ operation: 'runtime.puzzle.updateWork', routeVersion: PUZZLE_ROUTE_VERSION }), - asyncHandler(async (request, response) => { - const profileId = readParam(request.params.profileId); - if (!profileId) { - throw badRequest('profileId is required'); - } - - await proxyPuzzleRequest({ - context, - request, - response, - pathSuffix: `/api/runtime/puzzle/works/${encodeURIComponent(profileId)}`, - }); - }), - ); - - router.get( - '/gallery', - routeMeta({ operation: 'runtime.puzzle.listGallery', routeVersion: PUZZLE_ROUTE_VERSION }), - asyncHandler(async (request, response) => { - await proxyPuzzleRequest({ - context, - request, - response, - pathSuffix: '/api/runtime/puzzle/gallery', - }); - }), - ); - - router.get( - '/gallery/:profileId', - routeMeta({ operation: 'runtime.puzzle.getGalleryDetail', routeVersion: PUZZLE_ROUTE_VERSION }), - asyncHandler(async (request, response) => { - const profileId = readParam(request.params.profileId); - if (!profileId) { - throw badRequest('profileId is required'); - } - - await proxyPuzzleRequest({ - context, - request, - response, - pathSuffix: `/api/runtime/puzzle/gallery/${encodeURIComponent(profileId)}`, - }); - }), - ); - - router.post( - '/runs', - routeMeta({ operation: 'runtime.puzzle.startRun', routeVersion: PUZZLE_ROUTE_VERSION }), - asyncHandler(async (request, response) => { - await proxyPuzzleRequest({ - context, - request, - response, - pathSuffix: '/api/runtime/puzzle/runs', - }); - }), - ); - - router.get( - '/runs/:runId', - routeMeta({ operation: 'runtime.puzzle.getRun', routeVersion: PUZZLE_ROUTE_VERSION }), - asyncHandler(async (request, response) => { - const runId = readParam(request.params.runId); - if (!runId) { - throw badRequest('runId is required'); - } - - await proxyPuzzleRequest({ - context, - request, - response, - pathSuffix: `/api/runtime/puzzle/runs/${encodeURIComponent(runId)}`, - }); - }), - ); - - router.post( - '/runs/:runId/swap', - routeMeta({ operation: 'runtime.puzzle.swapPieces', routeVersion: PUZZLE_ROUTE_VERSION }), - asyncHandler(async (request, response) => { - const runId = readParam(request.params.runId); - if (!runId) { - throw badRequest('runId is required'); - } - - await proxyPuzzleRequest({ - context, - request, - response, - pathSuffix: `/api/runtime/puzzle/runs/${encodeURIComponent(runId)}/swap`, - }); - }), - ); - - router.post( - '/runs/:runId/drag', - routeMeta({ operation: 'runtime.puzzle.dragPiece', routeVersion: PUZZLE_ROUTE_VERSION }), - asyncHandler(async (request, response) => { - const runId = readParam(request.params.runId); - if (!runId) { - throw badRequest('runId is required'); - } - - await proxyPuzzleRequest({ - context, - request, - response, - pathSuffix: `/api/runtime/puzzle/runs/${encodeURIComponent(runId)}/drag`, - }); - }), - ); - - router.post( - '/runs/:runId/next-level', - routeMeta({ operation: 'runtime.puzzle.nextLevel', routeVersion: PUZZLE_ROUTE_VERSION }), - asyncHandler(async (request, response) => { - const runId = readParam(request.params.runId); - if (!runId) { - throw badRequest('runId is required'); - } - - await proxyPuzzleRequest({ - context, - request, - response, - pathSuffix: `/api/runtime/puzzle/runs/${encodeURIComponent(runId)}/next-level`, - }); - }), - ); - - return router; -} diff --git a/server-node/src/routes/rpg-entry/rpgEntrySaveRoutes.ts b/server-node/src/routes/rpg-entry/rpgEntrySaveRoutes.ts deleted file mode 100644 index 52e88c76..00000000 --- a/server-node/src/routes/rpg-entry/rpgEntrySaveRoutes.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { Router } from 'express'; -import { z } from 'zod'; - -import type { - ProfileSaveArchiveResumeResponse, - SavedGameSnapshotInput, -} from '../../../../packages/shared/src/contracts/runtime.js'; -import type { AppContext } from '../../context.js'; -import { badRequest, notFound } from '../../errors.js'; -import { asyncHandler, sendApiResponse } from '../../http.js'; -import { requireJwtAuth } from '../../middleware/auth.js'; -import { routeMeta } from '../../middleware/routeMeta.js'; -import { - hydrateSavedSnapshot, - normalizeSavedSnapshotPayload, -} from '../../modules/runtime/runtimeSnapshotHydration.js'; - -const saveSnapshotSchema = z.object({ - gameState: z.unknown(), - bottomTab: z.string().trim().min(1), - currentStory: z.unknown().nullable().optional().default(null), - savedAt: z.string().trim().optional().default(''), -}); - -export const RPG_ENTRY_SAVE_ROUTE_BASE_PATH = '/api/runtime/save'; -export const RPG_ENTRY_SAVE_ARCHIVE_ROUTE_BASE_PATH = - '/api/runtime/profile/save-archives'; -export const RPG_ENTRY_SAVE_ARCHIVE_LEGACY_ROUTE_BASE_PATH = - '/api/profile/save-archives'; - -function readParam(param: string | string[] | undefined) { - return Array.isArray(param) ? param[0]?.trim() || '' : param?.trim() || ''; -} - -function routeCompatPaths(path: string) { - return [path, path.replace('runtime/', '')] as const; -} - -export function createRpgEntrySaveRoutes(context: AppContext) { - const router = Router(); - const requireAuth = requireJwtAuth(context.config, context.userRepository); - - router.get( - '/runtime/save/snapshot', - requireAuth, - routeMeta({ operation: 'runtime.snapshot.get' }), - asyncHandler(async (request, response) => { - sendApiResponse( - response, - hydrateSavedSnapshot( - await context.rpgRuntimeSnapshotRepository.getSnapshot(request.userId!), - ), - ); - }), - ); - - router.put( - '/runtime/save/snapshot', - requireAuth, - routeMeta({ operation: 'runtime.snapshot.put' }), - asyncHandler(async (request, response) => { - const payload = saveSnapshotSchema.parse( - request.body, - ) as SavedGameSnapshotInput; - const normalizedSnapshot = normalizeSavedSnapshotPayload({ - savedAt: payload.savedAt || new Date().toISOString(), - gameState: payload.gameState, - bottomTab: payload.bottomTab, - currentStory: payload.currentStory ?? null, - }); - sendApiResponse( - response, - hydrateSavedSnapshot( - await context.rpgRuntimeSnapshotRepository.putSnapshot( - request.userId!, - normalizedSnapshot, - ), - ), - ); - }), - ); - - router.delete( - '/runtime/save/snapshot', - requireAuth, - routeMeta({ operation: 'runtime.snapshot.delete' }), - asyncHandler(async (request, response) => { - await context.rpgRuntimeSnapshotRepository.deleteSnapshot(request.userId!); - sendApiResponse(response, { ok: true }); - }), - ); - - [ - '/runtime/profile/save-archives/:worldKey', - '/profile/save-archives/:worldKey', - ].forEach((path, index) => { - router.post( - path, - requireAuth, - routeMeta({ - operation: - index === 0 - ? 'profile.saveArchives.resume' - : 'profile.saveArchives.resume.compat', - }), - asyncHandler(async (request, response) => { - const worldKey = readParam(request.params.worldKey); - if (!worldKey) { - throw badRequest('worldKey 不能为空'); - } - - const resumedArchive = - await context.rpgSaveArchiveRepository.resumeProfileSaveArchive( - request.userId!, - worldKey, - ); - - if (!resumedArchive) { - throw notFound('指定存档不存在'); - } - - sendApiResponse(response, { - entry: resumedArchive.entry, - snapshot: hydrateSavedSnapshot(resumedArchive.snapshot)!, - }); - }), - ); - }); - - routeCompatPaths('/api/runtime/profile/save-archives').forEach((path, index) => { - router.get( - path.replace('/api/', '/'), - requireAuth, - routeMeta({ - operation: - index === 0 - ? 'profile.saveArchives.list' - : 'profile.saveArchives.list.compat', - }), - asyncHandler(async (request, response) => { - sendApiResponse(response, { - entries: await context.rpgSaveArchiveRepository.listProfileSaveArchives( - request.userId!, - ), - }); - }), - ); - }); - - return router; -} diff --git a/server-node/src/routes/rpg-entry/rpgWorldLibraryRoutes.ts b/server-node/src/routes/rpg-entry/rpgWorldLibraryRoutes.ts deleted file mode 100644 index 5be8d8f0..00000000 --- a/server-node/src/routes/rpg-entry/rpgWorldLibraryRoutes.ts +++ /dev/null @@ -1,338 +0,0 @@ -import { Router } from 'express'; -import { z } from 'zod'; - -import type { ListCustomWorldWorksResponse } from '../../../../packages/shared/src/contracts/customWorldAgent.js'; -import type { - CustomWorldGalleryDetailResponse, - CustomWorldGalleryResponse, - CustomWorldLibraryMutationResponse, - CustomWorldLibraryResponse, -} from '../../../../packages/shared/src/contracts/runtime.js'; -import type { AppContext } from '../../context.js'; -import { badRequest, conflict, notFound } from '../../errors.js'; -import { asyncHandler, jsonClone, sendApiResponse } from '../../http.js'; -import { requireJwtAuth } from '../../middleware/auth.js'; -import { routeMeta } from '../../middleware/routeMeta.js'; -import { CustomWorldAgentPublishingService } from '../../services/customWorldAgentPublishingService.js'; - -const jsonObjectSchema = z.record(z.string(), z.unknown()); - -const customWorldProfileSchema = z.object({ - profile: jsonObjectSchema, -}); - -export const RPG_WORLD_LIBRARY_ROUTE_BASE_PATH = - '/api/runtime/custom-world-library'; -export const RPG_WORLD_GALLERY_ROUTE_BASE_PATH = - '/api/runtime/custom-world-gallery'; -export const RPG_WORLD_WORKS_ROUTE_BASE_PATH = - '/api/runtime/custom-world/works'; -const AGENT_DRAFT_PROFILE_ID_PREFIX = 'agent-draft-'; - -function readParam(param: string | string[] | undefined) { - return Array.isArray(param) ? param[0]?.trim() || '' : param?.trim() || ''; -} - -function toText(value: unknown) { - return typeof value === 'string' ? value.trim() : ''; -} - -function resolveAgentSessionIdFromProfileId(profileId: string) { - if (!profileId.startsWith(AGENT_DRAFT_PROFILE_ID_PREFIX)) { - return null; - } - - const sessionId = profileId.slice(AGENT_DRAFT_PROFILE_ID_PREFIX.length).trim(); - return sessionId || null; -} - -function resolvePublishedWorldName(profile: unknown) { - const profileRecord = - profile && typeof profile === 'object' && !Array.isArray(profile) - ? (profile as Record) - : null; - - return toText(profileRecord?.name) || '当前世界'; -} - -async function syncAgentSessionPublishedState(params: { - context: AppContext; - userId: string; - sessionId: string; - worldName: string; - qualityFindings: Array<{ - id: string; - severity: 'info' | 'warning' | 'blocker'; - code: string; - targetId?: string | null; - message: string; - }>; -}) { - const publishedQualityFindings = params.qualityFindings.filter( - (entry) => entry.severity !== 'blocker', - ); - const publishedState = { - stage: 'published' as const, - qualityFindings: publishedQualityFindings, - }; - - await params.context.customWorldAgentSessions.replaceDerivedState( - params.userId, - params.sessionId, - publishedState, - ); - await params.context.customWorldAgentSessions.appendCheckpoint( - params.userId, - params.sessionId, - { - label: `发布世界 ${params.worldName}`, - snapshot: publishedState, - }, - ); - await params.context.customWorldAgentSessions.appendMessage( - params.userId, - params.sessionId, - { - id: `message-${Date.now().toString(36)}-library-publish`, - role: 'assistant', - kind: 'action_result', - text: - publishedQualityFindings.length > 0 - ? `世界「${params.worldName}」已发布,并保留 ${publishedQualityFindings.length} 条 warning 供后续继续优化。` - : `世界「${params.worldName}」已正式发布,可以进入作品库与世界入口。`, - createdAt: new Date().toISOString(), - relatedOperationId: null, - }, - ); -} - -async function resolveAuthDisplayName(context: AppContext, userId: string) { - const user = await context.userRepository.findById(userId); - if (!user) { - throw notFound('user not found'); - } - - return user.displayName?.trim() || '玩家'; -} - -export function createRpgWorldLibraryRoutes(context: AppContext) { - const router = Router(); - const requireAuth = requireJwtAuth(context.config, context.userRepository); - const publishingService = new CustomWorldAgentPublishingService( - context.rpgWorldProfileRepository, - ); - - router.get( - '/runtime/custom-world-gallery', - routeMeta({ operation: 'runtime.customWorldGallery.list' }), - asyncHandler(async (_request, response) => { - sendApiResponse(response, { - entries: - await context.rpgWorldLibraryRepository.listPublishedCustomWorldGallery(), - } satisfies CustomWorldGalleryResponse); - }), - ); - - router.get( - '/runtime/custom-world-gallery/:ownerUserId/:profileId', - routeMeta({ operation: 'runtime.customWorldGallery.detail' }), - asyncHandler(async (request, response) => { - const ownerUserId = readParam(request.params.ownerUserId); - const profileId = readParam(request.params.profileId); - if (!ownerUserId || !profileId) { - throw badRequest('ownerUserId and profileId are required'); - } - - const entry = - await context.rpgWorldLibraryRepository.getPublishedCustomWorldGalleryDetail( - ownerUserId, - profileId, - ); - if (!entry) { - throw notFound('public custom world not found'); - } - - sendApiResponse(response, { - entry, - } satisfies CustomWorldGalleryDetailResponse); - }), - ); - - router.get( - '/runtime/custom-world/works', - requireAuth, - routeMeta({ operation: 'runtime.customWorldWorks.list' }), - asyncHandler(async (request, response) => { - sendApiResponse(response, { - items: await context.rpgWorldWorkSummaryService.list(request.userId!), - }); - }), - ); - - router.get( - '/runtime/custom-world-library', - requireAuth, - routeMeta({ operation: 'runtime.customWorldLibrary.list' }), - asyncHandler(async (request, response) => { - sendApiResponse(response, { - entries: await context.rpgWorldLibraryRepository.listCustomWorldProfiles( - request.userId!, - ), - } satisfies CustomWorldLibraryResponse); - }), - ); - - router.put( - '/runtime/custom-world-library/:profileId', - requireAuth, - routeMeta({ operation: 'runtime.customWorldLibrary.upsert' }), - asyncHandler(async (request, response) => { - const profileId = readParam(request.params.profileId); - if (!profileId) { - throw badRequest('profileId is required'); - } - - const payload = customWorldProfileSchema.parse(request.body); - const authorDisplayName = await resolveAuthDisplayName( - context, - request.userId!, - ); - sendApiResponse( - response, - await context.rpgWorldLibraryRepository.upsertCustomWorldProfile( - request.userId!, - profileId, - jsonClone(payload.profile), - authorDisplayName, - ), - ); - }), - ); - - router.delete( - '/runtime/custom-world-library/:profileId', - requireAuth, - routeMeta({ operation: 'runtime.customWorldLibrary.delete' }), - asyncHandler(async (request, response) => { - const profileId = readParam(request.params.profileId); - if (!profileId) { - throw badRequest('profileId is required'); - } - - sendApiResponse(response, { - entries: await context.rpgWorldLibraryRepository.deleteCustomWorldProfile( - request.userId!, - profileId, - ), - } satisfies CustomWorldLibraryResponse); - }), - ); - - router.post( - '/runtime/custom-world-library/:profileId/publish', - requireAuth, - routeMeta({ operation: 'runtime.customWorldLibrary.publish' }), - asyncHandler(async (request, response) => { - const profileId = readParam(request.params.profileId); - if (!profileId) { - throw badRequest('profileId is required'); - } - - const authorDisplayName = await resolveAuthDisplayName( - context, - request.userId!, - ); - const agentSessionId = resolveAgentSessionIdFromProfileId(profileId); - if (agentSessionId) { - const agentSession = await context.customWorldAgentSessions.get( - request.userId!, - agentSessionId, - ); - - if (agentSession) { - try { - publishingService.buildPublishReadiness({ - sessionId: agentSessionId, - draftProfile: agentSession.draftProfile, - qualityFindings: agentSession.qualityFindings, - }); - } catch (error) { - throw conflict( - error instanceof Error - ? error.message - : '当前世界还没有通过发布校验。', - ); - } - - const publishResult = await publishingService.publishSessionDraft({ - userId: request.userId!, - authorDisplayName, - sessionId: agentSessionId, - draftProfile: - (agentSession.draftProfile ?? {}) as Record, - qualityFindings: agentSession.qualityFindings, - }); - await syncAgentSessionPublishedState({ - context, - userId: request.userId!, - sessionId: agentSessionId, - worldName: resolvePublishedWorldName(publishResult.publishedProfile), - qualityFindings: agentSession.qualityFindings, - }); - sendApiResponse( - response, - publishResult.mutation satisfies CustomWorldLibraryMutationResponse, - ); - return; - } - } - - const mutation = await context.rpgWorldLibraryRepository.publishCustomWorldProfile( - request.userId!, - profileId, - authorDisplayName, - ); - if (!mutation) { - throw notFound('custom world not found'); - } - - sendApiResponse( - response, - mutation satisfies CustomWorldLibraryMutationResponse, - ); - }), - ); - - router.post( - '/runtime/custom-world-library/:profileId/unpublish', - requireAuth, - routeMeta({ operation: 'runtime.customWorldLibrary.unpublish' }), - asyncHandler(async (request, response) => { - const profileId = readParam(request.params.profileId); - if (!profileId) { - throw badRequest('profileId is required'); - } - - const authorDisplayName = await resolveAuthDisplayName( - context, - request.userId!, - ); - const mutation = - await context.rpgWorldLibraryRepository.unpublishCustomWorldProfile( - request.userId!, - profileId, - authorDisplayName, - ); - if (!mutation) { - throw notFound('custom world not found'); - } - - sendApiResponse( - response, - mutation satisfies CustomWorldLibraryMutationResponse, - ); - }), - ); - - return router; -} diff --git a/server-node/src/routes/rpg-profile/rpgProfileRoutes.ts b/server-node/src/routes/rpg-profile/rpgProfileRoutes.ts deleted file mode 100644 index e86076b5..00000000 --- a/server-node/src/routes/rpg-profile/rpgProfileRoutes.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { Router } from 'express'; -import { z } from 'zod'; - -import type { - PlatformBrowseHistoryBatchSyncRequest, - PlatformBrowseHistoryResponse, - PlatformBrowseHistoryWriteEntry, - ProfileDashboardSummary, - ProfilePlayStatsResponse, - ProfileWalletLedgerResponse, - RuntimeSettings, -} from '../../../../packages/shared/src/contracts/runtime.js'; -import { PLATFORM_THEMES } from '../../../../packages/shared/src/contracts/runtime.js'; -import type { AppContext } from '../../context.js'; -import { asyncHandler, sendApiResponse } from '../../http.js'; -import { requireJwtAuth } from '../../middleware/auth.js'; -import { routeMeta } from '../../middleware/routeMeta.js'; - -const platformBrowseHistoryEntrySchema = z.object({ - ownerUserId: z.string().trim().min(1), - profileId: z.string().trim().min(1), - worldName: z.string().trim().min(1), - subtitle: z.string().trim().optional().default(''), - summaryText: z.string().trim().optional().default(''), - coverImageSrc: z.string().trim().nullable().optional().default(null), - themeMode: z.string().trim().optional().default('mythic'), - authorDisplayName: z.string().trim().optional().default('玩家'), - visitedAt: z.string().trim().optional().default(''), -}); - -const platformBrowseHistoryBatchSchema = z.object({ - entries: z.array(platformBrowseHistoryEntrySchema).max(100), -}); - -const settingsSchema = z.object({ - musicVolume: z.number().min(0).max(1), - platformTheme: z.enum(PLATFORM_THEMES), -}); - -export const RPG_PROFILE_ROUTE_BASE_PATH = '/api/runtime/profile'; -export const RPG_PROFILE_LEGACY_ROUTE_BASE_PATH = '/api/profile'; - -function routeCompatPaths(path: string) { - return [path, path.replace('runtime/', '')] as const; -} - -export function createRpgProfileRoutes(context: AppContext) { - const router = Router(); - const requireAuth = requireJwtAuth(context.config, context.userRepository); - - routeCompatPaths('/api/runtime/profile/dashboard').forEach((path, index) => { - router.get( - path.replace('/api/', '/'), - requireAuth, - routeMeta({ - operation: - index === 0 - ? 'profile.dashboard.get' - : 'profile.dashboard.get.compat', - }), - asyncHandler(async (request, response) => { - sendApiResponse( - response, - await context.rpgProfileDashboardRepository.getProfileDashboard( - request.userId!, - ), - ); - }), - ); - }); - - routeCompatPaths('/api/runtime/profile/wallet-ledger').forEach((path, index) => { - router.get( - path.replace('/api/', '/'), - requireAuth, - routeMeta({ - operation: - index === 0 - ? 'profile.walletLedger.list' - : 'profile.walletLedger.list.compat', - }), - asyncHandler(async (request, response) => { - sendApiResponse(response, { - entries: - await context.rpgProfileDashboardRepository.listProfileWalletLedger( - request.userId!, - ), - }); - }), - ); - }); - - routeCompatPaths('/api/runtime/profile/play-stats').forEach((path, index) => { - router.get( - path.replace('/api/', '/'), - requireAuth, - routeMeta({ - operation: - index === 0 - ? 'profile.playStats.get' - : 'profile.playStats.get.compat', - }), - asyncHandler(async (request, response) => { - sendApiResponse( - response, - await context.rpgProfileDashboardRepository.getProfilePlayStats( - request.userId!, - ), - ); - }), - ); - }); - - routeCompatPaths('/api/runtime/profile/browse-history').forEach((path, index) => { - router.get( - path.replace('/api/', '/'), - requireAuth, - routeMeta({ - operation: - index === 0 - ? 'profile.browseHistory.list' - : 'profile.browseHistory.list.compat', - }), - asyncHandler(async (request, response) => { - sendApiResponse(response, { - entries: await context.rpgBrowseHistoryRepository.listPlatformBrowseHistory( - request.userId!, - ), - }); - }), - ); - - router.post( - path.replace('/api/', '/'), - requireAuth, - routeMeta({ - operation: - index === 0 - ? 'profile.browseHistory.upsert' - : 'profile.browseHistory.upsert.compat', - }), - asyncHandler(async (request, response) => { - const rawBody = - request.body && typeof request.body === 'object' ? request.body : {}; - const payload = ( - 'entries' in rawBody - ? platformBrowseHistoryBatchSchema.parse(rawBody) - : platformBrowseHistoryEntrySchema.parse(rawBody) - ) as - | PlatformBrowseHistoryBatchSyncRequest - | PlatformBrowseHistoryWriteEntry; - - const entries = 'entries' in payload ? payload.entries : [payload]; - - sendApiResponse(response, { - entries: - await context.rpgBrowseHistoryRepository.upsertPlatformBrowseHistoryEntries( - request.userId!, - entries, - ), - }); - }), - ); - - router.delete( - path.replace('/api/', '/'), - requireAuth, - routeMeta({ - operation: - index === 0 - ? 'profile.browseHistory.clear' - : 'profile.browseHistory.clear.compat', - }), - asyncHandler(async (request, response) => { - await context.rpgBrowseHistoryRepository.clearPlatformBrowseHistory( - request.userId!, - ); - sendApiResponse(response, { - entries: [], - }); - }), - ); - }); - - router.get( - '/api/runtime/settings'.replace('/api/', '/'), - requireAuth, - routeMeta({ operation: 'runtime.settings.get' }), - asyncHandler(async (request, response) => { - sendApiResponse( - response, - await context.rpgProfileDashboardRepository.getSettings(request.userId!), - ); - }), - ); - - router.put( - '/api/runtime/settings'.replace('/api/', '/'), - requireAuth, - routeMeta({ operation: 'runtime.settings.put' }), - asyncHandler(async (request, response) => { - const payload = settingsSchema.parse(request.body) as RuntimeSettings; - sendApiResponse( - response, - await context.rpgProfileDashboardRepository.putSettings( - request.userId!, - payload, - ), - ); - }), - ); - - return router; -} diff --git a/server-node/src/routes/rpg-runtime/rpgRuntimeAiAssistRoutes.ts b/server-node/src/routes/rpg-runtime/rpgRuntimeAiAssistRoutes.ts deleted file mode 100644 index 06973c12..00000000 --- a/server-node/src/routes/rpg-runtime/rpgRuntimeAiAssistRoutes.ts +++ /dev/null @@ -1,370 +0,0 @@ -import { Router } from 'express'; -import { z } from 'zod'; - -import type { - QuestGenerationRequest, - RuntimeItemIntentRequest, -} from '../../../../packages/shared/src/contracts/rpgRuntimeQuestAssist.js'; -import type { - CharacterChatReplyRequest, - CharacterChatSuggestionsRequest, - CharacterChatSummaryRequest, - NpcChatDialogueRequest, - NpcChatTurnRequest, - NpcRecruitDialogueRequest, - StoryRequestPayload, -} from '../../../../packages/shared/src/contracts/rpgRuntimeChat.js'; -import type { GenerateCustomWorldProfileInput } from '../../../../packages/shared/src/contracts/runtime.js'; -import type { AppContext } from '../../context.js'; -import { asyncHandler, sendApiResponse } from '../../http.js'; -import { requireJwtAuth } from '../../middleware/auth.js'; -import { routeMeta } from '../../middleware/routeMeta.js'; -import { - generateCharacterChatSuggestionsFromOrchestrator, - generateCharacterChatSummaryFromOrchestrator, - streamCharacterChatReplyFromOrchestrator, - streamNpcChatDialogueFromOrchestrator, - streamNpcChatTurnFromOrchestrator, - streamNpcRecruitDialogueFromOrchestrator, -} from '../../modules/ai/chatOrchestrator.js'; -import { generateCustomWorldProfileFromOrchestrator } from '../../modules/ai/customWorldOrchestrator.js'; -import { - characterChatReplyRequestSchema, - characterChatSuggestionsRequestSchema, - characterChatSummaryRequestSchema, - npcChatDialogueRequestSchema, - npcChatTurnRequestSchema, - npcRecruitDialogueRequestSchema, -} from '../../services/chatService.js'; -import { - customWorldCoverImageSchema, - customWorldCoverUploadSchema, - generateCustomWorldCoverImage, - uploadCustomWorldCoverImage, -} from '../../services/customWorldCoverAssetService.js'; -import { generateCustomWorldEntity } from '../../services/customWorldEntityGenerationService.js'; -import { generateSceneNpcForLandmark } from '../../services/customWorldSceneNpcGenerationService.js'; -import { generateQuestForNpcEncounter } from '../../services/questService.js'; -import { generateRuntimeItemIntents } from '../../services/runtimeItemService.js'; -import { - generateSceneImage, - sceneImageSchema, -} from '../../services/sceneImageService.js'; -import { - generateHighQualityInitialStory, - generateHighQualityNextStory, - parseStoryRequest, -} from '../../services/storyService.js'; - -const jsonObjectSchema = z.record(z.string(), z.unknown()); - -const customWorldProfileGenerationSchema = z.object({ - settingText: z.string().trim().min(1), - creatorIntent: jsonObjectSchema.nullish(), - generationMode: z.enum(['fast', 'full']).optional(), -}); - -const customWorldSceneNpcSchema = z.object({ - profile: jsonObjectSchema, - landmarkId: z.string().trim().min(1), -}); - -const customWorldEntitySchema = z.object({ - profile: jsonObjectSchema, - kind: z.enum(['playable', 'story', 'landmark']), -}); - -const runtimeItemIntentSchema = z.object({ - context: jsonObjectSchema, - plans: z.array(jsonObjectSchema), -}); - -const questGenerationSchema = z.object({ - state: jsonObjectSchema, - encounter: jsonObjectSchema, -}); - -const llmProxySchema = jsonObjectSchema; - -export const RPG_RUNTIME_AI_ASSIST_ROUTE_BASE_PATH = '/api/runtime'; - -export function createRpgRuntimeAiAssistRoutes(context: AppContext) { - const router = Router(); - const requireAuth = requireJwtAuth(context.config, context.userRepository); - const handleCustomWorldEntityGeneration = asyncHandler( - async (request, response) => { - const payload = customWorldEntitySchema.parse(request.body) as { - profile: Record; - kind: 'playable' | 'story' | 'landmark'; - }; - sendApiResponse( - response, - await generateCustomWorldEntity(context.llmClient, payload), - ); - }, - ); - const handleCustomWorldSceneNpcGeneration = asyncHandler( - async (request, response) => { - const payload = customWorldSceneNpcSchema.parse(request.body) as { - profile: Record; - landmarkId: string; - }; - sendApiResponse(response, { - npc: await generateSceneNpcForLandmark(context.llmClient, payload), - }); - }, - ); - - router.post( - '/llm/chat/completions', - requireAuth, - routeMeta({ operation: 'runtime.llm.chatCompletionsProxy' }), - asyncHandler(async (request, response) => { - const body = llmProxySchema.parse(request.body); - await context.llmClient.forwardCompletion(request, body, response); - }), - ); - - router.post( - '/custom-world/cover-image', - requireAuth, - routeMeta({ operation: 'runtime.customWorld.coverImage' }), - asyncHandler(async (request, response) => { - const payload = customWorldCoverImageSchema.parse(request.body); - sendApiResponse(response, await generateCustomWorldCoverImage(context, payload)); - }), - ); - - router.post( - '/custom-world/cover-upload', - requireAuth, - routeMeta({ operation: 'runtime.customWorld.coverUpload' }), - asyncHandler(async (request, response) => { - const payload = customWorldCoverUploadSchema.parse(request.body); - sendApiResponse(response, await uploadCustomWorldCoverImage(context, payload)); - }), - ); - - router.post( - '/custom-world/scene-image', - requireAuth, - routeMeta({ operation: 'runtime.customWorld.sceneImage' }), - asyncHandler(async (request, response) => { - const payload = sceneImageSchema.parse(request.body); - sendApiResponse(response, await generateSceneImage(context, payload)); - }), - ); - - router.post( - '/custom-world/entity', - requireAuth, - routeMeta({ operation: 'runtime.customWorld.entity' }), - handleCustomWorldEntityGeneration, - ); - - router.post( - '/runtime/custom-world/entity', - requireAuth, - routeMeta({ operation: 'runtime.customWorld.entity.compat' }), - handleCustomWorldEntityGeneration, - ); - - router.post( - '/custom-world/scene-npc', - requireAuth, - routeMeta({ operation: 'runtime.customWorld.sceneNpc' }), - handleCustomWorldSceneNpcGeneration, - ); - - router.post( - '/runtime/custom-world/scene-npc', - requireAuth, - routeMeta({ operation: 'runtime.customWorld.sceneNpc.compat' }), - handleCustomWorldSceneNpcGeneration, - ); - - router.post( - '/runtime/custom-world/profile', - requireAuth, - routeMeta({ operation: 'runtime.customWorld.profile' }), - asyncHandler(async (request, response) => { - const payload = customWorldProfileGenerationSchema.parse( - request.body, - ) as GenerateCustomWorldProfileInput; - sendApiResponse( - response, - await generateCustomWorldProfileFromOrchestrator( - context.llmClient, - payload, - ), - ); - }), - ); - - router.post( - '/runtime/story/initial', - requireAuth, - routeMeta({ operation: 'runtime.story.initial' }), - asyncHandler(async (request, response) => { - const payload = parseStoryRequest(request.body) as StoryRequestPayload; - sendApiResponse( - response, - await generateHighQualityInitialStory(context.llmClient, payload), - ); - }), - ); - - router.post( - '/runtime/story/continue', - requireAuth, - routeMeta({ operation: 'runtime.story.continue' }), - asyncHandler(async (request, response) => { - const payload = parseStoryRequest(request.body) as StoryRequestPayload; - sendApiResponse( - response, - await generateHighQualityNextStory(context.llmClient, payload), - ); - }), - ); - - router.post( - '/runtime/chat/character/suggestions', - requireAuth, - routeMeta({ operation: 'runtime.chat.character.suggestions' }), - asyncHandler(async (request, response) => { - const payload = characterChatSuggestionsRequestSchema.parse( - request.body, - ) as CharacterChatSuggestionsRequest; - sendApiResponse(response, { - text: await generateCharacterChatSuggestionsFromOrchestrator( - context.llmClient, - payload, - ), - }); - }), - ); - - router.post( - '/runtime/chat/character/summary', - requireAuth, - routeMeta({ operation: 'runtime.chat.character.summary' }), - asyncHandler(async (request, response) => { - const payload = characterChatSummaryRequestSchema.parse( - request.body, - ) as CharacterChatSummaryRequest; - sendApiResponse(response, { - text: await generateCharacterChatSummaryFromOrchestrator( - context.llmClient, - payload, - ), - }); - }), - ); - - router.post( - '/runtime/chat/character/reply/stream', - requireAuth, - routeMeta({ operation: 'runtime.chat.character.replyStream' }), - asyncHandler(async (request, response) => { - const payload = characterChatReplyRequestSchema.parse( - request.body, - ) as CharacterChatReplyRequest; - await streamCharacterChatReplyFromOrchestrator(context.llmClient, { - request, - response, - payload, - }); - }), - ); - - router.post( - '/runtime/chat/npc/dialogue/stream', - requireAuth, - routeMeta({ operation: 'runtime.chat.npc.dialogueStream' }), - asyncHandler(async (request, response) => { - const payload = npcChatDialogueRequestSchema.parse( - request.body, - ) as NpcChatDialogueRequest; - await streamNpcChatDialogueFromOrchestrator(context.llmClient, { - request, - response, - payload, - }); - }), - ); - - router.post( - '/runtime/chat/npc/turn/stream', - requireAuth, - routeMeta({ operation: 'runtime.chat.npc.turnStream' }), - asyncHandler(async (request, response) => { - const payload = npcChatTurnRequestSchema.parse( - request.body, - ) as NpcChatTurnRequest; - await streamNpcChatTurnFromOrchestrator(context.llmClient, { - request, - response, - payload, - }); - }), - ); - - router.post( - '/runtime/chat/npc/recruit/stream', - requireAuth, - routeMeta({ operation: 'runtime.chat.npc.recruitStream' }), - asyncHandler(async (request, response) => { - const payload = npcRecruitDialogueRequestSchema.parse( - request.body, - ) as NpcRecruitDialogueRequest; - await streamNpcRecruitDialogueFromOrchestrator(context.llmClient, { - request, - response, - payload, - }); - }), - ); - - router.post( - '/runtime/items/runtime-intent', - requireAuth, - routeMeta({ operation: 'runtime.items.intent' }), - asyncHandler(async (request, response) => { - const payload = runtimeItemIntentSchema.parse( - request.body, - ) as RuntimeItemIntentRequest; - sendApiResponse(response, { - intents: await generateRuntimeItemIntents(context.llmClient, payload), - }); - }), - ); - - router.post( - '/runtime/quests/generate', - requireAuth, - routeMeta({ operation: 'runtime.quests.generate' }), - asyncHandler(async (request, response) => { - const payload = questGenerationSchema.parse( - request.body, - ) as QuestGenerationRequest; - sendApiResponse( - response, - await generateQuestForNpcEncounter(context.llmClient, payload), - ); - }), - ); - - router.get( - '/ws/health', - requireAuth, - routeMeta({ operation: 'runtime.ws.health' }), - (_request, response) => { - sendApiResponse(response, { - ok: true, - message: 'websocket routes reserved for future real-time support', - }); - }, - ); - - return router; -} diff --git a/server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.test.ts b/server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.test.ts deleted file mode 100644 index 2596303d..00000000 --- a/server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.test.ts +++ /dev/null @@ -1,2741 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import type { AddressInfo } from 'node:net'; -import os from 'node:os'; -import path from 'node:path'; -import test from 'node:test'; - -import { createApp } from '../../app.ts'; -import { buildQuestForEncounter } from '../../bridges/legacyQuestProgressBridge.js'; -import type { AppConfig } from '../../config.ts'; -import { applyQuestSignal } from '../../modules/quest/questProgressionService.ts'; -import { createAppContext } from '../../server.ts'; -import { createTestPlayerCharacter } from '../../testFixtures/runtimeCharacter.ts'; -import { httpRequest, type TestRequestInit } from '../../testHttp.ts'; - -function createTestConfig(testName: string): AppConfig { - const tempRoot = fs.mkdtempSync( - path.join(os.tmpdir(), `genarrative-story-actions-${testName}-`), - ); - - return { - nodeEnv: 'test', - projectRoot: tempRoot, - publicDir: path.join(tempRoot, 'public'), - logsDir: path.join(tempRoot, 'logs'), - dataDir: path.join(tempRoot, 'data'), - rawEnv: {}, - databaseUrl: `pg-mem://genarrative-story-actions-${testName}`, - serverAddr: ':0', - logLevel: 'silent', - editorApiEnabled: true, - assetsApiEnabled: true, - jwtSecret: 'test-secret', - jwtExpiresIn: '7d', - jwtIssuer: 'genarrative-story-actions-test', - llm: { - baseUrl: 'https://example.invalid', - apiKey: '', - model: 'test-model', - }, - dashScope: { - baseUrl: 'https://example.invalid', - apiKey: '', - imageModel: 'test-image-model', - requestTimeoutMs: 1000, - }, - smsAuth: { - enabled: true, - provider: 'mock', - endpoint: 'dypnsapi.aliyuncs.com', - accessKeyId: '', - accessKeySecret: '', - signName: 'Test Sign', - templateCode: '100001', - templateParamKey: 'code', - countryCode: '86', - schemeName: '', - codeLength: 6, - codeType: 1, - validTimeSeconds: 300, - intervalSeconds: 60, - duplicatePolicy: 1, - caseAuthPolicy: 1, - returnVerifyCode: false, - mockVerifyCode: '123456', - maxSendPerPhonePerDay: 20, - maxSendPerIpPerHour: 30, - maxVerifyFailuresPerPhonePerHour: 12, - maxVerifyFailuresPerIpPerHour: 24, - captchaTtlSeconds: 180, - captchaTriggerVerifyFailuresPerPhone: 3, - captchaTriggerVerifyFailuresPerIp: 5, - blockPhoneFailureThreshold: 6, - blockIpFailureThreshold: 10, - blockPhoneDurationMinutes: 30, - blockIpDurationMinutes: 30, - }, - wechatAuth: { - enabled: true, - provider: 'mock', - appId: '', - appSecret: '', - authorizeEndpoint: 'https://open.weixin.qq.com/connect/qrconnect', - accessTokenEndpoint: 'https://api.weixin.qq.com/sns/oauth2/access_token', - userInfoEndpoint: 'https://api.weixin.qq.com/sns/userinfo', - callbackPath: '/api/auth/wechat/callback', - defaultRedirectPath: '/', - mockUserId: 'mock_wechat_user', - mockUnionId: 'mock_wechat_union', - mockDisplayName: '微信旅人', - mockAvatarUrl: '', - }, - authSession: { - accessCookieName: 'genarrative_access_session', - accessCookieTtlSeconds: 7200, - accessCookieSecure: false, - accessCookieSameSite: 'Lax', - accessCookiePath: '/', - refreshCookieName: 'genarrative_refresh_session', - refreshSessionTtlDays: 30, - refreshCookieSecure: false, - refreshCookieSameSite: 'Lax', - refreshCookiePath: '/api/auth', - }, - }; -} - -async function withTestServer( - testName: string, - run: (options: { baseUrl: string }) => Promise, -) { - const context = await createAppContext(createTestConfig(testName)); - const app = createApp(context); - const server = await new Promise((resolve) => { - const nextServer = app.listen(0, '127.0.0.1', () => resolve(nextServer)); - }); - - try { - const address = server.address() as AddressInfo; - return await run({ - baseUrl: `http://127.0.0.1:${address.port}`, - }); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => { - if (error) { - reject(error); - return; - } - resolve(); - }); - }); - await context.db.close(); - } -} - -async function authEntry(baseUrl: string, username: string, password: string) { - const response = await httpRequest(`${baseUrl}/api/auth/entry`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - username, - password, - }), - }); - const payload = (await response.json()) as { - token: string; - }; - - assert.equal(response.status, 200); - assert.ok(payload.token); - return payload; -} - -function withBearer(token: string, init: TestRequestInit = {}) { - return { - ...init, - headers: { - ...(init.headers ?? {}), - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - } satisfies TestRequestInit; -} - -async function putSnapshot( - baseUrl: string, - token: string, - gameState: unknown, - currentStory: unknown = { - text: '初始化剧情', - options: [], - }, -) { - const response = await httpRequest( - `${baseUrl}/api/runtime/save/snapshot`, - withBearer(token, { - method: 'PUT', - body: JSON.stringify({ - gameState, - bottomTab: 'adventure', - currentStory, - }), - }), - ); - - assert.equal(response.status, 200); -} - -function requirePlayerCharacter() { - return createTestPlayerCharacter(); -} - -function createTask6GameState(overrides: Record = {}) { - return { - worldType: 'WUXIA', - playerCharacter: requirePlayerCharacter(), - runtimeStats: { - playTimeMs: 0, - lastPlayTickAt: null, - hostileNpcsDefeated: 0, - questsAccepted: 0, - itemsUsed: 0, - scenesTraveled: 0, - }, - currentScene: 'test-scene', - storyHistory: [], - characterChats: {}, - animationState: 'idle', - currentEncounter: null, - npcInteractionActive: false, - currentScenePreset: null, - sceneHostileNpcs: [], - playerX: 0, - playerOffsetY: 0, - playerFacing: 'right', - playerActionMode: 'idle', - scrollWorld: false, - inBattle: false, - playerHp: 32, - playerMaxHp: 40, - playerMana: 9, - playerMaxMana: 16, - playerSkillCooldowns: { - slash: 2, - }, - activeBuildBuffs: [], - activeCombatEffects: [], - playerCurrency: 90, - playerInventory: [], - playerEquipment: { - weapon: null, - armor: null, - relic: null, - }, - npcStates: {}, - quests: [], - roster: [], - companions: [], - currentBattleNpcId: null, - currentNpcBattleMode: null, - currentNpcBattleOutcome: null, - sparReturnEncounter: null, - sparPlayerHpBefore: null, - sparPlayerMaxHpBefore: null, - sparStoryHistoryBefore: null, - ...overrides, - }; -} - -function createPendingQuestOfferCurrentStory(quest: Record) { - return { - text: '巡路人终于把真正的委托说了出来。', - options: [ - { - functionId: 'npc_chat_quest_offer_view', - actionText: '查看任务', - text: '查看任务', - detailText: '', - visuals: { - playerAnimation: 'idle', - playerMoveMeters: 0, - playerOffsetY: 0, - playerFacing: 'right', - scrollWorld: false, - monsterChanges: [], - }, - }, - { - functionId: 'npc_chat_quest_offer_replace', - actionText: '更换任务', - text: '更换任务', - detailText: '', - visuals: { - playerAnimation: 'idle', - playerMoveMeters: 0, - playerOffsetY: 0, - playerFacing: 'right', - scrollWorld: false, - monsterChanges: [], - }, - }, - { - functionId: 'npc_chat_quest_offer_abandon', - actionText: '放弃任务', - text: '放弃任务', - detailText: '', - visuals: { - playerAnimation: 'idle', - playerMoveMeters: 0, - playerOffsetY: 0, - playerFacing: 'right', - scrollWorld: false, - monsterChanges: [], - }, - }, - ], - displayMode: 'dialogue', - dialogue: [ - { - speaker: 'npc', - speakerName: '巡路人', - text: '这件事我只想托给你。', - }, - ], - npcChatState: { - npcId: 'npc_scout_01', - npcName: '巡路人', - turnCount: 2, - customInputPlaceholder: '输入你想对 TA 说的话', - pendingQuestOffer: { - quest, - }, - }, - }; -} - -const QUEST_BATTLE_SCENE = { - id: 'quest-bridge', - name: '断桥口', - description: '桥口被匪首和刀痕压得极紧。', - npcs: [ - { - id: 'npc_bandit_01', - name: '断桥匪首', - description: '手提短刀的拦路匪徒', - avatar: '匪', - role: '敌对角色', - monsterPresetId: 'npc_bandit_01', - initialAffinity: -40, - hostile: true, - }, - ], - treasureHints: [], -}; - -const QUEST_TREASURE_SCENE = { - id: 'quest-ruins', - name: '残碑古道', - description: '路旁散着断碑和旧匣。', - npcs: [], - treasureHints: ['残匣', '旧印'], -}; - -function createChapterAutoScalingProfile() { - return { - id: 'custom-world-auto-scaling', - settingText: '测试世界', - name: '测试世界', - subtitle: '章节自动定级', - summary: '用于 runtime 章节定级测试。', - tone: '压迫', - playerGoal: '推进章节', - templateWorldType: 'CUSTOM', - majorFactions: [], - coreConflicts: [], - attributeSchema: { - id: 'schema-1', - worldId: 'custom-world-auto-scaling', - schemaVersion: 1, - schemaName: '测试属性', - generatedFrom: { - worldType: 'CUSTOM', - worldName: '测试世界', - settingSummary: '测试', - tone: '压迫', - conflictCore: '推进', - }, - slots: [], - }, - playableNpcs: [], - storyNpcs: [ - { - id: 'npc_outskirts_raider', - name: '谷口匪徒', - title: '匪徒', - role: '敌对角色', - description: '卡在谷口要道上的拦路人', - backstory: '', - personality: '', - motivation: '', - combatStyle: '近战', - initialAffinity: -20, - relationshipHooks: [], - tags: ['hostile'], - backstoryReveal: { - publicSummary: '', - privateChatUnlockAffinity: 0, - chapters: [], - }, - skills: [], - initialItems: [], - }, - { - id: 'npc_sanctum_lord', - name: '祭坛领主', - title: '镇守者', - role: '敌对首领', - description: '守在终章祭坛里的重压敌人', - backstory: '', - personality: '', - motivation: '', - combatStyle: '重击', - initialAffinity: -40, - relationshipHooks: [], - tags: ['hostile', 'boss'], - backstoryReveal: { - publicSummary: '', - privateChatUnlockAffinity: 0, - chapters: [], - }, - skills: [], - initialItems: [], - }, - ], - items: [], - landmarks: [], - sceneChapterBlueprints: [ - { - id: 'chapter-outskirts', - sceneId: 'scene-outskirts', - title: '谷口起势', - summary: '初段冲突', - linkedThreadIds: [], - linkedLandmarkIds: [], - acts: [ - { - id: 'act-outskirts-open', - sceneId: 'scene-outskirts', - title: '谷口相撞', - summary: '第一轮冲突', - stageCoverage: ['opening'], - encounterNpcIds: ['npc_outskirts_raider'], - primaryNpcId: 'npc_outskirts_raider', - linkedThreadIds: [], - advanceRule: 'after_primary_contact', - actGoal: '稳住开局', - transitionHook: '继续深入', - }, - ], - }, - { - id: 'chapter-forest', - sceneId: 'scene-forest', - title: '林地紧逼', - summary: '中段过渡', - linkedThreadIds: [], - linkedLandmarkIds: [], - acts: [ - { - id: 'act-forest-mid', - sceneId: 'scene-forest', - title: '林地追逼', - summary: '第二轮压迫', - stageCoverage: ['expansion', 'turning_point'], - encounterNpcIds: ['npc_outskirts_raider'], - primaryNpcId: 'npc_outskirts_raider', - linkedThreadIds: [], - advanceRule: 'after_active_step_complete', - actGoal: '逼近深处', - transitionHook: '抵达祭坛', - }, - ], - }, - { - id: 'chapter-sanctum', - sceneId: 'scene-sanctum', - title: '祭坛收束', - summary: '终章对决', - linkedThreadIds: [], - linkedLandmarkIds: [], - acts: [ - { - id: 'act-sanctum-final', - sceneId: 'scene-sanctum', - title: '终章收口', - summary: '最终对决', - stageCoverage: ['climax'], - encounterNpcIds: ['npc_sanctum_lord'], - primaryNpcId: 'npc_sanctum_lord', - linkedThreadIds: [], - advanceRule: 'after_chapter_resolution', - actGoal: '击败首领', - transitionHook: '余波展开', - }, - ], - }, - ], - }; -} - -test('runtime story actions resolve npc chat on the server and persist updated affinity', async () => { - await withTestServer('npc-chat', async ({ baseUrl }) => { - const entry = await authEntry(baseUrl, 'story_npc_chat', 'secret123'); - - await putSnapshot(baseUrl, entry.token, { - worldType: 'WUXIA', - storyHistory: [], - currentEncounter: { - kind: 'npc', - id: 'npc_merchant_01', - npcName: '沈七', - npcDescription: '腰间挂着药囊的行商', - context: '受伤行商', - }, - npcInteractionActive: true, - sceneHostileNpcs: [], - inBattle: false, - playerHp: 31, - playerMaxHp: 40, - playerMana: 9, - playerMaxMana: 16, - npcStates: { - npc_merchant_01: { - affinity: 46, - chattedCount: 0, - helpUsed: false, - giftsGiven: 0, - inventory: [], - recruited: false, - }, - }, - companions: [], - currentNpcBattleMode: null, - currentNpcBattleOutcome: null, - }); - - const response = await httpRequest( - `${baseUrl}/api/runtime/story/actions/resolve`, - withBearer(entry.token, { - method: 'POST', - body: JSON.stringify({ - sessionId: 'runtime-main', - clientVersion: 0, - action: { - type: 'story_choice', - functionId: 'npc_chat', - }, - }), - }), - ); - const payload = (await response.json()) as { - serverVersion: number; - viewModel: { - encounter: { - affinity: number; - } | null; - availableOptions: Array<{ - functionId: string; - interaction?: { - kind: string; - npcId?: string; - action?: string; - }; - }>; - }; - presentation: { - storyText: string; - }; - patches: Array<{ - type: string; - }>; - }; - - assert.equal(response.status, 200); - assert.equal(payload.serverVersion, 1); - assert.equal(payload.viewModel.encounter?.affinity, 52); - assert.match(payload.presentation.storyText, /沈七/u); - assert.ok( - payload.viewModel.availableOptions.some( - (option) => option.functionId === 'npc_help', - ), - ); - assert.deepEqual( - payload.viewModel.availableOptions.find( - (option) => option.functionId === 'npc_help', - )?.interaction, - { - kind: 'npc', - npcId: 'npc_merchant_01', - action: 'help', - }, - ); - assert.ok( - payload.patches.some((patch) => patch.type === 'npc_affinity_changed'), - ); - - const snapshotResponse = await httpRequest( - `${baseUrl}/api/runtime/save/snapshot`, - { - headers: { - Authorization: `Bearer ${entry.token}`, - }, - }, - ); - const snapshotPayload = (await snapshotResponse.json()) as { - gameState: { - runtimeActionVersion: number; - npcStates: { - npc_merchant_01: { - affinity: number; - }; - }; - }; - currentStory: { - text: string; - }; - }; - - assert.equal(snapshotResponse.status, 200); - assert.equal(snapshotPayload.gameState.runtimeActionVersion, 1); - assert.equal( - snapshotPayload.gameState.npcStates.npc_merchant_01.affinity, - 52, - ); - assert.match(snapshotPayload.currentStory.text, /沈七/u); - }); -}); - -test('runtime story state exposes npc interaction metadata directly from the server option builder', async () => { - await withTestServer('npc-state-options', async ({ baseUrl }) => { - const entry = await authEntry(baseUrl, 'story_npc_state', 'secret123'); - - await putSnapshot( - baseUrl, - entry.token, - createTask6GameState({ - currentEncounter: { - kind: 'npc', - id: 'npc_merchant_01', - npcName: '沈七', - npcDescription: '腰间挂着药囊的行商', - context: '受伤行商', - }, - npcInteractionActive: true, - npcStates: { - npc_merchant_01: { - affinity: 46, - chattedCount: 1, - helpUsed: false, - giftsGiven: 0, - inventory: [], - recruited: false, - }, - }, - }), - ); - - const response = await httpRequest( - `${baseUrl}/api/runtime/story/state/runtime-main`, - { - headers: { - Authorization: `Bearer ${entry.token}`, - }, - }, - ); - const payload = (await response.json()) as { - viewModel: { - availableOptions: Array<{ - functionId: string; - interaction?: { - kind: string; - npcId?: string; - action?: string; - }; - }>; - }; - }; - - assert.equal(response.status, 200); - assert.deepEqual( - payload.viewModel.availableOptions.find( - (option) => option.functionId === 'npc_chat', - )?.interaction, - { - kind: 'npc', - npcId: 'npc_merchant_01', - action: 'chat', - }, - ); - assert.deepEqual( - payload.viewModel.availableOptions.find( - (option) => option.functionId === 'npc_help', - )?.interaction, - { - kind: 'npc', - npcId: 'npc_merchant_01', - action: 'help', - }, - ); - }); -}); - -test('runtime story actions attach hostile level metadata when npc fights start on the server', async () => { - await withTestServer('npc-fight-level-profile', async ({ baseUrl }) => { - const entry = await authEntry( - baseUrl, - 'story_npc_fight_level', - 'secret123', - ); - - await putSnapshot( - baseUrl, - entry.token, - createTask6GameState({ - currentEncounter: { - kind: 'npc', - id: 'npc_duelist_01', - npcName: '拦路刀客', - npcDescription: '持刀拦路的江湖客', - context: '渡口挑衅', - hostile: true, - }, - npcInteractionActive: true, - playerProgression: { - level: 4, - currentLevelXp: 0, - totalXp: 280, - xpToNextLevel: 192, - }, - npcStates: { - npc_duelist_01: { - affinity: -18, - chattedCount: 0, - helpUsed: false, - giftsGiven: 0, - inventory: [], - recruited: false, - }, - }, - }), - ); - - const response = await httpRequest( - `${baseUrl}/api/runtime/story/actions/resolve`, - withBearer(entry.token, { - method: 'POST', - body: JSON.stringify({ - sessionId: 'runtime-main', - clientVersion: 0, - action: { - type: 'story_choice', - functionId: 'npc_fight', - }, - }), - }), - ); - const payload = (await response.json()) as { - snapshot: { - gameState: { - currentEncounter: { - levelProfile?: { - level: number; - progressionRole: string; - }; - experienceReward?: number; - } | null; - sceneHostileNpcs: Array<{ - maxHp: number; - levelProfile?: { - level: number; - referenceStrength: number; - progressionRole: string; - }; - experienceReward?: number; - }>; - currentNpcBattleMode: string | null; - inBattle: boolean; - }; - }; - }; - - assert.equal(response.status, 200); - assert.equal(payload.snapshot.gameState.inBattle, true); - assert.equal(payload.snapshot.gameState.currentNpcBattleMode, 'fight'); - assert.equal( - payload.snapshot.gameState.currentEncounter?.levelProfile?.level, - 4, - ); - assert.equal( - payload.snapshot.gameState.currentEncounter?.experienceReward, - 15, - ); - assert.equal( - payload.snapshot.gameState.sceneHostileNpcs[0]?.levelProfile?.level, - 4, - ); - assert.equal( - payload.snapshot.gameState.sceneHostileNpcs[0]?.levelProfile - ?.progressionRole, - 'hostile_standard', - ); - assert.ok( - (payload.snapshot.gameState.sceneHostileNpcs[0]?.levelProfile - ?.referenceStrength ?? 0) > 0, - ); - assert.equal( - payload.snapshot.gameState.sceneHostileNpcs[0]?.experienceReward, - 15, - ); - assert.ok( - (payload.snapshot.gameState.sceneHostileNpcs[0]?.maxHp ?? 0) >= 32, - ); - }); -}); - -test('runtime story actions auto-scale hostile levels across chapters instead of only following player level', async () => { - await withTestServer('npc-fight-chapter-auto-scaling', async ({ baseUrl }) => { - const entry = await authEntry( - baseUrl, - 'story_nf_auto', - 'secret123', - ); - const profile = createChapterAutoScalingProfile(); - - await putSnapshot( - baseUrl, - entry.token, - createTask6GameState({ - customWorldProfile: profile, - currentScenePreset: { - id: 'scene-outskirts', - name: '谷口', - description: '路口被劫匪堵住。', - npcs: [], - treasureHints: [], - }, - currentEncounter: { - kind: 'npc', - id: 'npc_outskirts_raider', - npcName: '谷口匪徒', - npcDescription: '盘踞谷口的拦路人', - context: '谷口拦截', - hostile: true, - monsterPresetId: 'outskirts-raider', - }, - npcInteractionActive: true, - playerProgression: { - level: 2, - currentLevelXp: 0, - totalXp: 80, - xpToNextLevel: 88, - }, - chapterState: { - id: 'chapter-outskirts', - title: '谷口起势', - theme: '开局冲突', - primaryThreadIds: [], - stage: 'opening', - chapterSummary: '第一章刚刚开始。', - sceneId: 'scene-outskirts', - chapterQuestId: null, - }, - storyEngineMemory: { - currentChapter: { - id: 'chapter-outskirts', - title: '谷口起势', - theme: '开局冲突', - primaryThreadIds: [], - stage: 'opening', - chapterSummary: '第一章刚刚开始。', - sceneId: 'scene-outskirts', - chapterQuestId: null, - }, - currentSceneActState: { - sceneId: 'scene-outskirts', - chapterId: 'chapter-outskirts', - currentActId: 'act-outskirts-open', - currentActIndex: 0, - completedActIds: [], - visitedActIds: ['act-outskirts-open'], - }, - }, - npcStates: { - npc_outskirts_raider: { - affinity: -20, - chattedCount: 0, - helpUsed: false, - giftsGiven: 0, - inventory: [], - recruited: false, - }, - }, - }), - ); - - const openingResponse = await httpRequest( - `${baseUrl}/api/runtime/story/actions/resolve`, - withBearer(entry.token, { - method: 'POST', - body: JSON.stringify({ - sessionId: 'runtime-main', - clientVersion: 0, - action: { - type: 'story_choice', - functionId: 'npc_fight', - }, - }), - }), - ); - const openingPayload = (await openingResponse.json()) as { - snapshot: { - gameState: { - currentEncounter: { - levelProfile?: { - level: number; - source: string; - progressionRole: string; - }; - experienceReward?: number; - } | null; - }; - }; - }; - - assert.equal(openingResponse.status, 200); - const openingLevel = - openingPayload.snapshot.gameState.currentEncounter?.levelProfile?.level ?? 0; - const openingXp = - openingPayload.snapshot.gameState.currentEncounter?.experienceReward ?? 0; - assert.equal( - openingPayload.snapshot.gameState.currentEncounter?.levelProfile?.source, - 'chapter_auto', - ); - assert.equal( - openingPayload.snapshot.gameState.currentEncounter?.levelProfile - ?.progressionRole, - 'hostile_elite', - ); - - await putSnapshot( - baseUrl, - entry.token, - createTask6GameState({ - customWorldProfile: profile, - currentScenePreset: { - id: 'scene-sanctum', - name: '祭坛', - description: '终章祭坛已经压到面前。', - npcs: [], - treasureHints: [], - }, - currentEncounter: { - kind: 'npc', - id: 'npc_sanctum_lord', - npcName: '祭坛领主', - npcDescription: '镇守终章祭坛的首领', - context: '祭坛正面压制', - hostile: true, - monsterPresetId: 'sanctum-lord', - }, - npcInteractionActive: true, - playerProgression: { - level: 2, - currentLevelXp: 0, - totalXp: 80, - xpToNextLevel: 88, - }, - chapterState: { - id: 'chapter-sanctum', - title: '祭坛收束', - theme: '最终对决', - primaryThreadIds: [], - stage: 'climax', - chapterSummary: '终章已经进入最后收口。', - sceneId: 'scene-sanctum', - chapterQuestId: null, - }, - storyEngineMemory: { - currentChapter: { - id: 'chapter-sanctum', - title: '祭坛收束', - theme: '最终对决', - primaryThreadIds: [], - stage: 'climax', - chapterSummary: '终章已经进入最后收口。', - sceneId: 'scene-sanctum', - chapterQuestId: null, - }, - currentSceneActState: { - sceneId: 'scene-sanctum', - chapterId: 'chapter-sanctum', - currentActId: 'act-sanctum-final', - currentActIndex: 0, - completedActIds: [], - visitedActIds: ['act-sanctum-final'], - }, - }, - npcStates: { - npc_sanctum_lord: { - affinity: -32, - chattedCount: 0, - helpUsed: false, - giftsGiven: 0, - inventory: [], - recruited: false, - }, - }, - }), - ); - - const climaxResponse = await httpRequest( - `${baseUrl}/api/runtime/story/actions/resolve`, - withBearer(entry.token, { - method: 'POST', - body: JSON.stringify({ - sessionId: 'runtime-main', - clientVersion: 0, - action: { - type: 'story_choice', - functionId: 'npc_fight', - }, - }), - }), - ); - const climaxPayload = (await climaxResponse.json()) as { - snapshot: { - gameState: { - currentEncounter: { - levelProfile?: { - level: number; - source: string; - progressionRole: string; - chapterIndex?: number; - }; - experienceReward?: number; - } | null; - }; - }; - }; - - assert.equal(climaxResponse.status, 200); - const climaxLevel = - climaxPayload.snapshot.gameState.currentEncounter?.levelProfile?.level ?? 0; - const climaxXp = - climaxPayload.snapshot.gameState.currentEncounter?.experienceReward ?? 0; - assert.equal( - climaxPayload.snapshot.gameState.currentEncounter?.levelProfile?.source, - 'chapter_auto', - ); - assert.equal( - climaxPayload.snapshot.gameState.currentEncounter?.levelProfile - ?.progressionRole, - 'hostile_boss', - ); - assert.equal( - climaxPayload.snapshot.gameState.currentEncounter?.levelProfile - ?.chapterIndex, - 3, - ); - assert.ok(climaxLevel > openingLevel); - assert.ok(climaxLevel > 2); - assert.ok(climaxXp > openingXp); - }); -}); - -test('runtime story actions resolve combat finishers on the server and collapse the battle state', async () => { - await withTestServer('combat-finisher', async ({ baseUrl }) => { - const entry = await authEntry( - baseUrl, - 'story_combat_finisher', - 'secret123', - ); - - await putSnapshot( - baseUrl, - entry.token, - createTask6GameState({ - currentEncounter: { - kind: 'npc', - id: 'npc_bandit_01', - npcName: '断桥匪首', - npcDescription: '手提短刀的拦路匪徒', - context: '桥口劫匪', - hostile: true, - }, - npcInteractionActive: false, - sceneHostileNpcs: [ - { - id: 'npc_bandit_01', - name: '断桥匪首', - hp: 12, - maxHp: 28, - description: '桥口劫匪', - }, - ], - inBattle: true, - playerHp: 42, - playerMaxHp: 50, - playerMana: 20, - playerMaxMana: 20, - playerSkillCooldowns: {}, - npcStates: { - npc_bandit_01: { - affinity: -12, - chattedCount: 0, - helpUsed: false, - giftsGiven: 0, - inventory: [], - recruited: false, - }, - }, - currentNpcBattleMode: 'fight', - currentNpcBattleOutcome: null, - }), - ); - - const response = await httpRequest( - `${baseUrl}/api/runtime/story/actions/resolve`, - withBearer(entry.token, { - method: 'POST', - body: JSON.stringify({ - sessionId: 'runtime-main', - clientVersion: 0, - action: { - type: 'story_choice', - functionId: 'battle_finisher_window', - }, - }), - }), - ); - const payload = (await response.json()) as { - viewModel: { - encounter: null; - status: { - inBattle: boolean; - currentNpcBattleOutcome: string | null; - }; - availableOptions: Array<{ - functionId: string; - }>; - }; - presentation: { - battle: { - outcome: string; - damageDealt: number; - } | null; - }; - snapshot: { - gameState: { - currentNpcBattleOutcome: string | null; - playerProgression: { - level: number; - totalXp: number; - lastGrantedSource: string | null; - }; - runtimeStats: { - hostileNpcsDefeated: number; - }; - }; - }; - }; - - assert.equal(response.status, 200); - assert.equal(payload.viewModel.encounter, null); - assert.equal(payload.viewModel.status.inBattle, false); - assert.equal( - payload.viewModel.status.currentNpcBattleOutcome, - 'fight_victory', - ); - assert.equal(payload.presentation.battle?.outcome, 'victory'); - assert.ok((payload.presentation.battle?.damageDealt ?? 0) >= 12); - assert.ok(payload.snapshot.gameState.playerProgression.totalXp > 0); - assert.equal( - payload.snapshot.gameState.playerProgression.lastGrantedSource, - 'hostile_npc', - ); - assert.equal( - payload.snapshot.gameState.runtimeStats.hostileNpcsDefeated, - 1, - ); - assert.ok( - payload.viewModel.availableOptions.some( - (option) => option.functionId === 'idle_observe_signs', - ), - ); - - const snapshotResponse = await httpRequest( - `${baseUrl}/api/runtime/save/snapshot`, - { - headers: { - Authorization: `Bearer ${entry.token}`, - }, - }, - ); - const snapshotPayload = (await snapshotResponse.json()) as { - gameState: { - inBattle: boolean; - currentEncounter: unknown; - sceneHostileNpcs: unknown[]; - currentNpcBattleOutcome: string | null; - }; - }; - - assert.equal(snapshotResponse.status, 200); - assert.equal(snapshotPayload.gameState.inBattle, false); - assert.equal(snapshotPayload.gameState.currentEncounter, null); - assert.deepEqual(snapshotPayload.gameState.sceneHostileNpcs, []); - assert.equal( - snapshotPayload.gameState.currentNpcBattleOutcome, - 'fight_victory', - ); - }); -}); - -test('runtime story state exposes the single-action combat option pool with runtime payload metadata', async () => { - await withTestServer('combat-state-options', async ({ baseUrl }) => { - const entry = await authEntry(baseUrl, 'story_combat_state', 'secret123'); - const playerCharacter = { - ...requirePlayerCharacter(), - skills: [ - { - id: 'slash', - name: '试锋斩', - animation: 'attack', - damage: 18, - manaCost: 4, - cooldownTurns: 2, - range: 1, - style: 'steady', - }, - { - id: 'wind-step', - name: '断风步', - animation: 'attack', - damage: 12, - manaCost: 2, - cooldownTurns: 0, - range: 1, - style: 'steady', - }, - ], - }; - - await putSnapshot( - baseUrl, - entry.token, - createTask6GameState({ - playerCharacter, - currentEncounter: { - kind: 'npc', - id: 'npc_bandit_01', - npcName: '断桥匪首', - npcDescription: '手提短刀的拦路匪徒', - context: '桥口劫匪', - hostile: true, - }, - npcInteractionActive: false, - sceneHostileNpcs: [ - { - id: 'npc_bandit_01', - name: '断桥匪首', - hp: 36, - maxHp: 36, - description: '桥口劫匪', - }, - ], - inBattle: true, - playerMana: 6, - playerMaxMana: 16, - playerSkillCooldowns: { - slash: 2, - 'wind-step': 0, - }, - playerInventory: [ - { - id: 'focus-tonic', - category: '消耗品', - name: '凝神灵液', - quantity: 1, - rarity: 'rare', - tags: ['mana'], - useProfile: { - manaRestore: 6, - }, - }, - ], - npcStates: { - npc_bandit_01: { - affinity: -12, - chattedCount: 0, - helpUsed: false, - giftsGiven: 0, - inventory: [], - recruited: false, - }, - }, - currentNpcBattleMode: 'fight', - }), - ); - - const response = await httpRequest( - `${baseUrl}/api/runtime/story/state/runtime-main`, - { - headers: { - Authorization: `Bearer ${entry.token}`, - }, - }, - ); - const payload = (await response.json()) as { - viewModel: { - status: { - inBattle: boolean; - }; - availableOptions: Array<{ - functionId: string; - actionText: string; - payload?: { - skillId?: string; - itemId?: string; - }; - disabled?: boolean; - reason?: string; - }>; - }; - }; - - assert.equal(response.status, 200); - assert.equal(payload.viewModel.status.inBattle, true); - assert.deepEqual( - payload.viewModel.availableOptions.map((option) => option.functionId), - [ - 'battle_attack_basic', - 'battle_recover_breath', - 'inventory_use', - 'battle_use_skill', - 'battle_use_skill', - 'battle_escape_breakout', - ], - ); - - const itemOption = payload.viewModel.availableOptions[2]; - assert.equal(itemOption?.functionId, 'inventory_use'); - assert.equal(itemOption?.payload?.itemId, 'focus-tonic'); - assert.equal(itemOption?.disabled, undefined); - - const slashOption = payload.viewModel.availableOptions[3]; - assert.equal(slashOption?.actionText, '试锋斩'); - assert.equal(slashOption?.payload?.skillId, 'slash'); - assert.equal(slashOption?.disabled, true); - assert.match(slashOption?.reason ?? '', /冷却中/u); - - const windStepOption = payload.viewModel.availableOptions[4]; - assert.equal(windStepOption?.actionText, '断风步'); - assert.equal(windStepOption?.payload?.skillId, 'wind-step'); - assert.equal(windStepOption?.disabled, undefined); - }); -}); - -test('runtime story actions resolve battle_use_skill as a single ongoing combat turn', async () => { - await withTestServer('combat-use-skill', async ({ baseUrl }) => { - const entry = await authEntry( - baseUrl, - 'story_combat_use_skill', - 'secret123', - ); - const playerCharacter = { - ...requirePlayerCharacter(), - skills: [ - { - id: 'slash', - name: '试锋斩', - animation: 'attack', - damage: 18, - manaCost: 4, - cooldownTurns: 2, - range: 1, - style: 'steady', - buildBuffs: [ - { - id: 'slash:buff', - sourceType: 'skill', - sourceId: 'slash', - name: '试锋余势', - tags: ['快剑'], - durationTurns: 2, - }, - ], - }, - ], - }; - - await putSnapshot( - baseUrl, - entry.token, - createTask6GameState({ - playerCharacter, - currentEncounter: { - kind: 'npc', - id: 'npc_bandit_01', - npcName: '断桥匪首', - npcDescription: '手提短刀的拦路匪徒', - context: '桥口劫匪', - hostile: true, - }, - npcInteractionActive: false, - sceneHostileNpcs: [ - { - id: 'npc_bandit_01', - name: '断桥匪首', - hp: 80, - maxHp: 80, - description: '桥口劫匪', - }, - ], - inBattle: true, - playerHp: 32, - playerMaxHp: 40, - playerMana: 9, - playerMaxMana: 16, - playerSkillCooldowns: {}, - activeBuildBuffs: [], - npcStates: { - npc_bandit_01: { - affinity: -12, - chattedCount: 0, - helpUsed: false, - giftsGiven: 0, - inventory: [], - recruited: false, - }, - }, - currentNpcBattleMode: 'fight', - }), - ); - - const response = await httpRequest( - `${baseUrl}/api/runtime/story/actions/resolve`, - withBearer(entry.token, { - method: 'POST', - body: JSON.stringify({ - sessionId: 'runtime-main', - clientVersion: 0, - action: { - type: 'story_choice', - functionId: 'battle_use_skill', - payload: { - skillId: 'slash', - }, - }, - }), - }), - ); - const payload = (await response.json()) as { - serverVersion: number; - viewModel: { - player: { - mana: number; - }; - status: { - inBattle: boolean; - }; - availableOptions: Array<{ - functionId: string; - actionText: string; - payload?: { - skillId?: string; - }; - disabled?: boolean; - reason?: string; - }>; - }; - presentation: { - resultText: string; - storyText: string; - battle: { - outcome: string; - damageDealt: number; - } | null; - }; - snapshot: { - gameState: { - playerMana: number; - playerSkillCooldowns: Record; - activeBuildBuffs: Array<{ - id: string; - }>; - }; - }; - patches: Array<{ - type: string; - functionId?: string; - }>; - }; - - assert.equal(response.status, 200); - assert.equal(payload.serverVersion, 1); - assert.equal(payload.presentation.battle?.outcome, 'ongoing'); - assert.ok((payload.presentation.battle?.damageDealt ?? 0) > 0); - assert.equal( - payload.presentation.storyText, - payload.presentation.resultText, - ); - assert.match(payload.presentation.storyText, /试锋斩/u); - assert.equal(payload.viewModel.status.inBattle, true); - assert.equal(payload.viewModel.player.mana, 5); - assert.equal(payload.snapshot.gameState.playerMana, 5); - assert.equal(payload.snapshot.gameState.playerSkillCooldowns.slash, 2); - assert.equal( - payload.snapshot.gameState.activeBuildBuffs[0]?.id, - 'slash:buff', - ); - assert.ok( - payload.patches.some( - (patch) => - patch.type === 'battle_resolved' && - patch.functionId === 'battle_use_skill', - ), - ); - - const skillOption = payload.viewModel.availableOptions.find( - (option) => - option.functionId === 'battle_use_skill' && - option.payload?.skillId === 'slash', - ); - assert.ok(skillOption); - assert.equal(skillOption.actionText, '试锋斩'); - assert.equal(skillOption.disabled, true); - assert.match(skillOption.reason ?? '', /冷却中/u); - }); -}); - -test('runtime story actions resolve inventory_use as a single ongoing combat turn', async () => { - await withTestServer('combat-use-item', async ({ baseUrl }) => { - const entry = await authEntry(baseUrl, 'story_combat_item', 'secret123'); - - await putSnapshot( - baseUrl, - entry.token, - createTask6GameState({ - currentEncounter: { - kind: 'npc', - id: 'npc_bandit_01', - npcName: '断桥匪首', - npcDescription: '手提短刀的拦路匪徒', - context: '桥口劫匪', - hostile: true, - }, - npcInteractionActive: false, - sceneHostileNpcs: [ - { - id: 'npc_bandit_01', - name: '断桥匪首', - hp: 80, - maxHp: 80, - description: '桥口劫匪', - }, - ], - inBattle: true, - playerHp: 20, - playerMaxHp: 40, - playerMana: 4, - playerMaxMana: 16, - playerSkillCooldowns: { - slash: 2, - }, - activeBuildBuffs: [], - playerInventory: [ - { - id: 'focus-tonic', - category: '消耗品', - name: '凝神灵液', - quantity: 1, - rarity: 'rare', - tags: ['mana', 'healing'], - useProfile: { - hpRestore: 12, - manaRestore: 6, - cooldownReduction: 1, - buildBuffs: [ - { - id: 'focus-tonic:buff', - sourceType: 'item', - sourceId: 'focus-tonic', - name: '凝神增益', - tags: ['快剑'], - durationTurns: 2, - }, - ], - }, - }, - ], - npcStates: { - npc_bandit_01: { - affinity: -12, - chattedCount: 0, - helpUsed: false, - giftsGiven: 0, - inventory: [], - recruited: false, - }, - }, - currentNpcBattleMode: 'fight', - }), - ); - - const response = await httpRequest( - `${baseUrl}/api/runtime/story/actions/resolve`, - withBearer(entry.token, { - method: 'POST', - body: JSON.stringify({ - sessionId: 'runtime-main', - clientVersion: 0, - action: { - type: 'story_choice', - functionId: 'inventory_use', - payload: { - itemId: 'focus-tonic', - }, - }, - }), - }), - ); - const payload = (await response.json()) as { - serverVersion: number; - viewModel: { - player: { - hp: number; - mana: number; - }; - status: { - inBattle: boolean; - }; - availableOptions: Array<{ - functionId: string; - actionText: string; - payload?: { - skillId?: string; - itemId?: string; - }; - disabled?: boolean; - reason?: string; - }>; - }; - presentation: { - resultText: string; - storyText: string; - battle: { - outcome: string; - damageTaken: number; - } | null; - }; - snapshot: { - gameState: { - playerHp: number; - playerMana: number; - playerSkillCooldowns: Record; - runtimeStats: { - itemsUsed: number; - }; - playerInventory: unknown[]; - activeBuildBuffs: Array<{ - id: string; - }>; - }; - }; - patches: Array<{ - type: string; - functionId?: string; - }>; - }; - - assert.equal(response.status, 200); - assert.equal(payload.serverVersion, 1); - assert.equal(payload.presentation.battle?.outcome, 'ongoing'); - assert.equal(payload.presentation.battle?.damageTaken, 8); - assert.equal( - payload.presentation.storyText, - payload.presentation.resultText, - ); - assert.match(payload.presentation.storyText, /凝神灵液/u); - assert.equal(payload.viewModel.status.inBattle, true); - assert.equal(payload.viewModel.player.hp, 24); - assert.equal(payload.viewModel.player.mana, 10); - assert.equal(payload.snapshot.gameState.playerHp, 24); - assert.equal(payload.snapshot.gameState.playerMana, 10); - assert.equal(payload.snapshot.gameState.playerSkillCooldowns.slash, 0); - assert.equal(payload.snapshot.gameState.runtimeStats.itemsUsed, 1); - assert.deepEqual(payload.snapshot.gameState.playerInventory, []); - assert.equal( - payload.snapshot.gameState.activeBuildBuffs[0]?.id, - 'focus-tonic:buff', - ); - assert.ok( - payload.patches.some( - (patch) => - patch.type === 'battle_resolved' && - patch.functionId === 'inventory_use', - ), - ); - - const inventoryOption = payload.viewModel.availableOptions.find( - (option) => option.functionId === 'inventory_use', - ); - assert.ok(inventoryOption); - assert.equal(inventoryOption.disabled, true); - assert.match(inventoryOption.reason ?? '', /暂无可用物品/u); - - const skillOption = payload.viewModel.availableOptions.find( - (option) => - option.functionId === 'battle_use_skill' && - option.payload?.skillId === 'slash', - ); - assert.ok(skillOption); - assert.equal(skillOption.actionText, '试锋斩'); - assert.equal(skillOption.disabled, undefined); - }); -}); - -test('runtime story actions resolve inventory_use and persist updated resources', async () => { - await withTestServer('task6-inventory-use', async ({ baseUrl }) => { - const entry = await authEntry( - baseUrl, - 'story_task6_inventory', - 'secret123', - ); - - await putSnapshot( - baseUrl, - entry.token, - createTask6GameState({ - playerInventory: [ - { - id: 'focus-tonic', - category: '消耗品', - name: '凝神灵液', - quantity: 1, - rarity: 'rare', - tags: ['healing', 'mana'], - useProfile: { - hpRestore: 12, - manaRestore: 6, - cooldownReduction: 1, - buildBuffs: [ - { - id: 'focus-tonic:buff', - sourceType: 'item', - sourceId: 'focus-tonic', - name: '凝神增益', - tags: ['快剑'], - durationTurns: 2, - }, - ], - }, - }, - ], - }), - ); - - const response = await httpRequest( - `${baseUrl}/api/runtime/story/actions/resolve`, - withBearer(entry.token, { - method: 'POST', - body: JSON.stringify({ - sessionId: 'runtime-main', - clientVersion: 0, - action: { - type: 'story_choice', - functionId: 'inventory_use', - payload: { - itemId: 'focus-tonic', - }, - }, - }), - }), - ); - const payload = (await response.json()) as { - serverVersion: number; - viewModel: { - player: { - hp: number; - mana: number; - }; - }; - presentation: { - storyText: string; - toast: string | null; - }; - snapshot: { - gameState: { - runtimeStats: { - itemsUsed: number; - }; - playerInventory: unknown[]; - }; - }; - }; - - assert.equal(response.status, 200); - assert.equal(payload.serverVersion, 1); - assert.equal(payload.viewModel.player.hp, 44); - assert.equal(payload.viewModel.player.mana, 15); - assert.match(payload.presentation.storyText, /凝神灵液/u); - assert.match(payload.presentation.toast ?? '', /Build/u); - assert.equal(payload.snapshot.gameState.runtimeStats.itemsUsed, 1); - assert.deepEqual(payload.snapshot.gameState.playerInventory, []); - }); -}); - -test('runtime story actions resolve equipment_equip and persist updated loadout', async () => { - await withTestServer('task6-equipment-equip', async ({ baseUrl }) => { - const entry = await authEntry(baseUrl, 'story_task6_equip', 'secret123'); - - await putSnapshot( - baseUrl, - entry.token, - createTask6GameState({ - playerInventory: [ - { - id: 'ward-mail', - category: '护甲', - name: '镇岳甲', - quantity: 1, - rarity: 'rare', - tags: ['armor', '守御', '护体'], - equipmentSlotId: 'armor', - statProfile: { - maxHpBonus: 24, - outgoingDamageBonus: 0.04, - incomingDamageMultiplier: 0.92, - }, - buildProfile: { - role: '守御', - tags: ['守御', '护体'], - synergy: ['守御', '护体'], - forgeRank: 0, - }, - }, - ], - }), - ); - - const response = await httpRequest( - `${baseUrl}/api/runtime/story/actions/resolve`, - withBearer(entry.token, { - method: 'POST', - body: JSON.stringify({ - sessionId: 'runtime-main', - clientVersion: 0, - action: { - type: 'story_choice', - functionId: 'equipment_equip', - payload: { - itemId: 'ward-mail', - }, - }, - }), - }), - ); - const payload = (await response.json()) as { - viewModel: { - player: { - maxHp: number; - }; - }; - presentation: { - storyText: string; - }; - snapshot: { - gameState: { - playerInventory: unknown[]; - playerEquipment: { - armor: { - id: string; - name: string; - } | null; - }; - }; - }; - }; - - assert.equal(response.status, 200); - assert.ok(payload.viewModel.player.maxHp > 40); - assert.match(payload.presentation.storyText, /镇岳甲/u); - assert.equal(payload.snapshot.gameState.playerInventory.length, 0); - assert.equal( - payload.snapshot.gameState.playerEquipment.armor?.id, - 'ward-mail', - ); - assert.equal( - payload.snapshot.gameState.playerEquipment.armor?.name, - '镇岳甲', - ); - }); -}); - -test('runtime story actions resolve npc_recruit directly on the server', async () => { - await withTestServer('task6-recruit-direct', async ({ baseUrl }) => { - const entry = await authEntry(baseUrl, 'story_task6_recruit', 'secret123'); - - await putSnapshot( - baseUrl, - entry.token, - createTask6GameState({ - currentEncounter: { - kind: 'npc', - id: 'npc_guard_01', - npcName: '守桥人', - npcDescription: '在桥口驻守多年的旧识', - context: '桥口守卫', - characterId: 'bridge-guard', - }, - npcInteractionActive: true, - npcStates: { - npc_guard_01: { - affinity: 64, - chattedCount: 2, - helpUsed: false, - giftsGiven: 0, - inventory: [], - recruited: false, - }, - }, - companions: [], - roster: [], - }), - ); - - const response = await httpRequest( - `${baseUrl}/api/runtime/story/actions/resolve`, - withBearer(entry.token, { - method: 'POST', - body: JSON.stringify({ - sessionId: 'runtime-main', - clientVersion: 0, - action: { - type: 'story_choice', - functionId: 'npc_recruit', - payload: { - preludeText: - '守桥人:你既然想清楚了,那我就跟你走这一程。', - }, - }, - }), - }), - ); - const payload = (await response.json()) as { - presentation: { - storyText: string; - }; - snapshot: { - gameState: { - currentEncounter: unknown; - npcInteractionActive: boolean; - companions: Array<{ - npcId: string; - characterId: string; - joinedAtAffinity: number; - maxHp: number; - maxMana: number; - }>; - roster: Array; - npcStates: { - npc_guard_01: { - recruited: boolean; - firstMeaningfulContactResolved: boolean; - }; - }; - }; - }; - viewModel: { - companions: Array<{ - npcId: string; - }>; - }; - }; - - assert.equal(response.status, 200); - assert.match(payload.presentation.storyText, /守桥人/u); - assert.equal(payload.snapshot.gameState.currentEncounter, null); - assert.equal(payload.snapshot.gameState.npcInteractionActive, false); - assert.equal(payload.snapshot.gameState.companions.length, 1); - assert.equal(payload.snapshot.gameState.companions[0]?.npcId, 'npc_guard_01'); - assert.equal( - payload.snapshot.gameState.companions[0]?.joinedAtAffinity, - 64, - ); - assert.ok((payload.snapshot.gameState.companions[0]?.maxHp ?? 0) > 0); - assert.ok((payload.snapshot.gameState.companions[0]?.maxMana ?? 0) > 0); - assert.deepEqual(payload.snapshot.gameState.roster, []); - assert.equal( - payload.snapshot.gameState.npcStates.npc_guard_01.recruited, - true, - ); - assert.equal( - payload.snapshot.gameState.npcStates.npc_guard_01 - .firstMeaningfulContactResolved, - true, - ); - assert.equal(payload.viewModel.companions[0]?.npcId, 'npc_guard_01'); - }); -}); - -test('runtime story actions resolve npc_recruit with full-party replacement on the server', async () => { - await withTestServer('task6-recruit-swap', async ({ baseUrl }) => { - const entry = await authEntry( - baseUrl, - 'story_task6_recruit_swap', - 'secret123', - ); - - await putSnapshot( - baseUrl, - entry.token, - createTask6GameState({ - currentEncounter: { - kind: 'npc', - id: 'npc_scout_02', - npcName: '追迹人', - npcDescription: '擅长沿痕追人的同路者', - context: '山道追迹', - characterId: 'trail-scout', - }, - npcInteractionActive: true, - npcStates: { - npc_scout_02: { - affinity: 71, - chattedCount: 3, - helpUsed: false, - giftsGiven: 0, - inventory: [], - recruited: false, - }, - npc_old_guard: { - affinity: 48, - chattedCount: 1, - helpUsed: false, - giftsGiven: 0, - inventory: [], - recruited: true, - }, - npc_old_medic: { - affinity: 55, - chattedCount: 1, - helpUsed: false, - giftsGiven: 0, - inventory: [], - recruited: true, - }, - }, - companions: [ - { - npcId: 'npc_old_guard', - characterId: 'old-guard', - joinedAtAffinity: 48, - hp: 180, - maxHp: 180, - mana: 999, - maxMana: 999, - skillCooldowns: {}, - animationState: 'idle', - actionMode: 'idle', - offsetX: 0, - offsetY: 0, - transitionMs: 0, - }, - { - npcId: 'npc_old_medic', - characterId: 'old-medic', - joinedAtAffinity: 55, - hp: 170, - maxHp: 170, - mana: 999, - maxMana: 999, - skillCooldowns: {}, - animationState: 'idle', - actionMode: 'idle', - offsetX: 0, - offsetY: 0, - transitionMs: 0, - }, - ], - roster: [], - }), - ); - - const response = await httpRequest( - `${baseUrl}/api/runtime/story/actions/resolve`, - withBearer(entry.token, { - method: 'POST', - body: JSON.stringify({ - sessionId: 'runtime-main', - clientVersion: 0, - action: { - type: 'story_choice', - functionId: 'npc_recruit', - payload: { - releaseNpcId: 'npc_old_guard', - preludeText: - '追迹人:如果你真要带我同行,那就先把你队里的位置理顺。', - }, - }, - }), - }), - ); - const payload = (await response.json()) as { - snapshot: { - gameState: { - companions: Array<{ npcId: string }>; - roster: Array<{ npcId: string }>; - }; - }; - viewModel: { - companions: Array<{ npcId: string }>; - }; - }; - - assert.equal(response.status, 200); - assert.deepEqual( - payload.snapshot.gameState.companions.map((companion) => companion.npcId), - ['npc_scout_02', 'npc_old_medic'], - ); - assert.deepEqual( - payload.snapshot.gameState.roster.map((companion) => companion.npcId), - ['npc_old_guard'], - ); - assert.deepEqual( - payload.viewModel.companions.map((companion) => companion.npcId), - ['npc_scout_02', 'npc_old_medic'], - ); - }); -}); - -test('runtime story actions resolve npc_trade buy transactions on the server', async () => { - await withTestServer('task6-trade-buy', async ({ baseUrl }) => { - const entry = await authEntry( - baseUrl, - 'story_task6_trade_buy', - 'secret123', - ); - - await putSnapshot( - baseUrl, - entry.token, - createTask6GameState({ - currentEncounter: { - kind: 'npc', - id: 'npc_merchant_02', - npcName: '梁伯', - npcDescription: '携带杂货箱的老人', - context: '沿街商贩', - characterId: 'merchant-test', - }, - npcInteractionActive: true, - playerCurrency: 90, - npcStates: { - npc_merchant_02: { - affinity: 58, - chattedCount: 1, - helpUsed: false, - giftsGiven: 0, - inventory: [ - { - id: 'merchant-essence', - category: '消耗品', - name: '回气散', - quantity: 3, - rarity: 'uncommon', - tags: ['mana'], - }, - ], - recruited: false, - }, - }, - }), - ); - - const response = await httpRequest( - `${baseUrl}/api/runtime/story/actions/resolve`, - withBearer(entry.token, { - method: 'POST', - body: JSON.stringify({ - sessionId: 'runtime-main', - clientVersion: 0, - action: { - type: 'story_choice', - functionId: 'npc_trade', - payload: { - mode: 'buy', - itemId: 'merchant-essence', - quantity: 2, - }, - }, - }), - }), - ); - const payload = (await response.json()) as { - presentation: { - storyText: string; - }; - snapshot: { - gameState: { - playerCurrency: number; - playerInventory: Array<{ name: string; quantity: number }>; - npcStates: { - npc_merchant_02: { - inventory: Array<{ id: string; quantity: number }>; - }; - }; - }; - }; - }; - - assert.equal(response.status, 200); - assert.match(payload.presentation.storyText, /回气散/u); - assert.ok(payload.snapshot.gameState.playerCurrency < 90); - assert.equal(payload.snapshot.gameState.playerInventory[0]?.name, '回气散'); - assert.equal(payload.snapshot.gameState.playerInventory[0]?.quantity, 2); - assert.equal( - payload.snapshot.gameState.npcStates.npc_merchant_02.inventory[0] - ?.quantity, - 1, - ); - }); -}); - -test('runtime story actions resolve npc_gift and persist affinity changes', async () => { - await withTestServer('task6-gift', async ({ baseUrl }) => { - const entry = await authEntry(baseUrl, 'story_task6_gift', 'secret123'); - - await putSnapshot( - baseUrl, - entry.token, - createTask6GameState({ - currentEncounter: { - kind: 'npc', - id: 'npc_merchant_03', - npcName: '沈娘', - npcDescription: '对药性很敏感的行脚商', - context: '药商', - characterId: 'merchant-gift', - }, - npcInteractionActive: true, - playerInventory: [ - { - id: 'gift-herb', - category: '材料', - name: '暖息草', - quantity: 1, - rarity: 'rare', - tags: ['material', 'mana'], - }, - ], - npcStates: { - npc_merchant_03: { - affinity: 22, - chattedCount: 0, - helpUsed: false, - giftsGiven: 0, - inventory: [], - recruited: false, - }, - }, - }), - ); - - const response = await httpRequest( - `${baseUrl}/api/runtime/story/actions/resolve`, - withBearer(entry.token, { - method: 'POST', - body: JSON.stringify({ - sessionId: 'runtime-main', - clientVersion: 0, - action: { - type: 'story_choice', - functionId: 'npc_gift', - payload: { - itemId: 'gift-herb', - }, - }, - }), - }), - ); - const payload = (await response.json()) as { - snapshot: { - gameState: { - playerInventory: unknown[]; - npcStates: { - npc_merchant_03: { - affinity: number; - giftsGiven: number; - }; - }; - }; - }; - patches: Array<{ type: string }>; - }; - - assert.equal(response.status, 200); - assert.equal(payload.snapshot.gameState.playerInventory.length, 0); - assert.ok( - payload.snapshot.gameState.npcStates.npc_merchant_03.affinity > 22, - ); - assert.equal( - payload.snapshot.gameState.npcStates.npc_merchant_03.giftsGiven, - 1, - ); - assert.ok( - payload.patches.some((patch) => patch.type === 'npc_affinity_changed'), - ); - }); -}); - -test('runtime story actions resolve npc_quest_accept and persist accepted quests', async () => { - await withTestServer('task6-quest-accept', async ({ baseUrl }) => { - const entry = await authEntry( - baseUrl, - 'story_task6_quest_accept', - 'secret123', - ); - - await putSnapshot( - baseUrl, - entry.token, - createTask6GameState({ - currentEncounter: { - kind: 'npc', - id: 'npc_scout_01', - npcName: '巡路人', - npcDescription: '熟悉桥口风向的探子', - context: '巡路人', - characterId: 'scout-quest', - }, - currentScenePreset: QUEST_BATTLE_SCENE, - npcInteractionActive: true, - npcStates: { - npc_scout_01: { - affinity: 16, - chattedCount: 0, - helpUsed: false, - giftsGiven: 0, - inventory: [], - recruited: false, - }, - }, - }), - ); - - const response = await httpRequest( - `${baseUrl}/api/runtime/story/actions/resolve`, - withBearer(entry.token, { - method: 'POST', - body: JSON.stringify({ - sessionId: 'runtime-main', - clientVersion: 0, - action: { - type: 'story_choice', - functionId: 'npc_quest_accept', - }, - }), - }), - ); - const payload = (await response.json()) as { - snapshot: { - gameState: { - quests: Array<{ issuerNpcId: string; status: string }>; - runtimeStats: { - questsAccepted: number; - }; - }; - }; - presentation: { - storyText: string; - }; - }; - - assert.equal(response.status, 200); - assert.equal(payload.snapshot.gameState.quests.length, 1); - assert.equal( - payload.snapshot.gameState.quests[0]?.issuerNpcId, - 'npc_scout_01', - ); - assert.equal(payload.snapshot.gameState.quests[0]?.status, 'active'); - assert.equal(payload.snapshot.gameState.runtimeStats.questsAccepted, 1); - assert.match(payload.presentation.storyText, /正式把委托交到了你手上/u); - }); -}); - -test('runtime story actions accept pending npc quest offers from saved chat state', async () => { - await withTestServer( - 'task6-quest-accept-pending-offer', - async ({ baseUrl }) => { - const entry = await authEntry( - baseUrl, - 'story_q_accept_pending', - 'secret123', - ); - const seededQuest = buildQuestForEncounter({ - issuerNpcId: 'npc_scout_01', - issuerNpcName: '巡路人', - roleText: '巡路人', - scene: QUEST_BATTLE_SCENE, - worldType: 'WUXIA', - currentQuests: [], - }); - assert.ok(seededQuest); - const pendingQuest = { - ...seededQuest, - id: 'quest-pending-offer', - }; - - await putSnapshot( - baseUrl, - entry.token, - createTask6GameState({ - currentEncounter: { - kind: 'npc', - id: 'npc_scout_01', - npcName: '巡路人', - npcDescription: '熟悉桥口风向的探子', - context: '巡路人', - characterId: 'scout-quest', - }, - currentScenePreset: QUEST_BATTLE_SCENE, - npcInteractionActive: true, - npcStates: { - npc_scout_01: { - affinity: 16, - chattedCount: 0, - helpUsed: false, - giftsGiven: 0, - inventory: [], - recruited: false, - }, - }, - }), - createPendingQuestOfferCurrentStory(pendingQuest), - ); - - const response = await httpRequest( - `${baseUrl}/api/runtime/story/actions/resolve`, - withBearer(entry.token, { - method: 'POST', - body: JSON.stringify({ - sessionId: 'runtime-main', - clientVersion: 0, - action: { - type: 'story_choice', - functionId: 'npc_quest_accept', - }, - }), - }), - ); - const payload = (await response.json()) as { - snapshot: { - gameState: { - quests: Array<{ id: string; issuerNpcId: string; status: string }>; - }; - currentStory: { - displayMode?: string; - options?: Array<{ actionText?: string }>; - dialogue?: Array<{ speaker?: string; text?: string }>; - npcChatState?: { - pendingQuestOffer?: unknown; - }; - }; - }; - }; - - assert.equal(response.status, 200); - assert.equal(payload.snapshot.gameState.quests.length, 1); - assert.equal( - payload.snapshot.gameState.quests[0]?.id, - 'quest-pending-offer', - ); - assert.equal( - payload.snapshot.gameState.quests[0]?.issuerNpcId, - 'npc_scout_01', - ); - assert.equal(payload.snapshot.currentStory.displayMode, 'dialogue'); - assert.equal( - payload.snapshot.currentStory.npcChatState?.pendingQuestOffer ?? null, - null, - ); - assert.deepEqual( - payload.snapshot.currentStory.options?.map( - (option) => option.actionText, - ), - [ - '这件事里你最担心哪一步', - '我回来时你最想先知道什么', - '除了这份委托,你还想提醒我什么', - ], - ); - assert.equal( - payload.snapshot.currentStory.dialogue?.at(-2)?.text, - '这件事我愿意接下,你把关键要点交给我。', - ); - assert.match( - payload.snapshot.currentStory.dialogue?.at(-1)?.text ?? '', - /那就拜托你了。/u, - ); - }, - ); -}); - -test('runtime story actions progress quests from combat victories and npc turn-ins', async () => { - await withTestServer('task6-quest-progress-turnin', async ({ baseUrl }) => { - const entry = await authEntry(baseUrl, 'story_qp_turnin', 'secret123'); - const quest = buildQuestForEncounter({ - issuerNpcId: 'npc_bandit_01', - issuerNpcName: '断桥匪首', - roleText: '桥口劫匪', - scene: QUEST_BATTLE_SCENE, - worldType: 'WUXIA', - currentQuests: [], - }); - assert.ok(quest); - - await putSnapshot(baseUrl, entry.token, { - ...createTask6GameState({ - currentEncounter: { - kind: 'npc', - id: 'npc_bandit_01', - npcName: '断桥匪首', - npcDescription: '手提短刀的拦路匪徒', - context: '桥口劫匪', - characterId: 'bandit-quest', - hostile: true, - }, - currentScenePreset: QUEST_BATTLE_SCENE, - sceneHostileNpcs: [ - { - id: 'npc_bandit_01', - name: '断桥匪首', - hp: 8, - maxHp: 28, - description: '桥口劫匪', - }, - ], - inBattle: true, - playerMana: 20, - playerMaxMana: 20, - currentNpcBattleMode: 'fight', - npcInteractionActive: false, - quests: [quest], - npcStates: { - npc_bandit_01: { - affinity: -12, - chattedCount: 0, - helpUsed: false, - giftsGiven: 0, - inventory: [], - recruited: false, - }, - }, - }), - }); - - const battleResponse = await httpRequest( - `${baseUrl}/api/runtime/story/actions/resolve`, - withBearer(entry.token, { - method: 'POST', - body: JSON.stringify({ - sessionId: 'runtime-main', - clientVersion: 0, - action: { - type: 'story_choice', - functionId: 'battle_finisher_window', - }, - }), - }), - ); - const battlePayload = (await battleResponse.json()) as { - snapshot: { - gameState: { - quests: Array<{ - status: string; - objective: { kind: string }; - }>; - }; - }; - }; - - assert.equal(battleResponse.status, 200); - assert.equal(battlePayload.snapshot.gameState.quests[0]?.status, 'active'); - assert.equal( - battlePayload.snapshot.gameState.quests[0]?.objective.kind, - 'talk_to_npc', - ); - - const afterHostile = applyQuestSignal([quest], { - kind: 'hostile_npc_defeated', - sceneId: QUEST_BATTLE_SCENE.id, - hostileNpcId: 'npc_bandit_01', - }).nextQuests[0]; - assert.ok(afterHostile); - const readyQuest = applyQuestSignal([afterHostile], { - kind: 'npc_talk_completed', - npcId: 'npc_bandit_01', - }).nextQuests[0]; - assert.ok(readyQuest); - - await putSnapshot( - baseUrl, - entry.token, - createTask6GameState({ - currentEncounter: { - kind: 'npc', - id: 'npc_bandit_01', - npcName: '断桥匪首', - npcDescription: '手提短刀的拦路匪徒', - context: '桥口劫匪', - characterId: 'bandit-quest', - }, - currentScenePreset: QUEST_BATTLE_SCENE, - npcInteractionActive: true, - playerCurrency: 12, - quests: [readyQuest], - npcStates: { - npc_bandit_01: { - affinity: 6, - chattedCount: 0, - helpUsed: false, - giftsGiven: 0, - inventory: [], - recruited: false, - }, - }, - }), - ); - - const turnInResponse = await httpRequest( - `${baseUrl}/api/runtime/story/actions/resolve`, - withBearer(entry.token, { - method: 'POST', - body: JSON.stringify({ - sessionId: 'runtime-main', - clientVersion: 0, - action: { - type: 'story_choice', - functionId: 'npc_quest_turn_in', - payload: { - questId: readyQuest.id, - }, - }, - }), - }), - ); - const turnInPayload = (await turnInResponse.json()) as { - snapshot: { - gameState: { - quests: Array<{ status: string }>; - playerCurrency: number; - playerProgression: { - level: number; - totalXp: number; - }; - playerInventory: Array<{ name: string }>; - npcStates: { - npc_bandit_01: { - affinity: number; - }; - }; - }; - }; - }; - - assert.equal(turnInResponse.status, 200); - assert.equal( - turnInPayload.snapshot.gameState.quests[0]?.status, - 'turned_in', - ); - assert.ok(turnInPayload.snapshot.gameState.playerCurrency > 12); - assert.ok(turnInPayload.snapshot.gameState.playerProgression.totalXp > 0); - assert.ok(turnInPayload.snapshot.gameState.playerProgression.level >= 1); - assert.ok(turnInPayload.snapshot.gameState.playerInventory.length > 0); - assert.ok( - turnInPayload.snapshot.gameState.npcStates.npc_bandit_01.affinity > 6, - ); - }); -}); - -test('runtime story actions resolve treasure_inspect and advance treasure quests on the server', async () => { - await withTestServer('task6-treasure', async ({ baseUrl }) => { - const entry = await authEntry(baseUrl, 'story_task6_treasure', 'secret123'); - const quest = buildQuestForEncounter({ - issuerNpcId: 'npc_researcher_01', - issuerNpcName: '碑下学人', - roleText: '考据学人', - scene: QUEST_TREASURE_SCENE, - worldType: 'WUXIA', - currentQuests: [], - }); - assert.ok(quest); - - await putSnapshot( - baseUrl, - entry.token, - createTask6GameState({ - currentEncounter: { - kind: 'treasure', - id: 'treasure-stone-box', - npcName: '残匣', - npcDescription: '匣盖和断碑之间卡着旧印。', - context: '残碑古道', - }, - currentScenePreset: QUEST_TREASURE_SCENE, - quests: [quest], - }), - ); - - const response = await httpRequest( - `${baseUrl}/api/runtime/story/actions/resolve`, - withBearer(entry.token, { - method: 'POST', - body: JSON.stringify({ - sessionId: 'runtime-main', - clientVersion: 0, - action: { - type: 'story_choice', - functionId: 'treasure_inspect', - }, - }), - }), - ); - const payload = (await response.json()) as { - snapshot: { - gameState: { - currentEncounter: unknown; - playerInventory: Array<{ name: string }>; - quests: Array<{ objective: { kind: string } }>; - }; - }; - presentation: { - storyText: string; - }; - }; - - assert.equal(response.status, 200); - assert.equal(payload.snapshot.gameState.currentEncounter, null); - assert.ok(payload.snapshot.gameState.playerInventory.length > 0); - assert.equal( - payload.snapshot.gameState.quests[0]?.objective.kind, - 'talk_to_npc', - ); - assert.match(payload.presentation.storyText, /仔细检查了\s*残匣/u); - }); -}); diff --git a/server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.ts b/server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.ts deleted file mode 100644 index f9012657..00000000 --- a/server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { Router } from 'express'; -import { z } from 'zod'; - -import type { - RuntimeStoryActionRequest, - RuntimeStoryStateRequest, -} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js'; -import type { AppContext } from '../../context.js'; -import { badRequest } from '../../errors.js'; -import { asyncHandler, sendApiResponse } from '../../http.js'; -import { requireJwtAuth } from '../../middleware/auth.js'; -import { routeMeta } from '../../middleware/routeMeta.js'; -import { getRuntimeStoryState } from '../../modules/rpg-runtime-story/RpgRuntimeStoryStateService.js'; -import { resolveRuntimeStoryAction } from '../../modules/rpg-runtime-story/RpgRuntimeStoryActionService.js'; - -const actionPayloadSchema = z.record(z.string(), z.unknown()); - -const runtimeStoryActionSchema = z.object({ - sessionId: z.string().trim().min(1), - clientVersion: z.number().int().min(0).optional(), - snapshot: z.unknown().optional(), - action: z.object({ - type: z.literal('story_choice'), - functionId: z.string().trim().min(1), - targetId: z.string().trim().optional(), - payload: actionPayloadSchema.optional().default({}), - }), -}); - -const runtimeStoryStateResolveSchema = z.object({ - sessionId: z.string().trim().min(1), - clientVersion: z.number().int().min(0).optional(), - snapshot: z.unknown().optional(), -}); - -export const RPG_RUNTIME_STORY_ROUTE_BASE_PATH = '/api/runtime/story'; - -export function createRpgRuntimeStoryRoutes(context: AppContext) { - const router = Router(); - const requireAuth = requireJwtAuth(context.config, context.userRepository); - - router.use(requireAuth); - - router.post( - '/actions/resolve', - routeMeta({ operation: 'runtime.story.actions.resolve' }), - asyncHandler(async (request, response) => { - const payload = runtimeStoryActionSchema.parse( - request.body, - ) as RuntimeStoryActionRequest; - sendApiResponse( - response, - await resolveRuntimeStoryAction({ - snapshotRepository: context.rpgRuntimeSnapshotRepository, - llmClient: context.llmClient, - userId: request.userId!, - request: payload, - }), - ); - }), - ); - - router.get( - '/state/:sessionId', - routeMeta({ operation: 'runtime.story.state.get' }), - asyncHandler(async (request, response) => { - const sessionId = request.params.sessionId?.trim() || ''; - if (!sessionId) { - throw badRequest('sessionId is required'); - } - - sendApiResponse( - response, - await getRuntimeStoryState({ - snapshotRepository: context.rpgRuntimeSnapshotRepository, - userId: request.userId!, - sessionId, - }), - ); - }), - ); - - router.post( - '/state/resolve', - routeMeta({ operation: 'runtime.story.state.resolve' }), - asyncHandler(async (request, response) => { - const payload = runtimeStoryStateResolveSchema.parse( - request.body, - ) as RuntimeStoryStateRequest; - sendApiResponse( - response, - await getRuntimeStoryState({ - snapshotRepository: context.rpgRuntimeSnapshotRepository, - userId: request.userId!, - sessionId: payload.sessionId, - clientVersion: payload.clientVersion, - snapshot: payload.snapshot, - }), - ); - }), - ); - - return router; -} diff --git a/server-node/src/routes/rpgRouteBoundaries.test.ts b/server-node/src/routes/rpgRouteBoundaries.test.ts deleted file mode 100644 index 820e317e..00000000 --- a/server-node/src/routes/rpgRouteBoundaries.test.ts +++ /dev/null @@ -1,524 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import type { AddressInfo } from 'node:net'; -import os from 'node:os'; -import path from 'node:path'; -import test from 'node:test'; - -import { createApp } from '../app.ts'; -import type { AppConfig } from '../config.ts'; -import { createAppContext } from '../server.ts'; -import { httpRequest, type TestRequestInit } from '../testHttp.ts'; - -function createTestConfig(testName: string): AppConfig { - const tempRoot = fs.mkdtempSync( - path.join(os.tmpdir(), `genarrative-rpg-routes-${testName}-`), - ); - - return { - nodeEnv: 'test', - projectRoot: tempRoot, - publicDir: path.join(tempRoot, 'public'), - logsDir: path.join(tempRoot, 'logs'), - dataDir: path.join(tempRoot, 'data'), - rawEnv: {}, - databaseUrl: `pg-mem://genarrative-rpg-routes-${testName}`, - serverAddr: ':0', - logLevel: 'silent', - editorApiEnabled: true, - assetsApiEnabled: true, - jwtSecret: 'test-secret', - jwtExpiresIn: '7d', - jwtIssuer: 'genarrative-rpg-routes-test', - llm: { - baseUrl: 'https://example.invalid', - apiKey: '', - model: 'test-model', - }, - dashScope: { - baseUrl: 'https://example.invalid', - apiKey: '', - imageModel: 'test-image-model', - requestTimeoutMs: 1000, - }, - smsAuth: { - enabled: true, - provider: 'mock', - endpoint: 'dypnsapi.aliyuncs.com', - accessKeyId: '', - accessKeySecret: '', - signName: 'Test Sign', - templateCode: '100001', - templateParamKey: 'code', - countryCode: '86', - schemeName: '', - codeLength: 6, - codeType: 1, - validTimeSeconds: 300, - intervalSeconds: 60, - duplicatePolicy: 1, - caseAuthPolicy: 1, - returnVerifyCode: false, - mockVerifyCode: '123456', - maxSendPerPhonePerDay: 20, - maxSendPerIpPerHour: 30, - maxVerifyFailuresPerPhonePerHour: 12, - maxVerifyFailuresPerIpPerHour: 24, - captchaTtlSeconds: 180, - captchaTriggerVerifyFailuresPerPhone: 3, - captchaTriggerVerifyFailuresPerIp: 5, - blockPhoneFailureThreshold: 6, - blockIpFailureThreshold: 10, - blockPhoneDurationMinutes: 30, - blockIpDurationMinutes: 30, - }, - wechatAuth: { - enabled: true, - provider: 'mock', - appId: '', - appSecret: '', - authorizeEndpoint: 'https://open.weixin.qq.com/connect/qrconnect', - accessTokenEndpoint: 'https://api.weixin.qq.com/sns/oauth2/access_token', - userInfoEndpoint: 'https://api.weixin.qq.com/sns/userinfo', - callbackPath: '/api/auth/wechat/callback', - defaultRedirectPath: '/', - mockUserId: 'mock_wechat_user', - mockUnionId: 'mock_wechat_union', - mockDisplayName: '微信旅人', - mockAvatarUrl: '', - }, - authSession: { - accessCookieName: 'genarrative_access_session', - accessCookieTtlSeconds: 7200, - accessCookieSecure: false, - accessCookieSameSite: 'Lax', - accessCookiePath: '/', - refreshCookieName: 'genarrative_refresh_session', - refreshSessionTtlDays: 30, - refreshCookieSecure: false, - refreshCookieSameSite: 'Lax', - refreshCookiePath: '/api/auth', - }, - }; -} - -async function withTestServer( - testName: string, - run: (options: { baseUrl: string }) => Promise, -) { - const context = await createAppContext(createTestConfig(testName)); - const app = createApp(context); - const server = await new Promise((resolve) => { - const nextServer = app.listen(0, '127.0.0.1', () => resolve(nextServer)); - }); - - try { - const address = server.address() as AddressInfo; - return await run({ - baseUrl: `http://127.0.0.1:${address.port}`, - }); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => { - if (error) { - reject(error); - return; - } - resolve(); - }); - }); - await context.db.close(); - } -} - -async function authEntry(baseUrl: string, username: string, password: string) { - const response = await httpRequest(`${baseUrl}/api/auth/entry`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - username, - password, - }), - }); - const payload = (await response.json()) as { - token: string; - user: { - id: string; - }; - }; - - assert.equal(response.status, 200); - assert.ok(payload.token); - return payload; -} - -function withBearer(token: string, init: TestRequestInit = {}) { - return { - ...init, - headers: { - ...(init.headers ?? {}), - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - } satisfies TestRequestInit; -} - -async function putSnapshot( - baseUrl: string, - token: string, - body: Record, -) { - const response = await httpRequest( - `${baseUrl}/api/runtime/save/snapshot`, - withBearer(token, { - method: 'PUT', - body: JSON.stringify(body), - }), - ); - - assert.equal(response.status, 200); - return response.json(); -} - -test('rpg profile routes keep new and legacy dashboard compatibility', async () => { - await withTestServer('profile-compat', async ({ baseUrl }) => { - const entry = await authEntry(baseUrl, 'rpg_profile_user', 'secret123'); - - await putSnapshot(baseUrl, entry.token, { - gameState: { - currentScene: 'Story', - worldType: 'WUXIA', - playerCharacter: { - id: 'hero-profile', - title: '试剑客', - description: '赶路的人。', - personality: '稳重', - attributes: { - strength: 8, - }, - skills: [], - }, - }, - bottomTab: 'adventure', - currentStory: { - text: '第一段记录', - options: [], - }, - savedAt: '2026-04-21T10:00:00.000Z', - }); - - const runtimeResponse = await httpRequest( - `${baseUrl}/api/runtime/profile/dashboard`, - withBearer(entry.token), - ); - const runtimePayload = (await runtimeResponse.json()) as { - walletBalance: number; - playedWorldCount: number; - }; - const legacyResponse = await httpRequest( - `${baseUrl}/api/profile/dashboard`, - withBearer(entry.token), - ); - const legacyPayload = (await legacyResponse.json()) as typeof runtimePayload; - - assert.equal(runtimeResponse.status, 200); - assert.equal(legacyResponse.status, 200); - assert.deepEqual(legacyPayload, runtimePayload); - }); -}); - -test('rpg entry save routes keep list and resume archive compatibility', async () => { - await withTestServer('save-archive-compat', async ({ baseUrl }) => { - const entry = await authEntry(baseUrl, 'rpg_save_user', 'secret123'); - - await putSnapshot(baseUrl, entry.token, { - gameState: { - currentScene: 'Story', - worldType: 'CUSTOM', - customWorldProfile: { - id: 'world-archive-a', - name: '裂潮边城', - }, - playerCharacter: { - id: 'hero-save', - title: '归乡人', - description: '带着旧信回城。', - personality: '沉静', - attributes: { - spirit: 9, - }, - skills: [], - }, - playerCurrency: 42, - }, - bottomTab: 'adventure', - currentStory: { - text: '旧灯塔还亮着。', - options: [], - }, - savedAt: '2026-04-21T10:05:00.000Z', - }); - - const listRuntime = await httpRequest( - `${baseUrl}/api/runtime/profile/save-archives`, - withBearer(entry.token), - ); - const listLegacy = await httpRequest( - `${baseUrl}/api/profile/save-archives`, - withBearer(entry.token), - ); - const runtimePayload = (await listRuntime.json()) as { - entries: Array<{ worldKey: string }>; - }; - const legacyPayload = (await listLegacy.json()) as typeof runtimePayload; - - assert.equal(listRuntime.status, 200); - assert.equal(listLegacy.status, 200); - assert.deepEqual(legacyPayload.entries, runtimePayload.entries); - assert.equal(runtimePayload.entries.length, 1); - - const worldKey = runtimePayload.entries[0]?.worldKey; - assert.ok(worldKey); - - const resumeRuntime = await httpRequest( - `${baseUrl}/api/runtime/profile/save-archives/${encodeURIComponent(worldKey!)}`, - withBearer(entry.token, { - method: 'POST', - }), - ); - const resumeLegacy = await httpRequest( - `${baseUrl}/api/profile/save-archives/${encodeURIComponent(worldKey!)}`, - withBearer(entry.token, { - method: 'POST', - }), - ); - const resumeRuntimePayload = (await resumeRuntime.json()) as { - entry: { worldKey: string }; - snapshot: { gameState: { playerCurrency: number } }; - }; - const resumeLegacyPayload = (await resumeLegacy.json()) as typeof resumeRuntimePayload; - - assert.equal(resumeRuntime.status, 200); - assert.equal(resumeLegacy.status, 200); - assert.deepEqual(resumeLegacyPayload.entry, resumeRuntimePayload.entry); - assert.equal( - resumeLegacyPayload.snapshot.bottomTab, - resumeRuntimePayload.snapshot.bottomTab, - ); - assert.equal( - resumeLegacyPayload.snapshot.currentStory.text, - resumeRuntimePayload.snapshot.currentStory.text, - ); - assert.equal(resumeRuntimePayload.snapshot.gameState.playerCurrency, 42); - assert.equal(resumeLegacyPayload.snapshot.gameState.playerCurrency, 42); - }); -}); - -test('rpg world library routes expose gallery and library through new boundaries', async () => { - await withTestServer('world-library-boundary', async ({ baseUrl }) => { - const owner = await authEntry(baseUrl, 'rpg_world_owner', 'secret123'); - - const upsertResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world-library/world-a`, - withBearer(owner.token, { - method: 'PUT', - body: JSON.stringify({ - profile: { - name: '裂桥前线', - subtitle: '雾潮压城', - summary: '守桥与沉船商盟持续拉扯。', - settingText: '一座被雾潮包住的边城。', - templateWorldType: 'WUXIA', - majorFactions: [], - coreConflicts: [], - playableNpcs: [], - storyNpcs: [], - items: [], - landmarks: [], - attributeSchema: { - slots: [], - }, - }, - }), - }), - ); - assert.equal(upsertResponse.status, 200); - - const libraryResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world-library`, - withBearer(owner.token), - ); - const libraryPayload = (await libraryResponse.json()) as { - entries: Array<{ profileId: string }>; - }; - assert.equal(libraryResponse.status, 200); - assert.deepEqual( - libraryPayload.entries.map((entry) => entry.profileId), - ['world-a'], - ); - - const publishResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world-library/world-a/publish`, - withBearer(owner.token, { - method: 'POST', - }), - ); - assert.equal(publishResponse.status, 200); - - const galleryResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world-gallery`, - ); - const galleryPayload = (await galleryResponse.json()) as { - entries: Array<{ ownerUserId: string; profileId: string }>; - }; - assert.equal(galleryResponse.status, 200); - assert.equal(galleryPayload.entries.length, 1); - - const detailResponse = await httpRequest( - `${baseUrl}/api/runtime/custom-world-gallery/${encodeURIComponent(galleryPayload.entries[0]!.ownerUserId)}/${encodeURIComponent(galleryPayload.entries[0]!.profileId)}`, - ); - const detailPayload = (await detailResponse.json()) as { - entry: { - profileId: string; - worldName: string; - }; - }; - assert.equal(detailResponse.status, 200); - assert.equal(detailPayload.entry.profileId, 'world-a'); - assert.equal(detailPayload.entry.worldName, '裂桥前线'); - }); -}); - -test('rpg runtime story routes resolve through the new route boundary', async () => { - await withTestServer('runtime-story-boundary', async ({ baseUrl }) => { - const entry = await authEntry(baseUrl, 'rpg_story_user', 'secret123'); - - await putSnapshot(baseUrl, entry.token, { - gameState: { - worldType: 'WUXIA', - playerCharacter: { - id: 'hero-story', - title: '试剑客', - description: '站在桥口的人。', - personality: '谨慎', - attributes: { - strength: 8, - spirit: 6, - }, - skills: [], - }, - runtimeStats: { - playTimeMs: 0, - lastPlayTickAt: null, - hostileNpcsDefeated: 0, - questsAccepted: 0, - itemsUsed: 0, - scenesTraveled: 0, - }, - currentScene: 'test-scene', - storyHistory: [], - characterChats: {}, - animationState: 'idle', - currentEncounter: { - kind: 'npc', - id: 'npc_merchant_01', - npcName: '沈七', - npcDescription: '腰间挂着药囊的行商', - context: '受伤行商', - }, - npcInteractionActive: true, - currentScenePreset: null, - sceneHostileNpcs: [], - playerX: 0, - playerOffsetY: 0, - playerFacing: 'right', - playerActionMode: 'idle', - scrollWorld: false, - inBattle: false, - playerHp: 31, - playerMaxHp: 40, - playerMana: 9, - playerMaxMana: 16, - playerSkillCooldowns: {}, - activeBuildBuffs: [], - activeCombatEffects: [], - playerCurrency: 90, - playerInventory: [], - playerEquipment: { - weapon: null, - armor: null, - relic: null, - }, - npcStates: { - npc_merchant_01: { - affinity: 46, - chattedCount: 0, - helpUsed: false, - giftsGiven: 0, - inventory: [], - recruited: false, - }, - }, - quests: [], - roster: [], - companions: [], - currentNpcBattleMode: null, - currentNpcBattleOutcome: null, - sparReturnEncounter: null, - sparPlayerHpBefore: null, - sparPlayerMaxHpBefore: null, - sparStoryHistoryBefore: null, - }, - bottomTab: 'adventure', - currentStory: { - text: '巡路人看着你,像在等一句开口。', - options: [], - }, - }); - - const stateResponse = await httpRequest( - `${baseUrl}/api/runtime/story/state/runtime-main`, - withBearer(entry.token), - ); - const statePayload = (await stateResponse.json()) as { - viewModel: { - availableOptions: Array<{ functionId: string }>; - }; - }; - assert.equal(stateResponse.status, 200); - assert.ok( - statePayload.viewModel.availableOptions.some( - (option) => option.functionId === 'npc_chat', - ), - ); - - const actionResponse = await httpRequest( - `${baseUrl}/api/runtime/story/actions/resolve`, - withBearer(entry.token, { - method: 'POST', - body: JSON.stringify({ - sessionId: 'runtime-main', - clientVersion: 0, - action: { - type: 'story_choice', - functionId: 'npc_chat', - }, - }), - }), - ); - const actionPayload = (await actionResponse.json()) as { - serverVersion: number; - viewModel: { - encounter: { - affinity: number; - } | null; - }; - }; - - assert.equal(actionResponse.status, 200); - assert.equal(actionPayload.serverVersion, 1); - assert.equal(actionPayload.viewModel.encounter?.affinity, 52); - }); -}); diff --git a/server-node/src/server.ts b/server-node/src/server.ts deleted file mode 100644 index 62c05fa5..00000000 --- a/server-node/src/server.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { pathToFileURL } from 'node:url'; - -import { createApp } from './app.js'; -import { type AppConfig, loadConfig } from './config.js'; -import type { AppContext } from './context.js'; -import { createDatabase } from './db.js'; -import { createLogger } from './logging.js'; -import { AuthAuditLogRepository } from './repositories/authAuditLogRepository.js'; -import { AuthIdentityRepository } from './repositories/authIdentityRepository.js'; -import { AuthRiskBlockRepository } from './repositories/authRiskBlockRepository.js'; -import { RpgAgentSessionRepository } from './repositories/RpgAgentSessionRepository.js'; -import { RpgWorldProfileRepository } from './repositories/RpgWorldProfileRepository.js'; -import { RpgSaveArchiveRepository } from './repositories/rpg-entry/RpgSaveArchiveRepository.js'; -import { RpgWorldLibraryRepository } from './repositories/rpg-entry/RpgWorldLibraryRepository.js'; -import { RpgBrowseHistoryRepository } from './repositories/rpg-profile/RpgBrowseHistoryRepository.js'; -import { RpgProfileDashboardRepository } from './repositories/rpg-profile/RpgProfileDashboardRepository.js'; -import { RpgRuntimeSnapshotRepository } from './repositories/rpg-runtime/RpgRuntimeSnapshotRepository.js'; -import { RuntimeRepository } from './repositories/runtimeRepository.js'; -import { SmsAuthEventRepository } from './repositories/smsAuthEventRepository.js'; -import { UserRepository } from './repositories/userRepository.js'; -import { UserSessionRepository } from './repositories/userSessionRepository.js'; -import { CaptchaChallengeStore } from './services/captchaChallengeStore.js'; -import { CustomWorldAgentAutoAssetService } from './services/customWorldAgentAutoAssetService.js'; -import { CustomWorldAgentOrchestrator } from './services/customWorldAgentOrchestrator.js'; -import { CustomWorldAgentSessionStore } from './services/customWorldAgentSessionStore.js'; -import { UpstreamLlmClient } from './services/llmClient.js'; -import { RpgWorldWorkSummaryService } from './services/RpgWorldWorkSummaryService.js'; -import { createSmsVerificationService } from './services/smsVerificationService.js'; -import { createWechatAuthService } from './services/wechatAuthService.js'; -import { WechatAuthStateStore } from './services/wechatAuthStateStore.js'; - -function resolveListenTarget(serverAddr: string) { - const trimmed = serverAddr.trim(); - if (!trimmed) { - return { host: '0.0.0.0', port: 8081 }; - } - if (trimmed.startsWith(':')) { - return { - host: '0.0.0.0', - port: Number(trimmed.slice(1)), - }; - } - if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { - const url = new URL(trimmed); - return { - host: url.hostname, - port: Number(url.port || 80), - }; - } - if (trimmed.includes(':')) { - const [host, portText] = trimmed.split(':'); - return { - host: host || '0.0.0.0', - port: Number(portText), - }; - } - return { - host: '0.0.0.0', - port: Number(trimmed), - }; -} - -function describeDatabase(databaseUrl: string) { - if (databaseUrl.startsWith('pg-mem://')) { - return { - database_engine: 'pg-mem', - database_name: databaseUrl.slice('pg-mem://'.length) || 'memory', - }; - } - - try { - const url = new URL(databaseUrl); - return { - database_engine: url.protocol.replace(/:$/u, ''), - database_host: url.hostname, - database_port: Number(url.port || 5432), - database_name: url.pathname.replace(/^\/+/u, '') || 'postgres', - }; - } catch { - return { - database_engine: 'postgresql', - database_target: 'configured', - }; - } -} - -export async function createAppContext(config: AppConfig = loadConfig()) { - const logger = createLogger(config); - const db = await createDatabase(config); - const rpgAgentSessionRepository = new RpgAgentSessionRepository(db); - const rpgWorldProfileRepository = new RpgWorldProfileRepository(db); - const runtimeRepository = new RuntimeRepository(db); - const rpgProfileDashboardRepository = new RpgProfileDashboardRepository( - runtimeRepository, - ); - const rpgBrowseHistoryRepository = new RpgBrowseHistoryRepository( - runtimeRepository, - ); - const rpgSaveArchiveRepository = new RpgSaveArchiveRepository( - runtimeRepository, - ); - const rpgWorldLibraryRepository = new RpgWorldLibraryRepository( - runtimeRepository, - ); - const rpgRuntimeSnapshotRepository = new RpgRuntimeSnapshotRepository( - runtimeRepository, - ); - const userRepository = new UserRepository(db); - const customWorldAgentSessions = new CustomWorldAgentSessionStore( - rpgAgentSessionRepository, - ); - const rpgWorldWorkSummaryService = new RpgWorldWorkSummaryService( - rpgWorldProfileRepository, - customWorldAgentSessions, - ); - const autoAssetService = new CustomWorldAgentAutoAssetService( - config, - config.dashScope.apiKey.trim() - ? CustomWorldAgentAutoAssetService.createDashScopeCharacterVisualGenerator( - config, - ) - : CustomWorldAgentAutoAssetService.createFallbackCharacterVisualGenerator( - config, - ), - config.dashScope.apiKey.trim() - ? CustomWorldAgentAutoAssetService.createDashScopeSceneActBackgroundGenerator( - config, - ) - : CustomWorldAgentAutoAssetService.createFallbackSceneActBackgroundGenerator( - config, - ), - ); - const context: AppContext = { - config, - logger, - db, - userRepository, - authIdentityRepository: new AuthIdentityRepository(db), - authAuditLogRepository: new AuthAuditLogRepository(db), - authRiskBlockRepository: new AuthRiskBlockRepository(db), - smsAuthEventRepository: new SmsAuthEventRepository(db), - userSessionRepository: new UserSessionRepository(db), - rpgAgentSessionRepository, - rpgWorldProfileRepository, - rpgProfileDashboardRepository, - rpgBrowseHistoryRepository, - rpgSaveArchiveRepository, - rpgWorldLibraryRepository, - rpgRuntimeSnapshotRepository, - runtimeRepository, - llmClient: new UpstreamLlmClient(config, logger), - customWorldAgentSessions, - customWorldAgentOrchestrator: new CustomWorldAgentOrchestrator( - customWorldAgentSessions, - config.llm.apiKey.trim() - ? new UpstreamLlmClient(config, logger) - : null, - { - autoAssetService, - rpgWorldProfileRepository, - userRepository, - }, - ), - rpgWorldWorkSummaryService, - smsVerificationService: createSmsVerificationService(config, logger), - wechatAuthService: createWechatAuthService(config, logger), - wechatAuthStates: new WechatAuthStateStore(), - captchaChallenges: new CaptchaChallengeStore(), - }; - - return context; -} - -async function main() { - const context = await createAppContext(); - const app = createApp(context); - const { host, port } = resolveListenTarget(context.config.serverAddr); - const server = app.listen(port, host, () => { - context.logger.info( - { - host, - port, - ...describeDatabase(context.config.databaseUrl), - }, - 'server-node started', - ); - }); - - let shuttingDown = false; - const shutdown = () => { - if (shuttingDown) { - return; - } - shuttingDown = true; - context.logger.info('server-node shutting down'); - server.close(() => { - void context.db - .close() - .then(() => { - process.exit(0); - }) - .catch((error) => { - context.logger.error({ err: error }, 'failed to close database'); - process.exit(1); - }); - }); - }; - - process.on('SIGINT', shutdown); - process.on('SIGTERM', shutdown); -} - -function isEntryModule() { - if (typeof process.argv[1] !== 'string') { - return false; - } - - const entryHref = pathToFileURL(process.argv[1]).href; - if (typeof import.meta.url === 'string' && import.meta.url === entryHref) { - return true; - } - - return ( - typeof __filename === 'string' && - pathToFileURL(__filename).href === entryHref - ); -} - -const isEntryPoint = isEntryModule(); - -if (isEntryPoint) { - void main().catch((error) => { - console.error(error); - process.exit(1); - }); -} diff --git a/server-node/src/services/RpgWorldPreviewCompiler.fixture.test.ts b/server-node/src/services/RpgWorldPreviewCompiler.fixture.test.ts deleted file mode 100644 index ad73d3be..00000000 --- a/server-node/src/services/RpgWorldPreviewCompiler.fixture.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { - createRpgAgentFoundationDraftProfileFixture, - createRpgCreationPublishedProfileFixture, -} from '../../../packages/shared/src/contracts/rpgCreationFixtures.js'; -import { - buildRpgWorldPreviewEnvelope, - normalizeRpgWorldPreviewEnvelope, -} from './RpgWorldPreviewCompiler.js'; - -test('rpg world preview compiler can consume shared published profile fixture as a stable unit baseline', () => { - const publishedProfile = createRpgCreationPublishedProfileFixture(); - const previewEnvelope = buildRpgWorldPreviewEnvelope( - publishedProfile, - String(publishedProfile.settingText ?? ''), - ); - - assert.equal(previewEnvelope.source, 'session_preview'); - assert.equal(previewEnvelope.preview.name, publishedProfile.name); - assert.equal( - (previewEnvelope.preview.playableNpcs as Array<{ generatedAnimationSetId?: string }>)[0] - ?.generatedAnimationSetId, - 'animation-set-playable-1', - ); - assert.equal( - ( - previewEnvelope.preview.sceneChapterBlueprints as Array<{ - acts?: Array<{ backgroundImageSrc?: string }>; - }> - )[0]?.acts?.[0]?.backgroundImageSrc, - '/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png', - ); -}); - -test('regression: foundation-like shared fixture fields are preserved after normalize + preview compile chain', () => { - const foundationDraft = createRpgAgentFoundationDraftProfileFixture(); - const normalizedPreviewEnvelope = normalizeRpgWorldPreviewEnvelope( - { - name: foundationDraft.name, - subtitle: foundationDraft.subtitle, - summary: foundationDraft.summary, - tone: foundationDraft.tone, - playerGoal: foundationDraft.playerGoal, - templateWorldType: 'WUXIA', - majorFactions: foundationDraft.majorFactions, - coreConflicts: foundationDraft.coreConflicts, - playableNpcs: foundationDraft.playableNpcs, - storyNpcs: foundationDraft.storyNpcs, - camp: foundationDraft.camp, - landmarks: foundationDraft.landmarks, - sceneChapterBlueprints: foundationDraft.sceneChapters, - themePack: foundationDraft.themePack, - storyGraph: foundationDraft.storyGraph, - }, - foundationDraft.worldHook, - ); - - assert.equal(normalizedPreviewEnvelope.source, 'session_preview'); - assert.equal( - (normalizedPreviewEnvelope.preview.playableNpcs as Array<{ imageSrc?: string }>)[0] - ?.imageSrc, - '/generated-characters/playable-1/visual/asset-runtime/master.png', - ); - assert.equal( - (normalizedPreviewEnvelope.preview.playableNpcs as Array<{ - animationMap?: { attack?: { basePath?: string } }; - }>)[0]?.animationMap?.attack?.basePath, - '/generated-characters/playable-1/animations/attack', - ); - assert.equal( - ( - normalizedPreviewEnvelope.preview.sceneChapterBlueprints as Array<{ - acts?: Array<{ backgroundAssetId?: string }>; - }> - )[0]?.acts?.[0]?.backgroundAssetId, - 'scene-asset-runtime', - ); -}); diff --git a/server-node/src/services/RpgWorldPreviewCompiler.test.ts b/server-node/src/services/RpgWorldPreviewCompiler.test.ts deleted file mode 100644 index f4899792..00000000 --- a/server-node/src/services/RpgWorldPreviewCompiler.test.ts +++ /dev/null @@ -1,269 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { - buildRpgWorldPreviewEnvelope, - buildRpgWorldPreviewProfile, - normalizeRpgWorldPreviewEnvelope, -} from './RpgWorldPreviewCompiler.js'; -import { buildRpgCreationPreviewProfileFromDraftProfile } from './rpgCreationPreviewProfileBuilder.js'; - -function createPreviewFixture() { - const storyNpcs = Array.from({ length: 25 }, (_, index) => ({ - name: `场景角色${index + 1}`, - title: `头衔${index + 1}`, - role: `职责${index + 1}`, - description: `场景角色描述${index + 1}`, - backstory: `场景角色背景${index + 1}`, - personality: `场景角色性格${index + 1}`, - motivation: `场景角色动机${index + 1}`, - combatStyle: `场景角色战斗风格${index + 1}`, - initialAffinity: index % 4 === 0 ? -10 : 6, - relationshipHooks: [`关系${index + 1}`], - tags: [`线索${index + 1}`], - })); - - return { - id: 'preview-world', - name: '预览测试世界', - subtitle: '预览副标题', - summary: '服务端预览编译的兼容结果。', - tone: '压抑、潮湿', - playerGoal: '先确认谁在推动局势,再决定站位。', - templateWorldType: 'WUXIA', - majorFactions: ['守灯会', '潮线商盟'], - coreConflicts: ['旧航道解释权正在被重写'], - playableNpcs: Array.from({ length: 5 }, (_, index) => ({ - name: `角色${index + 1}`, - title: `称号${index + 1}`, - role: `身份${index + 1}`, - description: `角色描述${index + 1}`, - backstory: `角色背景${index + 1}`, - personality: `角色性格${index + 1}`, - motivation: `角色动机${index + 1}`, - combatStyle: `战斗风格${index + 1}`, - initialAffinity: 18, - relationshipHooks: [`接触点${index + 1}`], - tags: [`标签${index + 1}`], - })), - storyNpcs, - landmarks: Array.from({ length: 10 }, (_, index) => ({ - name: `场景${index + 1}`, - description: `场景描述${index + 1}`, - dangerLevel: 'medium', - sceneNpcNames: [ - storyNpcs[index % storyNpcs.length]?.name ?? `场景角色${index + 1}`, - storyNpcs[(index + 1) % storyNpcs.length]?.name ?? - `场景角色${index + 2}`, - storyNpcs[(index + 2) % storyNpcs.length]?.name ?? - `场景角色${index + 3}`, - ], - connections: [ - { - targetLandmarkName: `场景${((index + 1) % 10) + 1}`, - relativePosition: 'forward', - summary: '沿主路前行', - }, - { - targetLandmarkName: `场景${((index + 9) % 10) + 1}`, - relativePosition: 'back', - summary: '回身可返', - }, - ], - })), - }; -} - -test('rpg world preview compiler builds a legacy-compatible preview envelope on the server', () => { - const settingText = '一个被潮雾反复切开的边境世界。'; - const rawProfile = createPreviewFixture(); - - const previewProfile = buildRpgWorldPreviewProfile(rawProfile, settingText); - const previewEnvelope = buildRpgWorldPreviewEnvelope(rawProfile, settingText); - const normalizedEnvelope = normalizeRpgWorldPreviewEnvelope( - rawProfile, - settingText, - ); - - assert.equal(previewProfile.name, '预览测试世界'); - assert.equal(previewProfile.playableNpcs.length, 5); - assert.equal(previewEnvelope.source, 'session_preview'); - assert.equal(normalizedEnvelope.source, 'session_preview'); - assert.equal(previewEnvelope.preview.name, '预览测试世界'); - assert.equal(previewEnvelope.preview.scenarioPackId, 'scenario-pack:预览测试世界'); - assert.equal( - normalizedEnvelope.preview.campaignPackId, - 'campaign-pack:预览测试世界', - ); -}); - -test('phase5 preview builder keeps legacy runtime-rich fields while merging latest draft assets', () => { - const previewProfile = buildRpgCreationPreviewProfileFromDraftProfile({ - sessionId: 'session-phase5-preview', - draftProfile: { - name: '潮雾列岛', - subtitle: '旧灯塔与失控航路', - summary: '第一版世界底稿已经整理完成。', - tone: '压抑、潮湿、悬疑', - playerGoal: '查清沉船与禁航区异动的真相。', - majorFactions: ['守灯会', '航运公会'], - coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], - playableNpcs: [ - { - id: 'playable-1', - name: '沈砺', - imageSrc: '/generated-characters/playable-1/visual/asset-runtime/master.png', - generatedVisualAssetId: 'asset-runtime-playable', - generatedAnimationSetId: 'animation-set-runtime-playable', - animationMap: { - attack: { - basePath: '/generated-characters/playable-1/animations/attack', - }, - }, - }, - ], - storyNpcs: [ - { - id: 'story-1', - name: '顾潮音', - imageSrc: '/generated-characters/story-1/visual/asset-runtime/master.png', - generatedVisualAssetId: 'asset-runtime-story', - }, - ], - landmarks: [ - { - id: 'landmark-1', - name: '回潮旧灯塔', - imageSrc: '/generated-custom-world-scenes/landmark-1/scene.png', - generatedSceneAssetId: 'scene-asset-runtime', - }, - ], - sceneChapters: [ - { - id: 'scene-chapter-1', - sceneId: 'landmark-1', - title: '灯塔初章', - acts: [ - { - id: 'scene-act-1', - title: '第一幕', - backgroundImageSrc: - '/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png', - backgroundAssetId: 'scene-act-runtime', - }, - ], - }, - ], - legacyResultProfile: { - id: 'agent-draft-session-phase5-preview', - settingText: '被海雾吞没的旧航路群岛', - name: '潮雾列岛·结果页精修版', - subtitle: '旧灯塔与失控航路', - summary: '服务端 preview 需要保留结果页富字段。', - tone: '压抑、潮湿、悬疑', - playerGoal: '查清沉船夜与假航灯的真正操盘者。', - templateWorldType: 'WUXIA', - majorFactions: ['守灯会', '航运公会'], - coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], - playableNpcs: [ - { - id: 'playable-1', - name: '沈砺', - title: '旧航路引路人', - role: '关键同行者', - description: '最熟悉旧航路的人。', - backstory: '曾在沉船夜里带着半支船队逃出海雾。', - personality: '表面沉稳,心里一直在算退路。', - motivation: '想赶在守灯会封航前查清真相。', - combatStyle: '借地形和潮路换位,先拉扯再压近。', - initialAffinity: 18, - relationshipHooks: ['旧友', '沉船旧案'], - tags: ['潮路', '引路'], - narrativeProfile: { - publicMask: '像个只想把旧路再走通一次的熟路人。', - }, - }, - ], - storyNpcs: [ - { - id: 'story-1', - name: '顾潮音', - title: '守灯会值夜人', - role: '场景关键角色', - description: '夜里巡灯与封锁禁航区的人。', - backstory: '在失控海雾第一次吞船那夜,她是最后一个还留在灯塔顶的人。', - personality: '冷静克制,但提到旧灯册时会显得过分警觉。', - motivation: '想守住灯塔记录,也想找到谁先改动了禁航信号。', - combatStyle: '借塔顶视角和风向压制,再用灯火错位扰乱。', - initialAffinity: 8, - relationshipHooks: ['禁航记录', '灯塔值夜'], - tags: ['守灯会', '灯塔'], - }, - ], - items: [ - { - id: 'item-world-1', - name: '潮雾罗盘', - category: '饰品', - }, - ], - landmarks: [ - { - id: 'landmark-1', - name: '回潮旧灯塔', - description: '被海雾啃旧的石塔仍在夜里维持着微弱灯火。', - dangerLevel: 'high', - sceneNpcIds: ['story-1'], - connections: [], - }, - ], - themePack: { - id: 'theme-pack:tide', - }, - knowledgeFacts: [ - { - id: 'fact-1', - title: '高处潮痕', - }, - ], - threadContracts: [ - { - id: 'contract-1', - threadId: 'thread-visible-1', - }, - ], - sceneChapterBlueprints: [ - { - id: 'scene-chapter-1', - sceneId: 'landmark-1', - title: '灯塔初章', - acts: [ - { - id: 'scene-act-1', - title: '第一幕', - }, - ], - }, - ], - generationMode: 'full', - generationStatus: 'complete', - }, - }, - }); - - assert.equal(previewProfile.name, '潮雾列岛'); - assert.equal(previewProfile.playerGoal, '查清沉船与禁航区异动的真相。'); - assert.equal(previewProfile.themePack?.id, 'theme-pack:tide'); - assert.equal(previewProfile.knowledgeFacts?.[0]?.id, 'fact-1'); - assert.equal(previewProfile.threadContracts?.[0]?.id, 'contract-1'); - assert.equal(previewProfile.playableNpcs[0]?.imageSrc, '/generated-characters/playable-1/visual/asset-runtime/master.png'); - assert.equal(previewProfile.playableNpcs[0]?.generatedAnimationSetId, 'animation-set-runtime-playable'); - assert.equal( - previewProfile.playableNpcs[0]?.narrativeProfile?.publicMask, - '像个只想把旧路再走通一次的熟路人。', - ); - assert.equal( - previewProfile.sceneChapterBlueprints?.[0]?.acts?.[0]?.backgroundAssetId, - 'scene-act-runtime', - ); -}); diff --git a/server-node/src/services/RpgWorldPreviewCompiler.ts b/server-node/src/services/RpgWorldPreviewCompiler.ts deleted file mode 100644 index 483b488b..00000000 --- a/server-node/src/services/RpgWorldPreviewCompiler.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { - buildCompiledCustomWorldProfile, - normalizeCustomWorldProfile, -} from '../modules/custom-world/runtime-profile/index.js'; -import type { - RpgCreationPreview, - RpgCreationPreviewEnvelope, - RpgCreationPreviewSource, -} from '../../../packages/shared/src/contracts/rpgCreationPreview.js'; -import type { CustomWorldProfile } from '../modules/custom-world/runtimeTypes.js'; - -/** - * 工作包 G 把服务端结果预览编译入口收口到这里。 - * Phase 5 后当前 preview 正式作为 session_preview 主链输出, - * 编译边界已经从 foundation draft 流程中抽离。 - */ -export type RpgWorldPreviewProfile = CustomWorldProfile; - -const RPG_WORLD_PREVIEW_SOURCE: RpgCreationPreviewSource = - 'session_preview'; - -function toRpgCreationPreview( - profile: RpgWorldPreviewProfile, -): RpgCreationPreview { - return profile as unknown as RpgCreationPreview; -} - -export function buildRpgWorldPreviewProfile( - raw: unknown, - settingText: string, -): RpgWorldPreviewProfile { - return buildCompiledCustomWorldProfile(raw, settingText); -} - -export function normalizeRpgWorldPreviewProfile( - raw: unknown, - settingText: string, -): RpgWorldPreviewProfile { - return normalizeCustomWorldProfile(raw, settingText); -} - -export function buildRpgWorldPreviewEnvelope( - raw: unknown, - settingText: string, -): RpgCreationPreviewEnvelope { - return { - preview: toRpgCreationPreview(buildRpgWorldPreviewProfile(raw, settingText)), - source: RPG_WORLD_PREVIEW_SOURCE, - }; -} - -export function normalizeRpgWorldPreviewEnvelope( - raw: unknown, - settingText: string, -): RpgCreationPreviewEnvelope { - return { - preview: toRpgCreationPreview( - buildRpgWorldPreviewProfile( - normalizeRpgWorldPreviewProfile(raw, settingText), - settingText, - ), - ), - source: RPG_WORLD_PREVIEW_SOURCE, - }; -} diff --git a/server-node/src/services/RpgWorldWorkCoverResolver.ts b/server-node/src/services/RpgWorldWorkCoverResolver.ts deleted file mode 100644 index 186825cf..00000000 --- a/server-node/src/services/RpgWorldWorkCoverResolver.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { - CustomWorldLibraryEntry, - CustomWorldProfileRecord, -} from '../../../packages/shared/src/contracts/runtime.js'; -import { resolveCustomWorldCoverPresentation } from '../repositories/customWorldLibraryMetadata.js'; -import type { CustomWorldAgentSessionRecord } from './customWorldAgentSessionStore.js'; - -function toRecord(value: unknown) { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : null; -} - -/** - * 作品封面解析统一收口在这里,避免 works 聚合服务重复理解草稿态与发布态的封面规则。 - */ -export function resolveRpgWorldDraftWorkCover( - session: CustomWorldAgentSessionRecord, -) { - const draftProfile = toRecord(session.draftProfile); - if (!draftProfile) { - return { - imageSrc: null, - renderMode: 'image' as const, - characterImageSrcs: [], - }; - } - - return resolveCustomWorldCoverPresentation( - draftProfile as CustomWorldProfileRecord, - ); -} - -export function resolveRpgWorldPublishedWorkCover( - libraryEntry: CustomWorldLibraryEntry, -) { - const coverPresentation = resolveCustomWorldCoverPresentation( - libraryEntry.profile, - ); - - return { - imageSrc: libraryEntry.coverImageSrc || coverPresentation.imageSrc, - renderMode: coverPresentation.renderMode, - characterImageSrcs: coverPresentation.characterImageSrcs, - }; -} diff --git a/server-node/src/services/RpgWorldWorkSummaryAssembler.fixture.test.ts b/server-node/src/services/RpgWorldWorkSummaryAssembler.fixture.test.ts deleted file mode 100644 index 9aeb0771..00000000 --- a/server-node/src/services/RpgWorldWorkSummaryAssembler.fixture.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { - createRpgAgentSessionFixture, - createRpgCreationWorksResponseFixture, - createRpgWorldLibraryEntryFixture, -} from '../../../packages/shared/src/contracts/rpgCreationFixtures.js'; -import { RpgWorldWorkSummaryAssembler } from './RpgWorldWorkSummaryAssembler.js'; - -test('rpg world work summary assembler can consume shared fixture baselines as a unit test', () => { - const assembler = new RpgWorldWorkSummaryAssembler(); - const session = createRpgAgentSessionFixture(); - const libraryEntry = createRpgWorldLibraryEntryFixture(); - const [draftItem] = assembler.assembleDraftItems([ - { - ...JSON.parse(JSON.stringify(session)), - userId: 'fixture-user', - seedText: '被海雾吞没的旧航路群岛', - operations: [], - checkpoints: [], - createdAt: session.updatedAt, - }, - ]); - const [publishedItem] = assembler.assemblePublishedItems([libraryEntry]); - - assert.equal(draftItem.sourceType, 'agent_session'); - assert.equal(draftItem.roleVisualReadyCount, 2); - assert.equal(draftItem.roleAnimationReadyCount, 2); - assert.equal(draftItem.roleAssetSummaryLabel, '沈砺 · 动作已就绪'); - assert.equal(draftItem.canEnterWorld, false); - assert.equal(draftItem.publishReady, true); - assert.equal(draftItem.blockerCount, 0); - assert.equal(publishedItem.sourceType, 'published_profile'); - assert.equal(publishedItem.canEnterWorld, true); - assert.equal(publishedItem.publishReady, true); - assert.equal(publishedItem.blockerCount, 0); - assert.equal(publishedItem.roleAnimationReadyCount, 1); -}); - -test('regression: assembler output stays aligned with shared works response fixture', () => { - const assembler = new RpgWorldWorkSummaryAssembler(); - const session = createRpgAgentSessionFixture(); - const libraryEntry = createRpgWorldLibraryEntryFixture(); - const expected = createRpgCreationWorksResponseFixture(); - - const [draftItem] = assembler.assembleDraftItems([ - { - ...JSON.parse(JSON.stringify(session)), - userId: 'fixture-user', - seedText: '被海雾吞没的旧航路群岛', - operations: [], - checkpoints: [], - createdAt: session.updatedAt, - }, - ]); - const [publishedItem] = assembler.assemblePublishedItems([libraryEntry]); - const expectedDraft = expected.items.find((entry) => entry.sourceType === 'agent_session'); - const expectedPublished = expected.items.find( - (entry) => entry.sourceType === 'published_profile', - ); - - assert.ok(expectedDraft); - assert.ok(expectedPublished); - assert.equal(draftItem.coverImageSrc, expectedDraft.coverImageSrc); - assert.deepEqual( - draftItem.coverCharacterImageSrcs, - expectedDraft.coverCharacterImageSrcs, - ); - assert.equal(draftItem.stageLabel, expectedDraft.stageLabel); - assert.equal(draftItem.publishReady, expectedDraft.publishReady); - assert.equal(draftItem.blockerCount, expectedDraft.blockerCount); - assert.equal(publishedItem.coverImageSrc, expectedPublished.coverImageSrc); - assert.equal( - publishedItem.roleAssetSummaryLabel, - expectedPublished.roleAssetSummaryLabel, - ); -}); - -test('published sessions do not leak back into draft work summaries', () => { - const assembler = new RpgWorldWorkSummaryAssembler(); - const session = createRpgAgentSessionFixture(); - const draftItems = assembler.assembleDraftItems([ - { - ...JSON.parse(JSON.stringify(session)), - userId: 'fixture-user', - seedText: '被海雾吞没的旧航路群岛', - operations: [], - checkpoints: [], - createdAt: session.updatedAt, - stage: 'published', - }, - ]); - - assert.equal(draftItems.length, 0); -}); diff --git a/server-node/src/services/RpgWorldWorkSummaryAssembler.ts b/server-node/src/services/RpgWorldWorkSummaryAssembler.ts deleted file mode 100644 index 7942c629..00000000 --- a/server-node/src/services/RpgWorldWorkSummaryAssembler.ts +++ /dev/null @@ -1,301 +0,0 @@ -import type { - CustomWorldAgentStage, - CustomWorldWorkSummary, -} from '../../../packages/shared/src/contracts/customWorldAgent.js'; -import type { - CustomWorldLibraryEntry, - CustomWorldProfileRecord, -} from '../../../packages/shared/src/contracts/runtime.js'; -import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js'; -import { - buildDraftSummaryFromIntent, - buildDraftTitleFromIntent, - normalizeCreatorIntentRecord, -} from './customWorldAgentIntentExtractionService.js'; -import { - rebuildRoleAssetCoverage, - resolveRoleAssetStatusLabel, -} from './customWorldAgentRoleAssetStateService.js'; -import type { CustomWorldAgentSessionRecord } from './customWorldAgentSessionStore.js'; -import { - buildDraftSummaryFromEightAnchorContent, - buildDraftTitleFromEightAnchorContent, -} from './eightAnchorCompatibilityService.js'; -import { - resolveRpgWorldDraftWorkCover, - resolveRpgWorldPublishedWorkCover, -} from './RpgWorldWorkCoverResolver.js'; -import { CustomWorldAgentPublishingService } from './customWorldAgentPublishingService.js'; - -function toText(value: unknown) { - return typeof value === 'string' ? value.trim() : ''; -} - -function toRecord(value: unknown) { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : null; -} - -function toRecordArray(value: unknown) { - return Array.isArray(value) - ? value.filter((item) => item && typeof item === 'object') - : []; -} - -function truncateText(value: string, maxLength: number) { - if (value.length <= maxLength) { - return value; - } - - return `${value.slice(0, Math.max(0, maxLength - 1)).trim()}…`; -} - -function formatDraftStageLabel(stage: CustomWorldAgentStage) { - if (stage === 'collecting_intent') return '收集世界锚点'; - if (stage === 'clarifying') return '补齐关键锚点'; - if (stage === 'foundation_review') return '准备整理底稿'; - if (stage === 'object_refining') return '待完善草稿'; - if (stage === 'visual_refining') return '视觉工坊'; - if (stage === 'long_tail_review') return '扩展长尾'; - if (stage === 'ready_to_publish') return '准备发布'; - if (stage === 'published') return '已发布'; - return '发生错误'; -} - -function resolveDraftTitle(session: CustomWorldAgentSessionRecord) { - const intent = normalizeCreatorIntentRecord(session.creatorIntent); - const draftProfile = normalizeFoundationDraftProfile(session.draftProfile); - - return ( - draftProfile?.name || - buildDraftTitleFromEightAnchorContent(session.anchorContent) || - buildDraftTitleFromIntent(intent) || - toText(session.draftProfile?.title) || - truncateText(session.seedText, 18) || - '未命名草稿' - ); -} - -function resolveDraftSummary(session: CustomWorldAgentSessionRecord) { - const intent = normalizeCreatorIntentRecord(session.creatorIntent); - const compiledSummary = buildDraftSummaryFromIntent(intent); - const draftProfile = normalizeFoundationDraftProfile(session.draftProfile); - - return ( - draftProfile?.summary || - buildDraftSummaryFromEightAnchorContent(session.anchorContent) || - compiledSummary || - toText(session.draftProfile?.summary) || - truncateText(session.seedText, 72) || - '还在收集你的世界锚点。' - ); -} - -function resolveDraftCounts(session: CustomWorldAgentSessionRecord) { - const draftProfile = normalizeFoundationDraftProfile(session.draftProfile); - if (draftProfile) { - // 草稿作品卡需要展示当前可编辑的全部角色数量,而不是仅统计可扮演角色。 - const totalRoleCount = [ - ...new Set( - [...draftProfile.playableNpcs, ...draftProfile.storyNpcs].map( - (entry) => entry.id, - ), - ), - ].length; - - return { - playableNpcCount: totalRoleCount, - landmarkCount: draftProfile.landmarks.length, - }; - } - - const playableNpcCount = session.draftCards.filter( - (card) => card.kind === 'character', - ).length; - const landmarkCount = session.draftCards.filter( - (card) => card.kind === 'landmark' || card.kind === 'camp', - ).length; - - return { - playableNpcCount, - landmarkCount, - }; -} - -function resolveDraftRoleAssetProgress(session: CustomWorldAgentSessionRecord) { - const coverage = rebuildRoleAssetCoverage(session.draftProfile); - const roleVisualReadyCount = coverage.roleAssets.filter( - (entry) => entry.status !== 'missing', - ).length; - const roleAnimationReadyCount = coverage.roleAssets.filter( - (entry) => entry.status === 'complete', - ).length; - const leadRole = coverage.roleAssets[0]; - - return { - roleVisualReadyCount, - roleAnimationReadyCount, - roleAssetSummaryLabel: leadRole - ? `${leadRole.roleName} · ${resolveRoleAssetStatusLabel(leadRole.status)}` - : coverage.roleAssets.length > 0 - ? '角色资产进行中' - : null, - }; -} - -function isLibraryEntry( - value: unknown, -): value is CustomWorldLibraryEntry { - const record = toRecord(value); - return ( - record !== null && - typeof record.ownerUserId === 'string' && - typeof record.profileId === 'string' && - Boolean(toRecord(record.profile)) - ); -} - -function isPublishedLibraryEntry( - value: unknown, -): value is CustomWorldLibraryEntry { - return isLibraryEntry(value) && value.visibility === 'published'; -} - -/** - * works 组装器只负责把 session/profile 转成稳定读模型,不直接发起仓储读取。 - */ -export class RpgWorldWorkSummaryAssembler { - private readonly publishGateService = new CustomWorldAgentPublishingService({ - listOwnProfiles: async () => [], - upsertOwnProfile: async () => { - throw new Error('publish repository is unavailable in work summary assembler'); - }, - syncProfileFromSnapshot: async () => undefined, - softDeleteOwnProfile: async () => [], - publishOwnProfile: async () => null, - unpublishOwnProfile: async () => null, - listPublishedGallery: async () => [], - getPublishedGalleryDetail: async () => null, - }); - - assembleDraftItems(sessions: CustomWorldAgentSessionRecord[]) { - return sessions - .filter((session) => session.stage !== 'published') - .map((session) => { - const counts = resolveDraftCounts(session); - const roleAssetProgress = resolveDraftRoleAssetProgress(session); - const coverPresentation = resolveRpgWorldDraftWorkCover(session); - const publishState = this.publishGateService.summarizePublishGate({ - sessionId: session.sessionId, - stage: session.stage, - draftProfile: session.draftProfile, - qualityFindings: session.qualityFindings, - }); - - return { - workId: `draft:${session.sessionId}`, - sourceType: 'agent_session', - status: 'draft', - title: resolveDraftTitle(session), - subtitle: - normalizeFoundationDraftProfile(session.draftProfile)?.subtitle || - formatDraftStageLabel(session.stage), - summary: resolveDraftSummary(session), - coverImageSrc: coverPresentation.imageSrc, - coverRenderMode: coverPresentation.renderMode, - coverCharacterImageSrcs: coverPresentation.characterImageSrcs, - updatedAt: session.updatedAt, - publishedAt: null, - stage: session.stage, - stageLabel: formatDraftStageLabel(session.stage), - playableNpcCount: counts.playableNpcCount, - landmarkCount: counts.landmarkCount, - roleVisualReadyCount: roleAssetProgress.roleVisualReadyCount, - roleAnimationReadyCount: roleAssetProgress.roleAnimationReadyCount, - roleAssetSummaryLabel: roleAssetProgress.roleAssetSummaryLabel, - sessionId: session.sessionId, - profileId: null, - canResume: true, - canEnterWorld: publishState.canEnterWorld, - blockerCount: publishState.blockerCount, - publishReady: publishState.publishReady, - } satisfies CustomWorldWorkSummary; - }); - } - - assemblePublishedItems( - profiles: Array>, - ) { - return profiles.filter(isPublishedLibraryEntry).map((libraryEntry) => { - const profileRecord = libraryEntry.profile as CustomWorldProfileRecord & - Record; - const playableNpcs = toRecordArray(profileRecord.playableNpcs); - const landmarks = toRecordArray(profileRecord.landmarks); - const updatedAt = - toText(libraryEntry.updatedAt) || - toText(profileRecord.updatedAt) || - new Date().toISOString(); - const coverPresentation = resolveRpgWorldPublishedWorkCover(libraryEntry); - const roleVisualReadyCount = playableNpcs.filter( - (entry) => - Boolean(toText(entry.imageSrc)) && - Boolean(toText(entry.generatedVisualAssetId)), - ).length; - const roleAnimationReadyCount = playableNpcs.filter((entry) => - Boolean(toText(entry.generatedAnimationSetId)), - ).length; - - return { - workId: `published:${toText(profileRecord.id) || updatedAt}`, - sourceType: 'published_profile', - status: 'published', - title: - toText(libraryEntry.worldName) || - toText(profileRecord.name) || - '未命名世界', - subtitle: - toText(libraryEntry.subtitle) || - toText(profileRecord.subtitle) || - '已保存作品', - summary: - toText(libraryEntry.summaryText) || - toText(profileRecord.summary) || - '这个世界已经可以直接进入体验。', - coverImageSrc: coverPresentation.imageSrc, - coverRenderMode: coverPresentation.renderMode, - coverCharacterImageSrcs: coverPresentation.characterImageSrcs, - updatedAt, - publishedAt: - toText(libraryEntry.publishedAt) || - toText(profileRecord.publishedAt) || - updatedAt, - stage: 'published', - stageLabel: '已发布', - playableNpcCount: - libraryEntry.playableNpcCount > 0 - ? libraryEntry.playableNpcCount - : playableNpcs.length, - landmarkCount: - libraryEntry.landmarkCount > 0 - ? libraryEntry.landmarkCount - : landmarks.length, - roleVisualReadyCount, - roleAnimationReadyCount, - roleAssetSummaryLabel: - roleAnimationReadyCount > 0 - ? `动作已就绪 ${roleAnimationReadyCount}` - : roleVisualReadyCount > 0 - ? `主图已就绪 ${roleVisualReadyCount}` - : null, - sessionId: null, - profileId: - toText(libraryEntry.profileId) || toText(profileRecord.id) || null, - canResume: false, - canEnterWorld: true, - blockerCount: 0, - publishReady: true, - } satisfies CustomWorldWorkSummary; - }); - } -} diff --git a/server-node/src/services/RpgWorldWorkSummaryService.ts b/server-node/src/services/RpgWorldWorkSummaryService.ts deleted file mode 100644 index 06cd2df1..00000000 --- a/server-node/src/services/RpgWorldWorkSummaryService.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent.js'; -import type { RpgWorldProfileRepositoryPort } from '../repositories/RpgWorldProfileRepository.js'; -import type { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js'; -import { RpgWorldWorkSummaryAssembler } from './RpgWorldWorkSummaryAssembler.js'; - -/** - * RPG 作品卡服务只负责组织“草稿 session + 已发布作品”两类读模型, - * 不再直接承担读库 SQL 或封面字段推导细节。 - */ -export class RpgWorldWorkSummaryService { - private readonly assembler: RpgWorldWorkSummaryAssembler; - - constructor( - private readonly rpgWorldProfiles: RpgWorldProfileRepositoryPort, - private readonly customWorldAgentSessions: CustomWorldAgentSessionStore, - assembler: RpgWorldWorkSummaryAssembler = new RpgWorldWorkSummaryAssembler(), - ) { - this.assembler = assembler; - } - - async list(userId: string): Promise { - const [sessions, profiles] = await Promise.all([ - this.customWorldAgentSessions.list(userId), - this.rpgWorldProfiles.listOwnProfiles(userId), - ]); - - const draftItems = this.assembler.assembleDraftItems(sessions); - const publishedItems = this.assembler.assemblePublishedItems(profiles); - - return [...draftItems, ...publishedItems].sort((left, right) => { - const updatedAtDiff = - new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime(); - if (updatedAtDiff !== 0) { - return updatedAtDiff; - } - - if (left.sourceType !== right.sourceType) { - return left.sourceType === 'agent_session' ? -1 : 1; - } - - return left.workId.localeCompare(right.workId); - }); - } -} diff --git a/server-node/src/services/captchaChallengeStore.ts b/server-node/src/services/captchaChallengeStore.ts deleted file mode 100644 index 1db4ffc7..00000000 --- a/server-node/src/services/captchaChallengeStore.ts +++ /dev/null @@ -1,97 +0,0 @@ -import crypto from 'node:crypto'; - -import type { AuthCaptchaChallenge } from '../../../packages/shared/src/contracts/auth.js'; - -type CaptchaChallengeRecord = { - challengeId: string; - scopeKey: string; - answer: string; - createdAt: string; - expiresAt: string; - imageDataUrl: string; -}; - -function buildCaptchaSvgDataUrl(text: string) { - const lines = Array.from({ length: 4 }, (_, index) => { - const x1 = 8 + index * 18; - const x2 = 150 - index * 16; - const y1 = 12 + index * 8; - const y2 = 46 - index * 6; - return ``; - }).join(''); - - const noise = Array.from(text).map((char, index) => { - const x = 24 + index * 24; - const y = 30 + ((index % 2) * 6 - 3); - const rotate = index % 2 === 0 ? -8 : 7; - return `${char}`; - }).join(''); - - const svg = ` - - -${lines} -${noise} -`; - - return `data:image/svg+xml;base64,${Buffer.from(svg, 'utf8').toString('base64')}`; -} - -function normalizeCaptchaAnswer(answer: string) { - return answer.trim().toLowerCase(); -} - -function buildCaptchaText() { - return crypto.randomBytes(3).toString('hex').slice(0, 5).toUpperCase(); -} - -export class CaptchaChallengeStore { - private readonly challenges = new Map(); - - create(scopeKey: string, expiresInSeconds: number): AuthCaptchaChallenge { - const text = buildCaptchaText(); - const challengeId = `captcha_${crypto.randomBytes(16).toString('hex')}`; - const createdAt = new Date(); - const expiresAt = new Date(createdAt.getTime() + expiresInSeconds * 1000); - - this.challenges.set(challengeId, { - challengeId, - scopeKey, - answer: normalizeCaptchaAnswer(text), - createdAt: createdAt.toISOString(), - expiresAt: expiresAt.toISOString(), - imageDataUrl: buildCaptchaSvgDataUrl(text), - }); - - return { - challengeId, - promptText: '请输入图中的验证码后再获取短信验证码', - imageDataUrl: buildCaptchaSvgDataUrl(text), - expiresInSeconds, - }; - } - - verify(params: { - challengeId: string; - scopeKey: string; - answer: string; - }) { - const record = this.challenges.get(params.challengeId); - if (!record) { - return false; - } - if (record.scopeKey !== params.scopeKey) { - this.challenges.delete(params.challengeId); - return false; - } - if (new Date(record.expiresAt).getTime() <= Date.now()) { - this.challenges.delete(params.challengeId); - return false; - } - - const isValid = - record.answer === normalizeCaptchaAnswer(params.answer); - this.challenges.delete(params.challengeId); - return isValid; - } -} diff --git a/server-node/src/services/chatService.test.ts b/server-node/src/services/chatService.test.ts deleted file mode 100644 index 51b26196..00000000 --- a/server-node/src/services/chatService.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { npcChatTurnRequestSchema } from './chatService.js'; - -test('npc chat turn schema normalizes player and dialogue aliases', () => { - const payload = npcChatTurnRequestSchema.parse({ - worldType: 'WUXIA', - player: { - id: 'hero', - name: '沈行', - }, - encounter: { - id: 'npc-liu', - npcName: '柳无声', - }, - monsters: [], - history: [], - context: { - sceneName: '客栈内室', - }, - dialogue: [ - { - speaker: 'player', - text: '你刚才那句话是什么意思?', - }, - ], - combatContext: { - summary: '你刚和柳无声短兵相接,胜负已分,但话还没有说完。', - logLines: [ - '你侧身避开他的第一刀,反手逼退一步。', - '柳无声被逼到桌角,终于没有继续出手。', - ], - battleOutcome: 'victory', - }, - playerMessage: '你能说得再明白一点吗?', - npcState: { - affinity: 4, - chattedCount: 1, - recruited: false, - }, - questOfferContext: { - state: { - currentScenePreset: { - id: 'scene-inn', - }, - }, - encounter: { - id: 'npc-liu', - npcName: '柳无声', - }, - turnCount: 2, - }, - chatDirective: { - sceneActId: 'scene-inn-act-1', - turnLimit: 5, - remainingTurns: 3, - limitReason: 'negative_affinity', - closingMode: 'free', - forceExitAfterTurn: false, - }, - }); - - assert.equal(payload.character.name, '沈行'); - assert.deepEqual(payload.conversationHistory, [ - { - speaker: 'player', - text: '你刚才那句话是什么意思?', - }, - ]); - assert.equal( - payload.combatContext?.summary, - '你刚和柳无声短兵相接,胜负已分,但话还没有说完。', - ); - assert.deepEqual(payload.combatContext?.logLines, [ - '你侧身避开他的第一刀,反手逼退一步。', - '柳无声被逼到桌角,终于没有继续出手。', - ]); - assert.equal(payload.questOfferContext?.turnCount, 2); - assert.equal(payload.chatDirective?.sceneActId, 'scene-inn-act-1'); - assert.equal(payload.chatDirective?.remainingTurns, 3); -}); diff --git a/server-node/src/services/chatService.ts b/server-node/src/services/chatService.ts deleted file mode 100644 index 4242155e..00000000 --- a/server-node/src/services/chatService.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { z } from 'zod'; - -import type { - CharacterChatReplyRequest, - CharacterChatSuggestionsRequest, - CharacterChatSummaryRequest, - NpcChatDialogueRequest, - NpcChatTurnRequest, - NpcRecruitDialogueRequest, -} from '../../../packages/shared/src/contracts/rpgRuntimeChat.js'; - -const jsonObjectSchema = z.record(z.string(), z.unknown()); - -const baseCharacterChatSchema = z.object({ - worldType: z.string().trim().min(1), - playerCharacter: jsonObjectSchema, - targetCharacter: jsonObjectSchema, - storyHistory: z.array(jsonObjectSchema).default([]), - context: jsonObjectSchema, - conversationHistory: z.array(jsonObjectSchema).default([]), - targetStatus: jsonObjectSchema, -}); - -const baseNpcChatSchema = z.object({ - worldType: z.string().trim().min(1), - character: jsonObjectSchema.optional(), - player: jsonObjectSchema.optional(), - encounter: jsonObjectSchema, - monsters: z.array(jsonObjectSchema).default([]), - history: z.array(jsonObjectSchema).default([]), - context: jsonObjectSchema, -}); - -const npcChatDirectiveSchema = z.object({ - sceneActId: z.string().trim().min(1).nullable().optional(), - turnLimit: z.number().int().nonnegative().nullable().optional(), - remainingTurns: z.number().int().nonnegative().nullable().optional(), - limitReason: z.enum(['negative_affinity']).nullable().optional(), - closingMode: z.enum(['free', 'foreshadow_close']).nullable().optional(), - forceExitAfterTurn: z.boolean().optional(), -}); - -const npcChatQuestOfferContextSchema = z.object({ - state: jsonObjectSchema, - encounter: jsonObjectSchema, - turnCount: z.number().int().nonnegative(), -}); - -const npcChatCombatContextSchema = z.object({ - summary: z.string().trim().min(1), - logLines: z.array(z.string().trim().min(1)).default([]), - battleOutcome: z.enum(['victory', 'spar_complete']), -}); - -export const characterChatReplyRequestSchema = baseCharacterChatSchema.extend({ - conversationSummary: z.string().optional().default(''), - playerMessage: z.string().trim().min(1), -}) satisfies z.ZodType; - -export const characterChatSuggestionsRequestSchema = - baseCharacterChatSchema.extend({ - conversationSummary: z.string().optional().default(''), - }) satisfies z.ZodType; - -export const characterChatSummaryRequestSchema = baseCharacterChatSchema.extend( - { - previousSummary: z.string().optional().default(''), - }, -) satisfies z.ZodType; - -export const npcChatDialogueRequestSchema = baseNpcChatSchema.extend({ - character: jsonObjectSchema, - topic: z.string().trim().min(1), - resultSummary: z.string().optional().default(''), - npcInitiatesConversation: z.boolean().optional(), -}) satisfies z.ZodType; - -export const npcChatTurnRequestSchema = baseNpcChatSchema - .extend({ - conversationHistory: z.array(jsonObjectSchema).optional(), - dialogue: z.array(jsonObjectSchema).optional(), - combatContext: npcChatCombatContextSchema.nullable().optional(), - playerMessage: z.string().trim().min(1), - npcState: jsonObjectSchema, - npcInitiatesConversation: z.boolean().optional(), - questOfferContext: npcChatQuestOfferContextSchema.nullable().optional(), - chatDirective: npcChatDirectiveSchema.nullable().optional(), - }) - .superRefine((value, ctx) => { - if (!value.character && !value.player) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'npc chat turn request requires character or player', - path: ['character'], - }); - } - }) - .transform((value) => ({ - ...value, - character: value.character ?? value.player ?? {}, - conversationHistory: value.conversationHistory ?? value.dialogue ?? [], - })) satisfies z.ZodType; - -export const npcRecruitDialogueRequestSchema = baseNpcChatSchema.extend({ - character: jsonObjectSchema, - invitationText: z.string().trim().min(1), - recruitSummary: z.string().optional().default(''), -}) satisfies z.ZodType; diff --git a/server-node/src/services/customWorldAgentActionExecutors/draftFoundationExecutor.ts b/server-node/src/services/customWorldAgentActionExecutors/draftFoundationExecutor.ts deleted file mode 100644 index 9cfd0031..00000000 --- a/server-node/src/services/customWorldAgentActionExecutors/draftFoundationExecutor.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { - buildCreatorIntentFromEightAnchorContent, - buildAnchorPackFromEightAnchorContent, -} from '../eightAnchorCompatibilityService.js'; -import { rebuildRoleAssetCoverage } from '../customWorldAgentRoleAssetStateService.js'; -import type { CustomWorldAgentActionExecutor } from './types.js'; -import type { CustomWorldAgentFoundationDraftService } from '../customWorldAgentFoundationDraftService.js'; -import type { CustomWorldAgentAutoAssetService } from '../customWorldAgentAutoAssetService.js'; -import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js'; -import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; -import { buildFoundationDraftAssistantMessage } from './helpers.js'; -import { - buildCheckpointSnapshot, - createOperationUpdater, - getRequiredSession, -} from './executorShared.js'; - -export function createDraftFoundationExecutor(params: { - sessionStore: CustomWorldAgentSessionStore; - foundationDraftService: CustomWorldAgentFoundationDraftService; - autoAssetService: CustomWorldAgentAutoAssetService | null; - snapshotBuilder: CustomWorldAgentSnapshotBuilder; -}): CustomWorldAgentActionExecutor<'draft_foundation'> { - return async ({ userId, sessionId, operationId }) => { - const updateOperation = createOperationUpdater({ - sessionStore: params.sessionStore, - userId, - sessionId, - operationId, - }); - - try { - await updateOperation({ - status: 'running', - phaseLabel: '整理世界骨架', - phaseDetail: '正在校验已确认锚点,并准备第一版世界框架生成链路。', - progress: 12, - }); - - const latestSession = await getRequiredSession({ - sessionStore: params.sessionStore, - userId, - sessionId, - }); - if (latestSession.progressPercent < 100) { - throw new Error('session progressPercent is below 100'); - } - - const creatorIntent = buildCreatorIntentFromEightAnchorContent( - latestSession.anchorContent, - ); - const anchorPack = buildAnchorPackFromEightAnchorContent( - latestSession.anchorContent, - latestSession.progressPercent, - ); - const draftProfile = await params.foundationDraftService.generate({ - creatorIntent, - anchorPack, - anchorContent: latestSession.anchorContent, - onProgress: async (progress) => { - await updateOperation({ - status: 'running', - phaseLabel: progress.phaseLabel, - phaseDetail: progress.phaseDetail, - progress: progress.progress, - }); - }, - }); - - const draftWithAssets = params.autoAssetService - ? await params.autoAssetService.populateDraftAssets({ - draftProfile, - onProgress: async (progress) => { - await updateOperation({ - status: 'running', - phaseLabel: progress.phaseLabel, - phaseDetail: progress.phaseDetail, - progress: progress.progress, - }); - }, - }) - : { - draftProfile, - assetCoverage: rebuildRoleAssetCoverage(draftProfile), - warnings: [], - }; - - await updateOperation({ - phaseLabel: '编译草稿卡', - phaseDetail: '正在把世界底稿整理成可浏览的卡片摘要和详情结构。', - progress: 98, - }); - - const nextState = params.snapshotBuilder.buildFoundationDraftState({ - creatorIntent, - anchorPack, - draftProfile: - draftWithAssets.draftProfile as unknown as Record, - assetCoverage: draftWithAssets.assetCoverage, - }); - - await params.sessionStore.replaceDerivedState(userId, sessionId, nextState); - await params.sessionStore.appendCheckpoint(userId, sessionId, { - label: '世界底稿 V1', - snapshot: buildCheckpointSnapshot(latestSession, nextState), - }); - await params.sessionStore.appendMessage( - userId, - sessionId, - buildFoundationDraftAssistantMessage({ - relatedOperationId: operationId, - draftProfile: draftWithAssets.draftProfile, - warnings: draftWithAssets.warnings, - }), - ); - - await updateOperation({ - status: 'completed', - phaseLabel: '世界底稿已生成', - phaseDetail: - draftWithAssets.warnings.length > 0 - ? `第一版世界底稿和 ${(nextState.draftCards ?? []).length} 张草稿卡已经整理完成,另有 ${draftWithAssets.warnings.length} 项资产补齐待后续处理。` - : `第一版世界底稿和 ${(nextState.draftCards ?? []).length} 张草稿卡已经整理完成。`, - progress: 100, - error: null, - }); - } catch (error) { - const currentOperation = await params.sessionStore.getOperation( - userId, - sessionId, - operationId, - ); - await updateOperation({ - status: 'failed', - phaseLabel: currentOperation?.phaseLabel?.trim() || '底稿生成失败', - phaseDetail: - currentOperation?.phaseDetail?.trim() || - '这一轮没有成功把设定编成世界底稿。', - progress: 100, - error: - error instanceof Error ? error.message : 'draft foundation failed', - }); - } - }; -} diff --git a/server-node/src/services/customWorldAgentActionExecutors/executorShared.ts b/server-node/src/services/customWorldAgentActionExecutors/executorShared.ts deleted file mode 100644 index 6c046b2c..00000000 --- a/server-node/src/services/customWorldAgentActionExecutors/executorShared.ts +++ /dev/null @@ -1,108 +0,0 @@ -import type { CustomWorldAgentOperationRecord } from '../../../../packages/shared/src/contracts/customWorldAgent.js'; -import type { CustomWorldAgentSessionRecord, CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; - -export type UpdateExecutorOperation = ( - patch: Partial, -) => Promise; - -export async function getRequiredSession(params: { - sessionStore: CustomWorldAgentSessionStore; - userId: string; - sessionId: string; -}) { - const session = (await params.sessionStore.get( - params.userId, - params.sessionId, - )) as CustomWorldAgentSessionRecord | null; - if (!session) { - throw new Error('custom world agent session not found'); - } - - return session; -} - -export function createOperationUpdater(params: { - sessionStore: CustomWorldAgentSessionStore; - userId: string; - sessionId: string; - operationId: string; -}): UpdateExecutorOperation { - return (patch) => - params.sessionStore.updateOperation( - params.userId, - params.sessionId, - params.operationId, - patch, - ); -} - -// checkpoint 恢复依赖这份最小可回放快照,统一由 executor 共享,避免每个动作手写字段集合。 -export function buildCheckpointSnapshot( - session: CustomWorldAgentSessionRecord, - patch: Partial< - Pick< - CustomWorldAgentSessionRecord, - | 'currentTurn' - | 'anchorContent' - | 'progressPercent' - | 'lastAssistantReply' - | 'stage' - | 'focusCardId' - | 'creatorIntent' - | 'creatorIntentReadiness' - | 'anchorPack' - | 'lockState' - | 'draftProfile' - | 'pendingClarifications' - | 'suggestedActions' - | 'recommendedReplies' - | 'draftCards' - | 'qualityFindings' - | 'assetCoverage' - > - >, -) { - return { - currentTurn: patch.currentTurn ?? session.currentTurn, - anchorContent: patch.anchorContent ?? session.anchorContent, - progressPercent: patch.progressPercent ?? session.progressPercent, - lastAssistantReply: - patch.lastAssistantReply !== undefined - ? patch.lastAssistantReply - : session.lastAssistantReply, - stage: patch.stage ?? session.stage, - focusCardId: - patch.focusCardId !== undefined ? patch.focusCardId : session.focusCardId, - creatorIntent: - patch.creatorIntent !== undefined - ? patch.creatorIntent - : session.creatorIntent, - creatorIntentReadiness: - patch.creatorIntentReadiness ?? session.creatorIntentReadiness, - anchorPack: patch.anchorPack !== undefined ? patch.anchorPack : session.anchorPack, - lockState: patch.lockState !== undefined ? patch.lockState : session.lockState, - draftProfile: - patch.draftProfile !== undefined ? patch.draftProfile : session.draftProfile, - pendingClarifications: - patch.pendingClarifications !== undefined - ? patch.pendingClarifications - : session.pendingClarifications, - suggestedActions: - patch.suggestedActions !== undefined - ? patch.suggestedActions - : session.suggestedActions, - recommendedReplies: - patch.recommendedReplies !== undefined - ? patch.recommendedReplies - : session.recommendedReplies, - draftCards: patch.draftCards !== undefined ? patch.draftCards : session.draftCards, - qualityFindings: - patch.qualityFindings !== undefined - ? patch.qualityFindings - : session.qualityFindings, - assetCoverage: - patch.assetCoverage !== undefined - ? patch.assetCoverage - : session.assetCoverage, - }; -} diff --git a/server-node/src/services/customWorldAgentActionExecutors/expandLongTailExecutor.ts b/server-node/src/services/customWorldAgentActionExecutors/expandLongTailExecutor.ts deleted file mode 100644 index 343473fb..00000000 --- a/server-node/src/services/customWorldAgentActionExecutors/expandLongTailExecutor.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { getWorldFoundationCardId } from '../customWorldAgentDraftCompiler.js'; -import type { CustomWorldAgentActionExecutor } from './types.js'; -import type { CustomWorldAgentEntityGenerationService } from '../customWorldAgentEntityGenerationService.js'; -import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js'; -import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; -import { buildActionResultMessage } from './helpers.js'; -import { - buildCheckpointSnapshot, - createOperationUpdater, - getRequiredSession, -} from './executorShared.js'; - -export function createExpandLongTailExecutor(params: { - sessionStore: CustomWorldAgentSessionStore; - entityGenerationService: CustomWorldAgentEntityGenerationService; - snapshotBuilder: CustomWorldAgentSnapshotBuilder; -}): CustomWorldAgentActionExecutor<'expand_long_tail'> { - return async ({ userId, sessionId, operationId }) => { - const updateOperation = createOperationUpdater({ - sessionStore: params.sessionStore, - userId, - sessionId, - operationId, - }); - - try { - await updateOperation({ - status: 'running', - phaseLabel: '扩展长尾内容', - phaseDetail: '正在补充边缘角色与次级地点,让世界草稿更完整可玩。', - progress: 28, - }); - - const latestSession = await getRequiredSession({ - sessionStore: params.sessionStore, - userId, - sessionId, - }); - const baseDraftProfile = - (latestSession.draftProfile ?? {}) as Record; - const characterResult = - await params.entityGenerationService.generateAdditionalCharacters({ - creatorIntent: latestSession.creatorIntent, - anchorPack: latestSession.anchorPack, - draftProfile: baseDraftProfile, - count: 2, - anchorCardIds: - latestSession.focusCardId && latestSession.focusCardId.trim() - ? [latestSession.focusCardId] - : [getWorldFoundationCardId()], - }); - - await updateOperation({ - phaseLabel: '补充次级地点', - phaseDetail: '正在围绕新线索补齐可承接支线与长尾内容的地点。', - progress: 62, - }); - - const landmarkResult = - await params.entityGenerationService.generateAdditionalLandmarks({ - creatorIntent: latestSession.creatorIntent, - anchorPack: latestSession.anchorPack, - draftProfile: characterResult.draftProfile, - count: 2, - anchorCardIds: - characterResult.generatedCharacters.length > 0 - ? [characterResult.generatedCharacters[0]!.id] - : latestSession.focusCardId && latestSession.focusCardId.trim() - ? [latestSession.focusCardId] - : [getWorldFoundationCardId()], - }); - - const focusCardId = - landmarkResult.generatedLandmarks[0]?.id ?? - characterResult.generatedCharacters[0]?.id ?? - latestSession.focusCardId; - const nextState = params.snapshotBuilder.buildRefiningState({ - previousStage: latestSession.stage, - nextStage: 'long_tail_review', - draftProfile: landmarkResult.draftProfile, - focusCardId, - }); - - await params.sessionStore.replaceDerivedState(userId, sessionId, nextState); - await params.sessionStore.appendCheckpoint(userId, sessionId, { - label: `扩展长尾 ${characterResult.generatedCharacters.length} 角色 / ${landmarkResult.generatedLandmarks.length} 地点`, - snapshot: buildCheckpointSnapshot(latestSession, nextState), - }); - await params.sessionStore.appendMessage( - userId, - sessionId, - buildActionResultMessage({ - relatedOperationId: operationId, - text: `已补出 ${characterResult.generatedCharacters.length} 个长尾角色和 ${landmarkResult.generatedLandmarks.length} 个次级地点,当前阶段进入补全长尾内容。`, - }), - ); - - await updateOperation({ - status: 'completed', - phaseLabel: '长尾内容已扩展', - phaseDetail: '长尾角色与次级地点已经补回草稿,可继续收口后进入发布前检查。', - progress: 100, - error: null, - }); - } catch (error) { - await updateOperation({ - status: 'failed', - phaseLabel: '扩展长尾失败', - phaseDetail: '这一轮没有成功补出长尾内容。', - progress: 100, - error: - error instanceof Error ? error.message : 'expand long tail failed', - }); - } - }; -} diff --git a/server-node/src/services/customWorldAgentActionExecutors/generateCharactersExecutor.ts b/server-node/src/services/customWorldAgentActionExecutors/generateCharactersExecutor.ts deleted file mode 100644 index 4931f0e5..00000000 --- a/server-node/src/services/customWorldAgentActionExecutors/generateCharactersExecutor.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { getWorldFoundationCardId } from '../customWorldAgentDraftCompiler.js'; -import type { CustomWorldAgentActionExecutor } from './types.js'; -import type { CustomWorldAgentEntityGenerationService } from '../customWorldAgentEntityGenerationService.js'; -import type { CustomWorldAgentChangeSummaryService } from '../customWorldAgentChangeSummaryService.js'; -import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js'; -import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; -import { buildActionResultMessage } from './helpers.js'; -import { - buildCheckpointSnapshot, - createOperationUpdater, - getRequiredSession, -} from './executorShared.js'; - -export function createGenerateCharactersExecutor(params: { - sessionStore: CustomWorldAgentSessionStore; - entityGenerationService: CustomWorldAgentEntityGenerationService; - changeSummaryService: CustomWorldAgentChangeSummaryService; - snapshotBuilder: CustomWorldAgentSnapshotBuilder; -}): CustomWorldAgentActionExecutor<'generate_characters'> { - return async ({ userId, sessionId, operationId, payload }) => { - const updateOperation = createOperationUpdater({ - sessionStore: params.sessionStore, - userId, - sessionId, - operationId, - }); - - try { - await updateOperation({ - status: 'running', - phaseLabel: '生成新角色', - phaseDetail: '正在围绕当前世界底稿补出新角色。', - progress: 32, - }); - - const latestSession = await getRequiredSession({ - sessionStore: params.sessionStore, - userId, - sessionId, - }); - const generationResult = - await params.entityGenerationService.generateAdditionalCharacters({ - creatorIntent: latestSession.creatorIntent, - anchorPack: latestSession.anchorPack, - draftProfile: (latestSession.draftProfile ?? {}) as Record< - string, - unknown - >, - count: payload.count, - promptText: payload.promptText, - anchorCardIds: - payload.anchorCardIds && payload.anchorCardIds.length > 0 - ? payload.anchorCardIds - : latestSession.focusCardId - ? [latestSession.focusCardId] - : [getWorldFoundationCardId()], - }); - - await updateOperation({ - phaseLabel: '插入新角色卡', - phaseDetail: '正在把新角色插回草稿并刷新卡片列表。', - progress: 74, - }); - - const focusCardId = generationResult.generatedCharacters[0]?.id ?? null; - const nextState = params.snapshotBuilder.buildRefiningState({ - previousStage: latestSession.stage, - draftProfile: generationResult.draftProfile, - focusCardId, - }); - - await params.sessionStore.replaceDerivedState(userId, sessionId, nextState); - await params.sessionStore.appendCheckpoint(userId, sessionId, { - label: `新增角色 ${generationResult.generatedCharacters.length} 个`, - snapshot: buildCheckpointSnapshot(latestSession, nextState), - }); - await params.sessionStore.appendMessage( - userId, - sessionId, - buildActionResultMessage({ - relatedOperationId: operationId, - text: params.changeSummaryService.buildSummary({ - action: 'generate_characters', - names: generationResult.generatedCharacters.map( - (entry) => entry.name, - ), - draftProfile: generationResult.draftProfile, - }), - }), - ); - - await updateOperation({ - status: 'completed', - phaseLabel: '新角色已加入草稿', - phaseDetail: `已补出 ${generationResult.generatedCharacters.length} 个新角色。`, - progress: 100, - error: null, - }); - } catch (error) { - await updateOperation({ - status: 'failed', - phaseLabel: '角色生成失败', - phaseDetail: '这一轮没有成功补出新角色。', - progress: 100, - error: - error instanceof Error ? error.message : 'generate characters failed', - }); - } - }; -} diff --git a/server-node/src/services/customWorldAgentActionExecutors/generateLandmarksExecutor.ts b/server-node/src/services/customWorldAgentActionExecutors/generateLandmarksExecutor.ts deleted file mode 100644 index 1277ff4e..00000000 --- a/server-node/src/services/customWorldAgentActionExecutors/generateLandmarksExecutor.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { getWorldFoundationCardId } from '../customWorldAgentDraftCompiler.js'; -import type { CustomWorldAgentActionExecutor } from './types.js'; -import type { CustomWorldAgentEntityGenerationService } from '../customWorldAgentEntityGenerationService.js'; -import type { CustomWorldAgentChangeSummaryService } from '../customWorldAgentChangeSummaryService.js'; -import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js'; -import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; -import { buildActionResultMessage } from './helpers.js'; -import { - buildCheckpointSnapshot, - createOperationUpdater, - getRequiredSession, -} from './executorShared.js'; - -export function createGenerateLandmarksExecutor(params: { - sessionStore: CustomWorldAgentSessionStore; - entityGenerationService: CustomWorldAgentEntityGenerationService; - changeSummaryService: CustomWorldAgentChangeSummaryService; - snapshotBuilder: CustomWorldAgentSnapshotBuilder; -}): CustomWorldAgentActionExecutor<'generate_landmarks'> { - return async ({ userId, sessionId, operationId, payload }) => { - const updateOperation = createOperationUpdater({ - sessionStore: params.sessionStore, - userId, - sessionId, - operationId, - }); - - try { - await updateOperation({ - status: 'running', - phaseLabel: '生成新地点', - phaseDetail: '正在围绕当前世界底稿补出新地点。', - progress: 32, - }); - - const latestSession = await getRequiredSession({ - sessionStore: params.sessionStore, - userId, - sessionId, - }); - const generationResult = - await params.entityGenerationService.generateAdditionalLandmarks({ - creatorIntent: latestSession.creatorIntent, - anchorPack: latestSession.anchorPack, - draftProfile: (latestSession.draftProfile ?? {}) as Record< - string, - unknown - >, - count: payload.count, - promptText: payload.promptText, - anchorCardIds: - payload.anchorCardIds && payload.anchorCardIds.length > 0 - ? payload.anchorCardIds - : latestSession.focusCardId - ? [latestSession.focusCardId] - : [getWorldFoundationCardId()], - }); - - await updateOperation({ - phaseLabel: '插入新地点卡', - phaseDetail: '正在把新地点插回草稿并刷新卡片列表。', - progress: 74, - }); - - const focusCardId = generationResult.generatedLandmarks[0]?.id ?? null; - const nextState = params.snapshotBuilder.buildRefiningState({ - previousStage: latestSession.stage, - draftProfile: generationResult.draftProfile, - focusCardId, - }); - - await params.sessionStore.replaceDerivedState(userId, sessionId, nextState); - await params.sessionStore.appendCheckpoint(userId, sessionId, { - label: `新增地点 ${generationResult.generatedLandmarks.length} 个`, - snapshot: buildCheckpointSnapshot(latestSession, nextState), - }); - await params.sessionStore.appendMessage( - userId, - sessionId, - buildActionResultMessage({ - relatedOperationId: operationId, - text: params.changeSummaryService.buildSummary({ - action: 'generate_landmarks', - names: generationResult.generatedLandmarks.map( - (entry) => entry.name, - ), - draftProfile: generationResult.draftProfile, - }), - }), - ); - - await updateOperation({ - status: 'completed', - phaseLabel: '新地点已加入草稿', - phaseDetail: `已补出 ${generationResult.generatedLandmarks.length} 个新地点。`, - progress: 100, - error: null, - }); - } catch (error) { - await updateOperation({ - status: 'failed', - phaseLabel: '地点生成失败', - phaseDetail: '这一轮没有成功补出新地点。', - progress: 100, - error: - error instanceof Error ? error.message : 'generate landmarks failed', - }); - } - }; -} diff --git a/server-node/src/services/customWorldAgentActionExecutors/generateRoleAssetsExecutor.ts b/server-node/src/services/customWorldAgentActionExecutors/generateRoleAssetsExecutor.ts deleted file mode 100644 index 2daea938..00000000 --- a/server-node/src/services/customWorldAgentActionExecutors/generateRoleAssetsExecutor.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { CustomWorldAgentActionExecutor } from './types.js'; -import type { CustomWorldAgentAssetBridgeService } from '../customWorldAgentAssetBridgeService.js'; -import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js'; -import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; -import { buildActionResultMessage } from './helpers.js'; -import { - createOperationUpdater, - getRequiredSession, -} from './executorShared.js'; - -export function createGenerateRoleAssetsExecutor(params: { - sessionStore: CustomWorldAgentSessionStore; - assetBridgeService: CustomWorldAgentAssetBridgeService; - snapshotBuilder: CustomWorldAgentSnapshotBuilder; -}): CustomWorldAgentActionExecutor<'generate_role_assets'> { - return async ({ userId, sessionId, operationId, payload }) => { - const updateOperation = createOperationUpdater({ - sessionStore: params.sessionStore, - userId, - sessionId, - operationId, - }); - - try { - await updateOperation({ - status: 'running', - phaseLabel: '准备角色资产工坊', - phaseDetail: '正在校验角色并整理工坊上下文。', - progress: 40, - }); - - const latestSession = await getRequiredSession({ - sessionStore: params.sessionStore, - userId, - sessionId, - }); - const roleId = payload.roleIds[0]!; - const studioContext = params.assetBridgeService.buildRoleAssetStudioContext( - latestSession.draftProfile, - roleId, - ); - const nextState = params.snapshotBuilder.buildRefiningState({ - previousStage: latestSession.stage, - nextStage: 'visual_refining', - draftProfile: - (latestSession.draftProfile ?? {}) as Record, - draftCards: latestSession.draftCards, - assetCoverage: latestSession.assetCoverage, - focusCardId: roleId, - }); - - await params.sessionStore.replaceDerivedState(userId, sessionId, nextState); - await params.sessionStore.appendMessage( - userId, - sessionId, - buildActionResultMessage({ - relatedOperationId: operationId, - text: `已为「${studioContext.roleName}」准备好角色资产工坊,先生成主图候选,再补核心动作。`, - }), - ); - - await updateOperation({ - status: 'completed', - phaseLabel: '角色资产工坊已就绪', - phaseDetail: `「${studioContext.roleName}」现在可以开始生成主图和动作。`, - progress: 100, - error: null, - }); - } catch (error) { - await updateOperation({ - status: 'failed', - phaseLabel: '角色资产工坊准备失败', - phaseDetail: '这一轮没有成功进入角色资产工坊。', - progress: 100, - error: - error instanceof Error - ? error.message - : 'generate role assets failed', - }); - } - }; -} diff --git a/server-node/src/services/customWorldAgentActionExecutors/generateSceneAssetsExecutor.ts b/server-node/src/services/customWorldAgentActionExecutors/generateSceneAssetsExecutor.ts deleted file mode 100644 index e2e69ad6..00000000 --- a/server-node/src/services/customWorldAgentActionExecutors/generateSceneAssetsExecutor.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { CustomWorldAgentActionExecutor } from './types.js'; -import type { CustomWorldAgentAssetBridgeService } from '../customWorldAgentAssetBridgeService.js'; -import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js'; -import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; -import { buildActionResultMessage } from './helpers.js'; -import { - createOperationUpdater, - getRequiredSession, -} from './executorShared.js'; - -export function createGenerateSceneAssetsExecutor(params: { - sessionStore: CustomWorldAgentSessionStore; - assetBridgeService: CustomWorldAgentAssetBridgeService; - snapshotBuilder: CustomWorldAgentSnapshotBuilder; -}): CustomWorldAgentActionExecutor<'generate_scene_assets'> { - return async ({ userId, sessionId, operationId, payload }) => { - const updateOperation = createOperationUpdater({ - sessionStore: params.sessionStore, - userId, - sessionId, - operationId, - }); - - try { - await updateOperation({ - status: 'running', - phaseLabel: '准备场景资产工坊', - phaseDetail: '正在校验目标场景并整理场景图工坊上下文。', - progress: 40, - }); - - const latestSession = await getRequiredSession({ - sessionStore: params.sessionStore, - userId, - sessionId, - }); - const sceneId = payload.sceneIds[0]!; - const sceneKind = - latestSession.draftCards.find((entry) => entry.id === sceneId)?.kind === - 'camp' - ? 'camp' - : 'landmark'; - const sceneContext = params.assetBridgeService.buildSceneAssetStudioContext( - latestSession.draftProfile, - sceneId, - sceneKind, - ); - const nextState = params.snapshotBuilder.buildRefiningState({ - previousStage: latestSession.stage, - nextStage: 'visual_refining', - draftProfile: - (latestSession.draftProfile ?? {}) as Record, - draftCards: latestSession.draftCards, - assetCoverage: latestSession.assetCoverage, - focusCardId: sceneId, - }); - - await params.sessionStore.replaceDerivedState(userId, sessionId, nextState); - await params.sessionStore.appendMessage( - userId, - sessionId, - buildActionResultMessage({ - relatedOperationId: operationId, - text: `已为「${sceneContext.sceneName}」准备好场景图工坊,保存生成结果后会自动同步回当前草稿。`, - }), - ); - - await updateOperation({ - status: 'completed', - phaseLabel: '场景资产工坊已就绪', - phaseDetail: `「${sceneContext.sceneName}」现在可以继续生成和确认正式场景图。`, - progress: 100, - error: null, - }); - } catch (error) { - await updateOperation({ - status: 'failed', - phaseLabel: '场景资产工坊准备失败', - phaseDetail: '这一轮没有成功进入场景资产工坊。', - progress: 100, - error: - error instanceof Error - ? error.message - : 'generate scene assets failed', - }); - } - }; -} diff --git a/server-node/src/services/customWorldAgentActionExecutors/helpers.ts b/server-node/src/services/customWorldAgentActionExecutors/helpers.ts deleted file mode 100644 index 1c838f4f..00000000 --- a/server-node/src/services/customWorldAgentActionExecutors/helpers.ts +++ /dev/null @@ -1,58 +0,0 @@ -import crypto from 'node:crypto'; - -import type { CustomWorldAgentMessage } from '../../../../packages/shared/src/contracts/customWorldAgent.js'; -import { - normalizeFoundationDraftProfile, -} from '../customWorldAgentDraftCompiler.js'; - -export function buildRoleAssetSyncResultText(params: { - roleName: string; - assetStatusLabel: string; -}) { - return `已把「${params.roleName}」的角色资产写回草稿,当前状态:${params.assetStatusLabel}。`; -} - -export function buildFoundationDraftAssistantMessage(params: { - relatedOperationId: string; - draftProfile: unknown; - warnings?: string[]; -}) { - const profile = normalizeFoundationDraftProfile(params.draftProfile); - const leadCharacter = profile?.playableNpcs[0]; - const leadLandmark = profile?.landmarks[0]; - const warnings = (params.warnings ?? []).filter(Boolean); - - return { - id: `message-${crypto.randomBytes(8).toString('hex')}`, - role: 'assistant', - kind: 'summary', - text: [ - `我先把第一版世界底稿整理出来了:${profile?.summary || '底稿已经生成完成。'}`, - '', - `当前已经落下来的第一批对象数量是:关键角色 ${profile?.playableNpcs.length ?? 0} 个,关键地点 ${profile?.landmarks.length ?? 0} 个,势力 ${profile?.factions.length ?? 0} 个。`, - `建议你先从“${profile?.name || '世界总卡'}”这张世界总卡看起${leadCharacter ? `,再顺着角色「${leadCharacter.name}」往下细修` : ''}${leadLandmark ? `,地点可以先看「${leadLandmark.name}」` : ''}。`, - ...(warnings.length > 0 - ? [ - '', - `这一轮有 ${warnings.length} 项资产补齐未完成,但不影响世界底稿继续精修。`, - ] - : []), - ].join('\n'), - createdAt: new Date().toISOString(), - relatedOperationId: params.relatedOperationId, - } satisfies CustomWorldAgentMessage; -} - -export function buildActionResultMessage(params: { - relatedOperationId: string; - text: string; -}) { - return { - id: `message-${crypto.randomBytes(8).toString('hex')}`, - role: 'assistant', - kind: 'action_result', - text: params.text, - createdAt: new Date().toISOString(), - relatedOperationId: params.relatedOperationId, - } satisfies CustomWorldAgentMessage; -} diff --git a/server-node/src/services/customWorldAgentActionExecutors/index.ts b/server-node/src/services/customWorldAgentActionExecutors/index.ts deleted file mode 100644 index 0dd62e53..00000000 --- a/server-node/src/services/customWorldAgentActionExecutors/index.ts +++ /dev/null @@ -1,105 +0,0 @@ -import type { CustomWorldAgentAutoAssetService } from '../customWorldAgentAutoAssetService.js'; -import type { CustomWorldAgentAssetBridgeService } from '../customWorldAgentAssetBridgeService.js'; -import type { CustomWorldAgentChangeSummaryService } from '../customWorldAgentChangeSummaryService.js'; -import type { CustomWorldAgentDraftCompiler } from '../customWorldAgentDraftCompiler.js'; -import type { CustomWorldAgentEntityGenerationService } from '../customWorldAgentEntityGenerationService.js'; -import type { CustomWorldAgentFoundationDraftService } from '../customWorldAgentFoundationDraftService.js'; -import type { CustomWorldAgentPublishingService } from '../customWorldAgentPublishingService.js'; -import type { CustomWorldAgentResultSyncService } from '../customWorldAgentResultSyncService.js'; -import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; -import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js'; -import { createDraftFoundationExecutor } from './draftFoundationExecutor.js'; -import { createExpandLongTailExecutor } from './expandLongTailExecutor.js'; -import { createGenerateCharactersExecutor } from './generateCharactersExecutor.js'; -import { createGenerateLandmarksExecutor } from './generateLandmarksExecutor.js'; -import { createGenerateRoleAssetsExecutor } from './generateRoleAssetsExecutor.js'; -import { createGenerateSceneAssetsExecutor } from './generateSceneAssetsExecutor.js'; -import { createPublishWorldExecutor } from './publishWorldExecutor.js'; -import { createRevertCheckpointExecutor } from './revertCheckpointExecutor.js'; -import { createSyncResultProfileExecutor } from './syncResultProfileExecutor.js'; -import { createSyncRoleAssetsExecutor } from './syncRoleAssetsExecutor.js'; -import { createSyncSceneAssetsExecutor } from './syncSceneAssetsExecutor.js'; -import type { CustomWorldAgentActionExecutorMap } from './types.js'; -import { createUpdateDraftCardExecutor } from './updateDraftCardExecutor.js'; - -export * from './types.js'; - -export function createCustomWorldAgentActionExecutorMap(params: { - sessionStore: CustomWorldAgentSessionStore; - foundationDraftService: CustomWorldAgentFoundationDraftService; - draftCompiler: CustomWorldAgentDraftCompiler; - entityGenerationService: CustomWorldAgentEntityGenerationService; - changeSummaryService: CustomWorldAgentChangeSummaryService; - assetBridgeService: CustomWorldAgentAssetBridgeService; - autoAssetService: CustomWorldAgentAutoAssetService | null; - snapshotBuilder: CustomWorldAgentSnapshotBuilder; - resultSyncService: CustomWorldAgentResultSyncService; - publishingService: CustomWorldAgentPublishingService; - resolveAuthorDisplayName?: ((userId: string) => Promise) | null; -}): CustomWorldAgentActionExecutorMap { - return { - draft_foundation: createDraftFoundationExecutor({ - sessionStore: params.sessionStore, - foundationDraftService: params.foundationDraftService, - autoAssetService: params.autoAssetService, - snapshotBuilder: params.snapshotBuilder, - }), - update_draft_card: createUpdateDraftCardExecutor({ - sessionStore: params.sessionStore, - draftCompiler: params.draftCompiler, - changeSummaryService: params.changeSummaryService, - snapshotBuilder: params.snapshotBuilder, - }), - sync_result_profile: createSyncResultProfileExecutor({ - sessionStore: params.sessionStore, - resultSyncService: params.resultSyncService, - snapshotBuilder: params.snapshotBuilder, - }), - generate_characters: createGenerateCharactersExecutor({ - sessionStore: params.sessionStore, - entityGenerationService: params.entityGenerationService, - changeSummaryService: params.changeSummaryService, - snapshotBuilder: params.snapshotBuilder, - }), - generate_landmarks: createGenerateLandmarksExecutor({ - sessionStore: params.sessionStore, - entityGenerationService: params.entityGenerationService, - changeSummaryService: params.changeSummaryService, - snapshotBuilder: params.snapshotBuilder, - }), - generate_role_assets: createGenerateRoleAssetsExecutor({ - sessionStore: params.sessionStore, - assetBridgeService: params.assetBridgeService, - snapshotBuilder: params.snapshotBuilder, - }), - sync_role_assets: createSyncRoleAssetsExecutor({ - sessionStore: params.sessionStore, - assetBridgeService: params.assetBridgeService, - snapshotBuilder: params.snapshotBuilder, - }), - generate_scene_assets: createGenerateSceneAssetsExecutor({ - sessionStore: params.sessionStore, - assetBridgeService: params.assetBridgeService, - snapshotBuilder: params.snapshotBuilder, - }), - sync_scene_assets: createSyncSceneAssetsExecutor({ - sessionStore: params.sessionStore, - assetBridgeService: params.assetBridgeService, - snapshotBuilder: params.snapshotBuilder, - }), - expand_long_tail: createExpandLongTailExecutor({ - sessionStore: params.sessionStore, - entityGenerationService: params.entityGenerationService, - snapshotBuilder: params.snapshotBuilder, - }), - publish_world: createPublishWorldExecutor({ - sessionStore: params.sessionStore, - publishingService: params.publishingService, - resolveAuthorDisplayName: params.resolveAuthorDisplayName ?? null, - }), - revert_checkpoint: createRevertCheckpointExecutor({ - sessionStore: params.sessionStore, - snapshotBuilder: params.snapshotBuilder, - }), - }; -} diff --git a/server-node/src/services/customWorldAgentActionExecutors/publishWorldExecutor.ts b/server-node/src/services/customWorldAgentActionExecutors/publishWorldExecutor.ts deleted file mode 100644 index b3b4694f..00000000 --- a/server-node/src/services/customWorldAgentActionExecutors/publishWorldExecutor.ts +++ /dev/null @@ -1,166 +0,0 @@ -import type { CustomWorldAgentActionExecutor } from './types.js'; -import type { CustomWorldAgentPublishingService } from '../customWorldAgentPublishingService.js'; -import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; -import { buildActionResultMessage } from './helpers.js'; -import { - buildCheckpointSnapshot, - createOperationUpdater, - getRequiredSession, -} from './executorShared.js'; - -function toText(value: unknown) { - return typeof value === 'string' ? value.trim() : ''; -} - -function extractPublishBlockerMessages(message: string) { - const normalized = message.trim(); - if (!normalized) { - return []; - } - - const detailText = normalized.includes(':') - ? normalized.split(':').slice(1).join(':').trim() - : normalized; - - return detailText - .split(';') - .map((entry) => entry.trim()) - .filter(Boolean); -} - -function buildGateFailureMessage(errorMessage: string) { - return [ - '当前世界还不能发布,先把这些阻断项补齐:', - ...(extractPublishBlockerMessages(errorMessage).length > 0 - ? extractPublishBlockerMessages(errorMessage) - : [errorMessage.trim()] - ) - .slice(0, 4) - .map((entry, index) => `${index + 1}. ${entry}`), - ].join('\n'); -} - -function resolvePublishedWorldName(profile: unknown) { - const profileRecord = - profile && typeof profile === 'object' && !Array.isArray(profile) - ? (profile as Record) - : null; - - return toText(profileRecord?.name) || '当前世界'; -} - -export function createPublishWorldExecutor(params: { - sessionStore: CustomWorldAgentSessionStore; - publishingService: CustomWorldAgentPublishingService; - resolveAuthorDisplayName?: ((userId: string) => Promise) | null; -}): CustomWorldAgentActionExecutor<'publish_world'> { - return async ({ userId, sessionId, operationId }) => { - const updateOperation = createOperationUpdater({ - sessionStore: params.sessionStore, - userId, - sessionId, - operationId, - }); - - try { - await updateOperation({ - status: 'running', - phaseLabel: '执行发布校验', - phaseDetail: '正在检查角色资产、场景图和主线草稿是否满足发布门槛。', - progress: 28, - }); - - const latestSession = await getRequiredSession({ - sessionStore: params.sessionStore, - userId, - sessionId, - }); - try { - params.publishingService.buildPublishReadiness({ - sessionId, - draftProfile: latestSession.draftProfile, - qualityFindings: latestSession.qualityFindings, - }); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : 'publish world failed'; - await params.sessionStore.appendMessage( - userId, - sessionId, - { - id: `message-${Date.now().toString(36)}-publish-warning`, - role: 'assistant', - kind: 'warning', - text: buildGateFailureMessage(errorMessage), - createdAt: new Date().toISOString(), - relatedOperationId: operationId, - }, - ); - throw error; - } - - await updateOperation({ - phaseLabel: '发布正式世界', - phaseDetail: '正在把当前草稿编译成正式世界档案并写入作品库。', - progress: 68, - }); - - const authorDisplayName = params.resolveAuthorDisplayName - ? await params.resolveAuthorDisplayName(userId) - : '玩家'; - const publishResult = await params.publishingService.publishSessionDraft({ - userId, - authorDisplayName: authorDisplayName.trim() || '玩家', - sessionId, - draftProfile: - (latestSession.draftProfile ?? {}) as Record, - qualityFindings: latestSession.qualityFindings, - }); - const worldName = resolvePublishedWorldName(publishResult.publishedProfile); - const publishedQualityFindings = latestSession.qualityFindings.filter( - (entry) => entry.severity !== 'blocker', - ); - const publishedState = { - stage: 'published' as const, - qualityFindings: publishedQualityFindings, - }; - - await params.sessionStore.replaceDerivedState( - userId, - sessionId, - publishedState, - ); - await params.sessionStore.appendCheckpoint(userId, sessionId, { - label: `发布世界 ${worldName}`, - snapshot: buildCheckpointSnapshot(latestSession, publishedState), - }); - await params.sessionStore.appendMessage( - userId, - sessionId, - buildActionResultMessage({ - relatedOperationId: operationId, - text: - publishedQualityFindings.length > 0 - ? `世界「${worldName}」已发布,并保留 ${publishedQualityFindings.length} 条 warning 供后续继续优化。` - : `世界「${worldName}」已正式发布,可以进入作品库与世界入口。`, - }), - ); - - await updateOperation({ - status: 'completed', - phaseLabel: '世界已发布', - phaseDetail: `正式世界档案已写入作品库:${publishResult.profileId}。`, - progress: 100, - error: null, - }); - } catch (error) { - await updateOperation({ - status: 'failed', - phaseLabel: '发布失败', - phaseDetail: '当前世界还没有通过发布校验或写入作品库失败。', - progress: 100, - error: error instanceof Error ? error.message : 'publish world failed', - }); - } - }; -} diff --git a/server-node/src/services/customWorldAgentActionExecutors/revertCheckpointExecutor.ts b/server-node/src/services/customWorldAgentActionExecutors/revertCheckpointExecutor.ts deleted file mode 100644 index 0c70585c..00000000 --- a/server-node/src/services/customWorldAgentActionExecutors/revertCheckpointExecutor.ts +++ /dev/null @@ -1,95 +0,0 @@ -import type { CustomWorldAgentActionExecutor } from './types.js'; -import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js'; -import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; -import { buildActionResultMessage } from './helpers.js'; -import { - createOperationUpdater, - getRequiredSession, -} from './executorShared.js'; - -export function createRevertCheckpointExecutor(params: { - sessionStore: CustomWorldAgentSessionStore; - snapshotBuilder: CustomWorldAgentSnapshotBuilder; -}): CustomWorldAgentActionExecutor<'revert_checkpoint'> { - return async ({ userId, sessionId, operationId, payload }) => { - const updateOperation = createOperationUpdater({ - sessionStore: params.sessionStore, - userId, - sessionId, - operationId, - }); - - try { - await updateOperation({ - status: 'running', - phaseLabel: '恢复历史检查点', - phaseDetail: '正在把指定检查点的草稿状态恢复到当前会话。', - progress: 36, - }); - - const latestSession = await getRequiredSession({ - sessionStore: params.sessionStore, - userId, - sessionId, - }); - const checkpoint = latestSession.checkpoints.find( - (entry) => entry.checkpointId === payload.checkpointId, - ); - if (!checkpoint?.snapshot) { - throw new Error('目标检查点不存在,或当前检查点还没有可恢复快照。'); - } - - await params.sessionStore.restoreCheckpoint( - userId, - sessionId, - payload.checkpointId, - ); - const restoredSession = await getRequiredSession({ - sessionStore: params.sessionStore, - userId, - sessionId, - }); - const nextState = params.snapshotBuilder.buildRefiningState({ - previousStage: restoredSession.stage, - nextStage: - restoredSession.stage === 'visual_refining' || - restoredSession.stage === 'long_tail_review' || - restoredSession.stage === 'ready_to_publish' - ? restoredSession.stage - : 'object_refining', - draftProfile: - (restoredSession.draftProfile ?? {}) as Record, - focusCardId: restoredSession.focusCardId, - }); - - await params.sessionStore.replaceDerivedState(userId, sessionId, nextState); - await params.sessionStore.appendMessage( - userId, - sessionId, - buildActionResultMessage({ - relatedOperationId: operationId, - text: `已恢复到检查点「${checkpoint.label}」,当前草稿和卡片摘要已经回滚到对应版本。`, - }), - ); - - await updateOperation({ - status: 'completed', - phaseLabel: '检查点已恢复', - phaseDetail: `已恢复到「${checkpoint.label}」。`, - progress: 100, - error: null, - }); - } catch (error) { - await updateOperation({ - status: 'failed', - phaseLabel: '恢复检查点失败', - phaseDetail: '这一轮没有成功恢复历史检查点。', - progress: 100, - error: - error instanceof Error - ? error.message - : 'revert checkpoint failed', - }); - } - }; -} diff --git a/server-node/src/services/customWorldAgentActionExecutors/syncResultProfileExecutor.ts b/server-node/src/services/customWorldAgentActionExecutors/syncResultProfileExecutor.ts deleted file mode 100644 index 2058fc3b..00000000 --- a/server-node/src/services/customWorldAgentActionExecutors/syncResultProfileExecutor.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type { CustomWorldAgentActionExecutor } from './types.js'; -import type { CustomWorldAgentResultSyncService } from '../customWorldAgentResultSyncService.js'; -import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js'; -import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; -import { buildActionResultMessage } from './helpers.js'; -import { - buildCheckpointSnapshot, - createOperationUpdater, - getRequiredSession, -} from './executorShared.js'; - -export function createSyncResultProfileExecutor(params: { - sessionStore: CustomWorldAgentSessionStore; - resultSyncService: CustomWorldAgentResultSyncService; - snapshotBuilder: CustomWorldAgentSnapshotBuilder; -}): CustomWorldAgentActionExecutor<'sync_result_profile'> { - return async ({ userId, sessionId, operationId, payload }) => { - const updateOperation = createOperationUpdater({ - sessionStore: params.sessionStore, - userId, - sessionId, - operationId, - }); - - try { - await updateOperation({ - status: 'running', - phaseLabel: '同步结果页快照', - phaseDetail: '正在把结果页里的最新世界结构写回当前草稿。', - progress: 36, - }); - - const latestSession = await getRequiredSession({ - sessionStore: params.sessionStore, - userId, - sessionId, - }); - const nextDraftProfile = - params.resultSyncService.syncResultProfileIntoDraftProfile({ - currentDraftProfile: latestSession.draftProfile, - resultProfile: payload.profile as never, - }); - - await updateOperation({ - phaseLabel: '重编译草稿摘要', - phaseDetail: '正在刷新草稿卡摘要、资产覆盖和结果页恢复快照。', - progress: 72, - }); - - const nextState = params.snapshotBuilder.buildRefiningState({ - previousStage: latestSession.stage, - draftProfile: nextDraftProfile, - }); - - await params.sessionStore.replaceDerivedState(userId, sessionId, nextState); - await params.sessionStore.appendCheckpoint(userId, sessionId, { - label: '同步结果页编辑', - snapshot: buildCheckpointSnapshot(latestSession, nextState), - }); - await params.sessionStore.appendMessage( - userId, - sessionId, - buildActionResultMessage({ - relatedOperationId: operationId, - text: '结果页里的最新世界结构已经同步回当前草稿。', - }), - ); - - await updateOperation({ - status: 'completed', - phaseLabel: '结果页快照已同步', - phaseDetail: '当前结果页编辑内容已经并回 Agent 草稿主链。', - progress: 100, - error: null, - }); - } catch (error) { - await updateOperation({ - status: 'failed', - phaseLabel: '结果页同步失败', - phaseDetail: '这一轮结果页编辑没有成功写回当前草稿。', - progress: 100, - error: - error instanceof Error ? error.message : 'sync result profile failed', - }); - } - }; -} diff --git a/server-node/src/services/customWorldAgentActionExecutors/syncRoleAssetsExecutor.ts b/server-node/src/services/customWorldAgentActionExecutors/syncRoleAssetsExecutor.ts deleted file mode 100644 index 5cd581c3..00000000 --- a/server-node/src/services/customWorldAgentActionExecutors/syncRoleAssetsExecutor.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { resolveRoleAssetStatusLabel } from '../customWorldAgentRoleAssetStateService.js'; -import type { CustomWorldAgentActionExecutor } from './types.js'; -import type { CustomWorldAgentAssetBridgeService } from '../customWorldAgentAssetBridgeService.js'; -import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js'; -import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; -import { - buildActionResultMessage, - buildRoleAssetSyncResultText, -} from './helpers.js'; -import { - buildCheckpointSnapshot, - createOperationUpdater, - getRequiredSession, -} from './executorShared.js'; - -export function createSyncRoleAssetsExecutor(params: { - sessionStore: CustomWorldAgentSessionStore; - assetBridgeService: CustomWorldAgentAssetBridgeService; - snapshotBuilder: CustomWorldAgentSnapshotBuilder; -}): CustomWorldAgentActionExecutor<'sync_role_assets'> { - return async ({ userId, sessionId, operationId, payload }) => { - const updateOperation = createOperationUpdater({ - sessionStore: params.sessionStore, - userId, - sessionId, - operationId, - }); - - try { - await updateOperation({ - status: 'running', - phaseLabel: '同步角色资产', - phaseDetail: '正在把主图与动作结果写回当前世界草稿。', - progress: 36, - }); - - const latestSession = await getRequiredSession({ - sessionStore: params.sessionStore, - userId, - sessionId, - }); - const syncResult = params.assetBridgeService.applyRoleAssetPublishResult( - latestSession.draftProfile, - payload, - ); - - await updateOperation({ - phaseLabel: '刷新角色卡摘要', - phaseDetail: '正在同步更新角色卡状态与资产覆盖。', - progress: 72, - }); - - const nextState = params.snapshotBuilder.buildRefiningState({ - previousStage: latestSession.stage, - nextStage: 'visual_refining', - draftProfile: syncResult.draftProfile, - focusCardId: payload.roleId, - }); - - await params.sessionStore.replaceDerivedState(userId, sessionId, nextState); - await params.sessionStore.appendCheckpoint(userId, sessionId, { - label: `同步角色资产 ${syncResult.updatedAssetSummary.roleName}`, - snapshot: buildCheckpointSnapshot(latestSession, nextState), - }); - await params.sessionStore.appendMessage( - userId, - sessionId, - buildActionResultMessage({ - relatedOperationId: operationId, - text: buildRoleAssetSyncResultText({ - roleName: syncResult.updatedAssetSummary.roleName, - assetStatusLabel: resolveRoleAssetStatusLabel( - syncResult.updatedAssetSummary.status, - ), - }), - }), - ); - - await updateOperation({ - status: 'completed', - phaseLabel: '角色资产已同步', - phaseDetail: `「${syncResult.updatedAssetSummary.roleName}」的资产状态已更新为${resolveRoleAssetStatusLabel(syncResult.updatedAssetSummary.status)}。`, - progress: 100, - error: null, - }); - } catch (error) { - await updateOperation({ - status: 'failed', - phaseLabel: '角色资产同步失败', - phaseDetail: '这一轮没有成功把角色资产写回草稿。', - progress: 100, - error: - error instanceof Error ? error.message : 'sync role assets failed', - }); - } - }; -} diff --git a/server-node/src/services/customWorldAgentActionExecutors/syncSceneAssetsExecutor.ts b/server-node/src/services/customWorldAgentActionExecutors/syncSceneAssetsExecutor.ts deleted file mode 100644 index 6dc24354..00000000 --- a/server-node/src/services/customWorldAgentActionExecutors/syncSceneAssetsExecutor.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { CustomWorldAgentActionExecutor } from './types.js'; -import type { CustomWorldAgentAssetBridgeService } from '../customWorldAgentAssetBridgeService.js'; -import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js'; -import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; -import { buildActionResultMessage } from './helpers.js'; -import { - buildCheckpointSnapshot, - createOperationUpdater, - getRequiredSession, -} from './executorShared.js'; - -export function createSyncSceneAssetsExecutor(params: { - sessionStore: CustomWorldAgentSessionStore; - assetBridgeService: CustomWorldAgentAssetBridgeService; - snapshotBuilder: CustomWorldAgentSnapshotBuilder; -}): CustomWorldAgentActionExecutor<'sync_scene_assets'> { - return async ({ userId, sessionId, operationId, payload }) => { - const updateOperation = createOperationUpdater({ - sessionStore: params.sessionStore, - userId, - sessionId, - operationId, - }); - - try { - await updateOperation({ - status: 'running', - phaseLabel: '同步场景资产', - phaseDetail: '正在把营地/地点场景图写回当前世界草稿。', - progress: 38, - }); - - const latestSession = await getRequiredSession({ - sessionStore: params.sessionStore, - userId, - sessionId, - }); - const syncResult = params.assetBridgeService.applySceneAssetPublishResult( - latestSession.draftProfile, - payload, - ); - - await updateOperation({ - phaseLabel: '刷新场景卡摘要', - phaseDetail: '正在更新地点卡、幕背景摘要和场景资产覆盖率。', - progress: 72, - }); - - const nextState = params.snapshotBuilder.buildRefiningState({ - previousStage: latestSession.stage, - nextStage: 'visual_refining', - draftProfile: syncResult.draftProfile, - focusCardId: payload.sceneId, - }); - - await params.sessionStore.replaceDerivedState(userId, sessionId, nextState); - await params.sessionStore.appendCheckpoint(userId, sessionId, { - label: `同步场景资产 ${String(syncResult.updatedScene.name ?? payload.sceneId)}`, - snapshot: buildCheckpointSnapshot(latestSession, nextState), - }); - await params.sessionStore.appendMessage( - userId, - sessionId, - buildActionResultMessage({ - relatedOperationId: operationId, - text: `已把「${String(syncResult.updatedScene.name ?? '当前场景')}」的场景图写回草稿,并同步刷新地点卡与幕背景状态。`, - }), - ); - - await updateOperation({ - status: 'completed', - phaseLabel: '场景资产已同步', - phaseDetail: `「${String(syncResult.updatedScene.name ?? '当前场景')}」的场景图已经进入当前草稿。`, - progress: 100, - error: null, - }); - } catch (error) { - await updateOperation({ - status: 'failed', - phaseLabel: '场景资产同步失败', - phaseDetail: '这一轮没有成功把场景图写回当前草稿。', - progress: 100, - error: - error instanceof Error ? error.message : 'sync scene assets failed', - }); - } - }; -} diff --git a/server-node/src/services/customWorldAgentActionExecutors/types.ts b/server-node/src/services/customWorldAgentActionExecutors/types.ts deleted file mode 100644 index 93d10e91..00000000 --- a/server-node/src/services/customWorldAgentActionExecutors/types.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { CustomWorldAgentActionRequest } from '../../../../packages/shared/src/contracts/customWorldAgent.js'; - -export type CustomWorldAgentActionPayload< - K extends CustomWorldAgentActionRequest['action'], -> = Extract; - -export type CustomWorldAgentActionExecutor< - K extends CustomWorldAgentActionRequest['action'], -> = (params: { - userId: string; - sessionId: string; - operationId: string; - payload: CustomWorldAgentActionPayload; -}) => Promise; - -export type CustomWorldAgentActionExecutorMap = { - draft_foundation: CustomWorldAgentActionExecutor<'draft_foundation'>; - update_draft_card: CustomWorldAgentActionExecutor<'update_draft_card'>; - sync_result_profile: CustomWorldAgentActionExecutor<'sync_result_profile'>; - generate_characters: CustomWorldAgentActionExecutor<'generate_characters'>; - generate_landmarks: CustomWorldAgentActionExecutor<'generate_landmarks'>; - generate_role_assets: CustomWorldAgentActionExecutor<'generate_role_assets'>; - sync_role_assets: CustomWorldAgentActionExecutor<'sync_role_assets'>; - generate_scene_assets: CustomWorldAgentActionExecutor<'generate_scene_assets'>; - sync_scene_assets: CustomWorldAgentActionExecutor<'sync_scene_assets'>; - expand_long_tail: CustomWorldAgentActionExecutor<'expand_long_tail'>; - publish_world: CustomWorldAgentActionExecutor<'publish_world'>; - revert_checkpoint: CustomWorldAgentActionExecutor<'revert_checkpoint'>; -}; diff --git a/server-node/src/services/customWorldAgentActionExecutors/updateDraftCardExecutor.ts b/server-node/src/services/customWorldAgentActionExecutors/updateDraftCardExecutor.ts deleted file mode 100644 index a3a5fcc2..00000000 --- a/server-node/src/services/customWorldAgentActionExecutors/updateDraftCardExecutor.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { updateDraftCardSections } from '../customWorldAgentDraftEditService.js'; -import type { CustomWorldAgentActionExecutor } from './types.js'; -import type { CustomWorldAgentChangeSummaryService } from '../customWorldAgentChangeSummaryService.js'; -import type { CustomWorldAgentDraftCompiler } from '../customWorldAgentDraftCompiler.js'; -import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js'; -import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; -import { buildActionResultMessage } from './helpers.js'; -import { - buildCheckpointSnapshot, - createOperationUpdater, - getRequiredSession, -} from './executorShared.js'; - -export function createUpdateDraftCardExecutor(params: { - sessionStore: CustomWorldAgentSessionStore; - draftCompiler: CustomWorldAgentDraftCompiler; - changeSummaryService: CustomWorldAgentChangeSummaryService; - snapshotBuilder: CustomWorldAgentSnapshotBuilder; -}): CustomWorldAgentActionExecutor<'update_draft_card'> { - return async ({ userId, sessionId, operationId, payload }) => { - const updateOperation = createOperationUpdater({ - sessionStore: params.sessionStore, - userId, - sessionId, - operationId, - }); - - try { - await updateOperation({ - status: 'running', - phaseLabel: '写回草稿设定', - phaseDetail: '正在把这次编辑内容写回当前世界底稿。', - progress: 34, - }); - - const latestSession = await getRequiredSession({ - sessionStore: params.sessionStore, - userId, - sessionId, - }); - const nextDraftProfile = updateDraftCardSections({ - draftProfile: (latestSession.draftProfile ?? {}) as Record< - string, - unknown - >, - cardId: payload.cardId, - sections: payload.sections, - }); - - await updateOperation({ - phaseLabel: '重编译草稿卡', - phaseDetail: '正在同步更新草稿摘要和详情内容。', - progress: 72, - }); - - const nextState = params.snapshotBuilder.buildRefiningState({ - previousStage: latestSession.stage, - draftProfile: nextDraftProfile, - focusCardId: payload.cardId, - }); - const updatedDetail = params.draftCompiler.getDraftCardDetail( - nextDraftProfile, - payload.cardId, - ); - const changedSectionIds = new Set( - payload.sections - .map((section) => section.sectionId.trim()) - .filter(Boolean), - ); - - await params.sessionStore.replaceDerivedState(userId, sessionId, nextState); - await params.sessionStore.appendCheckpoint(userId, sessionId, { - label: `编辑 ${updatedDetail?.title || '草稿卡'}`, - snapshot: buildCheckpointSnapshot(latestSession, nextState), - }); - await params.sessionStore.appendMessage( - userId, - sessionId, - buildActionResultMessage({ - relatedOperationId: operationId, - text: params.changeSummaryService.buildSummary({ - action: 'update_draft_card', - cardId: payload.cardId, - changedLabels: - updatedDetail?.sections - .filter((section) => changedSectionIds.has(section.id)) - .map((section) => section.label) ?? [], - draftProfile: nextDraftProfile, - }), - }), - ); - - await updateOperation({ - status: 'completed', - phaseLabel: '草稿设定已保存', - phaseDetail: `「${updatedDetail?.title || '当前卡片'}」的设定已经同步更新。`, - progress: 100, - error: null, - }); - } catch (error) { - await updateOperation({ - status: 'failed', - phaseLabel: '保存失败', - phaseDetail: '这次草稿编辑没有成功写回到底稿。', - progress: 100, - error: - error instanceof Error ? error.message : 'update draft card failed', - }); - } - }; -} diff --git a/server-node/src/services/customWorldAgentActionRegistry.test.ts b/server-node/src/services/customWorldAgentActionRegistry.test.ts deleted file mode 100644 index 00b0ca89..00000000 --- a/server-node/src/services/customWorldAgentActionRegistry.test.ts +++ /dev/null @@ -1,260 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import type { CustomWorldAgentActionExecutorMap } from './customWorldAgentActionExecutors/index.js'; -import { CustomWorldAgentActionRegistry } from './customWorldAgentActionRegistry.js'; -import { createRpgAgentSessionFixture } from '../../../packages/shared/src/contracts/rpgCreationFixtures.js'; - -function createExecutorLog() { - const calls: Array<{ - action: keyof CustomWorldAgentActionExecutorMap; - payload: unknown; - userId: string; - sessionId: string; - operationId: string; - }> = []; - - const createExecutor = ( - action: K, - ): CustomWorldAgentActionExecutorMap[K] => { - return (async (params) => { - calls.push({ - action, - payload: params.payload, - userId: params.userId, - sessionId: params.sessionId, - operationId: params.operationId, - }); - }) as CustomWorldAgentActionExecutorMap[K]; - }; - - return { - calls, - executors: { - draft_foundation: createExecutor('draft_foundation'), - update_draft_card: createExecutor('update_draft_card'), - sync_result_profile: createExecutor('sync_result_profile'), - generate_characters: createExecutor('generate_characters'), - generate_landmarks: createExecutor('generate_landmarks'), - generate_role_assets: createExecutor('generate_role_assets'), - sync_role_assets: createExecutor('sync_role_assets'), - generate_scene_assets: createExecutor('generate_scene_assets'), - sync_scene_assets: createExecutor('sync_scene_assets'), - expand_long_tail: createExecutor('expand_long_tail'), - publish_world: createExecutor('publish_world'), - revert_checkpoint: createExecutor('revert_checkpoint'), - } satisfies CustomWorldAgentActionExecutorMap, - }; -} - -function createSessionRecord(overrides: Partial> = {}) { - const session = createRpgAgentSessionFixture(); - - return { - ...JSON.parse(JSON.stringify(session)), - userId: 'fixture-user', - seedText: '被海雾吞没的旧航路群岛', - operations: [], - checkpoints: [], - createdAt: session.updatedAt, - updatedAt: session.updatedAt, - ...overrides, - }; -} - -test('action registry exposes supported actions with stage-aware enablement and disabled reasons', () => { - const { executors } = createExecutorLog(); - const registry = new CustomWorldAgentActionRegistry(executors); - const session = createSessionRecord({ - stage: 'foundation_review', - progressPercent: 80, - }); - const supportedActions = registry.buildSupportedActions(session as never); - const draftFoundation = supportedActions.find( - (entry) => entry.action === 'draft_foundation', - ); - const syncResultProfile = supportedActions.find( - (entry) => entry.action === 'sync_result_profile', - ); - const publishWorld = supportedActions.find( - (entry) => entry.action === 'publish_world', - ); - const expandLongTail = supportedActions.find( - (entry) => entry.action === 'expand_long_tail', - ); - const revertCheckpoint = supportedActions.find( - (entry) => entry.action === 'revert_checkpoint', - ); - - assert.equal(draftFoundation?.enabled, false); - assert.match(draftFoundation?.reason ?? '', /progressPercent >= 100/u); - assert.equal(syncResultProfile?.enabled, false); - assert.match( - syncResultProfile?.reason ?? '', - /object_refining or visual_refining/u, - ); - assert.equal(publishWorld?.enabled, false); - assert.match( - publishWorld?.reason ?? '', - /object_refining, visual_refining, long_tail_review or ready_to_publish/u, - ); - assert.equal(expandLongTail?.enabled, false); - assert.match( - expandLongTail?.reason ?? '', - /object_refining, visual_refining, long_tail_review or ready_to_publish/u, - ); - assert.equal(revertCheckpoint?.enabled, false); - assert.match( - revertCheckpoint?.reason ?? '', - /requires at least one restorable checkpoint snapshot/u, - ); -}); - -test('action registry enables long-tail and publish actions in late stages, and exposes revert when restorable checkpoint exists', () => { - const { executors } = createExecutorLog(); - const registry = new CustomWorldAgentActionRegistry(executors); - const session = createSessionRecord({ - stage: 'ready_to_publish', - checkpoints: [ - { - checkpointId: 'checkpoint-1', - createdAt: '2026-04-21T12:00:00.000Z', - label: '可回滚版本', - snapshot: { - currentTurn: 2, - anchorContent: createSessionRecord().anchorContent, - progressPercent: 100, - lastAssistantReply: '已生成草稿。', - stage: 'object_refining', - focusCardId: 'world-foundation', - creatorIntent: {}, - creatorIntentReadiness: { - isReady: true, - completedKeys: [], - missingKeys: [], - }, - anchorPack: {}, - lockState: {}, - draftProfile: createSessionRecord().draftProfile, - pendingClarifications: [], - suggestedActions: [], - recommendedReplies: [], - draftCards: createSessionRecord().draftCards, - qualityFindings: [], - assetCoverage: createSessionRecord().assetCoverage, - }, - }, - ], - }); - - const supportedActions = registry.buildSupportedActions(session as never); - - assert.equal( - supportedActions.find((entry) => entry.action === 'expand_long_tail')?.enabled, - true, - ); - assert.equal( - supportedActions.find((entry) => entry.action === 'publish_world')?.enabled, - true, - ); - assert.equal( - supportedActions.find((entry) => entry.action === 'revert_checkpoint')?.enabled, - true, - ); -}); - -test('action registry validates sync_scene_assets required payload and dispatches scene action executors', async () => { - const { calls, executors } = createExecutorLog(); - const registry = new CustomWorldAgentActionRegistry(executors); - const session = createSessionRecord({ - stage: 'visual_refining', - }); - - assert.throws( - () => - registry.prepareExecution(session as never, { - action: 'sync_scene_assets', - sceneId: 'camp-home', - sceneKind: 'camp', - imageSrc: '', - generatedSceneAssetId: 'scene-asset-1', - }), - /imageSrc and generatedSceneAssetId/u, - ); - - const prepared = registry.prepareExecution(session as never, { - action: 'generate_scene_assets', - sceneIds: ['camp-home'], - }); - - assert.equal(prepared.operationType, 'generate_scene_assets'); - - await prepared.execute({ - userId: 'fixture-user', - sessionId: 'fixture-session', - operationId: 'operation-scene-1', - }); - - assert.equal(calls.at(-1)?.action, 'generate_scene_assets'); -}); - -test('action registry normalizes sync_result_profile payload before dispatching executor', async () => { - const { calls, executors } = createExecutorLog(); - const registry = new CustomWorldAgentActionRegistry(executors); - const session = createSessionRecord({ - stage: 'object_refining', - }); - const prepared = registry.prepareExecution(session as never, { - action: 'sync_result_profile', - profile: { - id: 'profile-1', - settingText: '潮雾列岛', - name: '潮雾列岛', - subtitle: '旧灯塔与失控航路', - summary: '结果页确认版。', - tone: '压抑、潮湿、悬疑', - playerGoal: '查清真相。', - templateWorldType: 'WUXIA', - majorFactions: ['守灯会'], - coreConflicts: ['争夺旧航路控制权'], - playableNpcs: [], - storyNpcs: [], - items: [], - landmarks: [], - generationMode: 'full', - generationStatus: 'complete', - }, - }); - - assert.equal(prepared.operationType, 'sync_result_profile'); - - await prepared.execute({ - userId: 'fixture-user', - sessionId: 'fixture-session', - operationId: 'operation-1', - }); - - assert.equal(calls.length, 1); - assert.equal(calls[0]?.action, 'sync_result_profile'); - assert.equal( - (calls[0]?.payload as { profile?: { name?: string } })?.profile?.name, - '潮雾列岛', - ); -}); - -test('action registry rejects invalid generate_role_assets payload with unit-level validation', () => { - const { executors } = createExecutorLog(); - const registry = new CustomWorldAgentActionRegistry(executors); - const session = createSessionRecord({ - stage: 'object_refining', - }); - - assert.throws( - () => - registry.prepareExecution(session as never, { - action: 'generate_role_assets', - roleIds: ['playable-1', 'story-1'], - }), - /exactly one roleId/u, - ); -}); diff --git a/server-node/src/services/customWorldAgentActionRegistry.ts b/server-node/src/services/customWorldAgentActionRegistry.ts deleted file mode 100644 index 07f7f133..00000000 --- a/server-node/src/services/customWorldAgentActionRegistry.ts +++ /dev/null @@ -1,403 +0,0 @@ -import type { - CustomWorldAgentActionRequest, - CustomWorldAgentOperationRecord, - CustomWorldSupportedAction, -} from '../../../packages/shared/src/contracts/customWorldAgent.js'; -import { badRequest } from '../errors.js'; -import { normalizeCustomWorldProfile } from '../modules/custom-world/runtimeProfile.js'; -import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js'; -import type { - CustomWorldAgentActionExecutorMap, - CustomWorldAgentActionPayload, -} from './customWorldAgentActionExecutors/index.js'; -import type { CustomWorldAgentSessionRecord } from './customWorldAgentSessionStore.js'; - -type EnabledAction = keyof CustomWorldAgentActionExecutorMap; -type EnabledDescriptor = { - operationType: CustomWorldAgentOperationRecord['type']; - normalizePayload?: ( - payload: CustomWorldAgentActionPayload, - ) => CustomWorldAgentActionPayload; - validate?: ( - session: CustomWorldAgentSessionRecord, - payload: CustomWorldAgentActionPayload, - ) => void; - execute: CustomWorldAgentActionExecutorMap[K]; -}; -type DisabledAction = Exclude; -type DisabledDescriptor = { - disabledReason: string; -}; - -type ActionCapabilityState = { - enabled: boolean; - reason?: string; -}; - -function assertDraftRefiningActionAvailable( - session: CustomWorldAgentSessionRecord, - action: string, -) { - if ( - session.stage !== 'object_refining' && - session.stage !== 'visual_refining' - ) { - throw badRequest( - `${action} is only available during object_refining or visual_refining`, - ); - } - - const hasDraftFoundation = Boolean( - normalizeFoundationDraftProfile(session.draftProfile) && - session.draftCards.length > 0, - ); - if (!hasDraftFoundation) { - throw badRequest(`${action} requires an existing draft foundation`); - } -} - -function assertLongTailActionAvailable( - session: CustomWorldAgentSessionRecord, - action: string, -) { - if ( - session.stage !== 'object_refining' && - session.stage !== 'visual_refining' && - session.stage !== 'long_tail_review' && - session.stage !== 'ready_to_publish' - ) { - throw badRequest( - `${action} is only available during object_refining, visual_refining, long_tail_review or ready_to_publish`, - ); - } -} - -function assertPublishActionAvailable( - session: CustomWorldAgentSessionRecord, - action: string, -) { - assertLongTailActionAvailable(session, action); - if (!normalizeFoundationDraftProfile(session.draftProfile)) { - throw badRequest(`${action} requires an existing draft foundation`); - } -} - -export type PreparedCustomWorldAgentActionExecution = { - operationType: CustomWorldAgentOperationRecord['type']; - execute: (params: { - userId: string; - sessionId: string; - operationId: string; - }) => Promise; -}; - -export class CustomWorldAgentActionRegistry { - private readonly descriptors: Record< - CustomWorldAgentActionRequest['action'], - EnabledDescriptor | DisabledDescriptor - >; - - constructor(executors: CustomWorldAgentActionExecutorMap) { - this.descriptors = { - draft_foundation: { - operationType: 'draft_foundation', - validate: (session) => { - if (session.progressPercent < 100) { - throw badRequest('draft_foundation requires progressPercent >= 100'); - } - }, - execute: executors.draft_foundation, - }, - update_draft_card: { - operationType: 'update_draft_card', - validate: (session, payload) => { - assertDraftRefiningActionAvailable(session, payload.action); - if (!payload.cardId.trim()) { - throw badRequest('update_draft_card requires cardId'); - } - if (!Array.isArray(payload.sections) || payload.sections.length === 0) { - throw badRequest('update_draft_card requires sections'); - } - }, - execute: executors.update_draft_card, - }, - sync_result_profile: { - operationType: 'sync_result_profile', - normalizePayload: (payload) => { - const normalizedProfile = normalizeCustomWorldProfile(payload.profile, ''); - if (!normalizedProfile) { - throw badRequest('sync_result_profile requires a valid profile'); - } - - return { - ...payload, - profile: normalizedProfile as unknown as Record, - }; - }, - validate: (session, payload) => { - assertDraftRefiningActionAvailable(session, payload.action); - }, - execute: executors.sync_result_profile, - }, - generate_characters: { - operationType: 'generate_characters', - validate: (session, payload) => { - assertDraftRefiningActionAvailable(session, payload.action); - if (payload.count < 1 || payload.count > 3) { - throw badRequest( - 'generate_characters count must be between 1 and 3', - ); - } - }, - execute: executors.generate_characters, - }, - generate_landmarks: { - operationType: 'generate_landmarks', - validate: (session, payload) => { - assertDraftRefiningActionAvailable(session, payload.action); - if (payload.count < 1 || payload.count > 3) { - throw badRequest( - 'generate_landmarks count must be between 1 and 3', - ); - } - }, - execute: executors.generate_landmarks, - }, - generate_role_assets: { - operationType: 'generate_role_assets', - validate: (session, payload) => { - assertDraftRefiningActionAvailable(session, payload.action); - if (!Array.isArray(payload.roleIds) || payload.roleIds.length !== 1) { - throw badRequest( - 'generate_role_assets currently requires exactly one roleId', - ); - } - }, - execute: executors.generate_role_assets, - }, - sync_role_assets: { - operationType: 'sync_role_assets', - validate: (session, payload) => { - assertDraftRefiningActionAvailable(session, payload.action); - if (!payload.roleId.trim()) { - throw badRequest('sync_role_assets requires roleId'); - } - if ( - !payload.portraitPath.trim() || - !payload.generatedVisualAssetId.trim() - ) { - throw badRequest( - 'sync_role_assets requires portraitPath and generatedVisualAssetId', - ); - } - }, - execute: executors.sync_role_assets, - }, - generate_scene_assets: { - operationType: 'generate_scene_assets', - validate: (session, payload) => { - assertDraftRefiningActionAvailable(session, payload.action); - if (!Array.isArray(payload.sceneIds) || payload.sceneIds.length !== 1) { - throw badRequest( - 'generate_scene_assets currently requires exactly one sceneId', - ); - } - }, - execute: executors.generate_scene_assets, - }, - sync_scene_assets: { - operationType: 'sync_scene_assets', - validate: (session, payload) => { - assertDraftRefiningActionAvailable(session, payload.action); - if (!payload.sceneId.trim()) { - throw badRequest('sync_scene_assets requires sceneId'); - } - if (!payload.imageSrc.trim() || !payload.generatedSceneAssetId.trim()) { - throw badRequest( - 'sync_scene_assets requires imageSrc and generatedSceneAssetId', - ); - } - }, - execute: executors.sync_scene_assets, - }, - expand_long_tail: { - operationType: 'expand_long_tail', - validate: (session, payload) => { - assertLongTailActionAvailable(session, payload.action); - if (!normalizeFoundationDraftProfile(session.draftProfile)) { - throw badRequest('expand_long_tail requires an existing draft foundation'); - } - }, - execute: executors.expand_long_tail, - }, - publish_world: { - operationType: 'publish_world', - validate: (session, payload) => { - assertPublishActionAvailable(session, payload.action); - }, - execute: executors.publish_world, - }, - revert_checkpoint: { - operationType: 'revert_checkpoint', - validate: (session, payload) => { - assertLongTailActionAvailable(session, payload.action); - if (!payload.checkpointId.trim()) { - throw badRequest('revert_checkpoint requires checkpointId'); - } - const checkpoint = session.checkpoints.find( - (entry) => entry.checkpointId === payload.checkpointId, - ); - if (!checkpoint) { - throw badRequest('revert_checkpoint target checkpoint does not exist'); - } - if (!checkpoint.snapshot) { - throw badRequest( - 'revert_checkpoint target checkpoint does not contain a restorable snapshot', - ); - } - }, - execute: executors.revert_checkpoint, - }, - }; - } - - // orchestrator 只关心“拿到一个已校验的动作执行计划”,不再自己维护所有 action 分支。 - prepareExecution( - session: CustomWorldAgentSessionRecord, - payload: CustomWorldAgentActionRequest, - ): PreparedCustomWorldAgentActionExecution { - const descriptor = this.descriptors[payload.action]; - if ('disabledReason' in descriptor) { - throw badRequest(descriptor.disabledReason); - } - - const normalizedPayload = descriptor.normalizePayload - ? descriptor.normalizePayload(payload as never) - : payload; - - descriptor.validate?.(session, normalizedPayload as never); - - return { - operationType: descriptor.operationType, - execute: ({ userId, sessionId, operationId }) => - descriptor.execute({ - userId, - sessionId, - operationId, - payload: normalizedPayload as never, - }), - }; - } - - buildSupportedActions( - session: CustomWorldAgentSessionRecord, - ): CustomWorldSupportedAction[] { - return ( - Object.entries(this.descriptors) as Array< - [ - CustomWorldAgentActionRequest['action'], - EnabledDescriptor | DisabledDescriptor, - ] - > - ).map(([action, descriptor]) => { - const capability = this.resolveCapabilityState(session, action, descriptor); - - return { - action, - enabled: capability.enabled, - reason: capability.reason ?? null, - } satisfies CustomWorldSupportedAction; - }); - } - - private resolveCapabilityState( - session: CustomWorldAgentSessionRecord, - action: CustomWorldAgentActionRequest['action'], - descriptor: EnabledDescriptor | DisabledDescriptor, - ): ActionCapabilityState { - if ('disabledReason' in descriptor) { - return { - enabled: false, - reason: descriptor.disabledReason, - }; - } - - if (action === 'draft_foundation') { - return session.progressPercent >= 100 - ? { enabled: true } - : { - enabled: false, - reason: 'draft_foundation requires progressPercent >= 100', - }; - } - - if ( - action === 'update_draft_card' || - action === 'sync_result_profile' || - action === 'generate_characters' || - action === 'generate_landmarks' || - action === 'generate_role_assets' || - action === 'sync_role_assets' || - action === 'generate_scene_assets' || - action === 'sync_scene_assets' - ) { - try { - assertDraftRefiningActionAvailable(session, action); - return { enabled: true }; - } catch (error) { - return { - enabled: false, - reason: error instanceof Error ? error.message : 'action unavailable', - }; - } - } - - if (action === 'expand_long_tail') { - try { - assertLongTailActionAvailable(session, action); - return { enabled: true }; - } catch (error) { - return { - enabled: false, - reason: error instanceof Error ? error.message : 'action unavailable', - }; - } - } - - if (action === 'publish_world') { - try { - assertPublishActionAvailable(session, action); - return { enabled: true }; - } catch (error) { - return { - enabled: false, - reason: error instanceof Error ? error.message : 'action unavailable', - }; - } - } - - if (action === 'revert_checkpoint') { - const restorableCheckpoint = session.checkpoints.find( - (entry) => Boolean(entry.snapshot), - ); - if (!restorableCheckpoint) { - return { - enabled: false, - reason: 'revert_checkpoint requires at least one restorable checkpoint snapshot', - }; - } - - try { - assertLongTailActionAvailable(session, action); - return { enabled: true }; - } catch (error) { - return { - enabled: false, - reason: error instanceof Error ? error.message : 'action unavailable', - }; - } - } - - return { enabled: true }; - } -} diff --git a/server-node/src/services/customWorldAgentAssetBridgeService.ts b/server-node/src/services/customWorldAgentAssetBridgeService.ts deleted file mode 100644 index 6f1d4378..00000000 --- a/server-node/src/services/customWorldAgentAssetBridgeService.ts +++ /dev/null @@ -1,324 +0,0 @@ -import type { - CustomWorldRoleAssetSummary, - CustomWorldSceneAssetSummary, -} from '../../../packages/shared/src/contracts/customWorldAgent.js'; -import { - getRoleAssetSummaryById, - rebuildRoleAssetCoverage, - mergeRoleAssetIntoDraftProfile, -} from './customWorldAgentRoleAssetStateService.js'; - -function toText(value: unknown) { - return typeof value === 'string' ? value.trim() : ''; -} - -function toRecord(value: unknown) { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : null; -} - -function toRecordArray(value: unknown) { - return Array.isArray(value) - ? value.filter( - (item): item is Record => - Boolean(item) && typeof item === 'object' && !Array.isArray(item), - ) - : []; -} - -type SyncRoleAssetsPayload = { - roleId: string; - portraitPath: string; - generatedVisualAssetId: string; - generatedAnimationSetId?: string | null; - animationMap?: Record | null; -}; - -type SceneKind = 'camp' | 'landmark'; - -type SyncSceneAssetsPayload = { - sceneId: string; - sceneKind: SceneKind; - imageSrc: string; - generatedSceneAssetId: string; - generatedScenePrompt?: string | null; - generatedSceneModel?: string | null; -}; - -export type SyncRoleAssetsResult = { - roleId: string; - updatedRole: Record; - updatedAssetSummary: CustomWorldRoleAssetSummary; - draftProfile: Record; -}; - -export type SceneAssetStudioContext = { - sceneId: string; - sceneKind: SceneKind; - sceneName: string; - sceneDescription: string; - imageSrc: string | null; - readyActCount: number; - missingActCount: number; -}; - -export type SyncSceneAssetsResult = { - sceneId: string; - sceneKind: SceneKind; - updatedScene: Record; - updatedAssetSummaries: CustomWorldSceneAssetSummary[]; - draftProfile: Record; -}; - -function cloneRecord>(value: T): T { - return JSON.parse(JSON.stringify(value)) as T; -} - -function toSceneDescription(scene: Record, sceneKind: SceneKind) { - if (sceneKind === 'camp') { - return ( - toText(scene.description) || - toText(scene.summary) || - toText(scene.mood) - ); - } - - return ( - toText(scene.description) || - toText(scene.summary) || - toText(scene.purpose) || - toText(scene.mood) - ); -} - -function findSceneActsBySceneId( - draftProfile: Record, - sceneId: string, -) { - return toRecordArray(draftProfile.sceneChapters) - .filter((chapter) => toText(chapter.sceneId) === sceneId) - .flatMap((chapter) => toRecordArray(chapter.acts)); -} - -function updateSceneChapterActsForScene(params: { - draftProfile: Record; - sceneId: string; - imageSrc: string; - generatedSceneAssetId: string; -}) { - return toRecordArray(params.draftProfile.sceneChapters).map((chapter) => { - if (toText(chapter.sceneId) !== params.sceneId) { - return chapter; - } - - return { - ...chapter, - acts: toRecordArray(chapter.acts).map((act) => ({ - ...act, - backgroundImageSrc: params.imageSrc, - backgroundAssetId: params.generatedSceneAssetId, - })), - } satisfies Record; - }); -} - -function buildSceneAssetFallbackSummary(params: { - sceneId: string; - sceneKind: SceneKind; - updatedScene: Record; - imageSrc: string; - generatedSceneAssetId: string; -}) { - return { - sceneId: params.sceneId, - sceneName: - toText(params.updatedScene.name) || - (params.sceneKind === 'camp' ? '开局营地' : '未命名场景'), - actId: null, - actTitle: params.sceneKind === 'camp' ? '营地正式背景图' : '场景正式背景图', - imageSrc: params.imageSrc, - assetId: params.generatedSceneAssetId, - status: 'ready', - nextPointCost: 0, - } satisfies CustomWorldSceneAssetSummary; -} - -export class CustomWorldAgentAssetBridgeService { - buildRoleAssetStudioContext(snapshot: unknown, roleId: string) { - const profile = toRecord(snapshot); - if (!profile) { - throw new Error('当前世界草稿为空,无法打开角色资产工坊。'); - } - - const playableRole = toRecordArray(profile.playableNpcs).find( - (item) => toText(item.id) === roleId, - ); - const storyRole = toRecordArray(profile.storyNpcs).find( - (item) => toText(item.id) === roleId, - ); - const role = playableRole ?? storyRole; - if (!role) { - throw new Error('未找到目标角色,无法进入角色资产工坊。'); - } - - const assetSummary = getRoleAssetSummaryById(profile, roleId); - if (!assetSummary) { - throw new Error('未找到目标角色的资产摘要。'); - } - - return { - roleId, - roleName: toText(role.name) || assetSummary.roleName, - roleKind: playableRole ? ('playable' as const) : ('story' as const), - startFrom: - assetSummary.status === 'missing' ? ('visual' as const) : ('animation' as const), - assetSummary, - }; - } - - applyRoleAssetPublishResult( - snapshot: unknown, - payload: SyncRoleAssetsPayload, - ): SyncRoleAssetsResult { - const profile = toRecord(snapshot); - if (!profile) { - throw new Error('当前世界草稿为空,无法同步角色资产。'); - } - - const { draftProfile, updatedRole } = mergeRoleAssetIntoDraftProfile( - profile, - payload, - ); - const assetSummary = getRoleAssetSummaryById(draftProfile, payload.roleId); - if (!assetSummary) { - throw new Error('角色资产同步后未能生成新的资产摘要。'); - } - - return { - roleId: payload.roleId, - updatedRole, - updatedAssetSummary: assetSummary, - draftProfile, - }; - } - - buildSceneAssetStudioContext( - snapshot: unknown, - sceneId: string, - sceneKind: SceneKind, - ): SceneAssetStudioContext { - const profile = toRecord(snapshot); - if (!profile) { - throw new Error('当前世界草稿为空,无法打开场景资产工坊。'); - } - - const scene = - sceneKind === 'camp' - ? toRecord(profile.camp) - : toRecordArray(profile.landmarks).find( - (item) => toText(item.id) === sceneId, - ) ?? null; - if (!scene) { - throw new Error('未找到目标场景,无法进入场景资产工坊。'); - } - - const sceneActs = findSceneActsBySceneId(profile, sceneId); - const readyActCount = sceneActs.filter((act) => - Boolean(toText(act.backgroundImageSrc) || toText(act.backgroundAssetId)), - ).length; - - return { - sceneId, - sceneKind, - sceneName: - toText(scene.name) || (sceneKind === 'camp' ? '开局营地' : '未命名场景'), - sceneDescription: toSceneDescription(scene, sceneKind), - imageSrc: toText(scene.imageSrc) || null, - readyActCount, - missingActCount: Math.max(0, sceneActs.length - readyActCount), - }; - } - - applySceneAssetPublishResult( - snapshot: unknown, - payload: SyncSceneAssetsPayload, - ): SyncSceneAssetsResult { - const profile = toRecord(snapshot); - if (!profile) { - throw new Error('当前世界草稿为空,无法同步场景资产。'); - } - - const nextDraftProfile = cloneRecord(profile); - let updatedScene: Record | null = null; - - if (payload.sceneKind === 'camp') { - const currentCamp = toRecord(nextDraftProfile.camp); - if (!currentCamp || toText(currentCamp.id) !== payload.sceneId) { - throw new Error('目标营地不存在,无法同步场景资产。'); - } - - updatedScene = { - ...currentCamp, - imageSrc: payload.imageSrc, - generatedSceneAssetId: payload.generatedSceneAssetId, - generatedScenePrompt: payload.generatedScenePrompt ?? null, - generatedSceneModel: payload.generatedSceneModel ?? null, - }; - nextDraftProfile.camp = updatedScene; - } else { - let touched = false; - nextDraftProfile.landmarks = toRecordArray(nextDraftProfile.landmarks).map( - (item) => { - if (toText(item.id) !== payload.sceneId) { - return item; - } - - touched = true; - updatedScene = { - ...item, - imageSrc: payload.imageSrc, - generatedSceneAssetId: payload.generatedSceneAssetId, - generatedScenePrompt: payload.generatedScenePrompt ?? null, - generatedSceneModel: payload.generatedSceneModel ?? null, - }; - return updatedScene; - }, - ); - - if (!touched || !updatedScene) { - throw new Error('目标地点不存在,无法同步场景资产。'); - } - } - - nextDraftProfile.sceneChapters = updateSceneChapterActsForScene({ - draftProfile: nextDraftProfile, - sceneId: payload.sceneId, - imageSrc: payload.imageSrc, - generatedSceneAssetId: payload.generatedSceneAssetId, - }); - - const updatedAssetSummaries = rebuildRoleAssetCoverage( - nextDraftProfile, - ).sceneAssets.filter((entry) => entry.sceneId === payload.sceneId); - - return { - sceneId: payload.sceneId, - sceneKind: payload.sceneKind, - updatedScene: updatedScene ?? {}, - updatedAssetSummaries: - updatedAssetSummaries.length > 0 - ? updatedAssetSummaries - : [ - buildSceneAssetFallbackSummary({ - sceneId: payload.sceneId, - sceneKind: payload.sceneKind, - updatedScene: updatedScene ?? {}, - imageSrc: payload.imageSrc, - generatedSceneAssetId: payload.generatedSceneAssetId, - }), - ], - draftProfile: nextDraftProfile, - }; - } -} diff --git a/server-node/src/services/customWorldAgentAutoAssetService.test.ts b/server-node/src/services/customWorldAgentAutoAssetService.test.ts deleted file mode 100644 index 537b8e1f..00000000 --- a/server-node/src/services/customWorldAgentAutoAssetService.test.ts +++ /dev/null @@ -1,401 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import test from 'node:test'; - -import sharp from 'sharp'; - -import type { AppConfig } from '../config.js'; -import { CustomWorldAgentAutoAssetService } from './customWorldAgentAutoAssetService.js'; - -function createTestConfig(testName: string): AppConfig { - const projectRoot = fs.mkdtempSync( - path.join(os.tmpdir(), `genarrative-auto-assets-${testName}-`), - ); - - return { - nodeEnv: 'test', - projectRoot, - publicDir: path.join(projectRoot, 'public'), - logsDir: path.join(projectRoot, 'logs'), - dataDir: path.join(projectRoot, 'data'), - rawEnv: {}, - databaseUrl: 'pg-mem://auto-assets', - serverAddr: ':0', - logLevel: 'silent', - editorApiEnabled: true, - assetsApiEnabled: true, - jwtSecret: 'test', - jwtExpiresIn: '7d', - jwtIssuer: 'test', - llm: { - baseUrl: 'https://example.invalid', - apiKey: '', - model: 'test-model', - }, - dashScope: { - baseUrl: 'https://example.invalid', - apiKey: '', - imageModel: 'test-image-model', - requestTimeoutMs: 1000, - }, - smsAuth: { - enabled: false, - provider: 'mock', - endpoint: '', - accessKeyId: '', - accessKeySecret: '', - signName: '', - templateCode: '', - templateParamKey: '', - countryCode: '86', - schemeName: '', - codeLength: 6, - codeType: 1, - validTimeSeconds: 300, - intervalSeconds: 60, - duplicatePolicy: 1, - caseAuthPolicy: 1, - returnVerifyCode: false, - mockVerifyCode: '123456', - maxSendPerPhonePerDay: 20, - maxSendPerIpPerHour: 30, - maxVerifyFailuresPerPhonePerHour: 12, - maxVerifyFailuresPerIpPerHour: 24, - captchaTtlSeconds: 180, - captchaTriggerVerifyFailuresPerPhone: 3, - captchaTriggerVerifyFailuresPerIp: 5, - blockPhoneFailureThreshold: 6, - blockIpFailureThreshold: 10, - blockPhoneDurationMinutes: 30, - blockIpDurationMinutes: 30, - }, - wechatAuth: { - enabled: false, - provider: 'mock', - appId: '', - appSecret: '', - authorizeEndpoint: '', - accessTokenEndpoint: '', - userInfoEndpoint: '', - callbackPath: '', - defaultRedirectPath: '/', - mockUserId: '', - mockUnionId: '', - mockDisplayName: '', - mockAvatarUrl: '', - }, - authSession: { - accessCookieName: 'genarrative_access_session', - accessCookieTtlSeconds: 7200, - accessCookieSecure: false, - accessCookieSameSite: 'Lax', - accessCookiePath: '/', - refreshCookieName: 'refresh_token', - refreshSessionTtlDays: 30, - refreshCookieSecure: false, - refreshCookieSameSite: 'Lax', - refreshCookiePath: '/', - }, - }; -} - -test('auto asset service populates role visuals and scene act backgrounds', async () => { - const config = createTestConfig('populate'); - const service = new CustomWorldAgentAutoAssetService( - config, - CustomWorldAgentAutoAssetService.createFallbackCharacterVisualGenerator(config), - CustomWorldAgentAutoAssetService.createFallbackSceneActBackgroundGenerator(config), - ); - - const result = await service.populateDraftAssets({ - draftProfile: { - name: '雾港列岛', - subtitle: '守灯人与失序航道', - summary: '潮雾、盐火和旧航道互相绞紧的海岛世界。', - tone: '冷峻、克制、海风里带着锈味', - playerGoal: '先在旧灯塔一带站稳,再找出谁在提前布网。', - majorFactions: [], - coreConflicts: ['守灯会与沉船商盟正在争夺旧航道解释权'], - playableNpcs: [ - { - id: 'role-playable', - name: '沈砺', - title: '失职守灯人', - role: '可扮演角色', - publicIdentity: '曾经的守灯人,如今回到失序海域前线。', - currentPressure: '必须在旧友和旧职责之间重新站位。', - relationToPlayer: '玩家本人', - threadIds: ['thread-main'], - summary: '他是玩家在这次风暴里的第一视角。', - }, - ], - storyNpcs: [ - { - id: 'role-story-1', - name: '林潮', - title: '码头引路人', - role: '场景角色', - publicIdentity: '码头上最懂回潮时间的人。', - currentPressure: '决定今晚要不要让人进港。', - relationToPlayer: '先帮一把,再继续试探。', - threadIds: ['thread-main'], - summary: '他是第一幕的引路人。', - }, - ], - landmarks: [ - { - id: 'scene-dock', - name: '潮汐码头', - purpose: '承接第一章的主要碰撞。', - mood: '潮声压低,封锁正在加重。', - importance: '这里是玩家开局必须接住的门槛。', - characterIds: ['role-story-1'], - threadIds: ['thread-main'], - summary: '码头上的第一次碰撞会直接决定后续节奏。', - }, - ], - factions: [], - threads: [ - { - id: 'thread-main', - title: '旧航道争夺', - type: 'main', - conflict: '守灯会与沉船商盟正在争夺旧航道解释权', - characterIds: ['role-playable', 'role-story-1'], - landmarkIds: ['scene-dock'], - summary: '整条主线都围绕旧航道解释权改写展开。', - }, - ], - chapters: [], - sceneChapters: [ - { - id: 'scene-chapter-dock', - sceneId: 'scene-dock', - sceneName: '潮汐码头', - title: '潮汐码头章节', - summary: '三幕推进码头章节。', - linkedThreadIds: ['thread-main'], - linkedLandmarkIds: ['scene-dock'], - acts: [ - { - id: 'dock-act-1', - title: '雾里靠岸', - summary: '先由林潮把玩家带进港口节拍。', - stageCoverage: ['opening'], - backgroundImageSrc: null, - backgroundAssetId: null, - encounterNpcIds: ['role-story-1', 'role-playable'], - primaryNpcId: 'role-story-1', - linkedThreadIds: ['thread-main'], - actGoal: '接住第一幕入口压力', - transitionHook: '下一幕开始会有人继续封锁码头。', - advanceRule: 'after_primary_contact', - }, - { - id: 'dock-act-2', - title: '封锁加压', - summary: '第二幕把封锁真正抬上台面。', - stageCoverage: ['expansion', 'turning_point'], - backgroundImageSrc: null, - backgroundAssetId: null, - encounterNpcIds: ['role-story-1', 'role-playable'], - primaryNpcId: 'role-story-1', - linkedThreadIds: ['thread-main'], - actGoal: '把冲突推高', - transitionHook: '第三幕要把下一跳抛给玩家。', - advanceRule: 'after_active_step_complete', - }, - { - id: 'dock-act-3', - title: '潮线收束', - summary: '第三幕负责把这章收住。', - stageCoverage: ['climax', 'aftermath'], - backgroundImageSrc: null, - backgroundAssetId: null, - encounterNpcIds: ['role-story-1', 'role-playable'], - primaryNpcId: 'role-story-1', - linkedThreadIds: ['thread-main'], - actGoal: '完成章节收束', - transitionHook: '把下一跳交给玩家。', - advanceRule: 'after_chapter_resolution', - }, - ], - }, - ], - worldHook: '雾港列岛', - playerPremise: '被迫返乡的失职守灯人', - openingSituation: '玩家正站在即将熄灭的旧灯塔上。', - iconicElements: ['潮雾钟声', '盐火灯塔'], - sourceAnchorSummary: '海岛悬疑,冷峻克制。', - }, - }); - - assert.equal(result.assetCoverage.allRoleAssetsReady, true); - assert.equal(result.assetCoverage.allSceneAssetsReady, true); - assert.equal(result.assetCoverage.sceneAssets.length, 3); - assert.deepEqual(result.warnings, []); - assert.ok( - result.draftProfile.playableNpcs.every( - (role) => typeof role.imageSrc === 'string' && typeof role.generatedVisualAssetId === 'string', - ), - ); - assert.ok( - result.draftProfile.playableNpcs.every((role) => - role.imageSrc?.endsWith('.png') ?? false, - ), - ); - const playableImageSrc = result.draftProfile.playableNpcs[0]?.imageSrc; - assert.ok(playableImageSrc); - const playableImageMetadata = await sharp( - path.join(config.publicDir, playableImageSrc.replace(/^\/+/u, '')), - ).metadata(); - assert.equal(playableImageMetadata.width, 1024); - assert.equal(playableImageMetadata.height, 1024); - assert.ok( - result.draftProfile.sceneChapters.every((chapter) => - chapter.acts.every( - (act) => - typeof act.backgroundImageSrc === 'string' && - typeof act.backgroundAssetId === 'string', - ), - ), - ); - assert.ok( - result.draftProfile.sceneChapters.every((chapter) => - chapter.acts.every((act) => act.backgroundImageSrc?.endsWith('.png') ?? false), - ), - ); -}); - -test('auto asset service degrades gracefully when asset generators fail', async () => { - const config = createTestConfig('degrade'); - const service = new CustomWorldAgentAutoAssetService( - config, - async () => { - throw new Error('visual generator unavailable'); - }, - async () => { - throw new Error('scene generator unavailable'); - }, - ); - - const result = await service.populateDraftAssets({ - draftProfile: { - name: '雾港列岛', - subtitle: '守灯人与失序航道', - summary: '潮雾、盐火和旧航道互相绞紧的海岛世界。', - tone: '冷峻、克制、海风里带着锈味', - playerGoal: '先在旧灯塔一带站稳,再找出谁在提前布网。', - majorFactions: [], - coreConflicts: ['守灯会与沉船商盟正在争夺旧航道解释权'], - playableNpcs: [ - { - id: 'role-playable', - name: '沈砺', - title: '失职守灯人', - role: '可扮演角色', - publicIdentity: '曾经的守灯人,如今回到失序海域前线。', - currentPressure: '必须在旧友和旧职责之间重新站位。', - relationToPlayer: '玩家本人', - threadIds: ['thread-main'], - summary: '他是玩家在这次风暴里的第一视角。', - }, - ], - storyNpcs: [], - landmarks: [ - { - id: 'scene-dock', - name: '潮汐码头', - purpose: '承接第一章的主要碰撞。', - mood: '潮声压低,封锁正在加重。', - importance: '这里是玩家开局必须接住的门槛。', - characterIds: ['role-playable'], - threadIds: ['thread-main'], - summary: '码头上的第一次碰撞会直接决定后续节奏。', - }, - ], - factions: [], - threads: [ - { - id: 'thread-main', - title: '旧航道争夺', - type: 'main', - conflict: '守灯会与沉船商盟正在争夺旧航道解释权', - characterIds: ['role-playable'], - landmarkIds: ['scene-dock'], - summary: '整条主线都围绕旧航道解释权改写展开。', - }, - ], - chapters: [], - sceneChapters: [ - { - id: 'scene-chapter-dock', - sceneId: 'scene-dock', - sceneName: '潮汐码头', - title: '潮汐码头章节', - summary: '单章测试。', - linkedThreadIds: ['thread-main'], - linkedLandmarkIds: ['scene-dock'], - acts: [ - { - id: 'dock-act-1', - title: '雾里靠岸', - summary: '先接住入口。', - stageCoverage: ['opening'], - backgroundImageSrc: null, - backgroundAssetId: null, - encounterNpcIds: ['role-playable'], - primaryNpcId: 'role-playable', - linkedThreadIds: ['thread-main'], - actGoal: '接住入口压力', - transitionHook: '继续推进。', - advanceRule: 'after_primary_contact', - }, - { - id: 'dock-act-2', - title: '封锁加压', - summary: '继续抬高冲突。', - stageCoverage: ['turning_point'], - backgroundImageSrc: null, - backgroundAssetId: null, - encounterNpcIds: ['role-playable'], - primaryNpcId: 'role-playable', - linkedThreadIds: ['thread-main'], - actGoal: '继续推进', - transitionHook: '继续推进。', - advanceRule: 'after_active_step_complete', - }, - ], - }, - ], - worldHook: '雾港列岛', - playerPremise: '被迫返乡的失职守灯人', - openingSituation: '玩家正站在即将熄灭的旧灯塔上。', - iconicElements: ['潮雾钟声'], - sourceAnchorSummary: '海岛悬疑,冷峻克制。', - }, - }); - - assert.equal(result.assetCoverage.allRoleAssetsReady, true); - assert.equal(result.assetCoverage.allSceneAssetsReady, true); - assert.deepEqual(result.warnings, []); - assert.ok( - result.draftProfile.playableNpcs.every((role) => - role.imageSrc?.endsWith('.png') ?? false, - ), - ); - const fallbackPlayableImageSrc = result.draftProfile.playableNpcs[0]?.imageSrc; - assert.ok(fallbackPlayableImageSrc); - const fallbackPlayableImageMetadata = await sharp( - path.join(config.publicDir, fallbackPlayableImageSrc.replace(/^\/+/u, '')), - ).metadata(); - assert.equal(fallbackPlayableImageMetadata.width, 1024); - assert.equal(fallbackPlayableImageMetadata.height, 1024); - assert.ok( - result.draftProfile.sceneChapters.every((chapter) => - chapter.acts.every((act) => act.backgroundImageSrc?.endsWith('.png') ?? false), - ), - ); -}); diff --git a/server-node/src/services/customWorldAgentAutoAssetService.ts b/server-node/src/services/customWorldAgentAutoAssetService.ts deleted file mode 100644 index 14941faf..00000000 --- a/server-node/src/services/customWorldAgentAutoAssetService.ts +++ /dev/null @@ -1,771 +0,0 @@ -import crypto from 'node:crypto'; -import fs from 'node:fs'; -import path from 'node:path'; - -import sharp from 'sharp'; - -import type { - CustomWorldAssetCoverageSummary, - CustomWorldFoundationDraftCharacter, - CustomWorldFoundationDraftProfile, - CustomWorldFoundationDraftSceneAct, - CustomWorldSceneAssetSummary, -} from '../../../packages/shared/src/contracts/customWorldAgent.js'; -import { - buildNpcVisualNegativePrompt, - buildNpcVisualPrompt, -} from '../prompts/characterAssetPrompts.js'; -import type { AppConfig } from '../config.js'; - -type DraftProgressPayload = { - phaseLabel: string; - phaseDetail: string; - progress: number; -}; - -type DraftProgressCallback = ( - payload: DraftProgressPayload, -) => void | Promise; - -export type CharacterVisualGenerator = (params: { - role: CustomWorldFoundationDraftCharacter; - draftProfile: CustomWorldFoundationDraftProfile; -}) => Promise<{ - imageSrc: string; - generatedVisualAssetId: string; -}>; - -export type SceneActBackgroundGenerator = (params: { - draftProfile: CustomWorldFoundationDraftProfile; - sceneName: string; - act: CustomWorldFoundationDraftSceneAct; - primaryRoleName: string; - supportRoleNames: string[]; -}) => Promise<{ - imageSrc: string; - assetId: string; -}>; - -function toText(value: unknown) { - return typeof value === 'string' ? value.trim() : ''; -} - -function sanitizeSegment(value: string, fallback: string) { - const normalized = value - .trim() - .replace(/[^\w\u4e00-\u9fa5-]+/gu, '-') - .replace(/^-+|-+$/gu, '') - .slice(0, 48); - - return normalized || fallback; -} - -function normalizeDashScopeBaseUrl(value: string) { - return value.replace(/\/+$/u, ''); -} - -function createGeneratedAssetId(prefix: string) { - return `${prefix}-${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`; -} - -async function writePlaceholderPng(params: { - outputPath: string; - width: number; - height: number; - rgb: [number, number, number]; -}) { - const [r, g, b] = params.rgb; - await sharp({ - create: { - width: params.width, - height: params.height, - channels: 3, - background: { r, g, b }, - }, - }) - .png() - .toFile(params.outputPath); -} - -function collectStringsByKey( - value: unknown, - targetKey: string, - results: string[], -) { - if (typeof value === 'string') { - return; - } - - if (Array.isArray(value)) { - value.forEach((entry) => collectStringsByKey(entry, targetKey, results)); - return; - } - - if (!value || typeof value !== 'object') { - return; - } - - Object.entries(value).forEach(([key, nestedValue]) => { - if ( - key === targetKey && - typeof nestedValue === 'string' && - nestedValue.trim() - ) { - results.push(nestedValue.trim()); - return; - } - - collectStringsByKey(nestedValue, targetKey, results); - }); -} - -function findFirstStringByKey(value: unknown, targetKey: string) { - const results: string[] = []; - collectStringsByKey(value, targetKey, results); - return results[0] ?? ''; -} - -function extractTaskId(payload: Record) { - return findFirstStringByKey(payload, 'task_id'); -} - -function extractImageUrls(payload: Record) { - const urls: string[] = []; - collectStringsByKey(payload, 'image', urls); - collectStringsByKey(payload, 'url', urls); - return [...new Set(urls)]; -} - -function buildRoleVisualSeedText( - role: CustomWorldFoundationDraftCharacter, - draftProfile: CustomWorldFoundationDraftProfile, -) { - return [ - `世界:${draftProfile.name}`, - `世界摘要:${draftProfile.summary}`, - `角色名:${role.name}`, - `称号:${role.title}`, - `身份:${role.role}`, - `公开身份:${role.publicIdentity}`, - role.publicMask ? `第一印象:${role.publicMask}` : '', - `当前压力:${role.currentPressure}`, - role.hiddenHook ? `隐藏钩子:${role.hiddenHook}` : '', - `与玩家关系:${role.relationToPlayer}`, - `角色摘要:${role.summary}`, - ] - .filter(Boolean) - .join('\n'); -} - -async function createFallbackCharacterVisual(params: { - config: AppConfig; - role: CustomWorldFoundationDraftCharacter; -}) { - const assetId = createGeneratedAssetId('draft-role-visual'); - const roleSegment = sanitizeSegment(params.role.id || params.role.name, 'role'); - const relativeDir = path.join( - 'generated-characters', - roleSegment, - 'visual', - assetId, - ); - const outputDir = path.join(params.config.publicDir, relativeDir); - - fs.mkdirSync(outputDir, { recursive: true }); - const fileName = 'master.png'; - const filePath = path.join(outputDir, fileName); - await writePlaceholderPng({ - outputPath: filePath, - width: 1024, - height: 1024, - rgb: [78, 134, 220], - }); - - return { - imageSrc: `/${relativeDir.replace(/\\/gu, '/')}/${fileName}`, - generatedVisualAssetId: assetId, - }; -} - -function buildSceneActPrompt(params: { - draftProfile: CustomWorldFoundationDraftProfile; - sceneName: string; - act: CustomWorldFoundationDraftSceneAct; - primaryRoleName: string; - supportRoleNames: string[]; -}) { - return [ - `这是世界《${params.draftProfile.name}》中的场景幕背景图。`, - `场景:${params.sceneName}`, - `幕标题:${params.act.title}`, - `幕摘要:${params.act.summary}`, - `幕目标:${params.act.actGoal}`, - `过渡钩子:${params.act.transitionHook}`, - `主角色:${params.primaryRoleName || '待补主角色'}`, - params.supportRoleNames.length > 0 - ? `辅助角色:${params.supportRoleNames.join('、')}` - : '', - `世界气质:${params.draftProfile.tone}`, - `要求:只生成环境背景,不出现角色立绘、站位 UI、对白框、按钮或文字。`, - ] - .filter(Boolean) - .join('\n'); -} - -async function createDashScopeTextToImageTask(params: { - config: AppConfig; - prompt: string; - negativePrompt?: string; - size: string; - model: string; -}) { - const response = await fetch( - `${normalizeDashScopeBaseUrl(params.config.dashScope.baseUrl)}/services/aigc/text2image/image-synthesis`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${params.config.dashScope.apiKey}`, - 'Content-Type': 'application/json', - 'X-DashScope-Async': 'enable', - }, - body: JSON.stringify({ - model: params.model, - input: { - prompt: params.prompt, - ...(params.negativePrompt - ? { negative_prompt: params.negativePrompt } - : {}), - }, - parameters: { - n: 1, - size: params.size, - prompt_extend: true, - watermark: false, - }, - }), - }, - ); - const responseText = await response.text(); - - if (!response.ok) { - throw new Error(responseText || '创建图像生成任务失败。'); - } - - const payload = JSON.parse(responseText) as Record; - const taskId = extractTaskId(payload); - if (!taskId) { - throw new Error('图像生成任务未返回 task_id。'); - } - - return taskId; -} - -async function waitForDashScopeImage(params: { - config: AppConfig; - taskId: string; -}) { - const deadline = Date.now() + params.config.dashScope.requestTimeoutMs; - const baseUrl = normalizeDashScopeBaseUrl(params.config.dashScope.baseUrl); - - while (Date.now() < deadline) { - const pollResponse = await fetch(`${baseUrl}/tasks/${params.taskId}`, { - headers: { - Authorization: `Bearer ${params.config.dashScope.apiKey}`, - }, - }); - const pollText = await pollResponse.text(); - if (!pollResponse.ok) { - throw new Error(pollText || '查询图像生成任务失败。'); - } - - const pollPayload = JSON.parse(pollText) as Record; - const status = findFirstStringByKey(pollPayload, 'task_status').trim(); - if (status === 'SUCCEEDED') { - const imageUrl = extractImageUrls(pollPayload)[0] ?? ''; - const actualPrompt = findFirstStringByKey(pollPayload, 'actual_prompt').trim(); - if (!imageUrl) { - throw new Error('图像生成任务成功,但未返回图片地址。'); - } - - return { - imageUrl, - actualPrompt, - }; - } - - if (status === 'FAILED' || status === 'UNKNOWN') { - throw new Error(pollText || '图像生成任务失败。'); - } - - await new Promise((resolve) => setTimeout(resolve, 2000)); - } - - throw new Error('图像生成任务超时。'); -} - -async function saveRemoteImage(params: { - config: AppConfig; - imageUrl: string; - relativeDir: string; - fileBaseName: string; - manifest: Record; -}) { - const response = await fetch(params.imageUrl); - if (!response.ok) { - throw new Error('下载生成图片失败。'); - } - - const buffer = Buffer.from(await response.arrayBuffer()); - const contentType = response.headers.get('content-type') || ''; - const extension = contentType.includes('png') - ? 'png' - : contentType.includes('webp') - ? 'webp' - : 'jpg'; - const outputDir = path.join(params.config.publicDir, params.relativeDir); - fs.mkdirSync(outputDir, { recursive: true }); - const fileName = `${params.fileBaseName}.${extension}`; - const filePath = path.join(outputDir, fileName); - - fs.writeFileSync(filePath, buffer); - fs.writeFileSync( - path.join(outputDir, 'manifest.json'), - `${JSON.stringify(params.manifest, null, 2)}\n`, - 'utf8', - ); - - return `/${path.join(params.relativeDir, fileName).replace(/\\/gu, '/')}`; -} - -function findRoleById( - draftProfile: CustomWorldFoundationDraftProfile, - roleId: string, -) { - return [...draftProfile.playableNpcs, ...draftProfile.storyNpcs].find( - (role) => role.id === roleId, - ); -} - -export class CustomWorldAgentAutoAssetService { - constructor( - private readonly config: AppConfig | null = null, - private readonly characterVisualGenerator?: CharacterVisualGenerator | null, - private readonly sceneActBackgroundGenerator?: SceneActBackgroundGenerator | null, - ) {} - - async populateDraftAssets(params: { - draftProfile: CustomWorldFoundationDraftProfile; - onProgress?: DraftProgressCallback; - }): Promise<{ - draftProfile: CustomWorldFoundationDraftProfile; - assetCoverage: CustomWorldAssetCoverageSummary; - warnings: string[]; - }> { - const nextDraftProfile: CustomWorldFoundationDraftProfile = JSON.parse( - JSON.stringify(params.draftProfile), - ) as CustomWorldFoundationDraftProfile; - const roles = [...nextDraftProfile.playableNpcs, ...nextDraftProfile.storyNpcs]; - const sceneAssetSummaries: CustomWorldSceneAssetSummary[] = []; - const warnings: string[] = []; - const totalRoleCount = roles.length; - const totalActCount = nextDraftProfile.sceneChapters.reduce( - (sum, chapter) => sum + chapter.acts.length, - 0, - ); - let completedRoleCount = 0; - let completedActCount = 0; - - for (const role of roles) { - if (!role.imageSrc || !role.generatedVisualAssetId) { - try { - const generatedVisual = this.characterVisualGenerator - ? await this.characterVisualGenerator({ - role, - draftProfile: nextDraftProfile, - }) - : this.config - ? await createFallbackCharacterVisual({ - config: this.config, - role, - }) - : null; - - if (generatedVisual) { - role.imageSrc = generatedVisual.imageSrc; - role.generatedVisualAssetId = generatedVisual.generatedVisualAssetId; - } - } catch (error) { - try { - const fallbackVisual = this.config - ? await createFallbackCharacterVisual({ - config: this.config, - role, - }) - : null; - if (fallbackVisual) { - role.imageSrc = fallbackVisual.imageSrc; - role.generatedVisualAssetId = - fallbackVisual.generatedVisualAssetId; - } else { - warnings.push( - `角色主形象生成失败:${role.name}(${error instanceof Error ? error.message : 'unknown error'})`, - ); - } - } catch (fallbackError) { - // 角色主形象属于增强链路,主生成与回退都失败时仅记录告警,不阻断世界底稿主链。 - warnings.push( - `角色主形象生成失败:${role.name}(${fallbackError instanceof Error ? fallbackError.message : error instanceof Error ? error.message : 'unknown error'})`, - ); - } - } - } - - completedRoleCount += 1; - if (params.onProgress) { - await params.onProgress({ - phaseLabel: '生成角色主形象', - phaseDetail: `正在生成角色主形象 ${completedRoleCount}/${totalRoleCount}:${role.name}。`, - progress: - 97 + - Math.min( - 1, - Math.round((completedRoleCount / Math.max(1, totalRoleCount)) * 1), - ), - }); - } - } - - for (const sceneChapter of nextDraftProfile.sceneChapters) { - for (const act of sceneChapter.acts) { - let imageSrc = toText(act.backgroundImageSrc) || null; - let assetId = toText(act.backgroundAssetId) || null; - const primaryRole = findRoleById( - nextDraftProfile, - act.primaryNpcId || act.encounterNpcIds[0] || '', - ); - const supportRoleNames = act.encounterNpcIds - .slice(1) - .map((roleId) => findRoleById(nextDraftProfile, roleId)?.name || '') - .filter(Boolean); - if (!imageSrc && this.sceneActBackgroundGenerator) { - try { - const result = await this.sceneActBackgroundGenerator({ - draftProfile: nextDraftProfile, - sceneName: sceneChapter.sceneName, - act, - primaryRoleName: primaryRole?.name || '', - supportRoleNames, - }); - imageSrc = result.imageSrc; - assetId = result.assetId; - act.backgroundImageSrc = result.imageSrc; - act.backgroundAssetId = result.assetId; - } catch (error) { - try { - const fallbackScene = this.config - ? await CustomWorldAgentAutoAssetService.createFallbackSceneActBackgroundGenerator( - this.config, - )({ - draftProfile: nextDraftProfile, - sceneName: sceneChapter.sceneName, - act, - primaryRoleName: primaryRole?.name || '', - supportRoleNames, - }) - : null; - if (fallbackScene) { - imageSrc = fallbackScene.imageSrc; - assetId = fallbackScene.assetId; - act.backgroundImageSrc = fallbackScene.imageSrc; - act.backgroundAssetId = fallbackScene.assetId; - } else { - warnings.push( - `幕背景图生成失败:${sceneChapter.sceneName} / ${act.title}(${error instanceof Error ? error.message : 'unknown error'})`, - ); - } - } catch (fallbackError) { - // 幕图失败允许草稿继续生成;只有主生成与回退都失败时才保留缺口告警。 - warnings.push( - `幕背景图生成失败:${sceneChapter.sceneName} / ${act.title}(${fallbackError instanceof Error ? fallbackError.message : error instanceof Error ? error.message : 'unknown error'})`, - ); - } - } - } - - sceneAssetSummaries.push({ - sceneId: sceneChapter.sceneId, - sceneName: sceneChapter.sceneName, - actId: act.id, - actTitle: act.title, - imageSrc, - assetId, - status: imageSrc ? 'ready' : 'missing', - nextPointCost: imageSrc ? 0 : 12, - }); - - completedActCount += 1; - if (params.onProgress) { - await params.onProgress({ - phaseLabel: '生成幕背景图', - phaseDetail: `正在生成幕背景图 ${completedActCount}/${totalActCount}:${sceneChapter.sceneName} · ${act.title}。`, - progress: - 98 + - Math.min( - 1, - Math.round((completedActCount / Math.max(1, totalActCount)) * 1), - ), - }); - } - } - } - - const roleAssets = roles.map((role) => ({ - roleId: role.id, - roleName: role.name, - roleKind: nextDraftProfile.playableNpcs.some((entry) => entry.id === role.id) - ? ('playable' as const) - : ('story' as const), - priorityTier: nextDraftProfile.playableNpcs.some((entry) => entry.id === role.id) - ? ('hero' as const) - : ('featured' as const), - portraitPath: role.imageSrc || null, - generatedVisualAssetId: role.generatedVisualAssetId || null, - generatedAnimationSetId: role.generatedAnimationSetId || null, - status: role.imageSrc && role.generatedVisualAssetId ? 'visual_ready' : 'missing', - missingAnimations: [], - nextPointCost: role.imageSrc && role.generatedVisualAssetId ? 0 : 20, - })); - - return { - draftProfile: nextDraftProfile, - assetCoverage: { - roleAssets, - sceneAssets: sceneAssetSummaries, - allRoleAssetsReady: - roleAssets.length > 0 && - roleAssets.every((entry) => entry.status !== 'missing'), - allSceneAssetsReady: - sceneAssetSummaries.length > 0 && - sceneAssetSummaries.every((entry) => entry.status === 'ready'), - }, - warnings, - }; - } - - static createFallbackCharacterVisualGenerator(config: AppConfig): CharacterVisualGenerator { - return async ({ role, draftProfile }) => { - const assetId = createGeneratedAssetId('draft-role-visual'); - const roleSegment = sanitizeSegment(role.id || role.name, 'role'); - const relativeDir = path.join( - 'generated-characters', - roleSegment, - 'visual', - assetId, - ); - const outputDir = path.join(config.publicDir, relativeDir); - - fs.mkdirSync(outputDir, { recursive: true }); - const fileName = 'master.png'; - await writePlaceholderPng({ - outputPath: path.join(outputDir, fileName), - width: 1024, - height: 1024, - rgb: [78, 134, 220], - }); - const finalPrompt = buildNpcVisualPrompt( - buildRoleVisualSeedText(role, draftProfile), - ); - fs.writeFileSync( - path.join(outputDir, 'manifest.json'), - `${JSON.stringify( - { - assetId, - roleId: role.id, - roleName: role.name, - prompt: finalPrompt, - fallback: true, - createdAt: new Date().toISOString(), - }, - null, - 2, - )}\n`, - 'utf8', - ); - - return { - imageSrc: `/${relativeDir.replace(/\\/gu, '/')}/${fileName}`, - generatedVisualAssetId: assetId, - }; - }; - } - - static createDashScopeCharacterVisualGenerator( - config: AppConfig, - ): CharacterVisualGenerator { - return async ({ role, draftProfile }) => { - const prompt = buildNpcVisualPrompt( - buildRoleVisualSeedText(role, draftProfile), - ); - const assetId = `draft-role-visual-${Date.now().toString(36)}`; - const roleSegment = sanitizeSegment(role.id || role.name, 'role'); - const taskId = await createDashScopeTextToImageTask({ - config, - prompt, - negativePrompt: buildNpcVisualNegativePrompt(), - size: '1024*1024', - model: config.dashScope.imageModel || 'qwen-image-2.0', - }); - const { imageUrl, actualPrompt } = await waitForDashScopeImage({ - config, - taskId, - }); - const relativeDir = path.join( - 'generated-characters', - roleSegment, - 'visual', - assetId, - ); - const imageSrc = await saveRemoteImage({ - config, - imageUrl, - relativeDir, - fileBaseName: 'master', - manifest: { - assetId, - taskId, - roleId: role.id, - roleName: role.name, - prompt, - actualPrompt, - createdAt: new Date().toISOString(), - }, - }); - - return { - imageSrc, - generatedVisualAssetId: assetId, - }; - }; - } - - static createFallbackSceneActBackgroundGenerator( - config: AppConfig, - ): SceneActBackgroundGenerator { - return async ({ - draftProfile, - sceneName, - act, - primaryRoleName, - supportRoleNames, - }) => { - const finalPrompt = buildSceneActPrompt({ - draftProfile, - sceneName, - act, - primaryRoleName, - supportRoleNames, - }); - const assetId = createGeneratedAssetId('draft-scene-act'); - const sceneSegment = sanitizeSegment(act.sceneId || sceneName, 'scene'); - const actSegment = sanitizeSegment(act.id || act.title, 'act'); - const relativeDir = path.join( - 'generated-custom-world-scenes', - sceneSegment, - actSegment, - assetId, - ); - const outputDir = path.join(config.publicDir, relativeDir); - - fs.mkdirSync(outputDir, { recursive: true }); - const fileName = 'scene.png'; - await writePlaceholderPng({ - outputPath: path.join(outputDir, fileName), - width: 1280, - height: 720, - rgb: [34, 52, 88], - }); - fs.writeFileSync( - path.join(outputDir, 'manifest.json'), - `${JSON.stringify( - { - assetId, - sceneName, - actId: act.id, - actTitle: act.title, - prompt: finalPrompt, - fallback: true, - createdAt: new Date().toISOString(), - }, - null, - 2, - )}\n`, - 'utf8', - ); - - return { - imageSrc: `/${relativeDir.replace(/\\/gu, '/')}/${fileName}`, - assetId, - }; - }; - } - - static createDashScopeSceneActBackgroundGenerator( - config: AppConfig, - ): SceneActBackgroundGenerator { - return async ({ - draftProfile, - sceneName, - act, - primaryRoleName, - supportRoleNames, - }) => { - const prompt = buildSceneActPrompt({ - draftProfile, - sceneName, - act, - primaryRoleName, - supportRoleNames, - }); - const assetId = createGeneratedAssetId('draft-scene-act'); - const sceneSegment = sanitizeSegment(act.sceneId || sceneName, 'scene'); - const actSegment = sanitizeSegment(act.id || act.title, 'act'); - const taskId = await createDashScopeTextToImageTask({ - config, - prompt, - size: '1280*720', - model: config.dashScope.imageModel || 'wan2.2-t2i-flash', - }); - const { imageUrl, actualPrompt } = await waitForDashScopeImage({ - config, - taskId, - }); - const relativeDir = path.join( - 'generated-custom-world-scenes', - sceneSegment, - actSegment, - assetId, - ); - const imageSrc = await saveRemoteImage({ - config, - imageUrl, - relativeDir, - fileBaseName: 'scene', - manifest: { - assetId, - taskId, - sceneName, - actId: act.id, - actTitle: act.title, - prompt, - actualPrompt, - createdAt: new Date().toISOString(), - }, - }); - - return { - imageSrc, - assetId, - }; - }; - } -} diff --git a/server-node/src/services/customWorldAgentChangeSummaryService.ts b/server-node/src/services/customWorldAgentChangeSummaryService.ts deleted file mode 100644 index 50885e5d..00000000 --- a/server-node/src/services/customWorldAgentChangeSummaryService.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { - getWorldFoundationCardId, - normalizeFoundationDraftProfile, -} from './customWorldAgentDraftCompiler.js'; - -type BuildDraftChangeSummaryParams = - | { - action: 'update_draft_card'; - cardId: string; - changedLabels: string[]; - draftProfile: Record; - } - | { - action: 'generate_characters'; - names: string[]; - draftProfile: Record; - } - | { - action: 'generate_landmarks'; - names: string[]; - draftProfile: Record; - }; - -function resolveTotalCharacterCount( - profile: NonNullable>, -) { - return [...new Set([...profile.playableNpcs, ...profile.storyNpcs].map((entry) => entry.id))] - .length; -} - -function resolveCardTitle( - draftProfile: NonNullable>, - cardId: string, -) { - if (cardId === getWorldFoundationCardId()) { - return draftProfile.name; - } - - return ( - draftProfile.factions.find((entry) => entry.id === cardId)?.title || - draftProfile.factions.find((entry) => entry.id === cardId)?.name || - [...draftProfile.playableNpcs, ...draftProfile.storyNpcs].find( - (entry) => entry.id === cardId, - )?.name || - draftProfile.landmarks.find((entry) => entry.id === cardId)?.name || - draftProfile.threads.find((entry) => entry.id === cardId)?.title || - draftProfile.chapters.find((entry) => entry.id === cardId)?.title || - draftProfile.sceneChapters.find((entry) => entry.id === cardId)?.title || - (draftProfile.camp?.id === cardId ? draftProfile.camp.name : '') || - '当前卡片' - ); -} - -export class CustomWorldAgentChangeSummaryService { - buildSummary(params: BuildDraftChangeSummaryParams) { - const draftProfile = normalizeFoundationDraftProfile(params.draftProfile); - if (!draftProfile) { - return '这次改动已经写回草稿。'; - } - - const characterCount = resolveTotalCharacterCount(draftProfile); - const landmarkCount = draftProfile.landmarks.length; - - if (params.action === 'update_draft_card') { - const title = resolveCardTitle(draftProfile, params.cardId); - const changedLabelText = - params.changedLabels.length > 0 - ? params.changedLabels.slice(0, 4).join('、') - : '核心字段'; - - return [ - `已更新「${title}」的 ${changedLabelText}。`, - `当前底稿里共有 ${characterCount} 个角色、${landmarkCount} 个地点。`, - '下一步建议顺着这张卡直接检查它牵动的线程或地点。', - ].join('\n'); - } - - if (params.action === 'generate_characters') { - return [ - `已补出 ${params.names.length} 个新角色:${params.names.join('、')}。`, - `当前底稿里共有 ${characterCount} 个角色、${landmarkCount} 个地点。`, - '下一步建议先点开新角色卡,把玩家关系和关联线程收紧一轮。', - ].join('\n'); - } - - return [ - `已补出 ${params.names.length} 个新地点:${params.names.join('、')}。`, - `当前底稿里共有 ${characterCount} 个角色、${landmarkCount} 个地点。`, - '下一步建议先点开新地点卡,把线程挂钩和场景气质收紧一轮。', - ].join('\n'); - } -} diff --git a/server-node/src/services/customWorldAgentClarificationService.ts b/server-node/src/services/customWorldAgentClarificationService.ts deleted file mode 100644 index 6d0c8b71..00000000 --- a/server-node/src/services/customWorldAgentClarificationService.ts +++ /dev/null @@ -1,161 +0,0 @@ -import type { - CreatorIntentReadiness, - CustomWorldPendingClarification, -} from '../../../packages/shared/src/contracts/customWorldAgent.js'; -import type { CustomWorldAgentStage } from '../../../packages/shared/src/contracts/customWorldAgent.js'; -import type { CustomWorldCreatorIntentRecord } from './customWorldAgentIntentExtractionService.js'; - -type CreatorIntentReadinessKey = - | 'world_hook' - | 'player_premise' - | 'theme_and_tone' - | 'core_conflict' - | 'relationship_seed' - | 'iconic_element'; - -const CLARIFICATION_DEFINITIONS: Array<{ - targetKey: CreatorIntentReadinessKey; - priority: number; - label: string; - question: string; -}> = [ - { - targetKey: 'world_hook', - priority: 1, - label: '世界一句话', - question: '先用一句话收住这个世界最独特的核心幻想,我会据此继续往下补。', - }, - { - targetKey: 'player_premise', - priority: 2, - label: '玩家身份与开局', - question: - '玩家是谁,故事开场时卡在什么处境里?你可以把身份和开局困境一起告诉我。', - }, - { - targetKey: 'core_conflict', - priority: 3, - label: '核心冲突', - question: - '现在推动这个世界往前走的主要冲突是什么?最好是能立刻形成剧情压力的那种。', - }, - { - targetKey: 'theme_and_tone', - priority: 4, - label: '主题气质', - question: - '它整体更偏什么主题和气质?比如冷峻、压迫、浪漫、潮湿,也可以顺手告诉我不要什么。', - }, - { - targetKey: 'relationship_seed', - priority: 5, - label: '关键关系钩子', - question: - '给我一个关键人物种子就行,他和玩家是什么关系,或者他藏着什么暗线?', - }, - { - targetKey: 'iconic_element', - priority: 6, - label: '标志性要素', - question: '这个世界至少给我 1 个一眼能认出来的标志性元素、机制或意象。', - }, -]; - -function toText(value: unknown) { - return typeof value === 'string' ? value.trim() : ''; -} - -export function evaluateCreatorIntentReadiness( - intent: CustomWorldCreatorIntentRecord | null | undefined, -): CreatorIntentReadiness { - const completedKeys: CreatorIntentReadinessKey[] = []; - const missingKeys: CreatorIntentReadinessKey[] = []; - const relationshipReady = - intent?.keyCharacters.some( - (entry) => - Boolean(toText(entry.name)) && - Boolean(toText(entry.relationToPlayer) || toText(entry.hiddenHook)), - ) ?? false; - - const keyChecks: Array<{ - key: CreatorIntentReadinessKey; - ready: boolean; - }> = [ - { - key: 'world_hook', - ready: - (intent?.worldHook.trim().length ?? 0) >= 8 || - (intent?.rawSettingText.trim().length ?? 0) >= 24, - }, - { - key: 'player_premise', - ready: Boolean( - intent?.playerPremise.trim() && intent?.openingSituation.trim(), - ), - }, - { - key: 'theme_and_tone', - ready: - (intent?.themeKeywords.length ?? 0) >= 1 && - (intent?.toneDirectives.length ?? 0) >= 1, - }, - { - key: 'core_conflict', - ready: (intent?.coreConflicts.length ?? 0) >= 1, - }, - { - key: 'relationship_seed', - ready: (intent?.keyCharacters.length ?? 0) >= 1 && relationshipReady, - }, - { - key: 'iconic_element', - ready: (intent?.iconicElements.length ?? 0) >= 1, - }, - ]; - - keyChecks.forEach((entry) => { - if (entry.ready) { - completedKeys.push(entry.key); - return; - } - - missingKeys.push(entry.key); - }); - - return { - isReady: missingKeys.length === 0, - completedKeys, - missingKeys, - }; -} - -export function buildPendingClarifications( - intent: CustomWorldCreatorIntentRecord | null | undefined, - readiness = evaluateCreatorIntentReadiness(intent), -) { - return CLARIFICATION_DEFINITIONS.filter((entry) => - readiness.missingKeys.includes(entry.targetKey), - ) - .sort((left, right) => left.priority - right.priority) - .slice(0, 1) - .map( - (entry): CustomWorldPendingClarification => ({ - id: entry.targetKey, - label: entry.label, - question: entry.question, - targetKey: entry.targetKey, - priority: entry.priority, - }), - ); -} - -export function resolveCreatorIntentStage(params: { - hasUserInput: boolean; - readiness: CreatorIntentReadiness; -}): CustomWorldAgentStage { - if (params.readiness.isReady) { - return 'foundation_review'; - } - - return params.hasUserInput ? 'clarifying' : 'collecting_intent'; -} diff --git a/server-node/src/services/customWorldAgentDraftCompiler.test.ts b/server-node/src/services/customWorldAgentDraftCompiler.test.ts deleted file mode 100644 index 72f34471..00000000 --- a/server-node/src/services/customWorldAgentDraftCompiler.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { updateDraftCardSections } from './customWorldAgentDraftEditService.js'; -import { - CustomWorldAgentDraftCompiler, - normalizeFoundationDraftProfile, -} from './customWorldAgentDraftCompiler.js'; - -function createSceneChapterDraftProfile() { - return { - name: '雾港列岛', - summary: '潮雾、旧航道和失序港口缠在一起的海岛世界。', - tone: '冷峻、克制、带着海盐和旧铁锈味道。', - playerGoal: '先在失序的港口里站稳,再找出谁在提前布网。', - coreConflicts: ['旧航道解释权正在被重新争夺'], - iconicElements: ['潮雾钟声', '盐火灯塔'], - playableNpcs: [ - { - id: 'npc-lin', - name: '林潮', - title: '守潮人', - role: '码头引路人', - publicIdentity: '码头上最懂回潮时间的人。', - publicMask: '码头上最懂回潮时间的人。', - currentPressure: '必须决定今晚要不要帮玩家进港。', - hiddenHook: '他知道第一批被转移的货不是普通货。', - relationToPlayer: '对玩家保持试探,但还愿意给一次机会。', - threadIds: ['thread-smuggling'], - summary: '他像向导,也像仍在权衡站位的守门人。', - }, - ], - storyNpcs: [ - { - id: 'npc-yan', - name: '晏九', - title: '黑市中间人', - role: '封锁码头的人', - publicIdentity: '他负责把不该上岸的东西挡在潮线外。', - publicMask: '他负责把不该上岸的东西挡在潮线外。', - currentPressure: '必须让今晚的码头保持沉默。', - hiddenHook: '他已经替更大的势力提前清过一次场。', - relationToPlayer: '对玩家带着明显敌意,但又不想立刻翻脸。', - threadIds: ['thread-smuggling'], - summary: '他像威胁,也像握着下一跳线索的人。', - }, - ], - landmarks: [ - { - id: 'landmark-docks', - name: '潮汐码头', - description: '涨潮时会吞没半条旧栈桥的码头。', - purpose: '承接玩家和封锁者的第一次正式碰撞。', - mood: '潮声压低,空气里有明显不欢迎的意味。', - importance: '这里是玩家第一章必须破开的门槛。', - secret: '今晚靠岸的货和旧航道失踪案有关。', - dangerLevel: '中高', - imageSrc: '/images/scene/docks-base.webp', - characterIds: ['npc-lin', 'npc-yan'], - threadIds: ['thread-smuggling'], - summary: '这里不是背景,而是第一章真正开始收紧的地方。', - }, - ], - factions: [], - threads: [ - { - id: 'thread-smuggling', - title: '失踪货船去哪了', - type: 'main', - conflictType: '明线', - conflict: '有人在重写旧航道的夜间进出规则。', - stakes: '如果玩家跟不上这条线,整个港口都会先把他排除在外。', - characterIds: ['npc-lin', 'npc-yan'], - landmarkIds: ['landmark-docks'], - summary: '旧航道的解释权正在被重新洗牌。', - }, - ], - chapters: [ - { - id: 'chapter-docks', - title: '码头开场', - openingEvent: '一艘不该靠岸的船提前抵达潮线外。', - playerGoal: '先确认谁在码头上拥有发言权。', - characterIds: ['npc-lin', 'npc-yan'], - landmarkIds: ['landmark-docks'], - understandingShift: '玩家会意识到这不是简单的港口封锁。', - summary: '码头上的第一次碰撞会直接决定后续节奏。', - }, - ], - sceneChapters: [ - { - id: 'scene-chapter-docks', - sceneId: 'landmark-docks', - sceneName: '潮汐码头', - title: '潮汐码头章节', - summary: '玩家会在这里完成试探、逼问和第一次局部收束。', - linkedThreadIds: ['thread-smuggling'], - linkedLandmarkIds: ['landmark-docks'], - acts: [ - { - id: 'act-docks-1', - title: '雾里靠岸', - summary: '玩家刚抵达时,林潮先决定要不要放行。', - stageCoverage: ['opening'], - backgroundImageSrc: '/images/scene/docks-act-1.webp', - encounterNpcIds: ['npc-lin', 'npc-yan'], - primaryNpcId: 'npc-lin', - linkedThreadIds: ['thread-smuggling'], - actGoal: '先让玩家拿到码头里的第一句真话。', - transitionHook: '确认站位后,真正的封锁者会压上来。', - advanceRule: 'after_primary_contact', - }, - { - id: 'act-docks-2', - title: '封锁加压', - summary: '晏九开始把玩家往更危险的方向逼。', - stageCoverage: ['turning_point', 'climax', 'aftermath'], - backgroundImageSrc: '/images/scene/docks-act-2.webp', - encounterNpcIds: ['npc-yan', 'npc-lin'], - primaryNpcId: 'npc-yan', - linkedThreadIds: ['thread-smuggling'], - actGoal: '把矛盾推向必须接住的下一跳。', - transitionHook: '第 2 幕收束时必须把下一步追踪方向抛出来。', - advanceRule: 'after_chapter_resolution', - }, - ], - }, - ], - }; -} - -test('draft compiler compiles scene chapter cards with act-level editable sections', () => { - const draftProfile = createSceneChapterDraftProfile(); - const compiler = new CustomWorldAgentDraftCompiler(); - - const draftCards = compiler.compileDraftCards(draftProfile); - const sceneChapterCard = draftCards.find((entry) => entry.kind === 'scene_chapter'); - const detail = compiler.getDraftCardDetail(draftProfile, 'scene-chapter-docks'); - - assert.ok(sceneChapterCard); - assert.equal(sceneChapterCard?.title, '潮汐码头章节'); - assert.match(sceneChapterCard?.subtitle ?? '', /2 幕/u); - assert.ok(detail); - assert.equal(detail?.kind, 'scene_chapter'); - assert.ok(detail?.editableSectionIds.includes('title')); - assert.ok(detail?.editableSectionIds.includes('act:act-docks-1:title')); - assert.ok( - detail?.sections.some( - (section) => - section.id === 'act:act-docks-1:backgroundImageSrc' && - section.value === '/images/scene/docks-act-1.webp', - ), - ); - assert.ok( - detail?.sections.some( - (section) => - section.id === 'act:act-docks-2:primaryNpcId' && - section.value.includes('晏九'), - ), - ); -}); - -test('updateDraftCardSections rewrites scene chapter act NPC order and primary npc', () => { - const updatedDraftProfile = updateDraftCardSections({ - draftProfile: JSON.parse(JSON.stringify(createSceneChapterDraftProfile())), - cardId: 'scene-chapter-docks', - sections: [ - { - sectionId: 'title', - value: '潮汐码头对峙章', - }, - { - sectionId: 'act:act-docks-1:title', - value: '封港前夜', - }, - { - sectionId: 'act:act-docks-1:backgroundImageSrc', - value: '/images/scene/docks-act-1-night.webp', - }, - { - sectionId: 'act:act-docks-1:encounterNpcIds', - value: '晏九\n林潮', - }, - { - sectionId: 'act:act-docks-1:transitionHook', - value: '第 1 幕最后要把玩家逼到必须继续追的方向上。', - }, - ], - }); - - const normalized = normalizeFoundationDraftProfile(updatedDraftProfile); - const updatedSceneChapter = normalized?.sceneChapters.find( - (entry) => entry.id === 'scene-chapter-docks', - ); - const updatedAct = updatedSceneChapter?.acts.find((entry) => entry.id === 'act-docks-1'); - - assert.ok(updatedSceneChapter); - assert.ok(updatedAct); - assert.equal(updatedSceneChapter?.title, '潮汐码头对峙章'); - assert.equal(updatedAct?.title, '封港前夜'); - assert.equal( - updatedAct?.backgroundImageSrc, - '/images/scene/docks-act-1-night.webp', - ); - assert.deepEqual(updatedAct?.encounterNpcIds, ['npc-yan', 'npc-lin']); - assert.equal(updatedAct?.primaryNpcId, 'npc-yan'); - assert.equal( - updatedAct?.transitionHook, - '第 1 幕最后要把玩家逼到必须继续追的方向上。', - ); -}); diff --git a/server-node/src/services/customWorldAgentDraftCompiler.ts b/server-node/src/services/customWorldAgentDraftCompiler.ts deleted file mode 100644 index 42f2abd8..00000000 --- a/server-node/src/services/customWorldAgentDraftCompiler.ts +++ /dev/null @@ -1,1733 +0,0 @@ -import type { - CustomWorldDraftCardDetail, - CustomWorldDraftCardDetailSection, - CustomWorldDraftCardKind, - CustomWorldDraftCardSummary, - CustomWorldRoleAssetStatus, - CustomWorldFoundationDraftCamp, - CustomWorldFoundationDraftChapter, - CustomWorldFoundationDraftCharacter, - CustomWorldFoundationDraftFaction, - CustomWorldFoundationDraftLandmark, - CustomWorldFoundationDraftProfile, - CustomWorldFoundationDraftSceneAct, - CustomWorldFoundationDraftSceneChapter, - CustomWorldFoundationDraftThread, -} from '../../../packages/shared/src/contracts/customWorldAgent.js'; -import { - buildRoleAssetSummary, - resolveRoleAssetStatusLabel, -} from './customWorldAgentRoleAssetStateService.js'; - -const WORLD_CARD_ID = 'world-foundation'; - -const EDITABLE_WORLD_SECTION_IDS = [ - 'title', - 'subtitle', - 'summary', - 'playerGoal', - 'tone', - 'coreConflicts', -] as const; - -const EDITABLE_FACTION_SECTION_IDS = [ - 'title', - 'subtitle', - 'summary', - 'publicGoal', - 'tension', -] as const; - -const EDITABLE_CHARACTER_SECTION_IDS = [ - 'name', - 'role', - 'publicMask', - 'hiddenHook', - 'relationToPlayer', - 'summary', -] as const; - -const EDITABLE_LANDMARK_SECTION_IDS = [ - 'name', - 'purpose', - 'mood', - 'secret', - 'summary', -] as const; - -const EDITABLE_THREAD_SECTION_IDS = [ - 'title', - 'summary', - 'conflictType', - 'stakes', -] as const; - -const EDITABLE_CHAPTER_SECTION_IDS = [ - 'title', - 'summary', - 'openingEvent', - 'playerGoal', - 'understandingShift', -] as const; - -const EDITABLE_CAMP_SECTION_IDS = [ - 'name', - 'description', - 'dangerLevel', -] as const; - -const EDITABLE_SCENE_CHAPTER_BASE_SECTION_IDS = [ - 'title', - 'summary', -] as const; - -const SCENE_ACT_STAGE_ORDER = [ - 'opening', - 'expansion', - 'turning_point', - 'climax', - 'aftermath', -] as const; - -const SCENE_ACT_STAGE_LABELS: Record< - CustomWorldFoundationDraftSceneAct['stageCoverage'][number], - string -> = { - opening: '开场', - expansion: '铺展', - turning_point: '转折', - climax: '高潮', - aftermath: '余波', -}; - -const SCENE_ACT_ADVANCE_RULE_LABELS: Record< - CustomWorldFoundationDraftSceneAct['advanceRule'], - string -> = { - after_primary_contact: '主角色首次有效接触后推进', - after_active_step_complete: '当前主动步骤完成后推进', - after_chapter_resolution: '章节进入收束后推进', -}; - -function toText(value: unknown) { - return typeof value === 'string' ? value.trim() : ''; -} - -function toRecord(value: unknown) { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : null; -} - -function toRecordArray(value: unknown) { - return Array.isArray(value) - ? value.filter((item) => item && typeof item === 'object') - : []; -} - -function toStringArray(value: unknown, maxCount = 8) { - if (!Array.isArray(value)) { - return []; - } - - return [...new Set(value.map((item) => toText(item)).filter(Boolean))].slice( - 0, - maxCount, - ); -} - -function normalizeCharacterSkills(value: unknown, fallbackName: string) { - const skills = toRecordArray(value) - .map((item, index) => ({ - id: toText(item.id) || `skill-${index + 1}`, - name: toText(item.name) || `技能${index + 1}`, - actionPreviewConfig: toRecord(item.actionPreviewConfig), - })) - .filter((item) => Boolean(item.id)); - - if (skills.length > 0) { - return skills; - } - - return [ - { - id: 'skill-1', - name: `${clampText(fallbackName, 10) || '角色'}招牌动作`, - actionPreviewConfig: null, - }, - ]; -} - -function slugify(value: string) { - const normalized = value - .trim() - .toLowerCase() - .replace(/[^a-z0-9\u4e00-\u9fa5]+/gu, '-') - .replace(/^-+|-+$/gu, ''); - - return normalized || 'entry'; -} - -function createId(prefix: string, label: string, index: number) { - return `${prefix}-${slugify(label || `${prefix}-${index + 1}`)}-${index + 1}`; -} - -function clampText(value: string, maxLength: number) { - const normalized = value.replace(/\s+/gu, ' ').trim(); - if (!normalized) { - return ''; - } - - if (normalized.length <= maxLength) { - return normalized; - } - - return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}…`; -} - -function dedupeById(items: T[]) { - const seen = new Set(); - return items.filter((item) => { - const key = item.id.trim(); - if (!key || seen.has(key)) { - return false; - } - - seen.add(key); - return true; - }); -} - -function resolveEditableSectionIds(kind: CustomWorldDraftCardKind) { - if (kind === 'world') return [...EDITABLE_WORLD_SECTION_IDS]; - if (kind === 'faction') return [...EDITABLE_FACTION_SECTION_IDS]; - if (kind === 'character') return [...EDITABLE_CHARACTER_SECTION_IDS]; - if (kind === 'landmark') return [...EDITABLE_LANDMARK_SECTION_IDS]; - if (kind === 'thread') return [...EDITABLE_THREAD_SECTION_IDS]; - if (kind === 'chapter') return [...EDITABLE_CHAPTER_SECTION_IDS]; - if (kind === 'camp') return [...EDITABLE_CAMP_SECTION_IDS]; - if (kind === 'scene_chapter') return [...EDITABLE_SCENE_CHAPTER_BASE_SECTION_IDS]; - return []; -} - -function resolveSceneChapterEditableSectionIds( - sceneChapter: CustomWorldFoundationDraftSceneChapter, -) { - return [ - ...EDITABLE_SCENE_CHAPTER_BASE_SECTION_IDS, - ...sceneChapter.acts.flatMap((act) => [ - `act:${act.id}:title`, - `act:${act.id}:summary`, - `act:${act.id}:backgroundImageSrc`, - `act:${act.id}:encounterNpcIds`, - `act:${act.id}:actGoal`, - `act:${act.id}:transitionHook`, - ]), - ]; -} - -function resolveSceneActStageCoverageLabel( - stageCoverage: CustomWorldFoundationDraftSceneAct['stageCoverage'], -) { - return stageCoverage - .map((stage) => SCENE_ACT_STAGE_LABELS[stage] || stage) - .join('、'); -} - -function resolveSceneActAdvanceRuleLabel( - advanceRule: CustomWorldFoundationDraftSceneAct['advanceRule'], -) { - return SCENE_ACT_ADVANCE_RULE_LABELS[advanceRule] || advanceRule; -} - -function normalizeFaction( - value: unknown, - index: number, -): CustomWorldFoundationDraftFaction | null { - const record = toRecord(value); - if (!record) { - return null; - } - - const title = toText(record.title) || toText(record.name); - const subtitle = toText(record.subtitle); - const publicGoal = toText(record.publicGoal); - const tension = toText(record.tension) || toText(record.relatedConflict); - const playerRelation = toText(record.playerRelation); - const summary = toText(record.summary); - - if (!title && !publicGoal && !tension && !summary) { - return null; - } - - return { - id: toText(record.id) || createId('faction', title || publicGoal, index), - name: title || `关键势力 ${index + 1}`, - title: title || `关键势力 ${index + 1}`, - subtitle: - subtitle || - clampText( - [publicGoal || '关键势力', tension || '当前张力仍在升级'] - .filter(Boolean) - .join(' · '), - 40, - ), - publicGoal: publicGoal || '稳住自己在当前局势中的位置', - relatedConflict: tension || '局势仍在快速失衡', - tension: tension || '局势仍在快速失衡', - playerRelation: playerRelation || '玩家迟早要和它发生直接关系', - summary: - summary || - clampText( - [ - publicGoal || '正在抢夺当前局势的主动权', - tension || '和主线冲突直接相连', - playerRelation || '会逼玩家选边', - ].join(';'), - 120, - ), - }; -} - -function normalizeCharacter( - value: unknown, - index: number, -): CustomWorldFoundationDraftCharacter | null { - const record = toRecord(value); - if (!record) { - return null; - } - - const name = toText(record.name); - const title = toText(record.title); - const role = toText(record.role); - const publicMask = toText(record.publicMask) || toText(record.publicIdentity); - const hiddenHook = toText(record.hiddenHook) || toText(record.currentPressure); - const relationToPlayer = toText(record.relationToPlayer); - const summary = toText(record.summary); - - if (!name && !title && !role && !summary) { - return null; - } - - return { - id: toText(record.id) || createId('character', name || title || role, index), - name: name || `关键角色 ${index + 1}`, - title: title || role || '关键角色', - role: role || title || '关键角色', - publicIdentity: publicMask || title || role || '正在局势前台行动的人', - publicMask: publicMask || title || role || '正在局势前台行动的人', - currentPressure: hiddenHook || '必须立刻回应眼前的局势压力', - hiddenHook: hiddenHook || '必须立刻回应眼前的局势压力', - relationToPlayer: relationToPlayer || '和玩家存在尚待精修的关系钩子', - threadIds: toStringArray(record.threadIds, 6), - summary: - summary || - clampText( - [ - publicMask || title || role || '处在局势前台', - hiddenHook || '眼下压力仍在加码', - relationToPlayer || '与玩家关系待细化', - ].join(';'), - 120, - ), - skills: normalizeCharacterSkills(record.skills, role || title || name || '角色'), - imageSrc: toText(record.imageSrc) || null, - generatedVisualAssetId: toText(record.generatedVisualAssetId) || null, - generatedAnimationSetId: toText(record.generatedAnimationSetId) || null, - animationMap: toRecord(record.animationMap), - }; -} - -function normalizeLandmark( - value: unknown, - index: number, -): CustomWorldFoundationDraftLandmark | null { - const record = toRecord(value); - if (!record) { - return null; - } - - const name = toText(record.name); - const description = toText(record.description); - const purpose = toText(record.purpose); - const mood = toText(record.mood); - const secret = toText(record.secret) || toText(record.importance); - const dangerLevel = toText(record.dangerLevel); - const summary = toText(record.summary); - - if (!name && !purpose && !mood && !secret && !summary) { - return null; - } - - return { - id: toText(record.id) || createId('landmark', name || purpose, index), - name: name || `关键地点 ${index + 1}`, - description: - description || - clampText( - [purpose || '承接关键冲突', mood || '整体情绪仍在发酵'] - .filter(Boolean) - .join(';'), - 96, - ), - purpose: purpose || '承接主线推进的关键地点', - mood: mood || '带着明显张力与未明感', - importance: secret || '玩家第一次抵达就会意识到它不只是背景', - secret: secret || '玩家第一次抵达就会意识到它不只是背景', - dangerLevel: dangerLevel || '中', - imageSrc: toText(record.imageSrc) || null, - generatedSceneAssetId: toText(record.generatedSceneAssetId) || null, - generatedScenePrompt: toText(record.generatedScenePrompt) || null, - generatedSceneModel: toText(record.generatedSceneModel) || null, - characterIds: toStringArray(record.characterIds, 8), - threadIds: toStringArray(record.threadIds, 8), - summary: - summary || - clampText( - [ - purpose || '承担关键戏剧功能', - secret || '和当前冲突直接相连', - mood || '会立刻形成情绪印象', - ].join(';'), - 120, - ), - }; -} - -function normalizeThread( - value: unknown, - index: number, -): CustomWorldFoundationDraftThread | null { - const record = toRecord(value); - if (!record) { - return null; - } - - const title = toText(record.title); - const conflictTypeText = toText(record.conflictType); - const type = - record.type === 'hidden' || - conflictTypeText.includes('暗') || - conflictTypeText.toLowerCase() === 'hidden' - ? 'hidden' - : 'main'; - const stakes = toText(record.stakes) || toText(record.conflict); - const summary = toText(record.summary); - - if (!title && !stakes && !summary) { - return null; - } - - return { - id: toText(record.id) || createId('thread', title || stakes, index), - title: title || `世界线程 ${index + 1}`, - type, - conflictType: - conflictTypeText || (type === 'hidden' ? '暗线' : '明线'), - conflict: stakes || '这条线仍在等待进一步精修', - stakes: stakes || '这条线仍在等待进一步精修', - characterIds: toStringArray(record.characterIds, 8), - landmarkIds: toStringArray(record.landmarkIds, 8), - summary: - summary || - clampText( - [ - type === 'hidden' ? '暗线' : '明线', - stakes || '主要冲突待细化', - ].join(':'), - 120, - ), - }; -} - -function normalizeChapter( - value: unknown, - index: number, -): CustomWorldFoundationDraftChapter | null { - const record = toRecord(value); - if (!record) { - return null; - } - - const title = toText(record.title); - const openingEvent = toText(record.openingEvent); - const playerGoal = toText(record.playerGoal); - const understandingShift = toText(record.understandingShift); - const summary = toText(record.summary); - - if (!title && !openingEvent && !playerGoal && !summary) { - return null; - } - - return { - id: toText(record.id) || createId('chapter', title || openingEvent, index), - title: title || '第一幕', - openingEvent: openingEvent || '局势在开幕时突然失控', - playerGoal: playerGoal || '先稳住开局并找到下一步目标', - characterIds: toStringArray(record.characterIds, 8), - landmarkIds: toStringArray(record.landmarkIds, 8), - understandingShift: - understandingShift || '玩家会意识到这场冲突远不止表面那一层', - summary: - summary || - clampText( - [ - openingEvent || '开幕事件已逼近', - playerGoal || '玩家需要尽快立住脚跟', - understandingShift || '第一幕会改写玩家对世界的理解', - ].join(';'), - 140, - ), - }; -} - -function normalizeCamp(value: unknown): CustomWorldFoundationDraftCamp | null { - const record = toRecord(value); - if (!record) { - return null; - } - - const name = toText(record.name); - const description = toText(record.description); - const dangerLevel = toText(record.dangerLevel) || toText(record.mood); - const summary = toText(record.summary); - - if (!name && !description && !summary) { - return null; - } - - return { - id: toText(record.id) || 'camp-home', - name: name || '临时落脚处', - description: description || '玩家暂时还能整顿情报和喘口气的地方', - mood: dangerLevel || '克制、紧绷,但还能暂时收拢局势', - dangerLevel: dangerLevel || '克制、紧绷,但还能暂时收拢局势', - imageSrc: toText(record.imageSrc) || null, - generatedSceneAssetId: toText(record.generatedSceneAssetId) || null, - generatedScenePrompt: toText(record.generatedScenePrompt) || null, - generatedSceneModel: toText(record.generatedSceneModel) || null, - summary: - summary || - clampText( - [ - description || '这是玩家当前最稳的回气点', - dangerLevel || '它承担落脚与整理线索的功能', - ].join(';'), - 120, - ), - }; -} - -function normalizeStageCoverage(value: unknown) { - const stageCoverage = Array.isArray(value) - ? value - .filter((entry): entry is string => typeof entry === 'string') - .map((entry) => entry.trim()) - .filter( - ( - entry, - ): entry is CustomWorldFoundationDraftSceneAct['stageCoverage'][number] => - SCENE_ACT_STAGE_ORDER.includes( - entry as (typeof SCENE_ACT_STAGE_ORDER)[number], - ), - ) - : []; - - return [...new Set(stageCoverage)]; -} - -function buildFallbackSceneActStageCoverage(index: number, actCount: number) { - if (actCount <= 2) { - return index === 0 - ? (['opening', 'expansion'] as CustomWorldFoundationDraftSceneAct['stageCoverage']) - : (['turning_point', 'climax', 'aftermath'] as CustomWorldFoundationDraftSceneAct['stageCoverage']); - } - - if (actCount === 3) { - if (index === 0) { - return ['opening'] as CustomWorldFoundationDraftSceneAct['stageCoverage']; - } - if (index === 1) { - return ['expansion', 'turning_point'] as CustomWorldFoundationDraftSceneAct['stageCoverage']; - } - return ['climax', 'aftermath'] as CustomWorldFoundationDraftSceneAct['stageCoverage']; - } - - if (actCount === 4) { - if (index === 0) return ['opening'] as CustomWorldFoundationDraftSceneAct['stageCoverage']; - if (index === 1) return ['expansion'] as CustomWorldFoundationDraftSceneAct['stageCoverage']; - if (index === 2) return ['turning_point'] as CustomWorldFoundationDraftSceneAct['stageCoverage']; - return ['climax', 'aftermath'] as CustomWorldFoundationDraftSceneAct['stageCoverage']; - } - - return [SCENE_ACT_STAGE_ORDER[Math.min(index, SCENE_ACT_STAGE_ORDER.length - 1)]]; -} - -function normalizeSceneAct( - value: unknown, - index: number, - fallback: { - sceneId: string; - sceneName: string; - backgroundImageSrc?: string | null; - encounterNpcIds: string[]; - linkedThreadIds: string[]; - actCount: number; - }, -): CustomWorldFoundationDraftSceneAct | null { - const record = toRecord(value); - if (!record) { - return null; - } - - const title = toText(record.title); - const summary = toText(record.summary); - const encounterNpcIds = toStringArray( - record.encounterNpcIds, - Math.max(1, fallback.encounterNpcIds.length || 8), - ); - const stageCoverage = normalizeStageCoverage(record.stageCoverage); - - if (!title && !summary && encounterNpcIds.length === 0) { - return null; - } - - const resolvedEncounterNpcIds = - encounterNpcIds.length > 0 ? encounterNpcIds : fallback.encounterNpcIds; - const primaryNpcId = toText(record.primaryNpcId) || resolvedEncounterNpcIds[0] || ''; - - return { - id: - toText(record.id) || - createId(`scene-act-${fallback.sceneId}`, title || fallback.sceneName, index), - title: title || `第 ${index + 1} 幕`, - summary: - summary || - clampText( - [ - title || `第 ${index + 1} 幕`, - toText(record.actGoal) || '这一幕仍需继续精修', - ].join(';'), - 120, - ), - stageCoverage: - stageCoverage.length > 0 - ? stageCoverage - : buildFallbackSceneActStageCoverage(index, fallback.actCount), - backgroundImageSrc: - toText(record.backgroundImageSrc) || fallback.backgroundImageSrc || null, - backgroundAssetId: toText(record.backgroundAssetId) || null, - encounterNpcIds: resolvedEncounterNpcIds, - primaryNpcId, - linkedThreadIds: - toStringArray(record.linkedThreadIds, 8).length > 0 - ? toStringArray(record.linkedThreadIds, 8) - : fallback.linkedThreadIds, - actGoal: - toText(record.actGoal) || - (index === 0 - ? `先在${fallback.sceneName}接住开场 lead` - : index === fallback.actCount - 1 - ? `把${fallback.sceneName}这一章收住` - : `继续逼近${fallback.sceneName}的核心压力`), - transitionHook: - toText(record.transitionHook) || - (index === fallback.actCount - 1 - ? '这一幕结束后会把问题推向下一跳。' - : '完成当前推进后,局势会进入下一幕。'), - advanceRule: - toText(record.advanceRule) === 'after_primary_contact' || - toText(record.advanceRule) === 'after_active_step_complete' || - toText(record.advanceRule) === 'after_chapter_resolution' - ? (toText(record.advanceRule) as CustomWorldFoundationDraftSceneAct['advanceRule']) - : index === 0 - ? 'after_primary_contact' - : index === fallback.actCount - 1 - ? 'after_chapter_resolution' - : 'after_active_step_complete', - }; -} - -function buildFallbackSceneActs(params: { - sceneId: string; - sceneName: string; - sceneSummary: string; - backgroundImageSrc?: string | null; - encounterNpcIds: string[]; - linkedThreadIds: string[]; -}) { - const actCount = 3; - - return [ - { - id: `${params.sceneId}-act-1`, - title: `初见 ${params.sceneName}`, - summary: clampText( - `玩家第一次真正接住${params.sceneName}这一章的入口。${params.sceneSummary}`, - 120, - ), - stageCoverage: buildFallbackSceneActStageCoverage(0, actCount), - backgroundImageSrc: params.backgroundImageSrc || null, - backgroundAssetId: null, - encounterNpcIds: params.encounterNpcIds, - primaryNpcId: params.encounterNpcIds[0] || '', - linkedThreadIds: params.linkedThreadIds, - actGoal: `先在${params.sceneName}接住开场 lead`, - transitionHook: '和主角色完成首次有效接触后,局势会继续加压。', - advanceRule: 'after_primary_contact', - }, - { - id: `${params.sceneId}-act-2`, - title: `${params.sceneName}承压`, - summary: clampText( - `玩家开始确认${params.sceneName}不只是背景,而是这一章真正承压的地方。`, - 120, - ), - stageCoverage: buildFallbackSceneActStageCoverage(1, actCount), - backgroundImageSrc: params.backgroundImageSrc || null, - backgroundAssetId: null, - encounterNpcIds: params.encounterNpcIds, - primaryNpcId: params.encounterNpcIds[0] || '', - linkedThreadIds: params.linkedThreadIds, - actGoal: `继续逼近${params.sceneName}的核心压力`, - transitionHook: '完成当前主动 step 后,这一章会转向收束。', - advanceRule: 'after_active_step_complete', - }, - { - id: `${params.sceneId}-act-3`, - title: `${params.sceneName}收束`, - summary: clampText( - `这一幕承担${params.sceneName}的局部收束和下一跳 handoff。`, - 120, - ), - stageCoverage: buildFallbackSceneActStageCoverage(2, actCount), - backgroundImageSrc: params.backgroundImageSrc || null, - backgroundAssetId: null, - encounterNpcIds: params.encounterNpcIds, - primaryNpcId: params.encounterNpcIds[0] || '', - linkedThreadIds: params.linkedThreadIds, - actGoal: `把${params.sceneName}这一章收住`, - transitionHook: '这一幕结束后需要把后续方向明确抛给玩家。', - advanceRule: 'after_chapter_resolution', - }, - ] satisfies CustomWorldFoundationDraftSceneAct[]; -} - -function normalizeSceneChapter( - value: unknown, - index: number, - fallback: { - sceneId: string; - sceneName: string; - sceneSummary: string; - linkedThreadIds: string[]; - linkedLandmarkIds: string[]; - backgroundImageSrc?: string | null; - encounterNpcIds: string[]; - }, -): CustomWorldFoundationDraftSceneChapter | null { - const record = toRecord(value); - if (!record) { - return null; - } - - const sceneId = toText(record.sceneId) || fallback.sceneId; - const sceneName = toText(record.sceneName) || fallback.sceneName; - const title = toText(record.title); - const summary = toText(record.summary); - const actsInput = Array.isArray(record.acts) ? record.acts : []; - const actCount = Math.min(5, Math.max(2, actsInput.length || 3)); - const linkedThreadIds = - toStringArray(record.linkedThreadIds, 8).length > 0 - ? toStringArray(record.linkedThreadIds, 8) - : fallback.linkedThreadIds; - const linkedLandmarkIds = - toStringArray(record.linkedLandmarkIds, 8).length > 0 - ? toStringArray(record.linkedLandmarkIds, 8) - : fallback.linkedLandmarkIds; - - const acts = actsInput - .map((entry, actIndex) => - normalizeSceneAct(entry, actIndex, { - sceneId, - sceneName, - backgroundImageSrc: fallback.backgroundImageSrc, - encounterNpcIds: fallback.encounterNpcIds, - linkedThreadIds, - actCount, - }), - ) - .filter((entry): entry is CustomWorldFoundationDraftSceneAct => Boolean(entry)) - .slice(0, 5); - - return { - id: toText(record.id) || createId('scene-chapter', sceneName || title, index), - sceneId, - sceneName, - title: title || `${sceneName}章节`, - summary: - summary || - clampText( - [ - sceneName, - fallback.sceneSummary || '这一章的场景节拍仍可继续收紧', - ].join(':'), - 140, - ), - linkedThreadIds, - linkedLandmarkIds, - acts: acts.length >= 2 ? acts : buildFallbackSceneActs({ - sceneId, - sceneName, - sceneSummary: fallback.sceneSummary, - backgroundImageSrc: fallback.backgroundImageSrc, - encounterNpcIds: fallback.encounterNpcIds, - linkedThreadIds, - }), - }; -} - -function buildFallbackSceneChapters(params: { - landmarks: CustomWorldFoundationDraftLandmark[]; - characters: CustomWorldFoundationDraftCharacter[]; - threads: CustomWorldFoundationDraftThread[]; - chapters: CustomWorldFoundationDraftChapter[]; -}) { - const fallbackCharacterIds = params.characters.slice(0, 3).map((entry) => entry.id); - - return params.landmarks.map((landmark, index) => { - const matchingChapter = - params.chapters.find((chapter) => chapter.landmarkIds.includes(landmark.id)) ?? null; - const encounterNpcIds = - landmark.characterIds.length > 0 ? landmark.characterIds : fallbackCharacterIds; - const linkedThreadIds = - landmark.threadIds.length > 0 - ? landmark.threadIds - : params.threads - .filter((thread) => thread.landmarkIds.includes(landmark.id)) - .map((thread) => thread.id) - .slice(0, 4); - - return { - id: `scene-chapter-${landmark.id}`, - sceneId: landmark.id, - sceneName: landmark.name, - title: matchingChapter?.title || `${landmark.name}章节`, - summary: - matchingChapter?.summary || - clampText( - [landmark.summary, matchingChapter?.openingEvent || '这一章会从这里真正展开'] - .filter(Boolean) - .join(';'), - 140, - ), - linkedThreadIds, - linkedLandmarkIds: [landmark.id], - acts: buildFallbackSceneActs({ - sceneId: landmark.id, - sceneName: landmark.name, - sceneSummary: landmark.summary, - backgroundImageSrc: landmark.imageSrc || null, - encounterNpcIds, - linkedThreadIds, - }), - } satisfies CustomWorldFoundationDraftSceneChapter; - }); -} - -function resolveSceneChapterFallbackFromRecord(item: unknown, index: number) { - const record = toRecord(item); - const linkedLandmarkIds = toStringArray(record?.linkedLandmarkIds, 8); - return { - sceneId: toText(record?.sceneId) || linkedLandmarkIds[0] || `scene-${index + 1}`, - sceneName: - toText(record?.sceneName) || - toText(record?.title) || - `场景章节 ${index + 1}`, - sceneSummary: - toText(record?.summary) || - '这一章仍可继续精修场景幕结构。', - linkedThreadIds: toStringArray(record?.linkedThreadIds, 8), - linkedLandmarkIds, - backgroundImageSrc: toText(record?.backgroundImageSrc) || null, - encounterNpcIds: toStringArray(record?.encounterNpcIds, 8), - }; -} - -export function normalizeFoundationDraftProfile( - value: unknown, -): CustomWorldFoundationDraftProfile | null { - const record = toRecord(value); - if (!record) { - return null; - } - - const name = toText(record.name) || toText(record.title); - const summary = toText(record.summary); - const playableNpcs = dedupeById( - toRecordArray(record.playableNpcs) - .map((item, index) => normalizeCharacter(item, index)) - .filter((item): item is CustomWorldFoundationDraftCharacter => - Boolean(item), - ), - ); - const storyNpcs = dedupeById( - toRecordArray(record.storyNpcs) - .map((item, index) => normalizeCharacter(item, index)) - .filter((item): item is CustomWorldFoundationDraftCharacter => - Boolean(item), - ), - ); - const landmarks = dedupeById( - toRecordArray(record.landmarks) - .map((item, index) => normalizeLandmark(item, index)) - .filter((item): item is CustomWorldFoundationDraftLandmark => - Boolean(item), - ), - ); - const factions = dedupeById( - toRecordArray(record.factions) - .map((item, index) => normalizeFaction(item, index)) - .filter((item): item is CustomWorldFoundationDraftFaction => - Boolean(item), - ), - ); - const threads = dedupeById( - toRecordArray(record.threads) - .map((item, index) => normalizeThread(item, index)) - .filter((item): item is CustomWorldFoundationDraftThread => - Boolean(item), - ), - ); - const chapters = dedupeById( - toRecordArray(record.chapters) - .map((item, index) => normalizeChapter(item, index)) - .filter((item): item is CustomWorldFoundationDraftChapter => - Boolean(item), - ), - ); - const mergedCharacters = dedupeById([...playableNpcs, ...storyNpcs]); - const explicitSceneChapters = toRecordArray(record.sceneChapters) - .map((item, index) => - normalizeSceneChapter( - item, - index, - resolveSceneChapterFallbackFromRecord(item, index), - ), - ) - .filter((item): item is CustomWorldFoundationDraftSceneChapter => - Boolean(item), - ); - const sceneChapters = dedupeById( - explicitSceneChapters.length > 0 - ? explicitSceneChapters - : buildFallbackSceneChapters({ - landmarks, - characters: mergedCharacters, - threads, - chapters, - }) - ); - const camp = normalizeCamp(record.camp); - const hasStructuredFoundationContent = - playableNpcs.length > 0 || - storyNpcs.length > 0 || - landmarks.length > 0 || - factions.length > 0 || - threads.length > 0 || - chapters.length > 0 || - sceneChapters.length > 0 || - Boolean(camp); - - if (!hasStructuredFoundationContent) { - return null; - } - const coreConflicts = toStringArray(record.coreConflicts, 6); - - return { - name: name || '未命名世界底稿', - subtitle: - toText(record.subtitle) || - clampText( - [toText(record.playerPremise), coreConflicts[0] ?? '核心冲突仍在整理'] - .filter(Boolean) - .join(' · '), - 40, - ) || - '第一版世界底稿', - summary: - summary || - clampText( - [ - toText(record.worldHook), - toText(record.playerPremise), - coreConflicts[0] ?? '', - ] - .filter(Boolean) - .join(' '), - 160, - ) || - '第一版世界底稿已经整理完成。', - tone: toText(record.tone) || '整体气质仍可继续精修', - playerGoal: toText(record.playerGoal) || '先站稳开局,再判断下一步', - majorFactions: - toStringArray(record.majorFactions, 6).length > 0 - ? toStringArray(record.majorFactions, 6) - : factions.map((entry) => entry.name), - coreConflicts, - playableNpcs: - playableNpcs.length > 0 - ? playableNpcs - : mergedCharacters.slice(0, Math.max(3, mergedCharacters.length)), - storyNpcs: - storyNpcs.length > 0 - ? storyNpcs - : mergedCharacters.filter( - (entry) => !playableNpcs.some((npc) => npc.id === entry.id), - ), - landmarks, - camp, - themePack: toRecord(record.themePack), - storyGraph: toRecord(record.storyGraph), - factions, - threads, - chapters, - sceneChapters, - worldHook: toText(record.worldHook) || name || summary, - playerPremise: toText(record.playerPremise), - openingSituation: toText(record.openingSituation), - iconicElements: toStringArray(record.iconicElements, 8), - sourceAnchorSummary: toText(record.sourceAnchorSummary) || summary, - }; -} - -function buildSection( - id: string, - label: string, - value: string, -): CustomWorldDraftCardDetailSection { - return { - id, - label, - value: value.trim() || '待继续精修', - }; -} - -function resolveThreadTypeLabel(type: CustomWorldFoundationDraftThread['type']) { - return type === 'hidden' ? '暗线' : '明线'; -} - -function buildWorldWarnings(profile: CustomWorldFoundationDraftProfile) { - const warnings: string[] = []; - const totalCharacters = dedupeById([ - ...profile.playableNpcs, - ...profile.storyNpcs, - ]).length; - if (profile.iconicElements.length === 0) { - warnings.push('标志性要素还偏少,后续可以补 1 到 2 个记忆点。'); - } - if (totalCharacters < 3) { - warnings.push('关键角色数量还偏少,建议继续补角色关系网。'); - } - if (profile.landmarks.length < 2) { - warnings.push('关键地点仍然偏少,第一版场景章节还不够饱满。'); - } - return warnings; -} - -function buildFactionWarnings(faction: CustomWorldFoundationDraftFaction) { - const warnings: string[] = []; - if (!faction.playerRelation.trim()) { - warnings.push('这个势力和玩家的关系仍可更具体。'); - } - if (!faction.relatedConflict.trim()) { - warnings.push('这个势力还缺少更明确的冲突挂钩。'); - } - return warnings; -} - -function buildCharacterWarnings(character: CustomWorldFoundationDraftCharacter) { - const warnings: string[] = []; - if (!character.relationToPlayer.trim()) { - warnings.push('和玩家的关系钩子还不够明确。'); - } - if (character.threadIds.length === 0) { - warnings.push('这个角色尚未绑定到明确线程。'); - } - return warnings; -} - -function buildLandmarkWarnings(landmark: CustomWorldFoundationDraftLandmark) { - const warnings: string[] = []; - if (landmark.characterIds.length === 0) { - warnings.push('这个地点还没有挂住足够明确的角色。'); - } - if (landmark.threadIds.length === 0) { - warnings.push('这个地点还缺少更清楚的线程挂钩。'); - } - if (!landmark.imageSrc || !landmark.generatedSceneAssetId) { - warnings.push('这个地点还没有绑定正式场景图。'); - } - return warnings; -} - -function buildThreadWarnings(thread: CustomWorldFoundationDraftThread) { - const warnings: string[] = []; - if (thread.characterIds.length === 0) { - warnings.push('这条线还缺少更明确的角色挂点。'); - } - if (thread.landmarkIds.length === 0) { - warnings.push('这条线还缺少更明确的地点挂点。'); - } - return warnings; -} - -function buildChapterWarnings(chapter: CustomWorldFoundationDraftChapter) { - const warnings: string[] = []; - if (chapter.characterIds.length < 2) { - warnings.push('第一幕涉及的关键角色还偏少。'); - } - if (chapter.landmarkIds.length < 2) { - warnings.push('第一幕涉及的关键地点还偏少。'); - } - return warnings; -} - -function buildSceneChapterWarnings(params: { - sceneChapter: CustomWorldFoundationDraftSceneChapter; - characterById: Map; - threadById: Map; - landmarkById: Map; -}) { - const { sceneChapter, characterById, threadById, landmarkById } = params; - const warnings: string[] = []; - - if (sceneChapter.acts.length < 2) { - warnings.push('这个场景章节至少需要 2 幕。'); - } - if (sceneChapter.acts.length > 5) { - warnings.push('这个场景章节当前超过 5 幕,建议先收束到 5 幕以内。'); - } - - const linkedLandmarks = sceneChapter.linkedLandmarkIds - .map((id) => landmarkById.get(id)) - .filter((entry): entry is CustomWorldFoundationDraftLandmark => Boolean(entry)); - - sceneChapter.acts.forEach((act, index) => { - const actLabel = `第 ${index + 1} 幕`; - const primaryNpcId = act.encounterNpcIds[0] || act.primaryNpcId; - const actThreadIds = - act.linkedThreadIds.length > 0 - ? act.linkedThreadIds - : sceneChapter.linkedThreadIds; - - if (!act.backgroundImageSrc && !act.backgroundAssetId) { - warnings.push(`${actLabel}还没有绑定背景图。`); - } - if (act.encounterNpcIds.length === 0) { - warnings.push(`${actLabel}还没有配置相遇 NPC。`); - } - if (!primaryNpcId) { - warnings.push(`${actLabel}缺少主角色。`); - } - if (act.primaryNpcId && act.primaryNpcId !== (act.encounterNpcIds[0] ?? '')) { - warnings.push(`${actLabel}的主角色必须放在相遇 NPC 的第一位。`); - } - if (actThreadIds.length === 0) { - warnings.push(`${actLabel}还没有挂到明确线程。`); - } - - const unresolvedNpcIds = act.encounterNpcIds.filter((id) => !characterById.has(id)); - if (unresolvedNpcIds.length > 0) { - warnings.push( - `${actLabel}存在未进入当前世界角色池的 NPC:${unresolvedNpcIds - .slice(0, 3) - .join('、')}。`, - ); - } - - const unresolvedThreadIds = actThreadIds.filter((id) => !threadById.has(id)); - if (unresolvedThreadIds.length > 0) { - warnings.push( - `${actLabel}存在未绑定的线程引用:${unresolvedThreadIds - .slice(0, 3) - .join('、')}。`, - ); - } - - if (primaryNpcId && characterById.has(primaryNpcId)) { - const linkedToLandmark = linkedLandmarks.some((landmark) => - landmark.characterIds.includes(primaryNpcId), - ); - const linkedToThread = actThreadIds.some((threadId) => - threadById.get(threadId)?.characterIds.includes(primaryNpcId), - ); - if (!linkedToLandmark && !linkedToThread) { - warnings.push(`${actLabel}的主角色和当前场景/线程的关联还不够明确。`); - } - } - }); - - return warnings; -} - -function buildCampWarnings(camp: CustomWorldFoundationDraftCamp) { - const warnings: string[] = []; - if (!camp.imageSrc || !camp.generatedSceneAssetId) { - warnings.push('营地还没有绑定正式场景图。'); - } - return warnings; -} - -function buildCharacterAssetHeadline(character: CustomWorldFoundationDraftCharacter) { - const assetSummary = buildRoleAssetSummary({ - role: { - id: character.id, - name: character.name, - threadIds: character.threadIds, - imageSrc: character.imageSrc, - generatedVisualAssetId: character.generatedVisualAssetId, - generatedAnimationSetId: character.generatedAnimationSetId, - animationMap: character.animationMap, - skills: character.skills ?? [], - }, - roleKind: 'story', - }); - - return { - status: assetSummary.status, - label: resolveRoleAssetStatusLabel(assetSummary.status), - }; -} - -type CompiledCard = { - summary: CustomWorldDraftCardSummary; - detail: CustomWorldDraftCardDetail; -}; - -export class CustomWorldAgentDraftCompiler { - compileDraftCards(profileInput: unknown) { - return this.compile(profileInput).map((entry) => entry.summary); - } - - getDraftCardDetail(profileInput: unknown, cardId: string) { - return ( - this.compile(profileInput).find((entry) => entry.summary.id === cardId) - ?.detail ?? null - ); - } - - private compile(profileInput: unknown): CompiledCard[] { - const profile = normalizeFoundationDraftProfile(profileInput); - if (!profile) { - return []; - } - - const characters = dedupeById([ - ...profile.playableNpcs, - ...profile.storyNpcs, - ]); - const characterById = new Map(characters.map((entry) => [entry.id, entry])); - const landmarkById = new Map(profile.landmarks.map((entry) => [entry.id, entry])); - const threadById = new Map(profile.threads.map((entry) => [entry.id, entry])); - - const resolveCharacterNames = (ids: string[]) => - ids - .map((id) => characterById.get(id)?.name) - .filter((entry): entry is string => Boolean(entry)) - .join('、'); - const resolveLandmarkNames = (ids: string[]) => - ids - .map((id) => landmarkById.get(id)?.name) - .filter((entry): entry is string => Boolean(entry)) - .join('、'); - const resolveThreadTitles = (ids: string[]) => - ids - .map((id) => threadById.get(id)?.title) - .filter((entry): entry is string => Boolean(entry)) - .join('、'); - - const cards: CompiledCard[] = []; - - const pushCard = (params: { - id: string; - kind: CustomWorldDraftCardKind; - title: string; - subtitle: string; - summary: string; - linkedIds: string[]; - sections: CustomWorldDraftCardDetailSection[]; - editableSectionIds?: string[]; - warningMessages: string[]; - assetStatus?: CustomWorldRoleAssetStatus | null; - assetStatusLabel?: string | null; - }) => { - const warningMessages = [...new Set(params.warningMessages.filter(Boolean))]; - const editableSectionIds = params.editableSectionIds ?? []; - cards.push({ - summary: { - id: params.id, - kind: params.kind, - title: params.title, - subtitle: params.subtitle, - summary: clampText(params.summary, 180), - status: warningMessages.length > 0 ? 'warning' : 'suggested', - linkedIds: [...new Set(params.linkedIds.filter(Boolean))], - warningCount: warningMessages.length, - assetStatus: params.assetStatus ?? null, - assetStatusLabel: params.assetStatusLabel ?? null, - }, - detail: { - id: params.id, - kind: params.kind, - title: params.title, - sections: params.sections, - linkedIds: [...new Set(params.linkedIds.filter(Boolean))], - locked: false, - editable: editableSectionIds.length > 0, - editableSectionIds, - warningMessages, - assetStatus: params.assetStatus ?? null, - assetStatusLabel: params.assetStatusLabel ?? null, - }, - }); - }; - - const worldWarnings = buildWorldWarnings(profile); - pushCard({ - id: WORLD_CARD_ID, - kind: 'world', - title: profile.name, - subtitle: - clampText( - [profile.playerPremise, profile.coreConflicts[0] ?? '核心冲突待继续精修'] - .filter(Boolean) - .join(' · '), - 40, - ) || profile.subtitle, - summary: profile.summary, - linkedIds: [ - ...(profile.camp ? [profile.camp.id] : []), - ...profile.factions.map((entry) => entry.id), - ...characters.map((entry) => entry.id), - ...profile.landmarks.map((entry) => entry.id), - ...profile.threads.map((entry) => entry.id), - ...profile.chapters.map((entry) => entry.id), - ...profile.sceneChapters.map((entry) => entry.id), - ].slice(0, 12), - sections: [ - buildSection('title', '标题', profile.name), - buildSection('subtitle', '副标题', profile.subtitle), - buildSection('summary', '摘要', profile.summary), - buildSection('playerGoal', '玩家目标', profile.playerGoal), - buildSection( - 'tone', - '世界气质', - [profile.tone, profile.iconicElements.join('、')] - .filter(Boolean) - .join(' / '), - ), - buildSection( - 'coreConflicts', - '核心冲突', - profile.coreConflicts.join(';') || profile.summary, - ), - buildSection('worldHook', '世界一句话', profile.worldHook || profile.summary), - buildSection( - 'playerPremise', - '玩家是谁', - profile.playerPremise, - ), - ], - editableSectionIds: resolveEditableSectionIds('world'), - warningMessages: worldWarnings, - }); - - if (profile.camp) { - const campWarnings = buildCampWarnings(profile.camp); - pushCard({ - id: profile.camp.id, - kind: 'camp', - title: profile.camp.name, - subtitle: clampText( - [ - profile.camp.mood || '开局落脚处', - profile.camp.imageSrc && profile.camp.generatedSceneAssetId - ? '背景图已就绪' - : '待生成背景图', - ] - .filter(Boolean) - .join(' / '), - 28, - ), - summary: profile.camp.summary, - linkedIds: [ - ...profile.landmarks.slice(0, 2).map((entry) => entry.id), - ...characters.slice(0, 2).map((entry) => entry.id), - ...profile.chapters.slice(0, 1).map((entry) => entry.id), - ], - sections: [ - buildSection('name', '营地名称', profile.camp.name), - buildSection('description', '当前定位', profile.camp.description), - buildSection( - 'dangerLevel', - '危险等级', - profile.camp.dangerLevel || profile.camp.mood, - ), - buildSection( - 'sceneAsset', - '场景资产', - profile.camp.imageSrc || profile.camp.generatedSceneAssetId - ? '正式场景图已就绪' - : '待生成正式场景图', - ), - buildSection( - 'linkedObjects', - '关联对象', - [ - resolveLandmarkNames( - profile.landmarks.slice(0, 2).map((entry) => entry.id), - ), - resolveCharacterNames( - characters.slice(0, 2).map((entry) => entry.id), - ), - ] - .filter(Boolean) - .join(';'), - ), - ], - editableSectionIds: resolveEditableSectionIds('camp'), - warningMessages: campWarnings, - }); - } - - profile.threads.forEach((thread) => { - const warnings = buildThreadWarnings(thread); - pushCard({ - id: thread.id, - kind: 'thread', - title: thread.title, - subtitle: resolveThreadTypeLabel(thread.type), - summary: thread.summary, - linkedIds: [...thread.characterIds, ...thread.landmarkIds], - sections: [ - buildSection('title', '线程标题', thread.title), - buildSection('summary', '线程摘要', thread.summary), - buildSection( - 'conflictType', - '冲突类型', - thread.conflictType || resolveThreadTypeLabel(thread.type), - ), - buildSection('stakes', '冲突内容', thread.stakes || thread.conflict), - buildSection( - 'relatedObjects', - '相关对象', - [ - resolveCharacterNames(thread.characterIds), - resolveLandmarkNames(thread.landmarkIds), - ] - .filter(Boolean) - .join(';'), - ), - ], - editableSectionIds: resolveEditableSectionIds('thread'), - warningMessages: warnings, - }); - }); - - profile.factions.forEach((faction) => { - const warnings = buildFactionWarnings(faction); - const linkedThreadIds = profile.threads - .filter( - (thread) => - thread.conflict.includes(faction.name) || - thread.conflict.includes(faction.relatedConflict) || - thread.summary.includes(faction.name), - ) - .map((entry) => entry.id) - .slice(0, 3); - pushCard({ - id: faction.id, - kind: 'faction', - title: faction.title || faction.name, - subtitle: clampText(faction.subtitle || faction.publicGoal, 28), - summary: faction.summary, - linkedIds: linkedThreadIds, - sections: [ - buildSection('title', '势力标题', faction.title || faction.name), - buildSection( - 'subtitle', - '副标题', - faction.subtitle || clampText(faction.publicGoal, 40), - ), - buildSection('summary', '势力摘要', faction.summary), - buildSection('publicGoal', '公开目标', faction.publicGoal), - buildSection('tension', '当前张力', faction.tension || faction.relatedConflict), - buildSection('playerRelation', '玩家关系', faction.playerRelation), - ], - editableSectionIds: resolveEditableSectionIds('faction'), - warningMessages: warnings, - }); - }); - - characters.forEach((character) => { - const warnings = buildCharacterWarnings(character); - const assetHeadline = buildCharacterAssetHeadline(character); - const linkedLandmarks = profile.landmarks - .filter((landmark) => landmark.characterIds.includes(character.id)) - .map((entry) => entry.id) - .slice(0, 3); - pushCard({ - id: character.id, - kind: 'character', - title: character.name, - subtitle: [ - clampText(character.publicMask || character.publicIdentity, 18), - assetHeadline.label, - ] - .filter(Boolean) - .join(' / '), - summary: clampText(character.summary, 180), - linkedIds: [...character.threadIds, ...linkedLandmarks].slice(0, 6), - sections: [ - buildSection('name', '角色名', character.name), - buildSection('role', '角色定位', character.role || character.title), - buildSection( - 'publicMask', - '外显身份', - character.publicMask || character.publicIdentity, - ), - buildSection( - 'hiddenHook', - '隐藏钩子', - character.hiddenHook || character.currentPressure, - ), - buildSection('relationToPlayer', '玩家关系', character.relationToPlayer), - buildSection('summary', '角色摘要', character.summary), - buildSection( - 'threadIds', - '关联线程', - resolveThreadTitles(character.threadIds), - ), - ], - editableSectionIds: resolveEditableSectionIds('character'), - warningMessages: warnings, - assetStatus: assetHeadline.status, - assetStatusLabel: assetHeadline.label, - }); - }); - - profile.landmarks.forEach((landmark) => { - const warnings = buildLandmarkWarnings(landmark); - pushCard({ - id: landmark.id, - kind: 'landmark', - title: landmark.name, - subtitle: clampText( - [ - landmark.purpose || landmark.mood, - landmark.imageSrc && landmark.generatedSceneAssetId - ? '背景图已就绪' - : '待生成背景图', - ] - .filter(Boolean) - .join(' / '), - 28, - ), - summary: landmark.summary, - linkedIds: [...landmark.characterIds, ...landmark.threadIds].slice(0, 8), - sections: [ - buildSection('name', '地点名', landmark.name), - buildSection('purpose', '地点定位', landmark.purpose), - buildSection('mood', '场景情绪', landmark.mood), - buildSection('secret', '隐藏秘密', landmark.secret || landmark.importance), - buildSection('summary', '地点摘要', landmark.summary), - buildSection( - 'sceneAsset', - '场景资产', - landmark.imageSrc || landmark.generatedSceneAssetId - ? '正式场景图已就绪' - : '待生成正式场景图', - ), - buildSection( - 'characterIds', - '关联角色', - resolveCharacterNames(landmark.characterIds), - ), - buildSection( - 'threadIds', - '关联线程', - resolveThreadTitles(landmark.threadIds), - ), - ], - editableSectionIds: resolveEditableSectionIds('landmark'), - warningMessages: warnings, - }); - }); - - profile.chapters.forEach((chapter) => { - const warnings = buildChapterWarnings(chapter); - pushCard({ - id: chapter.id, - kind: 'chapter', - title: chapter.title, - subtitle: clampText(chapter.playerGoal, 28), - summary: chapter.summary, - linkedIds: [...chapter.characterIds, ...chapter.landmarkIds].slice(0, 10), - sections: [ - buildSection('title', '章节标题', chapter.title), - buildSection('summary', '章节摘要', chapter.summary), - buildSection('openingEvent', '开幕事件', chapter.openingEvent), - buildSection('playerGoal', '玩家目标', chapter.playerGoal), - buildSection( - 'characterIds', - '第一批角色', - resolveCharacterNames(chapter.characterIds), - ), - buildSection( - 'landmarkIds', - '第一批地点', - resolveLandmarkNames(chapter.landmarkIds), - ), - buildSection( - 'understandingShift', - '第一幕理解变化', - chapter.understandingShift, - ), - ], - editableSectionIds: resolveEditableSectionIds('chapter'), - warningMessages: warnings, - }); - }); - - profile.sceneChapters.forEach((sceneChapter) => { - const uniqueNpcIds = [...new Set(sceneChapter.acts.flatMap((act) => act.encounterNpcIds))]; - const readyBackgroundCount = sceneChapter.acts.filter( - (act) => Boolean(act.backgroundImageSrc || act.backgroundAssetId), - ).length; - const warnings = buildSceneChapterWarnings({ - sceneChapter, - characterById, - threadById, - landmarkById, - }); - - pushCard({ - id: sceneChapter.id, - kind: 'scene_chapter', - title: sceneChapter.title, - subtitle: clampText( - `${sceneChapter.sceneName} · ${sceneChapter.acts.length} 幕 · 背景 ${readyBackgroundCount}/${sceneChapter.acts.length}`, - 40, - ), - summary: sceneChapter.summary, - linkedIds: [ - ...sceneChapter.linkedLandmarkIds, - ...sceneChapter.linkedThreadIds, - ...uniqueNpcIds, - ].slice(0, 12), - sections: [ - buildSection('sceneName', '所属场景', sceneChapter.sceneName), - buildSection('title', '场景章节标题', sceneChapter.title), - buildSection('summary', '场景章节摘要', sceneChapter.summary), - buildSection( - 'actOverview', - '幕结构总览', - sceneChapter.acts - .map((act, index) => { - const primaryNpcName = - resolveCharacterNames([act.encounterNpcIds[0] || act.primaryNpcId]) || - '待补主角色'; - const supportNpcNames = - resolveCharacterNames(act.encounterNpcIds.slice(1)) || '当前没有辅助 NPC'; - return [ - `第 ${index + 1} 幕|${act.title}`, - `主角色:${primaryNpcName}`, - `辅助 NPC:${supportNpcNames}`, - `目标:${act.actGoal}`, - `过渡:${act.transitionHook}`, - ].join('\n'); - }) - .join('\n\n'), - ), - buildSection( - 'linkedLandmarkIds', - '关联地点', - resolveLandmarkNames(sceneChapter.linkedLandmarkIds), - ), - buildSection( - 'linkedThreadIds', - '关联线程', - resolveThreadTitles(sceneChapter.linkedThreadIds), - ), - ...sceneChapter.acts.flatMap((act, index) => { - const actLabel = `第 ${index + 1} 幕`; - const encounterNpcValue = - resolveCharacterNames(act.encounterNpcIds) || - act.encounterNpcIds.join('、'); - const primaryNpcValue = - resolveCharacterNames([act.encounterNpcIds[0] || act.primaryNpcId]) || - act.encounterNpcIds[0] || - act.primaryNpcId; - const actThreadTitles = - resolveThreadTitles( - act.linkedThreadIds.length > 0 - ? act.linkedThreadIds - : sceneChapter.linkedThreadIds, - ) || '待补线程挂钩'; - - return [ - buildSection(`act:${act.id}:title`, `${actLabel}标题`, act.title), - buildSection(`act:${act.id}:summary`, `${actLabel}摘要`, act.summary), - buildSection( - `act:${act.id}:backgroundImageSrc`, - `${actLabel}背景图`, - act.backgroundImageSrc || act.backgroundAssetId || '', - ), - buildSection( - `act:${act.id}:encounterNpcIds`, - `${actLabel}相遇 NPC`, - encounterNpcValue, - ), - buildSection( - `act:${act.id}:primaryNpcId`, - `${actLabel}主角色`, - primaryNpcValue, - ), - buildSection( - `act:${act.id}:stageCoverage`, - `${actLabel}阶段覆盖`, - resolveSceneActStageCoverageLabel(act.stageCoverage), - ), - buildSection(`act:${act.id}:actGoal`, `${actLabel}目标`, act.actGoal), - buildSection( - `act:${act.id}:transitionHook`, - `${actLabel}过渡钩子`, - act.transitionHook, - ), - buildSection( - `act:${act.id}:linkedThreadIds`, - `${actLabel}关联线程`, - actThreadTitles, - ), - buildSection( - `act:${act.id}:advanceRule`, - `${actLabel}推进规则`, - resolveSceneActAdvanceRuleLabel(act.advanceRule), - ), - ]; - }), - ], - editableSectionIds: resolveSceneChapterEditableSectionIds(sceneChapter), - warningMessages: warnings, - }); - }); - - return cards; - } -} - -export function getWorldFoundationCardId() { - return WORLD_CARD_ID; -} diff --git a/server-node/src/services/customWorldAgentDraftEditService.ts b/server-node/src/services/customWorldAgentDraftEditService.ts deleted file mode 100644 index c81c0aaa..00000000 --- a/server-node/src/services/customWorldAgentDraftEditService.ts +++ /dev/null @@ -1,453 +0,0 @@ -import { badRequest, notFound } from '../errors.js'; -import { - getWorldFoundationCardId, - normalizeFoundationDraftProfile, -} from './customWorldAgentDraftCompiler.js'; - -type DraftSectionPatch = { - sectionId: string; - value: string; -}; - -export type UpdateDraftCardSectionsParams = { - draftProfile: Record; - cardId: string; - sections: DraftSectionPatch[]; -}; - -const EDITABLE_SECTION_IDS = { - world: new Set(['title', 'subtitle', 'summary', 'playerGoal', 'tone', 'coreConflicts']), - faction: new Set(['title', 'subtitle', 'summary', 'publicGoal', 'tension']), - character: new Set(['name', 'role', 'publicMask', 'hiddenHook', 'relationToPlayer', 'summary']), - landmark: new Set(['name', 'purpose', 'mood', 'secret', 'summary']), - thread: new Set(['title', 'summary', 'conflictType', 'stakes']), - chapter: new Set(['title', 'summary', 'openingEvent', 'playerGoal', 'understandingShift']), - camp: new Set(['name', 'description', 'dangerLevel']), - sceneChapter: new Set(['title', 'summary']), -} as const; - -function normalizePatches(sections: DraftSectionPatch[]) { - const normalized = sections - .map((section) => ({ - sectionId: section.sectionId.trim(), - value: section.value.trim(), - })) - .filter((section) => section.sectionId); - - if (normalized.length === 0) { - throw badRequest('update_draft_card requires at least one section patch'); - } - - const deduped = new Map(); - normalized.forEach((section) => { - deduped.set(section.sectionId, section.value); - }); - - return [...deduped.entries()].map(([sectionId, value]) => ({ - sectionId, - value, - })); -} - -function parseStringList(value: string) { - return [...new Set(value.split(/[\n;;]+/u).map((item) => item.trim()).filter(Boolean))]; -} - -function parseReferenceList(value: string) { - return [ - ...new Set( - value - .split(/[\n,,、;;]+/u) - .map((item) => item.trim()) - .filter(Boolean), - ), - ]; -} - -function resolveThreadType(value: string) { - if (value.includes('暗') || value.toLowerCase() === 'hidden') { - return 'hidden' as const; - } - - return 'main' as const; -} - -function parseSceneActSectionId(sectionId: string) { - const match = sectionId.match( - /^act:([^:]+):(title|summary|backgroundImageSrc|encounterNpcIds|actGoal|transitionHook)$/u, - ); - if (!match) { - return null; - } - - return { - actId: match[1], - field: match[2] as - | 'title' - | 'summary' - | 'backgroundImageSrc' - | 'encounterNpcIds' - | 'actGoal' - | 'transitionHook', - }; -} - -function resolveCharacterIdByReference( - value: string, - draftProfile: NonNullable>, -) { - const characters = [...draftProfile.playableNpcs, ...draftProfile.storyNpcs]; - return ( - characters.find((entry) => entry.id === value)?.id || - characters.find((entry) => entry.name === value)?.id || - '' - ); -} - -function parseEncounterNpcIds( - value: string, - draftProfile: NonNullable>, -) { - const references = parseReferenceList(value); - if (references.length === 0) { - throw badRequest('scene act requires at least one encounter NPC'); - } - - const unresolvedReferences = references.filter( - (reference) => !resolveCharacterIdByReference(reference, draftProfile), - ); - if (unresolvedReferences.length > 0) { - throw badRequest( - `unknown scene act NPC reference: ${unresolvedReferences.join('、')}`, - ); - } - - return references.map((reference) => - resolveCharacterIdByReference(reference, draftProfile), - ); -} - -export function updateDraftCardSections(params: UpdateDraftCardSectionsParams) { - const draftProfile = normalizeFoundationDraftProfile(params.draftProfile); - if (!draftProfile) { - throw badRequest('draftProfile is empty'); - } - - const patches = normalizePatches(params.sections); - const worldCardId = getWorldFoundationCardId(); - - if (params.cardId === worldCardId) { - patches.forEach(({ sectionId, value }) => { - if (!EDITABLE_SECTION_IDS.world.has(sectionId as never)) { - throw badRequest(`section ${sectionId} is not editable for world`); - } - - if (sectionId === 'title') { - draftProfile.name = value; - return; - } - - if (sectionId === 'subtitle') { - draftProfile.subtitle = value; - return; - } - - if (sectionId === 'summary') { - draftProfile.summary = value; - return; - } - - if (sectionId === 'playerGoal') { - draftProfile.playerGoal = value; - return; - } - - if (sectionId === 'tone') { - draftProfile.tone = value; - return; - } - - if (sectionId === 'coreConflicts') { - draftProfile.coreConflicts = parseStringList(value); - } - }); - - return draftProfile as unknown as Record; - } - - const faction = draftProfile.factions.find((entry) => entry.id === params.cardId); - if (faction) { - patches.forEach(({ sectionId, value }) => { - if (!EDITABLE_SECTION_IDS.faction.has(sectionId as never)) { - throw badRequest(`section ${sectionId} is not editable for faction`); - } - - if (sectionId === 'title') { - faction.name = value; - faction.title = value; - return; - } - - if (sectionId === 'subtitle') { - faction.subtitle = value; - return; - } - - if (sectionId === 'summary') { - faction.summary = value; - return; - } - - if (sectionId === 'publicGoal') { - faction.publicGoal = value; - return; - } - - if (sectionId === 'tension') { - faction.tension = value; - faction.relatedConflict = value; - } - }); - - return draftProfile as unknown as Record; - } - - const character = [...draftProfile.playableNpcs, ...draftProfile.storyNpcs].find( - (entry) => entry.id === params.cardId, - ); - if (character) { - patches.forEach(({ sectionId, value }) => { - if (!EDITABLE_SECTION_IDS.character.has(sectionId as never)) { - throw badRequest(`section ${sectionId} is not editable for character`); - } - - if (sectionId === 'name') { - character.name = value; - return; - } - - if (sectionId === 'role') { - character.role = value; - character.title = value; - return; - } - - if (sectionId === 'publicMask') { - character.publicMask = value; - character.publicIdentity = value; - return; - } - - if (sectionId === 'hiddenHook') { - character.hiddenHook = value; - character.currentPressure = value; - return; - } - - if (sectionId === 'relationToPlayer') { - character.relationToPlayer = value; - return; - } - - if (sectionId === 'summary') { - character.summary = value; - } - }); - - return draftProfile as unknown as Record; - } - - const landmark = draftProfile.landmarks.find((entry) => entry.id === params.cardId); - if (landmark) { - patches.forEach(({ sectionId, value }) => { - if (!EDITABLE_SECTION_IDS.landmark.has(sectionId as never)) { - throw badRequest(`section ${sectionId} is not editable for landmark`); - } - - if (sectionId === 'name') { - landmark.name = value; - return; - } - - if (sectionId === 'purpose') { - landmark.purpose = value; - return; - } - - if (sectionId === 'mood') { - landmark.mood = value; - return; - } - - if (sectionId === 'secret') { - landmark.secret = value; - landmark.importance = value; - return; - } - - if (sectionId === 'summary') { - landmark.summary = value; - } - }); - - return draftProfile as unknown as Record; - } - - const thread = draftProfile.threads.find((entry) => entry.id === params.cardId); - if (thread) { - patches.forEach(({ sectionId, value }) => { - if (!EDITABLE_SECTION_IDS.thread.has(sectionId as never)) { - throw badRequest(`section ${sectionId} is not editable for thread`); - } - - if (sectionId === 'title') { - thread.title = value; - return; - } - - if (sectionId === 'summary') { - thread.summary = value; - return; - } - - if (sectionId === 'conflictType') { - thread.conflictType = value; - thread.type = resolveThreadType(value); - return; - } - - if (sectionId === 'stakes') { - thread.stakes = value; - thread.conflict = value; - } - }); - - return draftProfile as unknown as Record; - } - - const chapter = draftProfile.chapters.find((entry) => entry.id === params.cardId); - if (chapter) { - patches.forEach(({ sectionId, value }) => { - if (!EDITABLE_SECTION_IDS.chapter.has(sectionId as never)) { - throw badRequest(`section ${sectionId} is not editable for chapter`); - } - - if (sectionId === 'title') { - chapter.title = value; - return; - } - - if (sectionId === 'summary') { - chapter.summary = value; - return; - } - - if (sectionId === 'openingEvent') { - chapter.openingEvent = value; - return; - } - - if (sectionId === 'playerGoal') { - chapter.playerGoal = value; - return; - } - - if (sectionId === 'understandingShift') { - chapter.understandingShift = value; - } - }); - - return draftProfile as unknown as Record; - } - - const sceneChapter = draftProfile.sceneChapters.find( - (entry) => entry.id === params.cardId, - ); - if (sceneChapter) { - patches.forEach(({ sectionId, value }) => { - if (EDITABLE_SECTION_IDS.sceneChapter.has(sectionId as never)) { - if (sectionId === 'title') { - sceneChapter.title = value; - return; - } - - if (sectionId === 'summary') { - sceneChapter.summary = value; - } - return; - } - - const parsedSceneActSection = parseSceneActSectionId(sectionId); - if (!parsedSceneActSection) { - throw badRequest(`section ${sectionId} is not editable for scene_chapter`); - } - - const targetAct = sceneChapter.acts.find( - (entry) => entry.id === parsedSceneActSection.actId, - ); - if (!targetAct) { - throw notFound(`scene act ${parsedSceneActSection.actId} not found`); - } - - if (parsedSceneActSection.field === 'title') { - targetAct.title = value; - return; - } - - if (parsedSceneActSection.field === 'summary') { - targetAct.summary = value; - return; - } - - if (parsedSceneActSection.field === 'backgroundImageSrc') { - targetAct.backgroundImageSrc = value || null; - return; - } - - if (parsedSceneActSection.field === 'encounterNpcIds') { - const encounterNpcIds = parseEncounterNpcIds(value, draftProfile); - targetAct.encounterNpcIds = encounterNpcIds; - targetAct.primaryNpcId = encounterNpcIds[0] || ''; - return; - } - - if (parsedSceneActSection.field === 'actGoal') { - targetAct.actGoal = value; - return; - } - - if (parsedSceneActSection.field === 'transitionHook') { - targetAct.transitionHook = value; - } - }); - - return draftProfile as unknown as Record; - } - - if (draftProfile.camp?.id === params.cardId) { - patches.forEach(({ sectionId, value }) => { - if (!EDITABLE_SECTION_IDS.camp.has(sectionId as never)) { - throw badRequest(`section ${sectionId} is not editable for camp`); - } - - if (sectionId === 'name') { - draftProfile.camp!.name = value; - return; - } - - if (sectionId === 'description') { - draftProfile.camp!.description = value; - return; - } - - if (sectionId === 'dangerLevel') { - draftProfile.camp!.dangerLevel = value; - draftProfile.camp!.mood = value; - } - }); - - return draftProfile as unknown as Record; - } - - throw notFound('draft card not found'); -} diff --git a/server-node/src/services/customWorldAgentEntityGenerationService.ts b/server-node/src/services/customWorldAgentEntityGenerationService.ts deleted file mode 100644 index 358ed137..00000000 --- a/server-node/src/services/customWorldAgentEntityGenerationService.ts +++ /dev/null @@ -1,649 +0,0 @@ -import type { - CustomWorldFoundationDraftCharacter, - CustomWorldFoundationDraftLandmark, -} from '../../../packages/shared/src/contracts/customWorldAgent.js'; -import { badRequest } from '../errors.js'; -import { - buildCustomWorldAgentCharacterExpansionPrompt, - buildCustomWorldAgentLandmarkExpansionPrompt, - CUSTOM_WORLD_AGENT_CHARACTER_EXPANSION_SYSTEM_PROMPT, - CUSTOM_WORLD_AGENT_LANDMARK_EXPANSION_SYSTEM_PROMPT, -} from '../prompts/customWorldAgentPrompts.js'; -import { - getWorldFoundationCardId, - normalizeFoundationDraftProfile, -} from './customWorldAgentDraftCompiler.js'; -import type { UpstreamLlmClient } from './llmClient.js'; - -type GenerateEntitiesParams = { - creatorIntent: unknown; - anchorPack: unknown; - draftProfile: Record; - count: number; - promptText?: string | null; - anchorCardIds?: string[]; - llmClient?: UpstreamLlmClient | null; -}; - -const CHARACTER_SURNAME_POOL = [ - '沈', - '顾', - '裴', - '闻', - '纪', - '苏', - '岑', - '陆', - '白', - '商', - '温', - '严', - '黎', - '季', -] as const; - -const CHARACTER_GIVEN_POOL = [ - '砺', - '岚', - '澄', - '栖', - '弦', - '朔', - '遥', - '霁', - '衡', - '铃', - '潮', - '燧', - '宁', - '鸢', -] as const; - -const CHARACTER_ROLE_POOL = [ - '线人', - '调停者', - '巡查官', - '记录员', - '司钥人', - '护送者', -] as const; - -const LANDMARK_PREFIX_POOL = [ - '盐火', - '潮碑', - '雾湾', - '沉钟', - '旧航', - '灰塔', - '回潮', - '断潮', -] as const; - -const LANDMARK_SUFFIX_POOL = [ - '观测台', - '栈桥', - '档案楼', - '前哨站', - '藏书库', - '工坊', - '集市', - '驿站', -] as const; - -const DANGER_LEVEL_POOL = ['中', '中高', '高'] as const; - -type AnchorContext = { - anchorLabels: string[]; - threadIds: string[]; - characterIds: string[]; - landmarkIds: string[]; - factionNames: string[]; -}; - -function toText(value: unknown) { - return typeof value === 'string' ? value.trim() : ''; -} - -function toRecord(value: unknown) { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : null; -} - -function clampText(value: string, maxLength: number) { - const normalized = value.replace(/\s+/gu, ' ').trim(); - if (!normalized) { - return ''; - } - - if (normalized.length <= maxLength) { - return normalized; - } - - return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}…`; -} - -function slugify(value: string) { - const normalized = value - .trim() - .toLowerCase() - .replace(/[^a-z0-9\u4e00-\u9fa5]+/gu, '-') - .replace(/^-+|-+$/gu, ''); - - return normalized || 'entry'; -} - -function createStableId(prefix: string, label: string, index: number) { - return `${prefix}-${slugify(label || `${prefix}-${index + 1}`)}-${index + 1}`; -} - -function ensureCount(count: number) { - const normalized = Number.isFinite(count) ? Math.round(count) : 0; - if (normalized < 1 || normalized > 3) { - throw badRequest('count must be between 1 and 3'); - } - - return normalized; -} - -function getAllCharacters( - profile: NonNullable>, -) { - return [...profile.playableNpcs, ...profile.storyNpcs]; -} - -function dedupeStrings(values: string[]) { - return [...new Set(values.map((value) => value.trim()).filter(Boolean))]; -} - -function extractJsonPayload(content: string) { - const fencedMatch = content.match(/```(?:json)?\s*([\s\S]+?)\s*```/u); - if (fencedMatch?.[1]) { - return fencedMatch[1].trim(); - } - - const arrayStart = content.indexOf('['); - const arrayEnd = content.lastIndexOf(']'); - if (arrayStart >= 0 && arrayEnd > arrayStart) { - return content.slice(arrayStart, arrayEnd + 1); - } - - return content.trim(); -} - -function buildAnchorContext( - profile: NonNullable>, - anchorCardIds: string[], -): AnchorContext { - const worldCardId = getWorldFoundationCardId(); - const labels: string[] = []; - const threadIds: string[] = []; - const characterIds: string[] = []; - const landmarkIds: string[] = []; - const factionNames: string[] = []; - const characters = getAllCharacters(profile); - - anchorCardIds.forEach((cardId) => { - if (cardId === worldCardId) { - labels.push(profile.name); - if (profile.threads[0]) { - threadIds.push(profile.threads[0].id); - } - return; - } - - const faction = profile.factions.find((entry) => entry.id === cardId); - if (faction) { - labels.push(faction.title || faction.name); - factionNames.push(faction.title || faction.name); - profile.threads - .filter( - (thread) => - thread.summary.includes(faction.name) || - thread.conflict.includes(faction.name) || - thread.conflict.includes(faction.relatedConflict), - ) - .slice(0, 2) - .forEach((thread) => { - threadIds.push(thread.id); - }); - return; - } - - const character = characters.find((entry) => entry.id === cardId); - if (character) { - labels.push(character.name); - characterIds.push(character.id); - threadIds.push(...character.threadIds); - return; - } - - const landmark = profile.landmarks.find((entry) => entry.id === cardId); - if (landmark) { - labels.push(landmark.name); - landmarkIds.push(landmark.id); - characterIds.push(...landmark.characterIds); - threadIds.push(...landmark.threadIds); - return; - } - - const thread = profile.threads.find((entry) => entry.id === cardId); - if (thread) { - labels.push(thread.title); - threadIds.push(thread.id); - characterIds.push(...thread.characterIds); - landmarkIds.push(...thread.landmarkIds); - return; - } - - const chapter = profile.chapters.find((entry) => entry.id === cardId); - if (chapter) { - labels.push(chapter.title); - characterIds.push(...chapter.characterIds); - landmarkIds.push(...chapter.landmarkIds); - return; - } - - if (profile.camp?.id === cardId) { - labels.push(profile.camp.name); - landmarkIds.push(...profile.landmarks.slice(0, 2).map((entry) => entry.id)); - } - }); - - if (labels.length === 0) { - labels.push(profile.name); - } - if (threadIds.length === 0 && profile.threads[0]) { - threadIds.push(profile.threads[0].id); - } - if (characterIds.length === 0 && characters[0]) { - characterIds.push(characters[0].id); - } - - return { - anchorLabels: dedupeStrings(labels), - threadIds: dedupeStrings(threadIds).slice(0, 3), - characterIds: dedupeStrings(characterIds).slice(0, 4), - landmarkIds: dedupeStrings(landmarkIds).slice(0, 4), - factionNames: dedupeStrings(factionNames).slice(0, 3), - }; -} - -function buildUniqueCharacterName(existingNames: Set, startIndex: number) { - for (let attempt = 0; attempt < 120; attempt += 1) { - const index = startIndex + attempt; - const surname = - CHARACTER_SURNAME_POOL[index % CHARACTER_SURNAME_POOL.length]; - const firstName = - CHARACTER_GIVEN_POOL[ - Math.floor(index / CHARACTER_SURNAME_POOL.length) % - CHARACTER_GIVEN_POOL.length - ]; - const secondName = - CHARACTER_GIVEN_POOL[ - (index + 5) % CHARACTER_GIVEN_POOL.length - ]; - const candidate = `${surname}${firstName}${secondName}`; - - if (!existingNames.has(candidate)) { - existingNames.add(candidate); - return candidate; - } - } - - const fallback = `新角色${existingNames.size + 1}`; - existingNames.add(fallback); - return fallback; -} - -function buildUniqueLandmarkName(existingNames: Set, startIndex: number) { - for (let attempt = 0; attempt < 120; attempt += 1) { - const index = startIndex + attempt; - const candidate = `${LANDMARK_PREFIX_POOL[index % LANDMARK_PREFIX_POOL.length]}${ - LANDMARK_SUFFIX_POOL[ - Math.floor(index / LANDMARK_PREFIX_POOL.length) % - LANDMARK_SUFFIX_POOL.length - ] - }`; - - if (!existingNames.has(candidate)) { - existingNames.add(candidate); - return candidate; - } - } - - const fallback = `新地点${existingNames.size + 1}`; - existingNames.add(fallback); - return fallback; -} - -function buildPromptSeed(promptText?: string | null) { - return clampText(promptText || '', 28); -} - -function buildAnchorSummary(anchorContext: AnchorContext) { - return anchorContext.anchorLabels[0] || '当前底稿'; -} - -function buildCharacterFallback( - profile: NonNullable>, - anchorContext: AnchorContext, - promptSeed: string, - index: number, - existingNames: Set, -): CustomWorldFoundationDraftCharacter { - const name = buildUniqueCharacterName(existingNames, getAllCharacters(profile).length + index); - const role = CHARACTER_ROLE_POOL[ - (getAllCharacters(profile).length + index) % CHARACTER_ROLE_POOL.length - ]; - const anchorSummary = buildAnchorSummary(anchorContext); - const publicMask = clampText( - [ - `表面上以${role}身份靠近${anchorSummary}`, - promptSeed ? `对外总把话题往“${promptSeed}”上带` : '', - ] - .filter(Boolean) - .join(','), - 72, - ); - const hiddenHook = clampText( - [ - `暗中握着和${anchorSummary}有关的旧线索`, - anchorContext.factionNames[0] - ? `并持续替${anchorContext.factionNames[0]}观察局势变化` - : '一直在等一个足以翻盘的时机', - ].join(','), - 72, - ); - const relationToPlayer = clampText( - anchorContext.characterIds[0] - ? `会先借熟人网络试探玩家愿不愿意卷入${anchorSummary}。` - : `会先试探玩家是否愿意站到${anchorSummary}这一侧。`, - 72, - ); - const summary = clampText( - `${publicMask}。${hiddenHook}。${relationToPlayer}`, - 140, - ); - - return { - id: createStableId('character', name, getAllCharacters(profile).length + index), - name, - title: role, - role, - publicIdentity: publicMask, - publicMask, - currentPressure: hiddenHook, - hiddenHook, - relationToPlayer, - threadIds: anchorContext.threadIds.slice(0, 2), - summary, - }; -} - -function buildLandmarkFallback( - profile: NonNullable>, - anchorContext: AnchorContext, - promptSeed: string, - index: number, - existingNames: Set, -): CustomWorldFoundationDraftLandmark { - const name = buildUniqueLandmarkName(existingNames, profile.landmarks.length + index); - const anchorSummary = buildAnchorSummary(anchorContext); - const purpose = clampText( - promptSeed - ? `承接“${promptSeed}”这条补充要求的关键场景` - : `承接${anchorSummary}这条线的关键场景`, - 72, - ); - const mood = clampText( - buildPromptSeed(profile.tone) || '压迫、克制、带着未明感', - 28, - ); - const dangerLevel = - DANGER_LEVEL_POOL[(profile.landmarks.length + index) % DANGER_LEVEL_POOL.length]; - const secret = clampText( - anchorContext.characterIds[0] - ? `埋着与现有角色有关的旧痕和反转线索` - : `埋着足以改写${anchorSummary}解释权的旧线索`, - 72, - ); - const summary = clampText( - `${purpose},整体气质${mood}。${secret}`, - 140, - ); - - return { - id: createStableId('landmark', name, profile.landmarks.length + index), - name, - description: summary, - purpose, - mood, - importance: secret, - secret, - dangerLevel, - characterIds: anchorContext.characterIds.slice(0, 3), - threadIds: anchorContext.threadIds.slice(0, 2), - summary, - }; -} - -async function requestCharacterSuggestionsFromLlm(params: { - llmClient: UpstreamLlmClient; - profile: NonNullable>; - anchorContext: AnchorContext; - count: number; - promptSeed: string; - creatorIntent: unknown; - anchorPack: unknown; -}) { - const anchorSummary = buildAnchorSummary(params.anchorContext); - const creatorIntentSummary = - toText(toRecord(params.anchorPack)?.creatorIntentSummary) || - toText(toRecord(params.creatorIntent)?.worldHook) || - params.profile.summary; - - const content = await params.llmClient.requestMessageContent({ - systemPrompt: CUSTOM_WORLD_AGENT_CHARACTER_EXPANSION_SYSTEM_PROMPT, - userPrompt: buildCustomWorldAgentCharacterExpansionPrompt({ - worldName: params.profile.name, - worldSummary: params.profile.summary, - creatorIntentSummary, - anchorSummary, - existingNames: getAllCharacters(params.profile) - .slice(0, 10) - .map((entry) => entry.name), - count: params.count, - promptSeed: params.promptSeed, - }), - timeoutMs: 45000, - debugLabel: 'custom-world-agent-generate-characters', - }); - - const parsed = JSON.parse(extractJsonPayload(content)) as Array>; - return Array.isArray(parsed) ? parsed : []; -} - -async function requestLandmarkSuggestionsFromLlm(params: { - llmClient: UpstreamLlmClient; - profile: NonNullable>; - anchorContext: AnchorContext; - count: number; - promptSeed: string; - creatorIntent: unknown; - anchorPack: unknown; -}) { - const anchorSummary = buildAnchorSummary(params.anchorContext); - const creatorIntentSummary = - toText(toRecord(params.anchorPack)?.creatorIntentSummary) || - toText(toRecord(params.creatorIntent)?.worldHook) || - params.profile.summary; - - const content = await params.llmClient.requestMessageContent({ - systemPrompt: CUSTOM_WORLD_AGENT_LANDMARK_EXPANSION_SYSTEM_PROMPT, - userPrompt: buildCustomWorldAgentLandmarkExpansionPrompt({ - worldName: params.profile.name, - worldSummary: params.profile.summary, - creatorIntentSummary, - anchorSummary, - existingNames: params.profile.landmarks - .slice(0, 10) - .map((entry) => entry.name), - count: params.count, - promptSeed: params.promptSeed, - }), - timeoutMs: 45000, - debugLabel: 'custom-world-agent-generate-landmarks', - }); - - const parsed = JSON.parse(extractJsonPayload(content)) as Array>; - return Array.isArray(parsed) ? parsed : []; -} - -export class CustomWorldAgentEntityGenerationService { - constructor(private readonly llmClient: UpstreamLlmClient | null = null) {} - - async generateAdditionalCharacters(params: GenerateEntitiesParams) { - const draftProfile = normalizeFoundationDraftProfile(params.draftProfile); - if (!draftProfile) { - throw badRequest('draftProfile is empty'); - } - - const count = ensureCount(params.count); - const promptSeed = buildPromptSeed(params.promptText); - const anchorContext = buildAnchorContext(draftProfile, params.anchorCardIds ?? []); - const existingNames = new Set( - getAllCharacters(draftProfile).map((entry) => entry.name), - ); - - let llmDrafts: Array> = []; - if (this.llmClient) { - try { - llmDrafts = await requestCharacterSuggestionsFromLlm({ - llmClient: this.llmClient, - profile: draftProfile, - anchorContext, - count, - promptSeed, - creatorIntent: params.creatorIntent, - anchorPack: params.anchorPack, - }); - } catch { - llmDrafts = []; - } - } - - const generatedCharacters = Array.from({ length: count }, (_, index) => { - const fallback = buildCharacterFallback( - draftProfile, - anchorContext, - promptSeed, - index, - existingNames, - ); - const llmDraft = toRecord(llmDrafts[index]); - if (!llmDraft) { - return fallback; - } - - const name = toText(llmDraft.name) || fallback.name; - return { - ...fallback, - id: createStableId('character', name, getAllCharacters(draftProfile).length + index), - name, - title: toText(llmDraft.role) || fallback.title, - role: toText(llmDraft.role) || fallback.role, - publicIdentity: toText(llmDraft.publicMask) || fallback.publicIdentity, - publicMask: toText(llmDraft.publicMask) || fallback.publicMask, - currentPressure: toText(llmDraft.hiddenHook) || fallback.currentPressure, - hiddenHook: toText(llmDraft.hiddenHook) || fallback.hiddenHook, - relationToPlayer: - toText(llmDraft.relationToPlayer) || fallback.relationToPlayer, - threadIds: - Array.isArray(llmDraft.threadIds) && llmDraft.threadIds.length > 0 - ? dedupeStrings(llmDraft.threadIds.map((entry) => toText(entry))).slice(0, 2) - : fallback.threadIds, - summary: toText(llmDraft.summary) || fallback.summary, - } satisfies CustomWorldFoundationDraftCharacter; - }); - - draftProfile.storyNpcs = [...draftProfile.storyNpcs, ...generatedCharacters]; - - return { - draftProfile: draftProfile as unknown as Record, - generatedCharacters, - }; - } - - async generateAdditionalLandmarks(params: GenerateEntitiesParams) { - const draftProfile = normalizeFoundationDraftProfile(params.draftProfile); - if (!draftProfile) { - throw badRequest('draftProfile is empty'); - } - - const count = ensureCount(params.count); - const promptSeed = buildPromptSeed(params.promptText); - const anchorContext = buildAnchorContext(draftProfile, params.anchorCardIds ?? []); - const existingNames = new Set(draftProfile.landmarks.map((entry) => entry.name)); - - let llmDrafts: Array> = []; - if (this.llmClient) { - try { - llmDrafts = await requestLandmarkSuggestionsFromLlm({ - llmClient: this.llmClient, - profile: draftProfile, - anchorContext, - count, - promptSeed, - creatorIntent: params.creatorIntent, - anchorPack: params.anchorPack, - }); - } catch { - llmDrafts = []; - } - } - - const generatedLandmarks = Array.from({ length: count }, (_, index) => { - const fallback = buildLandmarkFallback( - draftProfile, - anchorContext, - promptSeed, - index, - existingNames, - ); - const llmDraft = toRecord(llmDrafts[index]); - if (!llmDraft) { - return fallback; - } - - const name = toText(llmDraft.name) || fallback.name; - return { - ...fallback, - id: createStableId('landmark', name, draftProfile.landmarks.length + index), - name, - description: toText(llmDraft.description) || toText(llmDraft.summary) || fallback.description, - purpose: toText(llmDraft.purpose) || fallback.purpose, - mood: toText(llmDraft.mood) || fallback.mood, - importance: toText(llmDraft.secret) || fallback.importance, - secret: toText(llmDraft.secret) || fallback.secret, - dangerLevel: toText(llmDraft.dangerLevel) || fallback.dangerLevel, - characterIds: - Array.isArray(llmDraft.characterIds) && llmDraft.characterIds.length > 0 - ? dedupeStrings(llmDraft.characterIds.map((entry) => toText(entry))).slice(0, 3) - : fallback.characterIds, - threadIds: - Array.isArray(llmDraft.threadIds) && llmDraft.threadIds.length > 0 - ? dedupeStrings(llmDraft.threadIds.map((entry) => toText(entry))).slice(0, 2) - : fallback.threadIds, - summary: toText(llmDraft.summary) || fallback.summary, - } satisfies CustomWorldFoundationDraftLandmark; - }); - - draftProfile.landmarks = [...draftProfile.landmarks, ...generatedLandmarks]; - - return { - draftProfile: draftProfile as unknown as Record, - generatedLandmarks, - }; - } -} diff --git a/server-node/src/services/customWorldAgentFoundationDraftService.test.ts b/server-node/src/services/customWorldAgentFoundationDraftService.test.ts deleted file mode 100644 index d566871d..00000000 --- a/server-node/src/services/customWorldAgentFoundationDraftService.test.ts +++ /dev/null @@ -1,407 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import type { UpstreamLlmClient } from './llmClient.js'; -import { CustomWorldAgentFoundationDraftService } from './customWorldAgentFoundationDraftService.js'; -import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js'; - -function createFoundationDraftLlmClient( - options: { - omitStoryOptionalVisualFields?: boolean; - } = {}, -): UpstreamLlmClient { - let roleOutlineBatch = 0; - let landmarkSeedBatch = 0; - let landmarkNetworkBatch = 0; - let playableNarrativeBatch = 0; - let playableDossierBatch = 0; - let storyNarrativeBatch = 0; - let storyDossierBatch = 0; - - return { - requestMessageContent: async (params) => { - const debugLabel = params.debugLabel ?? ''; - - if (debugLabel === 'agent-foundation-framework') { - return JSON.stringify({ - name: '潮雾列岛', - subtitle: '盐火灯塔与失控航路', - summary: '潮雾列岛正在被假航灯和沉船商盟重新切开。', - tone: '冷峻、潮湿、悬疑', - playerGoal: '先确认谁在操盘假航灯,再决定自己站在哪一边。', - templateWorldType: 'WUXIA', - majorFactions: ['守灯会', '沉船商盟'], - coreConflicts: ['守灯会与沉船商盟争夺旧航路解释权'], - camp: { - name: '雾湾前哨', - description: '玩家在盐火灯塔下方临时收束线索的地方。', - dangerLevel: 'medium', - }, - playableNpcs: [], - storyNpcs: [], - landmarks: [], - }); - } - - if (debugLabel.startsWith('agent-foundation-playable-outline-batch-')) { - roleOutlineBatch += 1; - return JSON.stringify({ - playableNpcs: [ - { - name: '返灯人', - title: '失职守灯人', - role: '玩家前线身份', - description: '被迫返乡的守灯人,刚站回快要熄灭的旧灯塔。', - visualDescription: '风衣上沾着盐霜,眼底始终没有睡意。', - actionDescription: '先守住灯塔,再判断该不该相信旧友。', - sceneVisualDescription: '每次钟声响起时,他都像被过去拽住。', - initialAffinity: 18, - relationshipHooks: ['这是玩家贴近世界的第一切口'], - tags: ['玩家视角', '守灯'], - }, - ], - }); - } - - if (debugLabel.startsWith('agent-foundation-story-outline-batch-')) { - roleOutlineBatch += 1; - return JSON.stringify({ - storyNpcs: [ - { - name: '沈砺', - title: '旧友兼宿敌', - role: '沉船商盟引路人', - description: '他像旧友,也像最早知道假航灯秘密的人。', - ...(options.omitStoryOptionalVisualFields - ? {} - : { - visualDescription: - '衣角总带着潮水味,像是刚从夜雾里走出来。', - actionDescription: - '会不断试探玩家到底愿不愿意回到旧航路。', - sceneVisualDescription: '总在钟声停下后的空隙里现身。', - }), - initialAffinity: 6, - relationshipHooks: ['和玩家共享一段无法翻篇的旧灯塔往事'], - tags: ['旧友', '宿敌'], - }, - { - name: '岚珀', - title: '守灯会巡夜官', - role: '守灯会前台接口人', - description: '她负责把守灯会的怀疑与命令直接压到玩家面前。', - ...(options.omitStoryOptionalVisualFields - ? {} - : { - visualDescription: '披着潮湿斗篷,眼神总先看灯芯再看人。', - actionDescription: '要求玩家立刻证明自己还配站回灯塔。', - sceneVisualDescription: - '总把巡夜灯举得很高,不给人躲闪空间。', - }), - initialAffinity: 6, - relationshipHooks: ['会逼玩家更早站队'], - tags: ['守灯会', '巡夜'], - }, - ], - }); - } - - if (debugLabel.startsWith('agent-foundation-landmark-seed-batch-')) { - landmarkSeedBatch += 1; - return JSON.stringify({ - landmarks: [ - { - name: '盐火灯塔', - description: '旧灯塔正在熄灭边缘摇晃,所有人都盯着它的火。', - visualDescription: '塔身被盐霜和旧火痕反复覆盖。', - dangerLevel: 'high', - sceneNpcNames: ['沈砺', '岚珀'], - connections: [], - }, - { - name: '沉船码头', - description: '假航灯把沉船和黑市都引到了这片雾港。', - visualDescription: '破碎船骨和黑帆在潮雾里若隐若现。', - dangerLevel: 'high', - sceneNpcNames: ['沈砺'], - connections: [], - }, - ], - }); - } - - if (debugLabel.startsWith('agent-foundation-landmark-network-batch-')) { - landmarkNetworkBatch += 1; - return JSON.stringify({ - landmarks: [ - { - name: '盐火灯塔', - description: '旧灯塔正在熄灭边缘摇晃,所有人都盯着它的火。', - visualDescription: '塔身被盐霜和旧火痕反复覆盖。', - dangerLevel: 'high', - sceneNpcNames: ['沈砺', '岚珀'], - connections: [ - { - targetLandmarkName: '沉船码头', - relativePosition: 'forward', - summary: '顺着残灯下的潮道走,就会被拖进沉船码头。', - }, - ], - }, - { - name: '沉船码头', - description: '假航灯把沉船和黑市都引到了这片雾港。', - visualDescription: '破碎船骨和黑帆在潮雾里若隐若现。', - dangerLevel: 'high', - sceneNpcNames: ['沈砺'], - connections: [ - { - targetLandmarkName: '盐火灯塔', - relativePosition: 'back', - summary: '码头所有线头最终都会重新指回灯塔。', - }, - ], - }, - ], - }); - } - - if (debugLabel.startsWith('agent-foundation-playable-narrative-batch-')) { - playableNarrativeBatch += 1; - return JSON.stringify({ - playableNpcs: [ - { - name: '返灯人', - title: '失职守灯人', - role: '玩家前线身份', - description: '被迫返乡的守灯人,刚站回快要熄灭的旧灯塔。', - visualDescription: '风衣上沾着盐霜,眼底始终没有睡意。', - actionDescription: '先守住灯塔,再判断该不该相信旧友。', - sceneVisualDescription: '每次钟声响起时,他都像被过去拽住。', - relationshipHooks: ['这是玩家贴近世界的第一切口'], - tags: ['玩家视角', '守灯'], - }, - ], - }); - } - - if (debugLabel.startsWith('agent-foundation-playable-dossier-batch-')) { - playableDossierBatch += 1; - return JSON.stringify({ - playableNpcs: [ - { - name: '返灯人', - title: '失职守灯人', - role: '玩家前线身份', - description: '被迫返乡的守灯人,刚站回快要熄灭的旧灯塔。', - visualDescription: '风衣上沾着盐霜,眼底始终没有睡意。', - actionDescription: '先守住灯塔,再判断该不该相信旧友。', - sceneVisualDescription: '每次钟声响起时,他都像被过去拽住。', - relationshipHooks: ['这是玩家贴近世界的第一切口'], - tags: ['玩家视角', '守灯'], - }, - ], - }); - } - - if (debugLabel.startsWith('agent-foundation-story-narrative-batch-')) { - storyNarrativeBatch += 1; - return JSON.stringify({ - storyNpcs: [ - { - name: '沈砺', - title: '旧友兼宿敌', - role: '沉船商盟引路人', - description: '他像旧友,也像最早知道假航灯秘密的人。', - ...(options.omitStoryOptionalVisualFields - ? {} - : { - visualDescription: - '衣角总带着潮水味,像是刚从夜雾里走出来。', - actionDescription: - '会不断试探玩家到底愿不愿意回到旧航路。', - sceneVisualDescription: '总在钟声停下后的空隙里现身。', - }), - relationshipHooks: ['和玩家共享一段无法翻篇的旧灯塔往事'], - tags: ['旧友', '宿敌'], - }, - { - name: '岚珀', - title: '守灯会巡夜官', - role: '守灯会前台接口人', - description: '她负责把守灯会的怀疑与命令直接压到玩家面前。', - ...(options.omitStoryOptionalVisualFields - ? {} - : { - visualDescription: '披着潮湿斗篷,眼神总先看灯芯再看人。', - actionDescription: '要求玩家立刻证明自己还配站回灯塔。', - sceneVisualDescription: - '总把巡夜灯举得很高,不给人躲闪空间。', - }), - relationshipHooks: ['会逼玩家更早站队'], - tags: ['守灯会', '巡夜'], - }, - ], - }); - } - - if (debugLabel.startsWith('agent-foundation-story-dossier-batch-')) { - storyDossierBatch += 1; - return JSON.stringify({ - storyNpcs: [ - { - name: '沈砺', - title: '旧友兼宿敌', - role: '沉船商盟引路人', - description: '他像旧友,也像最早知道假航灯秘密的人。', - ...(options.omitStoryOptionalVisualFields - ? {} - : { - visualDescription: - '衣角总带着潮水味,像是刚从夜雾里走出来。', - actionDescription: - '会不断试探玩家到底愿不愿意回到旧航路。', - sceneVisualDescription: '总在钟声停下后的空隙里现身。', - }), - relationshipHooks: ['和玩家共享一段无法翻篇的旧灯塔往事'], - tags: ['旧友', '宿敌'], - }, - { - name: '岚珀', - title: '守灯会巡夜官', - role: '守灯会前台接口人', - description: '她负责把守灯会的怀疑与命令直接压到玩家面前。', - ...(options.omitStoryOptionalVisualFields - ? {} - : { - visualDescription: '披着潮湿斗篷,眼神总先看灯芯再看人。', - actionDescription: '要求玩家立刻证明自己还配站回灯塔。', - sceneVisualDescription: - '总把巡夜灯举得很高,不给人躲闪空间。', - }), - relationshipHooks: ['会逼玩家更早站队'], - tags: ['守灯会', '巡夜'], - }, - ], - }); - } - - throw new Error(`未覆盖的测试 debugLabel: ${debugLabel}`); - }, - streamMessageContent: async () => { - throw new Error('这个测试不应该走流式接口'); - }, - } as UpstreamLlmClient; -} - -test('foundation draft service builds draft fields directly from framework instead of reusing preview compiler output', async () => { - const service = new CustomWorldAgentFoundationDraftService( - createFoundationDraftLlmClient(), - ); - - const draft = await service.generate({ - creatorIntent: { - sourceMode: 'freeform', - rawSettingText: '被海雾反复切开的列岛世界。', - worldHook: '旧灯塔、假航灯与失控航路重新把列岛撕开。', - themeKeywords: ['海岛', '悬疑'], - toneDirectives: ['冷峻', '潮湿'], - playerPremise: '玩家是被迫返乡的失职守灯人', - openingSituation: '开局时正站在即将熄灭的旧灯塔上', - coreConflicts: ['守灯会与沉船商盟争夺旧航路解释权'], - keyFactions: [], - keyCharacters: [], - keyLandmarks: [], - iconicElements: ['潮雾钟声', '盐火灯塔'], - forbiddenDirectives: [], - }, - anchorPack: { - creatorIntentSummary: '潮雾、旧灯塔、假航灯和被迫返乡的守灯人。', - }, - }); - - const normalized = normalizeFoundationDraftProfile(draft); - const legacyResultProfile = (draft as Record) - .legacyResultProfile as Record | undefined; - const legacyStoryNpcs = Array.isArray(legacyResultProfile?.storyNpcs) - ? (legacyResultProfile?.storyNpcs as Array>) - : []; - - assert.ok(normalized); - assert.equal(normalized?.name, '潮雾列岛'); - assert.equal( - normalized?.summary, - '潮雾列岛正在被假航灯和沉船商盟重新切开。', - ); - assert.equal(normalized?.playableNpcs.length, 1); - assert.equal(normalized?.storyNpcs.length, 2); - assert.equal(normalized?.storyNpcs[0]?.name, '沈砺'); - assert.match( - normalized?.storyNpcs[0]?.summary ?? '', - /旧友|假航灯|灯塔/u, - ); - assert.equal( - normalized?.storyNpcs[0]?.publicMask, - '衣角总带着潮水味,像是刚从夜雾里走出来。', - ); - assert.equal(normalized?.landmarks.length, 2); - assert.equal(normalized?.landmarks[0]?.name, '盐火灯塔'); - assert.equal(normalized?.sceneChapters.length, 2); - assert.equal(legacyResultProfile?.name, '潮雾列岛'); - assert.equal( - legacyResultProfile?.scenarioPackId, - 'scenario-pack:潮雾列岛', - ); - assert.equal( - legacyResultProfile?.campaignPackId, - 'campaign-pack:潮雾列岛', - ); - assert.equal(legacyStoryNpcs[0]?.name, '沈砺'); - assert.equal(legacyStoryNpcs[0]?.backstory, undefined); -}); - -test('foundation draft service tolerates missing optional scene role visual fields', async () => { - const service = new CustomWorldAgentFoundationDraftService( - createFoundationDraftLlmClient({ - omitStoryOptionalVisualFields: true, - }), - ); - - const draft = await service.generate({ - creatorIntent: { - sourceMode: 'freeform', - rawSettingText: '被海雾反复切开的列岛世界。', - worldHook: '旧灯塔、假航灯与失控航路重新把列岛撕开。', - themeKeywords: ['海岛', '悬疑'], - toneDirectives: ['冷峻', '潮湿'], - playerPremise: '玩家是被迫返乡的失职守灯人', - openingSituation: '开局时正站在即将熄灭的旧灯塔上', - coreConflicts: ['守灯会与沉船商盟争夺旧航路解释权'], - keyFactions: [], - keyCharacters: [], - keyLandmarks: [], - iconicElements: ['潮雾钟声', '盐火灯塔'], - forbiddenDirectives: [], - }, - anchorPack: { - creatorIntentSummary: '潮雾、旧灯塔、假航灯和被迫返乡的守灯人。', - }, - }); - - const normalized = normalizeFoundationDraftProfile(draft); - const legacyResultProfile = (draft as Record) - .legacyResultProfile as Record | undefined; - const legacyStoryNpcs = Array.isArray(legacyResultProfile?.storyNpcs) - ? (legacyResultProfile?.storyNpcs as Array>) - : []; - - assert.ok(normalized); - assert.equal(normalized?.storyNpcs.length, 2); - assert.equal(normalized?.storyNpcs[0]?.name, '沈砺'); - assert.equal( - normalized?.storyNpcs[0]?.publicMask, - '他像旧友,也像最早知道假航灯秘密的人。', - ); - assert.ok((normalized?.storyNpcs[0]?.currentPressure ?? '').trim()); - assert.equal(legacyStoryNpcs[0]?.visualDescription, undefined); -}); diff --git a/server-node/src/services/customWorldAgentFoundationDraftService.ts b/server-node/src/services/customWorldAgentFoundationDraftService.ts deleted file mode 100644 index fbd79f88..00000000 --- a/server-node/src/services/customWorldAgentFoundationDraftService.ts +++ /dev/null @@ -1,2182 +0,0 @@ -import type { - CustomWorldFoundationDraftCamp, - CustomWorldFoundationDraftCharacter, - CustomWorldFoundationDraftFaction, - CustomWorldFoundationDraftLandmark, - CustomWorldFoundationDraftProfile, - CustomWorldFoundationDraftSceneChapter, - CustomWorldFoundationDraftThread, - EightAnchorContent, -} from '../../../packages/shared/src/contracts/customWorldAgent.js'; -import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js'; -import { - FOUNDATION_JSON_ONLY_SYSTEM_PROMPT, - FOUNDATION_JSON_REPAIR_SYSTEM_PROMPT, -} from '../prompts/customWorldAgentPrompts.js'; -import { - buildCustomWorldFrameworkJsonRepairPrompt, - buildCustomWorldFrameworkPrompt, - buildCustomWorldLandmarkNetworkBatchJsonRepairPrompt, - buildCustomWorldLandmarkNetworkBatchPrompt, - buildCustomWorldLandmarkSeedBatchJsonRepairPrompt, - buildCustomWorldLandmarkSeedBatchPrompt, - buildCustomWorldRoleBatchJsonRepairPrompt, - buildCustomWorldRoleBatchPrompt, - buildCustomWorldRoleOutlineBatchJsonRepairPrompt, - buildCustomWorldRoleOutlineBatchPrompt, -} from '../prompts/customWorldPrompts.js'; -import { - buildCustomWorldRawProfileFromFramework, - type CustomWorldGenerationFramework, - type CustomWorldGenerationLandmarkOutline, - type CustomWorldGenerationRoleBatchStage, - type CustomWorldGenerationRoleBatchType, - type CustomWorldGenerationRoleOutline, - normalizeCustomWorldGenerationFramework, - normalizeCustomWorldGenerationLandmarkOutlineBatch, - normalizeCustomWorldGenerationRoleOutlineBatch, -} from '../modules/custom-world/runtime-profile/index.js'; -import { - buildDraftSummaryFromIntent, - type CreatorCharacterSeedRecord, - type CustomWorldCreatorIntentRecord, - normalizeCreatorIntentRecord, -} from './customWorldAgentIntentExtractionService.js'; -import { - buildCreatorIntentFromEightAnchorContent, - buildDraftSummaryFromEightAnchorContent, - buildDraftTitleFromEightAnchorContent, - buildEightAnchorFoundationText, - normalizeEightAnchorContent, -} from './eightAnchorCompatibilityService.js'; -import type { UpstreamLlmClient } from './llmClient.js'; - -function toText(value: unknown) { - return typeof value === 'string' ? value.trim() : ''; -} - -function toRecord(value: unknown) { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : null; -} - -function clampText(value: unknown, maxLength: number) { - const normalized = toText(value).replace(/\s+/gu, ' ').trim(); - if (!normalized) { - return ''; - } - - if (normalized.length <= maxLength) { - return normalized; - } - - return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}…`; -} - -function slugify(value: string) { - const normalized = value - .trim() - .toLowerCase() - .replace(/[^a-z0-9\u4e00-\u9fa5]+/gu, '-') - .replace(/^-+|-+$/gu, ''); - - return normalized || 'entry'; -} - -function createId(prefix: string, label: string, index: number) { - return `${prefix}-${slugify(label || `${prefix}-${index + 1}`)}-${index + 1}`; -} - -function dedupeStrings(items: string[], maxCount = 8) { - return [...new Set(items.map((item) => item.trim()).filter(Boolean))].slice( - 0, - maxCount, - ); -} - -function sanitizeEntityName(value: string) { - return value - .replace(/^(一个|一种|一名|一位|被迫|正在|眼下|此刻|这个|这座|这片)/u, '') - .replace(/[。!?;,,]/gu, '') - .trim(); -} - -function buildCompactLabel(text: string, fallback: string, maxLength = 14) { - const normalized = sanitizeEntityName(text) - .replace(/^(玩家是|主角是|玩家身份是|故事开场时|故事开场|开局时|开局)/u, '') - .trim(); - - return clampText(normalized || fallback, maxLength) || fallback; -} - -function extractConflictSides(conflict: string) { - const relationMatch = conflict.match( - /([A-Za-z0-9\u4e00-\u9fa5·-]{2,12}?)(?:与|和|及)([A-Za-z0-9\u4e00-\u9fa5·-]{2,12}?)(?:争夺|对抗|角力|围绕|拉扯|较量|冲突)/u, - ); - if (relationMatch?.[1] && relationMatch?.[2]) { - return [relationMatch[1].trim(), relationMatch[2].trim()]; - } - - return [ - ...conflict.matchAll( - /([A-Za-z0-9\u4e00-\u9fa5·-]{2,12}(?:会|盟|门|宗|阁|府|庭|院|司|营|局|军|团|殿|邦|教|社|帮|署))/gu, - ), - ] - .map((entry) => entry[1]?.trim() || '') - .filter(Boolean) - .slice(0, 3); -} - -function extractConflictTarget(conflict: string) { - const matched = conflict.match( - /(?:争夺|抢夺|围绕|对抗|角力|争取)([^,。;]{2,20})/u, - ); - return clampText(toText(matched?.[1]), 18); -} - -function extractPlaceLikePhrase(text: string) { - const patterns = [ - /在([^,。;]{2,18}?(?:塔|港|湾|岛|城|宫|桥|门|站|镇|村|院|殿|阁|馆|楼|海|岸|街|巷|林|谷|原|坑|窟|池|湖|河))(?:上|里|中|内|前|旁|边)?/u, - /正站在([^,。;]{2,18}?(?:塔|港|湾|岛|城|宫|桥|门|站|镇|村|院|殿|阁|馆|楼|海|岸|街|巷|林|谷|原|坑|窟|池|湖|河))(?:上|里|中|内)?/u, - ]; - - for (const pattern of patterns) { - const matched = text.match(pattern); - const candidate = sanitizeEntityName(toText(matched?.[1])); - if (candidate) { - return clampText(candidate, 16); - } - } - - return ''; -} - -function looksLikePlaceName(value: string) { - return /(塔|港|湾|岛|城|宫|桥|门|站|镇|村|院|殿|阁|馆|楼|海|岸|街|巷|林|谷|原|坑|窟|池|湖|河|道|渡口|码头)/u.test( - value, - ); -} - -function convertElementToLandmarkName(element: string) { - const normalized = sanitizeEntityName(element); - if (!normalized) { - return ''; - } - - if (looksLikePlaceName(normalized)) { - return clampText(normalized, 16); - } - - if (normalized.endsWith('钟声')) { - return clampText(normalized.replace(/钟声$/u, '钟塔'), 16); - } - if (normalized.endsWith('盟约') || normalized.endsWith('残片')) { - return clampText(`${normalized}档库`, 16); - } - if (normalized.endsWith('火')) { - return clampText(`${normalized}哨点`, 16); - } - - return clampText(`${normalized}回响区`, 16); -} - -function buildWorldName(intent: CustomWorldCreatorIntentRecord) { - const worldHook = sanitizeEntityName( - intent.worldHook || intent.rawSettingText, - ); - const namedMatch = worldHook.match( - /([A-Za-z0-9\u4e00-\u9fa5·-]{2,16}(?:列岛|群岛|王朝|帝国|海域|边境|疆域|之城|之境|之域|城邦|废都|王庭|海岸|高地))/u, - ); - - return ( - clampText( - namedMatch?.[1] || worldHook || intent.iconicElements[0] || '', - 18, - ) || '未命名世界底稿' - ); -} - -function buildTone(intent: CustomWorldCreatorIntentRecord) { - return ( - dedupeStrings( - [ - ...intent.themeKeywords, - ...intent.toneDirectives, - ...intent.iconicElements, - ], - 8, - ).join('、') || '紧绷、未明、带着继续展开的空间' - ); -} - -function buildPlayerGoal(params: { - playerPremise: string; - openingSituation: string; - coreConflict: string; -}) { - const conflictTarget = extractConflictTarget(params.coreConflict); - const location = extractPlaceLikePhrase(params.openingSituation); - const lead = location - ? `先在${location}站稳` - : params.openingSituation - ? `先扛过“${buildCompactLabel(params.openingSituation, '开局风暴', 12)}”` - : '先稳住眼前的局势'; - const tail = conflictTarget - ? `,再查清谁在主导“${conflictTarget}”` - : params.coreConflict - ? `,再判断自己在“${buildCompactLabel(params.coreConflict, '核心冲突', 12)}”里的站位` - : ''; - - return clampText(`${lead}${tail}`, 60); -} - -function buildFactions(params: { - intent: CustomWorldCreatorIntentRecord; - coreConflicts: string[]; - playerPremise: string; - iconicElements: string[]; -}): CustomWorldFoundationDraftFaction[] { - const explicitFactions = params.intent.keyFactions.map((entry) => ({ - name: sanitizeEntityName(entry.name), - publicGoal: clampText(entry.publicGoal, 28), - relatedConflict: - clampText(entry.tension, 48) || params.coreConflicts[0] || '局势正在升温', - playerRelation: '玩家很难绕开它的影响', - })); - const conflictSideNames = params.coreConflicts.flatMap((entry) => - extractConflictSides(entry), - ); - const fallbackPrefixes = dedupeStrings( - [ - ...params.iconicElements.map((entry) => buildCompactLabel(entry, '', 6)), - buildCompactLabel(params.intent.worldHook, '', 6), - ], - 4, - ).filter(Boolean); - const fallbackNames = [ - fallbackPrefixes[0] ? `${fallbackPrefixes[0]}守望会` : '', - fallbackPrefixes[1] ? `${fallbackPrefixes[1]}商盟` : '', - '旧约议庭', - '灰区中间人', - ].filter(Boolean); - - const names = dedupeStrings( - [ - ...explicitFactions.map((entry) => entry.name), - ...conflictSideNames, - ...fallbackNames, - ], - 4, - ).slice(0, 3); - - return names.map((name, index) => { - const explicit = explicitFactions.find((entry) => entry.name === name); - const relatedConflict = - explicit?.relatedConflict || - params.coreConflicts.find((entry) => entry.includes(name)) || - params.coreConflicts[index % Math.max(1, params.coreConflicts.length)] || - '局势仍在快速失衡'; - const conflictTarget = extractConflictTarget(relatedConflict); - const publicGoal = - explicit?.publicGoal || - clampText( - conflictTarget - ? `拿下${conflictTarget}的主动解释权` - : '在变局里先一步拿到主动权', - 28, - ); - const playerRelation = - explicit?.playerRelation || - clampText( - index === 0 - ? '它会把玩家当成必须争取的关键变量' - : index === 1 - ? '它迟早会逼玩家在立场上做选择' - : '它可能提供入口,也可能直接加码风险', - 36, - ); - - return { - id: createId('faction', name, index), - name, - publicGoal, - relatedConflict, - playerRelation, - summary: clampText( - `${name}正在围绕“${buildCompactLabel(relatedConflict, '核心冲突', 16)}”抢先手,公开目标是${publicGoal},并且${playerRelation}。`, - 140, - ), - }; - }); -} - -function buildBaseThreads(params: { - intent: CustomWorldCreatorIntentRecord; - coreConflicts: string[]; - playerPremise: string; - openingSituation: string; - iconicElements: string[]; -}): CustomWorldFoundationDraftThread[] { - const firstConflict = - params.coreConflicts[0] || '旧秩序与新力量正在拉扯世界走向'; - const hiddenSeed = - params.intent.keyCharacters.find((entry) => entry.hiddenHook.trim()) - ?.hiddenHook || - params.iconicElements[0] || - '表面冲突背后还有更深的一层'; - const relationshipSeed = - params.intent.keyCharacters.find((entry) => entry.relationToPlayer.trim()) - ?.relationToPlayer || - params.playerPremise || - params.openingSituation; - const extraSeed = params.coreConflicts[1] || params.iconicElements[1] || ''; - - const seeds = [ - { - title: buildCompactLabel(firstConflict, '主线推进', 16), - type: 'main' as const, - conflict: firstConflict, - summary: clampText( - `明线围绕“${firstConflict}”推进,玩家一入局就会被迫参与。`, - 90, - ), - }, - { - title: buildCompactLabel(hiddenSeed, '暗线回潮', 16), - type: 'hidden' as const, - conflict: hiddenSeed, - summary: clampText( - `暗线真正牵动的不是表面立场,而是“${buildCompactLabel(hiddenSeed, '暗线真相', 18)}”。`, - 90, - ), - }, - { - title: buildCompactLabel(relationshipSeed, '关系裂口', 16), - type: 'main' as const, - conflict: relationshipSeed, - summary: clampText( - `玩家身边的关系与身份会决定这条线最先从哪里裂开。`, - 90, - ), - }, - ...(extraSeed - ? [ - { - title: buildCompactLabel(extraSeed, '余波扩散', 16), - type: 'hidden' as const, - conflict: extraSeed, - summary: clampText(`这条线负责把世界里更深的余波慢慢带出来。`, 90), - }, - ] - : []), - ]; - - return seeds.slice(0, 4).map((entry, index) => ({ - id: createId('thread', entry.title, index), - title: entry.title, - type: entry.type, - conflict: clampText(entry.conflict, 72), - characterIds: [], - landmarkIds: [], - summary: entry.summary, - })); -} - -function buildPlayerProxyCharacter( - intent: CustomWorldCreatorIntentRecord, - threads: CustomWorldFoundationDraftThread[], - coreConflict: string, -): CustomWorldFoundationDraftCharacter | null { - const playerPremise = sanitizeEntityName(intent.playerPremise); - if (!playerPremise) { - return null; - } - - const mainThreadId = threads[0]?.id ?? null; - const relationThreadId = threads[2]?.id ?? threads[1]?.id ?? null; - const name = buildCompactLabel(playerPremise, '玩家前线身份', 10); - - return { - id: createId('character', name, 0), - name, - title: '玩家前线身份', - role: playerPremise, - publicIdentity: playerPremise, - currentPressure: - clampText(intent.openingSituation || coreConflict, 48) || - '必须先扛过眼前的局势压力', - relationToPlayer: '这是玩家当前最贴近世界的切入口', - threadIds: [mainThreadId, relationThreadId].filter( - (entry): entry is string => Boolean(entry), - ), - summary: clampText( - `${playerPremise}被直接推到台前,眼下压力是“${buildCompactLabel(intent.openingSituation || coreConflict, '开局压力', 18)}”。`, - 120, - ), - }; -} - -function buildCharacterFromSeed(params: { - seed: CreatorCharacterSeedRecord; - index: number; - threads: CustomWorldFoundationDraftThread[]; - coreConflict: string; -}): CustomWorldFoundationDraftCharacter { - const hiddenThreadId = params.threads.find( - (entry) => entry.type === 'hidden', - )?.id; - const mainThreadId = params.threads[0]?.id ?? null; - const relationThreadId = params.threads[2]?.id ?? hiddenThreadId ?? null; - - return { - id: - params.seed.id || - createId('character', params.seed.name || params.seed.role, params.index), - name: - sanitizeEntityName(params.seed.name) || - buildCompactLabel( - params.seed.role || params.seed.relationToPlayer, - '关键角色', - 10, - ), - title: clampText(params.seed.role || '关键人物', 18) || '关键人物', - role: clampText(params.seed.role || '关键人物', 28) || '关键人物', - publicIdentity: - clampText( - params.seed.publicMask || params.seed.role || '站在当前局势前台的人', - 36, - ) || '站在当前局势前台的人', - currentPressure: - clampText(params.seed.hiddenHook || params.coreConflict, 48) || - '正在被当前局势不断加压', - relationToPlayer: - clampText( - params.seed.relationToPlayer || '会直接改变玩家的第一步选择', - 36, - ) || '会直接改变玩家的第一步选择', - threadIds: dedupeStrings( - [ - params.seed.hiddenHook ? (hiddenThreadId ?? '') : '', - params.seed.relationToPlayer ? (relationThreadId ?? '') : '', - mainThreadId ?? '', - ], - 3, - ), - summary: clampText( - `${params.seed.publicMask || params.seed.role || '表面上像是立场前台的人'};当前压力是${params.seed.hiddenHook || '必须在明暗两条线上同时做选择'};与玩家关系是${params.seed.relationToPlayer || '会直接左右玩家的站位'}`, - 130, - ), - }; -} - -function buildGeneratedCharacters(params: { - existingNames: string[]; - factions: CustomWorldFoundationDraftFaction[]; - threads: CustomWorldFoundationDraftThread[]; - iconicElements: string[]; - coreConflict: string; -}): CustomWorldFoundationDraftCharacter[] { - const suffixes = ['联络人', '记录官', '引路人', '修补匠', '代言人']; - const generated: CustomWorldFoundationDraftCharacter[] = []; - const mainThreadId = params.threads[0]?.id ?? null; - const hiddenThreadId = params.threads.find( - (entry) => entry.type === 'hidden', - )?.id; - const relationThreadId = params.threads[2]?.id ?? mainThreadId; - - params.factions.forEach((faction, index) => { - const prefix = - buildCompactLabel( - faction.name.replace(/(会|盟|庭|局|司|府|团|营|帮)$/u, ''), - '关键', - 6, - ) || buildCompactLabel(params.iconicElements[index] || '', '关键', 6); - const name = `${prefix}${suffixes[index % suffixes.length]}`; - if (params.existingNames.includes(name)) { - return; - } - - generated.push({ - id: createId('character', name, generated.length + 1), - name, - title: '关键阵营接口人', - role: `${faction.name}在前台推动局势的人`, - publicIdentity: `${faction.name}的前台接口人`, - currentPressure: faction.relatedConflict || params.coreConflict, - relationToPlayer: - index === 0 - ? '会主动把玩家拉进局势中心' - : '对玩家既有利用价值也有试探意图', - threadIds: dedupeStrings( - [ - mainThreadId ?? '', - index % 2 === 0 ? (relationThreadId ?? '') : (hiddenThreadId ?? ''), - ], - 3, - ), - summary: clampText( - `${name}代表${faction.name}在前台出手,眼下压力直指“${buildCompactLabel(faction.relatedConflict || params.coreConflict, '局势升级', 18)}”,同时会主动试探玩家的站位。`, - 130, - ), - }); - }); - - return generated; -} - -function buildCharacters(params: { - intent: CustomWorldCreatorIntentRecord; - factions: CustomWorldFoundationDraftFaction[]; - threads: CustomWorldFoundationDraftThread[]; - coreConflicts: string[]; - iconicElements: string[]; -}) { - const firstConflict = - params.coreConflicts[0] || '旧秩序与新力量正在拉扯世界走向'; - const characters: CustomWorldFoundationDraftCharacter[] = []; - const playerProxy = buildPlayerProxyCharacter( - params.intent, - params.threads, - firstConflict, - ); - - if (playerProxy) { - characters.push(playerProxy); - } - - params.intent.keyCharacters.forEach((seed, index) => { - characters.push( - buildCharacterFromSeed({ - seed, - index: index + 1, - threads: params.threads, - coreConflict: firstConflict, - }), - ); - }); - - const generated = buildGeneratedCharacters({ - existingNames: characters.map((entry) => entry.name), - factions: params.factions, - threads: params.threads, - iconicElements: params.iconicElements, - coreConflict: firstConflict, - }); - - generated.forEach((entry) => { - if (characters.some((item) => item.name === entry.name)) { - return; - } - - characters.push(entry); - }); - - return dedupeStrings( - characters.map((entry) => entry.name), - FOUNDATION_DRAFT_PLAYABLE_COUNT + FOUNDATION_DRAFT_STORY_COUNT, - ).map((name) => characters.find((entry) => entry.name === name)!); -} - -function splitDraftCharacters(params: { - characters: CustomWorldFoundationDraftCharacter[]; - playableCount: number; - storyCount: number; -}) { - const playableNpcs = params.characters.slice(0, params.playableCount); - const storyNpcs = params.characters - .slice(params.playableCount, params.playableCount + params.storyCount); - - return { - playableNpcs, - storyNpcs, - }; -} - -function buildCamp(params: { - openingSituation: string; - worldHook: string; - iconicElements: string[]; -}): CustomWorldFoundationDraftCamp { - const openingPlace = extractPlaceLikePhrase(params.openingSituation); - const prefix = - openingPlace || - buildCompactLabel(params.iconicElements[0] || params.worldHook, '归返', 6); - const name = looksLikePlaceName(prefix) ? `${prefix}守望舍` : `${prefix}前哨`; - - return { - id: 'camp-home', - name: clampText(name, 16), - description: clampText( - openingPlace - ? `贴着${openingPlace}搭起来的临时落脚处,玩家还能在这里喘口气和整理线索。` - : '玩家暂时还能整顿情报、换口气并决定下一步站位的落脚处。', - 72, - ), - mood: '克制、紧绷,但还有一点能重新收住局势的余地', - summary: clampText( - `${clampText(name, 12)}不是安全区,而是玩家在风暴边缘还能勉强站稳的一块地方。`, - 88, - ), - }; -} - -function buildLandmarks(params: { - intent: CustomWorldCreatorIntentRecord; - camp: CustomWorldFoundationDraftCamp; - factions: CustomWorldFoundationDraftFaction[]; - characters: CustomWorldFoundationDraftCharacter[]; - threads: CustomWorldFoundationDraftThread[]; - coreConflicts: string[]; - iconicElements: string[]; - openingSituation: string; -}): CustomWorldFoundationDraftLandmark[] { - const explicit = params.intent.keyLandmarks.map((entry) => ({ - name: clampText(sanitizeEntityName(entry.name), 16), - purpose: clampText(entry.purpose, 24) || '承接关键剧情推进', - mood: clampText(entry.mood, 24) || '带着明显的情绪指向', - importance: - clampText(entry.secret, 36) || '和当前主线冲突直接勾连的关键地点', - })); - const openingPlace = extractPlaceLikePhrase(params.openingSituation); - const conflictTarget = extractConflictTarget(params.coreConflicts[0] || ''); - const derivedNames = dedupeStrings( - [ - ...explicit.map((entry) => entry.name), - openingPlace, - ...params.iconicElements.map((entry) => - convertElementToLandmarkName(entry), - ), - conflictTarget - ? looksLikePlaceName(conflictTarget) - ? conflictTarget - : `${conflictTarget}争议带` - : '', - `${buildCompactLabel(params.factions[0]?.name || params.camp.name, '前线', 8)}前场`, - '旧档案库', - '灰雾渡口', - ], - 6, - ).slice(0, 5); - - return derivedNames.map((name, index) => { - const explicitEntry = explicit.find((entry) => entry.name === name); - const threadIds = dedupeStrings( - [ - params.threads[index % Math.max(1, params.threads.length)]?.id ?? '', - params.threads[(index + 1) % Math.max(1, params.threads.length)]?.id ?? - '', - ], - 3, - ); - const characterIds = dedupeStrings( - [ - params.characters[index % Math.max(1, params.characters.length)]?.id ?? - '', - params.characters[(index + 1) % Math.max(1, params.characters.length)] - ?.id ?? '', - ], - 3, - ); - - return { - id: createId('landmark', name, index), - name, - purpose: - explicitEntry?.purpose || - clampText( - index === 0 - ? '玩家最先被推到局势前台的位置' - : index === 1 - ? '不同立场开始交锋和试探的地方' - : '把世界气质、冲突和人物同时挂住的关键地标', - 28, - ), - mood: - explicitEntry?.mood || - clampText( - index === 0 - ? '第一眼就能感到风暴逼近' - : index === 1 - ? '压迫里带着可探索的缝隙' - : '既有吸引力,也有明显风险感', - 24, - ), - importance: - explicitEntry?.importance || - clampText( - `${name}和“${buildCompactLabel(params.coreConflicts[0] || params.threads[0]?.title || '主线推进', '主线', 16)}”直接勾连,玩家第一次抵达时就会意识到它不只是背景。`, - 60, - ), - characterIds, - threadIds, - summary: clampText( - `${name}承担${explicitEntry?.purpose || '主线推进'},会把${characterIds.length > 0 ? '关键人物' : '局势压力'}直接挂到玩家面前。`, - 120, - ), - }; - }); -} - -function finalizeThreads(params: { - threads: CustomWorldFoundationDraftThread[]; - characters: CustomWorldFoundationDraftCharacter[]; - landmarks: CustomWorldFoundationDraftLandmark[]; -}) { - return params.threads.map((thread) => { - const characterIds = params.characters - .filter((entry) => entry.threadIds.includes(thread.id)) - .map((entry) => entry.id) - .slice(0, 4); - const landmarkIds = params.landmarks - .filter((entry) => entry.threadIds.includes(thread.id)) - .map((entry) => entry.id) - .slice(0, 4); - - return { - ...thread, - characterIds, - landmarkIds, - summary: clampText( - `${thread.type === 'hidden' ? '暗线' : '明线'}围绕“${buildCompactLabel(thread.conflict, thread.title, 18)}”推进,相关对象包括${ - [ - characterIds.length > 0 ? `${characterIds.length} 名关键角色` : '', - landmarkIds.length > 0 ? `${landmarkIds.length} 个关键地点` : '', - ] - .filter(Boolean) - .join('、') || '当前第一批底稿对象' - }。`, - 120, - ), - }; - }); -} - -function buildChapter(params: { - worldName: string; - openingSituation: string; - playerGoal: string; - characters: CustomWorldFoundationDraftCharacter[]; - landmarks: CustomWorldFoundationDraftLandmark[]; - threads: CustomWorldFoundationDraftThread[]; -}) { - const openingEvent = - clampText(params.openingSituation, 60) || - `玩家被迫卷入“${buildCompactLabel(params.threads[0]?.conflict || '', '主线冲突', 18)}”。`; - const characterIds = params.characters.slice(0, 3).map((entry) => entry.id); - const landmarkIds = params.landmarks.slice(0, 3).map((entry) => entry.id); - const hiddenThread = params.threads.find((entry) => entry.type === 'hidden'); - - return { - id: 'chapter-first-act', - title: clampText( - `第一幕:${buildCompactLabel(params.worldName, '世界开幕', 12)}`, - 18, - ), - openingEvent, - playerGoal: params.playerGoal, - characterIds, - landmarkIds, - understandingShift: clampText( - hiddenThread - ? `第一幕结束时,玩家会意识到“${buildCompactLabel(hiddenThread.conflict, hiddenThread.title, 18)}”并不是背景噪音,而是会反过来改写主线走向。` - : '第一幕结束时,玩家会意识到这场冲突远不止表面那一层。', - 72, - ), - summary: clampText( - `${openingEvent} 玩家第一步要做的不是立刻解决一切,而是先在${params.landmarks[0]?.name || '关键地点'}站稳,并看清${params.characters[0]?.name || '关键角色'}等人分别在推什么。`, - 140, - ), - }; -} - -const FOUNDATION_DRAFT_PLAYABLE_COUNT = 1; -const FOUNDATION_DRAFT_STORY_COUNT = 8; -const FOUNDATION_DRAFT_LANDMARK_COUNT = 2; -const FOUNDATION_ROLE_OUTLINE_BATCH_SIZE = 2; -const FOUNDATION_LANDMARK_BATCH_SIZE = 2; -const FOUNDATION_ROLE_DETAIL_BATCH_SIZE = 2; -const FOUNDATION_LLM_TIMEOUT_MS = 90000; - -type DraftProgressPayload = { - phaseLabel: string; - phaseDetail: string; - progress: number; -}; - -type DraftProgressCallback = ( - payload: DraftProgressPayload, -) => void | Promise; - -type MergeableNamedRecord = { - name: string; -}; - -function buildFallbackSceneActStageCoverage(index: number, actCount: number) { - if (actCount <= 2) { - return index === 0 - ? (['opening', 'expansion'] as const) - : (['turning_point', 'climax', 'aftermath'] as const); - } - - if (actCount === 3) { - return index === 0 - ? (['opening'] as const) - : index === 1 - ? (['expansion', 'turning_point'] as const) - : (['climax', 'aftermath'] as const); - } - - if (actCount === 4) { - return index === 0 - ? (['opening'] as const) - : index === 1 - ? (['expansion'] as const) - : index === 2 - ? (['turning_point'] as const) - : (['climax', 'aftermath'] as const); - } - - return ( - [ - ['opening'], - ['expansion'], - ['turning_point'], - ['climax'], - ['aftermath'], - ][index] ?? ['aftermath'] - ) as readonly string[]; -} - -function buildSceneChaptersFromDraft(params: { - landmarks: CustomWorldFoundationDraftLandmark[]; - playableNpcs: CustomWorldFoundationDraftCharacter[]; - storyNpcs: CustomWorldFoundationDraftCharacter[]; - threads: CustomWorldFoundationDraftThread[]; -}): CustomWorldFoundationDraftSceneChapter[] { - const leadPlayable = params.playableNpcs[0] ?? null; - const sceneRoles = params.storyNpcs; - - return params.landmarks.slice(0, FOUNDATION_DRAFT_LANDMARK_COUNT).map((landmark, index) => { - const linkedThreadIds = - landmark.threadIds.length > 0 - ? landmark.threadIds.slice(0, 3) - : params.threads - .filter((thread) => thread.landmarkIds.includes(landmark.id)) - .map((thread) => thread.id) - .slice(0, 3); - const baseNpcIds = landmark.characterIds.length > 0 - ? landmark.characterIds - : sceneRoles.slice(index * 3, index * 3 + 3).map((role) => role.id); - const uniqueNpcIds = [...new Set(baseNpcIds)].filter(Boolean); - const primaryIds = uniqueNpcIds.slice(0, 3); - const fallbackPrimaryIds = sceneRoles - .filter((role) => !primaryIds.includes(role.id)) - .slice(0, 3 - primaryIds.length) - .map((role) => role.id); - const actPrimaryIds = [...primaryIds, ...fallbackPrimaryIds].slice(0, 3); - const supportPool = [ - ...uniqueNpcIds, - ...sceneRoles.map((role) => role.id), - ...(leadPlayable ? [leadPlayable.id] : []), - ].filter(Boolean); - - const acts = actPrimaryIds.map((primaryNpcId, actIndex) => { - const supportIds = supportPool.filter((roleId) => roleId !== primaryNpcId); - const orderedEncounterNpcIds = [ - primaryNpcId, - ...supportIds.slice(0, 2), - ]; - const primaryRole = - sceneRoles.find((role) => role.id === primaryNpcId) ?? leadPlayable; - const supportRoles = orderedEncounterNpcIds - .slice(1) - .map((roleId) => - sceneRoles.find((role) => role.id === roleId) ?? - (leadPlayable?.id === roleId ? leadPlayable : null), - ) - .filter((role): role is CustomWorldFoundationDraftCharacter => Boolean(role)); - - return { - id: `${landmark.id}-act-${actIndex + 1}`, - title: - actIndex === 0 - ? `${landmark.name}起势` - : actIndex === 1 - ? `${landmark.name}承压` - : `${landmark.name}收束`, - summary: clampText( - [ - actIndex === 0 - ? `这一幕先由${primaryRole?.name || '主角色'}把玩家带进${landmark.name}的当前压力。` - : actIndex === 1 - ? `${primaryRole?.name || '主角色'}会把${landmark.name}的冲突真正抬上台面。` - : `${primaryRole?.name || '主角色'}会负责把这一章收束并抛出下一跳。`, - landmark.summary, - ].join(' '), - 120, - ), - stageCoverage: buildFallbackSceneActStageCoverage(actIndex, 3), - backgroundImageSrc: null, - backgroundAssetId: null, - encounterNpcIds: orderedEncounterNpcIds, - primaryNpcId, - linkedThreadIds, - actGoal: - actIndex === 0 - ? `让玩家先接住${landmark.name}的入口压力` - : actIndex === 1 - ? `把${landmark.name}的冲突推到不可回避` - : `把${landmark.name}这一章收住并抛向下一跳`, - transitionHook: - actIndex === 0 - ? `${supportRoles[0]?.name || '另一名角色'}会在这一幕后继续加压。` - : actIndex === 1 - ? `这一幕结束后,${primaryRole?.name || '主角色'}会逼玩家接住最终选择。` - : '这一幕结束后要把下一步去向和关系压力一起抛给玩家。', - advanceRule: - actIndex === 0 - ? 'after_primary_contact' - : actIndex === 2 - ? 'after_chapter_resolution' - : 'after_active_step_complete', - }; - }); - - return { - id: `scene-chapter-${landmark.id}`, - sceneId: landmark.id, - sceneName: landmark.name, - title: `${landmark.name}章节`, - summary: clampText( - `${landmark.name}会按三幕推进:先起势、再承压、最后收束。`, - 120, - ), - linkedThreadIds, - linkedLandmarkIds: [landmark.id], - acts, - } satisfies CustomWorldFoundationDraftSceneChapter; - }); -} - -function buildDraftFactionsFromFramework(params: { - majorFactions: string[]; - coreConflicts: string[]; - fallbackSummary: string; -}): CustomWorldFoundationDraftFaction[] { - const names = dedupeStrings(params.majorFactions, 4); - const fallbackConflict = - params.coreConflicts[0] || params.fallbackSummary || '局势仍在持续升温'; - - return names.map((name, index) => { - const relatedConflict = - params.coreConflicts[index % Math.max(1, params.coreConflicts.length)] || - fallbackConflict; - const conflictTarget = extractConflictTarget(relatedConflict); - - return { - id: createId('faction', name, index), - name, - title: name, - publicGoal: clampText( - conflictTarget - ? `拿下${conflictTarget}的主动解释权` - : '在失衡局势里先一步抢到主动权', - 28, - ), - relatedConflict, - tension: clampText(relatedConflict, 48), - playerRelation: clampText( - index === 0 - ? '它会先一步影响玩家的开局站位' - : '玩家迟早要和它发生正面碰撞', - 32, - ), - summary: clampText( - `${name}围绕“${buildCompactLabel(relatedConflict, '核心冲突', 16)}”持续施压,也会直接改变玩家的开局判断。`, - 120, - ), - } satisfies CustomWorldFoundationDraftFaction; - }); -} - -function buildDraftCharactersFromGenerationRoles(params: { - roles: CustomWorldGenerationRoleOutline[]; - roleKind: 'playable' | 'story'; - threads: CustomWorldFoundationDraftThread[]; - maxCount: number; - fallbackPressure: string; -}): CustomWorldFoundationDraftCharacter[] { - const threadCount = Math.max(1, params.threads.length); - - return params.roles.slice(0, params.maxCount).map((role, index) => { - const primaryThreadId = params.threads[index % threadCount]?.id ?? ''; - const secondaryThreadId = - params.threads[ - (index + (params.roleKind === 'playable' ? 2 : 1)) % threadCount - ]?.id ?? ''; - const fallbackRelation = - params.roleKind === 'playable' - ? '这是玩家当前最贴近世界的切入口' - : '会直接改变玩家的下一步选择'; - const publicIdentity = - clampText(role.description, 36) || '站在当前局势前台的人'; - const currentPressure = - clampText( - role.actionDescription || - role.sceneVisualDescription || - params.fallbackPressure, - 48, - ) || '正在被当前局势不断加压'; - const publicMask = clampText(role.visualDescription, 36) || undefined; - const hiddenHook = - clampText(role.sceneVisualDescription || role.tags[0] || '', 48) || - undefined; - const relationToPlayer = - clampText(role.relationshipHooks[0] || fallbackRelation, 36) || - fallbackRelation; - - return { - id: createId( - 'character', - `${params.roleKind}-${role.name || role.role || index + 1}`, - index, - ), - name: - clampText(role.name, 16) || - buildCompactLabel(role.role || role.title, '关键角色', 10), - title: clampText(role.title || role.role, 18) || '关键角色', - role: clampText(role.role || role.title, 28) || '关键角色', - publicIdentity, - publicMask, - currentPressure, - hiddenHook, - relationToPlayer, - threadIds: dedupeStrings([primaryThreadId, secondaryThreadId], 3), - summary: clampText( - [ - publicIdentity, - currentPressure ? `眼下压力是${currentPressure}` : '', - relationToPlayer ? `与玩家的关系是${relationToPlayer}` : '', - ] - .filter(Boolean) - .join(';'), - 130, - ), - } satisfies CustomWorldFoundationDraftCharacter; - }); -} - -function buildDraftLandmarksFromFramework(params: { - landmarks: CustomWorldGenerationLandmarkOutline[]; - threads: CustomWorldFoundationDraftThread[]; - storyNpcs: CustomWorldFoundationDraftCharacter[]; - maxCount: number; - fallbackConflict: string; -}): CustomWorldFoundationDraftLandmark[] { - const threadCount = Math.max(1, params.threads.length); - const storyNpcIdByName = new Map( - params.storyNpcs.map((role) => [role.name.trim(), role.id] as const), - ); - - return params.landmarks.slice(0, params.maxCount).map((landmark, index) => { - const threadIds = dedupeStrings( - [ - params.threads[index % threadCount]?.id ?? '', - params.threads[(index + 1) % threadCount]?.id ?? '', - ], - 3, - ); - const characterIds = dedupeStrings( - landmark.sceneNpcNames.map( - (name) => storyNpcIdByName.get(name.trim()) ?? '', - ), - 4, - ); - - return { - id: createId('landmark', landmark.name, index), - name: clampText(landmark.name, 16) || `关键地点${index + 1}`, - description: - clampText(landmark.visualDescription || landmark.description, 48) || - undefined, - purpose: clampText(landmark.description, 28) || '承接关键剧情推进', - mood: - clampText(landmark.visualDescription || landmark.dangerLevel, 24) || - '带着明显风险的关键地点', - importance: clampText( - `${landmark.name}和“${buildCompactLabel(params.fallbackConflict, '主线冲突', 16)}”直接勾连,第一次抵达时就会意识到它不只是背景。`, - 60, - ), - secret: - clampText(landmark.connections[0]?.summary || '', 36) || undefined, - dangerLevel: clampText(landmark.dangerLevel, 24) || undefined, - characterIds, - threadIds, - summary: clampText( - landmark.description || - landmark.visualDescription || - `${landmark.name}会把当前局势的压力直接抬到台前。`, - 120, - ), - } satisfies CustomWorldFoundationDraftLandmark; - }); -} - -/** - * 工作包 G 的最小收口实现: - * foundation draft 主字段直接由 framework / role detail / landmark detail 组装, - * 不再通过 preview compiler 先转成 legacy runtime profile 再反解回 draft。 - */ -function buildFoundationDraftProfileFromFramework(params: { - framework: CustomWorldGenerationFramework; - playableDetailed: CustomWorldGenerationRoleOutline[]; - storyDetailed: CustomWorldGenerationRoleOutline[]; - creatorIntent: CustomWorldCreatorIntentRecord; - anchorPack: unknown; - anchorContent?: EightAnchorContent | null; - settingText: string; -}) { - const normalizedAnchorContent = normalizeEightAnchorContent( - params.anchorContent, - ); - const coreConflicts = - dedupeStrings(params.framework.coreConflicts, 4).length > 0 - ? dedupeStrings(params.framework.coreConflicts, 4) - : dedupeStrings(params.creatorIntent.coreConflicts, 4).length > 0 - ? dedupeStrings(params.creatorIntent.coreConflicts, 4) - : [params.framework.summary || '旧秩序与新力量正在争夺这个世界的解释权']; - const iconicElements = dedupeStrings(params.creatorIntent.iconicElements, 6); - const playerPremise = - clampText(params.creatorIntent.playerPremise, 72) || - '玩家是一名被卷进局势中心的行动者'; - const openingSituation = - clampText(params.creatorIntent.openingSituation, 72) || - '故事开局时,玩家已经站在必须立刻选边的位置上'; - const worldHook = - clampText( - params.creatorIntent.worldHook || - params.creatorIntent.rawSettingText || - params.framework.summary, - 72, - ) || '一个仍在失衡边缘不断扩张的世界'; - const fallbackFactions = buildFactions({ - intent: params.creatorIntent, - coreConflicts, - playerPremise, - iconicElements, - }); - const factions = - dedupeStrings(params.framework.majorFactions, 4).length > 0 - ? buildDraftFactionsFromFramework({ - majorFactions: params.framework.majorFactions, - coreConflicts, - fallbackSummary: params.framework.summary, - }) - : fallbackFactions; - const baseThreads = buildBaseThreads({ - intent: params.creatorIntent, - coreConflicts, - playerPremise, - openingSituation, - iconicElements, - }); - const playableNpcs = buildDraftCharactersFromGenerationRoles({ - roles: params.playableDetailed, - roleKind: 'playable', - threads: baseThreads, - maxCount: FOUNDATION_DRAFT_PLAYABLE_COUNT, - fallbackPressure: coreConflicts[0] || params.framework.summary, - }); - const storyNpcs = buildDraftCharactersFromGenerationRoles({ - roles: params.storyDetailed, - roleKind: 'story', - threads: baseThreads, - maxCount: FOUNDATION_DRAFT_STORY_COUNT, - fallbackPressure: coreConflicts[0] || params.framework.summary, - }); - const landmarks = buildDraftLandmarksFromFramework({ - landmarks: params.framework.landmarks, - threads: baseThreads, - storyNpcs, - maxCount: FOUNDATION_DRAFT_LANDMARK_COUNT, - fallbackConflict: coreConflicts[0] || params.framework.summary, - }); - const threads = finalizeThreads({ - threads: baseThreads.slice(0, 4), - characters: [...playableNpcs, ...storyNpcs], - landmarks, - }); - const chapter = buildChapter({ - worldName: params.framework.name, - openingSituation, - playerGoal: params.framework.playerGoal, - characters: [...playableNpcs, ...storyNpcs], - landmarks, - threads, - }); - const sceneChapters = buildSceneChaptersFromDraft({ - landmarks, - playableNpcs, - storyNpcs, - threads, - }); - - const legacyFramework: CustomWorldGenerationFramework = { - ...params.framework, - playableNpcs: params.playableDetailed, - storyNpcs: params.storyDetailed, - }; - const legacyResultProfile = buildCustomWorldRawProfileFromFramework( - legacyFramework, - ) as Record; - legacyResultProfile.id = - legacyResultProfile.id ?? - `agent-draft-${slugify(params.framework.name || 'world')}`; - legacyResultProfile.settingText = params.settingText; - legacyResultProfile.sceneChapterBlueprints = sceneChapters; - legacyResultProfile.generationMode = 'fast'; - legacyResultProfile.generationStatus = 'key_only'; - legacyResultProfile.scenarioPackId = - legacyResultProfile.scenarioPackId ?? - `scenario-pack:${slugify(params.framework.name || 'world')}`; - legacyResultProfile.campaignPackId = - legacyResultProfile.campaignPackId ?? - `campaign-pack:${slugify(params.framework.name || 'world')}`; - legacyResultProfile.creatorIntent = - legacyResultProfile.creatorIntent ?? - (params.creatorIntent as unknown as Record); - legacyResultProfile.anchorPack = - legacyResultProfile.anchorPack ?? - (toRecord(params.anchorPack) ?? - ({ value: params.anchorPack } as Record)); - if (normalizedAnchorContent) { - legacyResultProfile.anchorContent = - legacyResultProfile.anchorContent ?? - (normalizedAnchorContent as unknown as Record); - } - - return { - name: clampText(params.framework.name, 40) || '未命名世界底稿', - subtitle: - clampText(params.framework.subtitle, 40) || - clampText( - [ - buildCompactLabel(playerPremise, '玩家视角', 12), - buildCompactLabel(coreConflicts[0] || '', '核心冲突', 16), - ] - .filter(Boolean) - .join(' · '), - 40, - ) || - '第一版世界底稿', - summary: - clampText(params.framework.summary, 180) || - clampText( - `${worldHook} 玩家会以“${playerPremise}”切入这个世界,眼下最直接的冲突是“${coreConflicts[0]}”。`, - 180, - ) || - '第一版世界底稿已经整理完成。', - tone: - clampText(params.framework.tone, 72) || - buildTone(params.creatorIntent), - playerGoal: - clampText(params.framework.playerGoal, 72) || - buildPlayerGoal({ - playerPremise, - openingSituation, - coreConflict: coreConflicts[0] || '', - }), - majorFactions: - dedupeStrings(params.framework.majorFactions, 6).length > 0 - ? dedupeStrings(params.framework.majorFactions, 6) - : factions.map((entry) => entry.name), - coreConflicts, - playableNpcs, - storyNpcs, - landmarks, - camp: { - id: 'camp-home', - name: clampText(params.framework.camp.name, 16) || '开局据点', - description: - clampText(params.framework.camp.description, 72) || - '玩家暂时还能整顿情报、换口气并决定下一步站位的落脚处。', - mood: - clampText(params.framework.tone, 36) || '紧绷但还可暂时收住局势', - dangerLevel: - clampText(params.framework.camp.dangerLevel, 24) || undefined, - summary: clampText( - params.framework.camp.description || - `${params.framework.camp.name}仍是玩家在风暴边缘还能勉强站稳的一块地方。`, - 88, - ), - } satisfies CustomWorldFoundationDraftCamp, - themePack: null, - storyGraph: null, - factions, - threads, - chapters: [chapter], - sceneChapters, - worldHook, - playerPremise, - openingSituation, - iconicElements, - sourceAnchorSummary: - buildDraftSummaryFromEightAnchorContent(normalizedAnchorContent) || - toText(toRecord(params.anchorPack)?.creatorIntentSummary) || - buildDraftSummaryFromIntent(params.creatorIntent) || - params.framework.summary, - legacyResultProfile, - } satisfies CustomWorldFoundationDraftProfile & { - legacyResultProfile: Record; - }; -} - -function getNamedRecordKey(value: unknown) { - return toText(value).replace(/\s+/gu, ''); -} - -function chunkArray(items: T[], size: number) { - if (size <= 0 || items.length === 0) { - return items.length === 0 ? [] : [items]; - } - - const chunks: T[][] = []; - for (let index = 0; index < items.length; index += size) { - chunks.push(items.slice(index, index + size)); - } - return chunks; -} - -function mergeRoleBatchDetails( - baseEntries: T[], - detailEntries: Array>, -) { - const nextEntries = baseEntries.map((entry) => ({ ...entry })) as T[]; - const availableIndexes = new Set(nextEntries.map((_, index) => index)); - const indexByName = new Map(); - - nextEntries.forEach((entry, index) => { - const name = getNamedRecordKey(entry.name); - if (name) { - indexByName.set(name, index); - } - }); - - detailEntries.forEach((detail) => { - const detailName = getNamedRecordKey(detail.name); - let targetIndex = - detailName && indexByName.has(detailName) - ? indexByName.get(detailName) - : undefined; - - if (targetIndex === undefined) { - for (const index of availableIndexes) { - targetIndex = index; - break; - } - } - - if (targetIndex === undefined) { - return; - } - - const baseEntry = nextEntries[targetIndex]; - if (!baseEntry) { - return; - } - - nextEntries[targetIndex] = { - ...baseEntry, - ...detail, - name: getNamedRecordKey(baseEntry.name) || detailName || baseEntry.name, - } as T; - availableIndexes.delete(targetIndex); - }); - - return nextEntries; -} - -function appendUniqueNamedEntries( - baseEntries: T[], - nextEntries: T[], - maxCount: number, -) { - const merged = baseEntries.map((entry) => ({ ...entry })) as T[]; - const existingNames = new Set( - merged.map((entry) => getNamedRecordKey(entry.name)).filter(Boolean), - ); - - nextEntries.forEach((entry) => { - if (merged.length >= maxCount) { - return; - } - - const name = getNamedRecordKey(entry.name); - if (!name || existingNames.has(name)) { - return; - } - - merged.push({ ...entry, name } as T); - existingNames.add(name); - }); - - return merged; -} - -function extractJsonPayload(text: string) { - const trimmed = text.trim(); - if (!trimmed) { - return ''; - } - - const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/iu); - const unfenced = fencedMatch?.[1]?.trim() || trimmed; - const firstBrace = unfenced.indexOf('{'); - const firstBracket = unfenced.indexOf('['); - const starts = [firstBrace, firstBracket].filter((value) => value >= 0); - const start = starts.length > 0 ? Math.min(...starts) : -1; - - if (start < 0) { - return unfenced; - } - - const opener = unfenced[start]; - const closer = opener === '[' ? ']' : '}'; - const end = unfenced.lastIndexOf(closer); - if (end > start) { - return unfenced.slice(start, end + 1); - } - - return unfenced.slice(start); -} - -function sanitizeJsonLikeText(text: string) { - return extractJsonPayload(text) - .replace(/^\uFEFF/u, '') - .replace(/[\u201C\u201D]/gu, '"') - .replace(/[\u2018\u2019]/gu, "'") - .replace(/\u00A0/gu, ' ') - .replace(/,\s*([}\]])/gu, '$1') - .trim(); -} - -function buildFoundationGenerationSeedText(params: { - intent: CustomWorldCreatorIntentRecord; - anchorPack: unknown; - anchorContent?: EightAnchorContent | null; -}) { - const anchorText = params.anchorContent - ? buildEightAnchorFoundationText(params.anchorContent) - : ''; - if (anchorText) { - return anchorText; - } - - const anchorRecord = toRecord(params.anchorPack); - const anchorSummary = toText(anchorRecord?.creatorIntentSummary); - if (anchorSummary) { - return anchorSummary; - } - - const sections = [ - params.intent.worldHook ? `世界核心:${params.intent.worldHook}` : '', - params.intent.playerPremise - ? `玩家身份:${params.intent.playerPremise}` - : '', - params.intent.openingSituation - ? `开局处境:${params.intent.openingSituation}` - : '', - params.intent.coreConflicts.length > 0 - ? `核心冲突:${params.intent.coreConflicts.join('、')}` - : '', - params.intent.iconicElements.length > 0 - ? `标志元素:${params.intent.iconicElements.join('、')}` - : '', - ].filter(Boolean); - - return sections.join('\n') || buildDraftSummaryFromIntent(params.intent); -} - -async function emitDraftProgress( - onProgress: DraftProgressCallback | undefined, - payload: DraftProgressPayload, -) { - if (!onProgress) { - return; - } - - await onProgress({ - ...payload, - progress: Math.max(0, Math.min(100, Math.round(payload.progress))), - }); -} - -function toBatchProgress( - start: number, - end: number, - completed: number, - total: number, -) { - if (total <= 0) { - return end; - } - - const ratio = Math.max(0, Math.min(1, completed / total)); - return start + (end - start) * ratio; -} - -async function requestFoundationJsonStage(params: { - llmClient: UpstreamLlmClient; - userPrompt: string; - debugLabel: string; - repairPromptBuilder: (responseText: string) => string; - repairDebugLabel: string; - emptyResponseMessage: string; - signal?: AbortSignal; -}) { - const responseText = await params.llmClient.requestMessageContent({ - systemPrompt: FOUNDATION_JSON_ONLY_SYSTEM_PROMPT, - userPrompt: params.userPrompt, - signal: params.signal, - timeoutMs: FOUNDATION_LLM_TIMEOUT_MS, - debugLabel: params.debugLabel, - }); - - const text = typeof responseText === 'string' ? responseText.trim() : ''; - if (!text) { - throw new Error(params.emptyResponseMessage); - } - - try { - return parseJsonResponseText(text); - } catch { - const sanitized = sanitizeJsonLikeText(text); - if (sanitized && sanitized !== text) { - try { - return parseJsonResponseText(sanitized); - } catch { - // Fall through to model-assisted repair. - } - } - - const repairedText = await params.llmClient.requestMessageContent({ - systemPrompt: FOUNDATION_JSON_REPAIR_SYSTEM_PROMPT, - userPrompt: params.repairPromptBuilder(text), - signal: params.signal, - timeoutMs: Math.min(FOUNDATION_LLM_TIMEOUT_MS, 60000), - debugLabel: params.repairDebugLabel, - }); - - return parseJsonResponseText( - sanitizeJsonLikeText(repairedText) || repairedText, - ); - } -} - -async function generateFoundationRoleOutlineEntries(params: { - llmClient: UpstreamLlmClient; - framework: CustomWorldGenerationFramework; - roleType: CustomWorldGenerationRoleBatchType; - totalCount: number; - batchSize: number; - signal?: AbortSignal; - onProgress?: DraftProgressCallback; - progressRange: [number, number]; -}) { - const plannedBatchCount = Math.max( - 1, - Math.ceil(params.totalCount / params.batchSize), - ); - const roleLabel = params.roleType === 'playable' ? '可扮演角色' : '场景角色'; - let mergedEntries: CustomWorldGenerationRoleOutline[] = []; - - for ( - let batchIndex = 0; - batchIndex < plannedBatchCount && mergedEntries.length < params.totalCount; - batchIndex += 1 - ) { - const batchCount = Math.min( - params.batchSize, - params.totalCount - mergedEntries.length, - ); - await emitDraftProgress(params.onProgress, { - phaseLabel: `生成${roleLabel}`, - phaseDetail: `正在生成${roleLabel}第 ${batchIndex + 1} / ${plannedBatchCount} 批,当前已完成 ${mergedEntries.length}/${params.totalCount}。`, - progress: toBatchProgress( - params.progressRange[0], - params.progressRange[1], - mergedEntries.length, - params.totalCount, - ), - }); - - const batchRaw = await requestFoundationJsonStage({ - llmClient: params.llmClient, - userPrompt: buildCustomWorldRoleOutlineBatchPrompt({ - framework: params.framework, - roleType: params.roleType, - batchCount, - forbiddenNames: mergedEntries.map((entry) => entry.name), - }), - debugLabel: `agent-foundation-${params.roleType}-outline-batch-${batchIndex + 1}`, - repairPromptBuilder: (responseText) => - buildCustomWorldRoleOutlineBatchJsonRepairPrompt({ - responseText, - roleType: params.roleType, - expectedCount: batchCount, - forbiddenNames: mergedEntries.map((entry) => entry.name), - }), - repairDebugLabel: `agent-foundation-${params.roleType}-outline-batch-${batchIndex + 1}-json-repair`, - emptyResponseMessage: `${roleLabel}第 ${batchIndex + 1} 批没有返回有效内容。`, - signal: params.signal, - }); - - mergedEntries = appendUniqueNamedEntries( - mergedEntries, - normalizeCustomWorldGenerationRoleOutlineBatch( - batchRaw, - params.roleType, - ) as MergeableNamedRecord[] as CustomWorldGenerationRoleOutline[], - params.totalCount, - ); - } - - await emitDraftProgress(params.onProgress, { - phaseLabel: `生成${roleLabel}`, - phaseDetail: `${roleLabel}名单已整理完成,共 ${mergedEntries.length} 个。`, - progress: params.progressRange[1], - }); - - return mergedEntries; -} - -async function generateFoundationLandmarkSeedEntries(params: { - llmClient: UpstreamLlmClient; - framework: CustomWorldGenerationFramework; - totalCount: number; - batchSize: number; - signal?: AbortSignal; - onProgress?: DraftProgressCallback; - progressRange: [number, number]; -}) { - const plannedBatchCount = Math.max( - 1, - Math.ceil(params.totalCount / params.batchSize), - ); - let mergedEntries: CustomWorldGenerationLandmarkOutline[] = []; - - for ( - let batchIndex = 0; - batchIndex < plannedBatchCount && mergedEntries.length < params.totalCount; - batchIndex += 1 - ) { - const batchCount = Math.min( - params.batchSize, - params.totalCount - mergedEntries.length, - ); - await emitDraftProgress(params.onProgress, { - phaseLabel: '生成关键场景', - phaseDetail: `正在生成关键场景第 ${batchIndex + 1} / ${plannedBatchCount} 批,当前已完成 ${mergedEntries.length}/${params.totalCount}。`, - progress: toBatchProgress( - params.progressRange[0], - params.progressRange[1], - mergedEntries.length, - params.totalCount, - ), - }); - - const batchRaw = await requestFoundationJsonStage({ - llmClient: params.llmClient, - userPrompt: buildCustomWorldLandmarkSeedBatchPrompt({ - framework: params.framework, - batchCount, - forbiddenNames: mergedEntries.map((entry) => entry.name), - }), - debugLabel: `agent-foundation-landmark-seed-batch-${batchIndex + 1}`, - repairPromptBuilder: (responseText) => - buildCustomWorldLandmarkSeedBatchJsonRepairPrompt({ - responseText, - expectedCount: batchCount, - forbiddenNames: mergedEntries.map((entry) => entry.name), - }), - repairDebugLabel: `agent-foundation-landmark-seed-batch-${batchIndex + 1}-json-repair`, - emptyResponseMessage: `关键场景第 ${batchIndex + 1} 批没有返回有效内容。`, - signal: params.signal, - }); - - mergedEntries = appendUniqueNamedEntries( - mergedEntries, - normalizeCustomWorldGenerationLandmarkOutlineBatch( - batchRaw, - ) as MergeableNamedRecord[] as CustomWorldGenerationLandmarkOutline[], - params.totalCount, - ); - } - - await emitDraftProgress(params.onProgress, { - phaseLabel: '生成关键场景', - phaseDetail: `关键场景骨架已整理完成,共 ${mergedEntries.length} 个。`, - progress: params.progressRange[1], - }); - - return mergedEntries; -} - -async function expandFoundationLandmarkNetworkEntries(params: { - llmClient: UpstreamLlmClient; - framework: CustomWorldGenerationFramework; - storyNpcs: CustomWorldGenerationFramework['storyNpcs']; - baseEntries: CustomWorldGenerationLandmarkOutline[]; - batchSize: number; - signal?: AbortSignal; - onProgress?: DraftProgressCallback; - progressRange: [number, number]; -}) { - let mergedEntries = params.baseEntries.map((entry) => ({ ...entry })); - const batches = chunkArray(params.framework.landmarks, params.batchSize); - let processedCount = 0; - - for (const [batchIndex, landmarkBatch] of batches.entries()) { - await emitDraftProgress(params.onProgress, { - phaseLabel: '建立场景连接', - phaseDetail: `正在补全场景连接第 ${batchIndex + 1} / ${batches.length} 批,当前已完成 ${processedCount}/${params.framework.landmarks.length}。`, - progress: toBatchProgress( - params.progressRange[0], - params.progressRange[1], - processedCount, - params.framework.landmarks.length, - ), - }); - - const batchRaw = await requestFoundationJsonStage({ - llmClient: params.llmClient, - userPrompt: buildCustomWorldLandmarkNetworkBatchPrompt({ - framework: params.framework, - landmarkBatch, - storyNpcs: params.storyNpcs, - }), - debugLabel: `agent-foundation-landmark-network-batch-${batchIndex + 1}`, - repairPromptBuilder: (responseText) => - buildCustomWorldLandmarkNetworkBatchJsonRepairPrompt({ - responseText, - expectedNames: landmarkBatch.map((landmark) => landmark.name), - }), - repairDebugLabel: `agent-foundation-landmark-network-batch-${batchIndex + 1}-json-repair`, - emptyResponseMessage: `场景连接第 ${batchIndex + 1} 批没有返回有效内容。`, - signal: params.signal, - }); - - mergedEntries = mergeRoleBatchDetails( - mergedEntries as MergeableNamedRecord[], - normalizeCustomWorldGenerationLandmarkOutlineBatch(batchRaw), - ) as CustomWorldGenerationLandmarkOutline[]; - processedCount = Math.min( - params.framework.landmarks.length, - processedCount + landmarkBatch.length, - ); - } - - await emitDraftProgress(params.onProgress, { - phaseLabel: '建立场景连接', - phaseDetail: '关键场景的角色分布与路径连接已经整理完成。', - progress: params.progressRange[1], - }); - - return mergedEntries; -} - -async function expandFoundationRoleEntries(params: { - llmClient: UpstreamLlmClient; - framework: CustomWorldGenerationFramework; - roleType: CustomWorldGenerationRoleBatchType; - baseEntries: CustomWorldGenerationRoleOutline[]; - stage: CustomWorldGenerationRoleBatchStage; - batchSize: number; - signal?: AbortSignal; - onProgress?: DraftProgressCallback; - progressRange: [number, number]; -}) { - const roleLabel = params.roleType === 'playable' ? '可扮演角色' : '场景角色'; - const stageLabel = params.stage === 'narrative' ? '叙事基础' : '档案细节'; - const batches = chunkArray(params.baseEntries, params.batchSize); - let mergedEntries = params.baseEntries.map((entry) => ({ ...entry })); - let processedCount = 0; - - for (const [batchIndex, roleBatch] of batches.entries()) { - await emitDraftProgress(params.onProgress, { - phaseLabel: `补全${roleLabel}${stageLabel}`, - phaseDetail: `正在补全${roleLabel}${stageLabel}第 ${batchIndex + 1} / ${batches.length} 批,当前已完成 ${processedCount}/${params.baseEntries.length}。`, - progress: toBatchProgress( - params.progressRange[0], - params.progressRange[1], - processedCount, - params.baseEntries.length, - ), - }); - - const stageRaw = await requestFoundationJsonStage({ - llmClient: params.llmClient, - userPrompt: buildCustomWorldRoleBatchPrompt({ - framework: params.framework, - roleType: params.roleType, - roleBatch, - stage: params.stage, - }), - debugLabel: `agent-foundation-${params.roleType}-${params.stage}-batch-${batchIndex + 1}`, - repairPromptBuilder: (responseText) => - buildCustomWorldRoleBatchJsonRepairPrompt({ - responseText, - roleType: params.roleType, - expectedNames: roleBatch.map((role) => getNamedRecordKey(role.name)), - stage: params.stage, - }), - repairDebugLabel: `agent-foundation-${params.roleType}-${params.stage}-batch-${batchIndex + 1}-json-repair`, - emptyResponseMessage: `${roleLabel}${stageLabel}第 ${batchIndex + 1} 批没有返回有效内容。`, - signal: params.signal, - }); - - const detailEntries = Array.isArray( - stageRaw && typeof stageRaw === 'object' - ? (stageRaw as Record)[ - params.roleType === 'playable' ? 'playableNpcs' : 'storyNpcs' - ] - : [], - ) - ? (((stageRaw as Record)[ - params.roleType === 'playable' ? 'playableNpcs' : 'storyNpcs' - ] as Array>) ?? []) - : []; - - mergedEntries = mergeRoleBatchDetails( - mergedEntries as MergeableNamedRecord[], - detailEntries, - ) as CustomWorldGenerationRoleOutline[]; - processedCount = Math.min( - params.baseEntries.length, - processedCount + roleBatch.length, - ); - } - - await emitDraftProgress(params.onProgress, { - phaseLabel: `补全${roleLabel}${stageLabel}`, - phaseDetail: `${roleLabel}${stageLabel}已经整理完成。`, - progress: params.progressRange[1], - }); - - return mergedEntries; -} - -async function buildFoundationDraftProfileWithLlm(params: { - llmClient: UpstreamLlmClient; - creatorIntent: CustomWorldCreatorIntentRecord; - anchorPack: unknown; - anchorContent?: EightAnchorContent | null; - signal?: AbortSignal; - onProgress?: DraftProgressCallback; -}) { - const settingText = buildFoundationGenerationSeedText({ - intent: params.creatorIntent, - anchorPack: params.anchorPack, - anchorContent: params.anchorContent, - }); - - await emitDraftProgress(params.onProgress, { - phaseLabel: '整理世界骨架', - phaseDetail: '正在根据创作者锚点生成第一版世界框架。', - progress: 12, - }); - const frameworkRaw = await requestFoundationJsonStage({ - llmClient: params.llmClient, - userPrompt: buildCustomWorldFrameworkPrompt(settingText), - debugLabel: 'agent-foundation-framework', - repairPromptBuilder: (responseText) => - buildCustomWorldFrameworkJsonRepairPrompt(responseText), - repairDebugLabel: 'agent-foundation-framework-json-repair', - emptyResponseMessage: '世界框架阶段没有返回有效内容。', - signal: params.signal, - }); - const framework = normalizeCustomWorldGenerationFramework( - frameworkRaw, - settingText, - ); - - framework.playableNpcs = await generateFoundationRoleOutlineEntries({ - llmClient: params.llmClient, - framework, - roleType: 'playable', - totalCount: FOUNDATION_DRAFT_PLAYABLE_COUNT, - batchSize: FOUNDATION_ROLE_OUTLINE_BATCH_SIZE, - signal: params.signal, - onProgress: params.onProgress, - progressRange: [16, 30], - }); - - framework.storyNpcs = await generateFoundationRoleOutlineEntries({ - llmClient: params.llmClient, - framework, - roleType: 'story', - totalCount: FOUNDATION_DRAFT_STORY_COUNT, - batchSize: FOUNDATION_ROLE_OUTLINE_BATCH_SIZE, - signal: params.signal, - onProgress: params.onProgress, - progressRange: [30, 44], - }); - - framework.landmarks = await generateFoundationLandmarkSeedEntries({ - llmClient: params.llmClient, - framework, - totalCount: FOUNDATION_DRAFT_LANDMARK_COUNT, - batchSize: FOUNDATION_LANDMARK_BATCH_SIZE, - signal: params.signal, - onProgress: params.onProgress, - progressRange: [44, 56], - }); - - framework.landmarks = await expandFoundationLandmarkNetworkEntries({ - llmClient: params.llmClient, - framework, - storyNpcs: framework.storyNpcs, - baseEntries: framework.landmarks, - batchSize: FOUNDATION_LANDMARK_BATCH_SIZE, - signal: params.signal, - onProgress: params.onProgress, - progressRange: [56, 66], - }); - - const playableNarrative = await expandFoundationRoleEntries({ - llmClient: params.llmClient, - framework, - roleType: 'playable', - baseEntries: framework.playableNpcs, - stage: 'narrative', - batchSize: FOUNDATION_ROLE_DETAIL_BATCH_SIZE, - signal: params.signal, - onProgress: params.onProgress, - progressRange: [66, 76], - }); - const playableDetailed = await expandFoundationRoleEntries({ - llmClient: params.llmClient, - framework, - roleType: 'playable', - baseEntries: playableNarrative, - stage: 'dossier', - batchSize: FOUNDATION_ROLE_DETAIL_BATCH_SIZE, - signal: params.signal, - onProgress: params.onProgress, - progressRange: [76, 84], - }); - const storyNarrative = await expandFoundationRoleEntries({ - llmClient: params.llmClient, - framework, - roleType: 'story', - baseEntries: framework.storyNpcs, - stage: 'narrative', - batchSize: FOUNDATION_ROLE_DETAIL_BATCH_SIZE, - signal: params.signal, - onProgress: params.onProgress, - progressRange: [84, 92], - }); - const storyDetailed = await expandFoundationRoleEntries({ - llmClient: params.llmClient, - framework, - roleType: 'story', - baseEntries: storyNarrative, - stage: 'dossier', - batchSize: FOUNDATION_ROLE_DETAIL_BATCH_SIZE, - signal: params.signal, - onProgress: params.onProgress, - progressRange: [92, 96], - }); - - await emitDraftProgress(params.onProgress, { - phaseLabel: '编译世界底稿', - phaseDetail: - '正在把分批生成结果直接整理成第一版 foundation draft,并同步兼容结果快照。', - progress: 97, - }); - - return buildFoundationDraftProfileFromFramework({ - framework, - playableDetailed, - storyDetailed, - creatorIntent: params.creatorIntent, - anchorPack: params.anchorPack, - anchorContent: params.anchorContent, - settingText, - }); -} - -export class CustomWorldAgentFoundationDraftService { - constructor(private readonly llmClient: UpstreamLlmClient | null = null) {} - - private generateFallbackDraft(params: { - creatorIntent: unknown; - anchorPack: unknown; - anchorContent?: EightAnchorContent | null; - }): CustomWorldFoundationDraftProfile { - const normalizedAnchorContent = normalizeEightAnchorContent( - params.anchorContent, - ); - const intent = - normalizeCreatorIntentRecord(params.creatorIntent) ?? - buildCreatorIntentFromEightAnchorContent(normalizedAnchorContent); - const anchorPack = toRecord(params.anchorPack); - const worldHook = - clampText(intent.worldHook || intent.rawSettingText, 72) || - '一个仍在失衡边缘不断扩张的世界'; - const playerPremise = - clampText(intent.playerPremise, 72) || '玩家是一名被卷进局势中心的行动者'; - const openingSituation = - clampText(intent.openingSituation, 72) || - '故事开局时,玩家已经站在必须立刻选边的位置上'; - const coreConflicts = - dedupeStrings(intent.coreConflicts, 4).length > 0 - ? dedupeStrings(intent.coreConflicts, 4) - : ['旧秩序与新力量正在争夺这个世界的解释权']; - const iconicElements = dedupeStrings(intent.iconicElements, 6); - const tone = buildTone(intent); - const worldName = buildWorldName(intent); - const playerGoal = buildPlayerGoal({ - playerPremise, - openingSituation, - coreConflict: coreConflicts[0] || '', - }); - const anchorDraftTitle = - buildDraftTitleFromEightAnchorContent(normalizedAnchorContent); - const factions = buildFactions({ - intent, - coreConflicts, - playerPremise, - iconicElements, - }); - const baseThreads = buildBaseThreads({ - intent, - coreConflicts, - playerPremise, - openingSituation, - iconicElements, - }); - const characters = buildCharacters({ - intent, - factions, - threads: baseThreads, - coreConflicts, - iconicElements, - }).slice(0, FOUNDATION_DRAFT_PLAYABLE_COUNT + FOUNDATION_DRAFT_STORY_COUNT); - const { playableNpcs, storyNpcs } = splitDraftCharacters({ - characters, - playableCount: FOUNDATION_DRAFT_PLAYABLE_COUNT, - storyCount: FOUNDATION_DRAFT_STORY_COUNT, - }); - const camp = buildCamp({ - openingSituation, - worldHook, - iconicElements, - }); - const landmarks = buildLandmarks({ - intent, - camp, - factions, - characters: [...playableNpcs, ...storyNpcs], - threads: baseThreads, - coreConflicts, - iconicElements, - openingSituation, - }).slice(0, FOUNDATION_DRAFT_LANDMARK_COUNT); - const threads = finalizeThreads({ - threads: baseThreads.slice(0, 4), - characters, - landmarks, - }); - const chapter = buildChapter({ - worldName, - openingSituation, - playerGoal, - characters: [...playableNpcs, ...storyNpcs], - landmarks, - threads, - }); - const uniquePoint = - iconicElements.length > 0 - ? `最抓人的记忆点是${iconicElements.slice(0, 2).join('、')}` - : '这个世界的吸引力来自它正在失衡中的人和秩序'; - const summary = clampText( - `${worldHook} 玩家会以“${playerPremise}”切入这个世界,眼下最直接的冲突是“${coreConflicts[0]}”。${uniquePoint}。`, - 180, - ); - - return { - name: - anchorDraftTitle && anchorDraftTitle !== '未命名草稿' - ? anchorDraftTitle - : worldName, - subtitle: - clampText( - [ - buildCompactLabel(playerPremise, '玩家视角', 12), - buildCompactLabel(coreConflicts[0] || '', '核心冲突', 16), - ] - .filter(Boolean) - .join(' · '), - 40, - ) || '第一版世界底稿', - summary, - tone, - playerGoal, - majorFactions: factions.map((entry) => entry.name), - coreConflicts, - playableNpcs, - storyNpcs, - landmarks, - camp, - themePack: null, - storyGraph: null, - factions, - threads, - chapters: [chapter], - sceneChapters: buildSceneChaptersFromDraft({ - landmarks, - playableNpcs, - storyNpcs, - threads, - }), - worldHook, - playerPremise, - openingSituation, - iconicElements, - sourceAnchorSummary: - buildDraftSummaryFromEightAnchorContent(normalizedAnchorContent) || - toText(anchorPack?.creatorIntentSummary) || - buildDraftSummaryFromIntent(intent) || - summary, - }; - } - - async generate(params: { - creatorIntent: unknown; - anchorPack: unknown; - anchorContent?: EightAnchorContent | null; - signal?: AbortSignal; - onProgress?: DraftProgressCallback; - }): Promise { - const intent = - normalizeCreatorIntentRecord(params.creatorIntent) ?? - buildCreatorIntentFromEightAnchorContent( - normalizeEightAnchorContent(params.anchorContent), - ); - - if (!this.llmClient || !intent) { - return this.generateFallbackDraft(params); - } - - return buildFoundationDraftProfileWithLlm({ - llmClient: this.llmClient, - creatorIntent: intent, - anchorPack: params.anchorPack, - anchorContent: params.anchorContent, - signal: params.signal, - onProgress: params.onProgress, - }); - } -} diff --git a/server-node/src/services/customWorldAgentIntentExtractionService.ts b/server-node/src/services/customWorldAgentIntentExtractionService.ts deleted file mode 100644 index 53ed0116..00000000 --- a/server-node/src/services/customWorldAgentIntentExtractionService.ts +++ /dev/null @@ -1,1128 +0,0 @@ -type CustomWorldCreatorInputMode = 'freeform' | 'card'; - -export interface CreatorFactionSeedRecord { - id: string; - name: string; - publicGoal: string; - tension: string; - notes: string; - locked?: boolean; -} - -export interface CreatorCharacterSeedRecord { - id: string; - name: string; - role: string; - publicMask: string; - hiddenHook: string; - relationToPlayer: string; - notes: string; - locked?: boolean; -} - -export interface CreatorLandmarkSeedRecord { - id: string; - name: string; - purpose: string; - mood: string; - secret: string; - locked?: boolean; -} - -export interface CustomWorldCreatorIntentRecord { - sourceMode: CustomWorldCreatorInputMode; - rawSettingText: string; - worldHook: string; - themeKeywords: string[]; - toneDirectives: string[]; - playerPremise: string; - openingSituation: string; - coreConflicts: string[]; - keyFactions: CreatorFactionSeedRecord[]; - keyCharacters: CreatorCharacterSeedRecord[]; - keyLandmarks: CreatorLandmarkSeedRecord[]; - iconicElements: string[]; - forbiddenDirectives: string[]; -} - -export type ExtractedCreatorIntentPatch = Partial< - Pick< - CustomWorldCreatorIntentRecord, - | 'rawSettingText' - | 'worldHook' - | 'themeKeywords' - | 'toneDirectives' - | 'playerPremise' - | 'openingSituation' - | 'coreConflicts' - | 'keyFactions' - | 'keyCharacters' - | 'keyLandmarks' - | 'iconicElements' - | 'forbiddenDirectives' - > -> & { - replaceFields?: Array< - | 'rawSettingText' - | 'worldHook' - | 'themeKeywords' - | 'toneDirectives' - | 'playerPremise' - | 'openingSituation' - | 'coreConflicts' - | 'keyFactions' - | 'keyCharacters' - | 'keyLandmarks' - | 'iconicElements' - | 'forbiddenDirectives' - >; -}; - -const THEME_LEXICON = [ - '武侠', - '修仙', - '仙侠', - '赛博', - '蒸汽', - '废土', - '悬疑', - '宫廷', - '海岛', - '边境', - '宗教', - '朝堂', - '奇谭', - '妖异', - '科幻', - '神秘', - '冒险', - '克苏鲁', - '侦探', -]; - -const TONE_LEXICON = [ - '冷峻', - '克制', - '压抑', - '浪漫', - '潮湿', - '荒凉', - '悬疑', - '紧张', - '明快', - '史诗', - '残酷', - '诡异', - '黑暗', - '肃杀', - '温柔', - '宏大', - '宿命', - '神秘', -]; - -const RELATIONSHIP_TERMS = [ - '宿敌', - '盟友', - '导师', - '师父', - '搭档', - '同伴', - '恋人', - '家人', - '兄弟', - '姐妹', - '父亲', - '母亲', - '向导', - '引路人', - '守望者', - '巡夜人', -]; - -const META_MESSAGE_PATTERN = - /^(请)?(总结|梳理|归纳|收一下|概括)|继续补充锚点|继续收集锚点/u; - -function toText(value: unknown) { - return typeof value === 'string' ? value.replace(/\s+/gu, ' ').trim() : ''; -} - -function toStringArray(value: unknown, maxCount = 8) { - if (!Array.isArray(value)) { - return []; - } - - return [...new Set(value.map((item) => toText(item)).filter(Boolean))].slice( - 0, - maxCount, - ); -} - -function slugify(value: string) { - const normalized = value - .trim() - .toLowerCase() - .replace(/[^a-z0-9\u4e00-\u9fa5]+/gu, '-') - .replace(/^-+|-+$/gu, ''); - - return normalized || 'entry'; -} - -function createSeedId(prefix: string, label: string, index: number) { - return `${prefix}-${slugify(label || `${prefix}-${index + 1}`)}-${index + 1}`; -} - -function clampText(value: string, maxLength: number) { - const normalized = value.trim().replace(/\s+/gu, ' '); - if (!normalized) { - return ''; - } - if (normalized.length <= maxLength) { - return normalized; - } - - return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}…`; -} - -function splitSentences(text: string) { - return text - .split(/[。!?;\n]/u) - .map((sentence) => sentence.trim()) - .filter(Boolean); -} - -function splitList(text: string, maxCount = 8) { - const normalized = text - .replace(/[“”"'`]/gu, '') - .replace(/^(改成|改为|换成|重设|重新设定|覆盖为|更新为)/u, '') - .replace(/^(包括|比如|例如|像是|例如说)/u, '') - .replace(/^(是|为|有|偏|走|要|想要)/u, '') - .trim(); - - if (!normalized) { - return []; - } - - return [ - ...new Set( - normalized - .split(/[、,,\/|;;]/u) - .map((item) => item.trim()) - .filter((item) => item.length >= 2 && item.length <= 24), - ), - ].slice(0, maxCount); -} - -function mergeStringArray( - base: string[], - patch: string[] | undefined, - maxCount: number, -) { - if (!patch || patch.length === 0) { - return [...base]; - } - - return [ - ...new Set([...base, ...patch.map((item) => toText(item)).filter(Boolean)]), - ].slice(0, maxCount); -} - -function mergeNarrativeText(base: string, patch: string | undefined) { - const nextText = toText(patch); - if (!nextText) { - return base; - } - if (!base) { - return nextText; - } - if (base.includes(nextText)) { - return base; - } - - return `${base}\n${nextText}`.trim(); -} - -function normalizeCreatorFactionSeed( - value: unknown, - index: number, -): CreatorFactionSeedRecord | null { - if (!value || typeof value !== 'object') { - return null; - } - - const item = value as Record; - const name = toText(item.name); - const publicGoal = toText(item.publicGoal); - const tension = toText(item.tension); - const notes = toText(item.notes); - - if (!name && !publicGoal && !tension && !notes) { - return null; - } - - return { - id: - toText(item.id) || - createSeedId('creator-faction', name || publicGoal, index), - name, - publicGoal, - tension, - notes, - locked: Boolean(item.locked), - }; -} - -function normalizeCreatorCharacterSeed( - value: unknown, - index: number, -): CreatorCharacterSeedRecord | null { - if (!value || typeof value !== 'object') { - return null; - } - - const item = value as Record; - const name = toText(item.name); - const role = toText(item.role); - const publicMask = toText(item.publicMask); - const hiddenHook = toText(item.hiddenHook); - const relationToPlayer = toText(item.relationToPlayer); - const notes = toText(item.notes); - - if ( - !name && - !role && - !publicMask && - !hiddenHook && - !relationToPlayer && - !notes - ) { - return null; - } - - return { - id: - toText(item.id) || - createSeedId('creator-character', name || role || publicMask, index), - name, - role, - publicMask, - hiddenHook, - relationToPlayer, - notes, - locked: Boolean(item.locked), - }; -} - -function normalizeCreatorLandmarkSeed( - value: unknown, - index: number, -): CreatorLandmarkSeedRecord | null { - if (!value || typeof value !== 'object') { - return null; - } - - const item = value as Record; - const name = toText(item.name); - const purpose = toText(item.purpose); - const mood = toText(item.mood); - const secret = toText(item.secret); - - if (!name && !purpose && !mood && !secret) { - return null; - } - - return { - id: - toText(item.id) || - createSeedId('creator-landmark', name || purpose || mood, index), - name, - purpose, - mood, - secret, - locked: Boolean(item.locked), - }; -} - -function normalizeAnchorArray( - value: unknown, - normalizer: (value: unknown, index: number) => T | null, - maxCount: number, -) { - if (!Array.isArray(value)) { - return []; - } - - return value - .map((item, index) => normalizer(item, index)) - .filter((item): item is T => Boolean(item)) - .slice(0, maxCount); -} - -function mergeSeedArray( - base: T[], - patch: T[] | undefined, - maxCount: number, - mergeEntry: (current: T, next: T) => T, -) { - if (!patch || patch.length === 0) { - return [...base]; - } - - const nextItems = [...base]; - - patch.forEach((entry) => { - const normalizedName = toText(entry.name); - const existingIndex = nextItems.findIndex( - (item) => - item.id === entry.id || - (normalizedName && - toText(item.name).toLowerCase() === normalizedName.toLowerCase()), - ); - - if (existingIndex >= 0) { - nextItems[existingIndex] = mergeEntry(nextItems[existingIndex], entry); - return; - } - - nextItems.push(entry); - }); - - return nextItems.slice(0, maxCount); -} - -function mergeCharacterSeed( - current: CreatorCharacterSeedRecord, - next: CreatorCharacterSeedRecord, -): CreatorCharacterSeedRecord { - return { - ...current, - ...next, - id: next.id || current.id, - name: toText(next.name) || current.name, - role: toText(next.role) || current.role, - publicMask: toText(next.publicMask) || current.publicMask, - hiddenHook: toText(next.hiddenHook) || current.hiddenHook, - relationToPlayer: toText(next.relationToPlayer) || current.relationToPlayer, - notes: toText(next.notes) || current.notes, - locked: - typeof next.locked === 'boolean' ? next.locked : Boolean(current.locked), - }; -} - -function mergeFactionSeed( - current: CreatorFactionSeedRecord, - next: CreatorFactionSeedRecord, -): CreatorFactionSeedRecord { - return { - ...current, - ...next, - id: next.id || current.id, - name: toText(next.name) || current.name, - publicGoal: toText(next.publicGoal) || current.publicGoal, - tension: toText(next.tension) || current.tension, - notes: toText(next.notes) || current.notes, - locked: - typeof next.locked === 'boolean' ? next.locked : Boolean(current.locked), - }; -} - -function mergeLandmarkSeed( - current: CreatorLandmarkSeedRecord, - next: CreatorLandmarkSeedRecord, -): CreatorLandmarkSeedRecord { - return { - ...current, - ...next, - id: next.id || current.id, - name: toText(next.name) || current.name, - purpose: toText(next.purpose) || current.purpose, - mood: toText(next.mood) || current.mood, - secret: toText(next.secret) || current.secret, - locked: - typeof next.locked === 'boolean' ? next.locked : Boolean(current.locked), - }; -} - -export function createEmptyCreatorIntentRecord( - sourceMode: CustomWorldCreatorInputMode = 'freeform', -): CustomWorldCreatorIntentRecord { - return { - sourceMode, - rawSettingText: '', - worldHook: '', - themeKeywords: [], - toneDirectives: [], - playerPremise: '', - openingSituation: '', - coreConflicts: [], - keyFactions: [], - keyCharacters: [], - keyLandmarks: [], - iconicElements: [], - forbiddenDirectives: [], - }; -} - -export function normalizeCreatorIntentRecord( - value: unknown, - fallbackMode: CustomWorldCreatorInputMode = 'freeform', -): CustomWorldCreatorIntentRecord | null { - if (!value || typeof value !== 'object') { - return null; - } - - const item = value as Record; - const sourceMode = - item.sourceMode === 'card' || item.sourceMode === 'freeform' - ? item.sourceMode - : fallbackMode; - const rawSettingText = toText(item.rawSettingText); - const worldHook = toText(item.worldHook); - const playerPremise = toText(item.playerPremise); - const openingSituation = toText(item.openingSituation); - const themeKeywords = toStringArray(item.themeKeywords, 8); - const toneDirectives = toStringArray(item.toneDirectives, 8); - const coreConflicts = toStringArray(item.coreConflicts, 6); - const iconicElements = toStringArray(item.iconicElements, 8); - const forbiddenDirectives = toStringArray(item.forbiddenDirectives, 8); - const keyFactions = normalizeAnchorArray( - item.keyFactions, - normalizeCreatorFactionSeed, - 6, - ); - const keyCharacters = normalizeAnchorArray( - item.keyCharacters, - normalizeCreatorCharacterSeed, - 8, - ); - const keyLandmarks = normalizeAnchorArray( - item.keyLandmarks, - normalizeCreatorLandmarkSeed, - 8, - ); - - if ( - !rawSettingText && - !worldHook && - themeKeywords.length === 0 && - toneDirectives.length === 0 && - !playerPremise && - !openingSituation && - coreConflicts.length === 0 && - keyFactions.length === 0 && - keyCharacters.length === 0 && - keyLandmarks.length === 0 && - iconicElements.length === 0 && - forbiddenDirectives.length === 0 - ) { - return null; - } - - return { - sourceMode, - rawSettingText, - worldHook, - themeKeywords, - toneDirectives, - playerPremise, - openingSituation, - coreConflicts, - keyFactions, - keyCharacters, - keyLandmarks, - iconicElements, - forbiddenDirectives, - }; -} - -export function mergeCreatorIntentRecord( - current: CustomWorldCreatorIntentRecord | null | undefined, - patch: ExtractedCreatorIntentPatch | null | undefined, - fallbackMode: CustomWorldCreatorInputMode = 'freeform', -) { - if (!patch) { - return ( - normalizeCreatorIntentRecord(current, fallbackMode) ?? - createEmptyCreatorIntentRecord(fallbackMode) - ); - } - - const base = - normalizeCreatorIntentRecord(current, fallbackMode) ?? - createEmptyCreatorIntentRecord(fallbackMode); - const replaceFields = new Set(patch.replaceFields ?? []); - const patchIntent = - normalizeCreatorIntentRecord( - { - sourceMode: base.sourceMode, - ...patch, - }, - base.sourceMode, - ) ?? createEmptyCreatorIntentRecord(base.sourceMode); - - return { - ...base, - rawSettingText: replaceFields.has('rawSettingText') - ? toText(patchIntent.rawSettingText) || base.rawSettingText - : mergeNarrativeText(base.rawSettingText, patchIntent.rawSettingText), - worldHook: toText(patchIntent.worldHook) || base.worldHook, - themeKeywords: replaceFields.has('themeKeywords') - ? [...patchIntent.themeKeywords] - : mergeStringArray(base.themeKeywords, patchIntent.themeKeywords, 8), - toneDirectives: replaceFields.has('toneDirectives') - ? [...patchIntent.toneDirectives] - : mergeStringArray(base.toneDirectives, patchIntent.toneDirectives, 8), - playerPremise: toText(patchIntent.playerPremise) || base.playerPremise, - openingSituation: - toText(patchIntent.openingSituation) || base.openingSituation, - coreConflicts: replaceFields.has('coreConflicts') - ? [...patchIntent.coreConflicts] - : mergeStringArray(base.coreConflicts, patchIntent.coreConflicts, 6), - keyFactions: replaceFields.has('keyFactions') - ? [...patchIntent.keyFactions] - : mergeSeedArray( - base.keyFactions, - patchIntent.keyFactions, - 6, - mergeFactionSeed, - ), - keyCharacters: replaceFields.has('keyCharacters') - ? [...patchIntent.keyCharacters] - : mergeSeedArray( - base.keyCharacters, - patchIntent.keyCharacters, - 8, - mergeCharacterSeed, - ), - keyLandmarks: replaceFields.has('keyLandmarks') - ? [...patchIntent.keyLandmarks] - : mergeSeedArray( - base.keyLandmarks, - patchIntent.keyLandmarks, - 8, - mergeLandmarkSeed, - ), - iconicElements: replaceFields.has('iconicElements') - ? [...patchIntent.iconicElements] - : mergeStringArray(base.iconicElements, patchIntent.iconicElements, 8), - forbiddenDirectives: replaceFields.has('forbiddenDirectives') - ? [...patchIntent.forbiddenDirectives] - : mergeStringArray( - base.forbiddenDirectives, - patchIntent.forbiddenDirectives, - 8, - ), - } satisfies CustomWorldCreatorIntentRecord; -} - -export function hasMeaningfulCreatorIntentRecord( - intent: CustomWorldCreatorIntentRecord | null | undefined, -) { - return Boolean( - intent && - (intent.rawSettingText || - intent.worldHook || - intent.themeKeywords.length > 0 || - intent.toneDirectives.length > 0 || - intent.playerPremise || - intent.openingSituation || - intent.coreConflicts.length > 0 || - intent.keyFactions.length > 0 || - intent.keyCharacters.length > 0 || - intent.keyLandmarks.length > 0 || - intent.iconicElements.length > 0 || - intent.forbiddenDirectives.length > 0), - ); -} - -function buildAnchorLine(label: string, content: string) { - return content ? `${label}:${content}` : ''; -} - -export function buildCreatorIntentDisplayText( - intent: CustomWorldCreatorIntentRecord | null | undefined, -) { - if (!hasMeaningfulCreatorIntentRecord(intent)) { - return ''; - } - - const lines = [ - intent?.worldHook ? `世界一句话:${intent.worldHook}` : '', - buildAnchorLine('玩家身份', intent?.playerPremise || ''), - buildAnchorLine('开局处境', intent?.openingSituation || ''), - buildAnchorLine('核心冲突', intent?.coreConflicts.join('、') || ''), - buildAnchorLine( - '主题气质', - [...(intent?.themeKeywords ?? []), ...(intent?.toneDirectives ?? [])] - .filter(Boolean) - .join('、'), - ), - buildAnchorLine('标志性要素', intent?.iconicElements.join('、') || ''), - ].filter(Boolean); - - return lines.join('\n'); -} - -export function buildDraftTitleFromIntent( - intent: CustomWorldCreatorIntentRecord | null | undefined, -) { - return ( - clampText(intent?.worldHook || '', 24) || - clampText(intent?.rawSettingText || '', 24) || - '未命名草稿' - ); -} - -export function buildDraftSummaryFromIntent( - intent: CustomWorldCreatorIntentRecord | null | undefined, -) { - const summary = buildCreatorIntentDisplayText(intent); - if (summary) { - return clampText(summary.replace(/\n+/gu, ' · '), 180); - } - - return ( - clampText(intent?.rawSettingText || '', 180) || '还在收集你的世界锚点。' - ); -} - -export function buildAnchorPackFromIntent( - intent: CustomWorldCreatorIntentRecord | null | undefined, - options: { - completedKeys?: string[]; - missingKeys?: string[]; - } = {}, -) { - return { - worldSummary: clampText( - intent?.worldHook || intent?.rawSettingText || '', - 96, - ), - creatorIntentSummary: clampText(buildDraftSummaryFromIntent(intent), 180), - completedKeys: [...(options.completedKeys ?? [])], - missingKeys: [...(options.missingKeys ?? [])], - keyCharacterAnchors: - intent?.keyCharacters.map((entry) => ({ - id: entry.id, - name: entry.name || '未命名关键人物', - summary: clampText( - [entry.role, entry.relationToPlayer, entry.hiddenHook] - .filter(Boolean) - .join(';'), - 60, - ), - })) ?? [], - motifDirectives: [ - ...(intent?.themeKeywords ?? []), - ...(intent?.toneDirectives ?? []), - ...(intent?.iconicElements ?? []), - ].slice(0, 12), - }; -} - -function findSentenceByPattern(text: string, pattern: RegExp) { - return splitSentences(text).find((sentence) => pattern.test(sentence)) ?? ''; -} - -function extractAfterCue(text: string, cues: string[]) { - const sentences = splitSentences(text); - - for (const sentence of sentences) { - for (const cue of cues) { - const index = sentence.indexOf(cue); - if (index < 0) { - continue; - } - - const candidate = sentence - .slice(index + cue.length) - .replace(/^[::,,是为偏走要想]+/u, '') - .trim(); - if (candidate) { - return candidate; - } - } - } - - return ''; -} - -function escapeRegExp(value: string) { - return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); -} - -function isExplicitRewrite(text: string, cues: string[]) { - const rewritePattern = /(改成|改为|换成|重设|重新设定|覆盖为|更新为)/u; - - return cues.some((cue) => { - const escapedCue = escapeRegExp(cue); - return ( - new RegExp( - `${escapedCue}[^。!?;\\n]{0,28}${rewritePattern.source}`, - 'u', - ).test(text) || - new RegExp( - `${rewritePattern.source}[^。!?;\\n]{0,28}${escapedCue}`, - 'u', - ).test(text) - ); - }); -} - -function extractWorldHook(text: string, contextText: string) { - const explicit = - extractAfterCue(text, ['世界一句话', '核心幻想', '一句话概括', '一句话']) || - extractAfterCue(text, ['这个世界', '整体设定', '世界设定']); - if (explicit) { - return clampText(explicit, 72); - } - - const firstSentence = splitSentences(contextText)[0] ?? ''; - if (firstSentence.length >= 8 && !META_MESSAGE_PATTERN.test(firstSentence)) { - return clampText(firstSentence, 72); - } - - return ''; -} - -function extractThemeKeywords(text: string) { - const explicitSource = extractAfterCue(text, [ - '主题关键词', - '关键词', - '主题', - '题材', - ]).replace( - /(?:,|,).*(气质|风格|基调|氛围|核心冲突|冲突|玩家|开局|不要|避免).*/u, - '', - ); - const explicit = splitList(explicitSource); - const inferred = THEME_LEXICON.filter((entry) => text.includes(entry)); - return [...new Set([...explicit, ...inferred])].slice(0, 8); -} - -function extractToneDirectives(text: string) { - const explicit = splitList( - extractAfterCue(text, ['气质', '风格', '基调', '氛围', '风味']), - ); - const inferred = TONE_LEXICON.filter((entry) => text.includes(entry)); - return [...new Set([...explicit, ...inferred])].slice(0, 8); -} - -function extractPlayerPremise(text: string) { - const explicit = - extractAfterCue(text, [ - '玩家是', - '玩家身份是', - '主角是', - '你扮演', - '玩家身份', - ]) || findSentenceByPattern(text, /(玩家|主角|你扮演|身份|视角)/u); - - return clampText(explicit, 96); -} - -function extractOpeningSituation(text: string) { - const explicit = - extractAfterCue(text, [ - '开局是', - '开局', - '故事开场', - '开场', - '一开始', - '起始', - ]) || findSentenceByPattern(text, /(开局|开场|一开始|故事开始|起始|初始)/u); - - return clampText(explicit, 96); -} - -function extractCoreConflicts(text: string) { - const explicit = splitList( - extractAfterCue(text, ['核心冲突', '冲突', '危机', '主要矛盾']), - 6, - ); - const inferred = splitSentences(text) - .filter((sentence) => - /(冲突|危机|争夺|战争|对抗|灾变|失衡|威胁|追杀|背叛|悬念)/u.test( - sentence, - ), - ) - .map((sentence) => clampText(sentence, 72)); - - return [...new Set([...explicit, ...inferred])].slice(0, 6); -} - -function extractForbiddenDirectives(text: string) { - return splitSentences(text) - .filter((sentence) => /(不要|避免|禁止|不能|别出现)/u.test(sentence)) - .map((sentence) => - clampText( - sentence.replace(/^(不要|避免|禁止|不能|别出现)/u, '').trim() || - sentence, - 48, - ), - ) - .filter(Boolean) - .slice(0, 8); -} - -function extractIconicElements(text: string) { - const explicit = splitList( - extractAfterCue(text, [ - '标志性元素', - '标志性要素', - '标志元素', - '视觉符号', - '核心意象', - '一眼能认出来的设定', - ]), - ); - - return explicit.slice(0, 8); -} - -function extractCharacterName(sentence: string) { - const matchers = [ - /叫([A-Za-z0-9\u4e00-\u9fa5·-]{2,12})/u, - /名为([A-Za-z0-9\u4e00-\u9fa5·-]{2,12})/u, - /([A-Za-z0-9\u4e00-\u9fa5·-]{2,12})(?:是|作为|担任)/u, - ]; - - for (const matcher of matchers) { - const matched = sentence.match(matcher); - const candidate = toText(matched?.[1]); - if ( - candidate && - !['玩家', '主角', '世界', '故事', '开局', '气质'].includes(candidate) - ) { - return candidate; - } - } - - return ''; -} - -function extractRelationToPlayer(sentence: string) { - const explicit = sentence.match( - /(与玩家[^,。;]+|和玩家[^,。;]+|对玩家[^,。;]+|主角的[^,。;]+)/u, - ); - if (explicit?.[1]) { - return clampText(explicit[1], 48); - } - - const relationKeyword = RELATIONSHIP_TERMS.find((entry) => - sentence.includes(entry), - ); - return relationKeyword ?? ''; -} - -function extractHiddenHook(sentence: string) { - const explicit = sentence.match( - /(其实[^,。;]+|暗地里[^,。;]+|暗线是[^,。;]+|秘密是[^,。;]+|真实身份[^,。;]+|真正目的[^,。;]+)/u, - ); - - return clampText(toText(explicit?.[1]), 64); -} - -function extractRole(sentence: string, name: string) { - if (!name) { - return ''; - } - - const escapedName = name.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); - const matcher = new RegExp(`${escapedName}(?:是|作为|担任)([^,。;]+)`, 'u'); - const matched = sentence.match(matcher); - return clampText(toText(matched?.[1]), 48); -} - -function extractCharacterSeeds(text: string) { - const candidateSentences = splitSentences(text).filter((sentence) => - /(关键人物|关键角色|人物|角色|宿敌|盟友|导师|搭档|同伴|恋人|家人|与玩家|对玩家)/u.test( - sentence, - ), - ); - - return candidateSentences - .map((sentence, index) => { - const name = extractCharacterName(sentence); - const relationToPlayer = extractRelationToPlayer(sentence); - const hiddenHook = extractHiddenHook(sentence); - const role = extractRole(sentence, name); - - if (!name && !role && !relationToPlayer && !hiddenHook) { - return null; - } - - return { - id: createSeedId( - 'creator-character', - name || role || hiddenHook, - index, - ), - name, - role, - publicMask: '', - hiddenHook, - relationToPlayer, - notes: '', - } satisfies CreatorCharacterSeedRecord; - }) - .filter((entry): entry is CreatorCharacterSeedRecord => Boolean(entry)) - .slice(0, 3); -} - -function shouldAppendRawSettingText(text: string) { - return text.length >= 8 && !META_MESSAGE_PATTERN.test(text); -} - -export function extractCreatorIntentPatch(params: { - currentIntent: CustomWorldCreatorIntentRecord | null | undefined; - latestUserMessage: string; - recentMessages?: string[]; -}) { - const currentIntent = - normalizeCreatorIntentRecord(params.currentIntent) ?? - createEmptyCreatorIntentRecord('freeform'); - const latestUserMessage = toText(params.latestUserMessage); - const recentMessages = (params.recentMessages ?? []) - .map((entry) => toText(entry)) - .filter(Boolean) - .slice(-10); - const contextText = [...recentMessages, latestUserMessage].join('\n'); - - if (!latestUserMessage) { - return {} satisfies ExtractedCreatorIntentPatch; - } - - const patch: ExtractedCreatorIntentPatch = {}; - const markReplace = ( - field: NonNullable[number], - ) => { - patch.replaceFields = [...new Set([...(patch.replaceFields ?? []), field])]; - }; - - if (shouldAppendRawSettingText(latestUserMessage)) { - patch.rawSettingText = latestUserMessage; - } - - const worldHook = extractWorldHook( - latestUserMessage, - currentIntent.worldHook ? latestUserMessage : contextText, - ); - if (worldHook) { - patch.worldHook = worldHook; - if ( - isExplicitRewrite(latestUserMessage, [ - '世界一句话', - '核心幻想', - '一句话概括', - '这个世界', - '世界设定', - ]) - ) { - markReplace('worldHook'); - } - } - - const themeKeywords = extractThemeKeywords(latestUserMessage); - if (themeKeywords.length > 0) { - patch.themeKeywords = themeKeywords; - if ( - isExplicitRewrite(latestUserMessage, [ - '主题关键词', - '关键词', - '主题', - '题材', - ]) - ) { - markReplace('themeKeywords'); - } - } - - const toneDirectives = extractToneDirectives(latestUserMessage); - if (toneDirectives.length > 0) { - patch.toneDirectives = toneDirectives; - if ( - isExplicitRewrite(latestUserMessage, ['气质', '风格', '基调', '氛围']) - ) { - markReplace('toneDirectives'); - } - } - - const playerPremise = extractPlayerPremise(latestUserMessage); - if (playerPremise) { - patch.playerPremise = playerPremise; - if ( - isExplicitRewrite(latestUserMessage, ['玩家', '玩家身份', '主角', '身份']) - ) { - markReplace('playerPremise'); - } - } - - const openingSituation = extractOpeningSituation(latestUserMessage); - if (openingSituation) { - patch.openingSituation = openingSituation; - if ( - isExplicitRewrite(latestUserMessage, ['开局', '故事开场', '开场', '起始']) - ) { - markReplace('openingSituation'); - } - } - - const coreConflicts = extractCoreConflicts(latestUserMessage); - if (coreConflicts.length > 0) { - patch.coreConflicts = coreConflicts; - if ( - isExplicitRewrite(latestUserMessage, [ - '核心冲突', - '冲突', - '危机', - '主要矛盾', - ]) - ) { - markReplace('coreConflicts'); - } - } - - const keyCharacters = extractCharacterSeeds(latestUserMessage); - if (keyCharacters.length > 0) { - patch.keyCharacters = keyCharacters; - if ( - isExplicitRewrite(latestUserMessage, [ - '关键人物', - '关键角色', - '人物', - '角色', - ]) - ) { - markReplace('keyCharacters'); - } - } - - const iconicElements = extractIconicElements(latestUserMessage); - if (iconicElements.length > 0) { - patch.iconicElements = iconicElements; - if ( - isExplicitRewrite(latestUserMessage, [ - '标志性元素', - '标志性要素', - '标志元素', - '视觉符号', - '核心意象', - ]) - ) { - markReplace('iconicElements'); - } - } - - const forbiddenDirectives = extractForbiddenDirectives(latestUserMessage); - if (forbiddenDirectives.length > 0) { - patch.forbiddenDirectives = forbiddenDirectives; - if ( - isExplicitRewrite(latestUserMessage, ['禁忌', '禁止事项', '不要', '避免']) - ) { - markReplace('forbiddenDirectives'); - } - } - - return patch; -} diff --git a/server-node/src/services/customWorldAgentMessageTurnService.ts b/server-node/src/services/customWorldAgentMessageTurnService.ts deleted file mode 100644 index bf2843e4..00000000 --- a/server-node/src/services/customWorldAgentMessageTurnService.ts +++ /dev/null @@ -1,196 +0,0 @@ -import crypto from 'node:crypto'; - -import type { - CreatorIntentReadiness, - CustomWorldAgentMessage, - CustomWorldAgentSessionSnapshot, -} from '../../../packages/shared/src/contracts/customWorldAgent.js'; -import { - buildPendingClarifications, - evaluateCreatorIntentReadiness, - resolveCreatorIntentStage, -} from './customWorldAgentClarificationService.js'; -import { - buildAnchorPackFromIntent, - buildDraftSummaryFromIntent, - buildDraftTitleFromIntent, - type CustomWorldCreatorIntentRecord, -} from './customWorldAgentIntentExtractionService.js'; -import type { - CustomWorldAgentSessionRecord, - CustomWorldAgentSessionStore, -} from './customWorldAgentSessionStore.js'; -import type { CustomWorldAgentSuggestedActionService } from './customWorldAgentSuggestedActionService.js'; -import { CustomWorldAgentSnapshotBuilder } from './customWorldAgentSnapshotBuilder.js'; -import { - buildCreatorIntentFromEightAnchorContent, - buildEightAnchorContentFromCreatorIntent, -} from './eightAnchorCompatibilityService.js'; -import { EightAnchorSingleTurnService } from './eightAnchorSingleTurnService.js'; - -function buildDerivedState( - intent: CustomWorldCreatorIntentRecord, - hasUserInput: boolean, - suggestedActionService: CustomWorldAgentSuggestedActionService, -) { - const readiness = evaluateCreatorIntentReadiness(intent); - const pendingClarifications = buildPendingClarifications(intent, readiness); - const stage = resolveCreatorIntentStage({ - hasUserInput, - readiness, - }); - - return { - readiness, - pendingClarifications, - stage, - anchorPack: buildAnchorPackFromIntent(intent, { - completedKeys: readiness.completedKeys, - missingKeys: readiness.missingKeys, - }), - draftProfile: { - title: buildDraftTitleFromIntent(intent), - summary: buildDraftSummaryFromIntent(intent), - }, - suggestedActions: suggestedActionService.buildSuggestedActions({ - stage, - isReady: readiness.isReady, - }), - }; -} - -export class CustomWorldAgentMessageTurnService { - constructor( - private readonly sessionStore: CustomWorldAgentSessionStore, - private readonly eightAnchorSingleTurnService: EightAnchorSingleTurnService, - private readonly suggestedActionService: CustomWorldAgentSuggestedActionService, - private readonly snapshotBuilder: CustomWorldAgentSnapshotBuilder, - ) {} - - async applyMessageTurn(params: { - userId: string; - sessionId: string; - latestUserText: string; - quickFillRequested: boolean; - relatedOperationId?: string | null; - onReplyUpdate?: (text: string) => void; - }) { - const latestSession = (await this.sessionStore.get( - params.userId, - params.sessionId, - )) as CustomWorldAgentSessionRecord | null; - if (!latestSession) { - throw new Error('custom world agent session not found'); - } - - const shouldPreserveDraftStage = - (latestSession.stage === 'object_refining' || - latestSession.stage === 'visual_refining') && - latestSession.draftCards.length > 0; - - const assistantTurn = await this.eightAnchorSingleTurnService.streamTurn( - { - currentTurn: latestSession.currentTurn + 1, - progressPercent: latestSession.progressPercent, - quickFillRequested: params.quickFillRequested, - currentAnchorContent: latestSession.anchorContent, - chatHistory: latestSession.messages - .filter( - (message): message is CustomWorldAgentMessage => - (message.role === 'user' || message.role === 'assistant') && - Boolean(message.text.trim()), - ) - .map((message) => ({ - role: message.role, - content: message.text, - })), - }, - { - onReplyUpdate: params.onReplyUpdate, - }, - ); - const nextCreatorIntent = buildCreatorIntentFromEightAnchorContent( - assistantTurn.nextAnchorContent, - ); - const progressPercent = Math.max( - 0, - Math.min(100, Math.round(assistantTurn.progressPercent)), - ); - const creatorIntentReadiness: CreatorIntentReadiness = - progressPercent >= 100 - ? { - isReady: true, - completedKeys: [ - 'world_hook', - 'player_premise', - 'theme_and_tone', - 'core_conflict', - 'relationship_seed', - 'iconic_element', - ], - missingKeys: [], - } - : evaluateCreatorIntentReadiness(nextCreatorIntent); - const derivedState = buildDerivedState( - nextCreatorIntent, - true, - this.suggestedActionService, - ); - const shouldStayInDraftStage = - shouldPreserveDraftStage && progressPercent >= 100; - const assistantMessage = { - id: `message-${crypto.randomBytes(8).toString('hex')}`, - role: 'assistant', - kind: 'chat', - text: assistantTurn.replyText, - createdAt: new Date().toISOString(), - relatedOperationId: params.relatedOperationId ?? null, - } satisfies CustomWorldAgentMessage; - - await this.sessionStore.replaceDerivedState( - params.userId, - params.sessionId, - this.snapshotBuilder.buildMessageTurnState({ - latestSession, - nextAnchorContent: assistantTurn.nextAnchorContent, - progressPercent, - replyText: assistantTurn.replyText, - nextCreatorIntent, - creatorIntentReadiness, - derivedDraftProfile: derivedState.draftProfile, - derivedPendingClarifications: derivedState.pendingClarifications, - derivedStage: derivedState.stage, - shouldStayInDraftStage, - }), - ); - await this.sessionStore.appendMessage( - params.userId, - params.sessionId, - assistantMessage, - ); - - return (await this.sessionStore.getSnapshot( - params.userId, - params.sessionId, - )) as CustomWorldAgentSessionSnapshot; - } - - deriveInitialSessionState(params: { - seedText: string; - creatorIntent: CustomWorldCreatorIntentRecord; - }) { - const anchorContent = buildEightAnchorContentFromCreatorIntent( - params.creatorIntent, - ); - const derivedState = buildDerivedState( - params.creatorIntent, - Boolean(params.seedText), - this.suggestedActionService, - ); - - return { - anchorContent, - ...derivedState, - }; - } -} diff --git a/server-node/src/services/customWorldAgentOrchestrator.ts b/server-node/src/services/customWorldAgentOrchestrator.ts deleted file mode 100644 index 6910d45b..00000000 --- a/server-node/src/services/customWorldAgentOrchestrator.ts +++ /dev/null @@ -1,675 +0,0 @@ -import crypto from 'node:crypto'; -import type { Request, Response } from 'express'; - -import type { - CreateCustomWorldAgentSessionRequest, - CustomWorldAgentActionRequest, - CustomWorldAgentActionResponse, - CustomWorldAgentMessage, - CustomWorldAgentOperationRecord, - CustomWorldAgentSessionSnapshot, - CustomWorldPendingClarification, - SendCustomWorldAgentMessageRequest, - SendCustomWorldAgentMessageResponse, -} from '../../../packages/shared/src/contracts/customWorldAgent.js'; -import { notFound } from '../errors.js'; -import { prepareEventStreamResponse } from '../http.js'; -import { CustomWorldAgentActionRegistry } from './customWorldAgentActionRegistry.js'; -import { createCustomWorldAgentActionExecutorMap } from './customWorldAgentActionExecutors/index.js'; -import { CustomWorldAgentAssetBridgeService } from './customWorldAgentAssetBridgeService.js'; -import { CustomWorldAgentAutoAssetService } from './customWorldAgentAutoAssetService.js'; -import { CustomWorldAgentChangeSummaryService } from './customWorldAgentChangeSummaryService.js'; -import { CustomWorldAgentMessageTurnService } from './customWorldAgentMessageTurnService.js'; -import { - CustomWorldAgentDraftCompiler, - normalizeFoundationDraftProfile, -} from './customWorldAgentDraftCompiler.js'; -import { CustomWorldAgentEntityGenerationService } from './customWorldAgentEntityGenerationService.js'; -import { CustomWorldAgentFoundationDraftService } from './customWorldAgentFoundationDraftService.js'; -import { - createEmptyCreatorIntentRecord, - type CustomWorldCreatorIntentRecord, - extractCreatorIntentPatch, - hasMeaningfulCreatorIntentRecord, - mergeCreatorIntentRecord, -} from './customWorldAgentIntentExtractionService.js'; -import { CustomWorldAgentPublishingService } from './customWorldAgentPublishingService.js'; -import { CustomWorldAgentQualityGateService } from './customWorldAgentQualityGateService.js'; -import { CustomWorldAgentResultSyncService } from './customWorldAgentResultSyncService.js'; -import { CustomWorldAgentSnapshotBuilder } from './customWorldAgentSnapshotBuilder.js'; -import { - type CustomWorldAgentSessionRecord, - CustomWorldAgentSessionStore, -} from './customWorldAgentSessionStore.js'; -import { CustomWorldAgentSuggestedActionService } from './customWorldAgentSuggestedActionService.js'; -import { - buildEightAnchorContentFromCreatorIntent, - estimateProgressPercentFromAnchorContent, -} from './eightAnchorCompatibilityService.js'; -import { EightAnchorSingleTurnService } from './eightAnchorSingleTurnService.js'; -import { buildRpgWorldPreviewEnvelope } from './RpgWorldPreviewCompiler.js'; -import { buildRpgCreationPreviewProfileFromDraftProfile } from './rpgCreationPreviewProfileBuilder.js'; -import type { UpstreamLlmClient } from './llmClient.js'; -import type { RpgWorldProfileRepositoryPort } from '../repositories/RpgWorldProfileRepository.js'; -import type { UserRepositoryPort } from '../repositories/userRepository.js'; - -const PHASE2_FORCE_FAIL_TOKEN = '__phase1_force_fail__'; -function truncateText(value: string, maxLength: number) { - if (value.length <= maxLength) { - return value; - } - - return `${value.slice(0, Math.max(0, maxLength - 1)).trim()}…`; -} - -function sleep(ms: number) { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - -function buildOperation(type: CustomWorldAgentOperationRecord['type']) { - const phaseDetail = - type === 'draft_foundation' - ? '正在把已确认设定编成第一版世界底稿。' - : type === 'update_draft_card' - ? '正在把这次设定改动写回草稿。' - : type === 'sync_result_profile' - ? '正在把结果页里的世界快照同步回当前草稿。' - : type === 'generate_characters' - ? '正在围绕当前底稿补出新角色。' - : type === 'generate_landmarks' - ? '正在围绕当前底稿补出新地点。' - : type === 'generate_role_assets' - ? '正在准备角色资产工坊入口。' - : type === 'sync_role_assets' - ? '正在把角色资产结果写回世界草稿。' - : '正在整理这一轮新增的世界设定。'; - - return { - operationId: `operation-${crypto.randomBytes(10).toString('hex')}`, - type, - status: 'queued', - phaseLabel: '已接收请求', - phaseDetail, - progress: 10, - error: null, - } satisfies CustomWorldAgentOperationRecord; -} - -function buildUserMessage( - text: string, - clientMessageId: string, -): CustomWorldAgentMessage { - return { - id: - clientMessageId.trim() || - `message-${crypto.randomBytes(8).toString('hex')}`, - role: 'user', - kind: 'chat', - text, - createdAt: new Date().toISOString(), - relatedOperationId: null, - }; -} - -function buildRoleAssetSyncResultText(params: { - roleName: string; - assetStatusLabel: string; -}) { - return `已把「${params.roleName}」的角色资产写回草稿,当前状态:${params.assetStatusLabel}。`; -} - -function buildQuestionLines( - pendingClarifications: CustomWorldPendingClarification[], -) { - return pendingClarifications.map((entry) => entry.question.trim()); -} - -function composeAssistantReply(params: { - openingText: string; - intent: CustomWorldCreatorIntentRecord; - pendingClarifications: CustomWorldPendingClarification[]; - isReady: boolean; -}) { - const questionLines = buildQuestionLines(params.pendingClarifications); - - return [ - params.openingText, - params.isReady - ? '当前设定已经齐备。' - : questionLines.slice(0, 1).join('\n'), - ].join('\n'); -} - -function buildWelcomeMessage(params: { - seedText: string; - intent: CustomWorldCreatorIntentRecord; - pendingClarifications: CustomWorldPendingClarification[]; - isReady: boolean; -}) { - let openingText: string; - - if (params.seedText) { - openingText = `收到:${truncateText(params.seedText, 88)}`; - } else { - // When user enters without saying anything, provide a welcoming introduction - const hasAnyAnchors = hasMeaningfulCreatorIntentRecord(params.intent); - openingText = hasAnyAnchors - ? '继续聊聊你的世界设定吧。' - : '你好!我是你的世界设定助手,可以帮你一起构建游戏世界的核心设定。'; - } - - return composeAssistantReply({ - openingText, - intent: params.intent, - pendingClarifications: params.pendingClarifications, - isReady: params.isReady, - }); -} - -function writeSseEvent( - response: Response, - event: string, - data: unknown, -) { - if (response.writableEnded) { - return; - } - - response.write(`event: ${event}\n`); - response.write(`data: ${JSON.stringify(data)}\n\n`); -} - -/** - * 发布 readiness 校验和正式写库复用同一个服务。 - * 当运行环境还没接入真实作品仓储时,真正执行发布动作会在这里统一抛出明确错误。 - */ -function createUnavailablePublishingRepository(): RpgWorldProfileRepositoryPort { - const throwUnavailable = async () => { - throw new Error('当前环境还没有注入发布仓储,暂时无法执行世界发布。'); - }; - - return { - listOwnProfiles: throwUnavailable, - upsertOwnProfile: throwUnavailable, - syncProfileFromSnapshot: throwUnavailable, - softDeleteOwnProfile: throwUnavailable, - publishOwnProfile: throwUnavailable, - unpublishOwnProfile: throwUnavailable, - listPublishedGallery: throwUnavailable, - getPublishedGalleryDetail: throwUnavailable, - }; -} - -export class CustomWorldAgentOrchestrator { - private readonly foundationDraftService: CustomWorldAgentFoundationDraftService; - - private readonly draftCompiler: CustomWorldAgentDraftCompiler; - - private readonly entityGenerationService: CustomWorldAgentEntityGenerationService; - - private readonly changeSummaryService: CustomWorldAgentChangeSummaryService; - - private readonly assetBridgeService: CustomWorldAgentAssetBridgeService; - - private readonly autoAssetService: CustomWorldAgentAutoAssetService | null; - - private readonly eightAnchorSingleTurnService: EightAnchorSingleTurnService; - - private readonly suggestedActionService: CustomWorldAgentSuggestedActionService; - - private readonly qualityGateService: CustomWorldAgentQualityGateService; - - private readonly snapshotBuilder: CustomWorldAgentSnapshotBuilder; - - private readonly resultSyncService: CustomWorldAgentResultSyncService; - - private readonly publishingService: CustomWorldAgentPublishingService; - - private readonly actionRegistry: CustomWorldAgentActionRegistry; - - private readonly messageTurnService: CustomWorldAgentMessageTurnService; - - constructor( - private readonly sessionStore: CustomWorldAgentSessionStore, - llmClient: UpstreamLlmClient | null = null, - options: { - singleTurnLlmClient?: UpstreamLlmClient | null; - autoAssetService?: CustomWorldAgentAutoAssetService | null; - userRepository?: UserRepositoryPort | null; - resolveAuthorDisplayName?: ((userId: string) => Promise) | null; - rpgWorldProfileRepository?: RpgWorldProfileRepositoryPort | null; - } = {}, - ) { - this.foundationDraftService = new CustomWorldAgentFoundationDraftService( - llmClient, - ); - this.draftCompiler = new CustomWorldAgentDraftCompiler(); - this.entityGenerationService = new CustomWorldAgentEntityGenerationService( - llmClient, - ); - this.changeSummaryService = new CustomWorldAgentChangeSummaryService(); - this.assetBridgeService = new CustomWorldAgentAssetBridgeService(); - this.autoAssetService = options.autoAssetService ?? null; - this.eightAnchorSingleTurnService = new EightAnchorSingleTurnService( - (options.singleTurnLlmClient ?? llmClient) ?? undefined, - ); - this.suggestedActionService = new CustomWorldAgentSuggestedActionService(); - this.qualityGateService = new CustomWorldAgentQualityGateService(); - this.snapshotBuilder = new CustomWorldAgentSnapshotBuilder( - this.draftCompiler, - this.suggestedActionService, - this.qualityGateService, - ); - this.resultSyncService = new CustomWorldAgentResultSyncService(); - this.publishingService = new CustomWorldAgentPublishingService( - options.rpgWorldProfileRepository ?? createUnavailablePublishingRepository(), - ); - const resolveAuthorDisplayName = - options.resolveAuthorDisplayName ?? - (options.userRepository - ? async (userId: string) => { - const user = await options.userRepository?.findById(userId); - return user?.displayName?.trim() || '玩家'; - } - : null); - this.messageTurnService = new CustomWorldAgentMessageTurnService( - this.sessionStore, - this.eightAnchorSingleTurnService, - this.suggestedActionService, - this.snapshotBuilder, - ); - this.actionRegistry = new CustomWorldAgentActionRegistry( - createCustomWorldAgentActionExecutorMap({ - sessionStore: this.sessionStore, - foundationDraftService: this.foundationDraftService, - draftCompiler: this.draftCompiler, - entityGenerationService: this.entityGenerationService, - changeSummaryService: this.changeSummaryService, - assetBridgeService: this.assetBridgeService, - autoAssetService: this.autoAssetService, - snapshotBuilder: this.snapshotBuilder, - resultSyncService: this.resultSyncService, - publishingService: this.publishingService, - resolveAuthorDisplayName, - }), - ); - } - - async createSession( - userId: string, - payload: CreateCustomWorldAgentSessionRequest, - ): Promise { - const seedText = payload.seedText?.trim() ?? ''; - const baseIntent = createEmptyCreatorIntentRecord('freeform'); - const seedPatch = seedText - ? extractCreatorIntentPatch({ - currentIntent: baseIntent, - latestUserMessage: seedText, - }) - : {}; - const creatorIntent = mergeCreatorIntentRecord(baseIntent, seedPatch); - const initialState = this.messageTurnService.deriveInitialSessionState({ - seedText, - creatorIntent, - }); - const progressPercent = seedText - ? estimateProgressPercentFromAnchorContent(initialState.anchorContent) - : 0; - const fallbackWelcomeMessage = buildWelcomeMessage({ - seedText, - intent: creatorIntent, - pendingClarifications: initialState.pendingClarifications, - isReady: initialState.readiness.isReady, - }); - - const record = await this.sessionStore.create(userId, { - seedText, - welcomeMessage: fallbackWelcomeMessage, - currentTurn: 0, - anchorContent: initialState.anchorContent, - progressPercent, - lastAssistantReply: fallbackWelcomeMessage, - creatorIntent, - creatorIntentReadiness: initialState.readiness, - anchorPack: initialState.anchorPack, - draftProfile: initialState.draftProfile, - pendingClarifications: initialState.pendingClarifications, - stage: progressPercent >= 100 ? 'foundation_review' : 'collecting_intent', - suggestedActions: initialState.suggestedActions, - recommendedReplies: [], - }); - - return (await this.getSessionSnapshot( - userId, - record.sessionId, - )) as CustomWorldAgentSessionSnapshot; - } - - async getSessionSnapshot(userId: string, sessionId: string) { - const sessionRecord = await this.sessionStore.get(userId, sessionId); - if (!sessionRecord) { - return null; - } - - return this.buildSessionSnapshot(sessionRecord); - } - - async submitMessage( - userId: string, - sessionId: string, - payload: SendCustomWorldAgentMessageRequest, - ): Promise { - const session = await this.sessionStore.get(userId, sessionId); - if (!session) { - throw notFound('custom world agent session not found'); - } - - const trimmedText = payload.text.trim(); - const operation = buildOperation('process_message'); - await this.sessionStore.createOperation(userId, sessionId, operation); - await this.sessionStore.appendMessage( - userId, - sessionId, - buildUserMessage(trimmedText, payload.clientMessageId), - ); - - void this.processMessageOperation({ - userId, - sessionId, - operationId: operation.operationId, - latestUserText: trimmedText, - quickFillRequested: Boolean(payload.quickFillRequested), - }); - - return { - operation, - }; - } - - async streamMessage(params: { - request: Request; - response: Response; - userId: string; - sessionId: string; - payload: SendCustomWorldAgentMessageRequest; - }) { - const session = await this.sessionStore.get(params.userId, params.sessionId); - if (!session) { - throw notFound('custom world agent session not found'); - } - - prepareEventStreamResponse(params.request, params.response); - - const trimmedText = params.payload.text.trim(); - const userMessage = buildUserMessage( - trimmedText, - params.payload.clientMessageId, - ); - await this.sessionStore.appendMessage( - params.userId, - params.sessionId, - userMessage, - ); - - let latestReplyText = ''; - - try { - const nextSession = await this.applyMessageTurn({ - userId: params.userId, - sessionId: params.sessionId, - latestUserText: trimmedText, - quickFillRequested: Boolean(params.payload.quickFillRequested), - relatedOperationId: null, - onReplyUpdate: (text) => { - if (!text.trim() || text === latestReplyText) { - return; - } - - latestReplyText = text; - writeSseEvent(params.response, 'reply_delta', { - text, - }); - }, - }); - - writeSseEvent(params.response, 'session', { - session: nextSession, - }); - writeSseEvent(params.response, 'done', { - ok: true, - }); - } catch (error) { - writeSseEvent(params.response, 'error', { - message: - error instanceof Error ? error.message : 'stream custom world message failed', - }); - } finally { - params.response.end(); - } - } - - async executeAction( - userId: string, - sessionId: string, - payload: CustomWorldAgentActionRequest, - ): Promise { - const session = await this.sessionStore.get(userId, sessionId); - if (!session) { - throw notFound('custom world agent session not found'); - } - - const preparedExecution = this.actionRegistry.prepareExecution( - session, - payload, - ); - const operation = buildOperation(preparedExecution.operationType); - await this.sessionStore.createOperation(userId, sessionId, operation); - void preparedExecution.execute({ - userId, - sessionId, - operationId: operation.operationId, - }); - - return { - operation, - }; - } - - async getOperation(userId: string, sessionId: string, operationId: string) { - return this.sessionStore.getOperation(userId, sessionId, operationId); - } - - async getCardDetail(userId: string, sessionId: string, cardId: string) { - const session = await this.sessionStore.get(userId, sessionId); - if (!session) { - return null; - } - - return this.draftCompiler.getDraftCardDetail(session.draftProfile, cardId); - } - - private async applyMessageTurn(params: { - userId: string; - sessionId: string; - latestUserText: string; - quickFillRequested: boolean; - relatedOperationId?: string | null; - onReplyUpdate?: (text: string) => void; - }) { - await this.messageTurnService.applyMessageTurn(params); - return this.getSessionSnapshot(params.userId, params.sessionId); - } - - /** - * 统一 session snapshot 的读模型装配口径,避免普通拉取、SSE 流和内部调用返回不同字段集合。 - */ - private buildSessionSnapshot( - sessionRecord: CustomWorldAgentSessionRecord, - ): CustomWorldAgentSessionSnapshot { - const snapshot = { - sessionId: sessionRecord.sessionId, - currentTurn: sessionRecord.currentTurn, - anchorContent: sessionRecord.anchorContent, - progressPercent: sessionRecord.progressPercent, - lastAssistantReply: sessionRecord.lastAssistantReply, - stage: sessionRecord.stage, - focusCardId: sessionRecord.focusCardId, - creatorIntent: sessionRecord.creatorIntent, - creatorIntentReadiness: sessionRecord.creatorIntentReadiness, - anchorPack: sessionRecord.anchorPack, - lockState: sessionRecord.lockState, - draftProfile: sessionRecord.draftProfile, - messages: sessionRecord.messages, - draftCards: sessionRecord.draftCards, - pendingClarifications: sessionRecord.pendingClarifications, - suggestedActions: sessionRecord.suggestedActions, - recommendedReplies: sessionRecord.recommendedReplies, - qualityFindings: sessionRecord.qualityFindings, - assetCoverage: sessionRecord.assetCoverage, - checkpoints: sessionRecord.checkpoints.map((checkpoint) => ({ - checkpointId: checkpoint.checkpointId, - createdAt: checkpoint.createdAt, - label: checkpoint.label, - })), - supportedActions: this.actionRegistry.buildSupportedActions(sessionRecord), - resultPreview: this.buildResultPreview(sessionRecord), - updatedAt: sessionRecord.updatedAt, - } satisfies CustomWorldAgentSessionSnapshot; - - return snapshot; - } - - /** - * 当前仍输出 legacy-compatible preview envelope,但正式把它接入 session snapshot 主链, - * 为后续结果页切换到服务端 preview 数据源提供稳定入口。 - */ - private buildResultPreview( - sessionRecord: CustomWorldAgentSessionRecord, - ): CustomWorldAgentSessionSnapshot['resultPreview'] { - const draftProfile = - sessionRecord.draftProfile && - typeof sessionRecord.draftProfile === 'object' && - !Array.isArray(sessionRecord.draftProfile) - ? (sessionRecord.draftProfile as Record) - : null; - - if (!draftProfile) { - return null; - } - - if (!normalizeFoundationDraftProfile(draftProfile)) { - return null; - } - - try { - const publishGate = this.publishingService.summarizePublishGate({ - sessionId: sessionRecord.sessionId, - stage: sessionRecord.stage, - draftProfile, - qualityFindings: sessionRecord.qualityFindings, - }); - const previewProfile = buildRpgCreationPreviewProfileFromDraftProfile({ - sessionId: sessionRecord.sessionId, - draftProfile, - profileId: publishGate.profileId, - }); - - return { - ...buildRpgWorldPreviewEnvelope( - previewProfile, - String(previewProfile.settingText ?? ''), - ), - generatedAt: sessionRecord.updatedAt, - qualityFindings: sessionRecord.qualityFindings.map((finding) => ({ - id: finding.id, - severity: finding.severity, - code: finding.code, - targetId: finding.targetId ?? null, - message: finding.message, - })), - blockers: publishGate.blockers, - publishReady: publishGate.publishReady, - canEnterWorld: publishGate.canEnterWorld, - }; - } catch { - return null; - } - } - - private async processMessageOperation(params: { - userId: string; - sessionId: string; - operationId: string; - latestUserText: string; - quickFillRequested: boolean; - }) { - const { - userId, - sessionId, - operationId, - latestUserText, - quickFillRequested, - } = params; - - try { - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'running', - phaseLabel: quickFillRequested ? '补全剩余设定' : '整理当前设定', - phaseDetail: quickFillRequested - ? '正在基于当前方向补齐剩余设定。' - : '正在把这轮输入沉淀成新的完整设定。', - progress: 45, - }); - - await sleep(30); - - if (latestUserText.includes(PHASE2_FORCE_FAIL_TOKEN)) { - throw new Error('phase2 forced failure'); - } - - const latestSession = (await this.sessionStore.get( - userId, - sessionId, - )) as CustomWorldAgentSessionRecord | null; - if (!latestSession) { - throw new Error('custom world agent session not found'); - } - - const shouldPreserveDraftStage = - (latestSession.stage === 'object_refining' || - latestSession.stage === 'visual_refining') && - latestSession.draftCards.length > 0; - - await this.applyMessageTurn({ - userId, - sessionId, - latestUserText, - quickFillRequested, - relatedOperationId: operationId, - }); - - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'completed', - phaseLabel: '设定已更新', - phaseDetail: shouldPreserveDraftStage - ? '这轮补充已挂回当前底稿语境,现有草稿卡保持可继续浏览。' - : quickFillRequested - ? '剩余设定已补全,现在可以进入游戏设定草稿生成。' - : '这一轮的设定更新已经完成。', - progress: 100, - error: null, - }); - } catch (error) { - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'failed', - phaseLabel: '处理失败', - phaseDetail: '这一轮消息没有成功沉淀为当前设定。', - progress: 100, - error: - error instanceof Error ? error.message : 'process message failed', - }); - } - } -} diff --git a/server-node/src/services/customWorldAgentPhase2.test.ts b/server-node/src/services/customWorldAgentPhase2.test.ts deleted file mode 100644 index f3087757..00000000 --- a/server-node/src/services/customWorldAgentPhase2.test.ts +++ /dev/null @@ -1,290 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { - buildPendingClarifications, - evaluateCreatorIntentReadiness, -} from './customWorldAgentClarificationService.js'; -import { - extractCreatorIntentPatch, - mergeCreatorIntentRecord, -} from './customWorldAgentIntentExtractionService.js'; -import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js'; -import { createInMemoryRpgWorldRepositoryPorts } from './customWorldAgentRepositoryTestHelpers.js'; -import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js'; -import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js'; -import { RpgWorldWorkSummaryService } from './RpgWorldWorkSummaryService.js'; - -async function waitForOperation( - orchestrator: CustomWorldAgentOrchestrator, - userId: string, - sessionId: string, - operationId: string, -) { - for (let attempt = 0; attempt < 40; attempt += 1) { - const operation = await orchestrator.getOperation( - userId, - sessionId, - operationId, - ); - - if (operation?.status === 'completed' || operation?.status === 'failed') { - return operation; - } - - await new Promise((resolve) => setTimeout(resolve, 20)); - } - - throw new Error('operation did not finish in time'); -} - -test('phase2 extractor can pull multiple creator intent anchors from natural language', () => { - const patch = extractCreatorIntentPatch({ - currentIntent: null, - latestUserMessage: - '玩家是被迫返乡的失职守灯人,开局时正站在即将熄灭的旧灯塔上。整体主题是海岛悬疑,气质冷峻克制。核心冲突是守灯会与沉船商盟争夺航道解释权。标志性元素是潮雾钟声、盐火灯塔。', - }); - - assert.match(patch.playerPremise ?? '', /守灯人/u); - assert.match(patch.openingSituation ?? '', /旧灯塔/u); - assert.ok(patch.themeKeywords?.some((entry) => /海岛|悬疑/u.test(entry))); - assert.ok(patch.toneDirectives?.some((entry) => /冷峻|克制/u.test(entry))); - assert.ok(patch.coreConflicts?.[0]?.includes('争夺航道解释权')); - assert.deepEqual(patch.iconicElements, ['潮雾钟声', '盐火灯塔']); -}); - -test('phase2 extractor marks explicit rewrite fields for merge replacement', () => { - const patch = extractCreatorIntentPatch({ - currentIntent: { - sourceMode: 'freeform', - rawSettingText: '', - worldHook: '一个被潮雾切开的列岛世界。', - themeKeywords: ['海岛'], - toneDirectives: ['冷峻'], - playerPremise: '', - openingSituation: '', - coreConflicts: ['守灯会与沉船商盟争夺航道解释权'], - keyFactions: [], - keyCharacters: [], - keyLandmarks: [], - iconicElements: [], - forbiddenDirectives: [], - }, - latestUserMessage: - '主题改成宫廷悬疑,核心冲突改为王庭继承人与旧灯塔盟约对抗。', - }); - - assert.ok(patch.replaceFields?.includes('themeKeywords')); - assert.ok(patch.replaceFields?.includes('coreConflicts')); - assert.ok(patch.themeKeywords?.some((entry) => /宫廷|悬疑/u.test(entry))); - assert.ok(patch.coreConflicts?.some((entry) => /王庭继承/u.test(entry))); -}); - -test('phase2 clarification service only keeps the top highest leverage gap', () => { - const readiness = evaluateCreatorIntentReadiness({ - sourceMode: 'freeform', - rawSettingText: '', - worldHook: '', - themeKeywords: [], - toneDirectives: [], - playerPremise: '', - openingSituation: '', - coreConflicts: [], - keyFactions: [], - keyCharacters: [], - keyLandmarks: [], - iconicElements: [], - forbiddenDirectives: [], - }); - const clarifications = buildPendingClarifications(null, readiness); - - assert.equal(clarifications.length, 1); - assert.equal(clarifications[0]?.targetKey, 'world_hook'); -}); - -test('phase2 orchestrator advances session to foundation_review when minimal anchors are complete', async () => { - const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); - const sessionStore = new CustomWorldAgentSessionStore( - rpgAgentSessionRepository, - ); - const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { - singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), - }); - const userId = 'user-phase2-ready'; - - const createdSession = await orchestrator.createSession(userId, { - seedText: '一个被潮雾切开的列岛世界。', - }); - - assert.equal(createdSession.stage, 'clarifying'); - assert.match( - String( - (createdSession.creatorIntent as Record)?.worldHook ?? - '', - ), - /列岛世界/u, - ); - assert.ok( - createdSession.messages[0]?.text.includes('1.') === false, - ); - - const message1 = await orchestrator.submitMessage( - userId, - createdSession.sessionId, - { - clientMessageId: 'client-1', - text: '玩家是被迫返乡的失职守灯人,开局时正站在即将熄灭的旧灯塔上。', - focusCardId: null, - selectedCardIds: [], - }, - ); - await waitForOperation( - orchestrator, - userId, - createdSession.sessionId, - message1.operation.operationId, - ); - - const message2 = await orchestrator.submitMessage( - userId, - createdSession.sessionId, - { - clientMessageId: 'client-2', - text: '整体主题是海岛悬疑,气质冷峻克制。核心冲突是守灯会与沉船商盟争夺航道解释权。关键人物叫沈砺,是玩家的旧友兼宿敌,其实暗地里在为沉船商盟引路。标志性元素是潮雾钟声、盐火灯塔。', - focusCardId: null, - selectedCardIds: [], - }, - ); - const operation = await waitForOperation( - orchestrator, - userId, - createdSession.sessionId, - message2.operation.operationId, - ); - const snapshot = await orchestrator.getSessionSnapshot( - userId, - createdSession.sessionId, - ); - - assert.equal(operation?.status, 'completed'); - assert.equal(snapshot?.stage, 'foundation_review'); - assert.equal(snapshot?.creatorIntentReadiness.isReady, true); - assert.deepEqual(snapshot?.pendingClarifications, []); - assert.match( - String( - (snapshot?.creatorIntent as Record)?.worldHook ?? '', - ), - /列岛世界/u, - ); - assert.ok( - snapshot?.messages.some( - (message) => - message.role === 'assistant' && - /进入下一阶段|生成游戏设定草稿/u.test(message.text), - ), - ); -}); - -test('phase2 work summaries compile draft title and summary from creator intent', async () => { - const { rpgAgentSessionRepository, rpgWorldProfileRepository } = - createInMemoryRpgWorldRepositoryPorts(); - const sessionStore = new CustomWorldAgentSessionStore( - rpgAgentSessionRepository, - ); - const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { - singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), - }); - const userId = 'user-phase2-summary'; - - const createdSession = await orchestrator.createSession(userId, { - seedText: '一个被潮雾切开的列岛世界。', - }); - - const update = await orchestrator.submitMessage( - userId, - createdSession.sessionId, - { - clientMessageId: 'client-summary', - text: '玩家是被迫返乡的失职守灯人,开局时正站在即将熄灭的旧灯塔上。核心冲突是守灯会与沉船商盟争夺航道解释权。', - focusCardId: null, - selectedCardIds: [], - }, - ); - await waitForOperation( - orchestrator, - userId, - createdSession.sessionId, - update.operation.operationId, - ); - - const items = await new RpgWorldWorkSummaryService( - rpgWorldProfileRepository, - sessionStore, - ).list(userId); - const draft = items.find( - (item) => item.sessionId === createdSession.sessionId, - ); - - assert.ok(draft); - assert.match(draft?.title ?? '', /列岛世界/u); - assert.match(draft?.summary ?? '', /守灯人/u); - assert.match(draft?.summary ?? '', /争夺航道解释权/u); -}); - -test('phase2 merge keeps existing anchors while applying new patch', () => { - const merged = mergeCreatorIntentRecord( - { - sourceMode: 'freeform', - rawSettingText: '一个被潮雾切开的列岛世界。', - worldHook: '一个被潮雾切开的列岛世界。', - themeKeywords: [], - toneDirectives: [], - playerPremise: '玩家是失职返乡的守灯人。', - openingSituation: '', - coreConflicts: [], - keyFactions: [], - keyCharacters: [], - keyLandmarks: [], - iconicElements: [], - forbiddenDirectives: [], - }, - { - coreConflicts: ['守灯会与沉船商盟争夺航道解释权'], - toneDirectives: ['冷峻'], - }, - ); - - assert.equal(merged.playerPremise, '玩家是失职返乡的守灯人。'); - assert.equal(merged.worldHook, '一个被潮雾切开的列岛世界。'); - assert.deepEqual(merged.coreConflicts, ['守灯会与沉船商盟争夺航道解释权']); - assert.deepEqual(merged.toneDirectives, ['冷峻']); -}); - -test('phase2 merge replaces explicit rewrite arrays instead of appending them', () => { - const merged = mergeCreatorIntentRecord( - { - sourceMode: 'freeform', - rawSettingText: '', - worldHook: '一个被潮雾切开的列岛世界。', - themeKeywords: ['海岛', '旧案'], - toneDirectives: ['冷峻'], - playerPremise: '', - openingSituation: '', - coreConflicts: ['守灯会与沉船商盟争夺航道解释权'], - keyFactions: [], - keyCharacters: [], - keyLandmarks: [], - iconicElements: [], - forbiddenDirectives: [], - }, - { - themeKeywords: ['宫廷', '悬疑'], - coreConflicts: ['王庭继承人与旧灯塔盟约对抗'], - replaceFields: ['themeKeywords', 'coreConflicts'], - }, - ); - - assert.deepEqual(merged.themeKeywords, ['宫廷', '悬疑']); - assert.deepEqual(merged.coreConflicts, ['王庭继承人与旧灯塔盟约对抗']); - assert.deepEqual(merged.toneDirectives, ['冷峻']); -}); diff --git a/server-node/src/services/customWorldAgentPhase3.test.ts b/server-node/src/services/customWorldAgentPhase3.test.ts deleted file mode 100644 index e572f998..00000000 --- a/server-node/src/services/customWorldAgentPhase3.test.ts +++ /dev/null @@ -1,444 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import test from 'node:test'; - -import type { AppConfig } from '../config.js'; -import { CustomWorldAgentAutoAssetService } from './customWorldAgentAutoAssetService.js'; -import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js'; -import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js'; -import { createInMemoryRpgWorldRepositoryPorts } from './customWorldAgentRepositoryTestHelpers.js'; -import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js'; -import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js'; -import { RpgWorldWorkSummaryService } from './RpgWorldWorkSummaryService.js'; - -function createAutoAssetTestConfig(testName: string): AppConfig { - const projectRoot = fs.mkdtempSync( - path.join(os.tmpdir(), `genarrative-agent-phase3-${testName}-`), - ); - - return { - nodeEnv: 'test', - projectRoot, - publicDir: path.join(projectRoot, 'public'), - logsDir: path.join(projectRoot, 'logs'), - dataDir: path.join(projectRoot, 'data'), - rawEnv: {}, - databaseUrl: `pg-mem://${testName}`, - serverAddr: ':0', - logLevel: 'silent', - editorApiEnabled: true, - assetsApiEnabled: true, - jwtSecret: 'test', - jwtExpiresIn: '7d', - jwtIssuer: 'test', - llm: { - baseUrl: 'https://example.invalid', - apiKey: '', - model: 'test-model', - }, - dashScope: { - baseUrl: 'https://example.invalid', - apiKey: '', - imageModel: 'test-image-model', - requestTimeoutMs: 1000, - }, - smsAuth: { - enabled: false, - provider: 'mock', - endpoint: '', - accessKeyId: '', - accessKeySecret: '', - signName: '', - templateCode: '', - templateParamKey: '', - countryCode: '86', - schemeName: '', - codeLength: 6, - codeType: 1, - validTimeSeconds: 300, - intervalSeconds: 60, - duplicatePolicy: 1, - caseAuthPolicy: 1, - returnVerifyCode: false, - mockVerifyCode: '123456', - maxSendPerPhonePerDay: 20, - maxSendPerIpPerHour: 30, - maxVerifyFailuresPerPhonePerHour: 12, - maxVerifyFailuresPerIpPerHour: 24, - captchaTtlSeconds: 180, - captchaTriggerVerifyFailuresPerPhone: 3, - captchaTriggerVerifyFailuresPerIp: 5, - blockPhoneFailureThreshold: 6, - blockIpFailureThreshold: 10, - blockPhoneDurationMinutes: 30, - blockIpDurationMinutes: 30, - }, - wechatAuth: { - enabled: false, - provider: 'mock', - appId: '', - appSecret: '', - authorizeEndpoint: '', - accessTokenEndpoint: '', - userInfoEndpoint: '', - callbackPath: '', - defaultRedirectPath: '/', - mockUserId: '', - mockUnionId: '', - mockDisplayName: '', - mockAvatarUrl: '', - }, - authSession: { - accessCookieName: 'genarrative_access_session', - accessCookieTtlSeconds: 7200, - accessCookieSecure: false, - accessCookieSameSite: 'Lax', - accessCookiePath: '/', - refreshCookieName: 'refresh_token', - refreshSessionTtlDays: 30, - refreshCookieSecure: false, - refreshCookieSameSite: 'Lax', - refreshCookiePath: '/', - }, - }; -} - -function createFallbackAutoAssetService(testName: string) { - const config = createAutoAssetTestConfig(testName); - return new CustomWorldAgentAutoAssetService( - config, - CustomWorldAgentAutoAssetService.createFallbackCharacterVisualGenerator(config), - CustomWorldAgentAutoAssetService.createFallbackSceneActBackgroundGenerator(config), - ); -} - -async function waitForOperation( - orchestrator: CustomWorldAgentOrchestrator, - userId: string, - sessionId: string, - operationId: string, -) { - for (let attempt = 0; attempt < 50; attempt += 1) { - const operation = await orchestrator.getOperation( - userId, - sessionId, - operationId, - ); - - if (operation?.status === 'completed' || operation?.status === 'failed') { - return operation; - } - - await new Promise((resolve) => setTimeout(resolve, 20)); - } - - throw new Error('operation did not finish in time'); -} - -async function createReadySession( - orchestrator: CustomWorldAgentOrchestrator, - userId: string, -) { - const createdSession = await orchestrator.createSession(userId, { - seedText: '一个被潮雾切开的列岛世界。', - }); - - const message1 = await orchestrator.submitMessage(userId, createdSession.sessionId, { - clientMessageId: 'phase3-ready-1', - text: '玩家是被迫返乡的失职守灯人,开局时正站在即将熄灭的旧灯塔上。', - focusCardId: null, - selectedCardIds: [], - }); - await waitForOperation( - orchestrator, - userId, - createdSession.sessionId, - message1.operation.operationId, - ); - - const message2 = await orchestrator.submitMessage(userId, createdSession.sessionId, { - clientMessageId: 'phase3-ready-2', - text: '整体主题是海岛悬疑,气质冷峻克制。核心冲突是守灯会与沉船商盟争夺航道解释权。关键人物叫沈砺,是玩家的旧友兼宿敌,其实暗地里在为沉船商盟引路。标志性元素是潮雾钟声、盐火灯塔。', - focusCardId: null, - selectedCardIds: [], - }); - await waitForOperation( - orchestrator, - userId, - createdSession.sessionId, - message2.operation.operationId, - ); - - const readySession = await orchestrator.getSessionSnapshot( - userId, - createdSession.sessionId, - ); - - assert.equal(readySession?.stage, 'foundation_review'); - assert.equal(readySession?.creatorIntentReadiness.isReady, true); - assert.equal(readySession?.resultPreview, null); - assert.equal( - readySession?.supportedActions?.find( - (entry) => entry.action === 'draft_foundation', - )?.enabled, - true, - ); - assert.equal( - readySession?.supportedActions?.find( - (entry) => entry.action === 'sync_result_profile', - )?.enabled, - false, - ); - - return readySession!; -} - -test('phase3 ready session can execute draft_foundation and expose card detail', async () => { - const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); - const sessionStore = new CustomWorldAgentSessionStore( - rpgAgentSessionRepository, - ); - const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { - singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), - autoAssetService: createFallbackAutoAssetService('draft'), - }); - const userId = 'user-phase3-draft'; - const readySession = await createReadySession(orchestrator, userId); - - const response = await orchestrator.executeAction( - userId, - readySession.sessionId, - { - action: 'draft_foundation', - }, - ); - const operation = await waitForOperation( - orchestrator, - userId, - readySession.sessionId, - response.operation.operationId, - ); - const snapshot = await orchestrator.getSessionSnapshot(userId, readySession.sessionId); - const draftProfile = snapshot?.draftProfile as Record | undefined; - const playableNpcs = Array.isArray(draftProfile?.playableNpcs) - ? draftProfile?.playableNpcs - : []; - const storyNpcs = Array.isArray(draftProfile?.storyNpcs) - ? draftProfile?.storyNpcs - : []; - const sceneChapters = Array.isArray(draftProfile?.sceneChapters) - ? draftProfile?.sceneChapters - : []; - - assert.equal(operation?.status, 'completed'); - assert.equal(snapshot?.stage, 'object_refining'); - assert.ok(snapshot?.draftCards.length); - assert.equal(snapshot?.resultPreview?.source, 'session_preview'); - assert.equal( - snapshot?.resultPreview?.preview.name, - typeof (snapshot?.draftProfile as Record)?.name === 'string' - ? ((snapshot?.draftProfile as Record).name as string) - : '未命名世界底稿', - ); - assert.ok(Array.isArray(snapshot?.resultPreview?.blockers)); - assert.ok((snapshot?.resultPreview?.blockers?.length ?? 0) >= 0); - assert.equal(snapshot?.resultPreview?.publishReady, false); - assert.equal(snapshot?.resultPreview?.canEnterWorld, false); - assert.equal( - snapshot?.resultPreview?.qualityFindings?.length, - snapshot?.qualityFindings.length, - ); - assert.ok(snapshot?.draftCards.some((card) => card.kind === 'world')); - assert.ok(snapshot?.draftCards.some((card) => card.kind === 'faction')); - assert.ok(snapshot?.draftCards.some((card) => card.kind === 'character')); - assert.ok(snapshot?.draftCards.some((card) => card.kind === 'landmark')); - assert.ok(snapshot?.draftCards.some((card) => card.kind === 'thread')); - assert.ok(snapshot?.draftCards.some((card) => card.kind === 'chapter')); - assert.equal(playableNpcs.length, 1); - assert.ok(storyNpcs.length >= 4); - assert.equal(sceneChapters.length, 2); - assert.ok( - sceneChapters.every( - (entry) => Array.isArray((entry as { acts?: unknown[] }).acts) && ((entry as { acts?: unknown[] }).acts?.length ?? 0) === 3, - ), - ); - assert.ok( - playableNpcs.every( - (entry) => - typeof (entry as { imageSrc?: unknown }).imageSrc === 'string' && - typeof (entry as { generatedVisualAssetId?: unknown }).generatedVisualAssetId === 'string', - ), - ); - assert.ok((snapshot?.assetCoverage.sceneAssets.length ?? 0) >= 6); - assert.equal(snapshot?.assetCoverage.allSceneAssetsReady, true); - assert.equal( - typeof (snapshot?.draftProfile as Record)?.name, - 'string', - ); - assert.ok( - snapshot?.messages.some( - (message) => - message.role === 'assistant' && - message.text.includes('第一版世界底稿整理出来了'), - ), - ); - assert.equal( - snapshot?.supportedActions?.find( - (entry) => entry.action === 'update_draft_card', - )?.enabled, - true, - ); - assert.equal( - snapshot?.supportedActions?.find( - (entry) => entry.action === 'generate_role_assets', - )?.enabled, - true, - ); - assert.equal( - snapshot?.supportedActions?.find( - (entry) => entry.action === 'publish_world', - )?.enabled, - true, - ); - - const worldCard = snapshot?.draftCards.find((card) => card.kind === 'world'); - assert.ok(worldCard); - - const detail = await orchestrator.getCardDetail( - userId, - readySession.sessionId, - worldCard!.id, - ); - - assert.ok(detail); - assert.equal(detail?.kind, 'world'); - assert.ok(detail?.sections.length); - assert.ok(detail?.sections.some((section) => section.label === '世界一句话')); -}); - -test('phase3 draft_foundation rejects not-ready session', async () => { - const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); - const sessionStore = new CustomWorldAgentSessionStore( - rpgAgentSessionRepository, - ); - const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { - singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), - autoAssetService: createFallbackAutoAssetService('not-ready'), - }); - const userId = 'user-phase3-not-ready'; - const createdSession = await orchestrator.createSession(userId, { - seedText: '一个被潮雾切开的列岛世界。', - }); - - await assert.rejects( - () => - orchestrator.executeAction(userId, createdSession.sessionId, { - action: 'draft_foundation', - }), - /progressPercent >= 100|draft_foundation/u, - ); -}); - -test('phase3 work summaries prefer compiled foundation draft fields', async () => { - const { rpgAgentSessionRepository, rpgWorldProfileRepository } = - createInMemoryRpgWorldRepositoryPorts(); - const sessionStore = new CustomWorldAgentSessionStore( - rpgAgentSessionRepository, - ); - const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { - singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), - autoAssetService: createFallbackAutoAssetService('summary'), - }); - const userId = 'user-phase3-summary'; - const readySession = await createReadySession(orchestrator, userId); - - const response = await orchestrator.executeAction( - userId, - readySession.sessionId, - { - action: 'draft_foundation', - }, - ); - await waitForOperation( - orchestrator, - userId, - readySession.sessionId, - response.operation.operationId, - ); - - const items = await new RpgWorldWorkSummaryService( - rpgWorldProfileRepository, - sessionStore, - ).list(userId); - const draft = items.find((item) => item.sessionId === readySession.sessionId); - const compiledProfile = normalizeFoundationDraftProfile( - ( - await orchestrator.getSessionSnapshot(userId, readySession.sessionId) - )?.draftProfile, - ); - const totalRoleCount = [ - ...new Set( - [ - ...(compiledProfile?.playableNpcs ?? []), - ...(compiledProfile?.storyNpcs ?? []), - ].map((entry) => entry.id), - ), - ].length; - - assert.ok(draft); - assert.equal(draft?.playableNpcCount ?? 0, totalRoleCount); - assert.equal(draft?.landmarkCount ?? 0, 2); - assert.match(draft?.summary ?? '', /潮雾|守灯|航道/u); - assert.match(draft?.subtitle ?? '', /守灯|冲突|列岛/u); -}); - -test('phase3 draft foundation still completes when auto asset generation fails', async () => { - const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); - const sessionStore = new CustomWorldAgentSessionStore( - rpgAgentSessionRepository, - ); - const autoAssetService = new CustomWorldAgentAutoAssetService( - createAutoAssetTestConfig('asset-failure'), - async () => { - throw new Error('visual service timeout'); - }, - async () => { - throw new Error('scene service timeout'); - }, - ); - const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { - singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), - autoAssetService, - }); - const userId = 'user-phase3-asset-failure'; - const readySession = await createReadySession(orchestrator, userId); - - const response = await orchestrator.executeAction( - userId, - readySession.sessionId, - { - action: 'draft_foundation', - }, - ); - const operation = await waitForOperation( - orchestrator, - userId, - readySession.sessionId, - response.operation.operationId, - ); - const snapshot = await orchestrator.getSessionSnapshot(userId, readySession.sessionId); - - assert.equal(operation?.status, 'completed'); - assert.doesNotMatch(operation?.phaseDetail ?? '', /资产补齐待后续处理/u); - assert.ok(snapshot?.draftCards.length); - assert.ok( - snapshot?.messages.every( - (message) => - message.role !== 'assistant' || !message.text.includes('资产补齐未完成'), - ), - ); - assert.equal(snapshot?.assetCoverage.allRoleAssetsReady, true); - assert.equal(snapshot?.assetCoverage.allSceneAssetsReady, true); -}); diff --git a/server-node/src/services/customWorldAgentPhase4.test.ts b/server-node/src/services/customWorldAgentPhase4.test.ts deleted file mode 100644 index b2b1453c..00000000 --- a/server-node/src/services/customWorldAgentPhase4.test.ts +++ /dev/null @@ -1,705 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js'; -import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js'; -import { createInMemoryRpgWorldRepositoryPorts } from './customWorldAgentRepositoryTestHelpers.js'; -import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js'; -import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js'; -import { RpgWorldWorkSummaryService } from './RpgWorldWorkSummaryService.js'; - -async function waitForOperation( - orchestrator: CustomWorldAgentOrchestrator, - userId: string, - sessionId: string, - operationId: string, -) { - for (let attempt = 0; attempt < 60; attempt += 1) { - const operation = await orchestrator.getOperation( - userId, - sessionId, - operationId, - ); - - if (operation?.status === 'completed' || operation?.status === 'failed') { - return operation; - } - - await new Promise((resolve) => setTimeout(resolve, 20)); - } - - throw new Error('operation did not finish in time'); -} - -async function createObjectRefiningSession( - orchestrator: CustomWorldAgentOrchestrator, - userId: string, -) { - const createdSession = await orchestrator.createSession(userId, { - seedText: '一个被潮雾切开的列岛世界。', - }); - - const message1 = await orchestrator.submitMessage(userId, createdSession.sessionId, { - clientMessageId: 'phase4-ready-1', - text: '玩家是被迫返乡的失职守灯人,开局时正站在即将熄灭的旧灯塔上。', - focusCardId: null, - selectedCardIds: [], - }); - await waitForOperation( - orchestrator, - userId, - createdSession.sessionId, - message1.operation.operationId, - ); - - const message2 = await orchestrator.submitMessage(userId, createdSession.sessionId, { - clientMessageId: 'phase4-ready-2', - text: '整体主题是海岛悬疑,气质冷峻克制。核心冲突是守灯会与沉船商盟争夺航道解释权。关键人物叫沈砺,是玩家的旧友兼宿敌,其实暗地里在为沉船商盟引路。标志性元素是潮雾钟声、盐火灯塔。', - focusCardId: null, - selectedCardIds: [], - }); - await waitForOperation( - orchestrator, - userId, - createdSession.sessionId, - message2.operation.operationId, - ); - - const foundationOperation = await orchestrator.executeAction( - userId, - createdSession.sessionId, - { - action: 'draft_foundation', - }, - ); - await waitForOperation( - orchestrator, - userId, - createdSession.sessionId, - foundationOperation.operation.operationId, - ); - - return (await orchestrator.getSessionSnapshot( - userId, - createdSession.sessionId, - ))!; -} - -test('phase4 update_draft_card writes back draft profile and recompiles summaries', async () => { - const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); - const sessionStore = new CustomWorldAgentSessionStore( - rpgAgentSessionRepository, - ); - const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { - singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), - }); - const userId = 'user-phase4-edit'; - const session = await createObjectRefiningSession(orchestrator, userId); - const characterCard = session.draftCards.find((card) => card.kind === 'character'); - - assert.ok(characterCard); - - const response = await orchestrator.executeAction(userId, session.sessionId, { - action: 'update_draft_card', - cardId: characterCard!.id, - sections: [ - { - sectionId: 'publicMask', - value: '表面上仍是守灯会里最懂旧航道的人。', - }, - { - sectionId: 'relationToPlayer', - value: '和玩家共享一段无法轻易翻篇的旧灯塔往事。', - }, - { - sectionId: 'summary', - value: '他像旧友,也像最早知道航道秘密的人。', - }, - ], - }); - const operation = await waitForOperation( - orchestrator, - userId, - session.sessionId, - response.operation.operationId, - ); - const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId); - const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile); - const editedCharacter = [...(profile?.playableNpcs ?? []), ...(profile?.storyNpcs ?? [])].find( - (entry) => entry.id === characterCard!.id, - ); - const editedCard = snapshot?.draftCards.find((card) => card.id === characterCard!.id); - - assert.equal(operation?.status, 'completed'); - assert.equal( - editedCharacter?.publicMask, - '表面上仍是守灯会里最懂旧航道的人。', - ); - assert.equal( - editedCharacter?.relationToPlayer, - '和玩家共享一段无法轻易翻篇的旧灯塔往事。', - ); - assert.equal(editedCard?.summary, '他像旧友,也像最早知道航道秘密的人。'); - assert.ok( - snapshot?.messages.some( - (message) => - message.kind === 'action_result' && message.text.includes('已更新'), - ), - ); -}); - -test('phase4 sync_result_profile writes result-page snapshot back into session draft chain', async () => { - const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); - const sessionStore = new CustomWorldAgentSessionStore( - rpgAgentSessionRepository, - ); - const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { - singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), - }); - const userId = 'user-phase4-sync-result-profile'; - const session = await createObjectRefiningSession(orchestrator, userId); - - const response = await orchestrator.executeAction(userId, session.sessionId, { - action: 'sync_result_profile', - profile: { - id: `agent-draft-${session.sessionId}`, - settingText: '被海雾吞没的旧航路群岛', - name: '潮雾列岛·结果页精修版', - subtitle: '旧灯塔与失控航路', - summary: '结果页已经把世界概述继续往沉船夜暗线收紧。', - tone: '压抑、潮湿、悬疑', - playerGoal: '查清沉船夜与假航灯的真正操盘者。', - templateWorldType: 'WUXIA', - majorFactions: ['守灯会', '航运公会'], - coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], - attributeSchema: { - id: 'schema:test', - worldId: 'CUSTOM', - schemaVersion: 1, - schemaName: '测试', - generatedFrom: { - worldType: 'CUSTOM', - worldName: '潮雾列岛·结果页精修版', - settingSummary: '测试', - tone: '测试', - conflictCore: '测试', - }, - slots: [], - }, - playableNpcs: [], - storyNpcs: [], - items: [], - landmarks: [], - generationMode: 'full', - generationStatus: 'complete', - }, - }); - const operation = await waitForOperation( - orchestrator, - userId, - session.sessionId, - response.operation.operationId, - ); - const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId); - const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile); - const draftRecord = snapshot?.draftProfile as Record | null; - const legacyResultProfile = draftRecord?.legacyResultProfile as - | Record - | undefined; - - assert.equal(operation?.status, 'completed'); - assert.equal(profile?.name, '潮雾列岛·结果页精修版'); - assert.equal( - profile?.summary, - '结果页已经把世界概述继续往沉船夜暗线收紧。', - ); - assert.equal(snapshot?.resultPreview?.source, 'session_preview'); - assert.equal( - snapshot?.resultPreview?.preview.name, - '潮雾列岛·结果页精修版', - ); - assert.equal( - snapshot?.resultPreview?.preview.playerGoal, - '查清沉船夜与假航灯的真正操盘者。', - ); - assert.equal(legacyResultProfile?.name, '潮雾列岛·结果页精修版'); - assert.equal( - legacyResultProfile?.playerGoal, - '查清沉船夜与假航灯的真正操盘者。', - ); - assert.ok( - snapshot?.messages.some( - (message) => - message.kind === 'action_result' && - message.text.includes('结果页里的最新世界结构已经同步回当前草稿'), - ), - ); -}); - -test('phase4 sync_result_profile keeps existing foundation structure while updating summary snapshot', async () => { - const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); - const sessionStore = new CustomWorldAgentSessionStore( - rpgAgentSessionRepository, - ); - const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { - singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), - }); - const userId = 'user-phase4-sync-result-profile-structure'; - const session = await createObjectRefiningSession(orchestrator, userId); - const baselineProfile = normalizeFoundationDraftProfile(session.draftProfile); - const baselinePlayableName = baselineProfile?.playableNpcs[0]?.name; - const baselineStoryName = baselineProfile?.storyNpcs[0]?.name; - const baselineLandmarkName = baselineProfile?.landmarks[0]?.name; - - assert.ok(baselinePlayableName); - assert.ok(baselineStoryName); - assert.ok(baselineLandmarkName); - - const response = await orchestrator.executeAction(userId, session.sessionId, { - action: 'sync_result_profile', - profile: { - id: `agent-draft-${session.sessionId}`, - settingText: '被海雾吞没的旧航路群岛', - name: '潮雾列岛·结果页精修版', - subtitle: '旧灯塔与失控航路', - summary: '结果页已经把世界概述继续往沉船夜暗线收紧。', - tone: '压抑、潮湿、悬疑', - playerGoal: '查清沉船夜与假航灯的真正操盘者。', - templateWorldType: 'WUXIA', - majorFactions: ['守灯会', '航运公会'], - coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], - attributeSchema: { - id: 'schema:test', - worldId: 'CUSTOM', - schemaVersion: 1, - schemaName: '测试', - generatedFrom: { - worldType: 'CUSTOM', - worldName: '潮雾列岛·结果页精修版', - settingSummary: '测试', - tone: '测试', - conflictCore: '测试', - }, - slots: [], - }, - playableNpcs: [ - { - id: 'playable-runtime-only', - name: '结果页临时角色', - title: '运行时角色', - role: '测试角色', - description: '不应该直接覆盖 foundation draft。', - backstory: '仅用于验证 sync 边界。', - personality: '谨慎', - motivation: '验证同步边界', - combatStyle: '观察', - initialAffinity: 0, - relationshipHooks: [], - tags: [], - }, - ], - storyNpcs: [ - { - id: 'story-runtime-only', - name: '结果页临时场景角色', - title: '运行时场景角色', - role: '测试角色', - description: '不应该直接覆盖 foundation draft。', - backstory: '仅用于验证 sync 边界。', - personality: '克制', - motivation: '验证同步边界', - combatStyle: '观察', - initialAffinity: 0, - relationshipHooks: [], - tags: [], - }, - ], - items: [], - landmarks: [ - { - id: 'landmark-runtime-only', - name: '结果页临时地点', - description: '不应该直接覆盖 foundation draft。', - dangerLevel: '低', - sceneNpcIds: [], - connections: [], - }, - ], - generationMode: 'full', - generationStatus: 'complete', - }, - }); - const operation = await waitForOperation( - orchestrator, - userId, - session.sessionId, - response.operation.operationId, - ); - const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId); - const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile); - const draftRecord = snapshot?.draftProfile as Record | null; - const legacyResultProfile = draftRecord?.legacyResultProfile as - | Record - | undefined; - - assert.equal(operation?.status, 'completed'); - assert.equal(profile?.name, '潮雾列岛·结果页精修版'); - assert.equal(profile?.playableNpcs[0]?.name, baselinePlayableName); - assert.equal(profile?.storyNpcs[0]?.name, baselineStoryName); - assert.equal(profile?.landmarks[0]?.name, baselineLandmarkName); - assert.equal(legacyResultProfile?.name, '潮雾列岛·结果页精修版'); - assert.equal( - (legacyResultProfile?.playableNpcs as Array<{ name?: string }> | undefined)?.[0] - ?.name, - '结果页临时角色', - ); -}); - -test('phase4 sync_result_profile also writes latest role and scene assets back into draft profile', async () => { - const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); - const sessionStore = new CustomWorldAgentSessionStore( - rpgAgentSessionRepository, - ); - const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { - singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), - }); - const userId = 'user-phase4-sync-result-profile-assets'; - const session = await createObjectRefiningSession(orchestrator, userId); - const baselineProfile = normalizeFoundationDraftProfile(session.draftProfile)!; - const playableRole = baselineProfile.playableNpcs[0]!; - const storyRole = baselineProfile.storyNpcs[0]!; - const landmark = baselineProfile.landmarks[0]!; - - const response = await orchestrator.executeAction(userId, session.sessionId, { - action: 'sync_result_profile', - profile: { - id: `agent-draft-${session.sessionId}`, - settingText: '被海雾吞没的旧航路群岛', - name: '潮雾列岛·结果页精修版', - subtitle: '旧灯塔与失控航路', - summary: '结果页已经把最新图与动作一起确认。 ', - tone: '压抑、潮湿、悬疑', - playerGoal: '查清沉船夜与假航灯的真正操盘者。', - templateWorldType: 'WUXIA', - majorFactions: ['守灯会', '航运公会'], - coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], - attributeSchema: { - id: 'schema:test', - worldId: 'CUSTOM', - schemaVersion: 1, - schemaName: '测试', - generatedFrom: { - worldType: 'CUSTOM', - worldName: '潮雾列岛·结果页精修版', - settingSummary: '测试', - tone: '测试', - conflictCore: '测试', - }, - slots: [], - }, - playableNpcs: [ - { - id: playableRole.id, - name: playableRole.name, - title: '结果页角色', - role: '关键同行者', - description: '结果页确认的最新角色资产。', - backstory: '测试', - personality: '冷静', - motivation: '验证资产回写', - combatStyle: '观察', - initialAffinity: 12, - relationshipHooks: [], - tags: [], - imageSrc: '/generated/playable/latest-master.png', - generatedVisualAssetId: 'visual-playable-latest', - generatedAnimationSetId: 'anim-playable-latest', - animationMap: { - idle: { - spriteSheetPath: '/generated/playable/idle.png', - }, - }, - }, - ], - storyNpcs: [ - { - id: storyRole.id, - name: storyRole.name, - title: '结果页场景角色', - role: '场景关键角色', - description: '结果页确认的最新场景角色资产。', - backstory: '测试', - personality: '克制', - motivation: '验证资产回写', - combatStyle: '观察', - initialAffinity: 6, - relationshipHooks: [], - tags: [], - imageSrc: '/generated/story/latest-master.png', - generatedVisualAssetId: 'visual-story-latest', - }, - ], - items: [], - landmarks: [ - { - id: landmark.id, - name: landmark.name, - description: '结果页确认的最新地点图。', - dangerLevel: '中', - sceneNpcIds: [], - connections: [], - imageSrc: '/generated/landmark/latest-scene.png', - }, - ], - sceneChapterBlueprints: [ - { - id: 'scene-chapter-1', - sceneId: landmark.id, - title: '灯塔初章', - summary: '结果页确认最新分幕图。', - linkedThreadIds: [], - linkedLandmarkIds: [landmark.id], - acts: [ - { - id: `${landmark.id}-act-1`, - sceneId: landmark.id, - title: '第一幕', - summary: '第一幕', - stageCoverage: ['opening'], - backgroundImageSrc: '/generated/scene/act-1-latest.png', - backgroundAssetId: 'scene-asset-latest', - encounterNpcIds: [], - primaryNpcId: '', - linkedThreadIds: [], - advanceRule: 'after_primary_contact', - actGoal: '验证分幕图回写', - transitionHook: '进入下一幕', - }, - ], - }, - ], - generationMode: 'full', - generationStatus: 'complete', - }, - }); - const operation = await waitForOperation( - orchestrator, - userId, - session.sessionId, - response.operation.operationId, - ); - const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId); - const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile)!; - const syncedPlayable = profile.playableNpcs.find( - (entry) => entry.id === playableRole.id, - ); - const syncedStory = profile.storyNpcs.find((entry) => entry.id === storyRole.id); - const syncedLandmark = profile.landmarks.find((entry) => entry.id === landmark.id); - const syncedSceneAct = profile.sceneChapters[0]?.acts[0]; - - assert.equal(operation?.status, 'completed'); - assert.equal(syncedPlayable?.imageSrc, '/generated/playable/latest-master.png'); - assert.equal(syncedPlayable?.generatedVisualAssetId, 'visual-playable-latest'); - assert.equal(syncedPlayable?.generatedAnimationSetId, 'anim-playable-latest'); - assert.deepEqual(syncedPlayable?.animationMap, { - idle: { - spriteSheetPath: '/generated/playable/idle.png', - }, - }); - assert.equal(syncedStory?.imageSrc, '/generated/story/latest-master.png'); - assert.equal(syncedStory?.generatedVisualAssetId, 'visual-story-latest'); - assert.equal(syncedLandmark?.imageSrc, '/generated/landmark/latest-scene.png'); - assert.equal(syncedSceneAct?.backgroundImageSrc, '/generated/scene/act-1-latest.png'); - assert.equal(syncedSceneAct?.backgroundAssetId, 'scene-asset-latest'); -}); - -test('phase4 generate_characters appends story npcs and updates work summary counts', async () => { - const { rpgAgentSessionRepository, rpgWorldProfileRepository } = - createInMemoryRpgWorldRepositoryPorts(); - const sessionStore = new CustomWorldAgentSessionStore( - rpgAgentSessionRepository, - ); - const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { - singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), - }); - const userId = 'user-phase4-characters'; - const session = await createObjectRefiningSession(orchestrator, userId); - const baselineProfile = normalizeFoundationDraftProfile(session.draftProfile)!; - const baselineCharacterCount = [ - ...new Set( - [...baselineProfile.playableNpcs, ...baselineProfile.storyNpcs].map( - (entry) => entry.id, - ), - ), - ].length; - - const response = await orchestrator.executeAction(userId, session.sessionId, { - action: 'generate_characters', - count: 2, - promptText: '补两位更贴近旧航道线的边缘角色。', - anchorCardIds: [session.draftCards.find((card) => card.kind === 'thread')!.id], - }); - const operation = await waitForOperation( - orchestrator, - userId, - session.sessionId, - response.operation.operationId, - ); - const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId); - const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile)!; - const nextCharacterCount = [ - ...new Set( - [...profile.playableNpcs, ...profile.storyNpcs].map((entry) => entry.id), - ), - ].length; - const workItems = await new RpgWorldWorkSummaryService( - rpgWorldProfileRepository, - sessionStore, - ).list(userId); - const draftItem = workItems.find((item) => item.sessionId === session.sessionId); - - assert.equal(operation?.status, 'completed'); - assert.ok(profile.storyNpcs.length >= 2); - assert.ok(nextCharacterCount >= baselineCharacterCount + 2); - assert.ok(snapshot?.draftCards.filter((card) => card.kind === 'character').length); - assert.ok(snapshot?.focusCardId); - assert.ok( - snapshot?.messages.some( - (message) => - message.kind === 'action_result' && message.text.includes('新角色'), - ), - ); - assert.ok((draftItem?.playableNpcCount ?? 0) >= baselineCharacterCount + 2); -}); - -test('phase4 generate_landmarks appends new landmark cards and checkpoints', async () => { - const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); - const sessionStore = new CustomWorldAgentSessionStore( - rpgAgentSessionRepository, - ); - const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { - singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), - }); - const userId = 'user-phase4-landmarks'; - const session = await createObjectRefiningSession(orchestrator, userId); - const baselineProfile = normalizeFoundationDraftProfile(session.draftProfile)!; - const baselineLandmarkCount = baselineProfile.landmarks.length; - - const response = await orchestrator.executeAction(userId, session.sessionId, { - action: 'generate_landmarks', - count: 2, - promptText: '补两个适合藏旧航道秘密的地点。', - anchorCardIds: [session.draftCards.find((card) => card.kind === 'character')!.id], - }); - const operation = await waitForOperation( - orchestrator, - userId, - session.sessionId, - response.operation.operationId, - ); - const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId); - const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile)!; - const latestSessionRecord = await sessionStore.get(userId, session.sessionId); - - assert.equal(operation?.status, 'completed'); - assert.ok(profile.landmarks.length >= baselineLandmarkCount + 2); - assert.ok( - snapshot?.draftCards.filter((card) => card.kind === 'landmark').length, - ); - assert.ok( - snapshot?.messages.some( - (message) => - message.kind === 'action_result' && message.text.includes('新地点'), - ), - ); - assert.ok((latestSessionRecord?.checkpoints.length ?? 0) >= 2); -}); - -test('phase4 work summaries exclude library draft entries after phase3 downgrade', async () => { - const { rpgAgentSessionRepository, rpgWorldProfileRepository } = - createInMemoryRpgWorldRepositoryPorts(); - const sessionStore = new CustomWorldAgentSessionStore( - rpgAgentSessionRepository, - ); - const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { - singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), - }); - const userId = 'user-phase4-work-summary-phase3'; - const session = await createObjectRefiningSession(orchestrator, userId); - - await rpgWorldProfileRepository.upsertOwnProfile( - userId, - 'library-draft-1', - { - id: 'library-draft-1', - name: '旧兼容草稿', - subtitle: '仍保留在作品库', - summary: '不应该继续出现在创作中心 works 聚合里。', - playableNpcs: [], - landmarks: [], - }, - '玩家', - ); - - const workItems = await new RpgWorldWorkSummaryService( - rpgWorldProfileRepository, - sessionStore, - ).list(userId); - - assert.ok(workItems.some((item) => item.sessionId === session.sessionId)); - assert.equal( - workItems.some((item) => item.profileId === 'library-draft-1'), - false, - ); -}); - -test('phase4 work summaries hide published agent sessions from draft lane and keep published entry enterable', async () => { - const { rpgAgentSessionRepository, rpgWorldProfileRepository } = - createInMemoryRpgWorldRepositoryPorts(); - const sessionStore = new CustomWorldAgentSessionStore( - rpgAgentSessionRepository, - ); - const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { - singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), - }); - const userId = 'user-phase4-work-summary-published'; - const session = await createObjectRefiningSession(orchestrator, userId); - - await sessionStore.replaceDerivedState(userId, session.sessionId, { - stage: 'published', - qualityFindings: [], - }); - await rpgWorldProfileRepository.upsertOwnProfile( - userId, - `agent-draft-${session.sessionId}`, - { - id: `agent-draft-${session.sessionId}`, - name: '潮雾列岛', - subtitle: '旧灯塔与失控航路', - summary: '已发布版本。', - playableNpcs: [], - landmarks: [], - }, - '玩家', - ); - await rpgWorldProfileRepository.publishOwnProfile( - userId, - `agent-draft-${session.sessionId}`, - '玩家', - ); - - const workItems = await new RpgWorldWorkSummaryService( - rpgWorldProfileRepository, - sessionStore, - ).list(userId); - const draftItem = workItems.find((item) => item.sessionId === session.sessionId); - const publishedItem = workItems.find( - (item) => item.profileId === `agent-draft-${session.sessionId}`, - ); - - assert.equal(draftItem, undefined); - assert.equal(publishedItem?.status, 'published'); - assert.equal(publishedItem?.canEnterWorld, true); - assert.equal(publishedItem?.publishReady, true); - assert.equal(publishedItem?.blockerCount, 0); -}); diff --git a/server-node/src/services/customWorldAgentPhase5.test.ts b/server-node/src/services/customWorldAgentPhase5.test.ts deleted file mode 100644 index 8ad74ed2..00000000 --- a/server-node/src/services/customWorldAgentPhase5.test.ts +++ /dev/null @@ -1,983 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import test from 'node:test'; - -import type { AppConfig } from '../config.js'; -import type { UserRepositoryPort } from '../repositories/userRepository.js'; -import { CustomWorldAgentAutoAssetService } from './customWorldAgentAutoAssetService.js'; -import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js'; -import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js'; -import { createInMemoryRpgWorldRepositoryPorts } from './customWorldAgentRepositoryTestHelpers.js'; -import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js'; -import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js'; - -function createAutoAssetTestConfig(testName: string): AppConfig { - const projectRoot = fs.mkdtempSync( - path.join(os.tmpdir(), `genarrative-agent-phase5-${testName}-`), - ); - - return { - nodeEnv: 'test', - projectRoot, - publicDir: path.join(projectRoot, 'public'), - logsDir: path.join(projectRoot, 'logs'), - dataDir: path.join(projectRoot, 'data'), - rawEnv: {}, - databaseUrl: `pg-mem://${testName}`, - serverAddr: ':0', - logLevel: 'silent', - editorApiEnabled: true, - assetsApiEnabled: true, - jwtSecret: 'test', - jwtExpiresIn: '7d', - jwtIssuer: 'test', - llm: { - baseUrl: 'https://example.invalid', - apiKey: '', - model: 'test-model', - }, - dashScope: { - baseUrl: 'https://example.invalid', - apiKey: '', - imageModel: 'test-image-model', - requestTimeoutMs: 1000, - }, - smsAuth: { - enabled: false, - provider: 'mock', - endpoint: '', - accessKeyId: '', - accessKeySecret: '', - signName: '', - templateCode: '', - templateParamKey: '', - countryCode: '86', - schemeName: '', - codeLength: 6, - codeType: 1, - validTimeSeconds: 300, - intervalSeconds: 60, - duplicatePolicy: 1, - caseAuthPolicy: 1, - returnVerifyCode: false, - mockVerifyCode: '123456', - maxSendPerPhonePerDay: 20, - maxSendPerIpPerHour: 30, - maxVerifyFailuresPerPhonePerHour: 12, - maxVerifyFailuresPerIpPerHour: 24, - captchaTtlSeconds: 180, - captchaTriggerVerifyFailuresPerPhone: 3, - captchaTriggerVerifyFailuresPerIp: 5, - blockPhoneFailureThreshold: 6, - blockIpFailureThreshold: 10, - blockPhoneDurationMinutes: 30, - blockIpDurationMinutes: 30, - }, - wechatAuth: { - enabled: false, - provider: 'mock', - appId: '', - appSecret: '', - authorizeEndpoint: '', - accessTokenEndpoint: '', - userInfoEndpoint: '', - callbackPath: '', - defaultRedirectPath: '/', - mockUserId: '', - mockUnionId: '', - mockDisplayName: '', - mockAvatarUrl: '', - }, - authSession: { - accessCookieName: 'genarrative_access_session', - accessCookieTtlSeconds: 7200, - accessCookieSecure: false, - accessCookieSameSite: 'Lax', - accessCookiePath: '/', - refreshCookieName: 'refresh_token', - refreshSessionTtlDays: 30, - refreshCookieSecure: false, - refreshCookieSameSite: 'Lax', - refreshCookiePath: '/', - }, - }; -} - -function createFallbackAutoAssetService(testName: string) { - const config = createAutoAssetTestConfig(testName); - return new CustomWorldAgentAutoAssetService( - config, - CustomWorldAgentAutoAssetService.createFallbackCharacterVisualGenerator(config), - CustomWorldAgentAutoAssetService.createFallbackSceneActBackgroundGenerator(config), - ); -} - -// 发布执行器当前通过 userRepository 读取作者展示名,这里用内存 stub 对齐主链接口。 -function createUserRepository(displayName = '测试玩家'): UserRepositoryPort { - const now = '2026-04-21T00:00:00.000Z'; - - return { - findByUsername: async () => null, - findByPhoneNumber: async () => null, - findById: async (userId) => ({ - id: userId, - username: null, - passwordHash: '', - tokenVersion: 1, - displayName, - loginProvider: 'password', - accountStatus: 'active', - phoneNumber: null, - phoneVerifiedAt: null, - createdAt: now, - updatedAt: now, - }), - create: async () => null, - createPhoneUser: async () => null, - createWechatPendingUser: async () => null, - activatePendingWechatUser: async () => null, - updatePhoneInfo: async () => null, - deleteUser: async () => undefined, - incrementTokenVersion: async () => null, - }; -} - -async function waitForOperation( - orchestrator: CustomWorldAgentOrchestrator, - userId: string, - sessionId: string, - operationId: string, -) { - for (let attempt = 0; attempt < 60; attempt += 1) { - const operation = await orchestrator.getOperation( - userId, - sessionId, - operationId, - ); - - if (operation?.status === 'completed' || operation?.status === 'failed') { - return operation; - } - - await new Promise((resolve) => setTimeout(resolve, 20)); - } - - throw new Error('operation did not finish in time'); -} - -async function createObjectRefiningSession( - orchestrator: CustomWorldAgentOrchestrator, - userId: string, -) { - const createdSession = await orchestrator.createSession(userId, { - seedText: '一个被潮雾切开的列岛世界。', - }); - - const message1 = await orchestrator.submitMessage( - userId, - createdSession.sessionId, - { - clientMessageId: 'phase5-ready-1', - text: '玩家是被迫返乡的失职守灯人,开局时正站在即将熄灭的旧灯塔上。', - focusCardId: null, - selectedCardIds: [], - }, - ); - await waitForOperation( - orchestrator, - userId, - createdSession.sessionId, - message1.operation.operationId, - ); - - const message2 = await orchestrator.submitMessage( - userId, - createdSession.sessionId, - { - clientMessageId: 'phase5-ready-2', - text: '整体主题是海岛悬疑,气质冷峻克制。核心冲突是守灯会与沉船商盟争夺航道解释权。关键人物叫沈砺,是玩家的旧友兼宿敌,其实暗地里在为沉船商盟引路。标志性元素是潮雾钟声、盐火灯塔。', - focusCardId: null, - selectedCardIds: [], - }, - ); - await waitForOperation( - orchestrator, - userId, - createdSession.sessionId, - message2.operation.operationId, - ); - - const foundationOperation = await orchestrator.executeAction( - userId, - createdSession.sessionId, - { - action: 'draft_foundation', - }, - ); - await waitForOperation( - orchestrator, - userId, - createdSession.sessionId, - foundationOperation.operation.operationId, - ); - - return (await orchestrator.getSessionSnapshot( - userId, - createdSession.sessionId, - ))!; -} - -async function createPublishReadySession( - orchestrator: CustomWorldAgentOrchestrator, - sessionStore: CustomWorldAgentSessionStore, - userId: string, -) { - const session = await createObjectRefiningSession(orchestrator, userId); - const profile = normalizeFoundationDraftProfile(session.draftProfile); - - assert.ok(profile); - assert.ok(profile.playableNpcs.length > 0); - assert.ok(profile.storyNpcs.length > 0); - assert.ok(profile.landmarks.length > 0); - assert.ok(profile.sceneChapters.length > 0); - - const publishReadyProfile = { - ...(session.draftProfile as Record), - camp: { - ...(profile.camp ?? {}), - id: profile.camp?.id ?? 'camp-home', - name: profile.camp?.name ?? '归潮营地', - description: profile.camp?.description ?? '可供玩家整理线索的临时据点。', - imageSrc: '/generated/camp/publish-ready.png', - generatedSceneAssetId: 'scene-camp-publish-ready', - generatedScenePrompt: '潮雾营地发布正式图', - generatedSceneModel: 'test-scene-model', - }, - playableNpcs: profile.playableNpcs.map((entry, index) => ({ - ...entry, - imageSrc: - entry.imageSrc || `/generated/playable/publish-ready-${index + 1}.png`, - generatedVisualAssetId: - entry.generatedVisualAssetId || `visual-playable-publish-${index + 1}`, - generatedAnimationSetId: - entry.generatedAnimationSetId || `anim-playable-publish-${index + 1}`, - })), - storyNpcs: profile.storyNpcs.map((entry, index) => ({ - ...entry, - imageSrc: - entry.imageSrc || `/generated/story/publish-ready-${index + 1}.png`, - generatedVisualAssetId: - entry.generatedVisualAssetId || `visual-story-publish-${index + 1}`, - generatedAnimationSetId: - entry.generatedAnimationSetId || `anim-story-publish-${index + 1}`, - })), - landmarks: profile.landmarks.map((entry, index) => ({ - ...entry, - imageSrc: - entry.imageSrc || `/generated/landmark/publish-ready-${index + 1}.png`, - generatedSceneAssetId: - entry.generatedSceneAssetId || `scene-landmark-publish-${index + 1}`, - generatedScenePrompt: - entry.generatedScenePrompt || `地点 ${entry.name} 的正式场景图`, - generatedSceneModel: - entry.generatedSceneModel || 'test-scene-model', - })), - sceneChapters: profile.sceneChapters.map((chapter) => ({ - ...chapter, - linkedThreadIds: - chapter.linkedThreadIds.length > 0 - ? chapter.linkedThreadIds - : [profile.threads[0]?.id ?? 'thread-publish-ready'], - acts: chapter.acts.map((act, index) => ({ - ...act, - encounterNpcIds: - act.encounterNpcIds.length > 0 - ? act.encounterNpcIds - : [profile.storyNpcs[0]?.id ?? profile.playableNpcs[0]?.id ?? 'role-publish-ready'], - primaryNpcId: - act.primaryNpcId || - act.encounterNpcIds[0] || - profile.storyNpcs[0]?.id || - profile.playableNpcs[0]?.id || - 'role-publish-ready', - backgroundImageSrc: - act.backgroundImageSrc || - `/generated/scene/publish-ready-${chapter.id}-${index + 1}.png`, - backgroundAssetId: - act.backgroundAssetId || `scene-act-publish-${chapter.id}-${index + 1}`, - })), - })), - chapters: profile.chapters, - } satisfies Record; - - await sessionStore.replaceDerivedState(userId, session.sessionId, { - stage: 'ready_to_publish', - draftProfile: publishReadyProfile, - draftCards: session.draftCards, - qualityFindings: [], - focusCardId: session.focusCardId, - assetCoverage: session.assetCoverage, - }); - - const publishReadySession = await orchestrator.getSessionSnapshot( - userId, - session.sessionId, - ); - - assert.equal(publishReadySession?.stage, 'ready_to_publish'); - assert.equal( - publishReadySession?.supportedActions.find( - (entry) => entry.action === 'publish_world', - )?.enabled, - true, - ); - - return publishReadySession!; -} - -test('phase5 generate_role_assets only allows a single role and moves session into visual_refining', async () => { - const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); - const sessionStore = new CustomWorldAgentSessionStore( - rpgAgentSessionRepository, - ); - const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { - singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), - autoAssetService: createFallbackAutoAssetService('generate-role-assets'), - }); - const userId = 'user-phase5-generate-role-assets'; - const session = await createObjectRefiningSession(orchestrator, userId); - const characterIds = session.draftCards - .filter((card) => card.kind === 'character') - .map((card) => card.id); - - await assert.rejects( - orchestrator.executeAction(userId, session.sessionId, { - action: 'generate_role_assets', - roleIds: characterIds.slice(0, 2), - }), - ); - - const response = await orchestrator.executeAction(userId, session.sessionId, { - action: 'generate_role_assets', - roleIds: [characterIds[0]!], - }); - const operation = await waitForOperation( - orchestrator, - userId, - session.sessionId, - response.operation.operationId, - ); - const snapshot = await orchestrator.getSessionSnapshot( - userId, - session.sessionId, - ); - - assert.equal(operation?.status, 'completed'); - assert.equal(snapshot?.stage, 'visual_refining'); - assert.equal(snapshot?.focusCardId, characterIds[0]); - assert.equal( - snapshot?.supportedActions?.find( - (entry) => entry.action === 'generate_role_assets', - )?.enabled, - true, - ); - assert.equal( - snapshot?.supportedActions?.find( - (entry) => entry.action === 'sync_role_assets', - )?.enabled, - true, - ); - assert.ok( - snapshot?.messages.some( - (message) => - message.kind === 'action_result' && - message.text.includes('角色资产工坊'), - ), - ); - const preparedAssetSummary = snapshot?.assetCoverage.roleAssets.find( - (entry) => entry.roleId === characterIds[0], - ); - assert.equal(preparedAssetSummary?.status, 'visual_ready'); -}); - -test('phase5 sync_role_assets writes fields back, updates coverage and recompiles character cards', async () => { - const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); - const sessionStore = new CustomWorldAgentSessionStore( - rpgAgentSessionRepository, - ); - const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { - singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), - autoAssetService: createFallbackAutoAssetService('sync-role-assets'), - }); - const userId = 'user-phase5-sync-role-assets'; - const session = await createObjectRefiningSession(orchestrator, userId); - const characterCard = session.draftCards.find( - (card) => card.kind === 'character', - ); - - assert.ok(characterCard); - - const prepareResponse = await orchestrator.executeAction( - userId, - session.sessionId, - { - action: 'generate_role_assets', - roleIds: [characterCard!.id], - }, - ); - await waitForOperation( - orchestrator, - userId, - session.sessionId, - prepareResponse.operation.operationId, - ); - - const response = await orchestrator.executeAction(userId, session.sessionId, { - action: 'sync_role_assets', - roleId: characterCard!.id, - portraitPath: '/generated/characters/shenli-portrait.png', - generatedVisualAssetId: 'visual-shenli-1', - generatedAnimationSetId: 'animation-set-shenli-1', - animationMap: { - idle: { basePath: '/generated/characters/shenli/idle' }, - run: { basePath: '/generated/characters/shenli/run' }, - attack: { basePath: '/generated/characters/shenli/attack' }, - hurt: { basePath: '/generated/characters/shenli/hurt' }, - die: { basePath: '/generated/characters/shenli/die' }, - }, - }); - const operation = await waitForOperation( - orchestrator, - userId, - session.sessionId, - response.operation.operationId, - ); - const snapshot = await orchestrator.getSessionSnapshot( - userId, - session.sessionId, - ); - const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile); - const syncedRole = [ - ...(profile?.playableNpcs ?? []), - ...(profile?.storyNpcs ?? []), - ].find((entry) => entry.id === characterCard!.id); - const syncedCard = snapshot?.draftCards.find( - (card) => card.id === characterCard!.id, - ); - const syncedAssetSummary = snapshot?.assetCoverage.roleAssets.find( - (entry) => entry.roleId === characterCard!.id, - ); - const latestRecord = await sessionStore.get(userId, session.sessionId); - - assert.equal(operation?.status, 'completed'); - assert.equal( - syncedRole?.imageSrc, - '/generated/characters/shenli-portrait.png', - ); - assert.equal(syncedRole?.generatedVisualAssetId, 'visual-shenli-1'); - assert.equal(syncedRole?.generatedAnimationSetId, 'animation-set-shenli-1'); - assert.equal( - (syncedRole?.animationMap as Record | null) - ?.idle?.basePath, - '/generated/characters/shenli/idle', - ); - const syncedSkillIds = syncedRole?.skills?.map((skill) => skill.id) ?? []; - assert.ok(syncedSkillIds.length > 0); - assert.equal(syncedAssetSummary?.status, 'animations_ready'); - assert.deepEqual( - syncedAssetSummary?.missingAnimations, - syncedSkillIds.map((skillId) => `skill:${skillId}`), - ); - assert.equal(syncedCard?.assetStatusLabel, '动作补齐中'); - assert.ok(syncedCard?.subtitle.includes('动作补齐中')); - assert.ok( - snapshot?.messages.some( - (message) => - message.kind === 'action_result' && message.text.includes('动作补齐中'), - ), - ); - assert.ok((latestRecord?.checkpoints.length ?? 0) >= 2); -}); - -test('phase5 publish_world persists published profile and moves session into published stage', async () => { - const { rpgAgentSessionRepository, rpgWorldProfileRepository } = - createInMemoryRpgWorldRepositoryPorts(); - const sessionStore = new CustomWorldAgentSessionStore( - rpgAgentSessionRepository, - ); - const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { - singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), - autoAssetService: createFallbackAutoAssetService('publish-world'), - rpgWorldProfileRepository, - userRepository: createUserRepository('发布测试玩家'), - }); - const userId = 'user-phase5-publish-world'; - const session = await createPublishReadySession( - orchestrator, - sessionStore, - userId, - ); - - const response = await orchestrator.executeAction(userId, session.sessionId, { - action: 'publish_world', - }); - const operation = await waitForOperation( - orchestrator, - userId, - session.sessionId, - response.operation.operationId, - ); - const snapshot = await orchestrator.getSessionSnapshot( - userId, - session.sessionId, - ); - const profiles = await rpgWorldProfileRepository.listOwnProfiles(userId); - const publishedEntry = profiles.find( - (entry) => entry.profileId === `agent-draft-${session.sessionId}`, - ); - const latestRecord = await sessionStore.get(userId, session.sessionId); - - assert.equal(operation?.status, 'completed'); - assert.equal(snapshot?.stage, 'published'); - assert.equal(snapshot?.resultPreview?.publishReady, true); - assert.equal(snapshot?.resultPreview?.canEnterWorld, true); - assert.deepEqual(snapshot?.resultPreview?.blockers ?? [], []); - assert.equal( - snapshot?.supportedActions.find( - (entry) => entry.action === 'publish_world', - )?.enabled, - false, - ); - assert.equal(publishedEntry?.visibility, 'published'); - assert.equal(publishedEntry?.authorDisplayName, '发布测试玩家'); - assert.equal(publishedEntry?.profile.id, `agent-draft-${session.sessionId}`); - assert.ok( - snapshot?.messages.some( - (message) => - message.kind === 'action_result' && - message.text.includes('已正式发布'), - ), - ); - assert.ok( - latestRecord?.checkpoints.some( - (checkpoint) => - checkpoint.label.includes('发布世界') && - checkpoint.snapshot?.stage === 'published', - ), - ); -}); - -test('phase5 generate_scene_assets prepares scene studio and sync_scene_assets writes back camp asset fields', async () => { - const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); - const sessionStore = new CustomWorldAgentSessionStore( - rpgAgentSessionRepository, - ); - const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { - singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), - autoAssetService: createFallbackAutoAssetService('sync-scene-assets'), - }); - const userId = 'user-phase5-sync-scene-assets'; - const session = await createObjectRefiningSession(orchestrator, userId); - const campCard = session.draftCards.find((card) => card.kind === 'camp'); - - assert.ok(campCard); - - const prepareResponse = await orchestrator.executeAction( - userId, - session.sessionId, - { - action: 'generate_scene_assets', - sceneIds: [campCard!.id], - }, - ); - const prepareOperation = await waitForOperation( - orchestrator, - userId, - session.sessionId, - prepareResponse.operation.operationId, - ); - const preparedSnapshot = await orchestrator.getSessionSnapshot( - userId, - session.sessionId, - ); - - assert.equal(prepareOperation?.status, 'completed'); - assert.equal(preparedSnapshot?.stage, 'visual_refining'); - assert.equal(preparedSnapshot?.focusCardId, campCard!.id); - assert.ok( - preparedSnapshot?.messages.some( - (message) => - message.kind === 'action_result' && - message.text.includes('场景图工坊'), - ), - ); - - const response = await orchestrator.executeAction(userId, session.sessionId, { - action: 'sync_scene_assets', - sceneId: campCard!.id, - sceneKind: 'camp', - imageSrc: '/generated/scenes/camp-home.png', - generatedSceneAssetId: 'scene-camp-home-1', - generatedScenePrompt: '潮雾中的灯塔营地', - generatedSceneModel: 'test-scene-model', - }); - const operation = await waitForOperation( - orchestrator, - userId, - session.sessionId, - response.operation.operationId, - ); - const snapshot = await orchestrator.getSessionSnapshot( - userId, - session.sessionId, - ); - const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile); - const latestRecord = await sessionStore.get(userId, session.sessionId); - - assert.equal(operation?.status, 'completed'); - assert.equal(snapshot?.stage, 'visual_refining'); - assert.equal(snapshot?.focusCardId, campCard!.id); - assert.equal(profile?.camp?.imageSrc, '/generated/scenes/camp-home.png'); - assert.equal(profile?.camp?.generatedSceneAssetId, 'scene-camp-home-1'); - assert.equal(profile?.camp?.generatedScenePrompt, '潮雾中的灯塔营地'); - assert.equal(profile?.camp?.generatedSceneModel, 'test-scene-model'); - assert.ok( - profile?.sceneChapters.every((chapter) => - chapter.sceneId === campCard!.id - ? chapter.acts.every( - (act) => - act.backgroundImageSrc === '/generated/scenes/camp-home.png' && - act.backgroundAssetId === 'scene-camp-home-1', - ) - : true, - ), - ); - assert.ok( - snapshot?.messages.some( - (message) => - message.kind === 'action_result' && - message.text.includes('场景图写回草稿'), - ), - ); - assert.ok( - latestRecord?.checkpoints.some( - (checkpoint) => - checkpoint.label.includes('同步场景资产') && Boolean(checkpoint.snapshot), - ), - ); -}); - -test('phase5 expand_long_tail appends characters and landmarks then moves into long_tail_review', async () => { - const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); - const sessionStore = new CustomWorldAgentSessionStore( - rpgAgentSessionRepository, - ); - const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { - singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), - autoAssetService: createFallbackAutoAssetService('expand-long-tail'), - }); - const userId = 'user-phase5-expand-long-tail'; - const session = await createObjectRefiningSession(orchestrator, userId); - const baselineProfile = normalizeFoundationDraftProfile(session.draftProfile)!; - const baselineCharacterCount = [ - ...new Set( - [...baselineProfile.playableNpcs, ...baselineProfile.storyNpcs].map( - (entry) => entry.id, - ), - ), - ].length; - const baselineLandmarkCount = baselineProfile.landmarks.length; - - const response = await orchestrator.executeAction(userId, session.sessionId, { - action: 'expand_long_tail', - }); - const operation = await waitForOperation( - orchestrator, - userId, - session.sessionId, - response.operation.operationId, - ); - const snapshot = await orchestrator.getSessionSnapshot( - userId, - session.sessionId, - ); - const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile)!; - const nextCharacterCount = [ - ...new Set( - [...profile.playableNpcs, ...profile.storyNpcs].map((entry) => entry.id), - ), - ].length; - const latestRecord = await sessionStore.get(userId, session.sessionId); - - assert.equal(operation?.status, 'completed'); - assert.equal(snapshot?.stage, 'long_tail_review'); - assert.ok(nextCharacterCount >= baselineCharacterCount + 2); - assert.ok(profile.landmarks.length >= baselineLandmarkCount + 2); - assert.ok( - snapshot?.messages.some( - (message) => - message.kind === 'action_result' && - message.text.includes('长尾角色'), - ), - ); - assert.ok( - latestRecord?.checkpoints.some( - (checkpoint) => - checkpoint.label.includes('扩展长尾') && Boolean(checkpoint.snapshot), - ), - ); -}); - -test('phase5 publish_world blocks incomplete draft and publishes complete world into repository', async () => { - const { rpgAgentSessionRepository, rpgWorldProfileRepository } = - createInMemoryRpgWorldRepositoryPorts(); - const sessionStore = new CustomWorldAgentSessionStore( - rpgAgentSessionRepository, - ); - const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { - singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), - autoAssetService: createFallbackAutoAssetService('publish-world'), - rpgWorldProfileRepository, - userRepository: createUserRepository(), - }); - const userId = 'user-phase5-publish-world'; - const session = await createObjectRefiningSession(orchestrator, userId); - - const blockedResponse = await orchestrator.executeAction( - userId, - session.sessionId, - { - action: 'publish_world', - }, - ); - const blockedOperation = await waitForOperation( - orchestrator, - userId, - session.sessionId, - blockedResponse.operation.operationId, - ); - const blockedSnapshot = await orchestrator.getSessionSnapshot( - userId, - session.sessionId, - ); - - assert.equal(blockedOperation?.status, 'failed'); - assert.ok( - blockedSnapshot?.messages.some( - (message) => - message.kind === 'warning' && message.text.includes('当前世界还不能发布'), - ), - ); - - const profile = normalizeFoundationDraftProfile(blockedSnapshot?.draftProfile)!; - const roleIds = [...profile.playableNpcs, ...profile.storyNpcs].map( - (entry) => entry.id, - ); - - for (const roleId of roleIds) { - const syncRoleResponse = await orchestrator.executeAction( - userId, - session.sessionId, - { - action: 'sync_role_assets', - roleId, - portraitPath: `/generated/characters/${roleId}.png`, - generatedVisualAssetId: `visual-${roleId}`, - generatedAnimationSetId: `animation-${roleId}`, - animationMap: { - run: { basePath: `/generated/characters/${roleId}/run` }, - attack: { basePath: `/generated/characters/${roleId}/attack` }, - }, - }, - ); - await waitForOperation( - orchestrator, - userId, - session.sessionId, - syncRoleResponse.operation.operationId, - ); - } - - const latestSnapshot = await orchestrator.getSessionSnapshot( - userId, - session.sessionId, - ); - const latestProfile = normalizeFoundationDraftProfile(latestSnapshot?.draftProfile)!; - const sceneTargets = [ - { - sceneId: latestProfile.camp?.id ?? 'camp-home', - sceneKind: 'camp' as const, - }, - ...latestProfile.landmarks.map((entry) => ({ - sceneId: entry.id, - sceneKind: 'landmark' as const, - })), - ]; - - for (const sceneTarget of sceneTargets) { - const syncSceneResponse = await orchestrator.executeAction( - userId, - session.sessionId, - { - action: 'sync_scene_assets', - sceneId: sceneTarget.sceneId, - sceneKind: sceneTarget.sceneKind, - imageSrc: `/generated/scenes/${sceneTarget.sceneId}.png`, - generatedSceneAssetId: `scene-${sceneTarget.sceneId}`, - generatedScenePrompt: `${sceneTarget.sceneId} 场景图`, - generatedSceneModel: 'test-scene-model', - }, - ); - await waitForOperation( - orchestrator, - userId, - session.sessionId, - syncSceneResponse.operation.operationId, - ); - } - - const publishResponse = await orchestrator.executeAction( - userId, - session.sessionId, - { - action: 'publish_world', - }, - ); - const publishOperation = await waitForOperation( - orchestrator, - userId, - session.sessionId, - publishResponse.operation.operationId, - ); - const publishedSnapshot = await orchestrator.getSessionSnapshot( - userId, - session.sessionId, - ); - const libraryEntries = await rpgWorldProfileRepository.listOwnProfiles(userId); - const publishedEntry = libraryEntries.find( - (entry) => entry.visibility === 'published', - ); - const latestRecord = await sessionStore.get(userId, session.sessionId); - - assert.equal(blockedOperation?.status, 'failed'); - assert.match(blockedOperation?.error ?? '', /缺少正式主图|缺少正式场景图|缺少章节草稿/u); - assert.equal(blockedSnapshot?.resultPreview?.publishReady, false); - assert.ok((blockedSnapshot?.resultPreview?.blockers?.length ?? 0) > 0); - assert.equal(publishOperation?.status, 'completed'); - assert.equal(publishedSnapshot?.stage, 'published'); - assert.equal(publishedSnapshot?.resultPreview?.publishReady, true); - assert.equal(publishedSnapshot?.resultPreview?.canEnterWorld, true); - assert.ok(publishedSnapshot?.qualityFindings.every((entry) => entry.severity !== 'blocker')); - assert.ok( - publishedSnapshot?.messages.some( - (message) => - message.kind === 'action_result' && - message.text.includes('已正式发布'), - ), - ); - assert.ok(publishedEntry); - assert.equal(publishedEntry?.profileId, `agent-draft-${session.sessionId}`); - assert.equal(publishedEntry?.authorDisplayName, '测试玩家'); - assert.ok( - latestRecord?.checkpoints.some( - (checkpoint) => - checkpoint.label.includes('发布世界') && - checkpoint.snapshot?.stage === 'published', - ), - ); -}); - -test('phase5 revert_checkpoint restores previous draft snapshot', async () => { - const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); - const sessionStore = new CustomWorldAgentSessionStore( - rpgAgentSessionRepository, - ); - const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { - singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), - autoAssetService: createFallbackAutoAssetService('revert-checkpoint'), - }); - const userId = 'user-phase5-revert-checkpoint'; - const session = await createObjectRefiningSession(orchestrator, userId); - - const updateResponse = await orchestrator.executeAction( - userId, - session.sessionId, - { - action: 'update_draft_card', - cardId: 'world-foundation', - sections: [ - { - sectionId: 'summary', - value: '回滚测试摘要版本', - }, - ], - }, - ); - await waitForOperation( - orchestrator, - userId, - session.sessionId, - updateResponse.operation.operationId, - ); - - const afterUpdateRecord = await sessionStore.get(userId, session.sessionId); - const restorableCheckpoint = [...(afterUpdateRecord?.checkpoints ?? [])] - .reverse() - .find((checkpoint) => Boolean(checkpoint.snapshot)); - - assert.ok(restorableCheckpoint); - - const syncRoleResponse = await orchestrator.executeAction( - userId, - session.sessionId, - { - action: 'sync_role_assets', - roleId: - normalizeFoundationDraftProfile( - (await orchestrator.getSessionSnapshot(userId, session.sessionId)) - ?.draftProfile, - )?.playableNpcs[0]?.id ?? 'unknown-role', - portraitPath: '/generated/characters/revert-test.png', - generatedVisualAssetId: 'visual-revert-test', - generatedAnimationSetId: 'animation-revert-test', - animationMap: { - run: { basePath: '/generated/characters/revert-test/run' }, - attack: { basePath: '/generated/characters/revert-test/attack' }, - }, - }, - ); - await waitForOperation( - orchestrator, - userId, - session.sessionId, - syncRoleResponse.operation.operationId, - ); - - const response = await orchestrator.executeAction(userId, session.sessionId, { - action: 'revert_checkpoint', - checkpointId: restorableCheckpoint!.checkpointId, - }); - const operation = await waitForOperation( - orchestrator, - userId, - session.sessionId, - response.operation.operationId, - ); - const snapshot = await orchestrator.getSessionSnapshot( - userId, - session.sessionId, - ); - const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile); - - assert.equal(operation?.status, 'completed'); - assert.equal(profile?.summary, '回滚测试摘要版本'); - assert.ok( - snapshot?.messages.some( - (message) => - message.kind === 'action_result' && - message.text.includes('已恢复到检查点'), - ), - ); -}); diff --git a/server-node/src/services/customWorldAgentPublishingService.ts b/server-node/src/services/customWorldAgentPublishingService.ts deleted file mode 100644 index 4577f38e..00000000 --- a/server-node/src/services/customWorldAgentPublishingService.ts +++ /dev/null @@ -1,256 +0,0 @@ -import type { CustomWorldProfileRecord } from '../../../packages/shared/src/contracts/runtime.js'; -import type { RpgWorldProfileRepositoryPort } from '../repositories/RpgWorldProfileRepository.js'; -import { buildRpgCreationPreviewProfileFromDraftProfile } from './rpgCreationPreviewProfileBuilder.js'; -import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js'; - -function toText(value: unknown) { - return typeof value === 'string' ? value.trim() : ''; -} - -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); -} - -function hasGeneratedSceneAsset( - value: unknown, -) { - return Boolean(toText((value as Record | null)?.generatedSceneAssetId)); -} - -export class CustomWorldAgentPublishingService { - constructor( - private readonly rpgWorldProfileRepository: RpgWorldProfileRepositoryPort, - ) {} - - /** - * Phase4 需要把“能不能发布”收成可读的后端真相, - * 这样结果页、works 和 publish executor 才能共享同一套 blocker 语义。 - */ - evaluatePublishReadiness(params: { - sessionId: string; - draftProfile: unknown; - qualityFindings?: Array<{ - severity: 'info' | 'warning' | 'blocker'; - code?: string; - targetId?: string | null; - message: string; - }>; - }) { - const draftProfile = normalizeFoundationDraftProfile(params.draftProfile); - if (!draftProfile) { - return { - profileId: `agent-draft-${params.sessionId}`, - blockers: [ - { - severity: 'blocker' as const, - code: 'publish_empty_draft', - message: '当前世界草稿为空,无法发布。', - }, - ], - }; - } - - const findings = params.qualityFindings ?? []; - const blockers = findings.filter((entry) => entry.severity === 'blocker'); - const readinessBlockers = [...blockers]; - - if (!draftProfile.worldHook.trim()) { - readinessBlockers.push({ - severity: 'blocker', - code: 'publish_missing_world_hook', - message: '当前世界缺少 world hook,发布前需要先补齐世界一句话钩子。', - }); - } - - if (!draftProfile.playerPremise.trim()) { - readinessBlockers.push({ - severity: 'blocker', - code: 'publish_missing_player_premise', - message: '当前世界缺少玩家身份与切入前提,发布前需要先补齐玩家 premise。', - }); - } - - if ( - draftProfile.coreConflicts.length <= 0 || - !draftProfile.coreConflicts.some((entry) => toText(entry)) - ) { - readinessBlockers.push({ - severity: 'blocker', - code: 'publish_missing_core_conflict', - message: '当前世界缺少核心冲突,发布前需要先补齐核心冲突。', - }); - } - - if ((draftProfile.chapters?.length ?? 0) <= 0) { - readinessBlockers.push({ - severity: 'blocker', - code: 'publish_missing_main_chapter', - message: '当前世界还没有主线章节草稿,发布前至少要保留主线第一幕。', - }); - } - - const firstSceneActExists = draftProfile.sceneChapters.some( - (chapter) => chapter.acts.length > 0, - ); - if (!firstSceneActExists) { - readinessBlockers.push({ - severity: 'blocker', - code: 'publish_missing_first_act', - message: '当前世界还没有主线第一幕,发布前至少要保留一个场景幕。', - }); - } - - const missingRoleAssets = [ - ...draftProfile.playableNpcs, - ...draftProfile.storyNpcs, - ].filter( - (role) => - !toText(role.generatedVisualAssetId) || - !toText(role.generatedAnimationSetId), - ); - if (missingRoleAssets.length > 0) { - readinessBlockers.push({ - severity: 'blocker', - code: 'publish_role_assets_incomplete', - targetId: missingRoleAssets[0]?.id ?? null, - message: '仍有角色缺少正式主图或动作资产,发布前需要先补齐。', - }); - } - - if (!draftProfile.camp || !toText(draftProfile.camp.imageSrc) || !hasGeneratedSceneAsset(draftProfile.camp)) { - readinessBlockers.push({ - severity: 'blocker', - code: 'publish_camp_scene_missing', - targetId: draftProfile.camp?.id ?? null, - message: '营地还缺少正式场景图资产,发布前需要先确认营地图。', - }); - } - - const missingLandmarkScenes = draftProfile.landmarks.filter( - (landmark) => - !toText(landmark.imageSrc) || !hasGeneratedSceneAsset(landmark), - ); - if (missingLandmarkScenes.length > 0) { - readinessBlockers.push({ - severity: 'blocker', - code: 'publish_landmark_scene_missing', - targetId: missingLandmarkScenes[0]?.id ?? null, - message: '仍有地点缺少正式场景图资产,发布前需要先补齐地点图。', - }); - } - - return { - profileId: - toText( - (params.draftProfile as Record | null) - ?.legacyResultProfile?.id, - ) || `agent-draft-${params.sessionId}`, - blockers: readinessBlockers, - }; - } - - /** - * Phase4 统一复用发布门禁摘要,避免 preview / works / enter-world 各自拼 blocker 口径。 - */ - summarizePublishGate(params: { - sessionId: string; - stage?: string | null; - draftProfile: unknown; - qualityFindings?: Array<{ - severity: 'info' | 'warning' | 'blocker'; - code?: string; - targetId?: string | null; - message: string; - }>; - }) { - const readiness = this.evaluatePublishReadiness(params); - const blockers = readiness.blockers.map((entry) => ({ - id: - typeof entry.code === 'string' && entry.code.trim() - ? entry.code - : `publish-blocker-${entry.message}`, - code: - typeof entry.code === 'string' && entry.code.trim() - ? entry.code - : 'publish_blocker', - message: entry.message, - })); - - return { - profileId: readiness.profileId, - blockers, - blockerCount: blockers.length, - publishReady: blockers.length === 0, - canEnterWorld: - String(params.stage ?? '').trim() === 'published' && blockers.length === 0, - }; - } - - buildPublishReadiness(params: { - sessionId: string; - draftProfile: unknown; - qualityFindings?: Array<{ - severity: 'info' | 'warning' | 'blocker'; - code?: string; - targetId?: string | null; - message: string; - }>; - }) { - const readiness = this.evaluatePublishReadiness(params); - if (readiness.blockers.length > 0) { - throw new Error( - `当前世界仍有 ${readiness.blockers.length} 个 blocker,暂时不能发布:${readiness.blockers - .map((entry) => entry.message) - .join(';')}`, - ); - } - - return { - profileId: readiness.profileId, - }; - } - - async publishSessionDraft(params: { - userId: string; - authorDisplayName: string; - sessionId: string; - draftProfile: Record; - qualityFindings?: Array<{ - severity: 'info' | 'warning' | 'blocker'; - message: string; - }>; - }) { - const readiness = this.buildPublishReadiness({ - sessionId: params.sessionId, - draftProfile: params.draftProfile, - qualityFindings: params.qualityFindings, - }); - const publishedProfile = buildRpgCreationPreviewProfileFromDraftProfile({ - sessionId: params.sessionId, - draftProfile: params.draftProfile, - profileId: readiness.profileId, - }); - - await this.rpgWorldProfileRepository.upsertOwnProfile( - params.userId, - readiness.profileId, - publishedProfile as unknown as Record, - params.authorDisplayName, - ); - - const mutation = await this.rpgWorldProfileRepository.publishOwnProfile( - params.userId, - readiness.profileId, - params.authorDisplayName, - ); - if (!mutation) { - throw new Error('世界发布失败,未找到目标作品。'); - } - - return { - profileId: readiness.profileId, - publishedProfile, - mutation, - }; - } -} diff --git a/server-node/src/services/customWorldAgentQualityGateService.ts b/server-node/src/services/customWorldAgentQualityGateService.ts deleted file mode 100644 index 856cd919..00000000 --- a/server-node/src/services/customWorldAgentQualityGateService.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { - CustomWorldAgentSessionSnapshot, - CustomWorldAgentStage, - CustomWorldAssetCoverageSummary, -} from '../../../packages/shared/src/contracts/customWorldAgent.js'; -import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js'; - -export type CustomWorldAgentQualityFinding = - CustomWorldAgentSessionSnapshot['qualityFindings'][number]; - -const QUALITY_GATE_STAGES = new Set([ - 'object_refining', - 'visual_refining', - 'long_tail_review', - 'ready_to_publish', -]); - -export class CustomWorldAgentQualityGateService { - // 当前先把最核心的阻断项和提醒项独立收口,后续 publish gate 可以直接复用同一套 finding。 - buildQualityFindings(params: { - draftProfile: unknown; - assetCoverage?: CustomWorldAssetCoverageSummary | null; - stage?: CustomWorldAgentStage; - }): CustomWorldAgentQualityFinding[] { - if (params.stage && !QUALITY_GATE_STAGES.has(params.stage)) { - return []; - } - - const profile = normalizeFoundationDraftProfile(params.draftProfile); - if (!profile) { - return []; - } - - const findings: CustomWorldAgentQualityFinding[] = []; - const totalRoleCount = [ - ...new Set( - [...profile.playableNpcs, ...profile.storyNpcs].map((entry) => entry.id), - ), - ].length; - - if (totalRoleCount === 0) { - findings.push({ - id: 'missing-core-roles', - severity: 'blocker', - code: 'missing_core_roles', - message: '当前世界底稿还没有任何角色,暂时无法进入发布前收口阶段。', - }); - } - - if (profile.landmarks.length === 0) { - findings.push({ - id: 'missing-core-landmarks', - severity: 'blocker', - code: 'missing_core_landmarks', - message: '当前世界底稿还没有任何地点,至少需要补出一处关键地点。', - }); - } - - if (!profile.playerGoal.trim()) { - findings.push({ - id: 'missing-player-goal', - severity: 'warning', - code: 'missing_player_goal', - message: '玩家目标还不够明确,后续进入结果页后建议优先补齐可执行目标。', - }); - } - - if (params.assetCoverage && !params.assetCoverage.allRoleAssetsReady) { - findings.push({ - id: 'role-assets-pending', - severity: 'warning', - code: 'role_assets_pending', - message: '仍有角色资产未完全补齐,结果页可继续补主图与动作资源。', - }); - } - - if (params.assetCoverage && !params.assetCoverage.allSceneAssetsReady) { - findings.push({ - id: 'scene-assets-pending', - severity: 'warning', - code: 'scene_assets_pending', - message: '仍有场景分幕图未补齐,后续结果页进入发布前需要继续完善。', - }); - } - - return findings; - } -} diff --git a/server-node/src/services/customWorldAgentRepositoryTestHelpers.ts b/server-node/src/services/customWorldAgentRepositoryTestHelpers.ts deleted file mode 100644 index a6bcb094..00000000 --- a/server-node/src/services/customWorldAgentRepositoryTestHelpers.ts +++ /dev/null @@ -1,305 +0,0 @@ -import type { - CustomWorldGalleryCard, - CustomWorldLibraryEntry, - CustomWorldProfileRecord, - CustomWorldSessionRecord, -} from '../../../packages/shared/src/contracts/runtime.js'; -import { extractCustomWorldLibraryMetadata } from '../repositories/customWorldLibraryMetadata.js'; -import type { RpgAgentSessionRepositoryPort } from '../repositories/RpgAgentSessionRepository.js'; -import type { RpgWorldProfileRepositoryPort } from '../repositories/RpgWorldProfileRepository.js'; - -type StoredProfileEntry = CustomWorldLibraryEntry; -type SeedSessionRecord = CustomWorldSessionRecord & { userId: string }; - -function cloneRepositoryValue(value: T): T { - return JSON.parse(JSON.stringify(value)) as T; -} - -function ensureProfileRecord( - profileId: string, - profile: Record, -): CustomWorldProfileRecord { - return { - ...cloneRepositoryValue(profile), - id: profileId, - } as CustomWorldProfileRecord; -} - -function buildProfileEntry(params: { - userId: string; - profileId: string; - profile: Record; - authorDisplayName: string; - visibility: 'draft' | 'published'; - updatedAt: string; - publishedAt: string | null; -}) { - const profileRecord = ensureProfileRecord(params.profileId, params.profile); - const metadata = extractCustomWorldLibraryMetadata(profileRecord); - - return { - ownerUserId: params.userId, - profileId: params.profileId, - profile: profileRecord, - visibility: params.visibility, - publishedAt: params.visibility === 'published' ? params.publishedAt : null, - updatedAt: params.updatedAt, - authorDisplayName: params.authorDisplayName || '玩家', - worldName: metadata.worldName, - subtitle: metadata.subtitle, - summaryText: metadata.summaryText, - coverImageSrc: metadata.coverImageSrc, - themeMode: metadata.themeMode, - playableNpcCount: metadata.playableNpcCount, - landmarkCount: metadata.landmarkCount, - } satisfies StoredProfileEntry; -} - -function toGalleryCard(entry: StoredProfileEntry): CustomWorldGalleryCard { - const { profile: _profile, ...card } = entry; - return cloneRepositoryValue(card); -} - -function sortEntriesByUpdatedAt(entries: T[]) { - return [...entries].sort((left, right) => - right.updatedAt.localeCompare(left.updatedAt), - ); -} - -/** - * 这组内存仓储 helper 让 phase2~5 与 works 集成测试直接依赖工作包 F 拆出来的领域端口, - * 避免继续把 RuntimeRepositoryPort 当成 session/profile 的测试替身。 - */ -export function createInMemoryRpgWorldRepositoryPorts(options?: { - sessionRecords?: SeedSessionRecord[]; - profileEntries?: Array>; -}) { - const sessionsByUser = new Map>(); - const profilesByUser = new Map>(); - - const ensureSessionBucket = (userId: string) => { - const currentBucket = sessionsByUser.get(userId); - if (currentBucket) { - return currentBucket; - } - - const nextBucket = new Map(); - sessionsByUser.set(userId, nextBucket); - return nextBucket; - }; - - const ensureProfileBucket = (userId: string) => { - const currentBucket = profilesByUser.get(userId); - if (currentBucket) { - return currentBucket; - } - - const nextBucket = new Map(); - profilesByUser.set(userId, nextBucket); - return nextBucket; - }; - - const listOwnEntries = (userId: string) => - sortEntriesByUpdatedAt([...ensureProfileBucket(userId).values()]).map((entry) => - cloneRepositoryValue(entry), - ); - - options?.sessionRecords?.forEach((record) => { - ensureSessionBucket(record.userId).set( - record.sessionId, - cloneRepositoryValue(record), - ); - }); - - options?.profileEntries?.forEach((entry) => { - ensureProfileBucket(entry.ownerUserId).set( - entry.profileId, - cloneRepositoryValue(entry), - ); - }); - - const rpgAgentSessionRepository: RpgAgentSessionRepositoryPort = { - async listSessions(userId: string) { - return sortEntriesByUpdatedAt([...ensureSessionBucket(userId).values()]).map( - (record) => cloneRepositoryValue(record), - ); - }, - - async getSession(userId: string, sessionId: string) { - const record = ensureSessionBucket(userId).get(sessionId) ?? null; - return record ? cloneRepositoryValue(record) : null; - }, - - async upsertSession( - userId: string, - sessionId: string, - session: CustomWorldSessionRecord, - ) { - const nextSession = cloneRepositoryValue({ - ...session, - userId, - sessionId, - }); - ensureSessionBucket(userId).set(sessionId, nextSession); - return cloneRepositoryValue(nextSession); - }, - }; - - const rpgWorldProfileRepository: RpgWorldProfileRepositoryPort = { - async listOwnProfiles(userId: string) { - return listOwnEntries(userId); - }, - - async upsertOwnProfile( - userId: string, - profileId: string, - profile: Record, - authorDisplayName: string, - ) { - const bucket = ensureProfileBucket(userId); - const currentEntry = bucket.get(profileId); - const now = new Date().toISOString(); - const nextEntry = buildProfileEntry({ - userId, - profileId, - profile, - authorDisplayName: - authorDisplayName || currentEntry?.authorDisplayName || '玩家', - visibility: currentEntry?.visibility ?? 'draft', - updatedAt: now, - publishedAt: - currentEntry?.visibility === 'published' - ? currentEntry.publishedAt || now - : null, - }); - - bucket.set(profileId, nextEntry); - - return { - entry: cloneRepositoryValue(nextEntry), - entries: await listOwnEntries(userId), - }; - }, - - async syncProfileFromSnapshot( - userId: string, - profileId: string, - profile: Record, - syncedAt: string, - ) { - const bucket = ensureProfileBucket(userId); - const currentEntry = bucket.get(profileId); - bucket.set( - profileId, - buildProfileEntry({ - userId, - profileId, - profile, - authorDisplayName: currentEntry?.authorDisplayName || '玩家', - visibility: currentEntry?.visibility ?? 'draft', - updatedAt: syncedAt, - publishedAt: - currentEntry?.visibility === 'published' - ? currentEntry.publishedAt || syncedAt - : null, - }), - ); - }, - - async softDeleteOwnProfile(userId: string, profileId: string) { - ensureProfileBucket(userId).delete(profileId); - return listOwnEntries(userId); - }, - - async publishOwnProfile( - userId: string, - profileId: string, - authorDisplayName: string, - ) { - const bucket = ensureProfileBucket(userId); - const currentEntry = bucket.get(profileId); - if (!currentEntry) { - return null; - } - - const now = new Date().toISOString(); - const nextEntry = buildProfileEntry({ - userId, - profileId, - profile: currentEntry.profile, - authorDisplayName: - authorDisplayName || currentEntry.authorDisplayName || '玩家', - visibility: 'published', - updatedAt: now, - publishedAt: now, - }); - bucket.set(profileId, nextEntry); - - return { - entry: cloneRepositoryValue(nextEntry), - entries: await listOwnEntries(userId), - }; - }, - - async unpublishOwnProfile( - userId: string, - profileId: string, - authorDisplayName: string, - ) { - const bucket = ensureProfileBucket(userId); - const currentEntry = bucket.get(profileId); - if (!currentEntry) { - return null; - } - - const now = new Date().toISOString(); - const nextEntry = buildProfileEntry({ - userId, - profileId, - profile: currentEntry.profile, - authorDisplayName: - authorDisplayName || currentEntry.authorDisplayName || '玩家', - visibility: 'draft', - updatedAt: now, - publishedAt: null, - }); - bucket.set(profileId, nextEntry); - - return { - entry: cloneRepositoryValue(nextEntry), - entries: await listOwnEntries(userId), - }; - }, - - async listPublishedGallery() { - return [...profilesByUser.values()] - .flatMap((bucket) => [...bucket.values()]) - .filter((entry) => entry.visibility === 'published') - .sort((left, right) => { - const publishedAtDiff = (right.publishedAt || '').localeCompare( - left.publishedAt || '', - ); - if (publishedAtDiff !== 0) { - return publishedAtDiff; - } - - return right.updatedAt.localeCompare(left.updatedAt); - }) - .map((entry) => toGalleryCard(entry)); - }, - - async getPublishedGalleryDetail(ownerUserId: string, profileId: string) { - const entry = ensureProfileBucket(ownerUserId).get(profileId); - if (!entry || entry.visibility !== 'published') { - return null; - } - - return cloneRepositoryValue(entry); - }, - }; - - return { - rpgAgentSessionRepository, - rpgWorldProfileRepository, - }; -} diff --git a/server-node/src/services/customWorldAgentResultSyncService.test.ts b/server-node/src/services/customWorldAgentResultSyncService.test.ts deleted file mode 100644 index af20b40a..00000000 --- a/server-node/src/services/customWorldAgentResultSyncService.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { - createRpgAgentFoundationDraftProfileFixture, - createRpgCreationPublishedProfileFixture, -} from '../../../packages/shared/src/contracts/rpgCreationFixtures.js'; -import { CustomWorldAgentResultSyncService } from './customWorldAgentResultSyncService.js'; - -test('result sync service only writes summary fields and matching asset confirmations back into draft profile', () => { - const service = new CustomWorldAgentResultSyncService(); - const currentDraftProfile = createRpgAgentFoundationDraftProfileFixture(); - const resultProfile = createRpgCreationPublishedProfileFixture(); - const nextDraftProfile = service.syncResultProfileIntoDraftProfile({ - currentDraftProfile: currentDraftProfile as unknown as Record, - resultProfile, - }); - - assert.equal(nextDraftProfile.name, resultProfile.name); - assert.equal(nextDraftProfile.summary, resultProfile.summary); - assert.equal( - ( - nextDraftProfile.playableNpcs as Array<{ - generatedAnimationSetId?: string | null; - }> - )[0]?.generatedAnimationSetId, - 'animation-set-playable-1', - ); - assert.equal( - ( - nextDraftProfile.landmarks as Array<{ - imageSrc?: string | null; - }> - )[0]?.imageSrc, - '/generated-custom-world-scenes/landmark-1/latest-scene.png', - ); - assert.equal( - ( - nextDraftProfile.sceneChapters as Array<{ - acts?: Array<{ backgroundAssetId?: string | null }>; - }> - )[0]?.acts?.[0]?.backgroundAssetId, - 'scene-asset-runtime', - ); -}); - -test('result sync service keeps existing foundation structure when result profile carries unmatched runtime-only entities', () => { - const service = new CustomWorldAgentResultSyncService(); - const currentDraftProfile = createRpgAgentFoundationDraftProfileFixture(); - const nextDraftProfile = service.syncResultProfileIntoDraftProfile({ - currentDraftProfile: currentDraftProfile as unknown as Record, - resultProfile: { - ...createRpgCreationPublishedProfileFixture(), - playableNpcs: [ - { - id: 'runtime-only-role', - name: '运行时临时角色', - title: '结果页临时角色', - role: '测试角色', - description: '不应覆盖 foundation draft。', - backstory: '测试', - personality: '冷静', - motivation: '测试', - combatStyle: '观察', - initialAffinity: 0, - relationshipHooks: [], - tags: [], - skills: [], - initialItems: [], - backstoryReveal: { - publicSummary: '测试', - privateChatUnlockAffinity: 0, - chapters: [], - }, - }, - ], - storyNpcs: [], - landmarks: [ - { - id: 'runtime-only-landmark', - name: '运行时临时地点', - description: '不应覆盖 foundation draft。', - dangerLevel: 'low', - sceneNpcIds: [], - connections: [], - }, - ], - sceneChapterBlueprints: [], - }, - }); - - assert.equal( - ( - nextDraftProfile.playableNpcs as Array<{ id?: string; name?: string }> - )[0]?.id, - 'playable-1', - ); - assert.equal( - ( - nextDraftProfile.playableNpcs as Array<{ id?: string; name?: string }> - )[0]?.name, - '沈砺', - ); - assert.equal( - ( - nextDraftProfile.landmarks as Array<{ id?: string; name?: string }> - )[0]?.id, - 'landmark-1', - ); - assert.equal( - ( - nextDraftProfile.legacyResultProfile as { - playableNpcs?: Array<{ id?: string }>; - } - ).playableNpcs?.[0]?.id, - 'runtime-only-role', - ); -}); diff --git a/server-node/src/services/customWorldAgentResultSyncService.ts b/server-node/src/services/customWorldAgentResultSyncService.ts deleted file mode 100644 index 8fb05b7e..00000000 --- a/server-node/src/services/customWorldAgentResultSyncService.ts +++ /dev/null @@ -1,150 +0,0 @@ -import type { CustomWorldProfile } from '../modules/custom-world/runtimeTypes.js'; - -function toText(value: unknown) { - return typeof value === 'string' ? value.trim() : ''; -} - -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); -} - -function toRecordArray(value: unknown) { - return Array.isArray(value) - ? value.filter((item): item is Record => isRecord(item)) - : []; -} - -function cloneJsonRecord(value: T): T { - return JSON.parse(JSON.stringify(value)) as T; -} - -function syncRoleAssetsFromResultProfile(params: { - currentRoles: unknown; - resultRoles: unknown; -}) { - const resultRoleById = new Map( - toRecordArray(params.resultRoles).map((role) => [toText(role.id), role]), - ); - - return toRecordArray(params.currentRoles).map((currentRole) => { - const resultRole = resultRoleById.get(toText(currentRole.id)); - if (!resultRole) { - return currentRole; - } - - return { - ...currentRole, - imageSrc: toText(resultRole.imageSrc) || null, - generatedVisualAssetId: toText(resultRole.generatedVisualAssetId) || null, - generatedAnimationSetId: - toText(resultRole.generatedAnimationSetId) || null, - animationMap: isRecord(resultRole.animationMap) - ? cloneJsonRecord(resultRole.animationMap) - : null, - } satisfies Record; - }); -} - -function syncLandmarkAssetsFromResultProfile(params: { - currentLandmarks: unknown; - resultLandmarks: unknown; -}) { - const resultLandmarkById = new Map( - toRecordArray(params.resultLandmarks).map((landmark) => [ - toText(landmark.id), - landmark, - ]), - ); - - return toRecordArray(params.currentLandmarks).map((currentLandmark) => { - const resultLandmark = resultLandmarkById.get(toText(currentLandmark.id)); - if (!resultLandmark) { - return currentLandmark; - } - - return { - ...currentLandmark, - imageSrc: toText(resultLandmark.imageSrc) || null, - } satisfies Record; - }); -} - -function syncSceneChapterAssetsFromResultProfile(params: { - currentSceneChapters: unknown; - resultSceneChapters: unknown; -}) { - const resultSceneChapterBySceneId = new Map( - toRecordArray(params.resultSceneChapters).map((chapter) => [ - toText(chapter.sceneId), - chapter, - ]), - ); - - return toRecordArray(params.currentSceneChapters).map((currentChapter) => { - const resultChapter = resultSceneChapterBySceneId.get( - toText(currentChapter.sceneId), - ); - if (!resultChapter) { - return currentChapter; - } - - const resultActById = new Map( - toRecordArray(resultChapter.acts).map((act) => [toText(act.id), act]), - ); - - return { - ...currentChapter, - acts: toRecordArray(currentChapter.acts).map((currentAct) => { - const resultAct = resultActById.get(toText(currentAct.id)); - if (!resultAct) { - return currentAct; - } - - return { - ...currentAct, - backgroundImageSrc: toText(resultAct.backgroundImageSrc) || null, - backgroundAssetId: toText(resultAct.backgroundAssetId) || null, - } satisfies Record; - }), - } satisfies Record; - }); -} - -export class CustomWorldAgentResultSyncService { - // 阶段一只允许结果页把摘要与资产确认结果回写进 foundation draft,避免 runtime 结构反向污染草稿真相源。 - syncResultProfileIntoDraftProfile(params: { - currentDraftProfile: Record | null | undefined; - resultProfile: CustomWorldProfile; - }) { - const currentDraftProfile = params.currentDraftProfile ?? {}; - const resultProfile = params.resultProfile; - - return { - ...currentDraftProfile, - name: resultProfile.name, - subtitle: resultProfile.subtitle, - summary: resultProfile.summary, - tone: resultProfile.tone, - playerGoal: resultProfile.playerGoal, - majorFactions: resultProfile.majorFactions, - coreConflicts: resultProfile.coreConflicts, - playableNpcs: syncRoleAssetsFromResultProfile({ - currentRoles: currentDraftProfile.playableNpcs, - resultRoles: resultProfile.playableNpcs, - }), - storyNpcs: syncRoleAssetsFromResultProfile({ - currentRoles: currentDraftProfile.storyNpcs, - resultRoles: resultProfile.storyNpcs, - }), - landmarks: syncLandmarkAssetsFromResultProfile({ - currentLandmarks: currentDraftProfile.landmarks, - resultLandmarks: resultProfile.landmarks, - }), - sceneChapters: syncSceneChapterAssetsFromResultProfile({ - currentSceneChapters: currentDraftProfile.sceneChapters, - resultSceneChapters: resultProfile.sceneChapterBlueprints, - }), - legacyResultProfile: resultProfile as unknown as Record, - } satisfies Record; - } -} diff --git a/server-node/src/services/customWorldAgentRoleAssetStateService.test.ts b/server-node/src/services/customWorldAgentRoleAssetStateService.test.ts deleted file mode 100644 index 6d13defc..00000000 --- a/server-node/src/services/customWorldAgentRoleAssetStateService.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { buildRoleAssetSummary } from './customWorldAgentRoleAssetStateService.js'; - -test('role asset summary only requires run attack and configured skill actions', () => { - const summary = buildRoleAssetSummary({ - role: { - id: 'role-shenli', - name: '沈砺', - threadIds: ['thread-1'], - imageSrc: '/generated/shenli/portrait.png', - generatedVisualAssetId: 'visual-shenli', - generatedAnimationSetId: 'animation-shenli', - animationMap: { - run: { basePath: '/generated/shenli/run' }, - attack: { basePath: '/generated/shenli/attack' }, - }, - skills: [ - { - id: 'skill-tidelight', - name: '潮灯斩', - actionPreviewConfig: { - basePath: '/generated/shenli/skill-tidelight', - }, - }, - ], - }, - roleKind: 'playable', - }); - - assert.equal(summary.status, 'complete'); - assert.deepEqual(summary.missingAnimations, []); -}); - -test('role asset summary marks missing skill actions as required gaps', () => { - const summary = buildRoleAssetSummary({ - role: { - id: 'role-yunhe', - name: '云禾', - threadIds: [], - imageSrc: '/generated/yunhe/portrait.png', - generatedVisualAssetId: 'visual-yunhe', - generatedAnimationSetId: 'animation-yunhe', - animationMap: { - run: { basePath: '/generated/yunhe/run' }, - attack: { basePath: '/generated/yunhe/attack' }, - }, - skills: [ - { - id: 'skill-wave', - name: '断潮步', - actionPreviewConfig: null, - }, - ], - }, - roleKind: 'story', - }); - - assert.equal(summary.status, 'animations_ready'); - assert.deepEqual(summary.missingAnimations, ['skill:skill-wave']); -}); - -test('role asset summary treats idle and die as optional', () => { - const summary = buildRoleAssetSummary({ - role: { - id: 'role-lin', - name: '林砂', - threadIds: [], - imageSrc: '/generated/lin/portrait.png', - generatedVisualAssetId: 'visual-lin', - generatedAnimationSetId: 'animation-lin', - animationMap: { - run: { basePath: '/generated/lin/run' }, - attack: { basePath: '/generated/lin/attack' }, - }, - skills: [], - }, - roleKind: 'story', - }); - - assert.equal(summary.status, 'complete'); - assert.deepEqual(summary.missingAnimations, []); -}); - -test('role asset coverage includes scene act background readiness', async () => { - const { rebuildRoleAssetCoverage } = await import( - './customWorldAgentRoleAssetStateService.js' - ); - - const coverage = rebuildRoleAssetCoverage({ - playableNpcs: [ - { - id: 'role-playable', - name: '沈砺', - threadIds: ['thread-1'], - imageSrc: '/generated/role-playable.png', - generatedVisualAssetId: 'visual-role-playable', - skills: [], - }, - ], - storyNpcs: [], - sceneChapters: [ - { - sceneId: 'scene-dock', - sceneName: '潮汐码头', - acts: [ - { - id: 'scene-dock-act-1', - title: '雾里靠岸', - backgroundImageSrc: '/generated/scene-dock-act-1.png', - backgroundAssetId: 'scene-act-asset-1', - }, - { - id: 'scene-dock-act-2', - title: '封锁加压', - backgroundImageSrc: '', - backgroundAssetId: '', - }, - ], - }, - ], - }); - - assert.equal(coverage.sceneAssets.length, 2); - assert.equal(coverage.sceneAssets[0]?.status, 'ready'); - assert.equal(coverage.sceneAssets[1]?.status, 'missing'); - assert.equal(coverage.allSceneAssetsReady, false); -}); diff --git a/server-node/src/services/customWorldAgentRoleAssetStateService.ts b/server-node/src/services/customWorldAgentRoleAssetStateService.ts deleted file mode 100644 index b04e46b6..00000000 --- a/server-node/src/services/customWorldAgentRoleAssetStateService.ts +++ /dev/null @@ -1,489 +0,0 @@ -import type { - CustomWorldAssetCoverageSummary, - CustomWorldAssetPriorityTier, - CustomWorldRoleAssetStatus, - CustomWorldRoleAssetSummary, - CustomWorldSceneAssetSummary, -} from '../../../packages/shared/src/contracts/customWorldAgent.js'; - -const REQUIRED_ROLE_ANIMATION_KEYS = ['run', 'attack'] as const; - -type DraftRoleSkillRecord = { - id: string; - name: string; - actionPreviewConfig?: Record | null; -}; - -type DraftRoleRecord = { - id: string; - name: string; - threadIds: string[]; - imageSrc?: string | null; - generatedVisualAssetId?: string | null; - generatedAnimationSetId?: string | null; - animationMap?: Record | null; - skills: DraftRoleSkillRecord[]; -}; - -type DraftRoleKind = 'playable' | 'story'; - -type DraftSceneActRecord = { - id: string; - title: string; - backgroundImageSrc?: string | null; - backgroundAssetId?: string | null; -}; - -type DraftSceneChapterRecord = { - sceneId: string; - sceneName: string; - acts: DraftSceneActRecord[]; -}; - -type DraftStandaloneSceneRecord = { - sceneId: string; - sceneName: string; - imageSrc: string | null; - assetId: string | null; - sceneKind: 'camp' | 'landmark'; -}; - -type MergeRoleAssetIntoDraftProfilePayload = { - roleId: string; - portraitPath: string; - generatedVisualAssetId: string; - generatedAnimationSetId?: string | null; - animationMap?: Record | null; -}; - -function toText(value: unknown) { - return typeof value === 'string' ? value.trim() : ''; -} - -function toRecord(value: unknown) { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : null; -} - -function toRecordArray(value: unknown) { - return Array.isArray(value) - ? value.filter( - (item): item is Record => - Boolean(item) && typeof item === 'object' && !Array.isArray(item), - ) - : []; -} - -function toStringArray(value: unknown) { - return Array.isArray(value) - ? value - .map((item) => toText(item)) - .filter(Boolean) - .slice(0, 12) - : []; -} - -function toAnimationMap(value: unknown) { - return toRecord(value); -} - -function normalizeSceneActs(value: unknown) { - return toRecordArray(value) - .map((item, index) => ({ - id: toText(item.id) || `act-${index + 1}`, - title: toText(item.title) || `第 ${index + 1} 幕`, - backgroundImageSrc: toText(item.backgroundImageSrc) || null, - backgroundAssetId: toText(item.backgroundAssetId) || null, - })) - .filter((item) => Boolean(item.id)); -} - -function hasAnimationAsset(entryValue: unknown) { - const entry = toRecord(entryValue); - if (!entry) { - return false; - } - - return Boolean(toText(entry.basePath) || toText(entry.spriteSheetPath)); -} - -function hasAnimationSlot( - animationMap: Record | null | undefined, - slot: string, -) { - return hasAnimationAsset(animationMap?.[slot]); -} - -function normalizeRoleSkills(value: unknown, fallbackName = '角色') { - const skills = toRecordArray(value) - .map((item, index) => ({ - id: toText(item.id) || `skill-${index + 1}`, - name: toText(item.name) || `技能${index + 1}`, - actionPreviewConfig: toRecord(item.actionPreviewConfig), - })) - .filter((item) => Boolean(item.id)); - - if (skills.length > 0) { - return skills; - } - - return [ - { - id: 'skill-1', - name: `${toText(fallbackName).slice(0, 10) || '角色'}招牌动作`, - actionPreviewConfig: null, - }, - ]; -} - -function collectMissingSkillActions(role: DraftRoleRecord) { - return role.skills - .filter((skill) => !hasAnimationAsset(skill.actionPreviewConfig)) - .map((skill) => `skill:${skill.id}`); -} - -function resolvePriorityTier( - role: DraftRoleRecord, - roleKind: DraftRoleKind, -): CustomWorldAssetPriorityTier { - if (roleKind === 'playable') { - return 'hero'; - } - - return role.threadIds.length > 0 ? 'featured' : 'supporting'; -} - -function resolveNextPointCost( - status: CustomWorldRoleAssetStatus, - priorityTier: CustomWorldAssetPriorityTier, -) { - if (status === 'complete') { - return 0; - } - - if (status === 'missing') { - return priorityTier === 'supporting' ? 12 : 20; - } - - return priorityTier === 'supporting' ? 36 : 60; -} - -function collectDraftRoles(profileInput: unknown) { - const profile = toRecord(profileInput); - if (!profile) { - return [] as Array<{ role: DraftRoleRecord; roleKind: DraftRoleKind }>; - } - - const normalizeRole = ( - item: Record, - ): DraftRoleRecord | null => { - const id = toText(item.id); - const name = toText(item.name); - - if (!id || !name) { - return null; - } - - return { - id, - name, - threadIds: toStringArray(item.threadIds), - imageSrc: toText(item.imageSrc) || null, - generatedVisualAssetId: toText(item.generatedVisualAssetId) || null, - generatedAnimationSetId: toText(item.generatedAnimationSetId) || null, - animationMap: toAnimationMap(item.animationMap), - skills: normalizeRoleSkills(item.skills, toText(item.role) || name), - }; - }; - - return [ - ...toRecordArray(profile.playableNpcs) - .map((item) => { - const role = normalizeRole(item); - return role ? { role, roleKind: 'playable' as const } : null; - }) - .filter( - ( - item, - ): item is { - role: DraftRoleRecord; - roleKind: DraftRoleKind; - } => Boolean(item), - ), - ...toRecordArray(profile.storyNpcs) - .map((item) => { - const role = normalizeRole(item); - return role ? { role, roleKind: 'story' as const } : null; - }) - .filter( - ( - item, - ): item is { - role: DraftRoleRecord; - roleKind: DraftRoleKind; - } => Boolean(item), - ), - ]; -} - -function collectDraftSceneChapters(profileInput: unknown) { - const profile = toRecord(profileInput); - if (!profile) { - return [] as DraftSceneChapterRecord[]; - } - - return toRecordArray(profile.sceneChapters) - .map((item, index) => { - const sceneId = toText(item.sceneId); - const sceneName = toText(item.sceneName) || toText(item.title); - const acts = normalizeSceneActs(item.acts); - - if (!sceneId || acts.length === 0) { - return null; - } - - return { - sceneId, - sceneName: sceneName || `场景 ${index + 1}`, - acts, - } satisfies DraftSceneChapterRecord; - }) - .filter((item): item is DraftSceneChapterRecord => Boolean(item)); -} - -function buildStandaloneSceneAssetSummary( - scene: DraftStandaloneSceneRecord, -): CustomWorldSceneAssetSummary { - const ready = Boolean(scene.imageSrc || scene.assetId); - - return { - sceneId: scene.sceneId, - sceneName: scene.sceneName, - actId: null, - actTitle: scene.sceneKind === 'camp' ? '营地正式背景图' : '场景正式背景图', - imageSrc: scene.imageSrc, - assetId: scene.assetId, - status: ready ? 'ready' : 'missing', - nextPointCost: ready ? 0 : 12, - }; -} - -function collectStandaloneSceneRecords( - profileInput: unknown, - coveredSceneIds: Set, -) { - const profile = toRecord(profileInput); - if (!profile) { - return [] as DraftStandaloneSceneRecord[]; - } - - const standaloneScenes: DraftStandaloneSceneRecord[] = []; - const camp = toRecord(profile.camp); - if (camp) { - const campId = toText(camp.id); - const campImageSrc = toText(camp.imageSrc) || null; - const campAssetId = toText(camp.generatedSceneAssetId) || null; - if ( - campId && - !coveredSceneIds.has(campId) && - Boolean(campImageSrc || campAssetId) - ) { - standaloneScenes.push({ - sceneId: campId, - sceneName: toText(camp.name) || '开局营地', - imageSrc: campImageSrc, - assetId: campAssetId, - sceneKind: 'camp', - }); - } - } - - toRecordArray(profile.landmarks).forEach((landmark, index) => { - const landmarkId = toText(landmark.id); - const landmarkImageSrc = toText(landmark.imageSrc) || null; - const landmarkAssetId = toText(landmark.generatedSceneAssetId) || null; - if ( - !landmarkId || - coveredSceneIds.has(landmarkId) || - !Boolean(landmarkImageSrc || landmarkAssetId) - ) { - return; - } - - standaloneScenes.push({ - sceneId: landmarkId, - sceneName: toText(landmark.name) || `关键地点 ${index + 1}`, - imageSrc: landmarkImageSrc, - assetId: landmarkAssetId, - sceneKind: 'landmark', - }); - }); - - return standaloneScenes; -} - -export function resolveRoleAssetStatusLabel( - status: CustomWorldRoleAssetStatus, -) { - if (status === 'complete') { - return '动作已就绪'; - } - - if (status === 'animations_ready') { - return '动作补齐中'; - } - - if (status === 'visual_ready') { - return '主图已就绪'; - } - - return '待生成主图'; -} - -export function buildRoleAssetSummary(params: { - role: DraftRoleRecord; - roleKind: DraftRoleKind; -}): CustomWorldRoleAssetSummary { - const { role, roleKind } = params; - const priorityTier = resolvePriorityTier(role, roleKind); - const missingAnimations = [ - ...REQUIRED_ROLE_ANIMATION_KEYS.filter( - (slot) => !hasAnimationSlot(role.animationMap, slot), - ), - ...collectMissingSkillActions(role), - ]; - const hasPortrait = - Boolean(role.imageSrc) && Boolean(role.generatedVisualAssetId); - const hasAnimationSet = Boolean(role.generatedAnimationSetId); - const status: CustomWorldRoleAssetStatus = !hasPortrait - ? 'missing' - : missingAnimations.length === 0 - ? 'complete' - : hasAnimationSet - ? 'animations_ready' - : 'visual_ready'; - - return { - roleId: role.id, - roleName: role.name, - roleKind, - priorityTier, - portraitPath: role.imageSrc ?? null, - generatedVisualAssetId: role.generatedVisualAssetId ?? null, - generatedAnimationSetId: role.generatedAnimationSetId ?? null, - status, - missingAnimations, - nextPointCost: resolveNextPointCost(status, priorityTier), - }; -} - -export function getRoleAssetSummaryById(draftProfile: unknown, roleId: string) { - const roleEntry = collectDraftRoles(draftProfile).find( - (entry) => entry.role.id === roleId, - ); - - if (!roleEntry) { - return null; - } - - return buildRoleAssetSummary(roleEntry); -} - -export function rebuildRoleAssetCoverage( - draftProfile: unknown, -): CustomWorldAssetCoverageSummary { - const roleAssets = collectDraftRoles(draftProfile).map((entry) => - buildRoleAssetSummary(entry), - ); - const chapterSceneAssets: CustomWorldSceneAssetSummary[] = collectDraftSceneChapters( - draftProfile, - ).flatMap((sceneChapter) => - sceneChapter.acts.map((act) => { - const imageSrc = act.backgroundImageSrc ?? null; - const assetId = act.backgroundAssetId ?? null; - const ready = Boolean(imageSrc || assetId); - - return { - sceneId: sceneChapter.sceneId, - sceneName: sceneChapter.sceneName, - actId: act.id, - actTitle: act.title, - imageSrc, - assetId, - status: ready ? 'ready' : 'missing', - nextPointCost: ready ? 0 : 12, - } satisfies CustomWorldSceneAssetSummary; - }), - ); - const coveredSceneIds = new Set( - chapterSceneAssets.map((entry) => entry.sceneId).filter(Boolean), - ); - const standaloneSceneAssets = collectStandaloneSceneRecords( - draftProfile, - coveredSceneIds, - ).map((scene) => buildStandaloneSceneAssetSummary(scene)); - const sceneAssets = [...chapterSceneAssets, ...standaloneSceneAssets]; - - return { - roleAssets, - sceneAssets, - allRoleAssetsReady: - roleAssets.length > 0 && - roleAssets.every((entry) => entry.status !== 'missing'), - allSceneAssetsReady: - sceneAssets.length > 0 && - sceneAssets.every((entry) => entry.status === 'ready'), - }; -} - -export function mergeRoleAssetIntoDraftProfile( - draftProfileInput: Record, - payload: MergeRoleAssetIntoDraftProfilePayload, -) { - const nextDraftProfile = { - ...draftProfileInput, - }; - let updatedRole: Record | null = null; - - const updateRoleList = (field: 'playableNpcs' | 'storyNpcs') => { - const currentList = toRecordArray(nextDraftProfile[field]); - let touched = false; - const nextList = currentList.map((item) => { - if (toText(item.id) !== payload.roleId) { - return item; - } - - touched = true; - updatedRole = { - ...item, - imageSrc: payload.portraitPath, - generatedVisualAssetId: payload.generatedVisualAssetId, - }; - if (payload.generatedAnimationSetId !== undefined) { - updatedRole.generatedAnimationSetId = payload.generatedAnimationSetId; - } - if (payload.animationMap !== undefined) { - updatedRole.animationMap = payload.animationMap; - } - return updatedRole; - }); - - if (touched) { - nextDraftProfile[field] = nextList; - } - - return touched; - }; - - const touched = updateRoleList('playableNpcs') || updateRoleList('storyNpcs'); - - if (!touched || !updatedRole) { - throw new Error('目标角色不存在,无法同步角色资产。'); - } - - return { - draftProfile: nextDraftProfile, - updatedRole, - }; -} diff --git a/server-node/src/services/customWorldAgentSessionStore.ts b/server-node/src/services/customWorldAgentSessionStore.ts deleted file mode 100644 index 73d5025e..00000000 --- a/server-node/src/services/customWorldAgentSessionStore.ts +++ /dev/null @@ -1,451 +0,0 @@ -import crypto from 'node:crypto'; - -import type { - CustomWorldAgentMessage, - CustomWorldAgentOperationRecord, - CustomWorldAgentSessionSnapshot, -} from '../../../packages/shared/src/contracts/customWorldAgent.js'; -import type { CustomWorldSessionRecord as LegacyCustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js'; -import { - applyCustomWorldAgentSessionCompatibility, - isCustomWorldAgentSessionRecord, -} from './rpg-agent-session-store/rpgAgentSessionCompatibility.js'; -import { createCustomWorldAgentSessionRecord } from './rpg-agent-session-store/rpgAgentSessionFactory.js'; -import { - cloneRpgAgentSessionValue, - type CreateCustomWorldAgentSessionInput, - type CustomWorldAgentSessionRecord, - CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX, -} from './rpg-agent-session-store/rpgAgentSessionRecord.js'; -import type { RpgAgentSessionRepositoryPort } from '../repositories/RpgAgentSessionRepository.js'; -import { - RpgAgentSessionRepositoryAdapter, -} from './rpg-agent-session-store/rpgAgentSessionRepositoryAdapter.js'; -import { normalizeEightAnchorContent } from './eightAnchorCompatibilityService.js'; - -function toSnapshot( - record: CustomWorldAgentSessionRecord, -): CustomWorldAgentSessionSnapshot { - return { - sessionId: record.sessionId, - currentTurn: record.currentTurn, - anchorContent: cloneRpgAgentSessionValue(record.anchorContent), - progressPercent: record.progressPercent, - lastAssistantReply: record.lastAssistantReply, - stage: record.stage, - focusCardId: record.focusCardId, - creatorIntent: cloneRpgAgentSessionValue(record.creatorIntent), - creatorIntentReadiness: cloneRpgAgentSessionValue( - record.creatorIntentReadiness, - ), - anchorPack: cloneRpgAgentSessionValue(record.anchorPack), - lockState: cloneRpgAgentSessionValue(record.lockState), - draftProfile: cloneRpgAgentSessionValue(record.draftProfile), - messages: cloneRpgAgentSessionValue(record.messages), - draftCards: cloneRpgAgentSessionValue(record.draftCards), - pendingClarifications: cloneRpgAgentSessionValue( - record.pendingClarifications, - ), - suggestedActions: cloneRpgAgentSessionValue(record.suggestedActions), - recommendedReplies: cloneRpgAgentSessionValue(record.recommendedReplies), - qualityFindings: cloneRpgAgentSessionValue(record.qualityFindings), - assetCoverage: cloneRpgAgentSessionValue(record.assetCoverage), - checkpoints: record.checkpoints.map((checkpoint) => ({ - checkpointId: checkpoint.checkpointId, - createdAt: checkpoint.createdAt, - label: checkpoint.label, - })), - updatedAt: record.updatedAt, - }; -} - -function normalizeCompatibleSessionRecord( - record: CustomWorldAgentSessionRecord, -): CustomWorldAgentSessionRecord { - return cloneRpgAgentSessionValue( - applyCustomWorldAgentSessionCompatibility( - record, - ) as unknown as CustomWorldAgentSessionRecord, - ); -} - -export { CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX }; -export type { CustomWorldAgentSessionRecord }; - -export class CustomWorldAgentSessionStore { - private readonly sessionRepository: RpgAgentSessionRepositoryAdapter; - - constructor(sessionRepository: RpgAgentSessionRepositoryPort) { - this.sessionRepository = new RpgAgentSessionRepositoryAdapter( - sessionRepository, - ); - } - - private async persist( - record: CustomWorldAgentSessionRecord, - ): Promise { - await this.sessionRepository.upsert( - record.userId, - record.sessionId, - record as unknown as LegacyCustomWorldSessionRecord, - ); - - return cloneRpgAgentSessionValue(record); - } - - private async mutate( - userId: string, - sessionId: string, - mutateFn: (record: CustomWorldAgentSessionRecord) => void, - ): Promise { - const current = await this.get(userId, sessionId); - if (!current) { - return null; - } - - const nextRecord = cloneRpgAgentSessionValue(current); - mutateFn(nextRecord); - nextRecord.updatedAt = new Date().toISOString(); - return this.persist(nextRecord); - } - - async create( - userId: string, - input: CreateCustomWorldAgentSessionInput, - ): Promise { - const record = createCustomWorldAgentSessionRecord(userId, input); - await this.persist(record); - return cloneRpgAgentSessionValue(record); - } - - async list(userId: string): Promise { - const records = await this.sessionRepository.list(userId); - - return records - .filter((record) => isCustomWorldAgentSessionRecord(record)) - .map((record) => normalizeCompatibleSessionRecord(record)) - .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); - } - - async get( - userId: string, - sessionId: string, - ): Promise { - if (!sessionId.trim()) { - return null; - } - - const record = await this.sessionRepository.get(userId, sessionId); - if (!isCustomWorldAgentSessionRecord(record)) { - return null; - } - - return normalizeCompatibleSessionRecord(record); - } - - async getSnapshot(userId: string, sessionId: string) { - const record = await this.get(userId, sessionId); - return record ? toSnapshot(record) : null; - } - - async appendMessage( - userId: string, - sessionId: string, - message: CustomWorldAgentMessage, - ) { - return this.mutate(userId, sessionId, (record) => { - record.messages.push(cloneRpgAgentSessionValue(message)); - }); - } - - async replaceDerivedState( - userId: string, - sessionId: string, - patch: Partial< - Pick< - CustomWorldAgentSessionRecord, - | 'currentTurn' - | 'anchorContent' - | 'progressPercent' - | 'lastAssistantReply' - | 'stage' - | 'creatorIntent' - | 'creatorIntentReadiness' - | 'anchorPack' - | 'lockState' - | 'draftProfile' - | 'pendingClarifications' - | 'suggestedActions' - | 'recommendedReplies' - | 'draftCards' - | 'qualityFindings' - | 'focusCardId' - | 'assetCoverage' - > - >, - ) { - return this.mutate(userId, sessionId, (record) => { - if (typeof patch.currentTurn === 'number') { - record.currentTurn = Math.max(0, Math.round(patch.currentTurn)); - } - if (patch.anchorContent !== undefined) { - record.anchorContent = normalizeEightAnchorContent(patch.anchorContent); - } - if (typeof patch.progressPercent === 'number') { - record.progressPercent = Math.max( - 0, - Math.min(100, Math.round(patch.progressPercent)), - ); - } - if (patch.lastAssistantReply !== undefined) { - record.lastAssistantReply = patch.lastAssistantReply; - } - if (patch.stage) { - record.stage = patch.stage; - } - if (patch.focusCardId !== undefined) { - record.focusCardId = patch.focusCardId; - } - if (patch.creatorIntent !== undefined) { - record.creatorIntent = cloneRpgAgentSessionValue(patch.creatorIntent); - } - if (patch.creatorIntentReadiness !== undefined) { - record.creatorIntentReadiness = cloneRpgAgentSessionValue( - patch.creatorIntentReadiness, - ); - } - if (patch.anchorPack !== undefined) { - record.anchorPack = cloneRpgAgentSessionValue(patch.anchorPack); - } - if (patch.lockState !== undefined) { - record.lockState = cloneRpgAgentSessionValue(patch.lockState); - } - if (patch.draftProfile !== undefined) { - record.draftProfile = cloneRpgAgentSessionValue(patch.draftProfile); - } - if (patch.pendingClarifications !== undefined) { - record.pendingClarifications = cloneRpgAgentSessionValue( - patch.pendingClarifications, - ); - } - if (patch.suggestedActions !== undefined) { - record.suggestedActions = cloneRpgAgentSessionValue( - patch.suggestedActions, - ); - } - if (patch.recommendedReplies !== undefined) { - record.recommendedReplies = cloneRpgAgentSessionValue( - patch.recommendedReplies, - ); - } - if (patch.draftCards !== undefined) { - record.draftCards = cloneRpgAgentSessionValue(patch.draftCards); - } - if (patch.qualityFindings !== undefined) { - record.qualityFindings = cloneRpgAgentSessionValue( - patch.qualityFindings, - ); - } - if (patch.assetCoverage !== undefined) { - record.assetCoverage = cloneRpgAgentSessionValue(patch.assetCoverage); - } - }); - } - - async createOperation( - userId: string, - sessionId: string, - operation: CustomWorldAgentOperationRecord, - ) { - return this.mutate(userId, sessionId, (record) => { - record.operations.push(cloneRpgAgentSessionValue(operation)); - }); - } - - async getOperation(userId: string, sessionId: string, operationId: string) { - const record = await this.get(userId, sessionId); - if (!record) { - return null; - } - - const operation = record.operations.find( - (item) => item.operationId === operationId, - ); - return operation ? cloneRpgAgentSessionValue(operation) : null; - } - - async updateOperation( - userId: string, - sessionId: string, - operationId: string, - patch: Partial, - ) { - return this.mutate(userId, sessionId, (record) => { - const operation = record.operations.find( - (item) => item.operationId === operationId, - ); - if (!operation) { - return; - } - - if (patch.type) { - operation.type = patch.type; - } - if (patch.status) { - operation.status = patch.status; - } - if (patch.phaseLabel) { - operation.phaseLabel = patch.phaseLabel; - } - if (patch.phaseDetail) { - operation.phaseDetail = patch.phaseDetail; - } - if (typeof patch.progress === 'number') { - operation.progress = patch.progress; - } - if (patch.error !== undefined) { - operation.error = patch.error; - } - }); - } - - async appendCheckpoint( - userId: string, - sessionId: string, - input: { - checkpointId?: string; - label: string; - snapshot?: Partial< - Pick< - CustomWorldAgentSessionRecord, - | 'currentTurn' - | 'anchorContent' - | 'progressPercent' - | 'lastAssistantReply' - | 'stage' - | 'focusCardId' - | 'creatorIntent' - | 'creatorIntentReadiness' - | 'anchorPack' - | 'lockState' - | 'draftProfile' - | 'pendingClarifications' - | 'suggestedActions' - | 'recommendedReplies' - | 'draftCards' - | 'qualityFindings' - | 'assetCoverage' - > - > | null; - }, - ) { - return this.mutate(userId, sessionId, (record) => { - record.checkpoints.push({ - checkpointId: - input.checkpointId || - `checkpoint-${crypto.randomBytes(8).toString('hex')}`, - createdAt: new Date().toISOString(), - label: input.label, - snapshot: input.snapshot - ? { - currentTurn: - typeof input.snapshot.currentTurn === 'number' - ? Math.max(0, Math.round(input.snapshot.currentTurn)) - : record.currentTurn, - anchorContent: cloneRpgAgentSessionValue( - input.snapshot.anchorContent ?? record.anchorContent, - ), - progressPercent: - typeof input.snapshot.progressPercent === 'number' - ? Math.max( - 0, - Math.min(100, Math.round(input.snapshot.progressPercent)), - ) - : record.progressPercent, - lastAssistantReply: - input.snapshot.lastAssistantReply ?? record.lastAssistantReply, - stage: input.snapshot.stage ?? record.stage, - focusCardId: - input.snapshot.focusCardId !== undefined - ? input.snapshot.focusCardId - : record.focusCardId, - creatorIntent: cloneRpgAgentSessionValue( - input.snapshot.creatorIntent ?? record.creatorIntent, - ), - creatorIntentReadiness: cloneRpgAgentSessionValue( - input.snapshot.creatorIntentReadiness ?? - record.creatorIntentReadiness, - ), - anchorPack: cloneRpgAgentSessionValue( - input.snapshot.anchorPack ?? record.anchorPack, - ), - lockState: cloneRpgAgentSessionValue( - input.snapshot.lockState ?? record.lockState, - ), - draftProfile: cloneRpgAgentSessionValue( - input.snapshot.draftProfile ?? record.draftProfile, - ), - pendingClarifications: cloneRpgAgentSessionValue( - input.snapshot.pendingClarifications ?? - record.pendingClarifications, - ), - suggestedActions: cloneRpgAgentSessionValue( - input.snapshot.suggestedActions ?? record.suggestedActions, - ), - recommendedReplies: cloneRpgAgentSessionValue( - input.snapshot.recommendedReplies ?? record.recommendedReplies, - ), - draftCards: cloneRpgAgentSessionValue( - input.snapshot.draftCards ?? record.draftCards, - ), - qualityFindings: cloneRpgAgentSessionValue( - input.snapshot.qualityFindings ?? record.qualityFindings, - ), - assetCoverage: cloneRpgAgentSessionValue( - input.snapshot.assetCoverage ?? record.assetCoverage, - ), - } - : null, - }); - }); - } - - async restoreCheckpoint( - userId: string, - sessionId: string, - checkpointId: string, - ) { - return this.mutate(userId, sessionId, (record) => { - const checkpoint = record.checkpoints.find( - (entry) => entry.checkpointId === checkpointId, - ); - if (!checkpoint?.snapshot) { - return; - } - - const snapshot = cloneRpgAgentSessionValue(checkpoint.snapshot); - record.currentTurn = snapshot.currentTurn; - record.anchorContent = snapshot.anchorContent; - record.progressPercent = snapshot.progressPercent; - record.lastAssistantReply = snapshot.lastAssistantReply; - record.stage = snapshot.stage; - record.focusCardId = snapshot.focusCardId; - record.creatorIntent = snapshot.creatorIntent; - record.creatorIntentReadiness = snapshot.creatorIntentReadiness; - record.anchorPack = snapshot.anchorPack; - record.lockState = snapshot.lockState; - record.draftProfile = snapshot.draftProfile; - record.pendingClarifications = snapshot.pendingClarifications; - record.suggestedActions = snapshot.suggestedActions; - record.recommendedReplies = snapshot.recommendedReplies; - record.draftCards = snapshot.draftCards; - record.qualityFindings = snapshot.qualityFindings; - record.assetCoverage = snapshot.assetCoverage; - }); - } - - async listDraftCards(userId: string, sessionId: string) { - const record = await this.get(userId, sessionId); - return record ? cloneRpgAgentSessionValue(record.draftCards) : null; - } -} diff --git a/server-node/src/services/customWorldAgentSnapshotBuilder.ts b/server-node/src/services/customWorldAgentSnapshotBuilder.ts deleted file mode 100644 index 76f69d3e..00000000 --- a/server-node/src/services/customWorldAgentSnapshotBuilder.ts +++ /dev/null @@ -1,199 +0,0 @@ -import type { - CreatorIntentReadiness, - CustomWorldAgentStage, - CustomWorldAssetCoverageSummary, - CustomWorldDraftCardSummary, - CustomWorldPendingClarification, - EightAnchorContent, -} from '../../../packages/shared/src/contracts/customWorldAgent.js'; -import { - buildDraftSummaryFromIntent, - buildDraftTitleFromIntent, - type CustomWorldCreatorIntentRecord, -} from './customWorldAgentIntentExtractionService.js'; -import { CustomWorldAgentQualityGateService } from './customWorldAgentQualityGateService.js'; -import { rebuildRoleAssetCoverage } from './customWorldAgentRoleAssetStateService.js'; -import type { CustomWorldAgentSessionRecord } from './customWorldAgentSessionStore.js'; -import { CustomWorldAgentSuggestedActionService } from './customWorldAgentSuggestedActionService.js'; -import { buildAnchorPackFromEightAnchorContent } from './eightAnchorCompatibilityService.js'; -import { CustomWorldAgentDraftCompiler } from './customWorldAgentDraftCompiler.js'; - -export type CustomWorldAgentDerivedStatePatch = Partial< - Pick< - CustomWorldAgentSessionRecord, - | 'currentTurn' - | 'anchorContent' - | 'progressPercent' - | 'lastAssistantReply' - | 'stage' - | 'focusCardId' - | 'creatorIntent' - | 'creatorIntentReadiness' - | 'anchorPack' - | 'draftProfile' - | 'pendingClarifications' - | 'suggestedActions' - | 'recommendedReplies' - | 'draftCards' - | 'qualityFindings' - | 'assetCoverage' - > ->; - -export class CustomWorldAgentSnapshotBuilder { - constructor( - private readonly draftCompiler: CustomWorldAgentDraftCompiler, - private readonly suggestedActionService: CustomWorldAgentSuggestedActionService, - private readonly qualityGateService: CustomWorldAgentQualityGateService, - ) {} - - // 把“草稿改动后需要重算哪些派生字段”统一封装成一个入口,避免每个 action 都重复拼 patch。 - buildRefiningState(params: { - previousStage: CustomWorldAgentStage; - draftProfile: Record; - draftCards?: CustomWorldDraftCardSummary[]; - assetCoverage?: CustomWorldAssetCoverageSummary; - nextStage?: Extract< - CustomWorldAgentStage, - | 'object_refining' - | 'visual_refining' - | 'long_tail_review' - | 'ready_to_publish' - >; - focusCardId?: string | null; - }): CustomWorldAgentDerivedStatePatch { - const nextDraftCards = - params.draftCards ?? this.draftCompiler.compileDraftCards(params.draftProfile); - const assetCoverage = - params.assetCoverage ?? rebuildRoleAssetCoverage(params.draftProfile); - const nextStage = - params.nextStage ?? - (params.previousStage === 'visual_refining' - ? 'visual_refining' - : params.previousStage === 'long_tail_review' - ? 'long_tail_review' - : params.previousStage === 'ready_to_publish' - ? 'ready_to_publish' - : 'object_refining'); - - return { - stage: nextStage, - draftProfile: params.draftProfile, - draftCards: nextDraftCards, - assetCoverage, - qualityFindings: this.qualityGateService.buildQualityFindings({ - draftProfile: params.draftProfile, - assetCoverage, - stage: nextStage, - }), - focusCardId: params.focusCardId, - suggestedActions: this.suggestedActionService.buildSuggestedActions({ - stage: nextStage, - isReady: true, - draftProfile: params.draftProfile, - draftCards: nextDraftCards, - }), - recommendedReplies: [], - }; - } - - buildFoundationDraftState(params: { - creatorIntent: Record | null; - anchorPack: Record | null; - draftProfile: Record; - assetCoverage?: CustomWorldAssetCoverageSummary; - }): CustomWorldAgentDerivedStatePatch { - return { - ...this.buildRefiningState({ - previousStage: 'object_refining', - nextStage: 'object_refining', - draftProfile: params.draftProfile, - assetCoverage: params.assetCoverage, - }), - creatorIntent: params.creatorIntent, - anchorPack: params.anchorPack, - pendingClarifications: [], - }; - } - - buildMessageTurnState(params: { - latestSession: CustomWorldAgentSessionRecord; - nextAnchorContent: EightAnchorContent; - progressPercent: number; - replyText: string; - nextCreatorIntent: CustomWorldCreatorIntentRecord; - creatorIntentReadiness: CreatorIntentReadiness; - derivedDraftProfile: { - title: string; - summary: string; - }; - derivedPendingClarifications: CustomWorldPendingClarification[]; - derivedStage: CustomWorldAgentStage; - shouldStayInDraftStage: boolean; - }): CustomWorldAgentDerivedStatePatch { - const preservedStage = - params.latestSession.stage === 'visual_refining' - ? ('visual_refining' as const) - : ('object_refining' as const); - const nextDraftProfile = params.shouldStayInDraftStage - ? ((params.latestSession.draftProfile ?? {}) as Record) - : params.progressPercent >= 100 - ? { - title: buildDraftTitleFromIntent(params.nextCreatorIntent), - summary: buildDraftSummaryFromIntent(params.nextCreatorIntent), - } - : params.derivedDraftProfile; - const nextStage = params.shouldStayInDraftStage - ? preservedStage - : params.derivedStage; - const assetCoverage = params.shouldStayInDraftStage - ? params.latestSession.assetCoverage - : rebuildRoleAssetCoverage(nextDraftProfile); - - return { - currentTurn: params.latestSession.currentTurn + 1, - anchorContent: params.nextAnchorContent, - progressPercent: params.progressPercent, - lastAssistantReply: params.replyText, - stage: nextStage, - focusCardId: params.shouldStayInDraftStage - ? params.latestSession.focusCardId - : null, - creatorIntent: params.nextCreatorIntent, - creatorIntentReadiness: params.creatorIntentReadiness, - anchorPack: buildAnchorPackFromEightAnchorContent( - params.nextAnchorContent, - params.progressPercent, - ), - draftProfile: nextDraftProfile, - draftCards: params.shouldStayInDraftStage - ? params.latestSession.draftCards - : [], - assetCoverage, - qualityFindings: this.qualityGateService.buildQualityFindings({ - draftProfile: nextDraftProfile, - assetCoverage, - stage: nextStage, - }), - pendingClarifications: - params.progressPercent >= 100 ? [] : params.derivedPendingClarifications, - suggestedActions: params.shouldStayInDraftStage - ? this.suggestedActionService.buildSuggestedActions({ - stage: preservedStage, - isReady: true, - draftProfile: params.latestSession.draftProfile, - draftCards: params.latestSession.draftCards, - }) - : params.progressPercent >= 100 - ? [ - { - id: 'draft_foundation', - type: 'draft_foundation', - label: '生成游戏设定草稿', - }, - ] - : [], - recommendedReplies: [], - }; - } -} diff --git a/server-node/src/services/customWorldAgentSuggestedActionService.ts b/server-node/src/services/customWorldAgentSuggestedActionService.ts deleted file mode 100644 index 3516224a..00000000 --- a/server-node/src/services/customWorldAgentSuggestedActionService.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { - CustomWorldAgentStage, - CustomWorldDraftCardSummary, - CustomWorldSuggestedAction, -} from '../../../packages/shared/src/contracts/customWorldAgent.js'; -import { - getWorldFoundationCardId, - normalizeFoundationDraftProfile, -} from './customWorldAgentDraftCompiler.js'; - -export class CustomWorldAgentSuggestedActionService { - // 统一维护 Agent 草稿阶段的建议动作,避免继续散落在 orchestrator 和 store 的兼容逻辑里。 - buildSuggestedActions( - params: { - stage?: CustomWorldAgentStage; - isReady?: boolean; - draftProfile?: unknown; - draftCards?: CustomWorldDraftCardSummary[]; - } = {}, - ): CustomWorldSuggestedAction[] { - const profile = normalizeFoundationDraftProfile(params.draftProfile); - const actions: CustomWorldSuggestedAction[] = [ - { - id: 'request_summary', - type: 'request_summary', - label: - params.stage === 'object_refining' || - params.stage === 'visual_refining' - ? '总结当前世界底稿' - : '总结当前设定', - }, - ]; - - if (params.stage === 'foundation_review' && params.isReady) { - actions.push({ - id: 'draft_foundation', - type: 'draft_foundation', - label: '整理一版世界底稿', - }); - return actions; - } - - if ( - (params.stage === 'object_refining' || - params.stage === 'visual_refining') && - profile - ) { - const worldCardId = - params.draftCards?.find((entry) => entry.kind === 'world')?.id ?? - getWorldFoundationCardId(); - const firstCharacter = [...profile.playableNpcs, ...profile.storyNpcs][0]; - const firstLandmark = profile.landmarks[0]; - - actions.push({ - id: 'refine_world', - type: 'refine_focus_target', - label: '先看世界总卡', - targetId: worldCardId, - }); - - if (firstCharacter) { - actions.push({ - id: `refine-character-${firstCharacter.id}`, - type: 'refine_focus_target', - label: `精修角色:${firstCharacter.name}`, - targetId: firstCharacter.id, - }); - } - - if (firstLandmark) { - actions.push({ - id: `refine-landmark-${firstLandmark.id}`, - type: 'refine_focus_target', - label: `继续补地点:${firstLandmark.name}`, - targetId: firstLandmark.id, - }); - } - } - - return actions; - } -} diff --git a/server-node/src/services/customWorldAgentTestHelpers.ts b/server-node/src/services/customWorldAgentTestHelpers.ts deleted file mode 100644 index 0ae37588..00000000 --- a/server-node/src/services/customWorldAgentTestHelpers.ts +++ /dev/null @@ -1,321 +0,0 @@ -import type { EightAnchorContent } from '../../../packages/shared/src/contracts/customWorldAgent.js'; -import type { UpstreamLlmClient } from './llmClient.js'; -import { - extractCreatorIntentPatch, - mergeCreatorIntentRecord, -} from './customWorldAgentIntentExtractionService.js'; -import { - buildCreatorIntentFromEightAnchorContent, - buildEightAnchorContentFromCreatorIntent, - createEmptyEightAnchorContent, - estimateProgressPercentFromAnchorContent, - normalizeEightAnchorContent, -} from './eightAnchorCompatibilityService.js'; - -type TestChatMessage = { - role: 'user' | 'assistant'; - content: string; -}; - -function toText(value: unknown) { - return typeof value === 'string' ? value.trim() : ''; -} - -function shouldReplaceWorldPromise(params: { - latestUserText: string; - hasExistingWorldPromise: boolean; -}) { - if (!params.hasExistingWorldPromise) { - return true; - } - - return /(世界一句话|一句话概括|世界设定|这个世界|题材|主题|风格|改成|改为|换成)/u.test( - params.latestUserText, - ); -} - -function buildAutoCompletePatch(intent: ReturnType< - typeof buildCreatorIntentFromEightAnchorContent ->) { - return { - worldHook: - intent.worldHook || - intent.rawSettingText || - '一个被未知异象改变秩序的边境世界。', - playerPremise: intent.playerPremise || '玩家是被卷入核心危机的返乡者。', - openingSituation: - intent.openingSituation || '开局时,玩家正抵达危机爆发的现场。', - themeKeywords: - intent.themeKeywords.length > 0 ? intent.themeKeywords : ['奇幻'], - toneDirectives: - intent.toneDirectives.length > 0 ? intent.toneDirectives : ['悬疑'], - coreConflicts: - intent.coreConflicts.length > 0 - ? intent.coreConflicts - : ['旧秩序与新威胁正在争夺世界的未来。'], - keyCharacters: - intent.keyCharacters.length > 0 - ? intent.keyCharacters - : [ - { - id: 'auto-key-character-1', - name: '未命名关键人物', - role: '关键关系', - publicMask: '看似能帮助玩家的人。', - hiddenHook: '掌握一条会改变局势的暗线。', - relationToPlayer: '旧识', - notes: '自动补全,可继续修改。', - }, - ], - iconicElements: - intent.iconicElements.length > 0 ? intent.iconicElements : ['失落信标'], - }; -} - -function buildReplyText(params: { - nextAnchorContent: EightAnchorContent; - progressPercent: number; - quickFillRequested: boolean; - latestUserText: string; -}) { - if (params.quickFillRequested || params.progressPercent >= 100) { - return '这一版已经收住了,现在可以直接生成游戏设定草稿。'; - } - - if (/(改成|改为|换成|不是)/u.test(params.latestUserText)) { - return '我已经按你刚刚修正后的方向重收了一版,现在这条主线会更稳。'; - } - - if (!params.nextAnchorContent.worldPromise?.hook) { - return '方向我先接住了一点。这个世界最抓人的那句核心设定,你想怎么钉住?'; - } - - if (!params.nextAnchorContent.playerFantasy?.playerRole) { - return '世界底色已经有了。你最想让玩家以什么身份卷进来?'; - } - - if (!params.nextAnchorContent.playerEntryPoint?.openingProblem) { - return '大方向先稳住了。故事开场时,玩家先撞上的麻烦是什么?'; - } - - if (!params.nextAnchorContent.coreConflict?.surfaceConflicts.length) { - return '现在气质和身份都更清楚了。接下来最值得钉住的,是这个世界正在爆开的主要冲突。'; - } - - return '这轮信息我已经收进当前版本里了,你可以继续往下补,也可以让我顺着这条线继续收束。'; -} - -function extractJsonBlock(text: string, marker: string) { - const markerIndex = text.indexOf(marker); - if (markerIndex < 0) { - return null; - } - - let startIndex = markerIndex + marker.length; - while (startIndex < text.length && /\s/u.test(text[startIndex] ?? '')) { - startIndex += 1; - } - - const firstCharacter = text[startIndex]; - if (firstCharacter !== '{' && firstCharacter !== '[') { - return null; - } - - const closingCharacter = firstCharacter === '{' ? '}' : ']'; - let depth = 0; - let insideString = false; - let escaping = false; - - for (let index = startIndex; index < text.length; index += 1) { - const character = text[index] ?? ''; - - if (insideString) { - if (escaping) { - escaping = false; - continue; - } - if (character === '\\') { - escaping = true; - continue; - } - if (character === '"') { - insideString = false; - } - continue; - } - - if (character === '"') { - insideString = true; - continue; - } - - if (character === firstCharacter) { - depth += 1; - continue; - } - - if (character === closingCharacter) { - depth -= 1; - if (depth === 0) { - return text.slice(startIndex, index + 1); - } - } - } - - return null; -} - -function parsePromptInput(text: string) { - const anchorJson = extractJsonBlock(text, '当前完整设定结构:'); - const chatJson = extractJsonBlock(text, '用户聊天记录:'); - - const currentAnchorContent = anchorJson - ? normalizeEightAnchorContent(JSON.parse(anchorJson)) - : createEmptyEightAnchorContent(); - const chatHistory = chatJson - ? (JSON.parse(chatJson) as TestChatMessage[]) - : []; - const quickFillRequested = - text.includes('是否要求自动补全:是') || - text.includes('conversationMode: force_complete') || - text.includes('用户刚刚主动要求你自动补全剩余设定'); - - return { - currentAnchorContent, - chatHistory, - quickFillRequested, - }; -} - -export function buildTestEightAnchorTurn(params: { - currentAnchorContent: EightAnchorContent; - chatHistory: TestChatMessage[]; - quickFillRequested: boolean; -}) { - const latestUserText = - [...params.chatHistory] - .reverse() - .find((entry) => entry.role === 'user' && entry.content.trim())?.content ?? - ''; - const currentIntent = buildCreatorIntentFromEightAnchorContent( - params.currentAnchorContent, - ); - const intentPatch = extractCreatorIntentPatch({ - currentIntent, - latestUserMessage: latestUserText, - recentMessages: params.chatHistory - .filter((entry) => entry.role === 'user') - .slice(-6, -1) - .map((entry) => entry.content), - }); - const mergedIntent = mergeCreatorIntentRecord( - currentIntent, - params.quickFillRequested - ? { - ...intentPatch, - ...buildAutoCompletePatch(currentIntent), - } - : intentPatch, - ); - - if ( - !shouldReplaceWorldPromise({ - latestUserText, - hasExistingWorldPromise: Boolean(currentIntent.worldHook), - }) - ) { - mergedIntent.worldHook = currentIntent.worldHook; - } - - const nextAnchorContent = buildEightAnchorContentFromCreatorIntent(mergedIntent); - const progressPercent = params.quickFillRequested - ? 100 - : estimateProgressPercentFromAnchorContent(nextAnchorContent); - - return { - replyText: buildReplyText({ - nextAnchorContent, - progressPercent, - quickFillRequested: params.quickFillRequested, - latestUserText, - }), - progressPercent, - nextAnchorContent, - }; -} - -function buildStateInferenceFromPrompt(text: string) { - const { chatHistory, quickFillRequested } = parsePromptInput(text); - const latestUserText = - [...chatHistory] - .reverse() - .find((entry) => entry.role === 'user' && entry.content.trim())?.content ?? - ''; - const correction = /(改成|改为|换成|不是|别走|不要)/u.test(latestUserText); - const delegate = /(你来|你帮我|默认方案|自动补全|按你觉得合理)/u.test( - latestUserText, - ); - - if (quickFillRequested) { - return { - userInputSignal: delegate ? 'delegate' : 'normal', - driftRisk: correction ? 'high' : 'medium', - conversationMode: 'force_complete', - judgementSummary: '用户希望系统直接补完,这一轮应优先补齐剩余设定并结束收集阶段。', - }; - } - - if (correction) { - return { - userInputSignal: 'correction', - driftRisk: 'high', - conversationMode: 'repair_direction', - judgementSummary: '用户正在修正旧方向,正式生成时要让修正后的版本直接接管当前语境。', - }; - } - - if (latestUserText.length < 20) { - return { - userInputSignal: delegate ? 'delegate' : 'sparse', - driftRisk: 'low', - conversationMode: 'bootstrap', - judgementSummary: '这轮新增信息较少,正式生成时应先低压力接住方向,再只推进一个最好回答的问题。', - }; - } - - return { - userInputSignal: latestUserText.length >= 40 ? 'rich' : 'normal', - driftRisk: 'low', - conversationMode: 'expand', - judgementSummary: '这轮是在顺着现有方向继续补充,正式生成时应吸收新增细节并往前推进一步。', - }; -} - -export function createTestCustomWorldAgentSingleTurnLlmClient() { - return { - requestMessageContent: async (params) => { - if (params.systemPrompt.includes('创作状态识别器')) { - return JSON.stringify(buildStateInferenceFromPrompt(params.userPrompt)); - } - - const promptInput = parsePromptInput( - [params.systemPrompt, params.userPrompt].join('\n\n'), - ); - return JSON.stringify(buildTestEightAnchorTurn(promptInput)); - }, - streamMessageContent: async (params) => { - const promptInput = parsePromptInput( - [params.systemPrompt, params.userPrompt].join('\n\n'), - ); - const output = buildTestEightAnchorTurn(promptInput); - const jsonText = JSON.stringify({ - replyText: output.replyText, - progressPercent: output.progressPercent, - nextAnchorContent: output.nextAnchorContent, - }); - - params.onUpdate?.(jsonText); - return jsonText; - }, - } as UpstreamLlmClient; -} diff --git a/server-node/src/services/customWorldCoverAssetService.test.ts b/server-node/src/services/customWorldCoverAssetService.test.ts deleted file mode 100644 index 64efe9d0..00000000 --- a/server-node/src/services/customWorldCoverAssetService.test.ts +++ /dev/null @@ -1,278 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import { - createServer, - type IncomingMessage, - type ServerResponse, -} from 'node:http'; -import os from 'node:os'; -import path from 'node:path'; -import test from 'node:test'; - -import sharp from 'sharp'; - -import { type AppConfig } from '../config.js'; -import type { AppContext } from '../context.js'; -import { - generateCustomWorldCoverImage, - uploadCustomWorldCoverImage, -} from './customWorldCoverAssetService.js'; - -function createTestConfig( - projectRoot: string, - dashScopeBaseUrl: string, -): AppConfig { - return { - projectRoot, - publicDir: path.join(projectRoot, 'public'), - dashScope: { - baseUrl: dashScopeBaseUrl, - apiKey: 'test-dashscope-key', - imageModel: 'wan2.2-t2i-flash', - requestTimeoutMs: 5_000, - }, - } as AppConfig; -} - -function sendJson(res: ServerResponse, payload: unknown) { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json; charset=utf-8'); - res.end(JSON.stringify(payload)); -} - -function readRequestBody(req: IncomingMessage) { - return new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - req.on('data', (chunk) => { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - }); - req.on('end', () => resolve(Buffer.concat(chunks))); - req.on('error', reject); - }); -} - -async function withHttpServer( - buildHandler: ( - baseUrl: string, - ) => (req: IncomingMessage, res: ServerResponse) => void | Promise, - run: (baseUrl: string) => Promise, -) { - let handler: ( - req: IncomingMessage, - res: ServerResponse, - ) => void | Promise = () => undefined; - const server = createServer((req, res) => { - Promise.resolve(handler(req, res)).catch((error) => { - res.statusCode = 500; - res.end(error instanceof Error ? error.stack : String(error)); - }); - }); - - await new Promise((resolve) => { - server.listen(0, '127.0.0.1', () => resolve()); - }); - - const address = server.address(); - if (!address || typeof address === 'string') { - throw new Error('failed to resolve test server address'); - } - - const baseUrl = `http://127.0.0.1:${address.port}`; - handler = buildHandler(baseUrl); - - try { - return await run(baseUrl); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => { - if (error) { - reject(error); - return; - } - resolve(); - }); - }); - } -} - -test('uploadCustomWorldCoverImage crops to 16:9 and saves a compressed webp cover', async () => { - const tempRoot = fs.mkdtempSync( - path.join(os.tmpdir(), 'genarrative-cover-upload-'), - ); - const context = { - config: createTestConfig(tempRoot, 'http://127.0.0.1:9999/api/v1'), - } as AppContext; - - const inputBuffer = await sharp({ - create: { - width: 2400, - height: 1800, - channels: 3, - background: { r: 40, g: 78, b: 132 }, - }, - }) - .jpeg({ quality: 92 }) - .toBuffer(); - const imageDataUrl = `data:image/jpeg;base64,${inputBuffer.toString('base64')}`; - - const result = await uploadCustomWorldCoverImage(context, { - profileId: 'world-1', - worldName: '潮雾群岛', - imageDataUrl, - cropRect: { - x: 240, - y: 225, - width: 1920, - height: 1080, - }, - }); - - assert.equal(result.sourceType, 'uploaded'); - assert.match(result.imageSrc, /^\/generated-custom-world-covers\//u); - - const savedPath = path.join(tempRoot, 'public', result.imageSrc.slice(1)); - assert.equal(fs.existsSync(savedPath), true); - const metadata = await sharp(savedPath).metadata(); - assert.equal(metadata.format, 'webp'); - assert.equal(metadata.width, 1600); - assert.equal(metadata.height, 900); - assert.ok(fs.statSync(savedPath).size <= Math.floor(1.5 * 1024 * 1024)); -}); - -test('generateCustomWorldCoverImage sends opening act and role images as reference images', async () => { - const tempRoot = fs.mkdtempSync( - path.join(os.tmpdir(), 'genarrative-cover-generate-'), - ); - const publicDir = path.join(tempRoot, 'public'); - fs.mkdirSync(path.join(publicDir, 'images', 'scene'), { recursive: true }); - fs.mkdirSync(path.join(publicDir, 'images', 'roles'), { recursive: true }); - - const referenceBuffer = await sharp({ - create: { - width: 64, - height: 64, - channels: 3, - background: { r: 80, g: 120, b: 160 }, - }, - }) - .png() - .toBuffer(); - fs.writeFileSync( - path.join(publicDir, 'images', 'scene', 'opening.png'), - referenceBuffer, - ); - fs.writeFileSync( - path.join(publicDir, 'images', 'roles', 'lead.png'), - referenceBuffer, - ); - - const capturedBodies: string[] = []; - - await withHttpServer( - (baseUrl) => async (req, res) => { - const url = new URL(req.url || '/', baseUrl); - - if ( - req.method === 'POST' && - url.pathname === '/api/v1/services/aigc/multimodal-generation/generation' - ) { - capturedBodies.push((await readRequestBody(req)).toString('utf8')); - sendJson(res, { - output: { - results: [ - { - url: `${baseUrl}/downloads/cover.png`, - actual_prompt: '整理后的封面提示词', - }, - ], - }, - }); - return; - } - - if (req.method === 'GET' && url.pathname === '/downloads/cover.png') { - res.statusCode = 200; - res.setHeader('Content-Type', 'image/png'); - res.end(referenceBuffer); - return; - } - - res.statusCode = 404; - res.end('not found'); - }, - async (dashScopeBaseUrl) => { - const context = { - config: createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`), - } as AppContext; - - const result = await generateCustomWorldCoverImage(context, { - profile: { - id: 'world-1', - name: '潮雾群岛', - subtitle: '旧航道与沉钟回响', - summary: '用于验证封面参考素材收集。', - tone: '潮湿、压抑', - playerGoal: '查明旧航道真相', - settingText: '旧港与潮雾正在失衡。', - camp: null, - landmarks: [ - { - id: 'landmark-1', - name: '沉钟码头', - description: '海雾压进旧码头。', - imageSrc: '/images/scene/opening.png', - }, - ], - playableNpcs: [ - { - id: 'playable-1', - name: '林潮', - title: '守潮人', - role: '可扮演角色', - description: '站在最前面的主角色。', - imageSrc: '/images/roles/lead.png', - }, - ], - storyNpcs: [], - sceneChapterBlueprints: [ - { - id: 'scene-chapter-1', - sceneId: 'landmark-1', - title: '沉钟码头', - summary: '玩家第一次登上旧码头。', - acts: [ - { - id: 'act-1', - title: '雾里靠岸', - summary: '第一幕潮声压低,玩家刚踏上栈桥。', - backgroundImageSrc: '/images/scene/opening.png', - }, - ], - }, - ], - }, - userPrompt: '像正式作品封面。', - referenceImageSrc: '', - characterRoleIds: ['playable-1'], - size: '1600*900', - }); - - assert.equal(result.sourceType, 'generated'); - }, - ); - - assert.equal(capturedBodies.length, 1); - const createPayload = JSON.parse(capturedBodies[0] ?? '{}') as { - input?: { - messages?: Array<{ - content?: Array<{ image?: string; text?: string }>; - }>; - }; - }; - const content = - createPayload.input?.messages?.[0]?.content?.map((item) => - item.image ? 'image' : item.text ? 'text' : 'unknown', - ) ?? []; - assert.ok(content.filter((item) => item === 'image').length >= 2); - assert.equal(content[content.length - 1], 'text'); -}); diff --git a/server-node/src/services/customWorldCoverAssetService.ts b/server-node/src/services/customWorldCoverAssetService.ts deleted file mode 100644 index ec1090ae..00000000 --- a/server-node/src/services/customWorldCoverAssetService.ts +++ /dev/null @@ -1,758 +0,0 @@ -import fs from 'node:fs'; -import { readFile } from 'node:fs/promises'; -import path from 'node:path'; - -import sharp from 'sharp'; -import { z } from 'zod'; - -import type { AppContext } from '../context.js'; -import { badRequest } from '../errors.js'; -import { extractApiErrorMessage } from '../http.js'; - -const TEXT_TO_IMAGE_COVER_MODEL = 'wan2.2-t2i-flash'; -const REFERENCE_IMAGE_COVER_MODEL = 'qwen-image-2.0'; - -const coverRoleSchema = z.object({ - id: z.string().trim().optional().default(''), - name: z.string().trim().optional().default(''), - title: z.string().trim().optional().default(''), - role: z.string().trim().optional().default(''), - description: z.string().trim().optional().default(''), - imageSrc: z.string().trim().optional().default(''), -}); - -const coverCampSchema = z.object({ - name: z.string().trim().optional().default(''), - description: z.string().trim().optional().default(''), - imageSrc: z.string().trim().optional().default(''), -}); - -const coverLandmarkSchema = z.object({ - id: z.string().trim().optional().default(''), - name: z.string().trim().optional().default(''), - description: z.string().trim().optional().default(''), - imageSrc: z.string().trim().optional().default(''), -}); - -const coverActSchema = z.object({ - id: z.string().trim().optional().default(''), - title: z.string().trim().optional().default(''), - summary: z.string().trim().optional().default(''), - backgroundImageSrc: z.string().trim().optional().default(''), -}); - -const coverSceneChapterSchema = z.object({ - id: z.string().trim().optional().default(''), - sceneId: z.string().trim().optional().default(''), - title: z.string().trim().optional().default(''), - summary: z.string().trim().optional().default(''), - acts: z.array(coverActSchema).optional().default([]), -}); - -const coverProfileSchema = z.object({ - id: z.string().trim().optional().default(''), - name: z.string().trim().optional().default(''), - subtitle: z.string().trim().optional().default(''), - summary: z.string().trim().optional().default(''), - tone: z.string().trim().optional().default(''), - playerGoal: z.string().trim().optional().default(''), - settingText: z.string().trim().optional().default(''), - camp: coverCampSchema.nullable().optional(), - landmarks: z.array(coverLandmarkSchema).optional().default([]), - playableNpcs: z.array(coverRoleSchema).optional().default([]), - storyNpcs: z.array(coverRoleSchema).optional().default([]), - sceneChapterBlueprints: z - .array(coverSceneChapterSchema) - .optional() - .default([]), -}); - -export const customWorldCoverImageSchema = z.object({ - profile: coverProfileSchema, - userPrompt: z.string().trim().optional().default(''), - referenceImageSrc: z.string().trim().optional().default(''), - characterRoleIds: z.array(z.string().trim()).max(3).optional().default([]), - size: z.string().trim().optional().default('1600*900'), -}); - -export const customWorldCoverUploadSchema = z.object({ - profileId: z.string().trim().optional().default(''), - worldName: z.string().trim().optional().default(''), - imageDataUrl: z.string().trim().min(1), - cropRect: z.object({ - x: z.number().finite().min(0), - y: z.number().finite().min(0), - width: z.number().finite().positive(), - height: z.number().finite().positive(), - }), -}); - -type CoverProfile = z.infer; - -const COVER_OUTPUT_WIDTH = 1600; -const COVER_OUTPUT_HEIGHT = 900; -const COVER_UPLOAD_MAX_BYTES = 10 * 1024 * 1024; -const COVER_OUTPUT_MAX_BYTES = Math.floor(1.5 * 1024 * 1024); - -type ParsedImageDataUrl = { - buffer: Buffer; - mimeType: string; -}; - -function parseImageDataUrl(source: string) { - const matched = /^data:(image\/[^;]+);base64,(.+)$/u.exec(source); - if (!matched) { - return null; - } - - return { - buffer: Buffer.from(matched[2], 'base64'), - mimeType: matched[1], - }; -} - -function clampCoverText(value: string, maxLength: number) { - return value.trim().replace(/\s+/gu, ' ').slice(0, maxLength); -} - -function resolveOpeningAct(profile: CoverProfile) { - return profile.sceneChapterBlueprints[0]?.acts[0] ?? null; -} - -function collectCoverReferenceImageSrcs( - profile: CoverProfile, - requestedRoleIds: string[], - explicitReferenceImageSrc: string, -) { - const selectedRoles = resolveSelectedRoles(profile, requestedRoleIds); - const sceneImageSrc = clampCoverText( - resolveOpeningAct(profile)?.backgroundImageSrc ?? '', - 240, - ); - const roleImageSrcs = selectedRoles - .map((role) => clampCoverText(role.imageSrc, 240)) - .filter(Boolean); - const campImageSrc = clampCoverText(profile.camp?.imageSrc ?? '', 240); - const landmarkImageSrc = profile.landmarks - .map((landmark) => clampCoverText(landmark.imageSrc, 240)) - .filter(Boolean)[0] ?? ''; - - return [ - clampCoverText(explicitReferenceImageSrc, 240), - sceneImageSrc, - ...roleImageSrcs, - campImageSrc, - landmarkImageSrc, - ].filter( - (source) => - Boolean(source) && (source.startsWith('/') || source.startsWith('data:')), - ); -} - -function buildCoverPromptContext(profile: CoverProfile, requestedRoleIds: string[]) { - const openingAct = resolveOpeningAct(profile); - const selectedRoles = resolveSelectedRoles(profile, requestedRoleIds); - const roleSummary = selectedRoles - .map((role) => - [ - clampCoverText(role.name, 18), - clampCoverText(role.title || role.role, 24), - clampCoverText(role.description, 72), - ] - .filter(Boolean) - .join(' / '), - ) - .filter(Boolean) - .join(';'); - const storyRoleSummary = profile.storyNpcs - .slice(0, 4) - .map((role) => - [clampCoverText(role.name, 18), clampCoverText(role.title || role.role, 24)] - .filter(Boolean) - .join(' / '), - ) - .filter(Boolean) - .join(';'); - - return { - openingActTitle: clampCoverText(openingAct?.title ?? '', 24), - openingActSummary: clampCoverText(openingAct?.summary ?? '', 96), - roleSummary, - storyRoleSummary, - landmarkSummary: profile.landmarks - .slice(0, 3) - .map((landmark) => - [ - clampCoverText(landmark.name, 18), - clampCoverText(landmark.description, 72), - ] - .filter(Boolean) - .join(' / '), - ) - .filter(Boolean) - .join(';'), - }; -} - -async function optimizeUploadedCoverImage( - parsedDataUrl: ParsedImageDataUrl, - cropRect: z.infer['cropRect'], -) { - if (parsedDataUrl.buffer.byteLength > COVER_UPLOAD_MAX_BYTES) { - throw badRequest('上传封面原图不能超过 10 MB。'); - } - - const image = sharp(parsedDataUrl.buffer, { failOn: 'none' }); - const metadata = await image.metadata(); - const sourceWidth = metadata.width ?? 0; - const sourceHeight = metadata.height ?? 0; - - if (sourceWidth <= 0 || sourceHeight <= 0) { - throw badRequest('无法解析上传封面的尺寸。'); - } - - const normalizedCrop = { - left: Math.max(0, Math.min(sourceWidth - 1, Math.floor(cropRect.x))), - top: Math.max(0, Math.min(sourceHeight - 1, Math.floor(cropRect.y))), - width: Math.max(1, Math.min(sourceWidth, Math.floor(cropRect.width))), - height: Math.max(1, Math.min(sourceHeight, Math.floor(cropRect.height))), - }; - normalizedCrop.width = Math.min( - normalizedCrop.width, - sourceWidth - normalizedCrop.left, - ); - normalizedCrop.height = Math.min( - normalizedCrop.height, - sourceHeight - normalizedCrop.top, - ); - - if ( - normalizedCrop.width <= 0 || - normalizedCrop.height <= 0 || - normalizedCrop.width / normalizedCrop.height < 1.7 || - normalizedCrop.width / normalizedCrop.height > 1.8 - ) { - throw badRequest('上传封面裁剪区域必须保持 16:9。'); - } - - const encodeWithQuality = async (quality: number) => - image - .extract(normalizedCrop) - .resize(COVER_OUTPUT_WIDTH, COVER_OUTPUT_HEIGHT, { - fit: 'cover', - position: 'centre', - }) - .webp({ quality, effort: 4 }) - .toBuffer(); - - let optimizedBuffer = await encodeWithQuality(90); - for ( - let quality = 84; - optimizedBuffer.byteLength > COVER_OUTPUT_MAX_BYTES && quality >= 60; - quality -= 8 - ) { - optimizedBuffer = await encodeWithQuality(quality); - } - - if (optimizedBuffer.byteLength > COVER_OUTPUT_MAX_BYTES) { - throw badRequest('上传封面压缩后仍超过体积限制,请缩小裁剪范围或更换图片。'); - } - - return { - buffer: optimizedBuffer, - mimeType: 'image/webp', - extension: 'webp', - }; -} - -async function resolveReferenceImageAsDataUrl(rootDir: string, source: string) { - const trimmedSource = source.trim(); - if (!trimmedSource) { - return ''; - } - - const parsedDataUrl = parseImageDataUrl(trimmedSource); - if (parsedDataUrl) { - return trimmedSource; - } - - if (!trimmedSource.startsWith('/')) { - throw badRequest('参考图必须是 Data URL 或 public 目录下的 URL。'); - } - - const normalizedSource = path.posix - .normalize(trimmedSource) - .replace(/^\/+/u, ''); - const absolutePath = path.resolve( - rootDir, - 'public', - ...normalizedSource.split('/'), - ); - const publicRoot = path.resolve(rootDir, 'public'); - if (!absolutePath.startsWith(publicRoot)) { - throw badRequest('参考图路径越界。'); - } - - const buffer = await readFile(absolutePath); - const extension = path - .extname(absolutePath) - .replace(/^\./u, '') - .toLowerCase(); - const mimeType = (() => { - switch (extension) { - case 'jpg': - case 'jpeg': - return 'image/jpeg'; - case 'webp': - return 'image/webp'; - default: - return 'image/png'; - } - })(); - - return `data:${mimeType};base64,${buffer.toString('base64')}`; -} - -function collectStringsByKey( - value: unknown, - targetKey: string, - results: string[], -) { - if (typeof value === 'string') { - return; - } - - if (Array.isArray(value)) { - value.forEach((entry) => collectStringsByKey(entry, targetKey, results)); - return; - } - - if (!value || typeof value !== 'object') { - return; - } - - Object.entries(value).forEach(([key, nestedValue]) => { - if ( - key === targetKey && - typeof nestedValue === 'string' && - nestedValue.trim() - ) { - results.push(nestedValue.trim()); - return; - } - - collectStringsByKey(nestedValue, targetKey, results); - }); -} - -function findFirstStringByKey(value: unknown, targetKey: string) { - const results: string[] = []; - collectStringsByKey(value, targetKey, results); - return results[0] ?? ''; -} - -function extractTaskId(payload: Record) { - return findFirstStringByKey(payload, 'task_id'); -} - -function extractImageUrls(payload: Record) { - const urls: string[] = []; - collectStringsByKey(payload, 'image', urls); - collectStringsByKey(payload, 'url', urls); - return [...new Set(urls)]; -} - -function sanitizeSegment(value: string, fallback: string) { - const normalized = value - .replace(/[^\w\u4e00-\u9fa5-]+/gu, '-') - .replace(/-+/gu, '-') - .replace(/^-+|-+$/gu, ''); - - return (normalized || fallback).slice(0, 48); -} - -function resolveSelectedRoles( - profile: CoverProfile, - requestedRoleIds: string[], -) { - const roleById = new Map( - profile.playableNpcs.map((role) => [role.id.trim(), role] as const), - ); - const selectedRoles = [...new Set(requestedRoleIds.map((roleId) => roleId.trim()))] - .map((roleId) => roleById.get(roleId)) - .filter((role): role is z.infer => Boolean(role)); - - if (selectedRoles.length > 0) { - return selectedRoles.slice(0, 3); - } - - return profile.playableNpcs.slice(0, 3); -} - -function buildCustomWorldCoverImagePrompt( - profile: CoverProfile, - requestedRoleIds: string[], - userPrompt: string, - options: { - hasReferenceImage?: boolean; - } = {}, -) { - const openingScene = profile.camp ?? profile.landmarks[0] ?? null; - const promptContext = buildCoverPromptContext(profile, requestedRoleIds); - - return [ - '为 16:9 横版 RPG 作品生成一张高完成度封面图,用于创作列表与作品详情头图。', - '画面重点是“开局场景 + 2 到 3 个主要角色”的主视觉,不是纯背景图,也不是 UI 截图。', - '构图需要有明显前中后景层次,前景角色清晰、主体集中、适合移动端缩略显示。', - '不要出现任何标题文字、UI、按钮、水印、logo、边框或排版装饰。', - options.hasReferenceImage - ? '已提供一张参考图,请尽量沿用其构图、镜头或色彩气质。' - : '', - profile.name ? `作品名:${profile.name}。` : '', - profile.subtitle ? `副标题:${profile.subtitle}。` : '', - profile.settingText ? `玩家设定:${profile.settingText}。` : '', - profile.summary ? `世界概述:${profile.summary}。` : '', - profile.tone ? `整体基调:${profile.tone}。` : '', - profile.playerGoal ? `主线目标:${profile.playerGoal}。` : '', - promptContext.openingActTitle ? `开局第一幕标题:${promptContext.openingActTitle}。` : '', - promptContext.openingActSummary ? `开局第一幕摘要:${promptContext.openingActSummary}。` : '', - openingScene?.name ? `开局场景:${openingScene.name}。` : '', - openingScene?.description ? `场景描述:${openingScene.description}。` : '', - promptContext.landmarkSummary ? `关键场景素材:${promptContext.landmarkSummary}。` : '', - promptContext.roleSummary ? `需要出现的角色主形象:${promptContext.roleSummary}。` : '', - promptContext.storyRoleSummary ? `可辅助参考的场景角色:${promptContext.storyRoleSummary}。` : '', - userPrompt ? `额外要求:${userPrompt}。` : '', - '整体观感要像一张正式作品封面,主体明确,氛围饱满,人物与场景统一。', - ] - .filter(Boolean) - .join('\n'); -} - -async function createCoverImageTask(params: { - baseUrl: string; - apiKey: string; - prompt: string; - size: string; -}) { - const response = await fetch( - `${params.baseUrl}/services/aigc/text2image/image-synthesis`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${params.apiKey}`, - 'Content-Type': 'application/json', - 'X-DashScope-Async': 'enable', - }, - body: JSON.stringify({ - model: TEXT_TO_IMAGE_COVER_MODEL, - input: { - prompt: params.prompt, - }, - parameters: { - n: 1, - size: params.size, - prompt_extend: true, - watermark: false, - }, - }), - }, - ); - const responseText = await response.text(); - - if (!response.ok) { - throw badRequest( - extractApiErrorMessage(responseText, '创建作品封面生成任务失败'), - ); - } - - return JSON.parse(responseText) as Record; -} - -async function createCoverImageFromReference(params: { - baseUrl: string; - apiKey: string; - prompt: string; - size: string; - referenceImages: string[]; -}) { - const response = await fetch( - `${params.baseUrl}/services/aigc/multimodal-generation/generation`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${params.apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - model: REFERENCE_IMAGE_COVER_MODEL, - input: { - messages: [ - { - role: 'user', - content: [ - ...params.referenceImages.map((image) => ({ image })), - { text: params.prompt }, - ], - }, - ], - }, - parameters: { - n: 1, - size: params.size, - prompt_extend: true, - watermark: false, - }, - }), - }, - ); - const responseText = await response.text(); - - if (!response.ok) { - throw badRequest( - extractApiErrorMessage(responseText, '创建参考图封面任务失败'), - ); - } - - const responsePayload = JSON.parse(responseText) as Record; - const imageUrl = extractImageUrls(responsePayload)[0] ?? ''; - if (!imageUrl) { - throw badRequest('封面生成未返回图片地址'); - } - - return { - imageUrl, - actualPrompt: findFirstStringByKey(responsePayload, 'actual_prompt').trim(), - taskId: `cover-edit-${Date.now()}`, - }; -} - -async function saveGeneratedCoverAsset(params: { - context: AppContext; - profile: CoverProfile; - imageUrl: string; - taskId: string; - prompt: string; - actualPrompt: string; - size: string; - model: string; -}) { - const imageResponse = await fetch(params.imageUrl); - if (!imageResponse.ok) { - throw badRequest('下载作品封面失败'); - } - - const imageBuffer = Buffer.from(await imageResponse.arrayBuffer()); - const contentType = imageResponse.headers.get('content-type') || ''; - const extension = contentType.includes('png') - ? 'png' - : contentType.includes('webp') - ? 'webp' - : 'jpg'; - const assetId = `custom-cover-${Date.now()}`; - const worldSegment = sanitizeSegment( - params.profile.id || params.profile.name, - 'world', - ); - const relativeDir = path.join( - 'generated-custom-world-covers', - worldSegment, - assetId, - ); - const outputDir = path.join(params.context.config.publicDir, relativeDir); - fs.mkdirSync(outputDir, { recursive: true }); - const fileName = `cover.${extension}`; - fs.writeFileSync(path.join(outputDir, fileName), imageBuffer); - - const imageSrc = `/${relativeDir.replace(/\\/gu, '/')}/${fileName}`; - fs.writeFileSync( - path.join(outputDir, 'manifest.json'), - `${JSON.stringify( - { - assetId, - sourceType: 'generated', - taskId: params.taskId, - model: params.model, - size: params.size, - prompt: params.prompt, - actualPrompt: params.actualPrompt, - imageSrc, - worldName: params.profile.name, - createdAt: new Date().toISOString(), - }, - null, - 2, - )}\n`, - ); - - return { - imageSrc, - assetId, - sourceType: 'generated' as const, - model: params.model, - size: params.size, - taskId: params.taskId, - prompt: params.prompt, - actualPrompt: params.actualPrompt, - }; -} - -export async function uploadCustomWorldCoverImage( - context: AppContext, - input: z.infer, -) { - const payload = customWorldCoverUploadSchema.parse(input); - const parsedDataUrl = parseImageDataUrl(payload.imageDataUrl); - if (!parsedDataUrl) { - throw badRequest('上传封面必须是有效图片 Data URL。'); - } - - const optimizedImage = await optimizeUploadedCoverImage( - parsedDataUrl, - payload.cropRect, - ); - const assetId = `custom-cover-upload-${Date.now()}`; - const worldSegment = sanitizeSegment( - payload.profileId || payload.worldName, - 'world', - ); - const relativeDir = path.join( - 'generated-custom-world-covers', - worldSegment, - assetId, - ); - const outputDir = path.join(context.config.publicDir, relativeDir); - fs.mkdirSync(outputDir, { recursive: true }); - const fileName = `cover.${optimizedImage.extension}`; - fs.writeFileSync(path.join(outputDir, fileName), optimizedImage.buffer); - - const imageSrc = `/${relativeDir.replace(/\\/gu, '/')}/${fileName}`; - fs.writeFileSync( - path.join(outputDir, 'manifest.json'), - `${JSON.stringify( - { - assetId, - sourceType: 'uploaded', - imageSrc, - size: `${COVER_OUTPUT_WIDTH}*${COVER_OUTPUT_HEIGHT}`, - outputBytes: optimizedImage.buffer.byteLength, - worldName: payload.worldName, - profileId: payload.profileId, - createdAt: new Date().toISOString(), - }, - null, - 2, - )}\n`, - ); - - return { - imageSrc, - assetId, - sourceType: 'uploaded' as const, - }; -} - -export async function generateCustomWorldCoverImage( - context: AppContext, - input: z.infer, -) { - const payload = customWorldCoverImageSchema.parse(input); - const referenceImageSources = collectCoverReferenceImageSrcs( - payload.profile, - payload.characterRoleIds, - payload.referenceImageSrc, - ).slice(0, 6); - const prompt = buildCustomWorldCoverImagePrompt( - payload.profile, - payload.characterRoleIds, - payload.userPrompt, - { - hasReferenceImage: referenceImageSources.length > 0, - }, - ); - const baseUrl = context.config.dashScope.baseUrl.replace(/\/+$/u, ''); - const referenceImages = await Promise.all( - referenceImageSources.map((source) => - resolveReferenceImageAsDataUrl(context.config.projectRoot, source), - ), - ); - - if (referenceImages.length > 0) { - const referenceResult = await createCoverImageFromReference({ - baseUrl, - apiKey: context.config.dashScope.apiKey, - prompt, - size: payload.size, - referenceImages, - }); - - return saveGeneratedCoverAsset({ - context, - profile: payload.profile, - imageUrl: referenceResult.imageUrl, - taskId: referenceResult.taskId, - prompt, - actualPrompt: referenceResult.actualPrompt, - size: payload.size, - model: REFERENCE_IMAGE_COVER_MODEL, - }); - } - - const createPayload = await createCoverImageTask({ - baseUrl, - apiKey: context.config.dashScope.apiKey, - prompt, - size: payload.size, - }); - const taskId = extractTaskId(createPayload); - if (!taskId) { - throw badRequest('作品封面任务未返回 task_id'); - } - - const deadline = Date.now() + context.config.dashScope.requestTimeoutMs; - let imageUrl = ''; - let actualPrompt = ''; - - while (Date.now() < deadline) { - const pollResponse = await fetch(`${baseUrl}/tasks/${taskId}`, { - headers: { - Authorization: `Bearer ${context.config.dashScope.apiKey}`, - }, - }); - const pollText = await pollResponse.text(); - if (!pollResponse.ok) { - throw badRequest( - extractApiErrorMessage(pollText, '查询作品封面任务失败'), - ); - } - - const pollPayload = JSON.parse(pollText) as Record; - const status = findFirstStringByKey(pollPayload, 'task_status').trim(); - if (status === 'SUCCEEDED') { - imageUrl = extractImageUrls(pollPayload)[0] ?? ''; - actualPrompt = findFirstStringByKey(pollPayload, 'actual_prompt').trim(); - break; - } - if (status === 'FAILED' || status === 'UNKNOWN') { - throw badRequest( - extractApiErrorMessage(pollText, '作品封面生成任务失败'), - ); - } - - await new Promise((resolve) => setTimeout(resolve, 2000)); - } - - if (!imageUrl) { - throw badRequest('作品封面生成超时或未返回图片地址'); - } - - return saveGeneratedCoverAsset({ - context, - profile: payload.profile, - imageUrl, - taskId, - prompt, - actualPrompt, - size: payload.size, - model: TEXT_TO_IMAGE_COVER_MODEL, - }); -} diff --git a/server-node/src/services/customWorldEntityGenerationService.test.ts b/server-node/src/services/customWorldEntityGenerationService.test.ts deleted file mode 100644 index 4817d6b3..00000000 --- a/server-node/src/services/customWorldEntityGenerationService.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { generateCustomWorldEntity } from './customWorldEntityGenerationService.js'; -import type { UpstreamLlmClient } from './llmClient.js'; - -function createProfile() { - return { - name: '裂潮边城', - settingText: '裂潮重新逼近边城,旧封桥令也被重新翻出。', - summary: '一座在裂潮与旧案之间摇摇欲坠的边城。', - tone: '紧绷、克制、暗流涌动', - playerGoal: '查清封桥旧令背后的真正操盘者', - playableNpcs: [ - { - id: 'playable-1', - name: '沈砺', - title: '灰炬向导', - role: '边路同行者', - description: '熟悉裂潮边路的向导。', - visualDescription: '灰斗篷和旧路标是他最显眼的识别点。', - actionDescription: '先试探风向,再用短弓牵制。', - sceneVisualDescription: '他常在旧边路哨点出现。', - backstory: '曾在旧撤离线里失去整支同行队。', - personality: '谨慎寡言。', - motivation: '想查清旧撤离线再次失控的原因。', - combatStyle: '短弓牵制后贴近补刀。', - initialAffinity: 18, - relationshipHooks: ['旧撤离线'], - tags: ['裂潮', '向导'], - }, - ], - storyNpcs: [ - { - id: 'story-1', - name: '梁砺', - title: '断桥巡守', - role: '巡守', - description: '守着旧桥与哨火的人。', - visualDescription: '披着旧制巡守外袍,枪柄磨损很重。', - actionDescription: '先立枪封路,再逼近压线。', - sceneVisualDescription: '多出现在断桥和潮湿石阶附近。', - backstory: '旧案爆发时,他是最后一个封桥的人。', - personality: '直接、警觉。', - motivation: '不想再让封桥旧案被人利用。', - combatStyle: '长枪压线。', - initialAffinity: 6, - relationshipHooks: ['断桥'], - tags: ['巡守'], - }, - ], - landmarks: [ - { - id: 'landmark-1', - name: '旧潮栈桥', - description: '裂潮来时最先响起铁索声的旧栈桥。', - visualDescription: '铁索、旧桩和盐雾一起压在栈桥上。', - dangerLevel: 'medium', - sceneNpcIds: ['story-1'], - connections: [], - }, - ], - }; -} - -test('generateCustomWorldEntity returns role-side visual descriptions from the same model response', async () => { - const llmClient = { - requestMessageContent: async () => - JSON.stringify({ - playableNpc: { - name: '顾潮音', - title: '潮港校灯人', - role: '边港同行者', - description: '在港区高处替玩家校正风向与路标的人。', - visualDescription: - '深蓝防潮外套压着风痕,腰侧悬着校灯尺和短刃,整个人像常年站在高处看潮线的人。', - actionDescription: - '先借高处观察和校灯信号牵制敌人,再突然贴近切断退路。', - sceneVisualDescription: - '他第一次出现的高台边缘挂着潮湿风旗,脚下是被盐雾浸白的木板和仍亮着的旧校灯。', - backstory: '曾负责港区夜航校灯,后被卷进旧案。', - personality: '沉稳、寡言、观察细。', - motivation: '想在港区秩序彻底失控前找到还能守住的线。', - combatStyle: '高差观察后快速切入。', - initialAffinity: 24, - relationshipHooks: ['夜航校灯', '旧港案'], - tags: ['港区', '校灯'], - publicSummary: '港区里很少有人比他更熟悉夜里的风向。', - chapterTeasers: ['他盯风向比盯人更久。', '旧港案在他身上没过去。', '他一直在等某个信号。', '他还藏着最后一次校灯记录。'], - chapterContents: ['他总先校风向。', '旧港案改变了他的站位。', '他真正守的是港区里还没断的线。', '最后那份校灯记录能指向操盘者。'], - skills: [ - { name: '校灯试探', summary: '先用灯信号试探敌我位置。', style: '起手压制' }, - { name: '斜坡切入', summary: '借高差快速贴近改线。', style: '机动周旋' }, - { name: '潮线封口', summary: '看准潮线后一口气断掉退路。', style: '爆发终结' }, - ], - initialItems: [ - { name: '校灯尺', category: '武器', quantity: 1, rarity: 'rare', description: '兼具校灯与近战功能。', tags: ['港区'] }, - { name: '旧港图片', category: '专属物品', quantity: 1, rarity: 'rare', description: '记着他自己的旧线路。', tags: ['旧案'] }, - { name: '潮雾止血包', category: '消耗品', quantity: 2, rarity: 'uncommon', description: '港区常备。', tags: ['补给'] }, - ], - }, - }), - } as UpstreamLlmClient; - - const result = await generateCustomWorldEntity(llmClient, { - profile: createProfile(), - kind: 'playable', - }); - - assert.equal(result.kind, 'playable'); - assert.equal( - result.entity.visualDescription, - '深蓝防潮外套压着风痕,腰侧悬着校灯尺和短刃,整个人像常年站在高处看潮线的人。', - ); - assert.equal( - result.entity.actionDescription, - '先借高处观察和校灯信号牵制敌人,再突然贴近切断退路。', - ); - assert.equal( - result.entity.sceneVisualDescription, - '他第一次出现的高台边缘挂着潮湿风旗,脚下是被盐雾浸白的木板和仍亮着的旧校灯。', - ); -}); - -test('generateCustomWorldEntity returns landmark visual descriptions from the same model response', async () => { - const llmClient = { - requestMessageContent: async () => - JSON.stringify({ - landmark: { - name: '回潮观测台', - description: '能俯瞰旧港和裂潮边缘的新观测点。', - visualDescription: - '观测台立在湿冷高处,铁质风标、旧灯架和被潮气侵白的石阶一起构成了压迫感很强的前景。', - dangerLevel: 'high', - sceneNpcNames: ['梁砺'], - connections: [ - { - targetLandmarkName: '旧潮栈桥', - relativePosition: 'forward', - summary: '沿风雨走廊可直接回到旧潮栈桥', - }, - ], - }, - }), - } as UpstreamLlmClient; - - const result = await generateCustomWorldEntity(llmClient, { - profile: createProfile(), - kind: 'landmark', - }); - - assert.equal(result.kind, 'landmark'); - assert.equal( - result.entity.visualDescription, - '观测台立在湿冷高处,铁质风标、旧灯架和被潮气侵白的石阶一起构成了压迫感很强的前景。', - ); -}); diff --git a/server-node/src/services/customWorldEntityGenerationService.ts b/server-node/src/services/customWorldEntityGenerationService.ts deleted file mode 100644 index 6c614f81..00000000 --- a/server-node/src/services/customWorldEntityGenerationService.ts +++ /dev/null @@ -1,901 +0,0 @@ -import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js'; -import { badRequest } from '../errors.js'; -import { - buildLandmarkPrompt, - buildPlayablePrompt, - buildStoryPrompt, - CUSTOM_WORLD_ENTITY_GENERATOR_SYSTEM_PROMPT, -} from '../prompts/customWorldEntityPrompts.js'; -import type { UpstreamLlmClient } from './llmClient.js'; - -type CustomWorldEntityKind = 'playable' | 'story' | 'landmark'; - -type GenerateCustomWorldEntityInput = { - profile: Record; - kind: CustomWorldEntityKind; -}; - -type ParsedRole = { - id: string; - name: string; - title: string; - role: string; - description: string; - visualDescription: string; - actionDescription: string; - sceneVisualDescription: string; - backstory: string; - personality: string; - motivation: string; - combatStyle: string; - initialAffinity: number; - relationshipHooks: string[]; - tags: string[]; -}; - -type ParsedLandmarkConnection = { - targetLandmarkId: string; - summary: string; - relativePosition: string; -}; - -type ParsedLandmark = { - id: string; - name: string; - description: string; - visualDescription: string; - dangerLevel: string; - sceneNpcIds: string[]; - connections: ParsedLandmarkConnection[]; -}; - -type ParsedProfile = { - name: string; - settingText: string; - summary: string; - tone: string; - playerGoal: string; - playableNpcs: ParsedRole[]; - storyNpcs: ParsedRole[]; - landmarks: ParsedLandmark[]; -}; - -const BACKSTORY_CHAPTERS = [ - { id: 'surface', title: '表层来意', affinityRequired: 6 }, - { id: 'scar', title: '旧事裂痕', affinityRequired: 12 }, - { id: 'hidden', title: '隐藏执念', affinityRequired: 18 }, - { id: 'final', title: '最终底牌', affinityRequired: 24 }, -] as const; - -const ROLE_SURNAME_POOL = [ - '沈', - '顾', - '裴', - '闻', - '纪', - '苏', - '岑', - '陆', - '白', - '商', - '温', - '严', - '黎', - '季', -] as const; - -const ROLE_GIVEN_POOL = [ - '砺', - '岚', - '澄', - '栖', - '弦', - '朔', - '遥', - '霁', - '衡', - '铃', - '潮', - '燧', - '宁', - '鸢', -] as const; - -const PLAYABLE_ROLE_POOL = [ - '同行策士', - '前线斥候', - '旧誓护卫', - '异闻译者', - '禁制解读者', - '地脉向导', -] as const; - -const STORY_ROLE_POOL = [ - '守望者', - '情报掮客', - '巡夜人', - '渡口看守', - '旧案证人', - '异类来客', -] as const; - -const LANDMARK_PREFIX_POOL = [ - '潮碑', - '沉钟', - '雾湾', - '灰塔', - '回潮', - '旧航', - '断潮', - '盐火', -] as const; - -const LANDMARK_SUFFIX_POOL = [ - '前哨', - '档案楼', - '栈桥', - '工坊', - '集市', - '观测台', - '驿站', - '藏书阁', -] as const; - -function toRecord(value: unknown) { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : null; -} - -function toText(value: unknown, fallback = '') { - return typeof value === 'string' && value.trim() ? value.trim() : fallback; -} - -function toStringArray(value: unknown, maxCount = 12) { - if (!Array.isArray(value)) { - return []; - } - - return value - .map((item) => toText(item)) - .filter(Boolean) - .slice(0, maxCount); -} - -function clampText(value: string, maxLength: number) { - const normalized = value.replace(/\s+/gu, ' ').trim(); - if (!normalized) { - return ''; - } - if (normalized.length <= maxLength) { - return normalized; - } - return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}…`; -} - -function slugify(value: string) { - const normalized = value - .trim() - .toLowerCase() - .replace(/[^a-z0-9\u4e00-\u9fa5]+/gu, '-') - .replace(/^-+|-+$/gu, ''); - - return normalized || 'entry'; -} - -function createStableId(prefix: string, label: string, seed: string) { - return `${prefix}-${slugify(label || prefix)}-${seed}`; -} - -function dedupeStrings(values: string[], maxCount = 8) { - return [...new Set(values.map((value) => value.trim()).filter(Boolean))].slice( - 0, - maxCount, - ); -} - -function extractJsonPayload(content: string) { - const fencedMatch = content.match(/```(?:json)?\s*([\s\S]+?)\s*```/u); - if (fencedMatch?.[1]) { - return fencedMatch[1].trim(); - } - - const objectStart = content.indexOf('{'); - const objectEnd = content.lastIndexOf('}'); - if (objectStart >= 0 && objectEnd > objectStart) { - return content.slice(objectStart, objectEnd + 1); - } - - return content.trim(); -} - -function normalizeRole(value: unknown): ParsedRole | null { - const record = toRecord(value); - if (!record) { - return null; - } - - const id = toText(record.id); - const name = toText(record.name); - if (!id || !name) { - return null; - } - - const title = toText(record.title); - const role = toText(record.role, title || '角色'); - - return { - id, - name, - title: title || role || '角色', - role, - description: toText(record.description), - visualDescription: toText(record.visualDescription), - actionDescription: toText(record.actionDescription), - sceneVisualDescription: toText(record.sceneVisualDescription), - backstory: toText(record.backstory), - personality: toText(record.personality), - motivation: toText(record.motivation), - combatStyle: toText(record.combatStyle), - initialAffinity: - typeof record.initialAffinity === 'number' && - Number.isFinite(record.initialAffinity) - ? Math.round(record.initialAffinity) - : 0, - relationshipHooks: toStringArray(record.relationshipHooks, 6), - tags: toStringArray(record.tags, 8), - }; -} - -function normalizeLandmark(value: unknown): ParsedLandmark | null { - const record = toRecord(value); - if (!record) { - return null; - } - - const id = toText(record.id); - const name = toText(record.name); - if (!id || !name) { - return null; - } - - const connections = Array.isArray(record.connections) - ? record.connections - .map((item) => { - const connection = toRecord(item); - if (!connection) { - return null; - } - - const targetLandmarkId = toText(connection.targetLandmarkId); - if (!targetLandmarkId) { - return null; - } - - return { - targetLandmarkId, - summary: toText(connection.summary), - relativePosition: toText(connection.relativePosition, 'forward'), - } satisfies ParsedLandmarkConnection; - }) - .filter( - (item): item is ParsedLandmarkConnection => item !== null, - ) - .slice(0, 8) - : []; - - return { - id, - name, - description: toText(record.description), - visualDescription: toText(record.visualDescription), - dangerLevel: toText(record.dangerLevel, 'medium'), - sceneNpcIds: toStringArray(record.sceneNpcIds, 12), - connections, - }; -} - -function normalizeProfile(value: unknown): ParsedProfile { - const record = toRecord(value); - if (!record) { - throw badRequest('profile is required'); - } - - return { - name: toText(record.name, '自定义世界'), - settingText: toText(record.settingText), - summary: toText(record.summary), - tone: toText(record.tone), - playerGoal: toText(record.playerGoal), - playableNpcs: Array.isArray(record.playableNpcs) - ? record.playableNpcs - .map(normalizeRole) - .filter((item): item is ParsedRole => item !== null) - : [], - storyNpcs: Array.isArray(record.storyNpcs) - ? record.storyNpcs - .map(normalizeRole) - .filter((item): item is ParsedRole => item !== null) - : [], - landmarks: Array.isArray(record.landmarks) - ? record.landmarks - .map(normalizeLandmark) - .filter((item): item is ParsedLandmark => item !== null) - : [], - }; -} - -function buildUniqueRoleName(existingNames: Set, startIndex: number) { - for (let attempt = 0; attempt < 120; attempt += 1) { - const index = startIndex + attempt; - const surname = ROLE_SURNAME_POOL[index % ROLE_SURNAME_POOL.length]; - const firstName = - ROLE_GIVEN_POOL[ - Math.floor(index / ROLE_SURNAME_POOL.length) % ROLE_GIVEN_POOL.length - ]; - const secondName = ROLE_GIVEN_POOL[(index + 5) % ROLE_GIVEN_POOL.length]; - const candidate = `${surname}${firstName}${secondName}`; - - if (!existingNames.has(candidate)) { - existingNames.add(candidate); - return candidate; - } - } - - const fallback = `新角色${existingNames.size + 1}`; - existingNames.add(fallback); - return fallback; -} - -function buildUniqueLandmarkName(existingNames: Set, startIndex: number) { - for (let attempt = 0; attempt < 120; attempt += 1) { - const index = startIndex + attempt; - const candidate = `${LANDMARK_PREFIX_POOL[index % LANDMARK_PREFIX_POOL.length]}${ - LANDMARK_SUFFIX_POOL[ - Math.floor(index / LANDMARK_PREFIX_POOL.length) % - LANDMARK_SUFFIX_POOL.length - ] - }`; - - if (!existingNames.has(candidate)) { - existingNames.add(candidate); - return candidate; - } - } - - const fallback = `新场景${existingNames.size + 1}`; - existingNames.add(fallback); - return fallback; -} - -function buildFallbackRoleDraft( - profile: ParsedProfile, - kind: 'playable' | 'story', -) { - const existingNames = new Set( - [...profile.playableNpcs, ...profile.storyNpcs].map((role) => role.name), - ); - const name = buildUniqueRoleName(existingNames, existingNames.size + 1); - const roleTitlePool = - kind === 'playable' ? PLAYABLE_ROLE_POOL : STORY_ROLE_POOL; - const role = roleTitlePool[existingNames.size % roleTitlePool.length]; - const relationHook = - kind === 'playable' - ? `愿意与玩家共同推进“${profile.playerGoal || profile.summary || profile.name}”` - : `与“${profile.playerGoal || profile.summary || profile.name}”这条局势线直接相关`; - - return { - name, - title: role, - role, - description: clampText( - kind === 'playable' - ? `适合与玩家同行,能补足当前队伍短板的关键角色。` - : `长期活跃于当前世界暗面,能补足场景视角的关键角色。`, - 60, - ), - visualDescription: '', - actionDescription: '', - sceneVisualDescription: '', - backstory: clampText( - `他与${profile.name}当前正在扩张的冲突链紧密相连,知道一些还未公开的内情。`, - 80, - ), - personality: kind === 'playable' ? '克制、敏锐,擅长合作推进。' : '谨慎、耐心,擅长观察局势。', - motivation: clampText( - kind === 'playable' - ? `希望借玩家的选择改变当前世界里已经失衡的局面。` - : `想借玩家的介入撬动一条已经僵住的关系链。`, - 72, - ), - combatStyle: - kind === 'playable' - ? '偏向协作压制与局势调度。' - : '偏向试探牵制与环境借势。', - initialAffinity: kind === 'playable' ? 22 : 6, - relationshipHooks: dedupeStrings( - [relationHook, profile.landmarks[0]?.name ? `常在${profile.landmarks[0].name}附近活动` : '', profile.playableNpcs[0]?.name ? `会先试探${profile.playableNpcs[0].name}与玩家的关系` : '会先试探玩家立场'], - 3, - ), - tags: dedupeStrings( - [profile.name, profile.tone, kind === 'playable' ? '可同行' : '场景线'], - 4, - ), - publicSummary: - kind === 'playable' - ? '一名能与玩家形成互补的新同行者。' - : '一名能补足当前场景关系网的新角色。', - chapterTeasers: [ - '他出现得并不偶然。', - '他与旧局势之间有一道未说透的裂痕。', - '他真正站队的理由比表面更复杂。', - '他留着最后一张不会轻易掀开的牌。', - ], - chapterContents: [ - `他在${profile.name}当前局势里早就留下了自己的位置。`, - '一段旧事让他无法再把自己完全抽离出去。', - '他真正想守住的并不是表面上说出口的东西。', - '一旦走到临界点,他会把最关键的底牌押上桌面。', - ], - skills: [ - { - name: kind === 'playable' ? '协作先手' : '观察起手', - summary: '先稳住局面,再把主动权拉回自己手里。', - style: '起手压制', - }, - { - name: kind === 'playable' ? '阵线补位' : '地形借势', - summary: '借助环境和站位撕开短暂缺口。', - style: '机动周旋', - }, - { - name: kind === 'playable' ? '压轴回合' : '暗线反制', - summary: '在关键回合揭出隐藏准备,改变节奏。', - style: '爆发终结', - }, - ], - initialItems: [ - { - name: kind === 'playable' ? '随身兵装' : '私藏器具', - category: '武器', - quantity: 1, - rarity: 'rare' as const, - description: '与其身份长期绑定的常备装备。', - tags: ['自定义'], - }, - { - name: kind === 'playable' ? '路书残页' : '情报残页', - category: '专属物品', - quantity: 1, - rarity: 'rare' as const, - description: '记录着只属于他自己的线索与判断。', - tags: ['线索'], - }, - { - name: '应急补给', - category: '消耗品', - quantity: 2, - rarity: 'uncommon' as const, - description: '面对突发局势时会先拿出来的保底物资。', - tags: ['备用'], - }, - ], - }; -} - -function buildFallbackLandmarkDraft(profile: ParsedProfile) { - const existingNames = new Set(profile.landmarks.map((landmark) => landmark.name)); - const name = buildUniqueLandmarkName(existingNames, profile.landmarks.length + 1); - const sceneNpcNames = profile.storyNpcs.slice(0, 3).map((npc) => npc.name); - const targetLandmarkNames = profile.landmarks.slice(0, 2).map((landmark) => landmark.name); - - return { - name, - description: clampText( - `承接${profile.name}当前主冲突的一处关键新场景,适合继续向外扩张世界关系网。`, - 72, - ), - visualDescription: '', - dangerLevel: 'medium', - sceneNpcNames, - connections: targetLandmarkNames.map((targetLandmarkName, index) => ({ - targetLandmarkName, - relativePosition: index === 0 ? 'forward' : 'inside', - summary: index === 0 ? `沿主路可抵达${targetLandmarkName}` : `可从暗线进入${targetLandmarkName}`, - })), - }; -} - -function ensureUniqueName(name: string, existingNames: string[], fallbackName: string) { - const normalized = name.trim() || fallbackName; - if (!existingNames.includes(normalized)) { - return normalized; - } - - let index = 2; - let nextName = `${normalized}${index}`; - while (existingNames.includes(nextName)) { - index += 1; - nextName = `${normalized}${index}`; - } - return nextName; -} - -function sanitizeGeneratedRole( - rawValue: unknown, - profile: ParsedProfile, - kind: 'playable' | 'story', -) { - const record = toRecord(rawValue); - const fallbackDraft = buildFallbackRoleDraft(profile, kind); - const existingNames = [...profile.playableNpcs, ...profile.storyNpcs].map( - (role) => role.name, - ); - const seed = Date.now().toString(36); - const relationshipHooks = dedupeStrings( - toStringArray(record?.relationshipHooks, 6).concat( - fallbackDraft.relationshipHooks, - ), - 4, - ); - const tags = dedupeStrings( - toStringArray(record?.tags, 8).concat(fallbackDraft.tags), - 6, - ); - const chapterTeasers = toStringArray(record?.chapterTeasers, 4); - const chapterContents = toStringArray(record?.chapterContents, 4); - const skillRecords = Array.isArray(record?.skills) ? record.skills : []; - const itemRecords = Array.isArray(record?.initialItems) - ? record.initialItems - : []; - const name = ensureUniqueName( - toText(record?.name, fallbackDraft.name), - existingNames, - fallbackDraft.name, - ); - - return { - id: createStableId( - kind === 'playable' ? 'playable-npc' : 'story-npc', - name, - seed, - ), - name, - title: clampText(toText(record?.title, fallbackDraft.title), 20), - role: clampText( - toText(record?.role, fallbackDraft.role || fallbackDraft.title), - 20, - ), - description: clampText( - toText(record?.description, fallbackDraft.description), - 120, - ), - visualDescription: clampText( - toText(record?.visualDescription, fallbackDraft.visualDescription), - 180, - ), - actionDescription: clampText( - toText(record?.actionDescription, fallbackDraft.actionDescription), - 140, - ), - sceneVisualDescription: clampText( - toText( - record?.sceneVisualDescription, - fallbackDraft.sceneVisualDescription, - ), - 180, - ), - backstory: clampText(toText(record?.backstory, fallbackDraft.backstory), 260), - personality: clampText( - toText(record?.personality, fallbackDraft.personality), - 120, - ), - motivation: clampText( - toText(record?.motivation, fallbackDraft.motivation), - 120, - ), - combatStyle: clampText( - toText(record?.combatStyle, fallbackDraft.combatStyle), - 120, - ), - initialAffinity: - typeof record?.initialAffinity === 'number' && - Number.isFinite(record.initialAffinity) - ? Math.round( - Math.max( - kind === 'playable' ? 12 : -40, - Math.min(90, record.initialAffinity), - ), - ) - : fallbackDraft.initialAffinity, - relationshipHooks, - relations: [], - tags, - backstoryReveal: { - publicSummary: clampText( - toText(record?.publicSummary, fallbackDraft.publicSummary), - 120, - ), - chapters: BACKSTORY_CHAPTERS.map((chapter, index) => ({ - id: chapter.id, - title: chapter.title, - affinityRequired: chapter.affinityRequired, - teaser: - chapterTeasers[index] ?? - fallbackDraft.chapterTeasers[index] ?? - fallbackDraft.chapterTeasers[0], - content: - chapterContents[index] ?? - fallbackDraft.chapterContents[index] ?? - fallbackDraft.chapterContents[0], - contextSnippet: clampText( - chapterContents[index] ?? - fallbackDraft.chapterContents[index] ?? - fallbackDraft.chapterContents[0], - 36, - ), - })), - }, - skills: - skillRecords.length >= 3 - ? skillRecords.slice(0, 3).map((skill, index) => { - const skillRecord = toRecord(skill); - const fallbackSkill = - fallbackDraft.skills[index] ?? fallbackDraft.skills[0]; - return { - id: createStableId( - 'skill', - `${name}-${toText(skillRecord?.name, fallbackSkill.name)}`, - `${seed}-${index + 1}`, - ), - name: clampText( - toText(skillRecord?.name, fallbackSkill.name), - 20, - ), - summary: clampText( - toText(skillRecord?.summary, fallbackSkill.summary), - 60, - ), - style: clampText( - toText(skillRecord?.style, fallbackSkill.style), - 20, - ), - }; - }) - : fallbackDraft.skills.map((skill, index) => ({ - id: createStableId('skill', `${name}-${skill.name}`, `${seed}-${index + 1}`), - name: skill.name, - summary: skill.summary, - style: skill.style, - })), - initialItems: - itemRecords.length >= 3 - ? itemRecords.slice(0, 3).map((item, index) => { - const itemRecord = toRecord(item); - const fallbackItem = - fallbackDraft.initialItems[index] ?? fallbackDraft.initialItems[0]; - const rarity = toText(itemRecord?.rarity, fallbackItem.rarity); - return { - id: createStableId( - 'item', - `${name}-${toText(itemRecord?.name, fallbackItem.name)}`, - `${seed}-${index + 1}`, - ), - name: clampText(toText(itemRecord?.name, fallbackItem.name), 20), - category: clampText( - toText(itemRecord?.category, fallbackItem.category), - 16, - ), - quantity: - typeof itemRecord?.quantity === 'number' && - Number.isFinite(itemRecord.quantity) - ? Math.max(1, Math.min(9, Math.round(itemRecord.quantity))) - : fallbackItem.quantity, - rarity: - rarity === 'common' || - rarity === 'uncommon' || - rarity === 'rare' || - rarity === 'epic' || - rarity === 'legendary' - ? rarity - : fallbackItem.rarity, - description: clampText( - toText(itemRecord?.description, fallbackItem.description), - 80, - ), - tags: dedupeStrings( - toStringArray(itemRecord?.tags, 4).concat(fallbackItem.tags), - 4, - ), - }; - }) - : fallbackDraft.initialItems.map((item, index) => ({ - id: createStableId('item', `${name}-${item.name}`, `${seed}-${index + 1}`), - name: item.name, - category: item.category, - quantity: item.quantity, - rarity: item.rarity, - description: item.description, - tags: item.tags, - })), - }; -} - -function sanitizeGeneratedLandmark(rawValue: unknown, profile: ParsedProfile) { - const record = toRecord(rawValue); - const fallbackDraft = buildFallbackLandmarkDraft(profile); - const existingNames = profile.landmarks.map((landmark) => landmark.name); - const name = ensureUniqueName( - toText(record?.name, fallbackDraft.name), - existingNames, - fallbackDraft.name, - ); - const seed = Date.now().toString(36); - const storyNpcByName = new Map( - profile.storyNpcs.map((npc) => [npc.name.trim(), npc.id]), - ); - const landmarkByName = new Map( - profile.landmarks.map((landmark) => [landmark.name.trim(), landmark.id]), - ); - const rawSceneNpcNames = toStringArray(record?.sceneNpcNames, 12); - const rawConnections = Array.isArray(record?.connections) ? record.connections : []; - const resolvedSceneNpcIds = dedupeStrings( - rawSceneNpcNames - .map((npcName) => storyNpcByName.get(npcName.trim()) ?? '') - .concat( - fallbackDraft.sceneNpcNames - .map((npcName) => storyNpcByName.get(npcName.trim()) ?? '') - .filter(Boolean), - ), - 3, - ); - const fallbackSceneNpcIds = dedupeStrings( - profile.storyNpcs.slice(0, 3).map((npc) => npc.id), - 3, - ); - const sceneNpcIds = - resolvedSceneNpcIds.length >= 3 ? resolvedSceneNpcIds : fallbackSceneNpcIds; - - const connections = rawConnections - .map((item, index) => { - const connection = toRecord(item); - if (!connection) { - return null; - } - const targetLandmarkId = - landmarkByName.get(toText(connection.targetLandmarkName)) ?? - landmarkByName.get(toText(connection.targetLandmarkId)) ?? - ''; - if (!targetLandmarkId) { - return null; - } - - return { - targetLandmarkId, - relativePosition: toText( - connection.relativePosition, - index === 0 ? 'forward' : 'inside', - ), - summary: clampText( - toText( - connection.summary, - fallbackDraft.connections[index]?.summary || '可通往相邻区域', - ), - 24, - ), - }; - }) - .filter((item): item is ParsedLandmarkConnection => item !== null) - .filter((item) => item.targetLandmarkId); - - const fallbackConnections = fallbackDraft.connections - .map((connection) => { - const targetLandmarkId = - landmarkByName.get(connection.targetLandmarkName.trim()) ?? ''; - if (!targetLandmarkId) { - return null; - } - return { - targetLandmarkId, - relativePosition: connection.relativePosition, - summary: connection.summary, - } satisfies ParsedLandmarkConnection; - }) - .filter((item): item is ParsedLandmarkConnection => item !== null); - - return { - id: createStableId('landmark', name, seed), - name, - description: clampText( - toText(record?.description, fallbackDraft.description), - 140, - ), - visualDescription: clampText( - toText(record?.visualDescription, fallbackDraft.visualDescription), - 180, - ), - dangerLevel: (() => { - const level = toText(record?.dangerLevel, fallbackDraft.dangerLevel); - return level === 'low' || - level === 'medium' || - level === 'high' || - level === 'extreme' - ? level - : 'medium'; - })(), - sceneNpcIds, - connections: (connections.length > 0 ? connections : fallbackConnections).slice(0, 3), - }; -} - -async function requestGeneratedEntity( - llmClient: UpstreamLlmClient, - kind: CustomWorldEntityKind, - profile: ParsedProfile, -) { - const userPrompt = - kind === 'playable' - ? buildPlayablePrompt(profile) - : kind === 'story' - ? buildStoryPrompt(profile) - : buildLandmarkPrompt(profile); - - const content = await llmClient.requestMessageContent({ - systemPrompt: CUSTOM_WORLD_ENTITY_GENERATOR_SYSTEM_PROMPT, - userPrompt, - timeoutMs: 45000, - debugLabel: `custom-world-generate-${kind}`, - }); - - return parseJsonResponseText(extractJsonPayload(content)); -} - -export async function generateCustomWorldEntity( - llmClient: UpstreamLlmClient, - input: GenerateCustomWorldEntityInput, -) { - const profile = normalizeProfile(input.profile); - - try { - const parsed = await requestGeneratedEntity(llmClient, input.kind, profile); - const record = toRecord(parsed); - - if (input.kind === 'playable') { - return { - kind: 'playable' as const, - entity: sanitizeGeneratedRole(record?.playableNpc ?? parsed, profile, 'playable'), - }; - } - - if (input.kind === 'story') { - return { - kind: 'story' as const, - entity: sanitizeGeneratedRole(record?.storyNpc ?? parsed, profile, 'story'), - }; - } - - return { - kind: 'landmark' as const, - entity: sanitizeGeneratedLandmark(record?.landmark ?? parsed, profile), - }; - } catch { - if (input.kind === 'playable') { - return { - kind: 'playable' as const, - entity: sanitizeGeneratedRole(null, profile, 'playable'), - }; - } - - if (input.kind === 'story') { - return { - kind: 'story' as const, - entity: sanitizeGeneratedRole(null, profile, 'story'), - }; - } - - return { - kind: 'landmark' as const, - entity: sanitizeGeneratedLandmark(null, profile), - }; - } -} diff --git a/server-node/src/services/customWorldSceneNpcGenerationService.ts b/server-node/src/services/customWorldSceneNpcGenerationService.ts deleted file mode 100644 index 02ae065b..00000000 --- a/server-node/src/services/customWorldSceneNpcGenerationService.ts +++ /dev/null @@ -1,514 +0,0 @@ -import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js'; -import { badRequest } from '../errors.js'; -import { - buildCustomWorldSceneNpcPrompt, - CUSTOM_WORLD_SCENE_NPC_SYSTEM_PROMPT, -} from '../prompts/customWorldSceneNpcPrompts.js'; -import type { UpstreamLlmClient } from './llmClient.js'; - -type SceneNpcGenerationInput = { - profile: Record; - landmarkId: string; -}; - -type ParsedStoryNpc = { - id: string; - name: string; - title: string; - role: string; - description: string; - personality: string; - motivation: string; - relationshipHooks: string[]; - tags: string[]; -}; - -type ParsedLandmark = { - id: string; - name: string; - description: string; - dangerLevel: string; - sceneNpcIds: string[]; -}; - -type ParsedProfile = { - name: string; - settingText: string; - storyNpcs: ParsedStoryNpc[]; - landmarks: ParsedLandmark[]; -}; - -type GeneratedNpcDraft = { - name: string; - title: string; - role: string; - description: string; - backstory: string; - personality: string; - motivation: string; - combatStyle: string; - initialAffinity: number; - relationshipHooks: string[]; - tags: string[]; - publicSummary: string; - chapterTeasers: string[]; - chapterContents: string[]; - skills: Array<{ - name: string; - summary: string; - style: string; - }>; - initialItems: Array<{ - name: string; - category: string; - quantity: number; - rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'; - description: string; - tags: string[]; - }>; -}; - -function toRecord(value: unknown) { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : null; -} - -function toText(value: unknown, fallback = '') { - return typeof value === 'string' && value.trim() ? value.trim() : fallback; -} - -function toStringArray(value: unknown, maxCount = 12) { - if (!Array.isArray(value)) { - return []; - } - - return value - .map((item) => toText(item)) - .filter(Boolean) - .slice(0, maxCount); -} - -function clampText(value: string, maxLength: number) { - const normalized = value.replace(/\s+/gu, ' ').trim(); - if (!normalized) { - return ''; - } - if (normalized.length <= maxLength) { - return normalized; - } - return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}…`; -} - -function slugify(value: string) { - const normalized = value - .trim() - .toLowerCase() - .replace(/[^a-z0-9\u4e00-\u9fa5]+/gu, '-') - .replace(/^-+|-+$/gu, ''); - - return normalized || 'entry'; -} - -function createStableId(prefix: string, label: string, seed: string) { - return `${prefix}-${slugify(label || prefix)}-${seed}`; -} - -function dedupeStrings(values: string[], maxCount = 8) { - return [...new Set(values.map((value) => value.trim()).filter(Boolean))].slice( - 0, - maxCount, - ); -} - -function normalizeStoryNpc(value: unknown): ParsedStoryNpc | null { - const record = toRecord(value); - if (!record) { - return null; - } - - const id = toText(record.id); - const name = toText(record.name); - if (!id || !name) { - return null; - } - - const title = toText(record.title); - const role = toText(record.role, title || '场景角色'); - - return { - id, - name, - title: title || role || '场景角色', - role, - description: toText(record.description), - personality: toText(record.personality), - motivation: toText(record.motivation), - relationshipHooks: toStringArray(record.relationshipHooks, 6), - tags: toStringArray(record.tags, 8), - }; -} - -function normalizeLandmark(value: unknown): ParsedLandmark | null { - const record = toRecord(value); - if (!record) { - return null; - } - - const id = toText(record.id); - const name = toText(record.name); - if (!id || !name) { - return null; - } - - return { - id, - name, - description: toText(record.description), - dangerLevel: toText(record.dangerLevel, '中'), - sceneNpcIds: toStringArray(record.sceneNpcIds, 12), - }; -} - -function normalizeProfile(value: unknown): ParsedProfile { - const record = toRecord(value); - if (!record) { - throw badRequest('profile is required'); - } - - const storyNpcs = Array.isArray(record.storyNpcs) - ? record.storyNpcs.map(normalizeStoryNpc).filter((item): item is ParsedStoryNpc => item !== null) - : []; - const landmarks = Array.isArray(record.landmarks) - ? record.landmarks.map(normalizeLandmark).filter((item): item is ParsedLandmark => item !== null) - : []; - - return { - name: toText(record.name, '自定义世界'), - settingText: toText(record.settingText), - storyNpcs, - landmarks, - }; -} - -function ensureUniqueName(name: string, existingNames: string[]) { - const normalizedName = name.trim() || '新场景角色'; - if (!existingNames.includes(normalizedName)) { - return normalizedName; - } - - let index = 2; - let nextName = `${normalizedName}${index}`; - while (existingNames.includes(nextName)) { - index += 1; - nextName = `${normalizedName}${index}`; - } - return nextName; -} - -function buildFallbackDraft( - profile: ParsedProfile, - landmark: ParsedLandmark, - sceneNpcs: ParsedStoryNpc[], -): GeneratedNpcDraft { - const tags = dedupeStrings([ - landmark.name, - landmark.dangerLevel, - ...sceneNpcs.flatMap((npc) => npc.tags), - ], 4); - - return { - name: `${landmark.name}来客`, - title: `${landmark.name}的观察者`, - role: `${landmark.name}的观察者`, - description: `长期活动于${landmark.name},熟悉这里的局势与暗线,能为玩家提供新的观察角度。`, - backstory: `他在${landmark.name}扎根已久,对这片区域的危险节奏、人物流动与隐藏冲突有自己的判断。`, - personality: '谨慎、敏锐,先观察再表态。', - motivation: `希望借玩家之手改变${landmark.name}当前逐渐失衡的局面。`, - combatStyle: '偏向控场与试探,不轻易暴露底牌。', - initialAffinity: 6, - relationshipHooks: dedupeStrings([ - `与${landmark.name}局势深度绑定`, - sceneNpcs[0] ? `对${sceneNpcs[0].name}保持长期观察` : '对玩家保持试探', - '愿意交换情报,但保留关键秘密', - ], 3), - tags, - publicSummary: `一名活跃于${landmark.name}的关键观察者。`, - chapterTeasers: [ - '他知道这片区域最近正在发生什么。', - '他与此地某个旧事件有直接牵连。', - '他真正想推动的局面并不只是自保。', - '他手里握有改变关系网的最后筹码。', - ], - chapterContents: [ - `他常年在${landmark.name}周边活动,对人和事的变化极为敏感。`, - `多年前的一次变故把他和${landmark.name}牢牢绑在了一起。`, - `他表面克制,实际上一直在寻找扭转局面的机会。`, - '他保留着一张只会在局势逼近临界点时才动用的底牌。', - ], - skills: [ - { - name: '试探起手', - summary: '以低风险方式摸清对手意图。', - style: '试探压制', - }, - { - name: '地形借势', - summary: `借助${landmark.name}环境制造主动权。`, - style: '环境协同', - }, - { - name: '暗线反制', - summary: '在关键回合揭示隐藏准备,打乱对方节奏。', - style: '后手翻盘', - }, - ], - initialItems: [ - { - name: '随身兵装', - category: '武器', - quantity: 1, - rarity: 'rare', - description: '常备的近身防护装备。', - tags: ['自定义', landmark.name], - }, - { - name: '区域通行物', - category: '道具', - quantity: 1, - rarity: 'uncommon', - description: `能在${landmark.name}一带快速周转的私人物件。`, - tags: ['自定义'], - }, - { - name: '情报残页', - category: '专属物品', - quantity: 1, - rarity: 'rare', - description: '记录着部分隐藏线索与往事片段。', - tags: ['线索'], - }, - ], - }; -} - -function sanitizeGeneratedNpc( - rawValue: unknown, - profile: ParsedProfile, - landmark: ParsedLandmark, - fallbackDraft: GeneratedNpcDraft, -) { - const record = toRecord(rawValue); - const existingNames = profile.storyNpcs.map((npc) => npc.name); - const seed = Date.now().toString(36); - const chapterTitles = ['表层来意', '旧事裂痕', '隐藏执念', '最终底牌']; - const chapterThresholds = [6, 12, 18, 24]; - const relationshipHooks = dedupeStrings( - toStringArray(record?.relationshipHooks, 6).concat( - fallbackDraft.relationshipHooks, - ), - 4, - ); - const tags = dedupeStrings( - toStringArray(record?.tags, 8).concat(fallbackDraft.tags, landmark.name), - 6, - ); - const chapterTeasers = toStringArray(record?.chapterTeasers, 4); - const chapterContents = toStringArray(record?.chapterContents, 4); - const skillRecords = Array.isArray(record?.skills) ? record?.skills : []; - const itemRecords = Array.isArray(record?.initialItems) ? record?.initialItems : []; - - const draft: GeneratedNpcDraft = { - name: ensureUniqueName( - toText(record?.name, fallbackDraft.name), - existingNames, - ), - title: toText(record?.title, fallbackDraft.title), - role: toText(record?.role, toText(record?.title, fallbackDraft.role)), - description: clampText( - toText(record?.description, fallbackDraft.description), - 120, - ), - backstory: clampText(toText(record?.backstory, fallbackDraft.backstory), 260), - personality: clampText( - toText(record?.personality, fallbackDraft.personality), - 100, - ), - motivation: clampText( - toText(record?.motivation, fallbackDraft.motivation), - 120, - ), - combatStyle: clampText( - toText(record?.combatStyle, fallbackDraft.combatStyle), - 100, - ), - initialAffinity: - typeof record?.initialAffinity === 'number' && - Number.isFinite(record.initialAffinity) - ? Math.min(12, Math.max(1, Math.round(record.initialAffinity))) - : fallbackDraft.initialAffinity, - relationshipHooks, - tags, - publicSummary: clampText( - toText(record?.publicSummary, fallbackDraft.publicSummary), - 120, - ), - chapterTeasers: - chapterTeasers.length === 4 - ? chapterTeasers - : fallbackDraft.chapterTeasers.slice(0, 4), - chapterContents: - chapterContents.length === 4 - ? chapterContents - : fallbackDraft.chapterContents.slice(0, 4), - skills: - skillRecords.length >= 3 - ? skillRecords.slice(0, 3).map((skill, index) => { - const skillRecord = toRecord(skill); - const fallbackSkill = - fallbackDraft.skills[index] ?? fallbackDraft.skills[0]; - return { - name: clampText( - toText(skillRecord?.name, fallbackSkill?.name || `技能${index + 1}`), - 20, - ), - summary: clampText( - toText(skillRecord?.summary, fallbackSkill?.summary || ''), - 60, - ), - style: clampText( - toText(skillRecord?.style, fallbackSkill?.style || ''), - 20, - ), - }; - }) - : fallbackDraft.skills, - initialItems: - itemRecords.length >= 3 - ? itemRecords.slice(0, 3).map((item, index) => { - const itemRecord = toRecord(item); - const fallbackItem = - fallbackDraft.initialItems[index] ?? fallbackDraft.initialItems[0]; - const rarity = toText(itemRecord?.rarity, fallbackItem?.rarity || 'rare'); - return { - name: clampText( - toText(itemRecord?.name, fallbackItem?.name || `物品${index + 1}`), - 20, - ), - category: clampText( - toText(itemRecord?.category, fallbackItem?.category || '道具'), - 16, - ), - quantity: - typeof itemRecord?.quantity === 'number' && - Number.isFinite(itemRecord.quantity) - ? Math.min(9, Math.max(1, Math.round(itemRecord.quantity))) - : fallbackItem?.quantity || 1, - rarity: - rarity === 'common' || - rarity === 'uncommon' || - rarity === 'rare' || - rarity === 'epic' || - rarity === 'legendary' - ? rarity - : fallbackItem?.rarity || 'rare', - description: clampText( - toText(itemRecord?.description, fallbackItem?.description || ''), - 80, - ), - tags: dedupeStrings( - toStringArray(itemRecord?.tags, 4).concat( - fallbackItem?.tags ?? [], - ), - 4, - ), - }; - }) - : fallbackDraft.initialItems, - }; - - return { - id: createStableId('story-npc', draft.name, seed), - name: draft.name, - title: draft.title || draft.role, - role: draft.role || draft.title, - description: draft.description, - backstory: draft.backstory, - personality: draft.personality, - motivation: draft.motivation, - combatStyle: draft.combatStyle, - initialAffinity: draft.initialAffinity, - relationshipHooks: draft.relationshipHooks, - relations: [], - tags: draft.tags, - backstoryReveal: { - publicSummary: draft.publicSummary, - chapters: chapterTitles.map((title, index) => ({ - id: ['surface', 'scar', 'hidden', 'final'][index], - title, - affinityRequired: chapterThresholds[index], - teaser: - draft.chapterTeasers[index] ?? fallbackDraft.chapterTeasers[index] ?? '', - content: - draft.chapterContents[index] ?? - fallbackDraft.chapterContents[index] ?? - '', - contextSnippet: '', - })), - }, - skills: draft.skills.map((skill, index) => ({ - id: createStableId('skill', `${draft.name}-${skill.name}`, `${seed}-${index + 1}`), - name: skill.name, - summary: skill.summary, - style: skill.style, - })), - initialItems: draft.initialItems.map((item, index) => ({ - id: createStableId('item', `${draft.name}-${item.name}`, `${seed}-${index + 1}`), - name: item.name, - category: item.category, - quantity: item.quantity, - rarity: item.rarity, - description: item.description, - tags: item.tags, - })), - }; -} - -export async function generateSceneNpcForLandmark( - llmClient: UpstreamLlmClient, - input: SceneNpcGenerationInput, -) { - const profile = normalizeProfile(input.profile); - const landmark = profile.landmarks.find((entry) => entry.id === input.landmarkId); - if (!landmark) { - throw badRequest('landmark not found'); - } - - const storyNpcById = new Map(profile.storyNpcs.map((npc) => [npc.id, npc])); - const sceneNpcs = landmark.sceneNpcIds - .map((npcId) => storyNpcById.get(npcId)) - .filter((npc): npc is ParsedStoryNpc => Boolean(npc)); - const otherNpcs = profile.storyNpcs.filter( - (npc) => !landmark.sceneNpcIds.includes(npc.id), - ); - const fallbackDraft = buildFallbackDraft(profile, landmark, sceneNpcs); - - try { - const content = await llmClient.requestMessageContent({ - systemPrompt: CUSTOM_WORLD_SCENE_NPC_SYSTEM_PROMPT, - userPrompt: buildCustomWorldSceneNpcPrompt( - profile, - landmark, - sceneNpcs, - otherNpcs, - ), - debugLabel: 'custom-world-scene-npc', - }); - const parsed = parseJsonResponseText(content); - const parsedRecord = toRecord(parsed); - const npcRecord = parsedRecord?.npc ?? parsed; - return sanitizeGeneratedNpc(npcRecord, profile, landmark, fallbackDraft); - } catch { - return sanitizeGeneratedNpc(fallbackDraft, profile, landmark, fallbackDraft); - } -} diff --git a/server-node/src/services/customWorldWorkSummaryService.integration.test.ts b/server-node/src/services/customWorldWorkSummaryService.integration.test.ts deleted file mode 100644 index 129ba6e9..00000000 --- a/server-node/src/services/customWorldWorkSummaryService.integration.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { - createRpgAgentSessionFixture, - createRpgCreationWorksResponseFixture, - createRpgWorldLibraryEntryFixture, -} from '../../../packages/shared/src/contracts/rpgCreationFixtures.js'; -import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js'; -import { createInMemoryRpgWorldRepositoryPorts } from './customWorldAgentRepositoryTestHelpers.js'; -import { - CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX, - CustomWorldAgentSessionStore, -} from './customWorldAgentSessionStore.js'; -import { RpgWorldWorkSummaryService } from './RpgWorldWorkSummaryService.js'; - -test('work summary service can aggregate shared RPG fixtures into draft and published entries', async () => { - const sessionFixture = createRpgAgentSessionFixture(); - const sessionRecord: CustomWorldSessionRecord = { - ...JSON.parse(JSON.stringify(sessionFixture)), - sessionId: `${CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX}fixture`, - userId: 'fixture-user', - seedText: '被海雾吞没的旧航路群岛', - operations: [], - checkpoints: [], - createdAt: sessionFixture.updatedAt, - updatedAt: sessionFixture.updatedAt, - } as CustomWorldSessionRecord; - const { rpgAgentSessionRepository, rpgWorldProfileRepository } = - createInMemoryRpgWorldRepositoryPorts({ - sessionRecords: [sessionRecord], - profileEntries: [createRpgWorldLibraryEntryFixture()], - }); - const sessionStore = new CustomWorldAgentSessionStore( - rpgAgentSessionRepository, - ); - const summaries = await new RpgWorldWorkSummaryService( - rpgWorldProfileRepository, - sessionStore, - ).list('fixture-user'); - const expected = createRpgCreationWorksResponseFixture(); - - assert.equal(summaries.length, expected.items.length); - - const draftItem = summaries.find((entry) => entry.sourceType === 'agent_session'); - const publishedItem = summaries.find( - (entry) => entry.sourceType === 'published_profile', - ); - const expectedDraft = expected.items.find( - (entry) => entry.sourceType === 'agent_session', - ); - const expectedPublished = expected.items.find( - (entry) => entry.sourceType === 'published_profile', - ); - - assert.ok(draftItem); - assert.ok(publishedItem); - assert.ok(expectedDraft); - assert.ok(expectedPublished); - - assert.equal(draftItem?.title, expectedDraft?.title); - assert.equal(draftItem?.subtitle, expectedDraft?.subtitle); - assert.equal(draftItem?.coverRenderMode, expectedDraft?.coverRenderMode); - assert.deepEqual( - draftItem?.coverCharacterImageSrcs, - expectedDraft?.coverCharacterImageSrcs, - ); - assert.equal(draftItem?.roleAssetSummaryLabel, expectedDraft?.roleAssetSummaryLabel); - assert.equal(draftItem?.publishReady, expectedDraft?.publishReady); - assert.equal(draftItem?.blockerCount, expectedDraft?.blockerCount); - - assert.equal(publishedItem?.title, expectedPublished?.title); - assert.equal(publishedItem?.profileId, expectedPublished?.profileId); - assert.equal(publishedItem?.canEnterWorld, true); - assert.equal(publishedItem?.coverRenderMode, expectedPublished?.coverRenderMode); -}); - -test('published agent sessions are filtered out after works unify to published profile truth', async () => { - const sessionFixture = createRpgAgentSessionFixture(); - const sessionRecord: CustomWorldSessionRecord = { - ...JSON.parse(JSON.stringify(sessionFixture)), - sessionId: `${CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX}published-fixture`, - userId: 'fixture-user', - seedText: '被海雾吞没的旧航路群岛', - stage: 'published', - operations: [], - checkpoints: [], - createdAt: sessionFixture.updatedAt, - updatedAt: sessionFixture.updatedAt, - } as CustomWorldSessionRecord; - const { rpgAgentSessionRepository, rpgWorldProfileRepository } = - createInMemoryRpgWorldRepositoryPorts({ - sessionRecords: [sessionRecord], - profileEntries: [createRpgWorldLibraryEntryFixture()], - }); - const sessionStore = new CustomWorldAgentSessionStore( - rpgAgentSessionRepository, - ); - - const summaries = await new RpgWorldWorkSummaryService( - rpgWorldProfileRepository, - sessionStore, - ).list('fixture-user'); - - assert.equal( - summaries.some((entry) => entry.sourceType === 'agent_session'), - false, - ); - assert.equal( - summaries.filter((entry) => entry.sourceType === 'published_profile').length, - 1, - ); -}); diff --git a/server-node/src/services/eightAnchorCompatibilityService.ts b/server-node/src/services/eightAnchorCompatibilityService.ts deleted file mode 100644 index 61754933..00000000 --- a/server-node/src/services/eightAnchorCompatibilityService.ts +++ /dev/null @@ -1,593 +0,0 @@ -import type { - CoreConflictValue, - EightAnchorContent, - HiddenLineValue, - IconicElementValue, - KeyRelationshipValue, - PlayerEntryPointValue, - PlayerFantasyValue, - ThemeBoundaryValue, - WorldPromiseValue, -} from '../../../packages/shared/src/contracts/customWorldAgent.js'; -import { - buildAnchorPackFromIntent, - createEmptyCreatorIntentRecord, - type CreatorCharacterSeedRecord, - type CustomWorldCreatorIntentRecord, -} from './customWorldAgentIntentExtractionService.js'; - -function toText(value: unknown) { - return typeof value === 'string' ? value.trim() : ''; -} - -function toStringArray(value: unknown, maxCount = 8) { - if (!Array.isArray(value)) { - return []; - } - - return [...new Set(value.map((item) => toText(item)).filter(Boolean))].slice( - 0, - maxCount, - ); -} - -function compactLines(items: Array) { - return items.map((item) => toText(item)).filter(Boolean).join(';'); -} - -function clampText(value: string, maxLength: number) { - const normalized = value.replace(/\s+/gu, ' ').trim(); - if (!normalized) { - return ''; - } - - if (normalized.length <= maxLength) { - return normalized; - } - - return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}…`; -} - -function createId(prefix: string, index: number) { - return `${prefix}-${index + 1}`; -} - -function splitRelationshipPair(value: string) { - const segments = value - .split(/[、//&|]/u) - .map((item) => item.trim()) - .flatMap((item) => item.split(/(?:与|和)/u)) - .map((item) => item.trim()) - .filter(Boolean); - - const meaningful = segments.filter( - (item) => item !== '玩家' && item !== '主角' && item !== '我', - ); - - return { - leadName: meaningful[0] || segments[0] || '', - relationToPlayer: - segments.length >= 2 ? segments.join(' / ') : value.trim(), - }; -} - -function normalizeWorldPromise(value: unknown): WorldPromiseValue | null { - if (!value || typeof value !== 'object') { - return null; - } - - const item = value as Record; - const nextValue = { - hook: toText(item.hook), - differentiator: toText(item.differentiator), - desiredExperience: toText(item.desiredExperience), - } satisfies WorldPromiseValue; - - return Object.values(nextValue).some(Boolean) ? nextValue : null; -} - -function normalizePlayerFantasy(value: unknown): PlayerFantasyValue | null { - if (!value || typeof value !== 'object') { - return null; - } - - const item = value as Record; - const nextValue = { - playerRole: toText(item.playerRole), - corePursuit: toText(item.corePursuit), - fearOfLoss: toText(item.fearOfLoss), - } satisfies PlayerFantasyValue; - - return Object.values(nextValue).some(Boolean) ? nextValue : null; -} - -function normalizeThemeBoundary(value: unknown): ThemeBoundaryValue | null { - if (!value || typeof value !== 'object') { - return null; - } - - const item = value as Record; - const nextValue = { - toneKeywords: toStringArray(item.toneKeywords, 8), - aestheticDirectives: toStringArray(item.aestheticDirectives, 8), - forbiddenDirectives: toStringArray(item.forbiddenDirectives, 8), - } satisfies ThemeBoundaryValue; - - return Object.values(nextValue).some((entry) => entry.length > 0) - ? nextValue - : null; -} - -function normalizePlayerEntryPoint(value: unknown): PlayerEntryPointValue | null { - if (!value || typeof value !== 'object') { - return null; - } - - const item = value as Record; - const nextValue = { - openingIdentity: toText(item.openingIdentity), - openingProblem: toText(item.openingProblem), - entryMotivation: toText(item.entryMotivation), - } satisfies PlayerEntryPointValue; - - return Object.values(nextValue).some(Boolean) ? nextValue : null; -} - -function normalizeCoreConflict(value: unknown): CoreConflictValue | null { - if (!value || typeof value !== 'object') { - return null; - } - - const item = value as Record; - const nextValue = { - surfaceConflicts: toStringArray(item.surfaceConflicts, 6), - hiddenCrisis: toText(item.hiddenCrisis), - firstTouchedConflict: toText(item.firstTouchedConflict), - } satisfies CoreConflictValue; - - return ( - nextValue.surfaceConflicts.length > 0 || - nextValue.hiddenCrisis || - nextValue.firstTouchedConflict - ) - ? nextValue - : null; -} - -function normalizeRelationship(value: unknown): KeyRelationshipValue | null { - if (!value || typeof value !== 'object') { - return null; - } - - const item = value as Record; - const nextValue = { - pairs: toText(item.pairs), - relationshipType: toText(item.relationshipType), - secretOrCost: toText(item.secretOrCost), - } satisfies KeyRelationshipValue; - - return Object.values(nextValue).some(Boolean) ? nextValue : null; -} - -function normalizeHiddenLines(value: unknown): HiddenLineValue | null { - if (!value || typeof value !== 'object') { - return null; - } - - const item = value as Record; - const nextValue = { - hiddenTruths: toStringArray(item.hiddenTruths, 6), - misdirectionHints: toStringArray(item.misdirectionHints, 6), - revealPacing: toText(item.revealPacing), - } satisfies HiddenLineValue; - - return ( - nextValue.hiddenTruths.length > 0 || - nextValue.misdirectionHints.length > 0 || - nextValue.revealPacing - ) - ? nextValue - : null; -} - -function normalizeIconicElements(value: unknown): IconicElementValue | null { - if (!value || typeof value !== 'object') { - return null; - } - - const item = value as Record; - const nextValue = { - iconicMotifs: toStringArray(item.iconicMotifs, 8), - institutionsOrArtifacts: toStringArray(item.institutionsOrArtifacts, 8), - hardRules: toStringArray(item.hardRules, 8), - } satisfies IconicElementValue; - - return ( - nextValue.iconicMotifs.length > 0 || - nextValue.institutionsOrArtifacts.length > 0 || - nextValue.hardRules.length > 0 - ) - ? nextValue - : null; -} - -export function createEmptyEightAnchorContent(): EightAnchorContent { - return { - worldPromise: null, - playerFantasy: null, - themeBoundary: null, - playerEntryPoint: null, - coreConflict: null, - keyRelationships: [], - hiddenLines: null, - iconicElements: null, - }; -} - -export function normalizeEightAnchorContent(value: unknown): EightAnchorContent { - if (!value || typeof value !== 'object') { - return createEmptyEightAnchorContent(); - } - - const item = value as Record; - - return { - worldPromise: normalizeWorldPromise(item.worldPromise), - playerFantasy: normalizePlayerFantasy(item.playerFantasy), - themeBoundary: normalizeThemeBoundary(item.themeBoundary), - playerEntryPoint: normalizePlayerEntryPoint(item.playerEntryPoint), - coreConflict: normalizeCoreConflict(item.coreConflict), - keyRelationships: Array.isArray(item.keyRelationships) - ? item.keyRelationships - .map((entry) => normalizeRelationship(entry)) - .filter((entry): entry is KeyRelationshipValue => Boolean(entry)) - .slice(0, 4) - : [], - hiddenLines: normalizeHiddenLines(item.hiddenLines), - iconicElements: normalizeIconicElements(item.iconicElements), - }; -} - -export function buildEightAnchorContentFromCreatorIntent( - intent: CustomWorldCreatorIntentRecord | null | undefined, -): EightAnchorContent { - if (!intent) { - return createEmptyEightAnchorContent(); - } - - const themeBoundary = - intent.themeKeywords.length > 0 || - intent.toneDirectives.length > 0 || - intent.forbiddenDirectives.length > 0 - ? { - toneKeywords: [...intent.themeKeywords], - aestheticDirectives: [...intent.toneDirectives], - forbiddenDirectives: [...intent.forbiddenDirectives], - } - : null; - - const firstCharacter = intent.keyCharacters[0] ?? null; - - return normalizeEightAnchorContent({ - worldPromise: - intent.worldHook || intent.rawSettingText - ? { - hook: intent.worldHook, - differentiator: intent.rawSettingText, - desiredExperience: compactLines([ - intent.themeKeywords[0], - intent.toneDirectives[0], - ]), - } - : null, - playerFantasy: - intent.playerPremise || intent.coreConflicts[0] - ? { - playerRole: intent.playerPremise, - corePursuit: intent.coreConflicts[0] ?? '', - fearOfLoss: firstCharacter?.hiddenHook ?? '', - } - : null, - themeBoundary, - playerEntryPoint: - intent.playerPremise || intent.openingSituation - ? { - openingIdentity: intent.playerPremise, - openingProblem: intent.openingSituation, - entryMotivation: intent.coreConflicts[0] ?? '', - } - : null, - coreConflict: - intent.coreConflicts.length > 0 - ? { - surfaceConflicts: intent.coreConflicts.slice(0, 3), - hiddenCrisis: intent.keyCharacters[0]?.hiddenHook ?? '', - firstTouchedConflict: intent.coreConflicts[0] ?? '', - } - : null, - keyRelationships: intent.keyCharacters.map((entry) => ({ - pairs: compactLines([ - entry.name, - entry.relationToPlayer ? `与玩家 ${entry.relationToPlayer}` : '', - ]), - relationshipType: entry.role, - secretOrCost: entry.hiddenHook, - })), - hiddenLines: - intent.keyCharacters.some((entry) => entry.hiddenHook) || - intent.forbiddenDirectives.length > 0 - ? { - hiddenTruths: intent.keyCharacters - .map((entry) => entry.hiddenHook) - .filter(Boolean) - .slice(0, 3), - misdirectionHints: intent.forbiddenDirectives.slice(0, 3), - revealPacing: '', - } - : null, - iconicElements: - intent.iconicElements.length > 0 - ? { - iconicMotifs: intent.iconicElements.slice(0, 4), - institutionsOrArtifacts: [], - hardRules: intent.forbiddenDirectives.slice(0, 3), - } - : null, - }); -} - -export function buildCreatorIntentFromEightAnchorContent( - anchorContent: EightAnchorContent, -): CustomWorldCreatorIntentRecord { - const nextIntent = createEmptyCreatorIntentRecord('freeform'); - const normalizedContent = normalizeEightAnchorContent(anchorContent); - const keyCharacters: CreatorCharacterSeedRecord[] = - normalizedContent.keyRelationships.map((entry, index) => { - const parsedPair = splitRelationshipPair(entry.pairs); - - return { - id: createId('creator-character', index), - name: parsedPair.leadName || `关键人物${index + 1}`, - role: entry.relationshipType, - publicMask: '', - hiddenHook: entry.secretOrCost, - relationToPlayer: parsedPair.relationToPlayer, - notes: '', - }; - }); - - const worldHook = compactLines([ - normalizedContent.worldPromise?.hook, - normalizedContent.worldPromise?.differentiator, - ]); - const playerPremise = compactLines([ - normalizedContent.playerFantasy?.playerRole, - normalizedContent.playerEntryPoint?.openingIdentity, - ]); - const openingSituation = compactLines([ - normalizedContent.playerEntryPoint?.openingProblem, - normalizedContent.playerEntryPoint?.entryMotivation, - ]); - const coreConflicts = [ - ...(normalizedContent.coreConflict?.surfaceConflicts ?? []), - normalizedContent.coreConflict?.hiddenCrisis ?? '', - ].filter(Boolean); - const iconicElements = [ - ...(normalizedContent.iconicElements?.iconicMotifs ?? []), - ...(normalizedContent.iconicElements?.institutionsOrArtifacts ?? []), - ].filter(Boolean); - const forbiddenDirectives = [ - ...(normalizedContent.themeBoundary?.forbiddenDirectives ?? []), - ...(normalizedContent.iconicElements?.hardRules ?? []), - ].filter(Boolean); - - return { - ...nextIntent, - rawSettingText: compactLines([ - normalizedContent.worldPromise?.differentiator, - normalizedContent.playerFantasy?.corePursuit, - normalizedContent.hiddenLines?.hiddenTruths[0], - ]), - worldHook, - themeKeywords: normalizedContent.themeBoundary?.toneKeywords ?? [], - toneDirectives: normalizedContent.themeBoundary?.aestheticDirectives ?? [], - playerPremise, - openingSituation, - coreConflicts: [...new Set(coreConflicts)].slice(0, 6), - keyCharacters, - iconicElements: [...new Set(iconicElements)].slice(0, 8), - forbiddenDirectives: [...new Set(forbiddenDirectives)].slice(0, 8), - } satisfies CustomWorldCreatorIntentRecord; -} - -function scoreFilledField(filled: boolean, score: number) { - return filled ? score : 0; -} - -export function estimateProgressPercentFromAnchorContent( - anchorContent: EightAnchorContent, -) { - const normalized = normalizeEightAnchorContent(anchorContent); - const progress = - scoreFilledField(Boolean(normalized.worldPromise?.hook), 14) + - scoreFilledField(Boolean(normalized.playerFantasy?.playerRole), 12) + - scoreFilledField( - Boolean( - normalized.themeBoundary?.toneKeywords.length || - normalized.themeBoundary?.aestheticDirectives.length, - ), - 12, - ) + - scoreFilledField( - Boolean(normalized.playerEntryPoint?.openingProblem), - 12, - ) + - scoreFilledField( - Boolean(normalized.coreConflict?.surfaceConflicts.length), - 16, - ) + - scoreFilledField(normalized.keyRelationships.length > 0, 14) + - scoreFilledField( - Boolean( - normalized.hiddenLines?.hiddenTruths.length || - normalized.hiddenLines?.revealPacing, - ), - 8, - ) + - scoreFilledField( - Boolean( - normalized.iconicElements?.iconicMotifs.length || - normalized.iconicElements?.institutionsOrArtifacts.length, - ), - 12, - ); - - return Math.max(0, Math.min(100, Math.round(progress))); -} - -export function buildAnchorPackFromEightAnchorContent( - anchorContent: EightAnchorContent, - progressPercent: number, -) { - const creatorIntent = buildCreatorIntentFromEightAnchorContent(anchorContent); - - return buildAnchorPackFromIntent(creatorIntent, { - completedKeys: progressPercent >= 100 ? ['eight_anchor_minimum_loop'] : [], - missingKeys: progressPercent >= 100 ? [] : ['eight_anchor_minimum_loop'], - }); -} - -export function buildEightAnchorFoundationText(anchorContent: EightAnchorContent) { - const normalized = normalizeEightAnchorContent(anchorContent); - - return [ - normalized.worldPromise - ? `世界承诺:${compactLines([ - normalized.worldPromise.hook, - normalized.worldPromise.differentiator, - normalized.worldPromise.desiredExperience, - ])}` - : '', - normalized.playerFantasy - ? `玩家幻想:${compactLines([ - normalized.playerFantasy.playerRole, - normalized.playerFantasy.corePursuit, - normalized.playerFantasy.fearOfLoss, - ])}` - : '', - normalized.themeBoundary - ? `主题边界:${compactLines([ - normalized.themeBoundary.toneKeywords.join('、'), - normalized.themeBoundary.aestheticDirectives.join('、'), - normalized.themeBoundary.forbiddenDirectives.join('、'), - ])}` - : '', - normalized.playerEntryPoint - ? `玩家切入口:${compactLines([ - normalized.playerEntryPoint.openingIdentity, - normalized.playerEntryPoint.openingProblem, - normalized.playerEntryPoint.entryMotivation, - ])}` - : '', - normalized.coreConflict - ? `核心冲突:${compactLines([ - normalized.coreConflict.surfaceConflicts.join('、'), - normalized.coreConflict.hiddenCrisis, - normalized.coreConflict.firstTouchedConflict, - ])}` - : '', - normalized.keyRelationships.length > 0 - ? `关键关系:${normalized.keyRelationships - .map((entry) => - compactLines([ - entry.pairs, - entry.relationshipType, - entry.secretOrCost, - ]), - ) - .filter(Boolean) - .join(';')}` - : '', - normalized.hiddenLines - ? `暗线与揭示:${compactLines([ - normalized.hiddenLines.hiddenTruths.join('、'), - normalized.hiddenLines.misdirectionHints.join('、'), - normalized.hiddenLines.revealPacing, - ])}` - : '', - normalized.iconicElements - ? `标志元素:${compactLines([ - normalized.iconicElements.iconicMotifs.join('、'), - normalized.iconicElements.institutionsOrArtifacts.join('、'), - normalized.iconicElements.hardRules.join('、'), - ])}` - : '', - ] - .filter(Boolean) - .join('\n'); -} - -export function buildDraftTitleFromEightAnchorContent( - anchorContent: EightAnchorContent, -) { - const normalized = normalizeEightAnchorContent(anchorContent); - const candidate = clampText( - normalized.worldPromise?.hook || - normalized.worldPromise?.differentiator || - normalized.iconicElements?.iconicMotifs[0] || - normalized.playerFantasy?.playerRole || - '', - 24, - ); - - return candidate || '未命名草稿'; -} - -export function buildDraftSummaryFromEightAnchorContent( - anchorContent: EightAnchorContent, -) { - const normalized = normalizeEightAnchorContent(anchorContent); - const summary = [ - compactLines([ - normalized.worldPromise?.hook, - normalized.worldPromise?.differentiator, - normalized.worldPromise?.desiredExperience, - ]), - compactLines([ - normalized.playerFantasy?.playerRole, - normalized.playerFantasy?.corePursuit, - normalized.playerFantasy?.fearOfLoss, - ]), - compactLines([ - normalized.playerEntryPoint?.openingIdentity, - normalized.playerEntryPoint?.openingProblem, - normalized.playerEntryPoint?.entryMotivation, - ]), - compactLines([ - normalized.coreConflict?.surfaceConflicts.join('、'), - normalized.coreConflict?.hiddenCrisis, - normalized.coreConflict?.firstTouchedConflict, - ]), - normalized.keyRelationships.length > 0 - ? normalized.keyRelationships - .map((entry) => - compactLines([ - entry.pairs, - entry.relationshipType, - entry.secretOrCost, - ]), - ) - .filter(Boolean) - .join(';') - : '', - compactLines([ - normalized.iconicElements?.iconicMotifs.join('、'), - normalized.iconicElements?.institutionsOrArtifacts.join('、'), - normalized.iconicElements?.hardRules.join('、'), - ]), - ] - .filter(Boolean) - .join(' · '); - - return clampText(summary, 180) || '还在收集你的世界锚点。'; -} diff --git a/server-node/src/services/eightAnchorPromptBuilder.ts b/server-node/src/services/eightAnchorPromptBuilder.ts deleted file mode 100644 index 3b84733a..00000000 --- a/server-node/src/services/eightAnchorPromptBuilder.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '../prompts/eightAnchorPrompts.js'; diff --git a/server-node/src/services/eightAnchorSingleTurnService.test.ts b/server-node/src/services/eightAnchorSingleTurnService.test.ts deleted file mode 100644 index 17c75831..00000000 --- a/server-node/src/services/eightAnchorSingleTurnService.test.ts +++ /dev/null @@ -1,420 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js'; -import { EightAnchorSingleTurnService } from './eightAnchorSingleTurnService.js'; -import type { UpstreamLlmClient } from './llmClient.js'; - -test('eight anchor single turn service updates anchors from model output', async () => { - const service = new EightAnchorSingleTurnService( - createTestCustomWorldAgentSingleTurnLlmClient(), - ); - - const result = await service.runTurn({ - currentTurn: 2, - progressPercent: 18, - quickFillRequested: false, - currentAnchorContent: { - worldPromise: { - hook: '一个被潮雾改写航线秩序的群岛世界。', - differentiator: '', - desiredExperience: '', - }, - playerFantasy: null, - themeBoundary: null, - playerEntryPoint: null, - coreConflict: null, - keyRelationships: [], - hiddenLines: null, - iconicElements: null, - }, - chatHistory: [ - { - role: 'assistant', - content: '现在世界底色有了,你最想让玩家以什么身份卷进来?', - }, - { - role: 'user', - content: - '玩家是被迫返乡的守灯人继承人,开场时刚回到港口就发现禁航区亮起了假航灯。', - }, - ], - }); - - assert.ok(result.nextAnchorContent.worldPromise?.hook); - assert.match( - result.nextAnchorContent.playerFantasy?.playerRole ?? '', - /守灯人继承人/u, - ); - assert.match( - result.nextAnchorContent.playerEntryPoint?.openingProblem ?? '', - /假航灯/u, - ); - assert.ok(result.progressPercent >= 20); - assert.ok(result.replyText.length > 0); -}); - -test('eight anchor single turn service forces completion from model output when quick fill is requested', async () => { - const service = new EightAnchorSingleTurnService( - createTestCustomWorldAgentSingleTurnLlmClient(), - ); - - const result = await service.runTurn({ - currentTurn: 6, - progressPercent: 62, - quickFillRequested: true, - currentAnchorContent: { - worldPromise: { - hook: '一个被潮雾改写航线秩序的群岛世界。', - differentiator: '所有人都要向旧灯塔借路。', - desiredExperience: '压抑、悬疑', - }, - playerFantasy: { - playerRole: '玩家是被迫返乡的守灯人继承人。', - corePursuit: '查清沉船夜背后的真相。', - fearOfLoss: '', - }, - themeBoundary: null, - playerEntryPoint: null, - coreConflict: null, - keyRelationships: [], - hiddenLines: null, - iconicElements: null, - }, - chatHistory: [ - { - role: 'user', - content: '请直接一键补全剩余设定。', - }, - ], - }); - - assert.equal(result.progressPercent, 100); - assert.ok(result.nextAnchorContent.coreConflict); - assert.ok(result.nextAnchorContent.keyRelationships.length > 0); - assert.match(result.replyText, /生成游戏设定草稿/u); -}); - -test('eight anchor single turn service keeps the current anchors unchanged when llm is unavailable', async () => { - const service = new EightAnchorSingleTurnService(); - const currentAnchorContent = { - worldPromise: { - hook: '一个被潮雾改写航线秩序的群岛世界。', - differentiator: '所有人都要向旧灯塔借路。', - desiredExperience: '压抑、悬疑', - }, - playerFantasy: null, - themeBoundary: null, - playerEntryPoint: null, - coreConflict: null, - keyRelationships: [], - hiddenLines: null, - iconicElements: null, - }; - - const result = await service.runTurn({ - currentTurn: 2, - progressPercent: 24, - quickFillRequested: false, - currentAnchorContent, - chatHistory: [ - { - role: 'user', - content: '玩家是被迫返乡的守灯人继承人。', - }, - ], - }); - - assert.deepEqual(result.nextAnchorContent, currentAnchorContent); - assert.equal(result.progressPercent, 24); - assert.match(result.replyText, /保留上一版/u); -}); - -test('eight anchor single turn service runs state inference before formal generation and injects it into the next prompt', async () => { - const inferenceCalls: Array<{ - debugLabel?: string; - systemPrompt: string; - userPrompt: string; - }> = []; - const streamCalls: Array<{ - debugLabel?: string; - systemPrompt: string; - userPrompt: string; - }> = []; - const streamedReplyUpdates: string[] = []; - const llmClient = { - requestMessageContent: async (params) => { - inferenceCalls.push({ - debugLabel: params.debugLabel, - systemPrompt: params.systemPrompt, - userPrompt: params.userPrompt, - }); - - if (params.debugLabel === 'custom-world-eight-anchor-state-inference') { - return JSON.stringify({ - userInputSignal: 'correction', - driftRisk: 'high', - conversationMode: 'repair_direction', - judgementSummary: - '用户正在修正既有方向,正式生成时要优先吸收修正并避免沿用旧设定。', - }); - } - - throw new Error('formal generation should use streamMessageContent'); - }, - streamMessageContent: async (params) => { - streamCalls.push({ - debugLabel: params.debugLabel, - systemPrompt: params.systemPrompt, - userPrompt: params.userPrompt, - }); - - params.onUpdate?.('{"replyText":"我先按你修正后的'); - params.onUpdate?.( - '{"replyText":"我先按你修正后的方向收住了,现在这套悬念会更稳一些。', - ); - - return JSON.stringify({ - nextAnchorContent: { - worldPromise: { - hook: '一个以旧航线骗局为核心悬念的群岛世界。', - differentiator: '假航灯会改写整片海域的生路判断。', - desiredExperience: '压迫、悬疑、潮湿', - }, - playerFantasy: { - playerRole: '玩家是返乡的守灯人继承人。', - corePursuit: '查清旧航线骗局的源头。', - fearOfLoss: '失去家族仅剩的航线名誉。', - }, - themeBoundary: { - toneKeywords: ['压迫'], - aestheticDirectives: ['潮湿群岛'], - forbiddenDirectives: [], - }, - playerEntryPoint: { - openingIdentity: '返乡继承人', - openingProblem: '港口重新亮起假航灯', - entryMotivation: '阻止更多船只误入禁航区', - }, - coreConflict: { - surfaceConflicts: ['假航灯骗局重新启动'], - hiddenCrisis: '有人借旧航线秩序收割整座群岛', - firstTouchedConflict: '玩家返乡当晚就撞上假航灯', - }, - keyRelationships: [ - { - pairs: '玩家 vs 旧港校灯人', - relationshipType: '旧识互疑', - secretOrCost: '对方知道家族旧案', - }, - ], - hiddenLines: { - hiddenTruths: ['假航灯背后藏着旧案延续'], - misdirectionHints: ['表面像海盗所为'], - revealPacing: '先见异常,再见旧案,再见操盘者', - }, - iconicElements: { - iconicMotifs: ['假航灯', '潮雾'], - institutionsOrArtifacts: ['旧灯塔'], - hardRules: ['错误航灯会把船引向死路'], - }, - }, - progressPercent: 58, - replyText: '我先按你修正后的方向收住了,现在这套悬念会更稳一些。', - }); - }, - } as UpstreamLlmClient; - const service = new EightAnchorSingleTurnService(llmClient); - - const result = await service.streamTurn( - { - currentTurn: 4, - progressPercent: 44, - quickFillRequested: false, - currentAnchorContent: { - worldPromise: { - hook: '一个被潮雾改写航线秩序的群岛世界。', - differentiator: '', - desiredExperience: '', - }, - playerFantasy: null, - themeBoundary: null, - playerEntryPoint: null, - coreConflict: null, - keyRelationships: [], - hiddenLines: null, - iconicElements: null, - }, - chatHistory: [ - { - role: 'assistant', - content: '我们先把世界方向定住,你最想强调哪种悬念?', - }, - { - role: 'user', - content: '不是海怪方向,改成旧航线骗局,假航灯才是这世界真正的危险。', - }, - ], - }, - { - onReplyUpdate: (text) => { - streamedReplyUpdates.push(text); - }, - }, - ); - - assert.equal(inferenceCalls.length, 1); - assert.equal(streamCalls.length, 1); - assert.equal( - inferenceCalls[0]?.debugLabel, - 'custom-world-eight-anchor-state-inference', - ); - assert.equal( - streamCalls[0]?.debugLabel, - 'custom-world-eight-anchor-single-turn', - ); - assert.match( - streamCalls[0]?.systemPrompt ?? '', - /userInputSignal: correction/u, - ); - assert.match( - streamCalls[0]?.systemPrompt ?? '', - /conversationMode: repair_direction/u, - ); - assert.match( - streamCalls[0]?.systemPrompt ?? '', - /用户正在修正既有方向/u, - ); - assert.deepEqual(streamedReplyUpdates, [ - '我先按你修正后的', - '我先按你修正后的方向收住了,现在这套悬念会更稳一些。', - ]); - assert.equal(result.progressPercent, 58); - assert.match(result.replyText, /修正后的方向/u); -}); - -test('eight anchor single turn service falls back to rule-based state when inference fails and still completes formal generation', async () => { - const inferenceCalls: Array<{ - debugLabel?: string; - systemPrompt: string; - }> = []; - const streamCalls: Array<{ - debugLabel?: string; - systemPrompt: string; - userPrompt: string; - }> = []; - const llmClient = { - requestMessageContent: async (params) => { - inferenceCalls.push({ - debugLabel: params.debugLabel, - systemPrompt: params.systemPrompt, - }); - - throw new Error('state inference failed'); - }, - streamMessageContent: async (params) => { - streamCalls.push({ - debugLabel: params.debugLabel, - systemPrompt: params.systemPrompt, - userPrompt: params.userPrompt, - }); - - return JSON.stringify({ - nextAnchorContent: { - worldPromise: { - hook: '一个被潮雾改写航线秩序的群岛世界。', - differentiator: '所有人都要向旧灯塔借路。', - desiredExperience: '压抑、悬疑', - }, - playerFantasy: { - playerRole: '玩家是被迫返乡的守灯人继承人。', - corePursuit: '查清沉船夜背后的真相。', - fearOfLoss: '', - }, - themeBoundary: { - toneKeywords: ['压抑'], - aestheticDirectives: ['潮雾群岛'], - forbiddenDirectives: [], - }, - playerEntryPoint: { - openingIdentity: '返乡继承人', - openingProblem: '港口重新亮起假航灯', - entryMotivation: '堵住灾难扩散', - }, - coreConflict: { - surfaceConflicts: ['禁航区异动'], - hiddenCrisis: '旧航线秩序正在被人篡改', - firstTouchedConflict: '返乡第一晚就撞上假航灯', - }, - keyRelationships: [ - { - pairs: '玩家 vs 港区旧识', - relationshipType: '彼此试探', - secretOrCost: '对方知道旧沉船夜的真相碎片', - }, - ], - hiddenLines: { - hiddenTruths: ['旧沉船夜不是意外'], - misdirectionHints: ['所有线索都先指向海盗'], - revealPacing: '先异常,再旧案,再真凶', - }, - iconicElements: { - iconicMotifs: ['假航灯'], - institutionsOrArtifacts: ['旧灯塔'], - hardRules: ['错误航灯会直接改写生路判断'], - }, - }, - progressPercent: 64, - replyText: '我先顺着你这轮修正把设定收住了,接下来可以继续往冲突和关系上补。', - }); - }, - } as UpstreamLlmClient; - const service = new EightAnchorSingleTurnService(llmClient); - - const result = await service.runTurn({ - currentTurn: 3, - progressPercent: 40, - quickFillRequested: false, - currentAnchorContent: { - worldPromise: { - hook: '一个被潮雾改写航线秩序的群岛世界。', - differentiator: '', - desiredExperience: '', - }, - playerFantasy: null, - themeBoundary: null, - playerEntryPoint: null, - coreConflict: null, - keyRelationships: [], - hiddenLines: null, - iconicElements: null, - }, - chatHistory: [ - { - role: 'user', - content: '不是海怪,改成旧航线骗局。', - }, - ], - }); - - assert.equal(inferenceCalls.length, 1); - assert.equal(streamCalls.length, 1); - assert.equal( - inferenceCalls[0]?.debugLabel, - 'custom-world-eight-anchor-state-inference', - ); - assert.equal( - streamCalls[0]?.debugLabel, - 'custom-world-eight-anchor-single-turn', - ); - assert.match( - streamCalls[0]?.systemPrompt ?? '', - /userInputSignal: correction/u, - ); - assert.match( - streamCalls[0]?.systemPrompt ?? '', - /conversationMode: repair_direction/u, - ); - assert.equal(result.progressPercent, 64); - assert.match(result.replyText, /修正/u); -}); diff --git a/server-node/src/services/eightAnchorSingleTurnService.ts b/server-node/src/services/eightAnchorSingleTurnService.ts deleted file mode 100644 index 63ed9321..00000000 --- a/server-node/src/services/eightAnchorSingleTurnService.ts +++ /dev/null @@ -1,322 +0,0 @@ -import type { EightAnchorContent } from '../../../packages/shared/src/contracts/customWorldAgent.js'; -import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js'; -import { - createEmptyEightAnchorContent, - normalizeEightAnchorContent, -} from './eightAnchorCompatibilityService.js'; -import { - buildEightAnchorSingleTurnPrompt, - buildPromptDynamicState, - buildPromptDynamicStateInferencePrompt, -} from './eightAnchorPromptBuilder.js'; -import type { UpstreamLlmClient } from './llmClient.js'; - -type SingleTurnChatMessage = { - role: 'user' | 'assistant'; - content: string; -}; - -export type SingleTurnModelOutput = { - nextAnchorContent: EightAnchorContent; - progressPercent: number; - replyText: string; -}; - -function toText(value: unknown) { - return typeof value === 'string' ? value.trim() : ''; -} - -function normalizeOutputValue(value: unknown) { - return normalizeEightAnchorContent(value ?? createEmptyEightAnchorContent()); -} - -function clampProgressPercent(value: unknown) { - if (typeof value !== 'number' || Number.isNaN(value)) { - return 0; - } - - return Math.max(0, Math.min(100, Math.round(value))); -} - -function decodeEscapedCharacter( - value: string, - input: string, - index: number, -): { decoded: string; nextIndex: number } | null { - if (value === '"' || value === '\\' || value === '/') { - return { - decoded: value, - nextIndex: index + 1, - }; - } - if (value === 'b') { - return { - decoded: '\b', - nextIndex: index + 1, - }; - } - if (value === 'f') { - return { - decoded: '\f', - nextIndex: index + 1, - }; - } - if (value === 'n') { - return { - decoded: '\n', - nextIndex: index + 1, - }; - } - if (value === 'r') { - return { - decoded: '\r', - nextIndex: index + 1, - }; - } - if (value === 't') { - return { - decoded: '\t', - nextIndex: index + 1, - }; - } - if (value === 'u') { - const hex = input.slice(index + 1, index + 5); - if (!/^[\da-fA-F]{4}$/u.test(hex)) { - return null; - } - - return { - decoded: String.fromCharCode(Number.parseInt(hex, 16)), - nextIndex: index + 5, - }; - } - - return { - decoded: value, - nextIndex: index + 1, - }; -} - -function extractReplyTextFromPartialJson(text: string) { - const keyIndex = text.indexOf('"replyText"'); - if (keyIndex < 0) { - return { - text: '', - started: false, - completed: false, - }; - } - - const colonIndex = text.indexOf(':', keyIndex); - if (colonIndex < 0) { - return { - text: '', - started: false, - completed: false, - }; - } - - let stringStartIndex = colonIndex + 1; - while ( - stringStartIndex < text.length && - /\s/u.test(text[stringStartIndex] ?? '') - ) { - stringStartIndex += 1; - } - - if (text[stringStartIndex] !== '"') { - return { - text: '', - started: false, - completed: false, - }; - } - - let cursor = stringStartIndex + 1; - let decoded = ''; - - while (cursor < text.length) { - const character = text[cursor] ?? ''; - if (character === '"') { - return { - text: decoded, - started: true, - completed: true, - }; - } - - if (character === '\\') { - const escaped = decodeEscapedCharacter( - text[cursor + 1] ?? '', - text, - cursor + 1, - ); - if (!escaped) { - break; - } - decoded += escaped.decoded; - cursor = escaped.nextIndex; - continue; - } - - decoded += character; - cursor += 1; - } - - return { - text: decoded, - started: true, - completed: false, - }; -} - -function buildUnavailableOutput( - input: { - progressPercent: number; - currentAnchorContent: EightAnchorContent; - }, - reason: 'unavailable' | 'failed', -) { - return { - nextAnchorContent: normalizeEightAnchorContent(input.currentAnchorContent), - progressPercent: Math.max(0, Math.min(100, Math.round(input.progressPercent))), - replyText: - reason === 'unavailable' - ? '当前模型不可用,这一轮设定先保留上一版。你可以稍后重试。' - : '这一轮设定还没成功更新,我先保留上一版。你可以再发一次,我继续接着收。', - } satisfies SingleTurnModelOutput; -} - -export class EightAnchorSingleTurnService { - constructor(private readonly llmClient?: UpstreamLlmClient) {} - - private async resolveDynamicState(input: { - currentTurn: number; - progressPercent: number; - quickFillRequested: boolean; - currentAnchorContent: EightAnchorContent; - chatHistory: SingleTurnChatMessage[]; - }) { - const fallbackState = buildPromptDynamicState(input); - - if (!this.llmClient) { - return fallbackState; - } - - const { systemPrompt, userPrompt } = - buildPromptDynamicStateInferencePrompt(input); - - try { - const content = await this.llmClient.requestMessageContent({ - systemPrompt, - userPrompt, - timeoutMs: 45000, - debugLabel: 'custom-world-eight-anchor-state-inference', - }); - const parsed = parseJsonResponseText(content) as { - userInputSignal?: unknown; - driftRisk?: unknown; - conversationMode?: unknown; - judgementSummary?: unknown; - }; - - return buildPromptDynamicState(input, parsed); - } catch { - return fallbackState; - } - } - - async runTurn(input: { - currentTurn: number; - progressPercent: number; - quickFillRequested: boolean; - currentAnchorContent: EightAnchorContent; - chatHistory: SingleTurnChatMessage[]; - }) { - return this.streamTurn(input); - } - - async streamTurn( - input: { - currentTurn: number; - progressPercent: number; - quickFillRequested: boolean; - currentAnchorContent: EightAnchorContent; - chatHistory: SingleTurnChatMessage[]; - }, - options: { - onReplyUpdate?: (text: string) => void; - } = {}, - ) { - const normalizedInput = { - ...input, - currentAnchorContent: normalizeEightAnchorContent(input.currentAnchorContent), - chatHistory: input.chatHistory.slice(-16), - }; - - if (!this.llmClient) { - const unavailableOutput = buildUnavailableOutput( - normalizedInput, - 'unavailable', - ); - options.onReplyUpdate?.(unavailableOutput.replyText); - return unavailableOutput; - } - - const dynamicState = await this.resolveDynamicState(normalizedInput); - const { prompt } = buildEightAnchorSingleTurnPrompt({ - ...normalizedInput, - dynamicState, - }); - let latestReplyText = ''; - - try { - const content = await this.llmClient.streamMessageContent({ - systemPrompt: prompt, - userPrompt: '请按约定输出这一轮的 JSON。', - timeoutMs: 60000, - debugLabel: 'custom-world-eight-anchor-single-turn', - onUpdate: (partialText) => { - const replyProgress = extractReplyTextFromPartialJson(partialText); - if ( - replyProgress.started && - replyProgress.text !== latestReplyText - ) { - latestReplyText = replyProgress.text; - options.onReplyUpdate?.(latestReplyText); - } - }, - }); - const parsed = parseJsonResponseText(content) as { - nextAnchorContent?: unknown; - progressPercent?: unknown; - replyText?: unknown; - }; - const nextAnchorContent = normalizeOutputValue(parsed.nextAnchorContent); - const progressPercent = normalizedInput.quickFillRequested - ? 100 - : clampProgressPercent(parsed.progressPercent); - const replyText = - toText(parsed.replyText) || - buildUnavailableOutput(normalizedInput, 'failed').replyText; - if (replyText !== latestReplyText) { - options.onReplyUpdate?.(replyText); - } - - return { - nextAnchorContent, - progressPercent, - replyText, - } satisfies SingleTurnModelOutput; - } catch { - const unavailableOutput = buildUnavailableOutput( - normalizedInput, - 'failed', - ); - if (unavailableOutput.replyText !== latestReplyText) { - options.onReplyUpdate?.(unavailableOutput.replyText); - } - return unavailableOutput; - } - } -} diff --git a/server-node/src/services/llmClient.ts b/server-node/src/services/llmClient.ts deleted file mode 100644 index f60d4b78..00000000 --- a/server-node/src/services/llmClient.ts +++ /dev/null @@ -1,510 +0,0 @@ -import { Readable } from 'node:stream'; - -import type { - Request as ExpressRequest, - Response as ExpressResponse, -} from 'express'; -import type { Logger } from 'pino'; - -import type { AppConfig } from '../config.js'; -import { HttpError, upstreamError } from '../errors.js'; -import { - extractApiErrorMessage, - prepareApiResponse, - prepareEventStreamResponse, -} from '../http.js'; - -export type ChatMessage = { - role: 'system' | 'user' | 'assistant'; - content: string; -}; - -type CompletionRequest = { - model?: string; - stream?: boolean; - messages: ChatMessage[]; -}; - -type RequestExecutionOptions = { - signal?: AbortSignal; - timeoutMs?: number; - debugLabel?: string; -}; - -const DEFAULT_LLM_REQUEST_TIMEOUT_MS = 30000; - -function normalizeBaseUrl(baseUrl: string) { - return baseUrl.replace(/\/+$/u, ''); -} - -function buildCompletionUrl(baseUrl: string) { - return `${normalizeBaseUrl(baseUrl)}/chat/completions`; -} - -function isAbortLikeError(error: unknown) { - return ( - (typeof DOMException !== 'undefined' && - error instanceof DOMException && - error.name === 'AbortError') || - (error instanceof Error && error.name === 'AbortError') - ); -} - -function readTimeoutMs(config: AppConfig) { - const parsed = Number(config.rawEnv.LLM_REQUEST_TIMEOUT_MS); - return Number.isFinite(parsed) && parsed > 0 - ? Math.round(parsed) - : DEFAULT_LLM_REQUEST_TIMEOUT_MS; -} - -export class UpstreamLlmTimeoutError extends HttpError { - constructor(message = 'LLM 上游请求超时') { - super(502, message, { - code: 'UPSTREAM_TIMEOUT', - }); - this.name = 'UpstreamLlmTimeoutError'; - } -} - -export class UpstreamLlmConnectivityError extends HttpError { - constructor(message = '无法连接 LLM 上游服务') { - super(502, message, { - code: 'UPSTREAM_CONNECTIVITY', - }); - this.name = 'UpstreamLlmConnectivityError'; - } -} - -export function isUpstreamLlmTimeoutError( - error: unknown, -): error is UpstreamLlmTimeoutError { - return ( - error instanceof UpstreamLlmTimeoutError || - (error instanceof HttpError && error.code === 'UPSTREAM_TIMEOUT') - ); -} - -export function isUpstreamLlmConnectivityError( - error: unknown, -): error is UpstreamLlmConnectivityError { - return ( - error instanceof UpstreamLlmConnectivityError || - (error instanceof HttpError && error.code === 'UPSTREAM_CONNECTIVITY') - ); -} - -export class UpstreamLlmClient { - readonly logger: Logger; - private readonly requestTimeoutMs: number; - - constructor( - private readonly config: AppConfig, - logger: Logger, - ) { - this.logger = logger; - this.requestTimeoutMs = readTimeoutMs(config); - } - - private resolveModel(model?: string) { - return model?.trim() || this.config.llm.model; - } - - private buildHeaders() { - if (!this.config.llm.apiKey) { - throw upstreamError('服务端缺少 LLM_API_KEY'); - } - - return { - Authorization: `Bearer ${this.config.llm.apiKey}`, - 'Content-Type': 'application/json', - }; - } - - private createRequestSignal( - externalSignal?: AbortSignal, - timeoutMs = this.requestTimeoutMs, - ) { - const controller = new AbortController(); - let timedOut = false; - const handleAbort = () => controller.abort(externalSignal?.reason); - const timeout = setTimeout(() => { - timedOut = true; - controller.abort(); - }, timeoutMs); - - if (externalSignal) { - if (externalSignal.aborted) { - handleAbort(); - } else { - externalSignal.addEventListener('abort', handleAbort, { - once: true, - }); - } - } - - return { - signal: controller.signal, - didTimeout() { - return timedOut; - }, - cleanup() { - clearTimeout(timeout); - externalSignal?.removeEventListener('abort', handleAbort); - }, - }; - } - - private attachRequestAbort(request: ExpressRequest) { - const controller = new AbortController(); - const handleClose = () => controller.abort(); - request.on('close', handleClose); - - return { - signal: controller.signal, - cleanup() { - request.removeListener('close', handleClose); - }, - }; - } - - async requestCompletion( - body: CompletionRequest, - options: RequestExecutionOptions = {}, - ) { - const timeoutMs = - typeof options.timeoutMs === 'number' && options.timeoutMs > 0 - ? Math.round(options.timeoutMs) - : this.requestTimeoutMs; - const requestSignal = this.createRequestSignal(options.signal, timeoutMs); - const model = this.resolveModel(body.model); - const debugLabel = - typeof options.debugLabel === 'string' && options.debugLabel.trim() - ? options.debugLabel.trim() - : undefined; - - const enableDebugLog = this.config.rawEnv.LLM_DEBUG_LOG === 'true'; - - if (enableDebugLog) { - this.logger.info( - { - llm_model: model, - llm_debug_label: debugLabel, - llm_messages: body.messages, - }, - '[LLM_DEBUG] Request prompt', - ); - } - - this.logger.debug( - { - llm_model: model, - llm_stream: body.stream === true, - llm_timeout_ms: timeoutMs, - llm_debug_label: debugLabel, - }, - 'llm upstream request started', - ); - - let response: globalThis.Response; - try { - response = await fetch(buildCompletionUrl(this.config.llm.baseUrl), { - method: 'POST', - headers: this.buildHeaders(), - body: JSON.stringify({ - ...body, - model, - }), - signal: requestSignal.signal, - }); - } catch (error) { - requestSignal.cleanup(); - if (requestSignal.didTimeout() && isAbortLikeError(error)) { - throw new UpstreamLlmTimeoutError(); - } - - if (error instanceof TypeError) { - throw new UpstreamLlmConnectivityError(); - } - - this.logger.warn( - { - err: error, - llm_model: model, - llm_stream: body.stream === true, - llm_debug_label: debugLabel, - }, - 'llm upstream request failed', - ); - throw error; - } - - requestSignal.cleanup(); - - if (!response.ok) { - const rawText = await response.text(); - throw upstreamError(extractApiErrorMessage(rawText, 'LLM 上游请求失败')); - } - - this.logger.debug( - { - llm_model: model, - llm_stream: body.stream === true, - llm_status: response.status, - llm_debug_label: debugLabel, - }, - 'llm upstream request succeeded', - ); - - return response; - } - - async requestMessageContent(params: { - systemPrompt: string; - userPrompt: string; - model?: string; - signal?: AbortSignal; - timeoutMs?: number; - debugLabel?: string; - }) { - const response = await this.requestCompletion( - { - model: params.model, - messages: [ - { role: 'system', content: params.systemPrompt }, - { role: 'user', content: params.userPrompt }, - ], - }, - { - signal: params.signal, - timeoutMs: params.timeoutMs, - debugLabel: params.debugLabel, - }, - ); - const rawText = await response.text(); - const parsed = JSON.parse(rawText) as { - choices?: Array<{ - message?: { - content?: string; - }; - }>; - }; - const content = parsed.choices?.[0]?.message?.content?.trim(); - - if (!content) { - throw upstreamError('LLM 返回内容为空'); - } - - const enableDebugLog = this.config.rawEnv.LLM_DEBUG_LOG === 'true'; - if (enableDebugLog) { - this.logger.info( - { - llm_debug_label: params.debugLabel, - llm_response_content: content, - llm_response_length: content.length, - }, - '[LLM_DEBUG] Response content', - ); - } - - return content; - } - - async streamMessageContent(params: { - systemPrompt: string; - userPrompt: string; - model?: string; - signal?: AbortSignal; - timeoutMs?: number; - debugLabel?: string; - onUpdate?: (text: string) => void; - }) { - const response = await this.requestCompletion( - { - model: params.model, - stream: true, - messages: [ - { role: 'system', content: params.systemPrompt }, - { role: 'user', content: params.userPrompt }, - ], - }, - { - signal: params.signal, - timeoutMs: params.timeoutMs, - debugLabel: params.debugLabel, - }, - ); - - if (!response.body) { - throw upstreamError('LLM 流式响应体不可用'); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder('utf-8'); - let buffer = ''; - let accumulatedText = ''; - - 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); - - for (const rawLine of eventBlock.split(/\r?\n/u)) { - const line = rawLine.trim(); - if (!line.startsWith('data:')) { - continue; - } - - const data = line.slice(5).trim(); - if (!data || data === '[DONE]') { - continue; - } - - try { - const parsed = JSON.parse(data) as { - choices?: Array<{ - delta?: { - content?: string; - }; - }>; - }; - const delta = parsed.choices?.[0]?.delta?.content; - if (typeof delta === 'string' && delta.length > 0) { - accumulatedText += delta; - params.onUpdate?.(accumulatedText); - } - } catch { - // Ignore malformed SSE frames from the upstream model. - } - } - } - } - - const content = accumulatedText.trim(); - if (!content) { - throw upstreamError('LLM 返回内容为空'); - } - - return content; - } - - async forwardCompletion( - request: ExpressRequest, - body: Record, - response: ExpressResponse, - ) { - const requestAbort = this.attachRequestAbort(request); - let upstreamResponse: globalThis.Response; - - try { - upstreamResponse = await fetch(buildCompletionUrl(this.config.llm.baseUrl), { - method: 'POST', - headers: this.buildHeaders(), - body: JSON.stringify({ - ...body, - model: - typeof body.model === 'string' && body.model.trim() - ? body.model - : this.config.llm.model, - }), - signal: requestAbort.signal, - }); - } catch (error) { - requestAbort.cleanup(); - if (requestAbort.signal.aborted && response.writableEnded) { - return; - } - throw error; - } - - if (!upstreamResponse.ok) { - requestAbort.cleanup(); - const rawText = await upstreamResponse.text(); - throw upstreamError(extractApiErrorMessage(rawText, 'LLM 上游请求失败')); - } - - prepareApiResponse(request, response, { - statusCode: upstreamResponse.status, - headers: { - 'Content-Type': - upstreamResponse.headers.get('content-type') || - 'application/json; charset=utf-8', - }, - }); - - if (!upstreamResponse.body) { - requestAbort.cleanup(); - response.end(); - return; - } - - try { - await Readable.fromWeb(upstreamResponse.body as never).pipe(response); - } finally { - requestAbort.cleanup(); - } - } - - async forwardSseText(params: { - request: ExpressRequest; - systemPrompt: string; - userPrompt: string; - response: ExpressResponse; - model?: string; - }) { - const requestAbort = this.attachRequestAbort(params.request); - let upstreamResponse: globalThis.Response; - - try { - upstreamResponse = await this.requestCompletion( - { - model: params.model, - stream: true, - messages: [ - { role: 'system', content: params.systemPrompt }, - { role: 'user', content: params.userPrompt }, - ], - }, - { - signal: requestAbort.signal, - }, - ); - } catch (error) { - requestAbort.cleanup(); - if (requestAbort.signal.aborted && params.response.writableEnded) { - return; - } - throw error; - } - - prepareEventStreamResponse(params.request, params.response, { - statusCode: upstreamResponse.status, - headers: { - 'Content-Type': - upstreamResponse.headers.get('content-type') || - 'text/event-stream; charset=utf-8', - }, - }); - - if (!upstreamResponse.body) { - requestAbort.cleanup(); - params.response.end(); - return; - } - - try { - await Readable.fromWeb(upstreamResponse.body as never).pipe( - params.response, - ); - } finally { - requestAbort.cleanup(); - } - } -} diff --git a/server-node/src/services/questService.ts b/server-node/src/services/questService.ts deleted file mode 100644 index 7bc8db97..00000000 --- a/server-node/src/services/questService.ts +++ /dev/null @@ -1,140 +0,0 @@ -import type { QuestGenerationRequest } from '../../../packages/shared/src/contracts/rpgRuntimeQuestAssist.js'; -import { - QUEST_INTIMACY_LEVELS, - QUEST_NARRATIVE_TYPES, - QUEST_OBJECTIVE_KINDS, - QUEST_REWARD_THEMES, - QUEST_URGENCY_LEVELS, -} from '../../../packages/shared/src/contracts/rpgRuntimeQuestAssist.js'; -import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js'; -import { - buildFallbackQuestIntent, - compileQuestIntentToQuest, - evaluateQuestOpportunity, - buildQuestGenerationContextFromState, - buildQuestIntentPrompt, - QUEST_INTENT_SYSTEM_PROMPT, -} from '../bridges/legacyQuestRuntimeBridge.js'; -import type { UpstreamLlmClient } from './llmClient.js'; - -type QuestPreviewRequest = Parameters[0]; -type QuestIntent = ReturnType; -type QuestGenerationInput = Parameters[0]; -type QuestGenerationState = QuestGenerationInput['state']; -type QuestGenerationEncounter = QuestGenerationInput['encounter']; -type QuestLogEntry = ReturnType; - -function coerceString(value: unknown, fallback: string) { - return typeof value === 'string' && value.trim() ? value.trim() : fallback; -} - -function coerceStringArray(value: unknown, fallback: string[]) { - if (!Array.isArray(value)) { - return fallback; - } - - const items = value - .map((item) => (typeof item === 'string' ? item.trim() : '')) - .filter(Boolean); - - return items.length > 0 ? items : fallback; -} - -function sanitizeQuestIntent(rawIntent: unknown, fallback: QuestIntent): QuestIntent { - if (!rawIntent || typeof rawIntent !== 'object') { - return fallback; - } - - const intent = rawIntent as Record; - - return { - title: coerceString(intent.title, fallback.title), - description: coerceString(intent.description, fallback.description), - summary: coerceString(intent.summary, fallback.summary), - narrativeType: - typeof intent.narrativeType === 'string' && - QUEST_NARRATIVE_TYPES.includes(intent.narrativeType) - ? (intent.narrativeType as QuestIntent['narrativeType']) - : fallback.narrativeType, - dramaticNeed: coerceString(intent.dramaticNeed, fallback.dramaticNeed), - issuerGoal: coerceString(intent.issuerGoal, fallback.issuerGoal), - playerHook: coerceString(intent.playerHook, fallback.playerHook), - worldReason: coerceString(intent.worldReason, fallback.worldReason), - recommendedObjectiveKinds: coerceStringArray( - intent.recommendedObjectiveKinds, - fallback.recommendedObjectiveKinds, - ).filter((kind) => QUEST_OBJECTIVE_KINDS.includes(kind)) as QuestIntent['recommendedObjectiveKinds'], - urgency: - typeof intent.urgency === 'string' && - QUEST_URGENCY_LEVELS.includes(intent.urgency) - ? (intent.urgency as QuestIntent['urgency']) - : fallback.urgency, - intimacy: - typeof intent.intimacy === 'string' && - QUEST_INTIMACY_LEVELS.includes(intent.intimacy) - ? (intent.intimacy as QuestIntent['intimacy']) - : fallback.intimacy, - rewardTheme: - typeof intent.rewardTheme === 'string' && - QUEST_REWARD_THEMES.includes(intent.rewardTheme) - ? (intent.rewardTheme as QuestIntent['rewardTheme']) - : fallback.rewardTheme, - followupHooks: coerceStringArray(intent.followupHooks, fallback.followupHooks), - }; -} - -export async function generateQuestForNpcEncounter( - llmClient: UpstreamLlmClient, - params: QuestGenerationRequest, -): Promise { - const { state, encounter } = params; - const issuerNpcId = encounter.id ?? encounter.npcName; - const request: QuestPreviewRequest = { - issuerNpcId, - issuerNpcName: encounter.npcName, - roleText: encounter.context, - scene: state.currentScenePreset, - worldType: state.worldType, - currentQuests: state.quests.map((quest) => ({ - id: quest.id, - issuerNpcId: quest.issuerNpcId, - status: quest.status, - })), - context: buildQuestGenerationContextFromState({ state, encounter }), - origin: 'ai_compiled', - }; - const opportunity = evaluateQuestOpportunity(request); - if (!opportunity.shouldOffer) { - return null; - } - - const fallbackIntent = buildFallbackQuestIntent(request); - - try { - const content = await llmClient.requestMessageContent({ - systemPrompt: QUEST_INTENT_SYSTEM_PROMPT, - userPrompt: buildQuestIntentPrompt({ - context: request.context!, - scene: request.scene, - opportunity, - }), - }); - const parsed = parseJsonResponseText(content) as { intent?: unknown }; - const intent = sanitizeQuestIntent(parsed.intent, fallbackIntent); - return compileQuestIntentToQuest( - { - ...request, - origin: 'ai_compiled', - }, - intent, - ); - } catch { - return compileQuestIntentToQuest( - { - ...request, - origin: 'fallback_builder', - }, - fallbackIntent, - ); - } -} diff --git a/server-node/src/services/rpg-agent-session-store/rpgAgentSessionCompatibility.test.ts b/server-node/src/services/rpg-agent-session-store/rpgAgentSessionCompatibility.test.ts deleted file mode 100644 index a2fcac97..00000000 --- a/server-node/src/services/rpg-agent-session-store/rpgAgentSessionCompatibility.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { createRpgAgentSessionFixture } from '../../../../packages/shared/src/contracts/rpgCreationFixtures.js'; -import { applyCustomWorldAgentSessionCompatibility } from './rpgAgentSessionCompatibility.js'; - -function createLegacySessionRecord() { - const session = createRpgAgentSessionFixture(); - - return { - ...JSON.parse(JSON.stringify(session)), - userId: 'fixture-user', - seedText: '被海雾吞没的旧航路群岛', - operations: [], - checkpoints: [], - createdAt: session.updatedAt, - updatedAt: session.updatedAt, - }; -} - -test('session compatibility can backfill foundation_review state directly without store participation', () => { - const legacyRecord = createLegacySessionRecord(); - legacyRecord.stage = 'collecting_intent'; - legacyRecord.progressPercent = 0; - legacyRecord.currentTurn = 0; - legacyRecord.lastAssistantReply = null; - legacyRecord.anchorPack = {}; - legacyRecord.pendingClarifications = []; - legacyRecord.suggestedActions = []; - legacyRecord.recommendedReplies = []; - legacyRecord.creatorIntentReadiness = { - isReady: false, - completedKeys: [], - missingKeys: [], - }; - legacyRecord.anchorContent = {}; - legacyRecord.creatorIntent = { - rawSettingText: '', - worldHook: '被海雾吞没的旧航路群岛', - playerPremise: '玩家回到群岛调查沉船真相。', - openingSituation: '首夜就有陌生船只闯入禁航区。', - themeKeywords: ['压抑', '潮湿', '悬疑'], - toneDirectives: ['旧灯塔', '潮雾'], - coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], - keyCharacters: [ - { - id: 'playable-1', - name: '沈砺', - role: '旧航路引路人', - publicMask: '看上去像可靠旧友。', - hiddenHook: '暗中替沉船商盟引路。', - relationToPlayer: '旧友兼潜在背叛者', - notes: '关键同行者。', - }, - ], - iconicElements: ['回潮旧灯塔', '会移动的海雾'], - }; - legacyRecord.draftProfile = { - title: '潮雾列岛', - summary: '第一版世界底稿已经整理完成。', - playableNpcs: [ - { - id: 'playable-1', - name: '沈砺', - role: '旧航路引路人', - }, - ], - landmarks: [ - { - id: 'landmark-1', - name: '回潮旧灯塔', - description: '被海雾啃旧的石塔仍在夜里维持着微弱灯火。', - }, - ], - }; - - const normalized = applyCustomWorldAgentSessionCompatibility(legacyRecord); - - assert.equal(normalized.stage, 'foundation_review'); - assert.equal(normalized.progressPercent, 0); - assert.equal(normalized.creatorIntentReadiness.isReady, true); - assert.match( - normalized.lastAssistantReply ?? '', - /世界底稿已整理完成|结果页确认资产与发布门槛/u, - ); - assert.equal(normalized.pendingClarifications.length, 0); - assert.equal( - normalized.suggestedActions.some( - (entry) => entry.type === 'draft_foundation', - ), - true, - ); - assert.equal(normalized.anchorContent.worldPromise?.hook, '被海雾吞没的旧航路群岛'); - assert.equal(normalized.draftProfile.name, '潮雾列岛'); - assert.equal(normalized.assetCoverage.roleAssets.length, 1); -}); - -test('session compatibility can recover missing clarifications and anchor pack from sparse collecting records', () => { - const legacyRecord = createLegacySessionRecord(); - legacyRecord.seedText = ''; - legacyRecord.stage = 'collecting_intent'; - legacyRecord.progressPercent = Number.NaN; - legacyRecord.currentTurn = undefined; - legacyRecord.lastAssistantReply = undefined; - legacyRecord.anchorPack = null; - legacyRecord.pendingClarifications = []; - legacyRecord.suggestedActions = []; - legacyRecord.recommendedReplies = [1, '继续补世界一句话', null]; - legacyRecord.anchorContent = {}; - legacyRecord.creatorIntent = { - rawSettingText: '', - worldHook: '一个被潮雾反复切开的边境世界。', - playerPremise: '', - openingSituation: '', - themeKeywords: [], - toneDirectives: [], - coreConflicts: [], - keyCharacters: [], - iconicElements: [], - }; - legacyRecord.messages = [ - { - id: 'message-user', - role: 'user', - kind: 'chat', - text: '这个世界先定成一个被潮雾反复切开的边境世界。', - createdAt: legacyRecord.updatedAt, - relatedOperationId: null, - }, - { - id: 'message-assistant', - role: 'assistant', - kind: 'chat', - text: '你好!我是你的世界设定助手。', - createdAt: legacyRecord.updatedAt, - relatedOperationId: null, - }, - ]; - legacyRecord.draftProfile = { - title: '潮雾边境', - summary: '还在收集你的世界锚点。', - }; - - const normalized = applyCustomWorldAgentSessionCompatibility(legacyRecord); - - assert.equal(normalized.stage, 'clarifying'); - assert.ok(normalized.progressPercent > 0); - assert.equal(normalized.creatorIntentReadiness.isReady, false); - assert.ok(normalized.pendingClarifications.length > 0); - assert.ok(normalized.pendingClarifications[0]?.question); - assert.ok(normalized.anchorPack); - assert.deepEqual(normalized.recommendedReplies, ['继续补世界一句话']); - assert.ok( - normalized.suggestedActions.some( - (entry) => entry.type === 'request_summary', - ), - ); -}); diff --git a/server-node/src/services/rpg-agent-session-store/rpgAgentSessionCompatibility.ts b/server-node/src/services/rpg-agent-session-store/rpgAgentSessionCompatibility.ts deleted file mode 100644 index be7ac766..00000000 --- a/server-node/src/services/rpg-agent-session-store/rpgAgentSessionCompatibility.ts +++ /dev/null @@ -1,443 +0,0 @@ -import type { - CreatorIntentReadiness, - CustomWorldAgentStage, - CustomWorldAssetCoverageSummary, - CustomWorldPendingClarification, -} from '../../../../packages/shared/src/contracts/customWorldAgent.js'; -import { - buildPendingClarifications, - evaluateCreatorIntentReadiness, - resolveCreatorIntentStage, -} from '../customWorldAgentClarificationService.js'; -import { - normalizeCreatorIntentRecord, -} from '../customWorldAgentIntentExtractionService.js'; -import { rebuildRoleAssetCoverage } from '../customWorldAgentRoleAssetStateService.js'; -import { - buildAnchorPackFromEightAnchorContent, - buildCreatorIntentFromEightAnchorContent, - buildDraftSummaryFromEightAnchorContent, - buildDraftTitleFromEightAnchorContent, - buildEightAnchorContentFromCreatorIntent, - estimateProgressPercentFromAnchorContent, - normalizeEightAnchorContent, -} from '../eightAnchorCompatibilityService.js'; -import { - CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX, - type CustomWorldAgentSessionRecord, -} from './rpgAgentSessionRecord.js'; - -function toRecord(value: unknown) { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : null; -} - -function toText(value: unknown) { - return typeof value === 'string' ? value.trim() : ''; -} - -function isStage(value: unknown): value is CustomWorldAgentStage { - return ( - value === 'collecting_intent' || - value === 'clarifying' || - value === 'foundation_review' || - value === 'object_refining' || - value === 'visual_refining' || - value === 'long_tail_review' || - value === 'ready_to_publish' || - value === 'published' || - value === 'error' - ); -} - -export function isCustomWorldAgentSessionRecord( - value: unknown, -): value is CustomWorldAgentSessionRecord { - const record = toRecord(value); - if (!record) { - return false; - } - - return ( - typeof record.sessionId === 'string' && - record.sessionId.startsWith(CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX) && - typeof record.userId === 'string' && - isStage(record.stage) && - Array.isArray(record.messages) && - Array.isArray(record.operations) && - typeof record.createdAt === 'string' && - typeof record.updatedAt === 'string' - ); -} - -function isCreatorIntentReadiness( - value: unknown, -): value is CreatorIntentReadiness { - const record = toRecord(value); - if (!record) { - return false; - } - - return ( - typeof record.isReady === 'boolean' && - Array.isArray(record.completedKeys) && - Array.isArray(record.missingKeys) - ); -} - -function mapLegacyClarificationTargetKey(id: string) { - if (id === 'world_hook') return 'world_hook'; - if (id === 'player_premise') return 'player_premise'; - if (id === 'theme_and_tone' || id === 'tone_boundary') { - return 'theme_and_tone'; - } - if (id === 'core_conflict') return 'core_conflict'; - if (id === 'relationship_seed' || id === 'relationship_hook') { - return 'relationship_seed'; - } - if (id === 'iconic_element' || id === 'iconic_elements') { - return 'iconic_element'; - } - - return null; -} - -function hasUserInput(record: CustomWorldAgentSessionRecord) { - return ( - Boolean(record.seedText.trim()) || - record.messages.some( - (message) => message.role === 'user' && message.text.trim(), - ) - ); -} - -function buildCompatibleCreatorIntent(record: CustomWorldAgentSessionRecord) { - const compatibleAnchorIntent = buildCreatorIntentFromEightAnchorContent( - normalizeEightAnchorContent( - (record as Record).anchorContent ?? null, - ), - ); - - if ( - compatibleAnchorIntent && - (compatibleAnchorIntent.worldHook || - compatibleAnchorIntent.rawSettingText || - compatibleAnchorIntent.playerPremise || - compatibleAnchorIntent.openingSituation || - compatibleAnchorIntent.coreConflicts.length > 0 || - compatibleAnchorIntent.keyCharacters.length > 0 || - compatibleAnchorIntent.iconicElements.length > 0) - ) { - return compatibleAnchorIntent; - } - - return normalizeCreatorIntentRecord(record.creatorIntent); -} - -function buildCompatibleCurrentTurn(record: CustomWorldAgentSessionRecord) { - if (typeof (record as Record).currentTurn === 'number') { - return Math.max( - 0, - Math.round((record as Record).currentTurn as number), - ); - } - - return record.messages.filter((message) => message.role === 'user').length; -} - -function buildCompatibleAnchorContent(record: CustomWorldAgentSessionRecord) { - const normalized = normalizeEightAnchorContent( - (record as Record).anchorContent ?? null, - ); - - if ( - normalized.worldPromise || - normalized.playerFantasy || - normalized.themeBoundary || - normalized.playerEntryPoint || - normalized.coreConflict || - normalized.keyRelationships.length > 0 || - normalized.hiddenLines || - normalized.iconicElements - ) { - return normalized; - } - - return buildEightAnchorContentFromCreatorIntent( - buildCompatibleCreatorIntent(record), - ); -} - -function buildCompatibleProgressPercent(record: CustomWorldAgentSessionRecord) { - const rawProgress = (record as Record).progressPercent; - if (typeof rawProgress === 'number' && Number.isFinite(rawProgress)) { - return Math.max(0, Math.min(100, Math.round(rawProgress))); - } - - if ( - record.stage === 'foundation_review' || - record.stage === 'object_refining' || - record.stage === 'visual_refining' || - record.stage === 'long_tail_review' || - record.stage === 'ready_to_publish' || - record.stage === 'published' - ) { - return 100; - } - - return estimateProgressPercentFromAnchorContent( - buildCompatibleAnchorContent(record), - ); -} - -function buildCompatibleLastAssistantReply(record: CustomWorldAgentSessionRecord) { - const existingReply = (record as Record).lastAssistantReply; - if (typeof existingReply === 'string') { - return existingReply; - } - - const lastAssistantMessage = [...record.messages] - .reverse() - .find((message) => message.role === 'assistant' && message.text.trim()); - - return lastAssistantMessage?.text ?? null; -} - -function buildCompatiblePendingClarifications( - record: CustomWorldAgentSessionRecord, -) { - const normalizedIntent = normalizeCreatorIntentRecord(record.creatorIntent); - const readiness = buildCompatibleReadiness(record); - const legacyClarifications = Array.isArray(record.pendingClarifications) - ? record.pendingClarifications - : []; - - const nextClarifications = legacyClarifications - .map((entry, index) => { - const targetKey = mapLegacyClarificationTargetKey(entry.id); - if (!targetKey) { - return null; - } - - return { - id: entry.id || targetKey, - label: entry.label || '待补充问题', - question: entry.question || '', - targetKey, - priority: - typeof entry.priority === 'number' ? entry.priority : index + 1, - answer: entry.answer, - } satisfies CustomWorldPendingClarification; - }) - .filter((entry): entry is CustomWorldPendingClarification => - Boolean(entry?.question), - ) - .slice(0, 3); - - if (nextClarifications.length > 0) { - return nextClarifications; - } - - return buildPendingClarifications(normalizedIntent, readiness); -} - -function buildCompatibleReadiness(record: CustomWorldAgentSessionRecord) { - if ( - isCreatorIntentReadiness( - (record as Record).creatorIntentReadiness, - ) - ) { - return record.creatorIntentReadiness; - } - - return evaluateCreatorIntentReadiness( - normalizeCreatorIntentRecord(record.creatorIntent), - ); -} - -function buildCompatibleDraftProfile( - record: CustomWorldAgentSessionRecord, -) { - const anchorContent = buildCompatibleAnchorContent(record); - const existingDraftProfile = toRecord(record.draftProfile); - const hasFoundationContent = Boolean( - existingDraftProfile && - (typeof existingDraftProfile.name === 'string' || - Array.isArray(existingDraftProfile.playableNpcs) || - Array.isArray(existingDraftProfile.landmarks) || - Array.isArray(existingDraftProfile.factions) || - Array.isArray(existingDraftProfile.threads) || - Array.isArray(existingDraftProfile.chapters)), - ); - - if (hasFoundationContent) { - return { - ...existingDraftProfile, - name: - toText(existingDraftProfile?.name) || - toText(existingDraftProfile?.title) || - buildDraftTitleFromEightAnchorContent(anchorContent), - summary: - toText(existingDraftProfile?.summary) || - buildDraftSummaryFromEightAnchorContent(anchorContent), - }; - } - - return { - ...(existingDraftProfile ?? {}), - title: - toText(existingDraftProfile?.title) || - buildDraftTitleFromEightAnchorContent(anchorContent), - summary: - toText(existingDraftProfile?.summary) || - buildDraftSummaryFromEightAnchorContent(anchorContent), - }; -} - -function buildCompatibleSuggestedActions(params: { - record: CustomWorldAgentSessionRecord; - stage: CustomWorldAgentStage; - readiness: CreatorIntentReadiness; -}) { - if (params.record.suggestedActions.length > 0) { - // 旧快照里可能带有“精修对象”的 Agent 建议动作;当前 Agent 对话框只服务八锚点收集。 - const compatibleActions = params.record.suggestedActions.filter( - (action) => action.type !== 'refine_focus_target', - ); - if (compatibleActions.length > 0) { - return compatibleActions; - } - } - - const actions = [ - { - id: 'request_summary', - type: 'request_summary', - label: - params.stage === 'object_refining' || params.stage === 'visual_refining' - ? '总结当前世界底稿' - : '总结当前设定', - }, - ] as CustomWorldAgentSessionRecord['suggestedActions']; - - if (params.stage === 'foundation_review' && params.readiness.isReady) { - actions.push({ - id: 'draft_foundation', - type: 'draft_foundation', - label: '整理一版世界底稿', - }); - } - - return actions; -} - -function normalizeRecommendedReplies(value: unknown) { - if (!Array.isArray(value)) { - return []; - } - - return value - .map((item) => toText(item)) - .filter(Boolean) - .slice(0, 3); -} - -function buildCompatibleAssetCoverage( - record: CustomWorldAgentSessionRecord, - draftProfile: Record, -) { - const derivedCoverage = rebuildRoleAssetCoverage(draftProfile); - const existingCoverage = toRecord(record.assetCoverage); - const sceneAssets = - derivedCoverage.sceneAssets.length > 0 - ? derivedCoverage.sceneAssets - : Array.isArray(existingCoverage?.sceneAssets) - ? existingCoverage.sceneAssets - : []; - const allSceneAssetsReady = - derivedCoverage.sceneAssets.length > 0 - ? derivedCoverage.allSceneAssetsReady - : typeof existingCoverage?.allSceneAssetsReady === 'boolean' - ? existingCoverage.allSceneAssetsReady - : false; - - return { - ...derivedCoverage, - sceneAssets, - allSceneAssetsReady, - } satisfies CustomWorldAssetCoverageSummary; -} - -/** - * 兼容层集中收口旧 session 字段兜底,避免继续把兼容判断散落回 store 主逻辑。 - */ -export function applyCustomWorldAgentSessionCompatibility( - record: CustomWorldAgentSessionRecord, -) { - const creatorIntent = buildCompatibleCreatorIntent(record); - const currentTurn = buildCompatibleCurrentTurn(record); - const anchorContent = buildCompatibleAnchorContent(record); - const progressPercent = buildCompatibleProgressPercent(record); - const lastAssistantReply = buildCompatibleLastAssistantReply(record); - const creatorIntentReadiness = - progressPercent >= 100 - ? { - isReady: true, - completedKeys: [ - 'world_hook', - 'player_premise', - 'theme_and_tone', - 'core_conflict', - 'relationship_seed', - 'iconic_element', - ], - missingKeys: [], - } - : evaluateCreatorIntentReadiness(creatorIntent); - const stage = - record.stage === 'object_refining' || - record.stage === 'visual_refining' || - record.stage === 'long_tail_review' || - record.stage === 'ready_to_publish' || - record.stage === 'published' - ? record.stage - : progressPercent >= 100 - ? ('foundation_review' as const) - : resolveCreatorIntentStage({ - hasUserInput: hasUserInput(record), - readiness: creatorIntentReadiness, - }); - const pendingClarifications = buildCompatiblePendingClarifications({ - ...record, - creatorIntent, - creatorIntentReadiness, - }); - const draftProfile = buildCompatibleDraftProfile(record); - - return { - ...record, - currentTurn, - anchorContent, - progressPercent, - lastAssistantReply, - stage, - creatorIntent, - creatorIntentReadiness, - anchorPack: - record.anchorPack && Object.keys(record.anchorPack).length > 0 - ? record.anchorPack - : buildAnchorPackFromEightAnchorContent(anchorContent, progressPercent), - draftProfile, - pendingClarifications, - suggestedActions: buildCompatibleSuggestedActions({ - record, - stage, - readiness: creatorIntentReadiness, - }), - assetCoverage: buildCompatibleAssetCoverage(record, draftProfile), - recommendedReplies: normalizeRecommendedReplies( - (record as Record).recommendedReplies, - ), - } satisfies CustomWorldAgentSessionRecord; -} diff --git a/server-node/src/services/rpg-agent-session-store/rpgAgentSessionFactory.ts b/server-node/src/services/rpg-agent-session-store/rpgAgentSessionFactory.ts deleted file mode 100644 index aeaaebd2..00000000 --- a/server-node/src/services/rpg-agent-session-store/rpgAgentSessionFactory.ts +++ /dev/null @@ -1,74 +0,0 @@ -import crypto from 'node:crypto'; - -import type { CustomWorldAgentMessage } from '../../../../packages/shared/src/contracts/customWorldAgent.js'; -import { rebuildRoleAssetCoverage } from '../customWorldAgentRoleAssetStateService.js'; -import { - createEmptyEightAnchorContent, - normalizeEightAnchorContent, -} from '../eightAnchorCompatibilityService.js'; -import { applyCustomWorldAgentSessionCompatibility } from './rpgAgentSessionCompatibility.js'; -import { - cloneRpgAgentSessionValue, - type CreateCustomWorldAgentSessionInput, - type CustomWorldAgentSessionRecord, - CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX, -} from './rpgAgentSessionRecord.js'; - -/** - * 新建 session 的初始值统一在这里生成,后续 store 只负责持久化与状态变更。 - */ -export function createCustomWorldAgentSessionRecord( - userId: string, - input: CreateCustomWorldAgentSessionInput, -) { - const sessionId = `${CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX}${crypto.randomBytes(16).toString('hex')}`; - const now = new Date().toISOString(); - const welcomeMessage: CustomWorldAgentMessage = { - id: `message-${crypto.randomBytes(8).toString('hex')}`, - role: 'assistant', - kind: 'chat', - text: input.welcomeMessage, - createdAt: now, - relatedOperationId: null, - }; - const record: CustomWorldAgentSessionRecord = { - sessionId, - userId, - seedText: input.seedText?.trim() ?? '', - currentTurn: Math.max(0, Math.round(input.currentTurn ?? 0)), - anchorContent: normalizeEightAnchorContent( - input.anchorContent ?? createEmptyEightAnchorContent(), - ), - progressPercent: Math.max( - 0, - Math.min(100, Math.round(input.progressPercent ?? 0)), - ), - lastAssistantReply: input.lastAssistantReply ?? input.welcomeMessage, - stage: input.stage ?? 'collecting_intent', - focusCardId: null, - creatorIntent: cloneRpgAgentSessionValue(input.creatorIntent ?? {}), - creatorIntentReadiness: input.creatorIntentReadiness ?? { - isReady: false, - completedKeys: [], - missingKeys: [], - }, - anchorPack: cloneRpgAgentSessionValue(input.anchorPack ?? {}), - lockState: {}, - draftProfile: cloneRpgAgentSessionValue(input.draftProfile ?? {}), - messages: [welcomeMessage], - draftCards: [], - pendingClarifications: cloneRpgAgentSessionValue( - input.pendingClarifications, - ), - suggestedActions: cloneRpgAgentSessionValue(input.suggestedActions), - recommendedReplies: cloneRpgAgentSessionValue(input.recommendedReplies ?? []), - qualityFindings: [], - assetCoverage: rebuildRoleAssetCoverage(input.draftProfile ?? {}), - operations: [], - checkpoints: [], - createdAt: now, - updatedAt: now, - }; - - return applyCustomWorldAgentSessionCompatibility(record); -} diff --git a/server-node/src/services/rpg-agent-session-store/rpgAgentSessionRecord.ts b/server-node/src/services/rpg-agent-session-store/rpgAgentSessionRecord.ts deleted file mode 100644 index 6681c9f5..00000000 --- a/server-node/src/services/rpg-agent-session-store/rpgAgentSessionRecord.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { - CreatorIntentReadiness, - CustomWorldAgentMessage, - CustomWorldAgentOperationRecord, - CustomWorldAgentStage, - CustomWorldAssetCoverageSummary, - CustomWorldDraftCardSummary, - CustomWorldPendingClarification, - CustomWorldSuggestedAction, - EightAnchorContent, -} from '../../../../packages/shared/src/contracts/customWorldAgent.js'; - -/** - * 当前阶段仍沿用旧 sessionId 前缀,避免影响已落库数据与前端恢复逻辑。 - */ -export const CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX = - 'custom-world-agent-session-'; - -export type CustomWorldAgentSessionRecord = { - sessionId: string; - userId: string; - seedText: string; - currentTurn: number; - anchorContent: EightAnchorContent; - progressPercent: number; - lastAssistantReply: string | null; - stage: CustomWorldAgentStage; - focusCardId: string | null; - creatorIntent: Record | null; - creatorIntentReadiness: CreatorIntentReadiness; - anchorPack: Record | null; - lockState: Record | null; - draftProfile: Record | null; - messages: CustomWorldAgentMessage[]; - draftCards: CustomWorldDraftCardSummary[]; - pendingClarifications: CustomWorldPendingClarification[]; - suggestedActions: CustomWorldSuggestedAction[]; - recommendedReplies: string[]; - qualityFindings: Array<{ - id: string; - severity: 'info' | 'warning' | 'blocker'; - code: string; - targetId?: string | null; - message: string; - }>; - assetCoverage: CustomWorldAssetCoverageSummary; - operations: CustomWorldAgentOperationRecord[]; - checkpoints: Array<{ - checkpointId: string; - createdAt: string; - label: string; - snapshot?: { - currentTurn: number; - anchorContent: EightAnchorContent; - progressPercent: number; - lastAssistantReply: string | null; - stage: CustomWorldAgentStage; - focusCardId: string | null; - creatorIntent: Record | null; - creatorIntentReadiness: CreatorIntentReadiness; - anchorPack: Record | null; - lockState: Record | null; - draftProfile: Record | null; - pendingClarifications: CustomWorldAgentSessionRecord['pendingClarifications']; - suggestedActions: CustomWorldAgentSessionRecord['suggestedActions']; - recommendedReplies: string[]; - draftCards: CustomWorldDraftCardSummary[]; - qualityFindings: CustomWorldAgentSessionRecord['qualityFindings']; - assetCoverage: CustomWorldAssetCoverageSummary; - } | null; - }>; - createdAt: string; - updatedAt: string; -}; - -export type CreateCustomWorldAgentSessionInput = { - seedText?: string; - welcomeMessage: string; - currentTurn?: number; - anchorContent?: EightAnchorContent; - progressPercent?: number; - lastAssistantReply?: string | null; - pendingClarifications: CustomWorldAgentSessionRecord['pendingClarifications']; - creatorIntent?: CustomWorldAgentSessionRecord['creatorIntent']; - creatorIntentReadiness?: CreatorIntentReadiness; - anchorPack?: CustomWorldAgentSessionRecord['anchorPack']; - draftProfile?: CustomWorldAgentSessionRecord['draftProfile']; - stage?: CustomWorldAgentStage; - suggestedActions: CustomWorldSuggestedAction[]; - recommendedReplies?: string[]; -}; - -/** - * session 记录里大量字段都是 JSON 结构,统一走结构化克隆可避免调用方误共享引用。 - */ -export function cloneRpgAgentSessionValue(value: T): T { - return JSON.parse(JSON.stringify(value)) as T; -} diff --git a/server-node/src/services/rpg-agent-session-store/rpgAgentSessionRepositoryAdapter.ts b/server-node/src/services/rpg-agent-session-store/rpgAgentSessionRepositoryAdapter.ts deleted file mode 100644 index 76153a53..00000000 --- a/server-node/src/services/rpg-agent-session-store/rpgAgentSessionRepositoryAdapter.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { CustomWorldSessionRecord } from '../../../../packages/shared/src/contracts/runtime.js'; -import type { RpgAgentSessionRepositoryPort } from '../../repositories/RpgAgentSessionRepository.js'; - -export class RpgAgentSessionRepositoryAdapter { - constructor( - private readonly repository: RpgAgentSessionRepositoryPort, - ) {} - - async list(userId: string) { - return this.repository.listSessions(userId); - } - - async get(userId: string, sessionId: string) { - return this.repository.getSession(userId, sessionId); - } - - async upsert( - userId: string, - sessionId: string, - session: CustomWorldSessionRecord, - ) { - return this.repository.upsertSession(userId, sessionId, session); - } -} diff --git a/server-node/src/services/rpgCreationPreviewProfileBuilder.ts b/server-node/src/services/rpgCreationPreviewProfileBuilder.ts deleted file mode 100644 index 112133d7..00000000 --- a/server-node/src/services/rpgCreationPreviewProfileBuilder.ts +++ /dev/null @@ -1,348 +0,0 @@ -import type { CustomWorldProfileRecord } from '../../../packages/shared/src/contracts/runtime.js'; -import { buildRpgWorldPreviewProfile } from './RpgWorldPreviewCompiler.js'; -import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js'; - -function toText(value: unknown) { - return typeof value === 'string' ? value.trim() : ''; -} - -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); -} - -function toRecordArray(value: unknown) { - return Array.isArray(value) - ? value.filter( - (entry): entry is Record => - Boolean(entry) && typeof entry === 'object' && !Array.isArray(entry), - ) - : []; -} - -function cloneRecord>(value: T): T { - return JSON.parse(JSON.stringify(value)) as T; -} - -function normalizeMatchText(value: unknown) { - return toText(value).toLocaleLowerCase(); -} - -function findUnusedMatchIndex( - records: Record[], - usedIndexes: Set, - matcher: (record: Record) => boolean, -) { - const matchedIndex = records.findIndex( - (record, index) => !usedIndexes.has(index) && matcher(record), - ); - if (matchedIndex >= 0) { - usedIndexes.add(matchedIndex); - } - return matchedIndex; -} - -function mergeDraftRolesIntoProfileRecord(params: { - baseRoles: unknown; - draftRoles: Array>; -}) { - const baseRoles = toRecordArray(params.baseRoles); - if (params.draftRoles.length <= 0) { - return baseRoles; - } - - const usedIndexes = new Set(); - return params.draftRoles.map((draftRole) => { - let matchedIndex = findUnusedMatchIndex( - baseRoles, - usedIndexes, - (record) => toText(record.id) === toText(draftRole.id), - ); - - if (matchedIndex < 0) { - matchedIndex = findUnusedMatchIndex( - baseRoles, - usedIndexes, - (record) => - normalizeMatchText(record.name) === normalizeMatchText(draftRole.name), - ); - } - - const baseRole = matchedIndex >= 0 ? baseRoles[matchedIndex] : null; - return { - ...(baseRole ?? {}), - ...draftRole, - imageSrc: toText(draftRole.imageSrc) || toText(baseRole?.imageSrc) || undefined, - generatedVisualAssetId: - toText(draftRole.generatedVisualAssetId) || - toText(baseRole?.generatedVisualAssetId) || - undefined, - generatedAnimationSetId: - toText(draftRole.generatedAnimationSetId) || - toText(baseRole?.generatedAnimationSetId) || - undefined, - animationMap: - isRecord(draftRole.animationMap) - ? draftRole.animationMap - : isRecord(baseRole?.animationMap) - ? baseRole.animationMap - : undefined, - } satisfies Record; - }); -} - -function mergeDraftLandmarksIntoProfileRecord(params: { - baseLandmarks: unknown; - draftLandmarks: Array>; -}) { - const baseLandmarks = toRecordArray(params.baseLandmarks); - if (params.draftLandmarks.length <= 0) { - return baseLandmarks; - } - - const usedIndexes = new Set(); - return params.draftLandmarks.map((draftLandmark) => { - let matchedIndex = findUnusedMatchIndex( - baseLandmarks, - usedIndexes, - (record) => toText(record.id) === toText(draftLandmark.id), - ); - - if (matchedIndex < 0) { - matchedIndex = findUnusedMatchIndex( - baseLandmarks, - usedIndexes, - (record) => - normalizeMatchText(record.name) === - normalizeMatchText(draftLandmark.name), - ); - } - - const baseLandmark = matchedIndex >= 0 ? baseLandmarks[matchedIndex] : null; - return { - ...(baseLandmark ?? {}), - ...draftLandmark, - imageSrc: - toText(draftLandmark.imageSrc) || toText(baseLandmark?.imageSrc) || undefined, - generatedSceneAssetId: - toText(draftLandmark.generatedSceneAssetId) || - toText(baseLandmark?.generatedSceneAssetId) || - undefined, - generatedScenePrompt: - toText(draftLandmark.generatedScenePrompt) || - toText(baseLandmark?.generatedScenePrompt) || - undefined, - generatedSceneModel: - toText(draftLandmark.generatedSceneModel) || - toText(baseLandmark?.generatedSceneModel) || - undefined, - } satisfies Record; - }); -} - -function mergeDraftSceneChaptersIntoProfileRecord(params: { - baseSceneChapters: unknown; - draftSceneChapters: unknown; -}) { - const baseSceneChapters = toRecordArray(params.baseSceneChapters); - const draftSceneChapters = toRecordArray(params.draftSceneChapters); - if (draftSceneChapters.length <= 0) { - return baseSceneChapters; - } - - const usedChapterIndexes = new Set(); - return draftSceneChapters.map((draftChapter) => { - let matchedIndex = findUnusedMatchIndex( - baseSceneChapters, - usedChapterIndexes, - (record) => toText(record.sceneId) === toText(draftChapter.sceneId), - ); - - if (matchedIndex < 0) { - matchedIndex = findUnusedMatchIndex( - baseSceneChapters, - usedChapterIndexes, - (record) => - normalizeMatchText(record.title) === normalizeMatchText(draftChapter.title), - ); - } - - const baseChapter = matchedIndex >= 0 ? baseSceneChapters[matchedIndex] : null; - const baseActs = toRecordArray(baseChapter?.acts); - const usedActIndexes = new Set(); - const mergedActs = toRecordArray(draftChapter.acts).map((draftAct) => { - let matchedActIndex = findUnusedMatchIndex( - baseActs, - usedActIndexes, - (record) => toText(record.id) === toText(draftAct.id), - ); - - if (matchedActIndex < 0) { - matchedActIndex = findUnusedMatchIndex( - baseActs, - usedActIndexes, - (record) => - normalizeMatchText(record.title) === - normalizeMatchText(draftAct.title), - ); - } - - const baseAct = matchedActIndex >= 0 ? baseActs[matchedActIndex] : null; - return { - ...(baseAct ?? {}), - ...draftAct, - backgroundImageSrc: - toText(draftAct.backgroundImageSrc) || - toText(baseAct?.backgroundImageSrc) || - undefined, - backgroundAssetId: - toText(draftAct.backgroundAssetId) || - toText(baseAct?.backgroundAssetId) || - undefined, - } satisfies Record; - }); - - return { - ...(baseChapter ?? {}), - ...draftChapter, - acts: mergedActs, - } satisfies Record; - }); -} - -function mergeDraftCampIntoProfileRecord(params: { - baseCamp: unknown; - draftCamp: unknown; -}) { - const draftCamp = isRecord(params.draftCamp) ? params.draftCamp : null; - if (!draftCamp) { - return isRecord(params.baseCamp) ? params.baseCamp : undefined; - } - - const baseCamp = isRecord(params.baseCamp) ? params.baseCamp : null; - return { - ...(baseCamp ?? {}), - ...draftCamp, - imageSrc: toText(draftCamp.imageSrc) || toText(baseCamp?.imageSrc) || undefined, - generatedSceneAssetId: - toText(draftCamp.generatedSceneAssetId) || - toText(baseCamp?.generatedSceneAssetId) || - undefined, - generatedScenePrompt: - toText(draftCamp.generatedScenePrompt) || - toText(baseCamp?.generatedScenePrompt) || - undefined, - generatedSceneModel: - toText(draftCamp.generatedSceneModel) || - toText(baseCamp?.generatedSceneModel) || - undefined, - } satisfies Record; -} - -function buildPreviewRawProfileSeed(params: { - sessionId: string; - profileId: string; - draftProfile: Record; -}) { - const foundationDraft = normalizeFoundationDraftProfile(params.draftProfile); - if (!foundationDraft) { - throw new Error('当前世界草稿为空,无法构建结果页预览。'); - } - - const legacyResultProfile = isRecord(params.draftProfile.legacyResultProfile) - ? cloneRecord(params.draftProfile.legacyResultProfile) - : null; - - const baseProfile = legacyResultProfile ?? { - id: params.profileId, - settingText: foundationDraft.worldHook, - name: foundationDraft.name, - subtitle: foundationDraft.subtitle, - summary: foundationDraft.summary, - tone: foundationDraft.tone, - playerGoal: foundationDraft.playerGoal, - templateWorldType: 'WUXIA', - majorFactions: foundationDraft.majorFactions, - coreConflicts: foundationDraft.coreConflicts, - playableNpcs: [], - storyNpcs: [], - items: [], - landmarks: [], - camp: null, - sceneChapterBlueprints: [], - generationMode: 'full', - generationStatus: 'complete', - }; - - return { - ...baseProfile, - id: params.profileId, - settingText: - toText(baseProfile.settingText) || foundationDraft.worldHook || foundationDraft.summary, - name: foundationDraft.name, - subtitle: foundationDraft.subtitle, - summary: foundationDraft.summary, - tone: foundationDraft.tone, - playerGoal: foundationDraft.playerGoal, - majorFactions: foundationDraft.majorFactions, - coreConflicts: foundationDraft.coreConflicts, - playableNpcs: mergeDraftRolesIntoProfileRecord({ - baseRoles: baseProfile.playableNpcs, - draftRoles: toRecordArray(params.draftProfile.playableNpcs), - }), - storyNpcs: mergeDraftRolesIntoProfileRecord({ - baseRoles: baseProfile.storyNpcs, - draftRoles: toRecordArray(params.draftProfile.storyNpcs), - }), - landmarks: mergeDraftLandmarksIntoProfileRecord({ - baseLandmarks: baseProfile.landmarks, - draftLandmarks: toRecordArray(params.draftProfile.landmarks), - }), - camp: mergeDraftCampIntoProfileRecord({ - baseCamp: baseProfile.camp, - draftCamp: params.draftProfile.camp, - }), - sceneChapterBlueprints: mergeDraftSceneChaptersIntoProfileRecord({ - baseSceneChapters: baseProfile.sceneChapterBlueprints, - draftSceneChapters: params.draftProfile.sceneChapters, - }), - creatorIntent: - (params.draftProfile.creatorIntent as Record | undefined) ?? - (baseProfile.creatorIntent as Record | undefined) ?? - null, - anchorPack: - (params.draftProfile.anchorPack as Record | undefined) ?? - (baseProfile.anchorPack as Record | undefined) ?? - null, - lockState: - (params.draftProfile.lockState as Record | undefined) ?? - (baseProfile.lockState as Record | undefined) ?? - null, - generationMode: 'full', - generationStatus: 'complete', - } satisfies Record; -} - -/** - * 结果页预览与正式发布统一走同一套“foundation draft + legacy 富字段合并”规则, - * 这样 Phase5 才能安全删除前端本地 fallback 编译桥。 - */ -export function buildRpgCreationPreviewProfileFromDraftProfile(params: { - sessionId: string; - draftProfile: Record; - profileId?: string; -}) { - const profileId = - toText(params.profileId) || - toText(params.draftProfile.legacyResultProfile?.id) || - `agent-draft-${params.sessionId}`; - const mergedProfile = buildPreviewRawProfileSeed({ - sessionId: params.sessionId, - profileId, - draftProfile: params.draftProfile, - }); - - return buildRpgWorldPreviewProfile( - mergedProfile, - toText(mergedProfile.settingText) || '', - ) as unknown as CustomWorldProfileRecord; -} diff --git a/server-node/src/services/runtimeItemService.ts b/server-node/src/services/runtimeItemService.ts deleted file mode 100644 index c8a21f7c..00000000 --- a/server-node/src/services/runtimeItemService.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { - RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES, - RUNTIME_ITEM_TONE_VALUES, -} from '../../../packages/shared/src/contracts/rpgRuntimeQuestAssist.js'; -import type { - RuntimeItemIntentRequest, -} from '../../../packages/shared/src/contracts/rpgRuntimeQuestAssist.js'; -import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js'; -import { - buildRuntimeItemAiIntent, - buildRuntimeItemIntentPrompt, - RUNTIME_ITEM_INTENT_SYSTEM_PROMPT, -} from '../bridges/legacyRuntimeItemBridge.js'; -import type { UpstreamLlmClient } from './llmClient.js'; - -type RuntimeItemGenerationContext = Parameters[0]; -type RuntimeItemPlan = Parameters[1]; -type RuntimeItemAiIntent = ReturnType; - -function coerceString(value: unknown, fallback: string) { - return typeof value === 'string' && value.trim() ? value.trim() : fallback; -} - -function coerceStringArray(value: unknown, fallback: string[], limit: number) { - if (!Array.isArray(value)) { - return fallback; - } - - const normalized = value - .map((item) => (typeof item === 'string' ? item.trim() : '')) - .filter(Boolean) - .slice(0, limit); - - return normalized.length > 0 ? normalized : fallback; -} - -function sanitizeRuntimeItemAiIntent( - rawIntent: unknown, - fallback: RuntimeItemAiIntent, -): RuntimeItemAiIntent { - if (!rawIntent || typeof rawIntent !== 'object') { - return fallback; - } - - const intent = rawIntent as Record; - const desiredFunctionalBias = coerceStringArray( - intent.desiredFunctionalBias, - fallback.desiredFunctionalBias, - 2, - ).filter( - ( - item, - ): item is RuntimeItemAiIntent['desiredFunctionalBias'][number] => - RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES.includes(item), - ); - const tone = coerceString(intent.tone, fallback.tone); - - return { - shortNameSeed: coerceString(intent.shortNameSeed, fallback.shortNameSeed), - sourcePhrase: coerceString(intent.sourcePhrase, fallback.sourcePhrase), - reasonToAppear: coerceString(intent.reasonToAppear, fallback.reasonToAppear), - relationHooks: coerceStringArray(intent.relationHooks, fallback.relationHooks, 2), - desiredBuildTags: coerceStringArray(intent.desiredBuildTags, fallback.desiredBuildTags, 3), - desiredFunctionalBias: - desiredFunctionalBias.length > 0 - ? desiredFunctionalBias - : fallback.desiredFunctionalBias, - tone: RUNTIME_ITEM_TONE_VALUES.includes(tone) - ? (tone as RuntimeItemAiIntent['tone']) - : fallback.tone, - visibleClue: coerceString(intent.visibleClue, fallback.visibleClue ?? ''), - witnessMark: coerceString(intent.witnessMark, fallback.witnessMark ?? ''), - unfinishedBusiness: coerceString( - intent.unfinishedBusiness, - fallback.unfinishedBusiness ?? '', - ), - hiddenHook: coerceString(intent.hiddenHook, fallback.hiddenHook ?? ''), - reactionHooks: coerceStringArray( - intent.reactionHooks, - fallback.reactionHooks ?? [], - 4, - ), - namingPattern: coerceString(intent.namingPattern, fallback.namingPattern ?? ''), - }; -} - -export async function generateRuntimeItemIntents( - llmClient: UpstreamLlmClient, - params: RuntimeItemIntentRequest, -) { - const fallbackIntents = params.plans.map((plan) => - buildRuntimeItemAiIntent(params.context, plan), - ); - - const content = await llmClient.requestMessageContent({ - systemPrompt: RUNTIME_ITEM_INTENT_SYSTEM_PROMPT, - userPrompt: buildRuntimeItemIntentPrompt(params), - }); - const parsed = parseJsonResponseText(content) as { - intents?: unknown[]; - }; - const rawIntents = Array.isArray(parsed.intents) ? parsed.intents : []; - - return params.plans.map((_, index) => - sanitizeRuntimeItemAiIntent(rawIntents[index], fallbackIntents[index]!), - ); -} diff --git a/server-node/src/services/sceneImageService.test.ts b/server-node/src/services/sceneImageService.test.ts deleted file mode 100644 index 83bbcbf0..00000000 --- a/server-node/src/services/sceneImageService.test.ts +++ /dev/null @@ -1,442 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import { - createServer, - type IncomingMessage, - type ServerResponse, -} from 'node:http'; -import os from 'node:os'; -import path from 'node:path'; -import test from 'node:test'; - -import { type AppConfig } from '../config.js'; -import type { AppContext } from '../context.js'; -import { generateSceneImage } from './sceneImageService.js'; - -const PNG_BUFFER = Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7+7aQAAAAASUVORK5CYII=', - 'base64', -); - -function createTestConfig( - projectRoot: string, - dashScopeBaseUrl: string, -): AppConfig { - return { - projectRoot, - publicDir: path.join(projectRoot, 'public'), - dashScope: { - baseUrl: dashScopeBaseUrl, - apiKey: 'test-dashscope-key', - imageModel: 'wan2.2-t2i-flash', - requestTimeoutMs: 5_000, - }, - } as AppConfig; -} - -function readRequestBody(req: IncomingMessage) { - return new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - req.on('data', (chunk) => { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - }); - req.on('end', () => resolve(Buffer.concat(chunks))); - req.on('error', reject); - }); -} - -function sendJson(res: ServerResponse, payload: unknown) { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json; charset=utf-8'); - res.end(JSON.stringify(payload)); -} - -async function withHttpServer( - buildHandler: ( - baseUrl: string, - ) => (req: IncomingMessage, res: ServerResponse) => void | Promise, - run: (baseUrl: string) => Promise, -) { - let handler: ( - req: IncomingMessage, - res: ServerResponse, - ) => void | Promise = () => undefined; - const server = createServer((req, res) => { - Promise.resolve(handler(req, res)).catch((error) => { - res.statusCode = 500; - res.end(error instanceof Error ? error.stack : String(error)); - }); - }); - - await new Promise((resolve) => { - server.listen(0, '127.0.0.1', () => resolve()); - }); - - const address = server.address(); - if (!address || typeof address === 'string') { - throw new Error('failed to resolve test server address'); - } - - const baseUrl = `http://127.0.0.1:${address.port}`; - handler = buildHandler(baseUrl); - - try { - return await run(baseUrl); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => { - if (error) { - reject(error); - return; - } - resolve(); - }); - }); - } -} - -test('generateSceneImage uses wan2.2-t2i-flash text-to-image payload and saves the generated scene', async () => { - const tempRoot = fs.mkdtempSync( - path.join(os.tmpdir(), 'genarrative-scene-image-'), - ); - - const capturedRequests: Array<{ - pathname: string; - bodyText?: string; - }> = []; - - await withHttpServer( - (baseUrl) => async (req, res) => { - const url = new URL(req.url || '/', baseUrl); - const bodyText = - req.method === 'POST' - ? (await readRequestBody(req)).toString('utf8') - : undefined; - capturedRequests.push({ - pathname: url.pathname, - bodyText, - }); - - if ( - req.method === 'POST' && - url.pathname === '/api/v1/services/aigc/text2image/image-synthesis' - ) { - sendJson(res, { - output: { - task_id: 'scene-task-1', - }, - }); - return; - } - - if ( - req.method === 'GET' && - url.pathname === '/api/v1/tasks/scene-task-1' - ) { - sendJson(res, { - output: { - task_status: 'SUCCEEDED', - results: [ - { - url: `${baseUrl}/downloads/scene.png`, - actual_prompt: '整理后的场景提示词', - }, - ], - }, - }); - return; - } - - if (req.method === 'GET' && url.pathname === '/downloads/scene.png') { - res.statusCode = 200; - res.setHeader('Content-Type', 'image/png'); - res.end(PNG_BUFFER); - return; - } - - res.statusCode = 404; - res.end('not found'); - }, - async (dashScopeBaseUrl) => { - const context = { - config: createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`), - } as AppContext; - - const result = await generateSceneImage(context, { - prompt: '海雾港口像素风场景', - negativePrompt: '模糊', - size: '1280*720', - model: 'wan2.7-image', - worldName: '潮雾群岛', - profileId: 'world-1', - landmarkName: '旧港灯塔', - landmarkId: 'landmark-1', - }); - - assert.equal(result.ok, true); - assert.match(result.imageSrc, /^\/generated-custom-world-scenes\//u); - assert.equal(result.actualPrompt, '整理后的场景提示词'); - - const createRequest = capturedRequests.find( - (entry) => - entry.pathname === '/api/v1/services/aigc/text2image/image-synthesis', - ); - assert.ok(createRequest?.bodyText); - - const createPayload = JSON.parse(createRequest.bodyText) as { - model: string; - input: { - prompt: string; - negative_prompt?: string; - }; - parameters: Record; - }; - - assert.equal(createPayload.model, 'wan2.2-t2i-flash'); - assert.equal(createPayload.input.prompt, '海雾港口像素风场景'); - assert.equal(createPayload.input.negative_prompt, '模糊'); - assert.equal(createPayload.parameters.size, '1280*720'); - - const savedImagePath = path.join( - tempRoot, - 'public', - result.imageSrc.slice(1), - ); - assert.equal(fs.existsSync(savedImagePath), true); - }, - ); -}); - -test('generateSceneImage builds the scene prompt on the server when the client only submits world and landmark context', async () => { - const tempRoot = fs.mkdtempSync( - path.join(os.tmpdir(), 'genarrative-scene-image-'), - ); - - const capturedRequests: Array<{ - pathname: string; - bodyText?: string; - }> = []; - - await withHttpServer( - (baseUrl) => async (req, res) => { - const url = new URL(req.url || '/', baseUrl); - const bodyText = - req.method === 'POST' - ? (await readRequestBody(req)).toString('utf8') - : undefined; - capturedRequests.push({ - pathname: url.pathname, - bodyText, - }); - - if ( - req.method === 'POST' && - url.pathname === '/api/v1/services/aigc/text2image/image-synthesis' - ) { - sendJson(res, { - output: { - task_id: 'scene-task-2', - }, - }); - return; - } - - if ( - req.method === 'GET' && - url.pathname === '/api/v1/tasks/scene-task-2' - ) { - sendJson(res, { - output: { - task_status: 'SUCCEEDED', - results: [ - { - url: `${baseUrl}/downloads/scene.png`, - actual_prompt: '服务端整理后的像素风提示词', - }, - ], - }, - }); - return; - } - - if (req.method === 'GET' && url.pathname === '/downloads/scene.png') { - res.statusCode = 200; - res.setHeader('Content-Type', 'image/png'); - res.end(PNG_BUFFER); - return; - } - - res.statusCode = 404; - res.end('not found'); - }, - async (dashScopeBaseUrl) => { - const context = { - config: createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`), - } as AppContext; - - const result = await generateSceneImage(context, { - worldName: '', - profileId: '', - landmarkName: '', - landmarkId: '', - userPrompt: '想让灯塔更偏暴风夜', - profile: { - id: 'world-3', - name: '潮雾群岛', - subtitle: '迷雾海界', - summary: '岛链被旧航道和风暴一起缠住。', - tone: '潮湿、压迫、带着未知回声', - playerGoal: '先找到断线的引路火', - settingText: '玩家在海雾和旧航道之间寻找可以靠岸的线索。', - }, - landmark: { - id: 'landmark-3', - name: '旧港灯塔', - description: '灯塔外墙被海盐侵蚀,塔下平台还能勉强落脚。', - dangerLevel: 'high', - }, - }); - - assert.equal(result.ok, true); - - const createRequest = capturedRequests.find( - (entry) => - entry.pathname === '/api/v1/services/aigc/text2image/image-synthesis', - ); - assert.ok(createRequest?.bodyText); - - const createPayload = JSON.parse(createRequest.bodyText) as { - input: { - prompt: string; - negative_prompt?: string; - }; - }; - - assert.match(createPayload.input.prompt, /世界:潮雾群岛,迷雾海界。/u); - assert.match(createPayload.input.prompt, /场景名称:旧港灯塔。/u); - assert.match( - createPayload.input.prompt, - /本次想要生成的画面内容:想让灯塔更偏暴风夜。/u, - ); - assert.match(createPayload.input.prompt, /危险感强烈/u); - assert.equal( - createPayload.input.negative_prompt, - '文字,水印,logo,UI界面,对话框,边框,人物近景特写,多人合照,模糊,低清晰度,畸形建筑,现代车辆,监控摄像头', - ); - }, - ); -}); - -test('generateSceneImage uses qwen-image-2.0 edit flow when a reference image is provided', async () => { - const tempRoot = fs.mkdtempSync( - path.join(os.tmpdir(), 'genarrative-scene-image-'), - ); - const publicDir = path.join(tempRoot, 'public'); - fs.mkdirSync(path.join(publicDir, 'scene_bg'), { recursive: true }); - fs.writeFileSync( - path.join(publicDir, 'scene_bg', 'reference-layout.png'), - PNG_BUFFER, - ); - - const capturedRequests: Array<{ - pathname: string; - bodyText?: string; - }> = []; - - await withHttpServer( - (baseUrl) => async (req, res) => { - const url = new URL(req.url || '/', baseUrl); - const bodyText = - req.method === 'POST' - ? (await readRequestBody(req)).toString('utf8') - : undefined; - capturedRequests.push({ - pathname: url.pathname, - bodyText, - }); - - if ( - req.method === 'POST' && - url.pathname === - '/api/v1/services/aigc/multimodal-generation/generation' - ) { - sendJson(res, { - output: { - choices: [ - { - message: { - content: [ - { - image: `${baseUrl}/downloads/reference-scene.png`, - }, - ], - }, - }, - ], - }, - }); - return; - } - - if ( - req.method === 'GET' && - url.pathname === '/downloads/reference-scene.png' - ) { - res.statusCode = 200; - res.setHeader('Content-Type', 'image/png'); - res.end(PNG_BUFFER); - return; - } - - res.statusCode = 404; - res.end('not found'); - }, - async (dashScopeBaseUrl) => { - const context = { - config: createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`), - } as AppContext; - - const result = await generateSceneImage(context, { - prompt: '废墟月台像素风场景', - negativePrompt: '模糊', - size: '1280*720', - worldName: '碎轨边境', - profileId: 'world-2', - landmarkName: '裂轨月台', - landmarkId: 'landmark-2', - referenceImageSrc: '/scene_bg/reference-layout.png', - }); - - assert.equal(result.ok, true); - assert.equal(result.model, 'qwen-image-2.0'); - assert.match(result.taskId, /^scene-edit-/u); - assert.equal( - capturedRequests.some( - (entry) => entry.pathname === '/api/v1/tasks/scene-task-1', - ), - false, - ); - - const createRequest = capturedRequests.find( - (entry) => - entry.pathname === - '/api/v1/services/aigc/multimodal-generation/generation', - ); - assert.ok(createRequest?.bodyText); - - const createPayload = JSON.parse(createRequest.bodyText) as { - model: string; - input: { - messages: Array<{ - content: Array<{ text?: string; image?: string }>; - }>; - }; - }; - - const content = createPayload.input.messages[0]?.content ?? []; - assert.equal(createPayload.model, 'qwen-image-2.0'); - assert.match(content[0]?.image ?? '', /^data:image\/png;base64,/u); - assert.equal(content[1]?.text, '废墟月台像素风场景'); - }, - ); -}); diff --git a/server-node/src/services/sceneImageService.ts b/server-node/src/services/sceneImageService.ts deleted file mode 100644 index 3f04e4d6..00000000 --- a/server-node/src/services/sceneImageService.ts +++ /dev/null @@ -1,505 +0,0 @@ -import fs from 'node:fs'; -import { readFile } from 'node:fs/promises'; -import path from 'node:path'; - -import { z } from 'zod'; - -import { - buildCustomWorldSceneImagePrompt, - DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT, -} from '../prompts/customWorldPrompts.js'; -import type { AppContext } from '../context.js'; -import { badRequest } from '../errors.js'; -import { extractApiErrorMessage } from '../http.js'; - -const sceneImageProfileSchema = z.object({ - id: z.string().trim().optional().default(''), - name: z.string().trim().optional().default(''), - subtitle: z.string().trim().optional().default(''), - summary: z.string().trim().optional().default(''), - tone: z.string().trim().optional().default(''), - playerGoal: z.string().trim().optional().default(''), - settingText: z.string().trim().optional().default(''), -}); - -const sceneImageLandmarkSchema = z.object({ - id: z.string().trim().optional().default(''), - name: z.string().trim().optional().default(''), - description: z.string().trim().optional().default(''), - dangerLevel: z.string().trim().optional().default(''), -}); - -export const sceneImageSchema = z.object({ - prompt: z.string().trim().optional().default(''), - negativePrompt: z.string().trim().optional().default(''), - size: z.string().trim().optional().default('1280*720'), - model: z.string().trim().optional().default(''), - worldName: z.string().trim().optional().default(''), - profileId: z.string().trim().optional().default(''), - landmarkName: z.string().trim().optional().default(''), - landmarkId: z.string().trim().optional().default(''), - referenceImageSrc: z.string().trim().optional().default(''), - userPrompt: z.string().trim().optional().default(''), - profile: sceneImageProfileSchema.optional(), - landmark: sceneImageLandmarkSchema.optional(), -}); -const TEXT_TO_IMAGE_SCENE_MODEL = 'wan2.2-t2i-flash'; -const REFERENCE_IMAGE_SCENE_MODEL = 'qwen-image-2.0'; - -function parseImageDataUrl(source: string) { - const matched = /^data:(image\/[^;]+);base64,(.+)$/u.exec(source); - if (!matched) { - return null; - } - - return { - buffer: Buffer.from(matched[2], 'base64'), - mimeType: matched[1], - }; -} - -async function resolveReferenceImageAsDataUrl(rootDir: string, source: string) { - const trimmedSource = source.trim(); - if (!trimmedSource) { - return ''; - } - - const parsedDataUrl = parseImageDataUrl(trimmedSource); - if (parsedDataUrl) { - return trimmedSource; - } - - if (!trimmedSource.startsWith('/')) { - throw badRequest('参考图必须是 Data URL 或 public 目录下的 URL。'); - } - - const normalizedSource = path.posix - .normalize(trimmedSource) - .replace(/^\/+/u, ''); - const absolutePath = path.resolve( - rootDir, - 'public', - ...normalizedSource.split('/'), - ); - const publicRoot = path.resolve(rootDir, 'public'); - if (!absolutePath.startsWith(publicRoot)) { - throw badRequest('参考图路径越界。'); - } - - const buffer = await readFile(absolutePath); - const extension = path - .extname(absolutePath) - .replace(/^\./u, '') - .toLowerCase(); - const mimeType = (() => { - switch (extension) { - case 'jpg': - case 'jpeg': - return 'image/jpeg'; - case 'webp': - return 'image/webp'; - default: - return 'image/png'; - } - })(); - - return `data:${mimeType};base64,${buffer.toString('base64')}`; -} - -function collectStringsByKey( - value: unknown, - targetKey: string, - results: string[], -) { - if (typeof value === 'string') { - return; - } - - if (Array.isArray(value)) { - value.forEach((entry) => collectStringsByKey(entry, targetKey, results)); - return; - } - - if (!value || typeof value !== 'object') { - return; - } - - Object.entries(value).forEach(([key, nestedValue]) => { - if ( - key === targetKey && - typeof nestedValue === 'string' && - nestedValue.trim() - ) { - results.push(nestedValue.trim()); - return; - } - - collectStringsByKey(nestedValue, targetKey, results); - }); -} - -function findFirstStringByKey(value: unknown, targetKey: string) { - const results: string[] = []; - collectStringsByKey(value, targetKey, results); - return results[0] ?? ''; -} - -function extractTaskId(payload: Record) { - return findFirstStringByKey(payload, 'task_id'); -} - -function extractImageUrls(payload: Record) { - const urls: string[] = []; - collectStringsByKey(payload, 'image', urls); - collectStringsByKey(payload, 'url', urls); - return [...new Set(urls)]; -} - -async function createSceneImageTask(params: { - baseUrl: string; - apiKey: string; - payload: z.infer; -}) { - const { baseUrl, apiKey, payload } = params; - const response = await fetch( - `${baseUrl}/services/aigc/text2image/image-synthesis`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - 'X-DashScope-Async': 'enable', - }, - body: JSON.stringify({ - model: payload.model, - input: { - prompt: payload.prompt, - ...(payload.negativePrompt - ? { negative_prompt: payload.negativePrompt } - : {}), - }, - parameters: { - n: 1, - size: payload.size, - prompt_extend: true, - watermark: false, - }, - }), - }, - ); - const responseText = await response.text(); - - if (!response.ok) { - return { - ok: false as const, - errorMessage: extractApiErrorMessage( - responseText, - '创建场景图片生成任务失败', - ), - }; - } - - return { - ok: true as const, - payload: JSON.parse(responseText) as Record, - }; -} - -async function createSceneImageFromReference(params: { - baseUrl: string; - apiKey: string; - payload: z.infer; - referenceImage: string; -}) { - const { baseUrl, apiKey, payload, referenceImage } = params; - const response = await fetch( - `${baseUrl}/services/aigc/multimodal-generation/generation`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - model: payload.model, - input: { - messages: [ - { - role: 'user', - content: [{ image: referenceImage }, { text: payload.prompt }], - }, - ], - }, - parameters: { - n: 1, - size: payload.size, - prompt_extend: true, - watermark: false, - ...(payload.negativePrompt - ? { negative_prompt: payload.negativePrompt } - : {}), - }, - }), - }, - ); - const responseText = await response.text(); - - if (!response.ok) { - return { - ok: false as const, - errorMessage: extractApiErrorMessage( - responseText, - '创建参考图场景编辑任务失败', - ), - }; - } - - const responsePayload = JSON.parse(responseText) as Record; - const imageUrl = extractImageUrls(responsePayload)[0] ?? ''; - if (!imageUrl) { - return { - ok: false as const, - errorMessage: '参考图场景编辑未返回图片地址', - }; - } - - return { - ok: true as const, - imageUrl, - actualPrompt: findFirstStringByKey(responsePayload, 'actual_prompt').trim(), - taskId: `scene-edit-${Date.now()}`, - }; -} - -function ensurePayload( - payload: z.infer, - _defaultModel: string, -) { - const referenceImageSrc = - typeof payload.referenceImageSrc === 'string' - ? payload.referenceImageSrc.trim() - : ''; - const profile = payload.profile ?? sceneImageProfileSchema.parse({}); - const landmark = payload.landmark ?? sceneImageLandmarkSchema.parse({}); - const profileId = payload.profileId.trim() || profile.id; - const worldName = payload.worldName.trim() || profile.name; - const landmarkId = payload.landmarkId.trim() || landmark.id; - const landmarkName = payload.landmarkName.trim() || landmark.name; - - if (!landmarkName && !landmarkId) { - throw badRequest('landmarkName 或 landmarkId 至少要提供一个'); - } - const prompt = - payload.prompt.trim() || - buildCustomWorldSceneImagePrompt( - { - ...profile, - id: profileId, - name: worldName, - }, - { - ...landmark, - id: landmarkId, - name: landmarkName, - }, - payload.userPrompt, - { - hasReferenceImage: Boolean(referenceImageSrc), - }, - ); - if (!prompt) { - throw badRequest('prompt 不能为空'); - } - const negativePrompt = - payload.negativePrompt.trim() || - DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT; - - return { - ...payload, - prompt, - negativePrompt, - worldName, - profileId, - landmarkName, - landmarkId, - referenceImageSrc, - model: referenceImageSrc - ? REFERENCE_IMAGE_SCENE_MODEL - : TEXT_TO_IMAGE_SCENE_MODEL, - }; -} - -async function saveSceneImageAsset(params: { - context: AppContext; - payload: z.infer; - imageUrl: string; - taskId: string; - actualPrompt: string; -}) { - const { context, payload, imageUrl, taskId, actualPrompt } = params; - const imageResponse = await fetch(imageUrl); - if (!imageResponse.ok) { - throw badRequest('下载生成图片失败'); - } - - const imageBuffer = Buffer.from(await imageResponse.arrayBuffer()); - const contentType = imageResponse.headers.get('content-type') || ''; - const extension = contentType.includes('png') - ? 'png' - : contentType.includes('webp') - ? 'webp' - : 'jpg'; - const assetId = `custom-scene-${Date.now()}`; - const worldSegment = (payload.profileId || payload.worldName || 'world') - .replace(/[^\w\u4e00-\u9fa5-]+/gu, '-') - .slice(0, 48); - const landmarkSegment = ( - payload.landmarkId || - payload.landmarkName || - 'landmark' - ) - .replace(/[^\w\u4e00-\u9fa5-]+/gu, '-') - .slice(0, 48); - const relativeDir = path.join( - 'generated-custom-world-scenes', - worldSegment || 'world', - landmarkSegment || 'landmark', - assetId, - ); - const outputDir = path.join(context.config.publicDir, relativeDir); - fs.mkdirSync(outputDir, { recursive: true }); - const fileName = `scene.${extension}`; - fs.writeFileSync(path.join(outputDir, fileName), imageBuffer); - - const imageSrc = `/${relativeDir.replace(/\\/gu, '/')}/${fileName}`; - fs.writeFileSync( - path.join(outputDir, 'manifest.json'), - `${JSON.stringify( - { - assetId, - taskId, - model: payload.model, - size: payload.size, - prompt: payload.prompt, - negativePrompt: payload.negativePrompt, - actualPrompt, - imageSrc, - worldName: payload.worldName, - landmarkName: payload.landmarkName, - createdAt: new Date().toISOString(), - }, - null, - 2, - )}\n`, - ); - - return { - ok: true, - imageSrc, - assetId, - taskId, - model: payload.model, - size: payload.size, - prompt: payload.prompt, - actualPrompt, - }; -} - -export async function generateSceneImage( - context: AppContext, - input: z.infer, -) { - const payload = ensurePayload( - sceneImageSchema.parse(input), - context.config.dashScope.imageModel, - ); - const baseUrl = context.config.dashScope.baseUrl.replace(/\/+$/u, ''); - const referenceImage = payload.referenceImageSrc.trim() - ? await resolveReferenceImageAsDataUrl( - context.config.projectRoot, - payload.referenceImageSrc, - ) - : ''; - - if (referenceImage) { - const referenceResult = await createSceneImageFromReference({ - baseUrl, - apiKey: context.config.dashScope.apiKey, - payload, - referenceImage, - }); - - if (!referenceResult.ok) { - throw badRequest(referenceResult.errorMessage); - } - - return saveSceneImageAsset({ - context, - payload, - imageUrl: referenceResult.imageUrl, - taskId: referenceResult.taskId, - actualPrompt: referenceResult.actualPrompt, - }); - } - - const createTaskResult = await createSceneImageTask({ - baseUrl, - apiKey: context.config.dashScope.apiKey, - payload, - }); - - if (!createTaskResult.ok) { - throw badRequest(createTaskResult.errorMessage); - } - - const createPayload = createTaskResult.payload; - const taskId = extractTaskId(createPayload); - if (!taskId) { - throw badRequest('场景图片生成任务未返回 task_id'); - } - - const deadline = Date.now() + context.config.dashScope.requestTimeoutMs; - let imageUrl = ''; - let actualPrompt = ''; - - while (Date.now() < deadline) { - const pollResponse = await fetch(`${baseUrl}/tasks/${taskId}`, { - headers: { - Authorization: `Bearer ${context.config.dashScope.apiKey}`, - }, - }); - const pollText = await pollResponse.text(); - if (!pollResponse.ok) { - throw badRequest( - extractApiErrorMessage(pollText, '查询场景图片任务失败'), - ); - } - - const pollPayload = JSON.parse(pollText) as Record; - const status = findFirstStringByKey(pollPayload, 'task_status').trim(); - if (status === 'SUCCEEDED') { - imageUrl = extractImageUrls(pollPayload)[0] ?? ''; - actualPrompt = findFirstStringByKey(pollPayload, 'actual_prompt').trim(); - break; - } - if (status === 'FAILED' || status === 'UNKNOWN') { - throw badRequest( - extractApiErrorMessage(pollText, '场景图片生成任务失败'), - ); - } - - await new Promise((resolve) => setTimeout(resolve, 2000)); - } - - if (!imageUrl) { - throw badRequest('场景图片生成超时或未返回图片地址'); - } - - return saveSceneImageAsset({ - context, - payload, - imageUrl, - taskId, - actualPrompt, - }); -} diff --git a/server-node/src/services/smsVerificationService.test.ts b/server-node/src/services/smsVerificationService.test.ts deleted file mode 100644 index 84b0d39e..00000000 --- a/server-node/src/services/smsVerificationService.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import pino from 'pino'; - -import type { AppConfig } from '../config.js'; -import { createSmsVerificationService } from './smsVerificationService.js'; - -function createAliyunSmsConfig(): AppConfig { - return { - smsAuth: { - enabled: true, - provider: 'aliyun', - endpoint: 'dypnsapi.aliyuncs.com', - accessKeyId: 'test-access-key-id', - accessKeySecret: 'test-access-key-secret', - signName: '测试签名', - templateCode: 'SMS_100001', - templateParamKey: 'code', - countryCode: '86', - schemeName: '', - codeLength: 6, - codeType: 1, - validTimeSeconds: 300, - intervalSeconds: 60, - duplicatePolicy: 1, - caseAuthPolicy: 1, - returnVerifyCode: false, - mockVerifyCode: '123456', - maxSendPerPhonePerDay: 20, - maxSendPerIpPerHour: 30, - maxVerifyFailuresPerPhonePerHour: 12, - maxVerifyFailuresPerIpPerHour: 24, - captchaTtlSeconds: 180, - captchaTriggerVerifyFailuresPerPhone: 3, - captchaTriggerVerifyFailuresPerIp: 5, - blockPhoneFailureThreshold: 6, - blockIpFailureThreshold: 10, - blockPhoneDurationMinutes: 30, - blockIpDurationMinutes: 30, - }, - } as AppConfig; -} - -test('createSmsVerificationService initializes aliyun sdk client under tsx esm runtime', () => { - const service = createSmsVerificationService( - createAliyunSmsConfig(), - pino({ enabled: false }), - ); - - assert.equal(typeof service.sendLoginCode, 'function'); - assert.equal(typeof service.verifyLoginCode, 'function'); -}); - -test('mock sms service reports delivered tracking metadata', async () => { - const config = createAliyunSmsConfig(); - config.smsAuth.provider = 'mock'; - config.smsAuth.accessKeyId = ''; - config.smsAuth.accessKeySecret = ''; - - const service = createSmsVerificationService( - config, - pino({ enabled: false }), - ); - - const result = await service.sendLoginCode({ - e164: '+8613800138000', - nationalNumber: '13800138000', - maskedNationalNumber: '138****8000', - }); - - assert.equal(result.provider, 'mock'); - assert.equal(result.deliveryStatus, 'delivered'); - assert.equal(result.providerRequestId, 'mock-request-id'); - assert.equal(result.providerOutId, 'mock-out-id'); -}); diff --git a/server-node/src/services/smsVerificationService.ts b/server-node/src/services/smsVerificationService.ts deleted file mode 100644 index 8bca9393..00000000 --- a/server-node/src/services/smsVerificationService.ts +++ /dev/null @@ -1,289 +0,0 @@ -import crypto from 'node:crypto'; - -import DypnsApiModule, { - CheckSmsVerifyCodeRequest, - SendSmsVerifyCodeRequest, -} from '@alicloud/dypnsapi20170525'; -import OpenApiClient from '@alicloud/openapi-client'; -import type { Logger } from 'pino'; - -import type { NormalizedPhoneNumber } from '../auth/phoneNumber.js'; -import type { AppConfig } from '../config.js'; -import { - badRequest, - unauthorized, - upstreamError, -} from '../errors.js'; - -export type SendLoginCodeResult = { - cooldownSeconds: number; - expiresInSeconds: number; - providerRequestId: string | null; - providerBizId: string | null; - providerOutId: string | null; - provider: 'aliyun' | 'mock'; - deliveryStatus: 'pending' | 'delivered'; -}; - -export type SmsVerificationService = { - sendLoginCode(phoneNumber: NormalizedPhoneNumber): Promise; - verifyLoginCode( - phoneNumber: NormalizedPhoneNumber, - verifyCode: string, - ): Promise; -}; - -type DypnsClientInstance = InstanceType; -type DypnsClientConstructor = new ( - config: OpenApiClient.Config, -) => DypnsClientInstance; - -function resolveDypnsClientConstructor(): DypnsClientConstructor { - const directExport = DypnsApiModule as unknown; - if (typeof directExport === 'function') { - return directExport as DypnsClientConstructor; - } - - // 兼容 CommonJS SDK 在 ESM/tsx 运行时被包一层 default 的情况。 - const nestedDefault = ( - DypnsApiModule as unknown as { default?: unknown } - ).default; - if (typeof nestedDefault === 'function') { - return nestedDefault as DypnsClientConstructor; - } - - throw new Error('阿里云短信 SDK Client 导出异常'); -} - -const DypnsClient = resolveDypnsClientConstructor(); - -function isAliyunConfigMissing(config: AppConfig['smsAuth']) { - return !config.accessKeyId || !config.accessKeySecret; -} - -function assertAliyunRequiredConfig(config: AppConfig['smsAuth']) { - if (!config.signName.trim()) { - throw new Error('ALIYUN_SMS_SIGN_NAME 未配置'); - } - if (!config.templateCode.trim()) { - throw new Error('ALIYUN_SMS_TEMPLATE_CODE 未配置'); - } - if (!config.templateParamKey.trim()) { - throw new Error('ALIYUN_SMS_TEMPLATE_PARAM_KEY 未配置'); - } -} - -function buildProviderErrorMessage(prefix: string, message: string) { - const normalizedMessage = message.trim(); - return normalizedMessage ? `${prefix}:${normalizedMessage}` : prefix; -} - -class AliyunSmsVerificationService implements SmsVerificationService { - private readonly client: DypnsClient; - - constructor( - private readonly config: AppConfig['smsAuth'], - private readonly logger: Logger, - ) { - if (isAliyunConfigMissing(config)) { - throw new Error('ALIYUN_SMS_ACCESS_KEY_ID 或 ALIYUN_SMS_ACCESS_KEY_SECRET 未配置'); - } - assertAliyunRequiredConfig(config); - - const clientConfig = new OpenApiClient.Config({ - accessKeyId: config.accessKeyId, - accessKeySecret: config.accessKeySecret, - endpoint: config.endpoint, - protocol: 'HTTPS', - }); - this.client = new DypnsClient(clientConfig); - } - - async sendLoginCode(phoneNumber: NormalizedPhoneNumber) { - const templateParam = JSON.stringify({ - [this.config.templateParamKey]: '##code##', - "min": this.config.validTimeSeconds, - }); - const request = new SendSmsVerifyCodeRequest({ - phoneNumber: phoneNumber.nationalNumber, - countryCode: this.config.countryCode, - signName: this.config.signName, - templateCode: this.config.templateCode, - templateParam, - codeLength: this.config.codeLength, - codeType: this.config.codeType, - validTime: this.config.validTimeSeconds, - interval: this.config.intervalSeconds, - duplicatePolicy: this.config.duplicatePolicy, - returnVerifyCode: this.config.returnVerifyCode, - schemeName: this.config.schemeName || undefined, - outId: `login_${crypto.randomBytes(12).toString('hex')}`, - }); - - try { - const response = await this.client.sendSmsVerifyCode(request); - const body = response.body; - if (!body?.success || body.code !== 'OK') { - throw this.resolveAliyunRequestError( - '短信验证码发送失败', - body?.message ?? '', - body?.code ?? '', - ); - } - - return { - cooldownSeconds: this.config.intervalSeconds, - expiresInSeconds: this.config.validTimeSeconds, - providerRequestId: body.requestId ?? body.model?.requestId ?? null, - providerBizId: body.model?.bizId ?? null, - providerOutId: body.model?.outId ?? null, - provider: 'aliyun', - deliveryStatus: 'pending', - } satisfies SendLoginCodeResult; - } catch (error) { - if (error instanceof Error && error.name === 'HttpError') { - throw error; - } - - this.logger.error( - { - err: error, - phone_suffix: phoneNumber.nationalNumber.slice(-4), - }, - 'aliyun sms send failed', - ); - throw upstreamError( - buildProviderErrorMessage( - '短信验证码发送失败', - error instanceof Error ? error.message : 'unknown error', - ), - ); - } - } - - async verifyLoginCode( - phoneNumber: NormalizedPhoneNumber, - verifyCode: string, - ) { - const request = new CheckSmsVerifyCodeRequest({ - phoneNumber: phoneNumber.nationalNumber, - countryCode: this.config.countryCode, - verifyCode, - caseAuthPolicy: this.config.caseAuthPolicy, - schemeName: this.config.schemeName || undefined, - }); - - try { - const response = await this.client.checkSmsVerifyCode(request); - const body = response.body; - if (!body?.success || body.code !== 'OK') { - throw this.resolveAliyunRequestError( - '验证码校验失败', - body?.message ?? '', - body?.code ?? '', - ); - } - - if (body.model?.verifyResult !== 'PASS') { - throw unauthorized('验证码错误或已失效'); - } - } catch (error) { - if (error instanceof Error && error.name === 'HttpError') { - throw error; - } - - this.logger.error( - { - err: error, - phone_suffix: phoneNumber.nationalNumber.slice(-4), - }, - 'aliyun sms verify failed', - ); - throw upstreamError( - buildProviderErrorMessage( - '验证码校验失败', - error instanceof Error ? error.message : 'unknown error', - ), - ); - } - } - - private resolveAliyunRequestError( - fallbackMessage: string, - providerMessage: string, - providerCode: string, - ) { - const normalizedCode = providerCode.trim().toUpperCase(); - if ( - normalizedCode.includes('MOBILE') || - normalizedCode.includes('PHONE') || - normalizedCode.includes('TEMPLATE') || - normalizedCode.includes('SIGN') - ) { - return badRequest( - buildProviderErrorMessage(fallbackMessage, providerMessage), - { - providerCode, - }, - ); - } - - return upstreamError( - buildProviderErrorMessage(fallbackMessage, providerMessage), - { - providerCode, - }, - ); - } -} - -class MockSmsVerificationService implements SmsVerificationService { - private readonly sentCodes = new Map(); - - constructor(private readonly config: AppConfig['smsAuth']) {} - - async sendLoginCode(phoneNumber: NormalizedPhoneNumber) { - this.sentCodes.set(phoneNumber.e164, this.config.mockVerifyCode); - return { - cooldownSeconds: this.config.intervalSeconds, - expiresInSeconds: this.config.validTimeSeconds, - providerRequestId: 'mock-request-id', - providerBizId: null, - providerOutId: 'mock-out-id', - provider: 'mock', - deliveryStatus: 'delivered', - } satisfies SendLoginCodeResult; - } - - async verifyLoginCode( - phoneNumber: NormalizedPhoneNumber, - verifyCode: string, - ) { - const expectedCode = this.sentCodes.get(phoneNumber.e164); - if (!expectedCode || expectedCode !== verifyCode) { - throw unauthorized('验证码错误或已失效'); - } - } -} - -export function createSmsVerificationService( - config: AppConfig, - logger: Logger, -): SmsVerificationService { - if (!config.smsAuth.enabled) { - return { - async sendLoginCode() { - throw badRequest('短信验证码登录未启用'); - }, - async verifyLoginCode() { - throw badRequest('短信验证码登录未启用'); - }, - }; - } - - if (config.smsAuth.provider === 'mock') { - return new MockSmsVerificationService(config.smsAuth); - } - - return new AliyunSmsVerificationService(config.smsAuth, logger); -} diff --git a/server-node/src/services/storyService.ts b/server-node/src/services/storyService.ts deleted file mode 100644 index e2a66612..00000000 --- a/server-node/src/services/storyService.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { z } from 'zod'; - -import type { StoryRequestPayload } from '../../../packages/shared/src/contracts/rpgRuntimeChat.js'; -import { - generateInitialStoryFromOrchestrator, - generateNextStoryFromOrchestrator, -} from '../modules/ai/storyOrchestrator.js'; -import type { UpstreamLlmClient } from './llmClient.js'; - -const jsonObjectSchema = z.record(z.string(), z.unknown()); - -const storyRequestSchema = z.object({ - worldType: z.string().trim().min(1), - character: jsonObjectSchema, - monsters: z.array(jsonObjectSchema).default([]), - history: z.array(jsonObjectSchema).default([]), - choice: z.string().optional().default(''), - context: jsonObjectSchema, - requestOptions: z.object({ - availableOptions: z.array(jsonObjectSchema).optional().default([]), - optionCatalog: z.array(jsonObjectSchema).optional().default([]), - }).optional().default({ - availableOptions: [], - optionCatalog: [], - }), -}); - -export function parseStoryRequest(body: unknown) { - return storyRequestSchema.parse(body) as StoryRequestPayload; -} - -function toTypedStoryParams( - request: ReturnType, -) { - return { - worldType: request.worldType, - character: request.character, - monsters: request.monsters, - history: request.history, - choice: request.choice.trim(), - context: request.context, - requestOptions: request.requestOptions, - }; -} - -export async function generateHighQualityInitialStory( - llmClient: UpstreamLlmClient, - request: ReturnType, -) { - const params = toTypedStoryParams(request); - return generateInitialStoryFromOrchestrator( - llmClient, - params.worldType, - params.character, - params.monsters, - params.context, - params.requestOptions, - ); -} - -export async function generateHighQualityNextStory( - llmClient: UpstreamLlmClient, - request: ReturnType, -) { - const params = toTypedStoryParams(request); - return generateNextStoryFromOrchestrator( - llmClient, - params.worldType, - params.character, - params.monsters, - params.history, - params.choice, - params.context, - params.requestOptions, - ); -} diff --git a/server-node/src/services/wechatAuthService.ts b/server-node/src/services/wechatAuthService.ts deleted file mode 100644 index 8bc18db3..00000000 --- a/server-node/src/services/wechatAuthService.ts +++ /dev/null @@ -1,220 +0,0 @@ -import type { Logger } from 'pino'; - -import type { AppConfig } from '../config.js'; -import { badRequest, upstreamError } from '../errors.js'; - -export type WechatIdentityProfile = { - providerUid: string; - providerUnionId: string | null; - displayName: string | null; - avatarUrl: string | null; - metaJson: Record | null; -}; - -export type WechatAuthService = { - buildAuthorizationUrl(params: { - callbackUrl: string; - state: string; - userAgent?: string | null; - }): string; - resolveCallbackProfile(params: { - code?: string | null; - mockCode?: string | null; - }): Promise; -}; - -type WechatAuthorizationScene = 'desktop' | 'wechat_in_app'; - -const WECHAT_IN_APP_AUTHORIZE_ENDPOINT = - 'https://open.weixin.qq.com/connect/oauth2/authorize'; - -function isWechatBrowser(userAgent?: string | null) { - return /MicroMessenger/iu.test(userAgent ?? ''); -} - -function isMobileBrowser(userAgent?: string | null) { - return /Android|iPhone|iPad|iPod|Mobile/iu.test(userAgent ?? ''); -} - -function resolveWechatAuthorizationScene( - userAgent?: string | null, -): WechatAuthorizationScene { - if (isWechatBrowser(userAgent)) { - return 'wechat_in_app'; - } - - if (isMobileBrowser(userAgent)) { - throw badRequest('当前浏览器请使用手机号登录,或在微信内打开后再使用微信登录'); - } - - return 'desktop'; -} - -class MockWechatAuthService implements WechatAuthService { - constructor(private readonly config: AppConfig['wechatAuth']) {} - - buildAuthorizationUrl(params: { - callbackUrl: string; - state: string; - userAgent?: string | null; - }) { - const callbackUrl = new URL(params.callbackUrl); - callbackUrl.searchParams.set('mock_code', this.config.mockUserId); - callbackUrl.searchParams.set('state', params.state); - return callbackUrl.toString(); - } - - async resolveCallbackProfile(params: { - mockCode?: string | null; - }) { - const mockCode = params.mockCode?.trim() || this.config.mockUserId; - return { - providerUid: mockCode, - providerUnionId: this.config.mockUnionId || null, - displayName: this.config.mockDisplayName || '微信旅人', - avatarUrl: this.config.mockAvatarUrl || null, - metaJson: { - mockCode, - }, - } satisfies WechatIdentityProfile; - } -} - -class RealWechatAuthService implements WechatAuthService { - constructor( - private readonly config: AppConfig['wechatAuth'], - private readonly logger: Logger, - ) { - if (!config.appId || !config.appSecret) { - throw new Error('WECHAT_APP_ID 或 WECHAT_APP_SECRET 未配置'); - } - } - - buildAuthorizationUrl(params: { - callbackUrl: string; - state: string; - userAgent?: string | null; - }) { - const scene = resolveWechatAuthorizationScene(params.userAgent); - const url = new URL( - scene === 'wechat_in_app' - ? WECHAT_IN_APP_AUTHORIZE_ENDPOINT - : this.config.authorizeEndpoint, - ); - url.searchParams.set('appid', this.config.appId); - url.searchParams.set('redirect_uri', params.callbackUrl); - url.searchParams.set('response_type', 'code'); - url.searchParams.set( - 'scope', - scene === 'wechat_in_app' ? 'snsapi_userinfo' : 'snsapi_login', - ); - url.searchParams.set('state', params.state); - return `${url.toString()}#wechat_redirect`; - } - - async resolveCallbackProfile(params: { - code?: string | null; - }) { - const code = params.code?.trim(); - if (!code) { - throw badRequest('缺少微信授权 code'); - } - - try { - const accessTokenUrl = new URL(this.config.accessTokenEndpoint); - accessTokenUrl.searchParams.set('appid', this.config.appId); - accessTokenUrl.searchParams.set('secret', this.config.appSecret); - accessTokenUrl.searchParams.set('code', code); - accessTokenUrl.searchParams.set('grant_type', 'authorization_code'); - - const accessTokenResponse = await fetch(accessTokenUrl.toString()); - const accessTokenPayload = - (await accessTokenResponse.json()) as Record; - - if (!accessTokenResponse.ok || typeof accessTokenPayload.openid !== 'string') { - throw new Error( - typeof accessTokenPayload.errmsg === 'string' - ? accessTokenPayload.errmsg - : 'failed to exchange code', - ); - } - - const accessToken = - typeof accessTokenPayload.access_token === 'string' - ? accessTokenPayload.access_token - : ''; - const openId = accessTokenPayload.openid; - const fallbackUnionId = - typeof accessTokenPayload.unionid === 'string' - ? accessTokenPayload.unionid - : null; - - if (!accessToken) { - throw new Error('missing access_token'); - } - - const userInfoUrl = new URL(this.config.userInfoEndpoint); - userInfoUrl.searchParams.set('access_token', accessToken); - userInfoUrl.searchParams.set('openid', openId); - userInfoUrl.searchParams.set('lang', 'zh_CN'); - - const userInfoResponse = await fetch(userInfoUrl.toString()); - const userInfoPayload = - (await userInfoResponse.json()) as Record; - - if (!userInfoResponse.ok || typeof userInfoPayload.openid !== 'string') { - throw new Error( - typeof userInfoPayload.errmsg === 'string' - ? userInfoPayload.errmsg - : 'failed to fetch user info', - ); - } - - return { - providerUid: userInfoPayload.openid, - providerUnionId: - typeof userInfoPayload.unionid === 'string' - ? userInfoPayload.unionid - : fallbackUnionId, - displayName: - typeof userInfoPayload.nickname === 'string' - ? userInfoPayload.nickname - : null, - avatarUrl: - typeof userInfoPayload.headimgurl === 'string' - ? userInfoPayload.headimgurl - : null, - metaJson: userInfoPayload, - } satisfies WechatIdentityProfile; - } catch (error) { - this.logger.error({ err: error }, 'wechat auth callback failed'); - throw upstreamError( - error instanceof Error - ? `微信登录失败:${error.message}` - : '微信登录失败', - ); - } - } -} - -export function createWechatAuthService( - config: AppConfig, - logger: Logger, -): WechatAuthService { - if (!config.wechatAuth.enabled) { - return { - buildAuthorizationUrl() { - throw badRequest('微信登录暂未启用'); - }, - async resolveCallbackProfile() { - throw badRequest('微信登录暂未启用'); - }, - }; - } - - if (config.wechatAuth.provider === 'mock') { - return new MockWechatAuthService(config.wechatAuth); - } - - return new RealWechatAuthService(config.wechatAuth, logger); -} diff --git a/server-node/src/services/wechatAuthStateStore.ts b/server-node/src/services/wechatAuthStateStore.ts deleted file mode 100644 index 7312f62c..00000000 --- a/server-node/src/services/wechatAuthStateStore.ts +++ /dev/null @@ -1,32 +0,0 @@ -import crypto from 'node:crypto'; - -export type WechatAuthStateRecord = { - state: string; - redirectPath: string; - createdAt: string; -}; - -export class WechatAuthStateStore { - private readonly states = new Map(); - - create(redirectPath: string) { - const state = crypto.randomBytes(18).toString('hex'); - const record: WechatAuthStateRecord = { - state, - redirectPath, - createdAt: new Date().toISOString(), - }; - this.states.set(state, record); - return record; - } - - consume(state: string) { - const record = this.states.get(state) ?? null; - if (!record) { - return null; - } - - this.states.delete(state); - return record; - } -} diff --git a/server-node/src/testFixtures/runtimeCharacter.ts b/server-node/src/testFixtures/runtimeCharacter.ts deleted file mode 100644 index d55d484b..00000000 --- a/server-node/src/testFixtures/runtimeCharacter.ts +++ /dev/null @@ -1,34 +0,0 @@ -export function createTestPlayerCharacter() { - return { - id: 'test-hero', - name: '测试主角', - title: '断桥行者', - description: '用于后端运行时测试的稳定角色夹具。', - backstory: '在断桥旧哨附近长期行动,熟悉近身交锋和临场判断。', - avatar: '/test-hero.png', - portrait: '/test-hero-portrait.png', - assetFolder: 'test-hero', - assetVariant: 'default', - gender: 'female', - attributes: { - strength: 12, - agility: 11, - intelligence: 8, - spirit: 10, - }, - personality: '沉稳果断', - skills: [ - { - id: 'slash', - name: '试锋斩', - animation: 'attack', - damage: 18, - manaCost: 4, - cooldownTurns: 1, - range: 1, - style: 'steady', - }, - ], - adventureOpenings: {}, - } as TCharacter; -} diff --git a/server-node/src/testHttp.ts b/server-node/src/testHttp.ts deleted file mode 100644 index 2bf60317..00000000 --- a/server-node/src/testHttp.ts +++ /dev/null @@ -1,92 +0,0 @@ -import http from 'node:http'; -import https from 'node:https'; - -type RequestHeaders = Record; - -export type TestRequestInit = { - method?: string; - headers?: RequestHeaders; - body?: string; -}; - -type TestHeaders = { - get(name: string): string | null; -}; - -export type TestResponse = { - status: number; - headers: TestHeaders; - text(): Promise; - json(): Promise; -}; - -function createHeadersMap(headers: http.IncomingHttpHeaders): TestHeaders { - const values = new Map(); - - for (const [key, value] of Object.entries(headers)) { - if (typeof value === 'string') { - values.set(key.toLowerCase(), value); - continue; - } - - if (Array.isArray(value)) { - values.set(key.toLowerCase(), value.join(', ')); - } - } - - return { - get(name: string) { - return values.get(name.toLowerCase()) ?? null; - }, - }; -} - -export async function httpRequest( - urlText: string, - init: TestRequestInit = {}, -): Promise { - const url = new URL(urlText); - const transport = url.protocol === 'https:' ? https : http; - - return new Promise((resolve, reject) => { - const request = transport.request( - { - protocol: url.protocol, - hostname: url.hostname, - port: url.port, - path: `${url.pathname}${url.search}`, - method: init.method ?? 'GET', - headers: init.headers, - }, - (response) => { - const chunks: Buffer[] = []; - - response.on('data', (chunk) => { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - }); - response.on('end', () => { - const body = Buffer.concat(chunks).toString('utf8'); - - resolve({ - status: response.statusCode ?? 0, - headers: createHeadersMap(response.headers), - async text() { - return body; - }, - async json() { - return JSON.parse(body) as T; - }, - }); - }); - }, - ); - - request.on('error', reject); - - if (typeof init.body === 'string' && init.body.length > 0) { - request.write(init.body); - } - - request.end(); - }); -} diff --git a/server-node/src/types/express.d.ts b/server-node/src/types/express.d.ts deleted file mode 100644 index e3f1b32f..00000000 --- a/server-node/src/types/express.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -declare global { - namespace Express { - interface Request { - requestId: string; - requestStartedAt: number; - userId?: string; - auth?: { - userId: string; - tokenVersion: number; - }; - } - } -} - -export {}; diff --git a/server-node/test.mjs b/server-node/test.mjs deleted file mode 100644 index 5fd18b38..00000000 --- a/server-node/test.mjs +++ /dev/null @@ -1,54 +0,0 @@ -import { spawnSync } from 'node:child_process'; -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const scriptPath = fileURLToPath(import.meta.url); -const serverRoot = path.dirname(scriptPath); -const repoRoot = path.resolve(serverRoot, '..'); -const bundledNodePath = path.join( - repoRoot, - '.tools', - 'node-v22.22.2-win-x64', - process.platform === 'win32' ? 'node.exe' : 'bin/node', -); -const runtimeNodePath = fs.existsSync(bundledNodePath) - ? bundledNodePath - : process.execPath; - -function collectTestFiles(dirPath) { - const entries = fs.readdirSync(dirPath, { withFileTypes: true }); - const testFiles = []; - - for (const entry of entries) { - const fullPath = path.join(dirPath, entry.name); - if (entry.isDirectory()) { - testFiles.push(...collectTestFiles(fullPath)); - continue; - } - - if (entry.isFile() && entry.name.endsWith('.test.ts')) { - testFiles.push(path.relative(serverRoot, fullPath)); - } - } - - return testFiles.sort(); -} - -const testFiles = collectTestFiles(path.join(serverRoot, 'src')); - -if (testFiles.length === 0) { - console.error('No test files found under server-node/src'); - process.exit(1); -} - -const result = spawnSync( - runtimeNodePath, - ['--test', '--test-concurrency=1', '--import', 'tsx', ...testFiles], - { - cwd: serverRoot, - stdio: 'inherit', - }, -); - -process.exit(result.status ?? 1); diff --git a/server-node/tsconfig.json b/server-node/tsconfig.json deleted file mode 100644 index 2472ab58..00000000 --- a/server-node/tsconfig.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "Bundler", - "outDir": "dist", - "strict": true, - "skipLibCheck": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "allowSyntheticDefaultImports": true, - "lib": [ - "ES2022", - "DOM", - "DOM.Iterable" - ] - }, - "include": [ - "src/**/*.ts", - "src/**/*.d.ts" - ] -} diff --git a/server-rs/README.md b/server-rs/README.md index 2876fd19..2da5588f 100644 --- a/server-rs/README.md +++ b/server-rs/README.md @@ -10,11 +10,11 @@ 2. `SpacetimeDB` 状态机模块 3. `阿里云 OSS` 资产接入与应用层编排 -该目录固定放在仓库根目录,与 `server-node/`、`src/`、`docs/` 同级。 +该目录固定放在仓库根目录,与 `src/`、`docs/` 同级。旧 `server-node/` 已进入分批删除流程,后续只可通过历史提交或迁移文档追溯。 ## 2. 当前阶段说明 -当前目录已经完成以下三十九项初始化: +当前目录已经完成以下三十八项初始化: 1. 为新后端预留正式目录并把路径固定到仓库结构中。 2. 创建虚拟 workspace `Cargo.toml`,后续 crate 会逐项挂入。 @@ -53,8 +53,7 @@ 35. 创建 `scripts/spacetime-dev.sh`,固定 Unix-like 本地 SpacetimeDB 启动入口。 36. 创建 `scripts/oss-smoke.ps1`,固定 Windows 本地阿里云 OSS 真实联调入口。 37. 创建 `scripts/m7-preflight.ps1`,固定 M7 切流前 Rust 后端预检入口。 -38. 创建根目录 `scripts/m7-api-compare.ts`,固定旧 Node 与新 Rust 的无状态 API contract 对比入口。 -39. 固定 Vite dev proxy 的 `GENARRATIVE_BACKEND_STACK` / `GENARRATIVE_RUNTIME_SERVER_TARGET` 切流和回退开关。 +38. 固定 Vite dev proxy 的 Rust `api-server` 默认目标与 `GENARRATIVE_RUNTIME_SERVER_TARGET` 覆盖开关。 后续任务会继续在本目录内按顺序补齐: @@ -76,7 +75,7 @@ 本目录后续落地时必须继续遵守 `M0` 已冻结的边界: -1. 迁移期保留 `server-node/`,不提前删除。 +1. 旧 `server-node/` 不再作为当前工程目录保留;若需查证旧实现,只允许通过历史提交、迁移文档或已迁移到 `server-rs/` 的实现对照。 2. 前端在 `M0 ~ M6` 期间只访问 Axum,不直连 SpacetimeDB。 3. 外部副作用统一收口在 Axum / crate 内应用层 / infra。 4. `crates/api-server` 只组合与暴露协议,不直接吞并业务模块实现。 diff --git a/server-rs/crates/api-server/src/custom_world_foundation_draft.rs b/server-rs/crates/api-server/src/custom_world_foundation_draft.rs index 41a2a16e..3c5b91d1 100644 --- a/server-rs/crates/api-server/src/custom_world_foundation_draft.rs +++ b/server-rs/crates/api-server/src/custom_world_foundation_draft.rs @@ -726,7 +726,9 @@ fn build_foundation_draft_profile_from_framework( )]) }), ); - let camp = framework.get("camp").cloned().unwrap_or_else(|| json!({ "name": "开局归处", "description": "玩家进入世界后的第一处落脚点。" })); + let camp = framework.get("camp").cloned().unwrap_or_else( + || json!({ "name": "开局归处", "description": "玩家进入世界后的第一处落脚点。" }), + ); object.insert("camp".to_string(), camp.clone()); object.insert( "playableNpcs".to_string(), @@ -927,7 +929,10 @@ fn normalize_framework_shape(framework: &mut JsonValue, setting_text: &str) { object.insert("coreConflicts".to_string(), JsonValue::Array(Vec::new())); } if !object.get("camp").is_some_and(JsonValue::is_object) { - object.insert("camp".to_string(), json!({ "name": "开局归处", "description": "玩家进入世界后的第一处落脚点。" })); + object.insert( + "camp".to_string(), + json!({ "name": "开局归处", "description": "玩家进入世界后的第一处落脚点。" }), + ); } if let Some(camp) = object.get_mut("camp").and_then(JsonValue::as_object_mut) { let camp_name = camp diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index f258fbbc..b8e3b5b4 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -44,6 +44,7 @@ mod request_context; mod response_headers; mod runtime_browse_history; mod runtime_chat; +mod runtime_chat_prompt; mod runtime_inventory; mod runtime_profile; mod runtime_save; diff --git a/server-rs/crates/api-server/src/prompt/scene_background.rs b/server-rs/crates/api-server/src/prompt/scene_background.rs index a56024d5..c611ea3e 100644 --- a/server-rs/crates/api-server/src/prompt/scene_background.rs +++ b/server-rs/crates/api-server/src/prompt/scene_background.rs @@ -164,4 +164,3 @@ fn conditional_prompt_line(prefix: &str, value: &str) -> String { format!("{prefix}:{value}。") } } - diff --git a/server-rs/crates/api-server/src/runtime_chat.rs b/server-rs/crates/api-server/src/runtime_chat.rs index 6f5ef330..591f2b08 100644 --- a/server-rs/crates/api-server/src/runtime_chat.rs +++ b/server-rs/crates/api-server/src/runtime_chat.rs @@ -1,26 +1,57 @@ use axum::{ Json, - extract::Extension, + extract::{Extension, State}, http::{StatusCode, header}, response::{IntoResponse, Response}, }; +use platform_llm::{LlmMessage, LlmTextRequest}; use serde::Deserialize; use serde_json::{Value, json}; -use crate::{http_error::AppError, request_context::RequestContext}; +use crate::{ + http_error::AppError, + request_context::RequestContext, + runtime_chat_prompt::{ + NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT, NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT, + NpcChatTurnPromptInput, build_npc_chat_turn_reply_prompt, + build_npc_chat_turn_suggestion_prompt, + }, + state::AppState, +}; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NpcChatTurnRequest { + #[serde(default)] + world_type: String, + #[serde(default)] + character: Option, + #[serde(default)] + player: Option, encounter: Value, + #[serde(default)] + monsters: Vec, + #[serde(default)] + history: Vec, + #[serde(default)] + context: Value, + #[serde(default)] + conversation_history: Vec, + #[serde(default)] + dialogue: Vec, + #[serde(default)] + combat_context: Option, player_message: String, #[serde(default)] + npc_state: Value, + #[serde(default)] npc_initiates_conversation: bool, #[serde(default)] chat_directive: Option, } pub async fn stream_runtime_npc_chat_turn( + State(state): State, Extension(request_context): Extension, Json(payload): Json, ) -> Result { @@ -38,27 +69,128 @@ pub async fn stream_runtime_npc_chat_turn( )); } - let npc_reply = build_deterministic_npc_reply( - npc_name.as_str(), - player_message, - payload.npc_initiates_conversation, - ); - let suggestions = build_deterministic_chat_suggestions(npc_name.as_str(), player_message); + let llm_result = + generate_llm_npc_chat_turn(&state, &request_context, &payload, &npc_name).await; + let (mut body, npc_reply, suggestions) = match llm_result { + Some(result) => result, + None => { + let npc_reply = build_deterministic_npc_reply( + npc_name.as_str(), + player_message, + payload.npc_initiates_conversation, + ); + let suggestions = if should_force_chat_exit(payload.chat_directive.as_ref()) { + Vec::new() + } else { + build_deterministic_chat_suggestions(npc_name.as_str(), player_message) + }; + let mut body = String::new(); + append_sse_event( + &request_context, + &mut body, + "reply_delta", + &json!({ "text": npc_reply }), + )?; + (body, npc_reply, suggestions) + } + }; + + let chatted_count = read_number_field(&payload.npc_state, "chattedCount").unwrap_or(0.0); + let affinity_delta = + compute_npc_chat_affinity_delta(player_message, npc_reply.as_str(), chatted_count); let complete_payload = json!({ "npcReply": npc_reply, - "affinityDelta": 0, - "affinityText": "关系暂未变化", + "affinityDelta": affinity_delta, + "affinityText": describe_affinity_shift(affinity_delta), "suggestions": suggestions, "pendingQuestOffer": null, "chatDirective": build_completion_directive(payload.chat_directive.as_ref()), }); - let mut body = String::new(); - append_sse_event(&request_context, &mut body, "reply_delta", &json!({ "text": npc_reply }))?; append_sse_event(&request_context, &mut body, "complete", &complete_payload)?; + body.push_str("data: [DONE]\n\n"); Ok(build_event_stream_response(body)) } +async fn generate_llm_npc_chat_turn( + state: &AppState, + request_context: &RequestContext, + payload: &NpcChatTurnRequest, + npc_name: &str, +) -> Option<(String, String, Vec)> { + let llm_client = state.llm_client()?; + let character = payload + .character + .as_ref() + .or(payload.player.as_ref()) + .unwrap_or(&Value::Null); + let prompt_input = NpcChatTurnPromptInput { + world_type: payload.world_type.as_str(), + character, + encounter: &payload.encounter, + monsters: &payload.monsters, + history: &payload.history, + context: &payload.context, + conversation_history: &payload.conversation_history, + dialogue: &payload.dialogue, + combat_context: payload.combat_context.as_ref(), + player_message: payload.player_message.as_str(), + npc_state: &payload.npc_state, + npc_initiates_conversation: payload.npc_initiates_conversation, + chat_directive: payload.chat_directive.as_ref(), + }; + + let mut body = String::new(); + let reply_prompt = build_npc_chat_turn_reply_prompt(&prompt_input); + let mut reply_request = LlmTextRequest::new(vec![ + LlmMessage::system(NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT), + LlmMessage::user(reply_prompt), + ]); + reply_request.max_tokens = Some(700); + reply_request.enable_web_search = state.config.rpg_llm_web_search_enabled; + + let reply_response = llm_client + .stream_text(reply_request, |delta| { + let _ = append_sse_event( + request_context, + &mut body, + "reply_delta", + &json!({ "text": delta.accumulated_text }), + ); + }) + .await + .ok()?; + let npc_reply = normalize_required_text(reply_response.content.as_str()).unwrap_or_else(|| { + build_deterministic_npc_reply( + npc_name, + payload.player_message.as_str(), + payload.npc_initiates_conversation, + ) + }); + + if should_force_chat_exit(payload.chat_directive.as_ref()) { + return Some((body, npc_reply, Vec::new())); + } + + let suggestion_prompt = + build_npc_chat_turn_suggestion_prompt(&prompt_input, npc_reply.as_str()); + let mut suggestion_request = LlmTextRequest::new(vec![ + LlmMessage::system(NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT), + LlmMessage::user(suggestion_prompt), + ]); + suggestion_request.max_tokens = Some(200); + suggestion_request.enable_web_search = state.config.rpg_llm_web_search_enabled; + let suggestions = llm_client + .request_text(suggestion_request) + .await + .ok() + .map(|response| parse_line_list_content(response.content.as_str(), 3)) + .filter(|items| items.len() == 3) + .unwrap_or_else(|| build_fallback_npc_chat_suggestions(payload.player_message.as_str())); + + Some((body, npc_reply, suggestions)) +} + fn build_deterministic_npc_reply( npc_name: &str, player_message: &str, @@ -84,15 +216,39 @@ fn build_deterministic_chat_suggestions(npc_name: &str, player_message: &str) -> ] } +fn build_fallback_npc_chat_suggestions(player_message: &str) -> Vec { + let topic = player_message.trim().chars().take(8).collect::(); + let topic = if topic.is_empty() { + "刚才那句".to_string() + } else { + topic + }; + + vec![ + "你刚才那句是什么意思".to_string(), + format!("这事和{topic}有关吗"), + "你愿意再说清楚点吗".to_string(), + ] +} + fn build_completion_directive(chat_directive: Option<&Value>) -> Value { let Some(directive) = chat_directive else { return Value::Null; }; + let closing_mode = read_string_field(directive, "closingMode") + .filter(|value| value == "foreshadow_close") + .unwrap_or_else(|| "free".to_string()); + let force_exit = closing_mode == "foreshadow_close" + || directive + .get("forceExitAfterTurn") + .and_then(Value::as_bool) + .unwrap_or(false); + json!({ "turnLimit": directive.get("turnLimit").cloned().unwrap_or(Value::Null), "remainingTurns": directive.get("remainingTurns").cloned().unwrap_or(Value::Null), - "forceExit": directive.get("forceExitAfterTurn").and_then(Value::as_bool).unwrap_or(false), - "closingMode": directive.get("closingMode").cloned().unwrap_or(Value::Null), + "forceExit": force_exit, + "closingMode": closing_mode, }) } @@ -105,6 +261,124 @@ fn read_string_field(value: &Value, field: &str) -> Option { .map(ToOwned::to_owned) } +fn read_number_field(value: &Value, field: &str) -> Option { + value + .get(field) + .and_then(Value::as_f64) + .filter(|number| number.is_finite()) +} + +fn should_force_chat_exit(chat_directive: Option<&Value>) -> bool { + let Some(directive) = chat_directive else { + return false; + }; + + read_string_field(directive, "closingMode").as_deref() == Some("foreshadow_close") + || directive + .get("forceExitAfterTurn") + .and_then(Value::as_bool) + .unwrap_or(false) +} + +fn normalize_required_text(value: &str) -> Option { + let normalized = value.trim(); + if normalized.is_empty() { + return None; + } + Some(normalized.to_string()) +} + +fn parse_line_list_content(text: &str, max_items: usize) -> Vec { + text.replace('\r', "") + .lines() + .map(|line| trim_line_list_marker(line.trim()).trim().to_string()) + .filter(|line| !line.is_empty()) + .take(max_items) + .collect() +} + +fn trim_line_list_marker(line: &str) -> &str { + line.trim_start_matches(|character: char| { + character == '-' + || character == '*' + || character.is_ascii_digit() + || character == '.' + || character == ')' + || character.is_whitespace() + }) +} + +fn count_keyword_matches(text: &str, keywords: &[&str]) -> i32 { + keywords + .iter() + .filter(|keyword| text.contains(**keyword)) + .count() as i32 +} + +fn clamp_affinity_delta(value: i32) -> i32 { + value.clamp(-3, 3) +} + +fn compute_npc_chat_affinity_delta( + player_message: &str, + npc_reply: &str, + chatted_count: f64, +) -> i32 { + let positive_keywords = [ + "谢谢", "辛苦", "抱歉", "理解", "相信", "放心", "一起", "帮你", "在意", "关心", + ]; + let negative_keywords = [ + "闭嘴", + "滚", + "少废话", + "威胁", + "骗", + "不信", + "别装", + "快说", + "审问", + "怀疑", + ]; + let warm_reply_keywords = ["可以", "愿意", "放心", "谢谢", "明白", "好"]; + let cold_reply_keywords = ["没必要", "不想", "别问", "与你无关", "算了", "住口"]; + + let positive_score = count_keyword_matches(player_message.trim(), &positive_keywords) + + count_keyword_matches(npc_reply.trim(), &warm_reply_keywords); + let negative_score = count_keyword_matches(player_message.trim(), &negative_keywords) + + count_keyword_matches(npc_reply.trim(), &cold_reply_keywords); + + if positive_score == 0 && negative_score == 0 { + return if chatted_count == 0.0 { 1 } else { 0 }; + } + + if positive_score > negative_score { + let base_delta = positive_score - negative_score + if chatted_count <= 1.0 { 1 } else { 0 }; + return clamp_affinity_delta(base_delta); + } + + if negative_score > positive_score { + return clamp_affinity_delta(positive_score - negative_score); + } + + 0 +} + +fn describe_affinity_shift(affinity_delta: i32) -> &'static str { + if affinity_delta >= 8 { + return "态度明显软化了下来。"; + } + if affinity_delta >= 5 { + return "态度比刚才亲近了一些。"; + } + if affinity_delta > 0 { + return "对话气氛稍微松动了一点。"; + } + if affinity_delta < 0 { + return "这轮对话让气氛变得更紧了一些。"; + } + "这轮对话暂时没有带来明显关系变化。" +} + fn append_sse_event( request_context: &RequestContext, body: &mut String, diff --git a/server-rs/crates/api-server/src/runtime_chat_prompt.rs b/server-rs/crates/api-server/src/runtime_chat_prompt.rs new file mode 100644 index 00000000..393a6a8b --- /dev/null +++ b/server-rs/crates/api-server/src/runtime_chat_prompt.rs @@ -0,0 +1,549 @@ +use serde_json::Value; + +pub(crate) const NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT: &str = r#"你是角色扮演 RPG 里的当前 NPC。 +你只输出这名 NPC 此刻会对玩家说的一轮回复。 +只输出纯中文口语回复正文,不要输出角色名、引号、旁白、动作描写、Markdown、JSON 或解释。 +- 如果这是第一次真正接触中的首轮回复,第一句必须先用自然招呼或开场判断起手,不能写成第三人称占位旁白。 +回复长度控制在 1 到 3 句,必须紧接玩家刚说的话,自然推进气氛、情报或关系。"#; + +pub(crate) const NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT: &str = r#"你要为玩家生成下一轮可直接点击的 3 条聊天续写候选。 +只输出纯文本,共 3 行,每行 1 条。 +不要加编号、项目符号、Markdown、JSON 或额外说明。 +三条候选必须明显不同,分别体现继续追问、表达态度、轻微拉近关系这三种不同方向。"#; + +#[derive(Debug)] +pub(crate) struct NpcChatTurnPromptInput<'a> { + pub world_type: &'a str, + pub character: &'a Value, + pub encounter: &'a Value, + pub monsters: &'a [Value], + pub history: &'a [Value], + pub context: &'a Value, + pub conversation_history: &'a [Value], + pub dialogue: &'a [Value], + pub combat_context: Option<&'a Value>, + pub player_message: &'a str, + pub npc_state: &'a Value, + pub npc_initiates_conversation: bool, + pub chat_directive: Option<&'a Value>, +} + +pub(crate) fn build_npc_chat_turn_reply_prompt(payload: &NpcChatTurnPromptInput<'_>) -> String { + let encounter = describe_encounter(payload.encounter); + let context = as_record(payload.context); + let npc_state = as_record(payload.npc_state); + let chat_directive = payload.chat_directive.and_then(as_record); + let conversation_history = if !payload.conversation_history.is_empty() { + payload.conversation_history + } else { + payload.dialogue + }; + let opening_camp_background = + context.and_then(|record| read_string(record.get("openingCampBackground"))); + let opening_camp_dialogue = + context.and_then(|record| read_string(record.get("openingCampDialogue"))); + let allowed_topics = context + .and_then(|record| record.get("encounterAllowedTopics")) + .map(read_string_array) + .unwrap_or_default(); + let blocked_topics = context + .and_then(|record| record.get("encounterBlockedTopics")) + .map(read_string_array) + .unwrap_or_default(); + let is_first_meaningful_contact = context + .and_then(|record| read_bool(record.get("isFirstMeaningfulContact"))) + .unwrap_or(false); + let affinity = npc_state + .and_then(|record| read_number(record.get("affinity"))) + .unwrap_or(0.0); + let chatted_count = npc_state + .and_then(|record| read_number(record.get("chattedCount"))) + .unwrap_or(0.0); + let limit_reason = chat_directive.and_then(|record| read_string(record.get("limitReason"))); + let turn_limit = chat_directive + .and_then(|record| read_number(record.get("turnLimit"))) + .unwrap_or(0.0) + .max(0.0); + let remaining_turns = chat_directive + .and_then(|record| read_number(record.get("remainingTurns"))) + .unwrap_or(0.0) + .max(0.0); + let closing_mode = chat_directive.and_then(|record| read_string(record.get("closingMode"))); + let is_limited_negative_affinity_chat = + limit_reason.as_deref() == Some("negative_affinity") && turn_limit > 0.0; + let is_foreshadow_close_turn = closing_mode.as_deref() == Some("foreshadow_close") + || chat_directive + .and_then(|record| read_bool(record.get("forceExitAfterTurn"))) + .unwrap_or(false); + let has_npc_reply_in_history = conversation_history.iter().any(|item| { + as_record(item) + .and_then(|turn| read_string(turn.get("speaker"))) + .is_some_and(|speaker| speaker == "npc") + }); + let is_first_npc_spoken_turn = + is_first_meaningful_contact && !has_npc_reply_in_history && chatted_count <= 0.0; + let first_contact_relation_stance = describe_first_contact_relation_stance( + context.and_then(|record| record.get("firstContactRelationStance")), + ); + let combat_context_block = payload.combat_context.and_then(describe_npc_combat_context); + + [ + Some(build_npc_dialogue_prompt_base(payload)), + Some(describe_npc_conversation_history( + conversation_history, + encounter.npc_name.as_str(), + )), + combat_context_block, + opening_camp_background.map(|text| format!("营地开场背景:{text}")), + opening_camp_dialogue.map(|text| format!("刚刚发生的第一段对话:{text}")), + Some(format!("当前关系值:{}", format_prompt_number(affinity))), + Some(format!("已聊天轮次:{}", format_prompt_number(chatted_count))), + if is_first_npc_spoken_turn { + Some(format!( + "当前接触阶段:第一次真正接触({first_contact_relation_stance})。这是这次聊天里 {} 第一次真正对玩家开口。", + encounter.npc_name + )) + } else { + None + }, + if is_first_npc_spoken_turn { + Some("第一句必须先用一句自然招呼或开场判断起手,再顺着玩家刚刚的话往下接。".to_string()) + } else { + None + }, + if is_first_npc_spoken_turn { + Some("不要写成“某人看着你,像是在等你把话接下去”这类第三人称占位旁白,也不要把整轮写成设定说明。".to_string()) + } else { + None + }, + if payload.npc_initiates_conversation { + Some(format!( + "当前要求:这是 {} 主动开口的第一句,不要假装玩家已经先说过话。", + encounter.npc_name + )) + } else { + None + }, + if allowed_topics.is_empty() { + None + } else { + Some(format!("当前更适合先谈:{}", allowed_topics.join("、"))) + }, + if blocked_topics.is_empty() { + None + } else { + Some(format!("当前避免直接说破:{}", blocked_topics.join("、"))) + }, + if is_limited_negative_affinity_chat { + Some(format!( + "当前相遇属于负好感主角色有限聊天,本次总上限 {} 轮。", + format_prompt_number(turn_limit) + )) + } else { + None + }, + if is_limited_negative_affinity_chat { + Some(format!( + "在你回复完这一轮之后,还剩 {} 轮可以继续聊。", + format_prompt_number(remaining_turns) + )) + } else { + None + }, + if is_limited_negative_affinity_chat && !is_foreshadow_close_turn { + Some("语气可以戒备、冷淡、带刺,但不要立刻转成开战,也不要把对话硬掐死。".to_string()) + } else { + None + }, + if is_foreshadow_close_turn { + Some("这是最后一轮回复。必须带有收束感,但不能只用“别问了”“滚开”之类的话把聊天粗暴截断。".to_string()) + } else { + None + }, + if is_foreshadow_close_turn { + Some("最后一轮必须抛出能推动后续剧情的明确铺垫,例如威胁、线索、条件、去处、人物、未说完的真相或下一步悬念。".to_string()) + } else { + None + }, + if is_foreshadow_close_turn { + Some("回复后这轮聊天会结束,所以不要邀请继续闲聊,也不要直接宣布已经开战。".to_string()) + } else { + None + }, + if payload.npc_initiates_conversation { + Some("玩家此刻还没有先说话,请直接写 NPC 主动开口时会说的第一轮回复。".to_string()) + } else { + Some(format!("玩家刚刚说:{}", payload.player_message.trim())) + }, + if payload.npc_initiates_conversation { + Some(format!( + "现在请只写 {} 主动开口时会说的话。", + encounter.npc_name + )) + } else { + Some(format!( + "现在请只写 {} 这一轮会回复玩家的话。", + encounter.npc_name + )) + }, + ] + .into_iter() + .flatten() + .filter(|text| !text.trim().is_empty()) + .collect::>() + .join("\n\n") +} + +pub(crate) fn build_npc_chat_turn_suggestion_prompt( + payload: &NpcChatTurnPromptInput<'_>, + npc_reply: &str, +) -> String { + let encounter = describe_encounter(payload.encounter); + let conversation_history = if !payload.conversation_history.is_empty() { + payload.conversation_history + } else { + payload.dialogue + }; + let combat_context_block = payload.combat_context.and_then(describe_npc_combat_context); + + [ + Some(build_npc_dialogue_prompt_base(payload)), + Some(describe_npc_conversation_history( + conversation_history, + encounter.npc_name.as_str(), + )), + combat_context_block, + Some(format!("玩家刚刚说:{}", payload.player_message)), + Some(format!("NPC 刚刚回复:{npc_reply}")), + Some("请围绕刚刚这轮对话,为玩家生成 3 条下一轮可以直接说出口的中文接话短句。".to_string()), + Some("每条都必须像玩家台词,不能写成行为描述、语气说明或策略建议。".to_string()), + Some("每条都必须控制在 20 个字以内,不要加序号、引号、括号或解释。".to_string()), + ] + .into_iter() + .flatten() + .filter(|text| !text.trim().is_empty()) + .collect::>() + .join("\n\n") +} + +fn build_npc_dialogue_prompt_base(payload: &NpcChatTurnPromptInput<'_>) -> String { + let encounter = describe_encounter(payload.encounter); + + [ + format!("世界:{}", describe_world(payload.world_type)), + describe_scene_context(payload.context), + describe_character("玩家 / ", payload.character), + encounter.block, + describe_monsters(payload.monsters), + describe_story_history(payload.history), + ] + .into_iter() + .filter(|text| !text.trim().is_empty()) + .collect::>() + .join("\n\n") +} + +struct EncounterDescription { + npc_name: String, + block: String, +} + +fn describe_encounter(encounter: &Value) -> EncounterDescription { + let record = as_record(encounter); + let npc_name = record + .and_then(|item| read_string(item.get("npcName"))) + .unwrap_or_else(|| "眼前角色".to_string()); + let context_text = record + .and_then(|item| read_string(item.get("context"))) + .or_else(|| record.and_then(|item| read_string(item.get("npcDescription")))) + .unwrap_or_else(|| "你们正在当前遭遇里继续对话。".to_string()); + + EncounterDescription { + npc_name: npc_name.clone(), + block: format!("当前对象:{npc_name}\n对象背景:{context_text}"), + } +} + +fn describe_first_contact_relation_stance(value: Option<&Value>) -> String { + match value.and_then(|item| item.as_str()).map(str::trim) { + Some("guarded") => "戒备试探".to_string(), + Some("neutral") => "正常交流但仍不熟".to_string(), + Some("cooperative") => "已有善意,先确认合作节奏".to_string(), + Some("bonded") => "明显信任,但仍是第一次正式对上人".to_string(), + _ => "第一次真正接触".to_string(), + } +} + +fn describe_world(world_type: &str) -> String { + match world_type { + "WUXIA" => "边城模板".to_string(), + "XIANXIA" => "灵潮模板".to_string(), + "CUSTOM" => "自定义世界".to_string(), + value if !value.trim().is_empty() => value.to_string(), + _ => "未知世界".to_string(), + } +} + +fn describe_stats(label: &str, record: Option<&serde_json::Map>) -> String { + let hp = record + .and_then(|item| read_number(item.get("hp"))) + .unwrap_or(0.0); + let max_hp = record + .and_then(|item| read_number(item.get("maxHp"))) + .unwrap_or(hp) + .max(1.0); + let mana = record + .and_then(|item| read_number(item.get("mana"))) + .unwrap_or(0.0); + let max_mana = record + .and_then(|item| read_number(item.get("maxMana"))) + .unwrap_or(mana) + .max(1.0); + + format!( + "{label}生命 {}/{},灵力 {}/{}", + format_prompt_number(hp), + format_prompt_number(max_hp), + format_prompt_number(mana), + format_prompt_number(max_mana) + ) +} + +fn describe_character(label: &str, value: &Value) -> String { + let record = as_record(value); + let name = record + .and_then(|item| read_string(item.get("name"))) + .unwrap_or_else(|| "未知角色".to_string()); + let title = record + .and_then(|item| read_string(item.get("title"))) + .unwrap_or_else(|| "未知称号".to_string()); + let description = record + .and_then(|item| read_string(item.get("description"))) + .unwrap_or_else(|| "暂无额外描述".to_string()); + let personality = record + .and_then(|item| read_string(item.get("personality"))) + .unwrap_or_else(|| "性格信息未显式提供".to_string()); + + [ + format!("{label}姓名:{name}"), + format!("{label}称号:{title}"), + format!("{label}描述:{description}"), + format!("{label}性格:{personality}"), + ] + .join("\n") +} + +fn describe_story_history(history: &[Value]) -> String { + if history.is_empty() { + return "近期剧情:暂无。".to_string(); + } + + let lines = history + .iter() + .rev() + .take(4) + .collect::>() + .into_iter() + .rev() + .filter_map(|item| as_record(item).and_then(|record| read_string(record.get("text")))) + .collect::>(); + + if lines.is_empty() { + "近期剧情:暂无。".to_string() + } else { + let mut result = vec!["近期剧情:".to_string()]; + result.extend(lines.into_iter().map(|line| format!("- {line}"))); + result.join("\n") + } +} + +fn describe_npc_conversation_history(history: &[Value], npc_name: &str) -> String { + if history.is_empty() { + return "当前聊天记录:暂无。".to_string(); + } + + let lines = history + .iter() + .rev() + .take(10) + .collect::>() + .into_iter() + .rev() + .filter_map(|item| { + let record = as_record(item)?; + let speaker = read_string(record.get("speaker")); + let speaker_name = read_string(record.get("speakerName")); + let text = read_string(record.get("text"))?; + + match speaker.as_deref() { + Some("player") => Some(format!("- 玩家:{text}")), + Some("npc") => Some(format!( + "- {}:{text}", + speaker_name.unwrap_or_else(|| npc_name.to_string()) + )), + Some("system") => Some(format!("- 系统提示:{text}")), + _ => Some(format!( + "- {}:{text}", + speaker_name.unwrap_or_else(|| "同伴".to_string()) + )), + } + }) + .collect::>(); + + if lines.is_empty() { + "当前聊天记录:暂无。".to_string() + } else { + let mut result = vec!["当前聊天记录:".to_string()]; + result.extend(lines); + result.join("\n") + } +} + +fn describe_npc_combat_context(combat_context: &Value) -> Option { + let record = as_record(combat_context)?; + let summary = read_string(record.get("summary")); + let battle_outcome = read_string(record.get("battleOutcome")); + let log_lines = record + .get("logLines") + .map(read_string_array) + .unwrap_or_default() + .into_iter() + .take(6) + .collect::>(); + if summary.is_none() && log_lines.is_empty() { + return None; + } + + let outcome_text = match battle_outcome.as_deref() { + Some("spar_complete") => Some("切磋刚刚结束。".to_string()), + Some("victory") => Some("战斗刚刚分出胜负。".to_string()), + _ => None, + }; + let mut lines = vec!["刚刚结束的交锋:".to_string()]; + if let Some(text) = outcome_text { + lines.push(text); + } + if let Some(text) = summary { + lines.push(format!("- 结果摘要:{text}")); + } + if !log_lines.is_empty() { + lines.push("- 战斗日志:".to_string()); + lines.extend(log_lines.into_iter().map(|line| format!(" - {line}"))); + } + Some(lines.join("\n")) +} + +fn describe_scene_context(context: &Value) -> String { + let record = as_record(context); + let scene_name = record + .and_then(|item| read_string(item.get("sceneName"))) + .unwrap_or_else(|| "当前区域".to_string()); + let scene_description = record + .and_then(|item| read_string(item.get("sceneDescription"))) + .unwrap_or_else(|| "周围气氛仍未完全安定。".to_string()); + let in_battle = if record + .and_then(|item| read_bool(item.get("inBattle"))) + .unwrap_or(false) + { + "战斗中" + } else { + "非战斗" + }; + let custom_world_profile = record + .and_then(|item| item.get("customWorldProfile")) + .and_then(as_record); + let custom_world_name = custom_world_profile.and_then(|item| read_string(item.get("name"))); + let custom_world_summary = + custom_world_profile.and_then(|item| read_string(item.get("summary"))); + + [ + Some(format!( + "世界补充:{}", + custom_world_name.unwrap_or_else(|| "无".to_string()) + )), + custom_world_summary.map(|text| format!("世界摘要:{text}")), + Some(format!("场景:{scene_name}")), + Some(format!("场景描述:{scene_description}")), + Some(format!("当前状态:{in_battle}")), + Some(describe_stats("玩家", record)), + ] + .into_iter() + .flatten() + .collect::>() + .join("\n") +} + +fn describe_monsters(monsters: &[Value]) -> String { + if monsters.is_empty() { + return "当前敌对目标:无。".to_string(); + } + + let lines = monsters + .iter() + .take(4) + .filter_map(|item| { + let record = as_record(item)?; + let name = read_string(record.get("name")) + .or_else(|| read_string(record.get("npcName"))) + .or_else(|| read_string(record.get("id")))?; + let hp = read_number(record.get("hp")).unwrap_or(0.0); + let max_hp = read_number(record.get("maxHp")).unwrap_or(hp).max(1.0); + + Some(format!( + "- {name}(生命 {}/{})", + format_prompt_number(hp), + format_prompt_number(max_hp) + )) + }) + .collect::>(); + + if lines.is_empty() { + "当前敌对目标:无。".to_string() + } else { + let mut result = vec!["当前敌对目标:".to_string()]; + result.extend(lines); + result.join("\n") + } +} + +fn read_string(value: Option<&Value>) -> Option { + value + .and_then(Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(ToOwned::to_owned) +} + +fn read_number(value: Option<&Value>) -> Option { + value + .and_then(Value::as_f64) + .filter(|number| number.is_finite()) +} + +fn read_bool(value: Option<&Value>) -> Option { + value.and_then(Value::as_bool) +} + +fn read_string_array(value: &Value) -> Vec { + value + .as_array() + .map(|items| { + items + .iter() + .filter_map(|item| read_string(Some(item))) + .collect::>() + }) + .unwrap_or_default() +} + +fn as_record(value: &Value) -> Option<&serde_json::Map> { + value.as_object() +} + +fn format_prompt_number(value: f64) -> String { + if value.fract() == 0.0 { + format!("{}", value as i64) + } else { + value.to_string() + } +} diff --git a/src/data/functionCatalog/flow/storyOpeningCampDialogue.ts b/src/data/functionCatalog/flow/storyOpeningCampDialogue.ts index f05d015e..9f73c14a 100644 --- a/src/data/functionCatalog/flow/storyOpeningCampDialogue.ts +++ b/src/data/functionCatalog/flow/storyOpeningCampDialogue.ts @@ -30,7 +30,7 @@ export const STORY_OPENING_CAMP_DIALOGUE_FUNCTION: FunctionDocumentationEntry = storyMode: 'special_sequence', uiMode: 'none', executor: - 'server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryActionDomain.ts + src/hooks/rpg-runtime-story/storyContextBuilder.ts', + 'server-rs/crates/api-server/src/runtime_story/compat.rs -> resolve_runtime_story_choice_action', animationNote: '重点在对白本身,不额外驱动独立战斗/位移动画。', storyNote: '会把 prompt 切到营地开场对白模式,并要求输出结构化对话行。', uiNote: '不弹 modal,直接进入对白流。', diff --git a/src/data/functionCatalog/npc/npcChatQuestOffer.ts b/src/data/functionCatalog/npc/npcChatQuestOffer.ts index 6c142c0a..26d6c190 100644 --- a/src/data/functionCatalog/npc/npcChatQuestOffer.ts +++ b/src/data/functionCatalog/npc/npcChatQuestOffer.ts @@ -8,7 +8,7 @@ import type { FunctionDocumentationEntry } from '../types'; */ const QUEST_OFFER_SOURCE = 'src/data/functionCatalog/npc/npcChatQuestOffer.ts'; const QUEST_OFFER_EXECUTOR = - 'server-node/src/modules/quest/questStoryActionService.ts -> resolveQuestStoryAction'; + 'server-rs/crates/api-server/src/runtime_story/compat.rs -> resolve_runtime_story_choice_action'; export const NPC_CHAT_QUEST_OFFER_VIEW_FUNCTION: FunctionDocumentationEntry = { id: 'npc_chat_quest_offer_view', diff --git a/src/data/functionCatalog/state/battleAttackBasic.ts b/src/data/functionCatalog/state/battleAttackBasic.ts index acbdec3d..228e58a4 100644 --- a/src/data/functionCatalog/state/battleAttackBasic.ts +++ b/src/data/functionCatalog/state/battleAttackBasic.ts @@ -16,7 +16,7 @@ export const BATTLE_ATTACK_BASIC_FUNCTION: FunctionDocumentationEntry = { '这个 function 代表一次明确的普通攻击点击,后端直接结算伤害、敌方反击和下一轮战斗选项,不再请求 AI 续写整段战斗剧情。', trigger: '仅在 battle 状态且场上仍有存活敌人时,由后端战斗 option 池下发。', execution: - '前端透传 functionId,后端 combatResolutionService 直接按普通攻击规则结算本回合。', + '前端透传 functionId,Rust 后端经 story battle facade 调用 module-combat 按普通攻击规则结算本回合。', result: '刷新 HP、战斗日志和下一轮战斗 options;若敌人被击败,再进入脱战剧情推理。', state: 'battle', category: 'battle', @@ -25,7 +25,7 @@ export const BATTLE_ATTACK_BASIC_FUNCTION: FunctionDocumentationEntry = { storyMode: 'local_only', uiMode: 'none', executor: - 'server-node/src/modules/combat/combatResolutionService.ts -> resolveCombatAction', + 'server-rs/crates/module-combat/src/lib.rs -> resolve_combat_action', animationNote: '播放一次基础攻击和受击反馈,不扩展成连续多段连击。', storyNote: '战斗未结束时只展示本次结算文本;战斗结束后才请求脱战剧情。', diff --git a/src/data/functionCatalog/state/battleUseSkill.ts b/src/data/functionCatalog/state/battleUseSkill.ts index 47c1f87f..778fe03d 100644 --- a/src/data/functionCatalog/state/battleUseSkill.ts +++ b/src/data/functionCatalog/state/battleUseSkill.ts @@ -16,7 +16,7 @@ export const BATTLE_USE_SKILL_FUNCTION: FunctionDocumentationEntry = { '这个 functionId 可以对应多个技能 option 实例。前端只展示技能名和不可用原因,后端根据 runtimePayload.skillId 校验蓝量、冷却并结算本次技能效果。', trigger: '仅在 battle 状态下由后端按角色技能列表生成,可能携带 disabled 状态。', execution: - '前端透传 runtimePayload.skillId,后端 combatResolutionService 校验技能并完成一次技能动作结算。', + '前端透传 runtimePayload.skillId,Rust 后端经 story battle facade 调用 module-combat 校验技能并完成一次技能动作结算。', result: '更新 MP、技能冷却、敌我 HP 和下一轮战斗 options;若战斗结束,再触发脱战剧情推理。', state: 'battle', @@ -26,7 +26,7 @@ export const BATTLE_USE_SKILL_FUNCTION: FunctionDocumentationEntry = { storyMode: 'local_only', uiMode: 'none', executor: - 'server-node/src/modules/combat/combatResolutionService.ts -> resolveCombatAction', + 'server-rs/crates/module-combat/src/lib.rs -> resolve_combat_action', animationNote: '根据技能 option 播放一次技能演出,不在本 function 内追加多回合动作。', storyNote: '战斗未结束时使用本次技能结算文本;只有战斗结束才请求新剧情。', diff --git a/view-llm-logs.ps1 b/view-llm-logs.ps1 deleted file mode 100644 index 2decb309..00000000 --- a/view-llm-logs.ps1 +++ /dev/null @@ -1,42 +0,0 @@ -# LLM 日志查看脚本 -# 使用方法:右键点击此文件 -> "使用 PowerShell 运行" - -$LogDir = "E:\Repos\Genarrative\server-node\logs" -$TodayLog = Get-ChildItem $LogDir -Filter "server.log.*.1" | Sort-Object LastWriteTime -Descending | Select-Object -First 1 - -if ($TodayLog) { - Write-Host "正在监控日志文件: $($TodayLog.FullName)" -ForegroundColor Green - Write-Host "按 Ctrl+C 退出" -ForegroundColor Yellow - Write-Host "" - Write-Host "=== 只显示 LLM 调试日志 ===" -ForegroundColor Cyan - Write-Host "" - - Get-Content $TodayLog.FullName -Wait -Tail 20 | Where-Object { $_ -match "LLM_DEBUG" } | ForEach-Object { - # 尝试解析 JSON 并美化输出 - try { - $json = $_ | ConvertFrom-Json - if ($json.msg -eq "[LLM_DEBUG] Request prompt") { - Write-Host "`n[请求 Prompt]" -ForegroundColor Yellow - Write-Host "时间: $($json.time)" -ForegroundColor Gray - Write-Host "标签: $($json.llm_debug_label)" -ForegroundColor Gray - Write-Host "模型: $($json.llm_model)" -ForegroundColor Gray - Write-Host "消息:" -ForegroundColor Gray - $json.llm_messages | ForEach-Object { - Write-Host " [$($_.role)]" -ForegroundColor Cyan - Write-Host " $($_.content.Substring(0, [Math]::Min(200, $_.content.Length)))..." -ForegroundColor White - } - } elseif ($json.msg -eq "[LLM_DEBUG] Response content") { - Write-Host "`n[响应内容]" -ForegroundColor Green - Write-Host "时间: $($json.time)" -ForegroundColor Gray - Write-Host "标签: $($json.llm_debug_label)" -ForegroundColor Gray - Write-Host "长度: $($json.llm_response_length) 字符" -ForegroundColor Gray - Write-Host "内容:" -ForegroundColor Gray - Write-Host " $($json.llm_response_content.Substring(0, [Math]::Min(500, $json.llm_response_content.Length)))..." -ForegroundColor White - } - } catch { - Write-Host $_ -ForegroundColor White - } - } -} else { - Write-Host "未找到日志文件" -ForegroundColor Red -} diff --git a/vite.config.ts b/vite.config.ts index f22ab275..1dea3aab 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -17,17 +17,12 @@ export default defineConfig(({mode}) => { '**/public/generated-custom-world-scenes/**', '**/public/generated-qwen-sprites/**', ]; - const backendStack = (env.GENARRATIVE_BACKEND_STACK || 'rust').trim().toLowerCase(); - const nodeServerTarget = - env.NODE_SERVER_TARGET || - 'http://127.0.0.1:8081'; const rustServerTarget = env.RUST_SERVER_TARGET || env.GENARRATIVE_API_TARGET || `http://127.0.0.1:${env.GENARRATIVE_API_PORT || '3100'}`; const runtimeServerTarget = - env.GENARRATIVE_RUNTIME_SERVER_TARGET || - (backendStack === 'rust' ? rustServerTarget : nodeServerTarget); + env.GENARRATIVE_RUNTIME_SERVER_TARGET || rustServerTarget; return { root: __dirname,