This commit is contained in:
2026-04-25 13:44:48 +08:00
parent 03acbc5cb1
commit 2ebb7bf253
44 changed files with 1003 additions and 250 deletions

View File

@@ -123,6 +123,9 @@ WECHAT_MOCK_AVATAR_URL=""
# Model name for chat completions.
VITE_LLM_MODEL="doubao-1-5-pro-32k-character-250715"
# Optional: enable upstream web search for RPG story text generation.
RPG_LLM_WEB_SEARCH_ENABLED="true"
# Server-side DashScope endpoint and API key used by the local scene-image proxy.
DASHSCOPE_BASE_URL="https://dashscope.aliyuncs.com/api/v1"
DASHSCOPE_API_KEY="YOUR_DASHSCOPE_API_KEY"

View File

@@ -0,0 +1,29 @@
# RPG 运行时直读世界草稿 Profile 检查 2026-04-25
## 结论
RPG 运行时进入游戏时不应再通过 `resultPreview.preview` 或 legacy runtime profile 做中间转换,主数据源统一为 Agent session 的 `draftProfile`
本次检查确认:
1. Rust 侧 `custom_world_foundation_draft` 已直接产出 `draftProfile`
2. 前端原先 `buildCustomWorldProfileFromAgentSession()` 仍只读取 `session.resultPreview.preview`,这会绕过草稿 profile 中已经存在的角色形象、关系、压力等字段。
3. 角色选择页与游戏内角色本身可以消费 `CustomWorldProfile.playableNpcs[].imageSrc`断点在“session -> profile”的入口而不是角色选择页。
4. “进入世界”按钮原先还会先执行 `sync_result_profile`,把当前结果页旧快照再同步回 session如果结果页 profile 没有最新角色图,会在进入角色选择页前覆盖掉 `draftProfile` 中的正确形象。
## 已修正
- `buildCustomWorldProfileFromAgentSession()` 改为直接归一化 `session.draftProfile`
- `resultPreview` 只保留为发布质量、blocker、预览外壳信息不再作为进入游戏 profile 的数据源。
- Agent 草稿结果进入游戏时直接使用最新 `agentSessionProfile`,不再把当前结果页 profile 回写成新的运行时 profile。
- 前端 `normalizeCustomWorldProfileRecord()` 补齐 rs 草稿角色字段兼容:
- `publicMask/publicIdentity` -> `description/visualDescription/personality` fallback
- `currentPressure/hiddenHook` -> `backstory/actionDescription/sceneVisualDescription` fallback
- `relationToPlayer` -> `motivation/relationshipHooks` fallback
- `imageSrc/generatedVisualAssetId/generatedAnimationSetId/animationMap` 保持直通
## 后续约束
- 新 RPG 运行时链路只允许读取 `draftProfile`
- 不再为进入游戏构造额外 legacy profile也不再把 `resultPreview.preview` 当作运行时真相源。
- 如果草稿中新增角色、场景、物品字段,应优先扩展 `draftProfile` 的归一化读取,而不是增加中间转换结构。

View File

@@ -197,3 +197,8 @@
这一轮最关键的经验是:
**游戏 UI 的移动端优化,本质不是把元素缩小,而是重组视觉重心、固定操作锚点、让焦点内容在一屏内自然成立。**
### 10.1 可扮演角色形象预览保持 1:1
- 可扮演角色的形象预览容器统一使用 1:1 方形,入口选择轮播、角色资产工坊和结果页角色卡片都不能用纵向长卡片去拉伸预览图。
- 预览图片本身使用 `object-contain`,保证 AI 生成主形象、模板像素角色和运行时动画都在方形容器内完整显示,不裁切角色主体。
- 卡片可以在方形预览下方放角色名、称号、状态等信息,但这些文本区不能反向影响预览区比例。

View File

@@ -0,0 +1,20 @@
# 可扮演角色外观模板字段删除经验
## 背景
可扮演角色曾通过 `templateCharacterId` 保存“外观模板”选择。当前角色主形象已经由 `visualDescription``imageSrc` 与生成资产链路承接,外观模板不应继续作为可扮演角色档案字段暴露给用户编辑或持久化。
## 落地边界
- 可扮演角色数据结构不再声明 `templateCharacterId`
- 可扮演角色编辑面板删除“外观模板”下拉项,保存时不再补默认模板。
- 草稿规范化与资料库读取时丢弃旧数据中的 `templateCharacterId`,避免旧快照把字段带回新数据。
- 运行时如需要基础动作、默认立绘或战斗标签,只能通过角色文本、参考 profile 或固定 fallback 规则临时推导模板,不再写回角色字段。
- 资产工坊可以继续接收运行时临时模板提示,但该提示不得成为可扮演角色的持久字段。
## 验收要点
- 新建或编辑可扮演角色时界面不出现“外观模板”。
- 保存后的 `playableNpcs` 条目不包含 `templateCharacterId`
- 旧存档带有 `templateCharacterId` 时,进入当前规范化链路后会被丢弃。
- 自定义世界运行角色仍能通过推导模板获得基础动作与默认占位图,不因字段删除而中断。

View File

@@ -26,6 +26,7 @@ RPG 草稿生成进入底稿素材阶段后,角色主形象与场景幕背景
- 幕背景图失败文案必须带第几章、第几幕和幕标题不能只显示“第1幕 / 第2幕 / 第3幕”否则多章节同名幕会被用户误认为同一失败项重复上报。
- 中止或部分失败前必须持久化已经成功生成的部分底稿到会话 `draftProfile`,不能因为某个角色或某一幕失败而丢掉其它已生成的 `imageSrc / generatedVisualAssetId / backgroundImageSrc / backgroundAssetId`
- 每一幕自动生图必须记录 operation、session、第几章、第几幕、sceneId、sceneName、attempt、elapsedMs 与供应商真实错误,避免再次出现只看到“生成幕背景图失败”但无法定位哪张图、哪次请求、哪个上游原因的问题。
- 前端看到 `draft_foundation` operation completed 后,不能只延迟一次就读取 `resultPreview`SpacetimeDB 写入、API 读模型和前端状态同步之间可能有短暂时差,必须短轮询等待结果页 profile 可用后再自动跳转到草稿页,避免卡在“底稿已整理”。
- 前端 `CharacterAnimator` 对带 `generatedVisualAssetId` 但尚无 `animationMap` 的自定义角色,所有状态优先渲染生成主图;只有真正发布了动作集后才按动作帧播放,避免运行或战斗状态回落到模板 sprite。
## 后续注意
@@ -34,3 +35,12 @@ RPG 草稿生成进入底稿素材阶段后,角色主形象与场景幕背景
## 2026-04-25 补充:开局场景也必须逐幕生成背景图
本次排查发现旧草稿合成只从 `landmarks` 编译 `sceneChapterBlueprints`,导致 `camp` 开局场景只有 `camp.imageSrc`,没有进入 `sceneChapterBlueprints[*].acts[*]` 的幕背景生成队列。后续实现必须遵守:
1. `camp` 开局场景必须作为 `sceneChapterBlueprints[0]` 写入,`sceneId` 默认使用 `camp.id`,缺失时使用 `camp-1`
2. `camp.actBackgroundPromptTexts` 必须包含 3 条逐幕画面描述,并和普通场景一样生成 `acts[*].backgroundImageSrc`
3. 结果页场景目录可用场景图兜底展示旧草稿的幕缩略图,但新草稿不能只依赖兜底,必须让开局场景真实进入幕背景图生成链路。
4. 手动同步场景资产时,必须同时更新 `sceneChapterBlueprints` 与兼容字段 `sceneChapters`,当前主链以 `sceneChapterBlueprints` 为准。

View File

@@ -95,3 +95,28 @@ Rust 首版返回:
4. `Upstream(status=429)` -> `429 TOO_MANY_REQUESTS`
5. 其他 `Upstream` -> `502 BAD_GATEWAY`
6. `stream=true` 首版直接返回 `501 NOT_IMPLEMENTED`
## 8. 角色扮演模型联网搜索补充2026-04-25
### 8.1 目标
角色扮演运行时调用文本模型生成剧情正文、NPC 对话、战斗演出文本时,需要默认允许模型使用上游联网搜索能力,提升现实题材、时代背景、地名器物、文化细节的准确度。
### 8.2 落地范围
1. `platform-llm``LlmTextRequest` 增加 `enable_web_search` 布尔开关,默认 `false`,避免影响普通平台代理和非剧情调用。
2. `api-server` 配置增加 `GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED` / `RPG_LLM_WEB_SEARCH_ENABLED`,默认 `true`
3.`runtime_story` 兼容链路中的角色扮演剧情文本请求按配置开启联网搜索。
4. `/api/llm/chat/completions` 通用代理不默认开启联网搜索,避免外部调用方在无感情况下产生额外成本或不可预期内容来源。
### 8.3 上游请求口径
1. 当前默认文本模型走火山方舟 OpenAI 兼容 Chat Completions 路由。
2. 联网搜索开启时,请求体追加 `web_search_options: {}`;关闭时不序列化该字段。
3. 若后续迁移到 Responses API 或更换 provider`platform-llm` 统一收口字段映射,业务层仍只使用 `enable_web_search` 语义开关。
### 8.4 验收
1. `platform-llm` 单测能捕获开启搜索时上游 JSON 包含 `web_search_options`
2. `api-server` 配置单测能验证角色扮演搜索开关默认开启、环境变量可关闭。
3. 角色扮演剧情、NPC 对话、推理战斗文本请求都通过同一辅助函数设置搜索开关,避免漏接。

View File

