mod application; mod commands; mod domain; mod errors; mod events; pub use application::*; pub use commands::*; pub use domain::*; pub use errors::*; pub use events::*; #[cfg(test)] mod tests { use super::*; use module_quest::{ QuestNarrativeBindingSnapshot, QuestNarrativeOrigin, QuestNarrativeType, QuestObjectiveKind, QuestObjectiveSnapshot, QuestRecordSnapshot, QuestRewardEquipmentSlot, QuestRewardItem, QuestRewardItemRarity, QuestRewardSnapshot, QuestStatus, }; #[test] fn validate_story_session_input_accepts_minimal_contract() { let result = validate_story_session_input(&StorySessionInput { story_session_id: "storysess_001".to_string(), runtime_session_id: "runtime_001".to_string(), actor_user_id: "user_001".to_string(), world_profile_id: "profile_001".to_string(), initial_prompt: "进入营地".to_string(), opening_summary: Some("营地开场".to_string()), created_at_micros: 1_713_680_000_000_000, }); assert!(result.is_ok()); } #[test] fn validate_story_session_input_rejects_missing_required_fields() { let error = validate_story_session_input(&StorySessionInput { story_session_id: String::new(), runtime_session_id: String::new(), actor_user_id: String::new(), world_profile_id: String::new(), initial_prompt: String::new(), opening_summary: None, created_at_micros: 1, }) .expect_err("missing required story session fields should fail"); assert_eq!(error, StorySessionFieldError::MissingSessionId); } #[test] fn build_story_session_snapshot_uses_active_status_and_initial_version() { let snapshot = build_story_session_snapshot(StorySessionInput { story_session_id: "storysess_001".to_string(), runtime_session_id: "runtime_001".to_string(), actor_user_id: "user_001".to_string(), world_profile_id: "profile_001".to_string(), initial_prompt: "进入营地".to_string(), opening_summary: Some(" ".to_string()), created_at_micros: 12, }); assert_eq!(snapshot.status, StorySessionStatus::Active); assert_eq!(snapshot.version, INITIAL_STORY_SESSION_VERSION); assert_eq!(snapshot.opening_summary, None); } #[test] fn build_story_session_input_normalizes_optional_summary() { let input = build_story_session_input( " storysess_001 ".to_string(), " runtime_001 ".to_string(), " user_001 ".to_string(), " profile_001 ".to_string(), " 进入营地 ".to_string(), Some(" ".to_string()), 12, ) .expect("story session input should build"); assert_eq!(input.story_session_id, "storysess_001"); assert_eq!(input.runtime_session_id, "runtime_001"); assert_eq!(input.actor_user_id, "user_001"); assert_eq!(input.world_profile_id, "profile_001"); assert_eq!(input.initial_prompt, "进入营地"); assert_eq!(input.opening_summary, None); } #[test] fn build_story_session_state_input_rejects_blank_session_id() { let error = build_story_session_state_input(" ".to_string()) .expect_err("blank story session id should fail"); assert_eq!(error, StorySessionFieldError::MissingSessionId); } #[test] fn build_story_session_state_record_maps_all_events() { let record = build_story_session_state_record( StorySessionSnapshot { story_session_id: "storysess_001".to_string(), runtime_session_id: "runtime_001".to_string(), actor_user_id: "user_001".to_string(), world_profile_id: "profile_001".to_string(), initial_prompt: "进入营地".to_string(), opening_summary: Some("营地开场".to_string()), latest_narrative_text: "你看见篝火边有人招手。".to_string(), latest_choice_function_id: Some("talk_to_npc".to_string()), status: StorySessionStatus::Active, version: 2, created_at_micros: 1_713_686_400_000_000, updated_at_micros: 1_713_686_401_234_567, }, vec![ StoryEventSnapshot { event_id: "storyevt_001".to_string(), story_session_id: "storysess_001".to_string(), event_kind: StoryEventKind::SessionStarted, narrative_text: "营地开场".to_string(), choice_function_id: None, created_at_micros: 1_713_686_400_000_000, }, StoryEventSnapshot { event_id: "storyevt_002".to_string(), story_session_id: "storysess_001".to_string(), event_kind: StoryEventKind::StoryContinued, narrative_text: "你看见篝火边有人招手。".to_string(), choice_function_id: Some("talk_to_npc".to_string()), created_at_micros: 1_713_686_401_234_567, }, ], ); assert_eq!(record.session.story_session_id, "storysess_001"); assert_eq!(record.events.len(), 2); assert_eq!(record.events[0].event_kind, "session_started"); assert_eq!(record.events[1].event_kind, "story_continued"); } #[test] fn build_story_session_result_record_formats_status_and_timestamps() { let record = build_story_session_result_record( StorySessionSnapshot { story_session_id: "storysess_001".to_string(), runtime_session_id: "runtime_001".to_string(), actor_user_id: "user_001".to_string(), world_profile_id: "profile_001".to_string(), initial_prompt: "进入营地".to_string(), opening_summary: Some("营地开场".to_string()), latest_narrative_text: "你看到营地中央的篝火。".to_string(), latest_choice_function_id: Some("inspect_campfire".to_string()), status: StorySessionStatus::Active, version: 2, created_at_micros: 1_713_686_400_000_000, updated_at_micros: 1_713_686_401_234_567, }, StoryEventSnapshot { event_id: "storyevt_001".to_string(), story_session_id: "storysess_001".to_string(), event_kind: StoryEventKind::StoryContinued, narrative_text: "你看到营地中央的篝火。".to_string(), choice_function_id: Some("inspect_campfire".to_string()), created_at_micros: 1_713_686_401_234_567, }, ); assert_eq!(record.session.status, "active"); assert_eq!(record.session.created_at, "1713686400.000000Z"); assert_eq!(record.session.updated_at, "1713686401.234567Z"); assert_eq!(record.event.event_kind, "story_continued"); assert_eq!(record.event.created_at, "1713686401.234567Z"); } #[test] fn apply_story_continue_updates_latest_narrative_and_emits_event() { let current = build_story_session_snapshot(StorySessionInput { story_session_id: "storysess_001".to_string(), runtime_session_id: "runtime_001".to_string(), actor_user_id: "user_001".to_string(), world_profile_id: "profile_001".to_string(), initial_prompt: "进入营地".to_string(), opening_summary: Some("营地开场".to_string()), created_at_micros: 10, }); let (next, event) = apply_story_continue( current, StoryContinueInput { story_session_id: "storysess_001".to_string(), event_id: "storyevt_001".to_string(), narrative_text: "你看见篝火边有人招手。".to_string(), choice_function_id: Some("talk_to_npc".to_string()), updated_at_micros: 20, }, ) .expect("continue story should succeed"); assert_eq!(next.latest_narrative_text, "你看见篝火边有人招手。"); assert_eq!( next.latest_choice_function_id.as_deref(), Some("talk_to_npc") ); assert_eq!(next.version, INITIAL_STORY_SESSION_VERSION + 1); assert_eq!(event.event_kind, StoryEventKind::StoryContinued); } #[test] fn combat_victory_settlement_plan_grants_items_xp_and_chapter_ledger() { let plan = build_combat_victory_settlement_plan(&module_combat::BattleStateSnapshot { battle_state_id: "battle_001".to_string(), story_session_id: "storysess_001".to_string(), runtime_session_id: "runtime_001".to_string(), actor_user_id: "user_001".to_string(), chapter_id: Some("chapter_01".to_string()), target_npc_id: "npc_bandit".to_string(), target_name: "山匪".to_string(), battle_mode: module_combat::BattleMode::Fight, status: module_combat::BattleStatus::Resolved, player_hp: 20, player_max_hp: 30, player_mana: 6, player_max_mana: 10, target_hp: 0, target_max_hp: 18, experience_reward: 35, reward_items: vec![module_runtime_item::RuntimeItemRewardItemSnapshot { item_id: "iron_token".to_string(), category: "material".to_string(), item_name: "铁制信物".to_string(), description: Some("山匪随身携带的旧信物。".to_string()), quantity: 1, rarity: module_runtime_item::RuntimeItemRewardItemRarity::Uncommon, tags: vec!["quest".to_string()], stackable: true, stack_key: "iron_token".to_string(), equipment_slot_id: None, }], turn_index: 2, last_action_function_id: Some("battle_use_skill".to_string()), last_action_text: Some("破阵一击".to_string()), last_result_text: Some("战斗结束。".to_string()), last_damage_dealt: 18, last_damage_taken: 0, last_outcome: module_combat::CombatOutcome::Victory, version: 3, created_at_micros: 100, updated_at_micros: 200, }); assert_eq!(plan.inventory_mutations.len(), 1); assert_eq!( plan.progression_grant.as_ref().map(|input| input.amount), Some(35) ); assert_eq!( plan.chapter_ledger .as_ref() .map(|input| input.granted_hostile_xp), Some(35) ); let module_inventory::InventoryMutation::GrantItem(input) = &plan.inventory_mutations[0].mutation else { panic!("combat victory should grant inventory item"); }; assert_eq!( input.item.source_reference_id.as_deref(), Some("battle_001") ); assert_eq!( input.item.source_kind, module_inventory::InventoryItemSourceKind::CombatDrop ); } #[test] fn quest_turn_in_settlement_plan_grants_reward_items_and_quest_xp() { let plan = build_quest_turn_in_settlement_plan(&QuestRecordSnapshot { quest_id: "quest_001".to_string(), runtime_session_id: "runtime_001".to_string(), story_session_id: Some("storysess_001".to_string()), actor_user_id: "user_001".to_string(), issuer_npc_id: "npc_scout".to_string(), issuer_npc_name: "斥候".to_string(), scene_id: None, chapter_id: Some("chapter_01".to_string()), act_id: None, thread_id: None, contract_id: None, title: "寻找信物".to_string(), description: "找回遗失的信物。".to_string(), summary: "信物已找到。".to_string(), objective: QuestObjectiveSnapshot { kind: QuestObjectiveKind::InspectTreasure, target_hostile_npc_id: None, target_npc_id: None, target_scene_id: Some("scene_ruin".to_string()), target_item_id: None, required_count: 1, }, progress: 1, status: QuestStatus::TurnedIn, completion_notified: true, reward: QuestRewardSnapshot { affinity_bonus: 0, currency: 0, experience: Some(40), items: vec![QuestRewardItem { item_id: "scout_badge".to_string(), category: "trinket".to_string(), name: "斥候徽记".to_string(), description: None, quantity: 1, rarity: QuestRewardItemRarity::Rare, tags: vec!["badge".to_string()], stackable: false, stack_key: "scout_badge".to_string(), equipment_slot_id: Some(QuestRewardEquipmentSlot::Relic), }], intel: None, story_hint: None, }, reward_text: "获得斥候徽记。".to_string(), narrative_binding: QuestNarrativeBindingSnapshot { origin: QuestNarrativeOrigin::FallbackBuilder, narrative_type: QuestNarrativeType::Retrieval, dramatic_need: String::new(), issuer_goal: String::new(), player_hook: String::new(), world_reason: String::new(), followup_hooks: Vec::new(), }, steps: Vec::new(), active_step_id: None, visible_stage: 1, hidden_flags: Vec::new(), discovered_fact_ids: Vec::new(), related_carrier_ids: Vec::new(), consequence_ids: Vec::new(), created_at_micros: 100, updated_at_micros: 300, completed_at_micros: Some(200), turned_in_at_micros: Some(300), }); assert_eq!(plan.inventory_mutations.len(), 1); assert_eq!( plan.progression_grant.as_ref().map(|input| input.amount), Some(40) ); assert_eq!( plan.chapter_ledger .as_ref() .map(|input| input.granted_quest_xp), Some(40) ); let module_inventory::InventoryMutation::GrantItem(input) = &plan.inventory_mutations[0].mutation else { panic!("quest turn-in should grant inventory item"); }; assert_eq!(input.item.source_reference_id.as_deref(), Some("quest_001")); assert_eq!( input.item.equipment_slot_id, Some(module_inventory::InventoryEquipmentSlot::Relic) ); assert_eq!( input.item.source_kind, module_inventory::InventoryItemSourceKind::QuestReward ); } }