//! 成长应用服务。 //! //! 应用层把成长命令转换成玩家等级、章节预算、章节账本和实体定级结果;它不直接读取 //! 其他上下文表,也不执行 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, pub error_message: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ChapterProgressionProcedureResult { pub ok: bool, pub record: Option, pub error_message: Option, } 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, created_at_micros: i64, updated_at_micros: i64, ) -> Result { 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 { 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 { 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 { 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 { 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 { 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 { 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 { normalize_required_string(value).ok_or(error) }