Files
Genarrative/server-rs/crates/api-server/src/runtime_story.rs

3739 lines
134 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(&current_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(&current_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(&current_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
}
}
})
}
}