推进 server-rs DDD 分层与新接口接线
This commit is contained in:
@@ -23,7 +23,7 @@ module-inventory = { path = "../module-inventory" }
|
||||
module-npc = { path = "../module-npc" }
|
||||
module-puzzle = { path = "../module-puzzle" }
|
||||
module-runtime = { path = "../module-runtime" }
|
||||
module-runtime-story-compat = { path = "../module-runtime-story-compat" }
|
||||
module-runtime-story = { path = "../module-runtime-story" }
|
||||
module-runtime-item = { path = "../module-runtime-item" }
|
||||
module-story = { path = "../module-story" }
|
||||
platform-auth = { path = "../platform-auth" }
|
||||
|
||||
@@ -111,16 +111,13 @@ use crate::{
|
||||
put_runtime_snapshot, resume_profile_save_archive,
|
||||
},
|
||||
runtime_settings::{get_runtime_settings, put_runtime_settings},
|
||||
runtime_story::{
|
||||
begin_runtime_story_session, generate_runtime_story_continue,
|
||||
generate_runtime_story_initial, get_runtime_story_state, resolve_runtime_story_action,
|
||||
resolve_runtime_story_state,
|
||||
},
|
||||
state::AppState,
|
||||
story_battles::{
|
||||
create_story_battle, create_story_npc_battle, get_story_battle_state, resolve_story_battle,
|
||||
},
|
||||
story_sessions::{begin_story_session, continue_story, get_story_session_state},
|
||||
story_sessions::{
|
||||
begin_story_session, continue_story, get_story_runtime_projection, get_story_session_state,
|
||||
},
|
||||
wechat_auth::{bind_wechat_phone, handle_wechat_callback, start_wechat_login},
|
||||
};
|
||||
|
||||
@@ -991,48 +988,6 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/story/sessions",
|
||||
post(begin_runtime_story_session).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/story/state/resolve",
|
||||
post(resolve_runtime_story_state).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/story/state/{session_id}",
|
||||
get(get_runtime_story_state).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/story/actions/resolve",
|
||||
post(resolve_runtime_story_action).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/story/initial",
|
||||
post(generate_runtime_story_initial).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/story/continue",
|
||||
post(generate_runtime_story_continue).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/profile/play-stats",
|
||||
get(get_profile_play_stats).route_layer(middleware::from_fn_with_state(
|
||||
@@ -1054,6 +1009,13 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/story/sessions/{story_session_id}/runtime-projection",
|
||||
get(get_story_runtime_projection).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/story/sessions/continue",
|
||||
post(continue_story).route_layer(middleware::from_fn_with_state(
|
||||
@@ -1312,6 +1274,53 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_story_legacy_routes_are_not_mounted() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
for (method, uri) in [
|
||||
("POST", "/api/runtime/story/sessions"),
|
||||
("POST", "/api/runtime/story/state/resolve"),
|
||||
("GET", "/api/runtime/story/state/runtime-main"),
|
||||
("POST", "/api/runtime/story/actions/resolve"),
|
||||
("POST", "/api/runtime/story/initial"),
|
||||
("POST", "/api/runtime/story/continue"),
|
||||
] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::empty())
|
||||
.expect("legacy runtime story request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("legacy runtime story request should be handled");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("legacy runtime story body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("legacy runtime story body should be json");
|
||||
assert_eq!(payload["ok"], Value::Bool(false));
|
||||
assert_eq!(
|
||||
payload["error"]["code"],
|
||||
Value::String("NOT_FOUND".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
payload["error"]["message"],
|
||||
Value::String("资源不存在".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn internal_auth_claims_rejects_missing_bearer_token() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
@@ -55,7 +55,6 @@ mod runtime_inventory;
|
||||
mod runtime_profile;
|
||||
mod runtime_save;
|
||||
mod runtime_settings;
|
||||
mod runtime_story;
|
||||
mod session_client;
|
||||
mod state;
|
||||
mod story_battles;
|
||||
|
||||
@@ -12,7 +12,7 @@ use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::convert::Infallible;
|
||||
|
||||
use module_runtime_story_compat::{
|
||||
use module_runtime_story::{
|
||||
RuntimeStoryPromptContextExtras, build_runtime_story_prompt_context, current_world_type,
|
||||
normalize_required_string, read_array_field, read_field, read_i32_field, read_object_field,
|
||||
read_optional_string_field, read_runtime_session_id,
|
||||
|
||||
@@ -16,7 +16,7 @@ use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
prompt::runtime_chat::*, request_context::RequestContext, state::AppState,
|
||||
};
|
||||
use module_runtime_story_compat::{
|
||||
use module_runtime_story::{
|
||||
RuntimeStoryPromptContextExtras, build_runtime_story_prompt_context, current_world_type,
|
||||
normalize_required_string, read_array_field, read_field, read_runtime_session_id,
|
||||
};
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
mod compat;
|
||||
|
||||
pub use compat::{
|
||||
begin_runtime_story_session, generate_runtime_story_continue, generate_runtime_story_initial,
|
||||
get_runtime_story_state, resolve_runtime_story_action, resolve_runtime_story_state,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,368 +0,0 @@
|
||||
use super::*;
|
||||
use crate::prompt::runtime_chat::{
|
||||
RuntimeNpcDialoguePromptParams, RuntimeReasonedStoryPromptParams, RuntimeStoryTextPromptParams,
|
||||
build_runtime_npc_dialogue_user_prompt, build_runtime_reasoned_story_user_prompt,
|
||||
build_runtime_story_director_user_prompt, runtime_npc_dialogue_system_prompt,
|
||||
runtime_reasoned_story_system_prompt, runtime_story_director_system_prompt,
|
||||
};
|
||||
|
||||
pub(super) 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,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn generate_ai_story_text(
|
||||
state: &AppState,
|
||||
payload: &RuntimeStoryAiRequest,
|
||||
initial: bool,
|
||||
) -> Option<String> {
|
||||
let llm_client = state.llm_client()?;
|
||||
let system_prompt = runtime_story_director_system_prompt(initial);
|
||||
let user_prompt = build_runtime_story_director_user_prompt(RuntimeStoryTextPromptParams {
|
||||
world_type: payload.world_type.as_str(),
|
||||
character: payload.character.clone(),
|
||||
monsters: Value::Array(payload.monsters.clone()),
|
||||
history: Value::Array(payload.history.clone()),
|
||||
choice: Value::String(payload.choice.clone()),
|
||||
context: payload.context.clone(),
|
||||
available_options: Value::Array(payload.request_options.available_options.clone()),
|
||||
});
|
||||
let mut request = LlmTextRequest::new(vec![
|
||||
LlmMessage::system(system_prompt),
|
||||
LlmMessage::user(user_prompt),
|
||||
]);
|
||||
request.max_tokens = Some(700);
|
||||
apply_rpg_web_search(state, &mut request);
|
||||
|
||||
llm_client
|
||||
.request_text(request)
|
||||
.await
|
||||
.ok()
|
||||
.map(|response| response.content.trim().to_string())
|
||||
.filter(|text| !text.is_empty())
|
||||
}
|
||||
|
||||
pub(super) async fn generate_action_story_payload(
|
||||
state: &AppState,
|
||||
game_state: &Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
function_id: &str,
|
||||
action_text: &str,
|
||||
result_text: &str,
|
||||
options: &[RuntimeStoryOptionView],
|
||||
battle: Option<&RuntimeBattlePresentation>,
|
||||
) -> Option<GeneratedStoryPayload> {
|
||||
let llm_client = state.llm_client()?;
|
||||
// 动作结算仍由确定性规则完成;LLM 只负责把已结算结果改写为可展示文本,失败时不影响主链。
|
||||
if function_id == "npc_chat" || function_id == "story_opening_camp_dialogue" {
|
||||
return generate_npc_dialogue_payload(
|
||||
llm_client,
|
||||
state.config.rpg_llm_web_search_enabled,
|
||||
game_state,
|
||||
request,
|
||||
action_text,
|
||||
result_text,
|
||||
options,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
if should_generate_reasoned_combat_story(battle) {
|
||||
return generate_reasoned_story_payload(
|
||||
llm_client,
|
||||
state.config.rpg_llm_web_search_enabled,
|
||||
game_state,
|
||||
request,
|
||||
action_text,
|
||||
result_text,
|
||||
options,
|
||||
battle,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn apply_rpg_web_search(state: &AppState, request: &mut LlmTextRequest) {
|
||||
request.enable_web_search = state.config.rpg_llm_web_search_enabled;
|
||||
}
|
||||
|
||||
pub(super) async fn generate_npc_dialogue_payload(
|
||||
llm_client: &LlmClient,
|
||||
enable_web_search: bool,
|
||||
game_state: &Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
action_text: &str,
|
||||
result_text: &str,
|
||||
deferred_options: &[RuntimeStoryOptionView],
|
||||
) -> Option<GeneratedStoryPayload> {
|
||||
let world_type = current_world_type(game_state)?;
|
||||
let character = read_object_field(game_state, "playerCharacter")?.clone();
|
||||
let encounter = read_object_field(game_state, "currentEncounter")?;
|
||||
if read_required_string_field(encounter, "kind").as_deref() != Some("npc") {
|
||||
return None;
|
||||
}
|
||||
let npc_name = read_optional_string_field(encounter, "npcName")
|
||||
.or_else(|| read_optional_string_field(encounter, "name"))
|
||||
.unwrap_or_else(|| "对方".to_string());
|
||||
let user_prompt = build_runtime_npc_dialogue_user_prompt(
|
||||
npc_name.as_str(),
|
||||
RuntimeNpcDialoguePromptParams {
|
||||
world_type: world_type.as_str(),
|
||||
character: &character,
|
||||
encounter,
|
||||
monsters: read_array_field(game_state, "sceneHostileNpcs")
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>(),
|
||||
history: build_action_story_history(game_state, action_text, result_text),
|
||||
context: build_action_story_prompt_context(game_state, None),
|
||||
topic: action_text,
|
||||
result_summary: result_text,
|
||||
requested_option: request.action.payload.clone().unwrap_or(Value::Null),
|
||||
available_options: build_action_prompt_options(deferred_options),
|
||||
},
|
||||
);
|
||||
let mut llm_request = LlmTextRequest::new(vec![
|
||||
LlmMessage::system(runtime_npc_dialogue_system_prompt()),
|
||||
LlmMessage::user(user_prompt),
|
||||
]);
|
||||
llm_request.max_tokens = Some(700);
|
||||
llm_request.enable_web_search = enable_web_search;
|
||||
|
||||
let dialogue_text = llm_client
|
||||
.request_text(llm_request)
|
||||
.await
|
||||
.ok()
|
||||
.map(|response| response.content.trim().to_string())
|
||||
.filter(|text| !text.is_empty())?;
|
||||
let presentation_options = vec![build_continue_adventure_runtime_story_option()];
|
||||
let saved_current_story =
|
||||
build_dialogue_current_story(npc_name.as_str(), dialogue_text.as_str(), deferred_options);
|
||||
|
||||
Some(GeneratedStoryPayload {
|
||||
story_text: dialogue_text.clone(),
|
||||
history_result_text: dialogue_text,
|
||||
presentation_options,
|
||||
saved_current_story,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn generate_reasoned_story_payload(
|
||||
llm_client: &LlmClient,
|
||||
enable_web_search: bool,
|
||||
game_state: &Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
action_text: &str,
|
||||
result_text: &str,
|
||||
options: &[RuntimeStoryOptionView],
|
||||
battle: Option<&RuntimeBattlePresentation>,
|
||||
) -> Option<GeneratedStoryPayload> {
|
||||
let world_type = current_world_type(game_state)?;
|
||||
let character = read_object_field(game_state, "playerCharacter")?.clone();
|
||||
let user_prompt = build_runtime_reasoned_story_user_prompt(RuntimeReasonedStoryPromptParams {
|
||||
world_type: world_type.as_str(),
|
||||
character: &character,
|
||||
monsters: read_array_field(game_state, "sceneHostileNpcs")
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>(),
|
||||
history: build_action_story_history(game_state, action_text, result_text),
|
||||
context: build_action_story_prompt_context(game_state, battle),
|
||||
choice: action_text,
|
||||
result_summary: result_text,
|
||||
requested_option: request.action.payload.clone().unwrap_or(Value::Null),
|
||||
available_options: build_action_prompt_options(options),
|
||||
});
|
||||
let mut llm_request = LlmTextRequest::new(vec![
|
||||
LlmMessage::system(runtime_reasoned_story_system_prompt()),
|
||||
LlmMessage::user(user_prompt),
|
||||
]);
|
||||
llm_request.max_tokens = Some(700);
|
||||
llm_request.enable_web_search = enable_web_search;
|
||||
|
||||
let story_text = llm_client
|
||||
.request_text(llm_request)
|
||||
.await
|
||||
.ok()
|
||||
.map(|response| response.content.trim().to_string())
|
||||
.filter(|text| !text.is_empty())?;
|
||||
|
||||
Some(GeneratedStoryPayload {
|
||||
story_text: story_text.clone(),
|
||||
history_result_text: story_text.clone(),
|
||||
presentation_options: options.to_vec(),
|
||||
saved_current_story: build_legacy_current_story(story_text.as_str(), options),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn should_generate_reasoned_combat_story(
|
||||
_battle: Option<&RuntimeBattlePresentation>,
|
||||
) -> bool {
|
||||
// 战斗动作、逃跑、胜利、切磋结束与死亡都只走确定性结算,避免战斗链路再次触发剧情推理。
|
||||
false
|
||||
}
|
||||
|
||||
pub(super) fn build_action_story_history(
|
||||
game_state: &Value,
|
||||
action_text: &str,
|
||||
result_text: &str,
|
||||
) -> Vec<Value> {
|
||||
let mut history = read_array_field(game_state, "storyHistory")
|
||||
.into_iter()
|
||||
.filter_map(|entry| {
|
||||
let text = read_optional_string_field(entry, "text")?;
|
||||
let history_role = read_optional_string_field(entry, "historyRole")
|
||||
.unwrap_or_else(|| "result".to_string());
|
||||
Some(json!({
|
||||
"text": text,
|
||||
"historyRole": history_role,
|
||||
}))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
history.push(json!({
|
||||
"text": action_text,
|
||||
"historyRole": "action",
|
||||
}));
|
||||
history.push(json!({
|
||||
"text": result_text,
|
||||
"historyRole": "result",
|
||||
}));
|
||||
let keep_from = history.len().saturating_sub(12);
|
||||
history.into_iter().skip(keep_from).collect()
|
||||
}
|
||||
|
||||
pub(super) fn build_action_story_prompt_context(
|
||||
game_state: &Value,
|
||||
battle: Option<&RuntimeBattlePresentation>,
|
||||
) -> Value {
|
||||
let scene_preset = read_object_field(game_state, "currentScenePreset");
|
||||
let battle_value = battle
|
||||
.and_then(|presentation| serde_json::to_value(presentation).ok())
|
||||
.unwrap_or(Value::Null);
|
||||
|
||||
json!({
|
||||
"sceneName": scene_preset
|
||||
.and_then(|scene| read_optional_string_field(scene, "name"))
|
||||
.or_else(|| read_optional_string_field(game_state, "currentScene"))
|
||||
.unwrap_or_else(|| "当前区域".to_string()),
|
||||
"sceneDescription": scene_preset
|
||||
.and_then(|scene| read_optional_string_field(scene, "description"))
|
||||
.or_else(|| read_optional_string_field(game_state, "sceneDescription"))
|
||||
.unwrap_or_else(|| "周围气氛仍在继续变化。".to_string()),
|
||||
"encounterName": read_object_field(game_state, "currentEncounter")
|
||||
.and_then(|encounter| {
|
||||
read_optional_string_field(encounter, "npcName")
|
||||
.or_else(|| read_optional_string_field(encounter, "name"))
|
||||
}),
|
||||
"encounterId": current_encounter_id(game_state),
|
||||
"playerHp": read_i32_field(game_state, "playerHp").unwrap_or(0),
|
||||
"playerMaxHp": read_i32_field(game_state, "playerMaxHp").unwrap_or(1),
|
||||
"playerMana": read_i32_field(game_state, "playerMana").unwrap_or(0),
|
||||
"playerMaxMana": read_i32_field(game_state, "playerMaxMana").unwrap_or(1),
|
||||
"inBattle": read_bool_field(game_state, "inBattle").unwrap_or(false),
|
||||
"currentNpcBattleOutcome": read_optional_string_field(game_state, "currentNpcBattleOutcome"),
|
||||
"battle": battle_value,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn build_action_prompt_options(options: &[RuntimeStoryOptionView]) -> Vec<Value> {
|
||||
options
|
||||
.iter()
|
||||
.filter(|option| !option.disabled.unwrap_or(false))
|
||||
.map(|option| {
|
||||
json!({
|
||||
"functionId": option.function_id,
|
||||
"actionText": option.action_text,
|
||||
"text": option.action_text,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) 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", "原地调息"),
|
||||
]
|
||||
}
|
||||
|
||||
pub(super) 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))
|
||||
}
|
||||
|
||||
pub(super) 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": []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) 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} 的局势随之向下一步展开。")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,106 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
/// 对齐 Node 旧 inventory compat,先按装备位把物品从背包切到 playerEquipment,
|
||||
/// 再把基础面板属性回算到快照上。
|
||||
pub(super) fn resolve_equipment_equip_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
if read_field(game_state, "playerCharacter").is_none() {
|
||||
return Err("缺少玩家角色,无法调整装备。".to_string());
|
||||
}
|
||||
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
|
||||
return Err("战斗中无法调整装备。".to_string());
|
||||
}
|
||||
let item_id = request
|
||||
.action
|
||||
.payload
|
||||
.as_ref()
|
||||
.and_then(|payload| read_optional_string_field(payload, "itemId"))
|
||||
.or_else(|| request.action.target_id.clone())
|
||||
.ok_or_else(|| "equipment_equip 缺少 itemId".to_string())?;
|
||||
let item = find_player_inventory_entry(game_state, item_id.as_str())
|
||||
.cloned()
|
||||
.ok_or_else(|| "背包里没有这件装备。".to_string())?;
|
||||
let slot_id = resolve_equipment_slot_for_item(&item)
|
||||
.ok_or_else(|| format!("{} 不是可装备物品。", read_inventory_item_name(&item)))?;
|
||||
let previous_equipment = read_player_equipment_item(game_state, slot_id);
|
||||
let next_equipment_item = normalize_equipped_item(&item);
|
||||
|
||||
remove_player_inventory_item(game_state, item_id.as_str(), 1);
|
||||
if let Some(previous_equipment) = previous_equipment.as_ref() {
|
||||
add_player_inventory_items(game_state, vec![previous_equipment.clone()]);
|
||||
}
|
||||
write_player_equipment_item(game_state, slot_id, Some(next_equipment_item));
|
||||
apply_equipment_loadout_to_state(game_state);
|
||||
|
||||
let item_name = read_inventory_item_name(&item);
|
||||
let result_text = if let Some(previous_equipment) = previous_equipment.as_ref() {
|
||||
format!(
|
||||
"你将{}从{}位上换下,改为装备{}。",
|
||||
read_inventory_item_name(previous_equipment),
|
||||
equipment_slot_label(slot_id),
|
||||
item_name
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"你将{}装备在{}位上。",
|
||||
item_name,
|
||||
equipment_slot_label(slot_id)
|
||||
)
|
||||
};
|
||||
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text(&format!("装备{}", item_name), request),
|
||||
result_text,
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: Vec::new(),
|
||||
battle: None,
|
||||
toast: Some(build_current_build_toast(game_state)),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn resolve_equipment_unequip_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
ensure_inventory_action_available(
|
||||
game_state,
|
||||
"缺少玩家角色,无法卸下装备。",
|
||||
"战斗中无法卸下装备。",
|
||||
)?;
|
||||
let slot_id = request
|
||||
.action
|
||||
.payload
|
||||
.as_ref()
|
||||
.and_then(|payload| read_optional_string_field(payload, "slotId"))
|
||||
.or_else(|| request.action.target_id.clone())
|
||||
.ok_or_else(|| "equipment_unequip 缺少合法 slotId".to_string())?;
|
||||
let slot_id = normalize_equipment_slot_id(slot_id.as_str())
|
||||
.ok_or_else(|| "equipment_unequip 缺少合法 slotId".to_string())?;
|
||||
let equipped_item = read_player_equipment_item(game_state, slot_id)
|
||||
.ok_or_else(|| format!("{}位当前没有装备。", equipment_slot_label(slot_id)))?;
|
||||
|
||||
write_player_equipment_item(game_state, slot_id, None);
|
||||
add_player_inventory_items(game_state, vec![equipped_item.clone()]);
|
||||
apply_equipment_loadout_to_state(game_state);
|
||||
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text(
|
||||
&format!("卸下{}", read_inventory_item_name(&equipped_item)),
|
||||
request,
|
||||
),
|
||||
result_text: format!(
|
||||
"你卸下了{},暂时收回背包。",
|
||||
read_inventory_item_name(&equipped_item)
|
||||
),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: Vec::new(),
|
||||
battle: None,
|
||||
toast: Some(build_current_build_toast(game_state)),
|
||||
})
|
||||
}
|
||||
@@ -1,699 +0,0 @@
|
||||
use super::*;
|
||||
use module_runtime_story_compat::{build_runtime_equipment_item, build_runtime_material_item};
|
||||
|
||||
pub(super) fn current_npc_trade_context(game_state: &Value) -> Result<(String, String), 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 = current_encounter_name(game_state);
|
||||
let npc_id = current_encounter_id(game_state).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((npc_id, npc_name))
|
||||
}
|
||||
|
||||
pub(super) fn current_npc_inventory_items<'a>(game_state: &'a Value) -> Vec<&'a Value> {
|
||||
let Some(npc_id) = current_encounter_id(game_state) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let npc_name = current_encounter_name(game_state);
|
||||
resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str())
|
||||
.map(|state| read_array_field(state, "inventory"))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 兼容桥沿用 Node 旧域的入口预处理:在读取选项或结算动作前,
|
||||
/// 先确保当前 NPC 的持久状态最少可用,避免空快照直接打断交易/赠礼/委托主链。
|
||||
pub(super) fn ensure_runtime_story_bridge_state(game_state: &mut Value) {
|
||||
ensure_current_encounter_npc_state_initialized(game_state);
|
||||
}
|
||||
|
||||
/// 这里不尝试一次性重建完整真相态,只补 compat bridge 当前确实依赖的字段,
|
||||
/// 并为“纯商贩型 NPC”补一份确定性 trade stock,保证旧前端菜单不因空状态掉链子。
|
||||
pub(super) fn ensure_current_encounter_npc_state_initialized(game_state: &mut Value) {
|
||||
let Some(encounter) = read_object_field(game_state, "currentEncounter").cloned() else {
|
||||
return;
|
||||
};
|
||||
if read_optional_string_field(&encounter, "kind").as_deref() != Some("npc") {
|
||||
return;
|
||||
}
|
||||
|
||||
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());
|
||||
let storage_key = resolve_npc_state_storage_key(game_state, npc_id.as_str(), npc_name.as_str());
|
||||
let existing_state = read_field(game_state, "npcStates")
|
||||
.and_then(|states| read_field(states, storage_key.as_str()))
|
||||
.cloned();
|
||||
|
||||
let affinity = existing_state
|
||||
.as_ref()
|
||||
.and_then(|state| read_i32_field(state, "affinity"))
|
||||
.unwrap_or_else(|| default_current_npc_affinity(&encounter));
|
||||
let recruited = existing_state
|
||||
.as_ref()
|
||||
.and_then(|state| read_bool_field(state, "recruited"))
|
||||
.unwrap_or(false);
|
||||
let chatted_count = existing_state
|
||||
.as_ref()
|
||||
.and_then(|state| read_i32_field(state, "chattedCount"))
|
||||
.unwrap_or(0)
|
||||
.max(0);
|
||||
let gifts_given = existing_state
|
||||
.as_ref()
|
||||
.and_then(|state| read_i32_field(state, "giftsGiven"))
|
||||
.unwrap_or(0)
|
||||
.max(0);
|
||||
let help_used = existing_state
|
||||
.as_ref()
|
||||
.and_then(|state| read_bool_field(state, "helpUsed"))
|
||||
.unwrap_or(false);
|
||||
let first_meaningful_contact_resolved = existing_state
|
||||
.as_ref()
|
||||
.and_then(|state| read_bool_field(state, "firstMeaningfulContactResolved"))
|
||||
.unwrap_or(false);
|
||||
let revealed_facts = existing_state
|
||||
.as_ref()
|
||||
.map(|state| read_string_list_field(state, "revealedFacts"))
|
||||
.unwrap_or_default();
|
||||
let known_attribute_rumors = existing_state
|
||||
.as_ref()
|
||||
.map(|state| read_string_list_field(state, "knownAttributeRumors"))
|
||||
.unwrap_or_default();
|
||||
let seen_backstory_chapter_ids = existing_state
|
||||
.as_ref()
|
||||
.map(|state| read_string_list_field(state, "seenBackstoryChapterIds"))
|
||||
.unwrap_or_default();
|
||||
let existing_inventory = existing_state
|
||||
.as_ref()
|
||||
.map(|state| {
|
||||
read_array_field(state, "inventory")
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let existing_trade_stock_signature = existing_state
|
||||
.as_ref()
|
||||
.and_then(|state| read_optional_string_field(state, "tradeStockSignature"));
|
||||
let hostile = read_bool_field(&encounter, "hostile").unwrap_or(false)
|
||||
|| read_optional_string_field(&encounter, "monsterPresetId").is_some()
|
||||
|| affinity < 0;
|
||||
let context_text = read_optional_string_field(&encounter, "context");
|
||||
|
||||
let (inventory, trade_stock_signature) = if is_trade_driven_role_npc(&encounter) {
|
||||
let next_signature = build_current_npc_trade_stock_signature(game_state, npc_id.as_str());
|
||||
if existing_trade_stock_signature.as_deref() == Some(next_signature.as_str()) {
|
||||
(existing_inventory, Some(next_signature))
|
||||
} else {
|
||||
(
|
||||
sync_bootstrapped_trade_inventory(
|
||||
game_state,
|
||||
npc_id.as_str(),
|
||||
npc_name.as_str(),
|
||||
existing_inventory,
|
||||
next_signature.as_str(),
|
||||
),
|
||||
Some(next_signature),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
(existing_inventory, existing_trade_stock_signature)
|
||||
};
|
||||
|
||||
let relation_state = build_runtime_story_relation_state_value(affinity);
|
||||
let stance_profile = build_runtime_story_stance_profile_value(
|
||||
affinity,
|
||||
recruited,
|
||||
hostile,
|
||||
context_text.as_deref(),
|
||||
existing_state
|
||||
.as_ref()
|
||||
.and_then(|state| read_field(state, "stanceProfile"))
|
||||
.and_then(Value::as_object),
|
||||
);
|
||||
let npc_state = json!({
|
||||
"affinity": affinity,
|
||||
"chattedCount": chatted_count,
|
||||
"helpUsed": help_used,
|
||||
"giftsGiven": gifts_given,
|
||||
"inventory": inventory,
|
||||
"recruited": recruited,
|
||||
"relationState": relation_state,
|
||||
"revealedFacts": revealed_facts,
|
||||
"knownAttributeRumors": known_attribute_rumors,
|
||||
"firstMeaningfulContactResolved": first_meaningful_contact_resolved,
|
||||
"seenBackstoryChapterIds": seen_backstory_chapter_ids,
|
||||
"tradeStockSignature": trade_stock_signature,
|
||||
"stanceProfile": stance_profile,
|
||||
});
|
||||
|
||||
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());
|
||||
}
|
||||
npc_states
|
||||
.as_object_mut()
|
||||
.expect("npcStates should be object")
|
||||
.insert(storage_key, npc_state);
|
||||
}
|
||||
|
||||
pub(super) fn resolve_npc_state_storage_key(
|
||||
game_state: &Value,
|
||||
npc_id: &str,
|
||||
npc_name: &str,
|
||||
) -> String {
|
||||
read_object_field(game_state, "npcStates")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|states| {
|
||||
if states.contains_key(npc_id) {
|
||||
Some(npc_id.to_string())
|
||||
} else if states.contains_key(npc_name) {
|
||||
Some(npc_name.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| npc_id.to_string())
|
||||
}
|
||||
|
||||
pub(super) fn default_current_npc_affinity(encounter: &Value) -> i32 {
|
||||
read_i32_field(encounter, "initialAffinity").unwrap_or_else(|| {
|
||||
if read_optional_string_field(encounter, "monsterPresetId").is_some() {
|
||||
-40
|
||||
} else if read_optional_string_field(encounter, "characterId").is_some() {
|
||||
18
|
||||
} else {
|
||||
6
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn read_string_list_field(value: &Value, key: &str) -> Vec<String> {
|
||||
let mut items = read_array_field(value, key)
|
||||
.into_iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|entry| !entry.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
if items.len() > 3 {
|
||||
items = items.split_off(items.len() - 3);
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
pub(super) fn build_runtime_story_relation_state_value(affinity: i32) -> Value {
|
||||
let relation_state = build_module_npc_relation_state(affinity);
|
||||
json!({
|
||||
"affinity": relation_state.affinity,
|
||||
"stance": npc_relation_stance_key(relation_state.stance),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn npc_relation_stance_key(value: NpcRelationStance) -> &'static str {
|
||||
match value {
|
||||
NpcRelationStance::Hostile => "hostile",
|
||||
NpcRelationStance::Guarded => "guarded",
|
||||
NpcRelationStance::Neutral => "neutral",
|
||||
NpcRelationStance::Cooperative => "cooperative",
|
||||
NpcRelationStance::Bonded => "bonded",
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_runtime_story_stance_profile_value(
|
||||
affinity: i32,
|
||||
recruited: bool,
|
||||
hostile: bool,
|
||||
role_text: Option<&str>,
|
||||
existing_profile: Option<&Map<String, Value>>,
|
||||
) -> Value {
|
||||
let base = build_module_npc_initial_stance_profile(affinity, recruited, hostile, role_text);
|
||||
let read_metric = |key: &str, fallback: u8| -> i32 {
|
||||
existing_profile
|
||||
.and_then(|profile| profile.get(key))
|
||||
.and_then(Value::as_i64)
|
||||
.and_then(|value| i32::try_from(value).ok())
|
||||
.unwrap_or(i32::from(fallback))
|
||||
.clamp(0, 100)
|
||||
};
|
||||
let recent_approvals = existing_profile
|
||||
.and_then(|profile| profile.get("recentApprovals"))
|
||||
.map(|value| read_string_list_field(value, ""))
|
||||
.unwrap_or_else(|| base.recent_approvals.clone());
|
||||
let recent_disapprovals = existing_profile
|
||||
.and_then(|profile| profile.get("recentDisapprovals"))
|
||||
.map(|value| read_string_list_field(value, ""))
|
||||
.unwrap_or_else(|| base.recent_disapprovals.clone());
|
||||
|
||||
json!({
|
||||
"trust": read_metric("trust", base.trust),
|
||||
"warmth": read_metric("warmth", base.warmth),
|
||||
"ideologicalFit": read_metric("ideologicalFit", base.ideological_fit),
|
||||
"fearOrGuard": read_metric("fearOrGuard", base.fear_or_guard),
|
||||
"loyalty": read_metric("loyalty", base.loyalty),
|
||||
"currentConflictTag": existing_profile
|
||||
.and_then(|profile| profile.get("currentConflictTag"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.or(base.current_conflict_tag),
|
||||
"recentApprovals": recent_approvals,
|
||||
"recentDisapprovals": recent_disapprovals,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn is_trade_driven_role_npc(encounter: &Value) -> bool {
|
||||
read_optional_string_field(encounter, "characterId").is_none()
|
||||
&& read_optional_string_field(encounter, "monsterPresetId").is_none()
|
||||
}
|
||||
|
||||
pub(super) fn build_current_npc_trade_stock_signature(game_state: &Value, npc_id: &str) -> String {
|
||||
let scene_key = read_object_field(game_state, "currentScenePreset")
|
||||
.and_then(|preset| {
|
||||
read_optional_string_field(preset, "id")
|
||||
.or_else(|| read_optional_string_field(preset, "name"))
|
||||
})
|
||||
.or_else(|| read_optional_string_field(game_state, "currentScene"))
|
||||
.unwrap_or_else(|| "scene".to_string());
|
||||
let world_key = current_world_type(game_state).unwrap_or_else(|| "world".to_string());
|
||||
format!(
|
||||
"{}:{}:{}",
|
||||
sanitize_trade_stock_fragment(npc_id),
|
||||
sanitize_trade_stock_fragment(scene_key.as_str()),
|
||||
sanitize_trade_stock_fragment(world_key.as_str())
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn sanitize_trade_stock_fragment(value: &str) -> String {
|
||||
let normalized = value
|
||||
.trim()
|
||||
.chars()
|
||||
.map(|ch| match ch {
|
||||
':' | '/' | '\\' | ' ' => '-',
|
||||
_ => ch,
|
||||
})
|
||||
.collect::<String>();
|
||||
if normalized.is_empty() {
|
||||
"unknown".to_string()
|
||||
} else {
|
||||
normalized
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn sync_bootstrapped_trade_inventory(
|
||||
game_state: &Value,
|
||||
npc_id: &str,
|
||||
npc_name: &str,
|
||||
existing_inventory: Vec<Value>,
|
||||
trade_stock_signature: &str,
|
||||
) -> Vec<Value> {
|
||||
let preserved_inventory = existing_inventory
|
||||
.into_iter()
|
||||
.filter(|item| {
|
||||
read_field(item, "runtimeMetadata")
|
||||
.and_then(|metadata| read_optional_string_field(metadata, "generationChannel"))
|
||||
.as_deref()
|
||||
!= Some("npc_trade")
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut next_inventory = preserved_inventory;
|
||||
next_inventory.extend(build_bootstrapped_trade_inventory(
|
||||
game_state,
|
||||
npc_id,
|
||||
npc_name,
|
||||
trade_stock_signature,
|
||||
));
|
||||
next_inventory
|
||||
}
|
||||
|
||||
pub(super) fn build_bootstrapped_trade_inventory(
|
||||
game_state: &Value,
|
||||
npc_id: &str,
|
||||
npc_name: &str,
|
||||
trade_stock_signature: &str,
|
||||
) -> Vec<Value> {
|
||||
let world_type = current_world_type(game_state);
|
||||
let consumable_name = if world_type.as_deref() == Some("XIANXIA") {
|
||||
"回灵散"
|
||||
} else {
|
||||
"回气散"
|
||||
};
|
||||
let material_name = if world_type.as_deref() == Some("XIANXIA") {
|
||||
"凝光纱"
|
||||
} else {
|
||||
"工巧残材"
|
||||
};
|
||||
let relic_name = if world_type.as_deref() == Some("XIANXIA") {
|
||||
"行旅护符"
|
||||
} else {
|
||||
"结绳护符"
|
||||
};
|
||||
let armor_name = if world_type.as_deref() == Some("XIANXIA") {
|
||||
"护行法衣"
|
||||
} else {
|
||||
"护行短甲"
|
||||
};
|
||||
let tonic_id = format!("npc-trade:{trade_stock_signature}:tonic");
|
||||
let material_id = format!("npc-trade:{trade_stock_signature}:material");
|
||||
let relic_id = format!("npc-trade:{trade_stock_signature}:relic");
|
||||
let armor_id = format!("npc-trade:{trade_stock_signature}:armor");
|
||||
|
||||
vec![
|
||||
build_bootstrapped_trade_consumable_item(
|
||||
tonic_id.as_str(),
|
||||
consumable_name,
|
||||
npc_name,
|
||||
world_type.as_deref(),
|
||||
),
|
||||
attach_generated_trade_metadata(
|
||||
build_runtime_material_item(
|
||||
game_state,
|
||||
material_name,
|
||||
2,
|
||||
&["工巧", "补给"],
|
||||
"uncommon",
|
||||
),
|
||||
material_id.as_str(),
|
||||
"npc_trade",
|
||||
format!("{npc_id}:material").as_str(),
|
||||
format!("{npc_name}整理出来的可交易工坊材料。").as_str(),
|
||||
),
|
||||
attach_generated_trade_metadata(
|
||||
build_runtime_equipment_item(
|
||||
game_state,
|
||||
relic_name,
|
||||
"relic",
|
||||
"rare",
|
||||
"适合长途行路时稳住灵力与节奏的护符。",
|
||||
"护持",
|
||||
&["护持", "法力"],
|
||||
&["护持", "法力"],
|
||||
json!({
|
||||
"maxManaBonus": 12,
|
||||
"outgoingDamageBonus": 0.05
|
||||
}),
|
||||
),
|
||||
relic_id.as_str(),
|
||||
"npc_trade",
|
||||
format!("{npc_id}:relic").as_str(),
|
||||
format!("{npc_name}随身携带的护身小物。").as_str(),
|
||||
),
|
||||
attach_generated_trade_metadata(
|
||||
build_runtime_equipment_item(
|
||||
game_state,
|
||||
armor_name,
|
||||
"armor",
|
||||
"rare",
|
||||
"为行路与近身护体准备的轻装护具。",
|
||||
"守御",
|
||||
&["守御", "护体"],
|
||||
&["守御", "护体"],
|
||||
json!({
|
||||
"maxHpBonus": 18,
|
||||
"incomingDamageMultiplier": 0.93
|
||||
}),
|
||||
),
|
||||
armor_id.as_str(),
|
||||
"npc_trade",
|
||||
format!("{npc_id}:armor").as_str(),
|
||||
format!("{npc_name}压箱底留下的一件护身装备。").as_str(),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
pub(super) fn build_bootstrapped_trade_consumable_item(
|
||||
item_id: &str,
|
||||
name: &str,
|
||||
npc_name: &str,
|
||||
world_type: Option<&str>,
|
||||
) -> Value {
|
||||
json!({
|
||||
"id": item_id,
|
||||
"category": "消耗品",
|
||||
"name": name,
|
||||
"description": format!("{npc_name}常备的一份行路补给。"),
|
||||
"quantity": 2,
|
||||
"rarity": "uncommon",
|
||||
"tags": if world_type == Some("XIANXIA") {
|
||||
vec!["mana", "support", "trade"]
|
||||
} else {
|
||||
vec!["mana", "support", "trade"]
|
||||
},
|
||||
"useProfile": {
|
||||
"hpRestore": 0,
|
||||
"manaRestore": 10,
|
||||
"cooldownReduction": 0,
|
||||
"buildBuffs": []
|
||||
},
|
||||
"runtimeMetadata": {
|
||||
"origin": "procedural",
|
||||
"generationChannel": "npc_trade",
|
||||
"seedKey": format!("{item_id}:seed"),
|
||||
"sourceReason": format!("{npc_name}把最常用的补给拿出来做成了交易库存。"),
|
||||
"storyFingerprint": {
|
||||
"relatedScarIds": [format!("scar:npc_trade:{item_id}")],
|
||||
"relatedThreadIds": [],
|
||||
"visibleClue": format!("{npc_name}随身药囊里最顺手的一味补给。"),
|
||||
"witnessMark": "药包封口处还留着反复拆开的折痕。",
|
||||
"unresolvedQuestion": "这份补给之前究竟替谁留着。"
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn attach_generated_trade_metadata(
|
||||
mut item: Value,
|
||||
item_id: &str,
|
||||
generation_channel: &str,
|
||||
seed_key: &str,
|
||||
source_reason: &str,
|
||||
) -> Value {
|
||||
let item_name = read_inventory_item_name(&item);
|
||||
let entry = ensure_json_object(&mut item);
|
||||
entry.insert("id".to_string(), Value::String(item_id.to_string()));
|
||||
entry.insert(
|
||||
"runtimeMetadata".to_string(),
|
||||
json!({
|
||||
"origin": "procedural",
|
||||
"generationChannel": generation_channel,
|
||||
"seedKey": seed_key,
|
||||
"sourceReason": source_reason,
|
||||
"storyFingerprint": {
|
||||
"relatedScarIds": [format!("scar:{generation_channel}:{seed_key}")],
|
||||
"relatedThreadIds": [],
|
||||
"visibleClue": format!("{item_name}上保留着反复流转留下的使用痕迹。"),
|
||||
"witnessMark": "表面仍残留旧主人长期携带的磨损。",
|
||||
"unresolvedQuestion": format!("{item_name}最初为什么会落到这名 NPC 手里。"),
|
||||
}
|
||||
}),
|
||||
);
|
||||
item
|
||||
}
|
||||
|
||||
pub(super) fn read_current_npc_inventory_item<'a>(
|
||||
game_state: &'a Value,
|
||||
item_id: &str,
|
||||
) -> Option<&'a Value> {
|
||||
current_npc_inventory_items(game_state)
|
||||
.into_iter()
|
||||
.find(|item| read_optional_string_field(item, "id").as_deref() == Some(item_id))
|
||||
}
|
||||
|
||||
pub(super) 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))
|
||||
}
|
||||
|
||||
pub(super) 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))
|
||||
}
|
||||
|
||||
pub(super) 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))
|
||||
}
|
||||
|
||||
pub(super) 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));
|
||||
}
|
||||
|
||||
pub(super) 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));
|
||||
}
|
||||
|
||||
pub(super) 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))
|
||||
}
|
||||
|
||||
pub(super) 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)
|
||||
}
|
||||
|
||||
pub(super) 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")
|
||||
}
|
||||
|
||||
pub(super) fn mark_current_npc_first_meaningful_contact_resolved(game_state: &mut Value) {
|
||||
write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true);
|
||||
}
|
||||
|
||||
pub(super) fn ensure_current_npc_inventory_array<'a>(
|
||||
game_state: &'a mut Value,
|
||||
) -> Option<&'a mut Vec<Value>> {
|
||||
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 inventory = state
|
||||
.entry("inventory".to_string())
|
||||
.or_insert_with(|| Value::Array(Vec::new()));
|
||||
if !inventory.is_array() {
|
||||
*inventory = Value::Array(Vec::new());
|
||||
}
|
||||
inventory.as_array_mut()
|
||||
}
|
||||
|
||||
pub(super) fn add_current_npc_inventory_items(game_state: &mut Value, additions: Vec<Value>) {
|
||||
if additions.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(items) = ensure_current_npc_inventory_array(game_state) else {
|
||||
return;
|
||||
};
|
||||
for addition in additions {
|
||||
let Some(add_id) = read_optional_string_field(&addition, "id") else {
|
||||
continue;
|
||||
};
|
||||
let add_quantity = read_i32_field(&addition, "quantity").unwrap_or(1).max(1);
|
||||
if let Some(existing) = items
|
||||
.iter_mut()
|
||||
.find(|item| read_optional_string_field(item, "id").as_deref() == Some(add_id.as_str()))
|
||||
{
|
||||
let next_quantity =
|
||||
read_i32_field(existing, "quantity").unwrap_or(0).max(0) + add_quantity;
|
||||
if let Some(existing_object) = existing.as_object_mut() {
|
||||
existing_object.insert("quantity".to_string(), json!(next_quantity));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
items.push(addition);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn remove_current_npc_inventory_item(
|
||||
game_state: &mut Value,
|
||||
item_id: &str,
|
||||
quantity: i32,
|
||||
) {
|
||||
if quantity <= 0 {
|
||||
return;
|
||||
}
|
||||
let Some(items) = ensure_current_npc_inventory_array(game_state) else {
|
||||
return;
|
||||
};
|
||||
let Some(index) = items
|
||||
.iter()
|
||||
.position(|entry| read_optional_string_field(entry, "id").as_deref() == Some(item_id))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let current_quantity = read_i32_field(&items[index], "quantity")
|
||||
.unwrap_or(0)
|
||||
.max(0);
|
||||
let next_quantity = current_quantity - quantity;
|
||||
if next_quantity <= 0 {
|
||||
items.remove(index);
|
||||
return;
|
||||
}
|
||||
if let Some(entry) = items[index].as_object_mut() {
|
||||
entry.insert("quantity".to_string(), json!(next_quantity));
|
||||
}
|
||||
}
|
||||
@@ -1,523 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) 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,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) 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,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) 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)
|
||||
}
|
||||
|
||||
pub(super) 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)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) 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"
|
||||
};
|
||||
let return_encounter = read_object_field(game_state, "currentEncounter").cloned();
|
||||
let resolved_formation =
|
||||
resolve_npc_battle_formation(game_state, return_encounter.as_ref(), battle_mode);
|
||||
|
||||
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");
|
||||
write_null_field(game_state, "currentEncounter");
|
||||
ensure_json_object(game_state).insert(
|
||||
"sceneHostileNpcs".to_string(),
|
||||
Value::Array(resolved_formation),
|
||||
);
|
||||
if let Some(return_encounter) = return_encounter {
|
||||
ensure_json_object(game_state).insert("sparReturnEncounter".to_string(), return_encounter);
|
||||
}
|
||||
|
||||
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_battle_formation(
|
||||
game_state: &Value,
|
||||
encounter: Option<&Value>,
|
||||
battle_mode: &str,
|
||||
) -> Vec<Value> {
|
||||
let visible_formation = read_array_field(game_state, "sceneHostileNpcs")
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if !visible_formation.is_empty() {
|
||||
return visible_formation
|
||||
.into_iter()
|
||||
.map(|monster| normalize_npc_battle_monster(monster, battle_mode))
|
||||
.collect();
|
||||
}
|
||||
|
||||
encounter
|
||||
.map(|encounter| {
|
||||
vec![build_npc_battle_monster_from_encounter(
|
||||
game_state,
|
||||
encounter,
|
||||
battle_mode,
|
||||
3.2,
|
||||
0,
|
||||
)]
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn normalize_npc_battle_monster(mut monster: Value, battle_mode: &str) -> Value {
|
||||
let Some(monster_object) = monster.as_object_mut() else {
|
||||
return monster;
|
||||
};
|
||||
monster_object
|
||||
.entry("animation".to_string())
|
||||
.or_insert_with(|| Value::String("idle".to_string()));
|
||||
monster_object
|
||||
.entry("facing".to_string())
|
||||
.or_insert_with(|| Value::String("left".to_string()));
|
||||
monster_object
|
||||
.entry("renderKind".to_string())
|
||||
.or_insert_with(|| Value::String("npc".to_string()));
|
||||
monster_object
|
||||
.entry("attackRange".to_string())
|
||||
.or_insert_with(|| json!(1.8));
|
||||
monster_object
|
||||
.entry("speed".to_string())
|
||||
.or_insert_with(|| json!(7));
|
||||
let max_hp = monster_object
|
||||
.get("maxHp")
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or_else(|| if battle_mode == "spar" { 10 } else { 80 });
|
||||
monster_object
|
||||
.entry("hp".to_string())
|
||||
.or_insert_with(|| json!(max_hp));
|
||||
monster
|
||||
}
|
||||
|
||||
fn build_npc_battle_monster_from_encounter(
|
||||
game_state: &Value,
|
||||
encounter: &Value,
|
||||
battle_mode: &str,
|
||||
x_meters: f64,
|
||||
y_offset: i32,
|
||||
) -> Value {
|
||||
let npc_id = read_optional_string_field(encounter, "id")
|
||||
.unwrap_or_else(|| current_encounter_name(game_state));
|
||||
let npc_name = current_encounter_name(game_state);
|
||||
let npc_state =
|
||||
resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str());
|
||||
let affinity = npc_state
|
||||
.and_then(|state| read_i32_field(state, "affinity"))
|
||||
.or_else(|| read_i32_field(encounter, "initialAffinity"))
|
||||
.unwrap_or(0);
|
||||
let base_hp = if battle_mode == "spar" {
|
||||
10
|
||||
} else {
|
||||
(80 + affinity).max(24)
|
||||
};
|
||||
let monster_id = read_optional_string_field(encounter, "monsterPresetId")
|
||||
.unwrap_or_else(|| format!("npc-opponent-{npc_id}"));
|
||||
let mut battle_encounter = encounter.clone();
|
||||
if let Some(entry) = battle_encounter.as_object_mut() {
|
||||
entry.insert("hostile".to_string(), Value::Bool(true));
|
||||
entry.insert("xMeters".to_string(), json!(x_meters));
|
||||
}
|
||||
|
||||
json!({
|
||||
"id": monster_id,
|
||||
"name": npc_name,
|
||||
"action": if battle_mode == "spar" {
|
||||
"抱拳行礼,准备点到为止地切磋武艺"
|
||||
} else {
|
||||
"摆开架势,随时准备出手"
|
||||
},
|
||||
"description": read_optional_string_field(encounter, "npcDescription").unwrap_or_default(),
|
||||
"animation": "idle",
|
||||
"xMeters": x_meters,
|
||||
"yOffset": y_offset,
|
||||
"facing": "left",
|
||||
"attackRange": 1.8,
|
||||
"speed": 7,
|
||||
"hp": base_hp,
|
||||
"maxHp": base_hp,
|
||||
"renderKind": "npc",
|
||||
"levelProfile": read_field(encounter, "levelProfile").cloned(),
|
||||
"experienceReward": read_i32_field(encounter, "experienceReward").unwrap_or(0),
|
||||
"encounter": battle_encounter
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) 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(),
|
||||
current_affinity,
|
||||
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} 已加入队伍")),
|
||||
})
|
||||
}
|
||||
|
||||
/// 先按 NPC 当前遭遇态结算简化版买卖逻辑,保持与 Node compat 一致的字段写回,
|
||||
/// 后续再由真相态 inventory / runtime-item reducer 接管。
|
||||
pub(super) fn resolve_npc_trade_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
let (_npc_id, npc_name) = current_npc_trade_context(game_state)?;
|
||||
let payload = request.action.payload.as_ref();
|
||||
let mode = payload
|
||||
.and_then(|value| read_optional_string_field(value, "mode"))
|
||||
.ok_or_else(|| "npc_trade 缺少合法 mode,需为 buy 或 sell".to_string())?;
|
||||
if mode != "buy" && mode != "sell" {
|
||||
return Err("npc_trade 缺少合法 mode,需为 buy 或 sell".to_string());
|
||||
}
|
||||
let item_id = payload
|
||||
.and_then(|value| {
|
||||
read_optional_string_field(value, "itemId")
|
||||
.or_else(|| read_optional_string_field(value, "selectedNpcItemId"))
|
||||
.or_else(|| read_optional_string_field(value, "selectedPlayerItemId"))
|
||||
})
|
||||
.or_else(|| request.action.target_id.clone())
|
||||
.ok_or_else(|| "npc_trade 缺少 itemId".to_string())?;
|
||||
let quantity = payload
|
||||
.and_then(|value| read_i32_field(value, "quantity"))
|
||||
.unwrap_or(1);
|
||||
if quantity <= 0 {
|
||||
return Err("npc_trade.quantity 必须大于 0".to_string());
|
||||
}
|
||||
|
||||
if mode == "buy" {
|
||||
let npc_item = read_current_npc_inventory_item(game_state, item_id.as_str())
|
||||
.cloned()
|
||||
.ok_or_else(|| "目标商品不存在或库存不足。".to_string())?;
|
||||
let available_quantity = read_i32_field(&npc_item, "quantity").unwrap_or(0).max(0);
|
||||
if available_quantity < quantity {
|
||||
return Err("目标商品不存在或库存不足。".to_string());
|
||||
}
|
||||
let total_price = npc_purchase_price(&npc_item, read_current_npc_affinity(game_state))
|
||||
.saturating_mul(quantity);
|
||||
let player_currency = read_i32_field(game_state, "playerCurrency").unwrap_or(0);
|
||||
if player_currency < total_price {
|
||||
return Err("当前钱币不足,无法完成购买。".to_string());
|
||||
}
|
||||
|
||||
write_i32_field(game_state, "playerCurrency", player_currency - total_price);
|
||||
add_player_inventory_items(
|
||||
game_state,
|
||||
vec![clone_inventory_item_with_quantity(&npc_item, quantity)],
|
||||
);
|
||||
remove_current_npc_inventory_item(game_state, item_id.as_str(), quantity);
|
||||
mark_current_npc_first_meaningful_contact_resolved(game_state);
|
||||
|
||||
let item_name = read_inventory_item_name(&npc_item);
|
||||
return Ok(StoryResolution {
|
||||
action_text: resolve_action_text(
|
||||
&format!(
|
||||
"从{}手里买下{}{}",
|
||||
npc_name,
|
||||
item_name,
|
||||
trade_quantity_suffix(quantity)
|
||||
),
|
||||
request,
|
||||
),
|
||||
result_text: format!(
|
||||
"{}收下了{},把{}{}卖给了你。",
|
||||
npc_name,
|
||||
format_currency_text(
|
||||
total_price,
|
||||
read_optional_string_field(game_state, "worldType").as_deref()
|
||||
),
|
||||
item_name,
|
||||
trade_quantity_suffix(quantity)
|
||||
),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: Vec::new(),
|
||||
battle: None,
|
||||
toast: None,
|
||||
});
|
||||
}
|
||||
|
||||
let player_item = find_player_inventory_entry(game_state, item_id.as_str())
|
||||
.cloned()
|
||||
.ok_or_else(|| "背包里没有足够数量的目标物品。".to_string())?;
|
||||
let available_quantity = read_i32_field(&player_item, "quantity").unwrap_or(0).max(0);
|
||||
if available_quantity < quantity {
|
||||
return Err("背包里没有足够数量的目标物品。".to_string());
|
||||
}
|
||||
let total_price = npc_buyback_price(&player_item, read_current_npc_affinity(game_state))
|
||||
.saturating_mul(quantity);
|
||||
let player_currency = read_i32_field(game_state, "playerCurrency").unwrap_or(0);
|
||||
write_i32_field(
|
||||
game_state,
|
||||
"playerCurrency",
|
||||
player_currency.saturating_add(total_price),
|
||||
);
|
||||
remove_player_inventory_item(game_state, item_id.as_str(), quantity);
|
||||
add_current_npc_inventory_items(
|
||||
game_state,
|
||||
vec![clone_inventory_item_with_quantity(&player_item, quantity)],
|
||||
);
|
||||
mark_current_npc_first_meaningful_contact_resolved(game_state);
|
||||
|
||||
let item_name = read_inventory_item_name(&player_item);
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text(
|
||||
&format!(
|
||||
"把{}{}卖给{}",
|
||||
item_name,
|
||||
trade_quantity_suffix(quantity),
|
||||
npc_name
|
||||
),
|
||||
request,
|
||||
),
|
||||
result_text: format!(
|
||||
"{}收下了{}{},付给你{}。",
|
||||
npc_name,
|
||||
item_name,
|
||||
trade_quantity_suffix(quantity),
|
||||
format_currency_text(
|
||||
total_price,
|
||||
read_optional_string_field(game_state, "worldType").as_deref()
|
||||
)
|
||||
),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: Vec::new(),
|
||||
battle: None,
|
||||
toast: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn resolve_npc_gift_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
let (npc_id, npc_name) = current_npc_trade_context(game_state)?;
|
||||
let item_id = request
|
||||
.action
|
||||
.payload
|
||||
.as_ref()
|
||||
.and_then(|payload| read_optional_string_field(payload, "itemId"))
|
||||
.or_else(|| request.action.target_id.clone())
|
||||
.ok_or_else(|| "npc_gift 缺少 itemId".to_string())?;
|
||||
let gift_item = find_player_inventory_entry(game_state, item_id.as_str())
|
||||
.cloned()
|
||||
.ok_or_else(|| "背包里没有这件可赠送的物品。".to_string())?;
|
||||
if read_i32_field(&gift_item, "quantity").unwrap_or(0) <= 0 {
|
||||
return Err("背包里没有这件可赠送的物品。".to_string());
|
||||
}
|
||||
|
||||
let previous_affinity = read_current_npc_affinity(game_state);
|
||||
let affinity_gain = resolve_npc_gift_affinity_gain(&gift_item);
|
||||
let next_affinity = (previous_affinity + affinity_gain).clamp(-100, 100);
|
||||
remove_player_inventory_item(game_state, item_id.as_str(), 1);
|
||||
add_current_npc_inventory_items(
|
||||
game_state,
|
||||
vec![clone_inventory_item_with_quantity(&gift_item, 1)],
|
||||
);
|
||||
write_current_npc_state_i32_field(game_state, "affinity", next_affinity);
|
||||
let next_gifts_given =
|
||||
read_current_npc_state_i32_field(game_state, "giftsGiven").unwrap_or(0) + 1;
|
||||
write_current_npc_state_i32_field(game_state, "giftsGiven", next_gifts_given);
|
||||
mark_current_npc_first_meaningful_contact_resolved(game_state);
|
||||
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text(
|
||||
&format!("把{}赠给{}", read_inventory_item_name(&gift_item), npc_name),
|
||||
request,
|
||||
),
|
||||
result_text: build_npc_gift_result_text(
|
||||
npc_name.as_str(),
|
||||
&gift_item,
|
||||
affinity_gain,
|
||||
next_affinity,
|
||||
),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: vec![RuntimeStoryPatch::NpcAffinityChanged {
|
||||
npc_id,
|
||||
previous_affinity,
|
||||
next_affinity,
|
||||
}],
|
||||
battle: None,
|
||||
toast: None,
|
||||
})
|
||||
}
|
||||
@@ -1,735 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn build_runtime_story_state_response(
|
||||
requested_session_id: &str,
|
||||
client_version: Option<u32>,
|
||||
mut snapshot: RuntimeStorySnapshotPayload,
|
||||
) -> RuntimeStoryActionResponse {
|
||||
ensure_runtime_story_bridge_state(&mut snapshot.game_state);
|
||||
write_runtime_npc_interaction_view(&mut snapshot.game_state);
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) 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,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_dialogue_current_story(
|
||||
npc_name: &str,
|
||||
text: &str,
|
||||
deferred_options: &[RuntimeStoryOptionView],
|
||||
) -> Value {
|
||||
let continue_option = build_continue_adventure_runtime_story_option();
|
||||
// 对齐 Node 旧 currentStory:先展示单轮对话,只把真实下一步选项压到 deferredOptions。
|
||||
json!({
|
||||
"text": text,
|
||||
"options": vec![build_story_option_from_runtime_option(&continue_option)],
|
||||
"displayMode": "dialogue",
|
||||
"dialogue": parse_dialogue_turns(text, npc_name),
|
||||
"streaming": false,
|
||||
"deferredOptions": deferred_options
|
||||
.iter()
|
||||
.map(build_story_option_from_runtime_option)
|
||||
.collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn build_continue_adventure_runtime_story_option() -> RuntimeStoryOptionView {
|
||||
build_static_runtime_story_option(CONTINUE_ADVENTURE_FUNCTION_ID, "继续推进冒险", "story")
|
||||
}
|
||||
|
||||
pub(super) fn parse_dialogue_turns(text: &str, npc_name: &str) -> Vec<Value> {
|
||||
let mut turns = Vec::new();
|
||||
for raw_line in text.lines() {
|
||||
let line = raw_line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(turn) = parse_dialogue_line(line, npc_name) {
|
||||
turns.push(turn);
|
||||
}
|
||||
}
|
||||
|
||||
if turns.is_empty() && !text.trim().is_empty() {
|
||||
turns.push(json!({
|
||||
"speaker": "npc",
|
||||
"speakerName": npc_name,
|
||||
"text": text.trim(),
|
||||
}));
|
||||
}
|
||||
|
||||
turns
|
||||
}
|
||||
|
||||
pub(super) fn parse_dialogue_line(line: &str, npc_name: &str) -> Option<Value> {
|
||||
let delimiter_index = line.find(':').or_else(|| line.find(':'))?;
|
||||
let speaker_name = line[..delimiter_index].trim();
|
||||
let content_start = delimiter_index + line[delimiter_index..].chars().next()?.len_utf8();
|
||||
let content = line[content_start..].trim();
|
||||
if content.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if speaker_name == "你" {
|
||||
return Some(json!({
|
||||
"speaker": "player",
|
||||
"text": content,
|
||||
}));
|
||||
}
|
||||
|
||||
if speaker_name == npc_name {
|
||||
return Some(json!({
|
||||
"speaker": "npc",
|
||||
"speakerName": npc_name,
|
||||
"text": content,
|
||||
}));
|
||||
}
|
||||
|
||||
Some(json!({
|
||||
"speaker": "companion",
|
||||
"speakerName": speaker_name,
|
||||
"text": content,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) 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)
|
||||
}
|
||||
|
||||
pub(super) fn build_fallback_runtime_story_options(
|
||||
game_state: &Value,
|
||||
) -> Vec<RuntimeStoryOptionView> {
|
||||
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
|
||||
return build_battle_runtime_story_options(game_state);
|
||||
}
|
||||
|
||||
let encounter = read_object_field(game_state, "currentEncounter");
|
||||
if let Some(encounter) = encounter {
|
||||
if matches!(
|
||||
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 build_active_npc_runtime_story_options(game_state, npc_id.as_str());
|
||||
}
|
||||
|
||||
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"),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
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"),
|
||||
]
|
||||
}
|
||||
|
||||
pub(super) 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")
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) 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)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) 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")
|
||||
}
|
||||
}
|
||||
|
||||
/// 对齐 Node 旧 compat 入口顺序,在 NPC 交互态下统一补齐交易、赠礼、委托与招募入口。
|
||||
pub(super) fn build_active_npc_runtime_story_options(
|
||||
game_state: &Value,
|
||||
npc_id: &str,
|
||||
) -> Vec<RuntimeStoryOptionView> {
|
||||
let mut options = vec![
|
||||
build_npc_runtime_story_option("npc_chat", "继续交谈", npc_id, "chat"),
|
||||
build_npc_help_runtime_story_option(game_state, npc_id),
|
||||
build_npc_runtime_story_option("npc_spar", "点到为止切磋", npc_id, "spar"),
|
||||
build_npc_runtime_story_option("npc_fight", "与对方战斗", npc_id, "fight"),
|
||||
];
|
||||
|
||||
if current_npc_inventory_items(game_state)
|
||||
.iter()
|
||||
.any(|item| read_i32_field(item, "quantity").unwrap_or(0) > 0)
|
||||
{
|
||||
options.push(build_npc_runtime_story_option(
|
||||
"npc_trade",
|
||||
"交易",
|
||||
npc_id,
|
||||
"trade",
|
||||
));
|
||||
}
|
||||
|
||||
if has_giftable_player_inventory(game_state) {
|
||||
options.push(build_npc_runtime_story_option(
|
||||
"npc_gift",
|
||||
"赠送礼物",
|
||||
npc_id,
|
||||
"gift",
|
||||
));
|
||||
}
|
||||
|
||||
let active_quest = find_active_quest_for_issuer(game_state, npc_id);
|
||||
if let Some(active_quest) = active_quest {
|
||||
let can_turn_in = read_optional_string_field(active_quest, "status")
|
||||
.is_some_and(|status| status == "completed" || status == "ready_to_turn_in");
|
||||
if can_turn_in {
|
||||
options.push(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"),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
options.push(build_npc_runtime_story_option(
|
||||
"npc_quest_accept",
|
||||
"接下委托",
|
||||
npc_id,
|
||||
"quest_accept",
|
||||
));
|
||||
}
|
||||
|
||||
if read_current_npc_affinity(game_state) >= 60
|
||||
&& !read_current_npc_state_bool_field(game_state, "recruited").unwrap_or(false)
|
||||
{
|
||||
options.push(build_npc_runtime_story_option(
|
||||
"npc_recruit",
|
||||
"邀请同行",
|
||||
npc_id,
|
||||
"recruit",
|
||||
));
|
||||
}
|
||||
|
||||
options.push(build_npc_runtime_story_option(
|
||||
"npc_leave",
|
||||
"离开当前角色",
|
||||
npc_id,
|
||||
"leave",
|
||||
));
|
||||
options
|
||||
}
|
||||
|
||||
pub(super) fn build_npc_help_runtime_story_option(
|
||||
game_state: &Value,
|
||||
npc_id: &str,
|
||||
) -> RuntimeStoryOptionView {
|
||||
if read_current_npc_state_bool_field(game_state, "helpUsed").unwrap_or(false) {
|
||||
return build_disabled_runtime_story_option(
|
||||
"npc_help",
|
||||
"请求援手",
|
||||
"npc",
|
||||
None,
|
||||
"当前 NPC 的一次性援手已经用完了。",
|
||||
None,
|
||||
);
|
||||
}
|
||||
build_npc_runtime_story_option("npc_help", "请求援手", npc_id, "help")
|
||||
}
|
||||
|
||||
pub(super) 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 })
|
||||
}
|
||||
|
||||
pub(super) 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"),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) 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}"
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn append_dialogue_turns(existing: &[Value], additions: Vec<Value>) -> Vec<Value> {
|
||||
let mut dialogue = existing.to_vec();
|
||||
dialogue.extend(additions);
|
||||
dialogue
|
||||
}
|
||||
|
||||
pub(super) 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"
|
||||
}),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
pub(super) 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",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
pub(super) 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",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
pub(super) 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 })),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) 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": "talk_to_npc",
|
||||
"requiredCount": 1
|
||||
},
|
||||
"progress": 0,
|
||||
"status": "active",
|
||||
"reward": {
|
||||
"affinityBonus": 6,
|
||||
"currency": 30,
|
||||
"items": []
|
||||
},
|
||||
"rewardText": "完成后可以领取报酬。",
|
||||
"steps": [{
|
||||
"id": format!("{next_id}-step-1"),
|
||||
"title": "查清线索",
|
||||
"kind": "talk_to_npc",
|
||||
"requiredCount": 1,
|
||||
"progress": 0,
|
||||
"revealText": "先去断桥口附近把相关线索问清楚。",
|
||||
"completeText": "关键线索已经问清。"
|
||||
}],
|
||||
"activeStepId": format!("{next_id}-step-1")
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) 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")
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) 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());
|
||||
}
|
||||
|
||||
pub(super) fn first_quest_reveal_text(quest: &Value) -> Option<String> {
|
||||
read_array_field(quest, "steps")
|
||||
.first()
|
||||
.and_then(|step| read_optional_string_field(step, "revealText"))
|
||||
}
|
||||
|
||||
pub(super) 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}」,接下来可以开始推进任务目标。")
|
||||
}
|
||||
|
||||
pub(super) 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)
|
||||
}
|
||||
|
||||
pub(super) 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}")
|
||||
}
|
||||
|
||||
pub(super) 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");
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) 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
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn read_story_text(current_story: Option<&Value>) -> Option<String> {
|
||||
current_story.and_then(|story| read_optional_string_field(story, "text"))
|
||||
}
|
||||
|
||||
pub(super) 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()
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) 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,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) 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,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) 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,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) 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,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) 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,
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -166,6 +166,27 @@ pub async fn get_story_session_state(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_story_runtime_projection(
|
||||
State(state): State<AppState>,
|
||||
Path(story_session_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let actor_user_id = authenticated.claims().user_id().to_string();
|
||||
let source = state
|
||||
.spacetime_client()
|
||||
.get_story_runtime_projection_source(story_session_id, actor_user_id)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
story_sessions_error_response(&request_context, map_story_session_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
module_runtime_story::build_story_runtime_projection(source),
|
||||
))
|
||||
}
|
||||
|
||||
fn map_story_session_client_error(error: SpacetimeClientError) -> AppError {
|
||||
let status = match &error {
|
||||
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
|
||||
@@ -381,6 +402,61 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_story_runtime_projection_requires_authentication() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/story/sessions/storysess_001/runtime-projection")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_story_runtime_projection_returns_bad_gateway_when_spacetime_not_published() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/story/sessions/storysess_001/runtime-projection")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(false));
|
||||
assert_eq!(
|
||||
payload["error"]["details"]["provider"],
|
||||
Value::String("spacetimedb".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
async fn seed_authenticated_state() -> AppState {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state
|
||||
|
||||
Reference in New Issue
Block a user