@@ -0,0 +1,57 @@
# 自定义世界草稿场景幕事件与任务字段落地设计2026-04-25
## 背景
自定义世界 Agent 生成第一版草稿时,已经会为 `sceneChapterBlueprints[*].acts[*]` 生成逐幕背景图描述,并为场景写入基础描述、出场角色等信息。后续运行时需要更稳定的章节任务上下文,因此草稿阶段必须同时生成:
1. 每一幕的对面角色。
2. 每一幕的事件描述。
3. 每个场景的场景任务描述。
## 字段契约
### SceneActBlueprint
新增字段:
- `oppositeNpcId: string`
- 当前幕“对面的角色”,优先使用该场景 `sceneNpcNames` / `encounterNpcIds` 的第一个角色。
- 若当前场景暂未绑定角色,使用空字符串,不在草稿合成阶段伪造角色 ID。
- `eventDescription: string`
- 描述当前幕正在发生的事件。
- 必须强绑定 `oppositeNpcId` / `primaryNpcId` 所指角色,写清该角色的行动、阻碍、试探、求助或冲突。
- 默认生成兜底规则:`第N幕中玩家在当前场景遭遇/处理与某角色直接相关的事件,并推动当前场景问题升级或转向。`
兼容字段:
- `primaryNpcId` 继续保留,默认等于 `oppositeNpcId`,避免旧运行时代码读取不到主角色。
- `encounterNpcIds` 继续保留,至少承载当前场景可出场角色名称/ID。
### SceneChapterBlueprint
新增字段:
- `sceneTaskDescription: string`
- 当前场景的核心任务描述。
- 文本会作为游戏中首次进入某个场景生成章节任务的关键上下文。
- 必须结合场景描述、场景入口钩子、出场角色与 3 幕事件,说明玩家首次进入该场景时要完成什么。
## 生成链路
1. `api-server``custom_world_foundation_draft.rs` 是第一版草稿的真实生成入口。
2. LLM 提示词需要要求:
- `camp.sceneTaskDescription` 默认生成开局场景核心任务。
- `landmarks[*].sceneTaskDescription` 默认生成关键场景核心任务。
- `actEventDescriptions` 恰好 3 条,对应每一幕事件。
3. 后端合成 `sceneChapterBlueprints` 时把这些源字段落到:
- `sceneChapterBlueprints[*].sceneTaskDescription`
- `sceneChapterBlueprints[*].acts[*].oppositeNpcId`
- `sceneChapterBlueprints[*].acts[*].eventDescription`
4. 若 LLM 遗漏字段,归一化阶段用场景描述、入口钩子、角色名单生成中文默认值,保证草稿阶段字段非空。
5. 前端类型与归一化逻辑必须允许读取这些字段,旧草稿缺字段时仍自动补默认值。
## 非目标
- 不新增 UI 说明文案。
- 不迁移或兼容 `server-node`
- 不改变现有幕背景图生成队列与资产写回链路。

View File

