Close DDD cleanup and tests-support closure
This commit is contained in:
@@ -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(¤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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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(¤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 {}
|
||||
pub use application::*;
|
||||
pub use commands::*;
|
||||
pub use domain::*;
|
||||
pub use errors::*;
|
||||
pub use events::*;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
Reference in New Issue
Block a user