978 lines
36 KiB
Rust
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,
|
|
¤t_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")
|
|
}
|
|
}
|