use crate::*; use module_combat::resolve_combat_action as resolve_battle_state_action; use module_inventory::apply_inventory_mutation as apply_inventory_slot_mutation; use module_npc::resolve_npc_interaction as resolve_npc_interaction_domain; use module_quest::{ acknowledge_quest_completion as acknowledge_quest_record_completion, apply_quest_signal as apply_quest_record_signal, }; use module_story::{ RpgGameplaySettlementPlan, build_combat_victory_settlement_plan, build_quest_turn_in_settlement_plan, build_treasure_settlement_plan, }; #[spacetimedb::table(accessor = player_progression)] pub struct PlayerProgression { #[primary_key] user_id: String, level: u32, current_level_xp: u32, total_xp: u32, xp_to_next_level: u32, pending_level_ups: u32, last_granted_source: Option, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = chapter_progression, index(accessor = by_chapter_progression_user_id, btree(columns = [user_id])), index(accessor = by_chapter_progression_chapter_id, btree(columns = [chapter_id])), index(accessor = by_chapter_progression_user_chapter, btree(columns = [user_id, chapter_id])) )] pub struct ChapterProgression { #[primary_key] chapter_progression_id: String, user_id: String, chapter_id: String, chapter_index: u32, total_chapters: u32, entry_pseudo_level_millis: u32, exit_pseudo_level_millis: u32, entry_level: u32, exit_level: u32, planned_total_xp: u32, planned_quest_xp: u32, planned_hostile_xp: u32, actual_quest_xp: u32, actual_hostile_xp: u32, expected_hostile_defeat_count: u32, actual_hostile_defeat_count: u32, level_at_entry: u32, level_at_exit: Option, pace_band: ChapterPaceBand, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = npc_state, index(accessor = by_runtime_session_id, btree(columns = [runtime_session_id])), index(accessor = by_npc_id, btree(columns = [npc_id])), index(accessor = by_runtime_session_npc, btree(columns = [runtime_session_id, npc_id])) )] pub struct NpcState { #[primary_key] npc_state_id: String, runtime_session_id: String, npc_id: String, npc_name: String, affinity: i32, relation_state: NpcRelationState, help_used: bool, chatted_count: u32, gifts_given: u32, recruited: bool, trade_stock_signature: Option, revealed_facts: Vec, known_attribute_rumors: Vec, first_meaningful_contact_resolved: bool, seen_backstory_chapter_ids: Vec, stance_profile: NpcStanceProfile, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = story_session, index(accessor = by_runtime_session_id, btree(columns = [runtime_session_id])), index(accessor = by_actor_user_id, btree(columns = [actor_user_id])) )] pub struct StorySession { #[primary_key] story_session_id: String, runtime_session_id: String, actor_user_id: String, world_profile_id: String, initial_prompt: String, opening_summary: Option, latest_narrative_text: String, latest_choice_function_id: Option, status: StorySessionStatus, version: u32, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = story_event, index(accessor = by_story_session_id, btree(columns = [story_session_id])) )] pub struct StoryEvent { #[primary_key] event_id: String, story_session_id: String, event_kind: StoryEventKind, narrative_text: String, choice_function_id: Option, created_at: Timestamp, } #[spacetimedb::table( accessor = inventory_slot, index(accessor = by_inventory_runtime_session_id, btree(columns = [runtime_session_id])), index(accessor = by_inventory_actor_user_id, btree(columns = [actor_user_id])), index(accessor = by_inventory_container_slot, btree(columns = [container_kind, slot_key])), index(accessor = by_inventory_item_id, btree(columns = [item_id])) )] pub struct InventorySlot { #[primary_key] slot_id: String, runtime_session_id: String, story_session_id: Option, actor_user_id: String, container_kind: InventoryContainerKind, slot_key: String, item_id: String, category: String, name: String, description: Option, quantity: u32, rarity: InventoryItemRarity, tags: Vec, stackable: bool, stack_key: String, equipment_slot_id: Option, source_kind: InventoryItemSourceKind, source_reference_id: Option, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = battle_state, index(accessor = by_battle_story_session_id, btree(columns = [story_session_id])), index(accessor = by_battle_runtime_session_id, btree(columns = [runtime_session_id])), index(accessor = by_battle_actor_user_id, btree(columns = [actor_user_id])) )] pub struct BattleState { #[primary_key] battle_state_id: String, story_session_id: String, runtime_session_id: String, actor_user_id: String, chapter_id: Option, target_npc_id: String, target_name: String, battle_mode: BattleMode, status: BattleStatus, player_hp: i32, player_max_hp: i32, player_mana: i32, player_max_mana: i32, target_hp: i32, target_max_hp: i32, experience_reward: u32, reward_items: Vec, turn_index: u32, last_action_function_id: Option, last_action_text: Option, last_result_text: Option, last_damage_dealt: i32, last_damage_taken: i32, last_outcome: CombatOutcome, version: u32, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = treasure_record, index(accessor = by_treasure_story_session_id, btree(columns = [story_session_id])), index(accessor = by_treasure_runtime_session_id, btree(columns = [runtime_session_id])), index(accessor = by_treasure_actor_user_id, btree(columns = [actor_user_id])), index(accessor = by_treasure_encounter_id, btree(columns = [encounter_id])) )] pub struct TreasureRecord { #[primary_key] treasure_record_id: String, runtime_session_id: String, story_session_id: String, actor_user_id: String, encounter_id: String, encounter_name: String, scene_id: Option, scene_name: Option, action: TreasureInteractionAction, reward_items: Vec, reward_hp: u32, reward_mana: u32, reward_currency: u32, story_hint: Option, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = quest_record, index(accessor = by_runtime_session_id, btree(columns = [runtime_session_id])), index(accessor = by_actor_user_id, btree(columns = [actor_user_id])), index(accessor = by_issuer_npc_id, btree(columns = [issuer_npc_id])) )] pub struct QuestRecord { #[primary_key] quest_id: String, runtime_session_id: String, story_session_id: Option, actor_user_id: String, issuer_npc_id: String, issuer_npc_name: String, scene_id: Option, chapter_id: Option, act_id: Option, thread_id: Option, contract_id: Option, title: String, description: String, summary: String, objective: QuestObjectiveSnapshot, progress: u32, status: QuestStatus, completion_notified: bool, reward: QuestRewardSnapshot, reward_text: String, narrative_binding: QuestNarrativeBindingSnapshot, steps: Vec, active_step_id: Option, visible_stage: u32, hidden_flags: Vec, discovered_fact_ids: Vec, related_carrier_ids: Vec, consequence_ids: Vec, created_at: Timestamp, updated_at: Timestamp, completed_at: Option, turned_in_at: Option, } #[spacetimedb::table( accessor = quest_log, index(accessor = by_quest_id, btree(columns = [quest_id])), index(accessor = by_runtime_session_id, btree(columns = [runtime_session_id])), index(accessor = by_actor_user_id, btree(columns = [actor_user_id])) )] pub struct QuestLog { #[primary_key] log_id: String, quest_id: String, runtime_session_id: String, actor_user_id: String, event_kind: QuestLogEventKind, status_after: QuestStatus, signal_kind: Option, signal: Option, step_id: Option, step_progress: Option, created_at: Timestamp, } #[spacetimedb::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() .by_inventory_runtime_session_id() .filter(&input.runtime_session_id) .filter(|slot| slot.actor_user_id == input.actor_user_id) .map(|row| build_inventory_slot_snapshot_from_row(&row)) .collect::>(); let outcome = apply_inventory_slot_mutation(current_slots, input).map_err(|error| error.to_string())?; for removed_slot_id in outcome.removed_slot_ids { ctx.db.inventory_slot().slot_id().delete(&removed_slot_id); } for slot in outcome.next_slots { ctx.db.inventory_slot().slot_id().delete(&slot.slot_id); ctx.db .inventory_slot() .insert(build_inventory_slot_row(slot)); } Ok(()) } // procedure 面向 Axum 同步读取当前 runtime_session 下某个玩家的背包真相态。 #[spacetimedb::procedure] pub fn get_runtime_inventory_state( ctx: &mut ProcedureContext, input: RuntimeInventoryStateQueryInput, ) -> RuntimeInventoryStateProcedureResult { match ctx.try_with_tx(|tx| get_runtime_inventory_state_tx(tx, input.clone())) { Ok(snapshot) => RuntimeInventoryStateProcedureResult { ok: true, snapshot: Some(snapshot), error_message: None, }, Err(message) => RuntimeInventoryStateProcedureResult { ok: false, snapshot: None, error_message: Some(message), }, } } // M4 首轮先把 battle_state 作为战斗真相源落到 SpacetimeDB,避免继续把战斗状态埋在 runtime JSON 里。 #[spacetimedb::reducer] pub fn create_battle_state(ctx: &ReducerContext, input: BattleStateInput) -> Result<(), String> { create_battle_state_record(ctx, input).map(|_| ()) } // procedure 面向 Axum 同步创建 battle_state,返回当前最新战斗快照,避免 HTTP 层再次读取 private table。 #[spacetimedb::procedure] pub fn create_battle_state_and_return( ctx: &mut ProcedureContext, input: BattleStateInput, ) -> BattleStateProcedureResult { match ctx.try_with_tx(|tx| create_battle_state_record(tx, input.clone())) { Ok(snapshot) => BattleStateProcedureResult { ok: true, snapshot: Some(snapshot), error_message: None, }, Err(message) => BattleStateProcedureResult { ok: false, snapshot: None, error_message: Some(message), }, } } // procedure 面向 Axum 读取单个 battle_state 真相态,当前只返回最新战斗快照。 #[spacetimedb::procedure] pub fn get_battle_state( ctx: &mut ProcedureContext, input: BattleStateQueryInput, ) -> BattleStateProcedureResult { match ctx.try_with_tx(|tx| get_battle_state_record(tx, input.clone())) { Ok(snapshot) => BattleStateProcedureResult { ok: true, snapshot: Some(snapshot), error_message: None, }, Err(message) => BattleStateProcedureResult { ok: false, snapshot: None, error_message: Some(message), }, } } // M4 首轮只承接单行为战斗推进,不提前把 inventory / progression / story AI 续写耦进 reducer。 #[spacetimedb::reducer] pub fn resolve_combat_action( ctx: &ReducerContext, input: ResolveCombatActionInput, ) -> Result<(), String> { resolve_battle_state_record(ctx, input).map(|_| ()) } // procedure 面向 Axum 同步推进单次战斗动作,返回本次结算结果与 battle_state 最新快照。 #[spacetimedb::procedure] pub fn resolve_combat_action_and_return( ctx: &mut ProcedureContext, input: ResolveCombatActionInput, ) -> ResolveCombatActionProcedureResult { match ctx.try_with_tx(|tx| resolve_battle_state_record(tx, input.clone())) { Ok(result) => ResolveCombatActionProcedureResult { ok: true, result: Some(result), error_message: None, }, Err(message) => ResolveCombatActionProcedureResult { ok: false, result: None, error_message: Some(message), }, } } fn create_battle_state_record( ctx: &ReducerContext, input: BattleStateInput, ) -> Result { validate_battle_state_input(&input).map_err(|error| error.to_string())?; if ctx .db .battle_state() .battle_state_id() .find(&input.battle_state_id) .is_some() { return Err("battle_state.battle_state_id 已存在".to_string()); } let snapshot = build_battle_state_snapshot(input); ctx.db .battle_state() .insert(build_battle_state_row(snapshot.clone())); Ok(snapshot) } fn get_battle_state_record( ctx: &ReducerContext, input: BattleStateQueryInput, ) -> Result { validate_battle_state_query_input(&input).map_err(|error| error.to_string())?; let row = ctx .db .battle_state() .battle_state_id() .find(&input.battle_state_id) .ok_or_else(|| "battle_state 不存在".to_string())?; Ok(build_battle_state_snapshot_from_row(&row)) } fn get_runtime_inventory_state_tx( ctx: &ReducerContext, input: RuntimeInventoryStateQueryInput, ) -> Result { let validated_input = build_runtime_inventory_state_query_input(input.runtime_session_id, input.actor_user_id) .map_err(|error| error.to_string())?; // 这层只返回 inventory_slot 真相表的最小切片,不混入 story、quest、battle 的额外投影。 let slots = ctx .db .inventory_slot() .by_inventory_runtime_session_id() .filter(&validated_input.runtime_session_id) .filter(|row| row.actor_user_id == validated_input.actor_user_id) .map(|row| build_inventory_slot_snapshot_from_row(&row)) .collect::>(); Ok(build_runtime_inventory_state_snapshot( validated_input, slots, )) } fn resolve_battle_state_record( ctx: &ReducerContext, input: ResolveCombatActionInput, ) -> Result { let current = ctx .db .battle_state() .battle_state_id() .find(&input.battle_state_id) .ok_or_else(|| "battle_state 不存在,无法执行战斗动作".to_string())?; let result = resolve_battle_state_action(build_battle_state_snapshot_from_row(¤t), input) .map_err(|error| error.to_string())?; ctx.db .battle_state() .battle_state_id() .delete(¤t.battle_state_id); ctx.db .battle_state() .insert(build_battle_state_row(result.snapshot.clone())); apply_rpg_gameplay_settlement_plan( ctx, build_combat_victory_settlement_plan(&result.snapshot), Some(result.snapshot.battle_state_id.as_str()), )?; 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), }, } } 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 = build_story_session_snapshot_from_row(¤t); let (next_snapshot, event_snapshot) = apply_story_continue(current_snapshot, input).map_err(|error| error.to_string())?; ctx.db .story_session() .story_session_id() .delete(¤t.story_session_id); ctx.db.story_session().insert(StorySession { story_session_id: next_snapshot.story_session_id.clone(), runtime_session_id: next_snapshot.runtime_session_id.clone(), actor_user_id: next_snapshot.actor_user_id.clone(), world_profile_id: next_snapshot.world_profile_id.clone(), initial_prompt: next_snapshot.initial_prompt.clone(), opening_summary: next_snapshot.opening_summary.clone(), latest_narrative_text: next_snapshot.latest_narrative_text.clone(), latest_choice_function_id: next_snapshot.latest_choice_function_id.clone(), status: next_snapshot.status, version: next_snapshot.version, created_at: Timestamp::from_micros_since_unix_epoch(next_snapshot.created_at_micros), updated_at: Timestamp::from_micros_since_unix_epoch(next_snapshot.updated_at_micros), }); ctx.db.story_event().insert(StoryEvent { event_id: event_snapshot.event_id.clone(), story_session_id: event_snapshot.story_session_id.clone(), event_kind: event_snapshot.event_kind, narrative_text: event_snapshot.narrative_text.clone(), choice_function_id: event_snapshot.choice_function_id.clone(), created_at: Timestamp::from_micros_since_unix_epoch(event_snapshot.created_at_micros), }); Ok((next_snapshot, event_snapshot)) } fn get_story_session_state_tx( ctx: &ReducerContext, input: StorySessionStateInput, ) -> Result<(StorySessionSnapshot, Vec), String> { validate_story_session_state_input(&input).map_err(|error| error.to_string())?; let session = ctx .db .story_session() .story_session_id() .find(&input.story_session_id) .ok_or_else(|| "story_session 不存在".to_string())?; let session_snapshot = build_story_session_snapshot_from_row(&session); let mut events = ctx .db .story_event() .by_story_session_id() .filter(&input.story_session_id) .map(|row| build_story_event_snapshot_from_row(&row)) .collect::>(); events.sort_by_key(|event| (event.created_at_micros, event.event_id.clone())); Ok((session_snapshot, events)) } // 当前阶段先把 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, ); apply_rpg_gameplay_settlement_plan( ctx, build_quest_turn_in_settlement_plan(&next), Some(next.quest_id.as_str()), )?; Ok(()) } // M4 首轮先把 treasure_record 固定成可审计的宝藏结算真相表,奖励写入与 story 归属关系由 reducer 显式校验。 #[spacetimedb::reducer] pub fn resolve_treasure_interaction( ctx: &ReducerContext, input: TreasureResolveInput, ) -> Result<(), String> { upsert_treasure_record(ctx, input).map(|_| ()) } // procedure 面向后续 Axum facade,同步返回最终 treasure_record 快照,避免 HTTP 层再额外读取私有表。 #[spacetimedb::procedure] pub fn resolve_treasure_interaction_and_return( ctx: &mut ProcedureContext, input: TreasureResolveInput, ) -> TreasureRecordProcedureResult { match ctx.try_with_tx(|tx| upsert_treasure_record(tx, input.clone())) { Ok(record) => TreasureRecordProcedureResult { ok: true, record: Some(record), error_message: None, }, Err(message) => TreasureRecordProcedureResult { ok: false, record: None, error_message: Some(message), }, } } fn upsert_treasure_record( ctx: &ReducerContext, input: TreasureResolveInput, ) -> Result { let snapshot = build_treasure_record_snapshot(input).map_err(|error| error.to_string())?; let story_session = ctx .db .story_session() .story_session_id() .find(&snapshot.story_session_id) .ok_or_else(|| { "treasure_record.story_session_id 对应的 story_session 不存在".to_string() })?; if story_session.runtime_session_id != snapshot.runtime_session_id { return Err( "treasure_record.runtime_session_id 必须与 story_session.runtime_session_id 一致" .to_string(), ); } if story_session.actor_user_id != snapshot.actor_user_id { return Err( "treasure_record.actor_user_id 必须与 story_session.actor_user_id 一致".to_string(), ); } // treasure_record 首版按单次结算真相处理:同 id 重放直接返回已落库快照,避免记录更新和重复发奖脱节。 if let Some(existing) = ctx .db .treasure_record() .treasure_record_id() .find(&snapshot.treasure_record_id) { return Ok(build_treasure_record_snapshot_from_row(&existing)); } let updated_at = Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros); let created_at = Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros); ctx.db .treasure_record() .insert(build_treasure_record_row(&snapshot, created_at, updated_at)); apply_rpg_gameplay_settlement_plan( ctx, build_treasure_settlement_plan(&snapshot).map_err(|error| error.to_string())?, Some(snapshot.treasure_record_id.as_str()), )?; Ok(snapshot) } 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 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 apply_rpg_gameplay_settlement_plan( ctx: &ReducerContext, plan: RpgGameplaySettlementPlan, inventory_source_reference_id: Option<&str>, ) -> Result<(), String> { if !plan.inventory_mutations.is_empty() { if let Some(source_reference_id) = inventory_source_reference_id { if inventory_reward_source_already_granted(ctx, &plan, source_reference_id) { return apply_progression_and_chapter_settlement(ctx, plan); } } for mutation in plan.inventory_mutations.clone() { apply_inventory_mutation_tx(ctx, mutation)?; } } apply_progression_and_chapter_settlement(ctx, plan) } fn inventory_reward_source_already_granted( ctx: &ReducerContext, plan: &RpgGameplaySettlementPlan, source_reference_id: &str, ) -> bool { let Some(first_mutation) = plan.inventory_mutations.first() else { return false; }; ctx.db .inventory_slot() .by_inventory_runtime_session_id() .filter(&first_mutation.runtime_session_id) .filter(|row| row.actor_user_id == first_mutation.actor_user_id) .any(|row| row.source_reference_id.as_deref() == Some(source_reference_id)) } fn apply_progression_and_chapter_settlement( ctx: &ReducerContext, plan: RpgGameplaySettlementPlan, ) -> Result<(), String> { let updated_player = match plan.progression_grant { Some(input) => Some(upsert_player_progression_after_grant_tx(ctx, input)?), None => None, }; if let Some(mut input) = plan.chapter_ledger { if let Some(player) = updated_player { input.level_at_exit = Some(player.level); } // 章节计划可能尚未初始化;此时不能反向阻断战斗或任务主链。 try_update_chapter_progression_ledger_tx( ctx, input.user_id.clone(), Some(input.chapter_id.clone()), input, )?; } Ok(()) } fn build_story_session_snapshot_from_row(row: &StorySession) -> StorySessionSnapshot { StorySessionSnapshot { story_session_id: row.story_session_id.clone(), runtime_session_id: row.runtime_session_id.clone(), actor_user_id: row.actor_user_id.clone(), world_profile_id: row.world_profile_id.clone(), initial_prompt: row.initial_prompt.clone(), opening_summary: row.opening_summary.clone(), latest_narrative_text: row.latest_narrative_text.clone(), latest_choice_function_id: row.latest_choice_function_id.clone(), status: row.status, version: row.version, created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } fn build_story_event_snapshot_from_row(row: &StoryEvent) -> StoryEventSnapshot { StoryEventSnapshot { event_id: row.event_id.clone(), story_session_id: row.story_session_id.clone(), event_kind: row.event_kind, narrative_text: row.narrative_text.clone(), choice_function_id: row.choice_function_id.clone(), created_at_micros: row.created_at.to_micros_since_unix_epoch(), } } fn build_treasure_record_snapshot_from_row(row: &TreasureRecord) -> TreasureRecordSnapshot { TreasureRecordSnapshot { treasure_record_id: row.treasure_record_id.clone(), runtime_session_id: row.runtime_session_id.clone(), story_session_id: row.story_session_id.clone(), actor_user_id: row.actor_user_id.clone(), encounter_id: row.encounter_id.clone(), encounter_name: row.encounter_name.clone(), scene_id: row.scene_id.clone(), scene_name: row.scene_name.clone(), action: row.action, reward_items: row.reward_items.clone(), reward_hp: row.reward_hp, reward_mana: row.reward_mana, reward_currency: row.reward_currency, story_hint: row.story_hint.clone(), created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } fn append_quest_log( ctx: &ReducerContext, snapshot: &QuestRecordSnapshot, event_kind: QuestLogEventKind, signal_kind: Option, signal: Option, step_id: Option, step_progress: Option, created_at_micros: i64, ) { ctx.db.quest_log().insert(QuestLog { log_id: generate_quest_log_id(&snapshot.quest_id, event_kind, created_at_micros), quest_id: snapshot.quest_id.clone(), runtime_session_id: snapshot.runtime_session_id.clone(), actor_user_id: snapshot.actor_user_id.clone(), event_kind, status_after: snapshot.status, signal_kind, signal, step_id, step_progress, created_at: Timestamp::from_micros_since_unix_epoch(created_at_micros), }); } fn get_player_progression_snapshot_tx( ctx: &ReducerContext, input: PlayerProgressionGetInput, ) -> Result { let user_id = input.user_id.trim().to_string(); if user_id.is_empty() { return Err("player_progression.user_id 不能为空".to_string()); } if let Some(existing) = ctx.db.player_progression().user_id().find(&user_id) { return Ok(build_player_progression_snapshot_from_row(&existing)); } create_initial_player_progression(user_id, 0).map_err(|error| error.to_string()) } fn upsert_player_progression_after_grant_tx( ctx: &ReducerContext, input: PlayerProgressionGrantInput, ) -> Result { let current = if let Some(existing) = ctx.db.player_progression().user_id().find(&input.user_id) { build_player_progression_snapshot_from_row(&existing) } else { create_initial_player_progression(input.user_id.clone(), input.updated_at_micros) .map_err(|error| error.to_string())? }; let next = grant_player_experience(current, input).map_err(|error| error.to_string())?; if ctx .db .player_progression() .user_id() .find(&next.user_id) .is_some() { ctx.db.player_progression().user_id().delete(&next.user_id); } ctx.db .player_progression() .insert(build_player_progression_row(next.clone())); Ok(next) } fn get_chapter_progression_snapshot_tx( ctx: &ReducerContext, input: ChapterProgressionGetInput, ) -> Result { let user_id = input.user_id.trim().to_string(); let chapter_id = input.chapter_id.trim().to_string(); if user_id.is_empty() { return Err("chapter_progression.user_id 不能为空".to_string()); } if chapter_id.is_empty() { return Err("chapter_progression.chapter_id 不能为空".to_string()); } let row_id = build_chapter_progression_id(&user_id, &chapter_id); let existing = ctx .db .chapter_progression() .chapter_progression_id() .find(&row_id) .ok_or_else(|| "chapter_progression 不存在".to_string())?; Ok(build_chapter_progression_snapshot_from_row(&existing)) } fn upsert_chapter_progression_snapshot_tx( ctx: &ReducerContext, input: ChapterProgressionInput, ) -> Result { let snapshot = build_chapter_progression_snapshot(input).map_err(|error| error.to_string())?; let row_id = build_chapter_progression_id(&snapshot.user_id, &snapshot.chapter_id); if ctx .db .chapter_progression() .chapter_progression_id() .find(&row_id) .is_some() { ctx.db .chapter_progression() .chapter_progression_id() .delete(&row_id); } ctx.db .chapter_progression() .insert(build_chapter_progression_row(snapshot.clone())); Ok(snapshot) } fn update_chapter_progression_ledger_tx( ctx: &ReducerContext, input: ChapterProgressionLedgerInput, ) -> Result { let row_id = build_chapter_progression_id(&input.user_id, &input.chapter_id); let current = ctx .db .chapter_progression() .chapter_progression_id() .find(&row_id) .ok_or_else(|| "chapter_progression 不存在,无法记账".to_string())?; let next = apply_chapter_progression_ledger( build_chapter_progression_snapshot_from_row(¤t), input, ) .map_err(|error| error.to_string())?; ctx.db .chapter_progression() .chapter_progression_id() .delete(&row_id); ctx.db .chapter_progression() .insert(build_chapter_progression_row(next.clone())); Ok(next) } fn try_update_chapter_progression_ledger_tx( ctx: &ReducerContext, user_id: String, chapter_id: Option, input: ChapterProgressionLedgerInput, ) -> Result, String> { let Some(chapter_id) = chapter_id.map(|value| value.trim().to_string()) else { return Ok(None); }; if chapter_id.is_empty() || user_id.trim().is_empty() { return Ok(None); } let row_id = build_chapter_progression_id(user_id.trim(), &chapter_id); if ctx .db .chapter_progression() .chapter_progression_id() .find(&row_id) .is_none() { return Ok(None); } update_chapter_progression_ledger_tx(ctx, input).map(Some) } fn build_battle_state_row(snapshot: BattleStateSnapshot) -> BattleState { BattleState { battle_state_id: snapshot.battle_state_id, story_session_id: snapshot.story_session_id, runtime_session_id: snapshot.runtime_session_id, actor_user_id: snapshot.actor_user_id, chapter_id: snapshot.chapter_id, target_npc_id: snapshot.target_npc_id, target_name: snapshot.target_name, battle_mode: snapshot.battle_mode, status: snapshot.status, player_hp: snapshot.player_hp, player_max_hp: snapshot.player_max_hp, player_mana: snapshot.player_mana, player_max_mana: snapshot.player_max_mana, target_hp: snapshot.target_hp, target_max_hp: snapshot.target_max_hp, experience_reward: snapshot.experience_reward, reward_items: snapshot.reward_items, turn_index: snapshot.turn_index, last_action_function_id: snapshot.last_action_function_id, last_action_text: snapshot.last_action_text, last_result_text: snapshot.last_result_text, last_damage_dealt: snapshot.last_damage_dealt, last_damage_taken: snapshot.last_damage_taken, last_outcome: snapshot.last_outcome, version: snapshot.version, created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros), updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros), } } fn build_battle_state_snapshot_from_row(row: &BattleState) -> BattleStateSnapshot { BattleStateSnapshot { battle_state_id: row.battle_state_id.clone(), story_session_id: row.story_session_id.clone(), runtime_session_id: row.runtime_session_id.clone(), actor_user_id: row.actor_user_id.clone(), chapter_id: row.chapter_id.clone(), target_npc_id: row.target_npc_id.clone(), target_name: row.target_name.clone(), battle_mode: row.battle_mode, status: row.status, player_hp: row.player_hp, player_max_hp: row.player_max_hp, player_mana: row.player_mana, player_max_mana: row.player_max_mana, target_hp: row.target_hp, target_max_hp: row.target_max_hp, experience_reward: row.experience_reward, reward_items: row.reward_items.clone(), turn_index: row.turn_index, last_action_function_id: row.last_action_function_id.clone(), last_action_text: row.last_action_text.clone(), last_result_text: row.last_result_text.clone(), last_damage_dealt: row.last_damage_dealt, last_damage_taken: row.last_damage_taken, last_outcome: row.last_outcome, version: row.version, created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } fn upsert_npc_state_record( ctx: &ReducerContext, input: NpcStateUpsertInput, ) -> Result { let npc_state_id = generate_npc_state_id(&input.runtime_session_id, &input.npc_id); let existing = ctx.db.npc_state().npc_state_id().find(&npc_state_id); let normalized = normalize_npc_state_snapshot( input, existing .as_ref() .map(|row| row.created_at.to_micros_since_unix_epoch()), ) .map_err(|error| error.to_string())?; if existing.is_some() { ctx.db.npc_state().npc_state_id().delete(&npc_state_id); } ctx.db .npc_state() .insert(build_npc_state_row(normalized.clone())); Ok(normalized) } fn resolve_npc_social_action_record( ctx: &ReducerContext, input: ResolveNpcSocialActionInput, ) -> Result { let npc_state_id = generate_npc_state_id(&input.runtime_session_id, &input.npc_id); let current = ctx .db .npc_state() .npc_state_id() .find(&npc_state_id) .ok_or_else(|| "npc_state 不存在,无法执行社交动作".to_string())?; let next = apply_npc_social_action(build_npc_state_snapshot_from_row(¤t), input) .map_err(|error| error.to_string())?; ctx.db .npc_state() .npc_state_id() .delete(¤t.npc_state_id); ctx.db.npc_state().insert(build_npc_state_row(next.clone())); Ok(next) } fn resolve_npc_interaction_record( ctx: &ReducerContext, input: ResolveNpcInteractionInput, ) -> Result { let npc_state_id = generate_npc_state_id(&input.runtime_session_id, &input.npc_id); let current = ctx .db .npc_state() .npc_state_id() .find(&npc_state_id) .ok_or_else(|| "npc_state 不存在,无法执行交互".to_string())?; let result = resolve_npc_interaction_domain(build_npc_state_snapshot_from_row(¤t), input) .map_err(|error| error.to_string())?; ctx.db .npc_state() .npc_state_id() .delete(¤t.npc_state_id); ctx.db .npc_state() .insert(build_npc_state_row(result.npc_state.clone())); Ok(result) } fn resolve_npc_battle_interaction_tx( ctx: &ReducerContext, input: ResolveNpcBattleInteractionInput, ) -> Result { validate_npc_battle_interaction_input(&input)?; let interaction = resolve_npc_interaction_record(ctx, input.npc_interaction.clone())?; let battle_mode = interaction .battle_mode .ok_or_else(|| "当前 NPC 交互没有产出 battle_mode,不能初始化 battle_state".to_string())?; let battle_state_id = input .battle_state_id .clone() .unwrap_or_else(|| generate_battle_state_id(input.npc_interaction.updated_at_micros)); if ctx .db .battle_state() .battle_state_id() .find(&battle_state_id) .is_some() { return Err("battle_state.battle_state_id 已存在".to_string()); } let battle_input = BattleStateInput { battle_state_id, story_session_id: input.story_session_id.trim().to_string(), runtime_session_id: interaction.npc_state.runtime_session_id.clone(), actor_user_id: input.actor_user_id.trim().to_string(), chapter_id: None, target_npc_id: interaction.npc_state.npc_id.clone(), target_name: interaction.npc_state.npc_name.clone(), battle_mode: map_npc_battle_mode(battle_mode), player_hp: input.player_hp, player_max_hp: input.player_max_hp, player_mana: input.player_mana, player_max_mana: input.player_max_mana, target_hp: input.target_hp, target_max_hp: input.target_max_hp, experience_reward: input.experience_reward, reward_items: input.reward_items.clone(), created_at_micros: input.npc_interaction.updated_at_micros, }; validate_battle_state_input(&battle_input).map_err(|error| error.to_string())?; let battle_state = build_battle_state_snapshot(battle_input); ctx.db .battle_state() .insert(build_battle_state_row(battle_state.clone())); Ok(NpcBattleInteractionResult { interaction, battle_state, }) } fn validate_npc_battle_interaction_input( input: &ResolveNpcBattleInteractionInput, ) -> Result<(), String> { if input.story_session_id.trim().is_empty() { return Err("resolve_npc_battle_interaction.story_session_id 不能为空".to_string()); } if input.actor_user_id.trim().is_empty() { return Err("resolve_npc_battle_interaction.actor_user_id 不能为空".to_string()); } if !matches!( input.npc_interaction.interaction_function_id.trim(), NPC_FIGHT_FUNCTION_ID | NPC_SPAR_FUNCTION_ID ) { return Err("resolve_npc_battle_interaction 只支持 npc_fight 或 npc_spar".to_string()); } Ok(()) } fn map_npc_battle_mode(mode: NpcInteractionBattleMode) -> BattleMode { match mode { NpcInteractionBattleMode::Fight => BattleMode::Fight, NpcInteractionBattleMode::Spar => BattleMode::Spar, } } fn build_npc_state_row(snapshot: NpcStateSnapshot) -> NpcState { NpcState { npc_state_id: snapshot.npc_state_id, runtime_session_id: snapshot.runtime_session_id, npc_id: snapshot.npc_id, npc_name: snapshot.npc_name, affinity: snapshot.affinity, relation_state: snapshot.relation_state, help_used: snapshot.help_used, chatted_count: snapshot.chatted_count, gifts_given: snapshot.gifts_given, recruited: snapshot.recruited, trade_stock_signature: snapshot.trade_stock_signature, revealed_facts: snapshot.revealed_facts, known_attribute_rumors: snapshot.known_attribute_rumors, first_meaningful_contact_resolved: snapshot.first_meaningful_contact_resolved, seen_backstory_chapter_ids: snapshot.seen_backstory_chapter_ids, stance_profile: snapshot.stance_profile, created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros), updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros), } } fn build_npc_state_snapshot_from_row(row: &NpcState) -> NpcStateSnapshot { NpcStateSnapshot { npc_state_id: row.npc_state_id.clone(), runtime_session_id: row.runtime_session_id.clone(), npc_id: row.npc_id.clone(), npc_name: row.npc_name.clone(), affinity: row.affinity, relation_state: row.relation_state.clone(), help_used: row.help_used, chatted_count: row.chatted_count, gifts_given: row.gifts_given, recruited: row.recruited, trade_stock_signature: row.trade_stock_signature.clone(), revealed_facts: row.revealed_facts.clone(), known_attribute_rumors: row.known_attribute_rumors.clone(), first_meaningful_contact_resolved: row.first_meaningful_contact_resolved, seen_backstory_chapter_ids: row.seen_backstory_chapter_ids.clone(), stance_profile: row.stance_profile.clone(), created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } }