3739 lines
134 KiB
Rust
3739 lines
134 KiB
Rust
use axum::{
|
||
Json,
|
||
extract::{Extension, Path, State},
|
||
http::StatusCode,
|
||
response::Response,
|
||
};
|
||
use module_runtime::RuntimeSnapshotRecord;
|
||
use platform_llm::{LlmMessage, LlmTextRequest};
|
||
use serde_json::{Map, Value, json};
|
||
use shared_contracts::runtime_story::{
|
||
RuntimeBattlePresentation, RuntimeStoryActionRequest, RuntimeStoryActionResponse,
|
||
RuntimeStoryAiRequest, RuntimeStoryAiResponse, RuntimeStoryCompanionViewModel,
|
||
RuntimeStoryEncounterViewModel, RuntimeStoryOptionInteraction, RuntimeStoryOptionView,
|
||
RuntimeStoryPatch, RuntimeStoryPlayerViewModel, RuntimeStoryPresentation,
|
||
RuntimeStorySnapshotPayload, RuntimeStoryStateResolveRequest, RuntimeStoryStatusViewModel,
|
||
RuntimeStoryViewModel,
|
||
};
|
||
use shared_kernel::{format_rfc3339, 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,
|
||
};
|
||
|
||
const CONTINUE_ADVENTURE_FUNCTION_ID: &str = "story_continue_adventure";
|
||
const MAX_TASK5_COMPANIONS: usize = 2;
|
||
|
||
struct StoryResolution {
|
||
action_text: String,
|
||
result_text: String,
|
||
story_text: Option<String>,
|
||
presentation_options: Option<Vec<RuntimeStoryOptionView>>,
|
||
saved_current_story: Option<Value>,
|
||
patches: Vec<RuntimeStoryPatch>,
|
||
battle: Option<RuntimeBattlePresentation>,
|
||
toast: Option<String>,
|
||
}
|
||
|
||
struct CurrentEncounterNpcQuestContext {
|
||
npc_id: String,
|
||
npc_name: String,
|
||
}
|
||
|
||
struct PendingQuestOfferContext {
|
||
dialogue: Vec<Value>,
|
||
turn_count: i32,
|
||
custom_input_placeholder: String,
|
||
quest: Value,
|
||
quest_id: String,
|
||
intro_text: Option<String>,
|
||
}
|
||
|
||
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 story_text = resolution
|
||
.story_text
|
||
.clone()
|
||
.unwrap_or_else(|| resolution.result_text.clone());
|
||
let saved_current_story = resolution
|
||
.saved_current_story
|
||
.take()
|
||
.unwrap_or_else(|| build_legacy_current_story(story_text.as_str(), &options));
|
||
append_story_history(
|
||
&mut game_state,
|
||
resolution.action_text.as_str(),
|
||
resolution.result_text.as_str(),
|
||
);
|
||
|
||
let mut patches = vec![RuntimeStoryPatch::StoryHistoryAppend {
|
||
action_text: resolution.action_text.clone(),
|
||
result_text: resolution.result_text.clone(),
|
||
}];
|
||
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 build_runtime_story_state_response(
|
||
requested_session_id: &str,
|
||
client_version: Option<u32>,
|
||
snapshot: RuntimeStorySnapshotPayload,
|
||
) -> RuntimeStoryActionResponse {
|
||
let session_id = read_runtime_session_id(&snapshot.game_state)
|
||
.unwrap_or_else(|| requested_session_id.to_string());
|
||
let options =
|
||
build_runtime_story_options(snapshot.current_story.as_ref(), &snapshot.game_state);
|
||
let story_text = read_story_text(snapshot.current_story.as_ref())
|
||
.unwrap_or_else(|| build_fallback_story_text(&snapshot.game_state));
|
||
let server_version = read_u32_field(&snapshot.game_state, "runtimeActionVersion")
|
||
.or(client_version)
|
||
.unwrap_or(0);
|
||
|
||
build_runtime_story_action_response(RuntimeStoryActionResponseParts {
|
||
requested_session_id: session_id,
|
||
server_version,
|
||
snapshot,
|
||
action_text: String::new(),
|
||
result_text: String::new(),
|
||
story_text,
|
||
options,
|
||
patches: Vec::new(),
|
||
toast: None,
|
||
battle: None,
|
||
})
|
||
}
|
||
|
||
struct RuntimeStoryActionResponseParts {
|
||
requested_session_id: String,
|
||
server_version: u32,
|
||
snapshot: RuntimeStorySnapshotPayload,
|
||
action_text: String,
|
||
result_text: String,
|
||
story_text: String,
|
||
options: Vec<RuntimeStoryOptionView>,
|
||
patches: Vec<RuntimeStoryPatch>,
|
||
toast: Option<String>,
|
||
battle: Option<RuntimeBattlePresentation>,
|
||
}
|
||
|
||
fn build_runtime_story_action_response(
|
||
parts: RuntimeStoryActionResponseParts,
|
||
) -> RuntimeStoryActionResponse {
|
||
let session_id = read_runtime_session_id(&parts.snapshot.game_state)
|
||
.unwrap_or_else(|| parts.requested_session_id);
|
||
|
||
RuntimeStoryActionResponse {
|
||
session_id,
|
||
server_version: parts.server_version,
|
||
view_model: build_runtime_story_view_model(&parts.snapshot.game_state, &parts.options),
|
||
presentation: RuntimeStoryPresentation {
|
||
action_text: parts.action_text,
|
||
result_text: parts.result_text,
|
||
story_text: parts.story_text,
|
||
options: parts.options,
|
||
toast: parts.toast,
|
||
battle: parts.battle,
|
||
},
|
||
patches: parts.patches,
|
||
snapshot: parts.snapshot,
|
||
}
|
||
}
|
||
|
||
fn build_runtime_story_view_model(
|
||
game_state: &Value,
|
||
options: &[RuntimeStoryOptionView],
|
||
) -> RuntimeStoryViewModel {
|
||
RuntimeStoryViewModel {
|
||
player: RuntimeStoryPlayerViewModel {
|
||
hp: read_i32_field(game_state, "playerHp").unwrap_or(0),
|
||
max_hp: read_i32_field(game_state, "playerMaxHp").unwrap_or(1),
|
||
mana: read_i32_field(game_state, "playerMana").unwrap_or(0),
|
||
max_mana: read_i32_field(game_state, "playerMaxMana").unwrap_or(1),
|
||
},
|
||
encounter: build_runtime_story_encounter(game_state),
|
||
companions: build_runtime_story_companions(game_state),
|
||
available_options: options.to_vec(),
|
||
status: RuntimeStoryStatusViewModel {
|
||
in_battle: read_bool_field(game_state, "inBattle").unwrap_or(false),
|
||
npc_interaction_active: read_bool_field(game_state, "npcInteractionActive")
|
||
.unwrap_or(false),
|
||
current_npc_battle_mode: read_optional_string_field(game_state, "currentNpcBattleMode"),
|
||
current_npc_battle_outcome: read_optional_string_field(
|
||
game_state,
|
||
"currentNpcBattleOutcome",
|
||
),
|
||
},
|
||
}
|
||
}
|
||
|
||
fn resolve_runtime_story_choice_action(
|
||
game_state: &mut Value,
|
||
current_story: Option<&Value>,
|
||
request: &RuntimeStoryActionRequest,
|
||
function_id: &str,
|
||
) -> Result<StoryResolution, String> {
|
||
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_recruit" => resolve_npc_recruit_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" => resolve_battle_action(game_state, request, function_id),
|
||
"treasure_secure" | "treasure_inspect" | "treasure_leave" => {
|
||
resolve_treasure_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 resolve_npc_preview_action(
|
||
game_state: &mut Value,
|
||
request: &RuntimeStoryActionRequest,
|
||
) -> Result<StoryResolution, String> {
|
||
let npc_name = current_encounter_name(game_state);
|
||
write_bool_field(game_state, "npcInteractionActive", true);
|
||
|
||
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)],
|
||
battle: None,
|
||
toast: None,
|
||
})
|
||
}
|
||
|
||
fn resolve_npc_affinity_action(
|
||
game_state: &mut Value,
|
||
request: &RuntimeStoryActionRequest,
|
||
default_action_text: &str,
|
||
affinity_delta: i32,
|
||
fallback_result_text: &str,
|
||
) -> Result<StoryResolution, String> {
|
||
write_bool_field(game_state, "npcInteractionActive", true);
|
||
let affinity_patch = adjust_current_npc_affinity(game_state, affinity_delta).map(
|
||
|(npc_id, previous_affinity, next_affinity)| RuntimeStoryPatch::NpcAffinityChanged {
|
||
npc_id,
|
||
previous_affinity,
|
||
next_affinity,
|
||
},
|
||
);
|
||
let mut patches = Vec::new();
|
||
if let Some(patch) = affinity_patch {
|
||
patches.push(patch);
|
||
}
|
||
patches.push(build_status_patch(game_state));
|
||
|
||
Ok(StoryResolution {
|
||
action_text: resolve_action_text(default_action_text, request),
|
||
result_text: fallback_result_text.to_string(),
|
||
story_text: None,
|
||
presentation_options: None,
|
||
saved_current_story: None,
|
||
patches,
|
||
battle: None,
|
||
toast: None,
|
||
})
|
||
}
|
||
|
||
fn resolve_npc_chat_action(
|
||
game_state: &mut Value,
|
||
request: &RuntimeStoryActionRequest,
|
||
) -> Result<StoryResolution, String> {
|
||
let chatted_count = read_current_npc_state_i32_field(game_state, "chattedCount").unwrap_or(0);
|
||
let affinity_gain = (6 - chatted_count).max(2);
|
||
let result_text = format!(
|
||
"{} 愿意把话接下去,态度比刚才明显松动了一些。当前关系推进了 {} 点。",
|
||
current_encounter_name(game_state),
|
||
affinity_gain
|
||
);
|
||
let mut resolution = resolve_npc_affinity_action(
|
||
game_state,
|
||
request,
|
||
"继续交谈",
|
||
affinity_gain,
|
||
result_text.as_str(),
|
||
)?;
|
||
write_current_npc_state_i32_field(game_state, "chattedCount", chatted_count.saturating_add(1));
|
||
write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true);
|
||
resolution.action_text = format!("继续和{}交谈", current_encounter_name(game_state));
|
||
Ok(resolution)
|
||
}
|
||
|
||
fn resolve_npc_help_action(
|
||
game_state: &mut Value,
|
||
request: &RuntimeStoryActionRequest,
|
||
) -> Result<StoryResolution, String> {
|
||
if read_current_npc_state_bool_field(game_state, "helpUsed").unwrap_or(false) {
|
||
return Err("当前 NPC 的一次性援手已经用完了".to_string());
|
||
}
|
||
|
||
restore_player_resource(game_state, 10, 8);
|
||
write_current_npc_state_bool_field(game_state, "helpUsed", true);
|
||
resolve_npc_affinity_action(
|
||
game_state,
|
||
request,
|
||
&format!("向{}请求援手", current_encounter_name(game_state)),
|
||
4,
|
||
&format!(
|
||
"{} 给了你一次及时支援,你的状态暂时稳住了,关系也顺势拉近了一点。",
|
||
current_encounter_name(game_state)
|
||
),
|
||
)
|
||
}
|
||
|
||
fn resolve_pending_quest_offer_view_action(
|
||
game_state: &mut Value,
|
||
current_story: Option<&Value>,
|
||
request: &RuntimeStoryActionRequest,
|
||
) -> Result<StoryResolution, String> {
|
||
let encounter = current_encounter_npc_quest_context(game_state)?;
|
||
let pending_offer = read_pending_quest_offer_context(current_story, encounter.npc_id.as_str())
|
||
.ok_or_else(|| "当前没有待处理的委托可查看。".to_string())?;
|
||
Ok(StoryResolution {
|
||
action_text: resolve_action_text(&format!("查看{}提出的委托", encounter.npc_name), request),
|
||
result_text: pending_offer.intro_text.clone().unwrap_or_else(|| {
|
||
build_quest_offer_dialogue_text(encounter.npc_name.as_str(), &pending_offer.quest)
|
||
}),
|
||
story_text: None,
|
||
presentation_options: None,
|
||
saved_current_story: None,
|
||
patches: vec![],
|
||
battle: None,
|
||
toast: None,
|
||
})
|
||
}
|
||
|
||
fn resolve_pending_quest_offer_replace_action(
|
||
game_state: &mut Value,
|
||
current_story: Option<&Value>,
|
||
request: &RuntimeStoryActionRequest,
|
||
) -> Result<StoryResolution, String> {
|
||
let encounter = current_encounter_npc_quest_context(game_state)?;
|
||
let pending_offer = read_pending_quest_offer_context(current_story, encounter.npc_id.as_str())
|
||
.ok_or_else(|| "当前没有待处理的委托可更换。".to_string())?;
|
||
let next_quest = build_next_pending_quest_offer(
|
||
game_state,
|
||
encounter.npc_id.as_str(),
|
||
encounter.npc_name.as_str(),
|
||
Some(pending_offer.quest_id.as_str()),
|
||
);
|
||
let quest_text = build_quest_offer_dialogue_text(encounter.npc_name.as_str(), &next_quest);
|
||
let dialogue = append_dialogue_turns(
|
||
pending_offer.dialogue.as_slice(),
|
||
vec![
|
||
json!({
|
||
"speaker": "player",
|
||
"text": "能不能换一份更适合眼下局势的委托?"
|
||
}),
|
||
json!({
|
||
"speaker": "npc",
|
||
"speakerName": encounter.npc_name,
|
||
"text": quest_text,
|
||
}),
|
||
],
|
||
);
|
||
let options = build_pending_quest_offer_options(encounter.npc_id.as_str());
|
||
let saved_current_story = build_pending_quest_offer_story(
|
||
dialogue,
|
||
encounter.npc_id.as_str(),
|
||
encounter.npc_name.as_str(),
|
||
pending_offer.turn_count,
|
||
pending_offer.custom_input_placeholder.as_str(),
|
||
Some(next_quest.clone()),
|
||
options.as_slice(),
|
||
);
|
||
|
||
Ok(StoryResolution {
|
||
action_text: resolve_action_text(&format!("请{}更换委托", encounter.npc_name), request),
|
||
result_text: quest_text.clone(),
|
||
story_text: Some(quest_text),
|
||
presentation_options: Some(options),
|
||
saved_current_story: Some(saved_current_story),
|
||
patches: vec![],
|
||
battle: None,
|
||
toast: None,
|
||
})
|
||
}
|
||
|
||
fn resolve_pending_quest_offer_abandon_action(
|
||
game_state: &mut Value,
|
||
current_story: Option<&Value>,
|
||
request: &RuntimeStoryActionRequest,
|
||
) -> Result<StoryResolution, String> {
|
||
let encounter = current_encounter_npc_quest_context(game_state)?;
|
||
let pending_offer = read_pending_quest_offer_context(current_story, encounter.npc_id.as_str())
|
||
.ok_or_else(|| "当前没有待处理的委托可放弃。".to_string())?;
|
||
let npc_reply = format!(
|
||
"{}点了点头,没有继续强求,只把这份委托暂时收了回去。",
|
||
encounter.npc_name
|
||
);
|
||
let dialogue = append_dialogue_turns(
|
||
pending_offer.dialogue.as_slice(),
|
||
vec![
|
||
json!({
|
||
"speaker": "player",
|
||
"text": "这件事我先不接,咱们还是先聊别的。"
|
||
}),
|
||
json!({
|
||
"speaker": "npc",
|
||
"speakerName": encounter.npc_name,
|
||
"text": npc_reply,
|
||
}),
|
||
],
|
||
);
|
||
let options = build_post_quest_offer_chat_options(encounter.npc_id.as_str());
|
||
let saved_current_story = build_pending_quest_offer_story(
|
||
dialogue,
|
||
encounter.npc_id.as_str(),
|
||
encounter.npc_name.as_str(),
|
||
pending_offer.turn_count,
|
||
pending_offer.custom_input_placeholder.as_str(),
|
||
None,
|
||
options.as_slice(),
|
||
);
|
||
|
||
Ok(StoryResolution {
|
||
action_text: resolve_action_text(&format!("暂不接受{}的委托", encounter.npc_name), request),
|
||
result_text: npc_reply.clone(),
|
||
story_text: Some(npc_reply),
|
||
presentation_options: Some(options),
|
||
saved_current_story: Some(saved_current_story),
|
||
patches: vec![],
|
||
battle: None,
|
||
toast: None,
|
||
})
|
||
}
|
||
|
||
fn resolve_pending_quest_accept_action(
|
||
game_state: &mut Value,
|
||
current_story: Option<&Value>,
|
||
request: &RuntimeStoryActionRequest,
|
||
) -> Result<StoryResolution, String> {
|
||
let encounter = current_encounter_npc_quest_context(game_state)?;
|
||
let pending_offer = read_pending_quest_offer_context(current_story, encounter.npc_id.as_str())
|
||
.ok_or_else(|| "当前没有待处理的委托可接下。".to_string())?;
|
||
if find_active_quest_for_issuer(game_state, encounter.npc_id.as_str()).is_some() {
|
||
return Err("当前角色已经有未结清的委托。".to_string());
|
||
}
|
||
|
||
let quest = pending_offer.quest.clone();
|
||
push_quest_record(game_state, &quest);
|
||
increment_runtime_stat(game_state, "questsAccepted", 1);
|
||
write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true);
|
||
|
||
let reply_text = first_quest_reveal_text(&quest)
|
||
.map(|text| format!("那就拜托你了。{text}"))
|
||
.unwrap_or_else(|| {
|
||
format!(
|
||
"那就拜托你了。{}",
|
||
read_optional_string_field(&quest, "summary")
|
||
.unwrap_or_else(|| "这份委托的关键要点我已经交给你。".to_string())
|
||
)
|
||
});
|
||
let dialogue = append_dialogue_turns(
|
||
pending_offer.dialogue.as_slice(),
|
||
vec![
|
||
json!({
|
||
"speaker": "player",
|
||
"text": "这件事我愿意接下,你把关键要点交给我。"
|
||
}),
|
||
json!({
|
||
"speaker": "npc",
|
||
"speakerName": encounter.npc_name,
|
||
"text": reply_text,
|
||
}),
|
||
],
|
||
);
|
||
let options = build_post_quest_accept_chat_options(encounter.npc_id.as_str());
|
||
let saved_current_story = build_pending_quest_offer_story(
|
||
dialogue,
|
||
encounter.npc_id.as_str(),
|
||
encounter.npc_name.as_str(),
|
||
pending_offer.turn_count,
|
||
pending_offer.custom_input_placeholder.as_str(),
|
||
None,
|
||
options.as_slice(),
|
||
);
|
||
|
||
Ok(StoryResolution {
|
||
action_text: resolve_action_text(&format!("接下{}的委托", encounter.npc_name), request),
|
||
result_text: build_quest_accept_result_text(&quest),
|
||
story_text: Some(
|
||
saved_current_story["text"]
|
||
.as_str()
|
||
.unwrap_or_default()
|
||
.to_string(),
|
||
),
|
||
presentation_options: Some(options),
|
||
saved_current_story: Some(saved_current_story),
|
||
patches: vec![],
|
||
battle: None,
|
||
toast: None,
|
||
})
|
||
}
|
||
|
||
fn resolve_pending_quest_turn_in_action(
|
||
game_state: &mut Value,
|
||
request: &RuntimeStoryActionRequest,
|
||
) -> Result<StoryResolution, String> {
|
||
let encounter = current_encounter_npc_quest_context(game_state)?;
|
||
let quest_id = request
|
||
.action
|
||
.payload
|
||
.as_ref()
|
||
.and_then(|payload| read_optional_string_field(payload, "questId"))
|
||
.or_else(|| request.action.target_id.clone())
|
||
.or_else(|| {
|
||
find_active_quest_for_issuer(game_state, encounter.npc_id.as_str())
|
||
.and_then(|quest| read_optional_string_field(quest, "id"))
|
||
})
|
||
.ok_or_else(|| "当前没有可交付的委托。".to_string())?;
|
||
let turned_in = turn_in_quest_record(game_state, encounter.npc_id.as_str(), quest_id.as_str())?;
|
||
let previous_affinity = read_current_npc_affinity(game_state);
|
||
let affinity_bonus = read_field(&turned_in, "reward")
|
||
.and_then(|reward| read_i32_field(reward, "affinityBonus"))
|
||
.unwrap_or(0);
|
||
let next_affinity = previous_affinity.saturating_add(affinity_bonus);
|
||
write_current_npc_state_i32_field(game_state, "affinity", next_affinity);
|
||
write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true);
|
||
apply_quest_turn_in_rewards(game_state, &turned_in);
|
||
|
||
Ok(StoryResolution {
|
||
action_text: resolve_action_text(&format!("向{}交付委托", encounter.npc_name), request),
|
||
result_text: build_quest_turn_in_result_text(&turned_in),
|
||
story_text: None,
|
||
presentation_options: None,
|
||
saved_current_story: None,
|
||
patches: vec![RuntimeStoryPatch::NpcAffinityChanged {
|
||
npc_id: encounter.npc_id,
|
||
previous_affinity,
|
||
next_affinity,
|
||
}],
|
||
battle: None,
|
||
toast: None,
|
||
})
|
||
}
|
||
|
||
fn resolve_npc_battle_entry_action(
|
||
game_state: &mut Value,
|
||
request: &RuntimeStoryActionRequest,
|
||
function_id: &str,
|
||
) -> Result<StoryResolution, String> {
|
||
let npc_id = current_encounter_id(game_state).unwrap_or_else(|| "npc_current".to_string());
|
||
let npc_name = current_encounter_name(game_state);
|
||
let battle_mode = if function_id == "npc_spar" {
|
||
"spar"
|
||
} else {
|
||
"fight"
|
||
};
|
||
write_bool_field(game_state, "inBattle", true);
|
||
write_bool_field(game_state, "npcInteractionActive", false);
|
||
write_string_field(game_state, "currentBattleNpcId", npc_id.as_str());
|
||
write_string_field(game_state, "currentNpcBattleMode", battle_mode);
|
||
write_null_field(game_state, "currentNpcBattleOutcome");
|
||
|
||
Ok(StoryResolution {
|
||
action_text: resolve_action_text(
|
||
if battle_mode == "spar" {
|
||
"点到为止切磋"
|
||
} else {
|
||
"与对方战斗"
|
||
},
|
||
request,
|
||
),
|
||
result_text: format!(
|
||
"{npc_name} 已经进入{}节奏,下一步必须按战斗动作结算。",
|
||
battle_mode_text(battle_mode)
|
||
),
|
||
story_text: None,
|
||
presentation_options: None,
|
||
saved_current_story: None,
|
||
patches: vec![build_status_patch(game_state)],
|
||
battle: Some(RuntimeBattlePresentation {
|
||
target_id: Some(npc_id),
|
||
target_name: Some(npc_name),
|
||
damage_dealt: None,
|
||
damage_taken: None,
|
||
outcome: Some("ongoing".to_string()),
|
||
}),
|
||
toast: None,
|
||
})
|
||
}
|
||
|
||
fn resolve_npc_recruit_action(
|
||
game_state: &mut Value,
|
||
request: &RuntimeStoryActionRequest,
|
||
) -> Result<StoryResolution, String> {
|
||
let npc_id = current_encounter_id(game_state).unwrap_or_else(|| "npc_current".to_string());
|
||
let npc_name = current_encounter_name(game_state);
|
||
let current_affinity = read_current_npc_affinity(game_state);
|
||
if read_current_npc_state_bool_field(game_state, "recruited").unwrap_or(false) {
|
||
return Err("当前 NPC 已经处于已招募状态".to_string());
|
||
}
|
||
if current_affinity < 60 {
|
||
return Err("当前关系还没达到招募阈值,暂时不能邀请入队".to_string());
|
||
}
|
||
|
||
let release_npc_id = request
|
||
.action
|
||
.payload
|
||
.as_ref()
|
||
.and_then(|payload| read_optional_string_field(payload, "releaseNpcId"));
|
||
let released_companion_name = recruit_companion_to_party(
|
||
game_state,
|
||
npc_id.as_str(),
|
||
npc_name.as_str(),
|
||
release_npc_id.as_deref(),
|
||
)?;
|
||
let affinity_patch =
|
||
set_current_npc_recruited(game_state, true).map(|(previous_affinity, next_affinity)| {
|
||
RuntimeStoryPatch::NpcAffinityChanged {
|
||
npc_id: npc_id.clone(),
|
||
previous_affinity,
|
||
next_affinity,
|
||
}
|
||
});
|
||
write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true);
|
||
write_bool_field(game_state, "npcInteractionActive", false);
|
||
clear_encounter_only(game_state);
|
||
write_null_field(game_state, "currentNpcBattleMode");
|
||
write_null_field(game_state, "currentNpcBattleOutcome");
|
||
write_bool_field(game_state, "inBattle", false);
|
||
|
||
let mut patches = Vec::new();
|
||
if let Some(patch) = affinity_patch {
|
||
patches.push(patch);
|
||
}
|
||
patches.push(build_status_patch(game_state));
|
||
patches.push(RuntimeStoryPatch::EncounterChanged { encounter_id: None });
|
||
|
||
Ok(StoryResolution {
|
||
action_text: resolve_action_text(&format!("邀请{npc_name}加入队伍"), request),
|
||
result_text: match released_companion_name {
|
||
Some(released_name) => format!(
|
||
"{npc_name} 接受了你的邀请,你先让 {released_name} 暂时离队,把位置腾给了新的同行者。"
|
||
),
|
||
None => format!("{npc_name} 接受了你的邀请,正式进入了同行队伍。"),
|
||
},
|
||
story_text: None,
|
||
presentation_options: None,
|
||
saved_current_story: None,
|
||
patches,
|
||
battle: None,
|
||
toast: Some(format!("{npc_name} 已加入队伍")),
|
||
})
|
||
}
|
||
|
||
fn resolve_battle_action(
|
||
game_state: &mut Value,
|
||
request: &RuntimeStoryActionRequest,
|
||
function_id: &str,
|
||
) -> Result<StoryResolution, String> {
|
||
let target_id = current_encounter_id(game_state)
|
||
.or_else(|| first_hostile_npc_string_field(game_state, "id"))
|
||
.unwrap_or_else(|| "battle_target".to_string());
|
||
let target_name = current_encounter_name_from_battle(game_state);
|
||
let battle_mode = read_optional_string_field(game_state, "currentNpcBattleMode")
|
||
.unwrap_or_else(|| "fight".to_string());
|
||
|
||
if function_id == "battle_escape_breakout" {
|
||
clear_encounter_state(game_state);
|
||
return Ok(StoryResolution {
|
||
action_text: resolve_action_text("强行脱离战斗", request),
|
||
result_text: "你抓住空隙强行脱离战斗,把这一轮危险先甩在身后。".to_string(),
|
||
story_text: None,
|
||
presentation_options: None,
|
||
saved_current_story: None,
|
||
patches: vec![
|
||
RuntimeStoryPatch::BattleResolved {
|
||
function_id: function_id.to_string(),
|
||
target_id: Some(target_id.clone()),
|
||
damage_dealt: Some(0),
|
||
damage_taken: Some(0),
|
||
outcome: "escaped".to_string(),
|
||
},
|
||
build_status_patch(game_state),
|
||
RuntimeStoryPatch::EncounterChanged { encounter_id: None },
|
||
],
|
||
battle: Some(RuntimeBattlePresentation {
|
||
target_id: Some(target_id),
|
||
target_name: Some(target_name),
|
||
damage_dealt: Some(0),
|
||
damage_taken: Some(0),
|
||
outcome: Some("escaped".to_string()),
|
||
}),
|
||
toast: Some("已脱离战斗".to_string()),
|
||
});
|
||
}
|
||
|
||
let (damage_dealt, damage_taken, heal, mana_restore, mana_cost, action_text, result_text) =
|
||
battle_action_numbers(function_id);
|
||
spend_player_mana(game_state, mana_cost);
|
||
restore_player_resource(game_state, heal, mana_restore);
|
||
apply_player_damage(game_state, damage_taken);
|
||
let target_hp = apply_target_damage(game_state, damage_dealt);
|
||
let outcome = if target_hp <= 0 {
|
||
if battle_mode == "spar" {
|
||
"spar_complete"
|
||
} else {
|
||
"victory"
|
||
}
|
||
} else {
|
||
"ongoing"
|
||
};
|
||
|
||
if outcome != "ongoing" {
|
||
write_bool_field(game_state, "inBattle", false);
|
||
write_bool_field(game_state, "npcInteractionActive", false);
|
||
write_null_field(game_state, "currentNpcBattleMode");
|
||
write_string_field(
|
||
game_state,
|
||
"currentNpcBattleOutcome",
|
||
if outcome == "spar_complete" {
|
||
"spar_complete"
|
||
} else {
|
||
"fight_victory"
|
||
},
|
||
);
|
||
if outcome == "victory" {
|
||
clear_encounter_only(game_state);
|
||
}
|
||
}
|
||
|
||
let mut patches = vec![
|
||
RuntimeStoryPatch::BattleResolved {
|
||
function_id: function_id.to_string(),
|
||
target_id: Some(target_id.clone()),
|
||
damage_dealt: Some(damage_dealt),
|
||
damage_taken: Some(damage_taken),
|
||
outcome: outcome.to_string(),
|
||
},
|
||
build_status_patch(game_state),
|
||
];
|
||
if outcome == "victory" {
|
||
patches.push(RuntimeStoryPatch::EncounterChanged { encounter_id: None });
|
||
}
|
||
|
||
Ok(StoryResolution {
|
||
action_text: resolve_action_text(action_text, request),
|
||
result_text: if outcome == "ongoing" {
|
||
result_text.to_string()
|
||
} else if outcome == "spar_complete" {
|
||
format!("{target_name} 收住了最后一击,这场切磋已经分出结果。")
|
||
} else {
|
||
format!("{target_name} 被你压制下去,眼前的战斗已经结束。")
|
||
},
|
||
story_text: None,
|
||
presentation_options: None,
|
||
saved_current_story: None,
|
||
patches,
|
||
battle: Some(RuntimeBattlePresentation {
|
||
target_id: Some(target_id),
|
||
target_name: Some(target_name),
|
||
damage_dealt: Some(damage_dealt),
|
||
damage_taken: Some(damage_taken),
|
||
outcome: Some(outcome.to_string()),
|
||
}),
|
||
toast: None,
|
||
})
|
||
}
|
||
|
||
fn resolve_treasure_action(
|
||
game_state: &mut Value,
|
||
request: &RuntimeStoryActionRequest,
|
||
function_id: &str,
|
||
) -> Result<StoryResolution, String> {
|
||
match function_id {
|
||
"treasure_secure" => {
|
||
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: Some("已收取宝箱".to_string()),
|
||
})
|
||
}
|
||
"treasure_inspect" => Ok(simple_story_resolution(
|
||
game_state,
|
||
resolve_action_text("仔细检查", request),
|
||
"你没有急着伸手,而是绕着目标重新检查机关、痕迹和可能的埋伏。",
|
||
)),
|
||
"treasure_leave" => {
|
||
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,
|
||
})
|
||
}
|
||
_ => Err(format!("暂不支持的 treasure action:{function_id}")),
|
||
}
|
||
}
|
||
|
||
fn simple_story_resolution(
|
||
game_state: &Value,
|
||
action_text: String,
|
||
result_text: &str,
|
||
) -> StoryResolution {
|
||
StoryResolution {
|
||
action_text,
|
||
result_text: result_text.to_string(),
|
||
story_text: None,
|
||
presentation_options: None,
|
||
saved_current_story: None,
|
||
patches: vec![build_status_patch(game_state)],
|
||
battle: None,
|
||
toast: None,
|
||
}
|
||
}
|
||
|
||
async fn build_runtime_story_ai_response(
|
||
state: &AppState,
|
||
payload: RuntimeStoryAiRequest,
|
||
initial: bool,
|
||
) -> RuntimeStoryAiResponse {
|
||
let options = build_ai_response_options(&payload);
|
||
let fallback = build_ai_fallback_story_text(&payload, initial);
|
||
let story_text = generate_ai_story_text(state, &payload, initial)
|
||
.await
|
||
.filter(|text| !text.trim().is_empty())
|
||
.unwrap_or(fallback);
|
||
|
||
RuntimeStoryAiResponse {
|
||
story_text,
|
||
options,
|
||
encounter: None,
|
||
}
|
||
}
|
||
|
||
async fn generate_ai_story_text(
|
||
state: &AppState,
|
||
payload: &RuntimeStoryAiRequest,
|
||
initial: bool,
|
||
) -> Option<String> {
|
||
let llm_client = state.llm_client()?;
|
||
let system_prompt = if initial {
|
||
"你是游戏运行时剧情导演。请用中文输出一段可直接展示给玩家的开局剧情,不要输出 JSON。"
|
||
} else {
|
||
"你是游戏运行时剧情导演。请用中文根据玩家选择续写一段剧情,不要输出 JSON。"
|
||
};
|
||
let user_prompt = json!({
|
||
"worldType": payload.world_type,
|
||
"character": payload.character,
|
||
"monsters": payload.monsters,
|
||
"history": payload.history,
|
||
"choice": payload.choice,
|
||
"context": payload.context,
|
||
"availableOptions": payload.request_options.available_options,
|
||
})
|
||
.to_string();
|
||
let mut request = LlmTextRequest::new(vec![
|
||
LlmMessage::system(system_prompt),
|
||
LlmMessage::user(user_prompt),
|
||
]);
|
||
request.max_tokens = Some(700);
|
||
|
||
llm_client
|
||
.request_text(request)
|
||
.await
|
||
.ok()
|
||
.map(|response| response.content.trim().to_string())
|
||
.filter(|text| !text.is_empty())
|
||
}
|
||
|
||
fn build_ai_response_options(payload: &RuntimeStoryAiRequest) -> Vec<Value> {
|
||
let source = if payload.request_options.available_options.is_empty() {
|
||
&payload.request_options.option_catalog
|
||
} else {
|
||
&payload.request_options.available_options
|
||
};
|
||
let options = source
|
||
.iter()
|
||
.filter_map(normalize_ai_story_option)
|
||
.collect::<Vec<_>>();
|
||
if !options.is_empty() {
|
||
return options;
|
||
}
|
||
|
||
vec![
|
||
build_ai_story_option_value("idle_observe_signs", "观察周围迹象"),
|
||
build_ai_story_option_value("idle_explore_forward", "继续向前探索"),
|
||
build_ai_story_option_value("idle_rest_focus", "原地调息"),
|
||
]
|
||
}
|
||
|
||
fn normalize_ai_story_option(value: &Value) -> Option<Value> {
|
||
let function_id = read_required_string_field(value, "functionId")?;
|
||
let action_text = read_required_string_field(value, "actionText")
|
||
.or_else(|| read_required_string_field(value, "text"))
|
||
.unwrap_or_else(|| function_id.clone());
|
||
let mut option = value.as_object()?.clone();
|
||
option.insert("functionId".to_string(), Value::String(function_id));
|
||
option.insert("actionText".to_string(), Value::String(action_text.clone()));
|
||
option
|
||
.entry("text".to_string())
|
||
.or_insert_with(|| Value::String(action_text));
|
||
|
||
Some(Value::Object(option))
|
||
}
|
||
|
||
fn build_ai_story_option_value(function_id: &str, action_text: &str) -> Value {
|
||
json!({
|
||
"functionId": function_id,
|
||
"actionText": action_text,
|
||
"text": action_text,
|
||
"visuals": {
|
||
"playerAnimation": "idle",
|
||
"playerMoveMeters": 0,
|
||
"playerOffsetY": 0,
|
||
"playerFacing": "right",
|
||
"scrollWorld": false,
|
||
"monsterChanges": []
|
||
}
|
||
})
|
||
}
|
||
|
||
fn build_ai_fallback_story_text(payload: &RuntimeStoryAiRequest, initial: bool) -> String {
|
||
let character_name =
|
||
read_optional_string_field(&payload.character, "name").unwrap_or_else(|| "你".to_string());
|
||
let scene_name = read_optional_string_field(&payload.context, "sceneName")
|
||
.or_else(|| read_optional_string_field(&payload.context, "scene"))
|
||
.unwrap_or_else(|| "当前区域".to_string());
|
||
if initial {
|
||
return format!(
|
||
"{character_name} 在 {scene_name} 稳住脚步,周围的气息正在变化,第一轮选择已经摆到眼前。"
|
||
);
|
||
}
|
||
|
||
let choice = normalize_required_string(payload.choice.as_str())
|
||
.unwrap_or_else(|| "继续推进".to_string());
|
||
format!("{character_name} 选择了「{choice}」,{scene_name} 的局势随之向下一步展开。")
|
||
}
|
||
|
||
fn build_runtime_story_companions(game_state: &Value) -> Vec<RuntimeStoryCompanionViewModel> {
|
||
read_array_field(game_state, "companions")
|
||
.into_iter()
|
||
.filter_map(|entry| {
|
||
let npc_id = read_required_string_field(entry, "npcId")?;
|
||
Some(RuntimeStoryCompanionViewModel {
|
||
npc_id,
|
||
character_id: read_optional_string_field(entry, "characterId"),
|
||
joined_at_affinity: read_i32_field(entry, "joinedAtAffinity").unwrap_or(0),
|
||
})
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn build_runtime_story_encounter(game_state: &Value) -> Option<RuntimeStoryEncounterViewModel> {
|
||
let encounter = read_object_field(game_state, "currentEncounter")?;
|
||
let npc_name = read_required_string_field(encounter, "npcName")
|
||
.or_else(|| read_required_string_field(encounter, "name"))
|
||
.unwrap_or_else(|| "当前遭遇".to_string());
|
||
let encounter_id =
|
||
read_required_string_field(encounter, "id").unwrap_or_else(|| npc_name.clone());
|
||
let npc_state = resolve_current_encounter_npc_state(game_state, &encounter_id, &npc_name);
|
||
|
||
Some(RuntimeStoryEncounterViewModel {
|
||
id: encounter_id,
|
||
kind: read_required_string_field(encounter, "kind").unwrap_or_else(|| "npc".to_string()),
|
||
npc_name,
|
||
hostile: read_bool_field(encounter, "hostile").unwrap_or(false),
|
||
affinity: npc_state.and_then(|state| read_i32_field(state, "affinity")),
|
||
recruited: npc_state.and_then(|state| read_bool_field(state, "recruited")),
|
||
interaction_active: read_bool_field(game_state, "npcInteractionActive").unwrap_or(false),
|
||
battle_mode: read_optional_string_field(game_state, "currentNpcBattleMode"),
|
||
})
|
||
}
|
||
|
||
fn resolve_current_encounter_npc_state<'a>(
|
||
game_state: &'a Value,
|
||
encounter_id: &str,
|
||
npc_name: &str,
|
||
) -> Option<&'a Value> {
|
||
let npc_states = read_object_field(game_state, "npcStates")?;
|
||
|
||
npc_states
|
||
.get(encounter_id)
|
||
.or_else(|| npc_states.get(npc_name))
|
||
}
|
||
|
||
fn build_runtime_story_options(
|
||
current_story: Option<&Value>,
|
||
game_state: &Value,
|
||
) -> Vec<RuntimeStoryOptionView> {
|
||
if let Some(story) = current_story {
|
||
let prefers_deferred = read_required_string_field(story, "displayMode")
|
||
.is_some_and(|value| value == "dialogue")
|
||
&& !read_array_field(story, "deferredOptions").is_empty();
|
||
|
||
let source = if prefers_deferred {
|
||
read_array_field(story, "deferredOptions")
|
||
} else {
|
||
read_array_field(story, "options")
|
||
};
|
||
|
||
let compiled = source
|
||
.into_iter()
|
||
.filter_map(build_runtime_story_option_from_story_option)
|
||
.collect::<Vec<_>>();
|
||
|
||
if !compiled.is_empty() {
|
||
return compiled;
|
||
}
|
||
}
|
||
|
||
build_fallback_runtime_story_options(game_state)
|
||
}
|
||
|
||
fn build_runtime_story_option_from_story_option(value: &Value) -> Option<RuntimeStoryOptionView> {
|
||
let function_id = read_required_string_field(value, "functionId")?;
|
||
let action_text = read_required_string_field(value, "actionText")
|
||
.or_else(|| read_required_string_field(value, "text"))
|
||
.unwrap_or_else(|| function_id.clone());
|
||
|
||
Some(RuntimeStoryOptionView {
|
||
scope: infer_option_scope(function_id.as_str()).to_string(),
|
||
detail_text: read_optional_string_field(value, "detailText"),
|
||
interaction: build_runtime_story_option_interaction(read_field(value, "interaction")),
|
||
payload: read_field(value, "runtimePayload")
|
||
.or_else(|| read_field(value, "payload"))
|
||
.cloned(),
|
||
disabled: read_bool_field(value, "disabled"),
|
||
reason: read_optional_string_field(value, "disabledReason")
|
||
.or_else(|| read_optional_string_field(value, "reason")),
|
||
function_id,
|
||
action_text,
|
||
})
|
||
}
|
||
|
||
fn build_runtime_story_option_interaction(
|
||
value: Option<&Value>,
|
||
) -> Option<RuntimeStoryOptionInteraction> {
|
||
let interaction = value?;
|
||
match read_required_string_field(interaction, "kind")?.as_str() {
|
||
"npc" => Some(RuntimeStoryOptionInteraction::Npc {
|
||
npc_id: read_required_string_field(interaction, "npcId")?,
|
||
action: read_required_string_field(interaction, "action")?,
|
||
quest_id: read_optional_string_field(interaction, "questId"),
|
||
}),
|
||
"treasure" => Some(RuntimeStoryOptionInteraction::Treasure {
|
||
action: read_required_string_field(interaction, "action")?,
|
||
}),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
fn build_fallback_runtime_story_options(game_state: &Value) -> Vec<RuntimeStoryOptionView> {
|
||
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
|
||
return vec![
|
||
build_static_runtime_story_option("battle_attack_basic", "普通攻击", "combat"),
|
||
build_static_runtime_story_option("battle_recover_breath", "恢复", "combat"),
|
||
build_static_runtime_story_option("battle_escape_breakout", "强行脱离战斗", "combat"),
|
||
];
|
||
}
|
||
|
||
let encounter = read_object_field(game_state, "currentEncounter");
|
||
if let Some(encounter) = encounter {
|
||
match read_required_string_field(encounter, "kind").as_deref() {
|
||
Some("npc") => {
|
||
let interaction_active =
|
||
read_bool_field(game_state, "npcInteractionActive").unwrap_or(false);
|
||
let npc_id = read_required_string_field(encounter, "id")
|
||
.unwrap_or_else(|| "npc_current".to_string());
|
||
if let Some(active_quest) =
|
||
find_active_quest_for_issuer(game_state, npc_id.as_str())
|
||
{
|
||
if read_optional_string_field(active_quest, "status")
|
||
.is_some_and(|status| status == "completed")
|
||
{
|
||
return vec![
|
||
build_npc_runtime_story_option_with_quest(
|
||
"npc_quest_turn_in",
|
||
&format!("向{}交付委托", current_encounter_name(game_state)),
|
||
&npc_id,
|
||
"quest_turn_in",
|
||
read_optional_string_field(active_quest, "id"),
|
||
),
|
||
build_npc_runtime_story_option(
|
||
"npc_leave",
|
||
"离开当前角色",
|
||
&npc_id,
|
||
"leave",
|
||
),
|
||
];
|
||
}
|
||
}
|
||
if interaction_active {
|
||
return vec![
|
||
build_npc_runtime_story_option("npc_chat", "继续交谈", &npc_id, "chat"),
|
||
build_npc_runtime_story_option("npc_help", "请求援手", &npc_id, "help"),
|
||
build_npc_runtime_story_option(
|
||
"npc_recruit",
|
||
"邀请同行",
|
||
&npc_id,
|
||
"recruit",
|
||
),
|
||
build_npc_runtime_story_option("npc_spar", "点到为止切磋", &npc_id, "spar"),
|
||
build_npc_runtime_story_option("npc_fight", "与对方战斗", &npc_id, "fight"),
|
||
build_npc_runtime_story_option(
|
||
"npc_leave",
|
||
"离开当前角色",
|
||
&npc_id,
|
||
"leave",
|
||
),
|
||
];
|
||
}
|
||
|
||
return vec![
|
||
build_npc_runtime_story_option(
|
||
"npc_preview_talk",
|
||
"转向眼前角色",
|
||
&npc_id,
|
||
"chat",
|
||
),
|
||
build_npc_runtime_story_option("npc_fight", "与对方战斗", &npc_id, "fight"),
|
||
build_npc_runtime_story_option("npc_leave", "离开当前角色", &npc_id, "leave"),
|
||
];
|
||
}
|
||
Some("treasure") => {
|
||
return vec![
|
||
build_treasure_runtime_story_option("treasure_secure", "直接收取", "secure"),
|
||
build_treasure_runtime_story_option("treasure_inspect", "仔细检查", "inspect"),
|
||
build_treasure_runtime_story_option("treasure_leave", "先记下位置", "leave"),
|
||
];
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
vec![
|
||
build_static_runtime_story_option("idle_observe_signs", "观察周围迹象", "story"),
|
||
build_static_runtime_story_option("idle_call_out", "主动出声试探", "story"),
|
||
build_static_runtime_story_option("idle_rest_focus", "原地调息", "story"),
|
||
build_static_runtime_story_option("idle_explore_forward", "继续向前探索", "story"),
|
||
build_static_runtime_story_option("idle_travel_next_scene", "前往相邻场景", "story"),
|
||
build_static_runtime_story_option(CONTINUE_ADVENTURE_FUNCTION_ID, "继续推进冒险", "story"),
|
||
]
|
||
}
|
||
|
||
fn build_static_runtime_story_option(
|
||
function_id: &str,
|
||
action_text: &str,
|
||
scope: &str,
|
||
) -> RuntimeStoryOptionView {
|
||
RuntimeStoryOptionView {
|
||
function_id: function_id.to_string(),
|
||
action_text: action_text.to_string(),
|
||
detail_text: None,
|
||
scope: scope.to_string(),
|
||
interaction: None,
|
||
payload: None,
|
||
disabled: None,
|
||
reason: None,
|
||
}
|
||
}
|
||
|
||
fn build_npc_runtime_story_option(
|
||
function_id: &str,
|
||
action_text: &str,
|
||
npc_id: &str,
|
||
action: &str,
|
||
) -> RuntimeStoryOptionView {
|
||
RuntimeStoryOptionView {
|
||
interaction: Some(RuntimeStoryOptionInteraction::Npc {
|
||
npc_id: npc_id.to_string(),
|
||
action: action.to_string(),
|
||
quest_id: None,
|
||
}),
|
||
..build_static_runtime_story_option(function_id, action_text, "npc")
|
||
}
|
||
}
|
||
|
||
fn build_npc_runtime_story_option_with_payload(
|
||
function_id: &str,
|
||
action_text: &str,
|
||
npc_id: &str,
|
||
action: &str,
|
||
payload: Value,
|
||
) -> RuntimeStoryOptionView {
|
||
RuntimeStoryOptionView {
|
||
payload: Some(payload),
|
||
..build_npc_runtime_story_option(function_id, action_text, npc_id, action)
|
||
}
|
||
}
|
||
|
||
fn build_npc_runtime_story_option_with_quest(
|
||
function_id: &str,
|
||
action_text: &str,
|
||
npc_id: &str,
|
||
action: &str,
|
||
quest_id: Option<String>,
|
||
) -> RuntimeStoryOptionView {
|
||
RuntimeStoryOptionView {
|
||
interaction: Some(RuntimeStoryOptionInteraction::Npc {
|
||
npc_id: npc_id.to_string(),
|
||
action: action.to_string(),
|
||
quest_id,
|
||
}),
|
||
..build_static_runtime_story_option(function_id, action_text, "npc")
|
||
}
|
||
}
|
||
|
||
fn build_treasure_runtime_story_option(
|
||
function_id: &str,
|
||
action_text: &str,
|
||
action: &str,
|
||
) -> RuntimeStoryOptionView {
|
||
RuntimeStoryOptionView {
|
||
interaction: Some(RuntimeStoryOptionInteraction::Treasure {
|
||
action: action.to_string(),
|
||
}),
|
||
..build_static_runtime_story_option(function_id, action_text, "story")
|
||
}
|
||
}
|
||
|
||
fn current_encounter_npc_quest_context(
|
||
game_state: &Value,
|
||
) -> Result<CurrentEncounterNpcQuestContext, String> {
|
||
let encounter = read_object_field(game_state, "currentEncounter")
|
||
.ok_or_else(|| "当前不在可结算的 NPC 委托态。".to_string())?;
|
||
let kind = read_required_string_field(encounter, "kind")
|
||
.ok_or_else(|| "当前不在可结算的 NPC 委托态。".to_string())?;
|
||
if kind != "npc" {
|
||
return Err("当前不在可结算的 NPC 委托态。".to_string());
|
||
}
|
||
|
||
let npc_name = read_optional_string_field(encounter, "npcName")
|
||
.or_else(|| read_optional_string_field(encounter, "name"))
|
||
.unwrap_or_else(|| "当前角色".to_string());
|
||
let npc_id = read_optional_string_field(encounter, "id").unwrap_or_else(|| npc_name.clone());
|
||
|
||
if resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str()).is_none()
|
||
{
|
||
return Err("当前 NPC 状态不存在,无法处理委托。".to_string());
|
||
}
|
||
|
||
Ok(CurrentEncounterNpcQuestContext { npc_id, npc_name })
|
||
}
|
||
|
||
fn read_pending_quest_offer_context(
|
||
current_story: Option<&Value>,
|
||
npc_key: &str,
|
||
) -> Option<PendingQuestOfferContext> {
|
||
let current_story = current_story?;
|
||
let npc_chat_state = read_object_field(current_story, "npcChatState")?;
|
||
let pending_offer = read_object_field(npc_chat_state, "pendingQuestOffer")?;
|
||
let quest = read_object_field(pending_offer, "quest")?.clone();
|
||
let quest_id = read_optional_string_field(&quest, "id")?;
|
||
let pending_npc_id = read_optional_string_field(npc_chat_state, "npcId");
|
||
let issuer_npc_id = read_optional_string_field(&quest, "issuerNpcId");
|
||
if pending_npc_id
|
||
.as_deref()
|
||
.is_some_and(|value| value != npc_key)
|
||
{
|
||
return None;
|
||
}
|
||
if issuer_npc_id
|
||
.as_deref()
|
||
.is_some_and(|value| value != npc_key)
|
||
{
|
||
return None;
|
||
}
|
||
|
||
Some(PendingQuestOfferContext {
|
||
dialogue: read_array_field(current_story, "dialogue")
|
||
.into_iter()
|
||
.cloned()
|
||
.collect(),
|
||
turn_count: read_i32_field(npc_chat_state, "turnCount").unwrap_or(0),
|
||
custom_input_placeholder: read_optional_string_field(
|
||
npc_chat_state,
|
||
"customInputPlaceholder",
|
||
)
|
||
.unwrap_or_else(|| "输入你想对 TA 说的话".to_string()),
|
||
quest,
|
||
quest_id,
|
||
intro_text: read_optional_string_field(pending_offer, "introText"),
|
||
})
|
||
}
|
||
|
||
fn build_quest_offer_dialogue_text(npc_name: &str, quest: &Value) -> String {
|
||
let summary_text = read_optional_string_field(quest, "summary")
|
||
.or_else(|| read_optional_string_field(quest, "description"))
|
||
.unwrap_or_default();
|
||
if summary_text.is_empty() {
|
||
return format!(
|
||
"{npc_name}沉吟了片刻,像是终于把真正想托付的事说了出来。如果你愿意,我想把眼前这件事正式交给你。"
|
||
);
|
||
}
|
||
format!(
|
||
"{npc_name}沉吟了片刻,像是终于把真正想托付的事说了出来。如果你愿意,我想把这件事正式交给你:{summary_text}"
|
||
)
|
||
}
|
||
|
||
fn append_dialogue_turns(existing: &[Value], additions: Vec<Value>) -> Vec<Value> {
|
||
let mut dialogue = existing.to_vec();
|
||
dialogue.extend(additions);
|
||
dialogue
|
||
}
|
||
|
||
fn build_pending_quest_offer_options(npc_id: &str) -> Vec<RuntimeStoryOptionView> {
|
||
vec![
|
||
build_npc_runtime_story_option_with_payload(
|
||
"npc_chat_quest_offer_view",
|
||
"查看任务",
|
||
npc_id,
|
||
"quest_offer_view",
|
||
json!({
|
||
"npcChatQuestOfferAction": "view"
|
||
}),
|
||
),
|
||
build_npc_runtime_story_option_with_payload(
|
||
"npc_chat_quest_offer_replace",
|
||
"更换任务",
|
||
npc_id,
|
||
"quest_offer_replace",
|
||
json!({
|
||
"npcChatQuestOfferAction": "replace"
|
||
}),
|
||
),
|
||
build_npc_runtime_story_option_with_payload(
|
||
"npc_chat_quest_offer_abandon",
|
||
"放弃任务",
|
||
npc_id,
|
||
"quest_offer_abandon",
|
||
json!({
|
||
"npcChatQuestOfferAction": "abandon"
|
||
}),
|
||
),
|
||
]
|
||
}
|
||
|
||
fn build_post_quest_offer_chat_options(npc_id: &str) -> Vec<RuntimeStoryOptionView> {
|
||
vec![
|
||
build_npc_runtime_story_option(
|
||
"npc_chat",
|
||
"那先继续聊聊你刚才没说完的部分",
|
||
npc_id,
|
||
"chat",
|
||
),
|
||
build_npc_runtime_story_option(
|
||
"npc_chat",
|
||
"除了委托,你对眼前局势还有什么判断",
|
||
npc_id,
|
||
"chat",
|
||
),
|
||
build_npc_runtime_story_option(
|
||
"npc_chat",
|
||
"先把这附近真正危险的地方说清楚",
|
||
npc_id,
|
||
"chat",
|
||
),
|
||
]
|
||
}
|
||
|
||
fn build_post_quest_accept_chat_options(npc_id: &str) -> Vec<RuntimeStoryOptionView> {
|
||
vec![
|
||
build_npc_runtime_story_option("npc_chat", "这件事里你最担心哪一步", npc_id, "chat"),
|
||
build_npc_runtime_story_option("npc_chat", "我回来时你最想先知道什么", npc_id, "chat"),
|
||
build_npc_runtime_story_option(
|
||
"npc_chat",
|
||
"除了这份委托,你还想提醒我什么",
|
||
npc_id,
|
||
"chat",
|
||
),
|
||
]
|
||
}
|
||
|
||
fn build_pending_quest_offer_story(
|
||
dialogue: Vec<Value>,
|
||
npc_id: &str,
|
||
npc_name: &str,
|
||
turn_count: i32,
|
||
custom_input_placeholder: &str,
|
||
pending_quest: Option<Value>,
|
||
options: &[RuntimeStoryOptionView],
|
||
) -> Value {
|
||
json!({
|
||
"text": dialogue
|
||
.iter()
|
||
.filter_map(|entry| read_optional_string_field(entry, "text"))
|
||
.collect::<Vec<_>>()
|
||
.join("\n"),
|
||
"options": options.iter().map(build_story_option_from_runtime_option).collect::<Vec<_>>(),
|
||
"displayMode": "dialogue",
|
||
"dialogue": dialogue,
|
||
"streaming": false,
|
||
"npcChatState": {
|
||
"npcId": npc_id,
|
||
"npcName": npc_name,
|
||
"turnCount": turn_count,
|
||
"customInputPlaceholder": custom_input_placeholder,
|
||
"pendingQuestOffer": pending_quest.map(|quest| json!({ "quest": quest })),
|
||
}
|
||
})
|
||
}
|
||
|
||
fn build_next_pending_quest_offer(
|
||
game_state: &Value,
|
||
npc_id: &str,
|
||
npc_name: &str,
|
||
previous_quest_id: Option<&str>,
|
||
) -> Value {
|
||
let next_id = if previous_quest_id.is_some_and(|id| id == "quest-bridge-offer") {
|
||
"quest-bridge-replaced"
|
||
} else {
|
||
"quest-generated-replaced"
|
||
};
|
||
let title = if next_id == "quest-bridge-replaced" {
|
||
"断桥夜巡"
|
||
} else {
|
||
"新的临时委托"
|
||
};
|
||
let scene_id = read_object_field(game_state, "currentScenePreset")
|
||
.and_then(|scene| read_optional_string_field(scene, "id"));
|
||
json!({
|
||
"id": next_id,
|
||
"issuerNpcId": npc_id,
|
||
"issuerNpcName": npc_name,
|
||
"sceneId": scene_id,
|
||
"title": title,
|
||
"description": format!("{title}的详细说明。"),
|
||
"summary": format!("{title}的简要目标。"),
|
||
"objective": {
|
||
"kind": "inspect_treasure",
|
||
"requiredCount": 1
|
||
},
|
||
"progress": 0,
|
||
"status": "active",
|
||
"reward": {
|
||
"affinityBonus": 6,
|
||
"currency": 30,
|
||
"items": []
|
||
},
|
||
"rewardText": "完成后可以领取报酬。",
|
||
"steps": [{
|
||
"id": format!("{next_id}-step-1"),
|
||
"title": "查清线索",
|
||
"kind": "inspect_treasure",
|
||
"requiredCount": 1,
|
||
"progress": 0,
|
||
"revealText": "先去断桥口附近看看留下了什么痕迹。",
|
||
"completeText": "线索已经查清。"
|
||
}],
|
||
"activeStepId": format!("{next_id}-step-1")
|
||
})
|
||
}
|
||
|
||
fn find_active_quest_for_issuer<'a>(
|
||
game_state: &'a Value,
|
||
issuer_npc_id: &str,
|
||
) -> Option<&'a Value> {
|
||
read_array_field(game_state, "quests")
|
||
.into_iter()
|
||
.find(|quest| {
|
||
read_optional_string_field(quest, "issuerNpcId").as_deref() == Some(issuer_npc_id)
|
||
&& read_optional_string_field(quest, "status")
|
||
.is_some_and(|status| status != "turned_in")
|
||
})
|
||
}
|
||
|
||
fn push_quest_record(game_state: &mut Value, quest: &Value) {
|
||
let root = ensure_json_object(game_state);
|
||
let quests = root
|
||
.entry("quests".to_string())
|
||
.or_insert_with(|| Value::Array(Vec::new()));
|
||
if !quests.is_array() {
|
||
*quests = Value::Array(Vec::new());
|
||
}
|
||
quests
|
||
.as_array_mut()
|
||
.expect("quests should be array")
|
||
.push(quest.clone());
|
||
}
|
||
|
||
fn first_quest_reveal_text(quest: &Value) -> Option<String> {
|
||
read_array_field(quest, "steps")
|
||
.first()
|
||
.and_then(|step| read_optional_string_field(step, "revealText"))
|
||
}
|
||
|
||
fn build_quest_accept_result_text(quest: &Value) -> String {
|
||
let issuer_name =
|
||
read_optional_string_field(quest, "issuerNpcName").unwrap_or_else(|| "对方".to_string());
|
||
let title = read_optional_string_field(quest, "title").unwrap_or_else(|| "委托".to_string());
|
||
format!("你正式接下了 {issuer_name} 的委托「{title}」,接下来可以开始推进任务目标。")
|
||
}
|
||
|
||
fn turn_in_quest_record(
|
||
game_state: &mut Value,
|
||
issuer_npc_id: &str,
|
||
quest_id: &str,
|
||
) -> Result<Value, String> {
|
||
let root = ensure_json_object(game_state);
|
||
let quests = root
|
||
.entry("quests".to_string())
|
||
.or_insert_with(|| Value::Array(Vec::new()));
|
||
if !quests.is_array() {
|
||
*quests = Value::Array(Vec::new());
|
||
}
|
||
let quests = quests.as_array_mut().expect("quests should be array");
|
||
let Some(index) = quests.iter().position(|quest| {
|
||
read_optional_string_field(quest, "id").as_deref() == Some(quest_id)
|
||
&& read_optional_string_field(quest, "issuerNpcId").as_deref() == Some(issuer_npc_id)
|
||
}) else {
|
||
return Err("当前没有可交付的委托。".to_string());
|
||
};
|
||
|
||
let mut turned_in = quests[index].clone();
|
||
if read_optional_string_field(&turned_in, "status").as_deref() != Some("completed") {
|
||
return Err("这份委托还没有达到可交付状态。".to_string());
|
||
}
|
||
if let Some(object) = turned_in.as_object_mut() {
|
||
object.insert("status".to_string(), Value::String("turned_in".to_string()));
|
||
object.insert("completionNotified".to_string(), Value::Bool(true));
|
||
if let Some(steps) = object.get_mut("steps").and_then(Value::as_array_mut) {
|
||
for step in steps.iter_mut() {
|
||
let required_count = read_i32_field(step, "requiredCount").unwrap_or(0);
|
||
if let Some(step_object) = step.as_object_mut() {
|
||
step_object.insert("progress".to_string(), json!(required_count.max(0)));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
quests[index] = turned_in.clone();
|
||
Ok(turned_in)
|
||
}
|
||
|
||
fn build_quest_turn_in_result_text(quest: &Value) -> String {
|
||
let title = read_optional_string_field(quest, "title").unwrap_or_else(|| "委托".to_string());
|
||
let reward_text = read_optional_string_field(quest, "rewardText")
|
||
.unwrap_or_else(|| "报酬已经结清。".to_string());
|
||
format!("你已经完成并交付了「{title}」。{reward_text}")
|
||
}
|
||
|
||
fn apply_quest_turn_in_rewards(game_state: &mut Value, quest: &Value) {
|
||
let Some(reward) = read_field(quest, "reward") else {
|
||
return;
|
||
};
|
||
|
||
let currency = read_i32_field(reward, "currency").unwrap_or(0).max(0);
|
||
if currency > 0 {
|
||
add_player_currency(game_state, currency);
|
||
}
|
||
|
||
let reward_items = read_array_field(reward, "items")
|
||
.into_iter()
|
||
.cloned()
|
||
.collect::<Vec<_>>();
|
||
if !reward_items.is_empty() {
|
||
add_player_inventory_items(game_state, reward_items);
|
||
}
|
||
|
||
let experience = read_i32_field(reward, "experience").unwrap_or(0).max(0);
|
||
if experience > 0 {
|
||
grant_player_progression_experience(game_state, experience, "quest");
|
||
}
|
||
}
|
||
|
||
fn infer_option_scope(function_id: &str) -> &'static str {
|
||
if function_id.starts_with("battle_") || function_id == "inventory_use" {
|
||
"combat"
|
||
} else if function_id.starts_with("npc_") {
|
||
"npc"
|
||
} else {
|
||
"story"
|
||
}
|
||
}
|
||
|
||
fn build_legacy_current_story(story_text: &str, options: &[RuntimeStoryOptionView]) -> Value {
|
||
json!({
|
||
"text": story_text,
|
||
"options": options.iter().map(build_story_option_from_runtime_option).collect::<Vec<_>>(),
|
||
"streaming": false
|
||
})
|
||
}
|
||
|
||
fn build_story_option_from_runtime_option(option: &RuntimeStoryOptionView) -> Value {
|
||
json!({
|
||
"functionId": option.function_id,
|
||
"actionText": option.action_text,
|
||
"text": option.action_text,
|
||
"detailText": option.detail_text,
|
||
"visuals": {
|
||
"playerAnimation": "idle",
|
||
"playerMoveMeters": 0,
|
||
"playerOffsetY": 0,
|
||
"playerFacing": "right",
|
||
"scrollWorld": false,
|
||
"monsterChanges": []
|
||
},
|
||
"interaction": option.interaction,
|
||
"runtimePayload": option.payload,
|
||
"disabled": option.disabled,
|
||
"disabledReason": option.reason,
|
||
})
|
||
}
|
||
|
||
fn read_story_text(current_story: Option<&Value>) -> Option<String> {
|
||
current_story.and_then(|story| read_optional_string_field(story, "text"))
|
||
}
|
||
|
||
fn build_fallback_story_text(game_state: &Value) -> String {
|
||
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
|
||
let encounter_name = read_object_field(game_state, "currentEncounter")
|
||
.and_then(|encounter| read_optional_string_field(encounter, "npcName"))
|
||
.unwrap_or_else(|| "眼前的敌人".to_string());
|
||
return format!("战斗还没有结束,{encounter_name} 仍在逼你立刻做出下一步判断。");
|
||
}
|
||
|
||
if let Some(encounter) = read_object_field(game_state, "currentEncounter")
|
||
&& let Some(npc_name) = read_optional_string_field(encounter, "npcName")
|
||
{
|
||
return format!("{npc_name} 正在等你表态,当前局势已经可以继续推进。");
|
||
}
|
||
|
||
"当前故事状态已经同步到兼容状态桥,可以继续推进这一轮运行时动作。".to_string()
|
||
}
|
||
|
||
fn resolve_action_text(default_text: &str, request: &RuntimeStoryActionRequest) -> String {
|
||
request
|
||
.action
|
||
.payload
|
||
.as_ref()
|
||
.and_then(|payload| read_optional_string_field(payload, "optionText"))
|
||
.unwrap_or_else(|| default_text.to_string())
|
||
}
|
||
|
||
fn build_status_patch(game_state: &Value) -> RuntimeStoryPatch {
|
||
RuntimeStoryPatch::StatusChanged {
|
||
in_battle: read_bool_field(game_state, "inBattle").unwrap_or(false),
|
||
npc_interaction_active: read_bool_field(game_state, "npcInteractionActive")
|
||
.unwrap_or(false),
|
||
current_npc_battle_mode: read_optional_string_field(game_state, "currentNpcBattleMode"),
|
||
current_npc_battle_outcome: read_optional_string_field(
|
||
game_state,
|
||
"currentNpcBattleOutcome",
|
||
),
|
||
}
|
||
}
|
||
|
||
fn restore_player_resource(game_state: &mut Value, hp_restore: i32, mana_restore: i32) {
|
||
let max_hp = read_i32_field(game_state, "playerMaxHp")
|
||
.unwrap_or(1)
|
||
.max(1);
|
||
let max_mana = read_i32_field(game_state, "playerMaxMana")
|
||
.unwrap_or(0)
|
||
.max(0);
|
||
let hp = read_i32_field(game_state, "playerHp").unwrap_or(max_hp);
|
||
let mana = read_i32_field(game_state, "playerMana").unwrap_or(max_mana);
|
||
write_i32_field(game_state, "playerHp", (hp + hp_restore).clamp(0, max_hp));
|
||
write_i32_field(
|
||
game_state,
|
||
"playerMana",
|
||
(mana + mana_restore).clamp(0, max_mana),
|
||
);
|
||
}
|
||
|
||
fn spend_player_mana(game_state: &mut Value, mana_cost: i32) {
|
||
if mana_cost <= 0 {
|
||
return;
|
||
}
|
||
let mana = read_i32_field(game_state, "playerMana").unwrap_or(0);
|
||
write_i32_field(game_state, "playerMana", (mana - mana_cost).max(0));
|
||
}
|
||
|
||
fn apply_player_damage(game_state: &mut Value, damage: i32) {
|
||
if damage <= 0 {
|
||
return;
|
||
}
|
||
let hp = read_i32_field(game_state, "playerHp").unwrap_or(1);
|
||
write_i32_field(game_state, "playerHp", (hp - damage).max(1));
|
||
}
|
||
|
||
fn apply_target_damage(game_state: &mut Value, damage: i32) -> i32 {
|
||
let target_hp = read_object_field(game_state, "currentEncounter")
|
||
.and_then(|encounter| {
|
||
read_i32_field(encounter, "hp")
|
||
.or_else(|| read_i32_field(encounter, "currentHp"))
|
||
.or_else(|| read_i32_field(encounter, "targetHp"))
|
||
})
|
||
.or_else(|| {
|
||
read_array_field(game_state, "sceneHostileNpcs")
|
||
.first()
|
||
.and_then(|target| read_i32_field(target, "hp"))
|
||
})
|
||
.unwrap_or(24);
|
||
let next_hp = target_hp - damage.max(0);
|
||
write_current_encounter_i32_field(game_state, "hp", next_hp);
|
||
write_first_hostile_npc_i32_field(game_state, "hp", next_hp);
|
||
|
||
next_hp
|
||
}
|
||
|
||
fn battle_action_numbers(
|
||
function_id: &str,
|
||
) -> (i32, i32, i32, i32, i32, &'static str, &'static str) {
|
||
match function_id {
|
||
"battle_recover_breath" => (
|
||
0,
|
||
0,
|
||
8,
|
||
6,
|
||
0,
|
||
"恢复",
|
||
"你先稳住呼吸,把状态从危险边缘拉回一点。",
|
||
),
|
||
"battle_use_skill" => (
|
||
14,
|
||
4,
|
||
0,
|
||
0,
|
||
4,
|
||
"施放技能",
|
||
"你调动灵力打出一记更重的攻势,同时也承受了对方的反扑。",
|
||
),
|
||
"battle_all_in_crush" => (
|
||
22,
|
||
8,
|
||
0,
|
||
0,
|
||
6,
|
||
"全力压制",
|
||
"你把这一轮节奏全部压上去,试图用最强硬的方式打穿对方防线。",
|
||
),
|
||
"battle_feint_step" => (
|
||
6,
|
||
2,
|
||
0,
|
||
0,
|
||
0,
|
||
"佯攻换位",
|
||
"你用一次短促佯攻换开角度,虽然伤害有限,但避开了更重的反击。",
|
||
),
|
||
"battle_finisher_window" => (
|
||
18,
|
||
3,
|
||
0,
|
||
0,
|
||
3,
|
||
"抓住终结窗口",
|
||
"你抓住破绽打出决定性的一击,战斗天平明显向你倾斜。",
|
||
),
|
||
"battle_guard_break" => (
|
||
12,
|
||
5,
|
||
0,
|
||
0,
|
||
2,
|
||
"破开防守",
|
||
"你顶住压力破开对方防守,为后续行动争到更直接的窗口。",
|
||
),
|
||
"battle_probe_pressure" => (
|
||
5,
|
||
1,
|
||
0,
|
||
0,
|
||
0,
|
||
"试探压迫",
|
||
"你没有贸然压上,而是用轻攻测试对方反应。",
|
||
),
|
||
_ => (
|
||
10,
|
||
4,
|
||
0,
|
||
0,
|
||
0,
|
||
"普通攻击",
|
||
"你抓住当前窗口打出一记直接攻击,对方也立刻做出反击。",
|
||
),
|
||
}
|
||
}
|
||
|
||
fn battle_mode_text(value: &str) -> &'static str {
|
||
if value == "spar" { "切磋" } else { "战斗" }
|
||
}
|
||
|
||
fn current_encounter_name(game_state: &Value) -> String {
|
||
read_object_field(game_state, "currentEncounter")
|
||
.and_then(|encounter| {
|
||
read_optional_string_field(encounter, "npcName")
|
||
.or_else(|| read_optional_string_field(encounter, "name"))
|
||
})
|
||
.unwrap_or_else(|| "对方".to_string())
|
||
}
|
||
|
||
fn current_encounter_name_from_battle(game_state: &Value) -> String {
|
||
read_object_field(game_state, "currentEncounter")
|
||
.and_then(|encounter| {
|
||
read_optional_string_field(encounter, "npcName")
|
||
.or_else(|| read_optional_string_field(encounter, "name"))
|
||
})
|
||
.or_else(|| first_hostile_npc_string_field(game_state, "name"))
|
||
.unwrap_or_else(|| "眼前的敌人".to_string())
|
||
}
|
||
|
||
fn current_encounter_id(game_state: &Value) -> Option<String> {
|
||
read_object_field(game_state, "currentEncounter")
|
||
.and_then(|encounter| read_optional_string_field(encounter, "id"))
|
||
}
|
||
|
||
fn adjust_current_npc_affinity(game_state: &mut Value, delta: i32) -> Option<(String, i32, i32)> {
|
||
let npc_id = current_encounter_id(game_state)?;
|
||
let npc_name = current_encounter_name(game_state);
|
||
let state = ensure_npc_state_object(game_state, npc_id.as_str(), npc_name.as_str());
|
||
let previous_affinity = state
|
||
.get("affinity")
|
||
.and_then(Value::as_i64)
|
||
.and_then(|value| i32::try_from(value).ok())
|
||
.unwrap_or(0);
|
||
let next_affinity = (previous_affinity + delta).clamp(-100, 100);
|
||
state.insert("affinity".to_string(), json!(next_affinity));
|
||
state
|
||
.entry("recruited".to_string())
|
||
.or_insert(Value::Bool(false));
|
||
|
||
Some((npc_id, previous_affinity, next_affinity))
|
||
}
|
||
|
||
fn read_current_npc_state_i32_field(game_state: &Value, key: &str) -> Option<i32> {
|
||
let npc_id = current_encounter_id(game_state)?;
|
||
let npc_name = current_encounter_name(game_state);
|
||
resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str())
|
||
.and_then(|state| read_i32_field(state, key))
|
||
}
|
||
|
||
fn read_current_npc_state_bool_field(game_state: &Value, key: &str) -> Option<bool> {
|
||
let npc_id = current_encounter_id(game_state)?;
|
||
let npc_name = current_encounter_name(game_state);
|
||
resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str())
|
||
.and_then(|state| read_bool_field(state, key))
|
||
}
|
||
|
||
fn write_current_npc_state_i32_field(game_state: &mut Value, key: &str, value: i32) {
|
||
let Some(npc_id) = current_encounter_id(game_state) else {
|
||
return;
|
||
};
|
||
let npc_name = current_encounter_name(game_state);
|
||
let state = ensure_npc_state_object(game_state, npc_id.as_str(), npc_name.as_str());
|
||
state.insert(key.to_string(), json!(value));
|
||
}
|
||
|
||
fn write_current_npc_state_bool_field(game_state: &mut Value, key: &str, value: bool) {
|
||
let Some(npc_id) = current_encounter_id(game_state) else {
|
||
return;
|
||
};
|
||
let npc_name = current_encounter_name(game_state);
|
||
let state = ensure_npc_state_object(game_state, npc_id.as_str(), npc_name.as_str());
|
||
state.insert(key.to_string(), Value::Bool(value));
|
||
}
|
||
|
||
fn set_current_npc_recruited(game_state: &mut Value, recruited: bool) -> Option<(i32, i32)> {
|
||
let npc_id = current_encounter_id(game_state)?;
|
||
let npc_name = current_encounter_name(game_state);
|
||
let state = ensure_npc_state_object(game_state, npc_id.as_str(), npc_name.as_str());
|
||
let previous_affinity = state
|
||
.get("affinity")
|
||
.and_then(Value::as_i64)
|
||
.and_then(|value| i32::try_from(value).ok())
|
||
.unwrap_or(0);
|
||
let next_affinity = previous_affinity.max(60);
|
||
state.insert("affinity".to_string(), json!(next_affinity));
|
||
state.insert("recruited".to_string(), Value::Bool(recruited));
|
||
|
||
Some((previous_affinity, next_affinity))
|
||
}
|
||
|
||
fn read_current_npc_affinity(game_state: &Value) -> i32 {
|
||
let Some(npc_id) = current_encounter_id(game_state) else {
|
||
return 0;
|
||
};
|
||
let npc_name = current_encounter_name(game_state);
|
||
resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str())
|
||
.and_then(|state| read_i32_field(state, "affinity"))
|
||
.unwrap_or(0)
|
||
}
|
||
|
||
fn ensure_npc_state_object<'a>(
|
||
game_state: &'a mut Value,
|
||
npc_id: &str,
|
||
npc_name: &str,
|
||
) -> &'a mut Map<String, Value> {
|
||
let root = ensure_json_object(game_state);
|
||
let npc_states = root
|
||
.entry("npcStates".to_string())
|
||
.or_insert_with(|| Value::Object(Map::new()));
|
||
if !npc_states.is_object() {
|
||
*npc_states = Value::Object(Map::new());
|
||
}
|
||
let states = npc_states
|
||
.as_object_mut()
|
||
.expect("npcStates should be object");
|
||
let existing_key = if states.contains_key(npc_id) {
|
||
npc_id.to_string()
|
||
} else if states.contains_key(npc_name) {
|
||
npc_name.to_string()
|
||
} else {
|
||
npc_id.to_string()
|
||
};
|
||
let state = states
|
||
.entry(existing_key)
|
||
.or_insert_with(|| Value::Object(Map::new()));
|
||
if !state.is_object() {
|
||
*state = Value::Object(Map::new());
|
||
}
|
||
state.as_object_mut().expect("npc state should be object")
|
||
}
|
||
|
||
fn add_companion_if_absent(
|
||
game_state: &mut Value,
|
||
npc_id: &str,
|
||
character_id: Option<String>,
|
||
joined_at_affinity: i32,
|
||
) {
|
||
let root = ensure_json_object(game_state);
|
||
let companions = root
|
||
.entry("companions".to_string())
|
||
.or_insert_with(|| Value::Array(Vec::new()));
|
||
if !companions.is_array() {
|
||
*companions = Value::Array(Vec::new());
|
||
}
|
||
let items = companions
|
||
.as_array_mut()
|
||
.expect("companions should be array");
|
||
if items
|
||
.iter()
|
||
.any(|item| read_optional_string_field(item, "npcId").is_some_and(|value| value == npc_id))
|
||
{
|
||
return;
|
||
}
|
||
items.push(json!({
|
||
"npcId": npc_id,
|
||
"characterId": character_id,
|
||
"joinedAtAffinity": joined_at_affinity,
|
||
}));
|
||
}
|
||
|
||
fn remove_companion_by_npc_id(game_state: &mut Value, npc_id: &str) -> Option<Value> {
|
||
let root = ensure_json_object(game_state);
|
||
let companions = root
|
||
.entry("companions".to_string())
|
||
.or_insert_with(|| Value::Array(Vec::new()));
|
||
if !companions.is_array() {
|
||
*companions = Value::Array(Vec::new());
|
||
}
|
||
let items = companions
|
||
.as_array_mut()
|
||
.expect("companions should be array");
|
||
let index = items.iter().position(|item| {
|
||
read_optional_string_field(item, "npcId").is_some_and(|value| value == npc_id)
|
||
})?;
|
||
Some(items.remove(index))
|
||
}
|
||
|
||
fn recruit_companion_to_party(
|
||
game_state: &mut Value,
|
||
npc_id: &str,
|
||
_npc_name: &str,
|
||
release_npc_id: Option<&str>,
|
||
) -> Result<Option<String>, String> {
|
||
let companion_count = read_array_field(game_state, "companions").len();
|
||
if companion_count < MAX_TASK5_COMPANIONS {
|
||
add_companion_if_absent(
|
||
game_state,
|
||
npc_id,
|
||
None,
|
||
read_current_npc_affinity(game_state),
|
||
);
|
||
return Ok(None);
|
||
}
|
||
|
||
let Some(release_npc_id) = release_npc_id.and_then(normalize_required_string) else {
|
||
return Err("队伍已满时必须明确指定一名离队同伴".to_string());
|
||
};
|
||
|
||
let released_companion = remove_companion_by_npc_id(game_state, release_npc_id.as_str())
|
||
.ok_or_else(|| "指定的离队同伴不存在,无法完成换队招募".to_string())?;
|
||
let released_name = read_optional_string_field(&released_companion, "displayName")
|
||
.or_else(|| read_optional_string_field(&released_companion, "name"))
|
||
.or_else(|| read_optional_string_field(&released_companion, "npcName"))
|
||
.unwrap_or_else(|| release_npc_id.clone());
|
||
add_companion_if_absent(
|
||
game_state,
|
||
npc_id,
|
||
None,
|
||
read_current_npc_affinity(game_state),
|
||
);
|
||
Ok(Some(released_name))
|
||
}
|
||
|
||
fn clear_encounter_state(game_state: &mut Value) {
|
||
clear_encounter_only(game_state);
|
||
write_bool_field(game_state, "inBattle", false);
|
||
write_bool_field(game_state, "npcInteractionActive", false);
|
||
write_null_field(game_state, "currentNpcBattleMode");
|
||
}
|
||
|
||
fn clear_encounter_only(game_state: &mut Value) {
|
||
write_null_field(game_state, "currentEncounter");
|
||
let root = ensure_json_object(game_state);
|
||
root.insert("sceneHostileNpcs".to_string(), Value::Array(Vec::new()));
|
||
}
|
||
|
||
fn append_story_history(game_state: &mut Value, action_text: &str, result_text: &str) {
|
||
let root = ensure_json_object(game_state);
|
||
let story_history = root
|
||
.entry("storyHistory".to_string())
|
||
.or_insert_with(|| Value::Array(Vec::new()));
|
||
if !story_history.is_array() {
|
||
*story_history = Value::Array(Vec::new());
|
||
}
|
||
let entries = story_history
|
||
.as_array_mut()
|
||
.expect("storyHistory should be array");
|
||
entries.push(json!({
|
||
"text": action_text,
|
||
"historyRole": "action",
|
||
}));
|
||
entries.push(json!({
|
||
"text": result_text,
|
||
"historyRole": "result",
|
||
}));
|
||
}
|
||
|
||
fn increment_runtime_stat(game_state: &mut Value, key: &str, delta: i32) {
|
||
let root = ensure_json_object(game_state);
|
||
let stats = root
|
||
.entry("runtimeStats".to_string())
|
||
.or_insert_with(|| Value::Object(Map::new()));
|
||
if !stats.is_object() {
|
||
*stats = Value::Object(Map::new());
|
||
}
|
||
let stats = stats
|
||
.as_object_mut()
|
||
.expect("runtimeStats should be object");
|
||
let previous = stats
|
||
.get(key)
|
||
.and_then(Value::as_i64)
|
||
.and_then(|value| i32::try_from(value).ok())
|
||
.unwrap_or(0);
|
||
stats.insert(key.to_string(), json!((previous + delta).max(0)));
|
||
}
|
||
|
||
fn add_player_currency(game_state: &mut Value, delta: i32) {
|
||
let previous = read_i32_field(game_state, "playerCurrency").unwrap_or(0);
|
||
write_i32_field(
|
||
game_state,
|
||
"playerCurrency",
|
||
previous.saturating_add(delta.max(0)),
|
||
);
|
||
}
|
||
|
||
fn add_player_inventory_items(game_state: &mut Value, additions: Vec<Value>) {
|
||
if additions.is_empty() {
|
||
return;
|
||
}
|
||
|
||
let root = ensure_json_object(game_state);
|
||
let inventory = root
|
||
.entry("playerInventory".to_string())
|
||
.or_insert_with(|| Value::Array(Vec::new()));
|
||
if !inventory.is_array() {
|
||
*inventory = Value::Array(Vec::new());
|
||
}
|
||
let items = inventory
|
||
.as_array_mut()
|
||
.expect("playerInventory should be array");
|
||
items.extend(additions);
|
||
}
|
||
|
||
fn grant_player_progression_experience(game_state: &mut Value, amount: i32, source: &str) {
|
||
if amount <= 0 {
|
||
return;
|
||
}
|
||
|
||
let root = ensure_json_object(game_state);
|
||
let progression = root
|
||
.entry("playerProgression".to_string())
|
||
.or_insert_with(|| Value::Object(Map::new()));
|
||
if !progression.is_object() {
|
||
*progression = Value::Object(Map::new());
|
||
}
|
||
let progression = progression
|
||
.as_object_mut()
|
||
.expect("playerProgression should be object");
|
||
let previous_total_xp = progression
|
||
.get("totalXp")
|
||
.and_then(Value::as_i64)
|
||
.and_then(|value| i32::try_from(value).ok())
|
||
.unwrap_or(0)
|
||
.max(0);
|
||
let next_total_xp = previous_total_xp.saturating_add(amount);
|
||
let level = resolve_progression_level(next_total_xp);
|
||
let current_level_xp = next_total_xp.saturating_sub(cumulative_xp_required(level));
|
||
let xp_to_next_level = if level >= MAX_PLAYER_LEVEL {
|
||
0
|
||
} else {
|
||
xp_to_next_level_for(level)
|
||
};
|
||
|
||
progression.insert("level".to_string(), json!(level));
|
||
progression.insert("currentLevelXp".to_string(), json!(current_level_xp.max(0)));
|
||
progression.insert("totalXp".to_string(), json!(next_total_xp));
|
||
progression.insert("xpToNextLevel".to_string(), json!(xp_to_next_level.max(0)));
|
||
progression.insert("pendingLevelUps".to_string(), json!(0));
|
||
progression.insert(
|
||
"lastGrantedSource".to_string(),
|
||
Value::String(source.to_string()),
|
||
);
|
||
}
|
||
|
||
const MAX_PLAYER_LEVEL: i32 = 20;
|
||
|
||
fn xp_to_next_level_for(level: i32) -> i32 {
|
||
if level >= MAX_PLAYER_LEVEL {
|
||
0
|
||
} else {
|
||
let scale = (level - 1).max(0);
|
||
60 + 20 * scale + 8 * scale * scale
|
||
}
|
||
}
|
||
|
||
fn cumulative_xp_required(level: i32) -> i32 {
|
||
let mut total = 0;
|
||
let capped_level = level.clamp(1, MAX_PLAYER_LEVEL);
|
||
for current_level in 1..capped_level {
|
||
total += xp_to_next_level_for(current_level);
|
||
}
|
||
total
|
||
}
|
||
|
||
fn resolve_progression_level(total_xp: i32) -> i32 {
|
||
let normalized_total_xp = total_xp.max(0);
|
||
let mut resolved_level = 1;
|
||
for level in 2..=MAX_PLAYER_LEVEL {
|
||
if normalized_total_xp < cumulative_xp_required(level) {
|
||
break;
|
||
}
|
||
resolved_level = level;
|
||
}
|
||
resolved_level
|
||
}
|
||
|
||
fn write_current_encounter_i32_field(game_state: &mut Value, key: &str, value: i32) {
|
||
let root = ensure_json_object(game_state);
|
||
let Some(encounter) = root.get_mut("currentEncounter") else {
|
||
return;
|
||
};
|
||
if let Some(encounter) = encounter.as_object_mut() {
|
||
encounter.insert(key.to_string(), json!(value));
|
||
}
|
||
}
|
||
|
||
fn write_first_hostile_npc_i32_field(game_state: &mut Value, key: &str, value: i32) {
|
||
let root = ensure_json_object(game_state);
|
||
let Some(hostiles) = root.get_mut("sceneHostileNpcs") else {
|
||
return;
|
||
};
|
||
let Some(first) = hostiles.as_array_mut().and_then(|items| items.first_mut()) else {
|
||
return;
|
||
};
|
||
if let Some(first) = first.as_object_mut() {
|
||
first.insert(key.to_string(), json!(value));
|
||
}
|
||
}
|
||
|
||
fn first_hostile_npc_string_field(game_state: &Value, key: &str) -> Option<String> {
|
||
read_array_field(game_state, "sceneHostileNpcs")
|
||
.first()
|
||
.and_then(|target| read_optional_string_field(target, key))
|
||
}
|
||
|
||
fn read_runtime_session_id(game_state: &Value) -> Option<String> {
|
||
read_optional_string_field(game_state, "runtimeSessionId")
|
||
}
|
||
|
||
fn read_field<'a>(value: &'a Value, key: &str) -> Option<&'a Value> {
|
||
value.as_object()?.get(key)
|
||
}
|
||
|
||
fn read_object_field<'a>(value: &'a Value, key: &str) -> Option<&'a Value> {
|
||
let field = read_field(value, key)?;
|
||
field.is_object().then_some(field)
|
||
}
|
||
|
||
fn read_array_field<'a>(value: &'a Value, key: &str) -> Vec<&'a Value> {
|
||
read_field(value, key)
|
||
.and_then(Value::as_array)
|
||
.map(|items| items.iter().collect())
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
fn read_required_string_field(value: &Value, key: &str) -> Option<String> {
|
||
normalize_required_string(read_field(value, key)?.as_str()?)
|
||
}
|
||
|
||
fn read_optional_string_field(value: &Value, key: &str) -> Option<String> {
|
||
normalize_optional_string(read_field(value, key).and_then(Value::as_str))
|
||
}
|
||
|
||
fn read_bool_field(value: &Value, key: &str) -> Option<bool> {
|
||
read_field(value, key).and_then(Value::as_bool)
|
||
}
|
||
|
||
fn read_i32_field(value: &Value, key: &str) -> Option<i32> {
|
||
read_field(value, key)
|
||
.and_then(Value::as_i64)
|
||
.and_then(|number| i32::try_from(number).ok())
|
||
}
|
||
|
||
fn read_u32_field(value: &Value, key: &str) -> Option<u32> {
|
||
read_field(value, key)
|
||
.and_then(Value::as_u64)
|
||
.and_then(|number| u32::try_from(number).ok())
|
||
}
|
||
|
||
fn write_i32_field(value: &mut Value, key: &str, field_value: i32) {
|
||
ensure_json_object(value).insert(key.to_string(), json!(field_value));
|
||
}
|
||
|
||
fn write_u32_field(value: &mut Value, key: &str, field_value: u32) {
|
||
ensure_json_object(value).insert(key.to_string(), json!(field_value));
|
||
}
|
||
|
||
fn write_bool_field(value: &mut Value, key: &str, field_value: bool) {
|
||
ensure_json_object(value).insert(key.to_string(), Value::Bool(field_value));
|
||
}
|
||
|
||
fn write_string_field(value: &mut Value, key: &str, field_value: &str) {
|
||
ensure_json_object(value).insert(key.to_string(), Value::String(field_value.to_string()));
|
||
}
|
||
|
||
fn write_null_field(value: &mut Value, key: &str) {
|
||
ensure_json_object(value).insert(key.to_string(), Value::Null);
|
||
}
|
||
|
||
fn ensure_json_object(value: &mut Value) -> &mut Map<String, Value> {
|
||
if !value.is_object() {
|
||
*value = Value::Object(Map::new());
|
||
}
|
||
value.as_object_mut().expect("value should be object")
|
||
}
|
||
|
||
fn normalize_required_string(value: &str) -> Option<String> {
|
||
let trimmed = value.trim();
|
||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||
}
|
||
|
||
fn normalize_optional_string(value: Option<&str>) -> Option<String> {
|
||
value.and_then(normalize_required_string)
|
||
}
|
||
|
||
fn format_now_rfc3339() -> String {
|
||
format_rfc3339(OffsetDateTime::now_utc()).unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
|
||
}
|
||
|
||
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))
|
||
}
|
||
|
||
#[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 super::*;
|
||
use crate::{app::build_router, config::AppConfig, state::AppState};
|
||
|
||
#[tokio::test]
|
||
async fn runtime_story_state_resolve_requires_authentication() {
|
||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||
|
||
let response = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/runtime/story/state/resolve")
|
||
.header("content-type", "application/json")
|
||
.body(Body::from(
|
||
json!({
|
||
"sessionId": "runtime-main",
|
||
"snapshot": {
|
||
"bottomTab": "adventure",
|
||
"gameState": {
|
||
"runtimeSessionId": "runtime-main"
|
||
},
|
||
"currentStory": null
|
||
}
|
||
})
|
||
.to_string(),
|
||
))
|
||
.expect("request should build"),
|
||
)
|
||
.await
|
||
.expect("request should succeed");
|
||
|
||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn runtime_story_state_get_requires_authentication() {
|
||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||
|
||
let response = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("GET")
|
||
.uri("/api/runtime/story/state/runtime-main")
|
||
.body(Body::empty())
|
||
.expect("request should build"),
|
||
)
|
||
.await
|
||
.expect("request should succeed");
|
||
|
||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn runtime_story_action_resolve_requires_authentication() {
|
||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||
|
||
let response = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/runtime/story/actions/resolve")
|
||
.header("content-type", "application/json")
|
||
.body(Body::from(
|
||
json!({
|
||
"sessionId": "runtime-main",
|
||
"action": {
|
||
"type": "story_choice",
|
||
"functionId": "idle_rest_focus"
|
||
}
|
||
})
|
||
.to_string(),
|
||
))
|
||
.expect("request should build"),
|
||
)
|
||
.await
|
||
.expect("request should succeed");
|
||
|
||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn runtime_story_routes_resolve_through_rust_route_boundary() {
|
||
let state = seed_authenticated_state().await;
|
||
let token = issue_access_token(&state);
|
||
let app = build_router(state);
|
||
let snapshot_payload = json!({
|
||
"bottomTab": "adventure",
|
||
"gameState": build_runtime_story_boundary_game_state_fixture(),
|
||
"currentStory": {
|
||
"text": "巡路人看着你,像在等一句开口。",
|
||
"options": []
|
||
}
|
||
});
|
||
|
||
let put_response = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("PUT")
|
||
.uri("/api/runtime/save/snapshot")
|
||
.header("authorization", format!("Bearer {token}"))
|
||
.header("content-type", "application/json")
|
||
.header("x-genarrative-response-envelope", "v1")
|
||
.body(Body::from(snapshot_payload.to_string()))
|
||
.expect("request should build"),
|
||
)
|
||
.await
|
||
.expect("request should succeed");
|
||
assert_eq!(put_response.status(), StatusCode::OK);
|
||
|
||
let state_response = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("GET")
|
||
.uri("/api/runtime/story/state/runtime-main")
|
||
.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!(state_response.status(), StatusCode::OK);
|
||
let state_payload: Value = serde_json::from_slice(
|
||
&state_response
|
||
.into_body()
|
||
.collect()
|
||
.await
|
||
.expect("body should collect")
|
||
.to_bytes(),
|
||
)
|
||
.expect("response should be json");
|
||
assert!(
|
||
state_payload["data"]["viewModel"]["availableOptions"]
|
||
.as_array()
|
||
.is_some_and(|options| options
|
||
.iter()
|
||
.any(|option| { option["functionId"] == json!("npc_chat") }))
|
||
);
|
||
|
||
let action_response = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/runtime/story/actions/resolve")
|
||
.header("authorization", format!("Bearer {token}"))
|
||
.header("content-type", "application/json")
|
||
.header("x-genarrative-response-envelope", "v1")
|
||
.body(Body::from(
|
||
json!({
|
||
"sessionId": "runtime-main",
|
||
"clientVersion": 0,
|
||
"action": {
|
||
"type": "story_choice",
|
||
"functionId": "npc_chat"
|
||
}
|
||
})
|
||
.to_string(),
|
||
))
|
||
.expect("request should build"),
|
||
)
|
||
.await
|
||
.expect("request should succeed");
|
||
assert_eq!(action_response.status(), StatusCode::OK);
|
||
let action_payload: Value = serde_json::from_slice(
|
||
&action_response
|
||
.into_body()
|
||
.collect()
|
||
.await
|
||
.expect("body should collect")
|
||
.to_bytes(),
|
||
)
|
||
.expect("response should be json");
|
||
assert_eq!(action_payload["data"]["serverVersion"], json!(1));
|
||
assert_eq!(
|
||
action_payload["data"]["viewModel"]["encounter"]["affinity"],
|
||
json!(52)
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn runtime_story_action_resolve_rejects_client_version_conflict() {
|
||
let state = seed_authenticated_state().await;
|
||
let token = issue_access_token(&state);
|
||
let app = build_router(state);
|
||
|
||
let put_response = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("PUT")
|
||
.uri("/api/runtime/save/snapshot")
|
||
.header("authorization", format!("Bearer {token}"))
|
||
.header("content-type", "application/json")
|
||
.header("x-genarrative-response-envelope", "v1")
|
||
.body(Body::from(
|
||
json!({
|
||
"bottomTab": "adventure",
|
||
"gameState": {
|
||
"runtimeSessionId": "runtime-main",
|
||
"runtimeActionVersion": 5,
|
||
"playerHp": 20,
|
||
"playerMaxHp": 30,
|
||
"playerMana": 4,
|
||
"playerMaxMana": 12,
|
||
"storyHistory": []
|
||
},
|
||
"currentStory": {
|
||
"text": "旧局势仍然悬着。",
|
||
"options": []
|
||
}
|
||
})
|
||
.to_string(),
|
||
))
|
||
.expect("request should build"),
|
||
)
|
||
.await
|
||
.expect("request should succeed");
|
||
assert_eq!(put_response.status(), StatusCode::OK);
|
||
|
||
let response = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/runtime/story/actions/resolve")
|
||
.header("authorization", format!("Bearer {token}"))
|
||
.header("content-type", "application/json")
|
||
.header("x-genarrative-response-envelope", "v1")
|
||
.body(Body::from(
|
||
json!({
|
||
"sessionId": "runtime-main",
|
||
"clientVersion": 4,
|
||
"action": {
|
||
"type": "story_choice",
|
||
"functionId": "idle_rest_focus"
|
||
}
|
||
})
|
||
.to_string(),
|
||
))
|
||
.expect("request should build"),
|
||
)
|
||
.await
|
||
.expect("request should succeed");
|
||
|
||
assert_eq!(response.status(), StatusCode::CONFLICT);
|
||
let payload: Value = serde_json::from_slice(
|
||
&response
|
||
.into_body()
|
||
.collect()
|
||
.await
|
||
.expect("body should collect")
|
||
.to_bytes(),
|
||
)
|
||
.expect("response should be json");
|
||
assert_eq!(payload["error"]["details"]["clientVersion"], json!(4));
|
||
assert_eq!(payload["error"]["details"]["serverVersion"], json!(5));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn runtime_story_initial_returns_fallback_without_llm() {
|
||
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/runtime/story/initial")
|
||
.header("authorization", format!("Bearer {token}"))
|
||
.header("content-type", "application/json")
|
||
.header("x-genarrative-response-envelope", "v1")
|
||
.body(Body::from(
|
||
json!({
|
||
"worldType": "martial",
|
||
"character": { "name": "林迟" },
|
||
"monsters": [],
|
||
"context": { "sceneName": "旧驿道" },
|
||
"requestOptions": {
|
||
"availableOptions": [{
|
||
"functionId": "idle_observe_signs",
|
||
"actionText": "观察周围迹象"
|
||
}]
|
||
}
|
||
})
|
||
.to_string(),
|
||
))
|
||
.expect("request should build"),
|
||
)
|
||
.await
|
||
.expect("request should succeed");
|
||
|
||
assert_eq!(response.status(), StatusCode::OK);
|
||
|
||
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(true));
|
||
assert_eq!(
|
||
payload["data"]["options"][0]["functionId"],
|
||
json!("idle_observe_signs")
|
||
);
|
||
assert!(
|
||
payload["data"]["storyText"]
|
||
.as_str()
|
||
.is_some_and(|text| text.contains("林迟"))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn runtime_story_state_compiler_prefers_dialogue_deferred_options() {
|
||
let response = build_runtime_story_state_response(
|
||
"runtime-main",
|
||
Some(7),
|
||
RuntimeStorySnapshotPayload {
|
||
saved_at: None,
|
||
bottom_tab: "adventure".to_string(),
|
||
game_state: json!({
|
||
"runtimeSessionId": "runtime-main",
|
||
"runtimeActionVersion": 7,
|
||
"playerHp": 32,
|
||
"playerMaxHp": 40,
|
||
"playerMana": 18,
|
||
"playerMaxMana": 20,
|
||
"inBattle": false,
|
||
"npcInteractionActive": true,
|
||
"currentEncounter": {
|
||
"id": "npc_camp_firekeeper",
|
||
"kind": "npc",
|
||
"npcName": "守火人",
|
||
"hostile": false
|
||
},
|
||
"npcStates": {
|
||
"npc_camp_firekeeper": {
|
||
"affinity": 12,
|
||
"recruited": false
|
||
}
|
||
},
|
||
"companions": [{
|
||
"npcId": "npc_companion_001",
|
||
"characterId": "char_companion_001",
|
||
"joinedAtAffinity": 64
|
||
}]
|
||
}),
|
||
current_story: Some(json!({
|
||
"text": "守火人抬眼看了你一瞬,示意你把想问的话继续说完。",
|
||
"displayMode": "dialogue",
|
||
"options": [{
|
||
"functionId": "story_continue_adventure",
|
||
"actionText": "继续冒险"
|
||
}],
|
||
"deferredOptions": [{
|
||
"functionId": "npc_chat",
|
||
"actionText": "继续交谈",
|
||
"detailText": "围绕当前话题继续推进关系判断。",
|
||
"interaction": {
|
||
"kind": "npc",
|
||
"npcId": "npc_camp_firekeeper",
|
||
"action": "chat"
|
||
},
|
||
"runtimePayload": {
|
||
"note": "server-runtime-test"
|
||
}
|
||
}]
|
||
})),
|
||
},
|
||
);
|
||
|
||
assert_eq!(response.session_id, "runtime-main");
|
||
assert_eq!(response.server_version, 7);
|
||
assert_eq!(
|
||
response
|
||
.view_model
|
||
.encounter
|
||
.as_ref()
|
||
.expect("encounter should exist")
|
||
.npc_name,
|
||
"守火人"
|
||
);
|
||
assert_eq!(
|
||
response.view_model.available_options[0].function_id,
|
||
"npc_chat"
|
||
);
|
||
assert!(matches!(
|
||
response.presentation.options[0].interaction,
|
||
Some(RuntimeStoryOptionInteraction::Npc { .. })
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn runtime_story_action_resolution_updates_version_and_history() {
|
||
let request = RuntimeStoryActionRequest {
|
||
session_id: "runtime-main".to_string(),
|
||
client_version: Some(3),
|
||
action: shared_contracts::runtime_story::RuntimeStoryChoiceAction {
|
||
action_type: "story_choice".to_string(),
|
||
function_id: "idle_rest_focus".to_string(),
|
||
target_id: None,
|
||
payload: Some(json!({ "optionText": "原地调息" })),
|
||
},
|
||
snapshot: None,
|
||
};
|
||
let mut game_state = json!({
|
||
"runtimeSessionId": "runtime-main",
|
||
"runtimeActionVersion": 3,
|
||
"playerHp": 10,
|
||
"playerMaxHp": 30,
|
||
"playerMana": 2,
|
||
"playerMaxMana": 12,
|
||
"storyHistory": []
|
||
});
|
||
|
||
let resolution =
|
||
resolve_runtime_story_choice_action(&mut game_state, None, &request, "idle_rest_focus")
|
||
.expect("action should resolve");
|
||
let next_version = read_u32_field(&game_state, "runtimeActionVersion")
|
||
.unwrap_or(3)
|
||
.saturating_add(1);
|
||
write_u32_field(&mut game_state, "runtimeActionVersion", next_version);
|
||
append_story_history(
|
||
&mut game_state,
|
||
resolution.action_text.as_str(),
|
||
resolution.result_text.as_str(),
|
||
);
|
||
|
||
assert_eq!(read_i32_field(&game_state, "playerHp"), Some(18));
|
||
assert_eq!(read_i32_field(&game_state, "playerMana"), Some(8));
|
||
assert_eq!(read_u32_field(&game_state, "runtimeActionVersion"), Some(4));
|
||
assert_eq!(
|
||
read_array_field(&game_state, "storyHistory")
|
||
.first()
|
||
.and_then(|entry| read_optional_string_field(entry, "historyRole")),
|
||
Some("action".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn runtime_story_npc_help_is_one_shot_and_restores_resources() {
|
||
let request = RuntimeStoryActionRequest {
|
||
session_id: "runtime-main".to_string(),
|
||
client_version: Some(0),
|
||
action: shared_contracts::runtime_story::RuntimeStoryChoiceAction {
|
||
action_type: "story_choice".to_string(),
|
||
function_id: "npc_help".to_string(),
|
||
target_id: None,
|
||
payload: Some(json!({ "optionText": "请求援手" })),
|
||
},
|
||
snapshot: None,
|
||
};
|
||
let mut game_state = build_runtime_story_boundary_game_state_fixture();
|
||
write_i32_field(&mut game_state, "playerHp", 20);
|
||
write_i32_field(&mut game_state, "playerMana", 4);
|
||
|
||
let first =
|
||
resolve_runtime_story_choice_action(&mut game_state, None, &request, "npc_help")
|
||
.expect("first help should resolve");
|
||
|
||
assert!(first.result_text.contains("及时支援"));
|
||
assert_eq!(read_i32_field(&game_state, "playerHp"), Some(30));
|
||
assert_eq!(read_i32_field(&game_state, "playerMana"), Some(12));
|
||
assert_eq!(
|
||
read_current_npc_state_bool_field(&game_state, "helpUsed"),
|
||
Some(true)
|
||
);
|
||
|
||
let second =
|
||
resolve_runtime_story_choice_action(&mut game_state, None, &request, "npc_help");
|
||
match second {
|
||
Ok(_) => panic!("second help should be rejected"),
|
||
Err(error) => assert_eq!(error, "当前 NPC 的一次性援手已经用完了"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn runtime_story_npc_recruit_requires_threshold_and_release_target_when_party_full() {
|
||
let request = RuntimeStoryActionRequest {
|
||
session_id: "runtime-main".to_string(),
|
||
client_version: Some(0),
|
||
action: shared_contracts::runtime_story::RuntimeStoryChoiceAction {
|
||
action_type: "story_choice".to_string(),
|
||
function_id: "npc_recruit".to_string(),
|
||
target_id: None,
|
||
payload: Some(json!({ "optionText": "邀请同行" })),
|
||
},
|
||
snapshot: None,
|
||
};
|
||
|
||
let mut low_affinity_state = build_runtime_story_boundary_game_state_fixture();
|
||
let error = resolve_runtime_story_choice_action(
|
||
&mut low_affinity_state,
|
||
None,
|
||
&request,
|
||
"npc_recruit",
|
||
);
|
||
match error {
|
||
Ok(_) => panic!("low affinity recruit should be rejected"),
|
||
Err(message) => assert_eq!(message, "当前关系还没达到招募阈值,暂时不能邀请入队"),
|
||
}
|
||
|
||
let mut full_party_state = build_runtime_story_boundary_game_state_fixture();
|
||
write_current_npc_state_i32_field(&mut full_party_state, "affinity", 60);
|
||
let root = ensure_json_object(&mut full_party_state);
|
||
root.insert(
|
||
"companions".to_string(),
|
||
json!([
|
||
{
|
||
"npcId": "npc-ally-1",
|
||
"characterId": "char-ally-1",
|
||
"joinedAtAffinity": 64,
|
||
"npcName": "旧同伴甲"
|
||
},
|
||
{
|
||
"npcId": "npc-ally-2",
|
||
"characterId": "char-ally-2",
|
||
"joinedAtAffinity": 61,
|
||
"npcName": "旧同伴乙"
|
||
}
|
||
]),
|
||
);
|
||
|
||
let full_party_error = resolve_runtime_story_choice_action(
|
||
&mut full_party_state,
|
||
None,
|
||
&request,
|
||
"npc_recruit",
|
||
);
|
||
match full_party_error {
|
||
Ok(_) => panic!("full party recruit should require release target"),
|
||
Err(message) => assert_eq!(message, "队伍已满时必须明确指定一名离队同伴"),
|
||
}
|
||
|
||
let request_with_release = RuntimeStoryActionRequest {
|
||
action: shared_contracts::runtime_story::RuntimeStoryChoiceAction {
|
||
payload: Some(json!({
|
||
"optionText": "邀请同行",
|
||
"releaseNpcId": "npc-ally-1"
|
||
})),
|
||
..request.action.clone()
|
||
},
|
||
..request
|
||
};
|
||
let resolution = resolve_runtime_story_choice_action(
|
||
&mut full_party_state,
|
||
None,
|
||
&request_with_release,
|
||
"npc_recruit",
|
||
)
|
||
.expect("recruit with release target should resolve");
|
||
|
||
assert!(resolution.result_text.contains("旧同伴甲"));
|
||
assert_eq!(read_array_field(&full_party_state, "companions").len(), 2);
|
||
assert!(
|
||
read_array_field(&full_party_state, "companions")
|
||
.iter()
|
||
.any(|entry| {
|
||
read_optional_string_field(entry, "npcId").as_deref() == Some("npc_merchant_01")
|
||
})
|
||
);
|
||
assert_eq!(
|
||
read_field(&full_party_state, "currentEncounter"),
|
||
Some(&Value::Null)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn runtime_story_quest_offer_replace_updates_pending_offer_and_payload() {
|
||
let request = RuntimeStoryActionRequest {
|
||
session_id: "runtime-main".to_string(),
|
||
client_version: Some(0),
|
||
action: shared_contracts::runtime_story::RuntimeStoryChoiceAction {
|
||
action_type: "story_choice".to_string(),
|
||
function_id: "npc_chat_quest_offer_replace".to_string(),
|
||
target_id: None,
|
||
payload: Some(json!({
|
||
"optionText": "更换任务"
|
||
})),
|
||
},
|
||
snapshot: None,
|
||
};
|
||
let mut game_state = build_runtime_story_boundary_game_state_fixture();
|
||
let current_story = build_runtime_story_pending_quest_offer_fixture(
|
||
build_runtime_story_boundary_quest_fixture("quest-bridge-offer", "断桥口的密信"),
|
||
);
|
||
|
||
let resolution = resolve_runtime_story_choice_action(
|
||
&mut game_state,
|
||
Some(¤t_story),
|
||
&request,
|
||
"npc_chat_quest_offer_replace",
|
||
)
|
||
.expect("quest replace should resolve");
|
||
|
||
let saved_current_story = resolution
|
||
.saved_current_story
|
||
.expect("quest replace should save current story");
|
||
let pending_quest = read_field(&saved_current_story, "npcChatState")
|
||
.and_then(|state| read_field(state, "pendingQuestOffer"))
|
||
.and_then(|offer| read_field(offer, "quest"))
|
||
.expect("pending quest should exist after replace");
|
||
assert_eq!(
|
||
read_optional_string_field(pending_quest, "id"),
|
||
Some("quest-bridge-replaced".to_string())
|
||
);
|
||
|
||
let options = resolution
|
||
.presentation_options
|
||
.expect("quest replace should expose options");
|
||
assert_eq!(options.len(), 3);
|
||
assert_eq!(
|
||
options[1].payload.as_ref().and_then(|payload| {
|
||
read_optional_string_field(payload, "npcChatQuestOfferAction")
|
||
}),
|
||
Some("replace".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn runtime_story_quest_offer_abandon_clears_pending_offer_and_restores_chat_options() {
|
||
let request = RuntimeStoryActionRequest {
|
||
session_id: "runtime-main".to_string(),
|
||
client_version: Some(0),
|
||
action: shared_contracts::runtime_story::RuntimeStoryChoiceAction {
|
||
action_type: "story_choice".to_string(),
|
||
function_id: "npc_chat_quest_offer_abandon".to_string(),
|
||
target_id: None,
|
||
payload: Some(json!({
|
||
"optionText": "放弃任务"
|
||
})),
|
||
},
|
||
snapshot: None,
|
||
};
|
||
let mut game_state = build_runtime_story_boundary_game_state_fixture();
|
||
let current_story = build_runtime_story_pending_quest_offer_fixture(
|
||
build_runtime_story_boundary_quest_fixture("quest-bridge-offer", "断桥口的密信"),
|
||
);
|
||
|
||
let resolution = resolve_runtime_story_choice_action(
|
||
&mut game_state,
|
||
Some(¤t_story),
|
||
&request,
|
||
"npc_chat_quest_offer_abandon",
|
||
)
|
||
.expect("quest abandon should resolve");
|
||
|
||
let saved_current_story = resolution
|
||
.saved_current_story
|
||
.expect("quest abandon should save current story");
|
||
assert_eq!(
|
||
read_field(&saved_current_story, "npcChatState")
|
||
.and_then(|state| read_field(state, "pendingQuestOffer")),
|
||
Some(&Value::Null)
|
||
);
|
||
let options = resolution
|
||
.presentation_options
|
||
.expect("quest abandon should expose follow-up chat options");
|
||
assert_eq!(options.len(), 3);
|
||
assert!(
|
||
options
|
||
.iter()
|
||
.all(|option| option.function_id == "npc_chat")
|
||
);
|
||
assert_eq!(options[0].action_text, "那先继续聊聊你刚才没说完的部分");
|
||
}
|
||
|
||
#[test]
|
||
fn runtime_story_quest_accept_writes_quest_runtime_stats_and_followup_story() {
|
||
let request = RuntimeStoryActionRequest {
|
||
session_id: "runtime-main".to_string(),
|
||
client_version: Some(0),
|
||
action: shared_contracts::runtime_story::RuntimeStoryChoiceAction {
|
||
action_type: "story_choice".to_string(),
|
||
function_id: "npc_quest_accept".to_string(),
|
||
target_id: None,
|
||
payload: Some(json!({
|
||
"optionText": "接受任务"
|
||
})),
|
||
},
|
||
snapshot: None,
|
||
};
|
||
let mut game_state = build_runtime_story_boundary_game_state_fixture();
|
||
let pending_quest =
|
||
build_runtime_story_boundary_quest_fixture("quest-bridge-offer", "断桥口的密信");
|
||
let current_story = build_runtime_story_pending_quest_offer_fixture(pending_quest.clone());
|
||
|
||
let resolution = resolve_runtime_story_choice_action(
|
||
&mut game_state,
|
||
Some(¤t_story),
|
||
&request,
|
||
"npc_quest_accept",
|
||
)
|
||
.expect("quest accept should resolve");
|
||
|
||
let quests = read_array_field(&game_state, "quests");
|
||
assert_eq!(quests.len(), 1);
|
||
assert_eq!(
|
||
read_optional_string_field(quests[0], "id"),
|
||
read_optional_string_field(&pending_quest, "id")
|
||
);
|
||
assert_eq!(
|
||
read_field(&game_state, "runtimeStats")
|
||
.and_then(|stats| read_i32_field(stats, "questsAccepted")),
|
||
Some(1)
|
||
);
|
||
let saved_current_story = resolution
|
||
.saved_current_story
|
||
.expect("quest accept should save current story");
|
||
assert_eq!(
|
||
read_field(&saved_current_story, "npcChatState")
|
||
.and_then(|state| read_field(state, "pendingQuestOffer")),
|
||
Some(&Value::Null)
|
||
);
|
||
assert_eq!(
|
||
resolution
|
||
.presentation_options
|
||
.expect("quest accept should expose follow-up options")
|
||
.len(),
|
||
3
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn runtime_story_quest_turn_in_marks_quest_rewards_and_affinity() {
|
||
let request = RuntimeStoryActionRequest {
|
||
session_id: "runtime-main".to_string(),
|
||
client_version: Some(0),
|
||
action: shared_contracts::runtime_story::RuntimeStoryChoiceAction {
|
||
action_type: "story_choice".to_string(),
|
||
function_id: "npc_quest_turn_in".to_string(),
|
||
target_id: None,
|
||
payload: Some(json!({
|
||
"optionText": "交付任务",
|
||
"questId": "quest-bridge-complete"
|
||
})),
|
||
},
|
||
snapshot: None,
|
||
};
|
||
let mut game_state = build_runtime_story_boundary_game_state_fixture();
|
||
let mut completed_quest =
|
||
build_runtime_story_boundary_quest_fixture("quest-bridge-complete", "断桥夜巡");
|
||
if let Some(quest) = completed_quest.as_object_mut() {
|
||
quest.insert("status".to_string(), Value::String("completed".to_string()));
|
||
quest.insert(
|
||
"reward".to_string(),
|
||
json!({
|
||
"affinityBonus": 6,
|
||
"currency": 30,
|
||
"experience": 24,
|
||
"items": [{
|
||
"id": "reward-med-1",
|
||
"category": "补给",
|
||
"name": "回气散",
|
||
"quantity": 1,
|
||
"tags": []
|
||
}]
|
||
}),
|
||
);
|
||
}
|
||
push_quest_record(&mut game_state, &completed_quest);
|
||
|
||
let resolution = resolve_runtime_story_choice_action(
|
||
&mut game_state,
|
||
None,
|
||
&request,
|
||
"npc_quest_turn_in",
|
||
)
|
||
.expect("quest turn in should resolve");
|
||
|
||
let quests = read_array_field(&game_state, "quests");
|
||
assert_eq!(quests.len(), 1);
|
||
assert_eq!(
|
||
read_optional_string_field(quests[0], "status"),
|
||
Some("turned_in".to_string())
|
||
);
|
||
assert_eq!(read_i32_field(&game_state, "playerCurrency"), Some(120));
|
||
assert_eq!(read_array_field(&game_state, "playerInventory").len(), 1);
|
||
assert_eq!(
|
||
read_field(&game_state, "playerProgression")
|
||
.and_then(|progression| read_i32_field(progression, "totalXp")),
|
||
Some(24)
|
||
);
|
||
assert_eq!(
|
||
read_current_npc_state_i32_field(&game_state, "affinity"),
|
||
Some(52)
|
||
);
|
||
assert!(resolution.patches.iter().any(|patch| matches!(
|
||
patch,
|
||
RuntimeStoryPatch::NpcAffinityChanged {
|
||
previous_affinity: 46,
|
||
next_affinity: 52,
|
||
..
|
||
}
|
||
)));
|
||
}
|
||
|
||
async fn seed_authenticated_state() -> AppState {
|
||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||
state
|
||
.password_entry_service()
|
||
.execute(module_auth::PasswordEntryInput {
|
||
username: "runtime_story_state_user".to_string(),
|
||
password: "secret123".to_string(),
|
||
})
|
||
.await
|
||
.expect("seed login should succeed");
|
||
state
|
||
}
|
||
|
||
fn issue_access_token(state: &AppState) -> String {
|
||
let claims = AccessTokenClaims::from_input(
|
||
AccessTokenClaimsInput {
|
||
user_id: "user_00000001".to_string(),
|
||
session_id: "sess_runtime_story_state".to_string(),
|
||
provider: AuthProvider::Password,
|
||
roles: vec!["user".to_string()],
|
||
token_version: 1,
|
||
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")
|
||
}
|
||
|
||
fn build_runtime_story_boundary_game_state_fixture() -> Value {
|
||
serde_json::from_str(
|
||
r#"{
|
||
"worldType": "WUXIA",
|
||
"runtimeSessionId": "runtime-main",
|
||
"runtimeActionVersion": 0,
|
||
"playerCharacter": {
|
||
"id": "hero-story",
|
||
"title": "试剑客",
|
||
"description": "站在桥口的人。",
|
||
"personality": "谨慎",
|
||
"attributes": {
|
||
"strength": 8,
|
||
"spirit": 6
|
||
},
|
||
"skills": []
|
||
},
|
||
"runtimeStats": {
|
||
"playTimeMs": 0,
|
||
"lastPlayTickAt": null,
|
||
"hostileNpcsDefeated": 0,
|
||
"questsAccepted": 0,
|
||
"itemsUsed": 0,
|
||
"scenesTraveled": 0
|
||
},
|
||
"currentScene": "test-scene",
|
||
"storyHistory": [],
|
||
"characterChats": {},
|
||
"animationState": "idle",
|
||
"currentEncounter": {
|
||
"kind": "npc",
|
||
"id": "npc_merchant_01",
|
||
"npcName": "沈七",
|
||
"npcDescription": "腰间挂着药囊的行商",
|
||
"context": "受伤行商",
|
||
"hostile": false
|
||
},
|
||
"npcInteractionActive": true,
|
||
"currentScenePreset": null,
|
||
"sceneHostileNpcs": [],
|
||
"playerX": 0,
|
||
"playerOffsetY": 0,
|
||
"playerFacing": "right",
|
||
"playerActionMode": "idle",
|
||
"scrollWorld": false,
|
||
"inBattle": false,
|
||
"playerHp": 31,
|
||
"playerMaxHp": 40,
|
||
"playerMana": 9,
|
||
"playerMaxMana": 16,
|
||
"playerSkillCooldowns": {},
|
||
"activeBuildBuffs": [],
|
||
"activeCombatEffects": [],
|
||
"playerCurrency": 90,
|
||
"playerInventory": [],
|
||
"playerEquipment": {
|
||
"weapon": null,
|
||
"armor": null,
|
||
"relic": null
|
||
},
|
||
"npcStates": {
|
||
"npc_merchant_01": {
|
||
"affinity": 46,
|
||
"chattedCount": 0,
|
||
"helpUsed": false,
|
||
"giftsGiven": 0,
|
||
"inventory": [],
|
||
"recruited": false
|
||
}
|
||
},
|
||
"quests": [],
|
||
"roster": [],
|
||
"companions": [],
|
||
"currentNpcBattleMode": null,
|
||
"currentNpcBattleOutcome": null,
|
||
"sparReturnEncounter": null,
|
||
"sparPlayerHpBefore": null,
|
||
"sparPlayerMaxHpBefore": null,
|
||
"sparStoryHistoryBefore": null,
|
||
"playerProgression": {
|
||
"level": 1,
|
||
"currentLevelXp": 0,
|
||
"totalXp": 0,
|
||
"xpToNextLevel": 60,
|
||
"pendingLevelUps": 0,
|
||
"lastGrantedSource": null
|
||
}
|
||
}"#,
|
||
)
|
||
.expect("runtime story boundary game state fixture should parse")
|
||
}
|
||
|
||
fn build_runtime_story_boundary_quest_fixture(quest_id: &str, title: &str) -> Value {
|
||
json!({
|
||
"id": quest_id,
|
||
"issuerNpcId": "npc_merchant_01",
|
||
"issuerNpcName": "沈七",
|
||
"sceneId": "scene-bridge",
|
||
"title": title,
|
||
"description": format!("{title}的详细说明。"),
|
||
"summary": format!("{title}的简要目标。"),
|
||
"objective": {
|
||
"kind": "inspect_treasure",
|
||
"requiredCount": 1
|
||
},
|
||
"progress": 0,
|
||
"status": "active",
|
||
"reward": {
|
||
"affinityBonus": 6,
|
||
"currency": 30,
|
||
"items": []
|
||
},
|
||
"rewardText": "完成后可以领取报酬。",
|
||
"steps": [{
|
||
"id": format!("{quest_id}-step-1"),
|
||
"title": "查清线索",
|
||
"kind": "inspect_treasure",
|
||
"requiredCount": 1,
|
||
"progress": 0,
|
||
"revealText": "先去断桥口附近看看留下了什么痕迹。",
|
||
"completeText": "线索已经查清。"
|
||
}],
|
||
"activeStepId": format!("{quest_id}-step-1")
|
||
})
|
||
}
|
||
|
||
fn build_runtime_story_pending_quest_offer_fixture(quest: Value) -> Value {
|
||
json!({
|
||
"text": "沈七终于把真正的委托说了出来。",
|
||
"options": [],
|
||
"displayMode": "dialogue",
|
||
"dialogue": [{
|
||
"speaker": "npc",
|
||
"speakerName": "沈七",
|
||
"text": "这件事我只想托给你。"
|
||
}],
|
||
"npcChatState": {
|
||
"npcId": "npc_merchant_01",
|
||
"npcName": "沈七",
|
||
"turnCount": 2,
|
||
"customInputPlaceholder": "输入你想对 TA 说的话",
|
||
"pendingQuestOffer": {
|
||
"quest": quest
|
||
}
|
||
}
|
||
})
|
||
}
|
||
}
|