Files
Genarrative/server-rs/crates/module-quest/src/lib.rs
kdletters cbc27bad4a
Some checks failed
CI / verify (push) Has been cancelled
init with react+axum+spacetimedb
2026-04-26 18:06:23 +08:00

1157 lines
41 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(&current.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)
);
}
}