5596 lines
202 KiB
Rust
5596 lines
202 KiB
Rust
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<String>,
|
||
// 作者公开陶泥号在发布时固化到作品真相,供广场读模型与搜索结果直接展示。
|
||
author_public_user_code: Option<String>,
|
||
source_agent_session_id: Option<String>,
|
||
publication_status: CustomWorldPublicationStatus,
|
||
world_name: String,
|
||
subtitle: String,
|
||
summary_text: String,
|
||
theme_mode: CustomWorldThemeMode,
|
||
cover_image_src: Option<String>,
|
||
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<Timestamp>,
|
||
// 软删除后保留 profile 真相,供审计与幂等删除使用。
|
||
deleted_at: Option<Timestamp>,
|
||
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<String>,
|
||
question_snapshot_json: String,
|
||
result_payload_json: Option<String>,
|
||
last_error_message: Option<String>,
|
||
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<String>,
|
||
anchor_content_json: String,
|
||
creator_intent_json: Option<String>,
|
||
creator_intent_readiness_json: String,
|
||
anchor_pack_json: Option<String>,
|
||
lock_state_json: Option<String>,
|
||
draft_profile_json: Option<String>,
|
||
last_assistant_reply: Option<String>,
|
||
publish_gate_json: Option<String>,
|
||
result_preview_json: Option<String>,
|
||
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<String>,
|
||
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<String>,
|
||
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<CustomWorldRoleAssetStatus>,
|
||
asset_status_label: Option<String>,
|
||
detail_payload_json: Option<String>,
|
||
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<String>,
|
||
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<CustomWorldAgentSessionSnapshot, String> {
|
||
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<CustomWorldAgentSessionSnapshot, 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())?;
|
||
|
||
Ok(build_custom_world_agent_session_snapshot(ctx, &session))
|
||
}
|
||
|
||
fn delete_custom_world_agent_session_tx(
|
||
ctx: &ReducerContext,
|
||
input: CustomWorldAgentSessionGetInput,
|
||
) -> Result<Vec<CustomWorldWorkSummarySnapshot>, 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::<Vec<_>>()
|
||
{
|
||
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::<Vec<_>>()
|
||
{
|
||
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::<Vec<_>>()
|
||
{
|
||
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<CustomWorldAgentOperationSnapshot, String> {
|
||
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<CustomWorldAgentOperationSnapshot, String> {
|
||
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<CustomWorldAgentOperationSnapshot, String> {
|
||
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<CustomWorldAgentOperationSnapshot, String> {
|
||
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<CustomWorldGalleryEntrySnapshot>,
|
||
),
|
||
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<CustomWorldGalleryEntrySnapshot>,
|
||
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<CustomWorldGalleryEntrySnapshot>,
|
||
),
|
||
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<CustomWorldGalleryEntrySnapshot>,
|
||
),
|
||
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<Vec<CustomWorldProfileSnapshot>, 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::<Vec<_>>();
|
||
|
||
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<Vec<CustomWorldGalleryEntrySnapshot>, String> {
|
||
sync_missing_custom_world_gallery_entries(ctx)?;
|
||
|
||
let entries = ctx
|
||
.db
|
||
.custom_world_gallery_entry()
|
||
.iter()
|
||
.collect::<Vec<_>>();
|
||
let profile_ids = entries
|
||
.iter()
|
||
.map(|row| row.profile_id.clone())
|
||
.collect::<Vec<_>>();
|
||
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::<Vec<_>>();
|
||
|
||
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<CustomWorldProfileSnapshot>,
|
||
Option<CustomWorldGalleryEntrySnapshot>,
|
||
),
|
||
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<CustomWorldProfileSnapshot>,
|
||
Option<CustomWorldGalleryEntrySnapshot>,
|
||
),
|
||
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<CustomWorldProfileSnapshot>,
|
||
Option<CustomWorldGalleryEntrySnapshot>,
|
||
),
|
||
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<CustomWorldGalleryEntrySnapshot>,
|
||
),
|
||
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<Vec<CustomWorldWorkSummarySnapshot>, 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::<Vec<_>>();
|
||
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<String>,
|
||
) -> 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<CustomWorldDraftCardDetailSnapshot, String> {
|
||
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<CustomWorldAgentOperationSnapshot, String> {
|
||
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<String, JsonValue>,
|
||
) -> Result<CustomWorldAgentOperationSnapshot, String> {
|
||
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<String, JsonValue>,
|
||
) -> Result<CustomWorldAgentOperationSnapshot, String> {
|
||
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::<JsonValue>(&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<String, JsonValue>,
|
||
) -> Result<CustomWorldAgentOperationSnapshot, String> {
|
||
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<String> {
|
||
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<String, JsonValue>,
|
||
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<String, JsonValue>,
|
||
draft_profile: &JsonMap<String, JsonValue>,
|
||
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<String, JsonValue>,
|
||
) -> Result<CustomWorldAgentOperationSnapshot, String> {
|
||
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<JsonMap<String, JsonValue>, String> {
|
||
parse_optional_session_object(session.draft_profile_json.as_deref())
|
||
.ok_or_else(|| "publish_world requires draft_profile_json".to_string())
|
||
}
|
||
|
||
fn execute_revert_checkpoint_action(
|
||
ctx: &ReducerContext,
|
||
session: &CustomWorldAgentSession,
|
||
input: &CustomWorldAgentActionExecuteInput,
|
||
payload: &JsonMap<String, JsonValue>,
|
||
) -> Result<CustomWorldAgentOperationSnapshot, String> {
|
||
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<String, JsonValue>,
|
||
) -> Result<CustomWorldAgentOperationSnapshot, String> {
|
||
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<String, JsonValue>,
|
||
) -> Result<CustomWorldAgentOperationSnapshot, String> {
|
||
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<String, JsonValue>,
|
||
) -> Result<CustomWorldAgentOperationSnapshot, String> {
|
||
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<String, JsonValue>,
|
||
) -> Result<CustomWorldAgentOperationSnapshot, String> {
|
||
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<String, JsonValue>,
|
||
) -> Result<CustomWorldAgentOperationSnapshot, String> {
|
||
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<String, JsonValue>,
|
||
) -> Result<CustomWorldAgentOperationSnapshot, String> {
|
||
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<String, JsonValue>,
|
||
) -> Result<CustomWorldAgentOperationSnapshot, String> {
|
||
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<String, JsonValue> {
|
||
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<String, JsonValue>,
|
||
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<String, JsonValue>,
|
||
payload: &JsonMap<String, JsonValue>,
|
||
payload_key: &str,
|
||
profile_key: &str,
|
||
id_prefix: &str,
|
||
card_kind: RpgAgentDraftCardKind,
|
||
ctx: &ReducerContext,
|
||
session_id: &str,
|
||
updated_at_micros: i64,
|
||
) -> Result<u32, String> {
|
||
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<JsonValue>, 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<String, JsonValue>,
|
||
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<String, JsonValue>,
|
||
generated: bool,
|
||
) -> Result<String, String> {
|
||
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<String, JsonValue>,
|
||
generated: bool,
|
||
) -> Result<String, String> {
|
||
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<String, JsonValue>,
|
||
generated: bool,
|
||
) -> Vec<JsonValue> {
|
||
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<String, JsonValue>,
|
||
generated: bool,
|
||
) -> Vec<JsonValue> {
|
||
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<String, JsonValue>,
|
||
keys: &[&str],
|
||
) -> Vec<JsonMap<String, JsonValue>> {
|
||
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<String, JsonValue>,
|
||
payload: &JsonMap<String, JsonValue>,
|
||
) {
|
||
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<u32>,
|
||
progress_percent: Option<u32>,
|
||
stage: Option<RpgAgentStage>,
|
||
focus_card_id: Option<Option<String>>,
|
||
anchor_content_json: Option<String>,
|
||
creator_intent_json: Option<Option<String>>,
|
||
creator_intent_readiness_json: Option<String>,
|
||
anchor_pack_json: Option<Option<String>>,
|
||
lock_state_json: Option<Option<String>>,
|
||
draft_profile_json: Option<Option<String>>,
|
||
last_assistant_reply: Option<Option<String>>,
|
||
publish_gate_json: Option<Option<String>>,
|
||
result_preview_json: Option<Option<String>>,
|
||
pending_clarifications_json: Option<String>,
|
||
quality_findings_json: Option<String>,
|
||
suggested_actions_json: Option<String>,
|
||
recommended_replies_json: Option<String>,
|
||
asset_coverage_json: Option<String>,
|
||
checkpoints_json: Option<String>,
|
||
updated_at_micros: Option<i64>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Default)]
|
||
struct CustomWorldAgentOperationPatch {
|
||
status: Option<RpgAgentOperationStatus>,
|
||
phase_label: Option<String>,
|
||
phase_detail: Option<String>,
|
||
progress: Option<u32>,
|
||
error_message: Option<Option<String>>,
|
||
updated_at_micros: Option<i64>,
|
||
}
|
||
|
||
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<String, JsonValue>>,
|
||
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::<Vec<_>>(),
|
||
"blockerCount": gate.blocker_count,
|
||
"publishReady": gate.publish_ready,
|
||
"canEnterWorld": gate.can_enter_world,
|
||
})
|
||
}
|
||
|
||
fn build_result_preview_json(
|
||
draft_profile: Option<&JsonMap<String, JsonValue>>,
|
||
gate: &CustomWorldPublishGateSnapshot,
|
||
quality_findings: &[JsonValue],
|
||
generated_at_micros: i64,
|
||
) -> Result<Option<String>, 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::<Vec<_>>(),
|
||
"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<JsonValue> {
|
||
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<String>) -> JsonValue {
|
||
json!({
|
||
"action": action,
|
||
"enabled": enabled,
|
||
"reason": reason,
|
||
})
|
||
}
|
||
|
||
fn build_custom_world_draft_card_detail_snapshot(
|
||
card: &CustomWorldDraftCard,
|
||
) -> Result<CustomWorldDraftCardDetailSnapshot, String> {
|
||
if let Some(detail_payload_json) = card.detail_payload_json.as_deref() {
|
||
let detail_value =
|
||
serde_json::from_str::<JsonValue>(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::<Vec<_>>()
|
||
})
|
||
.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<CustomWorldDraftCardDetailSectionSnapshot> {
|
||
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<JsonValue> {
|
||
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<CustomWorldAgentSession, String> {
|
||
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<CustomWorldAgentOperation, String> {
|
||
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<CustomWorldAgentOperation, String> {
|
||
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<String, JsonValue>,
|
||
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<CustomWorldAgentSession, String> {
|
||
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<RpgAgentStage> {
|
||
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<JsonMap<String, JsonValue>> {
|
||
value
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.and_then(|value| serde_json::from_str::<JsonValue>(value).ok())
|
||
.and_then(|value| value.as_object().cloned())
|
||
}
|
||
|
||
fn parse_json_array_or_empty(raw: &str) -> Vec<JsonValue> {
|
||
serde_json::from_str::<JsonValue>(raw)
|
||
.ok()
|
||
.and_then(|value| value.as_array().cloned())
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
fn serialize_json_value(value: &JsonValue) -> Result<String, String> {
|
||
serde_json::to_string(value).map_err(|error| format!("JSON 序列化失败: {error}"))
|
||
}
|
||
|
||
fn read_required_payload_text(
|
||
payload: &JsonMap<String, JsonValue>,
|
||
key: &str,
|
||
error_message: &str,
|
||
) -> Result<String, String> {
|
||
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<String, JsonValue>, keys: &[&str]) -> Option<String> {
|
||
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, JsonValue>>,
|
||
) -> 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, JsonValue>>,
|
||
) -> 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<String, JsonValue>>,
|
||
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<String, JsonValue>>,
|
||
) -> Option<String> {
|
||
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<String, JsonValue>>,
|
||
) -> (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<String, JsonValue>,
|
||
seed_text: &str,
|
||
) -> JsonMap<String, JsonValue> {
|
||
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<String, JsonValue> {
|
||
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<String, String> {
|
||
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<String> {
|
||
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<RpgAgentStage, String> {
|
||
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<CustomWorldGalleryEntrySnapshot, String> {
|
||
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::<Vec<_>>();
|
||
|
||
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::<Vec<_>>();
|
||
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::<Vec<_>>();
|
||
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::<Vec<_>>();
|
||
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<String, u32>,
|
||
) -> 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::<String>();
|
||
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::<u64>()
|
||
.ok()
|
||
.map(|sequence| format!("SY-{sequence:08}"))
|
||
.unwrap_or_else(|| "SY-00000000".to_string())
|
||
}
|
||
|
||
fn normalize_public_work_code(input: &str) -> Option<String> {
|
||
let normalized = input
|
||
.trim()
|
||
.chars()
|
||
.filter(|character| character.is_ascii_alphanumeric())
|
||
.collect::<String>()
|
||
.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::<JsonValue>(
|
||
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"
|
||
);
|
||
}
|
||
}
|