fix: stabilize rpg publish and launch

This commit is contained in:
kdletters
2026-05-21 20:20:06 +08:00
parent 224a26d318
commit a9d23a8a44
14 changed files with 614 additions and 82 deletions

View File

@@ -1626,39 +1626,20 @@ pub async fn execute_custom_world_agent_action(
)
})?
} 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(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-agent",
"message": format!("action payload JSON 序列化失败:{error}"),
"message": error,
})),
)
})?;
if let Some(object) = publish_payload.as_object_mut() {
// 发布到广场时必须写入真实作者公开信息,避免 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}"),
})),
)
})?
publish_payload
} else {
serde_json::to_string(&payload).map_err(|error| {
custom_world_error_response(
@@ -1734,6 +1715,23 @@ fn serialize_sync_result_profile_action_payload(
.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(
mut profile: Value,
) -> 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]
fn custom_world_library_profile_payload_is_canonicalized_on_server() {
let (profile, metadata) = canonicalize_custom_world_library_profile_payload(json!({

View File

@@ -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 summary_text = resolve_text_field(&draft, &legacy, "summary").unwrap_or_default();
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 =
count_distinct_roles(draft.get("playableNpcs"), draft.get("storyNpcs"));
let landmark_count = to_array(draft.get("landmarks")).len() as u32;
@@ -912,11 +912,17 @@ fn resolve_text_field(
legacy: &Map<String, Value>,
key: &str,
) -> Option<String> {
// 中文注释:发布链路的草稿真相来自 session.draft_profile_json
// legacyResultProfile 只补历史草稿缺失字段,不能覆盖结果页刚保存的内容。
to_text(draft.get(key)).or_else(|| to_text(legacy.get(key)))
}
fn resolve_theme_mode(legacy: &Map<String, Value>) -> CustomWorldThemeMode {
to_text(legacy.get("themeMode"))
fn resolve_theme_mode(
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))
.unwrap_or(CustomWorldThemeMode::Mythic)
}
@@ -1067,6 +1073,47 @@ mod tests {
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]
fn publish_setting_text_falls_back_to_draft_profile_when_seed_is_empty() {
let payload = Map::new();
@@ -1079,8 +1126,7 @@ mod tests {
.cloned()
.expect("draft profile should be object");
let setting_text =
resolve_custom_world_publish_setting_text(&payload, &draft_profile, "");
let setting_text = resolve_custom_world_publish_setting_text(&payload, &draft_profile, "");
assert_eq!(setting_text, "海雾会吞掉记错航线的人。");
}

View File

@@ -2593,13 +2593,10 @@ fn execute_publish_world_action(
) -> Result<CustomWorldAgentOperationSnapshot, String> {
ensure_publishable_stage(session.stage, "publish_world")?;
let draft_profile =
if let Some(explicit) = payload.get("draftProfile").and_then(JsonValue::as_object) {
explicit.clone()
} else {
parse_optional_session_object(session.draft_profile_json.as_deref())
.ok_or_else(|| "publish_world requires draft_profile_json".to_string())?
};
// 中文注释:发布动作不再信任前端携带的 draftProfile
// 点击发布前,结果页 profile 必须先通过 sync_result_profile 写回
// custom_world_agent_session.draft_profile_json正式发布只读取这份会话真相。
let draft_profile = read_publish_world_draft_profile_from_session(session)?;
let gate = summarize_publish_gate_from_json(
&session.session_id,
session.stage,
@@ -2613,18 +2610,9 @@ fn execute_publish_world_action(
));
}
let profile_id = payload
.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 profile_id = gate.profile_id.clone();
let setting_text = resolve_publish_world_setting_text(payload, &draft_profile, session);
let legacy_result_profile_json = payload
.get("legacyResultProfile")
.map(serialize_json_value)
.transpose()?;
let legacy_result_profile_json = None;
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));
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))
}
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(
ctx: &ReducerContext,
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]
fn custom_world_agent_session_direct_work_content_ignores_empty_created_session() {
let empty_session =