1157 lines
41 KiB
Rust
1157 lines
41 KiB
Rust
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<String>,
|
||
pub quantity: u32,
|
||
pub rarity: QuestRewardItemRarity,
|
||
pub tags: Vec<String>,
|
||
pub stackable: bool,
|
||
pub stack_key: String,
|
||
pub equipment_slot_id: Option<QuestRewardEquipmentSlot>,
|
||
}
|
||
|
||
#[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<String>,
|
||
}
|
||
|
||
#[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<u32>,
|
||
pub items: Vec<QuestRewardItem>,
|
||
pub intel: Option<QuestRewardIntel>,
|
||
pub story_hint: Option<String>,
|
||
}
|
||
|
||
#[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<String>,
|
||
}
|
||
|
||
#[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<String>,
|
||
pub target_npc_id: Option<String>,
|
||
pub target_scene_id: Option<String>,
|
||
pub target_item_id: Option<String>,
|
||
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<String>,
|
||
pub target_npc_id: Option<String>,
|
||
pub target_scene_id: Option<String>,
|
||
pub target_item_id: Option<String>,
|
||
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<String>,
|
||
pub actor_user_id: String,
|
||
pub issuer_npc_id: String,
|
||
pub issuer_npc_name: String,
|
||
pub scene_id: Option<String>,
|
||
pub chapter_id: Option<String>,
|
||
pub act_id: Option<String>,
|
||
pub thread_id: Option<String>,
|
||
pub contract_id: Option<String>,
|
||
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<QuestStepSnapshot>,
|
||
pub active_step_id: Option<String>,
|
||
pub visible_stage: u32,
|
||
pub hidden_flags: Vec<String>,
|
||
pub discovered_fact_ids: Vec<String>,
|
||
pub related_carrier_ids: Vec<String>,
|
||
pub consequence_ids: Vec<String>,
|
||
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<String>,
|
||
pub actor_user_id: String,
|
||
pub issuer_npc_id: String,
|
||
pub issuer_npc_name: String,
|
||
pub scene_id: Option<String>,
|
||
pub chapter_id: Option<String>,
|
||
pub act_id: Option<String>,
|
||
pub thread_id: Option<String>,
|
||
pub contract_id: Option<String>,
|
||
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<QuestStepSnapshot>,
|
||
pub active_step_id: Option<String>,
|
||
pub visible_stage: u32,
|
||
pub hidden_flags: Vec<String>,
|
||
pub discovered_fact_ids: Vec<String>,
|
||
pub related_carrier_ids: Vec<String>,
|
||
pub consequence_ids: Vec<String>,
|
||
pub created_at_micros: i64,
|
||
pub updated_at_micros: i64,
|
||
pub completed_at_micros: Option<i64>,
|
||
pub turned_in_at_micros: Option<i64>,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct QuestHostileNpcDefeatedSignal {
|
||
pub scene_id: Option<String>,
|
||
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<String>,
|
||
}
|
||
|
||
#[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<String>,
|
||
pub changed_step_progress: Option<u32>,
|
||
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<String>) -> Option<String> {
|
||
normalize_shared_optional_string(value)
|
||
}
|
||
|
||
pub fn normalize_string_list(values: Vec<String>) -> Vec<String> {
|
||
normalize_shared_string_list(values)
|
||
}
|
||
|
||
pub fn build_quest_record_snapshot(
|
||
input: QuestRecordInput,
|
||
) -> Result<QuestRecordSnapshot, QuestRecordFieldError> {
|
||
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::<Result<Vec<_>, _>>()?;
|
||
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<QuestSignalApplyOutcome, QuestRecordFieldError> {
|
||
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::<Vec<_>>();
|
||
|
||
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<QuestCompletionAckOutcome, QuestRecordFieldError> {
|
||
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<QuestRecordSnapshot, QuestRecordFieldError> {
|
||
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::<Vec<_>>();
|
||
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<String, QuestRecordFieldError> {
|
||
normalize_required_string(value).ok_or(error)
|
||
}
|
||
|
||
fn normalize_quest_reward(
|
||
mut reward: QuestRewardSnapshot,
|
||
) -> Result<QuestRewardSnapshot, QuestRecordFieldError> {
|
||
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<QuestRewardItem, QuestRecordFieldError> {
|
||
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::<Result<Vec<_>, _>>()?;
|
||
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<QuestStepSnapshot, QuestRecordFieldError> {
|
||
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<QuestStepSnapshot> {
|
||
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)
|
||
);
|
||
}
|
||
}
|