500 lines
18 KiB
Rust
500 lines
18 KiB
Rust
//! 任务应用编排。
|
||
//!
|
||
//! 这里只推进任务状态、步骤进度和交付标记,不直接发放货币、背包或关系奖励。
|
||
|
||
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,
|
||
}
|
||
}
|