fix: sync rust api-server runtime and bindings

This commit is contained in:
2026-04-23 20:32:06 +08:00
parent 9d25a47b23
commit 27e84c46a0
82 changed files with 9534 additions and 2222 deletions

2
server-rs/Cargo.lock generated
View File

@@ -86,6 +86,7 @@ dependencies = [
"module-custom-world",
"module-inventory",
"module-npc",
"module-puzzle",
"module-runtime",
"module-runtime-item",
"module-runtime-story-compat",
@@ -2666,6 +2667,7 @@ dependencies = [
"module-runtime",
"module-runtime-item",
"module-story",
"serde",
"serde_json",
"shared-kernel",
"spacetimedb-sdk",

View File

@@ -21,6 +21,7 @@ module-combat = { path = "../module-combat" }
module-custom-world = { path = "../module-custom-world" }
module-inventory = { path = "../module-inventory" }
module-npc = { path = "../module-npc" }
module-puzzle = { path = "../module-puzzle" }
module-runtime = { path = "../module-runtime" }
module-runtime-story-compat = { path = "../module-runtime-story-compat" }
module-runtime-item = { path = "../module-runtime-item" }

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,10 @@ use tower_http::{
use tracing::{Level, Span, error, info, info_span, warn};
use crate::{
admin::{
admin_console_page, admin_debug_http, admin_login, admin_me, admin_overview,
require_admin_auth,
},
ai_tasks::{
append_ai_text_chunk, attach_ai_result_reference, cancel_ai_task, complete_ai_stage,
complete_ai_task, create_ai_task, fail_ai_task, start_ai_task, start_ai_task_stage,
@@ -26,10 +30,11 @@ use crate::{
require_bearer_auth,
},
auth_me::auth_me,
auth_public_user::get_public_user_by_code,
auth_sessions::auth_sessions,
big_fish::{
create_big_fish_session, execute_big_fish_action, get_big_fish_run, get_big_fish_session,
start_big_fish_run, stream_big_fish_message, submit_big_fish_input,
get_big_fish_works, start_big_fish_run, stream_big_fish_message, submit_big_fish_input,
submit_big_fish_message,
},
character_animation_assets::{
@@ -44,8 +49,9 @@ use crate::{
create_custom_world_agent_session, delete_custom_world_library_profile,
execute_custom_world_agent_action, get_custom_world_agent_card_detail,
get_custom_world_agent_operation, get_custom_world_agent_session,
get_custom_world_gallery_detail, get_custom_world_library, get_custom_world_library_detail,
get_custom_world_works, list_custom_world_gallery, publish_custom_world_library_profile,
get_custom_world_gallery_detail, get_custom_world_gallery_detail_by_code,
get_custom_world_library, get_custom_world_library_detail, get_custom_world_works,
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,
},
@@ -105,6 +111,29 @@ pub fn build_router(state: AppState) -> Router {
let slow_request_threshold_ms = state.config.slow_request_threshold_ms;
Router::new()
.route("/admin", get(admin_console_page))
.route("/admin/api/login", post(admin_login))
.route(
"/admin/api/me",
get(admin_me).route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/admin/api/overview",
get(admin_overview).route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/admin/api/debug/http",
post(admin_debug_http).route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/healthz",
get(|Extension(request_context): Extension<_>| async move {
@@ -126,6 +155,10 @@ pub fn build_router(state: AppState) -> Router {
)),
)
.route("/api/auth/login-options", get(auth_login_options))
.route(
"/api/auth/public-users/by-code/{code}",
get(get_public_user_by_code),
)
.route(
"/generated-character-drafts/{*path}",
get(proxy_generated_character_drafts),
@@ -391,6 +424,10 @@ pub fn build_router(state: AppState) -> Router {
"/api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}",
get(get_custom_world_gallery_detail),
)
.route(
"/api/runtime/custom-world-gallery/by-code/{code}",
get(get_custom_world_gallery_detail_by_code),
)
.route(
"/api/runtime/custom-world/agent/sessions",
post(create_custom_world_agent_session).route_layer(middleware::from_fn_with_state(
@@ -482,6 +519,13 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/works",
get(get_big_fish_works).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/sessions/{session_id}/runs",
post(start_big_fish_run).route_layer(middleware::from_fn_with_state(
@@ -2905,4 +2949,168 @@ mod tests {
.is_some_and(|value| value.contains("Max-Age=0"))
);
}
#[tokio::test]
async fn admin_login_returns_token_when_configured() {
let mut config = AppConfig::default();
config.admin_username = Some("root".to_string());
config.admin_password = Some("secret123".to_string());
let app = build_router(AppState::new(config).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/api/login")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"username": "root",
"password": "secret123"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert!(payload["token"].as_str().is_some());
assert_eq!(payload["admin"]["username"], Value::String("root".to_string()));
}
#[tokio::test]
async fn admin_route_rejects_regular_user_token() {
let mut config = AppConfig::default();
config.admin_username = Some("root".to_string());
config.admin_password = Some("secret123".to_string());
let state = AppState::new(config).expect("state should build");
let app = build_router(state.clone());
let login_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/entry")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"username": "guest_admin_forbidden",
"password": "secret123"
})
.to_string(),
))
.expect("login request should build"),
)
.await
.expect("login should succeed");
let login_body = login_response
.into_body()
.collect()
.await
.expect("login body should collect")
.to_bytes();
let login_payload: Value =
serde_json::from_slice(&login_body).expect("login payload should be json");
let access_token = login_payload["token"]
.as_str()
.expect("token should exist")
.to_string();
let response = app
.oneshot(
Request::builder()
.uri("/admin/api/me")
.header("authorization", format!("Bearer {access_token}"))
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn admin_debug_http_can_probe_healthz() {
let mut config = AppConfig::default();
config.admin_username = Some("root".to_string());
config.admin_password = Some("secret123".to_string());
let app = build_router(AppState::new(config).expect("state should build"));
let login_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/api/login")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"username": "root",
"password": "secret123"
})
.to_string(),
))
.expect("login request should build"),
)
.await
.expect("login should succeed");
let login_body = login_response
.into_body()
.collect()
.await
.expect("login body should collect")
.to_bytes();
let login_payload: Value =
serde_json::from_slice(&login_body).expect("login payload should be json");
let access_token = login_payload["token"]
.as_str()
.expect("token should exist")
.to_string();
let debug_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/api/debug/http")
.header("authorization", format!("Bearer {access_token}"))
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"method": "GET",
"path": "/healthz",
"headers": [],
"body": ""
})
.to_string(),
))
.expect("debug request should build"),
)
.await
.expect("debug request should succeed");
assert_eq!(debug_response.status(), StatusCode::OK);
let body = debug_response
.into_body()
.collect()
.await
.expect("debug body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("debug payload should be json");
assert_eq!(payload["status"], Value::Number(200.into()));
}
}

View File

@@ -3,11 +3,12 @@ use axum::{
extract::{Extension, State},
http::StatusCode,
};
use shared_contracts::auth::{AuthMeResponse, AuthUserPayload, build_available_login_methods};
use shared_contracts::auth::{AuthMeResponse, build_available_login_methods};
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
api_response::json_success_body, auth::AuthenticatedAccessToken,
auth_payload::map_auth_user_payload, http_error::AppError, request_context::RequestContext,
state::AppState,
};
pub async fn auth_me(
@@ -30,15 +31,7 @@ pub async fn auth_me(
Ok(json_success_body(
Some(&request_context),
AuthMeResponse {
user: AuthUserPayload {
id: user.user.id,
username: user.user.username,
display_name: user.user.display_name,
phone_number_masked: user.user.phone_number_masked,
login_method: user.user.login_method.as_str().to_string(),
binding_status: user.user.binding_status.as_str().to_string(),
wechat_bound: user.user.wechat_bound,
},
user: map_auth_user_payload(user.user),
available_login_methods: build_available_login_methods(
state.config.sms_auth_enabled,
state.config.wechat_auth_enabled,

View File

@@ -0,0 +1,23 @@
use module_auth::AuthUser;
use shared_contracts::auth::{AuthUserPayload, PublicUserSummaryPayload};
pub fn map_auth_user_payload(user: AuthUser) -> AuthUserPayload {
AuthUserPayload {
id: user.id,
public_user_code: user.public_user_code,
username: user.username,
display_name: user.display_name,
phone_number_masked: user.phone_number_masked,
login_method: user.login_method.as_str().to_string(),
binding_status: user.binding_status.as_str().to_string(),
wechat_bound: user.wechat_bound,
}
}
pub fn map_public_user_summary_payload(user: AuthUser) -> PublicUserSummaryPayload {
PublicUserSummaryPayload {
id: user.id,
public_user_code: user.public_user_code,
display_name: user.display_name,
}
}

View File

@@ -0,0 +1,50 @@
use axum::{
Json,
extract::{Extension, Path, State},
http::StatusCode,
};
use shared_contracts::auth::PublicUserSearchResponse;
use crate::{
api_response::json_success_body,
auth_payload::map_public_user_summary_payload,
http_error::AppError,
request_context::RequestContext,
state::AppState,
};
pub async fn get_public_user_by_code(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Path(code): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
let user = state
.password_entry_service()
.get_user_by_public_user_code(&code)
.map_err(map_public_user_search_error)?
.ok_or_else(|| {
AppError::from_status(StatusCode::NOT_FOUND).with_message("未找到对应叙世号用户")
})?;
Ok(json_success_body(
Some(&request_context),
PublicUserSearchResponse {
user: map_public_user_summary_payload(user.user),
},
))
}
fn map_public_user_search_error(error: module_auth::PasswordEntryError) -> AppError {
match error {
module_auth::PasswordEntryError::InvalidPublicUserCode => {
AppError::from_status(StatusCode::BAD_REQUEST).with_message("叙世号格式不正确")
}
module_auth::PasswordEntryError::Store(_)
| module_auth::PasswordEntryError::PasswordHash(_)
| module_auth::PasswordEntryError::InvalidUsername
| module_auth::PasswordEntryError::InvalidPasswordLength
| module_auth::PasswordEntryError::InvalidCredentials => {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
}
}
}

View File

@@ -26,7 +26,8 @@ use shared_contracts::big_fish::{
BigFishVector2Response, CreateBigFishSessionRequest, ExecuteBigFishActionRequest,
SendBigFishMessageRequest, SubmitBigFishInputRequest,
};
use shared_kernel::build_prefixed_uuid_id;
use shared_contracts::big_fish_works::{BigFishWorkSummaryResponse, BigFishWorksResponse};
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
use spacetime_client::{
BigFishAgentMessageRecord, BigFishAnchorItemRecord, BigFishAnchorPackRecord,
BigFishAssetCoverageRecord, BigFishAssetGenerateRecordInput, BigFishAssetSlotRecord,
@@ -34,7 +35,7 @@ use spacetime_client::{
BigFishMessageSubmitRecordInput, BigFishRunInputSubmitRecordInput, BigFishRunStartRecordInput,
BigFishRuntimeEntityRecord, BigFishRuntimeParamsRecord, BigFishRuntimeRecord,
BigFishSessionCreateRecordInput, BigFishSessionRecord, BigFishVector2Record,
SpacetimeClientError,
BigFishWorkSummaryRecord, SpacetimeClientError,
};
use tokio::time::sleep;
@@ -107,6 +108,30 @@ pub async fn get_big_fish_session(
))
}
pub async fn get_big_fish_works(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let items = state
.spacetime_client()
.list_big_fish_works(authenticated.claims().user_id().to_string())
.await
.map_err(|error| {
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
BigFishWorksResponse {
items: items
.into_iter()
.map(map_big_fish_work_summary_response)
.collect(),
},
))
}
pub async fn submit_big_fish_message(
State(state): State<AppState>,
Path(session_id): Path<String>,
@@ -610,6 +635,26 @@ fn map_big_fish_runtime_response(run: BigFishRuntimeRecord) -> BigFishRuntimeSna
}
}
fn map_big_fish_work_summary_response(
item: BigFishWorkSummaryRecord,
) -> BigFishWorkSummaryResponse {
BigFishWorkSummaryResponse {
work_id: item.work_id,
source_session_id: item.source_session_id,
title: item.title,
subtitle: item.subtitle,
summary: item.summary,
cover_image_src: item.cover_image_src,
status: item.status,
updated_at: current_timestamp_micros_to_string(item.updated_at_micros),
publish_ready: item.publish_ready,
level_count: item.level_count,
level_main_image_ready_count: item.level_main_image_ready_count,
level_motion_ready_count: item.level_motion_ready_count,
background_ready: item.background_ready,
}
}
fn map_big_fish_entity_response(
entity: BigFishRuntimeEntityRecord,
) -> BigFishRuntimeEntityResponse {
@@ -1475,3 +1520,7 @@ fn current_utc_micros() -> i64 {
.expect("system clock should be after unix epoch");
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
}
fn current_timestamp_micros_to_string(value: i64) -> String {
format_timestamp_micros(value)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,9 @@ pub struct AppConfig {
pub bind_host: String,
pub bind_port: u16,
pub log_filter: String,
pub admin_username: Option<String>,
pub admin_password: Option<String>,
pub admin_token_ttl_seconds: u64,
pub internal_api_secret: Option<String>,
pub jwt_issuer: String,
pub jwt_secret: String,
@@ -78,6 +81,13 @@ pub struct AppConfig {
pub dashscope_base_url: String,
pub dashscope_api_key: Option<String>,
pub dashscope_image_request_timeout_ms: u64,
pub ark_character_video_base_url: String,
pub ark_character_video_api_key: Option<String>,
pub ark_character_video_request_timeout_ms: u64,
pub ark_character_video_model: String,
pub character_animation_ffmpeg_path: String,
pub character_animation_ffprobe_path: String,
pub character_animation_frame_extract_timeout_ms: u64,
pub slow_request_threshold_ms: u64,
}
@@ -87,6 +97,9 @@ impl Default for AppConfig {
bind_host: "127.0.0.1".to_string(),
bind_port: 3000,
log_filter: "info,tower_http=info".to_string(),
admin_username: None,
admin_password: None,
admin_token_ttl_seconds: 4 * 60 * 60,
internal_api_secret: Some(DEFAULT_INTERNAL_API_SECRET.to_string()),
jwt_issuer: "https://auth.genarrative.local".to_string(),
jwt_secret: "genarrative-dev-secret".to_string(),
@@ -151,6 +164,13 @@ impl Default for AppConfig {
dashscope_base_url: "https://dashscope.aliyuncs.com/api/v1".to_string(),
dashscope_api_key: None,
dashscope_image_request_timeout_ms: 150_000,
ark_character_video_base_url: DEFAULT_ARK_BASE_URL.to_string(),
ark_character_video_api_key: None,
ark_character_video_request_timeout_ms: 420_000,
ark_character_video_model: "doubao-seedance-2-0-fast-260128".to_string(),
character_animation_ffmpeg_path: "ffmpeg".to_string(),
character_animation_ffprobe_path: "ffprobe".to_string(),
character_animation_frame_extract_timeout_ms: 120_000,
slow_request_threshold_ms: 1_000,
}
}
@@ -182,6 +202,14 @@ impl AppConfig {
config.log_filter = log_filter;
}
config.admin_username = read_first_non_empty_env(&["GENARRATIVE_ADMIN_USERNAME"]);
config.admin_password = read_first_non_empty_env(&["GENARRATIVE_ADMIN_PASSWORD"]);
if let Some(admin_token_ttl_seconds) =
read_first_duration_seconds_env(&["GENARRATIVE_ADMIN_TOKEN_TTL_SECONDS"])
{
config.admin_token_ttl_seconds = admin_token_ttl_seconds;
}
config.internal_api_secret = read_first_non_empty_env(&["GENARRATIVE_INTERNAL_API_SECRET"]);
if let Some(jwt_issuer) =
@@ -430,6 +458,56 @@ impl AppConfig {
config.dashscope_image_request_timeout_ms = dashscope_image_request_timeout_ms;
}
if let Some(ark_character_video_base_url) = read_first_non_empty_env(&[
"ARK_CHARACTER_VIDEO_BASE_URL",
"ARK_BASE_URL",
"GENARRATIVE_LLM_BASE_URL",
"LLM_BASE_URL",
]) {
config.ark_character_video_base_url = ark_character_video_base_url;
}
config.ark_character_video_api_key = read_first_non_empty_env(&[
"ARK_CHARACTER_VIDEO_API_KEY",
"ARK_API_KEY",
"GENARRATIVE_LLM_API_KEY",
"LLM_API_KEY",
]);
if let Some(ark_character_video_request_timeout_ms) = read_first_positive_u64_env(&[
"ARK_CHARACTER_VIDEO_REQUEST_TIMEOUT_MS",
"DASHSCOPE_CHARACTER_VIDEO_REQUEST_TIMEOUT_MS",
]) {
config.ark_character_video_request_timeout_ms =
ark_character_video_request_timeout_ms;
}
if let Some(ark_character_video_model) = read_first_non_empty_env(&[
"ARK_CHARACTER_VIDEO_MODEL",
"DASHSCOPE_CHARACTER_VIDEO_MODEL",
]) {
config.ark_character_video_model = ark_character_video_model;
}
if let Some(character_animation_ffmpeg_path) =
read_first_non_empty_env(&["CHARACTER_ANIMATION_FFMPEG_PATH"])
{
config.character_animation_ffmpeg_path = character_animation_ffmpeg_path;
}
if let Some(character_animation_ffprobe_path) =
read_first_non_empty_env(&["CHARACTER_ANIMATION_FFPROBE_PATH"])
{
config.character_animation_ffprobe_path = character_animation_ffprobe_path;
}
if let Some(character_animation_frame_extract_timeout_ms) = read_first_positive_u64_env(&[
"CHARACTER_ANIMATION_FRAME_EXTRACT_TIMEOUT_MS",
]) {
config.character_animation_frame_extract_timeout_ms =
character_animation_frame_extract_timeout_ms;
}
if let Some(slow_request_threshold_ms) =
read_first_positive_u64_env(&["GENARRATIVE_SLOW_REQUEST_THRESHOLD_MS"])
{

View File

@@ -40,12 +40,19 @@ use spacetime_client::{
use std::convert::Infallible;
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
api_response::json_success_body,
auth::AuthenticatedAccessToken,
custom_world_agent_turn::{
CustomWorldAgentTurnRequest, build_failed_finalize_record_input,
build_finalize_record_input, run_custom_world_agent_turn,
},
request_context::RequestContext, state::AppState,
custom_world_foundation_draft::{
DraftFoundationPayloadError, build_draft_foundation_action_payload_json,
generate_custom_world_foundation_draft,
},
http_error::AppError,
request_context::RequestContext,
state::AppState,
};
pub async fn get_custom_world_library(
@@ -142,12 +149,16 @@ pub async fn put_custom_world_library_profile(
})),
)
})?;
let author_display_name = resolve_author_display_name(&authenticated);
let author_display_name = resolve_author_display_name(&state, &authenticated);
let author_public_user_code =
resolve_author_public_user_code(&state, &authenticated, &request_context)?;
let mutation = state
.spacetime_client()
.upsert_custom_world_profile(CustomWorldProfileUpsertRecordInput {
profile_id: profile_id.clone(),
owner_user_id: owner_user_id.clone(),
public_work_code: None,
author_public_user_code: Some(author_public_user_code),
source_agent_session_id: payload.source_agent_session_id.clone(),
world_name: metadata.world_name,
subtitle: metadata.subtitle,
@@ -240,7 +251,9 @@ pub async fn publish_custom_world_library_profile(
.publish_custom_world_profile(
profile_id,
owner_user_id,
resolve_author_display_name(&authenticated),
None,
resolve_author_public_user_code(&state, &authenticated, &request_context)?,
resolve_author_display_name(&state, &authenticated),
current_utc_micros(),
)
.await
@@ -279,7 +292,7 @@ pub async fn unpublish_custom_world_library_profile(
.unpublish_custom_world_profile(
profile_id,
owner_user_id,
resolve_author_display_name(&authenticated),
resolve_author_display_name(&state, &authenticated),
current_utc_micros(),
)
.await
@@ -350,6 +363,37 @@ pub async fn get_custom_world_gallery_detail(
))
}
pub async fn get_custom_world_gallery_detail_by_code(
State(state): State<AppState>,
Path(code): Path<String>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<Value>, Response> {
if code.trim().is_empty() {
return Err(custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-gallery",
"message": "code is required",
})),
));
}
let detail = state
.spacetime_client()
.get_custom_world_gallery_detail_by_code(code)
.await
.map_err(|error| {
custom_world_error_response(&request_context, map_custom_world_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
CustomWorldGalleryDetailResponse {
entry: map_custom_world_library_entry_response(detail.entry),
},
))
}
pub async fn create_custom_world_agent_session(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -870,25 +914,88 @@ pub async fn execute_custom_world_agent_action(
));
}
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": format!("action payload JSON 序列化失败:{error}"),
})),
)
})?;
let owner_user_id = authenticated.claims().user_id().to_string();
let submitted_at_micros = current_utc_micros();
let payload_json = if action == "draft_foundation" {
let session = state
.spacetime_client()
.get_custom_world_agent_session(session_id.clone(), owner_user_id.clone())
.await
.map_err(|error| {
custom_world_error_response(&request_context, map_custom_world_client_error(error))
})?;
if session.progress_percent < 100 {
return Err(custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-agent",
"message": "draft_foundation requires progressPercent >= 100",
})),
));
}
let llm_client = state.llm_client().ok_or_else(|| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "custom-world-agent",
"message": "服务端尚未配置可用的 LLM API Key",
})),
)
})?;
let draft_result = generate_custom_world_foundation_draft(llm_client, &session)
.await
.map_err(|message| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "custom-world-agent",
"message": message,
})),
)
})?;
build_draft_foundation_action_payload_json(&payload, &draft_result.draft_profile_json)
.map_err(|error| {
let (status, message) = match error {
DraftFoundationPayloadError::SerializePayload(message) => {
(StatusCode::BAD_REQUEST, message)
}
DraftFoundationPayloadError::InvalidPayloadShape => (
StatusCode::BAD_REQUEST,
"action payload 必须是 object".to_string(),
),
DraftFoundationPayloadError::InvalidGeneratedDraft(message) => {
(StatusCode::BAD_GATEWAY, message)
}
};
custom_world_error_response(
&request_context,
AppError::from_status(status).with_details(json!({
"provider": "custom-world-agent",
"message": message,
})),
)
})?
} else {
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": format!("action payload JSON 序列化失败:{error}"),
})),
)
})?
};
let result = state
.spacetime_client()
.execute_custom_world_agent_action(CustomWorldAgentActionExecuteRecordInput {
session_id,
owner_user_id: authenticated.claims().user_id().to_string(),
owner_user_id,
operation_id: build_prefixed_uuid_id("operation-"),
action,
payload_json: Some(payload_json),
submitted_at_micros: current_utc_micros(),
submitted_at_micros,
})
.await
.map_err(|error| {
@@ -909,6 +1016,8 @@ fn map_custom_world_library_entry_response(
CustomWorldLibraryEntryResponse {
owner_user_id: entry.owner_user_id,
profile_id: entry.profile_id,
public_work_code: entry.public_work_code,
author_public_user_code: entry.author_public_user_code,
profile: entry.profile,
visibility: entry.visibility,
published_at: entry.published_at,
@@ -930,6 +1039,8 @@ fn map_custom_world_gallery_card_response(
CustomWorldGalleryCardResponse {
owner_user_id: entry.owner_user_id,
profile_id: entry.profile_id,
public_work_code: entry.public_work_code,
author_public_user_code: entry.author_public_user_code,
visibility: entry.visibility,
published_at: entry.published_at,
updated_at: entry.updated_at,
@@ -1213,8 +1324,48 @@ fn custom_world_sse_error_event_message(message: String) -> Event {
Event::default().event("error").data(payload)
}
fn resolve_author_display_name(_authenticated: &AuthenticatedAccessToken) -> String {
"玩家".to_string()
fn resolve_author_display_name(
state: &AppState,
authenticated: &AuthenticatedAccessToken,
) -> String {
state
.auth_user_service()
.get_user_by_id(authenticated.claims().user_id())
.ok()
.flatten()
.map(|user| user.display_name)
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "玩家".to_string())
}
fn resolve_author_public_user_code(
state: &AppState,
authenticated: &AuthenticatedAccessToken,
request_context: &RequestContext,
) -> Result<String, Response> {
state
.auth_user_service()
.get_user_by_id(authenticated.claims().user_id())
.map_err(|error| {
custom_world_error_response(
request_context,
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "custom-world-library",
"message": format!("作者叙世号读取失败:{error}"),
})),
)
})?
.map(|user| user.public_user_code)
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
custom_world_error_response(
request_context,
AppError::from_status(StatusCode::UNAUTHORIZED).with_details(json!({
"provider": "custom-world-library",
"message": "当前登录用户缺少叙世号",
})),
)
})
}
fn build_custom_world_agent_welcome_text(seed_text: &str) -> String {

View File

@@ -0,0 +1,669 @@
use platform_llm::{LlmClient, LlmMessage, LlmTextRequest};
use serde_json::{Map as JsonMap, Value as JsonValue, json};
use shared_contracts::runtime::ExecuteCustomWorldAgentActionRequest;
use spacetime_client::CustomWorldAgentSessionRecord;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CustomWorldFoundationDraftResult {
pub draft_profile_json: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DraftFoundationPayloadError {
SerializePayload(String),
InvalidPayloadShape,
InvalidGeneratedDraft(String),
}
pub async fn generate_custom_world_foundation_draft(
llm_client: &LlmClient,
session: &CustomWorldAgentSessionRecord,
) -> Result<CustomWorldFoundationDraftResult, String> {
let system_prompt = build_foundation_draft_system_prompt();
let user_prompt = build_foundation_draft_user_prompt(session);
let response = llm_client
.request_text(LlmTextRequest::new(vec![
LlmMessage::system(system_prompt),
LlmMessage::user(user_prompt),
]))
.await
.map_err(|error| format!("foundation draft LLM 请求失败:{error}"))?;
let parsed = parse_json_response_text(response.content.as_str())
.map_err(|error| format!("foundation draft JSON 解析失败:{error}"))?;
let draft_profile = normalize_foundation_draft_profile(parsed, session);
let draft_profile_json = serde_json::to_string(&JsonValue::Object(draft_profile))
.map_err(|error| format!("foundation draft JSON 序列化失败:{error}"))?;
Ok(CustomWorldFoundationDraftResult { draft_profile_json })
}
// foundation draft 已经由 api-server 真实生成,落库前只负责把它注入现有 action payload。
pub fn build_draft_foundation_action_payload_json(
payload: &ExecuteCustomWorldAgentActionRequest,
draft_profile_json: &str,
) -> Result<String, DraftFoundationPayloadError> {
let mut payload_value = serde_json::to_value(payload).map_err(|error| {
DraftFoundationPayloadError::SerializePayload(format!(
"action payload JSON 序列化失败:{error}"
))
})?;
let payload_object = payload_value
.as_object_mut()
.ok_or(DraftFoundationPayloadError::InvalidPayloadShape)?;
let draft_profile_value =
serde_json::from_str::<JsonValue>(draft_profile_json).map_err(|error| {
DraftFoundationPayloadError::InvalidGeneratedDraft(format!(
"foundation draft JSON 非法:{error}"
))
})?;
if !draft_profile_value.is_object() {
return Err(DraftFoundationPayloadError::InvalidGeneratedDraft(
"foundation draft JSON 必须是 object".to_string(),
));
}
payload_object.insert("draftProfile".to_string(), draft_profile_value);
serde_json::to_string(&payload_value).map_err(|error| {
DraftFoundationPayloadError::SerializePayload(format!(
"action payload JSON 序列化失败:{error}"
))
})
}
fn build_foundation_draft_system_prompt() -> String {
[
"你是 RPG 世界共创后端里的底稿编译器。",
"你的任务是根据当前会话已经确认的世界锚点,生成第一版“世界设定草稿” JSON。",
"必须只输出一个 JSON object不要输出 markdown、解释、前后缀。",
"输出必须使用中文内容。",
"不要返回占位符不要写“待补充”“略”“TBD”“placeholder”。",
"如果某些信息不完整,也要基于已知锚点给出一版合理、可继续精修的首稿。",
"字段必须至少包含name、subtitle、summary、worldHook、playerPremise、coreConflicts、playableNpcs、storyNpcs、landmarks、chapters、sceneChapterBlueprints。",
"sceneChapterBlueprints 至少包含 1 个 chapter且 chapter.acts 至少包含 1 个 act。",
"playableNpcs、storyNpcs、landmarks 可以是小规模首批关键对象,不要求长尾铺满。",
]
.join("\n")
}
fn build_foundation_draft_user_prompt(session: &CustomWorldAgentSessionRecord) -> String {
let anchor_content = to_pretty_json(&session.anchor_content);
let creator_intent = to_pretty_json(&session.creator_intent);
let anchor_pack = to_pretty_json(&session.anchor_pack);
let current_draft = if is_non_null_json(&session.draft_profile) {
to_pretty_json(&session.draft_profile)
} else {
"{}".to_string()
};
let quality_findings = to_pretty_json(&JsonValue::Array(session.quality_findings.clone()));
[
format!("seedText{}", session.seed_text.trim()),
format!("当前 stage{}", session.stage.trim()),
format!("当前 progressPercent{}", session.progress_percent),
format!(
"当前最后一条 assistant 回复:{}",
session.last_assistant_reply.clone().unwrap_or_default()
),
format!("当前 anchorContent\n{anchor_content}"),
format!("当前 creatorIntent\n{creator_intent}"),
format!("当前 anchorPack\n{anchor_pack}"),
format!("当前已有 draftProfile\n{current_draft}"),
format!("当前 qualityFindings\n{quality_findings}"),
"请直接返回第一版 foundation draft JSON。".to_string(),
"约束:".to_string(),
"1. worldHook 必须是一句可以直接用于发布门禁校验的世界钩子。".to_string(),
"2. playerPremise 必须明确玩家身份与切入前提。".to_string(),
"3. coreConflicts 必须至少 1 条。".to_string(),
"4. chapters 或 sceneChapterBlueprints 必须体现主线第一幕。".to_string(),
"5. sceneChapterBlueprints[0].acts 至少 1 条。".to_string(),
"6. summary 要像结果页摘要,不要只是原始 seed 重复。".to_string(),
]
.join("\n\n")
}
fn normalize_foundation_draft_profile(
value: JsonValue,
session: &CustomWorldAgentSessionRecord,
) -> JsonMap<String, JsonValue> {
let mut object = value.as_object().cloned().unwrap_or_default();
let fallback_title = derive_world_name(&object, session);
let fallback_world_hook = derive_world_hook(&object, session);
let fallback_player_premise = derive_player_premise(&object, session);
ensure_text_field(&mut object, "name", fallback_title.as_str());
ensure_text_field(&mut object, "subtitle", "世界底稿已生成");
ensure_text_field(
&mut object,
"summary",
"第一版世界底稿已经整理完成,可继续精修关键角色、地点和主线第一幕。",
);
ensure_text_field(&mut object, "worldHook", fallback_world_hook.as_str());
ensure_text_field(
&mut object,
"playerPremise",
fallback_player_premise.as_str(),
);
ensure_text_array_field(
&mut object,
"coreConflicts",
vec!["核心冲突仍需继续深化,但已经具备第一版主线推进方向。"],
);
ensure_object_array_field(&mut object, "playableNpcs");
ensure_object_array_field(&mut object, "storyNpcs");
ensure_object_array_field(&mut object, "landmarks");
ensure_object_array_field(&mut object, "chapters");
ensure_scene_chapter_blueprints(&mut object);
object
}
fn ensure_text_field(object: &mut JsonMap<String, JsonValue>, key: &str, fallback: &str) {
let current = object
.get(key)
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
object.insert(
key.to_string(),
JsonValue::String(current.unwrap_or_else(|| fallback.to_string())),
);
}
fn ensure_text_array_field(
object: &mut JsonMap<String, JsonValue>,
key: &str,
fallback_items: Vec<&str>,
) {
let current_items = object
.get(key)
.and_then(JsonValue::as_array)
.map(|entries| {
entries
.iter()
.filter_map(|entry| entry.as_str().map(str::trim))
.filter(|value| !value.is_empty())
.map(|value| JsonValue::String(value.to_string()))
.collect::<Vec<_>>()
})
.unwrap_or_default();
if current_items.is_empty() {
object.insert(
key.to_string(),
JsonValue::Array(
fallback_items
.into_iter()
.map(|value| JsonValue::String(value.to_string()))
.collect(),
),
);
} else {
object.insert(key.to_string(), JsonValue::Array(current_items));
}
}
fn ensure_object_array_field(object: &mut JsonMap<String, JsonValue>, key: &str) {
let current = object
.get(key)
.and_then(JsonValue::as_array)
.cloned()
.unwrap_or_default();
object.insert(key.to_string(), JsonValue::Array(current));
}
fn ensure_scene_chapter_blueprints(object: &mut JsonMap<String, JsonValue>) {
let blueprints = object
.get("sceneChapterBlueprints")
.and_then(JsonValue::as_array)
.cloned()
.unwrap_or_default();
if blueprints.is_empty() {
object.insert(
"sceneChapterBlueprints".to_string(),
JsonValue::Array(vec![build_fallback_scene_chapter_blueprint()]),
);
return;
}
let normalized = blueprints
.into_iter()
.map(|chapter| normalize_scene_chapter_blueprint(chapter))
.collect::<Vec<_>>();
object.insert(
"sceneChapterBlueprints".to_string(),
JsonValue::Array(normalized),
);
}
fn normalize_scene_chapter_blueprint(chapter: JsonValue) -> JsonValue {
let mut object = chapter.as_object().cloned().unwrap_or_default();
let title = object
.get("title")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("第一幕");
object.insert("title".to_string(), JsonValue::String(title.to_string()));
let acts = object
.get("acts")
.and_then(JsonValue::as_array)
.cloned()
.unwrap_or_default();
if acts.is_empty() {
object.insert(
"acts".to_string(),
JsonValue::Array(vec![build_fallback_scene_act()]),
);
}
JsonValue::Object(object)
}
fn build_fallback_scene_chapter_blueprint() -> JsonValue {
json!({
"id": "chapter-act-1",
"title": "第一幕",
"summary": "第一幕用于让玩家进入当前世界的主线矛盾,并看见最初的风险与方向。",
"acts": [build_fallback_scene_act()],
})
}
fn build_fallback_scene_act() -> JsonValue {
json!({
"id": "scene-act-1",
"title": "开场场景幕",
"summary": "玩家被推入第一波局势,必须先确认站位、威胁和下一步追查方向。",
})
}
fn derive_world_name(
object: &JsonMap<String, JsonValue>,
session: &CustomWorldAgentSessionRecord,
) -> String {
read_text_field(object, &["name", "title"])
.or_else(|| {
session
.anchor_content
.get("worldPromise")
.and_then(JsonValue::as_object)
.and_then(|entry| entry.get("hook"))
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
.unwrap_or_else(|| "未命名世界草稿".to_string())
}
fn derive_world_hook(
object: &JsonMap<String, JsonValue>,
session: &CustomWorldAgentSessionRecord,
) -> String {
read_text_field(object, &["worldHook"])
.or_else(|| {
session
.anchor_content
.get("worldPromise")
.and_then(JsonValue::as_object)
.and_then(|entry| entry.get("hook"))
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
.unwrap_or_else(|| {
"这个世界正被一条持续扩大的主线危机推向失衡,而玩家会被卷入其中心。".to_string()
})
}
fn derive_player_premise(
object: &JsonMap<String, JsonValue>,
session: &CustomWorldAgentSessionRecord,
) -> String {
read_text_field(object, &["playerPremise"])
.or_else(|| {
session
.anchor_content
.get("playerEntryPoint")
.and_then(JsonValue::as_object)
.map(|entry| {
let identity = entry
.get("openingIdentity")
.and_then(JsonValue::as_str)
.map(str::trim)
.unwrap_or_default();
let problem = entry
.get("openingProblem")
.and_then(JsonValue::as_str)
.map(str::trim)
.unwrap_or_default();
let motivation = entry
.get("entryMotivation")
.and_then(JsonValue::as_str)
.map(str::trim)
.unwrap_or_default();
[identity, problem, motivation]
.into_iter()
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.join("")
})
.filter(|value| !value.trim().is_empty())
})
.unwrap_or_else(|| {
"玩家会以一名已经卷入当前局势的人物进入世界,并被迫尽快确认自己的立场与行动方向。"
.to_string()
})
}
fn read_text_field(object: &JsonMap<String, JsonValue>, keys: &[&str]) -> Option<String> {
for key in keys {
let mut current = JsonValue::Object(object.clone());
let mut found = true;
for segment in key.split('.') {
if let Some(next) = current.get(segment) {
current = next.clone();
} else {
found = false;
break;
}
}
if found
&& let Some(value) = current
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
{
return Some(value.to_string());
}
}
None
}
fn parse_json_response_text(text: &str) -> Result<JsonValue, serde_json::Error> {
let trimmed = text.trim();
if let Some(start) = trimmed.find('{')
&& let Some(end) = trimmed.rfind('}')
&& end > start
{
return serde_json::from_str::<JsonValue>(&trimmed[start..=end]);
}
serde_json::from_str::<JsonValue>(trimmed)
}
fn to_pretty_json(value: &JsonValue) -> String {
serde_json::to_string_pretty(value).unwrap_or_else(|_| "null".to_string())
}
fn is_non_null_json(value: &JsonValue) -> bool {
!matches!(value, JsonValue::Null)
}
#[cfg(test)]
mod tests {
use std::{
io::{Read, Write},
net::TcpListener,
sync::{Arc, Mutex},
thread,
time::Duration as StdDuration,
};
use platform_llm::{DEFAULT_REQUEST_TIMEOUT_MS, LlmConfig, LlmProvider};
use super::*;
#[test]
fn foundation_prompt_uses_real_seed_text() {
let session = build_test_session();
let prompt = build_foundation_draft_user_prompt(&session);
assert!(prompt.contains("seedText海雾会吞掉记错航线的人。"));
assert!(!prompt.contains("seedTextcustom-world-agent-session-1"));
}
#[test]
fn build_draft_foundation_action_payload_json_injects_generated_profile() {
let payload = ExecuteCustomWorldAgentActionRequest {
action: "draft_foundation".to_string(),
profile_id: Some("profile-1".to_string()),
draft_profile: Some(json!({ "name": "旧草稿" })),
legacy_result_profile: None,
setting_text: Some("旧设定".to_string()),
card_id: None,
sections: None,
profile: None,
count: None,
prompt_text: Some("补充提示".to_string()),
anchor_card_ids: Some(vec!["card-1".to_string()]),
role_ids: None,
role_id: None,
portrait_path: None,
generated_visual_asset_id: None,
generated_animation_set_id: None,
animation_map: None,
scene_ids: None,
scene_id: None,
scene_kind: None,
image_src: None,
generated_scene_asset_id: None,
generated_scene_prompt: None,
generated_scene_model: None,
checkpoint_id: None,
};
let payload_json = build_draft_foundation_action_payload_json(
&payload,
r#"{"name":"新草稿","worldHook":"失灯海域会吞掉所有记错航线的人。"}"#,
)
.expect("payload json should build");
let payload_value =
serde_json::from_str::<JsonValue>(&payload_json).expect("payload json should parse");
assert_eq!(
payload_value.get("action"),
Some(&json!("draft_foundation"))
);
assert_eq!(payload_value.get("profileId"), Some(&json!("profile-1")));
assert_eq!(
payload_value
.get("draftProfile")
.and_then(|value| value.get("name")),
Some(&json!("新草稿"))
);
}
#[tokio::test]
async fn generate_custom_world_foundation_draft_uses_seed_text_and_normalizes_fields() {
let request_capture = Arc::new(Mutex::new(String::new()));
let server_url = spawn_mock_server(
request_capture.clone(),
r#"{"id":"resp_01","choices":[{"message":{"content":"{\"name\":\"雾港归航\",\"coreConflicts\":[\"守灯塔的旧档案被人改写。\"]}"}}]}"#
.to_string(),
);
let llm_client = build_test_llm_client(server_url);
let session = build_test_session();
let result = generate_custom_world_foundation_draft(&llm_client, &session)
.await
.expect("draft generation should succeed");
let draft_profile = serde_json::from_str::<JsonValue>(&result.draft_profile_json)
.expect("draft profile should parse");
let request_text = request_capture
.lock()
.expect("request capture should lock")
.clone();
assert!(request_text.contains("海雾会吞掉记错航线的人。"));
assert!(!request_text.contains("seedText\\uff1acustom-world-agent-session-1"));
assert_eq!(draft_profile.get("name"), Some(&json!("雾港归航")));
assert!(
draft_profile
.get("worldHook")
.and_then(JsonValue::as_str)
.is_some()
);
assert!(
draft_profile
.get("playerPremise")
.and_then(JsonValue::as_str)
.is_some()
);
assert_eq!(
draft_profile
.get("sceneChapterBlueprints")
.and_then(JsonValue::as_array)
.and_then(|entries| entries.first())
.and_then(|entry| entry.get("acts"))
.and_then(JsonValue::as_array)
.map(|entries| !entries.is_empty()),
Some(true)
);
}
fn build_test_session() -> CustomWorldAgentSessionRecord {
CustomWorldAgentSessionRecord {
session_id: "custom-world-agent-session-1".to_string(),
seed_text: "海雾会吞掉记错航线的人。".to_string(),
current_turn: 2,
anchor_content: json!({
"worldPromise": {
"hook": "在失真的海图上追查一场被篡改的沉船事故。"
},
"playerEntryPoint": {
"openingIdentity": "被停职返乡的守灯人",
"openingProblem": "灯塔记录被人改写",
"entryMotivation": "查清父亲沉船真相"
}
}),
progress_percent: 100,
last_assistant_reply: Some("世界锚点已经基本齐全,可以整理第一版底稿。".to_string()),
stage: "foundation_review".to_string(),
focus_card_id: None,
creator_intent: json!({
"theme": "悬疑航海",
"playerPremise": "玩家是返乡调查旧案的守灯人。"
}),
creator_intent_readiness: json!({
"isReady": true
}),
anchor_pack: json!({
"coreConflict": "群岛议会正在掩盖沉船真相。"
}),
lock_state: json!({}),
draft_profile: JsonValue::Null,
messages: Vec::new(),
draft_cards: Vec::new(),
pending_clarifications: Vec::new(),
suggested_actions: Vec::new(),
recommended_replies: Vec::new(),
quality_findings: Vec::new(),
asset_coverage: json!({}),
checkpoints: Vec::new(),
supported_actions: Vec::new(),
publish_gate: None,
result_preview: None,
updated_at: "2026-04-23T00:00:00Z".to_string(),
}
}
fn build_test_llm_client(base_url: String) -> LlmClient {
let config = LlmConfig::new(
LlmProvider::Ark,
base_url,
"test-key".to_string(),
"test-model".to_string(),
DEFAULT_REQUEST_TIMEOUT_MS,
0,
1,
)
.expect("llm config should build");
LlmClient::new(config).expect("llm client should build")
}
fn spawn_mock_server(request_capture: Arc<Mutex<String>>, response_body: String) -> String {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let address = listener
.local_addr()
.expect("listener should expose address");
thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("request should connect");
let request_text = read_request(&mut stream);
*request_capture.lock().expect("request capture should lock") = request_text;
write_response(&mut stream, response_body);
});
format!("http://{address}")
}
fn read_request(stream: &mut std::net::TcpStream) -> String {
stream
.set_read_timeout(Some(StdDuration::from_secs(1)))
.expect("read timeout should be configured");
let mut buffer = Vec::new();
let mut chunk = [0_u8; 1024];
let mut expected_total = None;
loop {
match stream.read(&mut chunk) {
Ok(0) => break,
Ok(bytes_read) => {
buffer.extend_from_slice(&chunk[..bytes_read]);
if expected_total.is_none()
&& let Some(header_end) = find_header_end(&buffer)
{
let content_length =
read_content_length(&buffer[..header_end]).unwrap_or(0);
expected_total = Some(header_end + content_length);
}
if let Some(total_bytes) = expected_total
&& buffer.len() >= total_bytes
{
break;
}
}
Err(error)
if error.kind() == std::io::ErrorKind::WouldBlock
|| error.kind() == std::io::ErrorKind::TimedOut =>
{
break;
}
Err(error) => panic!("mock server failed to read request: {error}"),
}
}
String::from_utf8(buffer).expect("request should be utf-8")
}
fn write_response(stream: &mut std::net::TcpStream, body: String) {
let raw_response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(raw_response.as_bytes())
.expect("mock response should be written");
stream.flush().expect("mock response should flush");
}
fn find_header_end(buffer: &[u8]) -> Option<usize> {
buffer
.windows(4)
.position(|window| window == b"\r\n\r\n")
.map(|index| index + 4)
}
fn read_content_length(headers: &[u8]) -> Option<usize> {
let text = String::from_utf8_lossy(headers);
text.lines().find_map(|line| {
let (name, value) = line.split_once(':')?;
if name.eq_ignore_ascii_case("content-length") {
return value.trim().parse::<usize>().ok();
}
None
})
}
}

View File

@@ -1,9 +1,12 @@
mod admin;
mod ai_tasks;
mod api_response;
mod app;
mod assets;
mod auth;
mod auth_me;
mod auth_payload;
mod auth_public_user;
mod auth_session;
mod auth_sessions;
mod big_fish;
@@ -13,6 +16,7 @@ mod config;
mod custom_world;
mod custom_world_agent_turn;
mod custom_world_ai;
mod custom_world_foundation_draft;
mod error_middleware;
mod health;
mod http_error;
@@ -24,6 +28,7 @@ mod logout_all;
mod password_entry;
mod phone_auth;
mod puzzle;
mod puzzle_agent_turn;
mod refresh_session;
mod request_context;
mod response_headers;

View File

@@ -6,10 +6,11 @@ use axum::{
};
use module_auth::{PasswordEntryError, PasswordEntryInput};
use serde_json::json;
use shared_contracts::auth::{AuthUserPayload, PasswordEntryRequest, PasswordEntryResponse};
use shared_contracts::auth::{PasswordEntryRequest, PasswordEntryResponse};
use crate::{
api_response::json_success_body,
auth_payload::map_auth_user_payload,
auth_session::{
attach_set_cookie_header, build_refresh_session_cookie_header, create_password_auth_session,
},
@@ -48,15 +49,7 @@ pub async fn password_entry(
Some(&request_context),
PasswordEntryResponse {
token: signed_session.access_token,
user: AuthUserPayload {
id: result.user.id,
username: result.user.username,
display_name: result.user.display_name,
phone_number_masked: result.user.phone_number_masked,
login_method: result.user.login_method.as_str().to_string(),
binding_status: result.user.binding_status.as_str().to_string(),
wechat_bound: result.user.wechat_bound,
},
user: map_auth_user_payload(result.user),
},
),
))
@@ -74,6 +67,11 @@ fn map_password_entry_error(error: PasswordEntryError) -> AppError {
.with_details(json!({
"field": "password",
})),
PasswordEntryError::InvalidPublicUserCode => AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("叙世号格式不正确")
.with_details(json!({
"field": "username",
})),
PasswordEntryError::InvalidCredentials => {
AppError::from_status(StatusCode::UNAUTHORIZED).with_message("用户名或密码错误")
}

View File

@@ -9,8 +9,7 @@ use module_auth::{
};
use serde_json::json;
use shared_contracts::auth::{
AuthUserPayload, PhoneLoginRequest, PhoneLoginResponse, PhoneSendCodeRequest,
PhoneSendCodeResponse,
PhoneLoginRequest, PhoneLoginResponse, PhoneSendCodeRequest, PhoneSendCodeResponse,
};
use time::OffsetDateTime;
use tracing::{info, warn};
@@ -20,6 +19,7 @@ use crate::{
auth_session::{
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
},
auth_payload::map_auth_user_payload,
http_error::AppError,
request_context::RequestContext,
session_client::resolve_session_client_context,
@@ -166,15 +166,7 @@ pub async fn phone_login(
Some(&request_context),
PhoneLoginResponse {
token: signed_session.access_token,
user: AuthUserPayload {
id: result.user.id,
username: result.user.username,
display_name: result.user.display_name,
phone_number_masked: result.user.phone_number_masked,
login_method: result.user.login_method.as_str().to_string(),
binding_status: result.user.binding_status.as_str().to_string(),
wechat_bound: result.user.wechat_bound,
},
user: map_auth_user_payload(result.user),
},
),
))

View File

@@ -8,7 +8,10 @@ use axum::{
Json,
extract::{Extension, Path as AxumPath, State, rejection::JsonRejection},
http::{HeaderName, StatusCode, header},
response::{IntoResponse, Response},
response::{
IntoResponse, Response,
sse::{Event, Sse},
},
};
use serde_json::{Value, json};
use shared_contracts::{
@@ -46,9 +49,14 @@ use spacetime_client::{
PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkUpsertRecordInput,
SpacetimeClientError,
};
use std::convert::Infallible;
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
puzzle_agent_turn::{
PuzzleAgentTurnRequest, build_failed_finalize_record_input, build_finalize_record_input,
run_puzzle_agent_turn,
},
request_context::RequestContext, state::AppState,
};
@@ -169,11 +177,12 @@ pub async fn submit_puzzle_agent_message(
));
}
let session = state
let owner_user_id = authenticated.claims().user_id().to_string();
let submitted_session = state
.spacetime_client()
.submit_puzzle_agent_message(PuzzleAgentMessageSubmitRecordInput {
session_id,
owner_user_id: authenticated.claims().user_id().to_string(),
session_id: session_id.clone(),
owner_user_id: owner_user_id.clone(),
user_message_id: client_message_id,
user_message_text: message_text,
submitted_at_micros: current_utc_micros(),
@@ -186,6 +195,41 @@ pub async fn submit_puzzle_agent_message(
map_puzzle_client_error(error),
)
})?;
let turn_result = run_puzzle_agent_turn(
PuzzleAgentTurnRequest {
llm_client: state.llm_client(),
session: &submitted_session,
},
|_| {},
)
.await;
let finalize_input = match turn_result {
Ok(turn_result) => build_finalize_record_input(
session_id.clone(),
owner_user_id.clone(),
format!("assistant-{session_id}-{}", current_utc_micros()),
turn_result,
current_utc_micros(),
),
Err(error) => build_failed_finalize_record_input(
session_id.clone(),
owner_user_id.clone(),
&submitted_session,
error.to_string(),
current_utc_micros(),
),
};
let session = state
.spacetime_client()
.finalize_puzzle_agent_message(finalize_input)
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
map_puzzle_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
@@ -219,11 +263,12 @@ pub async fn stream_puzzle_agent_message(
"sessionId",
)?;
let owner_user_id = authenticated.claims().user_id().to_string();
let session = state
.spacetime_client()
.submit_puzzle_agent_message(PuzzleAgentMessageSubmitRecordInput {
session_id,
owner_user_id: authenticated.claims().user_id().to_string(),
session_id: session_id.clone(),
owner_user_id: owner_user_id.clone(),
user_message_id: payload.client_message_id.trim().to_string(),
user_message_text: payload.text.trim().to_string(),
submitted_at_micros: current_utc_micros(),
@@ -236,32 +281,100 @@ pub async fn stream_puzzle_agent_message(
map_puzzle_client_error(error),
)
})?;
let state = state.clone();
let session_id_for_stream = session_id.clone();
let owner_user_id_for_stream = owner_user_id.clone();
let stream = async_stream::stream! {
let (reply_tx, mut reply_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let turn_result = {
let run_turn = run_puzzle_agent_turn(
PuzzleAgentTurnRequest {
llm_client: state.llm_client(),
session: &session,
},
move |text| {
let _ = reply_tx.send(text.to_string());
},
);
tokio::pin!(run_turn);
let session_response = map_puzzle_agent_session_response(session);
let reply_text = session_response
.last_assistant_reply
.clone()
.unwrap_or_else(|| "拼图锚点已更新。".to_string());
let mut sse_body = String::new();
append_sse_event(
&request_context,
&mut sse_body,
"reply_delta",
&json!({ "text": reply_text }),
)?;
append_sse_event(
&request_context,
&mut sse_body,
"session",
&json!({ "session": session_response }),
)?;
append_sse_event(
&request_context,
&mut sse_body,
"done",
&json!({ "ok": true }),
)?;
Ok(build_event_stream_response(sse_body))
loop {
tokio::select! {
result = &mut run_turn => break result,
maybe_text = reply_rx.recv() => {
if let Some(text) = maybe_text {
yield Ok::<Event, Infallible>(puzzle_sse_json_event_or_error(
"reply_delta",
json!({ "text": text }),
));
}
}
}
}
};
while let Some(text) = reply_rx.recv().await {
yield Ok::<Event, Infallible>(puzzle_sse_json_event_or_error(
"reply_delta",
json!({ "text": text }),
));
}
let finalize_input = match turn_result {
Ok(turn_result) => build_finalize_record_input(
session_id_for_stream.clone(),
owner_user_id_for_stream.clone(),
format!("assistant-{session_id_for_stream}-{}", current_utc_micros()),
turn_result,
current_utc_micros(),
),
Err(error) => build_failed_finalize_record_input(
session_id_for_stream.clone(),
owner_user_id_for_stream.clone(),
&session,
error.to_string(),
current_utc_micros(),
),
};
let finalize_result = state
.spacetime_client()
.finalize_puzzle_agent_message(finalize_input)
.await;
let _final_session = match finalize_result {
Ok(session) => session,
Err(error) => {
yield Ok::<Event, Infallible>(puzzle_sse_json_event_or_error(
"error",
json!({ "message": error.to_string() }),
));
return;
}
};
let final_session = match state
.spacetime_client()
.get_puzzle_agent_session(session_id_for_stream, owner_user_id_for_stream)
.await
{
Ok(session) => session,
Err(error) => {
yield Ok::<Event, Infallible>(puzzle_sse_json_event_or_error(
"error",
json!({ "message": error.to_string() }),
));
return;
}
};
let session_response = map_puzzle_agent_session_response(final_session);
yield Ok::<Event, Infallible>(puzzle_sse_json_event_or_error(
"session",
json!({ "session": session_response }),
));
yield Ok::<Event, Infallible>(puzzle_sse_json_event_or_error(
"done",
json!({ "ok": true }),
));
};
Ok(Sse::new(stream).into_response())
}
pub async fn execute_puzzle_agent_action(
@@ -413,13 +526,15 @@ pub async fn execute_puzzle_agent_action(
)
}
"publish_puzzle_work" => {
let (work_id, profile_id) = build_stable_puzzle_work_ids(&session_id);
let profile = state
.spacetime_client()
.publish_puzzle_work(PuzzlePublishRecordInput {
session_id: session_id.clone(),
owner_user_id: owner_user_id.clone(),
work_id: build_prefixed_uuid_id("puzzle-work-"),
profile_id: build_prefixed_uuid_id("puzzle-profile-"),
// 发布沿用 session 派生的稳定作品 ID避免草稿卡与已发布卡重复。
work_id,
profile_id,
author_display_name: resolve_author_display_name(&state, &authenticated),
level_name: payload.level_name.clone(),
summary: payload.summary.clone(),
@@ -1153,6 +1268,14 @@ fn build_puzzle_welcome_text(seed_text: &str) -> String {
"我先接住你的画面灵感,再一起把它收束成正式拼图关卡。".to_string()
}
fn build_stable_puzzle_work_ids(session_id: &str) -> (String, String) {
let stable_suffix = session_id.strip_prefix("puzzle-session-").unwrap_or(session_id);
(
format!("puzzle-work-{stable_suffix}"),
format!("puzzle-profile-{stable_suffix}"),
)
}
fn ensure_non_empty(
request_context: &RequestContext,
provider: &str,
@@ -1193,6 +1316,14 @@ fn map_puzzle_client_error(error: SpacetimeClientError) -> AppError {
{
StatusCode::NOT_FOUND
}
SpacetimeClientError::Procedure(message)
if message.contains("当前模型不可用")
|| message.contains("生成失败")
|| message.contains("解析失败")
|| message.contains("缺少有效回复") =>
{
StatusCode::BAD_GATEWAY
}
_ => StatusCode::BAD_GATEWAY,
};
@@ -1216,41 +1347,32 @@ fn puzzle_error_response(
response
}
fn append_sse_event(
request_context: &RequestContext,
body: &mut String,
event_name: &str,
payload: &Value,
) -> Result<(), Response> {
let payload = serde_json::to_string(payload).map_err(|error| {
puzzle_error_response(
request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
fn puzzle_sse_json_event(event_name: &str, payload: Value) -> Result<Event, AppError> {
Event::default()
.event(event_name)
.json_data(payload)
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"provider": "sse",
"message": format!("SSE payload 序列化失败:{error}"),
})),
)
})?;
body.push_str("event: ");
body.push_str(event_name);
body.push('\n');
body.push_str("data: ");
body.push_str(&payload);
body.push_str("\n\n");
Ok(())
}))
})
}
fn build_event_stream_response(body: String) -> Response {
(
[
(header::CONTENT_TYPE, "text/event-stream; charset=utf-8"),
(header::CACHE_CONTROL, "no-cache, no-transform"),
(header::CONNECTION, "keep-alive"),
],
body,
)
.into_response()
fn puzzle_sse_json_event_or_error(event_name: &str, payload: Value) -> Event {
match puzzle_sse_json_event(event_name, payload) {
Ok(event) => event,
Err(_) => puzzle_sse_error_event_message("SSE payload 序列化失败".to_string()),
}
}
fn puzzle_sse_error_event_message(message: String) -> Event {
let payload = format!(
"{{\"message\":{}}}",
serde_json::to_string(&message)
.unwrap_or_else(|_| "\"SSE 错误事件序列化失败\"".to_string())
);
Event::default().event("error").data(payload)
}
fn build_placeholder_puzzle_candidates(

View File

@@ -0,0 +1,372 @@
use module_puzzle::{
PuzzleAgentStage, PuzzleAnchorPack, PuzzleAnchorStatus, empty_anchor_pack,
};
use platform_llm::{LlmClient, LlmMessage, LlmStreamDelta, LlmTextRequest};
use serde::{Deserialize, Serialize};
use serde_json::{Value as JsonValue, json};
use spacetime_client::{
PuzzleAgentMessageFinalizeRecordInput, PuzzleAgentMessageRecord, PuzzleAgentSessionRecord,
};
#[derive(Clone, Debug)]
pub(crate) struct PuzzleAgentTurnRequest<'a> {
pub llm_client: Option<&'a LlmClient>,
pub session: &'a PuzzleAgentSessionRecord,
}
#[derive(Clone, Debug)]
pub(crate) struct PuzzleAgentTurnResult {
pub assistant_reply_text: String,
pub stage: String,
pub progress_percent: u32,
pub anchor_pack_json: String,
pub error_message: Option<String>,
}
#[derive(Clone, Debug)]
pub(crate) struct PuzzleAgentTurnError {
message: String,
}
impl PuzzleAgentTurnError {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
impl std::fmt::Display for PuzzleAgentTurnError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for PuzzleAgentTurnError {}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PuzzleAgentModelOutput {
reply_text: String,
progress_percent: u32,
next_anchor_pack: PuzzleAnchorPack,
}
const PUZZLE_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和创作者共创拼图画面的中文创意策划。
你要帮助用户把一句灵感逐步收束成可以发布成拼图关卡的视觉方案。
你必须同时输出:
1. 一段直接发给用户的中文回复 replyText
2. 当前进度 progressPercent
3. 下一轮完整可用的 nextAnchorPack
拼图创作固定围绕 5 个视觉锚点:
1. themePromise题材承诺
2. visualSubject画面主体
3. visualMood视觉气质
4. compositionHooks拼图记忆点
5. tagsAndForbidden标签与禁忌
硬约束:
1. 只能输出 JSON不能输出代码块或解释
2. nextAnchorPack 必须是完整对象,不能只输出 patch
3. replyText 必须是自然中文不能提“字段”“锚点”“结构”“JSON”等内部词
4. replyText 一次最多推进一个最关键问题
5. 如果用户已经给出明确方向,就优先吸收和收束,不要机械反问
6. progressPercent 范围只能是 0 到 100
7. status 只能使用 missing / inferred / confirmed / locked
"#;
const PUZZLE_AGENT_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 输出,不要输出其他文字:
{
"replyText": "",
"progressPercent": 0,
"nextAnchorPack": {
"themePromise": {
"key": "themePromise",
"label": "题材承诺",
"value": "",
"status": "missing"
},
"visualSubject": {
"key": "visualSubject",
"label": "画面主体",
"value": "",
"status": "missing"
},
"visualMood": {
"key": "visualMood",
"label": "视觉气质",
"value": "",
"status": "missing"
},
"compositionHooks": {
"key": "compositionHooks",
"label": "拼图记忆点",
"value": "",
"status": "missing"
},
"tagsAndForbidden": {
"key": "tagsAndForbidden",
"label": "标签与禁忌",
"value": "",
"status": "missing"
}
}
}"#;
pub(crate) async fn run_puzzle_agent_turn<F>(
request: PuzzleAgentTurnRequest<'_>,
mut on_reply_update: F,
) -> Result<PuzzleAgentTurnResult, PuzzleAgentTurnError>
where
F: FnMut(&str),
{
let llm_client = request
.llm_client
.ok_or_else(|| PuzzleAgentTurnError::new("当前模型不可用,请稍后重试。"))?;
let prompt = build_puzzle_agent_prompt(request.session);
let mut latest_reply_text = String::new();
let response = llm_client
.stream_text(
LlmTextRequest::new(vec![
LlmMessage::system(format!("{PUZZLE_AGENT_SYSTEM_PROMPT}\n\n{prompt}")),
LlmMessage::user("请按约定输出这一轮的 JSON。"),
]),
|delta: &LlmStreamDelta| {
if let Some(reply_progress) =
extract_reply_text_from_partial_json(delta.accumulated_text.as_str())
&& reply_progress != latest_reply_text
{
latest_reply_text = reply_progress.clone();
on_reply_update(reply_progress.as_str());
}
},
)
.await
.map_err(|_| PuzzleAgentTurnError::new("拼图聊天生成失败,请稍后重试。"))?;
let parsed = parse_json_response_text(response.content.as_str())
.map_err(|_| PuzzleAgentTurnError::new("拼图聊天结果解析失败,请稍后重试。"))?;
let output = parse_model_output(&parsed)?;
if output.reply_text != latest_reply_text {
on_reply_update(output.reply_text.as_str());
}
Ok(PuzzleAgentTurnResult {
assistant_reply_text: output.reply_text,
stage: resolve_puzzle_agent_stage(output.progress_percent).as_str().to_string(),
progress_percent: output.progress_percent,
anchor_pack_json: serde_json::to_string(&output.next_anchor_pack)
.unwrap_or_else(|_| serde_json::to_string(&empty_anchor_pack()).unwrap_or_else(|_| "{}".to_string())),
error_message: None,
})
}
pub(crate) fn build_finalize_record_input(
session_id: String,
owner_user_id: String,
assistant_message_id: String,
result: PuzzleAgentTurnResult,
updated_at_micros: i64,
) -> PuzzleAgentMessageFinalizeRecordInput {
PuzzleAgentMessageFinalizeRecordInput {
session_id,
owner_user_id,
assistant_message_id: Some(assistant_message_id),
assistant_reply_text: Some(result.assistant_reply_text),
stage: result.stage,
progress_percent: result.progress_percent,
anchor_pack_json: result.anchor_pack_json,
error_message: result.error_message,
updated_at_micros,
}
}
pub(crate) fn build_failed_finalize_record_input(
session_id: String,
owner_user_id: String,
session: &PuzzleAgentSessionRecord,
error_message: String,
updated_at_micros: i64,
) -> PuzzleAgentMessageFinalizeRecordInput {
let anchor_pack_json = serde_json::to_string(&map_record_anchor_pack(&session.anchor_pack))
.unwrap_or_else(|_| serde_json::to_string(&empty_anchor_pack()).unwrap_or_else(|_| "{}".to_string()));
PuzzleAgentMessageFinalizeRecordInput {
session_id,
owner_user_id,
assistant_message_id: None,
assistant_reply_text: None,
stage: session.stage.clone(),
progress_percent: session.progress_percent,
anchor_pack_json,
error_message: Some(error_message),
updated_at_micros,
}
}
fn build_puzzle_agent_prompt(session: &PuzzleAgentSessionRecord) -> String {
format!(
"当前是第 {turn} 轮,当前进度 {progress}% 。\n\n当前 anchor pack\n{anchor_pack}\n\n最近聊天记录:\n{chat_history}\n\n{contract}",
turn = session.current_turn.saturating_add(1),
progress = session.progress_percent,
anchor_pack = serde_json::to_string_pretty(&map_record_anchor_pack(&session.anchor_pack))
.unwrap_or_else(|_| "{}".to_string()),
chat_history = serde_json::to_string_pretty(&build_chat_history(session.messages.as_slice()))
.unwrap_or_else(|_| "[]".to_string()),
contract = PUZZLE_AGENT_OUTPUT_CONTRACT,
)
}
fn build_chat_history(messages: &[PuzzleAgentMessageRecord]) -> Vec<JsonValue> {
messages
.iter()
.map(|message| {
json!({
"role": message.role,
"kind": message.kind,
"content": message.text,
})
})
.collect()
}
fn parse_model_output(parsed: &JsonValue) -> Result<PuzzleAgentModelOutput, PuzzleAgentTurnError> {
let reply_text = parsed
.get("replyText")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| PuzzleAgentTurnError::new("拼图聊天结果缺少有效回复,请稍后重试。"))?
.to_string();
let progress_percent = parsed
.get("progressPercent")
.and_then(JsonValue::as_u64)
.map(|value| value.min(100) as u32)
.unwrap_or(0);
let next_anchor_pack = parsed
.get("nextAnchorPack")
.cloned()
.ok_or_else(|| PuzzleAgentTurnError::new("拼图聊天结果缺少 nextAnchorPack。"))
.and_then(|value| {
serde_json::from_value::<PuzzleAnchorPack>(value)
.map_err(|_| PuzzleAgentTurnError::new("拼图 anchor pack 解析失败,请稍后重试。"))
})?;
Ok(PuzzleAgentModelOutput {
reply_text,
progress_percent,
next_anchor_pack,
})
}
fn resolve_puzzle_agent_stage(progress_percent: u32) -> PuzzleAgentStage {
if progress_percent >= 85 {
PuzzleAgentStage::DraftReady
} else {
PuzzleAgentStage::CollectingAnchors
}
}
fn map_record_anchor_pack(record: &spacetime_client::PuzzleAnchorPackRecord) -> PuzzleAnchorPack {
PuzzleAnchorPack {
theme_promise: map_record_anchor_item(&record.theme_promise),
visual_subject: map_record_anchor_item(&record.visual_subject),
visual_mood: map_record_anchor_item(&record.visual_mood),
composition_hooks: map_record_anchor_item(&record.composition_hooks),
tags_and_forbidden: map_record_anchor_item(&record.tags_and_forbidden),
}
}
fn map_record_anchor_item(record: &spacetime_client::PuzzleAnchorItemRecord) -> module_puzzle::PuzzleAnchorItem {
module_puzzle::PuzzleAnchorItem {
key: record.key.clone(),
label: record.label.clone(),
value: record.value.clone(),
status: parse_anchor_status(record.status.as_str()),
}
}
fn parse_anchor_status(value: &str) -> PuzzleAnchorStatus {
match value {
"confirmed" => PuzzleAnchorStatus::Confirmed,
"locked" => PuzzleAnchorStatus::Locked,
"inferred" => PuzzleAnchorStatus::Inferred,
_ => PuzzleAnchorStatus::Missing,
}
}
fn parse_json_response_text(text: &str) -> Result<JsonValue, serde_json::Error> {
let trimmed = text.trim();
if let Some(start) = trimmed.find('{')
&& let Some(end) = trimmed.rfind('}')
&& end > start
{
return serde_json::from_str::<JsonValue>(&trimmed[start..=end]);
}
serde_json::from_str::<JsonValue>(trimmed)
}
fn extract_reply_text_from_partial_json(text: &str) -> Option<String> {
let key_index = text.find("\"replyText\"")?;
let colon_index = text[key_index..].find(':')? + key_index;
let mut cursor = colon_index + 1;
while cursor < text.len() && text.as_bytes()[cursor].is_ascii_whitespace() {
cursor += 1;
}
if text.as_bytes().get(cursor).copied() != Some(b'"') {
return None;
}
cursor += 1;
let mut decoded = String::new();
let remainder = text.get(cursor..)?;
let mut characters = remainder.chars().peekable();
while let Some(current) = characters.next() {
if current == '"' {
return Some(decoded);
}
if current == '\\' {
let escaped = characters.next()?;
match escaped {
'"' => decoded.push('"'),
'\\' => decoded.push('\\'),
'/' => decoded.push('/'),
'b' => decoded.push('\u{0008}'),
'f' => decoded.push('\u{000C}'),
'n' => decoded.push('\n'),
'r' => decoded.push('\r'),
't' => decoded.push('\t'),
'u' => {
let mut hex = String::new();
for _ in 0..4 {
hex.push(characters.next()?);
}
if let Ok(code) = u16::from_str_radix(hex.as_str(), 16)
&& let Some(character) = char::from_u32(code as u32)
{
decoded.push(character);
}
}
other => decoded.push(other),
}
continue;
}
decoded.push(current);
}
Some(decoded)
}
#[cfg(test)]
mod tests {
use super::extract_reply_text_from_partial_json;
#[test]
fn extract_reply_text_from_partial_json_preserves_chinese_characters() {
let partial_json = r#"{"replyText":"夜雨猫咪遗迹","progressPercent":42"#;
let extracted = extract_reply_text_from_partial_json(partial_json);
assert_eq!(extracted.as_deref(), Some("夜雨猫咪遗迹"));
}
}

View File

@@ -1,4 +1,4 @@
use std::{error::Error, fmt};
use std::{error::Error, fmt, sync::Arc};
#[cfg(test)]
use std::{
@@ -15,17 +15,22 @@ use module_runtime::RuntimeSnapshotRecord;
#[cfg(test)]
use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros};
use platform_auth::{
JwtConfig, JwtError, RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite,
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, JwtConfig, JwtError,
RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite,
sign_access_token, verify_access_token,
SmsAuthConfig, SmsAuthProvider, SmsAuthProviderKind, SmsProviderError,
};
use platform_llm::{LlmClient, LlmConfig, LlmError};
use platform_oss::{OssClient, OssConfig, OssError};
use serde_json::Value;
use spacetime_client::{SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError};
use time::OffsetDateTime;
use crate::config::AppConfig;
use crate::wechat_provider::{WechatProvider, build_wechat_provider};
const ADMIN_ROLE: &str = "admin";
// 当前阶段先保留最小共享状态壳,后续逐步接入配置、客户端与平台适配。
#[derive(Clone, Debug)]
pub struct AppState {
@@ -33,6 +38,7 @@ pub struct AppState {
#[allow(dead_code)]
pub config: AppConfig,
auth_jwt_config: JwtConfig,
admin_runtime: Option<AdminRuntime>,
refresh_cookie_config: RefreshCookieConfig,
oss_client: Option<OssClient>,
password_entry_service: PasswordEntryService,
@@ -51,6 +57,34 @@ pub struct AppState {
test_runtime_snapshot_store: Arc<Mutex<HashMap<String, RuntimeSnapshotRecord>>>,
}
#[derive(Clone, Debug)]
pub struct AdminRuntime {
username: Arc<str>,
password: Arc<str>,
subject: Arc<str>,
display_name: Arc<str>,
token_ttl_seconds: u64,
jwt_config: JwtConfig,
}
#[derive(Clone, Debug)]
pub struct AdminClaims {
pub subject: String,
pub username: String,
pub issued_at: OffsetDateTime,
pub expires_at: OffsetDateTime,
}
#[derive(Clone, Debug)]
pub struct AdminSession {
pub subject: String,
pub username: String,
pub display_name: String,
pub roles: Vec<String>,
pub issued_at: OffsetDateTime,
pub expires_at: OffsetDateTime,
}
#[derive(Debug)]
pub enum AppStateInitError {
Jwt(JwtError),
@@ -67,6 +101,7 @@ impl AppState {
config.jwt_secret.clone(),
config.jwt_access_token_ttl_seconds,
)?;
let admin_runtime = build_admin_runtime(&config, &auth_jwt_config)?;
let refresh_cookie_same_site =
RefreshCookieSameSite::parse(&config.refresh_cookie_same_site).ok_or(
RefreshCookieError::InvalidConfig("refresh cookie SameSite 取值非法"),
@@ -123,6 +158,7 @@ impl AppState {
Ok(Self {
config,
auth_jwt_config,
admin_runtime,
refresh_cookie_config,
oss_client,
password_entry_service,
@@ -144,6 +180,10 @@ impl AppState {
&self.auth_jwt_config
}
pub fn admin_runtime(&self) -> Option<&AdminRuntime> {
self.admin_runtime.as_ref()
}
pub fn refresh_cookie_config(&self) -> &RefreshCookieConfig {
&self.refresh_cookie_config
}
@@ -394,6 +434,90 @@ impl From<LlmError> for AppStateInitError {
}
}
impl AdminRuntime {
pub fn is_enabled(&self) -> bool {
!self.username.trim().is_empty() && !self.password.trim().is_empty()
}
pub fn username(&self) -> &str {
&self.username
}
pub fn password(&self) -> &str {
&self.password
}
pub fn build_claims(&self, now: OffsetDateTime) -> Result<AdminClaims, String> {
let expires_at = now
.checked_add(time::Duration::seconds(
i64::try_from(self.token_ttl_seconds)
.map_err(|_| "后台 token TTL 超出 i64 上限".to_string())?,
))
.ok_or_else(|| "后台 token 过期时间计算溢出".to_string())?;
Ok(AdminClaims {
subject: self.subject.to_string(),
username: self.username.to_string(),
issued_at: now,
expires_at,
})
}
pub fn sign_token(&self, claims: &AdminClaims) -> Result<String, String> {
let jwt_claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: claims.subject.clone(),
session_id: format!("admin-session-{}", claims.username),
provider: AuthProvider::Password,
roles: vec![ADMIN_ROLE.to_string()],
token_version: 1,
phone_verified: false,
binding_status: BindingStatus::Active,
display_name: Some(self.display_name.to_string()),
},
&self.jwt_config,
claims.issued_at,
)
.map_err(|error| error.to_string())?;
sign_access_token(&jwt_claims, &self.jwt_config).map_err(|error| error.to_string())
}
pub fn verify_token(&self, token: &str) -> Result<AccessTokenClaims, String> {
verify_access_token(token, &self.jwt_config).map_err(|error| error.to_string())
}
pub fn validate_claims(&self, claims: &AccessTokenClaims) -> Result<AdminSession, String> {
if claims.user_id() != self.subject.as_ref() {
return Err("后台管理员主体不匹配".to_string());
}
if !claims.roles.iter().any(|role| role == ADMIN_ROLE) {
return Err("当前令牌不是管理员令牌".to_string());
}
let issued_at = OffsetDateTime::from_unix_timestamp(claims.iat as i64)
.map_err(|_| "后台令牌签发时间无效".to_string())?;
let expires_at = OffsetDateTime::from_unix_timestamp(claims.exp as i64)
.map_err(|_| "后台令牌过期时间无效".to_string())?;
Ok(AdminSession {
subject: claims.user_id().to_string(),
username: self.username.to_string(),
display_name: self.display_name.to_string(),
roles: claims.roles.clone(),
issued_at,
expires_at,
})
}
pub fn build_session(&self, claims: &AdminClaims) -> AdminSession {
AdminSession {
subject: claims.subject.clone(),
username: claims.username.clone(),
display_name: self.display_name.to_string(),
roles: vec![ADMIN_ROLE.to_string()],
issued_at: claims.issued_at,
expires_at: claims.expires_at,
}
}
}
fn build_oss_client(config: &AppConfig) -> Result<Option<OssClient>, AppStateInitError> {
let has_any_oss_field = config.oss_bucket.is_some()
|| config.oss_endpoint.is_some()
@@ -441,6 +565,42 @@ fn build_llm_client(config: &AppConfig) -> Result<Option<LlmClient>, AppStateIni
Ok(Some(LlmClient::new(llm_config)?))
}
fn build_admin_runtime(
config: &AppConfig,
base_jwt_config: &JwtConfig,
) -> Result<Option<AdminRuntime>, AppStateInitError> {
let Some(username) = config
.admin_username
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
else {
return Ok(None);
};
let Some(password) = config
.admin_password
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
else {
return Ok(None);
};
let jwt_config = JwtConfig::new(
base_jwt_config.issuer().to_string(),
config.jwt_secret.clone(),
config.admin_token_ttl_seconds,
)?;
Ok(Some(AdminRuntime {
username: Arc::<str>::from(username),
password: Arc::<str>::from(password),
subject: Arc::<str>::from(format!("admin:{username}")),
display_name: Arc::<str>::from(format!("管理员 {username}")),
token_ttl_seconds: config.admin_token_ttl_seconds,
jwt_config,
}))
}
#[cfg(test)]
mod tests {
use module_ai::{AiTaskKind, generate_ai_task_id};

View File

@@ -9,8 +9,8 @@ use module_auth::{
WechatAuthScene,
};
use shared_contracts::auth::{
AuthUserPayload, WechatBindPhoneRequest, WechatBindPhoneResponse, WechatCallbackQuery,
WechatStartQuery, WechatStartResponse,
WechatBindPhoneRequest, WechatBindPhoneResponse, WechatCallbackQuery, WechatStartQuery,
WechatStartResponse,
};
use time::OffsetDateTime;
use url::Url;
@@ -18,6 +18,7 @@ use url::Url;
use crate::{
api_response::json_success_body,
auth::AuthenticatedAccessToken,
auth_payload::map_auth_user_payload,
auth_session::{
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
},
@@ -199,15 +200,7 @@ pub async fn bind_wechat_phone(
Some(&request_context),
WechatBindPhoneResponse {
token: signed_session.access_token,
user: AuthUserPayload {
id: result.user.id,
username: result.user.username,
display_name: result.user.display_name,
phone_number_masked: result.user.phone_number_masked,
login_method: result.user.login_method.as_str().to_string(),
binding_status: result.user.binding_status.as_str().to_string(),
wechat_bound: result.user.wechat_bound,
},
user: map_auth_user_payload(result.user),
},
),
))

View File

@@ -41,6 +41,7 @@ pub enum AuthBindingStatus {
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AuthUser {
pub id: String,
pub public_user_code: String,
pub username: String,
pub display_name: String,
pub phone_number_masked: Option<String>,
@@ -55,6 +56,11 @@ pub struct AuthMeResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PublicUserSearchResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PasswordEntryInput {
pub username: String,
@@ -262,6 +268,7 @@ pub struct LogoutAllSessionsResult {
pub enum PasswordEntryError {
InvalidUsername,
InvalidPasswordLength,
InvalidPublicUserCode,
InvalidCredentials,
Store(String),
PasswordHash(String),
@@ -457,6 +464,16 @@ impl PasswordEntryService {
.find_by_user_id(user_id)
.map(|maybe_user| maybe_user.map(|stored| AuthMeResult { user: stored.user }))
}
pub fn get_user_by_public_user_code(
&self,
public_user_code: &str,
) -> Result<Option<PublicUserSearchResult>, PasswordEntryError> {
let normalized_public_user_code = normalize_public_user_code(public_user_code)?;
self.store
.find_by_public_user_code(&normalized_public_user_code)
.map(|maybe_user| maybe_user.map(|stored| PublicUserSearchResult { user: stored.user }))
}
}
impl RefreshSessionService {
@@ -870,6 +887,18 @@ impl AuthUserService {
.map_err(map_password_error_to_logout_error)
}
pub fn get_user_by_public_user_code(
&self,
public_user_code: &str,
) -> Result<Option<AuthUser>, LogoutError> {
let normalized_public_user_code = normalize_public_user_code(public_user_code)
.map_err(map_password_error_to_logout_error)?;
self.store
.find_by_public_user_code(&normalized_public_user_code)
.map(|maybe_user| maybe_user.map(|stored| stored.user))
.map_err(map_password_error_to_logout_error)
}
pub fn logout_current_session(
&self,
input: LogoutCurrentSessionInput,
@@ -962,6 +991,22 @@ impl InMemoryAuthStore {
.cloned())
}
fn find_by_public_user_code(
&self,
public_user_code: &str,
) -> Result<Option<StoredPasswordUser>, PasswordEntryError> {
let state = self
.inner
.lock()
.map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?;
Ok(state
.users_by_username
.values()
.find(|stored_user| stored_user.user.public_user_code == public_user_code)
.cloned())
}
fn find_by_phone_number(
&self,
phone_number: &str,
@@ -994,11 +1039,14 @@ impl InMemoryAuthStore {
return Err(CreateUserError::AlreadyExists);
}
let user_id = format!("user_{:08}", state.next_user_id);
let sequence = state.next_user_id;
let user_id = format!("user_{sequence:08}");
let public_user_code = build_public_user_code(sequence);
state.next_user_id += 1;
let user = AuthUser {
id: user_id,
public_user_code,
username: username.clone(),
display_name: username.clone(),
phone_number_masked: None,
@@ -1035,11 +1083,14 @@ impl InMemoryAuthStore {
));
}
let user_id = format!("user_{:08}", state.next_user_id);
let sequence = state.next_user_id;
let user_id = format!("user_{sequence:08}");
let public_user_code = build_public_user_code(sequence);
state.next_user_id += 1;
let username = build_system_username("phone", state.next_user_id);
let user = AuthUser {
id: user_id.clone(),
public_user_code,
username: username.clone(),
display_name,
phone_number_masked: Some(phone_number.masked_national_number.clone()),
@@ -1073,7 +1124,9 @@ impl InMemoryAuthStore {
.lock()
.map_err(|_| WechatAuthError::Store("用户仓储锁已中毒".to_string()))?;
let user_id = format!("user_{:08}", state.next_user_id);
let sequence = state.next_user_id;
let user_id = format!("user_{sequence:08}");
let public_user_code = build_public_user_code(sequence);
state.next_user_id += 1;
let username = build_system_username("wechat", state.next_user_id);
let display_name = profile
@@ -1085,6 +1138,7 @@ impl InMemoryAuthStore {
.to_string();
let user = AuthUser {
id: user_id.clone(),
public_user_code,
username: username.clone(),
display_name,
phone_number_masked: None,
@@ -1722,6 +1776,7 @@ impl fmt::Display for PasswordEntryError {
match self {
Self::InvalidUsername => f.write_str("用户名只允许 3 到 24 位字母、数字、下划线"),
Self::InvalidPasswordLength => f.write_str("密码长度需要在 6 到 128 位之间"),
Self::InvalidPublicUserCode => f.write_str("叙世号格式不正确"),
Self::InvalidCredentials => f.write_str("用户名或密码错误"),
Self::Store(message) | Self::PasswordHash(message) => f.write_str(message),
}
@@ -1794,6 +1849,7 @@ fn map_password_store_error(error: PasswordEntryError) -> RefreshSessionError {
PasswordEntryError::Store(message) => RefreshSessionError::Store(message),
PasswordEntryError::InvalidUsername
| PasswordEntryError::InvalidPasswordLength
| PasswordEntryError::InvalidPublicUserCode
| PasswordEntryError::InvalidCredentials
| PasswordEntryError::PasswordHash(_) => {
RefreshSessionError::Store("用户仓储读取失败".to_string())
@@ -1807,6 +1863,7 @@ fn map_password_error_to_phone_error(error: PasswordEntryError) -> PhoneAuthErro
PasswordEntryError::PasswordHash(message) => PhoneAuthError::PasswordHash(message),
PasswordEntryError::InvalidUsername
| PasswordEntryError::InvalidPasswordLength
| PasswordEntryError::InvalidPublicUserCode
| PasswordEntryError::InvalidCredentials => {
PhoneAuthError::Store("用户仓储读取失败".to_string())
}
@@ -1818,6 +1875,7 @@ fn map_password_error_to_logout_error(error: PasswordEntryError) -> LogoutError
PasswordEntryError::Store(message) => LogoutError::Store(message),
PasswordEntryError::InvalidUsername
| PasswordEntryError::InvalidPasswordLength
| PasswordEntryError::InvalidPublicUserCode
| PasswordEntryError::InvalidCredentials
| PasswordEntryError::PasswordHash(_) => LogoutError::Store("用户仓储读取失败".to_string()),
}
@@ -1923,6 +1981,30 @@ fn build_system_username(prefix: &str, sequence: u64) -> String {
format!("{prefix}_{sequence:08}")
}
// 公开叙世号是稳定的公开检索键,不替代内部 user_id仅用于展示、分享与搜索。
fn build_public_user_code(sequence: u64) -> String {
format!("SY-{sequence:08}")
}
pub fn normalize_public_user_code(input: &str) -> Result<String, PasswordEntryError> {
let normalized = input
.trim()
.chars()
.filter(|character| character.is_ascii_alphanumeric())
.collect::<String>()
.to_ascii_uppercase();
let digits = normalized.strip_prefix("SY").unwrap_or(&normalized);
if digits.is_empty()
|| digits.len() > 8
|| !digits.chars().all(|character| character.is_ascii_digit())
{
return Err(PasswordEntryError::InvalidPublicUserCode);
}
Ok(format!("SY-{digits:0>8}"))
}
fn format_rfc3339(value: OffsetDateTime) -> Result<String, String> {
format_shared_rfc3339(value)
}

View File

@@ -252,6 +252,38 @@ pub struct BigFishSessionProcedureResult {
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishWorkSummarySnapshot {
pub work_id: String,
pub source_session_id: String,
pub title: String,
pub subtitle: String,
pub summary: String,
pub cover_image_src: Option<String>,
pub status: String,
pub updated_at_micros: i64,
pub publish_ready: bool,
pub level_count: u32,
pub level_main_image_ready_count: u32,
pub level_motion_ready_count: u32,
pub background_ready: bool,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishWorksListInput {
pub owner_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishWorksProcedureResult {
pub ok: bool,
pub items_json: Option<String>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BigFishRunProcedureResult {
@@ -693,6 +725,13 @@ pub fn validate_session_get_input(input: &BigFishSessionGetInput) -> Result<(),
validate_session_owner(&input.session_id, &input.owner_user_id)
}
pub fn validate_works_list_input(input: &BigFishWorksListInput) -> Result<(), BigFishFieldError> {
if normalize_required_string(&input.owner_user_id).is_none() {
return Err(BigFishFieldError::MissingOwnerUserId);
}
Ok(())
}
pub fn validate_session_create_input(
input: &BigFishSessionCreateInput,
) -> Result<(), BigFishFieldError> {

View File

@@ -140,6 +140,7 @@ pub enum CustomWorldFieldError {
MissingProfileId,
MissingSessionId,
MissingOwnerUserId,
MissingPublicWorkCode,
MissingAction,
MissingWorldName,
MissingDraftProfileJson,
@@ -170,6 +171,8 @@ pub enum CustomWorldFieldError {
pub struct CustomWorldProfileSnapshot {
pub profile_id: String,
pub owner_user_id: String,
pub public_work_code: Option<String>,
pub author_public_user_code: Option<String>,
pub source_agent_session_id: Option<String>,
pub publication_status: CustomWorldPublicationStatus,
pub world_name: String,
@@ -192,6 +195,8 @@ pub struct CustomWorldProfileSnapshot {
pub struct CustomWorldGalleryEntrySnapshot {
pub profile_id: String,
pub owner_user_id: String,
pub public_work_code: String,
pub author_public_user_code: String,
pub author_display_name: String,
pub world_name: String,
pub subtitle: String,
@@ -408,6 +413,8 @@ pub struct CustomWorldAgentSessionProcedureResult {
pub struct CustomWorldProfileUpsertInput {
pub profile_id: String,
pub owner_user_id: String,
pub public_work_code: Option<String>,
pub author_public_user_code: Option<String>,
pub source_agent_session_id: Option<String>,
pub world_name: String,
pub subtitle: String,
@@ -426,6 +433,8 @@ pub struct CustomWorldProfileUpsertInput {
pub struct CustomWorldProfilePublishInput {
pub profile_id: String,
pub owner_user_id: String,
pub public_work_code: Option<String>,
pub author_public_user_code: String,
pub author_display_name: String,
pub published_at_micros: i64,
}
@@ -467,6 +476,12 @@ pub struct CustomWorldGalleryDetailInput {
pub profile_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldGalleryDetailByCodeInput {
pub public_work_code: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentSessionCreateInput {
@@ -630,6 +645,8 @@ pub struct CustomWorldPublishWorldInput {
pub session_id: String,
pub profile_id: String,
pub owner_user_id: String,
pub public_work_code: Option<String>,
pub author_public_user_code: String,
pub draft_profile_json: String,
pub legacy_result_profile_json: Option<String>,
pub setting_text: String,
@@ -862,6 +879,9 @@ pub fn validate_custom_world_published_profile_compile_input(
pub fn validate_custom_world_publish_world_input(
input: &CustomWorldPublishWorldInput,
) -> Result<(), CustomWorldFieldError> {
if input.author_public_user_code.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
validate_custom_world_published_profile_compile_input(
&CustomWorldPublishedProfileCompileInput {
session_id: input.session_id.clone(),
@@ -905,6 +925,9 @@ pub fn validate_custom_world_profile_publish_input(
if input.author_display_name.trim().is_empty() {
return Err(CustomWorldFieldError::MissingAuthorDisplayName);
}
if input.author_public_user_code.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
Ok(())
}
@@ -974,6 +997,16 @@ pub fn validate_custom_world_gallery_detail_input(
Ok(())
}
pub fn validate_custom_world_gallery_detail_by_code_input(
input: &CustomWorldGalleryDetailByCodeInput,
) -> Result<(), CustomWorldFieldError> {
if input.public_work_code.trim().is_empty() {
return Err(CustomWorldFieldError::MissingPublicWorkCode);
}
Ok(())
}
pub fn validate_custom_world_session_fields(
session_id: &str,
owner_user_id: &str,
@@ -1562,6 +1595,9 @@ impl fmt::Display for CustomWorldFieldError {
Self::MissingProfileId => f.write_str("custom_world.profile_id 不能为空"),
Self::MissingSessionId => f.write_str("custom_world.session_id 不能为空"),
Self::MissingOwnerUserId => f.write_str("custom_world.owner_user_id 不能为空"),
Self::MissingPublicWorkCode => {
f.write_str("custom_world_gallery_detail.public_work_code 不能为空")
}
Self::MissingAction => f.write_str("custom_world_agent_action.action 不能为空"),
Self::MissingWorldName => f.write_str("custom_world.world_name 不能为空"),
Self::MissingDraftProfileJson => {

View File

@@ -302,6 +302,20 @@ pub struct PuzzleAgentMessageSubmitInput {
pub submitted_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleAgentMessageFinalizeInput {
pub session_id: String,
pub owner_user_id: String,
pub assistant_message_id: Option<String>,
pub assistant_reply_text: Option<String>,
pub stage: PuzzleAgentStage,
pub progress_percent: u32,
pub anchor_pack_json: String,
pub error_message: Option<String>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleDraftCompileInput {

View File

@@ -0,0 +1,96 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminLoginRequest {
pub username: String,
pub password: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminLoginResponse {
pub token: String,
pub admin: AdminSessionPayload,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminSessionPayload {
pub subject: String,
pub username: String,
pub display_name: String,
pub roles: Vec<String>,
pub issued_at: String,
pub expires_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminMeResponse {
pub admin: AdminSessionPayload,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AdminOverviewResponse {
pub service: AdminServiceOverviewPayload,
pub database: AdminDatabaseOverviewPayload,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminServiceOverviewPayload {
pub bind_host: String,
pub bind_port: u16,
pub jwt_issuer: String,
pub admin_enabled: bool,
pub spacetime_server_url: String,
pub spacetime_database: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminDatabaseOverviewPayload {
pub database_identity: Option<String>,
pub owner_identity: Option<String>,
pub host_type: Option<String>,
pub schema_table_names: Vec<String>,
pub table_stats: Vec<AdminDatabaseTableStatPayload>,
pub fetch_errors: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminDatabaseTableStatPayload {
pub table_name: String,
pub row_count: Option<u64>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AdminDebugHttpRequest {
pub method: String,
pub path: String,
pub headers: Option<Vec<AdminDebugHeaderInput>>,
pub body: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminDebugHeaderInput {
pub name: String,
pub value: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AdminDebugHttpResponse {
pub status: u16,
pub status_text: String,
pub headers: Vec<AdminDebugHeaderInput>,
pub body_text: String,
pub body_json: Option<Value>,
}

View File

@@ -277,6 +277,7 @@ pub struct CharacterAnimationGenerateResponse {
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CharacterAnimationDraftPayload {
#[serde(default)]
pub frames_data_urls: Vec<String>,
pub fps: u32,
#[serde(rename = "loop")]
@@ -284,6 +285,14 @@ pub struct CharacterAnimationDraftPayload {
pub frame_width: u32,
pub frame_height: u32,
#[serde(default)]
pub frame_count: Option<u32>,
#[serde(default)]
pub apply_chroma_key: Option<bool>,
#[serde(default)]
pub sample_start_ratio: Option<f32>,
#[serde(default)]
pub sample_end_ratio: Option<f32>,
#[serde(default)]
pub preview_video_path: Option<String>,
}
@@ -815,4 +824,26 @@ mod tests {
assert_eq!(payload["animationSetId"], json!("animation-set-1"));
assert_eq!(payload["animationMap"]["idle"]["frames"], json!(2));
}
#[test]
fn character_animation_draft_payload_accepts_backend_extraction_fields() {
let payload = serde_json::from_value::<CharacterAnimationDraftPayload>(json!({
"fps": 8,
"loop": true,
"frameWidth": 192,
"frameHeight": 256,
"frameCount": 8,
"applyChromaKey": true,
"sampleStartRatio": 0.12,
"sampleEndRatio": 0.94,
"previewVideoPath": "/generated-character-drafts/hero/animation/idle/task/preview.mp4"
}))
.expect("draft payload should deserialize without framesDataUrls");
assert!(payload.frames_data_urls.is_empty());
assert_eq!(payload.frame_count, Some(8));
assert_eq!(payload.apply_chroma_key, Some(true));
assert_eq!(payload.sample_start_ratio, Some(0.12));
assert_eq!(payload.sample_end_ratio, Some(0.94));
}
}

View File

@@ -16,6 +16,7 @@ pub struct AuthLoginOptionsResponse {
#[serde(rename_all = "camelCase")]
pub struct AuthUserPayload {
pub id: String,
pub public_user_code: String,
pub username: String,
pub display_name: String,
pub phone_number_masked: Option<String>,
@@ -24,6 +25,20 @@ pub struct AuthUserPayload {
pub wechat_bound: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PublicUserSummaryPayload {
pub id: String,
pub public_user_code: String,
pub display_name: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PublicUserSearchResponse {
pub user: PublicUserSummaryPayload,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PasswordEntryRequest {

View File

@@ -0,0 +1,26 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct BigFishWorkSummaryResponse {
pub work_id: String,
pub source_session_id: String,
pub title: String,
pub subtitle: String,
pub summary: String,
#[serde(default)]
pub cover_image_src: Option<String>,
pub status: String,
pub updated_at: String,
pub publish_ready: bool,
pub level_count: u32,
pub level_main_image_ready_count: u32,
pub level_motion_ready_count: u32,
pub background_ready: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct BigFishWorksResponse {
pub items: Vec<BigFishWorkSummaryResponse>,
}

View File

@@ -1,8 +1,10 @@
pub mod admin;
pub mod ai;
pub mod api;
pub mod assets;
pub mod auth;
pub mod big_fish;
pub mod big_fish_works;
pub mod llm;
pub mod puzzle_agent;
pub mod puzzle_gallery;

View File

@@ -233,6 +233,8 @@ pub struct CustomWorldProfileUpsertRequest {
pub struct CustomWorldLibraryEntryResponse {
pub owner_user_id: String,
pub profile_id: String,
pub public_work_code: Option<String>,
pub author_public_user_code: Option<String>,
pub profile: serde_json::Value,
pub visibility: String,
pub published_at: Option<String>,
@@ -252,6 +254,8 @@ pub struct CustomWorldLibraryEntryResponse {
pub struct CustomWorldGalleryCardResponse {
pub owner_user_id: String,
pub profile_id: String,
pub public_work_code: String,
pub author_public_user_code: String,
pub visibility: String,
pub published_at: Option<String>,
pub updated_at: String,

View File

@@ -16,6 +16,7 @@ module-puzzle = { path = "../module-puzzle" }
module-runtime = { path = "../module-runtime" }
module-runtime-item = { path = "../module-runtime-item" }
module-story = { path = "../module-story" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
shared-kernel = { path = "../shared-kernel" }
spacetimedb-sdk = "2.1.0"

View File

@@ -155,7 +155,10 @@ use crate::module_bindings::{
BigFishSessionGetInput as BindingBigFishSessionGetInput,
BigFishSessionProcedureResult as BindingBigFishSessionProcedureResult,
BigFishSessionSnapshot as BindingBigFishSessionSnapshot,
BigFishVector2 as BindingBigFishVector2, CombatOutcome as BindingCombatOutcome,
BigFishVector2 as BindingBigFishVector2,
BigFishWorksListInput as BindingBigFishWorksListInput,
BigFishWorksProcedureResult as BindingBigFishWorksProcedureResult,
CombatOutcome as BindingCombatOutcome,
CustomWorldAgentActionExecuteInput as BindingCustomWorldAgentActionExecuteInput,
CustomWorldAgentActionExecuteResult as BindingCustomWorldAgentActionExecuteResult,
CustomWorldAgentCardDetailGetInput as BindingCustomWorldAgentCardDetailGetInput,
@@ -173,6 +176,7 @@ use crate::module_bindings::{
CustomWorldDraftCardDetailSectionSnapshot as BindingCustomWorldDraftCardDetailSectionSnapshot,
CustomWorldDraftCardDetailSnapshot as BindingCustomWorldDraftCardDetailSnapshot,
CustomWorldDraftCardSnapshot as BindingCustomWorldDraftCardSnapshot,
CustomWorldGalleryDetailByCodeInput as BindingCustomWorldGalleryDetailByCodeInput,
CustomWorldGalleryDetailInput as BindingCustomWorldGalleryDetailInput,
CustomWorldGalleryEntrySnapshot as BindingCustomWorldGalleryEntrySnapshot,
CustomWorldGalleryListResult as BindingCustomWorldGalleryListResult,
@@ -205,6 +209,7 @@ use crate::module_bindings::{
NpcInteractionStatus as BindingNpcInteractionStatus,
NpcRelationStance as BindingNpcRelationStance, NpcRelationState as BindingNpcRelationState,
NpcStanceProfile as BindingNpcStanceProfile, NpcStateSnapshot as BindingNpcStateSnapshot,
PuzzleAgentMessageFinalizeInput as BindingPuzzleAgentMessageFinalizeInput,
PuzzleAgentMessageSubmitInput as BindingPuzzleAgentMessageSubmitInput,
PuzzleAgentSessionCreateInput as BindingPuzzleAgentSessionCreateInput,
PuzzleAgentSessionGetInput as BindingPuzzleAgentSessionGetInput,
@@ -297,6 +302,7 @@ use crate::module_bindings::{
execute_custom_world_agent_action_procedure::execute_custom_world_agent_action as _,
fail_ai_task_and_return_procedure::fail_ai_task_and_return as _,
finalize_custom_world_agent_message_turn_procedure::finalize_custom_world_agent_message_turn as _,
finalize_puzzle_agent_message_turn_procedure::finalize_puzzle_agent_message_turn as _,
generate_big_fish_asset_procedure::generate_big_fish_asset as _,
get_battle_state_procedure::get_battle_state as _,
get_big_fish_run_procedure::get_big_fish_run as _,
@@ -304,6 +310,7 @@ use crate::module_bindings::{
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_by_code_procedure::get_custom_world_gallery_detail_by_code as _,
get_custom_world_gallery_detail_procedure::get_custom_world_gallery_detail as _,
get_custom_world_library_detail_procedure::get_custom_world_library_detail as _,
get_profile_dashboard_procedure::get_profile_dashboard as _,
@@ -319,6 +326,7 @@ use crate::module_bindings::{
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_big_fish_works_procedure::list_big_fish_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 _,
@@ -739,12 +747,16 @@ impl SpacetimeClient {
&self,
profile_id: String,
owner_user_id: String,
public_work_code: Option<String>,
author_public_user_code: String,
author_display_name: String,
published_at_micros: i64,
) -> Result<CustomWorldLibraryMutationRecord, SpacetimeClientError> {
let procedure_input = BindingCustomWorldProfilePublishInput {
profile_id,
owner_user_id,
public_work_code,
author_public_user_code,
author_display_name,
published_at_micros,
};
@@ -856,6 +868,25 @@ impl SpacetimeClient {
.await
}
pub async fn get_custom_world_gallery_detail_by_code(
&self,
public_work_code: String,
) -> Result<CustomWorldLibraryMutationRecord, SpacetimeClientError> {
let procedure_input = BindingCustomWorldGalleryDetailByCodeInput { public_work_code };
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.get_custom_world_gallery_detail_by_code_then(procedure_input, move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_custom_world_library_mutation_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn publish_custom_world_world(
&self,
input: CustomWorldPublishWorldRecordInput,
@@ -1086,6 +1117,35 @@ impl SpacetimeClient {
.await
}
pub async fn finalize_puzzle_agent_message(
&self,
input: PuzzleAgentMessageFinalizeRecordInput,
) -> Result<PuzzleAgentSessionRecord, SpacetimeClientError> {
let procedure_input = BindingPuzzleAgentMessageFinalizeInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
assistant_message_id: input.assistant_message_id,
assistant_reply_text: input.assistant_reply_text,
stage: parse_puzzle_agent_stage_record(input.stage.as_str())?,
progress_percent: input.progress_percent,
anchor_pack_json: input.anchor_pack_json,
error_message: input.error_message,
updated_at_micros: input.updated_at_micros,
};
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.finalize_puzzle_agent_message_turn_then(procedure_input, move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_puzzle_agent_session_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn compile_puzzle_agent_draft(
&self,
session_id: String,
@@ -1467,6 +1527,26 @@ impl SpacetimeClient {
.await
}
pub async fn list_big_fish_works(
&self,
owner_user_id: String,
) -> Result<Vec<BigFishWorkSummaryRecord>, SpacetimeClientError> {
let procedure_input = BindingBigFishWorksListInput { owner_user_id };
self.call_after_connect(move |connection, sender| {
connection.procedures().list_big_fish_works_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_big_fish_works_procedure_result);
send_once(&sender, mapped);
},
);
})
.await
}
pub async fn submit_big_fish_message(
&self,
input: BigFishMessageSubmitRecordInput,
@@ -1707,15 +1787,17 @@ impl SpacetimeClient {
};
self.call_after_connect(move |connection, sender| {
connection.procedures().finalize_custom_world_agent_message_turn_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_custom_world_agent_operation_procedure_result);
send_once(&sender, mapped);
},
);
connection
.procedures()
.finalize_custom_world_agent_message_turn_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_custom_world_agent_operation_procedure_result);
send_once(&sender, mapped);
},
);
})
.await
}
@@ -2764,6 +2846,8 @@ fn map_custom_world_profile_upsert_input(
BindingCustomWorldProfileUpsertInput {
profile_id: input.profile_id,
owner_user_id: input.owner_user_id,
public_work_code: input.public_work_code,
author_public_user_code: input.author_public_user_code,
source_agent_session_id: input.source_agent_session_id,
world_name: input.world_name,
subtitle: input.subtitle,
@@ -2785,6 +2869,8 @@ fn map_custom_world_publish_world_input(
session_id: input.session_id,
profile_id: input.profile_id,
owner_user_id: input.owner_user_id,
public_work_code: input.public_work_code,
author_public_user_code: input.author_public_user_code,
draft_profile_json: input.draft_profile_json,
legacy_result_profile_json: input.legacy_result_profile_json,
setting_text: input.setting_text,
@@ -3480,6 +3566,26 @@ fn map_big_fish_session_procedure_result(
Ok(map_big_fish_session_snapshot(session))
}
fn map_big_fish_works_procedure_result(
result: BindingBigFishWorksProcedureResult,
) -> Result<Vec<BigFishWorkSummaryRecord>, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::Procedure(
result
.error_message
.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()),
));
}
let items_json = result.items_json.ok_or_else(|| {
SpacetimeClientError::Procedure(
"SpacetimeDB procedure 未返回 big fish works 快照".to_string(),
)
})?;
serde_json::from_str::<Vec<BigFishWorkSummaryRecord>>(&items_json)
.map_err(|error| SpacetimeClientError::Runtime(format!("big fish works items_json 非法: {error}")))
}
fn map_big_fish_run_procedure_result(
result: BindingBigFishRunProcedureResult,
) -> Result<BigFishRuntimeRecord, SpacetimeClientError> {
@@ -3817,6 +3923,8 @@ fn map_custom_world_library_entry_from_profile_snapshot(
Ok(CustomWorldLibraryEntryRecord {
owner_user_id: snapshot.owner_user_id,
profile_id: snapshot.profile_id,
public_work_code: snapshot.public_work_code,
author_public_user_code: snapshot.author_public_user_code,
profile,
visibility: map_custom_world_publication_status(snapshot.publication_status).to_string(),
published_at: snapshot.published_at_micros.map(format_timestamp_micros),
@@ -3841,6 +3949,8 @@ fn map_custom_world_gallery_entry_snapshot(
Ok(CustomWorldGalleryEntryRecord {
owner_user_id: snapshot.owner_user_id,
profile_id: snapshot.profile_id,
public_work_code: snapshot.public_work_code,
author_public_user_code: snapshot.author_public_user_code,
visibility: "published".to_string(),
published_at: Some(format_timestamp_micros(snapshot.published_at_micros)),
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
@@ -3990,6 +4100,7 @@ fn map_custom_world_agent_session_snapshot(
Ok(CustomWorldAgentSessionRecord {
session_id: snapshot.session_id,
seed_text: snapshot.seed_text,
current_turn: snapshot.current_turn,
anchor_content,
progress_percent: snapshot.progress_percent,
@@ -5021,6 +5132,21 @@ fn map_rpg_agent_stage(value: crate::module_bindings::RpgAgentStage) -> String {
.to_string()
}
fn parse_puzzle_agent_stage_record(
value: &str,
) -> Result<crate::module_bindings::PuzzleAgentStage, SpacetimeClientError> {
match value.trim() {
"collecting_anchors" => Ok(crate::module_bindings::PuzzleAgentStage::CollectingAnchors),
"draft_ready" => Ok(crate::module_bindings::PuzzleAgentStage::DraftReady),
"image_refining" => Ok(crate::module_bindings::PuzzleAgentStage::ImageRefining),
"ready_to_publish" => Ok(crate::module_bindings::PuzzleAgentStage::ReadyToPublish),
"published" => Ok(crate::module_bindings::PuzzleAgentStage::Published),
other => Err(SpacetimeClientError::Runtime(format!(
"未知 puzzle agent stage: {other}"
))),
}
}
fn parse_rpg_agent_stage_record(
value: &str,
) -> Result<crate::module_bindings::RpgAgentStage, SpacetimeClientError> {
@@ -5680,6 +5806,8 @@ pub struct ResolveCombatActionRecord {
pub struct CustomWorldLibraryEntryRecord {
pub owner_user_id: String,
pub profile_id: String,
pub public_work_code: Option<String>,
pub author_public_user_code: Option<String>,
pub profile: serde_json::Value,
pub visibility: String,
pub published_at: Option<String>,
@@ -5698,6 +5826,8 @@ pub struct CustomWorldLibraryEntryRecord {
pub struct CustomWorldGalleryEntryRecord {
pub owner_user_id: String,
pub profile_id: String,
pub public_work_code: String,
pub author_public_user_code: String,
pub visibility: String,
pub published_at: Option<String>,
pub updated_at: String,
@@ -5863,6 +5993,7 @@ pub struct CustomWorldDraftCardDetailRecord {
#[derive(Clone, Debug, PartialEq)]
pub struct CustomWorldAgentSessionRecord {
pub session_id: String,
pub seed_text: String,
pub current_turn: u32,
pub anchor_content: serde_json::Value,
pub progress_percent: u32,
@@ -5892,6 +6023,8 @@ pub struct CustomWorldAgentSessionRecord {
pub struct CustomWorldProfileUpsertRecordInput {
pub profile_id: String,
pub owner_user_id: String,
pub public_work_code: Option<String>,
pub author_public_user_code: Option<String>,
pub source_agent_session_id: Option<String>,
pub world_name: String,
pub subtitle: String,
@@ -5910,6 +6043,8 @@ pub struct CustomWorldPublishWorldRecordInput {
pub session_id: String,
pub profile_id: String,
pub owner_user_id: String,
pub public_work_code: Option<String>,
pub author_public_user_code: String,
pub draft_profile_json: String,
pub legacy_result_profile_json: Option<String>,
pub setting_text: String,
@@ -6011,6 +6146,19 @@ pub struct PuzzleAgentMessageSubmitRecordInput {
pub submitted_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PuzzleAgentMessageFinalizeRecordInput {
pub session_id: String,
pub owner_user_id: String,
pub assistant_message_id: Option<String>,
pub assistant_reply_text: Option<String>,
pub stage: String,
pub progress_percent: u32,
pub anchor_pack_json: String,
pub error_message: Option<String>,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PuzzleGeneratedImagesSaveRecordInput {
pub session_id: String,
@@ -6438,6 +6586,23 @@ pub struct BigFishSessionRecord {
pub updated_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct BigFishWorkSummaryRecord {
pub work_id: String,
pub source_session_id: String,
pub title: String,
pub subtitle: String,
pub summary: String,
pub cover_image_src: Option<String>,
pub status: String,
pub updated_at_micros: i64,
pub publish_ready: bool,
pub level_count: u32,
pub level_main_image_ready_count: u32,
pub level_motion_ready_count: u32,
pub background_ready: bool,
}
#[derive(Clone, Debug, PartialEq)]
pub struct BigFishVector2Record {
pub x: f32,

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 BigFishWorksListInput {
pub owner_user_id: String,
}
impl __sdk::InModule for BigFishWorksListInput {
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 BigFishWorksProcedureResult {
pub ok: bool,
pub items_json: Option::<String>,
pub error_message: Option::<String>,
}
impl __sdk::InModule for BigFishWorksProcedureResult {
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 CustomWorldGalleryDetailByCodeInput {
pub public_work_code: String,
}
impl __sdk::InModule for CustomWorldGalleryDetailByCodeInput {
type Module = super::RemoteModule;
}

View File

@@ -16,6 +16,8 @@ use super::custom_world_theme_mode_type::CustomWorldThemeMode;
pub struct CustomWorldGalleryEntrySnapshot {
pub profile_id: String,
pub owner_user_id: String,
pub public_work_code: String,
pub author_public_user_code: String,
pub author_display_name: String,
pub world_name: String,
pub subtitle: String,

View File

@@ -16,6 +16,8 @@ use super::custom_world_theme_mode_type::CustomWorldThemeMode;
pub struct CustomWorldGalleryEntry {
pub profile_id: String,
pub owner_user_id: String,
pub public_work_code: String,
pub author_public_user_code: String,
pub author_display_name: String,
pub world_name: String,
pub subtitle: String,
@@ -40,6 +42,8 @@ impl __sdk::InModule for CustomWorldGalleryEntry {
pub struct CustomWorldGalleryEntryCols {
pub profile_id: __sdk::__query_builder::Col<CustomWorldGalleryEntry, String>,
pub owner_user_id: __sdk::__query_builder::Col<CustomWorldGalleryEntry, String>,
pub public_work_code: __sdk::__query_builder::Col<CustomWorldGalleryEntry, String>,
pub author_public_user_code: __sdk::__query_builder::Col<CustomWorldGalleryEntry, String>,
pub author_display_name: __sdk::__query_builder::Col<CustomWorldGalleryEntry, String>,
pub world_name: __sdk::__query_builder::Col<CustomWorldGalleryEntry, String>,
pub subtitle: __sdk::__query_builder::Col<CustomWorldGalleryEntry, String>,
@@ -58,6 +62,8 @@ impl __sdk::__query_builder::HasCols for CustomWorldGalleryEntry {
CustomWorldGalleryEntryCols {
profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"),
owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"),
public_work_code: __sdk::__query_builder::Col::new(table_name, "public_work_code"),
author_public_user_code: __sdk::__query_builder::Col::new(table_name, "author_public_user_code"),
author_display_name: __sdk::__query_builder::Col::new(table_name, "author_display_name"),
world_name: __sdk::__query_builder::Col::new(table_name, "world_name"),
subtitle: __sdk::__query_builder::Col::new(table_name, "subtitle"),
@@ -79,6 +85,7 @@ impl __sdk::__query_builder::HasCols for CustomWorldGalleryEntry {
pub struct CustomWorldGalleryEntryIxCols {
pub owner_user_id: __sdk::__query_builder::IxCol<CustomWorldGalleryEntry, String>,
pub profile_id: __sdk::__query_builder::IxCol<CustomWorldGalleryEntry, String>,
pub public_work_code: __sdk::__query_builder::IxCol<CustomWorldGalleryEntry, String>,
pub theme_mode: __sdk::__query_builder::IxCol<CustomWorldGalleryEntry, CustomWorldThemeMode>,
}
@@ -88,6 +95,7 @@ impl __sdk::__query_builder::HasIxCols for CustomWorldGalleryEntry {
CustomWorldGalleryEntryIxCols {
owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"),
profile_id: __sdk::__query_builder::IxCol::new(table_name, "profile_id"),
public_work_code: __sdk::__query_builder::IxCol::new(table_name, "public_work_code"),
theme_mode: __sdk::__query_builder::IxCol::new(table_name, "theme_mode"),
}

View File

@@ -15,6 +15,8 @@ use spacetimedb_sdk::__codegen::{
pub struct CustomWorldProfilePublishInput {
pub profile_id: String,
pub owner_user_id: String,
pub public_work_code: Option::<String>,
pub author_public_user_code: String,
pub author_display_name: String,
pub published_at_micros: i64,
}

View File

@@ -17,6 +17,8 @@ use super::custom_world_publication_status_type::CustomWorldPublicationStatus;
pub struct CustomWorldProfileSnapshot {
pub profile_id: String,
pub owner_user_id: String,
pub public_work_code: Option::<String>,
pub author_public_user_code: Option::<String>,
pub source_agent_session_id: Option::<String>,
pub publication_status: CustomWorldPublicationStatus,
pub world_name: String,

View File

@@ -17,6 +17,8 @@ use super::custom_world_publication_status_type::CustomWorldPublicationStatus;
pub struct CustomWorldProfile {
pub profile_id: String,
pub owner_user_id: String,
pub public_work_code: Option::<String>,
pub author_public_user_code: Option::<String>,
pub source_agent_session_id: Option::<String>,
pub publication_status: CustomWorldPublicationStatus,
pub world_name: String,
@@ -46,6 +48,8 @@ impl __sdk::InModule for CustomWorldProfile {
pub struct CustomWorldProfileCols {
pub profile_id: __sdk::__query_builder::Col<CustomWorldProfile, String>,
pub owner_user_id: __sdk::__query_builder::Col<CustomWorldProfile, String>,
pub public_work_code: __sdk::__query_builder::Col<CustomWorldProfile, Option::<String>>,
pub author_public_user_code: __sdk::__query_builder::Col<CustomWorldProfile, Option::<String>>,
pub source_agent_session_id: __sdk::__query_builder::Col<CustomWorldProfile, Option::<String>>,
pub publication_status: __sdk::__query_builder::Col<CustomWorldProfile, CustomWorldPublicationStatus>,
pub world_name: __sdk::__query_builder::Col<CustomWorldProfile, String>,
@@ -69,6 +73,8 @@ impl __sdk::__query_builder::HasCols for CustomWorldProfile {
CustomWorldProfileCols {
profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"),
owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"),
public_work_code: __sdk::__query_builder::Col::new(table_name, "public_work_code"),
author_public_user_code: __sdk::__query_builder::Col::new(table_name, "author_public_user_code"),
source_agent_session_id: __sdk::__query_builder::Col::new(table_name, "source_agent_session_id"),
publication_status: __sdk::__query_builder::Col::new(table_name, "publication_status"),
world_name: __sdk::__query_builder::Col::new(table_name, "world_name"),

View File

@@ -16,6 +16,8 @@ use super::custom_world_theme_mode_type::CustomWorldThemeMode;
pub struct CustomWorldProfileUpsertInput {
pub profile_id: String,
pub owner_user_id: String,
pub public_work_code: Option::<String>,
pub author_public_user_code: Option::<String>,
pub source_agent_session_id: Option::<String>,
pub world_name: String,
pub subtitle: String,

View File

@@ -16,6 +16,8 @@ pub struct CustomWorldPublishWorldInput {
pub session_id: String,
pub profile_id: String,
pub owner_user_id: String,
pub public_work_code: Option::<String>,
pub author_public_user_code: String,
pub draft_profile_json: String,
pub legacy_result_profile_json: Option::<String>,
pub setting_text: String,

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::puzzle_agent_session_procedure_result_type::PuzzleAgentSessionProcedureResult;
use super::puzzle_agent_message_finalize_input_type::PuzzleAgentMessageFinalizeInput;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct FinalizePuzzleAgentMessageTurnArgs {
pub input: PuzzleAgentMessageFinalizeInput,
}
impl __sdk::InModule for FinalizePuzzleAgentMessageTurnArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `finalize_puzzle_agent_message_turn`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait finalize_puzzle_agent_message_turn {
fn finalize_puzzle_agent_message_turn(&self, input: PuzzleAgentMessageFinalizeInput,
) {
self.finalize_puzzle_agent_message_turn_then(input, |_, _| {});
}
fn finalize_puzzle_agent_message_turn_then(
&self,
input: PuzzleAgentMessageFinalizeInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<PuzzleAgentSessionProcedureResult, __sdk::InternalError>) + Send + 'static,
);
}
impl finalize_puzzle_agent_message_turn for super::RemoteProcedures {
fn finalize_puzzle_agent_message_turn_then(
&self,
input: PuzzleAgentMessageFinalizeInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<PuzzleAgentSessionProcedureResult, __sdk::InternalError>) + Send + 'static,
) {
self.imp.invoke_procedure_with_callback::<_, PuzzleAgentSessionProcedureResult>(
"finalize_puzzle_agent_message_turn",
FinalizePuzzleAgentMessageTurnArgs { 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_library_mutation_result_type::CustomWorldLibraryMutationResult;
use super::custom_world_gallery_detail_by_code_input_type::CustomWorldGalleryDetailByCodeInput;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct GetCustomWorldGalleryDetailByCodeArgs {
pub input: CustomWorldGalleryDetailByCodeInput,
}
impl __sdk::InModule for GetCustomWorldGalleryDetailByCodeArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `get_custom_world_gallery_detail_by_code`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait get_custom_world_gallery_detail_by_code {
fn get_custom_world_gallery_detail_by_code(&self, input: CustomWorldGalleryDetailByCodeInput,
) {
self.get_custom_world_gallery_detail_by_code_then(input, |_, _| {});
}
fn get_custom_world_gallery_detail_by_code_then(
&self,
input: CustomWorldGalleryDetailByCodeInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<CustomWorldLibraryMutationResult, __sdk::InternalError>) + Send + 'static,
);
}
impl get_custom_world_gallery_detail_by_code for super::RemoteProcedures {
fn get_custom_world_gallery_detail_by_code_then(
&self,
input: CustomWorldGalleryDetailByCodeInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<CustomWorldLibraryMutationResult, __sdk::InternalError>) + Send + 'static,
) {
self.imp.invoke_procedure_with_callback::<_, CustomWorldLibraryMutationResult>(
"get_custom_world_gallery_detail_by_code",
GetCustomWorldGalleryDetailByCodeArgs { 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::big_fish_works_list_input_type::BigFishWorksListInput;
use super::big_fish_works_procedure_result_type::BigFishWorksProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct ListBigFishWorksArgs {
pub input: BigFishWorksListInput,
}
impl __sdk::InModule for ListBigFishWorksArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `list_big_fish_works`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait list_big_fish_works {
fn list_big_fish_works(&self, input: BigFishWorksListInput,
) {
self.list_big_fish_works_then(input, |_, _| {});
}
fn list_big_fish_works_then(
&self,
input: BigFishWorksListInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<BigFishWorksProcedureResult, __sdk::InternalError>) + Send + 'static,
);
}
impl list_big_fish_works for super::RemoteProcedures {
fn list_big_fish_works_then(
&self,
input: BigFishWorksListInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<BigFishWorksProcedureResult, __sdk::InternalError>) + Send + 'static,
) {
self.imp.invoke_procedure_with_callback::<_, BigFishWorksProcedureResult>(
"list_big_fish_works",
ListBigFishWorksArgs { input, },
__callback,
);
}
}

View File

@@ -86,6 +86,8 @@ pub mod big_fish_session_get_input_type;
pub mod big_fish_session_procedure_result_type;
pub mod big_fish_session_snapshot_type;
pub mod big_fish_vector_2_type;
pub mod big_fish_works_list_input_type;
pub mod big_fish_works_procedure_result_type;
pub mod chapter_pace_band_type;
pub mod chapter_progression_type;
pub mod chapter_progression_get_input_type;
@@ -116,6 +118,7 @@ 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_by_code_input_type;
pub mod custom_world_gallery_detail_input_type;
pub mod custom_world_gallery_entry_type;
pub mod custom_world_gallery_entry_snapshot_type;
@@ -179,6 +182,7 @@ pub mod profile_dashboard_state_type;
pub mod profile_played_world_type;
pub mod profile_save_archive_type;
pub mod profile_wallet_ledger_type;
pub mod puzzle_agent_message_finalize_input_type;
pub mod puzzle_agent_message_kind_type;
pub mod puzzle_agent_message_role_type;
pub mod puzzle_agent_message_row_type;
@@ -328,7 +332,44 @@ pub mod unpublish_custom_world_profile_reducer;
pub mod upsert_chapter_progression_reducer;
pub mod upsert_custom_world_profile_reducer;
pub mod upsert_npc_state_reducer;
pub mod ai_result_reference_table;
pub mod ai_task_table;
pub mod ai_task_stage_table;
pub mod ai_text_chunk_table;
pub mod asset_entity_binding_table;
pub mod asset_object_table;
pub mod battle_state_table;
pub mod big_fish_agent_message_table;
pub mod big_fish_asset_slot_table;
pub mod big_fish_creation_session_table;
pub mod big_fish_runtime_run_table;
pub mod chapter_progression_table;
pub mod custom_world_agent_message_table;
pub mod custom_world_agent_operation_table;
pub mod custom_world_agent_session_table;
pub mod custom_world_draft_card_table;
pub mod custom_world_gallery_entry_table;
pub mod custom_world_profile_table;
pub mod custom_world_session_table;
pub mod inventory_slot_table;
pub mod npc_state_table;
pub mod player_progression_table;
pub mod profile_dashboard_state_table;
pub mod profile_played_world_table;
pub mod profile_save_archive_table;
pub mod profile_wallet_ledger_table;
pub mod puzzle_agent_message_table;
pub mod puzzle_agent_session_table;
pub mod puzzle_runtime_run_table;
pub mod puzzle_work_profile_table;
pub mod quest_log_table;
pub mod quest_record_table;
pub mod runtime_setting_table;
pub mod runtime_snapshot_table;
pub mod story_event_table;
pub mod story_session_table;
pub mod treasure_record_table;
pub mod user_browse_history_table;
pub mod advance_puzzle_next_level_procedure;
pub mod append_ai_text_chunk_and_return_procedure;
pub mod apply_chapter_progression_ledger_entry_and_return_procedure;
@@ -355,6 +396,7 @@ pub mod drag_puzzle_piece_or_group_procedure;
pub mod execute_custom_world_agent_action_procedure;
pub mod fail_ai_task_and_return_procedure;
pub mod finalize_custom_world_agent_message_turn_procedure;
pub mod finalize_puzzle_agent_message_turn_procedure;
pub mod generate_big_fish_asset_procedure;
pub mod get_battle_state_procedure;
pub mod get_big_fish_run_procedure;
@@ -364,6 +406,7 @@ 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;
pub mod get_custom_world_gallery_detail_by_code_procedure;
pub mod get_custom_world_library_detail_procedure;
pub mod get_player_progression_or_default_procedure;
pub mod get_profile_dashboard_procedure;
@@ -377,6 +420,7 @@ pub mod get_runtime_setting_or_default_procedure;
pub mod get_runtime_snapshot_procedure;
pub mod get_story_session_state_procedure;
pub mod grant_player_progression_experience_and_return_procedure;
pub mod list_big_fish_works_procedure;
pub mod list_custom_world_gallery_entries_procedure;
pub mod list_custom_world_profiles_procedure;
pub mod list_custom_world_works_procedure;
@@ -488,6 +532,8 @@ pub use big_fish_session_get_input_type::BigFishSessionGetInput;
pub use big_fish_session_procedure_result_type::BigFishSessionProcedureResult;
pub use big_fish_session_snapshot_type::BigFishSessionSnapshot;
pub use big_fish_vector_2_type::BigFishVector2;
pub use big_fish_works_list_input_type::BigFishWorksListInput;
pub use big_fish_works_procedure_result_type::BigFishWorksProcedureResult;
pub use chapter_pace_band_type::ChapterPaceBand;
pub use chapter_progression_type::ChapterProgression;
pub use chapter_progression_get_input_type::ChapterProgressionGetInput;
@@ -518,6 +564,7 @@ pub use custom_world_draft_card_detail_result_type::CustomWorldDraftCardDetailRe
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_by_code_input_type::CustomWorldGalleryDetailByCodeInput;
pub use custom_world_gallery_detail_input_type::CustomWorldGalleryDetailInput;
pub use custom_world_gallery_entry_type::CustomWorldGalleryEntry;
pub use custom_world_gallery_entry_snapshot_type::CustomWorldGalleryEntrySnapshot;
@@ -581,6 +628,7 @@ pub use profile_dashboard_state_type::ProfileDashboardState;
pub use profile_played_world_type::ProfilePlayedWorld;
pub use profile_save_archive_type::ProfileSaveArchive;
pub use profile_wallet_ledger_type::ProfileWalletLedger;
pub use puzzle_agent_message_finalize_input_type::PuzzleAgentMessageFinalizeInput;
pub use puzzle_agent_message_kind_type::PuzzleAgentMessageKind;
pub use puzzle_agent_message_role_type::PuzzleAgentMessageRole;
pub use puzzle_agent_message_row_type::PuzzleAgentMessageRow;
@@ -706,7 +754,44 @@ pub use treasure_record_snapshot_type::TreasureRecordSnapshot;
pub use treasure_resolve_input_type::TreasureResolveInput;
pub use unequip_inventory_item_input_type::UnequipInventoryItemInput;
pub use user_browse_history_type::UserBrowseHistory;
pub use ai_result_reference_table::*;
pub use ai_task_table::*;
pub use ai_task_stage_table::*;
pub use ai_text_chunk_table::*;
pub use asset_entity_binding_table::*;
pub use asset_object_table::*;
pub use battle_state_table::*;
pub use big_fish_agent_message_table::*;
pub use big_fish_asset_slot_table::*;
pub use big_fish_creation_session_table::*;
pub use big_fish_runtime_run_table::*;
pub use chapter_progression_table::*;
pub use custom_world_agent_message_table::*;
pub use custom_world_agent_operation_table::*;
pub use custom_world_agent_session_table::*;
pub use custom_world_draft_card_table::*;
pub use custom_world_gallery_entry_table::*;
pub use custom_world_profile_table::*;
pub use custom_world_session_table::*;
pub use inventory_slot_table::*;
pub use npc_state_table::*;
pub use player_progression_table::*;
pub use profile_dashboard_state_table::*;
pub use profile_played_world_table::*;
pub use profile_save_archive_table::*;
pub use profile_wallet_ledger_table::*;
pub use puzzle_agent_message_table::*;
pub use puzzle_agent_session_table::*;
pub use puzzle_runtime_run_table::*;
pub use puzzle_work_profile_table::*;
pub use quest_log_table::*;
pub use quest_record_table::*;
pub use runtime_setting_table::*;
pub use runtime_snapshot_table::*;
pub use story_event_table::*;
pub use story_session_table::*;
pub use treasure_record_table::*;
pub use user_browse_history_table::*;
pub use accept_quest_reducer::accept_quest;
pub use acknowledge_quest_completion_reducer::acknowledge_quest_completion;
pub use apply_chapter_progression_ledger_entry_reducer::apply_chapter_progression_ledger_entry;
@@ -757,6 +842,7 @@ pub use drag_puzzle_piece_or_group_procedure::drag_puzzle_piece_or_group;
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 finalize_custom_world_agent_message_turn_procedure::finalize_custom_world_agent_message_turn;
pub use finalize_puzzle_agent_message_turn_procedure::finalize_puzzle_agent_message_turn;
pub use generate_big_fish_asset_procedure::generate_big_fish_asset;
pub use get_battle_state_procedure::get_battle_state;
pub use get_big_fish_run_procedure::get_big_fish_run;
@@ -766,6 +852,7 @@ pub use get_custom_world_agent_card_detail_procedure::get_custom_world_agent_car
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;
pub use get_custom_world_gallery_detail_by_code_procedure::get_custom_world_gallery_detail_by_code;
pub use get_custom_world_library_detail_procedure::get_custom_world_library_detail;
pub use get_player_progression_or_default_procedure::get_player_progression_or_default;
pub use get_profile_dashboard_procedure::get_profile_dashboard;
@@ -779,6 +866,7 @@ pub use get_runtime_setting_or_default_procedure::get_runtime_setting_or_default
pub use get_runtime_snapshot_procedure::get_runtime_snapshot;
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_big_fish_works_procedure::list_big_fish_works;
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;
@@ -1064,7 +1152,44 @@ fn args_bsatn(&self) -> Result<Vec<u8>, __sats::bsatn::EncodeError> {
#[allow(non_snake_case)]
#[doc(hidden)]
pub struct DbUpdate {
custom_world_gallery_entry: __sdk::TableUpdate<CustomWorldGalleryEntry>,
ai_result_reference: __sdk::TableUpdate<AiResultReference>,
ai_task: __sdk::TableUpdate<AiTask>,
ai_task_stage: __sdk::TableUpdate<AiTaskStage>,
ai_text_chunk: __sdk::TableUpdate<AiTextChunk>,
asset_entity_binding: __sdk::TableUpdate<AssetEntityBinding>,
asset_object: __sdk::TableUpdate<AssetObject>,
battle_state: __sdk::TableUpdate<BattleState>,
big_fish_agent_message: __sdk::TableUpdate<BigFishAgentMessage>,
big_fish_asset_slot: __sdk::TableUpdate<BigFishAssetSlot>,
big_fish_creation_session: __sdk::TableUpdate<BigFishCreationSession>,
big_fish_runtime_run: __sdk::TableUpdate<BigFishRuntimeRun>,
chapter_progression: __sdk::TableUpdate<ChapterProgression>,
custom_world_agent_message: __sdk::TableUpdate<CustomWorldAgentMessage>,
custom_world_agent_operation: __sdk::TableUpdate<CustomWorldAgentOperation>,
custom_world_agent_session: __sdk::TableUpdate<CustomWorldAgentSession>,
custom_world_draft_card: __sdk::TableUpdate<CustomWorldDraftCard>,
custom_world_gallery_entry: __sdk::TableUpdate<CustomWorldGalleryEntry>,
custom_world_profile: __sdk::TableUpdate<CustomWorldProfile>,
custom_world_session: __sdk::TableUpdate<CustomWorldSession>,
inventory_slot: __sdk::TableUpdate<InventorySlot>,
npc_state: __sdk::TableUpdate<NpcState>,
player_progression: __sdk::TableUpdate<PlayerProgression>,
profile_dashboard_state: __sdk::TableUpdate<ProfileDashboardState>,
profile_played_world: __sdk::TableUpdate<ProfilePlayedWorld>,
profile_save_archive: __sdk::TableUpdate<ProfileSaveArchive>,
profile_wallet_ledger: __sdk::TableUpdate<ProfileWalletLedger>,
puzzle_agent_message: __sdk::TableUpdate<PuzzleAgentMessageRow>,
puzzle_agent_session: __sdk::TableUpdate<PuzzleAgentSessionRow>,
puzzle_runtime_run: __sdk::TableUpdate<PuzzleRuntimeRunRow>,
puzzle_work_profile: __sdk::TableUpdate<PuzzleWorkProfileRow>,
quest_log: __sdk::TableUpdate<QuestLog>,
quest_record: __sdk::TableUpdate<QuestRecord>,
runtime_setting: __sdk::TableUpdate<RuntimeSetting>,
runtime_snapshot: __sdk::TableUpdate<RuntimeSnapshotRow>,
story_event: __sdk::TableUpdate<StoryEvent>,
story_session: __sdk::TableUpdate<StorySession>,
treasure_record: __sdk::TableUpdate<TreasureRecord>,
user_browse_history: __sdk::TableUpdate<UserBrowseHistory>,
}
@@ -1075,7 +1200,44 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate {
for table_update in __sdk::transaction_update_iter_table_updates(raw) {
match &table_update.table_name[..] {
"custom_world_gallery_entry" => db_update.custom_world_gallery_entry.append(custom_world_gallery_entry_table::parse_table_update(table_update)?),
"ai_result_reference" => db_update.ai_result_reference.append(ai_result_reference_table::parse_table_update(table_update)?),
"ai_task" => db_update.ai_task.append(ai_task_table::parse_table_update(table_update)?),
"ai_task_stage" => db_update.ai_task_stage.append(ai_task_stage_table::parse_table_update(table_update)?),
"ai_text_chunk" => db_update.ai_text_chunk.append(ai_text_chunk_table::parse_table_update(table_update)?),
"asset_entity_binding" => db_update.asset_entity_binding.append(asset_entity_binding_table::parse_table_update(table_update)?),
"asset_object" => db_update.asset_object.append(asset_object_table::parse_table_update(table_update)?),
"battle_state" => db_update.battle_state.append(battle_state_table::parse_table_update(table_update)?),
"big_fish_agent_message" => db_update.big_fish_agent_message.append(big_fish_agent_message_table::parse_table_update(table_update)?),
"big_fish_asset_slot" => db_update.big_fish_asset_slot.append(big_fish_asset_slot_table::parse_table_update(table_update)?),
"big_fish_creation_session" => db_update.big_fish_creation_session.append(big_fish_creation_session_table::parse_table_update(table_update)?),
"big_fish_runtime_run" => db_update.big_fish_runtime_run.append(big_fish_runtime_run_table::parse_table_update(table_update)?),
"chapter_progression" => db_update.chapter_progression.append(chapter_progression_table::parse_table_update(table_update)?),
"custom_world_agent_message" => db_update.custom_world_agent_message.append(custom_world_agent_message_table::parse_table_update(table_update)?),
"custom_world_agent_operation" => db_update.custom_world_agent_operation.append(custom_world_agent_operation_table::parse_table_update(table_update)?),
"custom_world_agent_session" => db_update.custom_world_agent_session.append(custom_world_agent_session_table::parse_table_update(table_update)?),
"custom_world_draft_card" => db_update.custom_world_draft_card.append(custom_world_draft_card_table::parse_table_update(table_update)?),
"custom_world_gallery_entry" => db_update.custom_world_gallery_entry.append(custom_world_gallery_entry_table::parse_table_update(table_update)?),
"custom_world_profile" => db_update.custom_world_profile.append(custom_world_profile_table::parse_table_update(table_update)?),
"custom_world_session" => db_update.custom_world_session.append(custom_world_session_table::parse_table_update(table_update)?),
"inventory_slot" => db_update.inventory_slot.append(inventory_slot_table::parse_table_update(table_update)?),
"npc_state" => db_update.npc_state.append(npc_state_table::parse_table_update(table_update)?),
"player_progression" => db_update.player_progression.append(player_progression_table::parse_table_update(table_update)?),
"profile_dashboard_state" => db_update.profile_dashboard_state.append(profile_dashboard_state_table::parse_table_update(table_update)?),
"profile_played_world" => db_update.profile_played_world.append(profile_played_world_table::parse_table_update(table_update)?),
"profile_save_archive" => db_update.profile_save_archive.append(profile_save_archive_table::parse_table_update(table_update)?),
"profile_wallet_ledger" => db_update.profile_wallet_ledger.append(profile_wallet_ledger_table::parse_table_update(table_update)?),
"puzzle_agent_message" => db_update.puzzle_agent_message.append(puzzle_agent_message_table::parse_table_update(table_update)?),
"puzzle_agent_session" => db_update.puzzle_agent_session.append(puzzle_agent_session_table::parse_table_update(table_update)?),
"puzzle_runtime_run" => db_update.puzzle_runtime_run.append(puzzle_runtime_run_table::parse_table_update(table_update)?),
"puzzle_work_profile" => db_update.puzzle_work_profile.append(puzzle_work_profile_table::parse_table_update(table_update)?),
"quest_log" => db_update.quest_log.append(quest_log_table::parse_table_update(table_update)?),
"quest_record" => db_update.quest_record.append(quest_record_table::parse_table_update(table_update)?),
"runtime_setting" => db_update.runtime_setting.append(runtime_setting_table::parse_table_update(table_update)?),
"runtime_snapshot" => db_update.runtime_snapshot.append(runtime_snapshot_table::parse_table_update(table_update)?),
"story_event" => db_update.story_event.append(story_event_table::parse_table_update(table_update)?),
"story_session" => db_update.story_session.append(story_session_table::parse_table_update(table_update)?),
"treasure_record" => db_update.treasure_record.append(treasure_record_table::parse_table_update(table_update)?),
"user_browse_history" => db_update.user_browse_history.append(user_browse_history_table::parse_table_update(table_update)?),
unknown => {
return Err(__sdk::InternalError::unknown_name(
@@ -1098,7 +1260,44 @@ impl __sdk::DbUpdate for DbUpdate {
fn apply_to_client_cache(&self, cache: &mut __sdk::ClientCache<RemoteModule>) -> AppliedDiff<'_> {
let mut diff = AppliedDiff::default();
diff.custom_world_gallery_entry = cache.apply_diff_to_table::<CustomWorldGalleryEntry>("custom_world_gallery_entry", &self.custom_world_gallery_entry).with_updates_by_pk(|row| &row.profile_id);
diff.ai_result_reference = cache.apply_diff_to_table::<AiResultReference>("ai_result_reference", &self.ai_result_reference).with_updates_by_pk(|row| &row.result_reference_row_id);
diff.ai_task = cache.apply_diff_to_table::<AiTask>("ai_task", &self.ai_task).with_updates_by_pk(|row| &row.task_id);
diff.ai_task_stage = cache.apply_diff_to_table::<AiTaskStage>("ai_task_stage", &self.ai_task_stage).with_updates_by_pk(|row| &row.task_stage_id);
diff.ai_text_chunk = cache.apply_diff_to_table::<AiTextChunk>("ai_text_chunk", &self.ai_text_chunk).with_updates_by_pk(|row| &row.text_chunk_row_id);
diff.asset_entity_binding = cache.apply_diff_to_table::<AssetEntityBinding>("asset_entity_binding", &self.asset_entity_binding).with_updates_by_pk(|row| &row.binding_id);
diff.asset_object = cache.apply_diff_to_table::<AssetObject>("asset_object", &self.asset_object).with_updates_by_pk(|row| &row.asset_object_id);
diff.battle_state = cache.apply_diff_to_table::<BattleState>("battle_state", &self.battle_state).with_updates_by_pk(|row| &row.battle_state_id);
diff.big_fish_agent_message = cache.apply_diff_to_table::<BigFishAgentMessage>("big_fish_agent_message", &self.big_fish_agent_message).with_updates_by_pk(|row| &row.message_id);
diff.big_fish_asset_slot = cache.apply_diff_to_table::<BigFishAssetSlot>("big_fish_asset_slot", &self.big_fish_asset_slot).with_updates_by_pk(|row| &row.slot_id);
diff.big_fish_creation_session = cache.apply_diff_to_table::<BigFishCreationSession>("big_fish_creation_session", &self.big_fish_creation_session).with_updates_by_pk(|row| &row.session_id);
diff.big_fish_runtime_run = cache.apply_diff_to_table::<BigFishRuntimeRun>("big_fish_runtime_run", &self.big_fish_runtime_run).with_updates_by_pk(|row| &row.run_id);
diff.chapter_progression = cache.apply_diff_to_table::<ChapterProgression>("chapter_progression", &self.chapter_progression).with_updates_by_pk(|row| &row.chapter_progression_id);
diff.custom_world_agent_message = cache.apply_diff_to_table::<CustomWorldAgentMessage>("custom_world_agent_message", &self.custom_world_agent_message).with_updates_by_pk(|row| &row.message_id);
diff.custom_world_agent_operation = cache.apply_diff_to_table::<CustomWorldAgentOperation>("custom_world_agent_operation", &self.custom_world_agent_operation).with_updates_by_pk(|row| &row.operation_id);
diff.custom_world_agent_session = cache.apply_diff_to_table::<CustomWorldAgentSession>("custom_world_agent_session", &self.custom_world_agent_session).with_updates_by_pk(|row| &row.session_id);
diff.custom_world_draft_card = cache.apply_diff_to_table::<CustomWorldDraftCard>("custom_world_draft_card", &self.custom_world_draft_card).with_updates_by_pk(|row| &row.card_id);
diff.custom_world_gallery_entry = cache.apply_diff_to_table::<CustomWorldGalleryEntry>("custom_world_gallery_entry", &self.custom_world_gallery_entry).with_updates_by_pk(|row| &row.profile_id);
diff.custom_world_profile = cache.apply_diff_to_table::<CustomWorldProfile>("custom_world_profile", &self.custom_world_profile).with_updates_by_pk(|row| &row.profile_id);
diff.custom_world_session = cache.apply_diff_to_table::<CustomWorldSession>("custom_world_session", &self.custom_world_session).with_updates_by_pk(|row| &row.session_id);
diff.inventory_slot = cache.apply_diff_to_table::<InventorySlot>("inventory_slot", &self.inventory_slot).with_updates_by_pk(|row| &row.slot_id);
diff.npc_state = cache.apply_diff_to_table::<NpcState>("npc_state", &self.npc_state).with_updates_by_pk(|row| &row.npc_state_id);
diff.player_progression = cache.apply_diff_to_table::<PlayerProgression>("player_progression", &self.player_progression).with_updates_by_pk(|row| &row.user_id);
diff.profile_dashboard_state = cache.apply_diff_to_table::<ProfileDashboardState>("profile_dashboard_state", &self.profile_dashboard_state).with_updates_by_pk(|row| &row.user_id);
diff.profile_played_world = cache.apply_diff_to_table::<ProfilePlayedWorld>("profile_played_world", &self.profile_played_world).with_updates_by_pk(|row| &row.played_world_id);
diff.profile_save_archive = cache.apply_diff_to_table::<ProfileSaveArchive>("profile_save_archive", &self.profile_save_archive).with_updates_by_pk(|row| &row.archive_id);
diff.profile_wallet_ledger = cache.apply_diff_to_table::<ProfileWalletLedger>("profile_wallet_ledger", &self.profile_wallet_ledger).with_updates_by_pk(|row| &row.wallet_ledger_id);
diff.puzzle_agent_message = cache.apply_diff_to_table::<PuzzleAgentMessageRow>("puzzle_agent_message", &self.puzzle_agent_message).with_updates_by_pk(|row| &row.message_id);
diff.puzzle_agent_session = cache.apply_diff_to_table::<PuzzleAgentSessionRow>("puzzle_agent_session", &self.puzzle_agent_session).with_updates_by_pk(|row| &row.session_id);
diff.puzzle_runtime_run = cache.apply_diff_to_table::<PuzzleRuntimeRunRow>("puzzle_runtime_run", &self.puzzle_runtime_run).with_updates_by_pk(|row| &row.run_id);
diff.puzzle_work_profile = cache.apply_diff_to_table::<PuzzleWorkProfileRow>("puzzle_work_profile", &self.puzzle_work_profile).with_updates_by_pk(|row| &row.profile_id);
diff.quest_log = cache.apply_diff_to_table::<QuestLog>("quest_log", &self.quest_log).with_updates_by_pk(|row| &row.log_id);
diff.quest_record = cache.apply_diff_to_table::<QuestRecord>("quest_record", &self.quest_record).with_updates_by_pk(|row| &row.quest_id);
diff.runtime_setting = cache.apply_diff_to_table::<RuntimeSetting>("runtime_setting", &self.runtime_setting).with_updates_by_pk(|row| &row.user_id);
diff.runtime_snapshot = cache.apply_diff_to_table::<RuntimeSnapshotRow>("runtime_snapshot", &self.runtime_snapshot).with_updates_by_pk(|row| &row.user_id);
diff.story_event = cache.apply_diff_to_table::<StoryEvent>("story_event", &self.story_event).with_updates_by_pk(|row| &row.event_id);
diff.story_session = cache.apply_diff_to_table::<StorySession>("story_session", &self.story_session).with_updates_by_pk(|row| &row.story_session_id);
diff.treasure_record = cache.apply_diff_to_table::<TreasureRecord>("treasure_record", &self.treasure_record).with_updates_by_pk(|row| &row.treasure_record_id);
diff.user_browse_history = cache.apply_diff_to_table::<UserBrowseHistory>("user_browse_history", &self.user_browse_history).with_updates_by_pk(|row| &row.browse_history_id);
diff
}
@@ -1106,7 +1305,44 @@ fn parse_initial_rows(raw: __ws::v2::QueryRows) -> __sdk::Result<Self> {
let mut db_update = DbUpdate::default();
for table_rows in raw.tables {
match &table_rows.table[..] {
"custom_world_gallery_entry" => db_update.custom_world_gallery_entry.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"ai_result_reference" => db_update.ai_result_reference.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"ai_task" => db_update.ai_task.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"ai_task_stage" => db_update.ai_task_stage.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"ai_text_chunk" => db_update.ai_text_chunk.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"asset_entity_binding" => db_update.asset_entity_binding.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"asset_object" => db_update.asset_object.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"battle_state" => db_update.battle_state.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"big_fish_agent_message" => db_update.big_fish_agent_message.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"big_fish_asset_slot" => db_update.big_fish_asset_slot.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"big_fish_creation_session" => db_update.big_fish_creation_session.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"big_fish_runtime_run" => db_update.big_fish_runtime_run.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"chapter_progression" => db_update.chapter_progression.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"custom_world_agent_message" => db_update.custom_world_agent_message.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"custom_world_agent_operation" => db_update.custom_world_agent_operation.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"custom_world_agent_session" => db_update.custom_world_agent_session.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"custom_world_draft_card" => db_update.custom_world_draft_card.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"custom_world_gallery_entry" => db_update.custom_world_gallery_entry.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"custom_world_profile" => db_update.custom_world_profile.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"custom_world_session" => db_update.custom_world_session.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"inventory_slot" => db_update.inventory_slot.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"npc_state" => db_update.npc_state.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"player_progression" => db_update.player_progression.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"profile_dashboard_state" => db_update.profile_dashboard_state.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"profile_played_world" => db_update.profile_played_world.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"profile_save_archive" => db_update.profile_save_archive.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"profile_wallet_ledger" => db_update.profile_wallet_ledger.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"puzzle_agent_message" => db_update.puzzle_agent_message.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"puzzle_agent_session" => db_update.puzzle_agent_session.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"puzzle_runtime_run" => db_update.puzzle_runtime_run.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"puzzle_work_profile" => db_update.puzzle_work_profile.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"quest_log" => db_update.quest_log.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"quest_record" => db_update.quest_record.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"runtime_setting" => db_update.runtime_setting.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"runtime_snapshot" => db_update.runtime_snapshot.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"story_event" => db_update.story_event.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"story_session" => db_update.story_session.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"treasure_record" => db_update.treasure_record.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"user_browse_history" => db_update.user_browse_history.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
unknown => { return Err(__sdk::InternalError::unknown_name("table", unknown, "QueryRows").into()); }
}} Ok(db_update)
}
@@ -1114,7 +1350,44 @@ fn parse_unsubscribe_rows(raw: __ws::v2::QueryRows) -> __sdk::Result<Self> {
let mut db_update = DbUpdate::default();
for table_rows in raw.tables {
match &table_rows.table[..] {
"custom_world_gallery_entry" => db_update.custom_world_gallery_entry.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"ai_result_reference" => db_update.ai_result_reference.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"ai_task" => db_update.ai_task.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"ai_task_stage" => db_update.ai_task_stage.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"ai_text_chunk" => db_update.ai_text_chunk.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"asset_entity_binding" => db_update.asset_entity_binding.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"asset_object" => db_update.asset_object.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"battle_state" => db_update.battle_state.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"big_fish_agent_message" => db_update.big_fish_agent_message.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"big_fish_asset_slot" => db_update.big_fish_asset_slot.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"big_fish_creation_session" => db_update.big_fish_creation_session.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"big_fish_runtime_run" => db_update.big_fish_runtime_run.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"chapter_progression" => db_update.chapter_progression.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"custom_world_agent_message" => db_update.custom_world_agent_message.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"custom_world_agent_operation" => db_update.custom_world_agent_operation.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"custom_world_agent_session" => db_update.custom_world_agent_session.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"custom_world_draft_card" => db_update.custom_world_draft_card.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"custom_world_gallery_entry" => db_update.custom_world_gallery_entry.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"custom_world_profile" => db_update.custom_world_profile.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"custom_world_session" => db_update.custom_world_session.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"inventory_slot" => db_update.inventory_slot.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"npc_state" => db_update.npc_state.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"player_progression" => db_update.player_progression.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"profile_dashboard_state" => db_update.profile_dashboard_state.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"profile_played_world" => db_update.profile_played_world.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"profile_save_archive" => db_update.profile_save_archive.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"profile_wallet_ledger" => db_update.profile_wallet_ledger.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"puzzle_agent_message" => db_update.puzzle_agent_message.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"puzzle_agent_session" => db_update.puzzle_agent_session.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"puzzle_runtime_run" => db_update.puzzle_runtime_run.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"puzzle_work_profile" => db_update.puzzle_work_profile.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"quest_log" => db_update.quest_log.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"quest_record" => db_update.quest_record.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"runtime_setting" => db_update.runtime_setting.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"runtime_snapshot" => db_update.runtime_snapshot.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"story_event" => db_update.story_event.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"story_session" => db_update.story_session.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"treasure_record" => db_update.treasure_record.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"user_browse_history" => db_update.user_browse_history.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
unknown => { return Err(__sdk::InternalError::unknown_name("table", unknown, "QueryRows").into()); }
}} Ok(db_update)
}
@@ -1124,7 +1397,44 @@ for table_rows in raw.tables {
#[allow(non_snake_case)]
#[doc(hidden)]
pub struct AppliedDiff<'r> {
custom_world_gallery_entry: __sdk::TableAppliedDiff<'r, CustomWorldGalleryEntry>,
ai_result_reference: __sdk::TableAppliedDiff<'r, AiResultReference>,
ai_task: __sdk::TableAppliedDiff<'r, AiTask>,
ai_task_stage: __sdk::TableAppliedDiff<'r, AiTaskStage>,
ai_text_chunk: __sdk::TableAppliedDiff<'r, AiTextChunk>,
asset_entity_binding: __sdk::TableAppliedDiff<'r, AssetEntityBinding>,
asset_object: __sdk::TableAppliedDiff<'r, AssetObject>,
battle_state: __sdk::TableAppliedDiff<'r, BattleState>,
big_fish_agent_message: __sdk::TableAppliedDiff<'r, BigFishAgentMessage>,
big_fish_asset_slot: __sdk::TableAppliedDiff<'r, BigFishAssetSlot>,
big_fish_creation_session: __sdk::TableAppliedDiff<'r, BigFishCreationSession>,
big_fish_runtime_run: __sdk::TableAppliedDiff<'r, BigFishRuntimeRun>,
chapter_progression: __sdk::TableAppliedDiff<'r, ChapterProgression>,
custom_world_agent_message: __sdk::TableAppliedDiff<'r, CustomWorldAgentMessage>,
custom_world_agent_operation: __sdk::TableAppliedDiff<'r, CustomWorldAgentOperation>,
custom_world_agent_session: __sdk::TableAppliedDiff<'r, CustomWorldAgentSession>,
custom_world_draft_card: __sdk::TableAppliedDiff<'r, CustomWorldDraftCard>,
custom_world_gallery_entry: __sdk::TableAppliedDiff<'r, CustomWorldGalleryEntry>,
custom_world_profile: __sdk::TableAppliedDiff<'r, CustomWorldProfile>,
custom_world_session: __sdk::TableAppliedDiff<'r, CustomWorldSession>,
inventory_slot: __sdk::TableAppliedDiff<'r, InventorySlot>,
npc_state: __sdk::TableAppliedDiff<'r, NpcState>,
player_progression: __sdk::TableAppliedDiff<'r, PlayerProgression>,
profile_dashboard_state: __sdk::TableAppliedDiff<'r, ProfileDashboardState>,
profile_played_world: __sdk::TableAppliedDiff<'r, ProfilePlayedWorld>,
profile_save_archive: __sdk::TableAppliedDiff<'r, ProfileSaveArchive>,
profile_wallet_ledger: __sdk::TableAppliedDiff<'r, ProfileWalletLedger>,
puzzle_agent_message: __sdk::TableAppliedDiff<'r, PuzzleAgentMessageRow>,
puzzle_agent_session: __sdk::TableAppliedDiff<'r, PuzzleAgentSessionRow>,
puzzle_runtime_run: __sdk::TableAppliedDiff<'r, PuzzleRuntimeRunRow>,
puzzle_work_profile: __sdk::TableAppliedDiff<'r, PuzzleWorkProfileRow>,
quest_log: __sdk::TableAppliedDiff<'r, QuestLog>,
quest_record: __sdk::TableAppliedDiff<'r, QuestRecord>,
runtime_setting: __sdk::TableAppliedDiff<'r, RuntimeSetting>,
runtime_snapshot: __sdk::TableAppliedDiff<'r, RuntimeSnapshotRow>,
story_event: __sdk::TableAppliedDiff<'r, StoryEvent>,
story_session: __sdk::TableAppliedDiff<'r, StorySession>,
treasure_record: __sdk::TableAppliedDiff<'r, TreasureRecord>,
user_browse_history: __sdk::TableAppliedDiff<'r, UserBrowseHistory>,
__unused: std::marker::PhantomData<&'r ()>,
}
@@ -1135,7 +1445,44 @@ impl __sdk::InModule for AppliedDiff<'_> {
impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> {
fn invoke_row_callbacks(&self, event: &EventContext, callbacks: &mut __sdk::DbCallbacks<RemoteModule>) {
callbacks.invoke_table_row_callbacks::<CustomWorldGalleryEntry>("custom_world_gallery_entry", &self.custom_world_gallery_entry, event);
callbacks.invoke_table_row_callbacks::<AiResultReference>("ai_result_reference", &self.ai_result_reference, event);
callbacks.invoke_table_row_callbacks::<AiTask>("ai_task", &self.ai_task, event);
callbacks.invoke_table_row_callbacks::<AiTaskStage>("ai_task_stage", &self.ai_task_stage, event);
callbacks.invoke_table_row_callbacks::<AiTextChunk>("ai_text_chunk", &self.ai_text_chunk, event);
callbacks.invoke_table_row_callbacks::<AssetEntityBinding>("asset_entity_binding", &self.asset_entity_binding, event);
callbacks.invoke_table_row_callbacks::<AssetObject>("asset_object", &self.asset_object, event);
callbacks.invoke_table_row_callbacks::<BattleState>("battle_state", &self.battle_state, event);
callbacks.invoke_table_row_callbacks::<BigFishAgentMessage>("big_fish_agent_message", &self.big_fish_agent_message, event);
callbacks.invoke_table_row_callbacks::<BigFishAssetSlot>("big_fish_asset_slot", &self.big_fish_asset_slot, event);
callbacks.invoke_table_row_callbacks::<BigFishCreationSession>("big_fish_creation_session", &self.big_fish_creation_session, event);
callbacks.invoke_table_row_callbacks::<BigFishRuntimeRun>("big_fish_runtime_run", &self.big_fish_runtime_run, event);
callbacks.invoke_table_row_callbacks::<ChapterProgression>("chapter_progression", &self.chapter_progression, event);
callbacks.invoke_table_row_callbacks::<CustomWorldAgentMessage>("custom_world_agent_message", &self.custom_world_agent_message, event);
callbacks.invoke_table_row_callbacks::<CustomWorldAgentOperation>("custom_world_agent_operation", &self.custom_world_agent_operation, event);
callbacks.invoke_table_row_callbacks::<CustomWorldAgentSession>("custom_world_agent_session", &self.custom_world_agent_session, event);
callbacks.invoke_table_row_callbacks::<CustomWorldDraftCard>("custom_world_draft_card", &self.custom_world_draft_card, event);
callbacks.invoke_table_row_callbacks::<CustomWorldGalleryEntry>("custom_world_gallery_entry", &self.custom_world_gallery_entry, event);
callbacks.invoke_table_row_callbacks::<CustomWorldProfile>("custom_world_profile", &self.custom_world_profile, event);
callbacks.invoke_table_row_callbacks::<CustomWorldSession>("custom_world_session", &self.custom_world_session, event);
callbacks.invoke_table_row_callbacks::<InventorySlot>("inventory_slot", &self.inventory_slot, event);
callbacks.invoke_table_row_callbacks::<NpcState>("npc_state", &self.npc_state, event);
callbacks.invoke_table_row_callbacks::<PlayerProgression>("player_progression", &self.player_progression, event);
callbacks.invoke_table_row_callbacks::<ProfileDashboardState>("profile_dashboard_state", &self.profile_dashboard_state, event);
callbacks.invoke_table_row_callbacks::<ProfilePlayedWorld>("profile_played_world", &self.profile_played_world, event);
callbacks.invoke_table_row_callbacks::<ProfileSaveArchive>("profile_save_archive", &self.profile_save_archive, event);
callbacks.invoke_table_row_callbacks::<ProfileWalletLedger>("profile_wallet_ledger", &self.profile_wallet_ledger, event);
callbacks.invoke_table_row_callbacks::<PuzzleAgentMessageRow>("puzzle_agent_message", &self.puzzle_agent_message, event);
callbacks.invoke_table_row_callbacks::<PuzzleAgentSessionRow>("puzzle_agent_session", &self.puzzle_agent_session, event);
callbacks.invoke_table_row_callbacks::<PuzzleRuntimeRunRow>("puzzle_runtime_run", &self.puzzle_runtime_run, event);
callbacks.invoke_table_row_callbacks::<PuzzleWorkProfileRow>("puzzle_work_profile", &self.puzzle_work_profile, event);
callbacks.invoke_table_row_callbacks::<QuestLog>("quest_log", &self.quest_log, event);
callbacks.invoke_table_row_callbacks::<QuestRecord>("quest_record", &self.quest_record, event);
callbacks.invoke_table_row_callbacks::<RuntimeSetting>("runtime_setting", &self.runtime_setting, event);
callbacks.invoke_table_row_callbacks::<RuntimeSnapshotRow>("runtime_snapshot", &self.runtime_snapshot, event);
callbacks.invoke_table_row_callbacks::<StoryEvent>("story_event", &self.story_event, event);
callbacks.invoke_table_row_callbacks::<StorySession>("story_session", &self.story_session, event);
callbacks.invoke_table_row_callbacks::<TreasureRecord>("treasure_record", &self.treasure_record, event);
callbacks.invoke_table_row_callbacks::<UserBrowseHistory>("user_browse_history", &self.user_browse_history, event);
}
}
@@ -1787,9 +2134,83 @@ impl __sdk::SpacetimeModule for RemoteModule {
type QueryBuilder = __sdk::QueryBuilder;
fn register_tables(client_cache: &mut __sdk::ClientCache<Self>) {
custom_world_gallery_entry_table::register_table(client_cache);
ai_result_reference_table::register_table(client_cache);
ai_task_table::register_table(client_cache);
ai_task_stage_table::register_table(client_cache);
ai_text_chunk_table::register_table(client_cache);
asset_entity_binding_table::register_table(client_cache);
asset_object_table::register_table(client_cache);
battle_state_table::register_table(client_cache);
big_fish_agent_message_table::register_table(client_cache);
big_fish_asset_slot_table::register_table(client_cache);
big_fish_creation_session_table::register_table(client_cache);
big_fish_runtime_run_table::register_table(client_cache);
chapter_progression_table::register_table(client_cache);
custom_world_agent_message_table::register_table(client_cache);
custom_world_agent_operation_table::register_table(client_cache);
custom_world_agent_session_table::register_table(client_cache);
custom_world_draft_card_table::register_table(client_cache);
custom_world_gallery_entry_table::register_table(client_cache);
custom_world_profile_table::register_table(client_cache);
custom_world_session_table::register_table(client_cache);
inventory_slot_table::register_table(client_cache);
npc_state_table::register_table(client_cache);
player_progression_table::register_table(client_cache);
profile_dashboard_state_table::register_table(client_cache);
profile_played_world_table::register_table(client_cache);
profile_save_archive_table::register_table(client_cache);
profile_wallet_ledger_table::register_table(client_cache);
puzzle_agent_message_table::register_table(client_cache);
puzzle_agent_session_table::register_table(client_cache);
puzzle_runtime_run_table::register_table(client_cache);
puzzle_work_profile_table::register_table(client_cache);
quest_log_table::register_table(client_cache);
quest_record_table::register_table(client_cache);
runtime_setting_table::register_table(client_cache);
runtime_snapshot_table::register_table(client_cache);
story_event_table::register_table(client_cache);
story_session_table::register_table(client_cache);
treasure_record_table::register_table(client_cache);
user_browse_history_table::register_table(client_cache);
}
const ALL_TABLE_NAMES: &'static [&'static str] = &[
"custom_world_gallery_entry",
"ai_result_reference",
"ai_task",
"ai_task_stage",
"ai_text_chunk",
"asset_entity_binding",
"asset_object",
"battle_state",
"big_fish_agent_message",
"big_fish_asset_slot",
"big_fish_creation_session",
"big_fish_runtime_run",
"chapter_progression",
"custom_world_agent_message",
"custom_world_agent_operation",
"custom_world_agent_session",
"custom_world_draft_card",
"custom_world_gallery_entry",
"custom_world_profile",
"custom_world_session",
"inventory_slot",
"npc_state",
"player_progression",
"profile_dashboard_state",
"profile_played_world",
"profile_save_archive",
"profile_wallet_ledger",
"puzzle_agent_message",
"puzzle_agent_session",
"puzzle_runtime_run",
"puzzle_work_profile",
"quest_log",
"quest_record",
"runtime_setting",
"runtime_snapshot",
"story_event",
"story_session",
"treasure_record",
"user_browse_history",
];
}

View File

@@ -0,0 +1,32 @@
// 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::puzzle_agent_stage_type::PuzzleAgentStage;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct PuzzleAgentMessageFinalizeInput {
pub session_id: String,
pub owner_user_id: String,
pub assistant_message_id: Option::<String>,
pub assistant_reply_text: Option::<String>,
pub stage: PuzzleAgentStage,
pub progress_percent: u32,
pub anchor_pack_json: String,
pub error_message: Option::<String>,
pub updated_at_micros: i64,
}
impl __sdk::InModule for PuzzleAgentMessageFinalizeInput {
type Module = super::RemoteModule;
}

View File

@@ -55,6 +55,76 @@
- `cancel_ai_task_and_return`
18. `turn_in_quest``resolve_combat_action(Victory)``player_progression / chapter_progression` 的最小经验联动
## 2.1 `src/lib.rs` 拆分路由规则
`2026-04-23` 起,`src/lib.rs` 不再允许继续承载具体业务域的 table / reducer / procedure / tx helper。
根入口后续只允许保留:
1. `use` 聚合
2. `mod` 声明
3. 少量跨域共享 helper
4. 迁移过渡期测试
根入口与子模块的导入导出规则同步冻结为:
1. `src/lib.rs` 对外统一优先使用 `pub use xxx::*;` 重新导出模块内容
2. 已拆业务模块内部统一优先使用 `use crate::*;` 复用主入口已聚合的类型与函数
3. 只有当 `use crate::*;` 无法覆盖或会引入明显歧义时,才补局部显式 `use`
4. 新增业务域内容禁止为了堆 `use` 列表再回写到 `src/lib.rs`
具体内容必须落到下面的模块:
1. `src/entry.rs`
- SpacetimeDB `init` 入口
2. `src/domain_types.rs`
- 跨域共享的 SpacetimeDB 类型
3. `src/asset_metadata/`
- 资产对象与资产绑定真相表
4. `src/big_fish/`
- Big Fish 创作与运行态
5. `src/runtime/`
- runtime setting / snapshot / browse history / profile 投影
6. `src/gameplay/`
- `story / combat / inventory / npc / quest / runtime_item / progression`
7. `src/custom_world/`
- custom world profile / session / agent / publishing / gallery / works
8. `src/ai/`
- ai task / stage / chunk / result reference
9. `src/puzzle.rs`
- 拼图玩法当前仍为单文件域模块
### 已冻结的二级模块落位点
1. `src/asset_metadata/objects.rs`
2. `src/asset_metadata/bindings.rs`
3. `src/big_fish/tables.rs`
4. `src/big_fish/session.rs`
5. `src/big_fish/assets.rs`
6. `src/big_fish/runtime.rs`
7. `src/runtime/settings.rs`
8. `src/runtime/snapshots.rs`
9. `src/runtime/browse_history.rs`
10. `src/runtime/profile.rs`
11. `src/gameplay/combat.rs`
12. `src/gameplay/inventory.rs`
13. `src/gameplay/npc.rs`
14. `src/gameplay/progression.rs`
15. `src/gameplay/quest.rs`
16. `src/gameplay/runtime_item.rs`
17. `src/gameplay/story.rs`
18. `src/custom_world/profile.rs`
19. `src/custom_world/session.rs`
20. `src/custom_world/agent.rs`
21. `src/custom_world/publishing.rs`
22. `src/custom_world/gallery.rs`
23. `src/custom_world/works.rs`
24. `src/ai/tasks.rs`
25. `src/ai/stages.rs`
26. `src/ai/snapshots.rs`
后续如果新增 SpacetimeDB 表、reducer、procedure 或同域 helper必须先判断属于哪个一级模块与二级落位点再写入对应文件禁止直接追加到 `src/lib.rs`
`asset_object` 的详细设计见:
1. [../../../docs/technical/SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md)

View File

@@ -0,0 +1 @@
// AI snapshot / row 转换 helper 落位点。

View File

@@ -0,0 +1 @@
// AI stage、chunk、reference 与阶段级 helper 落位点。

View File

@@ -0,0 +1 @@
// AI task reducer / procedure 与任务状态迁移落位点。

View File

@@ -0,0 +1,142 @@
use crate::*;
#[spacetimedb::table(
accessor = asset_entity_binding,
index(accessor = by_entity_slot, btree(columns = [entity_kind, entity_id, slot])),
index(accessor = by_asset_object_id, btree(columns = [asset_object_id]))
)]
pub struct AssetEntityBinding {
#[primary_key]
binding_id: String,
asset_object_id: String,
entity_kind: String,
entity_id: String,
slot: String,
asset_kind: String,
owner_user_id: Option<String>,
profile_id: Option<String>,
created_at: Timestamp,
updated_at: Timestamp,
}
// reducer 负责把已确认对象绑定到实体槽位,强业务资产表稳定前先用通用绑定表承接关系。
#[spacetimedb::reducer]
pub fn bind_asset_object_to_entity(
ctx: &ReducerContext,
input: AssetEntityBindingInput,
) -> Result<(), String> {
upsert_asset_entity_binding(ctx, input).map(|_| ())
}
// procedure 面向 Axum 同步绑定接口,返回最终绑定快照,避免 HTTP 层读取 private table。
#[spacetimedb::procedure]
pub fn bind_asset_object_to_entity_and_return(
ctx: &mut ProcedureContext,
input: AssetEntityBindingInput,
) -> AssetEntityBindingProcedureResult {
match ctx.try_with_tx(|tx| upsert_asset_entity_binding(tx, input.clone())) {
Ok(record) => AssetEntityBindingProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => AssetEntityBindingProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
fn upsert_asset_entity_binding(
ctx: &ReducerContext,
input: AssetEntityBindingInput,
) -> Result<AssetEntityBindingSnapshot, String> {
validate_asset_entity_binding_fields(
&input.binding_id,
&input.asset_object_id,
&input.entity_kind,
&input.entity_id,
&input.slot,
&input.asset_kind,
)
.map_err(|error| error.to_string())?;
if !has_asset_object(ctx, &input.asset_object_id) {
return Err("asset_entity_binding.asset_object_id 对应的 asset_object 不存在".to_string());
}
let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
// 首版绑定按 entity_kind + entity_id + slot 幂等定位,后续访问量明确后再改为组合索引扫描。
let current = ctx.db.asset_entity_binding().iter().find(|row| {
row.entity_kind == input.entity_kind
&& row.entity_id == input.entity_id
&& row.slot == input.slot
});
let snapshot = match current {
Some(existing) => {
ctx.db
.asset_entity_binding()
.binding_id()
.delete(&existing.binding_id);
let row = AssetEntityBinding {
binding_id: existing.binding_id.clone(),
asset_object_id: input.asset_object_id.clone(),
entity_kind: input.entity_kind.clone(),
entity_id: input.entity_id.clone(),
slot: input.slot.clone(),
asset_kind: input.asset_kind.clone(),
owner_user_id: input.owner_user_id.clone(),
profile_id: input.profile_id.clone(),
created_at: existing.created_at,
updated_at,
};
ctx.db.asset_entity_binding().insert(row);
AssetEntityBindingSnapshot {
binding_id: existing.binding_id,
asset_object_id: input.asset_object_id,
entity_kind: input.entity_kind,
entity_id: input.entity_id,
slot: input.slot,
asset_kind: input.asset_kind,
owner_user_id: input.owner_user_id,
profile_id: input.profile_id,
created_at_micros: existing.created_at.to_micros_since_unix_epoch(),
updated_at_micros: input.updated_at_micros,
}
}
None => {
let created_at = updated_at;
let row = AssetEntityBinding {
binding_id: input.binding_id.clone(),
asset_object_id: input.asset_object_id.clone(),
entity_kind: input.entity_kind.clone(),
entity_id: input.entity_id.clone(),
slot: input.slot.clone(),
asset_kind: input.asset_kind.clone(),
owner_user_id: input.owner_user_id.clone(),
profile_id: input.profile_id.clone(),
created_at,
updated_at,
};
ctx.db.asset_entity_binding().insert(row);
AssetEntityBindingSnapshot {
binding_id: input.binding_id,
asset_object_id: input.asset_object_id,
entity_kind: input.entity_kind,
entity_id: input.entity_id,
slot: input.slot,
asset_kind: input.asset_kind,
owner_user_id: input.owner_user_id,
profile_id: input.profile_id,
created_at_micros: input.updated_at_micros,
updated_at_micros: input.updated_at_micros,
}
}
};
Ok(snapshot)
}

View File

@@ -1,305 +1,5 @@
#[spacetimedb::table(
accessor = asset_object,
index(accessor = by_bucket_object_key, btree(columns = [bucket, object_key]))
)]
pub struct AssetObject {
#[primary_key]
asset_object_id: String,
// 正式对象定位固定拆成 bucket + object_key 两列,避免后续再从单字符串路径做 schema 拆分。
bucket: String,
object_key: String,
access_policy: AssetObjectAccessPolicy,
content_type: Option<String>,
content_length: u64,
content_hash: Option<String>,
version: u32,
source_job_id: Option<String>,
owner_user_id: Option<String>,
profile_id: Option<String>,
entity_id: Option<String>,
#[index(btree)]
asset_kind: String,
created_at: Timestamp,
updated_at: Timestamp,
}
mod bindings;
mod objects;
#[spacetimedb::table(
accessor = asset_entity_binding,
index(accessor = by_entity_slot, btree(columns = [entity_kind, entity_id, slot])),
index(accessor = by_asset_object_id, btree(columns = [asset_object_id]))
)]
pub struct AssetEntityBinding {
#[primary_key]
binding_id: String,
asset_object_id: String,
entity_kind: String,
entity_id: String,
slot: String,
asset_kind: String,
owner_user_id: Option<String>,
profile_id: Option<String>,
created_at: Timestamp,
updated_at: Timestamp,
}
// reducer 负责固定资产对象的正式写规则,供后续内部模块逻辑复用。
#[spacetimedb::reducer]
pub fn confirm_asset_object(
ctx: &ReducerContext,
input: AssetObjectUpsertInput,
) -> Result<(), String> {
upsert_asset_object(ctx, input).map(|_| ())
}
// procedure 面向 Axum 同步确认接口,返回最终持久化后的对象记录,避免 HTTP 层再额外查询 private table。
#[spacetimedb::procedure]
pub fn confirm_asset_object_and_return(
ctx: &mut ProcedureContext,
input: AssetObjectUpsertInput,
) -> AssetObjectProcedureResult {
match ctx.try_with_tx(|tx| upsert_asset_object(tx, input.clone())) {
Ok(record) => AssetObjectProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => AssetObjectProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
// reducer 负责把已确认对象绑定到实体槽位,强业务资产表稳定前先用通用绑定表承接关系。
#[spacetimedb::reducer]
pub fn bind_asset_object_to_entity(
ctx: &ReducerContext,
input: AssetEntityBindingInput,
) -> Result<(), String> {
upsert_asset_entity_binding(ctx, input).map(|_| ())
}
// procedure 面向 Axum 同步绑定接口,返回最终绑定快照,避免 HTTP 层读取 private table。
#[spacetimedb::procedure]
pub fn bind_asset_object_to_entity_and_return(
ctx: &mut ProcedureContext,
input: AssetEntityBindingInput,
) -> AssetEntityBindingProcedureResult {
match ctx.try_with_tx(|tx| upsert_asset_entity_binding(tx, input.clone())) {
Ok(record) => AssetEntityBindingProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => AssetEntityBindingProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
fn upsert_asset_object(
ctx: &ReducerContext,
input: AssetObjectUpsertInput,
) -> Result<AssetObjectUpsertSnapshot, String> {
validate_asset_object_fields(
&input.bucket,
&input.object_key,
&input.asset_kind,
input.version,
)
.map_err(|error| error.to_string())?;
let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
// 这里先保持最小可发布实现:查重语义已经冻结,后续再把实现优化回组合索引扫描。
let current = ctx
.db
.asset_object()
.iter()
.find(|row| row.bucket == input.bucket && row.object_key == input.object_key);
let snapshot = match current {
Some(existing) => {
ctx.db
.asset_object()
.asset_object_id()
.delete(&existing.asset_object_id);
let row = AssetObject {
asset_object_id: existing.asset_object_id.clone(),
bucket: input.bucket.clone(),
object_key: input.object_key.clone(),
access_policy: input.access_policy,
content_type: input.content_type.clone(),
content_length: input.content_length,
content_hash: input.content_hash.clone(),
version: input.version,
source_job_id: input.source_job_id.clone(),
owner_user_id: input.owner_user_id.clone(),
profile_id: input.profile_id.clone(),
entity_id: input.entity_id.clone(),
asset_kind: input.asset_kind.clone(),
created_at: existing.created_at,
updated_at,
};
ctx.db.asset_object().insert(row);
AssetObjectUpsertSnapshot {
asset_object_id: existing.asset_object_id,
bucket: input.bucket,
object_key: input.object_key,
access_policy: input.access_policy,
content_type: input.content_type,
content_length: input.content_length,
content_hash: input.content_hash,
version: input.version,
source_job_id: input.source_job_id,
owner_user_id: input.owner_user_id,
profile_id: input.profile_id,
entity_id: input.entity_id,
asset_kind: input.asset_kind,
created_at_micros: existing.created_at.to_micros_since_unix_epoch(),
updated_at_micros: input.updated_at_micros,
}
}
None => {
let created_at = updated_at;
let row = AssetObject {
asset_object_id: input.asset_object_id.clone(),
bucket: input.bucket.clone(),
object_key: input.object_key.clone(),
access_policy: input.access_policy,
content_type: input.content_type.clone(),
content_length: input.content_length,
content_hash: input.content_hash.clone(),
version: input.version,
source_job_id: input.source_job_id.clone(),
owner_user_id: input.owner_user_id.clone(),
profile_id: input.profile_id.clone(),
entity_id: input.entity_id.clone(),
asset_kind: input.asset_kind.clone(),
created_at,
updated_at,
};
ctx.db.asset_object().insert(row);
AssetObjectUpsertSnapshot {
asset_object_id: input.asset_object_id,
bucket: input.bucket,
object_key: input.object_key,
access_policy: input.access_policy,
content_type: input.content_type,
content_length: input.content_length,
content_hash: input.content_hash,
version: input.version,
source_job_id: input.source_job_id,
owner_user_id: input.owner_user_id,
profile_id: input.profile_id,
entity_id: input.entity_id,
asset_kind: input.asset_kind,
created_at_micros: input.updated_at_micros,
updated_at_micros: input.updated_at_micros,
}
}
};
Ok(snapshot)
}
fn upsert_asset_entity_binding(
ctx: &ReducerContext,
input: AssetEntityBindingInput,
) -> Result<AssetEntityBindingSnapshot, String> {
validate_asset_entity_binding_fields(
&input.binding_id,
&input.asset_object_id,
&input.entity_kind,
&input.entity_id,
&input.slot,
&input.asset_kind,
)
.map_err(|error| error.to_string())?;
if ctx
.db
.asset_object()
.asset_object_id()
.find(&input.asset_object_id)
.is_none()
{
return Err("asset_entity_binding.asset_object_id 对应的 asset_object 不存在".to_string());
}
let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
// 首版绑定按 entity_kind + entity_id + slot 幂等定位,后续访问量明确后再改为组合索引扫描。
let current = ctx.db.asset_entity_binding().iter().find(|row| {
row.entity_kind == input.entity_kind
&& row.entity_id == input.entity_id
&& row.slot == input.slot
});
let snapshot = match current {
Some(existing) => {
ctx.db
.asset_entity_binding()
.binding_id()
.delete(&existing.binding_id);
let row = AssetEntityBinding {
binding_id: existing.binding_id.clone(),
asset_object_id: input.asset_object_id.clone(),
entity_kind: input.entity_kind.clone(),
entity_id: input.entity_id.clone(),
slot: input.slot.clone(),
asset_kind: input.asset_kind.clone(),
owner_user_id: input.owner_user_id.clone(),
profile_id: input.profile_id.clone(),
created_at: existing.created_at,
updated_at,
};
ctx.db.asset_entity_binding().insert(row);
AssetEntityBindingSnapshot {
binding_id: existing.binding_id,
asset_object_id: input.asset_object_id,
entity_kind: input.entity_kind,
entity_id: input.entity_id,
slot: input.slot,
asset_kind: input.asset_kind,
owner_user_id: input.owner_user_id,
profile_id: input.profile_id,
created_at_micros: existing.created_at.to_micros_since_unix_epoch(),
updated_at_micros: input.updated_at_micros,
}
}
None => {
let created_at = updated_at;
let row = AssetEntityBinding {
binding_id: input.binding_id.clone(),
asset_object_id: input.asset_object_id.clone(),
entity_kind: input.entity_kind.clone(),
entity_id: input.entity_id.clone(),
slot: input.slot.clone(),
asset_kind: input.asset_kind.clone(),
owner_user_id: input.owner_user_id.clone(),
profile_id: input.profile_id.clone(),
created_at,
updated_at,
};
ctx.db.asset_entity_binding().insert(row);
AssetEntityBindingSnapshot {
binding_id: input.binding_id,
asset_object_id: input.asset_object_id,
entity_kind: input.entity_kind,
entity_id: input.entity_id,
slot: input.slot,
asset_kind: input.asset_kind,
owner_user_id: input.owner_user_id,
profile_id: input.profile_id,
created_at_micros: input.updated_at_micros,
updated_at_micros: input.updated_at_micros,
}
}
};
Ok(snapshot)
}
pub use bindings::*;
pub use objects::*;

View File

@@ -0,0 +1,169 @@
use crate::*;
#[spacetimedb::table(
accessor = asset_object,
index(accessor = by_bucket_object_key, btree(columns = [bucket, object_key]))
)]
pub struct AssetObject {
#[primary_key]
asset_object_id: String,
// 正式对象定位固定拆成 bucket + object_key 两列,避免后续再从单字符串路径做 schema 拆分。
bucket: String,
object_key: String,
access_policy: AssetObjectAccessPolicy,
content_type: Option<String>,
content_length: u64,
content_hash: Option<String>,
version: u32,
source_job_id: Option<String>,
owner_user_id: Option<String>,
profile_id: Option<String>,
entity_id: Option<String>,
#[index(btree)]
asset_kind: String,
created_at: Timestamp,
updated_at: Timestamp,
}
// reducer 负责固定资产对象的正式写规则,供后续内部模块逻辑复用。
#[spacetimedb::reducer]
pub fn confirm_asset_object(
ctx: &ReducerContext,
input: AssetObjectUpsertInput,
) -> Result<(), String> {
upsert_asset_object(ctx, input).map(|_| ())
}
// procedure 面向 Axum 同步确认接口,返回最终持久化后的对象记录,避免 HTTP 层再额外查询 private table。
#[spacetimedb::procedure]
pub fn confirm_asset_object_and_return(
ctx: &mut ProcedureContext,
input: AssetObjectUpsertInput,
) -> AssetObjectProcedureResult {
match ctx.try_with_tx(|tx| upsert_asset_object(tx, input.clone())) {
Ok(record) => AssetObjectProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => AssetObjectProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
pub(crate) fn upsert_asset_object(
ctx: &ReducerContext,
input: AssetObjectUpsertInput,
) -> Result<AssetObjectUpsertSnapshot, String> {
validate_asset_object_fields(
&input.bucket,
&input.object_key,
&input.asset_kind,
input.version,
)
.map_err(|error| error.to_string())?;
let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
// 这里先保持最小可发布实现:查重语义已经冻结,后续再把实现优化回组合索引扫描。
let current = ctx
.db
.asset_object()
.iter()
.find(|row| row.bucket == input.bucket && row.object_key == input.object_key);
let snapshot = match current {
Some(existing) => {
ctx.db
.asset_object()
.asset_object_id()
.delete(&existing.asset_object_id);
let row = AssetObject {
asset_object_id: existing.asset_object_id.clone(),
bucket: input.bucket.clone(),
object_key: input.object_key.clone(),
access_policy: input.access_policy,
content_type: input.content_type.clone(),
content_length: input.content_length,
content_hash: input.content_hash.clone(),
version: input.version,
source_job_id: input.source_job_id.clone(),
owner_user_id: input.owner_user_id.clone(),
profile_id: input.profile_id.clone(),
entity_id: input.entity_id.clone(),
asset_kind: input.asset_kind.clone(),
created_at: existing.created_at,
updated_at,
};
ctx.db.asset_object().insert(row);
AssetObjectUpsertSnapshot {
asset_object_id: existing.asset_object_id,
bucket: input.bucket,
object_key: input.object_key,
access_policy: input.access_policy,
content_type: input.content_type,
content_length: input.content_length,
content_hash: input.content_hash,
version: input.version,
source_job_id: input.source_job_id,
owner_user_id: input.owner_user_id,
profile_id: input.profile_id,
entity_id: input.entity_id,
asset_kind: input.asset_kind,
created_at_micros: existing.created_at.to_micros_since_unix_epoch(),
updated_at_micros: input.updated_at_micros,
}
}
None => {
let created_at = updated_at;
let row = AssetObject {
asset_object_id: input.asset_object_id.clone(),
bucket: input.bucket.clone(),
object_key: input.object_key.clone(),
access_policy: input.access_policy,
content_type: input.content_type.clone(),
content_length: input.content_length,
content_hash: input.content_hash.clone(),
version: input.version,
source_job_id: input.source_job_id.clone(),
owner_user_id: input.owner_user_id.clone(),
profile_id: input.profile_id.clone(),
entity_id: input.entity_id.clone(),
asset_kind: input.asset_kind.clone(),
created_at,
updated_at,
};
ctx.db.asset_object().insert(row);
AssetObjectUpsertSnapshot {
asset_object_id: input.asset_object_id,
bucket: input.bucket,
object_key: input.object_key,
access_policy: input.access_policy,
content_type: input.content_type,
content_length: input.content_length,
content_hash: input.content_hash,
version: input.version,
source_job_id: input.source_job_id,
owner_user_id: input.owner_user_id,
profile_id: input.profile_id,
entity_id: input.entity_id,
asset_kind: input.asset_kind,
created_at_micros: input.updated_at_micros,
updated_at_micros: input.updated_at_micros,
}
}
};
Ok(snapshot)
}
pub(crate) fn has_asset_object(ctx: &ReducerContext, asset_object_id: &str) -> bool {
ctx.db
.asset_object()
.iter()
.any(|row| row.asset_object_id == asset_object_id)
}

View File

@@ -0,0 +1,238 @@
use crate::*;
use crate::big_fish::tables::{big_fish_asset_slot, big_fish_creation_session};
#[spacetimedb::procedure]
pub fn generate_big_fish_asset(
ctx: &mut ProcedureContext,
input: BigFishAssetGenerateInput,
) -> BigFishSessionProcedureResult {
match ctx.try_with_tx(|tx| generate_big_fish_asset_tx(tx, input.clone())) {
Ok(session) => BigFishSessionProcedureResult {
ok: true,
session: Some(session),
error_message: None,
},
Err(message) => BigFishSessionProcedureResult {
ok: false,
session: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn publish_big_fish_game(
ctx: &mut ProcedureContext,
input: BigFishPublishInput,
) -> BigFishSessionProcedureResult {
match ctx.try_with_tx(|tx| publish_big_fish_game_tx(tx, input.clone())) {
Ok(session) => BigFishSessionProcedureResult {
ok: true,
session: Some(session),
error_message: None,
},
Err(message) => BigFishSessionProcedureResult {
ok: false,
session: None,
error_message: Some(message),
},
}
}
pub(crate) fn generate_big_fish_asset_tx(
ctx: &ReducerContext,
input: BigFishAssetGenerateInput,
) -> Result<BigFishSessionSnapshot, String> {
let session = ctx
.db
.big_fish_creation_session()
.session_id()
.find(&input.session_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "big_fish_creation_session 不存在".to_string())?;
let draft = session
.draft_json
.as_deref()
.ok_or_else(|| "big_fish.draft 尚未编译".to_string())
.and_then(|value| deserialize_draft(value).map_err(|error| error.to_string()))?;
validate_asset_generate_input(&input, &draft).map_err(|error| error.to_string())?;
let slot = build_generated_asset_slot(
&input.session_id,
&draft,
input.asset_kind,
input.level,
input.motion_key.clone(),
input.asset_url.clone(),
input.generated_at_micros,
)
.map_err(|error| error.to_string())?;
upsert_big_fish_asset_slot(ctx, slot);
let asset_slots = list_big_fish_asset_slots(ctx, &session.session_id);
let coverage = build_asset_coverage(Some(&draft), &asset_slots);
let updated_at = Timestamp::from_micros_since_unix_epoch(input.generated_at_micros);
let uses_placeholder = input
.asset_url
.as_deref()
.map(str::trim)
.is_none_or(str::is_empty);
let reply = match (input.asset_kind, uses_placeholder) {
(BigFishAssetKind::LevelMainImage, true) => "本级主图占位图已生成,可在结果页继续预览。",
(BigFishAssetKind::LevelMainImage, false) => "本级主图已正式生成,可在结果页继续预览。",
(BigFishAssetKind::LevelMotion, true) => "本级动作占位图已生成,可在结果页继续预览。",
(BigFishAssetKind::LevelMotion, false) => "本级动作图已正式生成,可在结果页继续预览。",
(BigFishAssetKind::StageBackground, true) => {
"活动区域背景占位图已生成,可在结果页继续预览。"
}
(BigFishAssetKind::StageBackground, false) => {
"活动区域背景已正式生成,可在结果页继续预览。"
}
}
.to_string();
let next_stage = if coverage.publish_ready {
BigFishCreationStage::ReadyToPublish
} else {
BigFishCreationStage::AssetRefining
};
let next_session = BigFishCreationSession {
session_id: session.session_id.clone(),
owner_user_id: session.owner_user_id.clone(),
seed_text: session.seed_text.clone(),
current_turn: session.current_turn,
progress_percent: if coverage.publish_ready { 96 } else { 88 },
stage: next_stage,
anchor_pack_json: session.anchor_pack_json.clone(),
draft_json: session.draft_json.clone(),
asset_coverage_json: serialize_asset_coverage(&coverage)
.map_err(|error| error.to_string())?,
last_assistant_reply: Some(reply.clone()),
publish_ready: coverage.publish_ready,
created_at: session.created_at,
updated_at,
};
replace_big_fish_session(ctx, &session, next_session);
append_big_fish_system_message(
ctx,
&input.session_id,
format!("big-fish-message-asset-{}", input.generated_at_micros),
reply,
input.generated_at_micros,
);
get_big_fish_session_tx(
ctx,
BigFishSessionGetInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
},
)
}
pub(crate) fn publish_big_fish_game_tx(
ctx: &ReducerContext,
input: BigFishPublishInput,
) -> Result<BigFishSessionSnapshot, String> {
validate_publish_input(&input).map_err(|error| error.to_string())?;
let session = ctx
.db
.big_fish_creation_session()
.session_id()
.find(&input.session_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "big_fish_creation_session 不存在".to_string())?;
let draft = session
.draft_json
.as_deref()
.ok_or_else(|| "big_fish.draft 尚未编译".to_string())
.and_then(|value| deserialize_draft(value).map_err(|error| error.to_string()))?;
let coverage = build_asset_coverage(
Some(&draft),
&list_big_fish_asset_slots(ctx, &session.session_id),
);
if !coverage.publish_ready {
return Err(format!(
"big_fish 发布校验未通过:{}",
coverage.blockers.join("")
));
}
let published_at = Timestamp::from_micros_since_unix_epoch(input.published_at_micros);
let next_session = BigFishCreationSession {
session_id: session.session_id.clone(),
owner_user_id: session.owner_user_id.clone(),
seed_text: session.seed_text.clone(),
current_turn: session.current_turn,
progress_percent: 100,
stage: BigFishCreationStage::Published,
anchor_pack_json: session.anchor_pack_json.clone(),
draft_json: session.draft_json.clone(),
asset_coverage_json: serialize_asset_coverage(&coverage)
.map_err(|error| error.to_string())?,
last_assistant_reply: Some("玩法已发布,可以进入测试运行态。".to_string()),
publish_ready: true,
created_at: session.created_at,
updated_at: published_at,
};
replace_big_fish_session(ctx, &session, next_session);
get_big_fish_session_tx(
ctx,
BigFishSessionGetInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
},
)
}
pub(crate) fn list_big_fish_asset_slots(
ctx: &ReducerContext,
session_id: &str,
) -> Vec<BigFishAssetSlotSnapshot> {
let mut slots = ctx
.db
.big_fish_asset_slot()
.iter()
.filter(|slot| slot.session_id == session_id)
.map(|slot| BigFishAssetSlotSnapshot {
slot_id: slot.slot_id,
session_id: slot.session_id,
asset_kind: slot.asset_kind,
level: slot.level,
motion_key: slot.motion_key,
status: slot.status,
asset_url: slot.asset_url,
prompt_snapshot: slot.prompt_snapshot,
updated_at_micros: slot.updated_at.to_micros_since_unix_epoch(),
})
.collect::<Vec<_>>();
slots.sort_by_key(|slot| {
(
slot.level.unwrap_or(0),
slot.asset_kind.as_str().to_string(),
slot.motion_key.clone().unwrap_or_default(),
slot.slot_id.clone(),
)
});
slots
}
pub(crate) fn upsert_big_fish_asset_slot(ctx: &ReducerContext, slot: BigFishAssetSlotSnapshot) {
if let Some(existing) = ctx.db.big_fish_asset_slot().slot_id().find(&slot.slot_id) {
ctx.db
.big_fish_asset_slot()
.slot_id()
.delete(&existing.slot_id);
}
ctx.db.big_fish_asset_slot().insert(BigFishAssetSlot {
slot_id: slot.slot_id,
session_id: slot.session_id,
asset_kind: slot.asset_kind,
level: slot.level,
motion_key: slot.motion_key,
status: slot.status,
asset_url: slot.asset_url,
prompt_snapshot: slot.prompt_snapshot,
updated_at: Timestamp::from_micros_since_unix_epoch(slot.updated_at_micros),
});
}

View File

@@ -0,0 +1,9 @@
mod assets;
mod runtime;
mod session;
mod tables;
pub use assets::*;
pub use runtime::*;
pub use session::*;
pub use tables::*;

View File

@@ -0,0 +1,190 @@
use crate::*;
use crate::big_fish::tables::{big_fish_creation_session, big_fish_runtime_run};
#[spacetimedb::procedure]
pub fn start_big_fish_run(
ctx: &mut ProcedureContext,
input: BigFishRunStartInput,
) -> BigFishRunProcedureResult {
match ctx.try_with_tx(|tx| start_big_fish_run_tx(tx, input.clone())) {
Ok(run) => BigFishRunProcedureResult {
ok: true,
run: Some(run),
error_message: None,
},
Err(message) => BigFishRunProcedureResult {
ok: false,
run: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn submit_big_fish_input(
ctx: &mut ProcedureContext,
input: BigFishRunInputSubmitInput,
) -> BigFishRunProcedureResult {
match ctx.try_with_tx(|tx| submit_big_fish_input_tx(tx, input.clone())) {
Ok(run) => BigFishRunProcedureResult {
ok: true,
run: Some(run),
error_message: None,
},
Err(message) => BigFishRunProcedureResult {
ok: false,
run: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn get_big_fish_run(
ctx: &mut ProcedureContext,
input: BigFishRunGetInput,
) -> BigFishRunProcedureResult {
match ctx.try_with_tx(|tx| get_big_fish_run_tx(tx, input.clone())) {
Ok(run) => BigFishRunProcedureResult {
ok: true,
run: Some(run),
error_message: None,
},
Err(message) => BigFishRunProcedureResult {
ok: false,
run: None,
error_message: Some(message),
},
}
}
fn start_big_fish_run_tx(
ctx: &ReducerContext,
input: BigFishRunStartInput,
) -> Result<BigFishRuntimeSnapshot, String> {
validate_run_start_input(&input).map_err(|error| error.to_string())?;
if ctx
.db
.big_fish_runtime_run()
.run_id()
.find(&input.run_id)
.is_some()
{
return Err("big_fish_runtime_run.run_id 已存在".to_string());
}
let session = ctx
.db
.big_fish_creation_session()
.session_id()
.find(&input.session_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "big_fish_creation_session 不存在".to_string())?;
let draft = session
.draft_json
.as_deref()
.ok_or_else(|| "big_fish.draft 尚未编译".to_string())
.and_then(|value| deserialize_draft(value).map_err(|error| error.to_string()))?;
let snapshot = build_initial_runtime_snapshot(
input.run_id.clone(),
input.session_id.clone(),
&draft,
input.started_at_micros,
);
let now = Timestamp::from_micros_since_unix_epoch(input.started_at_micros);
ctx.db.big_fish_runtime_run().insert(BigFishRuntimeRun {
run_id: input.run_id,
session_id: input.session_id,
owner_user_id: input.owner_user_id,
status: snapshot.status,
snapshot_json: serialize_runtime_snapshot(&snapshot).map_err(|error| error.to_string())?,
last_input_x: 0.0,
last_input_y: 0.0,
tick: snapshot.tick,
created_at: now,
updated_at: now,
});
Ok(snapshot)
}
fn submit_big_fish_input_tx(
ctx: &ReducerContext,
input: BigFishRunInputSubmitInput,
) -> Result<BigFishRuntimeSnapshot, String> {
validate_run_input_submit_input(&input).map_err(|error| error.to_string())?;
let run = ctx
.db
.big_fish_runtime_run()
.run_id()
.find(&input.run_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "big_fish_runtime_run 不存在".to_string())?;
let session = ctx
.db
.big_fish_creation_session()
.session_id()
.find(&run.session_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "big_fish_creation_session 不存在".to_string())?;
let draft = session
.draft_json
.as_deref()
.ok_or_else(|| "big_fish.draft 尚未编译".to_string())
.and_then(|value| deserialize_draft(value).map_err(|error| error.to_string()))?;
let current_snapshot =
deserialize_runtime_snapshot(&run.snapshot_json).map_err(|error| error.to_string())?;
let next_snapshot = advance_runtime_snapshot(
current_snapshot,
&draft.runtime_params,
input.input_x,
input.input_y,
input.submitted_at_micros,
);
replace_big_fish_run(
ctx,
&run,
BigFishRuntimeRun {
run_id: run.run_id.clone(),
session_id: run.session_id.clone(),
owner_user_id: run.owner_user_id.clone(),
status: next_snapshot.status,
snapshot_json: serialize_runtime_snapshot(&next_snapshot)
.map_err(|error| error.to_string())?,
last_input_x: input.input_x,
last_input_y: input.input_y,
tick: next_snapshot.tick,
created_at: run.created_at,
updated_at: Timestamp::from_micros_since_unix_epoch(input.submitted_at_micros),
},
);
Ok(next_snapshot)
}
fn get_big_fish_run_tx(
ctx: &ReducerContext,
input: BigFishRunGetInput,
) -> Result<BigFishRuntimeSnapshot, String> {
validate_run_get_input(&input).map_err(|error| error.to_string())?;
let run = ctx
.db
.big_fish_runtime_run()
.run_id()
.find(&input.run_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "big_fish_runtime_run 不存在".to_string())?;
deserialize_runtime_snapshot(&run.snapshot_json).map_err(|error| error.to_string())
}
fn replace_big_fish_run(
ctx: &ReducerContext,
current: &BigFishRuntimeRun,
next: BigFishRuntimeRun,
) {
ctx.db
.big_fish_runtime_run()
.run_id()
.delete(&current.run_id);
ctx.db.big_fish_runtime_run().insert(next);
}

View File

@@ -0,0 +1,494 @@
use crate::*;
use crate::big_fish::tables::{big_fish_agent_message, big_fish_creation_session};
#[spacetimedb::procedure]
pub fn create_big_fish_session(
ctx: &mut ProcedureContext,
input: BigFishSessionCreateInput,
) -> BigFishSessionProcedureResult {
match ctx.try_with_tx(|tx| create_big_fish_session_tx(tx, input.clone())) {
Ok(session) => BigFishSessionProcedureResult {
ok: true,
session: Some(session),
error_message: None,
},
Err(message) => BigFishSessionProcedureResult {
ok: false,
session: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn get_big_fish_session(
ctx: &mut ProcedureContext,
input: BigFishSessionGetInput,
) -> BigFishSessionProcedureResult {
match ctx.try_with_tx(|tx| get_big_fish_session_tx(tx, input.clone())) {
Ok(session) => BigFishSessionProcedureResult {
ok: true,
session: Some(session),
error_message: None,
},
Err(message) => BigFishSessionProcedureResult {
ok: false,
session: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn list_big_fish_works(
ctx: &mut ProcedureContext,
input: BigFishWorksListInput,
) -> BigFishWorksProcedureResult {
match ctx.try_with_tx(|tx| list_big_fish_works_tx(tx, input.clone())) {
Ok(items) => match serde_json::to_string(&items) {
Ok(items_json) => BigFishWorksProcedureResult {
ok: true,
items_json: Some(items_json),
error_message: None,
},
Err(error) => BigFishWorksProcedureResult {
ok: false,
items_json: None,
error_message: Some(error.to_string()),
},
},
Err(message) => BigFishWorksProcedureResult {
ok: false,
items_json: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn submit_big_fish_message(
ctx: &mut ProcedureContext,
input: BigFishMessageSubmitInput,
) -> BigFishSessionProcedureResult {
match ctx.try_with_tx(|tx| submit_big_fish_message_tx(tx, input.clone())) {
Ok(session) => BigFishSessionProcedureResult {
ok: true,
session: Some(session),
error_message: None,
},
Err(message) => BigFishSessionProcedureResult {
ok: false,
session: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn compile_big_fish_draft(
ctx: &mut ProcedureContext,
input: BigFishDraftCompileInput,
) -> BigFishSessionProcedureResult {
match ctx.try_with_tx(|tx| compile_big_fish_draft_tx(tx, input.clone())) {
Ok(session) => BigFishSessionProcedureResult {
ok: true,
session: Some(session),
error_message: None,
},
Err(message) => BigFishSessionProcedureResult {
ok: false,
session: None,
error_message: Some(message),
},
}
}
pub(crate) fn create_big_fish_session_tx(
ctx: &ReducerContext,
input: BigFishSessionCreateInput,
) -> Result<BigFishSessionSnapshot, String> {
validate_session_create_input(&input).map_err(|error| error.to_string())?;
if ctx
.db
.big_fish_creation_session()
.session_id()
.find(&input.session_id)
.is_some()
{
return Err("big_fish_creation_session.session_id 已存在".to_string());
}
if ctx
.db
.big_fish_agent_message()
.message_id()
.find(&input.welcome_message_id)
.is_some()
{
return Err("big_fish_agent_message.message_id 已存在".to_string());
}
let created_at = Timestamp::from_micros_since_unix_epoch(input.created_at_micros);
let anchor_pack = infer_anchor_pack(&input.seed_text, None);
let asset_coverage = build_asset_coverage(None, &[]);
ctx.db
.big_fish_creation_session()
.insert(BigFishCreationSession {
session_id: input.session_id.clone(),
owner_user_id: input.owner_user_id.clone(),
seed_text: input.seed_text.trim().to_string(),
current_turn: 0,
progress_percent: 20,
stage: BigFishCreationStage::CollectingAnchors,
anchor_pack_json: serialize_anchor_pack(&anchor_pack)
.map_err(|error| error.to_string())?,
draft_json: None,
asset_coverage_json: serialize_asset_coverage(&asset_coverage)
.map_err(|error| error.to_string())?,
last_assistant_reply: Some(input.welcome_message_text.clone()),
publish_ready: false,
created_at,
updated_at: created_at,
});
ctx.db.big_fish_agent_message().insert(BigFishAgentMessage {
message_id: input.welcome_message_id,
session_id: input.session_id.clone(),
role: BigFishAgentMessageRole::Assistant,
kind: BigFishAgentMessageKind::Chat,
text: input.welcome_message_text,
created_at,
});
get_big_fish_session_tx(
ctx,
BigFishSessionGetInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
},
)
}
pub(crate) fn get_big_fish_session_tx(
ctx: &ReducerContext,
input: BigFishSessionGetInput,
) -> Result<BigFishSessionSnapshot, String> {
validate_session_get_input(&input).map_err(|error| error.to_string())?;
let session = ctx
.db
.big_fish_creation_session()
.session_id()
.find(&input.session_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "big_fish_creation_session 不存在".to_string())?;
build_big_fish_session_snapshot(ctx, &session)
}
pub(crate) fn list_big_fish_works_tx(
ctx: &ReducerContext,
input: BigFishWorksListInput,
) -> Result<Vec<BigFishWorkSummarySnapshot>, String> {
validate_works_list_input(&input).map_err(|error| error.to_string())?;
let mut items = ctx
.db
.big_fish_creation_session()
.iter()
.filter(|row| row.owner_user_id == input.owner_user_id)
.map(|row| build_big_fish_work_summary(ctx, &row))
.collect::<Result<Vec<_>, _>>()?;
items.sort_by(|left, right| {
right
.updated_at_micros
.cmp(&left.updated_at_micros)
.then_with(|| left.work_id.cmp(&right.work_id))
});
Ok(items)
}
pub(crate) fn submit_big_fish_message_tx(
ctx: &ReducerContext,
input: BigFishMessageSubmitInput,
) -> Result<BigFishSessionSnapshot, String> {
validate_message_submit_input(&input).map_err(|error| error.to_string())?;
let session = ctx
.db
.big_fish_creation_session()
.session_id()
.find(&input.session_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "big_fish_creation_session 不存在".to_string())?;
if ctx
.db
.big_fish_agent_message()
.message_id()
.find(&input.user_message_id)
.is_some()
{
return Err("big_fish_agent_message.user_message_id 已存在".to_string());
}
if ctx
.db
.big_fish_agent_message()
.message_id()
.find(&input.assistant_message_id)
.is_some()
{
return Err("big_fish_agent_message.assistant_message_id 已存在".to_string());
}
let submitted_at = Timestamp::from_micros_since_unix_epoch(input.submitted_at_micros);
ctx.db.big_fish_agent_message().insert(BigFishAgentMessage {
message_id: input.user_message_id,
session_id: input.session_id.clone(),
role: BigFishAgentMessageRole::User,
kind: BigFishAgentMessageKind::Chat,
text: input.user_message_text.trim().to_string(),
created_at: submitted_at,
});
let anchor_pack = infer_anchor_pack(&session.seed_text, Some(&input.user_message_text));
let assistant_text =
"我已经把这版方向收束成 4 个高杠杆锚点,可以继续细化,也可以直接编译第一版玩法草稿。"
.to_string();
ctx.db.big_fish_agent_message().insert(BigFishAgentMessage {
message_id: input.assistant_message_id,
session_id: input.session_id.clone(),
role: BigFishAgentMessageRole::Assistant,
kind: BigFishAgentMessageKind::Summary,
text: assistant_text.clone(),
created_at: submitted_at,
});
let next_session = BigFishCreationSession {
session_id: session.session_id.clone(),
owner_user_id: session.owner_user_id.clone(),
seed_text: session.seed_text.clone(),
current_turn: session.current_turn.saturating_add(1),
progress_percent: 60,
stage: BigFishCreationStage::CollectingAnchors,
anchor_pack_json: serialize_anchor_pack(&anchor_pack).map_err(|error| error.to_string())?,
draft_json: session.draft_json.clone(),
asset_coverage_json: session.asset_coverage_json.clone(),
last_assistant_reply: Some(assistant_text),
publish_ready: session.publish_ready,
created_at: session.created_at,
updated_at: submitted_at,
};
replace_big_fish_session(ctx, &session, next_session);
get_big_fish_session_tx(
ctx,
BigFishSessionGetInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
},
)
}
pub(crate) fn compile_big_fish_draft_tx(
ctx: &ReducerContext,
input: BigFishDraftCompileInput,
) -> Result<BigFishSessionSnapshot, String> {
validate_draft_compile_input(&input).map_err(|error| error.to_string())?;
let session = ctx
.db
.big_fish_creation_session()
.session_id()
.find(&input.session_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "big_fish_creation_session 不存在".to_string())?;
let anchor_pack =
deserialize_anchor_pack(&session.anchor_pack_json).map_err(|error| error.to_string())?;
let draft = compile_default_draft(&anchor_pack);
let asset_slots = list_big_fish_asset_slots(ctx, &session.session_id);
let coverage = build_asset_coverage(Some(&draft), &asset_slots);
let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros);
let reply = "第一版玩法草稿已编译完成,可以在结果页逐级生成主图、动作和场地背景。".to_string();
let next_session = BigFishCreationSession {
session_id: session.session_id.clone(),
owner_user_id: session.owner_user_id.clone(),
seed_text: session.seed_text.clone(),
current_turn: session.current_turn,
progress_percent: 80,
stage: BigFishCreationStage::DraftReady,
anchor_pack_json: session.anchor_pack_json.clone(),
draft_json: Some(serialize_draft(&draft).map_err(|error| error.to_string())?),
asset_coverage_json: serialize_asset_coverage(&coverage)
.map_err(|error| error.to_string())?,
last_assistant_reply: Some(reply.clone()),
publish_ready: coverage.publish_ready,
created_at: session.created_at,
updated_at: compiled_at,
};
replace_big_fish_session(ctx, &session, next_session);
append_big_fish_system_message(
ctx,
&input.session_id,
format!("big-fish-message-compile-{}", input.compiled_at_micros),
reply,
input.compiled_at_micros,
);
get_big_fish_session_tx(
ctx,
BigFishSessionGetInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
},
)
}
pub(crate) fn build_big_fish_session_snapshot(
ctx: &ReducerContext,
row: &BigFishCreationSession,
) -> Result<BigFishSessionSnapshot, String> {
let anchor_pack =
deserialize_anchor_pack(&row.anchor_pack_json).unwrap_or_else(|_| empty_anchor_pack());
let draft = row
.draft_json
.as_deref()
.map(deserialize_draft)
.transpose()
.map_err(|error| format!("big_fish.draft_json 非法: {error}"))?;
let asset_slots = list_big_fish_asset_slots(ctx, &row.session_id);
let asset_coverage = build_asset_coverage(draft.as_ref(), &asset_slots);
let mut messages = ctx
.db
.big_fish_agent_message()
.iter()
.filter(|message| message.session_id == row.session_id)
.map(|message| BigFishAgentMessageSnapshot {
message_id: message.message_id,
session_id: message.session_id,
role: message.role,
kind: message.kind,
text: message.text,
created_at_micros: message.created_at.to_micros_since_unix_epoch(),
})
.collect::<Vec<_>>();
messages.sort_by_key(|message| (message.created_at_micros, message.message_id.clone()));
Ok(BigFishSessionSnapshot {
session_id: row.session_id.clone(),
owner_user_id: row.owner_user_id.clone(),
seed_text: row.seed_text.clone(),
current_turn: row.current_turn,
progress_percent: row.progress_percent,
stage: row.stage,
anchor_pack,
draft,
asset_slots,
asset_coverage,
messages,
last_assistant_reply: row.last_assistant_reply.clone(),
publish_ready: row.publish_ready,
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
})
}
pub(crate) fn build_big_fish_work_summary(
ctx: &ReducerContext,
row: &BigFishCreationSession,
) -> Result<BigFishWorkSummarySnapshot, String> {
let draft = row
.draft_json
.as_deref()
.map(deserialize_draft)
.transpose()
.map_err(|error| format!("big_fish.draft_json 非法: {error}"))?;
let asset_slots = list_big_fish_asset_slots(ctx, &row.session_id);
let coverage = build_asset_coverage(draft.as_ref(), &asset_slots);
let cover_image_src = asset_slots
.iter()
.find(|slot| slot.asset_kind == BigFishAssetKind::StageBackground)
.and_then(|slot| slot.asset_url.clone())
.or_else(|| {
asset_slots
.iter()
.find(|slot| slot.asset_kind == BigFishAssetKind::LevelMainImage)
.and_then(|slot| slot.asset_url.clone())
});
let title = draft
.as_ref()
.map(|value| value.title.clone())
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "未命名大鱼草稿".to_string());
let subtitle = draft
.as_ref()
.map(|value| value.subtitle.clone())
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "等待整理玩法草稿".to_string());
let summary = draft
.as_ref()
.map(|value| value.core_fun.clone())
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| {
row.last_assistant_reply
.clone()
.unwrap_or_else(|| "继续补齐锚点后即可生成玩法草稿。".to_string())
});
Ok(BigFishWorkSummarySnapshot {
work_id: format!("big-fish-work-{}", row.session_id),
source_session_id: row.session_id.clone(),
title,
subtitle,
summary,
cover_image_src,
status: if row.stage == BigFishCreationStage::Published {
"published".to_string()
} else {
"draft".to_string()
},
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
publish_ready: coverage.publish_ready,
level_count: draft
.as_ref()
.map(|value| value.runtime_params.level_count)
.unwrap_or(BIG_FISH_DEFAULT_LEVEL_COUNT),
level_main_image_ready_count: coverage.level_main_image_ready_count,
level_motion_ready_count: coverage.level_motion_ready_count,
background_ready: coverage.background_ready,
})
}
pub(crate) fn replace_big_fish_session(
ctx: &ReducerContext,
current: &BigFishCreationSession,
next: BigFishCreationSession,
) {
ctx.db
.big_fish_creation_session()
.session_id()
.delete(&current.session_id);
ctx.db.big_fish_creation_session().insert(next);
}
pub(crate) fn append_big_fish_system_message(
ctx: &ReducerContext,
session_id: &str,
message_id: String,
text: String,
created_at_micros: i64,
) {
if ctx
.db
.big_fish_agent_message()
.message_id()
.find(&message_id)
.is_some()
{
return;
}
ctx.db.big_fish_agent_message().insert(BigFishAgentMessage {
message_id,
session_id: session_id.to_string(),
role: BigFishAgentMessageRole::Assistant,
kind: BigFishAgentMessageKind::ActionResult,
text,
created_at: Timestamp::from_micros_since_unix_epoch(created_at_micros),
});
}

View File

@@ -0,0 +1,72 @@
use crate::*;
#[spacetimedb::table(
accessor = big_fish_creation_session,
index(accessor = by_big_fish_session_owner_user_id, btree(columns = [owner_user_id]))
)]
pub struct BigFishCreationSession {
#[primary_key]
pub(crate) session_id: String,
pub(crate) owner_user_id: String,
pub(crate) seed_text: String,
pub(crate) current_turn: u32,
pub(crate) progress_percent: u32,
pub(crate) stage: BigFishCreationStage,
pub(crate) anchor_pack_json: String,
pub(crate) draft_json: Option<String>,
pub(crate) asset_coverage_json: String,
pub(crate) last_assistant_reply: Option<String>,
pub(crate) publish_ready: bool,
pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = big_fish_agent_message,
index(accessor = by_big_fish_message_session_id, btree(columns = [session_id]))
)]
pub struct BigFishAgentMessage {
#[primary_key]
pub(crate) message_id: String,
pub(crate) session_id: String,
pub(crate) role: BigFishAgentMessageRole,
pub(crate) kind: BigFishAgentMessageKind,
pub(crate) text: String,
pub(crate) created_at: Timestamp,
}
#[spacetimedb::table(
accessor = big_fish_asset_slot,
index(accessor = by_big_fish_asset_session_id, btree(columns = [session_id]))
)]
pub struct BigFishAssetSlot {
#[primary_key]
pub(crate) slot_id: String,
pub(crate) session_id: String,
pub(crate) asset_kind: BigFishAssetKind,
pub(crate) level: Option<u32>,
pub(crate) motion_key: Option<String>,
pub(crate) status: BigFishAssetStatus,
pub(crate) asset_url: Option<String>,
pub(crate) prompt_snapshot: String,
pub(crate) updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = big_fish_runtime_run,
index(accessor = by_big_fish_run_owner_user_id, btree(columns = [owner_user_id])),
index(accessor = by_big_fish_run_session_id, btree(columns = [session_id]))
)]
pub struct BigFishRuntimeRun {
#[primary_key]
pub(crate) run_id: String,
pub(crate) session_id: String,
pub(crate) owner_user_id: String,
pub(crate) status: BigFishRunStatus,
pub(crate) snapshot_json: String,
pub(crate) last_input_x: f32,
pub(crate) last_input_y: f32,
pub(crate) tick: u64,
pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp,
}

View File

@@ -0,0 +1 @@
// Custom World agent message、operation、draft card 与 action 执行落位点。

View File

@@ -0,0 +1 @@
// Custom World gallery 与 detail 读模型落位点。

View File

@@ -995,15 +995,14 @@ fn upsert_custom_world_profile_record(
.find(&input.profile_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.or_else(|| {
input.source_agent_session_id.as_ref().and_then(|session_id| {
ctx.db.custom_world_profile().iter().find(|row| {
is_same_agent_draft_profile_candidate(
row,
&input.owner_user_id,
session_id,
)
input
.source_agent_session_id
.as_ref()
.and_then(|session_id| {
ctx.db.custom_world_profile().iter().find(|row| {
is_same_agent_draft_profile_candidate(row, &input.owner_user_id, session_id)
})
})
})
});
let next_row = match current {
@@ -1432,18 +1431,16 @@ fn list_custom_world_work_snapshots(
let mut items = Vec::new();
for session in ctx
.db
.custom_world_agent_session()
.iter()
.filter(|row| row.owner_user_id == input.owner_user_id && row.stage != RpgAgentStage::Published)
{
for session in ctx.db.custom_world_agent_session().iter().filter(|row| {
row.owner_user_id == input.owner_user_id && row.stage != RpgAgentStage::Published
}) {
let gate = build_custom_world_publish_gate_from_session(&session);
let draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref());
let title = resolve_session_work_title(&session, draft_profile.as_ref());
let summary = resolve_session_work_summary(&session, draft_profile.as_ref());
let stage_label = Some(resolve_rpg_agent_stage_label(session.stage).to_string());
let subtitle = resolve_session_work_subtitle(draft_profile.as_ref(), stage_label.as_deref());
let subtitle =
resolve_session_work_subtitle(draft_profile.as_ref(), stage_label.as_deref());
let (playable_npc_count, landmark_count) =
resolve_session_work_counts(ctx, &session, draft_profile.as_ref());
@@ -1516,8 +1513,16 @@ fn list_custom_world_work_snapshots(
.updated_at_micros
.cmp(&left.updated_at_micros)
.then_with(|| {
let left_rank = if left.source_type == "agent_session" { 0 } else { 1 };
let right_rank = if right.source_type == "agent_session" { 0 } else { 1 };
let left_rank = if left.source_type == "agent_session" {
0
} else {
1
};
let right_rank = if right.source_type == "agent_session" {
0
} else {
1
};
left_rank.cmp(&right_rank)
})
.then(left.work_id.cmp(&right.work_id))
@@ -1578,7 +1583,9 @@ fn execute_custom_world_agent_action_tx(
match input.action.trim() {
"draft_foundation" => execute_draft_foundation_action(ctx, &session, &input, &payload),
"update_draft_card" => execute_update_draft_card_action(ctx, &session, &input, &payload),
"sync_result_profile" => execute_sync_result_profile_action(ctx, &session, &input, &payload),
"sync_result_profile" => {
execute_sync_result_profile_action(ctx, &session, &input, &payload)
}
"publish_world" => execute_publish_world_action(ctx, &session, &input, &payload),
"revert_checkpoint" => execute_revert_checkpoint_action(ctx, &session, &input, &payload),
"generate_characters"
@@ -1603,18 +1610,16 @@ fn execute_draft_foundation_action(
}
let updated_at = input.submitted_at_micros;
let draft_profile = if let Some(profile) = payload.get("draftProfile").and_then(JsonValue::as_object) {
profile.clone()
} else if let Some(existing) = parse_optional_session_object(session.draft_profile_json.as_deref()) {
ensure_minimal_draft_profile(existing, &session.seed_text)
} else {
build_minimal_draft_profile_from_seed(&session.seed_text)
};
let draft_profile_json =
serde_json::to_string(&JsonValue::Object(draft_profile.clone())).map_err(|error| {
format!("draft_foundation 无法序列化 draft_profile_json: {error}")
let draft_profile = payload
.get("draftProfile")
.and_then(JsonValue::as_object)
.cloned()
.ok_or_else(|| {
"draft_foundation requires externally generated payload.draftProfile".to_string()
})?;
let draft_profile_json = serde_json::to_string(&JsonValue::Object(draft_profile.clone()))
.map_err(|error| format!("draft_foundation 无法序列化 draft_profile_json: {error}"))?;
let gate = summarize_publish_gate_from_json(
&input.session_id,
RpgAgentStage::ObjectRefining,
@@ -1627,8 +1632,12 @@ fn execute_draft_foundation_action(
progress_percent: Some(100),
stage: Some(RpgAgentStage::ObjectRefining),
draft_profile_json: Some(Some(draft_profile_json.clone())),
last_assistant_reply: Some(Some("世界底稿已整理完成,接下来可以继续细化卡片和发布预览。".to_string())),
publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value(&gate))?)),
last_assistant_reply: Some(Some(
"世界底稿已整理完成,接下来可以继续细化卡片和发布预览。".to_string(),
)),
publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value(
&gate,
))?)),
result_preview_json: Some(build_result_preview_json(
Some(&draft_profile),
&gate,
@@ -1675,7 +1684,8 @@ fn execute_update_draft_card_action(
) -> Result<CustomWorldAgentOperationSnapshot, String> {
ensure_refining_stage(session.stage, "update_draft_card")?;
let card_id = read_required_payload_text(payload, "cardId", "update_draft_card requires cardId")?;
let card_id =
read_required_payload_text(payload, "cardId", "update_draft_card requires cardId")?;
let card = ctx
.db
.custom_world_draft_card()
@@ -1691,7 +1701,8 @@ fn execute_update_draft_card_action(
return Err("update_draft_card requires sections".to_string());
}
let mut detail_object = parse_optional_session_object(card.detail_payload_json.as_deref()).unwrap_or_default();
let mut detail_object =
parse_optional_session_object(card.detail_payload_json.as_deref()).unwrap_or_default();
let mut detail_sections = detail_object
.get("sections")
.and_then(JsonValue::as_array)
@@ -1735,27 +1746,36 @@ fn execute_update_draft_card_action(
}
detail_object.insert("id".to_string(), JsonValue::String(card.card_id.clone()));
detail_object.insert("kind".to_string(), JsonValue::String(card.kind.as_str().to_string()));
detail_object.insert(
"kind".to_string(),
JsonValue::String(card.kind.as_str().to_string()),
);
detail_object.insert("title".to_string(), JsonValue::String(card.title.clone()));
detail_object.insert("sections".to_string(), JsonValue::Array(detail_sections.clone()));
detail_object.insert(
"sections".to_string(),
JsonValue::Array(detail_sections.clone()),
);
detail_object.insert(
"linkedIds".to_string(),
serde_json::from_str::<JsonValue>(&card.linked_ids_json).unwrap_or_else(|_| JsonValue::Array(Vec::new())),
serde_json::from_str::<JsonValue>(&card.linked_ids_json)
.unwrap_or_else(|_| JsonValue::Array(Vec::new())),
);
detail_object.insert("locked".to_string(), JsonValue::Bool(false));
detail_object.insert("editable".to_string(), JsonValue::Bool(false));
detail_object.insert("editableSectionIds".to_string(), JsonValue::Array(Vec::new()));
detail_object.insert(
"editableSectionIds".to_string(),
JsonValue::Array(Vec::new()),
);
detail_object.insert("warningMessages".to_string(), JsonValue::Array(Vec::new()));
let updated_title = extract_detail_section_value(&detail_sections, "title").unwrap_or_else(|| card.title.clone());
let updated_subtitle =
extract_detail_section_value(&detail_sections, "subtitle").unwrap_or_else(|| card.subtitle.clone());
let updated_summary =
extract_detail_section_value(&detail_sections, "summary").unwrap_or_else(|| card.summary.clone());
let detail_payload_json =
serde_json::to_string(&JsonValue::Object(detail_object)).map_err(|error| {
format!("update_draft_card 无法序列化 detail_payload_json: {error}")
})?;
let updated_title = extract_detail_section_value(&detail_sections, "title")
.unwrap_or_else(|| card.title.clone());
let updated_subtitle = extract_detail_section_value(&detail_sections, "subtitle")
.unwrap_or_else(|| card.subtitle.clone());
let updated_summary = extract_detail_section_value(&detail_sections, "summary")
.unwrap_or_else(|| card.summary.clone());
let detail_payload_json = serde_json::to_string(&JsonValue::Object(detail_object))
.map_err(|error| format!("update_draft_card 无法序列化 detail_payload_json: {error}"))?;
replace_custom_world_draft_card(
ctx,
@@ -1778,7 +1798,14 @@ fn execute_update_draft_card_action(
},
);
let next_session = sync_session_draft_profile_from_card_update(session, &card, &updated_title, &updated_subtitle, &updated_summary, input.submitted_at_micros)?;
let next_session = sync_session_draft_profile_from_card_update(
session,
&card,
&updated_title,
&updated_subtitle,
&updated_summary,
input.submitted_at_micros,
)?;
replace_custom_world_agent_session(ctx, session, next_session);
append_custom_world_action_result_message(
@@ -1816,7 +1843,10 @@ fn execute_sync_result_profile_action(
.ok_or_else(|| "sync_result_profile requires profile".to_string())?;
if let Some(stable_profile_id) = resolve_stable_agent_draft_profile_id(session) {
// 结果页回写时必须沿用当前草稿的稳定身份,避免把同一草稿写成新条目。
profile.insert("id".to_string(), JsonValue::String(stable_profile_id.clone()));
profile.insert(
"id".to_string(),
JsonValue::String(stable_profile_id.clone()),
);
upsert_nested_result_profile_id(&mut profile, &stable_profile_id);
}
let draft_profile = ensure_minimal_draft_profile(profile, &session.seed_text);
@@ -1830,9 +1860,13 @@ fn execute_sync_result_profile_action(
let next_session = rebuild_custom_world_agent_session_row(
session,
CustomWorldAgentSessionPatch {
draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object(draft_profile.clone()))?)),
draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object(
draft_profile.clone(),
))?)),
last_assistant_reply: Some(Some("结果页草稿已同步回当前会话。".to_string())),
publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value(&gate))?)),
publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value(
&gate,
))?)),
result_preview_json: Some(build_result_preview_json(
Some(&draft_profile),
&gate,
@@ -1871,12 +1905,14 @@ fn execute_sync_result_profile_action(
}
fn resolve_stable_agent_draft_profile_id(session: &CustomWorldAgentSession) -> Option<String> {
parse_optional_session_object(session.draft_profile_json.as_deref()).and_then(|profile| {
read_optional_text_field(&profile, &["legacyResultProfile.id", "id"])
})
parse_optional_session_object(session.draft_profile_json.as_deref())
.and_then(|profile| read_optional_text_field(&profile, &["legacyResultProfile.id", "id"]))
}
fn upsert_nested_result_profile_id(profile: &mut JsonMap<String, JsonValue>, stable_profile_id: &str) {
fn upsert_nested_result_profile_id(
profile: &mut JsonMap<String, JsonValue>,
stable_profile_id: &str,
) {
let legacy_result_profile = profile
.entry("legacyResultProfile".to_string())
.or_insert_with(|| JsonValue::Object(JsonMap::new()));
@@ -1907,12 +1943,13 @@ fn execute_publish_world_action(
) -> Result<CustomWorldAgentOperationSnapshot, String> {
ensure_publishable_stage(session.stage, "publish_world")?;
let draft_profile = if let Some(explicit) = payload.get("draftProfile").and_then(JsonValue::as_object) {
explicit.clone()
} else {
parse_optional_session_object(session.draft_profile_json.as_deref())
.ok_or_else(|| "publish_world requires draft_profile_json".to_string())?
};
let draft_profile =
if let Some(explicit) = payload.get("draftProfile").and_then(JsonValue::as_object) {
explicit.clone()
} else {
parse_optional_session_object(session.draft_profile_json.as_deref())
.ok_or_else(|| "publish_world requires draft_profile_json".to_string())?
};
let gate = summarize_publish_gate_from_json(
&session.session_id,
session.stage,
@@ -1972,7 +2009,10 @@ fn execute_publish_world_action(
&session.session_id,
RpgAgentOperationType::PublishWorld,
"世界已发布",
&format!("正式世界档案已写入作品库:{}", publish_result.1.profile_id),
&format!(
"正式世界档案已写入作品库:{}",
publish_result.1.profile_id
),
input.submitted_at_micros,
);
@@ -2046,9 +2086,15 @@ fn execute_revert_checkpoint_action(
.map(|value| serialize_json_value(&JsonValue::Object(value.clone())))
.transpose()?,
),
last_assistant_reply: Some(Some("已恢复到所选 checkpoint 的世界草稿状态。".to_string())),
quality_findings_json: Some(serialize_json_value(&JsonValue::Array(restored_quality_findings))?),
publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value(&gate))?)),
last_assistant_reply: Some(Some(
"已恢复到所选 checkpoint 的世界草稿状态。".to_string(),
)),
quality_findings_json: Some(serialize_json_value(&JsonValue::Array(
restored_quality_findings,
))?),
publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value(
&gate,
))?)),
result_preview_json: Some(build_result_preview_json(
restored_draft_profile.as_ref(),
&gate,
@@ -2099,7 +2145,10 @@ fn execute_placeholder_custom_world_action(
ctx,
&session.session_id,
&input.operation_id,
&format!("动作 {} 已接入最小兼容占位,后续会继续补真实编排。", input.action),
&format!(
"动作 {} 已接入最小兼容占位,后续会继续补真实编排。",
input.action
),
input.submitted_at_micros,
);
let operation = build_and_insert_custom_world_operation(
@@ -2201,7 +2250,8 @@ fn summarize_publish_gate_from_json(
blockers.push(CustomWorldPublishBlockerSnapshot {
blocker_id: "publish_missing_player_premise".to_string(),
code: "publish_missing_player_premise".to_string(),
message: "当前世界缺少玩家身份与切入前提,发布前需要先补齐玩家 premise。".to_string(),
message: "当前世界缺少玩家身份与切入前提,发布前需要先补齐玩家 premise。"
.to_string(),
});
}
if !json_array_has_non_empty_text(profile.get("coreConflicts")) {
@@ -2342,8 +2392,10 @@ fn build_supported_actions_json(
let has_checkpoint = checkpoints
.iter()
.any(|entry| entry.get("snapshot").is_some());
let draft_refining_enabled =
matches!(stage, RpgAgentStage::ObjectRefining | RpgAgentStage::VisualRefining);
let draft_refining_enabled = matches!(
stage,
RpgAgentStage::ObjectRefining | RpgAgentStage::VisualRefining
);
let long_tail_enabled = matches!(
stage,
RpgAgentStage::ObjectRefining
@@ -2462,8 +2514,10 @@ fn build_custom_world_draft_card_detail_snapshot(
card: &CustomWorldDraftCard,
) -> Result<CustomWorldDraftCardDetailSnapshot, String> {
if let Some(detail_payload_json) = card.detail_payload_json.as_deref() {
let detail_value = serde_json::from_str::<JsonValue>(detail_payload_json)
.map_err(|error| format!("custom_world_draft_card.detail_payload_json 非法: {error}"))?;
let detail_value =
serde_json::from_str::<JsonValue>(detail_payload_json).map_err(|error| {
format!("custom_world_draft_card.detail_payload_json 非法: {error}")
})?;
if let Some(object) = detail_value.as_object() {
let sections = object
.get("sections")
@@ -2501,8 +2555,14 @@ fn build_custom_world_draft_card_detail_snapshot(
.to_string(),
sections,
linked_ids_json: card.linked_ids_json.clone(),
locked: object.get("locked").and_then(JsonValue::as_bool).unwrap_or(false),
editable: object.get("editable").and_then(JsonValue::as_bool).unwrap_or(false),
locked: object
.get("locked")
.and_then(JsonValue::as_bool)
.unwrap_or(false),
editable: object
.get("editable")
.and_then(JsonValue::as_bool)
.unwrap_or(false),
editable_section_ids_json: serialize_json_value(
object
.get("editableSectionIds")
@@ -2534,7 +2594,9 @@ fn build_custom_world_draft_card_detail_snapshot(
})
}
fn build_fallback_card_sections(card: &CustomWorldDraftCard) -> Vec<CustomWorldDraftCardDetailSectionSnapshot> {
fn build_fallback_card_sections(
card: &CustomWorldDraftCard,
) -> Vec<CustomWorldDraftCardDetailSectionSnapshot> {
vec![
CustomWorldDraftCardDetailSectionSnapshot {
section_id: "title".to_string(),
@@ -2578,7 +2640,9 @@ fn rebuild_custom_world_agent_session_row(
current_turn: current.current_turn,
progress_percent: patch.progress_percent.unwrap_or(current.progress_percent),
stage: patch.stage.unwrap_or(current.stage),
focus_card_id: patch.focus_card_id.unwrap_or_else(|| current.focus_card_id.clone()),
focus_card_id: patch
.focus_card_id
.unwrap_or_else(|| current.focus_card_id.clone()),
anchor_content_json: patch
.anchor_content_json
.unwrap_or_else(|| current.anchor_content_json.clone()),
@@ -2588,8 +2652,12 @@ fn rebuild_custom_world_agent_session_row(
creator_intent_readiness_json: patch
.creator_intent_readiness_json
.unwrap_or_else(|| current.creator_intent_readiness_json.clone()),
anchor_pack_json: patch.anchor_pack_json.unwrap_or_else(|| current.anchor_pack_json.clone()),
lock_state_json: patch.lock_state_json.unwrap_or_else(|| current.lock_state_json.clone()),
anchor_pack_json: patch
.anchor_pack_json
.unwrap_or_else(|| current.anchor_pack_json.clone()),
lock_state_json: patch
.lock_state_json
.unwrap_or_else(|| current.lock_state_json.clone()),
draft_profile_json: patch
.draft_profile_json
.unwrap_or_else(|| current.draft_profile_json.clone()),
@@ -2741,7 +2809,8 @@ fn upsert_world_foundation_card(
status: RpgAgentDraftCardStatus::Confirmed,
title: read_optional_text_field(draft_profile, &["name", "title"])
.unwrap_or_else(|| "世界底稿".to_string()),
subtitle: read_optional_text_field(draft_profile, &["subtitle"]).unwrap_or_default(),
subtitle: read_optional_text_field(draft_profile, &["subtitle"])
.unwrap_or_default(),
summary: read_optional_text_field(draft_profile, &["summary"])
.unwrap_or_else(|| "第一版世界底稿已生成。".to_string()),
linked_ids_json: "[]".to_string(),
@@ -2754,24 +2823,27 @@ fn upsert_world_foundation_card(
},
);
} else {
ctx.db.custom_world_draft_card().insert(CustomWorldDraftCard {
card_id,
session_id: session_id.to_string(),
kind: RpgAgentDraftCardKind::World,
status: RpgAgentDraftCardStatus::Confirmed,
title: read_optional_text_field(draft_profile, &["name", "title"])
.unwrap_or_else(|| "世界底稿".to_string()),
subtitle: read_optional_text_field(draft_profile, &["subtitle"]).unwrap_or_default(),
summary: read_optional_text_field(draft_profile, &["summary"])
.unwrap_or_else(|| "第一版世界底稿已生成。".to_string()),
linked_ids_json: "[]".to_string(),
warning_count: 0,
asset_status: None,
asset_status_label: None,
detail_payload_json: Some(detail_payload_json),
created_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros),
updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros),
});
ctx.db
.custom_world_draft_card()
.insert(CustomWorldDraftCard {
card_id,
session_id: session_id.to_string(),
kind: RpgAgentDraftCardKind::World,
status: RpgAgentDraftCardStatus::Confirmed,
title: read_optional_text_field(draft_profile, &["name", "title"])
.unwrap_or_else(|| "世界底稿".to_string()),
subtitle: read_optional_text_field(draft_profile, &["subtitle"])
.unwrap_or_default(),
summary: read_optional_text_field(draft_profile, &["summary"])
.unwrap_or_else(|| "第一版世界底稿已生成。".to_string()),
linked_ids_json: "[]".to_string(),
warning_count: 0,
asset_status: None,
asset_status_label: None,
detail_payload_json: Some(detail_payload_json),
created_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros),
updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros),
});
}
Ok(())
@@ -2788,7 +2860,10 @@ fn sync_session_draft_profile_from_card_update(
let mut draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref())
.unwrap_or_else(|| build_minimal_draft_profile_from_seed(&session.seed_text));
if card.kind == RpgAgentDraftCardKind::World {
draft_profile.insert("name".to_string(), JsonValue::String(updated_title.to_string()));
draft_profile.insert(
"name".to_string(),
JsonValue::String(updated_title.to_string()),
);
draft_profile.insert(
"subtitle".to_string(),
JsonValue::String(updated_subtitle.to_string()),
@@ -2808,8 +2883,12 @@ fn sync_session_draft_profile_from_card_update(
rebuild_custom_world_agent_session_row(
session,
CustomWorldAgentSessionPatch {
draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object(draft_profile.clone()))?)),
publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value(&gate))?)),
draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object(
draft_profile.clone(),
))?)),
publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value(
&gate,
))?)),
result_preview_json: Some(build_result_preview_json(
Some(&draft_profile),
&gate,
@@ -2824,7 +2903,10 @@ fn sync_session_draft_profile_from_card_update(
}
fn ensure_refining_stage(stage: RpgAgentStage, action: &str) -> Result<(), String> {
if matches!(stage, RpgAgentStage::ObjectRefining | RpgAgentStage::VisualRefining) {
if matches!(
stage,
RpgAgentStage::ObjectRefining | RpgAgentStage::VisualRefining
) {
Ok(())
} else {
Err(format!(
@@ -2933,10 +3015,7 @@ fn read_required_payload_text(
.ok_or_else(|| error_message.to_string())
}
fn read_optional_text_field(
object: &JsonMap<String, JsonValue>,
keys: &[&str],
) -> Option<String> {
fn read_optional_text_field(object: &JsonMap<String, JsonValue>, keys: &[&str]) -> Option<String> {
for key in keys {
let mut current = JsonValue::Object(object.clone());
let mut found = true;
@@ -2949,7 +3028,11 @@ fn read_optional_text_field(
}
}
if found {
if let Some(value) = current.as_str().map(str::trim).filter(|value| !value.is_empty()) {
if let Some(value) = current
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
{
return Some(value.to_string());
}
}
@@ -3144,21 +3227,28 @@ fn append_checkpoint_json(current: &str, checkpoint: &JsonValue) -> Result<Strin
fn extract_detail_section_value(sections: &[JsonValue], target_id: &str) -> Option<String> {
sections.iter().find_map(|entry| {
let object = entry.as_object()?;
(object.get("id").and_then(JsonValue::as_str) == Some(target_id))
.then(|| {
object
.get("value")
.and_then(JsonValue::as_str)
.unwrap_or_default()
.to_string()
})
(object.get("id").and_then(JsonValue::as_str) == Some(target_id)).then(|| {
object
.get("value")
.and_then(JsonValue::as_str)
.unwrap_or_default()
.to_string()
})
})
}
fn json_array_has_non_empty_text(value: Option<&JsonValue>) -> bool {
value
.and_then(JsonValue::as_array)
.map(|entries| entries.iter().any(|entry| entry.as_str().map(str::trim).filter(|text| !text.is_empty()).is_some()))
.map(|entries| {
entries.iter().any(|entry| {
entry
.as_str()
.map(str::trim)
.filter(|text| !text.is_empty())
.is_some()
})
})
.unwrap_or(false)
}
@@ -3341,12 +3431,14 @@ fn build_custom_world_agent_session_snapshot(
recommended_replies_json: row.recommended_replies_json.clone(),
asset_coverage_json: row.asset_coverage_json.clone(),
checkpoints_json: row.checkpoints_json.clone(),
supported_actions_json: serialize_json_value(&JsonValue::Array(build_supported_actions_json(
row.stage,
row.progress_percent,
&build_custom_world_publish_gate_from_session(row),
&parse_json_array_or_empty(&row.checkpoints_json),
)))
supported_actions_json: serialize_json_value(&JsonValue::Array(
build_supported_actions_json(
row.stage,
row.progress_percent,
&build_custom_world_publish_gate_from_session(row),
&parse_json_array_or_empty(&row.checkpoints_json),
),
))
.unwrap_or_else(|_| "[]".to_string()),
messages,
draft_cards,

View File

@@ -0,0 +1 @@
// Custom World profile 读写落位点。

View File

@@ -0,0 +1 @@
// Custom World publish gate、published profile compile 与 publish_world 落位点。

View File

@@ -0,0 +1 @@
// Custom World 旧 session 与 agent session 真相表落位点。

View File

@@ -0,0 +1 @@
// Custom World works 聚合与 work summary 落位点。

View File

@@ -1,3 +1,5 @@
use crate::*;
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct ResolveNpcBattleInteractionInput {
pub npc_interaction: ResolveNpcInteractionInput,
@@ -17,7 +19,7 @@ pub struct ResolveNpcBattleInteractionInput {
// 输出同时返回 NPC 交互结果与 battle_state 快照,避免 Axum 再回头读取 private table。
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct NpcBattleInteractionResult {
pub interaction: module_npc::NpcInteractionResult,
pub interaction: NpcInteractionResult,
pub battle_state: BattleStateSnapshot,
}

View File

@@ -1,3 +1,5 @@
use crate::*;
// 当前阶段先落可发布的最小模块入口,后续再补对象确认、业务绑定与任务编排 reducer。
#[spacetimedb::reducer(init)]
pub fn init(_ctx: &ReducerContext) {

View File

@@ -0,0 +1 @@
// Combat 相关表、procedure 与 helper 落位点。

View File

@@ -0,0 +1 @@
// Inventory 相关表、procedure 与 helper 落位点。

View File

@@ -0,0 +1 @@
// NPC 相关表、procedure 与 helper 落位点。

View File

@@ -0,0 +1 @@
// Progression 相关表、procedure 与 helper 落位点。

View File

@@ -0,0 +1 @@
// Quest 相关表、procedure 与 helper 落位点。

View File

@@ -0,0 +1 @@
// Runtime item / treasure 相关表、procedure 与 helper 落位点。

View File

@@ -0,0 +1 @@
// Story session / story event 相关表、procedure 与 helper 落位点。

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
use module_puzzle::{
PUZZLE_MAX_TAG_COUNT, PuzzleAgentMessageKind, PuzzleAgentMessageRole,
PuzzleAgentMessageSnapshot, PuzzleAgentSessionCreateInput, PuzzleAgentSessionGetInput,
PuzzleAgentSessionProcedureResult, PuzzleAgentSessionSnapshot, PuzzleAgentStage,
PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleGeneratedImageCandidate,
PUZZLE_MAX_TAG_COUNT, PuzzleAgentMessageFinalizeInput, PuzzleAgentMessageKind,
PuzzleAgentMessageRole, PuzzleAgentMessageSnapshot, PuzzleAgentSessionCreateInput,
PuzzleAgentSessionGetInput, PuzzleAgentSessionProcedureResult, PuzzleAgentSessionSnapshot,
PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleGeneratedImageCandidate,
PuzzleGeneratedImagesSaveInput, PuzzlePublicationStatus, PuzzlePublishInput, PuzzleResultDraft,
PuzzleRunDragInput, PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunProcedureResult,
PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus,
@@ -158,6 +158,25 @@ pub fn submit_puzzle_agent_message(
}
}
#[spacetimedb::procedure]
pub fn finalize_puzzle_agent_message_turn(
ctx: &mut ProcedureContext,
input: PuzzleAgentMessageFinalizeInput,
) -> PuzzleAgentSessionProcedureResult {
match ctx.try_with_tx(|tx| finalize_puzzle_agent_message_turn_tx(tx, input.clone())) {
Ok(session) => PuzzleAgentSessionProcedureResult {
ok: true,
session_json: Some(serialize_json(&session)),
error_message: None,
},
Err(message) => PuzzleAgentSessionProcedureResult {
ok: false,
session_json: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn compile_puzzle_agent_draft(
ctx: &mut ProcedureContext,
@@ -472,11 +491,9 @@ fn submit_puzzle_agent_message_tx(
ctx: &TxContext,
input: module_puzzle::PuzzleAgentMessageSubmitInput,
) -> Result<PuzzleAgentSessionSnapshot, String> {
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
ensure_message_missing(ctx, &input.user_message_id)?;
let submitted_at = Timestamp::from_micros_since_unix_epoch(input.submitted_at_micros);
let next_anchor_pack = infer_anchor_pack(&row.seed_text, Some(&input.user_message_text));
let assistant_message_text = build_puzzle_assistant_reply(&next_anchor_pack);
ctx.db.puzzle_agent_message().insert(PuzzleAgentMessageRow {
message_id: input.user_message_id.clone(),
@@ -486,19 +503,75 @@ fn submit_puzzle_agent_message_tx(
text: input.user_message_text.clone(),
created_at: submitted_at,
});
let assistant_message_id = format!(
"{}assistant-{}",
input.session_id, input.submitted_at_micros
);
get_puzzle_agent_session_tx(
ctx,
PuzzleAgentSessionGetInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
},
)
}
fn finalize_puzzle_agent_message_turn_tx(
ctx: &TxContext,
input: PuzzleAgentMessageFinalizeInput,
) -> Result<PuzzleAgentSessionSnapshot, String> {
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
if let Some(error_message) = input
.error_message
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
replace_puzzle_agent_session(
ctx,
&row,
PuzzleAgentSessionRow {
session_id: row.session_id.clone(),
owner_user_id: row.owner_user_id.clone(),
seed_text: row.seed_text.clone(),
current_turn: row.current_turn,
progress_percent: row.progress_percent,
stage: row.stage,
anchor_pack_json: row.anchor_pack_json.clone(),
draft_json: row.draft_json.clone(),
last_assistant_reply: row.last_assistant_reply.clone(),
published_profile_id: row.published_profile_id.clone(),
created_at: row.created_at,
updated_at,
},
);
return Err(error_message.to_string());
}
let assistant_message_id = input
.assistant_message_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| "拼图 assistant_message_id 不能为空".to_string())?
.to_string();
let assistant_reply_text = input
.assistant_reply_text
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| "拼图 assistant_reply_text 不能为空".to_string())?
.to_string();
ensure_message_missing(ctx, &assistant_message_id)?;
let next_anchor_pack = deserialize_anchor_pack(&input.anchor_pack_json)?;
ctx.db.puzzle_agent_message().insert(PuzzleAgentMessageRow {
message_id: assistant_message_id,
session_id: input.session_id.clone(),
role: PuzzleAgentMessageRole::Assistant,
kind: PuzzleAgentMessageKind::Summary,
text: assistant_message_text.clone(),
created_at: submitted_at,
kind: PuzzleAgentMessageKind::Chat,
text: assistant_reply_text.clone(),
created_at: updated_at,
});
replace_puzzle_agent_session(
ctx,
&row,
@@ -507,14 +580,14 @@ fn submit_puzzle_agent_message_tx(
owner_user_id: row.owner_user_id.clone(),
seed_text: row.seed_text.clone(),
current_turn: row.current_turn.saturating_add(1),
progress_percent: (row.progress_percent + 18).min(82),
stage: PuzzleAgentStage::CollectingAnchors,
progress_percent: input.progress_percent.min(100),
stage: input.stage,
anchor_pack_json: serialize_json(&next_anchor_pack),
draft_json: row.draft_json.clone(),
last_assistant_reply: Some(assistant_message_text),
last_assistant_reply: Some(assistant_reply_text),
published_profile_id: row.published_profile_id.clone(),
created_at: row.created_at,
updated_at: submitted_at,
updated_at,
},
);
@@ -535,6 +608,15 @@ fn compile_puzzle_agent_draft_tx(
let anchor_pack = deserialize_anchor_pack(&row.anchor_pack_json)?;
let messages = list_session_messages(ctx, &row.session_id);
let draft = compile_result_draft(&anchor_pack, &messages);
// 创作中心的拼图草稿卡只是 Agent session 的列表投影,
// 每次编译结果页时同步 upsert保证后续能按 source_session_id 恢复聊天。
upsert_puzzle_draft_work_profile(
ctx,
&row.session_id,
&row.owner_user_id,
&draft,
input.compiled_at_micros,
)?;
let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros);
replace_puzzle_agent_session(
ctx,
@@ -601,6 +683,14 @@ fn save_puzzle_generated_images_tx(
} else {
PuzzleAgentStage::ImageRefining
};
// 结果页草稿封面和候选图发生变化后,草稿卡需要同步刷新。
upsert_puzzle_draft_work_profile(
ctx,
&row.session_id,
&row.owner_user_id,
&draft,
input.saved_at_micros,
)?;
replace_puzzle_agent_session(
ctx,
&row,
@@ -642,6 +732,14 @@ fn select_puzzle_cover_image_tx(
} else {
PuzzleAgentStage::ImageRefining
};
// 选定正式封面后,创作中心草稿卡要立即反映最新正式图。
upsert_puzzle_draft_work_profile(
ctx,
&row.session_id,
&row.owner_user_id,
&draft,
input.selected_at_micros,
)?;
replace_puzzle_agent_session(
ctx,
&row,
@@ -682,9 +780,10 @@ fn publish_puzzle_work_tx(
input.theme_tags.clone(),
)
.map_err(|error| error.to_string())?;
let (work_id, profile_id) = build_puzzle_work_ids_from_session_id(&input.session_id);
let mut profile = create_work_profile(
input.work_id.clone(),
input.profile_id.clone(),
work_id,
profile_id,
input.owner_user_id.clone(),
Some(input.session_id.clone()),
input.author_display_name.clone(),
@@ -996,6 +1095,42 @@ fn build_puzzle_work_profile_from_row(
})
}
fn build_puzzle_work_ids_from_session_id(session_id: &str) -> (String, String) {
let stable_suffix = session_id
.strip_prefix("puzzle-session-")
.unwrap_or(session_id);
(
format!("puzzle-work-{stable_suffix}"),
format!("puzzle-profile-{stable_suffix}"),
)
}
fn upsert_puzzle_draft_work_profile(
ctx: &TxContext,
session_id: &str,
owner_user_id: &str,
draft: &PuzzleResultDraft,
updated_at_micros: i64,
) -> Result<(), String> {
let (work_id, profile_id) = build_puzzle_work_ids_from_session_id(session_id);
if let Some(existing) = ctx.db.puzzle_work_profile().profile_id().find(&profile_id) {
if existing.publication_status == PuzzlePublicationStatus::Published {
return Ok(());
}
}
let profile = create_work_profile(
work_id,
profile_id,
owner_user_id.to_string(),
Some(session_id.to_string()),
"创作者".to_string(),
draft,
updated_at_micros,
)
.map_err(|error| error.to_string())?;
upsert_puzzle_work_profile(ctx, profile)
}
fn list_session_messages(ctx: &TxContext, session_id: &str) -> Vec<PuzzleAgentMessageSnapshot> {
let mut items = ctx
.db
@@ -1045,15 +1180,6 @@ fn build_puzzle_suggested_actions(
}
}
fn build_puzzle_assistant_reply(anchor_pack: &PuzzleAnchorPack) -> String {
format!(
"我先帮你收束成一版拼图方向:题材是“{}”,主体聚焦“{}”,氛围偏“{}”。",
anchor_pack.theme_promise.value,
anchor_pack.visual_subject.value,
anchor_pack.visual_mood.value
)
}
fn append_system_message(
ctx: &TxContext,
session_id: &str,

View File

@@ -0,0 +1 @@
// Browse history 相关表、procedure 与 helper 落位点。

View File

@@ -0,0 +1 @@
// Profile dashboard、wallet 与 played world 投影落位点。

View File

@@ -0,0 +1 @@
// Runtime settings 相关表、procedure 与 helper 落位点。

View File

@@ -0,0 +1 @@
// Runtime snapshot 与 save archive 相关表、procedure 与 helper 落位点。