后端重写提交

This commit is contained in:
2026-04-22 12:34:49 +08:00
parent cf8da3f50f
commit 997a8daada
438 changed files with 53355 additions and 865 deletions

View File

@@ -0,0 +1,14 @@
[package]
name = "module-progression"
edition.workspace = true
version.workspace = true
license.workspace = true
[features]
default = []
spacetime-types = ["dep:spacetimedb"]
[dependencies]
serde = { version = "1", features = ["derive"] }
shared-kernel = { path = "../shared-kernel" }
spacetimedb = { workspace = true, optional = true }

View File

@@ -1,29 +1,43 @@
# module-progression 独立模块 package 占位说明
# module-progression 成长与章节推进模块 crate 说明
日期:`2026-04-20`
日期:`2026-04-21`
## 1. package 职责
## 1. crate 职责
`module-progression` 是成长与章节推进模块 package后续负责:
`module-progression` 是成长与章节推进模块 crate当前与后续负责:
1. `player_progression``chapter_progression` 等成长状态模型
2. 等级、章节推进、敌对强度与进程规则
3. 与 runtime、story、quest 的成长联动
4.`apps/spacetime-module` 的成长表、reducer、view 聚合对接
1. `player_progression``chapter_progression` 的领域快照与校验规则。
2. 等级经验曲线、同级参考强度、敌对战斗生命值与经验掉落的统一数学基线。
3. 章节经验预算、章节实际记账与章节自动定级的纯领域 helper。
4.`crates/spacetime-module` 的成长真相表、reducer、procedure 聚合对接
## 2. 当前阶段说明
当前提交仅完成目录占位,不提前进入成长规则、投影与兼容接口实现。
当前阶段已不再是目录占位,已经完成以下首版落地:
后续与本 package 直接相关的任务包括:
1. 新增 `Cargo.toml``src/lib.rs`,形成真实可编译 crate。
2. 冻结 `LevelBenchmark``PlayerProgressionSnapshot``ChapterProgressionSnapshot``RuntimeEntityLevelProfile` 等首版领域类型。
3. 固化与 Node 侧一致的经验曲线、参考强度曲线、章节 pseudo level 曲线与敌对经验/生命值 fallback 规则。
4. 提供 `create_initial_player_progression``grant_player_experience``build_chapter_progression_snapshot``apply_chapter_progression_ledger` 等领域原语。
5. 提供 `build_chapter_auto_level_profile``build_hostile_experience_reward``resolve_hostile_battle_max_hp`,为后续 `quest / combat / npc` 联动提供统一成长基线。
6. `spacetime-module` 已把 `turn_in_quest``resolve_combat_action(Victory)` 接到 `player_progression / chapter_progression` 最小经验结算链。
1. 设计 `player_progression``chapter_progression`
2. 设计 `update_progression_state`
3. 对齐章节推进、成长变化与兼容输出结构
4. 接入 runtime 与 story 的成长联动
当前这轮刻意未做的范围:
## 3. 边界约束
1. 还没有把 `custom-world` 章节蓝图编译直接迁进 Rust。
2. 还没有把 `repeatPenalty`、超预算衰减和完整章节偏差审计表独立拆出。
3. 还没有在 crate 内直接承接 HTTP、Axum、LLM 或 OSS 副作用。
1. `module-progression` 保持纯领域规则与状态建模,不直接承接 LLM、OSS 或 HTTP 协议。
2. 成长状态作为 runtime 与 story 的公共领域组件,不能再次散落回单个 handler 或临时 service 中。
3. 前端兼容输出由 `apps/api-server` 暴露,成长状态真相由 `apps/spacetime-module` 聚合。
## 3. 当前已冻结关联文档
1. [../../../docs/design/LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md](../../../docs/design/LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md)
2. [../../../docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](../../../docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md)
3. [../../../docs/technical/M4_MODULE_PROGRESSION_SPACETIMEDB_BASELINE_2026-04-21.md](../../../docs/technical/M4_MODULE_PROGRESSION_SPACETIMEDB_BASELINE_2026-04-21.md)
4. [../../../docs/technical/M4_PROGRESSION_QUEST_COMBAT_INTEGRATION_2026-04-21.md](../../../docs/technical/M4_PROGRESSION_QUEST_COMBAT_INTEGRATION_2026-04-21.md)
## 4. 边界约束
1. `module-progression` 保持纯领域规则与状态建模,不直接承接 HTTP、JWT、OSS、LLM 或本地文件副作用。
2. 等级、经验、章节预算、自动定级必须以本 crate 的规则为唯一数学基线,不能再次散落回 route handler 或前端临时推导。
3. `player_progression``chapter_progression` 的持久化真相由 `crates/spacetime-module` 聚合,前端兼容输出与后端 facade 由 `crates/api-server` 暴露。
4. 若后续 `module-custom-world` 的章节蓝图 Rust 化与当前 helper 有冲突,必须先校正文档和领域规则,再继续接线。

