9000 lines
320 KiB
Rust
9000 lines
320 KiB
Rust
use module_ai::{
|
||
AI_RESULT_REF_ID_PREFIX, AI_TEXT_CHUNK_ID_PREFIX, AiResultReferenceInput,
|
||
AiResultReferenceKind, AiResultReferenceSnapshot, AiStageCompletionInput, AiTaskCancelInput,
|
||
AiTaskCreateInput, AiTaskFailureInput, AiTaskFinishInput, AiTaskKind, AiTaskProcedureResult,
|
||
AiTaskSnapshot, AiTaskStageKind, AiTaskStageSnapshot, AiTaskStageStartInput, AiTaskStageStatus,
|
||
AiTaskStartInput, AiTaskStatus, AiTextChunkAppendInput, AiTextChunkSnapshot,
|
||
INITIAL_AI_TASK_VERSION, generate_ai_result_ref_id, generate_ai_task_stage_id,
|
||
generate_ai_text_chunk_id, normalize_optional_text, normalize_string_list,
|
||
validate_task_create_input,
|
||
};
|
||
use module_assets::{
|
||
ASSET_BINDING_ID_PREFIX, ASSET_OBJECT_ID_PREFIX, AssetEntityBindingInput,
|
||
AssetEntityBindingProcedureResult, AssetEntityBindingSnapshot, AssetObjectAccessPolicy,
|
||
AssetObjectProcedureResult, AssetObjectUpsertInput, AssetObjectUpsertSnapshot,
|
||
INITIAL_ASSET_OBJECT_VERSION, validate_asset_entity_binding_fields,
|
||
validate_asset_object_fields,
|
||
};
|
||
use module_big_fish::{
|
||
BigFishAgentMessageKind, BigFishAgentMessageRole, BigFishAgentMessageSnapshot,
|
||
BigFishAssetGenerateInput, BigFishAssetKind, BigFishAssetSlotSnapshot, BigFishAssetStatus,
|
||
BigFishCreationStage, BigFishDraftCompileInput, BigFishMessageSubmitInput, BigFishPublishInput,
|
||
BigFishRunGetInput, BigFishRunInputSubmitInput, BigFishRunProcedureResult,
|
||
BigFishRunStartInput, BigFishRunStatus, BigFishRuntimeSnapshot, BigFishSessionCreateInput,
|
||
BigFishSessionGetInput, BigFishSessionProcedureResult, BigFishSessionSnapshot,
|
||
advance_runtime_snapshot, build_asset_coverage, build_generated_asset_slot,
|
||
build_initial_runtime_snapshot, compile_default_draft, deserialize_anchor_pack,
|
||
deserialize_draft, deserialize_runtime_snapshot, empty_anchor_pack, infer_anchor_pack,
|
||
serialize_anchor_pack, serialize_asset_coverage, serialize_draft, serialize_runtime_snapshot,
|
||
validate_asset_generate_input, validate_draft_compile_input, validate_message_submit_input,
|
||
validate_publish_input, validate_run_get_input, validate_run_input_submit_input,
|
||
validate_run_start_input, validate_session_create_input, validate_session_get_input,
|
||
};
|
||
use module_combat::{
|
||
BATTLE_STATE_ID_PREFIX, BattleMode, BattleStateInput, BattleStateProcedureResult,
|
||
BattleStateQueryInput, BattleStateSnapshot, BattleStatus, CombatOutcome,
|
||
INITIAL_BATTLE_VERSION, ResolveCombatActionInput, ResolveCombatActionProcedureResult,
|
||
build_battle_state_snapshot, generate_battle_state_id,
|
||
resolve_combat_action as resolve_battle_state_action, validate_battle_state_input,
|
||
validate_battle_state_query_input,
|
||
};
|
||
use module_custom_world::{
|
||
CustomWorldAgentActionExecuteInput, CustomWorldAgentActionExecuteResult,
|
||
CustomWorldAgentCardDetailGetInput, CustomWorldAgentMessageSnapshot,
|
||
CustomWorldAgentMessageSubmitInput, CustomWorldAgentOperationGetInput,
|
||
CustomWorldAgentOperationProcedureResult, CustomWorldAgentOperationSnapshot,
|
||
CustomWorldAgentSessionCreateInput, CustomWorldAgentSessionGetInput,
|
||
CustomWorldAgentSessionProcedureResult, CustomWorldAgentSessionSnapshot,
|
||
CustomWorldDraftCardDetailResult, CustomWorldDraftCardDetailSectionSnapshot,
|
||
CustomWorldDraftCardDetailSnapshot, CustomWorldDraftCardSnapshot,
|
||
CustomWorldGalleryDetailInput, CustomWorldGalleryEntrySnapshot, CustomWorldGalleryListResult,
|
||
CustomWorldGenerationMode, CustomWorldLibraryDetailInput, CustomWorldLibraryMutationResult,
|
||
CustomWorldProfileListInput, CustomWorldProfileListResult, CustomWorldProfilePublishInput,
|
||
CustomWorldProfileSnapshot, CustomWorldProfileUnpublishInput, CustomWorldProfileUpsertInput,
|
||
CustomWorldPublicationStatus, CustomWorldPublishBlockerSnapshot,
|
||
CustomWorldPublishGateSnapshot, CustomWorldPublishWorldInput, CustomWorldPublishWorldResult,
|
||
CustomWorldPublishedProfileCompileInput, CustomWorldPublishedProfileCompileResult,
|
||
CustomWorldRoleAssetStatus, CustomWorldSessionStatus, CustomWorldThemeMode,
|
||
CustomWorldWorkSummarySnapshot, CustomWorldWorksListInput, CustomWorldWorksListResult,
|
||
RpgAgentDraftCardKind, RpgAgentDraftCardStatus, RpgAgentMessageKind, RpgAgentMessageRole,
|
||
RpgAgentOperationStatus, RpgAgentOperationType, RpgAgentStage,
|
||
build_custom_world_published_profile_compile_snapshot,
|
||
validate_custom_world_agent_action_execute_input,
|
||
validate_custom_world_agent_card_detail_get_input,
|
||
validate_custom_world_agent_message_submit_input,
|
||
validate_custom_world_agent_operation_get_input,
|
||
validate_custom_world_agent_session_create_input,
|
||
validate_custom_world_agent_session_get_input, validate_custom_world_gallery_detail_input,
|
||
validate_custom_world_library_detail_input, validate_custom_world_profile_delete_input,
|
||
validate_custom_world_profile_list_input, validate_custom_world_profile_publish_input,
|
||
validate_custom_world_profile_unpublish_input, validate_custom_world_profile_upsert_input,
|
||
validate_custom_world_publish_world_input, validate_custom_world_works_list_input,
|
||
};
|
||
use module_inventory::{
|
||
GrantInventoryItemInput, INVENTORY_MUTATION_ID_PREFIX, INVENTORY_SLOT_ID_PREFIX,
|
||
InventoryContainerKind, InventoryEquipmentSlot, InventoryItemRarity, InventoryItemSnapshot,
|
||
InventoryItemSourceKind, InventoryMutation, InventoryMutationInput, InventorySlotSnapshot,
|
||
RuntimeInventoryStateProcedureResult, RuntimeInventoryStateQueryInput,
|
||
RuntimeInventoryStateSnapshot, apply_inventory_mutation as apply_inventory_slot_mutation,
|
||
build_runtime_inventory_state_query_input, build_runtime_inventory_state_snapshot,
|
||
generate_inventory_mutation_id, generate_inventory_slot_id,
|
||
};
|
||
use module_npc::{
|
||
NPC_FIGHT_FUNCTION_ID, NPC_RECRUIT_AFFINITY_THRESHOLD, NPC_SPAR_FUNCTION_ID,
|
||
NPC_STATE_ID_PREFIX, NpcInteractionBattleMode, NpcInteractionProcedureResult, NpcRelationState,
|
||
NpcStanceProfile, NpcStateProcedureResult, NpcStateSnapshot, NpcStateUpsertInput,
|
||
ResolveNpcInteractionInput, ResolveNpcSocialActionInput, apply_npc_social_action,
|
||
generate_npc_state_id, normalize_npc_state_snapshot,
|
||
resolve_npc_interaction as resolve_npc_interaction_domain,
|
||
};
|
||
use module_progression::{
|
||
ChapterPaceBand, ChapterProgressionGetInput, ChapterProgressionInput,
|
||
ChapterProgressionLedgerInput, ChapterProgressionProcedureResult, ChapterProgressionSnapshot,
|
||
PlayerProgressionGetInput, PlayerProgressionGrantInput, PlayerProgressionGrantSource,
|
||
PlayerProgressionProcedureResult, PlayerProgressionSnapshot, apply_chapter_progression_ledger,
|
||
build_chapter_progression_snapshot, create_initial_player_progression, grant_player_experience,
|
||
};
|
||
use module_quest::{
|
||
QUEST_LOG_ID_PREFIX, QuestCompletionAckInput, QuestLogEventKind, QuestNarrativeBindingSnapshot,
|
||
QuestObjectiveSnapshot, QuestProgressSignal, QuestRecordInput, QuestRecordSnapshot,
|
||
QuestRewardEquipmentSlot, QuestRewardItem, QuestRewardItemRarity, QuestRewardSnapshot,
|
||
QuestSignalApplyInput, QuestSignalKind, QuestStatus, QuestStepSnapshot, QuestTurnInInput,
|
||
acknowledge_quest_completion as acknowledge_quest_record_completion,
|
||
apply_quest_signal as apply_quest_record_signal, build_quest_record_snapshot,
|
||
generate_quest_log_id, turn_in_quest_record,
|
||
};
|
||
use module_runtime::{
|
||
DEFAULT_MUSIC_VOLUME, DEFAULT_PLATFORM_THEME, DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT,
|
||
PROFILE_WALLET_LEDGER_LIST_LIMIT, RuntimeBrowseHistoryClearInput,
|
||
RuntimeBrowseHistoryListInput, RuntimeBrowseHistoryProcedureResult,
|
||
RuntimeBrowseHistorySnapshot, RuntimeBrowseHistorySyncInput, RuntimeBrowseHistoryThemeMode,
|
||
RuntimePlatformTheme, RuntimeProfileDashboardGetInput, RuntimeProfileDashboardProcedureResult,
|
||
RuntimeProfileDashboardSnapshot, RuntimeProfilePlayStatsGetInput,
|
||
RuntimeProfilePlayStatsProcedureResult, RuntimeProfilePlayStatsSnapshot,
|
||
RuntimeProfilePlayedWorldSnapshot, RuntimeProfileSaveArchiveListInput,
|
||
RuntimeProfileSaveArchiveProcedureResult, RuntimeProfileSaveArchiveResumeInput,
|
||
RuntimeProfileSaveArchiveSnapshot, RuntimeProfileWalletLedgerEntrySnapshot,
|
||
RuntimeProfileWalletLedgerListInput, RuntimeProfileWalletLedgerProcedureResult,
|
||
RuntimeProfileWalletLedgerSourceType, RuntimeSettingGetInput, RuntimeSettingProcedureResult,
|
||
RuntimeSettingSnapshot, RuntimeSettingUpsertInput, RuntimeSnapshot, RuntimeSnapshotDeleteInput,
|
||
RuntimeSnapshotGetInput, RuntimeSnapshotProcedureResult, RuntimeSnapshotUpsertInput,
|
||
SAVE_SNAPSHOT_VERSION, build_runtime_browse_history_clear_input,
|
||
build_runtime_browse_history_list_input, build_runtime_profile_dashboard_get_input,
|
||
build_runtime_profile_play_stats_get_input, build_runtime_profile_save_archive_list_input,
|
||
build_runtime_profile_save_archive_resume_input,
|
||
build_runtime_profile_wallet_ledger_list_input, build_runtime_setting_get_input,
|
||
build_runtime_setting_upsert_input, build_runtime_snapshot_delete_input,
|
||
build_runtime_snapshot_get_input, build_runtime_snapshot_upsert_input,
|
||
prepare_runtime_browse_history_entries,
|
||
};
|
||
use module_runtime_item::{
|
||
RuntimeItemRewardItemSnapshot, TREASURE_RECORD_ID_PREFIX, TreasureInteractionAction,
|
||
TreasureRecordProcedureResult, TreasureRecordSnapshot, TreasureResolveInput,
|
||
build_inventory_item_snapshot_from_reward_item, build_treasure_record_snapshot,
|
||
};
|
||
use module_story::{
|
||
INITIAL_STORY_SESSION_VERSION, STORY_EVENT_ID_PREFIX, STORY_SESSION_ID_PREFIX,
|
||
StoryContinueInput, StoryEventKind, StoryEventSnapshot, StorySessionInput,
|
||
StorySessionProcedureResult, StorySessionSnapshot, StorySessionStateInput,
|
||
StorySessionStateProcedureResult, StorySessionStatus, apply_story_continue,
|
||
build_story_session_snapshot, build_story_started_event, validate_story_continue_input,
|
||
validate_story_session_input, validate_story_session_state_input,
|
||
};
|
||
use serde_json::{Map as JsonMap, Value as JsonValue, json};
|
||
use shared_kernel::format_timestamp_micros;
|
||
use spacetimedb::{ProcedureContext, ReducerContext, SpacetimeType, Table, Timestamp};
|
||
|
||
mod puzzle;
|
||
|
||
// 这层输入只服务 NPC 开战编排;普通聊天、援手、招募继续走已有 resolve_npc_interaction 接口。
|
||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||
pub struct ResolveNpcBattleInteractionInput {
|
||
pub npc_interaction: ResolveNpcInteractionInput,
|
||
pub story_session_id: String,
|
||
pub actor_user_id: String,
|
||
pub battle_state_id: Option<String>,
|
||
pub player_hp: i32,
|
||
pub player_max_hp: i32,
|
||
pub player_mana: i32,
|
||
pub player_max_mana: i32,
|
||
pub target_hp: i32,
|
||
pub target_max_hp: i32,
|
||
pub experience_reward: u32,
|
||
pub reward_items: Vec<RuntimeItemRewardItemSnapshot>,
|
||
}
|
||
|
||
// 输出同时返回 NPC 交互结果与 battle_state 快照,避免 Axum 再回头读取 private table。
|
||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||
pub struct NpcBattleInteractionResult {
|
||
pub interaction: module_npc::NpcInteractionResult,
|
||
pub battle_state: BattleStateSnapshot,
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||
pub struct NpcBattleInteractionProcedureResult {
|
||
pub ok: bool,
|
||
pub result: Option<NpcBattleInteractionResult>,
|
||
pub error_message: Option<String>,
|
||
}
|
||
|
||
#[spacetimedb::table(
|
||
accessor = big_fish_creation_session,
|
||
index(accessor = by_big_fish_session_owner_user_id, btree(columns = [owner_user_id]))
|
||
)]
|
||
pub struct BigFishCreationSession {
|
||
#[primary_key]
|
||
session_id: String,
|
||
owner_user_id: String,
|
||
seed_text: String,
|
||
current_turn: u32,
|
||
progress_percent: u32,
|
||
stage: BigFishCreationStage,
|
||
anchor_pack_json: String,
|
||
draft_json: Option<String>,
|
||
asset_coverage_json: String,
|
||
last_assistant_reply: Option<String>,
|
||
publish_ready: bool,
|
||
created_at: Timestamp,
|
||
updated_at: Timestamp,
|
||
}
|
||
|
||
#[spacetimedb::table(
|
||
accessor = big_fish_agent_message,
|
||
index(accessor = by_big_fish_message_session_id, btree(columns = [session_id]))
|
||
)]
|
||
pub struct BigFishAgentMessage {
|
||
#[primary_key]
|
||
message_id: String,
|
||
session_id: String,
|
||
role: BigFishAgentMessageRole,
|
||
kind: BigFishAgentMessageKind,
|
||
text: String,
|
||
created_at: Timestamp,
|
||
}
|
||
|
||
#[spacetimedb::table(
|
||
accessor = big_fish_asset_slot,
|
||
index(accessor = by_big_fish_asset_session_id, btree(columns = [session_id]))
|
||
)]
|
||
pub struct BigFishAssetSlot {
|
||
#[primary_key]
|
||
slot_id: String,
|
||
session_id: String,
|
||
asset_kind: BigFishAssetKind,
|
||
level: Option<u32>,
|
||
motion_key: Option<String>,
|
||
status: BigFishAssetStatus,
|
||
asset_url: Option<String>,
|
||
prompt_snapshot: String,
|
||
updated_at: Timestamp,
|
||
}
|
||
|
||
#[spacetimedb::table(
|
||
accessor = big_fish_runtime_run,
|
||
index(accessor = by_big_fish_run_owner_user_id, btree(columns = [owner_user_id])),
|
||
index(accessor = by_big_fish_run_session_id, btree(columns = [session_id]))
|
||
)]
|
||
pub struct BigFishRuntimeRun {
|
||
#[primary_key]
|
||
run_id: String,
|
||
session_id: String,
|
||
owner_user_id: String,
|
||
status: BigFishRunStatus,
|
||
snapshot_json: String,
|
||
last_input_x: f32,
|
||
last_input_y: f32,
|
||
tick: u64,
|
||
created_at: Timestamp,
|
||
updated_at: Timestamp,
|
||
}
|
||
|
||
#[spacetimedb::table(
|
||
accessor = asset_object,
|
||
index(accessor = by_bucket_object_key, btree(columns = [bucket, object_key]))
|
||
)]
|
||
pub struct AssetObject {
|
||
#[primary_key]
|
||
asset_object_id: String,
|
||
// 正式对象定位固定拆成 bucket + object_key 两列,避免后续再从单字符串路径做 schema 拆分。
|
||
bucket: String,
|
||
object_key: String,
|
||
access_policy: AssetObjectAccessPolicy,
|
||
content_type: Option<String>,
|
||
content_length: u64,
|
||
content_hash: Option<String>,
|
||
version: u32,
|
||
source_job_id: Option<String>,
|
||
owner_user_id: Option<String>,
|
||
profile_id: Option<String>,
|
||
entity_id: Option<String>,
|
||
#[index(btree)]
|
||
asset_kind: String,
|
||
created_at: Timestamp,
|
||
updated_at: Timestamp,
|
||
}
|
||
|
||
#[spacetimedb::table(
|
||
accessor = asset_entity_binding,
|
||
index(accessor = by_entity_slot, btree(columns = [entity_kind, entity_id, slot])),
|
||
index(accessor = by_asset_object_id, btree(columns = [asset_object_id]))
|
||
)]
|
||
pub struct AssetEntityBinding {
|
||
#[primary_key]
|
||
binding_id: String,
|
||
asset_object_id: String,
|
||
entity_kind: String,
|
||
entity_id: String,
|
||
slot: String,
|
||
asset_kind: String,
|
||
owner_user_id: Option<String>,
|
||
profile_id: Option<String>,
|
||
created_at: Timestamp,
|
||
updated_at: Timestamp,
|
||
}
|
||
|
||
#[spacetimedb::table(accessor = runtime_setting)]
|
||
pub struct RuntimeSetting {
|
||
#[primary_key]
|
||
user_id: String,
|
||
music_volume: f32,
|
||
platform_theme: RuntimePlatformTheme,
|
||
created_at: Timestamp,
|
||
updated_at: Timestamp,
|
||
}
|
||
|
||
#[spacetimedb::table(accessor = runtime_snapshot)]
|
||
pub struct RuntimeSnapshotRow {
|
||
#[primary_key]
|
||
user_id: String,
|
||
version: u32,
|
||
saved_at: Timestamp,
|
||
bottom_tab: String,
|
||
game_state_json: String,
|
||
current_story_json: Option<String>,
|
||
created_at: Timestamp,
|
||
updated_at: Timestamp,
|
||
}
|
||
|
||
#[spacetimedb::table(
|
||
accessor = user_browse_history,
|
||
index(accessor = by_browse_history_user_id, btree(columns = [user_id])),
|
||
index(
|
||
accessor = by_browse_history_user_owner_profile,
|
||
btree(columns = [user_id, owner_user_id, profile_id])
|
||
)
|
||
)]
|
||
pub struct UserBrowseHistory {
|
||
#[primary_key]
|
||
browse_history_id: String,
|
||
user_id: String,
|
||
owner_user_id: String,
|
||
profile_id: String,
|
||
world_name: String,
|
||
subtitle: String,
|
||
summary_text: String,
|
||
cover_image_src: Option<String>,
|
||
theme_mode: RuntimeBrowseHistoryThemeMode,
|
||
author_display_name: String,
|
||
visited_at: Timestamp,
|
||
created_at: Timestamp,
|
||
updated_at: Timestamp,
|
||
}
|
||
|
||
#[spacetimedb::table(accessor = profile_dashboard_state)]
|
||
pub struct ProfileDashboardState {
|
||
#[primary_key]
|
||
user_id: String,
|
||
wallet_balance: u64,
|
||
total_play_time_ms: u64,
|
||
created_at: Timestamp,
|
||
updated_at: Timestamp,
|
||
}
|
||
|
||
#[spacetimedb::table(
|
||
accessor = profile_wallet_ledger,
|
||
index(accessor = by_profile_wallet_ledger_user_id, btree(columns = [user_id])),
|
||
index(
|
||
accessor = by_profile_wallet_ledger_user_created_at,
|
||
btree(columns = [user_id, created_at])
|
||
)
|
||
)]
|
||
pub struct ProfileWalletLedger {
|
||
#[primary_key]
|
||
wallet_ledger_id: String,
|
||
user_id: String,
|
||
amount_delta: i64,
|
||
balance_after: u64,
|
||
source_type: RuntimeProfileWalletLedgerSourceType,
|
||
created_at: Timestamp,
|
||
}
|
||
|
||
#[spacetimedb::table(
|
||
accessor = profile_played_world,
|
||
index(accessor = by_profile_played_world_user_id, btree(columns = [user_id])),
|
||
index(
|
||
accessor = by_profile_played_world_user_world_key,
|
||
btree(columns = [user_id, world_key])
|
||
),
|
||
index(
|
||
accessor = by_profile_played_world_user_last_played_at,
|
||
btree(columns = [user_id, last_played_at])
|
||
)
|
||
)]
|
||
pub struct ProfilePlayedWorld {
|
||
#[primary_key]
|
||
played_world_id: String,
|
||
user_id: String,
|
||
world_key: String,
|
||
owner_user_id: Option<String>,
|
||
profile_id: Option<String>,
|
||
world_type: Option<String>,
|
||
world_title: String,
|
||
world_subtitle: String,
|
||
first_played_at: Timestamp,
|
||
last_played_at: Timestamp,
|
||
last_observed_play_time_ms: u64,
|
||
}
|
||
|
||
#[spacetimedb::table(
|
||
accessor = profile_save_archive,
|
||
index(accessor = by_profile_save_archive_user_id, btree(columns = [user_id])),
|
||
index(
|
||
accessor = by_profile_save_archive_user_world_key,
|
||
btree(columns = [user_id, world_key])
|
||
),
|
||
index(
|
||
accessor = by_profile_save_archive_user_saved_at,
|
||
btree(columns = [user_id, saved_at])
|
||
)
|
||
)]
|
||
pub struct ProfileSaveArchive {
|
||
#[primary_key]
|
||
archive_id: String,
|
||
user_id: String,
|
||
world_key: String,
|
||
owner_user_id: Option<String>,
|
||
profile_id: Option<String>,
|
||
world_type: Option<String>,
|
||
world_name: String,
|
||
subtitle: String,
|
||
summary_text: String,
|
||
cover_image_src: Option<String>,
|
||
saved_at: Timestamp,
|
||
bottom_tab: String,
|
||
game_state_json: String,
|
||
current_story_json: Option<String>,
|
||
created_at: Timestamp,
|
||
updated_at: Timestamp,
|
||
}
|
||
|
||
#[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<PlayerProgressionGrantSource>,
|
||
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<u32>,
|
||
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<String>,
|
||
revealed_facts: Vec<String>,
|
||
known_attribute_rumors: Vec<String>,
|
||
first_meaningful_contact_resolved: bool,
|
||
seen_backstory_chapter_ids: Vec<String>,
|
||
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<String>,
|
||
latest_narrative_text: String,
|
||
latest_choice_function_id: Option<String>,
|
||
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<String>,
|
||
created_at: Timestamp,
|
||
}
|
||
|
||
#[spacetimedb::table(
|
||
accessor = ai_task,
|
||
index(accessor = by_ai_task_owner_user_id, btree(columns = [owner_user_id])),
|
||
index(accessor = by_ai_task_status, btree(columns = [status])),
|
||
index(accessor = by_ai_task_kind, btree(columns = [task_kind]))
|
||
)]
|
||
pub struct AiTask {
|
||
#[primary_key]
|
||
task_id: String,
|
||
task_kind: AiTaskKind,
|
||
owner_user_id: String,
|
||
request_label: String,
|
||
source_module: String,
|
||
source_entity_id: Option<String>,
|
||
request_payload_json: Option<String>,
|
||
status: AiTaskStatus,
|
||
failure_message: Option<String>,
|
||
latest_text_output: Option<String>,
|
||
latest_structured_payload_json: Option<String>,
|
||
version: u32,
|
||
created_at: Timestamp,
|
||
started_at: Option<Timestamp>,
|
||
completed_at: Option<Timestamp>,
|
||
updated_at: Timestamp,
|
||
}
|
||
|
||
#[spacetimedb::table(
|
||
accessor = ai_task_stage,
|
||
index(accessor = by_ai_task_stage_task_id, btree(columns = [task_id])),
|
||
index(accessor = by_ai_task_stage_task_order, btree(columns = [task_id, stage_order]))
|
||
)]
|
||
pub struct AiTaskStage {
|
||
#[primary_key]
|
||
task_stage_id: String,
|
||
task_id: String,
|
||
stage_kind: AiTaskStageKind,
|
||
label: String,
|
||
detail: String,
|
||
stage_order: u32,
|
||
status: AiTaskStageStatus,
|
||
text_output: Option<String>,
|
||
structured_payload_json: Option<String>,
|
||
warning_messages: Vec<String>,
|
||
started_at: Option<Timestamp>,
|
||
completed_at: Option<Timestamp>,
|
||
}
|
||
|
||
#[spacetimedb::table(
|
||
accessor = ai_text_chunk,
|
||
index(accessor = by_ai_text_chunk_task_id, btree(columns = [task_id])),
|
||
index(
|
||
accessor = by_ai_text_chunk_task_stage_sequence,
|
||
btree(columns = [task_id, stage_kind, sequence])
|
||
)
|
||
)]
|
||
pub struct AiTextChunk {
|
||
#[primary_key]
|
||
text_chunk_row_id: String,
|
||
chunk_id: String,
|
||
task_id: String,
|
||
stage_kind: AiTaskStageKind,
|
||
sequence: u32,
|
||
delta_text: String,
|
||
created_at: Timestamp,
|
||
}
|
||
|
||
#[spacetimedb::table(
|
||
accessor = ai_result_reference,
|
||
index(accessor = by_ai_result_reference_task_id, btree(columns = [task_id]))
|
||
)]
|
||
pub struct AiResultReference {
|
||
#[primary_key]
|
||
result_reference_row_id: String,
|
||
result_ref_id: String,
|
||
task_id: String,
|
||
reference_kind: AiResultReferenceKind,
|
||
reference_id: String,
|
||
label: Option<String>,
|
||
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<String>,
|
||
actor_user_id: String,
|
||
container_kind: InventoryContainerKind,
|
||
slot_key: String,
|
||
item_id: String,
|
||
category: String,
|
||
name: String,
|
||
description: Option<String>,
|
||
quantity: u32,
|
||
rarity: InventoryItemRarity,
|
||
tags: Vec<String>,
|
||
stackable: bool,
|
||
stack_key: String,
|
||
equipment_slot_id: Option<InventoryEquipmentSlot>,
|
||
source_kind: InventoryItemSourceKind,
|
||
source_reference_id: Option<String>,
|
||
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<String>,
|
||
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<RuntimeItemRewardItemSnapshot>,
|
||
turn_index: u32,
|
||
last_action_function_id: Option<String>,
|
||
last_action_text: Option<String>,
|
||
last_result_text: Option<String>,
|
||
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<String>,
|
||
scene_name: Option<String>,
|
||
action: TreasureInteractionAction,
|
||
reward_items: Vec<RuntimeItemRewardItemSnapshot>,
|
||
reward_hp: u32,
|
||
reward_mana: u32,
|
||
reward_currency: u32,
|
||
story_hint: Option<String>,
|
||
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<String>,
|
||
actor_user_id: String,
|
||
issuer_npc_id: String,
|
||
issuer_npc_name: String,
|
||
scene_id: Option<String>,
|
||
chapter_id: Option<String>,
|
||
act_id: Option<String>,
|
||
thread_id: Option<String>,
|
||
contract_id: Option<String>,
|
||
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<QuestStepSnapshot>,
|
||
active_step_id: Option<String>,
|
||
visible_stage: u32,
|
||
hidden_flags: Vec<String>,
|
||
discovered_fact_ids: Vec<String>,
|
||
related_carrier_ids: Vec<String>,
|
||
consequence_ids: Vec<String>,
|
||
created_at: Timestamp,
|
||
updated_at: Timestamp,
|
||
completed_at: Option<Timestamp>,
|
||
turned_in_at: Option<Timestamp>,
|
||
}
|
||
|
||
#[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<QuestSignalKind>,
|
||
signal: Option<QuestProgressSignal>,
|
||
step_id: Option<String>,
|
||
step_progress: Option<u32>,
|
||
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,
|
||
source_agent_session_id: Option<String>,
|
||
publication_status: CustomWorldPublicationStatus,
|
||
world_name: String,
|
||
subtitle: String,
|
||
summary_text: String,
|
||
theme_mode: CustomWorldThemeMode,
|
||
cover_image_src: Option<String>,
|
||
profile_payload_json: String,
|
||
playable_npc_count: u32,
|
||
landmark_count: u32,
|
||
author_display_name: String,
|
||
published_at: Option<Timestamp>,
|
||
// 软删除后保留 profile 真相,供审计与幂等删除使用。
|
||
deleted_at: Option<Timestamp>,
|
||
created_at: Timestamp,
|
||
updated_at: Timestamp,
|
||
}
|
||
|
||
#[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<String>,
|
||
question_snapshot_json: String,
|
||
result_payload_json: Option<String>,
|
||
last_error_message: Option<String>,
|
||
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<String>,
|
||
anchor_content_json: String,
|
||
creator_intent_json: Option<String>,
|
||
creator_intent_readiness_json: String,
|
||
anchor_pack_json: Option<String>,
|
||
lock_state_json: Option<String>,
|
||
draft_profile_json: Option<String>,
|
||
last_assistant_reply: Option<String>,
|
||
publish_gate_json: Option<String>,
|
||
result_preview_json: Option<String>,
|
||
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<String>,
|
||
created_at: Timestamp,
|
||
}
|
||
|
||
#[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<String>,
|
||
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<CustomWorldRoleAssetStatus>,
|
||
asset_status_label: Option<String>,
|
||
detail_payload_json: Option<String>,
|
||
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]))
|
||
)]
|
||
pub struct CustomWorldGalleryEntry {
|
||
#[primary_key]
|
||
profile_id: String,
|
||
// 画廊是公开订阅读模型,不再运行时从 profile 即席拼装。
|
||
owner_user_id: String,
|
||
author_display_name: String,
|
||
world_name: String,
|
||
subtitle: String,
|
||
summary_text: String,
|
||
cover_image_src: Option<String>,
|
||
theme_mode: CustomWorldThemeMode,
|
||
playable_npc_count: u32,
|
||
landmark_count: u32,
|
||
published_at: Timestamp,
|
||
updated_at: Timestamp,
|
||
}
|
||
|
||
// 当前阶段先落可发布的最小模块入口,后续再补对象确认、业务绑定与任务编排 reducer。
|
||
#[spacetimedb::reducer(init)]
|
||
pub fn init(_ctx: &ReducerContext) {
|
||
log::info!(
|
||
"spacetime-module 初始化完成,asset_object 已固定 bucket/object_key 双列主存储口径,runtime_setting 已固定默认音量={} 和默认主题={},battle_state 前缀={},战斗初始版本={},npc_state 前缀={},npc 招募阈值={},story_session 前缀={},story_event 前缀={},inventory_slot 前缀={},inventory_mutation 前缀={},quest_log 前缀={},treasure_record 前缀={},player_progression 与 chapter_progression 已接入成长真相表,M5 custom_world_profile/session/agent/gallery 首批表骨架已接入,默认对象 ID 前缀={},默认绑定 ID 前缀={},资产初始版本={},故事会话初始版本={}",
|
||
DEFAULT_MUSIC_VOLUME,
|
||
DEFAULT_PLATFORM_THEME.as_str(),
|
||
BATTLE_STATE_ID_PREFIX,
|
||
INITIAL_BATTLE_VERSION,
|
||
NPC_STATE_ID_PREFIX,
|
||
NPC_RECRUIT_AFFINITY_THRESHOLD,
|
||
STORY_SESSION_ID_PREFIX,
|
||
STORY_EVENT_ID_PREFIX,
|
||
INVENTORY_SLOT_ID_PREFIX,
|
||
INVENTORY_MUTATION_ID_PREFIX,
|
||
QUEST_LOG_ID_PREFIX,
|
||
TREASURE_RECORD_ID_PREFIX,
|
||
ASSET_OBJECT_ID_PREFIX,
|
||
ASSET_BINDING_ID_PREFIX,
|
||
INITIAL_ASSET_OBJECT_VERSION,
|
||
INITIAL_STORY_SESSION_VERSION
|
||
);
|
||
}
|
||
|
||
// 成长状态默认按 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::<Vec<_>>();
|
||
|
||
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<BattleStateSnapshot, String> {
|
||
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<BattleStateSnapshot, String> {
|
||
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<RuntimeInventoryStateSnapshot, String> {
|
||
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::<Vec<_>>();
|
||
|
||
Ok(build_runtime_inventory_state_snapshot(
|
||
validated_input,
|
||
slots,
|
||
))
|
||
}
|
||
|
||
fn resolve_battle_state_record(
|
||
ctx: &ReducerContext,
|
||
input: ResolveCombatActionInput,
|
||
) -> Result<module_combat::ResolveCombatActionResult, String> {
|
||
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),
|
||
},
|
||
}
|
||
}
|
||
|
||
#[spacetimedb::procedure]
|
||
pub fn create_big_fish_session(
|
||
ctx: &mut ProcedureContext,
|
||
input: BigFishSessionCreateInput,
|
||
) -> BigFishSessionProcedureResult {
|
||
match ctx.try_with_tx(|tx| create_big_fish_session_tx(tx, input.clone())) {
|
||
Ok(session) => BigFishSessionProcedureResult {
|
||
ok: true,
|
||
session: Some(session),
|
||
error_message: None,
|
||
},
|
||
Err(message) => BigFishSessionProcedureResult {
|
||
ok: false,
|
||
session: None,
|
||
error_message: Some(message),
|
||
},
|
||
}
|
||
}
|
||
|
||
#[spacetimedb::procedure]
|
||
pub fn get_big_fish_session(
|
||
ctx: &mut ProcedureContext,
|
||
input: BigFishSessionGetInput,
|
||
) -> BigFishSessionProcedureResult {
|
||
match ctx.try_with_tx(|tx| get_big_fish_session_tx(tx, input.clone())) {
|
||
Ok(session) => BigFishSessionProcedureResult {
|
||
ok: true,
|
||
session: Some(session),
|
||
error_message: None,
|
||
},
|
||
Err(message) => BigFishSessionProcedureResult {
|
||
ok: false,
|
||
session: None,
|
||
error_message: Some(message),
|
||
},
|
||
}
|
||
}
|
||
|
||
#[spacetimedb::procedure]
|
||
pub fn submit_big_fish_message(
|
||
ctx: &mut ProcedureContext,
|
||
input: BigFishMessageSubmitInput,
|
||
) -> BigFishSessionProcedureResult {
|
||
match ctx.try_with_tx(|tx| submit_big_fish_message_tx(tx, input.clone())) {
|
||
Ok(session) => BigFishSessionProcedureResult {
|
||
ok: true,
|
||
session: Some(session),
|
||
error_message: None,
|
||
},
|
||
Err(message) => BigFishSessionProcedureResult {
|
||
ok: false,
|
||
session: None,
|
||
error_message: Some(message),
|
||
},
|
||
}
|
||
}
|
||
|
||
#[spacetimedb::procedure]
|
||
pub fn compile_big_fish_draft(
|
||
ctx: &mut ProcedureContext,
|
||
input: BigFishDraftCompileInput,
|
||
) -> BigFishSessionProcedureResult {
|
||
match ctx.try_with_tx(|tx| compile_big_fish_draft_tx(tx, input.clone())) {
|
||
Ok(session) => BigFishSessionProcedureResult {
|
||
ok: true,
|
||
session: Some(session),
|
||
error_message: None,
|
||
},
|
||
Err(message) => BigFishSessionProcedureResult {
|
||
ok: false,
|
||
session: None,
|
||
error_message: Some(message),
|
||
},
|
||
}
|
||
}
|
||
|
||
#[spacetimedb::procedure]
|
||
pub fn generate_big_fish_asset(
|
||
ctx: &mut ProcedureContext,
|
||
input: BigFishAssetGenerateInput,
|
||
) -> BigFishSessionProcedureResult {
|
||
match ctx.try_with_tx(|tx| generate_big_fish_asset_tx(tx, input.clone())) {
|
||
Ok(session) => BigFishSessionProcedureResult {
|
||
ok: true,
|
||
session: Some(session),
|
||
error_message: None,
|
||
},
|
||
Err(message) => BigFishSessionProcedureResult {
|
||
ok: false,
|
||
session: None,
|
||
error_message: Some(message),
|
||
},
|
||
}
|
||
}
|
||
|
||
#[spacetimedb::procedure]
|
||
pub fn publish_big_fish_game(
|
||
ctx: &mut ProcedureContext,
|
||
input: BigFishPublishInput,
|
||
) -> BigFishSessionProcedureResult {
|
||
match ctx.try_with_tx(|tx| publish_big_fish_game_tx(tx, input.clone())) {
|
||
Ok(session) => BigFishSessionProcedureResult {
|
||
ok: true,
|
||
session: Some(session),
|
||
error_message: None,
|
||
},
|
||
Err(message) => BigFishSessionProcedureResult {
|
||
ok: false,
|
||
session: None,
|
||
error_message: Some(message),
|
||
},
|
||
}
|
||
}
|
||
|
||
#[spacetimedb::procedure]
|
||
pub fn start_big_fish_run(
|
||
ctx: &mut ProcedureContext,
|
||
input: BigFishRunStartInput,
|
||
) -> BigFishRunProcedureResult {
|
||
match ctx.try_with_tx(|tx| start_big_fish_run_tx(tx, input.clone())) {
|
||
Ok(run) => BigFishRunProcedureResult {
|
||
ok: true,
|
||
run: Some(run),
|
||
error_message: None,
|
||
},
|
||
Err(message) => BigFishRunProcedureResult {
|
||
ok: false,
|
||
run: None,
|
||
error_message: Some(message),
|
||
},
|
||
}
|
||
}
|
||
|
||
#[spacetimedb::procedure]
|
||
pub fn submit_big_fish_input(
|
||
ctx: &mut ProcedureContext,
|
||
input: BigFishRunInputSubmitInput,
|
||
) -> BigFishRunProcedureResult {
|
||
match ctx.try_with_tx(|tx| submit_big_fish_input_tx(tx, input.clone())) {
|
||
Ok(run) => BigFishRunProcedureResult {
|
||
ok: true,
|
||
run: Some(run),
|
||
error_message: None,
|
||
},
|
||
Err(message) => BigFishRunProcedureResult {
|
||
ok: false,
|
||
run: None,
|
||
error_message: Some(message),
|
||
},
|
||
}
|
||
}
|
||
|
||
#[spacetimedb::procedure]
|
||
pub fn get_big_fish_run(
|
||
ctx: &mut ProcedureContext,
|
||
input: BigFishRunGetInput,
|
||
) -> BigFishRunProcedureResult {
|
||
match ctx.try_with_tx(|tx| get_big_fish_run_tx(tx, input.clone())) {
|
||
Ok(run) => BigFishRunProcedureResult {
|
||
ok: true,
|
||
run: Some(run),
|
||
error_message: None,
|
||
},
|
||
Err(message) => BigFishRunProcedureResult {
|
||
ok: false,
|
||
run: None,
|
||
error_message: Some(message),
|
||
},
|
||
}
|
||
}
|
||
|
||
// 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 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 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),
|
||
},
|
||
}
|
||
}
|
||
|
||
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<StoryEventSnapshot>), 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::<Vec<_>>();
|
||
events.sort_by_key(|event| (event.created_at_micros, event.event_id.clone()));
|
||
|
||
Ok((session_snapshot, events))
|
||
}
|
||
|
||
fn create_big_fish_session_tx(
|
||
ctx: &ReducerContext,
|
||
input: BigFishSessionCreateInput,
|
||
) -> Result<BigFishSessionSnapshot, String> {
|
||
validate_session_create_input(&input).map_err(|error| error.to_string())?;
|
||
if ctx
|
||
.db
|
||
.big_fish_creation_session()
|
||
.session_id()
|
||
.find(&input.session_id)
|
||
.is_some()
|
||
{
|
||
return Err("big_fish_creation_session.session_id 已存在".to_string());
|
||
}
|
||
if ctx
|
||
.db
|
||
.big_fish_agent_message()
|
||
.message_id()
|
||
.find(&input.welcome_message_id)
|
||
.is_some()
|
||
{
|
||
return Err("big_fish_agent_message.message_id 已存在".to_string());
|
||
}
|
||
|
||
let created_at = Timestamp::from_micros_since_unix_epoch(input.created_at_micros);
|
||
let anchor_pack = infer_anchor_pack(&input.seed_text, None);
|
||
let asset_coverage = build_asset_coverage(None, &[]);
|
||
ctx.db
|
||
.big_fish_creation_session()
|
||
.insert(BigFishCreationSession {
|
||
session_id: input.session_id.clone(),
|
||
owner_user_id: input.owner_user_id.clone(),
|
||
seed_text: input.seed_text.trim().to_string(),
|
||
current_turn: 0,
|
||
progress_percent: 20,
|
||
stage: BigFishCreationStage::CollectingAnchors,
|
||
anchor_pack_json: serialize_anchor_pack(&anchor_pack)
|
||
.map_err(|error| error.to_string())?,
|
||
draft_json: None,
|
||
asset_coverage_json: serialize_asset_coverage(&asset_coverage)
|
||
.map_err(|error| error.to_string())?,
|
||
last_assistant_reply: Some(input.welcome_message_text.clone()),
|
||
publish_ready: false,
|
||
created_at,
|
||
updated_at: created_at,
|
||
});
|
||
ctx.db.big_fish_agent_message().insert(BigFishAgentMessage {
|
||
message_id: input.welcome_message_id,
|
||
session_id: input.session_id.clone(),
|
||
role: BigFishAgentMessageRole::Assistant,
|
||
kind: BigFishAgentMessageKind::Chat,
|
||
text: input.welcome_message_text,
|
||
created_at,
|
||
});
|
||
|
||
get_big_fish_session_tx(
|
||
ctx,
|
||
BigFishSessionGetInput {
|
||
session_id: input.session_id,
|
||
owner_user_id: input.owner_user_id,
|
||
},
|
||
)
|
||
}
|
||
|
||
fn get_big_fish_session_tx(
|
||
ctx: &ReducerContext,
|
||
input: BigFishSessionGetInput,
|
||
) -> Result<BigFishSessionSnapshot, String> {
|
||
validate_session_get_input(&input).map_err(|error| error.to_string())?;
|
||
let session = ctx
|
||
.db
|
||
.big_fish_creation_session()
|
||
.session_id()
|
||
.find(&input.session_id)
|
||
.filter(|row| row.owner_user_id == input.owner_user_id)
|
||
.ok_or_else(|| "big_fish_creation_session 不存在".to_string())?;
|
||
|
||
build_big_fish_session_snapshot(ctx, &session)
|
||
}
|
||
|
||
fn submit_big_fish_message_tx(
|
||
ctx: &ReducerContext,
|
||
input: BigFishMessageSubmitInput,
|
||
) -> Result<BigFishSessionSnapshot, String> {
|
||
validate_message_submit_input(&input).map_err(|error| error.to_string())?;
|
||
let session = ctx
|
||
.db
|
||
.big_fish_creation_session()
|
||
.session_id()
|
||
.find(&input.session_id)
|
||
.filter(|row| row.owner_user_id == input.owner_user_id)
|
||
.ok_or_else(|| "big_fish_creation_session 不存在".to_string())?;
|
||
if ctx
|
||
.db
|
||
.big_fish_agent_message()
|
||
.message_id()
|
||
.find(&input.user_message_id)
|
||
.is_some()
|
||
{
|
||
return Err("big_fish_agent_message.user_message_id 已存在".to_string());
|
||
}
|
||
if ctx
|
||
.db
|
||
.big_fish_agent_message()
|
||
.message_id()
|
||
.find(&input.assistant_message_id)
|
||
.is_some()
|
||
{
|
||
return Err("big_fish_agent_message.assistant_message_id 已存在".to_string());
|
||
}
|
||
|
||
let submitted_at = Timestamp::from_micros_since_unix_epoch(input.submitted_at_micros);
|
||
ctx.db.big_fish_agent_message().insert(BigFishAgentMessage {
|
||
message_id: input.user_message_id,
|
||
session_id: input.session_id.clone(),
|
||
role: BigFishAgentMessageRole::User,
|
||
kind: BigFishAgentMessageKind::Chat,
|
||
text: input.user_message_text.trim().to_string(),
|
||
created_at: submitted_at,
|
||
});
|
||
|
||
let anchor_pack = infer_anchor_pack(&session.seed_text, Some(&input.user_message_text));
|
||
let assistant_text =
|
||
"我已经把这版方向收束成 4 个高杠杆锚点,可以继续细化,也可以直接编译第一版玩法草稿。"
|
||
.to_string();
|
||
ctx.db.big_fish_agent_message().insert(BigFishAgentMessage {
|
||
message_id: input.assistant_message_id,
|
||
session_id: input.session_id.clone(),
|
||
role: BigFishAgentMessageRole::Assistant,
|
||
kind: BigFishAgentMessageKind::Summary,
|
||
text: assistant_text.clone(),
|
||
created_at: submitted_at,
|
||
});
|
||
|
||
let next_session = BigFishCreationSession {
|
||
session_id: session.session_id.clone(),
|
||
owner_user_id: session.owner_user_id.clone(),
|
||
seed_text: session.seed_text.clone(),
|
||
current_turn: session.current_turn.saturating_add(1),
|
||
progress_percent: 60,
|
||
stage: BigFishCreationStage::CollectingAnchors,
|
||
anchor_pack_json: serialize_anchor_pack(&anchor_pack).map_err(|error| error.to_string())?,
|
||
draft_json: session.draft_json.clone(),
|
||
asset_coverage_json: session.asset_coverage_json.clone(),
|
||
last_assistant_reply: Some(assistant_text),
|
||
publish_ready: session.publish_ready,
|
||
created_at: session.created_at,
|
||
updated_at: submitted_at,
|
||
};
|
||
replace_big_fish_session(ctx, &session, next_session);
|
||
|
||
get_big_fish_session_tx(
|
||
ctx,
|
||
BigFishSessionGetInput {
|
||
session_id: input.session_id,
|
||
owner_user_id: input.owner_user_id,
|
||
},
|
||
)
|
||
}
|
||
|
||
fn compile_big_fish_draft_tx(
|
||
ctx: &ReducerContext,
|
||
input: BigFishDraftCompileInput,
|
||
) -> Result<BigFishSessionSnapshot, String> {
|
||
validate_draft_compile_input(&input).map_err(|error| error.to_string())?;
|
||
let session = ctx
|
||
.db
|
||
.big_fish_creation_session()
|
||
.session_id()
|
||
.find(&input.session_id)
|
||
.filter(|row| row.owner_user_id == input.owner_user_id)
|
||
.ok_or_else(|| "big_fish_creation_session 不存在".to_string())?;
|
||
let anchor_pack =
|
||
deserialize_anchor_pack(&session.anchor_pack_json).map_err(|error| error.to_string())?;
|
||
let draft = compile_default_draft(&anchor_pack);
|
||
let asset_slots = list_big_fish_asset_slots(ctx, &session.session_id);
|
||
let coverage = build_asset_coverage(Some(&draft), &asset_slots);
|
||
let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros);
|
||
let reply = "第一版玩法草稿已编译完成,可以在结果页逐级生成主图、动作和场地背景。".to_string();
|
||
|
||
let next_session = BigFishCreationSession {
|
||
session_id: session.session_id.clone(),
|
||
owner_user_id: session.owner_user_id.clone(),
|
||
seed_text: session.seed_text.clone(),
|
||
current_turn: session.current_turn,
|
||
progress_percent: 80,
|
||
stage: BigFishCreationStage::DraftReady,
|
||
anchor_pack_json: session.anchor_pack_json.clone(),
|
||
draft_json: Some(serialize_draft(&draft).map_err(|error| error.to_string())?),
|
||
asset_coverage_json: serialize_asset_coverage(&coverage)
|
||
.map_err(|error| error.to_string())?,
|
||
last_assistant_reply: Some(reply.clone()),
|
||
publish_ready: coverage.publish_ready,
|
||
created_at: session.created_at,
|
||
updated_at: compiled_at,
|
||
};
|
||
replace_big_fish_session(ctx, &session, next_session);
|
||
append_big_fish_system_message(
|
||
ctx,
|
||
&input.session_id,
|
||
format!("big-fish-message-compile-{}", input.compiled_at_micros),
|
||
reply,
|
||
input.compiled_at_micros,
|
||
);
|
||
|
||
get_big_fish_session_tx(
|
||
ctx,
|
||
BigFishSessionGetInput {
|
||
session_id: input.session_id,
|
||
owner_user_id: input.owner_user_id,
|
||
},
|
||
)
|
||
}
|
||
|
||
fn generate_big_fish_asset_tx(
|
||
ctx: &ReducerContext,
|
||
input: BigFishAssetGenerateInput,
|
||
) -> Result<BigFishSessionSnapshot, String> {
|
||
let session = ctx
|
||
.db
|
||
.big_fish_creation_session()
|
||
.session_id()
|
||
.find(&input.session_id)
|
||
.filter(|row| row.owner_user_id == input.owner_user_id)
|
||
.ok_or_else(|| "big_fish_creation_session 不存在".to_string())?;
|
||
let draft = session
|
||
.draft_json
|
||
.as_deref()
|
||
.ok_or_else(|| "big_fish.draft 尚未编译".to_string())
|
||
.and_then(|value| deserialize_draft(value).map_err(|error| error.to_string()))?;
|
||
validate_asset_generate_input(&input, &draft).map_err(|error| error.to_string())?;
|
||
|
||
let slot = build_generated_asset_slot(
|
||
&input.session_id,
|
||
&draft,
|
||
input.asset_kind,
|
||
input.level,
|
||
input.motion_key.clone(),
|
||
input.generated_at_micros,
|
||
)
|
||
.map_err(|error| error.to_string())?;
|
||
upsert_big_fish_asset_slot(ctx, slot);
|
||
|
||
let asset_slots = list_big_fish_asset_slots(ctx, &session.session_id);
|
||
let coverage = build_asset_coverage(Some(&draft), &asset_slots);
|
||
let updated_at = Timestamp::from_micros_since_unix_epoch(input.generated_at_micros);
|
||
let reply = match input.asset_kind {
|
||
BigFishAssetKind::LevelMainImage => "本级主图已生成并设为正式资产。",
|
||
BigFishAssetKind::LevelMotion => "本级动作已生成并设为正式资产。",
|
||
BigFishAssetKind::StageBackground => "活动区域背景已生成并设为正式资产。",
|
||
}
|
||
.to_string();
|
||
let next_stage = if coverage.publish_ready {
|
||
BigFishCreationStage::ReadyToPublish
|
||
} else {
|
||
BigFishCreationStage::AssetRefining
|
||
};
|
||
let next_session = BigFishCreationSession {
|
||
session_id: session.session_id.clone(),
|
||
owner_user_id: session.owner_user_id.clone(),
|
||
seed_text: session.seed_text.clone(),
|
||
current_turn: session.current_turn,
|
||
progress_percent: if coverage.publish_ready { 96 } else { 88 },
|
||
stage: next_stage,
|
||
anchor_pack_json: session.anchor_pack_json.clone(),
|
||
draft_json: session.draft_json.clone(),
|
||
asset_coverage_json: serialize_asset_coverage(&coverage)
|
||
.map_err(|error| error.to_string())?,
|
||
last_assistant_reply: Some(reply.clone()),
|
||
publish_ready: coverage.publish_ready,
|
||
created_at: session.created_at,
|
||
updated_at,
|
||
};
|
||
replace_big_fish_session(ctx, &session, next_session);
|
||
append_big_fish_system_message(
|
||
ctx,
|
||
&input.session_id,
|
||
format!("big-fish-message-asset-{}", input.generated_at_micros),
|
||
reply,
|
||
input.generated_at_micros,
|
||
);
|
||
|
||
get_big_fish_session_tx(
|
||
ctx,
|
||
BigFishSessionGetInput {
|
||
session_id: input.session_id,
|
||
owner_user_id: input.owner_user_id,
|
||
},
|
||
)
|
||
}
|
||
|
||
fn publish_big_fish_game_tx(
|
||
ctx: &ReducerContext,
|
||
input: BigFishPublishInput,
|
||
) -> Result<BigFishSessionSnapshot, String> {
|
||
validate_publish_input(&input).map_err(|error| error.to_string())?;
|
||
let session = ctx
|
||
.db
|
||
.big_fish_creation_session()
|
||
.session_id()
|
||
.find(&input.session_id)
|
||
.filter(|row| row.owner_user_id == input.owner_user_id)
|
||
.ok_or_else(|| "big_fish_creation_session 不存在".to_string())?;
|
||
let draft = session
|
||
.draft_json
|
||
.as_deref()
|
||
.ok_or_else(|| "big_fish.draft 尚未编译".to_string())
|
||
.and_then(|value| deserialize_draft(value).map_err(|error| error.to_string()))?;
|
||
let coverage = build_asset_coverage(
|
||
Some(&draft),
|
||
&list_big_fish_asset_slots(ctx, &session.session_id),
|
||
);
|
||
if !coverage.publish_ready {
|
||
return Err(format!(
|
||
"big_fish 发布校验未通过:{}",
|
||
coverage.blockers.join(";")
|
||
));
|
||
}
|
||
|
||
let published_at = Timestamp::from_micros_since_unix_epoch(input.published_at_micros);
|
||
let next_session = BigFishCreationSession {
|
||
session_id: session.session_id.clone(),
|
||
owner_user_id: session.owner_user_id.clone(),
|
||
seed_text: session.seed_text.clone(),
|
||
current_turn: session.current_turn,
|
||
progress_percent: 100,
|
||
stage: BigFishCreationStage::Published,
|
||
anchor_pack_json: session.anchor_pack_json.clone(),
|
||
draft_json: session.draft_json.clone(),
|
||
asset_coverage_json: serialize_asset_coverage(&coverage)
|
||
.map_err(|error| error.to_string())?,
|
||
last_assistant_reply: Some("玩法已发布,可以进入测试运行态。".to_string()),
|
||
publish_ready: true,
|
||
created_at: session.created_at,
|
||
updated_at: published_at,
|
||
};
|
||
replace_big_fish_session(ctx, &session, next_session);
|
||
|
||
get_big_fish_session_tx(
|
||
ctx,
|
||
BigFishSessionGetInput {
|
||
session_id: input.session_id,
|
||
owner_user_id: input.owner_user_id,
|
||
},
|
||
)
|
||
}
|
||
|
||
fn start_big_fish_run_tx(
|
||
ctx: &ReducerContext,
|
||
input: BigFishRunStartInput,
|
||
) -> Result<BigFishRuntimeSnapshot, String> {
|
||
validate_run_start_input(&input).map_err(|error| error.to_string())?;
|
||
if ctx
|
||
.db
|
||
.big_fish_runtime_run()
|
||
.run_id()
|
||
.find(&input.run_id)
|
||
.is_some()
|
||
{
|
||
return Err("big_fish_runtime_run.run_id 已存在".to_string());
|
||
}
|
||
let session = ctx
|
||
.db
|
||
.big_fish_creation_session()
|
||
.session_id()
|
||
.find(&input.session_id)
|
||
.filter(|row| row.owner_user_id == input.owner_user_id)
|
||
.ok_or_else(|| "big_fish_creation_session 不存在".to_string())?;
|
||
let draft = session
|
||
.draft_json
|
||
.as_deref()
|
||
.ok_or_else(|| "big_fish.draft 尚未编译".to_string())
|
||
.and_then(|value| deserialize_draft(value).map_err(|error| error.to_string()))?;
|
||
let snapshot = build_initial_runtime_snapshot(
|
||
input.run_id.clone(),
|
||
input.session_id.clone(),
|
||
&draft,
|
||
input.started_at_micros,
|
||
);
|
||
let now = Timestamp::from_micros_since_unix_epoch(input.started_at_micros);
|
||
ctx.db.big_fish_runtime_run().insert(BigFishRuntimeRun {
|
||
run_id: input.run_id,
|
||
session_id: input.session_id,
|
||
owner_user_id: input.owner_user_id,
|
||
status: snapshot.status,
|
||
snapshot_json: serialize_runtime_snapshot(&snapshot).map_err(|error| error.to_string())?,
|
||
last_input_x: 0.0,
|
||
last_input_y: 0.0,
|
||
tick: snapshot.tick,
|
||
created_at: now,
|
||
updated_at: now,
|
||
});
|
||
|
||
Ok(snapshot)
|
||
}
|
||
|
||
fn submit_big_fish_input_tx(
|
||
ctx: &ReducerContext,
|
||
input: BigFishRunInputSubmitInput,
|
||
) -> Result<BigFishRuntimeSnapshot, String> {
|
||
validate_run_input_submit_input(&input).map_err(|error| error.to_string())?;
|
||
let run = ctx
|
||
.db
|
||
.big_fish_runtime_run()
|
||
.run_id()
|
||
.find(&input.run_id)
|
||
.filter(|row| row.owner_user_id == input.owner_user_id)
|
||
.ok_or_else(|| "big_fish_runtime_run 不存在".to_string())?;
|
||
let session = ctx
|
||
.db
|
||
.big_fish_creation_session()
|
||
.session_id()
|
||
.find(&run.session_id)
|
||
.filter(|row| row.owner_user_id == input.owner_user_id)
|
||
.ok_or_else(|| "big_fish_creation_session 不存在".to_string())?;
|
||
let draft = session
|
||
.draft_json
|
||
.as_deref()
|
||
.ok_or_else(|| "big_fish.draft 尚未编译".to_string())
|
||
.and_then(|value| deserialize_draft(value).map_err(|error| error.to_string()))?;
|
||
let current_snapshot =
|
||
deserialize_runtime_snapshot(&run.snapshot_json).map_err(|error| error.to_string())?;
|
||
let next_snapshot = advance_runtime_snapshot(
|
||
current_snapshot,
|
||
&draft.runtime_params,
|
||
input.input_x,
|
||
input.input_y,
|
||
input.submitted_at_micros,
|
||
);
|
||
replace_big_fish_run(
|
||
ctx,
|
||
&run,
|
||
BigFishRuntimeRun {
|
||
run_id: run.run_id.clone(),
|
||
session_id: run.session_id.clone(),
|
||
owner_user_id: run.owner_user_id.clone(),
|
||
status: next_snapshot.status,
|
||
snapshot_json: serialize_runtime_snapshot(&next_snapshot)
|
||
.map_err(|error| error.to_string())?,
|
||
last_input_x: input.input_x,
|
||
last_input_y: input.input_y,
|
||
tick: next_snapshot.tick,
|
||
created_at: run.created_at,
|
||
updated_at: Timestamp::from_micros_since_unix_epoch(input.submitted_at_micros),
|
||
},
|
||
);
|
||
|
||
Ok(next_snapshot)
|
||
}
|
||
|
||
fn get_big_fish_run_tx(
|
||
ctx: &ReducerContext,
|
||
input: BigFishRunGetInput,
|
||
) -> Result<BigFishRuntimeSnapshot, String> {
|
||
validate_run_get_input(&input).map_err(|error| error.to_string())?;
|
||
let run = ctx
|
||
.db
|
||
.big_fish_runtime_run()
|
||
.run_id()
|
||
.find(&input.run_id)
|
||
.filter(|row| row.owner_user_id == input.owner_user_id)
|
||
.ok_or_else(|| "big_fish_runtime_run 不存在".to_string())?;
|
||
|
||
deserialize_runtime_snapshot(&run.snapshot_json).map_err(|error| error.to_string())
|
||
}
|
||
|
||
fn create_custom_world_agent_session_tx(
|
||
ctx: &ReducerContext,
|
||
input: CustomWorldAgentSessionCreateInput,
|
||
) -> Result<CustomWorldAgentSessionSnapshot, String> {
|
||
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<CustomWorldAgentSessionSnapshot, 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())?;
|
||
|
||
Ok(build_custom_world_agent_session_snapshot(ctx, &session))
|
||
}
|
||
|
||
fn submit_custom_world_agent_message_tx(
|
||
ctx: &ReducerContext,
|
||
input: CustomWorldAgentMessageSubmitInput,
|
||
) -> Result<CustomWorldAgentOperationSnapshot, String> {
|
||
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();
|
||
let assistant_message_id = format!("assistant-{}", input.operation_id);
|
||
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: 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,
|
||
});
|
||
|
||
let user_message_count = ctx
|
||
.db
|
||
.custom_world_agent_message()
|
||
.iter()
|
||
.filter(|row| {
|
||
row.session_id == input.session_id && matches!(row.role, RpgAgentMessageRole::User)
|
||
})
|
||
.count() as u32;
|
||
let next_turn = session.current_turn.saturating_add(1);
|
||
let (next_stage, next_progress_percent, next_readiness_json, next_pending_clarifications_json) =
|
||
if user_message_count >= 2 {
|
||
(
|
||
RpgAgentStage::FoundationReview,
|
||
100,
|
||
r#"{"isReady":true,"completedKeys":["seed_input"],"missingKeys":[]}"#.to_string(),
|
||
"[]".to_string(),
|
||
)
|
||
} else {
|
||
(
|
||
RpgAgentStage::Clarifying,
|
||
session.progress_percent.max(20),
|
||
session.creator_intent_readiness_json.clone(),
|
||
format!(
|
||
r#"[{{"id":"clarify-{next_turn}","label":"补充核心设定","question":"请继续补充这个世界的玩家身份、主题氛围或核心冲突。","targetKey":"core_conflict","priority":1}}]"#
|
||
),
|
||
)
|
||
};
|
||
let assistant_reply = "已记录这条设定。我会先把它当作新的世界线索收进当前草稿,你可以继续补充玩家身份、主题氛围、核心冲突、关键关系或标志性元素。".to_string();
|
||
|
||
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::Completed,
|
||
phase_label: "消息已处理".to_string(),
|
||
phase_detail: if next_stage == RpgAgentStage::FoundationReview {
|
||
"当前上下文已达到最小 foundation_review 门槛。".to_string()
|
||
} else {
|
||
"当前上下文已记录,继续收集世界关键锚点。".to_string()
|
||
},
|
||
progress: 100,
|
||
error_message: None,
|
||
created_at: submitted_at,
|
||
updated_at: submitted_at,
|
||
});
|
||
|
||
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.clone(),
|
||
related_operation_id: Some(input.operation_id.clone()),
|
||
created_at: submitted_at,
|
||
});
|
||
|
||
ctx.db
|
||
.custom_world_agent_session()
|
||
.session_id()
|
||
.update(CustomWorldAgentSession {
|
||
session_id: session.session_id.clone(),
|
||
owner_user_id: session.owner_user_id.clone(),
|
||
seed_text: session.seed_text.clone(),
|
||
current_turn: next_turn,
|
||
progress_percent: next_progress_percent,
|
||
stage: next_stage,
|
||
focus_card_id: session.focus_card_id.clone(),
|
||
anchor_content_json: session.anchor_content_json.clone(),
|
||
creator_intent_json: session.creator_intent_json.clone(),
|
||
creator_intent_readiness_json: next_readiness_json,
|
||
anchor_pack_json: session.anchor_pack_json.clone(),
|
||
lock_state_json: session.lock_state_json.clone(),
|
||
draft_profile_json: session.draft_profile_json.clone(),
|
||
last_assistant_reply: Some(assistant_reply),
|
||
publish_gate_json: session.publish_gate_json.clone(),
|
||
result_preview_json: session.result_preview_json.clone(),
|
||
pending_clarifications_json: next_pending_clarifications_json,
|
||
quality_findings_json: session.quality_findings_json.clone(),
|
||
suggested_actions_json: session.suggested_actions_json.clone(),
|
||
recommended_replies_json: session.recommended_replies_json.clone(),
|
||
asset_coverage_json: session.asset_coverage_json.clone(),
|
||
checkpoints_json: session.checkpoints_json.clone(),
|
||
created_at: session.created_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<CustomWorldAgentOperationSnapshot, String> {
|
||
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))
|
||
}
|
||
|
||
// AI 任务当前先固定成 private 真相表,后续由 Axum / platform-llm 再往外包一层 HTTP 与 SSE 协议。
|
||
#[spacetimedb::reducer]
|
||
pub fn create_ai_task(ctx: &ReducerContext, input: AiTaskCreateInput) -> Result<(), String> {
|
||
create_ai_task_tx(ctx, input).map(|_| ())
|
||
}
|
||
|
||
#[spacetimedb::procedure]
|
||
pub fn create_ai_task_and_return(
|
||
ctx: &mut ProcedureContext,
|
||
input: AiTaskCreateInput,
|
||
) -> AiTaskProcedureResult {
|
||
match ctx.try_with_tx(|tx| create_ai_task_tx(tx, input.clone())) {
|
||
Ok(task) => AiTaskProcedureResult {
|
||
ok: true,
|
||
task: Some(task),
|
||
text_chunk: None,
|
||
error_message: None,
|
||
},
|
||
Err(message) => AiTaskProcedureResult {
|
||
ok: false,
|
||
task: None,
|
||
text_chunk: None,
|
||
error_message: Some(message),
|
||
},
|
||
}
|
||
}
|
||
|
||
#[spacetimedb::reducer]
|
||
pub fn start_ai_task(ctx: &ReducerContext, input: AiTaskStartInput) -> Result<(), String> {
|
||
start_ai_task_tx(ctx, input).map(|_| ())
|
||
}
|
||
|
||
#[spacetimedb::reducer]
|
||
pub fn start_ai_task_stage(
|
||
ctx: &ReducerContext,
|
||
input: AiTaskStageStartInput,
|
||
) -> Result<(), String> {
|
||
start_ai_task_stage_tx(ctx, input).map(|_| ())
|
||
}
|
||
|
||
// 流式增量写入需要同步返回 chunk 与聚合后的任务快照,方便后续 Axum facade 直接复用。
|
||
#[spacetimedb::procedure]
|
||
pub fn append_ai_text_chunk_and_return(
|
||
ctx: &mut ProcedureContext,
|
||
input: AiTextChunkAppendInput,
|
||
) -> AiTaskProcedureResult {
|
||
match ctx.try_with_tx(|tx| append_ai_text_chunk_tx(tx, input.clone())) {
|
||
Ok((task, text_chunk)) => AiTaskProcedureResult {
|
||
ok: true,
|
||
task: Some(task),
|
||
text_chunk: Some(text_chunk),
|
||
error_message: None,
|
||
},
|
||
Err(message) => AiTaskProcedureResult {
|
||
ok: false,
|
||
task: None,
|
||
text_chunk: None,
|
||
error_message: Some(message),
|
||
},
|
||
}
|
||
}
|
||
|
||
#[spacetimedb::procedure]
|
||
pub fn complete_ai_stage_and_return(
|
||
ctx: &mut ProcedureContext,
|
||
input: AiStageCompletionInput,
|
||
) -> AiTaskProcedureResult {
|
||
match ctx.try_with_tx(|tx| complete_ai_stage_tx(tx, input.clone())) {
|
||
Ok(task) => AiTaskProcedureResult {
|
||
ok: true,
|
||
task: Some(task),
|
||
text_chunk: None,
|
||
error_message: None,
|
||
},
|
||
Err(message) => AiTaskProcedureResult {
|
||
ok: false,
|
||
task: None,
|
||
text_chunk: None,
|
||
error_message: Some(message),
|
||
},
|
||
}
|
||
}
|
||
|
||
#[spacetimedb::procedure]
|
||
pub fn attach_ai_result_reference_and_return(
|
||
ctx: &mut ProcedureContext,
|
||
input: AiResultReferenceInput,
|
||
) -> AiTaskProcedureResult {
|
||
match ctx.try_with_tx(|tx| attach_ai_result_reference_tx(tx, input.clone())) {
|
||
Ok(task) => AiTaskProcedureResult {
|
||
ok: true,
|
||
task: Some(task),
|
||
text_chunk: None,
|
||
error_message: None,
|
||
},
|
||
Err(message) => AiTaskProcedureResult {
|
||
ok: false,
|
||
task: None,
|
||
text_chunk: None,
|
||
error_message: Some(message),
|
||
},
|
||
}
|
||
}
|
||
|
||
#[spacetimedb::procedure]
|
||
pub fn complete_ai_task_and_return(
|
||
ctx: &mut ProcedureContext,
|
||
input: AiTaskFinishInput,
|
||
) -> AiTaskProcedureResult {
|
||
match ctx.try_with_tx(|tx| complete_ai_task_tx(tx, input.clone())) {
|
||
Ok(task) => AiTaskProcedureResult {
|
||
ok: true,
|
||
task: Some(task),
|
||
text_chunk: None,
|
||
error_message: None,
|
||
},
|
||
Err(message) => AiTaskProcedureResult {
|
||
ok: false,
|
||
task: None,
|
||
text_chunk: None,
|
||
error_message: Some(message),
|
||
},
|
||
}
|
||
}
|
||
|
||
#[spacetimedb::procedure]
|
||
pub fn fail_ai_task_and_return(
|
||
ctx: &mut ProcedureContext,
|
||
input: AiTaskFailureInput,
|
||
) -> AiTaskProcedureResult {
|
||
match ctx.try_with_tx(|tx| fail_ai_task_tx(tx, input.clone())) {
|
||
Ok(task) => AiTaskProcedureResult {
|
||
ok: true,
|
||
task: Some(task),
|
||
text_chunk: None,
|
||
error_message: None,
|
||
},
|
||
Err(message) => AiTaskProcedureResult {
|
||
ok: false,
|
||
task: None,
|
||
text_chunk: None,
|
||
error_message: Some(message),
|
||
},
|
||
}
|
||
}
|
||
|
||
#[spacetimedb::procedure]
|
||
pub fn cancel_ai_task_and_return(
|
||
ctx: &mut ProcedureContext,
|
||
input: AiTaskCancelInput,
|
||
) -> AiTaskProcedureResult {
|
||
match ctx.try_with_tx(|tx| cancel_ai_task_tx(tx, input.clone())) {
|
||
Ok(task) => AiTaskProcedureResult {
|
||
ok: true,
|
||
task: Some(task),
|
||
text_chunk: None,
|
||
error_message: None,
|
||
},
|
||
Err(message) => AiTaskProcedureResult {
|
||
ok: false,
|
||
task: None,
|
||
text_chunk: None,
|
||
error_message: Some(message),
|
||
},
|
||
}
|
||
}
|
||
|
||
// 当前阶段先把 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(())
|
||
}
|
||
|
||
// reducer 负责固定资产对象的正式写规则,供后续内部模块逻辑复用。
|
||
#[spacetimedb::reducer]
|
||
pub fn confirm_asset_object(
|
||
ctx: &ReducerContext,
|
||
input: AssetObjectUpsertInput,
|
||
) -> Result<(), String> {
|
||
upsert_asset_object(ctx, input).map(|_| ())
|
||
}
|
||
|
||
// procedure 面向 Axum 同步确认接口,返回最终持久化后的对象记录,避免 HTTP 层再额外查询 private table。
|
||
#[spacetimedb::procedure]
|
||
pub fn confirm_asset_object_and_return(
|
||
ctx: &mut ProcedureContext,
|
||
input: AssetObjectUpsertInput,
|
||
) -> AssetObjectProcedureResult {
|
||
match ctx.try_with_tx(|tx| upsert_asset_object(tx, input.clone())) {
|
||
Ok(record) => AssetObjectProcedureResult {
|
||
ok: true,
|
||
record: Some(record),
|
||
error_message: None,
|
||
},
|
||
Err(message) => AssetObjectProcedureResult {
|
||
ok: false,
|
||
record: None,
|
||
error_message: Some(message),
|
||
},
|
||
}
|
||
}
|
||
|
||
// reducer 负责把已确认对象绑定到实体槽位,强业务资产表稳定前先用通用绑定表承接关系。
|
||
#[spacetimedb::reducer]
|
||
pub fn bind_asset_object_to_entity(
|
||
ctx: &ReducerContext,
|
||
input: AssetEntityBindingInput,
|
||
) -> Result<(), String> {
|
||
upsert_asset_entity_binding(ctx, input).map(|_| ())
|
||
}
|
||
|
||
// procedure 面向 Axum 同步绑定接口,返回最终绑定快照,避免 HTTP 层读取 private table。
|
||
#[spacetimedb::procedure]
|
||
pub fn bind_asset_object_to_entity_and_return(
|
||
ctx: &mut ProcedureContext,
|
||
input: AssetEntityBindingInput,
|
||
) -> AssetEntityBindingProcedureResult {
|
||
match ctx.try_with_tx(|tx| upsert_asset_entity_binding(tx, input.clone())) {
|
||
Ok(record) => AssetEntityBindingProcedureResult {
|
||
ok: true,
|
||
record: Some(record),
|
||
error_message: None,
|
||
},
|
||
Err(message) => AssetEntityBindingProcedureResult {
|
||
ok: false,
|
||
record: None,
|
||
error_message: Some(message),
|
||
},
|
||
}
|
||
}
|
||
|
||
// procedure 面向 Axum 同步读取设置;若没有持久化记录则返回默认值快照,但不产生额外写入。
|
||
#[spacetimedb::procedure]
|
||
pub fn get_runtime_setting_or_default(
|
||
ctx: &mut ProcedureContext,
|
||
input: RuntimeSettingGetInput,
|
||
) -> RuntimeSettingProcedureResult {
|
||
match ctx.try_with_tx(|tx| get_runtime_setting_snapshot(tx, input.clone())) {
|
||
Ok(record) => RuntimeSettingProcedureResult {
|
||
ok: true,
|
||
record: Some(record),
|
||
error_message: None,
|
||
},
|
||
Err(message) => RuntimeSettingProcedureResult {
|
||
ok: false,
|
||
record: None,
|
||
error_message: Some(message),
|
||
},
|
||
}
|
||
}
|
||
|
||
// 当前快照读取保持旧 Node 语义:无快照时返回 ok=true + record=None,而不是默认空对象。
|
||
#[spacetimedb::procedure]
|
||
pub fn get_runtime_snapshot(
|
||
ctx: &mut ProcedureContext,
|
||
input: RuntimeSnapshotGetInput,
|
||
) -> RuntimeSnapshotProcedureResult {
|
||
match ctx.try_with_tx(|tx| get_runtime_snapshot_record(tx, input.clone())) {
|
||
Ok(record) => RuntimeSnapshotProcedureResult {
|
||
ok: true,
|
||
record,
|
||
error_message: None,
|
||
},
|
||
Err(message) => RuntimeSnapshotProcedureResult {
|
||
ok: false,
|
||
record: None,
|
||
error_message: Some(message),
|
||
},
|
||
}
|
||
}
|
||
|
||
// PUT snapshot 主链会同步刷新 dashboard / wallet / played_world / save_archive 四类 projection。
|
||
#[spacetimedb::procedure]
|
||
pub fn upsert_runtime_snapshot_and_return(
|
||
ctx: &mut ProcedureContext,
|
||
input: RuntimeSnapshotUpsertInput,
|
||
) -> RuntimeSnapshotProcedureResult {
|
||
match ctx.try_with_tx(|tx| upsert_runtime_snapshot_record(tx, input.clone())) {
|
||
Ok(record) => RuntimeSnapshotProcedureResult {
|
||
ok: true,
|
||
record: Some(record),
|
||
error_message: None,
|
||
},
|
||
Err(message) => RuntimeSnapshotProcedureResult {
|
||
ok: false,
|
||
record: None,
|
||
error_message: Some(message),
|
||
},
|
||
}
|
||
}
|
||
|
||
// 删除当前快照只影响 runtime_snapshot 主表,不联动清理 profile projection。
|
||
#[spacetimedb::procedure]
|
||
pub fn delete_runtime_snapshot_and_return(
|
||
ctx: &mut ProcedureContext,
|
||
input: RuntimeSnapshotDeleteInput,
|
||
) -> RuntimeSnapshotProcedureResult {
|
||
match ctx.try_with_tx(|tx| delete_runtime_snapshot_record(tx, input.clone())) {
|
||
Ok(record) => RuntimeSnapshotProcedureResult {
|
||
ok: true,
|
||
record,
|
||
error_message: None,
|
||
},
|
||
Err(message) => RuntimeSnapshotProcedureResult {
|
||
ok: false,
|
||
record: None,
|
||
error_message: Some(message),
|
||
},
|
||
}
|
||
}
|
||
|
||
// procedure 面向 Axum 同步写入设置,并返回最终归一化后的持久化结果。
|
||
#[spacetimedb::procedure]
|
||
pub fn upsert_runtime_setting_and_return(
|
||
ctx: &mut ProcedureContext,
|
||
input: RuntimeSettingUpsertInput,
|
||
) -> RuntimeSettingProcedureResult {
|
||
match ctx.try_with_tx(|tx| upsert_runtime_setting(tx, input.clone())) {
|
||
Ok(record) => RuntimeSettingProcedureResult {
|
||
ok: true,
|
||
record: Some(record),
|
||
error_message: None,
|
||
},
|
||
Err(message) => RuntimeSettingProcedureResult {
|
||
ok: false,
|
||
record: None,
|
||
error_message: Some(message),
|
||
},
|
||
}
|
||
}
|
||
|
||
// save archive 列表是按世界聚合后的最近一次快照视图,读取时只做排序,不再拼装默认值。
|
||
#[spacetimedb::procedure]
|
||
pub fn list_profile_save_archives(
|
||
ctx: &mut ProcedureContext,
|
||
input: RuntimeProfileSaveArchiveListInput,
|
||
) -> RuntimeProfileSaveArchiveProcedureResult {
|
||
match ctx.try_with_tx(|tx| list_profile_save_archive_rows(tx, input.clone())) {
|
||
Ok(entries) => RuntimeProfileSaveArchiveProcedureResult {
|
||
ok: true,
|
||
entries,
|
||
record: None,
|
||
current_snapshot: None,
|
||
error_message: None,
|
||
},
|
||
Err(message) => RuntimeProfileSaveArchiveProcedureResult {
|
||
ok: false,
|
||
entries: Vec::new(),
|
||
record: None,
|
||
current_snapshot: None,
|
||
error_message: Some(message),
|
||
},
|
||
}
|
||
}
|
||
|
||
// resume 会把指定 archive 回填到当前 snapshot,并同步返回 entry + 当前 snapshot。
|
||
#[spacetimedb::procedure]
|
||
pub fn resume_profile_save_archive_and_return(
|
||
ctx: &mut ProcedureContext,
|
||
input: RuntimeProfileSaveArchiveResumeInput,
|
||
) -> RuntimeProfileSaveArchiveProcedureResult {
|
||
match ctx.try_with_tx(|tx| resume_profile_save_archive_record(tx, input.clone())) {
|
||
Ok((record, current_snapshot)) => RuntimeProfileSaveArchiveProcedureResult {
|
||
ok: true,
|
||
entries: Vec::new(),
|
||
record: Some(record),
|
||
current_snapshot: Some(current_snapshot),
|
||
error_message: None,
|
||
},
|
||
Err(message) => RuntimeProfileSaveArchiveProcedureResult {
|
||
ok: false,
|
||
entries: Vec::new(),
|
||
record: None,
|
||
current_snapshot: None,
|
||
error_message: Some(message),
|
||
},
|
||
}
|
||
}
|
||
|
||
// profile dashboard 当前先作为 projection 读入口返回默认零值,等待 runtime_snapshot 写链补齐刷新。
|
||
#[spacetimedb::procedure]
|
||
pub fn get_profile_dashboard(
|
||
ctx: &mut ProcedureContext,
|
||
input: RuntimeProfileDashboardGetInput,
|
||
) -> RuntimeProfileDashboardProcedureResult {
|
||
match ctx.try_with_tx(|tx| get_profile_dashboard_snapshot(tx, input.clone())) {
|
||
Ok(record) => RuntimeProfileDashboardProcedureResult {
|
||
ok: true,
|
||
record: Some(record),
|
||
error_message: None,
|
||
},
|
||
Err(message) => RuntimeProfileDashboardProcedureResult {
|
||
ok: false,
|
||
record: None,
|
||
error_message: Some(message),
|
||
},
|
||
}
|
||
}
|
||
|
||
// 钱包流水当前只暴露最近 50 条只读视图,排序与截断逻辑在 procedure 内统一收口。
|
||
#[spacetimedb::procedure]
|
||
pub fn list_profile_wallet_ledger(
|
||
ctx: &mut ProcedureContext,
|
||
input: RuntimeProfileWalletLedgerListInput,
|
||
) -> RuntimeProfileWalletLedgerProcedureResult {
|
||
match ctx.try_with_tx(|tx| list_profile_wallet_ledger_entries(tx, input.clone())) {
|
||
Ok(entries) => RuntimeProfileWalletLedgerProcedureResult {
|
||
ok: true,
|
||
entries,
|
||
error_message: None,
|
||
},
|
||
Err(message) => RuntimeProfileWalletLedgerProcedureResult {
|
||
ok: false,
|
||
entries: Vec::new(),
|
||
error_message: Some(message),
|
||
},
|
||
}
|
||
}
|
||
|
||
// play stats 与 dashboard 共用 dashboard projection 的 total_play_time / updated_at,避免 Axum 侧拼装。
|
||
#[spacetimedb::procedure]
|
||
pub fn get_profile_play_stats(
|
||
ctx: &mut ProcedureContext,
|
||
input: RuntimeProfilePlayStatsGetInput,
|
||
) -> RuntimeProfilePlayStatsProcedureResult {
|
||
match ctx.try_with_tx(|tx| get_profile_play_stats_snapshot(tx, input.clone())) {
|
||
Ok(record) => RuntimeProfilePlayStatsProcedureResult {
|
||
ok: true,
|
||
record: Some(record),
|
||
error_message: None,
|
||
},
|
||
Err(message) => RuntimeProfilePlayStatsProcedureResult {
|
||
ok: false,
|
||
record: None,
|
||
error_message: Some(message),
|
||
},
|
||
}
|
||
}
|
||
|
||
// 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| Ok::<_, String>(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 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),
|
||
},
|
||
}
|
||
}
|
||
|
||
// procedure 面向 Axum 同步拉取浏览历史,继续沿用旧 Node 的 visitedAt 倒序输出语义。
|
||
#[spacetimedb::procedure]
|
||
pub fn list_platform_browse_history(
|
||
ctx: &mut ProcedureContext,
|
||
input: RuntimeBrowseHistoryListInput,
|
||
) -> RuntimeBrowseHistoryProcedureResult {
|
||
match ctx.try_with_tx(|tx| list_platform_browse_history_rows(tx, input.clone())) {
|
||
Ok(entries) => RuntimeBrowseHistoryProcedureResult {
|
||
ok: true,
|
||
entries,
|
||
error_message: None,
|
||
},
|
||
Err(message) => RuntimeBrowseHistoryProcedureResult {
|
||
ok: false,
|
||
entries: Vec::new(),
|
||
error_message: Some(message),
|
||
},
|
||
}
|
||
}
|
||
|
||
// procedure 面向 Axum 承接 browse history 的单条/批量 POST,同步返回当前用户的完整列表。
|
||
#[spacetimedb::procedure]
|
||
pub fn upsert_platform_browse_history_and_return(
|
||
ctx: &mut ProcedureContext,
|
||
input: RuntimeBrowseHistorySyncInput,
|
||
) -> RuntimeBrowseHistoryProcedureResult {
|
||
match ctx.try_with_tx(|tx| upsert_platform_browse_history_rows(tx, input.clone())) {
|
||
Ok(entries) => RuntimeBrowseHistoryProcedureResult {
|
||
ok: true,
|
||
entries,
|
||
error_message: None,
|
||
},
|
||
Err(message) => RuntimeBrowseHistoryProcedureResult {
|
||
ok: false,
|
||
entries: Vec::new(),
|
||
error_message: Some(message),
|
||
},
|
||
}
|
||
}
|
||
|
||
// procedure 面向 Axum 清空当前用户浏览历史,并直接返回空列表响应。
|
||
#[spacetimedb::procedure]
|
||
pub fn clear_platform_browse_history_and_return(
|
||
ctx: &mut ProcedureContext,
|
||
input: RuntimeBrowseHistoryClearInput,
|
||
) -> RuntimeBrowseHistoryProcedureResult {
|
||
match ctx.try_with_tx(|tx| clear_platform_browse_history_rows(tx, input.clone())) {
|
||
Ok(entries) => RuntimeBrowseHistoryProcedureResult {
|
||
ok: true,
|
||
entries,
|
||
error_message: None,
|
||
},
|
||
Err(message) => RuntimeBrowseHistoryProcedureResult {
|
||
ok: false,
|
||
entries: Vec::new(),
|
||
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<TreasureRecordSnapshot, String> {
|
||
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_asset_object(
|
||
ctx: &ReducerContext,
|
||
input: AssetObjectUpsertInput,
|
||
) -> Result<AssetObjectUpsertSnapshot, String> {
|
||
validate_asset_object_fields(
|
||
&input.bucket,
|
||
&input.object_key,
|
||
&input.asset_kind,
|
||
input.version,
|
||
)
|
||
.map_err(|error| error.to_string())?;
|
||
|
||
let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
|
||
// 这里先保持最小可发布实现:查重语义已经冻结,后续再把实现优化回组合索引扫描。
|
||
let current = ctx
|
||
.db
|
||
.asset_object()
|
||
.iter()
|
||
.find(|row| row.bucket == input.bucket && row.object_key == input.object_key);
|
||
|
||
let snapshot = match current {
|
||
Some(existing) => {
|
||
ctx.db
|
||
.asset_object()
|
||
.asset_object_id()
|
||
.delete(&existing.asset_object_id);
|
||
let row = AssetObject {
|
||
asset_object_id: existing.asset_object_id.clone(),
|
||
bucket: input.bucket.clone(),
|
||
object_key: input.object_key.clone(),
|
||
access_policy: input.access_policy,
|
||
content_type: input.content_type.clone(),
|
||
content_length: input.content_length,
|
||
content_hash: input.content_hash.clone(),
|
||
version: input.version,
|
||
source_job_id: input.source_job_id.clone(),
|
||
owner_user_id: input.owner_user_id.clone(),
|
||
profile_id: input.profile_id.clone(),
|
||
entity_id: input.entity_id.clone(),
|
||
asset_kind: input.asset_kind.clone(),
|
||
created_at: existing.created_at,
|
||
updated_at,
|
||
};
|
||
ctx.db.asset_object().insert(row);
|
||
|
||
AssetObjectUpsertSnapshot {
|
||
asset_object_id: existing.asset_object_id,
|
||
bucket: input.bucket,
|
||
object_key: input.object_key,
|
||
access_policy: input.access_policy,
|
||
content_type: input.content_type,
|
||
content_length: input.content_length,
|
||
content_hash: input.content_hash,
|
||
version: input.version,
|
||
source_job_id: input.source_job_id,
|
||
owner_user_id: input.owner_user_id,
|
||
profile_id: input.profile_id,
|
||
entity_id: input.entity_id,
|
||
asset_kind: input.asset_kind,
|
||
created_at_micros: existing.created_at.to_micros_since_unix_epoch(),
|
||
updated_at_micros: input.updated_at_micros,
|
||
}
|
||
}
|
||
None => {
|
||
let created_at = updated_at;
|
||
let row = AssetObject {
|
||
asset_object_id: input.asset_object_id.clone(),
|
||
bucket: input.bucket.clone(),
|
||
object_key: input.object_key.clone(),
|
||
access_policy: input.access_policy,
|
||
content_type: input.content_type.clone(),
|
||
content_length: input.content_length,
|
||
content_hash: input.content_hash.clone(),
|
||
version: input.version,
|
||
source_job_id: input.source_job_id.clone(),
|
||
owner_user_id: input.owner_user_id.clone(),
|
||
profile_id: input.profile_id.clone(),
|
||
entity_id: input.entity_id.clone(),
|
||
asset_kind: input.asset_kind.clone(),
|
||
created_at,
|
||
updated_at,
|
||
};
|
||
ctx.db.asset_object().insert(row);
|
||
|
||
AssetObjectUpsertSnapshot {
|
||
asset_object_id: input.asset_object_id,
|
||
bucket: input.bucket,
|
||
object_key: input.object_key,
|
||
access_policy: input.access_policy,
|
||
content_type: input.content_type,
|
||
content_length: input.content_length,
|
||
content_hash: input.content_hash,
|
||
version: input.version,
|
||
source_job_id: input.source_job_id,
|
||
owner_user_id: input.owner_user_id,
|
||
profile_id: input.profile_id,
|
||
entity_id: input.entity_id,
|
||
asset_kind: input.asset_kind,
|
||
created_at_micros: input.updated_at_micros,
|
||
updated_at_micros: input.updated_at_micros,
|
||
}
|
||
}
|
||
};
|
||
|
||
Ok(snapshot)
|
||
}
|
||
|
||
fn upsert_custom_world_profile_record(
|
||
ctx: &ReducerContext,
|
||
input: CustomWorldProfileUpsertInput,
|
||
) -> Result<
|
||
(
|
||
CustomWorldProfileSnapshot,
|
||
Option<CustomWorldGalleryEntrySnapshot>,
|
||
),
|
||
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);
|
||
|
||
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(),
|
||
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,
|
||
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(),
|
||
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,
|
||
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<CustomWorldGalleryEntrySnapshot>,
|
||
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(),
|
||
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(),
|
||
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<CustomWorldGalleryEntrySnapshot>,
|
||
),
|
||
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(),
|
||
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,
|
||
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<CustomWorldGalleryEntrySnapshot>,
|
||
),
|
||
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(),
|
||
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,
|
||
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(),
|
||
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,
|
||
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<Vec<CustomWorldProfileSnapshot>, String> {
|
||
validate_custom_world_profile_list_input(&input).map_err(|error| error.to_string())?;
|
||
|
||
let mut entries = ctx
|
||
.db
|
||
.custom_world_profile()
|
||
.iter()
|
||
.filter(|row| row.owner_user_id == input.owner_user_id && row.deleted_at.is_none())
|
||
.map(|row| build_custom_world_profile_snapshot(&row))
|
||
.collect::<Vec<_>>();
|
||
|
||
entries.sort_by(|left, right| right.updated_at_micros.cmp(&left.updated_at_micros));
|
||
|
||
Ok(entries)
|
||
}
|
||
|
||
fn list_custom_world_gallery_snapshots(
|
||
ctx: &ReducerContext,
|
||
) -> Vec<CustomWorldGalleryEntrySnapshot> {
|
||
let mut entries = ctx
|
||
.db
|
||
.custom_world_gallery_entry()
|
||
.iter()
|
||
.map(|row| build_custom_world_gallery_entry_snapshot(&row))
|
||
.collect::<Vec<_>>();
|
||
|
||
entries.sort_by(|left, right| {
|
||
right
|
||
.published_at_micros
|
||
.cmp(&left.published_at_micros)
|
||
.then(right.updated_at_micros.cmp(&left.updated_at_micros))
|
||
});
|
||
|
||
entries
|
||
}
|
||
|
||
fn get_custom_world_library_detail_record(
|
||
ctx: &ReducerContext,
|
||
input: CustomWorldLibraryDetailInput,
|
||
) -> Result<
|
||
(
|
||
Option<CustomWorldProfileSnapshot>,
|
||
Option<CustomWorldGalleryEntrySnapshot>,
|
||
),
|
||
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(build_custom_world_gallery_entry_snapshot),
|
||
))
|
||
}
|
||
|
||
fn get_custom_world_gallery_detail_record(
|
||
ctx: &ReducerContext,
|
||
input: CustomWorldGalleryDetailInput,
|
||
) -> Result<
|
||
(
|
||
Option<CustomWorldProfileSnapshot>,
|
||
Option<CustomWorldGalleryEntrySnapshot>,
|
||
),
|
||
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(build_custom_world_gallery_entry_snapshot),
|
||
))
|
||
}
|
||
|
||
fn list_custom_world_work_snapshots(
|
||
ctx: &ReducerContext,
|
||
input: CustomWorldWorksListInput,
|
||
) -> Result<Vec<CustomWorldWorkSummarySnapshot>, String> {
|
||
validate_custom_world_works_list_input(&input).map_err(|error| error.to_string())?;
|
||
|
||
let mut items = Vec::new();
|
||
|
||
for session in ctx.db.custom_world_agent_session().iter().filter(|row| {
|
||
row.owner_user_id == input.owner_user_id && row.stage != RpgAgentStage::Published
|
||
}) {
|
||
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())
|
||
{
|
||
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 get_custom_world_agent_card_detail_tx(
|
||
ctx: &ReducerContext,
|
||
input: CustomWorldAgentCardDetailGetInput,
|
||
) -> Result<CustomWorldDraftCardDetailSnapshot, String> {
|
||
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<CustomWorldAgentOperationSnapshot, String> {
|
||
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 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 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<String, JsonValue>,
|
||
) -> Result<CustomWorldAgentOperationSnapshot, String> {
|
||
if session.progress_percent < 100 {
|
||
return Err("draft_foundation requires progressPercent >= 100".to_string());
|
||
}
|
||
|
||
let updated_at = input.submitted_at_micros;
|
||
let draft_profile =
|
||
if let Some(profile) = payload.get("draftProfile").and_then(JsonValue::as_object) {
|
||
profile.clone()
|
||
} else if let Some(existing) =
|
||
parse_optional_session_object(session.draft_profile_json.as_deref())
|
||
{
|
||
ensure_minimal_draft_profile(existing, &session.seed_text)
|
||
} else {
|
||
build_minimal_draft_profile_from_seed(&session.seed_text)
|
||
};
|
||
|
||
let draft_profile_json = serde_json::to_string(&JsonValue::Object(draft_profile.clone()))
|
||
.map_err(|error| format!("draft_foundation 无法序列化 draft_profile_json: {error}"))?;
|
||
let 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 = build_and_insert_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<String, JsonValue>,
|
||
) -> Result<CustomWorldAgentOperationSnapshot, String> {
|
||
ensure_refining_stage(session.stage, "update_draft_card")?;
|
||
|
||
let card_id =
|
||
read_required_payload_text(payload, "cardId", "update_draft_card requires cardId")?;
|
||
let card = 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::<JsonValue>(&card.linked_ids_json)
|
||
.unwrap_or_else(|_| JsonValue::Array(Vec::new())),
|
||
);
|
||
detail_object.insert("locked".to_string(), JsonValue::Bool(false));
|
||
detail_object.insert("editable".to_string(), JsonValue::Bool(false));
|
||
detail_object.insert(
|
||
"editableSectionIds".to_string(),
|
||
JsonValue::Array(Vec::new()),
|
||
);
|
||
detail_object.insert("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<String, JsonValue>,
|
||
) -> Result<CustomWorldAgentOperationSnapshot, String> {
|
||
ensure_refining_stage(session.stage, "sync_result_profile")?;
|
||
let profile = payload
|
||
.get("profile")
|
||
.and_then(JsonValue::as_object)
|
||
.cloned()
|
||
.ok_or_else(|| "sync_result_profile requires profile".to_string())?;
|
||
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 execute_publish_world_action(
|
||
ctx: &ReducerContext,
|
||
session: &CustomWorldAgentSession,
|
||
input: &CustomWorldAgentActionExecuteInput,
|
||
payload: &JsonMap<String, JsonValue>,
|
||
) -> Result<CustomWorldAgentOperationSnapshot, String> {
|
||
ensure_publishable_stage(session.stage, "publish_world")?;
|
||
|
||
let draft_profile =
|
||
if let Some(explicit) = payload.get("draftProfile").and_then(JsonValue::as_object) {
|
||
explicit.clone()
|
||
} else {
|
||
parse_optional_session_object(session.draft_profile_json.as_deref())
|
||
.ok_or_else(|| "publish_world requires draft_profile_json".to_string())?
|
||
};
|
||
let 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 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(),
|
||
draft_profile_json: serialize_json_value(&JsonValue::Object(draft_profile.clone()))?,
|
||
legacy_result_profile_json,
|
||
setting_text,
|
||
author_display_name: "创作者".to_string(),
|
||
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<String, JsonValue>,
|
||
) -> Result<CustomWorldAgentOperationSnapshot, String> {
|
||
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<CustomWorldAgentOperationSnapshot, String> {
|
||
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 {
|
||
progress_percent: Option<u32>,
|
||
stage: Option<RpgAgentStage>,
|
||
focus_card_id: Option<Option<String>>,
|
||
anchor_content_json: Option<String>,
|
||
creator_intent_json: Option<Option<String>>,
|
||
creator_intent_readiness_json: Option<String>,
|
||
anchor_pack_json: Option<Option<String>>,
|
||
lock_state_json: Option<Option<String>>,
|
||
draft_profile_json: Option<Option<String>>,
|
||
last_assistant_reply: Option<Option<String>>,
|
||
publish_gate_json: Option<Option<String>>,
|
||
result_preview_json: Option<Option<String>>,
|
||
pending_clarifications_json: Option<String>,
|
||
quality_findings_json: Option<String>,
|
||
suggested_actions_json: Option<String>,
|
||
recommended_replies_json: Option<String>,
|
||
asset_coverage_json: Option<String>,
|
||
checkpoints_json: Option<String>,
|
||
updated_at_micros: Option<i64>,
|
||
}
|
||
|
||
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<String, JsonValue>>,
|
||
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"]).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"]).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(),
|
||
});
|
||
}
|
||
if profile
|
||
.get("chapters")
|
||
.and_then(JsonValue::as_array)
|
||
.map(|value| value.is_empty())
|
||
.unwrap_or(true)
|
||
{
|
||
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("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::<Vec<_>>(),
|
||
"blockerCount": gate.blocker_count,
|
||
"publishReady": gate.publish_ready,
|
||
"canEnterWorld": gate.can_enter_world,
|
||
})
|
||
}
|
||
|
||
fn build_result_preview_json(
|
||
draft_profile: Option<&JsonMap<String, JsonValue>>,
|
||
gate: &CustomWorldPublishGateSnapshot,
|
||
quality_findings: &[JsonValue],
|
||
generated_at_micros: i64,
|
||
) -> Result<Option<String>, 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::<Vec<_>>(),
|
||
"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<JsonValue> {
|
||
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<String>) -> JsonValue {
|
||
json!({
|
||
"action": action,
|
||
"enabled": enabled,
|
||
"reason": reason,
|
||
})
|
||
}
|
||
|
||
fn build_custom_world_draft_card_detail_snapshot(
|
||
card: &CustomWorldDraftCard,
|
||
) -> Result<CustomWorldDraftCardDetailSnapshot, String> {
|
||
if let Some(detail_payload_json) = card.detail_payload_json.as_deref() {
|
||
let detail_value =
|
||
serde_json::from_str::<JsonValue>(detail_payload_json).map_err(|error| {
|
||
format!("custom_world_draft_card.detail_payload_json 非法: {error}")
|
||
})?;
|
||
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::<Vec<_>>()
|
||
})
|
||
.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<CustomWorldDraftCardDetailSectionSnapshot> {
|
||
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<JsonValue> {
|
||
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<CustomWorldAgentSession, String> {
|
||
Ok(CustomWorldAgentSession {
|
||
session_id: current.session_id.clone(),
|
||
owner_user_id: current.owner_user_id.clone(),
|
||
seed_text: current.seed_text.clone(),
|
||
current_turn: current.current_turn,
|
||
progress_percent: patch.progress_percent.unwrap_or(current.progress_percent),
|
||
stage: patch.stage.unwrap_or(current.stage),
|
||
focus_card_id: patch
|
||
.focus_card_id
|
||
.unwrap_or_else(|| current.focus_card_id.clone()),
|
||
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 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_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 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<String, JsonValue>,
|
||
updated_at_micros: i64,
|
||
) -> Result<(), String> {
|
||
let card_id = "world-foundation".to_string();
|
||
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) = ctx
|
||
.db
|
||
.custom_world_draft_card()
|
||
.card_id()
|
||
.find(&card_id)
|
||
.filter(|row| row.session_id == session_id)
|
||
{
|
||
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 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<CustomWorldAgentSession, String> {
|
||
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_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<RpgAgentOperationType> {
|
||
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<RpgAgentStage> {
|
||
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<JsonMap<String, JsonValue>> {
|
||
value
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.and_then(|value| serde_json::from_str::<JsonValue>(value).ok())
|
||
.and_then(|value| value.as_object().cloned())
|
||
}
|
||
|
||
fn parse_json_array_or_empty(raw: &str) -> Vec<JsonValue> {
|
||
serde_json::from_str::<JsonValue>(raw)
|
||
.ok()
|
||
.and_then(|value| value.as_array().cloned())
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
fn serialize_json_value(value: &JsonValue) -> Result<String, String> {
|
||
serde_json::to_string(value).map_err(|error| format!("JSON 序列化失败: {error}"))
|
||
}
|
||
|
||
fn read_required_payload_text(
|
||
payload: &JsonMap<String, JsonValue>,
|
||
key: &str,
|
||
error_message: &str,
|
||
) -> Result<String, String> {
|
||
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<String, JsonValue>, keys: &[&str]) -> Option<String> {
|
||
for key in keys {
|
||
let mut current = JsonValue::Object(object.clone());
|
||
let mut found = true;
|
||
for segment in key.split('.') {
|
||
if let Some(next) = current.get(segment) {
|
||
current = next.clone();
|
||
} else {
|
||
found = false;
|
||
break;
|
||
}
|
||
}
|
||
if found {
|
||
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, JsonValue>>,
|
||
) -> 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, JsonValue>>,
|
||
) -> 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<String, JsonValue>>,
|
||
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<String, JsonValue>>,
|
||
) -> Option<String> {
|
||
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<String, JsonValue>>,
|
||
) -> (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<String, JsonValue>,
|
||
seed_text: &str,
|
||
) -> JsonMap<String, JsonValue> {
|
||
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
|
||
}
|
||
|
||
fn build_minimal_draft_profile_from_seed(seed_text: &str) -> JsonMap<String, JsonValue> {
|
||
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<String, String> {
|
||
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<String> {
|
||
sections.iter().find_map(|entry| {
|
||
let object = entry.as_object()?;
|
||
(object.get("id").and_then(JsonValue::as_str) == Some(target_id)).then(|| {
|
||
object
|
||
.get("value")
|
||
.and_then(JsonValue::as_str)
|
||
.unwrap_or_default()
|
||
.to_string()
|
||
})
|
||
})
|
||
}
|
||
|
||
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<RpgAgentStage, String> {
|
||
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<CustomWorldGalleryEntrySnapshot, String> {
|
||
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(),
|
||
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,
|
||
published_at,
|
||
updated_at: profile.updated_at,
|
||
};
|
||
|
||
let inserted = ctx.db.custom_world_gallery_entry().insert(row);
|
||
|
||
Ok(build_custom_world_gallery_entry_snapshot(&inserted))
|
||
}
|
||
|
||
fn build_custom_world_profile_snapshot(row: &CustomWorldProfile) -> CustomWorldProfileSnapshot {
|
||
CustomWorldProfileSnapshot {
|
||
profile_id: row.profile_id.clone(),
|
||
owner_user_id: row.owner_user_id.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,
|
||
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::<Vec<_>>();
|
||
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::<Vec<_>>();
|
||
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::<Vec<_>>();
|
||
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(
|
||
row: &CustomWorldGalleryEntry,
|
||
) -> CustomWorldGalleryEntrySnapshot {
|
||
CustomWorldGalleryEntrySnapshot {
|
||
profile_id: row.profile_id.clone(),
|
||
owner_user_id: row.owner_user_id.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,
|
||
published_at_micros: row.published_at.to_micros_since_unix_epoch(),
|
||
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
|
||
}
|
||
}
|
||
|
||
fn create_ai_task_tx(
|
||
ctx: &ReducerContext,
|
||
input: AiTaskCreateInput,
|
||
) -> Result<AiTaskSnapshot, String> {
|
||
validate_task_create_input(&input).map_err(|error| error.to_string())?;
|
||
|
||
if ctx.db.ai_task().task_id().find(&input.task_id).is_some() {
|
||
return Err("ai_task.task_id 已存在".to_string());
|
||
}
|
||
|
||
let task_snapshot = build_ai_task_snapshot_from_create_input(&input);
|
||
ctx.db.ai_task().insert(build_ai_task_row(&task_snapshot));
|
||
replace_ai_task_stages(ctx, &task_snapshot.task_id, &task_snapshot.stages);
|
||
|
||
get_ai_task_snapshot_tx(ctx, &task_snapshot.task_id)
|
||
}
|
||
|
||
fn start_ai_task_tx(
|
||
ctx: &ReducerContext,
|
||
input: AiTaskStartInput,
|
||
) -> Result<AiTaskSnapshot, String> {
|
||
let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?;
|
||
ensure_ai_task_can_transition(snapshot.status)?;
|
||
|
||
snapshot.status = AiTaskStatus::Running;
|
||
if snapshot.started_at_micros.is_none() {
|
||
snapshot.started_at_micros = Some(input.started_at_micros);
|
||
}
|
||
snapshot.updated_at_micros = input.started_at_micros;
|
||
snapshot.version += 1;
|
||
|
||
persist_ai_task_snapshot(ctx, &snapshot)?;
|
||
Ok(snapshot)
|
||
}
|
||
|
||
fn start_ai_task_stage_tx(
|
||
ctx: &ReducerContext,
|
||
input: AiTaskStageStartInput,
|
||
) -> Result<AiTaskSnapshot, String> {
|
||
let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?;
|
||
ensure_ai_task_can_transition(snapshot.status)?;
|
||
|
||
let stage = snapshot
|
||
.stages
|
||
.iter_mut()
|
||
.find(|stage| stage.stage_kind == input.stage_kind)
|
||
.ok_or_else(|| "ai_task.stage 不存在".to_string())?;
|
||
|
||
snapshot.status = AiTaskStatus::Running;
|
||
if snapshot.started_at_micros.is_none() {
|
||
snapshot.started_at_micros = Some(input.started_at_micros);
|
||
}
|
||
stage.status = AiTaskStageStatus::Running;
|
||
if stage.started_at_micros.is_none() {
|
||
stage.started_at_micros = Some(input.started_at_micros);
|
||
}
|
||
snapshot.updated_at_micros = input.started_at_micros;
|
||
snapshot.version += 1;
|
||
|
||
persist_ai_task_snapshot(ctx, &snapshot)?;
|
||
Ok(snapshot)
|
||
}
|
||
|
||
fn append_ai_text_chunk_tx(
|
||
ctx: &ReducerContext,
|
||
input: AiTextChunkAppendInput,
|
||
) -> Result<(AiTaskSnapshot, AiTextChunkSnapshot), String> {
|
||
if input.delta_text.trim().is_empty() {
|
||
return Err("ai_text_chunk.delta_text 不能为空".to_string());
|
||
}
|
||
if input.sequence == 0 {
|
||
return Err("ai_text_chunk.sequence 必须大于 0".to_string());
|
||
}
|
||
|
||
let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?;
|
||
ensure_ai_task_can_transition(snapshot.status)?;
|
||
|
||
let stage = snapshot
|
||
.stages
|
||
.iter_mut()
|
||
.find(|stage| stage.stage_kind == input.stage_kind)
|
||
.ok_or_else(|| "ai_task.stage 不存在".to_string())?;
|
||
|
||
let chunk = AiTextChunkSnapshot {
|
||
chunk_id: generate_ai_text_chunk_id(input.created_at_micros, input.sequence),
|
||
task_id: input.task_id.trim().to_string(),
|
||
stage_kind: input.stage_kind,
|
||
sequence: input.sequence,
|
||
delta_text: input.delta_text.trim().to_string(),
|
||
created_at_micros: input.created_at_micros,
|
||
};
|
||
ctx.db
|
||
.ai_text_chunk()
|
||
.insert(build_ai_text_chunk_row(&chunk));
|
||
|
||
let aggregated_text = collect_ai_stage_text_output(ctx, &chunk.task_id, chunk.stage_kind);
|
||
|
||
snapshot.status = AiTaskStatus::Running;
|
||
if snapshot.started_at_micros.is_none() {
|
||
snapshot.started_at_micros = Some(input.created_at_micros);
|
||
}
|
||
stage.status = AiTaskStageStatus::Running;
|
||
if stage.started_at_micros.is_none() {
|
||
stage.started_at_micros = Some(input.created_at_micros);
|
||
}
|
||
stage.text_output = aggregated_text.clone();
|
||
snapshot.latest_text_output = aggregated_text;
|
||
snapshot.updated_at_micros = input.created_at_micros;
|
||
snapshot.version += 1;
|
||
|
||
persist_ai_task_snapshot(ctx, &snapshot)?;
|
||
Ok((snapshot, chunk))
|
||
}
|
||
|
||
fn complete_ai_stage_tx(
|
||
ctx: &ReducerContext,
|
||
input: AiStageCompletionInput,
|
||
) -> Result<AiTaskSnapshot, String> {
|
||
let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?;
|
||
ensure_ai_task_can_transition(snapshot.status)?;
|
||
|
||
let stage = snapshot
|
||
.stages
|
||
.iter_mut()
|
||
.find(|stage| stage.stage_kind == input.stage_kind)
|
||
.ok_or_else(|| "ai_task.stage 不存在".to_string())?;
|
||
|
||
stage.status = AiTaskStageStatus::Completed;
|
||
stage.completed_at_micros = Some(input.completed_at_micros);
|
||
stage.text_output = normalize_optional_text(input.text_output.clone());
|
||
stage.structured_payload_json = normalize_optional_text(input.structured_payload_json.clone());
|
||
stage.warning_messages = normalize_string_list(input.warning_messages.clone());
|
||
|
||
snapshot.latest_text_output = stage.text_output.clone();
|
||
snapshot.latest_structured_payload_json = stage.structured_payload_json.clone();
|
||
snapshot.updated_at_micros = input.completed_at_micros;
|
||
snapshot.version += 1;
|
||
|
||
persist_ai_task_snapshot(ctx, &snapshot)?;
|
||
Ok(snapshot)
|
||
}
|
||
|
||
fn attach_ai_result_reference_tx(
|
||
ctx: &ReducerContext,
|
||
input: AiResultReferenceInput,
|
||
) -> Result<AiTaskSnapshot, String> {
|
||
let reference_id = input.reference_id.trim().to_string();
|
||
if reference_id.is_empty() {
|
||
return Err("ai_result_reference.reference_id 不能为空".to_string());
|
||
}
|
||
|
||
let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?;
|
||
ensure_ai_task_can_transition(snapshot.status)?;
|
||
|
||
let reference = AiResultReferenceSnapshot {
|
||
result_ref_id: generate_ai_result_ref_id(input.created_at_micros),
|
||
task_id: input.task_id.trim().to_string(),
|
||
reference_kind: input.reference_kind,
|
||
reference_id,
|
||
label: normalize_optional_text(input.label),
|
||
created_at_micros: input.created_at_micros,
|
||
};
|
||
ctx.db
|
||
.ai_result_reference()
|
||
.insert(build_ai_result_reference_row(&reference));
|
||
|
||
snapshot.result_references.push(reference);
|
||
snapshot.updated_at_micros = input.created_at_micros;
|
||
snapshot.version += 1;
|
||
|
||
persist_ai_task_snapshot(ctx, &snapshot)?;
|
||
Ok(snapshot)
|
||
}
|
||
|
||
fn complete_ai_task_tx(
|
||
ctx: &ReducerContext,
|
||
input: AiTaskFinishInput,
|
||
) -> Result<AiTaskSnapshot, String> {
|
||
let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?;
|
||
ensure_ai_task_can_transition(snapshot.status)?;
|
||
|
||
snapshot.status = AiTaskStatus::Completed;
|
||
snapshot.completed_at_micros = Some(input.completed_at_micros);
|
||
snapshot.updated_at_micros = input.completed_at_micros;
|
||
snapshot.version += 1;
|
||
|
||
persist_ai_task_snapshot(ctx, &snapshot)?;
|
||
Ok(snapshot)
|
||
}
|
||
|
||
fn fail_ai_task_tx(
|
||
ctx: &ReducerContext,
|
||
input: AiTaskFailureInput,
|
||
) -> Result<AiTaskSnapshot, String> {
|
||
let failure_message = input.failure_message.trim().to_string();
|
||
if failure_message.is_empty() {
|
||
return Err("ai_task.failure_message 不能为空".to_string());
|
||
}
|
||
|
||
let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?;
|
||
ensure_ai_task_can_transition(snapshot.status)?;
|
||
|
||
snapshot.status = AiTaskStatus::Failed;
|
||
snapshot.failure_message = Some(failure_message);
|
||
snapshot.completed_at_micros = Some(input.completed_at_micros);
|
||
snapshot.updated_at_micros = input.completed_at_micros;
|
||
snapshot.version += 1;
|
||
|
||
persist_ai_task_snapshot(ctx, &snapshot)?;
|
||
Ok(snapshot)
|
||
}
|
||
|
||
fn cancel_ai_task_tx(
|
||
ctx: &ReducerContext,
|
||
input: AiTaskCancelInput,
|
||
) -> Result<AiTaskSnapshot, String> {
|
||
let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?;
|
||
ensure_ai_task_can_transition(snapshot.status)?;
|
||
|
||
snapshot.status = AiTaskStatus::Cancelled;
|
||
snapshot.completed_at_micros = Some(input.completed_at_micros);
|
||
snapshot.updated_at_micros = input.completed_at_micros;
|
||
snapshot.version += 1;
|
||
|
||
persist_ai_task_snapshot(ctx, &snapshot)?;
|
||
Ok(snapshot)
|
||
}
|
||
|
||
fn get_ai_task_snapshot_tx(ctx: &ReducerContext, task_id: &str) -> Result<AiTaskSnapshot, String> {
|
||
let row = ctx
|
||
.db
|
||
.ai_task()
|
||
.task_id()
|
||
.find(&task_id.trim().to_string())
|
||
.ok_or_else(|| "ai_task 不存在".to_string())?;
|
||
|
||
Ok(build_ai_task_snapshot_from_row(ctx, &row))
|
||
}
|
||
|
||
fn persist_ai_task_snapshot(ctx: &ReducerContext, snapshot: &AiTaskSnapshot) -> Result<(), String> {
|
||
ctx.db.ai_task().task_id().delete(&snapshot.task_id);
|
||
ctx.db.ai_task().insert(build_ai_task_row(snapshot));
|
||
replace_ai_task_stages(ctx, &snapshot.task_id, &snapshot.stages);
|
||
Ok(())
|
||
}
|
||
|
||
fn replace_ai_task_stages(ctx: &ReducerContext, task_id: &str, stages: &[AiTaskStageSnapshot]) {
|
||
let stage_ids = ctx
|
||
.db
|
||
.ai_task_stage()
|
||
.iter()
|
||
.filter(|row| row.task_id == task_id)
|
||
.map(|row| row.task_stage_id.clone())
|
||
.collect::<Vec<_>>();
|
||
for stage_id in stage_ids {
|
||
ctx.db.ai_task_stage().task_stage_id().delete(&stage_id);
|
||
}
|
||
|
||
for stage in stages {
|
||
ctx.db
|
||
.ai_task_stage()
|
||
.insert(build_ai_task_stage_row(task_id, stage));
|
||
}
|
||
}
|
||
|
||
fn collect_ai_stage_text_output(
|
||
ctx: &ReducerContext,
|
||
task_id: &str,
|
||
stage_kind: AiTaskStageKind,
|
||
) -> Option<String> {
|
||
let mut chunks = ctx
|
||
.db
|
||
.ai_text_chunk()
|
||
.iter()
|
||
.filter(|row| row.task_id == task_id && row.stage_kind == stage_kind)
|
||
.map(|row| build_ai_text_chunk_snapshot_from_row(&row))
|
||
.collect::<Vec<_>>();
|
||
chunks.sort_by_key(|chunk| chunk.sequence);
|
||
|
||
let aggregated = chunks
|
||
.into_iter()
|
||
.map(|chunk| chunk.delta_text)
|
||
.collect::<Vec<_>>()
|
||
.join("");
|
||
if aggregated.trim().is_empty() {
|
||
None
|
||
} else {
|
||
Some(aggregated)
|
||
}
|
||
}
|
||
|
||
fn ensure_ai_task_can_transition(status: AiTaskStatus) -> Result<(), String> {
|
||
if matches!(
|
||
status,
|
||
AiTaskStatus::Completed | AiTaskStatus::Failed | AiTaskStatus::Cancelled
|
||
) {
|
||
Err("当前 ai_task 状态不允许执行该操作".to_string())
|
||
} else {
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
fn build_ai_task_snapshot_from_create_input(input: &AiTaskCreateInput) -> AiTaskSnapshot {
|
||
AiTaskSnapshot {
|
||
task_id: input.task_id.trim().to_string(),
|
||
task_kind: input.task_kind,
|
||
owner_user_id: input.owner_user_id.trim().to_string(),
|
||
request_label: input.request_label.trim().to_string(),
|
||
source_module: input.source_module.trim().to_string(),
|
||
source_entity_id: normalize_optional_text(input.source_entity_id.clone()),
|
||
request_payload_json: normalize_optional_text(input.request_payload_json.clone()),
|
||
status: AiTaskStatus::Pending,
|
||
failure_message: None,
|
||
stages: input
|
||
.stages
|
||
.iter()
|
||
.map(|stage| AiTaskStageSnapshot {
|
||
stage_kind: stage.stage_kind,
|
||
label: stage.label.trim().to_string(),
|
||
detail: stage.detail.trim().to_string(),
|
||
order: stage.order,
|
||
status: AiTaskStageStatus::Pending,
|
||
text_output: None,
|
||
structured_payload_json: None,
|
||
warning_messages: Vec::new(),
|
||
started_at_micros: None,
|
||
completed_at_micros: None,
|
||
})
|
||
.collect(),
|
||
result_references: Vec::new(),
|
||
latest_text_output: None,
|
||
latest_structured_payload_json: None,
|
||
version: INITIAL_AI_TASK_VERSION,
|
||
created_at_micros: input.created_at_micros,
|
||
started_at_micros: None,
|
||
completed_at_micros: None,
|
||
updated_at_micros: input.created_at_micros,
|
||
}
|
||
}
|
||
|
||
fn build_ai_task_row(snapshot: &AiTaskSnapshot) -> AiTask {
|
||
AiTask {
|
||
task_id: snapshot.task_id.clone(),
|
||
task_kind: snapshot.task_kind,
|
||
owner_user_id: snapshot.owner_user_id.clone(),
|
||
request_label: snapshot.request_label.clone(),
|
||
source_module: snapshot.source_module.clone(),
|
||
source_entity_id: snapshot.source_entity_id.clone(),
|
||
request_payload_json: snapshot.request_payload_json.clone(),
|
||
status: snapshot.status,
|
||
failure_message: snapshot.failure_message.clone(),
|
||
latest_text_output: snapshot.latest_text_output.clone(),
|
||
latest_structured_payload_json: snapshot.latest_structured_payload_json.clone(),
|
||
version: snapshot.version,
|
||
created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros),
|
||
started_at: snapshot
|
||
.started_at_micros
|
||
.map(Timestamp::from_micros_since_unix_epoch),
|
||
completed_at: snapshot
|
||
.completed_at_micros
|
||
.map(Timestamp::from_micros_since_unix_epoch),
|
||
updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros),
|
||
}
|
||
}
|
||
|
||
fn build_ai_task_snapshot_from_row(ctx: &ReducerContext, row: &AiTask) -> AiTaskSnapshot {
|
||
let mut stages = ctx
|
||
.db
|
||
.ai_task_stage()
|
||
.iter()
|
||
.filter(|stage| stage.task_id == row.task_id)
|
||
.map(|stage| build_ai_task_stage_snapshot_from_row(&stage))
|
||
.collect::<Vec<_>>();
|
||
stages.sort_by_key(|stage| stage.order);
|
||
|
||
let mut result_references = ctx
|
||
.db
|
||
.ai_result_reference()
|
||
.iter()
|
||
.filter(|reference| reference.task_id == row.task_id)
|
||
.map(|reference| build_ai_result_reference_snapshot_from_row(&reference))
|
||
.collect::<Vec<_>>();
|
||
result_references.sort_by_key(|reference| reference.created_at_micros);
|
||
|
||
AiTaskSnapshot {
|
||
task_id: row.task_id.clone(),
|
||
task_kind: row.task_kind,
|
||
owner_user_id: row.owner_user_id.clone(),
|
||
request_label: row.request_label.clone(),
|
||
source_module: row.source_module.clone(),
|
||
source_entity_id: row.source_entity_id.clone(),
|
||
request_payload_json: row.request_payload_json.clone(),
|
||
status: row.status,
|
||
failure_message: row.failure_message.clone(),
|
||
stages,
|
||
result_references,
|
||
latest_text_output: row.latest_text_output.clone(),
|
||
latest_structured_payload_json: row.latest_structured_payload_json.clone(),
|
||
version: row.version,
|
||
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
|
||
started_at_micros: row
|
||
.started_at
|
||
.map(|value| value.to_micros_since_unix_epoch()),
|
||
completed_at_micros: row
|
||
.completed_at
|
||
.map(|value| value.to_micros_since_unix_epoch()),
|
||
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
|
||
}
|
||
}
|
||
|
||
fn build_ai_task_stage_row(task_id: &str, snapshot: &AiTaskStageSnapshot) -> AiTaskStage {
|
||
AiTaskStage {
|
||
task_stage_id: generate_ai_task_stage_id(task_id, snapshot.stage_kind),
|
||
task_id: task_id.to_string(),
|
||
stage_kind: snapshot.stage_kind,
|
||
label: snapshot.label.clone(),
|
||
detail: snapshot.detail.clone(),
|
||
stage_order: snapshot.order,
|
||
status: snapshot.status,
|
||
text_output: snapshot.text_output.clone(),
|
||
structured_payload_json: snapshot.structured_payload_json.clone(),
|
||
warning_messages: snapshot.warning_messages.clone(),
|
||
started_at: snapshot
|
||
.started_at_micros
|
||
.map(Timestamp::from_micros_since_unix_epoch),
|
||
completed_at: snapshot
|
||
.completed_at_micros
|
||
.map(Timestamp::from_micros_since_unix_epoch),
|
||
}
|
||
}
|
||
|
||
fn build_ai_task_stage_snapshot_from_row(row: &AiTaskStage) -> AiTaskStageSnapshot {
|
||
AiTaskStageSnapshot {
|
||
stage_kind: row.stage_kind,
|
||
label: row.label.clone(),
|
||
detail: row.detail.clone(),
|
||
order: row.stage_order,
|
||
status: row.status,
|
||
text_output: row.text_output.clone(),
|
||
structured_payload_json: row.structured_payload_json.clone(),
|
||
warning_messages: row.warning_messages.clone(),
|
||
started_at_micros: row
|
||
.started_at
|
||
.map(|value| value.to_micros_since_unix_epoch()),
|
||
completed_at_micros: row
|
||
.completed_at
|
||
.map(|value| value.to_micros_since_unix_epoch()),
|
||
}
|
||
}
|
||
|
||
fn build_ai_text_chunk_row(snapshot: &AiTextChunkSnapshot) -> AiTextChunk {
|
||
AiTextChunk {
|
||
text_chunk_row_id: format!(
|
||
"{}{}_{}_{}",
|
||
AI_TEXT_CHUNK_ID_PREFIX,
|
||
snapshot.task_id,
|
||
snapshot.stage_kind.as_str(),
|
||
snapshot.sequence
|
||
),
|
||
chunk_id: snapshot.chunk_id.clone(),
|
||
task_id: snapshot.task_id.clone(),
|
||
stage_kind: snapshot.stage_kind,
|
||
sequence: snapshot.sequence,
|
||
delta_text: snapshot.delta_text.clone(),
|
||
created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros),
|
||
}
|
||
}
|
||
|
||
fn build_ai_text_chunk_snapshot_from_row(row: &AiTextChunk) -> AiTextChunkSnapshot {
|
||
AiTextChunkSnapshot {
|
||
chunk_id: row.chunk_id.clone(),
|
||
task_id: row.task_id.clone(),
|
||
stage_kind: row.stage_kind,
|
||
sequence: row.sequence,
|
||
delta_text: row.delta_text.clone(),
|
||
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
|
||
}
|
||
}
|
||
|
||
fn build_ai_result_reference_row(snapshot: &AiResultReferenceSnapshot) -> AiResultReference {
|
||
AiResultReference {
|
||
result_reference_row_id: format!(
|
||
"{}{}_{}",
|
||
AI_RESULT_REF_ID_PREFIX, snapshot.task_id, snapshot.result_ref_id
|
||
),
|
||
result_ref_id: snapshot.result_ref_id.clone(),
|
||
task_id: snapshot.task_id.clone(),
|
||
reference_kind: snapshot.reference_kind,
|
||
reference_id: snapshot.reference_id.clone(),
|
||
label: snapshot.label.clone(),
|
||
created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros),
|
||
}
|
||
}
|
||
|
||
fn build_ai_result_reference_snapshot_from_row(
|
||
row: &AiResultReference,
|
||
) -> AiResultReferenceSnapshot {
|
||
AiResultReferenceSnapshot {
|
||
result_ref_id: row.result_ref_id.clone(),
|
||
task_id: row.task_id.clone(),
|
||
reference_kind: row.reference_kind,
|
||
reference_id: row.reference_id.clone(),
|
||
label: row.label.clone(),
|
||
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
|
||
}
|
||
}
|
||
|
||
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<String>,
|
||
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::<Vec<_>>();
|
||
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<QuestSignalKind>,
|
||
signal: Option<QuestProgressSignal>,
|
||
step_id: Option<String>,
|
||
step_progress: Option<u32>,
|
||
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<PlayerProgressionSnapshot, String> {
|
||
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<PlayerProgressionSnapshot, String> {
|
||
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<ChapterProgressionSnapshot, String> {
|
||
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<ChapterProgressionSnapshot, String> {
|
||
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<ChapterProgressionSnapshot, String> {
|
||
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<String>,
|
||
input: ChapterProgressionLedgerInput,
|
||
) -> Result<Option<ChapterProgressionSnapshot>, 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 upsert_asset_entity_binding(
|
||
ctx: &ReducerContext,
|
||
input: AssetEntityBindingInput,
|
||
) -> Result<AssetEntityBindingSnapshot, String> {
|
||
validate_asset_entity_binding_fields(
|
||
&input.binding_id,
|
||
&input.asset_object_id,
|
||
&input.entity_kind,
|
||
&input.entity_id,
|
||
&input.slot,
|
||
&input.asset_kind,
|
||
)
|
||
.map_err(|error| error.to_string())?;
|
||
|
||
if ctx
|
||
.db
|
||
.asset_object()
|
||
.asset_object_id()
|
||
.find(&input.asset_object_id)
|
||
.is_none()
|
||
{
|
||
return Err("asset_entity_binding.asset_object_id 对应的 asset_object 不存在".to_string());
|
||
}
|
||
|
||
let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
|
||
// 首版绑定按 entity_kind + entity_id + slot 幂等定位,后续访问量明确后再改为组合索引扫描。
|
||
let current = ctx.db.asset_entity_binding().iter().find(|row| {
|
||
row.entity_kind == input.entity_kind
|
||
&& row.entity_id == input.entity_id
|
||
&& row.slot == input.slot
|
||
});
|
||
|
||
let snapshot = match current {
|
||
Some(existing) => {
|
||
ctx.db
|
||
.asset_entity_binding()
|
||
.binding_id()
|
||
.delete(&existing.binding_id);
|
||
let row = AssetEntityBinding {
|
||
binding_id: existing.binding_id.clone(),
|
||
asset_object_id: input.asset_object_id.clone(),
|
||
entity_kind: input.entity_kind.clone(),
|
||
entity_id: input.entity_id.clone(),
|
||
slot: input.slot.clone(),
|
||
asset_kind: input.asset_kind.clone(),
|
||
owner_user_id: input.owner_user_id.clone(),
|
||
profile_id: input.profile_id.clone(),
|
||
created_at: existing.created_at,
|
||
updated_at,
|
||
};
|
||
ctx.db.asset_entity_binding().insert(row);
|
||
|
||
AssetEntityBindingSnapshot {
|
||
binding_id: existing.binding_id,
|
||
asset_object_id: input.asset_object_id,
|
||
entity_kind: input.entity_kind,
|
||
entity_id: input.entity_id,
|
||
slot: input.slot,
|
||
asset_kind: input.asset_kind,
|
||
owner_user_id: input.owner_user_id,
|
||
profile_id: input.profile_id,
|
||
created_at_micros: existing.created_at.to_micros_since_unix_epoch(),
|
||
updated_at_micros: input.updated_at_micros,
|
||
}
|
||
}
|
||
None => {
|
||
let created_at = updated_at;
|
||
let row = AssetEntityBinding {
|
||
binding_id: input.binding_id.clone(),
|
||
asset_object_id: input.asset_object_id.clone(),
|
||
entity_kind: input.entity_kind.clone(),
|
||
entity_id: input.entity_id.clone(),
|
||
slot: input.slot.clone(),
|
||
asset_kind: input.asset_kind.clone(),
|
||
owner_user_id: input.owner_user_id.clone(),
|
||
profile_id: input.profile_id.clone(),
|
||
created_at,
|
||
updated_at,
|
||
};
|
||
ctx.db.asset_entity_binding().insert(row);
|
||
|
||
AssetEntityBindingSnapshot {
|
||
binding_id: input.binding_id,
|
||
asset_object_id: input.asset_object_id,
|
||
entity_kind: input.entity_kind,
|
||
entity_id: input.entity_id,
|
||
slot: input.slot,
|
||
asset_kind: input.asset_kind,
|
||
owner_user_id: input.owner_user_id,
|
||
profile_id: input.profile_id,
|
||
created_at_micros: input.updated_at_micros,
|
||
updated_at_micros: input.updated_at_micros,
|
||
}
|
||
}
|
||
};
|
||
|
||
Ok(snapshot)
|
||
}
|
||
|
||
fn get_runtime_setting_snapshot(
|
||
ctx: &ReducerContext,
|
||
input: RuntimeSettingGetInput,
|
||
) -> Result<RuntimeSettingSnapshot, String> {
|
||
let validated_input =
|
||
build_runtime_setting_get_input(input.user_id).map_err(|error| error.to_string())?;
|
||
|
||
if let Some(existing) = ctx
|
||
.db
|
||
.runtime_setting()
|
||
.user_id()
|
||
.find(&validated_input.user_id)
|
||
{
|
||
return Ok(RuntimeSettingSnapshot {
|
||
user_id: existing.user_id,
|
||
music_volume: existing.music_volume,
|
||
platform_theme: existing.platform_theme,
|
||
created_at_micros: existing.created_at.to_micros_since_unix_epoch(),
|
||
updated_at_micros: existing.updated_at.to_micros_since_unix_epoch(),
|
||
});
|
||
}
|
||
|
||
Ok(RuntimeSettingSnapshot {
|
||
user_id: validated_input.user_id,
|
||
music_volume: DEFAULT_MUSIC_VOLUME,
|
||
platform_theme: DEFAULT_PLATFORM_THEME,
|
||
created_at_micros: 0,
|
||
updated_at_micros: 0,
|
||
})
|
||
}
|
||
|
||
fn upsert_runtime_setting(
|
||
ctx: &ReducerContext,
|
||
input: RuntimeSettingUpsertInput,
|
||
) -> Result<RuntimeSettingSnapshot, String> {
|
||
let validated_input = build_runtime_setting_upsert_input(
|
||
input.user_id,
|
||
input.music_volume,
|
||
input.platform_theme,
|
||
input.updated_at_micros,
|
||
)
|
||
.map_err(|error| error.to_string())?;
|
||
let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros);
|
||
|
||
let snapshot = match ctx
|
||
.db
|
||
.runtime_setting()
|
||
.user_id()
|
||
.find(&validated_input.user_id)
|
||
{
|
||
Some(existing) => {
|
||
ctx.db.runtime_setting().user_id().delete(&existing.user_id);
|
||
ctx.db.runtime_setting().insert(RuntimeSetting {
|
||
user_id: existing.user_id.clone(),
|
||
music_volume: validated_input.music_volume,
|
||
platform_theme: validated_input.platform_theme,
|
||
created_at: existing.created_at,
|
||
updated_at,
|
||
});
|
||
|
||
RuntimeSettingSnapshot {
|
||
user_id: existing.user_id,
|
||
music_volume: validated_input.music_volume,
|
||
platform_theme: validated_input.platform_theme,
|
||
created_at_micros: existing.created_at.to_micros_since_unix_epoch(),
|
||
updated_at_micros: validated_input.updated_at_micros,
|
||
}
|
||
}
|
||
None => {
|
||
ctx.db.runtime_setting().insert(RuntimeSetting {
|
||
user_id: validated_input.user_id.clone(),
|
||
music_volume: validated_input.music_volume,
|
||
platform_theme: validated_input.platform_theme,
|
||
created_at: updated_at,
|
||
updated_at,
|
||
});
|
||
|
||
RuntimeSettingSnapshot {
|
||
user_id: validated_input.user_id,
|
||
music_volume: validated_input.music_volume,
|
||
platform_theme: validated_input.platform_theme,
|
||
created_at_micros: validated_input.updated_at_micros,
|
||
updated_at_micros: validated_input.updated_at_micros,
|
||
}
|
||
}
|
||
};
|
||
|
||
Ok(snapshot)
|
||
}
|
||
|
||
fn get_runtime_snapshot_record(
|
||
ctx: &ReducerContext,
|
||
input: RuntimeSnapshotGetInput,
|
||
) -> Result<Option<RuntimeSnapshot>, String> {
|
||
let validated_input =
|
||
build_runtime_snapshot_get_input(input.user_id).map_err(|error| error.to_string())?;
|
||
|
||
Ok(ctx
|
||
.db
|
||
.runtime_snapshot()
|
||
.user_id()
|
||
.find(&validated_input.user_id)
|
||
.map(|row| build_runtime_snapshot_from_row(&row)))
|
||
}
|
||
|
||
fn upsert_runtime_snapshot_record(
|
||
ctx: &ReducerContext,
|
||
input: RuntimeSnapshotUpsertInput,
|
||
) -> Result<RuntimeSnapshot, String> {
|
||
let current_story_value = parse_optional_json_str(input.current_story_json.as_deref())?;
|
||
let game_state = parse_json_str(&input.game_state_json)?;
|
||
let prepared = build_runtime_snapshot_upsert_input(
|
||
input.user_id,
|
||
input.saved_at_micros,
|
||
input.bottom_tab,
|
||
game_state.clone(),
|
||
current_story_value.clone(),
|
||
input.updated_at_micros,
|
||
)
|
||
.map_err(|error| error.to_string())?;
|
||
let updated_at = Timestamp::from_micros_since_unix_epoch(prepared.updated_at_micros);
|
||
let saved_at = Timestamp::from_micros_since_unix_epoch(prepared.saved_at_micros);
|
||
|
||
let snapshot = match ctx.db.runtime_snapshot().user_id().find(&prepared.user_id) {
|
||
Some(existing) => {
|
||
ctx.db
|
||
.runtime_snapshot()
|
||
.user_id()
|
||
.delete(&existing.user_id);
|
||
ctx.db.runtime_snapshot().insert(RuntimeSnapshotRow {
|
||
user_id: existing.user_id.clone(),
|
||
version: SAVE_SNAPSHOT_VERSION,
|
||
saved_at,
|
||
bottom_tab: prepared.bottom_tab.clone(),
|
||
game_state_json: prepared.game_state_json.clone(),
|
||
current_story_json: prepared.current_story_json.clone(),
|
||
created_at: existing.created_at,
|
||
updated_at,
|
||
});
|
||
|
||
RuntimeSnapshot {
|
||
user_id: existing.user_id,
|
||
version: SAVE_SNAPSHOT_VERSION,
|
||
saved_at_micros: prepared.saved_at_micros,
|
||
bottom_tab: prepared.bottom_tab,
|
||
game_state_json: prepared.game_state_json,
|
||
current_story_json: prepared.current_story_json,
|
||
created_at_micros: existing.created_at.to_micros_since_unix_epoch(),
|
||
updated_at_micros: prepared.updated_at_micros,
|
||
}
|
||
}
|
||
None => {
|
||
ctx.db.runtime_snapshot().insert(RuntimeSnapshotRow {
|
||
user_id: prepared.user_id.clone(),
|
||
version: SAVE_SNAPSHOT_VERSION,
|
||
saved_at,
|
||
bottom_tab: prepared.bottom_tab.clone(),
|
||
game_state_json: prepared.game_state_json.clone(),
|
||
current_story_json: prepared.current_story_json.clone(),
|
||
created_at: updated_at,
|
||
updated_at,
|
||
});
|
||
|
||
RuntimeSnapshot {
|
||
user_id: prepared.user_id,
|
||
version: SAVE_SNAPSHOT_VERSION,
|
||
saved_at_micros: prepared.saved_at_micros,
|
||
bottom_tab: prepared.bottom_tab,
|
||
game_state_json: prepared.game_state_json,
|
||
current_story_json: prepared.current_story_json,
|
||
created_at_micros: prepared.updated_at_micros,
|
||
updated_at_micros: prepared.updated_at_micros,
|
||
}
|
||
}
|
||
};
|
||
|
||
sync_profile_projections_from_snapshot(ctx, &snapshot)?;
|
||
|
||
Ok(snapshot)
|
||
}
|
||
|
||
fn delete_runtime_snapshot_record(
|
||
ctx: &ReducerContext,
|
||
input: RuntimeSnapshotDeleteInput,
|
||
) -> Result<Option<RuntimeSnapshot>, String> {
|
||
let validated_input =
|
||
build_runtime_snapshot_delete_input(input.user_id).map_err(|error| error.to_string())?;
|
||
|
||
let existing = ctx
|
||
.db
|
||
.runtime_snapshot()
|
||
.user_id()
|
||
.find(&validated_input.user_id);
|
||
if let Some(existing) = existing {
|
||
let snapshot = build_runtime_snapshot_from_row(&existing);
|
||
ctx.db
|
||
.runtime_snapshot()
|
||
.user_id()
|
||
.delete(&existing.user_id);
|
||
return Ok(Some(snapshot));
|
||
}
|
||
|
||
Ok(None)
|
||
}
|
||
|
||
fn list_profile_save_archive_rows(
|
||
ctx: &ReducerContext,
|
||
input: RuntimeProfileSaveArchiveListInput,
|
||
) -> Result<Vec<RuntimeProfileSaveArchiveSnapshot>, String> {
|
||
let validated_input = build_runtime_profile_save_archive_list_input(input.user_id)
|
||
.map_err(|error| error.to_string())?;
|
||
|
||
let mut entries = ctx
|
||
.db
|
||
.profile_save_archive()
|
||
.iter()
|
||
.filter(|row| row.user_id == validated_input.user_id)
|
||
.map(|row| build_profile_save_archive_snapshot_from_row(&row))
|
||
.collect::<Vec<_>>();
|
||
|
||
entries.sort_by(|left, right| {
|
||
right
|
||
.saved_at_micros
|
||
.cmp(&left.saved_at_micros)
|
||
.then_with(|| left.archive_id.cmp(&right.archive_id))
|
||
});
|
||
|
||
Ok(entries)
|
||
}
|
||
|
||
fn resume_profile_save_archive_record(
|
||
ctx: &ReducerContext,
|
||
input: RuntimeProfileSaveArchiveResumeInput,
|
||
) -> Result<(RuntimeProfileSaveArchiveSnapshot, RuntimeSnapshot), String> {
|
||
let validated_input =
|
||
build_runtime_profile_save_archive_resume_input(input.user_id, input.world_key)
|
||
.map_err(|error| error.to_string())?;
|
||
let archive = ctx
|
||
.db
|
||
.profile_save_archive()
|
||
.iter()
|
||
.find(|row| {
|
||
row.user_id == validated_input.user_id && row.world_key == validated_input.world_key
|
||
})
|
||
.ok_or_else(|| "profile_save_archive 对应 world_key 不存在".to_string())?;
|
||
|
||
let existing_snapshot = ctx
|
||
.db
|
||
.runtime_snapshot()
|
||
.user_id()
|
||
.find(&validated_input.user_id);
|
||
let created_at = existing_snapshot
|
||
.as_ref()
|
||
.map(|row| row.created_at)
|
||
.unwrap_or(archive.saved_at);
|
||
|
||
if let Some(existing) = existing_snapshot {
|
||
ctx.db
|
||
.runtime_snapshot()
|
||
.user_id()
|
||
.delete(&existing.user_id);
|
||
}
|
||
|
||
ctx.db.runtime_snapshot().insert(RuntimeSnapshotRow {
|
||
user_id: archive.user_id.clone(),
|
||
version: SAVE_SNAPSHOT_VERSION,
|
||
saved_at: archive.saved_at,
|
||
bottom_tab: archive.bottom_tab.clone(),
|
||
game_state_json: archive.game_state_json.clone(),
|
||
current_story_json: archive.current_story_json.clone(),
|
||
created_at,
|
||
updated_at: archive.saved_at,
|
||
});
|
||
|
||
Ok((
|
||
build_profile_save_archive_snapshot_from_row(&archive),
|
||
RuntimeSnapshot {
|
||
user_id: archive.user_id.clone(),
|
||
version: SAVE_SNAPSHOT_VERSION,
|
||
saved_at_micros: archive.saved_at.to_micros_since_unix_epoch(),
|
||
bottom_tab: archive.bottom_tab.clone(),
|
||
game_state_json: archive.game_state_json.clone(),
|
||
current_story_json: archive.current_story_json.clone(),
|
||
created_at_micros: created_at.to_micros_since_unix_epoch(),
|
||
updated_at_micros: archive.saved_at.to_micros_since_unix_epoch(),
|
||
},
|
||
))
|
||
}
|
||
|
||
fn sync_profile_projections_from_snapshot(
|
||
ctx: &ReducerContext,
|
||
snapshot: &RuntimeSnapshot,
|
||
) -> Result<(), String> {
|
||
let game_state = parse_json_str(&snapshot.game_state_json)?;
|
||
let game_state_object = game_state.as_object();
|
||
let saved_at = Timestamp::from_micros_since_unix_epoch(snapshot.saved_at_micros);
|
||
|
||
sync_profile_dashboard_from_snapshot(ctx, snapshot, game_state_object, saved_at);
|
||
sync_profile_save_archive_from_snapshot(ctx, snapshot, &game_state, saved_at)?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
fn sync_profile_dashboard_from_snapshot(
|
||
ctx: &ReducerContext,
|
||
snapshot: &RuntimeSnapshot,
|
||
game_state: Option<&serde_json::Map<String, JsonValue>>,
|
||
saved_at: Timestamp,
|
||
) {
|
||
let current_state = ctx
|
||
.db
|
||
.profile_dashboard_state()
|
||
.user_id()
|
||
.find(&snapshot.user_id);
|
||
let previous_wallet_balance = current_state
|
||
.as_ref()
|
||
.map(|row| row.wallet_balance)
|
||
.unwrap_or(0);
|
||
let previous_total_play_time_ms = current_state
|
||
.as_ref()
|
||
.map(|row| row.total_play_time_ms)
|
||
.unwrap_or(0);
|
||
let next_wallet_balance =
|
||
read_non_negative_u64(game_state.and_then(|state| state.get("playerCurrency")));
|
||
let mut next_total_play_time_ms = previous_total_play_time_ms;
|
||
|
||
if next_wallet_balance != previous_wallet_balance {
|
||
ctx.db.profile_wallet_ledger().insert(ProfileWalletLedger {
|
||
wallet_ledger_id: format!(
|
||
"{}:{}:{}",
|
||
snapshot.user_id, snapshot.saved_at_micros, next_wallet_balance
|
||
),
|
||
user_id: snapshot.user_id.clone(),
|
||
amount_delta: next_wallet_balance as i64 - previous_wallet_balance as i64,
|
||
balance_after: next_wallet_balance,
|
||
source_type: RuntimeProfileWalletLedgerSourceType::SnapshotSync,
|
||
created_at: saved_at,
|
||
});
|
||
}
|
||
|
||
if let Some(world_meta) = resolve_profile_world_snapshot_meta(game_state) {
|
||
let current_play_time_ms = read_non_negative_u64(
|
||
game_state
|
||
.and_then(|state| state.get("runtimeStats"))
|
||
.and_then(JsonValue::as_object)
|
||
.and_then(|stats| stats.get("playTimeMs")),
|
||
);
|
||
let played_world_id = format!("{}:{}", snapshot.user_id, world_meta.world_key);
|
||
let existing = ctx
|
||
.db
|
||
.profile_played_world()
|
||
.played_world_id()
|
||
.find(&played_world_id);
|
||
let previous_observed_play_time_ms = existing
|
||
.as_ref()
|
||
.map(|row| row.last_observed_play_time_ms)
|
||
.unwrap_or(0);
|
||
let incremental_play_time_ms =
|
||
current_play_time_ms.saturating_sub(previous_observed_play_time_ms);
|
||
next_total_play_time_ms = next_total_play_time_ms.saturating_add(incremental_play_time_ms);
|
||
|
||
if let Some(existing) = existing {
|
||
ctx.db
|
||
.profile_played_world()
|
||
.played_world_id()
|
||
.delete(&existing.played_world_id);
|
||
ctx.db.profile_played_world().insert(ProfilePlayedWorld {
|
||
played_world_id,
|
||
user_id: snapshot.user_id.clone(),
|
||
world_key: world_meta.world_key,
|
||
owner_user_id: world_meta.owner_user_id,
|
||
profile_id: world_meta.profile_id,
|
||
world_type: world_meta.world_type,
|
||
world_title: world_meta.world_title,
|
||
world_subtitle: world_meta.world_subtitle,
|
||
first_played_at: existing.first_played_at,
|
||
last_played_at: saved_at,
|
||
last_observed_play_time_ms: current_play_time_ms
|
||
.max(existing.last_observed_play_time_ms),
|
||
});
|
||
} else {
|
||
ctx.db.profile_played_world().insert(ProfilePlayedWorld {
|
||
played_world_id,
|
||
user_id: snapshot.user_id.clone(),
|
||
world_key: world_meta.world_key,
|
||
owner_user_id: world_meta.owner_user_id,
|
||
profile_id: world_meta.profile_id,
|
||
world_type: world_meta.world_type,
|
||
world_title: world_meta.world_title,
|
||
world_subtitle: world_meta.world_subtitle,
|
||
first_played_at: saved_at,
|
||
last_played_at: saved_at,
|
||
last_observed_play_time_ms: current_play_time_ms,
|
||
});
|
||
}
|
||
}
|
||
|
||
if let Some(existing) = current_state {
|
||
ctx.db
|
||
.profile_dashboard_state()
|
||
.user_id()
|
||
.delete(&existing.user_id);
|
||
ctx.db
|
||
.profile_dashboard_state()
|
||
.insert(ProfileDashboardState {
|
||
user_id: snapshot.user_id.clone(),
|
||
wallet_balance: next_wallet_balance,
|
||
total_play_time_ms: next_total_play_time_ms,
|
||
created_at: existing.created_at,
|
||
updated_at: saved_at,
|
||
});
|
||
} else {
|
||
ctx.db
|
||
.profile_dashboard_state()
|
||
.insert(ProfileDashboardState {
|
||
user_id: snapshot.user_id.clone(),
|
||
wallet_balance: next_wallet_balance,
|
||
total_play_time_ms: next_total_play_time_ms,
|
||
created_at: saved_at,
|
||
updated_at: saved_at,
|
||
});
|
||
}
|
||
}
|
||
|
||
fn sync_profile_save_archive_from_snapshot(
|
||
ctx: &ReducerContext,
|
||
snapshot: &RuntimeSnapshot,
|
||
game_state: &JsonValue,
|
||
saved_at: Timestamp,
|
||
) -> Result<(), String> {
|
||
let Some(archive_meta) =
|
||
resolve_profile_save_archive_meta(game_state, snapshot.current_story_json.as_deref())
|
||
else {
|
||
return Ok(());
|
||
};
|
||
|
||
let archive_id = format!("{}:{}", snapshot.user_id, archive_meta.world_key);
|
||
let existing = ctx.db.profile_save_archive().archive_id().find(&archive_id);
|
||
let created_at = existing
|
||
.as_ref()
|
||
.map(|row| row.created_at)
|
||
.unwrap_or(saved_at);
|
||
|
||
if let Some(existing) = existing {
|
||
ctx.db
|
||
.profile_save_archive()
|
||
.archive_id()
|
||
.delete(&existing.archive_id);
|
||
}
|
||
|
||
ctx.db.profile_save_archive().insert(ProfileSaveArchive {
|
||
archive_id,
|
||
user_id: snapshot.user_id.clone(),
|
||
world_key: archive_meta.world_key,
|
||
owner_user_id: archive_meta.owner_user_id,
|
||
profile_id: archive_meta.profile_id,
|
||
world_type: archive_meta.world_type,
|
||
world_name: archive_meta.world_name,
|
||
subtitle: archive_meta.subtitle,
|
||
summary_text: archive_meta.summary_text,
|
||
cover_image_src: archive_meta.cover_image_src,
|
||
saved_at,
|
||
bottom_tab: snapshot.bottom_tab.clone(),
|
||
game_state_json: snapshot.game_state_json.clone(),
|
||
current_story_json: snapshot.current_story_json.clone(),
|
||
created_at,
|
||
updated_at: saved_at,
|
||
});
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
struct ProfileWorldSnapshotMeta {
|
||
world_key: String,
|
||
owner_user_id: Option<String>,
|
||
profile_id: Option<String>,
|
||
world_type: Option<String>,
|
||
world_title: String,
|
||
world_subtitle: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
struct ProfileSaveArchiveMeta {
|
||
world_key: String,
|
||
owner_user_id: Option<String>,
|
||
profile_id: Option<String>,
|
||
world_type: Option<String>,
|
||
world_name: String,
|
||
subtitle: String,
|
||
summary_text: String,
|
||
cover_image_src: Option<String>,
|
||
}
|
||
|
||
fn build_runtime_snapshot_from_row(row: &RuntimeSnapshotRow) -> RuntimeSnapshot {
|
||
RuntimeSnapshot {
|
||
user_id: row.user_id.clone(),
|
||
version: row.version,
|
||
saved_at_micros: row.saved_at.to_micros_since_unix_epoch(),
|
||
bottom_tab: row.bottom_tab.clone(),
|
||
game_state_json: row.game_state_json.clone(),
|
||
current_story_json: row.current_story_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_profile_save_archive_snapshot_from_row(
|
||
row: &ProfileSaveArchive,
|
||
) -> RuntimeProfileSaveArchiveSnapshot {
|
||
RuntimeProfileSaveArchiveSnapshot {
|
||
archive_id: row.archive_id.clone(),
|
||
user_id: row.user_id.clone(),
|
||
world_key: row.world_key.clone(),
|
||
owner_user_id: row.owner_user_id.clone(),
|
||
profile_id: row.profile_id.clone(),
|
||
world_type: row.world_type.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(),
|
||
saved_at_micros: row.saved_at.to_micros_since_unix_epoch(),
|
||
bottom_tab: row.bottom_tab.clone(),
|
||
game_state_json: row.game_state_json.clone(),
|
||
current_story_json: row.current_story_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 parse_json_str(raw: &str) -> Result<JsonValue, String> {
|
||
serde_json::from_str::<JsonValue>(raw)
|
||
.map_err(|error| format!("game_state_json 解析失败: {error}"))
|
||
}
|
||
|
||
fn parse_optional_json_str(raw: Option<&str>) -> Result<Option<JsonValue>, String> {
|
||
match raw.map(str::trim).filter(|value| !value.is_empty()) {
|
||
Some(value) => serde_json::from_str::<JsonValue>(value)
|
||
.map(Some)
|
||
.map_err(|error| format!("current_story_json 解析失败: {error}")),
|
||
None => Ok(None),
|
||
}
|
||
}
|
||
|
||
fn read_non_negative_u64(value: Option<&JsonValue>) -> u64 {
|
||
match value {
|
||
Some(JsonValue::Number(number)) => {
|
||
if let Some(raw) = number.as_u64() {
|
||
raw
|
||
} else if let Some(raw) = number.as_i64() {
|
||
raw.max(0) as u64
|
||
} else if let Some(raw) = number.as_f64() {
|
||
if raw.is_finite() && raw > 0.0 {
|
||
raw.floor() as u64
|
||
} else {
|
||
0
|
||
}
|
||
} else {
|
||
0
|
||
}
|
||
}
|
||
Some(JsonValue::String(raw)) => raw.trim().parse::<u64>().ok().unwrap_or(0),
|
||
_ => 0,
|
||
}
|
||
}
|
||
|
||
fn read_string_from_json(value: Option<&JsonValue>) -> Option<String> {
|
||
value
|
||
.and_then(JsonValue::as_str)
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(ToString::to_string)
|
||
}
|
||
|
||
fn resolve_profile_world_snapshot_meta(
|
||
game_state: Option<&serde_json::Map<String, JsonValue>>,
|
||
) -> Option<ProfileWorldSnapshotMeta> {
|
||
let game_state = game_state?;
|
||
let custom_world_profile = game_state
|
||
.get("customWorldProfile")
|
||
.and_then(JsonValue::as_object);
|
||
|
||
if let Some(custom_world_profile) = custom_world_profile {
|
||
let profile_id = read_string_from_json(custom_world_profile.get("id"));
|
||
let world_title = read_string_from_json(custom_world_profile.get("name"))
|
||
.or_else(|| read_string_from_json(custom_world_profile.get("title")));
|
||
if profile_id.is_some() || world_title.is_some() {
|
||
let world_title = world_title.unwrap_or_else(|| "自定义世界".to_string());
|
||
return Some(ProfileWorldSnapshotMeta {
|
||
world_key: profile_id
|
||
.as_ref()
|
||
.map(|profile_id| format!("custom:{profile_id}"))
|
||
.unwrap_or_else(|| format!("custom:{world_title}")),
|
||
owner_user_id: None,
|
||
profile_id,
|
||
world_type: Some("CUSTOM".to_string()),
|
||
world_title,
|
||
world_subtitle: read_string_from_json(custom_world_profile.get("summary"))
|
||
.or_else(|| read_string_from_json(custom_world_profile.get("settingText")))
|
||
.unwrap_or_default(),
|
||
});
|
||
}
|
||
}
|
||
|
||
let world_type = read_string_from_json(game_state.get("worldType"))?;
|
||
let current_scene_preset = game_state
|
||
.get("currentScenePreset")
|
||
.and_then(JsonValue::as_object);
|
||
|
||
Some(ProfileWorldSnapshotMeta {
|
||
world_key: format!("builtin:{world_type}"),
|
||
owner_user_id: None,
|
||
profile_id: None,
|
||
world_type: Some(world_type.clone()),
|
||
world_title: current_scene_preset
|
||
.and_then(|preset| read_string_from_json(preset.get("name")))
|
||
.unwrap_or_else(|| build_builtin_world_title(&world_type)),
|
||
world_subtitle: current_scene_preset
|
||
.and_then(|preset| {
|
||
read_string_from_json(preset.get("summary"))
|
||
.or_else(|| read_string_from_json(preset.get("description")))
|
||
})
|
||
.unwrap_or_default(),
|
||
})
|
||
}
|
||
|
||
fn resolve_profile_save_archive_meta(
|
||
game_state: &JsonValue,
|
||
current_story_json: Option<&str>,
|
||
) -> Option<ProfileSaveArchiveMeta> {
|
||
let game_state_object = game_state.as_object();
|
||
let world_meta = resolve_profile_world_snapshot_meta(game_state_object)?;
|
||
let story_engine_memory = game_state_object
|
||
.and_then(|state| state.get("storyEngineMemory"))
|
||
.and_then(JsonValue::as_object);
|
||
let continue_game_digest = story_engine_memory
|
||
.and_then(|memory| read_string_from_json(memory.get("continueGameDigest")));
|
||
let current_story_text = parse_optional_json_str(current_story_json)
|
||
.ok()
|
||
.flatten()
|
||
.and_then(|story| story.as_object().cloned())
|
||
.and_then(|story| read_string_from_json(story.get("text")));
|
||
let custom_world_profile = game_state_object
|
||
.and_then(|state| state.get("customWorldProfile"))
|
||
.and_then(JsonValue::as_object);
|
||
|
||
if let Some(custom_world_profile) = custom_world_profile {
|
||
let world_name = read_string_from_json(custom_world_profile.get("name"))
|
||
.or_else(|| read_string_from_json(custom_world_profile.get("title")))
|
||
.unwrap_or_else(|| world_meta.world_title.clone());
|
||
let subtitle = read_string_from_json(custom_world_profile.get("summary"))
|
||
.or_else(|| read_string_from_json(custom_world_profile.get("settingText")))
|
||
.unwrap_or_else(|| world_meta.world_subtitle.clone());
|
||
let summary_text = continue_game_digest
|
||
.or(current_story_text)
|
||
.or_else(|| {
|
||
if subtitle.is_empty() {
|
||
None
|
||
} else {
|
||
Some(subtitle.clone())
|
||
}
|
||
})
|
||
.unwrap_or_else(|| DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT.to_string());
|
||
|
||
return Some(ProfileSaveArchiveMeta {
|
||
world_key: world_meta.world_key,
|
||
owner_user_id: world_meta.owner_user_id,
|
||
profile_id: world_meta.profile_id,
|
||
world_type: world_meta.world_type,
|
||
world_name,
|
||
subtitle,
|
||
summary_text,
|
||
cover_image_src: read_string_from_json(custom_world_profile.get("coverImageSrc")),
|
||
});
|
||
}
|
||
|
||
let summary_text = continue_game_digest
|
||
.or(current_story_text)
|
||
.or_else(|| {
|
||
if world_meta.world_subtitle.is_empty() {
|
||
None
|
||
} else {
|
||
Some(world_meta.world_subtitle.clone())
|
||
}
|
||
})
|
||
.unwrap_or_else(|| DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT.to_string());
|
||
let current_scene_preset = game_state_object
|
||
.and_then(|state| state.get("currentScenePreset"))
|
||
.and_then(JsonValue::as_object);
|
||
|
||
Some(ProfileSaveArchiveMeta {
|
||
world_key: world_meta.world_key,
|
||
owner_user_id: world_meta.owner_user_id,
|
||
profile_id: world_meta.profile_id,
|
||
world_type: world_meta.world_type,
|
||
world_name: world_meta.world_title,
|
||
subtitle: world_meta.world_subtitle.clone(),
|
||
summary_text,
|
||
cover_image_src: current_scene_preset
|
||
.and_then(|preset| read_string_from_json(preset.get("imageSrc"))),
|
||
})
|
||
}
|
||
|
||
fn build_builtin_world_title(world_type: &str) -> String {
|
||
match world_type {
|
||
"WUXIA" => "武侠世界".to_string(),
|
||
"XIANXIA" => "仙侠世界".to_string(),
|
||
_ => "叙事世界".to_string(),
|
||
}
|
||
}
|
||
|
||
fn get_profile_dashboard_snapshot(
|
||
ctx: &ReducerContext,
|
||
input: RuntimeProfileDashboardGetInput,
|
||
) -> Result<RuntimeProfileDashboardSnapshot, String> {
|
||
let validated_input = build_runtime_profile_dashboard_get_input(input.user_id)
|
||
.map_err(|error| error.to_string())?;
|
||
let state = ctx
|
||
.db
|
||
.profile_dashboard_state()
|
||
.user_id()
|
||
.find(&validated_input.user_id);
|
||
let played_world_count = ctx
|
||
.db
|
||
.profile_played_world()
|
||
.iter()
|
||
.filter(|row| row.user_id == validated_input.user_id)
|
||
.count() as u32;
|
||
|
||
Ok(match state {
|
||
Some(existing) => RuntimeProfileDashboardSnapshot {
|
||
user_id: existing.user_id,
|
||
wallet_balance: existing.wallet_balance,
|
||
total_play_time_ms: existing.total_play_time_ms,
|
||
played_world_count,
|
||
updated_at_micros: Some(existing.updated_at.to_micros_since_unix_epoch()),
|
||
},
|
||
None => RuntimeProfileDashboardSnapshot {
|
||
user_id: validated_input.user_id,
|
||
wallet_balance: 0,
|
||
total_play_time_ms: 0,
|
||
played_world_count,
|
||
updated_at_micros: None,
|
||
},
|
||
})
|
||
}
|
||
|
||
fn list_profile_wallet_ledger_entries(
|
||
ctx: &ReducerContext,
|
||
input: RuntimeProfileWalletLedgerListInput,
|
||
) -> Result<Vec<RuntimeProfileWalletLedgerEntrySnapshot>, String> {
|
||
let validated_input = build_runtime_profile_wallet_ledger_list_input(input.user_id)
|
||
.map_err(|error| error.to_string())?;
|
||
|
||
let mut entries = ctx
|
||
.db
|
||
.profile_wallet_ledger()
|
||
.iter()
|
||
.filter(|row| row.user_id == validated_input.user_id)
|
||
.map(|row| build_profile_wallet_ledger_snapshot_from_row(&row))
|
||
.collect::<Vec<_>>();
|
||
|
||
entries.sort_by(|left, right| {
|
||
right
|
||
.created_at_micros
|
||
.cmp(&left.created_at_micros)
|
||
.then_with(|| left.wallet_ledger_id.cmp(&right.wallet_ledger_id))
|
||
});
|
||
entries.truncate(PROFILE_WALLET_LEDGER_LIST_LIMIT);
|
||
|
||
Ok(entries)
|
||
}
|
||
|
||
fn get_profile_play_stats_snapshot(
|
||
ctx: &ReducerContext,
|
||
input: RuntimeProfilePlayStatsGetInput,
|
||
) -> Result<RuntimeProfilePlayStatsSnapshot, String> {
|
||
let validated_input = build_runtime_profile_play_stats_get_input(input.user_id)
|
||
.map_err(|error| error.to_string())?;
|
||
let dashboard_state = ctx
|
||
.db
|
||
.profile_dashboard_state()
|
||
.user_id()
|
||
.find(&validated_input.user_id);
|
||
let mut played_works = ctx
|
||
.db
|
||
.profile_played_world()
|
||
.iter()
|
||
.filter(|row| row.user_id == validated_input.user_id)
|
||
.map(|row| build_profile_played_world_snapshot_from_row(&row))
|
||
.collect::<Vec<_>>();
|
||
|
||
played_works.sort_by(|left, right| {
|
||
right
|
||
.last_played_at_micros
|
||
.cmp(&left.last_played_at_micros)
|
||
.then_with(|| left.played_world_id.cmp(&right.played_world_id))
|
||
});
|
||
|
||
Ok(RuntimeProfilePlayStatsSnapshot {
|
||
user_id: validated_input.user_id,
|
||
total_play_time_ms: dashboard_state
|
||
.as_ref()
|
||
.map(|row| row.total_play_time_ms)
|
||
.unwrap_or(0),
|
||
played_works,
|
||
updated_at_micros: dashboard_state
|
||
.as_ref()
|
||
.map(|row| row.updated_at.to_micros_since_unix_epoch()),
|
||
})
|
||
}
|
||
|
||
fn list_platform_browse_history_rows(
|
||
ctx: &ReducerContext,
|
||
input: RuntimeBrowseHistoryListInput,
|
||
) -> Result<Vec<RuntimeBrowseHistorySnapshot>, String> {
|
||
let validated_input = build_runtime_browse_history_list_input(input.user_id)
|
||
.map_err(|error| error.to_string())?;
|
||
|
||
let mut entries = ctx
|
||
.db
|
||
.user_browse_history()
|
||
.iter()
|
||
.filter(|row| row.user_id == validated_input.user_id)
|
||
.map(|row| build_runtime_browse_history_snapshot_from_row(&row))
|
||
.collect::<Vec<_>>();
|
||
|
||
entries.sort_by(|left, right| {
|
||
right
|
||
.visited_at_micros
|
||
.cmp(&left.visited_at_micros)
|
||
.then_with(|| left.browse_history_id.cmp(&right.browse_history_id))
|
||
});
|
||
|
||
Ok(entries)
|
||
}
|
||
|
||
fn upsert_platform_browse_history_rows(
|
||
ctx: &ReducerContext,
|
||
input: RuntimeBrowseHistorySyncInput,
|
||
) -> Result<Vec<RuntimeBrowseHistorySnapshot>, String> {
|
||
let user_id = input.user_id.clone();
|
||
let prepared_entries =
|
||
prepare_runtime_browse_history_entries(input).map_err(|error| error.to_string())?;
|
||
|
||
for prepared in prepared_entries {
|
||
let existing = ctx
|
||
.db
|
||
.user_browse_history()
|
||
.browse_history_id()
|
||
.find(&prepared.browse_history_id);
|
||
let created_at = existing
|
||
.as_ref()
|
||
.map(|row| row.created_at)
|
||
.unwrap_or_else(|| Timestamp::from_micros_since_unix_epoch(prepared.updated_at_micros));
|
||
|
||
if let Some(existing) = existing {
|
||
ctx.db
|
||
.user_browse_history()
|
||
.browse_history_id()
|
||
.delete(&existing.browse_history_id);
|
||
}
|
||
|
||
ctx.db.user_browse_history().insert(UserBrowseHistory {
|
||
browse_history_id: prepared.browse_history_id,
|
||
user_id: prepared.user_id,
|
||
owner_user_id: prepared.owner_user_id,
|
||
profile_id: prepared.profile_id,
|
||
world_name: prepared.world_name,
|
||
subtitle: prepared.subtitle,
|
||
summary_text: prepared.summary_text,
|
||
cover_image_src: prepared.cover_image_src,
|
||
theme_mode: prepared.theme_mode,
|
||
author_display_name: prepared.author_display_name,
|
||
visited_at: Timestamp::from_micros_since_unix_epoch(prepared.visited_at_micros),
|
||
created_at,
|
||
updated_at: Timestamp::from_micros_since_unix_epoch(prepared.updated_at_micros),
|
||
});
|
||
}
|
||
|
||
list_platform_browse_history_rows(ctx, RuntimeBrowseHistoryListInput { user_id })
|
||
}
|
||
|
||
fn clear_platform_browse_history_rows(
|
||
ctx: &ReducerContext,
|
||
input: RuntimeBrowseHistoryClearInput,
|
||
) -> Result<Vec<RuntimeBrowseHistorySnapshot>, String> {
|
||
let validated_input = build_runtime_browse_history_clear_input(input.user_id)
|
||
.map_err(|error| error.to_string())?;
|
||
let row_ids = ctx
|
||
.db
|
||
.user_browse_history()
|
||
.iter()
|
||
.filter(|row| row.user_id == validated_input.user_id)
|
||
.map(|row| row.browse_history_id.clone())
|
||
.collect::<Vec<_>>();
|
||
|
||
for row_id in row_ids {
|
||
ctx.db
|
||
.user_browse_history()
|
||
.browse_history_id()
|
||
.delete(&row_id);
|
||
}
|
||
|
||
Ok(Vec::new())
|
||
}
|
||
|
||
fn build_runtime_browse_history_snapshot_from_row(
|
||
row: &UserBrowseHistory,
|
||
) -> RuntimeBrowseHistorySnapshot {
|
||
RuntimeBrowseHistorySnapshot {
|
||
browse_history_id: row.browse_history_id.clone(),
|
||
user_id: row.user_id.clone(),
|
||
owner_user_id: row.owner_user_id.clone(),
|
||
profile_id: row.profile_id.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,
|
||
author_display_name: row.author_display_name.clone(),
|
||
visited_at_micros: row.visited_at.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_profile_wallet_ledger_snapshot_from_row(
|
||
row: &ProfileWalletLedger,
|
||
) -> RuntimeProfileWalletLedgerEntrySnapshot {
|
||
RuntimeProfileWalletLedgerEntrySnapshot {
|
||
wallet_ledger_id: row.wallet_ledger_id.clone(),
|
||
user_id: row.user_id.clone(),
|
||
amount_delta: row.amount_delta,
|
||
balance_after: row.balance_after,
|
||
source_type: row.source_type,
|
||
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
|
||
}
|
||
}
|
||
|
||
fn build_profile_played_world_snapshot_from_row(
|
||
row: &ProfilePlayedWorld,
|
||
) -> RuntimeProfilePlayedWorldSnapshot {
|
||
RuntimeProfilePlayedWorldSnapshot {
|
||
played_world_id: row.played_world_id.clone(),
|
||
user_id: row.user_id.clone(),
|
||
world_key: row.world_key.clone(),
|
||
owner_user_id: row.owner_user_id.clone(),
|
||
profile_id: row.profile_id.clone(),
|
||
world_type: row.world_type.clone(),
|
||
world_title: row.world_title.clone(),
|
||
world_subtitle: row.world_subtitle.clone(),
|
||
first_played_at_micros: row.first_played_at.to_micros_since_unix_epoch(),
|
||
last_played_at_micros: row.last_played_at.to_micros_since_unix_epoch(),
|
||
last_observed_play_time_ms: row.last_observed_play_time_ms,
|
||
}
|
||
}
|
||
|
||
#[allow(dead_code)]
|
||
fn build_runtime_browse_history_row(snapshot: RuntimeBrowseHistorySnapshot) -> UserBrowseHistory {
|
||
UserBrowseHistory {
|
||
browse_history_id: snapshot.browse_history_id,
|
||
user_id: snapshot.user_id,
|
||
owner_user_id: snapshot.owner_user_id,
|
||
profile_id: snapshot.profile_id,
|
||
world_name: snapshot.world_name,
|
||
subtitle: snapshot.subtitle,
|
||
summary_text: snapshot.summary_text,
|
||
cover_image_src: snapshot.cover_image_src,
|
||
theme_mode: snapshot.theme_mode,
|
||
author_display_name: snapshot.author_display_name,
|
||
visited_at: Timestamp::from_micros_since_unix_epoch(snapshot.visited_at_micros),
|
||
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_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<NpcStateSnapshot, String> {
|
||
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<NpcStateSnapshot, String> {
|
||
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<module_npc::NpcInteractionResult, String> {
|
||
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<NpcBattleInteractionResult, String> {
|
||
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(),
|
||
}
|
||
}
|
||
|
||
fn build_big_fish_session_snapshot(
|
||
ctx: &ReducerContext,
|
||
row: &BigFishCreationSession,
|
||
) -> Result<BigFishSessionSnapshot, String> {
|
||
let anchor_pack =
|
||
deserialize_anchor_pack(&row.anchor_pack_json).unwrap_or_else(|_| empty_anchor_pack());
|
||
let draft = row
|
||
.draft_json
|
||
.as_deref()
|
||
.map(deserialize_draft)
|
||
.transpose()
|
||
.map_err(|error| format!("big_fish.draft_json 非法: {error}"))?;
|
||
let asset_slots = list_big_fish_asset_slots(ctx, &row.session_id);
|
||
let asset_coverage = build_asset_coverage(draft.as_ref(), &asset_slots);
|
||
let mut messages = ctx
|
||
.db
|
||
.big_fish_agent_message()
|
||
.iter()
|
||
.filter(|message| message.session_id == row.session_id)
|
||
.map(|message| BigFishAgentMessageSnapshot {
|
||
message_id: message.message_id,
|
||
session_id: message.session_id,
|
||
role: message.role,
|
||
kind: message.kind,
|
||
text: message.text,
|
||
created_at_micros: message.created_at.to_micros_since_unix_epoch(),
|
||
})
|
||
.collect::<Vec<_>>();
|
||
messages.sort_by_key(|message| (message.created_at_micros, message.message_id.clone()));
|
||
|
||
Ok(BigFishSessionSnapshot {
|
||
session_id: row.session_id.clone(),
|
||
owner_user_id: row.owner_user_id.clone(),
|
||
seed_text: row.seed_text.clone(),
|
||
current_turn: row.current_turn,
|
||
progress_percent: row.progress_percent,
|
||
stage: row.stage,
|
||
anchor_pack,
|
||
draft,
|
||
asset_slots,
|
||
asset_coverage,
|
||
messages,
|
||
last_assistant_reply: row.last_assistant_reply.clone(),
|
||
publish_ready: row.publish_ready,
|
||
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
|
||
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
|
||
})
|
||
}
|
||
|
||
fn list_big_fish_asset_slots(
|
||
ctx: &ReducerContext,
|
||
session_id: &str,
|
||
) -> Vec<BigFishAssetSlotSnapshot> {
|
||
let mut slots = ctx
|
||
.db
|
||
.big_fish_asset_slot()
|
||
.iter()
|
||
.filter(|slot| slot.session_id == session_id)
|
||
.map(|slot| BigFishAssetSlotSnapshot {
|
||
slot_id: slot.slot_id,
|
||
session_id: slot.session_id,
|
||
asset_kind: slot.asset_kind,
|
||
level: slot.level,
|
||
motion_key: slot.motion_key,
|
||
status: slot.status,
|
||
asset_url: slot.asset_url,
|
||
prompt_snapshot: slot.prompt_snapshot,
|
||
updated_at_micros: slot.updated_at.to_micros_since_unix_epoch(),
|
||
})
|
||
.collect::<Vec<_>>();
|
||
slots.sort_by_key(|slot| {
|
||
(
|
||
slot.level.unwrap_or(0),
|
||
slot.asset_kind.as_str().to_string(),
|
||
slot.motion_key.clone().unwrap_or_default(),
|
||
slot.slot_id.clone(),
|
||
)
|
||
});
|
||
slots
|
||
}
|
||
|
||
fn replace_big_fish_session(
|
||
ctx: &ReducerContext,
|
||
current: &BigFishCreationSession,
|
||
next: BigFishCreationSession,
|
||
) {
|
||
ctx.db
|
||
.big_fish_creation_session()
|
||
.session_id()
|
||
.delete(¤t.session_id);
|
||
ctx.db.big_fish_creation_session().insert(next);
|
||
}
|
||
|
||
fn replace_big_fish_run(
|
||
ctx: &ReducerContext,
|
||
current: &BigFishRuntimeRun,
|
||
next: BigFishRuntimeRun,
|
||
) {
|
||
ctx.db
|
||
.big_fish_runtime_run()
|
||
.run_id()
|
||
.delete(¤t.run_id);
|
||
ctx.db.big_fish_runtime_run().insert(next);
|
||
}
|
||
|
||
fn upsert_big_fish_asset_slot(ctx: &ReducerContext, slot: BigFishAssetSlotSnapshot) {
|
||
if let Some(existing) = ctx.db.big_fish_asset_slot().slot_id().find(&slot.slot_id) {
|
||
ctx.db
|
||
.big_fish_asset_slot()
|
||
.slot_id()
|
||
.delete(&existing.slot_id);
|
||
}
|
||
ctx.db.big_fish_asset_slot().insert(BigFishAssetSlot {
|
||
slot_id: slot.slot_id,
|
||
session_id: slot.session_id,
|
||
asset_kind: slot.asset_kind,
|
||
level: slot.level,
|
||
motion_key: slot.motion_key,
|
||
status: slot.status,
|
||
asset_url: slot.asset_url,
|
||
prompt_snapshot: slot.prompt_snapshot,
|
||
updated_at: Timestamp::from_micros_since_unix_epoch(slot.updated_at_micros),
|
||
});
|
||
}
|
||
|
||
fn append_big_fish_system_message(
|
||
ctx: &ReducerContext,
|
||
session_id: &str,
|
||
message_id: String,
|
||
text: String,
|
||
created_at_micros: i64,
|
||
) {
|
||
if ctx
|
||
.db
|
||
.big_fish_agent_message()
|
||
.message_id()
|
||
.find(&message_id)
|
||
.is_some()
|
||
{
|
||
return;
|
||
}
|
||
ctx.db.big_fish_agent_message().insert(BigFishAgentMessage {
|
||
message_id,
|
||
session_id: session_id.to_string(),
|
||
role: BigFishAgentMessageRole::Assistant,
|
||
kind: BigFishAgentMessageKind::ActionResult,
|
||
text,
|
||
created_at: Timestamp::from_micros_since_unix_epoch(created_at_micros),
|
||
});
|
||
}
|