This commit is contained in:
2026-04-22 22:01:07 +08:00
parent d8716d70b0
commit b317c2a8ea
37 changed files with 1821 additions and 515 deletions

View File

@@ -9,8 +9,7 @@ use axum::{
response::Response,
};
use platform_auth::{
AccessTokenClaims, AuthProvider, BindingStatus, read_refresh_session_token,
verify_access_token,
AccessTokenClaims, AuthProvider, BindingStatus, read_refresh_session_token, verify_access_token,
};
use serde_json::{Value, json};
use time::OffsetDateTime;
@@ -237,11 +236,11 @@ mod tests {
INTERNAL_API_SECRET_HEADER, INTERNAL_AUTH_USER_ID_HEADER, RefreshSessionToken,
extract_bearer_token, try_build_internal_forwarded_claims,
};
use crate::{config::AppConfig, state::AppState};
use axum::{
http::{HeaderMap, HeaderValue, StatusCode, header::AUTHORIZATION},
response::IntoResponse,
};
use crate::{config::AppConfig, state::AppState};
#[test]
fn extract_bearer_token_accepts_standard_header() {

View File

@@ -9,8 +9,8 @@ use shared_contracts::big_fish::{
BigFishActionResponse, BigFishAgentMessageResponse, BigFishAnchorItemResponse,
BigFishAnchorPackResponse, BigFishAssetCoverageResponse, BigFishAssetSlotResponse,
BigFishBackgroundBlueprintResponse, BigFishGameDraftResponse, BigFishLevelBlueprintResponse,
BigFishRuntimeEntityResponse, BigFishRuntimeParamsResponse, BigFishRuntimeSnapshotResponse,
BigFishRunResponse, BigFishSessionResponse, BigFishSessionSnapshotResponse,
BigFishRunResponse, BigFishRuntimeEntityResponse, BigFishRuntimeParamsResponse,
BigFishRuntimeSnapshotResponse, BigFishSessionResponse, BigFishSessionSnapshotResponse,
BigFishVector2Response, CreateBigFishSessionRequest, ExecuteBigFishActionRequest,
SendBigFishMessageRequest, SubmitBigFishInputRequest,
};
@@ -58,7 +58,9 @@ pub async fn create_big_fish_session(
created_at_micros: current_utc_micros(),
})
.await
.map_err(|error| big_fish_error_response(&request_context, map_big_fish_client_error(error)))?;
.map_err(|error| {
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
@@ -80,7 +82,9 @@ pub async fn get_big_fish_session(
.spacetime_client()
.get_big_fish_session(session_id, authenticated.claims().user_id().to_string())
.await
.map_err(|error| big_fish_error_response(&request_context, map_big_fish_client_error(error)))?;
.map_err(|error| {
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
@@ -128,7 +132,9 @@ pub async fn submit_big_fish_message(
submitted_at_micros: current_utc_micros(),
})
.await
.map_err(|error| big_fish_error_response(&request_context, map_big_fish_client_error(error)))?;
.map_err(|error| {
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
@@ -168,7 +174,9 @@ pub async fn stream_big_fish_message(
submitted_at_micros: current_utc_micros(),
})
.await
.map_err(|error| big_fish_error_response(&request_context, map_big_fish_client_error(error)))?;
.map_err(|error| {
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
let session_response = map_big_fish_session_response(session);
let reply_text = session_response
@@ -176,9 +184,24 @@ pub async fn stream_big_fish_message(
.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 }))?;
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))
}
@@ -288,7 +311,9 @@ pub async fn start_big_fish_run(
started_at_micros: current_utc_micros(),
})
.await
.map_err(|error| big_fish_error_response(&request_context, map_big_fish_client_error(error)))?;
.map_err(|error| {
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
@@ -310,7 +335,9 @@ pub async fn get_big_fish_run(
.spacetime_client()
.get_big_fish_run(run_id, authenticated.claims().user_id().to_string())
.await
.map_err(|error| big_fish_error_response(&request_context, map_big_fish_client_error(error)))?;
.map_err(|error| {
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
@@ -348,7 +375,9 @@ pub async fn submit_big_fish_input(
submitted_at_micros: current_utc_micros(),
})
.await
.map_err(|error| big_fish_error_response(&request_context, map_big_fish_client_error(error)))?;
.map_err(|error| {
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
@@ -383,7 +412,9 @@ fn map_big_fish_session_response(session: BigFishSessionRecord) -> BigFishSessio
}
}
fn map_big_fish_anchor_pack_response(anchor_pack: BigFishAnchorPackRecord) -> BigFishAnchorPackResponse {
fn map_big_fish_anchor_pack_response(
anchor_pack: BigFishAnchorPackRecord,
) -> BigFishAnchorPackResponse {
BigFishAnchorPackResponse {
gameplay_promise: map_big_fish_anchor_item_response(anchor_pack.gameplay_promise),
ecology_visual_theme: map_big_fish_anchor_item_response(anchor_pack.ecology_visual_theme),
@@ -417,7 +448,9 @@ fn map_big_fish_draft_response(draft: BigFishGameDraftRecord) -> BigFishGameDraf
}
}
fn map_big_fish_level_response(level: BigFishLevelBlueprintRecord) -> BigFishLevelBlueprintResponse {
fn map_big_fish_level_response(
level: BigFishLevelBlueprintRecord,
) -> BigFishLevelBlueprintResponse {
BigFishLevelBlueprintResponse {
level: level.level,
name: level.name,
@@ -528,7 +561,9 @@ fn map_big_fish_runtime_response(run: BigFishRuntimeRecord) -> BigFishRuntimeSna
}
}
fn map_big_fish_entity_response(entity: BigFishRuntimeEntityRecord) -> BigFishRuntimeEntityResponse {
fn map_big_fish_entity_response(
entity: BigFishRuntimeEntityRecord,
) -> BigFishRuntimeEntityResponse {
BigFishRuntimeEntityResponse {
entity_id: entity.entity_id,
level: entity.level,
@@ -547,7 +582,8 @@ fn map_big_fish_vector_response(vector: BigFishVector2Record) -> BigFishVector2R
fn build_big_fish_welcome_text(seed_text: &str) -> String {
if seed_text.trim().is_empty() {
return "我会先帮你确定大鱼吃小鱼的核心锚点。可以从主题生态、成长阶梯或风险节奏开始。".to_string();
return "我会先帮你确定大鱼吃小鱼的核心锚点。可以从主题生态、成长阶梯或风险节奏开始。"
.to_string();
}
"我已经收到你的玩法起点,会先把它整理成锚点并准备结果页草稿。".to_string()
}

View File

@@ -1,4 +1,4 @@
use std::{env, net::SocketAddr};
use std::{env, fs, net::SocketAddr, path::PathBuf};
use platform_llm::{
DEFAULT_ARK_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_REQUEST_TIMEOUT_MS,
@@ -7,6 +7,7 @@ use platform_llm::{
const DEFAULT_LLM_MODEL: &str = "doubao-1-5-pro-32k-character-250715";
const DEFAULT_INTERNAL_API_SECRET: &str = "genarrative-dev-internal-bridge";
const SPACETIME_LOCAL_CONFIG_FILE: &str = "spacetime.local.json";
// 集中管理 api-server 的启动配置,避免入口层直接散落环境变量解析逻辑。
#[derive(Clone, Debug)]
@@ -123,6 +124,10 @@ impl AppConfig {
pub fn from_env() -> Self {
let mut config = Self::default();
if let Some(local_spacetime_database) = read_local_spacetime_database() {
config.spacetime_database = local_spacetime_database;
}
if let Ok(bind_host) = env::var("GENARRATIVE_API_HOST")
&& !bind_host.trim().is_empty()
{
@@ -141,8 +146,7 @@ impl AppConfig {
config.log_filter = log_filter;
}
config.internal_api_secret =
read_first_non_empty_env(&["GENARRATIVE_INTERNAL_API_SECRET"]);
config.internal_api_secret = read_first_non_empty_env(&["GENARRATIVE_INTERNAL_API_SECRET"]);
if let Some(jwt_issuer) =
read_first_non_empty_env(&["GENARRATIVE_JWT_ISSUER", "JWT_ISSUER"])
@@ -361,6 +365,33 @@ fn read_first_non_empty_env(keys: &[&str]) -> Option<String> {
})
}
fn read_local_spacetime_database() -> Option<String> {
let config_path = find_upward_file(SPACETIME_LOCAL_CONFIG_FILE)?;
let raw_text = fs::read_to_string(config_path).ok()?;
let parsed = serde_json::from_str::<serde_json::Value>(&raw_text).ok()?;
parsed
.get("database")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn find_upward_file(file_name: &str) -> Option<PathBuf> {
let mut current_dir = env::current_dir().ok()?;
loop {
let candidate = current_dir.join(file_name);
if candidate.is_file() {
return Some(candidate);
}
if !current_dir.pop() {
return None;
}
}
}
fn read_first_duration_seconds_env(keys: &[&str]) -> Option<u64> {
keys.iter().find_map(|key| {
env::var(key)

View File

@@ -30,15 +30,16 @@ use shared_contracts::{
SwapPuzzlePiecesRequest,
},
puzzle_works::{
PuzzleWorkDetailResponse, PuzzleWorkMutationResponse, PuzzleWorkProfileResponse,
PuzzleWorkSummaryResponse, PuzzleWorksResponse, PutPuzzleWorkRequest,
PutPuzzleWorkRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse,
PuzzleWorkProfileResponse, PuzzleWorkSummaryResponse, PuzzleWorksResponse,
},
};
use shared_kernel::build_prefixed_uuid_id;
use spacetime_client::{
PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput,
PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord,
PuzzleAnchorPackRecord, PuzzleCreatorIntentRecord, PuzzleGeneratedImageCandidateRecord,
PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput,
PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord,
PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord,
PuzzleCreatorIntentRecord, PuzzleGeneratedImageCandidateRecord,
PuzzleGeneratedImagesSaveRecordInput, PuzzlePublishRecordInput, PuzzleResultDraftRecord,
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
PuzzleRunDragRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
@@ -47,11 +48,8 @@ use spacetime_client::{
};
use crate::{
api_response::json_success_body,
auth::AuthenticatedAccessToken,
http_error::AppError,
request_context::RequestContext,
state::AppState,
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
};
const PUZZLE_AGENT_API_BASE_PROVIDER: &str = "puzzle-agent";
@@ -110,14 +108,16 @@ pub async fn get_puzzle_agent_session(
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, &session_id, "sessionId")?;
ensure_non_empty(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
&session_id,
"sessionId",
)?;
let session = state
.spacetime_client()
.get_puzzle_agent_session(
session_id,
authenticated.claims().user_id().to_string(),
)
.get_puzzle_agent_session(session_id, authenticated.claims().user_id().to_string())
.await
.map_err(|error| {
puzzle_error_response(
@@ -152,7 +152,12 @@ pub async fn submit_puzzle_agent_message(
})),
)
})?;
ensure_non_empty(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, &session_id, "sessionId")?;
ensure_non_empty(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
&session_id,
"sessionId",
)?;
let client_message_id = payload.client_message_id.trim().to_string();
let message_text = payload.text.trim().to_string();
@@ -207,7 +212,12 @@ pub async fn stream_puzzle_agent_message(
})),
)
})?;
ensure_non_empty(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, &session_id, "sessionId")?;
ensure_non_empty(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
&session_id,
"sessionId",
)?;
let session = state
.spacetime_client()
@@ -245,7 +255,12 @@ pub async fn stream_puzzle_agent_message(
"session",
&json!({ "session": session_response }),
)?;
append_sse_event(&request_context, &mut sse_body, "done", &json!({ "ok": true }))?;
append_sse_event(
&request_context,
&mut sse_body,
"done",
&json!({ "ok": true }),
)?;
Ok(build_event_stream_response(sse_body))
}
@@ -266,7 +281,12 @@ pub async fn execute_puzzle_agent_action(
})),
)
})?;
ensure_non_empty(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, &session_id, "sessionId")?;
ensure_non_empty(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
&session_id,
"sessionId",
)?;
let owner_user_id = authenticated.claims().user_id().to_string();
let now = current_utc_micros();
@@ -327,11 +347,11 @@ pub async fn execute_puzzle_agent_action(
})
.collect::<Vec<_>>(),
)
.map_err(|error| {
SpacetimeClientError::Runtime(format!(
"拼图候选图序列化失败:{error}"
))
});
.map_err(|error| {
SpacetimeClientError::Runtime(format!(
"拼图候选图序列化失败:{error}"
))
});
match candidates_json {
Ok(candidates_json) => {
state
@@ -497,7 +517,12 @@ pub async fn get_puzzle_work_detail(
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, PUZZLE_WORKS_PROVIDER, &profile_id, "profileId")?;
ensure_non_empty(
&request_context,
PUZZLE_WORKS_PROVIDER,
&profile_id,
"profileId",
)?;
let item = state
.spacetime_client()
@@ -536,7 +561,12 @@ pub async fn put_puzzle_work(
})),
)
})?;
ensure_non_empty(&request_context, PUZZLE_WORKS_PROVIDER, &profile_id, "profileId")?;
ensure_non_empty(
&request_context,
PUZZLE_WORKS_PROVIDER,
&profile_id,
"profileId",
)?;
let item = state
.spacetime_client()
@@ -599,7 +629,12 @@ pub async fn get_puzzle_gallery_detail(
AxumPath(profile_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, PUZZLE_GALLERY_PROVIDER, &profile_id, "profileId")?;
ensure_non_empty(
&request_context,
PUZZLE_GALLERY_PROVIDER,
&profile_id,
"profileId",
)?;
let item = state
.spacetime_client()
@@ -637,7 +672,12 @@ pub async fn start_puzzle_run(
})),
)
})?;
ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &payload.profile_id, "profileId")?;
ensure_non_empty(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
&payload.profile_id,
"profileId",
)?;
let run = state
.spacetime_client()
@@ -767,7 +807,12 @@ pub async fn drag_puzzle_piece_or_group(
)
})?;
ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?;
ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &payload.piece_id, "pieceId")?;
ensure_non_empty(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
&payload.piece_id,
"pieceId",
)?;
let run = state
.spacetime_client()
@@ -850,12 +895,16 @@ fn map_puzzle_agent_session_response(
.into_iter()
.map(map_puzzle_suggested_action_response)
.collect(),
result_preview: session.result_preview.map(map_puzzle_result_preview_response),
result_preview: session
.result_preview
.map(map_puzzle_result_preview_response),
updated_at: session.updated_at,
}
}
fn map_puzzle_anchor_pack_response(anchor_pack: PuzzleAnchorPackRecord) -> PuzzleAnchorPackResponse {
fn map_puzzle_anchor_pack_response(
anchor_pack: PuzzleAnchorPackRecord,
) -> PuzzleAnchorPackResponse {
PuzzleAnchorPackResponse {
theme_promise: map_puzzle_anchor_item_response(anchor_pack.theme_promise),
visual_subject: map_puzzle_anchor_item_response(anchor_pack.visual_subject),
@@ -1098,8 +1147,7 @@ fn resolve_author_display_name(
fn build_puzzle_welcome_text(seed_text: &str) -> String {
if seed_text.trim().is_empty() {
return "先告诉我你想做一张什么样的拼图图像,我会帮你收束成可发布的题材锚点。"
.to_string();
return "先告诉我你想做一张什么样的拼图图像,我会帮你收束成可发布的题材锚点。".to_string();
}
"我先接住你的画面灵感,再一起把它收束成正式拼图关卡。".to_string()
@@ -1124,11 +1172,7 @@ fn ensure_non_empty(
Ok(())
}
fn puzzle_bad_request(
request_context: &RequestContext,
provider: &str,
message: &str,
) -> Response {
fn puzzle_bad_request(request_context: &RequestContext, provider: &str, message: &str) -> Response {
puzzle_error_response(
request_context,
provider,
@@ -1279,7 +1323,11 @@ fn save_placeholder_puzzle_asset(
fs::write(output_dir.join(&file_name), svg).map_err(io_error)?;
Ok(GeneratedPuzzleAssetResponse {
image_src: format!("/{}/{}", relative_dir.to_string_lossy().replace('\\', "/"), file_name),
image_src: format!(
"/{}/{}",
relative_dir.to_string_lossy().replace('\\', "/"),
file_name
),
asset_id,
})
}

View File

@@ -11,35 +11,27 @@ use module_npc::{
use module_runtime::RuntimeSnapshotRecord;
use module_runtime_story_compat::{
CONTINUE_ADVENTURE_FUNCTION_ID, CurrentEncounterNpcQuestContext, GeneratedStoryPayload,
PendingQuestOfferContext, RuntimeStoryActionResponseParts,
StoryResolution, add_player_currency, add_player_inventory_items,
append_story_history, apply_equipment_loadout_to_state,
battle_mode_text, build_battle_runtime_story_options, build_current_build_toast,
build_status_patch,
build_npc_gift_result_text,
build_runtime_story_view_model,
PendingQuestOfferContext, RuntimeStoryActionResponseParts, StoryResolution,
add_player_currency, add_player_inventory_items, append_story_history,
apply_equipment_loadout_to_state, battle_mode_text, build_battle_runtime_story_options,
build_current_build_toast, build_disabled_runtime_story_option, build_npc_gift_result_text,
build_runtime_story_option_from_story_option, build_runtime_story_view_model,
build_static_runtime_story_option, build_status_patch, build_story_option_from_runtime_option,
clear_encounter_only, clear_encounter_state, clone_inventory_item_with_quantity,
current_encounter_id, current_encounter_name, current_world_type,
ensure_inventory_action_available, ensure_json_object, equipment_slot_label,
find_player_inventory_entry,
format_now_rfc3339, grant_player_progression_experience, has_giftable_player_inventory,
format_currency_text,
increment_runtime_stat, normalize_equipped_item,
normalize_equipment_slot_id, normalize_required_string, npc_buyback_price,
npc_purchase_price, read_array_field, read_bool_field, read_field, read_i32_field,
read_inventory_item_name, read_object_field, read_optional_string_field,
find_player_inventory_entry, format_currency_text, format_now_rfc3339,
grant_player_progression_experience, has_giftable_player_inventory, increment_runtime_stat,
normalize_equipment_slot_id, normalize_equipped_item, normalize_required_string,
npc_buyback_price, npc_purchase_price, read_array_field, read_bool_field, read_field,
read_i32_field, read_inventory_item_name, read_object_field, read_optional_string_field,
read_player_equipment_item, read_required_string_field, read_runtime_session_id,
read_u32_field, recruit_companion_to_party, remove_player_inventory_item,
restore_player_resource,
resolve_action_text, resolve_battle_action, resolve_equipment_slot_for_item,
resolve_forge_craft_action,
resolve_forge_dismantle_action, resolve_forge_reforge_action,
resolve_npc_gift_affinity_gain, simple_story_resolution, trade_quantity_suffix,
resolve_current_encounter_npc_state,
build_disabled_runtime_story_option, build_runtime_story_option_from_story_option,
build_static_runtime_story_option, build_story_option_from_runtime_option,
write_bool_field, write_i32_field, write_null_field, write_player_equipment_item,
write_string_field, write_u32_field,
read_u32_field, recruit_companion_to_party, remove_player_inventory_item, resolve_action_text,
resolve_battle_action, resolve_current_encounter_npc_state, resolve_equipment_slot_for_item,
resolve_forge_craft_action, resolve_forge_dismantle_action, resolve_forge_reforge_action,
resolve_npc_gift_affinity_gain, restore_player_resource, simple_story_resolution,
trade_quantity_suffix, write_bool_field, write_i32_field, write_null_field,
write_player_equipment_item, write_string_field, write_u32_field,
};
use platform_llm::{LlmClient, LlmMessage, LlmTextRequest};
use serde_json::{Map, Value, json};

View File

@@ -19,9 +19,9 @@ use module_big_fish::{
BigFishAgentMessageKind, BigFishAgentMessageRole, BigFishAgentMessageSnapshot,
BigFishAssetGenerateInput, BigFishAssetKind, BigFishAssetSlotSnapshot, BigFishAssetStatus,
BigFishCreationStage, BigFishDraftCompileInput, BigFishMessageSubmitInput, BigFishPublishInput,
BigFishRunGetInput, BigFishRunInputSubmitInput, BigFishRunProcedureResult, BigFishRunStartInput,
BigFishRunStatus, BigFishRuntimeSnapshot, BigFishSessionCreateInput, BigFishSessionGetInput,
BigFishSessionProcedureResult, BigFishSessionSnapshot,
BigFishRunGetInput, BigFishRunInputSubmitInput, BigFishRunProcedureResult,
BigFishRunStartInput, BigFishRunStatus, BigFishRuntimeSnapshot, BigFishSessionCreateInput,
BigFishSessionGetInput, BigFishSessionProcedureResult, BigFishSessionSnapshot,
advance_runtime_snapshot, build_asset_coverage, build_generated_asset_slot,
build_initial_runtime_snapshot, compile_default_draft, deserialize_anchor_pack,
deserialize_draft, deserialize_runtime_snapshot, empty_anchor_pack, infer_anchor_pack,
@@ -41,29 +41,29 @@ use module_combat::{
use module_custom_world::{
CustomWorldAgentActionExecuteInput, CustomWorldAgentActionExecuteResult,
CustomWorldAgentCardDetailGetInput, CustomWorldAgentMessageSnapshot,
CustomWorldAgentMessageSubmitInput,
CustomWorldAgentOperationGetInput, CustomWorldAgentOperationProcedureResult,
CustomWorldAgentOperationSnapshot, CustomWorldAgentSessionCreateInput,
CustomWorldAgentSessionGetInput, CustomWorldAgentSessionProcedureResult,
CustomWorldAgentSessionSnapshot, CustomWorldDraftCardDetailResult,
CustomWorldDraftCardDetailSectionSnapshot, CustomWorldDraftCardDetailSnapshot,
CustomWorldDraftCardSnapshot, CustomWorldPublishBlockerSnapshot,
CustomWorldPublishGateSnapshot, CustomWorldWorkSummarySnapshot, CustomWorldWorksListInput,
CustomWorldWorksListResult,
CustomWorldGalleryDetailInput, CustomWorldGalleryEntrySnapshot,
CustomWorldGalleryListResult, CustomWorldGenerationMode, CustomWorldLibraryDetailInput,
CustomWorldLibraryMutationResult, CustomWorldProfileListInput,
CustomWorldProfileListResult, CustomWorldProfilePublishInput, CustomWorldProfileSnapshot,
CustomWorldProfileUnpublishInput, CustomWorldProfileUpsertInput,
CustomWorldPublicationStatus, CustomWorldPublishWorldInput, CustomWorldPublishWorldResult,
CustomWorldAgentMessageSubmitInput, CustomWorldAgentOperationGetInput,
CustomWorldAgentOperationProcedureResult, CustomWorldAgentOperationSnapshot,
CustomWorldAgentSessionCreateInput, CustomWorldAgentSessionGetInput,
CustomWorldAgentSessionProcedureResult, CustomWorldAgentSessionSnapshot,
CustomWorldDraftCardDetailResult, CustomWorldDraftCardDetailSectionSnapshot,
CustomWorldDraftCardDetailSnapshot, CustomWorldDraftCardSnapshot,
CustomWorldGalleryDetailInput, CustomWorldGalleryEntrySnapshot, CustomWorldGalleryListResult,
CustomWorldGenerationMode, CustomWorldLibraryDetailInput, CustomWorldLibraryMutationResult,
CustomWorldProfileListInput, CustomWorldProfileListResult, CustomWorldProfilePublishInput,
CustomWorldProfileSnapshot, CustomWorldProfileUnpublishInput, CustomWorldProfileUpsertInput,
CustomWorldPublicationStatus, CustomWorldPublishBlockerSnapshot,
CustomWorldPublishGateSnapshot, CustomWorldPublishWorldInput, CustomWorldPublishWorldResult,
CustomWorldPublishedProfileCompileInput, CustomWorldPublishedProfileCompileResult,
CustomWorldRoleAssetStatus, CustomWorldSessionStatus, CustomWorldThemeMode,
CustomWorldWorkSummarySnapshot, CustomWorldWorksListInput, CustomWorldWorksListResult,
RpgAgentDraftCardKind, RpgAgentDraftCardStatus, RpgAgentMessageKind, RpgAgentMessageRole,
RpgAgentOperationStatus, RpgAgentOperationType, RpgAgentStage,
build_custom_world_published_profile_compile_snapshot, validate_custom_world_agent_message_submit_input,
build_custom_world_published_profile_compile_snapshot,
validate_custom_world_agent_action_execute_input,
validate_custom_world_agent_card_detail_get_input,
validate_custom_world_agent_operation_get_input, validate_custom_world_agent_session_create_input,
validate_custom_world_agent_message_submit_input,
validate_custom_world_agent_operation_get_input,
validate_custom_world_agent_session_create_input,
validate_custom_world_agent_session_get_input, validate_custom_world_gallery_detail_input,
validate_custom_world_library_detail_input, validate_custom_world_profile_list_input,
validate_custom_world_profile_publish_input, validate_custom_world_profile_unpublish_input,
@@ -105,26 +105,24 @@ use module_quest::{
};
use module_runtime::{
DEFAULT_MUSIC_VOLUME, DEFAULT_PLATFORM_THEME, DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT,
PROFILE_WALLET_LEDGER_LIST_LIMIT, SAVE_SNAPSHOT_VERSION,
RuntimeBrowseHistoryClearInput, RuntimeBrowseHistoryListInput,
RuntimeBrowseHistoryProcedureResult, RuntimeBrowseHistorySnapshot,
RuntimeBrowseHistorySyncInput, RuntimeBrowseHistoryThemeMode, RuntimePlatformTheme,
RuntimeProfileSaveArchiveListInput, RuntimeProfileSaveArchiveProcedureResult,
RuntimeProfileSaveArchiveResumeInput, RuntimeProfileSaveArchiveSnapshot,
RuntimeProfileDashboardGetInput, RuntimeProfileDashboardProcedureResult,
PROFILE_WALLET_LEDGER_LIST_LIMIT, RuntimeBrowseHistoryClearInput,
RuntimeBrowseHistoryListInput, RuntimeBrowseHistoryProcedureResult,
RuntimeBrowseHistorySnapshot, RuntimeBrowseHistorySyncInput, RuntimeBrowseHistoryThemeMode,
RuntimePlatformTheme, RuntimeProfileDashboardGetInput, RuntimeProfileDashboardProcedureResult,
RuntimeProfileDashboardSnapshot, RuntimeProfilePlayStatsGetInput,
RuntimeProfilePlayStatsProcedureResult, RuntimeProfilePlayStatsSnapshot,
RuntimeProfilePlayedWorldSnapshot, RuntimeProfileWalletLedgerEntrySnapshot,
RuntimeProfilePlayedWorldSnapshot, RuntimeProfileSaveArchiveListInput,
RuntimeProfileSaveArchiveProcedureResult, RuntimeProfileSaveArchiveResumeInput,
RuntimeProfileSaveArchiveSnapshot, RuntimeProfileWalletLedgerEntrySnapshot,
RuntimeProfileWalletLedgerListInput, RuntimeProfileWalletLedgerProcedureResult,
RuntimeProfileWalletLedgerSourceType, RuntimeSettingGetInput,
RuntimeSettingProcedureResult, RuntimeSettingSnapshot, RuntimeSettingUpsertInput,
RuntimeSnapshot, RuntimeSnapshotDeleteInput, RuntimeSnapshotGetInput,
RuntimeSnapshotProcedureResult, RuntimeSnapshotUpsertInput,
build_runtime_browse_history_clear_input,
RuntimeProfileWalletLedgerSourceType, RuntimeSettingGetInput, RuntimeSettingProcedureResult,
RuntimeSettingSnapshot, RuntimeSettingUpsertInput, RuntimeSnapshot, RuntimeSnapshotDeleteInput,
RuntimeSnapshotGetInput, RuntimeSnapshotProcedureResult, RuntimeSnapshotUpsertInput,
SAVE_SNAPSHOT_VERSION, build_runtime_browse_history_clear_input,
build_runtime_browse_history_list_input, build_runtime_profile_dashboard_get_input,
build_runtime_profile_play_stats_get_input, build_runtime_profile_wallet_ledger_list_input,
build_runtime_profile_save_archive_list_input,
build_runtime_profile_save_archive_resume_input, build_runtime_setting_get_input,
build_runtime_profile_play_stats_get_input, build_runtime_profile_save_archive_list_input,
build_runtime_profile_save_archive_resume_input,
build_runtime_profile_wallet_ledger_list_input, build_runtime_setting_get_input,
build_runtime_setting_upsert_input, build_runtime_snapshot_delete_input,
build_runtime_snapshot_get_input, build_runtime_snapshot_upsert_input,
prepare_runtime_browse_history_entries,
@@ -142,13 +140,29 @@ use module_story::{
build_story_session_snapshot, build_story_started_event, validate_story_continue_input,
validate_story_session_input, validate_story_session_state_input,
};
use shared_kernel::format_timestamp_micros;
use serde_json::{Map as JsonMap, Value as JsonValue, json};
use shared_kernel::format_timestamp_micros;
use spacetimedb::{ProcedureContext, ReducerContext, SpacetimeType, Table, Timestamp};
mod puzzle;
// 这层输入只服务 NPC 开战编排;普通聊天、援手、招募继续走已有 resolve_npc_interaction 接口。
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct ResolveNpcBattleInteractionInput {
pub npc_interaction: ResolveNpcInteractionInput,
pub story_session_id: String,
pub actor_user_id: String,
pub battle_state_id: Option<String>,
pub player_hp: i32,
pub player_max_hp: i32,
pub player_mana: i32,
pub player_max_mana: i32,
pub target_hp: i32,
pub target_max_hp: i32,
pub experience_reward: u32,
pub reward_items: Vec<RuntimeItemRewardItemSnapshot>,
}
// 输出同时返回 NPC 交互结果与 battle_state 快照,避免 Axum 再回头读取 private table。
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct NpcBattleInteractionResult {
@@ -2023,8 +2037,9 @@ fn submit_big_fish_message_tx(
});
let anchor_pack = infer_anchor_pack(&session.seed_text, Some(&input.user_message_text));
let assistant_text = "我已经把这版方向收束成 4 个高杠杆锚点,可以继续细化,也可以直接编译第一版玩法草稿。"
.to_string();
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(),
@@ -2208,9 +2223,15 @@ fn publish_big_fish_game_tx(
.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));
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("")));
return Err(format!(
"big_fish 发布校验未通过:{}",
coverage.blockers.join("")
));
}
let published_at = Timestamp::from_micros_since_unix_epoch(input.published_at_micros);
@@ -4217,18 +4238,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());
@@ -4301,8 +4320,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))
@@ -4363,7 +4390,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"
@@ -4388,18 +4417,19 @@ 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 =
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_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,
@@ -4412,8 +4442,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,
@@ -4460,7 +4494,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()
@@ -4476,7 +4511,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)
@@ -4520,27 +4556,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,
@@ -4563,7 +4608,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(
@@ -4610,9 +4662,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,
@@ -4658,12 +4714,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,
@@ -4723,7 +4780,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,
);
@@ -4797,9 +4857,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,
@@ -4850,7 +4916,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(
@@ -4931,7 +5000,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")) {
@@ -5061,8 +5131,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
@@ -5181,8 +5253,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")
@@ -5220,8 +5294,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")
@@ -5253,7 +5333,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(),
@@ -5297,7 +5379,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()),
@@ -5307,8 +5391,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()),
@@ -5460,7 +5548,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(),
@@ -5473,24 +5562,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(())
@@ -5507,7 +5599,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()),
@@ -5527,8 +5622,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,
@@ -5543,7 +5642,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!(
@@ -5652,10 +5754,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;
@@ -5668,7 +5767,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());
}
}
@@ -5860,21 +5963,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)
}
@@ -6054,12 +6164,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,
@@ -7524,7 +7636,10 @@ fn upsert_runtime_snapshot_record(
let snapshot = match ctx.db.runtime_snapshot().user_id().find(&prepared.user_id) {
Some(existing) => {
ctx.db.runtime_snapshot().user_id().delete(&existing.user_id);
ctx.db
.runtime_snapshot()
.user_id()
.delete(&existing.user_id);
ctx.db.runtime_snapshot().insert(RuntimeSnapshotRow {
user_id: existing.user_id.clone(),
version: SAVE_SNAPSHOT_VERSION,
@@ -7591,7 +7706,10 @@ fn delete_runtime_snapshot_record(
.find(&validated_input.user_id);
if let Some(existing) = existing {
let snapshot = build_runtime_snapshot_from_row(&existing);
ctx.db.runtime_snapshot().user_id().delete(&existing.user_id);
ctx.db
.runtime_snapshot()
.user_id()
.delete(&existing.user_id);
return Ok(Some(snapshot));
}
@@ -7602,8 +7720,8 @@ fn list_profile_save_archive_rows(
ctx: &ReducerContext,
input: RuntimeProfileSaveArchiveListInput,
) -> Result<Vec<RuntimeProfileSaveArchiveSnapshot>, String> {
let validated_input =
build_runtime_profile_save_archive_list_input(input.user_id).map_err(|error| error.to_string())?;
let validated_input = build_runtime_profile_save_archive_list_input(input.user_id)
.map_err(|error| error.to_string())?;
let mut entries = ctx
.db
@@ -7627,13 +7745,16 @@ fn resume_profile_save_archive_record(
ctx: &ReducerContext,
input: RuntimeProfileSaveArchiveResumeInput,
) -> Result<(RuntimeProfileSaveArchiveSnapshot, RuntimeSnapshot), String> {
let validated_input = build_runtime_profile_save_archive_resume_input(input.user_id, input.world_key)
.map_err(|error| error.to_string())?;
let validated_input =
build_runtime_profile_save_archive_resume_input(input.user_id, input.world_key)
.map_err(|error| error.to_string())?;
let archive = ctx
.db
.profile_save_archive()
.iter()
.find(|row| row.user_id == validated_input.user_id && row.world_key == validated_input.world_key)
.find(|row| {
row.user_id == validated_input.user_id && row.world_key == validated_input.world_key
})
.ok_or_else(|| "profile_save_archive 对应 world_key 不存在".to_string())?;
let existing_snapshot = ctx
@@ -7647,7 +7768,10 @@ fn resume_profile_save_archive_record(
.unwrap_or(archive.saved_at);
if let Some(existing) = existing_snapshot {
ctx.db.runtime_snapshot().user_id().delete(&existing.user_id);
ctx.db
.runtime_snapshot()
.user_id()
.delete(&existing.user_id);
}
ctx.db.runtime_snapshot().insert(RuntimeSnapshotRow {
@@ -7701,21 +7825,23 @@ fn sync_profile_dashboard_from_snapshot(
.profile_dashboard_state()
.user_id()
.find(&snapshot.user_id);
let previous_wallet_balance = current_state.as_ref().map(|row| row.wallet_balance).unwrap_or(0);
let previous_wallet_balance = current_state
.as_ref()
.map(|row| row.wallet_balance)
.unwrap_or(0);
let previous_total_play_time_ms = current_state
.as_ref()
.map(|row| row.total_play_time_ms)
.unwrap_or(0);
let next_wallet_balance = read_non_negative_u64(game_state.and_then(|state| state.get("playerCurrency")));
let next_wallet_balance =
read_non_negative_u64(game_state.and_then(|state| state.get("playerCurrency")));
let mut next_total_play_time_ms = previous_total_play_time_ms;
if next_wallet_balance != previous_wallet_balance {
ctx.db.profile_wallet_ledger().insert(ProfileWalletLedger {
wallet_ledger_id: format!(
"{}:{}:{}",
snapshot.user_id,
snapshot.saved_at_micros,
next_wallet_balance
snapshot.user_id, snapshot.saved_at_micros, next_wallet_balance
),
user_id: snapshot.user_id.clone(),
amount_delta: next_wallet_balance as i64 - previous_wallet_balance as i64,
@@ -7733,12 +7859,17 @@ fn sync_profile_dashboard_from_snapshot(
.and_then(|stats| stats.get("playTimeMs")),
);
let played_world_id = format!("{}:{}", snapshot.user_id, world_meta.world_key);
let existing = ctx.db.profile_played_world().played_world_id().find(&played_world_id);
let existing = ctx
.db
.profile_played_world()
.played_world_id()
.find(&played_world_id);
let previous_observed_play_time_ms = existing
.as_ref()
.map(|row| row.last_observed_play_time_ms)
.unwrap_or(0);
let incremental_play_time_ms = current_play_time_ms.saturating_sub(previous_observed_play_time_ms);
let incremental_play_time_ms =
current_play_time_ms.saturating_sub(previous_observed_play_time_ms);
next_total_play_time_ms = next_total_play_time_ms.saturating_add(incremental_play_time_ms);
if let Some(existing) = existing {
@@ -7757,7 +7888,8 @@ fn sync_profile_dashboard_from_snapshot(
world_subtitle: world_meta.world_subtitle,
first_played_at: existing.first_played_at,
last_played_at: saved_at,
last_observed_play_time_ms: current_play_time_ms.max(existing.last_observed_play_time_ms),
last_observed_play_time_ms: current_play_time_ms
.max(existing.last_observed_play_time_ms),
});
} else {
ctx.db.profile_played_world().insert(ProfilePlayedWorld {
@@ -7781,21 +7913,25 @@ fn sync_profile_dashboard_from_snapshot(
.profile_dashboard_state()
.user_id()
.delete(&existing.user_id);
ctx.db.profile_dashboard_state().insert(ProfileDashboardState {
user_id: snapshot.user_id.clone(),
wallet_balance: next_wallet_balance,
total_play_time_ms: next_total_play_time_ms,
created_at: existing.created_at,
updated_at: saved_at,
});
ctx.db
.profile_dashboard_state()
.insert(ProfileDashboardState {
user_id: snapshot.user_id.clone(),
wallet_balance: next_wallet_balance,
total_play_time_ms: next_total_play_time_ms,
created_at: existing.created_at,
updated_at: saved_at,
});
} else {
ctx.db.profile_dashboard_state().insert(ProfileDashboardState {
user_id: snapshot.user_id.clone(),
wallet_balance: next_wallet_balance,
total_play_time_ms: next_total_play_time_ms,
created_at: saved_at,
updated_at: saved_at,
});
ctx.db
.profile_dashboard_state()
.insert(ProfileDashboardState {
user_id: snapshot.user_id.clone(),
wallet_balance: next_wallet_balance,
total_play_time_ms: next_total_play_time_ms,
created_at: saved_at,
updated_at: saved_at,
});
}
}
@@ -7805,17 +7941,18 @@ fn sync_profile_save_archive_from_snapshot(
game_state: &JsonValue,
saved_at: Timestamp,
) -> Result<(), String> {
let Some(archive_meta) = resolve_profile_save_archive_meta(game_state, snapshot.current_story_json.as_deref()) else {
let Some(archive_meta) =
resolve_profile_save_archive_meta(game_state, snapshot.current_story_json.as_deref())
else {
return Ok(());
};
let archive_id = format!("{}:{}", snapshot.user_id, archive_meta.world_key);
let existing = ctx
.db
.profile_save_archive()
.archive_id()
.find(&archive_id);
let created_at = existing.as_ref().map(|row| row.created_at).unwrap_or(saved_at);
let existing = ctx.db.profile_save_archive().archive_id().find(&archive_id);
let created_at = existing
.as_ref()
.map(|row| row.created_at)
.unwrap_or(saved_at);
if let Some(existing) = existing {
ctx.db
@@ -7905,7 +8042,8 @@ fn build_profile_save_archive_snapshot_from_row(
}
fn parse_json_str(raw: &str) -> Result<JsonValue, String> {
serde_json::from_str::<JsonValue>(raw).map_err(|error| format!("game_state_json 解析失败: {error}"))
serde_json::from_str::<JsonValue>(raw)
.map_err(|error| format!("game_state_json 解析失败: {error}"))
}
fn parse_optional_json_str(raw: Option<&str>) -> Result<Option<JsonValue>, String> {
@@ -7951,7 +8089,9 @@ fn resolve_profile_world_snapshot_meta(
game_state: Option<&serde_json::Map<String, JsonValue>>,
) -> Option<ProfileWorldSnapshotMeta> {
let game_state = game_state?;
let custom_world_profile = game_state.get("customWorldProfile").and_then(JsonValue::as_object);
let custom_world_profile = game_state
.get("customWorldProfile")
.and_then(JsonValue::as_object);
if let Some(custom_world_profile) = custom_world_profile {
let profile_id = read_string_from_json(custom_world_profile.get("id"));
@@ -7976,7 +8116,9 @@ fn resolve_profile_world_snapshot_meta(
}
let world_type = read_string_from_json(game_state.get("worldType"))?;
let current_scene_preset = game_state.get("currentScenePreset").and_then(JsonValue::as_object);
let current_scene_preset = game_state
.get("currentScenePreset")
.and_then(JsonValue::as_object);
Some(ProfileWorldSnapshotMeta {
world_key: format!("builtin:{world_type}"),
@@ -8004,8 +8146,8 @@ fn resolve_profile_save_archive_meta(
let story_engine_memory = game_state_object
.and_then(|state| state.get("storyEngineMemory"))
.and_then(JsonValue::as_object);
let continue_game_digest =
story_engine_memory.and_then(|memory| read_string_from_json(memory.get("continueGameDigest")));
let continue_game_digest = story_engine_memory
.and_then(|memory| read_string_from_json(memory.get("continueGameDigest")));
let current_story_text = parse_optional_json_str(current_story_json)
.ok()
.flatten()
@@ -8704,7 +8846,11 @@ fn replace_big_fish_session(
ctx.db.big_fish_creation_session().insert(next);
}
fn replace_big_fish_run(ctx: &ReducerContext, current: &BigFishRuntimeRun, next: BigFishRuntimeRun) {
fn replace_big_fish_run(
ctx: &ReducerContext,
current: &BigFishRuntimeRun,
next: BigFishRuntimeRun,
) {
ctx.db
.big_fish_runtime_run()
.run_id()
@@ -8713,12 +8859,7 @@ fn replace_big_fish_run(ctx: &ReducerContext, current: &BigFishRuntimeRun, next:
}
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)
{
if let Some(existing) = ctx.db.big_fish_asset_slot().slot_id().find(&slot.slot_id) {
ctx.db
.big_fish_asset_slot()
.slot_id()

View File

@@ -1,18 +1,16 @@
use module_puzzle::{
use module_puzzle::{
PUZZLE_MAX_TAG_COUNT, PuzzleAgentMessageKind, PuzzleAgentMessageRole,
PuzzleAgentMessageSnapshot, PuzzleAgentSessionCreateInput, PuzzleAgentSessionGetInput,
PuzzleAgentSessionProcedureResult, PuzzleAgentSessionSnapshot, PuzzleAgentStage,
PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleGeneratedImageCandidate,
PuzzleGeneratedImagesSaveInput, PuzzlePublicationStatus, PuzzlePublishInput,
PuzzleResultDraft, PuzzleRunDragInput, PuzzleRunGetInput, PuzzleRunNextLevelInput,
PuzzleRunProcedureResult, PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput,
PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput, PuzzleWorkGetInput,
PuzzleWorkProcedureResult, PuzzleWorkProfile, PuzzleWorkUpsertInput,
PuzzleWorksListInput, PuzzleWorksProcedureResult, apply_publish_overrides_to_draft,
apply_selected_candidate,
build_result_preview, compile_result_draft, create_work_profile, infer_anchor_pack,
normalize_theme_tags, publish_work_profile, resolve_puzzle_grid_size,
select_next_profile, start_run, swap_pieces,
PuzzleGeneratedImagesSaveInput, PuzzlePublicationStatus, PuzzlePublishInput, PuzzleResultDraft,
PuzzleRunDragInput, PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunProcedureResult,
PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus,
PuzzleSelectCoverImageInput, PuzzleWorkGetInput, PuzzleWorkProcedureResult, PuzzleWorkProfile,
PuzzleWorkUpsertInput, PuzzleWorksListInput, PuzzleWorksProcedureResult,
apply_publish_overrides_to_draft, apply_selected_candidate, build_result_preview,
compile_result_draft, create_work_profile, infer_anchor_pack, normalize_theme_tags,
publish_work_profile, resolve_puzzle_grid_size, select_next_profile, start_run, swap_pieces,
};
use serde_json::from_str as json_from_str;
use serde_json::to_string as json_to_string;
@@ -488,7 +486,10 @@ 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);
let assistant_message_id = format!(
"{}assistant-{}",
input.session_id, input.submitted_at_micros
);
ctx.db.puzzle_agent_message().insert(PuzzleAgentMessageRow {
message_id: assistant_message_id,
session_id: input.session_id.clone(),
@@ -547,7 +548,9 @@ fn compile_puzzle_agent_draft_tx(
stage: PuzzleAgentStage::DraftReady,
anchor_pack_json: serialize_json(&anchor_pack),
draft_json: Some(serialize_json(&draft)),
last_assistant_reply: Some("拼图结果页草稿已经生成,可以开始出图并确认标签。".to_string()),
last_assistant_reply: Some(
"拼图结果页草稿已经生成,可以开始出图并确认标签。".to_string(),
),
published_profile_id: row.published_profile_id.clone(),
created_at: row.created_at,
updated_at: compiled_at,
@@ -574,14 +577,19 @@ fn save_puzzle_generated_images_tx(
) -> Result<PuzzleAgentSessionSnapshot, String> {
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
let mut draft = deserialize_draft_required(&row.draft_json)?;
let candidates: Vec<PuzzleGeneratedImageCandidate> =
json_from_str(&input.candidates_json).map_err(|error| format!("拼图候选图 JSON 非法: {error}"))?;
let candidates: Vec<PuzzleGeneratedImageCandidate> = json_from_str(&input.candidates_json)
.map_err(|error| format!("拼图候选图 JSON 非法: {error}"))?;
if candidates.is_empty() {
return Err("拼图候选图不能为空".to_string());
}
draft.candidates = candidates;
draft.generation_status = "ready".to_string();
if let Some(selected) = draft.candidates.iter().find(|entry| entry.selected).cloned() {
if let Some(selected) = draft
.candidates
.iter()
.find(|entry| entry.selected)
.cloned()
{
draft.selected_candidate_id = Some(selected.candidate_id);
draft.cover_image_src = Some(selected.image_src);
draft.cover_asset_id = Some(selected.asset_id);
@@ -626,7 +634,8 @@ fn select_puzzle_cover_image_tx(
) -> Result<PuzzleAgentSessionSnapshot, String> {
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
let draft = deserialize_draft_required(&row.draft_json)?;
let draft = apply_selected_candidate(draft, &input.candidate_id).map_err(|error| error.to_string())?;
let draft =
apply_selected_candidate(draft, &input.candidate_id).map_err(|error| error.to_string())?;
let selected_at = Timestamp::from_micros_since_unix_epoch(input.selected_at_micros);
let next_stage = if build_result_preview(&draft, Some("创作者")).publish_ready {
PuzzleAgentStage::ReadyToPublish
@@ -814,7 +823,13 @@ fn start_puzzle_run_tx(
ctx: &TxContext,
input: PuzzleRunStartInput,
) -> Result<PuzzleRunSnapshot, String> {
if ctx.db.puzzle_runtime_run().run_id().find(&input.run_id).is_some() {
if ctx
.db
.puzzle_runtime_run()
.run_id()
.find(&input.run_id)
.is_some()
{
return Err("拼图 run 已存在".to_string());
}
let entry_profile_row = ctx
@@ -827,7 +842,8 @@ fn start_puzzle_run_tx(
return Err("入口拼图作品未发布".to_string());
}
let entry_profile = build_puzzle_work_profile_from_row(&entry_profile_row)?;
let mut run = start_run(input.run_id.clone(), &entry_profile, 0).map_err(|error| error.to_string())?;
let mut run =
start_run(input.run_id.clone(), &entry_profile, 0).map_err(|error| error.to_string())?;
run.recommended_next_profile_id = select_next_profile(
&entry_profile,
&run.played_profile_ids,
@@ -854,8 +870,8 @@ fn swap_puzzle_pieces_tx(
) -> Result<PuzzleRunSnapshot, String> {
let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?;
let current_run = deserialize_run(&row.snapshot_json)?;
let mut next_run =
swap_pieces(&current_run, &input.first_piece_id, &input.second_piece_id).map_err(|error| error.to_string())?;
let mut next_run = swap_pieces(&current_run, &input.first_piece_id, &input.second_piece_id)
.map_err(|error| error.to_string())?;
refresh_next_profile_recommendation(ctx, &mut next_run)?;
replace_puzzle_runtime_run(ctx, &row, &next_run, input.swapped_at_micros);
Ok(next_run)
@@ -900,17 +916,18 @@ fn advance_puzzle_next_level_tx(
.ok_or_else(|| "当前拼图作品不存在".to_string())?,
)?;
let candidates = list_published_puzzle_profiles(ctx)?;
let next_profile = select_next_profile(&current_profile, &current_run.played_profile_ids, &candidates)
.ok_or_else(|| "没有可用的下一关候选".to_string())?
.clone();
let mut next_run =
module_puzzle::advance_next_level(&current_run, &next_profile).map_err(|error| error.to_string())?;
next_run.recommended_next_profile_id = select_next_profile(
&next_profile,
&next_run.played_profile_ids,
let next_profile = select_next_profile(
&current_profile,
&current_run.played_profile_ids,
&candidates,
)
.map(|value| value.profile_id.clone());
.ok_or_else(|| "没有可用的下一关候选".to_string())?
.clone();
let mut next_run = module_puzzle::advance_next_level(&current_run, &next_profile)
.map_err(|error| error.to_string())?;
next_run.recommended_next_profile_id =
select_next_profile(&next_profile, &next_run.played_profile_ids, &candidates)
.map(|value| value.profile_id.clone());
if let Some(next_profile_row) = ctx
.db
@@ -954,7 +971,9 @@ fn build_puzzle_agent_session_snapshot(
})
}
fn build_puzzle_work_profile_from_row(row: &PuzzleWorkProfileRow) -> Result<PuzzleWorkProfile, String> {
fn build_puzzle_work_profile_from_row(
row: &PuzzleWorkProfileRow,
) -> Result<PuzzleWorkProfile, String> {
Ok(PuzzleWorkProfile {
work_id: row.work_id.clone(),
profile_id: row.profile_id.clone(),
@@ -968,7 +987,9 @@ fn build_puzzle_work_profile_from_row(row: &PuzzleWorkProfileRow) -> Result<Puzz
cover_asset_id: row.cover_asset_id.clone(),
publication_status: row.publication_status,
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
published_at_micros: row.published_at.map(|value| value.to_micros_since_unix_epoch()),
published_at_micros: row
.published_at
.map(|value| value.to_micros_since_unix_epoch()),
play_count: row.play_count,
publish_ready: row.publish_ready,
anchor_pack: deserialize_anchor_pack(&row.anchor_pack_json)?,
@@ -994,7 +1015,9 @@ fn list_session_messages(ctx: &TxContext, session_id: &str) -> Vec<PuzzleAgentMe
items
}
fn build_puzzle_suggested_actions(stage: PuzzleAgentStage) -> Vec<module_puzzle::PuzzleAgentSuggestedAction> {
fn build_puzzle_suggested_actions(
stage: PuzzleAgentStage,
) -> Vec<module_puzzle::PuzzleAgentSuggestedAction> {
match stage {
PuzzleAgentStage::CollectingAnchors => vec![module_puzzle::PuzzleAgentSuggestedAction {
id: "compile-draft".to_string(),
@@ -1051,14 +1074,26 @@ fn append_system_message(
}
fn ensure_session_missing(ctx: &TxContext, session_id: &str) -> Result<(), String> {
if ctx.db.puzzle_agent_session().session_id().find(&session_id.to_string()).is_some() {
if ctx
.db
.puzzle_agent_session()
.session_id()
.find(&session_id.to_string())
.is_some()
{
return Err("拼图 session 已存在".to_string());
}
Ok(())
}
fn ensure_message_missing(ctx: &TxContext, message_id: &str) -> Result<(), String> {
if ctx.db.puzzle_agent_message().message_id().find(&message_id.to_string()).is_some() {
if ctx
.db
.puzzle_agent_message()
.message_id()
.find(&message_id.to_string())
.is_some()
{
return Err("拼图消息已存在".to_string());
}
Ok(())
@@ -1122,10 +1157,7 @@ fn replace_puzzle_work_profile(
ctx.db.puzzle_work_profile().insert(next);
}
fn upsert_puzzle_work_profile(
ctx: &TxContext,
profile: PuzzleWorkProfile,
) -> Result<(), String> {
fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Result<(), String> {
if let Some(existing) = ctx
.db
.puzzle_work_profile()
@@ -1219,10 +1251,7 @@ fn replace_puzzle_runtime_run(
run: &PuzzleRunSnapshot,
updated_at_micros: i64,
) {
ctx.db
.puzzle_runtime_run()
.run_id()
.delete(&current.run_id);
ctx.db.puzzle_runtime_run().run_id().delete(&current.run_id);
ctx.db.puzzle_runtime_run().insert(PuzzleRuntimeRunRow {
run_id: run.run_id.clone(),
owner_user_id: current.owner_user_id.clone(),
@@ -1414,6 +1443,9 @@ mod tests {
author_display_name: "作者".to_string(),
summary: String::new(),
};
assert!(recommendation_score(&left, &right) > tag_similarity_score(&left.theme_tags, &right.theme_tags));
assert!(
recommendation_score(&left, &right)
> tag_similarity_score(&left.theme_tags, &right.theme_tags)
);
}
}