fix: stabilize rpg publish and launch
This commit is contained in:
@@ -30,6 +30,22 @@
|
|||||||
- 验证:`cargo test -p module-custom-world publish_setting_text --manifest-path server-rs\Cargo.toml`;`cargo check -p spacetime-module --manifest-path server-rs\Cargo.toml`。
|
- 验证:`cargo test -p module-custom-world publish_setting_text --manifest-path server-rs\Cargo.toml`;`cargo check -p spacetime-module --manifest-path server-rs\Cargo.toml`。
|
||||||
- 关联:`server-rs/crates/module-custom-world/src/application.rs`、`server-rs/crates/spacetime-module/src/custom_world.rs`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
- 关联:`server-rs/crates/module-custom-world/src/application.rs`、`server-rs/crates/spacetime-module/src/custom_world.rs`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
|
## RPG 已发布结果页进入世界不能重复 publish_world
|
||||||
|
|
||||||
|
- 现象:RPG 草稿发布成功后,按钮文案已变为“进入世界”,但点击仍请求 `POST /api/runtime/custom-world/agent/sessions/{sessionId}/actions` 且 payload 为 `{"action":"publish_world"}`,后端返回 `publish_world is only available during object_refining, visual_refining, long_tail_review or ready_to_publish`。
|
||||||
|
- 原因:按钮文案依据 agent session `stage === 'published'` 切换,但点击处理仍走发布协调路径;如果前端只依赖草稿同步回包判断是否已发布,回包为空或缺少可进入状态时就会继续重复发送 `publish_world`。
|
||||||
|
- 处理:进入世界协调器接收当前 agent session stage;当 stage 已为 `published` 时,只调用 `result-view` 回读已发布 profile 并启动运行态,不再调用 `sync_result_profile` 或 `publish_world`。
|
||||||
|
- 验证:`npm run test -- src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx`;确认已发布场景下 `syncAgentDraftResultProfile` 与 `executePublishWorld` 均未被调用。
|
||||||
|
- 关联:`src/components/rpg-entry/useRpgCreationEnterWorld.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
|
## RPG 点击启动黑屏先查 profile 归一化和角色选择兜底
|
||||||
|
|
||||||
|
- 现象:作品详情点击“启动”后页面切到 RPG runtime,但用户只看到黑屏或空白;DevTools 里可能同时看到旧自动存档 `/api/runtime/save/snapshot` 被主动 cancel。
|
||||||
|
- 原因:`/custom-world-library` / `/custom-world-gallery` 详情接口可能返回历史或摘要式 `profile`,缺少 `playableNpcs`、`storyNpcs`、`landmarks`、`attributeSchema` 等运行态字段;前端 client 若直接把该对象传给 runtime,角色选择首屏会在 `buildCustomWorldPlayableCharacters(profile)` 或后续属性解析处抛错。`save/snapshot (canceled)` 通常是切 runtime 或卸载时 `AbortController` 取消旧自动存档,不是黑屏根因。
|
||||||
|
- 处理:RPG 入口作品库 client 在所有返回 `CustomWorldLibraryEntry<CustomWorldProfile>` 的接口边界统一调用 `normalizeCustomWorldProfileRecord`,并用 `profileId/worldName/subtitle/summaryText` 补齐旧数据缺字段;角色选择页对角色生成异常或空数组回退默认角色,并保留返回按钮/轻量空态;顶层 runtime 懒加载 fallback 不使用纯 `null`。
|
||||||
|
- 验证:`npm run test -- src/services/rpg-entry/rpgEntryLibraryClient.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryCharacterSelectView.test.tsx`、`npm run typecheck`。
|
||||||
|
- 关联:`src/services/rpg-entry/rpgEntryLibraryClient.ts`、`src/components/rpg-entry/RpgEntryCharacterSelectView.tsx`、`src/App.tsx`、`src/components/rpg-runtime-shell/RpgRuntimeShell.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
## Windows provision 下载截断要断点续传而不是回退目标机下载
|
## Windows provision 下载截断要断点续传而不是回退目标机下载
|
||||||
|
|
||||||
- 现象:`Genarrative-Server-Provision` 在 `Download Provision Tool Archives` 阶段出现 `curl: (18) end of response ... bytes missing`,常见于 `otelcol-contrib_0.151.0_linux_amd64.tar.gz` 等 GitHub release 大文件。
|
- 现象:`Genarrative-Server-Provision` 在 `Download Provision Tool Archives` 阶段出现 `curl: (18) end of response ... bytes missing`,常见于 `otelcol-contrib_0.151.0_linux_amd64.tar.gz` 等 GitHub release 大文件。
|
||||||
|
|||||||
@@ -48,12 +48,16 @@ RPG 是历史既有链路例外:当前仍使用对话式 Agent 共创工作台
|
|||||||
|
|
||||||
RPG API 仍沿用历史命名空间:`/api/runtime/custom-world*`、`/api/story/*`、`/api/runtime/chat/*`。这些路由在 `api-server` 入口熔断中统一映射到 `rpg`,只按 `open` 判断是否允许调用;`visible` 只控制创作页入口展示和作品架可见性。
|
RPG API 仍沿用历史命名空间:`/api/runtime/custom-world*`、`/api/story/*`、`/api/runtime/chat/*`。这些路由在 `api-server` 入口熔断中统一映射到 `rpg`,只按 `open` 判断是否允许调用;`visible` 只控制创作页入口展示和作品架可见性。
|
||||||
|
|
||||||
RPG Agent 结果页发布动作的前端契约只保证提交 `{ action: 'publish_world' }`;后端发布时以当前 `custom_world_agent_session.draft_profile_json` 为草稿真相,从 `settingText`、`creatorIntent.rawSettingText`、`creatorIntent.worldHook`、`worldHook`、`anchorContent.worldPromise(.hook)`、`summary`、`name/title` 依次派生正式 `setting_text`,最后才回退 `seed_text`。不要把 `seed_text` 当作唯一设定来源,旧会话可能为空。
|
RPG Agent 结果页点击发布或发布并进入世界时,必须先把结果页当前 profile 通过 `sync_result_profile` 保存回 `custom_world_agent_session.draft_profile_json`,再发送发布动作;发布动作前端契约只允许提交 `{ action: 'publish_world' }`,`api-server` 只补作者公开信息,不转发 `profile`、`draftProfile`、`legacyResultProfile` 或 `settingText`。`spacetime-module` 发布时只读取当前 session 的 `draft_profile_json` 作为草稿真相,从 `settingText`、`creatorIntent.rawSettingText`、`creatorIntent.worldHook`、`worldHook`、`anchorContent.worldPromise(.hook)`、`summary`、`name/title` 依次派生正式 `setting_text`,最后才回退 `seed_text`。不要把 `seed_text` 当作唯一设定来源,旧会话可能为空。
|
||||||
|
|
||||||
`legacyResultProfile` 只作为历史结果页 profile 兼容兜底;`publish_world` 请求缺省或显式为 `null` 时等价于未提供,编译正式 profile 时不得因此报 `custom_world.compile.legacy_result_profile_json 不是合法 JSON object`。真正的数组、字符串、数字等非 object legacy 载荷仍应拒绝。
|
Agent session 已进入 `published` 后,结果页按钮只能执行“进入世界”:前端需先通过 `result-view` 回读已发布 profile 并启动运行态,不得再次调用 `sync_result_profile` 或发送 `{ action: 'publish_world' }`。`publish_world` 只允许在 `object_refining`、`visual_refining`、`long_tail_review`、`ready_to_publish` 等发布前阶段触发;否则会被后端阶段门槛拒绝。
|
||||||
|
|
||||||
|
`legacyResultProfile` 只作为历史结果页 profile 兼容兜底;编译正式 profile 时,session 草稿内已保存字段优先于 legacy 字段,legacy 只能补缺失字段。`publish_world` 不再接受前端临时传入的 legacy 载荷;历史兼容路径中 legacy 缺省或显式为 `null` 时等价于未提供,不得因此报 `custom_world.compile.legacy_result_profile_json 不是合法 JSON object`。真正的数组、字符串、数字等非 object legacy 载荷仍应拒绝。
|
||||||
|
|
||||||
RPG 结果页开局 CG 是 `profile.openingCg` 资产槽位:`api-server` 负责 VectorEngine / OSS 副作用并返回故事板和视频引用,前端只把结果写回当前 profile;`sync_result_profile`、作品库保存和 `normalizeCustomWorldProfileRecord` 都必须保留该槽位。若生成成功后画面短暂显示又变回空白,优先检查父层重新同步或 profile 归一化是否把 `openingCg` 丢掉,而不是先怀疑已生成资源本身失效。
|
RPG 结果页开局 CG 是 `profile.openingCg` 资产槽位:`api-server` 负责 VectorEngine / OSS 副作用并返回故事板和视频引用,前端只把结果写回当前 profile;`sync_result_profile`、作品库保存和 `normalizeCustomWorldProfileRecord` 都必须保留该槽位。若生成成功后画面短暂显示又变回空白,优先检查父层重新同步或 profile 归一化是否把 `openingCg` 丢掉,而不是先怀疑已生成资源本身失效。
|
||||||
|
|
||||||
|
RPG 从作品架、广场详情或作品号搜索点击“启动”前,入口 client 必须把后端返回的完整 `profile` 先经过 `normalizeCustomWorldProfileRecord`,并用作品条目的 `profileId/worldName/subtitle/summaryText` 补齐旧数据缺失字段;运行态和详情页不得直接消费未归一化的旧 profile。角色选择页还需要在角色数组异常或为空时回退默认角色,并显示可返回的轻量空态,不能 `return null` 造成黑屏。运行态懒加载 fallback 必须可见,不能用纯 `null` 让用户误判为黑屏。
|
||||||
|
|
||||||
## 拼图
|
## 拼图
|
||||||
|
|
||||||
当前拼图链路:
|
当前拼图链路:
|
||||||
|
|||||||
@@ -1626,39 +1626,20 @@ pub async fn execute_custom_world_agent_action(
|
|||||||
)
|
)
|
||||||
})?
|
})?
|
||||||
} else if action == "publish_world" {
|
} else if action == "publish_world" {
|
||||||
let mut publish_payload = serde_json::to_value(&payload).map_err(|error| {
|
let publish_payload = serialize_publish_world_action_payload(
|
||||||
|
resolve_author_public_user_code(&state, &authenticated, &request_context)?,
|
||||||
|
resolve_author_display_name(&state, &authenticated),
|
||||||
|
)
|
||||||
|
.map_err(|error| {
|
||||||
custom_world_error_response(
|
custom_world_error_response(
|
||||||
&request_context,
|
&request_context,
|
||||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||||
"provider": "custom-world-agent",
|
"provider": "custom-world-agent",
|
||||||
"message": format!("action payload JSON 序列化失败:{error}"),
|
"message": error,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
if let Some(object) = publish_payload.as_object_mut() {
|
publish_payload
|
||||||
// 发布到广场时必须写入真实作者公开信息,避免 gallery 投影落成匿名兜底数据。
|
|
||||||
object.insert(
|
|
||||||
"authorPublicUserCode".to_string(),
|
|
||||||
Value::String(resolve_author_public_user_code(
|
|
||||||
&state,
|
|
||||||
&authenticated,
|
|
||||||
&request_context,
|
|
||||||
)?),
|
|
||||||
);
|
|
||||||
object.insert(
|
|
||||||
"authorDisplayName".to_string(),
|
|
||||||
Value::String(resolve_author_display_name(&state, &authenticated)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
serde_json::to_string(&publish_payload).map_err(|error| {
|
|
||||||
custom_world_error_response(
|
|
||||||
&request_context,
|
|
||||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
|
||||||
"provider": "custom-world-agent",
|
|
||||||
"message": format!("action payload JSON 序列化失败:{error}"),
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
})?
|
|
||||||
} else {
|
} else {
|
||||||
serde_json::to_string(&payload).map_err(|error| {
|
serde_json::to_string(&payload).map_err(|error| {
|
||||||
custom_world_error_response(
|
custom_world_error_response(
|
||||||
@@ -1734,6 +1715,23 @@ fn serialize_sync_result_profile_action_payload(
|
|||||||
.map_err(|error| format!("action payload JSON 序列化失败:{error}"))
|
.map_err(|error| format!("action payload JSON 序列化失败:{error}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn serialize_publish_world_action_payload(
|
||||||
|
author_public_user_code: String,
|
||||||
|
author_display_name: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
// 中文注释:发布动作只提交动作名和作者公开信息。
|
||||||
|
// 结果页当前 profile 必须先通过 sync_result_profile 写入 session;
|
||||||
|
// SpacetimeDB 发布时再从 session.draft_profile_json 读取草稿真相,避免前端
|
||||||
|
// draftProfile / legacyResultProfile / profile 旧载荷覆盖刚保存的内容。
|
||||||
|
let payload_value = json!({
|
||||||
|
"action": "publish_world",
|
||||||
|
"authorPublicUserCode": author_public_user_code,
|
||||||
|
"authorDisplayName": author_display_name,
|
||||||
|
});
|
||||||
|
serde_json::to_string(&payload_value)
|
||||||
|
.map_err(|error| format!("action payload JSON 序列化失败:{error}"))
|
||||||
|
}
|
||||||
|
|
||||||
fn canonicalize_custom_world_library_profile_payload(
|
fn canonicalize_custom_world_library_profile_payload(
|
||||||
mut profile: Value,
|
mut profile: Value,
|
||||||
) -> Result<(Value, CustomWorldProfileMetadata), String> {
|
) -> Result<(Value, CustomWorldProfileMetadata), String> {
|
||||||
@@ -3414,6 +3412,36 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn publish_world_payload_only_contains_action_and_author_identity() {
|
||||||
|
let payload_json =
|
||||||
|
serialize_publish_world_action_payload("TN-0001".to_string(), "潮汐作者".to_string())
|
||||||
|
.expect("publish payload serializes");
|
||||||
|
let payload_value: Value =
|
||||||
|
serde_json::from_str(&payload_json).expect("payload should be valid JSON");
|
||||||
|
let object = payload_value
|
||||||
|
.as_object()
|
||||||
|
.expect("publish payload should be object");
|
||||||
|
|
||||||
|
assert_eq!(object.len(), 3);
|
||||||
|
assert_eq!(
|
||||||
|
object.get("action").and_then(Value::as_str),
|
||||||
|
Some("publish_world")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
object.get("authorPublicUserCode").and_then(Value::as_str),
|
||||||
|
Some("TN-0001")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
object.get("authorDisplayName").and_then(Value::as_str),
|
||||||
|
Some("潮汐作者")
|
||||||
|
);
|
||||||
|
assert!(!object.contains_key("profile"));
|
||||||
|
assert!(!object.contains_key("draftProfile"));
|
||||||
|
assert!(!object.contains_key("legacyResultProfile"));
|
||||||
|
assert!(!object.contains_key("settingText"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn custom_world_library_profile_payload_is_canonicalized_on_server() {
|
fn custom_world_library_profile_payload_is_canonicalized_on_server() {
|
||||||
let (profile, metadata) = canonicalize_custom_world_library_profile_payload(json!({
|
let (profile, metadata) = canonicalize_custom_world_library_profile_payload(json!({
|
||||||
|
|||||||
@@ -544,7 +544,7 @@ pub fn build_custom_world_published_profile_compile_snapshot(
|
|||||||
let subtitle = resolve_text_field(&draft, &legacy, "subtitle").unwrap_or_default();
|
let subtitle = resolve_text_field(&draft, &legacy, "subtitle").unwrap_or_default();
|
||||||
let summary_text = resolve_text_field(&draft, &legacy, "summary").unwrap_or_default();
|
let summary_text = resolve_text_field(&draft, &legacy, "summary").unwrap_or_default();
|
||||||
let cover_image_src = resolve_cover_image_src(&draft, &legacy);
|
let cover_image_src = resolve_cover_image_src(&draft, &legacy);
|
||||||
let theme_mode = resolve_theme_mode(&legacy);
|
let theme_mode = resolve_theme_mode(&draft, &legacy);
|
||||||
let playable_npc_count =
|
let playable_npc_count =
|
||||||
count_distinct_roles(draft.get("playableNpcs"), draft.get("storyNpcs"));
|
count_distinct_roles(draft.get("playableNpcs"), draft.get("storyNpcs"));
|
||||||
let landmark_count = to_array(draft.get("landmarks")).len() as u32;
|
let landmark_count = to_array(draft.get("landmarks")).len() as u32;
|
||||||
@@ -912,11 +912,17 @@ fn resolve_text_field(
|
|||||||
legacy: &Map<String, Value>,
|
legacy: &Map<String, Value>,
|
||||||
key: &str,
|
key: &str,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
|
// 中文注释:发布链路的草稿真相来自 session.draft_profile_json,
|
||||||
|
// legacyResultProfile 只补历史草稿缺失字段,不能覆盖结果页刚保存的内容。
|
||||||
to_text(draft.get(key)).or_else(|| to_text(legacy.get(key)))
|
to_text(draft.get(key)).or_else(|| to_text(legacy.get(key)))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_theme_mode(legacy: &Map<String, Value>) -> CustomWorldThemeMode {
|
fn resolve_theme_mode(
|
||||||
to_text(legacy.get("themeMode"))
|
draft: &Map<String, Value>,
|
||||||
|
legacy: &Map<String, Value>,
|
||||||
|
) -> CustomWorldThemeMode {
|
||||||
|
to_text(draft.get("themeMode"))
|
||||||
|
.or_else(|| to_text(legacy.get("themeMode")))
|
||||||
.and_then(|value| CustomWorldThemeMode::from_client_str(&value))
|
.and_then(|value| CustomWorldThemeMode::from_client_str(&value))
|
||||||
.unwrap_or(CustomWorldThemeMode::Mythic)
|
.unwrap_or(CustomWorldThemeMode::Mythic)
|
||||||
}
|
}
|
||||||
@@ -1067,6 +1073,47 @@ mod tests {
|
|||||||
assert_eq!(error, CustomWorldFieldError::InvalidLegacyResultProfileJson);
|
assert_eq!(error, CustomWorldFieldError::InvalidLegacyResultProfileJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn published_profile_compile_prefers_saved_draft_over_legacy_profile() {
|
||||||
|
let input = CustomWorldPublishedProfileCompileInput {
|
||||||
|
draft_profile_json: json!({
|
||||||
|
"name": "结果页保存后的世界",
|
||||||
|
"summary": "发布前最后一次填写的摘要。",
|
||||||
|
"themeMode": "tide",
|
||||||
|
"playableNpcs": [],
|
||||||
|
"storyNpcs": [],
|
||||||
|
"landmarks": []
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
legacy_result_profile_json: Some(
|
||||||
|
json!({
|
||||||
|
"name": "旧结果页世界",
|
||||||
|
"summary": "旧摘要不应覆盖保存草稿。",
|
||||||
|
"themeMode": "mythic"
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
..build_test_compile_input(None)
|
||||||
|
};
|
||||||
|
|
||||||
|
let snapshot = build_custom_world_published_profile_compile_snapshot(input)
|
||||||
|
.expect("compile should prefer saved draft");
|
||||||
|
let payload: Value = serde_json::from_str(&snapshot.compiled_profile_payload_json)
|
||||||
|
.expect("compiled payload should be json");
|
||||||
|
|
||||||
|
assert_eq!(snapshot.world_name, "结果页保存后的世界");
|
||||||
|
assert_eq!(snapshot.summary_text, "发布前最后一次填写的摘要。");
|
||||||
|
assert_eq!(snapshot.theme_mode, CustomWorldThemeMode::Tide);
|
||||||
|
assert_eq!(
|
||||||
|
payload.get("name").and_then(Value::as_str),
|
||||||
|
Some("结果页保存后的世界")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload.get("summary").and_then(Value::as_str),
|
||||||
|
Some("发布前最后一次填写的摘要。")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn publish_setting_text_falls_back_to_draft_profile_when_seed_is_empty() {
|
fn publish_setting_text_falls_back_to_draft_profile_when_seed_is_empty() {
|
||||||
let payload = Map::new();
|
let payload = Map::new();
|
||||||
@@ -1079,8 +1126,7 @@ mod tests {
|
|||||||
.cloned()
|
.cloned()
|
||||||
.expect("draft profile should be object");
|
.expect("draft profile should be object");
|
||||||
|
|
||||||
let setting_text =
|
let setting_text = resolve_custom_world_publish_setting_text(&payload, &draft_profile, "");
|
||||||
resolve_custom_world_publish_setting_text(&payload, &draft_profile, "");
|
|
||||||
|
|
||||||
assert_eq!(setting_text, "海雾会吞掉记错航线的人。");
|
assert_eq!(setting_text, "海雾会吞掉记错航线的人。");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2593,13 +2593,10 @@ fn execute_publish_world_action(
|
|||||||
) -> Result<CustomWorldAgentOperationSnapshot, String> {
|
) -> Result<CustomWorldAgentOperationSnapshot, String> {
|
||||||
ensure_publishable_stage(session.stage, "publish_world")?;
|
ensure_publishable_stage(session.stage, "publish_world")?;
|
||||||
|
|
||||||
let draft_profile =
|
// 中文注释:发布动作不再信任前端携带的 draftProfile。
|
||||||
if let Some(explicit) = payload.get("draftProfile").and_then(JsonValue::as_object) {
|
// 点击发布前,结果页 profile 必须先通过 sync_result_profile 写回
|
||||||
explicit.clone()
|
// custom_world_agent_session.draft_profile_json;正式发布只读取这份会话真相。
|
||||||
} else {
|
let draft_profile = read_publish_world_draft_profile_from_session(session)?;
|
||||||
parse_optional_session_object(session.draft_profile_json.as_deref())
|
|
||||||
.ok_or_else(|| "publish_world requires draft_profile_json".to_string())?
|
|
||||||
};
|
|
||||||
let gate = summarize_publish_gate_from_json(
|
let gate = summarize_publish_gate_from_json(
|
||||||
&session.session_id,
|
&session.session_id,
|
||||||
session.stage,
|
session.stage,
|
||||||
@@ -2613,18 +2610,9 @@ fn execute_publish_world_action(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let profile_id = payload
|
let profile_id = gate.profile_id.clone();
|
||||||
.get("profileId")
|
|
||||||
.and_then(JsonValue::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
.unwrap_or_else(|| gate.profile_id.clone());
|
|
||||||
let setting_text = resolve_publish_world_setting_text(payload, &draft_profile, session);
|
let setting_text = resolve_publish_world_setting_text(payload, &draft_profile, session);
|
||||||
let legacy_result_profile_json = payload
|
let legacy_result_profile_json = None;
|
||||||
.get("legacyResultProfile")
|
|
||||||
.map(serialize_json_value)
|
|
||||||
.transpose()?;
|
|
||||||
let author_public_user_code = read_optional_text_field(payload, &["authorPublicUserCode"])
|
let author_public_user_code = read_optional_text_field(payload, &["authorPublicUserCode"])
|
||||||
.unwrap_or_else(|| build_public_user_code_from_owner_user_id(&session.owner_user_id));
|
.unwrap_or_else(|| build_public_user_code_from_owner_user_id(&session.owner_user_id));
|
||||||
let author_display_name = read_optional_text_field(payload, &["authorDisplayName"])
|
let author_display_name = read_optional_text_field(payload, &["authorDisplayName"])
|
||||||
@@ -2669,6 +2657,13 @@ fn execute_publish_world_action(
|
|||||||
Ok(build_custom_world_agent_operation_snapshot(&operation))
|
Ok(build_custom_world_agent_operation_snapshot(&operation))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn read_publish_world_draft_profile_from_session(
|
||||||
|
session: &CustomWorldAgentSession,
|
||||||
|
) -> Result<JsonMap<String, JsonValue>, String> {
|
||||||
|
parse_optional_session_object(session.draft_profile_json.as_deref())
|
||||||
|
.ok_or_else(|| "publish_world requires draft_profile_json".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
fn execute_revert_checkpoint_action(
|
fn execute_revert_checkpoint_action(
|
||||||
ctx: &ReducerContext,
|
ctx: &ReducerContext,
|
||||||
session: &CustomWorldAgentSession,
|
session: &CustomWorldAgentSession,
|
||||||
@@ -5256,6 +5251,26 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn publish_world_draft_profile_comes_from_session_not_payload() {
|
||||||
|
let session = build_test_custom_world_agent_session(
|
||||||
|
"seed",
|
||||||
|
RpgAgentStage::ReadyToPublish,
|
||||||
|
Some(r#"{"id":"saved-profile","name":"已保存草稿"}"#),
|
||||||
|
);
|
||||||
|
let draft_profile =
|
||||||
|
read_publish_world_draft_profile_from_session(&session).expect("session draft exists");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
draft_profile.get("id").and_then(JsonValue::as_str),
|
||||||
|
Some("saved-profile")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
draft_profile.get("name").and_then(JsonValue::as_str),
|
||||||
|
Some("已保存草稿")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn custom_world_agent_session_direct_work_content_ignores_empty_created_session() {
|
fn custom_world_agent_session_direct_work_content_ignores_empty_created_session() {
|
||||||
let empty_session =
|
let empty_session =
|
||||||
|
|||||||
12
src/App.tsx
12
src/App.tsx
@@ -32,6 +32,16 @@ const RpgRuntimeApp = lazy(async () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function RuntimeLoadingFallback() {
|
||||||
|
return (
|
||||||
|
<div className="platform-ui-shell platform-viewport-shell platform-theme platform-theme--dark flex h-screen items-center justify-center bg-[image:var(--platform-body-fill)] p-4 font-sans text-[var(--platform-text-strong)]">
|
||||||
|
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-zinc-300">
|
||||||
|
正在启动
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function isRpgRuntimeRoute(pathname: string) {
|
function isRpgRuntimeRoute(pathname: string) {
|
||||||
const normalizedPath = normalizeAppPath(pathname);
|
const normalizedPath = normalizeAppPath(pathname);
|
||||||
return (
|
return (
|
||||||
@@ -126,7 +136,7 @@ export default function App() {
|
|||||||
|
|
||||||
if (isRuntimeActive) {
|
if (isRuntimeActive) {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={<RuntimeLoadingFallback />}>
|
||||||
<RpgRuntimeApp
|
<RpgRuntimeApp
|
||||||
initialIntent={runtimeIntent}
|
initialIntent={runtimeIntent}
|
||||||
onExitRuntime={() => {
|
onExitRuntime={() => {
|
||||||
|
|||||||
@@ -3216,6 +3216,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
const enterWorldCoordinator = useRpgCreationEnterWorld({
|
const enterWorldCoordinator = useRpgCreationEnterWorld({
|
||||||
isAgentDraftResultView: sessionController.isAgentDraftResultView,
|
isAgentDraftResultView: sessionController.isAgentDraftResultView,
|
||||||
activeAgentSessionId: sessionController.activeAgentSessionId,
|
activeAgentSessionId: sessionController.activeAgentSessionId,
|
||||||
|
currentAgentSessionStage: sessionController.agentSession?.stage ?? null,
|
||||||
generatedCustomWorldProfile: sessionController.generatedCustomWorldProfile,
|
generatedCustomWorldProfile: sessionController.generatedCustomWorldProfile,
|
||||||
handleCustomWorldSelect,
|
handleCustomWorldSelect,
|
||||||
syncAgentDraftResultProfile:
|
syncAgentDraftResultProfile:
|
||||||
|
|||||||
@@ -15,7 +15,51 @@ import {
|
|||||||
import { RpgEntryCharacterSelectView } from './RpgEntryCharacterSelectView';
|
import { RpgEntryCharacterSelectView } from './RpgEntryCharacterSelectView';
|
||||||
|
|
||||||
vi.mock('../../data/characterPresets', () => ({
|
vi.mock('../../data/characterPresets', () => ({
|
||||||
ROLE_TEMPLATE_CHARACTERS: [],
|
ROLE_TEMPLATE_CHARACTERS: [
|
||||||
|
{
|
||||||
|
id: 'fallback-hero',
|
||||||
|
name: '兜底侠',
|
||||||
|
title: '默认角色',
|
||||||
|
description: '兜底角色',
|
||||||
|
backstory: '兜底背景',
|
||||||
|
personality: '冷静 果断',
|
||||||
|
gender: 'unknown',
|
||||||
|
portrait: '/portraits/fallback.png',
|
||||||
|
attributes: {
|
||||||
|
strength: 8,
|
||||||
|
agility: 8,
|
||||||
|
intelligence: 8,
|
||||||
|
spirit: 8,
|
||||||
|
},
|
||||||
|
attributeProfile: {
|
||||||
|
schemaId: 'schema:custom:fallback',
|
||||||
|
values: {
|
||||||
|
axis_a: 8,
|
||||||
|
axis_b: 8,
|
||||||
|
axis_c: 8,
|
||||||
|
axis_d: 8,
|
||||||
|
axis_e: 8,
|
||||||
|
axis_f: 8,
|
||||||
|
},
|
||||||
|
evidence: [],
|
||||||
|
},
|
||||||
|
attributeProfiles: {
|
||||||
|
CUSTOM: {
|
||||||
|
schemaId: 'schema:custom:fallback',
|
||||||
|
values: {
|
||||||
|
axis_a: 8,
|
||||||
|
axis_b: 8,
|
||||||
|
axis_c: 8,
|
||||||
|
axis_d: 8,
|
||||||
|
axis_e: 8,
|
||||||
|
axis_f: 8,
|
||||||
|
},
|
||||||
|
evidence: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
skills: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
buildCustomWorldPlayableCharacters: vi.fn(),
|
buildCustomWorldPlayableCharacters: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -190,3 +234,46 @@ test('custom world character selection stays stable when character ids are empty
|
|||||||
|
|
||||||
expect(duplicateKeyCalls).toHaveLength(0);
|
expect(duplicateKeyCalls).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('custom world character selection falls back instead of rendering a blank screen when profile characters are malformed', () => {
|
||||||
|
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||||
|
vi.mocked(buildCustomWorldPlayableCharacters).mockImplementation(() => {
|
||||||
|
throw new TypeError('profile.playableNpcs is not iterable');
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<RpgEntryCharacterSelectView
|
||||||
|
worldType={WorldType.CUSTOM}
|
||||||
|
customWorldProfile={{
|
||||||
|
id: 'broken-profile',
|
||||||
|
name: '坏数据',
|
||||||
|
attributeSchema: {
|
||||||
|
id: 'schema:custom:fallback',
|
||||||
|
worldId: 'broken-profile',
|
||||||
|
schemaVersion: 1,
|
||||||
|
generatedFrom: {
|
||||||
|
worldType: WorldType.CUSTOM,
|
||||||
|
worldName: '坏数据',
|
||||||
|
settingSummary: '坏数据',
|
||||||
|
tone: '测试',
|
||||||
|
conflictCore: '测试',
|
||||||
|
},
|
||||||
|
slots: [
|
||||||
|
{ slotId: 'axis_a', name: '骨势' },
|
||||||
|
{ slotId: 'axis_b', name: '身法' },
|
||||||
|
{ slotId: 'axis_c', name: '眼脉' },
|
||||||
|
{ slotId: 'axis_d', name: '心焰' },
|
||||||
|
{ slotId: 'axis_e', name: '尘缘' },
|
||||||
|
{ slotId: 'axis_f', name: '玄息' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as unknown as CustomWorldProfile}
|
||||||
|
onBack={() => {}}
|
||||||
|
onConfirm={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('选择你的角色')).toBeTruthy();
|
||||||
|
expect(screen.getAllByText('兜底侠').length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByRole('button', { name: /进入营地/u })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|||||||
@@ -112,6 +112,19 @@ function buildSelectionCharacterKey(character: Character, index: number) {
|
|||||||
return `selection-character-${index}-${fallbackSeed}`;
|
return `selection-character-${index}-${fallbackSeed}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveSelectionCharacters(profile: CustomWorldProfile | null) {
|
||||||
|
try {
|
||||||
|
const characters = profile
|
||||||
|
? buildCustomWorldPlayableCharacters(profile)
|
||||||
|
: ROLE_TEMPLATE_CHARACTERS;
|
||||||
|
|
||||||
|
return characters.length > 0 ? characters : ROLE_TEMPLATE_CHARACTERS;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('自定义世界角色数据异常,已回退默认角色。', error);
|
||||||
|
return ROLE_TEMPLATE_CHARACTERS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function applyCharacterSelectionDraft(
|
function applyCharacterSelectionDraft(
|
||||||
character: Character | null,
|
character: Character | null,
|
||||||
draft?: CharacterSelectionDraft | null,
|
draft?: CharacterSelectionDraft | null,
|
||||||
@@ -209,7 +222,7 @@ export function RpgEntryCharacterSelectView({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
}: RpgEntryCharacterSelectViewProps) {
|
}: RpgEntryCharacterSelectViewProps) {
|
||||||
const selectionCharacters = useMemo(
|
const selectionCharacters = useMemo(
|
||||||
() => (customWorldProfile ? buildCustomWorldPlayableCharacters(customWorldProfile) : ROLE_TEMPLATE_CHARACTERS),
|
() => resolveSelectionCharacters(customWorldProfile),
|
||||||
[customWorldProfile],
|
[customWorldProfile],
|
||||||
);
|
);
|
||||||
const selectionEntries = useMemo(
|
const selectionEntries = useMemo(
|
||||||
@@ -329,7 +342,18 @@ export function RpgEntryCharacterSelectView({
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!selectedCharacter || !selectedCharacterMeta) {
|
if (!selectedCharacter || !selectedCharacterMeta) {
|
||||||
return null;
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-col items-center justify-center gap-4 text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onBack}
|
||||||
|
className="rounded-full border border-white/10 bg-black/18 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white"
|
||||||
|
>
|
||||||
|
返回
|
||||||
|
</button>
|
||||||
|
<div className="text-sm text-zinc-300">角色数据暂不可用</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { act, render } from '@testing-library/react';
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||||
|
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
|
||||||
|
import type { CustomWorldProfileRecord } from '../../../packages/shared/src/contracts/runtime';
|
||||||
import { type CustomWorldProfile, WorldType } from '../../types';
|
import { type CustomWorldProfile, WorldType } from '../../types';
|
||||||
import { useRpgCreationEnterWorld } from './useRpgCreationEnterWorld';
|
import { useRpgCreationEnterWorld } from './useRpgCreationEnterWorld';
|
||||||
|
|
||||||
@@ -69,7 +71,9 @@ function buildProfile(params: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSession(): CustomWorldAgentSessionSnapshot {
|
function buildSession(
|
||||||
|
stage: CustomWorldAgentSessionSnapshot['stage'] = 'ready_to_publish',
|
||||||
|
): CustomWorldAgentSessionSnapshot {
|
||||||
return {
|
return {
|
||||||
sessionId: 'session-1',
|
sessionId: 'session-1',
|
||||||
currentTurn: 1,
|
currentTurn: 1,
|
||||||
@@ -85,7 +89,7 @@ function buildSession(): CustomWorldAgentSessionSnapshot {
|
|||||||
},
|
},
|
||||||
progressPercent: 100,
|
progressPercent: 100,
|
||||||
lastAssistantReply: '',
|
lastAssistantReply: '',
|
||||||
stage: 'ready_to_publish',
|
stage,
|
||||||
focusCardId: null,
|
focusCardId: null,
|
||||||
creatorIntent: null,
|
creatorIntent: null,
|
||||||
creatorIntentReadiness: {
|
creatorIntentReadiness: {
|
||||||
@@ -113,6 +117,31 @@ function buildSession(): CustomWorldAgentSessionSnapshot {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildResultView(params: {
|
||||||
|
stage?: CustomWorldAgentSessionSnapshot['stage'];
|
||||||
|
profile: CustomWorldProfile | null;
|
||||||
|
canEnterWorld?: boolean;
|
||||||
|
}): RpgCreationResultView {
|
||||||
|
const stage = params.stage ?? 'ready_to_publish';
|
||||||
|
const profileRecord = params.profile
|
||||||
|
? (structuredClone(params.profile) as unknown as CustomWorldProfileRecord)
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
session: buildSession(stage),
|
||||||
|
profile: profileRecord,
|
||||||
|
profileSource: profileRecord ? 'result_preview' : 'none',
|
||||||
|
targetStage: 'custom-world-result',
|
||||||
|
generationViewSource: null,
|
||||||
|
resultViewSource: profileRecord ? 'agent-draft' : null,
|
||||||
|
canAutosaveLibrary: true,
|
||||||
|
canSyncResultProfile: stage !== 'published',
|
||||||
|
publishReady: true,
|
||||||
|
canEnterWorld: params.canEnterWorld ?? stage === 'published',
|
||||||
|
blockerCount: 0,
|
||||||
|
recoveryAction: 'open_result',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('useRpgCreationEnterWorld', () => {
|
describe('useRpgCreationEnterWorld', () => {
|
||||||
it('Agent 草稿测试进入游戏时优先使用结果页当前 profile,而不是回退到会话快照', async () => {
|
it('Agent 草稿测试进入游戏时优先使用结果页当前 profile,而不是回退到会话快照', async () => {
|
||||||
const resultProfile = buildProfile({
|
const resultProfile = buildProfile({
|
||||||
@@ -167,4 +196,148 @@ describe('useRpgCreationEnterWorld', () => {
|
|||||||
handleCustomWorldSelect.mock.calls[0]?.[0].playableNpcs[0]?.imageSrc,
|
handleCustomWorldSelect.mock.calls[0]?.[0].playableNpcs[0]?.imageSrc,
|
||||||
).toBe('/generated-characters/draft-role/portrait.png');
|
).toBe('/generated-characters/draft-role/portrait.png');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Agent 草稿发布时先保存当前结果页 profile,再发送 publish_world 并回读结果页', async () => {
|
||||||
|
const resultProfile = buildProfile({
|
||||||
|
id: 'draft-profile',
|
||||||
|
name: '发布前填写内容',
|
||||||
|
imageSrc: '/generated-characters/draft-role/portrait.png',
|
||||||
|
});
|
||||||
|
const syncedProfile = buildProfile({
|
||||||
|
id: 'draft-profile',
|
||||||
|
name: '已保存的填写内容',
|
||||||
|
imageSrc: '/generated-characters/draft-role/synced.png',
|
||||||
|
});
|
||||||
|
const publishedProfile = buildProfile({
|
||||||
|
id: 'draft-profile',
|
||||||
|
name: '已发布世界',
|
||||||
|
imageSrc: '/generated-characters/draft-role/published.png',
|
||||||
|
});
|
||||||
|
const callOrder: string[] = [];
|
||||||
|
const handleCustomWorldSelect = vi.fn();
|
||||||
|
const setGeneratedCustomWorldProfile = vi.fn();
|
||||||
|
const syncAgentDraftResultProfile = vi.fn(async () => {
|
||||||
|
callOrder.push('save');
|
||||||
|
return {
|
||||||
|
profile: syncedProfile,
|
||||||
|
view: buildResultView({
|
||||||
|
stage: 'ready_to_publish',
|
||||||
|
profile: syncedProfile,
|
||||||
|
canEnterWorld: false,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const executePublishWorld = vi.fn(async () => {
|
||||||
|
callOrder.push('publish');
|
||||||
|
return buildSession('published');
|
||||||
|
});
|
||||||
|
const syncAgentCreationResultView = vi.fn(async () => {
|
||||||
|
callOrder.push('reload');
|
||||||
|
return buildResultView({
|
||||||
|
stage: 'published',
|
||||||
|
profile: publishedProfile,
|
||||||
|
canEnterWorld: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function Harness() {
|
||||||
|
const { publishCurrentResult } = useRpgCreationEnterWorld({
|
||||||
|
isAgentDraftResultView: true,
|
||||||
|
activeAgentSessionId: 'session-1',
|
||||||
|
currentAgentSessionStage: 'ready_to_publish',
|
||||||
|
generatedCustomWorldProfile: resultProfile,
|
||||||
|
handleCustomWorldSelect,
|
||||||
|
syncAgentDraftResultProfile,
|
||||||
|
executePublishWorld,
|
||||||
|
syncAgentCreationResultView,
|
||||||
|
setGeneratedCustomWorldProfile,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={() => void publishCurrentResult()}>
|
||||||
|
发布
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { getByText } = render(<Harness />);
|
||||||
|
await act(async () => {
|
||||||
|
getByText('发布').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(callOrder).toEqual(['save', 'publish', 'reload']);
|
||||||
|
expect(syncAgentDraftResultProfile).toHaveBeenCalledWith(resultProfile);
|
||||||
|
expect(executePublishWorld).toHaveBeenCalledTimes(1);
|
||||||
|
expect(syncAgentCreationResultView).toHaveBeenCalledWith('session-1');
|
||||||
|
expect(setGeneratedCustomWorldProfile).toHaveBeenCalledWith(syncedProfile);
|
||||||
|
expect(
|
||||||
|
setGeneratedCustomWorldProfile.mock.calls.at(-1)?.[0]?.id,
|
||||||
|
).toBe('draft-profile');
|
||||||
|
expect(
|
||||||
|
setGeneratedCustomWorldProfile.mock.calls.at(-1)?.[0]?.playableNpcs[0]
|
||||||
|
?.imageSrc,
|
||||||
|
).toBe('/generated-characters/draft-role/published.png');
|
||||||
|
expect(handleCustomWorldSelect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Agent 会话已发布后点击进入世界不再重复发送 publish_world', async () => {
|
||||||
|
const resultProfile = buildProfile({
|
||||||
|
id: 'published-profile',
|
||||||
|
name: '已发布世界',
|
||||||
|
imageSrc: '/generated-characters/published-role/portrait.png',
|
||||||
|
});
|
||||||
|
const publishedView = buildResultView({
|
||||||
|
stage: 'published',
|
||||||
|
profile: resultProfile,
|
||||||
|
canEnterWorld: true,
|
||||||
|
});
|
||||||
|
const handleCustomWorldSelect = vi.fn();
|
||||||
|
const setGeneratedCustomWorldProfile = vi.fn();
|
||||||
|
const executePublishWorld = vi.fn(async () => buildSession('published'));
|
||||||
|
const syncAgentCreationResultView = vi.fn(async () => publishedView);
|
||||||
|
const syncAgentDraftResultProfile = vi.fn(async () => ({
|
||||||
|
profile: resultProfile,
|
||||||
|
view: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function Harness() {
|
||||||
|
const { enterWorldFromCurrentResult } = useRpgCreationEnterWorld({
|
||||||
|
isAgentDraftResultView: true,
|
||||||
|
activeAgentSessionId: 'session-1',
|
||||||
|
currentAgentSessionStage: 'published',
|
||||||
|
generatedCustomWorldProfile: resultProfile,
|
||||||
|
handleCustomWorldSelect,
|
||||||
|
syncAgentDraftResultProfile,
|
||||||
|
executePublishWorld,
|
||||||
|
syncAgentCreationResultView,
|
||||||
|
setGeneratedCustomWorldProfile,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void enterWorldFromCurrentResult()}
|
||||||
|
>
|
||||||
|
进入世界
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { getByText } = render(<Harness />);
|
||||||
|
await act(async () => {
|
||||||
|
getByText('进入世界').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(syncAgentDraftResultProfile).not.toHaveBeenCalled();
|
||||||
|
expect(executePublishWorld).not.toHaveBeenCalled();
|
||||||
|
expect(syncAgentCreationResultView).toHaveBeenCalledWith('session-1');
|
||||||
|
expect(setGeneratedCustomWorldProfile).toHaveBeenCalledTimes(1);
|
||||||
|
expect(setGeneratedCustomWorldProfile.mock.calls[0]?.[0]?.id).toBe(
|
||||||
|
'published-profile',
|
||||||
|
);
|
||||||
|
expect(handleCustomWorldSelect).toHaveBeenCalledTimes(1);
|
||||||
|
expect(handleCustomWorldSelect.mock.calls[0]?.[0]?.id).toBe(
|
||||||
|
'published-profile',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
|
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
|
||||||
|
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||||
import type { CustomWorldRuntimeLaunchOptions } from '../platform-entry/platformEntryTypes';
|
import type { CustomWorldRuntimeLaunchOptions } from '../platform-entry/platformEntryTypes';
|
||||||
import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter';
|
import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter';
|
||||||
import type { CustomWorldProfile } from '../../types';
|
import type { CustomWorldProfile } from '../../types';
|
||||||
@@ -8,6 +9,7 @@ import type { CustomWorldProfile } from '../../types';
|
|||||||
type UseRpgCreationEnterWorldParams = {
|
type UseRpgCreationEnterWorldParams = {
|
||||||
isAgentDraftResultView: boolean;
|
isAgentDraftResultView: boolean;
|
||||||
activeAgentSessionId: string | null;
|
activeAgentSessionId: string | null;
|
||||||
|
currentAgentSessionStage?: CustomWorldAgentSessionSnapshot['stage'] | null;
|
||||||
generatedCustomWorldProfile: CustomWorldProfile | null;
|
generatedCustomWorldProfile: CustomWorldProfile | null;
|
||||||
handleCustomWorldSelect: (
|
handleCustomWorldSelect: (
|
||||||
customWorldProfile: CustomWorldProfile,
|
customWorldProfile: CustomWorldProfile,
|
||||||
@@ -33,6 +35,7 @@ export function useRpgCreationEnterWorld(
|
|||||||
const {
|
const {
|
||||||
isAgentDraftResultView,
|
isAgentDraftResultView,
|
||||||
activeAgentSessionId,
|
activeAgentSessionId,
|
||||||
|
currentAgentSessionStage,
|
||||||
generatedCustomWorldProfile,
|
generatedCustomWorldProfile,
|
||||||
handleCustomWorldSelect,
|
handleCustomWorldSelect,
|
||||||
syncAgentDraftResultProfile,
|
syncAgentDraftResultProfile,
|
||||||
@@ -77,6 +80,17 @@ export function useRpgCreationEnterWorld(
|
|||||||
return generatedCustomWorldProfile;
|
return generatedCustomWorldProfile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (currentAgentSessionStage === 'published') {
|
||||||
|
const latestView = await syncAgentCreationResultView(activeAgentSessionId);
|
||||||
|
const publishedProfile =
|
||||||
|
rpgCreationPreviewAdapter.buildPreviewFromResultView(latestView) ??
|
||||||
|
generatedCustomWorldProfile;
|
||||||
|
// 中文注释:已发布会话的“进入世界”只读取后端结果页真相,
|
||||||
|
// 不能再同步草稿或重复发送 publish_world,否则会被发布阶段门槛拒绝。
|
||||||
|
setGeneratedCustomWorldProfile(publishedProfile);
|
||||||
|
return publishedProfile;
|
||||||
|
}
|
||||||
|
|
||||||
const syncedResult = await syncAgentDraftResultProfile(
|
const syncedResult = await syncAgentDraftResultProfile(
|
||||||
generatedCustomWorldProfile,
|
generatedCustomWorldProfile,
|
||||||
);
|
);
|
||||||
@@ -112,6 +126,7 @@ export function useRpgCreationEnterWorld(
|
|||||||
return publishedProfile;
|
return publishedProfile;
|
||||||
}, [
|
}, [
|
||||||
activeAgentSessionId,
|
activeAgentSessionId,
|
||||||
|
currentAgentSessionStage,
|
||||||
executePublishWorld,
|
executePublishWorld,
|
||||||
generatedCustomWorldProfile,
|
generatedCustomWorldProfile,
|
||||||
isAgentDraftResultView,
|
isAgentDraftResultView,
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ const RpgRuntimeOverlayHost = lazy(async () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function RuntimeLayerLoadingFallback({ label }: { label: string }) {
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none fixed inset-x-0 top-4 z-[24] flex justify-center px-4">
|
||||||
|
<div className="rounded-full border border-white/10 bg-black/55 px-4 py-2 text-xs text-zinc-200 shadow-[0_12px_30px_rgba(0,0,0,0.32)] backdrop-blur-sm">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RPG 运行态总外壳。
|
* RPG 运行态总外壳。
|
||||||
* 这里承接运行时主布局、画布舞台、主阶段路由和 overlay host,
|
* 这里承接运行时主布局、画布舞台、主阶段路由和 overlay host,
|
||||||
@@ -167,7 +177,7 @@ export function RpgRuntimeShell({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{gameState.worldType ? (
|
{gameState.worldType ? (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={<RuntimeLayerLoadingFallback label="正在加载场景" />}>
|
||||||
<RpgRuntimeCanvasStage
|
<RpgRuntimeCanvasStage
|
||||||
gameState={gameState}
|
gameState={gameState}
|
||||||
visibleGameState={visibleGameState}
|
visibleGameState={visibleGameState}
|
||||||
@@ -275,7 +285,7 @@ export function RpgRuntimeShell({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{gameState.worldType ? (
|
{gameState.worldType ? (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={<RuntimeLayerLoadingFallback label="正在加载界面" />}>
|
||||||
<RpgRuntimeOverlayHost
|
<RpgRuntimeOverlayHost
|
||||||
gameState={gameState}
|
gameState={gameState}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
|||||||
@@ -79,6 +79,72 @@ describe('rpgEntryLibraryClient world library routes', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('normalizes detail profiles before runtime launch consumes them', async () => {
|
||||||
|
requestJsonMock.mockResolvedValueOnce({
|
||||||
|
entry: {
|
||||||
|
ownerUserId: 'owner-1',
|
||||||
|
profileId: 'profile-1',
|
||||||
|
publicWorkCode: 'CW-1',
|
||||||
|
authorPublicUserCode: 'U-1',
|
||||||
|
profile: {
|
||||||
|
id: 'profile-1',
|
||||||
|
name: '旧数据世界',
|
||||||
|
summary: '只有摘要字段的旧 profile。',
|
||||||
|
},
|
||||||
|
visibility: 'published',
|
||||||
|
publishedAt: '2026-05-21T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-05-21T00:00:00.000Z',
|
||||||
|
authorDisplayName: '作者',
|
||||||
|
worldName: '旧数据世界',
|
||||||
|
subtitle: '旧数据',
|
||||||
|
summaryText: '只有摘要字段的旧 profile。',
|
||||||
|
coverImageSrc: null,
|
||||||
|
themeMode: 'martial',
|
||||||
|
playableNpcCount: 0,
|
||||||
|
landmarkCount: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const entry = await getRpgEntryWorldGalleryDetail('owner-1', 'profile-1');
|
||||||
|
|
||||||
|
expect(Array.isArray(entry.profile.playableNpcs)).toBe(true);
|
||||||
|
expect(Array.isArray(entry.profile.storyNpcs)).toBe(true);
|
||||||
|
expect(Array.isArray(entry.profile.landmarks)).toBe(true);
|
||||||
|
expect(entry.profile.attributeSchema.schemaVersion).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to entry summary when old detail profile cannot be normalized', async () => {
|
||||||
|
requestJsonMock.mockResolvedValueOnce({
|
||||||
|
entry: {
|
||||||
|
ownerUserId: 'owner-1',
|
||||||
|
profileId: 'profile-1',
|
||||||
|
publicWorkCode: 'CW-1',
|
||||||
|
authorPublicUserCode: 'U-1',
|
||||||
|
profile: {
|
||||||
|
id: 'profile-1',
|
||||||
|
summary: '缺少 name 的旧 profile。',
|
||||||
|
},
|
||||||
|
visibility: 'published',
|
||||||
|
publishedAt: '2026-05-21T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-05-21T00:00:00.000Z',
|
||||||
|
authorDisplayName: '作者',
|
||||||
|
worldName: '摘要兜底世界',
|
||||||
|
subtitle: '旧数据',
|
||||||
|
summaryText: '缺少 name 的旧 profile。',
|
||||||
|
coverImageSrc: null,
|
||||||
|
themeMode: 'martial',
|
||||||
|
playableNpcCount: 0,
|
||||||
|
landmarkCount: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const entry = await getRpgEntryWorldGalleryDetail('owner-1', 'profile-1');
|
||||||
|
|
||||||
|
expect(entry.profile.id).toBe('profile-1');
|
||||||
|
expect(entry.profile.name).toBe('摘要兜底世界');
|
||||||
|
expect(Array.isArray(entry.profile.playableNpcs)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('reads owned library detail from the runtime entry route', async () => {
|
it('reads owned library detail from the runtime entry route', async () => {
|
||||||
requestJsonMock.mockResolvedValueOnce({
|
requestJsonMock.mockResolvedValueOnce({
|
||||||
entry: {
|
entry: {
|
||||||
|
|||||||
@@ -7,13 +7,62 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
CustomWorldGalleryDetailResponse,
|
CustomWorldGalleryDetailResponse,
|
||||||
CustomWorldGalleryResponse,
|
CustomWorldGalleryResponse,
|
||||||
|
CustomWorldLibraryEntry,
|
||||||
CustomWorldLibraryMutationResponse,
|
CustomWorldLibraryMutationResponse,
|
||||||
CustomWorldLibraryResponse,
|
CustomWorldLibraryResponse,
|
||||||
} from '../../../packages/shared/src/contracts/runtime';
|
} from '../../../packages/shared/src/contracts/runtime';
|
||||||
|
import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary';
|
||||||
import type { CustomWorldProfile } from '../../types';
|
import type { CustomWorldProfile } from '../../types';
|
||||||
|
|
||||||
export type { RuntimeRequestOptions };
|
export type { RuntimeRequestOptions };
|
||||||
|
|
||||||
|
type RpgEntryWorldEntry = CustomWorldLibraryEntry<CustomWorldProfile>;
|
||||||
|
type RpgEntryWorldMutationResponse =
|
||||||
|
CustomWorldLibraryMutationResponse<CustomWorldProfile>;
|
||||||
|
|
||||||
|
function normalizeRpgEntryWorldProfile(entry: RpgEntryWorldEntry) {
|
||||||
|
const rawProfile =
|
||||||
|
entry.profile && typeof entry.profile === 'object' ? entry.profile : {};
|
||||||
|
const fallbackProfile = {
|
||||||
|
id: entry.profileId,
|
||||||
|
name: entry.worldName,
|
||||||
|
subtitle: entry.subtitle,
|
||||||
|
summary: entry.summaryText,
|
||||||
|
settingText: entry.summaryText || entry.worldName,
|
||||||
|
playableNpcs: [],
|
||||||
|
storyNpcs: [],
|
||||||
|
items: [],
|
||||||
|
landmarks: [],
|
||||||
|
};
|
||||||
|
const normalizedProfile =
|
||||||
|
normalizeCustomWorldProfileRecord({
|
||||||
|
...fallbackProfile,
|
||||||
|
...rawProfile,
|
||||||
|
}) ?? normalizeCustomWorldProfileRecord(fallbackProfile);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...entry,
|
||||||
|
profile: normalizedProfile ?? entry.profile,
|
||||||
|
} as RpgEntryWorldEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRpgEntryWorldEntries(
|
||||||
|
entries: RpgEntryWorldEntry[] | null | undefined,
|
||||||
|
) {
|
||||||
|
return Array.isArray(entries)
|
||||||
|
? entries.map((entry) => normalizeRpgEntryWorldProfile(entry))
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRpgEntryWorldMutationResponse(
|
||||||
|
response: RpgEntryWorldMutationResponse,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
entry: normalizeRpgEntryWorldProfile(response.entry),
|
||||||
|
entries: normalizeRpgEntryWorldEntries(response.entries),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RPG 入口世界库 client 的真实实现。
|
* RPG 入口世界库 client 的真实实现。
|
||||||
* 第三批收口后,平台首页/详情页开始游戏链直接走 rpg-entry 域请求,不再反向穿旧 storageService 兼容层。
|
* 第三批收口后,平台首页/详情页开始游戏链直接走 rpg-entry 域请求,不再反向穿旧 storageService 兼容层。
|
||||||
@@ -33,7 +82,7 @@ export async function listRpgEntryWorldLibrary(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return Array.isArray(response?.entries) ? response.entries : [];
|
return normalizeRpgEntryWorldEntries(response?.entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listRpgEntryWorldGallery(
|
export async function listRpgEntryWorldGallery(
|
||||||
@@ -63,7 +112,7 @@ export async function getRpgEntryWorldGalleryDetail(
|
|||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
|
|
||||||
return response.entry;
|
return normalizeRpgEntryWorldProfile(response.entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRpgEntryWorldGalleryDetailByCode(
|
export async function getRpgEntryWorldGalleryDetailByCode(
|
||||||
@@ -79,7 +128,7 @@ export async function getRpgEntryWorldGalleryDetailByCode(
|
|||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
|
|
||||||
return response.entry;
|
return normalizeRpgEntryWorldProfile(response.entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function remixRpgEntryWorldGallery(
|
export async function remixRpgEntryWorldGallery(
|
||||||
@@ -96,10 +145,7 @@ export async function remixRpgEntryWorldGallery(
|
|||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return normalizeRpgEntryWorldMutationResponse(response);
|
||||||
entry: response.entry,
|
|
||||||
entries: Array.isArray(response?.entries) ? response.entries : [],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function recordRpgEntryWorldGalleryPlay(
|
export async function recordRpgEntryWorldGalleryPlay(
|
||||||
@@ -116,7 +162,7 @@ export async function recordRpgEntryWorldGalleryPlay(
|
|||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
|
|
||||||
return response.entry;
|
return normalizeRpgEntryWorldProfile(response.entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function likeRpgEntryWorldGallery(
|
export async function likeRpgEntryWorldGallery(
|
||||||
@@ -133,7 +179,7 @@ export async function likeRpgEntryWorldGallery(
|
|||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
|
|
||||||
return response.entry;
|
return normalizeRpgEntryWorldProfile(response.entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRpgEntryWorldLibraryDetail(
|
export async function getRpgEntryWorldLibraryDetail(
|
||||||
@@ -149,7 +195,7 @@ export async function getRpgEntryWorldLibraryDetail(
|
|||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
|
|
||||||
return response.entry;
|
return normalizeRpgEntryWorldProfile(response.entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upsertRpgEntryWorldProfile(
|
export async function upsertRpgEntryWorldProfile(
|
||||||
@@ -171,10 +217,7 @@ export async function upsertRpgEntryWorldProfile(
|
|||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return normalizeRpgEntryWorldMutationResponse(response);
|
||||||
entry: response.entry,
|
|
||||||
entries: Array.isArray(response?.entries) ? response.entries : [],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteRpgEntryWorldProfile(
|
export async function deleteRpgEntryWorldProfile(
|
||||||
@@ -190,7 +233,7 @@ export async function deleteRpgEntryWorldProfile(
|
|||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
|
|
||||||
return Array.isArray(response?.entries) ? response.entries : [];
|
return normalizeRpgEntryWorldEntries(response?.entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function publishRpgEntryWorldProfile(
|
export async function publishRpgEntryWorldProfile(
|
||||||
@@ -206,10 +249,7 @@ export async function publishRpgEntryWorldProfile(
|
|||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return normalizeRpgEntryWorldMutationResponse(response);
|
||||||
entry: response.entry,
|
|
||||||
entries: Array.isArray(response?.entries) ? response.entries : [],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function unpublishRpgEntryWorldProfile(
|
export async function unpublishRpgEntryWorldProfile(
|
||||||
@@ -225,10 +265,7 @@ export async function unpublishRpgEntryWorldProfile(
|
|||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return normalizeRpgEntryWorldMutationResponse(response);
|
||||||
entry: response.entry,
|
|
||||||
entries: Array.isArray(response?.entries) ? response.entries : [],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const rpgEntryLibraryClient = {
|
export const rpgEntryLibraryClient = {
|
||||||
|
|||||||
Reference in New Issue
Block a user