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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user