@@ -409,7 +409,6 @@ export function createRpgCreationPublishedProfileFixture(): CustomWorldProfileRe
style: '机动周旋',
},
],
templateCharacterId: 'archer-hero',
})),
storyNpcs: draft.storyNpcs.map((role) => ({
id: role.id,

View File

@@ -80,6 +80,7 @@ pub struct AppConfig {
pub llm_request_timeout_ms: u64,
pub llm_max_retries: u32,
pub llm_retry_backoff_ms: u64,
pub rpg_llm_web_search_enabled: bool,
pub dashscope_base_url: String,
pub dashscope_api_key: Option<String>,
pub dashscope_scene_image_model: String,
@@ -168,6 +169,7 @@ impl Default for AppConfig {
llm_request_timeout_ms: DEFAULT_REQUEST_TIMEOUT_MS,
llm_max_retries: DEFAULT_MAX_RETRIES,
llm_retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS,
rpg_llm_web_search_enabled: true,
dashscope_base_url: "https://dashscope.aliyuncs.com/api/v1".to_string(),
dashscope_api_key: None,
dashscope_scene_image_model: "wan2.2-t2i-flash".to_string(),
@@ -466,6 +468,13 @@ impl AppConfig {
config.llm_retry_backoff_ms = llm_retry_backoff_ms;
}
if let Some(rpg_llm_web_search_enabled) = read_first_bool_env(&[
"GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED",
"RPG_LLM_WEB_SEARCH_ENABLED",
]) {
config.rpg_llm_web_search_enabled = rpg_llm_web_search_enabled;
}
if let Some(dashscope_base_url) = read_first_non_empty_env(&["DASHSCOPE_BASE_URL"]) {
config.dashscope_base_url = dashscope_base_url;
}
@@ -814,4 +823,24 @@ mod tests {
std::env::remove_var("GENARRATIVE_SPACETIME_POOL_SIZE");
}
}
#[test]
fn from_env_reads_rpg_llm_web_search_switch() {
let _guard = ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.expect("env lock should not poison");
unsafe {
std::env::remove_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED");
std::env::set_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED", "false");
}
let config = AppConfig::from_env();
assert!(!config.rpg_llm_web_search_enabled);
unsafe {
std::env::remove_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED");
}
}
}

View File

@@ -625,6 +625,9 @@ fn build_custom_world_framework_prompt(setting_text: &str) -> 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(),
@@ -635,6 +638,9 @@ fn build_custom_world_framework_prompt(setting_text: &str) -> 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(),
@@ -650,7 +656,7 @@ fn build_custom_world_framework_json_repair_prompt(response_text: &str) -> Strin
"顶层必须只包含name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。",
"不要输出 playableNpcs、storyNpcs、landmarks、items 或任何其他字段。",
"majorFactions 与 coreConflicts 必须是字符串数组。",
"camp 必须是对象且包含name、description、dangerLevel。",
"camp 必须是对象且包含name、description、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions、dangerLevel。",
"原始文本:",
response_text.trim(),
].join("\n")
@@ -751,7 +757,9 @@ fn build_custom_world_landmark_seed_batch_prompt(
" \"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(),
@@ -761,10 +769,12 @@ fn build_custom_world_landmark_seed_batch_prompt(
format!("- 必须生成恰好 {batch_count} 个关键场景。"),
"- 这是一个完全独立的自定义世界;地点名称必须直接服务玩家输入主题。".to_string(),
"- 名称必须具体且互不重复,不要使用 地点1、场景1 之类的占位名。".to_string(),
"- 每个地点只保留name、description、visualDescription、actBackgroundPromptTexts、dangerLevel。".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(),
@@ -783,8 +793,8 @@ 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、actBackgroundPromptTexts、dangerLevel。".to_string(),
"如果缺少字段字符串补空字符串actBackgroundPromptTexts 补空数组dangerLevel 补 medium。".to_string(),
"每个地点只包含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(),
@@ -1061,13 +1071,14 @@ fn build_foundation_draft_profile_from_framework(
)])
}),
);
object.insert("camp".to_string(), framework.get("camp").cloned().unwrap_or_else(|| json!({ "name": "开局归处", "description": "玩家进入世界后的第一处落脚点。", "dangerLevel": "low" })));
let camp = framework.get("camp").cloned().unwrap_or_else(|| json!({ "name": "开局归处", "description": "玩家进入世界后的第一处落脚点。", "dangerLevel": "low" }));
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_landmarks(&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(
@@ -1077,45 +1088,79 @@ fn build_foundation_draft_profile_from_framework(
normalize_foundation_draft_profile(JsonValue::Object(object), session)
}
fn build_scene_chapter_blueprints_from_camp_and_landmarks(
camp: &JsonValue,
landmarks: &[JsonValue],
) -> Vec<JsonValue> {
let mut blueprints = Vec::with_capacity(landmarks.len() + 1);
blueprints.push(build_scene_chapter_blueprint_from_scene(
camp,
0,
"camp",
"开局归处",
));
blueprints.extend(build_scene_chapter_blueprints_from_landmarks(landmarks));
blueprints
}
fn build_scene_chapter_blueprints_from_landmarks(landmarks: &[JsonValue]) -> Vec<JsonValue> {
// 幕背景描述必须来自关键场景生成步骤,不能在草稿合成阶段再用规则句拼接。
landmarks
.iter()
.enumerate()
.map(|(chapter_index, landmark)| {
let scene_name = json_text(landmark, "name")
.unwrap_or_else(|| format!("关键场景{}", chapter_index + 1));
let scene_id = json_text(landmark, "id")
.unwrap_or_else(|| format!("saved-landmark-{}", chapter_index + 1));
let summary = json_text(landmark, "description").unwrap_or_default();
let act_prompts =
json_string_array(landmark, "actBackgroundPromptTexts").unwrap_or_default();
let scene_npc_names = json_string_array(landmark, "sceneNpcNames").unwrap_or_default();
json!({
"id": scene_id.clone(),
"sceneId": scene_id.clone(),
"title": scene_name,
"summary": summary,
"linkedLandmarkIds": [scene_id.clone()],
"acts": (0..3)
.map(|act_index| build_scene_act_blueprint_from_landmark(
&scene_id,
&summary,
&act_prompts,
&scene_npc_names,
act_index,
))
.collect::<Vec<_>>(),
})
build_scene_chapter_blueprint_from_scene(
landmark,
chapter_index,
"saved-landmark",
"关键场景",
)
})
.collect()
}
fn build_scene_chapter_blueprint_from_scene(
scene: &JsonValue,
chapter_index: usize,
id_prefix: &str,
fallback_name_prefix: &str,
) -> 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 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));
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();
json!({
"id": scene_id.clone(),
"sceneId": scene_id.clone(),
"title": scene_name,
"summary": summary,
"sceneTaskDescription": scene_task_description,
"linkedLandmarkIds": [scene_id.clone()],
"acts": (0..3)
.map(|act_index| build_scene_act_blueprint_from_landmark(
&scene_id,
&summary,
&act_prompts,
&act_events,
&scene_npc_names,
act_index,
))
.collect::<Vec<_>>(),
})
}
fn build_scene_act_blueprint_from_landmark(
scene_id: &str,
scene_summary: &str,
act_prompts: &[String],
act_events: &[String],
scene_npc_names: &[String],
act_index: usize,
) -> JsonValue {
@@ -1130,6 +1175,16 @@ fn build_scene_act_blueprint_from_landmark(
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("");
let opposite_npc_id = scene_npc_names.first().cloned().unwrap_or_default();
let event_description = act_events
.get(act_index)
.map(String::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| {
build_default_act_event_description(scene_summary, opposite_npc_id.as_str(), act_index)
});
// 缺失时保留空值,让后续生图前校验暴露底稿质量问题。
json!({
"id": format!("{}-act-{}", scene_id, act_index + 1),
@@ -1138,9 +1193,42 @@ fn build_scene_act_blueprint_from_landmark(
"summary": scene_summary,
"backgroundPromptText": prompt,
"encounterNpcIds": scene_npc_names,
"primaryNpcId": opposite_npc_id,
"oppositeNpcId": opposite_npc_id,
"eventDescription": event_description,
})
}
fn build_default_scene_task_description(scene_name: &str, scene_summary: &str) -> String {
if scene_summary.trim().is_empty() {
return format!("首次进入{scene_name}时,确认当前场景的核心异常、关键角色与下一步行动方向。");
}
format!("首次进入{scene_name}时,围绕{scene_summary}确认核心异常、关键角色与下一步行动方向。")
}
fn build_default_act_event_description(
scene_summary: &str,
opposite_npc_id: &str,
act_index: usize,
) -> String {
let role_text = if opposite_npc_id.trim().is_empty() {
"当前场景关键角色"
} else {
opposite_npc_id.trim()
};
let scene_text = if scene_summary.trim().is_empty() {
"场景内的主线压力"
} else {
scene_summary.trim()
};
format!(
"{}幕中,玩家与{}正面接触,围绕{}处理一件会改变局势走向的事件。",
act_index + 1,
role_text,
scene_text,
)
}
fn normalize_framework_shape(framework: &mut JsonValue, setting_text: &str) {
if !framework.is_object() {
*framework = json!({});
@@ -1183,6 +1271,83 @@ fn normalize_framework_shape(framework: &mut JsonValue, setting_text: &str) {
if !object.get("camp").is_some_and(JsonValue::is_object) {
object.insert("camp".to_string(), json!({ "name": "开局归处", "description": "玩家进入世界后的第一处落脚点。", "dangerLevel": "low" }));
}
if let Some(camp) = object.get_mut("camp").and_then(JsonValue::as_object_mut) {
let camp_name = camp
.get("name")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("开局归处")
.to_string();
let camp_description = camp
.get("description")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("玩家进入世界后的第一处落脚点。")
.to_string();
camp.insert("name".to_string(), JsonValue::String(camp_name.clone()));
camp.insert(
"description".to_string(),
JsonValue::String(camp_description.clone()),
);
if !camp
.get("sceneTaskDescription")
.and_then(JsonValue::as_str)
.map(str::trim)
.is_some_and(|value| !value.is_empty())
{
camp.insert(
"sceneTaskDescription".to_string(),
JsonValue::String(build_default_scene_task_description(
camp_name.as_str(),
camp_description.as_str(),
)),
);
}
if !camp
.get("actBackgroundPromptTexts")
.and_then(JsonValue::as_array)
.is_some_and(|items| items.len() == 3)
{
// 中文注释:开局场景也必须进入逐幕生图队列;模型漏字段时用 camp 信息生成可用的三幕画面描述。
camp.insert(
"actBackgroundPromptTexts".to_string(),
JsonValue::Array(
(0..3)
.map(|index| {
JsonValue::String(format!(
"{}{}幕,{},画面保留玩家站位、近景可交互物件与远景世界压力。",
camp_name,
index + 1,
camp_description,
))
})
.collect(),
),
);
}
if !camp
.get("actEventDescriptions")
.and_then(JsonValue::as_array)
.is_some_and(|items| items.len() == 3)
{
camp.insert(
"actEventDescriptions".to_string(),
JsonValue::Array(
(0..3)
.map(|index| {
JsonValue::String(build_default_act_event_description(
camp_description.as_str(),
"开局关键角色",
index,
))
})
.collect(),
),
);
}
}
}
fn build_framework_summary_text(framework: &JsonValue, max_landmarks: usize) -> String {
@@ -1516,6 +1681,25 @@ fn normalize_scene_chapter_blueprint(chapter: JsonValue) -> JsonValue {
.filter(|value| !value.is_empty())
.unwrap_or("第一幕");
object.insert("title".to_string(), JsonValue::String(title.to_string()));
let summary = object
.get("summary")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| "第一幕用于让玩家进入当前世界的主线矛盾。".to_string());
object.insert("summary".to_string(), JsonValue::String(summary.clone()));
let scene_task_description = object
.get("sceneTaskDescription")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| build_default_scene_task_description(title, summary.as_str()));
object.insert(
"sceneTaskDescription".to_string(),
JsonValue::String(scene_task_description),
);
let acts = object
.get("acts")
.and_then(JsonValue::as_array)
@@ -1569,6 +1753,57 @@ fn normalize_scene_act_blueprint(act: JsonValue, index: usize) -> JsonValue {
"backgroundPromptText".to_string(),
JsonValue::String(background_prompt),
);
let encounter_npc_ids = object
.get("encounterNpcIds")
.and_then(JsonValue::as_array)
.map(|items| {
items
.iter()
.filter_map(|entry| entry.as_str().map(str::trim))
.filter(|value| !value.is_empty())
.map(|value| JsonValue::String(value.to_string()))
.collect::<Vec<_>>()
})
.unwrap_or_default();
let opposite_npc_id = object
.get("oppositeNpcId")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.or_else(|| {
object
.get("primaryNpcId")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
})
.map(ToOwned::to_owned)
.or_else(|| {
encounter_npc_ids
.first()
.and_then(JsonValue::as_str)
.map(ToOwned::to_owned)
})
.unwrap_or_default();
let event_description = object
.get("eventDescription")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.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(
"primaryNpcId".to_string(),
JsonValue::String(opposite_npc_id.clone()),
);
object.insert("oppositeNpcId".to_string(), JsonValue::String(opposite_npc_id));
object.insert(
"eventDescription".to_string(),
JsonValue::String(event_description),
);
JsonValue::Object(object)
}
@@ -1577,6 +1812,7 @@ fn build_fallback_scene_chapter_blueprint() -> JsonValue {
"id": "chapter-act-1",
"title": "第一幕",
"summary": "第一幕用于让玩家进入当前世界的主线矛盾,并看见最初的风险与方向。",
"sceneTaskDescription": "首次进入当前场景时,确认主线矛盾、关键角色与下一步追查方向。",
"acts": [build_fallback_scene_act()],
})
}
@@ -1591,6 +1827,10 @@ fn build_fallback_scene_act_with_index(index: usize) -> JsonValue {
"title": if index == 0 { "开场场景幕".to_string() } else { format!("{}", index + 1) },
"summary": "玩家被推入第一波局势,必须先确认站位、威胁和下一步追查方向。",
"backgroundPromptText": "",
"encounterNpcIds": [],
"primaryNpcId": "",
"oppositeNpcId": "",
"eventDescription": build_default_act_event_description("玩家被推入第一波局势,必须先确认站位、威胁和下一步追查方向。", "", index),
})
}
@@ -1769,6 +2009,42 @@ mod tests {
);
}
#[test]
fn scene_chapter_blueprints_include_opening_camp_acts() {
let mut framework = json!({
"camp": {
"name": "萧家祖宅",
"description": "玩家开局并成长的家族祖宅。",
"dangerLevel": "low"
}
});
normalize_framework_shape(&mut framework, "废柴少年的逆袭传奇");
let camp = framework
.get("camp")
.expect("camp should exist after normalize");
let landmarks = vec![json!({
"id": "landmark-duel-ground",
"name": "萧家斗技场",
"description": "萧家子弟修炼斗技、比试的场所。",
"actBackgroundPromptTexts": ["斗技台晨雾未散,石阶旁少年们列队观望。", "木桩与兵器架围出训练区,族徽旗帜在风里猎猎。", "暮色压下斗技场,中央擂台留出一对一交锋空间。"]
})];
let blueprints = build_scene_chapter_blueprints_from_camp_and_landmarks(camp, &landmarks);
let opening_chapter = &blueprints[0];
let opening_acts = opening_chapter
.get("acts")
.and_then(JsonValue::as_array)
.expect("opening camp acts should exist");
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_eq!(blueprints.len(), 2);
}
#[test]
fn normalize_scene_act_keeps_missing_background_prompt_empty() {
let act = normalize_scene_act_blueprint(
@@ -1943,6 +2219,15 @@ mod tests {
.and_then(JsonValue::as_str)
.is_some()
);
assert_eq!(
draft_profile
.get("sceneChapterBlueprints")
.and_then(JsonValue::as_array)
.and_then(|entries| entries.first())
.and_then(|entry| entry.get("sceneId"))
.and_then(JsonValue::as_str),
Some("camp-1")
);
assert_eq!(
draft_profile
.get("sceneChapterBlueprints")
@@ -1950,8 +2235,8 @@ mod tests {
.and_then(|entries| entries.first())
.and_then(|entry| entry.get("acts"))
.and_then(JsonValue::as_array)
.map(|entries| !entries.is_empty()),
Some(true)
.map(Vec::len),
Some(3)
);
}

View File

@@ -45,6 +45,7 @@ pub async fn proxy_llm_chat_completions(
.map(map_chat_message)
.collect::<Vec<_>>(),
max_tokens: None,
enable_web_search: false,
};
let response = llm_client

View File

@@ -45,6 +45,7 @@ pub(super) async fn generate_ai_story_text(
LlmMessage::user(user_prompt),
]);
request.max_tokens = Some(700);
apply_rpg_web_search(state, &mut request);
llm_client
.request_text(request)
@@ -69,6 +70,7 @@ pub(super) async fn generate_action_story_payload(
if function_id == "npc_chat" || function_id == "story_opening_camp_dialogue" {
return generate_npc_dialogue_payload(
llm_client,
state.config.rpg_llm_web_search_enabled,
game_state,
request,
action_text,
@@ -81,6 +83,7 @@ pub(super) async fn generate_action_story_payload(
if should_generate_reasoned_combat_story(battle) {
return generate_reasoned_story_payload(
llm_client,
state.config.rpg_llm_web_search_enabled,
game_state,
request,
action_text,
@@ -94,8 +97,13 @@ pub(super) async fn generate_action_story_payload(
None
}
fn apply_rpg_web_search(state: &AppState, request: &mut LlmTextRequest) {
request.enable_web_search = state.config.rpg_llm_web_search_enabled;
}
pub(super) async fn generate_npc_dialogue_payload(
llm_client: &LlmClient,
enable_web_search: bool,
game_state: &Value,
request: &RuntimeStoryActionRequest,
action_text: &str,
@@ -133,6 +141,7 @@ pub(super) async fn generate_npc_dialogue_payload(
)),
]);
llm_request.max_tokens = Some(700);
llm_request.enable_web_search = enable_web_search;
let dialogue_text = llm_client
.request_text(llm_request)
@@ -154,6 +163,7 @@ pub(super) async fn generate_npc_dialogue_payload(
pub(super) async fn generate_reasoned_story_payload(
llm_client: &LlmClient,
enable_web_search: bool,
game_state: &Value,
request: &RuntimeStoryActionRequest,
action_text: &str,
@@ -184,6 +194,7 @@ pub(super) async fn generate_reasoned_story_payload(
)),
]);
llm_request.max_tokens = Some(700);
llm_request.enable_web_search = enable_web_search;
let story_text = llm_client
.request_text(llm_request)

View File

@@ -1560,9 +1560,11 @@ mod tests {
.expect("candidates should build");
assert_eq!(candidates.len(), 2);
assert!(candidates[0]
.image_src
.starts_with("/generated-puzzle-assets/session-1/"));
assert!(
candidates[0]
.image_src
.starts_with("/generated-puzzle-assets/session-1/")
);
let legacy_public_prefix = ["generated-puzzle", "covers"].join("-");
assert!(!candidates[0].image_src.contains(&legacy_public_prefix));
}

View File

@@ -65,6 +65,7 @@ pub struct LlmTextRequest {
pub model: Option<String>,
pub messages: Vec<LlmMessage>,
pub max_tokens: Option<u32>,
pub enable_web_search: bool,
}
// 上层在流式消费时拿到的是“累计文本 + 当前增量”,避免每层重新自己拼接。
@@ -122,8 +123,13 @@ struct ChatCompletionsRequestBody<'a> {
stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
max_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
web_search_options: Option<ChatCompletionsWebSearchOptions>,
}
#[derive(Serialize)]
struct ChatCompletionsWebSearchOptions {}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct LlmRawFailureInputLog<'a> {
@@ -305,6 +311,7 @@ impl LlmTextRequest {
model: None,
messages,
max_tokens: None,
enable_web_search: false,
}
}
@@ -325,6 +332,11 @@ impl LlmTextRequest {
self
}
pub fn with_web_search(mut self, enabled: bool) -> Self {
self.enable_web_search = enabled;
self
}
fn validate(&self) -> Result<(), LlmError> {
if self.messages.is_empty() {
return Err(LlmError::InvalidRequest(
@@ -651,6 +663,9 @@ impl LlmClient {
messages: request.messages.as_slice(),
stream,
max_tokens: request.max_tokens,
web_search_options: request
.enable_web_search
.then_some(ChatCompletionsWebSearchOptions {}),
};
let max_attempts = self.config.max_retries().saturating_add(1);
@@ -1228,6 +1243,47 @@ mod tests {
assert_eq!(response.response_id.as_deref(), Some("resp_retry"));
}
#[tokio::test]
async fn request_text_sends_web_search_options_when_enabled() {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let address = listener.local_addr().expect("listener should have addr");
let server_handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("request should connect");
let request_text = read_request(&mut stream);
write_response(
&mut stream,
MockResponse {
status_line: "200 OK",
content_type: "application/json; charset=utf-8",
body: r#"{"id":"resp_search","model":"test-model","choices":[{"message":{"content":""},"finish_reason":"stop"}]}"#.to_string(),
extra_headers: Vec::new(),
},
);
request_text
});
let client = build_test_client(format!("http://{address}"), 0);
let response = client
.request_text(
LlmTextRequest::single_turn("系统", "用户")
.with_web_search(true)
.with_max_tokens(128),
)
.await
.expect("request_text should succeed");
let request_text = server_handle.join().expect("server thread should join");
let request_body = request_text
.split("\r\n\r\n")
.nth(1)
.expect("request body should exist");
let request_json: serde_json::Value =
serde_json::from_str(request_body).expect("request body should be json");
assert_eq!(response.content, "搜索成功");
assert_eq!(request_json["web_search_options"], serde_json::json!({}));
}
#[tokio::test]
async fn stream_text_accumulates_sse_response() {
let server_url = spawn_mock_server(vec![MockResponse {
@@ -1344,7 +1400,7 @@ mod tests {
format!("http://{address}")
}
fn read_request(stream: &mut std::net::TcpStream) {
fn read_request(stream: &mut std::net::TcpStream) -> String {
stream
.set_read_timeout(Some(StdDuration::from_secs(1)))
.expect("read timeout should be set");
@@ -1381,6 +1437,8 @@ mod tests {
Err(error) => panic!("mock server failed to read request: {error}"),
}
}
String::from_utf8_lossy(buffer.as_slice()).to_string()
}
fn write_response(stream: &mut std::net::TcpStream, response: MockResponse) {

View File

@@ -519,5 +519,4 @@ impl SpacetimeClient {
})
.await
}
}

View File

@@ -18,8 +18,7 @@ pub use mapper::{
CustomWorldAgentMessageFinalizeRecordInput, CustomWorldAgentMessageRecord,
CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentOperationProgressRecordInput,
CustomWorldAgentOperationRecord, CustomWorldAgentSessionCreateRecordInput,
CustomWorldAgentSessionRecord,
CustomWorldCheckpointRecord, CustomWorldDraftCardDetailRecord,
CustomWorldAgentSessionRecord, CustomWorldCheckpointRecord, CustomWorldDraftCardDetailRecord,
CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord,
CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord, CustomWorldLibraryMutationRecord,
CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord,

View File

@@ -2733,12 +2733,22 @@ pub(crate) fn parse_rpg_agent_operation_type_record(
"process_message" => Ok(crate::module_bindings::RpgAgentOperationType::ProcessMessage),
"draft_foundation" => Ok(crate::module_bindings::RpgAgentOperationType::DraftFoundation),
"update_draft_card" => Ok(crate::module_bindings::RpgAgentOperationType::UpdateDraftCard),
"sync_result_profile" => Ok(crate::module_bindings::RpgAgentOperationType::SyncResultProfile),
"generate_characters" => Ok(crate::module_bindings::RpgAgentOperationType::GenerateCharacters),
"generate_landmarks" => Ok(crate::module_bindings::RpgAgentOperationType::GenerateLandmarks),
"generate_role_assets" => Ok(crate::module_bindings::RpgAgentOperationType::GenerateRoleAssets),
"sync_result_profile" => {
Ok(crate::module_bindings::RpgAgentOperationType::SyncResultProfile)
}
"generate_characters" => {
Ok(crate::module_bindings::RpgAgentOperationType::GenerateCharacters)
}
"generate_landmarks" => {
Ok(crate::module_bindings::RpgAgentOperationType::GenerateLandmarks)
}
"generate_role_assets" => {
Ok(crate::module_bindings::RpgAgentOperationType::GenerateRoleAssets)
}
"sync_role_assets" => Ok(crate::module_bindings::RpgAgentOperationType::SyncRoleAssets),
"generate_scene_assets" => Ok(crate::module_bindings::RpgAgentOperationType::GenerateSceneAssets),
"generate_scene_assets" => {
Ok(crate::module_bindings::RpgAgentOperationType::GenerateSceneAssets)
}
"sync_scene_assets" => Ok(crate::module_bindings::RpgAgentOperationType::SyncSceneAssets),
"expand_long_tail" => Ok(crate::module_bindings::RpgAgentOperationType::ExpandLongTail),
"publish_world" => Ok(crate::module_bindings::RpgAgentOperationType::PublishWorld),

View File

@@ -3771,15 +3771,24 @@ fn apply_scene_asset_publish_result(profile: &mut JsonMap<String, JsonValue>, sc
}
fn update_scene_chapter_acts_for_scene(profile: &mut JsonMap<String, JsonValue>, scene_id: &str, image_src: &str, generated_scene_asset_id: &str) {
let Some(chapters) = profile.get_mut("sceneChapters").and_then(JsonValue::as_array_mut) else { return; };
for chapter in chapters {
let Some(chapter_object) = chapter.as_object_mut() else { continue; };
if read_optional_text_field(chapter_object, &["sceneId"]).as_deref() != Some(scene_id) { continue; }
let Some(acts) = chapter_object.get_mut("acts").and_then(JsonValue::as_array_mut) else { continue; };
for act in acts {
if let Some(act_object) = act.as_object_mut() {
act_object.insert("backgroundImageSrc".to_string(), JsonValue::String(image_src.to_string()));
act_object.insert("backgroundAssetId".to_string(), JsonValue::String(generated_scene_asset_id.to_string()));
// 中文注释:当前结果页与发布链路以 sceneChapterBlueprints 为主,旧 sceneChapters 仅作兼容;同步场景资产时两边都要写,避免开局场景幕图只落在旧字段。
for field in ["sceneChapterBlueprints", "sceneChapters"] {
let Some(chapters) = profile.get_mut(field).and_then(JsonValue::as_array_mut) else { continue; };
for chapter in chapters {
let Some(chapter_object) = chapter.as_object_mut() else { continue; };
let is_target_scene = read_optional_text_field(chapter_object, &["sceneId"]).as_deref() == Some(scene_id)
|| chapter_object
.get("linkedLandmarkIds")
.and_then(JsonValue::as_array)
.map(|ids| ids.iter().any(|id| id.as_str().map(str::trim) == Some(scene_id)))
.unwrap_or(false);
if !is_target_scene { continue; }
let Some(acts) = chapter_object.get_mut("acts").and_then(JsonValue::as_array_mut) else { continue; };
for act in acts {
if let Some(act_object) = act.as_object_mut() {
act_object.insert("backgroundImageSrc".to_string(), JsonValue::String(image_src.to_string()));
act_object.insert("backgroundAssetId".to_string(), JsonValue::String(generated_scene_asset_id.to_string()));
}
}
}
}

View File

@@ -11,7 +11,6 @@ import type {
EightAnchorContent,
KeyRelationshipValue,
} from '../../packages/shared/src/contracts/customWorldAgent';
import { ROLE_TEMPLATE_CHARACTERS } from '../data/characterPresets';
import { getCustomWorldSceneRelativePositionLabel } from '../data/customWorldSceneGraph';
import {
resolveCustomWorldCampSceneImage,
@@ -353,6 +352,25 @@ function collectSceneActImagePreviews(sceneChapters: SceneChapterBlueprint[]) {
);
}
function buildFallbackSceneActImagePreviews(params: {
sceneChapters: SceneChapterBlueprint[];
sceneImageSrc?: string | null;
}) {
const actPreviews = collectSceneActImagePreviews(params.sceneChapters);
const sceneImageSrc = params.sceneImageSrc?.trim() || '';
if (actPreviews.length > 0 || !sceneImageSrc) {
return actPreviews;
}
// 中文注释:旧草稿可能只把开局场景图写在 camp.imageSrc尚未回填到每一幕目录侧先用场景图兜底避免开局场景看起来没有幕图片。
return [1, 2, 3].map((actNumber) => ({
id: `fallback-scene-act-${actNumber}`,
title: `${actNumber}`,
imageSrc: sceneImageSrc,
}));
}
function SceneActPreviewStrip({
acts,
sceneName,
@@ -536,13 +554,7 @@ function resolvePlayableRolePreviewImage(
return previewCharacter.avatar;
}
const template = role.templateCharacterId
? ROLE_TEMPLATE_CHARACTERS.find(
(character) => character.id === role.templateCharacterId,
) ?? null
: null;
return template?.portrait ?? '';
return '';
}
function toText(value: unknown) {
@@ -1045,17 +1057,21 @@ export function CustomWorldEntityCatalog({
sceneId: resolvedCampScene.id,
sceneName: resolvedCampScene.name,
});
const openingSceneImageSrc = resolveSceneCardImage({
sceneImageSrc: resolvedCampImageSrc,
sceneChapters: openingSceneChapters,
});
const openingSceneEntry = {
id: resolvedCampScene.id,
kind: 'camp' as const,
name: resolvedCampScene.name,
description: resolvedCampScene.description,
imageSrc: resolveSceneCardImage({
sceneImageSrc: resolvedCampImageSrc,
sceneChapters: openingSceneChapters,
}),
imageSrc: openingSceneImageSrc,
sceneChapters: openingSceneChapters,
actPreviews: collectSceneActImagePreviews(openingSceneChapters),
actPreviews: buildFallbackSceneActImagePreviews({
sceneChapters: openingSceneChapters,
sceneImageSrc: openingSceneImageSrc,
}),
searchText: [
buildOpeningSceneSearchText(profile, resolvedCampScene),
buildSceneChapterSearchText(openingSceneChapters, roleById),
@@ -1069,18 +1085,22 @@ export function CustomWorldEntityCatalog({
sceneId: landmark.id,
sceneName: landmark.name,
});
const sceneImageSrc = resolveSceneCardImage({
sceneImageSrc: landmarkImageById.get(landmark.id) ?? landmark.imageSrc,
sceneChapters,
});
return {
id: landmark.id,
kind: 'landmark' as const,
name: landmark.name,
description: landmark.description,
imageSrc: resolveSceneCardImage({
sceneImageSrc: landmarkImageById.get(landmark.id) ?? landmark.imageSrc,
sceneChapters,
}),
imageSrc: sceneImageSrc,
sceneChapters,
actPreviews: collectSceneActImagePreviews(sceneChapters),
actPreviews: buildFallbackSceneActImagePreviews({
sceneChapters,
sceneImageSrc,
}),
searchText: [
buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
buildSceneChapterSearchText(sceneChapters, roleById),

View File

@@ -148,7 +148,6 @@ function createPlayableRole(id: string, name: string): CustomWorldPlayableNpc {
backstoryReveal: createBackstoryReveal(),
skills: [],
initialItems: [],
templateCharacterId: 'knight-female-1',
};
}

View File

@@ -445,8 +445,6 @@ export function PlatformEntryFlowShellImpl({
autosaveCoordinator.executeAgentActionAndWait({
action: 'publish_world',
}),
syncAgentDraftResultProfile:
autosaveCoordinator.syncAgentDraftResultProfile,
setGeneratedCustomWorldProfile:
sessionController.setGeneratedCustomWorldProfile,
});

View File

@@ -536,7 +536,6 @@ export function RpgCreationRoleAssetStudioModal({
motivation: role.motivation,
combatStyle: role.combatStyle,
tags: role.tags,
templateCharacterId: role.templateCharacterId,
imageSrc: role.imageSrc,
generatedVisualAssetId: role.generatedVisualAssetId,
generatedAnimationSetId: role.generatedAnimationSetId,
@@ -558,7 +557,6 @@ export function RpgCreationRoleAssetStudioModal({
role.role,
role.sceneVisualDescription,
role.tags,
role.templateCharacterId,
role.title,
role.visualDescription,
],
@@ -610,11 +608,7 @@ export function RpgCreationRoleAssetStudioModal({
useRoleAnimationWorkflow();
const selectedTemplate =
roleKind === 'playable' && workingRole.templateCharacterId
? (ROLE_TEMPLATE_CHARACTERS.find(
(character) => character.id === workingRole.templateCharacterId,
) ?? null)
: null;
roleKind === 'playable' ? (ROLE_TEMPLATE_CHARACTERS[0] ?? null) : null;
const characterBriefText = useMemo(
() =>
buildRoleCharacterBrief(

View File

@@ -14,7 +14,6 @@ export type EditableCustomWorldRole = {
motivation?: string;
combatStyle?: string;
tags?: string[];
templateCharacterId?: string;
imageSrc?: string;
generatedVisualAssetId?: string;
generatedAnimationSetId?: string;

View File

@@ -3473,13 +3473,7 @@ export function SectionPanel({
function buildRolePreviewCharacter(
role: CustomWorldPlayableNpc | CustomWorldNpc,
): Character | null {
const template =
'templateCharacterId' in role && role.templateCharacterId
? ROLE_TEMPLATE_CHARACTERS.find(
(entry) => entry.id === role.templateCharacterId,
) ?? null
: null;
const portrait = role.imageSrc || template?.portrait;
const portrait = role.imageSrc;
if (!portrait) {
return null;
@@ -3493,15 +3487,15 @@ function buildRolePreviewCharacter(
backstory: role.backstory,
avatar: portrait,
portrait,
assetFolder: template?.assetFolder ?? 'custom-world',
assetVariant: template?.assetVariant ?? 'generated',
assetFolder: 'custom-world',
assetVariant: 'generated',
generatedVisualAssetId: role.generatedVisualAssetId,
generatedAnimationSetId: role.generatedAnimationSetId,
animationMap: template?.animationMap ?? role.animationMap,
attributes: template?.attributes ?? {},
animationMap: role.animationMap,
attributes: { strength: 0, agility: 0, intelligence: 0, spirit: 0 },
personality: role.personality,
skills: template?.skills ?? [],
adventureOpenings: template?.adventureOpenings ?? {},
skills: [],
adventureOpenings: {},
} as Character;
}
@@ -4568,12 +4562,7 @@ export function PlayableNpcEditor({
const initialSnapshot = useMemo(() => JSON.stringify(npc), [npc]);
const draftSnapshot = useMemo(() => JSON.stringify(draft), [draft]);
const hasUnsavedChanges = draftSnapshot !== initialSnapshot;
const selectedTemplate =
ROLE_TEMPLATE_CHARACTERS.find(
(character) => character.id === draft.templateCharacterId,
) ??
ROLE_TEMPLATE_CHARACTERS[0] ??
null;
const previewImageSrc = draft.imageSrc?.trim() ?? '';
const roleOptions = useMemo(
() =>
[...profile.playableNpcs, ...profile.storyNpcs]
@@ -4608,7 +4597,7 @@ export function PlayableNpcEditor({
disableClose={isAiAssetStudioOpen || isCloseConfirmOpen}
>
<div className="space-y-4">
{selectedTemplate ? (
{previewImageSrc ? (
<div className="rounded-2xl border border-white/8 bg-black/20 p-4">
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-300">
@@ -4616,20 +4605,17 @@ export function PlayableNpcEditor({
<div className="mt-3 grid gap-4 sm:grid-cols-[7rem_minmax(0,1fr)]">
<div className="overflow-hidden rounded-2xl border border-white/10 bg-black/30">
<img
src={draft.imageSrc || selectedTemplate.portrait}
alt={selectedTemplate.name}
src={previewImageSrc}
alt={draft.name || '角色形象'}
className="h-28 w-full object-cover object-top"
/>
</div>
<div className="min-w-0">
<div className="text-base font-semibold text-white">
{selectedTemplate.name}
{draft.name || '未命名角色'}
</div>
<div className="mt-1 text-sm text-zinc-400">
{selectedTemplate.title}
</div>
<div className="mt-3 text-sm leading-6 text-zinc-300">
{selectedTemplate.description}
{draft.title || draft.role}
</div>
<div className="mt-3 flex flex-wrap gap-2">
{draft.generatedVisualAssetId ? (
@@ -4654,22 +4640,6 @@ export function PlayableNpcEditor({
</div>
</div>
) : null}
<Field label="外观模板">
<SelectField
value={draft.templateCharacterId ?? ROLE_TEMPLATE_CHARACTERS[0]?.id ?? ''}
onChange={(value) =>
setDraft((current) => ({
...current,
templateCharacterId: value,
}))
}
options={ROLE_TEMPLATE_CHARACTERS.map((character) => ({
value: character.id,
label: `${character.name} / ${character.title}`,
}))}
/>
</Field>
<Field label="名称">
<TextInput
value={draft.name}
@@ -4798,11 +4768,7 @@ export function PlayableNpcEditor({
<SaveBar
onClose={handleRequestClose}
onSave={() => {
onSave({
...draft,
templateCharacterId:
draft.templateCharacterId ?? ROLE_TEMPLATE_CHARACTERS[0]?.id,
});
onSave(draft);
onClose();
}}
showClose={false}

View File

@@ -127,7 +127,6 @@ export function createPlayableNpcDraft(
tags: ['自定义'],
},
],
templateCharacterId: profile.playableNpcs[0]?.templateCharacterId,
};
}

View File

@@ -0,0 +1,158 @@
/** @vitest-environment jsdom */
import { act, render } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
import { WorldType, type CustomWorldProfile } from '../../types';
import { useRpgCreationEnterWorld } from './useRpgCreationEnterWorld';
function buildProfile(params: {
id: string;
name: string;
imageSrc: string;
}): CustomWorldProfile {
return {
id: params.id,
settingText: params.name,
name: params.name,
subtitle: params.name,
summary: params.name,
tone: '测试',
playerGoal: '测试',
templateWorldType: WorldType.WUXIA,
compatibilityTemplateWorldType: WorldType.WUXIA,
majorFactions: [],
coreConflicts: [],
attributeSchema: {
id: `${params.id}-attribute-schema`,
worldId: params.id,
schemaVersion: 1,
generatedFrom: {
worldType: WorldType.CUSTOM,
worldName: params.name,
settingSummary: params.name,
tone: '测试',
conflictCore: '测试',
},
slots: [],
},
playableNpcs: [
{
id: `${params.id}-role`,
name: '可扮演角色',
title: '测试角色',
role: '主角',
description: '测试角色',
backstory: '测试背景',
personality: '测试性格',
motivation: '测试动机',
combatStyle: '测试战斗风格',
initialAffinity: 18,
relationshipHooks: [],
tags: [],
backstoryReveal: {
publicSummary: '测试角色',
privateChatUnlockAffinity: 60,
chapters: [],
},
skills: [],
initialItems: [],
imageSrc: params.imageSrc,
},
],
storyNpcs: [],
items: [],
landmarks: [],
generationMode: 'full',
generationStatus: 'complete',
};
}
function buildSession(): CustomWorldAgentSessionSnapshot {
return {
sessionId: 'session-1',
currentTurn: 1,
anchorContent: {
worldPromise: null,
playerFantasy: null,
themeBoundary: null,
playerEntryPoint: null,
coreConflict: null,
keyRelationships: [],
hiddenLines: null,
iconicElements: null,
},
progressPercent: 100,
lastAssistantReply: '',
stage: 'ready_to_publish',
focusCardId: null,
creatorIntent: null,
creatorIntentReadiness: { isReady: true, completedKeys: [], missingKeys: [] },
anchorPack: null,
lockState: null,
draftProfile: null,
messages: [],
draftCards: [],
pendingClarifications: [],
suggestedActions: [],
recommendedReplies: [],
qualityFindings: [],
assetCoverage: {
roleAssets: [],
sceneAssets: [],
allRoleAssetsReady: true,
allSceneAssetsReady: true,
},
resultPreview: null,
updatedAt: '2026-04-25T00:00:00.000Z',
};
}
describe('useRpgCreationEnterWorld', () => {
it('Agent 草稿进入游戏时使用 session draft profile 的角色形象', async () => {
const staleResultProfile = buildProfile({
id: 'stale-result',
name: '旧结果页快照',
imageSrc: '/template/old-role.png',
});
const draftProfile = buildProfile({
id: 'draft-profile',
name: '草稿真相源',
imageSrc: '/generated-characters/draft-role/portrait.png',
});
const handleCustomWorldSelect = vi.fn();
const setGeneratedCustomWorldProfile = vi.fn();
const executePublishWorld = vi.fn(async () => buildSession());
function Harness() {
const { enterWorldForTestFromCurrentResult } = useRpgCreationEnterWorld({
isAgentDraftResultView: true,
activeAgentSessionId: 'session-1',
generatedCustomWorldProfile: staleResultProfile,
agentSessionProfile: draftProfile,
agentSession: buildSession(),
handleCustomWorldSelect,
executePublishWorld,
setGeneratedCustomWorldProfile,
});
return (
<button type="button" onClick={() => void enterWorldForTestFromCurrentResult()}>
</button>
);
}
const { getByText } = render(<Harness />);
await act(async () => {
getByText('进入').click();
});
expect(executePublishWorld).not.toHaveBeenCalled();
expect(handleCustomWorldSelect).toHaveBeenCalledWith(draftProfile);
expect(handleCustomWorldSelect.mock.calls[0]?.[0].playableNpcs[0]?.imageSrc).toBe(
'/generated-characters/draft-role/portrait.png',
);
});
});

View File

@@ -12,18 +12,12 @@ type UseRpgCreationEnterWorldParams = {
agentSession: CustomWorldAgentSessionSnapshot | null;
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
executePublishWorld: () => Promise<CustomWorldAgentSessionSnapshot | null>;
syncAgentDraftResultProfile: (
profile: CustomWorldProfile,
) => Promise<{
profile: CustomWorldProfile | null;
session: CustomWorldAgentSessionSnapshot | null;
}>;
setGeneratedCustomWorldProfile: (profile: CustomWorldProfile | null) => void;
};
/**
* 统一“进入世界”前的最终同步策略。
* Agent 草稿结果直接进入Agent 草稿结果必须先把当前结果页并回 session
* Agent 草稿结果进入游戏时只读 session.draftProfile不再把结果页快照回写成新的运行时 profile
*/
export function useRpgCreationEnterWorld(
params: UseRpgCreationEnterWorldParams,
@@ -36,7 +30,6 @@ export function useRpgCreationEnterWorld(
agentSession,
handleCustomWorldSelect,
executePublishWorld,
syncAgentDraftResultProfile,
setGeneratedCustomWorldProfile,
} = params;
@@ -50,11 +43,7 @@ export function useRpgCreationEnterWorld(
return;
}
const latestResult = await syncAgentDraftResultProfile(
generatedCustomWorldProfile,
);
const latestProfile =
latestResult.profile ?? agentSessionProfile ?? generatedCustomWorldProfile;
const latestProfile = agentSessionProfile ?? generatedCustomWorldProfile;
setGeneratedCustomWorldProfile(latestProfile);
handleCustomWorldSelect(latestProfile);
}, [
@@ -64,7 +53,6 @@ export function useRpgCreationEnterWorld(
handleCustomWorldSelect,
isAgentDraftResultView,
setGeneratedCustomWorldProfile,
syncAgentDraftResultProfile,
]);
const publishCurrentResult = useCallback(async () => {
@@ -76,14 +64,10 @@ export function useRpgCreationEnterWorld(
return generatedCustomWorldProfile;
}
const latestResult = await syncAgentDraftResultProfile(
generatedCustomWorldProfile,
);
const latestProfile =
latestResult.profile ?? agentSessionProfile ?? generatedCustomWorldProfile;
const latestProfile = agentSessionProfile ?? generatedCustomWorldProfile;
setGeneratedCustomWorldProfile(latestProfile);
const latestSession = latestResult.session ?? agentSession;
const latestSession = agentSession;
const canEnterPublishedWorld =
latestSession?.stage === 'published' &&
latestSession.resultPreview?.canEnterWorld;
@@ -108,7 +92,6 @@ export function useRpgCreationEnterWorld(
handleCustomWorldSelect,
isAgentDraftResultView,
setGeneratedCustomWorldProfile,
syncAgentDraftResultProfile,
]);
const enterWorldFromCurrentResult = useCallback(async () => {

View File

@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it } from 'vitest';
import { buildExpandedCustomWorldProfile } from '../services/customWorldBuilder';
import { AnimationState } from '../types';
import {
buildCustomWorldPlayableCharacters,
buildCustomWorldRuntimeCharacters,
getCharacterById,
resolveEncounterRecruitCharacter,
@@ -109,19 +110,9 @@ describe('characterPresets custom world runtime characters', () => {
tone: '潮湿、压抑、克制',
playerGoal: '查清夜港失踪案和潮路背后的势力牵连。',
templateWorldType: 'WUXIA',
playableNpcs: Array.from({ length: 5 }, (_, index) => ({
...createRole(index),
templateCharacterId:
index === 0
? 'sword-princess'
: index === 1
? 'archer-hero'
: index === 2
? 'girl-hero'
: index === 3
? 'punch-hero'
: 'fighter-4',
})),
playableNpcs: Array.from({ length: 5 }, (_, index) =>
createRole(index),
),
storyNpcs: [
{
...createRole(10),
@@ -260,4 +251,38 @@ describe('characterPresets custom world runtime characters', () => {
expect(recruitCharacter?.id).toBe(storyRole!.id);
expect(recruitCharacter?.name).toBe('沈雾');
});
it('uses draft playable role image directly before generated animations exist', () => {
const profile = buildExpandedCustomWorldProfile(
{
name: '潮雾列岛',
subtitle: '灯塔未眠',
summary: '围绕潮雾、灯塔和失踪航路展开的世界。',
tone: '冷峻、潮湿、悬疑',
playerGoal: '找到灯塔失踪航路。',
templateWorldType: 'WUXIA',
playableNpcs: [
{
...createRole(0),
id: 'playable-lighthouse-keeper',
imageSrc: '/generated-characters/lighthouse-keeper/portrait.png',
generatedVisualAssetId: 'assetobj-lighthouse-keeper',
generatedAnimationSetId: undefined,
animationMap: undefined,
},
],
},
'玩家想测试灯塔守望者草稿。',
);
const [playableCharacter] = buildCustomWorldPlayableCharacters(profile);
expect(playableCharacter?.portrait).toBe(
'/generated-characters/lighthouse-keeper/portrait.png',
);
expect(playableCharacter?.avatar).toBe(
'/generated-characters/lighthouse-keeper/portrait.png',
);
expect(playableCharacter?.animationMap).toBeUndefined();
});
});

View File

@@ -216,9 +216,7 @@ function buildCharacterResourceProfile(character: Character) {
};
}
type CustomWorldRuntimeRole = CustomWorldPlayableNpc | (CustomWorldNpc & {
templateCharacterId?: string;
});
type CustomWorldRuntimeRole = CustomWorldPlayableNpc | CustomWorldNpc;
function buildFallbackCustomRuntimeRole(character: Character): CustomWorldRuntimeRole {
return {
@@ -1617,6 +1615,15 @@ function buildCustomWorldRoleCharacter(
role: CustomWorldRuntimeRole,
) {
const combatTags = deriveCustomWorldCharacterCombatTags(profile, role, baseCharacter);
const roleImageSrc = role.imageSrc?.trim() || '';
const roleAnimationMap = role.animationMap
? {
...(baseCharacter.animationMap ?? {}),
...role.animationMap,
}
: roleImageSrc
? undefined
: baseCharacter.animationMap;
const opening = buildCustomWorldAdventureOpening(profile, {
...baseCharacter,
name: role.name,
@@ -1634,15 +1641,13 @@ function buildCustomWorldRoleCharacter(
description: role.description,
backstory: role.backstory,
backstoryReveal: role.backstoryReveal,
portrait: role.imageSrc?.trim() || baseCharacter.portrait,
avatar: roleImageSrc || baseCharacter.avatar,
portrait: roleImageSrc || baseCharacter.portrait,
generatedVisualAssetId: role.generatedVisualAssetId,
generatedAnimationSetId: role.generatedAnimationSetId,
animationMap: role.animationMap
? {
...(baseCharacter.animationMap ?? {}),
...role.animationMap,
}
: baseCharacter.animationMap,
// 草稿 profile 已提供角色形象但尚未生成动作集时,不能继承模板动作帧,
// 否则角色选择页选中态会优先渲染旧模板动画,看起来像草稿形象没有加载。
animationMap: roleAnimationMap,
visual: 'visual' in role ? role.visual : undefined,
groundOffsetY: 'visual' in role && role.visual ? 22 : baseCharacter.groundOffsetY,
personality: role.personality,
@@ -1683,13 +1688,6 @@ function pickCustomWorldRoleTemplateCharacter(
throw new Error('Missing preset characters for custom world generation');
}
const explicitTemplateCharacter = role.templateCharacterId
? ROLE_TEMPLATE_CHARACTERS.find(character => character.id === role.templateCharacterId) ?? null
: null;
if (explicitTemplateCharacter) {
return explicitTemplateCharacter;
}
const referenceTemplateCharacterId = resolveRoleTemplateCharacterIdFromReferenceProfile(
profile ?? null,
{
@@ -1749,7 +1747,6 @@ export function buildCustomWorldPlayableCharacters(profile: CustomWorldProfile |
profile,
{
...role,
templateCharacterId: role.templateCharacterId ?? templateCharacter.id,
},
);
});

View File

@@ -10,9 +10,7 @@ type CustomWorldTagProfile = Pick<
type CustomWorldTagRole = Pick<
CustomWorldPlayableNpc,
'name' | 'title' | 'description' | 'backstory' | 'personality' | 'combatStyle' | 'tags'
> & {
templateCharacterId?: string;
};
>;
const TEMPLATE_CHARACTER_TAGS: Record<string, string[]> = {
'sword-princess': ['\u5feb\u5251', '\u7a81\u8fdb', '\u538b\u5236'],
@@ -160,7 +158,7 @@ export function deriveCustomWorldCharacterCombatTags(
) {
return deriveCustomWorldCombatTags(profile, role, {
fallbackTags: normalizeBuildTags(baseCharacter.combatTags, 3),
templateCharacterId: role.templateCharacterId ?? baseCharacter.id,
templateCharacterId: baseCharacter.id,
maxCount: 3,
});
}

View File

@@ -130,7 +130,6 @@ function resolveCustomWorldRole(
) {
return profile.playableNpcs.find(role => role.id === character.id)
?? profile.storyNpcs.find(role => role.id === character.id)
?? profile.playableNpcs.find(role => role.templateCharacterId === character.id)
?? profile.playableNpcs.find(role => role.name === character.name)
?? profile.storyNpcs.find(role => role.name === character.name)
?? null;

View File

@@ -72,5 +72,53 @@ describe('normalizeCustomWorldProfileRecord role asset descriptions', () => {
expect(profile?.playerPremise).toBe('玩家是返乡调查旧案的守灯人。');
expect(profile?.sceneChapterBlueprints?.[0]?.acts).toHaveLength(1);
});
it('直接读取 Rust 草稿角色字段和形象资源', () => {
const profile = normalizeCustomWorldProfileRecord({
name: '雾港归航',
settingText: '海雾旧案',
playableNpcs: [
{
id: 'playable-cendeng',
name: '岑灯',
title: '返乡守灯人',
role: '主角代理',
publicMask: '深蓝旧雨衣、铜灯和卷边海图。',
currentPressure: '灯塔记录被人改写,旧案正在逼近。',
relationToPlayer: '这是玩家进入世界的第一视角。',
imageSrc: '/generated-characters/playable-cendeng/portrait.png',
generatedVisualAssetId: 'visual-playable-cendeng',
},
],
storyNpcs: [
{
id: 'story-yizhang',
name: '议长甲',
title: '群岛议长',
role: '遮掩者',
publicIdentity: '压住旧档的人。',
hiddenHook: '长期维持群岛议会体面并遮掩沉船旧案。',
relationToPlayer: '会阻止玩家继续追查。',
imageSrc: '/generated-characters/story-yizhang/portrait.png',
},
],
});
expect(profile?.playableNpcs[0]?.description).toBe(
'深蓝旧雨衣、铜灯和卷边海图。',
);
expect(profile?.playableNpcs[0]?.backstory).toContain('灯塔记录');
expect(profile?.playableNpcs[0]?.relationshipHooks[0]).toBe(
'这是玩家进入世界的第一视角。',
);
expect(profile?.playableNpcs[0]?.imageSrc).toBe(
'/generated-characters/playable-cendeng/portrait.png',
);
expect(profile?.storyNpcs[0]?.description).toBe('压住旧档的人。');
expect(profile?.storyNpcs[0]?.backstory).toContain('沉船旧案');
expect(profile?.storyNpcs[0]?.imageSrc).toBe(
'/generated-characters/story-yizhang/portrait.png',
);
});
});

View File

@@ -663,17 +663,23 @@ function normalizePlayableNpc(
.filter(Boolean)
.slice(0, 8);
const tags = toStringArray(value.tags);
const publicMask = toText(value.publicMask) || toText(value.publicIdentity);
const currentPressure = toText(value.currentPressure) || toText(value.hiddenHook);
const relationToPlayer = toText(value.relationToPlayer);
const fallbackSource = {
name,
title,
role,
description: toText(value.description),
backstory: toText(value.backstory),
personality: toText(value.personality),
motivation: toText(value.motivation, toText(value.description)),
description: toText(value.description) || publicMask,
backstory: toText(value.backstory) || currentPressure,
personality: toText(value.personality) || publicMask,
motivation:
toText(value.motivation) || relationToPlayer || currentPressure,
combatStyle: toText(value.combatStyle),
relationshipHooks:
relationshipHooks.length > 0 ? relationshipHooks : tags.slice(0, 3),
relationshipHooks.length > 0
? relationshipHooks
: [relationToPlayer, currentPressure, ...tags].filter(Boolean).slice(0, 3),
tags: tags.length > 0 ? tags : relationshipHooks.slice(0, 5),
} satisfies CustomWorldRoleFallbackSource;
@@ -683,9 +689,10 @@ function normalizePlayableNpc(
title,
role,
description: fallbackSource.description,
visualDescription: toText(value.visualDescription) || undefined,
actionDescription: toText(value.actionDescription) || undefined,
sceneVisualDescription: toText(value.sceneVisualDescription) || undefined,
visualDescription: toText(value.visualDescription) || publicMask || undefined,
actionDescription: toText(value.actionDescription) || currentPressure || undefined,
sceneVisualDescription:
toText(value.sceneVisualDescription) || currentPressure || undefined,
backstory: fallbackSource.backstory,
personality: fallbackSource.personality,
motivation: fallbackSource.motivation,
@@ -717,7 +724,6 @@ function normalizePlayableNpc(
preserveStructuredRecord<CustomWorldPlayableNpc['narrativeProfile']>(
value.narrativeProfile,
) ?? undefined,
templateCharacterId: toText(value.templateCharacterId) || undefined,
};
}
@@ -738,17 +744,23 @@ function normalizeStoryNpc(
.filter(Boolean)
.slice(0, 8);
const tags = toStringArray(value.tags);
const publicMask = toText(value.publicMask) || toText(value.publicIdentity);
const currentPressure = toText(value.currentPressure) || toText(value.hiddenHook);
const relationToPlayer = toText(value.relationToPlayer);
const fallbackSource = {
name,
title,
role,
description: toText(value.description),
backstory: toText(value.backstory),
personality: toText(value.personality),
motivation: toText(value.motivation),
description: toText(value.description) || publicMask,
backstory: toText(value.backstory) || currentPressure,
personality: toText(value.personality) || publicMask,
motivation:
toText(value.motivation) || relationToPlayer || currentPressure,
combatStyle: toText(value.combatStyle),
relationshipHooks:
relationshipHooks.length > 0 ? relationshipHooks : tags.slice(0, 3),
relationshipHooks.length > 0
? relationshipHooks
: [relationToPlayer, currentPressure, ...tags].filter(Boolean).slice(0, 3),
tags: tags.length > 0 ? tags : relationshipHooks.slice(0, 5),
} satisfies CustomWorldRoleFallbackSource;
@@ -758,9 +770,10 @@ function normalizeStoryNpc(
title,
role,
description: fallbackSource.description,
visualDescription: toText(value.visualDescription) || undefined,
actionDescription: toText(value.actionDescription) || undefined,
sceneVisualDescription: toText(value.sceneVisualDescription) || undefined,
visualDescription: toText(value.visualDescription) || publicMask || undefined,
actionDescription: toText(value.actionDescription) || currentPressure || undefined,
sceneVisualDescription:
toText(value.sceneVisualDescription) || currentPressure || undefined,
backstory: fallbackSource.backstory,
personality: fallbackSource.personality,
motivation: fallbackSource.motivation,

View File

@@ -79,9 +79,6 @@ function buildExplicitCustomWorldRoleStarterState(
const role =
profile.playableNpcs.find((entry) => entry.id === character.id) ??
profile.storyNpcs.find((entry) => entry.id === character.id) ??
profile.playableNpcs.find(
(entry) => entry.templateCharacterId === character.id,
) ??
profile.playableNpcs.find((entry) => entry.name === character.name) ??
profile.storyNpcs.find((entry) => entry.name === character.name) ??
null;

View File

@@ -146,7 +146,6 @@ function buildSavedProfile() {
tags: ['线索', '真相'],
},
],
templateCharacterId: 'archer-hero',
},
],
storyNpcs: [

View File

@@ -895,7 +895,6 @@ function normalizePlayableNpcList(value: unknown) {
titleFallback: '未定称号',
defaultAffinity: DEFAULT_PLAYABLE_INITIAL_AFFINITY,
}),
templateCharacterId: toText(item.templateCharacterId) || undefined,
}))
.filter((entry) => entry.name)
.slice(0, MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT);

View File

@@ -118,12 +118,10 @@ export function buildExpandedCustomWorldProfile(
const playableNpcs = dedupeByName(profile.playableNpcs)
.slice(0, PLAYABLE_TEMPLATE_CHARACTER_IDS.length)
.map((npc, index) => {
const templateCharacterId =
npc.templateCharacterId ?? getPlayableTemplateCharacterId(index);
const templateCharacterId = getPlayableTemplateCharacterId(index);
return {
...npc,
id: npc.id || createEntryId('playable-npc', npc.name, index),
templateCharacterId,
tags: mergeCustomWorldPlayableNpcTags(profile, npc, {
templateCharacterId,
maxCount: 5,

View File

@@ -1,4 +1,3 @@
import { ROLE_TEMPLATE_CHARACTERS } from '../data/characterPresets';
import type {
CustomWorldCoverProfile,
CustomWorldPlayableNpc,
@@ -43,15 +42,7 @@ function resolvePlayableCoverImageSrc(role: CustomWorldPlayableNpc) {
return explicitImageSrc;
}
if (!role.templateCharacterId) {
return null;
}
return (
ROLE_TEMPLATE_CHARACTERS.find(
(character) => character.id === role.templateCharacterId,
)?.portrait ?? null
);
return null;
}
function normalizeCoverCharacterRoleIds(

View File

@@ -378,9 +378,7 @@ function buildRoleArchetypes(profile: CustomWorldProfile) {
narrativeFunction:
role.role.trim() || role.description.trim() || '在主线推进中提供关键响应。',
sourceRoleIds: [role.id],
sourceTemplateCharacterIds: role.templateCharacterId
? [role.templateCharacterId]
: [],
sourceTemplateCharacterIds: [],
tags: dedupeStrings(role.tags, 5),
})) satisfies RoleArchetypeProfile[];
}

View File

@@ -32,13 +32,37 @@ const sessionWithPreview: CustomWorldAgentSessionSnapshot = {
anchorPack: null,
lockState: null,
draftProfile: {
id: 'draft-profile-1',
settingText: '草稿 profile 直接进入游戏。',
name: '只作为 fallback 的本地草稿名',
subtitle: 'fallback',
summary: 'fallback',
tone: 'fallback',
playerGoal: 'fallback',
playableNpcs: [],
templateWorldType: 'WUXIA',
majorFactions: [],
coreConflicts: [],
playableNpcs: [
{
id: 'draft-playable-1',
name: '草稿角色',
title: '直读测试',
role: '可扮演角色',
description: '从 draftProfile 直接进入角色选择页。',
backstory: '草稿角色的背景不经过 resultPreview 转换。',
personality: '直接、清醒',
motivation: '验证草稿直读链路',
combatStyle: '以直读链路破局',
initialAffinity: 18,
relationshipHooks: ['来自草稿'],
tags: ['draft-profile'],
skills: [],
initialItems: [],
imageSrc: '/generated-characters/draft-playable-1/portrait.png',
},
],
storyNpcs: [],
items: [],
landmarks: [],
},
messages: [],
@@ -103,19 +127,21 @@ test('buildRpgCreationPreviewFromResultPreview normalizes server preview envelop
expect(profile?.settingText).toBe('被海雾吞没的旧航路群岛');
});
test('buildRpgCreationPreviewFromSession prefers server resultPreview over draft fallback', () => {
test('buildRpgCreationPreviewFromSession reads draftProfile directly', () => {
const profile = buildRpgCreationPreviewFromSession(sessionWithPreview);
expect(profile?.name).toBe('服务端结果预览');
expect(profile?.name).not.toBe('只作为 fallback 的本地草稿名');
expect(profile?.summary).toBe('结果页应该优先消费 session.resultPreview。');
expect(profile?.name).toBe('只作为 fallback 的本地草稿名');
expect(profile?.name).not.toBe('服务端结果预览');
expect(profile?.playableNpcs[0]?.imageSrc).toBe(
'/generated-characters/draft-playable-1/portrait.png',
);
});
test('buildRpgCreationPreviewFromSession returns null when server resultPreview is missing', () => {
test('buildRpgCreationPreviewFromSession does not require resultPreview', () => {
const profile = buildRpgCreationPreviewFromSession({
...sessionWithPreview,
resultPreview: null,
});
expect(profile).toBeNull();
expect(profile?.name).toBe('只作为 fallback 的本地草稿名');
});

View File

@@ -2,10 +2,6 @@ import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/s
import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary';
import type { CustomWorldProfile } from '../../types';
/**
* Phase 5 起结果页只消费服务端回传的 result preview。
* 前端不再承担 session draft -> runtime profile 的本地兼容编译职责。
*/
export function buildCustomWorldProfileFromResultPreview(
resultPreview: CustomWorldAgentSessionSnapshot['resultPreview'] | null | undefined,
): CustomWorldProfile | null {
@@ -13,20 +9,18 @@ export function buildCustomWorldProfileFromResultPreview(
}
/**
* 统一“从 session 取结果页 profile”的主入口
* Phase 5 后主链没有 preview 就视为服务端未准备完成,而不是继续做前端本地编译
* RPG 运行时直接读取 Agent session 的 draftProfile。
* resultPreview 只作为质量/发布信息外壳,不再参与进入游戏 profile 的数据转换
*/
export function buildCustomWorldProfileFromAgentSession(
session: CustomWorldAgentSessionSnapshot | null | undefined,
): CustomWorldProfile | null {
return buildCustomWorldProfileFromResultPreview(session?.resultPreview);
return normalizeCustomWorldProfileRecord(session?.draftProfile ?? null);
}
/**
* 这是工作包 A 提供的新命名兼容层。
* Phase 3 后该适配层只负责:
* 1. 把服务端 resultPreview 转成前端 view model
* 2. 保持前端 session 读模型入口稳定
* 主入口保持命名稳定,但数据来源已经收敛为 draftProfile 单一真相源。
*/
export const rpgCreationPreviewAdapter = {
buildPreviewFromSession: buildCustomWorldProfileFromAgentSession,

View File

@@ -13,7 +13,7 @@ describe('campaignPackCompiler', () => {
storyGraph: {
visibleThreads: [{ id: 'thread-1', title: '封桥旧案' }],
},
playableNpcs: [{ id: 'npc-1', templateCharacterId: 'archer-hero' }],
playableNpcs: [{ id: 'npc-1' }],
} as unknown as CustomWorldProfile;
const compiled = compileCampaignFromWorldProfile({ profile });

View File

@@ -48,7 +48,7 @@ export function buildCampaignPack(params: {
authoringStyle,
campaignStateSeed,
actTemplates,
requiredCompanionIds: profile.playableNpcs.slice(0, 2).map((npc) => npc.templateCharacterId ?? npc.id),
requiredCompanionIds: profile.playableNpcs.slice(0, 2).map((npc) => npc.id),
} satisfies CampaignPack;
}

View File

@@ -281,9 +281,7 @@ export interface CustomWorldNpcVisual {
offHand?: CustomWorldNpcVisualGear | null;
}
export interface CustomWorldPlayableNpc extends CustomWorldRoleProfile {
templateCharacterId?: string;
}
export interface CustomWorldPlayableNpc extends CustomWorldRoleProfile {}
export interface CustomWorldNpc extends CustomWorldRoleProfile {
visual?: CustomWorldNpcVisual;
@@ -350,6 +348,10 @@ export interface SceneActBlueprint {
backgroundAssetId?: string | null;
encounterNpcIds: string[];
primaryNpcId: string;
/** 当前幕对面的角色,草稿阶段默认与 primaryNpcId 保持一致。 */
oppositeNpcId: string;
/** 当前幕发生的事件描述,需强绑定对面角色与场景主线压力。 */
eventDescription: string;
linkedThreadIds: string[];
advanceRule: SceneActAdvanceRule;
actGoal: string;
@@ -361,6 +363,8 @@ export interface SceneChapterBlueprint {
sceneId: string;
title: string;
summary: string;
/** 首次进入该场景时生成章节任务所需的核心上下文。 */
sceneTaskDescription: string;
linkedThreadIds: string[];
linkedLandmarkIds: string[];
acts: SceneActBlueprint[];