后端重写提交
This commit is contained in:
184
server-rs/Cargo.lock
generated
184
server-rs/Cargo.lock
generated
@@ -75,14 +75,25 @@ dependencies = [
|
||||
"hmac",
|
||||
"http-body-util",
|
||||
"httpdate",
|
||||
"module-ai",
|
||||
"module-assets",
|
||||
"module-auth",
|
||||
"module-combat",
|
||||
"module-custom-world",
|
||||
"module-inventory",
|
||||
"module-npc",
|
||||
"module-runtime",
|
||||
"module-runtime-item",
|
||||
"module-story",
|
||||
"platform-auth",
|
||||
"platform-llm",
|
||||
"platform-oss",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"shared-contracts",
|
||||
"shared-kernel",
|
||||
"shared-logging",
|
||||
"spacetime-client",
|
||||
"time",
|
||||
@@ -1382,6 +1393,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "module-ai"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"shared-kernel",
|
||||
"spacetimedb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "module-assets"
|
||||
version = "0.1.0"
|
||||
@@ -1389,6 +1410,7 @@ dependencies = [
|
||||
"platform-oss",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"shared-kernel",
|
||||
"spacetimedb",
|
||||
]
|
||||
|
||||
@@ -1397,11 +1419,96 @@ name = "module-auth"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"platform-auth",
|
||||
"shared-kernel",
|
||||
"time",
|
||||
"tokio",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "module-combat"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"module-runtime-item",
|
||||
"serde",
|
||||
"shared-kernel",
|
||||
"spacetimedb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "module-custom-world"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"spacetimedb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "module-inventory"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"shared-kernel",
|
||||
"spacetimedb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "module-npc"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"shared-kernel",
|
||||
"spacetimedb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "module-progression"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"shared-kernel",
|
||||
"spacetimedb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "module-quest"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"shared-kernel",
|
||||
"spacetimedb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "module-runtime"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"shared-kernel",
|
||||
"spacetimedb",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "module-runtime-item"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"module-inventory",
|
||||
"serde",
|
||||
"shared-kernel",
|
||||
"spacetimedb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "module-story"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"shared-kernel",
|
||||
"spacetimedb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.14"
|
||||
@@ -1605,12 +1712,24 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
"serde",
|
||||
"sha2",
|
||||
"shared-kernel",
|
||||
"time",
|
||||
"tokio",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "platform-llm"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "platform-oss"
|
||||
version = "0.1.0"
|
||||
@@ -1947,12 +2066,14 @@ dependencies = [
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
@@ -2273,6 +2394,23 @@ dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shared-contracts"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"platform-oss",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shared-kernel"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"time",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shared-logging"
|
||||
version = "0.1.0"
|
||||
@@ -2346,7 +2484,17 @@ dependencies = [
|
||||
name = "spacetime-client"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"module-ai",
|
||||
"module-assets",
|
||||
"module-combat",
|
||||
"module-custom-world",
|
||||
"module-inventory",
|
||||
"module-npc",
|
||||
"module-runtime",
|
||||
"module-runtime-item",
|
||||
"module-story",
|
||||
"serde_json",
|
||||
"shared-kernel",
|
||||
"spacetimedb-sdk",
|
||||
"tokio",
|
||||
]
|
||||
@@ -2356,7 +2504,17 @@ name = "spacetime-module"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"module-ai",
|
||||
"module-assets",
|
||||
"module-combat",
|
||||
"module-custom-world",
|
||||
"module-inventory",
|
||||
"module-npc",
|
||||
"module-progression",
|
||||
"module-quest",
|
||||
"module-runtime",
|
||||
"module-runtime-item",
|
||||
"module-story",
|
||||
"spacetimedb",
|
||||
]
|
||||
|
||||
@@ -2889,6 +3047,19 @@ dependencies = [
|
||||
"tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
@@ -3276,6 +3447,19 @@ dependencies = [
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.244.0"
|
||||
|
||||
@@ -5,10 +5,23 @@
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/api-server",
|
||||
"crates/module-ai",
|
||||
"crates/module-assets",
|
||||
"crates/module-auth",
|
||||
"crates/module-combat",
|
||||
"crates/module-inventory",
|
||||
"crates/module-custom-world",
|
||||
"crates/module-npc",
|
||||
"crates/module-progression",
|
||||
"crates/module-quest",
|
||||
"crates/module-runtime",
|
||||
"crates/module-runtime-item",
|
||||
"crates/module-story",
|
||||
"crates/platform-oss",
|
||||
"crates/platform-auth",
|
||||
"crates/platform-llm",
|
||||
"crates/shared-contracts",
|
||||
"crates/shared-kernel",
|
||||
"crates/shared-logging",
|
||||
"crates/spacetime-client",
|
||||
"crates/spacetime-module",
|
||||
|
||||
@@ -8,12 +8,23 @@ license.workspace = true
|
||||
axum = "0.8"
|
||||
dotenvy = "0.15"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
module-ai = { path = "../module-ai" }
|
||||
module-assets = { path = "../module-assets" }
|
||||
module-auth = { path = "../module-auth" }
|
||||
module-combat = { path = "../module-combat" }
|
||||
module-custom-world = { path = "../module-custom-world" }
|
||||
module-inventory = { path = "../module-inventory" }
|
||||
module-npc = { path = "../module-npc" }
|
||||
module-runtime = { path = "../module-runtime" }
|
||||
module-runtime-item = { path = "../module-runtime-item" }
|
||||
module-story = { path = "../module-story" }
|
||||
platform-auth = { path = "../platform-auth" }
|
||||
platform-llm = { path = "../platform-llm" }
|
||||
platform-oss = { path = "../platform-oss" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
shared-contracts = { path = "../shared-contracts" }
|
||||
shared-kernel = { path = "../shared-kernel" }
|
||||
shared-logging = { path = "../shared-logging" }
|
||||
spacetime-client = { path = "../spacetime-client" }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net"] }
|
||||
|
||||
@@ -42,6 +42,8 @@
|
||||
20. 接入 `POST /api/auth/wechat/bind-phone` 微信待绑定账号补绑手机号链路
|
||||
21. 接入 `POST /api/assets/objects/bind` 已确认对象绑定业务实体槽位链路
|
||||
22. 接入 `POST /api/assets/sts-upload-credentials` 禁用式 STS 写权限 contract
|
||||
23. 接入 `custom-world-library`、`custom-world-gallery` 与 agent `publish_world` 首批 Axum facade
|
||||
24. 接入 custom world agent `session create / session snapshot` Axum facade
|
||||
|
||||
后续与本 crate 直接相关的任务包括:
|
||||
|
||||
@@ -64,6 +66,8 @@
|
||||
17. [x] 接入 `/api/auth/wechat/bind-phone`
|
||||
18. [x] 接入 `/api/assets/objects/bind`
|
||||
19. [x] 接入 `/api/assets/sts-upload-credentials`
|
||||
20. [x] 接入 `custom world library / gallery / publish_world` 首批 facade
|
||||
21. [x] 接入 `custom world agent session create / snapshot` facade
|
||||
|
||||
当前 tracing 约定:
|
||||
|
||||
@@ -131,3 +135,4 @@
|
||||
11. 当前手机号登录与微信登录都复用 `module-auth` 的进程内认证仓储,`api-server` 负责请求解析、场景判定、系统 JWT 签发与 refresh cookie 写回。
|
||||
12. 当前微信回调不会把第三方 token 直接透传给前端或 SpacetimeDB,而是统一换成系统签发的 JWT。
|
||||
13. 当前 `/api/assets/sts-upload-credentials` 按“服务器上传、Web 只下载”口径固定返回 `403`,不向浏览器下发 OSS 写权限。
|
||||
14. 当前 `/api/runtime/custom-world/agent/sessions` 与 `/api/runtime/custom-world/agent/sessions/{session_id}` 只提供 deterministic session 骨架与 snapshot 读取,不承诺 message submit、operation query、card detail 的完整能力。
|
||||
|
||||
674
server-rs/crates/api-server/src/ai_tasks.rs
Normal file
674
server-rs/crates/api-server/src/ai_tasks.rs
Normal file
@@ -0,0 +1,674 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use module_ai::{
|
||||
AiResultReferenceInput, AiResultReferenceKind, AiStageCompletionInput, AiTaskCancelInput,
|
||||
AiTaskCreateInput, AiTaskFailureInput, AiTaskFinishInput, AiTaskKind, AiTaskStageBlueprint,
|
||||
AiTaskStageKind, AiTaskStageStartInput, AiTaskStartInput, AiTextChunkAppendInput,
|
||||
generate_ai_task_id,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::ai::{
|
||||
AiResultReferencePayload, AiTaskAcceptedResponse, AiTaskMutationResponse, AiTaskPayload,
|
||||
AiTaskStagePayload, AiTextChunkPayload, AppendAiTextChunkRequest,
|
||||
AttachAiResultReferenceRequest, CompleteAiStageRequest, CreateAiTaskRequest, FailAiTaskRequest,
|
||||
};
|
||||
use spacetime_client::{AiTaskMutationRecord, SpacetimeClientError};
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
request_context::RequestContext, state::AppState,
|
||||
};
|
||||
|
||||
pub async fn create_ai_task(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<CreateAiTaskRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let now_micros = current_utc_micros();
|
||||
let task_kind = parse_ai_task_kind_strict(&payload.task_kind).ok_or_else(|| {
|
||||
ai_tasks_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "ai-task",
|
||||
"message": "taskKind 非法",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let stages = build_stage_blueprints(task_kind, payload.stage_kinds, &request_context)?;
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
|
||||
let result = state
|
||||
.spacetime_client()
|
||||
.create_ai_task(AiTaskCreateInput {
|
||||
task_id: generate_ai_task_id(now_micros),
|
||||
task_kind,
|
||||
owner_user_id,
|
||||
request_label: payload.request_label,
|
||||
source_module: payload.source_module,
|
||||
source_entity_id: payload.source_entity_id,
|
||||
request_payload_json: payload.request_payload_json,
|
||||
stages,
|
||||
created_at_micros: now_micros,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
ai_tasks_error_response(&request_context, map_ai_task_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_ai_task_mutation_response(result),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn start_ai_task(
|
||||
State(state): State<AppState>,
|
||||
Path(task_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Response, Response> {
|
||||
state
|
||||
.spacetime_client()
|
||||
.start_ai_task(AiTaskStartInput {
|
||||
task_id: task_id.clone(),
|
||||
started_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
ai_tasks_error_response(&request_context, map_ai_task_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(ai_task_accepted_response(
|
||||
&request_context,
|
||||
AiTaskAcceptedResponse {
|
||||
accepted: true,
|
||||
task_id,
|
||||
action: "start_task".to_string(),
|
||||
stage_kind: None,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn start_ai_task_stage(
|
||||
State(state): State<AppState>,
|
||||
Path((task_id, stage_kind_text)): Path<(String, String)>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Response, Response> {
|
||||
let stage_kind = parse_ai_task_stage_kind_strict(&stage_kind_text).ok_or_else(|| {
|
||||
ai_tasks_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "ai-task-stage",
|
||||
"message": "stageKind 非法",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
state
|
||||
.spacetime_client()
|
||||
.start_ai_task_stage(AiTaskStageStartInput {
|
||||
task_id: task_id.clone(),
|
||||
stage_kind,
|
||||
started_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
ai_tasks_error_response(&request_context, map_ai_task_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(ai_task_accepted_response(
|
||||
&request_context,
|
||||
AiTaskAcceptedResponse {
|
||||
accepted: true,
|
||||
task_id,
|
||||
action: "start_stage".to_string(),
|
||||
stage_kind: Some(stage_kind.as_str().to_string()),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn append_ai_text_chunk(
|
||||
State(state): State<AppState>,
|
||||
Path(task_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<AppendAiTextChunkRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let stage_kind = parse_ai_task_stage_kind_strict(&payload.stage_kind).ok_or_else(|| {
|
||||
ai_tasks_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "ai-task-stage",
|
||||
"message": "stageKind 非法",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let result = state
|
||||
.spacetime_client()
|
||||
.append_ai_text_chunk(AiTextChunkAppendInput {
|
||||
task_id,
|
||||
stage_kind,
|
||||
sequence: payload.sequence,
|
||||
delta_text: payload.delta_text,
|
||||
created_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
ai_tasks_error_response(&request_context, map_ai_task_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_ai_task_mutation_response(result),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn complete_ai_stage(
|
||||
State(state): State<AppState>,
|
||||
Path((task_id, stage_kind_text)): Path<(String, String)>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<CompleteAiStageRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let stage_kind = parse_ai_task_stage_kind_strict(&stage_kind_text).ok_or_else(|| {
|
||||
ai_tasks_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "ai-task-stage",
|
||||
"message": "stageKind 非法",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let result = state
|
||||
.spacetime_client()
|
||||
.complete_ai_stage(AiStageCompletionInput {
|
||||
task_id,
|
||||
stage_kind,
|
||||
text_output: payload.text_output,
|
||||
structured_payload_json: payload.structured_payload_json,
|
||||
warning_messages: payload.warning_messages,
|
||||
completed_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
ai_tasks_error_response(&request_context, map_ai_task_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_ai_task_mutation_response(result),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn attach_ai_result_reference(
|
||||
State(state): State<AppState>,
|
||||
Path(task_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<AttachAiResultReferenceRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let reference_kind = parse_ai_result_reference_kind_strict(&payload.reference_kind)
|
||||
.ok_or_else(|| {
|
||||
ai_tasks_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "ai-task-reference",
|
||||
"message": "referenceKind 非法",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let result = state
|
||||
.spacetime_client()
|
||||
.attach_ai_result_reference(AiResultReferenceInput {
|
||||
task_id,
|
||||
reference_kind,
|
||||
reference_id: payload.reference_id,
|
||||
label: payload.label,
|
||||
created_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
ai_tasks_error_response(&request_context, map_ai_task_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_ai_task_mutation_response(result),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn complete_ai_task(
|
||||
State(state): State<AppState>,
|
||||
Path(task_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let result = state
|
||||
.spacetime_client()
|
||||
.complete_ai_task(AiTaskFinishInput {
|
||||
task_id,
|
||||
completed_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
ai_tasks_error_response(&request_context, map_ai_task_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_ai_task_mutation_response(result),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn fail_ai_task(
|
||||
State(state): State<AppState>,
|
||||
Path(task_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<FailAiTaskRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let result = state
|
||||
.spacetime_client()
|
||||
.fail_ai_task(AiTaskFailureInput {
|
||||
task_id,
|
||||
failure_message: payload.failure_message,
|
||||
completed_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
ai_tasks_error_response(&request_context, map_ai_task_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_ai_task_mutation_response(result),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn cancel_ai_task(
|
||||
State(state): State<AppState>,
|
||||
Path(task_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let result = state
|
||||
.spacetime_client()
|
||||
.cancel_ai_task(AiTaskCancelInput {
|
||||
task_id,
|
||||
completed_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
ai_tasks_error_response(&request_context, map_ai_task_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_ai_task_mutation_response(result),
|
||||
))
|
||||
}
|
||||
|
||||
fn build_stage_blueprints(
|
||||
task_kind: AiTaskKind,
|
||||
stage_kinds: Vec<String>,
|
||||
request_context: &RequestContext,
|
||||
) -> Result<Vec<AiTaskStageBlueprint>, Response> {
|
||||
if stage_kinds.is_empty() {
|
||||
return Ok(task_kind.default_stage_blueprints());
|
||||
}
|
||||
|
||||
stage_kinds
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, stage_kind_text)| {
|
||||
let stage_kind =
|
||||
parse_ai_task_stage_kind_strict(&stage_kind_text).ok_or_else(|| {
|
||||
ai_tasks_error_response(
|
||||
request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "ai-task-stage",
|
||||
"message": format!("stageKinds[{index}] 非法"),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(AiTaskStageBlueprint {
|
||||
stage_kind,
|
||||
label: stage_kind.default_label().to_string(),
|
||||
detail: stage_kind.default_detail().to_string(),
|
||||
order: index as u32,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_ai_task_mutation_response(record: AiTaskMutationRecord) -> AiTaskMutationResponse {
|
||||
AiTaskMutationResponse {
|
||||
ai_task: build_ai_task_payload(record.task),
|
||||
ai_text_chunk: record.text_chunk.map(build_ai_text_chunk_payload),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ai_task_payload(record: spacetime_client::AiTaskRecord) -> AiTaskPayload {
|
||||
AiTaskPayload {
|
||||
task_id: record.task_id,
|
||||
task_kind: record.task_kind,
|
||||
owner_user_id: record.owner_user_id,
|
||||
request_label: record.request_label,
|
||||
source_module: record.source_module,
|
||||
source_entity_id: record.source_entity_id,
|
||||
request_payload_json: record.request_payload_json,
|
||||
status: record.status,
|
||||
failure_message: record.failure_message,
|
||||
stages: record
|
||||
.stages
|
||||
.into_iter()
|
||||
.map(build_ai_task_stage_payload)
|
||||
.collect(),
|
||||
result_references: record
|
||||
.result_references
|
||||
.into_iter()
|
||||
.map(build_ai_result_reference_payload)
|
||||
.collect(),
|
||||
latest_text_output: record.latest_text_output,
|
||||
latest_structured_payload_json: record.latest_structured_payload_json,
|
||||
version: record.version,
|
||||
created_at: record.created_at,
|
||||
started_at: record.started_at,
|
||||
completed_at: record.completed_at,
|
||||
updated_at: record.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ai_task_stage_payload(record: spacetime_client::AiTaskStageRecord) -> AiTaskStagePayload {
|
||||
AiTaskStagePayload {
|
||||
stage_kind: record.stage_kind,
|
||||
label: record.label,
|
||||
detail: record.detail,
|
||||
order: record.order,
|
||||
status: record.status,
|
||||
text_output: record.text_output,
|
||||
structured_payload_json: record.structured_payload_json,
|
||||
warning_messages: record.warning_messages,
|
||||
started_at: record.started_at,
|
||||
completed_at: record.completed_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ai_result_reference_payload(
|
||||
record: spacetime_client::AiResultReferenceRecord,
|
||||
) -> AiResultReferencePayload {
|
||||
AiResultReferencePayload {
|
||||
result_ref_id: record.result_ref_id,
|
||||
task_id: record.task_id,
|
||||
reference_kind: record.reference_kind,
|
||||
reference_id: record.reference_id,
|
||||
label: record.label,
|
||||
created_at: record.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ai_text_chunk_payload(record: spacetime_client::AiTextChunkRecord) -> AiTextChunkPayload {
|
||||
AiTextChunkPayload {
|
||||
chunk_id: record.chunk_id,
|
||||
task_id: record.task_id,
|
||||
stage_kind: record.stage_kind,
|
||||
sequence: record.sequence,
|
||||
delta_text: record.delta_text,
|
||||
created_at: record.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_ai_task_kind_strict(value: &str) -> Option<AiTaskKind> {
|
||||
match value.trim() {
|
||||
"story_generation" => Some(AiTaskKind::StoryGeneration),
|
||||
"character_chat" => Some(AiTaskKind::CharacterChat),
|
||||
"npc_chat" => Some(AiTaskKind::NpcChat),
|
||||
"custom_world_generation" => Some(AiTaskKind::CustomWorldGeneration),
|
||||
"quest_intent" => Some(AiTaskKind::QuestIntent),
|
||||
"runtime_item_intent" => Some(AiTaskKind::RuntimeItemIntent),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_ai_task_stage_kind_strict(value: &str) -> Option<AiTaskStageKind> {
|
||||
match value.trim() {
|
||||
"prepare_prompt" => Some(AiTaskStageKind::PreparePrompt),
|
||||
"request_model" => Some(AiTaskStageKind::RequestModel),
|
||||
"repair_response" => Some(AiTaskStageKind::RepairResponse),
|
||||
"normalize_result" => Some(AiTaskStageKind::NormalizeResult),
|
||||
"persist_result" => Some(AiTaskStageKind::PersistResult),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_ai_result_reference_kind_strict(value: &str) -> Option<AiResultReferenceKind> {
|
||||
match value.trim() {
|
||||
"story_session" => Some(AiResultReferenceKind::StorySession),
|
||||
"story_event" => Some(AiResultReferenceKind::StoryEvent),
|
||||
"custom_world_profile" => Some(AiResultReferenceKind::CustomWorldProfile),
|
||||
"quest_record" => Some(AiResultReferenceKind::QuestRecord),
|
||||
"runtime_item_record" => Some(AiResultReferenceKind::RuntimeItemRecord),
|
||||
"asset_object" => Some(AiResultReferenceKind::AssetObject),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_ai_task_client_error(error: SpacetimeClientError) -> AppError {
|
||||
let status = match &error {
|
||||
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
|
||||
_ => StatusCode::BAD_GATEWAY,
|
||||
};
|
||||
|
||||
AppError::from_status(status).with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn ai_tasks_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
||||
error.into_response_with_context(Some(request_context))
|
||||
}
|
||||
|
||||
fn ai_task_accepted_response(
|
||||
request_context: &RequestContext,
|
||||
payload: AiTaskAcceptedResponse,
|
||||
) -> Response {
|
||||
let mut response = json_success_body(Some(request_context), payload).into_response();
|
||||
*response.status_mut() = StatusCode::ACCEPTED;
|
||||
response
|
||||
}
|
||||
|
||||
fn current_utc_micros() -> i64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
let duration = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system clock should be after unix epoch");
|
||||
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use platform_auth::{
|
||||
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use time::OffsetDateTime;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::{app::build_router, config::AppConfig, state::AppState};
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_ai_task_requires_authentication() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/ai/tasks")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"taskKind": "story_generation",
|
||||
"requestLabel": "营地开场",
|
||||
"sourceModule": "story"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_ai_task_returns_bad_gateway_when_spacetime_not_published() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/ai/tasks")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"taskKind": "npc_chat",
|
||||
"requestLabel": "试探问话",
|
||||
"sourceModule": "npc"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(false));
|
||||
assert_eq!(
|
||||
payload["error"]["details"]["provider"],
|
||||
Value::String("spacetimedb".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_ai_task_requires_authentication() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/ai/tasks/aitask_001/start")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_ai_task_returns_bad_gateway_when_spacetime_not_published() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/ai/tasks/aitask_001/start")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(false));
|
||||
assert_eq!(
|
||||
payload["error"]["details"]["provider"],
|
||||
Value::String("spacetimedb".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
async fn seed_authenticated_state() -> AppState {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state
|
||||
.password_entry_service()
|
||||
.execute(module_auth::PasswordEntryInput {
|
||||
username: "ai_tasks_user".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("seed login should succeed");
|
||||
state
|
||||
}
|
||||
|
||||
fn issue_access_token(state: &AppState) -> String {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: "sess_ai_tasks".to_string(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 1,
|
||||
phone_verified: true,
|
||||
binding_status: BindingStatus::Active,
|
||||
display_name: Some("AI 任务用户".to_string()),
|
||||
},
|
||||
state.auth_jwt_config(),
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.expect("claims should build");
|
||||
|
||||
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,15 @@
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
#[cfg(test)]
|
||||
use serde_json::json;
|
||||
use shared_contracts::api::{
|
||||
API_VERSION, ApiErrorEnvelope, ApiErrorPayload, ApiResponseMeta, ApiSuccessEnvelope,
|
||||
LegacyApiErrorResponse,
|
||||
};
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
|
||||
use crate::{http_error::ApiErrorPayload, request_context::RequestContext};
|
||||
|
||||
pub const API_VERSION: &str = "2026-04-08";
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ApiResponseMeta {
|
||||
#[serde(rename = "apiVersion")]
|
||||
api_version: &'static str,
|
||||
#[serde(rename = "requestId", skip_serializing_if = "Option::is_none")]
|
||||
request_id: Option<String>,
|
||||
#[serde(rename = "routeVersion")]
|
||||
route_version: &'static str,
|
||||
operation: Option<String>,
|
||||
#[serde(rename = "latencyMs")]
|
||||
latency_ms: u64,
|
||||
timestamp: String,
|
||||
}
|
||||
use crate::request_context::RequestContext;
|
||||
|
||||
// 当前阶段先把成功响应 envelope helper 准备好,后续 `/healthz` 与业务 handler 会直接复用这里的输出逻辑。
|
||||
#[allow(dead_code)]
|
||||
@@ -30,12 +20,13 @@ where
|
||||
if let Some(context) = request_context
|
||||
&& context.wants_envelope()
|
||||
{
|
||||
return Json(json!({
|
||||
"ok": true,
|
||||
"data": data,
|
||||
"error": null,
|
||||
"meta": build_api_response_meta(Some(context)),
|
||||
}));
|
||||
return Json(
|
||||
serde_json::to_value(ApiSuccessEnvelope::new(
|
||||
data,
|
||||
build_api_response_meta(Some(context)),
|
||||
))
|
||||
.unwrap_or(Value::Null),
|
||||
);
|
||||
}
|
||||
|
||||
Json(serde_json::to_value(data).unwrap_or(Value::Null))
|
||||
@@ -48,33 +39,30 @@ pub fn json_error_body(
|
||||
let meta = build_api_response_meta(request_context);
|
||||
|
||||
if request_context.is_some_and(RequestContext::wants_envelope) {
|
||||
return Json(json!({
|
||||
"ok": false,
|
||||
"data": null,
|
||||
"error": error,
|
||||
"meta": meta,
|
||||
}));
|
||||
return Json(
|
||||
serde_json::to_value(ApiErrorEnvelope::new(error.clone(), meta)).unwrap_or(Value::Null),
|
||||
);
|
||||
}
|
||||
|
||||
Json(json!({
|
||||
"error": error,
|
||||
"meta": meta,
|
||||
}))
|
||||
Json(
|
||||
serde_json::to_value(LegacyApiErrorResponse::new(error.clone(), meta))
|
||||
.unwrap_or(Value::Null),
|
||||
)
|
||||
}
|
||||
|
||||
fn build_api_response_meta(request_context: Option<&RequestContext>) -> ApiResponseMeta {
|
||||
ApiResponseMeta {
|
||||
api_version: API_VERSION,
|
||||
request_id: request_context.map(|context| context.request_id().to_string()),
|
||||
route_version: API_VERSION,
|
||||
operation: request_context.map(|context| context.operation().to_string()),
|
||||
latency_ms: request_context
|
||||
ApiResponseMeta::new(
|
||||
API_VERSION,
|
||||
request_context.map(|context| context.request_id().to_string()),
|
||||
API_VERSION,
|
||||
request_context.map(|context| context.operation().to_string()),
|
||||
request_context
|
||||
.map(RequestContext::elapsed)
|
||||
.unwrap_or_default(),
|
||||
timestamp: OffsetDateTime::now_utc()
|
||||
OffsetDateTime::now_utc()
|
||||
.format(&Rfc3339)
|
||||
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -122,7 +110,7 @@ mod tests {
|
||||
fn error_body_returns_legacy_shape_without_envelope_header() {
|
||||
let request_context = build_request_context(false);
|
||||
let error = ApiErrorPayload {
|
||||
code: "NOT_FOUND",
|
||||
code: "NOT_FOUND".to_string(),
|
||||
message: "资源不存在".to_string(),
|
||||
details: None,
|
||||
};
|
||||
|
||||
@@ -10,6 +10,10 @@ use tower_http::trace::{DefaultOnFailure, DefaultOnRequest, DefaultOnResponse, T
|
||||
use tracing::{Level, info_span};
|
||||
|
||||
use crate::{
|
||||
ai_tasks::{
|
||||
append_ai_text_chunk, attach_ai_result_reference, cancel_ai_task, complete_ai_stage,
|
||||
complete_ai_task, create_ai_task, fail_ai_task, start_ai_task, start_ai_task_stage,
|
||||
},
|
||||
assets::{
|
||||
bind_asset_object_to_entity, confirm_asset_object, create_direct_upload_ticket,
|
||||
create_sts_upload_credentials, get_asset_read_url,
|
||||
@@ -20,8 +24,18 @@ use crate::{
|
||||
},
|
||||
auth_me::auth_me,
|
||||
auth_sessions::auth_sessions,
|
||||
custom_world::{
|
||||
create_custom_world_agent_session, execute_custom_world_agent_action,
|
||||
get_custom_world_agent_operation, get_custom_world_agent_session,
|
||||
get_custom_world_gallery_detail, get_custom_world_library,
|
||||
get_custom_world_library_detail, list_custom_world_gallery,
|
||||
publish_custom_world_library_profile, put_custom_world_library_profile,
|
||||
stream_custom_world_agent_message, submit_custom_world_agent_message,
|
||||
unpublish_custom_world_library_profile,
|
||||
},
|
||||
error_middleware::normalize_error_response,
|
||||
health::health_check,
|
||||
llm::proxy_llm_chat_completions,
|
||||
login_options::auth_login_options,
|
||||
logout::logout,
|
||||
logout_all::logout_all,
|
||||
@@ -30,7 +44,18 @@ use crate::{
|
||||
refresh_session::refresh_session,
|
||||
request_context::{attach_request_context, resolve_request_id},
|
||||
response_headers::propagate_request_id_header,
|
||||
runtime_browse_history::{
|
||||
delete_runtime_browse_history, get_runtime_browse_history, post_runtime_browse_history,
|
||||
},
|
||||
runtime_inventory::get_runtime_inventory_state,
|
||||
runtime_profile::{get_profile_dashboard, get_profile_play_stats, get_profile_wallet_ledger},
|
||||
runtime_settings::{get_runtime_settings, put_runtime_settings},
|
||||
runtime_story::resolve_runtime_story_state,
|
||||
state::AppState,
|
||||
story_battles::{
|
||||
create_story_battle, create_story_npc_battle, get_story_battle_state, resolve_story_battle,
|
||||
},
|
||||
story_sessions::{begin_story_session, continue_story, get_story_session_state},
|
||||
wechat_auth::{bind_wechat_phone, handle_wechat_callback, start_wechat_login},
|
||||
};
|
||||
|
||||
@@ -95,6 +120,13 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/llm/chat/completions",
|
||||
post(proxy_llm_chat_completions).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/auth/logout",
|
||||
post(logout)
|
||||
@@ -114,6 +146,69 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/ai/tasks",
|
||||
post(create_ai_task).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/ai/tasks/{task_id}/start",
|
||||
post(start_ai_task).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/ai/tasks/{task_id}/stages/{stage_kind}/start",
|
||||
post(start_ai_task_stage).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/ai/tasks/{task_id}/chunks",
|
||||
post(append_ai_text_chunk).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/ai/tasks/{task_id}/stages/{stage_kind}/complete",
|
||||
post(complete_ai_stage).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/ai/tasks/{task_id}/references",
|
||||
post(attach_ai_result_reference).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/ai/tasks/{task_id}/complete",
|
||||
post(complete_ai_task).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/ai/tasks/{task_id}/fail",
|
||||
post(fail_ai_task).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/ai/tasks/{task_id}/cancel",
|
||||
post(cancel_ai_task).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/direct-upload-tickets",
|
||||
post(create_direct_upload_ticket),
|
||||
@@ -128,6 +223,219 @@ pub fn build_router(state: AppState) -> Router {
|
||||
post(bind_asset_object_to_entity),
|
||||
)
|
||||
.route("/api/assets/read-url", get(get_asset_read_url))
|
||||
.route(
|
||||
"/api/runtime/settings",
|
||||
get(get_runtime_settings)
|
||||
.put(put_runtime_settings)
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world-library",
|
||||
get(get_custom_world_library).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world-library/{profile_id}",
|
||||
get(get_custom_world_library_detail)
|
||||
.put(put_custom_world_library_profile)
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world-library/{profile_id}/publish",
|
||||
post(publish_custom_world_library_profile).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world-library/{profile_id}/unpublish",
|
||||
post(unpublish_custom_world_library_profile).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world-gallery",
|
||||
get(list_custom_world_gallery),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}",
|
||||
get(get_custom_world_gallery_detail),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world/agent/sessions",
|
||||
post(create_custom_world_agent_session).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world/agent/sessions/{session_id}",
|
||||
get(get_custom_world_agent_session).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world/agent/sessions/{session_id}/messages",
|
||||
post(submit_custom_world_agent_message).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world/agent/sessions/{session_id}/messages/stream",
|
||||
post(stream_custom_world_agent_message).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world/agent/sessions/{session_id}/actions",
|
||||
post(execute_custom_world_agent_action).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world/agent/sessions/{session_id}/operations/{operation_id}",
|
||||
get(get_custom_world_agent_operation).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/profile/browse-history",
|
||||
get(get_runtime_browse_history)
|
||||
.post(post_runtime_browse_history)
|
||||
.delete(delete_runtime_browse_history)
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/profile/browse-history",
|
||||
get(get_runtime_browse_history)
|
||||
.post(post_runtime_browse_history)
|
||||
.delete(delete_runtime_browse_history)
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/profile/dashboard",
|
||||
get(get_profile_dashboard).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/profile/dashboard",
|
||||
get(get_profile_dashboard).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/profile/wallet-ledger",
|
||||
get(get_profile_wallet_ledger).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/profile/wallet-ledger",
|
||||
get(get_profile_wallet_ledger).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/profile/play-stats",
|
||||
get(get_profile_play_stats).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/sessions/{runtime_session_id}/inventory",
|
||||
get(get_runtime_inventory_state).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/story/state/resolve",
|
||||
post(resolve_runtime_story_state).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/profile/play-stats",
|
||||
get(get_profile_play_stats).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/story/sessions",
|
||||
post(begin_story_session).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/story/sessions/{story_session_id}/state",
|
||||
get(get_story_session_state).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/story/sessions/continue",
|
||||
post(continue_story).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/story/battles",
|
||||
post(create_story_battle).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/story/battles/{battle_state_id}",
|
||||
get(get_story_battle_state).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/story/npc/battle",
|
||||
post(create_story_npc_battle).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/story/battles/resolve",
|
||||
post(resolve_story_battle).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route("/api/auth/entry", post(password_entry))
|
||||
// 错误归一化层放在 tracing 里侧,让 tracing 记录到最终对外返回的状态与错误体形态。
|
||||
.layer(middleware::from_fn(normalize_error_response))
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, Query, State},
|
||||
@@ -14,8 +12,13 @@ use platform_oss::{
|
||||
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPostObjectRequest,
|
||||
OssSignedGetObjectUrlRequest,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::assets::{
|
||||
AssetBindingPayload, AssetObjectPayload, AssetReadUrlPayload, BindAssetObjectRequest,
|
||||
BindAssetObjectResponse, ConfirmAssetObjectAccessPolicy, ConfirmAssetObjectRequest,
|
||||
ConfirmAssetObjectResponse, CreateDirectUploadTicketRequest, CreateDirectUploadTicketResponse,
|
||||
DirectUploadTicketPayload, GetAssetReadUrlResponse, GetReadUrlQuery,
|
||||
};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
|
||||
use crate::{
|
||||
@@ -23,84 +26,6 @@ use crate::{
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateDirectUploadTicketRequest {
|
||||
pub legacy_prefix: String,
|
||||
#[serde(default)]
|
||||
pub path_segments: Vec<String>,
|
||||
pub file_name: String,
|
||||
#[serde(default)]
|
||||
pub content_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub access: Option<OssObjectAccess>,
|
||||
#[serde(default)]
|
||||
pub metadata: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub max_size_bytes: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub expire_seconds: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub success_action_status: Option<u16>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetReadUrlQuery {
|
||||
#[serde(default)]
|
||||
pub object_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub legacy_public_path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub expire_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConfirmAssetObjectRequest {
|
||||
#[serde(default)]
|
||||
pub bucket: Option<String>,
|
||||
pub object_key: String,
|
||||
#[serde(default)]
|
||||
pub content_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub content_length: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub content_hash: Option<String>,
|
||||
pub asset_kind: String,
|
||||
#[serde(default)]
|
||||
pub access_policy: Option<ConfirmAssetObjectAccessPolicy>,
|
||||
#[serde(default)]
|
||||
pub source_job_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub owner_user_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub profile_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub entity_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BindAssetObjectRequest {
|
||||
pub asset_object_id: String,
|
||||
pub entity_kind: String,
|
||||
pub entity_id: String,
|
||||
pub slot: String,
|
||||
pub asset_kind: String,
|
||||
#[serde(default)]
|
||||
pub owner_user_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub profile_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConfirmAssetObjectAccessPolicy {
|
||||
Private,
|
||||
PublicRead,
|
||||
}
|
||||
|
||||
pub async fn create_direct_upload_ticket(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -141,9 +66,9 @@ pub async fn create_direct_upload_ticket(
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
json!({
|
||||
"upload": signed,
|
||||
}),
|
||||
CreateDirectUploadTicketResponse {
|
||||
upload: DirectUploadTicketPayload::from(signed),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
@@ -180,9 +105,9 @@ pub async fn get_asset_read_url(
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
json!({
|
||||
"read": signed,
|
||||
}),
|
||||
GetAssetReadUrlResponse {
|
||||
read: AssetReadUrlPayload::from(signed),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
@@ -223,25 +148,25 @@ pub async fn confirm_asset_object(
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
json!({
|
||||
"assetObject": {
|
||||
"assetObjectId": result.asset_object_id,
|
||||
"bucket": result.bucket,
|
||||
"objectKey": result.object_key,
|
||||
"accessPolicy": result.access_policy.as_str(),
|
||||
"contentType": result.content_type,
|
||||
"contentLength": result.content_length,
|
||||
"contentHash": result.content_hash,
|
||||
"version": result.version,
|
||||
"sourceJobId": result.source_job_id,
|
||||
"ownerUserId": result.owner_user_id,
|
||||
"profileId": result.profile_id,
|
||||
"entityId": result.entity_id,
|
||||
"assetKind": result.asset_kind,
|
||||
"createdAt": result.created_at,
|
||||
"updatedAt": result.updated_at,
|
||||
}
|
||||
}),
|
||||
ConfirmAssetObjectResponse {
|
||||
asset_object: AssetObjectPayload {
|
||||
asset_object_id: result.asset_object_id,
|
||||
bucket: result.bucket,
|
||||
object_key: result.object_key,
|
||||
access_policy: result.access_policy.as_str().to_string(),
|
||||
content_type: result.content_type,
|
||||
content_length: result.content_length,
|
||||
content_hash: result.content_hash,
|
||||
version: result.version,
|
||||
source_job_id: result.source_job_id,
|
||||
owner_user_id: result.owner_user_id,
|
||||
profile_id: result.profile_id,
|
||||
entity_id: result.entity_id,
|
||||
asset_kind: result.asset_kind,
|
||||
created_at: result.created_at,
|
||||
updated_at: result.updated_at,
|
||||
},
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
@@ -272,20 +197,20 @@ pub async fn bind_asset_object_to_entity(
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
json!({
|
||||
"assetBinding": {
|
||||
"bindingId": result.binding_id,
|
||||
"assetObjectId": result.asset_object_id,
|
||||
"entityKind": result.entity_kind,
|
||||
"entityId": result.entity_id,
|
||||
"slot": result.slot,
|
||||
"assetKind": result.asset_kind,
|
||||
"ownerUserId": result.owner_user_id,
|
||||
"profileId": result.profile_id,
|
||||
"createdAt": result.created_at,
|
||||
"updatedAt": result.updated_at,
|
||||
}
|
||||
}),
|
||||
BindAssetObjectResponse {
|
||||
asset_binding: AssetBindingPayload {
|
||||
binding_id: result.binding_id,
|
||||
asset_object_id: result.asset_object_id,
|
||||
entity_kind: result.entity_kind,
|
||||
entity_id: result.entity_id,
|
||||
slot: result.slot,
|
||||
asset_kind: result.asset_kind,
|
||||
owner_user_id: result.owner_user_id,
|
||||
profile_id: result.profile_id,
|
||||
created_at: result.created_at,
|
||||
updated_at: result.updated_at,
|
||||
},
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
@@ -355,7 +280,7 @@ async fn build_confirm_asset_object_upsert_input(
|
||||
head.object_key,
|
||||
payload
|
||||
.access_policy
|
||||
.map(Into::into)
|
||||
.map(map_confirm_asset_object_access_policy)
|
||||
.unwrap_or(AssetObjectAccessPolicy::Private),
|
||||
head.content_type
|
||||
.or_else(|| normalize_optional_value(payload.content_type)),
|
||||
@@ -439,12 +364,12 @@ impl std::fmt::Display for ConfirmAssetObjectPrepareError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ConfirmAssetObjectAccessPolicy> for AssetObjectAccessPolicy {
|
||||
fn from(value: ConfirmAssetObjectAccessPolicy) -> Self {
|
||||
match value {
|
||||
ConfirmAssetObjectAccessPolicy::Private => Self::Private,
|
||||
ConfirmAssetObjectAccessPolicy::PublicRead => Self::PublicRead,
|
||||
}
|
||||
fn map_confirm_asset_object_access_policy(
|
||||
value: ConfirmAssetObjectAccessPolicy,
|
||||
) -> AssetObjectAccessPolicy {
|
||||
match value {
|
||||
ConfirmAssetObjectAccessPolicy::Private => AssetObjectAccessPolicy::Private,
|
||||
ConfirmAssetObjectAccessPolicy::PublicRead => AssetObjectAccessPolicy::PublicRead,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,8 +394,8 @@ mod tests {
|
||||
use reqwest::{Method, multipart};
|
||||
use serde_json::{Value, json};
|
||||
use sha1::Sha1;
|
||||
use shared_kernel::new_uuid_simple_string;
|
||||
use tower::ServiceExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{app::build_router, config::AppConfig, state::AppState};
|
||||
|
||||
@@ -885,7 +810,7 @@ mod tests {
|
||||
ensure_success_status(bucket_head.status().as_u16(), "bucket HEAD 应成功")?;
|
||||
|
||||
let app = build_router(AppState::new(config.clone()).expect("state should build"));
|
||||
let run_id = Uuid::new_v4().simple().to_string();
|
||||
let run_id = new_uuid_simple_string();
|
||||
let file_name = format!("oss-live-{run_id}.txt");
|
||||
let file_content = format!("Genarrative OSS Rust live test {run_id}");
|
||||
|
||||
@@ -1032,7 +957,7 @@ mod tests {
|
||||
|
||||
let test_result = async {
|
||||
let app = build_router(AppState::new(config.clone()).expect("state should build"));
|
||||
let run_id = Uuid::new_v4().simple().to_string();
|
||||
let run_id = new_uuid_simple_string();
|
||||
let file_content = format!("Genarrative confirm asset object live test {run_id}");
|
||||
|
||||
let ticket_response = app
|
||||
|
||||
@@ -3,32 +3,13 @@ use axum::{
|
||||
extract::{Extension, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use shared_contracts::auth::{AuthMeResponse, AuthUserPayload, build_available_login_methods};
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
request_context::RequestContext, state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthMeResponse {
|
||||
pub user: AuthMeUserPayload,
|
||||
pub available_login_methods: Vec<&'static str>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthMeUserPayload {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub phone_number_masked: Option<String>,
|
||||
pub login_method: &'static str,
|
||||
pub binding_status: &'static str,
|
||||
pub wechat_bound: bool,
|
||||
}
|
||||
|
||||
pub async fn auth_me(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -49,27 +30,19 @@ pub async fn auth_me(
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
AuthMeResponse {
|
||||
user: AuthMeUserPayload {
|
||||
user: AuthUserPayload {
|
||||
id: user.user.id,
|
||||
username: user.user.username,
|
||||
display_name: user.user.display_name,
|
||||
phone_number_masked: user.user.phone_number_masked,
|
||||
login_method: user.user.login_method.as_str(),
|
||||
binding_status: user.user.binding_status.as_str(),
|
||||
login_method: user.user.login_method.as_str().to_string(),
|
||||
binding_status: user.user.binding_status.as_str().to_string(),
|
||||
wechat_bound: user.user.wechat_bound,
|
||||
},
|
||||
available_login_methods: build_available_login_methods(&state),
|
||||
available_login_methods: build_available_login_methods(
|
||||
state.config.sms_auth_enabled,
|
||||
state.config.wechat_auth_enabled,
|
||||
),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn build_available_login_methods(state: &AppState) -> Vec<&'static str> {
|
||||
let mut methods = Vec::new();
|
||||
if state.config.sms_auth_enabled {
|
||||
methods.push("phone");
|
||||
}
|
||||
if state.config.wechat_auth_enabled {
|
||||
methods.push("wechat");
|
||||
}
|
||||
methods
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use axum::{
|
||||
http::StatusCode,
|
||||
};
|
||||
use platform_auth::hash_refresh_session_token;
|
||||
use serde::Serialize;
|
||||
use shared_contracts::auth::{AuthSessionSummaryPayload, AuthSessionsResponse};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{
|
||||
@@ -16,31 +16,6 @@ use crate::{
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthSessionsResponse {
|
||||
pub sessions: Vec<AuthSessionSummaryPayload>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthSessionSummaryPayload {
|
||||
pub session_id: String,
|
||||
pub client_type: String,
|
||||
pub client_runtime: String,
|
||||
pub client_platform: String,
|
||||
pub client_label: String,
|
||||
pub device_display_name: String,
|
||||
pub mini_program_app_id: Option<String>,
|
||||
pub mini_program_env: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub ip_masked: Option<String>,
|
||||
pub is_current: bool,
|
||||
pub created_at: String,
|
||||
pub last_seen_at: String,
|
||||
pub expires_at: String,
|
||||
}
|
||||
|
||||
pub async fn auth_sessions(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
use std::{env, net::SocketAddr};
|
||||
|
||||
use platform_llm::{
|
||||
DEFAULT_ARK_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
DEFAULT_RETRY_BACKOFF_MS, LlmProvider,
|
||||
};
|
||||
|
||||
const DEFAULT_LLM_MODEL: &str = "doubao-1-5-pro-32k-character-250715";
|
||||
|
||||
// 集中管理 api-server 的启动配置,避免入口层直接散落环境变量解析逻辑。
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AppConfig {
|
||||
@@ -40,6 +47,13 @@ pub struct AppConfig {
|
||||
pub spacetime_server_url: String,
|
||||
pub spacetime_database: String,
|
||||
pub spacetime_token: Option<String>,
|
||||
pub llm_provider: LlmProvider,
|
||||
pub llm_base_url: String,
|
||||
pub llm_api_key: Option<String>,
|
||||
pub llm_model: String,
|
||||
pub llm_request_timeout_ms: u64,
|
||||
pub llm_max_retries: u32,
|
||||
pub llm_retry_backoff_ms: u64,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
@@ -83,6 +97,13 @@ impl Default for AppConfig {
|
||||
spacetime_server_url: "http://127.0.0.1:3000".to_string(),
|
||||
spacetime_database: "genarrative-dev".to_string(),
|
||||
spacetime_token: None,
|
||||
llm_provider: LlmProvider::Ark,
|
||||
llm_base_url: DEFAULT_ARK_BASE_URL.to_string(),
|
||||
llm_api_key: None,
|
||||
llm_model: DEFAULT_LLM_MODEL.to_string(),
|
||||
llm_request_timeout_ms: DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
llm_max_retries: DEFAULT_MAX_RETRIES,
|
||||
llm_retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -244,6 +265,46 @@ impl AppConfig {
|
||||
|
||||
config.spacetime_token = read_first_non_empty_env(&["GENARRATIVE_SPACETIME_TOKEN"]);
|
||||
|
||||
if let Some(llm_provider) =
|
||||
read_first_llm_provider_env(&["GENARRATIVE_LLM_PROVIDER", "LLM_PROVIDER"])
|
||||
{
|
||||
config.llm_provider = llm_provider;
|
||||
}
|
||||
|
||||
if let Some(llm_base_url) =
|
||||
read_first_non_empty_env(&["GENARRATIVE_LLM_BASE_URL", "LLM_BASE_URL"])
|
||||
{
|
||||
config.llm_base_url = llm_base_url;
|
||||
}
|
||||
|
||||
config.llm_api_key =
|
||||
read_first_non_empty_env(&["GENARRATIVE_LLM_API_KEY", "LLM_API_KEY", "ARK_API_KEY"]);
|
||||
|
||||
if let Some(llm_model) =
|
||||
read_first_non_empty_env(&["GENARRATIVE_LLM_MODEL", "LLM_MODEL", "VITE_LLM_MODEL"])
|
||||
{
|
||||
config.llm_model = llm_model;
|
||||
}
|
||||
|
||||
if let Some(llm_request_timeout_ms) = read_first_positive_u64_env(&[
|
||||
"GENARRATIVE_LLM_REQUEST_TIMEOUT_MS",
|
||||
"LLM_REQUEST_TIMEOUT_MS",
|
||||
]) {
|
||||
config.llm_request_timeout_ms = llm_request_timeout_ms;
|
||||
}
|
||||
|
||||
if let Some(llm_max_retries) =
|
||||
read_first_u32_env(&["GENARRATIVE_LLM_MAX_RETRIES", "LLM_MAX_RETRIES"])
|
||||
{
|
||||
config.llm_max_retries = llm_max_retries;
|
||||
}
|
||||
|
||||
if let Some(llm_retry_backoff_ms) =
|
||||
read_first_u64_env(&["GENARRATIVE_LLM_RETRY_BACKOFF_MS", "LLM_RETRY_BACKOFF_MS"])
|
||||
{
|
||||
config.llm_retry_backoff_ms = llm_retry_backoff_ms;
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
@@ -281,6 +342,14 @@ fn read_first_bool_env(keys: &[&str]) -> Option<bool> {
|
||||
.find_map(|key| env::var(key).ok().and_then(|value| parse_bool(&value)))
|
||||
}
|
||||
|
||||
fn read_first_llm_provider_env(keys: &[&str]) -> Option<LlmProvider> {
|
||||
keys.iter().find_map(|key| {
|
||||
env::var(key)
|
||||
.ok()
|
||||
.and_then(|value| parse_llm_provider(&value))
|
||||
})
|
||||
}
|
||||
|
||||
fn read_first_positive_u32_env(keys: &[&str]) -> Option<u32> {
|
||||
keys.iter().find_map(|key| {
|
||||
env::var(key)
|
||||
@@ -297,6 +366,16 @@ fn read_first_positive_u64_env(keys: &[&str]) -> Option<u64> {
|
||||
})
|
||||
}
|
||||
|
||||
fn read_first_u32_env(keys: &[&str]) -> Option<u32> {
|
||||
keys.iter()
|
||||
.find_map(|key| env::var(key).ok().and_then(|value| parse_u32(&value)))
|
||||
}
|
||||
|
||||
fn read_first_u64_env(keys: &[&str]) -> Option<u64> {
|
||||
keys.iter()
|
||||
.find_map(|key| env::var(key).ok().and_then(|value| parse_u64(&value)))
|
||||
}
|
||||
|
||||
fn read_first_positive_u16_env(keys: &[&str]) -> Option<u16> {
|
||||
keys.iter().find_map(|key| {
|
||||
env::var(key)
|
||||
@@ -338,6 +417,15 @@ fn parse_bool(raw: &str) -> Option<bool> {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_llm_provider(raw: &str) -> Option<LlmProvider> {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"ark" => Some(LlmProvider::Ark),
|
||||
"dash_scope" | "dashscope" => Some(LlmProvider::DashScope),
|
||||
"openai_compatible" | "openai-compatible" | "openai" => Some(LlmProvider::OpenAiCompatible),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_positive_u32(raw: &str) -> Option<u32> {
|
||||
let value = raw.trim().parse::<u32>().ok()?;
|
||||
if value == 0 {
|
||||
@@ -347,6 +435,10 @@ fn parse_positive_u32(raw: &str) -> Option<u32> {
|
||||
Some(value)
|
||||
}
|
||||
|
||||
fn parse_u32(raw: &str) -> Option<u32> {
|
||||
raw.trim().parse::<u32>().ok()
|
||||
}
|
||||
|
||||
fn parse_positive_u64(raw: &str) -> Option<u64> {
|
||||
let value = raw.trim().parse::<u64>().ok()?;
|
||||
if value == 0 {
|
||||
@@ -356,6 +448,10 @@ fn parse_positive_u64(raw: &str) -> Option<u64> {
|
||||
Some(value)
|
||||
}
|
||||
|
||||
fn parse_u64(raw: &str) -> Option<u64> {
|
||||
raw.trim().parse::<u64>().ok()
|
||||
}
|
||||
|
||||
fn parse_positive_u16(raw: &str) -> Option<u16> {
|
||||
let value = raw.trim().parse::<u16>().ok()?;
|
||||
if value == 0 {
|
||||
|
||||
1015
server-rs/crates/api-server/src/custom_world.rs
Normal file
1015
server-rs/crates/api-server/src/custom_world.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,8 @@ use axum::{
|
||||
http::{HeaderMap, HeaderValue},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use shared_contracts::api::ApiErrorPayload;
|
||||
|
||||
use crate::{api_response::json_error_body, request_context::RequestContext};
|
||||
|
||||
@@ -17,14 +17,6 @@ pub struct AppError {
|
||||
headers: HeaderMap,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ApiErrorPayload {
|
||||
pub code: &'static str,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub details: Option<Value>,
|
||||
}
|
||||
|
||||
impl AppError {
|
||||
pub fn from_status(status_code: StatusCode) -> Self {
|
||||
let (code, message) = resolve_http_error(status_code);
|
||||
@@ -71,11 +63,7 @@ impl AppError {
|
||||
}
|
||||
|
||||
fn to_payload(&self) -> ApiErrorPayload {
|
||||
ApiErrorPayload {
|
||||
code: self.code,
|
||||
message: self.message.clone(),
|
||||
details: self.details.clone(),
|
||||
}
|
||||
ApiErrorPayload::new(self.code, self.message.clone(), self.details.clone())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +79,7 @@ fn resolve_http_error(status_code: StatusCode) -> (&'static str, &'static str) {
|
||||
StatusCode::UNAUTHORIZED => ("UNAUTHORIZED", "未授权访问"),
|
||||
StatusCode::FORBIDDEN => ("FORBIDDEN", "禁止访问"),
|
||||
StatusCode::NOT_FOUND => ("NOT_FOUND", "资源不存在"),
|
||||
StatusCode::NOT_IMPLEMENTED => ("NOT_IMPLEMENTED", "功能暂未实现"),
|
||||
StatusCode::CONFLICT => ("CONFLICT", "请求冲突"),
|
||||
StatusCode::TOO_MANY_REQUESTS => ("TOO_MANY_REQUESTS", "请求过于频繁"),
|
||||
StatusCode::BAD_GATEWAY => ("UPSTREAM_ERROR", "上游服务请求失败"),
|
||||
|
||||
376
server-rs/crates/api-server/src/llm.rs
Normal file
376
server-rs/crates/api-server/src/llm.rs
Normal file
@@ -0,0 +1,376 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, State},
|
||||
http::StatusCode,
|
||||
response::Response,
|
||||
};
|
||||
use platform_llm::{LlmError, LlmMessage, LlmMessageRole, LlmTextRequest};
|
||||
use serde_json::Value;
|
||||
use shared_contracts::llm::{
|
||||
LlmChatCompletionRequest, LlmChatCompletionResponse, LlmChatMessagePayload, LlmChatMessageRole,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
request_context::RequestContext, state::AppState,
|
||||
};
|
||||
|
||||
pub async fn proxy_llm_chat_completions(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<LlmChatCompletionRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
if payload.stream {
|
||||
return Err(llm_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::NOT_IMPLEMENTED)
|
||||
.with_message("Rust `api-server` 首版暂不支持流式 LLM 代理"),
|
||||
));
|
||||
}
|
||||
|
||||
let llm_client = state.llm_client().ok_or_else(|| {
|
||||
llm_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
|
||||
.with_message("服务端尚未配置可用的 LLM API Key"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let request = LlmTextRequest {
|
||||
model: payload.model,
|
||||
messages: payload
|
||||
.messages
|
||||
.into_iter()
|
||||
.map(map_chat_message)
|
||||
.collect::<Vec<_>>(),
|
||||
max_tokens: None,
|
||||
};
|
||||
|
||||
let response = llm_client
|
||||
.request_text(request)
|
||||
.await
|
||||
.map_err(|error| llm_error_response(&request_context, map_llm_error(error)))?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
LlmChatCompletionResponse {
|
||||
id: response.response_id,
|
||||
model: response.model,
|
||||
content: response.content,
|
||||
finish_reason: response.finish_reason,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn map_chat_message(message: LlmChatMessagePayload) -> LlmMessage {
|
||||
let role = match message.role {
|
||||
LlmChatMessageRole::System => LlmMessageRole::System,
|
||||
LlmChatMessageRole::User => LlmMessageRole::User,
|
||||
LlmChatMessageRole::Assistant => LlmMessageRole::Assistant,
|
||||
};
|
||||
|
||||
LlmMessage::new(role, message.content)
|
||||
}
|
||||
|
||||
fn map_llm_error(error: LlmError) -> AppError {
|
||||
match error {
|
||||
LlmError::InvalidRequest(message) => {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message(message)
|
||||
}
|
||||
LlmError::InvalidConfig(message) => {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message(message)
|
||||
}
|
||||
LlmError::Upstream {
|
||||
status_code: 429,
|
||||
message,
|
||||
} => AppError::from_status(StatusCode::TOO_MANY_REQUESTS).with_message(message),
|
||||
LlmError::Upstream { message, .. } => {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_message(message)
|
||||
}
|
||||
LlmError::Timeout { attempts } => AppError::from_status(StatusCode::BAD_GATEWAY)
|
||||
.with_message(format!("LLM 请求超时,累计尝试 {attempts} 次")),
|
||||
LlmError::Connectivity { attempts, message } => {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY)
|
||||
.with_message(format!("LLM 连接失败,累计尝试 {attempts} 次:{message}"))
|
||||
}
|
||||
LlmError::StreamUnavailable => {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_message("LLM 流式响应体不可用")
|
||||
}
|
||||
LlmError::EmptyResponse => {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_message("LLM 返回内容为空")
|
||||
}
|
||||
LlmError::Transport(message) | LlmError::Deserialize(message) => {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_message(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn llm_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
||||
error.into_response_with_context(Some(request_context))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
net::TcpListener,
|
||||
thread,
|
||||
time::Duration as StdDuration,
|
||||
};
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use platform_auth::{
|
||||
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use time::OffsetDateTime;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::{app::build_router, config::AppConfig, state::AppState};
|
||||
|
||||
struct MockResponse {
|
||||
status_line: &'static str,
|
||||
content_type: &'static str,
|
||||
body: String,
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn llm_chat_completions_returns_non_stream_text_payload() {
|
||||
let server_url = spawn_mock_server(vec![MockResponse {
|
||||
status_line: "200 OK",
|
||||
content_type: "application/json; charset=utf-8",
|
||||
body: r#"{"id":"resp_api_server_01","model":"ark-router-test","choices":[{"message":{"content":"代理成功"},"finish_reason":"stop"}]}"#.to_string(),
|
||||
}]);
|
||||
let state = seed_authenticated_state(AppConfig {
|
||||
llm_base_url: server_url,
|
||||
llm_api_key: Some("test-key".to_string()),
|
||||
..AppConfig::default()
|
||||
})
|
||||
.await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/llm/chat/completions")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"messages": [
|
||||
{ "role": "system", "content": "系统" },
|
||||
{ "role": "user", "content": "用户" }
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(true));
|
||||
assert_eq!(
|
||||
payload["data"]["id"],
|
||||
Value::String("resp_api_server_01".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
payload["data"]["model"],
|
||||
Value::String("ark-router-test".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
payload["data"]["content"],
|
||||
Value::String("代理成功".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
payload["data"]["finishReason"],
|
||||
Value::String("stop".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn llm_chat_completions_rejects_stream_mode() {
|
||||
let state = seed_authenticated_state(AppConfig::default()).await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/llm/chat/completions")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"stream": true,
|
||||
"messages": [
|
||||
{ "role": "user", "content": "用户" }
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(false));
|
||||
assert_eq!(
|
||||
payload["error"]["code"],
|
||||
Value::String("NOT_IMPLEMENTED".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
async fn seed_authenticated_state(config: AppConfig) -> AppState {
|
||||
let state = AppState::new(config).expect("state should build");
|
||||
state
|
||||
.password_entry_service()
|
||||
.execute(module_auth::PasswordEntryInput {
|
||||
username: "llm_proxy_user".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("seed login should succeed");
|
||||
state
|
||||
}
|
||||
|
||||
fn issue_access_token(state: &AppState) -> String {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: "sess_llm_proxy".to_string(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 1,
|
||||
phone_verified: true,
|
||||
binding_status: BindingStatus::Active,
|
||||
display_name: Some("LLM 代理用户".to_string()),
|
||||
},
|
||||
state.auth_jwt_config(),
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.expect("claims should build");
|
||||
|
||||
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
|
||||
}
|
||||
|
||||
fn spawn_mock_server(responses: Vec<MockResponse>) -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
|
||||
let address = listener.local_addr().expect("listener should have addr");
|
||||
|
||||
thread::spawn(move || {
|
||||
for response in responses {
|
||||
let (mut stream, _) = listener.accept().expect("request should connect");
|
||||
read_request(&mut stream);
|
||||
write_response(&mut stream, response);
|
||||
}
|
||||
});
|
||||
|
||||
format!("http://{address}")
|
||||
}
|
||||
|
||||
fn read_request(stream: &mut std::net::TcpStream) {
|
||||
stream
|
||||
.set_read_timeout(Some(StdDuration::from_secs(1)))
|
||||
.expect("read timeout should be set");
|
||||
let mut buffer = Vec::new();
|
||||
let mut chunk = [0_u8; 1024];
|
||||
let mut expected_total = None;
|
||||
|
||||
loop {
|
||||
match stream.read(&mut chunk) {
|
||||
Ok(0) => break,
|
||||
Ok(bytes_read) => {
|
||||
buffer.extend_from_slice(&chunk[..bytes_read]);
|
||||
|
||||
if expected_total.is_none()
|
||||
&& let Some(header_end) = find_header_end(&buffer)
|
||||
{
|
||||
let content_length =
|
||||
read_content_length(&buffer[..header_end]).unwrap_or(0);
|
||||
expected_total = Some(header_end + content_length);
|
||||
}
|
||||
|
||||
if let Some(total_bytes) = expected_total
|
||||
&& buffer.len() >= total_bytes
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(error)
|
||||
if error.kind() == std::io::ErrorKind::WouldBlock
|
||||
|| error.kind() == std::io::ErrorKind::TimedOut =>
|
||||
{
|
||||
break;
|
||||
}
|
||||
Err(error) => panic!("mock server failed to read request: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_response(stream: &mut std::net::TcpStream, response: MockResponse) {
|
||||
let body = response.body;
|
||||
let raw_response = format!(
|
||||
"HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
response.status_line,
|
||||
response.content_type,
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
|
||||
stream
|
||||
.write_all(raw_response.as_bytes())
|
||||
.expect("mock response should be written");
|
||||
stream.flush().expect("mock response should flush");
|
||||
}
|
||||
|
||||
fn find_header_end(buffer: &[u8]) -> Option<usize> {
|
||||
buffer
|
||||
.windows(4)
|
||||
.position(|window| window == b"\r\n\r\n")
|
||||
.map(|index| index + 4)
|
||||
}
|
||||
|
||||
fn read_content_length(headers: &[u8]) -> Option<usize> {
|
||||
let text = String::from_utf8_lossy(headers);
|
||||
text.lines().find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
if name.eq_ignore_ascii_case("content-length") {
|
||||
return value.trim().parse::<usize>().ok();
|
||||
}
|
||||
None
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,32 +2,21 @@ use axum::{
|
||||
Json,
|
||||
extract::{Extension, State},
|
||||
};
|
||||
use serde::Serialize;
|
||||
use shared_contracts::auth::{AuthLoginOptionsResponse, build_available_login_methods};
|
||||
|
||||
use crate::{api_response::json_success_body, request_context::RequestContext, state::AppState};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthLoginOptionsResponse {
|
||||
pub available_login_methods: Vec<&'static str>,
|
||||
}
|
||||
|
||||
pub async fn auth_login_options(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let mut methods = Vec::new();
|
||||
if state.config.sms_auth_enabled {
|
||||
methods.push("phone");
|
||||
}
|
||||
if state.config.wechat_auth_enabled {
|
||||
methods.push("wechat");
|
||||
}
|
||||
|
||||
json_success_body(
|
||||
Some(&request_context),
|
||||
AuthLoginOptionsResponse {
|
||||
available_login_methods: methods,
|
||||
available_login_methods: build_available_login_methods(
|
||||
state.config.sms_auth_enabled,
|
||||
state.config.wechat_auth_enabled,
|
||||
),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use axum::{
|
||||
};
|
||||
use module_auth::LogoutCurrentSessionInput;
|
||||
use platform_auth::hash_refresh_session_token;
|
||||
use serde::Serialize;
|
||||
use shared_contracts::auth::LogoutResponse;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{
|
||||
@@ -19,11 +19,6 @@ use crate::{
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LogoutResponse {
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
pub async fn logout(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
|
||||
@@ -4,7 +4,7 @@ use axum::{
|
||||
response::IntoResponse,
|
||||
};
|
||||
use module_auth::LogoutAllSessionsInput;
|
||||
use serde::Serialize;
|
||||
use shared_contracts::auth::LogoutAllResponse;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{
|
||||
@@ -18,11 +18,6 @@ use crate::{
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LogoutAllResponse {
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
pub async fn logout_all(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod ai_tasks;
|
||||
mod api_response;
|
||||
mod app;
|
||||
mod assets;
|
||||
@@ -6,9 +7,11 @@ mod auth_me;
|
||||
mod auth_session;
|
||||
mod auth_sessions;
|
||||
mod config;
|
||||
mod custom_world;
|
||||
mod error_middleware;
|
||||
mod health;
|
||||
mod http_error;
|
||||
mod llm;
|
||||
mod login_options;
|
||||
mod logout;
|
||||
mod logout_all;
|
||||
@@ -17,8 +20,15 @@ mod phone_auth;
|
||||
mod refresh_session;
|
||||
mod request_context;
|
||||
mod response_headers;
|
||||
mod runtime_browse_history;
|
||||
mod runtime_inventory;
|
||||
mod runtime_profile;
|
||||
mod runtime_settings;
|
||||
mod runtime_story;
|
||||
mod session_client;
|
||||
mod state;
|
||||
mod story_battles;
|
||||
mod story_sessions;
|
||||
mod wechat_auth;
|
||||
mod wechat_provider;
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ use axum::{
|
||||
response::IntoResponse,
|
||||
};
|
||||
use module_auth::{PasswordEntryError, PasswordEntryInput};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use shared_contracts::auth::{AuthUserPayload, PasswordEntryRequest, PasswordEntryResponse};
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body,
|
||||
@@ -19,32 +19,6 @@ use crate::{
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasswordEntryRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasswordEntryResponse {
|
||||
pub token: String,
|
||||
pub user: PasswordEntryUserPayload,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasswordEntryUserPayload {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub phone_number_masked: Option<String>,
|
||||
pub login_method: &'static str,
|
||||
pub binding_status: &'static str,
|
||||
pub wechat_bound: bool,
|
||||
}
|
||||
|
||||
pub async fn password_entry(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -74,13 +48,13 @@ pub async fn password_entry(
|
||||
Some(&request_context),
|
||||
PasswordEntryResponse {
|
||||
token: signed_session.access_token,
|
||||
user: PasswordEntryUserPayload {
|
||||
user: AuthUserPayload {
|
||||
id: result.user.id,
|
||||
username: result.user.username,
|
||||
display_name: result.user.display_name,
|
||||
phone_number_masked: result.user.phone_number_masked,
|
||||
login_method: result.user.login_method.as_str(),
|
||||
binding_status: result.user.binding_status.as_str(),
|
||||
login_method: result.user.login_method.as_str().to_string(),
|
||||
binding_status: result.user.binding_status.as_str().to_string(),
|
||||
wechat_bound: result.user.wechat_bound,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,8 +7,11 @@ use axum::{
|
||||
use module_auth::{
|
||||
AuthLoginMethod, PhoneAuthError, PhoneAuthScene, PhoneLoginInput, SendPhoneCodeInput,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use shared_contracts::auth::{
|
||||
AuthUserPayload, PhoneLoginRequest, PhoneLoginResponse, PhoneSendCodeRequest,
|
||||
PhoneSendCodeResponse,
|
||||
};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{
|
||||
@@ -17,42 +20,11 @@ use crate::{
|
||||
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
|
||||
},
|
||||
http_error::AppError,
|
||||
password_entry::PasswordEntryUserPayload,
|
||||
request_context::RequestContext,
|
||||
session_client::resolve_session_client_context,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PhoneSendCodeRequest {
|
||||
pub phone: String,
|
||||
pub scene: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PhoneSendCodeResponse {
|
||||
pub ok: bool,
|
||||
pub cooldown_seconds: u64,
|
||||
pub expires_in_seconds: u64,
|
||||
pub provider_request_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PhoneLoginRequest {
|
||||
pub phone: String,
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PhoneLoginResponse {
|
||||
pub token: String,
|
||||
pub user: PasswordEntryUserPayload,
|
||||
}
|
||||
|
||||
pub async fn send_phone_code(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -130,13 +102,13 @@ pub async fn phone_login(
|
||||
Some(&request_context),
|
||||
PhoneLoginResponse {
|
||||
token: signed_session.access_token,
|
||||
user: PasswordEntryUserPayload {
|
||||
user: AuthUserPayload {
|
||||
id: result.user.id,
|
||||
username: result.user.username,
|
||||
display_name: result.user.display_name,
|
||||
phone_number_masked: result.user.phone_number_masked,
|
||||
login_method: result.user.login_method.as_str(),
|
||||
binding_status: result.user.binding_status.as_str(),
|
||||
login_method: result.user.login_method.as_str().to_string(),
|
||||
binding_status: result.user.binding_status.as_str().to_string(),
|
||||
wechat_bound: result.user.wechat_bound,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ use axum::{
|
||||
};
|
||||
use module_auth::{RefreshSessionError, RotateRefreshSessionInput};
|
||||
use platform_auth::hash_refresh_session_token;
|
||||
use serde::Serialize;
|
||||
use shared_contracts::auth::RefreshSessionResponse;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{
|
||||
@@ -20,12 +20,6 @@ use crate::{
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RefreshSessionResponse {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
pub async fn refresh_session(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
|
||||
@@ -6,10 +6,10 @@ use axum::{
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use shared_contracts::api::API_RESPONSE_ENVELOPE_HEADER;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub const API_RESPONSE_ENVELOPE_HEADER: &str = "x-genarrative-response-envelope";
|
||||
pub const X_REQUEST_ID_HEADER: &str = "x-request-id";
|
||||
pub use shared_contracts::api::X_REQUEST_ID_HEADER;
|
||||
|
||||
// 当前阶段先把请求级元信息统一挂到 extensions,后续响应头、envelope 与错误处理中间件继续复用。
|
||||
#[derive(Clone, Debug)]
|
||||
|
||||
@@ -4,15 +4,11 @@ use axum::{
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
api_response::API_VERSION,
|
||||
request_context::{RequestContext, X_REQUEST_ID_HEADER, resolve_request_id},
|
||||
use shared_contracts::api::{
|
||||
API_VERSION, API_VERSION_HEADER, RESPONSE_TIME_HEADER, ROUTE_VERSION_HEADER,
|
||||
};
|
||||
|
||||
pub const API_VERSION_HEADER: &str = "x-api-version";
|
||||
pub const RESPONSE_TIME_HEADER: &str = "x-response-time-ms";
|
||||
pub const ROUTE_VERSION_HEADER: &str = "x-route-version";
|
||||
use crate::request_context::{RequestContext, X_REQUEST_ID_HEADER, resolve_request_id};
|
||||
|
||||
pub async fn propagate_request_id_header(request: Request, next: Next) -> Response {
|
||||
let request_id = resolve_request_id(&request);
|
||||
|
||||
454
server-rs/crates/api-server/src/runtime_browse_history.rs
Normal file
454
server-rs/crates/api-server/src/runtime_browse_history.rs
Normal file
@@ -0,0 +1,454 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, State, rejection::JsonRejection},
|
||||
http::StatusCode,
|
||||
response::Response,
|
||||
};
|
||||
use module_runtime::{MAX_BROWSE_HISTORY_BATCH_SIZE, RuntimeBrowseHistoryWriteInput};
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::runtime::{
|
||||
BROWSE_HISTORY_THEME_MODE_ARCANE, BROWSE_HISTORY_THEME_MODE_MACHINA,
|
||||
BROWSE_HISTORY_THEME_MODE_MARTIAL, BROWSE_HISTORY_THEME_MODE_MYTHIC,
|
||||
BROWSE_HISTORY_THEME_MODE_RIFT, BROWSE_HISTORY_THEME_MODE_TIDE,
|
||||
PlatformBrowseHistoryEntryResponse, PlatformBrowseHistoryResponse,
|
||||
PlatformBrowseHistoryUpsertRequest, PlatformBrowseHistoryWriteEntryRequest,
|
||||
};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
request_context::RequestContext, state::AppState,
|
||||
};
|
||||
|
||||
pub async fn get_runtime_browse_history(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let user_id = authenticated.claims().user_id().to_string();
|
||||
let entries = state
|
||||
.spacetime_client()
|
||||
.list_platform_browse_history(user_id)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_browse_history_error_response(
|
||||
&request_context,
|
||||
map_runtime_browse_history_client_error(error),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
PlatformBrowseHistoryResponse {
|
||||
entries: entries
|
||||
.into_iter()
|
||||
.map(map_browse_history_entry_response)
|
||||
.collect(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn post_runtime_browse_history(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<PlatformBrowseHistoryUpsertRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = payload.map_err(|error| {
|
||||
runtime_browse_history_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "browse-history",
|
||||
"message": error.body_text(),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let now_micros = current_utc_micros();
|
||||
let user_id = authenticated.claims().user_id().to_string();
|
||||
let request_entries = payload.into_entries();
|
||||
validate_browse_history_request_entries(&request_context, &request_entries)?;
|
||||
let entries = request_entries
|
||||
.into_iter()
|
||||
.map(|entry| RuntimeBrowseHistoryWriteInput {
|
||||
owner_user_id: entry.owner_user_id,
|
||||
profile_id: entry.profile_id,
|
||||
world_name: entry.world_name,
|
||||
subtitle: entry.subtitle,
|
||||
summary_text: entry.summary_text,
|
||||
cover_image_src: entry.cover_image_src,
|
||||
theme_mode: entry.theme_mode,
|
||||
author_display_name: entry.author_display_name,
|
||||
visited_at: entry.visited_at,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let entries = state
|
||||
.spacetime_client()
|
||||
.upsert_platform_browse_history_entries(user_id, entries, now_micros)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_browse_history_error_response(
|
||||
&request_context,
|
||||
map_runtime_browse_history_client_error(error),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
PlatformBrowseHistoryResponse {
|
||||
entries: entries
|
||||
.into_iter()
|
||||
.map(map_browse_history_entry_response)
|
||||
.collect(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn delete_runtime_browse_history(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let user_id = authenticated.claims().user_id().to_string();
|
||||
let entries = state
|
||||
.spacetime_client()
|
||||
.clear_platform_browse_history(user_id)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_browse_history_error_response(
|
||||
&request_context,
|
||||
map_runtime_browse_history_client_error(error),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
PlatformBrowseHistoryResponse {
|
||||
entries: entries
|
||||
.into_iter()
|
||||
.map(map_browse_history_entry_response)
|
||||
.collect(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn map_browse_history_entry_response(
|
||||
entry: module_runtime::RuntimeBrowseHistoryRecord,
|
||||
) -> PlatformBrowseHistoryEntryResponse {
|
||||
PlatformBrowseHistoryEntryResponse {
|
||||
owner_user_id: entry.owner_user_id,
|
||||
profile_id: entry.profile_id,
|
||||
world_name: entry.world_name,
|
||||
subtitle: entry.subtitle,
|
||||
summary_text: entry.summary_text,
|
||||
cover_image_src: entry.cover_image_src,
|
||||
theme_mode: map_browse_history_theme_mode(entry.theme_mode).to_string(),
|
||||
author_display_name: entry.author_display_name,
|
||||
visited_at: entry.visited_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_browse_history_theme_mode(
|
||||
value: module_runtime::RuntimeBrowseHistoryThemeMode,
|
||||
) -> &'static str {
|
||||
match value {
|
||||
module_runtime::RuntimeBrowseHistoryThemeMode::Martial => BROWSE_HISTORY_THEME_MODE_MARTIAL,
|
||||
module_runtime::RuntimeBrowseHistoryThemeMode::Arcane => BROWSE_HISTORY_THEME_MODE_ARCANE,
|
||||
module_runtime::RuntimeBrowseHistoryThemeMode::Machina => BROWSE_HISTORY_THEME_MODE_MACHINA,
|
||||
module_runtime::RuntimeBrowseHistoryThemeMode::Tide => BROWSE_HISTORY_THEME_MODE_TIDE,
|
||||
module_runtime::RuntimeBrowseHistoryThemeMode::Rift => BROWSE_HISTORY_THEME_MODE_RIFT,
|
||||
module_runtime::RuntimeBrowseHistoryThemeMode::Mythic => BROWSE_HISTORY_THEME_MODE_MYTHIC,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_runtime_browse_history_client_error(error: SpacetimeClientError) -> AppError {
|
||||
let (status, provider) = match error {
|
||||
// 这类错误发生在 Rust 本地 DTO 构建阶段,语义上属于请求不合法,而不是下游不可用。
|
||||
SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "browse-history"),
|
||||
_ => (StatusCode::BAD_GATEWAY, "spacetimedb"),
|
||||
};
|
||||
|
||||
AppError::from_status(status).with_details(json!({
|
||||
"provider": provider,
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn runtime_browse_history_error_response(
|
||||
request_context: &RequestContext,
|
||||
error: AppError,
|
||||
) -> Response {
|
||||
error.into_response_with_context(Some(request_context))
|
||||
}
|
||||
|
||||
fn validate_browse_history_request_entries(
|
||||
request_context: &RequestContext,
|
||||
entries: &[PlatformBrowseHistoryWriteEntryRequest],
|
||||
) -> Result<(), Response> {
|
||||
if entries.len() > MAX_BROWSE_HISTORY_BATCH_SIZE {
|
||||
return Err(runtime_browse_history_error_response(
|
||||
request_context,
|
||||
browse_history_bad_request(format!(
|
||||
"entries 单次最多只允许 {} 条",
|
||||
MAX_BROWSE_HISTORY_BATCH_SIZE
|
||||
)),
|
||||
));
|
||||
}
|
||||
|
||||
for entry in entries {
|
||||
if entry.owner_user_id.trim().is_empty() {
|
||||
return Err(runtime_browse_history_error_response(
|
||||
request_context,
|
||||
browse_history_bad_request("ownerUserId 不能为空"),
|
||||
));
|
||||
}
|
||||
if entry.profile_id.trim().is_empty() {
|
||||
return Err(runtime_browse_history_error_response(
|
||||
request_context,
|
||||
browse_history_bad_request("profileId 不能为空"),
|
||||
));
|
||||
}
|
||||
if entry.world_name.trim().is_empty() {
|
||||
return Err(runtime_browse_history_error_response(
|
||||
request_context,
|
||||
browse_history_bad_request("worldName 不能为空"),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn browse_history_bad_request(message: impl Into<String>) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "browse-history",
|
||||
"message": message.into(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn current_utc_micros() -> i64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
let duration = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system clock should be after unix epoch");
|
||||
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use platform_auth::{
|
||||
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use time::OffsetDateTime;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::{app::build_router, config::AppConfig, state::AppState};
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_browse_history_requires_authentication() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/runtime/profile/browse-history")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_browse_history_rejects_blank_required_fields() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/runtime/profile/browse-history")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"ownerUserId": " ",
|
||||
"profileId": "profile-1",
|
||||
"worldName": "世界A"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(false));
|
||||
assert_eq!(
|
||||
payload["error"]["details"]["provider"],
|
||||
Value::String("browse-history".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_browse_history_accepts_batch_shape_and_surfaces_backend_failure_as_bad_gateway()
|
||||
{
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/runtime/profile/browse-history")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"entries": [{
|
||||
"ownerUserId": "owner-1",
|
||||
"profileId": "profile-1",
|
||||
"worldName": "世界A"
|
||||
}]
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(false));
|
||||
assert_eq!(
|
||||
payload["error"]["details"]["provider"],
|
||||
Value::String("spacetimedb".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_browse_history_compat_route_matches_main_route_error_shape() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let main_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/runtime/profile/browse-history")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
let compat_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/profile/browse-history")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(main_response.status(), compat_response.status());
|
||||
|
||||
let main_body = main_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let compat_body = compat_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let main_payload: Value =
|
||||
serde_json::from_slice(&main_body).expect("response body should be valid json");
|
||||
let compat_payload: Value =
|
||||
serde_json::from_slice(&compat_body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(
|
||||
main_payload["error"]["details"]["provider"],
|
||||
compat_payload["error"]["details"]["provider"]
|
||||
);
|
||||
}
|
||||
|
||||
async fn seed_authenticated_state() -> AppState {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state
|
||||
.password_entry_service()
|
||||
.execute(module_auth::PasswordEntryInput {
|
||||
username: "browse_history_user".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("seed login should succeed");
|
||||
state
|
||||
}
|
||||
|
||||
fn issue_access_token(state: &AppState) -> String {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: "sess_runtime_browse_history".to_string(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 1,
|
||||
phone_verified: true,
|
||||
binding_status: BindingStatus::Active,
|
||||
display_name: Some("浏览历史用户".to_string()),
|
||||
},
|
||||
state.auth_jwt_config(),
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.expect("claims should build");
|
||||
|
||||
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
|
||||
}
|
||||
}
|
||||
196
server-rs/crates/api-server/src/runtime_inventory.rs
Normal file
196
server-rs/crates/api-server/src/runtime_inventory.rs
Normal file
@@ -0,0 +1,196 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, Path, State},
|
||||
http::StatusCode,
|
||||
response::Response,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::runtime::{RuntimeInventorySlotResponse, RuntimeInventoryStateResponse};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
request_context::RequestContext, state::AppState,
|
||||
};
|
||||
|
||||
pub async fn get_runtime_inventory_state(
|
||||
State(state): State<AppState>,
|
||||
Path(runtime_session_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let actor_user_id = authenticated.claims().user_id().to_string();
|
||||
let record = state
|
||||
.spacetime_client()
|
||||
.get_runtime_inventory_state(runtime_session_id, actor_user_id)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_inventory_error_response(
|
||||
&request_context,
|
||||
map_runtime_inventory_client_error(error),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
RuntimeInventoryStateResponse {
|
||||
runtime_session_id: record.runtime_session_id,
|
||||
actor_user_id: record.actor_user_id,
|
||||
backpack_items: record
|
||||
.backpack_items
|
||||
.into_iter()
|
||||
.map(map_runtime_inventory_slot_response)
|
||||
.collect(),
|
||||
equipment_items: record
|
||||
.equipment_items
|
||||
.into_iter()
|
||||
.map(map_runtime_inventory_slot_response)
|
||||
.collect(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn map_runtime_inventory_slot_response(
|
||||
record: module_inventory::RuntimeInventorySlotRecord,
|
||||
) -> RuntimeInventorySlotResponse {
|
||||
RuntimeInventorySlotResponse {
|
||||
slot_id: record.slot_id,
|
||||
container_kind: record.container_kind,
|
||||
slot_key: record.slot_key,
|
||||
item_id: record.item_id,
|
||||
category: record.category,
|
||||
name: record.name,
|
||||
description: record.description,
|
||||
quantity: record.quantity,
|
||||
rarity: record.rarity,
|
||||
tags: record.tags,
|
||||
stackable: record.stackable,
|
||||
stack_key: record.stack_key,
|
||||
equipment_slot_id: record.equipment_slot_id,
|
||||
source_kind: record.source_kind,
|
||||
source_reference_id: record.source_reference_id,
|
||||
created_at: record.created_at,
|
||||
updated_at: record.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_runtime_inventory_client_error(error: SpacetimeClientError) -> AppError {
|
||||
let (status, provider) = match error {
|
||||
SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "runtime-inventory"),
|
||||
_ => (StatusCode::BAD_GATEWAY, "spacetimedb"),
|
||||
};
|
||||
|
||||
AppError::from_status(status).with_details(json!({
|
||||
"provider": provider,
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn runtime_inventory_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
||||
error.into_response_with_context(Some(request_context))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use platform_auth::{
|
||||
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use time::OffsetDateTime;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::{app::build_router, config::AppConfig, state::AppState};
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_inventory_requires_authentication() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/runtime/sessions/runtime_001/inventory")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_inventory_returns_bad_gateway_when_spacetime_not_published() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/runtime/sessions/runtime_001/inventory")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(false));
|
||||
assert_eq!(
|
||||
payload["error"]["details"]["provider"],
|
||||
Value::String("spacetimedb".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
async fn seed_authenticated_state() -> AppState {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state
|
||||
.password_entry_service()
|
||||
.execute(module_auth::PasswordEntryInput {
|
||||
username: "runtime_inventory_user".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("seed login should succeed");
|
||||
state
|
||||
}
|
||||
|
||||
fn issue_access_token(state: &AppState) -> String {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: "sess_runtime_inventory".to_string(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 1,
|
||||
phone_verified: true,
|
||||
binding_status: BindingStatus::Active,
|
||||
display_name: Some("背包查询用户".to_string()),
|
||||
},
|
||||
state.auth_jwt_config(),
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.expect("claims should build");
|
||||
|
||||
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
|
||||
}
|
||||
}
|
||||
332
server-rs/crates/api-server/src/runtime_profile.rs
Normal file
332
server-rs/crates/api-server/src/runtime_profile.rs
Normal file
@@ -0,0 +1,332 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, State},
|
||||
http::StatusCode,
|
||||
response::Response,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::runtime::{
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse,
|
||||
ProfilePlayStatsResponse, ProfilePlayedWorkSummaryResponse, ProfileWalletLedgerEntryResponse,
|
||||
ProfileWalletLedgerResponse,
|
||||
};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
request_context::RequestContext, state::AppState,
|
||||
};
|
||||
|
||||
pub async fn get_profile_dashboard(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let user_id = authenticated.claims().user_id().to_string();
|
||||
let record = state
|
||||
.spacetime_client()
|
||||
.get_profile_dashboard(user_id)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_profile_error_response(
|
||||
&request_context,
|
||||
map_runtime_profile_client_error(error),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
ProfileDashboardSummaryResponse {
|
||||
wallet_balance: record.wallet_balance,
|
||||
total_play_time_ms: record.total_play_time_ms,
|
||||
played_world_count: record.played_world_count,
|
||||
updated_at: record.updated_at,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_profile_wallet_ledger(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let user_id = authenticated.claims().user_id().to_string();
|
||||
let entries = state
|
||||
.spacetime_client()
|
||||
.list_profile_wallet_ledger(user_id)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_profile_error_response(
|
||||
&request_context,
|
||||
map_runtime_profile_client_error(error),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
ProfileWalletLedgerResponse {
|
||||
entries: entries
|
||||
.into_iter()
|
||||
.map(|entry| ProfileWalletLedgerEntryResponse {
|
||||
id: entry.wallet_ledger_id,
|
||||
amount_delta: entry.amount_delta,
|
||||
balance_after: entry.balance_after,
|
||||
source_type: match entry.source_type {
|
||||
module_runtime::RuntimeProfileWalletLedgerSourceType::SnapshotSync => {
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC.to_string()
|
||||
}
|
||||
},
|
||||
created_at: entry.created_at,
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_profile_play_stats(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let user_id = authenticated.claims().user_id().to_string();
|
||||
let record = state
|
||||
.spacetime_client()
|
||||
.get_profile_play_stats(user_id)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_profile_error_response(
|
||||
&request_context,
|
||||
map_runtime_profile_client_error(error),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
ProfilePlayStatsResponse {
|
||||
total_play_time_ms: record.total_play_time_ms,
|
||||
played_works: record
|
||||
.played_works
|
||||
.into_iter()
|
||||
.map(|entry| ProfilePlayedWorkSummaryResponse {
|
||||
world_key: entry.world_key,
|
||||
owner_user_id: entry.owner_user_id,
|
||||
profile_id: entry.profile_id,
|
||||
world_type: entry.world_type,
|
||||
world_title: entry.world_title,
|
||||
world_subtitle: entry.world_subtitle,
|
||||
first_played_at: entry.first_played_at,
|
||||
last_played_at: entry.last_played_at,
|
||||
last_observed_play_time_ms: entry.last_observed_play_time_ms,
|
||||
})
|
||||
.collect(),
|
||||
updated_at: record.updated_at,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn map_runtime_profile_client_error(error: SpacetimeClientError) -> AppError {
|
||||
let (status, provider) = match error {
|
||||
SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "runtime-profile"),
|
||||
_ => (StatusCode::BAD_GATEWAY, "spacetimedb"),
|
||||
};
|
||||
|
||||
AppError::from_status(status).with_details(json!({
|
||||
"provider": provider,
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn runtime_profile_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
||||
error.into_response_with_context(Some(request_context))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use platform_auth::{
|
||||
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use time::OffsetDateTime;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::{app::build_router, config::AppConfig, state::AppState};
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_dashboard_requires_authentication() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/runtime/profile/dashboard")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_wallet_ledger_requires_authentication() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/runtime/profile/wallet-ledger")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_play_stats_requires_authentication() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/runtime/profile/play-stats")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_dashboard_compat_route_matches_main_route_error_shape() {
|
||||
assert_compat_route_matches_main_route_error_shape(
|
||||
"/api/runtime/profile/dashboard",
|
||||
"/api/profile/dashboard",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_wallet_ledger_compat_route_matches_main_route_error_shape() {
|
||||
assert_compat_route_matches_main_route_error_shape(
|
||||
"/api/runtime/profile/wallet-ledger",
|
||||
"/api/profile/wallet-ledger",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_play_stats_compat_route_matches_main_route_error_shape() {
|
||||
assert_compat_route_matches_main_route_error_shape(
|
||||
"/api/runtime/profile/play-stats",
|
||||
"/api/profile/play-stats",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn assert_compat_route_matches_main_route_error_shape(
|
||||
main_route: &str,
|
||||
compat_route: &str,
|
||||
) {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let main_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri(main_route)
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
let compat_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri(compat_route)
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(main_response.status(), compat_response.status());
|
||||
|
||||
let main_body = main_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let compat_body = compat_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let main_payload: Value =
|
||||
serde_json::from_slice(&main_body).expect("response body should be valid json");
|
||||
let compat_payload: Value =
|
||||
serde_json::from_slice(&compat_body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(
|
||||
main_payload["error"]["details"]["provider"],
|
||||
compat_payload["error"]["details"]["provider"]
|
||||
);
|
||||
}
|
||||
|
||||
async fn seed_authenticated_state() -> AppState {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state
|
||||
.password_entry_service()
|
||||
.execute(module_auth::PasswordEntryInput {
|
||||
username: "runtime_profile_user".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("seed login should succeed");
|
||||
state
|
||||
}
|
||||
|
||||
fn issue_access_token(state: &AppState) -> String {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: "sess_runtime_profile".to_string(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 1,
|
||||
phone_verified: true,
|
||||
binding_status: BindingStatus::Active,
|
||||
display_name: Some("资料页用户".to_string()),
|
||||
},
|
||||
state.auth_jwt_config(),
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.expect("claims should build");
|
||||
|
||||
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
|
||||
}
|
||||
}
|
||||
372
server-rs/crates/api-server/src/runtime_settings.rs
Normal file
372
server-rs/crates/api-server/src/runtime_settings.rs
Normal file
@@ -0,0 +1,372 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, State, rejection::JsonRejection},
|
||||
http::StatusCode,
|
||||
response::Response,
|
||||
};
|
||||
use module_runtime::{
|
||||
RuntimePlatformTheme, RuntimeSettingsFieldError, build_runtime_setting_upsert_input,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::runtime::{
|
||||
PutRuntimeSettingsRequest, RUNTIME_PLATFORM_THEME_DARK, RUNTIME_PLATFORM_THEME_LIGHT,
|
||||
RuntimeSettingsResponse,
|
||||
};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
request_context::RequestContext, state::AppState,
|
||||
};
|
||||
|
||||
pub async fn get_runtime_settings(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let user_id = authenticated.claims().user_id().to_string();
|
||||
let settings = state
|
||||
.spacetime_client()
|
||||
.get_runtime_settings(user_id)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_settings_error_response(
|
||||
&request_context,
|
||||
map_runtime_settings_client_error(error),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
RuntimeSettingsResponse {
|
||||
music_volume: settings.music_volume,
|
||||
platform_theme: settings.platform_theme.as_str().to_string(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn put_runtime_settings(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<PutRuntimeSettingsRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = payload.map_err(|error| {
|
||||
runtime_settings_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "runtime-settings",
|
||||
"message": error.body_text(),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let user_id = authenticated.claims().user_id().to_string();
|
||||
let theme = parse_platform_theme_strict(&payload.platform_theme).ok_or_else(|| {
|
||||
runtime_settings_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "runtime-settings",
|
||||
"message": "platformTheme 仅支持 light 或 dark",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
if !(0.0..=1.0).contains(&payload.music_volume) {
|
||||
return Err(runtime_settings_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "runtime-settings",
|
||||
"message": "musicVolume 必须在 0 到 1 之间",
|
||||
})),
|
||||
));
|
||||
}
|
||||
let now_micros = current_utc_micros();
|
||||
let prepared =
|
||||
build_runtime_setting_upsert_input(user_id, payload.music_volume, theme, now_micros)
|
||||
.map_err(|error| {
|
||||
runtime_settings_error_response(
|
||||
&request_context,
|
||||
map_runtime_settings_prepare_error(error),
|
||||
)
|
||||
})?;
|
||||
let settings = state
|
||||
.spacetime_client()
|
||||
.put_runtime_settings(
|
||||
prepared.user_id,
|
||||
prepared.music_volume,
|
||||
prepared.platform_theme,
|
||||
prepared.updated_at_micros,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_settings_error_response(
|
||||
&request_context,
|
||||
map_runtime_settings_client_error(error),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
RuntimeSettingsResponse {
|
||||
music_volume: settings.music_volume,
|
||||
platform_theme: settings.platform_theme.as_str().to_string(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn map_runtime_settings_prepare_error(error: RuntimeSettingsFieldError) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "runtime-settings",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_runtime_settings_client_error(error: SpacetimeClientError) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn runtime_settings_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
||||
error.into_response_with_context(Some(request_context))
|
||||
}
|
||||
|
||||
fn parse_platform_theme_strict(raw: &str) -> Option<RuntimePlatformTheme> {
|
||||
match raw.trim() {
|
||||
RUNTIME_PLATFORM_THEME_LIGHT => Some(RuntimePlatformTheme::Light),
|
||||
RUNTIME_PLATFORM_THEME_DARK => Some(RuntimePlatformTheme::Dark),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn current_utc_micros() -> i64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
let duration = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system clock should be after unix epoch");
|
||||
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use platform_auth::{
|
||||
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use time::OffsetDateTime;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::{app::build_router, config::AppConfig, state::AppState};
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_settings_requires_authentication() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/runtime/settings")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_settings_returns_bad_gateway_when_spacetime_not_published() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/runtime/settings")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(false));
|
||||
assert_eq!(
|
||||
payload["error"]["details"]["provider"],
|
||||
Value::String("spacetimedb".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_settings_rejects_invalid_theme_with_envelope() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/api/runtime/settings")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"musicVolume": 0.42,
|
||||
"platformTheme": "mythic"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(false));
|
||||
assert_eq!(
|
||||
payload["error"]["details"]["provider"],
|
||||
Value::String("runtime-settings".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "需要本地 SpacetimeDB xushi-p4wfr 已启动并发布当前 module;验证 PUT/GET settings 主链"]
|
||||
async fn runtime_settings_round_trip_against_local_spacetimedb() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let put_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/api/runtime/settings")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"musicVolume": 1.4,
|
||||
"platformTheme": "dark"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(put_response.status(), StatusCode::OK);
|
||||
let put_body = put_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let put_payload: Value =
|
||||
serde_json::from_slice(&put_body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(
|
||||
put_payload["data"]["platformTheme"],
|
||||
Value::String("dark".to_string())
|
||||
);
|
||||
assert_eq!(put_payload["data"]["musicVolume"], json!(1.0));
|
||||
|
||||
let get_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/runtime/settings")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(get_response.status(), StatusCode::OK);
|
||||
let get_body = get_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let get_payload: Value =
|
||||
serde_json::from_slice(&get_body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(
|
||||
get_payload["data"]["platformTheme"],
|
||||
Value::String("dark".to_string())
|
||||
);
|
||||
assert_eq!(get_payload["data"]["musicVolume"], json!(1.0));
|
||||
}
|
||||
|
||||
async fn seed_authenticated_state() -> AppState {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state
|
||||
.password_entry_service()
|
||||
.execute(module_auth::PasswordEntryInput {
|
||||
username: "runtime_settings_user".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("seed login should succeed");
|
||||
state
|
||||
}
|
||||
|
||||
fn issue_access_token(state: &AppState) -> String {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: "sess_runtime_settings".to_string(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 1,
|
||||
phone_verified: true,
|
||||
binding_status: BindingStatus::Active,
|
||||
display_name: Some("设置用户".to_string()),
|
||||
},
|
||||
state.auth_jwt_config(),
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.expect("claims should build");
|
||||
|
||||
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
|
||||
}
|
||||
}
|
||||
593
server-rs/crates/api-server/src/runtime_story.rs
Normal file
593
server-rs/crates/api-server/src/runtime_story.rs
Normal file
@@ -0,0 +1,593 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, State},
|
||||
http::StatusCode,
|
||||
response::Response,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::runtime_story::{
|
||||
RuntimeStoryActionResponse, RuntimeStoryCompanionViewModel, RuntimeStoryEncounterViewModel,
|
||||
RuntimeStoryOptionInteraction, RuntimeStoryOptionView, RuntimeStoryPlayerViewModel,
|
||||
RuntimeStoryPresentation, RuntimeStorySnapshotPayload, RuntimeStoryStateResolveRequest,
|
||||
RuntimeStoryStatusViewModel, RuntimeStoryViewModel,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
request_context::RequestContext, state::AppState,
|
||||
};
|
||||
|
||||
pub async fn resolve_runtime_story_state(
|
||||
State(_state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<RuntimeStoryStateResolveRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let session_id = normalize_required_string(payload.session_id.as_str()).ok_or_else(|| {
|
||||
runtime_story_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "runtime-story",
|
||||
"field": "sessionId",
|
||||
"message": "sessionId 不能为空",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let snapshot = payload.snapshot.ok_or_else(|| {
|
||||
runtime_story_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "runtime-story",
|
||||
"field": "snapshot",
|
||||
"message": "当前首版兼容状态桥要求随请求提交 snapshot",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_runtime_story_state_response(
|
||||
&session_id,
|
||||
payload.client_version,
|
||||
snapshot,
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
fn build_runtime_story_state_response(
|
||||
requested_session_id: &str,
|
||||
client_version: Option<u32>,
|
||||
snapshot: RuntimeStorySnapshotPayload,
|
||||
) -> RuntimeStoryActionResponse {
|
||||
let session_id = read_runtime_session_id(&snapshot.game_state)
|
||||
.unwrap_or_else(|| requested_session_id.to_string());
|
||||
let options = build_runtime_story_options(snapshot.current_story.as_ref(), &snapshot.game_state);
|
||||
let story_text =
|
||||
read_story_text(snapshot.current_story.as_ref()).unwrap_or_else(|| build_fallback_story_text(&snapshot.game_state));
|
||||
let server_version =
|
||||
read_u32_field(&snapshot.game_state, "runtimeActionVersion").or(client_version).unwrap_or(0);
|
||||
|
||||
RuntimeStoryActionResponse {
|
||||
session_id,
|
||||
server_version,
|
||||
view_model: RuntimeStoryViewModel {
|
||||
player: RuntimeStoryPlayerViewModel {
|
||||
hp: read_i32_field(&snapshot.game_state, "playerHp").unwrap_or(0),
|
||||
max_hp: read_i32_field(&snapshot.game_state, "playerMaxHp").unwrap_or(1),
|
||||
mana: read_i32_field(&snapshot.game_state, "playerMana").unwrap_or(0),
|
||||
max_mana: read_i32_field(&snapshot.game_state, "playerMaxMana").unwrap_or(1),
|
||||
},
|
||||
encounter: build_runtime_story_encounter(&snapshot.game_state),
|
||||
companions: build_runtime_story_companions(&snapshot.game_state),
|
||||
available_options: options.clone(),
|
||||
status: RuntimeStoryStatusViewModel {
|
||||
in_battle: read_bool_field(&snapshot.game_state, "inBattle").unwrap_or(false),
|
||||
npc_interaction_active: read_bool_field(&snapshot.game_state, "npcInteractionActive")
|
||||
.unwrap_or(false),
|
||||
current_npc_battle_mode: read_optional_string_field(
|
||||
&snapshot.game_state,
|
||||
"currentNpcBattleMode",
|
||||
),
|
||||
current_npc_battle_outcome: read_optional_string_field(
|
||||
&snapshot.game_state,
|
||||
"currentNpcBattleOutcome",
|
||||
),
|
||||
},
|
||||
},
|
||||
presentation: RuntimeStoryPresentation {
|
||||
action_text: String::new(),
|
||||
result_text: String::new(),
|
||||
story_text,
|
||||
options,
|
||||
toast: None,
|
||||
battle: None,
|
||||
},
|
||||
patches: Vec::new(),
|
||||
snapshot,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_runtime_story_companions(game_state: &Value) -> Vec<RuntimeStoryCompanionViewModel> {
|
||||
read_array_field(game_state, "companions")
|
||||
.into_iter()
|
||||
.filter_map(|entry| {
|
||||
let npc_id = read_required_string_field(entry, "npcId")?;
|
||||
Some(RuntimeStoryCompanionViewModel {
|
||||
npc_id,
|
||||
character_id: read_optional_string_field(entry, "characterId"),
|
||||
joined_at_affinity: read_i32_field(entry, "joinedAtAffinity").unwrap_or(0),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_runtime_story_encounter(game_state: &Value) -> Option<RuntimeStoryEncounterViewModel> {
|
||||
let encounter = read_object_field(game_state, "currentEncounter")?;
|
||||
let npc_name = read_required_string_field(encounter, "npcName")?;
|
||||
let encounter_id = read_required_string_field(encounter, "id").unwrap_or_else(|| npc_name.clone());
|
||||
let npc_state = resolve_current_encounter_npc_state(game_state, &encounter_id, &npc_name);
|
||||
|
||||
Some(RuntimeStoryEncounterViewModel {
|
||||
id: encounter_id,
|
||||
kind: read_required_string_field(encounter, "kind").unwrap_or_else(|| "npc".to_string()),
|
||||
npc_name,
|
||||
hostile: read_bool_field(encounter, "hostile").unwrap_or(false),
|
||||
affinity: npc_state.and_then(|state| read_i32_field(state, "affinity")),
|
||||
recruited: npc_state.and_then(|state| read_bool_field(state, "recruited")),
|
||||
interaction_active: read_bool_field(game_state, "npcInteractionActive").unwrap_or(false),
|
||||
battle_mode: read_optional_string_field(game_state, "currentNpcBattleMode"),
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_current_encounter_npc_state<'a>(
|
||||
game_state: &'a Value,
|
||||
encounter_id: &str,
|
||||
npc_name: &str,
|
||||
) -> Option<&'a Value> {
|
||||
let npc_states = read_object_field(game_state, "npcStates")?;
|
||||
|
||||
npc_states
|
||||
.get(encounter_id)
|
||||
.or_else(|| npc_states.get(npc_name))
|
||||
}
|
||||
|
||||
fn build_runtime_story_options(
|
||||
current_story: Option<&Value>,
|
||||
game_state: &Value,
|
||||
) -> Vec<RuntimeStoryOptionView> {
|
||||
if let Some(story) = current_story {
|
||||
let prefers_deferred = read_required_string_field(story, "displayMode")
|
||||
.is_some_and(|value| value == "dialogue")
|
||||
&& !read_array_field(story, "deferredOptions").is_empty();
|
||||
|
||||
let source = if prefers_deferred {
|
||||
read_array_field(story, "deferredOptions")
|
||||
} else {
|
||||
read_array_field(story, "options")
|
||||
};
|
||||
|
||||
let compiled = source
|
||||
.into_iter()
|
||||
.filter_map(build_runtime_story_option_from_story_option)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if !compiled.is_empty() {
|
||||
return compiled;
|
||||
}
|
||||
}
|
||||
|
||||
build_fallback_runtime_story_options(game_state)
|
||||
}
|
||||
|
||||
fn build_runtime_story_option_from_story_option(value: &Value) -> Option<RuntimeStoryOptionView> {
|
||||
let function_id = read_required_string_field(value, "functionId")?;
|
||||
let action_text = read_required_string_field(value, "actionText")
|
||||
.or_else(|| read_required_string_field(value, "text"))
|
||||
.unwrap_or_else(|| function_id.clone());
|
||||
|
||||
Some(RuntimeStoryOptionView {
|
||||
scope: infer_option_scope(function_id.as_str()).to_string(),
|
||||
detail_text: read_optional_string_field(value, "detailText"),
|
||||
interaction: build_runtime_story_option_interaction(read_field(value, "interaction")),
|
||||
payload: read_field(value, "runtimePayload").cloned(),
|
||||
disabled: read_bool_field(value, "disabled"),
|
||||
reason: read_optional_string_field(value, "disabledReason")
|
||||
.or_else(|| read_optional_string_field(value, "reason")),
|
||||
function_id,
|
||||
action_text,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_runtime_story_option_interaction(
|
||||
value: Option<&Value>,
|
||||
) -> Option<RuntimeStoryOptionInteraction> {
|
||||
let interaction = value?;
|
||||
match read_required_string_field(interaction, "kind")?.as_str() {
|
||||
"npc" => Some(RuntimeStoryOptionInteraction::Npc {
|
||||
npc_id: read_required_string_field(interaction, "npcId")?,
|
||||
action: read_required_string_field(interaction, "action")?,
|
||||
quest_id: read_optional_string_field(interaction, "questId"),
|
||||
}),
|
||||
"treasure" => Some(RuntimeStoryOptionInteraction::Treasure {
|
||||
action: read_required_string_field(interaction, "action")?,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_fallback_runtime_story_options(game_state: &Value) -> Vec<RuntimeStoryOptionView> {
|
||||
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
|
||||
return vec![
|
||||
build_static_runtime_story_option("battle_attack_basic", "普通攻击", "combat"),
|
||||
build_static_runtime_story_option("battle_recover_breath", "恢复", "combat"),
|
||||
build_static_runtime_story_option("battle_escape_breakout", "强行脱离战斗", "combat"),
|
||||
];
|
||||
}
|
||||
|
||||
let encounter = read_object_field(game_state, "currentEncounter");
|
||||
if let Some(encounter) = encounter {
|
||||
match read_required_string_field(encounter, "kind").as_deref() {
|
||||
Some("npc") => {
|
||||
let interaction_active =
|
||||
read_bool_field(game_state, "npcInteractionActive").unwrap_or(false);
|
||||
if interaction_active {
|
||||
return vec![
|
||||
build_static_runtime_story_option("npc_chat", "继续交谈", "npc"),
|
||||
build_static_runtime_story_option("npc_help", "请求援手", "npc"),
|
||||
build_static_runtime_story_option("npc_spar", "点到为止切磋", "npc"),
|
||||
build_static_runtime_story_option("npc_fight", "与对方战斗", "npc"),
|
||||
build_static_runtime_story_option("npc_leave", "离开当前角色", "npc"),
|
||||
];
|
||||
}
|
||||
|
||||
return vec![
|
||||
build_static_runtime_story_option("npc_preview_talk", "转向眼前角色", "npc"),
|
||||
build_static_runtime_story_option("npc_fight", "与对方战斗", "npc"),
|
||||
build_static_runtime_story_option("npc_leave", "离开当前角色", "npc"),
|
||||
];
|
||||
}
|
||||
Some("treasure") => {
|
||||
return vec![
|
||||
build_static_runtime_story_option("treasure_secure", "直接收取", "story"),
|
||||
build_static_runtime_story_option("treasure_inspect", "仔细检查", "story"),
|
||||
build_static_runtime_story_option("treasure_leave", "先记下位置", "story"),
|
||||
];
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
vec![
|
||||
build_static_runtime_story_option("idle_observe_signs", "观察周围迹象", "story"),
|
||||
build_static_runtime_story_option("idle_call_out", "主动出声试探", "story"),
|
||||
build_static_runtime_story_option("idle_rest_focus", "原地调息", "story"),
|
||||
build_static_runtime_story_option("idle_explore_forward", "继续向前探索", "story"),
|
||||
build_static_runtime_story_option("idle_travel_next_scene", "前往相邻场景", "story"),
|
||||
build_static_runtime_story_option("story_continue_adventure", "继续推进冒险", "story"),
|
||||
]
|
||||
}
|
||||
|
||||
fn build_static_runtime_story_option(
|
||||
function_id: &str,
|
||||
action_text: &str,
|
||||
scope: &str,
|
||||
) -> RuntimeStoryOptionView {
|
||||
RuntimeStoryOptionView {
|
||||
function_id: function_id.to_string(),
|
||||
action_text: action_text.to_string(),
|
||||
detail_text: None,
|
||||
scope: scope.to_string(),
|
||||
interaction: None,
|
||||
payload: None,
|
||||
disabled: None,
|
||||
reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn infer_option_scope(function_id: &str) -> &'static str {
|
||||
if function_id.starts_with("battle_") || function_id == "inventory_use" {
|
||||
"combat"
|
||||
} else if function_id.starts_with("npc_") {
|
||||
"npc"
|
||||
} else {
|
||||
"story"
|
||||
}
|
||||
}
|
||||
|
||||
fn read_story_text(current_story: Option<&Value>) -> Option<String> {
|
||||
current_story.and_then(|story| read_optional_string_field(story, "text"))
|
||||
}
|
||||
|
||||
fn build_fallback_story_text(game_state: &Value) -> String {
|
||||
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
|
||||
let encounter_name = read_object_field(game_state, "currentEncounter")
|
||||
.and_then(|encounter| read_optional_string_field(encounter, "npcName"))
|
||||
.unwrap_or_else(|| "眼前的敌人".to_string());
|
||||
return format!("战斗还没有结束,{encounter_name} 仍在逼你立刻做出下一步判断。");
|
||||
}
|
||||
|
||||
if let Some(encounter) = read_object_field(game_state, "currentEncounter")
|
||||
&& let Some(npc_name) = read_optional_string_field(encounter, "npcName")
|
||||
{
|
||||
return format!("{npc_name} 正在等你表态,当前局势已经可以继续推进。");
|
||||
}
|
||||
|
||||
"当前故事状态已经同步到兼容状态桥,可以继续推进这一轮运行时动作。".to_string()
|
||||
}
|
||||
|
||||
fn read_runtime_session_id(game_state: &Value) -> Option<String> {
|
||||
read_optional_string_field(game_state, "runtimeSessionId")
|
||||
}
|
||||
|
||||
fn read_field<'a>(value: &'a Value, key: &str) -> Option<&'a Value> {
|
||||
value.as_object()?.get(key)
|
||||
}
|
||||
|
||||
fn read_object_field<'a>(value: &'a Value, key: &str) -> Option<&'a Value> {
|
||||
let field = read_field(value, key)?;
|
||||
field.is_object().then_some(field)
|
||||
}
|
||||
|
||||
fn read_array_field<'a>(value: &'a Value, key: &str) -> Vec<&'a Value> {
|
||||
read_field(value, key)
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| items.iter().collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn read_required_string_field(value: &Value, key: &str) -> Option<String> {
|
||||
normalize_required_string(read_field(value, key)?.as_str()?)
|
||||
}
|
||||
|
||||
fn read_optional_string_field(value: &Value, key: &str) -> Option<String> {
|
||||
normalize_optional_string(read_field(value, key).and_then(Value::as_str))
|
||||
}
|
||||
|
||||
fn read_bool_field(value: &Value, key: &str) -> Option<bool> {
|
||||
read_field(value, key).and_then(Value::as_bool)
|
||||
}
|
||||
|
||||
fn read_i32_field(value: &Value, key: &str) -> Option<i32> {
|
||||
read_field(value, key)
|
||||
.and_then(Value::as_i64)
|
||||
.and_then(|number| i32::try_from(number).ok())
|
||||
}
|
||||
|
||||
fn read_u32_field(value: &Value, key: &str) -> Option<u32> {
|
||||
read_field(value, key)
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|number| u32::try_from(number).ok())
|
||||
}
|
||||
|
||||
fn normalize_required_string(value: &str) -> Option<String> {
|
||||
let trimmed = value.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||
}
|
||||
|
||||
fn normalize_optional_string(value: Option<&str>) -> Option<String> {
|
||||
value.and_then(normalize_required_string)
|
||||
}
|
||||
|
||||
fn runtime_story_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
||||
error.into_response_with_context(Some(request_context))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use platform_auth::{
|
||||
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use time::OffsetDateTime;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::{app::build_router, config::AppConfig, state::AppState};
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_story_state_resolve_requires_authentication() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/runtime/story/state/resolve")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"sessionId": "runtime-main",
|
||||
"snapshot": {
|
||||
"savedAt": "2026-04-22T12:00:00.000Z",
|
||||
"bottomTab": "adventure",
|
||||
"gameState": {
|
||||
"runtimeSessionId": "runtime-main"
|
||||
},
|
||||
"currentStory": null
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_story_state_resolve_rejects_missing_snapshot() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/runtime/story/state/resolve")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"sessionId": "runtime-main"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_story_state_resolve_returns_compiled_snapshot_response() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/runtime/story/state/resolve")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"sessionId": "runtime-main",
|
||||
"clientVersion": 7,
|
||||
"snapshot": {
|
||||
"savedAt": "2026-04-22T12:00:00.000Z",
|
||||
"bottomTab": "adventure",
|
||||
"gameState": {
|
||||
"runtimeSessionId": "runtime-main",
|
||||
"runtimeActionVersion": 7,
|
||||
"playerHp": 32,
|
||||
"playerMaxHp": 40,
|
||||
"playerMana": 18,
|
||||
"playerMaxMana": 20,
|
||||
"inBattle": false,
|
||||
"npcInteractionActive": true,
|
||||
"currentEncounter": {
|
||||
"id": "npc_camp_firekeeper",
|
||||
"kind": "npc",
|
||||
"npcName": "守火人",
|
||||
"hostile": false
|
||||
},
|
||||
"npcStates": {
|
||||
"npc_camp_firekeeper": {
|
||||
"affinity": 12,
|
||||
"recruited": false
|
||||
}
|
||||
},
|
||||
"companions": [{
|
||||
"npcId": "npc_companion_001",
|
||||
"characterId": "char_companion_001",
|
||||
"joinedAtAffinity": 64
|
||||
}]
|
||||
},
|
||||
"currentStory": {
|
||||
"text": "守火人抬眼看了你一瞬,示意你把想问的话继续说完。",
|
||||
"displayMode": "dialogue",
|
||||
"options": [{
|
||||
"functionId": "story_continue_adventure",
|
||||
"actionText": "继续冒险"
|
||||
}],
|
||||
"deferredOptions": [{
|
||||
"functionId": "npc_chat",
|
||||
"actionText": "继续交谈",
|
||||
"detailText": "围绕当前话题继续推进关系判断。",
|
||||
"interaction": {
|
||||
"kind": "npc",
|
||||
"npcId": "npc_camp_firekeeper",
|
||||
"action": "chat"
|
||||
},
|
||||
"runtimePayload": {
|
||||
"note": "server-runtime-test"
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(true));
|
||||
assert_eq!(payload["data"]["sessionId"], json!("runtime-main"));
|
||||
assert_eq!(payload["data"]["serverVersion"], json!(7));
|
||||
assert_eq!(
|
||||
payload["data"]["viewModel"]["encounter"]["npcName"],
|
||||
json!("守火人")
|
||||
);
|
||||
assert_eq!(
|
||||
payload["data"]["viewModel"]["availableOptions"][0]["functionId"],
|
||||
json!("npc_chat")
|
||||
);
|
||||
assert_eq!(
|
||||
payload["data"]["presentation"]["options"][0]["interaction"]["npcId"],
|
||||
json!("npc_camp_firekeeper")
|
||||
);
|
||||
assert_eq!(
|
||||
payload["data"]["snapshot"]["currentStory"]["deferredOptions"][0]["functionId"],
|
||||
json!("npc_chat")
|
||||
);
|
||||
}
|
||||
|
||||
async fn seed_authenticated_state() -> AppState {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state
|
||||
.password_entry_service()
|
||||
.execute(module_auth::PasswordEntryInput {
|
||||
username: "runtime_story_state_user".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("seed login should succeed");
|
||||
state
|
||||
}
|
||||
|
||||
fn issue_access_token(state: &AppState) -> String {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: "sess_runtime_story_state".to_string(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 1,
|
||||
phone_verified: true,
|
||||
binding_status: BindingStatus::Active,
|
||||
display_name: Some("运行时剧情状态用户".to_string()),
|
||||
},
|
||||
state.auth_jwt_config(),
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.expect("claims should build");
|
||||
|
||||
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
use axum::http::HeaderMap;
|
||||
use module_auth::RefreshSessionClientInfo;
|
||||
use platform_auth::hash_refresh_session_token;
|
||||
use shared_kernel::normalize_optional_string;
|
||||
|
||||
const X_CLIENT_TYPE_HEADER: &str = "x-client-type";
|
||||
const X_CLIENT_RUNTIME_HEADER: &str = "x-client-runtime";
|
||||
@@ -104,17 +105,6 @@ fn header_value(headers: &HeaderMap, name: &str) -> Option<String> {
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|raw| {
|
||||
let normalized = raw.trim().to_string();
|
||||
if normalized.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(normalized)
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_client_type(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|raw| {
|
||||
let normalized = raw.trim().to_ascii_lowercase();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
use module_ai::{AiTaskService, InMemoryAiTaskStore};
|
||||
use module_auth::{
|
||||
AuthUserService, InMemoryAuthStore, PasswordEntryService, PhoneAuthService,
|
||||
RefreshSessionService, WechatAuthService, WechatAuthStateService,
|
||||
@@ -7,6 +8,7 @@ use module_auth::{
|
||||
use platform_auth::{
|
||||
JwtConfig, JwtError, RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite,
|
||||
};
|
||||
use platform_llm::{LlmClient, LlmConfig, LlmError};
|
||||
use platform_oss::{OssClient, OssConfig, OssError};
|
||||
use spacetime_client::{SpacetimeClient, SpacetimeClientConfig};
|
||||
|
||||
@@ -29,7 +31,10 @@ pub struct AppState {
|
||||
wechat_auth_state_service: WechatAuthStateService,
|
||||
wechat_auth_service: WechatAuthService,
|
||||
wechat_provider: WechatProvider,
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
ai_task_service: AiTaskService,
|
||||
spacetime_client: SpacetimeClient,
|
||||
llm_client: Option<LlmClient>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -37,6 +42,7 @@ pub enum AppStateInitError {
|
||||
Jwt(JwtError),
|
||||
RefreshCookie(RefreshCookieError),
|
||||
Oss(OssError),
|
||||
Llm(LlmError),
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -68,11 +74,14 @@ impl AppState {
|
||||
let wechat_provider = build_wechat_provider(&config);
|
||||
let refresh_session_service =
|
||||
RefreshSessionService::new(auth_store, config.refresh_session_ttl_days);
|
||||
// AI 编排服务当前先挂接内存态 store,后续再按 task table / procedure 接到 SpacetimeDB 真相源。
|
||||
let ai_task_service = AiTaskService::new(InMemoryAiTaskStore::default());
|
||||
let spacetime_client = SpacetimeClient::new(SpacetimeClientConfig {
|
||||
server_url: config.spacetime_server_url.clone(),
|
||||
database: config.spacetime_database.clone(),
|
||||
token: config.spacetime_token.clone(),
|
||||
});
|
||||
let llm_client = build_llm_client(&config)?;
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
@@ -86,7 +95,9 @@ impl AppState {
|
||||
wechat_auth_state_service,
|
||||
wechat_auth_service,
|
||||
wechat_provider,
|
||||
ai_task_service,
|
||||
spacetime_client,
|
||||
llm_client,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -130,9 +141,18 @@ impl AppState {
|
||||
&self.wechat_provider
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub fn ai_task_service(&self) -> &AiTaskService {
|
||||
&self.ai_task_service
|
||||
}
|
||||
|
||||
pub fn spacetime_client(&self) -> &SpacetimeClient {
|
||||
&self.spacetime_client
|
||||
}
|
||||
|
||||
pub fn llm_client(&self) -> Option<&LlmClient> {
|
||||
self.llm_client.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for AppStateInitError {
|
||||
@@ -141,6 +161,7 @@ impl fmt::Display for AppStateInitError {
|
||||
Self::Jwt(error) => write!(f, "{error}"),
|
||||
Self::RefreshCookie(error) => write!(f, "{error}"),
|
||||
Self::Oss(error) => write!(f, "{error}"),
|
||||
Self::Llm(error) => write!(f, "{error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,6 +186,12 @@ impl From<OssError> for AppStateInitError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<LlmError> for AppStateInitError {
|
||||
fn from(value: LlmError) -> Self {
|
||||
Self::Llm(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn build_oss_client(config: &AppConfig) -> Result<Option<OssClient>, AppStateInitError> {
|
||||
let has_any_oss_field = config.oss_bucket.is_some()
|
||||
|| config.oss_endpoint.is_some()
|
||||
@@ -188,3 +215,65 @@ fn build_oss_client(config: &AppConfig) -> Result<Option<OssClient>, AppStateIni
|
||||
|
||||
Ok(Some(OssClient::new(oss_config)))
|
||||
}
|
||||
|
||||
fn build_llm_client(config: &AppConfig) -> Result<Option<LlmClient>, AppStateInitError> {
|
||||
let Some(api_key) = config
|
||||
.llm_api_key
|
||||
.as_ref()
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let llm_config = LlmConfig::new(
|
||||
config.llm_provider,
|
||||
config.llm_base_url.clone(),
|
||||
api_key.to_string(),
|
||||
config.llm_model.clone(),
|
||||
config.llm_request_timeout_ms,
|
||||
config.llm_max_retries,
|
||||
config.llm_retry_backoff_ms,
|
||||
)?;
|
||||
|
||||
Ok(Some(LlmClient::new(llm_config)?))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use module_ai::{AiTaskKind, generate_ai_task_id};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn app_state_exposes_usable_ai_task_service() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
let task_id = generate_ai_task_id(1_713_680_000_000_000);
|
||||
|
||||
let created = state
|
||||
.ai_task_service()
|
||||
.create_task(module_ai::AiTaskCreateInput {
|
||||
task_id: task_id.clone(),
|
||||
task_kind: AiTaskKind::StoryGeneration,
|
||||
owner_user_id: "user_001".to_string(),
|
||||
request_label: "营地开场".to_string(),
|
||||
source_module: "story".to_string(),
|
||||
source_entity_id: Some("storysess_001".to_string()),
|
||||
request_payload_json: Some("{\"scene\":\"camp\"}".to_string()),
|
||||
stages: AiTaskKind::StoryGeneration.default_stage_blueprints(),
|
||||
created_at_micros: 1_713_680_000_000_000,
|
||||
})
|
||||
.expect("ai task should create");
|
||||
|
||||
assert_eq!(created.task_id, task_id);
|
||||
assert_eq!(created.task_kind, AiTaskKind::StoryGeneration);
|
||||
assert_eq!(created.stages.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_state_skips_llm_client_when_api_key_missing() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
|
||||
assert!(state.llm_client().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
829
server-rs/crates/api-server/src/story_battles.rs
Normal file
829
server-rs/crates/api-server/src/story_battles.rs
Normal file
@@ -0,0 +1,829 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, Path, State},
|
||||
http::StatusCode,
|
||||
response::Response,
|
||||
};
|
||||
use module_combat::{
|
||||
BattleMode, BattleStateInput, ResolveCombatActionInput, generate_battle_state_id,
|
||||
};
|
||||
use module_npc::{NPC_FIGHT_FUNCTION_ID, NPC_SPAR_FUNCTION_ID, ResolveNpcInteractionInput};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use shared_kernel::{normalize_optional_string, normalize_required_string, normalize_string_list};
|
||||
use spacetime_client::{ResolveNpcBattleInteractionInput, SpacetimeClientError};
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
request_context::RequestContext, state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateStoryBattleRequest {
|
||||
pub story_session_id: String,
|
||||
pub runtime_session_id: String,
|
||||
#[serde(default)]
|
||||
pub chapter_id: Option<String>,
|
||||
pub target_npc_id: String,
|
||||
pub target_name: String,
|
||||
pub battle_mode: String,
|
||||
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,
|
||||
#[serde(default)]
|
||||
pub experience_reward: u32,
|
||||
#[serde(default)]
|
||||
pub reward_items: Vec<StoryBattleRewardItemRequest>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResolveStoryBattleRequest {
|
||||
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,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateStoryNpcBattleRequest {
|
||||
pub story_session_id: String,
|
||||
pub runtime_session_id: String,
|
||||
pub npc_id: String,
|
||||
pub npc_name: String,
|
||||
pub interaction_function_id: String,
|
||||
#[serde(default)]
|
||||
pub release_npc_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub battle_state_id: Option<String>,
|
||||
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,
|
||||
#[serde(default)]
|
||||
pub experience_reward: u32,
|
||||
#[serde(default)]
|
||||
pub reward_items: Vec<StoryBattleRewardItemRequest>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StoryBattleRewardItemRequest {
|
||||
pub item_id: String,
|
||||
pub category: String,
|
||||
pub item_name: String,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
pub quantity: u32,
|
||||
pub rarity: String,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
pub stackable: bool,
|
||||
#[serde(default)]
|
||||
pub stack_key: String,
|
||||
#[serde(default)]
|
||||
pub equipment_slot_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn create_story_battle(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<CreateStoryBattleRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let now_micros = current_utc_micros();
|
||||
let actor_user_id = authenticated.claims().user_id().to_string();
|
||||
let battle_mode = parse_battle_mode_strict(&payload.battle_mode).ok_or_else(|| {
|
||||
story_battles_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "story-battle",
|
||||
"message": "battleMode 仅支持 fight 或 spar",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let reward_items =
|
||||
parse_story_battle_reward_items(&payload.reward_items).map_err(|message| {
|
||||
story_battles_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "story-battle",
|
||||
"message": message,
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let result = state
|
||||
.spacetime_client()
|
||||
.create_battle_state(BattleStateInput {
|
||||
battle_state_id: generate_battle_state_id(now_micros),
|
||||
story_session_id: payload.story_session_id,
|
||||
runtime_session_id: payload.runtime_session_id,
|
||||
actor_user_id,
|
||||
chapter_id: payload.chapter_id,
|
||||
target_npc_id: payload.target_npc_id,
|
||||
target_name: payload.target_name,
|
||||
battle_mode,
|
||||
player_hp: payload.player_hp,
|
||||
player_max_hp: payload.player_max_hp,
|
||||
player_mana: payload.player_mana,
|
||||
player_max_mana: payload.player_max_mana,
|
||||
target_hp: payload.target_hp,
|
||||
target_max_hp: payload.target_max_hp,
|
||||
experience_reward: payload.experience_reward,
|
||||
reward_items,
|
||||
created_at_micros: now_micros,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
story_battles_error_response(&request_context, map_story_battle_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
json!({
|
||||
"battleState": build_battle_state_payload(&result),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn resolve_story_battle(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<ResolveStoryBattleRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let now_micros = current_utc_micros();
|
||||
let result = state
|
||||
.spacetime_client()
|
||||
.resolve_combat_action(ResolveCombatActionInput {
|
||||
battle_state_id: payload.battle_state_id,
|
||||
function_id: payload.function_id,
|
||||
action_text: payload.action_text,
|
||||
base_damage: payload.base_damage,
|
||||
mana_cost: payload.mana_cost,
|
||||
heal: payload.heal,
|
||||
mana_restore: payload.mana_restore,
|
||||
counter_multiplier_basis_points: payload.counter_multiplier_basis_points,
|
||||
updated_at_micros: now_micros,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
story_battles_error_response(&request_context, map_story_battle_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
json!({
|
||||
"battleState": build_battle_state_payload(&result.battle_state),
|
||||
"combat": {
|
||||
"damageDealt": result.damage_dealt,
|
||||
"damageTaken": result.damage_taken,
|
||||
"outcome": result.outcome,
|
||||
}
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_story_battle_state(
|
||||
State(state): State<AppState>,
|
||||
Path(battle_state_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let result = state
|
||||
.spacetime_client()
|
||||
.get_battle_state(battle_state_id)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
story_battles_error_response(&request_context, map_story_battle_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
json!({
|
||||
"battleState": build_battle_state_payload(&result),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn create_story_npc_battle(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<CreateStoryNpcBattleRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let now_micros = current_utc_micros();
|
||||
let actor_user_id = authenticated.claims().user_id().to_string();
|
||||
let interaction_function_id =
|
||||
parse_npc_battle_interaction_function_id_strict(&payload.interaction_function_id)
|
||||
.ok_or_else(|| {
|
||||
story_battles_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "story-npc-battle",
|
||||
"message": "interactionFunctionId 仅支持 npc_fight 或 npc_spar",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let reward_items =
|
||||
parse_story_battle_reward_items(&payload.reward_items).map_err(|message| {
|
||||
story_battles_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "story-npc-battle",
|
||||
"message": message,
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let result = state
|
||||
.spacetime_client()
|
||||
.resolve_npc_battle_interaction(ResolveNpcBattleInteractionInput {
|
||||
npc_interaction: ResolveNpcInteractionInput {
|
||||
runtime_session_id: payload.runtime_session_id,
|
||||
npc_id: payload.npc_id,
|
||||
npc_name: payload.npc_name,
|
||||
interaction_function_id,
|
||||
release_npc_id: payload.release_npc_id,
|
||||
updated_at_micros: now_micros,
|
||||
},
|
||||
story_session_id: payload.story_session_id,
|
||||
actor_user_id,
|
||||
battle_state_id: payload.battle_state_id,
|
||||
player_hp: payload.player_hp,
|
||||
player_max_hp: payload.player_max_hp,
|
||||
player_mana: payload.player_mana,
|
||||
player_max_mana: payload.player_max_mana,
|
||||
target_hp: payload.target_hp,
|
||||
target_max_hp: payload.target_max_hp,
|
||||
experience_reward: payload.experience_reward,
|
||||
reward_items,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
story_battles_error_response(&request_context, map_story_battle_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
json!({
|
||||
"npcInteraction": build_npc_interaction_payload(&result.npc_interaction),
|
||||
"battleState": build_battle_state_payload(&result.battle_state),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
fn build_battle_state_payload(record: &spacetime_client::BattleStateRecord) -> Value {
|
||||
json!({
|
||||
"battleStateId": record.battle_state_id,
|
||||
"storySessionId": record.story_session_id,
|
||||
"runtimeSessionId": record.runtime_session_id,
|
||||
"actorUserId": record.actor_user_id,
|
||||
"chapterId": record.chapter_id,
|
||||
"targetNpcId": record.target_npc_id,
|
||||
"targetName": record.target_name,
|
||||
"battleMode": record.battle_mode,
|
||||
"status": record.status,
|
||||
"playerHp": record.player_hp,
|
||||
"playerMaxHp": record.player_max_hp,
|
||||
"playerMana": record.player_mana,
|
||||
"playerMaxMana": record.player_max_mana,
|
||||
"targetHp": record.target_hp,
|
||||
"targetMaxHp": record.target_max_hp,
|
||||
"experienceReward": record.experience_reward,
|
||||
"rewardItems": record.reward_items.iter().map(|item| {
|
||||
json!({
|
||||
"itemId": item.item_id,
|
||||
"category": item.category,
|
||||
"itemName": item.item_name,
|
||||
"description": item.description,
|
||||
"quantity": item.quantity,
|
||||
"rarity": format_runtime_item_reward_item_rarity(item.rarity),
|
||||
"tags": item.tags,
|
||||
"stackable": item.stackable,
|
||||
"stackKey": item.stack_key,
|
||||
"equipmentSlotId": item
|
||||
.equipment_slot_id
|
||||
.map(format_runtime_item_equipment_slot),
|
||||
})
|
||||
}).collect::<Vec<_>>(),
|
||||
"turnIndex": record.turn_index,
|
||||
"lastActionFunctionId": record.last_action_function_id,
|
||||
"lastActionText": record.last_action_text,
|
||||
"lastResultText": record.last_result_text,
|
||||
"lastDamageDealt": record.last_damage_dealt,
|
||||
"lastDamageTaken": record.last_damage_taken,
|
||||
"lastOutcome": record.last_outcome,
|
||||
"version": record.version,
|
||||
"createdAt": record.created_at,
|
||||
"updatedAt": record.updated_at,
|
||||
})
|
||||
}
|
||||
|
||||
fn format_runtime_item_reward_item_rarity(
|
||||
value: module_runtime_item::RuntimeItemRewardItemRarity,
|
||||
) -> &'static str {
|
||||
match value {
|
||||
module_runtime_item::RuntimeItemRewardItemRarity::Common => "common",
|
||||
module_runtime_item::RuntimeItemRewardItemRarity::Uncommon => "uncommon",
|
||||
module_runtime_item::RuntimeItemRewardItemRarity::Rare => "rare",
|
||||
module_runtime_item::RuntimeItemRewardItemRarity::Epic => "epic",
|
||||
module_runtime_item::RuntimeItemRewardItemRarity::Legendary => "legendary",
|
||||
}
|
||||
}
|
||||
|
||||
fn format_runtime_item_equipment_slot(
|
||||
value: module_runtime_item::RuntimeItemEquipmentSlot,
|
||||
) -> &'static str {
|
||||
match value {
|
||||
module_runtime_item::RuntimeItemEquipmentSlot::Weapon => "weapon",
|
||||
module_runtime_item::RuntimeItemEquipmentSlot::Armor => "armor",
|
||||
module_runtime_item::RuntimeItemEquipmentSlot::Relic => "relic",
|
||||
}
|
||||
}
|
||||
|
||||
fn build_npc_state_payload(record: &spacetime_client::NpcStateRecord) -> Value {
|
||||
json!({
|
||||
"npcStateId": record.npc_state_id,
|
||||
"runtimeSessionId": record.runtime_session_id,
|
||||
"npcId": record.npc_id,
|
||||
"npcName": record.npc_name,
|
||||
"affinity": record.affinity,
|
||||
"relationStance": record.relation_stance,
|
||||
"helpUsed": record.help_used,
|
||||
"chattedCount": record.chatted_count,
|
||||
"giftsGiven": record.gifts_given,
|
||||
"recruited": record.recruited,
|
||||
"tradeStockSignature": record.trade_stock_signature,
|
||||
"revealedFacts": record.revealed_facts,
|
||||
"knownAttributeRumors": record.known_attribute_rumors,
|
||||
"firstMeaningfulContactResolved": record.first_meaningful_contact_resolved,
|
||||
"seenBackstoryChapterIds": record.seen_backstory_chapter_ids,
|
||||
"stanceProfile": {
|
||||
"trust": record.trust,
|
||||
"warmth": record.warmth,
|
||||
"ideologicalFit": record.ideological_fit,
|
||||
"fearOrGuard": record.fear_or_guard,
|
||||
"loyalty": record.loyalty,
|
||||
"currentConflictTag": record.current_conflict_tag,
|
||||
"recentApprovals": record.recent_approvals,
|
||||
"recentDisapprovals": record.recent_disapprovals,
|
||||
},
|
||||
"createdAt": record.created_at,
|
||||
"updatedAt": record.updated_at,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_npc_interaction_payload(record: &spacetime_client::NpcInteractionRecord) -> Value {
|
||||
json!({
|
||||
"npcState": build_npc_state_payload(&record.npc_state),
|
||||
"interactionStatus": record.interaction_status,
|
||||
"actionText": record.action_text,
|
||||
"resultText": record.result_text,
|
||||
"storyText": record.story_text,
|
||||
"battleMode": record.battle_mode,
|
||||
"encounterClosed": record.encounter_closed,
|
||||
"affinityChanged": record.affinity_changed,
|
||||
"previousAffinity": record.previous_affinity,
|
||||
"nextAffinity": record.next_affinity,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_battle_mode_strict(raw: &str) -> Option<BattleMode> {
|
||||
match raw.trim() {
|
||||
"fight" => Some(BattleMode::Fight),
|
||||
"spar" => Some(BattleMode::Spar),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_npc_battle_interaction_function_id_strict(raw: &str) -> Option<String> {
|
||||
match raw.trim() {
|
||||
NPC_FIGHT_FUNCTION_ID => Some(NPC_FIGHT_FUNCTION_ID.to_string()),
|
||||
NPC_SPAR_FUNCTION_ID => Some(NPC_SPAR_FUNCTION_ID.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_story_battle_reward_items(
|
||||
values: &[StoryBattleRewardItemRequest],
|
||||
) -> Result<Vec<module_runtime_item::RuntimeItemRewardItemSnapshot>, String> {
|
||||
values.iter().map(parse_story_battle_reward_item).collect()
|
||||
}
|
||||
|
||||
fn parse_story_battle_reward_item(
|
||||
value: &StoryBattleRewardItemRequest,
|
||||
) -> Result<module_runtime_item::RuntimeItemRewardItemSnapshot, String> {
|
||||
Ok(module_runtime_item::RuntimeItemRewardItemSnapshot {
|
||||
item_id: normalize_required_string(&value.item_id)
|
||||
.ok_or_else(|| "battleState.rewardItems[].itemId 不能为空".to_string())?,
|
||||
category: normalize_required_string(&value.category)
|
||||
.ok_or_else(|| "battleState.rewardItems[].category 不能为空".to_string())?,
|
||||
item_name: normalize_required_string(&value.item_name)
|
||||
.ok_or_else(|| "battleState.rewardItems[].itemName 不能为空".to_string())?,
|
||||
description: normalize_optional_string(value.description.clone()),
|
||||
quantity: value.quantity,
|
||||
rarity: parse_runtime_item_reward_item_rarity(&value.rarity)?,
|
||||
tags: normalize_string_list(value.tags.clone()),
|
||||
stackable: value.stackable,
|
||||
stack_key: value.stack_key.trim().to_string(),
|
||||
equipment_slot_id: value
|
||||
.equipment_slot_id
|
||||
.as_deref()
|
||||
.map(parse_runtime_item_equipment_slot)
|
||||
.transpose()?,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_runtime_item_reward_item_rarity(
|
||||
raw: &str,
|
||||
) -> Result<module_runtime_item::RuntimeItemRewardItemRarity, String> {
|
||||
match raw.trim() {
|
||||
"common" => Ok(module_runtime_item::RuntimeItemRewardItemRarity::Common),
|
||||
"uncommon" => Ok(module_runtime_item::RuntimeItemRewardItemRarity::Uncommon),
|
||||
"rare" => Ok(module_runtime_item::RuntimeItemRewardItemRarity::Rare),
|
||||
"epic" => Ok(module_runtime_item::RuntimeItemRewardItemRarity::Epic),
|
||||
"legendary" => Ok(module_runtime_item::RuntimeItemRewardItemRarity::Legendary),
|
||||
_ => Err(
|
||||
"battleState.rewardItems[].rarity 仅支持 common/uncommon/rare/epic/legendary"
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_runtime_item_equipment_slot(
|
||||
raw: &str,
|
||||
) -> Result<module_runtime_item::RuntimeItemEquipmentSlot, String> {
|
||||
match raw.trim() {
|
||||
"weapon" => Ok(module_runtime_item::RuntimeItemEquipmentSlot::Weapon),
|
||||
"armor" => Ok(module_runtime_item::RuntimeItemEquipmentSlot::Armor),
|
||||
"relic" => Ok(module_runtime_item::RuntimeItemEquipmentSlot::Relic),
|
||||
_ => Err("battleState.rewardItems[].equipmentSlotId 仅支持 weapon/armor/relic".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_story_battle_client_error(error: SpacetimeClientError) -> AppError {
|
||||
let status = match &error {
|
||||
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
|
||||
_ => StatusCode::BAD_GATEWAY,
|
||||
};
|
||||
|
||||
AppError::from_status(status).with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn story_battles_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
||||
error.into_response_with_context(Some(request_context))
|
||||
}
|
||||
|
||||
fn current_utc_micros() -> i64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
let duration = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system clock should be after unix epoch");
|
||||
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use platform_auth::{
|
||||
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use time::OffsetDateTime;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::{app::build_router, config::AppConfig, state::AppState};
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_story_battle_requires_authentication() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/story/battles")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"storySessionId": "storysess_001",
|
||||
"runtimeSessionId": "runtime_001",
|
||||
"targetNpcId": "npc_001",
|
||||
"targetName": "黑爪狼",
|
||||
"battleMode": "fight",
|
||||
"playerHp": 60,
|
||||
"playerMaxHp": 60,
|
||||
"playerMana": 20,
|
||||
"playerMaxMana": 20,
|
||||
"targetHp": 30,
|
||||
"targetMaxHp": 30
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_story_npc_battle_requires_authentication() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/story/npc/battle")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"storySessionId": "storysess_001",
|
||||
"runtimeSessionId": "runtime_001",
|
||||
"npcId": "npc_001",
|
||||
"npcName": "试剑门徒",
|
||||
"interactionFunctionId": "npc_fight",
|
||||
"playerHp": 60,
|
||||
"playerMaxHp": 60,
|
||||
"playerMana": 20,
|
||||
"playerMaxMana": 20,
|
||||
"targetHp": 30,
|
||||
"targetMaxHp": 30
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_story_battle_returns_bad_gateway_when_spacetime_not_published() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/story/battles")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"storySessionId": "storysess_001",
|
||||
"runtimeSessionId": "runtime_001",
|
||||
"targetNpcId": "npc_001",
|
||||
"targetName": "黑爪狼",
|
||||
"battleMode": "fight",
|
||||
"playerHp": 60,
|
||||
"playerMaxHp": 60,
|
||||
"playerMana": 20,
|
||||
"playerMaxMana": 20,
|
||||
"targetHp": 30,
|
||||
"targetMaxHp": 30
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(false));
|
||||
assert_eq!(
|
||||
payload["error"]["details"]["provider"],
|
||||
Value::String("spacetimedb".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_story_npc_battle_returns_bad_gateway_when_spacetime_not_published() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/story/npc/battle")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"storySessionId": "storysess_001",
|
||||
"runtimeSessionId": "runtime_001",
|
||||
"npcId": "npc_001",
|
||||
"npcName": "试剑门徒",
|
||||
"interactionFunctionId": "npc_fight",
|
||||
"playerHp": 60,
|
||||
"playerMaxHp": 60,
|
||||
"playerMana": 20,
|
||||
"playerMaxMana": 20,
|
||||
"targetHp": 30,
|
||||
"targetMaxHp": 30
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(false));
|
||||
assert_eq!(
|
||||
payload["error"]["details"]["provider"],
|
||||
Value::String("spacetimedb".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_story_battle_state_requires_authentication() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/story/battles/battle_001")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_story_battle_state_returns_bad_gateway_when_spacetime_not_published() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/story/battles/battle_001")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(false));
|
||||
assert_eq!(
|
||||
payload["error"]["details"]["provider"],
|
||||
Value::String("spacetimedb".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_story_battle_returns_bad_gateway_when_spacetime_not_published() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/story/battles/resolve")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"battleStateId": "battle_001",
|
||||
"functionId": "battle_attack_basic",
|
||||
"actionText": "普通攻击",
|
||||
"baseDamage": 10,
|
||||
"manaCost": 0,
|
||||
"heal": 0,
|
||||
"manaRestore": 0,
|
||||
"counterMultiplierBasisPoints": 10000
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(false));
|
||||
assert_eq!(
|
||||
payload["error"]["details"]["provider"],
|
||||
Value::String("spacetimedb".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
async fn seed_authenticated_state() -> AppState {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state
|
||||
.password_entry_service()
|
||||
.execute(module_auth::PasswordEntryInput {
|
||||
username: "story_battles_user".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("seed login should succeed");
|
||||
state
|
||||
}
|
||||
|
||||
fn issue_access_token(state: &AppState) -> String {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: "sess_story_battles".to_string(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 1,
|
||||
phone_verified: true,
|
||||
binding_status: BindingStatus::Active,
|
||||
display_name: Some("战斗接口用户".to_string()),
|
||||
},
|
||||
state.auth_jwt_config(),
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.expect("claims should build");
|
||||
|
||||
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
|
||||
}
|
||||
}
|
||||
416
server-rs/crates/api-server/src/story_sessions.rs
Normal file
416
server-rs/crates/api-server/src/story_sessions.rs
Normal file
@@ -0,0 +1,416 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, Path, State},
|
||||
http::StatusCode,
|
||||
response::Response,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::story::{
|
||||
BeginStorySessionRequest, ContinueStoryRequest, StoryEventPayload,
|
||||
StorySessionMutationResponse, StorySessionPayload, StorySessionStateResponse,
|
||||
};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
request_context::RequestContext, state::AppState,
|
||||
};
|
||||
|
||||
pub async fn begin_story_session(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<BeginStorySessionRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let now_micros = current_utc_micros();
|
||||
let actor_user_id = authenticated.claims().user_id().to_string();
|
||||
let result = state
|
||||
.spacetime_client()
|
||||
.begin_story_session(
|
||||
module_story::generate_story_session_id(now_micros),
|
||||
payload.runtime_session_id,
|
||||
actor_user_id,
|
||||
payload.world_profile_id,
|
||||
payload.initial_prompt,
|
||||
payload.opening_summary,
|
||||
now_micros,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
story_sessions_error_response(&request_context, map_story_session_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
StorySessionMutationResponse {
|
||||
story_session: StorySessionPayload {
|
||||
story_session_id: result.session.story_session_id,
|
||||
runtime_session_id: result.session.runtime_session_id,
|
||||
actor_user_id: result.session.actor_user_id,
|
||||
world_profile_id: result.session.world_profile_id,
|
||||
initial_prompt: result.session.initial_prompt,
|
||||
opening_summary: result.session.opening_summary,
|
||||
latest_narrative_text: result.session.latest_narrative_text,
|
||||
latest_choice_function_id: result.session.latest_choice_function_id,
|
||||
status: result.session.status,
|
||||
version: result.session.version,
|
||||
created_at: result.session.created_at,
|
||||
updated_at: result.session.updated_at,
|
||||
},
|
||||
story_event: StoryEventPayload {
|
||||
event_id: result.event.event_id,
|
||||
story_session_id: result.event.story_session_id,
|
||||
event_kind: result.event.event_kind,
|
||||
narrative_text: result.event.narrative_text,
|
||||
choice_function_id: result.event.choice_function_id,
|
||||
created_at: result.event.created_at,
|
||||
},
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn continue_story(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<ContinueStoryRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let now_micros = current_utc_micros();
|
||||
let result = state
|
||||
.spacetime_client()
|
||||
.continue_story(
|
||||
payload.story_session_id,
|
||||
module_story::generate_story_event_id(now_micros),
|
||||
payload.narrative_text,
|
||||
payload.choice_function_id,
|
||||
now_micros,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
story_sessions_error_response(&request_context, map_story_session_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
StorySessionMutationResponse {
|
||||
story_session: StorySessionPayload {
|
||||
story_session_id: result.session.story_session_id,
|
||||
runtime_session_id: result.session.runtime_session_id,
|
||||
actor_user_id: result.session.actor_user_id,
|
||||
world_profile_id: result.session.world_profile_id,
|
||||
initial_prompt: result.session.initial_prompt,
|
||||
opening_summary: result.session.opening_summary,
|
||||
latest_narrative_text: result.session.latest_narrative_text,
|
||||
latest_choice_function_id: result.session.latest_choice_function_id,
|
||||
status: result.session.status,
|
||||
version: result.session.version,
|
||||
created_at: result.session.created_at,
|
||||
updated_at: result.session.updated_at,
|
||||
},
|
||||
story_event: StoryEventPayload {
|
||||
event_id: result.event.event_id,
|
||||
story_session_id: result.event.story_session_id,
|
||||
event_kind: result.event.event_kind,
|
||||
narrative_text: result.event.narrative_text,
|
||||
choice_function_id: result.event.choice_function_id,
|
||||
created_at: result.event.created_at,
|
||||
},
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_story_session_state(
|
||||
State(state): State<AppState>,
|
||||
Path(story_session_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let result = state
|
||||
.spacetime_client()
|
||||
.get_story_session_state(story_session_id)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
story_sessions_error_response(&request_context, map_story_session_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
StorySessionStateResponse {
|
||||
story_session: StorySessionPayload {
|
||||
story_session_id: result.session.story_session_id,
|
||||
runtime_session_id: result.session.runtime_session_id,
|
||||
actor_user_id: result.session.actor_user_id,
|
||||
world_profile_id: result.session.world_profile_id,
|
||||
initial_prompt: result.session.initial_prompt,
|
||||
opening_summary: result.session.opening_summary,
|
||||
latest_narrative_text: result.session.latest_narrative_text,
|
||||
latest_choice_function_id: result.session.latest_choice_function_id,
|
||||
status: result.session.status,
|
||||
version: result.session.version,
|
||||
created_at: result.session.created_at,
|
||||
updated_at: result.session.updated_at,
|
||||
},
|
||||
story_events: result
|
||||
.events
|
||||
.into_iter()
|
||||
.map(|event| StoryEventPayload {
|
||||
event_id: event.event_id,
|
||||
story_session_id: event.story_session_id,
|
||||
event_kind: event.event_kind,
|
||||
narrative_text: event.narrative_text,
|
||||
choice_function_id: event.choice_function_id,
|
||||
created_at: event.created_at,
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn map_story_session_client_error(error: SpacetimeClientError) -> AppError {
|
||||
let status = match &error {
|
||||
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
|
||||
_ => StatusCode::BAD_GATEWAY,
|
||||
};
|
||||
|
||||
AppError::from_status(status).with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn story_sessions_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
||||
// story session 路由需要保留 request_context,确保错误 envelope 与 requestId 一致。
|
||||
error.into_response_with_context(Some(request_context))
|
||||
}
|
||||
|
||||
fn current_utc_micros() -> i64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
let duration = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system clock should be after unix epoch");
|
||||
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use platform_auth::{
|
||||
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use time::OffsetDateTime;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::{app::build_router, config::AppConfig, state::AppState};
|
||||
|
||||
#[tokio::test]
|
||||
async fn begin_story_session_requires_authentication() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/story/sessions")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"runtimeSessionId": "runtime_001",
|
||||
"worldProfileId": "profile_001",
|
||||
"initialPrompt": "进入营地",
|
||||
"openingSummary": "营地开场"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn begin_story_session_returns_bad_gateway_when_spacetime_not_published() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/story/sessions")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"runtimeSessionId": "runtime_001",
|
||||
"worldProfileId": "profile_001",
|
||||
"initialPrompt": "进入营地",
|
||||
"openingSummary": "营地开场"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(false));
|
||||
assert_eq!(
|
||||
payload["error"]["details"]["provider"],
|
||||
Value::String("spacetimedb".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn continue_story_returns_bad_gateway_when_spacetime_not_published() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/story/sessions/continue")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"storySessionId": "storysess_001",
|
||||
"narrativeText": "你看见篝火边有人招手。",
|
||||
"choiceFunctionId": "talk_to_npc"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(false));
|
||||
assert_eq!(
|
||||
payload["error"]["details"]["provider"],
|
||||
Value::String("spacetimedb".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_story_session_state_requires_authentication() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/story/sessions/storysess_001/state")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_story_session_state_returns_bad_gateway_when_spacetime_not_published() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/story/sessions/storysess_001/state")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(false));
|
||||
assert_eq!(
|
||||
payload["error"]["details"]["provider"],
|
||||
Value::String("spacetimedb".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
async fn seed_authenticated_state() -> AppState {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state
|
||||
.password_entry_service()
|
||||
.execute(module_auth::PasswordEntryInput {
|
||||
username: "story_sessions_user".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("seed login should succeed");
|
||||
state
|
||||
}
|
||||
|
||||
fn issue_access_token(state: &AppState) -> String {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: "sess_story_sessions".to_string(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 1,
|
||||
phone_verified: true,
|
||||
binding_status: BindingStatus::Active,
|
||||
display_name: Some("故事会话用户".to_string()),
|
||||
},
|
||||
state.auth_jwt_config(),
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.expect("claims should build");
|
||||
|
||||
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,10 @@ use module_auth::{
|
||||
AuthLoginMethod, BindWechatPhoneInput, CreateWechatAuthStateInput, WechatAuthError,
|
||||
WechatAuthScene,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared_contracts::auth::{
|
||||
AuthUserPayload, WechatBindPhoneRequest, WechatBindPhoneResponse, WechatCallbackQuery,
|
||||
WechatStartQuery, WechatStartResponse,
|
||||
};
|
||||
use time::OffsetDateTime;
|
||||
use url::Url;
|
||||
|
||||
@@ -19,45 +22,11 @@ use crate::{
|
||||
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
|
||||
},
|
||||
http_error::AppError,
|
||||
password_entry::PasswordEntryUserPayload,
|
||||
request_context::RequestContext,
|
||||
session_client::resolve_session_client_context,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WechatStartQuery {
|
||||
pub redirect_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WechatStartResponse {
|
||||
pub authorization_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct WechatCallbackQuery {
|
||||
pub state: Option<String>,
|
||||
pub code: Option<String>,
|
||||
pub mock_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WechatBindPhoneRequest {
|
||||
pub phone: String,
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WechatBindPhoneResponse {
|
||||
pub token: String,
|
||||
pub user: PasswordEntryUserPayload,
|
||||
}
|
||||
|
||||
pub async fn start_wechat_login(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -230,13 +199,13 @@ pub async fn bind_wechat_phone(
|
||||
Some(&request_context),
|
||||
WechatBindPhoneResponse {
|
||||
token: signed_session.access_token,
|
||||
user: PasswordEntryUserPayload {
|
||||
user: AuthUserPayload {
|
||||
id: result.user.id,
|
||||
username: result.user.username,
|
||||
display_name: result.user.display_name,
|
||||
phone_number_masked: result.user.phone_number_masked,
|
||||
login_method: result.user.login_method.as_str(),
|
||||
binding_status: result.user.binding_status.as_str(),
|
||||
login_method: result.user.login_method.as_str().to_string(),
|
||||
binding_status: result.user.binding_status.as_str().to_string(),
|
||||
wechat_bound: result.user.wechat_bound,
|
||||
},
|
||||
},
|
||||
|
||||
15
server-rs/crates/module-ai/Cargo.toml
Normal file
15
server-rs/crates/module-ai/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "module-ai"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
spacetime-types = ["dep:spacetimedb"]
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
shared-kernel = { path = "../shared-kernel" }
|
||||
spacetimedb = { workspace = true, optional = true }
|
||||
@@ -1,29 +1,67 @@
|
||||
# module-ai 独立模块 package 占位说明
|
||||
# module-ai 模块说明
|
||||
|
||||
日期:`2026-04-20`
|
||||
日期:`2026-04-21`
|
||||
|
||||
## 1. package 职责
|
||||
|
||||
`module-ai` 是 AI 编排模块 package,后续负责:
|
||||
`module-ai` 是 AI 编排模块 crate,当前已经落地首版领域基座,负责:
|
||||
|
||||
1. 剧情、聊天、自定义世界、运行时物品等生成型流程的模块级编排
|
||||
2. prompt 组织、阶段状态、结果引用与模块间协同
|
||||
3. 与 `apps/api-server` 的流式输出与兼容接口对接
|
||||
4. 与 `apps/spacetime-module` 的任务状态、结果引用聚合对接
|
||||
1. 统一 AI 任务类型、任务状态、阶段状态与任务快照
|
||||
2. 统一流式文本片段、阶段输出、结果引用与最终结果聚合
|
||||
3. 为 `api-server` 与后续 `platform-llm` 接线提供稳定的模块领域服务接口
|
||||
4. 为 `spacetime-module` 映射 `ai_task / ai_task_stage / ai_text_chunk / ai_result_reference` 提供稳定类型基础
|
||||
|
||||
## 2. 当前阶段说明
|
||||
|
||||
当前提交仅完成目录占位,不提前进入模型调用、流式编排与结果回写实现。
|
||||
当前提交已完成:
|
||||
|
||||
后续与本 package 直接相关的任务包括:
|
||||
1. `module-ai` 的 `Cargo.toml`
|
||||
2. 首版核心类型:
|
||||
- `AiTaskKind`
|
||||
- `AiTaskStatus`
|
||||
- `AiTaskStageKind`
|
||||
- `AiTaskSnapshot`
|
||||
- `AiTextChunkSnapshot`
|
||||
- `AiResultReferenceSnapshot`
|
||||
3. 默认阶段蓝图与 ID 前缀
|
||||
4. `InMemoryAiTaskStore`
|
||||
5. `AiTaskService`
|
||||
6. 面向 `SpacetimeDB` 的输入类型与 ID helper:
|
||||
- `AiTaskStartInput`
|
||||
- `AiTaskStageStartInput`
|
||||
- `AiTextChunkAppendInput`
|
||||
- `AiResultReferenceInput`
|
||||
- `AiTaskFinishInput`
|
||||
- `AiTaskCancelInput`
|
||||
- `AiTaskFailureInput`
|
||||
7. 基础单元测试
|
||||
|
||||
1. 设计多模型编排与任务状态抽象
|
||||
2. 对齐剧情、聊天、自定义世界等生成链路
|
||||
3. 对齐流式输出、阶段事件与兼容响应结构
|
||||
4. 接入模块级结果回写与任务引用绑定
|
||||
首版详细设计见:
|
||||
|
||||
## 3. 边界约束
|
||||
1. [../../../docs/technical/M4_MODULE_AI_BASELINE_DESIGN_2026-04-21.md](../../../docs/technical/M4_MODULE_AI_BASELINE_DESIGN_2026-04-21.md)
|
||||
2. [../../../docs/technical/M4_MODULE_AI_SPACETIMEDB_BASELINE_2026-04-21.md](../../../docs/technical/M4_MODULE_AI_SPACETIMEDB_BASELINE_2026-04-21.md)
|
||||
3. [../../../docs/technical/M4_MODULE_AI_AXUM_FACADE_DESIGN_2026-04-22.md](../../../docs/technical/M4_MODULE_AI_AXUM_FACADE_DESIGN_2026-04-22.md)
|
||||
|
||||
1. `module-ai` 负责生成型流程的模块级编排,不把供应商 SDK 直接散落到各业务模块里。
|
||||
2. 实际模型接入通过 `packages/platform-llm` 完成,状态与结果引用最终回写到 `apps/spacetime-module` 聚合的状态模型中。
|
||||
3. 前端兼容 REST 与 SSE 由 `apps/api-server` 暴露,但 AI 编排过程不能再次退回单个大 orchestrator 的黑盒写法。
|
||||
## 3. 当前仍未进入的范围
|
||||
|
||||
当前刻意未进入:
|
||||
|
||||
1. 真实供应商 SDK 与模型请求
|
||||
2. SSE 协议输出
|
||||
3. 任务订阅 projection 与清理调度
|
||||
4. 业务模块自己的 prompt 组装实现
|
||||
|
||||
这些后续分别由:
|
||||
|
||||
1. `platform-llm`
|
||||
2. `api-server`
|
||||
3. `spacetime-module + spacetime-client`
|
||||
4. `module-story` / `module-npc` / `module-custom-world` / `module-quest` / `module-runtime-item`
|
||||
|
||||
继续接入。
|
||||
|
||||
## 4. 边界约束
|
||||
|
||||
1. `module-ai` 只负责生成型流程的模块级编排领域模型与最小服务,不直接承接供应商 HTTP 适配。
|
||||
2. 真实模型接入通过 `platform-llm` 完成,任务真相状态最终应下沉到 `spacetime-module`。
|
||||
3. `api-server` 负责 REST / SSE 对外协议,`module-ai` 不返回 HTTP DTO。
|
||||
|
||||
1050
server-rs/crates/module-ai/src/lib.rs
Normal file
1050
server-rs/crates/module-ai/src/lib.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -14,3 +14,4 @@ serde = { version = "1", features = ["derive"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"], optional = true }
|
||||
spacetimedb = { workspace = true, optional = true }
|
||||
platform-oss = { path = "../platform-oss", optional = true }
|
||||
shared-kernel = { path = "../shared-kernel" }
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared_kernel::{
|
||||
build_prefixed_seed_id, format_timestamp_micros, normalize_optional_string,
|
||||
normalize_required_string,
|
||||
};
|
||||
#[cfg(feature = "spacetime-types")]
|
||||
use spacetimedb::SpacetimeType;
|
||||
|
||||
@@ -175,6 +179,28 @@ impl AssetObjectAccessPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
// 资产核心对象字段需要继续保留模块自己的错误语义,但基础必填字符串归一化统一走 shared-kernel。
|
||||
fn normalize_required_asset_field(
|
||||
value: impl AsRef<str>,
|
||||
error: AssetObjectFieldError,
|
||||
) -> Result<String, AssetObjectFieldError> {
|
||||
normalize_required_string(value).ok_or(error)
|
||||
}
|
||||
|
||||
fn normalize_asset_object_key(value: impl AsRef<str>) -> Result<String, AssetObjectFieldError> {
|
||||
let normalized = value.as_ref().trim();
|
||||
let normalized = normalized.trim_start_matches('/');
|
||||
normalize_required_string(normalized).ok_or(AssetObjectFieldError::MissingObjectKey)
|
||||
}
|
||||
|
||||
fn validate_asset_object_version(version: u32) -> Result<(), AssetObjectFieldError> {
|
||||
if version == 0 {
|
||||
return Err(AssetObjectFieldError::InvalidVersion);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// bucket 与 object_key 是正式真相字段,因此这里只做字段校验,不回退成单字符串路径字段。
|
||||
pub fn validate_asset_object_fields(
|
||||
bucket: &str,
|
||||
@@ -182,22 +208,10 @@ pub fn validate_asset_object_fields(
|
||||
asset_kind: &str,
|
||||
version: u32,
|
||||
) -> Result<(), AssetObjectFieldError> {
|
||||
if bucket.trim().is_empty() {
|
||||
return Err(AssetObjectFieldError::MissingBucket);
|
||||
}
|
||||
|
||||
if object_key.trim().trim_start_matches('/').is_empty() {
|
||||
return Err(AssetObjectFieldError::MissingObjectKey);
|
||||
}
|
||||
|
||||
if asset_kind.trim().is_empty() {
|
||||
return Err(AssetObjectFieldError::MissingAssetKind);
|
||||
}
|
||||
|
||||
if version == 0 {
|
||||
return Err(AssetObjectFieldError::InvalidVersion);
|
||||
}
|
||||
|
||||
normalize_required_asset_field(bucket, AssetObjectFieldError::MissingBucket)?;
|
||||
normalize_asset_object_key(object_key)?;
|
||||
normalize_required_asset_field(asset_kind, AssetObjectFieldError::MissingAssetKind)?;
|
||||
validate_asset_object_version(version)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -210,30 +224,12 @@ pub fn validate_asset_entity_binding_fields(
|
||||
slot: &str,
|
||||
asset_kind: &str,
|
||||
) -> Result<(), AssetObjectFieldError> {
|
||||
if binding_id.trim().is_empty() {
|
||||
return Err(AssetObjectFieldError::MissingBindingId);
|
||||
}
|
||||
|
||||
if asset_object_id.trim().is_empty() {
|
||||
return Err(AssetObjectFieldError::MissingAssetObjectId);
|
||||
}
|
||||
|
||||
if entity_kind.trim().is_empty() {
|
||||
return Err(AssetObjectFieldError::MissingEntityKind);
|
||||
}
|
||||
|
||||
if entity_id.trim().is_empty() {
|
||||
return Err(AssetObjectFieldError::MissingEntityId);
|
||||
}
|
||||
|
||||
if slot.trim().is_empty() {
|
||||
return Err(AssetObjectFieldError::MissingSlot);
|
||||
}
|
||||
|
||||
if asset_kind.trim().is_empty() {
|
||||
return Err(AssetObjectFieldError::MissingAssetKind);
|
||||
}
|
||||
|
||||
normalize_required_asset_field(binding_id, AssetObjectFieldError::MissingBindingId)?;
|
||||
normalize_required_asset_field(asset_object_id, AssetObjectFieldError::MissingAssetObjectId)?;
|
||||
normalize_required_asset_field(entity_kind, AssetObjectFieldError::MissingEntityKind)?;
|
||||
normalize_required_asset_field(entity_id, AssetObjectFieldError::MissingEntityId)?;
|
||||
normalize_required_asset_field(slot, AssetObjectFieldError::MissingSlot)?;
|
||||
normalize_required_asset_field(asset_kind, AssetObjectFieldError::MissingAssetKind)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -253,21 +249,20 @@ pub fn build_asset_object_upsert_input(
|
||||
entity_id: Option<String>,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<AssetObjectUpsertInput, AssetObjectFieldError> {
|
||||
if asset_object_id.trim().is_empty() {
|
||||
return Err(AssetObjectFieldError::MissingAssetObjectId);
|
||||
}
|
||||
|
||||
validate_asset_object_fields(
|
||||
&bucket,
|
||||
&object_key,
|
||||
&asset_kind,
|
||||
INITIAL_ASSET_OBJECT_VERSION,
|
||||
let asset_object_id = normalize_required_asset_field(
|
||||
asset_object_id,
|
||||
AssetObjectFieldError::MissingAssetObjectId,
|
||||
)?;
|
||||
let bucket = normalize_required_asset_field(bucket, AssetObjectFieldError::MissingBucket)?;
|
||||
let object_key = normalize_asset_object_key(object_key)?;
|
||||
let asset_kind =
|
||||
normalize_required_asset_field(asset_kind, AssetObjectFieldError::MissingAssetKind)?;
|
||||
validate_asset_object_version(INITIAL_ASSET_OBJECT_VERSION)?;
|
||||
|
||||
Ok(AssetObjectUpsertInput {
|
||||
asset_object_id: asset_object_id.trim().to_string(),
|
||||
bucket: bucket.trim().to_string(),
|
||||
object_key: object_key.trim().trim_start_matches('/').to_string(),
|
||||
asset_object_id,
|
||||
bucket,
|
||||
object_key,
|
||||
access_policy,
|
||||
content_type: normalize_optional_value(content_type),
|
||||
content_length,
|
||||
@@ -277,7 +272,7 @@ pub fn build_asset_object_upsert_input(
|
||||
owner_user_id: normalize_optional_value(owner_user_id),
|
||||
profile_id: normalize_optional_value(profile_id),
|
||||
entity_id: normalize_optional_value(entity_id),
|
||||
asset_kind: asset_kind.trim().to_string(),
|
||||
asset_kind,
|
||||
updated_at_micros,
|
||||
})
|
||||
}
|
||||
@@ -314,22 +309,27 @@ pub fn build_asset_entity_binding_input(
|
||||
profile_id: Option<String>,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<AssetEntityBindingInput, AssetObjectFieldError> {
|
||||
validate_asset_entity_binding_fields(
|
||||
&binding_id,
|
||||
&asset_object_id,
|
||||
&entity_kind,
|
||||
&entity_id,
|
||||
&slot,
|
||||
&asset_kind,
|
||||
let binding_id =
|
||||
normalize_required_asset_field(binding_id, AssetObjectFieldError::MissingBindingId)?;
|
||||
let asset_object_id = normalize_required_asset_field(
|
||||
asset_object_id,
|
||||
AssetObjectFieldError::MissingAssetObjectId,
|
||||
)?;
|
||||
let entity_kind =
|
||||
normalize_required_asset_field(entity_kind, AssetObjectFieldError::MissingEntityKind)?;
|
||||
let entity_id =
|
||||
normalize_required_asset_field(entity_id, AssetObjectFieldError::MissingEntityId)?;
|
||||
let slot = normalize_required_asset_field(slot, AssetObjectFieldError::MissingSlot)?;
|
||||
let asset_kind =
|
||||
normalize_required_asset_field(asset_kind, AssetObjectFieldError::MissingAssetKind)?;
|
||||
|
||||
Ok(AssetEntityBindingInput {
|
||||
binding_id: binding_id.trim().to_string(),
|
||||
asset_object_id: asset_object_id.trim().to_string(),
|
||||
entity_kind: entity_kind.trim().to_string(),
|
||||
entity_id: entity_id.trim().to_string(),
|
||||
slot: slot.trim().to_string(),
|
||||
asset_kind: asset_kind.trim().to_string(),
|
||||
binding_id,
|
||||
asset_object_id,
|
||||
entity_kind,
|
||||
entity_id,
|
||||
slot,
|
||||
asset_kind,
|
||||
owner_user_id: normalize_optional_value(owner_user_id),
|
||||
profile_id: normalize_optional_value(profile_id),
|
||||
updated_at_micros,
|
||||
@@ -354,24 +354,15 @@ pub fn build_asset_entity_binding_record(
|
||||
}
|
||||
|
||||
pub fn generate_asset_object_id(seed_micros: i64) -> String {
|
||||
format!("{}{:x}", ASSET_OBJECT_ID_PREFIX, seed_micros)
|
||||
build_prefixed_seed_id(ASSET_OBJECT_ID_PREFIX, seed_micros)
|
||||
}
|
||||
|
||||
pub fn generate_asset_binding_id(seed_micros: i64) -> String {
|
||||
format!("{}{:x}", ASSET_BINDING_ID_PREFIX, seed_micros)
|
||||
build_prefixed_seed_id(ASSET_BINDING_ID_PREFIX, seed_micros)
|
||||
}
|
||||
|
||||
pub fn normalize_optional_value(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|value| {
|
||||
let value = value.trim().to_string();
|
||||
if value.is_empty() { None } else { Some(value) }
|
||||
})
|
||||
}
|
||||
|
||||
fn format_timestamp_micros(micros: i64) -> String {
|
||||
let seconds = micros.div_euclid(1_000_000);
|
||||
let subsec_micros = micros.rem_euclid(1_000_000);
|
||||
format!("{seconds}.{subsec_micros:06}Z")
|
||||
normalize_optional_string(value)
|
||||
}
|
||||
|
||||
impl fmt::Display for AssetObjectFieldError {
|
||||
|
||||
@@ -6,6 +6,7 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
platform-auth = { path = "../platform-auth" }
|
||||
shared-kernel = { path = "../shared-kernel" }
|
||||
time = { version = "0.3", features = ["formatting", "parsing"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
|
||||
@@ -6,8 +6,11 @@ use std::{
|
||||
};
|
||||
|
||||
use platform_auth::{hash_password, verify_password};
|
||||
use shared_kernel::{
|
||||
build_prefixed_uuid_id, format_rfc3339 as format_shared_rfc3339, new_uuid_simple_string,
|
||||
normalize_optional_string, normalize_required_string, parse_rfc3339,
|
||||
};
|
||||
use time::{Duration, OffsetDateTime};
|
||||
use uuid::Uuid;
|
||||
|
||||
const USERNAME_MIN_LENGTH: usize = 3;
|
||||
const USERNAME_MAX_LENGTH: usize = 24;
|
||||
@@ -463,22 +466,14 @@ impl RefreshSessionService {
|
||||
.map_err(map_password_store_error)?
|
||||
.ok_or(RefreshSessionError::UserNotFound)?;
|
||||
|
||||
let session_id = format!("usess_{}", Uuid::new_v4().simple());
|
||||
let session_id = build_prefixed_uuid_id("usess_");
|
||||
let expires_at = now
|
||||
.checked_add(Duration::days(i64::from(self.refresh_session_ttl_days)))
|
||||
.ok_or_else(|| {
|
||||
RefreshSessionError::Store("refresh session 过期时间计算溢出".to_string())
|
||||
})?;
|
||||
let now_iso = now
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.map_err(|error| {
|
||||
RefreshSessionError::Store(format!("refresh session 时间格式化失败:{error}"))
|
||||
})?;
|
||||
let expires_at_iso = expires_at
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.map_err(|error| {
|
||||
RefreshSessionError::Store(format!("refresh session 过期时间格式化失败:{error}"))
|
||||
})?;
|
||||
let now_iso = format_rfc3339_with_context(now, "refresh session 时间")?;
|
||||
let expires_at_iso = format_rfc3339_with_context(expires_at, "refresh session 过期时间")?;
|
||||
let session = RefreshSessionRecord {
|
||||
session_id,
|
||||
user_id: input.user_id,
|
||||
@@ -502,10 +497,9 @@ impl RefreshSessionService {
|
||||
input: RotateRefreshSessionInput,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<RotateRefreshSessionResult, RefreshSessionError> {
|
||||
let refresh_token_hash = input.refresh_token_hash.trim().to_string();
|
||||
if refresh_token_hash.is_empty() {
|
||||
let Some(refresh_token_hash) = normalize_required_string(&input.refresh_token_hash) else {
|
||||
return Err(RefreshSessionError::MissingToken);
|
||||
}
|
||||
};
|
||||
|
||||
let session = self
|
||||
.store
|
||||
@@ -516,13 +510,8 @@ impl RefreshSessionService {
|
||||
return Err(RefreshSessionError::SessionNotFound);
|
||||
}
|
||||
|
||||
let expires_at = OffsetDateTime::parse(
|
||||
&session.session.expires_at,
|
||||
&time::format_description::well_known::Rfc3339,
|
||||
)
|
||||
.map_err(|error| {
|
||||
RefreshSessionError::Store(format!("refresh session 过期时间解析失败:{error}"))
|
||||
})?;
|
||||
let expires_at =
|
||||
parse_rfc3339_with_context(&session.session.expires_at, "refresh session 过期时间")?;
|
||||
if expires_at <= now {
|
||||
return Err(RefreshSessionError::SessionExpired);
|
||||
}
|
||||
@@ -538,16 +527,9 @@ impl RefreshSessionService {
|
||||
.ok_or_else(|| {
|
||||
RefreshSessionError::Store("refresh session 过期时间计算溢出".to_string())
|
||||
})?;
|
||||
let now_iso = now
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.map_err(|error| {
|
||||
RefreshSessionError::Store(format!("refresh session 时间格式化失败:{error}"))
|
||||
})?;
|
||||
let next_expires_at_iso = next_expires_at
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.map_err(|error| {
|
||||
RefreshSessionError::Store(format!("refresh session 过期时间格式化失败:{error}"))
|
||||
})?;
|
||||
let now_iso = format_rfc3339_with_context(now, "refresh session 时间")?;
|
||||
let next_expires_at_iso =
|
||||
format_rfc3339_with_context(next_expires_at, "refresh session 过期时间")?;
|
||||
|
||||
let updated_session = self.store.rotate_session(
|
||||
&session.session.session_id,
|
||||
@@ -719,9 +701,9 @@ impl WechatAuthStateService {
|
||||
WechatAuthError::Store(format!("微信 state 过期时间格式化失败:{message}"))
|
||||
})?;
|
||||
let state = WechatAuthStateRecord {
|
||||
wechat_state_id: format!("wxstate_{}", Uuid::new_v4().simple()),
|
||||
wechat_state_id: build_prefixed_uuid_id("wxstate_"),
|
||||
state_token: create_wechat_state_token(),
|
||||
redirect_path: input.redirect_path.trim().to_string(),
|
||||
redirect_path: normalize_required_string(&input.redirect_path).unwrap_or_default(),
|
||||
scene: input.scene,
|
||||
request_user_agent: normalize_optional_string(input.request_user_agent),
|
||||
expires_at,
|
||||
@@ -1035,7 +1017,7 @@ impl InMemoryAuthStore {
|
||||
);
|
||||
let identity = StoredWechatIdentity {
|
||||
user_id: user_id.clone(),
|
||||
provider_uid: profile.provider_uid.trim().to_string(),
|
||||
provider_uid: normalize_required_string(&profile.provider_uid).unwrap_or_default(),
|
||||
provider_union_id: normalize_optional_string(profile.provider_union_id),
|
||||
display_name: normalize_optional_string(profile.display_name),
|
||||
avatar_url: normalize_optional_string(profile.avatar_url),
|
||||
@@ -1100,7 +1082,8 @@ impl InMemoryAuthStore {
|
||||
let next_display_name = normalize_optional_string(profile.display_name);
|
||||
let next_avatar_url = normalize_optional_string(profile.avatar_url);
|
||||
let next_provider_union_id = normalize_optional_string(profile.provider_union_id);
|
||||
let next_provider_uid = profile.provider_uid.trim().to_string();
|
||||
let next_provider_uid =
|
||||
normalize_required_string(&profile.provider_uid).unwrap_or_default();
|
||||
{
|
||||
let identity = state
|
||||
.wechat_identity_by_provider_uid
|
||||
@@ -1717,7 +1700,7 @@ fn map_refresh_error_to_logout_error(error: RefreshSessionError) -> LogoutError
|
||||
}
|
||||
|
||||
fn normalize_username(raw_username: &str) -> Result<String, PasswordEntryError> {
|
||||
let username = raw_username.trim().to_string();
|
||||
let username = normalize_required_string(raw_username).unwrap_or_default();
|
||||
let valid_length =
|
||||
(USERNAME_MIN_LENGTH..=USERNAME_MAX_LENGTH).contains(&username.chars().count());
|
||||
let valid_chars = username
|
||||
@@ -1775,21 +1758,11 @@ fn mask_phone_number(phone_number: &str) -> String {
|
||||
format!("{}****{}", &phone_number[..3], &phone_number[7..11])
|
||||
}
|
||||
|
||||
fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|field| {
|
||||
let trimmed = field.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(trimmed)
|
||||
})
|
||||
}
|
||||
|
||||
fn build_random_password_seed() -> String {
|
||||
format!(
|
||||
"seed_{}_{}",
|
||||
Uuid::new_v4().simple(),
|
||||
Uuid::new_v4().simple()
|
||||
new_uuid_simple_string(),
|
||||
new_uuid_simple_string()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1798,13 +1771,11 @@ fn build_system_username(prefix: &str, sequence: u64) -> String {
|
||||
}
|
||||
|
||||
fn format_rfc3339(value: OffsetDateTime) -> Result<String, String> {
|
||||
value
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.map_err(|error| error.to_string())
|
||||
format_shared_rfc3339(value)
|
||||
}
|
||||
|
||||
fn parse_phone_code_time(value: &str, field_label: &str) -> Result<OffsetDateTime, PhoneAuthError> {
|
||||
OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339)
|
||||
parse_rfc3339(value)
|
||||
.map_err(|error| PhoneAuthError::Store(format!("短信验证码{field_label}解析失败:{error}")))
|
||||
}
|
||||
|
||||
@@ -1818,7 +1789,23 @@ fn build_phone_code_key(phone_number: &str, scene: &PhoneAuthScene) -> String {
|
||||
}
|
||||
|
||||
fn create_wechat_state_token() -> String {
|
||||
Uuid::new_v4().simple().to_string()
|
||||
new_uuid_simple_string()
|
||||
}
|
||||
|
||||
fn format_rfc3339_with_context(
|
||||
value: OffsetDateTime,
|
||||
field_label: &str,
|
||||
) -> Result<String, RefreshSessionError> {
|
||||
format_shared_rfc3339(value)
|
||||
.map_err(|error| RefreshSessionError::Store(format!("{field_label}格式化失败:{error}")))
|
||||
}
|
||||
|
||||
fn parse_rfc3339_with_context(
|
||||
value: &str,
|
||||
field_label: &str,
|
||||
) -> Result<OffsetDateTime, RefreshSessionError> {
|
||||
parse_rfc3339(value)
|
||||
.map_err(|error| RefreshSessionError::Store(format!("{field_label}解析失败:{error}")))
|
||||
}
|
||||
|
||||
impl PhoneAuthScene {
|
||||
|
||||
15
server-rs/crates/module-combat/Cargo.toml
Normal file
15
server-rs/crates/module-combat/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "module-combat"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
spacetime-types = ["dep:spacetimedb"]
|
||||
|
||||
[dependencies]
|
||||
module-runtime-item = { path = "../module-runtime-item", default-features = false }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
shared-kernel = { path = "../shared-kernel" }
|
||||
spacetimedb = { workspace = true, optional = true }
|
||||
@@ -1,29 +1,47 @@
|
||||
# module-combat 独立模块 package 占位说明
|
||||
# module-combat
|
||||
|
||||
日期:`2026-04-20`
|
||||
日期:`2026-04-21`
|
||||
|
||||
## 1. package 职责
|
||||
|
||||
`module-combat` 是战斗规则模块 package,后续负责:
|
||||
`module-combat` 是 M4 阶段的战斗规则 crate,当前负责:
|
||||
|
||||
1. `battle_state` 等战斗状态模型
|
||||
2. 战斗指令、伤害结算、战斗阶段推进规则
|
||||
3. 与 story action 主循环的战斗联动
|
||||
4. 与 `apps/spacetime-module` 的战斗表、reducer、view 聚合对接
|
||||
1. `battle_state` 首版领域类型与字段校验
|
||||
2. `resolve_combat_action` 的纯规则推进
|
||||
3. `fight / spar` 两种模式下的战斗收束规则
|
||||
4. 为 `spacetime-module` 提供可直接复用的战斗状态与 reducer 输入输出类型
|
||||
|
||||
## 2. 当前阶段说明
|
||||
## 2. 当前实现范围
|
||||
|
||||
当前提交仅完成目录占位,不提前进入具体战斗规则与数值实现。
|
||||
当前已经真实落地:
|
||||
|
||||
后续与本 package 直接相关的任务包括:
|
||||
1. `BattleMode / BattleStatus / CombatOutcome`
|
||||
2. `BattleStateInput / BattleStateSnapshot / BattleStateQueryInput`
|
||||
3. `ResolveCombatActionInput / ResolveCombatActionResult`
|
||||
4. `BattleStateProcedureResult / ResolveCombatActionProcedureResult`
|
||||
5. `battle_attack_basic / battle_recover_breath / battle_use_skill / battle_escape_breakout`
|
||||
6. 旧攻击类 function 的兼容解析
|
||||
7. `chapter_id / experience_reward` 最小承载字段,供 `spacetime-module` 在胜利时联动成长结算
|
||||
|
||||
1. 设计 `battle_state`
|
||||
2. 设计 `resolve_combat_action`
|
||||
3. 对齐 battle 结果与兼容响应结构
|
||||
4. 接入 story 主循环的战斗型 action 结算
|
||||
当前刻意未做:
|
||||
|
||||
## 3. 边界约束
|
||||
1. `inventory_use`
|
||||
2. 掉落、好感、任务信号联动
|
||||
3. story AI 续写触发
|
||||
4. 多目标或完整 build / cooldown 真相建模
|
||||
|
||||
1. `module-combat` 保持纯规则、纯状态计算,不直接承接 HTTP、LLM、OSS 或其他外部副作用。
|
||||
2. 战斗联动通过明确 reducer 与模块边界协作,不回到散落在多个 service 的过程式写法。
|
||||
3. 前端兼容输出由 `apps/api-server` 暴露,战斗真相由 `apps/spacetime-module` 聚合。
|
||||
## 3. 配套文档
|
||||
|
||||
落地依据见:
|
||||
|
||||
1. [../../../docs/technical/M4_MODULE_COMBAT_SPACETIMEDB_BASELINE_2026-04-21.md](../../../docs/technical/M4_MODULE_COMBAT_SPACETIMEDB_BASELINE_2026-04-21.md)
|
||||
2. [../../../docs/technical/M4_MODULE_COMBAT_AXUM_FACADE_DESIGN_2026-04-21.md](../../../docs/technical/M4_MODULE_COMBAT_AXUM_FACADE_DESIGN_2026-04-21.md)
|
||||
3. [../../../docs/technical/M4_MODULE_COMBAT_STATE_QUERY_DESIGN_2026-04-22.md](../../../docs/technical/M4_MODULE_COMBAT_STATE_QUERY_DESIGN_2026-04-22.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/prd/AI_NATIVE_BATTLE_SINGLE_ACTION_FUNCTION_PRD_2026-04-18.md](../../../docs/prd/AI_NATIVE_BATTLE_SINGLE_ACTION_FUNCTION_PRD_2026-04-18.md)
|
||||
|
||||
## 4. 边界约束
|
||||
|
||||
1. `module-combat` 只做纯规则、纯状态推进,不承接 HTTP、LLM、OSS 或文件 IO。
|
||||
2. 任何与 `inventory / progression / npc / story` 的联动,都应先在文档里冻结边界后再继续接入。
|
||||
3. 该 crate 的目标不是替代 Axum facade,而是成为 `spacetime-module` 里的战斗真相规则层。
|
||||
|
||||
835
server-rs/crates/module-combat/src/lib.rs
Normal file
835
server-rs/crates/module-combat/src/lib.rs
Normal file
@@ -0,0 +1,835 @@
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
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;
|
||||
|
||||
pub const BATTLE_STATE_ID_PREFIX: &str = "battle_";
|
||||
pub const INITIAL_BATTLE_VERSION: u32 = 1;
|
||||
pub const BASIC_FIGHT_COUNTER_RATIO: f32 = 0.14;
|
||||
pub const MIN_FIGHT_COUNTER_DAMAGE: i32 = 4;
|
||||
pub const SPAR_MIN_HP: i32 = 1;
|
||||
|
||||
const LEGACY_ATTACK_FUNCTION_IDS: [&str; 5] = [
|
||||
"battle_all_in_crush",
|
||||
"battle_guard_break",
|
||||
"battle_probe_pressure",
|
||||
"battle_feint_step",
|
||||
"battle_finisher_window",
|
||||
];
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum BattleMode {
|
||||
Fight,
|
||||
Spar,
|
||||
}
|
||||
|
||||
impl BattleMode {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Fight => "fight",
|
||||
Self::Spar => "spar",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum BattleStatus {
|
||||
Ongoing,
|
||||
Resolved,
|
||||
Aborted,
|
||||
}
|
||||
|
||||
impl BattleStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Ongoing => "ongoing",
|
||||
Self::Resolved => "resolved",
|
||||
Self::Aborted => "aborted",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum CombatOutcome {
|
||||
Ongoing,
|
||||
Victory,
|
||||
SparComplete,
|
||||
Escaped,
|
||||
}
|
||||
|
||||
impl CombatOutcome {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Ongoing => "ongoing",
|
||||
Self::Victory => "victory",
|
||||
Self::SparComplete => "spar_complete",
|
||||
Self::Escaped => "escaped",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum CombatFieldError {
|
||||
MissingBattleStateId,
|
||||
MissingStorySessionId,
|
||||
MissingRuntimeSessionId,
|
||||
MissingActorUserId,
|
||||
MissingTargetNpcId,
|
||||
MissingTargetName,
|
||||
MissingFunctionId,
|
||||
InvalidVersion,
|
||||
InvalidPlayerVitals,
|
||||
InvalidTargetVitals,
|
||||
InvalidRewardItem(String),
|
||||
BattleAlreadyResolved,
|
||||
UnsupportedFunctionId,
|
||||
InsufficientMana,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BattleStateInput {
|
||||
pub battle_state_id: String,
|
||||
pub story_session_id: String,
|
||||
pub runtime_session_id: String,
|
||||
pub actor_user_id: String,
|
||||
pub chapter_id: Option<String>,
|
||||
pub target_npc_id: String,
|
||||
pub target_name: String,
|
||||
pub battle_mode: BattleMode,
|
||||
pub player_hp: i32,
|
||||
pub player_max_hp: i32,
|
||||
pub player_mana: i32,
|
||||
pub player_max_mana: i32,
|
||||
pub target_hp: i32,
|
||||
pub target_max_hp: i32,
|
||||
pub experience_reward: u32,
|
||||
pub reward_items: Vec<RuntimeItemRewardItemSnapshot>,
|
||||
pub created_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BattleStateSnapshot {
|
||||
pub battle_state_id: String,
|
||||
pub story_session_id: String,
|
||||
pub runtime_session_id: String,
|
||||
pub actor_user_id: String,
|
||||
pub chapter_id: Option<String>,
|
||||
pub target_npc_id: String,
|
||||
pub target_name: String,
|
||||
pub battle_mode: BattleMode,
|
||||
pub status: BattleStatus,
|
||||
pub player_hp: i32,
|
||||
pub player_max_hp: i32,
|
||||
pub player_mana: i32,
|
||||
pub player_max_mana: i32,
|
||||
pub target_hp: i32,
|
||||
pub target_max_hp: i32,
|
||||
pub experience_reward: u32,
|
||||
pub reward_items: Vec<RuntimeItemRewardItemSnapshot>,
|
||||
pub turn_index: u32,
|
||||
pub last_action_function_id: Option<String>,
|
||||
pub last_action_text: Option<String>,
|
||||
pub last_result_text: Option<String>,
|
||||
pub last_damage_dealt: i32,
|
||||
pub last_damage_taken: i32,
|
||||
pub last_outcome: CombatOutcome,
|
||||
pub version: u32,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ResolveCombatActionInput {
|
||||
pub battle_state_id: String,
|
||||
pub function_id: String,
|
||||
pub action_text: String,
|
||||
pub base_damage: i32,
|
||||
pub mana_cost: i32,
|
||||
pub heal: i32,
|
||||
pub mana_restore: i32,
|
||||
pub counter_multiplier_basis_points: u32,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BattleStateQueryInput {
|
||||
pub battle_state_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ResolveCombatActionResult {
|
||||
pub snapshot: BattleStateSnapshot,
|
||||
pub damage_dealt: i32,
|
||||
pub damage_taken: i32,
|
||||
pub outcome: CombatOutcome,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BattleStateProcedureResult {
|
||||
pub ok: bool,
|
||||
pub snapshot: Option<BattleStateSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ResolveCombatActionProcedureResult {
|
||||
pub ok: bool,
|
||||
pub result: Option<ResolveCombatActionResult>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
pub fn validate_battle_state_input(input: &BattleStateInput) -> Result<(), CombatFieldError> {
|
||||
if normalize_required_string(&input.battle_state_id).is_none() {
|
||||
return Err(CombatFieldError::MissingBattleStateId);
|
||||
}
|
||||
if normalize_required_string(&input.story_session_id).is_none() {
|
||||
return Err(CombatFieldError::MissingStorySessionId);
|
||||
}
|
||||
if normalize_required_string(&input.runtime_session_id).is_none() {
|
||||
return Err(CombatFieldError::MissingRuntimeSessionId);
|
||||
}
|
||||
if normalize_required_string(&input.actor_user_id).is_none() {
|
||||
return Err(CombatFieldError::MissingActorUserId);
|
||||
}
|
||||
if normalize_required_string(&input.target_npc_id).is_none() {
|
||||
return Err(CombatFieldError::MissingTargetNpcId);
|
||||
}
|
||||
if normalize_required_string(&input.target_name).is_none() {
|
||||
return Err(CombatFieldError::MissingTargetName);
|
||||
}
|
||||
if input.player_max_hp <= 0 || input.player_hp <= 0 || input.player_hp > input.player_max_hp {
|
||||
return Err(CombatFieldError::InvalidPlayerVitals);
|
||||
}
|
||||
if input.player_max_mana < 0
|
||||
|| input.player_mana < 0
|
||||
|| input.player_mana > input.player_max_mana
|
||||
{
|
||||
return Err(CombatFieldError::InvalidPlayerVitals);
|
||||
}
|
||||
if input.target_max_hp <= 0 || input.target_hp <= 0 || input.target_hp > input.target_max_hp {
|
||||
return Err(CombatFieldError::InvalidTargetVitals);
|
||||
}
|
||||
for reward_item in input.reward_items.iter().cloned() {
|
||||
normalize_reward_item_snapshot(reward_item).map_err(map_reward_item_field_error)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_resolve_combat_action_input(
|
||||
input: &ResolveCombatActionInput,
|
||||
) -> Result<(), CombatFieldError> {
|
||||
if normalize_required_string(&input.battle_state_id).is_none() {
|
||||
return Err(CombatFieldError::MissingBattleStateId);
|
||||
}
|
||||
if normalize_required_string(&input.function_id).is_none() {
|
||||
return Err(CombatFieldError::MissingFunctionId);
|
||||
}
|
||||
if !is_supported_combat_function_id(&input.function_id) {
|
||||
return Err(CombatFieldError::UnsupportedFunctionId);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn build_battle_state_query_input(
|
||||
battle_state_id: String,
|
||||
) -> Result<BattleStateQueryInput, CombatFieldError> {
|
||||
let input = BattleStateQueryInput {
|
||||
battle_state_id: normalize_required_string(battle_state_id).unwrap_or_default(),
|
||||
};
|
||||
|
||||
validate_battle_state_query_input(&input)?;
|
||||
|
||||
Ok(input)
|
||||
}
|
||||
|
||||
pub fn validate_battle_state_query_input(
|
||||
input: &BattleStateQueryInput,
|
||||
) -> Result<(), CombatFieldError> {
|
||||
if normalize_required_string(&input.battle_state_id).is_none() {
|
||||
return Err(CombatFieldError::MissingBattleStateId);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn build_battle_state_snapshot(input: BattleStateInput) -> BattleStateSnapshot {
|
||||
BattleStateSnapshot {
|
||||
battle_state_id: input.battle_state_id,
|
||||
story_session_id: input.story_session_id,
|
||||
runtime_session_id: input.runtime_session_id,
|
||||
actor_user_id: input.actor_user_id,
|
||||
chapter_id: input.chapter_id,
|
||||
target_npc_id: input.target_npc_id,
|
||||
target_name: input.target_name,
|
||||
battle_mode: input.battle_mode,
|
||||
status: BattleStatus::Ongoing,
|
||||
player_hp: input.player_hp,
|
||||
player_max_hp: input.player_max_hp,
|
||||
player_mana: input.player_mana,
|
||||
player_max_mana: input.player_max_mana,
|
||||
target_hp: input.target_hp,
|
||||
target_max_hp: input.target_max_hp,
|
||||
experience_reward: input.experience_reward,
|
||||
reward_items: input.reward_items,
|
||||
turn_index: 0,
|
||||
last_action_function_id: None,
|
||||
last_action_text: None,
|
||||
last_result_text: None,
|
||||
last_damage_dealt: 0,
|
||||
last_damage_taken: 0,
|
||||
last_outcome: CombatOutcome::Ongoing,
|
||||
version: INITIAL_BATTLE_VERSION,
|
||||
created_at_micros: input.created_at_micros,
|
||||
updated_at_micros: input.created_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_combat_action(
|
||||
current: BattleStateSnapshot,
|
||||
input: ResolveCombatActionInput,
|
||||
) -> Result<ResolveCombatActionResult, CombatFieldError> {
|
||||
validate_resolve_combat_action_input(&input)?;
|
||||
|
||||
if current.version == 0 {
|
||||
return Err(CombatFieldError::InvalidVersion);
|
||||
}
|
||||
if current.status != BattleStatus::Ongoing {
|
||||
return Err(CombatFieldError::BattleAlreadyResolved);
|
||||
}
|
||||
if current.player_mana < input.mana_cost.max(0) {
|
||||
return Err(CombatFieldError::InsufficientMana);
|
||||
}
|
||||
|
||||
let action_text = if input.action_text.trim().is_empty() {
|
||||
input.function_id.clone()
|
||||
} else {
|
||||
normalize_required_string(input.action_text).unwrap_or_else(|| input.function_id.clone())
|
||||
};
|
||||
|
||||
if input.function_id == "battle_escape_breakout" {
|
||||
let next = BattleStateSnapshot {
|
||||
status: BattleStatus::Resolved,
|
||||
turn_index: current.turn_index + 1,
|
||||
last_action_function_id: Some(input.function_id),
|
||||
last_action_text: Some(action_text),
|
||||
last_result_text: Some(format!("你抓住空当摆脱了{}的压制。", current.target_name)),
|
||||
last_damage_dealt: 0,
|
||||
last_damage_taken: 0,
|
||||
last_outcome: CombatOutcome::Escaped,
|
||||
version: current.version + 1,
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
..current
|
||||
};
|
||||
|
||||
return Ok(ResolveCombatActionResult {
|
||||
snapshot: next,
|
||||
damage_dealt: 0,
|
||||
damage_taken: 0,
|
||||
outcome: CombatOutcome::Escaped,
|
||||
});
|
||||
}
|
||||
|
||||
let mana_cost = input.mana_cost.max(0);
|
||||
let heal = input.heal.max(0);
|
||||
let mana_restore = input.mana_restore.max(0);
|
||||
let base_damage = input.base_damage.max(0);
|
||||
|
||||
let mut next_player_hp = current.player_hp;
|
||||
let mut next_player_mana = (current.player_mana - mana_cost).max(0);
|
||||
let mut next_target_hp = current.target_hp;
|
||||
let mut damage_dealt = 0;
|
||||
let mut damage_taken = 0;
|
||||
|
||||
next_player_hp = clamp_hp(
|
||||
current.battle_mode,
|
||||
next_player_hp + heal,
|
||||
current.player_max_hp,
|
||||
);
|
||||
next_player_mana = clamp_mana(next_player_mana + mana_restore, current.player_max_mana);
|
||||
|
||||
if base_damage > 0 {
|
||||
next_target_hp =
|
||||
clamp_target_hp_after_damage(current.battle_mode, current.target_hp, base_damage);
|
||||
damage_dealt = current.target_hp - next_target_hp;
|
||||
}
|
||||
|
||||
let (status, outcome, result_text) = if is_target_resolved(current.battle_mode, next_target_hp)
|
||||
{
|
||||
let outcome = match current.battle_mode {
|
||||
BattleMode::Fight => CombatOutcome::Victory,
|
||||
BattleMode::Spar => CombatOutcome::SparComplete,
|
||||
};
|
||||
|
||||
(
|
||||
BattleStatus::Resolved,
|
||||
outcome,
|
||||
build_resolved_result_text(&action_text, ¤t.target_name, outcome),
|
||||
)
|
||||
} else {
|
||||
damage_taken = compute_counter_damage(
|
||||
current.battle_mode,
|
||||
current.target_max_hp,
|
||||
input.counter_multiplier_basis_points,
|
||||
);
|
||||
next_player_hp = clamp_hp(
|
||||
current.battle_mode,
|
||||
next_player_hp - damage_taken,
|
||||
current.player_max_hp,
|
||||
);
|
||||
|
||||
(
|
||||
BattleStatus::Ongoing,
|
||||
CombatOutcome::Ongoing,
|
||||
build_ongoing_result_text(&input.function_id, &action_text, ¤t.target_name),
|
||||
)
|
||||
};
|
||||
|
||||
let next = BattleStateSnapshot {
|
||||
player_hp: next_player_hp,
|
||||
player_mana: next_player_mana,
|
||||
target_hp: next_target_hp,
|
||||
status,
|
||||
turn_index: current.turn_index + 1,
|
||||
last_action_function_id: Some(input.function_id),
|
||||
last_action_text: Some(action_text),
|
||||
last_result_text: Some(result_text),
|
||||
last_damage_dealt: damage_dealt,
|
||||
last_damage_taken: damage_taken,
|
||||
last_outcome: outcome,
|
||||
version: current.version + 1,
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
..current
|
||||
};
|
||||
|
||||
Ok(ResolveCombatActionResult {
|
||||
snapshot: next,
|
||||
damage_dealt,
|
||||
damage_taken,
|
||||
outcome,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn generate_battle_state_id(seed_micros: i64) -> String {
|
||||
build_prefixed_seed_id(BATTLE_STATE_ID_PREFIX, seed_micros)
|
||||
}
|
||||
|
||||
pub fn is_supported_combat_function_id(function_id: &str) -> bool {
|
||||
matches!(
|
||||
function_id,
|
||||
"battle_attack_basic"
|
||||
| "battle_recover_breath"
|
||||
| "battle_use_skill"
|
||||
| "battle_escape_breakout"
|
||||
) || LEGACY_ATTACK_FUNCTION_IDS.contains(&function_id)
|
||||
}
|
||||
|
||||
fn clamp_hp(mode: BattleMode, value: i32, max_hp: i32) -> i32 {
|
||||
let min_hp = match mode {
|
||||
BattleMode::Fight => 0,
|
||||
BattleMode::Spar => SPAR_MIN_HP,
|
||||
};
|
||||
|
||||
value.clamp(min_hp, max_hp)
|
||||
}
|
||||
|
||||
fn clamp_mana(value: i32, max_mana: i32) -> i32 {
|
||||
value.clamp(0, max_mana)
|
||||
}
|
||||
|
||||
fn clamp_target_hp_after_damage(mode: BattleMode, current_hp: i32, damage: i32) -> i32 {
|
||||
match mode {
|
||||
BattleMode::Fight => (current_hp - damage).max(0),
|
||||
BattleMode::Spar => (current_hp - damage).max(SPAR_MIN_HP),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_target_resolved(mode: BattleMode, target_hp: i32) -> bool {
|
||||
match mode {
|
||||
BattleMode::Fight => target_hp <= 0,
|
||||
BattleMode::Spar => target_hp <= SPAR_MIN_HP,
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_counter_damage(
|
||||
mode: BattleMode,
|
||||
target_max_hp: i32,
|
||||
counter_multiplier_basis_points: u32,
|
||||
) -> i32 {
|
||||
match mode {
|
||||
BattleMode::Spar => 1,
|
||||
BattleMode::Fight => {
|
||||
let multiplier = counter_multiplier_basis_points as f32 / 10_000.0;
|
||||
let raw =
|
||||
(target_max_hp as f32 * BASIC_FIGHT_COUNTER_RATIO * multiplier).round() as i32;
|
||||
raw.max(MIN_FIGHT_COUNTER_DAMAGE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_resolved_result_text(
|
||||
action_text: &str,
|
||||
target_name: &str,
|
||||
outcome: CombatOutcome,
|
||||
) -> String {
|
||||
match outcome {
|
||||
CombatOutcome::Victory => {
|
||||
format!(
|
||||
"{}命中了{},这轮战斗已经正式结束。",
|
||||
action_text, target_name
|
||||
)
|
||||
}
|
||||
CombatOutcome::SparComplete => {
|
||||
format!(
|
||||
"{}压住了{}的节奏,这场切磋已经分出高下。",
|
||||
action_text, target_name
|
||||
)
|
||||
}
|
||||
CombatOutcome::Escaped => {
|
||||
format!("{}后你成功脱离了当前战斗。", action_text)
|
||||
}
|
||||
CombatOutcome::Ongoing => format!("{}已完成结算。", action_text),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ongoing_result_text(function_id: &str, action_text: &str, target_name: &str) -> String {
|
||||
match function_id {
|
||||
"battle_recover_breath" => {
|
||||
format!(
|
||||
"你先把伤势和气息稳住了一轮,但{}仍在持续逼近。",
|
||||
target_name
|
||||
)
|
||||
}
|
||||
"battle_use_skill" => {
|
||||
format!(
|
||||
"{}命中了{},这一轮技能效果已经直接结算。",
|
||||
action_text, target_name
|
||||
)
|
||||
}
|
||||
_ => format!(
|
||||
"{}命中了{},本次攻击已经完成结算。",
|
||||
action_text, target_name
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_reward_item_field_error(error: TreasureFieldError) -> CombatFieldError {
|
||||
let message = match error {
|
||||
TreasureFieldError::MissingRewardItemId => {
|
||||
"battle_state.reward_items[].item_id 不能为空".to_string()
|
||||
}
|
||||
TreasureFieldError::MissingRewardItemCategory => {
|
||||
"battle_state.reward_items[].category 不能为空".to_string()
|
||||
}
|
||||
TreasureFieldError::MissingRewardItemName => {
|
||||
"battle_state.reward_items[].item_name 不能为空".to_string()
|
||||
}
|
||||
TreasureFieldError::InvalidRewardItemQuantity => {
|
||||
"battle_state.reward_items[].quantity 必须大于 0".to_string()
|
||||
}
|
||||
TreasureFieldError::MissingRewardItemStackKey => {
|
||||
"battle_state.reward_items[].stack_key 不能为空".to_string()
|
||||
}
|
||||
TreasureFieldError::RewardEquipmentItemCannotStack => {
|
||||
"battle_state.reward_items[] 可装备物品不能标记为 stackable".to_string()
|
||||
}
|
||||
TreasureFieldError::RewardNonStackableItemMustStaySingleQuantity => {
|
||||
"battle_state.reward_items[] 不可堆叠物品必须固定为单槽位单数量".to_string()
|
||||
}
|
||||
other => other.to_string(),
|
||||
};
|
||||
|
||||
CombatFieldError::InvalidRewardItem(message)
|
||||
}
|
||||
|
||||
impl fmt::Display for CombatFieldError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::MissingBattleStateId => f.write_str("battle_state.battle_state_id 不能为空"),
|
||||
Self::MissingStorySessionId => f.write_str("battle_state.story_session_id 不能为空"),
|
||||
Self::MissingRuntimeSessionId => {
|
||||
f.write_str("battle_state.runtime_session_id 不能为空")
|
||||
}
|
||||
Self::MissingActorUserId => f.write_str("battle_state.actor_user_id 不能为空"),
|
||||
Self::MissingTargetNpcId => f.write_str("battle_state.target_npc_id 不能为空"),
|
||||
Self::MissingTargetName => f.write_str("battle_state.target_name 不能为空"),
|
||||
Self::MissingFunctionId => f.write_str("resolve_combat_action.function_id 不能为空"),
|
||||
Self::InvalidVersion => f.write_str("battle_state.version 必须大于 0"),
|
||||
Self::InvalidPlayerVitals => f.write_str("battle_state 玩家生命或灵力字段不合法"),
|
||||
Self::InvalidTargetVitals => f.write_str("battle_state 目标生命字段不合法"),
|
||||
Self::InvalidRewardItem(message) => f.write_str(message),
|
||||
Self::BattleAlreadyResolved => f.write_str("battle_state 已经结束,不能继续结算"),
|
||||
Self::UnsupportedFunctionId => {
|
||||
f.write_str("resolve_combat_action.function_id 当前不受支持")
|
||||
}
|
||||
Self::InsufficientMana => f.write_str("当前灵力不足,无法执行该战斗动作"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for CombatFieldError {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn build_fight_snapshot() -> BattleStateSnapshot {
|
||||
build_battle_state_snapshot(BattleStateInput {
|
||||
battle_state_id: "battle_001".to_string(),
|
||||
story_session_id: "storysess_001".to_string(),
|
||||
runtime_session_id: "runtime_001".to_string(),
|
||||
actor_user_id: "user_001".to_string(),
|
||||
chapter_id: Some("chapter_001".to_string()),
|
||||
target_npc_id: "npc_001".to_string(),
|
||||
target_name: "黑爪狼".to_string(),
|
||||
battle_mode: BattleMode::Fight,
|
||||
player_hp: 60,
|
||||
player_max_hp: 60,
|
||||
player_mana: 20,
|
||||
player_max_mana: 20,
|
||||
target_hp: 30,
|
||||
target_max_hp: 30,
|
||||
experience_reward: 18,
|
||||
reward_items: vec![],
|
||||
created_at_micros: 10,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_battle_state_input_accepts_minimal_contract() {
|
||||
let result = validate_battle_state_input(&BattleStateInput {
|
||||
battle_state_id: "battle_001".to_string(),
|
||||
story_session_id: "storysess_001".to_string(),
|
||||
runtime_session_id: "runtime_001".to_string(),
|
||||
actor_user_id: "user_001".to_string(),
|
||||
chapter_id: Some("chapter_001".to_string()),
|
||||
target_npc_id: "npc_001".to_string(),
|
||||
target_name: "黑爪狼".to_string(),
|
||||
battle_mode: BattleMode::Fight,
|
||||
player_hp: 50,
|
||||
player_max_hp: 60,
|
||||
player_mana: 10,
|
||||
player_max_mana: 20,
|
||||
target_hp: 30,
|
||||
target_max_hp: 30,
|
||||
experience_reward: 12,
|
||||
reward_items: vec![],
|
||||
created_at_micros: 1,
|
||||
});
|
||||
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_battle_state_input_rejects_invalid_reward_items() {
|
||||
let error = validate_battle_state_input(&BattleStateInput {
|
||||
battle_state_id: "battle_001".to_string(),
|
||||
story_session_id: "storysess_001".to_string(),
|
||||
runtime_session_id: "runtime_001".to_string(),
|
||||
actor_user_id: "user_001".to_string(),
|
||||
chapter_id: Some("chapter_001".to_string()),
|
||||
target_npc_id: "npc_001".to_string(),
|
||||
target_name: "黑爪狼".to_string(),
|
||||
battle_mode: BattleMode::Fight,
|
||||
player_hp: 50,
|
||||
player_max_hp: 60,
|
||||
player_mana: 10,
|
||||
player_max_mana: 20,
|
||||
target_hp: 30,
|
||||
target_max_hp: 30,
|
||||
experience_reward: 12,
|
||||
reward_items: vec![RuntimeItemRewardItemSnapshot {
|
||||
item_id: String::new(),
|
||||
category: "遗物".to_string(),
|
||||
item_name: "铜钥残片".to_string(),
|
||||
description: None,
|
||||
quantity: 1,
|
||||
rarity: module_runtime_item::RuntimeItemRewardItemRarity::Rare,
|
||||
tags: vec![],
|
||||
stackable: false,
|
||||
stack_key: String::new(),
|
||||
equipment_slot_id: None,
|
||||
}],
|
||||
created_at_micros: 1,
|
||||
})
|
||||
.expect_err("invalid reward item should be rejected");
|
||||
|
||||
assert_eq!(
|
||||
error,
|
||||
CombatFieldError::InvalidRewardItem(
|
||||
"battle_state.reward_items[].item_id 不能为空".to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_battle_state_query_input_trims_and_validates_id() {
|
||||
let input = build_battle_state_query_input(" battle_001 ".to_string())
|
||||
.expect("query input should build");
|
||||
|
||||
assert_eq!(input.battle_state_id, "battle_001");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_battle_state_query_input_rejects_empty_id() {
|
||||
let error =
|
||||
build_battle_state_query_input(" ".to_string()).expect_err("empty id should fail");
|
||||
|
||||
assert_eq!(error, CombatFieldError::MissingBattleStateId);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_basic_attack_advances_turn_and_applies_counter_damage() {
|
||||
let result = resolve_combat_action(
|
||||
build_fight_snapshot(),
|
||||
ResolveCombatActionInput {
|
||||
battle_state_id: "battle_001".to_string(),
|
||||
function_id: "battle_attack_basic".to_string(),
|
||||
action_text: "普通攻击".to_string(),
|
||||
base_damage: 10,
|
||||
mana_cost: 0,
|
||||
heal: 0,
|
||||
mana_restore: 0,
|
||||
counter_multiplier_basis_points: 10_000,
|
||||
updated_at_micros: 20,
|
||||
},
|
||||
)
|
||||
.expect("basic attack should succeed");
|
||||
|
||||
assert_eq!(result.snapshot.turn_index, 1);
|
||||
assert_eq!(result.snapshot.target_hp, 20);
|
||||
assert_eq!(result.snapshot.player_hp, 56);
|
||||
assert_eq!(result.snapshot.last_damage_dealt, 10);
|
||||
assert_eq!(result.snapshot.last_damage_taken, 4);
|
||||
assert_eq!(result.outcome, CombatOutcome::Ongoing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_escape_marks_battle_resolved() {
|
||||
let result = resolve_combat_action(
|
||||
build_fight_snapshot(),
|
||||
ResolveCombatActionInput {
|
||||
battle_state_id: "battle_001".to_string(),
|
||||
function_id: "battle_escape_breakout".to_string(),
|
||||
action_text: "逃跑".to_string(),
|
||||
base_damage: 0,
|
||||
mana_cost: 0,
|
||||
heal: 0,
|
||||
mana_restore: 0,
|
||||
counter_multiplier_basis_points: 0,
|
||||
updated_at_micros: 20,
|
||||
},
|
||||
)
|
||||
.expect("escape should succeed");
|
||||
|
||||
assert_eq!(result.snapshot.status, BattleStatus::Resolved);
|
||||
assert_eq!(result.snapshot.last_outcome, CombatOutcome::Escaped);
|
||||
assert_eq!(result.damage_dealt, 0);
|
||||
assert_eq!(result.damage_taken, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_skill_can_finish_fight() {
|
||||
let result = resolve_combat_action(
|
||||
build_fight_snapshot(),
|
||||
ResolveCombatActionInput {
|
||||
battle_state_id: "battle_001".to_string(),
|
||||
function_id: "battle_use_skill".to_string(),
|
||||
action_text: "试锋斩".to_string(),
|
||||
base_damage: 35,
|
||||
mana_cost: 8,
|
||||
heal: 0,
|
||||
mana_restore: 0,
|
||||
counter_multiplier_basis_points: 9_500,
|
||||
updated_at_micros: 20,
|
||||
},
|
||||
)
|
||||
.expect("skill should succeed");
|
||||
|
||||
assert_eq!(result.snapshot.status, BattleStatus::Resolved);
|
||||
assert_eq!(result.snapshot.target_hp, 0);
|
||||
assert_eq!(result.snapshot.player_mana, 12);
|
||||
assert_eq!(result.outcome, CombatOutcome::Victory);
|
||||
assert_eq!(result.damage_taken, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spar_mode_keeps_hp_floor_at_one() {
|
||||
let snapshot = build_battle_state_snapshot(BattleStateInput {
|
||||
battle_state_id: "battle_002".to_string(),
|
||||
story_session_id: "storysess_001".to_string(),
|
||||
runtime_session_id: "runtime_001".to_string(),
|
||||
actor_user_id: "user_001".to_string(),
|
||||
chapter_id: Some("chapter_spar".to_string()),
|
||||
target_npc_id: "npc_002".to_string(),
|
||||
target_name: "卫队长".to_string(),
|
||||
battle_mode: BattleMode::Spar,
|
||||
player_hp: 5,
|
||||
player_max_hp: 5,
|
||||
player_mana: 10,
|
||||
player_max_mana: 10,
|
||||
target_hp: 3,
|
||||
target_max_hp: 3,
|
||||
experience_reward: 0,
|
||||
reward_items: vec![],
|
||||
created_at_micros: 10,
|
||||
});
|
||||
|
||||
let result = resolve_combat_action(
|
||||
snapshot,
|
||||
ResolveCombatActionInput {
|
||||
battle_state_id: "battle_002".to_string(),
|
||||
function_id: "battle_attack_basic".to_string(),
|
||||
action_text: "普通攻击".to_string(),
|
||||
base_damage: 5,
|
||||
mana_cost: 0,
|
||||
heal: 0,
|
||||
mana_restore: 0,
|
||||
counter_multiplier_basis_points: 10_000,
|
||||
updated_at_micros: 20,
|
||||
},
|
||||
)
|
||||
.expect("spar attack should succeed");
|
||||
|
||||
assert_eq!(result.snapshot.target_hp, 1);
|
||||
assert_eq!(result.snapshot.status, BattleStatus::Resolved);
|
||||
assert_eq!(result.outcome, CombatOutcome::SparComplete);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_rejects_unsupported_function() {
|
||||
let error = resolve_combat_action(
|
||||
build_fight_snapshot(),
|
||||
ResolveCombatActionInput {
|
||||
battle_state_id: "battle_001".to_string(),
|
||||
function_id: "inventory_use".to_string(),
|
||||
action_text: "使用物品".to_string(),
|
||||
base_damage: 0,
|
||||
mana_cost: 0,
|
||||
heal: 0,
|
||||
mana_restore: 0,
|
||||
counter_multiplier_basis_points: 7_200,
|
||||
updated_at_micros: 20,
|
||||
},
|
||||
)
|
||||
.expect_err("inventory_use should be deferred for now");
|
||||
|
||||
assert_eq!(error, CombatFieldError::UnsupportedFunctionId);
|
||||
}
|
||||
}
|
||||
14
server-rs/crates/module-custom-world/Cargo.toml
Normal file
14
server-rs/crates/module-custom-world/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "module-custom-world"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
spacetime-types = ["dep:spacetimedb"]
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
spacetimedb = { workspace = true, optional = true }
|
||||
@@ -1,6 +1,6 @@
|
||||
# module-custom-world 独立模块 package 占位说明
|
||||
# module-custom-world 独立模块 package 说明
|
||||
|
||||
日期:`2026-04-20`
|
||||
日期:`2026-04-21`
|
||||
|
||||
## 1. package 职责
|
||||
|
||||
@@ -14,7 +14,41 @@
|
||||
|
||||
## 2. 当前阶段说明
|
||||
|
||||
当前提交仅完成目录占位,不提前进入问答流、agent 流、世界编译与资产绑定实现。
|
||||
当前阶段已经不再是单纯目录占位,而是先把 `M5` 首批 `custom world / agent` 类型契约与字段校验固定下来,避免 `spacetime-module` 在缺少领域边界的情况下直接堆表。
|
||||
|
||||
当前已落地:
|
||||
|
||||
1. 真实 `Cargo.toml` crate scaffold
|
||||
2. `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
|
||||
|
||||
当前 crate 仍然只承接:
|
||||
|
||||
1. 共享枚举与类型口径
|
||||
2. 字段校验与字符串归一化
|
||||
3. published profile compile 的最小编译摘要 contract
|
||||
4. 后续 `spacetime-module` 聚合表时需要复用的领域边界
|
||||
|
||||
当前阶段明确不提前进入:
|
||||
|
||||
1. 旧问答流 reducer 编排
|
||||
2. RPG 创作 Agent 编排
|
||||
3. publish gate blocker 规则迁移
|
||||
4. 资产绑定与图片生成副作用
|
||||
|
||||
当前设计依据:
|
||||
|
||||
1. [../../../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)
|
||||
2. [../../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md](../../../backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md)
|
||||
3. [../../../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)
|
||||
4. [../../../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 直接相关的任务包括:
|
||||
|
||||
@@ -26,5 +60,6 @@
|
||||
## 3. 边界约束
|
||||
|
||||
1. `module-custom-world` 负责世界状态真相、agent 状态与模块级编排,不把整个会话重新塞回单大 JSON 体。
|
||||
2. 外部 LLM、图片生成、OSS 写入等副作用通过平台适配和应用层完成,状态最终回写到 `apps/spacetime-module` 聚合的状态模型中。
|
||||
3. 前端兼容 REST 与 SSE 由 `apps/api-server` 暴露,但自定义世界主链状态不能再次分散到本地 session store 或前端临时状态中。
|
||||
2. 外部 LLM、图片生成、OSS 写入等副作用通过平台适配和应用层完成,状态最终回写到 `spacetime-module` 聚合的状态模型中。
|
||||
3. 前端兼容 REST 与 SSE 由 `api-server` 暴露,但自定义世界主链状态不能再次分散到本地 session store 或前端临时状态中。
|
||||
4. `custom_world_asset_link` 本轮不冻结,等待 `asset_object / asset_entity_binding / M6 assets` 的槽位规则稳定后再接。
|
||||
|
||||
1544
server-rs/crates/module-custom-world/src/lib.rs
Normal file
1544
server-rs/crates/module-custom-world/src/lib.rs
Normal file
File diff suppressed because it is too large
Load Diff
14
server-rs/crates/module-inventory/Cargo.toml
Normal file
14
server-rs/crates/module-inventory/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "module-inventory"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
spacetime-types = ["dep:spacetimedb"]
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
shared-kernel = { path = "../shared-kernel" }
|
||||
spacetimedb = { workspace = true, optional = true }
|
||||
@@ -1,29 +1,45 @@
|
||||
# module-inventory 独立模块 package 占位说明
|
||||
# module-inventory 独立模块 package 说明
|
||||
|
||||
日期:`2026-04-20`
|
||||
日期:`2026-04-21`
|
||||
|
||||
## 1. package 职责
|
||||
|
||||
`module-inventory` 是背包与物品变更模块 package,后续负责:
|
||||
`module-inventory` 是背包与物品变更模块 package,当前负责:
|
||||
|
||||
1. `inventory_slot` 等背包状态模型
|
||||
2. 物品获得、消耗、赠礼、背包变更规则
|
||||
3. 与 story action、runtime item、NPC 交互的背包联动
|
||||
4. 与 `apps/spacetime-module` 的背包表、reducer、view 聚合对接
|
||||
1. 冻结 `inventory_slot` 的首版领域字段与枚举类型。
|
||||
2. 提供 `apply_inventory_mutation` 纯规则入口,先覆盖:
|
||||
- `GrantItem`
|
||||
- `ConsumeItem`
|
||||
- `EquipItem`
|
||||
- `UnequipItem`
|
||||
3. 冻结 `RuntimeInventoryStateQueryInput / RuntimeInventoryStateSnapshot / RuntimeInventoryStateRecord`
|
||||
4. 为 `spacetime-module` 的背包表、procedure 与 reducer 聚合提供可复用契约。
|
||||
|
||||
## 2. 当前阶段说明
|
||||
|
||||
当前提交仅完成目录占位,不提前进入具体背包规则与读模型实现。
|
||||
当前提交已经从“目录占位”推进到“真实 crate 基座”,但仍然只落最小背包主链,不提前扩成完整玩法迁移。
|
||||
|
||||
后续与本 package 直接相关的任务包括:
|
||||
本轮已落地:
|
||||
|
||||
1. 设计 `inventory_slot`
|
||||
2. 设计 `apply_inventory_mutation`
|
||||
3. 对齐背包 patch、奖励结果与兼容响应结构
|
||||
4. 接入 story action 主循环的背包联动
|
||||
1. `InventorySlotSnapshot` / `InventoryMutationInput` / `InventoryMutationOutcome`
|
||||
2. 背包堆叠、扣减、装备切换、卸下回包的纯规则
|
||||
3. `runtime_session_id + actor_user_id` 作用域下的最小 inventory state query contract
|
||||
4. 与 `spacetime-module` 对接所需的 `SpacetimeType` 兼容类型
|
||||
|
||||
## 3. 边界约束
|
||||
本轮明确未做:
|
||||
|
||||
1. `module-inventory` 负责物品状态真相与背包规则,不把外部 AI、OSS 或 HTTP 协议塞进模块内部。
|
||||
1. `UseItem / Craft / Dismantle / Reforge`
|
||||
2. `npc_trade / npc_gift / quest_turn_in` 的专属 reducer
|
||||
3. 前端背包 view 或 Axum façade
|
||||
4. 旧 `GameState.playerInventory / playerEquipment` 全量兼容
|
||||
|
||||
## 3. 当前冻结边界
|
||||
|
||||
1. `module-inventory` 只负责物品状态真相与背包规则,不把外部 AI、OSS 或 HTTP 协议塞进模块内部。
|
||||
2. 与 `module-story`、`module-runtime-item`、`module-npc` 的协作通过明确 reducer 或投影边界完成。
|
||||
3. 前端兼容输出由 `apps/api-server` 暴露,背包状态真相由 `apps/spacetime-module` 聚合。
|
||||
3. 背包真相由 `spacetime-module` 聚合,对外兼容输出后续由 `api-server` 或 view 补齐。
|
||||
|
||||
## 4. 关联文档
|
||||
|
||||
1. `docs/technical/M4_RPG_RUNTIME_INVENTORY_SPACETIMEDB_BASELINE_2026-04-21.md`
|
||||
2. `backend-rewrite-tasklist/03_M4_STORY_AND_GAMEPLAY.md`
|
||||
|
||||
1063
server-rs/crates/module-inventory/src/lib.rs
Normal file
1063
server-rs/crates/module-inventory/src/lib.rs
Normal file
File diff suppressed because it is too large
Load Diff
14
server-rs/crates/module-npc/Cargo.toml
Normal file
14
server-rs/crates/module-npc/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "module-npc"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
spacetime-types = ["dep:spacetimedb"]
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
shared-kernel = { path = "../shared-kernel" }
|
||||
spacetimedb = { workspace = true, optional = true }
|
||||
@@ -1,30 +1,31 @@
|
||||
# module-npc 独立模块 package 占位说明
|
||||
# module-npc 独立模块 package 说明
|
||||
|
||||
日期:`2026-04-20`
|
||||
日期:`2026-04-21`
|
||||
|
||||
## 1. package 职责
|
||||
|
||||
`module-npc` 是 NPC 状态与互动模块 package,后续负责:
|
||||
`module-npc` 是 NPC 状态与互动模块 package,当前首轮已经开始承接:
|
||||
|
||||
1. `npc_state` 等 NPC 关系与状态模型
|
||||
2. 招募、关系变化、互动规则与场景语义状态
|
||||
3. 与 story action、runtime、custom world 的 NPC 联动
|
||||
4. 与 `apps/api-server` 的 NPC 相关 facade 与流式交互对接
|
||||
5. 与 `apps/spacetime-module` 的 NPC 表、reducer、view 聚合对接
|
||||
1. `relation_state`
|
||||
2. `stance_profile`
|
||||
3. `npc_state`
|
||||
4. `resolve_npc_social_action`
|
||||
5. 与 `spacetime-module` 的 NPC 表、reducer、procedure 聚合对接
|
||||
|
||||
## 2. 当前阶段说明
|
||||
|
||||
当前提交仅完成目录占位,不提前进入对话生成、状态投影与交互规则实现。
|
||||
当前提交不再只是目录占位,已经进入首版领域 contract 落地阶段。
|
||||
|
||||
后续与本 package 直接相关的任务包括:
|
||||
当前已冻结的最小能力包括:
|
||||
|
||||
1. 设计 `npc_state`
|
||||
2. 设计 `resolve_npc_interaction`
|
||||
3. 对齐 NPC 关系变化、招募、对话相关兼容输出
|
||||
4. 接入 story 主循环与 custom world 的 NPC 联动
|
||||
1. `npc_state` 字段、校验与归一化 helper
|
||||
2. `relation_state` 与 `stance_profile` 的派生规则
|
||||
3. `Chat / Help / Gift / Recruit / QuestAccept` 五类社交动作的最小状态迁移
|
||||
4. `SpacetimeDB` 真相表与同步 procedure 接线
|
||||
|
||||
## 3. 边界约束
|
||||
|
||||
1. `module-npc` 负责 NPC 状态真相与互动规则,外部 LLM 台词生成与流式文本输出不直接塞进模块内部。
|
||||
2. 对话与招募文案生成优先通过对应模块应用层和平台适配完成,NPC 状态最终回写到 `apps/spacetime-module` 聚合的状态模型中。
|
||||
3. 前端兼容接口与 SSE 由 `apps/api-server` 暴露,但 NPC 状态不能再次分散到会话缓存或前端临时状态中。
|
||||
2. 对话与招募文案生成优先通过应用层和平台适配完成,`module-npc` 当前只处理状态真相,不直接产出台词。
|
||||
3. 前端兼容接口与 SSE 由 `api-server` 暴露,但 NPC 状态不能再次分散到会话缓存或前端临时状态中。
|
||||
4. 背包、任务、战斗、副本等副作用暂不在本 crate 内部结算,继续通过其他模块协作完成。
|
||||
|
||||
923
server-rs/crates/module-npc/src/lib.rs
Normal file
923
server-rs/crates/module-npc/src/lib.rs
Normal file
@@ -0,0 +1,923 @@
|
||||
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 {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn build_base_state() -> NpcStateSnapshot {
|
||||
normalize_npc_state_snapshot(
|
||||
NpcStateUpsertInput {
|
||||
runtime_session_id: "runtime_001".to_string(),
|
||||
npc_id: "npc_001".to_string(),
|
||||
npc_name: "宁霜".to_string(),
|
||||
affinity: 18,
|
||||
help_used: false,
|
||||
chatted_count: 0,
|
||||
gifts_given: 0,
|
||||
recruited: false,
|
||||
trade_stock_signature: None,
|
||||
revealed_facts: vec![],
|
||||
known_attribute_rumors: vec![],
|
||||
first_meaningful_contact_resolved: false,
|
||||
seen_backstory_chapter_ids: vec![],
|
||||
stance_profile: None,
|
||||
updated_at_micros: 10,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.expect("base npc state should be valid")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relation_state_uses_expected_thresholds() {
|
||||
assert_eq!(build_relation_state(-1).stance, NpcRelationStance::Hostile);
|
||||
assert_eq!(build_relation_state(0).stance, NpcRelationStance::Guarded);
|
||||
assert_eq!(build_relation_state(15).stance, NpcRelationStance::Neutral);
|
||||
assert_eq!(
|
||||
build_relation_state(30).stance,
|
||||
NpcRelationStance::Cooperative
|
||||
);
|
||||
assert_eq!(build_relation_state(60).stance, NpcRelationStance::Bonded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_npc_state_snapshot_builds_primary_fields() {
|
||||
let snapshot = build_base_state();
|
||||
|
||||
assert_eq!(snapshot.npc_state_id, "npcstate_runtime_001:npc_001");
|
||||
assert_eq!(snapshot.relation_state.stance, NpcRelationStance::Neutral);
|
||||
assert_eq!(snapshot.created_at_micros, 10);
|
||||
assert_eq!(snapshot.updated_at_micros, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_action_increases_affinity_and_marks_first_contact() {
|
||||
let next = apply_npc_social_action(
|
||||
build_base_state(),
|
||||
ResolveNpcSocialActionInput {
|
||||
runtime_session_id: "runtime_001".to_string(),
|
||||
npc_id: "npc_001".to_string(),
|
||||
npc_name: "宁霜".to_string(),
|
||||
action_kind: NpcSocialActionKind::Chat,
|
||||
affinity_gain_override: None,
|
||||
note: None,
|
||||
updated_at_micros: 20,
|
||||
},
|
||||
)
|
||||
.expect("chat should succeed");
|
||||
|
||||
assert_eq!(next.affinity, 24);
|
||||
assert_eq!(next.chatted_count, 1);
|
||||
assert!(next.first_meaningful_contact_resolved);
|
||||
assert_eq!(next.updated_at_micros, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_action_rejects_second_use() {
|
||||
let used_state = NpcStateSnapshot {
|
||||
help_used: true,
|
||||
..build_base_state()
|
||||
};
|
||||
|
||||
let error = apply_npc_social_action(
|
||||
used_state,
|
||||
ResolveNpcSocialActionInput {
|
||||
runtime_session_id: "runtime_001".to_string(),
|
||||
npc_id: "npc_001".to_string(),
|
||||
npc_name: "宁霜".to_string(),
|
||||
action_kind: NpcSocialActionKind::Help,
|
||||
affinity_gain_override: None,
|
||||
note: None,
|
||||
updated_at_micros: 20,
|
||||
},
|
||||
)
|
||||
.expect_err("help should fail once used");
|
||||
|
||||
assert_eq!(error, NpcStateFieldError::HelpAlreadyUsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recruit_requires_threshold() {
|
||||
let error = apply_npc_social_action(
|
||||
build_base_state(),
|
||||
ResolveNpcSocialActionInput {
|
||||
runtime_session_id: "runtime_001".to_string(),
|
||||
npc_id: "npc_001".to_string(),
|
||||
npc_name: "宁霜".to_string(),
|
||||
action_kind: NpcSocialActionKind::Recruit,
|
||||
affinity_gain_override: None,
|
||||
note: None,
|
||||
updated_at_micros: 20,
|
||||
},
|
||||
)
|
||||
.expect_err("recruit should require threshold");
|
||||
|
||||
assert_eq!(error, NpcStateFieldError::RecruitAffinityTooLow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recruit_marks_state_when_affinity_is_high_enough() {
|
||||
let recruitable = NpcStateSnapshot {
|
||||
affinity: 66,
|
||||
relation_state: build_relation_state(66),
|
||||
..build_base_state()
|
||||
};
|
||||
|
||||
let next = apply_npc_social_action(
|
||||
recruitable,
|
||||
ResolveNpcSocialActionInput {
|
||||
runtime_session_id: "runtime_001".to_string(),
|
||||
npc_id: "npc_001".to_string(),
|
||||
npc_name: "宁霜".to_string(),
|
||||
action_kind: NpcSocialActionKind::Recruit,
|
||||
affinity_gain_override: None,
|
||||
note: None,
|
||||
updated_at_micros: 20,
|
||||
},
|
||||
)
|
||||
.expect("recruit should succeed");
|
||||
|
||||
assert!(next.recruited);
|
||||
assert!(next.first_meaningful_contact_resolved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_preview_talk_keeps_affinity_unchanged() {
|
||||
let result = resolve_npc_interaction(
|
||||
build_base_state(),
|
||||
ResolveNpcInteractionInput {
|
||||
runtime_session_id: "runtime_001".to_string(),
|
||||
npc_id: "npc_001".to_string(),
|
||||
npc_name: "宁霜".to_string(),
|
||||
interaction_function_id: NPC_PREVIEW_TALK_FUNCTION_ID.to_string(),
|
||||
release_npc_id: None,
|
||||
updated_at_micros: 20,
|
||||
},
|
||||
)
|
||||
.expect("preview talk should succeed");
|
||||
|
||||
assert_eq!(result.interaction_status, NpcInteractionStatus::Previewed);
|
||||
assert!(!result.affinity_changed);
|
||||
assert_eq!(result.previous_affinity, 18);
|
||||
assert_eq!(result.next_affinity, 18);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_chat_updates_npc_state_and_returns_dialogue_status() {
|
||||
let result = resolve_npc_interaction(
|
||||
build_base_state(),
|
||||
ResolveNpcInteractionInput {
|
||||
runtime_session_id: "runtime_001".to_string(),
|
||||
npc_id: "npc_001".to_string(),
|
||||
npc_name: "宁霜".to_string(),
|
||||
interaction_function_id: NPC_CHAT_FUNCTION_ID.to_string(),
|
||||
release_npc_id: None,
|
||||
updated_at_micros: 20,
|
||||
},
|
||||
)
|
||||
.expect("chat interaction should succeed");
|
||||
|
||||
assert_eq!(result.interaction_status, NpcInteractionStatus::Dialogue);
|
||||
assert!(result.affinity_changed);
|
||||
assert_eq!(result.next_affinity, 24);
|
||||
assert!(result.npc_state.first_meaningful_contact_resolved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_fight_returns_battle_pending_without_affinity_change() {
|
||||
let result = resolve_npc_interaction(
|
||||
build_base_state(),
|
||||
ResolveNpcInteractionInput {
|
||||
runtime_session_id: "runtime_001".to_string(),
|
||||
npc_id: "npc_001".to_string(),
|
||||
npc_name: "宁霜".to_string(),
|
||||
interaction_function_id: NPC_FIGHT_FUNCTION_ID.to_string(),
|
||||
release_npc_id: None,
|
||||
updated_at_micros: 20,
|
||||
},
|
||||
)
|
||||
.expect("fight interaction should succeed");
|
||||
|
||||
assert_eq!(
|
||||
result.interaction_status,
|
||||
NpcInteractionStatus::BattlePending
|
||||
);
|
||||
assert_eq!(result.battle_mode, Some(NpcInteractionBattleMode::Fight));
|
||||
assert!(!result.affinity_changed);
|
||||
}
|
||||
}
|
||||
14
server-rs/crates/module-progression/Cargo.toml
Normal file
14
server-rs/crates/module-progression/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "module-progression"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
spacetime-types = ["dep:spacetimedb"]
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
shared-kernel = { path = "../shared-kernel" }
|
||||
spacetimedb = { workspace = true, optional = true }
|
||||
@@ -1,29 +1,43 @@
|
||||
# module-progression 独立模块 package 占位说明
|
||||
# module-progression 成长与章节推进模块 crate 说明
|
||||
|
||||
日期:`2026-04-20`
|
||||
日期:`2026-04-21`
|
||||
|
||||
## 1. package 职责
|
||||
## 1. crate 职责
|
||||
|
||||
`module-progression` 是成长与章节推进模块 package,后续负责:
|
||||
`module-progression` 是成长与章节推进模块 crate,当前与后续负责:
|
||||
|
||||
1. `player_progression`、`chapter_progression` 等成长状态模型
|
||||
2. 等级、章节推进、敌对强度与进程规则
|
||||
3. 与 runtime、story、quest 的成长联动
|
||||
4. 与 `apps/spacetime-module` 的成长表、reducer、view 聚合对接
|
||||
1. `player_progression`、`chapter_progression` 的领域快照与校验规则。
|
||||
2. 等级经验曲线、同级参考强度、敌对战斗生命值与经验掉落的统一数学基线。
|
||||
3. 章节经验预算、章节实际记账与章节自动定级的纯领域 helper。
|
||||
4. 与 `crates/spacetime-module` 的成长真相表、reducer、procedure 聚合对接。
|
||||
|
||||
## 2. 当前阶段说明
|
||||
|
||||
当前提交仅完成目录占位,不提前进入成长规则、投影与兼容接口实现。
|
||||
当前阶段已不再是目录占位,已经完成以下首版落地:
|
||||
|
||||
后续与本 package 直接相关的任务包括:
|
||||
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. 设计 `player_progression`、`chapter_progression`
|
||||
2. 设计 `update_progression_state`
|
||||
3. 对齐章节推进、成长变化与兼容输出结构
|
||||
4. 接入 runtime 与 story 的成长联动
|
||||
当前这轮刻意未做的范围:
|
||||
|
||||
## 3. 边界约束
|
||||
1. 还没有把 `custom-world` 章节蓝图编译直接迁进 Rust。
|
||||
2. 还没有把 `repeatPenalty`、超预算衰减和完整章节偏差审计表独立拆出。
|
||||
3. 还没有在 crate 内直接承接 HTTP、Axum、LLM 或 OSS 副作用。
|
||||
|
||||
1. `module-progression` 保持纯领域规则与状态建模,不直接承接 LLM、OSS 或 HTTP 协议。
|
||||
2. 成长状态作为 runtime 与 story 的公共领域组件,不能再次散落回单个 handler 或临时 service 中。
|
||||
3. 前端兼容输出由 `apps/api-server` 暴露,成长状态真相由 `apps/spacetime-module` 聚合。
|
||||
## 3. 当前已冻结关联文档
|
||||
|
||||
1. [../../../docs/design/LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md](../../../docs/design/LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md)
|
||||
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)
|
||||
|
||||
## 4. 边界约束
|
||||
|
||||
1. `module-progression` 保持纯领域规则与状态建模,不直接承接 HTTP、JWT、OSS、LLM 或本地文件副作用。
|
||||
2. 等级、经验、章节预算、自动定级必须以本 crate 的规则为唯一数学基线,不能再次散落回 route handler 或前端临时推导。
|
||||
3. `player_progression` 与 `chapter_progression` 的持久化真相由 `crates/spacetime-module` 聚合,前端兼容输出与后端 facade 由 `crates/api-server` 暴露。
|
||||
4. 若后续 `module-custom-world` 的章节蓝图 Rust 化与当前 helper 有冲突,必须先校正文档和领域规则,再继续接线。
|
||||
|
||||
770
server-rs/crates/module-progression/src/lib.rs
Normal file
770
server-rs/crates/module-progression/src/lib.rs
Normal file
@@ -0,0 +1,770 @@
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared_kernel::normalize_required_string;
|
||||
#[cfg(feature = "spacetime-types")]
|
||||
use spacetimedb::SpacetimeType;
|
||||
|
||||
pub const MAX_PLAYER_LEVEL: u32 = 20;
|
||||
pub const DEFAULT_TERMINAL_STORY_LEVEL: u32 = 15;
|
||||
pub const MIN_TERMINAL_STORY_LEVEL: u32 = 5;
|
||||
pub const PSEUDO_LEVEL_CURVE_EXPONENT: f64 = 0.92;
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PlayerProgressionGrantSource {
|
||||
Quest,
|
||||
HostileNpc,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ChapterPaceBand {
|
||||
OpeningFast,
|
||||
Steady,
|
||||
Pressure,
|
||||
FinaleDense,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ProgressionRole {
|
||||
Guide,
|
||||
Ambient,
|
||||
Support,
|
||||
HostileStandard,
|
||||
HostileElite,
|
||||
HostileBoss,
|
||||
Rival,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum LevelProfileSource {
|
||||
ChapterAuto,
|
||||
PresetOverride,
|
||||
Manual,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct LevelBenchmark {
|
||||
pub level: u32,
|
||||
pub xp_to_next_level: u32,
|
||||
pub cumulative_xp_required: u32,
|
||||
pub reference_strength: u32,
|
||||
pub base_hp: u32,
|
||||
pub base_mana: u32,
|
||||
pub baseline_damage_scale: f32,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PlayerProgressionSnapshot {
|
||||
pub user_id: String,
|
||||
pub level: u32,
|
||||
pub current_level_xp: u32,
|
||||
pub total_xp: u32,
|
||||
pub xp_to_next_level: u32,
|
||||
pub pending_level_ups: u32,
|
||||
pub last_granted_source: Option<PlayerProgressionGrantSource>,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PlayerProgressionGetInput {
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PlayerProgressionGrantInput {
|
||||
pub user_id: String,
|
||||
pub amount: u32,
|
||||
pub source: PlayerProgressionGrantSource,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PlayerProgressionProcedureResult {
|
||||
pub ok: bool,
|
||||
pub record: Option<PlayerProgressionSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ChapterProgressionSnapshot {
|
||||
pub user_id: String,
|
||||
pub chapter_id: String,
|
||||
pub chapter_index: u32,
|
||||
pub total_chapters: u32,
|
||||
pub entry_pseudo_level_millis: u32,
|
||||
pub exit_pseudo_level_millis: u32,
|
||||
pub entry_level: u32,
|
||||
pub exit_level: u32,
|
||||
pub planned_total_xp: u32,
|
||||
pub planned_quest_xp: u32,
|
||||
pub planned_hostile_xp: u32,
|
||||
pub actual_quest_xp: u32,
|
||||
pub actual_hostile_xp: u32,
|
||||
pub expected_hostile_defeat_count: u32,
|
||||
pub actual_hostile_defeat_count: u32,
|
||||
pub level_at_entry: u32,
|
||||
pub level_at_exit: Option<u32>,
|
||||
pub pace_band: ChapterPaceBand,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ChapterProgressionGetInput {
|
||||
pub user_id: String,
|
||||
pub chapter_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ChapterProgressionInput {
|
||||
pub user_id: String,
|
||||
pub chapter_id: String,
|
||||
pub chapter_index: u32,
|
||||
pub total_chapters: u32,
|
||||
pub entry_pseudo_level_millis: u32,
|
||||
pub exit_pseudo_level_millis: u32,
|
||||
pub entry_level: u32,
|
||||
pub exit_level: u32,
|
||||
pub planned_total_xp: u32,
|
||||
pub planned_quest_xp: u32,
|
||||
pub planned_hostile_xp: u32,
|
||||
pub expected_hostile_defeat_count: u32,
|
||||
pub level_at_entry: u32,
|
||||
pub pace_band: ChapterPaceBand,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ChapterProgressionLedgerInput {
|
||||
pub user_id: String,
|
||||
pub chapter_id: String,
|
||||
pub granted_quest_xp: u32,
|
||||
pub granted_hostile_xp: u32,
|
||||
pub hostile_defeat_increment: u32,
|
||||
pub level_at_exit: Option<u32>,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ChapterProgressionProcedureResult {
|
||||
pub ok: bool,
|
||||
pub record: Option<ChapterProgressionSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RuntimeEntityLevelProfile {
|
||||
pub level: u32,
|
||||
pub reference_strength: u32,
|
||||
pub chapter_id: Option<String>,
|
||||
pub chapter_index: Option<u32>,
|
||||
pub progression_role: ProgressionRole,
|
||||
pub source: LevelProfileSource,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ChapterAutoLevelProfileInput {
|
||||
pub chapter_id: String,
|
||||
pub chapter_index: u32,
|
||||
pub entry_pseudo_level_millis: u32,
|
||||
pub exit_pseudo_level_millis: u32,
|
||||
pub stage_progress_millis: u32,
|
||||
pub progression_role: ProgressionRole,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ProgressionFieldError {
|
||||
MissingUserId,
|
||||
MissingChapterId,
|
||||
InvalidChapterIndex,
|
||||
InvalidTotalChapters,
|
||||
InvalidLevel,
|
||||
InvalidEntryExitLevel,
|
||||
InvalidXpBudget,
|
||||
InvalidExpectedHostileDefeatCount,
|
||||
}
|
||||
|
||||
fn clamp_level(level: u32) -> u32 {
|
||||
level.clamp(1, MAX_PLAYER_LEVEL)
|
||||
}
|
||||
|
||||
fn round_metric(value: f64, digits: usize) -> f64 {
|
||||
let factor = 10_f64.powi(digits as i32);
|
||||
(value * factor).round() / factor
|
||||
}
|
||||
|
||||
fn scale(level: u32) -> u32 {
|
||||
level.saturating_sub(1)
|
||||
}
|
||||
|
||||
// 等级经验曲线与现有 Node 版保持同一公式,避免 Rust 迁移后成长节奏漂移。
|
||||
pub fn compute_xp_to_next_level(level: u32) -> u32 {
|
||||
let normalized_level = clamp_level(level);
|
||||
let scale = scale(normalized_level);
|
||||
60 + 20 * scale + 8 * scale * scale
|
||||
}
|
||||
|
||||
pub fn build_level_benchmark(level: u32) -> LevelBenchmark {
|
||||
let normalized_level = clamp_level(level);
|
||||
let current_scale = scale(normalized_level);
|
||||
let mut cumulative_xp_required = 0_u32;
|
||||
|
||||
for current in 1..normalized_level {
|
||||
cumulative_xp_required += compute_xp_to_next_level(current);
|
||||
}
|
||||
|
||||
let xp_to_next_level = if normalized_level >= MAX_PLAYER_LEVEL {
|
||||
0
|
||||
} else {
|
||||
compute_xp_to_next_level(normalized_level)
|
||||
};
|
||||
|
||||
LevelBenchmark {
|
||||
level: normalized_level,
|
||||
xp_to_next_level,
|
||||
cumulative_xp_required,
|
||||
reference_strength: 100 + 16 * current_scale + 6 * current_scale * current_scale,
|
||||
base_hp: 180 + 24 * current_scale + 10 * current_scale * current_scale,
|
||||
base_mana: 80 + 14 * current_scale + 6 * current_scale * current_scale,
|
||||
baseline_damage_scale: round_metric(
|
||||
1.0 + 0.12 * f64::from(current_scale) + 0.03 * f64::from(current_scale * current_scale),
|
||||
3,
|
||||
) as f32,
|
||||
}
|
||||
}
|
||||
|
||||
// 总经验决定真实等级,SpacetimeDB 持久化后不再允许前端自己推导等级结果。
|
||||
pub fn resolve_level_from_total_xp(total_xp: u32) -> u32 {
|
||||
let mut resolved_level = 1;
|
||||
|
||||
for level in 2..=MAX_PLAYER_LEVEL {
|
||||
if total_xp < build_level_benchmark(level).cumulative_xp_required {
|
||||
break;
|
||||
}
|
||||
resolved_level = level;
|
||||
}
|
||||
|
||||
resolved_level
|
||||
}
|
||||
|
||||
pub fn build_player_progression_snapshot(
|
||||
user_id: String,
|
||||
total_xp: u32,
|
||||
last_granted_source: Option<PlayerProgressionGrantSource>,
|
||||
created_at_micros: i64,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<PlayerProgressionSnapshot, ProgressionFieldError> {
|
||||
let user_id = normalize_required_text(user_id, ProgressionFieldError::MissingUserId)?;
|
||||
let level = resolve_level_from_total_xp(total_xp);
|
||||
let benchmark = build_level_benchmark(level);
|
||||
|
||||
let (current_level_xp, xp_to_next_level) = if level >= MAX_PLAYER_LEVEL {
|
||||
(0, 0)
|
||||
} else {
|
||||
(
|
||||
total_xp.saturating_sub(benchmark.cumulative_xp_required),
|
||||
benchmark.xp_to_next_level,
|
||||
)
|
||||
};
|
||||
|
||||
Ok(PlayerProgressionSnapshot {
|
||||
user_id,
|
||||
level,
|
||||
current_level_xp,
|
||||
total_xp,
|
||||
xp_to_next_level,
|
||||
pending_level_ups: 0,
|
||||
last_granted_source,
|
||||
created_at_micros,
|
||||
updated_at_micros,
|
||||
})
|
||||
}
|
||||
|
||||
// 新存档默认统一回填为 Lv.1 / 0 XP,后续再由任务和战斗奖励驱动成长。
|
||||
pub fn create_initial_player_progression(
|
||||
user_id: String,
|
||||
created_at_micros: i64,
|
||||
) -> Result<PlayerProgressionSnapshot, ProgressionFieldError> {
|
||||
build_player_progression_snapshot(user_id, 0, None, created_at_micros, created_at_micros)
|
||||
}
|
||||
|
||||
// 经验结算统一以“旧快照 + grant 输入”生成新快照,避免各调用方自行改等级字段。
|
||||
pub fn grant_player_experience(
|
||||
current: PlayerProgressionSnapshot,
|
||||
input: PlayerProgressionGrantInput,
|
||||
) -> Result<PlayerProgressionSnapshot, ProgressionFieldError> {
|
||||
let user_id = normalize_required_text(input.user_id, ProgressionFieldError::MissingUserId)?;
|
||||
if current.user_id != user_id {
|
||||
return Err(ProgressionFieldError::MissingUserId);
|
||||
}
|
||||
|
||||
let next_total_xp = current.total_xp.saturating_add(input.amount);
|
||||
let mut next = build_player_progression_snapshot(
|
||||
current.user_id.clone(),
|
||||
next_total_xp,
|
||||
Some(input.source),
|
||||
current.created_at_micros,
|
||||
input.updated_at_micros,
|
||||
)?;
|
||||
next.pending_level_ups = next.level.saturating_sub(current.level);
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
// 章节快照同时承载计划预算与实际记账,这样 chapter_progression 一张表就能覆盖计划/偏差回看。
|
||||
pub fn build_chapter_progression_snapshot(
|
||||
input: ChapterProgressionInput,
|
||||
) -> Result<ChapterProgressionSnapshot, ProgressionFieldError> {
|
||||
let user_id = normalize_required_text(input.user_id, ProgressionFieldError::MissingUserId)?;
|
||||
let chapter_id =
|
||||
normalize_required_text(input.chapter_id, ProgressionFieldError::MissingChapterId)?;
|
||||
|
||||
if input.chapter_index == 0 {
|
||||
return Err(ProgressionFieldError::InvalidChapterIndex);
|
||||
}
|
||||
if input.total_chapters == 0 || input.chapter_index > input.total_chapters {
|
||||
return Err(ProgressionFieldError::InvalidTotalChapters);
|
||||
}
|
||||
|
||||
let entry_level = clamp_level(input.entry_level);
|
||||
let exit_level = clamp_level(input.exit_level);
|
||||
if exit_level < entry_level {
|
||||
return Err(ProgressionFieldError::InvalidEntryExitLevel);
|
||||
}
|
||||
|
||||
if input.planned_total_xp < input.planned_quest_xp + input.planned_hostile_xp {
|
||||
return Err(ProgressionFieldError::InvalidXpBudget);
|
||||
}
|
||||
|
||||
Ok(ChapterProgressionSnapshot {
|
||||
user_id,
|
||||
chapter_id,
|
||||
chapter_index: input.chapter_index,
|
||||
total_chapters: input.total_chapters,
|
||||
entry_pseudo_level_millis: input.entry_pseudo_level_millis.max(1_000),
|
||||
exit_pseudo_level_millis: input
|
||||
.exit_pseudo_level_millis
|
||||
.max(input.entry_pseudo_level_millis.max(1_000)),
|
||||
entry_level,
|
||||
exit_level,
|
||||
planned_total_xp: input.planned_total_xp,
|
||||
planned_quest_xp: input.planned_quest_xp,
|
||||
planned_hostile_xp: input.planned_hostile_xp,
|
||||
actual_quest_xp: 0,
|
||||
actual_hostile_xp: 0,
|
||||
expected_hostile_defeat_count: input.expected_hostile_defeat_count,
|
||||
actual_hostile_defeat_count: 0,
|
||||
level_at_entry: clamp_level(input.level_at_entry),
|
||||
level_at_exit: None,
|
||||
pace_band: input.pace_band,
|
||||
created_at_micros: input.updated_at_micros,
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
})
|
||||
}
|
||||
|
||||
// 按章累计实际任务经验、敌对经验与击杀次数,为后续章节节奏评估提供同一真相源。
|
||||
pub fn apply_chapter_progression_ledger(
|
||||
current: ChapterProgressionSnapshot,
|
||||
input: ChapterProgressionLedgerInput,
|
||||
) -> Result<ChapterProgressionSnapshot, ProgressionFieldError> {
|
||||
let user_id = normalize_required_text(input.user_id, ProgressionFieldError::MissingUserId)?;
|
||||
let chapter_id =
|
||||
normalize_required_text(input.chapter_id, ProgressionFieldError::MissingChapterId)?;
|
||||
|
||||
if current.user_id != user_id || current.chapter_id != chapter_id {
|
||||
return Err(ProgressionFieldError::MissingChapterId);
|
||||
}
|
||||
|
||||
Ok(ChapterProgressionSnapshot {
|
||||
actual_quest_xp: current
|
||||
.actual_quest_xp
|
||||
.saturating_add(input.granted_quest_xp),
|
||||
actual_hostile_xp: current
|
||||
.actual_hostile_xp
|
||||
.saturating_add(input.granted_hostile_xp),
|
||||
actual_hostile_defeat_count: current
|
||||
.actual_hostile_defeat_count
|
||||
.saturating_add(input.hostile_defeat_increment),
|
||||
level_at_exit: input
|
||||
.level_at_exit
|
||||
.map(clamp_level)
|
||||
.or(current.level_at_exit),
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
..current
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resolve_terminal_story_level(total_chapters: u32) -> u32 {
|
||||
let resolved = (3_f64 + f64::from(total_chapters.max(1)) * 2.4).round() as u32;
|
||||
resolved.clamp(MIN_TERMINAL_STORY_LEVEL, DEFAULT_TERMINAL_STORY_LEVEL)
|
||||
}
|
||||
|
||||
// 章节边界先算 pseudo level,再反推经验预算;这里固化设计文档中的 0.92 曲线。
|
||||
pub fn resolve_chapter_boundary_pseudo_level_millis(
|
||||
boundary_index: u32,
|
||||
total_chapters: u32,
|
||||
) -> u32 {
|
||||
if boundary_index == 0 || total_chapters == 0 {
|
||||
return 1_000;
|
||||
}
|
||||
|
||||
let progress = (f64::from(boundary_index) / f64::from(total_chapters)).clamp(0.0, 1.0);
|
||||
let terminal_story_level = resolve_terminal_story_level(total_chapters);
|
||||
let pseudo_level = 1.0
|
||||
+ progress.powf(PSEUDO_LEVEL_CURVE_EXPONENT)
|
||||
* f64::from(terminal_story_level.saturating_sub(1));
|
||||
|
||||
(round_metric(pseudo_level, 3) * 1_000.0).round() as u32
|
||||
}
|
||||
|
||||
pub fn resolve_pseudo_level_xp_millis(pseudo_level_millis: u32) -> u32 {
|
||||
let pseudo_level = f64::from(pseudo_level_millis.max(1_000)) / 1_000.0;
|
||||
let lower_level = pseudo_level.floor().max(1.0) as u32;
|
||||
let mut lower_level_xp = 0_u32;
|
||||
|
||||
for level in 1..lower_level {
|
||||
lower_level_xp = lower_level_xp.saturating_add(compute_xp_to_next_level(level));
|
||||
}
|
||||
|
||||
let partial = (f64::from(compute_xp_to_next_level(lower_level))
|
||||
* (pseudo_level - f64::from(lower_level)))
|
||||
.round() as u32;
|
||||
|
||||
lower_level_xp.saturating_add(partial)
|
||||
}
|
||||
|
||||
// 章节自动定级当前先抽成纯数学 helper,等 custom-world Rust crate 就位后再直接接蓝图编译结果。
|
||||
pub fn build_chapter_auto_level_profile(
|
||||
input: ChapterAutoLevelProfileInput,
|
||||
) -> Result<RuntimeEntityLevelProfile, ProgressionFieldError> {
|
||||
let chapter_id =
|
||||
normalize_required_text(input.chapter_id, ProgressionFieldError::MissingChapterId)?;
|
||||
if input.chapter_index == 0 {
|
||||
return Err(ProgressionFieldError::InvalidChapterIndex);
|
||||
}
|
||||
|
||||
let base_stage_level = f64::from(input.entry_pseudo_level_millis.max(1_000))
|
||||
+ f64::from(
|
||||
input
|
||||
.exit_pseudo_level_millis
|
||||
.max(input.entry_pseudo_level_millis.max(1_000))
|
||||
.saturating_sub(input.entry_pseudo_level_millis.max(1_000)),
|
||||
) * (f64::from(input.stage_progress_millis.min(1_000)) / 1_000.0);
|
||||
let base_stage_level = base_stage_level / 1_000.0;
|
||||
let role_offset = role_level_offset(input.progression_role);
|
||||
let level = clamp_level((base_stage_level + f64::from(role_offset)).round().max(1.0) as u32);
|
||||
let benchmark = build_level_benchmark(level);
|
||||
|
||||
Ok(RuntimeEntityLevelProfile {
|
||||
level,
|
||||
reference_strength: benchmark.reference_strength,
|
||||
chapter_id: Some(chapter_id),
|
||||
chapter_index: Some(input.chapter_index),
|
||||
progression_role: input.progression_role,
|
||||
source: LevelProfileSource::ChapterAuto,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resolve_hostile_battle_max_hp(level_profile: &RuntimeEntityLevelProfile) -> u32 {
|
||||
let benchmark = build_level_benchmark(level_profile.level);
|
||||
let role_bonus = match level_profile.progression_role {
|
||||
ProgressionRole::HostileElite => 10,
|
||||
ProgressionRole::HostileBoss => 24,
|
||||
ProgressionRole::Rival => 6,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
(benchmark.base_hp / 9).max(32).saturating_add(role_bonus)
|
||||
}
|
||||
|
||||
// 击败敌对 NPC 的经验掉落沿用现有倍率口径,避免迁移后任务/战斗经验节奏突然变化。
|
||||
pub fn build_hostile_experience_reward(
|
||||
player_level: u32,
|
||||
level_profile: &RuntimeEntityLevelProfile,
|
||||
chapter_stage_multiplier_millis: u32,
|
||||
explicit_base_xp: Option<u32>,
|
||||
) -> u32 {
|
||||
let benchmark = build_level_benchmark(level_profile.level);
|
||||
let base_kill_xp = explicit_base_xp
|
||||
.unwrap_or_else(|| ((benchmark.xp_to_next_level as f64) * 0.08).round() as u32);
|
||||
let level_delta_multiplier_millis =
|
||||
resolve_level_delta_multiplier_millis(player_level, level_profile.level);
|
||||
let role_multiplier_millis = match level_profile.progression_role {
|
||||
ProgressionRole::HostileElite => 1_150,
|
||||
ProgressionRole::HostileBoss => 1_300,
|
||||
ProgressionRole::Guide | ProgressionRole::Ambient | ProgressionRole::Support => 0,
|
||||
_ => 1_000,
|
||||
};
|
||||
let scaled = u64::from(base_kill_xp)
|
||||
.saturating_mul(u64::from(chapter_stage_multiplier_millis))
|
||||
.saturating_mul(u64::from(level_delta_multiplier_millis))
|
||||
.saturating_mul(u64::from(role_multiplier_millis as u32))
|
||||
/ 1_000
|
||||
/ 1_000
|
||||
/ 1_000;
|
||||
let rounded = ((scaled as u32 + 2) / 5) * 5;
|
||||
rounded.max(5)
|
||||
}
|
||||
|
||||
fn resolve_level_delta_multiplier_millis(player_level: u32, target_level: u32) -> u32 {
|
||||
if target_level + 4 <= player_level {
|
||||
return 300;
|
||||
}
|
||||
if target_level + 2 <= player_level {
|
||||
return 700;
|
||||
}
|
||||
if target_level >= player_level + 2 {
|
||||
return 1_150;
|
||||
}
|
||||
1_000
|
||||
}
|
||||
|
||||
fn role_level_offset(role: ProgressionRole) -> i32 {
|
||||
match role {
|
||||
ProgressionRole::Ambient => -1,
|
||||
ProgressionRole::HostileElite => 1,
|
||||
ProgressionRole::HostileBoss => 2,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_required_text(
|
||||
value: String,
|
||||
error: ProgressionFieldError,
|
||||
) -> Result<String, ProgressionFieldError> {
|
||||
normalize_required_string(value).ok_or(error)
|
||||
}
|
||||
|
||||
impl ChapterPaceBand {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::OpeningFast => "opening_fast",
|
||||
Self::Steady => "steady",
|
||||
Self::Pressure => "pressure",
|
||||
Self::FinaleDense => "finale_dense",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProgressionRole {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Guide => "guide",
|
||||
Self::Ambient => "ambient",
|
||||
Self::Support => "support",
|
||||
Self::HostileStandard => "hostile_standard",
|
||||
Self::HostileElite => "hostile_elite",
|
||||
Self::HostileBoss => "hostile_boss",
|
||||
Self::Rival => "rival",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LevelProfileSource {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::ChapterAuto => "chapter_auto",
|
||||
Self::PresetOverride => "preset_override",
|
||||
Self::Manual => "manual",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PlayerProgressionGrantSource {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Quest => "quest",
|
||||
Self::HostileNpc => "hostile_npc",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ProgressionFieldError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::MissingUserId => f.write_str("player_progression.user_id 不能为空"),
|
||||
Self::MissingChapterId => f.write_str("chapter_progression.chapter_id 不能为空"),
|
||||
Self::InvalidChapterIndex => {
|
||||
f.write_str("chapter_progression.chapter_index 必须大于 0")
|
||||
}
|
||||
Self::InvalidTotalChapters => f.write_str("chapter_progression.total_chapters 非法"),
|
||||
Self::InvalidLevel => f.write_str("player_progression.level 非法"),
|
||||
Self::InvalidEntryExitLevel => {
|
||||
f.write_str("chapter_progression.entry_level / exit_level 非法")
|
||||
}
|
||||
Self::InvalidXpBudget => f.write_str("chapter_progression 经验预算非法"),
|
||||
Self::InvalidExpectedHostileDefeatCount => {
|
||||
f.write_str("chapter_progression.expected_hostile_defeat_count 非法")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for ProgressionFieldError {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn create_initial_player_progression_starts_from_level_one() {
|
||||
let snapshot =
|
||||
create_initial_player_progression("user_001".to_string(), 10).expect("should build");
|
||||
|
||||
assert_eq!(snapshot.level, 1);
|
||||
assert_eq!(snapshot.total_xp, 0);
|
||||
assert_eq!(snapshot.current_level_xp, 0);
|
||||
assert_eq!(snapshot.xp_to_next_level, 60);
|
||||
assert_eq!(snapshot.last_granted_source, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grant_player_experience_promotes_level_from_quest_reward() {
|
||||
let current = build_player_progression_snapshot("user_001".to_string(), 50, None, 10, 10)
|
||||
.expect("current snapshot should build");
|
||||
|
||||
let next = grant_player_experience(
|
||||
current,
|
||||
PlayerProgressionGrantInput {
|
||||
user_id: "user_001".to_string(),
|
||||
amount: 40,
|
||||
source: PlayerProgressionGrantSource::Quest,
|
||||
updated_at_micros: 20,
|
||||
},
|
||||
)
|
||||
.expect("grant should succeed");
|
||||
|
||||
assert_eq!(next.level, 2);
|
||||
assert_eq!(next.total_xp, 90);
|
||||
assert_eq!(next.current_level_xp, 30);
|
||||
assert_eq!(next.xp_to_next_level, 88);
|
||||
assert_eq!(next.pending_level_ups, 1);
|
||||
assert_eq!(
|
||||
next.last_granted_source,
|
||||
Some(PlayerProgressionGrantSource::Quest)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_level_benchmark_matches_node_curve() {
|
||||
let benchmark = build_level_benchmark(5);
|
||||
|
||||
assert_eq!(benchmark.level, 5);
|
||||
assert_eq!(benchmark.xp_to_next_level, 268);
|
||||
assert_eq!(benchmark.cumulative_xp_required, 472);
|
||||
assert_eq!(benchmark.reference_strength, 260);
|
||||
assert_eq!(benchmark.base_hp, 436);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chapter_boundary_pseudo_level_millis_grows_with_chapter_index() {
|
||||
let first = resolve_chapter_boundary_pseudo_level_millis(1, 3);
|
||||
let second = resolve_chapter_boundary_pseudo_level_millis(2, 3);
|
||||
let third = resolve_chapter_boundary_pseudo_level_millis(3, 3);
|
||||
|
||||
assert!(second > first);
|
||||
assert!(third > second);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_chapter_auto_level_profile_applies_role_offset() {
|
||||
let standard = build_chapter_auto_level_profile(ChapterAutoLevelProfileInput {
|
||||
chapter_id: "chapter-3".to_string(),
|
||||
chapter_index: 3,
|
||||
entry_pseudo_level_millis: 6_200,
|
||||
exit_pseudo_level_millis: 8_800,
|
||||
stage_progress_millis: 1_000,
|
||||
progression_role: ProgressionRole::HostileStandard,
|
||||
})
|
||||
.expect("standard profile should build");
|
||||
let boss = build_chapter_auto_level_profile(ChapterAutoLevelProfileInput {
|
||||
chapter_id: "chapter-3".to_string(),
|
||||
chapter_index: 3,
|
||||
entry_pseudo_level_millis: 6_200,
|
||||
exit_pseudo_level_millis: 8_800,
|
||||
stage_progress_millis: 1_000,
|
||||
progression_role: ProgressionRole::HostileBoss,
|
||||
})
|
||||
.expect("boss profile should build");
|
||||
|
||||
assert_eq!(standard.progression_role, ProgressionRole::HostileStandard);
|
||||
assert_eq!(boss.progression_role, ProgressionRole::HostileBoss);
|
||||
assert!(boss.level >= standard.level + 2);
|
||||
assert_eq!(boss.source, LevelProfileSource::ChapterAuto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_hostile_experience_reward_matches_existing_fallback_expectation() {
|
||||
let level_profile = RuntimeEntityLevelProfile {
|
||||
level: 5,
|
||||
reference_strength: 260,
|
||||
chapter_id: None,
|
||||
chapter_index: None,
|
||||
progression_role: ProgressionRole::HostileStandard,
|
||||
source: LevelProfileSource::Manual,
|
||||
};
|
||||
|
||||
let reward = build_hostile_experience_reward(5, &level_profile, 1_000, None);
|
||||
let hp = resolve_hostile_battle_max_hp(&level_profile);
|
||||
|
||||
assert_eq!(reward, 20);
|
||||
assert_eq!(hp, 48);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_chapter_progression_ledger_accumulates_actual_values() {
|
||||
let current = build_chapter_progression_snapshot(ChapterProgressionInput {
|
||||
user_id: "user_001".to_string(),
|
||||
chapter_id: "chapter-1".to_string(),
|
||||
chapter_index: 1,
|
||||
total_chapters: 3,
|
||||
entry_pseudo_level_millis: 1_000,
|
||||
exit_pseudo_level_millis: 5_000,
|
||||
entry_level: 1,
|
||||
exit_level: 5,
|
||||
planned_total_xp: 320,
|
||||
planned_quest_xp: 200,
|
||||
planned_hostile_xp: 120,
|
||||
expected_hostile_defeat_count: 3,
|
||||
level_at_entry: 1,
|
||||
pace_band: ChapterPaceBand::OpeningFast,
|
||||
updated_at_micros: 10,
|
||||
})
|
||||
.expect("chapter snapshot should build");
|
||||
|
||||
let next = apply_chapter_progression_ledger(
|
||||
current,
|
||||
ChapterProgressionLedgerInput {
|
||||
user_id: "user_001".to_string(),
|
||||
chapter_id: "chapter-1".to_string(),
|
||||
granted_quest_xp: 60,
|
||||
granted_hostile_xp: 20,
|
||||
hostile_defeat_increment: 1,
|
||||
level_at_exit: Some(2),
|
||||
updated_at_micros: 20,
|
||||
},
|
||||
)
|
||||
.expect("ledger apply should succeed");
|
||||
|
||||
assert_eq!(next.actual_quest_xp, 60);
|
||||
assert_eq!(next.actual_hostile_xp, 20);
|
||||
assert_eq!(next.actual_hostile_defeat_count, 1);
|
||||
assert_eq!(next.level_at_exit, Some(2));
|
||||
}
|
||||
}
|
||||
14
server-rs/crates/module-quest/Cargo.toml
Normal file
14
server-rs/crates/module-quest/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "module-quest"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
spacetime-types = ["dep:spacetimedb"]
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
shared-kernel = { path = "../shared-kernel" }
|
||||
spacetimedb = { workspace = true, optional = true }
|
||||
@@ -1,30 +1,55 @@
|
||||
# module-quest 独立模块 package 占位说明
|
||||
# module-quest 任务运行时模块说明
|
||||
|
||||
日期:`2026-04-20`
|
||||
日期:`2026-04-21`
|
||||
|
||||
## 1. package 职责
|
||||
|
||||
`module-quest` 是任务运行时模块 package,后续负责:
|
||||
`module-quest` 是任务运行时模块 package,当前已经承接下面 4 类纯领域能力:
|
||||
|
||||
1. `quest_record` 等任务状态模型
|
||||
2. 任务进度、任务日志、任务信号处理规则
|
||||
3. 与 story、runtime、progression 的任务联动
|
||||
4. 与 `apps/api-server` 的任务兼容接口对接
|
||||
5. 与 `apps/spacetime-module` 的任务表、reducer、view 聚合对接
|
||||
1. `QuestRecord / QuestStep / QuestReward / QuestProgressSignal` 等任务状态模型
|
||||
2. 任务 step 归一化、active step 选择、状态收口规则
|
||||
3. `accept -> apply signal -> acknowledge completion -> turn in` 的最小任务状态流转 helper
|
||||
4. 供 `crates/spacetime-module` 聚合表与 reducer 复用的纯 Rust 规则函数
|
||||
|
||||
## 2. 当前阶段说明
|
||||
## 2. 当前已落地的真实范围
|
||||
|
||||
当前提交仅完成目录占位,不提前进入任务草案、进度投影与兼容接口实现。
|
||||
当前 crate 已经提供:
|
||||
|
||||
后续与本 package 直接相关的任务包括:
|
||||
1. `QuestRecordInput / QuestRecordSnapshot`
|
||||
2. `QuestProgressSignal / QuestSignalApplyInput / QuestSignalApplyOutcome`
|
||||
3. `QuestCompletionAckInput / QuestTurnInInput`
|
||||
4. `build_quest_record_snapshot`
|
||||
5. `apply_quest_signal`
|
||||
6. `acknowledge_quest_completion`
|
||||
7. `turn_in_quest_record`
|
||||
8. `generate_quest_log_id`
|
||||
|
||||
1. 设计 `quest_record`
|
||||
2. 设计 `apply_quest_signal`
|
||||
3. 对齐任务进度、日志与兼容输出结构
|
||||
4. 接入 runtime 与 story 的任务联动
|
||||
补充说明:
|
||||
|
||||
## 3. 边界约束
|
||||
1. 当前 crate 仍保持“纯领域规则”定位,不直接依赖 Axum、SpacetimeDB reducer context 或外部平台。
|
||||
2. `spacetime-types` feature 只用于在 `spacetime-module` 中复用这些类型做表字段和 reducer 输入。
|
||||
|
||||
## 3. 当前未落地的范围
|
||||
|
||||
本 crate 当前明确还没有承接:
|
||||
|
||||
1. AI 任务意图生成
|
||||
2. 奖励中的货币、好感、情报统一发放
|
||||
3. runtime snapshot 投影
|
||||
4. story action 跨域编排
|
||||
5. Axum 兼容接口 DTO
|
||||
|
||||
补充说明:
|
||||
|
||||
1. 当前 `QuestRewardItem` 的字段已经升级到可无损映射 `inventory_slot` 的粒度,包含 `description / stackable / stack_key / equipment_slot_id`。
|
||||
2. 当前 `turn_in_quest` 已在 `crates/spacetime-module` 中完成经验奖励到 `player_progression / chapter_progression` 的最小联动。
|
||||
3. 当前 `turn_in_quest` 已在 `crates/spacetime-module` 中完成物品奖励写入 `inventory_slot`;`module-quest` 本身仍只冻结奖励 contract,真实聚合写入继续由 `crates/spacetime-module` 负责。
|
||||
|
||||
这些能力后续分别由 `module-ai`、`module-runtime`、`module-story`、`api-server` 衔接,不在这里堆成大而全服务。
|
||||
|
||||
## 4. 边界约束
|
||||
|
||||
1. `module-quest` 负责任务状态真相与任务规则,生成型任务草案与外部 AI 编排不直接塞进模块内部。
|
||||
2. 任务状态最终回写到 `apps/spacetime-module` 聚合的状态模型中,前端兼容接口由 `apps/api-server` 暴露。
|
||||
2. 任务状态最终回写到 `crates/spacetime-module` 聚合的状态模型中,前端兼容接口由 `crates/api-server` 暴露。
|
||||
3. 任务不能再次散落到 story service、runtime service 或前端临时状态里分别维护。
|
||||
4. 当前真实工程口径以 `docs/technical/M4_RPG_RUNTIME_QUEST_SPACETIMEDB_BASELINE_2026-04-21.md` 为准。
|
||||
|
||||
1156
server-rs/crates/module-quest/src/lib.rs
Normal file
1156
server-rs/crates/module-quest/src/lib.rs
Normal file
File diff suppressed because it is too large
Load Diff
15
server-rs/crates/module-runtime-item/Cargo.toml
Normal file
15
server-rs/crates/module-runtime-item/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "module-runtime-item"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
spacetime-types = ["dep:spacetimedb"]
|
||||
|
||||
[dependencies]
|
||||
module-inventory = { path = "../module-inventory", default-features = false }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
shared-kernel = { path = "../shared-kernel" }
|
||||
spacetimedb = { workspace = true, optional = true }
|
||||
@@ -1,30 +1,33 @@
|
||||
# module-runtime-item 独立模块 package 占位说明
|
||||
# module-runtime-item 运行时宝藏模块说明
|
||||
|
||||
日期:`2026-04-20`
|
||||
日期:`2026-04-21`
|
||||
|
||||
## 1. package 职责
|
||||
|
||||
`module-runtime-item` 是运行时物品模块 package,后续负责:
|
||||
`module-runtime-item` 是运行时物品子域 crate,当前已经承接宝藏奖励快照与背包桥接相关的纯领域能力:
|
||||
|
||||
1. `treasure_record` 等运行时物品与宝藏状态模型
|
||||
2. 奖励解析、宝藏逻辑、运行时物品结算规则
|
||||
3. 与 story、inventory、quest 的运行时物品联动
|
||||
4. 与 `apps/api-server` 的运行时物品兼容接口对接
|
||||
5. 与 `apps/spacetime-module` 的运行时物品表、reducer、view 聚合对接
|
||||
1. `TreasureResolveInput / TreasureRecordSnapshot / TreasureInteractionAction`
|
||||
2. 宝藏奖励物品 `RuntimeItemRewardItemSnapshot` 的字段校验与归一化
|
||||
3. `build_treasure_record_snapshot`
|
||||
4. `build_inventory_item_snapshot_from_reward_item`
|
||||
5. 供 `crates/spacetime-module` 聚合表与 reducer 复用的纯 Rust 规则函数
|
||||
|
||||
## 2. 当前阶段说明
|
||||
|
||||
当前提交仅完成目录占位,不提前进入奖励解析、意图生成与兼容接口实现。
|
||||
当前 crate 已经提供:
|
||||
|
||||
后续与本 package 直接相关的任务包括:
|
||||
1. `treasure_record` 首版领域 contract
|
||||
2. 运行时奖励物品到 `module-inventory::InventoryItemSnapshot` 的显式映射 helper
|
||||
3. 中文错误语义与最小测试
|
||||
4. 供 `crates/spacetime-module` 在 `resolve_treasure_interaction` 中把宝藏奖励同步写入 `inventory_slot` 的桥接规则
|
||||
|
||||
1. 设计 `treasure_record`
|
||||
2. 设计运行时物品结算与宝藏交互 reducer
|
||||
3. 对齐奖励、宝藏、patch 与兼容输出结构
|
||||
4. 接入 story action 主循环的运行时物品联动
|
||||
补充说明:
|
||||
|
||||
1. 当前 crate 仍保持“纯领域规则”定位,不直接依赖 SpacetimeDB reducer context 或 Axum。
|
||||
2. `spacetime-types` feature 只用于在 `spacetime-module` 中复用这些类型做表字段和 reducer 输入。
|
||||
|
||||
## 3. 边界约束
|
||||
|
||||
1. `module-runtime-item` 负责运行时物品状态真相与奖励规则,生成型物品意图与外部 AI 编排不直接塞进模块内部。
|
||||
2. 奖励与宝藏状态最终回写到 `apps/spacetime-module` 聚合的状态模型中,前端兼容接口由 `apps/api-server` 暴露。
|
||||
3. 运行时物品逻辑不能再次散落到 story、inventory 或 route handler 中分别维护。
|
||||
1. 当前 crate 只负责任务奖励/宝藏奖励与 inventory 的字段桥接,不承接 story 编排或前端 DTO。
|
||||
2. 宝藏、任务、交易等不同奖励入口若要写入 `inventory_slot`,优先复用这里与 `module-inventory` 的桥接口径,而不是各自重新拼装物品字段。
|
||||
3. 当前真实工程口径以 `docs/technical/M4_RUNTIME_ITEM_TREASURE_SPACETIMEDB_BASELINE_2026-04-21.md` 为准。
|
||||
|
||||
409
server-rs/crates/module-runtime-item/src/lib.rs
Normal file
409
server-rs/crates/module-runtime-item/src/lib.rs
Normal file
@@ -0,0 +1,409 @@
|
||||
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 {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_treasure_record_snapshot_accepts_minimal_contract() {
|
||||
let snapshot = build_treasure_record_snapshot(TreasureResolveInput {
|
||||
treasure_record_id: "treasure_001".to_string(),
|
||||
runtime_session_id: "runtime_001".to_string(),
|
||||
story_session_id: "storysess_001".to_string(),
|
||||
actor_user_id: "user_001".to_string(),
|
||||
encounter_id: "enc_001".to_string(),
|
||||
encounter_name: "旧钟楼暗格".to_string(),
|
||||
scene_id: Some("scene_001".to_string()),
|
||||
scene_name: Some("旧钟楼".to_string()),
|
||||
action: TreasureInteractionAction::Inspect,
|
||||
reward_items: vec![RuntimeItemRewardItemSnapshot {
|
||||
item_id: "item_001".to_string(),
|
||||
category: "遗物".to_string(),
|
||||
item_name: "铜钥残片".to_string(),
|
||||
description: Some("带着旧钟楼铜锈味的钥片。".to_string()),
|
||||
quantity: 1,
|
||||
rarity: RuntimeItemRewardItemRarity::Rare,
|
||||
tags: vec!["钥片".to_string(), "钟楼".to_string()],
|
||||
stackable: false,
|
||||
stack_key: String::new(),
|
||||
equipment_slot_id: None,
|
||||
}],
|
||||
reward_hp: 3,
|
||||
reward_mana: 2,
|
||||
reward_currency: 10,
|
||||
story_hint: Some("发现了旧机关的回响。".to_string()),
|
||||
created_at_micros: 10,
|
||||
updated_at_micros: 10,
|
||||
})
|
||||
.expect("minimal treasure snapshot should succeed");
|
||||
|
||||
assert_eq!(snapshot.treasure_record_id, "treasure_001");
|
||||
assert_eq!(snapshot.reward_items.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_inventory_item_snapshot_from_reward_item_keeps_inventory_fields() {
|
||||
let item = build_inventory_item_snapshot_from_reward_item(
|
||||
"treasure_001",
|
||||
RuntimeItemRewardItemSnapshot {
|
||||
item_id: "item_001".to_string(),
|
||||
category: "遗物".to_string(),
|
||||
item_name: "铜钥残片".to_string(),
|
||||
description: Some("带着旧钟楼铜锈味的钥片。".to_string()),
|
||||
quantity: 1,
|
||||
rarity: RuntimeItemRewardItemRarity::Rare,
|
||||
tags: vec!["钥片".to_string(), "钟楼".to_string()],
|
||||
stackable: false,
|
||||
stack_key: String::new(),
|
||||
equipment_slot_id: Some(RuntimeItemEquipmentSlot::Relic),
|
||||
},
|
||||
)
|
||||
.expect("reward item should convert into inventory item");
|
||||
|
||||
assert_eq!(item.item_id, "item_001");
|
||||
assert_eq!(item.category, "遗物");
|
||||
assert_eq!(item.name, "铜钥残片");
|
||||
assert_eq!(item.rarity, InventoryItemRarity::Rare);
|
||||
assert_eq!(item.stack_key, "item_001");
|
||||
assert_eq!(item.equipment_slot_id, Some(InventoryEquipmentSlot::Relic));
|
||||
assert_eq!(item.source_kind, InventoryItemSourceKind::TreasureReward);
|
||||
assert_eq!(item.source_reference_id, Some("treasure_001".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_reward_item_snapshot_trims_and_fills_stack_key() {
|
||||
let item = normalize_reward_item_snapshot(RuntimeItemRewardItemSnapshot {
|
||||
item_id: " item_001 ".to_string(),
|
||||
category: " 遗物 ".to_string(),
|
||||
item_name: " 铜钥残片 ".to_string(),
|
||||
description: Some(" 带着旧钟楼铜锈味的钥片。 ".to_string()),
|
||||
quantity: 1,
|
||||
rarity: RuntimeItemRewardItemRarity::Rare,
|
||||
tags: vec![" 钥片 ".to_string(), "".to_string(), "钟楼".to_string()],
|
||||
stackable: false,
|
||||
stack_key: String::new(),
|
||||
equipment_slot_id: None,
|
||||
})
|
||||
.expect("reward item should normalize");
|
||||
|
||||
assert_eq!(item.item_id, "item_001");
|
||||
assert_eq!(item.category, "遗物");
|
||||
assert_eq!(item.item_name, "铜钥残片");
|
||||
assert_eq!(
|
||||
item.description.as_deref(),
|
||||
Some("带着旧钟楼铜锈味的钥片。")
|
||||
);
|
||||
assert_eq!(item.tags, vec!["钥片".to_string(), "钟楼".to_string()]);
|
||||
assert_eq!(item.stack_key, "item_001");
|
||||
}
|
||||
}
|
||||
15
server-rs/crates/module-runtime/Cargo.toml
Normal file
15
server-rs/crates/module-runtime/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "module-runtime"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
spacetime-types = ["dep:spacetimedb"]
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
shared-kernel = { path = "../shared-kernel" }
|
||||
spacetimedb = { workspace = true, optional = true }
|
||||
time = { version = "0.3", features = ["formatting", "parsing"] }
|
||||
980
server-rs/crates/module-runtime/src/lib.rs
Normal file
980
server-rs/crates/module-runtime/src/lib.rs
Normal file
@@ -0,0 +1,980 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared_kernel::{
|
||||
format_rfc3339 as format_shared_rfc3339, normalize_optional_string, normalize_required_string,
|
||||
parse_rfc3339 as parse_shared_rfc3339,
|
||||
};
|
||||
#[cfg(feature = "spacetime-types")]
|
||||
use spacetimedb::SpacetimeType;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
pub const DEFAULT_MUSIC_VOLUME: f32 = 0.42;
|
||||
pub const DEFAULT_PLATFORM_THEME: RuntimePlatformTheme = RuntimePlatformTheme::Light;
|
||||
pub const DEFAULT_BROWSE_HISTORY_AUTHOR_DISPLAY_NAME: &str = "玩家";
|
||||
pub const MAX_BROWSE_HISTORY_BATCH_SIZE: usize = 100;
|
||||
pub const PROFILE_WALLET_LEDGER_LIST_LIMIT: usize = 50;
|
||||
|
||||
// 运行时设置目前只冻结 light/dark 两种主题,避免各层散落字符串字面量。
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum RuntimePlatformTheme {
|
||||
Light,
|
||||
Dark,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RuntimeSettings {
|
||||
pub music_volume: f32,
|
||||
pub platform_theme: RuntimePlatformTheme,
|
||||
}
|
||||
|
||||
// 浏览历史沿用平台已有的六种世界主题,但独立冻结在 runtime 领域内,避免反向耦合创作域 crate。
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum RuntimeBrowseHistoryThemeMode {
|
||||
Martial,
|
||||
Arcane,
|
||||
Machina,
|
||||
Tide,
|
||||
Rift,
|
||||
Mythic,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeSettingSnapshot {
|
||||
pub user_id: String,
|
||||
pub music_volume: f32,
|
||||
pub platform_theme: RuntimePlatformTheme,
|
||||
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 RuntimeSettingProcedureResult {
|
||||
pub ok: bool,
|
||||
pub record: Option<RuntimeSettingSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeSettingGetInput {
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeSettingUpsertInput {
|
||||
pub user_id: String,
|
||||
pub music_volume: f32,
|
||||
pub platform_theme: RuntimePlatformTheme,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeBrowseHistorySnapshot {
|
||||
pub browse_history_id: String,
|
||||
pub user_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub profile_id: String,
|
||||
pub world_name: String,
|
||||
pub subtitle: String,
|
||||
pub summary_text: String,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub theme_mode: RuntimeBrowseHistoryThemeMode,
|
||||
pub author_display_name: String,
|
||||
pub visited_at_micros: i64,
|
||||
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 RuntimeBrowseHistoryProcedureResult {
|
||||
pub ok: bool,
|
||||
pub entries: Vec<RuntimeBrowseHistorySnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeBrowseHistoryListInput {
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeBrowseHistoryClearInput {
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeBrowseHistoryWriteInput {
|
||||
pub owner_user_id: String,
|
||||
pub profile_id: String,
|
||||
pub world_name: String,
|
||||
pub subtitle: Option<String>,
|
||||
pub summary_text: Option<String>,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub theme_mode: Option<String>,
|
||||
pub author_display_name: Option<String>,
|
||||
pub visited_at: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeBrowseHistorySyncInput {
|
||||
pub user_id: String,
|
||||
pub entries: Vec<RuntimeBrowseHistoryWriteInput>,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileDashboardSnapshot {
|
||||
pub user_id: String,
|
||||
pub wallet_balance: u64,
|
||||
pub total_play_time_ms: u64,
|
||||
pub played_world_count: u32,
|
||||
pub updated_at_micros: Option<i64>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileDashboardProcedureResult {
|
||||
pub ok: bool,
|
||||
pub record: Option<RuntimeProfileDashboardSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileDashboardGetInput {
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum RuntimeProfileWalletLedgerSourceType {
|
||||
SnapshotSync,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileWalletLedgerEntrySnapshot {
|
||||
pub wallet_ledger_id: String,
|
||||
pub user_id: String,
|
||||
pub amount_delta: i64,
|
||||
pub balance_after: u64,
|
||||
pub source_type: RuntimeProfileWalletLedgerSourceType,
|
||||
pub created_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileWalletLedgerProcedureResult {
|
||||
pub ok: bool,
|
||||
pub entries: Vec<RuntimeProfileWalletLedgerEntrySnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileWalletLedgerListInput {
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfilePlayedWorldSnapshot {
|
||||
pub played_world_id: String,
|
||||
pub user_id: String,
|
||||
pub world_key: String,
|
||||
pub owner_user_id: Option<String>,
|
||||
pub profile_id: Option<String>,
|
||||
pub world_type: Option<String>,
|
||||
pub world_title: String,
|
||||
pub world_subtitle: String,
|
||||
pub first_played_at_micros: i64,
|
||||
pub last_played_at_micros: i64,
|
||||
pub last_observed_play_time_ms: u64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfilePlayStatsSnapshot {
|
||||
pub user_id: String,
|
||||
pub total_play_time_ms: u64,
|
||||
pub played_works: Vec<RuntimeProfilePlayedWorldSnapshot>,
|
||||
pub updated_at_micros: Option<i64>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfilePlayStatsProcedureResult {
|
||||
pub ok: bool,
|
||||
pub record: Option<RuntimeProfilePlayStatsSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfilePlayStatsGetInput {
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum RuntimeSettingsFieldError {
|
||||
MissingUserId,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum RuntimeBrowseHistoryFieldError {
|
||||
MissingUserId,
|
||||
TooManyEntries,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum RuntimeProfileFieldError {
|
||||
MissingUserId,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RuntimeBrowseHistoryPreparedEntry {
|
||||
pub browse_history_id: String,
|
||||
pub user_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub profile_id: String,
|
||||
pub world_name: String,
|
||||
pub subtitle: String,
|
||||
pub summary_text: String,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub theme_mode: RuntimeBrowseHistoryThemeMode,
|
||||
pub author_display_name: String,
|
||||
pub visited_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RuntimeBrowseHistoryRecord {
|
||||
pub browse_history_id: String,
|
||||
pub user_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub profile_id: String,
|
||||
pub world_name: String,
|
||||
pub subtitle: String,
|
||||
pub summary_text: String,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub theme_mode: RuntimeBrowseHistoryThemeMode,
|
||||
pub author_display_name: String,
|
||||
pub visited_at: String,
|
||||
pub visited_at_micros: i64,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
impl RuntimePlatformTheme {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Light => "light",
|
||||
Self::Dark => "dark",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_client_str(value: &str) -> Self {
|
||||
if value.trim().eq_ignore_ascii_case("dark") {
|
||||
Self::Dark
|
||||
} else {
|
||||
Self::Light
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeBrowseHistoryThemeMode {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Martial => "martial",
|
||||
Self::Arcane => "arcane",
|
||||
Self::Machina => "machina",
|
||||
Self::Tide => "tide",
|
||||
Self::Rift => "rift",
|
||||
Self::Mythic => "mythic",
|
||||
}
|
||||
}
|
||||
|
||||
// 浏览历史主题沿用旧 Node 逻辑:不做严格校验,未知值统一回退到 mythic。
|
||||
pub fn from_client_str(value: &str) -> Self {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"martial" => Self::Martial,
|
||||
"arcane" => Self::Arcane,
|
||||
"machina" => Self::Machina,
|
||||
"tide" => Self::Tide,
|
||||
"rift" => Self::Rift,
|
||||
_ => Self::Mythic,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeSettings {
|
||||
pub fn defaults() -> Self {
|
||||
Self {
|
||||
music_volume: DEFAULT_MUSIC_VOLUME,
|
||||
platform_theme: DEFAULT_PLATFORM_THEME,
|
||||
}
|
||||
}
|
||||
|
||||
// 与旧 Node 仓储保持一致:音量 clamp 到 0~1,主题除 dark 外统一回退到 light。
|
||||
pub fn normalized(music_volume: f32, platform_theme: RuntimePlatformTheme) -> Self {
|
||||
Self {
|
||||
music_volume: music_volume.clamp(0.0, 1.0),
|
||||
platform_theme,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 统一把共享必填字符串归一化映射到 runtime 各自的字段错误,避免输入构造函数重复 trim + 判空。
|
||||
fn normalize_runtime_settings_user_id(
|
||||
user_id: String,
|
||||
) -> Result<String, RuntimeSettingsFieldError> {
|
||||
normalize_required_string(user_id).ok_or(RuntimeSettingsFieldError::MissingUserId)
|
||||
}
|
||||
|
||||
fn normalize_runtime_browse_history_user_id(
|
||||
user_id: String,
|
||||
) -> Result<String, RuntimeBrowseHistoryFieldError> {
|
||||
normalize_required_string(user_id).ok_or(RuntimeBrowseHistoryFieldError::MissingUserId)
|
||||
}
|
||||
|
||||
fn normalize_runtime_profile_user_id(user_id: String) -> Result<String, RuntimeProfileFieldError> {
|
||||
normalize_required_string(user_id).ok_or(RuntimeProfileFieldError::MissingUserId)
|
||||
}
|
||||
|
||||
pub fn build_runtime_setting_get_input(
|
||||
user_id: String,
|
||||
) -> Result<RuntimeSettingGetInput, RuntimeSettingsFieldError> {
|
||||
let user_id = normalize_runtime_settings_user_id(user_id)?;
|
||||
Ok(RuntimeSettingGetInput { user_id })
|
||||
}
|
||||
|
||||
pub fn build_runtime_setting_upsert_input(
|
||||
user_id: String,
|
||||
music_volume: f32,
|
||||
platform_theme: RuntimePlatformTheme,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<RuntimeSettingUpsertInput, RuntimeSettingsFieldError> {
|
||||
let user_id = normalize_runtime_settings_user_id(user_id)?;
|
||||
let normalized = RuntimeSettings::normalized(music_volume, platform_theme);
|
||||
|
||||
Ok(RuntimeSettingUpsertInput {
|
||||
user_id,
|
||||
music_volume: normalized.music_volume,
|
||||
platform_theme: normalized.platform_theme,
|
||||
updated_at_micros,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_runtime_setting_record(snapshot: RuntimeSettingSnapshot) -> RuntimeSettingsRecord {
|
||||
RuntimeSettingsRecord {
|
||||
user_id: snapshot.user_id,
|
||||
music_volume: snapshot.music_volume,
|
||||
platform_theme: snapshot.platform_theme,
|
||||
created_at_micros: snapshot.created_at_micros,
|
||||
updated_at_micros: snapshot.updated_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RuntimeSettingsRecord {
|
||||
pub user_id: String,
|
||||
pub music_volume: f32,
|
||||
pub platform_theme: RuntimePlatformTheme,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RuntimeProfileDashboardRecord {
|
||||
pub user_id: String,
|
||||
pub wallet_balance: u64,
|
||||
pub total_play_time_ms: u64,
|
||||
pub played_world_count: u32,
|
||||
pub updated_at: Option<String>,
|
||||
pub updated_at_micros: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RuntimeProfileWalletLedgerEntryRecord {
|
||||
pub wallet_ledger_id: String,
|
||||
pub user_id: String,
|
||||
pub amount_delta: i64,
|
||||
pub balance_after: u64,
|
||||
pub source_type: RuntimeProfileWalletLedgerSourceType,
|
||||
pub created_at: String,
|
||||
pub created_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RuntimeProfilePlayedWorldRecord {
|
||||
pub played_world_id: String,
|
||||
pub user_id: String,
|
||||
pub world_key: String,
|
||||
pub owner_user_id: Option<String>,
|
||||
pub profile_id: Option<String>,
|
||||
pub world_type: Option<String>,
|
||||
pub world_title: String,
|
||||
pub world_subtitle: String,
|
||||
pub first_played_at: String,
|
||||
pub first_played_at_micros: i64,
|
||||
pub last_played_at: String,
|
||||
pub last_played_at_micros: i64,
|
||||
pub last_observed_play_time_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RuntimeProfilePlayStatsRecord {
|
||||
pub user_id: String,
|
||||
pub total_play_time_ms: u64,
|
||||
pub played_works: Vec<RuntimeProfilePlayedWorldRecord>,
|
||||
pub updated_at: Option<String>,
|
||||
pub updated_at_micros: Option<i64>,
|
||||
}
|
||||
|
||||
pub fn build_runtime_browse_history_list_input(
|
||||
user_id: String,
|
||||
) -> Result<RuntimeBrowseHistoryListInput, RuntimeBrowseHistoryFieldError> {
|
||||
let user_id = normalize_runtime_browse_history_user_id(user_id)?;
|
||||
Ok(RuntimeBrowseHistoryListInput { user_id })
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_dashboard_get_input(
|
||||
user_id: String,
|
||||
) -> Result<RuntimeProfileDashboardGetInput, RuntimeProfileFieldError> {
|
||||
let user_id = normalize_runtime_profile_user_id(user_id)?;
|
||||
Ok(RuntimeProfileDashboardGetInput { user_id })
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_wallet_ledger_list_input(
|
||||
user_id: String,
|
||||
) -> Result<RuntimeProfileWalletLedgerListInput, RuntimeProfileFieldError> {
|
||||
let user_id = normalize_runtime_profile_user_id(user_id)?;
|
||||
Ok(RuntimeProfileWalletLedgerListInput { user_id })
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_play_stats_get_input(
|
||||
user_id: String,
|
||||
) -> Result<RuntimeProfilePlayStatsGetInput, RuntimeProfileFieldError> {
|
||||
let user_id = normalize_runtime_profile_user_id(user_id)?;
|
||||
Ok(RuntimeProfilePlayStatsGetInput { user_id })
|
||||
}
|
||||
|
||||
pub fn build_runtime_browse_history_clear_input(
|
||||
user_id: String,
|
||||
) -> Result<RuntimeBrowseHistoryClearInput, RuntimeBrowseHistoryFieldError> {
|
||||
let user_id = normalize_runtime_browse_history_user_id(user_id)?;
|
||||
Ok(RuntimeBrowseHistoryClearInput { user_id })
|
||||
}
|
||||
|
||||
pub fn build_runtime_browse_history_sync_input(
|
||||
user_id: String,
|
||||
entries: Vec<RuntimeBrowseHistoryWriteInput>,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<RuntimeBrowseHistorySyncInput, RuntimeBrowseHistoryFieldError> {
|
||||
let user_id = normalize_runtime_browse_history_user_id(user_id)?;
|
||||
if entries.len() > MAX_BROWSE_HISTORY_BATCH_SIZE {
|
||||
return Err(RuntimeBrowseHistoryFieldError::TooManyEntries);
|
||||
}
|
||||
|
||||
let mut normalized_entries = Vec::with_capacity(entries.len());
|
||||
for entry in entries {
|
||||
let Some(owner_user_id) = normalize_required_string(entry.owner_user_id) else {
|
||||
continue;
|
||||
};
|
||||
let Some(profile_id) = normalize_required_string(entry.profile_id) else {
|
||||
continue;
|
||||
};
|
||||
let Some(world_name) = normalize_required_string(entry.world_name) else {
|
||||
continue;
|
||||
};
|
||||
// 与旧 Node 仓储保持一致:单条缺少关键字段时静默过滤,不让整批请求失败。
|
||||
let visited_at_micros = entry
|
||||
.visited_at
|
||||
.as_deref()
|
||||
.and_then(parse_utc_rfc3339_to_micros)
|
||||
.unwrap_or(updated_at_micros);
|
||||
|
||||
normalized_entries.push(RuntimeBrowseHistoryWriteInput {
|
||||
owner_user_id,
|
||||
profile_id,
|
||||
world_name,
|
||||
subtitle: normalize_optional_string(entry.subtitle),
|
||||
summary_text: normalize_optional_string(entry.summary_text),
|
||||
cover_image_src: normalize_optional_string(entry.cover_image_src),
|
||||
theme_mode: normalize_optional_string(entry.theme_mode),
|
||||
author_display_name: normalize_optional_string(entry.author_display_name),
|
||||
// 统一把 visitedAt 收口成 RFC3339,避免后续排序与回包格式继续漂移。
|
||||
visited_at: Some(format_utc_micros(visited_at_micros)),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(RuntimeBrowseHistorySyncInput {
|
||||
user_id,
|
||||
entries: normalized_entries,
|
||||
updated_at_micros,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn prepare_runtime_browse_history_entries(
|
||||
input: RuntimeBrowseHistorySyncInput,
|
||||
) -> Result<Vec<RuntimeBrowseHistoryPreparedEntry>, RuntimeBrowseHistoryFieldError> {
|
||||
let validated_input = build_runtime_browse_history_sync_input(
|
||||
input.user_id,
|
||||
input.entries,
|
||||
input.updated_at_micros,
|
||||
)?;
|
||||
let mut prepared_entries = validated_input
|
||||
.entries
|
||||
.into_iter()
|
||||
.map(|entry| {
|
||||
let visited_at_micros = entry
|
||||
.visited_at
|
||||
.as_deref()
|
||||
.and_then(parse_utc_rfc3339_to_micros)
|
||||
.unwrap_or(validated_input.updated_at_micros);
|
||||
|
||||
RuntimeBrowseHistoryPreparedEntry {
|
||||
browse_history_id: build_runtime_browse_history_id(
|
||||
&validated_input.user_id,
|
||||
&entry.owner_user_id,
|
||||
&entry.profile_id,
|
||||
),
|
||||
user_id: validated_input.user_id.clone(),
|
||||
owner_user_id: entry.owner_user_id,
|
||||
profile_id: entry.profile_id,
|
||||
world_name: entry.world_name,
|
||||
subtitle: entry.subtitle.unwrap_or_default(),
|
||||
summary_text: entry.summary_text.unwrap_or_default(),
|
||||
cover_image_src: entry.cover_image_src,
|
||||
theme_mode: RuntimeBrowseHistoryThemeMode::from_client_str(
|
||||
entry.theme_mode.as_deref().unwrap_or("mythic"),
|
||||
),
|
||||
author_display_name: entry
|
||||
.author_display_name
|
||||
.unwrap_or_else(|| DEFAULT_BROWSE_HISTORY_AUTHOR_DISPLAY_NAME.to_string()),
|
||||
visited_at_micros,
|
||||
updated_at_micros: validated_input.updated_at_micros,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// 与旧 Node 仓储保持一致:先按 visitedAt 倒序,再按 owner/profile 去重,只保留最近一次访问。
|
||||
prepared_entries.sort_by(|left, right| {
|
||||
right
|
||||
.visited_at_micros
|
||||
.cmp(&left.visited_at_micros)
|
||||
.then_with(|| left.browse_history_id.cmp(&right.browse_history_id))
|
||||
});
|
||||
|
||||
let mut seen_ids = HashSet::new();
|
||||
prepared_entries.retain(|entry| seen_ids.insert(entry.browse_history_id.clone()));
|
||||
|
||||
Ok(prepared_entries)
|
||||
}
|
||||
|
||||
pub fn build_runtime_browse_history_record(
|
||||
snapshot: RuntimeBrowseHistorySnapshot,
|
||||
) -> RuntimeBrowseHistoryRecord {
|
||||
RuntimeBrowseHistoryRecord {
|
||||
browse_history_id: snapshot.browse_history_id,
|
||||
user_id: snapshot.user_id,
|
||||
owner_user_id: snapshot.owner_user_id,
|
||||
profile_id: snapshot.profile_id,
|
||||
world_name: snapshot.world_name,
|
||||
subtitle: snapshot.subtitle,
|
||||
summary_text: snapshot.summary_text,
|
||||
cover_image_src: snapshot.cover_image_src,
|
||||
theme_mode: snapshot.theme_mode,
|
||||
author_display_name: snapshot.author_display_name,
|
||||
visited_at: format_utc_micros(snapshot.visited_at_micros),
|
||||
visited_at_micros: snapshot.visited_at_micros,
|
||||
created_at_micros: snapshot.created_at_micros,
|
||||
updated_at_micros: snapshot.updated_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_dashboard_record(
|
||||
snapshot: RuntimeProfileDashboardSnapshot,
|
||||
) -> RuntimeProfileDashboardRecord {
|
||||
RuntimeProfileDashboardRecord {
|
||||
user_id: snapshot.user_id,
|
||||
wallet_balance: snapshot.wallet_balance,
|
||||
total_play_time_ms: snapshot.total_play_time_ms,
|
||||
played_world_count: snapshot.played_world_count,
|
||||
updated_at: snapshot.updated_at_micros.map(format_utc_micros),
|
||||
updated_at_micros: snapshot.updated_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_wallet_ledger_entry_record(
|
||||
snapshot: RuntimeProfileWalletLedgerEntrySnapshot,
|
||||
) -> RuntimeProfileWalletLedgerEntryRecord {
|
||||
RuntimeProfileWalletLedgerEntryRecord {
|
||||
wallet_ledger_id: snapshot.wallet_ledger_id,
|
||||
user_id: snapshot.user_id,
|
||||
amount_delta: snapshot.amount_delta,
|
||||
balance_after: snapshot.balance_after,
|
||||
source_type: snapshot.source_type,
|
||||
created_at: format_utc_micros(snapshot.created_at_micros),
|
||||
created_at_micros: snapshot.created_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_played_world_record(
|
||||
snapshot: RuntimeProfilePlayedWorldSnapshot,
|
||||
) -> RuntimeProfilePlayedWorldRecord {
|
||||
RuntimeProfilePlayedWorldRecord {
|
||||
played_world_id: snapshot.played_world_id,
|
||||
user_id: snapshot.user_id,
|
||||
world_key: snapshot.world_key,
|
||||
owner_user_id: snapshot.owner_user_id,
|
||||
profile_id: snapshot.profile_id,
|
||||
world_type: snapshot.world_type,
|
||||
world_title: snapshot.world_title,
|
||||
world_subtitle: snapshot.world_subtitle,
|
||||
first_played_at: format_utc_micros(snapshot.first_played_at_micros),
|
||||
first_played_at_micros: snapshot.first_played_at_micros,
|
||||
last_played_at: format_utc_micros(snapshot.last_played_at_micros),
|
||||
last_played_at_micros: snapshot.last_played_at_micros,
|
||||
last_observed_play_time_ms: snapshot.last_observed_play_time_ms,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_play_stats_record(
|
||||
snapshot: RuntimeProfilePlayStatsSnapshot,
|
||||
) -> RuntimeProfilePlayStatsRecord {
|
||||
RuntimeProfilePlayStatsRecord {
|
||||
user_id: snapshot.user_id,
|
||||
total_play_time_ms: snapshot.total_play_time_ms,
|
||||
played_works: snapshot
|
||||
.played_works
|
||||
.into_iter()
|
||||
.map(build_runtime_profile_played_world_record)
|
||||
.collect(),
|
||||
updated_at: snapshot.updated_at_micros.map(format_utc_micros),
|
||||
updated_at_micros: snapshot.updated_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_browse_history_id(
|
||||
user_id: &str,
|
||||
owner_user_id: &str,
|
||||
profile_id: &str,
|
||||
) -> String {
|
||||
format!("{user_id}:{owner_user_id}:{profile_id}")
|
||||
}
|
||||
|
||||
pub fn format_utc_micros(micros: i64) -> String {
|
||||
let timestamp = OffsetDateTime::from_unix_timestamp_nanos(i128::from(micros) * 1_000)
|
||||
.unwrap_or(OffsetDateTime::UNIX_EPOCH);
|
||||
format_shared_rfc3339(timestamp).unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
|
||||
}
|
||||
|
||||
fn parse_utc_rfc3339_to_micros(value: &str) -> Option<i64> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let nanos = parse_shared_rfc3339(trimmed).ok()?.unix_timestamp_nanos();
|
||||
i64::try_from(nanos / 1_000).ok()
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RuntimeSettingsFieldError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::MissingUserId => f.write_str("runtime_setting.user_id 不能为空"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RuntimeBrowseHistoryFieldError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::MissingUserId => f.write_str("browse_history.user_id 不能为空"),
|
||||
Self::TooManyEntries => write!(
|
||||
f,
|
||||
"browse_history.entries 单次最多只允许 {} 条",
|
||||
MAX_BROWSE_HISTORY_BATCH_SIZE
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeProfileWalletLedgerSourceType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::SnapshotSync => "snapshot_sync",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RuntimeProfileFieldError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::MissingUserId => f.write_str("profile.user_id 不能为空"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn defaults_match_shared_contract_baseline() {
|
||||
let settings = RuntimeSettings::defaults();
|
||||
|
||||
assert!((settings.music_volume - DEFAULT_MUSIC_VOLUME).abs() < f32::EPSILON);
|
||||
assert_eq!(settings.platform_theme, RuntimePlatformTheme::Light);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_clamps_music_volume_into_valid_range() {
|
||||
let low = RuntimeSettings::normalized(-1.0, RuntimePlatformTheme::Light);
|
||||
let high = RuntimeSettings::normalized(3.5, RuntimePlatformTheme::Dark);
|
||||
|
||||
assert_eq!(low.music_volume, 0.0);
|
||||
assert_eq!(high.music_volume, 1.0);
|
||||
assert_eq!(high.platform_theme, RuntimePlatformTheme::Dark);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_from_client_string_falls_back_to_light() {
|
||||
assert_eq!(
|
||||
RuntimePlatformTheme::from_client_str("dark"),
|
||||
RuntimePlatformTheme::Dark
|
||||
);
|
||||
assert_eq!(
|
||||
RuntimePlatformTheme::from_client_str("LIGHT"),
|
||||
RuntimePlatformTheme::Light
|
||||
);
|
||||
assert_eq!(
|
||||
RuntimePlatformTheme::from_client_str("mythic"),
|
||||
RuntimePlatformTheme::Light
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_upsert_input_rejects_blank_user_id() {
|
||||
let error = build_runtime_setting_upsert_input(
|
||||
" ".to_string(),
|
||||
DEFAULT_MUSIC_VOLUME,
|
||||
RuntimePlatformTheme::Light,
|
||||
1,
|
||||
)
|
||||
.expect_err("blank user id should fail");
|
||||
|
||||
assert_eq!(error, RuntimeSettingsFieldError::MissingUserId);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browse_history_theme_from_client_string_falls_back_to_mythic() {
|
||||
assert_eq!(
|
||||
RuntimeBrowseHistoryThemeMode::from_client_str("martial"),
|
||||
RuntimeBrowseHistoryThemeMode::Martial
|
||||
);
|
||||
assert_eq!(
|
||||
RuntimeBrowseHistoryThemeMode::from_client_str("RIFT"),
|
||||
RuntimeBrowseHistoryThemeMode::Rift
|
||||
);
|
||||
assert_eq!(
|
||||
RuntimeBrowseHistoryThemeMode::from_client_str("unknown"),
|
||||
RuntimeBrowseHistoryThemeMode::Mythic
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_browse_history_sync_input_normalizes_optionals_and_visited_at() {
|
||||
let input = build_runtime_browse_history_sync_input(
|
||||
" user-1 ".to_string(),
|
||||
vec![RuntimeBrowseHistoryWriteInput {
|
||||
owner_user_id: " owner-a ".to_string(),
|
||||
profile_id: " profile-a ".to_string(),
|
||||
world_name: " 世界A ".to_string(),
|
||||
subtitle: Some(" ".to_string()),
|
||||
summary_text: Some(" 简介 ".to_string()),
|
||||
cover_image_src: Some(" /cover.png ".to_string()),
|
||||
theme_mode: Some(" arcane ".to_string()),
|
||||
author_display_name: Some(" ".to_string()),
|
||||
visited_at: None,
|
||||
}],
|
||||
1_713_680_000_000_000,
|
||||
)
|
||||
.expect("sync input should build");
|
||||
|
||||
assert_eq!(input.user_id, "user-1");
|
||||
assert_eq!(input.entries.len(), 1);
|
||||
assert_eq!(input.entries[0].owner_user_id, "owner-a");
|
||||
assert_eq!(input.entries[0].profile_id, "profile-a");
|
||||
assert_eq!(input.entries[0].world_name, "世界A");
|
||||
assert_eq!(input.entries[0].subtitle, None);
|
||||
assert_eq!(input.entries[0].summary_text, Some("简介".to_string()));
|
||||
assert_eq!(
|
||||
input.entries[0].cover_image_src,
|
||||
Some("/cover.png".to_string())
|
||||
);
|
||||
assert_eq!(input.entries[0].theme_mode, Some("arcane".to_string()));
|
||||
assert_eq!(input.entries[0].author_display_name, None);
|
||||
assert_eq!(
|
||||
input.entries[0].visited_at,
|
||||
Some("2024-04-21T06:13:20Z".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_browse_history_entries_sorts_desc_and_dedups_by_owner_profile() {
|
||||
let entries = prepare_runtime_browse_history_entries(RuntimeBrowseHistorySyncInput {
|
||||
user_id: "user-1".to_string(),
|
||||
entries: vec![
|
||||
RuntimeBrowseHistoryWriteInput {
|
||||
owner_user_id: "owner-a".to_string(),
|
||||
profile_id: "profile-a".to_string(),
|
||||
world_name: "世界旧".to_string(),
|
||||
subtitle: None,
|
||||
summary_text: None,
|
||||
cover_image_src: None,
|
||||
theme_mode: Some("martial".to_string()),
|
||||
author_display_name: None,
|
||||
visited_at: Some("2026-04-20T10:00:00Z".to_string()),
|
||||
},
|
||||
RuntimeBrowseHistoryWriteInput {
|
||||
owner_user_id: "owner-b".to_string(),
|
||||
profile_id: "profile-b".to_string(),
|
||||
world_name: "世界B".to_string(),
|
||||
subtitle: None,
|
||||
summary_text: None,
|
||||
cover_image_src: None,
|
||||
theme_mode: Some("rift".to_string()),
|
||||
author_display_name: Some("作者B".to_string()),
|
||||
visited_at: Some("2026-04-21T10:00:00Z".to_string()),
|
||||
},
|
||||
RuntimeBrowseHistoryWriteInput {
|
||||
owner_user_id: "owner-a".to_string(),
|
||||
profile_id: "profile-a".to_string(),
|
||||
world_name: "世界新".to_string(),
|
||||
subtitle: None,
|
||||
summary_text: None,
|
||||
cover_image_src: None,
|
||||
theme_mode: Some("unknown".to_string()),
|
||||
author_display_name: Some("".to_string()),
|
||||
visited_at: Some("2026-04-21T11:00:00Z".to_string()),
|
||||
},
|
||||
],
|
||||
updated_at_micros: 1_776_000_000_000_000,
|
||||
})
|
||||
.expect("entries should prepare");
|
||||
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert_eq!(entries[0].world_name, "世界新");
|
||||
assert_eq!(entries[0].theme_mode, RuntimeBrowseHistoryThemeMode::Mythic);
|
||||
assert_eq!(
|
||||
entries[0].author_display_name,
|
||||
DEFAULT_BROWSE_HISTORY_AUTHOR_DISPLAY_NAME
|
||||
);
|
||||
assert_eq!(entries[1].world_name, "世界B");
|
||||
assert!(entries[0].visited_at_micros > entries[1].visited_at_micros);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_browse_history_sync_input_silently_filters_invalid_entries() {
|
||||
let input = build_runtime_browse_history_sync_input(
|
||||
"user-1".to_string(),
|
||||
vec![
|
||||
RuntimeBrowseHistoryWriteInput {
|
||||
owner_user_id: " ".to_string(),
|
||||
profile_id: "profile-a".to_string(),
|
||||
world_name: "世界A".to_string(),
|
||||
subtitle: None,
|
||||
summary_text: None,
|
||||
cover_image_src: None,
|
||||
theme_mode: None,
|
||||
author_display_name: None,
|
||||
visited_at: None,
|
||||
},
|
||||
RuntimeBrowseHistoryWriteInput {
|
||||
owner_user_id: "owner-b".to_string(),
|
||||
profile_id: "profile-b".to_string(),
|
||||
world_name: " 世界B ".to_string(),
|
||||
subtitle: None,
|
||||
summary_text: None,
|
||||
cover_image_src: None,
|
||||
theme_mode: None,
|
||||
author_display_name: None,
|
||||
visited_at: None,
|
||||
},
|
||||
RuntimeBrowseHistoryWriteInput {
|
||||
owner_user_id: "owner-c".to_string(),
|
||||
profile_id: "".to_string(),
|
||||
world_name: "世界C".to_string(),
|
||||
subtitle: None,
|
||||
summary_text: None,
|
||||
cover_image_src: None,
|
||||
theme_mode: None,
|
||||
author_display_name: None,
|
||||
visited_at: None,
|
||||
},
|
||||
],
|
||||
1_776_000_000_000_000,
|
||||
)
|
||||
.expect("sync input should build");
|
||||
|
||||
assert_eq!(input.entries.len(), 1);
|
||||
assert_eq!(input.entries[0].owner_user_id, "owner-b");
|
||||
assert_eq!(input.entries[0].profile_id, "profile-b");
|
||||
assert_eq!(input.entries[0].world_name, "世界B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_profile_inputs_reject_blank_user_id() {
|
||||
assert_eq!(
|
||||
build_runtime_profile_dashboard_get_input(" ".to_string())
|
||||
.expect_err("dashboard input should fail"),
|
||||
RuntimeProfileFieldError::MissingUserId
|
||||
);
|
||||
assert_eq!(
|
||||
build_runtime_profile_wallet_ledger_list_input(" ".to_string())
|
||||
.expect_err("wallet ledger input should fail"),
|
||||
RuntimeProfileFieldError::MissingUserId
|
||||
);
|
||||
assert_eq!(
|
||||
build_runtime_profile_play_stats_get_input(" ".to_string())
|
||||
.expect_err("play stats input should fail"),
|
||||
RuntimeProfileFieldError::MissingUserId
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_dashboard_record_formats_optional_timestamp() {
|
||||
let record = build_runtime_profile_dashboard_record(RuntimeProfileDashboardSnapshot {
|
||||
user_id: "user-1".to_string(),
|
||||
wallet_balance: 8,
|
||||
total_play_time_ms: 12,
|
||||
played_world_count: 2,
|
||||
updated_at_micros: Some(1_713_680_000_000_000),
|
||||
});
|
||||
|
||||
assert_eq!(record.updated_at, Some("2024-04-21T06:13:20Z".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_wallet_ledger_source_type_formats_to_snapshot_sync() {
|
||||
assert_eq!(
|
||||
RuntimeProfileWalletLedgerSourceType::SnapshotSync.as_str(),
|
||||
"snapshot_sync"
|
||||
);
|
||||
}
|
||||
}
|
||||
14
server-rs/crates/module-story/Cargo.toml
Normal file
14
server-rs/crates/module-story/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "module-story"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
spacetime-types = ["dep:spacetimedb"]
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
shared-kernel = { path = "../shared-kernel" }
|
||||
spacetimedb = { workspace = true, optional = true }
|
||||
610
server-rs/crates/module-story/src/lib.rs
Normal file
610
server-rs/crates/module-story/src/lib.rs
Normal file
@@ -0,0 +1,610 @@
|
||||
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,
|
||||
};
|
||||
#[cfg(feature = "spacetime-types")]
|
||||
use spacetimedb::SpacetimeType;
|
||||
|
||||
pub const STORY_SESSION_ID_PREFIX: &str = "storysess_";
|
||||
pub const STORY_EVENT_ID_PREFIX: &str = "storyevt_";
|
||||
pub const INITIAL_STORY_SESSION_VERSION: u32 = 1;
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum StorySessionStatus {
|
||||
Active,
|
||||
Completed,
|
||||
Archived,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum StoryEventKind {
|
||||
SessionStarted,
|
||||
StoryContinued,
|
||||
}
|
||||
|
||||
impl StorySessionStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Active => "active",
|
||||
Self::Completed => "completed",
|
||||
Self::Archived => "archived",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StoryEventKind {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::SessionStarted => "session_started",
|
||||
Self::StoryContinued => "story_continued",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum StorySessionFieldError {
|
||||
MissingSessionId,
|
||||
MissingRuntimeSessionId,
|
||||
MissingActorUserId,
|
||||
MissingWorldProfileId,
|
||||
MissingInitialPrompt,
|
||||
MissingNarrativeText,
|
||||
MissingEventId,
|
||||
InvalidVersion,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct StorySessionInput {
|
||||
pub story_session_id: String,
|
||||
pub runtime_session_id: String,
|
||||
pub actor_user_id: String,
|
||||
pub world_profile_id: String,
|
||||
pub initial_prompt: String,
|
||||
pub opening_summary: Option<String>,
|
||||
pub created_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct StorySessionSnapshot {
|
||||
pub story_session_id: String,
|
||||
pub runtime_session_id: String,
|
||||
pub actor_user_id: String,
|
||||
pub world_profile_id: String,
|
||||
pub initial_prompt: String,
|
||||
pub opening_summary: Option<String>,
|
||||
pub latest_narrative_text: String,
|
||||
pub latest_choice_function_id: Option<String>,
|
||||
pub status: StorySessionStatus,
|
||||
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 StoryContinueInput {
|
||||
pub story_session_id: String,
|
||||
pub event_id: String,
|
||||
pub narrative_text: String,
|
||||
pub choice_function_id: Option<String>,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct StorySessionStateInput {
|
||||
pub story_session_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct StoryEventSnapshot {
|
||||
pub event_id: String,
|
||||
pub story_session_id: String,
|
||||
pub event_kind: StoryEventKind,
|
||||
pub narrative_text: String,
|
||||
pub choice_function_id: Option<String>,
|
||||
pub created_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct StorySessionProcedureResult {
|
||||
pub ok: bool,
|
||||
pub session: Option<StorySessionSnapshot>,
|
||||
pub event: Option<StoryEventSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct StorySessionStateProcedureResult {
|
||||
pub ok: bool,
|
||||
pub session: Option<StorySessionSnapshot>,
|
||||
pub events: Vec<StoryEventSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct StorySessionRecord {
|
||||
pub story_session_id: String,
|
||||
pub runtime_session_id: String,
|
||||
pub actor_user_id: String,
|
||||
pub world_profile_id: String,
|
||||
pub initial_prompt: String,
|
||||
pub opening_summary: Option<String>,
|
||||
pub latest_narrative_text: String,
|
||||
pub latest_choice_function_id: Option<String>,
|
||||
pub status: String,
|
||||
pub version: u32,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct StoryEventRecord {
|
||||
pub event_id: String,
|
||||
pub story_session_id: String,
|
||||
pub event_kind: String,
|
||||
pub narrative_text: String,
|
||||
pub choice_function_id: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct StorySessionResultRecord {
|
||||
pub session: StorySessionRecord,
|
||||
pub event: StoryEventRecord,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct StorySessionStateRecord {
|
||||
pub session: StorySessionRecord,
|
||||
pub events: Vec<StoryEventRecord>,
|
||||
}
|
||||
|
||||
pub fn build_story_session_input(
|
||||
story_session_id: String,
|
||||
runtime_session_id: String,
|
||||
actor_user_id: String,
|
||||
world_profile_id: String,
|
||||
initial_prompt: String,
|
||||
opening_summary: Option<String>,
|
||||
created_at_micros: i64,
|
||||
) -> Result<StorySessionInput, StorySessionFieldError> {
|
||||
let input = StorySessionInput {
|
||||
story_session_id: normalize_required_string(story_session_id).unwrap_or_default(),
|
||||
runtime_session_id: normalize_required_string(runtime_session_id).unwrap_or_default(),
|
||||
actor_user_id: normalize_required_string(actor_user_id).unwrap_or_default(),
|
||||
world_profile_id: normalize_required_string(world_profile_id).unwrap_or_default(),
|
||||
initial_prompt: normalize_required_string(initial_prompt).unwrap_or_default(),
|
||||
opening_summary: normalize_optional_value(opening_summary),
|
||||
created_at_micros,
|
||||
};
|
||||
|
||||
validate_story_session_input(&input)?;
|
||||
|
||||
Ok(input)
|
||||
}
|
||||
|
||||
pub fn build_story_session_state_input(
|
||||
story_session_id: String,
|
||||
) -> Result<StorySessionStateInput, StorySessionFieldError> {
|
||||
let input = StorySessionStateInput {
|
||||
story_session_id: normalize_required_string(story_session_id).unwrap_or_default(),
|
||||
};
|
||||
|
||||
validate_story_session_state_input(&input)?;
|
||||
|
||||
Ok(input)
|
||||
}
|
||||
|
||||
pub fn build_story_continue_input(
|
||||
story_session_id: String,
|
||||
event_id: String,
|
||||
narrative_text: String,
|
||||
choice_function_id: Option<String>,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<StoryContinueInput, StorySessionFieldError> {
|
||||
let input = StoryContinueInput {
|
||||
story_session_id: normalize_required_string(story_session_id).unwrap_or_default(),
|
||||
event_id: normalize_required_string(event_id).unwrap_or_default(),
|
||||
narrative_text: normalize_required_string(narrative_text).unwrap_or_default(),
|
||||
choice_function_id: normalize_optional_value(choice_function_id),
|
||||
updated_at_micros,
|
||||
};
|
||||
|
||||
validate_story_continue_input(&input)?;
|
||||
|
||||
Ok(input)
|
||||
}
|
||||
|
||||
pub fn validate_story_session_input(
|
||||
input: &StorySessionInput,
|
||||
) -> Result<(), StorySessionFieldError> {
|
||||
if normalize_required_string(&input.story_session_id).is_none() {
|
||||
return Err(StorySessionFieldError::MissingSessionId);
|
||||
}
|
||||
if normalize_required_string(&input.runtime_session_id).is_none() {
|
||||
return Err(StorySessionFieldError::MissingRuntimeSessionId);
|
||||
}
|
||||
if normalize_required_string(&input.actor_user_id).is_none() {
|
||||
return Err(StorySessionFieldError::MissingActorUserId);
|
||||
}
|
||||
if normalize_required_string(&input.world_profile_id).is_none() {
|
||||
return Err(StorySessionFieldError::MissingWorldProfileId);
|
||||
}
|
||||
if normalize_required_string(&input.initial_prompt).is_none() {
|
||||
return Err(StorySessionFieldError::MissingInitialPrompt);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_story_session_state_input(
|
||||
input: &StorySessionStateInput,
|
||||
) -> Result<(), StorySessionFieldError> {
|
||||
if normalize_required_string(&input.story_session_id).is_none() {
|
||||
return Err(StorySessionFieldError::MissingSessionId);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_story_continue_input(
|
||||
input: &StoryContinueInput,
|
||||
) -> Result<(), StorySessionFieldError> {
|
||||
if normalize_required_string(&input.story_session_id).is_none() {
|
||||
return Err(StorySessionFieldError::MissingSessionId);
|
||||
}
|
||||
if normalize_required_string(&input.event_id).is_none() {
|
||||
return Err(StorySessionFieldError::MissingEventId);
|
||||
}
|
||||
if normalize_required_string(&input.narrative_text).is_none() {
|
||||
return Err(StorySessionFieldError::MissingNarrativeText);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn build_story_session_snapshot(input: StorySessionInput) -> StorySessionSnapshot {
|
||||
StorySessionSnapshot {
|
||||
story_session_id: input.story_session_id,
|
||||
runtime_session_id: input.runtime_session_id,
|
||||
actor_user_id: input.actor_user_id,
|
||||
world_profile_id: input.world_profile_id,
|
||||
initial_prompt: input.initial_prompt,
|
||||
opening_summary: normalize_optional_value(input.opening_summary),
|
||||
latest_narrative_text: String::new(),
|
||||
latest_choice_function_id: None,
|
||||
status: StorySessionStatus::Active,
|
||||
version: INITIAL_STORY_SESSION_VERSION,
|
||||
created_at_micros: input.created_at_micros,
|
||||
updated_at_micros: input.created_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_story_started_event(snapshot: &StorySessionSnapshot) -> StoryEventSnapshot {
|
||||
StoryEventSnapshot {
|
||||
event_id: generate_story_event_id(snapshot.created_at_micros),
|
||||
story_session_id: snapshot.story_session_id.clone(),
|
||||
event_kind: StoryEventKind::SessionStarted,
|
||||
narrative_text: snapshot
|
||||
.opening_summary
|
||||
.clone()
|
||||
.unwrap_or_else(|| snapshot.initial_prompt.clone()),
|
||||
choice_function_id: None,
|
||||
created_at_micros: snapshot.created_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_story_continue(
|
||||
current: StorySessionSnapshot,
|
||||
input: StoryContinueInput,
|
||||
) -> Result<(StorySessionSnapshot, StoryEventSnapshot), StorySessionFieldError> {
|
||||
validate_story_continue_input(&input)?;
|
||||
|
||||
if current.version == 0 {
|
||||
return Err(StorySessionFieldError::InvalidVersion);
|
||||
}
|
||||
|
||||
let event = StoryEventSnapshot {
|
||||
event_id: input.event_id,
|
||||
story_session_id: current.story_session_id.clone(),
|
||||
event_kind: StoryEventKind::StoryContinued,
|
||||
narrative_text: input.narrative_text.clone(),
|
||||
choice_function_id: normalize_optional_value(input.choice_function_id),
|
||||
created_at_micros: input.updated_at_micros,
|
||||
};
|
||||
|
||||
let next = StorySessionSnapshot {
|
||||
latest_narrative_text: input.narrative_text,
|
||||
latest_choice_function_id: event.choice_function_id.clone(),
|
||||
version: current.version + 1,
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
..current
|
||||
};
|
||||
|
||||
Ok((next, event))
|
||||
}
|
||||
|
||||
pub fn generate_story_session_id(seed_micros: i64) -> String {
|
||||
build_prefixed_seed_id(STORY_SESSION_ID_PREFIX, seed_micros)
|
||||
}
|
||||
|
||||
pub fn generate_story_event_id(seed_micros: i64) -> String {
|
||||
build_prefixed_seed_id(STORY_EVENT_ID_PREFIX, seed_micros)
|
||||
}
|
||||
|
||||
pub fn build_story_session_record(snapshot: StorySessionSnapshot) -> StorySessionRecord {
|
||||
StorySessionRecord {
|
||||
story_session_id: snapshot.story_session_id,
|
||||
runtime_session_id: snapshot.runtime_session_id,
|
||||
actor_user_id: snapshot.actor_user_id,
|
||||
world_profile_id: snapshot.world_profile_id,
|
||||
initial_prompt: snapshot.initial_prompt,
|
||||
opening_summary: snapshot.opening_summary,
|
||||
latest_narrative_text: snapshot.latest_narrative_text,
|
||||
latest_choice_function_id: snapshot.latest_choice_function_id,
|
||||
status: snapshot.status.as_str().to_string(),
|
||||
version: snapshot.version,
|
||||
created_at: format_timestamp_micros(snapshot.created_at_micros),
|
||||
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_story_event_record(snapshot: StoryEventSnapshot) -> StoryEventRecord {
|
||||
StoryEventRecord {
|
||||
event_id: snapshot.event_id,
|
||||
story_session_id: snapshot.story_session_id,
|
||||
event_kind: snapshot.event_kind.as_str().to_string(),
|
||||
narrative_text: snapshot.narrative_text,
|
||||
choice_function_id: snapshot.choice_function_id,
|
||||
created_at: format_timestamp_micros(snapshot.created_at_micros),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_story_session_result_record(
|
||||
session: StorySessionSnapshot,
|
||||
event: StoryEventSnapshot,
|
||||
) -> StorySessionResultRecord {
|
||||
StorySessionResultRecord {
|
||||
session: build_story_session_record(session),
|
||||
event: build_story_event_record(event),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_story_session_state_record(
|
||||
session: StorySessionSnapshot,
|
||||
events: Vec<StoryEventSnapshot>,
|
||||
) -> StorySessionStateRecord {
|
||||
StorySessionStateRecord {
|
||||
session: build_story_session_record(session),
|
||||
events: events
|
||||
.into_iter()
|
||||
.map(build_story_event_record)
|
||||
.collect::<Vec<_>>(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_optional_value(value: Option<String>) -> Option<String> {
|
||||
normalize_shared_optional_string(value)
|
||||
}
|
||||
|
||||
impl fmt::Display for StorySessionFieldError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::MissingSessionId => f.write_str("story_session.story_session_id 不能为空"),
|
||||
Self::MissingRuntimeSessionId => {
|
||||
f.write_str("story_session.runtime_session_id 不能为空")
|
||||
}
|
||||
Self::MissingActorUserId => f.write_str("story_session.actor_user_id 不能为空"),
|
||||
Self::MissingWorldProfileId => f.write_str("story_session.world_profile_id 不能为空"),
|
||||
Self::MissingInitialPrompt => f.write_str("story_session.initial_prompt 不能为空"),
|
||||
Self::MissingNarrativeText => f.write_str("story_event.narrative_text 不能为空"),
|
||||
Self::MissingEventId => f.write_str("story_event.event_id 不能为空"),
|
||||
Self::InvalidVersion => f.write_str("story_session.version 必须大于 0"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for StorySessionFieldError {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn validate_story_session_input_accepts_minimal_contract() {
|
||||
let result = validate_story_session_input(&StorySessionInput {
|
||||
story_session_id: "storysess_001".to_string(),
|
||||
runtime_session_id: "runtime_001".to_string(),
|
||||
actor_user_id: "user_001".to_string(),
|
||||
world_profile_id: "profile_001".to_string(),
|
||||
initial_prompt: "进入营地".to_string(),
|
||||
opening_summary: Some("营地开场".to_string()),
|
||||
created_at_micros: 1_713_680_000_000_000,
|
||||
});
|
||||
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_story_session_input_rejects_missing_required_fields() {
|
||||
let error = validate_story_session_input(&StorySessionInput {
|
||||
story_session_id: String::new(),
|
||||
runtime_session_id: String::new(),
|
||||
actor_user_id: String::new(),
|
||||
world_profile_id: String::new(),
|
||||
initial_prompt: String::new(),
|
||||
opening_summary: None,
|
||||
created_at_micros: 1,
|
||||
})
|
||||
.expect_err("missing required story session fields should fail");
|
||||
|
||||
assert_eq!(error, StorySessionFieldError::MissingSessionId);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_story_session_snapshot_uses_active_status_and_initial_version() {
|
||||
let snapshot = build_story_session_snapshot(StorySessionInput {
|
||||
story_session_id: "storysess_001".to_string(),
|
||||
runtime_session_id: "runtime_001".to_string(),
|
||||
actor_user_id: "user_001".to_string(),
|
||||
world_profile_id: "profile_001".to_string(),
|
||||
initial_prompt: "进入营地".to_string(),
|
||||
opening_summary: Some(" ".to_string()),
|
||||
created_at_micros: 12,
|
||||
});
|
||||
|
||||
assert_eq!(snapshot.status, StorySessionStatus::Active);
|
||||
assert_eq!(snapshot.version, INITIAL_STORY_SESSION_VERSION);
|
||||
assert_eq!(snapshot.opening_summary, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_story_session_input_normalizes_optional_summary() {
|
||||
let input = build_story_session_input(
|
||||
" storysess_001 ".to_string(),
|
||||
" runtime_001 ".to_string(),
|
||||
" user_001 ".to_string(),
|
||||
" profile_001 ".to_string(),
|
||||
" 进入营地 ".to_string(),
|
||||
Some(" ".to_string()),
|
||||
12,
|
||||
)
|
||||
.expect("story session input should build");
|
||||
|
||||
assert_eq!(input.story_session_id, "storysess_001");
|
||||
assert_eq!(input.runtime_session_id, "runtime_001");
|
||||
assert_eq!(input.actor_user_id, "user_001");
|
||||
assert_eq!(input.world_profile_id, "profile_001");
|
||||
assert_eq!(input.initial_prompt, "进入营地");
|
||||
assert_eq!(input.opening_summary, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_story_session_state_input_rejects_blank_session_id() {
|
||||
let error = build_story_session_state_input(" ".to_string())
|
||||
.expect_err("blank story session id should fail");
|
||||
|
||||
assert_eq!(error, StorySessionFieldError::MissingSessionId);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_story_session_state_record_maps_all_events() {
|
||||
let record = build_story_session_state_record(
|
||||
StorySessionSnapshot {
|
||||
story_session_id: "storysess_001".to_string(),
|
||||
runtime_session_id: "runtime_001".to_string(),
|
||||
actor_user_id: "user_001".to_string(),
|
||||
world_profile_id: "profile_001".to_string(),
|
||||
initial_prompt: "进入营地".to_string(),
|
||||
opening_summary: Some("营地开场".to_string()),
|
||||
latest_narrative_text: "你看见篝火边有人招手。".to_string(),
|
||||
latest_choice_function_id: Some("talk_to_npc".to_string()),
|
||||
status: StorySessionStatus::Active,
|
||||
version: 2,
|
||||
created_at_micros: 1_713_686_400_000_000,
|
||||
updated_at_micros: 1_713_686_401_234_567,
|
||||
},
|
||||
vec![
|
||||
StoryEventSnapshot {
|
||||
event_id: "storyevt_001".to_string(),
|
||||
story_session_id: "storysess_001".to_string(),
|
||||
event_kind: StoryEventKind::SessionStarted,
|
||||
narrative_text: "营地开场".to_string(),
|
||||
choice_function_id: None,
|
||||
created_at_micros: 1_713_686_400_000_000,
|
||||
},
|
||||
StoryEventSnapshot {
|
||||
event_id: "storyevt_002".to_string(),
|
||||
story_session_id: "storysess_001".to_string(),
|
||||
event_kind: StoryEventKind::StoryContinued,
|
||||
narrative_text: "你看见篝火边有人招手。".to_string(),
|
||||
choice_function_id: Some("talk_to_npc".to_string()),
|
||||
created_at_micros: 1_713_686_401_234_567,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(record.session.story_session_id, "storysess_001");
|
||||
assert_eq!(record.events.len(), 2);
|
||||
assert_eq!(record.events[0].event_kind, "session_started");
|
||||
assert_eq!(record.events[1].event_kind, "story_continued");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_story_session_result_record_formats_status_and_timestamps() {
|
||||
let record = build_story_session_result_record(
|
||||
StorySessionSnapshot {
|
||||
story_session_id: "storysess_001".to_string(),
|
||||
runtime_session_id: "runtime_001".to_string(),
|
||||
actor_user_id: "user_001".to_string(),
|
||||
world_profile_id: "profile_001".to_string(),
|
||||
initial_prompt: "进入营地".to_string(),
|
||||
opening_summary: Some("营地开场".to_string()),
|
||||
latest_narrative_text: "你看到营地中央的篝火。".to_string(),
|
||||
latest_choice_function_id: Some("inspect_campfire".to_string()),
|
||||
status: StorySessionStatus::Active,
|
||||
version: 2,
|
||||
created_at_micros: 1_713_686_400_000_000,
|
||||
updated_at_micros: 1_713_686_401_234_567,
|
||||
},
|
||||
StoryEventSnapshot {
|
||||
event_id: "storyevt_001".to_string(),
|
||||
story_session_id: "storysess_001".to_string(),
|
||||
event_kind: StoryEventKind::StoryContinued,
|
||||
narrative_text: "你看到营地中央的篝火。".to_string(),
|
||||
choice_function_id: Some("inspect_campfire".to_string()),
|
||||
created_at_micros: 1_713_686_401_234_567,
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(record.session.status, "active");
|
||||
assert_eq!(record.session.created_at, "1713686400.000000Z");
|
||||
assert_eq!(record.session.updated_at, "1713686401.234567Z");
|
||||
assert_eq!(record.event.event_kind, "story_continued");
|
||||
assert_eq!(record.event.created_at, "1713686401.234567Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_story_continue_updates_latest_narrative_and_emits_event() {
|
||||
let current = build_story_session_snapshot(StorySessionInput {
|
||||
story_session_id: "storysess_001".to_string(),
|
||||
runtime_session_id: "runtime_001".to_string(),
|
||||
actor_user_id: "user_001".to_string(),
|
||||
world_profile_id: "profile_001".to_string(),
|
||||
initial_prompt: "进入营地".to_string(),
|
||||
opening_summary: Some("营地开场".to_string()),
|
||||
created_at_micros: 10,
|
||||
});
|
||||
|
||||
let (next, event) = apply_story_continue(
|
||||
current,
|
||||
StoryContinueInput {
|
||||
story_session_id: "storysess_001".to_string(),
|
||||
event_id: "storyevt_001".to_string(),
|
||||
narrative_text: "你看见篝火边有人招手。".to_string(),
|
||||
choice_function_id: Some("talk_to_npc".to_string()),
|
||||
updated_at_micros: 20,
|
||||
},
|
||||
)
|
||||
.expect("continue story should succeed");
|
||||
|
||||
assert_eq!(next.latest_narrative_text, "你看见篝火边有人招手。");
|
||||
assert_eq!(
|
||||
next.latest_choice_function_id.as_deref(),
|
||||
Some("talk_to_npc")
|
||||
);
|
||||
assert_eq!(next.version, INITIAL_STORY_SESSION_VERSION + 1);
|
||||
assert_eq!(event.event_kind, StoryEventKind::StoryContinued);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ sha2 = "0.10"
|
||||
jsonwebtoken = "9"
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
shared-kernel = { path = "../shared-kernel" }
|
||||
time = { version = "0.3", features = ["std"] }
|
||||
urlencoding = "2"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
@@ -7,8 +7,8 @@ use jsonwebtoken::{
|
||||
use rand_core::OsRng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use shared_kernel::{new_uuid_simple_string, normalize_optional_string, normalize_required_string};
|
||||
use time::{Duration, OffsetDateTime};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub const ACCESS_TOKEN_ALGORITHM: Algorithm = Algorithm::HS256;
|
||||
pub const DEFAULT_ACCESS_TOKEN_TTL_SECONDS: u64 = 2 * 60 * 60;
|
||||
@@ -114,16 +114,10 @@ impl JwtConfig {
|
||||
secret: String,
|
||||
access_token_ttl_seconds: u64,
|
||||
) -> Result<Self, JwtError> {
|
||||
let issuer = issuer.trim().to_string();
|
||||
let secret = secret.trim().to_string();
|
||||
|
||||
if issuer.is_empty() {
|
||||
return Err(JwtError::InvalidConfig("JWT issuer 不能为空"));
|
||||
}
|
||||
|
||||
if secret.is_empty() {
|
||||
return Err(JwtError::InvalidConfig("JWT secret 不能为空"));
|
||||
}
|
||||
let issuer = normalize_required_string(&issuer)
|
||||
.ok_or(JwtError::InvalidConfig("JWT issuer 不能为空"))?;
|
||||
let secret = normalize_required_string(&secret)
|
||||
.ok_or(JwtError::InvalidConfig("JWT secret 不能为空"))?;
|
||||
|
||||
if access_token_ttl_seconds == 0 {
|
||||
return Err(JwtError::InvalidConfig(
|
||||
@@ -174,20 +168,12 @@ impl RefreshCookieConfig {
|
||||
cookie_same_site: RefreshCookieSameSite,
|
||||
refresh_session_ttl_days: u32,
|
||||
) -> Result<Self, RefreshCookieError> {
|
||||
let cookie_name = cookie_name.trim().to_string();
|
||||
let cookie_path = cookie_path.trim().to_string();
|
||||
|
||||
if cookie_name.is_empty() {
|
||||
return Err(RefreshCookieError::InvalidConfig(
|
||||
"refresh cookie 名称不能为空",
|
||||
));
|
||||
}
|
||||
|
||||
if cookie_path.is_empty() {
|
||||
return Err(RefreshCookieError::InvalidConfig(
|
||||
"refresh cookie path 不能为空",
|
||||
));
|
||||
}
|
||||
let cookie_name = normalize_required_string(&cookie_name).ok_or(
|
||||
RefreshCookieError::InvalidConfig("refresh cookie 名称不能为空"),
|
||||
)?;
|
||||
let cookie_path = normalize_required_string(&cookie_path).ok_or(
|
||||
RefreshCookieError::InvalidConfig("refresh cookie path 不能为空"),
|
||||
)?;
|
||||
|
||||
if refresh_session_ttl_days == 0 {
|
||||
return Err(RefreshCookieError::InvalidConfig(
|
||||
@@ -401,7 +387,7 @@ pub async fn verify_password(
|
||||
}
|
||||
|
||||
pub fn create_refresh_session_token() -> String {
|
||||
Uuid::new_v4().simple().to_string()
|
||||
new_uuid_simple_string()
|
||||
}
|
||||
|
||||
pub fn hash_refresh_session_token(token: &str) -> String {
|
||||
@@ -484,23 +470,11 @@ fn normalize_required_field(
|
||||
value: String,
|
||||
error_message: &'static str,
|
||||
) -> Result<String, JwtError> {
|
||||
let value = value.trim().to_string();
|
||||
if value.is_empty() {
|
||||
return Err(JwtError::InvalidClaims(error_message));
|
||||
}
|
||||
|
||||
Ok(value)
|
||||
normalize_required_string(&value).ok_or(JwtError::InvalidClaims(error_message))
|
||||
}
|
||||
|
||||
fn normalize_optional_field(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|field| {
|
||||
let field = field.trim().to_string();
|
||||
if field.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(field)
|
||||
})
|
||||
normalize_optional_string(value)
|
||||
}
|
||||
|
||||
fn normalize_roles(roles: Vec<String>) -> Result<Vec<String>, JwtError> {
|
||||
@@ -681,7 +655,7 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
hash,
|
||||
"0b6901f0dcee3f50df4115ecb29214f7740f8173919f94cc1f5eb92ff2481ce8"
|
||||
"9fab76f9100ec6c151c8caa0c42ab10e10fbc7618f15e24cf3dffc93e19c4c4e"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
15
server-rs/crates/platform-llm/Cargo.toml
Normal file
15
server-rs/crates/platform-llm/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "platform-llm"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
log.workspace = true
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["time"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["macros", "rt"] }
|
||||
@@ -1,28 +1,48 @@
|
||||
# platform-llm 平台适配 package 占位说明
|
||||
# platform-llm 平台适配 crate
|
||||
|
||||
日期:`2026-04-20`
|
||||
日期:`2026-04-21`
|
||||
|
||||
## 1. package 职责
|
||||
## 1. crate 职责
|
||||
|
||||
`platform-llm` 是大模型平台适配 package,后续负责:
|
||||
`platform-llm` 是 Rust 工作区里的大模型平台适配 crate,当前首版已经落地以下能力:
|
||||
|
||||
1. DashScope、Ark 与其他模型供应商适配
|
||||
2. 统一模型调用、流式输出、重试、超时与日志策略
|
||||
3. 供 `module-ai`、`module-story`、`module-npc`、`module-custom-world` 等模块复用的模型基础设施能力
|
||||
1. 统一 Ark / DashScope / 其他 OpenAI 兼容网关的文本模型配置结构
|
||||
2. 统一 `/chat/completions` 文本请求、非流式响应与 SSE 流式增量解析
|
||||
3. 统一超时、连接失败、上游错误、空响应与重试策略
|
||||
4. 为后续 `module-ai`、`module-story`、`module-npc`、`module-custom-world` 提供可直接复用的基础 client
|
||||
|
||||
## 2. 当前阶段说明
|
||||
## 2. 当前首版边界
|
||||
|
||||
当前提交仅完成目录占位,不提前进入具体模型 SDK、流式调用与供应商切换实现。
|
||||
当前实现只覆盖“文本 chat completion”主链,不提前混入媒体生成和业务编排:
|
||||
|
||||
后续与本 package 直接相关的任务包括:
|
||||
1. 支持 OpenAI 兼容格式的 JSON 请求与 SSE 增量响应
|
||||
2. 支持按 provider 打标签,但不把业务 prompt、SSE 转发和模块状态写回放进本 crate
|
||||
3. `DashScope` 当前只通过“调用方显式提供兼容文本网关 base url”的方式接入,不复用图像 API
|
||||
4. 角色动画、图片、视频、资产轮询仍留在后续 `platform-llm` / `platform-oss` / 业务模块任务里另行实现
|
||||
|
||||
1. 落地统一模型请求与响应适配
|
||||
2. 落地流式文本输出与阶段事件适配
|
||||
3. 落地重试、超时、错误与日志策略
|
||||
4. 设计多供应商切换与能力分层
|
||||
## 3. 核心导出
|
||||
|
||||
## 3. 边界约束
|
||||
首版对外导出以下公共类型:
|
||||
|
||||
1. `platform-llm` 只承接模型平台适配,不承接业务模块的状态真相与业务规则。
|
||||
2. 生成型状态与结果引用最终由业务模块和 `apps/spacetime-module` 管理,前端接口由 `apps/api-server` 暴露。
|
||||
3. 不允许把供应商 SDK、流式细节和重试策略重新散落到多个业务模块里各自实现。
|
||||
1. `LlmProvider`
|
||||
2. `LlmConfig`
|
||||
3. `LlmMessageRole`
|
||||
4. `LlmMessage`
|
||||
5. `LlmTextRequest`
|
||||
6. `LlmStreamDelta`
|
||||
7. `LlmTextResponse`
|
||||
8. `LlmTokenUsage`
|
||||
9. `LlmClient`
|
||||
10. `LlmError`
|
||||
|
||||
## 4. 设计文档
|
||||
|
||||
详细约束与接口说明见:
|
||||
|
||||
- [../../../docs/technical/PLATFORM_LLM_TEXT_GATEWAY_DESIGN_2026-04-21.md](../../../docs/technical/PLATFORM_LLM_TEXT_GATEWAY_DESIGN_2026-04-21.md)
|
||||
|
||||
## 5. 边界约束
|
||||
|
||||
1. `platform-llm` 只承接模型平台适配,不承接业务模块状态真相与业务规则。
|
||||
2. 业务模块只能依赖这里的统一 client / DTO / 错误模型,不能再把上游请求细节散落回各 crate。
|
||||
3. `api-server` 后续如果需要做 REST/SSE façade,只允许在协议层调用 `platform-llm`,不能复制一份私有实现。
|
||||
|
||||
1069
server-rs/crates/platform-llm/src/lib.rs
Normal file
1069
server-rs/crates/platform-llm/src/lib.rs
Normal file
File diff suppressed because it is too large
Load Diff
10
server-rs/crates/shared-contracts/Cargo.toml
Normal file
10
server-rs/crates/shared-contracts/Cargo.toml
Normal file
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "shared-contracts"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
platform-oss = { path = "../platform-oss" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
@@ -1,6 +1,6 @@
|
||||
# shared-contracts 共享 crate 占位说明
|
||||
# shared-contracts 共享 crate 说明
|
||||
|
||||
日期:`2026-04-20`
|
||||
日期:`2026-04-21`
|
||||
|
||||
## 1. crate 职责
|
||||
|
||||
@@ -13,14 +13,55 @@
|
||||
|
||||
## 2. 当前阶段说明
|
||||
|
||||
当前提交仅完成目录占位,不提前进入 DTO、事件与兼容结构实现。
|
||||
当前阶段已完成 Stage1 最小真实落地:
|
||||
|
||||
后续与本 crate 直接相关的任务包括:
|
||||
1. 统一 response envelope / 头部常量
|
||||
2. `auth/login-options`
|
||||
3. `auth/me`
|
||||
4. `auth/sessions`
|
||||
5. `runtime/settings`
|
||||
|
||||
1. 对齐现有前端直接依赖的响应头与 envelope
|
||||
2. 对齐 story、custom world、chat 等 SSE 事件结构
|
||||
3. 对齐 auth、runtime、assets 等兼容 DTO
|
||||
4. 为 breaking change 建立显式变更边界
|
||||
当前阶段继续补齐的 Stage2 鉴权 DTO:
|
||||
|
||||
1. `auth/entry`
|
||||
2. `auth/refresh`
|
||||
3. `auth/logout`
|
||||
4. `auth/logout-all`
|
||||
5. `auth/phone/send-code`
|
||||
6. `auth/phone/login`
|
||||
7. `auth/wechat/start`
|
||||
8. `auth/wechat/callback`
|
||||
9. `auth/wechat/bind-phone`
|
||||
|
||||
当前阶段继续补齐的 Stage3 公开请求 DTO:
|
||||
|
||||
1. `assets/direct-upload-tickets`
|
||||
2. `assets/read-url`
|
||||
3. `assets/objects/confirm`
|
||||
4. `assets/objects/bind`
|
||||
5. `story-sessions/begin`
|
||||
6. `story-sessions/continue`
|
||||
|
||||
当前阶段继续补齐的 Stage4 显式成功响应 DTO:
|
||||
|
||||
1. `assets/direct-upload-tickets`
|
||||
2. `assets/read-url`
|
||||
3. `assets/objects/confirm`
|
||||
4. `assets/objects/bind`
|
||||
5. `story-sessions/begin`
|
||||
6. `story-sessions/continue`
|
||||
|
||||
当前阶段新增 Stage5 `runtime story` 兼容桥 DTO 基线:
|
||||
|
||||
1. `runtime/story/state/resolve` 请求 DTO
|
||||
2. `RuntimeStoryActionResponse` 兼容响应 DTO
|
||||
3. `RuntimeStoryViewModel / presentation / patches / snapshot` 显式结构
|
||||
|
||||
当前仍刻意未做:
|
||||
|
||||
1. SSE 事件结构
|
||||
2. 自动代码生成或跨语言 contract 同步
|
||||
3. 其他尚未收口模块的 handler 响应体显式 DTO 化
|
||||
|
||||
## 3. 边界约束
|
||||
|
||||
|
||||
223
server-rs/crates/shared-contracts/src/ai.rs
Normal file
223
server-rs/crates/shared-contracts/src/ai.rs
Normal file
@@ -0,0 +1,223 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateAiTaskRequest {
|
||||
pub task_kind: String,
|
||||
pub request_label: String,
|
||||
pub source_module: String,
|
||||
#[serde(default)]
|
||||
pub source_entity_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub request_payload_json: Option<String>,
|
||||
#[serde(default)]
|
||||
pub stage_kinds: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AppendAiTextChunkRequest {
|
||||
pub stage_kind: String,
|
||||
pub sequence: u32,
|
||||
pub delta_text: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CompleteAiStageRequest {
|
||||
#[serde(default)]
|
||||
pub text_output: Option<String>,
|
||||
#[serde(default)]
|
||||
pub structured_payload_json: Option<String>,
|
||||
#[serde(default)]
|
||||
pub warning_messages: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AttachAiResultReferenceRequest {
|
||||
pub reference_kind: String,
|
||||
pub reference_id: String,
|
||||
#[serde(default)]
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FailAiTaskRequest {
|
||||
pub failure_message: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AiTaskStagePayload {
|
||||
pub stage_kind: String,
|
||||
pub label: String,
|
||||
pub detail: String,
|
||||
pub order: u32,
|
||||
pub status: String,
|
||||
#[serde(default)]
|
||||
pub text_output: Option<String>,
|
||||
#[serde(default)]
|
||||
pub structured_payload_json: Option<String>,
|
||||
pub warning_messages: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub started_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub completed_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AiResultReferencePayload {
|
||||
pub result_ref_id: String,
|
||||
pub task_id: String,
|
||||
pub reference_kind: String,
|
||||
pub reference_id: String,
|
||||
#[serde(default)]
|
||||
pub label: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AiTextChunkPayload {
|
||||
pub chunk_id: String,
|
||||
pub task_id: String,
|
||||
pub stage_kind: String,
|
||||
pub sequence: u32,
|
||||
pub delta_text: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AiTaskPayload {
|
||||
pub task_id: String,
|
||||
pub task_kind: String,
|
||||
pub owner_user_id: String,
|
||||
pub request_label: String,
|
||||
pub source_module: String,
|
||||
#[serde(default)]
|
||||
pub source_entity_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub request_payload_json: Option<String>,
|
||||
pub status: String,
|
||||
#[serde(default)]
|
||||
pub failure_message: Option<String>,
|
||||
pub stages: Vec<AiTaskStagePayload>,
|
||||
pub result_references: Vec<AiResultReferencePayload>,
|
||||
#[serde(default)]
|
||||
pub latest_text_output: Option<String>,
|
||||
#[serde(default)]
|
||||
pub latest_structured_payload_json: Option<String>,
|
||||
pub version: u32,
|
||||
pub created_at: String,
|
||||
#[serde(default)]
|
||||
pub started_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub completed_at: Option<String>,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AiTaskMutationResponse {
|
||||
pub ai_task: AiTaskPayload,
|
||||
#[serde(default)]
|
||||
pub ai_text_chunk: Option<AiTextChunkPayload>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AiTaskAcceptedResponse {
|
||||
pub accepted: bool,
|
||||
pub task_id: String,
|
||||
pub action: String,
|
||||
#[serde(default)]
|
||||
pub stage_kind: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn create_ai_task_request_uses_camel_case_fields() {
|
||||
let payload = serde_json::to_value(CreateAiTaskRequest {
|
||||
task_kind: "story_generation".to_string(),
|
||||
request_label: "营地开场".to_string(),
|
||||
source_module: "story".to_string(),
|
||||
source_entity_id: Some("storysess_001".to_string()),
|
||||
request_payload_json: Some("{\"scene\":\"camp\"}".to_string()),
|
||||
stage_kinds: vec!["prepare_prompt".to_string(), "request_model".to_string()],
|
||||
})
|
||||
.expect("payload should serialize");
|
||||
|
||||
assert_eq!(
|
||||
payload,
|
||||
json!({
|
||||
"taskKind": "story_generation",
|
||||
"requestLabel": "营地开场",
|
||||
"sourceModule": "story",
|
||||
"sourceEntityId": "storysess_001",
|
||||
"requestPayloadJson": "{\"scene\":\"camp\"}",
|
||||
"stageKinds": ["prepare_prompt", "request_model"]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_task_mutation_response_uses_camel_case_fields() {
|
||||
let payload = serde_json::to_value(AiTaskMutationResponse {
|
||||
ai_task: AiTaskPayload {
|
||||
task_id: "aitask_001".to_string(),
|
||||
task_kind: "npc_chat".to_string(),
|
||||
owner_user_id: "user_001".to_string(),
|
||||
request_label: "试探问话".to_string(),
|
||||
source_module: "npc".to_string(),
|
||||
source_entity_id: Some("npc_001".to_string()),
|
||||
request_payload_json: None,
|
||||
status: "running".to_string(),
|
||||
failure_message: None,
|
||||
stages: vec![AiTaskStagePayload {
|
||||
stage_kind: "request_model".to_string(),
|
||||
label: "请求模型".to_string(),
|
||||
detail: "向上游模型发起正式推理请求。".to_string(),
|
||||
order: 1,
|
||||
status: "running".to_string(),
|
||||
text_output: Some("你盯着对方的刀柄。".to_string()),
|
||||
structured_payload_json: None,
|
||||
warning_messages: vec![],
|
||||
started_at: Some("2026-04-22T12:00:00Z".to_string()),
|
||||
completed_at: None,
|
||||
}],
|
||||
result_references: vec![],
|
||||
latest_text_output: Some("你盯着对方的刀柄。".to_string()),
|
||||
latest_structured_payload_json: None,
|
||||
version: 2,
|
||||
created_at: "2026-04-22T12:00:00Z".to_string(),
|
||||
started_at: Some("2026-04-22T12:00:01Z".to_string()),
|
||||
completed_at: None,
|
||||
updated_at: "2026-04-22T12:00:02Z".to_string(),
|
||||
},
|
||||
ai_text_chunk: Some(AiTextChunkPayload {
|
||||
chunk_id: "aichunk_001".to_string(),
|
||||
task_id: "aitask_001".to_string(),
|
||||
stage_kind: "request_model".to_string(),
|
||||
sequence: 1,
|
||||
delta_text: "你".to_string(),
|
||||
created_at: "2026-04-22T12:00:02Z".to_string(),
|
||||
}),
|
||||
})
|
||||
.expect("payload should serialize");
|
||||
|
||||
assert_eq!(payload["aiTask"]["taskId"], json!("aitask_001"));
|
||||
assert_eq!(
|
||||
payload["aiTask"]["stages"][0]["stageKind"],
|
||||
json!("request_model")
|
||||
);
|
||||
assert_eq!(payload["aiTextChunk"]["deltaText"], json!("你"));
|
||||
}
|
||||
}
|
||||
168
server-rs/crates/shared-contracts/src/api.rs
Normal file
168
server-rs/crates/shared-contracts/src/api.rs
Normal file
@@ -0,0 +1,168 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
pub const API_VERSION: &str = "2026-04-08";
|
||||
pub const API_RESPONSE_ENVELOPE_HEADER: &str = "x-genarrative-response-envelope";
|
||||
pub const X_REQUEST_ID_HEADER: &str = "x-request-id";
|
||||
pub const API_VERSION_HEADER: &str = "x-api-version";
|
||||
pub const ROUTE_VERSION_HEADER: &str = "x-route-version";
|
||||
pub const RESPONSE_TIME_HEADER: &str = "x-response-time-ms";
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ApiResponseMeta {
|
||||
#[serde(rename = "apiVersion")]
|
||||
pub api_version: String,
|
||||
#[serde(rename = "requestId", skip_serializing_if = "Option::is_none")]
|
||||
pub request_id: Option<String>,
|
||||
#[serde(rename = "routeVersion")]
|
||||
pub route_version: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub operation: Option<String>,
|
||||
#[serde(rename = "latencyMs")]
|
||||
pub latency_ms: u64,
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
impl ApiResponseMeta {
|
||||
pub fn new(
|
||||
api_version: impl Into<String>,
|
||||
request_id: Option<String>,
|
||||
route_version: impl Into<String>,
|
||||
operation: Option<String>,
|
||||
latency_ms: u64,
|
||||
timestamp: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
api_version: api_version.into(),
|
||||
request_id,
|
||||
route_version: route_version.into(),
|
||||
operation,
|
||||
latency_ms,
|
||||
timestamp: timestamp.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ApiErrorPayload {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub details: Option<Value>,
|
||||
}
|
||||
|
||||
impl ApiErrorPayload {
|
||||
pub fn new(
|
||||
code: impl Into<String>,
|
||||
message: impl Into<String>,
|
||||
details: Option<Value>,
|
||||
) -> Self {
|
||||
Self {
|
||||
code: code.into(),
|
||||
message: message.into(),
|
||||
details,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ApiSuccessEnvelope<T> {
|
||||
pub ok: bool,
|
||||
pub data: T,
|
||||
pub error: Option<ApiErrorPayload>,
|
||||
pub meta: ApiResponseMeta,
|
||||
}
|
||||
|
||||
impl<T> ApiSuccessEnvelope<T> {
|
||||
pub fn new(data: T, meta: ApiResponseMeta) -> Self {
|
||||
Self {
|
||||
ok: true,
|
||||
data,
|
||||
error: None,
|
||||
meta,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ApiErrorEnvelope {
|
||||
pub ok: bool,
|
||||
pub data: Option<Value>,
|
||||
pub error: ApiErrorPayload,
|
||||
pub meta: ApiResponseMeta,
|
||||
}
|
||||
|
||||
impl ApiErrorEnvelope {
|
||||
pub fn new(error: ApiErrorPayload, meta: ApiResponseMeta) -> Self {
|
||||
Self {
|
||||
ok: false,
|
||||
data: None,
|
||||
error,
|
||||
meta,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct LegacyApiErrorResponse {
|
||||
pub error: ApiErrorPayload,
|
||||
pub meta: ApiResponseMeta,
|
||||
}
|
||||
|
||||
impl LegacyApiErrorResponse {
|
||||
pub fn new(error: ApiErrorPayload, meta: ApiResponseMeta) -> Self {
|
||||
Self { error, meta }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn success_envelope_serializes_null_error_field() {
|
||||
let payload = serde_json::to_value(ApiSuccessEnvelope::new(
|
||||
json!({ "service": "genarrative" }),
|
||||
ApiResponseMeta::new(
|
||||
API_VERSION,
|
||||
Some("req-1".to_string()),
|
||||
API_VERSION,
|
||||
Some("GET /healthz".to_string()),
|
||||
12,
|
||||
"2026-04-21T00:00:00Z",
|
||||
),
|
||||
))
|
||||
.expect("payload should serialize");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(true));
|
||||
assert_eq!(payload["error"], Value::Null);
|
||||
assert_eq!(
|
||||
payload["meta"]["apiVersion"],
|
||||
Value::String(API_VERSION.to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_envelope_serializes_null_data_field() {
|
||||
let payload = serde_json::to_value(ApiErrorEnvelope::new(
|
||||
ApiErrorPayload::new("BAD_REQUEST", "请求参数不合法", None),
|
||||
ApiResponseMeta::new(
|
||||
API_VERSION,
|
||||
Some("req-2".to_string()),
|
||||
API_VERSION,
|
||||
Some("POST /api/test".to_string()),
|
||||
21,
|
||||
"2026-04-21T00:00:01Z",
|
||||
),
|
||||
))
|
||||
.expect("payload should serialize");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(false));
|
||||
assert_eq!(payload["data"], Value::Null);
|
||||
assert_eq!(
|
||||
payload["error"]["code"],
|
||||
Value::String("BAD_REQUEST".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
361
server-rs/crates/shared-contracts/src/assets.rs
Normal file
361
server-rs/crates/shared-contracts/src/assets.rs
Normal file
@@ -0,0 +1,361 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use platform_oss::{
|
||||
OssObjectAccess, OssPostObjectFormFields, OssPostObjectResponse, OssSignedGetObjectUrlResponse,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateDirectUploadTicketRequest {
|
||||
pub legacy_prefix: String,
|
||||
#[serde(default)]
|
||||
pub path_segments: Vec<String>,
|
||||
pub file_name: String,
|
||||
#[serde(default)]
|
||||
pub content_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub access: Option<OssObjectAccess>,
|
||||
#[serde(default)]
|
||||
pub metadata: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub max_size_bytes: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub expire_seconds: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub success_action_status: Option<u16>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetReadUrlQuery {
|
||||
#[serde(default)]
|
||||
pub object_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub legacy_public_path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub expire_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConfirmAssetObjectAccessPolicy {
|
||||
Private,
|
||||
PublicRead,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConfirmAssetObjectRequest {
|
||||
#[serde(default)]
|
||||
pub bucket: Option<String>,
|
||||
pub object_key: String,
|
||||
#[serde(default)]
|
||||
pub content_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub content_length: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub content_hash: Option<String>,
|
||||
pub asset_kind: String,
|
||||
#[serde(default)]
|
||||
pub access_policy: Option<ConfirmAssetObjectAccessPolicy>,
|
||||
#[serde(default)]
|
||||
pub source_job_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub owner_user_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub profile_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub entity_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BindAssetObjectRequest {
|
||||
pub asset_object_id: String,
|
||||
pub entity_kind: String,
|
||||
pub entity_id: String,
|
||||
pub slot: String,
|
||||
pub asset_kind: String,
|
||||
#[serde(default)]
|
||||
pub owner_user_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub profile_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateDirectUploadTicketResponse {
|
||||
pub upload: DirectUploadTicketPayload,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DirectUploadTicketPayload {
|
||||
pub signature_version: String,
|
||||
pub provider: String,
|
||||
pub bucket: String,
|
||||
pub endpoint: String,
|
||||
pub host: String,
|
||||
pub object_key: String,
|
||||
pub legacy_public_path: String,
|
||||
#[serde(default)]
|
||||
pub content_type: Option<String>,
|
||||
pub access: OssObjectAccess,
|
||||
pub key_prefix: String,
|
||||
pub expires_at: String,
|
||||
pub max_size_bytes: u64,
|
||||
pub success_action_status: u16,
|
||||
pub form_fields: DirectUploadTicketFormFields,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct DirectUploadTicketFormFields {
|
||||
pub key: String,
|
||||
pub policy: String,
|
||||
#[serde(rename = "OSSAccessKeyId")]
|
||||
pub oss_access_key_id: String,
|
||||
#[serde(rename = "Signature")]
|
||||
pub signature: String,
|
||||
#[serde(rename = "success_action_status")]
|
||||
pub success_action_status: String,
|
||||
#[serde(rename = "Content-Type", skip_serializing_if = "Option::is_none")]
|
||||
pub content_type: Option<String>,
|
||||
#[serde(flatten)]
|
||||
pub metadata: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetAssetReadUrlResponse {
|
||||
pub read: AssetReadUrlPayload,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AssetReadUrlPayload {
|
||||
pub provider: String,
|
||||
pub bucket: String,
|
||||
pub endpoint: String,
|
||||
pub host: String,
|
||||
pub object_key: String,
|
||||
pub expires_at: String,
|
||||
pub signed_url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConfirmAssetObjectResponse {
|
||||
pub asset_object: AssetObjectPayload,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AssetObjectPayload {
|
||||
pub asset_object_id: String,
|
||||
pub bucket: String,
|
||||
pub object_key: String,
|
||||
pub access_policy: String,
|
||||
#[serde(default)]
|
||||
pub content_type: Option<String>,
|
||||
pub content_length: u64,
|
||||
#[serde(default)]
|
||||
pub content_hash: Option<String>,
|
||||
pub version: u32,
|
||||
#[serde(default)]
|
||||
pub source_job_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub owner_user_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub profile_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub entity_id: Option<String>,
|
||||
pub asset_kind: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BindAssetObjectResponse {
|
||||
pub asset_binding: AssetBindingPayload,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AssetBindingPayload {
|
||||
pub binding_id: String,
|
||||
pub asset_object_id: String,
|
||||
pub entity_kind: String,
|
||||
pub entity_id: String,
|
||||
pub slot: String,
|
||||
pub asset_kind: String,
|
||||
#[serde(default)]
|
||||
pub owner_user_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub profile_id: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl From<OssPostObjectFormFields> for DirectUploadTicketFormFields {
|
||||
fn from(value: OssPostObjectFormFields) -> Self {
|
||||
Self {
|
||||
key: value.key,
|
||||
policy: value.policy,
|
||||
oss_access_key_id: value.oss_access_key_id,
|
||||
signature: value.signature,
|
||||
success_action_status: value.success_action_status,
|
||||
content_type: value.content_type,
|
||||
metadata: value.metadata,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<OssPostObjectResponse> for DirectUploadTicketPayload {
|
||||
fn from(value: OssPostObjectResponse) -> Self {
|
||||
Self {
|
||||
signature_version: value.signature_version.to_string(),
|
||||
provider: value.provider.to_string(),
|
||||
bucket: value.bucket,
|
||||
endpoint: value.endpoint,
|
||||
host: value.host,
|
||||
object_key: value.object_key,
|
||||
legacy_public_path: value.legacy_public_path,
|
||||
content_type: value.content_type,
|
||||
access: value.access,
|
||||
key_prefix: value.key_prefix,
|
||||
expires_at: value.expires_at,
|
||||
max_size_bytes: value.max_size_bytes,
|
||||
success_action_status: value.success_action_status,
|
||||
form_fields: value.form_fields.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<OssSignedGetObjectUrlResponse> for AssetReadUrlPayload {
|
||||
fn from(value: OssSignedGetObjectUrlResponse) -> Self {
|
||||
Self {
|
||||
provider: value.provider.to_string(),
|
||||
bucket: value.bucket,
|
||||
endpoint: value.endpoint,
|
||||
host: value.host,
|
||||
object_key: value.object_key,
|
||||
expires_at: value.expires_at,
|
||||
signed_url: value.signed_url,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn confirm_asset_object_access_policy_uses_snake_case() {
|
||||
let payload = serde_json::to_value(ConfirmAssetObjectAccessPolicy::PublicRead)
|
||||
.expect("payload should serialize");
|
||||
|
||||
assert_eq!(payload, json!("public_read"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bind_asset_object_request_uses_camel_case_fields() {
|
||||
let payload = serde_json::to_value(BindAssetObjectRequest {
|
||||
asset_object_id: "assetobj_1".to_string(),
|
||||
entity_kind: "character".to_string(),
|
||||
entity_id: "npc_1".to_string(),
|
||||
slot: "primary_visual".to_string(),
|
||||
asset_kind: "character_visual".to_string(),
|
||||
owner_user_id: Some("user_1".to_string()),
|
||||
profile_id: Some("profile_1".to_string()),
|
||||
})
|
||||
.expect("payload should serialize");
|
||||
|
||||
assert_eq!(
|
||||
payload,
|
||||
json!({
|
||||
"assetObjectId": "assetobj_1",
|
||||
"entityKind": "character",
|
||||
"entityId": "npc_1",
|
||||
"slot": "primary_visual",
|
||||
"assetKind": "character_visual",
|
||||
"ownerUserId": "user_1",
|
||||
"profileId": "profile_1"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_upload_ticket_response_keeps_form_fields_shape() {
|
||||
let payload = serde_json::to_value(CreateDirectUploadTicketResponse {
|
||||
upload: DirectUploadTicketPayload::from(OssPostObjectResponse {
|
||||
signature_version: "v1",
|
||||
provider: "aliyun-oss",
|
||||
bucket: "genarrative-assets".to_string(),
|
||||
endpoint: "oss-cn-shanghai.aliyuncs.com".to_string(),
|
||||
host: "https://genarrative-assets.oss-cn-shanghai.aliyuncs.com".to_string(),
|
||||
object_key: "generated-characters/hero/master.png".to_string(),
|
||||
legacy_public_path: "/generated-characters/hero/master.png".to_string(),
|
||||
content_type: Some("image/png".to_string()),
|
||||
access: OssObjectAccess::Private,
|
||||
key_prefix: "generated-characters/hero".to_string(),
|
||||
expires_at: "2026-04-21T00:00:00Z".to_string(),
|
||||
max_size_bytes: 1024,
|
||||
success_action_status: 200,
|
||||
form_fields: OssPostObjectFormFields {
|
||||
key: "generated-characters/hero/master.png".to_string(),
|
||||
policy: "policy".to_string(),
|
||||
oss_access_key_id: "ak".to_string(),
|
||||
signature: "sig".to_string(),
|
||||
success_action_status: "200".to_string(),
|
||||
content_type: Some("image/png".to_string()),
|
||||
metadata: BTreeMap::from([(
|
||||
"x-oss-meta-asset-kind".to_string(),
|
||||
"character_visual".to_string(),
|
||||
)]),
|
||||
},
|
||||
}),
|
||||
})
|
||||
.expect("payload should serialize");
|
||||
|
||||
assert_eq!(payload["upload"]["signatureVersion"], json!("v1"));
|
||||
assert_eq!(
|
||||
payload["upload"]["formFields"]["OSSAccessKeyId"],
|
||||
json!("ak")
|
||||
);
|
||||
assert_eq!(
|
||||
payload["upload"]["formFields"]["x-oss-meta-asset-kind"],
|
||||
json!("character_visual")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_asset_object_response_uses_camel_case_fields() {
|
||||
let payload = serde_json::to_value(ConfirmAssetObjectResponse {
|
||||
asset_object: AssetObjectPayload {
|
||||
asset_object_id: "assetobj_1".to_string(),
|
||||
bucket: "genarrative-assets".to_string(),
|
||||
object_key: "generated-characters/hero/master.png".to_string(),
|
||||
access_policy: "private".to_string(),
|
||||
content_type: Some("image/png".to_string()),
|
||||
content_length: 1024,
|
||||
content_hash: Some("etag-1".to_string()),
|
||||
version: 1,
|
||||
source_job_id: Some("job_1".to_string()),
|
||||
owner_user_id: Some("user_1".to_string()),
|
||||
profile_id: Some("profile_1".to_string()),
|
||||
entity_id: Some("entity_1".to_string()),
|
||||
asset_kind: "character_visual".to_string(),
|
||||
created_at: "1.000000Z".to_string(),
|
||||
updated_at: "1.000000Z".to_string(),
|
||||
},
|
||||
})
|
||||
.expect("payload should serialize");
|
||||
|
||||
assert_eq!(payload["assetObject"]["assetObjectId"], json!("assetobj_1"));
|
||||
assert_eq!(payload["assetObject"]["accessPolicy"], json!("private"));
|
||||
assert_eq!(payload["assetObject"]["contentLength"], json!(1024));
|
||||
}
|
||||
}
|
||||
218
server-rs/crates/shared-contracts/src/auth.rs
Normal file
218
server-rs/crates/shared-contracts/src/auth.rs
Normal file
@@ -0,0 +1,218 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const AUTH_LOGIN_METHOD_PASSWORD: &str = "password";
|
||||
pub const AUTH_LOGIN_METHOD_PHONE: &str = "phone";
|
||||
pub const AUTH_LOGIN_METHOD_WECHAT: &str = "wechat";
|
||||
pub const AUTH_BINDING_STATUS_ACTIVE: &str = "active";
|
||||
pub const AUTH_BINDING_STATUS_PENDING_BIND_PHONE: &str = "pending_bind_phone";
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthLoginOptionsResponse {
|
||||
pub available_login_methods: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthUserPayload {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub phone_number_masked: Option<String>,
|
||||
pub login_method: String,
|
||||
pub binding_status: String,
|
||||
pub wechat_bound: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasswordEntryRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasswordEntryResponse {
|
||||
pub token: String,
|
||||
pub user: AuthUserPayload,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthMeResponse {
|
||||
pub user: AuthUserPayload,
|
||||
pub available_login_methods: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthSessionsResponse {
|
||||
pub sessions: Vec<AuthSessionSummaryPayload>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthSessionSummaryPayload {
|
||||
pub session_id: String,
|
||||
pub client_type: String,
|
||||
pub client_runtime: String,
|
||||
pub client_platform: String,
|
||||
pub client_label: String,
|
||||
pub device_display_name: String,
|
||||
pub mini_program_app_id: Option<String>,
|
||||
pub mini_program_env: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub ip_masked: Option<String>,
|
||||
pub is_current: bool,
|
||||
pub created_at: String,
|
||||
pub last_seen_at: String,
|
||||
pub expires_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RefreshSessionResponse {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct LogoutResponse {
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct LogoutAllResponse {
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PhoneSendCodeRequest {
|
||||
pub phone: String,
|
||||
pub scene: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PhoneSendCodeResponse {
|
||||
pub ok: bool,
|
||||
pub cooldown_seconds: u64,
|
||||
pub expires_in_seconds: u64,
|
||||
pub provider_request_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PhoneLoginRequest {
|
||||
pub phone: String,
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PhoneLoginResponse {
|
||||
pub token: String,
|
||||
pub user: AuthUserPayload,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WechatStartQuery {
|
||||
pub redirect_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WechatStartResponse {
|
||||
pub authorization_url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct WechatCallbackQuery {
|
||||
pub state: Option<String>,
|
||||
pub code: Option<String>,
|
||||
pub mock_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WechatBindPhoneRequest {
|
||||
pub phone: String,
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WechatBindPhoneResponse {
|
||||
pub token: String,
|
||||
pub user: AuthUserPayload,
|
||||
}
|
||||
|
||||
pub fn build_available_login_methods(
|
||||
sms_auth_enabled: bool,
|
||||
wechat_auth_enabled: bool,
|
||||
) -> Vec<String> {
|
||||
let mut methods = Vec::new();
|
||||
if sms_auth_enabled {
|
||||
methods.push(AUTH_LOGIN_METHOD_PHONE.to_string());
|
||||
}
|
||||
if wechat_auth_enabled {
|
||||
methods.push(AUTH_LOGIN_METHOD_WECHAT.to_string());
|
||||
}
|
||||
methods
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn available_login_methods_keep_phone_then_wechat_order() {
|
||||
let methods = build_available_login_methods(true, true);
|
||||
|
||||
assert_eq!(
|
||||
methods,
|
||||
vec![
|
||||
AUTH_LOGIN_METHOD_PHONE.to_string(),
|
||||
AUTH_LOGIN_METHOD_WECHAT.to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_entry_request_uses_camel_case_fields() {
|
||||
let payload = serde_json::to_value(PasswordEntryRequest {
|
||||
username: "guest_001".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.expect("payload should serialize");
|
||||
|
||||
assert_eq!(
|
||||
payload,
|
||||
json!({
|
||||
"username": "guest_001",
|
||||
"password": "secret123"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wechat_callback_query_keeps_provider_compatible_field_names() {
|
||||
let payload = serde_json::to_value(WechatCallbackQuery {
|
||||
state: Some("state-1".to_string()),
|
||||
code: Some("code-1".to_string()),
|
||||
mock_code: Some("mock-1".to_string()),
|
||||
})
|
||||
.expect("payload should serialize");
|
||||
|
||||
assert_eq!(
|
||||
payload,
|
||||
json!({
|
||||
"state": "state-1",
|
||||
"code": "code-1",
|
||||
"mock_code": "mock-1"
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
8
server-rs/crates/shared-contracts/src/lib.rs
Normal file
8
server-rs/crates/shared-contracts/src/lib.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
pub mod ai;
|
||||
pub mod api;
|
||||
pub mod assets;
|
||||
pub mod auth;
|
||||
pub mod llm;
|
||||
pub mod runtime;
|
||||
pub mod runtime_story;
|
||||
pub mod story;
|
||||
62
server-rs/crates/shared-contracts/src/llm.rs
Normal file
62
server-rs/crates/shared-contracts/src/llm.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LlmChatMessageRole {
|
||||
System,
|
||||
User,
|
||||
Assistant,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct LlmChatMessagePayload {
|
||||
pub role: LlmChatMessageRole,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct LlmChatCompletionRequest {
|
||||
#[serde(default)]
|
||||
pub model: Option<String>,
|
||||
#[serde(default)]
|
||||
pub stream: bool,
|
||||
pub messages: Vec<LlmChatMessagePayload>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LlmChatCompletionResponse {
|
||||
pub id: Option<String>,
|
||||
pub model: String,
|
||||
pub content: String,
|
||||
pub finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn llm_chat_completion_request_keeps_openai_compatible_field_names() {
|
||||
let payload = serde_json::to_value(LlmChatCompletionRequest {
|
||||
model: Some("doubao-test".to_string()),
|
||||
stream: false,
|
||||
messages: vec![
|
||||
LlmChatMessagePayload {
|
||||
role: LlmChatMessageRole::System,
|
||||
content: "系统".to_string(),
|
||||
},
|
||||
LlmChatMessagePayload {
|
||||
role: LlmChatMessageRole::User,
|
||||
content: "用户".to_string(),
|
||||
},
|
||||
],
|
||||
})
|
||||
.expect("payload should serialize");
|
||||
|
||||
assert_eq!(payload["model"], json!("doubao-test"));
|
||||
assert_eq!(payload["stream"], json!(false));
|
||||
assert_eq!(payload["messages"][0]["role"], json!("system"));
|
||||
}
|
||||
}
|
||||
517
server-rs/crates/shared-contracts/src/runtime.rs
Normal file
517
server-rs/crates/shared-contracts/src/runtime.rs
Normal file
@@ -0,0 +1,517 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const RUNTIME_PLATFORM_THEME_LIGHT: &str = "light";
|
||||
pub const RUNTIME_PLATFORM_THEME_DARK: &str = "dark";
|
||||
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC: &str = "snapshot_sync";
|
||||
pub const BROWSE_HISTORY_THEME_MODE_MARTIAL: &str = "martial";
|
||||
pub const BROWSE_HISTORY_THEME_MODE_ARCANE: &str = "arcane";
|
||||
pub const BROWSE_HISTORY_THEME_MODE_MACHINA: &str = "machina";
|
||||
pub const BROWSE_HISTORY_THEME_MODE_TIDE: &str = "tide";
|
||||
pub const BROWSE_HISTORY_THEME_MODE_RIFT: &str = "rift";
|
||||
pub const BROWSE_HISTORY_THEME_MODE_MYTHIC: &str = "mythic";
|
||||
pub const CUSTOM_WORLD_VISIBILITY_DRAFT: &str = "draft";
|
||||
pub const CUSTOM_WORLD_VISIBILITY_PUBLISHED: &str = "published";
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeSettingsResponse {
|
||||
pub music_volume: f32,
|
||||
pub platform_theme: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PutRuntimeSettingsRequest {
|
||||
pub music_volume: f32,
|
||||
pub platform_theme: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlatformBrowseHistoryEntryResponse {
|
||||
pub owner_user_id: String,
|
||||
pub profile_id: String,
|
||||
pub world_name: String,
|
||||
pub subtitle: String,
|
||||
pub summary_text: String,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub theme_mode: String,
|
||||
pub author_display_name: String,
|
||||
pub visited_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlatformBrowseHistoryWriteEntryRequest {
|
||||
pub owner_user_id: String,
|
||||
pub profile_id: String,
|
||||
pub world_name: String,
|
||||
#[serde(default)]
|
||||
pub subtitle: Option<String>,
|
||||
#[serde(default)]
|
||||
pub summary_text: Option<String>,
|
||||
#[serde(default)]
|
||||
pub cover_image_src: Option<String>,
|
||||
#[serde(default)]
|
||||
pub theme_mode: Option<String>,
|
||||
#[serde(default)]
|
||||
pub author_display_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub visited_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlatformBrowseHistoryBatchSyncRequest {
|
||||
pub entries: Vec<PlatformBrowseHistoryWriteEntryRequest>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(untagged)]
|
||||
pub enum PlatformBrowseHistoryUpsertRequest {
|
||||
Single(PlatformBrowseHistoryWriteEntryRequest),
|
||||
Batch(PlatformBrowseHistoryBatchSyncRequest),
|
||||
}
|
||||
|
||||
impl PlatformBrowseHistoryUpsertRequest {
|
||||
pub fn into_entries(self) -> Vec<PlatformBrowseHistoryWriteEntryRequest> {
|
||||
match self {
|
||||
Self::Single(entry) => vec![entry],
|
||||
Self::Batch(batch) => batch.entries,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlatformBrowseHistoryResponse {
|
||||
pub entries: Vec<PlatformBrowseHistoryEntryResponse>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfileDashboardSummaryResponse {
|
||||
pub wallet_balance: u64,
|
||||
pub total_play_time_ms: u64,
|
||||
pub played_world_count: u32,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfileWalletLedgerEntryResponse {
|
||||
pub id: String,
|
||||
pub amount_delta: i64,
|
||||
pub balance_after: u64,
|
||||
pub source_type: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfileWalletLedgerResponse {
|
||||
pub entries: Vec<ProfileWalletLedgerEntryResponse>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfilePlayedWorkSummaryResponse {
|
||||
pub world_key: String,
|
||||
pub owner_user_id: Option<String>,
|
||||
pub profile_id: Option<String>,
|
||||
pub world_type: Option<String>,
|
||||
pub world_title: String,
|
||||
pub world_subtitle: String,
|
||||
pub first_played_at: String,
|
||||
pub last_played_at: String,
|
||||
pub last_observed_play_time_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfilePlayStatsResponse {
|
||||
pub total_play_time_ms: u64,
|
||||
pub played_works: Vec<ProfilePlayedWorkSummaryResponse>,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeInventorySlotResponse {
|
||||
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, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeInventoryStateResponse {
|
||||
pub runtime_session_id: String,
|
||||
pub actor_user_id: String,
|
||||
pub backpack_items: Vec<RuntimeInventorySlotResponse>,
|
||||
pub equipment_items: Vec<RuntimeInventorySlotResponse>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomWorldProfileUpsertRequest {
|
||||
pub profile: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomWorldLibraryEntryResponse {
|
||||
pub owner_user_id: String,
|
||||
pub profile_id: String,
|
||||
pub profile: serde_json::Value,
|
||||
pub visibility: String,
|
||||
pub published_at: Option<String>,
|
||||
pub updated_at: 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: String,
|
||||
pub playable_npc_count: u32,
|
||||
pub landmark_count: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomWorldGalleryCardResponse {
|
||||
pub owner_user_id: String,
|
||||
pub profile_id: String,
|
||||
pub visibility: String,
|
||||
pub published_at: Option<String>,
|
||||
pub updated_at: 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: String,
|
||||
pub playable_npc_count: u32,
|
||||
pub landmark_count: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomWorldLibraryResponse {
|
||||
pub entries: Vec<CustomWorldLibraryEntryResponse>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomWorldLibraryMutationResponse {
|
||||
pub entry: CustomWorldLibraryEntryResponse,
|
||||
pub entries: Vec<CustomWorldLibraryEntryResponse>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomWorldGalleryResponse {
|
||||
pub entries: Vec<CustomWorldGalleryCardResponse>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomWorldGalleryDetailResponse {
|
||||
pub entry: CustomWorldLibraryEntryResponse,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateCustomWorldAgentSessionRequest {
|
||||
#[serde(default)]
|
||||
pub seed_text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SendCustomWorldAgentMessageRequest {
|
||||
pub client_message_id: String,
|
||||
pub text: String,
|
||||
#[serde(default)]
|
||||
pub quick_fill_requested: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub focus_card_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub selected_card_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomWorldAgentMessageResponse {
|
||||
pub id: String,
|
||||
pub role: String,
|
||||
pub kind: String,
|
||||
pub text: String,
|
||||
pub created_at: String,
|
||||
pub related_operation_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomWorldAgentOperationResponse {
|
||||
pub operation_id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub operation_type: String,
|
||||
pub status: String,
|
||||
pub phase_label: String,
|
||||
pub phase_detail: String,
|
||||
pub progress: u32,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomWorldDraftCardSummaryResponse {
|
||||
pub id: String,
|
||||
pub kind: String,
|
||||
pub title: String,
|
||||
pub subtitle: String,
|
||||
pub summary: String,
|
||||
pub status: String,
|
||||
pub linked_ids: Vec<String>,
|
||||
pub warning_count: u32,
|
||||
pub asset_status: Option<String>,
|
||||
pub asset_status_label: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomWorldAgentCheckpointResponse {
|
||||
pub checkpoint_id: String,
|
||||
pub created_at: String,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomWorldSupportedActionResponse {
|
||||
pub action: String,
|
||||
pub enabled: bool,
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomWorldAgentSessionSnapshotResponse {
|
||||
pub session_id: String,
|
||||
pub current_turn: u32,
|
||||
pub anchor_content: serde_json::Value,
|
||||
pub progress_percent: u32,
|
||||
pub last_assistant_reply: Option<String>,
|
||||
pub stage: String,
|
||||
pub focus_card_id: Option<String>,
|
||||
pub creator_intent: serde_json::Value,
|
||||
pub creator_intent_readiness: serde_json::Value,
|
||||
pub anchor_pack: serde_json::Value,
|
||||
pub lock_state: serde_json::Value,
|
||||
pub draft_profile: serde_json::Value,
|
||||
pub messages: Vec<CustomWorldAgentMessageResponse>,
|
||||
pub draft_cards: Vec<CustomWorldDraftCardSummaryResponse>,
|
||||
pub pending_clarifications: Vec<serde_json::Value>,
|
||||
pub suggested_actions: Vec<serde_json::Value>,
|
||||
pub recommended_replies: Vec<String>,
|
||||
pub quality_findings: Vec<serde_json::Value>,
|
||||
pub asset_coverage: serde_json::Value,
|
||||
pub checkpoints: Vec<CustomWorldAgentCheckpointResponse>,
|
||||
pub supported_actions: Vec<CustomWorldSupportedActionResponse>,
|
||||
pub result_preview: Option<serde_json::Value>,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomWorldAgentSessionResponse {
|
||||
pub session: CustomWorldAgentSessionSnapshotResponse,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn runtime_settings_request_uses_camel_case_fields() {
|
||||
let payload = serde_json::to_value(PutRuntimeSettingsRequest {
|
||||
music_volume: 0.42,
|
||||
platform_theme: RUNTIME_PLATFORM_THEME_LIGHT.to_string(),
|
||||
})
|
||||
.expect("payload should serialize");
|
||||
|
||||
assert_eq!(payload["platformTheme"], json!("light"));
|
||||
let music_volume = payload["musicVolume"]
|
||||
.as_f64()
|
||||
.expect("musicVolume should serialize as number");
|
||||
assert!((music_volume - 0.42).abs() < 0.0001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browse_history_response_uses_camel_case_fields() {
|
||||
let payload = serde_json::to_value(PlatformBrowseHistoryResponse {
|
||||
entries: vec![PlatformBrowseHistoryEntryResponse {
|
||||
owner_user_id: "owner-1".to_string(),
|
||||
profile_id: "profile-1".to_string(),
|
||||
world_name: "世界".to_string(),
|
||||
subtitle: "".to_string(),
|
||||
summary_text: "".to_string(),
|
||||
cover_image_src: None,
|
||||
theme_mode: BROWSE_HISTORY_THEME_MODE_MYTHIC.to_string(),
|
||||
author_display_name: "玩家".to_string(),
|
||||
visited_at: "2026-04-21T00:00:00Z".to_string(),
|
||||
}],
|
||||
})
|
||||
.expect("payload should serialize");
|
||||
|
||||
assert_eq!(payload["entries"][0]["ownerUserId"], json!("owner-1"));
|
||||
assert_eq!(payload["entries"][0]["themeMode"], json!("mythic"));
|
||||
assert_eq!(
|
||||
payload["entries"][0]["visitedAt"],
|
||||
json!("2026-04-21T00:00:00Z")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browse_history_upsert_request_accepts_single_or_batch_shape() {
|
||||
let single: PlatformBrowseHistoryUpsertRequest = serde_json::from_value(json!({
|
||||
"ownerUserId": "owner-1",
|
||||
"profileId": "profile-1",
|
||||
"worldName": "世界"
|
||||
}))
|
||||
.expect("single shape should deserialize");
|
||||
let batch: PlatformBrowseHistoryUpsertRequest = serde_json::from_value(json!({
|
||||
"entries": [{
|
||||
"ownerUserId": "owner-1",
|
||||
"profileId": "profile-1",
|
||||
"worldName": "世界"
|
||||
}]
|
||||
}))
|
||||
.expect("batch shape should deserialize");
|
||||
|
||||
assert_eq!(single.into_entries().len(), 1);
|
||||
assert_eq!(batch.into_entries().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_dashboard_response_uses_camel_case_fields() {
|
||||
let payload = serde_json::to_value(ProfileDashboardSummaryResponse {
|
||||
wallet_balance: 8,
|
||||
total_play_time_ms: 16,
|
||||
played_world_count: 3,
|
||||
updated_at: Some("2026-04-22T10:00:00Z".to_string()),
|
||||
})
|
||||
.expect("payload should serialize");
|
||||
|
||||
assert_eq!(payload["walletBalance"], json!(8));
|
||||
assert_eq!(payload["totalPlayTimeMs"], json!(16));
|
||||
assert_eq!(payload["playedWorldCount"], json!(3));
|
||||
assert_eq!(payload["updatedAt"], json!("2026-04-22T10:00:00Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_wallet_ledger_response_uses_camel_case_fields() {
|
||||
let payload = serde_json::to_value(ProfileWalletLedgerResponse {
|
||||
entries: vec![ProfileWalletLedgerEntryResponse {
|
||||
id: "ledger-1".to_string(),
|
||||
amount_delta: 12,
|
||||
balance_after: 80,
|
||||
source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC.to_string(),
|
||||
created_at: "2026-04-22T10:00:00Z".to_string(),
|
||||
}],
|
||||
})
|
||||
.expect("payload should serialize");
|
||||
|
||||
assert_eq!(payload["entries"][0]["amountDelta"], json!(12));
|
||||
assert_eq!(payload["entries"][0]["balanceAfter"], json!(80));
|
||||
assert_eq!(payload["entries"][0]["sourceType"], json!("snapshot_sync"));
|
||||
assert_eq!(
|
||||
payload["entries"][0]["createdAt"],
|
||||
json!("2026-04-22T10:00:00Z")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_play_stats_response_uses_camel_case_fields() {
|
||||
let payload = serde_json::to_value(ProfilePlayStatsResponse {
|
||||
total_play_time_ms: 18,
|
||||
played_works: vec![ProfilePlayedWorkSummaryResponse {
|
||||
world_key: "builtin:WUXIA".to_string(),
|
||||
owner_user_id: None,
|
||||
profile_id: None,
|
||||
world_type: Some("WUXIA".to_string()),
|
||||
world_title: "武侠世界".to_string(),
|
||||
world_subtitle: "".to_string(),
|
||||
first_played_at: "2026-04-20T10:00:00Z".to_string(),
|
||||
last_played_at: "2026-04-22T10:00:00Z".to_string(),
|
||||
last_observed_play_time_ms: 1200,
|
||||
}],
|
||||
updated_at: Some("2026-04-22T10:00:00Z".to_string()),
|
||||
})
|
||||
.expect("payload should serialize");
|
||||
|
||||
assert_eq!(payload["totalPlayTimeMs"], json!(18));
|
||||
assert_eq!(
|
||||
payload["playedWorks"][0]["worldKey"],
|
||||
json!("builtin:WUXIA")
|
||||
);
|
||||
assert_eq!(
|
||||
payload["playedWorks"][0]["lastObservedPlayTimeMs"],
|
||||
json!(1200)
|
||||
);
|
||||
assert_eq!(payload["updatedAt"], json!("2026-04-22T10:00:00Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_inventory_state_response_uses_camel_case_fields() {
|
||||
let payload = serde_json::to_value(RuntimeInventoryStateResponse {
|
||||
runtime_session_id: "runtime_001".to_string(),
|
||||
actor_user_id: "user_001".to_string(),
|
||||
backpack_items: vec![RuntimeInventorySlotResponse {
|
||||
slot_id: "invslot_001".to_string(),
|
||||
container_kind: "backpack".to_string(),
|
||||
slot_key: "invslot_001".to_string(),
|
||||
item_id: "consumable_heal_potion".to_string(),
|
||||
category: "消耗品".to_string(),
|
||||
name: "疗伤药".to_string(),
|
||||
description: Some("用于恢复少量气血。".to_string()),
|
||||
quantity: 2,
|
||||
rarity: "common".to_string(),
|
||||
tags: vec!["healing".to_string()],
|
||||
stackable: true,
|
||||
stack_key: "heal_potion".to_string(),
|
||||
equipment_slot_id: None,
|
||||
source_kind: "treasure_reward".to_string(),
|
||||
source_reference_id: Some("treasure_001".to_string()),
|
||||
created_at: "2026-04-22T10:00:00Z".to_string(),
|
||||
updated_at: "2026-04-22T10:01:00Z".to_string(),
|
||||
}],
|
||||
equipment_items: vec![],
|
||||
})
|
||||
.expect("payload should serialize");
|
||||
|
||||
assert_eq!(payload["runtimeSessionId"], json!("runtime_001"));
|
||||
assert_eq!(payload["actorUserId"], json!("user_001"));
|
||||
assert_eq!(payload["backpackItems"][0]["slotId"], json!("invslot_001"));
|
||||
assert_eq!(
|
||||
payload["backpackItems"][0]["sourceKind"],
|
||||
json!("treasure_reward")
|
||||
);
|
||||
}
|
||||
}
|
||||
324
server-rs/crates/shared-contracts/src/runtime_story.rs
Normal file
324
server-rs/crates/shared-contracts/src/runtime_story.rs
Normal file
@@ -0,0 +1,324 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeStorySnapshotPayload {
|
||||
pub saved_at: String,
|
||||
pub bottom_tab: String,
|
||||
pub game_state: Value,
|
||||
#[serde(default)]
|
||||
pub current_story: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeStoryStateResolveRequest {
|
||||
pub session_id: String,
|
||||
#[serde(default)]
|
||||
pub client_version: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub snapshot: Option<RuntimeStorySnapshotPayload>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeStoryOptionView {
|
||||
pub function_id: String,
|
||||
pub action_text: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub detail_text: Option<String>,
|
||||
pub scope: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub interaction: Option<RuntimeStoryOptionInteraction>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub payload: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub disabled: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum RuntimeStoryOptionInteraction {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Npc {
|
||||
npc_id: String,
|
||||
action: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
quest_id: Option<String>,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Treasure {
|
||||
action: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeStoryPlayerViewModel {
|
||||
pub hp: i32,
|
||||
pub max_hp: i32,
|
||||
pub mana: i32,
|
||||
pub max_mana: i32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeStoryCompanionViewModel {
|
||||
pub npc_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub character_id: Option<String>,
|
||||
pub joined_at_affinity: i32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeStoryEncounterViewModel {
|
||||
pub id: String,
|
||||
pub kind: String,
|
||||
pub npc_name: String,
|
||||
pub hostile: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub affinity: Option<i32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub recruited: Option<bool>,
|
||||
pub interaction_active: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub battle_mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeStoryStatusViewModel {
|
||||
pub in_battle: bool,
|
||||
pub npc_interaction_active: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_npc_battle_mode: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_npc_battle_outcome: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeBattlePresentation {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub target_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub target_name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub damage_dealt: Option<i32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub damage_taken: Option<i32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub outcome: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeStoryViewModel {
|
||||
pub player: RuntimeStoryPlayerViewModel,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub encounter: Option<RuntimeStoryEncounterViewModel>,
|
||||
pub companions: Vec<RuntimeStoryCompanionViewModel>,
|
||||
pub available_options: Vec<RuntimeStoryOptionView>,
|
||||
pub status: RuntimeStoryStatusViewModel,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeStoryPresentation {
|
||||
pub action_text: String,
|
||||
pub result_text: String,
|
||||
pub story_text: String,
|
||||
pub options: Vec<RuntimeStoryOptionView>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub toast: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub battle: Option<RuntimeBattlePresentation>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum RuntimeStoryPatch {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
StoryHistoryAppend {
|
||||
action_text: String,
|
||||
result_text: String,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
NpcAffinityChanged {
|
||||
npc_id: String,
|
||||
previous_affinity: i32,
|
||||
next_affinity: i32,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
BattleResolved {
|
||||
function_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
target_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
damage_dealt: Option<i32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
damage_taken: Option<i32>,
|
||||
outcome: String,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
StatusChanged {
|
||||
in_battle: bool,
|
||||
npc_interaction_active: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
current_npc_battle_mode: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
current_npc_battle_outcome: Option<String>,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
EncounterChanged {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
encounter_id: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeStoryActionResponse {
|
||||
pub session_id: String,
|
||||
pub server_version: u32,
|
||||
pub view_model: RuntimeStoryViewModel,
|
||||
pub presentation: RuntimeStoryPresentation,
|
||||
pub patches: Vec<RuntimeStoryPatch>,
|
||||
pub snapshot: RuntimeStorySnapshotPayload,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn runtime_story_state_resolve_request_uses_camel_case_fields() {
|
||||
let payload = serde_json::to_value(RuntimeStoryStateResolveRequest {
|
||||
session_id: "runtime-main".to_string(),
|
||||
client_version: Some(7),
|
||||
snapshot: Some(RuntimeStorySnapshotPayload {
|
||||
saved_at: "2026-04-22T12:00:00.000Z".to_string(),
|
||||
bottom_tab: "adventure".to_string(),
|
||||
game_state: json!({ "runtimeSessionId": "runtime-main" }),
|
||||
current_story: Some(json!({ "text": "营地里的火光还没有熄灭。" })),
|
||||
}),
|
||||
})
|
||||
.expect("payload should serialize");
|
||||
|
||||
assert_eq!(payload["sessionId"], json!("runtime-main"));
|
||||
assert_eq!(payload["clientVersion"], json!(7));
|
||||
assert_eq!(payload["snapshot"]["savedAt"], json!("2026-04-22T12:00:00.000Z"));
|
||||
assert_eq!(payload["snapshot"]["bottomTab"], json!("adventure"));
|
||||
assert_eq!(payload["snapshot"]["gameState"]["runtimeSessionId"], json!("runtime-main"));
|
||||
assert_eq!(
|
||||
payload["snapshot"]["currentStory"]["text"],
|
||||
json!("营地里的火光还没有熄灭。")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_story_action_response_uses_camel_case_fields() {
|
||||
let payload = serde_json::to_value(RuntimeStoryActionResponse {
|
||||
session_id: "runtime-main".to_string(),
|
||||
server_version: 8,
|
||||
view_model: RuntimeStoryViewModel {
|
||||
player: RuntimeStoryPlayerViewModel {
|
||||
hp: 32,
|
||||
max_hp: 40,
|
||||
mana: 18,
|
||||
max_mana: 20,
|
||||
},
|
||||
encounter: Some(RuntimeStoryEncounterViewModel {
|
||||
id: "npc_camp_firekeeper".to_string(),
|
||||
kind: "npc".to_string(),
|
||||
npc_name: "守火人".to_string(),
|
||||
hostile: false,
|
||||
affinity: Some(12),
|
||||
recruited: Some(false),
|
||||
interaction_active: true,
|
||||
battle_mode: None,
|
||||
}),
|
||||
companions: vec![RuntimeStoryCompanionViewModel {
|
||||
npc_id: "npc_companion_001".to_string(),
|
||||
character_id: Some("char_companion_001".to_string()),
|
||||
joined_at_affinity: 64,
|
||||
}],
|
||||
available_options: vec![RuntimeStoryOptionView {
|
||||
function_id: "npc_chat".to_string(),
|
||||
action_text: "继续交谈".to_string(),
|
||||
detail_text: Some("围绕当前话题继续推进关系判断。".to_string()),
|
||||
scope: "npc".to_string(),
|
||||
interaction: Some(RuntimeStoryOptionInteraction::Npc {
|
||||
npc_id: "npc_camp_firekeeper".to_string(),
|
||||
action: "chat".to_string(),
|
||||
quest_id: None,
|
||||
}),
|
||||
payload: Some(json!({ "note": "server-runtime-test" })),
|
||||
disabled: None,
|
||||
reason: None,
|
||||
}],
|
||||
status: RuntimeStoryStatusViewModel {
|
||||
in_battle: false,
|
||||
npc_interaction_active: true,
|
||||
current_npc_battle_mode: None,
|
||||
current_npc_battle_outcome: None,
|
||||
},
|
||||
},
|
||||
presentation: RuntimeStoryPresentation {
|
||||
action_text: "".to_string(),
|
||||
result_text: "".to_string(),
|
||||
story_text: "守火人抬眼看了你一瞬,示意你把想问的话继续说完。".to_string(),
|
||||
options: vec![RuntimeStoryOptionView {
|
||||
function_id: "npc_chat".to_string(),
|
||||
action_text: "继续交谈".to_string(),
|
||||
detail_text: Some("围绕当前话题继续推进关系判断。".to_string()),
|
||||
scope: "npc".to_string(),
|
||||
interaction: Some(RuntimeStoryOptionInteraction::Npc {
|
||||
npc_id: "npc_camp_firekeeper".to_string(),
|
||||
action: "chat".to_string(),
|
||||
quest_id: None,
|
||||
}),
|
||||
payload: Some(json!({ "note": "server-runtime-test" })),
|
||||
disabled: None,
|
||||
reason: None,
|
||||
}],
|
||||
toast: None,
|
||||
battle: None,
|
||||
},
|
||||
patches: vec![RuntimeStoryPatch::StatusChanged {
|
||||
in_battle: false,
|
||||
npc_interaction_active: true,
|
||||
current_npc_battle_mode: None,
|
||||
current_npc_battle_outcome: None,
|
||||
}],
|
||||
snapshot: RuntimeStorySnapshotPayload {
|
||||
saved_at: "2026-04-22T12:00:00.000Z".to_string(),
|
||||
bottom_tab: "adventure".to_string(),
|
||||
game_state: json!({ "runtimeSessionId": "runtime-main" }),
|
||||
current_story: Some(json!({
|
||||
"text": "守火人抬眼看了你一瞬,示意你把想问的话继续说完。"
|
||||
})),
|
||||
},
|
||||
})
|
||||
.expect("payload should serialize");
|
||||
|
||||
assert_eq!(payload["sessionId"], json!("runtime-main"));
|
||||
assert_eq!(payload["serverVersion"], json!(8));
|
||||
assert_eq!(payload["viewModel"]["player"]["maxHp"], json!(40));
|
||||
assert_eq!(
|
||||
payload["viewModel"]["availableOptions"][0]["interaction"]["npcId"],
|
||||
json!("npc_camp_firekeeper")
|
||||
);
|
||||
assert_eq!(
|
||||
payload["presentation"]["storyText"],
|
||||
json!("守火人抬眼看了你一瞬,示意你把想问的话继续说完。")
|
||||
);
|
||||
assert_eq!(payload["patches"][0]["type"], json!("status_changed"));
|
||||
assert_eq!(payload["snapshot"]["bottomTab"], json!("adventure"));
|
||||
}
|
||||
}
|
||||
164
server-rs/crates/shared-contracts/src/story.rs
Normal file
164
server-rs/crates/shared-contracts/src/story.rs
Normal file
@@ -0,0 +1,164 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BeginStorySessionRequest {
|
||||
pub runtime_session_id: String,
|
||||
pub world_profile_id: String,
|
||||
pub initial_prompt: String,
|
||||
#[serde(default)]
|
||||
pub opening_summary: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContinueStoryRequest {
|
||||
pub story_session_id: String,
|
||||
pub narrative_text: String,
|
||||
#[serde(default)]
|
||||
pub choice_function_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StorySessionPayload {
|
||||
pub story_session_id: String,
|
||||
pub runtime_session_id: String,
|
||||
pub actor_user_id: String,
|
||||
pub world_profile_id: String,
|
||||
pub initial_prompt: String,
|
||||
#[serde(default)]
|
||||
pub opening_summary: Option<String>,
|
||||
pub latest_narrative_text: String,
|
||||
#[serde(default)]
|
||||
pub latest_choice_function_id: Option<String>,
|
||||
pub status: String,
|
||||
pub version: u32,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StoryEventPayload {
|
||||
pub event_id: String,
|
||||
pub story_session_id: String,
|
||||
pub event_kind: String,
|
||||
pub narrative_text: String,
|
||||
#[serde(default)]
|
||||
pub choice_function_id: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StorySessionMutationResponse {
|
||||
pub story_session: StorySessionPayload,
|
||||
pub story_event: StoryEventPayload,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StorySessionStateResponse {
|
||||
pub story_session: StorySessionPayload,
|
||||
pub story_events: Vec<StoryEventPayload>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn continue_story_request_uses_camel_case_fields() {
|
||||
let payload = serde_json::to_value(ContinueStoryRequest {
|
||||
story_session_id: "storysess_1".to_string(),
|
||||
narrative_text: "继续前进".to_string(),
|
||||
choice_function_id: Some("npc_chat".to_string()),
|
||||
})
|
||||
.expect("payload should serialize");
|
||||
|
||||
assert_eq!(
|
||||
payload,
|
||||
json!({
|
||||
"storySessionId": "storysess_1",
|
||||
"narrativeText": "继续前进",
|
||||
"choiceFunctionId": "npc_chat"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn story_session_mutation_response_uses_camel_case_fields() {
|
||||
let payload = serde_json::to_value(StorySessionMutationResponse {
|
||||
story_session: StorySessionPayload {
|
||||
story_session_id: "storysess_1".to_string(),
|
||||
runtime_session_id: "runtime_1".to_string(),
|
||||
actor_user_id: "user_1".to_string(),
|
||||
world_profile_id: "profile_1".to_string(),
|
||||
initial_prompt: "进入营地".to_string(),
|
||||
opening_summary: Some("营地开场".to_string()),
|
||||
latest_narrative_text: "篝火正在燃烧。".to_string(),
|
||||
latest_choice_function_id: Some("talk".to_string()),
|
||||
status: "active".to_string(),
|
||||
version: 1,
|
||||
created_at: "1.000000Z".to_string(),
|
||||
updated_at: "1.000000Z".to_string(),
|
||||
},
|
||||
story_event: StoryEventPayload {
|
||||
event_id: "storyevt_1".to_string(),
|
||||
story_session_id: "storysess_1".to_string(),
|
||||
event_kind: "session_started".to_string(),
|
||||
narrative_text: "篝火正在燃烧。".to_string(),
|
||||
choice_function_id: Some("talk".to_string()),
|
||||
created_at: "1.000000Z".to_string(),
|
||||
},
|
||||
})
|
||||
.expect("payload should serialize");
|
||||
|
||||
assert_eq!(
|
||||
payload["storySession"]["storySessionId"],
|
||||
json!("storysess_1")
|
||||
);
|
||||
assert_eq!(payload["storyEvent"]["eventKind"], json!("session_started"));
|
||||
assert_eq!(payload["storyEvent"]["choiceFunctionId"], json!("talk"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn story_session_state_response_uses_camel_case_fields() {
|
||||
let payload = serde_json::to_value(StorySessionStateResponse {
|
||||
story_session: StorySessionPayload {
|
||||
story_session_id: "storysess_1".to_string(),
|
||||
runtime_session_id: "runtime_1".to_string(),
|
||||
actor_user_id: "user_1".to_string(),
|
||||
world_profile_id: "profile_1".to_string(),
|
||||
initial_prompt: "进入营地".to_string(),
|
||||
opening_summary: Some("营地开场".to_string()),
|
||||
latest_narrative_text: "你看见篝火边有人招手。".to_string(),
|
||||
latest_choice_function_id: Some("talk_to_npc".to_string()),
|
||||
status: "active".to_string(),
|
||||
version: 2,
|
||||
created_at: "1.000000Z".to_string(),
|
||||
updated_at: "2.000000Z".to_string(),
|
||||
},
|
||||
story_events: vec![StoryEventPayload {
|
||||
event_id: "storyevt_2".to_string(),
|
||||
story_session_id: "storysess_1".to_string(),
|
||||
event_kind: "story_continued".to_string(),
|
||||
narrative_text: "你看见篝火边有人招手。".to_string(),
|
||||
choice_function_id: Some("talk_to_npc".to_string()),
|
||||
created_at: "2.000000Z".to_string(),
|
||||
}],
|
||||
})
|
||||
.expect("payload should serialize");
|
||||
|
||||
assert_eq!(
|
||||
payload["storySession"]["latestChoiceFunctionId"],
|
||||
json!("talk_to_npc")
|
||||
);
|
||||
assert_eq!(
|
||||
payload["storyEvents"][0]["eventKind"],
|
||||
json!("story_continued")
|
||||
);
|
||||
}
|
||||
}
|
||||
11
server-rs/crates/shared-kernel/Cargo.toml
Normal file
11
server-rs/crates/shared-kernel/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "shared-kernel"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
time = { version = "0.3", features = ["formatting", "parsing"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
@@ -1,10 +1,10 @@
|
||||
# shared-kernel 共享 crate 占位说明
|
||||
# shared-kernel 共享 crate 阶段性说明
|
||||
|
||||
日期:`2026-04-20`
|
||||
日期:`2026-04-22`
|
||||
|
||||
## 1. crate 职责
|
||||
|
||||
`shared-kernel` 是跨模块共享领域内核 crate,后续负责:
|
||||
`shared-kernel` 是跨模块共享领域内核 crate,当前阶段已经开始承接最小共享基础能力,负责:
|
||||
|
||||
1. 共享 ID、值对象、枚举与基础领域类型
|
||||
2. 共享时间、状态、版本、通用校验等基础规则
|
||||
@@ -12,7 +12,13 @@
|
||||
|
||||
## 2. 当前阶段说明
|
||||
|
||||
当前提交仅完成目录占位,不提前进入具体共享类型与基础规则实现。
|
||||
当前阶段已落地的共享能力:
|
||||
|
||||
1. 必填/可选字符串归一化
|
||||
2. 字符串列表归一化
|
||||
3. 前缀 UUID / 前缀种子 ID 生成
|
||||
4. RFC3339 格式化与解析
|
||||
5. 微秒时间戳文本格式化
|
||||
|
||||
后续与本 crate 直接相关的任务包括:
|
||||
|
||||
@@ -21,8 +27,33 @@
|
||||
3. 抽取真正跨模块复用的最小领域规则
|
||||
4. 避免把模块私有规则错误上提到共享内核
|
||||
|
||||
当前已接入的 crate 已覆盖:
|
||||
|
||||
1. `module-assets`
|
||||
2. `module-auth`
|
||||
3. `platform-auth`
|
||||
4. `module-runtime`
|
||||
5. `module-story`
|
||||
6. `spacetime-client`
|
||||
7. `api-server`
|
||||
8. `module-ai`
|
||||
9. `module-inventory`
|
||||
10. `module-runtime-item`
|
||||
11. `module-npc`
|
||||
12. `module-quest`
|
||||
13. `module-combat`
|
||||
14. `module-progression`
|
||||
|
||||
## 3. 边界约束
|
||||
|
||||
1. `shared-kernel` 只放跨模块最小共享内核,不承接具体业务模块的私有规则。
|
||||
2. 任何进入本 crate 的类型都必须证明至少被多个模块稳定复用。
|
||||
3. 不能把主模块实现重新堆进共享内核,避免形成新的“大公共垃圾桶”。
|
||||
|
||||
更详细的阶段性设计见:
|
||||
|
||||
1. `docs/technical/RUST_SHARED_KERNEL_CRATE_STAGE1_DESIGN_2026-04-21.md`
|
||||
2. `docs/technical/RUST_SHARED_KERNEL_CRATE_STAGE2_ADOPTION_2026-04-21.md`
|
||||
3. `docs/technical/RUST_SHARED_KERNEL_CRATE_STAGE3_VALUE_NORMALIZATION_2026-04-22.md`
|
||||
4. `docs/technical/RUST_SHARED_KERNEL_CRATE_STAGE4_REQUIRED_STRING_ADOPTION_2026-04-22.md`
|
||||
5. `docs/technical/RUST_SHARED_KERNEL_CRATE_STAGE5_PURE_DOMAIN_FIELD_ADOPTION_2026-04-22.md`
|
||||
|
||||
138
server-rs/crates/shared-kernel/src/lib.rs
Normal file
138
server-rs/crates/shared-kernel/src/lib.rs
Normal file
@@ -0,0 +1,138 @@
|
||||
use time::OffsetDateTime;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use uuid::Uuid;
|
||||
|
||||
/// 统一做必填字符串归一化,避免各模块散落重复的 `trim().to_string()`。
|
||||
pub fn normalize_required_string(value: impl AsRef<str>) -> Option<String> {
|
||||
let normalized = value.as_ref().trim();
|
||||
if normalized.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(normalized.to_string())
|
||||
}
|
||||
|
||||
/// 统一做可选字符串归一化,空白字符串一律视为 `None`。
|
||||
pub fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||||
value.and_then(normalize_required_string)
|
||||
}
|
||||
|
||||
/// 统一做字符串列表归一化,逐项裁剪并丢弃空白项。
|
||||
pub fn normalize_string_list(values: Vec<String>) -> Vec<String> {
|
||||
values
|
||||
.into_iter()
|
||||
.filter_map(|value| normalize_required_string(value))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 统一生成“前缀 + 十六进制微秒种子”的稳定 ID,适合业务对象主键。
|
||||
pub fn build_prefixed_seed_id(prefix: &str, seed_micros: i64) -> String {
|
||||
format!("{prefix}{seed_micros:x}")
|
||||
}
|
||||
|
||||
/// 统一生成“前缀 + UUID simple”随机 ID,适合会话态或一次性票据主键。
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn build_prefixed_uuid_id(prefix: &str) -> String {
|
||||
format!("{prefix}{}", Uuid::new_v4().simple())
|
||||
}
|
||||
|
||||
/// SpacetimeDB 的 wasm32 模块不应走浏览器/本地随机 UUID 生成。
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn build_prefixed_uuid_id(_prefix: &str) -> String {
|
||||
panic!(
|
||||
"shared-kernel::build_prefixed_uuid_id 不支持 wasm32,请改用显式 ID 或 SpacetimeDB 上下文生成能力"
|
||||
)
|
||||
}
|
||||
|
||||
/// 统一生成 UUID simple 字符串,供 token、随机种子等轻量场景复用。
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn new_uuid_simple_string() -> String {
|
||||
Uuid::new_v4().simple().to_string()
|
||||
}
|
||||
|
||||
/// SpacetimeDB 的 wasm32 模块不应走浏览器/本地随机 UUID 生成。
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn new_uuid_simple_string() -> String {
|
||||
panic!(
|
||||
"shared-kernel::new_uuid_simple_string 不支持 wasm32,请改用显式 ID 或 SpacetimeDB 上下文生成能力"
|
||||
)
|
||||
}
|
||||
|
||||
/// 统一格式化微秒时间戳,当前阶段固定为 `seconds.microsZ` 文本口径。
|
||||
pub fn format_timestamp_micros(micros: i64) -> String {
|
||||
let seconds = micros.div_euclid(1_000_000);
|
||||
let subsec_micros = micros.rem_euclid(1_000_000);
|
||||
format!("{seconds}.{subsec_micros:06}Z")
|
||||
}
|
||||
|
||||
/// 统一格式化 RFC3339 字符串,避免每个模块自己拼格式化错误文案。
|
||||
pub fn format_rfc3339(value: OffsetDateTime) -> Result<String, String> {
|
||||
value
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// 统一解析 RFC3339 字符串,供模块自行补充更贴近业务的错误上下文。
|
||||
pub fn parse_rfc3339(value: &str) -> Result<OffsetDateTime, String> {
|
||||
OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalize_required_string_trims_and_filters_blank() {
|
||||
assert_eq!(
|
||||
normalize_required_string(" hero_001 "),
|
||||
Some("hero_001".to_string())
|
||||
);
|
||||
assert_eq!(normalize_required_string(" "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_optional_string_filters_blank() {
|
||||
assert_eq!(
|
||||
normalize_optional_string(Some(" profile_001 ".to_string())),
|
||||
Some("profile_001".to_string())
|
||||
);
|
||||
assert_eq!(normalize_optional_string(Some(" ".to_string())), None);
|
||||
assert_eq!(normalize_optional_string(None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_string_list_trims_and_filters_blank() {
|
||||
assert_eq!(
|
||||
normalize_string_list(vec![
|
||||
" alpha ".to_string(),
|
||||
"".to_string(),
|
||||
" ".to_string(),
|
||||
"beta".to_string()
|
||||
]),
|
||||
vec!["alpha".to_string(), "beta".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_prefixed_seed_id_uses_hex_seed() {
|
||||
assert_eq!(build_prefixed_seed_id("assetobj_", 255), "assetobj_ff");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_timestamp_micros_is_stable() {
|
||||
assert_eq!(
|
||||
format_timestamp_micros(1_713_686_401_234_567),
|
||||
"1713686401.234567Z"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_and_parse_rfc3339_round_trip() {
|
||||
let now = OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(1_713_686_400);
|
||||
let text = format_rfc3339(now).expect("rfc3339 should format");
|
||||
let parsed = parse_rfc3339(&text).expect("rfc3339 should parse");
|
||||
|
||||
assert_eq!(parsed.unix_timestamp(), now.unix_timestamp());
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,16 @@ version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
module-ai = { path = "../module-ai" }
|
||||
module-custom-world = { path = "../module-custom-world" }
|
||||
module-assets = { path = "../module-assets" }
|
||||
module-combat = { path = "../module-combat" }
|
||||
module-inventory = { path = "../module-inventory" }
|
||||
module-npc = { path = "../module-npc" }
|
||||
module-runtime = { path = "../module-runtime" }
|
||||
module-runtime-item = { path = "../module-runtime-item" }
|
||||
module-story = { path = "../module-story" }
|
||||
serde_json = "1"
|
||||
shared-kernel = { path = "../shared-kernel" }
|
||||
spacetimedb-sdk = "2.1.0"
|
||||
tokio = { version = "1", features = ["rt", "sync", "time"] }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,68 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
use super::quest_record_input_type::QuestRecordInput;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub(super) struct AcceptQuestArgs {
|
||||
pub input: QuestRecordInput,
|
||||
}
|
||||
|
||||
impl From<AcceptQuestArgs> for super::Reducer {
|
||||
fn from(args: AcceptQuestArgs) -> Self {
|
||||
Self::AcceptQuest { input: args.input }
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::InModule for AcceptQuestArgs {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the reducer `accept_quest`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteReducers`].
|
||||
pub trait accept_quest {
|
||||
/// Request that the remote module invoke the reducer `accept_quest` to run as soon as possible.
|
||||
///
|
||||
/// This method returns immediately, and errors only if we are unable to send the request.
|
||||
/// The reducer will run asynchronously in the future,
|
||||
/// and this method provides no way to listen for its completion status.
|
||||
/// /// Use [`accept_quest:accept_quest_then`] to run a callback after the reducer completes.
|
||||
fn accept_quest(&self, input: QuestRecordInput) -> __sdk::Result<()> {
|
||||
self.accept_quest_then(input, |_, _| {})
|
||||
}
|
||||
|
||||
/// Request that the remote module invoke the reducer `accept_quest` to run as soon as possible,
|
||||
/// registering `callback` to run when we are notified that the reducer completed.
|
||||
///
|
||||
/// This method returns immediately, and errors only if we are unable to send the request.
|
||||
/// The reducer will run asynchronously in the future,
|
||||
/// and its status can be observed with the `callback`.
|
||||
fn accept_quest_then(
|
||||
&self,
|
||||
input: QuestRecordInput,
|
||||
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()>;
|
||||
}
|
||||
|
||||
impl accept_quest for super::RemoteReducers {
|
||||
fn accept_quest_then(
|
||||
&self,
|
||||
input: QuestRecordInput,
|
||||
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()> {
|
||||
self.imp
|
||||
.invoke_reducer_with_callback(AcceptQuestArgs { input }, callback)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
use super::quest_completion_ack_input_type::QuestCompletionAckInput;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub(super) struct AcknowledgeQuestCompletionArgs {
|
||||
pub input: QuestCompletionAckInput,
|
||||
}
|
||||
|
||||
impl From<AcknowledgeQuestCompletionArgs> for super::Reducer {
|
||||
fn from(args: AcknowledgeQuestCompletionArgs) -> Self {
|
||||
Self::AcknowledgeQuestCompletion { input: args.input }
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::InModule for AcknowledgeQuestCompletionArgs {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the reducer `acknowledge_quest_completion`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteReducers`].
|
||||
pub trait acknowledge_quest_completion {
|
||||
/// Request that the remote module invoke the reducer `acknowledge_quest_completion` to run as soon as possible.
|
||||
///
|
||||
/// This method returns immediately, and errors only if we are unable to send the request.
|
||||
/// The reducer will run asynchronously in the future,
|
||||
/// and this method provides no way to listen for its completion status.
|
||||
/// /// Use [`acknowledge_quest_completion:acknowledge_quest_completion_then`] to run a callback after the reducer completes.
|
||||
fn acknowledge_quest_completion(&self, input: QuestCompletionAckInput) -> __sdk::Result<()> {
|
||||
self.acknowledge_quest_completion_then(input, |_, _| {})
|
||||
}
|
||||
|
||||
/// Request that the remote module invoke the reducer `acknowledge_quest_completion` to run as soon as possible,
|
||||
/// registering `callback` to run when we are notified that the reducer completed.
|
||||
///
|
||||
/// This method returns immediately, and errors only if we are unable to send the request.
|
||||
/// The reducer will run asynchronously in the future,
|
||||
/// and its status can be observed with the `callback`.
|
||||
fn acknowledge_quest_completion_then(
|
||||
&self,
|
||||
input: QuestCompletionAckInput,
|
||||
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()>;
|
||||
}
|
||||
|
||||
impl acknowledge_quest_completion for super::RemoteReducers {
|
||||
fn acknowledge_quest_completion_then(
|
||||
&self,
|
||||
input: QuestCompletionAckInput,
|
||||
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()> {
|
||||
self.imp
|
||||
.invoke_reducer_with_callback(AcknowledgeQuestCompletionArgs { input }, callback)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
use super::ai_result_reference_kind_type::AiResultReferenceKind;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct AiResultReferenceInput {
|
||||
pub task_id: String,
|
||||
pub reference_kind: AiResultReferenceKind,
|
||||
pub reference_id: String,
|
||||
pub label: Option<String>,
|
||||
pub created_at_micros: i64,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for AiResultReferenceInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
#[derive(Copy, Eq, Hash)]
|
||||
pub enum AiResultReferenceKind {
|
||||
StorySession,
|
||||
|
||||
StoryEvent,
|
||||
|
||||
CustomWorldProfile,
|
||||
|
||||
QuestRecord,
|
||||
|
||||
RuntimeItemRecord,
|
||||
|
||||
AssetObject,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for AiResultReferenceKind {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
use super::ai_result_reference_kind_type::AiResultReferenceKind;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct AiResultReferenceSnapshot {
|
||||
pub result_ref_id: String,
|
||||
pub task_id: String,
|
||||
pub reference_kind: AiResultReferenceKind,
|
||||
pub reference_id: String,
|
||||
pub label: Option<String>,
|
||||
pub created_at_micros: i64,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for AiResultReferenceSnapshot {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use super::ai_result_reference_kind_type::AiResultReferenceKind;
|
||||
use super::ai_result_reference_type::AiResultReference;
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
/// Table handle for the table `ai_result_reference`.
|
||||
///
|
||||
/// Obtain a handle from the [`AiResultReferenceTableAccess::ai_result_reference`] method on [`super::RemoteTables`],
|
||||
/// like `ctx.db.ai_result_reference()`.
|
||||
///
|
||||
/// Users are encouraged not to explicitly reference this type,
|
||||
/// but to directly chain method calls,
|
||||
/// like `ctx.db.ai_result_reference().on_insert(...)`.
|
||||
pub struct AiResultReferenceTableHandle<'ctx> {
|
||||
imp: __sdk::TableHandle<AiResultReference>,
|
||||
ctx: std::marker::PhantomData<&'ctx super::RemoteTables>,
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the table `ai_result_reference`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteTables`].
|
||||
pub trait AiResultReferenceTableAccess {
|
||||
#[allow(non_snake_case)]
|
||||
/// Obtain a [`AiResultReferenceTableHandle`], which mediates access to the table `ai_result_reference`.
|
||||
fn ai_result_reference(&self) -> AiResultReferenceTableHandle<'_>;
|
||||
}
|
||||
|
||||
impl AiResultReferenceTableAccess for super::RemoteTables {
|
||||
fn ai_result_reference(&self) -> AiResultReferenceTableHandle<'_> {
|
||||
AiResultReferenceTableHandle {
|
||||
imp: self
|
||||
.imp
|
||||
.get_table::<AiResultReference>("ai_result_reference"),
|
||||
ctx: std::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AiResultReferenceInsertCallbackId(__sdk::CallbackId);
|
||||
pub struct AiResultReferenceDeleteCallbackId(__sdk::CallbackId);
|
||||
|
||||
impl<'ctx> __sdk::Table for AiResultReferenceTableHandle<'ctx> {
|
||||
type Row = AiResultReference;
|
||||
type EventContext = super::EventContext;
|
||||
|
||||
fn count(&self) -> u64 {
|
||||
self.imp.count()
|
||||
}
|
||||
fn iter(&self) -> impl Iterator<Item = AiResultReference> + '_ {
|
||||
self.imp.iter()
|
||||
}
|
||||
|
||||
type InsertCallbackId = AiResultReferenceInsertCallbackId;
|
||||
|
||||
fn on_insert(
|
||||
&self,
|
||||
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
|
||||
) -> AiResultReferenceInsertCallbackId {
|
||||
AiResultReferenceInsertCallbackId(self.imp.on_insert(Box::new(callback)))
|
||||
}
|
||||
|
||||
fn remove_on_insert(&self, callback: AiResultReferenceInsertCallbackId) {
|
||||
self.imp.remove_on_insert(callback.0)
|
||||
}
|
||||
|
||||
type DeleteCallbackId = AiResultReferenceDeleteCallbackId;
|
||||
|
||||
fn on_delete(
|
||||
&self,
|
||||
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
|
||||
) -> AiResultReferenceDeleteCallbackId {
|
||||
AiResultReferenceDeleteCallbackId(self.imp.on_delete(Box::new(callback)))
|
||||
}
|
||||
|
||||
fn remove_on_delete(&self, callback: AiResultReferenceDeleteCallbackId) {
|
||||
self.imp.remove_on_delete(callback.0)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AiResultReferenceUpdateCallbackId(__sdk::CallbackId);
|
||||
|
||||
impl<'ctx> __sdk::TableWithPrimaryKey for AiResultReferenceTableHandle<'ctx> {
|
||||
type UpdateCallbackId = AiResultReferenceUpdateCallbackId;
|
||||
|
||||
fn on_update(
|
||||
&self,
|
||||
callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static,
|
||||
) -> AiResultReferenceUpdateCallbackId {
|
||||
AiResultReferenceUpdateCallbackId(self.imp.on_update(Box::new(callback)))
|
||||
}
|
||||
|
||||
fn remove_on_update(&self, callback: AiResultReferenceUpdateCallbackId) {
|
||||
self.imp.remove_on_update(callback.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Access to the `result_reference_row_id` unique index on the table `ai_result_reference`,
|
||||
/// which allows point queries on the field of the same name
|
||||
/// via the [`AiResultReferenceResultReferenceRowIdUnique::find`] method.
|
||||
///
|
||||
/// Users are encouraged not to explicitly reference this type,
|
||||
/// but to directly chain method calls,
|
||||
/// like `ctx.db.ai_result_reference().result_reference_row_id().find(...)`.
|
||||
pub struct AiResultReferenceResultReferenceRowIdUnique<'ctx> {
|
||||
imp: __sdk::UniqueConstraintHandle<AiResultReference, String>,
|
||||
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
|
||||
}
|
||||
|
||||
impl<'ctx> AiResultReferenceTableHandle<'ctx> {
|
||||
/// Get a handle on the `result_reference_row_id` unique index on the table `ai_result_reference`.
|
||||
pub fn result_reference_row_id(&self) -> AiResultReferenceResultReferenceRowIdUnique<'ctx> {
|
||||
AiResultReferenceResultReferenceRowIdUnique {
|
||||
imp: self
|
||||
.imp
|
||||
.get_unique_constraint::<String>("result_reference_row_id"),
|
||||
phantom: std::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'ctx> AiResultReferenceResultReferenceRowIdUnique<'ctx> {
|
||||
/// Find the subscribed row whose `result_reference_row_id` column value is equal to `col_val`,
|
||||
/// if such a row is present in the client cache.
|
||||
pub fn find(&self, col_val: &String) -> Option<AiResultReference> {
|
||||
self.imp.find(col_val)
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
|
||||
let _table = client_cache.get_or_make_table::<AiResultReference>("ai_result_reference");
|
||||
_table.add_unique_constraint::<String>("result_reference_row_id", |row| {
|
||||
&row.result_reference_row_id
|
||||
});
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub(super) fn parse_table_update(
|
||||
raw_updates: __ws::v2::TableUpdate,
|
||||
) -> __sdk::Result<__sdk::TableUpdate<AiResultReference>> {
|
||||
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
|
||||
__sdk::InternalError::failed_parse("TableUpdate<AiResultReference>", "TableUpdate")
|
||||
.with_cause(e)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for query builder access to the table `AiResultReference`.
|
||||
///
|
||||
/// Implemented for [`__sdk::QueryTableAccessor`].
|
||||
pub trait ai_result_referenceQueryTableAccess {
|
||||
#[allow(non_snake_case)]
|
||||
/// Get a query builder for the table `AiResultReference`.
|
||||
fn ai_result_reference(&self) -> __sdk::__query_builder::Table<AiResultReference>;
|
||||
}
|
||||
|
||||
impl ai_result_referenceQueryTableAccess for __sdk::QueryTableAccessor {
|
||||
fn ai_result_reference(&self) -> __sdk::__query_builder::Table<AiResultReference> {
|
||||
__sdk::__query_builder::Table::new("ai_result_reference")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
use super::ai_result_reference_kind_type::AiResultReferenceKind;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct AiResultReference {
|
||||
pub result_reference_row_id: String,
|
||||
pub result_ref_id: String,
|
||||
pub task_id: String,
|
||||
pub reference_kind: AiResultReferenceKind,
|
||||
pub reference_id: String,
|
||||
pub label: Option<String>,
|
||||
pub created_at: __sdk::Timestamp,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for AiResultReference {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
/// Column accessor struct for the table `AiResultReference`.
|
||||
///
|
||||
/// Provides typed access to columns for query building.
|
||||
pub struct AiResultReferenceCols {
|
||||
pub result_reference_row_id: __sdk::__query_builder::Col<AiResultReference, String>,
|
||||
pub result_ref_id: __sdk::__query_builder::Col<AiResultReference, String>,
|
||||
pub task_id: __sdk::__query_builder::Col<AiResultReference, String>,
|
||||
pub reference_kind: __sdk::__query_builder::Col<AiResultReference, AiResultReferenceKind>,
|
||||
pub reference_id: __sdk::__query_builder::Col<AiResultReference, String>,
|
||||
pub label: __sdk::__query_builder::Col<AiResultReference, Option<String>>,
|
||||
pub created_at: __sdk::__query_builder::Col<AiResultReference, __sdk::Timestamp>,
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::HasCols for AiResultReference {
|
||||
type Cols = AiResultReferenceCols;
|
||||
fn cols(table_name: &'static str) -> Self::Cols {
|
||||
AiResultReferenceCols {
|
||||
result_reference_row_id: __sdk::__query_builder::Col::new(
|
||||
table_name,
|
||||
"result_reference_row_id",
|
||||
),
|
||||
result_ref_id: __sdk::__query_builder::Col::new(table_name, "result_ref_id"),
|
||||
task_id: __sdk::__query_builder::Col::new(table_name, "task_id"),
|
||||
reference_kind: __sdk::__query_builder::Col::new(table_name, "reference_kind"),
|
||||
reference_id: __sdk::__query_builder::Col::new(table_name, "reference_id"),
|
||||
label: __sdk::__query_builder::Col::new(table_name, "label"),
|
||||
created_at: __sdk::__query_builder::Col::new(table_name, "created_at"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Indexed column accessor struct for the table `AiResultReference`.
|
||||
///
|
||||
/// Provides typed access to indexed columns for query building.
|
||||
pub struct AiResultReferenceIxCols {
|
||||
pub result_reference_row_id: __sdk::__query_builder::IxCol<AiResultReference, String>,
|
||||
pub task_id: __sdk::__query_builder::IxCol<AiResultReference, String>,
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::HasIxCols for AiResultReference {
|
||||
type IxCols = AiResultReferenceIxCols;
|
||||
fn ix_cols(table_name: &'static str) -> Self::IxCols {
|
||||
AiResultReferenceIxCols {
|
||||
result_reference_row_id: __sdk::__query_builder::IxCol::new(
|
||||
table_name,
|
||||
"result_reference_row_id",
|
||||
),
|
||||
task_id: __sdk::__query_builder::IxCol::new(table_name, "task_id"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::CanBeLookupTable for AiResultReference {}
|
||||
@@ -0,0 +1,22 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
use super::ai_task_stage_kind_type::AiTaskStageKind;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct AiStageCompletionInput {
|
||||
pub task_id: String,
|
||||
pub stage_kind: AiTaskStageKind,
|
||||
pub text_output: Option<String>,
|
||||
pub structured_payload_json: Option<String>,
|
||||
pub warning_messages: Vec<String>,
|
||||
pub completed_at_micros: i64,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for AiStageCompletionInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct AiTaskCancelInput {
|
||||
pub task_id: String,
|
||||
pub completed_at_micros: i64,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for AiTaskCancelInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
use super::ai_task_kind_type::AiTaskKind;
|
||||
use super::ai_task_stage_blueprint_type::AiTaskStageBlueprint;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct AiTaskCreateInput {
|
||||
pub task_id: String,
|
||||
pub task_kind: AiTaskKind,
|
||||
pub owner_user_id: String,
|
||||
pub request_label: String,
|
||||
pub source_module: String,
|
||||
pub source_entity_id: Option<String>,
|
||||
pub request_payload_json: Option<String>,
|
||||
pub stages: Vec<AiTaskStageBlueprint>,
|
||||
pub created_at_micros: i64,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for AiTaskCreateInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct AiTaskFailureInput {
|
||||
pub task_id: String,
|
||||
pub failure_message: String,
|
||||
pub completed_at_micros: i64,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for AiTaskFailureInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct AiTaskFinishInput {
|
||||
pub task_id: String,
|
||||
pub completed_at_micros: i64,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for AiTaskFinishInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
#[derive(Copy, Eq, Hash)]
|
||||
pub enum AiTaskKind {
|
||||
StoryGeneration,
|
||||
|
||||
CharacterChat,
|
||||
|
||||
NpcChat,
|
||||
|
||||
CustomWorldGeneration,
|
||||
|
||||
QuestIntent,
|
||||
|
||||
RuntimeItemIntent,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for AiTaskKind {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
use super::ai_task_snapshot_type::AiTaskSnapshot;
|
||||
use super::ai_text_chunk_snapshot_type::AiTextChunkSnapshot;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct AiTaskProcedureResult {
|
||||
pub ok: bool,
|
||||
pub task: Option<AiTaskSnapshot>,
|
||||
pub text_chunk: Option<AiTextChunkSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for AiTaskProcedureResult {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user