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,386 @@
//! 成长应用编排过渡落位
//! 成长应用服务
//!
//! 这里只返回等级变化、预算变化和账本结果,不直接读取其他上下文表。
//! 应用层把成长命令转换成玩家等级、章节预算、章节账本和实体定级结果;它不直接读取
//! 其他上下文表,也不执行 HTTP、LLM、OSS 等外部副作用。
use crate::commands::{
ChapterAutoLevelProfileInput, ChapterProgressionInput, ChapterProgressionLedgerInput,
PlayerProgressionGrantInput,
};
use crate::domain::{
ChapterProgressionSnapshot, DEFAULT_TERMINAL_STORY_LEVEL, LevelBenchmark, LevelProfileSource,
MAX_PLAYER_LEVEL, MIN_TERMINAL_STORY_LEVEL, PSEUDO_LEVEL_CURVE_EXPONENT,
PlayerProgressionGrantSource, PlayerProgressionSnapshot, ProgressionRole,
RuntimeEntityLevelProfile,
};
use crate::errors::ProgressionFieldError;
use serde::{Deserialize, Serialize};
use shared_kernel::normalize_required_string;
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlayerProgressionProcedureResult {
pub ok: bool,
pub record: Option<PlayerProgressionSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChapterProgressionProcedureResult {
pub ok: bool,
pub record: Option<ChapterProgressionSnapshot>,
pub error_message: Option<String>,
}
fn clamp_level(level: u32) -> u32 {
level.clamp(1, MAX_PLAYER_LEVEL)
}
fn round_metric(value: f64, digits: usize) -> f64 {
let factor = 10_f64.powi(digits as i32);
(value * factor).round() / factor
}
fn scale(level: u32) -> u32 {
level.saturating_sub(1)
}
// 等级经验曲线与现有 Node 版保持同一公式,避免 Rust 迁移后成长节奏漂移。
pub fn compute_xp_to_next_level(level: u32) -> u32 {
let normalized_level = clamp_level(level);
let scale = scale(normalized_level);
60 + 20 * scale + 8 * scale * scale
}
pub fn build_level_benchmark(level: u32) -> LevelBenchmark {
let normalized_level = clamp_level(level);
let current_scale = scale(normalized_level);
let mut cumulative_xp_required = 0_u32;
for current in 1..normalized_level {
cumulative_xp_required += compute_xp_to_next_level(current);
}
let xp_to_next_level = if normalized_level >= MAX_PLAYER_LEVEL {
0
} else {
compute_xp_to_next_level(normalized_level)
};
LevelBenchmark {
level: normalized_level,
xp_to_next_level,
cumulative_xp_required,
reference_strength: 100 + 16 * current_scale + 6 * current_scale * current_scale,
base_hp: 180 + 24 * current_scale + 10 * current_scale * current_scale,
base_mana: 80 + 14 * current_scale + 6 * current_scale * current_scale,
baseline_damage_scale: round_metric(
1.0 + 0.12 * f64::from(current_scale) + 0.03 * f64::from(current_scale * current_scale),
3,
) as f32,
}
}
// 总经验决定真实等级SpacetimeDB 持久化后不再允许前端自己推导等级结果。
pub fn resolve_level_from_total_xp(total_xp: u32) -> u32 {
let mut resolved_level = 1;
for level in 2..=MAX_PLAYER_LEVEL {
if total_xp < build_level_benchmark(level).cumulative_xp_required {
break;
}
resolved_level = level;
}
resolved_level
}
pub fn build_player_progression_snapshot(
user_id: String,
total_xp: u32,
last_granted_source: Option<PlayerProgressionGrantSource>,
created_at_micros: i64,
updated_at_micros: i64,
) -> Result<PlayerProgressionSnapshot, ProgressionFieldError> {
let user_id = normalize_required_text(user_id, ProgressionFieldError::MissingUserId)?;
let level = resolve_level_from_total_xp(total_xp);
let benchmark = build_level_benchmark(level);
let (current_level_xp, xp_to_next_level) = if level >= MAX_PLAYER_LEVEL {
(0, 0)
} else {
(
total_xp.saturating_sub(benchmark.cumulative_xp_required),
benchmark.xp_to_next_level,
)
};
Ok(PlayerProgressionSnapshot {
user_id,
level,
current_level_xp,
total_xp,
xp_to_next_level,
pending_level_ups: 0,
last_granted_source,
created_at_micros,
updated_at_micros,
})
}
// 新存档默认统一回填为 Lv.1 / 0 XP后续再由任务和战斗奖励驱动成长。
pub fn create_initial_player_progression(
user_id: String,
created_at_micros: i64,
) -> Result<PlayerProgressionSnapshot, ProgressionFieldError> {
build_player_progression_snapshot(user_id, 0, None, created_at_micros, created_at_micros)
}
// 经验结算统一以“旧快照 + grant 输入”生成新快照,避免各调用方自行改等级字段。
pub fn grant_player_experience(
current: PlayerProgressionSnapshot,
input: PlayerProgressionGrantInput,
) -> Result<PlayerProgressionSnapshot, ProgressionFieldError> {
let user_id = normalize_required_text(input.user_id, ProgressionFieldError::MissingUserId)?;
if current.user_id != user_id {
return Err(ProgressionFieldError::MissingUserId);
}
let next_total_xp = current.total_xp.saturating_add(input.amount);
let mut next = build_player_progression_snapshot(
current.user_id.clone(),
next_total_xp,
Some(input.source),
current.created_at_micros,
input.updated_at_micros,
)?;
next.pending_level_ups = next.level.saturating_sub(current.level);
Ok(next)
}
// 章节快照同时承载计划预算与实际记账,这样 chapter_progression 一张表就能覆盖计划/偏差回看。
pub fn build_chapter_progression_snapshot(
input: ChapterProgressionInput,
) -> Result<ChapterProgressionSnapshot, ProgressionFieldError> {
let user_id = normalize_required_text(input.user_id, ProgressionFieldError::MissingUserId)?;
let chapter_id =
normalize_required_text(input.chapter_id, ProgressionFieldError::MissingChapterId)?;
if input.chapter_index == 0 {
return Err(ProgressionFieldError::InvalidChapterIndex);
}
if input.total_chapters == 0 || input.chapter_index > input.total_chapters {
return Err(ProgressionFieldError::InvalidTotalChapters);
}
let entry_level = clamp_level(input.entry_level);
let exit_level = clamp_level(input.exit_level);
if exit_level < entry_level {
return Err(ProgressionFieldError::InvalidEntryExitLevel);
}
if input.planned_total_xp < input.planned_quest_xp + input.planned_hostile_xp {
return Err(ProgressionFieldError::InvalidXpBudget);
}
Ok(ChapterProgressionSnapshot {
user_id,
chapter_id,
chapter_index: input.chapter_index,
total_chapters: input.total_chapters,
entry_pseudo_level_millis: input.entry_pseudo_level_millis.max(1_000),
exit_pseudo_level_millis: input
.exit_pseudo_level_millis
.max(input.entry_pseudo_level_millis.max(1_000)),
entry_level,
exit_level,
planned_total_xp: input.planned_total_xp,
planned_quest_xp: input.planned_quest_xp,
planned_hostile_xp: input.planned_hostile_xp,
actual_quest_xp: 0,
actual_hostile_xp: 0,
expected_hostile_defeat_count: input.expected_hostile_defeat_count,
actual_hostile_defeat_count: 0,
level_at_entry: clamp_level(input.level_at_entry),
level_at_exit: None,
pace_band: input.pace_band,
created_at_micros: input.updated_at_micros,
updated_at_micros: input.updated_at_micros,
})
}
// 按章累计实际任务经验、敌对经验与击杀次数,为后续章节节奏评估提供同一真相源。
pub fn apply_chapter_progression_ledger(
current: ChapterProgressionSnapshot,
input: ChapterProgressionLedgerInput,
) -> Result<ChapterProgressionSnapshot, ProgressionFieldError> {
let user_id = normalize_required_text(input.user_id, ProgressionFieldError::MissingUserId)?;
let chapter_id =
normalize_required_text(input.chapter_id, ProgressionFieldError::MissingChapterId)?;
if current.user_id != user_id || current.chapter_id != chapter_id {
return Err(ProgressionFieldError::MissingChapterId);
}
Ok(ChapterProgressionSnapshot {
actual_quest_xp: current
.actual_quest_xp
.saturating_add(input.granted_quest_xp),
actual_hostile_xp: current
.actual_hostile_xp
.saturating_add(input.granted_hostile_xp),
actual_hostile_defeat_count: current
.actual_hostile_defeat_count
.saturating_add(input.hostile_defeat_increment),
level_at_exit: input
.level_at_exit
.map(clamp_level)
.or(current.level_at_exit),
updated_at_micros: input.updated_at_micros,
..current
})
}
pub fn resolve_terminal_story_level(total_chapters: u32) -> u32 {
let resolved = (3_f64 + f64::from(total_chapters.max(1)) * 2.4).round() as u32;
resolved.clamp(MIN_TERMINAL_STORY_LEVEL, DEFAULT_TERMINAL_STORY_LEVEL)
}
// 章节边界先算 pseudo level再反推经验预算这里固化设计文档中的 0.92 曲线。
pub fn resolve_chapter_boundary_pseudo_level_millis(
boundary_index: u32,
total_chapters: u32,
) -> u32 {
if boundary_index == 0 || total_chapters == 0 {
return 1_000;
}
let progress = (f64::from(boundary_index) / f64::from(total_chapters)).clamp(0.0, 1.0);
let terminal_story_level = resolve_terminal_story_level(total_chapters);
let pseudo_level = 1.0
+ progress.powf(PSEUDO_LEVEL_CURVE_EXPONENT)
* f64::from(terminal_story_level.saturating_sub(1));
(round_metric(pseudo_level, 3) * 1_000.0).round() as u32
}
pub fn resolve_pseudo_level_xp_millis(pseudo_level_millis: u32) -> u32 {
let pseudo_level = f64::from(pseudo_level_millis.max(1_000)) / 1_000.0;
let lower_level = pseudo_level.floor().max(1.0) as u32;
let mut lower_level_xp = 0_u32;
for level in 1..lower_level {
lower_level_xp = lower_level_xp.saturating_add(compute_xp_to_next_level(level));
}
let partial = (f64::from(compute_xp_to_next_level(lower_level))
* (pseudo_level - f64::from(lower_level)))
.round() as u32;
lower_level_xp.saturating_add(partial)
}
// 章节自动定级当前先抽成纯数学 helper等 custom-world Rust crate 就位后再直接接蓝图编译结果。
pub fn build_chapter_auto_level_profile(
input: ChapterAutoLevelProfileInput,
) -> Result<RuntimeEntityLevelProfile, ProgressionFieldError> {
let chapter_id =
normalize_required_text(input.chapter_id, ProgressionFieldError::MissingChapterId)?;
if input.chapter_index == 0 {
return Err(ProgressionFieldError::InvalidChapterIndex);
}
let base_stage_level = f64::from(input.entry_pseudo_level_millis.max(1_000))
+ f64::from(
input
.exit_pseudo_level_millis
.max(input.entry_pseudo_level_millis.max(1_000))
.saturating_sub(input.entry_pseudo_level_millis.max(1_000)),
) * (f64::from(input.stage_progress_millis.min(1_000)) / 1_000.0);
let base_stage_level = base_stage_level / 1_000.0;
let role_offset = role_level_offset(input.progression_role);
let level = clamp_level((base_stage_level + f64::from(role_offset)).round().max(1.0) as u32);
let benchmark = build_level_benchmark(level);
Ok(RuntimeEntityLevelProfile {
level,
reference_strength: benchmark.reference_strength,
chapter_id: Some(chapter_id),
chapter_index: Some(input.chapter_index),
progression_role: input.progression_role,
source: LevelProfileSource::ChapterAuto,
})
}
pub fn resolve_hostile_battle_max_hp(level_profile: &RuntimeEntityLevelProfile) -> u32 {
let benchmark = build_level_benchmark(level_profile.level);
let role_bonus = match level_profile.progression_role {
ProgressionRole::HostileElite => 10,
ProgressionRole::HostileBoss => 24,
ProgressionRole::Rival => 6,
_ => 0,
};
(benchmark.base_hp / 9).max(32).saturating_add(role_bonus)
}
// 击败敌对 NPC 的经验掉落沿用现有倍率口径,避免迁移后任务/战斗经验节奏突然变化。
pub fn build_hostile_experience_reward(
player_level: u32,
level_profile: &RuntimeEntityLevelProfile,
chapter_stage_multiplier_millis: u32,
explicit_base_xp: Option<u32>,
) -> u32 {
let benchmark = build_level_benchmark(level_profile.level);
let base_kill_xp = explicit_base_xp
.unwrap_or_else(|| ((benchmark.xp_to_next_level as f64) * 0.08).round() as u32);
let level_delta_multiplier_millis =
resolve_level_delta_multiplier_millis(player_level, level_profile.level);
let role_multiplier_millis = match level_profile.progression_role {
ProgressionRole::HostileElite => 1_150,
ProgressionRole::HostileBoss => 1_300,
ProgressionRole::Guide | ProgressionRole::Ambient | ProgressionRole::Support => 0,
_ => 1_000,
};
let scaled = u64::from(base_kill_xp)
.saturating_mul(u64::from(chapter_stage_multiplier_millis))
.saturating_mul(u64::from(level_delta_multiplier_millis))
.saturating_mul(u64::from(role_multiplier_millis as u32))
/ 1_000
/ 1_000
/ 1_000;
let rounded = ((scaled as u32 + 2) / 5) * 5;
rounded.max(5)
}
fn resolve_level_delta_multiplier_millis(player_level: u32, target_level: u32) -> u32 {
if target_level + 4 <= player_level {
return 300;
}
if target_level + 2 <= player_level {
return 700;
}
if target_level >= player_level + 2 {
return 1_150;
}
1_000
}
fn role_level_offset(role: ProgressionRole) -> i32 {
match role {
ProgressionRole::Ambient => -1,
ProgressionRole::HostileElite => 1,
ProgressionRole::HostileBoss => 2,
_ => 0,
}
}
fn normalize_required_text(
value: String,
error: ProgressionFieldError,
) -> Result<String, ProgressionFieldError> {
normalize_required_string(value).ok_or(error)
}