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, 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, pub error_message: Option, } #[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, 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, 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, pub error_message: Option, } #[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, pub chapter_index: Option, 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, created_at_micros: i64, updated_at_micros: i64, ) -> Result { let user_id = normalize_required_text(user_id, ProgressionFieldError::MissingUserId)?; let level = resolve_level_from_total_xp(total_xp); let benchmark = build_level_benchmark(level); let (current_level_xp, xp_to_next_level) = if level >= MAX_PLAYER_LEVEL { (0, 0) } else { ( total_xp.saturating_sub(benchmark.cumulative_xp_required), benchmark.xp_to_next_level, ) }; Ok(PlayerProgressionSnapshot { user_id, level, current_level_xp, total_xp, xp_to_next_level, pending_level_ups: 0, last_granted_source, created_at_micros, updated_at_micros, }) } // 新存档默认统一回填为 Lv.1 / 0 XP,后续再由任务和战斗奖励驱动成长。 pub fn create_initial_player_progression( user_id: String, created_at_micros: i64, ) -> Result { build_player_progression_snapshot(user_id, 0, None, created_at_micros, created_at_micros) } // 经验结算统一以“旧快照 + grant 输入”生成新快照,避免各调用方自行改等级字段。 pub fn grant_player_experience( current: PlayerProgressionSnapshot, input: PlayerProgressionGrantInput, ) -> Result { let user_id = normalize_required_text(input.user_id, ProgressionFieldError::MissingUserId)?; if current.user_id != user_id { return Err(ProgressionFieldError::MissingUserId); } let next_total_xp = current.total_xp.saturating_add(input.amount); let mut next = build_player_progression_snapshot( current.user_id.clone(), next_total_xp, Some(input.source), current.created_at_micros, input.updated_at_micros, )?; next.pending_level_ups = next.level.saturating_sub(current.level); Ok(next) } // 章节快照同时承载计划预算与实际记账,这样 chapter_progression 一张表就能覆盖计划/偏差回看。 pub fn build_chapter_progression_snapshot( input: ChapterProgressionInput, ) -> Result { let user_id = normalize_required_text(input.user_id, ProgressionFieldError::MissingUserId)?; let chapter_id = normalize_required_text(input.chapter_id, ProgressionFieldError::MissingChapterId)?; if input.chapter_index == 0 { return Err(ProgressionFieldError::InvalidChapterIndex); } if input.total_chapters == 0 || input.chapter_index > input.total_chapters { return Err(ProgressionFieldError::InvalidTotalChapters); } let entry_level = clamp_level(input.entry_level); let exit_level = clamp_level(input.exit_level); if exit_level < entry_level { return Err(ProgressionFieldError::InvalidEntryExitLevel); } if input.planned_total_xp < input.planned_quest_xp + input.planned_hostile_xp { return Err(ProgressionFieldError::InvalidXpBudget); } Ok(ChapterProgressionSnapshot { user_id, chapter_id, chapter_index: input.chapter_index, total_chapters: input.total_chapters, entry_pseudo_level_millis: input.entry_pseudo_level_millis.max(1_000), exit_pseudo_level_millis: input .exit_pseudo_level_millis .max(input.entry_pseudo_level_millis.max(1_000)), entry_level, exit_level, planned_total_xp: input.planned_total_xp, planned_quest_xp: input.planned_quest_xp, planned_hostile_xp: input.planned_hostile_xp, actual_quest_xp: 0, actual_hostile_xp: 0, expected_hostile_defeat_count: input.expected_hostile_defeat_count, actual_hostile_defeat_count: 0, level_at_entry: clamp_level(input.level_at_entry), level_at_exit: None, pace_band: input.pace_band, created_at_micros: input.updated_at_micros, updated_at_micros: input.updated_at_micros, }) } // 按章累计实际任务经验、敌对经验与击杀次数,为后续章节节奏评估提供同一真相源。 pub fn apply_chapter_progression_ledger( current: ChapterProgressionSnapshot, input: ChapterProgressionLedgerInput, ) -> Result { let user_id = normalize_required_text(input.user_id, ProgressionFieldError::MissingUserId)?; let chapter_id = normalize_required_text(input.chapter_id, ProgressionFieldError::MissingChapterId)?; if current.user_id != user_id || current.chapter_id != chapter_id { return Err(ProgressionFieldError::MissingChapterId); } Ok(ChapterProgressionSnapshot { actual_quest_xp: current .actual_quest_xp .saturating_add(input.granted_quest_xp), actual_hostile_xp: current .actual_hostile_xp .saturating_add(input.granted_hostile_xp), actual_hostile_defeat_count: current .actual_hostile_defeat_count .saturating_add(input.hostile_defeat_increment), level_at_exit: input .level_at_exit .map(clamp_level) .or(current.level_at_exit), updated_at_micros: input.updated_at_micros, ..current }) } pub fn resolve_terminal_story_level(total_chapters: u32) -> u32 { let resolved = (3_f64 + f64::from(total_chapters.max(1)) * 2.4).round() as u32; resolved.clamp(MIN_TERMINAL_STORY_LEVEL, DEFAULT_TERMINAL_STORY_LEVEL) } // 章节边界先算 pseudo level,再反推经验预算;这里固化设计文档中的 0.92 曲线。 pub fn resolve_chapter_boundary_pseudo_level_millis( boundary_index: u32, total_chapters: u32, ) -> u32 { if boundary_index == 0 || total_chapters == 0 { return 1_000; } let progress = (f64::from(boundary_index) / f64::from(total_chapters)).clamp(0.0, 1.0); let terminal_story_level = resolve_terminal_story_level(total_chapters); let pseudo_level = 1.0 + progress.powf(PSEUDO_LEVEL_CURVE_EXPONENT) * f64::from(terminal_story_level.saturating_sub(1)); (round_metric(pseudo_level, 3) * 1_000.0).round() as u32 } pub fn resolve_pseudo_level_xp_millis(pseudo_level_millis: u32) -> u32 { let pseudo_level = f64::from(pseudo_level_millis.max(1_000)) / 1_000.0; let lower_level = pseudo_level.floor().max(1.0) as u32; let mut lower_level_xp = 0_u32; for level in 1..lower_level { lower_level_xp = lower_level_xp.saturating_add(compute_xp_to_next_level(level)); } let partial = (f64::from(compute_xp_to_next_level(lower_level)) * (pseudo_level - f64::from(lower_level))) .round() as u32; lower_level_xp.saturating_add(partial) } // 章节自动定级当前先抽成纯数学 helper,等 custom-world Rust crate 就位后再直接接蓝图编译结果。 pub fn build_chapter_auto_level_profile( input: ChapterAutoLevelProfileInput, ) -> Result { let chapter_id = normalize_required_text(input.chapter_id, ProgressionFieldError::MissingChapterId)?; if input.chapter_index == 0 { return Err(ProgressionFieldError::InvalidChapterIndex); } let base_stage_level = f64::from(input.entry_pseudo_level_millis.max(1_000)) + f64::from( input .exit_pseudo_level_millis .max(input.entry_pseudo_level_millis.max(1_000)) .saturating_sub(input.entry_pseudo_level_millis.max(1_000)), ) * (f64::from(input.stage_progress_millis.min(1_000)) / 1_000.0); let base_stage_level = base_stage_level / 1_000.0; let role_offset = role_level_offset(input.progression_role); let level = clamp_level((base_stage_level + f64::from(role_offset)).round().max(1.0) as u32); let benchmark = build_level_benchmark(level); Ok(RuntimeEntityLevelProfile { level, reference_strength: benchmark.reference_strength, chapter_id: Some(chapter_id), chapter_index: Some(input.chapter_index), progression_role: input.progression_role, source: LevelProfileSource::ChapterAuto, }) } pub fn resolve_hostile_battle_max_hp(level_profile: &RuntimeEntityLevelProfile) -> u32 { let benchmark = build_level_benchmark(level_profile.level); let role_bonus = match level_profile.progression_role { ProgressionRole::HostileElite => 10, ProgressionRole::HostileBoss => 24, ProgressionRole::Rival => 6, _ => 0, }; (benchmark.base_hp / 9).max(32).saturating_add(role_bonus) } // 击败敌对 NPC 的经验掉落沿用现有倍率口径,避免迁移后任务/战斗经验节奏突然变化。 pub fn build_hostile_experience_reward( player_level: u32, level_profile: &RuntimeEntityLevelProfile, chapter_stage_multiplier_millis: u32, explicit_base_xp: Option, ) -> u32 { let benchmark = build_level_benchmark(level_profile.level); let base_kill_xp = explicit_base_xp .unwrap_or_else(|| ((benchmark.xp_to_next_level as f64) * 0.08).round() as u32); let level_delta_multiplier_millis = resolve_level_delta_multiplier_millis(player_level, level_profile.level); let role_multiplier_millis = match level_profile.progression_role { ProgressionRole::HostileElite => 1_150, ProgressionRole::HostileBoss => 1_300, ProgressionRole::Guide | ProgressionRole::Ambient | ProgressionRole::Support => 0, _ => 1_000, }; let scaled = u64::from(base_kill_xp) .saturating_mul(u64::from(chapter_stage_multiplier_millis)) .saturating_mul(u64::from(level_delta_multiplier_millis)) .saturating_mul(u64::from(role_multiplier_millis as u32)) / 1_000 / 1_000 / 1_000; let rounded = ((scaled as u32 + 2) / 5) * 5; rounded.max(5) } fn resolve_level_delta_multiplier_millis(player_level: u32, target_level: u32) -> u32 { if target_level + 4 <= player_level { return 300; } if target_level + 2 <= player_level { return 700; } if target_level >= player_level + 2 { return 1_150; } 1_000 } fn role_level_offset(role: ProgressionRole) -> i32 { match role { ProgressionRole::Ambient => -1, ProgressionRole::HostileElite => 1, ProgressionRole::HostileBoss => 2, _ => 0, } } fn normalize_required_text( value: String, error: ProgressionFieldError, ) -> Result { normalize_required_string(value).ok_or(error) } 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)); } }