pub use module_ai::*; pub use module_assets::*; pub use module_big_fish::*; pub use module_combat::*; pub use module_custom_world::*; pub use module_inventory::*; pub use module_npc::*; pub use module_progression::*; pub use module_quest::*; pub use module_runtime::*; pub use module_runtime_item::*; pub use module_story::*; use module_combat::resolve_combat_action as resolve_battle_state_action; use module_inventory::apply_inventory_mutation as apply_inventory_slot_mutation; use module_npc::resolve_npc_interaction as resolve_npc_interaction_domain; use module_quest::{ acknowledge_quest_completion as acknowledge_quest_record_completion, apply_quest_signal as apply_quest_record_signal, }; pub(crate) use serde_json::{Map as JsonMap, Value as JsonValue, json}; pub(crate) use shared_kernel::format_timestamp_micros; pub use spacetimedb::{ Identity, ProcedureContext, ReducerContext, SpacetimeType, Table, Timestamp, }; use std::collections::HashSet; mod ai; mod asset_metadata; mod auth; mod big_fish; mod domain_types; mod entry; mod match3d; mod migration; mod puzzle; mod runtime; pub use ai::*; pub use asset_metadata::*; pub use auth::*; pub use big_fish::*; pub use domain_types::*; pub use entry::*; pub use match3d::*; pub use migration::*; pub use runtime::*; #[spacetimedb::table(accessor = player_progression)] pub struct PlayerProgression { #[primary_key] user_id: String, level: u32, current_level_xp: u32, total_xp: u32, xp_to_next_level: u32, pending_level_ups: u32, last_granted_source: Option, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = chapter_progression, index(accessor = by_chapter_progression_user_id, btree(columns = [user_id])), index(accessor = by_chapter_progression_chapter_id, btree(columns = [chapter_id])), index(accessor = by_chapter_progression_user_chapter, btree(columns = [user_id, chapter_id])) )] pub struct ChapterProgression { #[primary_key] chapter_progression_id: String, user_id: String, chapter_id: String, chapter_index: u32, total_chapters: u32, entry_pseudo_level_millis: u32, exit_pseudo_level_millis: u32, entry_level: u32, exit_level: u32, planned_total_xp: u32, planned_quest_xp: u32, planned_hostile_xp: u32, actual_quest_xp: u32, actual_hostile_xp: u32, expected_hostile_defeat_count: u32, actual_hostile_defeat_count: u32, level_at_entry: u32, level_at_exit: Option, pace_band: ChapterPaceBand, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = npc_state, index(accessor = by_runtime_session_id, btree(columns = [runtime_session_id])), index(accessor = by_npc_id, btree(columns = [npc_id])), index(accessor = by_runtime_session_npc, btree(columns = [runtime_session_id, npc_id])) )] pub struct NpcState { #[primary_key] npc_state_id: String, runtime_session_id: String, npc_id: String, npc_name: String, affinity: i32, relation_state: NpcRelationState, help_used: bool, chatted_count: u32, gifts_given: u32, recruited: bool, trade_stock_signature: Option, revealed_facts: Vec, known_attribute_rumors: Vec, first_meaningful_contact_resolved: bool, seen_backstory_chapter_ids: Vec, stance_profile: NpcStanceProfile, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = story_session, index(accessor = by_runtime_session_id, btree(columns = [runtime_session_id])), index(accessor = by_actor_user_id, btree(columns = [actor_user_id])) )] pub struct StorySession { #[primary_key] story_session_id: String, runtime_session_id: String, actor_user_id: String, world_profile_id: String, initial_prompt: String, opening_summary: Option, latest_narrative_text: String, latest_choice_function_id: Option, status: StorySessionStatus, version: u32, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = story_event, index(accessor = by_story_session_id, btree(columns = [story_session_id])) )] pub struct StoryEvent { #[primary_key] event_id: String, story_session_id: String, event_kind: StoryEventKind, narrative_text: String, choice_function_id: Option, created_at: Timestamp, } #[spacetimedb::table( accessor = inventory_slot, index(accessor = by_inventory_runtime_session_id, btree(columns = [runtime_session_id])), index(accessor = by_inventory_actor_user_id, btree(columns = [actor_user_id])), index(accessor = by_inventory_container_slot, btree(columns = [container_kind, slot_key])), index(accessor = by_inventory_item_id, btree(columns = [item_id])) )] pub struct InventorySlot { #[primary_key] slot_id: String, runtime_session_id: String, story_session_id: Option, actor_user_id: String, container_kind: InventoryContainerKind, slot_key: String, item_id: String, category: String, name: String, description: Option, quantity: u32, rarity: InventoryItemRarity, tags: Vec, stackable: bool, stack_key: String, equipment_slot_id: Option, source_kind: InventoryItemSourceKind, source_reference_id: Option, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = battle_state, index(accessor = by_battle_story_session_id, btree(columns = [story_session_id])), index(accessor = by_battle_runtime_session_id, btree(columns = [runtime_session_id])), index(accessor = by_battle_actor_user_id, btree(columns = [actor_user_id])) )] pub struct BattleState { #[primary_key] battle_state_id: String, story_session_id: String, runtime_session_id: String, actor_user_id: String, chapter_id: Option, target_npc_id: String, target_name: String, battle_mode: BattleMode, status: BattleStatus, player_hp: i32, player_max_hp: i32, player_mana: i32, player_max_mana: i32, target_hp: i32, target_max_hp: i32, experience_reward: u32, reward_items: Vec, turn_index: u32, last_action_function_id: Option, last_action_text: Option, last_result_text: Option, last_damage_dealt: i32, last_damage_taken: i32, last_outcome: CombatOutcome, version: u32, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = treasure_record, index(accessor = by_treasure_story_session_id, btree(columns = [story_session_id])), index(accessor = by_treasure_runtime_session_id, btree(columns = [runtime_session_id])), index(accessor = by_treasure_actor_user_id, btree(columns = [actor_user_id])), index(accessor = by_treasure_encounter_id, btree(columns = [encounter_id])) )] pub struct TreasureRecord { #[primary_key] treasure_record_id: String, runtime_session_id: String, story_session_id: String, actor_user_id: String, encounter_id: String, encounter_name: String, scene_id: Option, scene_name: Option, action: TreasureInteractionAction, reward_items: Vec, reward_hp: u32, reward_mana: u32, reward_currency: u32, story_hint: Option, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = quest_record, index(accessor = by_runtime_session_id, btree(columns = [runtime_session_id])), index(accessor = by_actor_user_id, btree(columns = [actor_user_id])), index(accessor = by_issuer_npc_id, btree(columns = [issuer_npc_id])) )] pub struct QuestRecord { #[primary_key] quest_id: String, runtime_session_id: String, story_session_id: Option, actor_user_id: String, issuer_npc_id: String, issuer_npc_name: String, scene_id: Option, chapter_id: Option, act_id: Option, thread_id: Option, contract_id: Option, title: String, description: String, summary: String, objective: QuestObjectiveSnapshot, progress: u32, status: QuestStatus, completion_notified: bool, reward: QuestRewardSnapshot, reward_text: String, narrative_binding: QuestNarrativeBindingSnapshot, steps: Vec, active_step_id: Option, visible_stage: u32, hidden_flags: Vec, discovered_fact_ids: Vec, related_carrier_ids: Vec, consequence_ids: Vec, created_at: Timestamp, updated_at: Timestamp, completed_at: Option, turned_in_at: Option, } #[spacetimedb::table( accessor = quest_log, index(accessor = by_quest_id, btree(columns = [quest_id])), index(accessor = by_runtime_session_id, btree(columns = [runtime_session_id])), index(accessor = by_actor_user_id, btree(columns = [actor_user_id])) )] pub struct QuestLog { #[primary_key] log_id: String, quest_id: String, runtime_session_id: String, actor_user_id: String, event_kind: QuestLogEventKind, status_after: QuestStatus, signal_kind: Option, signal: Option, step_id: Option, step_progress: Option, created_at: Timestamp, } #[spacetimedb::table( accessor = custom_world_profile, index(accessor = by_custom_world_profile_owner_user_id, btree(columns = [owner_user_id])), index( accessor = by_custom_world_profile_publication_status, btree(columns = [publication_status]) ) )] pub struct CustomWorldProfile { #[primary_key] profile_id: String, // 当前 profile 承接 library / publish / enter-world 的正式世界工件真相。 owner_user_id: String, // 作品公开编号是稳定分享键,第一次发布时分配,后续重复发布沿用。 public_work_code: Option, // 作者公开陶泥号在发布时固化到作品真相,供广场读模型与搜索结果直接展示。 author_public_user_code: Option, source_agent_session_id: Option, publication_status: CustomWorldPublicationStatus, world_name: String, subtitle: String, summary_text: String, theme_mode: CustomWorldThemeMode, cover_image_src: Option, profile_payload_json: String, playable_npc_count: u32, landmark_count: u32, author_display_name: String, published_at: Option, // 软删除后保留 profile 真相,供审计与幂等删除使用。 deleted_at: Option, created_at: Timestamp, updated_at: Timestamp, // 公开消费计数随 profile 真相持久化,发布、编辑和取消发布都不能重置。 #[default(0)] play_count: u32, #[default(0)] remix_count: u32, #[default(0)] like_count: u32, } #[spacetimedb::table( accessor = custom_world_session, index(accessor = by_custom_world_session_owner_user_id, btree(columns = [owner_user_id])) )] pub struct CustomWorldSession { #[primary_key] session_id: String, // 这张表只承接旧 custom-world/sessions 传统问答流,不和 agent 会话混存。 owner_user_id: String, generation_mode: CustomWorldGenerationMode, status: CustomWorldSessionStatus, setting_text: String, creator_intent_json: Option, question_snapshot_json: String, result_payload_json: Option, last_error_message: Option, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = custom_world_agent_session, index( accessor = by_custom_world_agent_session_owner_user_id, btree(columns = [owner_user_id]) ), index(accessor = by_custom_world_agent_session_stage, btree(columns = [stage])) )] pub struct CustomWorldAgentSession { #[primary_key] session_id: String, // Agent 会话只保留会话级聚合字段,消息、操作、卡片都拆到独立表。 owner_user_id: String, seed_text: String, current_turn: u32, progress_percent: u32, stage: RpgAgentStage, focus_card_id: Option, anchor_content_json: String, creator_intent_json: Option, creator_intent_readiness_json: String, anchor_pack_json: Option, lock_state_json: Option, draft_profile_json: Option, last_assistant_reply: Option, publish_gate_json: Option, result_preview_json: Option, pending_clarifications_json: String, quality_findings_json: String, suggested_actions_json: String, recommended_replies_json: String, asset_coverage_json: String, checkpoints_json: String, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = custom_world_agent_message, index(accessor = by_custom_world_agent_message_session_id, btree(columns = [session_id])) )] pub struct CustomWorldAgentMessage { #[primary_key] message_id: String, // 消息流水单独成表,避免继续塞回 session 大 JSON。 session_id: String, role: RpgAgentMessageRole, kind: RpgAgentMessageKind, text: String, related_operation_id: Option, created_at: Timestamp, } #[derive(Clone)] #[spacetimedb::table( accessor = custom_world_agent_operation, index(accessor = by_custom_world_agent_operation_session_id, btree(columns = [session_id])) )] pub struct CustomWorldAgentOperation { #[primary_key] operation_id: String, // 异步操作单独建表,为 message stream / operation query 提供真相源。 session_id: String, operation_type: RpgAgentOperationType, status: RpgAgentOperationStatus, phase_label: String, phase_detail: String, progress: u32, error_message: Option, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = custom_world_draft_card, index(accessor = by_custom_world_draft_card_session_id, btree(columns = [session_id])), index(accessor = by_custom_world_draft_card_kind, btree(columns = [kind])) )] pub struct CustomWorldDraftCard { #[primary_key] card_id: String, // 卡片实体从 agent session 拆出,后续 detail / update 都直接对这张表操作。 session_id: String, kind: RpgAgentDraftCardKind, status: RpgAgentDraftCardStatus, title: String, subtitle: String, summary: String, linked_ids_json: String, warning_count: u32, asset_status: Option, asset_status_label: Option, detail_payload_json: Option, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = custom_world_gallery_entry, public, index(accessor = by_custom_world_gallery_owner_user_id, btree(columns = [owner_user_id])), index(accessor = by_custom_world_gallery_theme_mode, btree(columns = [theme_mode])), index(accessor = by_custom_world_gallery_public_work_code, btree(columns = [public_work_code])) )] pub struct CustomWorldGalleryEntry { #[primary_key] profile_id: String, // 画廊是公开订阅读模型,不再运行时从 profile 即席拼装。 owner_user_id: String, public_work_code: String, author_public_user_code: String, author_display_name: String, world_name: String, subtitle: String, summary_text: String, cover_image_src: Option, theme_mode: CustomWorldThemeMode, playable_npc_count: u32, landmark_count: u32, published_at: Timestamp, updated_at: Timestamp, // 画廊读模型直接同步互动计数,避免前端临时把评分或游玩数改名成点赞。 #[default(0)] play_count: u32, #[default(0)] remix_count: u32, #[default(0)] like_count: u32, } // 成长状态默认按 user_id 单行持久化;若尚未存在记录则返回 Lv.1 / 0 XP 的兼容初始值。 #[spacetimedb::procedure] pub fn get_player_progression_or_default( ctx: &mut ProcedureContext, input: PlayerProgressionGetInput, ) -> PlayerProgressionProcedureResult { match ctx.try_with_tx(|tx| get_player_progression_snapshot_tx(tx, input.clone())) { Ok(record) => PlayerProgressionProcedureResult { ok: true, record: Some(record), error_message: None, }, Err(message) => PlayerProgressionProcedureResult { ok: false, record: None, error_message: Some(message), }, } } // 经验发放统一走 progression reducer,避免任务和战斗各自直接写等级字段。 #[spacetimedb::reducer] pub fn grant_player_progression_experience( ctx: &ReducerContext, input: PlayerProgressionGrantInput, ) -> Result<(), String> { upsert_player_progression_after_grant_tx(ctx, input).map(|_| ()) } #[spacetimedb::procedure] pub fn grant_player_progression_experience_and_return( ctx: &mut ProcedureContext, input: PlayerProgressionGrantInput, ) -> PlayerProgressionProcedureResult { match ctx.try_with_tx(|tx| upsert_player_progression_after_grant_tx(tx, input.clone())) { Ok(record) => PlayerProgressionProcedureResult { ok: true, record: Some(record), error_message: None, }, Err(message) => PlayerProgressionProcedureResult { ok: false, record: None, error_message: Some(message), }, } } // 章节计划在进入章节或编译章节预算时写入;当前先用单表同时承接计划值与实际记账值。 #[spacetimedb::reducer] pub fn upsert_chapter_progression( ctx: &ReducerContext, input: ChapterProgressionInput, ) -> Result<(), String> { upsert_chapter_progression_snapshot_tx(ctx, input).map(|_| ()) } #[spacetimedb::procedure] pub fn upsert_chapter_progression_and_return( ctx: &mut ProcedureContext, input: ChapterProgressionInput, ) -> ChapterProgressionProcedureResult { match ctx.try_with_tx(|tx| upsert_chapter_progression_snapshot_tx(tx, input.clone())) { Ok(record) => ChapterProgressionProcedureResult { ok: true, record: Some(record), error_message: None, }, Err(message) => ChapterProgressionProcedureResult { ok: false, record: None, error_message: Some(message), }, } } // 章节实际经验与击杀记账后续由 quest/combat 联动调用,这一轮先把真相写入口固定下来。 #[spacetimedb::reducer] pub fn apply_chapter_progression_ledger_entry( ctx: &ReducerContext, input: ChapterProgressionLedgerInput, ) -> Result<(), String> { update_chapter_progression_ledger_tx(ctx, input).map(|_| ()) } #[spacetimedb::procedure] pub fn apply_chapter_progression_ledger_entry_and_return( ctx: &mut ProcedureContext, input: ChapterProgressionLedgerInput, ) -> ChapterProgressionProcedureResult { match ctx.try_with_tx(|tx| update_chapter_progression_ledger_tx(tx, input.clone())) { Ok(record) => ChapterProgressionProcedureResult { ok: true, record: Some(record), error_message: None, }, Err(message) => ChapterProgressionProcedureResult { ok: false, record: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn get_chapter_progression( ctx: &mut ProcedureContext, input: ChapterProgressionGetInput, ) -> ChapterProgressionProcedureResult { match ctx.try_with_tx(|tx| get_chapter_progression_snapshot_tx(tx, input.clone())) { Ok(record) => ChapterProgressionProcedureResult { ok: true, record: Some(record), error_message: None, }, Err(message) => ChapterProgressionProcedureResult { ok: false, record: None, error_message: Some(message), }, } } // 当前阶段先把 inventory_slot 立成显式背包真相表,避免继续由多个 service 各自改 runtime snapshot JSON。 #[spacetimedb::reducer] pub fn apply_inventory_mutation( ctx: &ReducerContext, input: InventoryMutationInput, ) -> Result<(), String> { apply_inventory_mutation_tx(ctx, input) } fn apply_inventory_mutation_tx( ctx: &ReducerContext, input: InventoryMutationInput, ) -> Result<(), String> { let current_slots = ctx .db .inventory_slot() .iter() .filter(|slot| { slot.runtime_session_id == input.runtime_session_id && slot.actor_user_id == input.actor_user_id }) .map(|row| build_inventory_slot_snapshot_from_row(&row)) .collect::>(); let outcome = apply_inventory_slot_mutation(current_slots, input).map_err(|error| error.to_string())?; for removed_slot_id in outcome.removed_slot_ids { ctx.db.inventory_slot().slot_id().delete(&removed_slot_id); } for slot in outcome.next_slots { ctx.db.inventory_slot().slot_id().delete(&slot.slot_id); ctx.db .inventory_slot() .insert(build_inventory_slot_row(slot)); } Ok(()) } // procedure 面向 Axum 同步读取当前 runtime_session 下某个玩家的背包真相态。 #[spacetimedb::procedure] pub fn get_runtime_inventory_state( ctx: &mut ProcedureContext, input: RuntimeInventoryStateQueryInput, ) -> RuntimeInventoryStateProcedureResult { match ctx.try_with_tx(|tx| get_runtime_inventory_state_tx(tx, input.clone())) { Ok(snapshot) => RuntimeInventoryStateProcedureResult { ok: true, snapshot: Some(snapshot), error_message: None, }, Err(message) => RuntimeInventoryStateProcedureResult { ok: false, snapshot: None, error_message: Some(message), }, } } // M4 首轮先把 battle_state 作为战斗真相源落到 SpacetimeDB,避免继续把战斗状态埋在 runtime JSON 里。 #[spacetimedb::reducer] pub fn create_battle_state(ctx: &ReducerContext, input: BattleStateInput) -> Result<(), String> { create_battle_state_record(ctx, input).map(|_| ()) } // procedure 面向 Axum 同步创建 battle_state,返回当前最新战斗快照,避免 HTTP 层再次读取 private table。 #[spacetimedb::procedure] pub fn create_battle_state_and_return( ctx: &mut ProcedureContext, input: BattleStateInput, ) -> BattleStateProcedureResult { match ctx.try_with_tx(|tx| create_battle_state_record(tx, input.clone())) { Ok(snapshot) => BattleStateProcedureResult { ok: true, snapshot: Some(snapshot), error_message: None, }, Err(message) => BattleStateProcedureResult { ok: false, snapshot: None, error_message: Some(message), }, } } // procedure 面向 Axum 读取单个 battle_state 真相态,当前只返回最新战斗快照。 #[spacetimedb::procedure] pub fn get_battle_state( ctx: &mut ProcedureContext, input: BattleStateQueryInput, ) -> BattleStateProcedureResult { match ctx.try_with_tx(|tx| get_battle_state_record(tx, input.clone())) { Ok(snapshot) => BattleStateProcedureResult { ok: true, snapshot: Some(snapshot), error_message: None, }, Err(message) => BattleStateProcedureResult { ok: false, snapshot: None, error_message: Some(message), }, } } // M4 首轮只承接单行为战斗推进,不提前把 inventory / progression / story AI 续写耦进 reducer。 #[spacetimedb::reducer] pub fn resolve_combat_action( ctx: &ReducerContext, input: ResolveCombatActionInput, ) -> Result<(), String> { resolve_battle_state_record(ctx, input).map(|_| ()) } // procedure 面向 Axum 同步推进单次战斗动作,返回本次结算结果与 battle_state 最新快照。 #[spacetimedb::procedure] pub fn resolve_combat_action_and_return( ctx: &mut ProcedureContext, input: ResolveCombatActionInput, ) -> ResolveCombatActionProcedureResult { match ctx.try_with_tx(|tx| resolve_battle_state_record(tx, input.clone())) { Ok(result) => ResolveCombatActionProcedureResult { ok: true, result: Some(result), error_message: None, }, Err(message) => ResolveCombatActionProcedureResult { ok: false, result: None, error_message: Some(message), }, } } fn create_battle_state_record( ctx: &ReducerContext, input: BattleStateInput, ) -> Result { validate_battle_state_input(&input).map_err(|error| error.to_string())?; if ctx .db .battle_state() .battle_state_id() .find(&input.battle_state_id) .is_some() { return Err("battle_state.battle_state_id 已存在".to_string()); } let snapshot = build_battle_state_snapshot(input); ctx.db .battle_state() .insert(build_battle_state_row(snapshot.clone())); Ok(snapshot) } fn get_battle_state_record( ctx: &ReducerContext, input: BattleStateQueryInput, ) -> Result { validate_battle_state_query_input(&input).map_err(|error| error.to_string())?; let row = ctx .db .battle_state() .battle_state_id() .find(&input.battle_state_id) .ok_or_else(|| "battle_state 不存在".to_string())?; Ok(build_battle_state_snapshot_from_row(&row)) } fn get_runtime_inventory_state_tx( ctx: &ReducerContext, input: RuntimeInventoryStateQueryInput, ) -> Result { let validated_input = build_runtime_inventory_state_query_input(input.runtime_session_id, input.actor_user_id) .map_err(|error| error.to_string())?; // 这层只返回 inventory_slot 真相表的最小切片,不混入 story、quest、battle 的额外投影。 let slots = ctx .db .inventory_slot() .iter() .filter(|row| { row.runtime_session_id == validated_input.runtime_session_id && row.actor_user_id == validated_input.actor_user_id }) .map(|row| build_inventory_slot_snapshot_from_row(&row)) .collect::>(); Ok(build_runtime_inventory_state_snapshot( validated_input, slots, )) } fn resolve_battle_state_record( ctx: &ReducerContext, input: ResolveCombatActionInput, ) -> Result { let current = ctx .db .battle_state() .battle_state_id() .find(&input.battle_state_id) .ok_or_else(|| "battle_state 不存在,无法执行战斗动作".to_string())?; let result = resolve_battle_state_action(build_battle_state_snapshot_from_row(¤t), input) .map_err(|error| error.to_string())?; ctx.db .battle_state() .battle_state_id() .delete(¤t.battle_state_id); ctx.db .battle_state() .insert(build_battle_state_row(result.snapshot.clone())); if result.outcome == CombatOutcome::Victory { grant_battle_reward_items(ctx, &result.snapshot)?; if result.snapshot.experience_reward > 0 { let updated_player = upsert_player_progression_after_grant_tx( ctx, PlayerProgressionGrantInput { user_id: result.snapshot.actor_user_id.clone(), amount: result.snapshot.experience_reward, source: PlayerProgressionGrantSource::HostileNpc, updated_at_micros: result.snapshot.updated_at_micros, }, )?; // 章节计划可能尚未初始化;此时不能阻断战斗胜利结算,只跳过章节账本写入。 try_update_chapter_progression_ledger_tx( ctx, result.snapshot.actor_user_id.clone(), result.snapshot.chapter_id.clone(), ChapterProgressionLedgerInput { user_id: result.snapshot.actor_user_id.clone(), chapter_id: result.snapshot.chapter_id.clone().unwrap_or_default(), granted_quest_xp: 0, granted_hostile_xp: result.snapshot.experience_reward, hostile_defeat_increment: 1, level_at_exit: Some(updated_player.level), updated_at_micros: result.snapshot.updated_at_micros, }, )?; } } Ok(result) } // 当前阶段先把 npc_state 立成显式真相表,避免继续把关系状态藏在运行时 JSON 快照里。 #[spacetimedb::reducer] pub fn upsert_npc_state(ctx: &ReducerContext, input: NpcStateUpsertInput) -> Result<(), String> { upsert_npc_state_record(ctx, input).map(|_| ()) } // procedure 面向 Axum 同步 upsert 接口,返回最新 NPC 状态快照。 #[spacetimedb::procedure] pub fn upsert_npc_state_and_return( ctx: &mut ProcedureContext, input: NpcStateUpsertInput, ) -> NpcStateProcedureResult { match ctx.try_with_tx(|tx| upsert_npc_state_record(tx, input.clone())) { Ok(record) => NpcStateProcedureResult { ok: true, record: Some(record), error_message: None, }, Err(message) => NpcStateProcedureResult { ok: false, record: None, error_message: Some(message), }, } } // 当前阶段只承接 NPC 关系状态的最小社交动作,不提前把背包、战斗和队伍副作用也塞进来。 #[spacetimedb::reducer] pub fn resolve_npc_social_action( ctx: &ReducerContext, input: ResolveNpcSocialActionInput, ) -> Result<(), String> { resolve_npc_social_action_record(ctx, input).map(|_| ()) } // procedure 面向 Axum 同步社交动作接口,返回动作后的 NPC 状态快照。 #[spacetimedb::procedure] pub fn resolve_npc_social_action_and_return( ctx: &mut ProcedureContext, input: ResolveNpcSocialActionInput, ) -> NpcStateProcedureResult { match ctx.try_with_tx(|tx| resolve_npc_social_action_record(tx, input.clone())) { Ok(record) => NpcStateProcedureResult { ok: true, record: Some(record), error_message: None, }, Err(message) => NpcStateProcedureResult { ok: false, record: None, error_message: Some(message), }, } } // 当前阶段先冻结 NPC 正式交互统一入口,不直接在这里扩出队伍、战斗、背包等跨子域副作用。 #[spacetimedb::reducer] pub fn resolve_npc_interaction( ctx: &ReducerContext, input: ResolveNpcInteractionInput, ) -> Result<(), String> { resolve_npc_interaction_record(ctx, input).map(|_| ()) } #[spacetimedb::procedure] pub fn resolve_npc_interaction_and_return( ctx: &mut ProcedureContext, input: ResolveNpcInteractionInput, ) -> NpcInteractionProcedureResult { match ctx.try_with_tx(|tx| resolve_npc_interaction_record(tx, input.clone())) { Ok(result) => NpcInteractionProcedureResult { ok: true, result: Some(result), error_message: None, }, Err(message) => NpcInteractionProcedureResult { ok: false, result: None, error_message: Some(message), }, } } // fight / spar 的 battle_state 初始化属于聚合层编排,不回灌到 module-npc 纯领域 crate。 #[spacetimedb::procedure] pub fn resolve_npc_battle_interaction_and_return( ctx: &mut ProcedureContext, input: ResolveNpcBattleInteractionInput, ) -> NpcBattleInteractionProcedureResult { match ctx.try_with_tx(|tx| resolve_npc_battle_interaction_tx(tx, input.clone())) { Ok(result) => NpcBattleInteractionProcedureResult { ok: true, result: Some(result), error_message: None, }, Err(message) => NpcBattleInteractionProcedureResult { ok: false, result: None, error_message: Some(message), }, } } // M4 首轮先把 story_session / story_event 作为显式会话真相源落到 SpacetimeDB,避免后续继续依赖大 JSON 覆盖式写法。 #[spacetimedb::reducer] pub fn begin_story_session(ctx: &ReducerContext, input: StorySessionInput) -> Result<(), String> { begin_story_session_tx(ctx, input).map(|_| ()) } // procedure 面向 Axum 同步创建故事会话,返回最新会话快照与开场事件,避免 HTTP 层再读 private table。 #[spacetimedb::procedure] pub fn begin_story_session_and_return( ctx: &mut ProcedureContext, input: StorySessionInput, ) -> StorySessionProcedureResult { match ctx.try_with_tx(|tx| begin_story_session_tx(tx, input.clone())) { Ok((session, event)) => StorySessionProcedureResult { ok: true, session: Some(session), event: Some(event), error_message: None, }, Err(message) => StorySessionProcedureResult { ok: false, session: None, event: None, error_message: Some(message), }, } } fn begin_story_session_tx( ctx: &ReducerContext, input: StorySessionInput, ) -> Result<(StorySessionSnapshot, StoryEventSnapshot), String> { validate_story_session_input(&input).map_err(|error| error.to_string())?; if ctx .db .story_session() .story_session_id() .find(&input.story_session_id) .is_some() { return Err("story_session.story_session_id 已存在".to_string()); } let snapshot = build_story_session_snapshot(input); let started_event = build_story_started_event(&snapshot); let created_at = Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros); let updated_at = Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros); ctx.db.story_session().insert(StorySession { story_session_id: snapshot.story_session_id.clone(), runtime_session_id: snapshot.runtime_session_id.clone(), actor_user_id: snapshot.actor_user_id.clone(), world_profile_id: snapshot.world_profile_id.clone(), initial_prompt: snapshot.initial_prompt.clone(), opening_summary: snapshot.opening_summary.clone(), latest_narrative_text: snapshot.latest_narrative_text.clone(), latest_choice_function_id: snapshot.latest_choice_function_id.clone(), status: snapshot.status, version: snapshot.version, created_at, updated_at, }); ctx.db.story_event().insert(StoryEvent { event_id: started_event.event_id.clone(), story_session_id: started_event.story_session_id.clone(), event_kind: started_event.event_kind, narrative_text: started_event.narrative_text.clone(), choice_function_id: started_event.choice_function_id.clone(), created_at, }); Ok((snapshot, started_event)) } // M4 首轮继续把“故事推进”固定为事件追加 + 会话版本递增,为后续 resolve_story_action 接线提供最小基座。 #[spacetimedb::reducer] pub fn continue_story(ctx: &ReducerContext, input: StoryContinueInput) -> Result<(), String> { continue_story_tx(ctx, input).map(|_| ()) } // procedure 面向 Axum 同步推进故事会话,返回最新会话快照与本次事件,避免 HTTP 层再读 private table。 #[spacetimedb::procedure] pub fn continue_story_and_return( ctx: &mut ProcedureContext, input: StoryContinueInput, ) -> StorySessionProcedureResult { match ctx.try_with_tx(|tx| continue_story_tx(tx, input.clone())) { Ok((session, event)) => StorySessionProcedureResult { ok: true, session: Some(session), event: Some(event), error_message: None, }, Err(message) => StorySessionProcedureResult { ok: false, session: None, event: None, error_message: Some(message), }, } } // procedure 面向 Axum 读取指定 story session 的最小真实状态,当前只返回 session + event 列表。 #[spacetimedb::procedure] pub fn get_story_session_state( ctx: &mut ProcedureContext, input: StorySessionStateInput, ) -> StorySessionStateProcedureResult { match ctx.try_with_tx(|tx| get_story_session_state_tx(tx, input.clone())) { Ok((session, events)) => StorySessionStateProcedureResult { ok: true, session: Some(session), events, error_message: None, }, Err(message) => StorySessionStateProcedureResult { ok: false, session: None, events: Vec::new(), error_message: Some(message), }, } } // Stage 6 先把 Agent 会话骨架写入 SpacetimeDB,LLM 采集与卡片生成后续再接入。 #[spacetimedb::procedure] pub fn create_custom_world_agent_session( ctx: &mut ProcedureContext, input: CustomWorldAgentSessionCreateInput, ) -> CustomWorldAgentSessionProcedureResult { match ctx.try_with_tx(|tx| create_custom_world_agent_session_tx(tx, input.clone())) { Ok(session) => CustomWorldAgentSessionProcedureResult { ok: true, session: Some(session), error_message: None, }, Err(message) => CustomWorldAgentSessionProcedureResult { ok: false, session: None, error_message: Some(message), }, } } // Stage 6 读取拆表后的最小 Agent session snapshot,供 Axum 兼容旧前端 contract。 #[spacetimedb::procedure] pub fn get_custom_world_agent_session( ctx: &mut ProcedureContext, input: CustomWorldAgentSessionGetInput, ) -> CustomWorldAgentSessionProcedureResult { match ctx.try_with_tx(|tx| get_custom_world_agent_session_tx(tx, input.clone())) { Ok(session) => CustomWorldAgentSessionProcedureResult { ok: true, session: Some(session), error_message: None, }, Err(message) => CustomWorldAgentSessionProcedureResult { ok: false, session: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn delete_custom_world_agent_session( ctx: &mut ProcedureContext, input: CustomWorldAgentSessionGetInput, ) -> CustomWorldWorksListResult { match ctx.try_with_tx(|tx| delete_custom_world_agent_session_tx(tx, input.clone())) { Ok(items) => CustomWorldWorksListResult { ok: true, items, error_message: None, }, Err(message) => CustomWorldWorksListResult { ok: false, items: Vec::new(), error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn submit_custom_world_agent_message( ctx: &mut ProcedureContext, input: CustomWorldAgentMessageSubmitInput, ) -> CustomWorldAgentOperationProcedureResult { match ctx.try_with_tx(|tx| submit_custom_world_agent_message_tx(tx, input.clone())) { Ok(operation) => CustomWorldAgentOperationProcedureResult { ok: true, operation: Some(operation), error_message: None, }, Err(message) => CustomWorldAgentOperationProcedureResult { ok: false, operation: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn finalize_custom_world_agent_message_turn( ctx: &mut ProcedureContext, input: CustomWorldAgentMessageFinalizeInput, ) -> CustomWorldAgentOperationProcedureResult { match ctx.try_with_tx(|tx| finalize_custom_world_agent_message_turn_tx(tx, input.clone())) { Ok(operation) => CustomWorldAgentOperationProcedureResult { ok: true, operation: Some(operation), error_message: None, }, Err(message) => CustomWorldAgentOperationProcedureResult { ok: false, operation: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn get_custom_world_agent_operation( ctx: &mut ProcedureContext, input: CustomWorldAgentOperationGetInput, ) -> CustomWorldAgentOperationProcedureResult { match ctx.try_with_tx(|tx| get_custom_world_agent_operation_tx(tx, input.clone())) { Ok(operation) => CustomWorldAgentOperationProcedureResult { ok: true, operation: Some(operation), error_message: None, }, Err(message) => CustomWorldAgentOperationProcedureResult { ok: false, operation: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn upsert_custom_world_agent_operation_progress( ctx: &mut ProcedureContext, input: CustomWorldAgentOperationProgressInput, ) -> CustomWorldAgentOperationProcedureResult { match ctx.try_with_tx(|tx| upsert_custom_world_agent_operation_progress_tx(tx, input.clone())) { Ok(operation) => CustomWorldAgentOperationProcedureResult { ok: true, operation: Some(operation), error_message: None, }, Err(message) => CustomWorldAgentOperationProcedureResult { ok: false, operation: None, error_message: Some(message), }, } } fn continue_story_tx( ctx: &ReducerContext, input: StoryContinueInput, ) -> Result<(StorySessionSnapshot, StoryEventSnapshot), String> { validate_story_continue_input(&input).map_err(|error| error.to_string())?; let current = ctx .db .story_session() .story_session_id() .find(&input.story_session_id) .ok_or_else(|| "story_session 不存在,无法继续推进".to_string())?; let current_snapshot = StorySessionSnapshot { story_session_id: current.story_session_id.clone(), runtime_session_id: current.runtime_session_id.clone(), actor_user_id: current.actor_user_id.clone(), world_profile_id: current.world_profile_id.clone(), initial_prompt: current.initial_prompt.clone(), opening_summary: current.opening_summary.clone(), latest_narrative_text: current.latest_narrative_text.clone(), latest_choice_function_id: current.latest_choice_function_id.clone(), status: current.status, version: current.version, created_at_micros: current.created_at.to_micros_since_unix_epoch(), updated_at_micros: current.updated_at.to_micros_since_unix_epoch(), }; let (next_snapshot, event_snapshot) = apply_story_continue(current_snapshot, input).map_err(|error| error.to_string())?; ctx.db .story_session() .story_session_id() .delete(¤t.story_session_id); ctx.db.story_session().insert(StorySession { story_session_id: next_snapshot.story_session_id.clone(), runtime_session_id: next_snapshot.runtime_session_id.clone(), actor_user_id: next_snapshot.actor_user_id.clone(), world_profile_id: next_snapshot.world_profile_id.clone(), initial_prompt: next_snapshot.initial_prompt.clone(), opening_summary: next_snapshot.opening_summary.clone(), latest_narrative_text: next_snapshot.latest_narrative_text.clone(), latest_choice_function_id: next_snapshot.latest_choice_function_id.clone(), status: next_snapshot.status, version: next_snapshot.version, created_at: Timestamp::from_micros_since_unix_epoch(next_snapshot.created_at_micros), updated_at: Timestamp::from_micros_since_unix_epoch(next_snapshot.updated_at_micros), }); ctx.db.story_event().insert(StoryEvent { event_id: event_snapshot.event_id.clone(), story_session_id: event_snapshot.story_session_id.clone(), event_kind: event_snapshot.event_kind, narrative_text: event_snapshot.narrative_text.clone(), choice_function_id: event_snapshot.choice_function_id.clone(), created_at: Timestamp::from_micros_since_unix_epoch(event_snapshot.created_at_micros), }); Ok((next_snapshot, event_snapshot)) } fn get_story_session_state_tx( ctx: &ReducerContext, input: StorySessionStateInput, ) -> Result<(StorySessionSnapshot, Vec), String> { validate_story_session_state_input(&input).map_err(|error| error.to_string())?; let session = ctx .db .story_session() .story_session_id() .find(&input.story_session_id) .ok_or_else(|| "story_session 不存在".to_string())?; let session_snapshot = build_story_session_snapshot_from_row(&session); let mut events = ctx .db .story_event() .iter() .filter(|row| row.story_session_id == input.story_session_id) .map(|row| build_story_event_snapshot_from_row(&row)) .collect::>(); events.sort_by_key(|event| (event.created_at_micros, event.event_id.clone())); Ok((session_snapshot, events)) } fn create_custom_world_agent_session_tx( ctx: &ReducerContext, input: CustomWorldAgentSessionCreateInput, ) -> Result { validate_custom_world_agent_session_create_input(&input).map_err(|error| error.to_string())?; if ctx .db .custom_world_agent_session() .session_id() .find(&input.session_id) .is_some() { return Err("custom_world_agent_session.session_id 已存在".to_string()); } if ctx .db .custom_world_agent_message() .message_id() .find(&input.welcome_message_id) .is_some() { return Err("custom_world_agent_message.message_id 已存在".to_string()); } let created_at = Timestamp::from_micros_since_unix_epoch(input.created_at_micros); ctx.db .custom_world_agent_session() .insert(CustomWorldAgentSession { session_id: input.session_id.clone(), owner_user_id: input.owner_user_id.clone(), seed_text: input.seed_text.trim().to_string(), current_turn: 0, progress_percent: 0, stage: RpgAgentStage::CollectingIntent, focus_card_id: None, anchor_content_json: input.anchor_content_json.clone(), creator_intent_json: input.creator_intent_json.clone(), creator_intent_readiness_json: input.creator_intent_readiness_json.clone(), anchor_pack_json: input.anchor_pack_json.clone(), lock_state_json: input.lock_state_json.clone(), draft_profile_json: input.draft_profile_json.clone(), last_assistant_reply: Some(input.welcome_message_text.trim().to_string()), publish_gate_json: None, result_preview_json: None, pending_clarifications_json: input.pending_clarifications_json.clone(), quality_findings_json: input.quality_findings_json.clone(), suggested_actions_json: input.suggested_actions_json.clone(), recommended_replies_json: input.recommended_replies_json.clone(), asset_coverage_json: input.asset_coverage_json.clone(), checkpoints_json: input.checkpoints_json.clone(), created_at, updated_at: created_at, }); ctx.db .custom_world_agent_message() .insert(CustomWorldAgentMessage { message_id: input.welcome_message_id, session_id: input.session_id.clone(), role: RpgAgentMessageRole::Assistant, kind: RpgAgentMessageKind::Chat, text: input.welcome_message_text.trim().to_string(), related_operation_id: None, created_at, }); get_custom_world_agent_session_tx( ctx, CustomWorldAgentSessionGetInput { session_id: input.session_id, owner_user_id: input.owner_user_id, }, ) } fn get_custom_world_agent_session_tx( ctx: &ReducerContext, input: CustomWorldAgentSessionGetInput, ) -> Result { validate_custom_world_agent_session_get_input(&input).map_err(|error| error.to_string())?; let session = ctx .db .custom_world_agent_session() .session_id() .find(&input.session_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "custom_world_agent_session 不存在".to_string())?; Ok(build_custom_world_agent_session_snapshot(ctx, &session)) } fn delete_custom_world_agent_session_tx( ctx: &ReducerContext, input: CustomWorldAgentSessionGetInput, ) -> Result, String> { validate_custom_world_agent_session_get_input(&input).map_err(|error| error.to_string())?; let session = ctx .db .custom_world_agent_session() .session_id() .find(&input.session_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "custom_world_agent_session 不存在".to_string())?; if session.stage == RpgAgentStage::Published { let published_profile = ctx .db .custom_world_profile() .iter() .find(|row| { row.owner_user_id == input.owner_user_id && row.source_agent_session_id.as_deref() == Some(input.session_id.as_str()) && row.deleted_at.is_none() }) .ok_or_else(|| "已发布 RPG 作品缺少关联 profile,无法删除".to_string())?; // 作品卡可能只携带源 Agent sessionId。这里把“按 session 删除已发布作品” // 收敛为 profile 软删除,避免前端误入草稿删除接口时继续暴露 procedure 分叉。 delete_custom_world_profile_record( ctx, CustomWorldProfileDeleteInput { profile_id: published_profile.profile_id, owner_user_id: input.owner_user_id.clone(), deleted_at_micros: ctx.timestamp.to_micros_since_unix_epoch(), }, )?; return list_custom_world_work_snapshots( ctx, CustomWorldWorksListInput { owner_user_id: input.owner_user_id, }, ); } // 删除纯 Agent 草稿时同步清理消息、操作与草稿卡,避免作品列表消失后残留孤儿数据。 ctx.db .custom_world_agent_session() .session_id() .delete(&session.session_id); for message in ctx .db .custom_world_agent_message() .iter() .filter(|row| row.session_id == input.session_id) .collect::>() { ctx.db .custom_world_agent_message() .message_id() .delete(&message.message_id); } for operation in ctx .db .custom_world_agent_operation() .iter() .filter(|row| row.session_id == input.session_id) .collect::>() { ctx.db .custom_world_agent_operation() .operation_id() .delete(&operation.operation_id); } for card in ctx .db .custom_world_draft_card() .iter() .filter(|row| row.session_id == input.session_id) .collect::>() { ctx.db .custom_world_draft_card() .card_id() .delete(&card.card_id); } list_custom_world_work_snapshots( ctx, CustomWorldWorksListInput { owner_user_id: input.owner_user_id, }, ) } fn submit_custom_world_agent_message_tx( ctx: &ReducerContext, input: CustomWorldAgentMessageSubmitInput, ) -> Result { validate_custom_world_agent_message_submit_input(&input).map_err(|error| error.to_string())?; if input.user_message_text.contains("__phase1_force_fail__") { return Err("forced failure".to_string()); } let _session = ctx .db .custom_world_agent_session() .session_id() .find(&input.session_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "custom_world_agent_session 不存在".to_string())?; if ctx .db .custom_world_agent_message() .message_id() .find(&input.user_message_id) .is_some() { return Err("custom_world_agent_message.message_id 已存在".to_string()); } if ctx .db .custom_world_agent_operation() .operation_id() .find(&input.operation_id) .is_some() { return Err("custom_world_agent_operation.operation_id 已存在".to_string()); } let submitted_at = Timestamp::from_micros_since_unix_epoch(input.submitted_at_micros); let user_message_text = input.user_message_text.trim().to_string(); ctx.db .custom_world_agent_message() .insert(CustomWorldAgentMessage { message_id: input.user_message_id, session_id: input.session_id.clone(), role: RpgAgentMessageRole::User, kind: RpgAgentMessageKind::Chat, text: user_message_text, related_operation_id: Some(input.operation_id.clone()), created_at: submitted_at, }); ctx.db .custom_world_agent_operation() .insert(CustomWorldAgentOperation { operation_id: input.operation_id.clone(), session_id: input.session_id.clone(), operation_type: RpgAgentOperationType::ProcessMessage, status: RpgAgentOperationStatus::Running, phase_label: "消息处理中".to_string(), phase_detail: "已记录用户消息,等待大模型生成本轮回复。".to_string(), progress: 10, error_message: None, created_at: submitted_at, updated_at: submitted_at, }); get_custom_world_agent_operation_tx( ctx, CustomWorldAgentOperationGetInput { session_id: input.session_id, owner_user_id: input.owner_user_id, operation_id: input.operation_id, }, ) } fn get_custom_world_agent_operation_tx( ctx: &ReducerContext, input: CustomWorldAgentOperationGetInput, ) -> Result { validate_custom_world_agent_operation_get_input(&input).map_err(|error| error.to_string())?; ctx.db .custom_world_agent_session() .session_id() .find(&input.session_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "custom_world_agent_session 不存在".to_string())?; let operation = ctx .db .custom_world_agent_operation() .operation_id() .find(&input.operation_id) .filter(|row| row.session_id == input.session_id) .ok_or_else(|| "custom_world_agent_operation 不存在".to_string())?; Ok(build_custom_world_agent_operation_snapshot(&operation)) } fn upsert_custom_world_agent_operation_progress_tx( ctx: &ReducerContext, input: CustomWorldAgentOperationProgressInput, ) -> Result { validate_custom_world_agent_operation_progress_input(&input) .map_err(|error| error.to_string())?; ctx.db .custom_world_agent_session() .session_id() .find(&input.session_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "custom_world_agent_session 不存在".to_string())?; let timestamp = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); let operation = if let Some(current) = ctx .db .custom_world_agent_operation() .operation_id() .find(&input.operation_id) { if current.session_id != input.session_id { return Err("custom_world_agent_operation.session_id 不匹配".to_string()); } let next = rebuild_custom_world_agent_operation_row( ¤t, CustomWorldAgentOperationPatch { status: Some(input.operation_status), phase_label: Some(input.phase_label.clone()), phase_detail: Some(input.phase_detail.clone()), progress: Some(input.operation_progress), error_message: Some(input.error_message.clone()), updated_at_micros: Some(input.updated_at_micros), }, )?; replace_custom_world_agent_operation(ctx, ¤t, next.clone()); next } else { ctx.db .custom_world_agent_operation() .insert(CustomWorldAgentOperation { operation_id: input.operation_id.clone(), session_id: input.session_id.clone(), operation_type: input.operation_type, status: input.operation_status, phase_label: input.phase_label.clone(), phase_detail: input.phase_detail.clone(), progress: input.operation_progress, error_message: input.error_message.clone(), created_at: timestamp, updated_at: timestamp, }) }; Ok(build_custom_world_agent_operation_snapshot(&operation)) } fn finalize_custom_world_agent_message_turn_tx( ctx: &ReducerContext, input: CustomWorldAgentMessageFinalizeInput, ) -> Result { validate_custom_world_agent_message_finalize_input(&input) .map_err(|error| error.to_string())?; let session = ctx .db .custom_world_agent_session() .session_id() .find(&input.session_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "custom_world_agent_session 不存在".to_string())?; let operation = ctx .db .custom_world_agent_operation() .operation_id() .find(&input.operation_id) .filter(|row| row.session_id == input.session_id) .ok_or_else(|| "custom_world_agent_operation 不存在".to_string())?; let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); let next_session = if input.operation_status == RpgAgentOperationStatus::Failed { rebuild_custom_world_agent_session_row( &session, CustomWorldAgentSessionPatch { updated_at_micros: Some(input.updated_at_micros), ..CustomWorldAgentSessionPatch::default() }, )? } else { let assistant_message_id = input.assistant_message_id.clone().ok_or_else(|| { "custom_world_agent_message.assistant_message_id 不能为空".to_string() })?; let assistant_reply_text = input .assistant_reply_text .clone() .ok_or_else(|| "custom_world_agent_message.text 不能为空".to_string())?; if ctx .db .custom_world_agent_message() .message_id() .find(&assistant_message_id) .is_some() { return Err("custom_world_agent_message.assistant_message_id 已存在".to_string()); } ctx.db .custom_world_agent_message() .insert(CustomWorldAgentMessage { message_id: assistant_message_id, session_id: input.session_id.clone(), role: RpgAgentMessageRole::Assistant, kind: RpgAgentMessageKind::Chat, text: assistant_reply_text.clone(), related_operation_id: Some(input.operation_id.clone()), created_at: updated_at, }); rebuild_custom_world_agent_session_row( &session, CustomWorldAgentSessionPatch { current_turn: Some(session.current_turn.saturating_add(1)), progress_percent: Some(input.progress_percent), stage: Some(input.stage), focus_card_id: Some(input.focus_card_id.clone()), anchor_content_json: Some(input.anchor_content_json.clone()), creator_intent_json: Some(input.creator_intent_json.clone()), creator_intent_readiness_json: Some(input.creator_intent_readiness_json.clone()), anchor_pack_json: Some(input.anchor_pack_json.clone()), draft_profile_json: Some(input.draft_profile_json.clone()), last_assistant_reply: Some(Some(assistant_reply_text)), pending_clarifications_json: Some(input.pending_clarifications_json.clone()), quality_findings_json: Some(input.quality_findings_json.clone()), suggested_actions_json: Some(input.suggested_actions_json.clone()), recommended_replies_json: Some(input.recommended_replies_json.clone()), asset_coverage_json: Some(input.asset_coverage_json.clone()), updated_at_micros: Some(input.updated_at_micros), ..CustomWorldAgentSessionPatch::default() }, )? }; replace_custom_world_agent_session(ctx, &session, next_session); let next_operation = rebuild_custom_world_agent_operation_row( &operation, CustomWorldAgentOperationPatch { status: Some(input.operation_status), phase_label: Some(input.phase_label.clone()), phase_detail: Some(input.phase_detail.clone()), progress: Some(input.operation_progress), error_message: Some(input.error_message.clone()), updated_at_micros: Some(input.updated_at_micros), }, )?; replace_custom_world_agent_operation(ctx, &operation, next_operation.clone()); Ok(build_custom_world_agent_operation_snapshot(&next_operation)) } // 当前阶段先把 quest_record / quest_log 立成最小任务真相源,后续再把奖励结算和 story action 总分发接进来。 #[spacetimedb::reducer] pub fn accept_quest(ctx: &ReducerContext, input: QuestRecordInput) -> Result<(), String> { let snapshot = build_quest_record_snapshot(input).map_err(|error| error.to_string())?; if ctx .db .quest_record() .quest_id() .find(&snapshot.quest_id) .is_some() { return Err("quest_record.quest_id 已存在".to_string()); } ctx.db .quest_record() .insert(build_quest_record_row(snapshot.clone())); append_quest_log( ctx, &snapshot, QuestLogEventKind::Accepted, None, None, None, None, snapshot.created_at_micros, ); Ok(()) } // 任务推进 reducer 只认 QuestProgressSignal,不直接掺入背包、成长和关系奖励发放。 #[spacetimedb::reducer] pub fn apply_quest_signal( ctx: &ReducerContext, input: QuestSignalApplyInput, ) -> Result<(), String> { let signal = input.signal.clone(); let current = ctx .db .quest_record() .quest_id() .find(&input.quest_id) .ok_or_else(|| "quest_record 不存在,无法应用任务信号".to_string())?; let outcome = apply_quest_record_signal(build_quest_record_snapshot_from_row(¤t), input) .map_err(|error| error.to_string())?; if !outcome.changed { return Ok(()); } ctx.db.quest_record().quest_id().delete(¤t.quest_id); ctx.db .quest_record() .insert(build_quest_record_row(outcome.next_record.clone())); append_quest_log( ctx, &outcome.next_record, if outcome.completed_now { QuestLogEventKind::Completed } else { QuestLogEventKind::Progressed }, Some(outcome.signal_kind), Some(signal), outcome.changed_step_id, outcome.changed_step_progress, outcome.next_record.updated_at_micros, ); Ok(()) } #[spacetimedb::reducer] pub fn acknowledge_quest_completion( ctx: &ReducerContext, input: QuestCompletionAckInput, ) -> Result<(), String> { let current = ctx .db .quest_record() .quest_id() .find(&input.quest_id) .ok_or_else(|| "quest_record 不存在,无法确认完成提示".to_string())?; let outcome = acknowledge_quest_record_completion(build_quest_record_snapshot_from_row(¤t), input) .map_err(|error| error.to_string())?; if !outcome.changed { return Ok(()); } ctx.db.quest_record().quest_id().delete(¤t.quest_id); ctx.db .quest_record() .insert(build_quest_record_row(outcome.next_record.clone())); append_quest_log( ctx, &outcome.next_record, QuestLogEventKind::CompletionAcknowledged, None, None, None, None, outcome.next_record.updated_at_micros, ); Ok(()) } #[spacetimedb::reducer] pub fn turn_in_quest(ctx: &ReducerContext, input: QuestTurnInInput) -> Result<(), String> { let current = ctx .db .quest_record() .quest_id() .find(&input.quest_id) .ok_or_else(|| "quest_record 不存在,无法交付任务".to_string())?; let next = turn_in_quest_record(build_quest_record_snapshot_from_row(¤t), input) .map_err(|error| error.to_string())?; ctx.db.quest_record().quest_id().delete(¤t.quest_id); ctx.db .quest_record() .insert(build_quest_record_row(next.clone())); append_quest_log( ctx, &next, QuestLogEventKind::TurnedIn, None, None, None, None, next.updated_at_micros, ); let reward_experience = next.reward.experience.unwrap_or(0); grant_quest_reward_items(ctx, &next)?; if reward_experience > 0 { let updated_player = upsert_player_progression_after_grant_tx( ctx, PlayerProgressionGrantInput { user_id: next.actor_user_id.clone(), amount: reward_experience, source: PlayerProgressionGrantSource::Quest, updated_at_micros: next.updated_at_micros, }, )?; // 章节计划缺失时先保持任务交付成功,避免成长联动反向阻断 quest 主链。 try_update_chapter_progression_ledger_tx( ctx, next.actor_user_id.clone(), next.chapter_id.clone(), ChapterProgressionLedgerInput { user_id: next.actor_user_id.clone(), chapter_id: next.chapter_id.clone().unwrap_or_default(), granted_quest_xp: reward_experience, granted_hostile_xp: 0, hostile_defeat_increment: 0, level_at_exit: Some(updated_player.level), updated_at_micros: next.updated_at_micros, }, )?; } Ok(()) } // M5 Stage 2 先把 library profile upsert 固定成最小正式写入口;已发布作品在这里同步刷新 gallery 投影。 #[spacetimedb::reducer] pub fn upsert_custom_world_profile( ctx: &ReducerContext, input: CustomWorldProfileUpsertInput, ) -> Result<(), String> { upsert_custom_world_profile_record(ctx, input).map(|_| ()) } // procedure 面向 Axum 返回 profile 与可能同步出的 gallery 投影,避免 HTTP 层再二次查询私有表。 #[spacetimedb::procedure] pub fn upsert_custom_world_profile_and_return( ctx: &mut ProcedureContext, input: CustomWorldProfileUpsertInput, ) -> CustomWorldLibraryMutationResult { match ctx.try_with_tx(|tx| upsert_custom_world_profile_record(tx, input.clone())) { Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { ok: true, entry: Some(entry), gallery_entry, error_message: None, }, Err(message) => CustomWorldLibraryMutationResult { ok: false, entry: None, gallery_entry: None, error_message: Some(message), }, } } // publish 负责同时推进 profile 发布态与 gallery 公开投影,避免公开列表继续运行时拼装。 #[spacetimedb::reducer] pub fn publish_custom_world_profile( ctx: &ReducerContext, input: CustomWorldProfilePublishInput, ) -> Result<(), String> { publish_custom_world_profile_record(ctx, input).map(|_| ()) } #[spacetimedb::procedure] pub fn publish_custom_world_profile_and_return( ctx: &mut ProcedureContext, input: CustomWorldProfilePublishInput, ) -> CustomWorldLibraryMutationResult { match ctx.try_with_tx(|tx| publish_custom_world_profile_record(tx, input.clone())) { Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { ok: true, entry: Some(entry), gallery_entry, error_message: None, }, Err(message) => CustomWorldLibraryMutationResult { ok: false, entry: None, gallery_entry: None, error_message: Some(message), }, } } // unpublish 负责撤掉 gallery 投影,并把 profile 恢复为 draft。 #[spacetimedb::reducer] pub fn unpublish_custom_world_profile( ctx: &ReducerContext, input: CustomWorldProfileUnpublishInput, ) -> Result<(), String> { unpublish_custom_world_profile_record(ctx, input).map(|_| ()) } #[spacetimedb::procedure] pub fn unpublish_custom_world_profile_and_return( ctx: &mut ProcedureContext, input: CustomWorldProfileUnpublishInput, ) -> CustomWorldLibraryMutationResult { match ctx.try_with_tx(|tx| unpublish_custom_world_profile_record(tx, input.clone())) { Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { ok: true, entry: Some(entry), gallery_entry, error_message: None, }, Err(message) => CustomWorldLibraryMutationResult { ok: false, entry: None, gallery_entry: None, error_message: Some(message), }, } } // 删除入口继续走 owner-only 软删除,不直接物理删除 profile 真相。 #[spacetimedb::procedure] pub fn delete_custom_world_profile_and_return( ctx: &mut ProcedureContext, input: module_custom_world::CustomWorldProfileDeleteInput, ) -> CustomWorldProfileListResult { match ctx.try_with_tx(|tx| { delete_custom_world_profile_record(tx, input.clone())?; list_custom_world_profile_snapshots( tx, CustomWorldProfileListInput { owner_user_id: input.owner_user_id.clone(), }, ) }) { Ok(entries) => CustomWorldProfileListResult { ok: true, entries, error_message: None, }, Err(message) => CustomWorldProfileListResult { ok: false, entries: Vec::new(), error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn list_custom_world_profiles( ctx: &mut ProcedureContext, input: CustomWorldProfileListInput, ) -> CustomWorldProfileListResult { match ctx.try_with_tx(|tx| list_custom_world_profile_snapshots(tx, input.clone())) { Ok(entries) => CustomWorldProfileListResult { ok: true, entries, error_message: None, }, Err(message) => CustomWorldProfileListResult { ok: false, entries: Vec::new(), error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn list_custom_world_gallery_entries( ctx: &mut ProcedureContext, ) -> CustomWorldGalleryListResult { match ctx.try_with_tx(|tx| list_custom_world_gallery_snapshots(tx)) { Ok(entries) => CustomWorldGalleryListResult { ok: true, entries, error_message: None, }, Err(message) => CustomWorldGalleryListResult { ok: false, entries: Vec::new(), error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn get_custom_world_library_detail( ctx: &mut ProcedureContext, input: CustomWorldLibraryDetailInput, ) -> CustomWorldLibraryMutationResult { match ctx.try_with_tx(|tx| get_custom_world_library_detail_record(tx, input.clone())) { Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { ok: true, entry, gallery_entry, error_message: None, }, Err(message) => CustomWorldLibraryMutationResult { ok: false, entry: None, gallery_entry: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn get_custom_world_gallery_detail( ctx: &mut ProcedureContext, input: CustomWorldGalleryDetailInput, ) -> CustomWorldLibraryMutationResult { match ctx.try_with_tx(|tx| get_custom_world_gallery_detail_record(tx, input.clone())) { Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { ok: true, entry, gallery_entry, error_message: None, }, Err(message) => CustomWorldLibraryMutationResult { ok: false, entry: None, gallery_entry: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn get_custom_world_gallery_detail_by_code( ctx: &mut ProcedureContext, input: module_custom_world::CustomWorldGalleryDetailByCodeInput, ) -> CustomWorldLibraryMutationResult { match ctx.try_with_tx(|tx| get_custom_world_gallery_detail_record_by_code(tx, input.clone())) { Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { ok: true, entry, gallery_entry, error_message: None, }, Err(message) => CustomWorldLibraryMutationResult { ok: false, entry: None, gallery_entry: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn remix_custom_world_profile( ctx: &mut ProcedureContext, input: module_custom_world::CustomWorldProfileRemixInput, ) -> CustomWorldLibraryMutationResult { match ctx.try_with_tx(|tx| remix_custom_world_profile_record(tx, input.clone())) { Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { ok: true, entry: Some(entry), gallery_entry, error_message: None, }, Err(message) => CustomWorldLibraryMutationResult { ok: false, entry: None, gallery_entry: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn record_custom_world_profile_play( ctx: &mut ProcedureContext, input: module_custom_world::CustomWorldProfilePlayRecordInput, ) -> CustomWorldLibraryMutationResult { match ctx.try_with_tx(|tx| record_custom_world_profile_play_record(tx, input.clone())) { Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { ok: true, entry: Some(entry), gallery_entry: Some(gallery_entry), error_message: None, }, Err(message) => CustomWorldLibraryMutationResult { ok: false, entry: None, gallery_entry: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn record_custom_world_profile_like( ctx: &mut ProcedureContext, input: module_custom_world::CustomWorldProfileLikeRecordInput, ) -> CustomWorldLibraryMutationResult { match ctx.try_with_tx(|tx| record_custom_world_profile_like_record(tx, input.clone())) { Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { ok: true, entry: Some(entry), gallery_entry: Some(gallery_entry), error_message: None, }, Err(message) => CustomWorldLibraryMutationResult { ok: false, entry: None, gallery_entry: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn list_custom_world_works( ctx: &mut ProcedureContext, input: CustomWorldWorksListInput, ) -> CustomWorldWorksListResult { match ctx.try_with_tx(|tx| list_custom_world_work_snapshots(tx, input.clone())) { Ok(items) => CustomWorldWorksListResult { ok: true, items, error_message: None, }, Err(message) => CustomWorldWorksListResult { ok: false, items: Vec::new(), error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn get_custom_world_agent_card_detail( ctx: &mut ProcedureContext, input: CustomWorldAgentCardDetailGetInput, ) -> CustomWorldDraftCardDetailResult { match ctx.try_with_tx(|tx| get_custom_world_agent_card_detail_tx(tx, input.clone())) { Ok(card) => CustomWorldDraftCardDetailResult { ok: true, card: Some(card), error_message: None, }, Err(message) => CustomWorldDraftCardDetailResult { ok: false, card: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn execute_custom_world_agent_action( ctx: &mut ProcedureContext, input: CustomWorldAgentActionExecuteInput, ) -> CustomWorldAgentActionExecuteResult { match ctx.try_with_tx(|tx| execute_custom_world_agent_action_tx(tx, input.clone())) { Ok(operation) => CustomWorldAgentActionExecuteResult { ok: true, operation: Some(operation), error_message: None, }, Err(message) => CustomWorldAgentActionExecuteResult { ok: false, operation: None, error_message: Some(message), }, } } // Stage 3 先把 published profile compile 作为独立 procedure 暴露,避免把编译逻辑和表写入、发布动作强耦合。 #[spacetimedb::procedure] pub fn compile_custom_world_published_profile( _ctx: &mut ProcedureContext, input: CustomWorldPublishedProfileCompileInput, ) -> CustomWorldPublishedProfileCompileResult { match build_custom_world_published_profile_compile_snapshot(input) { Ok(record) => CustomWorldPublishedProfileCompileResult { ok: true, record: Some(record), error_message: None, }, Err(error) => CustomWorldPublishedProfileCompileResult { ok: false, record: None, error_message: Some(error.to_string()), }, } } // Stage 4 把 publish_world 串成单事务主链:compile -> profile upsert -> profile publish -> session.stage 推进。 #[spacetimedb::procedure] pub fn publish_custom_world_world( ctx: &mut ProcedureContext, input: CustomWorldPublishWorldInput, ) -> CustomWorldPublishWorldResult { match ctx.try_with_tx(|tx| publish_custom_world_world_record(tx, input.clone())) { Ok((compiled_record, entry, gallery_entry, session_stage)) => { CustomWorldPublishWorldResult { ok: true, compiled_record: Some(compiled_record), entry: Some(entry), gallery_entry, session_stage: Some(session_stage), error_message: None, } } Err(message) => CustomWorldPublishWorldResult { ok: false, compiled_record: None, entry: None, gallery_entry: None, session_stage: None, error_message: Some(message), }, } } // M4 首轮先把 treasure_record 固定成可审计的宝藏结算真相表,奖励写入与 story 归属关系由 reducer 显式校验。 #[spacetimedb::reducer] pub fn resolve_treasure_interaction( ctx: &ReducerContext, input: TreasureResolveInput, ) -> Result<(), String> { upsert_treasure_record(ctx, input).map(|_| ()) } // procedure 面向后续 Axum facade,同步返回最终 treasure_record 快照,避免 HTTP 层再额外读取私有表。 #[spacetimedb::procedure] pub fn resolve_treasure_interaction_and_return( ctx: &mut ProcedureContext, input: TreasureResolveInput, ) -> TreasureRecordProcedureResult { match ctx.try_with_tx(|tx| upsert_treasure_record(tx, input.clone())) { Ok(record) => TreasureRecordProcedureResult { ok: true, record: Some(record), error_message: None, }, Err(message) => TreasureRecordProcedureResult { ok: false, record: None, error_message: Some(message), }, } } fn upsert_treasure_record( ctx: &ReducerContext, input: TreasureResolveInput, ) -> Result { let snapshot = build_treasure_record_snapshot(input).map_err(|error| error.to_string())?; let story_session = ctx .db .story_session() .story_session_id() .find(&snapshot.story_session_id) .ok_or_else(|| { "treasure_record.story_session_id 对应的 story_session 不存在".to_string() })?; if story_session.runtime_session_id != snapshot.runtime_session_id { return Err( "treasure_record.runtime_session_id 必须与 story_session.runtime_session_id 一致" .to_string(), ); } if story_session.actor_user_id != snapshot.actor_user_id { return Err( "treasure_record.actor_user_id 必须与 story_session.actor_user_id 一致".to_string(), ); } // treasure_record 首版按单次结算真相处理:同 id 重放直接返回已落库快照,避免记录更新和重复发奖脱节。 if let Some(existing) = ctx .db .treasure_record() .treasure_record_id() .find(&snapshot.treasure_record_id) { return Ok(build_treasure_record_snapshot_from_row(&existing)); } let updated_at = Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros); let created_at = Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros); ctx.db .treasure_record() .insert(build_treasure_record_row(&snapshot, created_at, updated_at)); grant_treasure_reward_items_to_inventory(ctx, &snapshot)?; Ok(snapshot) } fn grant_treasure_reward_items_to_inventory( ctx: &ReducerContext, snapshot: &TreasureRecordSnapshot, ) -> Result<(), String> { for (index, reward_item) in snapshot.reward_items.iter().cloned().enumerate() { let inventory_item = build_inventory_item_snapshot_from_reward_item( &snapshot.treasure_record_id, reward_item, ) .map_err(|error| error.to_string())?; let slot_id = build_treasure_inventory_slot_id(&snapshot.treasure_record_id, index); let mutation_id = build_treasure_inventory_mutation_id(&snapshot.treasure_record_id, index); apply_inventory_mutation_tx( ctx, InventoryMutationInput { mutation_id, runtime_session_id: snapshot.runtime_session_id.clone(), story_session_id: Some(snapshot.story_session_id.clone()), actor_user_id: snapshot.actor_user_id.clone(), mutation: InventoryMutation::GrantItem(module_inventory::GrantInventoryItemInput { slot_id, item: inventory_item, }), updated_at_micros: snapshot.updated_at_micros, }, )?; } Ok(()) } fn build_treasure_inventory_slot_id(treasure_record_id: &str, reward_index: usize) -> String { format!( "{}{}_{}", INVENTORY_SLOT_ID_PREFIX, treasure_record_id, reward_index ) } fn build_treasure_inventory_mutation_id(treasure_record_id: &str, reward_index: usize) -> String { format!( "{}{}_{}", INVENTORY_MUTATION_ID_PREFIX, treasure_record_id, reward_index ) } fn build_treasure_record_row( snapshot: &TreasureRecordSnapshot, created_at: Timestamp, updated_at: Timestamp, ) -> TreasureRecord { TreasureRecord { treasure_record_id: snapshot.treasure_record_id.clone(), runtime_session_id: snapshot.runtime_session_id.clone(), story_session_id: snapshot.story_session_id.clone(), actor_user_id: snapshot.actor_user_id.clone(), encounter_id: snapshot.encounter_id.clone(), encounter_name: snapshot.encounter_name.clone(), scene_id: snapshot.scene_id.clone(), scene_name: snapshot.scene_name.clone(), action: snapshot.action, reward_items: snapshot.reward_items.clone(), reward_hp: snapshot.reward_hp, reward_mana: snapshot.reward_mana, reward_currency: snapshot.reward_currency, story_hint: snapshot.story_hint.clone(), created_at, updated_at, } } fn upsert_custom_world_profile_record( ctx: &ReducerContext, input: CustomWorldProfileUpsertInput, ) -> Result< ( CustomWorldProfileSnapshot, Option, ), String, > { validate_custom_world_profile_upsert_input(&input).map_err(|error| error.to_string())?; let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); let current = ctx .db .custom_world_profile() .profile_id() .find(&input.profile_id) .filter(|row| row.owner_user_id == input.owner_user_id) .or_else(|| { input .source_agent_session_id .as_ref() .and_then(|session_id| { ctx.db.custom_world_profile().iter().find(|row| { is_same_agent_draft_profile_candidate(row, &input.owner_user_id, session_id) }) }) }); let next_row = match current { Some(existing) => { ctx.db .custom_world_profile() .profile_id() .delete(&existing.profile_id); CustomWorldProfile { profile_id: existing.profile_id.clone(), owner_user_id: existing.owner_user_id.clone(), public_work_code: existing.public_work_code.clone(), author_public_user_code: existing.author_public_user_code.clone(), source_agent_session_id: input.source_agent_session_id.clone(), publication_status: existing.publication_status, world_name: input.world_name.clone(), subtitle: input.subtitle.clone(), summary_text: input.summary_text.clone(), theme_mode: input.theme_mode, cover_image_src: input.cover_image_src.clone(), profile_payload_json: input.profile_payload_json.clone(), playable_npc_count: input.playable_npc_count, landmark_count: input.landmark_count, play_count: existing.play_count, remix_count: existing.remix_count, like_count: existing.like_count, author_display_name: input.author_display_name.clone(), published_at: existing.published_at, deleted_at: None, created_at: existing.created_at, updated_at, } } None => CustomWorldProfile { profile_id: input.profile_id.clone(), owner_user_id: input.owner_user_id.clone(), public_work_code: input.public_work_code.clone(), author_public_user_code: input.author_public_user_code.clone(), source_agent_session_id: input.source_agent_session_id.clone(), publication_status: CustomWorldPublicationStatus::Draft, world_name: input.world_name.clone(), subtitle: input.subtitle.clone(), summary_text: input.summary_text.clone(), theme_mode: input.theme_mode, cover_image_src: input.cover_image_src.clone(), profile_payload_json: input.profile_payload_json.clone(), playable_npc_count: input.playable_npc_count, landmark_count: input.landmark_count, play_count: 0, remix_count: 0, like_count: 0, author_display_name: input.author_display_name.clone(), published_at: None, deleted_at: None, created_at: updated_at, updated_at, }, }; let inserted = ctx.db.custom_world_profile().insert(next_row); let gallery_entry = if inserted.publication_status == CustomWorldPublicationStatus::Published { Some(sync_custom_world_gallery_entry_from_profile( ctx, &inserted, )?) } else { ctx.db .custom_world_gallery_entry() .profile_id() .delete(&inserted.profile_id); None }; Ok(( build_custom_world_profile_snapshot(&inserted), gallery_entry, )) } fn publish_custom_world_world_record( ctx: &ReducerContext, input: CustomWorldPublishWorldInput, ) -> Result< ( module_custom_world::CustomWorldPublishedProfileCompileSnapshot, CustomWorldProfileSnapshot, Option, RpgAgentStage, ), String, > { validate_custom_world_publish_world_input(&input).map_err(|error| error.to_string())?; let compiled_record = build_custom_world_published_profile_compile_snapshot( CustomWorldPublishedProfileCompileInput { session_id: input.session_id.clone(), profile_id: input.profile_id.clone(), owner_user_id: input.owner_user_id.clone(), draft_profile_json: input.draft_profile_json.clone(), legacy_result_profile_json: input.legacy_result_profile_json.clone(), setting_text: input.setting_text.clone(), author_display_name: input.author_display_name.clone(), updated_at_micros: input.published_at_micros, }, ) .map_err(|error| error.to_string())?; let _ = upsert_custom_world_profile_record( ctx, CustomWorldProfileUpsertInput { profile_id: compiled_record.profile_id.clone(), owner_user_id: compiled_record.owner_user_id.clone(), public_work_code: input.public_work_code.clone(), author_public_user_code: Some(input.author_public_user_code.clone()), source_agent_session_id: Some(input.session_id.clone()), world_name: compiled_record.world_name.clone(), subtitle: compiled_record.subtitle.clone(), summary_text: compiled_record.summary_text.clone(), theme_mode: compiled_record.theme_mode, cover_image_src: compiled_record.cover_image_src.clone(), profile_payload_json: compiled_record.compiled_profile_payload_json.clone(), playable_npc_count: compiled_record.playable_npc_count, landmark_count: compiled_record.landmark_count, author_display_name: compiled_record.author_display_name.clone(), updated_at_micros: input.published_at_micros, }, )?; let (entry, gallery_entry) = publish_custom_world_profile_record( ctx, CustomWorldProfilePublishInput { profile_id: compiled_record.profile_id.clone(), owner_user_id: compiled_record.owner_user_id.clone(), public_work_code: input.public_work_code.clone(), author_public_user_code: input.author_public_user_code.clone(), author_display_name: compiled_record.author_display_name.clone(), published_at_micros: input.published_at_micros, }, )?; let session_stage = mark_custom_world_agent_session_published( ctx, &input.session_id, &input.owner_user_id, input.published_at_micros, )?; Ok((compiled_record, entry, gallery_entry, session_stage)) } fn publish_custom_world_profile_record( ctx: &ReducerContext, input: CustomWorldProfilePublishInput, ) -> Result< ( CustomWorldProfileSnapshot, Option, ), String, > { validate_custom_world_profile_publish_input(&input).map_err(|error| error.to_string())?; let existing = ctx .db .custom_world_profile() .profile_id() .find(&input.profile_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "custom_world_profile 不存在,无法发布".to_string())?; let published_at = Timestamp::from_micros_since_unix_epoch(input.published_at_micros); ctx.db .custom_world_profile() .profile_id() .delete(&existing.profile_id); let next_row = CustomWorldProfile { profile_id: existing.profile_id.clone(), owner_user_id: existing.owner_user_id.clone(), public_work_code: existing .public_work_code .clone() .or_else(|| Some(build_public_work_code_from_profile_id(&existing.profile_id))), author_public_user_code: Some(input.author_public_user_code.clone()), source_agent_session_id: existing.source_agent_session_id.clone(), publication_status: CustomWorldPublicationStatus::Published, world_name: existing.world_name.clone(), subtitle: existing.subtitle.clone(), summary_text: existing.summary_text.clone(), theme_mode: existing.theme_mode, cover_image_src: existing.cover_image_src.clone(), profile_payload_json: existing.profile_payload_json.clone(), playable_npc_count: existing.playable_npc_count, landmark_count: existing.landmark_count, play_count: existing.play_count, remix_count: existing.remix_count, like_count: existing.like_count, author_display_name: input.author_display_name.clone(), published_at: Some(published_at), deleted_at: None, created_at: existing.created_at, updated_at: published_at, }; let inserted = ctx.db.custom_world_profile().insert(next_row); let gallery_entry = sync_custom_world_gallery_entry_from_profile(ctx, &inserted)?; Ok(( build_custom_world_profile_snapshot(&inserted), Some(gallery_entry), )) } fn unpublish_custom_world_profile_record( ctx: &ReducerContext, input: CustomWorldProfileUnpublishInput, ) -> Result< ( CustomWorldProfileSnapshot, Option, ), String, > { validate_custom_world_profile_unpublish_input(&input).map_err(|error| error.to_string())?; let existing = ctx .db .custom_world_profile() .profile_id() .find(&input.profile_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "custom_world_profile 不存在,无法取消发布".to_string())?; let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); ctx.db .custom_world_profile() .profile_id() .delete(&existing.profile_id); ctx.db .custom_world_gallery_entry() .profile_id() .delete(&existing.profile_id); let next_row = CustomWorldProfile { profile_id: existing.profile_id.clone(), owner_user_id: existing.owner_user_id.clone(), public_work_code: existing.public_work_code.clone(), author_public_user_code: existing.author_public_user_code.clone(), source_agent_session_id: existing.source_agent_session_id.clone(), publication_status: CustomWorldPublicationStatus::Draft, world_name: existing.world_name.clone(), subtitle: existing.subtitle.clone(), summary_text: existing.summary_text.clone(), theme_mode: existing.theme_mode, cover_image_src: existing.cover_image_src.clone(), profile_payload_json: existing.profile_payload_json.clone(), playable_npc_count: existing.playable_npc_count, landmark_count: existing.landmark_count, play_count: existing.play_count, remix_count: existing.remix_count, like_count: existing.like_count, author_display_name: input.author_display_name.clone(), published_at: None, deleted_at: None, created_at: existing.created_at, updated_at, }; let inserted = ctx.db.custom_world_profile().insert(next_row); Ok((build_custom_world_profile_snapshot(&inserted), None)) } fn delete_custom_world_profile_record( ctx: &ReducerContext, input: module_custom_world::CustomWorldProfileDeleteInput, ) -> Result<(), String> { validate_custom_world_profile_delete_input(&input).map_err(|error| error.to_string())?; let Some(existing) = ctx .db .custom_world_profile() .profile_id() .find(&input.profile_id) .filter(|row| row.owner_user_id == input.owner_user_id) else { return Ok(()); }; if existing.deleted_at.is_some() { return Ok(()); } let deleted_at = Timestamp::from_micros_since_unix_epoch(input.deleted_at_micros); ctx.db .custom_world_profile() .profile_id() .delete(&existing.profile_id); ctx.db .custom_world_gallery_entry() .profile_id() .delete(&existing.profile_id); let next_row = CustomWorldProfile { profile_id: existing.profile_id.clone(), owner_user_id: existing.owner_user_id.clone(), public_work_code: existing.public_work_code.clone(), author_public_user_code: existing.author_public_user_code.clone(), source_agent_session_id: existing.source_agent_session_id.clone(), publication_status: CustomWorldPublicationStatus::Draft, world_name: existing.world_name.clone(), subtitle: existing.subtitle.clone(), summary_text: existing.summary_text.clone(), theme_mode: existing.theme_mode, cover_image_src: existing.cover_image_src.clone(), profile_payload_json: existing.profile_payload_json.clone(), playable_npc_count: existing.playable_npc_count, landmark_count: existing.landmark_count, play_count: existing.play_count, remix_count: existing.remix_count, like_count: existing.like_count, author_display_name: existing.author_display_name.clone(), published_at: None, deleted_at: Some(deleted_at), created_at: existing.created_at, updated_at: deleted_at, }; let _ = ctx.db.custom_world_profile().insert(next_row); Ok(()) } fn list_custom_world_profile_snapshots( ctx: &ReducerContext, input: CustomWorldProfileListInput, ) -> Result, String> { validate_custom_world_profile_list_input(&input).map_err(|error| error.to_string())?; let mut entries = ctx .db .custom_world_profile() .by_custom_world_profile_owner_user_id() .filter(&input.owner_user_id) .filter(|row| row.deleted_at.is_none()) .map(|row| build_custom_world_profile_list_snapshot(&row)) .collect::>(); entries.sort_by(|left, right| right.updated_at_micros.cmp(&left.updated_at_micros)); Ok(entries) } fn build_custom_world_profile_list_snapshot( row: &CustomWorldProfile, ) -> CustomWorldProfileSnapshot { let mut snapshot = build_custom_world_profile_snapshot(row); snapshot.profile_payload_json = build_custom_world_profile_list_payload_json(row); snapshot } fn build_custom_world_profile_list_payload_json(row: &CustomWorldProfile) -> String { let source_profile = serde_json::from_str::(&row.profile_payload_json).ok(); let source_object = source_profile.as_ref().and_then(JsonValue::as_object); let empty_roles = JsonValue::Array(Vec::new()); let empty_landmarks = JsonValue::Array(Vec::new()); // 中文注释:首屏作品列表只需要卡片摘要,不能继续把完整 profile 大 JSON 随列表搬回 Axum。 let payload = json!({ "id": row.profile_id, "name": row.world_name, "subtitle": row.subtitle, "summary": row.summary_text, "tone": source_object .and_then(|object| object.get("tone")) .and_then(JsonValue::as_str) .unwrap_or_default(), "playerGoal": source_object .and_then(|object| object.get("playerGoal")) .and_then(JsonValue::as_str) .unwrap_or_default(), "settingText": source_object .and_then(|object| object.get("settingText")) .and_then(JsonValue::as_str) .unwrap_or_default(), "themeMode": row.theme_mode.as_str(), "templateWorldType": source_object .and_then(|object| object.get("templateWorldType")) .and_then(JsonValue::as_str) .unwrap_or("WUXIA"), "compatibilityTemplateWorldType": source_object .and_then(|object| object.get("compatibilityTemplateWorldType")) .cloned() .unwrap_or(JsonValue::Null), "cover": row.cover_image_src.as_ref().map(|image_src| json!({ "sourceType": "generated", "imageSrc": image_src, })), "majorFactions": source_object .and_then(|object| object.get("majorFactions")) .cloned() .unwrap_or_else(|| JsonValue::Array(Vec::new())), "coreConflicts": source_object .and_then(|object| object.get("coreConflicts")) .cloned() .unwrap_or_else(|| JsonValue::Array(Vec::new())), "playableNpcs": source_object .and_then(|object| object.get("playableNpcs")) .cloned() .unwrap_or_else(|| empty_roles.clone()), "storyNpcs": source_object .and_then(|object| object.get("storyNpcs")) .cloned() .unwrap_or_else(|| JsonValue::Array(Vec::new())), "items": source_object .and_then(|object| object.get("items")) .cloned() .unwrap_or_else(|| JsonValue::Array(Vec::new())), "camp": source_object .and_then(|object| object.get("camp")) .cloned() .unwrap_or(JsonValue::Null), "landmarks": source_object .and_then(|object| object.get("landmarks")) .cloned() .unwrap_or_else(|| empty_landmarks.clone()), "ownedSettingLayers": source_object .and_then(|object| object.get("ownedSettingLayers")) .cloned() .unwrap_or(JsonValue::Null), }); serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string()) } fn list_custom_world_gallery_snapshots( ctx: &ReducerContext, ) -> Result, String> { sync_missing_custom_world_gallery_entries(ctx)?; let mut entries = ctx .db .custom_world_gallery_entry() .iter() .map(|row| build_custom_world_gallery_entry_snapshot(ctx, &row)) .collect::>(); entries.sort_by(|left, right| { right .published_at_micros .cmp(&left.published_at_micros) .then(right.updated_at_micros.cmp(&left.updated_at_micros)) }); Ok(entries) } fn get_custom_world_library_detail_record( ctx: &ReducerContext, input: CustomWorldLibraryDetailInput, ) -> Result< ( Option, Option, ), String, > { validate_custom_world_library_detail_input(&input).map_err(|error| error.to_string())?; let profile = ctx .db .custom_world_profile() .profile_id() .find(&input.profile_id) .filter(|row| row.owner_user_id == input.owner_user_id && row.deleted_at.is_none()); let gallery_entry = profile .as_ref() .filter(|row| row.publication_status == CustomWorldPublicationStatus::Published) .and_then(|row| { ctx.db .custom_world_gallery_entry() .profile_id() .find(&row.profile_id) .filter(|gallery_row| gallery_row.owner_user_id == row.owner_user_id) }); Ok(( profile.as_ref().map(build_custom_world_profile_snapshot), gallery_entry .as_ref() .map(|row| build_custom_world_gallery_entry_snapshot(ctx, row)), )) } fn get_custom_world_gallery_detail_record( ctx: &ReducerContext, input: CustomWorldGalleryDetailInput, ) -> Result< ( Option, Option, ), String, > { validate_custom_world_gallery_detail_input(&input).map_err(|error| error.to_string())?; let profile = ctx .db .custom_world_profile() .profile_id() .find(&input.profile_id) .filter(|row| { row.owner_user_id == input.owner_user_id && row.publication_status == CustomWorldPublicationStatus::Published && row.deleted_at.is_none() }); let gallery_entry = ctx .db .custom_world_gallery_entry() .profile_id() .find(&input.profile_id) .filter(|row| row.owner_user_id == input.owner_user_id); Ok(( profile.as_ref().map(build_custom_world_profile_snapshot), gallery_entry .as_ref() .map(|row| build_custom_world_gallery_entry_snapshot(ctx, row)), )) } fn get_custom_world_gallery_detail_record_by_code( ctx: &ReducerContext, input: module_custom_world::CustomWorldGalleryDetailByCodeInput, ) -> Result< ( Option, Option, ), String, > { validate_custom_world_gallery_detail_by_code_input(&input) .map_err(|error| error.to_string())?; let normalized_public_work_code = normalize_public_work_code(&input.public_work_code) .ok_or_else(|| "public_work_code 格式不正确".to_string())?; let gallery_entry = ctx .db .custom_world_gallery_entry() .iter() .find(|row| row.public_work_code == normalized_public_work_code); let profile = gallery_entry.as_ref().and_then(|row| { ctx.db .custom_world_profile() .profile_id() .find(&row.profile_id) .filter(|profile_row| { profile_row.owner_user_id == row.owner_user_id && profile_row.publication_status == CustomWorldPublicationStatus::Published && profile_row.deleted_at.is_none() }) }); Ok(( profile.as_ref().map(build_custom_world_profile_snapshot), gallery_entry .as_ref() .map(|row| build_custom_world_gallery_entry_snapshot(ctx, row)), )) } fn remix_custom_world_profile_record( ctx: &ReducerContext, input: module_custom_world::CustomWorldProfileRemixInput, ) -> Result< ( CustomWorldProfileSnapshot, Option, ), String, > { let source_owner_user_id = input.source_owner_user_id.trim(); let source_profile_id = input.source_profile_id.trim(); let target_owner_user_id = input.target_owner_user_id.trim(); let target_profile_id = input.target_profile_id.trim(); if source_owner_user_id.is_empty() || source_profile_id.is_empty() || target_owner_user_id.is_empty() || target_profile_id.is_empty() { return Err("custom_world remix 参数不能为空".to_string()); } if input.author_display_name.trim().is_empty() { return Err("custom_world remix 作者名不能为空".to_string()); } // Remix 只允许从已发布源作品派生草稿,同时把源作品的公开 remix 计数同步到画廊。 let source = ctx .db .custom_world_profile() .profile_id() .find(&source_profile_id.to_string()) .filter(|row| row.owner_user_id == source_owner_user_id) .filter(|row| { row.publication_status == CustomWorldPublicationStatus::Published && row.deleted_at.is_none() && row.published_at.is_some() }) .ok_or_else(|| "custom_world 已发布源作品不存在".to_string())?; if ctx .db .custom_world_profile() .profile_id() .find(&target_profile_id.to_string()) .is_some() { return Err("custom_world remix 目标 profile 已存在".to_string()); } let remixed_at = Timestamp::from_micros_since_unix_epoch(input.remixed_at_micros); let next_source = CustomWorldProfile { profile_id: source.profile_id.clone(), owner_user_id: source.owner_user_id.clone(), public_work_code: source.public_work_code.clone(), author_public_user_code: source.author_public_user_code.clone(), source_agent_session_id: source.source_agent_session_id.clone(), publication_status: source.publication_status, world_name: source.world_name.clone(), subtitle: source.subtitle.clone(), summary_text: source.summary_text.clone(), theme_mode: source.theme_mode, cover_image_src: source.cover_image_src.clone(), profile_payload_json: source.profile_payload_json.clone(), playable_npc_count: source.playable_npc_count, landmark_count: source.landmark_count, play_count: source.play_count, remix_count: source.remix_count.saturating_add(1), like_count: source.like_count, author_display_name: source.author_display_name.clone(), published_at: source.published_at, deleted_at: source.deleted_at, created_at: source.created_at, updated_at: remixed_at, }; ctx.db .custom_world_profile() .profile_id() .delete(&source.profile_id); let updated_source = ctx.db.custom_world_profile().insert(next_source); let source_gallery = sync_custom_world_gallery_entry_from_profile(ctx, &updated_source)?; // 新草稿继承作品内容,但互动计数从 0 开始,避免把源作品热度复制成用户资产。 let draft = CustomWorldProfile { profile_id: target_profile_id.to_string(), owner_user_id: target_owner_user_id.to_string(), public_work_code: None, author_public_user_code: None, source_agent_session_id: None, publication_status: CustomWorldPublicationStatus::Draft, world_name: source.world_name.clone(), subtitle: source.subtitle.clone(), summary_text: source.summary_text.clone(), theme_mode: source.theme_mode, cover_image_src: source.cover_image_src.clone(), profile_payload_json: source.profile_payload_json.clone(), playable_npc_count: source.playable_npc_count, landmark_count: source.landmark_count, play_count: 0, remix_count: 0, like_count: 0, author_display_name: input.author_display_name.trim().to_string(), published_at: None, deleted_at: None, created_at: remixed_at, updated_at: remixed_at, }; let inserted_draft = ctx.db.custom_world_profile().insert(draft); Ok(( build_custom_world_profile_snapshot(&inserted_draft), Some(source_gallery), )) } fn record_custom_world_profile_play_record( ctx: &ReducerContext, input: module_custom_world::CustomWorldProfilePlayRecordInput, ) -> Result<(CustomWorldProfileSnapshot, CustomWorldGalleryEntrySnapshot), String> { let owner_user_id = input.owner_user_id.trim(); let profile_id = input.profile_id.trim(); if owner_user_id.is_empty() || profile_id.is_empty() { return Err("custom_world play 参数不能为空".to_string()); } let existing = ctx .db .custom_world_profile() .profile_id() .find(&profile_id.to_string()) .filter(|row| row.owner_user_id == owner_user_id) .filter(|row| { row.publication_status == CustomWorldPublicationStatus::Published && row.deleted_at.is_none() && row.published_at.is_some() }) .ok_or_else(|| "custom_world 已发布作品不存在,无法记录游玩".to_string())?; let played_at = Timestamp::from_micros_since_unix_epoch(input.played_at_micros); record_public_work_play( ctx, crate::runtime::PublicWorkPlayRecordInput { source_type: "custom-world".to_string(), owner_user_id: existing.owner_user_id.clone(), profile_id: existing.profile_id.clone(), played_at_micros: input.played_at_micros, }, )?; // 游玩计数是公开广场消费数据,只增加计数并保持作品内容不变。 let next_row = CustomWorldProfile { profile_id: existing.profile_id.clone(), owner_user_id: existing.owner_user_id.clone(), public_work_code: existing.public_work_code.clone(), author_public_user_code: existing.author_public_user_code.clone(), source_agent_session_id: existing.source_agent_session_id.clone(), publication_status: existing.publication_status, world_name: existing.world_name.clone(), subtitle: existing.subtitle.clone(), summary_text: existing.summary_text.clone(), theme_mode: existing.theme_mode, cover_image_src: existing.cover_image_src.clone(), profile_payload_json: existing.profile_payload_json.clone(), playable_npc_count: existing.playable_npc_count, landmark_count: existing.landmark_count, play_count: existing.play_count.saturating_add(1), remix_count: existing.remix_count, like_count: existing.like_count, author_display_name: existing.author_display_name.clone(), published_at: existing.published_at, deleted_at: existing.deleted_at, created_at: existing.created_at, updated_at: played_at, }; ctx.db .custom_world_profile() .profile_id() .delete(&existing.profile_id); let inserted = ctx.db.custom_world_profile().insert(next_row); let gallery_entry = sync_custom_world_gallery_entry_from_profile(ctx, &inserted)?; Ok(( build_custom_world_profile_snapshot(&inserted), gallery_entry, )) } fn record_custom_world_profile_like_record( ctx: &ReducerContext, input: module_custom_world::CustomWorldProfileLikeRecordInput, ) -> Result<(CustomWorldProfileSnapshot, CustomWorldGalleryEntrySnapshot), String> { let owner_user_id = input.owner_user_id.trim(); let profile_id = input.profile_id.trim(); let user_id = input.user_id.trim(); if owner_user_id.is_empty() || profile_id.is_empty() || user_id.is_empty() { return Err("custom_world like 参数不能为空".to_string()); } let existing = ctx .db .custom_world_profile() .profile_id() .find(&profile_id.to_string()) .filter(|row| row.owner_user_id == owner_user_id) .filter(|row| { row.publication_status == CustomWorldPublicationStatus::Published && row.deleted_at.is_none() && row.published_at.is_some() }) .ok_or_else(|| "custom_world 已发布作品不存在,无法点赞".to_string())?; let liked_at = Timestamp::from_micros_since_unix_epoch(input.liked_at_micros); let inserted_like = record_public_work_like( ctx, crate::runtime::PublicWorkLikeRecordInput { source_type: "custom-world".to_string(), owner_user_id: existing.owner_user_id.clone(), profile_id: existing.profile_id.clone(), user_id: user_id.to_string(), liked_at_micros: input.liked_at_micros, }, )?; if !inserted_like { let gallery_entry = ctx .db .custom_world_gallery_entry() .profile_id() .find(&existing.profile_id) .filter(|row| row.owner_user_id == existing.owner_user_id) .map(|row| build_custom_world_gallery_entry_snapshot(ctx, &row)) .ok_or_else(|| "custom_world gallery_entry 不存在".to_string())?; return Ok(( build_custom_world_profile_snapshot(&existing), gallery_entry, )); } // 中文注释:点赞关系表先保证一人一作品一次,再递增公开作品计数,避免前端重复点击造成热度膨胀。 let next_row = CustomWorldProfile { profile_id: existing.profile_id.clone(), owner_user_id: existing.owner_user_id.clone(), public_work_code: existing.public_work_code.clone(), author_public_user_code: existing.author_public_user_code.clone(), source_agent_session_id: existing.source_agent_session_id.clone(), publication_status: existing.publication_status, world_name: existing.world_name.clone(), subtitle: existing.subtitle.clone(), summary_text: existing.summary_text.clone(), theme_mode: existing.theme_mode, cover_image_src: existing.cover_image_src.clone(), profile_payload_json: existing.profile_payload_json.clone(), playable_npc_count: existing.playable_npc_count, landmark_count: existing.landmark_count, play_count: existing.play_count, remix_count: existing.remix_count, like_count: existing.like_count.saturating_add(1), author_display_name: existing.author_display_name.clone(), published_at: existing.published_at, deleted_at: existing.deleted_at, created_at: existing.created_at, updated_at: liked_at, }; ctx.db .custom_world_profile() .profile_id() .delete(&existing.profile_id); let inserted = ctx.db.custom_world_profile().insert(next_row); let gallery_entry = sync_custom_world_gallery_entry_from_profile(ctx, &inserted)?; Ok(( build_custom_world_profile_snapshot(&inserted), gallery_entry, )) } fn list_custom_world_work_snapshots( ctx: &ReducerContext, input: CustomWorldWorksListInput, ) -> Result, String> { validate_custom_world_works_list_input(&input).map_err(|error| error.to_string())?; let mut items = Vec::new(); let mut active_agent_session_ids = HashSet::new(); for session in ctx.db.custom_world_agent_session().iter().filter(|row| { row.owner_user_id == input.owner_user_id && row.stage != RpgAgentStage::Published && should_include_custom_world_agent_session_work(ctx, row) }) { active_agent_session_ids.insert(session.session_id.clone()); let gate = build_custom_world_publish_gate_from_session(&session); let draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref()); let title = resolve_session_work_title(&session, draft_profile.as_ref()); let summary = resolve_session_work_summary(&session, draft_profile.as_ref()); let stage_label = Some(resolve_rpg_agent_stage_label(session.stage).to_string()); let subtitle = resolve_session_work_subtitle(draft_profile.as_ref(), stage_label.as_deref()); let (playable_npc_count, landmark_count) = resolve_session_work_counts(ctx, &session, draft_profile.as_ref()); items.push(CustomWorldWorkSummarySnapshot { work_id: format!("draft:{}", session.session_id), source_type: "agent_session".to_string(), status: "draft".to_string(), title, subtitle, summary, cover_image_src: resolve_session_work_cover_image_src(draft_profile.as_ref()), cover_render_mode: None, cover_character_image_srcs_json: "[]".to_string(), updated_at_micros: session.updated_at.to_micros_since_unix_epoch(), published_at_micros: None, stage: Some(session.stage), stage_label, playable_npc_count, landmark_count, role_visual_ready_count: None, role_animation_ready_count: None, role_asset_summary_label: None, session_id: Some(session.session_id.clone()), profile_id: None, can_resume: true, can_enter_world: gate.can_enter_world, blocker_count: gate.blocker_count, publish_ready: gate.publish_ready, }); } for profile in ctx .db .custom_world_profile() .iter() .filter(|row| row.owner_user_id == input.owner_user_id && row.deleted_at.is_none()) .filter(|row| should_include_custom_world_profile_work(row, &active_agent_session_ids)) { items.push(CustomWorldWorkSummarySnapshot { work_id: format!("published:{}", profile.profile_id), source_type: "published_profile".to_string(), status: profile.publication_status.as_str().to_string(), title: profile.world_name.clone(), subtitle: profile.subtitle.clone(), summary: profile.summary_text.clone(), cover_image_src: profile.cover_image_src.clone(), cover_render_mode: None, cover_character_image_srcs_json: "[]".to_string(), updated_at_micros: profile.updated_at.to_micros_since_unix_epoch(), published_at_micros: profile .published_at .map(|value| value.to_micros_since_unix_epoch()), stage: None, stage_label: None, playable_npc_count: profile.playable_npc_count, landmark_count: profile.landmark_count, role_visual_ready_count: None, role_animation_ready_count: None, role_asset_summary_label: None, session_id: profile.source_agent_session_id.clone(), profile_id: Some(profile.profile_id.clone()), can_resume: false, can_enter_world: profile.publication_status == CustomWorldPublicationStatus::Published, blocker_count: 0, publish_ready: true, }); } items.sort_by(|left, right| { right .updated_at_micros .cmp(&left.updated_at_micros) .then_with(|| { let left_rank = if left.source_type == "agent_session" { 0 } else { 1 }; let right_rank = if right.source_type == "agent_session" { 0 } else { 1 }; left_rank.cmp(&right_rank) }) .then(left.work_id.cmp(&right.work_id)) }); Ok(items) } fn should_include_custom_world_agent_session_work( ctx: &ReducerContext, session: &CustomWorldAgentSession, ) -> bool { if custom_world_agent_session_has_direct_work_content(session) { return true; } if ctx.db.custom_world_agent_message().iter().any(|message| { message.session_id == session.session_id && matches!(message.role, RpgAgentMessageRole::User) }) { return true; } ctx.db .custom_world_draft_card() .iter() .any(|card| card.session_id == session.session_id) } fn custom_world_agent_session_has_direct_work_content(session: &CustomWorldAgentSession) -> bool { // 创建会话时写入的助手欢迎语和空 `{}` draftProfile 不算草稿内容; // 这里只承认用户显式输入的 seed 或已经生成出的真实草稿阶段。 !session.seed_text.trim().is_empty() || matches!( session.stage, RpgAgentStage::ObjectRefining | RpgAgentStage::VisualRefining | RpgAgentStage::LongTailReview | RpgAgentStage::ReadyToPublish | RpgAgentStage::Published ) || parse_optional_session_object(session.draft_profile_json.as_deref()) .as_ref() .is_some_and(|profile| !profile.is_empty()) } fn should_include_custom_world_profile_work( row: &CustomWorldProfile, active_agent_session_ids: &HashSet, ) -> bool { // 已发布 profile 是正式作品;即使来源会话还存在,也必须保留独立入口。 if row.publication_status == CustomWorldPublicationStatus::Published { return true; } // 未发布 profile 若来源于仍可继续聊天的 Agent 会话,只是同一草稿的编译产物, // works 里保留 agent_session 即可,避免草稿分组显示两份同名作品。 row.source_agent_session_id .as_ref() .map_or(true, |session_id| { !active_agent_session_ids.contains(session_id) }) } fn get_custom_world_agent_card_detail_tx( ctx: &ReducerContext, input: CustomWorldAgentCardDetailGetInput, ) -> Result { validate_custom_world_agent_card_detail_get_input(&input).map_err(|error| error.to_string())?; ctx.db .custom_world_agent_session() .session_id() .find(&input.session_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "custom_world_agent_session 不存在".to_string())?; let card = ctx .db .custom_world_draft_card() .card_id() .find(&input.card_id) .filter(|row| row.session_id == input.session_id) .ok_or_else(|| "custom_world_draft_card 不存在".to_string())?; build_custom_world_draft_card_detail_snapshot(&card) } fn execute_custom_world_agent_action_tx( ctx: &ReducerContext, input: CustomWorldAgentActionExecuteInput, ) -> Result { validate_custom_world_agent_action_execute_input(&input).map_err(|error| error.to_string())?; let session = ctx .db .custom_world_agent_session() .session_id() .find(&input.session_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "custom_world_agent_session 不存在".to_string())?; if let Some(existing_operation) = ctx .db .custom_world_agent_operation() .operation_id() .find(&input.operation_id) { let can_reuse_running_draft_operation = input.action.trim() == "draft_foundation" && existing_operation.session_id == input.session_id && existing_operation.operation_type == RpgAgentOperationType::DraftFoundation && matches!( existing_operation.status, RpgAgentOperationStatus::Queued | RpgAgentOperationStatus::Running ); if !can_reuse_running_draft_operation { return Err("custom_world_agent_operation.operation_id 已存在".to_string()); } } let payload = parse_optional_session_object(input.payload_json.as_deref()).unwrap_or_default(); match input.action.trim() { "draft_foundation" => execute_draft_foundation_action(ctx, &session, &input, &payload), "update_draft_card" => execute_update_draft_card_action(ctx, &session, &input, &payload), "sync_result_profile" => { execute_sync_result_profile_action(ctx, &session, &input, &payload) } "publish_world" => execute_publish_world_action(ctx, &session, &input, &payload), "revert_checkpoint" => execute_revert_checkpoint_action(ctx, &session, &input, &payload), "generate_characters" | "generate_landmarks" | "generate_role_assets" | "sync_role_assets" | "generate_scene_assets" | "sync_scene_assets" | "expand_long_tail" => execute_placeholder_custom_world_action(ctx, &session, &input), other => Err(format!("custom world action `{other}` 当前尚未支持")), } } fn execute_draft_foundation_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, input: &CustomWorldAgentActionExecuteInput, payload: &JsonMap, ) -> Result { if session.progress_percent < 100 { return Err("draft_foundation requires progressPercent >= 100".to_string()); } let updated_at = input.submitted_at_micros; let draft_profile = payload .get("draftProfile") .and_then(JsonValue::as_object) .cloned() .ok_or_else(|| { "draft_foundation requires externally generated payload.draftProfile".to_string() })?; let draft_profile_json = serde_json::to_string(&JsonValue::Object(draft_profile.clone())) .map_err(|error| format!("draft_foundation 无法序列化 draft_profile_json: {error}"))?; let gate = summarize_publish_gate_from_json( &input.session_id, RpgAgentStage::ObjectRefining, Some(&draft_profile), &parse_json_array_or_empty(&session.quality_findings_json), ); let next_session = rebuild_custom_world_agent_session_row( session, CustomWorldAgentSessionPatch { progress_percent: Some(100), stage: Some(RpgAgentStage::ObjectRefining), draft_profile_json: Some(Some(draft_profile_json.clone())), last_assistant_reply: Some(Some( "世界底稿已整理完成,接下来可以继续细化卡片和发布预览。".to_string(), )), publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value( &gate, ))?)), result_preview_json: Some(build_result_preview_json( Some(&draft_profile), &gate, &parse_json_array_or_empty(&session.quality_findings_json), updated_at, )?), checkpoints_json: Some(append_checkpoint_json( &session.checkpoints_json, &build_session_checkpoint_value("foundation-ready", "底稿整理完成", session), )?), updated_at_micros: Some(updated_at), ..CustomWorldAgentSessionPatch::default() }, )?; replace_custom_world_agent_session(ctx, session, next_session); upsert_world_foundation_card(ctx, &session.session_id, &draft_profile, updated_at)?; append_custom_world_action_result_message( ctx, &session.session_id, &input.operation_id, "已整理出第一版世界底稿,并同步生成世界基础卡片。", updated_at, ); let operation = complete_custom_world_operation( ctx, &input.operation_id, &session.session_id, RpgAgentOperationType::DraftFoundation, "底稿已整理", "第一版 foundation draft 已写入会话与世界卡。", updated_at, )?; Ok(build_custom_world_agent_operation_snapshot(&operation)) } fn execute_update_draft_card_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, input: &CustomWorldAgentActionExecuteInput, payload: &JsonMap, ) -> Result { ensure_refining_stage(session.stage, "update_draft_card")?; let card_id = read_required_payload_text(payload, "cardId", "update_draft_card requires cardId")?; let card = ctx .db .custom_world_draft_card() .card_id() .find(&card_id) .filter(|row| row.session_id == session.session_id) .ok_or_else(|| "update_draft_card target card does not exist".to_string())?; let sections = payload .get("sections") .and_then(JsonValue::as_array) .ok_or_else(|| "update_draft_card requires sections".to_string())?; if sections.is_empty() { return Err("update_draft_card requires sections".to_string()); } let mut detail_object = parse_optional_session_object(card.detail_payload_json.as_deref()).unwrap_or_default(); let mut detail_sections = detail_object .get("sections") .and_then(JsonValue::as_array) .cloned() .unwrap_or_else(|| build_fallback_card_sections_json(&card)); for patch in sections { let patch_object = patch .as_object() .ok_or_else(|| "update_draft_card.sections 必须是 object 数组".to_string())?; let section_id = read_required_payload_text( patch_object, "sectionId", "update_draft_card section.sectionId is required", )?; let value = patch_object .get("value") .and_then(JsonValue::as_str) .unwrap_or_default() .trim() .to_string(); let mut updated = false; for existing in &mut detail_sections { if existing.get("id").and_then(JsonValue::as_str) == Some(section_id.as_str()) { if let Some(object) = existing.as_object_mut() { object.insert("value".to_string(), JsonValue::String(value.clone())); } updated = true; break; } } if !updated { detail_sections.push(json!({ "id": section_id, "label": section_id, "value": value, })); } } detail_object.insert("id".to_string(), JsonValue::String(card.card_id.clone())); detail_object.insert( "kind".to_string(), JsonValue::String(card.kind.as_str().to_string()), ); detail_object.insert("title".to_string(), JsonValue::String(card.title.clone())); detail_object.insert( "sections".to_string(), JsonValue::Array(detail_sections.clone()), ); detail_object.insert( "linkedIds".to_string(), serde_json::from_str::(&card.linked_ids_json) .unwrap_or_else(|_| JsonValue::Array(Vec::new())), ); detail_object.insert("locked".to_string(), JsonValue::Bool(false)); detail_object.insert("editable".to_string(), JsonValue::Bool(false)); detail_object.insert( "editableSectionIds".to_string(), JsonValue::Array(Vec::new()), ); detail_object.insert("warningMessages".to_string(), JsonValue::Array(Vec::new())); let updated_title = extract_detail_section_value(&detail_sections, "title") .unwrap_or_else(|| card.title.clone()); let updated_subtitle = extract_detail_section_value(&detail_sections, "subtitle") .unwrap_or_else(|| card.subtitle.clone()); let updated_summary = extract_detail_section_value(&detail_sections, "summary") .unwrap_or_else(|| card.summary.clone()); let detail_payload_json = serde_json::to_string(&JsonValue::Object(detail_object)) .map_err(|error| format!("update_draft_card 无法序列化 detail_payload_json: {error}"))?; replace_custom_world_draft_card( ctx, &card, CustomWorldDraftCard { card_id: card.card_id.clone(), session_id: card.session_id.clone(), kind: card.kind, status: card.status, title: updated_title.clone(), subtitle: updated_subtitle.clone(), summary: updated_summary.clone(), linked_ids_json: card.linked_ids_json.clone(), warning_count: card.warning_count, asset_status: card.asset_status, asset_status_label: card.asset_status_label.clone(), detail_payload_json: Some(detail_payload_json), created_at: card.created_at, updated_at: Timestamp::from_micros_since_unix_epoch(input.submitted_at_micros), }, ); let next_session = sync_session_draft_profile_from_card_update( session, &card, &updated_title, &updated_subtitle, &updated_summary, input.submitted_at_micros, )?; replace_custom_world_agent_session(ctx, session, next_session); append_custom_world_action_result_message( ctx, &session.session_id, &input.operation_id, &format!("已更新卡片《{}》的草稿内容。", updated_title), input.submitted_at_micros, ); let operation = build_and_insert_custom_world_operation( ctx, &input.operation_id, &session.session_id, RpgAgentOperationType::UpdateDraftCard, "卡片已更新", &format!("卡片 {} 的 detail 与摘要字段已同步更新。", card_id), input.submitted_at_micros, ); Ok(build_custom_world_agent_operation_snapshot(&operation)) } fn execute_sync_result_profile_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, input: &CustomWorldAgentActionExecuteInput, payload: &JsonMap, ) -> Result { ensure_result_profile_sync_stage(session.stage, "sync_result_profile")?; let mut profile = payload .get("profile") .and_then(JsonValue::as_object) .cloned() .ok_or_else(|| "sync_result_profile requires profile".to_string())?; if let Some(stable_profile_id) = resolve_stable_agent_draft_profile_id(session) { // 结果页回写时必须沿用当前草稿的稳定身份,避免把同一草稿写成新条目。 profile.insert( "id".to_string(), JsonValue::String(stable_profile_id.clone()), ); upsert_nested_result_profile_id(&mut profile, &stable_profile_id); } let draft_profile = ensure_minimal_draft_profile(profile, &session.seed_text); let gate = summarize_publish_gate_from_json( &session.session_id, session.stage, Some(&draft_profile), &parse_json_array_or_empty(&session.quality_findings_json), ); let next_session = rebuild_custom_world_agent_session_row( session, CustomWorldAgentSessionPatch { draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object( draft_profile.clone(), ))?)), last_assistant_reply: Some(Some("结果页草稿已同步回当前会话。".to_string())), publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value( &gate, ))?)), result_preview_json: Some(build_result_preview_json( Some(&draft_profile), &gate, &parse_json_array_or_empty(&session.quality_findings_json), input.submitted_at_micros, )?), checkpoints_json: Some(append_checkpoint_json( &session.checkpoints_json, &build_session_checkpoint_value("sync-result-profile", "同步结果页草稿", session), )?), updated_at_micros: Some(input.submitted_at_micros), ..CustomWorldAgentSessionPatch::default() }, )?; replace_custom_world_agent_session(ctx, session, next_session); append_custom_world_action_result_message( ctx, &session.session_id, &input.operation_id, "结果页 profile 已回写当前会话,并重建预览。", input.submitted_at_micros, ); let operation = build_and_insert_custom_world_operation( ctx, &input.operation_id, &session.session_id, RpgAgentOperationType::SyncResultProfile, "结果页已同步", "draft_profile_json 与 result_preview 已更新。", input.submitted_at_micros, ); Ok(build_custom_world_agent_operation_snapshot(&operation)) } fn resolve_stable_agent_draft_profile_id(session: &CustomWorldAgentSession) -> Option { 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, stable_profile_id: &str, ) { let legacy_result_profile = profile .entry("legacyResultProfile".to_string()) .or_insert_with(|| JsonValue::Object(JsonMap::new())); if let Some(object) = legacy_result_profile.as_object_mut() { object.insert( "id".to_string(), JsonValue::String(stable_profile_id.to_string()), ); } } fn is_same_agent_draft_profile_candidate( row: &CustomWorldProfile, owner_user_id: &str, source_agent_session_id: &str, ) -> bool { row.owner_user_id == owner_user_id && row.deleted_at.is_none() && row.publication_status == CustomWorldPublicationStatus::Draft && row.source_agent_session_id.as_deref() == Some(source_agent_session_id) } fn execute_publish_world_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, input: &CustomWorldAgentActionExecuteInput, payload: &JsonMap, ) -> Result { 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 gate = summarize_publish_gate_from_json( &session.session_id, session.stage, Some(&draft_profile), &parse_json_array_or_empty(&session.quality_findings_json), ); if !gate.publish_ready { return Err(format!( "当前世界仍有 {} 个 blocker,暂时不能发布", gate.blocker_count )); } let profile_id = payload .get("profileId") .and_then(JsonValue::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .unwrap_or_else(|| gate.profile_id.clone()); let setting_text = payload .get("settingText") .and_then(JsonValue::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .unwrap_or_else(|| session.seed_text.clone()); let legacy_result_profile_json = payload .get("legacyResultProfile") .map(serialize_json_value) .transpose()?; let author_public_user_code = read_optional_text_field(payload, &["authorPublicUserCode"]) .unwrap_or_else(|| build_public_user_code_from_owner_user_id(&session.owner_user_id)); let author_display_name = read_optional_text_field(payload, &["authorDisplayName"]) .unwrap_or_else(|| "陶泥主".to_string()); let publish_result = publish_custom_world_world_record( ctx, CustomWorldPublishWorldInput { session_id: session.session_id.clone(), profile_id, owner_user_id: session.owner_user_id.clone(), public_work_code: None, author_public_user_code, draft_profile_json: serialize_json_value(&JsonValue::Object(draft_profile.clone()))?, legacy_result_profile_json, setting_text, author_display_name, published_at_micros: input.submitted_at_micros, }, )?; append_custom_world_action_result_message( ctx, &session.session_id, &input.operation_id, &format!("正式世界档案已发布:{}。", publish_result.1.profile_id), input.submitted_at_micros, ); let operation = build_and_insert_custom_world_operation( ctx, &input.operation_id, &session.session_id, RpgAgentOperationType::PublishWorld, "世界已发布", &format!( "正式世界档案已写入作品库:{}。", publish_result.1.profile_id ), input.submitted_at_micros, ); Ok(build_custom_world_agent_operation_snapshot(&operation)) } fn execute_revert_checkpoint_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, input: &CustomWorldAgentActionExecuteInput, payload: &JsonMap, ) -> Result { ensure_long_tail_stage(session.stage, "revert_checkpoint")?; let checkpoint_id = read_required_payload_text( payload, "checkpointId", "revert_checkpoint requires checkpointId", )?; let checkpoint = parse_json_array_or_empty(&session.checkpoints_json) .into_iter() .find(|entry| { entry .get("checkpointId") .and_then(JsonValue::as_str) .map(str::trim) == Some(checkpoint_id.as_str()) }) .ok_or_else(|| "revert_checkpoint target checkpoint does not exist".to_string())?; let snapshot = checkpoint .get("snapshot") .and_then(JsonValue::as_object) .cloned() .ok_or_else(|| { "revert_checkpoint target checkpoint does not contain a restorable snapshot".to_string() })?; let restored_stage = snapshot .get("stage") .and_then(JsonValue::as_str) .and_then(parse_rpg_agent_stage) .unwrap_or(session.stage); let restored_progress = snapshot .get("progressPercent") .and_then(JsonValue::as_u64) .and_then(|value| u32::try_from(value).ok()) .unwrap_or(session.progress_percent); let restored_draft_profile = snapshot .get("draftProfile") .and_then(JsonValue::as_object) .cloned(); let restored_quality_findings = snapshot .get("qualityFindings") .and_then(JsonValue::as_array) .cloned() .unwrap_or_else(Vec::new); let gate = summarize_publish_gate_from_json( &session.session_id, restored_stage, restored_draft_profile.as_ref(), &restored_quality_findings, ); let next_session = rebuild_custom_world_agent_session_row( session, CustomWorldAgentSessionPatch { progress_percent: Some(restored_progress), stage: Some(restored_stage), draft_profile_json: Some( restored_draft_profile .as_ref() .map(|value| serialize_json_value(&JsonValue::Object(value.clone()))) .transpose()?, ), last_assistant_reply: Some(Some( "已恢复到所选 checkpoint 的世界草稿状态。".to_string(), )), quality_findings_json: Some(serialize_json_value(&JsonValue::Array( restored_quality_findings, ))?), publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value( &gate, ))?)), result_preview_json: Some(build_result_preview_json( restored_draft_profile.as_ref(), &gate, &parse_json_array_or_empty(&serialize_json_value(&JsonValue::Array( snapshot .get("qualityFindings") .and_then(JsonValue::as_array) .cloned() .unwrap_or_else(Vec::new), ))?), input.submitted_at_micros, )?), updated_at_micros: Some(input.submitted_at_micros), ..CustomWorldAgentSessionPatch::default() }, )?; replace_custom_world_agent_session(ctx, session, next_session); append_custom_world_action_result_message( ctx, &session.session_id, &input.operation_id, "已恢复到所选 checkpoint。", input.submitted_at_micros, ); let operation = build_and_insert_custom_world_operation( ctx, &input.operation_id, &session.session_id, RpgAgentOperationType::RevertCheckpoint, "已回滚 checkpoint", &format!("会话已恢复到 checkpoint {}。", checkpoint_id), input.submitted_at_micros, ); Ok(build_custom_world_agent_operation_snapshot(&operation)) } fn execute_placeholder_custom_world_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, input: &CustomWorldAgentActionExecuteInput, ) -> Result { let operation_type = map_action_name_to_operation_type(input.action.as_str()) .ok_or_else(|| format!("action {} 无法映射到 operation type", input.action))?; append_custom_world_action_result_message( ctx, &session.session_id, &input.operation_id, &format!( "动作 {} 已接入最小兼容占位,后续会继续补真实编排。", input.action ), input.submitted_at_micros, ); let operation = build_and_insert_custom_world_operation( ctx, &input.operation_id, &session.session_id, operation_type, "动作已完成", &format!("{} 当前已走最小兼容闭环。", input.action), input.submitted_at_micros, ); Ok(build_custom_world_agent_operation_snapshot(&operation)) } #[derive(Clone, Debug, Default)] struct CustomWorldAgentSessionPatch { current_turn: Option, progress_percent: Option, stage: Option, focus_card_id: Option>, anchor_content_json: Option, creator_intent_json: Option>, creator_intent_readiness_json: Option, anchor_pack_json: Option>, lock_state_json: Option>, draft_profile_json: Option>, last_assistant_reply: Option>, publish_gate_json: Option>, result_preview_json: Option>, pending_clarifications_json: Option, quality_findings_json: Option, suggested_actions_json: Option, recommended_replies_json: Option, asset_coverage_json: Option, checkpoints_json: Option, updated_at_micros: Option, } #[derive(Clone, Debug, Default)] struct CustomWorldAgentOperationPatch { status: Option, phase_label: Option, phase_detail: Option, progress: Option, error_message: Option>, updated_at_micros: Option, } fn build_custom_world_publish_gate_from_session( session: &CustomWorldAgentSession, ) -> CustomWorldPublishGateSnapshot { let quality_findings = parse_json_array_or_empty(&session.quality_findings_json); summarize_publish_gate_from_json( &session.session_id, session.stage, parse_optional_session_object(session.draft_profile_json.as_deref()).as_ref(), &quality_findings, ) } fn summarize_publish_gate_from_json( session_id: &str, stage: RpgAgentStage, draft_profile: Option<&JsonMap>, quality_findings: &[JsonValue], ) -> CustomWorldPublishGateSnapshot { let profile_id = draft_profile .and_then(|profile| read_optional_text_field(profile, &["legacyResultProfile.id", "id"])) .unwrap_or_else(|| format!("agent-draft-{session_id}")); let mut blockers = Vec::new(); if draft_profile.is_none() { blockers.push(CustomWorldPublishBlockerSnapshot { blocker_id: "publish_empty_draft".to_string(), code: "publish_empty_draft".to_string(), message: "当前世界草稿为空,无法发布。".to_string(), }); } if let Some(profile) = draft_profile { if read_optional_text_field( profile, &[ "worldHook", "creatorIntent.worldHook", "anchorContent.worldPromise", "anchorContent.worldPromise.hook", "settingText", ], ) .is_none() { blockers.push(CustomWorldPublishBlockerSnapshot { blocker_id: "publish_missing_world_hook".to_string(), code: "publish_missing_world_hook".to_string(), message: "当前世界缺少 world hook,发布前需要先补齐世界一句话钩子。".to_string(), }); } if read_optional_text_field( profile, &[ "playerPremise", "creatorIntent.playerPremise", "anchorContent.playerEntryPoint", "anchorContent.playerEntryPoint.openingIdentity", "anchorContent.playerEntryPoint.openingProblem", "anchorContent.playerEntryPoint.entryMotivation", ], ) .is_none() { blockers.push(CustomWorldPublishBlockerSnapshot { blocker_id: "publish_missing_player_premise".to_string(), code: "publish_missing_player_premise".to_string(), message: "当前世界缺少玩家身份与切入前提,发布前需要先补齐玩家 premise。" .to_string(), }); } if !json_array_has_non_empty_text(profile.get("coreConflicts")) { blockers.push(CustomWorldPublishBlockerSnapshot { blocker_id: "publish_missing_core_conflict".to_string(), code: "publish_missing_core_conflict".to_string(), message: "当前世界缺少核心冲突,发布前需要先补齐核心冲突。".to_string(), }); } let has_main_chapter = profile .get("chapters") .and_then(JsonValue::as_array) .map(|value| !value.is_empty()) .unwrap_or(false) || profile .get("sceneChapterBlueprints") .and_then(JsonValue::as_array) .map(|value| !value.is_empty()) .unwrap_or(false) || profile .get("sceneChapters") .and_then(JsonValue::as_array) .map(|value| !value.is_empty()) .unwrap_or(false); if !has_main_chapter { blockers.push(CustomWorldPublishBlockerSnapshot { blocker_id: "publish_missing_main_chapter".to_string(), code: "publish_missing_main_chapter".to_string(), message: "当前世界还没有主线章节草稿,发布前至少要保留主线第一幕。".to_string(), }); } let has_scene_act = profile .get("sceneChapterBlueprints") .or_else(|| profile.get("sceneChapters")) .and_then(JsonValue::as_array) .map(|chapters| { chapters.iter().any(|chapter| { chapter .get("acts") .and_then(JsonValue::as_array) .map(|acts| !acts.is_empty()) .unwrap_or(false) }) }) .unwrap_or(false); if !has_scene_act { blockers.push(CustomWorldPublishBlockerSnapshot { blocker_id: "publish_missing_first_act".to_string(), code: "publish_missing_first_act".to_string(), message: "当前世界还没有主线第一幕,发布前至少要保留一个场景幕。".to_string(), }); } } for finding in quality_findings { if finding.get("severity").and_then(JsonValue::as_str) == Some("blocker") { blockers.push(CustomWorldPublishBlockerSnapshot { blocker_id: finding .get("id") .and_then(JsonValue::as_str) .unwrap_or("publish-quality-blocker") .to_string(), code: finding .get("code") .and_then(JsonValue::as_str) .unwrap_or("publish_quality_blocker") .to_string(), message: finding .get("message") .and_then(JsonValue::as_str) .unwrap_or("当前世界仍存在 blocker。") .to_string(), }); } } let blocker_count = blockers.len() as u32; let publish_ready = blocker_count == 0; CustomWorldPublishGateSnapshot { profile_id, blockers, blocker_count, publish_ready, can_enter_world: stage == RpgAgentStage::Published && publish_ready, } } fn publish_gate_to_json_value(gate: &CustomWorldPublishGateSnapshot) -> JsonValue { json!({ "profileId": gate.profile_id, "blockers": gate.blockers.iter().map(|entry| { json!({ "id": entry.blocker_id, "code": entry.code, "message": entry.message, }) }).collect::>(), "blockerCount": gate.blocker_count, "publishReady": gate.publish_ready, "canEnterWorld": gate.can_enter_world, }) } fn build_result_preview_json( draft_profile: Option<&JsonMap>, gate: &CustomWorldPublishGateSnapshot, quality_findings: &[JsonValue], generated_at_micros: i64, ) -> Result, String> { let Some(profile) = draft_profile else { return Ok(None); }; serialize_json_value(&json!({ "preview": JsonValue::Object(profile.clone()), "source": "session_preview", "generatedAt": format_timestamp_micros(generated_at_micros), "qualityFindings": quality_findings, "blockers": gate.blockers.iter().map(|entry| { json!({ "id": entry.blocker_id, "code": entry.code, "message": entry.message, }) }).collect::>(), "publishReady": gate.publish_ready, "canEnterWorld": gate.can_enter_world, })) .map(Some) } fn build_supported_actions_json( stage: RpgAgentStage, progress_percent: u32, gate: &CustomWorldPublishGateSnapshot, checkpoints: &[JsonValue], ) -> Vec { let has_checkpoint = checkpoints .iter() .any(|entry| entry.get("snapshot").is_some()); let draft_refining_enabled = matches!( stage, RpgAgentStage::ObjectRefining | RpgAgentStage::VisualRefining ); let long_tail_enabled = matches!( stage, RpgAgentStage::ObjectRefining | RpgAgentStage::VisualRefining | RpgAgentStage::LongTailReview | RpgAgentStage::ReadyToPublish ); vec![ build_supported_action_json( "draft_foundation", progress_percent >= 100, (progress_percent < 100).then(|| "draft_foundation requires progressPercent >= 100".to_string()), ), build_supported_action_json( "update_draft_card", draft_refining_enabled, (!draft_refining_enabled).then(|| { "update_draft_card is only available during object_refining or visual_refining" .to_string() }), ), build_supported_action_json( "sync_result_profile", draft_refining_enabled, (!draft_refining_enabled).then(|| { "sync_result_profile is only available during object_refining or visual_refining" .to_string() }), ), build_supported_action_json( "generate_characters", draft_refining_enabled, (!draft_refining_enabled).then(|| { "generate_characters is only available during object_refining or visual_refining" .to_string() }), ), build_supported_action_json( "generate_landmarks", draft_refining_enabled, (!draft_refining_enabled).then(|| { "generate_landmarks is only available during object_refining or visual_refining" .to_string() }), ), build_supported_action_json( "generate_role_assets", draft_refining_enabled, (!draft_refining_enabled).then(|| { "generate_role_assets is only available during object_refining or visual_refining" .to_string() }), ), build_supported_action_json( "sync_role_assets", draft_refining_enabled, (!draft_refining_enabled).then(|| { "sync_role_assets is only available during object_refining or visual_refining" .to_string() }), ), build_supported_action_json( "generate_scene_assets", draft_refining_enabled, (!draft_refining_enabled).then(|| { "generate_scene_assets is only available during object_refining or visual_refining" .to_string() }), ), build_supported_action_json( "sync_scene_assets", draft_refining_enabled, (!draft_refining_enabled).then(|| { "sync_scene_assets is only available during object_refining or visual_refining" .to_string() }), ), build_supported_action_json( "expand_long_tail", long_tail_enabled, (!long_tail_enabled).then(|| { "expand_long_tail is only available during object_refining, visual_refining, long_tail_review or ready_to_publish".to_string() }), ), build_supported_action_json( "publish_world", long_tail_enabled && gate.publish_ready, (!long_tail_enabled) .then(|| { "publish_world is only available during object_refining, visual_refining, long_tail_review or ready_to_publish".to_string() }) .or_else(|| (!gate.publish_ready).then(|| "publish_world requires publish gate without blockers".to_string())), ), build_supported_action_json( "revert_checkpoint", long_tail_enabled && has_checkpoint, (!long_tail_enabled) .then(|| { "revert_checkpoint is only available during object_refining, visual_refining, long_tail_review or ready_to_publish".to_string() }) .or_else(|| (!has_checkpoint).then(|| "revert_checkpoint requires at least one restorable checkpoint snapshot".to_string())), ), ] } fn build_supported_action_json(action: &str, enabled: bool, reason: Option) -> JsonValue { json!({ "action": action, "enabled": enabled, "reason": reason, }) } fn build_custom_world_draft_card_detail_snapshot( card: &CustomWorldDraftCard, ) -> Result { if let Some(detail_payload_json) = card.detail_payload_json.as_deref() { let detail_value = serde_json::from_str::(detail_payload_json).map_err(|error| { format!("custom_world_draft_card.detail_payload_json 非法: {error}") })?; if let Some(object) = detail_value.as_object() { let sections = object .get("sections") .and_then(JsonValue::as_array) .map(|entries| { entries .iter() .filter_map(|entry| { let object = entry.as_object()?; Some(CustomWorldDraftCardDetailSectionSnapshot { section_id: object.get("id")?.as_str()?.to_string(), label: object .get("label") .and_then(JsonValue::as_str) .unwrap_or_default() .to_string(), value: object .get("value") .and_then(JsonValue::as_str) .unwrap_or_default() .to_string(), }) }) .collect::>() }) .unwrap_or_else(|| build_fallback_card_sections(&card)); return Ok(CustomWorldDraftCardDetailSnapshot { card_id: card.card_id.clone(), kind: card.kind, title: object .get("title") .and_then(JsonValue::as_str) .unwrap_or(card.title.as_str()) .to_string(), sections, linked_ids_json: card.linked_ids_json.clone(), locked: object .get("locked") .and_then(JsonValue::as_bool) .unwrap_or(false), editable: object .get("editable") .and_then(JsonValue::as_bool) .unwrap_or(false), editable_section_ids_json: serialize_json_value( object .get("editableSectionIds") .unwrap_or(&JsonValue::Array(Vec::new())), )?, warning_messages_json: serialize_json_value( object .get("warningMessages") .unwrap_or(&JsonValue::Array(Vec::new())), )?, asset_status: card.asset_status, asset_status_label: card.asset_status_label.clone(), }); } } Ok(CustomWorldDraftCardDetailSnapshot { card_id: card.card_id.clone(), kind: card.kind, title: card.title.clone(), sections: build_fallback_card_sections(card), linked_ids_json: card.linked_ids_json.clone(), locked: false, editable: false, editable_section_ids_json: "[]".to_string(), warning_messages_json: "[]".to_string(), asset_status: card.asset_status, asset_status_label: card.asset_status_label.clone(), }) } fn build_fallback_card_sections( card: &CustomWorldDraftCard, ) -> Vec { vec![ CustomWorldDraftCardDetailSectionSnapshot { section_id: "title".to_string(), label: "标题".to_string(), value: card.title.clone(), }, CustomWorldDraftCardDetailSectionSnapshot { section_id: "subtitle".to_string(), label: "副标题".to_string(), value: card.subtitle.clone(), }, CustomWorldDraftCardDetailSectionSnapshot { section_id: "summary".to_string(), label: "摘要".to_string(), value: card.summary.clone(), }, ] } fn build_fallback_card_sections_json(card: &CustomWorldDraftCard) -> Vec { build_fallback_card_sections(card) .into_iter() .map(|section| { json!({ "id": section.section_id, "label": section.label, "value": section.value, }) }) .collect() } fn rebuild_custom_world_agent_session_row( current: &CustomWorldAgentSession, patch: CustomWorldAgentSessionPatch, ) -> Result { Ok(CustomWorldAgentSession { session_id: current.session_id.clone(), owner_user_id: current.owner_user_id.clone(), seed_text: current.seed_text.clone(), current_turn: patch.current_turn.unwrap_or(current.current_turn), progress_percent: patch.progress_percent.unwrap_or(current.progress_percent), stage: patch.stage.unwrap_or(current.stage), focus_card_id: patch .focus_card_id .unwrap_or_else(|| current.focus_card_id.clone()), anchor_content_json: patch .anchor_content_json .unwrap_or_else(|| current.anchor_content_json.clone()), creator_intent_json: patch .creator_intent_json .unwrap_or_else(|| current.creator_intent_json.clone()), creator_intent_readiness_json: patch .creator_intent_readiness_json .unwrap_or_else(|| current.creator_intent_readiness_json.clone()), anchor_pack_json: patch .anchor_pack_json .unwrap_or_else(|| current.anchor_pack_json.clone()), lock_state_json: patch .lock_state_json .unwrap_or_else(|| current.lock_state_json.clone()), draft_profile_json: patch .draft_profile_json .unwrap_or_else(|| current.draft_profile_json.clone()), last_assistant_reply: patch .last_assistant_reply .unwrap_or_else(|| current.last_assistant_reply.clone()), publish_gate_json: patch .publish_gate_json .unwrap_or_else(|| current.publish_gate_json.clone()), result_preview_json: patch .result_preview_json .unwrap_or_else(|| current.result_preview_json.clone()), pending_clarifications_json: patch .pending_clarifications_json .unwrap_or_else(|| current.pending_clarifications_json.clone()), quality_findings_json: patch .quality_findings_json .unwrap_or_else(|| current.quality_findings_json.clone()), suggested_actions_json: patch .suggested_actions_json .unwrap_or_else(|| current.suggested_actions_json.clone()), recommended_replies_json: patch .recommended_replies_json .unwrap_or_else(|| current.recommended_replies_json.clone()), asset_coverage_json: patch .asset_coverage_json .unwrap_or_else(|| current.asset_coverage_json.clone()), checkpoints_json: patch .checkpoints_json .unwrap_or_else(|| current.checkpoints_json.clone()), created_at: current.created_at, updated_at: Timestamp::from_micros_since_unix_epoch( patch .updated_at_micros .unwrap_or_else(|| current.updated_at.to_micros_since_unix_epoch()), ), }) } fn rebuild_custom_world_agent_operation_row( current: &CustomWorldAgentOperation, patch: CustomWorldAgentOperationPatch, ) -> Result { let phase_label = patch .phase_label .unwrap_or_else(|| current.phase_label.clone()); let progress = patch.progress.unwrap_or(current.progress); validate_custom_world_agent_operation_fields( ¤t.operation_id, ¤t.session_id, &phase_label, progress, ) .map_err(|error| error.to_string())?; Ok(CustomWorldAgentOperation { operation_id: current.operation_id.clone(), session_id: current.session_id.clone(), operation_type: current.operation_type, status: patch.status.unwrap_or(current.status), phase_label, phase_detail: patch .phase_detail .unwrap_or_else(|| current.phase_detail.clone()), progress, error_message: patch .error_message .unwrap_or_else(|| current.error_message.clone()), created_at: current.created_at, updated_at: Timestamp::from_micros_since_unix_epoch( patch .updated_at_micros .unwrap_or_else(|| current.updated_at.to_micros_since_unix_epoch()), ), }) } fn replace_custom_world_agent_session( ctx: &ReducerContext, current: &CustomWorldAgentSession, next: CustomWorldAgentSession, ) { ctx.db .custom_world_agent_session() .session_id() .delete(¤t.session_id); ctx.db.custom_world_agent_session().insert(next); } fn replace_custom_world_agent_operation( ctx: &ReducerContext, current: &CustomWorldAgentOperation, next: CustomWorldAgentOperation, ) { ctx.db .custom_world_agent_operation() .operation_id() .delete(¤t.operation_id); ctx.db.custom_world_agent_operation().insert(next); } fn replace_custom_world_draft_card( ctx: &ReducerContext, current: &CustomWorldDraftCard, next: CustomWorldDraftCard, ) { ctx.db .custom_world_draft_card() .card_id() .delete(¤t.card_id); ctx.db.custom_world_draft_card().insert(next); } fn complete_custom_world_operation( ctx: &ReducerContext, operation_id: &str, session_id: &str, operation_type: RpgAgentOperationType, phase_label: &str, phase_detail: &str, timestamp_micros: i64, ) -> Result { if let Some(current) = ctx .db .custom_world_agent_operation() .operation_id() .find(&operation_id.to_string()) { if current.session_id != session_id { return Err("custom_world_agent_operation.session_id 不匹配".to_string()); } if current.operation_type != operation_type { return Err("custom_world_agent_operation.operation_type 不匹配".to_string()); } let next = rebuild_custom_world_agent_operation_row( ¤t, CustomWorldAgentOperationPatch { status: Some(RpgAgentOperationStatus::Completed), phase_label: Some(phase_label.to_string()), phase_detail: Some(phase_detail.to_string()), progress: Some(100), error_message: Some(None), updated_at_micros: Some(timestamp_micros), }, )?; replace_custom_world_agent_operation(ctx, ¤t, next.clone()); return Ok(next); } Ok(build_and_insert_custom_world_operation( ctx, operation_id, session_id, operation_type, phase_label, phase_detail, timestamp_micros, )) } fn build_and_insert_custom_world_operation( ctx: &ReducerContext, operation_id: &str, session_id: &str, operation_type: RpgAgentOperationType, phase_label: &str, phase_detail: &str, timestamp_micros: i64, ) -> CustomWorldAgentOperation { let row = CustomWorldAgentOperation { operation_id: operation_id.to_string(), session_id: session_id.to_string(), operation_type, status: RpgAgentOperationStatus::Completed, phase_label: phase_label.to_string(), phase_detail: phase_detail.to_string(), progress: 100, error_message: None, created_at: Timestamp::from_micros_since_unix_epoch(timestamp_micros), updated_at: Timestamp::from_micros_since_unix_epoch(timestamp_micros), }; ctx.db.custom_world_agent_operation().insert(row) } fn append_custom_world_action_result_message( ctx: &ReducerContext, session_id: &str, operation_id: &str, text: &str, timestamp_micros: i64, ) { let row = CustomWorldAgentMessage { message_id: format!("message-action-{}-{}", operation_id, timestamp_micros), session_id: session_id.to_string(), role: RpgAgentMessageRole::Assistant, kind: RpgAgentMessageKind::ActionResult, text: text.to_string(), related_operation_id: Some(operation_id.to_string()), created_at: Timestamp::from_micros_since_unix_epoch(timestamp_micros), }; ctx.db.custom_world_agent_message().insert(row); } fn upsert_world_foundation_card( ctx: &ReducerContext, session_id: &str, draft_profile: &JsonMap, updated_at_micros: i64, ) -> Result<(), String> { let card_id = build_world_foundation_card_id(session_id); let existing_card = ctx .db .custom_world_draft_card() .card_id() .find(&card_id) .filter(|row| row.session_id == session_id); let title = read_optional_text_field(draft_profile, &["name", "title"]) .unwrap_or_else(|| "世界底稿".to_string()); let subtitle = read_optional_text_field(draft_profile, &["subtitle"]).unwrap_or_default(); let summary = read_optional_text_field(draft_profile, &["summary"]) .unwrap_or_else(|| "第一版世界底稿已生成。".to_string()); let detail_payload_json = serialize_json_value(&json!({ "id": card_id, "kind": "world", "title": title, "sections": [ { "id": "title", "label": "标题", "value": read_optional_text_field(draft_profile, &["name", "title"]).unwrap_or_else(|| "世界底稿".to_string()) }, { "id": "subtitle", "label": "副标题", "value": subtitle }, { "id": "summary", "label": "摘要", "value": summary }, ], "linkedIds": [], "locked": false, "editable": false, "editableSectionIds": [], "warningMessages": [], }))?; if let Some(existing) = existing_card { replace_custom_world_draft_card( ctx, &existing, CustomWorldDraftCard { card_id: existing.card_id.clone(), session_id: existing.session_id.clone(), kind: RpgAgentDraftCardKind::World, status: RpgAgentDraftCardStatus::Confirmed, title: read_optional_text_field(draft_profile, &["name", "title"]) .unwrap_or_else(|| "世界底稿".to_string()), subtitle: read_optional_text_field(draft_profile, &["subtitle"]) .unwrap_or_default(), summary: read_optional_text_field(draft_profile, &["summary"]) .unwrap_or_else(|| "第一版世界底稿已生成。".to_string()), linked_ids_json: "[]".to_string(), warning_count: 0, asset_status: None, asset_status_label: None, detail_payload_json: Some(detail_payload_json), created_at: existing.created_at, updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), }, ); } else { ctx.db .custom_world_draft_card() .insert(CustomWorldDraftCard { card_id, session_id: session_id.to_string(), kind: RpgAgentDraftCardKind::World, status: RpgAgentDraftCardStatus::Confirmed, title: read_optional_text_field(draft_profile, &["name", "title"]) .unwrap_or_else(|| "世界底稿".to_string()), subtitle: read_optional_text_field(draft_profile, &["subtitle"]) .unwrap_or_default(), summary: read_optional_text_field(draft_profile, &["summary"]) .unwrap_or_else(|| "第一版世界底稿已生成。".to_string()), linked_ids_json: "[]".to_string(), warning_count: 0, asset_status: None, asset_status_label: None, detail_payload_json: Some(detail_payload_json), created_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), }); } Ok(()) } fn build_world_foundation_card_id(session_id: &str) -> String { // `custom_world_draft_card.card_id` 是全局主键,世界底稿卡必须带上会话维度,避免多会话写入时触发唯一键冲突。 format!("custom-world:{session_id}:world-foundation") } fn sync_session_draft_profile_from_card_update( session: &CustomWorldAgentSession, card: &CustomWorldDraftCard, updated_title: &str, updated_subtitle: &str, updated_summary: &str, updated_at_micros: i64, ) -> Result { let mut draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref()) .unwrap_or_else(|| build_minimal_draft_profile_from_seed(&session.seed_text)); if card.kind == RpgAgentDraftCardKind::World { draft_profile.insert( "name".to_string(), JsonValue::String(updated_title.to_string()), ); draft_profile.insert( "subtitle".to_string(), JsonValue::String(updated_subtitle.to_string()), ); draft_profile.insert( "summary".to_string(), JsonValue::String(updated_summary.to_string()), ); } let gate = summarize_publish_gate_from_json( &session.session_id, session.stage, Some(&draft_profile), &parse_json_array_or_empty(&session.quality_findings_json), ); rebuild_custom_world_agent_session_row( session, CustomWorldAgentSessionPatch { draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object( draft_profile.clone(), ))?)), publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value( &gate, ))?)), result_preview_json: Some(build_result_preview_json( Some(&draft_profile), &gate, &parse_json_array_or_empty(&session.quality_findings_json), updated_at_micros, )?), last_assistant_reply: Some(Some(format!("卡片《{}》已更新。", updated_title))), updated_at_micros: Some(updated_at_micros), ..CustomWorldAgentSessionPatch::default() }, ) } fn ensure_refining_stage(stage: RpgAgentStage, action: &str) -> Result<(), String> { if matches!( stage, RpgAgentStage::ObjectRefining | RpgAgentStage::VisualRefining ) { Ok(()) } else { Err(format!( "{action} is only available during object_refining or visual_refining" )) } } fn ensure_result_profile_sync_stage(stage: RpgAgentStage, action: &str) -> Result<(), String> { if matches!( stage, RpgAgentStage::ObjectRefining | RpgAgentStage::VisualRefining | RpgAgentStage::LongTailReview | RpgAgentStage::ReadyToPublish ) { Ok(()) } else { Err(format!( "{action} is only available during object_refining, visual_refining, long_tail_review or ready_to_publish" )) } } fn ensure_long_tail_stage(stage: RpgAgentStage, action: &str) -> Result<(), String> { if matches!( stage, RpgAgentStage::ObjectRefining | RpgAgentStage::VisualRefining | RpgAgentStage::LongTailReview | RpgAgentStage::ReadyToPublish ) { Ok(()) } else { Err(format!( "{action} is only available during object_refining, visual_refining, long_tail_review or ready_to_publish" )) } } fn ensure_publishable_stage(stage: RpgAgentStage, action: &str) -> Result<(), String> { ensure_long_tail_stage(stage, action) } fn map_action_name_to_operation_type(action: &str) -> Option { match action { "draft_foundation" => Some(RpgAgentOperationType::DraftFoundation), "update_draft_card" => Some(RpgAgentOperationType::UpdateDraftCard), "sync_result_profile" => Some(RpgAgentOperationType::SyncResultProfile), "generate_characters" => Some(RpgAgentOperationType::GenerateCharacters), "generate_landmarks" => Some(RpgAgentOperationType::GenerateLandmarks), "generate_role_assets" => Some(RpgAgentOperationType::GenerateRoleAssets), "sync_role_assets" => Some(RpgAgentOperationType::SyncRoleAssets), "generate_scene_assets" => Some(RpgAgentOperationType::GenerateSceneAssets), "sync_scene_assets" => Some(RpgAgentOperationType::SyncSceneAssets), "expand_long_tail" => Some(RpgAgentOperationType::ExpandLongTail), "publish_world" => Some(RpgAgentOperationType::PublishWorld), "revert_checkpoint" => Some(RpgAgentOperationType::RevertCheckpoint), _ => None, } } fn parse_rpg_agent_stage(value: &str) -> Option { match value.trim() { "collecting_intent" => Some(RpgAgentStage::CollectingIntent), "clarifying" => Some(RpgAgentStage::Clarifying), "foundation_review" => Some(RpgAgentStage::FoundationReview), "object_refining" => Some(RpgAgentStage::ObjectRefining), "visual_refining" => Some(RpgAgentStage::VisualRefining), "long_tail_review" => Some(RpgAgentStage::LongTailReview), "ready_to_publish" => Some(RpgAgentStage::ReadyToPublish), "published" => Some(RpgAgentStage::Published), "error" => Some(RpgAgentStage::Error), _ => None, } } fn resolve_rpg_agent_stage_label(stage: RpgAgentStage) -> &'static str { match stage { RpgAgentStage::CollectingIntent => "收集世界锚点", RpgAgentStage::Clarifying => "补齐关键锚点", RpgAgentStage::FoundationReview => "准备整理底稿", RpgAgentStage::ObjectRefining => "待完善草稿", RpgAgentStage::VisualRefining => "视觉工坊", RpgAgentStage::LongTailReview => "扩展长尾", RpgAgentStage::ReadyToPublish => "准备发布", RpgAgentStage::Published => "已发布", RpgAgentStage::Error => "发生错误", } } fn parse_optional_session_object(value: Option<&str>) -> Option> { value .map(str::trim) .filter(|value| !value.is_empty()) .and_then(|value| serde_json::from_str::(value).ok()) .and_then(|value| value.as_object().cloned()) } fn parse_json_array_or_empty(raw: &str) -> Vec { serde_json::from_str::(raw) .ok() .and_then(|value| value.as_array().cloned()) .unwrap_or_default() } fn serialize_json_value(value: &JsonValue) -> Result { serde_json::to_string(value).map_err(|error| format!("JSON 序列化失败: {error}")) } fn read_required_payload_text( payload: &JsonMap, key: &str, error_message: &str, ) -> Result { payload .get(key) .and_then(JsonValue::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .ok_or_else(|| error_message.to_string()) } fn read_optional_text_field(object: &JsonMap, keys: &[&str]) -> Option { for key in keys { let mut current = JsonValue::Object(object.clone()); let mut found = true; for segment in key.split('.') { if let Some(next) = current.get(segment) { current = next.clone(); } else { found = false; break; } } if found { if let Some(value) = current .as_str() .map(str::trim) .filter(|value| !value.is_empty()) { return Some(value.to_string()); } } } None } fn resolve_session_work_title( session: &CustomWorldAgentSession, draft_profile: Option<&JsonMap>, ) -> String { draft_profile .and_then(|profile| read_optional_text_field(profile, &["name", "title"])) .or_else(|| { let seed = session.seed_text.trim(); (!seed.is_empty()).then(|| seed.to_string()) }) .unwrap_or_else(|| "未命名草稿".to_string()) } fn resolve_session_work_summary( session: &CustomWorldAgentSession, draft_profile: Option<&JsonMap>, ) -> String { draft_profile .and_then(|profile| read_optional_text_field(profile, &["summary"])) .or_else(|| { let seed = session.seed_text.trim(); (!seed.is_empty()).then(|| seed.to_string()) }) .unwrap_or_else(|| "还在收集你的世界锚点。".to_string()) } fn resolve_session_work_subtitle( draft_profile: Option<&JsonMap>, stage_label: Option<&str>, ) -> String { draft_profile .and_then(|profile| read_optional_text_field(profile, &["subtitle"])) .or_else(|| stage_label.map(ToOwned::to_owned)) .unwrap_or_default() } fn resolve_session_work_cover_image_src( draft_profile: Option<&JsonMap>, ) -> Option { let profile = draft_profile?; if let Some(camp) = profile.get("camp").and_then(JsonValue::as_object) { if let Some(image_src) = read_optional_text_field(camp, &["imageSrc"]) { return Some(image_src); } } if let Some(landmarks) = profile.get("landmarks").and_then(JsonValue::as_array) { for landmark in landmarks { if let Some(object) = landmark.as_object() { if let Some(image_src) = read_optional_text_field(object, &["imageSrc"]) { return Some(image_src); } } } } None } fn resolve_session_work_counts( ctx: &ReducerContext, session: &CustomWorldAgentSession, draft_profile: Option<&JsonMap>, ) -> (u32, u32) { if let Some(profile) = draft_profile { let role_count = profile .get("playableNpcs") .and_then(JsonValue::as_array) .map(|entries| entries.len() as u32) .unwrap_or(0) + profile .get("storyNpcs") .and_then(JsonValue::as_array) .map(|entries| entries.len() as u32) .unwrap_or(0); let landmark_count = profile .get("landmarks") .and_then(JsonValue::as_array) .map(|entries| entries.len() as u32) .unwrap_or(0); return (role_count, landmark_count); } let mut role_count = 0u32; let mut landmark_count = 0u32; for card in ctx .db .custom_world_draft_card() .iter() .filter(|row| row.session_id == session.session_id) { match card.kind { RpgAgentDraftCardKind::Character => { role_count = role_count.saturating_add(1); } RpgAgentDraftCardKind::Landmark => { landmark_count = landmark_count.saturating_add(1); } _ => {} } } (role_count, landmark_count) } fn ensure_minimal_draft_profile( mut profile: JsonMap, seed_text: &str, ) -> JsonMap { if read_optional_text_field(&profile, &["name", "title"]).is_none() { profile.insert( "name".to_string(), JsonValue::String(seed_text.trim().to_string().if_empty("未命名草稿")), ); } if read_optional_text_field(&profile, &["summary"]).is_none() { profile.insert( "summary".to_string(), JsonValue::String( (!seed_text.trim().is_empty()) .then(|| seed_text.trim().to_string()) .unwrap_or_else(|| "还在收集你的世界锚点。".to_string()), ), ); } profile .entry("subtitle".to_string()) .or_insert_with(|| JsonValue::String(String::new())); profile .entry("worldHook".to_string()) .or_insert_with(|| JsonValue::String(String::new())); profile .entry("playerPremise".to_string()) .or_insert_with(|| JsonValue::String(String::new())); profile .entry("coreConflicts".to_string()) .or_insert_with(|| JsonValue::Array(Vec::new())); profile .entry("playableNpcs".to_string()) .or_insert_with(|| JsonValue::Array(Vec::new())); profile .entry("storyNpcs".to_string()) .or_insert_with(|| JsonValue::Array(Vec::new())); profile .entry("landmarks".to_string()) .or_insert_with(|| JsonValue::Array(Vec::new())); profile .entry("chapters".to_string()) .or_insert_with(|| JsonValue::Array(Vec::new())); profile .entry("sceneChapters".to_string()) .or_insert_with(|| JsonValue::Array(Vec::new())); profile .entry("sceneChapterBlueprints".to_string()) .or_insert_with(|| JsonValue::Array(Vec::new())); profile } fn build_minimal_draft_profile_from_seed(seed_text: &str) -> JsonMap { ensure_minimal_draft_profile(JsonMap::new(), seed_text) } fn build_session_checkpoint_value( checkpoint_id_suffix: &str, label: &str, session: &CustomWorldAgentSession, ) -> JsonValue { json!({ "checkpointId": format!("checkpoint-{}-{}", session.session_id, checkpoint_id_suffix), "createdAt": format_timestamp_micros(session.updated_at.to_micros_since_unix_epoch()), "label": label, "snapshot": { "stage": session.stage.as_str(), "progressPercent": session.progress_percent, "draftProfile": parse_optional_session_object(session.draft_profile_json.as_deref()).map(JsonValue::Object), "qualityFindings": parse_json_array_or_empty(&session.quality_findings_json), } }) } fn append_checkpoint_json(current: &str, checkpoint: &JsonValue) -> Result { let mut checkpoints = parse_json_array_or_empty(current); checkpoints.push(checkpoint.clone()); serialize_json_value(&JsonValue::Array(checkpoints)) } fn extract_detail_section_value(sections: &[JsonValue], target_id: &str) -> Option { sections.iter().find_map(|entry| { let object = entry.as_object()?; (object.get("id").and_then(JsonValue::as_str) == Some(target_id)).then(|| { object .get("value") .and_then(JsonValue::as_str) .unwrap_or_default() .to_string() }) }) } fn json_array_has_non_empty_text(value: Option<&JsonValue>) -> bool { value .and_then(JsonValue::as_array) .map(|entries| { entries.iter().any(|entry| { entry .as_str() .map(str::trim) .filter(|text| !text.is_empty()) .is_some() }) }) .unwrap_or(false) } trait IfEmptyString { fn if_empty(self, fallback: &str) -> String; } impl IfEmptyString for String { fn if_empty(self, fallback: &str) -> String { if self.trim().is_empty() { fallback.to_string() } else { self } } } fn mark_custom_world_agent_session_published( ctx: &ReducerContext, session_id: &str, owner_user_id: &str, updated_at_micros: i64, ) -> Result { let existing = ctx .db .custom_world_agent_session() .session_id() .find(&session_id.to_string()) .filter(|row| row.owner_user_id == owner_user_id) .ok_or_else(|| "custom_world_agent_session 不存在,无法推进到 published".to_string())?; ctx.db .custom_world_agent_session() .session_id() .delete(&existing.session_id); let next_row = CustomWorldAgentSession { session_id: existing.session_id.clone(), owner_user_id: existing.owner_user_id.clone(), seed_text: existing.seed_text.clone(), current_turn: existing.current_turn, progress_percent: existing.progress_percent, stage: RpgAgentStage::Published, focus_card_id: existing.focus_card_id.clone(), anchor_content_json: existing.anchor_content_json.clone(), creator_intent_json: existing.creator_intent_json.clone(), creator_intent_readiness_json: existing.creator_intent_readiness_json.clone(), anchor_pack_json: existing.anchor_pack_json.clone(), lock_state_json: existing.lock_state_json.clone(), draft_profile_json: existing.draft_profile_json.clone(), last_assistant_reply: existing.last_assistant_reply.clone(), publish_gate_json: existing.publish_gate_json.clone(), result_preview_json: existing.result_preview_json.clone(), pending_clarifications_json: existing.pending_clarifications_json.clone(), quality_findings_json: existing.quality_findings_json.clone(), suggested_actions_json: existing.suggested_actions_json.clone(), recommended_replies_json: existing.recommended_replies_json.clone(), asset_coverage_json: existing.asset_coverage_json.clone(), checkpoints_json: existing.checkpoints_json.clone(), created_at: existing.created_at, updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), }; ctx.db.custom_world_agent_session().insert(next_row); Ok(RpgAgentStage::Published) } fn sync_custom_world_gallery_entry_from_profile( ctx: &ReducerContext, profile: &CustomWorldProfile, ) -> Result { let published_at = profile .published_at .ok_or_else(|| "published profile 缺少 published_at,无法同步 gallery".to_string())?; ctx.db .custom_world_gallery_entry() .profile_id() .delete(&profile.profile_id); let row = CustomWorldGalleryEntry { profile_id: profile.profile_id.clone(), owner_user_id: profile.owner_user_id.clone(), public_work_code: profile.public_work_code.clone().ok_or_else(|| { "published profile 缺少 public_work_code,无法同步 gallery".to_string() })?, author_public_user_code: profile.author_public_user_code.clone().ok_or_else(|| { "published profile 缺少 author_public_user_code,无法同步 gallery".to_string() })?, author_display_name: profile.author_display_name.clone(), world_name: profile.world_name.clone(), subtitle: profile.subtitle.clone(), summary_text: profile.summary_text.clone(), cover_image_src: profile.cover_image_src.clone(), theme_mode: profile.theme_mode, playable_npc_count: profile.playable_npc_count, landmark_count: profile.landmark_count, play_count: profile.play_count, remix_count: profile.remix_count, like_count: profile.like_count, published_at, updated_at: profile.updated_at, }; let inserted = ctx.db.custom_world_gallery_entry().insert(row); Ok(build_custom_world_gallery_entry_snapshot(ctx, &inserted)) } fn sync_missing_custom_world_gallery_entries(ctx: &ReducerContext) -> Result<(), String> { let published_profiles = ctx .db .custom_world_profile() .iter() .filter(|profile| { profile.publication_status == CustomWorldPublicationStatus::Published && profile.deleted_at.is_none() }) .collect::>(); for profile in published_profiles { if profile.published_at.is_none() { continue; } let existing_gallery_entry = ctx .db .custom_world_gallery_entry() .profile_id() .find(&profile.profile_id) .filter(|entry| entry.owner_user_id == profile.owner_user_id); if existing_gallery_entry.is_some() && profile.public_work_code.is_some() && profile.author_public_user_code.is_some() { continue; } let profile_with_public_fields = ensure_custom_world_profile_public_fields(ctx, &profile); sync_custom_world_gallery_entry_from_profile(ctx, &profile_with_public_fields)?; } Ok(()) } fn ensure_custom_world_profile_public_fields( ctx: &ReducerContext, profile: &CustomWorldProfile, ) -> CustomWorldProfile { if profile.public_work_code.is_some() && profile.author_public_user_code.is_some() { return build_custom_world_profile_row_copy(profile); } ctx.db .custom_world_profile() .profile_id() .delete(&profile.profile_id); let next_row = CustomWorldProfile { profile_id: profile.profile_id.clone(), owner_user_id: profile.owner_user_id.clone(), public_work_code: profile .public_work_code .clone() .or_else(|| Some(build_public_work_code_from_profile_id(&profile.profile_id))), author_public_user_code: profile.author_public_user_code.clone().or_else(|| { Some(build_public_user_code_from_owner_user_id( &profile.owner_user_id, )) }), source_agent_session_id: profile.source_agent_session_id.clone(), publication_status: profile.publication_status, world_name: profile.world_name.clone(), subtitle: profile.subtitle.clone(), summary_text: profile.summary_text.clone(), theme_mode: profile.theme_mode, cover_image_src: profile.cover_image_src.clone(), profile_payload_json: profile.profile_payload_json.clone(), playable_npc_count: profile.playable_npc_count, landmark_count: profile.landmark_count, play_count: profile.play_count, remix_count: profile.remix_count, like_count: profile.like_count, author_display_name: profile.author_display_name.clone(), published_at: profile.published_at, deleted_at: profile.deleted_at, created_at: profile.created_at, updated_at: profile.updated_at, }; ctx.db.custom_world_profile().insert(next_row) } fn build_custom_world_profile_row_copy(profile: &CustomWorldProfile) -> CustomWorldProfile { CustomWorldProfile { profile_id: profile.profile_id.clone(), owner_user_id: profile.owner_user_id.clone(), public_work_code: profile.public_work_code.clone(), author_public_user_code: profile.author_public_user_code.clone(), source_agent_session_id: profile.source_agent_session_id.clone(), publication_status: profile.publication_status, world_name: profile.world_name.clone(), subtitle: profile.subtitle.clone(), summary_text: profile.summary_text.clone(), theme_mode: profile.theme_mode, cover_image_src: profile.cover_image_src.clone(), profile_payload_json: profile.profile_payload_json.clone(), playable_npc_count: profile.playable_npc_count, landmark_count: profile.landmark_count, play_count: profile.play_count, remix_count: profile.remix_count, like_count: profile.like_count, author_display_name: profile.author_display_name.clone(), published_at: profile.published_at, deleted_at: profile.deleted_at, created_at: profile.created_at, updated_at: profile.updated_at, } } fn build_custom_world_profile_snapshot(row: &CustomWorldProfile) -> CustomWorldProfileSnapshot { CustomWorldProfileSnapshot { profile_id: row.profile_id.clone(), owner_user_id: row.owner_user_id.clone(), public_work_code: row.public_work_code.clone(), author_public_user_code: row.author_public_user_code.clone(), source_agent_session_id: row.source_agent_session_id.clone(), publication_status: row.publication_status, world_name: row.world_name.clone(), subtitle: row.subtitle.clone(), summary_text: row.summary_text.clone(), theme_mode: row.theme_mode, cover_image_src: row.cover_image_src.clone(), profile_payload_json: row.profile_payload_json.clone(), playable_npc_count: row.playable_npc_count, landmark_count: row.landmark_count, play_count: row.play_count, remix_count: row.remix_count, like_count: row.like_count, author_display_name: row.author_display_name.clone(), published_at_micros: row .published_at .map(|value| value.to_micros_since_unix_epoch()), deleted_at_micros: row .deleted_at .map(|value| value.to_micros_since_unix_epoch()), created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } fn build_custom_world_agent_session_snapshot( ctx: &ReducerContext, row: &CustomWorldAgentSession, ) -> CustomWorldAgentSessionSnapshot { let mut messages = ctx .db .custom_world_agent_message() .iter() .filter(|message| message.session_id == row.session_id) .map(|message| build_custom_world_agent_message_snapshot(&message)) .collect::>(); messages.sort_by_key(|message| (message.created_at_micros, message.message_id.clone())); let mut draft_cards = ctx .db .custom_world_draft_card() .iter() .filter(|card| card.session_id == row.session_id) .map(|card| build_custom_world_draft_card_snapshot(&card)) .collect::>(); draft_cards.sort_by_key(|card| (card.created_at_micros, card.card_id.clone())); let mut operations = ctx .db .custom_world_agent_operation() .iter() .filter(|operation| operation.session_id == row.session_id) .map(|operation| build_custom_world_agent_operation_snapshot(&operation)) .collect::>(); operations .sort_by_key(|operation| (operation.created_at_micros, operation.operation_id.clone())); CustomWorldAgentSessionSnapshot { session_id: row.session_id.clone(), owner_user_id: row.owner_user_id.clone(), seed_text: row.seed_text.clone(), current_turn: row.current_turn, progress_percent: row.progress_percent, stage: row.stage, focus_card_id: row.focus_card_id.clone(), anchor_content_json: row.anchor_content_json.clone(), creator_intent_json: row.creator_intent_json.clone(), creator_intent_readiness_json: row.creator_intent_readiness_json.clone(), anchor_pack_json: row.anchor_pack_json.clone(), lock_state_json: row.lock_state_json.clone(), draft_profile_json: row.draft_profile_json.clone(), last_assistant_reply: row.last_assistant_reply.clone(), publish_gate_json: row.publish_gate_json.clone(), result_preview_json: row.result_preview_json.clone(), pending_clarifications_json: row.pending_clarifications_json.clone(), quality_findings_json: row.quality_findings_json.clone(), suggested_actions_json: row.suggested_actions_json.clone(), recommended_replies_json: row.recommended_replies_json.clone(), asset_coverage_json: row.asset_coverage_json.clone(), checkpoints_json: row.checkpoints_json.clone(), supported_actions_json: serialize_json_value(&JsonValue::Array( build_supported_actions_json( row.stage, row.progress_percent, &build_custom_world_publish_gate_from_session(row), &parse_json_array_or_empty(&row.checkpoints_json), ), )) .unwrap_or_else(|_| "[]".to_string()), messages, draft_cards, operations, created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } fn build_custom_world_agent_message_snapshot( row: &CustomWorldAgentMessage, ) -> CustomWorldAgentMessageSnapshot { CustomWorldAgentMessageSnapshot { message_id: row.message_id.clone(), session_id: row.session_id.clone(), role: row.role, kind: row.kind, text: row.text.clone(), related_operation_id: row.related_operation_id.clone(), created_at_micros: row.created_at.to_micros_since_unix_epoch(), } } fn build_custom_world_agent_operation_snapshot( row: &CustomWorldAgentOperation, ) -> CustomWorldAgentOperationSnapshot { CustomWorldAgentOperationSnapshot { operation_id: row.operation_id.clone(), session_id: row.session_id.clone(), operation_type: row.operation_type, status: row.status, phase_label: row.phase_label.clone(), phase_detail: row.phase_detail.clone(), progress: row.progress, error_message: row.error_message.clone(), created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } fn build_custom_world_draft_card_snapshot( row: &CustomWorldDraftCard, ) -> CustomWorldDraftCardSnapshot { CustomWorldDraftCardSnapshot { card_id: row.card_id.clone(), session_id: row.session_id.clone(), kind: row.kind, status: row.status, title: row.title.clone(), subtitle: row.subtitle.clone(), summary: row.summary.clone(), linked_ids_json: row.linked_ids_json.clone(), warning_count: row.warning_count, asset_status: row.asset_status, asset_status_label: row.asset_status_label.clone(), detail_payload_json: row.detail_payload_json.clone(), created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } fn build_custom_world_gallery_entry_snapshot( ctx: &ReducerContext, row: &CustomWorldGalleryEntry, ) -> CustomWorldGalleryEntrySnapshot { CustomWorldGalleryEntrySnapshot { profile_id: row.profile_id.clone(), owner_user_id: row.owner_user_id.clone(), public_work_code: row.public_work_code.clone(), author_public_user_code: row.author_public_user_code.clone(), author_display_name: row.author_display_name.clone(), world_name: row.world_name.clone(), subtitle: row.subtitle.clone(), summary_text: row.summary_text.clone(), cover_image_src: row.cover_image_src.clone(), theme_mode: row.theme_mode, playable_npc_count: row.playable_npc_count, landmark_count: row.landmark_count, play_count: row.play_count, remix_count: row.remix_count, like_count: row.like_count, recent_play_count_7d: count_recent_public_work_plays( ctx, "custom-world", &row.profile_id, ctx.timestamp.to_micros_since_unix_epoch(), ), published_at_micros: row.published_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } // 作品公开号保持稳定公开语义,本期先由 profile_id 派生 deterministic fallback, // 后续若引入独立 sequence 表,可无痛替换生成来源而不影响读写接口。 fn build_public_work_code_from_profile_id(profile_id: &str) -> String { let digits = profile_id .chars() .filter(|character| character.is_ascii_digit()) .collect::(); let normalized_digits = if digits.is_empty() { let checksum = profile_id.bytes().fold(0u32, |accumulator, value| { accumulator.wrapping_mul(131) + u32::from(value) }); format!("{:08}", checksum % 100_000_000) } else { format!("{:0>8}", &digits[digits.len().saturating_sub(8)..]) }; format!("CW-{normalized_digits}") } fn build_public_user_code_from_owner_user_id(owner_user_id: &str) -> String { owner_user_id .trim_start_matches("user_") .parse::() .ok() .map(|sequence| format!("SY-{sequence:08}")) .unwrap_or_else(|| "SY-00000000".to_string()) } fn normalize_public_work_code(input: &str) -> Option { let normalized = input .trim() .chars() .filter(|character| character.is_ascii_alphanumeric()) .collect::() .to_ascii_uppercase(); let digits = normalized.strip_prefix("CW").unwrap_or(&normalized); if digits.is_empty() || digits.len() > 8 || !digits.chars().all(|character| character.is_ascii_digit()) { return None; } Some(format!("CW-{digits:0>8}")) } fn build_quest_record_row(snapshot: QuestRecordSnapshot) -> QuestRecord { QuestRecord { quest_id: snapshot.quest_id, runtime_session_id: snapshot.runtime_session_id, story_session_id: snapshot.story_session_id, actor_user_id: snapshot.actor_user_id, issuer_npc_id: snapshot.issuer_npc_id, issuer_npc_name: snapshot.issuer_npc_name, scene_id: snapshot.scene_id, chapter_id: snapshot.chapter_id, act_id: snapshot.act_id, thread_id: snapshot.thread_id, contract_id: snapshot.contract_id, title: snapshot.title, description: snapshot.description, summary: snapshot.summary, objective: snapshot.objective, progress: snapshot.progress, status: snapshot.status, completion_notified: snapshot.completion_notified, reward: snapshot.reward, reward_text: snapshot.reward_text, narrative_binding: snapshot.narrative_binding, steps: snapshot.steps, active_step_id: snapshot.active_step_id, visible_stage: snapshot.visible_stage, hidden_flags: snapshot.hidden_flags, discovered_fact_ids: snapshot.discovered_fact_ids, related_carrier_ids: snapshot.related_carrier_ids, consequence_ids: snapshot.consequence_ids, created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros), updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros), completed_at: snapshot .completed_at_micros .map(Timestamp::from_micros_since_unix_epoch), turned_in_at: snapshot .turned_in_at_micros .map(Timestamp::from_micros_since_unix_epoch), } } fn build_player_progression_row(snapshot: PlayerProgressionSnapshot) -> PlayerProgression { PlayerProgression { user_id: snapshot.user_id, level: snapshot.level, current_level_xp: snapshot.current_level_xp, total_xp: snapshot.total_xp, xp_to_next_level: snapshot.xp_to_next_level, pending_level_ups: snapshot.pending_level_ups, last_granted_source: snapshot.last_granted_source, created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros), updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros), } } fn build_player_progression_snapshot_from_row( row: &PlayerProgression, ) -> PlayerProgressionSnapshot { PlayerProgressionSnapshot { user_id: row.user_id.clone(), level: row.level, current_level_xp: row.current_level_xp, total_xp: row.total_xp, xp_to_next_level: row.xp_to_next_level, pending_level_ups: row.pending_level_ups, last_granted_source: row.last_granted_source, created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } fn build_chapter_progression_id(user_id: &str, chapter_id: &str) -> String { format!("chapprog_{}_{}", user_id.trim(), chapter_id.trim()) } fn build_chapter_progression_row(snapshot: ChapterProgressionSnapshot) -> ChapterProgression { ChapterProgression { chapter_progression_id: build_chapter_progression_id( &snapshot.user_id, &snapshot.chapter_id, ), user_id: snapshot.user_id, chapter_id: snapshot.chapter_id, chapter_index: snapshot.chapter_index, total_chapters: snapshot.total_chapters, entry_pseudo_level_millis: snapshot.entry_pseudo_level_millis, exit_pseudo_level_millis: snapshot.exit_pseudo_level_millis, entry_level: snapshot.entry_level, exit_level: snapshot.exit_level, planned_total_xp: snapshot.planned_total_xp, planned_quest_xp: snapshot.planned_quest_xp, planned_hostile_xp: snapshot.planned_hostile_xp, actual_quest_xp: snapshot.actual_quest_xp, actual_hostile_xp: snapshot.actual_hostile_xp, expected_hostile_defeat_count: snapshot.expected_hostile_defeat_count, actual_hostile_defeat_count: snapshot.actual_hostile_defeat_count, level_at_entry: snapshot.level_at_entry, level_at_exit: snapshot.level_at_exit, pace_band: snapshot.pace_band, created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros), updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros), } } fn build_chapter_progression_snapshot_from_row( row: &ChapterProgression, ) -> ChapterProgressionSnapshot { ChapterProgressionSnapshot { user_id: row.user_id.clone(), chapter_id: row.chapter_id.clone(), chapter_index: row.chapter_index, total_chapters: row.total_chapters, entry_pseudo_level_millis: row.entry_pseudo_level_millis, exit_pseudo_level_millis: row.exit_pseudo_level_millis, entry_level: row.entry_level, exit_level: row.exit_level, planned_total_xp: row.planned_total_xp, planned_quest_xp: row.planned_quest_xp, planned_hostile_xp: row.planned_hostile_xp, actual_quest_xp: row.actual_quest_xp, actual_hostile_xp: row.actual_hostile_xp, expected_hostile_defeat_count: row.expected_hostile_defeat_count, actual_hostile_defeat_count: row.actual_hostile_defeat_count, level_at_entry: row.level_at_entry, level_at_exit: row.level_at_exit, pace_band: row.pace_band, created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } fn build_quest_record_snapshot_from_row(row: &QuestRecord) -> QuestRecordSnapshot { QuestRecordSnapshot { quest_id: row.quest_id.clone(), runtime_session_id: row.runtime_session_id.clone(), story_session_id: row.story_session_id.clone(), actor_user_id: row.actor_user_id.clone(), issuer_npc_id: row.issuer_npc_id.clone(), issuer_npc_name: row.issuer_npc_name.clone(), scene_id: row.scene_id.clone(), chapter_id: row.chapter_id.clone(), act_id: row.act_id.clone(), thread_id: row.thread_id.clone(), contract_id: row.contract_id.clone(), title: row.title.clone(), description: row.description.clone(), summary: row.summary.clone(), objective: row.objective.clone(), progress: row.progress, status: row.status, completion_notified: row.completion_notified, reward: row.reward.clone(), reward_text: row.reward_text.clone(), narrative_binding: row.narrative_binding.clone(), steps: row.steps.clone(), active_step_id: row.active_step_id.clone(), visible_stage: row.visible_stage, hidden_flags: row.hidden_flags.clone(), discovered_fact_ids: row.discovered_fact_ids.clone(), related_carrier_ids: row.related_carrier_ids.clone(), consequence_ids: row.consequence_ids.clone(), created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), completed_at_micros: row .completed_at .map(|value| value.to_micros_since_unix_epoch()), turned_in_at_micros: row .turned_in_at .map(|value| value.to_micros_since_unix_epoch()), } } fn build_inventory_slot_row(snapshot: InventorySlotSnapshot) -> InventorySlot { InventorySlot { slot_id: snapshot.slot_id, runtime_session_id: snapshot.runtime_session_id, story_session_id: snapshot.story_session_id, actor_user_id: snapshot.actor_user_id, container_kind: snapshot.container_kind, slot_key: snapshot.slot_key, item_id: snapshot.item_id, category: snapshot.category, name: snapshot.name, description: snapshot.description, quantity: snapshot.quantity, rarity: snapshot.rarity, tags: snapshot.tags, stackable: snapshot.stackable, stack_key: snapshot.stack_key, equipment_slot_id: snapshot.equipment_slot_id, source_kind: snapshot.source_kind, source_reference_id: snapshot.source_reference_id, created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros), updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros), } } fn build_inventory_slot_snapshot_from_row(row: &InventorySlot) -> InventorySlotSnapshot { InventorySlotSnapshot { slot_id: row.slot_id.clone(), runtime_session_id: row.runtime_session_id.clone(), story_session_id: row.story_session_id.clone(), actor_user_id: row.actor_user_id.clone(), container_kind: row.container_kind, slot_key: row.slot_key.clone(), item_id: row.item_id.clone(), category: row.category.clone(), name: row.name.clone(), description: row.description.clone(), quantity: row.quantity, rarity: row.rarity, tags: row.tags.clone(), stackable: row.stackable, stack_key: row.stack_key.clone(), equipment_slot_id: row.equipment_slot_id, source_kind: row.source_kind, source_reference_id: row.source_reference_id.clone(), created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } fn grant_quest_reward_items( ctx: &ReducerContext, snapshot: &QuestRecordSnapshot, ) -> Result<(), String> { if !ctx .db .inventory_slot() .iter() .filter(|row| { row.runtime_session_id == snapshot.runtime_session_id && row.actor_user_id == snapshot.actor_user_id }) .all(|row| row.source_reference_id.as_deref() != Some(snapshot.quest_id.as_str())) { return Ok(()); } for (index, reward_item) in snapshot.reward.items.clone().into_iter().enumerate() { let inventory_item = build_inventory_item_snapshot_from_quest_reward_item(&snapshot.quest_id, reward_item); grant_inventory_item_to_actor( ctx, &snapshot.runtime_session_id, snapshot.story_session_id.clone(), &snapshot.actor_user_id, inventory_item, build_reward_seed(snapshot.updated_at_micros, index), snapshot.updated_at_micros, )?; } Ok(()) } fn grant_battle_reward_items( ctx: &ReducerContext, snapshot: &BattleStateSnapshot, ) -> Result<(), String> { if snapshot.reward_items.is_empty() { return Ok(()); } if !ctx .db .inventory_slot() .iter() .filter(|row| { row.runtime_session_id == snapshot.runtime_session_id && row.actor_user_id == snapshot.actor_user_id }) .all(|row| row.source_reference_id.as_deref() != Some(snapshot.battle_state_id.as_str())) { return Ok(()); } for (index, reward_item) in snapshot.reward_items.clone().into_iter().enumerate() { let inventory_item = build_inventory_item_snapshot_from_battle_reward_item( &snapshot.battle_state_id, reward_item, ); grant_inventory_item_to_actor( ctx, &snapshot.runtime_session_id, Some(snapshot.story_session_id.clone()), &snapshot.actor_user_id, inventory_item, build_reward_seed(snapshot.updated_at_micros, index), snapshot.updated_at_micros, )?; } Ok(()) } fn grant_inventory_item_to_actor( ctx: &ReducerContext, runtime_session_id: &str, story_session_id: Option, actor_user_id: &str, item: InventoryItemSnapshot, seed_micros: i64, updated_at_micros: i64, ) -> Result<(), String> { let current_slots = ctx .db .inventory_slot() .iter() .filter(|row| { row.runtime_session_id == runtime_session_id && row.actor_user_id == actor_user_id }) .map(|row| build_inventory_slot_snapshot_from_row(&row)) .collect::>(); let slot_id = generate_inventory_slot_id(seed_micros); let mutation_id = generate_inventory_mutation_id(seed_micros); let outcome = apply_inventory_slot_mutation( current_slots, InventoryMutationInput { mutation_id, runtime_session_id: runtime_session_id.to_string(), story_session_id, actor_user_id: actor_user_id.to_string(), mutation: InventoryMutation::GrantItem(GrantInventoryItemInput { slot_id, item }), updated_at_micros, }, ) .map_err(|error| error.to_string())?; for removed_slot_id in outcome.removed_slot_ids { ctx.db.inventory_slot().slot_id().delete(&removed_slot_id); } for slot in outcome.next_slots { ctx.db.inventory_slot().slot_id().delete(&slot.slot_id); ctx.db .inventory_slot() .insert(build_inventory_slot_row(slot)); } Ok(()) } fn build_inventory_item_snapshot_from_battle_reward_item( battle_state_id: &str, reward_item: RuntimeItemRewardItemSnapshot, ) -> InventoryItemSnapshot { InventoryItemSnapshot { item_id: reward_item.item_id, category: reward_item.category, name: reward_item.item_name, description: reward_item.description, quantity: reward_item.quantity, rarity: map_runtime_reward_item_rarity(reward_item.rarity), tags: reward_item.tags, stackable: reward_item.stackable, stack_key: reward_item.stack_key, equipment_slot_id: reward_item .equipment_slot_id .map(map_runtime_reward_equipment_slot), source_kind: InventoryItemSourceKind::CombatDrop, source_reference_id: Some(battle_state_id.to_string()), } } fn build_inventory_item_snapshot_from_quest_reward_item( quest_id: &str, reward_item: QuestRewardItem, ) -> InventoryItemSnapshot { InventoryItemSnapshot { item_id: reward_item.item_id, category: reward_item.category, name: reward_item.name, description: reward_item.description, quantity: reward_item.quantity, rarity: map_quest_reward_item_rarity(reward_item.rarity), tags: reward_item.tags, stackable: reward_item.stackable, stack_key: reward_item.stack_key, equipment_slot_id: reward_item .equipment_slot_id .map(map_quest_reward_equipment_slot), source_kind: InventoryItemSourceKind::QuestReward, source_reference_id: Some(quest_id.to_string()), } } fn map_quest_reward_item_rarity(rarity: QuestRewardItemRarity) -> InventoryItemRarity { match rarity { QuestRewardItemRarity::Common => InventoryItemRarity::Common, QuestRewardItemRarity::Uncommon => InventoryItemRarity::Uncommon, QuestRewardItemRarity::Rare => InventoryItemRarity::Rare, QuestRewardItemRarity::Epic => InventoryItemRarity::Epic, QuestRewardItemRarity::Legendary => InventoryItemRarity::Legendary, } } fn map_runtime_reward_item_rarity( rarity: module_runtime_item::RuntimeItemRewardItemRarity, ) -> InventoryItemRarity { match rarity { module_runtime_item::RuntimeItemRewardItemRarity::Common => InventoryItemRarity::Common, module_runtime_item::RuntimeItemRewardItemRarity::Uncommon => InventoryItemRarity::Uncommon, module_runtime_item::RuntimeItemRewardItemRarity::Rare => InventoryItemRarity::Rare, module_runtime_item::RuntimeItemRewardItemRarity::Epic => InventoryItemRarity::Epic, module_runtime_item::RuntimeItemRewardItemRarity::Legendary => { InventoryItemRarity::Legendary } } } fn map_quest_reward_equipment_slot(slot: QuestRewardEquipmentSlot) -> InventoryEquipmentSlot { match slot { QuestRewardEquipmentSlot::Weapon => InventoryEquipmentSlot::Weapon, QuestRewardEquipmentSlot::Armor => InventoryEquipmentSlot::Armor, QuestRewardEquipmentSlot::Relic => InventoryEquipmentSlot::Relic, } } fn map_runtime_reward_equipment_slot( slot: module_runtime_item::RuntimeItemEquipmentSlot, ) -> InventoryEquipmentSlot { match slot { module_runtime_item::RuntimeItemEquipmentSlot::Weapon => InventoryEquipmentSlot::Weapon, module_runtime_item::RuntimeItemEquipmentSlot::Armor => InventoryEquipmentSlot::Armor, module_runtime_item::RuntimeItemEquipmentSlot::Relic => InventoryEquipmentSlot::Relic, } } fn build_reward_seed(updated_at_micros: i64, index: usize) -> i64 { updated_at_micros.saturating_add(index as i64 + 1) } fn build_story_session_snapshot_from_row(row: &StorySession) -> StorySessionSnapshot { StorySessionSnapshot { story_session_id: row.story_session_id.clone(), runtime_session_id: row.runtime_session_id.clone(), actor_user_id: row.actor_user_id.clone(), world_profile_id: row.world_profile_id.clone(), initial_prompt: row.initial_prompt.clone(), opening_summary: row.opening_summary.clone(), latest_narrative_text: row.latest_narrative_text.clone(), latest_choice_function_id: row.latest_choice_function_id.clone(), status: row.status, version: row.version, created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } fn build_story_event_snapshot_from_row(row: &StoryEvent) -> StoryEventSnapshot { StoryEventSnapshot { event_id: row.event_id.clone(), story_session_id: row.story_session_id.clone(), event_kind: row.event_kind, narrative_text: row.narrative_text.clone(), choice_function_id: row.choice_function_id.clone(), created_at_micros: row.created_at.to_micros_since_unix_epoch(), } } fn build_treasure_record_snapshot_from_row(row: &TreasureRecord) -> TreasureRecordSnapshot { TreasureRecordSnapshot { treasure_record_id: row.treasure_record_id.clone(), runtime_session_id: row.runtime_session_id.clone(), story_session_id: row.story_session_id.clone(), actor_user_id: row.actor_user_id.clone(), encounter_id: row.encounter_id.clone(), encounter_name: row.encounter_name.clone(), scene_id: row.scene_id.clone(), scene_name: row.scene_name.clone(), action: row.action, reward_items: row.reward_items.clone(), reward_hp: row.reward_hp, reward_mana: row.reward_mana, reward_currency: row.reward_currency, story_hint: row.story_hint.clone(), created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } fn append_quest_log( ctx: &ReducerContext, snapshot: &QuestRecordSnapshot, event_kind: QuestLogEventKind, signal_kind: Option, signal: Option, step_id: Option, step_progress: Option, created_at_micros: i64, ) { ctx.db.quest_log().insert(QuestLog { log_id: generate_quest_log_id(&snapshot.quest_id, event_kind, created_at_micros), quest_id: snapshot.quest_id.clone(), runtime_session_id: snapshot.runtime_session_id.clone(), actor_user_id: snapshot.actor_user_id.clone(), event_kind, status_after: snapshot.status, signal_kind, signal, step_id, step_progress, created_at: Timestamp::from_micros_since_unix_epoch(created_at_micros), }); } fn get_player_progression_snapshot_tx( ctx: &ReducerContext, input: PlayerProgressionGetInput, ) -> Result { let user_id = input.user_id.trim().to_string(); if user_id.is_empty() { return Err("player_progression.user_id 不能为空".to_string()); } if let Some(existing) = ctx.db.player_progression().user_id().find(&user_id) { return Ok(build_player_progression_snapshot_from_row(&existing)); } create_initial_player_progression(user_id, 0).map_err(|error| error.to_string()) } fn upsert_player_progression_after_grant_tx( ctx: &ReducerContext, input: PlayerProgressionGrantInput, ) -> Result { let current = if let Some(existing) = ctx.db.player_progression().user_id().find(&input.user_id) { build_player_progression_snapshot_from_row(&existing) } else { create_initial_player_progression(input.user_id.clone(), input.updated_at_micros) .map_err(|error| error.to_string())? }; let next = grant_player_experience(current, input).map_err(|error| error.to_string())?; if ctx .db .player_progression() .user_id() .find(&next.user_id) .is_some() { ctx.db.player_progression().user_id().delete(&next.user_id); } ctx.db .player_progression() .insert(build_player_progression_row(next.clone())); Ok(next) } fn get_chapter_progression_snapshot_tx( ctx: &ReducerContext, input: ChapterProgressionGetInput, ) -> Result { let user_id = input.user_id.trim().to_string(); let chapter_id = input.chapter_id.trim().to_string(); if user_id.is_empty() { return Err("chapter_progression.user_id 不能为空".to_string()); } if chapter_id.is_empty() { return Err("chapter_progression.chapter_id 不能为空".to_string()); } let row_id = build_chapter_progression_id(&user_id, &chapter_id); let existing = ctx .db .chapter_progression() .chapter_progression_id() .find(&row_id) .ok_or_else(|| "chapter_progression 不存在".to_string())?; Ok(build_chapter_progression_snapshot_from_row(&existing)) } fn upsert_chapter_progression_snapshot_tx( ctx: &ReducerContext, input: ChapterProgressionInput, ) -> Result { let snapshot = build_chapter_progression_snapshot(input).map_err(|error| error.to_string())?; let row_id = build_chapter_progression_id(&snapshot.user_id, &snapshot.chapter_id); if ctx .db .chapter_progression() .chapter_progression_id() .find(&row_id) .is_some() { ctx.db .chapter_progression() .chapter_progression_id() .delete(&row_id); } ctx.db .chapter_progression() .insert(build_chapter_progression_row(snapshot.clone())); Ok(snapshot) } fn update_chapter_progression_ledger_tx( ctx: &ReducerContext, input: ChapterProgressionLedgerInput, ) -> Result { let row_id = build_chapter_progression_id(&input.user_id, &input.chapter_id); let current = ctx .db .chapter_progression() .chapter_progression_id() .find(&row_id) .ok_or_else(|| "chapter_progression 不存在,无法记账".to_string())?; let next = apply_chapter_progression_ledger( build_chapter_progression_snapshot_from_row(¤t), input, ) .map_err(|error| error.to_string())?; ctx.db .chapter_progression() .chapter_progression_id() .delete(&row_id); ctx.db .chapter_progression() .insert(build_chapter_progression_row(next.clone())); Ok(next) } fn try_update_chapter_progression_ledger_tx( ctx: &ReducerContext, user_id: String, chapter_id: Option, input: ChapterProgressionLedgerInput, ) -> Result, String> { let Some(chapter_id) = chapter_id.map(|value| value.trim().to_string()) else { return Ok(None); }; if chapter_id.is_empty() || user_id.trim().is_empty() { return Ok(None); } let row_id = build_chapter_progression_id(user_id.trim(), &chapter_id); if ctx .db .chapter_progression() .chapter_progression_id() .find(&row_id) .is_none() { return Ok(None); } update_chapter_progression_ledger_tx(ctx, input).map(Some) } fn build_battle_state_row(snapshot: BattleStateSnapshot) -> BattleState { BattleState { battle_state_id: snapshot.battle_state_id, story_session_id: snapshot.story_session_id, runtime_session_id: snapshot.runtime_session_id, actor_user_id: snapshot.actor_user_id, chapter_id: snapshot.chapter_id, target_npc_id: snapshot.target_npc_id, target_name: snapshot.target_name, battle_mode: snapshot.battle_mode, status: snapshot.status, player_hp: snapshot.player_hp, player_max_hp: snapshot.player_max_hp, player_mana: snapshot.player_mana, player_max_mana: snapshot.player_max_mana, target_hp: snapshot.target_hp, target_max_hp: snapshot.target_max_hp, experience_reward: snapshot.experience_reward, reward_items: snapshot.reward_items, turn_index: snapshot.turn_index, last_action_function_id: snapshot.last_action_function_id, last_action_text: snapshot.last_action_text, last_result_text: snapshot.last_result_text, last_damage_dealt: snapshot.last_damage_dealt, last_damage_taken: snapshot.last_damage_taken, last_outcome: snapshot.last_outcome, version: snapshot.version, created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros), updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros), } } fn build_battle_state_snapshot_from_row(row: &BattleState) -> BattleStateSnapshot { BattleStateSnapshot { battle_state_id: row.battle_state_id.clone(), story_session_id: row.story_session_id.clone(), runtime_session_id: row.runtime_session_id.clone(), actor_user_id: row.actor_user_id.clone(), chapter_id: row.chapter_id.clone(), target_npc_id: row.target_npc_id.clone(), target_name: row.target_name.clone(), battle_mode: row.battle_mode, status: row.status, player_hp: row.player_hp, player_max_hp: row.player_max_hp, player_mana: row.player_mana, player_max_mana: row.player_max_mana, target_hp: row.target_hp, target_max_hp: row.target_max_hp, experience_reward: row.experience_reward, reward_items: row.reward_items.clone(), turn_index: row.turn_index, last_action_function_id: row.last_action_function_id.clone(), last_action_text: row.last_action_text.clone(), last_result_text: row.last_result_text.clone(), last_damage_dealt: row.last_damage_dealt, last_damage_taken: row.last_damage_taken, last_outcome: row.last_outcome, version: row.version, created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } fn upsert_npc_state_record( ctx: &ReducerContext, input: NpcStateUpsertInput, ) -> Result { let npc_state_id = generate_npc_state_id(&input.runtime_session_id, &input.npc_id); let existing = ctx.db.npc_state().npc_state_id().find(&npc_state_id); let normalized = normalize_npc_state_snapshot( input, existing .as_ref() .map(|row| row.created_at.to_micros_since_unix_epoch()), ) .map_err(|error| error.to_string())?; if existing.is_some() { ctx.db.npc_state().npc_state_id().delete(&npc_state_id); } ctx.db .npc_state() .insert(build_npc_state_row(normalized.clone())); Ok(normalized) } fn resolve_npc_social_action_record( ctx: &ReducerContext, input: ResolveNpcSocialActionInput, ) -> Result { let npc_state_id = generate_npc_state_id(&input.runtime_session_id, &input.npc_id); let current = ctx .db .npc_state() .npc_state_id() .find(&npc_state_id) .ok_or_else(|| "npc_state 不存在,无法执行社交动作".to_string())?; let next = apply_npc_social_action(build_npc_state_snapshot_from_row(¤t), input) .map_err(|error| error.to_string())?; ctx.db .npc_state() .npc_state_id() .delete(¤t.npc_state_id); ctx.db.npc_state().insert(build_npc_state_row(next.clone())); Ok(next) } fn resolve_npc_interaction_record( ctx: &ReducerContext, input: ResolveNpcInteractionInput, ) -> Result { let npc_state_id = generate_npc_state_id(&input.runtime_session_id, &input.npc_id); let current = ctx .db .npc_state() .npc_state_id() .find(&npc_state_id) .ok_or_else(|| "npc_state 不存在,无法执行交互".to_string())?; let result = resolve_npc_interaction_domain(build_npc_state_snapshot_from_row(¤t), input) .map_err(|error| error.to_string())?; ctx.db .npc_state() .npc_state_id() .delete(¤t.npc_state_id); ctx.db .npc_state() .insert(build_npc_state_row(result.npc_state.clone())); Ok(result) } fn resolve_npc_battle_interaction_tx( ctx: &ReducerContext, input: ResolveNpcBattleInteractionInput, ) -> Result { validate_npc_battle_interaction_input(&input)?; let interaction = resolve_npc_interaction_record(ctx, input.npc_interaction.clone())?; let battle_mode = interaction .battle_mode .ok_or_else(|| "当前 NPC 交互没有产出 battle_mode,不能初始化 battle_state".to_string())?; let battle_state_id = input .battle_state_id .clone() .unwrap_or_else(|| generate_battle_state_id(input.npc_interaction.updated_at_micros)); if ctx .db .battle_state() .battle_state_id() .find(&battle_state_id) .is_some() { return Err("battle_state.battle_state_id 已存在".to_string()); } let battle_input = BattleStateInput { battle_state_id, story_session_id: input.story_session_id.trim().to_string(), runtime_session_id: interaction.npc_state.runtime_session_id.clone(), actor_user_id: input.actor_user_id.trim().to_string(), chapter_id: None, target_npc_id: interaction.npc_state.npc_id.clone(), target_name: interaction.npc_state.npc_name.clone(), battle_mode: map_npc_battle_mode(battle_mode), player_hp: input.player_hp, player_max_hp: input.player_max_hp, player_mana: input.player_mana, player_max_mana: input.player_max_mana, target_hp: input.target_hp, target_max_hp: input.target_max_hp, experience_reward: input.experience_reward, reward_items: input.reward_items.clone(), created_at_micros: input.npc_interaction.updated_at_micros, }; validate_battle_state_input(&battle_input).map_err(|error| error.to_string())?; let battle_state = build_battle_state_snapshot(battle_input); ctx.db .battle_state() .insert(build_battle_state_row(battle_state.clone())); Ok(NpcBattleInteractionResult { interaction, battle_state, }) } fn validate_npc_battle_interaction_input( input: &ResolveNpcBattleInteractionInput, ) -> Result<(), String> { if input.story_session_id.trim().is_empty() { return Err("resolve_npc_battle_interaction.story_session_id 不能为空".to_string()); } if input.actor_user_id.trim().is_empty() { return Err("resolve_npc_battle_interaction.actor_user_id 不能为空".to_string()); } if !matches!( input.npc_interaction.interaction_function_id.trim(), NPC_FIGHT_FUNCTION_ID | NPC_SPAR_FUNCTION_ID ) { return Err("resolve_npc_battle_interaction 只支持 npc_fight 或 npc_spar".to_string()); } Ok(()) } fn map_npc_battle_mode(mode: NpcInteractionBattleMode) -> BattleMode { match mode { NpcInteractionBattleMode::Fight => BattleMode::Fight, NpcInteractionBattleMode::Spar => BattleMode::Spar, } } fn build_npc_state_row(snapshot: NpcStateSnapshot) -> NpcState { NpcState { npc_state_id: snapshot.npc_state_id, runtime_session_id: snapshot.runtime_session_id, npc_id: snapshot.npc_id, npc_name: snapshot.npc_name, affinity: snapshot.affinity, relation_state: snapshot.relation_state, help_used: snapshot.help_used, chatted_count: snapshot.chatted_count, gifts_given: snapshot.gifts_given, recruited: snapshot.recruited, trade_stock_signature: snapshot.trade_stock_signature, revealed_facts: snapshot.revealed_facts, known_attribute_rumors: snapshot.known_attribute_rumors, first_meaningful_contact_resolved: snapshot.first_meaningful_contact_resolved, seen_backstory_chapter_ids: snapshot.seen_backstory_chapter_ids, stance_profile: snapshot.stance_profile, created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros), updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros), } } fn build_npc_state_snapshot_from_row(row: &NpcState) -> NpcStateSnapshot { NpcStateSnapshot { npc_state_id: row.npc_state_id.clone(), runtime_session_id: row.runtime_session_id.clone(), npc_id: row.npc_id.clone(), npc_name: row.npc_name.clone(), affinity: row.affinity, relation_state: row.relation_state.clone(), help_used: row.help_used, chatted_count: row.chatted_count, gifts_given: row.gifts_given, recruited: row.recruited, trade_stock_signature: row.trade_stock_signature.clone(), revealed_facts: row.revealed_facts.clone(), known_attribute_rumors: row.known_attribute_rumors.clone(), first_meaningful_contact_resolved: row.first_meaningful_contact_resolved, seen_backstory_chapter_ids: row.seen_backstory_chapter_ids.clone(), stance_profile: row.stance_profile.clone(), created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } #[cfg(test)] mod tests { use super::*; fn build_test_custom_world_agent_session( seed_text: &str, stage: RpgAgentStage, draft_profile_json: Option<&str>, ) -> CustomWorldAgentSession { CustomWorldAgentSession { session_id: "session-1".to_string(), owner_user_id: "user-1".to_string(), seed_text: seed_text.to_string(), current_turn: 0, progress_percent: 0, stage, focus_card_id: None, anchor_content_json: "{}".to_string(), creator_intent_json: None, creator_intent_readiness_json: "{}".to_string(), anchor_pack_json: None, lock_state_json: None, draft_profile_json: draft_profile_json.map(str::to_string), last_assistant_reply: None, publish_gate_json: None, result_preview_json: None, pending_clarifications_json: "[]".to_string(), quality_findings_json: "[]".to_string(), suggested_actions_json: "[]".to_string(), recommended_replies_json: "[]".to_string(), asset_coverage_json: "{}".to_string(), checkpoints_json: "[]".to_string(), created_at: Timestamp::from_micros_since_unix_epoch(1), updated_at: Timestamp::from_micros_since_unix_epoch(1), } } #[test] fn resolve_stable_agent_draft_profile_id_prefers_legacy_result_profile_id() { let session = build_test_custom_world_agent_session( "seed", RpgAgentStage::ObjectRefining, Some(r#"{"id":"drifted-profile","legacyResultProfile":{"id":"stable-profile"}}"#), ); assert_eq!( resolve_stable_agent_draft_profile_id(&session), Some("stable-profile".to_string()) ); } #[test] fn custom_world_agent_session_direct_work_content_ignores_empty_created_session() { let empty_session = build_test_custom_world_agent_session("", RpgAgentStage::CollectingIntent, Some("{}")); let seeded_session = build_test_custom_world_agent_session( "想做一个海雾群岛", RpgAgentStage::CollectingIntent, Some("{}"), ); let drafted_session = build_test_custom_world_agent_session("", RpgAgentStage::ObjectRefining, Some("{}")); let profile_session = build_test_custom_world_agent_session( "", RpgAgentStage::CollectingIntent, Some(r#"{"worldHook":"海雾会吞掉记错航线的人。"}"#), ); assert!(!custom_world_agent_session_has_direct_work_content( &empty_session, )); assert!(custom_world_agent_session_has_direct_work_content( &seeded_session, )); assert!(custom_world_agent_session_has_direct_work_content( &drafted_session, )); assert!(custom_world_agent_session_has_direct_work_content( &profile_session, )); } #[test] fn same_agent_draft_profile_candidate_requires_same_owner_active_draft_and_session() { let matching = CustomWorldProfile { profile_id: "profile-1".to_string(), owner_user_id: "user-1".to_string(), public_work_code: None, author_public_user_code: None, source_agent_session_id: Some("session-1".to_string()), publication_status: CustomWorldPublicationStatus::Draft, world_name: "潮雾列岛".to_string(), subtitle: String::new(), summary_text: String::new(), theme_mode: CustomWorldThemeMode::Mythic, cover_image_src: None, profile_payload_json: "{}".to_string(), playable_npc_count: 0, landmark_count: 0, play_count: 0, remix_count: 0, like_count: 0, author_display_name: "玩家".to_string(), published_at: None, deleted_at: None, created_at: Timestamp::from_micros_since_unix_epoch(1), updated_at: Timestamp::from_micros_since_unix_epoch(1), }; let deleted = CustomWorldProfile { profile_id: "profile-1".to_string(), owner_user_id: "user-1".to_string(), public_work_code: None, author_public_user_code: None, source_agent_session_id: Some("session-1".to_string()), publication_status: CustomWorldPublicationStatus::Draft, world_name: "潮雾列岛".to_string(), subtitle: String::new(), summary_text: String::new(), theme_mode: CustomWorldThemeMode::Mythic, cover_image_src: None, profile_payload_json: "{}".to_string(), playable_npc_count: 0, landmark_count: 0, play_count: 0, remix_count: 0, like_count: 0, author_display_name: "玩家".to_string(), published_at: None, deleted_at: Some(Timestamp::from_micros_since_unix_epoch(2)), created_at: Timestamp::from_micros_since_unix_epoch(1), updated_at: Timestamp::from_micros_since_unix_epoch(1), }; let published = CustomWorldProfile { profile_id: "profile-1".to_string(), owner_user_id: "user-1".to_string(), public_work_code: Some("CW-00000001".to_string()), author_public_user_code: Some("SY-00000001".to_string()), source_agent_session_id: Some("session-1".to_string()), publication_status: CustomWorldPublicationStatus::Published, world_name: "潮雾列岛".to_string(), subtitle: String::new(), summary_text: String::new(), theme_mode: CustomWorldThemeMode::Mythic, cover_image_src: None, profile_payload_json: "{}".to_string(), playable_npc_count: 0, landmark_count: 0, play_count: 0, remix_count: 0, like_count: 0, author_display_name: "玩家".to_string(), published_at: None, deleted_at: None, created_at: Timestamp::from_micros_since_unix_epoch(1), updated_at: Timestamp::from_micros_since_unix_epoch(1), }; assert!(is_same_agent_draft_profile_candidate( &matching, "user-1", "session-1", )); assert!(!is_same_agent_draft_profile_candidate( &matching, "user-2", "session-1", )); assert!(!is_same_agent_draft_profile_candidate( &matching, "user-1", "session-2", )); assert!(!is_same_agent_draft_profile_candidate( &deleted, "user-1", "session-1", )); assert!(!is_same_agent_draft_profile_candidate( &published, "user-1", "session-1", )); } #[test] fn custom_world_works_hides_compiled_draft_profile_when_agent_session_is_active() { fn build_test_custom_world_profile( profile_id: &str, source_agent_session_id: Option<&str>, publication_status: CustomWorldPublicationStatus, ) -> CustomWorldProfile { CustomWorldProfile { profile_id: profile_id.to_string(), owner_user_id: "user-1".to_string(), public_work_code: if publication_status == CustomWorldPublicationStatus::Published { Some("CW-00000001".to_string()) } else { None }, author_public_user_code: None, source_agent_session_id: source_agent_session_id.map(str::to_string), publication_status, world_name: "潮雾列岛".to_string(), subtitle: String::new(), summary_text: String::new(), theme_mode: CustomWorldThemeMode::Mythic, cover_image_src: None, profile_payload_json: "{}".to_string(), playable_npc_count: 0, landmark_count: 0, play_count: 0, remix_count: 0, like_count: 0, author_display_name: "玩家".to_string(), published_at: if publication_status == CustomWorldPublicationStatus::Published { Some(Timestamp::from_micros_since_unix_epoch(2)) } else { None }, deleted_at: None, created_at: Timestamp::from_micros_since_unix_epoch(1), updated_at: Timestamp::from_micros_since_unix_epoch(1), } } let draft_profile = build_test_custom_world_profile( "profile-1", Some("session-1"), CustomWorldPublicationStatus::Draft, ); let orphan_draft_profile = build_test_custom_world_profile( "profile-2", Some("session-2"), CustomWorldPublicationStatus::Draft, ); let published_profile = build_test_custom_world_profile( "profile-3", Some("session-1"), CustomWorldPublicationStatus::Published, ); let mut active_agent_session_ids = HashSet::new(); active_agent_session_ids.insert("session-1".to_string()); assert!(!should_include_custom_world_profile_work( &draft_profile, &active_agent_session_ids, )); assert!(should_include_custom_world_profile_work( &orphan_draft_profile, &active_agent_session_ids, )); assert!(should_include_custom_world_profile_work( &published_profile, &active_agent_session_ids, )); } #[test] fn custom_world_works_keeps_compiled_draft_profile_without_active_agent_session() { let draft_profile = CustomWorldProfile { profile_id: "profile-1".to_string(), owner_user_id: "user-1".to_string(), public_work_code: None, author_public_user_code: None, source_agent_session_id: Some("session-1".to_string()), publication_status: CustomWorldPublicationStatus::Draft, world_name: "潮雾列岛".to_string(), subtitle: String::new(), summary_text: String::new(), theme_mode: CustomWorldThemeMode::Mythic, cover_image_src: None, profile_payload_json: "{}".to_string(), playable_npc_count: 0, landmark_count: 0, play_count: 0, remix_count: 0, like_count: 0, author_display_name: "玩家".to_string(), published_at: None, deleted_at: None, created_at: Timestamp::from_micros_since_unix_epoch(1), updated_at: Timestamp::from_micros_since_unix_epoch(1), }; let mut active_agent_session_ids = HashSet::new(); assert!(should_include_custom_world_profile_work( &draft_profile, &active_agent_session_ids, )); active_agent_session_ids.insert("session-2".to_string()); assert!(should_include_custom_world_profile_work( &draft_profile, &active_agent_session_ids, )); } #[test] fn summarize_publish_gate_accepts_current_agent_result_schema() { let draft_profile = serde_json::from_str::( r#"{ "id":"agent-draft-session-1", "settingText":"海雾会吞掉记错航线的人。", "creatorIntent":{"playerPremise":"玩家是带着旧航海日志返乡的守灯人。"}, "anchorContent":{ "worldPromise":{"hook":"在失真的海图上追查一场被篡改的沉船事故。"}, "playerEntryPoint":{ "openingIdentity":"被停职返乡的守灯人", "openingProblem":"灯塔记录被人改写", "entryMotivation":"查清父亲沉船真相" } }, "coreConflicts":["群岛议会试图掩盖沉船真相。"], "sceneChapterBlueprints":[ { "id":"scene-chapter-1", "sceneId":"landmark-1", "title":"失灯港", "acts":[ { "id":"act-1", "title":"第一幕" } ] } ] }"#, ) .expect("draft profile should be valid json") .as_object() .cloned() .expect("draft profile should be object"); let gate = summarize_publish_gate_from_json( "session-1", RpgAgentStage::ReadyToPublish, Some(&draft_profile), &[], ); assert!(gate.publish_ready); assert_eq!(gate.blocker_count, 0); assert!(gate.blockers.is_empty()); } #[test] fn ensure_minimal_draft_profile_includes_scene_chapter_blueprints_slot() { let profile = ensure_minimal_draft_profile(JsonMap::new(), "旧航路群岛"); assert_eq!( profile.get("sceneChapterBlueprints"), Some(&JsonValue::Array(Vec::new())) ); } #[test] fn draft_foundation_payload_must_contain_external_draft_profile() { let payload = JsonMap::new(); let result = payload .get("draftProfile") .and_then(JsonValue::as_object) .cloned() .ok_or_else(|| { "draft_foundation requires externally generated payload.draftProfile".to_string() }); assert_eq!( result.expect_err("missing draftProfile should be rejected"), "draft_foundation requires externally generated payload.draftProfile" ); } }