use std::{error::Error, fmt}; use serde::{Deserialize, Serialize}; use shared_kernel::{ normalize_optional_string as normalize_shared_optional_string, normalize_required_string, normalize_string_list as normalize_shared_string_list, }; #[cfg(feature = "spacetime-types")] use spacetimedb::SpacetimeType; pub const QUEST_LOG_ID_PREFIX: &str = "questlog_"; #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum QuestStatus { Active, ReadyToTurnIn, Completed, TurnedIn, Failed, Expired, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum QuestNarrativeType { Bounty, Escort, Investigation, Retrieval, Relationship, Trial, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum QuestObjectiveKind { DefeatHostileNpc, InspectTreasure, SparWithNpc, TalkToNpc, ReachScene, DeliverItem, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum QuestRewardItemRarity { Common, Uncommon, Rare, Epic, Legendary, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum QuestNarrativeOrigin { AiCompiled, FallbackBuilder, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum QuestLogEventKind { Accepted, Progressed, Completed, CompletionAcknowledged, TurnedIn, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum QuestSignalKind { HostileNpcDefeated, TreasureInspected, NpcSparCompleted, NpcTalkCompleted, SceneReached, ItemDelivered, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct QuestRewardItem { pub item_id: String, pub category: String, pub name: String, pub description: Option, pub quantity: u32, pub rarity: QuestRewardItemRarity, pub tags: Vec, pub stackable: bool, pub stack_key: String, pub equipment_slot_id: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum QuestRewardEquipmentSlot { Weapon, Armor, Relic, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct QuestRewardIntel { pub rumor_text: String, pub unlocked_scene_id: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct QuestRewardSnapshot { pub affinity_bonus: i32, pub currency: i64, pub experience: Option, pub items: Vec, pub intel: Option, pub story_hint: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct QuestNarrativeBindingSnapshot { pub origin: QuestNarrativeOrigin, pub narrative_type: QuestNarrativeType, pub dramatic_need: String, pub issuer_goal: String, pub player_hook: String, pub world_reason: String, pub followup_hooks: Vec, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct QuestObjectiveSnapshot { pub kind: QuestObjectiveKind, pub target_hostile_npc_id: Option, pub target_npc_id: Option, pub target_scene_id: Option, pub target_item_id: Option, pub required_count: u32, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct QuestStepSnapshot { pub step_id: String, pub kind: QuestObjectiveKind, pub target_hostile_npc_id: Option, pub target_npc_id: Option, pub target_scene_id: Option, pub target_item_id: Option, pub required_count: u32, pub progress: u32, pub title: String, pub reveal_text: String, pub complete_text: String, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct QuestRecordInput { pub quest_id: String, pub runtime_session_id: String, pub story_session_id: Option, pub actor_user_id: String, pub issuer_npc_id: String, pub issuer_npc_name: String, pub scene_id: Option, pub chapter_id: Option, pub act_id: Option, pub thread_id: Option, pub contract_id: Option, pub title: String, pub description: String, pub summary: String, pub status: QuestStatus, pub completion_notified: bool, pub reward: QuestRewardSnapshot, pub reward_text: String, pub narrative_binding: QuestNarrativeBindingSnapshot, pub steps: Vec, pub active_step_id: Option, pub visible_stage: u32, pub hidden_flags: Vec, pub discovered_fact_ids: Vec, pub related_carrier_ids: Vec, pub consequence_ids: Vec, pub created_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct QuestRecordSnapshot { pub quest_id: String, pub runtime_session_id: String, pub story_session_id: Option, pub actor_user_id: String, pub issuer_npc_id: String, pub issuer_npc_name: String, pub scene_id: Option, pub chapter_id: Option, pub act_id: Option, pub thread_id: Option, pub contract_id: Option, pub title: String, pub description: String, pub summary: String, pub objective: QuestObjectiveSnapshot, pub progress: u32, pub status: QuestStatus, pub completion_notified: bool, pub reward: QuestRewardSnapshot, pub reward_text: String, pub narrative_binding: QuestNarrativeBindingSnapshot, pub steps: Vec, pub active_step_id: Option, pub visible_stage: u32, pub hidden_flags: Vec, pub discovered_fact_ids: Vec, pub related_carrier_ids: Vec, pub consequence_ids: Vec, pub created_at_micros: i64, pub updated_at_micros: i64, pub completed_at_micros: Option, pub turned_in_at_micros: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct QuestHostileNpcDefeatedSignal { pub scene_id: Option, pub hostile_npc_id: String, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct QuestTreasureInspectedSignal { pub scene_id: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct QuestNpcSparCompletedSignal { pub npc_id: String, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct QuestNpcTalkCompletedSignal { pub npc_id: String, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct QuestSceneReachedSignal { pub scene_id: String, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct QuestItemDeliveredSignal { pub npc_id: String, pub item_id: String, pub quantity: u32, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum QuestProgressSignal { HostileNpcDefeated(QuestHostileNpcDefeatedSignal), TreasureInspected(QuestTreasureInspectedSignal), NpcSparCompleted(QuestNpcSparCompletedSignal), NpcTalkCompleted(QuestNpcTalkCompletedSignal), SceneReached(QuestSceneReachedSignal), ItemDelivered(QuestItemDeliveredSignal), } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct QuestSignalApplyInput { pub quest_id: String, pub signal: QuestProgressSignal, pub updated_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct QuestSignalApplyOutcome { pub next_record: QuestRecordSnapshot, pub changed: bool, pub completed_now: bool, pub changed_step_id: Option, pub changed_step_progress: Option, pub signal_kind: QuestSignalKind, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct QuestCompletionAckInput { pub quest_id: String, pub updated_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct QuestCompletionAckOutcome { pub next_record: QuestRecordSnapshot, pub changed: bool, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct QuestTurnInInput { pub quest_id: String, pub turned_in_at_micros: i64, } #[derive(Clone, Debug, PartialEq, Eq)] pub enum QuestRecordFieldError { MissingQuestId, MissingRuntimeSessionId, MissingActorUserId, MissingIssuerNpcId, MissingIssuerNpcName, MissingTitle, MissingDescription, MissingRewardText, EmptySteps, MissingStepId, MissingStepTitle, MissingStepRevealText, MissingStepCompleteText, QuestNotReadyToTurnIn, MissingRewardItemId, MissingRewardItemCategory, MissingRewardItemName, InvalidRewardItemQuantity, MissingRewardItemStackKey, RewardEquipmentItemCannotStack, RewardNonStackableItemMustStaySingleQuantity, } impl QuestStatus { pub fn is_terminal(self) -> bool { matches!(self, Self::TurnedIn | Self::Failed | Self::Expired) } pub fn is_reward_ready(self) -> bool { matches!(self, Self::ReadyToTurnIn | Self::Completed) } } impl QuestLogEventKind { pub fn as_str(self) -> &'static str { match self { Self::Accepted => "accepted", Self::Progressed => "progressed", Self::Completed => "completed", Self::CompletionAcknowledged => "completion_ack", Self::TurnedIn => "turned_in", } } } impl From<&QuestProgressSignal> for QuestSignalKind { fn from(value: &QuestProgressSignal) -> Self { match value { QuestProgressSignal::HostileNpcDefeated(_) => Self::HostileNpcDefeated, QuestProgressSignal::TreasureInspected(_) => Self::TreasureInspected, QuestProgressSignal::NpcSparCompleted(_) => Self::NpcSparCompleted, QuestProgressSignal::NpcTalkCompleted(_) => Self::NpcTalkCompleted, QuestProgressSignal::SceneReached(_) => Self::SceneReached, QuestProgressSignal::ItemDelivered(_) => Self::ItemDelivered, } } } 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, } } impl fmt::Display for QuestRecordFieldError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::MissingQuestId => f.write_str("quest_record.quest_id 不能为空"), Self::MissingRuntimeSessionId => { f.write_str("quest_record.runtime_session_id 不能为空") } Self::MissingActorUserId => f.write_str("quest_record.actor_user_id 不能为空"), Self::MissingIssuerNpcId => f.write_str("quest_record.issuer_npc_id 不能为空"), Self::MissingIssuerNpcName => f.write_str("quest_record.issuer_npc_name 不能为空"), Self::MissingTitle => f.write_str("quest_record.title 不能为空"), Self::MissingDescription => f.write_str("quest_record.description 不能为空"), Self::MissingRewardText => f.write_str("quest_record.reward_text 不能为空"), Self::EmptySteps => f.write_str("quest_record.steps 至少需要一条 step"), Self::MissingStepId => f.write_str("quest_step.step_id 不能为空"), Self::MissingStepTitle => f.write_str("quest_step.title 不能为空"), Self::MissingStepRevealText => f.write_str("quest_step.reveal_text 不能为空"), Self::MissingStepCompleteText => f.write_str("quest_step.complete_text 不能为空"), Self::QuestNotReadyToTurnIn => f.write_str("当前任务还没有进入可交付状态"), Self::MissingRewardItemId => f.write_str("quest_reward.items[].item_id 不能为空"), Self::MissingRewardItemCategory => { f.write_str("quest_reward.items[].category 不能为空") } Self::MissingRewardItemName => f.write_str("quest_reward.items[].name 不能为空"), Self::InvalidRewardItemQuantity => { f.write_str("quest_reward.items[].quantity 必须大于 0") } Self::MissingRewardItemStackKey => { f.write_str("quest_reward.items[].stack_key 不能为空") } Self::RewardEquipmentItemCannotStack => { f.write_str("quest_reward.items[] 可装备物品不能标记为 stackable") } Self::RewardNonStackableItemMustStaySingleQuantity => { f.write_str("quest_reward.items[] 不可堆叠物品必须固定为单槽位单数量") } } } } impl Error for QuestRecordFieldError {} #[cfg(test)] mod tests { use super::*; fn build_test_reward() -> QuestRewardSnapshot { QuestRewardSnapshot { affinity_bonus: 12, currency: 72, experience: Some(35), items: vec![QuestRewardItem { item_id: "reward_item_01".to_string(), category: "补给".to_string(), name: "常备药包".to_string(), description: Some("带着草药苦味的便携补给。".to_string()), quantity: 1, rarity: QuestRewardItemRarity::Rare, tags: vec!["healing".to_string()], stackable: true, stack_key: "reward_item_01".to_string(), equipment_slot_id: None, }], intel: Some(QuestRewardIntel { rumor_text: "旧桥下面还埋着另一条线索。".to_string(), unlocked_scene_id: Some("scene_old_bridge".to_string()), }), story_hint: Some("委托人的态度明显缓和了下来。".to_string()), } } fn build_test_binding() -> QuestNarrativeBindingSnapshot { QuestNarrativeBindingSnapshot { origin: QuestNarrativeOrigin::FallbackBuilder, narrative_type: QuestNarrativeType::Investigation, dramatic_need: "委托人需要先确认遗迹异动是真是假。".to_string(), issuer_goal: "摸清遗迹周边的情况。".to_string(), player_hook: "玩家正好就在现场。".to_string(), world_reason: "最近的异常都收束到了这片区域。".to_string(), followup_hooks: vec!["遗迹后方还有更深的入口".to_string()], } } fn build_test_steps() -> Vec { vec![ QuestStepSnapshot { step_id: "step_investigate".to_string(), kind: QuestObjectiveKind::InspectTreasure, target_hostile_npc_id: None, target_npc_id: None, target_scene_id: Some("scene_ruins".to_string()), target_item_id: None, required_count: 1, progress: 0, title: "调查遗迹".to_string(), reveal_text: "先去把遗迹边缘的异动看清楚。".to_string(), complete_text: "遗迹调查已经完成。".to_string(), }, QuestStepSnapshot { step_id: "step_report_back".to_string(), kind: QuestObjectiveKind::TalkToNpc, target_hostile_npc_id: None, target_npc_id: Some("npc_scholar_lin".to_string()), target_scene_id: None, target_item_id: None, required_count: 1, progress: 0, title: "回去汇报".to_string(), reveal_text: "回去把结果告诉林朔。".to_string(), complete_text: "林朔已经收到了你的回报。".to_string(), }, ] } fn build_test_record() -> QuestRecordSnapshot { build_quest_record_snapshot(QuestRecordInput { quest_id: "quest:npc_scholar_lin:inspect_treasure:scene_ruins".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_scholar_lin".to_string(), issuer_npc_name: "林朔".to_string(), scene_id: Some("scene_ruins".to_string()), chapter_id: Some("chapter_01".to_string()), act_id: Some("act_01".to_string()), thread_id: Some("thread_ruins".to_string()), contract_id: Some("contract_01".to_string()), title: "遗迹异动".to_string(), description: "林朔希望你先去确认遗迹外缘的异常。".to_string(), summary: "调查遗迹异动,再回去汇报".to_string(), status: QuestStatus::Active, completion_notified: false, reward: build_test_reward(), reward_text: "完成后可获得赏金、补给和线索。".to_string(), narrative_binding: build_test_binding(), steps: build_test_steps(), active_step_id: Some("step_investigate".to_string()), visible_stage: 0, hidden_flags: vec!["ruins".to_string()], discovered_fact_ids: vec![], related_carrier_ids: vec![], consequence_ids: vec![], created_at_micros: 1_713_680_000_000_000, }) .expect("test quest record should build") } #[test] fn build_quest_record_snapshot_rejects_empty_steps() { let error = build_quest_record_snapshot(QuestRecordInput { quest_id: "quest_001".to_string(), runtime_session_id: "runtime_001".to_string(), story_session_id: None, actor_user_id: "user_001".to_string(), issuer_npc_id: "npc_001".to_string(), issuer_npc_name: "林朔".to_string(), scene_id: None, chapter_id: None, act_id: None, thread_id: None, contract_id: None, title: "遗迹异动".to_string(), description: "测试".to_string(), summary: String::new(), status: QuestStatus::Active, completion_notified: false, reward: build_test_reward(), reward_text: "完成后可获得赏金。".to_string(), narrative_binding: build_test_binding(), steps: vec![], active_step_id: None, visible_stage: 0, hidden_flags: vec![], discovered_fact_ids: vec![], related_carrier_ids: vec![], consequence_ids: vec![], created_at_micros: 1, }) .expect_err("empty steps should fail"); assert_eq!(error, QuestRecordFieldError::EmptySteps); } #[test] fn apply_quest_signal_advances_only_current_active_step() { let current = build_test_record(); let outcome = apply_quest_signal( current, QuestSignalApplyInput { quest_id: "quest:npc_scholar_lin:inspect_treasure:scene_ruins".to_string(), signal: QuestProgressSignal::TreasureInspected(QuestTreasureInspectedSignal { scene_id: Some("scene_ruins".to_string()), }), updated_at_micros: 1_713_680_000_100_000, }, ) .expect("signal apply should succeed"); assert!(outcome.changed); assert!(!outcome.completed_now); assert_eq!(outcome.next_record.status, QuestStatus::Active); assert_eq!( outcome.next_record.active_step_id.as_deref(), Some("step_report_back") ); assert_eq!(outcome.changed_step_id.as_deref(), Some("step_investigate")); assert_eq!(outcome.changed_step_progress, Some(1)); } #[test] fn apply_quest_signal_marks_completed_when_last_step_finishes() { let current = apply_quest_signal( build_test_record(), QuestSignalApplyInput { quest_id: "quest:npc_scholar_lin:inspect_treasure:scene_ruins".to_string(), signal: QuestProgressSignal::TreasureInspected(QuestTreasureInspectedSignal { scene_id: Some("scene_ruins".to_string()), }), updated_at_micros: 20, }, ) .expect("first step should succeed") .next_record; let outcome = apply_quest_signal( current, QuestSignalApplyInput { quest_id: "quest:npc_scholar_lin:inspect_treasure:scene_ruins".to_string(), signal: QuestProgressSignal::NpcTalkCompleted(QuestNpcTalkCompletedSignal { npc_id: "npc_scholar_lin".to_string(), }), updated_at_micros: 30, }, ) .expect("last step should complete"); assert!(outcome.changed); assert!(outcome.completed_now); assert_eq!(outcome.next_record.status, QuestStatus::Completed); assert_eq!(outcome.next_record.active_step_id, None); assert_eq!(outcome.next_record.completed_at_micros, Some(30)); } #[test] fn turn_in_quest_record_moves_status_to_turned_in() { let completed = apply_quest_signal( apply_quest_signal( build_test_record(), QuestSignalApplyInput { quest_id: "quest:npc_scholar_lin:inspect_treasure:scene_ruins".to_string(), signal: QuestProgressSignal::TreasureInspected(QuestTreasureInspectedSignal { scene_id: Some("scene_ruins".to_string()), }), updated_at_micros: 20, }, ) .expect("first step should succeed") .next_record, QuestSignalApplyInput { quest_id: "quest:npc_scholar_lin:inspect_treasure:scene_ruins".to_string(), signal: QuestProgressSignal::NpcTalkCompleted(QuestNpcTalkCompletedSignal { npc_id: "npc_scholar_lin".to_string(), }), updated_at_micros: 30, }, ) .expect("second step should succeed") .next_record; let turned_in = turn_in_quest_record( completed, QuestTurnInInput { quest_id: "quest:npc_scholar_lin:inspect_treasure:scene_ruins".to_string(), turned_in_at_micros: 40, }, ) .expect("completed quest should turn in"); assert_eq!(turned_in.status, QuestStatus::TurnedIn); assert_eq!(turned_in.turned_in_at_micros, Some(40)); assert!(turned_in.completion_notified); assert!( turned_in .steps .iter() .all(|step| step.progress == step.required_count) ); } }