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

1
server-rs/Cargo.lock generated
View File

@@ -2517,6 +2517,7 @@ dependencies = [
"module-runtime-item",
"module-story",
"serde_json",
"shared-kernel",
"spacetimedb",
]

View File

@@ -26,13 +26,20 @@ use crate::{
auth_sessions::auth_sessions,
custom_world::{
create_custom_world_agent_session, execute_custom_world_agent_action,
get_custom_world_agent_card_detail,
get_custom_world_agent_operation, get_custom_world_agent_session,
get_custom_world_works,
get_custom_world_gallery_detail, get_custom_world_library,
get_custom_world_library_detail, list_custom_world_gallery,
publish_custom_world_library_profile, put_custom_world_library_profile,
stream_custom_world_agent_message, submit_custom_world_agent_message,
unpublish_custom_world_library_profile,
},
custom_world_ai::{
generate_custom_world_cover_image, generate_custom_world_entity,
generate_custom_world_scene_image, generate_custom_world_scene_npc,
upload_custom_world_cover_image,
},
error_middleware::normalize_error_response,
health::health_check,
llm::proxy_llm_chat_completions,
@@ -54,7 +61,10 @@ use crate::{
put_runtime_snapshot, resume_profile_save_archive,
},
runtime_settings::{get_runtime_settings, put_runtime_settings},
runtime_story::resolve_runtime_story_state,
runtime_story::{
generate_runtime_story_continue, generate_runtime_story_initial,
get_runtime_story_state, resolve_runtime_story_action, resolve_runtime_story_state,
},
state::AppState,
story_battles::{
create_story_battle, create_story_npc_battle, get_story_battle_state, resolve_story_battle,
@@ -297,6 +307,19 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/works",
get(get_custom_world_works).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/agent/sessions/{session_id}/cards/{card_id}",
get(get_custom_world_agent_card_detail).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/runtime/custom-world/agent/sessions/{session_id}/messages",
post(submit_custom_world_agent_message).route_layer(middleware::from_fn_with_state(
@@ -325,6 +348,62 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/custom-world/entity",
post(generate_custom_world_entity).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/entity",
post(generate_custom_world_entity).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/custom-world/scene-npc",
post(generate_custom_world_scene_npc).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/runtime/custom-world/scene-npc",
post(generate_custom_world_scene_npc).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/custom-world/scene-image",
post(generate_custom_world_scene_image).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/custom-world/cover-image",
post(generate_custom_world_cover_image).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/runtime/custom-world/cover-image",
post(generate_custom_world_cover_image).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/custom-world/cover-upload",
post(upload_custom_world_cover_image).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/runtime/custom-world/cover-upload",
post(upload_custom_world_cover_image).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/runtime/profile/browse-history",
get(get_runtime_browse_history)
@@ -422,6 +501,34 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/story/state/{session_id}",
get(get_runtime_story_state).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/story/actions/resolve",
post(resolve_runtime_story_action).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/story/initial",
post(generate_runtime_story_initial).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/story/continue",
post(generate_runtime_story_continue).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/play-stats",
get(get_profile_play_stats).route_layer(middleware::from_fn_with_state(

View File

@@ -11,21 +11,28 @@ use module_custom_world::{
use serde_json::{Map, Value, json};
use shared_contracts::runtime::{
CreateCustomWorldAgentSessionRequest, CustomWorldAgentCheckpointResponse,
CustomWorldAgentCardDetailResponse,
CustomWorldAgentMessageResponse, CustomWorldAgentOperationResponse,
CustomWorldAgentSessionResponse, CustomWorldAgentSessionSnapshotResponse,
CustomWorldDraftCardDetailResponse, CustomWorldDraftCardDetailSectionResponse,
CustomWorldDraftCardSummaryResponse, CustomWorldGalleryCardResponse,
CustomWorldGalleryDetailResponse, CustomWorldGalleryResponse, CustomWorldLibraryEntryResponse,
CustomWorldLibraryMutationResponse, CustomWorldLibraryResponse,
CustomWorldProfileUpsertRequest, CustomWorldSupportedActionResponse,
SendCustomWorldAgentMessageRequest,
CustomWorldPublishGateResponse, CustomWorldResultPreviewBlockerResponse,
CustomWorldWorkSummaryResponse, CustomWorldWorksResponse,
ExecuteCustomWorldAgentActionRequest, SendCustomWorldAgentMessageRequest,
};
use shared_kernel::build_prefixed_uuid_id;
use spacetime_client::{
CustomWorldAgentActionExecuteRecordInput,
CustomWorldAgentCheckpointRecord, CustomWorldAgentMessageRecord,
CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentOperationRecord,
CustomWorldAgentSessionCreateRecordInput, CustomWorldAgentSessionRecord,
CustomWorldDraftCardDetailRecord, CustomWorldDraftCardDetailSectionRecord,
CustomWorldDraftCardRecord, CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord,
CustomWorldProfileUpsertRecordInput, CustomWorldPublishWorldRecordInput,
CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord,
CustomWorldResultPreviewBlockerRecord, CustomWorldWorkSummaryRecord,
CustomWorldSupportedActionRecord, SpacetimeClientError,
};
@@ -386,6 +393,66 @@ pub async fn get_custom_world_agent_session(
))
}
pub async fn get_custom_world_works(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let items = state
.spacetime_client()
.list_custom_world_works(authenticated.claims().user_id().to_string())
.await
.map_err(|error| {
custom_world_error_response(&request_context, map_custom_world_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
CustomWorldWorksResponse {
items: items
.into_iter()
.map(map_custom_world_work_summary_response)
.collect(),
},
))
}
pub async fn get_custom_world_agent_card_detail(
State(state): State<AppState>,
Path((session_id, card_id)): Path<(String, String)>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
if session_id.trim().is_empty() || card_id.trim().is_empty() {
return Err(custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-agent",
"message": "sessionId and cardId are required",
})),
));
}
let card = state
.spacetime_client()
.get_custom_world_agent_card_detail(
session_id,
authenticated.claims().user_id().to_string(),
card_id,
)
.await
.map_err(|error| {
custom_world_error_response(&request_context, map_custom_world_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
CustomWorldAgentCardDetailResponse {
card: map_custom_world_draft_card_detail_response(card),
},
))
}
pub async fn submit_custom_world_agent_message(
State(state): State<AppState>,
Path(session_id): Path<String>,
@@ -569,7 +636,7 @@ pub async fn execute_custom_world_agent_action(
Path(session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<Value>, JsonRejection>,
payload: Result<Json<ExecuteCustomWorldAgentActionRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
custom_world_error_response(
@@ -581,84 +648,46 @@ pub async fn execute_custom_world_agent_action(
)
})?;
let action = payload
.get("action")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or_default();
if action != "publish_world" {
if session_id.trim().is_empty() {
return Err(custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::NOT_IMPLEMENTED).with_details(json!({
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-agent",
"message": "当前 Stage 5 仅支持 publish_world action",
"message": "sessionId is required",
})),
));
}
let profile_id = payload
.get("profileId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("agent-draft-{session_id}"));
let draft_profile = payload.get("draftProfile").cloned().ok_or_else(|| {
let action = payload.action.trim().to_string();
if action.is_empty() {
return Err(custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-agent",
"message": "action is required",
})),
));
}
let payload_json = serde_json::to_string(&payload).map_err(|error| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-agent",
"message": "publish_world 当前必须显式提供 draftProfile",
"message": format!("action payload JSON 序列化失败:{error}"),
})),
)
})?;
let setting_text = payload
.get("settingText")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.ok_or_else(|| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-agent",
"message": "publish_world 当前必须显式提供 settingText",
})),
)
})?;
let publish_result = state
let result = state
.spacetime_client()
.publish_custom_world_world(CustomWorldPublishWorldRecordInput {
session_id: session_id.clone(),
profile_id,
.execute_custom_world_agent_action(CustomWorldAgentActionExecuteRecordInput {
session_id,
owner_user_id: authenticated.claims().user_id().to_string(),
draft_profile_json: serde_json::to_string(&draft_profile).map_err(|error| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-agent",
"message": format!("draftProfile JSON 序列化失败:{error}"),
})),
)
})?,
legacy_result_profile_json: payload
.get("legacyResultProfile")
.map(serde_json::to_string)
.transpose()
.map_err(|error| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-agent",
"message": format!("legacyResultProfile JSON 序列化失败:{error}"),
})),
)
})?,
setting_text,
author_display_name: resolve_author_display_name(&authenticated),
published_at_micros: current_utc_micros(),
operation_id: build_prefixed_uuid_id("operation-"),
action,
payload_json: Some(payload_json),
submitted_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
@@ -668,15 +697,7 @@ pub async fn execute_custom_world_agent_action(
Ok(json_success_body(
Some(&request_context),
json!({
"operation": {
"operationId": format!("publish-world-{session_id}"),
"type": "publish_world",
"status": "completed",
"phaseLabel": "世界已发布",
"phaseDetail": format!("正式世界档案已写入作品库:{}。", publish_result.entry.profile_id),
"progress": 100,
"error": Value::Null,
}
"operation": map_custom_world_agent_operation_response(result.operation),
}),
))
}
@@ -722,6 +743,37 @@ fn map_custom_world_gallery_card_response(
}
}
fn map_custom_world_work_summary_response(
item: CustomWorldWorkSummaryRecord,
) -> CustomWorldWorkSummaryResponse {
CustomWorldWorkSummaryResponse {
work_id: item.work_id,
source_type: item.source_type,
status: item.status,
title: item.title,
subtitle: item.subtitle,
summary: item.summary,
cover_image_src: item.cover_image_src,
cover_render_mode: item.cover_render_mode,
cover_character_image_srcs: item.cover_character_image_srcs,
updated_at: item.updated_at,
published_at: item.published_at,
stage: item.stage,
stage_label: item.stage_label,
playable_npc_count: item.playable_npc_count,
landmark_count: item.landmark_count,
role_visual_ready_count: item.role_visual_ready_count,
role_animation_ready_count: item.role_animation_ready_count,
role_asset_summary_label: item.role_asset_summary_label,
session_id: item.session_id,
profile_id: item.profile_id,
can_resume: item.can_resume,
can_enter_world: item.can_enter_world,
blocker_count: item.blocker_count,
publish_ready: item.publish_ready,
}
}
fn map_custom_world_agent_session_response(
session: CustomWorldAgentSessionRecord,
) -> CustomWorldAgentSessionSnapshotResponse {
@@ -763,11 +815,28 @@ fn map_custom_world_agent_session_response(
.into_iter()
.map(map_custom_world_supported_action_response)
.collect(),
publish_gate: session.publish_gate.map(map_custom_world_publish_gate_response),
result_preview: session.result_preview,
updated_at: session.updated_at,
}
}
fn map_custom_world_publish_gate_response(
gate: CustomWorldPublishGateRecord,
) -> CustomWorldPublishGateResponse {
CustomWorldPublishGateResponse {
profile_id: gate.profile_id,
blockers: gate
.blockers
.into_iter()
.map(map_custom_world_result_preview_blocker_response)
.collect(),
blocker_count: gate.blocker_count,
publish_ready: gate.publish_ready,
can_enter_world: gate.can_enter_world,
}
}
fn map_custom_world_agent_message_response(
message: CustomWorldAgentMessageRecord,
) -> CustomWorldAgentMessageResponse {
@@ -812,6 +881,38 @@ fn map_custom_world_draft_card_response(
}
}
fn map_custom_world_draft_card_detail_response(
card: CustomWorldDraftCardDetailRecord,
) -> CustomWorldDraftCardDetailResponse {
CustomWorldDraftCardDetailResponse {
id: card.card_id,
kind: card.kind,
title: card.title,
sections: card
.sections
.into_iter()
.map(map_custom_world_draft_card_detail_section_response)
.collect(),
linked_ids: card.linked_ids,
locked: card.locked,
editable: card.editable,
editable_section_ids: card.editable_section_ids,
warning_messages: card.warning_messages,
asset_status: card.asset_status,
asset_status_label: card.asset_status_label,
}
}
fn map_custom_world_draft_card_detail_section_response(
section: CustomWorldDraftCardDetailSectionRecord,
) -> CustomWorldDraftCardDetailSectionResponse {
CustomWorldDraftCardDetailSectionResponse {
id: section.section_id,
label: section.label,
value: section.value,
}
}
fn map_custom_world_agent_checkpoint_response(
checkpoint: CustomWorldAgentCheckpointRecord,
) -> CustomWorldAgentCheckpointResponse {
@@ -832,6 +933,16 @@ fn map_custom_world_supported_action_response(
}
}
fn map_custom_world_result_preview_blocker_response(
blocker: CustomWorldResultPreviewBlockerRecord,
) -> CustomWorldResultPreviewBlockerResponse {
CustomWorldResultPreviewBlockerResponse {
id: blocker.id,
code: blocker.code,
message: blocker.message,
}
}
fn resolve_stream_reply_text(session: &CustomWorldAgentSessionSnapshotResponse) -> String {
session
.last_assistant_reply

View File

@@ -0,0 +1,636 @@
use std::{
fs,
path::{Path, PathBuf},
};
use axum::{
Json,
extract::{Extension, State, rejection::JsonRejection},
http::StatusCode,
response::Response,
};
use platform_llm::{LlmMessage, LlmTextRequest};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
};
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CustomWorldEntityRequest {
profile: Value,
kind: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CustomWorldSceneNpcRequest {
profile: Value,
landmark_id: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CustomWorldSceneImageRequest {
#[serde(default)]
profile_id: Option<String>,
#[serde(default)]
world_name: Option<String>,
#[serde(default)]
landmark_id: Option<String>,
#[serde(default)]
landmark_name: Option<String>,
#[serde(default)]
prompt: Option<String>,
#[serde(default)]
size: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CustomWorldCoverImageRequest {
profile: Value,
#[serde(default)]
user_prompt: Option<String>,
#[serde(default)]
size: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CustomWorldCoverUploadRequest {
#[serde(default)]
profile_id: Option<String>,
#[serde(default)]
world_name: Option<String>,
image_data_url: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct GeneratedAssetResponse {
image_src: String,
asset_id: String,
source_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
size: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
task_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
prompt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
actual_prompt: Option<String>,
}
pub async fn generate_custom_world_entity(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<CustomWorldEntityRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
custom_world_ai_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"message": error.body_text(),
})),
)
})?;
let kind = payload.kind.trim();
if !matches!(kind, "playable" | "story" | "landmark") {
return Err(custom_world_ai_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"message": "kind 必须是 playable、story 或 landmark",
})),
));
}
let entity = generate_entity_with_fallback(&state, &payload.profile, kind).await;
Ok(json_success_body(
Some(&request_context),
json!({
"kind": kind,
"entity": entity,
}),
))
}
pub async fn generate_custom_world_scene_npc(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<CustomWorldSceneNpcRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
custom_world_ai_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"message": error.body_text(),
})),
)
})?;
let landmark_id = payload.landmark_id.trim();
if landmark_id.is_empty() {
return Err(custom_world_ai_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"message": "landmarkId is required",
})),
));
}
let npc = generate_scene_npc_with_fallback(&state, &payload.profile, landmark_id).await;
Ok(json_success_body(
Some(&request_context),
json!({ "npc": npc }),
))
}
pub async fn generate_custom_world_scene_image(
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<CustomWorldSceneImageRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
custom_world_ai_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"message": error.body_text(),
})),
)
})?;
let asset = save_placeholder_asset(
"generated-custom-world-scenes",
payload
.profile_id
.as_deref()
.or(payload.world_name.as_deref())
.unwrap_or("world"),
payload
.landmark_id
.as_deref()
.or(payload.landmark_name.as_deref())
.unwrap_or("scene"),
"scene",
payload.size.as_deref().unwrap_or("1280*720"),
payload.prompt.as_deref(),
)
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
Ok(json_success_body(Some(&request_context), asset))
}
pub async fn generate_custom_world_cover_image(
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<CustomWorldCoverImageRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
custom_world_ai_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"message": error.body_text(),
})),
)
})?;
let profile = payload.profile.as_object().cloned().unwrap_or_default();
let world_name = read_string_field(&profile, "name").unwrap_or_else(|| "world".to_string());
let asset = save_placeholder_asset(
"generated-custom-world-covers",
&read_string_field(&profile, "id").unwrap_or_else(|| world_name.clone()),
"cover",
"cover",
payload.size.as_deref().unwrap_or("1600*900"),
payload.user_prompt.as_deref(),
)
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
Ok(json_success_body(Some(&request_context), asset))
}
pub async fn upload_custom_world_cover_image(
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<CustomWorldCoverUploadRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
custom_world_ai_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"message": error.body_text(),
})),
)
})?;
let parsed = parse_image_data_url(payload.image_data_url.trim()).ok_or_else(|| {
custom_world_ai_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"message": "imageDataUrl 必须是有效的图片 Data URL",
})),
)
})?;
let asset_id = format!("custom-cover-upload-{}", current_utc_millis());
let world_segment = sanitize_path_segment(
payload
.profile_id
.as_deref()
.or(payload.world_name.as_deref())
.unwrap_or("world"),
"world",
);
let relative_dir = PathBuf::from("generated-custom-world-covers")
.join(world_segment)
.join(&asset_id);
let output_dir = resolve_public_output_dir(&relative_dir)
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
fs::create_dir_all(&output_dir)
.map_err(io_error)
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
let file_name = match parsed.mime_type.as_str() {
"image/png" => "cover.png",
"image/webp" => "cover.webp",
_ => "cover.jpg",
};
fs::write(output_dir.join(file_name), parsed.bytes)
.map_err(io_error)
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
let image_src = format!(
"/{}/{}",
relative_dir.to_string_lossy().replace('\\', "/"),
file_name
);
Ok(json_success_body(
Some(&request_context),
GeneratedAssetResponse {
image_src,
asset_id,
source_type: "uploaded".to_string(),
model: None,
size: None,
task_id: None,
prompt: None,
actual_prompt: None,
},
))
}
async fn generate_entity_with_fallback(
state: &AppState,
profile: &Value,
kind: &str,
) -> Value {
let fallback = build_entity_fallback(profile, kind);
let Some(llm_client) = state.llm_client() else {
return fallback;
};
let request = LlmTextRequest::new(vec![
LlmMessage::system(
"你是 RPG 自定义世界实体生成器。只输出一个 JSON 对象,不要输出 Markdown。",
),
LlmMessage::user(
json!({
"task": "generate_custom_world_entity",
"kind": kind,
"profile": profile,
"fallback": fallback,
})
.to_string(),
),
]);
llm_client
.request_text(request)
.await
.ok()
.and_then(|response| serde_json::from_str::<Value>(response.content.trim()).ok())
.unwrap_or(fallback)
}
async fn generate_scene_npc_with_fallback(
state: &AppState,
profile: &Value,
landmark_id: &str,
) -> Value {
let fallback = build_scene_npc_fallback(profile, landmark_id);
let Some(llm_client) = state.llm_client() else {
return fallback;
};
let request = LlmTextRequest::new(vec![
LlmMessage::system(
"你是 RPG 自定义世界场景 NPC 生成器。只输出一个 JSON 对象,不要输出 Markdown。",
),
LlmMessage::user(
json!({
"task": "generate_custom_world_scene_npc",
"landmarkId": landmark_id,
"profile": profile,
"fallback": fallback,
})
.to_string(),
),
]);
llm_client
.request_text(request)
.await
.ok()
.and_then(|response| serde_json::from_str::<Value>(response.content.trim()).ok())
.unwrap_or(fallback)
}
fn build_entity_fallback(profile: &Value, kind: &str) -> Value {
let object = profile.as_object().cloned().unwrap_or_default();
let world_name = read_string_field(&object, "name").unwrap_or_else(|| "自定义世界".to_string());
match kind {
"playable" => build_role_fallback("playable", "新同行者", &world_name, 18),
"story" => build_role_fallback("story", "新场景角色", &world_name, 6),
"landmark" => build_landmark_fallback(&world_name),
_ => json!({}),
}
}
fn build_scene_npc_fallback(profile: &Value, landmark_id: &str) -> Value {
let object = profile.as_object().cloned().unwrap_or_default();
let world_name = read_string_field(&object, "name").unwrap_or_else(|| "自定义世界".to_string());
let landmark_name = object
.get("landmarks")
.and_then(Value::as_array)
.and_then(|entries| {
entries.iter().find_map(|entry| {
let object = entry.as_object()?;
(read_string_field(object, "id").as_deref() == Some(landmark_id))
.then(|| read_string_field(object, "name"))
.flatten()
})
})
.unwrap_or_else(|| "当前场景".to_string());
let mut npc = build_role_fallback("story", &format!("{landmark_name}来客"), &world_name, 6);
if let Some(object) = npc.as_object_mut() {
object.insert(
"description".to_string(),
Value::String(format!("长期活动于{landmark_name},熟悉这里的局势与暗线。")),
);
}
npc
}
fn build_role_fallback(prefix: &str, name: &str, world_name: &str, affinity: i64) -> Value {
let suffix = current_utc_millis();
json!({
"id": format!("{prefix}-{}", suffix),
"name": name,
"title": "关键角色",
"role": "关键角色",
"description": format!("围绕《{world_name}》当前主线冲突生成的新增角色。"),
"backstory": format!("他与《{world_name}》正在展开的局势存在直接牵连。"),
"personality": "谨慎、敏锐,先观察再表态。",
"motivation": "希望借玩家的介入改变当前失衡局面。",
"combatStyle": "偏向试探与控场。",
"initialAffinity": affinity,
"relationshipHooks": ["与玩家保持试探", "掌握局势暗线"],
"relations": [],
"tags": ["自定义", "生成"],
"backstoryReveal": {
"publicSummary": "一个掌握部分旧线索的关键角色。",
"chapters": [
{ "id": "surface", "title": "表层来意", "affinityRequired": 6, "teaser": "他知道这里正在发生什么。", "content": "他一直在观察这片区域的变化。", "contextSnippet": "" },
{ "id": "scar", "title": "旧事裂痕", "affinityRequired": 12, "teaser": "他与旧案有直接关联。", "content": "过往的一次事件把他绑定在这条线里。", "contextSnippet": "" },
{ "id": "hidden", "title": "隐藏执念", "affinityRequired": 18, "teaser": "他真正想推动的局面还没说出口。", "content": "他一直在寻找能撬动局面的机会。", "contextSnippet": "" },
{ "id": "final", "title": "最终底牌", "affinityRequired": 24, "teaser": "他手里还压着一张底牌。", "content": "一旦局势逼近临界点,他会出手。", "contextSnippet": "" }
]
},
"skills": [
{ "id": format!("skill-{}-1", suffix), "name": "试探起手", "summary": "先判断局势与对手意图。", "style": "试探压制" },
{ "id": format!("skill-{}-2", suffix), "name": "借势压场", "summary": "利用环境为自己制造主动权。", "style": "环境协同" },
{ "id": format!("skill-{}-3", suffix), "name": "暗线反制", "summary": "在关键节点打乱对方节奏。", "style": "后手翻盘" }
],
"initialItems": [
{ "id": format!("item-{}-1", suffix), "name": "随身兵装", "category": "武器", "quantity": 1, "rarity": "rare", "description": "常备的近身装备。", "tags": ["自定义"] },
{ "id": format!("item-{}-2", suffix), "name": "私人物件", "category": "道具", "quantity": 1, "rarity": "uncommon", "description": "可在关键时刻调用的人情或凭证。", "tags": ["自定义"] },
{ "id": format!("item-{}-3", suffix), "name": "线索残页", "category": "专属物品", "quantity": 1, "rarity": "rare", "description": "记录部分隐藏线索。", "tags": ["线索"] }
]
})
}
fn build_landmark_fallback(world_name: &str) -> Value {
let suffix = current_utc_millis();
json!({
"id": format!("landmark-{}", suffix),
"name": "新场景",
"description": format!("围绕《{world_name}》当前主线冲突扩展出的关键场景。"),
"visualDescription": "低照度、层次复杂、带有明显环境叙事痕迹。",
"dangerLevel": "medium",
"sceneNpcIds": [],
"connections": [],
"narrativeResidues": [],
})
}
fn save_placeholder_asset(
root_segment: &str,
world_segment_seed: &str,
leaf_segment_seed: &str,
file_stem: &str,
size: &str,
prompt: Option<&str>,
) -> Result<GeneratedAssetResponse, AppError> {
let asset_id = format!("{file_stem}-{}", current_utc_millis());
let relative_dir = PathBuf::from(root_segment)
.join(sanitize_path_segment(world_segment_seed, "world"))
.join(sanitize_path_segment(leaf_segment_seed, file_stem))
.join(&asset_id);
let output_dir = resolve_public_output_dir(&relative_dir)?;
fs::create_dir_all(&output_dir).map_err(io_error)?;
let file_name = format!("{file_stem}.svg");
let svg = build_placeholder_svg(size, prompt.unwrap_or(file_stem));
fs::write(output_dir.join(&file_name), svg).map_err(io_error)?;
Ok(GeneratedAssetResponse {
image_src: format!("/{}/{}", relative_dir.to_string_lossy().replace('\\', "/"), file_name),
asset_id: asset_id.clone(),
source_type: "generated".to_string(),
model: Some("rust-placeholder".to_string()),
size: Some(size.to_string()),
task_id: Some(asset_id),
prompt: prompt.map(ToOwned::to_owned),
actual_prompt: prompt.map(ToOwned::to_owned),
})
}
fn build_placeholder_svg(size: &str, label: &str) -> String {
let (width, height) = parse_size(size);
format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#0f172a"/>
<stop offset="55%" stop-color="#164e63"/>
<stop offset="100%" stop-color="#0b1120"/>
</linearGradient>
</defs>
<rect width="100%" height="100%" fill="url(#bg)"/>
<circle cx="{cx1}" cy="{cy1}" r="{r1}" fill="rgba(255,255,255,0.12)"/>
<circle cx="{cx2}" cy="{cy2}" r="{r2}" fill="rgba(125,211,252,0.14)"/>
<text x="50%" y="46%" text-anchor="middle" fill="#e2e8f0" font-size="{font_main}" font-family="Microsoft YaHei, PingFang SC, sans-serif">{title}</text>
<text x="50%" y="56%" text-anchor="middle" fill="#bae6fd" font-size="{font_sub}" font-family="Microsoft YaHei, PingFang SC, sans-serif">Rust fallback asset</text>
</svg>"##,
width = width,
height = height,
cx1 = width / 3,
cy1 = height / 3,
r1 = (width.min(height) / 7).max(24),
cx2 = width * 3 / 4,
cy2 = height / 4,
r2 = (width.min(height) / 9).max(18),
font_main = (width.min(height) / 12).max(20),
font_sub = (width.min(height) / 24).max(12),
title = escape_svg_text(label),
)
}
fn parse_size(size: &str) -> (u32, u32) {
let mut parts = size.split('*');
let width = parts
.next()
.and_then(|value| value.trim().parse::<u32>().ok())
.filter(|value| *value > 0)
.unwrap_or(1280);
let height = parts
.next()
.and_then(|value| value.trim().parse::<u32>().ok())
.filter(|value| *value > 0)
.unwrap_or(720);
(width, height)
}
fn escape_svg_text(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
fn sanitize_path_segment(value: &str, fallback: &str) -> String {
let sanitized = value
.trim()
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ('\u{4e00}'..='\u{9fff}').contains(&ch) {
ch
} else {
'-'
}
})
.collect::<String>()
.trim_matches('-')
.to_string();
if sanitized.is_empty() {
fallback.to_string()
} else {
sanitized
}
}
fn resolve_public_output_dir(relative_dir: &Path) -> Result<PathBuf, AppError> {
let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(3)
.ok_or_else(|| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message("无法解析仓库根目录")
})?;
Ok(workspace_root.join("public").join(relative_dir))
}
fn parse_image_data_url(value: &str) -> Option<ParsedImageDataUrl> {
let prefix = "data:";
let separator = ";base64,";
let body = value.strip_prefix(prefix)?;
let (mime_type, data) = body.split_once(separator)?;
let bytes = decode_base64(data)?;
Some(ParsedImageDataUrl {
mime_type: mime_type.to_string(),
bytes,
})
}
fn decode_base64(value: &str) -> Option<Vec<u8>> {
let cleaned = value.trim().replace(char::is_whitespace, "");
let mut output = Vec::with_capacity(cleaned.len() * 3 / 4);
let mut buffer = 0u32;
let mut bits = 0u8;
for byte in cleaned.bytes() {
let value = match byte {
b'A'..=b'Z' => byte - b'A',
b'a'..=b'z' => byte - b'a' + 26,
b'0'..=b'9' => byte - b'0' + 52,
b'+' => 62,
b'/' => 63,
b'=' => break,
_ => return None,
} as u32;
buffer = (buffer << 6) | value;
bits += 6;
while bits >= 8 {
bits -= 8;
output.push(((buffer >> bits) & 0xFF) as u8);
}
}
Some(output)
}
fn read_string_field(object: &Map<String, Value>, key: &str) -> Option<String> {
object
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn current_utc_millis() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after unix epoch");
i64::try_from(duration.as_millis()).expect("current unix millis should fit in i64")
}
fn io_error(error: std::io::Error) -> AppError {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "custom-world-ai",
"message": format!("文件写入失败:{error}"),
}))
}
fn custom_world_ai_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}
struct ParsedImageDataUrl {
mime_type: String,
bytes: Vec<u8>,
}

