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] 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[];