Close DDD cleanup and tests-support closure

This commit is contained in:
2026-04-30 16:15:05 +08:00
parent 7ab0933f6d
commit fd08262bf0
81 changed files with 8415 additions and 6662 deletions

4
server-rs/Cargo.lock generated
View File

@@ -3066,6 +3066,10 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "tests-support"
version = "0.1.0"
[[package]]
name = "thiserror"
version = "1.0.69"

View File

@@ -31,6 +31,7 @@ members = [
"crates/shared-logging",
"crates/spacetime-client",
"crates/spacetime-module",
"crates/tests-support",
]
[workspace.package]

View File

@@ -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 本地测试入口。

View File

@@ -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 快照记录恢复认证快照失败");
}
}

View File

@@ -1,4 +1,4 @@
//! 资产领域事件过渡落位
//! 资产领域事件。
//!
//! 用于表达资产已确认、绑定已变更和资产历史投影待刷新等事实。
//! 当前阶段暂不新增事件类型,避免在 SpacetimeDB 表未补 event table 前扩散未消费 API。

View 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 承接。

View File

@@ -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::*;

View File

@@ -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>,
}

View File

@@ -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",
}
}
}

View File

@@ -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 {}

View File

@@ -1,4 +1,4 @@
//! 大鱼吃小鱼领域事件过渡落位
//! 大鱼吃小鱼领域事件。
//!
//! 用于表达草稿变化、资产槽变化和运行态 tick 等事实。

View File

@@ -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 {

View File

@@ -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, &current.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, &current.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
),
}
}

View File

@@ -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)
}

View File

@@ -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,
}

View File

@@ -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 {}

View File

@@ -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,
}

View File

@@ -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, &current.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, &current.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 {

View File

@@ -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 直接相关的任务包括:

View File

@@ -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)
}

View File

@@ -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>,
}

View File

@@ -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 {

View File

@@ -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 {}

View File

@@ -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

View File

@@ -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",
}
}

View File

@@ -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,
}

View File

@@ -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)
}

View File

@@ -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 {}

View File

@@ -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,
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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 {}

View File

@@ -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,
}

View File

@@ -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 {

View File

@@ -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. 边界约束

View File

@@ -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)
}

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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 {}

View File

@@ -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,
}

View File

@@ -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 {

View File

@@ -1,4 +1,4 @@
//! 拼图领域模型过渡落位
//! 拼图领域模型。
//!
//! 后续迁移拼图 Agent 会话、作品 profile 和运行态聚合时,只保留玩法规则;
//! 图片生成、发布 HTTP shape 和排行榜适配留在外层。

View File

@@ -1,4 +1,4 @@
//! 拼图领域事件过渡落位
//! 拼图领域事件。
//!
//! 用于表达草稿变化、作品发布、运行态推进和排行榜候选产生等事实。

View File

@@ -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(&current.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,
}
}

View File

@@ -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,
}

View File

@@ -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,
}
}
}

View File

@@ -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 {}

View File

@@ -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,
}

View File

@@ -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(&current.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 {

View File

@@ -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,
}
}

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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 {}

View File

@@ -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,
}

View File

@@ -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() {

View File

@@ -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)。

View File

@@ -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")
}

View File

@@ -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())
}

View File

@@ -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>,
}

View File

@@ -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 {}

View File

@@ -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,
},
}

View File

@@ -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")
}

View File

@@ -1,4 +1,4 @@
//! 运行时应用编排过渡落位
//! 运行时应用编排。
//!
//! 这里只返回运行时快照、个人页投影和领域事件,不直接访问外部 adapter。

View File

@@ -1,4 +1,4 @@
//! 运行时写入命令过渡落位
//! 运行时写入命令。
//!
//! 用于表达保存快照、更新设置、写入浏览历史、调整钱包和保存存档等输入。

View File

@@ -1,4 +1,4 @@
//! 运行时领域错误过渡落位
//! 运行时领域错误。
//!
//! 错误保持运行时业务语义,例如快照版本非法、兑换码不可用或钱包余额不足。

View File

@@ -1,3 +1,3 @@
//! 运行时领域事件过渡落位
//! 运行时领域事件。
//!
//! 用于表达快照已保存、设置已更新、钱包已记账和存档已变化等事实。

View File

@@ -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),

View File

@@ -0,0 +1,7 @@
[package]
name = "tests-support"
edition.workspace = true
version.workspace = true
license.workspace = true
[dependencies]

View File

@@ -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。

View 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 响应体不能为空");
}
}