View File

@@ -8,6 +8,7 @@ mod auth_session;
mod auth_sessions;
mod config;
mod custom_world;
mod custom_world_ai;
mod error_middleware;
mod health;
mod http_error;

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,

View File

@@ -0,0 +1,28 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct CustomWorldAgentActionExecuteInput {
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,
}
impl __sdk::InModule for CustomWorldAgentActionExecuteInput {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,26 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use super::custom_world_agent_operation_snapshot_type::CustomWorldAgentOperationSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct CustomWorldAgentActionExecuteResult {
pub ok: bool,
pub operation: Option::<CustomWorldAgentOperationSnapshot>,
pub error_message: Option::<String>,
}
impl __sdk::InModule for CustomWorldAgentActionExecuteResult {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,25 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct CustomWorldAgentCardDetailGetInput {
pub session_id: String,
pub owner_user_id: String,
pub card_id: String,
}
impl __sdk::InModule for CustomWorldAgentCardDetailGetInput {
type Module = super::RemoteModule;
}

View File

@@ -31,6 +31,7 @@ pub struct CustomWorldAgentSessionSnapshot {
pub lock_state_json: Option::<String>,
pub draft_profile_json: Option::<String>,
pub last_assistant_reply: Option::<String>,
pub publish_gate_json: Option::<String>,
pub result_preview_json: Option::<String>,
pub pending_clarifications_json: String,
pub quality_findings_json: String,
@@ -38,6 +39,7 @@ pub struct CustomWorldAgentSessionSnapshot {
pub recommended_replies_json: String,
pub asset_coverage_json: String,
pub checkpoints_json: String,
pub supported_actions_json: String,
pub messages: Vec::<CustomWorldAgentMessageSnapshot>,
pub draft_cards: Vec::<CustomWorldDraftCardSnapshot>,
pub operations: Vec::<CustomWorldAgentOperationSnapshot>,

View File

@@ -28,6 +28,7 @@ pub struct CustomWorldAgentSession {
pub lock_state_json: Option::<String>,
pub draft_profile_json: Option::<String>,
pub last_assistant_reply: Option::<String>,
pub publish_gate_json: Option::<String>,
pub result_preview_json: Option::<String>,
pub pending_clarifications_json: String,
pub quality_findings_json: String,
@@ -63,6 +64,7 @@ pub struct CustomWorldAgentSessionCols {
pub lock_state_json: __sdk::__query_builder::Col<CustomWorldAgentSession, Option::<String>>,
pub draft_profile_json: __sdk::__query_builder::Col<CustomWorldAgentSession, Option::<String>>,
pub last_assistant_reply: __sdk::__query_builder::Col<CustomWorldAgentSession, Option::<String>>,
pub publish_gate_json: __sdk::__query_builder::Col<CustomWorldAgentSession, Option::<String>>,
pub result_preview_json: __sdk::__query_builder::Col<CustomWorldAgentSession, Option::<String>>,
pub pending_clarifications_json: __sdk::__query_builder::Col<CustomWorldAgentSession, String>,
pub quality_findings_json: __sdk::__query_builder::Col<CustomWorldAgentSession, String>,
@@ -92,6 +94,7 @@ impl __sdk::__query_builder::HasCols for CustomWorldAgentSession {
lock_state_json: __sdk::__query_builder::Col::new(table_name, "lock_state_json"),
draft_profile_json: __sdk::__query_builder::Col::new(table_name, "draft_profile_json"),
last_assistant_reply: __sdk::__query_builder::Col::new(table_name, "last_assistant_reply"),
publish_gate_json: __sdk::__query_builder::Col::new(table_name, "publish_gate_json"),
result_preview_json: __sdk::__query_builder::Col::new(table_name, "result_preview_json"),
pending_clarifications_json: __sdk::__query_builder::Col::new(table_name, "pending_clarifications_json"),
quality_findings_json: __sdk::__query_builder::Col::new(table_name, "quality_findings_json"),

View File

@@ -0,0 +1,26 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use super::custom_world_draft_card_detail_snapshot_type::CustomWorldDraftCardDetailSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct CustomWorldDraftCardDetailResult {
pub ok: bool,
pub card: Option::<CustomWorldDraftCardDetailSnapshot>,
pub error_message: Option::<String>,
}
impl __sdk::InModule for CustomWorldDraftCardDetailResult {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,25 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct CustomWorldDraftCardDetailSectionSnapshot {
pub section_id: String,
pub label: String,
pub value: String,
}
impl __sdk::InModule for CustomWorldDraftCardDetailSectionSnapshot {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,36 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use super::rpg_agent_draft_card_kind_type::RpgAgentDraftCardKind;
use super::custom_world_role_asset_status_type::CustomWorldRoleAssetStatus;
use super::custom_world_draft_card_detail_section_snapshot_type::CustomWorldDraftCardDetailSectionSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct CustomWorldDraftCardDetailSnapshot {
pub card_id: String,
pub kind: RpgAgentDraftCardKind,
pub title: String,
pub sections: Vec::<CustomWorldDraftCardDetailSectionSnapshot>,
pub linked_ids_json: String,
pub locked: bool,
pub editable: bool,
pub editable_section_ids_json: String,
pub warning_messages_json: String,
pub asset_status: Option::<CustomWorldRoleAssetStatus>,
pub asset_status_label: Option::<String>,
}
impl __sdk::InModule for CustomWorldDraftCardDetailSnapshot {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,47 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use super::rpg_agent_stage_type::RpgAgentStage;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct CustomWorldWorkSummarySnapshot {
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_json: String,
pub updated_at_micros: i64,
pub published_at_micros: Option::<i64>,
pub stage: Option::<RpgAgentStage>,
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,
}
impl __sdk::InModule for CustomWorldWorkSummarySnapshot {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,23 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct CustomWorldWorksListInput {
pub owner_user_id: String,
}
impl __sdk::InModule for CustomWorldWorksListInput {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,26 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use super::custom_world_work_summary_snapshot_type::CustomWorldWorkSummarySnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct CustomWorldWorksListResult {
pub ok: bool,
pub items: Vec::<CustomWorldWorkSummarySnapshot>,
pub error_message: Option::<String>,
}
impl __sdk::InModule for CustomWorldWorksListResult {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,58 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use super::custom_world_agent_action_execute_input_type::CustomWorldAgentActionExecuteInput;
use super::custom_world_agent_action_execute_result_type::CustomWorldAgentActionExecuteResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct ExecuteCustomWorldAgentActionArgs {
pub input: CustomWorldAgentActionExecuteInput,
}
impl __sdk::InModule for ExecuteCustomWorldAgentActionArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `execute_custom_world_agent_action`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait execute_custom_world_agent_action {
fn execute_custom_world_agent_action(&self, input: CustomWorldAgentActionExecuteInput,
) {
self.execute_custom_world_agent_action_then(input, |_, _| {});
}
fn execute_custom_world_agent_action_then(
&self,
input: CustomWorldAgentActionExecuteInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<CustomWorldAgentActionExecuteResult, __sdk::InternalError>) + Send + 'static,
);
}
impl execute_custom_world_agent_action for super::RemoteProcedures {
fn execute_custom_world_agent_action_then(
&self,
input: CustomWorldAgentActionExecuteInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<CustomWorldAgentActionExecuteResult, __sdk::InternalError>) + Send + 'static,
) {
self.imp.invoke_procedure_with_callback::<_, CustomWorldAgentActionExecuteResult>(
"execute_custom_world_agent_action",
ExecuteCustomWorldAgentActionArgs { input, },
__callback,
);
}
}

View File

@@ -0,0 +1,58 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use super::custom_world_agent_card_detail_get_input_type::CustomWorldAgentCardDetailGetInput;
use super::custom_world_draft_card_detail_result_type::CustomWorldDraftCardDetailResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct GetCustomWorldAgentCardDetailArgs {
pub input: CustomWorldAgentCardDetailGetInput,
}
impl __sdk::InModule for GetCustomWorldAgentCardDetailArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `get_custom_world_agent_card_detail`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait get_custom_world_agent_card_detail {
fn get_custom_world_agent_card_detail(&self, input: CustomWorldAgentCardDetailGetInput,
) {
self.get_custom_world_agent_card_detail_then(input, |_, _| {});
}
fn get_custom_world_agent_card_detail_then(
&self,
input: CustomWorldAgentCardDetailGetInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<CustomWorldDraftCardDetailResult, __sdk::InternalError>) + Send + 'static,
);
}
impl get_custom_world_agent_card_detail for super::RemoteProcedures {
fn get_custom_world_agent_card_detail_then(
&self,
input: CustomWorldAgentCardDetailGetInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<CustomWorldDraftCardDetailResult, __sdk::InternalError>) + Send + 'static,
) {
self.imp.invoke_procedure_with_callback::<_, CustomWorldDraftCardDetailResult>(
"get_custom_world_agent_card_detail",
GetCustomWorldAgentCardDetailArgs { input, },
__callback,
);
}
}

View File

@@ -0,0 +1,58 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use super::custom_world_works_list_input_type::CustomWorldWorksListInput;
use super::custom_world_works_list_result_type::CustomWorldWorksListResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct ListCustomWorldWorksArgs {
pub input: CustomWorldWorksListInput,
}
impl __sdk::InModule for ListCustomWorldWorksArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `list_custom_world_works`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait list_custom_world_works {
fn list_custom_world_works(&self, input: CustomWorldWorksListInput,
) {
self.list_custom_world_works_then(input, |_, _| {});
}
fn list_custom_world_works_then(
&self,
input: CustomWorldWorksListInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<CustomWorldWorksListResult, __sdk::InternalError>) + Send + 'static,
);
}
impl list_custom_world_works for super::RemoteProcedures {
fn list_custom_world_works_then(
&self,
input: CustomWorldWorksListInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<CustomWorldWorksListResult, __sdk::InternalError>) + Send + 'static,
) {
self.imp.invoke_procedure_with_callback::<_, CustomWorldWorksListResult>(
"list_custom_world_works",
ListCustomWorldWorksArgs { input, },
__callback,
);
}
}

View File

@@ -60,6 +60,9 @@ pub mod chapter_progression_procedure_result_type;
pub mod chapter_progression_snapshot_type;
pub mod combat_outcome_type;
pub mod consume_inventory_item_input_type;
pub mod custom_world_agent_action_execute_input_type;
pub mod custom_world_agent_action_execute_result_type;
pub mod custom_world_agent_card_detail_get_input_type;
pub mod custom_world_agent_message_type;
pub mod custom_world_agent_message_snapshot_type;
pub mod custom_world_agent_message_submit_input_type;
@@ -73,6 +76,9 @@ pub mod custom_world_agent_session_get_input_type;
pub mod custom_world_agent_session_procedure_result_type;
pub mod custom_world_agent_session_snapshot_type;
pub mod custom_world_draft_card_type;
pub mod custom_world_draft_card_detail_result_type;
pub mod custom_world_draft_card_detail_section_snapshot_type;
pub mod custom_world_draft_card_detail_snapshot_type;
pub mod custom_world_draft_card_snapshot_type;
pub mod custom_world_gallery_detail_input_type;
pub mod custom_world_gallery_entry_type;
@@ -98,6 +104,9 @@ pub mod custom_world_role_asset_status_type;
pub mod custom_world_session_type;
pub mod custom_world_session_status_type;
pub mod custom_world_theme_mode_type;
pub mod custom_world_work_summary_snapshot_type;
pub mod custom_world_works_list_input_type;
pub mod custom_world_works_list_result_type;
pub mod equip_inventory_item_input_type;
pub mod grant_inventory_item_input_type;
pub mod inventory_container_kind_type;
@@ -301,9 +310,11 @@ pub mod create_ai_task_and_return_procedure;
pub mod create_battle_state_and_return_procedure;
pub mod create_custom_world_agent_session_procedure;
pub mod delete_runtime_snapshot_and_return_procedure;
pub mod execute_custom_world_agent_action_procedure;
pub mod fail_ai_task_and_return_procedure;
pub mod get_battle_state_procedure;
pub mod get_chapter_progression_procedure;
pub mod get_custom_world_agent_card_detail_procedure;
pub mod get_custom_world_agent_operation_procedure;
pub mod get_custom_world_agent_session_procedure;
pub mod get_custom_world_gallery_detail_procedure;
@@ -318,6 +329,7 @@ pub mod get_story_session_state_procedure;
pub mod grant_player_progression_experience_and_return_procedure;
pub mod list_custom_world_gallery_entries_procedure;
pub mod list_custom_world_profiles_procedure;
pub mod list_custom_world_works_procedure;
pub mod list_platform_browse_history_procedure;
pub mod list_profile_save_archives_procedure;
pub mod list_profile_wallet_ledger_procedure;
@@ -387,6 +399,9 @@ pub use chapter_progression_procedure_result_type::ChapterProgressionProcedureRe
pub use chapter_progression_snapshot_type::ChapterProgressionSnapshot;
pub use combat_outcome_type::CombatOutcome;
pub use consume_inventory_item_input_type::ConsumeInventoryItemInput;
pub use custom_world_agent_action_execute_input_type::CustomWorldAgentActionExecuteInput;
pub use custom_world_agent_action_execute_result_type::CustomWorldAgentActionExecuteResult;
pub use custom_world_agent_card_detail_get_input_type::CustomWorldAgentCardDetailGetInput;
pub use custom_world_agent_message_type::CustomWorldAgentMessage;
pub use custom_world_agent_message_snapshot_type::CustomWorldAgentMessageSnapshot;
pub use custom_world_agent_message_submit_input_type::CustomWorldAgentMessageSubmitInput;
@@ -400,6 +415,9 @@ pub use custom_world_agent_session_get_input_type::CustomWorldAgentSessionGetInp
pub use custom_world_agent_session_procedure_result_type::CustomWorldAgentSessionProcedureResult;
pub use custom_world_agent_session_snapshot_type::CustomWorldAgentSessionSnapshot;
pub use custom_world_draft_card_type::CustomWorldDraftCard;
pub use custom_world_draft_card_detail_result_type::CustomWorldDraftCardDetailResult;
pub use custom_world_draft_card_detail_section_snapshot_type::CustomWorldDraftCardDetailSectionSnapshot;
pub use custom_world_draft_card_detail_snapshot_type::CustomWorldDraftCardDetailSnapshot;
pub use custom_world_draft_card_snapshot_type::CustomWorldDraftCardSnapshot;
pub use custom_world_gallery_detail_input_type::CustomWorldGalleryDetailInput;
pub use custom_world_gallery_entry_type::CustomWorldGalleryEntry;
@@ -425,6 +443,9 @@ pub use custom_world_role_asset_status_type::CustomWorldRoleAssetStatus;
pub use custom_world_session_type::CustomWorldSession;
pub use custom_world_session_status_type::CustomWorldSessionStatus;
pub use custom_world_theme_mode_type::CustomWorldThemeMode;
pub use custom_world_work_summary_snapshot_type::CustomWorldWorkSummarySnapshot;
pub use custom_world_works_list_input_type::CustomWorldWorksListInput;
pub use custom_world_works_list_result_type::CustomWorldWorksListResult;
pub use equip_inventory_item_input_type::EquipInventoryItemInput;
pub use grant_inventory_item_input_type::GrantInventoryItemInput;
pub use inventory_container_kind_type::InventoryContainerKind;
@@ -628,9 +649,11 @@ pub use create_ai_task_and_return_procedure::create_ai_task_and_return;
pub use create_battle_state_and_return_procedure::create_battle_state_and_return;
pub use create_custom_world_agent_session_procedure::create_custom_world_agent_session;
pub use delete_runtime_snapshot_and_return_procedure::delete_runtime_snapshot_and_return;
pub use execute_custom_world_agent_action_procedure::execute_custom_world_agent_action;
pub use fail_ai_task_and_return_procedure::fail_ai_task_and_return;
pub use get_battle_state_procedure::get_battle_state;
pub use get_chapter_progression_procedure::get_chapter_progression;
pub use get_custom_world_agent_card_detail_procedure::get_custom_world_agent_card_detail;
pub use get_custom_world_agent_operation_procedure::get_custom_world_agent_operation;
pub use get_custom_world_agent_session_procedure::get_custom_world_agent_session;
pub use get_custom_world_gallery_detail_procedure::get_custom_world_gallery_detail;
@@ -645,6 +668,7 @@ pub use get_story_session_state_procedure::get_story_session_state;
pub use grant_player_progression_experience_and_return_procedure::grant_player_progression_experience_and_return;
pub use list_custom_world_gallery_entries_procedure::list_custom_world_gallery_entries;
pub use list_custom_world_profiles_procedure::list_custom_world_profiles;
pub use list_custom_world_works_procedure::list_custom_world_works;
pub use list_platform_browse_history_procedure::list_platform_browse_history;
pub use list_profile_save_archives_procedure::list_profile_save_archives;
pub use list_profile_wallet_ledger_procedure::list_profile_wallet_ledger;

View File

@@ -21,4 +21,5 @@ module-quest = { path = "../module-quest", default-features = false, features =
module-runtime = { path = "../module-runtime", default-features = false, features = ["spacetime-types"] }
module-runtime-item = { path = "../module-runtime-item", default-features = false, features = ["spacetime-types"] }
module-story = { path = "../module-story", default-features = false, features = ["spacetime-types"] }
shared-kernel = { path = "../shared-kernel" }
spacetimedb = { workspace = true, features = ["unstable"] }

View File

@@ -22,6 +22,7 @@
2. 继续设计表、reducer、view 的聚合方式
3. 接入身份 claims 透传
4. 在当前 scaffold 基础上接入 publish / dev 循环
5.`M7` 收口阶段拆分过大的 `src/lib.rs`,按 `runtime``gameplay/*``custom_world``asset_metadata``ai` 等业务与 SpacetimeDB 聚合层次重组目录,避免主工程 crate 回退成单大包
当前已落地:

File diff suppressed because it is too large Load Diff