推进 server-rs DDD 分层与新接口接线

This commit is contained in:
Codex
2026-04-29 15:46:16 +08:00
parent 9d3fcfae77
commit f82775b852
89 changed files with 3657 additions and 9636 deletions

View File

@@ -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" }

View File

@@ -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"));

View File

@@ -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;

View File

@@ -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,

View File

@@ -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,
};

View File

@@ -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

View File

@@ -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} 的局势随之向下一步展开。")
}

View File

@@ -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)),
})
}

View File

@@ -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));
}
}

View File

@@ -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,
})
}

View File

@@ -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()
}

View File

@@ -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

View File

@@ -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