Close DDD cleanup and tests-support closure
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# module-progression 成长与章节推进模块 crate 说明
|
||||
|
||||
日期:`2026-04-21`
|
||||
日期:`2026-04-30`
|
||||
|
||||
## 1. crate 职责
|
||||
|
||||
@@ -13,14 +13,15 @@
|
||||
|
||||
## 2. 当前阶段说明
|
||||
|
||||
当前阶段已不再是目录占位,已经完成以下首版落地:
|
||||
当前阶段已完成 DDD 物理拆分收口,已经不再是“真实逻辑集中在 `lib.rs`、分层文件只占位”的状态:
|
||||
|
||||
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. `src/domain.rs` 承载 `LevelBenchmark`、`PlayerProgressionSnapshot`、`ChapterProgressionSnapshot`、`RuntimeEntityLevelProfile` 等成长领域类型和值对象。
|
||||
2. `src/commands.rs` 承载玩家成长查询/授予经验、章节预算、章节账本和章节自动定级输入。
|
||||
3. `src/application.rs` 固化与既有 Node 侧一致的经验曲线、参考强度曲线、章节 pseudo level 曲线、敌对经验/生命值 fallback 和章节账本应用规则。
|
||||
4. `src/events.rs` 承载经验授予、章节账本应用和自动定级解析等领域事件。
|
||||
5. `src/errors.rs` 承载成长字段错误与中文错误文案。
|
||||
6. `src/lib.rs` 只保留模块声明和公开导出,继续保持 `module_progression::*` 公开 API。
|
||||
7. `spacetime-module` 已把 `turn_in_quest` 与 `resolve_combat_action(Victory)` 接到 `player_progression / chapter_progression` 最小经验结算链。
|
||||
|
||||
当前这轮刻意未做的范围:
|
||||
|
||||
@@ -34,6 +35,7 @@
|
||||
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)
|
||||
5. [../../../docs/technical/SERVER_RS_DDD_WP_RPG_PROGRESSION_DOMAIN_SPLIT_2026-04-30.md](../../../docs/technical/SERVER_RS_DDD_WP_RPG_PROGRESSION_DOMAIN_SPLIT_2026-04-30.md)
|
||||
|
||||
## 4. 边界约束
|
||||
|
||||
|
||||
@@ -1,3 +1,386 @@
|
||||
//! 成长应用编排过渡落位。
|
||||
//! 成长应用服务。
|
||||
//!
|
||||
//! 这里只返回等级变化、预算变化和账本结果,不直接读取其他上下文表。
|
||||
//! 应用层把成长命令转换成玩家等级、章节预算、章节账本和实体定级结果;它不直接读取
|
||||
//! 其他上下文表,也不执行 HTTP、LLM、OSS 等外部副作用。
|
||||
|
||||
use crate::commands::{
|
||||
ChapterAutoLevelProfileInput, ChapterProgressionInput, ChapterProgressionLedgerInput,
|
||||
PlayerProgressionGrantInput,
|
||||
};
|
||||
use crate::domain::{
|
||||
ChapterProgressionSnapshot, DEFAULT_TERMINAL_STORY_LEVEL, LevelBenchmark, LevelProfileSource,
|
||||
MAX_PLAYER_LEVEL, MIN_TERMINAL_STORY_LEVEL, PSEUDO_LEVEL_CURVE_EXPONENT,
|
||||
PlayerProgressionGrantSource, PlayerProgressionSnapshot, ProgressionRole,
|
||||
RuntimeEntityLevelProfile,
|
||||
};
|
||||
use crate::errors::ProgressionFieldError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared_kernel::normalize_required_string;
|
||||
#[cfg(feature = "spacetime-types")]
|
||||
use spacetimedb::SpacetimeType;
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PlayerProgressionProcedureResult {
|
||||
pub ok: bool,
|
||||
pub record: Option<PlayerProgressionSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ChapterProgressionProcedureResult {
|
||||
pub ok: bool,
|
||||
pub record: Option<ChapterProgressionSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
fn clamp_level(level: u32) -> u32 {
|
||||
level.clamp(1, MAX_PLAYER_LEVEL)
|
||||
}
|
||||
|
||||
fn round_metric(value: f64, digits: usize) -> f64 {
|
||||
let factor = 10_f64.powi(digits as i32);
|
||||
(value * factor).round() / factor
|
||||
}
|
||||
|
||||
fn scale(level: u32) -> u32 {
|
||||
level.saturating_sub(1)
|
||||
}
|
||||
|
||||
// 等级经验曲线与现有 Node 版保持同一公式,避免 Rust 迁移后成长节奏漂移。
|
||||
pub fn compute_xp_to_next_level(level: u32) -> u32 {
|
||||
let normalized_level = clamp_level(level);
|
||||
let scale = scale(normalized_level);
|
||||
60 + 20 * scale + 8 * scale * scale
|
||||
}
|
||||
|
||||
pub fn build_level_benchmark(level: u32) -> LevelBenchmark {
|
||||
let normalized_level = clamp_level(level);
|
||||
let current_scale = scale(normalized_level);
|
||||
let mut cumulative_xp_required = 0_u32;
|
||||
|
||||
for current in 1..normalized_level {
|
||||
cumulative_xp_required += compute_xp_to_next_level(current);
|
||||
}
|
||||
|
||||
let xp_to_next_level = if normalized_level >= MAX_PLAYER_LEVEL {
|
||||
0
|
||||
} else {
|
||||
compute_xp_to_next_level(normalized_level)
|
||||
};
|
||||
|
||||
LevelBenchmark {
|
||||
level: normalized_level,
|
||||
xp_to_next_level,
|
||||
cumulative_xp_required,
|
||||
reference_strength: 100 + 16 * current_scale + 6 * current_scale * current_scale,
|
||||
base_hp: 180 + 24 * current_scale + 10 * current_scale * current_scale,
|
||||
base_mana: 80 + 14 * current_scale + 6 * current_scale * current_scale,
|
||||
baseline_damage_scale: round_metric(
|
||||
1.0 + 0.12 * f64::from(current_scale) + 0.03 * f64::from(current_scale * current_scale),
|
||||
3,
|
||||
) as f32,
|
||||
}
|
||||
}
|
||||
|
||||
// 总经验决定真实等级,SpacetimeDB 持久化后不再允许前端自己推导等级结果。
|
||||
pub fn resolve_level_from_total_xp(total_xp: u32) -> u32 {
|
||||
let mut resolved_level = 1;
|
||||
|
||||
for level in 2..=MAX_PLAYER_LEVEL {
|
||||
if total_xp < build_level_benchmark(level).cumulative_xp_required {
|
||||
break;
|
||||
}
|
||||
resolved_level = level;
|
||||
}
|
||||
|
||||
resolved_level
|
||||
}
|
||||
|
||||
pub fn build_player_progression_snapshot(
|
||||
user_id: String,
|
||||
total_xp: u32,
|
||||
last_granted_source: Option<PlayerProgressionGrantSource>,
|
||||
created_at_micros: i64,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<PlayerProgressionSnapshot, ProgressionFieldError> {
|
||||
let user_id = normalize_required_text(user_id, ProgressionFieldError::MissingUserId)?;
|
||||
let level = resolve_level_from_total_xp(total_xp);
|
||||
let benchmark = build_level_benchmark(level);
|
||||
|
||||
let (current_level_xp, xp_to_next_level) = if level >= MAX_PLAYER_LEVEL {
|
||||
(0, 0)
|
||||
} else {
|
||||
(
|
||||
total_xp.saturating_sub(benchmark.cumulative_xp_required),
|
||||
benchmark.xp_to_next_level,
|
||||
)
|
||||
};
|
||||
|
||||
Ok(PlayerProgressionSnapshot {
|
||||
user_id,
|
||||
level,
|
||||
current_level_xp,
|
||||
total_xp,
|
||||
xp_to_next_level,
|
||||
pending_level_ups: 0,
|
||||
last_granted_source,
|
||||
created_at_micros,
|
||||
updated_at_micros,
|
||||
})
|
||||
}
|
||||
|
||||
// 新存档默认统一回填为 Lv.1 / 0 XP,后续再由任务和战斗奖励驱动成长。
|
||||
pub fn create_initial_player_progression(
|
||||
user_id: String,
|
||||
created_at_micros: i64,
|
||||
) -> Result<PlayerProgressionSnapshot, ProgressionFieldError> {
|
||||
build_player_progression_snapshot(user_id, 0, None, created_at_micros, created_at_micros)
|
||||
}
|
||||
|
||||
// 经验结算统一以“旧快照 + grant 输入”生成新快照,避免各调用方自行改等级字段。
|
||||
pub fn grant_player_experience(
|
||||
current: PlayerProgressionSnapshot,
|
||||
input: PlayerProgressionGrantInput,
|
||||
) -> Result<PlayerProgressionSnapshot, ProgressionFieldError> {
|
||||
let user_id = normalize_required_text(input.user_id, ProgressionFieldError::MissingUserId)?;
|
||||
if current.user_id != user_id {
|
||||
return Err(ProgressionFieldError::MissingUserId);
|
||||
}
|
||||
|
||||
let next_total_xp = current.total_xp.saturating_add(input.amount);
|
||||
let mut next = build_player_progression_snapshot(
|
||||
current.user_id.clone(),
|
||||
next_total_xp,
|
||||
Some(input.source),
|
||||
current.created_at_micros,
|
||||
input.updated_at_micros,
|
||||
)?;
|
||||
next.pending_level_ups = next.level.saturating_sub(current.level);
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
// 章节快照同时承载计划预算与实际记账,这样 chapter_progression 一张表就能覆盖计划/偏差回看。
|
||||
pub fn build_chapter_progression_snapshot(
|
||||
input: ChapterProgressionInput,
|
||||
) -> Result<ChapterProgressionSnapshot, ProgressionFieldError> {
|
||||
let user_id = normalize_required_text(input.user_id, ProgressionFieldError::MissingUserId)?;
|
||||
let chapter_id =
|
||||
normalize_required_text(input.chapter_id, ProgressionFieldError::MissingChapterId)?;
|
||||
|
||||
if input.chapter_index == 0 {
|
||||
return Err(ProgressionFieldError::InvalidChapterIndex);
|
||||
}
|
||||
if input.total_chapters == 0 || input.chapter_index > input.total_chapters {
|
||||
return Err(ProgressionFieldError::InvalidTotalChapters);
|
||||
}
|
||||
|
||||
let entry_level = clamp_level(input.entry_level);
|
||||
let exit_level = clamp_level(input.exit_level);
|
||||
if exit_level < entry_level {
|
||||
return Err(ProgressionFieldError::InvalidEntryExitLevel);
|
||||
}
|
||||
|
||||
if input.planned_total_xp < input.planned_quest_xp + input.planned_hostile_xp {
|
||||
return Err(ProgressionFieldError::InvalidXpBudget);
|
||||
}
|
||||
|
||||
Ok(ChapterProgressionSnapshot {
|
||||
user_id,
|
||||
chapter_id,
|
||||
chapter_index: input.chapter_index,
|
||||
total_chapters: input.total_chapters,
|
||||
entry_pseudo_level_millis: input.entry_pseudo_level_millis.max(1_000),
|
||||
exit_pseudo_level_millis: input
|
||||
.exit_pseudo_level_millis
|
||||
.max(input.entry_pseudo_level_millis.max(1_000)),
|
||||
entry_level,
|
||||
exit_level,
|
||||
planned_total_xp: input.planned_total_xp,
|
||||
planned_quest_xp: input.planned_quest_xp,
|
||||
planned_hostile_xp: input.planned_hostile_xp,
|
||||
actual_quest_xp: 0,
|
||||
actual_hostile_xp: 0,
|
||||
expected_hostile_defeat_count: input.expected_hostile_defeat_count,
|
||||
actual_hostile_defeat_count: 0,
|
||||
level_at_entry: clamp_level(input.level_at_entry),
|
||||
level_at_exit: None,
|
||||
pace_band: input.pace_band,
|
||||
created_at_micros: input.updated_at_micros,
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
})
|
||||
}
|
||||
|
||||
// 按章累计实际任务经验、敌对经验与击杀次数,为后续章节节奏评估提供同一真相源。
|
||||
pub fn apply_chapter_progression_ledger(
|
||||
current: ChapterProgressionSnapshot,
|
||||
input: ChapterProgressionLedgerInput,
|
||||
) -> Result<ChapterProgressionSnapshot, ProgressionFieldError> {
|
||||
let user_id = normalize_required_text(input.user_id, ProgressionFieldError::MissingUserId)?;
|
||||
let chapter_id =
|
||||
normalize_required_text(input.chapter_id, ProgressionFieldError::MissingChapterId)?;
|
||||
|
||||
if current.user_id != user_id || current.chapter_id != chapter_id {
|
||||
return Err(ProgressionFieldError::MissingChapterId);
|
||||
}
|
||||
|
||||
Ok(ChapterProgressionSnapshot {
|
||||
actual_quest_xp: current
|
||||
.actual_quest_xp
|
||||
.saturating_add(input.granted_quest_xp),
|
||||
actual_hostile_xp: current
|
||||
.actual_hostile_xp
|
||||
.saturating_add(input.granted_hostile_xp),
|
||||
actual_hostile_defeat_count: current
|
||||
.actual_hostile_defeat_count
|
||||
.saturating_add(input.hostile_defeat_increment),
|
||||
level_at_exit: input
|
||||
.level_at_exit
|
||||
.map(clamp_level)
|
||||
.or(current.level_at_exit),
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
..current
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resolve_terminal_story_level(total_chapters: u32) -> u32 {
|
||||
let resolved = (3_f64 + f64::from(total_chapters.max(1)) * 2.4).round() as u32;
|
||||
resolved.clamp(MIN_TERMINAL_STORY_LEVEL, DEFAULT_TERMINAL_STORY_LEVEL)
|
||||
}
|
||||
|
||||
// 章节边界先算 pseudo level,再反推经验预算;这里固化设计文档中的 0.92 曲线。
|
||||
pub fn resolve_chapter_boundary_pseudo_level_millis(
|
||||
boundary_index: u32,
|
||||
total_chapters: u32,
|
||||
) -> u32 {
|
||||
if boundary_index == 0 || total_chapters == 0 {
|
||||
return 1_000;
|
||||
}
|
||||
|
||||
let progress = (f64::from(boundary_index) / f64::from(total_chapters)).clamp(0.0, 1.0);
|
||||
let terminal_story_level = resolve_terminal_story_level(total_chapters);
|
||||
let pseudo_level = 1.0
|
||||
+ progress.powf(PSEUDO_LEVEL_CURVE_EXPONENT)
|
||||
* f64::from(terminal_story_level.saturating_sub(1));
|
||||
|
||||
(round_metric(pseudo_level, 3) * 1_000.0).round() as u32
|
||||
}
|
||||
|
||||
pub fn resolve_pseudo_level_xp_millis(pseudo_level_millis: u32) -> u32 {
|
||||
let pseudo_level = f64::from(pseudo_level_millis.max(1_000)) / 1_000.0;
|
||||
let lower_level = pseudo_level.floor().max(1.0) as u32;
|
||||
let mut lower_level_xp = 0_u32;
|
||||
|
||||
for level in 1..lower_level {
|
||||
lower_level_xp = lower_level_xp.saturating_add(compute_xp_to_next_level(level));
|
||||
}
|
||||
|
||||
let partial = (f64::from(compute_xp_to_next_level(lower_level))
|
||||
* (pseudo_level - f64::from(lower_level)))
|
||||
.round() as u32;
|
||||
|
||||
lower_level_xp.saturating_add(partial)
|
||||
}
|
||||
|
||||
// 章节自动定级当前先抽成纯数学 helper,等 custom-world Rust crate 就位后再直接接蓝图编译结果。
|
||||
pub fn build_chapter_auto_level_profile(
|
||||
input: ChapterAutoLevelProfileInput,
|
||||
) -> Result<RuntimeEntityLevelProfile, ProgressionFieldError> {
|
||||
let chapter_id =
|
||||
normalize_required_text(input.chapter_id, ProgressionFieldError::MissingChapterId)?;
|
||||
if input.chapter_index == 0 {
|
||||
return Err(ProgressionFieldError::InvalidChapterIndex);
|
||||
}
|
||||
|
||||
let base_stage_level = f64::from(input.entry_pseudo_level_millis.max(1_000))
|
||||
+ f64::from(
|
||||
input
|
||||
.exit_pseudo_level_millis
|
||||
.max(input.entry_pseudo_level_millis.max(1_000))
|
||||
.saturating_sub(input.entry_pseudo_level_millis.max(1_000)),
|
||||
) * (f64::from(input.stage_progress_millis.min(1_000)) / 1_000.0);
|
||||
let base_stage_level = base_stage_level / 1_000.0;
|
||||
let role_offset = role_level_offset(input.progression_role);
|
||||
let level = clamp_level((base_stage_level + f64::from(role_offset)).round().max(1.0) as u32);
|
||||
let benchmark = build_level_benchmark(level);
|
||||
|
||||
Ok(RuntimeEntityLevelProfile {
|
||||
level,
|
||||
reference_strength: benchmark.reference_strength,
|
||||
chapter_id: Some(chapter_id),
|
||||
chapter_index: Some(input.chapter_index),
|
||||
progression_role: input.progression_role,
|
||||
source: LevelProfileSource::ChapterAuto,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resolve_hostile_battle_max_hp(level_profile: &RuntimeEntityLevelProfile) -> u32 {
|
||||
let benchmark = build_level_benchmark(level_profile.level);
|
||||
let role_bonus = match level_profile.progression_role {
|
||||
ProgressionRole::HostileElite => 10,
|
||||
ProgressionRole::HostileBoss => 24,
|
||||
ProgressionRole::Rival => 6,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
(benchmark.base_hp / 9).max(32).saturating_add(role_bonus)
|
||||
}
|
||||
|
||||
// 击败敌对 NPC 的经验掉落沿用现有倍率口径,避免迁移后任务/战斗经验节奏突然变化。
|
||||
pub fn build_hostile_experience_reward(
|
||||
player_level: u32,
|
||||
level_profile: &RuntimeEntityLevelProfile,
|
||||
chapter_stage_multiplier_millis: u32,
|
||||
explicit_base_xp: Option<u32>,
|
||||
) -> u32 {
|
||||
let benchmark = build_level_benchmark(level_profile.level);
|
||||
let base_kill_xp = explicit_base_xp
|
||||
.unwrap_or_else(|| ((benchmark.xp_to_next_level as f64) * 0.08).round() as u32);
|
||||
let level_delta_multiplier_millis =
|
||||
resolve_level_delta_multiplier_millis(player_level, level_profile.level);
|
||||
let role_multiplier_millis = match level_profile.progression_role {
|
||||
ProgressionRole::HostileElite => 1_150,
|
||||
ProgressionRole::HostileBoss => 1_300,
|
||||
ProgressionRole::Guide | ProgressionRole::Ambient | ProgressionRole::Support => 0,
|
||||
_ => 1_000,
|
||||
};
|
||||
let scaled = u64::from(base_kill_xp)
|
||||
.saturating_mul(u64::from(chapter_stage_multiplier_millis))
|
||||
.saturating_mul(u64::from(level_delta_multiplier_millis))
|
||||
.saturating_mul(u64::from(role_multiplier_millis as u32))
|
||||
/ 1_000
|
||||
/ 1_000
|
||||
/ 1_000;
|
||||
let rounded = ((scaled as u32 + 2) / 5) * 5;
|
||||
rounded.max(5)
|
||||
}
|
||||
|
||||
fn resolve_level_delta_multiplier_millis(player_level: u32, target_level: u32) -> u32 {
|
||||
if target_level + 4 <= player_level {
|
||||
return 300;
|
||||
}
|
||||
if target_level + 2 <= player_level {
|
||||
return 700;
|
||||
}
|
||||
if target_level >= player_level + 2 {
|
||||
return 1_150;
|
||||
}
|
||||
1_000
|
||||
}
|
||||
|
||||
fn role_level_offset(role: ProgressionRole) -> i32 {
|
||||
match role {
|
||||
ProgressionRole::Ambient => -1,
|
||||
ProgressionRole::HostileElite => 1,
|
||||
ProgressionRole::HostileBoss => 2,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_required_text(
|
||||
value: String,
|
||||
error: ProgressionFieldError,
|
||||
) -> Result<String, ProgressionFieldError> {
|
||||
normalize_required_string(value).ok_or(error)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,74 @@
|
||||
//! 成长写入命令过渡落位。
|
||||
//! 成长写入命令。
|
||||
//!
|
||||
//! 用于表达授予经验、创建章节预算、结算章节节奏等输入。
|
||||
//! 这里固定授予经验、章节预算、章节账本和自动定级等输入结构,adapter 只能把外部
|
||||
//! 请求映射到这些命令,不在 SpacetimeDB 或 HTTP 层重复定义字段规则。
|
||||
|
||||
use crate::domain::{ChapterPaceBand, PlayerProgressionGrantSource, ProgressionRole};
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[cfg(feature = "spacetime-types")]
|
||||
use spacetimedb::SpacetimeType;
|
||||
|
||||
#[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 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 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,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,159 @@
|
||||
//! 成长领域模型过渡落位。
|
||||
//! 成长领域模型。
|
||||
//!
|
||||
//! 后续迁移玩家等级、章节预算和经验曲线时,只保留成长规则;
|
||||
//! 任务、战斗等奖励来源通过事件或应用结果接入。
|
||||
//! 本文件只承载玩家等级、章节预算、自动定级和实体强度相关的稳定值对象;
|
||||
//! 任务、战斗、NPC 等奖励来源通过应用服务输入和领域事件接入。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[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;
|
||||
/// 章节 pseudo level 曲线指数,保持与既有 Node 侧节奏一致。
|
||||
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,
|
||||
}
|
||||
|
||||
impl PlayerProgressionGrantSource {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Quest => "quest",
|
||||
Self::HostileNpc => "hostile_npc",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ChapterPaceBand {
|
||||
OpeningFast,
|
||||
Steady,
|
||||
Pressure,
|
||||
FinaleDense,
|
||||
}
|
||||
|
||||
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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum LevelProfileSource {
|
||||
ChapterAuto,
|
||||
PresetOverride,
|
||||
Manual,
|
||||
}
|
||||
|
||||
impl LevelProfileSource {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::ChapterAuto => "chapter_auto",
|
||||
Self::PresetOverride => "preset_override",
|
||||
Self::Manual => "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 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 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,
|
||||
}
|
||||
|
||||
@@ -1,3 +1,40 @@
|
||||
//! 成长领域错误过渡落位。
|
||||
//! 成长领域错误。
|
||||
//!
|
||||
//! 错误保持纯领域语义,例如章节参数非法或经验来源不被接受。
|
||||
//! 错误保持纯领域语义,例如章节参数非法、经验预算非法或用户/章节标识缺失。
|
||||
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ProgressionFieldError {
|
||||
MissingUserId,
|
||||
MissingChapterId,
|
||||
InvalidChapterIndex,
|
||||
InvalidTotalChapters,
|
||||
InvalidLevel,
|
||||
InvalidEntryExitLevel,
|
||||
InvalidXpBudget,
|
||||
InvalidExpectedHostileDefeatCount,
|
||||
}
|
||||
|
||||
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 {}
|
||||
|
||||
@@ -1,3 +1,46 @@
|
||||
//! 成长领域事件过渡落位。
|
||||
//! 成长领域事件。
|
||||
//!
|
||||
//! 用于表达经验已授予、升级待处理和章节节奏变化等事实。
|
||||
//! 领域事件用于表达经验、升级和章节账本已经发生的事实;是否持久化为 SpacetimeDB
|
||||
//! event table 或向前端投影,由外层 adapter 决定。
|
||||
|
||||
use crate::domain::{PlayerProgressionGrantSource, RuntimeEntityLevelProfile};
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[cfg(feature = "spacetime-types")]
|
||||
use spacetimedb::SpacetimeType;
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ProgressionDomainEvent {
|
||||
PlayerExperienceGranted(PlayerExperienceGrantedEvent),
|
||||
ChapterProgressionLedgerApplied(ChapterProgressionLedgerAppliedEvent),
|
||||
ChapterAutoLevelProfileResolved(ChapterAutoLevelProfileResolvedEvent),
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PlayerExperienceGrantedEvent {
|
||||
pub user_id: String,
|
||||
pub amount: u32,
|
||||
pub source: PlayerProgressionGrantSource,
|
||||
pub level: u32,
|
||||
pub pending_level_ups: u32,
|
||||
pub occurred_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ChapterProgressionLedgerAppliedEvent {
|
||||
pub user_id: String,
|
||||
pub chapter_id: String,
|
||||
pub granted_quest_xp: u32,
|
||||
pub granted_hostile_xp: u32,
|
||||
pub hostile_defeat_increment: u32,
|
||||
pub occurred_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ChapterAutoLevelProfileResolvedEvent {
|
||||
pub profile: RuntimeEntityLevelProfile,
|
||||
pub occurred_at_micros: i64,
|
||||
}
|
||||
|
||||
@@ -4,625 +4,11 @@ mod domain;
|
||||
mod errors;
|
||||
mod events;
|
||||
|
||||
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 {}
|
||||
pub use application::*;
|
||||
pub use commands::*;
|
||||
pub use domain::*;
|
||||
pub use errors::*;
|
||||
pub use events::*;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
Reference in New Issue
Block a user