From 705a2d3dd82146af154696605b89e8db0fc9befc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Sun, 26 Apr 2026 16:50:53 +0800 Subject: [PATCH] 1 --- docs/experience/MOBILE_UI_DEV_EXPERIENCE.md | 1 + ...ENTITY_MODAL_NPC_PREVIEW_FIX_2026-04-26.md | 45 ++ ..._WORLD_ASSET_PROMPT_DEFAULTS_2026-04-24.md | 2 + ...N_RS_NODE_WORKFLOW_ALIGNMENT_2026-04-24.md | 15 +- ...NE_TASK_AND_ACT_EVENT_FIELDS_2026-04-25.md | 16 +- ...B_AND_ACT_ROLE_ISOLATION_FIX_2026-04-26.md | 20 + ..._ATTRIBUTE_SCHEMA_GENERATION_2026-04-26.md | 37 ++ .../shared/src/contracts/rpgAgentDraft.ts | 30 + .../src/contracts/rpgCreationFixtures.ts | 81 +++ .../src/custom_world_foundation_draft.rs | 613 +++++++++++++----- .../api-server/src/prompt/foundation_draft.rs | 134 +--- src/components/AdventureEntityModal.test.tsx | 171 +++++ src/components/AdventureEntityModal.tsx | 217 +++++-- src/components/CustomWorldEntityCatalog.tsx | 58 +- .../CustomWorldEntityEditorModal.test.tsx | 4 + .../GameCanvasEntityLayer.test.tsx | 31 + .../game-canvas/GameCanvasEntityLayer.tsx | 25 +- .../game-canvas/GameCanvasShared.tsx | 47 +- .../RpgCreationEntityEditorShared.tsx | 57 +- src/data/customWorldLibrary.ts | 9 +- src/data/customWorldSceneGraph.ts | 2 + src/data/questFlow.test.ts | 23 + src/data/questFlow.ts | 58 +- .../rpg-runtime-story/progressionActions.ts | 19 + src/prompts/customWorldPrompts.ts | 127 ++-- src/services/ai.test.ts | 29 +- src/services/ai.ts | 109 +--- src/services/customWorld.ts | 32 +- .../customWorldAgentGenerationProgress.ts | 12 +- .../rpgCreationPreviewAdapter.test.ts | 83 +++ 30 files changed, 1537 insertions(+), 570 deletions(-) create mode 100644 docs/technical/ADVENTURE_ENTITY_MODAL_NPC_PREVIEW_FIX_2026-04-26.md create mode 100644 docs/technical/RPG_WORLD_ARCHIVE_SCENE_TAB_AND_ACT_ROLE_ISOLATION_FIX_2026-04-26.md create mode 100644 docs/technical/RPG_WORLD_DRAFT_ATTRIBUTE_SCHEMA_GENERATION_2026-04-26.md create mode 100644 src/components/AdventureEntityModal.test.tsx diff --git a/docs/experience/MOBILE_UI_DEV_EXPERIENCE.md b/docs/experience/MOBILE_UI_DEV_EXPERIENCE.md index 515c95bd..549d67dc 100644 --- a/docs/experience/MOBILE_UI_DEV_EXPERIENCE.md +++ b/docs/experience/MOBILE_UI_DEV_EXPERIENCE.md @@ -214,3 +214,4 @@ - 对战预览里主角和对手要沿画面中线成对出现,但纵向不能只共用一个 `bottom` 常量。 - 怪物精灵帧的空白、体型和脚底位置差异很大,运行画面应按帧高分档下沉,让怪物视觉底边落在主角同一条地面线上。 - 后续新增怪物资源时,先检查红圈标注的实际落点,再调整锚点分档或单怪物偏移,避免出现“悬在地面上方”的状态。 +- 自定义世界里敌对角色已经先作为场景 NPC 存在,即使它同时携带 `characterId` 和 `monsterPresetId`,画布也不能直接沿用模板角色的 `groundOffsetY`;只要 encounter 自身有 `imageSrc` 或 `visual`,就按场景 NPC 自定义形象锚点处理。 diff --git a/docs/technical/ADVENTURE_ENTITY_MODAL_NPC_PREVIEW_FIX_2026-04-26.md b/docs/technical/ADVENTURE_ENTITY_MODAL_NPC_PREVIEW_FIX_2026-04-26.md new file mode 100644 index 00000000..4bbdd563 --- /dev/null +++ b/docs/technical/ADVENTURE_ENTITY_MODAL_NPC_PREVIEW_FIX_2026-04-26.md @@ -0,0 +1,45 @@ +# 冒险实体详情 NPC 预览修复记录(2026-04-26) + +## 背景 + +RPG 运行态点击画面中的对面 NPC 角色形象时,详情弹窗的立绘与画布上实际显示的 NPC 不一致,并伴随 React 报错: + +`Encountered two children with the same key, ``.` + +## 问题定位 + +1. 画布层 `GameCanvasEntityLayer` 渲染 NPC 时,会优先使用当前 `Encounter` 实例上的 `visual`、`imageSrc`、`monsterPresetId`,再回退到 `characterId` 对应的预设角色。 +2. 详情弹窗 `AdventureEntityModal` 原本优先按 `characterId` 渲染预设角色,导致运行时遭遇已经携带独立形象时,点击后弹窗显示成另一个角色内容。 +3. `AdventureEntityModal` 内部存在多个浮层共用同一个 `AnimatePresence`,直系子节点没有显式稳定 key;同时 NPC 运行时背包物品如果传入空 `id`,会把空字符串直接交给物品格列表作为 React key。 + +## 落地约束 + +1. NPC 详情立绘必须与画布点击对象一致: + - `encounter.visual` + - `encounter.imageSrc` + - `encounter.monsterPresetId` + - `encounter.characterId` + - 通用 NPC 生成形象 +2. 前端只做展示优先级和 key 稳定性处理,不新增剧情规则、不改写运行时 NPC 数据来源。 +3. 所有列表和并列浮层都必须具备稳定、非空、可区分的渲染 key。 + +## 本次修改 + +1. `src/components/AdventureEntityModal.tsx` + - 新增 `NpcEncounterPortrait`,让弹窗立绘优先使用遭遇实例形象,与画布渲染策略对齐。 + - 新增 `selectionRenderKey`,给实体详情、标签详情、技能详情浮层提供稳定 key。 + - 新增 NPC 背包物品渲染 id 规范化,避免空 id 或重复 id 触发 React key 冲突,并避免点击物品时选中错误项。 + - 技能附带状态标签 key 增加兜底字段,避免空 buff id 冲突。 +2. `src/components/AdventureEntityModal.test.tsx` + - 覆盖“有 `characterId` 但遭遇实例提供 `imageSrc` 时,详情立绘必须显示遭遇图像”。 + - 覆盖“NPC 背包物品空 id 不再触发重复 key 警告”。 + +## 验证 + +已执行: + +```bash +npm run test -- AdventureEntityModal.test.tsx CharacterInfoShared.test.tsx +``` + +结果:5 个测试全部通过。 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 d98c0092..22f18972 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 @@ -10,8 +10,10 @@ - 角色:`visualDescription`,用于打开角色形象图像生成面板时默认填入角色形象描述框。 - 角色:`actionDescription`,用于打开角色动作视频生成面板时默认填入各动作描述框;当前每个动作会从同一角色默认动作描述起步,用户切换动作后可分别编辑并缓存。 +- 角色:`sceneVisualDescription`,用于描述角色常出现或关联的场景画面。三个角色默认描述字段必须在角色 outline 阶段同一次模型调用中产出;若模型遗漏,只允许后端本地兜底补字段,不再额外发起独立修复模型调用。 - 每一幕:`sceneChapterBlueprints[*].acts[*].backgroundPromptText`,用于打开该幕背景图像生成面板时默认填入场景描述框。 - 场景:`visualDescription` 只作为旧场景图或没有幕级描述时的兜底,不再从角色 AI 形象生成面板维护场景背景描述。 +- 场景:`actNPCNames`、`connectedLandmarkNames`、`entryHook` 必须在关键场景生成阶段同一次模型调用中产出,并由原场景解析链路写入 `landmarks` 与幕级 `primaryNpcId / oppositeNpcId / encounterNpcIds`;不再使用独立的场景网络补全提示词。旧草稿中的 `sceneNpcNames` 仅作为兼容读取兜底,不作为新生成字段。 草稿生成契约位置: diff --git a/docs/technical/CUSTOM_WORLD_DRAFT_FOUNDATION_RS_NODE_WORKFLOW_ALIGNMENT_2026-04-24.md b/docs/technical/CUSTOM_WORLD_DRAFT_FOUNDATION_RS_NODE_WORKFLOW_ALIGNMENT_2026-04-24.md index 0186d5bc..50c387ad 100644 --- a/docs/technical/CUSTOM_WORLD_DRAFT_FOUNDATION_RS_NODE_WORKFLOW_ALIGNMENT_2026-04-24.md +++ b/docs/technical/CUSTOM_WORLD_DRAFT_FOUNDATION_RS_NODE_WORKFLOW_ALIGNMENT_2026-04-24.md @@ -14,7 +14,6 @@ buildFoundationGenerationSeedText -> generateFoundationRoleOutlineEntries(playable) -> generateFoundationRoleOutlineEntries(story) -> generateFoundationLandmarkSeedEntries --> expandFoundationLandmarkNetworkEntries -> expandFoundationRoleEntries(playable, narrative) -> expandFoundationRoleEntries(playable, dossier) -> expandFoundationRoleEntries(story, narrative) @@ -30,15 +29,14 @@ buildFoundationGenerationSeedText 2. 使用旧 Node 的 framework prompt 生成世界核心骨架。 3. 分批生成可扮演角色 outline。 4. 分批生成场景角色 outline。 -5. 分批生成关键场景 seed。 -6. 补全关键场景探索网络。 -7. 先补可扮演角色叙事档案,再补养成档案。 -8. 先补场景角色叙事档案,再补养成档案。 -9. 将分阶段结果编译回 `draftProfile`,再交给 SpacetimeDB action 落库。 +5. 分批生成关键场景;同一次模型调用必须同时产出 `actNPCNames`、`connectedLandmarkNames`、`entryHook`、`actBackgroundPromptTexts` 与 `actEventDescriptions`,其中 `actNPCNames` 表示三幕各自默认主场景角色。旧草稿的 `sceneNpcNames` 只允许作为读取兜底。 +6. 先补可扮演角色叙事档案,再补养成档案。 +7. 先补场景角色叙事档案,再补养成档案。 +8. 将分阶段结果编译回 `draftProfile`,再交给 SpacetimeDB action 落库。 ## 约束 -1. 未修改旧 Node 提示词原文的语义与阶段顺序。 +1. Rust 主链不再兼容旧 Node 的独立场景网络补全阶段;场景生成只允许通过 `build_custom_world_landmark_seed_batch_prompt` 一次完成。 2. Rust 侧新增 prompt 构造只服务 `api-server` 外部 LLM 调用;SpacetimeDB reducer 仍只负责校验与落库,不承担联网生成。 3. 当前仍保留 Rust 侧最小归一化,目的仅是保证 `publish gate / result preview` 需要的字段存在,不替代 Node 的 AI 工作流。 4. 后续如继续迁移,需要优先把 Node `buildFoundationDraftProfileFromFramework` 的结构编译细节进一步完整 Rust 化,而不是回退到单 prompt 直出。 @@ -63,8 +61,7 @@ cargo test -p api-server custom_world_foundation_draft --no-default-features - `12`:整理世界骨架。 - `16-30`:生成可扮演角色。 - `30-44`:生成场景角色。 - - `44-56`:生成关键场景。 - - `56-66`:建立场景连接。 + - `44-66`:生成关键场景,并同步产出幕 NPC 与场景连接。 - `66-76`:补全可扮演角色叙事基础。 - `76-84`:补全可扮演角色档案细节。 - `84-92`:补全场景角色叙事基础。 diff --git a/docs/technical/CUSTOM_WORLD_DRAFT_SCENE_TASK_AND_ACT_EVENT_FIELDS_2026-04-25.md b/docs/technical/CUSTOM_WORLD_DRAFT_SCENE_TASK_AND_ACT_EVENT_FIELDS_2026-04-25.md index 3f1a59ab..f341e108 100644 --- a/docs/technical/CUSTOM_WORLD_DRAFT_SCENE_TASK_AND_ACT_EVENT_FIELDS_2026-04-25.md +++ b/docs/technical/CUSTOM_WORLD_DRAFT_SCENE_TASK_AND_ACT_EVENT_FIELDS_2026-04-25.md @@ -15,7 +15,8 @@ 新增字段: - `oppositeNpcId: string` - - 当前幕“对面的角色”,优先使用该场景 `sceneNpcNames` / `encounterNpcIds` 的第一个角色。 + - 当前幕“对面的角色”,优先使用该场景 `actNPCNames[actIndex]` 对应的角色。 + - 若 `actNPCNames` 缺少当前幕条目,使用 `actNPCNames[0]`;旧草稿只存在 `sceneNpcNames` 时,仅作为兼容兜底读取,不再作为新生成字段。 - 若当前场景暂未绑定角色,使用空字符串,不在草稿合成阶段伪造角色 ID。 - `eventDescription: string` - 描述当前幕正在发生的事件。 @@ -36,6 +37,15 @@ - 当前场景的核心任务描述。 - 文本会作为游戏中首次进入某个场景生成章节任务的关键上下文。 - 必须结合场景描述、场景入口钩子、出场角色与 3 幕事件,说明玩家首次进入该场景时要完成什么。 + - 世界档案的场景详情页必须直接展示该字段,便于创作者确认每个场景的默认章节任务。 + +### Landmark 生成源字段 + +- `actNPCNames: string[]` + - 关键场景生成阶段一次模型调用内产出,表示第 1/2/3 幕各自的主场景角色。 + - 只能引用同一批角色生成链路中已有的场景角色名。 + - 解析到幕蓝图时,每一幕默认写入 `primaryNpcId`、`oppositeNpcId`,并作为 `encounterNpcIds` 的首位。 + - 新生成不再使用 `sceneNpcNames`;前端和后端可保留旧字段读取兜底,用于历史草稿不丢角色。 ## 生成链路 @@ -43,6 +53,7 @@ 2. LLM 提示词需要要求: - `camp.sceneTaskDescription` 默认生成开局场景核心任务。 - `landmarks[*].sceneTaskDescription` 默认生成关键场景核心任务。 + - `actNPCNames` 恰好 3 条,对应每一幕默认主场景角色;如果可用场景角色名单为空,输出空数组。 - `actEventDescriptions` 恰好 3 条,对应每一幕事件。 - `actEventDescriptions[0] / [1] / [2]` 必须分别承担铺垫、冲突、高潮,不允许三条只是同一事件的近义复述。 - `actBackgroundPromptTexts[n]` 必须基于同序号幕事件和相关角色写出画面主体、站位空间、冲突痕迹与氛围,不能只用场景名或幕标题拼接。 @@ -50,9 +61,12 @@ - `sceneChapterBlueprints[*].sceneTaskDescription` - `sceneChapterBlueprints[*].acts[*].oppositeNpcId` - `sceneChapterBlueprints[*].acts[*].eventDescription` + - `sceneChapterBlueprints[*].acts[*].primaryNpcId` + - `sceneChapterBlueprints[*].acts[*].encounterNpcIds[0]` 4. 若 LLM 遗漏字段,归一化阶段用场景描述、入口钩子、角色名单生成中文默认值,保证草稿阶段字段非空。 5. 前端类型与归一化逻辑必须允许读取这些字段,旧草稿缺字段时仍自动补默认值。 6. 幕信息编辑界面必须直接展示 `eventDescription`,并在保存时保留 `sceneTaskDescription / oppositeNpcId / eventDescription / backgroundPromptText`,避免旧草稿经前端编辑后丢失后端生成字段。 +7. 首次进入某场景时,现有章节任务生成流程必须优先读取对应 `sceneChapterBlueprints[*].sceneTaskDescription`,并把它作为 `buildChapterQuestForScene` 的章节任务覆盖上下文;同一场景只生成一次章节任务。 ## 幕配置预览标识 diff --git a/docs/technical/RPG_WORLD_ARCHIVE_SCENE_TAB_AND_ACT_ROLE_ISOLATION_FIX_2026-04-26.md b/docs/technical/RPG_WORLD_ARCHIVE_SCENE_TAB_AND_ACT_ROLE_ISOLATION_FIX_2026-04-26.md new file mode 100644 index 00000000..e77614b8 --- /dev/null +++ b/docs/technical/RPG_WORLD_ARCHIVE_SCENE_TAB_AND_ACT_ROLE_ISOLATION_FIX_2026-04-26.md @@ -0,0 +1,20 @@ +# 世界档案场景 Tab 与幕主角色隔离修复(2026-04-26) + +## 背景 + +编辑世界档案时,开局场景已经复用普通场景的 `LandmarkEditor` 多幕配置面板,但幕角色归一化逻辑会把章节中任意一幕已选择的角色汇总成场景候选池。当第一幕刚配置主角色时,其他尚未配置角色的幕会被兜底补成同一个主角色,表现为“第一幕联动修改其他幕”。 + +## 落地规则 + +1. 世界档案实体 Tab 顺序调整为:世界、场景、可扮演角色、场景角色。 +2. 开局场景与普通场景的多幕角色保存规则保持一致: + - 当前幕的主角色只由当前幕 `encounterNpcIds[0]` 决定。 + - 已存在幕蓝图但当前幕未选角色时,保持空槽位,不从其他幕角色兜底。 + - 只有旧草稿完全缺少幕蓝图时,才允许从场景已有角色列表生成默认幕槽位。 +3. 保存场景时,`sceneNpcIds` 继续作为所有幕已选角色的汇总字段,用于列表检索、旧字段兼容和运行时场景候选,不反向覆盖未配置幕。 + +## 验收点 + +1. 在开局场景编辑器中只配置第一幕主角色,第二幕、第三幕仍保持未配置状态。 +2. 保存后 `camp.sceneNpcIds` 包含第一幕角色,`sceneChapterBlueprints` 中只有第一幕写入该角色。 +3. 普通场景的幕角色槽位、幕预览、场景任务和连接关系不发生行为漂移。 diff --git a/docs/technical/RPG_WORLD_DRAFT_ATTRIBUTE_SCHEMA_GENERATION_2026-04-26.md b/docs/technical/RPG_WORLD_DRAFT_ATTRIBUTE_SCHEMA_GENERATION_2026-04-26.md new file mode 100644 index 00000000..7d01b30c --- /dev/null +++ b/docs/technical/RPG_WORLD_DRAFT_ATTRIBUTE_SCHEMA_GENERATION_2026-04-26.md @@ -0,0 +1,37 @@ +# RPG 世界草稿属性六维生成 2026-04-26 + +## 背景 + +RPG Agent 生成世界草稿时,前端会把 `draftProfile` 归一化成 `CustomWorldProfile`。运行时已经支持 `attributeSchema`,但 foundation draft 当前没有稳定产出该字段,前端只能根据主题模式回退出固定模板,导致世界页面看到的六个维度更像预设,而不是本次世界草稿的一部分。 + +## 落地约束 + +- `draftProfile.attributeSchema` 是世界草稿真相源的一部分,必须随 foundation draft 一起生成并保存。 +- 六维固定使用 `axis_a` 到 `axis_f` 六个槽位,但 `schemaName`、每个槽位 `name` 和说明必须贴合本次世界设定。 +- 维度名不得沿用通用旧词:生命、法力、护甲、攻击、防御、力量、敏捷、智力、精神。 +- 若模型遗漏或结构不合规,后端必须生成中文兜底属性体系,不能让前端只靠固定模板补齐。 +- 世界页面的“世界”页签必须展示当前 `attributeSchema.slots` 的六个名称,作为玩家进入世界前可见的规则信号。 + +## 编码方案 + +1. `packages/shared/src/contracts/rpgAgentDraft.ts` + - 增加 `RpgAgentWorldAttributeSchema` 与 `RpgAgentWorldAttributeSlot` 合同。 + - `RpgAgentFoundationDraftProfile` 增加 `attributeSchema` 字段。 + +2. `server-rs/crates/api-server/src/prompt/foundation_draft.rs` + - framework 阶段要求模型输出 `attributeSchema`。 + - 修复提示也必须保留 `attributeSchema`,避免 JSON repair 丢字段。 + +3. `server-rs/crates/api-server/src/custom_world_foundation_draft.rs` + - `normalize_framework_shape()` 归一化 `attributeSchema`。 + - `build_foundation_draft_profile_from_framework()` 将归一化后的 `attributeSchema` 写入 `draftProfile`。 + - 新增兜底生成器,基于世界名、基调、目标、冲突和种子文本生成六个中文维度。 + +4. `src/components/CustomWorldEntityCatalog.tsx` + - 在世界页签增加“角色维度”区域,直接渲染 `profile.attributeSchema.slots` 的六个名称。 + +## 验收 + +- 新生成的 RPG 世界草稿 JSON 顶层包含 `attributeSchema.slots.length === 6`。 +- 结果页/世界页展示六个自定义维度名,而非固定的力量、敏捷、智力、精神。 +- 缺失或非法模型输出会被后端兜底为合法中文六维。 diff --git a/packages/shared/src/contracts/rpgAgentDraft.ts b/packages/shared/src/contracts/rpgAgentDraft.ts index 3866785a..fa92f66d 100644 --- a/packages/shared/src/contracts/rpgAgentDraft.ts +++ b/packages/shared/src/contracts/rpgAgentDraft.ts @@ -127,6 +127,32 @@ export interface RpgAgentFoundationDraftCamp { summary: string; } +export interface RpgAgentWorldAttributeSlot { + slotId: 'axis_a' | 'axis_b' | 'axis_c' | 'axis_d' | 'axis_e' | 'axis_f'; + name: string; + definition: string; + positiveSignals: string[]; + negativeSignals: string[]; + combatUseText: string; + socialUseText: string; + explorationUseText: string; +} + +export interface RpgAgentWorldAttributeSchema { + id: string; + worldId: string; + schemaVersion: number; + generatedFrom: { + worldType: 'CUSTOM' | 'WUXIA' | 'XIANXIA'; + worldName: string; + settingSummary: string; + tone: string; + conflictCore: string; + }; + schemaName?: string; + slots: RpgAgentWorldAttributeSlot[]; +} + export type RpgAgentSceneActStage = | 'opening' | 'expansion' @@ -148,6 +174,8 @@ export interface RpgAgentFoundationDraftSceneAct { backgroundAssetId?: string | null; encounterNpcIds: string[]; primaryNpcId: string; + oppositeNpcId: string; + eventDescription: string; linkedThreadIds: string[]; actGoal: string; transitionHook: string; @@ -160,6 +188,7 @@ export interface RpgAgentFoundationDraftSceneChapter { sceneName: string; title: string; summary: string; + sceneTaskDescription: string; linkedThreadIds: string[]; linkedLandmarkIds: string[]; acts: RpgAgentFoundationDraftSceneAct[]; @@ -179,6 +208,7 @@ export interface RpgAgentFoundationDraftProfile { camp?: RpgAgentFoundationDraftCamp | null; themePack?: Record | null; storyGraph?: Record | null; + attributeSchema: RpgAgentWorldAttributeSchema; factions: RpgAgentFoundationDraftFaction[]; threads: RpgAgentFoundationDraftThread[]; chapters: RpgAgentFoundationDraftChapter[]; diff --git a/packages/shared/src/contracts/rpgCreationFixtures.ts b/packages/shared/src/contracts/rpgCreationFixtures.ts index ba5e90ca..d6ce2fd8 100644 --- a/packages/shared/src/contracts/rpgCreationFixtures.ts +++ b/packages/shared/src/contracts/rpgCreationFixtures.ts @@ -178,6 +178,81 @@ export function createRpgAgentFoundationDraftProfileFixture(): RpgAgentFoundatio }, ], }, + attributeSchema: { + id: 'schema:rpg-agent:tide-fixture', + worldId: 'custom:潮雾列岛', + schemaVersion: 1, + schemaName: '潮雾六脉', + generatedFrom: { + worldType: 'CUSTOM', + worldName: '潮雾列岛', + settingSummary: '旧灯塔与失控航路', + tone: '压抑、潮湿、悬疑', + conflictCore: '沉船夜的航灯与灯册被人动过手脚。', + }, + slots: [ + { + slotId: 'axis_a', + name: '潮骨', + definition: '承受潮压、封航令与正面冲击的底子。', + positiveSignals: ['承压', '稳阵'], + negativeSignals: ['散乱', '畏压'], + combatUseText: '顶住正面压迫并守住行动空间。', + socialUseText: '在封锁与质问中保持可信姿态。', + explorationUseText: '穿过潮湿险境时维持身体与装备状态。', + }, + { + slotId: 'axis_b', + name: '浪步', + definition: '顺潮借势、换线穿行与抢占位置的能力。', + positiveSignals: ['借势', '轻快'], + negativeSignals: ['迟滞', '失位'], + combatUseText: '借地形切线、拉开距离或抢先手。', + socialUseText: '顺着对方语气调整节奏。', + explorationUseText: '穿越港口、水路、雾区与复杂地形。', + }, + { + slotId: 'axis_c', + name: '灯识', + definition: '看懂灯号、潮痕、档案错页与人心遮掩的能力。', + positiveSignals: ['辨伪', '识局'], + negativeSignals: ['误读', '迟钝'], + combatUseText: '识破破绽并判断局势变化。', + socialUseText: '听出隐瞒、试探与交换空间。', + explorationUseText: '辨认潮痕、灯册和沉船遗留线索。', + }, + { + slotId: 'axis_d', + name: '雾魄', + definition: '在海雾、旧案与封锁压力中推进真相的胆气。', + positiveSignals: ['果断', '压前'], + negativeSignals: ['犹疑', '退缩'], + combatUseText: '顶着高压窗口推进突破口。', + socialUseText: '在谈判或对峙中定调。', + explorationUseText: '面对陌生雾区与异状仍敢继续前探。', + }, + { + slotId: 'axis_e', + name: '旧约', + definition: '与旧友、信物、灯令和地方关系建立牵引的能力。', + positiveSignals: ['守诺', '通人情'], + negativeSignals: ['疏离', '失信'], + combatUseText: '借同伴协同与旧约牵制形成连锁。', + socialUseText: '安抚、结盟、交换与维系信任。', + explorationUseText: '从人情、传闻和旧物中打开线索。', + }, + { + slotId: 'axis_f', + name: '回澜', + definition: '在长线消耗中回稳节奏并维持判断的能力。', + positiveSignals: ['回稳', '续航'], + negativeSignals: ['紊乱', '断流'], + combatUseText: '久战不乱,把节奏重新拉回手里。', + socialUseText: '情绪稳定,不轻易被带偏。', + explorationUseText: '在漫长远行与恶劣天气里保有余力。', + }, + ], + }, factions: [ { id: 'faction-1', @@ -223,6 +298,7 @@ export function createRpgAgentFoundationDraftProfileFixture(): RpgAgentFoundatio sceneName: '回潮旧灯塔', title: '灯塔初章', summary: '围绕灯塔推进的首个场景章节。', + sceneTaskDescription: '首次进入回潮旧灯塔时,追查禁航灯册为何被人改写。', linkedThreadIds: ['thread-1'], linkedLandmarkIds: ['landmark-1'], acts: [ @@ -236,6 +312,8 @@ export function createRpgAgentFoundationDraftProfileFixture(): RpgAgentFoundatio backgroundAssetId: 'scene-asset-runtime', encounterNpcIds: ['story-1'], primaryNpcId: 'story-1', + oppositeNpcId: 'story-1', + eventDescription: '顾潮音在旧灯塔门前拦住玩家,交出第一段灯册疑点。', linkedThreadIds: ['thread-1'], actGoal: '接住首幕入口', transitionHook: '向第二幕推进。', @@ -435,6 +513,7 @@ export function createRpgCreationPublishedProfileFixture(): CustomWorldProfileRe sceneName: chapter.sceneName, title: chapter.title, summary: chapter.summary, + sceneTaskDescription: chapter.sceneTaskDescription, acts: chapter.acts.map((act) => ({ id: act.id, title: act.title, @@ -443,6 +522,8 @@ export function createRpgCreationPublishedProfileFixture(): CustomWorldProfileRe backgroundAssetId: act.backgroundAssetId, encounterNpcIds: act.encounterNpcIds, primaryNpcId: act.primaryNpcId, + oppositeNpcId: act.oppositeNpcId, + eventDescription: act.eventDescription, actGoal: act.actGoal, transitionHook: act.transitionHook, })), 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 64b57249..b67db3f2 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,11 +1,8 @@ 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_asset_fields_repair_prompt, build_custom_world_role_outline_batch_json_repair_prompt, build_custom_world_role_outline_batch_prompt, }; @@ -78,22 +75,11 @@ pub async fn generate_custom_world_foundation_draft( .await?; framework["storyNpcs"] = JsonValue::Array(story_outlines.clone()); - let landmark_seeds = generate_foundation_landmark_seed_entries( + let landmarks = generate_foundation_landmark_seed_entries( llm_client, &framework, FOUNDATION_DRAFT_LANDMARK_COUNT, - (44, 56), - &mut on_progress, - ) - .await?; - framework["landmarks"] = JsonValue::Array(landmark_seeds.clone()); - - let landmarks = expand_foundation_landmark_network_entries( - llm_client, - &framework, - &story_outlines, - &landmark_seeds, - (56, 66), + (44, 66), &mut on_progress, ) .await?; @@ -169,6 +155,12 @@ 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 WORLD_ATTRIBUTE_SLOT_IDS: [&str; 6] = + ["axis_a", "axis_b", "axis_c", "axis_d", "axis_e", "axis_f"]; +const BANNED_ATTRIBUTE_NAMES: [&str; 13] = [ + "生命", "法力", "护甲", "攻击", "防御", "力量", "敏捷", "智力", "精神", "战士", "法师", "刺客", + "魔道", +]; async fn request_foundation_json_stage( llm_client: &LlmClient, @@ -279,8 +271,7 @@ async fn generate_foundation_role_outline_entries( .into_iter() .take(batch_count) .collect(); - let repaired_entries = - ensure_role_outline_asset_fields(llm_client, role_type, raw_entries).await?; + let repaired_entries = ensure_role_outline_asset_fields(role_type, raw_entries)?; merged_entries.extend(repaired_entries); } let merged_entries: Vec = merged_entries.into_iter().take(total_count).collect(); @@ -357,45 +348,75 @@ async fn generate_foundation_landmark_seed_entries( Ok(merged_entries) } -async fn ensure_role_outline_asset_fields( - llm_client: &LlmClient, +fn ensure_role_outline_asset_fields( role_type: &str, entries: Vec, ) -> Result, String> { - let missing_report = role_asset_field_missing_report(&entries); - if missing_report.is_empty() { - return Ok(entries); - } - - let key = role_key(role_type); + // 中文注释:角色默认资产字段必须随角色 outline 同一次模型调用产出;模型漏字段时只做本地兜底,不再额外发起修复模型调用。 let expected_names = names_from_entries(&entries); - let repaired = request_foundation_json_stage( - llm_client, - build_custom_world_role_outline_asset_fields_repair_prompt( - role_type, - &entries, - missing_report.as_str(), - ), - format!("agent-foundation-{role_type}-outline-asset-fields-repair").as_str(), - |response_text| { - build_custom_world_role_outline_batch_json_repair_prompt( - response_text, - role_type, - entries.len(), - &[], - ) - }, - format!("agent-foundation-{role_type}-outline-asset-fields-json-repair").as_str(), - "角色形象设定文本修复阶段没有返回有效内容。", - ) - .await?; - let repaired_entries = array_field(&repaired, key) + let repaired_entries = entries .into_iter() - .take(entries.len()) + .map(|entry| fill_missing_role_outline_asset_fields(entry, role_type)) .collect::>(); - let merged_entries = merge_entries_by_name(&entries, &repaired_entries); - validate_role_outline_asset_fields(&merged_entries, &expected_names)?; - Ok(merged_entries) + validate_role_outline_asset_fields(&repaired_entries, &expected_names)?; + Ok(repaired_entries) +} + +fn fill_missing_role_outline_asset_fields(mut entry: JsonValue, role_type: &str) -> JsonValue { + if !entry.is_object() { + entry = json!({}); + } + let name = json_text(&entry, "name").unwrap_or_else(|| "未命名角色".to_string()); + let title = json_text(&entry, "title").unwrap_or_default(); + let role = json_text(&entry, "role").unwrap_or_else(|| { + if role_type == "playable" { + "可扮演角色".to_string() + } else { + "场景角色".to_string() + } + }); + let description = json_text(&entry, "description").unwrap_or_else(|| role.clone()); + let tags = json_string_array(&entry, "tags").unwrap_or_default(); + let tag_text = tags.first().cloned().unwrap_or_else(|| role.clone()); + let Some(object) = entry.as_object_mut() else { + return entry; + }; + insert_text_if_missing( + object, + "visualDescription", + format!("{name}身带{tag_text}气质,服装和轮廓呼应“{description}”,有清晰识别点。").as_str(), + ); + insert_text_if_missing( + object, + "actionDescription", + format!("{name}以{role}身份行动,围绕“{description}”做出稳定而可识别的动作。").as_str(), + ); + insert_text_if_missing( + object, + "sceneVisualDescription", + format!("{name}常出现在与“{description}”相关的场景中,周围保留其身份线索。").as_str(), + ); + if !object + .get("title") + .and_then(JsonValue::as_str) + .map(str::trim) + .is_some_and(|value| !value.is_empty()) + { + object.insert("title".to_string(), JsonValue::String(title)); + } + entry +} + +fn insert_text_if_missing(object: &mut JsonMap, key: &str, fallback: &str) { + if object + .get(key) + .and_then(JsonValue::as_str) + .map(str::trim) + .is_some_and(|value| !value.is_empty()) + { + return; + } + object.insert(key.to_string(), JsonValue::String(fallback.to_string())); } fn validate_role_outline_asset_fields( @@ -414,7 +435,7 @@ fn validate_role_outline_asset_fields( .any(|entry| json_text(entry, "name").as_deref() == Some(expected_name.as_str())) { return Err(format!( - "角色形象设定文本修复后缺少原角色「{expected_name}」。请重新生成底稿。" + "角色形象设定文本补齐后缺少原角色「{expected_name}」。请重新生成底稿。" )); } } @@ -440,69 +461,6 @@ fn role_asset_field_missing_report(entries: &[JsonValue]) -> String { missing_items.join(";") } -async fn expand_foundation_landmark_network_entries( - llm_client: &LlmClient, - framework: &JsonValue, - story_npcs: &[JsonValue], - base_entries: &[JsonValue], - progress_range: (u32, u32), - on_progress: &mut (impl FnMut(CustomWorldFoundationDraftProgress) + Send), -) -> Result, String> { - let mut merged_entries = Vec::new(); - let batches: Vec<&[JsonValue]> = base_entries - .chunks(FOUNDATION_LANDMARK_BATCH_SIZE) - .collect(); - let mut processed_count = 0usize; - for (batch_index, batch) in batches.iter().enumerate() { - emit_foundation_draft_progress( - on_progress, - "建立场景连接", - format!( - "正在补全场景连接第 {} / {} 批,当前已完成 {}/{}。", - batch_index + 1, - batches.len(), - processed_count, - base_entries.len(), - ) - .as_str(), - to_batch_progress(progress_range, processed_count, base_entries.len()), - ); - let raw = request_foundation_json_stage( - llm_client, - build_custom_world_landmark_network_batch_prompt(framework, story_npcs, batch), - format!( - "agent-foundation-landmark-network-batch-{}", - batch_index + 1 - ) - .as_str(), - |response_text| { - build_custom_world_landmark_network_batch_json_repair_prompt( - response_text, - &names_from_entries(batch), - ) - }, - format!( - "agent-foundation-landmark-network-batch-{}-json-repair", - batch_index + 1 - ) - .as_str(), - "地点网络补全阶段没有返回有效内容。", - ) - .await?; - merged_entries.extend(array_field(&raw, "landmarks")); - processed_count = processed_count - .saturating_add(batch.len()) - .min(base_entries.len()); - } - emit_foundation_draft_progress( - on_progress, - "建立场景连接", - "关键场景的角色分布与路径连接已经整理完成。", - progress_range.1, - ); - Ok(merge_entries_by_name(base_entries, &merged_entries)) -} - async fn expand_foundation_role_entries( llm_client: &LlmClient, framework: &JsonValue, @@ -827,6 +785,14 @@ fn build_foundation_draft_profile_from_framework( )]) }), ); + object.insert( + "attributeSchema".to_string(), + normalize_world_attribute_schema( + framework.get("attributeSchema"), + &framework, + setting_text, + ), + ); let camp = framework.get("camp").cloned().unwrap_or_else( || json!({ "name": "开局归处", "description": "玩家进入世界后的第一处落脚点。" }), ); @@ -847,6 +813,288 @@ fn build_foundation_draft_profile_from_framework( normalize_foundation_draft_profile(JsonValue::Object(object), session) } +fn normalize_world_attribute_schema( + raw_schema: Option<&JsonValue>, + framework: &JsonValue, + setting_text: &str, +) -> JsonValue { + let fallback = build_fallback_world_attribute_schema(framework, setting_text); + let Some(schema) = raw_schema.and_then(JsonValue::as_object) else { + return fallback; + }; + let raw_slots = schema + .get("slots") + .and_then(JsonValue::as_array) + .cloned() + .unwrap_or_default(); + if raw_slots.len() != WORLD_ATTRIBUTE_SLOT_IDS.len() { + return fallback; + } + + let fallback_slots = fallback + .get("slots") + .and_then(JsonValue::as_array) + .cloned() + .unwrap_or_default(); + let mut seen_names = Vec::with_capacity(WORLD_ATTRIBUTE_SLOT_IDS.len()); + let mut normalized_slots = Vec::with_capacity(WORLD_ATTRIBUTE_SLOT_IDS.len()); + for (index, slot_id) in WORLD_ATTRIBUTE_SLOT_IDS.iter().enumerate() { + let Some(raw_slot) = raw_slots.get(index).and_then(JsonValue::as_object) else { + return fallback; + }; + let fallback_slot = fallback_slots + .get(index) + .and_then(JsonValue::as_object) + .cloned() + .unwrap_or_default(); + let name = json_map_text(raw_slot, "name").unwrap_or_else(|| { + json_map_text(&fallback_slot, "name").unwrap_or_else(|| format!("叙轴{}", index + 1)) + }); + if is_invalid_attribute_name(name.as_str(), &seen_names) { + return fallback; + } + seen_names.push(name.clone()); + + normalized_slots.push(json!({ + "slotId": slot_id, + "name": name, + "definition": json_map_text(raw_slot, "definition") + .or_else(|| json_map_text(&fallback_slot, "definition")) + .unwrap_or_else(|| "这个维度用于描述角色在当前世界中的关键表现。".to_string()), + "positiveSignals": json_map_string_array(raw_slot, "positiveSignals") + .or_else(|| json_map_string_array(&fallback_slot, "positiveSignals")) + .unwrap_or_else(|| vec!["稳定".to_string(), "主动".to_string()]), + "negativeSignals": json_map_string_array(raw_slot, "negativeSignals") + .or_else(|| json_map_string_array(&fallback_slot, "negativeSignals")) + .unwrap_or_else(|| vec!["失衡".to_string(), "被动".to_string()]), + "combatUseText": json_map_text(raw_slot, "combatUseText") + .or_else(|| json_map_text(&fallback_slot, "combatUseText")) + .unwrap_or_else(|| "影响战斗中的推进、承压与应对。".to_string()), + "socialUseText": json_map_text(raw_slot, "socialUseText") + .or_else(|| json_map_text(&fallback_slot, "socialUseText")) + .unwrap_or_else(|| "影响对话中的判断、牵引与立场。".to_string()), + "explorationUseText": json_map_text(raw_slot, "explorationUseText") + .or_else(|| json_map_text(&fallback_slot, "explorationUseText")) + .unwrap_or_else(|| "影响探索中的观察、穿行与续航。".to_string()), + })); + } + + json!({ + "id": json_map_text(schema, "id") + .unwrap_or_else(|| build_attribute_schema_id(framework, setting_text)), + "worldId": json_map_text(schema, "worldId") + .unwrap_or_else(|| format!("custom:{}", framework_world_name(framework, setting_text))), + "schemaVersion": schema + .get("schemaVersion") + .and_then(JsonValue::as_i64) + .filter(|value| *value > 0) + .unwrap_or(1), + "schemaName": json_map_text(schema, "schemaName") + .filter(|value| !is_invalid_attribute_schema_name(value)) + .unwrap_or_else(|| build_attribute_schema_name(framework, setting_text)), + "generatedFrom": { + "worldType": "CUSTOM", + "worldName": framework_world_name(framework, setting_text), + "settingSummary": json_text(framework, "summary").unwrap_or_else(|| setting_text.to_string()), + "tone": json_text(framework, "tone").unwrap_or_default(), + "conflictCore": first_json_string(framework, "coreConflicts") + .or_else(|| json_text(framework, "playerGoal")) + .unwrap_or_else(|| setting_text.to_string()), + }, + "slots": normalized_slots, + }) +} + +fn build_fallback_world_attribute_schema(framework: &JsonValue, setting_text: &str) -> JsonValue { + let world_name = framework_world_name(framework, setting_text); + let summary = json_text(framework, "summary").unwrap_or_else(|| setting_text.to_string()); + let tone = json_text(framework, "tone").unwrap_or_default(); + let player_goal = json_text(framework, "playerGoal").unwrap_or_else(|| summary.clone()); + let conflict_core = + first_json_string(framework, "coreConflicts").unwrap_or_else(|| player_goal.clone()); + let theme_seed = [ + world_name.as_str(), + summary.as_str(), + tone.as_str(), + conflict_core.as_str(), + ] + .join("。"); + let theme_terms = collect_attribute_theme_terms(theme_seed.as_str()); + let prefix = theme_terms + .first() + .cloned() + .unwrap_or_else(|| "叙".to_string()); + let prefix_alt = theme_terms + .get(1) + .cloned() + .unwrap_or_else(|| "境".to_string()); + + json!({ + "id": build_attribute_schema_id(framework, setting_text), + "worldId": format!("custom:{world_name}"), + "schemaVersion": 1, + "schemaName": build_attribute_schema_name(framework, setting_text), + "generatedFrom": { + "worldType": "CUSTOM", + "worldName": world_name, + "settingSummary": summary, + "tone": tone, + "conflictCore": conflict_core, + }, + "slots": [ + build_attribute_slot("axis_a", format!("{prefix}骨"), format!("承受{prefix}压、正面冲击与长期消耗的底子。"), ["承压", "稳阵"], ["虚浮", "易散"], "顶住正面压力并守住行动空间。", "在强压场面里保持可信和稳固。", "穿过危险环境时维持身体与装备状态。"), + build_attribute_slot("axis_b", format!("{prefix_alt}步"), format!("顺应{prefix_alt}势、换位穿行与抢占时机的能力。"), ["借势", "轻快"], ["迟滞", "失位"], "切线换位、闪避、追击和抢先手。", "反应灵活,能顺势调整话术。", "穿越复杂地形、封锁线与危险通路。"), + build_attribute_slot("axis_c", format!("{prefix}识"), "看清局势、线索、虚实与隐藏代价的能力。", ["洞察", "辨伪"], ["误读", "迟钝"], "识破破绽并判断战局变化。", "听出隐瞒、试探与交换空间。", "整理线索、辨认路径并推断风险。"), + build_attribute_slot("axis_d", format!("{prefix_alt}魄"), "在高压变化里仍能推进目标和拍板的胆气。", ["果断", "压前"], ["犹疑", "退缩"], "顶着高压窗口推进突破口。", "在谈判或对峙中定调。", "面对未知异象仍敢继续前探。"), + build_attribute_slot("axis_e", format!("{prefix}契"), "与人、物、誓约、地方关系建立牵引的能力。", ["协同", "守诺"], ["疏离", "失信"], "借同伴协同与牵制形成连锁。", "安抚、结盟、交换与维系信任。", "从人情、传闻和旧物中打开线索。"), + build_attribute_slot("axis_f", format!("回{prefix_alt}"), "在长线消耗和局势反复中回稳节奏的能力。", ["回稳", "续航"], ["紊乱", "断续"], "久战不乱,把节奏重新拉回手里。", "情绪稳定,不轻易被带偏。", "在漫长探索与恶劣环境里保有余力。"), + ], + }) +} + +fn build_attribute_slot( + slot_id: &str, + name: String, + definition: impl Into, + positive_signals: [&str; 2], + negative_signals: [&str; 2], + combat_use_text: &str, + social_use_text: &str, + exploration_use_text: &str, +) -> JsonValue { + json!({ + "slotId": slot_id, + "name": name, + "definition": definition.into(), + "positiveSignals": positive_signals, + "negativeSignals": negative_signals, + "combatUseText": combat_use_text, + "socialUseText": social_use_text, + "explorationUseText": exploration_use_text, + }) +} + +fn framework_world_name(framework: &JsonValue, setting_text: &str) -> String { + json_text(framework, "name").unwrap_or_else(|| { + let fallback = setting_text + .chars() + .filter(|character| !character.is_whitespace()) + .take(8) + .collect::(); + if fallback.trim().is_empty() { + "自定义世界".to_string() + } else { + fallback + } + }) +} + +fn build_attribute_schema_id(framework: &JsonValue, setting_text: &str) -> String { + format!( + "schema:rpg-agent:{}:v1", + stable_ascii_slug(framework_world_name(framework, setting_text).as_str()) + ) +} + +fn build_attribute_schema_name(framework: &JsonValue, setting_text: &str) -> String { + let source = [ + framework_world_name(framework, setting_text), + json_text(framework, "summary").unwrap_or_default(), + json_text(framework, "tone").unwrap_or_default(), + ] + .join("。"); + let terms = collect_attribute_theme_terms(source.as_str()); + format!( + "{}六维", + terms.first().cloned().unwrap_or_else(|| "叙境".to_string()) + ) +} + +fn collect_attribute_theme_terms(source: &str) -> Vec { + let mut terms = Vec::new(); + let chinese_chars = source + .chars() + .filter(|character| ('\u{4e00}'..='\u{9fff}').contains(character)) + .collect::>(); + for size in [2usize, 1usize] { + if chinese_chars.len() < size { + continue; + } + for window in chinese_chars.windows(size) { + let term = window.iter().collect::(); + if term.chars().count() > 2 + || BANNED_ATTRIBUTE_NAMES + .iter() + .any(|banned| term.contains(banned)) + { + continue; + } + if !terms.contains(&term) { + terms.push(term); + } + if terms.len() >= 3 { + return terms; + } + } + } + terms +} + +fn is_invalid_attribute_name(name: &str, seen_names: &[String]) -> bool { + let trimmed = name.trim(); + trimmed.is_empty() + || trimmed.chars().count() > 4 + || seen_names.iter().any(|seen| seen == trimmed) + || BANNED_ATTRIBUTE_NAMES + .iter() + .any(|banned| trimmed.contains(banned)) +} + +fn is_invalid_attribute_schema_name(name: &str) -> bool { + BANNED_ATTRIBUTE_NAMES + .iter() + .any(|banned| name.trim().contains(banned)) +} + +fn json_map_text(map: &JsonMap, key: &str) -> Option { + map.get(key) + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn json_map_string_array(map: &JsonMap, key: &str) -> Option> { + let items = map + .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 first_json_string(value: &JsonValue, key: &str) -> Option { + value + .get(key) + .and_then(JsonValue::as_array) + .and_then(|items| items.first()) + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn stable_ascii_slug(value: &str) -> String { + let mut hash = 0u32; + for character in value.chars() { + hash = hash.wrapping_mul(31).wrapping_add(character as u32); + } + format!("{hash:08x}") +} + fn build_scene_chapter_blueprints_from_camp_and_landmarks( camp: &JsonValue, landmarks: &[JsonValue], @@ -893,7 +1141,9 @@ fn build_scene_chapter_blueprint_from_scene( .unwrap_or_else(|| build_default_scene_task_description(&scene_name, &summary)); let act_prompts = json_string_array(scene, "actBackgroundPromptTexts").unwrap_or_default(); let act_events = json_string_array(scene, "actEventDescriptions").unwrap_or_default(); - let scene_npc_names = json_string_array(scene, "sceneNpcNames").unwrap_or_default(); + let act_npc_names = json_string_array(scene, "actNPCNames") + .or_else(|| json_string_array(scene, "sceneNpcNames")) + .unwrap_or_default(); json!({ "id": scene_id.clone(), @@ -908,7 +1158,7 @@ fn build_scene_chapter_blueprint_from_scene( &summary, &act_prompts, &act_events, - &scene_npc_names, + &act_npc_names, act_index, )) .collect::>(), @@ -920,7 +1170,7 @@ fn build_scene_act_blueprint_from_landmark( scene_summary: &str, act_prompts: &[String], act_events: &[String], - scene_npc_names: &[String], + act_npc_names: &[String], act_index: usize, ) -> JsonValue { let act_title = if act_index == 0 { @@ -934,7 +1184,11 @@ fn build_scene_act_blueprint_from_landmark( .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); - let opposite_npc_id = scene_npc_names.first().cloned().unwrap_or_default(); + let opposite_npc_id = act_npc_names + .get(act_index) + .or_else(|| act_npc_names.first()) + .cloned() + .unwrap_or_default(); let event_description = act_events .get(act_index) .map(String::as_str) @@ -958,13 +1212,29 @@ fn build_scene_act_blueprint_from_landmark( "title": act_title, "summary": scene_summary, "backgroundPromptText": background_prompt, - "encounterNpcIds": scene_npc_names, + "encounterNpcIds": build_act_encounter_npc_ids(act_npc_names, opposite_npc_id.as_str()), "primaryNpcId": opposite_npc_id, "oppositeNpcId": opposite_npc_id, "eventDescription": event_description, }) } +fn build_act_encounter_npc_ids(act_npc_names: &[String], primary_npc_id: &str) -> Vec { + let mut names = Vec::with_capacity(act_npc_names.len().max(1)); + let primary = primary_npc_id.trim(); + if !primary.is_empty() { + names.push(primary.to_string()); + } + for name in act_npc_names { + let normalized = name.trim(); + if normalized.is_empty() || names.iter().any(|item| item == normalized) { + continue; + } + names.push(normalized.to_string()); + } + names +} + fn build_default_scene_task_description(scene_name: &str, scene_summary: &str) -> String { if scene_summary.trim().is_empty() { return format!( @@ -1071,6 +1341,13 @@ fn normalize_framework_shape(framework: &mut JsonValue, setting_text: &str) { if !object.get("coreConflicts").is_some_and(JsonValue::is_array) { object.insert("coreConflicts".to_string(), JsonValue::Array(Vec::new())); } + let framework_snapshot = JsonValue::Object(object.clone()); + let attribute_schema = normalize_world_attribute_schema( + framework_snapshot.get("attributeSchema"), + &framework_snapshot, + setting_text, + ); + object.insert("attributeSchema".to_string(), attribute_schema); if !object.get("camp").is_some_and(JsonValue::is_object) { object.insert( "camp".to_string(), @@ -1744,7 +2021,7 @@ mod tests { "灯童丁发现巡海灯突然转向,逼玩家判断封锁线真正保护的目标。", "灯童丁指认海图匣位置,玩家必须在退潮前确认父亲留下的暗号。" ], - "sceneNpcNames": ["灯童丁"] + "actNPCNames": ["灯童丁", "档吏庚", "灯童丁"] })]; let blueprints = build_scene_chapter_blueprints_from_landmarks(&landmarks); @@ -1768,6 +2045,8 @@ mod tests { ); assert_eq!(acts[0].get("oppositeNpcId"), Some(&json!("灯童丁"))); assert_eq!(acts[0].get("primaryNpcId"), Some(&json!("灯童丁"))); + assert_eq!(acts[1].get("oppositeNpcId"), Some(&json!("档吏庚"))); + assert_eq!(acts[1].get("primaryNpcId"), Some(&json!("档吏庚"))); assert_eq!( acts[0].get("eventDescription"), Some(&json!( @@ -1994,15 +2273,8 @@ mod tests { } #[tokio::test] - async fn role_outline_missing_asset_fields_are_repaired_before_details() { - let request_capture = Arc::new(Mutex::new(Vec::new())); - let server_url = spawn_mock_server( - request_capture.clone(), - vec![llm_response( - r#"{"storyNpcs":[{"name":"海洋生物学家","title":"深海观察员","role":"调查者","description":"记录异常海沟的人","visualDescription":"防水研究外套挂满盐痕,护目镜映着蓝绿海光,手提样本箱。","actionDescription":"蹲身取样并快速记录潮汐数据,遇险时护住样本箱后撤。","sceneVisualDescription":"她常站在潮湿实验船甲板边,身后是发光海沟与摇晃仪器。","initialAffinity":18,"relationshipHooks":["深海样本"],"tags":["科学家"]}]}"#, - )], - ); - let llm_client = build_test_llm_client(server_url); + async fn role_outline_missing_asset_fields_are_filled_locally_before_details() { + let request_capture = Arc::new(Mutex::new(Vec::::new())); let entries = vec![json!({ "name": "海洋生物学家", "title": "深海观察员", @@ -2013,37 +2285,34 @@ mod tests { "tags": ["科学家"] })]; - let repaired = ensure_role_outline_asset_fields(&llm_client, "story", entries) - .await + let repaired = ensure_role_outline_asset_fields("story", entries) .expect("missing asset fields should be repaired"); let captured_requests = request_capture .lock() .expect("request capture should lock") .clone(); - let request_text = captured_requests.join("\n---request---\n"); - assert_eq!(captured_requests.len(), 1); - assert!(request_text.contains("角色「海洋生物学家」缺少 visualDescription")); + assert_eq!(captured_requests.len(), 0); assert_eq!( repaired .first() .and_then(|entry| entry.get("visualDescription")) .and_then(JsonValue::as_str), - Some("防水研究外套挂满盐痕,护目镜映着蓝绿海光,手提样本箱。") + Some("海洋生物学家身带科学家气质,服装和轮廓呼应“记录异常海沟的人”,有清晰识别点。") ); assert_eq!( repaired .first() .and_then(|entry| entry.get("actionDescription")) .and_then(JsonValue::as_str), - Some("蹲身取样并快速记录潮汐数据,遇险时护住样本箱后撤。") + Some("海洋生物学家以调查者身份行动,围绕“记录异常海沟的人”做出稳定而可识别的动作。") ); assert_eq!( repaired .first() .and_then(|entry| entry.get("sceneVisualDescription")) .and_then(JsonValue::as_str), - Some("她常站在潮湿实验船甲板边,身后是发光海沟与摇晃仪器。") + Some("海洋生物学家常出现在与“记录异常海沟的人”相关的场景中,周围保留其身份线索。") ); } @@ -2054,7 +2323,7 @@ mod tests { request_capture.clone(), vec![ llm_response( - r#"{"name":"雾港归航","subtitle":"失灯旧案","summary":"守灯人与群岛议会围绕沉船旧案对峙。","tone":"海雾悬疑","playerGoal":"查清父亲沉船真相","templateWorldType":"WUXIA","majorFactions":["群岛议会","灯塔署"],"coreConflicts":["守灯塔的旧档案被人改写。"],"camp":{"name":"旧灯塔归舍","description":"海雾边缘的守灯人旧居。"}}"#, + r#"{"name":"雾港归航","subtitle":"失灯旧案","summary":"守灯人与群岛议会围绕沉船旧案对峙。","tone":"海雾悬疑","playerGoal":"查清父亲沉船真相","templateWorldType":"WUXIA","majorFactions":["群岛议会","灯塔署"],"coreConflicts":["守灯塔的旧档案被人改写。"],"attributeSchema":{"schemaName":"雾港六维","slots":[{"slotId":"axis_a","name":"灯骨","definition":"承受封航压力与潮湿险境的底子。","positiveSignals":["承压"],"negativeSignals":["虚浮"],"combatUseText":"顶住正面压迫。","socialUseText":"在质问中稳住姿态。","explorationUseText":"穿过潮湿险境。"},{"slotId":"axis_b","name":"潮步","definition":"顺潮换位与穿行的能力。","positiveSignals":["轻快"],"negativeSignals":["迟滞"],"combatUseText":"切线换位。","socialUseText":"顺势调整说法。","explorationUseText":"穿越雾港通路。"},{"slotId":"axis_c","name":"灯识","definition":"辨认灯号和旧档错页的能力。","positiveSignals":["辨伪"],"negativeSignals":["误读"],"combatUseText":"看破破绽。","socialUseText":"听出遮掩。","explorationUseText":"辨认旧档线索。"},{"slotId":"axis_d","name":"雾魄","definition":"在海雾和旧案压力中推进的胆气。","positiveSignals":["果断"],"negativeSignals":["退缩"],"combatUseText":"压上突破口。","socialUseText":"在对峙中定调。","explorationUseText":"敢进陌生雾区。"},{"slotId":"axis_e","name":"旧约","definition":"维系旧友、信物与地方关系的能力。","positiveSignals":["守诺"],"negativeSignals":["疏离"],"combatUseText":"借同伴协同。","socialUseText":"建立信任交换。","explorationUseText":"从人情旧物找线索。"},{"slotId":"axis_f","name":"回澜","definition":"长线消耗中回稳节奏的能力。","positiveSignals":["回稳"],"negativeSignals":["紊乱"],"combatUseText":"久战不乱。","socialUseText":"不被情绪带偏。","explorationUseText":"远行中保有余力。"}]},"camp":{"name":"旧灯塔归舍","description":"海雾边缘的守灯人旧居。"}}"#, ), llm_response( r#"{"playableNpcs":[{"name":"岑灯","title":"返乡守灯人","role":"主角代理","description":"追查旧案的人","visualDescription":"灰蓝旧灯披风压着海盐痕,腰侧挂旧海图筒和短灯杖。","actionDescription":"举灯照海图,短杖点地辨认潮声。","sceneVisualDescription":"旧灯塔回廊被海雾压低,墙上挂满潮湿航线图。","initialAffinity":24,"relationshipHooks":["旧案牵连"],"tags":["守灯人"]}]}"#, @@ -2072,10 +2341,7 @@ mod tests { r#"{"storyNpcs":[{"name":"档吏庚","title":"旧档吏","role":"保管者","description":"藏起原始卷宗","visualDescription":"褐色旧档袍袖口磨白,背着沉重文书匣,眼镜后目光闪躲。","actionDescription":"翻找卷宗时动作极快,被追问便把文书匣抱紧后退。","sceneVisualDescription":"他常守在潮湿档案室深处,旧柜标签被盐雾泡卷。","initialAffinity":10,"relationshipHooks":["原始卷宗"],"tags":["档案"]},{"name":"潮女辛","title":"听潮女","role":"引路人","description":"听懂海雾低语","visualDescription":"银灰长发被贝壳绳束起,披轻薄潮纹披肩,赤足沾水。","actionDescription":"侧耳听潮后抬手指向雾中路径,步伐像避开暗流。","sceneVisualDescription":"她常站在礁石浅水间,海雾绕过脚踝,远处灯火错位。","initialAffinity":35,"relationshipHooks":["海雾低语"],"tags":["引路"]}]}"#, ), llm_response( - r#"{"landmarks":[{"name":"旧灯塔","description":"雾中仍亮着错位灯火"},{"name":"沉船湾","description":"退潮后露出旧船骨"}]}"#, - ), - llm_response( - r#"{"landmarks":[{"name":"旧灯塔","description":"雾中仍亮着错位灯火","sceneNpcNames":["灯童丁","档吏庚"],"connectedLandmarkNames":["沉船湾"],"entryHook":"灯火按被篡改的航线闪烁。"},{"name":"沉船湾","description":"退潮后露出旧船骨","sceneNpcNames":["船魂戊","潮医乙"],"connectedLandmarkNames":["旧灯塔"],"entryHook":"旧船骨里传出父亲留下的暗号。"}]}"#, + r#"{"landmarks":[{"name":"旧灯塔","description":"雾中仍亮着错位灯火","visualDescription":"旧灯塔立在雾港高礁上,灯室漏出错位光束,石阶和回廊留出可站立空间。","sceneTaskDescription":"首次进入旧灯塔时,追查被篡改的灯火航线记录。","actBackgroundPromptTexts":["雾港高礁上的旧灯塔亮起错位灯火,灯童丁抱灯站在螺旋楼梯口。","潮湿档案室里灯火忽明忽暗,档吏庚抱紧文书匣,海图在桌面卷起。","灯室玻璃被海风震响,灯童丁指向错位航线,远处沉船湾雾光浮现。"],"actEventDescriptions":["灯童丁听见夜钟后发现灯火记录被人动过。","档吏庚试图带走原始卷宗,冲突在灯塔档案室升级。","灯童丁交出旧钥匙,玩家必须决定是否立刻追向沉船湾。"],"actNPCNames":["灯童丁","档吏庚","灯童丁"],"connectedLandmarkNames":["沉船湾"],"entryHook":"灯火按被篡改的航线闪烁。"},{"name":"沉船湾","description":"退潮后露出旧船骨","visualDescription":"退潮泥滩露出黑色旧船骨,破帆挂在礁石间,临时诊台灯影摇晃。","sceneTaskDescription":"首次进入沉船湾时,辨认旧船骨里残留的沉船真相。","actBackgroundPromptTexts":["沉船湾退潮泥滩露出旧船骨,船魂戊浮在黑色肋骨般的船梁旁。","湿木棚下潮医乙翻看伤痕记录,海水漫过脚边,巡海灯逼近湾口。","旧船骨深处传出暗号,船魂戊指向被封住的货舱,雾中灯塔光线错位。"],"actEventDescriptions":["船魂戊在退潮声里显形,指认父亲留下的暗号。","潮医乙发现伤痕与官方记录不符,巡海封锁让局势升级。","船魂戊带玩家接近旧货舱,必须在追捕前取走关键证物。"],"actNPCNames":["船魂戊","潮医乙","船魂戊"],"connectedLandmarkNames":["旧灯塔"],"entryHook":"旧船骨里传出父亲留下的暗号。"}]}"#, ), llm_response( r#"{"playableNpcs":[{"name":"岑灯","backstory":"被停职的守灯人返乡后发现父亲沉船案被改写。","personality":"克制执拗","motivation":"查清父亲沉船真相","combatStyle":"借灯火与海图周旋"}]}"#, @@ -2105,13 +2371,17 @@ mod tests { .clone(); let request_text = captured_requests.join("\n---request---\n"); - assert!(captured_requests.len() >= 18); + assert!(captured_requests.len() >= 17); assert!(request_text.contains("在失真的海图上追查一场被篡改的沉船事故。")); assert!(request_text.contains("世界核心骨架")); + assert!(request_text.contains("attributeSchema")); assert!(request_text.contains("可扮演角色框架名单")); assert!(request_text.contains("场景角色框架名单")); assert!(request_text.contains("关键场景框架名单")); - assert!(request_text.contains("探索网络信息")); + assert!(request_text.contains("actNPCNames")); + assert!(!request_text.contains("\"sceneNpcNames\"")); + assert!(request_text.contains("connectedLandmarkNames")); + assert!(!request_text.contains("探索网络信息")); assert!(request_text.contains("叙事档案")); assert!(request_text.contains("养成档案")); assert!(!request_text.contains("seedText\\uff1acustom-world-agent-session-1")); @@ -2134,6 +2404,24 @@ mod tests { Some("深色议会长袍垂到靴边,银扣像封蜡,手里总夹着旧档袋。") ); assert_eq!(draft_profile.get("name"), Some(&json!("雾港归航"))); + assert_eq!( + draft_profile + .get("attributeSchema") + .and_then(|schema| schema.get("slots")) + .and_then(JsonValue::as_array) + .map(Vec::len), + Some(6) + ); + assert_eq!( + draft_profile + .get("attributeSchema") + .and_then(|schema| schema.get("slots")) + .and_then(JsonValue::as_array) + .and_then(|entries| entries.first()) + .and_then(|entry| entry.get("name")) + .and_then(JsonValue::as_str), + Some("灯骨") + ); assert!( draft_profile .get("worldHook") @@ -2165,6 +2453,41 @@ mod tests { .map(Vec::len), Some(3) ); + assert_eq!( + draft_profile + .get("landmarks") + .and_then(JsonValue::as_array) + .and_then(|entries| entries.first()) + .and_then(|entry| entry.get("actNPCNames")) + .and_then(JsonValue::as_array) + .and_then(|items| items.first()) + .and_then(JsonValue::as_str), + Some("灯童丁") + ); + assert_eq!( + draft_profile + .get("sceneChapterBlueprints") + .and_then(JsonValue::as_array) + .and_then(|entries| entries.get(1)) + .and_then(|entry| entry.get("acts")) + .and_then(JsonValue::as_array) + .and_then(|acts| acts.get(1)) + .and_then(|act| act.get("primaryNpcId")) + .and_then(JsonValue::as_str), + Some("档吏庚") + ); + assert_eq!( + draft_profile + .get("sceneChapterBlueprints") + .and_then(JsonValue::as_array) + .and_then(|entries| entries.get(1)) + .and_then(|entry| entry.get("acts")) + .and_then(JsonValue::as_array) + .and_then(|acts| acts.first()) + .and_then(|act| act.get("primaryNpcId")) + .and_then(JsonValue::as_str), + Some("灯童丁") + ); } fn llm_response(content: &str) -> String { diff --git a/server-rs/crates/api-server/src/prompt/foundation_draft.rs b/server-rs/crates/api-server/src/prompt/foundation_draft.rs index a4e77d82..49916ba5 100644 --- a/server-rs/crates/api-server/src/prompt/foundation_draft.rs +++ b/server-rs/crates/api-server/src/prompt/foundation_draft.rs @@ -20,6 +20,17 @@ pub(crate) fn build_custom_world_framework_prompt(setting_text: &str) -> String " \"templateWorldType\": \"WUXIA|XIANXIA\",".to_string(), " \"majorFactions\": [\"势力甲\", \"势力乙\"],".to_string(), " \"coreConflicts\": [\"冲突甲\", \"冲突乙\"],".to_string(), + " \"attributeSchema\": {".to_string(), + " \"schemaName\": \"本世界六维名称\",".to_string(), + " \"slots\": [".to_string(), + " { \"slotId\": \"axis_a\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(), + " { \"slotId\": \"axis_b\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(), + " { \"slotId\": \"axis_c\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(), + " { \"slotId\": \"axis_d\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(), + " { \"slotId\": \"axis_e\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(), + " { \"slotId\": \"axis_f\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" }".to_string(), + " ]".to_string(), + " },".to_string(), " \"camp\": {".to_string(), " \"name\": \"开局归处名称\",".to_string(), " \"description\": \"这是玩家进入世界后的第一处落脚点描述\",".to_string(), @@ -31,7 +42,7 @@ pub(crate) fn build_custom_world_framework_prompt(setting_text: &str) -> String "".to_string(), "要求:".to_string(), "- 所有生成文本都必须使用中文。".to_string(), - "- 这一步只输出顶层 9 个字段:name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。".to_string(), + "- 这一步只输出顶层 10 个字段:name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、attributeSchema、camp。".to_string(), "- 这是一个完全独立的自定义世界;不要在任何正文里直接写出“武侠世界”“仙侠世界”等现成世界名。".to_string(), "- templateWorldType 只是系统兼容字段,不代表正文应当引用的世界名称。".to_string(), "- camp 必须表示玩家开局时的落脚处,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念。".to_string(), @@ -40,6 +51,9 @@ pub(crate) fn build_custom_world_framework_prompt(setting_text: &str) -> String "- camp.actBackgroundPromptTexts 必须恰好 3 条,分别对应第 1/2/3 幕背景图画面内容描述;每条必须基于同序号 actEventDescriptions 和相关角色写出画面主体、站位空间、冲突痕迹与氛围,能直接交给生图模型,控制在 40 到 90 个汉字内。".to_string(), "- 不要输出 playableNpcs、storyNpcs、landmarks、items,也不要输出任何角色和地图细节。".to_string(), "- majorFactions 保持 2 到 3 个,coreConflicts 保持 2 到 3 个。".to_string(), + "- attributeSchema 必须是本世界专属的角色六维属性体系,slots 必须恰好 6 个,slotId 固定为 axis_a 到 axis_f,维度名必须是 2 到 4 个汉字且互不重复。".to_string(), + "- attributeSchema.slots 的 name 禁止使用:生命、法力、护甲、攻击、防御、力量、敏捷、智力、精神;不要写通用 DND 或传统四维属性。".to_string(), + "- 每个属性维度都要同时能服务战斗、社交、探索三种场景,definition、combatUseText、socialUseText、explorationUseText 必须贴合本世界主题。".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(), @@ -50,9 +64,10 @@ pub(crate) fn build_custom_world_framework_json_repair_prompt(response_text: &st [ "下面这段文本本应是自定义世界核心骨架的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。", "请只输出修复后的 JSON 对象。", - "顶层必须只包含:name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。", + "顶层必须只包含:name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、attributeSchema、camp。", "不要输出 playableNpcs、storyNpcs、landmarks、items 或任何其他字段。", "majorFactions 与 coreConflicts 必须是字符串数组。", + "attributeSchema 必须是对象,且包含 schemaName 与 slots;slots 必须恰好 6 个,slotId 固定为 axis_a 到 axis_f。", "camp 必须是对象,且包含:name、description、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions。", "原始文本:", response_text.trim(), @@ -135,47 +150,19 @@ pub(crate) fn build_custom_world_role_outline_batch_json_repair_prompt( ].into_iter().filter(|value| !value.is_empty()).collect::>().join("\n") } -pub(crate) fn build_custom_world_role_outline_asset_fields_repair_prompt( - role_type: &str, - role_entries: &[JsonValue], - missing_report: &str, -) -> String { - let key = role_key(role_type); - let label = if role_type == "playable" { - "可扮演角色" - } else { - "场景角色" - }; - [ - format!("下面这批{label}框架名单已经能解析为 JSON,但有角色缺少资产默认描述字段。"), - "请只输出修复后的单个 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(), - format!("顶层必须只包含一个 {key} 数组。"), - "必须保留原有角色数量、顺序和 name,不得新增、删除或改名。".to_string(), - "每个角色只包含:name、title、role、description、visualDescription、actionDescription、sceneVisualDescription、initialAffinity、relationshipHooks、tags。".to_string(), - "visualDescription 必须具体到体型、服装、轮廓与识别点,控制在 24 到 60 个汉字内,不能复制 description。".to_string(), - "actionDescription 必须体现该角色默认动作节奏、武器或行动方式,控制在 18 到 48 个汉字内。".to_string(), - "sceneVisualDescription 必须描述该角色常出现或关联的场景画面,控制在 24 到 60 个汉字内。".to_string(), - "缺失报告:".to_string(), - missing_report.trim().to_string(), - "原始角色 JSON:".to_string(), - compact_json_text(&JsonValue::Array(role_entries.to_vec())), - ] - .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 { + let story_npc_names = names_from_entries(&array_field(framework, "storyNpcs")); [ "请根据下面的世界核心信息,生成一批关键场景框架名单。".to_string(), - "后续我会继续补全场景网络,所以这一步每个地点只保留场景骨架、地点默认生图描述和逐幕背景描述。".to_string(), + "这一步必须一次性生成场景骨架、地点默认生图描述、逐幕背景描述、幕 NPC 分配和相连场景信息。".to_string(), "你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(), "世界核心信息:".to_string(), build_framework_summary_text(framework, 0), + if story_npc_names.is_empty() { "".to_string() } else { format!("可用场景角色名单:{}", story_npc_names.join("、")) }, if forbidden_names.is_empty() { "".to_string() } else { format!("这些地点已经生成,禁止重复:{}", forbidden_names.join("、")) }, "".to_string(), "输出 JSON 模板:".to_string(), @@ -188,6 +175,9 @@ pub(crate) fn build_custom_world_landmark_seed_batch_prompt( " \"sceneTaskDescription\": \"首次进入该场景时要生成的章节任务核心上下文\",".to_string(), " \"actBackgroundPromptTexts\": [\"第一幕背景画面描述\", \"第二幕背景画面描述\", \"第三幕背景画面描述\"],".to_string(), " \"actEventDescriptions\": [\"第一幕事件描述\", \"第二幕事件描述\", \"第三幕事件描述\"],".to_string(), + " \"actNPCNames\": [\"第一幕主场景角色名\", \"第二幕主场景角色名\", \"第三幕主场景角色名\"],".to_string(), + " \"connectedLandmarkNames\": [\"相邻或可通往的地点名\"],".to_string(), + " \"entryHook\": \"玩家进入这里时首先遇到的钩子\"".to_string(), " }".to_string(), " ]".to_string(), "}".to_string(), @@ -196,9 +186,14 @@ pub(crate) fn build_custom_world_landmark_seed_batch_prompt( format!("- 必须生成恰好 {batch_count} 个关键场景。"), "- 这是一个完全独立的自定义世界;地点名称必须直接服务玩家输入主题。".to_string(), "- 名称必须具体且互不重复,不要使用 地点1、场景1 之类的占位名。".to_string(), - "- 每个地点只保留:name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions。".to_string(), + "- 每个地点只保留:name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions、actNPCNames、connectedLandmarkNames、entryHook。".to_string(), "- sceneTaskDescription 必须描述玩家首次进入该场景时要完成的核心任务,会作为游戏章节任务生成上下文,控制在 24 到 56 个汉字内。".to_string(), "- visualDescription 是打开场景背景图像生成面板时默认填入的场景描述,必须具体到画面主体、远近景层次、地面可站立区域和氛围识别点,控制在 32 到 80 个汉字内。".to_string(), + "- actNPCNames 只能引用上方可用场景角色名单中的名字,表示第 1/2/3 幕各自的主场景角色;如果名单为空,输出空数组。".to_string(), + "- 可用场景角色名单非空时,actNPCNames 必须恰好 3 个;可以重复使用同一角色,但每一项都必须服务对应幕事件。".to_string(), + "- actNPCNames[n] 会成为第 n+1 幕对面主角色;三幕事件和幕背景必须围绕对应角色的行动、阻碍、试探或求助展开。".to_string(), + "- connectedLandmarkNames 优先引用本批或已知关键场景名称,每个地点 1 到 3 个;只有 1 个地点时可以输出空数组。".to_string(), + "- entryHook 控制在 16 到 36 个汉字内。".to_string(), "- actEventDescriptions 必须恰好 3 条,分别描述每一幕发生的事件;第 1 幕负责铺垫,第 2 幕必须让冲突升级,第 3 幕必须形成高潮或关键抉择;事件必须和当前幕对面的角色强相关,控制在 24 到 56 个汉字内。".to_string(), "- actBackgroundPromptTexts 必须恰好 3 条,分别对应这个场景章节的第 1/2/3 幕背景图画面内容描述;每条都必须基于同序号 actEventDescriptions、当前地点和可出场角色直接写出画面主体、站位空间、冲突痕迹与氛围,控制在 40 到 90 个汉字内。".to_string(), "- actBackgroundPromptTexts 禁止使用“某某第1幕背景;玩家会在……”这类标题、摘要、规则句拼接格式;必须像可直接交给生图模型的自然画面描述。".to_string(), @@ -219,71 +214,14 @@ pub(crate) fn build_custom_world_landmark_seed_batch_json_repair_prompt( "顶层必须只包含一个 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(), + "每个地点只包含:name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions、actNPCNames、connectedLandmarkNames、entryHook。".to_string(), + "如果缺少字段:字符串补空字符串,actBackgroundPromptTexts、actEventDescriptions、actNPCNames 和 connectedLandmarkNames 补空数组。".to_string(), + "不要输出 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(), - "- sceneNpcNames 的第一位会成为每幕对面主角色;三幕事件和幕背景必须围绕这个角色的行动、阻碍、试探或求助展开。".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, @@ -499,7 +437,9 @@ fn landmark_names_for_role(framework: &JsonValue, role_name: &str) -> Vec Option> { .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/src/components/AdventureEntityModal.test.tsx b/src/components/AdventureEntityModal.test.tsx new file mode 100644 index 00000000..60c21e80 --- /dev/null +++ b/src/components/AdventureEntityModal.test.tsx @@ -0,0 +1,171 @@ +/* @vitest-environment jsdom */ + +import { render, screen } from '@testing-library/react'; +import { afterEach, expect, test, vi } from 'vitest'; + +import { + AnimationState, + type Encounter, + type GameState, + type EquipmentLoadout, + WorldType, +} from '../types'; +import { AdventureEntityModal } from './AdventureEntityModal'; + +vi.mock('./CharacterAnimator', () => ({ + CharacterAnimator: () =>
, +})); + +vi.mock('./MedievalNpcAnimator', () => ({ + MedievalNpcAnimator: () =>
, +})); + +vi.mock('./HostileNpcAnimator', () => ({ + HostileNpcAnimator: () =>
, +})); + +function createGameState(overrides: Partial = {}): GameState { + return { + worldType: WorldType.WUXIA, + customWorldProfile: null, + playerCharacter: null, + runtimeStats: { + playTimeMs: 0, + lastPlayTickAt: null, + hostileNpcsDefeated: 0, + questsAccepted: 0, + itemsUsed: 0, + scenesTraveled: 0, + }, + currentScene: 'test-scene', + storyHistory: [], + characterChats: {}, + animationState: AnimationState.IDLE, + currentEncounter: null, + npcInteractionActive: false, + currentScenePreset: null, + sceneHostileNpcs: [], + playerX: 0, + playerOffsetY: 0, + playerFacing: 'right', + playerActionMode: 'idle', + scrollWorld: false, + inBattle: false, + playerHp: 100, + playerMaxHp: 100, + playerMana: 30, + playerMaxMana: 30, + playerSkillCooldowns: {}, + activeCombatEffects: [], + playerCurrency: 0, + playerInventory: [], + playerEquipment: {} as EquipmentLoadout, + npcStates: {}, + quests: [], + roster: [], + companions: [], + currentBattleNpcId: null, + currentNpcBattleMode: null, + currentNpcBattleOutcome: null, + sparReturnEncounter: null, + sparPlayerHpBefore: null, + sparPlayerMaxHpBefore: null, + sparStoryHistoryBefore: null, + ...overrides, + } as GameState; +} + +function createEncounter(overrides: Partial = {}): Encounter { + return { + id: 'runtime-npc', + kind: 'npc', + npcName: '雾中来客', + npcDescription: '带着临时生成形象的相遇者', + npcAvatar: '/avatar.png', + context: '桥边试探', + ...overrides, + }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +test('NPC 详情立绘优先展示遭遇实例形象,而不是 characterId 对应预设', () => { + const encounter = createEncounter({ + characterId: 'sword-princess', + imageSrc: '/runtime-npc-preview.png', + }); + + render( + undefined} + />, + ); + + const portrait = screen.getByAltText('雾中来客'); + + expect(portrait.getAttribute('src')).toBe('/runtime-npc-preview.png'); + expect(screen.queryByTestId('character-portrait')).toBeNull(); +}); + +test('NPC 背包物品空 id 会被规范成稳定渲染 id', () => { + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + const encounter = createEncounter(); + + render( + undefined} + />, + ); + + expect(screen.getAllByTitle(/裂纹石片 x/)).toHaveLength(2); + expect( + consoleErrorSpy.mock.calls.some((call) => + call.some( + (arg) => + typeof arg === 'string' && + arg.includes('Encountered two children with the same key'), + ), + ), + ).toBe(false); +}); diff --git a/src/components/AdventureEntityModal.tsx b/src/components/AdventureEntityModal.tsx index 034c282c..fb5e9a77 100644 --- a/src/components/AdventureEntityModal.tsx +++ b/src/components/AdventureEntityModal.tsx @@ -55,6 +55,7 @@ import { type GameState, type InventoryItem, type NpcPersistentState, + type SceneHostileNpc, } from '../types'; import { getNineSliceStyle, UI_CHROME } from '../uiAssets'; import { AffinityStatusCard } from './AffinityStatusCard'; @@ -87,6 +88,7 @@ import { InventoryItemGrid, } from './InventoryItemViews'; import { MedievalNpcAnimator } from './MedievalNpcAnimator'; +import { ResolvedAssetImage } from './ResolvedAssetImage'; import { SkillEffectPreview } from './SkillEffectPreview'; interface AdventureEntityModalProps { @@ -201,6 +203,148 @@ function buildCharacterInventoryPreviewItems( ); } +function buildSelectionRenderKey(selection: GameCanvasEntitySelection | null) { + if (!selection) { + return 'none'; + } + + if (selection.kind === 'player') { + return 'player'; + } + + if (selection.kind === 'companion') { + return `companion-${selection.companion.npcId}`; + } + + const encounter = selection.encounter; + return `npc-${ + encounter.id || + selection.battleState?.id || + encounter.characterId || + encounter.monsterPresetId || + encounter.npcName + }`; +} + +function buildStableRenderKey(parts: Array) { + return parts + .map((part, index) => { + const normalized = String(part ?? '').trim(); + return normalized || `empty-${index}`; + }) + .join(':'); +} + +function normalizeInventoryItemRenderIds( + items: InventoryItem[], + ownerKey: string, +) { + const seenIds = new Map(); + + return items.map((item, index) => { + // 运行时 NPC 背包可能带空 id;这里只修正展示层 key,不改写原始状态。 + const rawId = item.id.trim(); + const baseId = + rawId || + buildStableRenderKey([ + 'inventory', + ownerKey, + item.category, + item.name, + index, + ]); + const repeatedCount = seenIds.get(baseId) ?? 0; + seenIds.set(baseId, repeatedCount + 1); + + if (rawId && repeatedCount === 0) { + return item; + } + + return { + ...item, + id: + repeatedCount === 0 + ? baseId + : buildStableRenderKey([baseId, repeatedCount]), + }; + }); +} + +function NpcEncounterPortrait({ + encounter, + character, + hostileNpcPreset, + battleState, +}: { + encounter: Encounter; + character: Character | null; + hostileNpcPreset: ReturnType | null; + battleState: SceneHostileNpc | null; +}) { + // 详情立绘必须优先服从当前遭遇实例,否则会和画布上点击到的 NPC 形象错位。 + if (encounter.visual) { + return ( + + ); + } + + if (encounter.imageSrc?.trim()) { + return ( + + ); + } + + if (hostileNpcPreset) { + return ( + + ); + } + + if (character?.visual) { + return ( + + ); + } + + if (character) { + return ( + + ); + } + + return ( + + ); +} + function getNpcBadge( encounter: Encounter, affinity: number, @@ -416,6 +560,7 @@ export function AdventureEntityModal({ : null; const npcBattleState = selection?.kind === 'npc' ? (selection.battleState ?? null) : null; + const selectionRenderKey = buildSelectionRenderKey(selection); const archiveCharacter = selection?.kind === 'companion' ? companionCharacter @@ -602,21 +747,26 @@ export function AdventureEntityModal({ } satisfies CharacterChatTarget) : null; const inventory = useMemo( - () => - selection?.kind === 'player' - ? gameState.playerInventory - : selection?.kind === 'companion' && companionCharacter - ? buildCharacterInventoryPreviewItems( - companionCharacter, - gameState.worldType, - ) - : (npcState?.inventory ?? []), + () => { + const rawInventory = + selection?.kind === 'player' + ? gameState.playerInventory + : selection?.kind === 'companion' && companionCharacter + ? buildCharacterInventoryPreviewItems( + companionCharacter, + gameState.worldType, + ) + : (npcState?.inventory ?? []); + + return normalizeInventoryItemRenderIds(rawInventory, selectionRenderKey); + }, [ companionCharacter, gameState.playerInventory, gameState.worldType, npcState?.inventory, selection?.kind, + selectionRenderKey, ], ); const attributeSchema = resolveAttributeSchema( @@ -791,6 +941,7 @@ export function AdventureEntityModal({ {selection && ( ) - ) : npcCharacter ? ( - npcCharacter.visual ? ( - - ) : ( - - ) - ) : hostileNpcPreset ? ( - ) : npcEncounter ? ( - ) : null}
@@ -1165,6 +1291,7 @@ export function AdventureEntityModal({ {selectedContributionRow && detailCharacter && (
- {selectedSkill.buildBuffs.map((buff) => ( + {selectedSkill.buildBuffs.map((buff, index) => ( {buff.name} / {buff.tags.join('、')} /{' '} diff --git a/src/components/CustomWorldEntityCatalog.tsx b/src/components/CustomWorldEntityCatalog.tsx index bc117ed3..211d6330 100644 --- a/src/components/CustomWorldEntityCatalog.tsx +++ b/src/components/CustomWorldEntityCatalog.tsx @@ -61,9 +61,9 @@ interface CustomWorldEntityCatalogProps { const RESULT_TABS: Array<{ id: ResultTab; label: string }> = [ { id: 'world', label: '世界' }, + { id: 'landmarks', label: '场景' }, { id: 'playable', label: '可扮演角色' }, { id: 'story', label: '场景角色' }, - { id: 'landmarks', label: '场景' }, ]; function Section({ @@ -315,6 +315,7 @@ function buildSceneChapterSearchText( .flatMap((chapter) => [ chapter.title, chapter.summary, + chapter.sceneTaskDescription, ...chapter.acts.flatMap((act) => [ act.title, act.summary, @@ -327,6 +328,12 @@ function buildSceneChapterSearchText( .join(' '); } +function buildSceneTaskDescriptionText(sceneChapters: SceneChapterBlueprint[]) { + return compactTextList( + sceneChapters.map((chapter) => chapter.sceneTaskDescription), + )[0] ?? ''; +} + function resolveSceneCardImage(params: { sceneImageSrc?: string | null; sceneChapters: SceneChapterBlueprint[]; @@ -631,6 +638,16 @@ function buildLandmarkSearchText( ].join(' '); } +function buildAttributeSlotSummary( + slot: CustomWorldProfile['attributeSchema']['slots'][number], +) { + return compactTextList([ + slot.combatUseText, + slot.socialUseText, + slot.explorationUseText, + ]).join(' / '); +} + export function CustomWorldEntityCatalog({ profile, previewCharacters, @@ -752,6 +769,9 @@ export function CustomWorldEntityCatalog({ () => normalizeCustomWorldCreatorIntent(profile.creatorIntent), [profile.creatorIntent], ); + const attributeSlots = Array.isArray(profile.attributeSchema?.slots) + ? profile.attributeSchema.slots + : []; const filteredSceneEntries = useMemo(() => { const openingSceneChapters = resolveSceneEntrySceneChapters({ sceneChapters: profile.sceneChapterBlueprints, @@ -762,13 +782,14 @@ export function CustomWorldEntityCatalog({ sceneImageSrc: resolvedCampImageSrc, sceneChapters: openingSceneChapters, }); - const openingSceneEntry = { + const openingSceneEntry = { id: resolvedCampScene.id, kind: 'camp' as const, name: resolvedCampScene.name, description: resolvedCampScene.description, imageSrc: openingSceneImageSrc, sceneChapters: openingSceneChapters, + sceneTaskDescription: buildSceneTaskDescriptionText(openingSceneChapters), actPreviews: buildFallbackSceneActImagePreviews({ sceneChapters: openingSceneChapters, sceneImageSrc: openingSceneImageSrc, @@ -803,7 +824,8 @@ export function CustomWorldEntityCatalog({ name: landmark.name, description: landmark.description, imageSrc: sceneImageSrc, - sceneChapters, + sceneChapters, + sceneTaskDescription: buildSceneTaskDescriptionText(sceneChapters), actPreviews: buildFallbackSceneActImagePreviews({ sceneChapters, sceneImageSrc, @@ -1049,6 +1071,27 @@ export function CustomWorldEntityCatalog({
+
+
+ {attributeSlots.map((slot) => ( +
+
+ {slot.name} +
+
+ {buildAttributeSlotSummary(slot) || slot.definition} +
+
+ ))} +
+
+
{ expect(getHostileNpcSceneBottomOffsetPx({frameHeight: 23})).toBe(-28); }); + it('uses scene npc visual anchors instead of template character foot offsets', () => { + const sceneNpcEncounter = createEncounter({ + characterId: 'hero', + monsterPresetId: 'monster-20', + imageSrc: '/generated-custom-world-npc/shark.png', + }); + const character = createCharacter(); + + expect(getEncounterCharacterOpponentBottom('18%', 68, sceneNpcEncounter, character)) + .toBe('calc(18% + 68px - 132px)'); + expect(getEncounterCharacterBottomOffsetPx(68, sceneNpcEncounter, character)) + .toBe(-64); + }); + + it('lowers scene npc custom visuals even without character ids', () => { + const sceneNpcEncounter = createEncounter({ + visual: { + species: 'aquatic', + body: '章鱼形态', + attire: '深海服饰', + palette: '蓝紫', + signature: '触腕', + }, + }); + + expect(getSceneNpcVisualBottomOffsetPx(sceneNpcEncounter)).toBe(-132); + }); + it('renders affinity effect on the matching hostile npc', () => { const html = renderEntityLayer('npc-liu'); diff --git a/src/components/game-canvas/GameCanvasEntityLayer.tsx b/src/components/game-canvas/GameCanvasEntityLayer.tsx index 2912423d..43848cea 100644 --- a/src/components/game-canvas/GameCanvasEntityLayer.tsx +++ b/src/components/game-canvas/GameCanvasEntityLayer.tsx @@ -21,12 +21,13 @@ import { DialogueBubbleIcon, type GameCanvasEntitySelection, GENERIC_NPC_SCENE_SCALE, - getCharacterBottomOffsetPx, - getCharacterOpponentBottom, getCompanionSlotOffset, + getEncounterCharacterBottomOffsetPx, + getEncounterCharacterOpponentBottom, getHostileNpcSceneBottomOffsetPx, getMonsterWorldLeft, getNpcCombatHpTop, + getSceneNpcVisualBottomOffsetPx, getSceneEntityZIndex, HpBar, mapHostileNpcAnimationToCharacterState, @@ -262,14 +263,18 @@ export function GameCanvasEntityLayer({ npcCharacter ? npcEncounter?.characterId : null, npcCharacter ? null : npcEncounter?.monsterPresetId, ); - const hostileNpcBottomOffsetPx = getHostileNpcSceneBottomOffsetPx(npcMonsterConfig); + const hostileNpcBottomOffsetPx = + npcMonsterConfig + ? getHostileNpcSceneBottomOffsetPx(npcMonsterConfig) + : getSceneNpcVisualBottomOffsetPx(npcEncounter); const opponentBottom = npcCharacter - ? getCharacterOpponentBottom(groundBottom, stageLiftPx, npcCharacter) + ? getEncounterCharacterOpponentBottom(groundBottom, stageLiftPx, npcEncounter, npcCharacter) : `calc(${groundBottom} + ${stageLiftPx}px)`; const entityBottom = `calc(${opponentBottom} + ${(hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx}px)`; const entityBottomOffsetPx = npcCharacter - ? getCharacterBottomOffsetPx( + ? getEncounterCharacterBottomOffsetPx( stageLiftPx, + npcEncounter, npcCharacter, (hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx, ) @@ -363,9 +368,12 @@ export function GameCanvasEntityLayer({ encounter.kind === 'npc' && encounter.monsterPresetId ? monsters.find(item => item.id === encounter.monsterPresetId) ?? null : null; - const peacefulHostileBottomOffsetPx = getHostileNpcSceneBottomOffsetPx(peacefulMonsterConfig); + const peacefulHostileBottomOffsetPx = + peacefulMonsterConfig + ? getHostileNpcSceneBottomOffsetPx(peacefulMonsterConfig) + : getSceneNpcVisualBottomOffsetPx(encounter); const peacefulBottomOffsetPx = peacefulResolvedCharacter - ? getCharacterBottomOffsetPx(stageLiftPx, peacefulResolvedCharacter) + ? getEncounterCharacterBottomOffsetPx(stageLiftPx, encounter, peacefulResolvedCharacter) : stageLiftPx + peacefulHostileBottomOffsetPx; const peacefulNpcSpriteFacing = towardPeacefulPlayer; @@ -380,9 +388,10 @@ export function GameCanvasEntityLayer({ monsterAnchorMeters, ), bottom: encounter.characterId - ? getCharacterOpponentBottom( + ? getEncounterCharacterOpponentBottom( groundBottom, stageLiftPx, + encounter, getCharacterById(encounter.characterId), ) : `calc(${groundBottom} + ${stageLiftPx + peacefulHostileBottomOffsetPx}px)`, diff --git a/src/components/game-canvas/GameCanvasShared.tsx b/src/components/game-canvas/GameCanvasShared.tsx index 8879e4d3..45088c0c 100644 --- a/src/components/game-canvas/GameCanvasShared.tsx +++ b/src/components/game-canvas/GameCanvasShared.tsx @@ -72,6 +72,7 @@ export const ROLE_CHARACTER_FRAME_CLASS = 'flex h-28 w-28 items-end justify-cent export const ROLE_CHARACTER_SPRITE_CLASS = 'h-full w-full scale-[1.32] origin-bottom'; export const ROLE_CHARACTER_SCENE_IMAGE_SCALE = 1.32; export const GENERIC_NPC_SCENE_SCALE = 1.72; +export const SCENE_NPC_CUSTOM_VISUAL_GROUND_OFFSET_PX = 132; const DEFAULT_IMAGE_STYLE: React.CSSProperties = { imageRendering: 'pixelated', objectPosition: 'center bottom', @@ -169,6 +170,49 @@ export function getCharacterOpponentBottom( return `calc(${groundBottom} + ${stageLiftPx}px - ${groundOffset}px)`; } +export function hasEncounterCustomSceneVisual(encounter: Encounter | null | undefined) { + return Boolean( + encounter?.visual + || encounter?.imageSrc?.trim(), + ); +} + +export function getEncounterCharacterGroundOffset( + encounter: Encounter | null | undefined, + character: Character | null | undefined, +) { + if (hasEncounterCustomSceneVisual(encounter)) { + // 场景 NPC 的 AI 形象通常是方图或组合视觉,不能沿用模板角色脚底偏移。 + return SCENE_NPC_CUSTOM_VISUAL_GROUND_OFFSET_PX; + } + + return character?.groundOffsetY ?? 22; +} + +export function getEncounterCharacterOpponentBottom( + groundBottom: string, + stageLiftPx: number, + encounter: Encounter | null | undefined, + character: Character | null | undefined, +) { + return `calc(${groundBottom} + ${stageLiftPx}px - ${getEncounterCharacterGroundOffset(encounter, character)}px)`; +} + +export function getEncounterCharacterBottomOffsetPx( + stageLiftPx: number, + encounter: Encounter | null | undefined, + character: Character | null | undefined, + extraOffsetPx = 0, +) { + return stageLiftPx - getEncounterCharacterGroundOffset(encounter, character) + extraOffsetPx; +} + +export function getSceneNpcVisualBottomOffsetPx(encounter: Encounter | null | undefined) { + return hasEncounterCustomSceneVisual(encounter) + ? -SCENE_NPC_CUSTOM_VISUAL_GROUND_OFFSET_PX + : 0; +} + export function getHostileNpcSceneBottomOffsetPx( monster: HostileNpcSceneAnchorConfig | null | undefined, ) { @@ -232,9 +276,10 @@ export function getEntityEffectBottom({ } if (targetHostileNpc.encounter?.characterId) { - return getCharacterOpponentBottom( + return getEncounterCharacterOpponentBottom( groundBottom, stageLiftPx + (targetHostileNpc.yOffset ?? 0) + anchorOffsetY, + targetHostileNpc.encounter, getCharacterById(targetHostileNpc.encounter.characterId), ); } diff --git a/src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx b/src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx index b38eda2f..4ec14970 100644 --- a/src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx +++ b/src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx @@ -412,6 +412,15 @@ function buildDefaultSceneActBackgroundPrompt(params: { return `${sceneText}的${phaseText}画面,${roleText}与玩家隔着可站立空间形成对峙,环境里保留“${params.eventDescription}”的冲突痕迹与清晰氛围。`; } +function buildDefaultSceneTaskDescription(landmark: CustomWorldLandmark) { + const sceneName = landmark.name.trim() || '当前场景'; + const sceneDescription = landmark.description.trim(); + if (!sceneDescription) { + return `首次进入${sceneName}时,确认当前场景的核心异常、关键角色与下一步行动方向。`; + } + return `首次进入${sceneName}时,围绕${sceneDescription}确认核心任务、关键角色与下一步行动。`; +} + function buildDefaultSceneChapterBlueprint(params: { landmark: CustomWorldLandmark; fallbackImageSrc?: string | null; @@ -432,7 +441,7 @@ function buildDefaultSceneChapterBlueprint(params: { sceneId: params.landmark.id, title: params.chapterTitle?.trim() || params.landmark.name.trim() || '场景章节', summary: params.chapterSummary?.trim() || params.landmark.description.trim(), - sceneTaskDescription: params.landmark.description.trim(), + sceneTaskDescription: buildDefaultSceneTaskDescription(params.landmark), linkedThreadIds: dedupeTextValues(params.linkedThreadIds ?? []), linkedLandmarkIds: dedupeTextValues([ params.landmark.id, @@ -502,11 +511,12 @@ function sanitizeSceneChapterBlueprint(params: { availableSceneNpcIdSet.size > 0 ? candidateNpcIds.filter((npcId) => availableSceneNpcIdSet.has(npcId)) : candidateNpcIds; + // 中文注释:已有幕只信任本幕保存的槽位;只有缺少整份幕蓝图的旧草稿才从场景角色里兜底,避免配置第一幕时把角色串到其他幕。 const resolvedEncounterNpcIds = encounterNpcIds.length > 0 ? encounterNpcIds - : availableSceneNpcIds.length > 0 - ? availableSceneNpcIds.slice(0, 1) + : currentAct + ? [] : fallbackAct.encounterNpcIds; const primaryNpcId = resolvedEncounterNpcIds[0] ?? ''; const oppositeNpcId = currentAct?.oppositeNpcId?.trim() || primaryNpcId; @@ -554,6 +564,13 @@ function sanitizeSceneChapterBlueprint(params: { id: params.chapter?.id?.trim() || fallbackChapter.id, title: params.chapter?.title?.trim() || fallbackChapter.title, summary: params.chapter?.summary?.trim() || fallbackChapter.summary, + sceneTaskDescription: (() => { + const currentTask = params.chapter?.sceneTaskDescription?.trim() ?? ''; + const sceneDescription = params.landmark.description.trim(); + return currentTask && currentTask !== sceneDescription + ? currentTask + : fallbackChapter.sceneTaskDescription; + })(), linkedThreadIds: dedupeTextValues(params.chapter?.linkedThreadIds ?? []), linkedLandmarkIds: dedupeTextValues([ params.landmark.id, @@ -1651,9 +1668,10 @@ function SceneActNpcSlotPickerModal({ -
+
+
当前角色 @@ -1746,7 +1764,9 @@ function SceneActNpcSlotPickerModal({
-
+
+ +
{selectedNpc ? ( ) : null} - { @@ -5752,7 +5771,7 @@ export function LandmarkEditor({ setIsCloseConfirmOpen(true); }; - const updateSceneActDraft = ( + const updateSceneChapterDraft = ( updater: (chapter: SceneChapterBlueprint) => SceneChapterBlueprint, ) => { setSceneChapterDraft((current) => @@ -5780,7 +5799,7 @@ export function LandmarkEditor({ index: number, updater: (act: SceneActBlueprint) => SceneActBlueprint, ) => { - updateSceneActDraft((current) => ({ + updateSceneChapterDraft((current) => ({ ...current, acts: current.acts.map((act, actIndex) => actIndex === index ? updater(act) : act, @@ -5794,7 +5813,7 @@ export function LandmarkEditor({ return; } - updateSceneActDraft((current) => { + updateSceneChapterDraft((current) => { const nextActCount = current.acts.length + 1; return { ...current, @@ -5821,14 +5840,14 @@ export function LandmarkEditor({ return; } - updateSceneActDraft((current) => ({ + updateSceneChapterDraft((current) => ({ ...current, acts: current.acts.filter((_act, actIndex) => actIndex !== index), })); }; const moveSceneAct = (index: number, delta: number) => { - updateSceneActDraft((current) => ({ + updateSceneChapterDraft((current) => ({ ...current, acts: moveArrayItem(current.acts, index, index + delta), })); @@ -5836,7 +5855,7 @@ export function LandmarkEditor({ const updateSceneActSharedBackground = (imageSrc?: string | null) => { const resolvedImageSrc = imageSrc?.trim() || compatibilityImageSrc || ''; - updateSceneActDraft((current) => ({ + updateSceneChapterDraft((current) => ({ ...current, acts: current.acts.map((act) => ({ ...act, @@ -6004,6 +6023,18 @@ export function LandmarkEditor({ rows={5} /> + +