后端重写提交

This commit is contained in:
2026-04-22 12:34:49 +08:00
parent cf8da3f50f
commit 997a8daada
438 changed files with 53355 additions and 865 deletions

184
server-rs/Cargo.lock generated
View File

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

View File

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

View File

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

View File

@@ -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 的完整能力。

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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", "上游服务请求失败"),

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

View File

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

View File

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

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

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

View File

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

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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` 里的战斗真相规则层。

View 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, &current.target_name, outcome),
)
} else {
damage_taken = compute_counter_damage(
current.battle_mode,
current.target_max_hp,
input.counter_multiplier_basis_points,
);
next_player_hp = clamp_hp(
current.battle_mode,
next_player_hp - damage_taken,
current.player_max_hp,
);
(
BattleStatus::Ongoing,
CombatOutcome::Ongoing,
build_ongoing_result_text(&input.function_id, &action_text, &current.target_name),
)
};
let next = BattleStateSnapshot {
player_hp: next_player_hp,
player_mana: next_player_mana,
target_hp: next_target_hp,
status,
turn_index: current.turn_index + 1,
last_action_function_id: Some(input.function_id),
last_action_text: Some(action_text),
last_result_text: Some(result_text),
last_damage_dealt: damage_dealt,
last_damage_taken: damage_taken,
last_outcome: outcome,
version: current.version + 1,
updated_at_micros: input.updated_at_micros,
..current
};
Ok(ResolveCombatActionResult {
snapshot: next,
damage_dealt,
damage_taken,
outcome,
})
}
pub fn generate_battle_state_id(seed_micros: i64) -> String {
build_prefixed_seed_id(BATTLE_STATE_ID_PREFIX, seed_micros)
}
pub fn is_supported_combat_function_id(function_id: &str) -> bool {
matches!(
function_id,
"battle_attack_basic"
| "battle_recover_breath"
| "battle_use_skill"
| "battle_escape_breakout"
) || LEGACY_ATTACK_FUNCTION_IDS.contains(&function_id)
}
fn clamp_hp(mode: BattleMode, value: i32, max_hp: i32) -> i32 {
let min_hp = match mode {
BattleMode::Fight => 0,
BattleMode::Spar => SPAR_MIN_HP,
};
value.clamp(min_hp, max_hp)
}
fn clamp_mana(value: i32, max_mana: i32) -> i32 {
value.clamp(0, max_mana)
}
fn clamp_target_hp_after_damage(mode: BattleMode, current_hp: i32, damage: i32) -> i32 {
match mode {
BattleMode::Fight => (current_hp - damage).max(0),
BattleMode::Spar => (current_hp - damage).max(SPAR_MIN_HP),
}
}
fn is_target_resolved(mode: BattleMode, target_hp: i32) -> bool {
match mode {
BattleMode::Fight => target_hp <= 0,
BattleMode::Spar => target_hp <= SPAR_MIN_HP,
}
}
fn compute_counter_damage(
mode: BattleMode,
target_max_hp: i32,
counter_multiplier_basis_points: u32,
) -> i32 {
match mode {
BattleMode::Spar => 1,
BattleMode::Fight => {
let multiplier = counter_multiplier_basis_points as f32 / 10_000.0;
let raw =
(target_max_hp as f32 * BASIC_FIGHT_COUNTER_RATIO * multiplier).round() as i32;
raw.max(MIN_FIGHT_COUNTER_DAMAGE)
}
}
}
fn build_resolved_result_text(
action_text: &str,
target_name: &str,
outcome: CombatOutcome,
) -> String {
match outcome {
CombatOutcome::Victory => {
format!(
"{}命中了{},这轮战斗已经正式结束。",
action_text, target_name
)
}
CombatOutcome::SparComplete => {
format!(
"{}压住了{}的节奏,这场切磋已经分出高下。",
action_text, target_name
)
}
CombatOutcome::Escaped => {
format!("{}后你成功脱离了当前战斗。", action_text)
}
CombatOutcome::Ongoing => format!("{}已完成结算。", action_text),
}
}
fn build_ongoing_result_text(function_id: &str, action_text: &str, target_name: &str) -> String {
match function_id {
"battle_recover_breath" => {
format!(
"你先把伤势和气息稳住了一轮,但{}仍在持续逼近。",
target_name
)
}
"battle_use_skill" => {
format!(
"{}命中了{},这一轮技能效果已经直接结算。",
action_text, target_name
)
}
_ => format!(
"{}命中了{},本次攻击已经完成结算。",
action_text, target_name
),
}
}
fn map_reward_item_field_error(error: TreasureFieldError) -> CombatFieldError {
let message = match error {
TreasureFieldError::MissingRewardItemId => {
"battle_state.reward_items[].item_id 不能为空".to_string()
}
TreasureFieldError::MissingRewardItemCategory => {
"battle_state.reward_items[].category 不能为空".to_string()
}
TreasureFieldError::MissingRewardItemName => {
"battle_state.reward_items[].item_name 不能为空".to_string()
}
TreasureFieldError::InvalidRewardItemQuantity => {
"battle_state.reward_items[].quantity 必须大于 0".to_string()
}
TreasureFieldError::MissingRewardItemStackKey => {
"battle_state.reward_items[].stack_key 不能为空".to_string()
}
TreasureFieldError::RewardEquipmentItemCannotStack => {
"battle_state.reward_items[] 可装备物品不能标记为 stackable".to_string()
}
TreasureFieldError::RewardNonStackableItemMustStaySingleQuantity => {
"battle_state.reward_items[] 不可堆叠物品必须固定为单槽位单数量".to_string()
}
other => other.to_string(),
};
CombatFieldError::InvalidRewardItem(message)
}
impl fmt::Display for CombatFieldError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingBattleStateId => f.write_str("battle_state.battle_state_id 不能为空"),
Self::MissingStorySessionId => f.write_str("battle_state.story_session_id 不能为空"),
Self::MissingRuntimeSessionId => {
f.write_str("battle_state.runtime_session_id 不能为空")
}
Self::MissingActorUserId => f.write_str("battle_state.actor_user_id 不能为空"),
Self::MissingTargetNpcId => f.write_str("battle_state.target_npc_id 不能为空"),
Self::MissingTargetName => f.write_str("battle_state.target_name 不能为空"),
Self::MissingFunctionId => f.write_str("resolve_combat_action.function_id 不能为空"),
Self::InvalidVersion => f.write_str("battle_state.version 必须大于 0"),
Self::InvalidPlayerVitals => f.write_str("battle_state 玩家生命或灵力字段不合法"),
Self::InvalidTargetVitals => f.write_str("battle_state 目标生命字段不合法"),
Self::InvalidRewardItem(message) => f.write_str(message),
Self::BattleAlreadyResolved => f.write_str("battle_state 已经结束,不能继续结算"),
Self::UnsupportedFunctionId => {
f.write_str("resolve_combat_action.function_id 当前不受支持")
}
Self::InsufficientMana => f.write_str("当前灵力不足,无法执行该战斗动作"),
}
}
}
impl Error for CombatFieldError {}
#[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);
}
}

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

View File

@@ -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` 的槽位规则稳定后再接。

File diff suppressed because it is too large Load Diff

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

View File

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

File diff suppressed because it is too large Load Diff

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

View File

@@ -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 内部结算,继续通过其他模块协作完成。

View 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);
}
}

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

View File

@@ -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 有冲突,必须先校正文档和领域规则,再继续接线。

View 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));
}
}

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

View File

@@ -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` 为准。

File diff suppressed because it is too large Load Diff

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

View File

@@ -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` 为准

View 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");
}
}

View 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"] }

View 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"
);
}
}

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

View 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);
}
}

View File

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

View File

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

View 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"] }

View File

@@ -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`,不能复制一份私有实现。

File diff suppressed because it is too large Load Diff

View 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"

View File

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

View 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!(""));
}
}

View 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())
);
}
}

View 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));
}
}

View 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"
})
);
}
}

View 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;

View 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"));
}
}

View 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")
);
}
}

View 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"));
}
}

View 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")
);
}
}

View 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"] }

View File

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

View 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());
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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