826 lines
30 KiB
Rust
826 lines
30 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::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")
|
|
}
|
|
}
|