This commit is contained in:
825
server-rs/crates/api-server/src/story_battles.rs
Normal file
825
server-rs/crates/api-server/src/story_battles.rs
Normal file
@@ -0,0 +1,825 @@
|
||||
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
|
||||
.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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user