387 lines
14 KiB
Rust
387 lines
14 KiB
Rust
//! 成长应用服务。
|
||
//!
|
||
//! 应用层把成长命令转换成玩家等级、章节预算、章节账本和实体定级结果;它不直接读取
|
||
//! 其他上下文表,也不执行 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)
|
||
}
|