1151 lines
41 KiB
Rust
1151 lines
41 KiB
Rust
use axum::{
|
||
Json,
|
||
extract::{Extension, Path, State},
|
||
http::StatusCode,
|
||
response::Response,
|
||
};
|
||
use module_runtime::RuntimeSnapshotRecord;
|
||
use serde_json::{Value, json};
|
||
use shared_contracts::story::{
|
||
BeginStoryRuntimeSessionRequest, BeginStorySessionRequest, ContinueStoryRequest,
|
||
ResolveStoryRuntimeActionRequest, StoryEventPayload, StoryRuntimeMutationResponse,
|
||
StoryRuntimeSnapshotPayload, StorySessionMutationResponse, StorySessionPayload,
|
||
StorySessionStateResponse,
|
||
};
|
||
use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339};
|
||
use spacetime_client::SpacetimeClientError;
|
||
use time::OffsetDateTime;
|
||
|
||
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: story_session_payload_from_record(result.session),
|
||
story_event: story_event_payload_from_record(result.event),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn begin_story_runtime_session(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
Json(payload): Json<BeginStoryRuntimeSessionRequest>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let now_micros = current_utc_micros();
|
||
let actor_user_id = authenticated.claims().user_id().to_string();
|
||
let runtime_session_id = module_runtime_story::generate_runtime_session_id(
|
||
actor_user_id.as_str(),
|
||
payload.custom_world_profile.as_ref(),
|
||
&payload.character,
|
||
now_micros,
|
||
);
|
||
let story_session_id = module_story::generate_story_session_id(now_micros);
|
||
let built = module_runtime_story::build_runtime_story_bootstrap(
|
||
&payload,
|
||
module_runtime_story::RuntimeStoryBootstrapSeed {
|
||
runtime_session_id,
|
||
story_session_id,
|
||
actor_user_id: actor_user_id.clone(),
|
||
now_micros,
|
||
},
|
||
)
|
||
.map_err(|message| {
|
||
story_sessions_error_response(
|
||
&request_context,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "story-runtime",
|
||
"message": message,
|
||
})),
|
||
)
|
||
})?;
|
||
|
||
let story_result = state
|
||
.spacetime_client()
|
||
.begin_story_session(
|
||
built.story_session_id.clone(),
|
||
built.runtime_session_id.clone(),
|
||
actor_user_id.clone(),
|
||
built.world_profile_id,
|
||
built.initial_prompt,
|
||
built.opening_summary,
|
||
now_micros,
|
||
)
|
||
.await
|
||
.map_err(|error| {
|
||
story_sessions_error_response(&request_context, map_story_session_client_error(error))
|
||
})?;
|
||
|
||
let persisted =
|
||
persist_story_runtime_snapshot(&state, &request_context, actor_user_id, built.snapshot)
|
||
.await?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
StoryRuntimeMutationResponse {
|
||
projection: build_story_runtime_projection_from_persisted(
|
||
story_session_payload_from_record(story_result.session),
|
||
vec![story_event_payload_from_record(story_result.event)],
|
||
&persisted,
|
||
None,
|
||
),
|
||
},
|
||
))
|
||
}
|
||
|
||
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 actor_user_id = authenticated.claims().user_id().to_string();
|
||
let story_session_id = payload.story_session_id;
|
||
let current_state = state
|
||
.spacetime_client()
|
||
.get_story_session_state(story_session_id.clone())
|
||
.await
|
||
.map_err(|error| {
|
||
story_sessions_error_response(&request_context, map_story_session_client_error(error))
|
||
})?;
|
||
require_story_session_owner(
|
||
&request_context,
|
||
¤t_state.session.actor_user_id,
|
||
&actor_user_id,
|
||
)?;
|
||
|
||
let result = state
|
||
.spacetime_client()
|
||
.continue_story(
|
||
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: story_session_payload_from_record(result.session),
|
||
story_event: story_event_payload_from_record(result.event),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn resolve_story_runtime_action(
|
||
State(state): State<AppState>,
|
||
Path(story_session_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
Json(payload): Json<ResolveStoryRuntimeActionRequest>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let now_micros = current_utc_micros();
|
||
let actor_user_id = authenticated.claims().user_id().to_string();
|
||
let story_session_id = validate_story_runtime_action_path(
|
||
&request_context,
|
||
story_session_id,
|
||
payload.story_session_id.as_str(),
|
||
)?;
|
||
let story_state = state
|
||
.spacetime_client()
|
||
.get_story_session_state(story_session_id.clone())
|
||
.await
|
||
.map_err(|error| {
|
||
story_sessions_error_response(&request_context, map_story_session_client_error(error))
|
||
})?;
|
||
require_story_session_owner(
|
||
&request_context,
|
||
&story_state.session.actor_user_id,
|
||
&actor_user_id,
|
||
)?;
|
||
|
||
let snapshot_record = state
|
||
.get_runtime_snapshot_record(actor_user_id.clone())
|
||
.await
|
||
.map_err(|error| {
|
||
story_sessions_error_response(&request_context, map_story_session_client_error(error))
|
||
})?
|
||
.ok_or_else(|| {
|
||
story_sessions_error_response(
|
||
&request_context,
|
||
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
|
||
"provider": "story-runtime",
|
||
"message": "当前用户缺少 runtime snapshot",
|
||
})),
|
||
)
|
||
})?;
|
||
let snapshot = story_runtime_snapshot_payload_from_record(&snapshot_record);
|
||
validate_story_runtime_client_version(
|
||
&request_context,
|
||
payload.client_version,
|
||
&snapshot.game_state,
|
||
)?;
|
||
|
||
let resolved = module_runtime_story::resolve_story_runtime_action(
|
||
module_runtime_story::StoryRuntimeActionResolveInput {
|
||
story_session_id: story_state.session.story_session_id.clone(),
|
||
runtime_session_id: story_state.session.runtime_session_id.clone(),
|
||
snapshot,
|
||
request: payload,
|
||
},
|
||
)
|
||
.map_err(|message| {
|
||
story_sessions_error_response(
|
||
&request_context,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "story-runtime",
|
||
"message": message,
|
||
})),
|
||
)
|
||
})?;
|
||
|
||
let story_result = state
|
||
.spacetime_client()
|
||
.continue_story(
|
||
story_state.session.story_session_id,
|
||
module_story::generate_story_event_id(now_micros),
|
||
resolved.narrative_text,
|
||
resolved.choice_function_id,
|
||
now_micros,
|
||
)
|
||
.await
|
||
.map_err(|error| {
|
||
story_sessions_error_response(&request_context, map_story_session_client_error(error))
|
||
})?;
|
||
let persisted =
|
||
persist_story_runtime_snapshot(&state, &request_context, actor_user_id, resolved.snapshot)
|
||
.await?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
StoryRuntimeMutationResponse {
|
||
projection: build_story_runtime_projection_from_persisted(
|
||
story_session_payload_from_record(story_result.session),
|
||
vec![story_event_payload_from_record(story_result.event)],
|
||
&persisted,
|
||
Some(resolved.server_version),
|
||
),
|
||
},
|
||
))
|
||
}
|
||
|
||
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 actor_user_id = authenticated.claims().user_id().to_string();
|
||
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))
|
||
})?;
|
||
require_story_session_owner(
|
||
&request_context,
|
||
&result.session.actor_user_id,
|
||
&actor_user_id,
|
||
)?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
StorySessionStateResponse {
|
||
story_session: story_session_payload_from_record(result.session),
|
||
story_events: result
|
||
.events
|
||
.into_iter()
|
||
.map(story_event_payload_from_record)
|
||
.collect(),
|
||
},
|
||
))
|
||
}
|
||
|
||
async fn persist_story_runtime_snapshot(
|
||
state: &AppState,
|
||
request_context: &RequestContext,
|
||
user_id: String,
|
||
snapshot: StoryRuntimeSnapshotPayload,
|
||
) -> Result<RuntimeSnapshotRecord, Response> {
|
||
validate_story_runtime_snapshot_payload(&snapshot).map_err(|message| {
|
||
story_sessions_error_response(
|
||
request_context,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "story-runtime",
|
||
"message": message,
|
||
})),
|
||
)
|
||
})?;
|
||
|
||
let now = OffsetDateTime::now_utc();
|
||
let saved_at = snapshot
|
||
.saved_at
|
||
.as_deref()
|
||
.and_then(module_runtime_story::normalize_required_string)
|
||
.map(|value| parse_rfc3339(value.as_str()))
|
||
.transpose()
|
||
.map_err(|error| {
|
||
story_sessions_error_response(
|
||
request_context,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "story-runtime",
|
||
"field": "snapshot.savedAt",
|
||
"message": format!("savedAt 非法: {error}"),
|
||
})),
|
||
)
|
||
})?
|
||
.unwrap_or(now);
|
||
let saved_at_micros = offset_datetime_to_unix_micros(saved_at);
|
||
let updated_at_micros = offset_datetime_to_unix_micros(now);
|
||
let game_state = canonicalize_story_runtime_game_state(snapshot.game_state);
|
||
|
||
state
|
||
.put_runtime_snapshot_record(
|
||
user_id,
|
||
saved_at_micros,
|
||
snapshot.bottom_tab,
|
||
game_state,
|
||
snapshot.current_story,
|
||
updated_at_micros,
|
||
)
|
||
.await
|
||
.map_err(|error| {
|
||
story_sessions_error_response(request_context, map_story_session_client_error(error))
|
||
})
|
||
}
|
||
|
||
fn canonicalize_story_runtime_game_state(mut game_state: Value) -> Value {
|
||
if let Some(root) = game_state.as_object_mut() {
|
||
// 中文注释:NPC 交易 / 赠礼 view 是展示层投影,持久快照只保存可复算真相。
|
||
root.remove("runtimeNpcInteraction");
|
||
}
|
||
game_state
|
||
}
|
||
|
||
fn validate_story_runtime_snapshot_payload(
|
||
snapshot: &StoryRuntimeSnapshotPayload,
|
||
) -> Result<(), String> {
|
||
if module_runtime_story::normalize_required_string(snapshot.bottom_tab.as_str()).is_none() {
|
||
return Err("snapshot.bottomTab 不能为空".to_string());
|
||
}
|
||
if !snapshot.game_state.is_object() {
|
||
return Err("snapshot.gameState 必须是 JSON object".to_string());
|
||
}
|
||
if snapshot
|
||
.current_story
|
||
.as_ref()
|
||
.is_some_and(|current_story| !current_story.is_object())
|
||
{
|
||
return Err("snapshot.currentStory 必须是 JSON object 或 null".to_string());
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
fn story_runtime_snapshot_payload_from_record(
|
||
record: &RuntimeSnapshotRecord,
|
||
) -> StoryRuntimeSnapshotPayload {
|
||
let mut game_state = record.game_state.clone();
|
||
module_runtime_story::write_runtime_npc_interaction_view(&mut game_state);
|
||
|
||
StoryRuntimeSnapshotPayload {
|
||
saved_at: Some(record.saved_at.clone()),
|
||
bottom_tab: record.bottom_tab.clone(),
|
||
game_state,
|
||
current_story: record.current_story.clone(),
|
||
}
|
||
}
|
||
|
||
fn build_story_runtime_projection_from_persisted(
|
||
story_session: StorySessionPayload,
|
||
story_events: Vec<StoryEventPayload>,
|
||
record: &RuntimeSnapshotRecord,
|
||
resolved_version: Option<u32>,
|
||
) -> shared_contracts::story::StoryRuntimeProjectionResponse {
|
||
let snapshot = story_runtime_snapshot_payload_from_record(record);
|
||
let current_story = snapshot.current_story.as_ref();
|
||
let options =
|
||
module_runtime_story::build_runtime_story_options(current_story, &snapshot.game_state);
|
||
let current_narrative_text = read_story_runtime_current_text(current_story)
|
||
.or_else(|| Some(story_session.latest_narrative_text.clone()));
|
||
let action_result_text = read_story_runtime_current_field(current_story, "resultText");
|
||
let toast = read_story_runtime_current_field(current_story, "toast");
|
||
let server_version =
|
||
resolve_story_runtime_projection_version(&snapshot.game_state, resolved_version);
|
||
|
||
module_runtime_story::build_story_runtime_projection(
|
||
module_runtime_story::StoryRuntimeProjectionSource {
|
||
story_session,
|
||
story_events,
|
||
game_state: snapshot.game_state,
|
||
options,
|
||
server_version,
|
||
current_narrative_text,
|
||
action_result_text,
|
||
toast,
|
||
},
|
||
)
|
||
}
|
||
|
||
fn resolve_story_runtime_projection_version(
|
||
game_state: &Value,
|
||
resolved_version: Option<u32>,
|
||
) -> u32 {
|
||
module_runtime_story::read_u32_field(game_state, "runtimeActionVersion")
|
||
.or(resolved_version)
|
||
.unwrap_or(1)
|
||
}
|
||
|
||
fn read_story_runtime_current_text(current_story: Option<&Value>) -> Option<String> {
|
||
read_story_runtime_current_field(current_story, "text")
|
||
.or_else(|| read_story_runtime_current_field(current_story, "storyText"))
|
||
}
|
||
|
||
fn read_story_runtime_current_field(current_story: Option<&Value>, field: &str) -> Option<String> {
|
||
current_story?
|
||
.as_object()?
|
||
.get(field)?
|
||
.as_str()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(ToOwned::to_owned)
|
||
}
|
||
|
||
fn validate_story_runtime_action_path(
|
||
request_context: &RequestContext,
|
||
path_story_session_id: String,
|
||
body_story_session_id: &str,
|
||
) -> Result<String, Response> {
|
||
let path_story_session_id =
|
||
module_runtime_story::normalize_required_string(path_story_session_id.as_str())
|
||
.ok_or_else(|| {
|
||
story_sessions_error_response(
|
||
request_context,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "story-runtime",
|
||
"message": "storySessionId 不能为空",
|
||
})),
|
||
)
|
||
})?;
|
||
let body_story_session_id = module_runtime_story::normalize_required_string(
|
||
body_story_session_id,
|
||
)
|
||
.ok_or_else(|| {
|
||
story_sessions_error_response(
|
||
request_context,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "story-runtime",
|
||
"message": "request.storySessionId 不能为空",
|
||
})),
|
||
)
|
||
})?;
|
||
|
||
if path_story_session_id != body_story_session_id {
|
||
return Err(story_sessions_error_response(
|
||
request_context,
|
||
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
|
||
"provider": "story-runtime",
|
||
"message": "path storySessionId 与 request.storySessionId 不一致",
|
||
"pathStorySessionId": path_story_session_id,
|
||
"requestStorySessionId": body_story_session_id,
|
||
})),
|
||
));
|
||
}
|
||
|
||
Ok(path_story_session_id)
|
||
}
|
||
|
||
fn validate_story_runtime_client_version(
|
||
request_context: &RequestContext,
|
||
client_version: Option<u32>,
|
||
game_state: &Value,
|
||
) -> Result<(), Response> {
|
||
let Some(client_version) = client_version else {
|
||
return Ok(());
|
||
};
|
||
let Some(server_version) =
|
||
module_runtime_story::read_u32_field(game_state, "runtimeActionVersion")
|
||
else {
|
||
return Ok(());
|
||
};
|
||
if client_version == server_version {
|
||
return Ok(());
|
||
}
|
||
|
||
Err(story_sessions_error_response(
|
||
request_context,
|
||
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
|
||
"provider": "story-runtime",
|
||
"message": "运行时版本已变化,请先同步最新快照后再提交动作",
|
||
"clientVersion": client_version,
|
||
"serverVersion": server_version,
|
||
})),
|
||
))
|
||
}
|
||
|
||
fn story_session_payload_from_record(
|
||
record: module_story::StorySessionRecord,
|
||
) -> StorySessionPayload {
|
||
StorySessionPayload {
|
||
story_session_id: record.story_session_id,
|
||
runtime_session_id: record.runtime_session_id,
|
||
actor_user_id: record.actor_user_id,
|
||
world_profile_id: record.world_profile_id,
|
||
initial_prompt: record.initial_prompt,
|
||
opening_summary: record.opening_summary,
|
||
latest_narrative_text: record.latest_narrative_text,
|
||
latest_choice_function_id: record.latest_choice_function_id,
|
||
status: record.status,
|
||
version: record.version,
|
||
created_at: record.created_at,
|
||
updated_at: record.updated_at,
|
||
}
|
||
}
|
||
|
||
fn story_event_payload_from_record(record: module_story::StoryEventRecord) -> StoryEventPayload {
|
||
StoryEventPayload {
|
||
event_id: record.event_id,
|
||
story_session_id: record.story_session_id,
|
||
event_kind: record.event_kind,
|
||
narrative_text: record.narrative_text,
|
||
choice_function_id: record.choice_function_id,
|
||
created_at: record.created_at,
|
||
}
|
||
}
|
||
|
||
pub async fn get_story_runtime_projection(
|
||
State(state): State<AppState>,
|
||
Path(story_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 source = state
|
||
.spacetime_client()
|
||
.get_story_runtime_projection_source(story_session_id, actor_user_id)
|
||
.await
|
||
.map_err(|error| {
|
||
story_sessions_error_response(&request_context, map_story_session_client_error(error))
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
module_runtime_story::build_story_runtime_projection(source),
|
||
))
|
||
}
|
||
|
||
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 require_story_session_owner(
|
||
request_context: &RequestContext,
|
||
resource_actor_user_id: &str,
|
||
authenticated_actor_user_id: &str,
|
||
) -> Result<(), Response> {
|
||
if resource_actor_user_id == authenticated_actor_user_id {
|
||
return Ok(());
|
||
}
|
||
|
||
// 这里只做 HTTP 鉴权边界判断,story session 的推进规则仍由 SpacetimeDB 领域层处理。
|
||
Err(story_sessions_error_response(
|
||
request_context,
|
||
AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({
|
||
"provider": "story-session",
|
||
"message": "story session 不属于当前用户",
|
||
})),
|
||
))
|
||
}
|
||
|
||
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 std::time::Duration;
|
||
|
||
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 super::{build_story_runtime_projection_from_persisted, require_story_session_owner};
|
||
use crate::{
|
||
app::build_router, config::AppConfig, request_context::RequestContext, state::AppState,
|
||
};
|
||
use module_runtime::RuntimeSnapshotRecord;
|
||
use shared_contracts::story::StorySessionPayload;
|
||
|
||
#[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_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/continue")
|
||
.header("content-type", "application/json")
|
||
.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::UNAUTHORIZED);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn begin_story_runtime_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/runtime")
|
||
.header("content-type", "application/json")
|
||
.body(Body::from(
|
||
json!({
|
||
"worldType": "CUSTOM",
|
||
"customWorldProfile": { "id": "profile_001" },
|
||
"character": { "id": "hero_001", "name": "沈砺" },
|
||
"runtimeMode": "play",
|
||
"disablePersistence": false
|
||
})
|
||
.to_string(),
|
||
))
|
||
.expect("request should build"),
|
||
)
|
||
.await
|
||
.expect("request should succeed");
|
||
|
||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn begin_story_runtime_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/runtime")
|
||
.header("authorization", format!("Bearer {token}"))
|
||
.header("content-type", "application/json")
|
||
.header("x-genarrative-response-envelope", "v1")
|
||
.body(Body::from(
|
||
json!({
|
||
"worldType": "CUSTOM",
|
||
"customWorldProfile": { "id": "profile_001" },
|
||
"character": { "id": "hero_001", "name": "沈砺" },
|
||
"runtimeMode": "play",
|
||
"disablePersistence": false
|
||
})
|
||
.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 resolve_story_runtime_action_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/storysess_001/actions/resolve")
|
||
.header("content-type", "application/json")
|
||
.body(Body::from(
|
||
json!({
|
||
"storySessionId": "storysess_001",
|
||
"clientVersion": 1,
|
||
"functionId": "idle_observe_signs",
|
||
"actionText": "观察周围迹象",
|
||
"payload": { "optionText": "观察周围迹象" }
|
||
})
|
||
.to_string(),
|
||
))
|
||
.expect("request should build"),
|
||
)
|
||
.await
|
||
.expect("request should succeed");
|
||
|
||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn resolve_story_runtime_action_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/storysess_001/actions/resolve")
|
||
.header("authorization", format!("Bearer {token}"))
|
||
.header("content-type", "application/json")
|
||
.header("x-genarrative-response-envelope", "v1")
|
||
.body(Body::from(
|
||
json!({
|
||
"storySessionId": "storysess_001",
|
||
"clientVersion": 1,
|
||
"functionId": "idle_observe_signs",
|
||
"actionText": "观察周围迹象",
|
||
"payload": { "optionText": "观察周围迹象" }
|
||
})
|
||
.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())
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn get_story_runtime_projection_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/runtime-projection")
|
||
.body(Body::empty())
|
||
.expect("request should build"),
|
||
)
|
||
.await
|
||
.expect("request should succeed");
|
||
|
||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn get_story_runtime_projection_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/runtime-projection")
|
||
.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())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn story_runtime_projection_version_prefers_runtime_action_version() {
|
||
let projection = build_story_runtime_projection_from_persisted(
|
||
StorySessionPayload {
|
||
story_session_id: "storysess_001".to_string(),
|
||
runtime_session_id: "runtime_001".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("npc_chat".to_string()),
|
||
status: "active".to_string(),
|
||
version: 9,
|
||
created_at: "1.000000Z".to_string(),
|
||
updated_at: "3.000000Z".to_string(),
|
||
},
|
||
vec![],
|
||
&RuntimeSnapshotRecord {
|
||
user_id: "user_1".to_string(),
|
||
version: 2,
|
||
saved_at: "3.000000Z".to_string(),
|
||
saved_at_micros: 3,
|
||
bottom_tab: "adventure".to_string(),
|
||
game_state: json!({
|
||
"runtimeSessionId": "runtime_001",
|
||
"runtimeActionVersion": 7,
|
||
"playerHp": 30,
|
||
"playerMaxHp": 40,
|
||
"playerMana": 10,
|
||
"playerMaxMana": 20,
|
||
"playerCurrency": 0,
|
||
"playerInventory": [],
|
||
"playerEquipment": { "weapon": null, "armor": null, "relic": null },
|
||
"inBattle": false,
|
||
"npcInteractionActive": false,
|
||
"storyHistory": []
|
||
}),
|
||
current_story: None,
|
||
game_state_json: "{}".to_string(),
|
||
current_story_json: None,
|
||
created_at_micros: 1,
|
||
updated_at_micros: 3,
|
||
},
|
||
None,
|
||
);
|
||
|
||
assert_eq!(projection.server_version, 7);
|
||
}
|
||
|
||
#[test]
|
||
fn story_session_owner_guard_rejects_mismatched_actor() {
|
||
let context = RequestContext::new(
|
||
"req_story_owner_guard".to_string(),
|
||
"GET /api/story/sessions/storysess_001/state".to_string(),
|
||
Duration::ZERO,
|
||
true,
|
||
);
|
||
|
||
let response = require_story_session_owner(&context, "user_owner", "user_other")
|
||
.expect_err("mismatched actor should be forbidden");
|
||
|
||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||
}
|
||
|
||
#[test]
|
||
fn story_session_owner_guard_accepts_matching_actor() {
|
||
let context = RequestContext::new(
|
||
"req_story_owner_guard".to_string(),
|
||
"GET /api/story/sessions/storysess_001/state".to_string(),
|
||
Duration::ZERO,
|
||
true,
|
||
);
|
||
|
||
require_story_session_owner(&context, "user_owner", "user_owner")
|
||
.expect("matching actor should pass");
|
||
}
|
||
|
||
async fn seed_authenticated_state() -> AppState {
|
||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||
state
|
||
.seed_test_phone_user_with_password("13800138108", "secret123")
|
||
.await
|
||
.id;
|
||
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: 2,
|
||
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")
|
||
}
|
||
}
|