Files
Genarrative/server-rs/crates/api-server/src/story_battles.rs

978 lines
36 KiB
Rust

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_json::{Value, json};
use shared_contracts::story::{
CreateStoryBattleRequest, CreateStoryNpcBattleRequest, CreateStoryNpcBattleResponse,
ResolveStoryBattleRequest, ResolveStoryBattleResponse, StoryBattleRewardItemPayload,
StoryBattleRewardItemRequest, StoryBattleStatePayload, StoryBattleStateResponse,
StoryCombatActionPayload, StoryNpcInteractionPayload, StoryNpcStanceProfilePayload,
StoryNpcStatePayload,
};
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,
};
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 story_state = state
.spacetime_client()
.get_story_session_state(payload.story_session_id.clone())
.await
.map_err(|error| {
story_battles_error_response(&request_context, map_story_battle_client_error(error))
})?;
require_story_session_owner_for_battle(
&request_context,
&story_state.session.actor_user_id,
&actor_user_id,
)?;
require_story_session_runtime_for_battle(
&request_context,
&story_state.session.runtime_session_id,
&payload.runtime_session_id,
)?;
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),
StoryBattleStateResponse {
battle_state: 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 actor_user_id = authenticated.claims().user_id().to_string();
let battle_state_id = payload.battle_state_id;
let current_battle = state
.spacetime_client()
.get_battle_state(battle_state_id.clone())
.await
.map_err(|error| {
story_battles_error_response(&request_context, map_story_battle_client_error(error))
})?;
require_story_battle_owner(
&request_context,
&current_battle.actor_user_id,
&actor_user_id,
)?;
let result = state
.spacetime_client()
.resolve_combat_action(ResolveCombatActionInput {
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),
ResolveStoryBattleResponse {
battle_state: build_battle_state_payload(&result.battle_state),
combat: StoryCombatActionPayload {
damage_dealt: result.damage_dealt,
damage_taken: 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 actor_user_id = authenticated.claims().user_id().to_string();
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))
})?;
require_story_battle_owner(&request_context, &result.actor_user_id, &actor_user_id)?;
Ok(json_success_body(
Some(&request_context),
StoryBattleStateResponse {
battle_state: 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 story_state = state
.spacetime_client()
.get_story_session_state(payload.story_session_id.clone())
.await
.map_err(|error| {
story_battles_error_response(&request_context, map_story_battle_client_error(error))
})?;
require_story_session_owner_for_battle(
&request_context,
&story_state.session.actor_user_id,
&actor_user_id,
)?;
require_story_session_runtime_for_battle(
&request_context,
&story_state.session.runtime_session_id,
&payload.runtime_session_id,
)?;
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),
CreateStoryNpcBattleResponse {
npc_interaction: build_npc_interaction_payload(&result.npc_interaction),
battle_state: build_battle_state_payload(&result.battle_state),
},
))
}
fn build_battle_state_payload(
record: &spacetime_client::BattleStateRecord,
) -> StoryBattleStatePayload {
StoryBattleStatePayload {
battle_state_id: record.battle_state_id.clone(),
story_session_id: record.story_session_id.clone(),
runtime_session_id: record.runtime_session_id.clone(),
actor_user_id: record.actor_user_id.clone(),
chapter_id: record.chapter_id.clone(),
target_npc_id: record.target_npc_id.clone(),
target_name: record.target_name.clone(),
battle_mode: record.battle_mode.clone(),
status: record.status.clone(),
player_hp: record.player_hp,
player_max_hp: record.player_max_hp,
player_mana: record.player_mana,
player_max_mana: record.player_max_mana,
target_hp: record.target_hp,
target_max_hp: record.target_max_hp,
experience_reward: record.experience_reward,
reward_items: record
.reward_items
.iter()
.map(build_battle_reward_item_payload)
.collect(),
turn_index: record.turn_index,
last_action_function_id: record.last_action_function_id.clone(),
last_action_text: record.last_action_text.clone(),
last_result_text: record.last_result_text.clone(),
last_damage_dealt: record.last_damage_dealt,
last_damage_taken: record.last_damage_taken,
last_outcome: record.last_outcome.clone(),
version: record.version,
created_at: record.created_at.clone(),
updated_at: record.updated_at.clone(),
}
}
fn build_battle_reward_item_payload(
item: &module_runtime_item::RuntimeItemRewardItemSnapshot,
) -> StoryBattleRewardItemPayload {
StoryBattleRewardItemPayload {
item_id: item.item_id.clone(),
category: item.category.clone(),
item_name: item.item_name.clone(),
description: item.description.clone(),
quantity: item.quantity,
rarity: format_runtime_item_reward_item_rarity(item.rarity).to_string(),
tags: item.tags.clone(),
stackable: item.stackable,
stack_key: item.stack_key.clone(),
equipment_slot_id: item
.equipment_slot_id
.map(format_runtime_item_equipment_slot)
.map(ToOwned::to_owned),
}
}
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) -> StoryNpcStatePayload {
StoryNpcStatePayload {
npc_state_id: record.npc_state_id.clone(),
runtime_session_id: record.runtime_session_id.clone(),
npc_id: record.npc_id.clone(),
npc_name: record.npc_name.clone(),
affinity: record.affinity,
relation_stance: record.relation_stance.clone(),
help_used: record.help_used,
chatted_count: record.chatted_count,
gifts_given: record.gifts_given,
recruited: record.recruited,
trade_stock_signature: record.trade_stock_signature.clone(),
revealed_facts: record.revealed_facts.clone(),
known_attribute_rumors: record.known_attribute_rumors.clone(),
first_meaningful_contact_resolved: record.first_meaningful_contact_resolved,
seen_backstory_chapter_ids: record.seen_backstory_chapter_ids.clone(),
stance_profile: StoryNpcStanceProfilePayload {
trust: record.trust,
warmth: record.warmth,
ideological_fit: record.ideological_fit,
fear_or_guard: record.fear_or_guard,
loyalty: record.loyalty,
current_conflict_tag: record.current_conflict_tag.clone(),
recent_approvals: record.recent_approvals.clone(),
recent_disapprovals: record.recent_disapprovals.clone(),
},
created_at: record.created_at.clone(),
updated_at: record.updated_at.clone(),
}
}
fn build_npc_interaction_payload(
record: &spacetime_client::NpcInteractionRecord,
) -> StoryNpcInteractionPayload {
StoryNpcInteractionPayload {
npc_state: build_npc_state_payload(&record.npc_state),
interaction_status: record.interaction_status.clone(),
action_text: record.action_text.clone(),
result_text: record.result_text.clone(),
story_text: record.story_text.clone(),
battle_mode: record.battle_mode.clone(),
encounter_closed: record.encounter_closed,
affinity_changed: record.affinity_changed,
previous_affinity: record.previous_affinity,
next_affinity: 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 require_story_session_owner_for_battle(
request_context: &RequestContext,
resource_actor_user_id: &str,
authenticated_actor_user_id: &str,
) -> Result<(), Response> {
require_resource_owner(
request_context,
resource_actor_user_id,
authenticated_actor_user_id,
"story-session",
"story session 不属于当前用户,不能创建战斗",
)
}
fn require_story_battle_owner(
request_context: &RequestContext,
resource_actor_user_id: &str,
authenticated_actor_user_id: &str,
) -> Result<(), Response> {
require_resource_owner(
request_context,
resource_actor_user_id,
authenticated_actor_user_id,
"story-battle",
"battle state 不属于当前用户",
)
}
fn require_story_session_runtime_for_battle(
request_context: &RequestContext,
session_runtime_id: &str,
requested_runtime_id: &str,
) -> Result<(), Response> {
if session_runtime_id == requested_runtime_id {
return Ok(());
}
Err(story_battles_error_response(
request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "story-session",
"message": "runtimeSessionId 与 story session 不匹配,不能创建战斗",
})),
))
}
fn require_resource_owner(
request_context: &RequestContext,
resource_actor_user_id: &str,
authenticated_actor_user_id: &str,
provider: &'static str,
message: &'static str,
) -> Result<(), Response> {
if resource_actor_user_id == authenticated_actor_user_id {
return Ok(());
}
// API 层只做登录用户与资源属主的边界检查;战斗结算仍由 module-combat 与 SpacetimeDB 承担。
Err(story_battles_error_response(
request_context,
AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({
"provider": provider,
"message": message,
})),
))
}
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::{require_story_battle_owner, require_story_session_runtime_for_battle};
use crate::{
app::build_router, config::AppConfig, request_context::RequestContext, 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 resolve_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/resolve")
.header("content-type", "application/json")
.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::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())
);
}
#[test]
fn story_battle_owner_guard_rejects_mismatched_actor() {
let context = RequestContext::new(
"req_story_battle_owner_guard".to_string(),
"GET /api/story/battles/battle_001".to_string(),
Duration::ZERO,
true,
);
let response = require_story_battle_owner(&context, "user_owner", "user_other")
.expect_err("mismatched actor should be forbidden");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[test]
fn story_battle_owner_guard_accepts_matching_actor() {
let context = RequestContext::new(
"req_story_battle_owner_guard".to_string(),
"GET /api/story/battles/battle_001".to_string(),
Duration::ZERO,
true,
);
require_story_battle_owner(&context, "user_owner", "user_owner")
.expect("matching actor should pass");
}
#[test]
fn story_battle_runtime_guard_rejects_mismatched_runtime_session() {
let context = RequestContext::new(
"req_story_battle_runtime_guard".to_string(),
"POST /api/story/battles".to_string(),
Duration::ZERO,
true,
);
let response =
require_story_session_runtime_for_battle(&context, "runtime_owner", "runtime_other")
.expect_err("mismatched runtime session should be bad request");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[test]
fn story_battle_runtime_guard_accepts_matching_runtime_session() {
let context = RequestContext::new(
"req_story_battle_runtime_guard".to_string(),
"POST /api/story/battles".to_string(),
Duration::ZERO,
true,
);
require_story_session_runtime_for_battle(&context, "runtime_owner", "runtime_owner")
.expect("matching runtime session 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("13800138107", "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_battles".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")
}
}