628 lines
24 KiB
Rust
628 lines
24 KiB
Rust
use axum::{
|
||
Json,
|
||
extract::{Extension, Path, State},
|
||
http::StatusCode,
|
||
response::Response,
|
||
};
|
||
use module_npc::{
|
||
NpcRelationStance, build_initial_stance_profile as build_module_npc_initial_stance_profile,
|
||
build_relation_state as build_module_npc_relation_state,
|
||
};
|
||
use module_runtime::RuntimeSnapshotRecord;
|
||
use module_runtime_story_compat::{
|
||
CONTINUE_ADVENTURE_FUNCTION_ID, CurrentEncounterNpcQuestContext, GeneratedStoryPayload,
|
||
PendingQuestOfferContext, RuntimeStoryActionResponseParts, StoryResolution,
|
||
add_player_currency, add_player_inventory_items, append_story_history,
|
||
apply_equipment_loadout_to_state, battle_mode_text, build_battle_runtime_story_options,
|
||
build_current_build_toast, build_disabled_runtime_story_option, build_npc_gift_result_text,
|
||
build_runtime_story_option_from_story_option, build_runtime_story_view_model,
|
||
build_static_runtime_story_option, build_status_patch, build_story_option_from_runtime_option,
|
||
clear_encounter_only, clear_encounter_state, clone_inventory_item_with_quantity,
|
||
current_encounter_id, current_encounter_name, current_world_type,
|
||
ensure_inventory_action_available, ensure_json_object, equipment_slot_label,
|
||
find_player_inventory_entry, format_currency_text, format_now_rfc3339,
|
||
grant_player_progression_experience, has_giftable_player_inventory, increment_runtime_stat,
|
||
normalize_equipment_slot_id, normalize_equipped_item, normalize_required_string,
|
||
npc_buyback_price, npc_purchase_price, read_array_field, read_bool_field, read_field,
|
||
read_i32_field, read_inventory_item_name, read_object_field, read_optional_string_field,
|
||
read_player_equipment_item, read_required_string_field, read_runtime_session_id,
|
||
read_u32_field, recruit_companion_to_party, remove_player_inventory_item, resolve_action_text,
|
||
resolve_battle_action, resolve_current_encounter_npc_state, resolve_equipment_slot_for_item,
|
||
resolve_forge_craft_action, resolve_forge_dismantle_action, resolve_forge_reforge_action,
|
||
resolve_npc_gift_affinity_gain, restore_player_resource, simple_story_resolution,
|
||
trade_quantity_suffix, write_bool_field, write_i32_field, write_null_field,
|
||
write_player_equipment_item, write_string_field, write_u32_field,
|
||
};
|
||
use platform_llm::{LlmClient, LlmMessage, LlmTextRequest};
|
||
use serde_json::{Map, Value, json};
|
||
use shared_contracts::runtime_story::{
|
||
RuntimeBattlePresentation, RuntimeStoryActionRequest, RuntimeStoryActionResponse,
|
||
RuntimeStoryAiRequest, RuntimeStoryAiResponse, RuntimeStoryOptionInteraction,
|
||
RuntimeStoryOptionView, RuntimeStoryPatch, RuntimeStoryPresentation,
|
||
RuntimeStorySnapshotPayload, RuntimeStoryStateResolveRequest,
|
||
};
|
||
use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339};
|
||
use spacetime_client::SpacetimeClientError;
|
||
use time::OffsetDateTime;
|
||
|
||
use crate::{
|
||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||
request_context::RequestContext, state::AppState,
|
||
};
|
||
|
||
#[path = "compat/ai.rs"]
|
||
mod ai;
|
||
#[path = "compat/equipment_actions.rs"]
|
||
mod equipment_actions;
|
||
#[path = "compat/game_state.rs"]
|
||
mod game_state;
|
||
#[path = "compat/npc_actions.rs"]
|
||
mod npc_actions;
|
||
#[path = "compat/presentation.rs"]
|
||
mod presentation;
|
||
#[path = "compat/quest_actions.rs"]
|
||
mod quest_actions;
|
||
|
||
use self::{
|
||
ai::*, equipment_actions::*, game_state::*, npc_actions::*, presentation::*, quest_actions::*,
|
||
};
|
||
|
||
#[cfg(test)]
|
||
#[path = "compat/tests.rs"]
|
||
mod tests;
|
||
|
||
pub async fn resolve_runtime_story_state(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
Json(payload): Json<RuntimeStoryStateResolveRequest>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let session_id = normalize_required_string(payload.session_id.as_str()).ok_or_else(|| {
|
||
runtime_story_error_response(
|
||
&request_context,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "runtime-story",
|
||
"field": "sessionId",
|
||
"message": "sessionId 不能为空",
|
||
})),
|
||
)
|
||
})?;
|
||
let snapshot = resolve_snapshot_for_request(
|
||
&state,
|
||
&request_context,
|
||
authenticated.claims().user_id().to_string(),
|
||
payload.snapshot,
|
||
)
|
||
.await?;
|
||
|
||
validate_client_version(
|
||
&request_context,
|
||
payload.client_version,
|
||
&snapshot.game_state,
|
||
"运行时版本已变化,请先同步最新快照后再读取状态",
|
||
)?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
build_runtime_story_state_response(&session_id, payload.client_version, snapshot),
|
||
))
|
||
}
|
||
|
||
pub async fn get_runtime_story_state(
|
||
State(state): State<AppState>,
|
||
Path(session_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let session_id = normalize_required_string(session_id.as_str()).ok_or_else(|| {
|
||
runtime_story_error_response(
|
||
&request_context,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "runtime-story",
|
||
"field": "sessionId",
|
||
"message": "sessionId 不能为空",
|
||
})),
|
||
)
|
||
})?;
|
||
let snapshot = resolve_snapshot_for_request(
|
||
&state,
|
||
&request_context,
|
||
authenticated.claims().user_id().to_string(),
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
build_runtime_story_state_response(&session_id, None, snapshot),
|
||
))
|
||
}
|
||
|
||
pub async fn resolve_runtime_story_action(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
Json(payload): Json<RuntimeStoryActionRequest>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let requested_session_id =
|
||
normalize_required_string(payload.session_id.as_str()).ok_or_else(|| {
|
||
runtime_story_error_response(
|
||
&request_context,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "runtime-story",
|
||
"field": "sessionId",
|
||
"message": "sessionId 不能为空",
|
||
})),
|
||
)
|
||
})?;
|
||
let function_id =
|
||
normalize_required_string(payload.action.function_id.as_str()).ok_or_else(|| {
|
||
runtime_story_error_response(
|
||
&request_context,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "runtime-story",
|
||
"field": "action.functionId",
|
||
"message": "functionId 不能为空",
|
||
})),
|
||
)
|
||
})?;
|
||
if payload.action.action_type.trim() != "story_choice" {
|
||
return Err(runtime_story_error_response(
|
||
&request_context,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "runtime-story",
|
||
"field": "action.type",
|
||
"message": "runtime story 当前只支持 story_choice 动作",
|
||
})),
|
||
));
|
||
}
|
||
|
||
let mut snapshot = resolve_snapshot_for_request(
|
||
&state,
|
||
&request_context,
|
||
authenticated.claims().user_id().to_string(),
|
||
payload.snapshot.clone(),
|
||
)
|
||
.await?;
|
||
validate_client_version(
|
||
&request_context,
|
||
payload.client_version,
|
||
&snapshot.game_state,
|
||
"运行时版本已变化,请先同步最新快照后再提交动作",
|
||
)?;
|
||
|
||
let current_story_before = snapshot.current_story.clone();
|
||
let mut game_state = snapshot.game_state.clone();
|
||
let mut resolution = resolve_runtime_story_choice_action(
|
||
&mut game_state,
|
||
current_story_before.as_ref(),
|
||
&payload,
|
||
&function_id,
|
||
)
|
||
.map_err(|message| {
|
||
runtime_story_error_response(
|
||
&request_context,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "runtime-story",
|
||
"message": message,
|
||
})),
|
||
)
|
||
})?;
|
||
|
||
let server_version = read_u32_field(&game_state, "runtimeActionVersion")
|
||
.unwrap_or(0)
|
||
.saturating_add(1);
|
||
write_u32_field(&mut game_state, "runtimeActionVersion", server_version);
|
||
write_string_field(
|
||
&mut game_state,
|
||
"runtimeSessionId",
|
||
requested_session_id.as_str(),
|
||
);
|
||
|
||
let mut options = resolution
|
||
.presentation_options
|
||
.take()
|
||
.unwrap_or_else(|| build_fallback_runtime_story_options(&game_state));
|
||
if options.is_empty() {
|
||
options = build_fallback_runtime_story_options(&game_state);
|
||
}
|
||
|
||
let mut story_text = resolution
|
||
.story_text
|
||
.clone()
|
||
.unwrap_or_else(|| resolution.result_text.clone());
|
||
let mut history_result_text = resolution.result_text.clone();
|
||
let mut saved_current_story = resolution
|
||
.saved_current_story
|
||
.take()
|
||
.unwrap_or_else(|| build_legacy_current_story(story_text.as_str(), &options));
|
||
if let Some(generated_payload) = generate_action_story_payload(
|
||
&state,
|
||
&game_state,
|
||
&payload,
|
||
&function_id,
|
||
resolution.action_text.as_str(),
|
||
resolution.result_text.as_str(),
|
||
&options,
|
||
resolution.battle.as_ref(),
|
||
)
|
||
.await
|
||
{
|
||
story_text = generated_payload.story_text;
|
||
history_result_text = generated_payload.history_result_text;
|
||
options = generated_payload.presentation_options;
|
||
saved_current_story = generated_payload.saved_current_story;
|
||
}
|
||
append_story_history(
|
||
&mut game_state,
|
||
resolution.action_text.as_str(),
|
||
history_result_text.as_str(),
|
||
);
|
||
|
||
let mut patches = vec![RuntimeStoryPatch::StoryHistoryAppend {
|
||
action_text: resolution.action_text.clone(),
|
||
result_text: history_result_text,
|
||
}];
|
||
patches.extend(resolution.patches);
|
||
|
||
snapshot.saved_at = Some(format_now_rfc3339());
|
||
snapshot.game_state = game_state;
|
||
snapshot.current_story = Some(saved_current_story);
|
||
let persisted = persist_runtime_story_snapshot(
|
||
&state,
|
||
&request_context,
|
||
authenticated.claims().user_id().to_string(),
|
||
snapshot,
|
||
)
|
||
.await?;
|
||
let persisted_snapshot = runtime_snapshot_payload_from_record(&persisted);
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
build_runtime_story_action_response(RuntimeStoryActionResponseParts {
|
||
requested_session_id,
|
||
server_version,
|
||
snapshot: persisted_snapshot,
|
||
action_text: resolution.action_text,
|
||
result_text: resolution.result_text,
|
||
story_text,
|
||
options,
|
||
patches,
|
||
toast: resolution.toast,
|
||
battle: resolution.battle,
|
||
}),
|
||
))
|
||
}
|
||
|
||
pub async fn generate_runtime_story_initial(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||
Json(payload): Json<RuntimeStoryAiRequest>,
|
||
) -> Result<Json<Value>, Response> {
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
build_runtime_story_ai_response(&state, payload, true).await,
|
||
))
|
||
}
|
||
|
||
pub async fn generate_runtime_story_continue(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||
Json(payload): Json<RuntimeStoryAiRequest>,
|
||
) -> Result<Json<Value>, Response> {
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
build_runtime_story_ai_response(&state, payload, false).await,
|
||
))
|
||
}
|
||
|
||
async fn resolve_snapshot_for_request(
|
||
state: &AppState,
|
||
request_context: &RequestContext,
|
||
user_id: String,
|
||
snapshot: Option<RuntimeStorySnapshotPayload>,
|
||
) -> Result<RuntimeStorySnapshotPayload, Response> {
|
||
if let Some(snapshot) = snapshot {
|
||
let record =
|
||
persist_runtime_story_snapshot(state, request_context, user_id, snapshot).await?;
|
||
return Ok(runtime_snapshot_payload_from_record(&record));
|
||
}
|
||
|
||
let record = state
|
||
.get_runtime_snapshot_record(user_id)
|
||
.await
|
||
.map_err(|error| {
|
||
runtime_story_error_response(request_context, map_runtime_story_client_error(error))
|
||
})?
|
||
.ok_or_else(|| {
|
||
runtime_story_error_response(
|
||
request_context,
|
||
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
|
||
"provider": "runtime-story",
|
||
"message": "运行时快照不存在,请先初始化并保存一次游戏",
|
||
})),
|
||
)
|
||
})?;
|
||
|
||
Ok(runtime_snapshot_payload_from_record(&record))
|
||
}
|
||
|
||
async fn persist_runtime_story_snapshot(
|
||
state: &AppState,
|
||
request_context: &RequestContext,
|
||
user_id: String,
|
||
snapshot: RuntimeStorySnapshotPayload,
|
||
) -> Result<RuntimeSnapshotRecord, Response> {
|
||
validate_snapshot_payload(&snapshot).map_err(|message| {
|
||
runtime_story_error_response(
|
||
request_context,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "runtime-story",
|
||
"message": message,
|
||
})),
|
||
)
|
||
})?;
|
||
|
||
let now = OffsetDateTime::now_utc();
|
||
let saved_at = snapshot
|
||
.saved_at
|
||
.as_deref()
|
||
.and_then(|value| normalize_required_string(value))
|
||
.map(|value| parse_rfc3339(value.as_str()))
|
||
.transpose()
|
||
.map_err(|error| {
|
||
runtime_story_error_response(
|
||
request_context,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "runtime-story",
|
||
"field": "snapshot.savedAt",
|
||
"message": format!("savedAt 非法: {error}"),
|
||
})),
|
||
)
|
||
})?
|
||
.unwrap_or(now);
|
||
|
||
state
|
||
.put_runtime_snapshot_record(
|
||
user_id,
|
||
offset_datetime_to_unix_micros(saved_at),
|
||
snapshot.bottom_tab,
|
||
snapshot.game_state,
|
||
snapshot.current_story,
|
||
offset_datetime_to_unix_micros(now),
|
||
)
|
||
.await
|
||
.map_err(|error| {
|
||
runtime_story_error_response(request_context, map_runtime_story_client_error(error))
|
||
})
|
||
}
|
||
|
||
fn validate_snapshot_payload(snapshot: &RuntimeStorySnapshotPayload) -> Result<(), String> {
|
||
if normalize_required_string(snapshot.bottom_tab.as_str()).is_none() {
|
||
return Err("snapshot.bottomTab 不能为空".to_string());
|
||
}
|
||
if !snapshot.game_state.is_object() {
|
||
return Err("snapshot.gameState 必须是 JSON object".to_string());
|
||
}
|
||
if snapshot
|
||
.current_story
|
||
.as_ref()
|
||
.is_some_and(|current_story| !current_story.is_object())
|
||
{
|
||
return Err("snapshot.currentStory 必须是 JSON object 或 null".to_string());
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
fn runtime_snapshot_payload_from_record(
|
||
record: &RuntimeSnapshotRecord,
|
||
) -> RuntimeStorySnapshotPayload {
|
||
RuntimeStorySnapshotPayload {
|
||
saved_at: Some(record.saved_at.clone()),
|
||
bottom_tab: record.bottom_tab.clone(),
|
||
game_state: record.game_state.clone(),
|
||
current_story: record.current_story.clone(),
|
||
}
|
||
}
|
||
|
||
fn validate_client_version(
|
||
request_context: &RequestContext,
|
||
client_version: Option<u32>,
|
||
game_state: &Value,
|
||
message: &str,
|
||
) -> Result<(), Response> {
|
||
let Some(client_version) = client_version else {
|
||
return Ok(());
|
||
};
|
||
let Some(server_version) = read_u32_field(game_state, "runtimeActionVersion") else {
|
||
return Ok(());
|
||
};
|
||
if client_version == server_version {
|
||
return Ok(());
|
||
}
|
||
|
||
Err(runtime_story_error_response(
|
||
request_context,
|
||
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
|
||
"provider": "runtime-story",
|
||
"message": message,
|
||
"clientVersion": client_version,
|
||
"serverVersion": server_version,
|
||
})),
|
||
))
|
||
}
|
||
|
||
fn resolve_runtime_story_choice_action(
|
||
game_state: &mut Value,
|
||
current_story: Option<&Value>,
|
||
request: &RuntimeStoryActionRequest,
|
||
function_id: &str,
|
||
) -> Result<StoryResolution, String> {
|
||
ensure_runtime_story_bridge_state(game_state);
|
||
match function_id {
|
||
CONTINUE_ADVENTURE_FUNCTION_ID => resolve_continue_adventure_action(current_story),
|
||
"story_opening_camp_dialogue" => resolve_npc_affinity_action(
|
||
game_state,
|
||
request,
|
||
"交换开场判断",
|
||
2,
|
||
"你把眼前局势先讲清楚,对方终于愿意把第一轮判断说出口。",
|
||
),
|
||
"camp_travel_home_scene" => {
|
||
clear_encounter_state(game_state);
|
||
Ok(StoryResolution {
|
||
action_text: resolve_action_text("返回营地", request),
|
||
result_text: "你主动结束了当前遭遇,把节奏带回了更安全的营地。".to_string(),
|
||
story_text: None,
|
||
presentation_options: None,
|
||
saved_current_story: None,
|
||
patches: vec![
|
||
build_status_patch(game_state),
|
||
RuntimeStoryPatch::EncounterChanged { encounter_id: None },
|
||
],
|
||
battle: None,
|
||
toast: None,
|
||
})
|
||
}
|
||
"idle_call_out" => Ok(simple_story_resolution(
|
||
game_state,
|
||
resolve_action_text("主动出声试探", request),
|
||
"你的喊话打破了当前静场,周围潜着的动静也更难继续藏住。",
|
||
)),
|
||
"idle_explore_forward" => Ok(simple_story_resolution(
|
||
game_state,
|
||
resolve_action_text("继续向前探索", request),
|
||
"你没有停在原地,而是继续向前压,把下一段遭遇主动推到自己面前。",
|
||
)),
|
||
"idle_observe_signs" => Ok(simple_story_resolution(
|
||
game_state,
|
||
resolve_action_text("观察周围迹象", request),
|
||
"你先压住动作,把风向、脚印和气味这些细节重新读了一遍。",
|
||
)),
|
||
"idle_rest_focus" => {
|
||
restore_player_resource(game_state, 8, 6);
|
||
Ok(simple_story_resolution(
|
||
game_state,
|
||
resolve_action_text("原地调息", request),
|
||
"你把呼吸慢下来重新稳住节奏,生命和灵力都回上来一点。",
|
||
))
|
||
}
|
||
"idle_travel_next_scene" => {
|
||
clear_encounter_state(game_state);
|
||
increment_runtime_stat(game_state, "scenesTraveled", 1);
|
||
Ok(StoryResolution {
|
||
action_text: resolve_action_text("前往相邻场景", request),
|
||
result_text: "你收束了这一段遭遇,顺着路线把故事推进到新的场景段落。".to_string(),
|
||
story_text: None,
|
||
presentation_options: None,
|
||
saved_current_story: None,
|
||
patches: vec![
|
||
build_status_patch(game_state),
|
||
RuntimeStoryPatch::EncounterChanged { encounter_id: None },
|
||
],
|
||
battle: None,
|
||
toast: None,
|
||
})
|
||
}
|
||
"npc_preview_talk" => resolve_npc_preview_action(game_state, request),
|
||
"npc_chat" => resolve_npc_chat_action(game_state, request),
|
||
"npc_help" => resolve_npc_help_action(game_state, request),
|
||
"npc_chat_quest_offer_view" => {
|
||
resolve_pending_quest_offer_view_action(game_state, current_story, request)
|
||
}
|
||
"npc_chat_quest_offer_replace" => {
|
||
resolve_pending_quest_offer_replace_action(game_state, current_story, request)
|
||
}
|
||
"npc_chat_quest_offer_abandon" => {
|
||
resolve_pending_quest_offer_abandon_action(game_state, current_story, request)
|
||
}
|
||
"npc_quest_accept" => {
|
||
resolve_pending_quest_accept_action(game_state, current_story, request)
|
||
}
|
||
"npc_quest_turn_in" => resolve_pending_quest_turn_in_action(game_state, request),
|
||
"npc_leave" => {
|
||
let npc_name = current_encounter_name(game_state);
|
||
clear_encounter_state(game_state);
|
||
Ok(StoryResolution {
|
||
action_text: resolve_action_text("离开当前角色", request),
|
||
result_text: format!("你结束了与 {npc_name} 的这一轮接触,把注意力重新放回旅途。"),
|
||
story_text: None,
|
||
presentation_options: None,
|
||
saved_current_story: None,
|
||
patches: vec![
|
||
build_status_patch(game_state),
|
||
RuntimeStoryPatch::EncounterChanged { encounter_id: None },
|
||
],
|
||
battle: None,
|
||
toast: None,
|
||
})
|
||
}
|
||
"npc_fight" | "npc_spar" => {
|
||
resolve_npc_battle_entry_action(game_state, request, function_id)
|
||
}
|
||
"npc_trade" => resolve_npc_trade_action(game_state, request),
|
||
"npc_gift" => resolve_npc_gift_action(game_state, request),
|
||
"npc_recruit" => resolve_npc_recruit_action(game_state, request),
|
||
"equipment_equip" => resolve_equipment_equip_action(game_state, request),
|
||
"equipment_unequip" => resolve_equipment_unequip_action(game_state, request),
|
||
"forge_craft" => resolve_forge_craft_action(game_state, request),
|
||
"forge_dismantle" => resolve_forge_dismantle_action(game_state, request),
|
||
"forge_reforge" => resolve_forge_reforge_action(game_state, request),
|
||
"battle_attack_basic"
|
||
| "battle_use_skill"
|
||
| "battle_all_in_crush"
|
||
| "battle_escape_breakout"
|
||
| "battle_feint_step"
|
||
| "battle_finisher_window"
|
||
| "battle_guard_break"
|
||
| "battle_probe_pressure"
|
||
| "battle_recover_breath"
|
||
| "inventory_use" => resolve_battle_action(game_state, request, function_id),
|
||
_ => Err(format!("暂不支持的 runtime action:{function_id}")),
|
||
}
|
||
}
|
||
|
||
fn resolve_continue_adventure_action(
|
||
current_story: Option<&Value>,
|
||
) -> Result<StoryResolution, String> {
|
||
let deferred_options = current_story
|
||
.map(|story| {
|
||
read_array_field(story, "deferredOptions")
|
||
.into_iter()
|
||
.filter_map(build_runtime_story_option_from_story_option)
|
||
.collect::<Vec<_>>()
|
||
})
|
||
.unwrap_or_default();
|
||
let options = (!deferred_options.is_empty()).then_some(deferred_options);
|
||
|
||
Ok(StoryResolution {
|
||
action_text: "继续推进冒险".to_string(),
|
||
result_text: "你没有把节奏停下来,而是顺着当前局势继续向前推进了这一段故事。".to_string(),
|
||
story_text: None,
|
||
presentation_options: options,
|
||
saved_current_story: None,
|
||
patches: Vec::new(),
|
||
battle: None,
|
||
toast: None,
|
||
})
|
||
}
|
||
|
||
fn map_runtime_story_client_error(error: SpacetimeClientError) -> AppError {
|
||
let (status, provider) = match error {
|
||
SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "runtime-story"),
|
||
_ => (StatusCode::BAD_GATEWAY, "spacetimedb"),
|
||
};
|
||
|
||
AppError::from_status(status).with_details(json!({
|
||
"provider": provider,
|
||
"message": error.to_string(),
|
||
}))
|
||
}
|
||
|
||
fn runtime_story_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
||
error.into_response_with_context(Some(request_context))
|
||
}
|