后端重写提交
This commit is contained in:
14
server-rs/crates/module-progression/Cargo.toml
Normal file
14
server-rs/crates/module-progression/Cargo.toml
Normal 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 }
|
||||
@@ -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 有冲突,必须先校正文档和领域规则,再继续接线。
|
||||
|
||||
770
server-rs/crates/module-progression/src/lib.rs
Normal file
770
server-rs/crates/module-progression/src/lib.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user