feat: complete M5 custom world and agent chain

This commit is contained in:
2026-04-22 14:15:27 +08:00
parent 209e924403
commit 0773a0d0ca
27 changed files with 3359 additions and 159 deletions

View File

@@ -106,6 +106,9 @@ use crate::module_bindings::{
BattleStateSnapshot as BindingBattleStateSnapshot, BattleStatus as BindingBattleStatus,
CombatOutcome as BindingCombatOutcome,
CustomWorldAgentMessageSnapshot as BindingCustomWorldAgentMessageSnapshot,
CustomWorldAgentActionExecuteInput as BindingCustomWorldAgentActionExecuteInput,
CustomWorldAgentActionExecuteResult as BindingCustomWorldAgentActionExecuteResult,
CustomWorldAgentCardDetailGetInput as BindingCustomWorldAgentCardDetailGetInput,
CustomWorldAgentMessageSubmitInput as BindingCustomWorldAgentMessageSubmitInput,
CustomWorldAgentOperationGetInput as BindingCustomWorldAgentOperationGetInput,
CustomWorldAgentOperationProcedureResult as BindingCustomWorldAgentOperationProcedureResult,
@@ -114,6 +117,9 @@ use crate::module_bindings::{
CustomWorldAgentSessionGetInput as BindingCustomWorldAgentSessionGetInput,
CustomWorldAgentSessionProcedureResult as BindingCustomWorldAgentSessionProcedureResult,
CustomWorldAgentSessionSnapshot as BindingCustomWorldAgentSessionSnapshot,
CustomWorldDraftCardDetailResult as BindingCustomWorldDraftCardDetailResult,
CustomWorldDraftCardDetailSectionSnapshot as BindingCustomWorldDraftCardDetailSectionSnapshot,
CustomWorldDraftCardDetailSnapshot as BindingCustomWorldDraftCardDetailSnapshot,
CustomWorldDraftCardSnapshot as BindingCustomWorldDraftCardSnapshot,
CustomWorldGalleryDetailInput as BindingCustomWorldGalleryDetailInput,
CustomWorldGalleryEntrySnapshot as BindingCustomWorldGalleryEntrySnapshot,
@@ -130,6 +136,9 @@ use crate::module_bindings::{
CustomWorldPublishWorldInput as BindingCustomWorldPublishWorldInput,
CustomWorldPublishWorldResult as BindingCustomWorldPublishWorldResult,
CustomWorldPublishedProfileCompileSnapshot as BindingCustomWorldPublishedProfileCompileSnapshot,
CustomWorldWorkSummarySnapshot as BindingCustomWorldWorkSummarySnapshot,
CustomWorldWorksListInput as BindingCustomWorldWorksListInput,
CustomWorldWorksListResult as BindingCustomWorldWorksListResult,
CustomWorldThemeMode as BindingCustomWorldThemeMode, DbConnection,
InventoryContainerKind as BindingInventoryContainerKind,
InventoryEquipmentSlot as BindingInventoryEquipmentSlot,
@@ -207,8 +216,10 @@ use crate::module_bindings::{
create_battle_state_and_return_procedure::create_battle_state_and_return as _,
create_custom_world_agent_session_procedure::create_custom_world_agent_session as _,
delete_runtime_snapshot_and_return_procedure::delete_runtime_snapshot_and_return as _,
execute_custom_world_agent_action_procedure::execute_custom_world_agent_action as _,
fail_ai_task_and_return_procedure::fail_ai_task_and_return as _,
get_battle_state_procedure::get_battle_state as _,
get_custom_world_agent_card_detail_procedure::get_custom_world_agent_card_detail as _,
get_custom_world_agent_operation_procedure::get_custom_world_agent_operation as _,
get_custom_world_agent_session_procedure::get_custom_world_agent_session as _,
get_custom_world_gallery_detail_procedure::get_custom_world_gallery_detail as _,
@@ -221,6 +232,7 @@ use crate::module_bindings::{
get_story_session_state_procedure::get_story_session_state as _,
list_custom_world_gallery_entries_procedure::list_custom_world_gallery_entries as _,
list_custom_world_profiles_procedure::list_custom_world_profiles as _,
list_custom_world_works_procedure::list_custom_world_works as _,
list_platform_browse_history_procedure::list_platform_browse_history as _,
list_profile_save_archives_procedure::list_profile_save_archives as _,
list_profile_wallet_ledger_procedure::list_profile_wallet_ledger as _,
@@ -764,6 +776,76 @@ impl SpacetimeClient {
.await
}
pub async fn list_custom_world_works(
&self,
owner_user_id: String,
) -> Result<Vec<CustomWorldWorkSummaryRecord>, SpacetimeClientError> {
let procedure_input = BindingCustomWorldWorksListInput { owner_user_id };
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.list_custom_world_works_then(procedure_input, move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_custom_world_works_list_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn get_custom_world_agent_card_detail(
&self,
session_id: String,
owner_user_id: String,
card_id: String,
) -> Result<CustomWorldDraftCardDetailRecord, SpacetimeClientError> {
let procedure_input = BindingCustomWorldAgentCardDetailGetInput {
session_id,
owner_user_id,
card_id,
};
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.get_custom_world_agent_card_detail_then(procedure_input, move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_custom_world_draft_card_detail_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn execute_custom_world_agent_action(
&self,
input: CustomWorldAgentActionExecuteRecordInput,
) -> Result<CustomWorldAgentActionExecuteRecord, SpacetimeClientError> {
let procedure_input = BindingCustomWorldAgentActionExecuteInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
operation_id: input.operation_id,
action: input.action,
payload_json: input.payload_json,
submitted_at_micros: input.submitted_at_micros,
};
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.execute_custom_world_agent_action_then(procedure_input, move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_custom_world_agent_action_execute_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn submit_custom_world_agent_message(
&self,
input: CustomWorldAgentMessageSubmitRecordInput,
@@ -2259,6 +2341,66 @@ fn map_custom_world_agent_operation_procedure_result(
Ok(map_custom_world_agent_operation_snapshot(operation))
}
fn map_custom_world_works_list_result(
result: BindingCustomWorldWorksListResult,
) -> Result<Vec<CustomWorldWorkSummaryRecord>, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::Procedure(
result
.error_message
.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()),
));
}
result
.items
.into_iter()
.map(map_custom_world_work_summary_snapshot)
.collect()
}
fn map_custom_world_draft_card_detail_result(
result: BindingCustomWorldDraftCardDetailResult,
) -> Result<CustomWorldDraftCardDetailRecord, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::Procedure(
result
.error_message
.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()),
));
}
let card = result.card.ok_or_else(|| {
SpacetimeClientError::Procedure(
"SpacetimeDB procedure 未返回 custom world card detail 快照".to_string(),
)
})?;
map_custom_world_draft_card_detail_snapshot(card)
}
fn map_custom_world_agent_action_execute_result(
result: BindingCustomWorldAgentActionExecuteResult,
) -> Result<CustomWorldAgentActionExecuteRecord, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::Procedure(
result
.error_message
.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()),
));
}
let operation = result.operation.ok_or_else(|| {
SpacetimeClientError::Procedure(
"SpacetimeDB procedure 未返回 custom world action operation 快照".to_string(),
)
})?;
Ok(CustomWorldAgentActionExecuteRecord {
operation: map_custom_world_agent_operation_snapshot(operation),
})
}
fn map_story_session_procedure_result(
result: BindingStorySessionProcedureResult,
) -> Result<StorySessionResultRecord, SpacetimeClientError> {
@@ -2647,6 +2789,40 @@ fn map_custom_world_published_profile_compile_snapshot(
})
}
fn map_custom_world_work_summary_snapshot(
snapshot: BindingCustomWorldWorkSummarySnapshot,
) -> Result<CustomWorldWorkSummaryRecord, SpacetimeClientError> {
Ok(CustomWorldWorkSummaryRecord {
work_id: snapshot.work_id,
source_type: snapshot.source_type,
status: snapshot.status,
title: snapshot.title,
subtitle: snapshot.subtitle,
summary: snapshot.summary,
cover_image_src: snapshot.cover_image_src,
cover_render_mode: snapshot.cover_render_mode,
cover_character_image_srcs: parse_json_string_array(
&snapshot.cover_character_image_srcs_json,
"custom world work cover_character_image_srcs_json",
)?,
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
published_at: snapshot.published_at_micros.map(format_timestamp_micros),
stage: snapshot.stage.map(map_rpg_agent_stage),
stage_label: snapshot.stage_label,
playable_npc_count: snapshot.playable_npc_count,
landmark_count: snapshot.landmark_count,
role_visual_ready_count: snapshot.role_visual_ready_count,
role_animation_ready_count: snapshot.role_animation_ready_count,
role_asset_summary_label: snapshot.role_asset_summary_label,
session_id: snapshot.session_id,
profile_id: snapshot.profile_id,
can_resume: snapshot.can_resume,
can_enter_world: snapshot.can_enter_world,
blocker_count: snapshot.blocker_count,
publish_ready: snapshot.publish_ready,
})
}
fn map_custom_world_agent_session_snapshot(
snapshot: BindingCustomWorldAgentSessionSnapshot,
) -> Result<CustomWorldAgentSessionRecord, SpacetimeClientError> {
@@ -2706,6 +2882,12 @@ fn map_custom_world_agent_session_snapshot(
.into_iter()
.map(map_custom_world_checkpoint_record)
.collect::<Result<Vec<_>, _>>()?;
let supported_actions = parse_supported_actions_json(&snapshot.supported_actions_json)?;
let publish_gate = snapshot
.publish_gate_json
.as_deref()
.map(parse_custom_world_publish_gate_record)
.transpose()?;
Ok(CustomWorldAgentSessionRecord {
session_id: snapshot.session_id,
@@ -2736,12 +2918,8 @@ fn map_custom_world_agent_session_snapshot(
quality_findings,
asset_coverage,
checkpoints,
supported_actions: build_minimal_custom_world_supported_actions(
snapshot.stage,
snapshot.progress_percent,
snapshot.result_preview_json.is_some(),
snapshot.checkpoints_json.as_str(),
),
supported_actions,
publish_gate,
result_preview: snapshot
.result_preview_json
.as_deref()
@@ -2797,9 +2975,57 @@ fn map_custom_world_draft_card_snapshot(
.asset_status
.map(format_custom_world_role_asset_status_back),
asset_status_label: snapshot.asset_status_label,
detail_payload: snapshot
.detail_payload_json
.as_deref()
.map(|value| parse_json_value(value, "custom world draft_card detail_payload_json"))
.transpose()?,
})
}
fn map_custom_world_draft_card_detail_snapshot(
snapshot: BindingCustomWorldDraftCardDetailSnapshot,
) -> Result<CustomWorldDraftCardDetailRecord, SpacetimeClientError> {
Ok(CustomWorldDraftCardDetailRecord {
card_id: snapshot.card_id,
kind: format_rpg_agent_draft_card_kind(snapshot.kind).to_string(),
title: snapshot.title,
sections: snapshot
.sections
.into_iter()
.map(map_custom_world_draft_card_detail_section_snapshot)
.collect(),
linked_ids: parse_json_string_array(
&snapshot.linked_ids_json,
"custom world card detail linked_ids_json",
)?,
locked: snapshot.locked,
editable: snapshot.editable,
editable_section_ids: parse_json_string_array(
&snapshot.editable_section_ids_json,
"custom world card detail editable_section_ids_json",
)?,
warning_messages: parse_json_string_array(
&snapshot.warning_messages_json,
"custom world card detail warning_messages_json",
)?,
asset_status: snapshot
.asset_status
.map(format_custom_world_role_asset_status_back),
asset_status_label: snapshot.asset_status_label,
})
}
fn map_custom_world_draft_card_detail_section_snapshot(
snapshot: BindingCustomWorldDraftCardDetailSectionSnapshot,
) -> CustomWorldDraftCardDetailSectionRecord {
CustomWorldDraftCardDetailSectionRecord {
section_id: snapshot.section_id,
label: snapshot.label,
value: snapshot.value,
}
}
fn map_story_session_snapshot(snapshot: BindingStorySessionSnapshot) -> StorySessionRecord {
StorySessionRecord {
story_session_id: snapshot.story_session_id,
@@ -3607,45 +3833,159 @@ fn map_custom_world_checkpoint_record(
})
}
fn build_minimal_custom_world_supported_actions(
stage: crate::module_bindings::RpgAgentStage,
progress_percent: u32,
has_result_preview: bool,
checkpoints_json: &str,
) -> Vec<CustomWorldSupportedActionRecord> {
let has_checkpoint = parse_json_array(checkpoints_json, "custom world agent checkpoints_json")
.map(|entries| !entries.is_empty())
.unwrap_or(false);
let refining_ready = matches!(
stage,
crate::module_bindings::RpgAgentStage::FoundationReview
| crate::module_bindings::RpgAgentStage::ObjectRefining
| crate::module_bindings::RpgAgentStage::VisualRefining
| crate::module_bindings::RpgAgentStage::LongTailReview
| crate::module_bindings::RpgAgentStage::ReadyToPublish
| crate::module_bindings::RpgAgentStage::Published
);
fn parse_supported_actions_json(
value: &str,
) -> Result<Vec<CustomWorldSupportedActionRecord>, SpacetimeClientError> {
parse_json_array(value, "custom world agent supported_actions_json")?
.into_iter()
.map(|entry| {
let object = entry.as_object().ok_or_else(|| {
SpacetimeClientError::Runtime(
"custom world supported action 必须是 JSON object".to_string(),
)
})?;
let action = object
.get("action")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
SpacetimeClientError::Runtime(
"custom world supported action.action 缺失".to_string(),
)
})?;
let enabled = object
.get("enabled")
.and_then(serde_json::Value::as_bool)
.ok_or_else(|| {
SpacetimeClientError::Runtime(
"custom world supported action.enabled 缺失".to_string(),
)
})?;
vec![
CustomWorldSupportedActionRecord {
action: "draft_foundation".to_string(),
enabled: progress_percent >= 100,
reason: (progress_percent < 100)
.then(|| "draft_foundation requires progressPercent >= 100".to_string()),
},
CustomWorldSupportedActionRecord {
action: "publish_world".to_string(),
enabled: refining_ready && has_result_preview,
reason: (!refining_ready || !has_result_preview)
.then(|| "publish_world requires refined draft and resultPreview".to_string()),
},
CustomWorldSupportedActionRecord {
action: "revert_checkpoint".to_string(),
enabled: has_checkpoint,
reason: (!has_checkpoint)
.then(|| "revert_checkpoint requires at least one checkpoint".to_string()),
},
]
Ok(CustomWorldSupportedActionRecord {
action: action.to_string(),
enabled,
reason: object
.get("reason")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
})
})
.collect()
}
fn parse_custom_world_publish_gate_record(
value: &str,
) -> Result<CustomWorldPublishGateRecord, SpacetimeClientError> {
let object = parse_json_value(value, "custom world publish_gate_json")?
.as_object()
.cloned()
.ok_or_else(|| {
SpacetimeClientError::Runtime(
"custom world publish_gate_json 必须是 JSON object".to_string(),
)
})?;
let profile_id = object
.get("profileId")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
SpacetimeClientError::Runtime(
"custom world publish_gate.profileId 缺失".to_string(),
)
})?;
let blockers = object
.get("blockers")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| {
SpacetimeClientError::Runtime(
"custom world publish_gate.blockers 缺失".to_string(),
)
})?
.iter()
.cloned()
.map(|entry| {
let object = entry.as_object().ok_or_else(|| {
SpacetimeClientError::Runtime(
"custom world publish gate blocker 必须是 JSON object".to_string(),
)
})?;
let id = object
.get("id")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
SpacetimeClientError::Runtime(
"custom world publish gate blocker.id 缺失".to_string(),
)
})?;
let code = object
.get("code")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
SpacetimeClientError::Runtime(
"custom world publish gate blocker.code 缺失".to_string(),
)
})?;
let message = object
.get("message")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
SpacetimeClientError::Runtime(
"custom world publish gate blocker.message 缺失".to_string(),
)
})?;
Ok(CustomWorldResultPreviewBlockerRecord {
id: id.to_string(),
code: code.to_string(),
message: message.to_string(),
})
})
.collect::<Result<Vec<_>, _>>()?;
let blocker_count = object
.get("blockerCount")
.and_then(serde_json::Value::as_u64)
.and_then(|value| u32::try_from(value).ok())
.ok_or_else(|| {
SpacetimeClientError::Runtime(
"custom world publish_gate.blockerCount 缺失".to_string(),
)
})?;
let publish_ready = object
.get("publishReady")
.and_then(serde_json::Value::as_bool)
.ok_or_else(|| {
SpacetimeClientError::Runtime(
"custom world publish_gate.publishReady 缺失".to_string(),
)
})?;
let can_enter_world = object
.get("canEnterWorld")
.and_then(serde_json::Value::as_bool)
.ok_or_else(|| {
SpacetimeClientError::Runtime(
"custom world publish_gate.canEnterWorld 缺失".to_string(),
)
})?;
Ok(CustomWorldPublishGateRecord {
profile_id: profile_id.to_string(),
blockers,
blocker_count,
publish_ready,
can_enter_world,
})
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -3785,6 +4125,7 @@ pub struct CustomWorldDraftCardRecord {
pub warning_count: u32,
pub asset_status: Option<String>,
pub asset_status_label: Option<String>,
pub detail_payload: Option<serde_json::Value>,
}
#[derive(Clone, Debug, PartialEq)]
@@ -3804,6 +4145,72 @@ pub struct CustomWorldCheckpointRecord {
// 兼容并行 custom world facade 中仍在使用的旧命名,避免本轮 module-npc 收口被无关改动阻塞。
pub type CustomWorldAgentCheckpointRecord = CustomWorldCheckpointRecord;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CustomWorldResultPreviewBlockerRecord {
pub id: String,
pub code: String,
pub message: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CustomWorldPublishGateRecord {
pub profile_id: String,
pub blockers: Vec<CustomWorldResultPreviewBlockerRecord>,
pub blocker_count: u32,
pub publish_ready: bool,
pub can_enter_world: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CustomWorldWorkSummaryRecord {
pub work_id: String,
pub source_type: String,
pub status: String,
pub title: String,
pub subtitle: String,
pub summary: String,
pub cover_image_src: Option<String>,
pub cover_render_mode: Option<String>,
pub cover_character_image_srcs: Vec<String>,
pub updated_at: String,
pub published_at: Option<String>,
pub stage: Option<String>,
pub stage_label: Option<String>,
pub playable_npc_count: u32,
pub landmark_count: u32,
pub role_visual_ready_count: Option<u32>,
pub role_animation_ready_count: Option<u32>,
pub role_asset_summary_label: Option<String>,
pub session_id: Option<String>,
pub profile_id: Option<String>,
pub can_resume: bool,
pub can_enter_world: bool,
pub blocker_count: u32,
pub publish_ready: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CustomWorldDraftCardDetailSectionRecord {
pub section_id: String,
pub label: String,
pub value: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CustomWorldDraftCardDetailRecord {
pub card_id: String,
pub kind: String,
pub title: String,
pub sections: Vec<CustomWorldDraftCardDetailSectionRecord>,
pub linked_ids: Vec<String>,
pub locked: bool,
pub editable: bool,
pub editable_section_ids: Vec<String>,
pub warning_messages: Vec<String>,
pub asset_status: Option<String>,
pub asset_status_label: Option<String>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct CustomWorldAgentSessionRecord {
pub session_id: String,
@@ -3827,6 +4234,7 @@ pub struct CustomWorldAgentSessionRecord {
pub asset_coverage: serde_json::Value,
pub checkpoints: Vec<CustomWorldCheckpointRecord>,
pub supported_actions: Vec<CustomWorldSupportedActionRecord>,
pub publish_gate: Option<CustomWorldPublishGateRecord>,
pub result_preview: Option<serde_json::Value>,
pub updated_at: String,
}
@@ -3892,6 +4300,21 @@ pub struct CustomWorldAgentMessageSubmitRecordInput {
pub submitted_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CustomWorldAgentActionExecuteRecordInput {
pub session_id: String,
pub owner_user_id: String,
pub operation_id: String,
pub action: String,
pub payload_json: Option<String>,
pub submitted_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct CustomWorldAgentActionExecuteRecord {
pub operation: CustomWorldAgentOperationRecord,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResolveNpcBattleInteractionInput {
pub npc_interaction: DomainResolveNpcInteractionInput,