Close DDD cleanup and tests-support closure

This commit is contained in:
2026-04-30 16:15:05 +08:00
parent 7ab0933f6d
commit fd08262bf0
81 changed files with 8415 additions and 6662 deletions

View File

@@ -1,3 +1,499 @@
//! 任务应用编排过渡落位
//! 任务应用编排。
//!
//! 这里只返回任务变更结果、日志和奖励待处理事件,不直接写背包或成长表
//! 这里只推进任务状态、步骤进度和交付标记,不直接发放货币、背包或关系奖励
use crate::commands::{
QuestCompletionAckInput, QuestCompletionAckOutcome, QuestRecordInput, QuestSignalApplyInput,
QuestSignalApplyOutcome, QuestTurnInInput,
};
use crate::domain::*;
use crate::errors::QuestRecordFieldError;
use shared_kernel::{
normalize_optional_string as normalize_shared_optional_string, normalize_required_string,
normalize_string_list as normalize_shared_string_list,
};
pub fn normalize_optional_text(value: Option<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,
}
}

View File

@@ -1,3 +1,83 @@
//! 任务写入命令过渡落位
//! 任务写入命令。
//!
//! 用于表达领取任务、推进信号、确认完成和交付任务等输入。
//! 用于表达任务创建、信号推进、完成确认和交付等输入。
use crate::domain::{
QuestNarrativeBindingSnapshot, QuestProgressSignal, QuestRecordSnapshot, QuestRewardSnapshot,
QuestSignalKind, QuestStatus, QuestStepSnapshot,
};
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
#[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 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,
}

View File

@@ -1,4 +1,282 @@
//! 任务领域模型过渡落位
//! 任务领域模型。
//!
//! 后续迁移任务记录、步骤、目标、奖励和日志规则时,只保留任务聚合内部变化;
//! 奖励发放和成长记账通过事件交给外层事务编排。
//! 本文件承载任务状态、步骤、奖励、叙事绑定和进度信号等稳定值对象。
use serde::{Deserialize, Serialize};
#[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 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),
}
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,
}
}
}

View File

@@ -1,3 +1,72 @@
//! 任务领域错误过渡落位
//! 任务领域错误。
//!
//! 错误保持任务规则语义,例如状态不允许、目标不匹配或重复交付。
//! 错误保持任务业务语义,例如字段缺失、步骤非法或任务尚未可交付。
use std::{error::Error, fmt};
#[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 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 {}

View File

@@ -1,3 +1,40 @@
//! 任务领域事件过渡落位
//! 任务领域事件。
//!
//! 用于表达任务已领取、进度已推进、任务已完成和奖励待发放等事实。
//! 用于表达任务接受、推进、完成确认和交付等事实。
use crate::domain::{QuestLogEventKind, QuestSignalKind};
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum QuestDomainEvent {
QuestAccepted(QuestAcceptedEvent),
QuestProgressed(QuestProgressedEvent),
QuestLogRecorded(QuestLogRecordedEvent),
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestAcceptedEvent {
pub quest_id: String,
pub occurred_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestProgressedEvent {
pub quest_id: String,
pub signal_kind: QuestSignalKind,
pub changed_step_id: Option<String>,
pub occurred_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestLogRecordedEvent {
pub quest_id: String,
pub event_kind: QuestLogEventKind,
pub occurred_at_micros: i64,
}

View File

@@ -4,914 +4,11 @@ mod domain;
mod errors;
mod events;
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 {}
pub use application::*;
pub use commands::*;
pub use domain::*;
pub use errors::*;
pub use events::*;
#[cfg(test)]
mod tests {