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, 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, } #[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, #[serde(default)] pub battle_state_id: Option, 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, } #[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, pub quantity: u32, pub rarity: String, #[serde(default)] pub tags: Vec, pub stackable: bool, #[serde(default)] pub stack_key: String, #[serde(default)] pub equipment_slot_id: Option, } pub async fn create_story_battle( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, ) -> Result, 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, Extension(request_context): Extension, Extension(_authenticated): Extension, Json(payload): Json, ) -> Result, 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, Path(battle_state_id): Path, Extension(request_context): Extension, Extension(_authenticated): Extension, ) -> Result, 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, Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, ) -> Result, 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::>(), "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 { match raw.trim() { "fight" => Some(BattleMode::Fight), "spar" => Some(BattleMode::Spar), _ => None, } } fn parse_npc_battle_interaction_function_id_strict(raw: &str) -> Option { 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, String> { values.iter().map(parse_story_battle_reward_item).collect() } fn parse_story_battle_reward_item( value: &StoryBattleRewardItemRequest, ) -> Result { 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 { 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 { 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") } }