use crate::*; use std::collections::{HashMap, HashSet}; #[spacetimedb::table( accessor = custom_world_profile, index(accessor = by_custom_world_profile_owner_user_id, btree(columns = [owner_user_id])), index( accessor = by_custom_world_profile_publication_status, btree(columns = [publication_status]) ) )] pub struct CustomWorldProfile { #[primary_key] profile_id: String, // 当前 profile 承接 library / publish / enter-world 的正式世界工件真相。 owner_user_id: String, // 作品公开编号是稳定分享键,第一次发布时分配,后续重复发布沿用。 public_work_code: Option, // 作者公开陶泥号在发布时固化到作品真相,供广场读模型与搜索结果直接展示。 author_public_user_code: Option, source_agent_session_id: Option, publication_status: CustomWorldPublicationStatus, world_name: String, subtitle: String, summary_text: String, theme_mode: CustomWorldThemeMode, cover_image_src: Option, profile_payload_json: String, playable_npc_count: u32, landmark_count: u32, // 公开消费计数随 profile 真相持久化,发布、编辑和取消发布都不能重置。 #[default(0)] play_count: u32, #[default(0)] remix_count: u32, #[default(0)] like_count: u32, author_display_name: String, published_at: Option, // 软删除后保留 profile 真相,供审计与幂等删除使用。 deleted_at: Option, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = custom_world_session, index(accessor = by_custom_world_session_owner_user_id, btree(columns = [owner_user_id])) )] pub struct CustomWorldSession { #[primary_key] session_id: String, // 这张表只承接旧 custom-world/sessions 传统问答流,不和 agent 会话混存。 owner_user_id: String, generation_mode: CustomWorldGenerationMode, status: CustomWorldSessionStatus, setting_text: String, creator_intent_json: Option, question_snapshot_json: String, result_payload_json: Option, last_error_message: Option, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = custom_world_agent_session, index( accessor = by_custom_world_agent_session_owner_user_id, btree(columns = [owner_user_id]) ), index(accessor = by_custom_world_agent_session_stage, btree(columns = [stage])) )] pub struct CustomWorldAgentSession { #[primary_key] session_id: String, // Agent 会话只保留会话级聚合字段,消息、操作、卡片都拆到独立表。 owner_user_id: String, seed_text: String, current_turn: u32, progress_percent: u32, stage: RpgAgentStage, focus_card_id: Option, anchor_content_json: String, creator_intent_json: Option, creator_intent_readiness_json: String, anchor_pack_json: Option, lock_state_json: Option, draft_profile_json: Option, last_assistant_reply: Option, publish_gate_json: Option, result_preview_json: Option, pending_clarifications_json: String, quality_findings_json: String, suggested_actions_json: String, recommended_replies_json: String, asset_coverage_json: String, checkpoints_json: String, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = custom_world_agent_message, index(accessor = by_custom_world_agent_message_session_id, btree(columns = [session_id])) )] pub struct CustomWorldAgentMessage { #[primary_key] message_id: String, // 消息流水单独成表,避免继续塞回 session 大 JSON。 session_id: String, role: RpgAgentMessageRole, kind: RpgAgentMessageKind, text: String, related_operation_id: Option, created_at: Timestamp, } #[derive(Clone)] #[spacetimedb::table( accessor = custom_world_agent_operation, index(accessor = by_custom_world_agent_operation_session_id, btree(columns = [session_id])) )] pub struct CustomWorldAgentOperation { #[primary_key] operation_id: String, // 异步操作单独建表,为 message stream / operation query 提供真相源。 session_id: String, operation_type: RpgAgentOperationType, status: RpgAgentOperationStatus, phase_label: String, phase_detail: String, progress: u32, error_message: Option, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = custom_world_draft_card, index(accessor = by_custom_world_draft_card_session_id, btree(columns = [session_id])), index(accessor = by_custom_world_draft_card_kind, btree(columns = [kind])) )] pub struct CustomWorldDraftCard { #[primary_key] card_id: String, // 卡片实体从 agent session 拆出,后续 detail / update 都直接对这张表操作。 session_id: String, kind: RpgAgentDraftCardKind, status: RpgAgentDraftCardStatus, title: String, subtitle: String, summary: String, linked_ids_json: String, warning_count: u32, asset_status: Option, asset_status_label: Option, detail_payload_json: Option, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = custom_world_gallery_entry, public, index(accessor = by_custom_world_gallery_owner_user_id, btree(columns = [owner_user_id])), index(accessor = by_custom_world_gallery_theme_mode, btree(columns = [theme_mode])), index(accessor = by_custom_world_gallery_public_work_code, btree(columns = [public_work_code])) )] pub struct CustomWorldGalleryEntry { #[primary_key] profile_id: String, // 画廊是公开订阅读模型,不再运行时从 profile 即席拼装。 owner_user_id: String, public_work_code: String, author_public_user_code: String, author_display_name: String, world_name: String, subtitle: String, summary_text: String, cover_image_src: Option, theme_mode: CustomWorldThemeMode, playable_npc_count: u32, landmark_count: u32, // 画廊读模型直接同步互动计数,避免前端临时把评分或游玩数改名成点赞。 #[default(0)] play_count: u32, #[default(0)] remix_count: u32, #[default(0)] like_count: u32, published_at: Timestamp, updated_at: Timestamp, } // Agent 会话首版只负责把可持久化创作状态落进 SpacetimeDB,LLM 采集与卡片生成后续再接入。 #[spacetimedb::procedure] pub fn create_custom_world_agent_session( ctx: &mut ProcedureContext, input: CustomWorldAgentSessionCreateInput, ) -> CustomWorldAgentSessionProcedureResult { match ctx.try_with_tx(|tx| create_custom_world_agent_session_tx(tx, input.clone())) { Ok(session) => CustomWorldAgentSessionProcedureResult { ok: true, session: Some(session), error_message: None, }, Err(message) => CustomWorldAgentSessionProcedureResult { ok: false, session: None, error_message: Some(message), }, } } // Stage 6 读取拆表后的最小 Agent session snapshot,供 Axum 兼容旧前端 contract。 #[spacetimedb::procedure] pub fn get_custom_world_agent_session( ctx: &mut ProcedureContext, input: CustomWorldAgentSessionGetInput, ) -> CustomWorldAgentSessionProcedureResult { match ctx.try_with_tx(|tx| get_custom_world_agent_session_tx(tx, input.clone())) { Ok(session) => CustomWorldAgentSessionProcedureResult { ok: true, session: Some(session), error_message: None, }, Err(message) => CustomWorldAgentSessionProcedureResult { ok: false, session: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn delete_custom_world_agent_session( ctx: &mut ProcedureContext, input: CustomWorldAgentSessionGetInput, ) -> CustomWorldWorksListResult { match ctx.try_with_tx(|tx| delete_custom_world_agent_session_tx(tx, input.clone())) { Ok(items) => CustomWorldWorksListResult { ok: true, items, error_message: None, }, Err(message) => CustomWorldWorksListResult { ok: false, items: Vec::new(), error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn submit_custom_world_agent_message( ctx: &mut ProcedureContext, input: CustomWorldAgentMessageSubmitInput, ) -> CustomWorldAgentOperationProcedureResult { match ctx.try_with_tx(|tx| submit_custom_world_agent_message_tx(tx, input.clone())) { Ok(operation) => CustomWorldAgentOperationProcedureResult { ok: true, operation: Some(operation), error_message: None, }, Err(message) => CustomWorldAgentOperationProcedureResult { ok: false, operation: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn finalize_custom_world_agent_message_turn( ctx: &mut ProcedureContext, input: CustomWorldAgentMessageFinalizeInput, ) -> CustomWorldAgentOperationProcedureResult { match ctx.try_with_tx(|tx| finalize_custom_world_agent_message_turn_tx(tx, input.clone())) { Ok(operation) => CustomWorldAgentOperationProcedureResult { ok: true, operation: Some(operation), error_message: None, }, Err(message) => CustomWorldAgentOperationProcedureResult { ok: false, operation: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn get_custom_world_agent_operation( ctx: &mut ProcedureContext, input: CustomWorldAgentOperationGetInput, ) -> CustomWorldAgentOperationProcedureResult { match ctx.try_with_tx(|tx| get_custom_world_agent_operation_tx(tx, input.clone())) { Ok(operation) => CustomWorldAgentOperationProcedureResult { ok: true, operation: Some(operation), error_message: None, }, Err(message) => CustomWorldAgentOperationProcedureResult { ok: false, operation: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn upsert_custom_world_agent_operation_progress( ctx: &mut ProcedureContext, input: CustomWorldAgentOperationProgressInput, ) -> CustomWorldAgentOperationProcedureResult { match ctx.try_with_tx(|tx| upsert_custom_world_agent_operation_progress_tx(tx, input.clone())) { Ok(operation) => CustomWorldAgentOperationProcedureResult { ok: true, operation: Some(operation), error_message: None, }, Err(message) => CustomWorldAgentOperationProcedureResult { ok: false, operation: None, error_message: Some(message), }, } } fn create_custom_world_agent_session_tx( ctx: &ReducerContext, input: CustomWorldAgentSessionCreateInput, ) -> Result { validate_custom_world_agent_session_create_input(&input).map_err(|error| error.to_string())?; if ctx .db .custom_world_agent_session() .session_id() .find(&input.session_id) .is_some() { return Err("custom_world_agent_session.session_id 已存在".to_string()); } if ctx .db .custom_world_agent_message() .message_id() .find(&input.welcome_message_id) .is_some() { return Err("custom_world_agent_message.message_id 已存在".to_string()); } let created_at = Timestamp::from_micros_since_unix_epoch(input.created_at_micros); ctx.db .custom_world_agent_session() .insert(CustomWorldAgentSession { session_id: input.session_id.clone(), owner_user_id: input.owner_user_id.clone(), seed_text: input.seed_text.trim().to_string(), current_turn: 0, progress_percent: 0, stage: RpgAgentStage::CollectingIntent, focus_card_id: None, anchor_content_json: input.anchor_content_json.clone(), creator_intent_json: input.creator_intent_json.clone(), creator_intent_readiness_json: input.creator_intent_readiness_json.clone(), anchor_pack_json: input.anchor_pack_json.clone(), lock_state_json: input.lock_state_json.clone(), draft_profile_json: input.draft_profile_json.clone(), last_assistant_reply: Some(input.welcome_message_text.trim().to_string()), publish_gate_json: None, result_preview_json: None, pending_clarifications_json: input.pending_clarifications_json.clone(), quality_findings_json: input.quality_findings_json.clone(), suggested_actions_json: input.suggested_actions_json.clone(), recommended_replies_json: input.recommended_replies_json.clone(), asset_coverage_json: input.asset_coverage_json.clone(), checkpoints_json: input.checkpoints_json.clone(), created_at, updated_at: created_at, }); ctx.db .custom_world_agent_message() .insert(CustomWorldAgentMessage { message_id: input.welcome_message_id, session_id: input.session_id.clone(), role: RpgAgentMessageRole::Assistant, kind: RpgAgentMessageKind::Chat, text: input.welcome_message_text.trim().to_string(), related_operation_id: None, created_at, }); get_custom_world_agent_session_tx( ctx, CustomWorldAgentSessionGetInput { session_id: input.session_id, owner_user_id: input.owner_user_id, }, ) } fn get_custom_world_agent_session_tx( ctx: &ReducerContext, input: CustomWorldAgentSessionGetInput, ) -> Result { validate_custom_world_agent_session_get_input(&input).map_err(|error| error.to_string())?; let session = ctx .db .custom_world_agent_session() .session_id() .find(&input.session_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "custom_world_agent_session 不存在".to_string())?; Ok(build_custom_world_agent_session_snapshot(ctx, &session)) } fn delete_custom_world_agent_session_tx( ctx: &ReducerContext, input: CustomWorldAgentSessionGetInput, ) -> Result, String> { validate_custom_world_agent_session_get_input(&input).map_err(|error| error.to_string())?; let session = ctx .db .custom_world_agent_session() .session_id() .find(&input.session_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "custom_world_agent_session 不存在".to_string())?; if session.stage == RpgAgentStage::Published { let published_profile = ctx .db .custom_world_profile() .by_custom_world_profile_owner_user_id() .filter(&input.owner_user_id) .find(|row| { row.owner_user_id == input.owner_user_id && row.source_agent_session_id.as_deref() == Some(input.session_id.as_str()) && row.deleted_at.is_none() }) .ok_or_else(|| "已发布 RPG 作品缺少关联 profile,无法删除".to_string())?; // 作品卡可能只携带源 Agent sessionId。这里把“按 session 删除已发布作品” // 收敛为 profile 软删除,避免前端误入草稿删除接口时继续暴露 procedure 分叉。 delete_custom_world_profile_record( ctx, CustomWorldProfileDeleteInput { profile_id: published_profile.profile_id, owner_user_id: input.owner_user_id.clone(), deleted_at_micros: ctx.timestamp.to_micros_since_unix_epoch(), }, )?; return list_custom_world_work_snapshots( ctx, CustomWorldWorksListInput { owner_user_id: input.owner_user_id, }, ); } // 删除纯 Agent 草稿时同步清理消息、操作与草稿卡,避免作品列表消失后残留孤儿数据。 ctx.db .custom_world_agent_session() .session_id() .delete(&session.session_id); for message in ctx .db .custom_world_agent_message() .by_custom_world_agent_message_session_id() .filter(&input.session_id) .collect::>() { ctx.db .custom_world_agent_message() .message_id() .delete(&message.message_id); } for operation in ctx .db .custom_world_agent_operation() .by_custom_world_agent_operation_session_id() .filter(&input.session_id) .collect::>() { ctx.db .custom_world_agent_operation() .operation_id() .delete(&operation.operation_id); } for card in ctx .db .custom_world_draft_card() .by_custom_world_draft_card_session_id() .filter(&input.session_id) .collect::>() { ctx.db .custom_world_draft_card() .card_id() .delete(&card.card_id); } list_custom_world_work_snapshots( ctx, CustomWorldWorksListInput { owner_user_id: input.owner_user_id, }, ) } fn submit_custom_world_agent_message_tx( ctx: &ReducerContext, input: CustomWorldAgentMessageSubmitInput, ) -> Result { validate_custom_world_agent_message_submit_input(&input).map_err(|error| error.to_string())?; if input.user_message_text.contains("__phase1_force_fail__") { return Err("forced failure".to_string()); } let _session = ctx .db .custom_world_agent_session() .session_id() .find(&input.session_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "custom_world_agent_session 不存在".to_string())?; if ctx .db .custom_world_agent_message() .message_id() .find(&input.user_message_id) .is_some() { return Err("custom_world_agent_message.message_id 已存在".to_string()); } if ctx .db .custom_world_agent_operation() .operation_id() .find(&input.operation_id) .is_some() { return Err("custom_world_agent_operation.operation_id 已存在".to_string()); } let submitted_at = Timestamp::from_micros_since_unix_epoch(input.submitted_at_micros); let user_message_text = input.user_message_text.trim().to_string(); ctx.db .custom_world_agent_message() .insert(CustomWorldAgentMessage { message_id: input.user_message_id, session_id: input.session_id.clone(), role: RpgAgentMessageRole::User, kind: RpgAgentMessageKind::Chat, text: user_message_text, related_operation_id: Some(input.operation_id.clone()), created_at: submitted_at, }); ctx.db .custom_world_agent_operation() .insert(CustomWorldAgentOperation { operation_id: input.operation_id.clone(), session_id: input.session_id.clone(), operation_type: RpgAgentOperationType::ProcessMessage, status: RpgAgentOperationStatus::Running, phase_label: "消息处理中".to_string(), phase_detail: "已记录用户消息,等待大模型生成本轮回复。".to_string(), progress: 10, error_message: None, created_at: submitted_at, updated_at: submitted_at, }); get_custom_world_agent_operation_tx( ctx, CustomWorldAgentOperationGetInput { session_id: input.session_id, owner_user_id: input.owner_user_id, operation_id: input.operation_id, }, ) } fn get_custom_world_agent_operation_tx( ctx: &ReducerContext, input: CustomWorldAgentOperationGetInput, ) -> Result { validate_custom_world_agent_operation_get_input(&input).map_err(|error| error.to_string())?; ctx.db .custom_world_agent_session() .session_id() .find(&input.session_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "custom_world_agent_session 不存在".to_string())?; let operation = ctx .db .custom_world_agent_operation() .operation_id() .find(&input.operation_id) .filter(|row| row.session_id == input.session_id) .ok_or_else(|| "custom_world_agent_operation 不存在".to_string())?; Ok(build_custom_world_agent_operation_snapshot(&operation)) } fn upsert_custom_world_agent_operation_progress_tx( ctx: &ReducerContext, input: CustomWorldAgentOperationProgressInput, ) -> Result { validate_custom_world_agent_operation_progress_input(&input) .map_err(|error| error.to_string())?; ctx.db .custom_world_agent_session() .session_id() .find(&input.session_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "custom_world_agent_session 不存在".to_string())?; let timestamp = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); let operation = if let Some(current) = ctx .db .custom_world_agent_operation() .operation_id() .find(&input.operation_id) { if current.session_id != input.session_id { return Err("custom_world_agent_operation.session_id 不匹配".to_string()); } let next = rebuild_custom_world_agent_operation_row( ¤t, CustomWorldAgentOperationPatch { status: Some(input.operation_status), phase_label: Some(input.phase_label.clone()), phase_detail: Some(input.phase_detail.clone()), progress: Some(input.operation_progress), error_message: Some(input.error_message.clone()), updated_at_micros: Some(input.updated_at_micros), }, )?; replace_custom_world_agent_operation(ctx, ¤t, next.clone()); next } else { ctx.db .custom_world_agent_operation() .insert(CustomWorldAgentOperation { operation_id: input.operation_id.clone(), session_id: input.session_id.clone(), operation_type: input.operation_type, status: input.operation_status, phase_label: input.phase_label.clone(), phase_detail: input.phase_detail.clone(), progress: input.operation_progress, error_message: input.error_message.clone(), created_at: timestamp, updated_at: timestamp, }) }; Ok(build_custom_world_agent_operation_snapshot(&operation)) } fn finalize_custom_world_agent_message_turn_tx( ctx: &ReducerContext, input: CustomWorldAgentMessageFinalizeInput, ) -> Result { validate_custom_world_agent_message_finalize_input(&input) .map_err(|error| error.to_string())?; let session = ctx .db .custom_world_agent_session() .session_id() .find(&input.session_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "custom_world_agent_session 不存在".to_string())?; let operation = ctx .db .custom_world_agent_operation() .operation_id() .find(&input.operation_id) .filter(|row| row.session_id == input.session_id) .ok_or_else(|| "custom_world_agent_operation 不存在".to_string())?; let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); let next_session = if input.operation_status == RpgAgentOperationStatus::Failed { rebuild_custom_world_agent_session_row( &session, CustomWorldAgentSessionPatch { updated_at_micros: Some(input.updated_at_micros), ..CustomWorldAgentSessionPatch::default() }, )? } else { let assistant_message_id = input.assistant_message_id.clone().ok_or_else(|| { "custom_world_agent_message.assistant_message_id 不能为空".to_string() })?; let assistant_reply_text = input .assistant_reply_text .clone() .ok_or_else(|| "custom_world_agent_message.text 不能为空".to_string())?; if ctx .db .custom_world_agent_message() .message_id() .find(&assistant_message_id) .is_some() { return Err("custom_world_agent_message.assistant_message_id 已存在".to_string()); } ctx.db .custom_world_agent_message() .insert(CustomWorldAgentMessage { message_id: assistant_message_id, session_id: input.session_id.clone(), role: RpgAgentMessageRole::Assistant, kind: RpgAgentMessageKind::Chat, text: assistant_reply_text.clone(), related_operation_id: Some(input.operation_id.clone()), created_at: updated_at, }); rebuild_custom_world_agent_session_row( &session, CustomWorldAgentSessionPatch { current_turn: Some(session.current_turn.saturating_add(1)), progress_percent: Some(input.progress_percent), stage: Some(input.stage), focus_card_id: Some(input.focus_card_id.clone()), anchor_content_json: Some(input.anchor_content_json.clone()), creator_intent_json: Some(input.creator_intent_json.clone()), creator_intent_readiness_json: Some(input.creator_intent_readiness_json.clone()), anchor_pack_json: Some(input.anchor_pack_json.clone()), draft_profile_json: Some(input.draft_profile_json.clone()), last_assistant_reply: Some(Some(assistant_reply_text)), pending_clarifications_json: Some(input.pending_clarifications_json.clone()), quality_findings_json: Some(input.quality_findings_json.clone()), suggested_actions_json: Some(input.suggested_actions_json.clone()), recommended_replies_json: Some(input.recommended_replies_json.clone()), asset_coverage_json: Some(input.asset_coverage_json.clone()), updated_at_micros: Some(input.updated_at_micros), ..CustomWorldAgentSessionPatch::default() }, )? }; replace_custom_world_agent_session(ctx, &session, next_session); let next_operation = rebuild_custom_world_agent_operation_row( &operation, CustomWorldAgentOperationPatch { status: Some(input.operation_status), phase_label: Some(input.phase_label.clone()), phase_detail: Some(input.phase_detail.clone()), progress: Some(input.operation_progress), error_message: Some(input.error_message.clone()), updated_at_micros: Some(input.updated_at_micros), }, )?; replace_custom_world_agent_operation(ctx, &operation, next_operation.clone()); Ok(build_custom_world_agent_operation_snapshot(&next_operation)) } // M5 Stage 2 先把 library profile upsert 固定成最小正式写入口;已发布作品在这里同步刷新 gallery 投影。 #[spacetimedb::reducer] pub fn upsert_custom_world_profile( ctx: &ReducerContext, input: CustomWorldProfileUpsertInput, ) -> Result<(), String> { upsert_custom_world_profile_record(ctx, input).map(|_| ()) } // procedure 面向 Axum 返回 profile 与可能同步出的 gallery 投影,避免 HTTP 层再二次查询私有表。 #[spacetimedb::procedure] pub fn upsert_custom_world_profile_and_return( ctx: &mut ProcedureContext, input: CustomWorldProfileUpsertInput, ) -> CustomWorldLibraryMutationResult { match ctx.try_with_tx(|tx| upsert_custom_world_profile_record(tx, input.clone())) { Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { ok: true, entry: Some(entry), gallery_entry, error_message: None, }, Err(message) => CustomWorldLibraryMutationResult { ok: false, entry: None, gallery_entry: None, error_message: Some(message), }, } } // publish 负责同时推进 profile 发布态与 gallery 公开投影,避免公开列表继续运行时拼装。 #[spacetimedb::reducer] pub fn publish_custom_world_profile( ctx: &ReducerContext, input: CustomWorldProfilePublishInput, ) -> Result<(), String> { publish_custom_world_profile_record(ctx, input).map(|_| ()) } #[spacetimedb::procedure] pub fn publish_custom_world_profile_and_return( ctx: &mut ProcedureContext, input: CustomWorldProfilePublishInput, ) -> CustomWorldLibraryMutationResult { match ctx.try_with_tx(|tx| publish_custom_world_profile_record(tx, input.clone())) { Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { ok: true, entry: Some(entry), gallery_entry, error_message: None, }, Err(message) => CustomWorldLibraryMutationResult { ok: false, entry: None, gallery_entry: None, error_message: Some(message), }, } } // unpublish 负责撤掉 gallery 投影,并把 profile 恢复为 draft。 #[spacetimedb::reducer] pub fn unpublish_custom_world_profile( ctx: &ReducerContext, input: CustomWorldProfileUnpublishInput, ) -> Result<(), String> { unpublish_custom_world_profile_record(ctx, input).map(|_| ()) } #[spacetimedb::procedure] pub fn unpublish_custom_world_profile_and_return( ctx: &mut ProcedureContext, input: CustomWorldProfileUnpublishInput, ) -> CustomWorldLibraryMutationResult { match ctx.try_with_tx(|tx| unpublish_custom_world_profile_record(tx, input.clone())) { Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { ok: true, entry: Some(entry), gallery_entry, error_message: None, }, Err(message) => CustomWorldLibraryMutationResult { ok: false, entry: None, gallery_entry: None, error_message: Some(message), }, } } // 删除入口继续走 owner-only 软删除,不直接物理删除 profile 真相。 #[spacetimedb::procedure] pub fn delete_custom_world_profile_and_return( ctx: &mut ProcedureContext, input: module_custom_world::CustomWorldProfileDeleteInput, ) -> CustomWorldProfileListResult { match ctx.try_with_tx(|tx| { delete_custom_world_profile_record(tx, input.clone())?; list_custom_world_profile_snapshots( tx, CustomWorldProfileListInput { owner_user_id: input.owner_user_id.clone(), }, ) }) { Ok(entries) => CustomWorldProfileListResult { ok: true, entries, error_message: None, }, Err(message) => CustomWorldProfileListResult { ok: false, entries: Vec::new(), error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn list_custom_world_profiles( ctx: &mut ProcedureContext, input: CustomWorldProfileListInput, ) -> CustomWorldProfileListResult { match ctx.try_with_tx(|tx| list_custom_world_profile_snapshots(tx, input.clone())) { Ok(entries) => CustomWorldProfileListResult { ok: true, entries, error_message: None, }, Err(message) => CustomWorldProfileListResult { ok: false, entries: Vec::new(), error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn list_custom_world_gallery_entries( ctx: &mut ProcedureContext, ) -> CustomWorldGalleryListResult { match ctx.try_with_tx(|tx| list_custom_world_gallery_snapshots(tx)) { Ok(entries) => CustomWorldGalleryListResult { ok: true, entries, error_message: None, }, Err(message) => CustomWorldGalleryListResult { ok: false, entries: Vec::new(), error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn get_custom_world_library_detail( ctx: &mut ProcedureContext, input: CustomWorldLibraryDetailInput, ) -> CustomWorldLibraryMutationResult { match ctx.try_with_tx(|tx| get_custom_world_library_detail_record(tx, input.clone())) { Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { ok: true, entry, gallery_entry, error_message: None, }, Err(message) => CustomWorldLibraryMutationResult { ok: false, entry: None, gallery_entry: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn get_custom_world_gallery_detail( ctx: &mut ProcedureContext, input: CustomWorldGalleryDetailInput, ) -> CustomWorldLibraryMutationResult { match ctx.try_with_tx(|tx| get_custom_world_gallery_detail_record(tx, input.clone())) { Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { ok: true, entry, gallery_entry, error_message: None, }, Err(message) => CustomWorldLibraryMutationResult { ok: false, entry: None, gallery_entry: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn get_custom_world_gallery_detail_by_code( ctx: &mut ProcedureContext, input: module_custom_world::CustomWorldGalleryDetailByCodeInput, ) -> CustomWorldLibraryMutationResult { match ctx.try_with_tx(|tx| get_custom_world_gallery_detail_record_by_code(tx, input.clone())) { Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { ok: true, entry, gallery_entry, error_message: None, }, Err(message) => CustomWorldLibraryMutationResult { ok: false, entry: None, gallery_entry: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn remix_custom_world_profile( ctx: &mut ProcedureContext, input: module_custom_world::CustomWorldProfileRemixInput, ) -> CustomWorldLibraryMutationResult { match ctx.try_with_tx(|tx| remix_custom_world_profile_record(tx, input.clone())) { Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { ok: true, entry: Some(entry), gallery_entry, error_message: None, }, Err(message) => CustomWorldLibraryMutationResult { ok: false, entry: None, gallery_entry: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn record_custom_world_profile_play( ctx: &mut ProcedureContext, input: module_custom_world::CustomWorldProfilePlayRecordInput, ) -> CustomWorldLibraryMutationResult { match ctx.try_with_tx(|tx| record_custom_world_profile_play_record(tx, input.clone())) { Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { ok: true, entry: Some(entry), gallery_entry: Some(gallery_entry), error_message: None, }, Err(message) => CustomWorldLibraryMutationResult { ok: false, entry: None, gallery_entry: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn record_custom_world_profile_like( ctx: &mut ProcedureContext, input: module_custom_world::CustomWorldProfileLikeRecordInput, ) -> CustomWorldLibraryMutationResult { match ctx.try_with_tx(|tx| record_custom_world_profile_like_record(tx, input.clone())) { Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { ok: true, entry: Some(entry), gallery_entry: Some(gallery_entry), error_message: None, }, Err(message) => CustomWorldLibraryMutationResult { ok: false, entry: None, gallery_entry: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn list_custom_world_works( ctx: &mut ProcedureContext, input: CustomWorldWorksListInput, ) -> CustomWorldWorksListResult { match ctx.try_with_tx(|tx| list_custom_world_work_snapshots(tx, input.clone())) { Ok(items) => CustomWorldWorksListResult { ok: true, items, error_message: None, }, Err(message) => CustomWorldWorksListResult { ok: false, items: Vec::new(), error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn get_custom_world_agent_card_detail( ctx: &mut ProcedureContext, input: CustomWorldAgentCardDetailGetInput, ) -> CustomWorldDraftCardDetailResult { match ctx.try_with_tx(|tx| get_custom_world_agent_card_detail_tx(tx, input.clone())) { Ok(card) => CustomWorldDraftCardDetailResult { ok: true, card: Some(card), error_message: None, }, Err(message) => CustomWorldDraftCardDetailResult { ok: false, card: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn execute_custom_world_agent_action( ctx: &mut ProcedureContext, input: CustomWorldAgentActionExecuteInput, ) -> CustomWorldAgentActionExecuteResult { match ctx.try_with_tx(|tx| execute_custom_world_agent_action_tx(tx, input.clone())) { Ok(operation) => CustomWorldAgentActionExecuteResult { ok: true, operation: Some(operation), error_message: None, }, Err(message) => CustomWorldAgentActionExecuteResult { ok: false, operation: None, error_message: Some(message), }, } } // Stage 3 先把 published profile compile 作为独立 procedure 暴露,避免把编译逻辑和表写入、发布动作强耦合。 #[spacetimedb::procedure] pub fn compile_custom_world_published_profile( _ctx: &mut ProcedureContext, input: CustomWorldPublishedProfileCompileInput, ) -> CustomWorldPublishedProfileCompileResult { match build_custom_world_published_profile_compile_snapshot(input) { Ok(record) => CustomWorldPublishedProfileCompileResult { ok: true, record: Some(record), error_message: None, }, Err(error) => CustomWorldPublishedProfileCompileResult { ok: false, record: None, error_message: Some(error.to_string()), }, } } // Stage 4 把 publish_world 串成单事务主链:compile -> profile upsert -> profile publish -> session.stage 推进。 #[spacetimedb::procedure] pub fn publish_custom_world_world( ctx: &mut ProcedureContext, input: CustomWorldPublishWorldInput, ) -> CustomWorldPublishWorldResult { match ctx.try_with_tx(|tx| publish_custom_world_world_record(tx, input.clone())) { Ok((compiled_record, entry, gallery_entry, session_stage)) => { CustomWorldPublishWorldResult { ok: true, compiled_record: Some(compiled_record), entry: Some(entry), gallery_entry, session_stage: Some(session_stage), error_message: None, } } Err(message) => CustomWorldPublishWorldResult { ok: false, compiled_record: None, entry: None, gallery_entry: None, session_stage: None, error_message: Some(message), }, } } fn upsert_custom_world_profile_record( ctx: &ReducerContext, input: CustomWorldProfileUpsertInput, ) -> Result< ( CustomWorldProfileSnapshot, Option, ), String, > { validate_custom_world_profile_upsert_input(&input).map_err(|error| error.to_string())?; let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); let current = ctx .db .custom_world_profile() .profile_id() .find(&input.profile_id) .filter(|row| row.owner_user_id == input.owner_user_id) .or_else(|| { input .source_agent_session_id .as_ref() .and_then(|session_id| { ctx.db .custom_world_profile() .by_custom_world_profile_owner_user_id() .filter(&input.owner_user_id) .find(|row| { is_same_agent_draft_profile_candidate( row, &input.owner_user_id, session_id, ) }) }) }); let next_row = match current { Some(existing) => { ctx.db .custom_world_profile() .profile_id() .delete(&existing.profile_id); CustomWorldProfile { profile_id: existing.profile_id.clone(), owner_user_id: existing.owner_user_id.clone(), public_work_code: existing.public_work_code.clone(), author_public_user_code: existing.author_public_user_code.clone(), source_agent_session_id: input.source_agent_session_id.clone(), publication_status: existing.publication_status, world_name: input.world_name.clone(), subtitle: input.subtitle.clone(), summary_text: input.summary_text.clone(), theme_mode: input.theme_mode, cover_image_src: input.cover_image_src.clone(), profile_payload_json: input.profile_payload_json.clone(), playable_npc_count: input.playable_npc_count, landmark_count: input.landmark_count, play_count: existing.play_count, remix_count: existing.remix_count, like_count: existing.like_count, author_display_name: input.author_display_name.clone(), published_at: existing.published_at, deleted_at: None, created_at: existing.created_at, updated_at, } } None => CustomWorldProfile { profile_id: input.profile_id.clone(), owner_user_id: input.owner_user_id.clone(), public_work_code: input.public_work_code.clone(), author_public_user_code: input.author_public_user_code.clone(), source_agent_session_id: input.source_agent_session_id.clone(), publication_status: CustomWorldPublicationStatus::Draft, world_name: input.world_name.clone(), subtitle: input.subtitle.clone(), summary_text: input.summary_text.clone(), theme_mode: input.theme_mode, cover_image_src: input.cover_image_src.clone(), profile_payload_json: input.profile_payload_json.clone(), playable_npc_count: input.playable_npc_count, landmark_count: input.landmark_count, play_count: 0, remix_count: 0, like_count: 0, author_display_name: input.author_display_name.clone(), published_at: None, deleted_at: None, created_at: updated_at, updated_at, }, }; let inserted = ctx.db.custom_world_profile().insert(next_row); let gallery_entry = if inserted.publication_status == CustomWorldPublicationStatus::Published { Some(sync_custom_world_gallery_entry_from_profile( ctx, &inserted, )?) } else { ctx.db .custom_world_gallery_entry() .profile_id() .delete(&inserted.profile_id); None }; Ok(( build_custom_world_profile_snapshot(&inserted), gallery_entry, )) } fn publish_custom_world_world_record( ctx: &ReducerContext, input: CustomWorldPublishWorldInput, ) -> Result< ( module_custom_world::CustomWorldPublishedProfileCompileSnapshot, CustomWorldProfileSnapshot, Option, RpgAgentStage, ), String, > { validate_custom_world_publish_world_input(&input).map_err(|error| error.to_string())?; let compiled_record = build_custom_world_published_profile_compile_snapshot( CustomWorldPublishedProfileCompileInput { session_id: input.session_id.clone(), profile_id: input.profile_id.clone(), owner_user_id: input.owner_user_id.clone(), draft_profile_json: input.draft_profile_json.clone(), legacy_result_profile_json: input.legacy_result_profile_json.clone(), setting_text: input.setting_text.clone(), author_display_name: input.author_display_name.clone(), updated_at_micros: input.published_at_micros, }, ) .map_err(|error| error.to_string())?; let _ = upsert_custom_world_profile_record( ctx, CustomWorldProfileUpsertInput { profile_id: compiled_record.profile_id.clone(), owner_user_id: compiled_record.owner_user_id.clone(), public_work_code: input.public_work_code.clone(), author_public_user_code: Some(input.author_public_user_code.clone()), source_agent_session_id: Some(input.session_id.clone()), world_name: compiled_record.world_name.clone(), subtitle: compiled_record.subtitle.clone(), summary_text: compiled_record.summary_text.clone(), theme_mode: compiled_record.theme_mode, cover_image_src: compiled_record.cover_image_src.clone(), profile_payload_json: compiled_record.compiled_profile_payload_json.clone(), playable_npc_count: compiled_record.playable_npc_count, landmark_count: compiled_record.landmark_count, author_display_name: compiled_record.author_display_name.clone(), updated_at_micros: input.published_at_micros, }, )?; let (entry, gallery_entry) = publish_custom_world_profile_record( ctx, CustomWorldProfilePublishInput { profile_id: compiled_record.profile_id.clone(), owner_user_id: compiled_record.owner_user_id.clone(), public_work_code: input.public_work_code.clone(), author_public_user_code: input.author_public_user_code.clone(), author_display_name: compiled_record.author_display_name.clone(), published_at_micros: input.published_at_micros, }, )?; let session_stage = mark_custom_world_agent_session_published( ctx, &input.session_id, &input.owner_user_id, input.published_at_micros, )?; Ok((compiled_record, entry, gallery_entry, session_stage)) } fn publish_custom_world_profile_record( ctx: &ReducerContext, input: CustomWorldProfilePublishInput, ) -> Result< ( CustomWorldProfileSnapshot, Option, ), String, > { validate_custom_world_profile_publish_input(&input).map_err(|error| error.to_string())?; let existing = ctx .db .custom_world_profile() .profile_id() .find(&input.profile_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "custom_world_profile 不存在,无法发布".to_string())?; let published_at = Timestamp::from_micros_since_unix_epoch(input.published_at_micros); ctx.db .custom_world_profile() .profile_id() .delete(&existing.profile_id); let next_row = CustomWorldProfile { profile_id: existing.profile_id.clone(), owner_user_id: existing.owner_user_id.clone(), public_work_code: existing .public_work_code .clone() .or_else(|| Some(build_public_work_code_from_profile_id(&existing.profile_id))), author_public_user_code: Some(input.author_public_user_code.clone()), source_agent_session_id: existing.source_agent_session_id.clone(), publication_status: CustomWorldPublicationStatus::Published, world_name: existing.world_name.clone(), subtitle: existing.subtitle.clone(), summary_text: existing.summary_text.clone(), theme_mode: existing.theme_mode, cover_image_src: existing.cover_image_src.clone(), profile_payload_json: existing.profile_payload_json.clone(), playable_npc_count: existing.playable_npc_count, landmark_count: existing.landmark_count, play_count: existing.play_count, remix_count: existing.remix_count, like_count: existing.like_count, author_display_name: input.author_display_name.clone(), published_at: Some(published_at), deleted_at: None, created_at: existing.created_at, updated_at: published_at, }; let inserted = ctx.db.custom_world_profile().insert(next_row); let gallery_entry = sync_custom_world_gallery_entry_from_profile(ctx, &inserted)?; Ok(( build_custom_world_profile_snapshot(&inserted), Some(gallery_entry), )) } fn unpublish_custom_world_profile_record( ctx: &ReducerContext, input: CustomWorldProfileUnpublishInput, ) -> Result< ( CustomWorldProfileSnapshot, Option, ), String, > { validate_custom_world_profile_unpublish_input(&input).map_err(|error| error.to_string())?; let existing = ctx .db .custom_world_profile() .profile_id() .find(&input.profile_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "custom_world_profile 不存在,无法取消发布".to_string())?; let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); ctx.db .custom_world_profile() .profile_id() .delete(&existing.profile_id); ctx.db .custom_world_gallery_entry() .profile_id() .delete(&existing.profile_id); let next_row = CustomWorldProfile { profile_id: existing.profile_id.clone(), owner_user_id: existing.owner_user_id.clone(), public_work_code: existing.public_work_code.clone(), author_public_user_code: existing.author_public_user_code.clone(), source_agent_session_id: existing.source_agent_session_id.clone(), publication_status: CustomWorldPublicationStatus::Draft, world_name: existing.world_name.clone(), subtitle: existing.subtitle.clone(), summary_text: existing.summary_text.clone(), theme_mode: existing.theme_mode, cover_image_src: existing.cover_image_src.clone(), profile_payload_json: existing.profile_payload_json.clone(), playable_npc_count: existing.playable_npc_count, landmark_count: existing.landmark_count, play_count: existing.play_count, remix_count: existing.remix_count, like_count: existing.like_count, author_display_name: input.author_display_name.clone(), published_at: None, deleted_at: None, created_at: existing.created_at, updated_at, }; let inserted = ctx.db.custom_world_profile().insert(next_row); Ok((build_custom_world_profile_snapshot(&inserted), None)) } fn delete_custom_world_profile_record( ctx: &ReducerContext, input: module_custom_world::CustomWorldProfileDeleteInput, ) -> Result<(), String> { validate_custom_world_profile_delete_input(&input).map_err(|error| error.to_string())?; let Some(existing) = ctx .db .custom_world_profile() .profile_id() .find(&input.profile_id) .filter(|row| row.owner_user_id == input.owner_user_id) else { return Ok(()); }; if existing.deleted_at.is_some() { return Ok(()); } let deleted_at = Timestamp::from_micros_since_unix_epoch(input.deleted_at_micros); ctx.db .custom_world_profile() .profile_id() .delete(&existing.profile_id); ctx.db .custom_world_gallery_entry() .profile_id() .delete(&existing.profile_id); let next_row = CustomWorldProfile { profile_id: existing.profile_id.clone(), owner_user_id: existing.owner_user_id.clone(), public_work_code: existing.public_work_code.clone(), author_public_user_code: existing.author_public_user_code.clone(), source_agent_session_id: existing.source_agent_session_id.clone(), publication_status: CustomWorldPublicationStatus::Draft, world_name: existing.world_name.clone(), subtitle: existing.subtitle.clone(), summary_text: existing.summary_text.clone(), theme_mode: existing.theme_mode, cover_image_src: existing.cover_image_src.clone(), profile_payload_json: existing.profile_payload_json.clone(), playable_npc_count: existing.playable_npc_count, landmark_count: existing.landmark_count, play_count: existing.play_count, remix_count: existing.remix_count, like_count: existing.like_count, author_display_name: existing.author_display_name.clone(), published_at: None, deleted_at: Some(deleted_at), created_at: existing.created_at, updated_at: deleted_at, }; let _ = ctx.db.custom_world_profile().insert(next_row); Ok(()) } fn list_custom_world_profile_snapshots( ctx: &ReducerContext, input: CustomWorldProfileListInput, ) -> Result, String> { validate_custom_world_profile_list_input(&input).map_err(|error| error.to_string())?; let mut entries = ctx .db .custom_world_profile() .by_custom_world_profile_owner_user_id() .filter(&input.owner_user_id) .filter(|row| row.deleted_at.is_none()) .map(|row| build_custom_world_profile_snapshot(&row)) .collect::>(); entries.sort_by(|left, right| right.updated_at_micros.cmp(&left.updated_at_micros)); Ok(entries) } fn list_custom_world_gallery_snapshots( ctx: &ReducerContext, ) -> Result, String> { sync_missing_custom_world_gallery_entries(ctx)?; let entries = ctx .db .custom_world_gallery_entry() .iter() .collect::>(); let profile_ids = entries .iter() .map(|row| row.profile_id.clone()) .collect::>(); let recent_play_counts = count_recent_public_work_plays_for_profiles( ctx, "custom-world", &profile_ids, ctx.timestamp.to_micros_since_unix_epoch(), ); let mut entries = entries .iter() .map(|row| { build_custom_world_gallery_entry_snapshot_with_recent_counts(row, &recent_play_counts) }) .collect::>(); entries.sort_by(|left, right| { right .published_at_micros .cmp(&left.published_at_micros) .then(right.updated_at_micros.cmp(&left.updated_at_micros)) }); Ok(entries) } fn get_custom_world_library_detail_record( ctx: &ReducerContext, input: CustomWorldLibraryDetailInput, ) -> Result< ( Option, Option, ), String, > { validate_custom_world_library_detail_input(&input).map_err(|error| error.to_string())?; let profile = ctx .db .custom_world_profile() .profile_id() .find(&input.profile_id) .filter(|row| row.owner_user_id == input.owner_user_id && row.deleted_at.is_none()); let gallery_entry = profile .as_ref() .filter(|row| row.publication_status == CustomWorldPublicationStatus::Published) .and_then(|row| { ctx.db .custom_world_gallery_entry() .profile_id() .find(&row.profile_id) .filter(|gallery_row| gallery_row.owner_user_id == row.owner_user_id) }); Ok(( profile.as_ref().map(build_custom_world_profile_snapshot), gallery_entry .as_ref() .map(|row| build_custom_world_gallery_entry_snapshot(ctx, row)), )) } fn get_custom_world_gallery_detail_record( ctx: &ReducerContext, input: CustomWorldGalleryDetailInput, ) -> Result< ( Option, Option, ), String, > { validate_custom_world_gallery_detail_input(&input).map_err(|error| error.to_string())?; let profile = ctx .db .custom_world_profile() .profile_id() .find(&input.profile_id) .filter(|row| { row.owner_user_id == input.owner_user_id && row.publication_status == CustomWorldPublicationStatus::Published && row.deleted_at.is_none() }); let gallery_entry = ctx .db .custom_world_gallery_entry() .profile_id() .find(&input.profile_id) .filter(|row| row.owner_user_id == input.owner_user_id); Ok(( profile.as_ref().map(build_custom_world_profile_snapshot), gallery_entry .as_ref() .map(|row| build_custom_world_gallery_entry_snapshot(ctx, row)), )) } fn get_custom_world_gallery_detail_record_by_code( ctx: &ReducerContext, input: module_custom_world::CustomWorldGalleryDetailByCodeInput, ) -> Result< ( Option, Option, ), String, > { validate_custom_world_gallery_detail_by_code_input(&input) .map_err(|error| error.to_string())?; let normalized_public_work_code = normalize_public_work_code(&input.public_work_code) .ok_or_else(|| "public_work_code 格式不正确".to_string())?; let gallery_entry = ctx .db .custom_world_gallery_entry() .by_custom_world_gallery_public_work_code() .filter(&normalized_public_work_code) .next(); let profile = gallery_entry.as_ref().and_then(|row| { ctx.db .custom_world_profile() .profile_id() .find(&row.profile_id) .filter(|profile_row| { profile_row.owner_user_id == row.owner_user_id && profile_row.publication_status == CustomWorldPublicationStatus::Published && profile_row.deleted_at.is_none() }) }); Ok(( profile.as_ref().map(build_custom_world_profile_snapshot), gallery_entry .as_ref() .map(|row| build_custom_world_gallery_entry_snapshot(ctx, row)), )) } fn remix_custom_world_profile_record( ctx: &ReducerContext, input: module_custom_world::CustomWorldProfileRemixInput, ) -> Result< ( CustomWorldProfileSnapshot, Option, ), String, > { let source_owner_user_id = input.source_owner_user_id.trim(); let source_profile_id = input.source_profile_id.trim(); let target_owner_user_id = input.target_owner_user_id.trim(); let target_profile_id = input.target_profile_id.trim(); if source_owner_user_id.is_empty() || source_profile_id.is_empty() || target_owner_user_id.is_empty() || target_profile_id.is_empty() { return Err("custom_world remix 参数不能为空".to_string()); } if input.author_display_name.trim().is_empty() { return Err("custom_world remix 作者名不能为空".to_string()); } let source = ctx .db .custom_world_profile() .profile_id() .find(&source_profile_id.to_string()) .filter(|row| row.owner_user_id == source_owner_user_id) .filter(|row| { row.publication_status == CustomWorldPublicationStatus::Published && row.deleted_at.is_none() && row.published_at.is_some() }) .ok_or_else(|| "custom_world 已发布源作品不存在,无法改编".to_string())?; let remixed_at = Timestamp::from_micros_since_unix_epoch(input.remixed_at_micros); ctx.db .custom_world_profile() .profile_id() .delete(&source.profile_id); let next_source = CustomWorldProfile { profile_id: source.profile_id.clone(), owner_user_id: source.owner_user_id.clone(), public_work_code: source.public_work_code.clone(), author_public_user_code: source.author_public_user_code.clone(), source_agent_session_id: source.source_agent_session_id.clone(), publication_status: source.publication_status, world_name: source.world_name.clone(), subtitle: source.subtitle.clone(), summary_text: source.summary_text.clone(), theme_mode: source.theme_mode, cover_image_src: source.cover_image_src.clone(), profile_payload_json: source.profile_payload_json.clone(), playable_npc_count: source.playable_npc_count, landmark_count: source.landmark_count, play_count: source.play_count, remix_count: source.remix_count.saturating_add(1), like_count: source.like_count, author_display_name: source.author_display_name.clone(), published_at: source.published_at, deleted_at: source.deleted_at, created_at: source.created_at, updated_at: remixed_at, }; let updated_source = ctx.db.custom_world_profile().insert(next_source); let source_gallery = sync_custom_world_gallery_entry_from_profile(ctx, &updated_source)?; // 改编生成目标用户草稿:复制内容,不复制源作品热度。 let draft = CustomWorldProfile { profile_id: target_profile_id.to_string(), owner_user_id: target_owner_user_id.to_string(), public_work_code: None, author_public_user_code: None, source_agent_session_id: None, publication_status: CustomWorldPublicationStatus::Draft, world_name: source.world_name.clone(), subtitle: source.subtitle.clone(), summary_text: source.summary_text.clone(), theme_mode: source.theme_mode, cover_image_src: source.cover_image_src.clone(), profile_payload_json: source.profile_payload_json.clone(), playable_npc_count: source.playable_npc_count, landmark_count: source.landmark_count, play_count: 0, remix_count: 0, like_count: 0, author_display_name: input.author_display_name.trim().to_string(), published_at: None, deleted_at: None, created_at: remixed_at, updated_at: remixed_at, }; if let Some(existing_target) = ctx .db .custom_world_profile() .profile_id() .find(&target_profile_id.to_string()) .filter(|row| row.owner_user_id == target_owner_user_id) { ctx.db .custom_world_profile() .profile_id() .delete(&existing_target.profile_id); } let inserted_draft = ctx.db.custom_world_profile().insert(draft); Ok(( build_custom_world_profile_snapshot(&inserted_draft), Some(source_gallery), )) } fn record_custom_world_profile_play_record( ctx: &ReducerContext, input: module_custom_world::CustomWorldProfilePlayRecordInput, ) -> Result<(CustomWorldProfileSnapshot, CustomWorldGalleryEntrySnapshot), String> { let owner_user_id = input.owner_user_id.trim(); let profile_id = input.profile_id.trim(); if owner_user_id.is_empty() || profile_id.is_empty() { return Err("custom_world play 参数不能为空".to_string()); } let existing = ctx .db .custom_world_profile() .profile_id() .find(&profile_id.to_string()) .filter(|row| row.owner_user_id == owner_user_id) .filter(|row| { row.publication_status == CustomWorldPublicationStatus::Published && row.deleted_at.is_none() && row.published_at.is_some() }) .ok_or_else(|| "custom_world 已发布作品不存在,无法记录游玩".to_string())?; let played_at = Timestamp::from_micros_since_unix_epoch(input.played_at_micros); record_public_work_play( ctx, PublicWorkPlayRecordInput { source_type: "custom-world".to_string(), owner_user_id: owner_user_id.to_string(), profile_id: profile_id.to_string(), played_at_micros: input.played_at_micros, }, )?; ctx.db .custom_world_profile() .profile_id() .delete(&existing.profile_id); let next_row = CustomWorldProfile { profile_id: existing.profile_id.clone(), owner_user_id: existing.owner_user_id.clone(), public_work_code: existing.public_work_code.clone(), author_public_user_code: existing.author_public_user_code.clone(), source_agent_session_id: existing.source_agent_session_id.clone(), publication_status: existing.publication_status, world_name: existing.world_name.clone(), subtitle: existing.subtitle.clone(), summary_text: existing.summary_text.clone(), theme_mode: existing.theme_mode, cover_image_src: existing.cover_image_src.clone(), profile_payload_json: existing.profile_payload_json.clone(), playable_npc_count: existing.playable_npc_count, landmark_count: existing.landmark_count, play_count: existing.play_count.saturating_add(1), remix_count: existing.remix_count, like_count: existing.like_count, author_display_name: existing.author_display_name.clone(), published_at: existing.published_at, deleted_at: existing.deleted_at, created_at: existing.created_at, updated_at: played_at, }; let inserted = ctx.db.custom_world_profile().insert(next_row); let gallery_entry = sync_custom_world_gallery_entry_from_profile(ctx, &inserted)?; Ok(( build_custom_world_profile_snapshot(&inserted), gallery_entry, )) } fn record_custom_world_profile_like_record( ctx: &ReducerContext, input: module_custom_world::CustomWorldProfileLikeRecordInput, ) -> Result<(CustomWorldProfileSnapshot, CustomWorldGalleryEntrySnapshot), String> { let owner_user_id = input.owner_user_id.trim(); let profile_id = input.profile_id.trim(); let user_id = input.user_id.trim(); if owner_user_id.is_empty() || profile_id.is_empty() || user_id.is_empty() { return Err("custom_world like 参数不能为空".to_string()); } let existing = ctx .db .custom_world_profile() .profile_id() .find(&profile_id.to_string()) .filter(|row| row.owner_user_id == owner_user_id) .filter(|row| { row.publication_status == CustomWorldPublicationStatus::Published && row.deleted_at.is_none() && row.published_at.is_some() }) .ok_or_else(|| "custom_world 已发布作品不存在,无法点赞".to_string())?; let liked_at = Timestamp::from_micros_since_unix_epoch(input.liked_at_micros); let inserted_like = record_public_work_like( ctx, PublicWorkLikeRecordInput { source_type: "custom-world".to_string(), owner_user_id: owner_user_id.to_string(), profile_id: profile_id.to_string(), user_id: user_id.to_string(), liked_at_micros: input.liked_at_micros, }, )?; if !inserted_like { let gallery_entry = sync_custom_world_gallery_entry_from_profile(ctx, &existing)?; return Ok(( build_custom_world_profile_snapshot(&existing), gallery_entry, )); } ctx.db .custom_world_profile() .profile_id() .delete(&existing.profile_id); let next_row = CustomWorldProfile { profile_id: existing.profile_id.clone(), owner_user_id: existing.owner_user_id.clone(), public_work_code: existing.public_work_code.clone(), author_public_user_code: existing.author_public_user_code.clone(), source_agent_session_id: existing.source_agent_session_id.clone(), publication_status: existing.publication_status, world_name: existing.world_name.clone(), subtitle: existing.subtitle.clone(), summary_text: existing.summary_text.clone(), theme_mode: existing.theme_mode, cover_image_src: existing.cover_image_src.clone(), profile_payload_json: existing.profile_payload_json.clone(), playable_npc_count: existing.playable_npc_count, landmark_count: existing.landmark_count, play_count: existing.play_count, remix_count: existing.remix_count, like_count: existing.like_count.saturating_add(1), author_display_name: existing.author_display_name.clone(), published_at: existing.published_at, deleted_at: existing.deleted_at, created_at: existing.created_at, updated_at: liked_at, }; let inserted = ctx.db.custom_world_profile().insert(next_row); let gallery_entry = sync_custom_world_gallery_entry_from_profile(ctx, &inserted)?; Ok(( build_custom_world_profile_snapshot(&inserted), gallery_entry, )) } fn list_custom_world_work_snapshots( ctx: &ReducerContext, input: CustomWorldWorksListInput, ) -> Result, String> { validate_custom_world_works_list_input(&input).map_err(|error| error.to_string())?; let mut items = Vec::new(); let mut active_agent_session_ids = HashSet::new(); let sessions = ctx .db .custom_world_agent_session() .by_custom_world_agent_session_owner_user_id() .filter(&input.owner_user_id) .collect::>(); for session in sessions.iter().filter(|row| { row.stage != RpgAgentStage::Published && should_include_custom_world_agent_session_work(ctx, row) }) { active_agent_session_ids.insert(session.session_id.clone()); let gate = build_custom_world_publish_gate_from_session(&session); let draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref()); let title = resolve_session_work_title(&session, draft_profile.as_ref()); let summary = resolve_session_work_summary(&session, draft_profile.as_ref()); let stage_label = Some(resolve_rpg_agent_stage_label(session.stage).to_string()); let subtitle = resolve_session_work_subtitle(draft_profile.as_ref(), stage_label.as_deref()); let (playable_npc_count, landmark_count) = resolve_session_work_counts(ctx, &session, draft_profile.as_ref()); items.push(CustomWorldWorkSummarySnapshot { work_id: format!("draft:{}", session.session_id), source_type: "agent_session".to_string(), status: "draft".to_string(), title, subtitle, summary, cover_image_src: resolve_session_work_cover_image_src(draft_profile.as_ref()), cover_render_mode: None, cover_character_image_srcs_json: "[]".to_string(), updated_at_micros: session.updated_at.to_micros_since_unix_epoch(), published_at_micros: None, stage: Some(session.stage), stage_label, playable_npc_count, landmark_count, role_visual_ready_count: None, role_animation_ready_count: None, role_asset_summary_label: None, session_id: Some(session.session_id.clone()), profile_id: None, can_resume: true, can_enter_world: gate.can_enter_world, blocker_count: gate.blocker_count, publish_ready: gate.publish_ready, }); } for profile in ctx .db .custom_world_profile() .by_custom_world_profile_owner_user_id() .filter(&input.owner_user_id) .filter(|row| row.deleted_at.is_none()) .filter(|row| should_include_custom_world_profile_work(row, &active_agent_session_ids)) { items.push(CustomWorldWorkSummarySnapshot { work_id: format!("published:{}", profile.profile_id), source_type: "published_profile".to_string(), status: profile.publication_status.as_str().to_string(), title: profile.world_name.clone(), subtitle: profile.subtitle.clone(), summary: profile.summary_text.clone(), cover_image_src: profile.cover_image_src.clone(), cover_render_mode: None, cover_character_image_srcs_json: "[]".to_string(), updated_at_micros: profile.updated_at.to_micros_since_unix_epoch(), published_at_micros: profile .published_at .map(|value| value.to_micros_since_unix_epoch()), stage: None, stage_label: None, playable_npc_count: profile.playable_npc_count, landmark_count: profile.landmark_count, role_visual_ready_count: None, role_animation_ready_count: None, role_asset_summary_label: None, session_id: profile.source_agent_session_id.clone(), profile_id: Some(profile.profile_id.clone()), can_resume: false, can_enter_world: profile.publication_status == CustomWorldPublicationStatus::Published, blocker_count: 0, publish_ready: true, }); } items.sort_by(|left, right| { right .updated_at_micros .cmp(&left.updated_at_micros) .then_with(|| { let left_rank = if left.source_type == "agent_session" { 0 } else { 1 }; let right_rank = if right.source_type == "agent_session" { 0 } else { 1 }; left_rank.cmp(&right_rank) }) .then(left.work_id.cmp(&right.work_id)) }); Ok(items) } fn should_include_custom_world_agent_session_work( ctx: &ReducerContext, session: &CustomWorldAgentSession, ) -> bool { if custom_world_agent_session_has_direct_work_content(session) { return true; } if ctx .db .custom_world_agent_message() .by_custom_world_agent_message_session_id() .filter(&session.session_id) .any(|message| matches!(message.role, RpgAgentMessageRole::User)) { return true; } ctx.db .custom_world_draft_card() .by_custom_world_draft_card_session_id() .filter(&session.session_id) .any(|card| card.session_id == session.session_id) } fn custom_world_agent_session_has_direct_work_content(session: &CustomWorldAgentSession) -> bool { // 创建会话时写入的助手欢迎语和空 `{}` draftProfile 不算草稿内容; // 这里只承认用户显式输入的 seed 或已经生成出的真实草稿阶段。 !session.seed_text.trim().is_empty() || matches!( session.stage, RpgAgentStage::ObjectRefining | RpgAgentStage::VisualRefining | RpgAgentStage::LongTailReview | RpgAgentStage::ReadyToPublish | RpgAgentStage::Published ) || parse_optional_session_object(session.draft_profile_json.as_deref()) .as_ref() .is_some_and(|profile| !profile.is_empty()) } fn should_include_custom_world_profile_work( row: &CustomWorldProfile, active_agent_session_ids: &HashSet, ) -> bool { // 已发布 profile 是正式作品;即使来源会话还存在,也必须保留独立入口。 if row.publication_status == CustomWorldPublicationStatus::Published { return true; } // 未发布 profile 若来源于仍可继续聊天的 Agent 会话,只是同一草稿的编译产物, // works 里保留 agent_session 即可,避免草稿分组显示两份同名作品。 row.source_agent_session_id .as_ref() .map_or(true, |session_id| { !active_agent_session_ids.contains(session_id) }) } fn get_custom_world_agent_card_detail_tx( ctx: &ReducerContext, input: CustomWorldAgentCardDetailGetInput, ) -> Result { validate_custom_world_agent_card_detail_get_input(&input).map_err(|error| error.to_string())?; ctx.db .custom_world_agent_session() .session_id() .find(&input.session_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "custom_world_agent_session 不存在".to_string())?; let card = ctx .db .custom_world_draft_card() .card_id() .find(&input.card_id) .filter(|row| row.session_id == input.session_id) .ok_or_else(|| "custom_world_draft_card 不存在".to_string())?; build_custom_world_draft_card_detail_snapshot(&card) } fn execute_custom_world_agent_action_tx( ctx: &ReducerContext, input: CustomWorldAgentActionExecuteInput, ) -> Result { validate_custom_world_agent_action_execute_input(&input).map_err(|error| error.to_string())?; let session = ctx .db .custom_world_agent_session() .session_id() .find(&input.session_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "custom_world_agent_session 不存在".to_string())?; if let Some(existing_operation) = ctx .db .custom_world_agent_operation() .operation_id() .find(&input.operation_id) { let can_reuse_running_draft_operation = input.action.trim() == "draft_foundation" && existing_operation.session_id == input.session_id && existing_operation.operation_type == RpgAgentOperationType::DraftFoundation && matches!( existing_operation.status, RpgAgentOperationStatus::Queued | RpgAgentOperationStatus::Running ); if !can_reuse_running_draft_operation { return Err("custom_world_agent_operation.operation_id 已存在".to_string()); } } let payload = parse_optional_session_object(input.payload_json.as_deref()).unwrap_or_default(); match input.action.trim() { "draft_foundation" => execute_draft_foundation_action(ctx, &session, &input, &payload), "update_draft_card" => execute_update_draft_card_action(ctx, &session, &input, &payload), "sync_result_profile" => { execute_sync_result_profile_action(ctx, &session, &input, &payload) } "publish_world" => execute_publish_world_action(ctx, &session, &input, &payload), "revert_checkpoint" => execute_revert_checkpoint_action(ctx, &session, &input, &payload), "generate_characters" => { execute_generate_characters_action(ctx, &session, &input, &payload) } "generate_landmarks" => execute_generate_landmarks_action(ctx, &session, &input, &payload), "generate_role_assets" => { execute_generate_role_assets_action(ctx, &session, &input, &payload) } "sync_role_assets" => execute_sync_role_assets_action(ctx, &session, &input, &payload), "generate_scene_assets" => { execute_generate_scene_assets_action(ctx, &session, &input, &payload) } "sync_scene_assets" => execute_sync_scene_assets_action(ctx, &session, &input, &payload), "expand_long_tail" => execute_expand_long_tail_action(ctx, &session, &input, &payload), other => Err(format!("custom world action `{other}` 当前尚未支持")), } } fn execute_draft_foundation_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, input: &CustomWorldAgentActionExecuteInput, payload: &JsonMap, ) -> Result { if session.progress_percent < 100 { return Err("draft_foundation requires progressPercent >= 100".to_string()); } let updated_at = input.submitted_at_micros; let draft_profile = payload .get("draftProfile") .and_then(JsonValue::as_object) .cloned() .ok_or_else(|| { "draft_foundation requires externally generated payload.draftProfile".to_string() })?; let draft_profile_json = serde_json::to_string(&JsonValue::Object(draft_profile.clone())) .map_err(|error| format!("draft_foundation 无法序列化 draft_profile_json: {error}"))?; let gate = summarize_publish_gate_from_json( &input.session_id, RpgAgentStage::ObjectRefining, Some(&draft_profile), &parse_json_array_or_empty(&session.quality_findings_json), ); let next_session = rebuild_custom_world_agent_session_row( session, CustomWorldAgentSessionPatch { progress_percent: Some(100), stage: Some(RpgAgentStage::ObjectRefining), draft_profile_json: Some(Some(draft_profile_json.clone())), last_assistant_reply: Some(Some( "世界底稿已整理完成,接下来可以继续细化卡片和发布预览。".to_string(), )), publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value( &gate, ))?)), result_preview_json: Some(build_result_preview_json( Some(&draft_profile), &gate, &parse_json_array_or_empty(&session.quality_findings_json), updated_at, )?), checkpoints_json: Some(append_checkpoint_json( &session.checkpoints_json, &build_session_checkpoint_value("foundation-ready", "底稿整理完成", session), )?), updated_at_micros: Some(updated_at), ..CustomWorldAgentSessionPatch::default() }, )?; replace_custom_world_agent_session(ctx, session, next_session); upsert_world_foundation_card(ctx, &session.session_id, &draft_profile, updated_at)?; append_custom_world_action_result_message( ctx, &session.session_id, &input.operation_id, "已整理出第一版世界底稿,并同步生成世界基础卡片。", updated_at, ); let operation = complete_custom_world_operation( ctx, &input.operation_id, &session.session_id, RpgAgentOperationType::DraftFoundation, "底稿已整理", "第一版 foundation draft 已写入会话与世界卡。", updated_at, )?; Ok(build_custom_world_agent_operation_snapshot(&operation)) } fn execute_update_draft_card_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, input: &CustomWorldAgentActionExecuteInput, payload: &JsonMap, ) -> Result { ensure_refining_stage(session.stage, "update_draft_card")?; let card_id = read_required_payload_text(payload, "cardId", "update_draft_card requires cardId")?; let card = ctx .db .custom_world_draft_card() .card_id() .find(&card_id) .filter(|row| row.session_id == session.session_id) .ok_or_else(|| "update_draft_card target card does not exist".to_string())?; let sections = payload .get("sections") .and_then(JsonValue::as_array) .ok_or_else(|| "update_draft_card requires sections".to_string())?; if sections.is_empty() { return Err("update_draft_card requires sections".to_string()); } let mut detail_object = parse_optional_session_object(card.detail_payload_json.as_deref()).unwrap_or_default(); let mut detail_sections = detail_object .get("sections") .and_then(JsonValue::as_array) .cloned() .unwrap_or_else(|| build_fallback_card_sections_json(&card)); for patch in sections { let patch_object = patch .as_object() .ok_or_else(|| "update_draft_card.sections 必须是 object 数组".to_string())?; let section_id = read_required_payload_text( patch_object, "sectionId", "update_draft_card section.sectionId is required", )?; let value = patch_object .get("value") .and_then(JsonValue::as_str) .unwrap_or_default() .trim() .to_string(); let mut updated = false; for existing in &mut detail_sections { if existing.get("id").and_then(JsonValue::as_str) == Some(section_id.as_str()) { if let Some(object) = existing.as_object_mut() { object.insert("value".to_string(), JsonValue::String(value.clone())); } updated = true; break; } } if !updated { detail_sections.push(json!({ "id": section_id, "label": section_id, "value": value, })); } } detail_object.insert("id".to_string(), JsonValue::String(card.card_id.clone())); detail_object.insert( "kind".to_string(), JsonValue::String(card.kind.as_str().to_string()), ); detail_object.insert("title".to_string(), JsonValue::String(card.title.clone())); detail_object.insert( "sections".to_string(), JsonValue::Array(detail_sections.clone()), ); detail_object.insert( "linkedIds".to_string(), serde_json::from_str::(&card.linked_ids_json) .unwrap_or_else(|_| JsonValue::Array(Vec::new())), ); detail_object.insert("locked".to_string(), JsonValue::Bool(false)); detail_object.insert("editable".to_string(), JsonValue::Bool(false)); detail_object.insert( "editableSectionIds".to_string(), JsonValue::Array(Vec::new()), ); detail_object.insert("warningMessages".to_string(), JsonValue::Array(Vec::new())); let updated_title = extract_detail_section_value(&detail_sections, "title") .unwrap_or_else(|| card.title.clone()); let updated_subtitle = extract_detail_section_value(&detail_sections, "subtitle") .unwrap_or_else(|| card.subtitle.clone()); let updated_summary = extract_detail_section_value(&detail_sections, "summary") .unwrap_or_else(|| card.summary.clone()); let detail_payload_json = serde_json::to_string(&JsonValue::Object(detail_object)) .map_err(|error| format!("update_draft_card 无法序列化 detail_payload_json: {error}"))?; replace_custom_world_draft_card( ctx, &card, CustomWorldDraftCard { card_id: card.card_id.clone(), session_id: card.session_id.clone(), kind: card.kind, status: card.status, title: updated_title.clone(), subtitle: updated_subtitle.clone(), summary: updated_summary.clone(), linked_ids_json: card.linked_ids_json.clone(), warning_count: card.warning_count, asset_status: card.asset_status, asset_status_label: card.asset_status_label.clone(), detail_payload_json: Some(detail_payload_json), created_at: card.created_at, updated_at: Timestamp::from_micros_since_unix_epoch(input.submitted_at_micros), }, ); let next_session = sync_session_draft_profile_from_card_update( session, &card, &updated_title, &updated_subtitle, &updated_summary, input.submitted_at_micros, )?; replace_custom_world_agent_session(ctx, session, next_session); append_custom_world_action_result_message( ctx, &session.session_id, &input.operation_id, &format!("已更新卡片《{}》的草稿内容。", updated_title), input.submitted_at_micros, ); let operation = build_and_insert_custom_world_operation( ctx, &input.operation_id, &session.session_id, RpgAgentOperationType::UpdateDraftCard, "卡片已更新", &format!("卡片 {} 的 detail 与摘要字段已同步更新。", card_id), input.submitted_at_micros, ); Ok(build_custom_world_agent_operation_snapshot(&operation)) } fn execute_sync_result_profile_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, input: &CustomWorldAgentActionExecuteInput, payload: &JsonMap, ) -> Result { ensure_result_profile_sync_stage(session.stage, "sync_result_profile")?; let mut profile = payload .get("profile") .and_then(JsonValue::as_object) .cloned() .ok_or_else(|| "sync_result_profile requires profile".to_string())?; if let Some(stable_profile_id) = resolve_stable_agent_draft_profile_id(session) { // 结果页回写时必须沿用当前草稿的稳定身份,避免把同一草稿写成新条目。 profile.insert( "id".to_string(), JsonValue::String(stable_profile_id.clone()), ); upsert_nested_result_profile_id(&mut profile, &stable_profile_id); } let draft_profile = ensure_minimal_draft_profile(profile, &session.seed_text); let gate = summarize_publish_gate_from_json( &session.session_id, session.stage, Some(&draft_profile), &parse_json_array_or_empty(&session.quality_findings_json), ); let next_session = rebuild_custom_world_agent_session_row( session, CustomWorldAgentSessionPatch { draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object( draft_profile.clone(), ))?)), last_assistant_reply: Some(Some("结果页草稿已同步回当前会话。".to_string())), publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value( &gate, ))?)), result_preview_json: Some(build_result_preview_json( Some(&draft_profile), &gate, &parse_json_array_or_empty(&session.quality_findings_json), input.submitted_at_micros, )?), checkpoints_json: Some(append_checkpoint_json( &session.checkpoints_json, &build_session_checkpoint_value("sync-result-profile", "同步结果页草稿", session), )?), updated_at_micros: Some(input.submitted_at_micros), ..CustomWorldAgentSessionPatch::default() }, )?; replace_custom_world_agent_session(ctx, session, next_session); append_custom_world_action_result_message( ctx, &session.session_id, &input.operation_id, "结果页 profile 已回写当前会话,并重建预览。", input.submitted_at_micros, ); let operation = build_and_insert_custom_world_operation( ctx, &input.operation_id, &session.session_id, RpgAgentOperationType::SyncResultProfile, "结果页已同步", "draft_profile_json 与 result_preview 已更新。", input.submitted_at_micros, ); Ok(build_custom_world_agent_operation_snapshot(&operation)) } fn resolve_stable_agent_draft_profile_id(session: &CustomWorldAgentSession) -> Option { parse_optional_session_object(session.draft_profile_json.as_deref()) .and_then(|profile| read_optional_text_field(&profile, &["legacyResultProfile.id", "id"])) } fn upsert_nested_result_profile_id( profile: &mut JsonMap, stable_profile_id: &str, ) { let legacy_result_profile = profile .entry("legacyResultProfile".to_string()) .or_insert_with(|| JsonValue::Object(JsonMap::new())); if let Some(object) = legacy_result_profile.as_object_mut() { object.insert( "id".to_string(), JsonValue::String(stable_profile_id.to_string()), ); } } fn resolve_publish_world_setting_text( payload: &JsonMap, draft_profile: &JsonMap, session: &CustomWorldAgentSession, ) -> String { module_custom_world::resolve_custom_world_publish_setting_text( payload, draft_profile, &session.seed_text, ) } fn is_same_agent_draft_profile_candidate( row: &CustomWorldProfile, owner_user_id: &str, source_agent_session_id: &str, ) -> bool { row.owner_user_id == owner_user_id && row.deleted_at.is_none() && row.publication_status == CustomWorldPublicationStatus::Draft && row.source_agent_session_id.as_deref() == Some(source_agent_session_id) } fn execute_publish_world_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, input: &CustomWorldAgentActionExecuteInput, payload: &JsonMap, ) -> Result { ensure_publishable_stage(session.stage, "publish_world")?; // 中文注释:发布动作不再信任前端携带的 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, Some(&draft_profile), &parse_json_array_or_empty(&session.quality_findings_json), ); if !gate.publish_ready { return Err(format!( "当前世界仍有 {} 个 blocker,暂时不能发布", gate.blocker_count )); } let profile_id = gate.profile_id.clone(); let setting_text = resolve_publish_world_setting_text(payload, &draft_profile, session); 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"]) .unwrap_or_else(|| "创作者".to_string()); let publish_result = publish_custom_world_world_record( ctx, CustomWorldPublishWorldInput { session_id: session.session_id.clone(), profile_id, owner_user_id: session.owner_user_id.clone(), public_work_code: None, author_public_user_code, draft_profile_json: serialize_json_value(&JsonValue::Object(draft_profile.clone()))?, legacy_result_profile_json, setting_text, author_display_name, published_at_micros: input.submitted_at_micros, }, )?; append_custom_world_action_result_message( ctx, &session.session_id, &input.operation_id, &format!("正式世界档案已发布:{}。", publish_result.1.profile_id), input.submitted_at_micros, ); let operation = build_and_insert_custom_world_operation( ctx, &input.operation_id, &session.session_id, RpgAgentOperationType::PublishWorld, "世界已发布", &format!( "正式世界档案已写入作品库:{}。", publish_result.1.profile_id ), input.submitted_at_micros, ); Ok(build_custom_world_agent_operation_snapshot(&operation)) } fn read_publish_world_draft_profile_from_session( session: &CustomWorldAgentSession, ) -> Result, 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, input: &CustomWorldAgentActionExecuteInput, payload: &JsonMap, ) -> Result { ensure_long_tail_stage(session.stage, "revert_checkpoint")?; let checkpoint_id = read_required_payload_text( payload, "checkpointId", "revert_checkpoint requires checkpointId", )?; let checkpoint = parse_json_array_or_empty(&session.checkpoints_json) .into_iter() .find(|entry| { entry .get("checkpointId") .and_then(JsonValue::as_str) .map(str::trim) == Some(checkpoint_id.as_str()) }) .ok_or_else(|| "revert_checkpoint target checkpoint does not exist".to_string())?; let snapshot = checkpoint .get("snapshot") .and_then(JsonValue::as_object) .cloned() .ok_or_else(|| { "revert_checkpoint target checkpoint does not contain a restorable snapshot".to_string() })?; let restored_stage = snapshot .get("stage") .and_then(JsonValue::as_str) .and_then(parse_rpg_agent_stage) .unwrap_or(session.stage); let restored_progress = snapshot .get("progressPercent") .and_then(JsonValue::as_u64) .and_then(|value| u32::try_from(value).ok()) .unwrap_or(session.progress_percent); let restored_draft_profile = snapshot .get("draftProfile") .and_then(JsonValue::as_object) .cloned(); let restored_quality_findings = snapshot .get("qualityFindings") .and_then(JsonValue::as_array) .cloned() .unwrap_or_else(Vec::new); let gate = summarize_publish_gate_from_json( &session.session_id, restored_stage, restored_draft_profile.as_ref(), &restored_quality_findings, ); let next_session = rebuild_custom_world_agent_session_row( session, CustomWorldAgentSessionPatch { progress_percent: Some(restored_progress), stage: Some(restored_stage), draft_profile_json: Some( restored_draft_profile .as_ref() .map(|value| serialize_json_value(&JsonValue::Object(value.clone()))) .transpose()?, ), last_assistant_reply: Some(Some( "已恢复到所选 checkpoint 的世界草稿状态。".to_string(), )), quality_findings_json: Some(serialize_json_value(&JsonValue::Array( restored_quality_findings, ))?), publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value( &gate, ))?)), result_preview_json: Some(build_result_preview_json( restored_draft_profile.as_ref(), &gate, &parse_json_array_or_empty(&serialize_json_value(&JsonValue::Array( snapshot .get("qualityFindings") .and_then(JsonValue::as_array) .cloned() .unwrap_or_else(Vec::new), ))?), input.submitted_at_micros, )?), updated_at_micros: Some(input.submitted_at_micros), ..CustomWorldAgentSessionPatch::default() }, )?; replace_custom_world_agent_session(ctx, session, next_session); append_custom_world_action_result_message( ctx, &session.session_id, &input.operation_id, "已恢复到所选 checkpoint。", input.submitted_at_micros, ); let operation = build_and_insert_custom_world_operation( ctx, &input.operation_id, &session.session_id, RpgAgentOperationType::RevertCheckpoint, "已回滚 checkpoint", &format!("会话已恢复到 checkpoint {}。", checkpoint_id), input.submitted_at_micros, ); Ok(build_custom_world_agent_operation_snapshot(&operation)) } fn execute_generate_characters_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, input: &CustomWorldAgentActionExecuteInput, payload: &JsonMap, ) -> Result { ensure_refining_stage(session.stage, "generate_characters")?; let mut draft_profile = current_custom_world_draft_profile(session); let inserted = upsert_draft_profile_array_from_payload( &mut draft_profile, payload, "characters", "playableNpcs", "character", RpgAgentDraftCardKind::Character, ctx, &session.session_id, input.submitted_at_micros, )?; let inserted_story = upsert_draft_profile_array_from_payload( &mut draft_profile, payload, "storyNpcs", "storyNpcs", "story-npc", RpgAgentDraftCardKind::Character, ctx, &session.session_id, input.submitted_at_micros, )?; let total_inserted = inserted.saturating_add(inserted_story); persist_custom_world_draft_profile_update( ctx, session, draft_profile, input.submitted_at_micros, RpgAgentStage::ObjectRefining, format!("已同步 {total_inserted} 个角色草稿。"), "generate-characters", "生成角色草稿", )?; append_custom_world_action_result_message( ctx, &session.session_id, &input.operation_id, &format!("已生成并同步 {total_inserted} 个角色草稿。"), input.submitted_at_micros, ); let operation = complete_custom_world_operation( ctx, &input.operation_id, &session.session_id, RpgAgentOperationType::GenerateCharacters, "角色草稿已同步", &format!("角色草稿已写入 draft_profile 与卡片表,新增 {total_inserted} 条。"), input.submitted_at_micros, )?; Ok(build_custom_world_agent_operation_snapshot(&operation)) } fn execute_generate_landmarks_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, input: &CustomWorldAgentActionExecuteInput, payload: &JsonMap, ) -> Result { ensure_refining_stage(session.stage, "generate_landmarks")?; let mut draft_profile = current_custom_world_draft_profile(session); let inserted = upsert_draft_profile_array_from_payload( &mut draft_profile, payload, "landmarks", "landmarks", "landmark", RpgAgentDraftCardKind::Landmark, ctx, &session.session_id, input.submitted_at_micros, )?; persist_custom_world_draft_profile_update( ctx, session, draft_profile, input.submitted_at_micros, RpgAgentStage::ObjectRefining, format!("已同步 {inserted} 个地标草稿。"), "generate-landmarks", "生成地标草稿", )?; append_custom_world_action_result_message( ctx, &session.session_id, &input.operation_id, &format!("已生成并同步 {inserted} 个地标草稿。"), input.submitted_at_micros, ); let operation = complete_custom_world_operation( ctx, &input.operation_id, &session.session_id, RpgAgentOperationType::GenerateLandmarks, "地标草稿已同步", &format!("地标草稿已写入 draft_profile 与卡片表,新增 {inserted} 条。"), input.submitted_at_micros, )?; Ok(build_custom_world_agent_operation_snapshot(&operation)) } fn execute_generate_role_assets_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, input: &CustomWorldAgentActionExecuteInput, payload: &JsonMap, ) -> Result { ensure_refining_stage(session.stage, "generate_role_assets")?; let next_coverage = build_role_asset_coverage_json(session, payload, true)?; let next_session = rebuild_custom_world_agent_session_row( session, CustomWorldAgentSessionPatch { stage: Some(RpgAgentStage::VisualRefining), asset_coverage_json: Some(next_coverage), last_assistant_reply: Some(Some( "角色视觉资产槽位已生成并进入视觉打磨阶段。".to_string(), )), updated_at_micros: Some(input.submitted_at_micros), ..CustomWorldAgentSessionPatch::default() }, )?; replace_custom_world_agent_session(ctx, session, next_session); update_role_asset_cards( ctx, &session.session_id, CustomWorldRoleAssetStatus::VisualReady, "角色主图已就绪", input.submitted_at_micros, ); append_custom_world_action_result_message( ctx, &session.session_id, &input.operation_id, "角色视觉资产槽位已生成,角色卡片状态已刷新。", input.submitted_at_micros, ); let operation = complete_custom_world_operation( ctx, &input.operation_id, &session.session_id, RpgAgentOperationType::GenerateRoleAssets, "角色资产已生成", "asset_coverage.roleAssets 与角色卡片视觉状态已更新。", input.submitted_at_micros, )?; Ok(build_custom_world_agent_operation_snapshot(&operation)) } fn execute_sync_role_assets_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, input: &CustomWorldAgentActionExecuteInput, payload: &JsonMap, ) -> Result { ensure_refining_stage(session.stage, "sync_role_assets")?; let next_coverage = build_role_asset_coverage_json(session, payload, false)?; let next_session = rebuild_custom_world_agent_session_row( session, CustomWorldAgentSessionPatch { stage: Some(RpgAgentStage::VisualRefining), asset_coverage_json: Some(next_coverage), last_assistant_reply: Some(Some("角色资产状态已按外部资产结果同步。".to_string())), updated_at_micros: Some(input.submitted_at_micros), ..CustomWorldAgentSessionPatch::default() }, )?; replace_custom_world_agent_session(ctx, session, next_session); update_role_asset_cards( ctx, &session.session_id, CustomWorldRoleAssetStatus::Complete, "角色资产已同步", input.submitted_at_micros, ); append_custom_world_action_result_message( ctx, &session.session_id, &input.operation_id, "角色资产结果已同步到会话覆盖率与角色卡片。", input.submitted_at_micros, ); let operation = complete_custom_world_operation( ctx, &input.operation_id, &session.session_id, RpgAgentOperationType::SyncRoleAssets, "角色资产已同步", "asset_coverage.roleAssets 与角色卡片完成状态已更新。", input.submitted_at_micros, )?; Ok(build_custom_world_agent_operation_snapshot(&operation)) } fn execute_generate_scene_assets_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, input: &CustomWorldAgentActionExecuteInput, payload: &JsonMap, ) -> Result { ensure_refining_stage(session.stage, "generate_scene_assets")?; let next_coverage = build_scene_asset_coverage_json(session, payload, true)?; let next_session = rebuild_custom_world_agent_session_row( session, CustomWorldAgentSessionPatch { stage: Some(RpgAgentStage::VisualRefining), asset_coverage_json: Some(next_coverage), last_assistant_reply: Some(Some( "场景视觉资产槽位已生成并进入视觉打磨阶段。".to_string(), )), updated_at_micros: Some(input.submitted_at_micros), ..CustomWorldAgentSessionPatch::default() }, )?; replace_custom_world_agent_session(ctx, session, next_session); append_custom_world_action_result_message( ctx, &session.session_id, &input.operation_id, "场景视觉资产槽位已生成,等待外层资产链写回对象结果。", input.submitted_at_micros, ); let operation = complete_custom_world_operation( ctx, &input.operation_id, &session.session_id, RpgAgentOperationType::GenerateSceneAssets, "场景资产已生成", "asset_coverage.sceneAssets 已根据当前草稿刷新。", input.submitted_at_micros, )?; Ok(build_custom_world_agent_operation_snapshot(&operation)) } fn execute_sync_scene_assets_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, input: &CustomWorldAgentActionExecuteInput, payload: &JsonMap, ) -> Result { ensure_refining_stage(session.stage, "sync_scene_assets")?; let next_coverage = build_scene_asset_coverage_json(session, payload, false)?; let next_session = rebuild_custom_world_agent_session_row( session, CustomWorldAgentSessionPatch { stage: Some(RpgAgentStage::VisualRefining), asset_coverage_json: Some(next_coverage), last_assistant_reply: Some(Some("场景资产状态已按外部资产结果同步。".to_string())), updated_at_micros: Some(input.submitted_at_micros), ..CustomWorldAgentSessionPatch::default() }, )?; replace_custom_world_agent_session(ctx, session, next_session); append_custom_world_action_result_message( ctx, &session.session_id, &input.operation_id, "场景资产结果已同步到会话覆盖率。", input.submitted_at_micros, ); let operation = complete_custom_world_operation( ctx, &input.operation_id, &session.session_id, RpgAgentOperationType::SyncSceneAssets, "场景资产已同步", "asset_coverage.sceneAssets 已更新为同步结果。", input.submitted_at_micros, )?; Ok(build_custom_world_agent_operation_snapshot(&operation)) } fn execute_expand_long_tail_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, input: &CustomWorldAgentActionExecuteInput, payload: &JsonMap, ) -> Result { ensure_long_tail_stage(session.stage, "expand_long_tail")?; let mut draft_profile = current_custom_world_draft_profile(session); merge_long_tail_payload(&mut draft_profile, payload); let gate = summarize_publish_gate_from_json( &session.session_id, RpgAgentStage::LongTailReview, Some(&draft_profile), &parse_json_array_or_empty(&session.quality_findings_json), ); let next_session = rebuild_custom_world_agent_session_row( session, CustomWorldAgentSessionPatch { stage: Some(if gate.publish_ready { RpgAgentStage::ReadyToPublish } else { RpgAgentStage::LongTailReview }), draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object( draft_profile.clone(), ))?)), publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value( &gate, ))?)), result_preview_json: Some(build_result_preview_json( Some(&draft_profile), &gate, &parse_json_array_or_empty(&session.quality_findings_json), input.submitted_at_micros, )?), checkpoints_json: Some(append_checkpoint_json( &session.checkpoints_json, &build_session_checkpoint_value("expand-long-tail", "补齐长尾内容", session), )?), last_assistant_reply: Some(Some("长尾内容已合并,并重新计算发布门禁。".to_string())), updated_at_micros: Some(input.submitted_at_micros), ..CustomWorldAgentSessionPatch::default() }, )?; replace_custom_world_agent_session(ctx, session, next_session); append_custom_world_action_result_message( ctx, &session.session_id, &input.operation_id, "长尾内容已合并到当前世界草稿,并刷新发布门禁。", input.submitted_at_micros, ); let operation = complete_custom_world_operation( ctx, &input.operation_id, &session.session_id, RpgAgentOperationType::ExpandLongTail, "长尾内容已扩展", "世界草稿、预览和发布门禁已同步刷新。", input.submitted_at_micros, )?; Ok(build_custom_world_agent_operation_snapshot(&operation)) } fn current_custom_world_draft_profile( session: &CustomWorldAgentSession, ) -> JsonMap { ensure_minimal_draft_profile( parse_optional_session_object(session.draft_profile_json.as_deref()).unwrap_or_default(), &session.seed_text, ) } fn persist_custom_world_draft_profile_update( ctx: &ReducerContext, session: &CustomWorldAgentSession, draft_profile: JsonMap, updated_at_micros: i64, stage: RpgAgentStage, assistant_reply: String, checkpoint_suffix: &str, checkpoint_label: &str, ) -> Result<(), String> { let gate = summarize_publish_gate_from_json( &session.session_id, stage, Some(&draft_profile), &parse_json_array_or_empty(&session.quality_findings_json), ); let next_session = rebuild_custom_world_agent_session_row( session, CustomWorldAgentSessionPatch { stage: Some(stage), draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object( draft_profile.clone(), ))?)), publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value( &gate, ))?)), result_preview_json: Some(build_result_preview_json( Some(&draft_profile), &gate, &parse_json_array_or_empty(&session.quality_findings_json), updated_at_micros, )?), checkpoints_json: Some(append_checkpoint_json( &session.checkpoints_json, &build_session_checkpoint_value(checkpoint_suffix, checkpoint_label, session), )?), last_assistant_reply: Some(Some(assistant_reply)), updated_at_micros: Some(updated_at_micros), ..CustomWorldAgentSessionPatch::default() }, )?; replace_custom_world_agent_session(ctx, session, next_session); Ok(()) } fn upsert_draft_profile_array_from_payload( draft_profile: &mut JsonMap, payload: &JsonMap, payload_key: &str, profile_key: &str, id_prefix: &str, card_kind: RpgAgentDraftCardKind, ctx: &ReducerContext, session_id: &str, updated_at_micros: i64, ) -> Result { let payload_items = payload .get(payload_key) .and_then(JsonValue::as_array) .cloned() .unwrap_or_else(|| { draft_profile .get(profile_key) .and_then(JsonValue::as_array) .cloned() .unwrap_or_default() }); if payload_items.is_empty() { return Ok(0); } let mut merged = draft_profile .get(profile_key) .and_then(JsonValue::as_array) .cloned() .unwrap_or_default(); let mut inserted = 0u32; for (index, item) in payload_items.into_iter().enumerate() { let Some(mut object) = item.as_object().cloned() else { continue; }; let id = read_optional_text_field(&object, &["id"]) .unwrap_or_else(|| format!("{id_prefix}-{}-{}", session_id, index + 1)); object.insert("id".to_string(), JsonValue::String(id.clone())); let value = JsonValue::Object(object.clone()); upsert_json_array_object_by_id(&mut merged, value); upsert_custom_world_entity_card( ctx, session_id, card_kind, &id, &object, updated_at_micros, )?; inserted = inserted.saturating_add(1); } draft_profile.insert(profile_key.to_string(), JsonValue::Array(merged)); Ok(inserted) } fn upsert_json_array_object_by_id(items: &mut Vec, next: JsonValue) { let Some(next_id) = next .get("id") .and_then(JsonValue::as_str) .map(ToOwned::to_owned) else { items.push(next); return; }; if let Some(existing) = items .iter_mut() .find(|entry| entry.get("id").and_then(JsonValue::as_str) == Some(next_id.as_str())) { *existing = next; } else { items.push(next); } } fn upsert_custom_world_entity_card( ctx: &ReducerContext, session_id: &str, kind: RpgAgentDraftCardKind, entity_id: &str, object: &JsonMap, updated_at_micros: i64, ) -> Result<(), String> { let card_id = format!( "custom-world:{}:{}:{}", session_id, kind.as_str(), entity_id ); let title = read_optional_text_field(object, &["name", "title"]) .unwrap_or_else(|| entity_id.to_string()); let subtitle = read_optional_text_field(object, &["role", "subtitle", "purpose"]).unwrap_or_default(); let summary = read_optional_text_field( object, &["summary", "notes", "publicGoal", "description", "mood"], ) .unwrap_or_else(|| title.clone()); let detail_payload_json = serialize_json_value(&json!({ "id": card_id, "entityId": entity_id, "kind": kind.as_str(), "title": title, "sections": [ { "id": "title", "label": "标题", "value": title }, { "id": "subtitle", "label": "副标题", "value": subtitle }, { "id": "summary", "label": "摘要", "value": summary }, ], "linkedIds": [entity_id], "locked": false, "editable": false, "editableSectionIds": [], "warningMessages": [], }))?; let existing = ctx .db .custom_world_draft_card() .card_id() .find(&card_id) .filter(|row| row.session_id == session_id); let next = CustomWorldDraftCard { card_id: card_id.clone(), session_id: session_id.to_string(), kind, status: RpgAgentDraftCardStatus::Suggested, title, subtitle, summary, linked_ids_json: serialize_json_value(&json!([entity_id]))?, warning_count: 0, asset_status: None, asset_status_label: None, detail_payload_json: Some(detail_payload_json), created_at: existing .as_ref() .map(|row| row.created_at) .unwrap_or_else(|| Timestamp::from_micros_since_unix_epoch(updated_at_micros)), updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), }; if let Some(existing) = existing { replace_custom_world_draft_card(ctx, &existing, next); } else { ctx.db.custom_world_draft_card().insert(next); } Ok(()) } fn build_role_asset_coverage_json( session: &CustomWorldAgentSession, payload: &JsonMap, generated: bool, ) -> Result { let mut coverage = parse_optional_session_object(Some(&session.asset_coverage_json)) .unwrap_or_else(JsonMap::new); let profile = current_custom_world_draft_profile(session); let mut role_assets = payload .get("roleAssets") .and_then(JsonValue::as_array) .cloned() .unwrap_or_else(|| build_role_asset_entries_from_profile(&profile, generated)); if role_assets.is_empty() { role_assets = build_role_asset_entries_from_profile(&profile, generated); } let all_ready = !role_assets.is_empty() && role_assets .iter() .all(|entry| asset_entry_ready(entry, &["visualReady", "animationsReady"])); coverage.insert("roleAssets".to_string(), JsonValue::Array(role_assets)); coverage.insert("allRoleAssetsReady".to_string(), JsonValue::Bool(all_ready)); coverage .entry("sceneAssets".to_string()) .or_insert_with(|| JsonValue::Array(Vec::new())); coverage .entry("allSceneAssetsReady".to_string()) .or_insert_with(|| JsonValue::Bool(false)); serialize_json_value(&JsonValue::Object(coverage)) } fn build_scene_asset_coverage_json( session: &CustomWorldAgentSession, payload: &JsonMap, generated: bool, ) -> Result { let mut coverage = parse_optional_session_object(Some(&session.asset_coverage_json)) .unwrap_or_else(JsonMap::new); let profile = current_custom_world_draft_profile(session); let mut scene_assets = payload .get("sceneAssets") .and_then(JsonValue::as_array) .cloned() .unwrap_or_else(|| build_scene_asset_entries_from_profile(&profile, generated)); if scene_assets.is_empty() { scene_assets = build_scene_asset_entries_from_profile(&profile, generated); } let all_ready = !scene_assets.is_empty() && scene_assets .iter() .all(|entry| asset_entry_ready(entry, &["visualReady", "synced"])); coverage.insert("sceneAssets".to_string(), JsonValue::Array(scene_assets)); coverage.insert( "allSceneAssetsReady".to_string(), JsonValue::Bool(all_ready), ); coverage .entry("roleAssets".to_string()) .or_insert_with(|| JsonValue::Array(Vec::new())); coverage .entry("allRoleAssetsReady".to_string()) .or_insert_with(|| JsonValue::Bool(false)); serialize_json_value(&JsonValue::Object(coverage)) } fn build_role_asset_entries_from_profile( profile: &JsonMap, generated: bool, ) -> Vec { collect_profile_entities(profile, &["playableNpcs", "storyNpcs"]) .into_iter() .map(|entry| { let id = entry .get("id") .and_then(JsonValue::as_str) .unwrap_or("role"); json!({ "roleId": id, "name": read_optional_text_field(&entry, &["name", "title"]).unwrap_or_else(|| id.to_string()), "visualReady": generated, "animationsReady": !generated, }) }) .collect() } fn build_scene_asset_entries_from_profile( profile: &JsonMap, generated: bool, ) -> Vec { collect_profile_entities(profile, &["landmarks", "sceneChapters", "sceneChapterBlueprints"]) .into_iter() .map(|entry| { let id = entry .get("id") .and_then(JsonValue::as_str) .unwrap_or("scene"); json!({ "sceneId": id, "name": read_optional_text_field(&entry, &["name", "title"]).unwrap_or_else(|| id.to_string()), "visualReady": generated, "synced": !generated, }) }) .collect() } fn collect_profile_entities( profile: &JsonMap, keys: &[&str], ) -> Vec> { let mut result = Vec::new(); for key in keys { if let Some(entries) = profile.get(*key).and_then(JsonValue::as_array) { for entry in entries { if let Some(object) = entry.as_object() { result.push(object.clone()); } } } } result } fn asset_entry_ready(entry: &JsonValue, keys: &[&str]) -> bool { keys.iter().all(|key| { entry .get(*key) .and_then(JsonValue::as_bool) .unwrap_or(false) }) } fn update_role_asset_cards( ctx: &ReducerContext, session_id: &str, status: CustomWorldRoleAssetStatus, label: &str, updated_at_micros: i64, ) { for card in ctx .db .custom_world_draft_card() .by_custom_world_draft_card_session_id() .filter(&session_id.to_string()) .filter(|row| row.kind == RpgAgentDraftCardKind::Character) { replace_custom_world_draft_card( ctx, &card, CustomWorldDraftCard { card_id: card.card_id.clone(), session_id: card.session_id.clone(), kind: card.kind, status: card.status, title: card.title.clone(), subtitle: card.subtitle.clone(), summary: card.summary.clone(), linked_ids_json: card.linked_ids_json.clone(), warning_count: card.warning_count, asset_status: Some(status), asset_status_label: Some(label.to_string()), detail_payload_json: card.detail_payload_json.clone(), created_at: card.created_at, updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), }, ); } } fn merge_long_tail_payload( draft_profile: &mut JsonMap, payload: &JsonMap, ) { for key in [ "coreConflicts", "chapters", "sceneChapters", "sceneChapterBlueprints", "sidequestSeeds", "carrierHooks", ] { if let Some(entries) = payload.get(key).and_then(JsonValue::as_array) { let mut merged = draft_profile .get(key) .and_then(JsonValue::as_array) .cloned() .unwrap_or_default(); for entry in entries { if let Some(object) = entry.as_object() { upsert_json_array_object_by_id(&mut merged, JsonValue::Object(object.clone())); } else if !merged.contains(entry) { merged.push(entry.clone()); } } draft_profile.insert(key.to_string(), JsonValue::Array(merged)); } } for key in ["worldHook", "playerPremise", "summary", "subtitle"] { if let Some(value) = payload .get(key) .and_then(JsonValue::as_str) .map(str::trim) .filter(|value| !value.is_empty()) { draft_profile.insert(key.to_string(), JsonValue::String(value.to_string())); } } } #[derive(Clone, Debug, Default)] struct CustomWorldAgentSessionPatch { current_turn: Option, progress_percent: Option, stage: Option, focus_card_id: Option>, anchor_content_json: Option, creator_intent_json: Option>, creator_intent_readiness_json: Option, anchor_pack_json: Option>, lock_state_json: Option>, draft_profile_json: Option>, last_assistant_reply: Option>, publish_gate_json: Option>, result_preview_json: Option>, pending_clarifications_json: Option, quality_findings_json: Option, suggested_actions_json: Option, recommended_replies_json: Option, asset_coverage_json: Option, checkpoints_json: Option, updated_at_micros: Option, } #[derive(Clone, Debug, Default)] struct CustomWorldAgentOperationPatch { status: Option, phase_label: Option, phase_detail: Option, progress: Option, error_message: Option>, updated_at_micros: Option, } fn build_custom_world_publish_gate_from_session( session: &CustomWorldAgentSession, ) -> CustomWorldPublishGateSnapshot { let quality_findings = parse_json_array_or_empty(&session.quality_findings_json); summarize_publish_gate_from_json( &session.session_id, session.stage, parse_optional_session_object(session.draft_profile_json.as_deref()).as_ref(), &quality_findings, ) } fn summarize_publish_gate_from_json( session_id: &str, stage: RpgAgentStage, draft_profile: Option<&JsonMap>, quality_findings: &[JsonValue], ) -> CustomWorldPublishGateSnapshot { let profile_id = draft_profile .and_then(|profile| read_optional_text_field(profile, &["legacyResultProfile.id", "id"])) .unwrap_or_else(|| format!("agent-draft-{session_id}")); let mut blockers = Vec::new(); if draft_profile.is_none() { blockers.push(CustomWorldPublishBlockerSnapshot { blocker_id: "publish_empty_draft".to_string(), code: "publish_empty_draft".to_string(), message: "当前世界草稿为空,无法发布。".to_string(), }); } if let Some(profile) = draft_profile { if read_optional_text_field( profile, &[ "worldHook", "creatorIntent.worldHook", "anchorContent.worldPromise", "anchorContent.worldPromise.hook", "settingText", ], ) .is_none() { blockers.push(CustomWorldPublishBlockerSnapshot { blocker_id: "publish_missing_world_hook".to_string(), code: "publish_missing_world_hook".to_string(), message: "当前世界缺少 world hook,发布前需要先补齐世界一句话钩子。".to_string(), }); } if read_optional_text_field( profile, &[ "playerPremise", "creatorIntent.playerPremise", "anchorContent.playerEntryPoint", "anchorContent.playerEntryPoint.openingIdentity", "anchorContent.playerEntryPoint.openingProblem", "anchorContent.playerEntryPoint.entryMotivation", ], ) .is_none() { blockers.push(CustomWorldPublishBlockerSnapshot { blocker_id: "publish_missing_player_premise".to_string(), code: "publish_missing_player_premise".to_string(), message: "当前世界缺少玩家身份与切入前提,发布前需要先补齐玩家 premise。" .to_string(), }); } if !json_array_has_non_empty_text(profile.get("coreConflicts")) { blockers.push(CustomWorldPublishBlockerSnapshot { blocker_id: "publish_missing_core_conflict".to_string(), code: "publish_missing_core_conflict".to_string(), message: "当前世界缺少核心冲突,发布前需要先补齐核心冲突。".to_string(), }); } let has_main_chapter = profile .get("chapters") .and_then(JsonValue::as_array) .map(|value| !value.is_empty()) .unwrap_or(false) || profile .get("sceneChapterBlueprints") .and_then(JsonValue::as_array) .map(|value| !value.is_empty()) .unwrap_or(false) || profile .get("sceneChapters") .and_then(JsonValue::as_array) .map(|value| !value.is_empty()) .unwrap_or(false); if !has_main_chapter { blockers.push(CustomWorldPublishBlockerSnapshot { blocker_id: "publish_missing_main_chapter".to_string(), code: "publish_missing_main_chapter".to_string(), message: "当前世界还没有主线章节草稿,发布前至少要保留主线第一幕。".to_string(), }); } let has_scene_act = profile .get("sceneChapterBlueprints") .or_else(|| profile.get("sceneChapters")) .and_then(JsonValue::as_array) .map(|chapters| { chapters.iter().any(|chapter| { chapter .get("acts") .and_then(JsonValue::as_array) .map(|acts| !acts.is_empty()) .unwrap_or(false) }) }) .unwrap_or(false); if !has_scene_act { blockers.push(CustomWorldPublishBlockerSnapshot { blocker_id: "publish_missing_first_act".to_string(), code: "publish_missing_first_act".to_string(), message: "当前世界还没有主线第一幕,发布前至少要保留一个场景幕。".to_string(), }); } } for finding in quality_findings { if finding.get("severity").and_then(JsonValue::as_str) == Some("blocker") { blockers.push(CustomWorldPublishBlockerSnapshot { blocker_id: finding .get("id") .and_then(JsonValue::as_str) .unwrap_or("publish-quality-blocker") .to_string(), code: finding .get("code") .and_then(JsonValue::as_str) .unwrap_or("publish_quality_blocker") .to_string(), message: finding .get("message") .and_then(JsonValue::as_str) .unwrap_or("当前世界仍存在 blocker。") .to_string(), }); } } let blocker_count = blockers.len() as u32; let publish_ready = blocker_count == 0; CustomWorldPublishGateSnapshot { profile_id, blockers, blocker_count, publish_ready, can_enter_world: stage == RpgAgentStage::Published && publish_ready, } } fn publish_gate_to_json_value(gate: &CustomWorldPublishGateSnapshot) -> JsonValue { json!({ "profileId": gate.profile_id, "blockers": gate.blockers.iter().map(|entry| { json!({ "id": entry.blocker_id, "code": entry.code, "message": entry.message, }) }).collect::>(), "blockerCount": gate.blocker_count, "publishReady": gate.publish_ready, "canEnterWorld": gate.can_enter_world, }) } fn build_result_preview_json( draft_profile: Option<&JsonMap>, gate: &CustomWorldPublishGateSnapshot, quality_findings: &[JsonValue], generated_at_micros: i64, ) -> Result, String> { let Some(profile) = draft_profile else { return Ok(None); }; serialize_json_value(&json!({ "preview": JsonValue::Object(profile.clone()), "source": "session_preview", "generatedAt": format_timestamp_micros(generated_at_micros), "qualityFindings": quality_findings, "blockers": gate.blockers.iter().map(|entry| { json!({ "id": entry.blocker_id, "code": entry.code, "message": entry.message, }) }).collect::>(), "publishReady": gate.publish_ready, "canEnterWorld": gate.can_enter_world, })) .map(Some) } fn build_supported_actions_json( stage: RpgAgentStage, progress_percent: u32, gate: &CustomWorldPublishGateSnapshot, checkpoints: &[JsonValue], ) -> Vec { let has_checkpoint = checkpoints .iter() .any(|entry| entry.get("snapshot").is_some()); let draft_refining_enabled = matches!( stage, RpgAgentStage::ObjectRefining | RpgAgentStage::VisualRefining ); let long_tail_enabled = matches!( stage, RpgAgentStage::ObjectRefining | RpgAgentStage::VisualRefining | RpgAgentStage::LongTailReview | RpgAgentStage::ReadyToPublish ); vec![ build_supported_action_json( "draft_foundation", progress_percent >= 100, (progress_percent < 100).then(|| "draft_foundation requires progressPercent >= 100".to_string()), ), build_supported_action_json( "update_draft_card", draft_refining_enabled, (!draft_refining_enabled).then(|| { "update_draft_card is only available during object_refining or visual_refining" .to_string() }), ), build_supported_action_json( "sync_result_profile", draft_refining_enabled, (!draft_refining_enabled).then(|| { "sync_result_profile is only available during object_refining or visual_refining" .to_string() }), ), build_supported_action_json( "generate_characters", draft_refining_enabled, (!draft_refining_enabled).then(|| { "generate_characters is only available during object_refining or visual_refining" .to_string() }), ), build_supported_action_json( "generate_landmarks", draft_refining_enabled, (!draft_refining_enabled).then(|| { "generate_landmarks is only available during object_refining or visual_refining" .to_string() }), ), build_supported_action_json( "generate_role_assets", draft_refining_enabled, (!draft_refining_enabled).then(|| { "generate_role_assets is only available during object_refining or visual_refining" .to_string() }), ), build_supported_action_json( "sync_role_assets", draft_refining_enabled, (!draft_refining_enabled).then(|| { "sync_role_assets is only available during object_refining or visual_refining" .to_string() }), ), build_supported_action_json( "generate_scene_assets", draft_refining_enabled, (!draft_refining_enabled).then(|| { "generate_scene_assets is only available during object_refining or visual_refining" .to_string() }), ), build_supported_action_json( "sync_scene_assets", draft_refining_enabled, (!draft_refining_enabled).then(|| { "sync_scene_assets is only available during object_refining or visual_refining" .to_string() }), ), build_supported_action_json( "expand_long_tail", long_tail_enabled, (!long_tail_enabled).then(|| { "expand_long_tail is only available during object_refining, visual_refining, long_tail_review or ready_to_publish".to_string() }), ), build_supported_action_json( "publish_world", long_tail_enabled && gate.publish_ready, (!long_tail_enabled) .then(|| { "publish_world is only available during object_refining, visual_refining, long_tail_review or ready_to_publish".to_string() }) .or_else(|| (!gate.publish_ready).then(|| "publish_world requires publish gate without blockers".to_string())), ), build_supported_action_json( "revert_checkpoint", long_tail_enabled && has_checkpoint, (!long_tail_enabled) .then(|| { "revert_checkpoint is only available during object_refining, visual_refining, long_tail_review or ready_to_publish".to_string() }) .or_else(|| (!has_checkpoint).then(|| "revert_checkpoint requires at least one restorable checkpoint snapshot".to_string())), ), ] } fn build_supported_action_json(action: &str, enabled: bool, reason: Option) -> JsonValue { json!({ "action": action, "enabled": enabled, "reason": reason, }) } fn build_custom_world_draft_card_detail_snapshot( card: &CustomWorldDraftCard, ) -> Result { if let Some(detail_payload_json) = card.detail_payload_json.as_deref() { let detail_value = serde_json::from_str::(detail_payload_json).map_err(|error| { format!("custom_world_draft_card.detail_payload_json 非法: {error}") })?; if let Some(object) = detail_value.as_object() { let sections = object .get("sections") .and_then(JsonValue::as_array) .map(|entries| { entries .iter() .filter_map(|entry| { let object = entry.as_object()?; Some(CustomWorldDraftCardDetailSectionSnapshot { section_id: object.get("id")?.as_str()?.to_string(), label: object .get("label") .and_then(JsonValue::as_str) .unwrap_or_default() .to_string(), value: object .get("value") .and_then(JsonValue::as_str) .unwrap_or_default() .to_string(), }) }) .collect::>() }) .unwrap_or_else(|| build_fallback_card_sections(&card)); return Ok(CustomWorldDraftCardDetailSnapshot { card_id: card.card_id.clone(), kind: card.kind, title: object .get("title") .and_then(JsonValue::as_str) .unwrap_or(card.title.as_str()) .to_string(), sections, linked_ids_json: card.linked_ids_json.clone(), locked: object .get("locked") .and_then(JsonValue::as_bool) .unwrap_or(false), editable: object .get("editable") .and_then(JsonValue::as_bool) .unwrap_or(false), editable_section_ids_json: serialize_json_value( object .get("editableSectionIds") .unwrap_or(&JsonValue::Array(Vec::new())), )?, warning_messages_json: serialize_json_value( object .get("warningMessages") .unwrap_or(&JsonValue::Array(Vec::new())), )?, asset_status: card.asset_status, asset_status_label: card.asset_status_label.clone(), }); } } Ok(CustomWorldDraftCardDetailSnapshot { card_id: card.card_id.clone(), kind: card.kind, title: card.title.clone(), sections: build_fallback_card_sections(card), linked_ids_json: card.linked_ids_json.clone(), locked: false, editable: false, editable_section_ids_json: "[]".to_string(), warning_messages_json: "[]".to_string(), asset_status: card.asset_status, asset_status_label: card.asset_status_label.clone(), }) } fn build_fallback_card_sections( card: &CustomWorldDraftCard, ) -> Vec { vec![ CustomWorldDraftCardDetailSectionSnapshot { section_id: "title".to_string(), label: "标题".to_string(), value: card.title.clone(), }, CustomWorldDraftCardDetailSectionSnapshot { section_id: "subtitle".to_string(), label: "副标题".to_string(), value: card.subtitle.clone(), }, CustomWorldDraftCardDetailSectionSnapshot { section_id: "summary".to_string(), label: "摘要".to_string(), value: card.summary.clone(), }, ] } fn build_fallback_card_sections_json(card: &CustomWorldDraftCard) -> Vec { build_fallback_card_sections(card) .into_iter() .map(|section| { json!({ "id": section.section_id, "label": section.label, "value": section.value, }) }) .collect() } fn rebuild_custom_world_agent_session_row( current: &CustomWorldAgentSession, patch: CustomWorldAgentSessionPatch, ) -> Result { Ok(CustomWorldAgentSession { session_id: current.session_id.clone(), owner_user_id: current.owner_user_id.clone(), seed_text: current.seed_text.clone(), current_turn: patch.current_turn.unwrap_or(current.current_turn), progress_percent: patch.progress_percent.unwrap_or(current.progress_percent), stage: patch.stage.unwrap_or(current.stage), focus_card_id: patch .focus_card_id .unwrap_or_else(|| current.focus_card_id.clone()), anchor_content_json: patch .anchor_content_json .unwrap_or_else(|| current.anchor_content_json.clone()), creator_intent_json: patch .creator_intent_json .unwrap_or_else(|| current.creator_intent_json.clone()), creator_intent_readiness_json: patch .creator_intent_readiness_json .unwrap_or_else(|| current.creator_intent_readiness_json.clone()), anchor_pack_json: patch .anchor_pack_json .unwrap_or_else(|| current.anchor_pack_json.clone()), lock_state_json: patch .lock_state_json .unwrap_or_else(|| current.lock_state_json.clone()), draft_profile_json: patch .draft_profile_json .unwrap_or_else(|| current.draft_profile_json.clone()), last_assistant_reply: patch .last_assistant_reply .unwrap_or_else(|| current.last_assistant_reply.clone()), publish_gate_json: patch .publish_gate_json .unwrap_or_else(|| current.publish_gate_json.clone()), result_preview_json: patch .result_preview_json .unwrap_or_else(|| current.result_preview_json.clone()), pending_clarifications_json: patch .pending_clarifications_json .unwrap_or_else(|| current.pending_clarifications_json.clone()), quality_findings_json: patch .quality_findings_json .unwrap_or_else(|| current.quality_findings_json.clone()), suggested_actions_json: patch .suggested_actions_json .unwrap_or_else(|| current.suggested_actions_json.clone()), recommended_replies_json: patch .recommended_replies_json .unwrap_or_else(|| current.recommended_replies_json.clone()), asset_coverage_json: patch .asset_coverage_json .unwrap_or_else(|| current.asset_coverage_json.clone()), checkpoints_json: patch .checkpoints_json .unwrap_or_else(|| current.checkpoints_json.clone()), created_at: current.created_at, updated_at: Timestamp::from_micros_since_unix_epoch( patch .updated_at_micros .unwrap_or_else(|| current.updated_at.to_micros_since_unix_epoch()), ), }) } fn rebuild_custom_world_agent_operation_row( current: &CustomWorldAgentOperation, patch: CustomWorldAgentOperationPatch, ) -> Result { let phase_label = patch .phase_label .unwrap_or_else(|| current.phase_label.clone()); let progress = patch.progress.unwrap_or(current.progress); validate_custom_world_agent_operation_fields( ¤t.operation_id, ¤t.session_id, &phase_label, progress, ) .map_err(|error| error.to_string())?; Ok(CustomWorldAgentOperation { operation_id: current.operation_id.clone(), session_id: current.session_id.clone(), operation_type: current.operation_type, status: patch.status.unwrap_or(current.status), phase_label, phase_detail: patch .phase_detail .unwrap_or_else(|| current.phase_detail.clone()), progress, error_message: patch .error_message .unwrap_or_else(|| current.error_message.clone()), created_at: current.created_at, updated_at: Timestamp::from_micros_since_unix_epoch( patch .updated_at_micros .unwrap_or_else(|| current.updated_at.to_micros_since_unix_epoch()), ), }) } fn replace_custom_world_agent_session( ctx: &ReducerContext, current: &CustomWorldAgentSession, next: CustomWorldAgentSession, ) { ctx.db .custom_world_agent_session() .session_id() .delete(¤t.session_id); ctx.db.custom_world_agent_session().insert(next); } fn replace_custom_world_agent_operation( ctx: &ReducerContext, current: &CustomWorldAgentOperation, next: CustomWorldAgentOperation, ) { ctx.db .custom_world_agent_operation() .operation_id() .delete(¤t.operation_id); ctx.db.custom_world_agent_operation().insert(next); } fn replace_custom_world_draft_card( ctx: &ReducerContext, current: &CustomWorldDraftCard, next: CustomWorldDraftCard, ) { ctx.db .custom_world_draft_card() .card_id() .delete(¤t.card_id); ctx.db.custom_world_draft_card().insert(next); } fn complete_custom_world_operation( ctx: &ReducerContext, operation_id: &str, session_id: &str, operation_type: RpgAgentOperationType, phase_label: &str, phase_detail: &str, timestamp_micros: i64, ) -> Result { if let Some(current) = ctx .db .custom_world_agent_operation() .operation_id() .find(&operation_id.to_string()) { if current.session_id != session_id { return Err("custom_world_agent_operation.session_id 不匹配".to_string()); } if current.operation_type != operation_type { return Err("custom_world_agent_operation.operation_type 不匹配".to_string()); } let next = rebuild_custom_world_agent_operation_row( ¤t, CustomWorldAgentOperationPatch { status: Some(RpgAgentOperationStatus::Completed), phase_label: Some(phase_label.to_string()), phase_detail: Some(phase_detail.to_string()), progress: Some(100), error_message: Some(None), updated_at_micros: Some(timestamp_micros), }, )?; replace_custom_world_agent_operation(ctx, ¤t, next.clone()); return Ok(next); } Ok(build_and_insert_custom_world_operation( ctx, operation_id, session_id, operation_type, phase_label, phase_detail, timestamp_micros, )) } fn build_and_insert_custom_world_operation( ctx: &ReducerContext, operation_id: &str, session_id: &str, operation_type: RpgAgentOperationType, phase_label: &str, phase_detail: &str, timestamp_micros: i64, ) -> CustomWorldAgentOperation { let row = CustomWorldAgentOperation { operation_id: operation_id.to_string(), session_id: session_id.to_string(), operation_type, status: RpgAgentOperationStatus::Completed, phase_label: phase_label.to_string(), phase_detail: phase_detail.to_string(), progress: 100, error_message: None, created_at: Timestamp::from_micros_since_unix_epoch(timestamp_micros), updated_at: Timestamp::from_micros_since_unix_epoch(timestamp_micros), }; ctx.db.custom_world_agent_operation().insert(row) } fn append_custom_world_action_result_message( ctx: &ReducerContext, session_id: &str, operation_id: &str, text: &str, timestamp_micros: i64, ) { let row = CustomWorldAgentMessage { message_id: format!("message-action-{}-{}", operation_id, timestamp_micros), session_id: session_id.to_string(), role: RpgAgentMessageRole::Assistant, kind: RpgAgentMessageKind::ActionResult, text: text.to_string(), related_operation_id: Some(operation_id.to_string()), created_at: Timestamp::from_micros_since_unix_epoch(timestamp_micros), }; ctx.db.custom_world_agent_message().insert(row); } fn upsert_world_foundation_card( ctx: &ReducerContext, session_id: &str, draft_profile: &JsonMap, updated_at_micros: i64, ) -> Result<(), String> { let card_id = build_world_foundation_card_id(session_id); let existing_card = ctx .db .custom_world_draft_card() .card_id() .find(&card_id) .filter(|row| row.session_id == session_id); let title = read_optional_text_field(draft_profile, &["name", "title"]) .unwrap_or_else(|| "世界底稿".to_string()); let subtitle = read_optional_text_field(draft_profile, &["subtitle"]).unwrap_or_default(); let summary = read_optional_text_field(draft_profile, &["summary"]) .unwrap_or_else(|| "第一版世界底稿已生成。".to_string()); let detail_payload_json = serialize_json_value(&json!({ "id": card_id, "kind": "world", "title": title, "sections": [ { "id": "title", "label": "标题", "value": read_optional_text_field(draft_profile, &["name", "title"]).unwrap_or_else(|| "世界底稿".to_string()) }, { "id": "subtitle", "label": "副标题", "value": subtitle }, { "id": "summary", "label": "摘要", "value": summary }, ], "linkedIds": [], "locked": false, "editable": false, "editableSectionIds": [], "warningMessages": [], }))?; if let Some(existing) = existing_card { replace_custom_world_draft_card( ctx, &existing, CustomWorldDraftCard { card_id: existing.card_id.clone(), session_id: existing.session_id.clone(), kind: RpgAgentDraftCardKind::World, status: RpgAgentDraftCardStatus::Confirmed, title: read_optional_text_field(draft_profile, &["name", "title"]) .unwrap_or_else(|| "世界底稿".to_string()), subtitle: read_optional_text_field(draft_profile, &["subtitle"]) .unwrap_or_default(), summary: read_optional_text_field(draft_profile, &["summary"]) .unwrap_or_else(|| "第一版世界底稿已生成。".to_string()), linked_ids_json: "[]".to_string(), warning_count: 0, asset_status: None, asset_status_label: None, detail_payload_json: Some(detail_payload_json), created_at: existing.created_at, updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), }, ); } else { ctx.db .custom_world_draft_card() .insert(CustomWorldDraftCard { card_id, session_id: session_id.to_string(), kind: RpgAgentDraftCardKind::World, status: RpgAgentDraftCardStatus::Confirmed, title: read_optional_text_field(draft_profile, &["name", "title"]) .unwrap_or_else(|| "世界底稿".to_string()), subtitle: read_optional_text_field(draft_profile, &["subtitle"]) .unwrap_or_default(), summary: read_optional_text_field(draft_profile, &["summary"]) .unwrap_or_else(|| "第一版世界底稿已生成。".to_string()), linked_ids_json: "[]".to_string(), warning_count: 0, asset_status: None, asset_status_label: None, detail_payload_json: Some(detail_payload_json), created_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), }); } Ok(()) } fn build_world_foundation_card_id(session_id: &str) -> String { // `custom_world_draft_card.card_id` 是全局主键,世界底稿卡必须带上会话维度,避免多会话写入时触发唯一键冲突。 format!("custom-world:{session_id}:world-foundation") } fn sync_session_draft_profile_from_card_update( session: &CustomWorldAgentSession, card: &CustomWorldDraftCard, updated_title: &str, updated_subtitle: &str, updated_summary: &str, updated_at_micros: i64, ) -> Result { let mut draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref()) .unwrap_or_else(|| build_minimal_draft_profile_from_seed(&session.seed_text)); if card.kind == RpgAgentDraftCardKind::World { draft_profile.insert( "name".to_string(), JsonValue::String(updated_title.to_string()), ); draft_profile.insert( "subtitle".to_string(), JsonValue::String(updated_subtitle.to_string()), ); draft_profile.insert( "summary".to_string(), JsonValue::String(updated_summary.to_string()), ); } let gate = summarize_publish_gate_from_json( &session.session_id, session.stage, Some(&draft_profile), &parse_json_array_or_empty(&session.quality_findings_json), ); rebuild_custom_world_agent_session_row( session, CustomWorldAgentSessionPatch { draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object( draft_profile.clone(), ))?)), publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value( &gate, ))?)), result_preview_json: Some(build_result_preview_json( Some(&draft_profile), &gate, &parse_json_array_or_empty(&session.quality_findings_json), updated_at_micros, )?), last_assistant_reply: Some(Some(format!("卡片《{}》已更新。", updated_title))), updated_at_micros: Some(updated_at_micros), ..CustomWorldAgentSessionPatch::default() }, ) } fn ensure_refining_stage(stage: RpgAgentStage, action: &str) -> Result<(), String> { if matches!( stage, RpgAgentStage::ObjectRefining | RpgAgentStage::VisualRefining ) { Ok(()) } else { Err(format!( "{action} is only available during object_refining or visual_refining" )) } } fn ensure_result_profile_sync_stage(stage: RpgAgentStage, action: &str) -> Result<(), String> { if matches!( stage, RpgAgentStage::ObjectRefining | RpgAgentStage::VisualRefining | RpgAgentStage::LongTailReview | RpgAgentStage::ReadyToPublish ) { Ok(()) } else { Err(format!( "{action} is only available during object_refining, visual_refining, long_tail_review or ready_to_publish" )) } } fn ensure_long_tail_stage(stage: RpgAgentStage, action: &str) -> Result<(), String> { if matches!( stage, RpgAgentStage::ObjectRefining | RpgAgentStage::VisualRefining | RpgAgentStage::LongTailReview | RpgAgentStage::ReadyToPublish ) { Ok(()) } else { Err(format!( "{action} is only available during object_refining, visual_refining, long_tail_review or ready_to_publish" )) } } fn ensure_publishable_stage(stage: RpgAgentStage, action: &str) -> Result<(), String> { ensure_long_tail_stage(stage, action) } fn parse_rpg_agent_stage(value: &str) -> Option { match value.trim() { "collecting_intent" => Some(RpgAgentStage::CollectingIntent), "clarifying" => Some(RpgAgentStage::Clarifying), "foundation_review" => Some(RpgAgentStage::FoundationReview), "object_refining" => Some(RpgAgentStage::ObjectRefining), "visual_refining" => Some(RpgAgentStage::VisualRefining), "long_tail_review" => Some(RpgAgentStage::LongTailReview), "ready_to_publish" => Some(RpgAgentStage::ReadyToPublish), "published" => Some(RpgAgentStage::Published), "error" => Some(RpgAgentStage::Error), _ => None, } } fn resolve_rpg_agent_stage_label(stage: RpgAgentStage) -> &'static str { match stage { RpgAgentStage::CollectingIntent => "收集世界锚点", RpgAgentStage::Clarifying => "补齐关键锚点", RpgAgentStage::FoundationReview => "准备整理底稿", RpgAgentStage::ObjectRefining => "待完善草稿", RpgAgentStage::VisualRefining => "视觉工坊", RpgAgentStage::LongTailReview => "扩展长尾", RpgAgentStage::ReadyToPublish => "准备发布", RpgAgentStage::Published => "已发布", RpgAgentStage::Error => "发生错误", } } fn parse_optional_session_object(value: Option<&str>) -> Option> { value .map(str::trim) .filter(|value| !value.is_empty()) .and_then(|value| serde_json::from_str::(value).ok()) .and_then(|value| value.as_object().cloned()) } fn parse_json_array_or_empty(raw: &str) -> Vec { serde_json::from_str::(raw) .ok() .and_then(|value| value.as_array().cloned()) .unwrap_or_default() } fn serialize_json_value(value: &JsonValue) -> Result { serde_json::to_string(value).map_err(|error| format!("JSON 序列化失败: {error}")) } fn read_required_payload_text( payload: &JsonMap, key: &str, error_message: &str, ) -> Result { payload .get(key) .and_then(JsonValue::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .ok_or_else(|| error_message.to_string()) } fn read_optional_text_field(object: &JsonMap, keys: &[&str]) -> Option { for key in keys { let mut current = JsonValue::Object(object.clone()); let mut found = true; for segment in key.split('.') { if let Some(next) = current.get(segment) { current = next.clone(); } else { found = false; break; } } if found { if let Some(value) = current .as_str() .map(str::trim) .filter(|value| !value.is_empty()) { return Some(value.to_string()); } } } None } fn resolve_session_work_title( session: &CustomWorldAgentSession, draft_profile: Option<&JsonMap>, ) -> String { draft_profile .and_then(|profile| read_optional_text_field(profile, &["name", "title"])) .or_else(|| { let seed = session.seed_text.trim(); (!seed.is_empty()).then(|| seed.to_string()) }) .unwrap_or_else(|| "未命名草稿".to_string()) } fn resolve_session_work_summary( session: &CustomWorldAgentSession, draft_profile: Option<&JsonMap>, ) -> String { draft_profile .and_then(|profile| read_optional_text_field(profile, &["summary"])) .or_else(|| { let seed = session.seed_text.trim(); (!seed.is_empty()).then(|| seed.to_string()) }) .unwrap_or_else(|| "还在收集你的世界锚点。".to_string()) } fn resolve_session_work_subtitle( draft_profile: Option<&JsonMap>, stage_label: Option<&str>, ) -> String { draft_profile .and_then(|profile| read_optional_text_field(profile, &["subtitle"])) .or_else(|| stage_label.map(ToOwned::to_owned)) .unwrap_or_default() } fn resolve_session_work_cover_image_src( draft_profile: Option<&JsonMap>, ) -> Option { let profile = draft_profile?; if let Some(camp) = profile.get("camp").and_then(JsonValue::as_object) { if let Some(image_src) = read_optional_text_field(camp, &["imageSrc"]) { return Some(image_src); } } if let Some(landmarks) = profile.get("landmarks").and_then(JsonValue::as_array) { for landmark in landmarks { if let Some(object) = landmark.as_object() { if let Some(image_src) = read_optional_text_field(object, &["imageSrc"]) { return Some(image_src); } } } } None } fn resolve_session_work_counts( ctx: &ReducerContext, session: &CustomWorldAgentSession, draft_profile: Option<&JsonMap>, ) -> (u32, u32) { if let Some(profile) = draft_profile { let role_count = profile .get("playableNpcs") .and_then(JsonValue::as_array) .map(|entries| entries.len() as u32) .unwrap_or(0) + profile .get("storyNpcs") .and_then(JsonValue::as_array) .map(|entries| entries.len() as u32) .unwrap_or(0); let landmark_count = profile .get("landmarks") .and_then(JsonValue::as_array) .map(|entries| entries.len() as u32) .unwrap_or(0); return (role_count, landmark_count); } let mut role_count = 0u32; let mut landmark_count = 0u32; for card in ctx .db .custom_world_draft_card() .by_custom_world_draft_card_session_id() .filter(&session.session_id) { match card.kind { RpgAgentDraftCardKind::Character => { role_count = role_count.saturating_add(1); } RpgAgentDraftCardKind::Landmark => { landmark_count = landmark_count.saturating_add(1); } _ => {} } } (role_count, landmark_count) } fn ensure_minimal_draft_profile( mut profile: JsonMap, seed_text: &str, ) -> JsonMap { if read_optional_text_field(&profile, &["name", "title"]).is_none() { profile.insert( "name".to_string(), JsonValue::String(seed_text.trim().to_string().if_empty("未命名草稿")), ); } if read_optional_text_field(&profile, &["summary"]).is_none() { profile.insert( "summary".to_string(), JsonValue::String( (!seed_text.trim().is_empty()) .then(|| seed_text.trim().to_string()) .unwrap_or_else(|| "还在收集你的世界锚点。".to_string()), ), ); } profile .entry("subtitle".to_string()) .or_insert_with(|| JsonValue::String(String::new())); profile .entry("worldHook".to_string()) .or_insert_with(|| JsonValue::String(String::new())); profile .entry("playerPremise".to_string()) .or_insert_with(|| JsonValue::String(String::new())); profile .entry("coreConflicts".to_string()) .or_insert_with(|| JsonValue::Array(Vec::new())); profile .entry("playableNpcs".to_string()) .or_insert_with(|| JsonValue::Array(Vec::new())); profile .entry("storyNpcs".to_string()) .or_insert_with(|| JsonValue::Array(Vec::new())); profile .entry("landmarks".to_string()) .or_insert_with(|| JsonValue::Array(Vec::new())); profile .entry("chapters".to_string()) .or_insert_with(|| JsonValue::Array(Vec::new())); profile .entry("sceneChapters".to_string()) .or_insert_with(|| JsonValue::Array(Vec::new())); profile .entry("sceneChapterBlueprints".to_string()) .or_insert_with(|| JsonValue::Array(Vec::new())); profile } fn build_minimal_draft_profile_from_seed(seed_text: &str) -> JsonMap { ensure_minimal_draft_profile(JsonMap::new(), seed_text) } fn build_session_checkpoint_value( checkpoint_id_suffix: &str, label: &str, session: &CustomWorldAgentSession, ) -> JsonValue { json!({ "checkpointId": format!("checkpoint-{}-{}", session.session_id, checkpoint_id_suffix), "createdAt": format_timestamp_micros(session.updated_at.to_micros_since_unix_epoch()), "label": label, "snapshot": { "stage": session.stage.as_str(), "progressPercent": session.progress_percent, "draftProfile": parse_optional_session_object(session.draft_profile_json.as_deref()).map(JsonValue::Object), "qualityFindings": parse_json_array_or_empty(&session.quality_findings_json), } }) } fn append_checkpoint_json(current: &str, checkpoint: &JsonValue) -> Result { let mut checkpoints = parse_json_array_or_empty(current); checkpoints.push(checkpoint.clone()); serialize_json_value(&JsonValue::Array(checkpoints)) } fn extract_detail_section_value(sections: &[JsonValue], target_id: &str) -> Option { sections.iter().find_map(|entry| { let object = entry.as_object()?; (object.get("id").and_then(JsonValue::as_str) == Some(target_id)).then(|| { object .get("value") .and_then(JsonValue::as_str) .unwrap_or_default() .to_string() }) }) } fn json_array_has_non_empty_text(value: Option<&JsonValue>) -> bool { value .and_then(JsonValue::as_array) .map(|entries| { entries.iter().any(|entry| { entry .as_str() .map(str::trim) .filter(|text| !text.is_empty()) .is_some() }) }) .unwrap_or(false) } trait IfEmptyString { fn if_empty(self, fallback: &str) -> String; } impl IfEmptyString for String { fn if_empty(self, fallback: &str) -> String { if self.trim().is_empty() { fallback.to_string() } else { self } } } fn mark_custom_world_agent_session_published( ctx: &ReducerContext, session_id: &str, owner_user_id: &str, updated_at_micros: i64, ) -> Result { let existing = ctx .db .custom_world_agent_session() .session_id() .find(&session_id.to_string()) .filter(|row| row.owner_user_id == owner_user_id) .ok_or_else(|| "custom_world_agent_session 不存在,无法推进到 published".to_string())?; ctx.db .custom_world_agent_session() .session_id() .delete(&existing.session_id); let next_row = CustomWorldAgentSession { session_id: existing.session_id.clone(), owner_user_id: existing.owner_user_id.clone(), seed_text: existing.seed_text.clone(), current_turn: existing.current_turn, progress_percent: existing.progress_percent, stage: RpgAgentStage::Published, focus_card_id: existing.focus_card_id.clone(), anchor_content_json: existing.anchor_content_json.clone(), creator_intent_json: existing.creator_intent_json.clone(), creator_intent_readiness_json: existing.creator_intent_readiness_json.clone(), anchor_pack_json: existing.anchor_pack_json.clone(), lock_state_json: existing.lock_state_json.clone(), draft_profile_json: existing.draft_profile_json.clone(), last_assistant_reply: existing.last_assistant_reply.clone(), publish_gate_json: existing.publish_gate_json.clone(), result_preview_json: existing.result_preview_json.clone(), pending_clarifications_json: existing.pending_clarifications_json.clone(), quality_findings_json: existing.quality_findings_json.clone(), suggested_actions_json: existing.suggested_actions_json.clone(), recommended_replies_json: existing.recommended_replies_json.clone(), asset_coverage_json: existing.asset_coverage_json.clone(), checkpoints_json: existing.checkpoints_json.clone(), created_at: existing.created_at, updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), }; ctx.db.custom_world_agent_session().insert(next_row); Ok(RpgAgentStage::Published) } fn sync_custom_world_gallery_entry_from_profile( ctx: &ReducerContext, profile: &CustomWorldProfile, ) -> Result { let published_at = profile .published_at .ok_or_else(|| "published profile 缺少 published_at,无法同步 gallery".to_string())?; ctx.db .custom_world_gallery_entry() .profile_id() .delete(&profile.profile_id); let row = CustomWorldGalleryEntry { profile_id: profile.profile_id.clone(), owner_user_id: profile.owner_user_id.clone(), public_work_code: profile.public_work_code.clone().ok_or_else(|| { "published profile 缺少 public_work_code,无法同步 gallery".to_string() })?, author_public_user_code: profile.author_public_user_code.clone().ok_or_else(|| { "published profile 缺少 author_public_user_code,无法同步 gallery".to_string() })?, author_display_name: profile.author_display_name.clone(), world_name: profile.world_name.clone(), subtitle: profile.subtitle.clone(), summary_text: profile.summary_text.clone(), cover_image_src: profile.cover_image_src.clone(), theme_mode: profile.theme_mode, playable_npc_count: profile.playable_npc_count, landmark_count: profile.landmark_count, play_count: profile.play_count, remix_count: profile.remix_count, like_count: profile.like_count, published_at, updated_at: profile.updated_at, }; let inserted = ctx.db.custom_world_gallery_entry().insert(row); Ok(build_custom_world_gallery_entry_snapshot(ctx, &inserted)) } fn sync_missing_custom_world_gallery_entries(ctx: &ReducerContext) -> Result<(), String> { let published_profiles = ctx .db .custom_world_profile() .by_custom_world_profile_publication_status() .filter(CustomWorldPublicationStatus::Published) .filter(|profile| profile.deleted_at.is_none()) .collect::>(); for profile in published_profiles { if profile.published_at.is_none() { continue; } let existing_gallery_entry = ctx .db .custom_world_gallery_entry() .profile_id() .find(&profile.profile_id) .filter(|entry| entry.owner_user_id == profile.owner_user_id); if existing_gallery_entry.is_some() && profile.public_work_code.is_some() && profile.author_public_user_code.is_some() { continue; } let profile_with_public_fields = ensure_custom_world_profile_public_fields(ctx, &profile); sync_custom_world_gallery_entry_from_profile(ctx, &profile_with_public_fields)?; } Ok(()) } fn ensure_custom_world_profile_public_fields( ctx: &ReducerContext, profile: &CustomWorldProfile, ) -> CustomWorldProfile { if profile.public_work_code.is_some() && profile.author_public_user_code.is_some() { return build_custom_world_profile_row_copy(profile); } ctx.db .custom_world_profile() .profile_id() .delete(&profile.profile_id); let next_row = CustomWorldProfile { profile_id: profile.profile_id.clone(), owner_user_id: profile.owner_user_id.clone(), public_work_code: profile .public_work_code .clone() .or_else(|| Some(build_public_work_code_from_profile_id(&profile.profile_id))), author_public_user_code: profile.author_public_user_code.clone().or_else(|| { Some(build_public_user_code_from_owner_user_id( &profile.owner_user_id, )) }), source_agent_session_id: profile.source_agent_session_id.clone(), publication_status: profile.publication_status, world_name: profile.world_name.clone(), subtitle: profile.subtitle.clone(), summary_text: profile.summary_text.clone(), theme_mode: profile.theme_mode, cover_image_src: profile.cover_image_src.clone(), profile_payload_json: profile.profile_payload_json.clone(), playable_npc_count: profile.playable_npc_count, landmark_count: profile.landmark_count, play_count: profile.play_count, remix_count: profile.remix_count, like_count: profile.like_count, author_display_name: profile.author_display_name.clone(), published_at: profile.published_at, deleted_at: profile.deleted_at, created_at: profile.created_at, updated_at: profile.updated_at, }; ctx.db.custom_world_profile().insert(next_row) } fn build_custom_world_profile_row_copy(profile: &CustomWorldProfile) -> CustomWorldProfile { CustomWorldProfile { profile_id: profile.profile_id.clone(), owner_user_id: profile.owner_user_id.clone(), public_work_code: profile.public_work_code.clone(), author_public_user_code: profile.author_public_user_code.clone(), source_agent_session_id: profile.source_agent_session_id.clone(), publication_status: profile.publication_status, world_name: profile.world_name.clone(), subtitle: profile.subtitle.clone(), summary_text: profile.summary_text.clone(), theme_mode: profile.theme_mode, cover_image_src: profile.cover_image_src.clone(), profile_payload_json: profile.profile_payload_json.clone(), playable_npc_count: profile.playable_npc_count, landmark_count: profile.landmark_count, play_count: profile.play_count, remix_count: profile.remix_count, like_count: profile.like_count, author_display_name: profile.author_display_name.clone(), published_at: profile.published_at, deleted_at: profile.deleted_at, created_at: profile.created_at, updated_at: profile.updated_at, } } fn build_custom_world_profile_snapshot(row: &CustomWorldProfile) -> CustomWorldProfileSnapshot { CustomWorldProfileSnapshot { profile_id: row.profile_id.clone(), owner_user_id: row.owner_user_id.clone(), public_work_code: row.public_work_code.clone(), author_public_user_code: row.author_public_user_code.clone(), source_agent_session_id: row.source_agent_session_id.clone(), publication_status: row.publication_status, world_name: row.world_name.clone(), subtitle: row.subtitle.clone(), summary_text: row.summary_text.clone(), theme_mode: row.theme_mode, cover_image_src: row.cover_image_src.clone(), profile_payload_json: row.profile_payload_json.clone(), playable_npc_count: row.playable_npc_count, landmark_count: row.landmark_count, play_count: row.play_count, remix_count: row.remix_count, like_count: row.like_count, author_display_name: row.author_display_name.clone(), published_at_micros: row .published_at .map(|value| value.to_micros_since_unix_epoch()), deleted_at_micros: row .deleted_at .map(|value| value.to_micros_since_unix_epoch()), created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } fn build_custom_world_agent_session_snapshot( ctx: &ReducerContext, row: &CustomWorldAgentSession, ) -> CustomWorldAgentSessionSnapshot { let mut messages = ctx .db .custom_world_agent_message() .by_custom_world_agent_message_session_id() .filter(&row.session_id) .map(|message| build_custom_world_agent_message_snapshot(&message)) .collect::>(); messages.sort_by_key(|message| (message.created_at_micros, message.message_id.clone())); let mut draft_cards = ctx .db .custom_world_draft_card() .by_custom_world_draft_card_session_id() .filter(&row.session_id) .map(|card| build_custom_world_draft_card_snapshot(&card)) .collect::>(); draft_cards.sort_by_key(|card| (card.created_at_micros, card.card_id.clone())); let mut operations = ctx .db .custom_world_agent_operation() .by_custom_world_agent_operation_session_id() .filter(&row.session_id) .map(|operation| build_custom_world_agent_operation_snapshot(&operation)) .collect::>(); operations .sort_by_key(|operation| (operation.created_at_micros, operation.operation_id.clone())); CustomWorldAgentSessionSnapshot { session_id: row.session_id.clone(), owner_user_id: row.owner_user_id.clone(), seed_text: row.seed_text.clone(), current_turn: row.current_turn, progress_percent: row.progress_percent, stage: row.stage, focus_card_id: row.focus_card_id.clone(), anchor_content_json: row.anchor_content_json.clone(), creator_intent_json: row.creator_intent_json.clone(), creator_intent_readiness_json: row.creator_intent_readiness_json.clone(), anchor_pack_json: row.anchor_pack_json.clone(), lock_state_json: row.lock_state_json.clone(), draft_profile_json: row.draft_profile_json.clone(), last_assistant_reply: row.last_assistant_reply.clone(), publish_gate_json: row.publish_gate_json.clone(), result_preview_json: row.result_preview_json.clone(), pending_clarifications_json: row.pending_clarifications_json.clone(), quality_findings_json: row.quality_findings_json.clone(), suggested_actions_json: row.suggested_actions_json.clone(), recommended_replies_json: row.recommended_replies_json.clone(), asset_coverage_json: row.asset_coverage_json.clone(), checkpoints_json: row.checkpoints_json.clone(), supported_actions_json: serialize_json_value(&JsonValue::Array( build_supported_actions_json( row.stage, row.progress_percent, &build_custom_world_publish_gate_from_session(row), &parse_json_array_or_empty(&row.checkpoints_json), ), )) .unwrap_or_else(|_| "[]".to_string()), messages, draft_cards, operations, created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } fn build_custom_world_agent_message_snapshot( row: &CustomWorldAgentMessage, ) -> CustomWorldAgentMessageSnapshot { CustomWorldAgentMessageSnapshot { message_id: row.message_id.clone(), session_id: row.session_id.clone(), role: row.role, kind: row.kind, text: row.text.clone(), related_operation_id: row.related_operation_id.clone(), created_at_micros: row.created_at.to_micros_since_unix_epoch(), } } fn build_custom_world_agent_operation_snapshot( row: &CustomWorldAgentOperation, ) -> CustomWorldAgentOperationSnapshot { CustomWorldAgentOperationSnapshot { operation_id: row.operation_id.clone(), session_id: row.session_id.clone(), operation_type: row.operation_type, status: row.status, phase_label: row.phase_label.clone(), phase_detail: row.phase_detail.clone(), progress: row.progress, error_message: row.error_message.clone(), created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } fn build_custom_world_draft_card_snapshot( row: &CustomWorldDraftCard, ) -> CustomWorldDraftCardSnapshot { CustomWorldDraftCardSnapshot { card_id: row.card_id.clone(), session_id: row.session_id.clone(), kind: row.kind, status: row.status, title: row.title.clone(), subtitle: row.subtitle.clone(), summary: row.summary.clone(), linked_ids_json: row.linked_ids_json.clone(), warning_count: row.warning_count, asset_status: row.asset_status, asset_status_label: row.asset_status_label.clone(), detail_payload_json: row.detail_payload_json.clone(), created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } fn build_custom_world_gallery_entry_snapshot( ctx: &ReducerContext, row: &CustomWorldGalleryEntry, ) -> CustomWorldGalleryEntrySnapshot { let recent_play_counts = count_recent_public_work_plays_for_profiles( ctx, "custom-world", &[row.profile_id.clone()], ctx.timestamp.to_micros_since_unix_epoch(), ); build_custom_world_gallery_entry_snapshot_with_recent_counts(row, &recent_play_counts) } fn build_custom_world_gallery_entry_snapshot_with_recent_counts( row: &CustomWorldGalleryEntry, recent_play_counts: &HashMap, ) -> CustomWorldGalleryEntrySnapshot { CustomWorldGalleryEntrySnapshot { profile_id: row.profile_id.clone(), owner_user_id: row.owner_user_id.clone(), public_work_code: row.public_work_code.clone(), author_public_user_code: row.author_public_user_code.clone(), author_display_name: row.author_display_name.clone(), world_name: row.world_name.clone(), subtitle: row.subtitle.clone(), summary_text: row.summary_text.clone(), cover_image_src: row.cover_image_src.clone(), theme_mode: row.theme_mode, playable_npc_count: row.playable_npc_count, landmark_count: row.landmark_count, play_count: row.play_count, remix_count: row.remix_count, like_count: row.like_count, recent_play_count_7d: recent_play_counts .get(&row.profile_id) .copied() .unwrap_or(0), published_at_micros: row.published_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } // 作品公开号保持稳定公开语义,本期先由 profile_id 派生 deterministic fallback, // 后续若引入独立 sequence 表,可无痛替换生成来源而不影响读写接口。 fn build_public_work_code_from_profile_id(profile_id: &str) -> String { let digits = profile_id .chars() .filter(|character| character.is_ascii_digit()) .collect::(); let normalized_digits = if digits.is_empty() { let checksum = profile_id.bytes().fold(0u32, |accumulator, value| { accumulator.wrapping_mul(131) + u32::from(value) }); format!("{:08}", checksum % 100_000_000) } else { format!("{:0>8}", &digits[digits.len().saturating_sub(8)..]) }; format!("CW-{normalized_digits}") } fn build_public_user_code_from_owner_user_id(owner_user_id: &str) -> String { owner_user_id .trim_start_matches("user_") .parse::() .ok() .map(|sequence| format!("SY-{sequence:08}")) .unwrap_or_else(|| "SY-00000000".to_string()) } fn normalize_public_work_code(input: &str) -> Option { let normalized = input .trim() .chars() .filter(|character| character.is_ascii_alphanumeric()) .collect::() .to_ascii_uppercase(); let digits = normalized.strip_prefix("CW").unwrap_or(&normalized); if digits.is_empty() || digits.len() > 8 || !digits.chars().all(|character| character.is_ascii_digit()) { return None; } Some(format!("CW-{digits:0>8}")) } #[cfg(test)] mod tests { use super::*; fn build_test_custom_world_agent_session( seed_text: &str, stage: RpgAgentStage, draft_profile_json: Option<&str>, ) -> CustomWorldAgentSession { CustomWorldAgentSession { session_id: "session-1".to_string(), owner_user_id: "user-1".to_string(), seed_text: seed_text.to_string(), current_turn: 0, progress_percent: 0, stage, focus_card_id: None, anchor_content_json: "{}".to_string(), creator_intent_json: None, creator_intent_readiness_json: "{}".to_string(), anchor_pack_json: None, lock_state_json: None, draft_profile_json: draft_profile_json.map(str::to_string), last_assistant_reply: None, publish_gate_json: None, result_preview_json: None, pending_clarifications_json: "[]".to_string(), quality_findings_json: "[]".to_string(), suggested_actions_json: "[]".to_string(), recommended_replies_json: "[]".to_string(), asset_coverage_json: "{}".to_string(), checkpoints_json: "[]".to_string(), created_at: Timestamp::from_micros_since_unix_epoch(1), updated_at: Timestamp::from_micros_since_unix_epoch(1), } } #[test] fn resolve_stable_agent_draft_profile_id_prefers_legacy_result_profile_id() { let session = build_test_custom_world_agent_session( "seed", RpgAgentStage::ObjectRefining, Some(r#"{"id":"drifted-profile","legacyResultProfile":{"id":"stable-profile"}}"#), ); assert_eq!( resolve_stable_agent_draft_profile_id(&session), Some("stable-profile".to_string()) ); } #[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 = build_test_custom_world_agent_session("", RpgAgentStage::CollectingIntent, Some("{}")); let seeded_session = build_test_custom_world_agent_session( "想做一个海雾群岛", RpgAgentStage::CollectingIntent, Some("{}"), ); let drafted_session = build_test_custom_world_agent_session("", RpgAgentStage::ObjectRefining, Some("{}")); let profile_session = build_test_custom_world_agent_session( "", RpgAgentStage::CollectingIntent, Some(r#"{"worldHook":"海雾会吞掉记错航线的人。"}"#), ); assert!(!custom_world_agent_session_has_direct_work_content( &empty_session, )); assert!(custom_world_agent_session_has_direct_work_content( &seeded_session, )); assert!(custom_world_agent_session_has_direct_work_content( &drafted_session, )); assert!(custom_world_agent_session_has_direct_work_content( &profile_session, )); } #[test] fn same_agent_draft_profile_candidate_requires_same_owner_active_draft_and_session() { let matching = CustomWorldProfile { profile_id: "profile-1".to_string(), owner_user_id: "user-1".to_string(), public_work_code: None, author_public_user_code: None, source_agent_session_id: Some("session-1".to_string()), publication_status: CustomWorldPublicationStatus::Draft, world_name: "潮雾列岛".to_string(), subtitle: String::new(), summary_text: String::new(), theme_mode: CustomWorldThemeMode::Mythic, cover_image_src: None, profile_payload_json: "{}".to_string(), playable_npc_count: 0, landmark_count: 0, play_count: 0, remix_count: 0, like_count: 0, author_display_name: "玩家".to_string(), published_at: None, deleted_at: None, created_at: Timestamp::from_micros_since_unix_epoch(1), updated_at: Timestamp::from_micros_since_unix_epoch(1), }; let deleted = CustomWorldProfile { profile_id: "profile-1".to_string(), owner_user_id: "user-1".to_string(), public_work_code: None, author_public_user_code: None, source_agent_session_id: Some("session-1".to_string()), publication_status: CustomWorldPublicationStatus::Draft, world_name: "潮雾列岛".to_string(), subtitle: String::new(), summary_text: String::new(), theme_mode: CustomWorldThemeMode::Mythic, cover_image_src: None, profile_payload_json: "{}".to_string(), playable_npc_count: 0, landmark_count: 0, play_count: 0, remix_count: 0, like_count: 0, author_display_name: "玩家".to_string(), published_at: None, deleted_at: Some(Timestamp::from_micros_since_unix_epoch(2)), created_at: Timestamp::from_micros_since_unix_epoch(1), updated_at: Timestamp::from_micros_since_unix_epoch(1), }; let published = CustomWorldProfile { profile_id: "profile-1".to_string(), owner_user_id: "user-1".to_string(), public_work_code: Some("CW-00000001".to_string()), author_public_user_code: Some("SY-00000001".to_string()), source_agent_session_id: Some("session-1".to_string()), publication_status: CustomWorldPublicationStatus::Published, world_name: "潮雾列岛".to_string(), subtitle: String::new(), summary_text: String::new(), theme_mode: CustomWorldThemeMode::Mythic, cover_image_src: None, profile_payload_json: "{}".to_string(), playable_npc_count: 0, landmark_count: 0, play_count: 0, remix_count: 0, like_count: 0, author_display_name: "玩家".to_string(), published_at: None, deleted_at: None, created_at: Timestamp::from_micros_since_unix_epoch(1), updated_at: Timestamp::from_micros_since_unix_epoch(1), }; assert!(is_same_agent_draft_profile_candidate( &matching, "user-1", "session-1", )); assert!(!is_same_agent_draft_profile_candidate( &matching, "user-2", "session-1", )); assert!(!is_same_agent_draft_profile_candidate( &matching, "user-1", "session-2", )); assert!(!is_same_agent_draft_profile_candidate( &deleted, "user-1", "session-1", )); assert!(!is_same_agent_draft_profile_candidate( &published, "user-1", "session-1", )); } #[test] fn custom_world_works_hides_compiled_draft_profile_when_agent_session_is_active() { fn build_test_custom_world_profile( profile_id: &str, source_agent_session_id: Option<&str>, publication_status: CustomWorldPublicationStatus, ) -> CustomWorldProfile { CustomWorldProfile { profile_id: profile_id.to_string(), owner_user_id: "user-1".to_string(), public_work_code: if publication_status == CustomWorldPublicationStatus::Published { Some("CW-00000001".to_string()) } else { None }, author_public_user_code: None, source_agent_session_id: source_agent_session_id.map(str::to_string), publication_status, world_name: "潮雾列岛".to_string(), subtitle: String::new(), summary_text: String::new(), theme_mode: CustomWorldThemeMode::Mythic, cover_image_src: None, profile_payload_json: "{}".to_string(), playable_npc_count: 0, landmark_count: 0, play_count: 0, remix_count: 0, like_count: 0, author_display_name: "玩家".to_string(), published_at: if publication_status == CustomWorldPublicationStatus::Published { Some(Timestamp::from_micros_since_unix_epoch(2)) } else { None }, deleted_at: None, created_at: Timestamp::from_micros_since_unix_epoch(1), updated_at: Timestamp::from_micros_since_unix_epoch(1), } } let draft_profile = build_test_custom_world_profile( "profile-1", Some("session-1"), CustomWorldPublicationStatus::Draft, ); let orphan_draft_profile = build_test_custom_world_profile( "profile-2", Some("session-2"), CustomWorldPublicationStatus::Draft, ); let published_profile = build_test_custom_world_profile( "profile-3", Some("session-1"), CustomWorldPublicationStatus::Published, ); let mut active_agent_session_ids = HashSet::new(); active_agent_session_ids.insert("session-1".to_string()); assert!(!should_include_custom_world_profile_work( &draft_profile, &active_agent_session_ids, )); assert!(should_include_custom_world_profile_work( &orphan_draft_profile, &active_agent_session_ids, )); assert!(should_include_custom_world_profile_work( &published_profile, &active_agent_session_ids, )); } #[test] fn custom_world_works_keeps_compiled_draft_profile_without_active_agent_session() { let draft_profile = CustomWorldProfile { profile_id: "profile-1".to_string(), owner_user_id: "user-1".to_string(), public_work_code: None, author_public_user_code: None, source_agent_session_id: Some("session-1".to_string()), publication_status: CustomWorldPublicationStatus::Draft, world_name: "潮雾列岛".to_string(), subtitle: String::new(), summary_text: String::new(), theme_mode: CustomWorldThemeMode::Mythic, cover_image_src: None, profile_payload_json: "{}".to_string(), playable_npc_count: 0, landmark_count: 0, play_count: 0, remix_count: 0, like_count: 0, author_display_name: "玩家".to_string(), published_at: None, deleted_at: None, created_at: Timestamp::from_micros_since_unix_epoch(1), updated_at: Timestamp::from_micros_since_unix_epoch(1), }; let mut active_agent_session_ids = HashSet::new(); assert!(should_include_custom_world_profile_work( &draft_profile, &active_agent_session_ids, )); active_agent_session_ids.insert("session-2".to_string()); assert!(should_include_custom_world_profile_work( &draft_profile, &active_agent_session_ids, )); } #[test] fn summarize_publish_gate_accepts_current_agent_result_schema() { let draft_profile = serde_json::from_str::( r#"{ "id":"agent-draft-session-1", "settingText":"海雾会吞掉记错航线的人。", "creatorIntent":{"playerPremise":"玩家是带着旧航海日志返乡的守灯人。"}, "anchorContent":{ "worldPromise":{"hook":"在失真的海图上追查一场被篡改的沉船事故。"}, "playerEntryPoint":{ "openingIdentity":"被停职返乡的守灯人", "openingProblem":"灯塔记录被人改写", "entryMotivation":"查清父亲沉船真相" } }, "coreConflicts":["群岛议会试图掩盖沉船真相。"], "sceneChapterBlueprints":[ { "id":"scene-chapter-1", "sceneId":"landmark-1", "title":"失灯港", "acts":[ { "id":"act-1", "title":"第一幕" } ] } ] }"#, ) .expect("draft profile should be valid json") .as_object() .cloned() .expect("draft profile should be object"); let gate = summarize_publish_gate_from_json( "session-1", RpgAgentStage::ReadyToPublish, Some(&draft_profile), &[], ); assert!(gate.publish_ready); assert_eq!(gate.blocker_count, 0); assert!(gate.blockers.is_empty()); } #[test] fn ensure_minimal_draft_profile_includes_scene_chapter_blueprints_slot() { let profile = ensure_minimal_draft_profile(JsonMap::new(), "旧航路群岛"); assert_eq!( profile.get("sceneChapterBlueprints"), Some(&JsonValue::Array(Vec::new())) ); } #[test] fn draft_foundation_payload_must_contain_external_draft_profile() { let payload = JsonMap::new(); let result = payload .get("draftProfile") .and_then(JsonValue::as_object) .cloned() .ok_or_else(|| { "draft_foundation requires externally generated payload.draftProfile".to_string() }); assert_eq!( result.expect_err("missing draftProfile should be rejected"), "draft_foundation requires externally generated payload.draftProfile" ); } }