fix: stabilize rpg publish and launch
This commit is contained in:
@@ -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!({
|
||||
|
||||
@@ -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, "海雾会吞掉记错航线的人。");
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user