Files
Genarrative/server-rs/crates/module-progression/src/lib.rs
kdletters cbc27bad4a
Some checks failed
CI / verify (push) Has been cancelled
init with react+axum+spacetimedb
2026-04-26 18:06:23 +08:00

771 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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));
}
}