Close DDD cleanup and tests-support closure

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

View File

@@ -1,3 +1,499 @@
//! 任务应用编排过渡落位
//! 任务应用编排。
//!
//! 这里只返回任务变更结果、日志和奖励待处理事件,不直接写背包或成长表
//! 这里只推进任务状态、步骤进度和交付标记,不直接发放货币、背包或关系奖励
use crate::commands::{
QuestCompletionAckInput, QuestCompletionAckOutcome, QuestRecordInput, QuestSignalApplyInput,
QuestSignalApplyOutcome, QuestTurnInInput,
};
use crate::domain::*;
use crate::errors::QuestRecordFieldError;
use shared_kernel::{
normalize_optional_string as normalize_shared_optional_string, normalize_required_string,
normalize_string_list as normalize_shared_string_list,
};
pub fn normalize_optional_text(value: Option<String>) -> Option<String> {
normalize_shared_optional_string(value)
}
pub fn normalize_string_list(values: Vec<String>) -> Vec<String> {
normalize_shared_string_list(values)
}
pub fn build_quest_record_snapshot(
input: QuestRecordInput,
) -> Result<QuestRecordSnapshot, QuestRecordFieldError> {
let quest_id = normalize_required_text(input.quest_id, QuestRecordFieldError::MissingQuestId)?;
let runtime_session_id = normalize_required_text(
input.runtime_session_id,
QuestRecordFieldError::MissingRuntimeSessionId,
)?;
let actor_user_id = normalize_required_text(
input.actor_user_id,
QuestRecordFieldError::MissingActorUserId,
)?;
let issuer_npc_id = normalize_required_text(
input.issuer_npc_id,
QuestRecordFieldError::MissingIssuerNpcId,
)?;
let issuer_npc_name = normalize_required_text(
input.issuer_npc_name,
QuestRecordFieldError::MissingIssuerNpcName,
)?;
let title = normalize_required_text(input.title, QuestRecordFieldError::MissingTitle)?;
let description =
normalize_required_text(input.description, QuestRecordFieldError::MissingDescription)?;
let reward_text =
normalize_required_text(input.reward_text, QuestRecordFieldError::MissingRewardText)?;
if input.steps.is_empty() {
return Err(QuestRecordFieldError::EmptySteps);
}
let steps = input
.steps
.into_iter()
.map(normalize_quest_step)
.collect::<Result<Vec<_>, _>>()?;
let active_step = resolve_active_step(&steps, input.active_step_id.as_deref());
let active_step_id = active_step.map(|step| step.step_id.clone());
let fallback_step = steps
.last()
.cloned()
.expect("BUG: validated quest steps should not be empty");
let objective = build_objective_from_step(active_step.unwrap_or(&fallback_step));
let progress = active_step
.map(|step| step.progress)
.unwrap_or(fallback_step.required_count);
let status = normalize_quest_status(input.status, active_step.is_some());
let completed_at_micros = if status.is_reward_ready() {
Some(input.created_at_micros)
} else {
None
};
let turned_in_at_micros = if status == QuestStatus::TurnedIn {
Some(input.created_at_micros)
} else {
None
};
Ok(QuestRecordSnapshot {
quest_id,
runtime_session_id,
story_session_id: normalize_optional_text(input.story_session_id),
actor_user_id,
issuer_npc_id,
issuer_npc_name,
scene_id: normalize_optional_text(input.scene_id),
chapter_id: normalize_optional_text(input.chapter_id),
act_id: normalize_optional_text(input.act_id),
thread_id: normalize_optional_text(input.thread_id),
contract_id: normalize_optional_text(input.contract_id),
title,
description: description.clone(),
summary: normalize_optional_text(Some(input.summary)).unwrap_or(description),
objective,
progress,
status,
completion_notified: input.completion_notified || status == QuestStatus::TurnedIn,
reward: normalize_quest_reward(input.reward)?,
reward_text,
narrative_binding: normalize_quest_narrative_binding(input.narrative_binding),
steps,
active_step_id,
visible_stage: input.visible_stage,
hidden_flags: normalize_string_list(input.hidden_flags),
discovered_fact_ids: normalize_string_list(input.discovered_fact_ids),
related_carrier_ids: normalize_string_list(input.related_carrier_ids),
consequence_ids: normalize_string_list(input.consequence_ids),
created_at_micros: input.created_at_micros,
updated_at_micros: input.created_at_micros,
completed_at_micros,
turned_in_at_micros,
})
}
// 任务推进只认当前 active step未命中或已终态时统一保持 no-op确保 story action 可安全重复派发信号。
pub fn apply_quest_signal(
current: QuestRecordSnapshot,
input: QuestSignalApplyInput,
) -> Result<QuestSignalApplyOutcome, QuestRecordFieldError> {
let quest_id = normalize_required_text(input.quest_id, QuestRecordFieldError::MissingQuestId)?;
let signal_kind = QuestSignalKind::from(&input.signal);
if current.quest_id != quest_id
|| current.status.is_terminal()
|| current.status.is_reward_ready()
{
return Ok(QuestSignalApplyOutcome {
next_record: current,
changed: false,
completed_now: false,
changed_step_id: None,
changed_step_progress: None,
signal_kind,
});
}
let active_step = match resolve_active_step(&current.steps, current.active_step_id.as_deref()) {
Some(step) => step,
None => {
return Ok(QuestSignalApplyOutcome {
next_record: current,
changed: false,
completed_now: false,
changed_step_id: None,
changed_step_progress: None,
signal_kind,
});
}
};
if !step_matches_signal(active_step, &input.signal) {
return Ok(QuestSignalApplyOutcome {
next_record: current,
changed: false,
completed_now: false,
changed_step_id: None,
changed_step_progress: None,
signal_kind,
});
}
let increment = signal_progress_increment(&input.signal);
let mut changed_step_id = None;
let mut changed_step_progress = None;
let next_steps = current
.steps
.iter()
.cloned()
.map(|mut step| {
if step.step_id == active_step.step_id {
let next_progress = (step.progress + increment).min(step.required_count);
if next_progress != step.progress {
step.progress = next_progress;
changed_step_id = Some(step.step_id.clone());
changed_step_progress = Some(step.progress);
}
}
step
})
.collect::<Vec<_>>();
if changed_step_id.is_none() {
return Ok(QuestSignalApplyOutcome {
next_record: current,
changed: false,
completed_now: false,
changed_step_id: None,
changed_step_progress: None,
signal_kind,
});
}
let next_active_step = resolve_active_step(&next_steps, None);
let next_active_step_id = next_active_step.map(|step| step.step_id.clone());
let fallback_step = next_steps
.last()
.cloned()
.expect("BUG: progressed quest should still contain steps");
let next_status = normalize_quest_status(current.status, next_active_step.is_some());
let completed_now = !current.status.is_reward_ready() && next_status.is_reward_ready();
let next_objective = build_objective_from_step(next_active_step.unwrap_or(&fallback_step));
let next_progress = next_active_step
.map(|step| step.progress)
.unwrap_or(fallback_step.required_count);
Ok(QuestSignalApplyOutcome {
next_record: QuestRecordSnapshot {
objective: next_objective,
progress: next_progress,
status: next_status,
completion_notified: false,
steps: next_steps,
active_step_id: next_active_step_id,
updated_at_micros: input.updated_at_micros,
completed_at_micros: if completed_now {
Some(input.updated_at_micros)
} else {
current.completed_at_micros
},
..current
},
changed: true,
completed_now,
changed_step_id,
changed_step_progress,
signal_kind,
})
}
pub fn acknowledge_quest_completion(
current: QuestRecordSnapshot,
input: QuestCompletionAckInput,
) -> Result<QuestCompletionAckOutcome, QuestRecordFieldError> {
let quest_id = normalize_required_text(input.quest_id, QuestRecordFieldError::MissingQuestId)?;
if current.quest_id != quest_id || current.completion_notified {
return Ok(QuestCompletionAckOutcome {
next_record: current,
changed: false,
});
}
Ok(QuestCompletionAckOutcome {
next_record: QuestRecordSnapshot {
completion_notified: true,
updated_at_micros: input.updated_at_micros,
..current
},
changed: true,
})
}
// 任务交付只负责把任务固定到 TurnedIn不在本轮提前掺入货币、背包和关系奖励发放。
pub fn turn_in_quest_record(
current: QuestRecordSnapshot,
input: QuestTurnInInput,
) -> Result<QuestRecordSnapshot, QuestRecordFieldError> {
let quest_id = normalize_required_text(input.quest_id, QuestRecordFieldError::MissingQuestId)?;
if current.quest_id != quest_id || !current.status.is_reward_ready() {
return Err(QuestRecordFieldError::QuestNotReadyToTurnIn);
}
let steps = current
.steps
.into_iter()
.map(|mut step| {
step.progress = step.required_count;
step
})
.collect::<Vec<_>>();
let fallback_step = steps
.last()
.cloned()
.expect("BUG: turn in quest should preserve steps");
Ok(QuestRecordSnapshot {
objective: build_objective_from_step(&fallback_step),
progress: fallback_step.required_count,
status: QuestStatus::TurnedIn,
completion_notified: true,
steps,
active_step_id: None,
updated_at_micros: input.turned_in_at_micros,
completed_at_micros: current
.completed_at_micros
.or(Some(input.turned_in_at_micros)),
turned_in_at_micros: Some(input.turned_in_at_micros),
..current
})
}
pub fn generate_quest_log_id(
quest_id: &str,
event_kind: QuestLogEventKind,
seed_micros: i64,
) -> String {
format!(
"{}{}_{:x}_{}",
QUEST_LOG_ID_PREFIX,
event_kind.as_str(),
seed_micros,
quest_id
)
}
fn normalize_required_text(
value: String,
error: QuestRecordFieldError,
) -> Result<String, QuestRecordFieldError> {
normalize_required_string(value).ok_or(error)
}
fn normalize_quest_reward(
mut reward: QuestRewardSnapshot,
) -> Result<QuestRewardSnapshot, QuestRecordFieldError> {
reward.story_hint = normalize_optional_text(reward.story_hint);
reward.intel = reward.intel.and_then(|intel| {
let rumor_text = intel.rumor_text.trim().to_string();
let unlocked_scene_id = normalize_optional_text(intel.unlocked_scene_id);
if rumor_text.is_empty() {
None
} else {
Some(QuestRewardIntel {
rumor_text,
unlocked_scene_id,
})
}
});
reward.items = reward
.items
.into_iter()
.map(
|mut item| -> Result<QuestRewardItem, QuestRecordFieldError> {
item.item_id = normalize_required_text(
item.item_id,
QuestRecordFieldError::MissingRewardItemId,
)?;
item.category = normalize_required_text(
item.category,
QuestRecordFieldError::MissingRewardItemCategory,
)?;
item.name = normalize_required_text(
item.name,
QuestRecordFieldError::MissingRewardItemName,
)?;
item.description = normalize_optional_text(item.description);
if item.quantity == 0 {
return Err(QuestRecordFieldError::InvalidRewardItemQuantity);
}
if !item.stackable && item.quantity != 1 {
return Err(
QuestRecordFieldError::RewardNonStackableItemMustStaySingleQuantity,
);
}
if item.equipment_slot_id.is_some() && item.stackable {
return Err(QuestRecordFieldError::RewardEquipmentItemCannotStack);
}
item.tags = normalize_string_list(item.tags);
item.stack_key = if item.stackable {
normalize_required_text(
item.stack_key,
QuestRecordFieldError::MissingRewardItemStackKey,
)?
} else {
normalize_optional_text(Some(item.stack_key))
.unwrap_or_else(|| item.item_id.clone())
};
Ok(item)
},
)
.collect::<Result<Vec<_>, _>>()?;
Ok(reward)
}
fn normalize_quest_narrative_binding(
mut binding: QuestNarrativeBindingSnapshot,
) -> QuestNarrativeBindingSnapshot {
binding.dramatic_need = binding.dramatic_need.trim().to_string();
binding.issuer_goal = binding.issuer_goal.trim().to_string();
binding.player_hook = binding.player_hook.trim().to_string();
binding.world_reason = binding.world_reason.trim().to_string();
binding.followup_hooks = normalize_string_list(binding.followup_hooks);
binding
}
fn normalize_quest_step(
mut step: QuestStepSnapshot,
) -> Result<QuestStepSnapshot, QuestRecordFieldError> {
step.step_id = normalize_required_text(step.step_id, QuestRecordFieldError::MissingStepId)?;
step.title = normalize_required_text(step.title, QuestRecordFieldError::MissingStepTitle)?;
step.reveal_text = normalize_required_text(
step.reveal_text,
QuestRecordFieldError::MissingStepRevealText,
)?;
step.complete_text = normalize_required_text(
step.complete_text,
QuestRecordFieldError::MissingStepCompleteText,
)?;
step.required_count = step.required_count.max(1);
step.progress = step.progress.min(step.required_count);
step.target_hostile_npc_id = normalize_optional_text(step.target_hostile_npc_id);
step.target_npc_id = normalize_optional_text(step.target_npc_id);
step.target_scene_id = normalize_optional_text(step.target_scene_id);
step.target_item_id = normalize_optional_text(step.target_item_id);
Ok(step)
}
fn resolve_active_step<'a>(
steps: &'a [QuestStepSnapshot],
active_step_id: Option<&str>,
) -> Option<&'a QuestStepSnapshot> {
if let Some(active_step_id) = active_step_id {
let active_step_id = active_step_id.trim();
if !active_step_id.is_empty() {
if let Some(step) = steps
.iter()
.find(|step| step.step_id == active_step_id && step.progress < step.required_count)
{
return Some(step);
}
}
}
steps
.iter()
.find(|step| step.progress < step.required_count)
}
fn build_objective_from_step(step: &QuestStepSnapshot) -> QuestObjectiveSnapshot {
QuestObjectiveSnapshot {
kind: step.kind,
target_hostile_npc_id: step.target_hostile_npc_id.clone(),
target_npc_id: step.target_npc_id.clone(),
target_scene_id: step.target_scene_id.clone(),
target_item_id: step.target_item_id.clone(),
required_count: step.required_count,
}
}
fn normalize_quest_status(status: QuestStatus, has_active_step: bool) -> QuestStatus {
if status.is_terminal() {
return status;
}
if has_active_step {
QuestStatus::Active
} else if status == QuestStatus::ReadyToTurnIn {
QuestStatus::ReadyToTurnIn
} else {
QuestStatus::Completed
}
}
fn step_matches_signal(step: &QuestStepSnapshot, signal: &QuestProgressSignal) -> bool {
match signal {
QuestProgressSignal::HostileNpcDefeated(payload) => {
step.kind == QuestObjectiveKind::DefeatHostileNpc
&& step.target_hostile_npc_id.as_deref() == Some(payload.hostile_npc_id.as_str())
&& step
.target_scene_id
.as_deref()
.is_none_or(|value| Some(value.to_string()) == payload.scene_id.clone())
}
QuestProgressSignal::TreasureInspected(payload) => {
step.kind == QuestObjectiveKind::InspectTreasure
&& step
.target_scene_id
.as_deref()
.is_none_or(|value| Some(value.to_string()) == payload.scene_id.clone())
}
QuestProgressSignal::NpcSparCompleted(payload) => {
step.kind == QuestObjectiveKind::SparWithNpc
&& step.target_npc_id.as_deref() == Some(payload.npc_id.as_str())
}
QuestProgressSignal::NpcTalkCompleted(payload) => {
step.kind == QuestObjectiveKind::TalkToNpc
&& step.target_npc_id.as_deref() == Some(payload.npc_id.as_str())
}
QuestProgressSignal::SceneReached(payload) => {
step.kind == QuestObjectiveKind::ReachScene
&& step.target_scene_id.as_deref() == Some(payload.scene_id.as_str())
}
QuestProgressSignal::ItemDelivered(payload) => {
step.kind == QuestObjectiveKind::DeliverItem
&& step.target_npc_id.as_deref() == Some(payload.npc_id.as_str())
&& step.target_item_id.as_deref() == Some(payload.item_id.as_str())
}
}
}
fn signal_progress_increment(signal: &QuestProgressSignal) -> u32 {
match signal {
QuestProgressSignal::ItemDelivered(payload) => payload.quantity.max(1),
_ => 1,
}
}