//! 任务应用编排。 //! //! 这里只推进任务状态、步骤进度和交付标记,不直接发放货币、背包或关系奖励。 use crate::commands::{ QuestCompletionAckInput, QuestCompletionAckOutcome, QuestRecordInput, QuestSignalApplyInput, QuestSignalApplyOutcome, QuestTurnInInput, }; use crate::domain::*; use crate::errors::QuestRecordFieldError; use shared_kernel::{ normalize_optional_string as normalize_shared_optional_string, normalize_required_string, normalize_string_list as normalize_shared_string_list, }; pub fn normalize_optional_text(value: Option) -> Option { normalize_shared_optional_string(value) } pub fn normalize_string_list(values: Vec) -> Vec { normalize_shared_string_list(values) } pub fn build_quest_record_snapshot( input: QuestRecordInput, ) -> Result { let quest_id = normalize_required_text(input.quest_id, QuestRecordFieldError::MissingQuestId)?; let runtime_session_id = normalize_required_text( input.runtime_session_id, QuestRecordFieldError::MissingRuntimeSessionId, )?; let actor_user_id = normalize_required_text( input.actor_user_id, QuestRecordFieldError::MissingActorUserId, )?; let issuer_npc_id = normalize_required_text( input.issuer_npc_id, QuestRecordFieldError::MissingIssuerNpcId, )?; let issuer_npc_name = normalize_required_text( input.issuer_npc_name, QuestRecordFieldError::MissingIssuerNpcName, )?; let title = normalize_required_text(input.title, QuestRecordFieldError::MissingTitle)?; let description = normalize_required_text(input.description, QuestRecordFieldError::MissingDescription)?; let reward_text = normalize_required_text(input.reward_text, QuestRecordFieldError::MissingRewardText)?; if input.steps.is_empty() { return Err(QuestRecordFieldError::EmptySteps); } let steps = input .steps .into_iter() .map(normalize_quest_step) .collect::, _>>()?; let active_step = resolve_active_step(&steps, input.active_step_id.as_deref()); let active_step_id = active_step.map(|step| step.step_id.clone()); let fallback_step = steps .last() .cloned() .expect("BUG: validated quest steps should not be empty"); let objective = build_objective_from_step(active_step.unwrap_or(&fallback_step)); let progress = active_step .map(|step| step.progress) .unwrap_or(fallback_step.required_count); let status = normalize_quest_status(input.status, active_step.is_some()); let completed_at_micros = if status.is_reward_ready() { Some(input.created_at_micros) } else { None }; let turned_in_at_micros = if status == QuestStatus::TurnedIn { Some(input.created_at_micros) } else { None }; Ok(QuestRecordSnapshot { quest_id, runtime_session_id, story_session_id: normalize_optional_text(input.story_session_id), actor_user_id, issuer_npc_id, issuer_npc_name, scene_id: normalize_optional_text(input.scene_id), chapter_id: normalize_optional_text(input.chapter_id), act_id: normalize_optional_text(input.act_id), thread_id: normalize_optional_text(input.thread_id), contract_id: normalize_optional_text(input.contract_id), title, description: description.clone(), summary: normalize_optional_text(Some(input.summary)).unwrap_or(description), objective, progress, status, completion_notified: input.completion_notified || status == QuestStatus::TurnedIn, reward: normalize_quest_reward(input.reward)?, reward_text, narrative_binding: normalize_quest_narrative_binding(input.narrative_binding), steps, active_step_id, visible_stage: input.visible_stage, hidden_flags: normalize_string_list(input.hidden_flags), discovered_fact_ids: normalize_string_list(input.discovered_fact_ids), related_carrier_ids: normalize_string_list(input.related_carrier_ids), consequence_ids: normalize_string_list(input.consequence_ids), created_at_micros: input.created_at_micros, updated_at_micros: input.created_at_micros, completed_at_micros, turned_in_at_micros, }) } // 任务推进只认当前 active step,未命中或已终态时统一保持 no-op,确保 story action 可安全重复派发信号。 pub fn apply_quest_signal( current: QuestRecordSnapshot, input: QuestSignalApplyInput, ) -> Result { let quest_id = normalize_required_text(input.quest_id, QuestRecordFieldError::MissingQuestId)?; let signal_kind = QuestSignalKind::from(&input.signal); if current.quest_id != quest_id || current.status.is_terminal() || current.status.is_reward_ready() { return Ok(QuestSignalApplyOutcome { next_record: current, changed: false, completed_now: false, changed_step_id: None, changed_step_progress: None, signal_kind, }); } let active_step = match resolve_active_step(¤t.steps, current.active_step_id.as_deref()) { Some(step) => step, None => { return Ok(QuestSignalApplyOutcome { next_record: current, changed: false, completed_now: false, changed_step_id: None, changed_step_progress: None, signal_kind, }); } }; if !step_matches_signal(active_step, &input.signal) { return Ok(QuestSignalApplyOutcome { next_record: current, changed: false, completed_now: false, changed_step_id: None, changed_step_progress: None, signal_kind, }); } let increment = signal_progress_increment(&input.signal); let mut changed_step_id = None; let mut changed_step_progress = None; let next_steps = current .steps .iter() .cloned() .map(|mut step| { if step.step_id == active_step.step_id { let next_progress = (step.progress + increment).min(step.required_count); if next_progress != step.progress { step.progress = next_progress; changed_step_id = Some(step.step_id.clone()); changed_step_progress = Some(step.progress); } } step }) .collect::>(); if changed_step_id.is_none() { return Ok(QuestSignalApplyOutcome { next_record: current, changed: false, completed_now: false, changed_step_id: None, changed_step_progress: None, signal_kind, }); } let next_active_step = resolve_active_step(&next_steps, None); let next_active_step_id = next_active_step.map(|step| step.step_id.clone()); let fallback_step = next_steps .last() .cloned() .expect("BUG: progressed quest should still contain steps"); let next_status = normalize_quest_status(current.status, next_active_step.is_some()); let completed_now = !current.status.is_reward_ready() && next_status.is_reward_ready(); let next_objective = build_objective_from_step(next_active_step.unwrap_or(&fallback_step)); let next_progress = next_active_step .map(|step| step.progress) .unwrap_or(fallback_step.required_count); Ok(QuestSignalApplyOutcome { next_record: QuestRecordSnapshot { objective: next_objective, progress: next_progress, status: next_status, completion_notified: false, steps: next_steps, active_step_id: next_active_step_id, updated_at_micros: input.updated_at_micros, completed_at_micros: if completed_now { Some(input.updated_at_micros) } else { current.completed_at_micros }, ..current }, changed: true, completed_now, changed_step_id, changed_step_progress, signal_kind, }) } pub fn acknowledge_quest_completion( current: QuestRecordSnapshot, input: QuestCompletionAckInput, ) -> Result { let quest_id = normalize_required_text(input.quest_id, QuestRecordFieldError::MissingQuestId)?; if current.quest_id != quest_id || current.completion_notified { return Ok(QuestCompletionAckOutcome { next_record: current, changed: false, }); } Ok(QuestCompletionAckOutcome { next_record: QuestRecordSnapshot { completion_notified: true, updated_at_micros: input.updated_at_micros, ..current }, changed: true, }) } // 任务交付只负责把任务固定到 TurnedIn,不在本轮提前掺入货币、背包和关系奖励发放。 pub fn turn_in_quest_record( current: QuestRecordSnapshot, input: QuestTurnInInput, ) -> Result { let quest_id = normalize_required_text(input.quest_id, QuestRecordFieldError::MissingQuestId)?; if current.quest_id != quest_id || !current.status.is_reward_ready() { return Err(QuestRecordFieldError::QuestNotReadyToTurnIn); } let steps = current .steps .into_iter() .map(|mut step| { step.progress = step.required_count; step }) .collect::>(); let fallback_step = steps .last() .cloned() .expect("BUG: turn in quest should preserve steps"); Ok(QuestRecordSnapshot { objective: build_objective_from_step(&fallback_step), progress: fallback_step.required_count, status: QuestStatus::TurnedIn, completion_notified: true, steps, active_step_id: None, updated_at_micros: input.turned_in_at_micros, completed_at_micros: current .completed_at_micros .or(Some(input.turned_in_at_micros)), turned_in_at_micros: Some(input.turned_in_at_micros), ..current }) } pub fn generate_quest_log_id( quest_id: &str, event_kind: QuestLogEventKind, seed_micros: i64, ) -> String { format!( "{}{}_{:x}_{}", QUEST_LOG_ID_PREFIX, event_kind.as_str(), seed_micros, quest_id ) } fn normalize_required_text( value: String, error: QuestRecordFieldError, ) -> Result { normalize_required_string(value).ok_or(error) } fn normalize_quest_reward( mut reward: QuestRewardSnapshot, ) -> Result { reward.story_hint = normalize_optional_text(reward.story_hint); reward.intel = reward.intel.and_then(|intel| { let rumor_text = intel.rumor_text.trim().to_string(); let unlocked_scene_id = normalize_optional_text(intel.unlocked_scene_id); if rumor_text.is_empty() { None } else { Some(QuestRewardIntel { rumor_text, unlocked_scene_id, }) } }); reward.items = reward .items .into_iter() .map( |mut item| -> Result { item.item_id = normalize_required_text( item.item_id, QuestRecordFieldError::MissingRewardItemId, )?; item.category = normalize_required_text( item.category, QuestRecordFieldError::MissingRewardItemCategory, )?; item.name = normalize_required_text( item.name, QuestRecordFieldError::MissingRewardItemName, )?; item.description = normalize_optional_text(item.description); if item.quantity == 0 { return Err(QuestRecordFieldError::InvalidRewardItemQuantity); } if !item.stackable && item.quantity != 1 { return Err( QuestRecordFieldError::RewardNonStackableItemMustStaySingleQuantity, ); } if item.equipment_slot_id.is_some() && item.stackable { return Err(QuestRecordFieldError::RewardEquipmentItemCannotStack); } item.tags = normalize_string_list(item.tags); item.stack_key = if item.stackable { normalize_required_text( item.stack_key, QuestRecordFieldError::MissingRewardItemStackKey, )? } else { normalize_optional_text(Some(item.stack_key)) .unwrap_or_else(|| item.item_id.clone()) }; Ok(item) }, ) .collect::, _>>()?; Ok(reward) } fn normalize_quest_narrative_binding( mut binding: QuestNarrativeBindingSnapshot, ) -> QuestNarrativeBindingSnapshot { binding.dramatic_need = binding.dramatic_need.trim().to_string(); binding.issuer_goal = binding.issuer_goal.trim().to_string(); binding.player_hook = binding.player_hook.trim().to_string(); binding.world_reason = binding.world_reason.trim().to_string(); binding.followup_hooks = normalize_string_list(binding.followup_hooks); binding } fn normalize_quest_step( mut step: QuestStepSnapshot, ) -> Result { step.step_id = normalize_required_text(step.step_id, QuestRecordFieldError::MissingStepId)?; step.title = normalize_required_text(step.title, QuestRecordFieldError::MissingStepTitle)?; step.reveal_text = normalize_required_text( step.reveal_text, QuestRecordFieldError::MissingStepRevealText, )?; step.complete_text = normalize_required_text( step.complete_text, QuestRecordFieldError::MissingStepCompleteText, )?; step.required_count = step.required_count.max(1); step.progress = step.progress.min(step.required_count); step.target_hostile_npc_id = normalize_optional_text(step.target_hostile_npc_id); step.target_npc_id = normalize_optional_text(step.target_npc_id); step.target_scene_id = normalize_optional_text(step.target_scene_id); step.target_item_id = normalize_optional_text(step.target_item_id); Ok(step) } fn resolve_active_step<'a>( steps: &'a [QuestStepSnapshot], active_step_id: Option<&str>, ) -> Option<&'a QuestStepSnapshot> { if let Some(active_step_id) = active_step_id { let active_step_id = active_step_id.trim(); if !active_step_id.is_empty() { if let Some(step) = steps .iter() .find(|step| step.step_id == active_step_id && step.progress < step.required_count) { return Some(step); } } } steps .iter() .find(|step| step.progress < step.required_count) } fn build_objective_from_step(step: &QuestStepSnapshot) -> QuestObjectiveSnapshot { QuestObjectiveSnapshot { kind: step.kind, target_hostile_npc_id: step.target_hostile_npc_id.clone(), target_npc_id: step.target_npc_id.clone(), target_scene_id: step.target_scene_id.clone(), target_item_id: step.target_item_id.clone(), required_count: step.required_count, } } fn normalize_quest_status(status: QuestStatus, has_active_step: bool) -> QuestStatus { if status.is_terminal() { return status; } if has_active_step { QuestStatus::Active } else if status == QuestStatus::ReadyToTurnIn { QuestStatus::ReadyToTurnIn } else { QuestStatus::Completed } } fn step_matches_signal(step: &QuestStepSnapshot, signal: &QuestProgressSignal) -> bool { match signal { QuestProgressSignal::HostileNpcDefeated(payload) => { step.kind == QuestObjectiveKind::DefeatHostileNpc && step.target_hostile_npc_id.as_deref() == Some(payload.hostile_npc_id.as_str()) && step .target_scene_id .as_deref() .is_none_or(|value| Some(value.to_string()) == payload.scene_id.clone()) } QuestProgressSignal::TreasureInspected(payload) => { step.kind == QuestObjectiveKind::InspectTreasure && step .target_scene_id .as_deref() .is_none_or(|value| Some(value.to_string()) == payload.scene_id.clone()) } QuestProgressSignal::NpcSparCompleted(payload) => { step.kind == QuestObjectiveKind::SparWithNpc && step.target_npc_id.as_deref() == Some(payload.npc_id.as_str()) } QuestProgressSignal::NpcTalkCompleted(payload) => { step.kind == QuestObjectiveKind::TalkToNpc && step.target_npc_id.as_deref() == Some(payload.npc_id.as_str()) } QuestProgressSignal::SceneReached(payload) => { step.kind == QuestObjectiveKind::ReachScene && step.target_scene_id.as_deref() == Some(payload.scene_id.as_str()) } QuestProgressSignal::ItemDelivered(payload) => { step.kind == QuestObjectiveKind::DeliverItem && step.target_npc_id.as_deref() == Some(payload.npc_id.as_str()) && step.target_item_id.as_deref() == Some(payload.item_id.as_str()) } } } fn signal_progress_increment(signal: &QuestProgressSignal) -> u32 { match signal { QuestProgressSignal::ItemDelivered(payload) => payload.quantity.max(1), _ => 1, } }