View File

@@ -0,0 +1,770 @@
use std::{error::Error, fmt};
use serde::{Deserialize, Serialize};
use shared_kernel::normalize_required_string;
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
pub const MAX_PLAYER_LEVEL: u32 = 20;
pub const DEFAULT_TERMINAL_STORY_LEVEL: u32 = 15;
pub const MIN_TERMINAL_STORY_LEVEL: u32 = 5;
pub const PSEUDO_LEVEL_CURVE_EXPONENT: f64 = 0.92;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum PlayerProgressionGrantSource {
Quest,
HostileNpc,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ChapterPaceBand {
OpeningFast,
Steady,
Pressure,
FinaleDense,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProgressionRole {
Guide,
Ambient,
Support,
HostileStandard,
HostileElite,
HostileBoss,
Rival,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum LevelProfileSource {
ChapterAuto,
PresetOverride,
Manual,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct LevelBenchmark {
pub level: u32,
pub xp_to_next_level: u32,
pub cumulative_xp_required: u32,
pub reference_strength: u32,
pub base_hp: u32,
pub base_mana: u32,
pub baseline_damage_scale: f32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlayerProgressionSnapshot {
pub user_id: String,
pub level: u32,
pub current_level_xp: u32,
pub total_xp: u32,
pub xp_to_next_level: u32,
pub pending_level_ups: u32,
pub last_granted_source: Option<PlayerProgressionGrantSource>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlayerProgressionGetInput {
pub user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlayerProgressionGrantInput {
pub user_id: String,
pub amount: u32,
pub source: PlayerProgressionGrantSource,
pub updated_at_micros: i64,
}
#[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 ChapterProgressionSnapshot {
pub user_id: String,
pub chapter_id: String,
pub chapter_index: u32,
pub total_chapters: u32,
pub entry_pseudo_level_millis: u32,
pub exit_pseudo_level_millis: u32,
pub entry_level: u32,
pub exit_level: u32,
pub planned_total_xp: u32,
pub planned_quest_xp: u32,
pub planned_hostile_xp: u32,
pub actual_quest_xp: u32,
pub actual_hostile_xp: u32,
pub expected_hostile_defeat_count: u32,
pub actual_hostile_defeat_count: u32,
pub level_at_entry: u32,
pub level_at_exit: Option<u32>,
pub pace_band: ChapterPaceBand,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChapterProgressionGetInput {
pub user_id: String,
pub chapter_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChapterProgressionInput {
pub user_id: String,
pub chapter_id: String,
pub chapter_index: u32,
pub total_chapters: u32,
pub entry_pseudo_level_millis: u32,
pub exit_pseudo_level_millis: u32,
pub entry_level: u32,
pub exit_level: u32,
pub planned_total_xp: u32,
pub planned_quest_xp: u32,
pub planned_hostile_xp: u32,
pub expected_hostile_defeat_count: u32,
pub level_at_entry: u32,
pub pace_band: ChapterPaceBand,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChapterProgressionLedgerInput {
pub user_id: String,
pub chapter_id: String,
pub granted_quest_xp: u32,
pub granted_hostile_xp: u32,
pub hostile_defeat_increment: u32,
pub level_at_exit: Option<u32>,
pub updated_at_micros: i64,
}
#[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>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeEntityLevelProfile {
pub level: u32,
pub reference_strength: u32,
pub chapter_id: Option<String>,
pub chapter_index: Option<u32>,
pub progression_role: ProgressionRole,
pub source: LevelProfileSource,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChapterAutoLevelProfileInput {
pub chapter_id: String,
pub chapter_index: u32,
pub entry_pseudo_level_millis: u32,
pub exit_pseudo_level_millis: u32,
pub stage_progress_millis: u32,
pub progression_role: ProgressionRole,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ProgressionFieldError {
MissingUserId,
MissingChapterId,
InvalidChapterIndex,
InvalidTotalChapters,
InvalidLevel,
InvalidEntryExitLevel,
InvalidXpBudget,
InvalidExpectedHostileDefeatCount,
}
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)
}
impl ChapterPaceBand {
pub fn as_str(&self) -> &'static str {
match self {
Self::OpeningFast => "opening_fast",
Self::Steady => "steady",
Self::Pressure => "pressure",
Self::FinaleDense => "finale_dense",
}
}
}
impl ProgressionRole {
pub fn as_str(&self) -> &'static str {
match self {
Self::Guide => "guide",
Self::Ambient => "ambient",
Self::Support => "support",
Self::HostileStandard => "hostile_standard",
Self::HostileElite => "hostile_elite",
Self::HostileBoss => "hostile_boss",
Self::Rival => "rival",
}
}
}
impl LevelProfileSource {
pub fn as_str(&self) -> &'static str {
match self {
Self::ChapterAuto => "chapter_auto",
Self::PresetOverride => "preset_override",
Self::Manual => "manual",
}
}
}
impl PlayerProgressionGrantSource {
pub fn as_str(&self) -> &'static str {
match self {
Self::Quest => "quest",
Self::HostileNpc => "hostile_npc",
}
}
}
impl fmt::Display for ProgressionFieldError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingUserId => f.write_str("player_progression.user_id 不能为空"),
Self::MissingChapterId => f.write_str("chapter_progression.chapter_id 不能为空"),
Self::InvalidChapterIndex => {
f.write_str("chapter_progression.chapter_index 必须大于 0")
}
Self::InvalidTotalChapters => f.write_str("chapter_progression.total_chapters 非法"),
Self::InvalidLevel => f.write_str("player_progression.level 非法"),
Self::InvalidEntryExitLevel => {
f.write_str("chapter_progression.entry_level / exit_level 非法")
}
Self::InvalidXpBudget => f.write_str("chapter_progression 经验预算非法"),
Self::InvalidExpectedHostileDefeatCount => {
f.write_str("chapter_progression.expected_hostile_defeat_count 非法")
}
}
}
}
impl Error for ProgressionFieldError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn create_initial_player_progression_starts_from_level_one() {
let snapshot =
create_initial_player_progression("user_001".to_string(), 10).expect("should build");
assert_eq!(snapshot.level, 1);
assert_eq!(snapshot.total_xp, 0);
assert_eq!(snapshot.current_level_xp, 0);
assert_eq!(snapshot.xp_to_next_level, 60);
assert_eq!(snapshot.last_granted_source, None);
}
#[test]
fn grant_player_experience_promotes_level_from_quest_reward() {
let current = build_player_progression_snapshot("user_001".to_string(), 50, None, 10, 10)
.expect("current snapshot should build");
let next = grant_player_experience(
current,
PlayerProgressionGrantInput {
user_id: "user_001".to_string(),
amount: 40,
source: PlayerProgressionGrantSource::Quest,
updated_at_micros: 20,
},
)
.expect("grant should succeed");
assert_eq!(next.level, 2);
assert_eq!(next.total_xp, 90);
assert_eq!(next.current_level_xp, 30);
assert_eq!(next.xp_to_next_level, 88);
assert_eq!(next.pending_level_ups, 1);
assert_eq!(
next.last_granted_source,
Some(PlayerProgressionGrantSource::Quest)
);
}
#[test]
fn build_level_benchmark_matches_node_curve() {
let benchmark = build_level_benchmark(5);
assert_eq!(benchmark.level, 5);
assert_eq!(benchmark.xp_to_next_level, 268);
assert_eq!(benchmark.cumulative_xp_required, 472);
assert_eq!(benchmark.reference_strength, 260);
assert_eq!(benchmark.base_hp, 436);
}
#[test]
fn chapter_boundary_pseudo_level_millis_grows_with_chapter_index() {
let first = resolve_chapter_boundary_pseudo_level_millis(1, 3);
let second = resolve_chapter_boundary_pseudo_level_millis(2, 3);
let third = resolve_chapter_boundary_pseudo_level_millis(3, 3);
assert!(second > first);
assert!(third > second);
}
#[test]
fn build_chapter_auto_level_profile_applies_role_offset() {
let standard = build_chapter_auto_level_profile(ChapterAutoLevelProfileInput {
chapter_id: "chapter-3".to_string(),
chapter_index: 3,
entry_pseudo_level_millis: 6_200,
exit_pseudo_level_millis: 8_800,
stage_progress_millis: 1_000,
progression_role: ProgressionRole::HostileStandard,
})
.expect("standard profile should build");
let boss = build_chapter_auto_level_profile(ChapterAutoLevelProfileInput {
chapter_id: "chapter-3".to_string(),
chapter_index: 3,
entry_pseudo_level_millis: 6_200,
exit_pseudo_level_millis: 8_800,
stage_progress_millis: 1_000,
progression_role: ProgressionRole::HostileBoss,
})
.expect("boss profile should build");
assert_eq!(standard.progression_role, ProgressionRole::HostileStandard);
assert_eq!(boss.progression_role, ProgressionRole::HostileBoss);
assert!(boss.level >= standard.level + 2);
assert_eq!(boss.source, LevelProfileSource::ChapterAuto);
}
#[test]
fn build_hostile_experience_reward_matches_existing_fallback_expectation() {
let level_profile = RuntimeEntityLevelProfile {
level: 5,
reference_strength: 260,
chapter_id: None,
chapter_index: None,
progression_role: ProgressionRole::HostileStandard,
source: LevelProfileSource::Manual,
};
let reward = build_hostile_experience_reward(5, &level_profile, 1_000, None);
let hp = resolve_hostile_battle_max_hp(&level_profile);
assert_eq!(reward, 20);
assert_eq!(hp, 48);
}
#[test]
fn apply_chapter_progression_ledger_accumulates_actual_values() {
let current = build_chapter_progression_snapshot(ChapterProgressionInput {
user_id: "user_001".to_string(),
chapter_id: "chapter-1".to_string(),
chapter_index: 1,
total_chapters: 3,
entry_pseudo_level_millis: 1_000,
exit_pseudo_level_millis: 5_000,
entry_level: 1,
exit_level: 5,
planned_total_xp: 320,
planned_quest_xp: 200,
planned_hostile_xp: 120,
expected_hostile_defeat_count: 3,
level_at_entry: 1,
pace_band: ChapterPaceBand::OpeningFast,
updated_at_micros: 10,
})
.expect("chapter snapshot should build");
let next = apply_chapter_progression_ledger(
current,
ChapterProgressionLedgerInput {
user_id: "user_001".to_string(),
chapter_id: "chapter-1".to_string(),
granted_quest_xp: 60,
granted_hostile_xp: 20,
hostile_defeat_increment: 1,
level_at_exit: Some(2),
updated_at_micros: 20,
},
)
.expect("ledger apply should succeed");
assert_eq!(next.actual_quest_xp, 60);
assert_eq!(next.actual_hostile_xp, 20);
assert_eq!(next.actual_hostile_defeat_count, 1);
assert_eq!(next.level_at_exit, Some(2));
}
}