Close DDD cleanup and tests-support closure
This commit is contained in:
4
server-rs/Cargo.lock
generated
4
server-rs/Cargo.lock
generated
@@ -3066,6 +3066,10 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tests-support"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
|
||||
@@ -31,6 +31,7 @@ members = [
|
||||
"crates/shared-logging",
|
||||
"crates/spacetime-client",
|
||||
"crates/spacetime-module",
|
||||
"crates/tests-support",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
22. 创建 `crates/platform-oss/` 目录占位,固定 OSS 平台适配 crate 落位。
|
||||
23. 创建 `crates/platform-llm/` 目录占位,固定大模型平台适配 crate 落位。
|
||||
24. 创建 `crates/spacetime-client/` 目录占位,固定 SpacetimeDB 客户端适配 crate 落位。
|
||||
25. 创建 `crates/tests-support/` 目录占位,固定测试支撑共享 crate 落位。
|
||||
25. 创建 `crates/tests-support/` 共享测试支撑 crate,固定 smoke/contract 测试辅助能力落位。
|
||||
26. 创建 `scripts/dev.ps1`,固定 Windows 本地开发入口。
|
||||
27. 创建 `scripts/dev.sh`,固定 Unix-like 本地开发入口。
|
||||
28. 创建 `scripts/test.ps1`,固定 Windows 本地测试入口。
|
||||
|
||||
@@ -227,7 +227,7 @@ impl AppState {
|
||||
self.spacetime_client
|
||||
.upsert_auth_store_snapshot(snapshot_json, updated_at_micros)
|
||||
.await?;
|
||||
// ?????????????????????????????????
|
||||
// 写入 SpacetimeDB 后立刻回读一次,确保内存快照与表真相对齐。
|
||||
#[cfg(not(test))]
|
||||
self.spacetime_client.import_auth_store_snapshot().await?;
|
||||
#[cfg(not(test))]
|
||||
@@ -252,13 +252,13 @@ impl AppState {
|
||||
if !snapshot_json.trim().is_empty() {
|
||||
let auth_store = InMemoryAuthStore::from_snapshot_json(&snapshot_json)
|
||||
.map_err(AppStateInitError::AuthStore)?;
|
||||
info!("?? SpacetimeDB ???????????");
|
||||
info!("已从 SpacetimeDB 表恢复认证快照");
|
||||
return Self::new_with_auth_store(config, auth_store);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(error = %error, "? SpacetimeDB ????????????????");
|
||||
warn!(error = %error, "从 SpacetimeDB 表恢复认证快照失败");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,13 +268,13 @@ impl AppState {
|
||||
if !snapshot_json.trim().is_empty() {
|
||||
let auth_store = InMemoryAuthStore::from_snapshot_json(&snapshot_json)
|
||||
.map_err(AppStateInitError::AuthStore)?;
|
||||
info!("?? SpacetimeDB ???????????");
|
||||
info!("已从 SpacetimeDB 快照记录恢复认证快照");
|
||||
return Self::new_with_auth_store(config, auth_store);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(error = %error, "? SpacetimeDB ?????????????????");
|
||||
warn!(error = %error, "从 SpacetimeDB 快照记录恢复认证快照失败");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 资产领域事件过渡落位。
|
||||
//! 资产领域事件。
|
||||
//!
|
||||
//! 用于表达资产已确认、绑定已变更和资产历史投影待刷新等事实。
|
||||
//! 当前阶段暂不新增事件类型,避免在 SpacetimeDB 表未补 event table 前扩散未消费 API。
|
||||
|
||||
34
server-rs/crates/module-big-fish/README.md
Normal file
34
server-rs/crates/module-big-fish/README.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# module-big-fish 独立模块 package 说明
|
||||
|
||||
日期:`2026-04-30`
|
||||
|
||||
## 1. package 职责
|
||||
|
||||
`module-big-fish` 是大鱼吃小鱼创作与运行态规则模块 package,负责:
|
||||
|
||||
1. 创作会话、锚点包、草稿、资产槽和作品摘要的纯领域类型。
|
||||
2. 草稿编译、资产覆盖、发布门禁、字段校验和序列化规则。
|
||||
3. Big Fish 运行态一局的服务端真相源规则。
|
||||
4. 为 `spacetime-module`、`spacetime-client` 和 `api-server` 提供稳定领域边界。
|
||||
|
||||
## 2. 当前阶段说明
|
||||
|
||||
当前 DDD 物理拆分已经收口:
|
||||
|
||||
1. `src/domain.rs` 承接创作阶段、锚点、资产槽、草稿、会话、作品摘要、发布门禁和运行态领域类型。
|
||||
2. `src/commands.rs` 承接会话、消息、草稿、资产、发布、游玩记录和运行态输入 DTO。
|
||||
3. `src/application.rs` 承接锚点推断、默认草稿编译、资产覆盖、资产槽构造、字段校验、序列化与运行态真相源规则。
|
||||
4. `src/errors.rs` 承接应用错误、字段错误和中文错误文案。
|
||||
5. `src/events.rs` 承接发布门禁和运行态领域事件。
|
||||
6. `src/lib.rs` 只保留模块声明、公开导出和测试,继续保持 `module_big_fish::*` 公开 API。
|
||||
|
||||
当前设计依据:
|
||||
|
||||
1. [../../../docs/technical/SERVER_RS_DDD_WP_BF_RUNTIME_BACKEND_TRUTH_2026-04-29.md](../../../docs/technical/SERVER_RS_DDD_WP_BF_RUNTIME_BACKEND_TRUTH_2026-04-29.md)
|
||||
2. [../../../docs/technical/SERVER_RS_DDD_WP_BF_AND_G2_DRIFT_CLEANUP_2026-04-30.md](../../../docs/technical/SERVER_RS_DDD_WP_BF_AND_G2_DRIFT_CLEANUP_2026-04-30.md)
|
||||
|
||||
## 3. 边界约束
|
||||
|
||||
1. `module-big-fish` 不直接调用图片生成、OSS、HTTP、SSE 或 SpacetimeDB SDK。
|
||||
2. 领域函数只处理纯规则和可序列化领域事实。
|
||||
3. 表、procedure、route、前端 client 和绑定 shape 由外层 adapter 承接。
|
||||
@@ -1,20 +1,27 @@
|
||||
//! 大鱼吃小鱼应用编排过渡落位。
|
||||
//! 大鱼吃小鱼应用编排。
|
||||
//!
|
||||
//! 这里只组合领域规则并返回结果或事件,不直接调用外部图片、视频或存储服务。
|
||||
|
||||
use shared_kernel::normalize_required_string;
|
||||
|
||||
use crate::{
|
||||
BIG_FISH_DEFAULT_LEVEL_COUNT, BIG_FISH_TARGET_WILD_COUNT, BigFishAssetSlotSnapshot,
|
||||
build_asset_coverage,
|
||||
commands::{
|
||||
EvaluateBigFishPublishReadinessCommand, StartBigFishRunCommand, SubmitBigFishInputCommand,
|
||||
BigFishAssetGenerateInput, BigFishDraftCompileInput, BigFishInputSubmitInput,
|
||||
BigFishMessageFinalizeInput, BigFishMessageSubmitInput, BigFishPlayRecordInput,
|
||||
BigFishPublishInput, BigFishRunGetInput, BigFishRunStartInput, BigFishSessionCreateInput,
|
||||
BigFishSessionGetInput, BigFishWorksListInput, EvaluateBigFishPublishReadinessCommand,
|
||||
StartBigFishRunCommand, SubmitBigFishInputCommand,
|
||||
},
|
||||
domain::{
|
||||
BIG_FISH_ASSET_SLOT_ID_PREFIX, BIG_FISH_DEFAULT_LEVEL_COUNT,
|
||||
BIG_FISH_MERGE_COUNT_PER_UPGRADE, BIG_FISH_OFFSCREEN_CULL_SECONDS,
|
||||
BIG_FISH_TARGET_WILD_COUNT, BigFishAnchorItem, BigFishAnchorPack, BigFishAnchorStatus,
|
||||
BigFishAssetCoverage, BigFishAssetKind, BigFishAssetSlotSnapshot, BigFishAssetStatus,
|
||||
BigFishBackgroundBlueprint, BigFishGameDraft, BigFishLevelBlueprint,
|
||||
BigFishPublishReadiness, BigFishRunStatus, BigFishRuntimeEntitySnapshot,
|
||||
BigFishRuntimeSnapshot, BigFishVector2,
|
||||
BigFishRuntimeParams, BigFishRuntimeSnapshot, BigFishVector2,
|
||||
},
|
||||
errors::BigFishApplicationError,
|
||||
errors::{BigFishApplicationError, BigFishFieldError},
|
||||
events::BigFishDomainEvent,
|
||||
};
|
||||
|
||||
@@ -578,6 +585,515 @@ fn settlement_events(
|
||||
}]
|
||||
}
|
||||
|
||||
pub fn empty_anchor_pack() -> BigFishAnchorPack {
|
||||
BigFishAnchorPack {
|
||||
gameplay_promise: BigFishAnchorItem {
|
||||
key: "gameplayPromise".to_string(),
|
||||
label: "玩法承诺".to_string(),
|
||||
value: String::new(),
|
||||
status: BigFishAnchorStatus::Missing,
|
||||
},
|
||||
ecology_visual_theme: BigFishAnchorItem {
|
||||
key: "ecologyVisualTheme".to_string(),
|
||||
label: "生态与视觉母题".to_string(),
|
||||
value: String::new(),
|
||||
status: BigFishAnchorStatus::Missing,
|
||||
},
|
||||
growth_ladder: BigFishAnchorItem {
|
||||
key: "growthLadder".to_string(),
|
||||
label: "成长阶梯".to_string(),
|
||||
value: String::new(),
|
||||
status: BigFishAnchorStatus::Missing,
|
||||
},
|
||||
risk_tempo: BigFishAnchorItem {
|
||||
key: "riskTempo".to_string(),
|
||||
label: "风险节奏".to_string(),
|
||||
value: "平衡".to_string(),
|
||||
status: BigFishAnchorStatus::Inferred,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn infer_anchor_pack(seed_text: &str, latest_message: Option<&str>) -> BigFishAnchorPack {
|
||||
let source = normalize_required_string(latest_message.unwrap_or(seed_text))
|
||||
.or_else(|| normalize_required_string(seed_text))
|
||||
.unwrap_or_else(|| "深海弱小逆袭,逐级吞噬成长".to_string());
|
||||
let mut pack = empty_anchor_pack();
|
||||
pack.gameplay_promise.value = if source.contains("可爱") {
|
||||
"可爱生态成长".to_string()
|
||||
} else if source.contains("机械") {
|
||||
"机械微生物吞并进化".to_string()
|
||||
} else {
|
||||
"弱小逆袭和群体吞并".to_string()
|
||||
};
|
||||
pack.gameplay_promise.status = BigFishAnchorStatus::Inferred;
|
||||
pack.ecology_visual_theme.value = if source.contains("机械") {
|
||||
"机械微生物水域".to_string()
|
||||
} else if source.contains("梦") {
|
||||
"梦境纸鱼生态".to_string()
|
||||
} else {
|
||||
"深海生物生态".to_string()
|
||||
};
|
||||
pack.ecology_visual_theme.status = BigFishAnchorStatus::Inferred;
|
||||
pack.growth_ladder.value = "8 级连续进化,从幼小个体成长为终局巨兽".to_string();
|
||||
pack.growth_ladder.status = BigFishAnchorStatus::Inferred;
|
||||
pack.risk_tempo.value = if source.contains("爽") {
|
||||
"偏爽快".to_string()
|
||||
} else if source.contains("压迫") {
|
||||
"偏压迫".to_string()
|
||||
} else {
|
||||
"平衡".to_string()
|
||||
};
|
||||
pack
|
||||
}
|
||||
|
||||
pub fn compile_default_draft(anchor_pack: &BigFishAnchorPack) -> BigFishGameDraft {
|
||||
let level_count = BIG_FISH_DEFAULT_LEVEL_COUNT;
|
||||
let theme = fallback_anchor_value(&anchor_pack.ecology_visual_theme, "深海生物生态");
|
||||
let core_fun = fallback_anchor_value(&anchor_pack.gameplay_promise, "弱小逆袭和群体吞并");
|
||||
let risk_tempo = fallback_anchor_value(&anchor_pack.risk_tempo, "平衡");
|
||||
|
||||
let levels = (1..=level_count)
|
||||
.map(|level| build_level_blueprint(level, level_count, &theme))
|
||||
.collect();
|
||||
|
||||
BigFishGameDraft {
|
||||
title: format!("{theme} 大鱼吃小鱼"),
|
||||
subtitle: format!("{core_fun} · {risk_tempo}节奏"),
|
||||
core_fun,
|
||||
ecology_theme: theme.clone(),
|
||||
levels,
|
||||
background: BigFishBackgroundBlueprint {
|
||||
theme: theme.clone(),
|
||||
color_mood: "深蓝、青绿、带少量暖色生物光".to_string(),
|
||||
foreground_hints: "只保留少量漂浮颗粒和边缘水草,不遮挡中央操作区".to_string(),
|
||||
midground_composition: "中央留出大面积清晰活动区域,边缘只做出生缓冲层".to_string(),
|
||||
background_depth: "简洁纵深水域与极少量远处剪影".to_string(),
|
||||
safe_play_area_hint: "9:16 竖屏中央 80% 为主要活动区".to_string(),
|
||||
spawn_edge_hint: "四周边缘以少量暗礁或水草提示野生实体出生区".to_string(),
|
||||
background_prompt_seed: format!(
|
||||
"{theme},竖屏 9:16,全屏大场地游戏背景,元素少,中央开阔,无文字,无 UI 框"
|
||||
),
|
||||
},
|
||||
runtime_params: BigFishRuntimeParams {
|
||||
level_count,
|
||||
merge_count_per_upgrade: BIG_FISH_MERGE_COUNT_PER_UPGRADE,
|
||||
spawn_target_count: BIG_FISH_TARGET_WILD_COUNT as u32,
|
||||
leader_move_speed: 160.0,
|
||||
follower_catch_up_speed: 120.0,
|
||||
offscreen_cull_seconds: BIG_FISH_OFFSCREEN_CULL_SECONDS,
|
||||
prey_spawn_delta_levels: vec![1, 2],
|
||||
threat_spawn_delta_levels: vec![1, 2],
|
||||
win_level: level_count,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_asset_coverage(
|
||||
draft: Option<&BigFishGameDraft>,
|
||||
asset_slots: &[BigFishAssetSlotSnapshot],
|
||||
) -> BigFishAssetCoverage {
|
||||
let required_level_count = draft
|
||||
.map(|value| value.runtime_params.level_count)
|
||||
.unwrap_or(BIG_FISH_DEFAULT_LEVEL_COUNT);
|
||||
let main_ready = asset_slots
|
||||
.iter()
|
||||
.filter(|slot| {
|
||||
slot.asset_kind == BigFishAssetKind::LevelMainImage
|
||||
&& slot.status == BigFishAssetStatus::Ready
|
||||
})
|
||||
.count() as u32;
|
||||
let motion_ready = asset_slots
|
||||
.iter()
|
||||
.filter(|slot| {
|
||||
slot.asset_kind == BigFishAssetKind::LevelMotion
|
||||
&& slot.status == BigFishAssetStatus::Ready
|
||||
})
|
||||
.count() as u32;
|
||||
let background_ready = asset_slots.iter().any(|slot| {
|
||||
slot.asset_kind == BigFishAssetKind::StageBackground
|
||||
&& slot.status == BigFishAssetStatus::Ready
|
||||
});
|
||||
|
||||
let required_motion_count = required_level_count * 2;
|
||||
let mut blockers = Vec::new();
|
||||
if draft.is_none() {
|
||||
blockers.push("玩法草稿尚未编译".to_string());
|
||||
}
|
||||
if main_ready < required_level_count {
|
||||
blockers.push(format!(
|
||||
"还缺少 {} 个等级主图",
|
||||
required_level_count.saturating_sub(main_ready)
|
||||
));
|
||||
}
|
||||
if motion_ready < required_motion_count {
|
||||
blockers.push(format!(
|
||||
"还缺少 {} 个基础动作",
|
||||
required_motion_count.saturating_sub(motion_ready)
|
||||
));
|
||||
}
|
||||
if !background_ready {
|
||||
blockers.push("还缺少活动区域背景图".to_string());
|
||||
}
|
||||
|
||||
BigFishAssetCoverage {
|
||||
level_main_image_ready_count: main_ready,
|
||||
level_motion_ready_count: motion_ready,
|
||||
background_ready,
|
||||
required_level_count,
|
||||
publish_ready: blockers.is_empty(),
|
||||
blockers,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_generated_asset_slot(
|
||||
session_id: &str,
|
||||
draft: &BigFishGameDraft,
|
||||
asset_kind: BigFishAssetKind,
|
||||
level: Option<u32>,
|
||||
motion_key: Option<String>,
|
||||
asset_url: Option<String>,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<BigFishAssetSlotSnapshot, BigFishFieldError> {
|
||||
let session_id =
|
||||
normalize_required_string(session_id).ok_or(BigFishFieldError::MissingSessionId)?;
|
||||
let prompt_snapshot =
|
||||
build_asset_prompt_snapshot(draft, asset_kind, level, motion_key.as_deref())?;
|
||||
let slot_id = build_asset_slot_id(&session_id, asset_kind, level, motion_key.as_deref());
|
||||
let resolved_asset_url = normalize_required_string(asset_url.as_deref().unwrap_or_default())
|
||||
.unwrap_or_else(|| build_placeholder_asset_url(asset_kind, level, updated_at_micros));
|
||||
|
||||
Ok(BigFishAssetSlotSnapshot {
|
||||
slot_id,
|
||||
session_id,
|
||||
asset_kind,
|
||||
level,
|
||||
motion_key,
|
||||
status: BigFishAssetStatus::Ready,
|
||||
asset_url: Some(resolved_asset_url),
|
||||
prompt_snapshot,
|
||||
updated_at_micros,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate_session_get_input(input: &BigFishSessionGetInput) -> Result<(), BigFishFieldError> {
|
||||
validate_session_owner(&input.session_id, &input.owner_user_id)
|
||||
}
|
||||
|
||||
pub fn validate_works_list_input(input: &BigFishWorksListInput) -> Result<(), BigFishFieldError> {
|
||||
if input.published_only {
|
||||
return Ok(());
|
||||
}
|
||||
if normalize_required_string(&input.owner_user_id).is_none() {
|
||||
return Err(BigFishFieldError::MissingOwnerUserId);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_session_create_input(
|
||||
input: &BigFishSessionCreateInput,
|
||||
) -> Result<(), BigFishFieldError> {
|
||||
validate_session_owner(&input.session_id, &input.owner_user_id)?;
|
||||
if normalize_required_string(&input.welcome_message_id).is_none() {
|
||||
return Err(BigFishFieldError::MissingMessageId);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_message_submit_input(
|
||||
input: &BigFishMessageSubmitInput,
|
||||
) -> Result<(), BigFishFieldError> {
|
||||
validate_session_owner(&input.session_id, &input.owner_user_id)?;
|
||||
if normalize_required_string(&input.user_message_id).is_none()
|
||||
|| normalize_required_string(&input.assistant_message_id).is_none()
|
||||
{
|
||||
return Err(BigFishFieldError::MissingMessageId);
|
||||
}
|
||||
if normalize_required_string(&input.user_message_text).is_none() {
|
||||
return Err(BigFishFieldError::MissingMessageText);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_message_finalize_input(
|
||||
input: &BigFishMessageFinalizeInput,
|
||||
) -> Result<(), BigFishFieldError> {
|
||||
validate_session_owner(&input.session_id, &input.owner_user_id)
|
||||
}
|
||||
|
||||
pub fn validate_draft_compile_input(
|
||||
input: &BigFishDraftCompileInput,
|
||||
) -> Result<(), BigFishFieldError> {
|
||||
validate_session_owner(&input.session_id, &input.owner_user_id)
|
||||
}
|
||||
|
||||
pub fn validate_asset_generate_input(
|
||||
input: &BigFishAssetGenerateInput,
|
||||
draft: &BigFishGameDraft,
|
||||
) -> Result<(), BigFishFieldError> {
|
||||
validate_session_owner(&input.session_id, &input.owner_user_id)?;
|
||||
match input.asset_kind {
|
||||
BigFishAssetKind::LevelMainImage => validate_level(input.level, draft),
|
||||
BigFishAssetKind::LevelMotion => {
|
||||
validate_level(input.level, draft)?;
|
||||
match input.motion_key.as_deref() {
|
||||
Some("idle_float" | "move_swim") => Ok(()),
|
||||
_ => Err(BigFishFieldError::InvalidAssetKind),
|
||||
}
|
||||
}
|
||||
BigFishAssetKind::StageBackground => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_publish_input(input: &BigFishPublishInput) -> Result<(), BigFishFieldError> {
|
||||
validate_session_owner(&input.session_id, &input.owner_user_id)
|
||||
}
|
||||
|
||||
pub fn validate_play_record_input(input: &BigFishPlayRecordInput) -> Result<(), BigFishFieldError> {
|
||||
if normalize_required_string(&input.session_id).is_none() {
|
||||
return Err(BigFishFieldError::MissingSessionId);
|
||||
}
|
||||
if normalize_required_string(&input.user_id).is_none() {
|
||||
return Err(BigFishFieldError::MissingOwnerUserId);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_run_start_input(input: &BigFishRunStartInput) -> Result<(), BigFishFieldError> {
|
||||
validate_session_owner(&input.session_id, &input.owner_user_id)?;
|
||||
if normalize_required_string(&input.run_id).is_none() {
|
||||
return Err(BigFishFieldError::MissingRunId);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_run_get_input(input: &BigFishRunGetInput) -> Result<(), BigFishFieldError> {
|
||||
if normalize_required_string(&input.run_id).is_none() {
|
||||
return Err(BigFishFieldError::MissingRunId);
|
||||
}
|
||||
if normalize_required_string(&input.owner_user_id).is_none() {
|
||||
return Err(BigFishFieldError::MissingOwnerUserId);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_input_submit_input(
|
||||
input: &BigFishInputSubmitInput,
|
||||
) -> Result<(), BigFishFieldError> {
|
||||
if normalize_required_string(&input.run_id).is_none() {
|
||||
return Err(BigFishFieldError::MissingRunId);
|
||||
}
|
||||
if normalize_required_string(&input.owner_user_id).is_none() {
|
||||
return Err(BigFishFieldError::MissingOwnerUserId);
|
||||
}
|
||||
if !input.x.is_finite() || !input.y.is_finite() {
|
||||
return Err(BigFishFieldError::InvalidRuntimeInput);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn serialize_anchor_pack(anchor_pack: &BigFishAnchorPack) -> Result<String, serde_json::Error> {
|
||||
serde_json::to_string(anchor_pack)
|
||||
}
|
||||
|
||||
pub fn deserialize_anchor_pack(value: &str) -> Result<BigFishAnchorPack, serde_json::Error> {
|
||||
serde_json::from_str(value)
|
||||
}
|
||||
|
||||
pub fn serialize_draft(draft: &BigFishGameDraft) -> Result<String, serde_json::Error> {
|
||||
serde_json::to_string(draft)
|
||||
}
|
||||
|
||||
pub fn deserialize_draft(value: &str) -> Result<BigFishGameDraft, serde_json::Error> {
|
||||
serde_json::from_str(value)
|
||||
}
|
||||
|
||||
pub fn serialize_asset_coverage(
|
||||
coverage: &BigFishAssetCoverage,
|
||||
) -> Result<String, serde_json::Error> {
|
||||
serde_json::to_string(coverage)
|
||||
}
|
||||
|
||||
pub fn deserialize_asset_coverage(value: &str) -> Result<BigFishAssetCoverage, serde_json::Error> {
|
||||
serde_json::from_str(value)
|
||||
}
|
||||
|
||||
fn fallback_anchor_value(anchor: &BigFishAnchorItem, fallback: &str) -> String {
|
||||
normalize_required_string(&anchor.value).unwrap_or_else(|| fallback.to_string())
|
||||
}
|
||||
|
||||
fn build_level_blueprint(level: u32, level_count: u32, theme: &str) -> BigFishLevelBlueprint {
|
||||
let prey_window = (1..level)
|
||||
.rev()
|
||||
.take(2)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect();
|
||||
let threat_window = ((level + 1)..=(level + 2).min(level_count)).collect::<Vec<_>>();
|
||||
let size_ratio = 1.0 + (level.saturating_sub(1) as f32 * 0.22);
|
||||
let name = format!("{theme} L{level}");
|
||||
let one_line_fantasy = if level == level_count {
|
||||
"终局巨兽形态,获得即可通关".to_string()
|
||||
} else {
|
||||
format!("第 {level} 阶实体,继续吞噬同级和低级个体成长")
|
||||
};
|
||||
let text_description = if level == 1 {
|
||||
format!(
|
||||
"{name} 是这套 {theme} 等级阶梯的起点个体,体型最小、动作轻盈,会在谨慎试探中寻找第一个可吞噬目标。"
|
||||
)
|
||||
} else if level == level_count {
|
||||
format!(
|
||||
"{name} 是这套 {theme} 生态中的终局霸主形态,体格巨大、压迫感最强,一旦成型就代表本局成长链已经完成。"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{name} 是 {theme} 生态里的第 {level} 阶进化体,已经具备更鲜明的轮廓、猎食性和压迫感,会继续通过吞并同级与低级实体向上跃迁。"
|
||||
)
|
||||
};
|
||||
let visual_description = if level == 1 {
|
||||
format!(
|
||||
"{theme} 风格的小型初始鱼形生物,体态轻巧,轮廓圆润,局部带少量发光纹路或主题特征,明显呈现弱小但灵动的开局形象。"
|
||||
)
|
||||
} else if level == level_count {
|
||||
format!(
|
||||
"{theme} 风格的终局巨型鱼形霸主,体长与鳍面明显扩张,轮廓锋利或威严,层次细节最丰富,拥有一眼可辨识的终局统治感。"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{theme} 风格的第 {level} 级进化鱼形生物,相比上一阶段更大、更强、更成熟,身体主轮廓更清晰,局部装饰、鳍面结构和主题特征都更明显。"
|
||||
)
|
||||
};
|
||||
let idle_motion_description = if level == level_count {
|
||||
"待机时缓慢悬停,身体主体保持稳定,尾鳍与侧鳍做低频摆动,呈现强者从容压场的漂浮感。"
|
||||
.to_string()
|
||||
} else {
|
||||
format!(
|
||||
"待机时保持轻微漂浮与呼吸感摆动,尾鳍和侧鳍以小幅度节奏晃动,体现 Lv.{level} 生物在水中蓄势观察的状态。"
|
||||
)
|
||||
};
|
||||
let move_motion_description = if level == level_count {
|
||||
"移动时身体前倾,尾鳍和背鳍形成强力推进姿态,带出稳定而有压迫感的高速巡游动势。".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"移动时身体向前游动,尾鳍形成清晰摆尾推进,整体节奏比待机更主动,体现 Lv.{level} 生物追逐猎物时的连续游动感。"
|
||||
)
|
||||
};
|
||||
BigFishLevelBlueprint {
|
||||
level,
|
||||
name,
|
||||
one_line_fantasy,
|
||||
text_description,
|
||||
silhouette_direction: format!(
|
||||
"体型约为初始的 {:.1} 倍,轮廓更清晰",
|
||||
1.0 + level as f32 * 0.22
|
||||
),
|
||||
size_ratio,
|
||||
visual_description: visual_description.clone(),
|
||||
visual_prompt_seed: format!(
|
||||
"{visual_description} 透明背景,单体完整入镜,适合作为竖屏吞噬成长玩法的等级主图。"
|
||||
),
|
||||
idle_motion_description: idle_motion_description.clone(),
|
||||
move_motion_description: move_motion_description.clone(),
|
||||
motion_prompt_seed: format!(
|
||||
"待机动作:{idle_motion_description} 移动动作:{move_motion_description}"
|
||||
),
|
||||
merge_source_level: if level == 1 { None } else { Some(level - 1) },
|
||||
prey_window,
|
||||
threat_window,
|
||||
is_final_level: level == level_count,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_asset_prompt_snapshot(
|
||||
draft: &BigFishGameDraft,
|
||||
asset_kind: BigFishAssetKind,
|
||||
level: Option<u32>,
|
||||
motion_key: Option<&str>,
|
||||
) -> Result<String, BigFishFieldError> {
|
||||
match asset_kind {
|
||||
BigFishAssetKind::LevelMainImage => {
|
||||
let level = level.ok_or(BigFishFieldError::InvalidLevel)?;
|
||||
let blueprint = draft
|
||||
.levels
|
||||
.iter()
|
||||
.find(|item| item.level == level)
|
||||
.ok_or(BigFishFieldError::InvalidLevel)?;
|
||||
Ok(blueprint.visual_prompt_seed.clone())
|
||||
}
|
||||
BigFishAssetKind::LevelMotion => {
|
||||
let level = level.ok_or(BigFishFieldError::InvalidLevel)?;
|
||||
let blueprint = draft
|
||||
.levels
|
||||
.iter()
|
||||
.find(|item| item.level == level)
|
||||
.ok_or(BigFishFieldError::InvalidLevel)?;
|
||||
let motion_key = motion_key.ok_or(BigFishFieldError::InvalidAssetKind)?;
|
||||
let motion_description = match motion_key {
|
||||
"idle_float" => blueprint.idle_motion_description.as_str(),
|
||||
"move_swim" => blueprint.move_motion_description.as_str(),
|
||||
_ => return Err(BigFishFieldError::InvalidAssetKind),
|
||||
};
|
||||
Ok(format!(
|
||||
"{} 动作位:{}。{} 透明背景,单体完整入镜。",
|
||||
blueprint.motion_prompt_seed, motion_key, motion_description
|
||||
))
|
||||
}
|
||||
BigFishAssetKind::StageBackground => Ok(draft.background.background_prompt_seed.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_asset_slot_id(
|
||||
session_id: &str,
|
||||
asset_kind: BigFishAssetKind,
|
||||
level: Option<u32>,
|
||||
motion_key: Option<&str>,
|
||||
) -> String {
|
||||
let level_part = level
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_else(|| "stage".to_string());
|
||||
let motion_part = motion_key.unwrap_or("main");
|
||||
format!(
|
||||
"{BIG_FISH_ASSET_SLOT_ID_PREFIX}{session_id}_{}_{}_{}",
|
||||
asset_kind.as_str(),
|
||||
level_part,
|
||||
motion_part
|
||||
)
|
||||
}
|
||||
|
||||
fn build_placeholder_asset_url(
|
||||
asset_kind: BigFishAssetKind,
|
||||
level: Option<u32>,
|
||||
seed_micros: i64,
|
||||
) -> String {
|
||||
let level_part = level
|
||||
.map(|value| format!("level-{value}"))
|
||||
.unwrap_or_else(|| "stage".to_string());
|
||||
format!(
|
||||
"/generated-big-fish/{}/{}/{}.png",
|
||||
asset_kind.as_str(),
|
||||
level_part,
|
||||
seed_micros
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_session_owner(session_id: &str, owner_user_id: &str) -> Result<(), BigFishFieldError> {
|
||||
if normalize_required_string(session_id).is_none() {
|
||||
return Err(BigFishFieldError::MissingSessionId);
|
||||
}
|
||||
if normalize_required_string(owner_user_id).is_none() {
|
||||
return Err(BigFishFieldError::MissingOwnerUserId);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_level(level: Option<u32>, draft: &BigFishGameDraft) -> Result<(), BigFishFieldError> {
|
||||
match level {
|
||||
Some(value) if (1..=draft.runtime_params.level_count).contains(&value) => Ok(()),
|
||||
_ => Err(BigFishFieldError::InvalidLevel),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
//! 大鱼吃小鱼写入命令过渡落位。
|
||||
//! 大鱼吃小鱼写入命令。
|
||||
//!
|
||||
//! 用于表达创建会话、写入消息、更新资产槽和推进运行态等输入。
|
||||
|
||||
use crate::{BigFishGameDraft, domain::BigFishRuntimeSnapshot};
|
||||
use crate::domain::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[cfg(feature = "spacetime-types")]
|
||||
use spacetimedb::SpacetimeType;
|
||||
|
||||
/// 评估作品是否可以发布的纯领域命令。
|
||||
///
|
||||
@@ -36,3 +39,140 @@ pub struct SubmitBigFishInputCommand {
|
||||
pub submitted_at_micros: i64,
|
||||
pub current_snapshot: BigFishRuntimeSnapshot,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishWorksListInput {
|
||||
pub owner_user_id: String,
|
||||
pub published_only: bool,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishWorkDeleteInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishWorksProcedureResult {
|
||||
pub ok: bool,
|
||||
pub items_json: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishSessionCreateInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub seed_text: String,
|
||||
pub welcome_message_id: String,
|
||||
pub welcome_message_text: String,
|
||||
pub created_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishSessionGetInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishMessageSubmitInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub user_message_id: String,
|
||||
pub user_message_text: String,
|
||||
pub assistant_message_id: String,
|
||||
pub submitted_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishMessageFinalizeInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub assistant_message_id: Option<String>,
|
||||
pub assistant_reply_text: Option<String>,
|
||||
pub stage: BigFishCreationStage,
|
||||
pub progress_percent: u32,
|
||||
pub anchor_pack_json: String,
|
||||
pub error_message: Option<String>,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishDraftCompileInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub draft_json: Option<String>,
|
||||
pub compiled_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishAssetGenerateInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub asset_kind: BigFishAssetKind,
|
||||
pub level: Option<u32>,
|
||||
pub motion_key: Option<String>,
|
||||
pub asset_url: Option<String>,
|
||||
pub generated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishPublishInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub published_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishPlayRecordInput {
|
||||
pub session_id: String,
|
||||
pub user_id: String,
|
||||
pub elapsed_ms: u64,
|
||||
pub played_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishRunStartInput {
|
||||
pub run_id: String,
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub started_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishRunGetInput {
|
||||
pub run_id: String,
|
||||
pub owner_user_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BigFishInputSubmitInput {
|
||||
pub run_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub submitted_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishRunProcedureResult {
|
||||
pub ok: bool,
|
||||
pub run_json: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
@@ -1,12 +1,234 @@
|
||||
//! 大鱼吃小鱼领域模型过渡落位。
|
||||
//! 大鱼吃小鱼领域模型。
|
||||
//!
|
||||
//! 后续迁移创作会话、资产槽和运行态聚合时,只保留玩法状态与规则;
|
||||
//! 图片生成、OSS 与 HTTP handler 均留在 adapter 层。
|
||||
//! 保留创作会话、资产槽、发布门禁和运行态聚合的纯领域结构;图片生成、OSS 与 HTTP handler 均留在 adapter 层。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[cfg(feature = "spacetime-types")]
|
||||
use spacetimedb::SpacetimeType;
|
||||
|
||||
pub const BIG_FISH_SESSION_ID_PREFIX: &str = "big-fish-session-";
|
||||
pub const BIG_FISH_MESSAGE_ID_PREFIX: &str = "big-fish-message-";
|
||||
pub const BIG_FISH_OPERATION_ID_PREFIX: &str = "big-fish-operation-";
|
||||
pub const BIG_FISH_ASSET_SLOT_ID_PREFIX: &str = "big-fish-asset-";
|
||||
pub const BIG_FISH_DEFAULT_LEVEL_COUNT: u32 = 8;
|
||||
pub const BIG_FISH_MIN_LEVEL_COUNT: u32 = 6;
|
||||
pub const BIG_FISH_MAX_LEVEL_COUNT: u32 = 12;
|
||||
pub const BIG_FISH_MERGE_COUNT_PER_UPGRADE: u32 = 3;
|
||||
pub const BIG_FISH_OFFSCREEN_CULL_SECONDS: f32 = 3.0;
|
||||
pub const BIG_FISH_TARGET_WILD_COUNT: usize = 12;
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum BigFishCreationStage {
|
||||
CollectingAnchors,
|
||||
DraftReady,
|
||||
AssetRefining,
|
||||
ReadyToPublish,
|
||||
Published,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum BigFishAnchorStatus {
|
||||
Confirmed,
|
||||
Inferred,
|
||||
Missing,
|
||||
Locked,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum BigFishAgentMessageRole {
|
||||
User,
|
||||
Assistant,
|
||||
System,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum BigFishAgentMessageKind {
|
||||
Chat,
|
||||
Summary,
|
||||
ActionResult,
|
||||
Warning,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum BigFishAssetKind {
|
||||
LevelMainImage,
|
||||
LevelMotion,
|
||||
StageBackground,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum BigFishAssetStatus {
|
||||
Missing,
|
||||
Ready,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishAnchorItem {
|
||||
pub key: String,
|
||||
pub label: String,
|
||||
pub value: String,
|
||||
pub status: BigFishAnchorStatus,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishAnchorPack {
|
||||
pub gameplay_promise: BigFishAnchorItem,
|
||||
pub ecology_visual_theme: BigFishAnchorItem,
|
||||
pub growth_ladder: BigFishAnchorItem,
|
||||
pub risk_tempo: BigFishAnchorItem,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BigFishLevelBlueprint {
|
||||
pub level: u32,
|
||||
pub name: String,
|
||||
pub one_line_fantasy: String,
|
||||
pub text_description: String,
|
||||
pub silhouette_direction: String,
|
||||
pub size_ratio: f32,
|
||||
pub visual_description: String,
|
||||
pub visual_prompt_seed: String,
|
||||
pub idle_motion_description: String,
|
||||
pub move_motion_description: String,
|
||||
pub motion_prompt_seed: String,
|
||||
pub merge_source_level: Option<u32>,
|
||||
pub prey_window: Vec<u32>,
|
||||
pub threat_window: Vec<u32>,
|
||||
pub is_final_level: bool,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishBackgroundBlueprint {
|
||||
pub theme: String,
|
||||
pub color_mood: String,
|
||||
pub foreground_hints: String,
|
||||
pub midground_composition: String,
|
||||
pub background_depth: String,
|
||||
pub safe_play_area_hint: String,
|
||||
pub spawn_edge_hint: String,
|
||||
pub background_prompt_seed: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BigFishRuntimeParams {
|
||||
pub level_count: u32,
|
||||
pub merge_count_per_upgrade: u32,
|
||||
pub spawn_target_count: u32,
|
||||
pub leader_move_speed: f32,
|
||||
pub follower_catch_up_speed: f32,
|
||||
pub offscreen_cull_seconds: f32,
|
||||
pub prey_spawn_delta_levels: Vec<u32>,
|
||||
pub threat_spawn_delta_levels: Vec<u32>,
|
||||
pub win_level: u32,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BigFishGameDraft {
|
||||
pub title: String,
|
||||
pub subtitle: String,
|
||||
pub core_fun: String,
|
||||
pub ecology_theme: String,
|
||||
pub levels: Vec<BigFishLevelBlueprint>,
|
||||
pub background: BigFishBackgroundBlueprint,
|
||||
pub runtime_params: BigFishRuntimeParams,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishAgentMessageSnapshot {
|
||||
pub message_id: String,
|
||||
pub session_id: String,
|
||||
pub role: BigFishAgentMessageRole,
|
||||
pub kind: BigFishAgentMessageKind,
|
||||
pub text: String,
|
||||
pub created_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishAssetSlotSnapshot {
|
||||
pub slot_id: String,
|
||||
pub session_id: String,
|
||||
pub asset_kind: BigFishAssetKind,
|
||||
pub level: Option<u32>,
|
||||
pub motion_key: Option<String>,
|
||||
pub status: BigFishAssetStatus,
|
||||
pub asset_url: Option<String>,
|
||||
pub prompt_snapshot: String,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishAssetCoverage {
|
||||
pub level_main_image_ready_count: u32,
|
||||
pub level_motion_ready_count: u32,
|
||||
pub background_ready: bool,
|
||||
pub required_level_count: u32,
|
||||
pub publish_ready: bool,
|
||||
pub blockers: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BigFishSessionSnapshot {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub seed_text: String,
|
||||
pub current_turn: u32,
|
||||
pub progress_percent: u32,
|
||||
pub stage: BigFishCreationStage,
|
||||
pub anchor_pack: BigFishAnchorPack,
|
||||
pub draft: Option<BigFishGameDraft>,
|
||||
pub asset_slots: Vec<BigFishAssetSlotSnapshot>,
|
||||
pub asset_coverage: BigFishAssetCoverage,
|
||||
pub messages: Vec<BigFishAgentMessageSnapshot>,
|
||||
pub last_assistant_reply: Option<String>,
|
||||
pub publish_ready: bool,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BigFishSessionProcedureResult {
|
||||
pub ok: bool,
|
||||
pub session: Option<BigFishSessionSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishWorkSummarySnapshot {
|
||||
pub work_id: String,
|
||||
pub source_session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub title: String,
|
||||
pub subtitle: String,
|
||||
pub summary: String,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub status: String,
|
||||
pub updated_at_micros: i64,
|
||||
pub publish_ready: bool,
|
||||
pub level_count: u32,
|
||||
pub level_main_image_ready_count: u32,
|
||||
pub level_motion_ready_count: u32,
|
||||
pub background_ready: bool,
|
||||
pub play_count: u32,
|
||||
}
|
||||
|
||||
/// 发布门禁的领域判定结果。
|
||||
///
|
||||
/// 这里不保存外部任务状态,只表达当前聚合快照是否满足发布条件。
|
||||
@@ -77,3 +299,66 @@ impl BigFishRunStatus {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BigFishCreationStage {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::CollectingAnchors => "collecting_anchors",
|
||||
Self::DraftReady => "draft_ready",
|
||||
Self::AssetRefining => "asset_refining",
|
||||
Self::ReadyToPublish => "ready_to_publish",
|
||||
Self::Published => "published",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BigFishAnchorStatus {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Confirmed => "confirmed",
|
||||
Self::Inferred => "inferred",
|
||||
Self::Missing => "missing",
|
||||
Self::Locked => "locked",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BigFishAgentMessageRole {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::User => "user",
|
||||
Self::Assistant => "assistant",
|
||||
Self::System => "system",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BigFishAgentMessageKind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Chat => "chat",
|
||||
Self::Summary => "summary",
|
||||
Self::ActionResult => "action_result",
|
||||
Self::Warning => "warning",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BigFishAssetKind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::LevelMainImage => "level_main_image",
|
||||
Self::LevelMotion => "level_motion",
|
||||
Self::StageBackground => "stage_background",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BigFishAssetStatus {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Missing => "missing",
|
||||
Self::Ready => "ready",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 大鱼吃小鱼领域错误过渡落位。
|
||||
//! 大鱼吃小鱼领域错误。
|
||||
//!
|
||||
//! 错误只表达玩法规则失败,由 HTTP 和 SpacetimeDB adapter 分别映射展示。
|
||||
|
||||
@@ -27,3 +27,34 @@ impl fmt::Display for BigFishApplicationError {
|
||||
}
|
||||
|
||||
impl Error for BigFishApplicationError {}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum BigFishFieldError {
|
||||
MissingSessionId,
|
||||
MissingOwnerUserId,
|
||||
MissingMessageId,
|
||||
MissingMessageText,
|
||||
MissingDraft,
|
||||
InvalidLevel,
|
||||
InvalidAssetKind,
|
||||
MissingRunId,
|
||||
InvalidRuntimeInput,
|
||||
}
|
||||
|
||||
impl fmt::Display for BigFishFieldError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::MissingSessionId => f.write_str("big_fish.session_id 不能为空"),
|
||||
Self::MissingOwnerUserId => f.write_str("big_fish.owner_user_id 不能为空"),
|
||||
Self::MissingMessageId => f.write_str("big_fish.message_id 不能为空"),
|
||||
Self::MissingMessageText => f.write_str("big_fish.message_text 不能为空"),
|
||||
Self::MissingDraft => f.write_str("big_fish.draft 尚未编译"),
|
||||
Self::InvalidLevel => f.write_str("big_fish.level 不在合法等级范围内"),
|
||||
Self::InvalidAssetKind => f.write_str("big_fish.asset_kind 或动作位非法"),
|
||||
Self::MissingRunId => f.write_str("big_fish.run_id 不能为空"),
|
||||
Self::InvalidRuntimeInput => f.write_str("big_fish.runtime_input 非法"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for BigFishFieldError {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 大鱼吃小鱼领域事件过渡落位。
|
||||
//! 大鱼吃小鱼领域事件。
|
||||
//!
|
||||
//! 用于表达草稿变化、资产槽变化和运行态 tick 等事实。
|
||||
|
||||
|
||||
@@ -4,990 +4,11 @@ mod domain;
|
||||
mod errors;
|
||||
mod events;
|
||||
|
||||
pub use application::{
|
||||
BigFishRuntimeResult, EvaluateBigFishPublishReadinessResult, deserialize_runtime_snapshot,
|
||||
evaluate_publish_readiness, serialize_runtime_snapshot, start_big_fish_run,
|
||||
submit_big_fish_input,
|
||||
};
|
||||
pub use commands::{
|
||||
EvaluateBigFishPublishReadinessCommand, StartBigFishRunCommand, SubmitBigFishInputCommand,
|
||||
};
|
||||
pub use domain::{
|
||||
BigFishPublishReadiness, BigFishRunStatus, BigFishRuntimeEntitySnapshot,
|
||||
BigFishRuntimeSnapshot, BigFishVector2,
|
||||
};
|
||||
pub use errors::BigFishApplicationError;
|
||||
pub use events::BigFishDomainEvent;
|
||||
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared_kernel::normalize_required_string;
|
||||
#[cfg(feature = "spacetime-types")]
|
||||
use spacetimedb::SpacetimeType;
|
||||
|
||||
pub const BIG_FISH_SESSION_ID_PREFIX: &str = "big-fish-session-";
|
||||
pub const BIG_FISH_MESSAGE_ID_PREFIX: &str = "big-fish-message-";
|
||||
pub const BIG_FISH_OPERATION_ID_PREFIX: &str = "big-fish-operation-";
|
||||
pub const BIG_FISH_ASSET_SLOT_ID_PREFIX: &str = "big-fish-asset-";
|
||||
pub const BIG_FISH_DEFAULT_LEVEL_COUNT: u32 = 8;
|
||||
pub const BIG_FISH_MIN_LEVEL_COUNT: u32 = 6;
|
||||
pub const BIG_FISH_MAX_LEVEL_COUNT: u32 = 12;
|
||||
pub const BIG_FISH_MERGE_COUNT_PER_UPGRADE: u32 = 3;
|
||||
pub const BIG_FISH_OFFSCREEN_CULL_SECONDS: f32 = 3.0;
|
||||
pub const BIG_FISH_TARGET_WILD_COUNT: usize = 12;
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum BigFishCreationStage {
|
||||
CollectingAnchors,
|
||||
DraftReady,
|
||||
AssetRefining,
|
||||
ReadyToPublish,
|
||||
Published,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum BigFishAnchorStatus {
|
||||
Confirmed,
|
||||
Inferred,
|
||||
Missing,
|
||||
Locked,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum BigFishAgentMessageRole {
|
||||
User,
|
||||
Assistant,
|
||||
System,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum BigFishAgentMessageKind {
|
||||
Chat,
|
||||
Summary,
|
||||
ActionResult,
|
||||
Warning,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum BigFishAssetKind {
|
||||
LevelMainImage,
|
||||
LevelMotion,
|
||||
StageBackground,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum BigFishAssetStatus {
|
||||
Missing,
|
||||
Ready,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishAnchorItem {
|
||||
pub key: String,
|
||||
pub label: String,
|
||||
pub value: String,
|
||||
pub status: BigFishAnchorStatus,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishAnchorPack {
|
||||
pub gameplay_promise: BigFishAnchorItem,
|
||||
pub ecology_visual_theme: BigFishAnchorItem,
|
||||
pub growth_ladder: BigFishAnchorItem,
|
||||
pub risk_tempo: BigFishAnchorItem,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BigFishLevelBlueprint {
|
||||
pub level: u32,
|
||||
pub name: String,
|
||||
pub one_line_fantasy: String,
|
||||
pub text_description: String,
|
||||
pub silhouette_direction: String,
|
||||
pub size_ratio: f32,
|
||||
pub visual_description: String,
|
||||
pub visual_prompt_seed: String,
|
||||
pub idle_motion_description: String,
|
||||
pub move_motion_description: String,
|
||||
pub motion_prompt_seed: String,
|
||||
pub merge_source_level: Option<u32>,
|
||||
pub prey_window: Vec<u32>,
|
||||
pub threat_window: Vec<u32>,
|
||||
pub is_final_level: bool,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishBackgroundBlueprint {
|
||||
pub theme: String,
|
||||
pub color_mood: String,
|
||||
pub foreground_hints: String,
|
||||
pub midground_composition: String,
|
||||
pub background_depth: String,
|
||||
pub safe_play_area_hint: String,
|
||||
pub spawn_edge_hint: String,
|
||||
pub background_prompt_seed: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BigFishRuntimeParams {
|
||||
pub level_count: u32,
|
||||
pub merge_count_per_upgrade: u32,
|
||||
pub spawn_target_count: u32,
|
||||
pub leader_move_speed: f32,
|
||||
pub follower_catch_up_speed: f32,
|
||||
pub offscreen_cull_seconds: f32,
|
||||
pub prey_spawn_delta_levels: Vec<u32>,
|
||||
pub threat_spawn_delta_levels: Vec<u32>,
|
||||
pub win_level: u32,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BigFishGameDraft {
|
||||
pub title: String,
|
||||
pub subtitle: String,
|
||||
pub core_fun: String,
|
||||
pub ecology_theme: String,
|
||||
pub levels: Vec<BigFishLevelBlueprint>,
|
||||
pub background: BigFishBackgroundBlueprint,
|
||||
pub runtime_params: BigFishRuntimeParams,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishAgentMessageSnapshot {
|
||||
pub message_id: String,
|
||||
pub session_id: String,
|
||||
pub role: BigFishAgentMessageRole,
|
||||
pub kind: BigFishAgentMessageKind,
|
||||
pub text: String,
|
||||
pub created_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishAssetSlotSnapshot {
|
||||
pub slot_id: String,
|
||||
pub session_id: String,
|
||||
pub asset_kind: BigFishAssetKind,
|
||||
pub level: Option<u32>,
|
||||
pub motion_key: Option<String>,
|
||||
pub status: BigFishAssetStatus,
|
||||
pub asset_url: Option<String>,
|
||||
pub prompt_snapshot: String,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishAssetCoverage {
|
||||
pub level_main_image_ready_count: u32,
|
||||
pub level_motion_ready_count: u32,
|
||||
pub background_ready: bool,
|
||||
pub required_level_count: u32,
|
||||
pub publish_ready: bool,
|
||||
pub blockers: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BigFishSessionSnapshot {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub seed_text: String,
|
||||
pub current_turn: u32,
|
||||
pub progress_percent: u32,
|
||||
pub stage: BigFishCreationStage,
|
||||
pub anchor_pack: BigFishAnchorPack,
|
||||
pub draft: Option<BigFishGameDraft>,
|
||||
pub asset_slots: Vec<BigFishAssetSlotSnapshot>,
|
||||
pub asset_coverage: BigFishAssetCoverage,
|
||||
pub messages: Vec<BigFishAgentMessageSnapshot>,
|
||||
pub last_assistant_reply: Option<String>,
|
||||
pub publish_ready: bool,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BigFishSessionProcedureResult {
|
||||
pub ok: bool,
|
||||
pub session: Option<BigFishSessionSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishWorkSummarySnapshot {
|
||||
pub work_id: String,
|
||||
pub source_session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub title: String,
|
||||
pub subtitle: String,
|
||||
pub summary: String,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub status: String,
|
||||
pub updated_at_micros: i64,
|
||||
pub publish_ready: bool,
|
||||
pub level_count: u32,
|
||||
pub level_main_image_ready_count: u32,
|
||||
pub level_motion_ready_count: u32,
|
||||
pub background_ready: bool,
|
||||
pub play_count: u32,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishWorksListInput {
|
||||
pub owner_user_id: String,
|
||||
pub published_only: bool,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishWorkDeleteInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishWorksProcedureResult {
|
||||
pub ok: bool,
|
||||
pub items_json: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishSessionCreateInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub seed_text: String,
|
||||
pub welcome_message_id: String,
|
||||
pub welcome_message_text: String,
|
||||
pub created_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishSessionGetInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishMessageSubmitInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub user_message_id: String,
|
||||
pub user_message_text: String,
|
||||
pub assistant_message_id: String,
|
||||
pub submitted_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishMessageFinalizeInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub assistant_message_id: Option<String>,
|
||||
pub assistant_reply_text: Option<String>,
|
||||
pub stage: BigFishCreationStage,
|
||||
pub progress_percent: u32,
|
||||
pub anchor_pack_json: String,
|
||||
pub error_message: Option<String>,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishDraftCompileInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub draft_json: Option<String>,
|
||||
pub compiled_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishAssetGenerateInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub asset_kind: BigFishAssetKind,
|
||||
pub level: Option<u32>,
|
||||
pub motion_key: Option<String>,
|
||||
pub asset_url: Option<String>,
|
||||
pub generated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishPublishInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub published_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishPlayRecordInput {
|
||||
pub session_id: String,
|
||||
pub user_id: String,
|
||||
pub elapsed_ms: u64,
|
||||
pub played_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishRunStartInput {
|
||||
pub run_id: String,
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub started_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishRunGetInput {
|
||||
pub run_id: String,
|
||||
pub owner_user_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BigFishInputSubmitInput {
|
||||
pub run_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub submitted_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishRunProcedureResult {
|
||||
pub ok: bool,
|
||||
pub run_json: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum BigFishFieldError {
|
||||
MissingSessionId,
|
||||
MissingOwnerUserId,
|
||||
MissingMessageId,
|
||||
MissingMessageText,
|
||||
MissingDraft,
|
||||
InvalidLevel,
|
||||
InvalidAssetKind,
|
||||
MissingRunId,
|
||||
InvalidRuntimeInput,
|
||||
}
|
||||
|
||||
impl BigFishCreationStage {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::CollectingAnchors => "collecting_anchors",
|
||||
Self::DraftReady => "draft_ready",
|
||||
Self::AssetRefining => "asset_refining",
|
||||
Self::ReadyToPublish => "ready_to_publish",
|
||||
Self::Published => "published",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BigFishAnchorStatus {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Confirmed => "confirmed",
|
||||
Self::Inferred => "inferred",
|
||||
Self::Missing => "missing",
|
||||
Self::Locked => "locked",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BigFishAgentMessageRole {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::User => "user",
|
||||
Self::Assistant => "assistant",
|
||||
Self::System => "system",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BigFishAgentMessageKind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Chat => "chat",
|
||||
Self::Summary => "summary",
|
||||
Self::ActionResult => "action_result",
|
||||
Self::Warning => "warning",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BigFishAssetKind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::LevelMainImage => "level_main_image",
|
||||
Self::LevelMotion => "level_motion",
|
||||
Self::StageBackground => "stage_background",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BigFishAssetStatus {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Missing => "missing",
|
||||
Self::Ready => "ready",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn empty_anchor_pack() -> BigFishAnchorPack {
|
||||
BigFishAnchorPack {
|
||||
gameplay_promise: BigFishAnchorItem {
|
||||
key: "gameplayPromise".to_string(),
|
||||
label: "玩法承诺".to_string(),
|
||||
value: String::new(),
|
||||
status: BigFishAnchorStatus::Missing,
|
||||
},
|
||||
ecology_visual_theme: BigFishAnchorItem {
|
||||
key: "ecologyVisualTheme".to_string(),
|
||||
label: "生态与视觉母题".to_string(),
|
||||
value: String::new(),
|
||||
status: BigFishAnchorStatus::Missing,
|
||||
},
|
||||
growth_ladder: BigFishAnchorItem {
|
||||
key: "growthLadder".to_string(),
|
||||
label: "成长阶梯".to_string(),
|
||||
value: String::new(),
|
||||
status: BigFishAnchorStatus::Missing,
|
||||
},
|
||||
risk_tempo: BigFishAnchorItem {
|
||||
key: "riskTempo".to_string(),
|
||||
label: "风险节奏".to_string(),
|
||||
value: "平衡".to_string(),
|
||||
status: BigFishAnchorStatus::Inferred,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn infer_anchor_pack(seed_text: &str, latest_message: Option<&str>) -> BigFishAnchorPack {
|
||||
let source = normalize_required_string(latest_message.unwrap_or(seed_text))
|
||||
.or_else(|| normalize_required_string(seed_text))
|
||||
.unwrap_or_else(|| "深海弱小逆袭,逐级吞噬成长".to_string());
|
||||
let mut pack = empty_anchor_pack();
|
||||
pack.gameplay_promise.value = if source.contains("可爱") {
|
||||
"可爱生态成长".to_string()
|
||||
} else if source.contains("机械") {
|
||||
"机械微生物吞并进化".to_string()
|
||||
} else {
|
||||
"弱小逆袭和群体吞并".to_string()
|
||||
};
|
||||
pack.gameplay_promise.status = BigFishAnchorStatus::Inferred;
|
||||
pack.ecology_visual_theme.value = if source.contains("机械") {
|
||||
"机械微生物水域".to_string()
|
||||
} else if source.contains("梦") {
|
||||
"梦境纸鱼生态".to_string()
|
||||
} else {
|
||||
"深海生物生态".to_string()
|
||||
};
|
||||
pack.ecology_visual_theme.status = BigFishAnchorStatus::Inferred;
|
||||
pack.growth_ladder.value = "8 级连续进化,从幼小个体成长为终局巨兽".to_string();
|
||||
pack.growth_ladder.status = BigFishAnchorStatus::Inferred;
|
||||
pack.risk_tempo.value = if source.contains("爽") {
|
||||
"偏爽快".to_string()
|
||||
} else if source.contains("压迫") {
|
||||
"偏压迫".to_string()
|
||||
} else {
|
||||
"平衡".to_string()
|
||||
};
|
||||
pack
|
||||
}
|
||||
|
||||
pub fn compile_default_draft(anchor_pack: &BigFishAnchorPack) -> BigFishGameDraft {
|
||||
let level_count = BIG_FISH_DEFAULT_LEVEL_COUNT;
|
||||
let theme = fallback_anchor_value(&anchor_pack.ecology_visual_theme, "深海生物生态");
|
||||
let core_fun = fallback_anchor_value(&anchor_pack.gameplay_promise, "弱小逆袭和群体吞并");
|
||||
let risk_tempo = fallback_anchor_value(&anchor_pack.risk_tempo, "平衡");
|
||||
|
||||
let levels = (1..=level_count)
|
||||
.map(|level| build_level_blueprint(level, level_count, &theme))
|
||||
.collect();
|
||||
|
||||
BigFishGameDraft {
|
||||
title: format!("{theme} 大鱼吃小鱼"),
|
||||
subtitle: format!("{core_fun} · {risk_tempo}节奏"),
|
||||
core_fun,
|
||||
ecology_theme: theme.clone(),
|
||||
levels,
|
||||
background: BigFishBackgroundBlueprint {
|
||||
theme: theme.clone(),
|
||||
color_mood: "深蓝、青绿、带少量暖色生物光".to_string(),
|
||||
foreground_hints: "只保留少量漂浮颗粒和边缘水草,不遮挡中央操作区".to_string(),
|
||||
midground_composition: "中央留出大面积清晰活动区域,边缘只做出生缓冲层".to_string(),
|
||||
background_depth: "简洁纵深水域与极少量远处剪影".to_string(),
|
||||
safe_play_area_hint: "9:16 竖屏中央 80% 为主要活动区".to_string(),
|
||||
spawn_edge_hint: "四周边缘以少量暗礁或水草提示野生实体出生区".to_string(),
|
||||
background_prompt_seed: format!(
|
||||
"{theme},竖屏 9:16,全屏大场地游戏背景,元素少,中央开阔,无文字,无 UI 框"
|
||||
),
|
||||
},
|
||||
runtime_params: BigFishRuntimeParams {
|
||||
level_count,
|
||||
merge_count_per_upgrade: BIG_FISH_MERGE_COUNT_PER_UPGRADE,
|
||||
spawn_target_count: BIG_FISH_TARGET_WILD_COUNT as u32,
|
||||
leader_move_speed: 160.0,
|
||||
follower_catch_up_speed: 120.0,
|
||||
offscreen_cull_seconds: BIG_FISH_OFFSCREEN_CULL_SECONDS,
|
||||
prey_spawn_delta_levels: vec![1, 2],
|
||||
threat_spawn_delta_levels: vec![1, 2],
|
||||
win_level: level_count,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_asset_coverage(
|
||||
draft: Option<&BigFishGameDraft>,
|
||||
asset_slots: &[BigFishAssetSlotSnapshot],
|
||||
) -> BigFishAssetCoverage {
|
||||
let required_level_count = draft
|
||||
.map(|value| value.runtime_params.level_count)
|
||||
.unwrap_or(BIG_FISH_DEFAULT_LEVEL_COUNT);
|
||||
let main_ready = asset_slots
|
||||
.iter()
|
||||
.filter(|slot| {
|
||||
slot.asset_kind == BigFishAssetKind::LevelMainImage
|
||||
&& slot.status == BigFishAssetStatus::Ready
|
||||
})
|
||||
.count() as u32;
|
||||
let motion_ready = asset_slots
|
||||
.iter()
|
||||
.filter(|slot| {
|
||||
slot.asset_kind == BigFishAssetKind::LevelMotion
|
||||
&& slot.status == BigFishAssetStatus::Ready
|
||||
})
|
||||
.count() as u32;
|
||||
let background_ready = asset_slots.iter().any(|slot| {
|
||||
slot.asset_kind == BigFishAssetKind::StageBackground
|
||||
&& slot.status == BigFishAssetStatus::Ready
|
||||
});
|
||||
|
||||
let required_motion_count = required_level_count * 2;
|
||||
let mut blockers = Vec::new();
|
||||
if draft.is_none() {
|
||||
blockers.push("玩法草稿尚未编译".to_string());
|
||||
}
|
||||
if main_ready < required_level_count {
|
||||
blockers.push(format!(
|
||||
"还缺少 {} 个等级主图",
|
||||
required_level_count.saturating_sub(main_ready)
|
||||
));
|
||||
}
|
||||
if motion_ready < required_motion_count {
|
||||
blockers.push(format!(
|
||||
"还缺少 {} 个基础动作",
|
||||
required_motion_count.saturating_sub(motion_ready)
|
||||
));
|
||||
}
|
||||
if !background_ready {
|
||||
blockers.push("还缺少活动区域背景图".to_string());
|
||||
}
|
||||
|
||||
BigFishAssetCoverage {
|
||||
level_main_image_ready_count: main_ready,
|
||||
level_motion_ready_count: motion_ready,
|
||||
background_ready,
|
||||
required_level_count,
|
||||
publish_ready: blockers.is_empty(),
|
||||
blockers,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_generated_asset_slot(
|
||||
session_id: &str,
|
||||
draft: &BigFishGameDraft,
|
||||
asset_kind: BigFishAssetKind,
|
||||
level: Option<u32>,
|
||||
motion_key: Option<String>,
|
||||
asset_url: Option<String>,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<BigFishAssetSlotSnapshot, BigFishFieldError> {
|
||||
let session_id =
|
||||
normalize_required_string(session_id).ok_or(BigFishFieldError::MissingSessionId)?;
|
||||
let prompt_snapshot =
|
||||
build_asset_prompt_snapshot(draft, asset_kind, level, motion_key.as_deref())?;
|
||||
let slot_id = build_asset_slot_id(&session_id, asset_kind, level, motion_key.as_deref());
|
||||
let resolved_asset_url = normalize_required_string(asset_url.as_deref().unwrap_or_default())
|
||||
.unwrap_or_else(|| build_placeholder_asset_url(asset_kind, level, updated_at_micros));
|
||||
|
||||
Ok(BigFishAssetSlotSnapshot {
|
||||
slot_id,
|
||||
session_id,
|
||||
asset_kind,
|
||||
level,
|
||||
motion_key,
|
||||
status: BigFishAssetStatus::Ready,
|
||||
asset_url: Some(resolved_asset_url),
|
||||
prompt_snapshot,
|
||||
updated_at_micros,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate_session_get_input(input: &BigFishSessionGetInput) -> Result<(), BigFishFieldError> {
|
||||
validate_session_owner(&input.session_id, &input.owner_user_id)
|
||||
}
|
||||
|
||||
pub fn validate_works_list_input(input: &BigFishWorksListInput) -> Result<(), BigFishFieldError> {
|
||||
if input.published_only {
|
||||
return Ok(());
|
||||
}
|
||||
if normalize_required_string(&input.owner_user_id).is_none() {
|
||||
return Err(BigFishFieldError::MissingOwnerUserId);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_session_create_input(
|
||||
input: &BigFishSessionCreateInput,
|
||||
) -> Result<(), BigFishFieldError> {
|
||||
validate_session_owner(&input.session_id, &input.owner_user_id)?;
|
||||
if normalize_required_string(&input.welcome_message_id).is_none() {
|
||||
return Err(BigFishFieldError::MissingMessageId);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_message_submit_input(
|
||||
input: &BigFishMessageSubmitInput,
|
||||
) -> Result<(), BigFishFieldError> {
|
||||
validate_session_owner(&input.session_id, &input.owner_user_id)?;
|
||||
if normalize_required_string(&input.user_message_id).is_none()
|
||||
|| normalize_required_string(&input.assistant_message_id).is_none()
|
||||
{
|
||||
return Err(BigFishFieldError::MissingMessageId);
|
||||
}
|
||||
if normalize_required_string(&input.user_message_text).is_none() {
|
||||
return Err(BigFishFieldError::MissingMessageText);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_message_finalize_input(
|
||||
input: &BigFishMessageFinalizeInput,
|
||||
) -> Result<(), BigFishFieldError> {
|
||||
validate_session_owner(&input.session_id, &input.owner_user_id)
|
||||
}
|
||||
|
||||
pub fn validate_draft_compile_input(
|
||||
input: &BigFishDraftCompileInput,
|
||||
) -> Result<(), BigFishFieldError> {
|
||||
validate_session_owner(&input.session_id, &input.owner_user_id)
|
||||
}
|
||||
|
||||
pub fn validate_asset_generate_input(
|
||||
input: &BigFishAssetGenerateInput,
|
||||
draft: &BigFishGameDraft,
|
||||
) -> Result<(), BigFishFieldError> {
|
||||
validate_session_owner(&input.session_id, &input.owner_user_id)?;
|
||||
match input.asset_kind {
|
||||
BigFishAssetKind::LevelMainImage => validate_level(input.level, draft),
|
||||
BigFishAssetKind::LevelMotion => {
|
||||
validate_level(input.level, draft)?;
|
||||
match input.motion_key.as_deref() {
|
||||
Some("idle_float" | "move_swim") => Ok(()),
|
||||
_ => Err(BigFishFieldError::InvalidAssetKind),
|
||||
}
|
||||
}
|
||||
BigFishAssetKind::StageBackground => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_publish_input(input: &BigFishPublishInput) -> Result<(), BigFishFieldError> {
|
||||
validate_session_owner(&input.session_id, &input.owner_user_id)
|
||||
}
|
||||
|
||||
pub fn validate_play_record_input(input: &BigFishPlayRecordInput) -> Result<(), BigFishFieldError> {
|
||||
if normalize_required_string(&input.session_id).is_none() {
|
||||
return Err(BigFishFieldError::MissingSessionId);
|
||||
}
|
||||
if normalize_required_string(&input.user_id).is_none() {
|
||||
return Err(BigFishFieldError::MissingOwnerUserId);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_run_start_input(input: &BigFishRunStartInput) -> Result<(), BigFishFieldError> {
|
||||
validate_session_owner(&input.session_id, &input.owner_user_id)?;
|
||||
if normalize_required_string(&input.run_id).is_none() {
|
||||
return Err(BigFishFieldError::MissingRunId);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_run_get_input(input: &BigFishRunGetInput) -> Result<(), BigFishFieldError> {
|
||||
if normalize_required_string(&input.run_id).is_none() {
|
||||
return Err(BigFishFieldError::MissingRunId);
|
||||
}
|
||||
if normalize_required_string(&input.owner_user_id).is_none() {
|
||||
return Err(BigFishFieldError::MissingOwnerUserId);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_input_submit_input(
|
||||
input: &BigFishInputSubmitInput,
|
||||
) -> Result<(), BigFishFieldError> {
|
||||
if normalize_required_string(&input.run_id).is_none() {
|
||||
return Err(BigFishFieldError::MissingRunId);
|
||||
}
|
||||
if normalize_required_string(&input.owner_user_id).is_none() {
|
||||
return Err(BigFishFieldError::MissingOwnerUserId);
|
||||
}
|
||||
if !input.x.is_finite() || !input.y.is_finite() {
|
||||
return Err(BigFishFieldError::InvalidRuntimeInput);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn serialize_anchor_pack(anchor_pack: &BigFishAnchorPack) -> Result<String, serde_json::Error> {
|
||||
serde_json::to_string(anchor_pack)
|
||||
}
|
||||
|
||||
pub fn deserialize_anchor_pack(value: &str) -> Result<BigFishAnchorPack, serde_json::Error> {
|
||||
serde_json::from_str(value)
|
||||
}
|
||||
|
||||
pub fn serialize_draft(draft: &BigFishGameDraft) -> Result<String, serde_json::Error> {
|
||||
serde_json::to_string(draft)
|
||||
}
|
||||
|
||||
pub fn deserialize_draft(value: &str) -> Result<BigFishGameDraft, serde_json::Error> {
|
||||
serde_json::from_str(value)
|
||||
}
|
||||
|
||||
pub fn serialize_asset_coverage(
|
||||
coverage: &BigFishAssetCoverage,
|
||||
) -> Result<String, serde_json::Error> {
|
||||
serde_json::to_string(coverage)
|
||||
}
|
||||
|
||||
pub fn deserialize_asset_coverage(value: &str) -> Result<BigFishAssetCoverage, serde_json::Error> {
|
||||
serde_json::from_str(value)
|
||||
}
|
||||
|
||||
fn fallback_anchor_value(anchor: &BigFishAnchorItem, fallback: &str) -> String {
|
||||
normalize_required_string(&anchor.value).unwrap_or_else(|| fallback.to_string())
|
||||
}
|
||||
|
||||
fn build_level_blueprint(level: u32, level_count: u32, theme: &str) -> BigFishLevelBlueprint {
|
||||
let prey_window = (1..level)
|
||||
.rev()
|
||||
.take(2)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect();
|
||||
let threat_window = ((level + 1)..=(level + 2).min(level_count)).collect::<Vec<_>>();
|
||||
let size_ratio = 1.0 + (level.saturating_sub(1) as f32 * 0.22);
|
||||
let name = format!("{theme} L{level}");
|
||||
let one_line_fantasy = if level == level_count {
|
||||
"终局巨兽形态,获得即可通关".to_string()
|
||||
} else {
|
||||
format!("第 {level} 阶实体,继续吞噬同级和低级个体成长")
|
||||
};
|
||||
let text_description = if level == 1 {
|
||||
format!(
|
||||
"{name} 是这套 {theme} 等级阶梯的起点个体,体型最小、动作轻盈,会在谨慎试探中寻找第一个可吞噬目标。"
|
||||
)
|
||||
} else if level == level_count {
|
||||
format!(
|
||||
"{name} 是这套 {theme} 生态中的终局霸主形态,体格巨大、压迫感最强,一旦成型就代表本局成长链已经完成。"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{name} 是 {theme} 生态里的第 {level} 阶进化体,已经具备更鲜明的轮廓、猎食性和压迫感,会继续通过吞并同级与低级实体向上跃迁。"
|
||||
)
|
||||
};
|
||||
let visual_description = if level == 1 {
|
||||
format!(
|
||||
"{theme} 风格的小型初始鱼形生物,体态轻巧,轮廓圆润,局部带少量发光纹路或主题特征,明显呈现弱小但灵动的开局形象。"
|
||||
)
|
||||
} else if level == level_count {
|
||||
format!(
|
||||
"{theme} 风格的终局巨型鱼形霸主,体长与鳍面明显扩张,轮廓锋利或威严,层次细节最丰富,拥有一眼可辨识的终局统治感。"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{theme} 风格的第 {level} 级进化鱼形生物,相比上一阶段更大、更强、更成熟,身体主轮廓更清晰,局部装饰、鳍面结构和主题特征都更明显。"
|
||||
)
|
||||
};
|
||||
let idle_motion_description = if level == level_count {
|
||||
"待机时缓慢悬停,身体主体保持稳定,尾鳍与侧鳍做低频摆动,呈现强者从容压场的漂浮感。"
|
||||
.to_string()
|
||||
} else {
|
||||
format!(
|
||||
"待机时保持轻微漂浮与呼吸感摆动,尾鳍和侧鳍以小幅度节奏晃动,体现 Lv.{level} 生物在水中蓄势观察的状态。"
|
||||
)
|
||||
};
|
||||
let move_motion_description = if level == level_count {
|
||||
"移动时身体前倾,尾鳍和背鳍形成强力推进姿态,带出稳定而有压迫感的高速巡游动势。".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"移动时身体向前游动,尾鳍形成清晰摆尾推进,整体节奏比待机更主动,体现 Lv.{level} 生物追逐猎物时的连续游动感。"
|
||||
)
|
||||
};
|
||||
BigFishLevelBlueprint {
|
||||
level,
|
||||
name,
|
||||
one_line_fantasy,
|
||||
text_description,
|
||||
silhouette_direction: format!(
|
||||
"体型约为初始的 {:.1} 倍,轮廓更清晰",
|
||||
1.0 + level as f32 * 0.22
|
||||
),
|
||||
size_ratio,
|
||||
visual_description: visual_description.clone(),
|
||||
visual_prompt_seed: format!(
|
||||
"{visual_description} 透明背景,单体完整入镜,适合作为竖屏吞噬成长玩法的等级主图。"
|
||||
),
|
||||
idle_motion_description: idle_motion_description.clone(),
|
||||
move_motion_description: move_motion_description.clone(),
|
||||
motion_prompt_seed: format!(
|
||||
"待机动作:{idle_motion_description} 移动动作:{move_motion_description}"
|
||||
),
|
||||
merge_source_level: if level == 1 { None } else { Some(level - 1) },
|
||||
prey_window,
|
||||
threat_window,
|
||||
is_final_level: level == level_count,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_asset_prompt_snapshot(
|
||||
draft: &BigFishGameDraft,
|
||||
asset_kind: BigFishAssetKind,
|
||||
level: Option<u32>,
|
||||
motion_key: Option<&str>,
|
||||
) -> Result<String, BigFishFieldError> {
|
||||
match asset_kind {
|
||||
BigFishAssetKind::LevelMainImage => {
|
||||
let level = level.ok_or(BigFishFieldError::InvalidLevel)?;
|
||||
let blueprint = draft
|
||||
.levels
|
||||
.iter()
|
||||
.find(|item| item.level == level)
|
||||
.ok_or(BigFishFieldError::InvalidLevel)?;
|
||||
Ok(blueprint.visual_prompt_seed.clone())
|
||||
}
|
||||
BigFishAssetKind::LevelMotion => {
|
||||
let level = level.ok_or(BigFishFieldError::InvalidLevel)?;
|
||||
let blueprint = draft
|
||||
.levels
|
||||
.iter()
|
||||
.find(|item| item.level == level)
|
||||
.ok_or(BigFishFieldError::InvalidLevel)?;
|
||||
let motion_key = motion_key.ok_or(BigFishFieldError::InvalidAssetKind)?;
|
||||
let motion_description = match motion_key {
|
||||
"idle_float" => blueprint.idle_motion_description.as_str(),
|
||||
"move_swim" => blueprint.move_motion_description.as_str(),
|
||||
_ => return Err(BigFishFieldError::InvalidAssetKind),
|
||||
};
|
||||
Ok(format!(
|
||||
"{} 动作位:{}。{} 透明背景,单体完整入镜。",
|
||||
blueprint.motion_prompt_seed, motion_key, motion_description
|
||||
))
|
||||
}
|
||||
BigFishAssetKind::StageBackground => Ok(draft.background.background_prompt_seed.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_asset_slot_id(
|
||||
session_id: &str,
|
||||
asset_kind: BigFishAssetKind,
|
||||
level: Option<u32>,
|
||||
motion_key: Option<&str>,
|
||||
) -> String {
|
||||
let level_part = level
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_else(|| "stage".to_string());
|
||||
let motion_part = motion_key.unwrap_or("main");
|
||||
format!(
|
||||
"{BIG_FISH_ASSET_SLOT_ID_PREFIX}{session_id}_{}_{}_{}",
|
||||
asset_kind.as_str(),
|
||||
level_part,
|
||||
motion_part
|
||||
)
|
||||
}
|
||||
|
||||
fn build_placeholder_asset_url(
|
||||
asset_kind: BigFishAssetKind,
|
||||
level: Option<u32>,
|
||||
seed_micros: i64,
|
||||
) -> String {
|
||||
let level_part = level
|
||||
.map(|value| format!("level-{value}"))
|
||||
.unwrap_or_else(|| "stage".to_string());
|
||||
format!(
|
||||
"/generated-big-fish/{}/{}/{}.png",
|
||||
asset_kind.as_str(),
|
||||
level_part,
|
||||
seed_micros
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_session_owner(session_id: &str, owner_user_id: &str) -> Result<(), BigFishFieldError> {
|
||||
if normalize_required_string(session_id).is_none() {
|
||||
return Err(BigFishFieldError::MissingSessionId);
|
||||
}
|
||||
if normalize_required_string(owner_user_id).is_none() {
|
||||
return Err(BigFishFieldError::MissingOwnerUserId);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_level(level: Option<u32>, draft: &BigFishGameDraft) -> Result<(), BigFishFieldError> {
|
||||
match level {
|
||||
Some(value) if (1..=draft.runtime_params.level_count).contains(&value) => Ok(()),
|
||||
_ => Err(BigFishFieldError::InvalidLevel),
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for BigFishFieldError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::MissingSessionId => f.write_str("big_fish.session_id 不能为空"),
|
||||
Self::MissingOwnerUserId => f.write_str("big_fish.owner_user_id 不能为空"),
|
||||
Self::MissingMessageId => f.write_str("big_fish.message_id 不能为空"),
|
||||
Self::MissingMessageText => f.write_str("big_fish.message_text 不能为空"),
|
||||
Self::MissingDraft => f.write_str("big_fish.draft 尚未编译"),
|
||||
Self::InvalidLevel => f.write_str("big_fish.level 不在合法等级范围内"),
|
||||
Self::InvalidAssetKind => f.write_str("big_fish.asset_kind 或动作位非法"),
|
||||
Self::MissingRunId => f.write_str("big_fish.run_id 不能为空"),
|
||||
Self::InvalidRuntimeInput => f.write_str("big_fish.runtime_input 非法"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for BigFishFieldError {}
|
||||
pub use application::*;
|
||||
pub use commands::*;
|
||||
pub use domain::*;
|
||||
pub use errors::*;
|
||||
pub use events::*;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -1,3 +1,291 @@
|
||||
//! 战斗应用编排过渡落位。
|
||||
//! 战斗应用编排。
|
||||
//!
|
||||
//! 这里只返回结算结果与待处理事件,不直接写入其他上下文表。
|
||||
|
||||
use crate::commands::{
|
||||
BattleStateInput, ResolveCombatActionInput, validate_resolve_combat_action_input,
|
||||
};
|
||||
use crate::domain::{
|
||||
BASIC_FIGHT_COUNTER_RATIO, BATTLE_STATE_ID_PREFIX, BattleMode, BattleStateSnapshot,
|
||||
BattleStatus, CombatOutcome, INITIAL_BATTLE_VERSION, MIN_FIGHT_COUNTER_DAMAGE, SPAR_MIN_HP,
|
||||
};
|
||||
use crate::errors::CombatFieldError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared_kernel::{build_prefixed_seed_id, 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 ResolveCombatActionResult {
|
||||
pub snapshot: BattleStateSnapshot,
|
||||
pub damage_dealt: i32,
|
||||
pub damage_taken: i32,
|
||||
pub outcome: CombatOutcome,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BattleStateProcedureResult {
|
||||
pub ok: bool,
|
||||
pub snapshot: Option<BattleStateSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ResolveCombatActionProcedureResult {
|
||||
pub ok: bool,
|
||||
pub result: Option<ResolveCombatActionResult>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
pub fn build_battle_state_snapshot(input: BattleStateInput) -> BattleStateSnapshot {
|
||||
BattleStateSnapshot {
|
||||
battle_state_id: input.battle_state_id,
|
||||
story_session_id: input.story_session_id,
|
||||
runtime_session_id: input.runtime_session_id,
|
||||
actor_user_id: input.actor_user_id,
|
||||
chapter_id: input.chapter_id,
|
||||
target_npc_id: input.target_npc_id,
|
||||
target_name: input.target_name,
|
||||
battle_mode: input.battle_mode,
|
||||
status: BattleStatus::Ongoing,
|
||||
player_hp: input.player_hp,
|
||||
player_max_hp: input.player_max_hp,
|
||||
player_mana: input.player_mana,
|
||||
player_max_mana: input.player_max_mana,
|
||||
target_hp: input.target_hp,
|
||||
target_max_hp: input.target_max_hp,
|
||||
experience_reward: input.experience_reward,
|
||||
reward_items: input.reward_items,
|
||||
turn_index: 0,
|
||||
last_action_function_id: None,
|
||||
last_action_text: None,
|
||||
last_result_text: None,
|
||||
last_damage_dealt: 0,
|
||||
last_damage_taken: 0,
|
||||
last_outcome: CombatOutcome::Ongoing,
|
||||
version: INITIAL_BATTLE_VERSION,
|
||||
created_at_micros: input.created_at_micros,
|
||||
updated_at_micros: input.created_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_combat_action(
|
||||
current: BattleStateSnapshot,
|
||||
input: ResolveCombatActionInput,
|
||||
) -> Result<ResolveCombatActionResult, CombatFieldError> {
|
||||
validate_resolve_combat_action_input(&input)?;
|
||||
|
||||
if current.version == 0 {
|
||||
return Err(CombatFieldError::InvalidVersion);
|
||||
}
|
||||
if current.status != BattleStatus::Ongoing {
|
||||
return Err(CombatFieldError::BattleAlreadyResolved);
|
||||
}
|
||||
if current.player_mana < input.mana_cost.max(0) {
|
||||
return Err(CombatFieldError::InsufficientMana);
|
||||
}
|
||||
|
||||
let action_text = if input.action_text.trim().is_empty() {
|
||||
input.function_id.clone()
|
||||
} else {
|
||||
normalize_required_string(input.action_text).unwrap_or_else(|| input.function_id.clone())
|
||||
};
|
||||
|
||||
if input.function_id == "battle_escape_breakout" {
|
||||
let next = BattleStateSnapshot {
|
||||
status: BattleStatus::Resolved,
|
||||
turn_index: current.turn_index + 1,
|
||||
last_action_function_id: Some(input.function_id),
|
||||
last_action_text: Some(action_text),
|
||||
last_result_text: Some(format!("你抓住空当摆脱了{}的压制。", current.target_name)),
|
||||
last_damage_dealt: 0,
|
||||
last_damage_taken: 0,
|
||||
last_outcome: CombatOutcome::Escaped,
|
||||
version: current.version + 1,
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
..current
|
||||
};
|
||||
|
||||
return Ok(ResolveCombatActionResult {
|
||||
snapshot: next,
|
||||
damage_dealt: 0,
|
||||
damage_taken: 0,
|
||||
outcome: CombatOutcome::Escaped,
|
||||
});
|
||||
}
|
||||
|
||||
let mana_cost = input.mana_cost.max(0);
|
||||
let heal = input.heal.max(0);
|
||||
let mana_restore = input.mana_restore.max(0);
|
||||
let base_damage = input.base_damage.max(0);
|
||||
|
||||
let mut next_player_hp = current.player_hp;
|
||||
let mut next_player_mana = (current.player_mana - mana_cost).max(0);
|
||||
let mut next_target_hp = current.target_hp;
|
||||
let mut damage_dealt = 0;
|
||||
let mut damage_taken = 0;
|
||||
|
||||
next_player_hp = clamp_hp(
|
||||
current.battle_mode,
|
||||
next_player_hp + heal,
|
||||
current.player_max_hp,
|
||||
);
|
||||
next_player_mana = clamp_mana(next_player_mana + mana_restore, current.player_max_mana);
|
||||
|
||||
if base_damage > 0 {
|
||||
next_target_hp =
|
||||
clamp_target_hp_after_damage(current.battle_mode, current.target_hp, base_damage);
|
||||
damage_dealt = current.target_hp - next_target_hp;
|
||||
}
|
||||
|
||||
let (status, outcome, result_text) = if is_target_resolved(current.battle_mode, next_target_hp)
|
||||
{
|
||||
let outcome = match current.battle_mode {
|
||||
BattleMode::Fight => CombatOutcome::Victory,
|
||||
BattleMode::Spar => CombatOutcome::SparComplete,
|
||||
};
|
||||
|
||||
(
|
||||
BattleStatus::Resolved,
|
||||
outcome,
|
||||
build_resolved_result_text(&action_text, ¤t.target_name, outcome),
|
||||
)
|
||||
} else {
|
||||
damage_taken = compute_counter_damage(
|
||||
current.battle_mode,
|
||||
current.target_max_hp,
|
||||
input.counter_multiplier_basis_points,
|
||||
);
|
||||
next_player_hp = clamp_hp(
|
||||
current.battle_mode,
|
||||
next_player_hp - damage_taken,
|
||||
current.player_max_hp,
|
||||
);
|
||||
|
||||
(
|
||||
BattleStatus::Ongoing,
|
||||
CombatOutcome::Ongoing,
|
||||
build_ongoing_result_text(&input.function_id, &action_text, ¤t.target_name),
|
||||
)
|
||||
};
|
||||
|
||||
let next = BattleStateSnapshot {
|
||||
player_hp: next_player_hp,
|
||||
player_mana: next_player_mana,
|
||||
target_hp: next_target_hp,
|
||||
status,
|
||||
turn_index: current.turn_index + 1,
|
||||
last_action_function_id: Some(input.function_id),
|
||||
last_action_text: Some(action_text),
|
||||
last_result_text: Some(result_text),
|
||||
last_damage_dealt: damage_dealt,
|
||||
last_damage_taken: damage_taken,
|
||||
last_outcome: outcome,
|
||||
version: current.version + 1,
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
..current
|
||||
};
|
||||
|
||||
Ok(ResolveCombatActionResult {
|
||||
snapshot: next,
|
||||
damage_dealt,
|
||||
damage_taken,
|
||||
outcome,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn generate_battle_state_id(seed_micros: i64) -> String {
|
||||
build_prefixed_seed_id(BATTLE_STATE_ID_PREFIX, seed_micros)
|
||||
}
|
||||
|
||||
fn clamp_hp(mode: BattleMode, value: i32, max_hp: i32) -> i32 {
|
||||
let min_hp = match mode {
|
||||
BattleMode::Fight => 0,
|
||||
BattleMode::Spar => SPAR_MIN_HP,
|
||||
};
|
||||
|
||||
value.clamp(min_hp, max_hp)
|
||||
}
|
||||
|
||||
fn clamp_mana(value: i32, max_mana: i32) -> i32 {
|
||||
value.clamp(0, max_mana)
|
||||
}
|
||||
|
||||
fn clamp_target_hp_after_damage(mode: BattleMode, current_hp: i32, damage: i32) -> i32 {
|
||||
match mode {
|
||||
BattleMode::Fight => (current_hp - damage).max(0),
|
||||
BattleMode::Spar => (current_hp - damage).max(SPAR_MIN_HP),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_target_resolved(mode: BattleMode, target_hp: i32) -> bool {
|
||||
match mode {
|
||||
BattleMode::Fight => target_hp <= 0,
|
||||
BattleMode::Spar => target_hp <= SPAR_MIN_HP,
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_counter_damage(
|
||||
mode: BattleMode,
|
||||
target_max_hp: i32,
|
||||
counter_multiplier_basis_points: u32,
|
||||
) -> i32 {
|
||||
match mode {
|
||||
BattleMode::Spar => 1,
|
||||
BattleMode::Fight => {
|
||||
let multiplier = counter_multiplier_basis_points as f32 / 10_000.0;
|
||||
let raw =
|
||||
(target_max_hp as f32 * BASIC_FIGHT_COUNTER_RATIO * multiplier).round() as i32;
|
||||
raw.max(MIN_FIGHT_COUNTER_DAMAGE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_resolved_result_text(
|
||||
action_text: &str,
|
||||
target_name: &str,
|
||||
outcome: CombatOutcome,
|
||||
) -> String {
|
||||
match outcome {
|
||||
CombatOutcome::Victory => {
|
||||
format!(
|
||||
"{}命中了{},这轮战斗已经正式结束。",
|
||||
action_text, target_name
|
||||
)
|
||||
}
|
||||
CombatOutcome::SparComplete => {
|
||||
format!(
|
||||
"{}压住了{}的节奏,这场切磋已经分出高下。",
|
||||
action_text, target_name
|
||||
)
|
||||
}
|
||||
CombatOutcome::Escaped => {
|
||||
format!("{}后你成功脱离了当前战斗。", action_text)
|
||||
}
|
||||
CombatOutcome::Ongoing => format!("{}已完成结算。", action_text),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ongoing_result_text(function_id: &str, action_text: &str, target_name: &str) -> String {
|
||||
match function_id {
|
||||
"battle_recover_breath" => {
|
||||
format!(
|
||||
"你先把伤势和气息稳住了一轮,但{}仍在持续逼近。",
|
||||
target_name
|
||||
)
|
||||
}
|
||||
"battle_use_skill" => {
|
||||
format!(
|
||||
"{}命中了{},这一轮技能效果已经直接结算。",
|
||||
action_text, target_name
|
||||
)
|
||||
}
|
||||
_ => format!(
|
||||
"{}命中了{},本次攻击已经完成结算。",
|
||||
action_text, target_name
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,170 @@
|
||||
//! 战斗写入命令过渡落位。
|
||||
//! 战斗写入命令。
|
||||
//!
|
||||
//! 用于表达创建战斗、使用技能、逃离和结算等输入,不直接携带表行类型。
|
||||
|
||||
use crate::domain::{BattleMode, LEGACY_ATTACK_FUNCTION_IDS};
|
||||
use crate::errors::CombatFieldError;
|
||||
use module_runtime_item::{
|
||||
RuntimeItemRewardItemSnapshot, TreasureFieldError, normalize_reward_item_snapshot,
|
||||
};
|
||||
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 BattleStateInput {
|
||||
pub battle_state_id: String,
|
||||
pub story_session_id: String,
|
||||
pub runtime_session_id: String,
|
||||
pub actor_user_id: String,
|
||||
pub chapter_id: Option<String>,
|
||||
pub target_npc_id: String,
|
||||
pub target_name: String,
|
||||
pub battle_mode: BattleMode,
|
||||
pub player_hp: i32,
|
||||
pub player_max_hp: i32,
|
||||
pub player_mana: i32,
|
||||
pub player_max_mana: i32,
|
||||
pub target_hp: i32,
|
||||
pub target_max_hp: i32,
|
||||
pub experience_reward: u32,
|
||||
pub reward_items: Vec<RuntimeItemRewardItemSnapshot>,
|
||||
pub created_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ResolveCombatActionInput {
|
||||
pub battle_state_id: String,
|
||||
pub function_id: String,
|
||||
pub action_text: String,
|
||||
pub base_damage: i32,
|
||||
pub mana_cost: i32,
|
||||
pub heal: i32,
|
||||
pub mana_restore: i32,
|
||||
pub counter_multiplier_basis_points: u32,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BattleStateQueryInput {
|
||||
pub battle_state_id: String,
|
||||
}
|
||||
|
||||
pub fn validate_battle_state_input(input: &BattleStateInput) -> Result<(), CombatFieldError> {
|
||||
if normalize_required_string(&input.battle_state_id).is_none() {
|
||||
return Err(CombatFieldError::MissingBattleStateId);
|
||||
}
|
||||
if normalize_required_string(&input.story_session_id).is_none() {
|
||||
return Err(CombatFieldError::MissingStorySessionId);
|
||||
}
|
||||
if normalize_required_string(&input.runtime_session_id).is_none() {
|
||||
return Err(CombatFieldError::MissingRuntimeSessionId);
|
||||
}
|
||||
if normalize_required_string(&input.actor_user_id).is_none() {
|
||||
return Err(CombatFieldError::MissingActorUserId);
|
||||
}
|
||||
if normalize_required_string(&input.target_npc_id).is_none() {
|
||||
return Err(CombatFieldError::MissingTargetNpcId);
|
||||
}
|
||||
if normalize_required_string(&input.target_name).is_none() {
|
||||
return Err(CombatFieldError::MissingTargetName);
|
||||
}
|
||||
if input.player_max_hp <= 0 || input.player_hp <= 0 || input.player_hp > input.player_max_hp {
|
||||
return Err(CombatFieldError::InvalidPlayerVitals);
|
||||
}
|
||||
if input.player_max_mana < 0
|
||||
|| input.player_mana < 0
|
||||
|| input.player_mana > input.player_max_mana
|
||||
{
|
||||
return Err(CombatFieldError::InvalidPlayerVitals);
|
||||
}
|
||||
if input.target_max_hp <= 0 || input.target_hp <= 0 || input.target_hp > input.target_max_hp {
|
||||
return Err(CombatFieldError::InvalidTargetVitals);
|
||||
}
|
||||
for reward_item in input.reward_items.iter().cloned() {
|
||||
normalize_reward_item_snapshot(reward_item).map_err(map_reward_item_field_error)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_resolve_combat_action_input(
|
||||
input: &ResolveCombatActionInput,
|
||||
) -> Result<(), CombatFieldError> {
|
||||
if normalize_required_string(&input.battle_state_id).is_none() {
|
||||
return Err(CombatFieldError::MissingBattleStateId);
|
||||
}
|
||||
if normalize_required_string(&input.function_id).is_none() {
|
||||
return Err(CombatFieldError::MissingFunctionId);
|
||||
}
|
||||
if !is_supported_combat_function_id(&input.function_id) {
|
||||
return Err(CombatFieldError::UnsupportedFunctionId);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn build_battle_state_query_input(
|
||||
battle_state_id: String,
|
||||
) -> Result<BattleStateQueryInput, CombatFieldError> {
|
||||
let input = BattleStateQueryInput {
|
||||
battle_state_id: normalize_required_string(battle_state_id).unwrap_or_default(),
|
||||
};
|
||||
|
||||
validate_battle_state_query_input(&input)?;
|
||||
|
||||
Ok(input)
|
||||
}
|
||||
|
||||
pub fn validate_battle_state_query_input(
|
||||
input: &BattleStateQueryInput,
|
||||
) -> Result<(), CombatFieldError> {
|
||||
if normalize_required_string(&input.battle_state_id).is_none() {
|
||||
return Err(CombatFieldError::MissingBattleStateId);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_supported_combat_function_id(function_id: &str) -> bool {
|
||||
matches!(
|
||||
function_id,
|
||||
"battle_attack_basic"
|
||||
| "battle_recover_breath"
|
||||
| "battle_use_skill"
|
||||
| "battle_escape_breakout"
|
||||
) || LEGACY_ATTACK_FUNCTION_IDS.contains(&function_id)
|
||||
}
|
||||
|
||||
fn map_reward_item_field_error(error: TreasureFieldError) -> CombatFieldError {
|
||||
let message = match error {
|
||||
TreasureFieldError::MissingRewardItemId => {
|
||||
"battle_state.reward_items[].item_id 不能为空".to_string()
|
||||
}
|
||||
TreasureFieldError::MissingRewardItemCategory => {
|
||||
"battle_state.reward_items[].category 不能为空".to_string()
|
||||
}
|
||||
TreasureFieldError::MissingRewardItemName => {
|
||||
"battle_state.reward_items[].item_name 不能为空".to_string()
|
||||
}
|
||||
TreasureFieldError::InvalidRewardItemQuantity => {
|
||||
"battle_state.reward_items[].quantity 必须大于 0".to_string()
|
||||
}
|
||||
TreasureFieldError::MissingRewardItemStackKey => {
|
||||
"battle_state.reward_items[].stack_key 不能为空".to_string()
|
||||
}
|
||||
TreasureFieldError::RewardEquipmentItemCannotStack => {
|
||||
"battle_state.reward_items[] 可装备物品不能标记为 stackable".to_string()
|
||||
}
|
||||
TreasureFieldError::RewardNonStackableItemMustStaySingleQuantity => {
|
||||
"battle_state.reward_items[] 不可堆叠物品必须固定为单槽位单数量".to_string()
|
||||
}
|
||||
other => other.to_string(),
|
||||
};
|
||||
|
||||
CombatFieldError::InvalidRewardItem(message)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! 战斗领域模型过渡落位。
|
||||
//! 战斗领域模型。
|
||||
//!
|
||||
//! 后续迁移 `BattleState` 与行动结算规则时,只保留单聚合内部状态变化;
|
||||
//! 背包奖励、成长记账和任务联动由应用服务或 SpacetimeDB 事务 adapter 编排。
|
||||
//! 本文件只承载战斗聚合内部状态和值对象;背包奖励、成长记账和任务联动由
|
||||
//! SpacetimeDB 事务 adapter 编排,不在战斗领域内直连其他上下文。
|
||||
|
||||
use module_runtime_item::RuntimeItemRewardItemSnapshot;
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[cfg(feature = "spacetime-types")]
|
||||
use spacetimedb::SpacetimeType;
|
||||
@@ -83,3 +84,35 @@ impl CombatOutcome {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BattleStateSnapshot {
|
||||
pub battle_state_id: String,
|
||||
pub story_session_id: String,
|
||||
pub runtime_session_id: String,
|
||||
pub actor_user_id: String,
|
||||
pub chapter_id: Option<String>,
|
||||
pub target_npc_id: String,
|
||||
pub target_name: String,
|
||||
pub battle_mode: BattleMode,
|
||||
pub status: BattleStatus,
|
||||
pub player_hp: i32,
|
||||
pub player_max_hp: i32,
|
||||
pub player_mana: i32,
|
||||
pub player_max_mana: i32,
|
||||
pub target_hp: i32,
|
||||
pub target_max_hp: i32,
|
||||
pub experience_reward: u32,
|
||||
pub reward_items: Vec<RuntimeItemRewardItemSnapshot>,
|
||||
pub turn_index: u32,
|
||||
pub last_action_function_id: Option<String>,
|
||||
pub last_action_text: Option<String>,
|
||||
pub last_result_text: Option<String>,
|
||||
pub last_damage_dealt: i32,
|
||||
pub last_damage_taken: i32,
|
||||
pub last_outcome: CombatOutcome,
|
||||
pub version: u32,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
@@ -1,3 +1,50 @@
|
||||
//! 战斗领域错误过渡落位。
|
||||
//! 战斗领域错误。
|
||||
//!
|
||||
//! 错误保持纯领域语义,不能绑定 HTTP 状态码或 SpacetimeDB 字符串格式。
|
||||
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum CombatFieldError {
|
||||
MissingBattleStateId,
|
||||
MissingStorySessionId,
|
||||
MissingRuntimeSessionId,
|
||||
MissingActorUserId,
|
||||
MissingTargetNpcId,
|
||||
MissingTargetName,
|
||||
MissingFunctionId,
|
||||
InvalidVersion,
|
||||
InvalidPlayerVitals,
|
||||
InvalidTargetVitals,
|
||||
InvalidRewardItem(String),
|
||||
BattleAlreadyResolved,
|
||||
UnsupportedFunctionId,
|
||||
InsufficientMana,
|
||||
}
|
||||
|
||||
impl fmt::Display for CombatFieldError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::MissingBattleStateId => f.write_str("battle_state.battle_state_id 不能为空"),
|
||||
Self::MissingStorySessionId => f.write_str("battle_state.story_session_id 不能为空"),
|
||||
Self::MissingRuntimeSessionId => {
|
||||
f.write_str("battle_state.runtime_session_id 不能为空")
|
||||
}
|
||||
Self::MissingActorUserId => f.write_str("battle_state.actor_user_id 不能为空"),
|
||||
Self::MissingTargetNpcId => f.write_str("battle_state.target_npc_id 不能为空"),
|
||||
Self::MissingTargetName => f.write_str("battle_state.target_name 不能为空"),
|
||||
Self::MissingFunctionId => f.write_str("resolve_combat_action.function_id 不能为空"),
|
||||
Self::InvalidVersion => f.write_str("battle_state.version 必须大于 0"),
|
||||
Self::InvalidPlayerVitals => f.write_str("battle_state 玩家生命或灵力字段不合法"),
|
||||
Self::InvalidTargetVitals => f.write_str("battle_state 目标生命字段不合法"),
|
||||
Self::InvalidRewardItem(message) => f.write_str(message),
|
||||
Self::BattleAlreadyResolved => f.write_str("battle_state 已经结束,不能继续结算"),
|
||||
Self::UnsupportedFunctionId => {
|
||||
f.write_str("resolve_combat_action.function_id 当前不受支持")
|
||||
}
|
||||
Self::InsufficientMana => f.write_str("当前灵力不足,无法执行该战斗动作"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for CombatFieldError {}
|
||||
|
||||
@@ -1,3 +1,34 @@
|
||||
//! 战斗领域事件过渡落位。
|
||||
//! 战斗领域事件。
|
||||
//!
|
||||
//! 用于表达战斗胜利、切磋完成、奖励待发放和战斗被终止等事实。
|
||||
|
||||
use crate::domain::CombatOutcome;
|
||||
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 CombatDomainEvent {
|
||||
BattleActionResolved(CombatBattleActionResolvedEvent),
|
||||
BattleRewardPending(CombatBattleRewardPendingEvent),
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CombatBattleActionResolvedEvent {
|
||||
pub battle_state_id: String,
|
||||
pub outcome: CombatOutcome,
|
||||
pub damage_dealt: i32,
|
||||
pub damage_taken: i32,
|
||||
pub occurred_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CombatBattleRewardPendingEvent {
|
||||
pub battle_state_id: String,
|
||||
pub actor_user_id: String,
|
||||
pub experience_reward: u32,
|
||||
pub occurred_at_micros: i64,
|
||||
}
|
||||
|
||||
@@ -4,531 +4,16 @@ mod domain;
|
||||
mod errors;
|
||||
mod events;
|
||||
|
||||
pub use application::*;
|
||||
pub use commands::*;
|
||||
pub use domain::*;
|
||||
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
use crate::domain::LEGACY_ATTACK_FUNCTION_IDS;
|
||||
use module_runtime_item::{
|
||||
RuntimeItemRewardItemSnapshot, TreasureFieldError, normalize_reward_item_snapshot,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared_kernel::{build_prefixed_seed_id, normalize_required_string};
|
||||
#[cfg(feature = "spacetime-types")]
|
||||
use spacetimedb::SpacetimeType;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum CombatFieldError {
|
||||
MissingBattleStateId,
|
||||
MissingStorySessionId,
|
||||
MissingRuntimeSessionId,
|
||||
MissingActorUserId,
|
||||
MissingTargetNpcId,
|
||||
MissingTargetName,
|
||||
MissingFunctionId,
|
||||
InvalidVersion,
|
||||
InvalidPlayerVitals,
|
||||
InvalidTargetVitals,
|
||||
InvalidRewardItem(String),
|
||||
BattleAlreadyResolved,
|
||||
UnsupportedFunctionId,
|
||||
InsufficientMana,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BattleStateInput {
|
||||
pub battle_state_id: String,
|
||||
pub story_session_id: String,
|
||||
pub runtime_session_id: String,
|
||||
pub actor_user_id: String,
|
||||
pub chapter_id: Option<String>,
|
||||
pub target_npc_id: String,
|
||||
pub target_name: String,
|
||||
pub battle_mode: BattleMode,
|
||||
pub player_hp: i32,
|
||||
pub player_max_hp: i32,
|
||||
pub player_mana: i32,
|
||||
pub player_max_mana: i32,
|
||||
pub target_hp: i32,
|
||||
pub target_max_hp: i32,
|
||||
pub experience_reward: u32,
|
||||
pub reward_items: Vec<RuntimeItemRewardItemSnapshot>,
|
||||
pub created_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BattleStateSnapshot {
|
||||
pub battle_state_id: String,
|
||||
pub story_session_id: String,
|
||||
pub runtime_session_id: String,
|
||||
pub actor_user_id: String,
|
||||
pub chapter_id: Option<String>,
|
||||
pub target_npc_id: String,
|
||||
pub target_name: String,
|
||||
pub battle_mode: BattleMode,
|
||||
pub status: BattleStatus,
|
||||
pub player_hp: i32,
|
||||
pub player_max_hp: i32,
|
||||
pub player_mana: i32,
|
||||
pub player_max_mana: i32,
|
||||
pub target_hp: i32,
|
||||
pub target_max_hp: i32,
|
||||
pub experience_reward: u32,
|
||||
pub reward_items: Vec<RuntimeItemRewardItemSnapshot>,
|
||||
pub turn_index: u32,
|
||||
pub last_action_function_id: Option<String>,
|
||||
pub last_action_text: Option<String>,
|
||||
pub last_result_text: Option<String>,
|
||||
pub last_damage_dealt: i32,
|
||||
pub last_damage_taken: i32,
|
||||
pub last_outcome: CombatOutcome,
|
||||
pub version: u32,
|
||||
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 ResolveCombatActionInput {
|
||||
pub battle_state_id: String,
|
||||
pub function_id: String,
|
||||
pub action_text: String,
|
||||
pub base_damage: i32,
|
||||
pub mana_cost: i32,
|
||||
pub heal: i32,
|
||||
pub mana_restore: i32,
|
||||
pub counter_multiplier_basis_points: u32,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BattleStateQueryInput {
|
||||
pub battle_state_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ResolveCombatActionResult {
|
||||
pub snapshot: BattleStateSnapshot,
|
||||
pub damage_dealt: i32,
|
||||
pub damage_taken: i32,
|
||||
pub outcome: CombatOutcome,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BattleStateProcedureResult {
|
||||
pub ok: bool,
|
||||
pub snapshot: Option<BattleStateSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ResolveCombatActionProcedureResult {
|
||||
pub ok: bool,
|
||||
pub result: Option<ResolveCombatActionResult>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
pub fn validate_battle_state_input(input: &BattleStateInput) -> Result<(), CombatFieldError> {
|
||||
if normalize_required_string(&input.battle_state_id).is_none() {
|
||||
return Err(CombatFieldError::MissingBattleStateId);
|
||||
}
|
||||
if normalize_required_string(&input.story_session_id).is_none() {
|
||||
return Err(CombatFieldError::MissingStorySessionId);
|
||||
}
|
||||
if normalize_required_string(&input.runtime_session_id).is_none() {
|
||||
return Err(CombatFieldError::MissingRuntimeSessionId);
|
||||
}
|
||||
if normalize_required_string(&input.actor_user_id).is_none() {
|
||||
return Err(CombatFieldError::MissingActorUserId);
|
||||
}
|
||||
if normalize_required_string(&input.target_npc_id).is_none() {
|
||||
return Err(CombatFieldError::MissingTargetNpcId);
|
||||
}
|
||||
if normalize_required_string(&input.target_name).is_none() {
|
||||
return Err(CombatFieldError::MissingTargetName);
|
||||
}
|
||||
if input.player_max_hp <= 0 || input.player_hp <= 0 || input.player_hp > input.player_max_hp {
|
||||
return Err(CombatFieldError::InvalidPlayerVitals);
|
||||
}
|
||||
if input.player_max_mana < 0
|
||||
|| input.player_mana < 0
|
||||
|| input.player_mana > input.player_max_mana
|
||||
{
|
||||
return Err(CombatFieldError::InvalidPlayerVitals);
|
||||
}
|
||||
if input.target_max_hp <= 0 || input.target_hp <= 0 || input.target_hp > input.target_max_hp {
|
||||
return Err(CombatFieldError::InvalidTargetVitals);
|
||||
}
|
||||
for reward_item in input.reward_items.iter().cloned() {
|
||||
normalize_reward_item_snapshot(reward_item).map_err(map_reward_item_field_error)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_resolve_combat_action_input(
|
||||
input: &ResolveCombatActionInput,
|
||||
) -> Result<(), CombatFieldError> {
|
||||
if normalize_required_string(&input.battle_state_id).is_none() {
|
||||
return Err(CombatFieldError::MissingBattleStateId);
|
||||
}
|
||||
if normalize_required_string(&input.function_id).is_none() {
|
||||
return Err(CombatFieldError::MissingFunctionId);
|
||||
}
|
||||
if !is_supported_combat_function_id(&input.function_id) {
|
||||
return Err(CombatFieldError::UnsupportedFunctionId);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn build_battle_state_query_input(
|
||||
battle_state_id: String,
|
||||
) -> Result<BattleStateQueryInput, CombatFieldError> {
|
||||
let input = BattleStateQueryInput {
|
||||
battle_state_id: normalize_required_string(battle_state_id).unwrap_or_default(),
|
||||
};
|
||||
|
||||
validate_battle_state_query_input(&input)?;
|
||||
|
||||
Ok(input)
|
||||
}
|
||||
|
||||
pub fn validate_battle_state_query_input(
|
||||
input: &BattleStateQueryInput,
|
||||
) -> Result<(), CombatFieldError> {
|
||||
if normalize_required_string(&input.battle_state_id).is_none() {
|
||||
return Err(CombatFieldError::MissingBattleStateId);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn build_battle_state_snapshot(input: BattleStateInput) -> BattleStateSnapshot {
|
||||
BattleStateSnapshot {
|
||||
battle_state_id: input.battle_state_id,
|
||||
story_session_id: input.story_session_id,
|
||||
runtime_session_id: input.runtime_session_id,
|
||||
actor_user_id: input.actor_user_id,
|
||||
chapter_id: input.chapter_id,
|
||||
target_npc_id: input.target_npc_id,
|
||||
target_name: input.target_name,
|
||||
battle_mode: input.battle_mode,
|
||||
status: BattleStatus::Ongoing,
|
||||
player_hp: input.player_hp,
|
||||
player_max_hp: input.player_max_hp,
|
||||
player_mana: input.player_mana,
|
||||
player_max_mana: input.player_max_mana,
|
||||
target_hp: input.target_hp,
|
||||
target_max_hp: input.target_max_hp,
|
||||
experience_reward: input.experience_reward,
|
||||
reward_items: input.reward_items,
|
||||
turn_index: 0,
|
||||
last_action_function_id: None,
|
||||
last_action_text: None,
|
||||
last_result_text: None,
|
||||
last_damage_dealt: 0,
|
||||
last_damage_taken: 0,
|
||||
last_outcome: CombatOutcome::Ongoing,
|
||||
version: INITIAL_BATTLE_VERSION,
|
||||
created_at_micros: input.created_at_micros,
|
||||
updated_at_micros: input.created_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_combat_action(
|
||||
current: BattleStateSnapshot,
|
||||
input: ResolveCombatActionInput,
|
||||
) -> Result<ResolveCombatActionResult, CombatFieldError> {
|
||||
validate_resolve_combat_action_input(&input)?;
|
||||
|
||||
if current.version == 0 {
|
||||
return Err(CombatFieldError::InvalidVersion);
|
||||
}
|
||||
if current.status != BattleStatus::Ongoing {
|
||||
return Err(CombatFieldError::BattleAlreadyResolved);
|
||||
}
|
||||
if current.player_mana < input.mana_cost.max(0) {
|
||||
return Err(CombatFieldError::InsufficientMana);
|
||||
}
|
||||
|
||||
let action_text = if input.action_text.trim().is_empty() {
|
||||
input.function_id.clone()
|
||||
} else {
|
||||
normalize_required_string(input.action_text).unwrap_or_else(|| input.function_id.clone())
|
||||
};
|
||||
|
||||
if input.function_id == "battle_escape_breakout" {
|
||||
let next = BattleStateSnapshot {
|
||||
status: BattleStatus::Resolved,
|
||||
turn_index: current.turn_index + 1,
|
||||
last_action_function_id: Some(input.function_id),
|
||||
last_action_text: Some(action_text),
|
||||
last_result_text: Some(format!("你抓住空当摆脱了{}的压制。", current.target_name)),
|
||||
last_damage_dealt: 0,
|
||||
last_damage_taken: 0,
|
||||
last_outcome: CombatOutcome::Escaped,
|
||||
version: current.version + 1,
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
..current
|
||||
};
|
||||
|
||||
return Ok(ResolveCombatActionResult {
|
||||
snapshot: next,
|
||||
damage_dealt: 0,
|
||||
damage_taken: 0,
|
||||
outcome: CombatOutcome::Escaped,
|
||||
});
|
||||
}
|
||||
|
||||
let mana_cost = input.mana_cost.max(0);
|
||||
let heal = input.heal.max(0);
|
||||
let mana_restore = input.mana_restore.max(0);
|
||||
let base_damage = input.base_damage.max(0);
|
||||
|
||||
let mut next_player_hp = current.player_hp;
|
||||
let mut next_player_mana = (current.player_mana - mana_cost).max(0);
|
||||
let mut next_target_hp = current.target_hp;
|
||||
let mut damage_dealt = 0;
|
||||
let mut damage_taken = 0;
|
||||
|
||||
next_player_hp = clamp_hp(
|
||||
current.battle_mode,
|
||||
next_player_hp + heal,
|
||||
current.player_max_hp,
|
||||
);
|
||||
next_player_mana = clamp_mana(next_player_mana + mana_restore, current.player_max_mana);
|
||||
|
||||
if base_damage > 0 {
|
||||
next_target_hp =
|
||||
clamp_target_hp_after_damage(current.battle_mode, current.target_hp, base_damage);
|
||||
damage_dealt = current.target_hp - next_target_hp;
|
||||
}
|
||||
|
||||
let (status, outcome, result_text) = if is_target_resolved(current.battle_mode, next_target_hp)
|
||||
{
|
||||
let outcome = match current.battle_mode {
|
||||
BattleMode::Fight => CombatOutcome::Victory,
|
||||
BattleMode::Spar => CombatOutcome::SparComplete,
|
||||
};
|
||||
|
||||
(
|
||||
BattleStatus::Resolved,
|
||||
outcome,
|
||||
build_resolved_result_text(&action_text, ¤t.target_name, outcome),
|
||||
)
|
||||
} else {
|
||||
damage_taken = compute_counter_damage(
|
||||
current.battle_mode,
|
||||
current.target_max_hp,
|
||||
input.counter_multiplier_basis_points,
|
||||
);
|
||||
next_player_hp = clamp_hp(
|
||||
current.battle_mode,
|
||||
next_player_hp - damage_taken,
|
||||
current.player_max_hp,
|
||||
);
|
||||
|
||||
(
|
||||
BattleStatus::Ongoing,
|
||||
CombatOutcome::Ongoing,
|
||||
build_ongoing_result_text(&input.function_id, &action_text, ¤t.target_name),
|
||||
)
|
||||
};
|
||||
|
||||
let next = BattleStateSnapshot {
|
||||
player_hp: next_player_hp,
|
||||
player_mana: next_player_mana,
|
||||
target_hp: next_target_hp,
|
||||
status,
|
||||
turn_index: current.turn_index + 1,
|
||||
last_action_function_id: Some(input.function_id),
|
||||
last_action_text: Some(action_text),
|
||||
last_result_text: Some(result_text),
|
||||
last_damage_dealt: damage_dealt,
|
||||
last_damage_taken: damage_taken,
|
||||
last_outcome: outcome,
|
||||
version: current.version + 1,
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
..current
|
||||
};
|
||||
|
||||
Ok(ResolveCombatActionResult {
|
||||
snapshot: next,
|
||||
damage_dealt,
|
||||
damage_taken,
|
||||
outcome,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn generate_battle_state_id(seed_micros: i64) -> String {
|
||||
build_prefixed_seed_id(BATTLE_STATE_ID_PREFIX, seed_micros)
|
||||
}
|
||||
|
||||
pub fn is_supported_combat_function_id(function_id: &str) -> bool {
|
||||
matches!(
|
||||
function_id,
|
||||
"battle_attack_basic"
|
||||
| "battle_recover_breath"
|
||||
| "battle_use_skill"
|
||||
| "battle_escape_breakout"
|
||||
) || LEGACY_ATTACK_FUNCTION_IDS.contains(&function_id)
|
||||
}
|
||||
|
||||
fn clamp_hp(mode: BattleMode, value: i32, max_hp: i32) -> i32 {
|
||||
let min_hp = match mode {
|
||||
BattleMode::Fight => 0,
|
||||
BattleMode::Spar => SPAR_MIN_HP,
|
||||
};
|
||||
|
||||
value.clamp(min_hp, max_hp)
|
||||
}
|
||||
|
||||
fn clamp_mana(value: i32, max_mana: i32) -> i32 {
|
||||
value.clamp(0, max_mana)
|
||||
}
|
||||
|
||||
fn clamp_target_hp_after_damage(mode: BattleMode, current_hp: i32, damage: i32) -> i32 {
|
||||
match mode {
|
||||
BattleMode::Fight => (current_hp - damage).max(0),
|
||||
BattleMode::Spar => (current_hp - damage).max(SPAR_MIN_HP),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_target_resolved(mode: BattleMode, target_hp: i32) -> bool {
|
||||
match mode {
|
||||
BattleMode::Fight => target_hp <= 0,
|
||||
BattleMode::Spar => target_hp <= SPAR_MIN_HP,
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_counter_damage(
|
||||
mode: BattleMode,
|
||||
target_max_hp: i32,
|
||||
counter_multiplier_basis_points: u32,
|
||||
) -> i32 {
|
||||
match mode {
|
||||
BattleMode::Spar => 1,
|
||||
BattleMode::Fight => {
|
||||
let multiplier = counter_multiplier_basis_points as f32 / 10_000.0;
|
||||
let raw =
|
||||
(target_max_hp as f32 * BASIC_FIGHT_COUNTER_RATIO * multiplier).round() as i32;
|
||||
raw.max(MIN_FIGHT_COUNTER_DAMAGE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_resolved_result_text(
|
||||
action_text: &str,
|
||||
target_name: &str,
|
||||
outcome: CombatOutcome,
|
||||
) -> String {
|
||||
match outcome {
|
||||
CombatOutcome::Victory => {
|
||||
format!(
|
||||
"{}命中了{},这轮战斗已经正式结束。",
|
||||
action_text, target_name
|
||||
)
|
||||
}
|
||||
CombatOutcome::SparComplete => {
|
||||
format!(
|
||||
"{}压住了{}的节奏,这场切磋已经分出高下。",
|
||||
action_text, target_name
|
||||
)
|
||||
}
|
||||
CombatOutcome::Escaped => {
|
||||
format!("{}后你成功脱离了当前战斗。", action_text)
|
||||
}
|
||||
CombatOutcome::Ongoing => format!("{}已完成结算。", action_text),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ongoing_result_text(function_id: &str, action_text: &str, target_name: &str) -> String {
|
||||
match function_id {
|
||||
"battle_recover_breath" => {
|
||||
format!(
|
||||
"你先把伤势和气息稳住了一轮,但{}仍在持续逼近。",
|
||||
target_name
|
||||
)
|
||||
}
|
||||
"battle_use_skill" => {
|
||||
format!(
|
||||
"{}命中了{},这一轮技能效果已经直接结算。",
|
||||
action_text, target_name
|
||||
)
|
||||
}
|
||||
_ => format!(
|
||||
"{}命中了{},本次攻击已经完成结算。",
|
||||
action_text, target_name
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_reward_item_field_error(error: TreasureFieldError) -> CombatFieldError {
|
||||
let message = match error {
|
||||
TreasureFieldError::MissingRewardItemId => {
|
||||
"battle_state.reward_items[].item_id 不能为空".to_string()
|
||||
}
|
||||
TreasureFieldError::MissingRewardItemCategory => {
|
||||
"battle_state.reward_items[].category 不能为空".to_string()
|
||||
}
|
||||
TreasureFieldError::MissingRewardItemName => {
|
||||
"battle_state.reward_items[].item_name 不能为空".to_string()
|
||||
}
|
||||
TreasureFieldError::InvalidRewardItemQuantity => {
|
||||
"battle_state.reward_items[].quantity 必须大于 0".to_string()
|
||||
}
|
||||
TreasureFieldError::MissingRewardItemStackKey => {
|
||||
"battle_state.reward_items[].stack_key 不能为空".to_string()
|
||||
}
|
||||
TreasureFieldError::RewardEquipmentItemCannotStack => {
|
||||
"battle_state.reward_items[] 可装备物品不能标记为 stackable".to_string()
|
||||
}
|
||||
TreasureFieldError::RewardNonStackableItemMustStaySingleQuantity => {
|
||||
"battle_state.reward_items[] 不可堆叠物品必须固定为单槽位单数量".to_string()
|
||||
}
|
||||
other => other.to_string(),
|
||||
};
|
||||
|
||||
CombatFieldError::InvalidRewardItem(message)
|
||||
}
|
||||
|
||||
impl fmt::Display for CombatFieldError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::MissingBattleStateId => f.write_str("battle_state.battle_state_id 不能为空"),
|
||||
Self::MissingStorySessionId => f.write_str("battle_state.story_session_id 不能为空"),
|
||||
Self::MissingRuntimeSessionId => {
|
||||
f.write_str("battle_state.runtime_session_id 不能为空")
|
||||
}
|
||||
Self::MissingActorUserId => f.write_str("battle_state.actor_user_id 不能为空"),
|
||||
Self::MissingTargetNpcId => f.write_str("battle_state.target_npc_id 不能为空"),
|
||||
Self::MissingTargetName => f.write_str("battle_state.target_name 不能为空"),
|
||||
Self::MissingFunctionId => f.write_str("resolve_combat_action.function_id 不能为空"),
|
||||
Self::InvalidVersion => f.write_str("battle_state.version 必须大于 0"),
|
||||
Self::InvalidPlayerVitals => f.write_str("battle_state 玩家生命或灵力字段不合法"),
|
||||
Self::InvalidTargetVitals => f.write_str("battle_state 目标生命字段不合法"),
|
||||
Self::InvalidRewardItem(message) => f.write_str(message),
|
||||
Self::BattleAlreadyResolved => f.write_str("battle_state 已经结束,不能继续结算"),
|
||||
Self::UnsupportedFunctionId => {
|
||||
f.write_str("resolve_combat_action.function_id 当前不受支持")
|
||||
}
|
||||
Self::InsufficientMana => f.write_str("当前灵力不足,无法执行该战斗动作"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for CombatFieldError {}
|
||||
pub use errors::*;
|
||||
pub use events::*;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use module_runtime_item::RuntimeItemRewardItemSnapshot;
|
||||
|
||||
fn build_fight_snapshot() -> BattleStateSnapshot {
|
||||
build_battle_state_snapshot(BattleStateInput {
|
||||
|
||||
@@ -14,42 +14,41 @@
|
||||
|
||||
## 2. 当前阶段说明
|
||||
|
||||
当前阶段已经不再是单纯目录占位,而是先把 `M5` 首批 `custom world / agent` 类型契约与字段校验固定下来,避免 `spacetime-module` 在缺少领域边界的情况下直接堆表。
|
||||
当前阶段已经不再是单纯目录占位,`custom world / agent` 类型契约、字段校验、发布编译规则和 Agent action 应用结果已经固定到 DDD 骨架中,避免 `spacetime-module` 在缺少领域边界的情况下直接堆表。
|
||||
|
||||
当前已落地:
|
||||
|
||||
1. 真实 `Cargo.toml` crate scaffold
|
||||
2. `src/domain.rs` 承接 `CustomWorldPublicationStatus`、`CustomWorldThemeMode`、`CustomWorldGenerationMode`
|
||||
3. `CustomWorldSessionStatus`、`RpgAgentStage`
|
||||
4. `RpgAgentMessageRole`、`RpgAgentMessageKind`
|
||||
5. `RpgAgentOperationType`、`RpgAgentOperationStatus`
|
||||
6. `RpgAgentDraftCardKind`、`RpgAgentDraftCardStatus`
|
||||
7. `CustomWorldRoleAssetStatus`
|
||||
8. 首批表字段校验函数与最小单测
|
||||
9. `published profile compile` 输入输出 contract
|
||||
10. `publish_world` 串联输入输出 contract
|
||||
2. `src/domain.rs` 承接基础枚举、进度常量、profile/session/card/gallery/publish gate 快照与结果类型。
|
||||
3. `src/commands.rs` 承接 profile、library/gallery、Agent session/message/operation/action、published profile compile 和 publish world 输入 DTO。
|
||||
4. `src/application.rs` 承接字段校验、默认 JSON、profile canonicalize、published profile compile 和 publish gate 相关纯规则。
|
||||
5. `src/errors.rs` 承接 `CustomWorldFieldError` 与中文错误文案。
|
||||
6. `src/events.rs` 承接 Custom World 领域事件与 payload struct。
|
||||
7. `src/lib.rs` 只保留模块声明、公开导出和测试,继续保持 `module_custom_world::*` 公开 API。
|
||||
8. `spacetime-module` 中 `generate_characters`、`generate_landmarks`、`generate_role_assets`、`sync_role_assets`、`generate_scene_assets`、`sync_scene_assets`、`expand_long_tail` 已移除最小兼容占位,改为确定性状态编排。
|
||||
|
||||
当前 crate 仍然只承接:
|
||||
|
||||
1. 共享枚举、进度常量与类型口径,基础枚举统一从 `src/domain.rs` 导出
|
||||
2. 字段校验与字符串归一化
|
||||
3. published profile compile 的最小编译摘要 contract
|
||||
4. 后续 `spacetime-module` 聚合表时需要复用的领域边界
|
||||
1. 共享枚举、进度常量与类型口径,基础枚举统一从 `src/domain.rs` 导出。
|
||||
2. 字段校验、字符串归一化与发布编译纯规则。
|
||||
3. published profile compile 与 publish world 的输入输出 contract。
|
||||
4. 后续 `spacetime-module` 聚合表时需要复用的领域边界。
|
||||
|
||||
当前阶段明确不提前进入:
|
||||
|
||||
1. 旧问答流 reducer 编排
|
||||
2. RPG 创作 Agent 编排
|
||||
3. publish gate blocker 规则迁移
|
||||
4. 资产绑定与图片生成副作用
|
||||
2. 外部 LLM 创作编排、图片生成、OSS 上传和 SSE 推送。
|
||||
3. 资产对象真相表、资产绑定表和完整资产历史。
|
||||
4. 前端创作流程和 UI 表现状态。
|
||||
|
||||
当前设计依据:
|
||||
|
||||
1. [../../../docs/technical/SERVER_RS_DDD_WP_CW_DOMAIN_ENUM_REHOME_2026-04-29.md](../../../docs/technical/SERVER_RS_DDD_WP_CW_DOMAIN_ENUM_REHOME_2026-04-29.md)
|
||||
2. [../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md)
|
||||
3. [../../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md](../../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md)
|
||||
4. [../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISHED_PROFILE_COMPILE_STAGE3_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISHED_PROFILE_COMPILE_STAGE3_DESIGN_2026-04-21.md)
|
||||
5. [../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISH_WORLD_STAGE4_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISH_WORLD_STAGE4_DESIGN_2026-04-21.md)
|
||||
2. [../../../docs/technical/SERVER_RS_DDD_WP_CW_ACTION_AND_DOMAIN_SPLIT_2026-04-30.md](../../../docs/technical/SERVER_RS_DDD_WP_CW_ACTION_AND_DOMAIN_SPLIT_2026-04-30.md)
|
||||
3. [../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md)
|
||||
4. [../../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md](../../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md)
|
||||
5. [../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISHED_PROFILE_COMPILE_STAGE3_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISHED_PROFILE_COMPILE_STAGE3_DESIGN_2026-04-21.md)
|
||||
6. [../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISH_WORLD_STAGE4_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISH_WORLD_STAGE4_DESIGN_2026-04-21.md)
|
||||
|
||||
后续与本 package 直接相关的任务包括:
|
||||
|
||||
|
||||
@@ -1,3 +1,957 @@
|
||||
//! 自定义世界应用编排过渡落位。
|
||||
//! 自定义世界应用规则。
|
||||
//!
|
||||
//! 这里只组合领域规则并返回草稿、发布门禁、投影刷新等结果或事件。
|
||||
//! 这里只组合纯领域校验、草稿编译、profile 归一化和默认 JSON 结构,不承接外部副作用。
|
||||
|
||||
use crate::{commands::*, domain::*, errors::CustomWorldFieldError};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
pub fn validate_custom_world_profile_fields(
|
||||
profile_id: &str,
|
||||
owner_user_id: &str,
|
||||
world_name: &str,
|
||||
profile_payload_json: &str,
|
||||
) -> Result<(), CustomWorldFieldError> {
|
||||
if profile_id.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingProfileId);
|
||||
}
|
||||
if owner_user_id.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingOwnerUserId);
|
||||
}
|
||||
if world_name.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingWorldName);
|
||||
}
|
||||
if profile_payload_json.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingProfilePayloadJson);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_custom_world_published_profile_compile_input(
|
||||
input: &CustomWorldPublishedProfileCompileInput,
|
||||
) -> Result<(), CustomWorldFieldError> {
|
||||
if input.session_id.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingSessionId);
|
||||
}
|
||||
if input.profile_id.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingProfileId);
|
||||
}
|
||||
if input.owner_user_id.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingOwnerUserId);
|
||||
}
|
||||
if input.draft_profile_json.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingDraftProfileJson);
|
||||
}
|
||||
if input.setting_text.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingSettingText);
|
||||
}
|
||||
if input.author_display_name.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingAuthorDisplayName);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_custom_world_publish_world_input(
|
||||
input: &CustomWorldPublishWorldInput,
|
||||
) -> Result<(), CustomWorldFieldError> {
|
||||
if input.author_public_user_code.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingOwnerUserId);
|
||||
}
|
||||
validate_custom_world_published_profile_compile_input(
|
||||
&CustomWorldPublishedProfileCompileInput {
|
||||
session_id: input.session_id.clone(),
|
||||
profile_id: input.profile_id.clone(),
|
||||
owner_user_id: input.owner_user_id.clone(),
|
||||
draft_profile_json: input.draft_profile_json.clone(),
|
||||
legacy_result_profile_json: input.legacy_result_profile_json.clone(),
|
||||
setting_text: input.setting_text.clone(),
|
||||
author_display_name: input.author_display_name.clone(),
|
||||
updated_at_micros: input.published_at_micros,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn validate_custom_world_profile_upsert_input(
|
||||
input: &CustomWorldProfileUpsertInput,
|
||||
) -> Result<(), CustomWorldFieldError> {
|
||||
validate_custom_world_profile_fields(
|
||||
&input.profile_id,
|
||||
&input.owner_user_id,
|
||||
&input.world_name,
|
||||
&input.profile_payload_json,
|
||||
)?;
|
||||
|
||||
if input.author_display_name.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingAuthorDisplayName);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_custom_world_profile_publish_input(
|
||||
input: &CustomWorldProfilePublishInput,
|
||||
) -> Result<(), CustomWorldFieldError> {
|
||||
if input.profile_id.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingProfileId);
|
||||
}
|
||||
if input.owner_user_id.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingOwnerUserId);
|
||||
}
|
||||
if input.author_display_name.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingAuthorDisplayName);
|
||||
}
|
||||
if input.author_public_user_code.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingOwnerUserId);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_custom_world_profile_unpublish_input(
|
||||
input: &CustomWorldProfileUnpublishInput,
|
||||
) -> Result<(), CustomWorldFieldError> {
|
||||
if input.profile_id.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingProfileId);
|
||||
}
|
||||
if input.owner_user_id.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingOwnerUserId);
|
||||
}
|
||||
if input.author_display_name.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingAuthorDisplayName);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_custom_world_profile_delete_input(
|
||||
input: &CustomWorldProfileDeleteInput,
|
||||
) -> Result<(), CustomWorldFieldError> {
|
||||
if input.profile_id.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingProfileId);
|
||||
}
|
||||
if input.owner_user_id.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingOwnerUserId);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_custom_world_profile_list_input(
|
||||
input: &CustomWorldProfileListInput,
|
||||
) -> Result<(), CustomWorldFieldError> {
|
||||
if input.owner_user_id.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingOwnerUserId);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_custom_world_library_detail_input(
|
||||
input: &CustomWorldLibraryDetailInput,
|
||||
) -> Result<(), CustomWorldFieldError> {
|
||||
if input.owner_user_id.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingOwnerUserId);
|
||||
}
|
||||
if input.profile_id.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingProfileId);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_custom_world_gallery_detail_input(
|
||||
input: &CustomWorldGalleryDetailInput,
|
||||
) -> Result<(), CustomWorldFieldError> {
|
||||
if input.owner_user_id.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingOwnerUserId);
|
||||
}
|
||||
if input.profile_id.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingProfileId);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_custom_world_gallery_detail_by_code_input(
|
||||
input: &CustomWorldGalleryDetailByCodeInput,
|
||||
) -> Result<(), CustomWorldFieldError> {
|
||||
if input.public_work_code.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingPublicWorkCode);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_custom_world_session_fields(
|
||||
session_id: &str,
|
||||
owner_user_id: &str,
|
||||
setting_text: &str,
|
||||
question_snapshot_json: &str,
|
||||
) -> Result<(), CustomWorldFieldError> {
|
||||
if session_id.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingSessionId);
|
||||
}
|
||||
if owner_user_id.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingOwnerUserId);
|
||||
}
|
||||
if setting_text.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingSettingText);
|
||||
}
|
||||
if question_snapshot_json.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingQuestionSnapshotJson);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_custom_world_agent_session_fields(
|
||||
session_id: &str,
|
||||
owner_user_id: &str,
|
||||
anchor_content_json: &str,
|
||||
creator_intent_readiness_json: &str,
|
||||
pending_clarifications_json: &str,
|
||||
asset_coverage_json: &str,
|
||||
progress_percent: u32,
|
||||
) -> Result<(), CustomWorldFieldError> {
|
||||
if session_id.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingSessionId);
|
||||
}
|
||||
if owner_user_id.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingOwnerUserId);
|
||||
}
|
||||
if anchor_content_json.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingAnchorContentJson);
|
||||
}
|
||||
if creator_intent_readiness_json.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingCreatorIntentReadinessJson);
|
||||
}
|
||||
if pending_clarifications_json.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingPendingClarificationsJson);
|
||||
}
|
||||
if asset_coverage_json.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingAssetCoverageJson);
|
||||
}
|
||||
if progress_percent > MAX_PROGRESS_PERCENT {
|
||||
return Err(CustomWorldFieldError::InvalidProgressPercent);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_custom_world_agent_session_create_input(
|
||||
input: &CustomWorldAgentSessionCreateInput,
|
||||
) -> Result<(), CustomWorldFieldError> {
|
||||
validate_custom_world_agent_session_fields(
|
||||
&input.session_id,
|
||||
&input.owner_user_id,
|
||||
&input.anchor_content_json,
|
||||
&input.creator_intent_readiness_json,
|
||||
&input.pending_clarifications_json,
|
||||
&input.asset_coverage_json,
|
||||
0,
|
||||
)?;
|
||||
|
||||
validate_custom_world_agent_message_fields(
|
||||
&input.welcome_message_id,
|
||||
&input.session_id,
|
||||
&input.welcome_message_text,
|
||||
)?;
|
||||
ensure_json_object(&input.anchor_content_json)?;
|
||||
ensure_optional_json_object(input.creator_intent_json.as_deref())?;
|
||||
ensure_json_object(&input.creator_intent_readiness_json)?;
|
||||
ensure_optional_json_object(input.anchor_pack_json.as_deref())?;
|
||||
ensure_optional_json_object(input.lock_state_json.as_deref())?;
|
||||
ensure_optional_json_object(input.draft_profile_json.as_deref())?;
|
||||
ensure_json_array(&input.pending_clarifications_json)?;
|
||||
ensure_json_array(&input.suggested_actions_json)?;
|
||||
ensure_json_array(&input.recommended_replies_json)?;
|
||||
ensure_json_array(&input.quality_findings_json)?;
|
||||
ensure_json_object(&input.asset_coverage_json)?;
|
||||
ensure_json_array(&input.checkpoints_json)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_custom_world_agent_session_get_input(
|
||||
input: &CustomWorldAgentSessionGetInput,
|
||||
) -> Result<(), CustomWorldFieldError> {
|
||||
if input.session_id.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingSessionId);
|
||||
}
|
||||
if input.owner_user_id.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingOwnerUserId);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_custom_world_agent_message_submit_input(
|
||||
input: &CustomWorldAgentMessageSubmitInput,
|
||||
) -> Result<(), CustomWorldFieldError> {
|
||||
if input.owner_user_id.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingOwnerUserId);
|
||||
}
|
||||
|
||||
validate_custom_world_agent_message_fields(
|
||||
&input.user_message_id,
|
||||
&input.session_id,
|
||||
&input.user_message_text,
|
||||
)?;
|
||||
validate_custom_world_agent_operation_fields(
|
||||
&input.operation_id,
|
||||
&input.session_id,
|
||||
"消息已处理",
|
||||
MAX_PROGRESS_PERCENT,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_custom_world_agent_message_finalize_input(
|
||||
input: &CustomWorldAgentMessageFinalizeInput,
|
||||
) -> Result<(), CustomWorldFieldError> {
|
||||
if input.owner_user_id.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingOwnerUserId);
|
||||
}
|
||||
|
||||
match input.operation_status {
|
||||
RpgAgentOperationStatus::Completed => {
|
||||
validate_custom_world_agent_message_fields(
|
||||
input.assistant_message_id.as_deref().unwrap_or_default(),
|
||||
&input.session_id,
|
||||
input.assistant_reply_text.as_deref().unwrap_or_default(),
|
||||
)?;
|
||||
}
|
||||
RpgAgentOperationStatus::Failed => {}
|
||||
_ => {
|
||||
validate_custom_world_agent_message_fields(
|
||||
input.assistant_message_id.as_deref().unwrap_or_default(),
|
||||
&input.session_id,
|
||||
input.assistant_reply_text.as_deref().unwrap_or_default(),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
validate_custom_world_agent_operation_fields(
|
||||
&input.operation_id,
|
||||
&input.session_id,
|
||||
&input.phase_label,
|
||||
input.operation_progress,
|
||||
)?;
|
||||
validate_custom_world_agent_session_fields(
|
||||
&input.session_id,
|
||||
&input.owner_user_id,
|
||||
&input.anchor_content_json,
|
||||
&input.creator_intent_readiness_json,
|
||||
&input.pending_clarifications_json,
|
||||
&input.asset_coverage_json,
|
||||
input.progress_percent,
|
||||
)?;
|
||||
ensure_json_object(&input.anchor_content_json)?;
|
||||
ensure_optional_json_object(input.creator_intent_json.as_deref())?;
|
||||
ensure_json_object(&input.creator_intent_readiness_json)?;
|
||||
ensure_optional_json_object(input.anchor_pack_json.as_deref())?;
|
||||
ensure_optional_json_object(input.draft_profile_json.as_deref())?;
|
||||
ensure_json_array(&input.pending_clarifications_json)?;
|
||||
ensure_json_array(&input.suggested_actions_json)?;
|
||||
ensure_json_array(&input.recommended_replies_json)?;
|
||||
ensure_json_array(&input.quality_findings_json)?;
|
||||
ensure_json_object(&input.asset_coverage_json)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_custom_world_agent_operation_get_input(
|
||||
input: &CustomWorldAgentOperationGetInput,
|
||||
) -> Result<(), CustomWorldFieldError> {
|
||||
if input.session_id.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingSessionId);
|
||||
}
|
||||
if input.owner_user_id.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingOwnerUserId);
|
||||
}
|
||||
if input.operation_id.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingOperationId);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_custom_world_agent_operation_progress_input(
|
||||
input: &CustomWorldAgentOperationProgressInput,
|
||||
) -> Result<(), CustomWorldFieldError> {
|
||||
validate_custom_world_agent_operation_get_input(&CustomWorldAgentOperationGetInput {
|
||||
session_id: input.session_id.clone(),
|
||||
owner_user_id: input.owner_user_id.clone(),
|
||||
operation_id: input.operation_id.clone(),
|
||||
})?;
|
||||
validate_custom_world_agent_operation_fields(
|
||||
&input.operation_id,
|
||||
&input.session_id,
|
||||
&input.phase_label,
|
||||
input.operation_progress,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_custom_world_works_list_input(
|
||||
input: &CustomWorldWorksListInput,
|
||||
) -> Result<(), CustomWorldFieldError> {
|
||||
if input.owner_user_id.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingOwnerUserId);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_custom_world_agent_card_detail_get_input(
|
||||
input: &CustomWorldAgentCardDetailGetInput,
|
||||
) -> Result<(), CustomWorldFieldError> {
|
||||
if input.session_id.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingSessionId);
|
||||
}
|
||||
if input.owner_user_id.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingOwnerUserId);
|
||||
}
|
||||
if input.card_id.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingCardId);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_custom_world_agent_action_execute_input(
|
||||
input: &CustomWorldAgentActionExecuteInput,
|
||||
) -> Result<(), CustomWorldFieldError> {
|
||||
validate_custom_world_agent_operation_get_input(&CustomWorldAgentOperationGetInput {
|
||||
session_id: input.session_id.clone(),
|
||||
owner_user_id: input.owner_user_id.clone(),
|
||||
operation_id: input.operation_id.clone(),
|
||||
})?;
|
||||
if input.action.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingAction);
|
||||
}
|
||||
ensure_optional_json_object(input.payload_json.as_deref())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_custom_world_agent_message_fields(
|
||||
message_id: &str,
|
||||
session_id: &str,
|
||||
text: &str,
|
||||
) -> Result<(), CustomWorldFieldError> {
|
||||
if message_id.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingMessageId);
|
||||
}
|
||||
if session_id.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingSessionId);
|
||||
}
|
||||
if text.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingMessageText);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_custom_world_agent_operation_fields(
|
||||
operation_id: &str,
|
||||
session_id: &str,
|
||||
phase_label: &str,
|
||||
progress: u32,
|
||||
) -> Result<(), CustomWorldFieldError> {
|
||||
if operation_id.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingOperationId);
|
||||
}
|
||||
if session_id.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingSessionId);
|
||||
}
|
||||
if phase_label.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingPhaseLabel);
|
||||
}
|
||||
if progress > MAX_PROGRESS_PERCENT {
|
||||
return Err(CustomWorldFieldError::InvalidProgressPercent);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_custom_world_draft_card_fields(
|
||||
card_id: &str,
|
||||
session_id: &str,
|
||||
title: &str,
|
||||
summary: &str,
|
||||
linked_ids_json: &str,
|
||||
) -> Result<(), CustomWorldFieldError> {
|
||||
if card_id.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingCardId);
|
||||
}
|
||||
if session_id.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingSessionId);
|
||||
}
|
||||
if title.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingCardTitle);
|
||||
}
|
||||
if summary.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingCardSummary);
|
||||
}
|
||||
if linked_ids_json.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingLinkedIdsJson);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_custom_world_gallery_entry_fields(
|
||||
profile_id: &str,
|
||||
owner_user_id: &str,
|
||||
author_display_name: &str,
|
||||
world_name: &str,
|
||||
) -> Result<(), CustomWorldFieldError> {
|
||||
if profile_id.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingProfileId);
|
||||
}
|
||||
if owner_user_id.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingOwnerUserId);
|
||||
}
|
||||
if author_display_name.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingAuthorDisplayName);
|
||||
}
|
||||
if world_name.trim().is_empty() {
|
||||
return Err(CustomWorldFieldError::MissingWorldName);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn build_custom_world_published_profile_compile_snapshot(
|
||||
input: CustomWorldPublishedProfileCompileInput,
|
||||
) -> Result<CustomWorldPublishedProfileCompileSnapshot, CustomWorldFieldError> {
|
||||
validate_custom_world_published_profile_compile_input(&input)?;
|
||||
|
||||
let draft = parse_required_json_object(
|
||||
&input.draft_profile_json,
|
||||
CustomWorldFieldError::InvalidDraftProfileJson,
|
||||
)?;
|
||||
let legacy = parse_optional_json_object(
|
||||
input.legacy_result_profile_json.clone(),
|
||||
CustomWorldFieldError::InvalidLegacyResultProfileJson,
|
||||
)?;
|
||||
|
||||
let world_name = resolve_text_field(&draft, &legacy, "name")
|
||||
.ok_or(CustomWorldFieldError::MissingWorldName)?;
|
||||
let subtitle = resolve_text_field(&draft, &legacy, "subtitle").unwrap_or_default();
|
||||
let summary_text = resolve_text_field(&draft, &legacy, "summary").unwrap_or_default();
|
||||
let cover_image_src = resolve_cover_image_src(&draft, &legacy);
|
||||
let theme_mode = resolve_theme_mode(&legacy);
|
||||
let playable_npc_count =
|
||||
count_distinct_roles(draft.get("playableNpcs"), draft.get("storyNpcs"));
|
||||
let landmark_count = to_array(draft.get("landmarks")).len() as u32;
|
||||
|
||||
let compiled_payload_json = build_compiled_profile_payload_json(
|
||||
&input,
|
||||
&draft,
|
||||
&legacy,
|
||||
&world_name,
|
||||
&subtitle,
|
||||
&summary_text,
|
||||
)?;
|
||||
|
||||
Ok(CustomWorldPublishedProfileCompileSnapshot {
|
||||
profile_id: input.profile_id,
|
||||
owner_user_id: input.owner_user_id,
|
||||
world_name,
|
||||
subtitle,
|
||||
summary_text,
|
||||
theme_mode,
|
||||
cover_image_src,
|
||||
playable_npc_count,
|
||||
landmark_count,
|
||||
author_display_name: input.author_display_name,
|
||||
compiled_profile_payload_json: compiled_payload_json,
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn canonicalize_custom_world_profile_before_save(profile: &mut Value) -> bool {
|
||||
let Some(object) = profile.as_object_mut() else {
|
||||
return false;
|
||||
};
|
||||
let foundation_text = build_creator_intent_foundation_text(object.get("creatorIntent"))
|
||||
.trim()
|
||||
.to_string();
|
||||
if foundation_text.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let current_setting_text = object
|
||||
.get("settingText")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or_default();
|
||||
if current_setting_text == foundation_text {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 中文注释:保存与 session 同步前统一以后端 creatorIntent 锚点重建 settingText,
|
||||
// 避免浏览器继续持有正式 profile canonicalize 规则。
|
||||
object.insert("settingText".to_string(), Value::String(foundation_text));
|
||||
true
|
||||
}
|
||||
|
||||
pub fn empty_agent_anchor_content_json() -> String {
|
||||
r#"{"worldPromise":null,"playerFantasy":null,"themeBoundary":null,"playerEntryPoint":null,"coreConflict":null,"keyRelationships":null,"hiddenLines":null,"iconicElements":null}"#.to_string()
|
||||
}
|
||||
|
||||
pub fn empty_agent_creator_intent_readiness_json() -> String {
|
||||
r#"{"isReady":false,"completedKeys":[],"missingKeys":[]}"#.to_string()
|
||||
}
|
||||
|
||||
pub fn empty_agent_asset_coverage_json() -> String {
|
||||
r#"{"roleAssets":[],"sceneAssets":[],"allRoleAssetsReady":false,"allSceneAssetsReady":false}"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub fn empty_json_object() -> String {
|
||||
"{}".to_string()
|
||||
}
|
||||
|
||||
pub fn empty_json_array() -> String {
|
||||
"[]".to_string()
|
||||
}
|
||||
|
||||
pub fn normalize_optional_json_slice(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|value| {
|
||||
let value = value.trim().to_string();
|
||||
if value.is_empty() { None } else { Some(value) }
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_json_object(value: &str) -> Result<(), CustomWorldFieldError> {
|
||||
match serde_json::from_str::<Value>(value) {
|
||||
Ok(Value::Object(_)) => Ok(()),
|
||||
_ => Err(CustomWorldFieldError::InvalidJsonPayload),
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_optional_json_object(value: Option<&str>) -> Result<(), CustomWorldFieldError> {
|
||||
match value.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
Some(value) => ensure_json_object(value),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_json_array(value: &str) -> Result<(), CustomWorldFieldError> {
|
||||
match serde_json::from_str::<Value>(value) {
|
||||
Ok(Value::Array(_)) => Ok(()),
|
||||
_ => Err(CustomWorldFieldError::InvalidJsonPayload),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_required_json_object(
|
||||
value: &str,
|
||||
error: CustomWorldFieldError,
|
||||
) -> Result<Map<String, Value>, CustomWorldFieldError> {
|
||||
match serde_json::from_str::<Value>(value) {
|
||||
Ok(Value::Object(object)) => Ok(object),
|
||||
_ => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_optional_json_object(
|
||||
value: Option<String>,
|
||||
error: CustomWorldFieldError,
|
||||
) -> Result<Map<String, Value>, CustomWorldFieldError> {
|
||||
match normalize_optional_json_slice(value) {
|
||||
Some(value) => parse_required_json_object(&value, error),
|
||||
None => Ok(Map::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_text(value: Option<&Value>) -> Option<String> {
|
||||
match value {
|
||||
Some(Value::String(value)) => {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_array(value: Option<&Value>) -> Vec<Value> {
|
||||
match value {
|
||||
Some(Value::Array(items)) => items.clone(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_object(value: Option<&Value>) -> Option<Map<String, Value>> {
|
||||
match value {
|
||||
Some(Value::Object(object)) => Some(object.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_creator_intent_foundation_text(value: Option<&Value>) -> String {
|
||||
let Some(intent) = value.and_then(Value::as_object) else {
|
||||
return String::new();
|
||||
};
|
||||
if !has_meaningful_creator_intent(intent) {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let relationship_text = intent
|
||||
.get("keyCharacters")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|items| items.first())
|
||||
.and_then(Value::as_object)
|
||||
.map(build_creator_intent_relationship_text)
|
||||
.unwrap_or_default();
|
||||
let player_opening_text = [
|
||||
read_text(intent, "playerPremise"),
|
||||
read_text(intent, "openingSituation"),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>()
|
||||
.join(";");
|
||||
let theme_tone_text = [
|
||||
read_string_list(intent, "themeKeywords").join("、"),
|
||||
read_string_list(intent, "toneDirectives").join("、"),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" / ");
|
||||
|
||||
[
|
||||
build_anchor_line(
|
||||
"世界一句话",
|
||||
read_text(intent, "worldHook").unwrap_or_default(),
|
||||
),
|
||||
build_anchor_line("玩家开局", player_opening_text),
|
||||
build_anchor_line("主题气质", theme_tone_text),
|
||||
build_anchor_line(
|
||||
"核心冲突",
|
||||
read_string_list(intent, "coreConflicts").join(";"),
|
||||
),
|
||||
build_anchor_line("关键关系", relationship_text),
|
||||
build_anchor_line(
|
||||
"标志元素",
|
||||
read_string_list(intent, "iconicElements").join("、"),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn has_meaningful_creator_intent(intent: &Map<String, Value>) -> bool {
|
||||
[
|
||||
"rawSettingText",
|
||||
"worldHook",
|
||||
"playerPremise",
|
||||
"openingSituation",
|
||||
]
|
||||
.iter()
|
||||
.any(|key| read_text(intent, key).is_some())
|
||||
|| [
|
||||
"themeKeywords",
|
||||
"toneDirectives",
|
||||
"coreConflicts",
|
||||
"iconicElements",
|
||||
"forbiddenDirectives",
|
||||
]
|
||||
.iter()
|
||||
.any(|key| !read_string_list(intent, key).is_empty())
|
||||
|| ["keyFactions", "keyCharacters", "keyLandmarks"]
|
||||
.iter()
|
||||
.any(|key| has_meaningful_creator_seed_array(intent.get(*key)))
|
||||
}
|
||||
|
||||
fn build_creator_intent_relationship_text(character: &Map<String, Value>) -> String {
|
||||
[
|
||||
read_text(character, "name"),
|
||||
read_text(character, "role"),
|
||||
read_text(character, "relationToPlayer").map(|value| format!("与玩家 {value}")),
|
||||
read_text(character, "hiddenHook").map(|value| format!("暗线 {value}")),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" · ")
|
||||
}
|
||||
|
||||
fn build_anchor_line(label: &str, content: String) -> String {
|
||||
if content.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("{label}:{content}")
|
||||
}
|
||||
}
|
||||
|
||||
fn read_text(object: &Map<String, Value>, key: &str) -> Option<String> {
|
||||
object
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn read_string_list(object: &Map<String, Value>, key: &str) -> Vec<String> {
|
||||
object
|
||||
.get(key)
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn has_meaningful_creator_seed_array(value: Option<&Value>) -> bool {
|
||||
value.and_then(Value::as_array).is_some_and(|items| {
|
||||
items.iter().any(|item| {
|
||||
item.as_object().is_some_and(|object| {
|
||||
[
|
||||
"name",
|
||||
"publicGoal",
|
||||
"tension",
|
||||
"notes",
|
||||
"role",
|
||||
"publicMask",
|
||||
"hiddenHook",
|
||||
"relationToPlayer",
|
||||
"purpose",
|
||||
"mood",
|
||||
"secret",
|
||||
]
|
||||
.iter()
|
||||
.any(|key| read_text(object, key).is_some())
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_text_field(
|
||||
draft: &Map<String, Value>,
|
||||
legacy: &Map<String, Value>,
|
||||
key: &str,
|
||||
) -> Option<String> {
|
||||
to_text(draft.get(key)).or_else(|| to_text(legacy.get(key)))
|
||||
}
|
||||
|
||||
fn resolve_theme_mode(legacy: &Map<String, Value>) -> CustomWorldThemeMode {
|
||||
to_text(legacy.get("themeMode"))
|
||||
.and_then(|value| CustomWorldThemeMode::from_client_str(&value))
|
||||
.unwrap_or(CustomWorldThemeMode::Mythic)
|
||||
}
|
||||
|
||||
fn resolve_cover_image_src(
|
||||
draft: &Map<String, Value>,
|
||||
legacy: &Map<String, Value>,
|
||||
) -> Option<String> {
|
||||
if let Some(camp) = to_object(draft.get("camp")) {
|
||||
if let Some(image_src) = to_text(camp.get("imageSrc")) {
|
||||
return Some(image_src);
|
||||
}
|
||||
}
|
||||
|
||||
for landmark in to_array(draft.get("landmarks")) {
|
||||
if let Value::Object(landmark) = landmark {
|
||||
if let Some(image_src) = to_text(landmark.get("imageSrc")) {
|
||||
return Some(image_src);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(cover) = to_object(legacy.get("cover")) {
|
||||
if let Some(image_src) = to_text(cover.get("imageSrc")) {
|
||||
return Some(image_src);
|
||||
}
|
||||
}
|
||||
|
||||
to_text(legacy.get("coverImageSrc"))
|
||||
}
|
||||
|
||||
fn count_distinct_roles(playable: Option<&Value>, story: Option<&Value>) -> u32 {
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
|
||||
for role in to_array(playable).into_iter().chain(to_array(story)) {
|
||||
if let Value::Object(role) = role {
|
||||
let key = to_text(role.get("id"))
|
||||
.or_else(|| to_text(role.get("name")))
|
||||
.unwrap_or_else(|| format!("role-{}", seen.len()));
|
||||
seen.insert(key);
|
||||
}
|
||||
}
|
||||
|
||||
seen.len() as u32
|
||||
}
|
||||
|
||||
fn build_compiled_profile_payload_json(
|
||||
input: &CustomWorldPublishedProfileCompileInput,
|
||||
draft: &Map<String, Value>,
|
||||
legacy: &Map<String, Value>,
|
||||
world_name: &str,
|
||||
subtitle: &str,
|
||||
summary_text: &str,
|
||||
) -> Result<String, CustomWorldFieldError> {
|
||||
let mut payload = legacy.clone();
|
||||
|
||||
payload.insert("id".to_string(), Value::String(input.profile_id.clone()));
|
||||
payload.insert(
|
||||
"settingText".to_string(),
|
||||
Value::String(input.setting_text.trim().to_string()),
|
||||
);
|
||||
payload.insert("name".to_string(), Value::String(world_name.to_string()));
|
||||
payload.insert("subtitle".to_string(), Value::String(subtitle.to_string()));
|
||||
payload.insert(
|
||||
"summary".to_string(),
|
||||
Value::String(summary_text.to_string()),
|
||||
);
|
||||
payload.insert(
|
||||
"updatedAtMicros".to_string(),
|
||||
Value::Number(input.updated_at_micros.into()),
|
||||
);
|
||||
|
||||
for key in ["tone", "playerGoal"] {
|
||||
if let Some(value) = draft.get(key) {
|
||||
payload.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
for key in [
|
||||
"majorFactions",
|
||||
"coreConflicts",
|
||||
"playableNpcs",
|
||||
"storyNpcs",
|
||||
"landmarks",
|
||||
"camp",
|
||||
] {
|
||||
if let Some(value) = draft.get(key) {
|
||||
payload.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(scene_chapters) = draft
|
||||
.get("sceneChapterBlueprints")
|
||||
.or_else(|| draft.get("sceneChapters"))
|
||||
{
|
||||
payload.insert("sceneChapterBlueprints".to_string(), scene_chapters.clone());
|
||||
}
|
||||
|
||||
serde_json::to_string(&Value::Object(payload))
|
||||
.map_err(|_| CustomWorldFieldError::InvalidDraftProfileJson)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,283 @@
|
||||
//! 自定义世界写入命令过渡落位。
|
||||
//! 自定义世界写入命令。
|
||||
//!
|
||||
//! 用于表达会话创建、消息写入、草稿更新、发布和下架等用例输入。
|
||||
|
||||
use crate::domain::{
|
||||
CustomWorldAgentOperationSnapshot, CustomWorldGalleryEntrySnapshot, CustomWorldProfileSnapshot,
|
||||
CustomWorldThemeMode, RpgAgentOperationStatus, RpgAgentOperationType, RpgAgentStage,
|
||||
};
|
||||
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 CustomWorldProfileUpsertInput {
|
||||
pub profile_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub public_work_code: Option<String>,
|
||||
pub author_public_user_code: Option<String>,
|
||||
pub source_agent_session_id: Option<String>,
|
||||
pub world_name: String,
|
||||
pub subtitle: String,
|
||||
pub summary_text: String,
|
||||
pub theme_mode: CustomWorldThemeMode,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub profile_payload_json: String,
|
||||
pub playable_npc_count: u32,
|
||||
pub landmark_count: u32,
|
||||
pub author_display_name: String,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldProfilePublishInput {
|
||||
pub profile_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub public_work_code: Option<String>,
|
||||
pub author_public_user_code: String,
|
||||
pub author_display_name: String,
|
||||
pub published_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldProfileUnpublishInput {
|
||||
pub profile_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub author_display_name: String,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldProfileDeleteInput {
|
||||
pub profile_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub deleted_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldProfileListInput {
|
||||
pub owner_user_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldLibraryDetailInput {
|
||||
pub owner_user_id: String,
|
||||
pub profile_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldGalleryDetailInput {
|
||||
pub owner_user_id: String,
|
||||
pub profile_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldGalleryDetailByCodeInput {
|
||||
pub public_work_code: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldAgentSessionCreateInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub seed_text: String,
|
||||
pub welcome_message_id: String,
|
||||
pub welcome_message_text: String,
|
||||
pub anchor_content_json: String,
|
||||
pub creator_intent_json: Option<String>,
|
||||
pub creator_intent_readiness_json: String,
|
||||
pub anchor_pack_json: Option<String>,
|
||||
pub lock_state_json: Option<String>,
|
||||
pub draft_profile_json: Option<String>,
|
||||
pub pending_clarifications_json: String,
|
||||
pub suggested_actions_json: String,
|
||||
pub recommended_replies_json: String,
|
||||
pub quality_findings_json: String,
|
||||
pub asset_coverage_json: String,
|
||||
pub checkpoints_json: String,
|
||||
pub created_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldAgentSessionGetInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldAgentMessageSubmitInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub user_message_id: String,
|
||||
pub user_message_text: String,
|
||||
pub operation_id: String,
|
||||
pub submitted_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldAgentMessageFinalizeInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub operation_id: String,
|
||||
pub assistant_message_id: Option<String>,
|
||||
pub assistant_reply_text: Option<String>,
|
||||
pub phase_label: String,
|
||||
pub phase_detail: String,
|
||||
pub operation_status: RpgAgentOperationStatus,
|
||||
pub operation_progress: u32,
|
||||
pub stage: RpgAgentStage,
|
||||
pub progress_percent: u32,
|
||||
pub focus_card_id: Option<String>,
|
||||
pub anchor_content_json: String,
|
||||
pub creator_intent_json: Option<String>,
|
||||
pub creator_intent_readiness_json: String,
|
||||
pub anchor_pack_json: Option<String>,
|
||||
pub draft_profile_json: Option<String>,
|
||||
pub pending_clarifications_json: String,
|
||||
pub suggested_actions_json: String,
|
||||
pub recommended_replies_json: String,
|
||||
pub quality_findings_json: String,
|
||||
pub asset_coverage_json: String,
|
||||
pub error_message: Option<String>,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldAgentOperationGetInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub operation_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldAgentOperationProgressInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub operation_id: String,
|
||||
pub operation_type: RpgAgentOperationType,
|
||||
pub operation_status: RpgAgentOperationStatus,
|
||||
pub phase_label: String,
|
||||
pub phase_detail: String,
|
||||
pub operation_progress: u32,
|
||||
pub error_message: Option<String>,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldAgentOperationProcedureResult {
|
||||
pub ok: bool,
|
||||
pub operation: Option<CustomWorldAgentOperationSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldWorksListInput {
|
||||
pub owner_user_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldAgentCardDetailGetInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub card_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldAgentActionExecuteInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub operation_id: String,
|
||||
pub action: String,
|
||||
pub payload_json: Option<String>,
|
||||
pub submitted_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldAgentActionExecuteResult {
|
||||
pub ok: bool,
|
||||
pub operation: Option<CustomWorldAgentOperationSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldPublishedProfileCompileInput {
|
||||
pub session_id: String,
|
||||
pub profile_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub draft_profile_json: String,
|
||||
pub legacy_result_profile_json: Option<String>,
|
||||
pub setting_text: String,
|
||||
pub author_display_name: String,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldPublishedProfileCompileSnapshot {
|
||||
pub profile_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub world_name: String,
|
||||
pub subtitle: String,
|
||||
pub summary_text: String,
|
||||
pub theme_mode: CustomWorldThemeMode,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub playable_npc_count: u32,
|
||||
pub landmark_count: u32,
|
||||
pub author_display_name: String,
|
||||
pub compiled_profile_payload_json: String,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldPublishedProfileCompileResult {
|
||||
pub ok: bool,
|
||||
pub record: Option<CustomWorldPublishedProfileCompileSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldPublishWorldInput {
|
||||
pub session_id: String,
|
||||
pub profile_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub public_work_code: Option<String>,
|
||||
pub author_public_user_code: String,
|
||||
pub draft_profile_json: String,
|
||||
pub legacy_result_profile_json: Option<String>,
|
||||
pub setting_text: String,
|
||||
pub author_display_name: String,
|
||||
pub published_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldPublishWorldResult {
|
||||
pub ok: bool,
|
||||
pub compiled_record: Option<CustomWorldPublishedProfileCompileSnapshot>,
|
||||
pub entry: Option<CustomWorldProfileSnapshot>,
|
||||
pub gallery_entry: Option<CustomWorldGalleryEntrySnapshot>,
|
||||
pub session_stage: Option<RpgAgentStage>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! 自定义世界领域模型过渡落位。
|
||||
//! 自定义世界领域模型。
|
||||
//!
|
||||
//! 后续迁移 profile、Agent 会话、草稿卡、发布门禁和画廊投影规则时,
|
||||
//! 只保留纯领域结构;LLM 推理、SSE 和 OSS 均留在外层 adapter。
|
||||
//! 只保留 profile、Agent 会话、草稿卡、发布门禁和画廊投影的纯领域结构;LLM 推理、SSE 和 OSS 均留在外层 adapter。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[cfg(feature = "spacetime-types")]
|
||||
@@ -139,6 +138,248 @@ pub enum CustomWorldRoleAssetStatus {
|
||||
Complete,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldProfileSnapshot {
|
||||
pub profile_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub public_work_code: Option<String>,
|
||||
pub author_public_user_code: Option<String>,
|
||||
pub source_agent_session_id: Option<String>,
|
||||
pub publication_status: CustomWorldPublicationStatus,
|
||||
pub world_name: String,
|
||||
pub subtitle: String,
|
||||
pub summary_text: String,
|
||||
pub theme_mode: CustomWorldThemeMode,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub profile_payload_json: String,
|
||||
pub playable_npc_count: u32,
|
||||
pub landmark_count: u32,
|
||||
pub author_display_name: String,
|
||||
pub published_at_micros: Option<i64>,
|
||||
pub deleted_at_micros: Option<i64>,
|
||||
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 CustomWorldGalleryEntrySnapshot {
|
||||
pub profile_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub public_work_code: String,
|
||||
pub author_public_user_code: String,
|
||||
pub author_display_name: String,
|
||||
pub world_name: String,
|
||||
pub subtitle: String,
|
||||
pub summary_text: String,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub theme_mode: CustomWorldThemeMode,
|
||||
pub playable_npc_count: u32,
|
||||
pub landmark_count: u32,
|
||||
pub published_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldLibraryMutationResult {
|
||||
pub ok: bool,
|
||||
pub entry: Option<CustomWorldProfileSnapshot>,
|
||||
pub gallery_entry: Option<CustomWorldGalleryEntrySnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldProfileListResult {
|
||||
pub ok: bool,
|
||||
pub entries: Vec<CustomWorldProfileSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldGalleryListResult {
|
||||
pub ok: bool,
|
||||
pub entries: Vec<CustomWorldGalleryEntrySnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldPublishBlockerSnapshot {
|
||||
pub blocker_id: String,
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldPublishGateSnapshot {
|
||||
pub profile_id: String,
|
||||
pub blockers: Vec<CustomWorldPublishBlockerSnapshot>,
|
||||
pub blocker_count: u32,
|
||||
pub publish_ready: bool,
|
||||
pub can_enter_world: bool,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldWorkSummarySnapshot {
|
||||
pub work_id: String,
|
||||
pub source_type: String,
|
||||
pub status: String,
|
||||
pub title: String,
|
||||
pub subtitle: String,
|
||||
pub summary: String,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub cover_render_mode: Option<String>,
|
||||
pub cover_character_image_srcs_json: String,
|
||||
pub updated_at_micros: i64,
|
||||
pub published_at_micros: Option<i64>,
|
||||
pub stage: Option<RpgAgentStage>,
|
||||
pub stage_label: Option<String>,
|
||||
pub playable_npc_count: u32,
|
||||
pub landmark_count: u32,
|
||||
pub role_visual_ready_count: Option<u32>,
|
||||
pub role_animation_ready_count: Option<u32>,
|
||||
pub role_asset_summary_label: Option<String>,
|
||||
pub session_id: Option<String>,
|
||||
pub profile_id: Option<String>,
|
||||
pub can_resume: bool,
|
||||
pub can_enter_world: bool,
|
||||
pub blocker_count: u32,
|
||||
pub publish_ready: bool,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldWorksListResult {
|
||||
pub ok: bool,
|
||||
pub items: Vec<CustomWorldWorkSummarySnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldAgentMessageSnapshot {
|
||||
pub message_id: String,
|
||||
pub session_id: String,
|
||||
pub role: RpgAgentMessageRole,
|
||||
pub kind: RpgAgentMessageKind,
|
||||
pub text: String,
|
||||
pub related_operation_id: Option<String>,
|
||||
pub created_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldAgentOperationSnapshot {
|
||||
pub operation_id: String,
|
||||
pub session_id: String,
|
||||
pub operation_type: RpgAgentOperationType,
|
||||
pub status: RpgAgentOperationStatus,
|
||||
pub phase_label: String,
|
||||
pub phase_detail: String,
|
||||
pub progress: u32,
|
||||
pub error_message: Option<String>,
|
||||
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 CustomWorldDraftCardSnapshot {
|
||||
pub card_id: String,
|
||||
pub session_id: String,
|
||||
pub kind: RpgAgentDraftCardKind,
|
||||
pub status: RpgAgentDraftCardStatus,
|
||||
pub title: String,
|
||||
pub subtitle: String,
|
||||
pub summary: String,
|
||||
pub linked_ids_json: String,
|
||||
pub warning_count: u32,
|
||||
pub asset_status: Option<CustomWorldRoleAssetStatus>,
|
||||
pub asset_status_label: Option<String>,
|
||||
pub detail_payload_json: Option<String>,
|
||||
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 CustomWorldDraftCardDetailSectionSnapshot {
|
||||
pub section_id: String,
|
||||
pub label: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldDraftCardDetailSnapshot {
|
||||
pub card_id: String,
|
||||
pub kind: RpgAgentDraftCardKind,
|
||||
pub title: String,
|
||||
pub sections: Vec<CustomWorldDraftCardDetailSectionSnapshot>,
|
||||
pub linked_ids_json: String,
|
||||
pub locked: bool,
|
||||
pub editable: bool,
|
||||
pub editable_section_ids_json: String,
|
||||
pub warning_messages_json: String,
|
||||
pub asset_status: Option<CustomWorldRoleAssetStatus>,
|
||||
pub asset_status_label: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldDraftCardDetailResult {
|
||||
pub ok: bool,
|
||||
pub card: Option<CustomWorldDraftCardDetailSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldAgentSessionSnapshot {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub seed_text: String,
|
||||
pub current_turn: u32,
|
||||
pub progress_percent: u32,
|
||||
pub stage: RpgAgentStage,
|
||||
pub focus_card_id: Option<String>,
|
||||
pub anchor_content_json: String,
|
||||
pub creator_intent_json: Option<String>,
|
||||
pub creator_intent_readiness_json: String,
|
||||
pub anchor_pack_json: Option<String>,
|
||||
pub lock_state_json: Option<String>,
|
||||
pub draft_profile_json: Option<String>,
|
||||
pub last_assistant_reply: Option<String>,
|
||||
pub publish_gate_json: Option<String>,
|
||||
pub result_preview_json: Option<String>,
|
||||
pub pending_clarifications_json: String,
|
||||
pub quality_findings_json: String,
|
||||
pub suggested_actions_json: String,
|
||||
pub recommended_replies_json: String,
|
||||
pub asset_coverage_json: String,
|
||||
pub checkpoints_json: String,
|
||||
pub supported_actions_json: String,
|
||||
pub messages: Vec<CustomWorldAgentMessageSnapshot>,
|
||||
pub draft_cards: Vec<CustomWorldDraftCardSnapshot>,
|
||||
pub operations: Vec<CustomWorldAgentOperationSnapshot>,
|
||||
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 CustomWorldAgentSessionProcedureResult {
|
||||
pub ok: bool,
|
||||
pub session: Option<CustomWorldAgentSessionSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
impl CustomWorldPublicationStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
|
||||
@@ -1,3 +1,100 @@
|
||||
//! 自定义世界领域错误过渡落位。
|
||||
//! 自定义世界领域错误。
|
||||
//!
|
||||
//! 错误只表达世界创作规则失败,由 adapter 显式映射为 HTTP 或 reducer 错误。
|
||||
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum CustomWorldFieldError {
|
||||
MissingProfileId,
|
||||
MissingSessionId,
|
||||
MissingOwnerUserId,
|
||||
MissingPublicWorkCode,
|
||||
MissingAction,
|
||||
MissingWorldName,
|
||||
MissingDraftProfileJson,
|
||||
MissingProfilePayloadJson,
|
||||
MissingSettingText,
|
||||
MissingQuestionSnapshotJson,
|
||||
MissingAnchorContentJson,
|
||||
MissingCreatorIntentReadinessJson,
|
||||
MissingAssetCoverageJson,
|
||||
MissingPendingClarificationsJson,
|
||||
MissingMessageId,
|
||||
MissingMessageText,
|
||||
MissingOperationId,
|
||||
MissingPhaseLabel,
|
||||
InvalidProgressPercent,
|
||||
MissingCardId,
|
||||
MissingCardTitle,
|
||||
MissingCardSummary,
|
||||
MissingLinkedIdsJson,
|
||||
MissingAuthorDisplayName,
|
||||
InvalidDraftProfileJson,
|
||||
InvalidLegacyResultProfileJson,
|
||||
InvalidJsonPayload,
|
||||
}
|
||||
|
||||
impl fmt::Display for CustomWorldFieldError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::MissingProfileId => f.write_str("custom_world.profile_id 不能为空"),
|
||||
Self::MissingSessionId => f.write_str("custom_world.session_id 不能为空"),
|
||||
Self::MissingOwnerUserId => f.write_str("custom_world.owner_user_id 不能为空"),
|
||||
Self::MissingPublicWorkCode => {
|
||||
f.write_str("custom_world_gallery_detail.public_work_code 不能为空")
|
||||
}
|
||||
Self::MissingAction => f.write_str("custom_world_agent_action.action 不能为空"),
|
||||
Self::MissingWorldName => f.write_str("custom_world.world_name 不能为空"),
|
||||
Self::MissingDraftProfileJson => {
|
||||
f.write_str("custom_world.compile.draft_profile_json 不能为空")
|
||||
}
|
||||
Self::MissingProfilePayloadJson => {
|
||||
f.write_str("custom_world.profile_payload_json 不能为空")
|
||||
}
|
||||
Self::MissingSettingText => f.write_str("custom_world.setting_text 不能为空"),
|
||||
Self::MissingQuestionSnapshotJson => {
|
||||
f.write_str("custom_world.question_snapshot_json 不能为空")
|
||||
}
|
||||
Self::MissingAnchorContentJson => {
|
||||
f.write_str("custom_world.anchor_content_json 不能为空")
|
||||
}
|
||||
Self::MissingCreatorIntentReadinessJson => {
|
||||
f.write_str("custom_world.creator_intent_readiness_json 不能为空")
|
||||
}
|
||||
Self::MissingAssetCoverageJson => {
|
||||
f.write_str("custom_world.asset_coverage_json 不能为空")
|
||||
}
|
||||
Self::MissingPendingClarificationsJson => {
|
||||
f.write_str("custom_world.pending_clarifications_json 不能为空")
|
||||
}
|
||||
Self::MissingMessageId => f.write_str("custom_world_agent_message.message_id 不能为空"),
|
||||
Self::MissingMessageText => f.write_str("custom_world_agent_message.text 不能为空"),
|
||||
Self::MissingOperationId => {
|
||||
f.write_str("custom_world_agent_operation.operation_id 不能为空")
|
||||
}
|
||||
Self::MissingPhaseLabel => {
|
||||
f.write_str("custom_world_agent_operation.phase_label 不能为空")
|
||||
}
|
||||
Self::InvalidProgressPercent => f.write_str("progress 必须位于 0~100"),
|
||||
Self::MissingCardId => f.write_str("custom_world_draft_card.card_id 不能为空"),
|
||||
Self::MissingCardTitle => f.write_str("custom_world_draft_card.title 不能为空"),
|
||||
Self::MissingCardSummary => f.write_str("custom_world_draft_card.summary 不能为空"),
|
||||
Self::MissingLinkedIdsJson => {
|
||||
f.write_str("custom_world_draft_card.linked_ids_json 不能为空")
|
||||
}
|
||||
Self::MissingAuthorDisplayName => {
|
||||
f.write_str("custom_world_gallery_entry.author_display_name 不能为空")
|
||||
}
|
||||
Self::InvalidDraftProfileJson => {
|
||||
f.write_str("custom_world.compile.draft_profile_json 不是合法 JSON object")
|
||||
}
|
||||
Self::InvalidLegacyResultProfileJson => {
|
||||
f.write_str("custom_world.compile.legacy_result_profile_json 不是合法 JSON object")
|
||||
}
|
||||
Self::InvalidJsonPayload => f.write_str("custom_world JSON payload 结构非法"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for CustomWorldFieldError {}
|
||||
|
||||
@@ -1,3 +1,68 @@
|
||||
//! 自定义世界领域事件过渡落位。
|
||||
//! 自定义世界领域事件。
|
||||
//!
|
||||
//! 用于表达草稿变化、profile 发布、画廊投影刷新和 Agent 操作进度变化。
|
||||
|
||||
use crate::domain::{
|
||||
RpgAgentDraftCardKind, RpgAgentOperationStatus, RpgAgentOperationType, RpgAgentStage,
|
||||
};
|
||||
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 CustomWorldDomainEvent {
|
||||
ProfileUpserted(CustomWorldProfileUpsertedEvent),
|
||||
ProfilePublished(CustomWorldProfilePublishedEvent),
|
||||
GalleryProjectionRefreshed(CustomWorldGalleryProjectionRefreshedEvent),
|
||||
AgentSessionAdvanced(CustomWorldAgentSessionAdvancedEvent),
|
||||
AgentOperationProgressed(CustomWorldAgentOperationProgressedEvent),
|
||||
DraftCardUpdated(CustomWorldDraftCardUpdatedEvent),
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldProfileUpsertedEvent {
|
||||
pub profile_id: String,
|
||||
pub owner_user_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldProfilePublishedEvent {
|
||||
pub profile_id: String,
|
||||
pub public_work_code: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldGalleryProjectionRefreshedEvent {
|
||||
pub profile_id: String,
|
||||
pub public_work_code: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldAgentSessionAdvancedEvent {
|
||||
pub session_id: String,
|
||||
pub stage: RpgAgentStage,
|
||||
pub progress_percent: u32,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldAgentOperationProgressedEvent {
|
||||
pub session_id: String,
|
||||
pub operation_id: String,
|
||||
pub operation_type: RpgAgentOperationType,
|
||||
pub status: RpgAgentOperationStatus,
|
||||
pub progress: u32,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldDraftCardUpdatedEvent {
|
||||
pub session_id: String,
|
||||
pub card_id: String,
|
||||
pub kind: RpgAgentDraftCardKind,
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,564 @@
|
||||
//! 背包应用编排过渡落位。
|
||||
//! 背包应用编排。
|
||||
//!
|
||||
//! 这里只返回背包变更结果和领域事件,不直接访问持久化。
|
||||
|
||||
use crate::commands::{
|
||||
ConsumeInventoryItemInput, EquipInventoryItemInput, GrantInventoryItemInput, InventoryMutation,
|
||||
InventoryMutationInput, RuntimeInventoryStateQueryInput, UnequipInventoryItemInput,
|
||||
};
|
||||
use crate::domain::{
|
||||
InventoryContainerKind, InventoryEquipmentSlot, InventoryItemRarity, InventoryItemSnapshot,
|
||||
InventoryItemSourceKind, InventorySlotSnapshot,
|
||||
};
|
||||
use crate::errors::InventoryMutationFieldError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared_kernel::{
|
||||
format_timestamp_micros, normalize_optional_string as normalize_shared_optional_string,
|
||||
normalize_required_string, normalize_string_list as normalize_shared_string_list,
|
||||
};
|
||||
#[cfg(feature = "spacetime-types")]
|
||||
use spacetimedb::SpacetimeType;
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RuntimeInventoryStateSnapshot {
|
||||
pub runtime_session_id: String,
|
||||
pub actor_user_id: String,
|
||||
pub backpack_items: Vec<InventorySlotSnapshot>,
|
||||
pub equipment_items: Vec<InventorySlotSnapshot>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RuntimeInventoryStateProcedureResult {
|
||||
pub ok: bool,
|
||||
pub snapshot: Option<RuntimeInventoryStateSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RuntimeInventorySlotRecord {
|
||||
pub slot_id: String,
|
||||
pub container_kind: String,
|
||||
pub slot_key: String,
|
||||
pub item_id: String,
|
||||
pub category: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub quantity: u32,
|
||||
pub rarity: String,
|
||||
pub tags: Vec<String>,
|
||||
pub stackable: bool,
|
||||
pub stack_key: String,
|
||||
pub equipment_slot_id: Option<String>,
|
||||
pub source_kind: String,
|
||||
pub source_reference_id: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RuntimeInventoryStateRecord {
|
||||
pub runtime_session_id: String,
|
||||
pub actor_user_id: String,
|
||||
pub backpack_items: Vec<RuntimeInventorySlotRecord>,
|
||||
pub equipment_items: Vec<RuntimeInventorySlotRecord>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct InventoryMutationOutcome {
|
||||
pub next_slots: Vec<InventorySlotSnapshot>,
|
||||
pub changed: bool,
|
||||
pub updated_slot_ids: Vec<String>,
|
||||
pub removed_slot_ids: Vec<String>,
|
||||
pub affected_equipment_slot: Option<InventoryEquipmentSlot>,
|
||||
}
|
||||
|
||||
pub fn normalize_optional_text(value: Option<String>) -> Option<String> {
|
||||
normalize_shared_optional_string(value)
|
||||
}
|
||||
|
||||
pub fn normalize_string_list(values: Vec<String>) -> Vec<String> {
|
||||
normalize_shared_string_list(values)
|
||||
}
|
||||
|
||||
pub fn build_runtime_inventory_state_query_input(
|
||||
runtime_session_id: String,
|
||||
actor_user_id: String,
|
||||
) -> Result<RuntimeInventoryStateQueryInput, InventoryMutationFieldError> {
|
||||
let input = RuntimeInventoryStateQueryInput {
|
||||
runtime_session_id: normalize_required_text(
|
||||
runtime_session_id,
|
||||
InventoryMutationFieldError::MissingRuntimeSessionId,
|
||||
)?,
|
||||
actor_user_id: normalize_required_text(
|
||||
actor_user_id,
|
||||
InventoryMutationFieldError::MissingActorUserId,
|
||||
)?,
|
||||
};
|
||||
|
||||
Ok(input)
|
||||
}
|
||||
|
||||
pub fn build_runtime_inventory_state_snapshot(
|
||||
input: RuntimeInventoryStateQueryInput,
|
||||
slots: Vec<InventorySlotSnapshot>,
|
||||
) -> RuntimeInventoryStateSnapshot {
|
||||
let mut backpack_items = Vec::new();
|
||||
let mut equipment_items = Vec::new();
|
||||
|
||||
for slot in slots {
|
||||
match slot.container_kind {
|
||||
InventoryContainerKind::Backpack => backpack_items.push(slot),
|
||||
InventoryContainerKind::Equipment => equipment_items.push(slot),
|
||||
}
|
||||
}
|
||||
|
||||
backpack_items.sort_by(|left, right| {
|
||||
left.slot_key
|
||||
.cmp(&right.slot_key)
|
||||
.then(left.slot_id.cmp(&right.slot_id))
|
||||
});
|
||||
equipment_items.sort_by(|left, right| {
|
||||
equipment_slot_order(left.equipment_slot_id)
|
||||
.cmp(&equipment_slot_order(right.equipment_slot_id))
|
||||
.then(left.slot_id.cmp(&right.slot_id))
|
||||
});
|
||||
|
||||
RuntimeInventoryStateSnapshot {
|
||||
runtime_session_id: input.runtime_session_id,
|
||||
actor_user_id: input.actor_user_id,
|
||||
backpack_items,
|
||||
equipment_items,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_inventory_mutation(
|
||||
current_slots: Vec<InventorySlotSnapshot>,
|
||||
input: InventoryMutationInput,
|
||||
) -> Result<InventoryMutationOutcome, InventoryMutationFieldError> {
|
||||
let _mutation_id = normalize_required_text(
|
||||
input.mutation_id,
|
||||
InventoryMutationFieldError::MissingMutationId,
|
||||
)?;
|
||||
let runtime_session_id = normalize_required_text(
|
||||
input.runtime_session_id,
|
||||
InventoryMutationFieldError::MissingRuntimeSessionId,
|
||||
)?;
|
||||
let actor_user_id = normalize_required_text(
|
||||
input.actor_user_id,
|
||||
InventoryMutationFieldError::MissingActorUserId,
|
||||
)?;
|
||||
let story_session_id = normalize_optional_text(input.story_session_id);
|
||||
|
||||
let mut slots = current_slots;
|
||||
for slot in &slots {
|
||||
if slot.runtime_session_id != runtime_session_id || slot.actor_user_id != actor_user_id {
|
||||
return Err(InventoryMutationFieldError::SlotScopeMismatch);
|
||||
}
|
||||
}
|
||||
|
||||
let outcome = match input.mutation {
|
||||
InventoryMutation::GrantItem(grant) => apply_grant_item(
|
||||
&mut slots,
|
||||
runtime_session_id,
|
||||
story_session_id,
|
||||
actor_user_id,
|
||||
grant,
|
||||
input.updated_at_micros,
|
||||
)?,
|
||||
InventoryMutation::ConsumeItem(consume) => {
|
||||
apply_consume_item(&mut slots, consume, input.updated_at_micros)?
|
||||
}
|
||||
InventoryMutation::EquipItem(equip) => {
|
||||
apply_equip_item(&mut slots, equip, input.updated_at_micros)?
|
||||
}
|
||||
InventoryMutation::UnequipItem(unequip) => {
|
||||
apply_unequip_item(&mut slots, unequip, input.updated_at_micros)?
|
||||
}
|
||||
};
|
||||
|
||||
Ok(InventoryMutationOutcome {
|
||||
next_slots: sort_inventory_slots(slots),
|
||||
changed: outcome.changed,
|
||||
updated_slot_ids: sort_string_list(outcome.updated_slot_ids),
|
||||
removed_slot_ids: sort_string_list(outcome.removed_slot_ids),
|
||||
affected_equipment_slot: outcome.affected_equipment_slot,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct InventoryMutationInternalOutcome {
|
||||
changed: bool,
|
||||
updated_slot_ids: Vec<String>,
|
||||
removed_slot_ids: Vec<String>,
|
||||
affected_equipment_slot: Option<InventoryEquipmentSlot>,
|
||||
}
|
||||
|
||||
fn apply_grant_item(
|
||||
slots: &mut Vec<InventorySlotSnapshot>,
|
||||
runtime_session_id: String,
|
||||
story_session_id: Option<String>,
|
||||
actor_user_id: String,
|
||||
grant: GrantInventoryItemInput,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<InventoryMutationInternalOutcome, InventoryMutationFieldError> {
|
||||
let slot_id =
|
||||
normalize_required_text(grant.slot_id, InventoryMutationFieldError::MissingSlotId)?;
|
||||
let item = normalize_inventory_item_snapshot(grant.item)?;
|
||||
|
||||
if item.stackable {
|
||||
if let Some(existing) = slots.iter_mut().find(|slot| {
|
||||
slot.container_kind == InventoryContainerKind::Backpack
|
||||
&& slot.stackable
|
||||
&& slot.item_id == item.item_id
|
||||
&& slot.stack_key == item.stack_key
|
||||
}) {
|
||||
existing.category = item.category;
|
||||
existing.name = item.name;
|
||||
existing.description = item.description;
|
||||
existing.quantity += item.quantity;
|
||||
existing.rarity = item.rarity;
|
||||
existing.tags = item.tags;
|
||||
existing.stackable = item.stackable;
|
||||
existing.stack_key = item.stack_key;
|
||||
existing.equipment_slot_id = item.equipment_slot_id;
|
||||
existing.source_kind = item.source_kind;
|
||||
existing.source_reference_id = item.source_reference_id;
|
||||
existing.updated_at_micros = updated_at_micros;
|
||||
|
||||
return Ok(InventoryMutationInternalOutcome {
|
||||
changed: true,
|
||||
updated_slot_ids: vec![existing.slot_id.clone()],
|
||||
removed_slot_ids: vec![],
|
||||
affected_equipment_slot: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
slots.push(InventorySlotSnapshot {
|
||||
slot_id: slot_id.clone(),
|
||||
runtime_session_id,
|
||||
story_session_id,
|
||||
actor_user_id,
|
||||
container_kind: InventoryContainerKind::Backpack,
|
||||
slot_key: build_backpack_slot_key(&slot_id),
|
||||
item_id: item.item_id,
|
||||
category: item.category,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
rarity: item.rarity,
|
||||
tags: item.tags,
|
||||
stackable: item.stackable,
|
||||
stack_key: item.stack_key,
|
||||
equipment_slot_id: item.equipment_slot_id,
|
||||
source_kind: item.source_kind,
|
||||
source_reference_id: item.source_reference_id,
|
||||
created_at_micros: updated_at_micros,
|
||||
updated_at_micros,
|
||||
});
|
||||
|
||||
Ok(InventoryMutationInternalOutcome {
|
||||
changed: true,
|
||||
updated_slot_ids: vec![slot_id],
|
||||
removed_slot_ids: vec![],
|
||||
affected_equipment_slot: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_consume_item(
|
||||
slots: &mut Vec<InventorySlotSnapshot>,
|
||||
consume: ConsumeInventoryItemInput,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<InventoryMutationInternalOutcome, InventoryMutationFieldError> {
|
||||
let slot_id =
|
||||
normalize_required_text(consume.slot_id, InventoryMutationFieldError::MissingSlotId)?;
|
||||
if consume.quantity == 0 {
|
||||
return Err(InventoryMutationFieldError::InvalidQuantity);
|
||||
}
|
||||
|
||||
let slot_index = slots
|
||||
.iter()
|
||||
.position(|slot| slot.slot_id == slot_id)
|
||||
.ok_or(InventoryMutationFieldError::ItemNotFound)?;
|
||||
|
||||
if slots[slot_index].container_kind != InventoryContainerKind::Backpack {
|
||||
return Err(InventoryMutationFieldError::ItemNotInBackpack);
|
||||
}
|
||||
|
||||
if slots[slot_index].quantity < consume.quantity {
|
||||
return Err(InventoryMutationFieldError::InsufficientQuantity);
|
||||
}
|
||||
|
||||
if slots[slot_index].quantity == consume.quantity {
|
||||
slots.remove(slot_index);
|
||||
return Ok(InventoryMutationInternalOutcome {
|
||||
changed: true,
|
||||
updated_slot_ids: vec![],
|
||||
removed_slot_ids: vec![slot_id],
|
||||
affected_equipment_slot: None,
|
||||
});
|
||||
}
|
||||
|
||||
slots[slot_index].quantity -= consume.quantity;
|
||||
slots[slot_index].updated_at_micros = updated_at_micros;
|
||||
|
||||
Ok(InventoryMutationInternalOutcome {
|
||||
changed: true,
|
||||
updated_slot_ids: vec![slots[slot_index].slot_id.clone()],
|
||||
removed_slot_ids: vec![],
|
||||
affected_equipment_slot: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_equip_item(
|
||||
slots: &mut [InventorySlotSnapshot],
|
||||
equip: EquipInventoryItemInput,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<InventoryMutationInternalOutcome, InventoryMutationFieldError> {
|
||||
let slot_id =
|
||||
normalize_required_text(equip.slot_id, InventoryMutationFieldError::MissingSlotId)?;
|
||||
let source_index = slots
|
||||
.iter()
|
||||
.position(|slot| slot.slot_id == slot_id)
|
||||
.ok_or(InventoryMutationFieldError::ItemNotFound)?;
|
||||
let target_slot = slots[source_index]
|
||||
.equipment_slot_id
|
||||
.ok_or(InventoryMutationFieldError::ItemNotEquippable)?;
|
||||
|
||||
if slots[source_index].stackable {
|
||||
return Err(InventoryMutationFieldError::EquipmentItemCannotStack);
|
||||
}
|
||||
if slots[source_index].quantity != 1 {
|
||||
return Err(InventoryMutationFieldError::NonStackableItemMustStaySingleQuantity);
|
||||
}
|
||||
if slots[source_index].container_kind != InventoryContainerKind::Backpack {
|
||||
if slots[source_index].container_kind == InventoryContainerKind::Equipment {
|
||||
return Ok(InventoryMutationInternalOutcome {
|
||||
changed: false,
|
||||
updated_slot_ids: vec![],
|
||||
removed_slot_ids: vec![],
|
||||
affected_equipment_slot: Some(target_slot),
|
||||
});
|
||||
}
|
||||
|
||||
return Err(InventoryMutationFieldError::ItemNotInBackpack);
|
||||
}
|
||||
|
||||
let occupied_index = slots.iter().position(|slot| {
|
||||
slot.container_kind == InventoryContainerKind::Equipment
|
||||
&& slot.slot_key == build_equipment_slot_key(target_slot)
|
||||
});
|
||||
|
||||
let mut updated_slot_ids = vec![slot_id.clone()];
|
||||
if let Some(occupied_index) = occupied_index {
|
||||
// 首版装备互换直接在同一条 slot 真相记录上切容器,不生成临时副本。
|
||||
slots[occupied_index].container_kind = InventoryContainerKind::Backpack;
|
||||
slots[occupied_index].slot_key = build_backpack_slot_key(&slots[occupied_index].slot_id);
|
||||
slots[occupied_index].updated_at_micros = updated_at_micros;
|
||||
updated_slot_ids.push(slots[occupied_index].slot_id.clone());
|
||||
}
|
||||
|
||||
slots[source_index].container_kind = InventoryContainerKind::Equipment;
|
||||
slots[source_index].slot_key = build_equipment_slot_key(target_slot);
|
||||
slots[source_index].updated_at_micros = updated_at_micros;
|
||||
|
||||
Ok(InventoryMutationInternalOutcome {
|
||||
changed: true,
|
||||
updated_slot_ids,
|
||||
removed_slot_ids: vec![],
|
||||
affected_equipment_slot: Some(target_slot),
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_unequip_item(
|
||||
slots: &mut [InventorySlotSnapshot],
|
||||
unequip: UnequipInventoryItemInput,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<InventoryMutationInternalOutcome, InventoryMutationFieldError> {
|
||||
let slot_id =
|
||||
normalize_required_text(unequip.slot_id, InventoryMutationFieldError::MissingSlotId)?;
|
||||
let slot_index = slots
|
||||
.iter()
|
||||
.position(|slot| slot.slot_id == slot_id)
|
||||
.ok_or(InventoryMutationFieldError::ItemNotFound)?;
|
||||
|
||||
if slots[slot_index].container_kind != InventoryContainerKind::Equipment {
|
||||
return Err(InventoryMutationFieldError::ItemNotEquipped);
|
||||
}
|
||||
|
||||
let affected_equipment_slot = slots[slot_index].equipment_slot_id;
|
||||
slots[slot_index].container_kind = InventoryContainerKind::Backpack;
|
||||
slots[slot_index].slot_key = build_backpack_slot_key(&slot_id);
|
||||
slots[slot_index].updated_at_micros = updated_at_micros;
|
||||
|
||||
Ok(InventoryMutationInternalOutcome {
|
||||
changed: true,
|
||||
updated_slot_ids: vec![slot_id],
|
||||
removed_slot_ids: vec![],
|
||||
affected_equipment_slot,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_inventory_item_snapshot(
|
||||
item: InventoryItemSnapshot,
|
||||
) -> Result<InventoryItemSnapshot, InventoryMutationFieldError> {
|
||||
let item_id =
|
||||
normalize_required_text(item.item_id, InventoryMutationFieldError::MissingItemId)?;
|
||||
let category =
|
||||
normalize_required_text(item.category, InventoryMutationFieldError::MissingCategory)?;
|
||||
let name = normalize_required_text(item.name, InventoryMutationFieldError::MissingName)?;
|
||||
if item.quantity == 0 {
|
||||
return Err(InventoryMutationFieldError::InvalidQuantity);
|
||||
}
|
||||
|
||||
if !item.stackable && item.quantity != 1 {
|
||||
return Err(InventoryMutationFieldError::NonStackableItemMustStaySingleQuantity);
|
||||
}
|
||||
|
||||
if item.equipment_slot_id.is_some() && item.stackable {
|
||||
return Err(InventoryMutationFieldError::EquipmentItemCannotStack);
|
||||
}
|
||||
|
||||
let stack_key = if item.stackable {
|
||||
normalize_required_text(item.stack_key, InventoryMutationFieldError::MissingStackKey)?
|
||||
} else {
|
||||
normalize_optional_text(Some(item.stack_key)).unwrap_or_else(|| item_id.clone())
|
||||
};
|
||||
|
||||
Ok(InventoryItemSnapshot {
|
||||
item_id,
|
||||
category,
|
||||
name,
|
||||
description: normalize_optional_text(item.description),
|
||||
quantity: item.quantity,
|
||||
rarity: item.rarity,
|
||||
tags: normalize_string_list(item.tags),
|
||||
stackable: item.stackable,
|
||||
stack_key,
|
||||
equipment_slot_id: item.equipment_slot_id,
|
||||
source_kind: item.source_kind,
|
||||
source_reference_id: normalize_optional_text(item.source_reference_id),
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_required_text(
|
||||
value: String,
|
||||
error: InventoryMutationFieldError,
|
||||
) -> Result<String, InventoryMutationFieldError> {
|
||||
normalize_required_string(value).ok_or(error)
|
||||
}
|
||||
|
||||
fn sort_inventory_slots(mut slots: Vec<InventorySlotSnapshot>) -> Vec<InventorySlotSnapshot> {
|
||||
slots.sort_by(|left, right| {
|
||||
container_order(left.container_kind)
|
||||
.cmp(&container_order(right.container_kind))
|
||||
.then(left.slot_key.cmp(&right.slot_key))
|
||||
.then(left.slot_id.cmp(&right.slot_id))
|
||||
});
|
||||
slots
|
||||
}
|
||||
|
||||
fn sort_string_list(mut values: Vec<String>) -> Vec<String> {
|
||||
values.sort();
|
||||
values
|
||||
}
|
||||
|
||||
fn container_order(kind: InventoryContainerKind) -> u8 {
|
||||
match kind {
|
||||
InventoryContainerKind::Equipment => 0,
|
||||
InventoryContainerKind::Backpack => 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn equipment_slot_order(slot: Option<InventoryEquipmentSlot>) -> u8 {
|
||||
match slot {
|
||||
Some(InventoryEquipmentSlot::Weapon) => 0,
|
||||
Some(InventoryEquipmentSlot::Armor) => 1,
|
||||
Some(InventoryEquipmentSlot::Relic) => 2,
|
||||
None => 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_backpack_slot_key(slot_id: &str) -> String {
|
||||
slot_id.to_string()
|
||||
}
|
||||
|
||||
fn build_equipment_slot_key(slot: InventoryEquipmentSlot) -> String {
|
||||
slot.as_str().to_string()
|
||||
}
|
||||
|
||||
pub fn build_runtime_inventory_state_record(
|
||||
snapshot: RuntimeInventoryStateSnapshot,
|
||||
) -> RuntimeInventoryStateRecord {
|
||||
RuntimeInventoryStateRecord {
|
||||
runtime_session_id: snapshot.runtime_session_id,
|
||||
actor_user_id: snapshot.actor_user_id,
|
||||
backpack_items: snapshot
|
||||
.backpack_items
|
||||
.into_iter()
|
||||
.map(build_runtime_inventory_slot_record)
|
||||
.collect(),
|
||||
equipment_items: snapshot
|
||||
.equipment_items
|
||||
.into_iter()
|
||||
.map(build_runtime_inventory_slot_record)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_runtime_inventory_slot_record(slot: InventorySlotSnapshot) -> RuntimeInventorySlotRecord {
|
||||
RuntimeInventorySlotRecord {
|
||||
slot_id: slot.slot_id,
|
||||
container_kind: format_inventory_container_kind(slot.container_kind).to_string(),
|
||||
slot_key: slot.slot_key,
|
||||
item_id: slot.item_id,
|
||||
category: slot.category,
|
||||
name: slot.name,
|
||||
description: slot.description,
|
||||
quantity: slot.quantity,
|
||||
rarity: format_inventory_item_rarity(slot.rarity).to_string(),
|
||||
tags: slot.tags,
|
||||
stackable: slot.stackable,
|
||||
stack_key: slot.stack_key,
|
||||
equipment_slot_id: slot
|
||||
.equipment_slot_id
|
||||
.map(|value| value.as_str().to_string()),
|
||||
source_kind: format_inventory_item_source_kind(slot.source_kind).to_string(),
|
||||
source_reference_id: slot.source_reference_id,
|
||||
created_at: format_timestamp_micros(slot.created_at_micros),
|
||||
updated_at: format_timestamp_micros(slot.updated_at_micros),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_inventory_container_kind(value: InventoryContainerKind) -> &'static str {
|
||||
match value {
|
||||
InventoryContainerKind::Backpack => "backpack",
|
||||
InventoryContainerKind::Equipment => "equipment",
|
||||
}
|
||||
}
|
||||
|
||||
fn format_inventory_item_rarity(value: InventoryItemRarity) -> &'static str {
|
||||
match value {
|
||||
InventoryItemRarity::Common => "common",
|
||||
InventoryItemRarity::Uncommon => "uncommon",
|
||||
InventoryItemRarity::Rare => "rare",
|
||||
InventoryItemRarity::Epic => "epic",
|
||||
InventoryItemRarity::Legendary => "legendary",
|
||||
}
|
||||
}
|
||||
|
||||
fn format_inventory_item_source_kind(value: InventoryItemSourceKind) -> &'static str {
|
||||
match value {
|
||||
InventoryItemSourceKind::StoryReward => "story_reward",
|
||||
InventoryItemSourceKind::QuestReward => "quest_reward",
|
||||
InventoryItemSourceKind::TreasureReward => "treasure_reward",
|
||||
InventoryItemSourceKind::NpcGift => "npc_gift",
|
||||
InventoryItemSourceKind::NpcTrade => "npc_trade",
|
||||
InventoryItemSourceKind::CombatDrop => "combat_drop",
|
||||
InventoryItemSourceKind::ForgeCraft => "forge_craft",
|
||||
InventoryItemSourceKind::ForgeReforge => "forge_reforge",
|
||||
InventoryItemSourceKind::ManualPatch => "manual_patch",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,61 @@
|
||||
//! 背包写入命令过渡落位。
|
||||
//! 背包写入命令。
|
||||
//!
|
||||
//! 用于表达授予物品、装备、卸下、消耗和整理等输入。
|
||||
|
||||
use crate::domain::InventoryItemSnapshot;
|
||||
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 GrantInventoryItemInput {
|
||||
pub slot_id: String,
|
||||
pub item: InventoryItemSnapshot,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ConsumeInventoryItemInput {
|
||||
pub slot_id: String,
|
||||
pub quantity: u32,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct EquipInventoryItemInput {
|
||||
pub slot_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UnequipInventoryItemInput {
|
||||
pub slot_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum InventoryMutation {
|
||||
GrantItem(GrantInventoryItemInput),
|
||||
ConsumeItem(ConsumeInventoryItemInput),
|
||||
EquipItem(EquipInventoryItemInput),
|
||||
UnequipItem(UnequipInventoryItemInput),
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct InventoryMutationInput {
|
||||
pub mutation_id: String,
|
||||
pub runtime_session_id: String,
|
||||
pub story_session_id: Option<String>,
|
||||
pub actor_user_id: String,
|
||||
pub mutation: InventoryMutation,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RuntimeInventoryStateQueryInput {
|
||||
pub runtime_session_id: String,
|
||||
pub actor_user_id: String,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,111 @@
|
||||
//! 背包领域模型过渡落位。
|
||||
//! 背包领域模型。
|
||||
//!
|
||||
//! 后续迁移背包槽、装备槽、堆叠和消耗规则时,只保留物品状态变化;
|
||||
//! 本文件只承载背包槽、装备槽、堆叠和物品来源等稳定值对象;
|
||||
//! SpacetimeDB 表查询写回由 adapter 处理。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared_kernel::build_prefixed_seed_id;
|
||||
#[cfg(feature = "spacetime-types")]
|
||||
use spacetimedb::SpacetimeType;
|
||||
|
||||
pub const INVENTORY_SLOT_ID_PREFIX: &str = "invslot_";
|
||||
pub const INVENTORY_MUTATION_ID_PREFIX: &str = "invmut_";
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum InventoryContainerKind {
|
||||
Backpack,
|
||||
Equipment,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum InventoryItemRarity {
|
||||
Common,
|
||||
Uncommon,
|
||||
Rare,
|
||||
Epic,
|
||||
Legendary,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum InventoryEquipmentSlot {
|
||||
Weapon,
|
||||
Armor,
|
||||
Relic,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum InventoryItemSourceKind {
|
||||
StoryReward,
|
||||
QuestReward,
|
||||
TreasureReward,
|
||||
NpcGift,
|
||||
NpcTrade,
|
||||
CombatDrop,
|
||||
ForgeCraft,
|
||||
ForgeReforge,
|
||||
ManualPatch,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct InventoryItemSnapshot {
|
||||
pub item_id: String,
|
||||
pub category: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub quantity: u32,
|
||||
pub rarity: InventoryItemRarity,
|
||||
pub tags: Vec<String>,
|
||||
pub stackable: bool,
|
||||
pub stack_key: String,
|
||||
pub equipment_slot_id: Option<InventoryEquipmentSlot>,
|
||||
pub source_kind: InventoryItemSourceKind,
|
||||
pub source_reference_id: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct InventorySlotSnapshot {
|
||||
pub slot_id: String,
|
||||
pub runtime_session_id: String,
|
||||
pub story_session_id: Option<String>,
|
||||
pub actor_user_id: String,
|
||||
pub container_kind: InventoryContainerKind,
|
||||
pub slot_key: String,
|
||||
pub item_id: String,
|
||||
pub category: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub quantity: u32,
|
||||
pub rarity: InventoryItemRarity,
|
||||
pub tags: Vec<String>,
|
||||
pub stackable: bool,
|
||||
pub stack_key: String,
|
||||
pub equipment_slot_id: Option<InventoryEquipmentSlot>,
|
||||
pub source_kind: InventoryItemSourceKind,
|
||||
pub source_reference_id: Option<String>,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
impl InventoryEquipmentSlot {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Weapon => "weapon",
|
||||
Self::Armor => "armor",
|
||||
Self::Relic => "relic",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_inventory_slot_id(seed_micros: i64) -> String {
|
||||
build_prefixed_seed_id(INVENTORY_SLOT_ID_PREFIX, seed_micros)
|
||||
}
|
||||
|
||||
pub fn generate_inventory_mutation_id(seed_micros: i64) -> String {
|
||||
build_prefixed_seed_id(INVENTORY_MUTATION_ID_PREFIX, seed_micros)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,58 @@
|
||||
//! 背包领域错误过渡落位。
|
||||
//! 背包领域错误。
|
||||
//!
|
||||
//! 错误保持可测试的业务语义,例如数量不足、槽位冲突和物品不存在。
|
||||
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum InventoryMutationFieldError {
|
||||
MissingMutationId,
|
||||
MissingRuntimeSessionId,
|
||||
MissingActorUserId,
|
||||
MissingSlotId,
|
||||
MissingItemId,
|
||||
MissingCategory,
|
||||
MissingName,
|
||||
InvalidQuantity,
|
||||
MissingStackKey,
|
||||
NonStackableItemMustStaySingleQuantity,
|
||||
EquipmentItemCannotStack,
|
||||
SlotScopeMismatch,
|
||||
ItemNotFound,
|
||||
ItemNotInBackpack,
|
||||
ItemNotEquipped,
|
||||
InsufficientQuantity,
|
||||
ItemNotEquippable,
|
||||
}
|
||||
|
||||
impl fmt::Display for InventoryMutationFieldError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::MissingMutationId => f.write_str("inventory_mutation.mutation_id 不能为空"),
|
||||
Self::MissingRuntimeSessionId => {
|
||||
f.write_str("inventory_mutation.runtime_session_id 不能为空")
|
||||
}
|
||||
Self::MissingActorUserId => f.write_str("inventory_mutation.actor_user_id 不能为空"),
|
||||
Self::MissingSlotId => f.write_str("inventory_slot.slot_id 不能为空"),
|
||||
Self::MissingItemId => f.write_str("inventory_item.item_id 不能为空"),
|
||||
Self::MissingCategory => f.write_str("inventory_item.category 不能为空"),
|
||||
Self::MissingName => f.write_str("inventory_item.name 不能为空"),
|
||||
Self::InvalidQuantity => f.write_str("inventory_item.quantity 必须大于 0"),
|
||||
Self::MissingStackKey => f.write_str("可堆叠物品必须提供 stack_key"),
|
||||
Self::NonStackableItemMustStaySingleQuantity => {
|
||||
f.write_str("不可堆叠物品必须固定为单槽位单数量")
|
||||
}
|
||||
Self::EquipmentItemCannotStack => f.write_str("可装备物品不能标记为 stackable"),
|
||||
Self::SlotScopeMismatch => {
|
||||
f.write_str("当前 inventory_slot 不属于本次 mutation 作用域")
|
||||
}
|
||||
Self::ItemNotFound => f.write_str("目标 inventory_slot 不存在"),
|
||||
Self::ItemNotInBackpack => f.write_str("目标物品当前不在背包中"),
|
||||
Self::ItemNotEquipped => f.write_str("目标物品当前不在装备位上"),
|
||||
Self::InsufficientQuantity => f.write_str("当前背包数量不足,无法完成扣减"),
|
||||
Self::ItemNotEquippable => f.write_str("目标物品当前不可装备"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for InventoryMutationFieldError {}
|
||||
|
||||
@@ -1,3 +1,41 @@
|
||||
//! 背包领域事件过渡落位。
|
||||
//! 背包领域事件。
|
||||
//!
|
||||
//! 用于表达物品获得、物品消耗、装备变化和槽位投影变化等事实。
|
||||
|
||||
use crate::domain::InventoryEquipmentSlot;
|
||||
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 InventoryDomainEvent {
|
||||
ItemGranted(InventoryItemGrantedEvent),
|
||||
ItemConsumed(InventoryItemConsumedEvent),
|
||||
EquipmentChanged(InventoryEquipmentChangedEvent),
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct InventoryItemGrantedEvent {
|
||||
pub slot_id: String,
|
||||
pub runtime_session_id: String,
|
||||
pub actor_user_id: String,
|
||||
pub occurred_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct InventoryItemConsumedEvent {
|
||||
pub slot_id: String,
|
||||
pub quantity: u32,
|
||||
pub occurred_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct InventoryEquipmentChangedEvent {
|
||||
pub slot_id: String,
|
||||
pub equipment_slot: InventoryEquipmentSlot,
|
||||
pub occurred_at_micros: i64,
|
||||
}
|
||||
|
||||
@@ -4,768 +4,11 @@ mod domain;
|
||||
mod errors;
|
||||
mod events;
|
||||
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared_kernel::{
|
||||
build_prefixed_seed_id, format_timestamp_micros,
|
||||
normalize_optional_string as normalize_shared_optional_string, normalize_required_string,
|
||||
normalize_string_list as normalize_shared_string_list,
|
||||
};
|
||||
#[cfg(feature = "spacetime-types")]
|
||||
use spacetimedb::SpacetimeType;
|
||||
|
||||
pub const INVENTORY_SLOT_ID_PREFIX: &str = "invslot_";
|
||||
pub const INVENTORY_MUTATION_ID_PREFIX: &str = "invmut_";
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum InventoryContainerKind {
|
||||
Backpack,
|
||||
Equipment,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum InventoryItemRarity {
|
||||
Common,
|
||||
Uncommon,
|
||||
Rare,
|
||||
Epic,
|
||||
Legendary,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum InventoryEquipmentSlot {
|
||||
Weapon,
|
||||
Armor,
|
||||
Relic,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum InventoryItemSourceKind {
|
||||
StoryReward,
|
||||
QuestReward,
|
||||
TreasureReward,
|
||||
NpcGift,
|
||||
NpcTrade,
|
||||
CombatDrop,
|
||||
ForgeCraft,
|
||||
ForgeReforge,
|
||||
ManualPatch,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct InventoryItemSnapshot {
|
||||
pub item_id: String,
|
||||
pub category: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub quantity: u32,
|
||||
pub rarity: InventoryItemRarity,
|
||||
pub tags: Vec<String>,
|
||||
pub stackable: bool,
|
||||
pub stack_key: String,
|
||||
pub equipment_slot_id: Option<InventoryEquipmentSlot>,
|
||||
pub source_kind: InventoryItemSourceKind,
|
||||
pub source_reference_id: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct InventorySlotSnapshot {
|
||||
pub slot_id: String,
|
||||
pub runtime_session_id: String,
|
||||
pub story_session_id: Option<String>,
|
||||
pub actor_user_id: String,
|
||||
pub container_kind: InventoryContainerKind,
|
||||
pub slot_key: String,
|
||||
pub item_id: String,
|
||||
pub category: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub quantity: u32,
|
||||
pub rarity: InventoryItemRarity,
|
||||
pub tags: Vec<String>,
|
||||
pub stackable: bool,
|
||||
pub stack_key: String,
|
||||
pub equipment_slot_id: Option<InventoryEquipmentSlot>,
|
||||
pub source_kind: InventoryItemSourceKind,
|
||||
pub source_reference_id: Option<String>,
|
||||
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 GrantInventoryItemInput {
|
||||
pub slot_id: String,
|
||||
pub item: InventoryItemSnapshot,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ConsumeInventoryItemInput {
|
||||
pub slot_id: String,
|
||||
pub quantity: u32,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct EquipInventoryItemInput {
|
||||
pub slot_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UnequipInventoryItemInput {
|
||||
pub slot_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum InventoryMutation {
|
||||
GrantItem(GrantInventoryItemInput),
|
||||
ConsumeItem(ConsumeInventoryItemInput),
|
||||
EquipItem(EquipInventoryItemInput),
|
||||
UnequipItem(UnequipInventoryItemInput),
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct InventoryMutationInput {
|
||||
pub mutation_id: String,
|
||||
pub runtime_session_id: String,
|
||||
pub story_session_id: Option<String>,
|
||||
pub actor_user_id: String,
|
||||
pub mutation: InventoryMutation,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RuntimeInventoryStateQueryInput {
|
||||
pub runtime_session_id: String,
|
||||
pub actor_user_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RuntimeInventoryStateSnapshot {
|
||||
pub runtime_session_id: String,
|
||||
pub actor_user_id: String,
|
||||
pub backpack_items: Vec<InventorySlotSnapshot>,
|
||||
pub equipment_items: Vec<InventorySlotSnapshot>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RuntimeInventoryStateProcedureResult {
|
||||
pub ok: bool,
|
||||
pub snapshot: Option<RuntimeInventoryStateSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RuntimeInventorySlotRecord {
|
||||
pub slot_id: String,
|
||||
pub container_kind: String,
|
||||
pub slot_key: String,
|
||||
pub item_id: String,
|
||||
pub category: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub quantity: u32,
|
||||
pub rarity: String,
|
||||
pub tags: Vec<String>,
|
||||
pub stackable: bool,
|
||||
pub stack_key: String,
|
||||
pub equipment_slot_id: Option<String>,
|
||||
pub source_kind: String,
|
||||
pub source_reference_id: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RuntimeInventoryStateRecord {
|
||||
pub runtime_session_id: String,
|
||||
pub actor_user_id: String,
|
||||
pub backpack_items: Vec<RuntimeInventorySlotRecord>,
|
||||
pub equipment_items: Vec<RuntimeInventorySlotRecord>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct InventoryMutationOutcome {
|
||||
pub next_slots: Vec<InventorySlotSnapshot>,
|
||||
pub changed: bool,
|
||||
pub updated_slot_ids: Vec<String>,
|
||||
pub removed_slot_ids: Vec<String>,
|
||||
pub affected_equipment_slot: Option<InventoryEquipmentSlot>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum InventoryMutationFieldError {
|
||||
MissingMutationId,
|
||||
MissingRuntimeSessionId,
|
||||
MissingActorUserId,
|
||||
MissingSlotId,
|
||||
MissingItemId,
|
||||
MissingCategory,
|
||||
MissingName,
|
||||
InvalidQuantity,
|
||||
MissingStackKey,
|
||||
NonStackableItemMustStaySingleQuantity,
|
||||
EquipmentItemCannotStack,
|
||||
SlotScopeMismatch,
|
||||
ItemNotFound,
|
||||
ItemNotInBackpack,
|
||||
ItemNotEquipped,
|
||||
InsufficientQuantity,
|
||||
ItemNotEquippable,
|
||||
}
|
||||
|
||||
impl InventoryEquipmentSlot {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Weapon => "weapon",
|
||||
Self::Armor => "armor",
|
||||
Self::Relic => "relic",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_inventory_slot_id(seed_micros: i64) -> String {
|
||||
build_prefixed_seed_id(INVENTORY_SLOT_ID_PREFIX, seed_micros)
|
||||
}
|
||||
|
||||
pub fn generate_inventory_mutation_id(seed_micros: i64) -> String {
|
||||
build_prefixed_seed_id(INVENTORY_MUTATION_ID_PREFIX, seed_micros)
|
||||
}
|
||||
|
||||
pub fn normalize_optional_text(value: Option<String>) -> Option<String> {
|
||||
normalize_shared_optional_string(value)
|
||||
}
|
||||
|
||||
pub fn normalize_string_list(values: Vec<String>) -> Vec<String> {
|
||||
normalize_shared_string_list(values)
|
||||
}
|
||||
|
||||
pub fn build_runtime_inventory_state_query_input(
|
||||
runtime_session_id: String,
|
||||
actor_user_id: String,
|
||||
) -> Result<RuntimeInventoryStateQueryInput, InventoryMutationFieldError> {
|
||||
let input = RuntimeInventoryStateQueryInput {
|
||||
runtime_session_id: normalize_required_text(
|
||||
runtime_session_id,
|
||||
InventoryMutationFieldError::MissingRuntimeSessionId,
|
||||
)?,
|
||||
actor_user_id: normalize_required_text(
|
||||
actor_user_id,
|
||||
InventoryMutationFieldError::MissingActorUserId,
|
||||
)?,
|
||||
};
|
||||
|
||||
Ok(input)
|
||||
}
|
||||
|
||||
pub fn build_runtime_inventory_state_snapshot(
|
||||
input: RuntimeInventoryStateQueryInput,
|
||||
slots: Vec<InventorySlotSnapshot>,
|
||||
) -> RuntimeInventoryStateSnapshot {
|
||||
let mut backpack_items = Vec::new();
|
||||
let mut equipment_items = Vec::new();
|
||||
|
||||
for slot in slots {
|
||||
match slot.container_kind {
|
||||
InventoryContainerKind::Backpack => backpack_items.push(slot),
|
||||
InventoryContainerKind::Equipment => equipment_items.push(slot),
|
||||
}
|
||||
}
|
||||
|
||||
backpack_items.sort_by(|left, right| {
|
||||
left.slot_key
|
||||
.cmp(&right.slot_key)
|
||||
.then(left.slot_id.cmp(&right.slot_id))
|
||||
});
|
||||
equipment_items.sort_by(|left, right| {
|
||||
equipment_slot_order(left.equipment_slot_id)
|
||||
.cmp(&equipment_slot_order(right.equipment_slot_id))
|
||||
.then(left.slot_id.cmp(&right.slot_id))
|
||||
});
|
||||
|
||||
RuntimeInventoryStateSnapshot {
|
||||
runtime_session_id: input.runtime_session_id,
|
||||
actor_user_id: input.actor_user_id,
|
||||
backpack_items,
|
||||
equipment_items,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_inventory_mutation(
|
||||
current_slots: Vec<InventorySlotSnapshot>,
|
||||
input: InventoryMutationInput,
|
||||
) -> Result<InventoryMutationOutcome, InventoryMutationFieldError> {
|
||||
let _mutation_id = normalize_required_text(
|
||||
input.mutation_id,
|
||||
InventoryMutationFieldError::MissingMutationId,
|
||||
)?;
|
||||
let runtime_session_id = normalize_required_text(
|
||||
input.runtime_session_id,
|
||||
InventoryMutationFieldError::MissingRuntimeSessionId,
|
||||
)?;
|
||||
let actor_user_id = normalize_required_text(
|
||||
input.actor_user_id,
|
||||
InventoryMutationFieldError::MissingActorUserId,
|
||||
)?;
|
||||
let story_session_id = normalize_optional_text(input.story_session_id);
|
||||
|
||||
let mut slots = current_slots;
|
||||
for slot in &slots {
|
||||
if slot.runtime_session_id != runtime_session_id || slot.actor_user_id != actor_user_id {
|
||||
return Err(InventoryMutationFieldError::SlotScopeMismatch);
|
||||
}
|
||||
}
|
||||
|
||||
let outcome = match input.mutation {
|
||||
InventoryMutation::GrantItem(grant) => apply_grant_item(
|
||||
&mut slots,
|
||||
runtime_session_id,
|
||||
story_session_id,
|
||||
actor_user_id,
|
||||
grant,
|
||||
input.updated_at_micros,
|
||||
)?,
|
||||
InventoryMutation::ConsumeItem(consume) => {
|
||||
apply_consume_item(&mut slots, consume, input.updated_at_micros)?
|
||||
}
|
||||
InventoryMutation::EquipItem(equip) => {
|
||||
apply_equip_item(&mut slots, equip, input.updated_at_micros)?
|
||||
}
|
||||
InventoryMutation::UnequipItem(unequip) => {
|
||||
apply_unequip_item(&mut slots, unequip, input.updated_at_micros)?
|
||||
}
|
||||
};
|
||||
|
||||
Ok(InventoryMutationOutcome {
|
||||
next_slots: sort_inventory_slots(slots),
|
||||
changed: outcome.changed,
|
||||
updated_slot_ids: sort_string_list(outcome.updated_slot_ids),
|
||||
removed_slot_ids: sort_string_list(outcome.removed_slot_ids),
|
||||
affected_equipment_slot: outcome.affected_equipment_slot,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct InventoryMutationInternalOutcome {
|
||||
changed: bool,
|
||||
updated_slot_ids: Vec<String>,
|
||||
removed_slot_ids: Vec<String>,
|
||||
affected_equipment_slot: Option<InventoryEquipmentSlot>,
|
||||
}
|
||||
|
||||
fn apply_grant_item(
|
||||
slots: &mut Vec<InventorySlotSnapshot>,
|
||||
runtime_session_id: String,
|
||||
story_session_id: Option<String>,
|
||||
actor_user_id: String,
|
||||
grant: GrantInventoryItemInput,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<InventoryMutationInternalOutcome, InventoryMutationFieldError> {
|
||||
let slot_id =
|
||||
normalize_required_text(grant.slot_id, InventoryMutationFieldError::MissingSlotId)?;
|
||||
let item = normalize_inventory_item_snapshot(grant.item)?;
|
||||
|
||||
if item.stackable {
|
||||
if let Some(existing) = slots.iter_mut().find(|slot| {
|
||||
slot.container_kind == InventoryContainerKind::Backpack
|
||||
&& slot.stackable
|
||||
&& slot.item_id == item.item_id
|
||||
&& slot.stack_key == item.stack_key
|
||||
}) {
|
||||
existing.category = item.category;
|
||||
existing.name = item.name;
|
||||
existing.description = item.description;
|
||||
existing.quantity += item.quantity;
|
||||
existing.rarity = item.rarity;
|
||||
existing.tags = item.tags;
|
||||
existing.stackable = item.stackable;
|
||||
existing.stack_key = item.stack_key;
|
||||
existing.equipment_slot_id = item.equipment_slot_id;
|
||||
existing.source_kind = item.source_kind;
|
||||
existing.source_reference_id = item.source_reference_id;
|
||||
existing.updated_at_micros = updated_at_micros;
|
||||
|
||||
return Ok(InventoryMutationInternalOutcome {
|
||||
changed: true,
|
||||
updated_slot_ids: vec![existing.slot_id.clone()],
|
||||
removed_slot_ids: vec![],
|
||||
affected_equipment_slot: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
slots.push(InventorySlotSnapshot {
|
||||
slot_id: slot_id.clone(),
|
||||
runtime_session_id,
|
||||
story_session_id,
|
||||
actor_user_id,
|
||||
container_kind: InventoryContainerKind::Backpack,
|
||||
slot_key: build_backpack_slot_key(&slot_id),
|
||||
item_id: item.item_id,
|
||||
category: item.category,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
rarity: item.rarity,
|
||||
tags: item.tags,
|
||||
stackable: item.stackable,
|
||||
stack_key: item.stack_key,
|
||||
equipment_slot_id: item.equipment_slot_id,
|
||||
source_kind: item.source_kind,
|
||||
source_reference_id: item.source_reference_id,
|
||||
created_at_micros: updated_at_micros,
|
||||
updated_at_micros,
|
||||
});
|
||||
|
||||
Ok(InventoryMutationInternalOutcome {
|
||||
changed: true,
|
||||
updated_slot_ids: vec![slot_id],
|
||||
removed_slot_ids: vec![],
|
||||
affected_equipment_slot: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_consume_item(
|
||||
slots: &mut Vec<InventorySlotSnapshot>,
|
||||
consume: ConsumeInventoryItemInput,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<InventoryMutationInternalOutcome, InventoryMutationFieldError> {
|
||||
let slot_id =
|
||||
normalize_required_text(consume.slot_id, InventoryMutationFieldError::MissingSlotId)?;
|
||||
if consume.quantity == 0 {
|
||||
return Err(InventoryMutationFieldError::InvalidQuantity);
|
||||
}
|
||||
|
||||
let slot_index = slots
|
||||
.iter()
|
||||
.position(|slot| slot.slot_id == slot_id)
|
||||
.ok_or(InventoryMutationFieldError::ItemNotFound)?;
|
||||
|
||||
if slots[slot_index].container_kind != InventoryContainerKind::Backpack {
|
||||
return Err(InventoryMutationFieldError::ItemNotInBackpack);
|
||||
}
|
||||
|
||||
if slots[slot_index].quantity < consume.quantity {
|
||||
return Err(InventoryMutationFieldError::InsufficientQuantity);
|
||||
}
|
||||
|
||||
if slots[slot_index].quantity == consume.quantity {
|
||||
slots.remove(slot_index);
|
||||
return Ok(InventoryMutationInternalOutcome {
|
||||
changed: true,
|
||||
updated_slot_ids: vec![],
|
||||
removed_slot_ids: vec![slot_id],
|
||||
affected_equipment_slot: None,
|
||||
});
|
||||
}
|
||||
|
||||
slots[slot_index].quantity -= consume.quantity;
|
||||
slots[slot_index].updated_at_micros = updated_at_micros;
|
||||
|
||||
Ok(InventoryMutationInternalOutcome {
|
||||
changed: true,
|
||||
updated_slot_ids: vec![slots[slot_index].slot_id.clone()],
|
||||
removed_slot_ids: vec![],
|
||||
affected_equipment_slot: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_equip_item(
|
||||
slots: &mut [InventorySlotSnapshot],
|
||||
equip: EquipInventoryItemInput,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<InventoryMutationInternalOutcome, InventoryMutationFieldError> {
|
||||
let slot_id =
|
||||
normalize_required_text(equip.slot_id, InventoryMutationFieldError::MissingSlotId)?;
|
||||
let source_index = slots
|
||||
.iter()
|
||||
.position(|slot| slot.slot_id == slot_id)
|
||||
.ok_or(InventoryMutationFieldError::ItemNotFound)?;
|
||||
let target_slot = slots[source_index]
|
||||
.equipment_slot_id
|
||||
.ok_or(InventoryMutationFieldError::ItemNotEquippable)?;
|
||||
|
||||
if slots[source_index].stackable {
|
||||
return Err(InventoryMutationFieldError::EquipmentItemCannotStack);
|
||||
}
|
||||
if slots[source_index].quantity != 1 {
|
||||
return Err(InventoryMutationFieldError::NonStackableItemMustStaySingleQuantity);
|
||||
}
|
||||
if slots[source_index].container_kind != InventoryContainerKind::Backpack {
|
||||
if slots[source_index].container_kind == InventoryContainerKind::Equipment {
|
||||
return Ok(InventoryMutationInternalOutcome {
|
||||
changed: false,
|
||||
updated_slot_ids: vec![],
|
||||
removed_slot_ids: vec![],
|
||||
affected_equipment_slot: Some(target_slot),
|
||||
});
|
||||
}
|
||||
|
||||
return Err(InventoryMutationFieldError::ItemNotInBackpack);
|
||||
}
|
||||
|
||||
let occupied_index = slots.iter().position(|slot| {
|
||||
slot.container_kind == InventoryContainerKind::Equipment
|
||||
&& slot.slot_key == build_equipment_slot_key(target_slot)
|
||||
});
|
||||
|
||||
let mut updated_slot_ids = vec![slot_id.clone()];
|
||||
if let Some(occupied_index) = occupied_index {
|
||||
// 首版装备互换直接在同一条 slot 真相记录上切容器,不生成临时副本。
|
||||
slots[occupied_index].container_kind = InventoryContainerKind::Backpack;
|
||||
slots[occupied_index].slot_key = build_backpack_slot_key(&slots[occupied_index].slot_id);
|
||||
slots[occupied_index].updated_at_micros = updated_at_micros;
|
||||
updated_slot_ids.push(slots[occupied_index].slot_id.clone());
|
||||
}
|
||||
|
||||
slots[source_index].container_kind = InventoryContainerKind::Equipment;
|
||||
slots[source_index].slot_key = build_equipment_slot_key(target_slot);
|
||||
slots[source_index].updated_at_micros = updated_at_micros;
|
||||
|
||||
Ok(InventoryMutationInternalOutcome {
|
||||
changed: true,
|
||||
updated_slot_ids,
|
||||
removed_slot_ids: vec![],
|
||||
affected_equipment_slot: Some(target_slot),
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_unequip_item(
|
||||
slots: &mut [InventorySlotSnapshot],
|
||||
unequip: UnequipInventoryItemInput,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<InventoryMutationInternalOutcome, InventoryMutationFieldError> {
|
||||
let slot_id =
|
||||
normalize_required_text(unequip.slot_id, InventoryMutationFieldError::MissingSlotId)?;
|
||||
let slot_index = slots
|
||||
.iter()
|
||||
.position(|slot| slot.slot_id == slot_id)
|
||||
.ok_or(InventoryMutationFieldError::ItemNotFound)?;
|
||||
|
||||
if slots[slot_index].container_kind != InventoryContainerKind::Equipment {
|
||||
return Err(InventoryMutationFieldError::ItemNotEquipped);
|
||||
}
|
||||
|
||||
let affected_equipment_slot = slots[slot_index].equipment_slot_id;
|
||||
slots[slot_index].container_kind = InventoryContainerKind::Backpack;
|
||||
slots[slot_index].slot_key = build_backpack_slot_key(&slot_id);
|
||||
slots[slot_index].updated_at_micros = updated_at_micros;
|
||||
|
||||
Ok(InventoryMutationInternalOutcome {
|
||||
changed: true,
|
||||
updated_slot_ids: vec![slot_id],
|
||||
removed_slot_ids: vec![],
|
||||
affected_equipment_slot,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_inventory_item_snapshot(
|
||||
item: InventoryItemSnapshot,
|
||||
) -> Result<InventoryItemSnapshot, InventoryMutationFieldError> {
|
||||
let item_id =
|
||||
normalize_required_text(item.item_id, InventoryMutationFieldError::MissingItemId)?;
|
||||
let category =
|
||||
normalize_required_text(item.category, InventoryMutationFieldError::MissingCategory)?;
|
||||
let name = normalize_required_text(item.name, InventoryMutationFieldError::MissingName)?;
|
||||
if item.quantity == 0 {
|
||||
return Err(InventoryMutationFieldError::InvalidQuantity);
|
||||
}
|
||||
|
||||
if !item.stackable && item.quantity != 1 {
|
||||
return Err(InventoryMutationFieldError::NonStackableItemMustStaySingleQuantity);
|
||||
}
|
||||
|
||||
if item.equipment_slot_id.is_some() && item.stackable {
|
||||
return Err(InventoryMutationFieldError::EquipmentItemCannotStack);
|
||||
}
|
||||
|
||||
let stack_key = if item.stackable {
|
||||
normalize_required_text(item.stack_key, InventoryMutationFieldError::MissingStackKey)?
|
||||
} else {
|
||||
normalize_optional_text(Some(item.stack_key)).unwrap_or_else(|| item_id.clone())
|
||||
};
|
||||
|
||||
Ok(InventoryItemSnapshot {
|
||||
item_id,
|
||||
category,
|
||||
name,
|
||||
description: normalize_optional_text(item.description),
|
||||
quantity: item.quantity,
|
||||
rarity: item.rarity,
|
||||
tags: normalize_string_list(item.tags),
|
||||
stackable: item.stackable,
|
||||
stack_key,
|
||||
equipment_slot_id: item.equipment_slot_id,
|
||||
source_kind: item.source_kind,
|
||||
source_reference_id: normalize_optional_text(item.source_reference_id),
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_required_text(
|
||||
value: String,
|
||||
error: InventoryMutationFieldError,
|
||||
) -> Result<String, InventoryMutationFieldError> {
|
||||
normalize_required_string(value).ok_or(error)
|
||||
}
|
||||
|
||||
fn sort_inventory_slots(mut slots: Vec<InventorySlotSnapshot>) -> Vec<InventorySlotSnapshot> {
|
||||
slots.sort_by(|left, right| {
|
||||
container_order(left.container_kind)
|
||||
.cmp(&container_order(right.container_kind))
|
||||
.then(left.slot_key.cmp(&right.slot_key))
|
||||
.then(left.slot_id.cmp(&right.slot_id))
|
||||
});
|
||||
slots
|
||||
}
|
||||
|
||||
fn sort_string_list(mut values: Vec<String>) -> Vec<String> {
|
||||
values.sort();
|
||||
values
|
||||
}
|
||||
|
||||
fn container_order(kind: InventoryContainerKind) -> u8 {
|
||||
match kind {
|
||||
InventoryContainerKind::Equipment => 0,
|
||||
InventoryContainerKind::Backpack => 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn equipment_slot_order(slot: Option<InventoryEquipmentSlot>) -> u8 {
|
||||
match slot {
|
||||
Some(InventoryEquipmentSlot::Weapon) => 0,
|
||||
Some(InventoryEquipmentSlot::Armor) => 1,
|
||||
Some(InventoryEquipmentSlot::Relic) => 2,
|
||||
None => 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_backpack_slot_key(slot_id: &str) -> String {
|
||||
slot_id.to_string()
|
||||
}
|
||||
|
||||
fn build_equipment_slot_key(slot: InventoryEquipmentSlot) -> String {
|
||||
slot.as_str().to_string()
|
||||
}
|
||||
|
||||
pub fn build_runtime_inventory_state_record(
|
||||
snapshot: RuntimeInventoryStateSnapshot,
|
||||
) -> RuntimeInventoryStateRecord {
|
||||
RuntimeInventoryStateRecord {
|
||||
runtime_session_id: snapshot.runtime_session_id,
|
||||
actor_user_id: snapshot.actor_user_id,
|
||||
backpack_items: snapshot
|
||||
.backpack_items
|
||||
.into_iter()
|
||||
.map(build_runtime_inventory_slot_record)
|
||||
.collect(),
|
||||
equipment_items: snapshot
|
||||
.equipment_items
|
||||
.into_iter()
|
||||
.map(build_runtime_inventory_slot_record)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_runtime_inventory_slot_record(slot: InventorySlotSnapshot) -> RuntimeInventorySlotRecord {
|
||||
RuntimeInventorySlotRecord {
|
||||
slot_id: slot.slot_id,
|
||||
container_kind: format_inventory_container_kind(slot.container_kind).to_string(),
|
||||
slot_key: slot.slot_key,
|
||||
item_id: slot.item_id,
|
||||
category: slot.category,
|
||||
name: slot.name,
|
||||
description: slot.description,
|
||||
quantity: slot.quantity,
|
||||
rarity: format_inventory_item_rarity(slot.rarity).to_string(),
|
||||
tags: slot.tags,
|
||||
stackable: slot.stackable,
|
||||
stack_key: slot.stack_key,
|
||||
equipment_slot_id: slot
|
||||
.equipment_slot_id
|
||||
.map(|value| value.as_str().to_string()),
|
||||
source_kind: format_inventory_item_source_kind(slot.source_kind).to_string(),
|
||||
source_reference_id: slot.source_reference_id,
|
||||
created_at: format_timestamp_micros(slot.created_at_micros),
|
||||
updated_at: format_timestamp_micros(slot.updated_at_micros),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_inventory_container_kind(value: InventoryContainerKind) -> &'static str {
|
||||
match value {
|
||||
InventoryContainerKind::Backpack => "backpack",
|
||||
InventoryContainerKind::Equipment => "equipment",
|
||||
}
|
||||
}
|
||||
|
||||
fn format_inventory_item_rarity(value: InventoryItemRarity) -> &'static str {
|
||||
match value {
|
||||
InventoryItemRarity::Common => "common",
|
||||
InventoryItemRarity::Uncommon => "uncommon",
|
||||
InventoryItemRarity::Rare => "rare",
|
||||
InventoryItemRarity::Epic => "epic",
|
||||
InventoryItemRarity::Legendary => "legendary",
|
||||
}
|
||||
}
|
||||
|
||||
fn format_inventory_item_source_kind(value: InventoryItemSourceKind) -> &'static str {
|
||||
match value {
|
||||
InventoryItemSourceKind::StoryReward => "story_reward",
|
||||
InventoryItemSourceKind::QuestReward => "quest_reward",
|
||||
InventoryItemSourceKind::TreasureReward => "treasure_reward",
|
||||
InventoryItemSourceKind::NpcGift => "npc_gift",
|
||||
InventoryItemSourceKind::NpcTrade => "npc_trade",
|
||||
InventoryItemSourceKind::CombatDrop => "combat_drop",
|
||||
InventoryItemSourceKind::ForgeCraft => "forge_craft",
|
||||
InventoryItemSourceKind::ForgeReforge => "forge_reforge",
|
||||
InventoryItemSourceKind::ManualPatch => "manual_patch",
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for InventoryMutationFieldError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::MissingMutationId => f.write_str("inventory_mutation.mutation_id 不能为空"),
|
||||
Self::MissingRuntimeSessionId => {
|
||||
f.write_str("inventory_mutation.runtime_session_id 不能为空")
|
||||
}
|
||||
Self::MissingActorUserId => f.write_str("inventory_mutation.actor_user_id 不能为空"),
|
||||
Self::MissingSlotId => f.write_str("inventory_slot.slot_id 不能为空"),
|
||||
Self::MissingItemId => f.write_str("inventory_item.item_id 不能为空"),
|
||||
Self::MissingCategory => f.write_str("inventory_item.category 不能为空"),
|
||||
Self::MissingName => f.write_str("inventory_item.name 不能为空"),
|
||||
Self::InvalidQuantity => f.write_str("inventory_item.quantity 必须大于 0"),
|
||||
Self::MissingStackKey => f.write_str("可堆叠物品必须提供 stack_key"),
|
||||
Self::NonStackableItemMustStaySingleQuantity => {
|
||||
f.write_str("不可堆叠物品必须固定为单槽位单数量")
|
||||
}
|
||||
Self::EquipmentItemCannotStack => f.write_str("可装备物品不能标记为 stackable"),
|
||||
Self::SlotScopeMismatch => {
|
||||
f.write_str("当前 inventory_slot 不属于本次 mutation 作用域")
|
||||
}
|
||||
Self::ItemNotFound => f.write_str("目标 inventory_slot 不存在"),
|
||||
Self::ItemNotInBackpack => f.write_str("目标物品当前不在背包中"),
|
||||
Self::ItemNotEquipped => f.write_str("目标物品当前不在装备位上"),
|
||||
Self::InsufficientQuantity => f.write_str("当前背包数量不足,无法完成扣减"),
|
||||
Self::ItemNotEquippable => f.write_str("目标物品当前不可装备"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for InventoryMutationFieldError {}
|
||||
pub use application::*;
|
||||
pub use commands::*;
|
||||
pub use domain::*;
|
||||
pub use errors::*;
|
||||
pub use events::*;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -1,3 +1,555 @@
|
||||
//! NPC 应用编排过渡落位。
|
||||
//! NPC 应用编排。
|
||||
//!
|
||||
//! 这里只返回关系变化、推荐动作和跨上下文事件,不直接写战斗表。
|
||||
|
||||
use crate::commands::{
|
||||
NpcStateUpsertInput, ResolveNpcInteractionInput, ResolveNpcSocialActionInput,
|
||||
};
|
||||
use crate::domain::*;
|
||||
use crate::errors::NpcStateFieldError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared_kernel::{
|
||||
normalize_optional_string as normalize_shared_optional_string, normalize_required_string,
|
||||
normalize_string_list as normalize_shared_string_list,
|
||||
};
|
||||
#[cfg(feature = "spacetime-types")]
|
||||
use spacetimedb::SpacetimeType;
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NpcStateProcedureResult {
|
||||
pub ok: bool,
|
||||
pub record: Option<NpcStateSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NpcInteractionResult {
|
||||
pub npc_state: NpcStateSnapshot,
|
||||
pub interaction_status: NpcInteractionStatus,
|
||||
pub action_text: String,
|
||||
pub result_text: String,
|
||||
pub story_text: Option<String>,
|
||||
pub battle_mode: Option<NpcInteractionBattleMode>,
|
||||
pub encounter_closed: bool,
|
||||
pub affinity_changed: bool,
|
||||
pub previous_affinity: i32,
|
||||
pub next_affinity: i32,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NpcInteractionProcedureResult {
|
||||
pub ok: bool,
|
||||
pub result: Option<NpcInteractionResult>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
pub fn generate_npc_state_id(runtime_session_id: &str, npc_id: &str) -> String {
|
||||
format!(
|
||||
"{}{}:{}",
|
||||
NPC_STATE_ID_PREFIX,
|
||||
runtime_session_id.trim(),
|
||||
npc_id.trim()
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_relation_state(affinity: i32) -> NpcRelationState {
|
||||
NpcRelationState {
|
||||
affinity,
|
||||
stance: if affinity < 0 {
|
||||
NpcRelationStance::Hostile
|
||||
} else if affinity < 15 {
|
||||
NpcRelationStance::Guarded
|
||||
} else if affinity < 30 {
|
||||
NpcRelationStance::Neutral
|
||||
} else if affinity < NPC_RECRUIT_AFFINITY_THRESHOLD {
|
||||
NpcRelationStance::Cooperative
|
||||
} else {
|
||||
NpcRelationStance::Bonded
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_initial_stance_profile(
|
||||
affinity: i32,
|
||||
recruited: bool,
|
||||
hostile: bool,
|
||||
role_text: Option<&str>,
|
||||
) -> NpcStanceProfile {
|
||||
let recruited_bonus = if recruited { 14.0 } else { 0.0 };
|
||||
let hostile_penalty = if hostile { 18.0 } else { 0.0 };
|
||||
let current_conflict_tag = role_text.and_then(infer_conflict_tag);
|
||||
|
||||
NpcStanceProfile {
|
||||
trust: clamp_stance_metric(
|
||||
42.0 + affinity as f32 * 0.55 + recruited_bonus - hostile_penalty,
|
||||
),
|
||||
warmth: clamp_stance_metric(36.0 + affinity as f32 * 0.5 + recruited_bonus),
|
||||
ideological_fit: clamp_stance_metric(48.0 + affinity as f32 * 0.25),
|
||||
fear_or_guard: clamp_stance_metric(62.0 - affinity as f32 * 0.55 + hostile_penalty),
|
||||
loyalty: clamp_stance_metric(
|
||||
24.0 + affinity as f32 * 0.35 + if recruited { 26.0 } else { 0.0 },
|
||||
),
|
||||
current_conflict_tag,
|
||||
recent_approvals: Vec::new(),
|
||||
recent_disapprovals: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_npc_state_snapshot(
|
||||
input: NpcStateUpsertInput,
|
||||
existing_created_at_micros: Option<i64>,
|
||||
) -> Result<NpcStateSnapshot, NpcStateFieldError> {
|
||||
validate_required_identity_fields(&input.runtime_session_id, &input.npc_id, &input.npc_name)?;
|
||||
|
||||
let affinity = input.affinity;
|
||||
let stance_profile = normalize_stance_profile(
|
||||
input.stance_profile,
|
||||
affinity,
|
||||
input.recruited,
|
||||
affinity < 0,
|
||||
None,
|
||||
);
|
||||
let created_at_micros = existing_created_at_micros.unwrap_or(input.updated_at_micros);
|
||||
|
||||
Ok(NpcStateSnapshot {
|
||||
npc_state_id: generate_npc_state_id(&input.runtime_session_id, &input.npc_id),
|
||||
runtime_session_id: normalize_required_string(input.runtime_session_id).unwrap_or_default(),
|
||||
npc_id: normalize_required_string(input.npc_id).unwrap_or_default(),
|
||||
npc_name: normalize_required_string(input.npc_name).unwrap_or_default(),
|
||||
affinity,
|
||||
relation_state: build_relation_state(affinity),
|
||||
help_used: input.help_used,
|
||||
chatted_count: input.chatted_count,
|
||||
gifts_given: input.gifts_given,
|
||||
recruited: input.recruited,
|
||||
trade_stock_signature: normalize_optional_value(input.trade_stock_signature),
|
||||
revealed_facts: normalize_string_list(input.revealed_facts),
|
||||
known_attribute_rumors: normalize_string_list(input.known_attribute_rumors),
|
||||
first_meaningful_contact_resolved: input.first_meaningful_contact_resolved,
|
||||
seen_backstory_chapter_ids: normalize_string_list(input.seen_backstory_chapter_ids),
|
||||
stance_profile,
|
||||
created_at_micros,
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn apply_npc_social_action(
|
||||
current: NpcStateSnapshot,
|
||||
input: ResolveNpcSocialActionInput,
|
||||
) -> Result<NpcStateSnapshot, NpcStateFieldError> {
|
||||
validate_required_identity_fields(&input.runtime_session_id, &input.npc_id, &input.npc_name)?;
|
||||
|
||||
let note = normalize_optional_value(input.note);
|
||||
let mut next = current;
|
||||
|
||||
match input.action_kind {
|
||||
NpcSocialActionKind::Chat => {
|
||||
let affinity_gain = input
|
||||
.affinity_gain_override
|
||||
.unwrap_or_else(|| (6 - next.chatted_count as i32).max(2));
|
||||
next.affinity += affinity_gain;
|
||||
next.chatted_count += 1;
|
||||
next.first_meaningful_contact_resolved = true;
|
||||
next.stance_profile = apply_story_choice_to_stance_profile(
|
||||
&next.stance_profile,
|
||||
input.action_kind,
|
||||
affinity_gain,
|
||||
next.recruited,
|
||||
note.as_deref(),
|
||||
);
|
||||
}
|
||||
NpcSocialActionKind::Help => {
|
||||
if next.help_used {
|
||||
return Err(NpcStateFieldError::HelpAlreadyUsed);
|
||||
}
|
||||
let affinity_gain = input.affinity_gain_override.unwrap_or(4);
|
||||
next.affinity += affinity_gain;
|
||||
next.help_used = true;
|
||||
next.stance_profile = apply_story_choice_to_stance_profile(
|
||||
&next.stance_profile,
|
||||
input.action_kind,
|
||||
affinity_gain,
|
||||
next.recruited,
|
||||
note.as_deref(),
|
||||
);
|
||||
}
|
||||
NpcSocialActionKind::Gift => {
|
||||
let affinity_gain = input.affinity_gain_override.unwrap_or(4);
|
||||
next.affinity += affinity_gain;
|
||||
next.gifts_given += 1;
|
||||
next.stance_profile = apply_story_choice_to_stance_profile(
|
||||
&next.stance_profile,
|
||||
input.action_kind,
|
||||
affinity_gain,
|
||||
next.recruited,
|
||||
note.as_deref(),
|
||||
);
|
||||
}
|
||||
NpcSocialActionKind::Recruit => {
|
||||
if next.affinity < NPC_RECRUIT_AFFINITY_THRESHOLD {
|
||||
return Err(NpcStateFieldError::RecruitAffinityTooLow);
|
||||
}
|
||||
next.recruited = true;
|
||||
next.first_meaningful_contact_resolved = true;
|
||||
next.stance_profile = apply_story_choice_to_stance_profile(
|
||||
&next.stance_profile,
|
||||
input.action_kind,
|
||||
input.affinity_gain_override.unwrap_or(0),
|
||||
true,
|
||||
note.as_deref(),
|
||||
);
|
||||
}
|
||||
NpcSocialActionKind::QuestAccept => {
|
||||
let affinity_gain = input.affinity_gain_override.unwrap_or(3);
|
||||
next.affinity += affinity_gain;
|
||||
next.stance_profile = apply_story_choice_to_stance_profile(
|
||||
&next.stance_profile,
|
||||
input.action_kind,
|
||||
affinity_gain,
|
||||
next.recruited,
|
||||
note.as_deref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
next.affinity = next.affinity.clamp(-100, 100);
|
||||
next.npc_name = normalize_required_string(input.npc_name).unwrap_or_default();
|
||||
next.relation_state = build_relation_state(next.affinity);
|
||||
next.updated_at_micros = input.updated_at_micros;
|
||||
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
pub fn resolve_npc_interaction(
|
||||
current: NpcStateSnapshot,
|
||||
input: ResolveNpcInteractionInput,
|
||||
) -> Result<NpcInteractionResult, NpcStateFieldError> {
|
||||
validate_required_identity_fields(&input.runtime_session_id, &input.npc_id, &input.npc_name)?;
|
||||
|
||||
let interaction_function_id = normalize_optional_value(Some(input.interaction_function_id))
|
||||
.ok_or(NpcStateFieldError::MissingInteractionFunctionId)?;
|
||||
if !is_supported_npc_interaction_function_id(&interaction_function_id) {
|
||||
return Err(NpcStateFieldError::UnsupportedInteractionFunctionId);
|
||||
}
|
||||
|
||||
let previous_affinity = current.affinity;
|
||||
let mut next_state = current.clone();
|
||||
|
||||
let (interaction_status, action_text, result_text, story_text, battle_mode, encounter_closed) =
|
||||
match interaction_function_id.as_str() {
|
||||
NPC_PREVIEW_TALK_FUNCTION_ID => (
|
||||
NpcInteractionStatus::Previewed,
|
||||
format!("转向{}", current.npc_name),
|
||||
format!(
|
||||
"你把注意力真正收回到{}身上,接下来可以围绕这名角色做正式交互了。",
|
||||
current.npc_name
|
||||
),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
),
|
||||
NPC_CHAT_FUNCTION_ID => {
|
||||
next_state = apply_npc_social_action(
|
||||
current,
|
||||
ResolveNpcSocialActionInput {
|
||||
runtime_session_id: input.runtime_session_id,
|
||||
npc_id: input.npc_id,
|
||||
npc_name: input.npc_name,
|
||||
action_kind: NpcSocialActionKind::Chat,
|
||||
affinity_gain_override: None,
|
||||
note: None,
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
},
|
||||
)?;
|
||||
(
|
||||
NpcInteractionStatus::Dialogue,
|
||||
format!("继续和{}交谈", next_state.npc_name),
|
||||
format!(
|
||||
"{}愿意把话接下去,态度比刚才明显松动了一些。",
|
||||
next_state.npc_name
|
||||
),
|
||||
Some(format!(
|
||||
"{}看起来已经愿意继续把话题往下接。",
|
||||
next_state.npc_name
|
||||
)),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
}
|
||||
NPC_HELP_FUNCTION_ID => {
|
||||
next_state = apply_npc_social_action(
|
||||
current,
|
||||
ResolveNpcSocialActionInput {
|
||||
runtime_session_id: input.runtime_session_id,
|
||||
npc_id: input.npc_id,
|
||||
npc_name: input.npc_name,
|
||||
action_kind: NpcSocialActionKind::Help,
|
||||
affinity_gain_override: None,
|
||||
note: None,
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
},
|
||||
)?;
|
||||
(
|
||||
NpcInteractionStatus::Resolved,
|
||||
format!("向{}请求援手", next_state.npc_name),
|
||||
format!(
|
||||
"{}给了你一次及时支援,关系也顺势拉近了一点。",
|
||||
next_state.npc_name
|
||||
),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
}
|
||||
NPC_RECRUIT_FUNCTION_ID => {
|
||||
next_state = apply_npc_social_action(
|
||||
current,
|
||||
ResolveNpcSocialActionInput {
|
||||
runtime_session_id: input.runtime_session_id,
|
||||
npc_id: input.npc_id,
|
||||
npc_name: input.npc_name,
|
||||
action_kind: NpcSocialActionKind::Recruit,
|
||||
affinity_gain_override: None,
|
||||
note: None,
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
},
|
||||
)?;
|
||||
(
|
||||
NpcInteractionStatus::Recruited,
|
||||
format!("邀请{}加入队伍", next_state.npc_name),
|
||||
format!("{}接受了你的邀请。", next_state.npc_name),
|
||||
Some(format!(
|
||||
"{}已经明确接受了与你同行的关系。",
|
||||
next_state.npc_name
|
||||
)),
|
||||
None,
|
||||
true,
|
||||
)
|
||||
}
|
||||
NPC_FIGHT_FUNCTION_ID => (
|
||||
NpcInteractionStatus::BattlePending,
|
||||
format!("与{}正面开战", current.npc_name),
|
||||
format!(
|
||||
"{}已经不再保留余地,当前冲突正式转入战斗结算。",
|
||||
current.npc_name
|
||||
),
|
||||
None,
|
||||
Some(NpcInteractionBattleMode::Fight),
|
||||
false,
|
||||
),
|
||||
NPC_SPAR_FUNCTION_ID => (
|
||||
NpcInteractionStatus::BattlePending,
|
||||
format!("与{}点到为止切磋", current.npc_name),
|
||||
format!(
|
||||
"{}摆开架势,准备和你来一场点到为止的切磋。",
|
||||
current.npc_name
|
||||
),
|
||||
None,
|
||||
Some(NpcInteractionBattleMode::Spar),
|
||||
false,
|
||||
),
|
||||
NPC_LEAVE_FUNCTION_ID => (
|
||||
NpcInteractionStatus::Left,
|
||||
format!("离开{}", current.npc_name),
|
||||
format!(
|
||||
"你暂时没有继续和{}纠缠,把注意力重新拉回了前路。",
|
||||
current.npc_name
|
||||
),
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
),
|
||||
_ => return Err(NpcStateFieldError::UnsupportedInteractionFunctionId),
|
||||
};
|
||||
|
||||
Ok(NpcInteractionResult {
|
||||
npc_state: next_state.clone(),
|
||||
interaction_status,
|
||||
action_text,
|
||||
result_text,
|
||||
story_text,
|
||||
battle_mode,
|
||||
encounter_closed,
|
||||
affinity_changed: previous_affinity != next_state.affinity,
|
||||
previous_affinity,
|
||||
next_affinity: next_state.affinity,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn normalize_optional_value(value: Option<String>) -> Option<String> {
|
||||
normalize_shared_optional_string(value)
|
||||
}
|
||||
|
||||
pub fn normalize_string_list(values: Vec<String>) -> Vec<String> {
|
||||
normalize_shared_string_list(values)
|
||||
}
|
||||
|
||||
pub fn is_supported_npc_interaction_function_id(function_id: &str) -> bool {
|
||||
matches!(
|
||||
function_id,
|
||||
NPC_PREVIEW_TALK_FUNCTION_ID
|
||||
| NPC_CHAT_FUNCTION_ID
|
||||
| NPC_HELP_FUNCTION_ID
|
||||
| NPC_RECRUIT_FUNCTION_ID
|
||||
| NPC_FIGHT_FUNCTION_ID
|
||||
| NPC_SPAR_FUNCTION_ID
|
||||
| NPC_LEAVE_FUNCTION_ID
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_required_identity_fields(
|
||||
runtime_session_id: &str,
|
||||
npc_id: &str,
|
||||
npc_name: &str,
|
||||
) -> Result<(), NpcStateFieldError> {
|
||||
if normalize_required_string(runtime_session_id).is_none() {
|
||||
return Err(NpcStateFieldError::MissingRuntimeSessionId);
|
||||
}
|
||||
if normalize_required_string(npc_id).is_none() {
|
||||
return Err(NpcStateFieldError::MissingNpcId);
|
||||
}
|
||||
if normalize_required_string(npc_name).is_none() {
|
||||
return Err(NpcStateFieldError::MissingNpcName);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_stance_profile(
|
||||
stance_profile: Option<NpcStanceProfile>,
|
||||
affinity: i32,
|
||||
recruited: bool,
|
||||
hostile: bool,
|
||||
role_text: Option<&str>,
|
||||
) -> NpcStanceProfile {
|
||||
let Some(stance_profile) = stance_profile else {
|
||||
return build_initial_stance_profile(affinity, recruited, hostile, role_text);
|
||||
};
|
||||
|
||||
NpcStanceProfile {
|
||||
trust: clamp_stance_metric(stance_profile.trust as f32),
|
||||
warmth: clamp_stance_metric(stance_profile.warmth as f32),
|
||||
ideological_fit: clamp_stance_metric(stance_profile.ideological_fit as f32),
|
||||
fear_or_guard: clamp_stance_metric(stance_profile.fear_or_guard as f32),
|
||||
loyalty: clamp_stance_metric(stance_profile.loyalty as f32),
|
||||
current_conflict_tag: normalize_optional_value(stance_profile.current_conflict_tag),
|
||||
recent_approvals: trim_recent_notes(stance_profile.recent_approvals),
|
||||
recent_disapprovals: trim_recent_notes(stance_profile.recent_disapprovals),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_story_choice_to_stance_profile(
|
||||
stance_profile: &NpcStanceProfile,
|
||||
action_kind: NpcSocialActionKind,
|
||||
affinity_gain: i32,
|
||||
recruited: bool,
|
||||
note: Option<&str>,
|
||||
) -> NpcStanceProfile {
|
||||
let mut next = stance_profile.clone();
|
||||
|
||||
match action_kind {
|
||||
NpcSocialActionKind::Chat => {
|
||||
next.trust = clamp_stance_metric(next.trust as f32 + 6.0 + affinity_gain as f32 * 2.0);
|
||||
next.warmth =
|
||||
clamp_stance_metric(next.warmth as f32 + 4.0 + affinity_gain as f32 * 2.0);
|
||||
next.fear_or_guard =
|
||||
clamp_stance_metric(next.fear_or_guard as f32 - 5.0 - affinity_gain as f32);
|
||||
if affinity_gain >= 0 {
|
||||
push_recent_note(
|
||||
&mut next.recent_approvals,
|
||||
note.unwrap_or("你愿意先从眼前局势和试探开始说话。"),
|
||||
);
|
||||
} else {
|
||||
push_recent_note(
|
||||
&mut next.recent_disapprovals,
|
||||
note.unwrap_or("这轮交流没能真正对上节奏。"),
|
||||
);
|
||||
}
|
||||
}
|
||||
NpcSocialActionKind::Help => {
|
||||
next.trust = clamp_stance_metric(next.trust as f32 + 12.0);
|
||||
next.warmth = clamp_stance_metric(next.warmth as f32 + 6.0);
|
||||
next.fear_or_guard = clamp_stance_metric(next.fear_or_guard as f32 - 8.0);
|
||||
push_recent_note(
|
||||
&mut next.recent_approvals,
|
||||
note.unwrap_or("你在对方需要的时候搭了手。"),
|
||||
);
|
||||
}
|
||||
NpcSocialActionKind::Gift => {
|
||||
next.trust = clamp_stance_metric(next.trust as f32 + 6.0 + affinity_gain as f32);
|
||||
next.warmth =
|
||||
clamp_stance_metric(next.warmth as f32 + 10.0 + affinity_gain as f32 * 2.0);
|
||||
next.fear_or_guard = clamp_stance_metric(next.fear_or_guard as f32 - 4.0);
|
||||
push_recent_note(
|
||||
&mut next.recent_approvals,
|
||||
note.unwrap_or("你给出的东西回应了对方眼下的处境。"),
|
||||
);
|
||||
}
|
||||
NpcSocialActionKind::Recruit => {
|
||||
next.trust = clamp_stance_metric(next.trust as f32 + 8.0);
|
||||
next.warmth = clamp_stance_metric(next.warmth as f32 + 6.0);
|
||||
next.loyalty =
|
||||
clamp_stance_metric(next.loyalty as f32 + 18.0 + if recruited { 4.0 } else { 0.0 });
|
||||
next.fear_or_guard = clamp_stance_metric(next.fear_or_guard as f32 - 10.0);
|
||||
push_recent_note(
|
||||
&mut next.recent_approvals,
|
||||
note.unwrap_or("你正式把对方纳入了同行关系。"),
|
||||
);
|
||||
}
|
||||
NpcSocialActionKind::QuestAccept => {
|
||||
next.trust = clamp_stance_metric(next.trust as f32 + 7.0);
|
||||
next.ideological_fit = clamp_stance_metric(next.ideological_fit as f32 + 5.0);
|
||||
next.loyalty = clamp_stance_metric(next.loyalty as f32 + 4.0);
|
||||
push_recent_note(
|
||||
&mut next.recent_approvals,
|
||||
note.unwrap_or("你接住了对方主动交出来的事。"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
next
|
||||
}
|
||||
|
||||
fn infer_conflict_tag(value: &str) -> Option<String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else if trimmed.contains("旧案") || trimmed.contains("调查") || trimmed.contains("追查")
|
||||
{
|
||||
Some("旧案".to_string())
|
||||
} else if trimmed.contains('守') || trimmed.contains('卫') || trimmed.contains('巡') {
|
||||
Some("守线".to_string())
|
||||
} else if trimmed.contains('商') || trimmed.contains('摊') || trimmed.contains("军需") {
|
||||
Some("交易".to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn trim_recent_notes(values: Vec<String>) -> Vec<String> {
|
||||
let mut values = normalize_string_list(values);
|
||||
if values.len() > MAX_STANCE_NOTES {
|
||||
values = values.split_off(values.len() - MAX_STANCE_NOTES);
|
||||
}
|
||||
values
|
||||
}
|
||||
|
||||
fn push_recent_note(target: &mut Vec<String>, note: &str) {
|
||||
let trimmed = note.trim();
|
||||
if trimmed.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
target.push(trimmed.to_string());
|
||||
if target.len() > MAX_STANCE_NOTES {
|
||||
let drain_len = target.len() - MAX_STANCE_NOTES;
|
||||
target.drain(0..drain_len);
|
||||
}
|
||||
}
|
||||
|
||||
fn clamp_stance_metric(value: f32) -> u8 {
|
||||
value.round().clamp(0.0, 100.0) as u8
|
||||
}
|
||||
|
||||
@@ -1,3 +1,51 @@
|
||||
//! NPC 写入命令过渡落位。
|
||||
//! NPC 写入命令。
|
||||
//!
|
||||
//! 用于表达聊天、帮助、送礼、招募、开战和切磋等输入。
|
||||
|
||||
use crate::domain::{NpcSocialActionKind, NpcStanceProfile};
|
||||
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 NpcStateUpsertInput {
|
||||
pub runtime_session_id: String,
|
||||
pub npc_id: String,
|
||||
pub npc_name: String,
|
||||
pub affinity: i32,
|
||||
pub help_used: bool,
|
||||
pub chatted_count: u32,
|
||||
pub gifts_given: u32,
|
||||
pub recruited: bool,
|
||||
pub trade_stock_signature: Option<String>,
|
||||
pub revealed_facts: Vec<String>,
|
||||
pub known_attribute_rumors: Vec<String>,
|
||||
pub first_meaningful_contact_resolved: bool,
|
||||
pub seen_backstory_chapter_ids: Vec<String>,
|
||||
pub stance_profile: Option<NpcStanceProfile>,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ResolveNpcSocialActionInput {
|
||||
pub runtime_session_id: String,
|
||||
pub npc_id: String,
|
||||
pub npc_name: String,
|
||||
pub action_kind: NpcSocialActionKind,
|
||||
pub affinity_gain_override: Option<i32>,
|
||||
pub note: Option<String>,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ResolveNpcInteractionInput {
|
||||
pub runtime_session_id: String,
|
||||
pub npc_id: String,
|
||||
pub npc_name: String,
|
||||
pub interaction_function_id: String,
|
||||
pub release_npc_id: Option<String>,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,99 @@
|
||||
//! NPC 领域模型过渡落位。
|
||||
//! NPC 领域模型。
|
||||
//!
|
||||
//! 后续迁移 NPC 状态、关系、好感、招募和互动规则时,只保留社交领域变化;
|
||||
//! 战斗初始化和跨表事务由外层编排。
|
||||
//! 本文件只承载 NPC 聚合状态、关系、立场和交互值对象;对话文本、战斗初始化和任务联动由外层应用服务或 adapter 编排。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[cfg(feature = "spacetime-types")]
|
||||
use spacetimedb::SpacetimeType;
|
||||
|
||||
pub const NPC_STATE_ID_PREFIX: &str = "npcstate_";
|
||||
pub const MAX_STANCE_NOTES: usize = 3;
|
||||
pub const NPC_RECRUIT_AFFINITY_THRESHOLD: i32 = 60;
|
||||
pub const NPC_PREVIEW_TALK_FUNCTION_ID: &str = "npc_preview_talk";
|
||||
pub const NPC_CHAT_FUNCTION_ID: &str = "npc_chat";
|
||||
pub const NPC_HELP_FUNCTION_ID: &str = "npc_help";
|
||||
pub const NPC_RECRUIT_FUNCTION_ID: &str = "npc_recruit";
|
||||
pub const NPC_FIGHT_FUNCTION_ID: &str = "npc_fight";
|
||||
pub const NPC_SPAR_FUNCTION_ID: &str = "npc_spar";
|
||||
pub const NPC_LEAVE_FUNCTION_ID: &str = "npc_leave";
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum NpcRelationStance {
|
||||
Hostile,
|
||||
Guarded,
|
||||
Neutral,
|
||||
Cooperative,
|
||||
Bonded,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum NpcSocialActionKind {
|
||||
Chat,
|
||||
Help,
|
||||
Gift,
|
||||
Recruit,
|
||||
QuestAccept,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum NpcInteractionStatus {
|
||||
Previewed,
|
||||
Dialogue,
|
||||
Resolved,
|
||||
Recruited,
|
||||
BattlePending,
|
||||
Left,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum NpcInteractionBattleMode {
|
||||
Fight,
|
||||
Spar,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NpcRelationState {
|
||||
pub affinity: i32,
|
||||
pub stance: NpcRelationStance,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NpcStanceProfile {
|
||||
pub trust: u8,
|
||||
pub warmth: u8,
|
||||
pub ideological_fit: u8,
|
||||
pub fear_or_guard: u8,
|
||||
pub loyalty: u8,
|
||||
pub current_conflict_tag: Option<String>,
|
||||
pub recent_approvals: Vec<String>,
|
||||
pub recent_disapprovals: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NpcStateSnapshot {
|
||||
pub npc_state_id: String,
|
||||
pub runtime_session_id: String,
|
||||
pub npc_id: String,
|
||||
pub npc_name: String,
|
||||
pub affinity: i32,
|
||||
pub relation_state: NpcRelationState,
|
||||
pub help_used: bool,
|
||||
pub chatted_count: u32,
|
||||
pub gifts_given: u32,
|
||||
pub recruited: bool,
|
||||
pub trade_stock_signature: Option<String>,
|
||||
pub revealed_facts: Vec<String>,
|
||||
pub known_attribute_rumors: Vec<String>,
|
||||
pub first_meaningful_contact_resolved: bool,
|
||||
pub seen_backstory_chapter_ids: Vec<String>,
|
||||
pub stance_profile: NpcStanceProfile,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
@@ -1,3 +1,38 @@
|
||||
//! NPC 领域错误过渡落位。
|
||||
//! NPC 领域错误。
|
||||
//!
|
||||
//! 错误只表达互动规则失败,例如状态不允许、好感不足或目标非法。
|
||||
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum NpcStateFieldError {
|
||||
MissingRuntimeSessionId,
|
||||
MissingNpcId,
|
||||
MissingNpcName,
|
||||
MissingInteractionFunctionId,
|
||||
HelpAlreadyUsed,
|
||||
RecruitAffinityTooLow,
|
||||
UnsupportedInteractionFunctionId,
|
||||
}
|
||||
|
||||
impl fmt::Display for NpcStateFieldError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::MissingRuntimeSessionId => f.write_str("npc_state.runtime_session_id 不能为空"),
|
||||
Self::MissingNpcId => f.write_str("npc_state.npc_id 不能为空"),
|
||||
Self::MissingNpcName => f.write_str("npc_state.npc_name 不能为空"),
|
||||
Self::MissingInteractionFunctionId => {
|
||||
f.write_str("resolve_npc_interaction.interaction_function_id 不能为空")
|
||||
}
|
||||
Self::HelpAlreadyUsed => f.write_str("npc_state.help_used 已经消耗,不能重复援手"),
|
||||
Self::RecruitAffinityTooLow => {
|
||||
f.write_str("npc_state.affinity 未达到招募阈值,不能执行招募动作")
|
||||
}
|
||||
Self::UnsupportedInteractionFunctionId => {
|
||||
f.write_str("resolve_npc_interaction.interaction_function_id 当前不受支持")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for NpcStateFieldError {}
|
||||
|
||||
@@ -1,3 +1,51 @@
|
||||
//! NPC 领域事件过渡落位。
|
||||
//! NPC 领域事件。
|
||||
//!
|
||||
//! 用于表达好感变化、关系变化、NPC 被招募和战斗请求产生等事实。
|
||||
|
||||
use crate::domain::{NpcInteractionBattleMode, NpcRelationStance, NpcSocialActionKind};
|
||||
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 NpcDomainEvent {
|
||||
RelationChanged(NpcRelationChangedEvent),
|
||||
SocialActionResolved(NpcSocialActionResolvedEvent),
|
||||
RecruitResolved(NpcRecruitResolvedEvent),
|
||||
BattleRequested(NpcBattleRequestedEvent),
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NpcRelationChangedEvent {
|
||||
pub npc_state_id: String,
|
||||
pub previous_affinity: i32,
|
||||
pub next_affinity: i32,
|
||||
pub next_stance: NpcRelationStance,
|
||||
pub occurred_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NpcSocialActionResolvedEvent {
|
||||
pub npc_state_id: String,
|
||||
pub action_kind: NpcSocialActionKind,
|
||||
pub occurred_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NpcRecruitResolvedEvent {
|
||||
pub npc_state_id: String,
|
||||
pub recruited: bool,
|
||||
pub occurred_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NpcBattleRequestedEvent {
|
||||
pub npc_state_id: String,
|
||||
pub battle_mode: NpcInteractionBattleMode,
|
||||
pub occurred_at_micros: i64,
|
||||
}
|
||||
|
||||
@@ -4,722 +4,11 @@ mod domain;
|
||||
mod errors;
|
||||
mod events;
|
||||
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared_kernel::{
|
||||
normalize_optional_string as normalize_shared_optional_string, normalize_required_string,
|
||||
normalize_string_list as normalize_shared_string_list,
|
||||
};
|
||||
#[cfg(feature = "spacetime-types")]
|
||||
use spacetimedb::SpacetimeType;
|
||||
|
||||
pub const NPC_STATE_ID_PREFIX: &str = "npcstate_";
|
||||
pub const MAX_STANCE_NOTES: usize = 3;
|
||||
pub const NPC_RECRUIT_AFFINITY_THRESHOLD: i32 = 60;
|
||||
pub const NPC_PREVIEW_TALK_FUNCTION_ID: &str = "npc_preview_talk";
|
||||
pub const NPC_CHAT_FUNCTION_ID: &str = "npc_chat";
|
||||
pub const NPC_HELP_FUNCTION_ID: &str = "npc_help";
|
||||
pub const NPC_RECRUIT_FUNCTION_ID: &str = "npc_recruit";
|
||||
pub const NPC_FIGHT_FUNCTION_ID: &str = "npc_fight";
|
||||
pub const NPC_SPAR_FUNCTION_ID: &str = "npc_spar";
|
||||
pub const NPC_LEAVE_FUNCTION_ID: &str = "npc_leave";
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum NpcRelationStance {
|
||||
Hostile,
|
||||
Guarded,
|
||||
Neutral,
|
||||
Cooperative,
|
||||
Bonded,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum NpcSocialActionKind {
|
||||
Chat,
|
||||
Help,
|
||||
Gift,
|
||||
Recruit,
|
||||
QuestAccept,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum NpcInteractionStatus {
|
||||
Previewed,
|
||||
Dialogue,
|
||||
Resolved,
|
||||
Recruited,
|
||||
BattlePending,
|
||||
Left,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum NpcInteractionBattleMode {
|
||||
Fight,
|
||||
Spar,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NpcRelationState {
|
||||
pub affinity: i32,
|
||||
pub stance: NpcRelationStance,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NpcStanceProfile {
|
||||
pub trust: u8,
|
||||
pub warmth: u8,
|
||||
pub ideological_fit: u8,
|
||||
pub fear_or_guard: u8,
|
||||
pub loyalty: u8,
|
||||
pub current_conflict_tag: Option<String>,
|
||||
pub recent_approvals: Vec<String>,
|
||||
pub recent_disapprovals: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NpcStateSnapshot {
|
||||
pub npc_state_id: String,
|
||||
pub runtime_session_id: String,
|
||||
pub npc_id: String,
|
||||
pub npc_name: String,
|
||||
pub affinity: i32,
|
||||
pub relation_state: NpcRelationState,
|
||||
pub help_used: bool,
|
||||
pub chatted_count: u32,
|
||||
pub gifts_given: u32,
|
||||
pub recruited: bool,
|
||||
pub trade_stock_signature: Option<String>,
|
||||
pub revealed_facts: Vec<String>,
|
||||
pub known_attribute_rumors: Vec<String>,
|
||||
pub first_meaningful_contact_resolved: bool,
|
||||
pub seen_backstory_chapter_ids: Vec<String>,
|
||||
pub stance_profile: NpcStanceProfile,
|
||||
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 NpcStateUpsertInput {
|
||||
pub runtime_session_id: String,
|
||||
pub npc_id: String,
|
||||
pub npc_name: String,
|
||||
pub affinity: i32,
|
||||
pub help_used: bool,
|
||||
pub chatted_count: u32,
|
||||
pub gifts_given: u32,
|
||||
pub recruited: bool,
|
||||
pub trade_stock_signature: Option<String>,
|
||||
pub revealed_facts: Vec<String>,
|
||||
pub known_attribute_rumors: Vec<String>,
|
||||
pub first_meaningful_contact_resolved: bool,
|
||||
pub seen_backstory_chapter_ids: Vec<String>,
|
||||
pub stance_profile: Option<NpcStanceProfile>,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ResolveNpcSocialActionInput {
|
||||
pub runtime_session_id: String,
|
||||
pub npc_id: String,
|
||||
pub npc_name: String,
|
||||
pub action_kind: NpcSocialActionKind,
|
||||
pub affinity_gain_override: Option<i32>,
|
||||
pub note: Option<String>,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ResolveNpcInteractionInput {
|
||||
pub runtime_session_id: String,
|
||||
pub npc_id: String,
|
||||
pub npc_name: String,
|
||||
pub interaction_function_id: String,
|
||||
pub release_npc_id: Option<String>,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NpcStateProcedureResult {
|
||||
pub ok: bool,
|
||||
pub record: Option<NpcStateSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NpcInteractionResult {
|
||||
pub npc_state: NpcStateSnapshot,
|
||||
pub interaction_status: NpcInteractionStatus,
|
||||
pub action_text: String,
|
||||
pub result_text: String,
|
||||
pub story_text: Option<String>,
|
||||
pub battle_mode: Option<NpcInteractionBattleMode>,
|
||||
pub encounter_closed: bool,
|
||||
pub affinity_changed: bool,
|
||||
pub previous_affinity: i32,
|
||||
pub next_affinity: i32,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NpcInteractionProcedureResult {
|
||||
pub ok: bool,
|
||||
pub result: Option<NpcInteractionResult>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum NpcStateFieldError {
|
||||
MissingRuntimeSessionId,
|
||||
MissingNpcId,
|
||||
MissingNpcName,
|
||||
MissingInteractionFunctionId,
|
||||
HelpAlreadyUsed,
|
||||
RecruitAffinityTooLow,
|
||||
UnsupportedInteractionFunctionId,
|
||||
}
|
||||
|
||||
pub fn generate_npc_state_id(runtime_session_id: &str, npc_id: &str) -> String {
|
||||
format!(
|
||||
"{}{}:{}",
|
||||
NPC_STATE_ID_PREFIX,
|
||||
runtime_session_id.trim(),
|
||||
npc_id.trim()
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_relation_state(affinity: i32) -> NpcRelationState {
|
||||
NpcRelationState {
|
||||
affinity,
|
||||
stance: if affinity < 0 {
|
||||
NpcRelationStance::Hostile
|
||||
} else if affinity < 15 {
|
||||
NpcRelationStance::Guarded
|
||||
} else if affinity < 30 {
|
||||
NpcRelationStance::Neutral
|
||||
} else if affinity < NPC_RECRUIT_AFFINITY_THRESHOLD {
|
||||
NpcRelationStance::Cooperative
|
||||
} else {
|
||||
NpcRelationStance::Bonded
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_initial_stance_profile(
|
||||
affinity: i32,
|
||||
recruited: bool,
|
||||
hostile: bool,
|
||||
role_text: Option<&str>,
|
||||
) -> NpcStanceProfile {
|
||||
let recruited_bonus = if recruited { 14.0 } else { 0.0 };
|
||||
let hostile_penalty = if hostile { 18.0 } else { 0.0 };
|
||||
let current_conflict_tag = role_text.and_then(infer_conflict_tag);
|
||||
|
||||
NpcStanceProfile {
|
||||
trust: clamp_stance_metric(
|
||||
42.0 + affinity as f32 * 0.55 + recruited_bonus - hostile_penalty,
|
||||
),
|
||||
warmth: clamp_stance_metric(36.0 + affinity as f32 * 0.5 + recruited_bonus),
|
||||
ideological_fit: clamp_stance_metric(48.0 + affinity as f32 * 0.25),
|
||||
fear_or_guard: clamp_stance_metric(62.0 - affinity as f32 * 0.55 + hostile_penalty),
|
||||
loyalty: clamp_stance_metric(
|
||||
24.0 + affinity as f32 * 0.35 + if recruited { 26.0 } else { 0.0 },
|
||||
),
|
||||
current_conflict_tag,
|
||||
recent_approvals: Vec::new(),
|
||||
recent_disapprovals: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_npc_state_snapshot(
|
||||
input: NpcStateUpsertInput,
|
||||
existing_created_at_micros: Option<i64>,
|
||||
) -> Result<NpcStateSnapshot, NpcStateFieldError> {
|
||||
validate_required_identity_fields(&input.runtime_session_id, &input.npc_id, &input.npc_name)?;
|
||||
|
||||
let affinity = input.affinity;
|
||||
let stance_profile = normalize_stance_profile(
|
||||
input.stance_profile,
|
||||
affinity,
|
||||
input.recruited,
|
||||
affinity < 0,
|
||||
None,
|
||||
);
|
||||
let created_at_micros = existing_created_at_micros.unwrap_or(input.updated_at_micros);
|
||||
|
||||
Ok(NpcStateSnapshot {
|
||||
npc_state_id: generate_npc_state_id(&input.runtime_session_id, &input.npc_id),
|
||||
runtime_session_id: normalize_required_string(input.runtime_session_id).unwrap_or_default(),
|
||||
npc_id: normalize_required_string(input.npc_id).unwrap_or_default(),
|
||||
npc_name: normalize_required_string(input.npc_name).unwrap_or_default(),
|
||||
affinity,
|
||||
relation_state: build_relation_state(affinity),
|
||||
help_used: input.help_used,
|
||||
chatted_count: input.chatted_count,
|
||||
gifts_given: input.gifts_given,
|
||||
recruited: input.recruited,
|
||||
trade_stock_signature: normalize_optional_value(input.trade_stock_signature),
|
||||
revealed_facts: normalize_string_list(input.revealed_facts),
|
||||
known_attribute_rumors: normalize_string_list(input.known_attribute_rumors),
|
||||
first_meaningful_contact_resolved: input.first_meaningful_contact_resolved,
|
||||
seen_backstory_chapter_ids: normalize_string_list(input.seen_backstory_chapter_ids),
|
||||
stance_profile,
|
||||
created_at_micros,
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn apply_npc_social_action(
|
||||
current: NpcStateSnapshot,
|
||||
input: ResolveNpcSocialActionInput,
|
||||
) -> Result<NpcStateSnapshot, NpcStateFieldError> {
|
||||
validate_required_identity_fields(&input.runtime_session_id, &input.npc_id, &input.npc_name)?;
|
||||
|
||||
let note = normalize_optional_value(input.note);
|
||||
let mut next = current;
|
||||
|
||||
match input.action_kind {
|
||||
NpcSocialActionKind::Chat => {
|
||||
let affinity_gain = input
|
||||
.affinity_gain_override
|
||||
.unwrap_or_else(|| (6 - next.chatted_count as i32).max(2));
|
||||
next.affinity += affinity_gain;
|
||||
next.chatted_count += 1;
|
||||
next.first_meaningful_contact_resolved = true;
|
||||
next.stance_profile = apply_story_choice_to_stance_profile(
|
||||
&next.stance_profile,
|
||||
input.action_kind,
|
||||
affinity_gain,
|
||||
next.recruited,
|
||||
note.as_deref(),
|
||||
);
|
||||
}
|
||||
NpcSocialActionKind::Help => {
|
||||
if next.help_used {
|
||||
return Err(NpcStateFieldError::HelpAlreadyUsed);
|
||||
}
|
||||
let affinity_gain = input.affinity_gain_override.unwrap_or(4);
|
||||
next.affinity += affinity_gain;
|
||||
next.help_used = true;
|
||||
next.stance_profile = apply_story_choice_to_stance_profile(
|
||||
&next.stance_profile,
|
||||
input.action_kind,
|
||||
affinity_gain,
|
||||
next.recruited,
|
||||
note.as_deref(),
|
||||
);
|
||||
}
|
||||
NpcSocialActionKind::Gift => {
|
||||
let affinity_gain = input.affinity_gain_override.unwrap_or(4);
|
||||
next.affinity += affinity_gain;
|
||||
next.gifts_given += 1;
|
||||
next.stance_profile = apply_story_choice_to_stance_profile(
|
||||
&next.stance_profile,
|
||||
input.action_kind,
|
||||
affinity_gain,
|
||||
next.recruited,
|
||||
note.as_deref(),
|
||||
);
|
||||
}
|
||||
NpcSocialActionKind::Recruit => {
|
||||
if next.affinity < NPC_RECRUIT_AFFINITY_THRESHOLD {
|
||||
return Err(NpcStateFieldError::RecruitAffinityTooLow);
|
||||
}
|
||||
next.recruited = true;
|
||||
next.first_meaningful_contact_resolved = true;
|
||||
next.stance_profile = apply_story_choice_to_stance_profile(
|
||||
&next.stance_profile,
|
||||
input.action_kind,
|
||||
input.affinity_gain_override.unwrap_or(0),
|
||||
true,
|
||||
note.as_deref(),
|
||||
);
|
||||
}
|
||||
NpcSocialActionKind::QuestAccept => {
|
||||
let affinity_gain = input.affinity_gain_override.unwrap_or(3);
|
||||
next.affinity += affinity_gain;
|
||||
next.stance_profile = apply_story_choice_to_stance_profile(
|
||||
&next.stance_profile,
|
||||
input.action_kind,
|
||||
affinity_gain,
|
||||
next.recruited,
|
||||
note.as_deref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
next.affinity = next.affinity.clamp(-100, 100);
|
||||
next.npc_name = normalize_required_string(input.npc_name).unwrap_or_default();
|
||||
next.relation_state = build_relation_state(next.affinity);
|
||||
next.updated_at_micros = input.updated_at_micros;
|
||||
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
pub fn resolve_npc_interaction(
|
||||
current: NpcStateSnapshot,
|
||||
input: ResolveNpcInteractionInput,
|
||||
) -> Result<NpcInteractionResult, NpcStateFieldError> {
|
||||
validate_required_identity_fields(&input.runtime_session_id, &input.npc_id, &input.npc_name)?;
|
||||
|
||||
let interaction_function_id = normalize_optional_value(Some(input.interaction_function_id))
|
||||
.ok_or(NpcStateFieldError::MissingInteractionFunctionId)?;
|
||||
if !is_supported_npc_interaction_function_id(&interaction_function_id) {
|
||||
return Err(NpcStateFieldError::UnsupportedInteractionFunctionId);
|
||||
}
|
||||
|
||||
let previous_affinity = current.affinity;
|
||||
let mut next_state = current.clone();
|
||||
|
||||
let (interaction_status, action_text, result_text, story_text, battle_mode, encounter_closed) =
|
||||
match interaction_function_id.as_str() {
|
||||
NPC_PREVIEW_TALK_FUNCTION_ID => (
|
||||
NpcInteractionStatus::Previewed,
|
||||
format!("转向{}", current.npc_name),
|
||||
format!(
|
||||
"你把注意力真正收回到{}身上,接下来可以围绕这名角色做正式交互了。",
|
||||
current.npc_name
|
||||
),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
),
|
||||
NPC_CHAT_FUNCTION_ID => {
|
||||
next_state = apply_npc_social_action(
|
||||
current,
|
||||
ResolveNpcSocialActionInput {
|
||||
runtime_session_id: input.runtime_session_id,
|
||||
npc_id: input.npc_id,
|
||||
npc_name: input.npc_name,
|
||||
action_kind: NpcSocialActionKind::Chat,
|
||||
affinity_gain_override: None,
|
||||
note: None,
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
},
|
||||
)?;
|
||||
(
|
||||
NpcInteractionStatus::Dialogue,
|
||||
format!("继续和{}交谈", next_state.npc_name),
|
||||
format!(
|
||||
"{}愿意把话接下去,态度比刚才明显松动了一些。",
|
||||
next_state.npc_name
|
||||
),
|
||||
Some(format!(
|
||||
"{}看起来已经愿意继续把话题往下接。",
|
||||
next_state.npc_name
|
||||
)),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
}
|
||||
NPC_HELP_FUNCTION_ID => {
|
||||
next_state = apply_npc_social_action(
|
||||
current,
|
||||
ResolveNpcSocialActionInput {
|
||||
runtime_session_id: input.runtime_session_id,
|
||||
npc_id: input.npc_id,
|
||||
npc_name: input.npc_name,
|
||||
action_kind: NpcSocialActionKind::Help,
|
||||
affinity_gain_override: None,
|
||||
note: None,
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
},
|
||||
)?;
|
||||
(
|
||||
NpcInteractionStatus::Resolved,
|
||||
format!("向{}请求援手", next_state.npc_name),
|
||||
format!(
|
||||
"{}给了你一次及时支援,关系也顺势拉近了一点。",
|
||||
next_state.npc_name
|
||||
),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
}
|
||||
NPC_RECRUIT_FUNCTION_ID => {
|
||||
next_state = apply_npc_social_action(
|
||||
current,
|
||||
ResolveNpcSocialActionInput {
|
||||
runtime_session_id: input.runtime_session_id,
|
||||
npc_id: input.npc_id,
|
||||
npc_name: input.npc_name,
|
||||
action_kind: NpcSocialActionKind::Recruit,
|
||||
affinity_gain_override: None,
|
||||
note: None,
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
},
|
||||
)?;
|
||||
(
|
||||
NpcInteractionStatus::Recruited,
|
||||
format!("邀请{}加入队伍", next_state.npc_name),
|
||||
format!("{}接受了你的邀请。", next_state.npc_name),
|
||||
Some(format!(
|
||||
"{}已经明确接受了与你同行的关系。",
|
||||
next_state.npc_name
|
||||
)),
|
||||
None,
|
||||
true,
|
||||
)
|
||||
}
|
||||
NPC_FIGHT_FUNCTION_ID => (
|
||||
NpcInteractionStatus::BattlePending,
|
||||
format!("与{}正面开战", current.npc_name),
|
||||
format!(
|
||||
"{}已经不再保留余地,当前冲突正式转入战斗结算。",
|
||||
current.npc_name
|
||||
),
|
||||
None,
|
||||
Some(NpcInteractionBattleMode::Fight),
|
||||
false,
|
||||
),
|
||||
NPC_SPAR_FUNCTION_ID => (
|
||||
NpcInteractionStatus::BattlePending,
|
||||
format!("与{}点到为止切磋", current.npc_name),
|
||||
format!(
|
||||
"{}摆开架势,准备和你来一场点到为止的切磋。",
|
||||
current.npc_name
|
||||
),
|
||||
None,
|
||||
Some(NpcInteractionBattleMode::Spar),
|
||||
false,
|
||||
),
|
||||
NPC_LEAVE_FUNCTION_ID => (
|
||||
NpcInteractionStatus::Left,
|
||||
format!("离开{}", current.npc_name),
|
||||
format!(
|
||||
"你暂时没有继续和{}纠缠,把注意力重新拉回了前路。",
|
||||
current.npc_name
|
||||
),
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
),
|
||||
_ => return Err(NpcStateFieldError::UnsupportedInteractionFunctionId),
|
||||
};
|
||||
|
||||
Ok(NpcInteractionResult {
|
||||
npc_state: next_state.clone(),
|
||||
interaction_status,
|
||||
action_text,
|
||||
result_text,
|
||||
story_text,
|
||||
battle_mode,
|
||||
encounter_closed,
|
||||
affinity_changed: previous_affinity != next_state.affinity,
|
||||
previous_affinity,
|
||||
next_affinity: next_state.affinity,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn normalize_optional_value(value: Option<String>) -> Option<String> {
|
||||
normalize_shared_optional_string(value)
|
||||
}
|
||||
|
||||
pub fn normalize_string_list(values: Vec<String>) -> Vec<String> {
|
||||
normalize_shared_string_list(values)
|
||||
}
|
||||
|
||||
pub fn is_supported_npc_interaction_function_id(function_id: &str) -> bool {
|
||||
matches!(
|
||||
function_id,
|
||||
NPC_PREVIEW_TALK_FUNCTION_ID
|
||||
| NPC_CHAT_FUNCTION_ID
|
||||
| NPC_HELP_FUNCTION_ID
|
||||
| NPC_RECRUIT_FUNCTION_ID
|
||||
| NPC_FIGHT_FUNCTION_ID
|
||||
| NPC_SPAR_FUNCTION_ID
|
||||
| NPC_LEAVE_FUNCTION_ID
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_required_identity_fields(
|
||||
runtime_session_id: &str,
|
||||
npc_id: &str,
|
||||
npc_name: &str,
|
||||
) -> Result<(), NpcStateFieldError> {
|
||||
if normalize_required_string(runtime_session_id).is_none() {
|
||||
return Err(NpcStateFieldError::MissingRuntimeSessionId);
|
||||
}
|
||||
if normalize_required_string(npc_id).is_none() {
|
||||
return Err(NpcStateFieldError::MissingNpcId);
|
||||
}
|
||||
if normalize_required_string(npc_name).is_none() {
|
||||
return Err(NpcStateFieldError::MissingNpcName);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_stance_profile(
|
||||
stance_profile: Option<NpcStanceProfile>,
|
||||
affinity: i32,
|
||||
recruited: bool,
|
||||
hostile: bool,
|
||||
role_text: Option<&str>,
|
||||
) -> NpcStanceProfile {
|
||||
let Some(stance_profile) = stance_profile else {
|
||||
return build_initial_stance_profile(affinity, recruited, hostile, role_text);
|
||||
};
|
||||
|
||||
NpcStanceProfile {
|
||||
trust: clamp_stance_metric(stance_profile.trust as f32),
|
||||
warmth: clamp_stance_metric(stance_profile.warmth as f32),
|
||||
ideological_fit: clamp_stance_metric(stance_profile.ideological_fit as f32),
|
||||
fear_or_guard: clamp_stance_metric(stance_profile.fear_or_guard as f32),
|
||||
loyalty: clamp_stance_metric(stance_profile.loyalty as f32),
|
||||
current_conflict_tag: normalize_optional_value(stance_profile.current_conflict_tag),
|
||||
recent_approvals: trim_recent_notes(stance_profile.recent_approvals),
|
||||
recent_disapprovals: trim_recent_notes(stance_profile.recent_disapprovals),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_story_choice_to_stance_profile(
|
||||
stance_profile: &NpcStanceProfile,
|
||||
action_kind: NpcSocialActionKind,
|
||||
affinity_gain: i32,
|
||||
recruited: bool,
|
||||
note: Option<&str>,
|
||||
) -> NpcStanceProfile {
|
||||
let mut next = stance_profile.clone();
|
||||
|
||||
match action_kind {
|
||||
NpcSocialActionKind::Chat => {
|
||||
next.trust = clamp_stance_metric(next.trust as f32 + 6.0 + affinity_gain as f32 * 2.0);
|
||||
next.warmth =
|
||||
clamp_stance_metric(next.warmth as f32 + 4.0 + affinity_gain as f32 * 2.0);
|
||||
next.fear_or_guard =
|
||||
clamp_stance_metric(next.fear_or_guard as f32 - 5.0 - affinity_gain as f32);
|
||||
if affinity_gain >= 0 {
|
||||
push_recent_note(
|
||||
&mut next.recent_approvals,
|
||||
note.unwrap_or("你愿意先从眼前局势和试探开始说话。"),
|
||||
);
|
||||
} else {
|
||||
push_recent_note(
|
||||
&mut next.recent_disapprovals,
|
||||
note.unwrap_or("这轮交流没能真正对上节奏。"),
|
||||
);
|
||||
}
|
||||
}
|
||||
NpcSocialActionKind::Help => {
|
||||
next.trust = clamp_stance_metric(next.trust as f32 + 12.0);
|
||||
next.warmth = clamp_stance_metric(next.warmth as f32 + 6.0);
|
||||
next.fear_or_guard = clamp_stance_metric(next.fear_or_guard as f32 - 8.0);
|
||||
push_recent_note(
|
||||
&mut next.recent_approvals,
|
||||
note.unwrap_or("你在对方需要的时候搭了手。"),
|
||||
);
|
||||
}
|
||||
NpcSocialActionKind::Gift => {
|
||||
next.trust = clamp_stance_metric(next.trust as f32 + 6.0 + affinity_gain as f32);
|
||||
next.warmth =
|
||||
clamp_stance_metric(next.warmth as f32 + 10.0 + affinity_gain as f32 * 2.0);
|
||||
next.fear_or_guard = clamp_stance_metric(next.fear_or_guard as f32 - 4.0);
|
||||
push_recent_note(
|
||||
&mut next.recent_approvals,
|
||||
note.unwrap_or("你给出的东西回应了对方眼下的处境。"),
|
||||
);
|
||||
}
|
||||
NpcSocialActionKind::Recruit => {
|
||||
next.trust = clamp_stance_metric(next.trust as f32 + 8.0);
|
||||
next.warmth = clamp_stance_metric(next.warmth as f32 + 6.0);
|
||||
next.loyalty =
|
||||
clamp_stance_metric(next.loyalty as f32 + 18.0 + if recruited { 4.0 } else { 0.0 });
|
||||
next.fear_or_guard = clamp_stance_metric(next.fear_or_guard as f32 - 10.0);
|
||||
push_recent_note(
|
||||
&mut next.recent_approvals,
|
||||
note.unwrap_or("你正式把对方纳入了同行关系。"),
|
||||
);
|
||||
}
|
||||
NpcSocialActionKind::QuestAccept => {
|
||||
next.trust = clamp_stance_metric(next.trust as f32 + 7.0);
|
||||
next.ideological_fit = clamp_stance_metric(next.ideological_fit as f32 + 5.0);
|
||||
next.loyalty = clamp_stance_metric(next.loyalty as f32 + 4.0);
|
||||
push_recent_note(
|
||||
&mut next.recent_approvals,
|
||||
note.unwrap_or("你接住了对方主动交出来的事。"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
next
|
||||
}
|
||||
|
||||
fn infer_conflict_tag(value: &str) -> Option<String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else if trimmed.contains("旧案") || trimmed.contains("调查") || trimmed.contains("追查")
|
||||
{
|
||||
Some("旧案".to_string())
|
||||
} else if trimmed.contains('守') || trimmed.contains('卫') || trimmed.contains('巡') {
|
||||
Some("守线".to_string())
|
||||
} else if trimmed.contains('商') || trimmed.contains('摊') || trimmed.contains("军需") {
|
||||
Some("交易".to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn trim_recent_notes(values: Vec<String>) -> Vec<String> {
|
||||
let mut values = normalize_string_list(values);
|
||||
if values.len() > MAX_STANCE_NOTES {
|
||||
values = values.split_off(values.len() - MAX_STANCE_NOTES);
|
||||
}
|
||||
values
|
||||
}
|
||||
|
||||
fn push_recent_note(target: &mut Vec<String>, note: &str) {
|
||||
let trimmed = note.trim();
|
||||
if trimmed.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
target.push(trimmed.to_string());
|
||||
if target.len() > MAX_STANCE_NOTES {
|
||||
let drain_len = target.len() - MAX_STANCE_NOTES;
|
||||
target.drain(0..drain_len);
|
||||
}
|
||||
}
|
||||
|
||||
fn clamp_stance_metric(value: f32) -> u8 {
|
||||
value.round().clamp(0.0, 100.0) as u8
|
||||
}
|
||||
|
||||
impl fmt::Display for NpcStateFieldError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::MissingRuntimeSessionId => f.write_str("npc_state.runtime_session_id 不能为空"),
|
||||
Self::MissingNpcId => f.write_str("npc_state.npc_id 不能为空"),
|
||||
Self::MissingNpcName => f.write_str("npc_state.npc_name 不能为空"),
|
||||
Self::MissingInteractionFunctionId => {
|
||||
f.write_str("resolve_npc_interaction.interaction_function_id 不能为空")
|
||||
}
|
||||
Self::HelpAlreadyUsed => f.write_str("npc_state.help_used 已经消耗,不能重复援手"),
|
||||
Self::RecruitAffinityTooLow => {
|
||||
f.write_str("npc_state.affinity 未达到招募阈值,不能执行招募动作")
|
||||
}
|
||||
Self::UnsupportedInteractionFunctionId => {
|
||||
f.write_str("resolve_npc_interaction.interaction_function_id 当前不受支持")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for NpcStateFieldError {}
|
||||
pub use application::*;
|
||||
pub use commands::*;
|
||||
pub use domain::*;
|
||||
pub use errors::*;
|
||||
pub use events::*;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 拼图领域模型过渡落位。
|
||||
//! 拼图领域模型。
|
||||
//!
|
||||
//! 后续迁移拼图 Agent 会话、作品 profile 和运行态聚合时,只保留玩法规则;
|
||||
//! 图片生成、发布 HTTP shape 和排行榜适配留在外层。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 拼图领域事件过渡落位。
|
||||
//! 拼图领域事件。
|
||||
//!
|
||||
//! 用于表达草稿变化、作品发布、运行态推进和排行榜候选产生等事实。
|
||||
|
||||
|
||||
@@ -1,3 +1,499 @@
|
||||
//! 任务应用编排过渡落位。
|
||||
//! 任务应用编排。
|
||||
//!
|
||||
//! 这里只返回任务变更结果、日志和奖励待处理事件,不直接写背包或成长表。
|
||||
//! 这里只推进任务状态、步骤进度和交付标记,不直接发放货币、背包或关系奖励。
|
||||
|
||||
use crate::commands::{
|
||||
QuestCompletionAckInput, QuestCompletionAckOutcome, QuestRecordInput, QuestSignalApplyInput,
|
||||
QuestSignalApplyOutcome, QuestTurnInInput,
|
||||
};
|
||||
use crate::domain::*;
|
||||
use crate::errors::QuestRecordFieldError;
|
||||
use shared_kernel::{
|
||||
normalize_optional_string as normalize_shared_optional_string, normalize_required_string,
|
||||
normalize_string_list as normalize_shared_string_list,
|
||||
};
|
||||
|
||||
pub fn normalize_optional_text(value: Option<String>) -> Option<String> {
|
||||
normalize_shared_optional_string(value)
|
||||
}
|
||||
|
||||
pub fn normalize_string_list(values: Vec<String>) -> Vec<String> {
|
||||
normalize_shared_string_list(values)
|
||||
}
|
||||
|
||||
pub fn build_quest_record_snapshot(
|
||||
input: QuestRecordInput,
|
||||
) -> Result<QuestRecordSnapshot, QuestRecordFieldError> {
|
||||
let quest_id = normalize_required_text(input.quest_id, QuestRecordFieldError::MissingQuestId)?;
|
||||
let runtime_session_id = normalize_required_text(
|
||||
input.runtime_session_id,
|
||||
QuestRecordFieldError::MissingRuntimeSessionId,
|
||||
)?;
|
||||
let actor_user_id = normalize_required_text(
|
||||
input.actor_user_id,
|
||||
QuestRecordFieldError::MissingActorUserId,
|
||||
)?;
|
||||
let issuer_npc_id = normalize_required_text(
|
||||
input.issuer_npc_id,
|
||||
QuestRecordFieldError::MissingIssuerNpcId,
|
||||
)?;
|
||||
let issuer_npc_name = normalize_required_text(
|
||||
input.issuer_npc_name,
|
||||
QuestRecordFieldError::MissingIssuerNpcName,
|
||||
)?;
|
||||
let title = normalize_required_text(input.title, QuestRecordFieldError::MissingTitle)?;
|
||||
let description =
|
||||
normalize_required_text(input.description, QuestRecordFieldError::MissingDescription)?;
|
||||
let reward_text =
|
||||
normalize_required_text(input.reward_text, QuestRecordFieldError::MissingRewardText)?;
|
||||
|
||||
if input.steps.is_empty() {
|
||||
return Err(QuestRecordFieldError::EmptySteps);
|
||||
}
|
||||
|
||||
let steps = input
|
||||
.steps
|
||||
.into_iter()
|
||||
.map(normalize_quest_step)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let active_step = resolve_active_step(&steps, input.active_step_id.as_deref());
|
||||
let active_step_id = active_step.map(|step| step.step_id.clone());
|
||||
let fallback_step = steps
|
||||
.last()
|
||||
.cloned()
|
||||
.expect("BUG: validated quest steps should not be empty");
|
||||
let objective = build_objective_from_step(active_step.unwrap_or(&fallback_step));
|
||||
let progress = active_step
|
||||
.map(|step| step.progress)
|
||||
.unwrap_or(fallback_step.required_count);
|
||||
let status = normalize_quest_status(input.status, active_step.is_some());
|
||||
let completed_at_micros = if status.is_reward_ready() {
|
||||
Some(input.created_at_micros)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let turned_in_at_micros = if status == QuestStatus::TurnedIn {
|
||||
Some(input.created_at_micros)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(QuestRecordSnapshot {
|
||||
quest_id,
|
||||
runtime_session_id,
|
||||
story_session_id: normalize_optional_text(input.story_session_id),
|
||||
actor_user_id,
|
||||
issuer_npc_id,
|
||||
issuer_npc_name,
|
||||
scene_id: normalize_optional_text(input.scene_id),
|
||||
chapter_id: normalize_optional_text(input.chapter_id),
|
||||
act_id: normalize_optional_text(input.act_id),
|
||||
thread_id: normalize_optional_text(input.thread_id),
|
||||
contract_id: normalize_optional_text(input.contract_id),
|
||||
title,
|
||||
description: description.clone(),
|
||||
summary: normalize_optional_text(Some(input.summary)).unwrap_or(description),
|
||||
objective,
|
||||
progress,
|
||||
status,
|
||||
completion_notified: input.completion_notified || status == QuestStatus::TurnedIn,
|
||||
reward: normalize_quest_reward(input.reward)?,
|
||||
reward_text,
|
||||
narrative_binding: normalize_quest_narrative_binding(input.narrative_binding),
|
||||
steps,
|
||||
active_step_id,
|
||||
visible_stage: input.visible_stage,
|
||||
hidden_flags: normalize_string_list(input.hidden_flags),
|
||||
discovered_fact_ids: normalize_string_list(input.discovered_fact_ids),
|
||||
related_carrier_ids: normalize_string_list(input.related_carrier_ids),
|
||||
consequence_ids: normalize_string_list(input.consequence_ids),
|
||||
created_at_micros: input.created_at_micros,
|
||||
updated_at_micros: input.created_at_micros,
|
||||
completed_at_micros,
|
||||
turned_in_at_micros,
|
||||
})
|
||||
}
|
||||
|
||||
// 任务推进只认当前 active step,未命中或已终态时统一保持 no-op,确保 story action 可安全重复派发信号。
|
||||
pub fn apply_quest_signal(
|
||||
current: QuestRecordSnapshot,
|
||||
input: QuestSignalApplyInput,
|
||||
) -> Result<QuestSignalApplyOutcome, QuestRecordFieldError> {
|
||||
let quest_id = normalize_required_text(input.quest_id, QuestRecordFieldError::MissingQuestId)?;
|
||||
let signal_kind = QuestSignalKind::from(&input.signal);
|
||||
|
||||
if current.quest_id != quest_id
|
||||
|| current.status.is_terminal()
|
||||
|| current.status.is_reward_ready()
|
||||
{
|
||||
return Ok(QuestSignalApplyOutcome {
|
||||
next_record: current,
|
||||
changed: false,
|
||||
completed_now: false,
|
||||
changed_step_id: None,
|
||||
changed_step_progress: None,
|
||||
signal_kind,
|
||||
});
|
||||
}
|
||||
|
||||
let active_step = match resolve_active_step(¤t.steps, current.active_step_id.as_deref()) {
|
||||
Some(step) => step,
|
||||
None => {
|
||||
return Ok(QuestSignalApplyOutcome {
|
||||
next_record: current,
|
||||
changed: false,
|
||||
completed_now: false,
|
||||
changed_step_id: None,
|
||||
changed_step_progress: None,
|
||||
signal_kind,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if !step_matches_signal(active_step, &input.signal) {
|
||||
return Ok(QuestSignalApplyOutcome {
|
||||
next_record: current,
|
||||
changed: false,
|
||||
completed_now: false,
|
||||
changed_step_id: None,
|
||||
changed_step_progress: None,
|
||||
signal_kind,
|
||||
});
|
||||
}
|
||||
|
||||
let increment = signal_progress_increment(&input.signal);
|
||||
let mut changed_step_id = None;
|
||||
let mut changed_step_progress = None;
|
||||
let next_steps = current
|
||||
.steps
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|mut step| {
|
||||
if step.step_id == active_step.step_id {
|
||||
let next_progress = (step.progress + increment).min(step.required_count);
|
||||
if next_progress != step.progress {
|
||||
step.progress = next_progress;
|
||||
changed_step_id = Some(step.step_id.clone());
|
||||
changed_step_progress = Some(step.progress);
|
||||
}
|
||||
}
|
||||
step
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if changed_step_id.is_none() {
|
||||
return Ok(QuestSignalApplyOutcome {
|
||||
next_record: current,
|
||||
changed: false,
|
||||
completed_now: false,
|
||||
changed_step_id: None,
|
||||
changed_step_progress: None,
|
||||
signal_kind,
|
||||
});
|
||||
}
|
||||
|
||||
let next_active_step = resolve_active_step(&next_steps, None);
|
||||
let next_active_step_id = next_active_step.map(|step| step.step_id.clone());
|
||||
let fallback_step = next_steps
|
||||
.last()
|
||||
.cloned()
|
||||
.expect("BUG: progressed quest should still contain steps");
|
||||
let next_status = normalize_quest_status(current.status, next_active_step.is_some());
|
||||
let completed_now = !current.status.is_reward_ready() && next_status.is_reward_ready();
|
||||
let next_objective = build_objective_from_step(next_active_step.unwrap_or(&fallback_step));
|
||||
let next_progress = next_active_step
|
||||
.map(|step| step.progress)
|
||||
.unwrap_or(fallback_step.required_count);
|
||||
|
||||
Ok(QuestSignalApplyOutcome {
|
||||
next_record: QuestRecordSnapshot {
|
||||
objective: next_objective,
|
||||
progress: next_progress,
|
||||
status: next_status,
|
||||
completion_notified: false,
|
||||
steps: next_steps,
|
||||
active_step_id: next_active_step_id,
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
completed_at_micros: if completed_now {
|
||||
Some(input.updated_at_micros)
|
||||
} else {
|
||||
current.completed_at_micros
|
||||
},
|
||||
..current
|
||||
},
|
||||
changed: true,
|
||||
completed_now,
|
||||
changed_step_id,
|
||||
changed_step_progress,
|
||||
signal_kind,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn acknowledge_quest_completion(
|
||||
current: QuestRecordSnapshot,
|
||||
input: QuestCompletionAckInput,
|
||||
) -> Result<QuestCompletionAckOutcome, QuestRecordFieldError> {
|
||||
let quest_id = normalize_required_text(input.quest_id, QuestRecordFieldError::MissingQuestId)?;
|
||||
|
||||
if current.quest_id != quest_id || current.completion_notified {
|
||||
return Ok(QuestCompletionAckOutcome {
|
||||
next_record: current,
|
||||
changed: false,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(QuestCompletionAckOutcome {
|
||||
next_record: QuestRecordSnapshot {
|
||||
completion_notified: true,
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
..current
|
||||
},
|
||||
changed: true,
|
||||
})
|
||||
}
|
||||
|
||||
// 任务交付只负责把任务固定到 TurnedIn,不在本轮提前掺入货币、背包和关系奖励发放。
|
||||
pub fn turn_in_quest_record(
|
||||
current: QuestRecordSnapshot,
|
||||
input: QuestTurnInInput,
|
||||
) -> Result<QuestRecordSnapshot, QuestRecordFieldError> {
|
||||
let quest_id = normalize_required_text(input.quest_id, QuestRecordFieldError::MissingQuestId)?;
|
||||
|
||||
if current.quest_id != quest_id || !current.status.is_reward_ready() {
|
||||
return Err(QuestRecordFieldError::QuestNotReadyToTurnIn);
|
||||
}
|
||||
|
||||
let steps = current
|
||||
.steps
|
||||
.into_iter()
|
||||
.map(|mut step| {
|
||||
step.progress = step.required_count;
|
||||
step
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let fallback_step = steps
|
||||
.last()
|
||||
.cloned()
|
||||
.expect("BUG: turn in quest should preserve steps");
|
||||
|
||||
Ok(QuestRecordSnapshot {
|
||||
objective: build_objective_from_step(&fallback_step),
|
||||
progress: fallback_step.required_count,
|
||||
status: QuestStatus::TurnedIn,
|
||||
completion_notified: true,
|
||||
steps,
|
||||
active_step_id: None,
|
||||
updated_at_micros: input.turned_in_at_micros,
|
||||
completed_at_micros: current
|
||||
.completed_at_micros
|
||||
.or(Some(input.turned_in_at_micros)),
|
||||
turned_in_at_micros: Some(input.turned_in_at_micros),
|
||||
..current
|
||||
})
|
||||
}
|
||||
|
||||
pub fn generate_quest_log_id(
|
||||
quest_id: &str,
|
||||
event_kind: QuestLogEventKind,
|
||||
seed_micros: i64,
|
||||
) -> String {
|
||||
format!(
|
||||
"{}{}_{:x}_{}",
|
||||
QUEST_LOG_ID_PREFIX,
|
||||
event_kind.as_str(),
|
||||
seed_micros,
|
||||
quest_id
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_required_text(
|
||||
value: String,
|
||||
error: QuestRecordFieldError,
|
||||
) -> Result<String, QuestRecordFieldError> {
|
||||
normalize_required_string(value).ok_or(error)
|
||||
}
|
||||
|
||||
fn normalize_quest_reward(
|
||||
mut reward: QuestRewardSnapshot,
|
||||
) -> Result<QuestRewardSnapshot, QuestRecordFieldError> {
|
||||
reward.story_hint = normalize_optional_text(reward.story_hint);
|
||||
reward.intel = reward.intel.and_then(|intel| {
|
||||
let rumor_text = intel.rumor_text.trim().to_string();
|
||||
let unlocked_scene_id = normalize_optional_text(intel.unlocked_scene_id);
|
||||
if rumor_text.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(QuestRewardIntel {
|
||||
rumor_text,
|
||||
unlocked_scene_id,
|
||||
})
|
||||
}
|
||||
});
|
||||
reward.items = reward
|
||||
.items
|
||||
.into_iter()
|
||||
.map(
|
||||
|mut item| -> Result<QuestRewardItem, QuestRecordFieldError> {
|
||||
item.item_id = normalize_required_text(
|
||||
item.item_id,
|
||||
QuestRecordFieldError::MissingRewardItemId,
|
||||
)?;
|
||||
item.category = normalize_required_text(
|
||||
item.category,
|
||||
QuestRecordFieldError::MissingRewardItemCategory,
|
||||
)?;
|
||||
item.name = normalize_required_text(
|
||||
item.name,
|
||||
QuestRecordFieldError::MissingRewardItemName,
|
||||
)?;
|
||||
item.description = normalize_optional_text(item.description);
|
||||
if item.quantity == 0 {
|
||||
return Err(QuestRecordFieldError::InvalidRewardItemQuantity);
|
||||
}
|
||||
if !item.stackable && item.quantity != 1 {
|
||||
return Err(
|
||||
QuestRecordFieldError::RewardNonStackableItemMustStaySingleQuantity,
|
||||
);
|
||||
}
|
||||
if item.equipment_slot_id.is_some() && item.stackable {
|
||||
return Err(QuestRecordFieldError::RewardEquipmentItemCannotStack);
|
||||
}
|
||||
item.tags = normalize_string_list(item.tags);
|
||||
item.stack_key = if item.stackable {
|
||||
normalize_required_text(
|
||||
item.stack_key,
|
||||
QuestRecordFieldError::MissingRewardItemStackKey,
|
||||
)?
|
||||
} else {
|
||||
normalize_optional_text(Some(item.stack_key))
|
||||
.unwrap_or_else(|| item.item_id.clone())
|
||||
};
|
||||
Ok(item)
|
||||
},
|
||||
)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(reward)
|
||||
}
|
||||
|
||||
fn normalize_quest_narrative_binding(
|
||||
mut binding: QuestNarrativeBindingSnapshot,
|
||||
) -> QuestNarrativeBindingSnapshot {
|
||||
binding.dramatic_need = binding.dramatic_need.trim().to_string();
|
||||
binding.issuer_goal = binding.issuer_goal.trim().to_string();
|
||||
binding.player_hook = binding.player_hook.trim().to_string();
|
||||
binding.world_reason = binding.world_reason.trim().to_string();
|
||||
binding.followup_hooks = normalize_string_list(binding.followup_hooks);
|
||||
binding
|
||||
}
|
||||
|
||||
fn normalize_quest_step(
|
||||
mut step: QuestStepSnapshot,
|
||||
) -> Result<QuestStepSnapshot, QuestRecordFieldError> {
|
||||
step.step_id = normalize_required_text(step.step_id, QuestRecordFieldError::MissingStepId)?;
|
||||
step.title = normalize_required_text(step.title, QuestRecordFieldError::MissingStepTitle)?;
|
||||
step.reveal_text = normalize_required_text(
|
||||
step.reveal_text,
|
||||
QuestRecordFieldError::MissingStepRevealText,
|
||||
)?;
|
||||
step.complete_text = normalize_required_text(
|
||||
step.complete_text,
|
||||
QuestRecordFieldError::MissingStepCompleteText,
|
||||
)?;
|
||||
step.required_count = step.required_count.max(1);
|
||||
step.progress = step.progress.min(step.required_count);
|
||||
step.target_hostile_npc_id = normalize_optional_text(step.target_hostile_npc_id);
|
||||
step.target_npc_id = normalize_optional_text(step.target_npc_id);
|
||||
step.target_scene_id = normalize_optional_text(step.target_scene_id);
|
||||
step.target_item_id = normalize_optional_text(step.target_item_id);
|
||||
Ok(step)
|
||||
}
|
||||
|
||||
fn resolve_active_step<'a>(
|
||||
steps: &'a [QuestStepSnapshot],
|
||||
active_step_id: Option<&str>,
|
||||
) -> Option<&'a QuestStepSnapshot> {
|
||||
if let Some(active_step_id) = active_step_id {
|
||||
let active_step_id = active_step_id.trim();
|
||||
if !active_step_id.is_empty() {
|
||||
if let Some(step) = steps
|
||||
.iter()
|
||||
.find(|step| step.step_id == active_step_id && step.progress < step.required_count)
|
||||
{
|
||||
return Some(step);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
steps
|
||||
.iter()
|
||||
.find(|step| step.progress < step.required_count)
|
||||
}
|
||||
|
||||
fn build_objective_from_step(step: &QuestStepSnapshot) -> QuestObjectiveSnapshot {
|
||||
QuestObjectiveSnapshot {
|
||||
kind: step.kind,
|
||||
target_hostile_npc_id: step.target_hostile_npc_id.clone(),
|
||||
target_npc_id: step.target_npc_id.clone(),
|
||||
target_scene_id: step.target_scene_id.clone(),
|
||||
target_item_id: step.target_item_id.clone(),
|
||||
required_count: step.required_count,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_quest_status(status: QuestStatus, has_active_step: bool) -> QuestStatus {
|
||||
if status.is_terminal() {
|
||||
return status;
|
||||
}
|
||||
|
||||
if has_active_step {
|
||||
QuestStatus::Active
|
||||
} else if status == QuestStatus::ReadyToTurnIn {
|
||||
QuestStatus::ReadyToTurnIn
|
||||
} else {
|
||||
QuestStatus::Completed
|
||||
}
|
||||
}
|
||||
|
||||
fn step_matches_signal(step: &QuestStepSnapshot, signal: &QuestProgressSignal) -> bool {
|
||||
match signal {
|
||||
QuestProgressSignal::HostileNpcDefeated(payload) => {
|
||||
step.kind == QuestObjectiveKind::DefeatHostileNpc
|
||||
&& step.target_hostile_npc_id.as_deref() == Some(payload.hostile_npc_id.as_str())
|
||||
&& step
|
||||
.target_scene_id
|
||||
.as_deref()
|
||||
.is_none_or(|value| Some(value.to_string()) == payload.scene_id.clone())
|
||||
}
|
||||
QuestProgressSignal::TreasureInspected(payload) => {
|
||||
step.kind == QuestObjectiveKind::InspectTreasure
|
||||
&& step
|
||||
.target_scene_id
|
||||
.as_deref()
|
||||
.is_none_or(|value| Some(value.to_string()) == payload.scene_id.clone())
|
||||
}
|
||||
QuestProgressSignal::NpcSparCompleted(payload) => {
|
||||
step.kind == QuestObjectiveKind::SparWithNpc
|
||||
&& step.target_npc_id.as_deref() == Some(payload.npc_id.as_str())
|
||||
}
|
||||
QuestProgressSignal::NpcTalkCompleted(payload) => {
|
||||
step.kind == QuestObjectiveKind::TalkToNpc
|
||||
&& step.target_npc_id.as_deref() == Some(payload.npc_id.as_str())
|
||||
}
|
||||
QuestProgressSignal::SceneReached(payload) => {
|
||||
step.kind == QuestObjectiveKind::ReachScene
|
||||
&& step.target_scene_id.as_deref() == Some(payload.scene_id.as_str())
|
||||
}
|
||||
QuestProgressSignal::ItemDelivered(payload) => {
|
||||
step.kind == QuestObjectiveKind::DeliverItem
|
||||
&& step.target_npc_id.as_deref() == Some(payload.npc_id.as_str())
|
||||
&& step.target_item_id.as_deref() == Some(payload.item_id.as_str())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn signal_progress_increment(signal: &QuestProgressSignal) -> u32 {
|
||||
match signal {
|
||||
QuestProgressSignal::ItemDelivered(payload) => payload.quantity.max(1),
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,83 @@
|
||||
//! 任务写入命令过渡落位。
|
||||
//! 任务写入命令。
|
||||
//!
|
||||
//! 用于表达领取任务、推进信号、确认完成和交付任务等输入。
|
||||
//! 用于表达任务创建、信号推进、完成确认和交付等输入。
|
||||
|
||||
use crate::domain::{
|
||||
QuestNarrativeBindingSnapshot, QuestProgressSignal, QuestRecordSnapshot, QuestRewardSnapshot,
|
||||
QuestSignalKind, QuestStatus, QuestStepSnapshot,
|
||||
};
|
||||
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 QuestRecordInput {
|
||||
pub quest_id: String,
|
||||
pub runtime_session_id: String,
|
||||
pub story_session_id: Option<String>,
|
||||
pub actor_user_id: String,
|
||||
pub issuer_npc_id: String,
|
||||
pub issuer_npc_name: String,
|
||||
pub scene_id: Option<String>,
|
||||
pub chapter_id: Option<String>,
|
||||
pub act_id: Option<String>,
|
||||
pub thread_id: Option<String>,
|
||||
pub contract_id: Option<String>,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub summary: String,
|
||||
pub status: QuestStatus,
|
||||
pub completion_notified: bool,
|
||||
pub reward: QuestRewardSnapshot,
|
||||
pub reward_text: String,
|
||||
pub narrative_binding: QuestNarrativeBindingSnapshot,
|
||||
pub steps: Vec<QuestStepSnapshot>,
|
||||
pub active_step_id: Option<String>,
|
||||
pub visible_stage: u32,
|
||||
pub hidden_flags: Vec<String>,
|
||||
pub discovered_fact_ids: Vec<String>,
|
||||
pub related_carrier_ids: Vec<String>,
|
||||
pub consequence_ids: Vec<String>,
|
||||
pub created_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuestSignalApplyInput {
|
||||
pub quest_id: String,
|
||||
pub signal: QuestProgressSignal,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuestSignalApplyOutcome {
|
||||
pub next_record: QuestRecordSnapshot,
|
||||
pub changed: bool,
|
||||
pub completed_now: bool,
|
||||
pub changed_step_id: Option<String>,
|
||||
pub changed_step_progress: Option<u32>,
|
||||
pub signal_kind: QuestSignalKind,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuestCompletionAckInput {
|
||||
pub quest_id: String,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuestCompletionAckOutcome {
|
||||
pub next_record: QuestRecordSnapshot,
|
||||
pub changed: bool,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuestTurnInInput {
|
||||
pub quest_id: String,
|
||||
pub turned_in_at_micros: i64,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,282 @@
|
||||
//! 任务领域模型过渡落位。
|
||||
//! 任务领域模型。
|
||||
//!
|
||||
//! 后续迁移任务记录、步骤、目标、奖励和日志规则时,只保留任务聚合内部变化;
|
||||
//! 奖励发放和成长记账通过事件交给外层事务编排。
|
||||
//! 本文件承载任务状态、步骤、奖励、叙事绑定和进度信号等稳定值对象。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[cfg(feature = "spacetime-types")]
|
||||
use spacetimedb::SpacetimeType;
|
||||
|
||||
pub const QUEST_LOG_ID_PREFIX: &str = "questlog_";
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum QuestStatus {
|
||||
Active,
|
||||
ReadyToTurnIn,
|
||||
Completed,
|
||||
TurnedIn,
|
||||
Failed,
|
||||
Expired,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum QuestNarrativeType {
|
||||
Bounty,
|
||||
Escort,
|
||||
Investigation,
|
||||
Retrieval,
|
||||
Relationship,
|
||||
Trial,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum QuestObjectiveKind {
|
||||
DefeatHostileNpc,
|
||||
InspectTreasure,
|
||||
SparWithNpc,
|
||||
TalkToNpc,
|
||||
ReachScene,
|
||||
DeliverItem,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum QuestRewardItemRarity {
|
||||
Common,
|
||||
Uncommon,
|
||||
Rare,
|
||||
Epic,
|
||||
Legendary,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum QuestNarrativeOrigin {
|
||||
AiCompiled,
|
||||
FallbackBuilder,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum QuestLogEventKind {
|
||||
Accepted,
|
||||
Progressed,
|
||||
Completed,
|
||||
CompletionAcknowledged,
|
||||
TurnedIn,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum QuestSignalKind {
|
||||
HostileNpcDefeated,
|
||||
TreasureInspected,
|
||||
NpcSparCompleted,
|
||||
NpcTalkCompleted,
|
||||
SceneReached,
|
||||
ItemDelivered,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuestRewardItem {
|
||||
pub item_id: String,
|
||||
pub category: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub quantity: u32,
|
||||
pub rarity: QuestRewardItemRarity,
|
||||
pub tags: Vec<String>,
|
||||
pub stackable: bool,
|
||||
pub stack_key: String,
|
||||
pub equipment_slot_id: Option<QuestRewardEquipmentSlot>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum QuestRewardEquipmentSlot {
|
||||
Weapon,
|
||||
Armor,
|
||||
Relic,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuestRewardIntel {
|
||||
pub rumor_text: String,
|
||||
pub unlocked_scene_id: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuestRewardSnapshot {
|
||||
pub affinity_bonus: i32,
|
||||
pub currency: i64,
|
||||
pub experience: Option<u32>,
|
||||
pub items: Vec<QuestRewardItem>,
|
||||
pub intel: Option<QuestRewardIntel>,
|
||||
pub story_hint: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuestNarrativeBindingSnapshot {
|
||||
pub origin: QuestNarrativeOrigin,
|
||||
pub narrative_type: QuestNarrativeType,
|
||||
pub dramatic_need: String,
|
||||
pub issuer_goal: String,
|
||||
pub player_hook: String,
|
||||
pub world_reason: String,
|
||||
pub followup_hooks: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuestObjectiveSnapshot {
|
||||
pub kind: QuestObjectiveKind,
|
||||
pub target_hostile_npc_id: Option<String>,
|
||||
pub target_npc_id: Option<String>,
|
||||
pub target_scene_id: Option<String>,
|
||||
pub target_item_id: Option<String>,
|
||||
pub required_count: u32,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuestStepSnapshot {
|
||||
pub step_id: String,
|
||||
pub kind: QuestObjectiveKind,
|
||||
pub target_hostile_npc_id: Option<String>,
|
||||
pub target_npc_id: Option<String>,
|
||||
pub target_scene_id: Option<String>,
|
||||
pub target_item_id: Option<String>,
|
||||
pub required_count: u32,
|
||||
pub progress: u32,
|
||||
pub title: String,
|
||||
pub reveal_text: String,
|
||||
pub complete_text: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuestRecordSnapshot {
|
||||
pub quest_id: String,
|
||||
pub runtime_session_id: String,
|
||||
pub story_session_id: Option<String>,
|
||||
pub actor_user_id: String,
|
||||
pub issuer_npc_id: String,
|
||||
pub issuer_npc_name: String,
|
||||
pub scene_id: Option<String>,
|
||||
pub chapter_id: Option<String>,
|
||||
pub act_id: Option<String>,
|
||||
pub thread_id: Option<String>,
|
||||
pub contract_id: Option<String>,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub summary: String,
|
||||
pub objective: QuestObjectiveSnapshot,
|
||||
pub progress: u32,
|
||||
pub status: QuestStatus,
|
||||
pub completion_notified: bool,
|
||||
pub reward: QuestRewardSnapshot,
|
||||
pub reward_text: String,
|
||||
pub narrative_binding: QuestNarrativeBindingSnapshot,
|
||||
pub steps: Vec<QuestStepSnapshot>,
|
||||
pub active_step_id: Option<String>,
|
||||
pub visible_stage: u32,
|
||||
pub hidden_flags: Vec<String>,
|
||||
pub discovered_fact_ids: Vec<String>,
|
||||
pub related_carrier_ids: Vec<String>,
|
||||
pub consequence_ids: Vec<String>,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
pub completed_at_micros: Option<i64>,
|
||||
pub turned_in_at_micros: Option<i64>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuestHostileNpcDefeatedSignal {
|
||||
pub scene_id: Option<String>,
|
||||
pub hostile_npc_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuestTreasureInspectedSignal {
|
||||
pub scene_id: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuestNpcSparCompletedSignal {
|
||||
pub npc_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuestNpcTalkCompletedSignal {
|
||||
pub npc_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuestSceneReachedSignal {
|
||||
pub scene_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuestItemDeliveredSignal {
|
||||
pub npc_id: String,
|
||||
pub item_id: String,
|
||||
pub quantity: u32,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum QuestProgressSignal {
|
||||
HostileNpcDefeated(QuestHostileNpcDefeatedSignal),
|
||||
TreasureInspected(QuestTreasureInspectedSignal),
|
||||
NpcSparCompleted(QuestNpcSparCompletedSignal),
|
||||
NpcTalkCompleted(QuestNpcTalkCompletedSignal),
|
||||
SceneReached(QuestSceneReachedSignal),
|
||||
ItemDelivered(QuestItemDeliveredSignal),
|
||||
}
|
||||
|
||||
impl QuestStatus {
|
||||
pub fn is_terminal(self) -> bool {
|
||||
matches!(self, Self::TurnedIn | Self::Failed | Self::Expired)
|
||||
}
|
||||
|
||||
pub fn is_reward_ready(self) -> bool {
|
||||
matches!(self, Self::ReadyToTurnIn | Self::Completed)
|
||||
}
|
||||
}
|
||||
|
||||
impl QuestLogEventKind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Accepted => "accepted",
|
||||
Self::Progressed => "progressed",
|
||||
Self::Completed => "completed",
|
||||
Self::CompletionAcknowledged => "completion_ack",
|
||||
Self::TurnedIn => "turned_in",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&QuestProgressSignal> for QuestSignalKind {
|
||||
fn from(value: &QuestProgressSignal) -> Self {
|
||||
match value {
|
||||
QuestProgressSignal::HostileNpcDefeated(_) => Self::HostileNpcDefeated,
|
||||
QuestProgressSignal::TreasureInspected(_) => Self::TreasureInspected,
|
||||
QuestProgressSignal::NpcSparCompleted(_) => Self::NpcSparCompleted,
|
||||
QuestProgressSignal::NpcTalkCompleted(_) => Self::NpcTalkCompleted,
|
||||
QuestProgressSignal::SceneReached(_) => Self::SceneReached,
|
||||
QuestProgressSignal::ItemDelivered(_) => Self::ItemDelivered,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,72 @@
|
||||
//! 任务领域错误过渡落位。
|
||||
//! 任务领域错误。
|
||||
//!
|
||||
//! 错误保持任务规则语义,例如状态不允许、目标不匹配或重复交付。
|
||||
//! 错误保持任务业务语义,例如字段缺失、步骤非法或任务尚未可交付。
|
||||
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum QuestRecordFieldError {
|
||||
MissingQuestId,
|
||||
MissingRuntimeSessionId,
|
||||
MissingActorUserId,
|
||||
MissingIssuerNpcId,
|
||||
MissingIssuerNpcName,
|
||||
MissingTitle,
|
||||
MissingDescription,
|
||||
MissingRewardText,
|
||||
EmptySteps,
|
||||
MissingStepId,
|
||||
MissingStepTitle,
|
||||
MissingStepRevealText,
|
||||
MissingStepCompleteText,
|
||||
QuestNotReadyToTurnIn,
|
||||
MissingRewardItemId,
|
||||
MissingRewardItemCategory,
|
||||
MissingRewardItemName,
|
||||
InvalidRewardItemQuantity,
|
||||
MissingRewardItemStackKey,
|
||||
RewardEquipmentItemCannotStack,
|
||||
RewardNonStackableItemMustStaySingleQuantity,
|
||||
}
|
||||
|
||||
impl fmt::Display for QuestRecordFieldError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::MissingQuestId => f.write_str("quest_record.quest_id 不能为空"),
|
||||
Self::MissingRuntimeSessionId => {
|
||||
f.write_str("quest_record.runtime_session_id 不能为空")
|
||||
}
|
||||
Self::MissingActorUserId => f.write_str("quest_record.actor_user_id 不能为空"),
|
||||
Self::MissingIssuerNpcId => f.write_str("quest_record.issuer_npc_id 不能为空"),
|
||||
Self::MissingIssuerNpcName => f.write_str("quest_record.issuer_npc_name 不能为空"),
|
||||
Self::MissingTitle => f.write_str("quest_record.title 不能为空"),
|
||||
Self::MissingDescription => f.write_str("quest_record.description 不能为空"),
|
||||
Self::MissingRewardText => f.write_str("quest_record.reward_text 不能为空"),
|
||||
Self::EmptySteps => f.write_str("quest_record.steps 至少需要一条 step"),
|
||||
Self::MissingStepId => f.write_str("quest_step.step_id 不能为空"),
|
||||
Self::MissingStepTitle => f.write_str("quest_step.title 不能为空"),
|
||||
Self::MissingStepRevealText => f.write_str("quest_step.reveal_text 不能为空"),
|
||||
Self::MissingStepCompleteText => f.write_str("quest_step.complete_text 不能为空"),
|
||||
Self::QuestNotReadyToTurnIn => f.write_str("当前任务还没有进入可交付状态"),
|
||||
Self::MissingRewardItemId => f.write_str("quest_reward.items[].item_id 不能为空"),
|
||||
Self::MissingRewardItemCategory => {
|
||||
f.write_str("quest_reward.items[].category 不能为空")
|
||||
}
|
||||
Self::MissingRewardItemName => f.write_str("quest_reward.items[].name 不能为空"),
|
||||
Self::InvalidRewardItemQuantity => {
|
||||
f.write_str("quest_reward.items[].quantity 必须大于 0")
|
||||
}
|
||||
Self::MissingRewardItemStackKey => {
|
||||
f.write_str("quest_reward.items[].stack_key 不能为空")
|
||||
}
|
||||
Self::RewardEquipmentItemCannotStack => {
|
||||
f.write_str("quest_reward.items[] 可装备物品不能标记为 stackable")
|
||||
}
|
||||
Self::RewardNonStackableItemMustStaySingleQuantity => {
|
||||
f.write_str("quest_reward.items[] 不可堆叠物品必须固定为单槽位单数量")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for QuestRecordFieldError {}
|
||||
|
||||
@@ -1,3 +1,40 @@
|
||||
//! 任务领域事件过渡落位。
|
||||
//! 任务领域事件。
|
||||
//!
|
||||
//! 用于表达任务已领取、进度已推进、任务已完成和奖励待发放等事实。
|
||||
//! 用于表达任务接受、推进、完成确认和交付等事实。
|
||||
|
||||
use crate::domain::{QuestLogEventKind, QuestSignalKind};
|
||||
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 QuestDomainEvent {
|
||||
QuestAccepted(QuestAcceptedEvent),
|
||||
QuestProgressed(QuestProgressedEvent),
|
||||
QuestLogRecorded(QuestLogRecordedEvent),
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuestAcceptedEvent {
|
||||
pub quest_id: String,
|
||||
pub occurred_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuestProgressedEvent {
|
||||
pub quest_id: String,
|
||||
pub signal_kind: QuestSignalKind,
|
||||
pub changed_step_id: Option<String>,
|
||||
pub occurred_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuestLogRecordedEvent {
|
||||
pub quest_id: String,
|
||||
pub event_kind: QuestLogEventKind,
|
||||
pub occurred_at_micros: i64,
|
||||
}
|
||||
|
||||
@@ -4,914 +4,11 @@ mod domain;
|
||||
mod errors;
|
||||
mod events;
|
||||
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared_kernel::{
|
||||
normalize_optional_string as normalize_shared_optional_string, normalize_required_string,
|
||||
normalize_string_list as normalize_shared_string_list,
|
||||
};
|
||||
#[cfg(feature = "spacetime-types")]
|
||||
use spacetimedb::SpacetimeType;
|
||||
|
||||
pub const QUEST_LOG_ID_PREFIX: &str = "questlog_";
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum QuestStatus {
|
||||
Active,
|
||||
ReadyToTurnIn,
|
||||
Completed,
|
||||
TurnedIn,
|
||||
Failed,
|
||||
Expired,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum QuestNarrativeType {
|
||||
Bounty,
|
||||
Escort,
|
||||
Investigation,
|
||||
Retrieval,
|
||||
Relationship,
|
||||
Trial,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum QuestObjectiveKind {
|
||||
DefeatHostileNpc,
|
||||
InspectTreasure,
|
||||
SparWithNpc,
|
||||
TalkToNpc,
|
||||
ReachScene,
|
||||
DeliverItem,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum QuestRewardItemRarity {
|
||||
Common,
|
||||
Uncommon,
|
||||
Rare,
|
||||
Epic,
|
||||
Legendary,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum QuestNarrativeOrigin {
|
||||
AiCompiled,
|
||||
FallbackBuilder,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum QuestLogEventKind {
|
||||
Accepted,
|
||||
Progressed,
|
||||
Completed,
|
||||
CompletionAcknowledged,
|
||||
TurnedIn,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum QuestSignalKind {
|
||||
HostileNpcDefeated,
|
||||
TreasureInspected,
|
||||
NpcSparCompleted,
|
||||
NpcTalkCompleted,
|
||||
SceneReached,
|
||||
ItemDelivered,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuestRewardItem {
|
||||
pub item_id: String,
|
||||
pub category: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub quantity: u32,
|
||||
pub rarity: QuestRewardItemRarity,
|
||||
pub tags: Vec<String>,
|
||||
pub stackable: bool,
|
||||
pub stack_key: String,
|
||||
pub equipment_slot_id: Option<QuestRewardEquipmentSlot>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum QuestRewardEquipmentSlot {
|
||||
Weapon,
|
||||
Armor,
|
||||
Relic,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuestRewardIntel {
|
||||
pub rumor_text: String,
|
||||
pub unlocked_scene_id: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuestRewardSnapshot {
|
||||
pub affinity_bonus: i32,
|
||||
pub currency: i64,
|
||||
pub experience: Option<u32>,
|
||||
pub items: Vec<QuestRewardItem>,
|
||||
pub intel: Option<QuestRewardIntel>,
|
||||
pub story_hint: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuestNarrativeBindingSnapshot {
|
||||
pub origin: QuestNarrativeOrigin,
|
||||
pub narrative_type: QuestNarrativeType,
|
||||
pub dramatic_need: String,
|
||||
pub issuer_goal: String,
|
||||
pub player_hook: String,
|
||||
pub world_reason: String,
|
||||
pub followup_hooks: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuestObjectiveSnapshot {
|
||||
pub kind: QuestObjectiveKind,
|
||||
pub target_hostile_npc_id: Option<String>,
|
||||
pub target_npc_id: Option<String>,
|
||||
pub target_scene_id: Option<String>,
|
||||
pub target_item_id: Option<String>,
|
||||
pub required_count: u32,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuestStepSnapshot {
|
||||
pub step_id: String,
|
||||
pub kind: QuestObjectiveKind,
|
||||
pub target_hostile_npc_id: Option<String>,
|
||||
pub target_npc_id: Option<String>,
|
||||
pub target_scene_id: Option<String>,
|
||||
pub target_item_id: Option<String>,
|
||||
pub required_count: u32,
|
||||
pub progress: u32,
|
||||
pub title: String,
|
||||
pub reveal_text: String,
|
||||
pub complete_text: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuestRecordInput {
|
||||
pub quest_id: String,
|
||||
pub runtime_session_id: String,
|
||||
pub story_session_id: Option<String>,
|
||||
pub actor_user_id: String,
|
||||
pub issuer_npc_id: String,
|
||||
pub issuer_npc_name: String,
|
||||
pub scene_id: Option<String>,
|
||||
pub chapter_id: Option<String>,
|
||||
pub act_id: Option<String>,
|
||||
pub thread_id: Option<String>,
|
||||
pub contract_id: Option<String>,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub summary: String,
|
||||
pub status: QuestStatus,
|
||||
pub completion_notified: bool,
|
||||
pub reward: QuestRewardSnapshot,
|
||||
pub reward_text: String,
|
||||
pub narrative_binding: QuestNarrativeBindingSnapshot,
|
||||
pub steps: Vec<QuestStepSnapshot>,
|
||||
pub active_step_id: Option<String>,
|
||||
pub visible_stage: u32,
|
||||
pub hidden_flags: Vec<String>,
|
||||
pub discovered_fact_ids: Vec<String>,
|
||||
pub related_carrier_ids: Vec<String>,
|
||||
pub consequence_ids: Vec<String>,
|
||||
pub created_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuestRecordSnapshot {
|
||||
pub quest_id: String,
|
||||
pub runtime_session_id: String,
|
||||
pub story_session_id: Option<String>,
|
||||
pub actor_user_id: String,
|
||||
pub issuer_npc_id: String,
|
||||
pub issuer_npc_name: String,
|
||||
pub scene_id: Option<String>,
|
||||
pub chapter_id: Option<String>,
|
||||
pub act_id: Option<String>,
|
||||
pub thread_id: Option<String>,
|
||||
pub contract_id: Option<String>,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub summary: String,
|
||||
pub objective: QuestObjectiveSnapshot,
|
||||
pub progress: u32,
|
||||
pub status: QuestStatus,
|
||||
pub completion_notified: bool,
|
||||
pub reward: QuestRewardSnapshot,
|
||||
pub reward_text: String,
|
||||
pub narrative_binding: QuestNarrativeBindingSnapshot,
|
||||
pub steps: Vec<QuestStepSnapshot>,
|
||||
pub active_step_id: Option<String>,
|
||||
pub visible_stage: u32,
|
||||
pub hidden_flags: Vec<String>,
|
||||
pub discovered_fact_ids: Vec<String>,
|
||||
pub related_carrier_ids: Vec<String>,
|
||||
pub consequence_ids: Vec<String>,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
pub completed_at_micros: Option<i64>,
|
||||
pub turned_in_at_micros: Option<i64>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuestHostileNpcDefeatedSignal {
|
||||
pub scene_id: Option<String>,
|
||||
pub hostile_npc_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuestTreasureInspectedSignal {
|
||||
pub scene_id: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuestNpcSparCompletedSignal {
|
||||
pub npc_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuestNpcTalkCompletedSignal {
|
||||
pub npc_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuestSceneReachedSignal {
|
||||
pub scene_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuestItemDeliveredSignal {
|
||||
pub npc_id: String,
|
||||
pub item_id: String,
|
||||
pub quantity: u32,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum QuestProgressSignal {
|
||||
HostileNpcDefeated(QuestHostileNpcDefeatedSignal),
|
||||
TreasureInspected(QuestTreasureInspectedSignal),
|
||||
NpcSparCompleted(QuestNpcSparCompletedSignal),
|
||||
NpcTalkCompleted(QuestNpcTalkCompletedSignal),
|
||||
SceneReached(QuestSceneReachedSignal),
|
||||
ItemDelivered(QuestItemDeliveredSignal),
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuestSignalApplyInput {
|
||||
pub quest_id: String,
|
||||
pub signal: QuestProgressSignal,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuestSignalApplyOutcome {
|
||||
pub next_record: QuestRecordSnapshot,
|
||||
pub changed: bool,
|
||||
pub completed_now: bool,
|
||||
pub changed_step_id: Option<String>,
|
||||
pub changed_step_progress: Option<u32>,
|
||||
pub signal_kind: QuestSignalKind,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuestCompletionAckInput {
|
||||
pub quest_id: String,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuestCompletionAckOutcome {
|
||||
pub next_record: QuestRecordSnapshot,
|
||||
pub changed: bool,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuestTurnInInput {
|
||||
pub quest_id: String,
|
||||
pub turned_in_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum QuestRecordFieldError {
|
||||
MissingQuestId,
|
||||
MissingRuntimeSessionId,
|
||||
MissingActorUserId,
|
||||
MissingIssuerNpcId,
|
||||
MissingIssuerNpcName,
|
||||
MissingTitle,
|
||||
MissingDescription,
|
||||
MissingRewardText,
|
||||
EmptySteps,
|
||||
MissingStepId,
|
||||
MissingStepTitle,
|
||||
MissingStepRevealText,
|
||||
MissingStepCompleteText,
|
||||
QuestNotReadyToTurnIn,
|
||||
MissingRewardItemId,
|
||||
MissingRewardItemCategory,
|
||||
MissingRewardItemName,
|
||||
InvalidRewardItemQuantity,
|
||||
MissingRewardItemStackKey,
|
||||
RewardEquipmentItemCannotStack,
|
||||
RewardNonStackableItemMustStaySingleQuantity,
|
||||
}
|
||||
|
||||
impl QuestStatus {
|
||||
pub fn is_terminal(self) -> bool {
|
||||
matches!(self, Self::TurnedIn | Self::Failed | Self::Expired)
|
||||
}
|
||||
|
||||
pub fn is_reward_ready(self) -> bool {
|
||||
matches!(self, Self::ReadyToTurnIn | Self::Completed)
|
||||
}
|
||||
}
|
||||
|
||||
impl QuestLogEventKind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Accepted => "accepted",
|
||||
Self::Progressed => "progressed",
|
||||
Self::Completed => "completed",
|
||||
Self::CompletionAcknowledged => "completion_ack",
|
||||
Self::TurnedIn => "turned_in",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&QuestProgressSignal> for QuestSignalKind {
|
||||
fn from(value: &QuestProgressSignal) -> Self {
|
||||
match value {
|
||||
QuestProgressSignal::HostileNpcDefeated(_) => Self::HostileNpcDefeated,
|
||||
QuestProgressSignal::TreasureInspected(_) => Self::TreasureInspected,
|
||||
QuestProgressSignal::NpcSparCompleted(_) => Self::NpcSparCompleted,
|
||||
QuestProgressSignal::NpcTalkCompleted(_) => Self::NpcTalkCompleted,
|
||||
QuestProgressSignal::SceneReached(_) => Self::SceneReached,
|
||||
QuestProgressSignal::ItemDelivered(_) => Self::ItemDelivered,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_optional_text(value: Option<String>) -> Option<String> {
|
||||
normalize_shared_optional_string(value)
|
||||
}
|
||||
|
||||
pub fn normalize_string_list(values: Vec<String>) -> Vec<String> {
|
||||
normalize_shared_string_list(values)
|
||||
}
|
||||
|
||||
pub fn build_quest_record_snapshot(
|
||||
input: QuestRecordInput,
|
||||
) -> Result<QuestRecordSnapshot, QuestRecordFieldError> {
|
||||
let quest_id = normalize_required_text(input.quest_id, QuestRecordFieldError::MissingQuestId)?;
|
||||
let runtime_session_id = normalize_required_text(
|
||||
input.runtime_session_id,
|
||||
QuestRecordFieldError::MissingRuntimeSessionId,
|
||||
)?;
|
||||
let actor_user_id = normalize_required_text(
|
||||
input.actor_user_id,
|
||||
QuestRecordFieldError::MissingActorUserId,
|
||||
)?;
|
||||
let issuer_npc_id = normalize_required_text(
|
||||
input.issuer_npc_id,
|
||||
QuestRecordFieldError::MissingIssuerNpcId,
|
||||
)?;
|
||||
let issuer_npc_name = normalize_required_text(
|
||||
input.issuer_npc_name,
|
||||
QuestRecordFieldError::MissingIssuerNpcName,
|
||||
)?;
|
||||
let title = normalize_required_text(input.title, QuestRecordFieldError::MissingTitle)?;
|
||||
let description =
|
||||
normalize_required_text(input.description, QuestRecordFieldError::MissingDescription)?;
|
||||
let reward_text =
|
||||
normalize_required_text(input.reward_text, QuestRecordFieldError::MissingRewardText)?;
|
||||
|
||||
if input.steps.is_empty() {
|
||||
return Err(QuestRecordFieldError::EmptySteps);
|
||||
}
|
||||
|
||||
let steps = input
|
||||
.steps
|
||||
.into_iter()
|
||||
.map(normalize_quest_step)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let active_step = resolve_active_step(&steps, input.active_step_id.as_deref());
|
||||
let active_step_id = active_step.map(|step| step.step_id.clone());
|
||||
let fallback_step = steps
|
||||
.last()
|
||||
.cloned()
|
||||
.expect("BUG: validated quest steps should not be empty");
|
||||
let objective = build_objective_from_step(active_step.unwrap_or(&fallback_step));
|
||||
let progress = active_step
|
||||
.map(|step| step.progress)
|
||||
.unwrap_or(fallback_step.required_count);
|
||||
let status = normalize_quest_status(input.status, active_step.is_some());
|
||||
let completed_at_micros = if status.is_reward_ready() {
|
||||
Some(input.created_at_micros)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let turned_in_at_micros = if status == QuestStatus::TurnedIn {
|
||||
Some(input.created_at_micros)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(QuestRecordSnapshot {
|
||||
quest_id,
|
||||
runtime_session_id,
|
||||
story_session_id: normalize_optional_text(input.story_session_id),
|
||||
actor_user_id,
|
||||
issuer_npc_id,
|
||||
issuer_npc_name,
|
||||
scene_id: normalize_optional_text(input.scene_id),
|
||||
chapter_id: normalize_optional_text(input.chapter_id),
|
||||
act_id: normalize_optional_text(input.act_id),
|
||||
thread_id: normalize_optional_text(input.thread_id),
|
||||
contract_id: normalize_optional_text(input.contract_id),
|
||||
title,
|
||||
description: description.clone(),
|
||||
summary: normalize_optional_text(Some(input.summary)).unwrap_or(description),
|
||||
objective,
|
||||
progress,
|
||||
status,
|
||||
completion_notified: input.completion_notified || status == QuestStatus::TurnedIn,
|
||||
reward: normalize_quest_reward(input.reward)?,
|
||||
reward_text,
|
||||
narrative_binding: normalize_quest_narrative_binding(input.narrative_binding),
|
||||
steps,
|
||||
active_step_id,
|
||||
visible_stage: input.visible_stage,
|
||||
hidden_flags: normalize_string_list(input.hidden_flags),
|
||||
discovered_fact_ids: normalize_string_list(input.discovered_fact_ids),
|
||||
related_carrier_ids: normalize_string_list(input.related_carrier_ids),
|
||||
consequence_ids: normalize_string_list(input.consequence_ids),
|
||||
created_at_micros: input.created_at_micros,
|
||||
updated_at_micros: input.created_at_micros,
|
||||
completed_at_micros,
|
||||
turned_in_at_micros,
|
||||
})
|
||||
}
|
||||
|
||||
// 任务推进只认当前 active step,未命中或已终态时统一保持 no-op,确保 story action 可安全重复派发信号。
|
||||
pub fn apply_quest_signal(
|
||||
current: QuestRecordSnapshot,
|
||||
input: QuestSignalApplyInput,
|
||||
) -> Result<QuestSignalApplyOutcome, QuestRecordFieldError> {
|
||||
let quest_id = normalize_required_text(input.quest_id, QuestRecordFieldError::MissingQuestId)?;
|
||||
let signal_kind = QuestSignalKind::from(&input.signal);
|
||||
|
||||
if current.quest_id != quest_id
|
||||
|| current.status.is_terminal()
|
||||
|| current.status.is_reward_ready()
|
||||
{
|
||||
return Ok(QuestSignalApplyOutcome {
|
||||
next_record: current,
|
||||
changed: false,
|
||||
completed_now: false,
|
||||
changed_step_id: None,
|
||||
changed_step_progress: None,
|
||||
signal_kind,
|
||||
});
|
||||
}
|
||||
|
||||
let active_step = match resolve_active_step(¤t.steps, current.active_step_id.as_deref()) {
|
||||
Some(step) => step,
|
||||
None => {
|
||||
return Ok(QuestSignalApplyOutcome {
|
||||
next_record: current,
|
||||
changed: false,
|
||||
completed_now: false,
|
||||
changed_step_id: None,
|
||||
changed_step_progress: None,
|
||||
signal_kind,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if !step_matches_signal(active_step, &input.signal) {
|
||||
return Ok(QuestSignalApplyOutcome {
|
||||
next_record: current,
|
||||
changed: false,
|
||||
completed_now: false,
|
||||
changed_step_id: None,
|
||||
changed_step_progress: None,
|
||||
signal_kind,
|
||||
});
|
||||
}
|
||||
|
||||
let increment = signal_progress_increment(&input.signal);
|
||||
let mut changed_step_id = None;
|
||||
let mut changed_step_progress = None;
|
||||
let next_steps = current
|
||||
.steps
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|mut step| {
|
||||
if step.step_id == active_step.step_id {
|
||||
let next_progress = (step.progress + increment).min(step.required_count);
|
||||
if next_progress != step.progress {
|
||||
step.progress = next_progress;
|
||||
changed_step_id = Some(step.step_id.clone());
|
||||
changed_step_progress = Some(step.progress);
|
||||
}
|
||||
}
|
||||
step
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if changed_step_id.is_none() {
|
||||
return Ok(QuestSignalApplyOutcome {
|
||||
next_record: current,
|
||||
changed: false,
|
||||
completed_now: false,
|
||||
changed_step_id: None,
|
||||
changed_step_progress: None,
|
||||
signal_kind,
|
||||
});
|
||||
}
|
||||
|
||||
let next_active_step = resolve_active_step(&next_steps, None);
|
||||
let next_active_step_id = next_active_step.map(|step| step.step_id.clone());
|
||||
let fallback_step = next_steps
|
||||
.last()
|
||||
.cloned()
|
||||
.expect("BUG: progressed quest should still contain steps");
|
||||
let next_status = normalize_quest_status(current.status, next_active_step.is_some());
|
||||
let completed_now = !current.status.is_reward_ready() && next_status.is_reward_ready();
|
||||
let next_objective = build_objective_from_step(next_active_step.unwrap_or(&fallback_step));
|
||||
let next_progress = next_active_step
|
||||
.map(|step| step.progress)
|
||||
.unwrap_or(fallback_step.required_count);
|
||||
|
||||
Ok(QuestSignalApplyOutcome {
|
||||
next_record: QuestRecordSnapshot {
|
||||
objective: next_objective,
|
||||
progress: next_progress,
|
||||
status: next_status,
|
||||
completion_notified: false,
|
||||
steps: next_steps,
|
||||
active_step_id: next_active_step_id,
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
completed_at_micros: if completed_now {
|
||||
Some(input.updated_at_micros)
|
||||
} else {
|
||||
current.completed_at_micros
|
||||
},
|
||||
..current
|
||||
},
|
||||
changed: true,
|
||||
completed_now,
|
||||
changed_step_id,
|
||||
changed_step_progress,
|
||||
signal_kind,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn acknowledge_quest_completion(
|
||||
current: QuestRecordSnapshot,
|
||||
input: QuestCompletionAckInput,
|
||||
) -> Result<QuestCompletionAckOutcome, QuestRecordFieldError> {
|
||||
let quest_id = normalize_required_text(input.quest_id, QuestRecordFieldError::MissingQuestId)?;
|
||||
|
||||
if current.quest_id != quest_id || current.completion_notified {
|
||||
return Ok(QuestCompletionAckOutcome {
|
||||
next_record: current,
|
||||
changed: false,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(QuestCompletionAckOutcome {
|
||||
next_record: QuestRecordSnapshot {
|
||||
completion_notified: true,
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
..current
|
||||
},
|
||||
changed: true,
|
||||
})
|
||||
}
|
||||
|
||||
// 任务交付只负责把任务固定到 TurnedIn,不在本轮提前掺入货币、背包和关系奖励发放。
|
||||
pub fn turn_in_quest_record(
|
||||
current: QuestRecordSnapshot,
|
||||
input: QuestTurnInInput,
|
||||
) -> Result<QuestRecordSnapshot, QuestRecordFieldError> {
|
||||
let quest_id = normalize_required_text(input.quest_id, QuestRecordFieldError::MissingQuestId)?;
|
||||
|
||||
if current.quest_id != quest_id || !current.status.is_reward_ready() {
|
||||
return Err(QuestRecordFieldError::QuestNotReadyToTurnIn);
|
||||
}
|
||||
|
||||
let steps = current
|
||||
.steps
|
||||
.into_iter()
|
||||
.map(|mut step| {
|
||||
step.progress = step.required_count;
|
||||
step
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let fallback_step = steps
|
||||
.last()
|
||||
.cloned()
|
||||
.expect("BUG: turn in quest should preserve steps");
|
||||
|
||||
Ok(QuestRecordSnapshot {
|
||||
objective: build_objective_from_step(&fallback_step),
|
||||
progress: fallback_step.required_count,
|
||||
status: QuestStatus::TurnedIn,
|
||||
completion_notified: true,
|
||||
steps,
|
||||
active_step_id: None,
|
||||
updated_at_micros: input.turned_in_at_micros,
|
||||
completed_at_micros: current
|
||||
.completed_at_micros
|
||||
.or(Some(input.turned_in_at_micros)),
|
||||
turned_in_at_micros: Some(input.turned_in_at_micros),
|
||||
..current
|
||||
})
|
||||
}
|
||||
|
||||
pub fn generate_quest_log_id(
|
||||
quest_id: &str,
|
||||
event_kind: QuestLogEventKind,
|
||||
seed_micros: i64,
|
||||
) -> String {
|
||||
format!(
|
||||
"{}{}_{:x}_{}",
|
||||
QUEST_LOG_ID_PREFIX,
|
||||
event_kind.as_str(),
|
||||
seed_micros,
|
||||
quest_id
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_required_text(
|
||||
value: String,
|
||||
error: QuestRecordFieldError,
|
||||
) -> Result<String, QuestRecordFieldError> {
|
||||
normalize_required_string(value).ok_or(error)
|
||||
}
|
||||
|
||||
fn normalize_quest_reward(
|
||||
mut reward: QuestRewardSnapshot,
|
||||
) -> Result<QuestRewardSnapshot, QuestRecordFieldError> {
|
||||
reward.story_hint = normalize_optional_text(reward.story_hint);
|
||||
reward.intel = reward.intel.and_then(|intel| {
|
||||
let rumor_text = intel.rumor_text.trim().to_string();
|
||||
let unlocked_scene_id = normalize_optional_text(intel.unlocked_scene_id);
|
||||
if rumor_text.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(QuestRewardIntel {
|
||||
rumor_text,
|
||||
unlocked_scene_id,
|
||||
})
|
||||
}
|
||||
});
|
||||
reward.items = reward
|
||||
.items
|
||||
.into_iter()
|
||||
.map(
|
||||
|mut item| -> Result<QuestRewardItem, QuestRecordFieldError> {
|
||||
item.item_id = normalize_required_text(
|
||||
item.item_id,
|
||||
QuestRecordFieldError::MissingRewardItemId,
|
||||
)?;
|
||||
item.category = normalize_required_text(
|
||||
item.category,
|
||||
QuestRecordFieldError::MissingRewardItemCategory,
|
||||
)?;
|
||||
item.name = normalize_required_text(
|
||||
item.name,
|
||||
QuestRecordFieldError::MissingRewardItemName,
|
||||
)?;
|
||||
item.description = normalize_optional_text(item.description);
|
||||
if item.quantity == 0 {
|
||||
return Err(QuestRecordFieldError::InvalidRewardItemQuantity);
|
||||
}
|
||||
if !item.stackable && item.quantity != 1 {
|
||||
return Err(
|
||||
QuestRecordFieldError::RewardNonStackableItemMustStaySingleQuantity,
|
||||
);
|
||||
}
|
||||
if item.equipment_slot_id.is_some() && item.stackable {
|
||||
return Err(QuestRecordFieldError::RewardEquipmentItemCannotStack);
|
||||
}
|
||||
item.tags = normalize_string_list(item.tags);
|
||||
item.stack_key = if item.stackable {
|
||||
normalize_required_text(
|
||||
item.stack_key,
|
||||
QuestRecordFieldError::MissingRewardItemStackKey,
|
||||
)?
|
||||
} else {
|
||||
normalize_optional_text(Some(item.stack_key))
|
||||
.unwrap_or_else(|| item.item_id.clone())
|
||||
};
|
||||
Ok(item)
|
||||
},
|
||||
)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(reward)
|
||||
}
|
||||
|
||||
fn normalize_quest_narrative_binding(
|
||||
mut binding: QuestNarrativeBindingSnapshot,
|
||||
) -> QuestNarrativeBindingSnapshot {
|
||||
binding.dramatic_need = binding.dramatic_need.trim().to_string();
|
||||
binding.issuer_goal = binding.issuer_goal.trim().to_string();
|
||||
binding.player_hook = binding.player_hook.trim().to_string();
|
||||
binding.world_reason = binding.world_reason.trim().to_string();
|
||||
binding.followup_hooks = normalize_string_list(binding.followup_hooks);
|
||||
binding
|
||||
}
|
||||
|
||||
fn normalize_quest_step(
|
||||
mut step: QuestStepSnapshot,
|
||||
) -> Result<QuestStepSnapshot, QuestRecordFieldError> {
|
||||
step.step_id = normalize_required_text(step.step_id, QuestRecordFieldError::MissingStepId)?;
|
||||
step.title = normalize_required_text(step.title, QuestRecordFieldError::MissingStepTitle)?;
|
||||
step.reveal_text = normalize_required_text(
|
||||
step.reveal_text,
|
||||
QuestRecordFieldError::MissingStepRevealText,
|
||||
)?;
|
||||
step.complete_text = normalize_required_text(
|
||||
step.complete_text,
|
||||
QuestRecordFieldError::MissingStepCompleteText,
|
||||
)?;
|
||||
step.required_count = step.required_count.max(1);
|
||||
step.progress = step.progress.min(step.required_count);
|
||||
step.target_hostile_npc_id = normalize_optional_text(step.target_hostile_npc_id);
|
||||
step.target_npc_id = normalize_optional_text(step.target_npc_id);
|
||||
step.target_scene_id = normalize_optional_text(step.target_scene_id);
|
||||
step.target_item_id = normalize_optional_text(step.target_item_id);
|
||||
Ok(step)
|
||||
}
|
||||
|
||||
fn resolve_active_step<'a>(
|
||||
steps: &'a [QuestStepSnapshot],
|
||||
active_step_id: Option<&str>,
|
||||
) -> Option<&'a QuestStepSnapshot> {
|
||||
if let Some(active_step_id) = active_step_id {
|
||||
let active_step_id = active_step_id.trim();
|
||||
if !active_step_id.is_empty() {
|
||||
if let Some(step) = steps
|
||||
.iter()
|
||||
.find(|step| step.step_id == active_step_id && step.progress < step.required_count)
|
||||
{
|
||||
return Some(step);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
steps
|
||||
.iter()
|
||||
.find(|step| step.progress < step.required_count)
|
||||
}
|
||||
|
||||
fn build_objective_from_step(step: &QuestStepSnapshot) -> QuestObjectiveSnapshot {
|
||||
QuestObjectiveSnapshot {
|
||||
kind: step.kind,
|
||||
target_hostile_npc_id: step.target_hostile_npc_id.clone(),
|
||||
target_npc_id: step.target_npc_id.clone(),
|
||||
target_scene_id: step.target_scene_id.clone(),
|
||||
target_item_id: step.target_item_id.clone(),
|
||||
required_count: step.required_count,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_quest_status(status: QuestStatus, has_active_step: bool) -> QuestStatus {
|
||||
if status.is_terminal() {
|
||||
return status;
|
||||
}
|
||||
|
||||
if has_active_step {
|
||||
QuestStatus::Active
|
||||
} else if status == QuestStatus::ReadyToTurnIn {
|
||||
QuestStatus::ReadyToTurnIn
|
||||
} else {
|
||||
QuestStatus::Completed
|
||||
}
|
||||
}
|
||||
|
||||
fn step_matches_signal(step: &QuestStepSnapshot, signal: &QuestProgressSignal) -> bool {
|
||||
match signal {
|
||||
QuestProgressSignal::HostileNpcDefeated(payload) => {
|
||||
step.kind == QuestObjectiveKind::DefeatHostileNpc
|
||||
&& step.target_hostile_npc_id.as_deref() == Some(payload.hostile_npc_id.as_str())
|
||||
&& step
|
||||
.target_scene_id
|
||||
.as_deref()
|
||||
.is_none_or(|value| Some(value.to_string()) == payload.scene_id.clone())
|
||||
}
|
||||
QuestProgressSignal::TreasureInspected(payload) => {
|
||||
step.kind == QuestObjectiveKind::InspectTreasure
|
||||
&& step
|
||||
.target_scene_id
|
||||
.as_deref()
|
||||
.is_none_or(|value| Some(value.to_string()) == payload.scene_id.clone())
|
||||
}
|
||||
QuestProgressSignal::NpcSparCompleted(payload) => {
|
||||
step.kind == QuestObjectiveKind::SparWithNpc
|
||||
&& step.target_npc_id.as_deref() == Some(payload.npc_id.as_str())
|
||||
}
|
||||
QuestProgressSignal::NpcTalkCompleted(payload) => {
|
||||
step.kind == QuestObjectiveKind::TalkToNpc
|
||||
&& step.target_npc_id.as_deref() == Some(payload.npc_id.as_str())
|
||||
}
|
||||
QuestProgressSignal::SceneReached(payload) => {
|
||||
step.kind == QuestObjectiveKind::ReachScene
|
||||
&& step.target_scene_id.as_deref() == Some(payload.scene_id.as_str())
|
||||
}
|
||||
QuestProgressSignal::ItemDelivered(payload) => {
|
||||
step.kind == QuestObjectiveKind::DeliverItem
|
||||
&& step.target_npc_id.as_deref() == Some(payload.npc_id.as_str())
|
||||
&& step.target_item_id.as_deref() == Some(payload.item_id.as_str())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn signal_progress_increment(signal: &QuestProgressSignal) -> u32 {
|
||||
match signal {
|
||||
QuestProgressSignal::ItemDelivered(payload) => payload.quantity.max(1),
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for QuestRecordFieldError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::MissingQuestId => f.write_str("quest_record.quest_id 不能为空"),
|
||||
Self::MissingRuntimeSessionId => {
|
||||
f.write_str("quest_record.runtime_session_id 不能为空")
|
||||
}
|
||||
Self::MissingActorUserId => f.write_str("quest_record.actor_user_id 不能为空"),
|
||||
Self::MissingIssuerNpcId => f.write_str("quest_record.issuer_npc_id 不能为空"),
|
||||
Self::MissingIssuerNpcName => f.write_str("quest_record.issuer_npc_name 不能为空"),
|
||||
Self::MissingTitle => f.write_str("quest_record.title 不能为空"),
|
||||
Self::MissingDescription => f.write_str("quest_record.description 不能为空"),
|
||||
Self::MissingRewardText => f.write_str("quest_record.reward_text 不能为空"),
|
||||
Self::EmptySteps => f.write_str("quest_record.steps 至少需要一条 step"),
|
||||
Self::MissingStepId => f.write_str("quest_step.step_id 不能为空"),
|
||||
Self::MissingStepTitle => f.write_str("quest_step.title 不能为空"),
|
||||
Self::MissingStepRevealText => f.write_str("quest_step.reveal_text 不能为空"),
|
||||
Self::MissingStepCompleteText => f.write_str("quest_step.complete_text 不能为空"),
|
||||
Self::QuestNotReadyToTurnIn => f.write_str("当前任务还没有进入可交付状态"),
|
||||
Self::MissingRewardItemId => f.write_str("quest_reward.items[].item_id 不能为空"),
|
||||
Self::MissingRewardItemCategory => {
|
||||
f.write_str("quest_reward.items[].category 不能为空")
|
||||
}
|
||||
Self::MissingRewardItemName => f.write_str("quest_reward.items[].name 不能为空"),
|
||||
Self::InvalidRewardItemQuantity => {
|
||||
f.write_str("quest_reward.items[].quantity 必须大于 0")
|
||||
}
|
||||
Self::MissingRewardItemStackKey => {
|
||||
f.write_str("quest_reward.items[].stack_key 不能为空")
|
||||
}
|
||||
Self::RewardEquipmentItemCannotStack => {
|
||||
f.write_str("quest_reward.items[] 可装备物品不能标记为 stackable")
|
||||
}
|
||||
Self::RewardNonStackableItemMustStaySingleQuantity => {
|
||||
f.write_str("quest_reward.items[] 不可堆叠物品必须固定为单槽位单数量")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for QuestRecordFieldError {}
|
||||
pub use application::*;
|
||||
pub use commands::*;
|
||||
pub use domain::*;
|
||||
pub use errors::*;
|
||||
pub use events::*;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -1,3 +1,177 @@
|
||||
//! 运行时物品应用编排过渡落位。
|
||||
//! 运行时物品应用编排。
|
||||
//!
|
||||
//! 这里只返回奖励结果、记录快照和待写入背包事件。
|
||||
|
||||
use crate::commands::TreasureResolveInput;
|
||||
use crate::domain::{
|
||||
RuntimeItemEquipmentSlot, RuntimeItemRewardItemRarity, RuntimeItemRewardItemSnapshot,
|
||||
TreasureRecordSnapshot,
|
||||
};
|
||||
use crate::errors::TreasureFieldError;
|
||||
use module_inventory::{
|
||||
InventoryEquipmentSlot, InventoryItemRarity, InventoryItemSnapshot, InventoryItemSourceKind,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared_kernel::{
|
||||
normalize_optional_string as normalize_shared_optional_string, normalize_required_string,
|
||||
normalize_string_list as normalize_shared_string_list,
|
||||
};
|
||||
#[cfg(feature = "spacetime-types")]
|
||||
use spacetimedb::SpacetimeType;
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TreasureRecordProcedureResult {
|
||||
pub ok: bool,
|
||||
pub record: Option<TreasureRecordSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
pub fn build_treasure_record_snapshot(
|
||||
input: TreasureResolveInput,
|
||||
) -> Result<TreasureRecordSnapshot, TreasureFieldError> {
|
||||
validate_treasure_input(&input)?;
|
||||
|
||||
Ok(TreasureRecordSnapshot {
|
||||
treasure_record_id: input.treasure_record_id,
|
||||
runtime_session_id: input.runtime_session_id,
|
||||
story_session_id: input.story_session_id,
|
||||
actor_user_id: input.actor_user_id,
|
||||
encounter_id: input.encounter_id,
|
||||
encounter_name: input.encounter_name,
|
||||
scene_id: normalize_optional_value(input.scene_id),
|
||||
scene_name: normalize_optional_value(input.scene_name),
|
||||
action: input.action,
|
||||
reward_items: input
|
||||
.reward_items
|
||||
.into_iter()
|
||||
.map(normalize_reward_item)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
reward_hp: input.reward_hp,
|
||||
reward_mana: input.reward_mana,
|
||||
reward_currency: input.reward_currency,
|
||||
story_hint: normalize_optional_value(input.story_hint),
|
||||
created_at_micros: input.created_at_micros,
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_inventory_item_snapshot_from_reward_item(
|
||||
treasure_record_id: &str,
|
||||
reward_item: RuntimeItemRewardItemSnapshot,
|
||||
) -> Result<InventoryItemSnapshot, TreasureFieldError> {
|
||||
let treasure_record_id = normalize_required_value(
|
||||
treasure_record_id.to_string(),
|
||||
TreasureFieldError::MissingTreasureRecordId,
|
||||
)?;
|
||||
let reward_item = normalize_reward_item(reward_item)?;
|
||||
|
||||
Ok(InventoryItemSnapshot {
|
||||
item_id: reward_item.item_id,
|
||||
category: reward_item.category,
|
||||
name: reward_item.item_name,
|
||||
description: reward_item.description,
|
||||
quantity: reward_item.quantity,
|
||||
rarity: map_reward_item_rarity(reward_item.rarity),
|
||||
tags: reward_item.tags,
|
||||
stackable: reward_item.stackable,
|
||||
stack_key: reward_item.stack_key,
|
||||
equipment_slot_id: reward_item
|
||||
.equipment_slot_id
|
||||
.map(map_reward_item_equipment_slot),
|
||||
source_kind: InventoryItemSourceKind::TreasureReward,
|
||||
source_reference_id: Some(treasure_record_id),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn normalize_reward_item_snapshot(
|
||||
reward_item: RuntimeItemRewardItemSnapshot,
|
||||
) -> Result<RuntimeItemRewardItemSnapshot, TreasureFieldError> {
|
||||
normalize_reward_item(reward_item)
|
||||
}
|
||||
|
||||
fn validate_treasure_input(input: &TreasureResolveInput) -> Result<(), TreasureFieldError> {
|
||||
if input.treasure_record_id.trim().is_empty() {
|
||||
return Err(TreasureFieldError::MissingTreasureRecordId);
|
||||
}
|
||||
if input.runtime_session_id.trim().is_empty() {
|
||||
return Err(TreasureFieldError::MissingRuntimeSessionId);
|
||||
}
|
||||
if input.story_session_id.trim().is_empty() {
|
||||
return Err(TreasureFieldError::MissingStorySessionId);
|
||||
}
|
||||
if input.actor_user_id.trim().is_empty() {
|
||||
return Err(TreasureFieldError::MissingActorUserId);
|
||||
}
|
||||
if input.encounter_id.trim().is_empty() {
|
||||
return Err(TreasureFieldError::MissingEncounterId);
|
||||
}
|
||||
if input.encounter_name.trim().is_empty() {
|
||||
return Err(TreasureFieldError::MissingEncounterName);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_optional_value(value: Option<String>) -> Option<String> {
|
||||
normalize_shared_optional_string(value)
|
||||
}
|
||||
|
||||
fn normalize_reward_item(
|
||||
mut item: RuntimeItemRewardItemSnapshot,
|
||||
) -> Result<RuntimeItemRewardItemSnapshot, TreasureFieldError> {
|
||||
item.item_id = normalize_required_value(item.item_id, TreasureFieldError::MissingRewardItemId)?;
|
||||
item.category =
|
||||
normalize_required_value(item.category, TreasureFieldError::MissingRewardItemCategory)?;
|
||||
item.item_name =
|
||||
normalize_required_value(item.item_name, TreasureFieldError::MissingRewardItemName)?;
|
||||
item.description = normalize_optional_value(item.description);
|
||||
if item.quantity == 0 {
|
||||
return Err(TreasureFieldError::InvalidRewardItemQuantity);
|
||||
}
|
||||
if !item.stackable && item.quantity != 1 {
|
||||
return Err(TreasureFieldError::RewardNonStackableItemMustStaySingleQuantity);
|
||||
}
|
||||
if item.equipment_slot_id.is_some() && item.stackable {
|
||||
return Err(TreasureFieldError::RewardEquipmentItemCannotStack);
|
||||
}
|
||||
item.tags = normalize_string_list(item.tags);
|
||||
item.stack_key = if item.stackable {
|
||||
normalize_required_value(
|
||||
item.stack_key,
|
||||
TreasureFieldError::MissingRewardItemStackKey,
|
||||
)?
|
||||
} else {
|
||||
normalize_optional_value(Some(item.stack_key)).unwrap_or_else(|| item.item_id.clone())
|
||||
};
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
fn normalize_required_value(
|
||||
value: String,
|
||||
error: TreasureFieldError,
|
||||
) -> Result<String, TreasureFieldError> {
|
||||
normalize_required_string(value).ok_or(error)
|
||||
}
|
||||
|
||||
fn normalize_string_list(values: Vec<String>) -> Vec<String> {
|
||||
normalize_shared_string_list(values)
|
||||
}
|
||||
|
||||
fn map_reward_item_rarity(rarity: RuntimeItemRewardItemRarity) -> InventoryItemRarity {
|
||||
match rarity {
|
||||
RuntimeItemRewardItemRarity::Common => InventoryItemRarity::Common,
|
||||
RuntimeItemRewardItemRarity::Uncommon => InventoryItemRarity::Uncommon,
|
||||
RuntimeItemRewardItemRarity::Rare => InventoryItemRarity::Rare,
|
||||
RuntimeItemRewardItemRarity::Epic => InventoryItemRarity::Epic,
|
||||
RuntimeItemRewardItemRarity::Legendary => InventoryItemRarity::Legendary,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_reward_item_equipment_slot(slot: RuntimeItemEquipmentSlot) -> InventoryEquipmentSlot {
|
||||
match slot {
|
||||
RuntimeItemEquipmentSlot::Weapon => InventoryEquipmentSlot::Weapon,
|
||||
RuntimeItemEquipmentSlot::Armor => InventoryEquipmentSlot::Armor,
|
||||
RuntimeItemEquipmentSlot::Relic => InventoryEquipmentSlot::Relic,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,29 @@
|
||||
//! 运行时物品写入命令过渡落位。
|
||||
//! 运行时物品写入命令。
|
||||
//!
|
||||
//! 用于表达宝箱检查、开启、离开和奖励记录等输入。
|
||||
|
||||
use crate::domain::{RuntimeItemRewardItemSnapshot, TreasureInteractionAction};
|
||||
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 TreasureResolveInput {
|
||||
pub treasure_record_id: String,
|
||||
pub runtime_session_id: String,
|
||||
pub story_session_id: String,
|
||||
pub actor_user_id: String,
|
||||
pub encounter_id: String,
|
||||
pub encounter_name: String,
|
||||
pub scene_id: Option<String>,
|
||||
pub scene_name: Option<String>,
|
||||
pub action: TreasureInteractionAction,
|
||||
pub reward_items: Vec<RuntimeItemRewardItemSnapshot>,
|
||||
pub reward_hp: u32,
|
||||
pub reward_mana: u32,
|
||||
pub reward_currency: u32,
|
||||
pub story_hint: Option<String>,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,71 @@
|
||||
//! 运行时物品领域模型过渡落位。
|
||||
//! 运行时物品领域模型。
|
||||
//!
|
||||
//! 后续迁移宝箱、奇遇和奖励物品规则时,只保留奖励生成与记录规则;
|
||||
//! 背包落库由外层事务 adapter 编排。
|
||||
//! 本文件承载宝箱、奇遇和奖励物品的稳定值对象;背包落库由外层事务 adapter 编排。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[cfg(feature = "spacetime-types")]
|
||||
use spacetimedb::SpacetimeType;
|
||||
|
||||
pub const TREASURE_RECORD_ID_PREFIX: &str = "treasure_";
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum TreasureInteractionAction {
|
||||
Inspect,
|
||||
Leave,
|
||||
Secure,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RuntimeItemRewardItemSnapshot {
|
||||
pub item_id: String,
|
||||
pub category: String,
|
||||
pub item_name: String,
|
||||
pub description: Option<String>,
|
||||
pub quantity: u32,
|
||||
pub rarity: RuntimeItemRewardItemRarity,
|
||||
pub tags: Vec<String>,
|
||||
pub stackable: bool,
|
||||
pub stack_key: String,
|
||||
pub equipment_slot_id: Option<RuntimeItemEquipmentSlot>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum RuntimeItemRewardItemRarity {
|
||||
Common,
|
||||
Uncommon,
|
||||
Rare,
|
||||
Epic,
|
||||
Legendary,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum RuntimeItemEquipmentSlot {
|
||||
Weapon,
|
||||
Armor,
|
||||
Relic,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TreasureRecordSnapshot {
|
||||
pub treasure_record_id: String,
|
||||
pub runtime_session_id: String,
|
||||
pub story_session_id: String,
|
||||
pub actor_user_id: String,
|
||||
pub encounter_id: String,
|
||||
pub encounter_name: String,
|
||||
pub scene_id: Option<String>,
|
||||
pub scene_name: Option<String>,
|
||||
pub action: TreasureInteractionAction,
|
||||
pub reward_items: Vec<RuntimeItemRewardItemSnapshot>,
|
||||
pub reward_hp: u32,
|
||||
pub reward_mana: u32,
|
||||
pub reward_currency: u32,
|
||||
pub story_hint: Option<String>,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
@@ -1,3 +1,62 @@
|
||||
//! 运行时物品领域错误过渡落位。
|
||||
//! 运行时物品领域错误。
|
||||
//!
|
||||
//! 错误只表达物品/奇遇规则失败,例如 encounter 缺失或奖励字段非法。
|
||||
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum TreasureFieldError {
|
||||
MissingTreasureRecordId,
|
||||
MissingRuntimeSessionId,
|
||||
MissingStorySessionId,
|
||||
MissingActorUserId,
|
||||
MissingEncounterId,
|
||||
MissingEncounterName,
|
||||
MissingRewardItemId,
|
||||
MissingRewardItemCategory,
|
||||
MissingRewardItemName,
|
||||
InvalidRewardItemQuantity,
|
||||
MissingRewardItemStackKey,
|
||||
RewardEquipmentItemCannotStack,
|
||||
RewardNonStackableItemMustStaySingleQuantity,
|
||||
}
|
||||
|
||||
impl fmt::Display for TreasureFieldError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::MissingTreasureRecordId => {
|
||||
f.write_str("treasure_record.treasure_record_id 不能为空")
|
||||
}
|
||||
Self::MissingRuntimeSessionId => {
|
||||
f.write_str("treasure_record.runtime_session_id 不能为空")
|
||||
}
|
||||
Self::MissingStorySessionId => f.write_str("treasure_record.story_session_id 不能为空"),
|
||||
Self::MissingActorUserId => f.write_str("treasure_record.actor_user_id 不能为空"),
|
||||
Self::MissingEncounterId => f.write_str("treasure_record.encounter_id 不能为空"),
|
||||
Self::MissingEncounterName => f.write_str("treasure_record.encounter_name 不能为空"),
|
||||
Self::MissingRewardItemId => {
|
||||
f.write_str("treasure_record.reward_items[].item_id 不能为空")
|
||||
}
|
||||
Self::MissingRewardItemCategory => {
|
||||
f.write_str("treasure_record.reward_items[].category 不能为空")
|
||||
}
|
||||
Self::MissingRewardItemName => {
|
||||
f.write_str("treasure_record.reward_items[].item_name 不能为空")
|
||||
}
|
||||
Self::InvalidRewardItemQuantity => {
|
||||
f.write_str("treasure_record.reward_items[].quantity 必须大于 0")
|
||||
}
|
||||
Self::MissingRewardItemStackKey => {
|
||||
f.write_str("treasure_record.reward_items[].stack_key 不能为空")
|
||||
}
|
||||
Self::RewardEquipmentItemCannotStack => {
|
||||
f.write_str("treasure_record.reward_items[] 可装备物品不能标记为 stackable")
|
||||
}
|
||||
Self::RewardNonStackableItemMustStaySingleQuantity => {
|
||||
f.write_str("treasure_record.reward_items[] 不可堆叠物品必须固定为单槽位单数量")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for TreasureFieldError {}
|
||||
|
||||
@@ -1,3 +1,32 @@
|
||||
//! 运行时物品领域事件过渡落位。
|
||||
//! 运行时物品领域事件。
|
||||
//!
|
||||
//! 用于表达宝箱已结算、奖励物品已生成和资源奖励待入账等事实。
|
||||
|
||||
use crate::domain::RuntimeItemRewardItemSnapshot;
|
||||
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 RuntimeItemDomainEvent {
|
||||
TreasureResolved(RuntimeItemTreasureResolvedEvent),
|
||||
TreasureRewardItemsGenerated(RuntimeItemTreasureRewardItemsGeneratedEvent),
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RuntimeItemTreasureResolvedEvent {
|
||||
pub treasure_record_id: String,
|
||||
pub runtime_session_id: String,
|
||||
pub story_session_id: String,
|
||||
pub occurred_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RuntimeItemTreasureRewardItemsGeneratedEvent {
|
||||
pub treasure_record_id: String,
|
||||
pub reward_items: Vec<RuntimeItemRewardItemSnapshot>,
|
||||
pub occurred_at_micros: i64,
|
||||
}
|
||||
|
||||
@@ -4,321 +4,16 @@ mod domain;
|
||||
mod errors;
|
||||
mod events;
|
||||
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
use module_inventory::{
|
||||
InventoryEquipmentSlot, InventoryItemRarity, InventoryItemSnapshot, InventoryItemSourceKind,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared_kernel::{
|
||||
normalize_optional_string as normalize_shared_optional_string, normalize_required_string,
|
||||
normalize_string_list as normalize_shared_string_list,
|
||||
};
|
||||
#[cfg(feature = "spacetime-types")]
|
||||
use spacetimedb::SpacetimeType;
|
||||
|
||||
pub const TREASURE_RECORD_ID_PREFIX: &str = "treasure_";
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum TreasureInteractionAction {
|
||||
Inspect,
|
||||
Leave,
|
||||
Secure,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RuntimeItemRewardItemSnapshot {
|
||||
pub item_id: String,
|
||||
pub category: String,
|
||||
pub item_name: String,
|
||||
pub description: Option<String>,
|
||||
pub quantity: u32,
|
||||
pub rarity: RuntimeItemRewardItemRarity,
|
||||
pub tags: Vec<String>,
|
||||
pub stackable: bool,
|
||||
pub stack_key: String,
|
||||
pub equipment_slot_id: Option<RuntimeItemEquipmentSlot>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum RuntimeItemRewardItemRarity {
|
||||
Common,
|
||||
Uncommon,
|
||||
Rare,
|
||||
Epic,
|
||||
Legendary,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum RuntimeItemEquipmentSlot {
|
||||
Weapon,
|
||||
Armor,
|
||||
Relic,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TreasureResolveInput {
|
||||
pub treasure_record_id: String,
|
||||
pub runtime_session_id: String,
|
||||
pub story_session_id: String,
|
||||
pub actor_user_id: String,
|
||||
pub encounter_id: String,
|
||||
pub encounter_name: String,
|
||||
pub scene_id: Option<String>,
|
||||
pub scene_name: Option<String>,
|
||||
pub action: TreasureInteractionAction,
|
||||
pub reward_items: Vec<RuntimeItemRewardItemSnapshot>,
|
||||
pub reward_hp: u32,
|
||||
pub reward_mana: u32,
|
||||
pub reward_currency: u32,
|
||||
pub story_hint: Option<String>,
|
||||
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 TreasureRecordSnapshot {
|
||||
pub treasure_record_id: String,
|
||||
pub runtime_session_id: String,
|
||||
pub story_session_id: String,
|
||||
pub actor_user_id: String,
|
||||
pub encounter_id: String,
|
||||
pub encounter_name: String,
|
||||
pub scene_id: Option<String>,
|
||||
pub scene_name: Option<String>,
|
||||
pub action: TreasureInteractionAction,
|
||||
pub reward_items: Vec<RuntimeItemRewardItemSnapshot>,
|
||||
pub reward_hp: u32,
|
||||
pub reward_mana: u32,
|
||||
pub reward_currency: u32,
|
||||
pub story_hint: Option<String>,
|
||||
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 TreasureRecordProcedureResult {
|
||||
pub ok: bool,
|
||||
pub record: Option<TreasureRecordSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum TreasureFieldError {
|
||||
MissingTreasureRecordId,
|
||||
MissingRuntimeSessionId,
|
||||
MissingStorySessionId,
|
||||
MissingActorUserId,
|
||||
MissingEncounterId,
|
||||
MissingEncounterName,
|
||||
MissingRewardItemId,
|
||||
MissingRewardItemCategory,
|
||||
MissingRewardItemName,
|
||||
InvalidRewardItemQuantity,
|
||||
MissingRewardItemStackKey,
|
||||
RewardEquipmentItemCannotStack,
|
||||
RewardNonStackableItemMustStaySingleQuantity,
|
||||
}
|
||||
|
||||
pub fn build_treasure_record_snapshot(
|
||||
input: TreasureResolveInput,
|
||||
) -> Result<TreasureRecordSnapshot, TreasureFieldError> {
|
||||
validate_treasure_input(&input)?;
|
||||
|
||||
Ok(TreasureRecordSnapshot {
|
||||
treasure_record_id: input.treasure_record_id,
|
||||
runtime_session_id: input.runtime_session_id,
|
||||
story_session_id: input.story_session_id,
|
||||
actor_user_id: input.actor_user_id,
|
||||
encounter_id: input.encounter_id,
|
||||
encounter_name: input.encounter_name,
|
||||
scene_id: normalize_optional_value(input.scene_id),
|
||||
scene_name: normalize_optional_value(input.scene_name),
|
||||
action: input.action,
|
||||
reward_items: input
|
||||
.reward_items
|
||||
.into_iter()
|
||||
.map(normalize_reward_item)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
reward_hp: input.reward_hp,
|
||||
reward_mana: input.reward_mana,
|
||||
reward_currency: input.reward_currency,
|
||||
story_hint: normalize_optional_value(input.story_hint),
|
||||
created_at_micros: input.created_at_micros,
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_inventory_item_snapshot_from_reward_item(
|
||||
treasure_record_id: &str,
|
||||
reward_item: RuntimeItemRewardItemSnapshot,
|
||||
) -> Result<InventoryItemSnapshot, TreasureFieldError> {
|
||||
let treasure_record_id = normalize_required_value(
|
||||
treasure_record_id.to_string(),
|
||||
TreasureFieldError::MissingTreasureRecordId,
|
||||
)?;
|
||||
let reward_item = normalize_reward_item(reward_item)?;
|
||||
|
||||
Ok(InventoryItemSnapshot {
|
||||
item_id: reward_item.item_id,
|
||||
category: reward_item.category,
|
||||
name: reward_item.item_name,
|
||||
description: reward_item.description,
|
||||
quantity: reward_item.quantity,
|
||||
rarity: map_reward_item_rarity(reward_item.rarity),
|
||||
tags: reward_item.tags,
|
||||
stackable: reward_item.stackable,
|
||||
stack_key: reward_item.stack_key,
|
||||
equipment_slot_id: reward_item
|
||||
.equipment_slot_id
|
||||
.map(map_reward_item_equipment_slot),
|
||||
source_kind: InventoryItemSourceKind::TreasureReward,
|
||||
source_reference_id: Some(treasure_record_id),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn normalize_reward_item_snapshot(
|
||||
reward_item: RuntimeItemRewardItemSnapshot,
|
||||
) -> Result<RuntimeItemRewardItemSnapshot, TreasureFieldError> {
|
||||
normalize_reward_item(reward_item)
|
||||
}
|
||||
|
||||
fn validate_treasure_input(input: &TreasureResolveInput) -> Result<(), TreasureFieldError> {
|
||||
if input.treasure_record_id.trim().is_empty() {
|
||||
return Err(TreasureFieldError::MissingTreasureRecordId);
|
||||
}
|
||||
if input.runtime_session_id.trim().is_empty() {
|
||||
return Err(TreasureFieldError::MissingRuntimeSessionId);
|
||||
}
|
||||
if input.story_session_id.trim().is_empty() {
|
||||
return Err(TreasureFieldError::MissingStorySessionId);
|
||||
}
|
||||
if input.actor_user_id.trim().is_empty() {
|
||||
return Err(TreasureFieldError::MissingActorUserId);
|
||||
}
|
||||
if input.encounter_id.trim().is_empty() {
|
||||
return Err(TreasureFieldError::MissingEncounterId);
|
||||
}
|
||||
if input.encounter_name.trim().is_empty() {
|
||||
return Err(TreasureFieldError::MissingEncounterName);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_optional_value(value: Option<String>) -> Option<String> {
|
||||
normalize_shared_optional_string(value)
|
||||
}
|
||||
|
||||
fn normalize_reward_item(
|
||||
mut item: RuntimeItemRewardItemSnapshot,
|
||||
) -> Result<RuntimeItemRewardItemSnapshot, TreasureFieldError> {
|
||||
item.item_id = normalize_required_value(item.item_id, TreasureFieldError::MissingRewardItemId)?;
|
||||
item.category =
|
||||
normalize_required_value(item.category, TreasureFieldError::MissingRewardItemCategory)?;
|
||||
item.item_name =
|
||||
normalize_required_value(item.item_name, TreasureFieldError::MissingRewardItemName)?;
|
||||
item.description = normalize_optional_value(item.description);
|
||||
if item.quantity == 0 {
|
||||
return Err(TreasureFieldError::InvalidRewardItemQuantity);
|
||||
}
|
||||
if !item.stackable && item.quantity != 1 {
|
||||
return Err(TreasureFieldError::RewardNonStackableItemMustStaySingleQuantity);
|
||||
}
|
||||
if item.equipment_slot_id.is_some() && item.stackable {
|
||||
return Err(TreasureFieldError::RewardEquipmentItemCannotStack);
|
||||
}
|
||||
item.tags = normalize_string_list(item.tags);
|
||||
item.stack_key = if item.stackable {
|
||||
normalize_required_value(
|
||||
item.stack_key,
|
||||
TreasureFieldError::MissingRewardItemStackKey,
|
||||
)?
|
||||
} else {
|
||||
normalize_optional_value(Some(item.stack_key)).unwrap_or_else(|| item.item_id.clone())
|
||||
};
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
fn normalize_required_value(
|
||||
value: String,
|
||||
error: TreasureFieldError,
|
||||
) -> Result<String, TreasureFieldError> {
|
||||
normalize_required_string(value).ok_or(error)
|
||||
}
|
||||
|
||||
fn normalize_string_list(values: Vec<String>) -> Vec<String> {
|
||||
normalize_shared_string_list(values)
|
||||
}
|
||||
|
||||
fn map_reward_item_rarity(rarity: RuntimeItemRewardItemRarity) -> InventoryItemRarity {
|
||||
match rarity {
|
||||
RuntimeItemRewardItemRarity::Common => InventoryItemRarity::Common,
|
||||
RuntimeItemRewardItemRarity::Uncommon => InventoryItemRarity::Uncommon,
|
||||
RuntimeItemRewardItemRarity::Rare => InventoryItemRarity::Rare,
|
||||
RuntimeItemRewardItemRarity::Epic => InventoryItemRarity::Epic,
|
||||
RuntimeItemRewardItemRarity::Legendary => InventoryItemRarity::Legendary,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_reward_item_equipment_slot(slot: RuntimeItemEquipmentSlot) -> InventoryEquipmentSlot {
|
||||
match slot {
|
||||
RuntimeItemEquipmentSlot::Weapon => InventoryEquipmentSlot::Weapon,
|
||||
RuntimeItemEquipmentSlot::Armor => InventoryEquipmentSlot::Armor,
|
||||
RuntimeItemEquipmentSlot::Relic => InventoryEquipmentSlot::Relic,
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for TreasureFieldError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::MissingTreasureRecordId => {
|
||||
f.write_str("treasure_record.treasure_record_id 不能为空")
|
||||
}
|
||||
Self::MissingRuntimeSessionId => {
|
||||
f.write_str("treasure_record.runtime_session_id 不能为空")
|
||||
}
|
||||
Self::MissingStorySessionId => f.write_str("treasure_record.story_session_id 不能为空"),
|
||||
Self::MissingActorUserId => f.write_str("treasure_record.actor_user_id 不能为空"),
|
||||
Self::MissingEncounterId => f.write_str("treasure_record.encounter_id 不能为空"),
|
||||
Self::MissingEncounterName => f.write_str("treasure_record.encounter_name 不能为空"),
|
||||
Self::MissingRewardItemId => {
|
||||
f.write_str("treasure_record.reward_items[].item_id 不能为空")
|
||||
}
|
||||
Self::MissingRewardItemCategory => {
|
||||
f.write_str("treasure_record.reward_items[].category 不能为空")
|
||||
}
|
||||
Self::MissingRewardItemName => {
|
||||
f.write_str("treasure_record.reward_items[].item_name 不能为空")
|
||||
}
|
||||
Self::InvalidRewardItemQuantity => {
|
||||
f.write_str("treasure_record.reward_items[].quantity 必须大于 0")
|
||||
}
|
||||
Self::MissingRewardItemStackKey => {
|
||||
f.write_str("treasure_record.reward_items[].stack_key 不能为空")
|
||||
}
|
||||
Self::RewardEquipmentItemCannotStack => {
|
||||
f.write_str("treasure_record.reward_items[] 可装备物品不能标记为 stackable")
|
||||
}
|
||||
Self::RewardNonStackableItemMustStaySingleQuantity => {
|
||||
f.write_str("treasure_record.reward_items[] 不可堆叠物品必须固定为单槽位单数量")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for TreasureFieldError {}
|
||||
pub use application::*;
|
||||
pub use commands::*;
|
||||
pub use domain::*;
|
||||
pub use errors::*;
|
||||
pub use events::*;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use module_inventory::{InventoryEquipmentSlot, InventoryItemRarity, InventoryItemSourceKind};
|
||||
|
||||
#[test]
|
||||
fn build_treasure_record_snapshot_accepts_minimal_contract() {
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
|
||||
`module-runtime-story` 承接 RPG runtime story 的纯领域规则、应用用例、事件和错误模型,不依赖 HTTP / `AppState` / SpacetimeDB。
|
||||
|
||||
当前已经迁入的历史快照态纯逻辑会继续收口为 session scoped 新主链:
|
||||
当前已经迁入的历史快照态纯逻辑会继续收口为 session scoped 新主链;顶层 DDD 物理拆分已经完成:
|
||||
|
||||
1. action 结算结果结构。
|
||||
2. action response 组装参数结构。
|
||||
3. NPC 委托上下文结构。
|
||||
4. functionId / 队伍上限常量。
|
||||
5. 少量只依赖 `serde_json::Value` 与 `shared-contracts` 的纯 helper。
|
||||
1. `src/domain.rs` 承载 action 结算结果结构、NPC 委托上下文、functionId / 队伍上限常量。
|
||||
2. `src/commands.rs` 承载 action 文本解析 helper。
|
||||
3. `src/application.rs` 承载 action response 组装参数、status patch 和 world type helper。
|
||||
4. `src/events.rs` 承载 runtime story 领域事件。
|
||||
5. `src/errors.rs` 承载 runtime story 纯规则错误。
|
||||
6. `src/lib.rs` 只保留模块声明、公开导出和子模块 re-export。
|
||||
|
||||
后续 WP-RS 继续按 battle / forge / NPC / quest / presentation 的顺序,把旧 `/api/runtime/story/*` 写侧能力迁到 session scoped 新接口,并删除运行代码中的旧入口命名。
|
||||
|
||||
配套记录见 [../../../docs/technical/SERVER_RS_DDD_WP_RS_RUNTIME_STORY_DOMAIN_SPLIT_2026-04-30.md](../../../docs/technical/SERVER_RS_DDD_WP_RS_RUNTIME_STORY_DOMAIN_SPLIT_2026-04-30.md)。
|
||||
|
||||
@@ -1,3 +1,58 @@
|
||||
//! runtime story 应用编排落位。
|
||||
//!
|
||||
//! 这里组合纯领域规则并返回后端投影;真实保存、SSE 和模型调用由外层完成。
|
||||
|
||||
use serde_json::Value;
|
||||
use shared_contracts::runtime_story::{
|
||||
RuntimeBattlePresentation, RuntimeStoryOptionView, RuntimeStoryPatch,
|
||||
RuntimeStorySnapshotPayload,
|
||||
};
|
||||
|
||||
use crate::{StoryResolution, read_bool_field, read_optional_string_field};
|
||||
|
||||
pub struct RuntimeStoryActionResponseParts {
|
||||
pub requested_session_id: String,
|
||||
pub server_version: u32,
|
||||
pub snapshot: RuntimeStorySnapshotPayload,
|
||||
pub action_text: String,
|
||||
pub result_text: String,
|
||||
pub story_text: String,
|
||||
pub options: Vec<RuntimeStoryOptionView>,
|
||||
pub patches: Vec<RuntimeStoryPatch>,
|
||||
pub toast: Option<String>,
|
||||
pub battle: Option<RuntimeBattlePresentation>,
|
||||
}
|
||||
|
||||
pub fn simple_story_resolution(
|
||||
game_state: &Value,
|
||||
action_text: String,
|
||||
result_text: &str,
|
||||
) -> StoryResolution {
|
||||
StoryResolution {
|
||||
action_text,
|
||||
result_text: result_text.to_string(),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: vec![build_status_patch(game_state)],
|
||||
battle: None,
|
||||
toast: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_status_patch(game_state: &Value) -> RuntimeStoryPatch {
|
||||
RuntimeStoryPatch::StatusChanged {
|
||||
in_battle: read_bool_field(game_state, "inBattle").unwrap_or(false),
|
||||
npc_interaction_active: read_bool_field(game_state, "npcInteractionActive")
|
||||
.unwrap_or(false),
|
||||
current_npc_battle_mode: read_optional_string_field(game_state, "currentNpcBattleMode"),
|
||||
current_npc_battle_outcome: read_optional_string_field(
|
||||
game_state,
|
||||
"currentNpcBattleOutcome",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current_world_type(game_state: &Value) -> Option<String> {
|
||||
read_optional_string_field(game_state, "worldType")
|
||||
}
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
//! runtime story 写入命令过渡落位。
|
||||
//! runtime story 写入命令。
|
||||
//!
|
||||
//! 用于表达剧情动作解析、战斗动作、锻造动作和 NPC 互动等输入。
|
||||
|
||||
use shared_contracts::runtime_story::RuntimeStoryActionRequest;
|
||||
|
||||
use crate::read_optional_string_field;
|
||||
|
||||
pub fn resolve_action_text(default_text: &str, request: &RuntimeStoryActionRequest) -> String {
|
||||
request
|
||||
.action
|
||||
.payload
|
||||
.as_ref()
|
||||
.and_then(|payload| read_optional_string_field(payload, "optionText"))
|
||||
.unwrap_or_else(|| default_text.to_string())
|
||||
}
|
||||
|
||||
@@ -1,4 +1,44 @@
|
||||
//! runtime story 领域模型过渡落位。
|
||||
//! runtime story 领域模型。
|
||||
//!
|
||||
//! 当前 crate 用于运行时剧情主链的纯规则收口。后续迁移时仍只能保留 JSON 规则、
|
||||
//! 选项生成和视图模型转换,不引入 Axum、LLM 或 SpacetimeDB。
|
||||
|
||||
use serde_json::Value;
|
||||
use shared_contracts::runtime_story::{
|
||||
RuntimeBattlePresentation, RuntimeStoryOptionView, RuntimeStoryPatch,
|
||||
};
|
||||
|
||||
pub const CONTINUE_ADVENTURE_FUNCTION_ID: &str = "story_continue_adventure";
|
||||
pub const MAX_TASK5_COMPANIONS: usize = 2;
|
||||
|
||||
pub struct StoryResolution {
|
||||
pub action_text: String,
|
||||
pub result_text: String,
|
||||
pub story_text: Option<String>,
|
||||
pub presentation_options: Option<Vec<RuntimeStoryOptionView>>,
|
||||
pub saved_current_story: Option<Value>,
|
||||
pub patches: Vec<RuntimeStoryPatch>,
|
||||
pub battle: Option<RuntimeBattlePresentation>,
|
||||
pub toast: Option<String>,
|
||||
}
|
||||
|
||||
pub struct GeneratedStoryPayload {
|
||||
pub story_text: String,
|
||||
pub history_result_text: String,
|
||||
pub presentation_options: Vec<RuntimeStoryOptionView>,
|
||||
pub saved_current_story: Value,
|
||||
}
|
||||
|
||||
pub struct CurrentEncounterNpcQuestContext {
|
||||
pub npc_id: String,
|
||||
pub npc_name: String,
|
||||
}
|
||||
|
||||
pub struct PendingQuestOfferContext {
|
||||
pub dialogue: Vec<Value>,
|
||||
pub turn_count: i32,
|
||||
pub custom_input_placeholder: String,
|
||||
pub quest: Value,
|
||||
pub quest_id: String,
|
||||
pub intro_text: Option<String>,
|
||||
}
|
||||
|
||||
@@ -1,3 +1,26 @@
|
||||
//! runtime story 领域错误过渡落位。
|
||||
//! runtime story 领域错误。
|
||||
//!
|
||||
//! 错误只表达运行时剧情规则失败,不能直接绑定 HTTP 或数据库错误模型。
|
||||
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum RuntimeStoryRuleError {
|
||||
MissingRuntimeSessionId,
|
||||
MissingStoryAction,
|
||||
UnsupportedStoryAction,
|
||||
InvalidRuntimeSnapshot,
|
||||
}
|
||||
|
||||
impl fmt::Display for RuntimeStoryRuleError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::MissingRuntimeSessionId => f.write_str("runtime_story.session_id 不能为空"),
|
||||
Self::MissingStoryAction => f.write_str("runtime_story.action 不能为空"),
|
||||
Self::UnsupportedStoryAction => f.write_str("runtime_story.action 当前不受支持"),
|
||||
Self::InvalidRuntimeSnapshot => f.write_str("runtime_story.snapshot 非法"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for RuntimeStoryRuleError {}
|
||||
|
||||
@@ -1,3 +1,22 @@
|
||||
//! runtime story 领域事件过渡落位。
|
||||
//! runtime story 领域事件。
|
||||
//!
|
||||
//! 用于表达剧情快照变化、战斗表现变化和物品/成长待同步等事实。
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum RuntimeStoryDomainEvent {
|
||||
SnapshotChanged {
|
||||
runtime_session_id: String,
|
||||
story_session_id: Option<String>,
|
||||
occurred_at_micros: i64,
|
||||
},
|
||||
BattlePresentationChanged {
|
||||
runtime_session_id: String,
|
||||
battle_state_id: Option<String>,
|
||||
occurred_at_micros: i64,
|
||||
},
|
||||
CrossDomainSyncPending {
|
||||
runtime_session_id: String,
|
||||
reason: String,
|
||||
occurred_at_micros: i64,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -4,12 +4,6 @@ mod domain;
|
||||
mod errors;
|
||||
mod events;
|
||||
|
||||
use serde_json::Value;
|
||||
use shared_contracts::runtime_story::{
|
||||
RuntimeBattlePresentation, RuntimeStoryActionRequest, RuntimeStoryOptionView,
|
||||
RuntimeStoryPatch, RuntimeStorySnapshotPayload,
|
||||
};
|
||||
|
||||
pub mod battle;
|
||||
#[cfg(test)]
|
||||
mod battle_tests;
|
||||
@@ -25,10 +19,12 @@ pub mod prompt_context;
|
||||
pub mod story_engine;
|
||||
pub mod view_model;
|
||||
|
||||
pub use application::*;
|
||||
pub use battle::{
|
||||
build_battle_runtime_story_options, inventory_item_has_usable_effect, resolve_battle_action,
|
||||
restore_player_resource,
|
||||
};
|
||||
pub use commands::*;
|
||||
pub use core::{
|
||||
MAX_PLAYER_LEVEL, add_player_currency, add_player_inventory_items, append_active_build_buffs,
|
||||
append_story_history, clear_encounter_only, clear_encounter_state, cumulative_xp_required,
|
||||
@@ -41,6 +37,9 @@ pub use core::{
|
||||
write_first_hostile_npc_i32_field, write_i32_field, write_null_field, write_string_field,
|
||||
write_u32_field, xp_to_next_level_for,
|
||||
};
|
||||
pub use domain::*;
|
||||
pub use errors::*;
|
||||
pub use events::*;
|
||||
pub use forge::{build_runtime_equipment_item, build_runtime_material_item, format_currency_text};
|
||||
pub use forge_actions::{
|
||||
resolve_forge_craft_action, resolve_forge_dismantle_action, resolve_forge_reforge_action,
|
||||
@@ -77,94 +76,3 @@ pub use view_model::{
|
||||
build_runtime_story_companions, build_runtime_story_encounter, build_runtime_story_view_model,
|
||||
resolve_current_encounter_npc_state,
|
||||
};
|
||||
|
||||
pub const CONTINUE_ADVENTURE_FUNCTION_ID: &str = "story_continue_adventure";
|
||||
pub const MAX_TASK5_COMPANIONS: usize = 2;
|
||||
|
||||
pub struct StoryResolution {
|
||||
pub action_text: String,
|
||||
pub result_text: String,
|
||||
pub story_text: Option<String>,
|
||||
pub presentation_options: Option<Vec<RuntimeStoryOptionView>>,
|
||||
pub saved_current_story: Option<Value>,
|
||||
pub patches: Vec<RuntimeStoryPatch>,
|
||||
pub battle: Option<RuntimeBattlePresentation>,
|
||||
pub toast: Option<String>,
|
||||
}
|
||||
|
||||
pub struct GeneratedStoryPayload {
|
||||
pub story_text: String,
|
||||
pub history_result_text: String,
|
||||
pub presentation_options: Vec<RuntimeStoryOptionView>,
|
||||
pub saved_current_story: Value,
|
||||
}
|
||||
|
||||
pub struct CurrentEncounterNpcQuestContext {
|
||||
pub npc_id: String,
|
||||
pub npc_name: String,
|
||||
}
|
||||
|
||||
pub struct PendingQuestOfferContext {
|
||||
pub dialogue: Vec<Value>,
|
||||
pub turn_count: i32,
|
||||
pub custom_input_placeholder: String,
|
||||
pub quest: Value,
|
||||
pub quest_id: String,
|
||||
pub intro_text: Option<String>,
|
||||
}
|
||||
|
||||
pub struct RuntimeStoryActionResponseParts {
|
||||
pub requested_session_id: String,
|
||||
pub server_version: u32,
|
||||
pub snapshot: RuntimeStorySnapshotPayload,
|
||||
pub action_text: String,
|
||||
pub result_text: String,
|
||||
pub story_text: String,
|
||||
pub options: Vec<RuntimeStoryOptionView>,
|
||||
pub patches: Vec<RuntimeStoryPatch>,
|
||||
pub toast: Option<String>,
|
||||
pub battle: Option<RuntimeBattlePresentation>,
|
||||
}
|
||||
|
||||
pub fn simple_story_resolution(
|
||||
game_state: &Value,
|
||||
action_text: String,
|
||||
result_text: &str,
|
||||
) -> StoryResolution {
|
||||
StoryResolution {
|
||||
action_text,
|
||||
result_text: result_text.to_string(),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: vec![build_status_patch(game_state)],
|
||||
battle: None,
|
||||
toast: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_action_text(default_text: &str, request: &RuntimeStoryActionRequest) -> String {
|
||||
request
|
||||
.action
|
||||
.payload
|
||||
.as_ref()
|
||||
.and_then(|payload| read_optional_string_field(payload, "optionText"))
|
||||
.unwrap_or_else(|| default_text.to_string())
|
||||
}
|
||||
|
||||
pub fn build_status_patch(game_state: &Value) -> RuntimeStoryPatch {
|
||||
RuntimeStoryPatch::StatusChanged {
|
||||
in_battle: read_bool_field(game_state, "inBattle").unwrap_or(false),
|
||||
npc_interaction_active: read_bool_field(game_state, "npcInteractionActive")
|
||||
.unwrap_or(false),
|
||||
current_npc_battle_mode: read_optional_string_field(game_state, "currentNpcBattleMode"),
|
||||
current_npc_battle_outcome: read_optional_string_field(
|
||||
game_state,
|
||||
"currentNpcBattleOutcome",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current_world_type(game_state: &Value) -> Option<String> {
|
||||
read_optional_string_field(game_state, "worldType")
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 运行时应用编排过渡落位。
|
||||
//! 运行时应用编排。
|
||||
//!
|
||||
//! 这里只返回运行时快照、个人页投影和领域事件,不直接访问外部 adapter。
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 运行时写入命令过渡落位。
|
||||
//! 运行时写入命令。
|
||||
//!
|
||||
//! 用于表达保存快照、更新设置、写入浏览历史、调整钱包和保存存档等输入。
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 运行时领域错误过渡落位。
|
||||
//! 运行时领域错误。
|
||||
//!
|
||||
//! 错误保持运行时业务语义,例如快照版本非法、兑换码不可用或钱包余额不足。
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
//! 运行时领域事件过渡落位。
|
||||
//! 运行时领域事件。
|
||||
//!
|
||||
//! 用于表达快照已保存、设置已更新、钱包已记账和存档已变化等事实。
|
||||
|
||||
@@ -1826,13 +1826,19 @@ fn execute_custom_world_agent_action_tx(
|
||||
}
|
||||
"publish_world" => execute_publish_world_action(ctx, &session, &input, &payload),
|
||||
"revert_checkpoint" => execute_revert_checkpoint_action(ctx, &session, &input, &payload),
|
||||
"generate_characters"
|
||||
| "generate_landmarks"
|
||||
| "generate_role_assets"
|
||||
| "sync_role_assets"
|
||||
| "generate_scene_assets"
|
||||
| "sync_scene_assets"
|
||||
| "expand_long_tail" => execute_placeholder_custom_world_action(ctx, &session, &input),
|
||||
"generate_characters" => {
|
||||
execute_generate_characters_action(ctx, &session, &input, &payload)
|
||||
}
|
||||
"generate_landmarks" => execute_generate_landmarks_action(ctx, &session, &input, &payload),
|
||||
"generate_role_assets" => {
|
||||
execute_generate_role_assets_action(ctx, &session, &input, &payload)
|
||||
}
|
||||
"sync_role_assets" => execute_sync_role_assets_action(ctx, &session, &input, &payload),
|
||||
"generate_scene_assets" => {
|
||||
execute_generate_scene_assets_action(ctx, &session, &input, &payload)
|
||||
}
|
||||
"sync_scene_assets" => execute_sync_scene_assets_action(ctx, &session, &input, &payload),
|
||||
"expand_long_tail" => execute_expand_long_tail_action(ctx, &session, &input, &payload),
|
||||
other => Err(format!("custom world action `{other}` 当前尚未支持")),
|
||||
}
|
||||
}
|
||||
@@ -2378,35 +2384,763 @@ fn execute_revert_checkpoint_action(
|
||||
Ok(build_custom_world_agent_operation_snapshot(&operation))
|
||||
}
|
||||
|
||||
fn execute_placeholder_custom_world_action(
|
||||
fn execute_generate_characters_action(
|
||||
ctx: &ReducerContext,
|
||||
session: &CustomWorldAgentSession,
|
||||
input: &CustomWorldAgentActionExecuteInput,
|
||||
payload: &JsonMap<String, JsonValue>,
|
||||
) -> Result<CustomWorldAgentOperationSnapshot, String> {
|
||||
let operation_type = map_action_name_to_operation_type(input.action.as_str())
|
||||
.ok_or_else(|| format!("action {} 无法映射到 operation type", input.action))?;
|
||||
ensure_refining_stage(session.stage, "generate_characters")?;
|
||||
let mut draft_profile = current_custom_world_draft_profile(session);
|
||||
let inserted = upsert_draft_profile_array_from_payload(
|
||||
&mut draft_profile,
|
||||
payload,
|
||||
"characters",
|
||||
"playableNpcs",
|
||||
"character",
|
||||
RpgAgentDraftCardKind::Character,
|
||||
ctx,
|
||||
&session.session_id,
|
||||
input.submitted_at_micros,
|
||||
)?;
|
||||
let inserted_story = upsert_draft_profile_array_from_payload(
|
||||
&mut draft_profile,
|
||||
payload,
|
||||
"storyNpcs",
|
||||
"storyNpcs",
|
||||
"story-npc",
|
||||
RpgAgentDraftCardKind::Character,
|
||||
ctx,
|
||||
&session.session_id,
|
||||
input.submitted_at_micros,
|
||||
)?;
|
||||
let total_inserted = inserted.saturating_add(inserted_story);
|
||||
persist_custom_world_draft_profile_update(
|
||||
ctx,
|
||||
session,
|
||||
draft_profile,
|
||||
input.submitted_at_micros,
|
||||
RpgAgentStage::ObjectRefining,
|
||||
format!("已同步 {total_inserted} 个角色草稿。"),
|
||||
"generate-characters",
|
||||
"生成角色草稿",
|
||||
)?;
|
||||
append_custom_world_action_result_message(
|
||||
ctx,
|
||||
&session.session_id,
|
||||
&input.operation_id,
|
||||
&format!(
|
||||
"动作 {} 已接入最小兼容占位,后续会继续补真实编排。",
|
||||
input.action
|
||||
),
|
||||
&format!("已生成并同步 {total_inserted} 个角色草稿。"),
|
||||
input.submitted_at_micros,
|
||||
);
|
||||
let operation = build_and_insert_custom_world_operation(
|
||||
|
||||
let operation = complete_custom_world_operation(
|
||||
ctx,
|
||||
&input.operation_id,
|
||||
&session.session_id,
|
||||
operation_type,
|
||||
"动作已完成",
|
||||
&format!("{} 当前已走最小兼容闭环。", input.action),
|
||||
RpgAgentOperationType::GenerateCharacters,
|
||||
"角色草稿已同步",
|
||||
&format!("角色草稿已写入 draft_profile 与卡片表,新增 {total_inserted} 条。"),
|
||||
input.submitted_at_micros,
|
||||
)?;
|
||||
Ok(build_custom_world_agent_operation_snapshot(&operation))
|
||||
}
|
||||
|
||||
fn execute_generate_landmarks_action(
|
||||
ctx: &ReducerContext,
|
||||
session: &CustomWorldAgentSession,
|
||||
input: &CustomWorldAgentActionExecuteInput,
|
||||
payload: &JsonMap<String, JsonValue>,
|
||||
) -> Result<CustomWorldAgentOperationSnapshot, String> {
|
||||
ensure_refining_stage(session.stage, "generate_landmarks")?;
|
||||
let mut draft_profile = current_custom_world_draft_profile(session);
|
||||
let inserted = upsert_draft_profile_array_from_payload(
|
||||
&mut draft_profile,
|
||||
payload,
|
||||
"landmarks",
|
||||
"landmarks",
|
||||
"landmark",
|
||||
RpgAgentDraftCardKind::Landmark,
|
||||
ctx,
|
||||
&session.session_id,
|
||||
input.submitted_at_micros,
|
||||
)?;
|
||||
persist_custom_world_draft_profile_update(
|
||||
ctx,
|
||||
session,
|
||||
draft_profile,
|
||||
input.submitted_at_micros,
|
||||
RpgAgentStage::ObjectRefining,
|
||||
format!("已同步 {inserted} 个地标草稿。"),
|
||||
"generate-landmarks",
|
||||
"生成地标草稿",
|
||||
)?;
|
||||
append_custom_world_action_result_message(
|
||||
ctx,
|
||||
&session.session_id,
|
||||
&input.operation_id,
|
||||
&format!("已生成并同步 {inserted} 个地标草稿。"),
|
||||
input.submitted_at_micros,
|
||||
);
|
||||
|
||||
let operation = complete_custom_world_operation(
|
||||
ctx,
|
||||
&input.operation_id,
|
||||
&session.session_id,
|
||||
RpgAgentOperationType::GenerateLandmarks,
|
||||
"地标草稿已同步",
|
||||
&format!("地标草稿已写入 draft_profile 与卡片表,新增 {inserted} 条。"),
|
||||
input.submitted_at_micros,
|
||||
)?;
|
||||
Ok(build_custom_world_agent_operation_snapshot(&operation))
|
||||
}
|
||||
|
||||
fn execute_generate_role_assets_action(
|
||||
ctx: &ReducerContext,
|
||||
session: &CustomWorldAgentSession,
|
||||
input: &CustomWorldAgentActionExecuteInput,
|
||||
payload: &JsonMap<String, JsonValue>,
|
||||
) -> Result<CustomWorldAgentOperationSnapshot, String> {
|
||||
ensure_refining_stage(session.stage, "generate_role_assets")?;
|
||||
let next_coverage = build_role_asset_coverage_json(session, payload, true)?;
|
||||
let next_session = rebuild_custom_world_agent_session_row(
|
||||
session,
|
||||
CustomWorldAgentSessionPatch {
|
||||
stage: Some(RpgAgentStage::VisualRefining),
|
||||
asset_coverage_json: Some(next_coverage),
|
||||
last_assistant_reply: Some(Some(
|
||||
"角色视觉资产槽位已生成并进入视觉打磨阶段。".to_string(),
|
||||
)),
|
||||
updated_at_micros: Some(input.submitted_at_micros),
|
||||
..CustomWorldAgentSessionPatch::default()
|
||||
},
|
||||
)?;
|
||||
replace_custom_world_agent_session(ctx, session, next_session);
|
||||
update_role_asset_cards(
|
||||
ctx,
|
||||
&session.session_id,
|
||||
CustomWorldRoleAssetStatus::VisualReady,
|
||||
"角色主图已就绪",
|
||||
input.submitted_at_micros,
|
||||
);
|
||||
append_custom_world_action_result_message(
|
||||
ctx,
|
||||
&session.session_id,
|
||||
&input.operation_id,
|
||||
"角色视觉资产槽位已生成,角色卡片状态已刷新。",
|
||||
input.submitted_at_micros,
|
||||
);
|
||||
|
||||
let operation = complete_custom_world_operation(
|
||||
ctx,
|
||||
&input.operation_id,
|
||||
&session.session_id,
|
||||
RpgAgentOperationType::GenerateRoleAssets,
|
||||
"角色资产已生成",
|
||||
"asset_coverage.roleAssets 与角色卡片视觉状态已更新。",
|
||||
input.submitted_at_micros,
|
||||
)?;
|
||||
Ok(build_custom_world_agent_operation_snapshot(&operation))
|
||||
}
|
||||
|
||||
fn execute_sync_role_assets_action(
|
||||
ctx: &ReducerContext,
|
||||
session: &CustomWorldAgentSession,
|
||||
input: &CustomWorldAgentActionExecuteInput,
|
||||
payload: &JsonMap<String, JsonValue>,
|
||||
) -> Result<CustomWorldAgentOperationSnapshot, String> {
|
||||
ensure_refining_stage(session.stage, "sync_role_assets")?;
|
||||
let next_coverage = build_role_asset_coverage_json(session, payload, false)?;
|
||||
let next_session = rebuild_custom_world_agent_session_row(
|
||||
session,
|
||||
CustomWorldAgentSessionPatch {
|
||||
stage: Some(RpgAgentStage::VisualRefining),
|
||||
asset_coverage_json: Some(next_coverage),
|
||||
last_assistant_reply: Some(Some("角色资产状态已按外部资产结果同步。".to_string())),
|
||||
updated_at_micros: Some(input.submitted_at_micros),
|
||||
..CustomWorldAgentSessionPatch::default()
|
||||
},
|
||||
)?;
|
||||
replace_custom_world_agent_session(ctx, session, next_session);
|
||||
update_role_asset_cards(
|
||||
ctx,
|
||||
&session.session_id,
|
||||
CustomWorldRoleAssetStatus::Complete,
|
||||
"角色资产已同步",
|
||||
input.submitted_at_micros,
|
||||
);
|
||||
append_custom_world_action_result_message(
|
||||
ctx,
|
||||
&session.session_id,
|
||||
&input.operation_id,
|
||||
"角色资产结果已同步到会话覆盖率与角色卡片。",
|
||||
input.submitted_at_micros,
|
||||
);
|
||||
|
||||
let operation = complete_custom_world_operation(
|
||||
ctx,
|
||||
&input.operation_id,
|
||||
&session.session_id,
|
||||
RpgAgentOperationType::SyncRoleAssets,
|
||||
"角色资产已同步",
|
||||
"asset_coverage.roleAssets 与角色卡片完成状态已更新。",
|
||||
input.submitted_at_micros,
|
||||
)?;
|
||||
Ok(build_custom_world_agent_operation_snapshot(&operation))
|
||||
}
|
||||
|
||||
fn execute_generate_scene_assets_action(
|
||||
ctx: &ReducerContext,
|
||||
session: &CustomWorldAgentSession,
|
||||
input: &CustomWorldAgentActionExecuteInput,
|
||||
payload: &JsonMap<String, JsonValue>,
|
||||
) -> Result<CustomWorldAgentOperationSnapshot, String> {
|
||||
ensure_refining_stage(session.stage, "generate_scene_assets")?;
|
||||
let next_coverage = build_scene_asset_coverage_json(session, payload, true)?;
|
||||
let next_session = rebuild_custom_world_agent_session_row(
|
||||
session,
|
||||
CustomWorldAgentSessionPatch {
|
||||
stage: Some(RpgAgentStage::VisualRefining),
|
||||
asset_coverage_json: Some(next_coverage),
|
||||
last_assistant_reply: Some(Some(
|
||||
"场景视觉资产槽位已生成并进入视觉打磨阶段。".to_string(),
|
||||
)),
|
||||
updated_at_micros: Some(input.submitted_at_micros),
|
||||
..CustomWorldAgentSessionPatch::default()
|
||||
},
|
||||
)?;
|
||||
replace_custom_world_agent_session(ctx, session, next_session);
|
||||
append_custom_world_action_result_message(
|
||||
ctx,
|
||||
&session.session_id,
|
||||
&input.operation_id,
|
||||
"场景视觉资产槽位已生成,等待外层资产链写回对象结果。",
|
||||
input.submitted_at_micros,
|
||||
);
|
||||
|
||||
let operation = complete_custom_world_operation(
|
||||
ctx,
|
||||
&input.operation_id,
|
||||
&session.session_id,
|
||||
RpgAgentOperationType::GenerateSceneAssets,
|
||||
"场景资产已生成",
|
||||
"asset_coverage.sceneAssets 已根据当前草稿刷新。",
|
||||
input.submitted_at_micros,
|
||||
)?;
|
||||
Ok(build_custom_world_agent_operation_snapshot(&operation))
|
||||
}
|
||||
|
||||
fn execute_sync_scene_assets_action(
|
||||
ctx: &ReducerContext,
|
||||
session: &CustomWorldAgentSession,
|
||||
input: &CustomWorldAgentActionExecuteInput,
|
||||
payload: &JsonMap<String, JsonValue>,
|
||||
) -> Result<CustomWorldAgentOperationSnapshot, String> {
|
||||
ensure_refining_stage(session.stage, "sync_scene_assets")?;
|
||||
let next_coverage = build_scene_asset_coverage_json(session, payload, false)?;
|
||||
let next_session = rebuild_custom_world_agent_session_row(
|
||||
session,
|
||||
CustomWorldAgentSessionPatch {
|
||||
stage: Some(RpgAgentStage::VisualRefining),
|
||||
asset_coverage_json: Some(next_coverage),
|
||||
last_assistant_reply: Some(Some("场景资产状态已按外部资产结果同步。".to_string())),
|
||||
updated_at_micros: Some(input.submitted_at_micros),
|
||||
..CustomWorldAgentSessionPatch::default()
|
||||
},
|
||||
)?;
|
||||
replace_custom_world_agent_session(ctx, session, next_session);
|
||||
append_custom_world_action_result_message(
|
||||
ctx,
|
||||
&session.session_id,
|
||||
&input.operation_id,
|
||||
"场景资产结果已同步到会话覆盖率。",
|
||||
input.submitted_at_micros,
|
||||
);
|
||||
|
||||
let operation = complete_custom_world_operation(
|
||||
ctx,
|
||||
&input.operation_id,
|
||||
&session.session_id,
|
||||
RpgAgentOperationType::SyncSceneAssets,
|
||||
"场景资产已同步",
|
||||
"asset_coverage.sceneAssets 已更新为同步结果。",
|
||||
input.submitted_at_micros,
|
||||
)?;
|
||||
Ok(build_custom_world_agent_operation_snapshot(&operation))
|
||||
}
|
||||
|
||||
fn execute_expand_long_tail_action(
|
||||
ctx: &ReducerContext,
|
||||
session: &CustomWorldAgentSession,
|
||||
input: &CustomWorldAgentActionExecuteInput,
|
||||
payload: &JsonMap<String, JsonValue>,
|
||||
) -> Result<CustomWorldAgentOperationSnapshot, String> {
|
||||
ensure_long_tail_stage(session.stage, "expand_long_tail")?;
|
||||
let mut draft_profile = current_custom_world_draft_profile(session);
|
||||
merge_long_tail_payload(&mut draft_profile, payload);
|
||||
let gate = summarize_publish_gate_from_json(
|
||||
&session.session_id,
|
||||
RpgAgentStage::LongTailReview,
|
||||
Some(&draft_profile),
|
||||
&parse_json_array_or_empty(&session.quality_findings_json),
|
||||
);
|
||||
let next_session = rebuild_custom_world_agent_session_row(
|
||||
session,
|
||||
CustomWorldAgentSessionPatch {
|
||||
stage: Some(if gate.publish_ready {
|
||||
RpgAgentStage::ReadyToPublish
|
||||
} else {
|
||||
RpgAgentStage::LongTailReview
|
||||
}),
|
||||
draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object(
|
||||
draft_profile.clone(),
|
||||
))?)),
|
||||
publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value(
|
||||
&gate,
|
||||
))?)),
|
||||
result_preview_json: Some(build_result_preview_json(
|
||||
Some(&draft_profile),
|
||||
&gate,
|
||||
&parse_json_array_or_empty(&session.quality_findings_json),
|
||||
input.submitted_at_micros,
|
||||
)?),
|
||||
checkpoints_json: Some(append_checkpoint_json(
|
||||
&session.checkpoints_json,
|
||||
&build_session_checkpoint_value("expand-long-tail", "补齐长尾内容", session),
|
||||
)?),
|
||||
last_assistant_reply: Some(Some("长尾内容已合并,并重新计算发布门禁。".to_string())),
|
||||
updated_at_micros: Some(input.submitted_at_micros),
|
||||
..CustomWorldAgentSessionPatch::default()
|
||||
},
|
||||
)?;
|
||||
replace_custom_world_agent_session(ctx, session, next_session);
|
||||
append_custom_world_action_result_message(
|
||||
ctx,
|
||||
&session.session_id,
|
||||
&input.operation_id,
|
||||
"长尾内容已合并到当前世界草稿,并刷新发布门禁。",
|
||||
input.submitted_at_micros,
|
||||
);
|
||||
|
||||
let operation = complete_custom_world_operation(
|
||||
ctx,
|
||||
&input.operation_id,
|
||||
&session.session_id,
|
||||
RpgAgentOperationType::ExpandLongTail,
|
||||
"长尾内容已扩展",
|
||||
"世界草稿、预览和发布门禁已同步刷新。",
|
||||
input.submitted_at_micros,
|
||||
)?;
|
||||
Ok(build_custom_world_agent_operation_snapshot(&operation))
|
||||
}
|
||||
|
||||
fn current_custom_world_draft_profile(
|
||||
session: &CustomWorldAgentSession,
|
||||
) -> JsonMap<String, JsonValue> {
|
||||
ensure_minimal_draft_profile(
|
||||
parse_optional_session_object(session.draft_profile_json.as_deref()).unwrap_or_default(),
|
||||
&session.seed_text,
|
||||
)
|
||||
}
|
||||
|
||||
fn persist_custom_world_draft_profile_update(
|
||||
ctx: &ReducerContext,
|
||||
session: &CustomWorldAgentSession,
|
||||
draft_profile: JsonMap<String, JsonValue>,
|
||||
updated_at_micros: i64,
|
||||
stage: RpgAgentStage,
|
||||
assistant_reply: String,
|
||||
checkpoint_suffix: &str,
|
||||
checkpoint_label: &str,
|
||||
) -> Result<(), String> {
|
||||
let gate = summarize_publish_gate_from_json(
|
||||
&session.session_id,
|
||||
stage,
|
||||
Some(&draft_profile),
|
||||
&parse_json_array_or_empty(&session.quality_findings_json),
|
||||
);
|
||||
let next_session = rebuild_custom_world_agent_session_row(
|
||||
session,
|
||||
CustomWorldAgentSessionPatch {
|
||||
stage: Some(stage),
|
||||
draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object(
|
||||
draft_profile.clone(),
|
||||
))?)),
|
||||
publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value(
|
||||
&gate,
|
||||
))?)),
|
||||
result_preview_json: Some(build_result_preview_json(
|
||||
Some(&draft_profile),
|
||||
&gate,
|
||||
&parse_json_array_or_empty(&session.quality_findings_json),
|
||||
updated_at_micros,
|
||||
)?),
|
||||
checkpoints_json: Some(append_checkpoint_json(
|
||||
&session.checkpoints_json,
|
||||
&build_session_checkpoint_value(checkpoint_suffix, checkpoint_label, session),
|
||||
)?),
|
||||
last_assistant_reply: Some(Some(assistant_reply)),
|
||||
updated_at_micros: Some(updated_at_micros),
|
||||
..CustomWorldAgentSessionPatch::default()
|
||||
},
|
||||
)?;
|
||||
replace_custom_world_agent_session(ctx, session, next_session);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn upsert_draft_profile_array_from_payload(
|
||||
draft_profile: &mut JsonMap<String, JsonValue>,
|
||||
payload: &JsonMap<String, JsonValue>,
|
||||
payload_key: &str,
|
||||
profile_key: &str,
|
||||
id_prefix: &str,
|
||||
card_kind: RpgAgentDraftCardKind,
|
||||
ctx: &ReducerContext,
|
||||
session_id: &str,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<u32, String> {
|
||||
let payload_items = payload
|
||||
.get(payload_key)
|
||||
.and_then(JsonValue::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| {
|
||||
draft_profile
|
||||
.get(profile_key)
|
||||
.and_then(JsonValue::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
});
|
||||
if payload_items.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let mut merged = draft_profile
|
||||
.get(profile_key)
|
||||
.and_then(JsonValue::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let mut inserted = 0u32;
|
||||
for (index, item) in payload_items.into_iter().enumerate() {
|
||||
let Some(mut object) = item.as_object().cloned() else {
|
||||
continue;
|
||||
};
|
||||
let id = read_optional_text_field(&object, &["id"])
|
||||
.unwrap_or_else(|| format!("{id_prefix}-{}-{}", session_id, index + 1));
|
||||
object.insert("id".to_string(), JsonValue::String(id.clone()));
|
||||
let value = JsonValue::Object(object.clone());
|
||||
upsert_json_array_object_by_id(&mut merged, value);
|
||||
upsert_custom_world_entity_card(
|
||||
ctx,
|
||||
session_id,
|
||||
card_kind,
|
||||
&id,
|
||||
&object,
|
||||
updated_at_micros,
|
||||
)?;
|
||||
inserted = inserted.saturating_add(1);
|
||||
}
|
||||
draft_profile.insert(profile_key.to_string(), JsonValue::Array(merged));
|
||||
Ok(inserted)
|
||||
}
|
||||
|
||||
fn upsert_json_array_object_by_id(items: &mut Vec<JsonValue>, next: JsonValue) {
|
||||
let Some(next_id) = next
|
||||
.get("id")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
else {
|
||||
items.push(next);
|
||||
return;
|
||||
};
|
||||
if let Some(existing) = items
|
||||
.iter_mut()
|
||||
.find(|entry| entry.get("id").and_then(JsonValue::as_str) == Some(next_id.as_str()))
|
||||
{
|
||||
*existing = next;
|
||||
} else {
|
||||
items.push(next);
|
||||
}
|
||||
}
|
||||
|
||||
fn upsert_custom_world_entity_card(
|
||||
ctx: &ReducerContext,
|
||||
session_id: &str,
|
||||
kind: RpgAgentDraftCardKind,
|
||||
entity_id: &str,
|
||||
object: &JsonMap<String, JsonValue>,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<(), String> {
|
||||
let card_id = format!(
|
||||
"custom-world:{}:{}:{}",
|
||||
session_id,
|
||||
kind.as_str(),
|
||||
entity_id
|
||||
);
|
||||
let title = read_optional_text_field(object, &["name", "title"])
|
||||
.unwrap_or_else(|| entity_id.to_string());
|
||||
let subtitle =
|
||||
read_optional_text_field(object, &["role", "subtitle", "purpose"]).unwrap_or_default();
|
||||
let summary = read_optional_text_field(
|
||||
object,
|
||||
&["summary", "notes", "publicGoal", "description", "mood"],
|
||||
)
|
||||
.unwrap_or_else(|| title.clone());
|
||||
let detail_payload_json = serialize_json_value(&json!({
|
||||
"id": card_id,
|
||||
"entityId": entity_id,
|
||||
"kind": kind.as_str(),
|
||||
"title": title,
|
||||
"sections": [
|
||||
{ "id": "title", "label": "标题", "value": title },
|
||||
{ "id": "subtitle", "label": "副标题", "value": subtitle },
|
||||
{ "id": "summary", "label": "摘要", "value": summary },
|
||||
],
|
||||
"linkedIds": [entity_id],
|
||||
"locked": false,
|
||||
"editable": false,
|
||||
"editableSectionIds": [],
|
||||
"warningMessages": [],
|
||||
}))?;
|
||||
let existing = ctx
|
||||
.db
|
||||
.custom_world_draft_card()
|
||||
.card_id()
|
||||
.find(&card_id)
|
||||
.filter(|row| row.session_id == session_id);
|
||||
let next = CustomWorldDraftCard {
|
||||
card_id: card_id.clone(),
|
||||
session_id: session_id.to_string(),
|
||||
kind,
|
||||
status: RpgAgentDraftCardStatus::Suggested,
|
||||
title,
|
||||
subtitle,
|
||||
summary,
|
||||
linked_ids_json: serialize_json_value(&json!([entity_id]))?,
|
||||
warning_count: 0,
|
||||
asset_status: None,
|
||||
asset_status_label: None,
|
||||
detail_payload_json: Some(detail_payload_json),
|
||||
created_at: existing
|
||||
.as_ref()
|
||||
.map(|row| row.created_at)
|
||||
.unwrap_or_else(|| Timestamp::from_micros_since_unix_epoch(updated_at_micros)),
|
||||
updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros),
|
||||
};
|
||||
if let Some(existing) = existing {
|
||||
replace_custom_world_draft_card(ctx, &existing, next);
|
||||
} else {
|
||||
ctx.db.custom_world_draft_card().insert(next);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_role_asset_coverage_json(
|
||||
session: &CustomWorldAgentSession,
|
||||
payload: &JsonMap<String, JsonValue>,
|
||||
generated: bool,
|
||||
) -> Result<String, String> {
|
||||
let mut coverage = parse_optional_session_object(Some(&session.asset_coverage_json))
|
||||
.unwrap_or_else(JsonMap::new);
|
||||
let profile = current_custom_world_draft_profile(session);
|
||||
let mut role_assets = payload
|
||||
.get("roleAssets")
|
||||
.and_then(JsonValue::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| build_role_asset_entries_from_profile(&profile, generated));
|
||||
if role_assets.is_empty() {
|
||||
role_assets = build_role_asset_entries_from_profile(&profile, generated);
|
||||
}
|
||||
let all_ready = !role_assets.is_empty()
|
||||
&& role_assets
|
||||
.iter()
|
||||
.all(|entry| asset_entry_ready(entry, &["visualReady", "animationsReady"]));
|
||||
coverage.insert("roleAssets".to_string(), JsonValue::Array(role_assets));
|
||||
coverage.insert("allRoleAssetsReady".to_string(), JsonValue::Bool(all_ready));
|
||||
coverage
|
||||
.entry("sceneAssets".to_string())
|
||||
.or_insert_with(|| JsonValue::Array(Vec::new()));
|
||||
coverage
|
||||
.entry("allSceneAssetsReady".to_string())
|
||||
.or_insert_with(|| JsonValue::Bool(false));
|
||||
serialize_json_value(&JsonValue::Object(coverage))
|
||||
}
|
||||
|
||||
fn build_scene_asset_coverage_json(
|
||||
session: &CustomWorldAgentSession,
|
||||
payload: &JsonMap<String, JsonValue>,
|
||||
generated: bool,
|
||||
) -> Result<String, String> {
|
||||
let mut coverage = parse_optional_session_object(Some(&session.asset_coverage_json))
|
||||
.unwrap_or_else(JsonMap::new);
|
||||
let profile = current_custom_world_draft_profile(session);
|
||||
let mut scene_assets = payload
|
||||
.get("sceneAssets")
|
||||
.and_then(JsonValue::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| build_scene_asset_entries_from_profile(&profile, generated));
|
||||
if scene_assets.is_empty() {
|
||||
scene_assets = build_scene_asset_entries_from_profile(&profile, generated);
|
||||
}
|
||||
let all_ready = !scene_assets.is_empty()
|
||||
&& scene_assets
|
||||
.iter()
|
||||
.all(|entry| asset_entry_ready(entry, &["visualReady", "synced"]));
|
||||
coverage.insert("sceneAssets".to_string(), JsonValue::Array(scene_assets));
|
||||
coverage.insert(
|
||||
"allSceneAssetsReady".to_string(),
|
||||
JsonValue::Bool(all_ready),
|
||||
);
|
||||
coverage
|
||||
.entry("roleAssets".to_string())
|
||||
.or_insert_with(|| JsonValue::Array(Vec::new()));
|
||||
coverage
|
||||
.entry("allRoleAssetsReady".to_string())
|
||||
.or_insert_with(|| JsonValue::Bool(false));
|
||||
serialize_json_value(&JsonValue::Object(coverage))
|
||||
}
|
||||
|
||||
fn build_role_asset_entries_from_profile(
|
||||
profile: &JsonMap<String, JsonValue>,
|
||||
generated: bool,
|
||||
) -> Vec<JsonValue> {
|
||||
collect_profile_entities(profile, &["playableNpcs", "storyNpcs"])
|
||||
.into_iter()
|
||||
.map(|entry| {
|
||||
let id = entry
|
||||
.get("id")
|
||||
.and_then(JsonValue::as_str)
|
||||
.unwrap_or("role");
|
||||
json!({
|
||||
"roleId": id,
|
||||
"name": read_optional_text_field(&entry, &["name", "title"]).unwrap_or_else(|| id.to_string()),
|
||||
"visualReady": generated,
|
||||
"animationsReady": !generated,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_scene_asset_entries_from_profile(
|
||||
profile: &JsonMap<String, JsonValue>,
|
||||
generated: bool,
|
||||
) -> Vec<JsonValue> {
|
||||
collect_profile_entities(profile, &["landmarks", "sceneChapters", "sceneChapterBlueprints"])
|
||||
.into_iter()
|
||||
.map(|entry| {
|
||||
let id = entry
|
||||
.get("id")
|
||||
.and_then(JsonValue::as_str)
|
||||
.unwrap_or("scene");
|
||||
json!({
|
||||
"sceneId": id,
|
||||
"name": read_optional_text_field(&entry, &["name", "title"]).unwrap_or_else(|| id.to_string()),
|
||||
"visualReady": generated,
|
||||
"synced": !generated,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn collect_profile_entities(
|
||||
profile: &JsonMap<String, JsonValue>,
|
||||
keys: &[&str],
|
||||
) -> Vec<JsonMap<String, JsonValue>> {
|
||||
let mut result = Vec::new();
|
||||
for key in keys {
|
||||
if let Some(entries) = profile.get(*key).and_then(JsonValue::as_array) {
|
||||
for entry in entries {
|
||||
if let Some(object) = entry.as_object() {
|
||||
result.push(object.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn asset_entry_ready(entry: &JsonValue, keys: &[&str]) -> bool {
|
||||
keys.iter().all(|key| {
|
||||
entry
|
||||
.get(*key)
|
||||
.and_then(JsonValue::as_bool)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
fn update_role_asset_cards(
|
||||
ctx: &ReducerContext,
|
||||
session_id: &str,
|
||||
status: CustomWorldRoleAssetStatus,
|
||||
label: &str,
|
||||
updated_at_micros: i64,
|
||||
) {
|
||||
for card in
|
||||
ctx.db.custom_world_draft_card().iter().filter(|row| {
|
||||
row.session_id == session_id && row.kind == RpgAgentDraftCardKind::Character
|
||||
})
|
||||
{
|
||||
replace_custom_world_draft_card(
|
||||
ctx,
|
||||
&card,
|
||||
CustomWorldDraftCard {
|
||||
card_id: card.card_id.clone(),
|
||||
session_id: card.session_id.clone(),
|
||||
kind: card.kind,
|
||||
status: card.status,
|
||||
title: card.title.clone(),
|
||||
subtitle: card.subtitle.clone(),
|
||||
summary: card.summary.clone(),
|
||||
linked_ids_json: card.linked_ids_json.clone(),
|
||||
warning_count: card.warning_count,
|
||||
asset_status: Some(status),
|
||||
asset_status_label: Some(label.to_string()),
|
||||
detail_payload_json: card.detail_payload_json.clone(),
|
||||
created_at: card.created_at,
|
||||
updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_long_tail_payload(
|
||||
draft_profile: &mut JsonMap<String, JsonValue>,
|
||||
payload: &JsonMap<String, JsonValue>,
|
||||
) {
|
||||
for key in [
|
||||
"coreConflicts",
|
||||
"chapters",
|
||||
"sceneChapters",
|
||||
"sceneChapterBlueprints",
|
||||
"sidequestSeeds",
|
||||
"carrierHooks",
|
||||
] {
|
||||
if let Some(entries) = payload.get(key).and_then(JsonValue::as_array) {
|
||||
let mut merged = draft_profile
|
||||
.get(key)
|
||||
.and_then(JsonValue::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
for entry in entries {
|
||||
if let Some(object) = entry.as_object() {
|
||||
upsert_json_array_object_by_id(&mut merged, JsonValue::Object(object.clone()));
|
||||
} else if !merged.contains(entry) {
|
||||
merged.push(entry.clone());
|
||||
}
|
||||
}
|
||||
draft_profile.insert(key.to_string(), JsonValue::Array(merged));
|
||||
}
|
||||
}
|
||||
for key in ["worldHook", "playerPremise", "summary", "subtitle"] {
|
||||
if let Some(value) = payload
|
||||
.get(key)
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
draft_profile.insert(key.to_string(), JsonValue::String(value.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct CustomWorldAgentSessionPatch {
|
||||
current_turn: Option<u32>,
|
||||
@@ -3310,24 +4044,6 @@ fn ensure_publishable_stage(stage: RpgAgentStage, action: &str) -> Result<(), St
|
||||
ensure_long_tail_stage(stage, action)
|
||||
}
|
||||
|
||||
fn map_action_name_to_operation_type(action: &str) -> Option<RpgAgentOperationType> {
|
||||
match action {
|
||||
"draft_foundation" => Some(RpgAgentOperationType::DraftFoundation),
|
||||
"update_draft_card" => Some(RpgAgentOperationType::UpdateDraftCard),
|
||||
"sync_result_profile" => Some(RpgAgentOperationType::SyncResultProfile),
|
||||
"generate_characters" => Some(RpgAgentOperationType::GenerateCharacters),
|
||||
"generate_landmarks" => Some(RpgAgentOperationType::GenerateLandmarks),
|
||||
"generate_role_assets" => Some(RpgAgentOperationType::GenerateRoleAssets),
|
||||
"sync_role_assets" => Some(RpgAgentOperationType::SyncRoleAssets),
|
||||
"generate_scene_assets" => Some(RpgAgentOperationType::GenerateSceneAssets),
|
||||
"sync_scene_assets" => Some(RpgAgentOperationType::SyncSceneAssets),
|
||||
"expand_long_tail" => Some(RpgAgentOperationType::ExpandLongTail),
|
||||
"publish_world" => Some(RpgAgentOperationType::PublishWorld),
|
||||
"revert_checkpoint" => Some(RpgAgentOperationType::RevertCheckpoint),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_rpg_agent_stage(value: &str) -> Option<RpgAgentStage> {
|
||||
match value.trim() {
|
||||
"collecting_intent" => Some(RpgAgentStage::CollectingIntent),
|
||||
|
||||
7
server-rs/crates/tests-support/Cargo.toml
Normal file
7
server-rs/crates/tests-support/Cargo.toml
Normal file
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "tests-support"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
@@ -1,18 +1,21 @@
|
||||
# tests-support 共享 crate 占位说明
|
||||
# tests-support 共享测试支撑 crate
|
||||
|
||||
日期:`2026-04-20`
|
||||
|
||||
## 1. crate 职责
|
||||
|
||||
`tests-support` 是测试支撑共享 crate,后续负责:
|
||||
`tests-support` 是测试支撑共享 crate,当前已作为 `server-rs` workspace member 落位,负责承接跨 crate 复用的测试辅助能力。
|
||||
|
||||
1. contract、integration、smoke 测试的共享夹具与辅助工具
|
||||
2. 测试环境配置、测试数据装配与断言工具
|
||||
3. 供 `crates/api-server`、`crates/spacetime-module` 与各模块 crate 复用的测试基础设施能力
|
||||
当前首版只放无业务规则的 smoke/HTTP 通用断言:
|
||||
|
||||
1. Maincloud healthz 默认地址常量
|
||||
2. smoke URL 空值与尾斜杠归一化
|
||||
3. HTTP 2xx 状态码断言
|
||||
4. healthz 非空响应体断言
|
||||
|
||||
## 2. 当前阶段说明
|
||||
|
||||
当前提交仅完成目录占位,不提前进入测试夹具、断言工具与 smoke 支撑实现。
|
||||
当前阶段不提前引入伪环境、不编造业务夹具,也不承接 contract DTO 或 SpacetimeDB reducer 的测试数据装配。
|
||||
|
||||
后续与本 crate 直接相关的任务包括:
|
||||
|
||||
@@ -26,3 +29,4 @@
|
||||
1. `tests-support` 只承接测试支撑能力,不承接业务规则实现。
|
||||
2. 测试夹具要尽量贴近真实 contract 与真实模块边界,避免重新引入脱离现网的伪环境。
|
||||
3. 不允许把测试辅助逻辑散落到各模块 crate 中重复实现。
|
||||
4. SpacetimeDB 表、reducer、procedure 和迁移规则仍归 `spacetime-module` 与 `WP-ST`,本 crate 不定义 schema。
|
||||
|
||||
92
server-rs/crates/tests-support/src/lib.rs
Normal file
92
server-rs/crates/tests-support/src/lib.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
use std::fmt;
|
||||
|
||||
pub const DEFAULT_MAINCLOUD_HEALTHZ_URL: &str = "http://127.0.0.1:3100/healthz";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SmokeAssertionError {
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl SmokeAssertionError {
|
||||
/// 测试支撑 crate 只提供断言辅助,不承接业务错误分类。
|
||||
pub fn new(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn message(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for SmokeAssertionError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SmokeAssertionError {}
|
||||
|
||||
/// 归一化本地 smoke URL,供不同测试入口复用同一套空值与斜杠处理口径。
|
||||
pub fn normalize_smoke_url(input: impl AsRef<str>) -> String {
|
||||
let trimmed = input.as_ref().trim();
|
||||
if trimmed.is_empty() {
|
||||
return DEFAULT_MAINCLOUD_HEALTHZ_URL.to_string();
|
||||
}
|
||||
|
||||
trimmed.trim_end_matches('/').to_string()
|
||||
}
|
||||
|
||||
/// 断言 HTTP 状态码处于 2xx,避免 smoke 测试散落重复判断。
|
||||
pub fn assert_success_status(status: u16) -> Result<(), SmokeAssertionError> {
|
||||
if (200..=299).contains(&status) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(SmokeAssertionError::new(format!(
|
||||
"期望 HTTP 2xx 状态码,实际为 {status}"
|
||||
)))
|
||||
}
|
||||
|
||||
/// 断言 healthz 响应体非空。具体 JSON 字段语义仍归 api-server 自己的 contract 测试负责。
|
||||
pub fn assert_non_empty_healthz_body(body: impl AsRef<str>) -> Result<(), SmokeAssertionError> {
|
||||
if body.as_ref().trim().is_empty() {
|
||||
return Err(SmokeAssertionError::new("healthz 响应体不能为空"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalize_smoke_url_uses_maincloud_healthz_when_empty() {
|
||||
assert_eq!(
|
||||
normalize_smoke_url(" "),
|
||||
DEFAULT_MAINCLOUD_HEALTHZ_URL.to_string()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_smoke_url_removes_trailing_slash() {
|
||||
assert_eq!(
|
||||
normalize_smoke_url(" http://127.0.0.1:3100/ "),
|
||||
"http://127.0.0.1:3100"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assert_success_status_rejects_non_2xx() {
|
||||
let error = assert_success_status(503).expect_err("非 2xx 状态码必须失败");
|
||||
assert_eq!(error.message(), "期望 HTTP 2xx 状态码,实际为 503");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assert_non_empty_healthz_body_rejects_blank_body() {
|
||||
let error = assert_non_empty_healthz_body(" \n ").expect_err("空响应体必须失败");
|
||||
assert_eq!(error.message(), "healthz 响应体不能为空");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user