1
This commit is contained in:
@@ -42,7 +42,7 @@ struct BattleSkillView {
|
||||
build_buffs: Vec<Value>,
|
||||
}
|
||||
|
||||
struct BattleInventoryUseProfile {
|
||||
pub struct BattleInventoryUseProfile {
|
||||
hp_restore: i32,
|
||||
mana_restore: i32,
|
||||
cooldown_reduction: i32,
|
||||
@@ -515,6 +515,29 @@ fn read_player_inventory_items(game_state: &Value) -> Vec<BattleInventoryItemVie
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn read_inventory_item_use_profile(item: &Value) -> Option<BattleInventoryUseProfile> {
|
||||
read_field(item, "useProfile").map(|profile| BattleInventoryUseProfile {
|
||||
hp_restore: read_i32_field(profile, "hpRestore").unwrap_or(0).max(0),
|
||||
mana_restore: read_i32_field(profile, "manaRestore").unwrap_or(0).max(0),
|
||||
cooldown_reduction: read_i32_field(profile, "cooldownReduction")
|
||||
.unwrap_or(0)
|
||||
.max(0),
|
||||
build_buffs: read_array_field(profile, "buildBuffs")
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn inventory_item_has_usable_effect(item: &Value) -> bool {
|
||||
read_inventory_item_use_profile(item).is_some_and(|effect| {
|
||||
effect.hp_restore > 0
|
||||
|| effect.mana_restore > 0
|
||||
|| effect.cooldown_reduction > 0
|
||||
|| !effect.build_buffs.is_empty()
|
||||
})
|
||||
}
|
||||
|
||||
fn find_player_inventory_item(
|
||||
game_state: &Value,
|
||||
item_id: &str,
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
use serde_json::json;
|
||||
|
||||
use shared_contracts::runtime_story::{RuntimeStoryActionRequest, RuntimeStoryChoiceAction, RuntimeStoryPatch};
|
||||
use shared_contracts::runtime_story::{
|
||||
RuntimeStoryActionRequest, RuntimeStoryChoiceAction, RuntimeStoryPatch,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
battle::resolve_battle_action,
|
||||
build_status_patch,
|
||||
read_bool_field,
|
||||
read_i32_field,
|
||||
battle::resolve_battle_action, build_status_patch, read_bool_field, read_i32_field,
|
||||
read_optional_string_field,
|
||||
};
|
||||
|
||||
@@ -82,6 +81,12 @@ fn battle_resolution_prefers_player_defeat_when_both_sides_fall_in_same_turn() {
|
||||
resolution.patches.first(),
|
||||
Some(RuntimeStoryPatch::BattleResolved { outcome, .. }) if outcome == "defeat"
|
||||
));
|
||||
assert_eq!(resolution.patches.get(1), Some(&build_status_patch(&game_state)));
|
||||
assert_eq!(resolution.battle.and_then(|battle| battle.outcome), Some("defeat".to_string()));
|
||||
assert_eq!(
|
||||
resolution.patches.get(1),
|
||||
Some(&build_status_patch(&game_state))
|
||||
);
|
||||
assert_eq!(
|
||||
resolution.battle.and_then(|battle| battle.outcome),
|
||||
Some("defeat".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,18 +10,25 @@ use crate::{
|
||||
///
|
||||
/// 当前仍然保持旧快照态结算口径,不引入 HTTP / AppState / 持久化边界。
|
||||
pub(crate) struct ForgeRequirementDefinition {
|
||||
pub(crate) id: &'static str,
|
||||
pub(crate) label: &'static str,
|
||||
pub(crate) quantity: i32,
|
||||
pub(crate) matcher: ForgeRequirementMatcher,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum ForgeRequirementMatcher {
|
||||
Named(&'static str),
|
||||
TaggedMaterial(&'static str),
|
||||
AnyMaterial,
|
||||
}
|
||||
|
||||
pub(crate) struct ForgeRecipeDefinition {
|
||||
pub(crate) id: &'static str,
|
||||
pub(crate) name: &'static str,
|
||||
pub(crate) kind: &'static str,
|
||||
pub(crate) description: &'static str,
|
||||
pub(crate) result_label: &'static str,
|
||||
pub(crate) currency_cost: i32,
|
||||
pub(crate) requirements: Vec<ForgeRequirementDefinition>,
|
||||
}
|
||||
@@ -32,33 +39,134 @@ pub(crate) struct ReforgeCostDefinition {
|
||||
}
|
||||
|
||||
pub(crate) fn forge_recipe_definition(recipe_id: &str) -> Option<ForgeRecipeDefinition> {
|
||||
match recipe_id {
|
||||
"synthesis-refined-ingot" => Some(ForgeRecipeDefinition {
|
||||
forge_recipe_definitions()
|
||||
.into_iter()
|
||||
.find(|recipe| recipe.id == recipe_id)
|
||||
}
|
||||
|
||||
pub(crate) fn forge_recipe_definitions() -> Vec<ForgeRecipeDefinition> {
|
||||
vec![
|
||||
ForgeRecipeDefinition {
|
||||
id: "synthesis-refined-ingot",
|
||||
name: "压炼锭材",
|
||||
kind: "synthesis",
|
||||
description: "把零散残片和基础材料压成稳定可用的金属锭材。",
|
||||
result_label: "精炼锭材",
|
||||
currency_cost: 18,
|
||||
requirements: vec![ForgeRequirementDefinition {
|
||||
id: "material:any",
|
||||
label: "任意材料",
|
||||
quantity: 3,
|
||||
matcher: ForgeRequirementMatcher::AnyMaterial,
|
||||
}],
|
||||
}),
|
||||
"forge-duelist-blade" => Some(ForgeRecipeDefinition {
|
||||
},
|
||||
ForgeRecipeDefinition {
|
||||
id: "synthesis-condensed-silk",
|
||||
name: "凝光纺丝",
|
||||
kind: "synthesis",
|
||||
description: "用灵性残材与粉末纺出适合饰品锻造的凝光纱。",
|
||||
result_label: "凝光纱",
|
||||
currency_cost: 24,
|
||||
requirements: vec![
|
||||
ForgeRequirementDefinition {
|
||||
id: "material:any",
|
||||
label: "任意材料",
|
||||
quantity: 2,
|
||||
matcher: ForgeRequirementMatcher::AnyMaterial,
|
||||
},
|
||||
ForgeRequirementDefinition {
|
||||
id: "tag:mana",
|
||||
label: "含法力标签材料",
|
||||
quantity: 1,
|
||||
matcher: ForgeRequirementMatcher::TaggedMaterial("mana"),
|
||||
},
|
||||
],
|
||||
},
|
||||
ForgeRecipeDefinition {
|
||||
id: "forge-duelist-blade",
|
||||
name: "锻造 百炼追风剑",
|
||||
kind: "forge",
|
||||
description: "围绕快剑、突进、追击构筑的轻灵主武器。",
|
||||
result_label: "百炼追风剑",
|
||||
currency_cost: 72,
|
||||
requirements: vec![
|
||||
ForgeRequirementDefinition {
|
||||
id: "name:精炼锭材",
|
||||
label: "精炼锭材",
|
||||
quantity: 2,
|
||||
matcher: ForgeRequirementMatcher::Named("精炼锭材"),
|
||||
},
|
||||
ForgeRequirementDefinition {
|
||||
id: "name:快剑精粹",
|
||||
label: "快剑精粹",
|
||||
quantity: 1,
|
||||
matcher: ForgeRequirementMatcher::Named("快剑精粹"),
|
||||
},
|
||||
ForgeRequirementDefinition {
|
||||
id: "name:突进精粹",
|
||||
label: "突进精粹",
|
||||
quantity: 1,
|
||||
matcher: ForgeRequirementMatcher::Named("突进精粹"),
|
||||
},
|
||||
],
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
},
|
||||
ForgeRecipeDefinition {
|
||||
id: "forge-ward-armor",
|
||||
name: "锻造 镇岳护甲",
|
||||
kind: "forge",
|
||||
description: "面向前排承压的护甲,适合守御与护体构筑。",
|
||||
result_label: "镇岳护甲",
|
||||
currency_cost: 78,
|
||||
requirements: vec![
|
||||
ForgeRequirementDefinition {
|
||||
id: "name:精炼锭材",
|
||||
label: "精炼锭材",
|
||||
quantity: 2,
|
||||
matcher: ForgeRequirementMatcher::Named("精炼锭材"),
|
||||
},
|
||||
ForgeRequirementDefinition {
|
||||
id: "name:守御精粹",
|
||||
label: "守御精粹",
|
||||
quantity: 1,
|
||||
matcher: ForgeRequirementMatcher::Named("守御精粹"),
|
||||
},
|
||||
ForgeRequirementDefinition {
|
||||
id: "name:护体精粹",
|
||||
label: "护体精粹",
|
||||
quantity: 1,
|
||||
matcher: ForgeRequirementMatcher::Named("护体精粹"),
|
||||
},
|
||||
],
|
||||
},
|
||||
ForgeRecipeDefinition {
|
||||
id: "forge-thunder-relic",
|
||||
name: "锻造 雷纹灵坠",
|
||||
kind: "forge",
|
||||
description: "为法修、雷法、过载 build 提供资源与爆发补强。",
|
||||
result_label: "雷纹灵坠",
|
||||
currency_cost: 88,
|
||||
requirements: vec![
|
||||
ForgeRequirementDefinition {
|
||||
id: "name:凝光纱",
|
||||
label: "凝光纱",
|
||||
quantity: 2,
|
||||
matcher: ForgeRequirementMatcher::Named("凝光纱"),
|
||||
},
|
||||
ForgeRequirementDefinition {
|
||||
id: "name:法力精粹",
|
||||
label: "法力精粹",
|
||||
quantity: 1,
|
||||
matcher: ForgeRequirementMatcher::Named("法力精粹"),
|
||||
},
|
||||
ForgeRequirementDefinition {
|
||||
id: "name:雷法精粹",
|
||||
label: "雷法精粹",
|
||||
quantity: 1,
|
||||
matcher: ForgeRequirementMatcher::Named("雷法精粹"),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
pub(crate) fn reforge_cost_definition(slot_id: Option<&str>) -> ReforgeCostDefinition {
|
||||
@@ -66,6 +174,8 @@ pub(crate) fn reforge_cost_definition(slot_id: Option<&str>) -> ReforgeCostDefin
|
||||
return ReforgeCostDefinition {
|
||||
currency_cost: 52,
|
||||
requirements: vec![ForgeRequirementDefinition {
|
||||
id: "name:凝光纱",
|
||||
label: "凝光纱",
|
||||
quantity: 1,
|
||||
matcher: ForgeRequirementMatcher::Named("凝光纱"),
|
||||
}],
|
||||
@@ -74,6 +184,8 @@ pub(crate) fn reforge_cost_definition(slot_id: Option<&str>) -> ReforgeCostDefin
|
||||
ReforgeCostDefinition {
|
||||
currency_cost: 46,
|
||||
requirements: vec![ForgeRequirementDefinition {
|
||||
id: "name:精炼锭材",
|
||||
label: "精炼锭材",
|
||||
quantity: 1,
|
||||
matcher: ForgeRequirementMatcher::Named("精炼锭材"),
|
||||
}],
|
||||
@@ -85,17 +197,28 @@ fn forge_requirement_matches(item: &Value, requirement: &ForgeRequirementDefinit
|
||||
ForgeRequirementMatcher::Named(name) => {
|
||||
read_optional_string_field(item, "name").as_deref() == Some(name)
|
||||
}
|
||||
ForgeRequirementMatcher::AnyMaterial => {
|
||||
read_array_field(item, "tags")
|
||||
.into_iter()
|
||||
.filter_map(Value::as_str)
|
||||
.any(|tag| tag == "material")
|
||||
|| read_optional_string_field(item, "category")
|
||||
.is_some_and(|category| category.contains("材料"))
|
||||
ForgeRequirementMatcher::TaggedMaterial(tag) => {
|
||||
is_material_item(item)
|
||||
&& read_array_field(item, "tags")
|
||||
.into_iter()
|
||||
.filter_map(Value::as_str)
|
||||
.any(|item_tag| forge_tag_matches(item_tag, tag))
|
||||
}
|
||||
ForgeRequirementMatcher::AnyMaterial => is_material_item(item),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn count_matching_forge_requirement(
|
||||
inventory: &[Value],
|
||||
requirement: &ForgeRequirementDefinition,
|
||||
) -> i32 {
|
||||
inventory
|
||||
.iter()
|
||||
.filter(|item| forge_requirement_matches(item, requirement))
|
||||
.map(|item| read_i32_field(item, "quantity").unwrap_or(0).max(0))
|
||||
.sum()
|
||||
}
|
||||
|
||||
pub(crate) fn apply_forge_requirements_if_possible(
|
||||
inventory: &[Value],
|
||||
requirements: &[ForgeRequirementDefinition],
|
||||
@@ -125,6 +248,19 @@ pub(crate) fn apply_forge_requirements_if_possible(
|
||||
Some(next_inventory)
|
||||
}
|
||||
|
||||
fn is_material_item(item: &Value) -> bool {
|
||||
read_array_field(item, "tags")
|
||||
.into_iter()
|
||||
.filter_map(Value::as_str)
|
||||
.any(|tag| tag == "material")
|
||||
|| read_optional_string_field(item, "category")
|
||||
.is_some_and(|category| category.contains("材料"))
|
||||
}
|
||||
|
||||
fn forge_tag_matches(item_tag: &str, expected_tag: &str) -> bool {
|
||||
item_tag == expected_tag || (expected_tag == "mana" && item_tag == "法力")
|
||||
}
|
||||
|
||||
pub fn build_runtime_material_item(
|
||||
game_state: &Value,
|
||||
name: &str,
|
||||
@@ -196,6 +332,9 @@ pub(crate) fn build_forge_recipe_result_item(
|
||||
"synthesis-refined-ingot" => {
|
||||
build_runtime_material_item(game_state, "精炼锭材", 1, &["工巧", "守御"], "rare")
|
||||
}
|
||||
"synthesis-condensed-silk" => {
|
||||
build_runtime_material_item(game_state, "凝光纱", 1, &["工巧", "法力"], "rare")
|
||||
}
|
||||
"forge-duelist-blade" => build_runtime_equipment_item(
|
||||
game_state,
|
||||
"百炼追风剑",
|
||||
@@ -210,6 +349,38 @@ pub(crate) fn build_forge_recipe_result_item(
|
||||
"outgoingDamageBonus": 0.20
|
||||
}),
|
||||
),
|
||||
"forge-ward-armor" => build_runtime_equipment_item(
|
||||
game_state,
|
||||
"镇岳护甲",
|
||||
"armor",
|
||||
"epic",
|
||||
"厚重但稳定的护甲套件,适合顶住正面压力后再伺机反打。",
|
||||
"守御",
|
||||
&["守御", "护体", "先锋"],
|
||||
&["守御", "护体", "先锋"],
|
||||
json!({
|
||||
"maxHpBonus": 56,
|
||||
"maxManaBonus": 8,
|
||||
"outgoingDamageBonus": 0.08,
|
||||
"incomingDamageMultiplier": 0.84
|
||||
}),
|
||||
),
|
||||
"forge-thunder-relic" => build_runtime_equipment_item(
|
||||
game_state,
|
||||
"雷纹灵坠",
|
||||
"relic",
|
||||
"epic",
|
||||
"内封雷纹与灵引回路的饰品,能在短窗口内快速放大法术节奏。",
|
||||
"法修",
|
||||
&["法修", "雷法", "过载"],
|
||||
&["法修", "雷法", "过载"],
|
||||
json!({
|
||||
"maxHpBonus": 8,
|
||||
"maxManaBonus": 42,
|
||||
"outgoingDamageBonus": 0.14,
|
||||
"incomingDamageMultiplier": 0.92
|
||||
}),
|
||||
),
|
||||
_ => build_runtime_material_item(game_state, "临时锻造产物", 1, &["工巧"], "common"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,10 +13,14 @@ pub mod forge_actions;
|
||||
pub mod game_state;
|
||||
pub mod npc_support;
|
||||
pub mod options;
|
||||
pub mod post_battle;
|
||||
pub mod prompt_context;
|
||||
pub mod story_engine;
|
||||
pub mod view_model;
|
||||
|
||||
pub use battle::{
|
||||
build_battle_runtime_story_options, resolve_battle_action, restore_player_resource,
|
||||
build_battle_runtime_story_options, inventory_item_has_usable_effect, resolve_battle_action,
|
||||
restore_player_resource,
|
||||
};
|
||||
pub use core::{
|
||||
MAX_PLAYER_LEVEL, add_player_currency, add_player_inventory_items, append_active_build_buffs,
|
||||
@@ -47,14 +51,20 @@ pub use game_state::{
|
||||
write_runtime_equipment_bonus_cache,
|
||||
};
|
||||
pub use npc_support::{
|
||||
build_npc_gift_result_text, npc_buyback_price, npc_purchase_price, recruit_companion_to_party,
|
||||
resolve_npc_gift_affinity_gain, trade_quantity_suffix,
|
||||
build_npc_gift_result_text, build_runtime_npc_interaction_view, npc_buyback_price,
|
||||
npc_purchase_price, recruit_companion_to_party, resolve_npc_gift_affinity_gain,
|
||||
trade_quantity_suffix, write_runtime_npc_interaction_view,
|
||||
};
|
||||
pub use options::{
|
||||
build_disabled_runtime_story_option, build_runtime_story_option_from_story_option,
|
||||
build_runtime_story_option_interaction, build_runtime_story_option_with_payload,
|
||||
build_static_runtime_story_option, build_story_option_from_runtime_option, infer_option_scope,
|
||||
};
|
||||
pub use post_battle::{
|
||||
finalize_post_battle_resolution, is_terminal_battle_outcome, resolve_post_battle_story_options,
|
||||
};
|
||||
pub use prompt_context::{RuntimeStoryPromptContextExtras, build_runtime_story_prompt_context};
|
||||
pub use story_engine::project_story_engine_after_action;
|
||||
pub use view_model::{
|
||||
build_runtime_story_companions, build_runtime_story_encounter, build_runtime_story_view_model,
|
||||
resolve_current_encounter_npc_state,
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use shared_contracts::runtime_story::{
|
||||
RuntimeNpcGiftItemView, RuntimeNpcGiftView, RuntimeNpcInteractionView, RuntimeNpcTradeItemView,
|
||||
RuntimeNpcTradeView,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
MAX_TASK5_COMPANIONS, ensure_json_object, item_rarity_key, normalize_required_string,
|
||||
read_array_field, read_i32_field, read_inventory_item_name, read_optional_string_field,
|
||||
read_array_field, read_bool_field, read_i32_field, read_inventory_item_name, read_object_field,
|
||||
read_optional_string_field, read_required_string_field,
|
||||
};
|
||||
|
||||
pub fn resolve_npc_gift_affinity_gain(item: &Value) -> i32 {
|
||||
@@ -142,6 +148,177 @@ pub fn trade_quantity_suffix(quantity: i32) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn currency_name_for_world(world_type: Option<&str>) -> String {
|
||||
match world_type {
|
||||
Some("XIANXIA") => "灵石",
|
||||
Some("WUXIA") => "铜钱",
|
||||
_ => "钱币",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn read_runtime_npc_state<'a>(
|
||||
game_state: &'a Value,
|
||||
encounter_id: &str,
|
||||
npc_name: &str,
|
||||
) -> Option<&'a Value> {
|
||||
let npc_states = read_object_field(game_state, "npcStates")?;
|
||||
|
||||
npc_states
|
||||
.get(encounter_id)
|
||||
.or_else(|| npc_states.get(npc_name))
|
||||
}
|
||||
|
||||
fn read_item_id(item: &Value) -> Option<String> {
|
||||
read_required_string_field(item, "id")
|
||||
}
|
||||
|
||||
fn sanitize_item_for_view(item: &Value) -> Value {
|
||||
let mut item = item.clone();
|
||||
if let Some(object) = item.as_object_mut() {
|
||||
object.retain(|key, _| key != "__internal");
|
||||
}
|
||||
item
|
||||
}
|
||||
|
||||
fn build_trade_item_view(params: BuildTradeItemViewParams<'_>) -> RuntimeNpcTradeItemView {
|
||||
let quantity = read_i32_field(params.item, "quantity").unwrap_or(0).max(0);
|
||||
let unit_price = match params.mode {
|
||||
"buy" => npc_purchase_price(params.item, params.affinity),
|
||||
_ => npc_buyback_price(params.item, params.affinity),
|
||||
};
|
||||
let mut reason = None;
|
||||
if quantity <= 0 {
|
||||
reason = Some(if params.mode == "buy" {
|
||||
"NPC 库存不足。".to_string()
|
||||
} else {
|
||||
"背包数量不足。".to_string()
|
||||
});
|
||||
} else if params.mode == "buy" && params.player_currency < unit_price {
|
||||
reason = Some("当前钱币不足。".to_string());
|
||||
}
|
||||
|
||||
RuntimeNpcTradeItemView {
|
||||
item_id: params.item_id.to_string(),
|
||||
item: sanitize_item_for_view(params.item),
|
||||
mode: params.mode.to_string(),
|
||||
unit_price,
|
||||
max_quantity: quantity,
|
||||
can_submit: reason.is_none(),
|
||||
reason,
|
||||
}
|
||||
}
|
||||
|
||||
struct BuildTradeItemViewParams<'a> {
|
||||
item_id: &'a str,
|
||||
item: &'a Value,
|
||||
mode: &'a str,
|
||||
affinity: i32,
|
||||
player_currency: i32,
|
||||
}
|
||||
|
||||
/// 编译 NPC 交易 / 送礼展示用 view。
|
||||
///
|
||||
/// 中文注释:这份 view 只服务前端展示与按钮状态,正式结算仍会在
|
||||
/// `resolve_npc_trade_action` / `resolve_npc_gift_action` 中重新校验。
|
||||
pub fn build_runtime_npc_interaction_view(game_state: &Value) -> Option<RuntimeNpcInteractionView> {
|
||||
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
|
||||
return None;
|
||||
}
|
||||
if !read_bool_field(game_state, "npcInteractionActive").unwrap_or(false) {
|
||||
return None;
|
||||
}
|
||||
|
||||
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 npc_id = read_optional_string_field(encounter, "id").unwrap_or_else(|| npc_name.clone());
|
||||
let npc_state = read_runtime_npc_state(game_state, npc_id.as_str(), npc_name.as_str())?;
|
||||
let affinity = read_i32_field(npc_state, "affinity").unwrap_or(0);
|
||||
let player_currency = read_i32_field(game_state, "playerCurrency")
|
||||
.unwrap_or(0)
|
||||
.max(0);
|
||||
let currency_name =
|
||||
currency_name_for_world(read_optional_string_field(game_state, "worldType").as_deref());
|
||||
|
||||
let buy_items = read_array_field(npc_state, "inventory")
|
||||
.into_iter()
|
||||
.filter_map(|item| {
|
||||
let item_id = read_item_id(item)?;
|
||||
Some(build_trade_item_view(BuildTradeItemViewParams {
|
||||
item_id: item_id.as_str(),
|
||||
item,
|
||||
mode: "buy",
|
||||
affinity,
|
||||
player_currency,
|
||||
}))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let sell_items = read_array_field(game_state, "playerInventory")
|
||||
.into_iter()
|
||||
.filter_map(|item| {
|
||||
let item_id = read_item_id(item)?;
|
||||
Some(build_trade_item_view(BuildTradeItemViewParams {
|
||||
item_id: item_id.as_str(),
|
||||
item,
|
||||
mode: "sell",
|
||||
affinity,
|
||||
player_currency,
|
||||
}))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let gift_items = read_array_field(game_state, "playerInventory")
|
||||
.into_iter()
|
||||
.filter_map(|item| {
|
||||
let item_id = read_item_id(item)?;
|
||||
let quantity = read_i32_field(item, "quantity").unwrap_or(0).max(0);
|
||||
let reason = if quantity <= 0 {
|
||||
Some("背包里没有这件可赠送的物品。".to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Some(RuntimeNpcGiftItemView {
|
||||
item_id,
|
||||
item: sanitize_item_for_view(item),
|
||||
affinity_gain: resolve_npc_gift_affinity_gain(item),
|
||||
can_submit: reason.is_none(),
|
||||
reason,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Some(RuntimeNpcInteractionView {
|
||||
npc_id,
|
||||
npc_name,
|
||||
player_currency,
|
||||
currency_name,
|
||||
trade: RuntimeNpcTradeView {
|
||||
buy_items,
|
||||
sell_items,
|
||||
},
|
||||
gift: RuntimeNpcGiftView { items: gift_items },
|
||||
})
|
||||
}
|
||||
|
||||
/// 将 NPC 交互 view 写入快照 JSON,方便旧前端在 hydrated snapshot 上直接读取。
|
||||
pub fn write_runtime_npc_interaction_view(game_state: &mut Value) {
|
||||
let view = build_runtime_npc_interaction_view(game_state);
|
||||
let root = ensure_json_object(game_state);
|
||||
match view {
|
||||
Some(view) => {
|
||||
let value = serde_json::to_value(view).unwrap_or_else(|_| Value::Object(Map::new()));
|
||||
root.insert("runtimeNpcInteraction".to_string(), value);
|
||||
}
|
||||
None => {
|
||||
root.remove("runtimeNpcInteraction");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn add_companion_if_absent(
|
||||
game_state: &mut Value,
|
||||
npc_id: &str,
|
||||
|
||||
903
server-rs/crates/module-runtime-story-compat/src/post_battle.rs
Normal file
903
server-rs/crates/module-runtime-story-compat/src/post_battle.rs
Normal file
@@ -0,0 +1,903 @@
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::runtime_story::RuntimeStoryOptionView;
|
||||
|
||||
use crate::{
|
||||
CONTINUE_ADVENTURE_FUNCTION_ID, build_static_runtime_story_option,
|
||||
build_story_option_from_runtime_option, ensure_json_object, read_array_field, read_bool_field,
|
||||
read_field, read_i32_field, read_object_field, read_optional_string_field, write_bool_field,
|
||||
write_i32_field, write_null_field, write_string_field,
|
||||
};
|
||||
|
||||
const WUXIA_FIRST_SCENE_ID: &str = "wuxia-bamboo-road";
|
||||
const WUXIA_FIRST_SCENE_NAME: &str = "竹林古道";
|
||||
const WUXIA_FIRST_SCENE_DESCRIPTION: &str =
|
||||
"风过竹叶如刀鸣,窄道蜿蜒向深处,最适合藏伏毒物和游侠。";
|
||||
const XIANXIA_FIRST_SCENE_ID: &str = "xianxia-cloud-gate";
|
||||
const XIANXIA_FIRST_SCENE_NAME: &str = "云海仙门";
|
||||
const XIANXIA_FIRST_SCENE_DESCRIPTION: &str =
|
||||
"云阶在脚下翻涌,门阙后方灵光不断,来客与守门异物都极显眼。";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PostBattleFinalization {
|
||||
pub story_text: String,
|
||||
pub presentation_options: Vec<RuntimeStoryOptionView>,
|
||||
pub saved_current_story: Value,
|
||||
}
|
||||
|
||||
/// 战斗终局统一由后端收口,前端只负责播放 presentation。
|
||||
pub fn finalize_post_battle_resolution(
|
||||
game_state: &mut Value,
|
||||
result_text: &str,
|
||||
outcome: Option<&str>,
|
||||
fallback_options: Vec<RuntimeStoryOptionView>,
|
||||
) -> Option<PostBattleFinalization> {
|
||||
let outcome = outcome?;
|
||||
if !is_terminal_battle_outcome(outcome) {
|
||||
return None;
|
||||
}
|
||||
|
||||
if outcome == "defeat" {
|
||||
return Some(finalize_defeat_revive(game_state, fallback_options));
|
||||
}
|
||||
|
||||
if outcome == "victory" || outcome == "spar_complete" {
|
||||
return Some(finalize_victory_or_spar(
|
||||
game_state,
|
||||
result_text,
|
||||
fallback_options,
|
||||
));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn is_terminal_battle_outcome(outcome: &str) -> bool {
|
||||
matches!(outcome, "victory" | "spar_complete" | "defeat")
|
||||
}
|
||||
|
||||
/// 后端战斗后故事选项只返回可展示 DTO,不再让前端重算章节推进结果。
|
||||
pub fn resolve_post_battle_story_options(game_state: &Value) -> Vec<RuntimeStoryOptionView> {
|
||||
build_scene_travel_options(game_state)
|
||||
}
|
||||
|
||||
fn finalize_victory_or_spar(
|
||||
game_state: &mut Value,
|
||||
result_text: &str,
|
||||
fallback_options: Vec<RuntimeStoryOptionView>,
|
||||
) -> PostBattleFinalization {
|
||||
clear_post_battle_state(game_state);
|
||||
let is_last_act = is_current_scene_act_last(game_state);
|
||||
let next_act_state = if is_last_act {
|
||||
None
|
||||
} else {
|
||||
resolve_next_scene_act_runtime_state(game_state)
|
||||
};
|
||||
if let Some(next_act_state) = next_act_state {
|
||||
write_current_scene_act_state(game_state, next_act_state);
|
||||
}
|
||||
|
||||
let deferred_options = if fallback_options.is_empty() {
|
||||
build_scene_travel_options(game_state)
|
||||
} else {
|
||||
fallback_options
|
||||
};
|
||||
let options = if is_last_act {
|
||||
deferred_options.clone()
|
||||
} else {
|
||||
vec![continue_adventure_option()]
|
||||
};
|
||||
let saved_current_story = if is_last_act {
|
||||
build_plain_current_story(result_text, &deferred_options)
|
||||
} else {
|
||||
build_deferred_current_story(
|
||||
result_text,
|
||||
&deferred_options,
|
||||
current_scene_act_state(game_state),
|
||||
)
|
||||
};
|
||||
|
||||
PostBattleFinalization {
|
||||
story_text: result_text.to_string(),
|
||||
presentation_options: options,
|
||||
saved_current_story,
|
||||
}
|
||||
}
|
||||
|
||||
fn finalize_defeat_revive(
|
||||
game_state: &mut Value,
|
||||
_fallback_options: Vec<RuntimeStoryOptionView>,
|
||||
) -> PostBattleFinalization {
|
||||
let first_scene = resolve_first_scene(game_state);
|
||||
write_first_scene(game_state, &first_scene);
|
||||
write_null_field(game_state, "currentEncounter");
|
||||
write_bool_field(game_state, "npcInteractionActive", false);
|
||||
ensure_json_object(game_state).insert("sceneHostileNpcs".to_string(), Value::Array(Vec::new()));
|
||||
write_i32_field(game_state, "playerX", 0);
|
||||
write_string_field(game_state, "playerFacing", "right");
|
||||
let player_max_hp = read_i32_field(game_state, "playerMaxHp")
|
||||
.unwrap_or(1)
|
||||
.max(1);
|
||||
let player_max_mana = read_i32_field(game_state, "playerMaxMana")
|
||||
.unwrap_or(0)
|
||||
.max(0);
|
||||
write_i32_field(game_state, "playerHp", player_max_hp);
|
||||
write_i32_field(game_state, "playerMana", player_max_mana);
|
||||
write_bool_field(game_state, "inBattle", false);
|
||||
write_null_field(game_state, "currentBattleNpcId");
|
||||
write_null_field(game_state, "currentNpcBattleMode");
|
||||
write_null_field(game_state, "currentNpcBattleOutcome");
|
||||
write_null_field(game_state, "sparReturnEncounter");
|
||||
write_null_field(game_state, "sparPlayerHpBefore");
|
||||
write_null_field(game_state, "sparPlayerMaxHpBefore");
|
||||
write_null_field(game_state, "sparStoryHistoryBefore");
|
||||
write_string_field(game_state, "animationState", "idle");
|
||||
write_string_field(game_state, "playerActionMode", "idle");
|
||||
ensure_json_object(game_state)
|
||||
.insert("activeCombatEffects".to_string(), Value::Array(Vec::new()));
|
||||
write_bool_field(game_state, "scrollWorld", false);
|
||||
|
||||
if let Some(first_act_state) =
|
||||
build_initial_scene_act_runtime_state(game_state, &first_scene.id)
|
||||
{
|
||||
write_current_scene_act_state(game_state, first_act_state);
|
||||
}
|
||||
ensure_first_scene_encounter_preview(game_state);
|
||||
|
||||
let story_text = if first_scene.name.is_empty() {
|
||||
"你在战斗中倒下,随后重新醒来。".to_string()
|
||||
} else {
|
||||
format!("你在战斗中倒下,随后在{}重新醒来。", first_scene.name)
|
||||
};
|
||||
// 中文注释:败北复活后的正式选项必须基于复活后的首场景重新生成,
|
||||
// 不能沿用战斗结算前旧场景的 fallback options。
|
||||
let deferred_options = build_scene_travel_options(game_state);
|
||||
let saved_current_story = build_death_current_story(story_text.as_str(), &deferred_options);
|
||||
|
||||
PostBattleFinalization {
|
||||
story_text,
|
||||
presentation_options: vec![continue_adventure_option()],
|
||||
saved_current_story,
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_post_battle_state(game_state: &mut Value) {
|
||||
write_null_field(game_state, "currentEncounter");
|
||||
write_bool_field(game_state, "npcInteractionActive", false);
|
||||
ensure_json_object(game_state).insert("sceneHostileNpcs".to_string(), Value::Array(Vec::new()));
|
||||
write_bool_field(game_state, "inBattle", false);
|
||||
write_null_field(game_state, "currentBattleNpcId");
|
||||
write_null_field(game_state, "currentNpcBattleMode");
|
||||
write_null_field(game_state, "currentNpcBattleOutcome");
|
||||
write_null_field(game_state, "sparReturnEncounter");
|
||||
write_null_field(game_state, "sparPlayerHpBefore");
|
||||
write_null_field(game_state, "sparPlayerMaxHpBefore");
|
||||
write_null_field(game_state, "sparStoryHistoryBefore");
|
||||
write_string_field(game_state, "animationState", "idle");
|
||||
write_string_field(game_state, "playerActionMode", "idle");
|
||||
ensure_json_object(game_state)
|
||||
.insert("activeCombatEffects".to_string(), Value::Array(Vec::new()));
|
||||
write_bool_field(game_state, "scrollWorld", false);
|
||||
}
|
||||
|
||||
fn continue_adventure_option() -> RuntimeStoryOptionView {
|
||||
build_static_runtime_story_option(CONTINUE_ADVENTURE_FUNCTION_ID, "继续前进", "story")
|
||||
}
|
||||
|
||||
fn build_plain_current_story(text: &str, options: &[RuntimeStoryOptionView]) -> Value {
|
||||
json!({
|
||||
"text": text,
|
||||
"options": options.iter().map(build_story_option_from_runtime_option).collect::<Vec<_>>(),
|
||||
"streaming": false
|
||||
})
|
||||
}
|
||||
|
||||
fn build_deferred_current_story(
|
||||
text: &str,
|
||||
deferred_options: &[RuntimeStoryOptionView],
|
||||
deferred_act_state: Option<Value>,
|
||||
) -> Value {
|
||||
let mut story = json!({
|
||||
"text": text,
|
||||
"options": vec![build_story_option_from_runtime_option(&continue_adventure_option())],
|
||||
"deferredOptions": deferred_options
|
||||
.iter()
|
||||
.map(build_story_option_from_runtime_option)
|
||||
.collect::<Vec<_>>(),
|
||||
"streaming": false
|
||||
});
|
||||
if let Some(deferred_act_state) = deferred_act_state {
|
||||
if let Some(object) = story.as_object_mut() {
|
||||
object.insert(
|
||||
"deferredRuntimeState".to_string(),
|
||||
json!({
|
||||
"storyEngineMemory": {
|
||||
"currentSceneActState": deferred_act_state
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
story
|
||||
}
|
||||
|
||||
fn build_death_current_story(text: &str, deferred_options: &[RuntimeStoryOptionView]) -> Value {
|
||||
let mut story = json!({
|
||||
"text": text,
|
||||
"options": vec![build_story_option_from_runtime_option(&continue_adventure_option())],
|
||||
"streaming": false
|
||||
});
|
||||
if !deferred_options.is_empty() {
|
||||
if let Some(object) = story.as_object_mut() {
|
||||
object.insert(
|
||||
"deferredOptions".to_string(),
|
||||
Value::Array(
|
||||
deferred_options
|
||||
.iter()
|
||||
.map(build_story_option_from_runtime_option)
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
story
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct RuntimeScene {
|
||||
id: String,
|
||||
name: String,
|
||||
description: String,
|
||||
image_src: String,
|
||||
connected_scene_ids: Vec<String>,
|
||||
connections: Vec<Value>,
|
||||
forward_scene_id: Option<String>,
|
||||
treasure_hints: Vec<String>,
|
||||
npcs: Vec<Value>,
|
||||
}
|
||||
|
||||
fn resolve_first_scene(game_state: &Value) -> RuntimeScene {
|
||||
if let Some(profile) = read_object_field(game_state, "customWorldProfile") {
|
||||
return build_custom_first_scene(profile);
|
||||
}
|
||||
|
||||
match read_optional_string_field(game_state, "worldType").as_deref() {
|
||||
Some("XIANXIA") => RuntimeScene {
|
||||
id: XIANXIA_FIRST_SCENE_ID.to_string(),
|
||||
name: XIANXIA_FIRST_SCENE_NAME.to_string(),
|
||||
description: XIANXIA_FIRST_SCENE_DESCRIPTION.to_string(),
|
||||
image_src: read_object_field(game_state, "currentScenePreset")
|
||||
.and_then(|scene| read_optional_string_field(scene, "imageSrc"))
|
||||
.unwrap_or_default(),
|
||||
connected_scene_ids: vec![
|
||||
"xianxia-floating-isle".to_string(),
|
||||
"xianxia-celestial-corridor".to_string(),
|
||||
"xianxia-star-vessel".to_string(),
|
||||
],
|
||||
connections: vec![
|
||||
json!({
|
||||
"sceneId": "xianxia-celestial-corridor",
|
||||
"relativePosition": "forward",
|
||||
"summary": "沿主路继续深入前方区域"
|
||||
}),
|
||||
json!({
|
||||
"sceneId": "xianxia-floating-isle",
|
||||
"relativePosition": "left",
|
||||
"summary": "这里分出一条支路"
|
||||
}),
|
||||
json!({
|
||||
"sceneId": "xianxia-star-vessel",
|
||||
"relativePosition": "right",
|
||||
"summary": "这里还能转向另一条路"
|
||||
}),
|
||||
],
|
||||
forward_scene_id: Some("xianxia-celestial-corridor".to_string()),
|
||||
treasure_hints: vec![
|
||||
"云阶尽头的灵符匣".to_string(),
|
||||
"门阙阴影里的玉牌".to_string(),
|
||||
],
|
||||
npcs: Vec::new(),
|
||||
},
|
||||
_ => RuntimeScene {
|
||||
id: WUXIA_FIRST_SCENE_ID.to_string(),
|
||||
name: WUXIA_FIRST_SCENE_NAME.to_string(),
|
||||
description: WUXIA_FIRST_SCENE_DESCRIPTION.to_string(),
|
||||
image_src: read_object_field(game_state, "currentScenePreset")
|
||||
.and_then(|scene| read_optional_string_field(scene, "imageSrc"))
|
||||
.unwrap_or_default(),
|
||||
connected_scene_ids: vec![
|
||||
"wuxia-mountain-gate".to_string(),
|
||||
"wuxia-mist-woods".to_string(),
|
||||
"wuxia-ferry-bridge".to_string(),
|
||||
],
|
||||
connections: vec![
|
||||
json!({
|
||||
"sceneId": "wuxia-mountain-gate",
|
||||
"relativePosition": "forward",
|
||||
"summary": "沿主路继续深入前方区域"
|
||||
}),
|
||||
json!({
|
||||
"sceneId": "wuxia-mist-woods",
|
||||
"relativePosition": "left",
|
||||
"summary": "这里分出一条支路"
|
||||
}),
|
||||
json!({
|
||||
"sceneId": "wuxia-ferry-bridge",
|
||||
"relativePosition": "right",
|
||||
"summary": "这里还能转向另一条路"
|
||||
}),
|
||||
],
|
||||
forward_scene_id: Some("wuxia-mountain-gate".to_string()),
|
||||
treasure_hints: vec!["竹根旁半埋的刀鞘".to_string(), "倒竹间的旧药囊".to_string()],
|
||||
npcs: Vec::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn build_custom_first_scene(profile: &Value) -> RuntimeScene {
|
||||
let camp = read_object_field(profile, "camp");
|
||||
let scene_id = camp
|
||||
.and_then(|camp| read_optional_string_field(camp, "id"))
|
||||
.unwrap_or_else(|| "custom-scene-camp".to_string());
|
||||
let scene_name = camp
|
||||
.and_then(|camp| read_optional_string_field(camp, "name"))
|
||||
.or_else(|| read_optional_string_field(profile, "name").map(|name| format!("{name}营地")))
|
||||
.unwrap_or_else(|| "开局营地".to_string());
|
||||
let description = camp
|
||||
.and_then(|camp| read_optional_string_field(camp, "description"))
|
||||
.or_else(|| read_optional_string_field(profile, "summary"))
|
||||
.unwrap_or_else(|| "你重新回到了旅途起点。".to_string());
|
||||
let connections = if let Some(camp) = camp {
|
||||
read_array_field(camp, "connections")
|
||||
.into_iter()
|
||||
.filter_map(|connection| {
|
||||
let target_landmark_id =
|
||||
read_optional_string_field(connection, "targetLandmarkId")?;
|
||||
let scene_id =
|
||||
custom_landmark_runtime_scene_id(profile, target_landmark_id.as_str())?;
|
||||
Some(json!({
|
||||
"sceneId": scene_id,
|
||||
"relativePosition": read_optional_string_field(connection, "relativePosition")
|
||||
.unwrap_or_else(|| "forward".to_string()),
|
||||
"summary": read_optional_string_field(connection, "summary").unwrap_or_default()
|
||||
}))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let connected_scene_ids = connections
|
||||
.iter()
|
||||
.filter_map(|connection| read_optional_string_field(connection, "sceneId"))
|
||||
.collect::<Vec<_>>();
|
||||
let forward_scene_id = connections
|
||||
.iter()
|
||||
.find(|connection| {
|
||||
read_optional_string_field(connection, "relativePosition").as_deref() == Some("forward")
|
||||
})
|
||||
.and_then(|connection| read_optional_string_field(connection, "sceneId"))
|
||||
.or_else(|| connected_scene_ids.first().cloned());
|
||||
|
||||
RuntimeScene {
|
||||
id: "custom-scene-camp".to_string(),
|
||||
name: scene_name,
|
||||
description,
|
||||
image_src: camp
|
||||
.and_then(|camp| read_optional_string_field(camp, "imageSrc"))
|
||||
.unwrap_or_default(),
|
||||
connected_scene_ids,
|
||||
connections,
|
||||
forward_scene_id,
|
||||
treasure_hints: vec![format!(
|
||||
"{}地图残页",
|
||||
read_optional_string_field(profile, "name").unwrap_or_else(|| "当前世界".to_string())
|
||||
)],
|
||||
npcs: build_custom_scene_npcs_for_scene(profile, scene_id.as_str()),
|
||||
}
|
||||
}
|
||||
|
||||
fn custom_landmark_runtime_scene_id(profile: &Value, landmark_id: &str) -> Option<String> {
|
||||
read_array_field(profile, "landmarks")
|
||||
.into_iter()
|
||||
.position(|landmark| {
|
||||
read_optional_string_field(landmark, "id").as_deref() == Some(landmark_id)
|
||||
})
|
||||
.map(|index| format!("custom-scene-landmark-{}", index + 1))
|
||||
}
|
||||
|
||||
fn write_first_scene(game_state: &mut Value, scene: &RuntimeScene) {
|
||||
ensure_json_object(game_state).insert(
|
||||
"currentScenePreset".to_string(),
|
||||
json!({
|
||||
"id": scene.id,
|
||||
"name": scene.name,
|
||||
"description": scene.description,
|
||||
"imageSrc": scene.image_src,
|
||||
"connectedSceneIds": scene.connected_scene_ids,
|
||||
"connections": scene.connections,
|
||||
"forwardSceneId": scene.forward_scene_id,
|
||||
"treasureHints": scene.treasure_hints,
|
||||
"npcs": scene.npcs,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
fn ensure_first_scene_encounter_preview(game_state: &mut Value) {
|
||||
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
|
||||
return;
|
||||
}
|
||||
if !read_array_field(game_state, "sceneHostileNpcs").is_empty()
|
||||
|| read_field(game_state, "currentEncounter").is_some_and(|value| !value.is_null())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(profile) = read_object_field(game_state, "customWorldProfile") else {
|
||||
return;
|
||||
};
|
||||
let scene_id = read_object_field(game_state, "currentScenePreset")
|
||||
.and_then(|scene| read_optional_string_field(scene, "id"));
|
||||
let focus_npc_id = resolve_active_scene_act_focus_npc_id(profile, scene_id.as_deref());
|
||||
let Some(focus_npc_id) = focus_npc_id else {
|
||||
return;
|
||||
};
|
||||
let Some(npc) = find_custom_world_role(profile, focus_npc_id.as_str()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
ensure_json_object(game_state).insert(
|
||||
"currentEncounter".to_string(),
|
||||
build_encounter_from_role(&npc, 12.0),
|
||||
);
|
||||
}
|
||||
|
||||
fn build_scene_travel_options(game_state: &Value) -> Vec<RuntimeStoryOptionView> {
|
||||
let Some(current_scene) = read_object_field(game_state, "currentScenePreset") else {
|
||||
return vec![build_static_runtime_story_option(
|
||||
"idle_explore_forward",
|
||||
"继续向前探索",
|
||||
"story",
|
||||
)];
|
||||
};
|
||||
let current_scene_id = read_optional_string_field(current_scene, "id");
|
||||
let mut options = read_array_field(current_scene, "connections")
|
||||
.into_iter()
|
||||
.filter_map(|connection| {
|
||||
let scene_id = read_optional_string_field(connection, "sceneId")?;
|
||||
if current_scene_id.as_deref() == Some(scene_id.as_str()) {
|
||||
return None;
|
||||
}
|
||||
let relative_position = read_optional_string_field(connection, "relativePosition")
|
||||
.unwrap_or_else(|| "forward".to_string());
|
||||
let scene_name = resolve_scene_name(game_state, scene_id.as_str())
|
||||
.unwrap_or_else(|| scene_id.clone());
|
||||
Some(RuntimeStoryOptionView {
|
||||
payload: Some(json!({ "targetSceneId": scene_id })),
|
||||
..build_static_runtime_story_option(
|
||||
"idle_travel_next_scene",
|
||||
format!(
|
||||
"{},前往{}",
|
||||
direction_text(relative_position.as_str()),
|
||||
scene_name
|
||||
)
|
||||
.as_str(),
|
||||
"story",
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if options.is_empty() {
|
||||
options.push(build_static_runtime_story_option(
|
||||
"idle_explore_forward",
|
||||
"继续向前探索",
|
||||
"story",
|
||||
));
|
||||
}
|
||||
|
||||
options
|
||||
}
|
||||
|
||||
fn resolve_scene_name(game_state: &Value, scene_id: &str) -> Option<String> {
|
||||
if read_object_field(game_state, "currentScenePreset")
|
||||
.and_then(|scene| read_optional_string_field(scene, "id"))
|
||||
.as_deref()
|
||||
== Some(scene_id)
|
||||
{
|
||||
return read_object_field(game_state, "currentScenePreset")
|
||||
.and_then(|scene| read_optional_string_field(scene, "name"));
|
||||
}
|
||||
|
||||
let profile = read_object_field(game_state, "customWorldProfile")?;
|
||||
if scene_id == "custom-scene-camp"
|
||||
|| read_object_field(profile, "camp")
|
||||
.and_then(|camp| read_optional_string_field(camp, "id"))
|
||||
.as_deref()
|
||||
== Some(scene_id)
|
||||
{
|
||||
return read_object_field(profile, "camp")
|
||||
.and_then(|camp| read_optional_string_field(camp, "name"))
|
||||
.or_else(|| {
|
||||
read_optional_string_field(profile, "name").map(|name| format!("{name}营地"))
|
||||
});
|
||||
}
|
||||
read_array_field(profile, "landmarks")
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.find_map(|(index, landmark)| {
|
||||
let runtime_id = format!("custom-scene-landmark-{}", index + 1);
|
||||
if runtime_id == scene_id
|
||||
|| read_optional_string_field(landmark, "id").as_deref() == Some(scene_id)
|
||||
{
|
||||
read_optional_string_field(landmark, "name")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn direction_text(relative_position: &str) -> &'static str {
|
||||
match relative_position {
|
||||
"north" => "向北走",
|
||||
"south" => "向南走",
|
||||
"east" => "向东走",
|
||||
"west" => "向西走",
|
||||
"left" => "向左走",
|
||||
"right" => "向右走",
|
||||
"back" => "往回走",
|
||||
"up" => "向上走",
|
||||
"down" => "向下走",
|
||||
"inside" => "向内走",
|
||||
"outside" => "向外走",
|
||||
"portal" => "穿过通路",
|
||||
_ => "向前走",
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_next_scene_act_runtime_state(game_state: &Value) -> Option<Value> {
|
||||
let profile = read_object_field(game_state, "customWorldProfile")?;
|
||||
let scene_id = read_object_field(game_state, "currentScenePreset")
|
||||
.and_then(|scene| read_optional_string_field(scene, "id"));
|
||||
let scene_id_text = scene_id.as_deref()?;
|
||||
let chapter = resolve_scene_chapter_blueprint(profile, Some(scene_id_text))?;
|
||||
let acts = read_array_field(chapter, "acts");
|
||||
if acts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let runtime_state = build_initial_scene_act_runtime_state(game_state, scene_id_text)?;
|
||||
let current_act_id = read_optional_string_field(&runtime_state, "currentActId");
|
||||
let current_index = acts
|
||||
.iter()
|
||||
.position(|act| {
|
||||
read_optional_string_field(act, "id").as_deref() == current_act_id.as_deref()
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
read_i32_field(&runtime_state, "currentActIndex")
|
||||
.unwrap_or(0)
|
||||
.clamp(0, acts.len().saturating_sub(1) as i32) as usize
|
||||
});
|
||||
let active_act = acts[current_index];
|
||||
let next_act = acts.get(current_index + 1)?;
|
||||
let active_act_id = read_optional_string_field(active_act, "id")?;
|
||||
let next_act_id = read_optional_string_field(next_act, "id")?;
|
||||
let completed = append_unique_string(
|
||||
read_string_array_field(&runtime_state, "completedActIds"),
|
||||
active_act_id,
|
||||
);
|
||||
let visited = append_unique_string(
|
||||
read_string_array_field(&runtime_state, "visitedActIds"),
|
||||
next_act_id.clone(),
|
||||
);
|
||||
|
||||
Some(json!({
|
||||
"sceneId": read_optional_string_field(chapter, "sceneId")
|
||||
.unwrap_or_else(|| scene_id_text.to_string()),
|
||||
"chapterId": read_optional_string_field(chapter, "id").unwrap_or_default(),
|
||||
"currentActId": next_act_id,
|
||||
"currentActIndex": current_index + 1,
|
||||
"completedActIds": completed,
|
||||
"visitedActIds": visited,
|
||||
}))
|
||||
}
|
||||
|
||||
fn current_scene_act_state(game_state: &Value) -> Option<Value> {
|
||||
read_object_field(game_state, "storyEngineMemory")
|
||||
.and_then(|memory| read_object_field(memory, "currentSceneActState"))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn is_current_scene_act_last(game_state: &Value) -> bool {
|
||||
let Some(profile) = read_object_field(game_state, "customWorldProfile") else {
|
||||
return false;
|
||||
};
|
||||
let Some(scene_id) = read_object_field(game_state, "currentScenePreset")
|
||||
.and_then(|scene| read_optional_string_field(scene, "id"))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(chapter) = resolve_scene_chapter_blueprint(profile, Some(scene_id.as_str())) else {
|
||||
return false;
|
||||
};
|
||||
let acts = read_array_field(chapter, "acts");
|
||||
if acts.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let Some(runtime_state) = build_initial_scene_act_runtime_state(game_state, scene_id.as_str())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let current_act_id = read_optional_string_field(&runtime_state, "currentActId");
|
||||
let current_index = acts
|
||||
.iter()
|
||||
.position(|act| {
|
||||
read_optional_string_field(act, "id").as_deref() == current_act_id.as_deref()
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
read_i32_field(&runtime_state, "currentActIndex")
|
||||
.unwrap_or(0)
|
||||
.clamp(0, acts.len().saturating_sub(1) as i32) as usize
|
||||
});
|
||||
|
||||
current_index + 1 >= acts.len()
|
||||
}
|
||||
|
||||
fn write_current_scene_act_state(game_state: &mut Value, act_state: Value) {
|
||||
let root = ensure_json_object(game_state);
|
||||
let memory = root
|
||||
.entry("storyEngineMemory".to_string())
|
||||
.or_insert_with(|| {
|
||||
json!({
|
||||
"discoveredFactIds": [],
|
||||
"activeThreadIds": [],
|
||||
"resolvedScarIds": [],
|
||||
"recentCarrierIds": []
|
||||
})
|
||||
});
|
||||
if !memory.is_object() {
|
||||
*memory = json!({
|
||||
"discoveredFactIds": [],
|
||||
"activeThreadIds": [],
|
||||
"resolvedScarIds": [],
|
||||
"recentCarrierIds": []
|
||||
});
|
||||
}
|
||||
memory
|
||||
.as_object_mut()
|
||||
.expect("storyEngineMemory should be object")
|
||||
.insert("currentSceneActState".to_string(), act_state);
|
||||
}
|
||||
|
||||
fn build_initial_scene_act_runtime_state(game_state: &Value, scene_id: &str) -> Option<Value> {
|
||||
let profile = read_object_field(game_state, "customWorldProfile")?;
|
||||
let chapter = resolve_scene_chapter_blueprint(profile, Some(scene_id))?;
|
||||
let acts = read_array_field(chapter, "acts");
|
||||
if acts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let runtime_state = current_scene_act_state(game_state);
|
||||
if let Some(runtime_state) = runtime_state {
|
||||
let chapter_id = read_optional_string_field(chapter, "id");
|
||||
let current_act_id = read_optional_string_field(&runtime_state, "currentActId");
|
||||
if read_optional_string_field(&runtime_state, "chapterId") == chapter_id
|
||||
&& acts.iter().any(|act| {
|
||||
read_optional_string_field(act, "id").as_deref() == current_act_id.as_deref()
|
||||
})
|
||||
{
|
||||
return Some(json!({
|
||||
"sceneId": read_optional_string_field(&runtime_state, "sceneId")
|
||||
.unwrap_or_else(|| read_optional_string_field(chapter, "sceneId").unwrap_or_default()),
|
||||
"chapterId": read_optional_string_field(&runtime_state, "chapterId").unwrap_or_default(),
|
||||
"currentActId": current_act_id.unwrap_or_default(),
|
||||
"currentActIndex": read_i32_field(&runtime_state, "currentActIndex").unwrap_or(0).max(0),
|
||||
"completedActIds": read_string_array_field(&runtime_state, "completedActIds"),
|
||||
"visitedActIds": read_string_array_field(&runtime_state, "visitedActIds"),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
let first_act = acts[0];
|
||||
let first_act_id = read_optional_string_field(first_act, "id")?;
|
||||
Some(json!({
|
||||
"sceneId": read_optional_string_field(chapter, "sceneId").unwrap_or_else(|| scene_id.to_string()),
|
||||
"chapterId": read_optional_string_field(chapter, "id").unwrap_or_default(),
|
||||
"currentActId": first_act_id,
|
||||
"currentActIndex": 0,
|
||||
"completedActIds": [],
|
||||
"visitedActIds": [read_optional_string_field(first_act, "id").unwrap_or_default()],
|
||||
}))
|
||||
}
|
||||
|
||||
fn resolve_scene_chapter_blueprint<'a>(
|
||||
profile: &'a Value,
|
||||
scene_id: Option<&str>,
|
||||
) -> Option<&'a Value> {
|
||||
let scene_id = scene_id?;
|
||||
read_array_field(profile, "sceneChapterBlueprints")
|
||||
.into_iter()
|
||||
.find(|chapter| does_scene_match_chapter(profile, scene_id, chapter))
|
||||
}
|
||||
|
||||
fn does_scene_match_chapter(profile: &Value, scene_id: &str, chapter: &Value) -> bool {
|
||||
let aliases = resolve_scene_aliases(profile, scene_id);
|
||||
let mut chapter_scene_ids = Vec::new();
|
||||
if let Some(value) = read_optional_string_field(chapter, "sceneId") {
|
||||
chapter_scene_ids.push(value);
|
||||
}
|
||||
chapter_scene_ids.extend(read_string_array_field(chapter, "linkedLandmarkIds"));
|
||||
for act in read_array_field(chapter, "acts") {
|
||||
if let Some(value) = read_optional_string_field(act, "sceneId") {
|
||||
chapter_scene_ids.push(value);
|
||||
}
|
||||
}
|
||||
aliases
|
||||
.iter()
|
||||
.any(|alias| chapter_scene_ids.iter().any(|id| id == alias))
|
||||
}
|
||||
|
||||
fn resolve_scene_aliases(profile: &Value, scene_id: &str) -> Vec<String> {
|
||||
let mut aliases = vec![scene_id.to_string()];
|
||||
let camp_id = read_object_field(profile, "camp")
|
||||
.and_then(|camp| read_optional_string_field(camp, "id"))
|
||||
.unwrap_or_else(|| "custom-scene-camp".to_string());
|
||||
if scene_id == "custom-scene-camp" || scene_id == camp_id {
|
||||
aliases.push(camp_id);
|
||||
aliases.push("custom-scene-camp".to_string());
|
||||
}
|
||||
for (index, landmark) in read_array_field(profile, "landmarks")
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let runtime_scene_id = format!("custom-scene-landmark-{}", index + 1);
|
||||
if scene_id == runtime_scene_id
|
||||
|| read_optional_string_field(landmark, "id").as_deref() == Some(scene_id)
|
||||
{
|
||||
aliases.push(runtime_scene_id);
|
||||
if let Some(id) = read_optional_string_field(landmark, "id") {
|
||||
aliases.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
dedupe_strings(aliases)
|
||||
}
|
||||
|
||||
fn resolve_active_scene_act_focus_npc_id(
|
||||
profile: &Value,
|
||||
scene_id: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let chapter = resolve_scene_chapter_blueprint(profile, scene_id)?;
|
||||
let act_state = read_array_field(chapter, "acts").first().copied()?;
|
||||
read_optional_string_field(act_state, "oppositeNpcId")
|
||||
.or_else(|| read_optional_string_field(act_state, "primaryNpcId"))
|
||||
.or_else(|| {
|
||||
read_array_field(act_state, "encounterNpcIds")
|
||||
.first()
|
||||
.and_then(|id| id.as_str().map(str::to_string))
|
||||
})
|
||||
}
|
||||
|
||||
fn build_custom_scene_npcs_for_scene(profile: &Value, scene_id: &str) -> Vec<Value> {
|
||||
let Some(chapter) = resolve_scene_chapter_blueprint(profile, Some(scene_id)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(first_act) = read_array_field(chapter, "acts").first().copied() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut role_ids = Vec::new();
|
||||
if let Some(id) = read_optional_string_field(first_act, "primaryNpcId") {
|
||||
role_ids.push(id);
|
||||
}
|
||||
if let Some(id) = read_optional_string_field(first_act, "oppositeNpcId") {
|
||||
role_ids.push(id);
|
||||
}
|
||||
role_ids.extend(read_string_array_field(first_act, "encounterNpcIds"));
|
||||
dedupe_strings(role_ids)
|
||||
.into_iter()
|
||||
.filter_map(|role_id| find_custom_world_role(profile, role_id.as_str()))
|
||||
.map(|role| build_scene_npc_from_role(&role))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn find_custom_world_role(profile: &Value, role_id: &str) -> Option<Value> {
|
||||
read_array_field(profile, "storyNpcs")
|
||||
.into_iter()
|
||||
.chain(read_array_field(profile, "playableNpcs"))
|
||||
.find(|role| {
|
||||
read_optional_string_field(role, "id").as_deref() == Some(role_id)
|
||||
|| read_optional_string_field(role, "name").as_deref() == Some(role_id)
|
||||
|| read_optional_string_field(role, "title").as_deref() == Some(role_id)
|
||||
})
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn build_scene_npc_from_role(role: &Value) -> Value {
|
||||
json!({
|
||||
"id": read_optional_string_field(role, "id").unwrap_or_else(|| read_optional_string_field(role, "name").unwrap_or_else(|| "npc".to_string())),
|
||||
"name": read_optional_string_field(role, "name").unwrap_or_else(|| "当前角色".to_string()),
|
||||
"description": read_optional_string_field(role, "description").unwrap_or_default(),
|
||||
"avatar": read_optional_string_field(role, "name")
|
||||
.and_then(|name| name.chars().next().map(|ch| ch.to_string()))
|
||||
.unwrap_or_else(|| "角".to_string()),
|
||||
"role": read_optional_string_field(role, "role").unwrap_or_default(),
|
||||
"title": read_optional_string_field(role, "title"),
|
||||
"characterId": read_optional_string_field(role, "id"),
|
||||
"initialAffinity": read_i32_field(role, "initialAffinity").unwrap_or(0),
|
||||
"hostile": read_i32_field(role, "initialAffinity").unwrap_or(0) < 0,
|
||||
"functions": ["trade", "fight", "spar", "help", "chat", "recruit", "gift"],
|
||||
"recruitable": true,
|
||||
"backstory": read_optional_string_field(role, "backstory"),
|
||||
"personality": read_optional_string_field(role, "personality"),
|
||||
"motivation": read_optional_string_field(role, "motivation"),
|
||||
"combatStyle": read_optional_string_field(role, "combatStyle"),
|
||||
"relationshipHooks": read_field(role, "relationshipHooks").cloned().unwrap_or_else(|| json!([])),
|
||||
"tags": read_field(role, "tags").cloned().unwrap_or_else(|| json!([])),
|
||||
"backstoryReveal": read_field(role, "backstoryReveal").cloned(),
|
||||
"skills": read_field(role, "skills").cloned().unwrap_or_else(|| json!([])),
|
||||
"initialItems": read_field(role, "initialItems").cloned().unwrap_or_else(|| json!([])),
|
||||
"imageSrc": read_optional_string_field(role, "imageSrc"),
|
||||
"visual": read_field(role, "visual").cloned(),
|
||||
"narrativeProfile": read_field(role, "narrativeProfile").cloned(),
|
||||
"levelProfile": read_field(role, "levelProfile").cloned(),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_encounter_from_role(role: &Value, x_meters: f64) -> Value {
|
||||
json!({
|
||||
"id": read_optional_string_field(role, "id").unwrap_or_else(|| read_optional_string_field(role, "name").unwrap_or_else(|| "npc".to_string())),
|
||||
"kind": "npc",
|
||||
"characterId": read_optional_string_field(role, "id"),
|
||||
"npcName": read_optional_string_field(role, "name").unwrap_or_else(|| "当前角色".to_string()),
|
||||
"npcDescription": read_optional_string_field(role, "description").unwrap_or_default(),
|
||||
"npcAvatar": read_optional_string_field(role, "name")
|
||||
.and_then(|name| name.chars().next().map(|ch| ch.to_string()))
|
||||
.unwrap_or_else(|| "角".to_string()),
|
||||
"context": read_optional_string_field(role, "role").unwrap_or_default(),
|
||||
"xMeters": x_meters,
|
||||
"initialAffinity": read_i32_field(role, "initialAffinity").unwrap_or(0),
|
||||
"hostile": read_i32_field(role, "initialAffinity").unwrap_or(0) < 0,
|
||||
"title": read_optional_string_field(role, "title"),
|
||||
"backstory": read_optional_string_field(role, "backstory"),
|
||||
"personality": read_optional_string_field(role, "personality"),
|
||||
"motivation": read_optional_string_field(role, "motivation"),
|
||||
"combatStyle": read_optional_string_field(role, "combatStyle"),
|
||||
"relationshipHooks": read_field(role, "relationshipHooks").cloned().unwrap_or_else(|| json!([])),
|
||||
"tags": read_field(role, "tags").cloned().unwrap_or_else(|| json!([])),
|
||||
"backstoryReveal": read_field(role, "backstoryReveal").cloned(),
|
||||
"skills": read_field(role, "skills").cloned().unwrap_or_else(|| json!([])),
|
||||
"initialItems": read_field(role, "initialItems").cloned().unwrap_or_else(|| json!([])),
|
||||
"imageSrc": read_optional_string_field(role, "imageSrc"),
|
||||
"visual": read_field(role, "visual").cloned(),
|
||||
"narrativeProfile": read_field(role, "narrativeProfile").cloned(),
|
||||
"levelProfile": read_field(role, "levelProfile").cloned(),
|
||||
})
|
||||
}
|
||||
|
||||
fn read_string_array_field(value: &Value, key: &str) -> Vec<String> {
|
||||
read_field(value, key)
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|item| !item.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn append_unique_string(mut values: Vec<String>, value: String) -> Vec<String> {
|
||||
if !values.iter().any(|entry| entry == &value) {
|
||||
values.push(value);
|
||||
}
|
||||
values
|
||||
}
|
||||
|
||||
fn dedupe_strings(values: Vec<String>) -> Vec<String> {
|
||||
let mut result = Vec::new();
|
||||
for value in values {
|
||||
if !value.trim().is_empty() && !result.iter().any(|entry| entry == &value) {
|
||||
result.push(value);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,939 @@
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::{
|
||||
current_encounter_id, current_encounter_name, read_array_field, read_bool_field, read_field,
|
||||
read_i32_field, read_object_field, read_optional_string_field,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct RuntimeStoryPromptContextExtras {
|
||||
pub pending_scene_encounter: bool,
|
||||
pub last_function_id: Option<String>,
|
||||
pub observe_signs_requested: bool,
|
||||
pub recent_action_result: Option<String>,
|
||||
pub opening_camp_background: Option<String>,
|
||||
pub opening_camp_dialogue: Option<String>,
|
||||
}
|
||||
|
||||
/// 基于后端持久化的运行时快照生成 LLM 所需 prompt context。
|
||||
/// 前端只能提交 session / choice 等轻量请求参数,正式上下文统一在这里投影。
|
||||
pub fn build_runtime_story_prompt_context(
|
||||
game_state: &Value,
|
||||
extras: RuntimeStoryPromptContextExtras,
|
||||
) -> Value {
|
||||
let scene = read_object_field(game_state, "currentScenePreset");
|
||||
let encounter = read_object_field(game_state, "currentEncounter");
|
||||
let npc_state = encounter.and_then(|_encounter| {
|
||||
let npc_name = current_encounter_name(game_state);
|
||||
let npc_id = current_encounter_id(game_state).unwrap_or_else(|| npc_name.clone());
|
||||
read_object_field(game_state, "npcStates").and_then(|states| {
|
||||
states
|
||||
.get(npc_id.as_str())
|
||||
.or_else(|| states.get(npc_name.as_str()))
|
||||
})
|
||||
});
|
||||
let conversation_situation = infer_conversation_situation(game_state, &extras);
|
||||
let conversation_pressure = infer_conversation_pressure(game_state, conversation_situation);
|
||||
let encounter_narrative_profile = resolve_encounter_narrative_profile(game_state, encounter);
|
||||
let story_engine_memory = read_object_field(game_state, "storyEngineMemory");
|
||||
let chapter_state = read_field(game_state, "chapterState")
|
||||
.or_else(|| story_engine_memory.and_then(|memory| read_field(memory, "currentChapter")));
|
||||
let journey_beat =
|
||||
story_engine_memory.and_then(|memory| read_field(memory, "currentJourneyBeat"));
|
||||
let active_thread_ids = read_string_array(
|
||||
story_engine_memory.and_then(|memory| read_field(memory, "activeThreadIds")),
|
||||
)
|
||||
.into_iter()
|
||||
.take(4)
|
||||
.collect::<Vec<_>>();
|
||||
let active_thread_ids = if active_thread_ids.is_empty() {
|
||||
read_string_array(
|
||||
encounter_narrative_profile.and_then(|profile| read_field(profile, "relatedThreadIds")),
|
||||
)
|
||||
.into_iter()
|
||||
.take(4)
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
active_thread_ids
|
||||
};
|
||||
|
||||
let recruited = npc_state
|
||||
.and_then(|state| read_bool_field(state, "recruited"))
|
||||
.unwrap_or(false);
|
||||
let affinity = npc_state.and_then(|state| read_i32_field(state, "affinity"));
|
||||
let disclosure = affinity.map(|value| disclosure_stage(value, recruited));
|
||||
|
||||
let mut context = Map::new();
|
||||
insert_base_context(&mut context, game_state, scene, &extras);
|
||||
insert_encounter_context(
|
||||
&mut context,
|
||||
game_state,
|
||||
encounter,
|
||||
npc_state,
|
||||
encounter_narrative_profile,
|
||||
affinity,
|
||||
disclosure,
|
||||
recruited,
|
||||
);
|
||||
insert_narrative_context(
|
||||
&mut context,
|
||||
game_state,
|
||||
story_engine_memory,
|
||||
chapter_state,
|
||||
journey_beat,
|
||||
active_thread_ids,
|
||||
conversation_situation,
|
||||
conversation_pressure,
|
||||
);
|
||||
context.insert(
|
||||
"openingCampBackground".to_string(),
|
||||
extras.opening_camp_background.into(),
|
||||
);
|
||||
context.insert(
|
||||
"openingCampDialogue".to_string(),
|
||||
extras.opening_camp_dialogue.into(),
|
||||
);
|
||||
|
||||
Value::Object(context)
|
||||
}
|
||||
|
||||
fn insert_base_context(
|
||||
context: &mut Map<String, Value>,
|
||||
game_state: &Value,
|
||||
scene: Option<&Value>,
|
||||
extras: &RuntimeStoryPromptContextExtras,
|
||||
) {
|
||||
context.insert(
|
||||
"playerHp".to_string(),
|
||||
read_i32_field(game_state, "playerHp").unwrap_or(0).into(),
|
||||
);
|
||||
context.insert(
|
||||
"playerMaxHp".to_string(),
|
||||
read_i32_field(game_state, "playerMaxHp")
|
||||
.unwrap_or(1)
|
||||
.max(1)
|
||||
.into(),
|
||||
);
|
||||
context.insert(
|
||||
"playerMana".to_string(),
|
||||
read_i32_field(game_state, "playerMana").unwrap_or(0).into(),
|
||||
);
|
||||
context.insert(
|
||||
"playerMaxMana".to_string(),
|
||||
read_i32_field(game_state, "playerMaxMana")
|
||||
.unwrap_or(1)
|
||||
.max(1)
|
||||
.into(),
|
||||
);
|
||||
context.insert(
|
||||
"inBattle".to_string(),
|
||||
read_bool_field(game_state, "inBattle")
|
||||
.unwrap_or(false)
|
||||
.into(),
|
||||
);
|
||||
context.insert(
|
||||
"playerX".to_string(),
|
||||
read_i32_field(game_state, "playerX").unwrap_or(0).into(),
|
||||
);
|
||||
context.insert(
|
||||
"playerFacing".to_string(),
|
||||
read_optional_string_field(game_state, "playerFacing")
|
||||
.unwrap_or_else(|| "right".to_string())
|
||||
.into(),
|
||||
);
|
||||
context.insert(
|
||||
"playerAnimation".to_string(),
|
||||
read_optional_string_field(game_state, "animationState")
|
||||
.unwrap_or_else(|| "idle".to_string())
|
||||
.into(),
|
||||
);
|
||||
context.insert(
|
||||
"skillCooldowns".to_string(),
|
||||
read_field(game_state, "playerSkillCooldowns")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!({})),
|
||||
);
|
||||
context.insert(
|
||||
"sceneId".to_string(),
|
||||
scene
|
||||
.and_then(|scene| read_optional_string_field(scene, "id"))
|
||||
.into(),
|
||||
);
|
||||
context.insert(
|
||||
"sceneName".to_string(),
|
||||
scene
|
||||
.and_then(|scene| read_optional_string_field(scene, "name"))
|
||||
.or_else(|| read_optional_string_field(game_state, "currentScene"))
|
||||
.into(),
|
||||
);
|
||||
context.insert(
|
||||
"sceneDescription".to_string(),
|
||||
build_scene_description(game_state, extras.observe_signs_requested).into(),
|
||||
);
|
||||
context.insert(
|
||||
"pendingSceneEncounter".to_string(),
|
||||
extras.pending_scene_encounter.into(),
|
||||
);
|
||||
context.insert(
|
||||
"lastFunctionId".to_string(),
|
||||
extras.last_function_id.clone().into(),
|
||||
);
|
||||
context.insert(
|
||||
"observeSignsRequested".to_string(),
|
||||
extras.observe_signs_requested.into(),
|
||||
);
|
||||
context.insert(
|
||||
"recentActionResult".to_string(),
|
||||
extras.recent_action_result.clone().into(),
|
||||
);
|
||||
context.insert(
|
||||
"lastObserveSignsReport".to_string(),
|
||||
resolve_last_observe_report(game_state, scene).into(),
|
||||
);
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn insert_encounter_context(
|
||||
context: &mut Map<String, Value>,
|
||||
game_state: &Value,
|
||||
encounter: Option<&Value>,
|
||||
npc_state: Option<&Value>,
|
||||
encounter_narrative_profile: Option<&Value>,
|
||||
affinity: Option<i32>,
|
||||
disclosure: Option<&'static str>,
|
||||
recruited: bool,
|
||||
) {
|
||||
context.insert(
|
||||
"encounterKind".to_string(),
|
||||
encounter
|
||||
.and_then(|encounter| read_optional_string_field(encounter, "kind"))
|
||||
.into(),
|
||||
);
|
||||
context.insert(
|
||||
"encounterName".to_string(),
|
||||
encounter.and_then(read_encounter_name).into(),
|
||||
);
|
||||
context.insert(
|
||||
"encounterDescription".to_string(),
|
||||
encounter
|
||||
.and_then(|encounter| {
|
||||
read_optional_string_field(encounter, "npcDescription")
|
||||
.or_else(|| read_optional_string_field(encounter, "description"))
|
||||
})
|
||||
.into(),
|
||||
);
|
||||
context.insert(
|
||||
"encounterContext".to_string(),
|
||||
encounter
|
||||
.and_then(|encounter| read_optional_string_field(encounter, "context"))
|
||||
.into(),
|
||||
);
|
||||
context.insert(
|
||||
"encounterId".to_string(),
|
||||
current_encounter_id(game_state).into(),
|
||||
);
|
||||
context.insert(
|
||||
"encounterCharacterId".to_string(),
|
||||
encounter
|
||||
.and_then(|encounter| read_optional_string_field(encounter, "characterId"))
|
||||
.into(),
|
||||
);
|
||||
context.insert(
|
||||
"encounterGender".to_string(),
|
||||
encounter
|
||||
.and_then(|encounter| read_optional_string_field(encounter, "gender"))
|
||||
.into(),
|
||||
);
|
||||
context.insert(
|
||||
"encounterCustomProfile".to_string(),
|
||||
encounter.cloned().unwrap_or(Value::Null),
|
||||
);
|
||||
context.insert("encounterAffinity".to_string(), affinity.into());
|
||||
context.insert(
|
||||
"encounterAffinityText".to_string(),
|
||||
affinity.map(describe_npc_affinity).into(),
|
||||
);
|
||||
context.insert(
|
||||
"encounterStanceProfile".to_string(),
|
||||
npc_state
|
||||
.and_then(|state| read_field(state, "stanceProfile"))
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null),
|
||||
);
|
||||
context.insert(
|
||||
"encounterConversationStyle".to_string(),
|
||||
encounter
|
||||
.and_then(|encounter| read_field(encounter, "conversationStyle"))
|
||||
.cloned()
|
||||
.unwrap_or_else(default_conversation_style),
|
||||
);
|
||||
context.insert("encounterDisclosureStage".to_string(), disclosure.into());
|
||||
context.insert(
|
||||
"encounterWarmthStage".to_string(),
|
||||
affinity.map(|value| warmth_stage(value, recruited)).into(),
|
||||
);
|
||||
context.insert(
|
||||
"encounterAnswerMode".to_string(),
|
||||
disclosure.map(answer_mode).into(),
|
||||
);
|
||||
context.insert(
|
||||
"encounterAllowedTopics".to_string(),
|
||||
disclosure.map(allowed_topics).into(),
|
||||
);
|
||||
context.insert(
|
||||
"encounterBlockedTopics".to_string(),
|
||||
disclosure.map(blocked_topics).into(),
|
||||
);
|
||||
context.insert(
|
||||
"isFirstMeaningfulContact".to_string(),
|
||||
is_first_meaningful_contact(npc_state).into(),
|
||||
);
|
||||
context.insert(
|
||||
"firstContactRelationStance".to_string(),
|
||||
first_contact_relation_stance(npc_state).into(),
|
||||
);
|
||||
context.insert(
|
||||
"encounterNarrativeProfile".to_string(),
|
||||
encounter_narrative_profile.cloned().unwrap_or(Value::Null),
|
||||
);
|
||||
context.insert(
|
||||
"encounterRelationshipSummary".to_string(),
|
||||
encounter
|
||||
.and_then(|encounter| read_optional_string_field(encounter, "characterId"))
|
||||
.and_then(|character_id| read_character_chat_summary(game_state, character_id.as_str()))
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn insert_narrative_context(
|
||||
context: &mut Map<String, Value>,
|
||||
game_state: &Value,
|
||||
story_engine_memory: Option<&Value>,
|
||||
chapter_state: Option<&Value>,
|
||||
journey_beat: Option<&Value>,
|
||||
active_thread_ids: Vec<String>,
|
||||
conversation_situation: &str,
|
||||
conversation_pressure: &str,
|
||||
) {
|
||||
context.insert(
|
||||
"conversationSituation".to_string(),
|
||||
conversation_situation.into(),
|
||||
);
|
||||
context.insert(
|
||||
"conversationPressure".to_string(),
|
||||
conversation_pressure.into(),
|
||||
);
|
||||
context.insert(
|
||||
"recentSharedEvent".to_string(),
|
||||
build_recent_shared_event(game_state)
|
||||
.unwrap_or_else(|| describe_conversation_situation(conversation_situation).to_string())
|
||||
.into(),
|
||||
);
|
||||
context.insert(
|
||||
"talkPriority".to_string(),
|
||||
describe_conversation_talk_priority(conversation_situation).into(),
|
||||
);
|
||||
context.insert("visibilitySlice".to_string(), Value::Null);
|
||||
context.insert("sceneNarrativeDirective".to_string(), Value::Null);
|
||||
context.insert(
|
||||
"campaignState".to_string(),
|
||||
read_field(game_state, "campaignState")
|
||||
.or_else(|| story_engine_memory.and_then(|memory| read_field(memory, "campaignState")))
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null),
|
||||
);
|
||||
context.insert(
|
||||
"actState".to_string(),
|
||||
story_engine_memory
|
||||
.and_then(|memory| read_field(memory, "actState"))
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null),
|
||||
);
|
||||
context.insert(
|
||||
"chapterState".to_string(),
|
||||
chapter_state.cloned().unwrap_or(Value::Null),
|
||||
);
|
||||
context.insert(
|
||||
"journeyBeat".to_string(),
|
||||
journey_beat.cloned().unwrap_or(Value::Null),
|
||||
);
|
||||
context.insert("goalStack".to_string(), Value::Null);
|
||||
context.insert(
|
||||
"currentCampEvent".to_string(),
|
||||
story_engine_memory
|
||||
.and_then(|memory| read_field(memory, "currentCampEvent"))
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null),
|
||||
);
|
||||
context.insert(
|
||||
"setpieceDirective".to_string(),
|
||||
story_engine_memory
|
||||
.and_then(|memory| read_field(memory, "currentSetpieceDirective"))
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null),
|
||||
);
|
||||
context.insert("activeScenarioPack".to_string(), Value::Null);
|
||||
context.insert("activeCampaignPack".to_string(), Value::Null);
|
||||
context.insert(
|
||||
"knowledgeFacts".to_string(),
|
||||
read_object_field(game_state, "customWorldProfile")
|
||||
.and_then(|profile| read_field(profile, "knowledgeFacts"))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!([])),
|
||||
);
|
||||
context.insert("activeThreadIds".to_string(), active_thread_ids.into());
|
||||
context.insert(
|
||||
"companionArcStates".to_string(),
|
||||
story_engine_memory
|
||||
.and_then(|memory| read_field(memory, "companionArcStates"))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!([])),
|
||||
);
|
||||
context.insert(
|
||||
"companionResolutions".to_string(),
|
||||
story_engine_memory
|
||||
.and_then(|memory| read_field(memory, "companionResolutions"))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!([])),
|
||||
);
|
||||
context.insert(
|
||||
"consequenceLedger".to_string(),
|
||||
story_engine_memory
|
||||
.and_then(|memory| read_field(memory, "consequenceLedger"))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!([])),
|
||||
);
|
||||
context.insert(
|
||||
"authorialConstraintPack".to_string(),
|
||||
story_engine_memory
|
||||
.and_then(|memory| read_field(memory, "authorialConstraintPack"))
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null),
|
||||
);
|
||||
context.insert(
|
||||
"playerStyleProfile".to_string(),
|
||||
story_engine_memory
|
||||
.and_then(|memory| read_field(memory, "playerStyleProfile"))
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null),
|
||||
);
|
||||
context.insert(
|
||||
"recentCompanionReactions".to_string(),
|
||||
story_engine_memory
|
||||
.and_then(|memory| read_field(memory, "recentCompanionReactions"))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!([])),
|
||||
);
|
||||
context.insert("recentCarrierEchoes".to_string(), json!([]));
|
||||
context.insert(
|
||||
"recentWorldMutations".to_string(),
|
||||
story_engine_memory
|
||||
.and_then(|memory| read_field(memory, "worldMutations"))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!([])),
|
||||
);
|
||||
context.insert(
|
||||
"recentFactionTensionStates".to_string(),
|
||||
story_engine_memory
|
||||
.and_then(|memory| read_field(memory, "factionTensionStates"))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!([])),
|
||||
);
|
||||
context.insert(
|
||||
"recentChronicleSummary".to_string(),
|
||||
build_recent_chronicle_summary(game_state).into(),
|
||||
);
|
||||
context.insert(
|
||||
"narrativeQaReport".to_string(),
|
||||
story_engine_memory
|
||||
.and_then(|memory| read_field(memory, "narrativeQaReport"))
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null),
|
||||
);
|
||||
context.insert(
|
||||
"releaseGateReport".to_string(),
|
||||
story_engine_memory
|
||||
.and_then(|memory| read_field(memory, "releaseGateReport"))
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null),
|
||||
);
|
||||
context.insert(
|
||||
"simulationRunResults".to_string(),
|
||||
story_engine_memory
|
||||
.and_then(|memory| read_field(memory, "simulationRunResults"))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!([])),
|
||||
);
|
||||
context.insert(
|
||||
"branchBudgetPressure".to_string(),
|
||||
story_engine_memory
|
||||
.and_then(|memory| read_field(memory, "branchBudgetStatus"))
|
||||
.and_then(|status| read_optional_string_field(status, "pressure"))
|
||||
.into(),
|
||||
);
|
||||
context.insert(
|
||||
"partyRelationshipNotes".to_string(),
|
||||
build_party_relationship_notes(game_state).into(),
|
||||
);
|
||||
context.insert(
|
||||
"customWorldProfile".to_string(),
|
||||
read_field(game_state, "customWorldProfile")
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null),
|
||||
);
|
||||
}
|
||||
|
||||
fn build_scene_description(game_state: &Value, observe_signs_requested: bool) -> String {
|
||||
let scene = read_object_field(game_state, "currentScenePreset");
|
||||
let base = scene
|
||||
.and_then(|scene| read_optional_string_field(scene, "description"))
|
||||
.or_else(|| read_optional_string_field(game_state, "sceneDescription"))
|
||||
.unwrap_or_else(|| "周围气氛仍在继续变化。".to_string());
|
||||
let mutation_text =
|
||||
scene.and_then(|scene| read_optional_string_field(scene, "mutationStateText"));
|
||||
let pressure_text = scene
|
||||
.and_then(|scene| read_optional_string_field(scene, "currentPressureLevel"))
|
||||
.and_then(|level| describe_scene_pressure_level(level.as_str()).map(str::to_string));
|
||||
let entity_catalog = if observe_signs_requested {
|
||||
Some(build_scene_entity_catalog_text(scene))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
[
|
||||
Some(base),
|
||||
mutation_text.map(|text| format!("最新世界变化:{text}")),
|
||||
pressure_text.map(|text| format!("当前区域压力等级:{text}")),
|
||||
entity_catalog,
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn build_scene_entity_catalog_text(scene: Option<&Value>) -> String {
|
||||
let Some(scene) = scene else {
|
||||
return "当前可观察实体池:暂无显式实体。".to_string();
|
||||
};
|
||||
let npc_names = read_array_field(scene, "npcs")
|
||||
.into_iter()
|
||||
.filter_map(read_encounter_name)
|
||||
.take(8)
|
||||
.collect::<Vec<_>>();
|
||||
let treasure_hints = read_array_field(scene, "treasureHints")
|
||||
.into_iter()
|
||||
.filter_map(|item| {
|
||||
read_optional_string_field(item, "title")
|
||||
.or_else(|| read_optional_string_field(item, "name"))
|
||||
.or_else(|| read_optional_string_field(item, "hint"))
|
||||
})
|
||||
.take(6)
|
||||
.collect::<Vec<_>>();
|
||||
let mut lines = vec!["当前可观察实体池:".to_string()];
|
||||
if !npc_names.is_empty() {
|
||||
lines.push(format!("- 角色:{}", npc_names.join("、")));
|
||||
}
|
||||
if !treasure_hints.is_empty() {
|
||||
lines.push(format!("- 线索/物件:{}", treasure_hints.join("、")));
|
||||
}
|
||||
if lines.len() == 1 {
|
||||
lines.push("- 暂无显式实体。".to_string());
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn resolve_last_observe_report(game_state: &Value, scene: Option<&Value>) -> Option<String> {
|
||||
let current_scene_id = scene.and_then(|scene| read_optional_string_field(scene, "id"));
|
||||
let last_scene_id = read_optional_string_field(game_state, "lastObserveSignsSceneId");
|
||||
if current_scene_id.is_some() && current_scene_id == last_scene_id {
|
||||
return read_optional_string_field(game_state, "lastObserveSignsReport");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn infer_conversation_situation(
|
||||
game_state: &Value,
|
||||
extras: &RuntimeStoryPromptContextExtras,
|
||||
) -> &'static str {
|
||||
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
|
||||
return "shared_danger_coordination";
|
||||
}
|
||||
if extras.last_function_id.as_deref() == Some("story_opening_camp_dialogue") {
|
||||
return "camp_first_contact";
|
||||
}
|
||||
let encounter = read_object_field(game_state, "currentEncounter");
|
||||
if encounter
|
||||
.and_then(|encounter| read_optional_string_field(encounter, "specialBehavior"))
|
||||
.as_deref()
|
||||
== Some("camp_companion")
|
||||
&& extras
|
||||
.opening_camp_dialogue
|
||||
.as_deref()
|
||||
.is_some_and(|text| !text.trim().is_empty())
|
||||
{
|
||||
return "camp_followup";
|
||||
}
|
||||
let recent_text = recent_story_text(game_state, 6);
|
||||
if contains_any(
|
||||
recent_text.as_str(),
|
||||
&["击败", "怪物", "战斗", "切磋", "交手", "脱身"],
|
||||
) {
|
||||
return "post_battle_breath";
|
||||
}
|
||||
if extras.last_function_id.as_deref() == Some("npc_chat") {
|
||||
return "private_followup";
|
||||
}
|
||||
"first_contact_cautious"
|
||||
}
|
||||
|
||||
fn infer_conversation_pressure(game_state: &Value, situation: &str) -> &'static str {
|
||||
let hp = read_i32_field(game_state, "playerHp").unwrap_or(0);
|
||||
let max_hp = read_i32_field(game_state, "playerMaxHp")
|
||||
.unwrap_or(1)
|
||||
.max(1);
|
||||
if read_bool_field(game_state, "inBattle").unwrap_or(false) || hp * 100 < max_hp * 35 {
|
||||
return "high";
|
||||
}
|
||||
match situation {
|
||||
"post_battle_breath" | "shared_danger_coordination" => "medium",
|
||||
"camp_first_contact" | "camp_followup" => "low",
|
||||
_ => "medium",
|
||||
}
|
||||
}
|
||||
|
||||
fn build_recent_shared_event(game_state: &Value) -> Option<String> {
|
||||
let recent_text = recent_story_text(game_state, 6);
|
||||
if contains_any(
|
||||
recent_text.as_str(),
|
||||
&["击败", "怪物", "战斗", "切磋", "交手", "脱身"],
|
||||
) {
|
||||
return Some("你们刚经历过一场交锋或切磋,空气里的紧张感还没有完全散去。".to_string());
|
||||
}
|
||||
if contains_any(recent_text.as_str(), &["携手", "相助", "帮你", "并肩"]) {
|
||||
return Some("你们刚并肩配合过一次,彼此之间的距离感稍微淡了一些。".to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn describe_conversation_situation(situation: &str) -> &'static str {
|
||||
match situation {
|
||||
"camp_first_contact" => {
|
||||
"这是营地里第一次真正静下来对话的时刻,语气要保持谨慎、观察和轻微试探。"
|
||||
}
|
||||
"camp_followup" => "营地里的第一轮试探已经发生过了,这一轮应当顺着刚才的话头稍微往深处接。",
|
||||
"post_battle_breath" => "一场交锋刚结束,眼前危险稍缓,但双方都还带着余悸和紧绷。",
|
||||
"shared_danger_coordination" => "危险还没过去,对话应当短、准、直接,优先服务眼前判断。",
|
||||
"private_followup" => "这已经不是严格意义上的初见,更适合作为刚才未说完那句话的延续。",
|
||||
_ => "双方才刚真正对上话,此刻仍在判断彼此能信到什么程度。",
|
||||
}
|
||||
}
|
||||
|
||||
fn describe_conversation_talk_priority(situation: &str) -> &'static str {
|
||||
match situation {
|
||||
"camp_first_contact" => "优先写眼前印象、彼此态度和营地气氛,不要一上来就把动机讲透。",
|
||||
"camp_followup" => "先接住上一轮还没说透的话头,再决定要不要继续往下追问。",
|
||||
"post_battle_breath" => "先谈刚刚那次交锋以及彼此的判断,再视情况往更深处推进。",
|
||||
"shared_danger_coordination" => "先说最有用的判断、危险和下一步,不要扩成大段背景说明。",
|
||||
"private_followup" => "承接当前话头和关系变化,不要把对话又写回刚见面时的节奏。",
|
||||
_ => "先试探态度和现场判断,不要急着把来意和秘密一次摊开。",
|
||||
}
|
||||
}
|
||||
|
||||
fn recent_story_text(game_state: &Value, limit: usize) -> String {
|
||||
read_array_field(game_state, "storyHistory")
|
||||
.into_iter()
|
||||
.rev()
|
||||
.take(limit)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.filter_map(|entry| read_optional_string_field(entry, "text"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn resolve_encounter_narrative_profile<'a>(
|
||||
game_state: &'a Value,
|
||||
encounter: Option<&'a Value>,
|
||||
) -> Option<&'a Value> {
|
||||
let encounter = encounter?;
|
||||
if let Some(profile) = read_field(encounter, "narrativeProfile") {
|
||||
return Some(profile);
|
||||
}
|
||||
let profile = read_object_field(game_state, "customWorldProfile")?;
|
||||
let encounter_id = read_optional_string_field(encounter, "id");
|
||||
let encounter_name = read_encounter_name(encounter);
|
||||
["storyNpcs", "playableNpcs"]
|
||||
.into_iter()
|
||||
.flat_map(|field| read_array_field(profile, field))
|
||||
.find(|npc| {
|
||||
let npc_id = read_optional_string_field(npc, "id");
|
||||
let npc_name = read_optional_string_field(npc, "name");
|
||||
npc_id.is_some() && npc_id == encounter_id
|
||||
|| npc_name.is_some() && npc_name == encounter_name
|
||||
})
|
||||
.and_then(|npc| read_field(npc, "narrativeProfile"))
|
||||
}
|
||||
|
||||
fn build_recent_chronicle_summary(game_state: &Value) -> Option<String> {
|
||||
let memory = read_object_field(game_state, "storyEngineMemory");
|
||||
let chapter_summary = read_field(game_state, "chapterState")
|
||||
.or_else(|| memory.and_then(|memory| read_field(memory, "currentChapter")))
|
||||
.and_then(|chapter| read_optional_string_field(chapter, "chapterSummary"));
|
||||
let chronicle_lines = memory
|
||||
.and_then(|memory| read_field(memory, "chronicle"))
|
||||
.and_then(Value::as_array)
|
||||
.map(|entries| {
|
||||
entries
|
||||
.iter()
|
||||
.rev()
|
||||
.take(4)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.filter_map(|entry| {
|
||||
let title = read_optional_string_field(entry, "title").unwrap_or_default();
|
||||
let summary = read_optional_string_field(entry, "summary").unwrap_or_default();
|
||||
let text = [title, summary]
|
||||
.into_iter()
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(":");
|
||||
(!text.trim().is_empty()).then_some(text)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let text = chapter_summary
|
||||
.into_iter()
|
||||
.chain(chronicle_lines)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
(!text.trim().is_empty()).then_some(text)
|
||||
}
|
||||
|
||||
fn build_party_relationship_notes(game_state: &Value) -> Option<String> {
|
||||
let mut lines = Vec::new();
|
||||
for (field, role_label) in [("companions", "当前同行"), ("roster", "营地待命")] {
|
||||
for companion in read_array_field(game_state, field) {
|
||||
let Some(character_id) = read_optional_string_field(companion, "characterId") else {
|
||||
continue;
|
||||
};
|
||||
let Some(summary) = read_character_chat_summary(game_state, character_id.as_str())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let name = resolve_character_name(game_state, character_id.as_str())
|
||||
.unwrap_or_else(|| character_id.clone());
|
||||
lines.push(format!("- {name}({role_label}):{summary}"));
|
||||
}
|
||||
}
|
||||
(!lines.is_empty()).then_some(lines.join("\n"))
|
||||
}
|
||||
|
||||
fn resolve_character_name(game_state: &Value, character_id: &str) -> Option<String> {
|
||||
let profile = read_object_field(game_state, "customWorldProfile")?;
|
||||
["playableNpcs", "storyNpcs"]
|
||||
.into_iter()
|
||||
.flat_map(|field| read_array_field(profile, field))
|
||||
.find(|npc| read_optional_string_field(npc, "id").as_deref() == Some(character_id))
|
||||
.and_then(|npc| read_optional_string_field(npc, "name"))
|
||||
}
|
||||
|
||||
fn read_character_chat_summary(game_state: &Value, character_id: &str) -> Option<String> {
|
||||
read_object_field(game_state, "characterChats")
|
||||
.and_then(|chats| chats.get(character_id))
|
||||
.and_then(|record| read_optional_string_field(record, "summary"))
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
}
|
||||
|
||||
fn is_first_meaningful_contact(npc_state: Option<&Value>) -> bool {
|
||||
let Some(npc_state) = npc_state else {
|
||||
return false;
|
||||
};
|
||||
!read_bool_field(npc_state, "firstMeaningfulContactResolved").unwrap_or(false)
|
||||
&& read_i32_field(npc_state, "chattedCount").unwrap_or(0) <= 0
|
||||
}
|
||||
|
||||
fn first_contact_relation_stance(npc_state: Option<&Value>) -> Option<String> {
|
||||
let npc_state = npc_state?;
|
||||
read_object_field(npc_state, "relationState")
|
||||
.and_then(|state| read_optional_string_field(state, "stance"))
|
||||
.filter(|stance| {
|
||||
matches!(
|
||||
stance.as_str(),
|
||||
"guarded" | "neutral" | "cooperative" | "bonded"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn disclosure_stage(affinity: i32, recruited: bool) -> &'static str {
|
||||
if recruited || affinity >= 50 {
|
||||
"deep"
|
||||
} else if affinity >= 30 {
|
||||
"honest"
|
||||
} else if affinity >= 15 {
|
||||
"partial"
|
||||
} else {
|
||||
"guarded"
|
||||
}
|
||||
}
|
||||
|
||||
fn warmth_stage(affinity: i32, recruited: bool) -> &'static str {
|
||||
if recruited || affinity >= 50 {
|
||||
"warm"
|
||||
} else if affinity >= 30 {
|
||||
"cooperative"
|
||||
} else if affinity >= 15 {
|
||||
"neutral"
|
||||
} else {
|
||||
"distant"
|
||||
}
|
||||
}
|
||||
|
||||
fn answer_mode(stage: &str) -> &'static str {
|
||||
match stage {
|
||||
"deep" => "candid",
|
||||
"honest" => "true_but_incomplete",
|
||||
"partial" => "half_truth",
|
||||
_ => "situational_only",
|
||||
}
|
||||
}
|
||||
|
||||
fn allowed_topics(stage: &str) -> Vec<&'static str> {
|
||||
match stage {
|
||||
"guarded" => vec!["眼前危险", "现场判断", "对玩家的态度", "模糊钩子"],
|
||||
"partial" => vec!["眼前危险", "表层理由", "试探性解释", "有限背景"],
|
||||
"honest" => vec!["真实动机的轮廓", "旧事碎片", "真正目标的一部分"],
|
||||
_ => vec!["真实来历", "真正目标", "旧事恩怨", "未说完的核心问题"],
|
||||
}
|
||||
}
|
||||
|
||||
fn blocked_topics(stage: &str) -> Vec<&'static str> {
|
||||
match stage {
|
||||
"guarded" => vec!["完整来历", "真正目标", "旧事全貌"],
|
||||
"partial" => vec!["完整来历", "旧事全貌"],
|
||||
"honest" => vec!["把全部底牌一次说完"],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn describe_npc_affinity(affinity: i32) -> String {
|
||||
if affinity >= 90 {
|
||||
"高度信赖,言谈间明显亲近。".to_string()
|
||||
} else if affinity >= 60 {
|
||||
"已经建立稳固信任,愿意进一步合作。".to_string()
|
||||
} else if affinity >= 30 {
|
||||
"态度明显友善,也更愿意正常交流。".to_string()
|
||||
} else if affinity >= 15 {
|
||||
"戒备开始松动,愿意试探性配合。".to_string()
|
||||
} else if affinity >= 0 {
|
||||
"仍保持明显距离,只会给出谨慎而有限的回应。".to_string()
|
||||
} else {
|
||||
"关系降到冰点,对玩家几乎不保留善意。".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn default_conversation_style() -> Value {
|
||||
json!({
|
||||
"guardStyle": "measured",
|
||||
"warmStyle": "steady",
|
||||
"truthStyle": "fragmented",
|
||||
})
|
||||
}
|
||||
|
||||
fn describe_scene_pressure_level(value: &str) -> Option<&'static str> {
|
||||
match value {
|
||||
"low" => Some("低"),
|
||||
"medium" => Some("中"),
|
||||
"high" => Some("高"),
|
||||
"extreme" => Some("极高"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn read_encounter_name(value: &Value) -> Option<String> {
|
||||
read_optional_string_field(value, "npcName")
|
||||
.or_else(|| read_optional_string_field(value, "name"))
|
||||
}
|
||||
|
||||
fn read_string_array(value: Option<&Value>) -> Vec<String> {
|
||||
value
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|text| !text.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn contains_any(text: &str, keywords: &[&str]) -> bool {
|
||||
keywords.iter().any(|keyword| text.contains(keyword))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prompt_context_projects_npc_directive_from_server_state() {
|
||||
let context = build_runtime_story_prompt_context(
|
||||
&json!({
|
||||
"worldType": "WUXIA",
|
||||
"playerHp": 20,
|
||||
"playerMaxHp": 100,
|
||||
"playerMana": 6,
|
||||
"playerMaxMana": 20,
|
||||
"inBattle": false,
|
||||
"currentScenePreset": {
|
||||
"id": "scene-1",
|
||||
"name": "旧驿道",
|
||||
"description": "山风压着尘土。",
|
||||
"mutationStateText": "路边新添了打斗痕迹。",
|
||||
"currentPressureLevel": "high"
|
||||
},
|
||||
"currentEncounter": {
|
||||
"id": "npc-1",
|
||||
"kind": "npc",
|
||||
"npcName": "守路人",
|
||||
"npcDescription": "守在路口的人。"
|
||||
},
|
||||
"npcStates": {
|
||||
"npc-1": {
|
||||
"affinity": 18,
|
||||
"chattedCount": 0,
|
||||
"recruited": false,
|
||||
"firstMeaningfulContactResolved": false,
|
||||
"relationState": { "stance": "guarded" }
|
||||
}
|
||||
},
|
||||
"storyHistory": [{
|
||||
"text": "你刚从一场战斗里脱身。",
|
||||
"historyRole": "result"
|
||||
}]
|
||||
}),
|
||||
RuntimeStoryPromptContextExtras {
|
||||
last_function_id: Some("npc_chat".to_string()),
|
||||
..RuntimeStoryPromptContextExtras::default()
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(context["sceneName"], json!("旧驿道"));
|
||||
assert_eq!(context["encounterDisclosureStage"], json!("partial"));
|
||||
assert_eq!(context["conversationPressure"], json!("high"));
|
||||
assert_eq!(context["firstContactRelationStance"], json!("guarded"));
|
||||
assert!(
|
||||
context["sceneDescription"]
|
||||
.as_str()
|
||||
.is_some_and(|text| text.contains("最新世界变化"))
|
||||
);
|
||||
}
|
||||
}
|
||||
1569
server-rs/crates/module-runtime-story-compat/src/story_engine.rs
Normal file
1569
server-rs/crates/module-runtime-story-compat/src/story_engine.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,24 @@
|
||||
use serde_json::Value;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use shared_contracts::runtime_story::{
|
||||
RuntimeStoryCompanionViewModel, RuntimeStoryEncounterViewModel, RuntimeStoryOptionView,
|
||||
RuntimeStoryPlayerViewModel, RuntimeStoryStatusViewModel, RuntimeStoryViewModel,
|
||||
RuntimeStoryCompanionViewModel, RuntimeStoryEncounterViewModel, RuntimeStoryEquipmentSlotView,
|
||||
RuntimeStoryForgeRecipeView, RuntimeStoryForgeRequirementView, RuntimeStoryInventoryActionView,
|
||||
RuntimeStoryInventoryItemActionsView, RuntimeStoryInventoryItemView,
|
||||
RuntimeStoryInventoryViewModel, RuntimeStoryOptionView, RuntimeStoryPlayerViewModel,
|
||||
RuntimeStoryStatusViewModel, RuntimeStoryViewModel,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
read_array_field, read_bool_field, read_i32_field, read_object_field,
|
||||
read_optional_string_field, read_required_string_field,
|
||||
battle::inventory_item_has_usable_effect, build_runtime_npc_interaction_view,
|
||||
equipment_slot_label, read_array_field, read_bool_field, read_field, read_i32_field,
|
||||
read_object_field, read_optional_string_field, read_player_equipment_item,
|
||||
read_player_inventory_values, read_required_string_field, remove_inventory_item_from_list,
|
||||
resolve_equipment_slot_for_item,
|
||||
};
|
||||
|
||||
use super::forge::{
|
||||
apply_forge_requirements_if_possible, count_matching_forge_requirement,
|
||||
forge_recipe_definitions, format_currency_text, reforge_cost_definition,
|
||||
};
|
||||
|
||||
/// 运行时故事 view-model 只依赖快照 JSON 与共享 contract,可脱离 HTTP 层独立编译。
|
||||
@@ -24,6 +35,7 @@ pub fn build_runtime_story_view_model(
|
||||
},
|
||||
encounter: build_runtime_story_encounter(game_state),
|
||||
companions: build_runtime_story_companions(game_state),
|
||||
inventory: build_runtime_story_inventory(game_state),
|
||||
available_options: options.to_vec(),
|
||||
status: RuntimeStoryStatusViewModel {
|
||||
in_battle: read_bool_field(game_state, "inBattle").unwrap_or(false),
|
||||
@@ -35,9 +47,293 @@ pub fn build_runtime_story_view_model(
|
||||
"currentNpcBattleOutcome",
|
||||
),
|
||||
},
|
||||
npc_interaction: build_runtime_npc_interaction_view(game_state),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_story_inventory(game_state: &Value) -> RuntimeStoryInventoryViewModel {
|
||||
let player_currency = read_i32_field(game_state, "playerCurrency").unwrap_or(0);
|
||||
let world_type = read_optional_string_field(game_state, "worldType");
|
||||
let in_battle = read_bool_field(game_state, "inBattle").unwrap_or(false);
|
||||
let inventory_items = read_player_inventory_values(game_state);
|
||||
|
||||
RuntimeStoryInventoryViewModel {
|
||||
player_currency,
|
||||
currency_text: format_currency_text(player_currency, world_type.as_deref()),
|
||||
in_battle,
|
||||
backpack_items: inventory_items
|
||||
.iter()
|
||||
.map(|item| build_inventory_item_view(game_state, item))
|
||||
.collect(),
|
||||
equipment_slots: ["weapon", "armor", "relic"]
|
||||
.into_iter()
|
||||
.map(|slot_id| build_equipment_slot_view(game_state, slot_id))
|
||||
.collect(),
|
||||
forge_recipes: forge_recipe_definitions()
|
||||
.into_iter()
|
||||
.map(|recipe| {
|
||||
let requirements = recipe
|
||||
.requirements
|
||||
.iter()
|
||||
.map(|requirement| RuntimeStoryForgeRequirementView {
|
||||
id: requirement.id.to_string(),
|
||||
label: requirement.label.to_string(),
|
||||
quantity: requirement.quantity,
|
||||
owned: count_matching_forge_requirement(
|
||||
inventory_items.as_slice(),
|
||||
requirement,
|
||||
),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let disabled_reason = forge_recipe_disabled_reason(
|
||||
game_state,
|
||||
player_currency,
|
||||
requirements.as_slice(),
|
||||
recipe.currency_cost,
|
||||
);
|
||||
let can_craft = disabled_reason.is_none();
|
||||
|
||||
RuntimeStoryForgeRecipeView {
|
||||
id: recipe.id.to_string(),
|
||||
name: recipe.name.to_string(),
|
||||
kind: recipe.kind.to_string(),
|
||||
description: recipe.description.to_string(),
|
||||
result_label: recipe.result_label.to_string(),
|
||||
currency_cost: recipe.currency_cost,
|
||||
currency_text: format_currency_text(
|
||||
recipe.currency_cost,
|
||||
world_type.as_deref(),
|
||||
),
|
||||
requirements,
|
||||
can_craft,
|
||||
disabled_reason: disabled_reason.clone(),
|
||||
action: build_inventory_action(
|
||||
"forge_craft",
|
||||
format!("制作{}", recipe.result_label),
|
||||
Some(json!({ "recipeId": recipe.id })),
|
||||
can_craft,
|
||||
disabled_reason,
|
||||
),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_inventory_item_view(game_state: &Value, item: &Value) -> RuntimeStoryInventoryItemView {
|
||||
RuntimeStoryInventoryItemView {
|
||||
item: item.clone(),
|
||||
actions: RuntimeStoryInventoryItemActionsView {
|
||||
use_item: build_use_item_action(game_state, item),
|
||||
equip: build_equip_item_action(game_state, item),
|
||||
dismantle: build_dismantle_item_action(game_state, item),
|
||||
reforge: build_reforge_item_action(game_state, item),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn build_equipment_slot_view(game_state: &Value, slot_id: &str) -> RuntimeStoryEquipmentSlotView {
|
||||
let item = read_player_equipment_item(game_state, slot_id);
|
||||
let item_name = item
|
||||
.as_ref()
|
||||
.and_then(|value| read_optional_string_field(value, "name"))
|
||||
.unwrap_or_else(|| equipment_slot_label(slot_id).to_string());
|
||||
let disabled_reason = inventory_non_battle_gate_reason(game_state).or_else(|| {
|
||||
item.is_none()
|
||||
.then(|| format!("{}位当前没有装备。", equipment_slot_label(slot_id)))
|
||||
});
|
||||
let enabled = disabled_reason.is_none();
|
||||
|
||||
RuntimeStoryEquipmentSlotView {
|
||||
slot_id: slot_id.to_string(),
|
||||
label: equipment_slot_label(slot_id).to_string(),
|
||||
item,
|
||||
unequip: build_inventory_action(
|
||||
"equipment_unequip",
|
||||
format!("卸下{item_name}"),
|
||||
Some(json!({ "slotId": slot_id })),
|
||||
enabled,
|
||||
disabled_reason,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_use_item_action(game_state: &Value, item: &Value) -> RuntimeStoryInventoryActionView {
|
||||
let item_id = read_optional_string_field(item, "id");
|
||||
let item_name = read_item_name(item);
|
||||
let disabled_reason = if read_field(game_state, "playerCharacter").is_none() {
|
||||
Some("缺少玩家角色,无法使用物品。".to_string())
|
||||
} else if !read_bool_field(game_state, "inBattle").unwrap_or(false) {
|
||||
Some("当前物品使用需要在战斗动作中结算。".to_string())
|
||||
} else if read_i32_field(item, "quantity").unwrap_or(0) <= 0 {
|
||||
Some("物品数量不足。".to_string())
|
||||
} else if !inventory_item_has_usable_effect(item) {
|
||||
Some("该物品当前没有可直接使用的效果。".to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let enabled = disabled_reason.is_none();
|
||||
|
||||
build_inventory_action(
|
||||
"inventory_use",
|
||||
format!("使用{item_name}"),
|
||||
item_id.map(|item_id| json!({ "itemId": item_id })),
|
||||
enabled,
|
||||
disabled_reason,
|
||||
)
|
||||
}
|
||||
|
||||
fn build_equip_item_action(game_state: &Value, item: &Value) -> RuntimeStoryInventoryActionView {
|
||||
let item_id = read_optional_string_field(item, "id");
|
||||
let item_name = read_item_name(item);
|
||||
let disabled_reason = inventory_non_battle_gate_reason(game_state).or_else(|| {
|
||||
if read_i32_field(item, "quantity").unwrap_or(0) <= 0 {
|
||||
Some("物品数量不足。".to_string())
|
||||
} else if resolve_equipment_slot_for_item(item).is_none() {
|
||||
Some("该物品不能装备。".to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
let enabled = disabled_reason.is_none();
|
||||
|
||||
build_inventory_action(
|
||||
"equipment_equip",
|
||||
format!("装备{item_name}"),
|
||||
item_id.map(|item_id| json!({ "itemId": item_id })),
|
||||
enabled,
|
||||
disabled_reason,
|
||||
)
|
||||
}
|
||||
|
||||
fn build_dismantle_item_action(
|
||||
game_state: &Value,
|
||||
item: &Value,
|
||||
) -> RuntimeStoryInventoryActionView {
|
||||
let item_id = read_optional_string_field(item, "id");
|
||||
let item_name = read_item_name(item);
|
||||
let disabled_reason = inventory_non_battle_gate_reason(game_state).or_else(|| {
|
||||
if read_i32_field(item, "quantity").unwrap_or(0) <= 0 {
|
||||
Some("物品数量不足。".to_string())
|
||||
} else if resolve_equipment_slot_for_item(item).is_none()
|
||||
&& read_field(item, "buildProfile").is_none()
|
||||
{
|
||||
Some("该物品不能拆解。".to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
let enabled = disabled_reason.is_none();
|
||||
|
||||
build_inventory_action(
|
||||
"forge_dismantle",
|
||||
format!("拆解{item_name}"),
|
||||
item_id.map(|item_id| json!({ "itemId": item_id })),
|
||||
enabled,
|
||||
disabled_reason,
|
||||
)
|
||||
}
|
||||
|
||||
fn build_reforge_item_action(game_state: &Value, item: &Value) -> RuntimeStoryInventoryActionView {
|
||||
let item_id = read_optional_string_field(item, "id");
|
||||
let item_name = read_item_name(item);
|
||||
let disabled_reason = inventory_non_battle_gate_reason(game_state).or_else(|| {
|
||||
let Some(slot_id) = resolve_equipment_slot_for_item(item) else {
|
||||
return Some("该物品不能重铸。".to_string());
|
||||
};
|
||||
if read_i32_field(item, "quantity").unwrap_or(0) <= 0 {
|
||||
return Some("物品数量不足。".to_string());
|
||||
}
|
||||
if read_field(item, "buildProfile").is_none() {
|
||||
return Some("该物品不能重铸。".to_string());
|
||||
}
|
||||
|
||||
let cost = reforge_cost_definition(Some(slot_id));
|
||||
let player_currency = read_i32_field(game_state, "playerCurrency").unwrap_or(0);
|
||||
if player_currency < cost.currency_cost {
|
||||
return Some("货币不足。".to_string());
|
||||
}
|
||||
let Some(item_id) = read_optional_string_field(item, "id") else {
|
||||
return Some("目标物品缺少 id。".to_string());
|
||||
};
|
||||
let base_inventory = remove_inventory_item_from_list(
|
||||
read_player_inventory_values(game_state),
|
||||
item_id.as_str(),
|
||||
1,
|
||||
);
|
||||
if apply_forge_requirements_if_possible(
|
||||
base_inventory.as_slice(),
|
||||
cost.requirements.as_slice(),
|
||||
)
|
||||
.is_none()
|
||||
{
|
||||
return Some("材料不足。".to_string());
|
||||
}
|
||||
None
|
||||
});
|
||||
let enabled = disabled_reason.is_none();
|
||||
|
||||
build_inventory_action(
|
||||
"forge_reforge",
|
||||
format!("重铸{item_name}"),
|
||||
item_id.map(|item_id| json!({ "itemId": item_id })),
|
||||
enabled,
|
||||
disabled_reason,
|
||||
)
|
||||
}
|
||||
|
||||
fn forge_recipe_disabled_reason(
|
||||
game_state: &Value,
|
||||
player_currency: i32,
|
||||
requirements: &[RuntimeStoryForgeRequirementView],
|
||||
currency_cost: i32,
|
||||
) -> Option<String> {
|
||||
inventory_non_battle_gate_reason(game_state).or_else(|| {
|
||||
if player_currency < currency_cost {
|
||||
Some("货币不足。".to_string())
|
||||
} else if requirements
|
||||
.iter()
|
||||
.any(|requirement| requirement.owned < requirement.quantity)
|
||||
{
|
||||
Some("材料不足。".to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn inventory_non_battle_gate_reason(game_state: &Value) -> Option<String> {
|
||||
if read_field(game_state, "playerCharacter").is_none() {
|
||||
return Some("缺少玩家角色,无法操作背包。".to_string());
|
||||
}
|
||||
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
|
||||
return Some("战斗中无法执行该操作。".to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn build_inventory_action(
|
||||
function_id: &str,
|
||||
action_text: String,
|
||||
payload: Option<Value>,
|
||||
enabled: bool,
|
||||
reason: Option<String>,
|
||||
) -> RuntimeStoryInventoryActionView {
|
||||
RuntimeStoryInventoryActionView {
|
||||
function_id: function_id.to_string(),
|
||||
action_text,
|
||||
payload,
|
||||
enabled,
|
||||
reason: if enabled { None } else { reason },
|
||||
}
|
||||
}
|
||||
|
||||
fn read_item_name(item: &Value) -> String {
|
||||
read_optional_string_field(item, "name")
|
||||
.or_else(|| read_optional_string_field(item, "id"))
|
||||
.unwrap_or_else(|| "未命名物品".to_string())
|
||||
}
|
||||
|
||||
pub fn build_runtime_story_companions(game_state: &Value) -> Vec<RuntimeStoryCompanionViewModel> {
|
||||
read_array_field(game_state, "companions")
|
||||
.into_iter()
|
||||
@@ -84,3 +380,125 @@ pub fn resolve_current_encounter_npc_state<'a>(
|
||||
.get(encounter_id)
|
||||
.or_else(|| npc_states.get(npc_name))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn base_game_state() -> Value {
|
||||
json!({
|
||||
"worldType": "WUXIA",
|
||||
"playerCharacter": {
|
||||
"id": "hero-1",
|
||||
"name": "沈砺"
|
||||
},
|
||||
"playerCurrency": 90,
|
||||
"playerInventory": [
|
||||
{
|
||||
"id": "scrap-a",
|
||||
"category": "材料",
|
||||
"name": "旧铜片",
|
||||
"quantity": 2,
|
||||
"rarity": "common",
|
||||
"tags": ["material", "工巧"]
|
||||
},
|
||||
{
|
||||
"id": "scrap-b",
|
||||
"category": "材料",
|
||||
"name": "风化铁片",
|
||||
"quantity": 1,
|
||||
"rarity": "common",
|
||||
"tags": ["material", "守御"]
|
||||
},
|
||||
{
|
||||
"id": "duelist-blade",
|
||||
"category": "武器",
|
||||
"name": "百炼追风剑",
|
||||
"quantity": 1,
|
||||
"rarity": "epic",
|
||||
"tags": ["weapon", "快剑", "突进"],
|
||||
"equipmentSlotId": "weapon",
|
||||
"buildProfile": {
|
||||
"role": "快剑",
|
||||
"tags": ["快剑", "突进"],
|
||||
"forgeRank": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "refined-ingot",
|
||||
"category": "材料",
|
||||
"name": "精炼锭材",
|
||||
"quantity": 1,
|
||||
"rarity": "rare",
|
||||
"tags": ["material", "工巧", "守御"]
|
||||
}
|
||||
],
|
||||
"playerEquipment": {
|
||||
"weapon": null,
|
||||
"armor": null,
|
||||
"relic": null
|
||||
},
|
||||
"inBattle": false,
|
||||
"npcInteractionActive": false,
|
||||
"companions": []
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inventory_view_compiles_forge_recipe_availability_on_server() {
|
||||
let view = build_runtime_story_inventory(&base_game_state());
|
||||
|
||||
let refined = view
|
||||
.forge_recipes
|
||||
.iter()
|
||||
.find(|recipe| recipe.id == "synthesis-refined-ingot")
|
||||
.expect("refined ingot recipe should exist");
|
||||
assert!(refined.can_craft);
|
||||
assert_eq!(refined.requirements[0].owned, 4);
|
||||
assert!(refined.action.enabled);
|
||||
|
||||
let blade = view
|
||||
.backpack_items
|
||||
.iter()
|
||||
.find(|item| {
|
||||
read_optional_string_field(&item.item, "id").as_deref() == Some("duelist-blade")
|
||||
})
|
||||
.expect("blade item view should exist");
|
||||
assert!(blade.actions.equip.enabled);
|
||||
assert!(blade.actions.dismantle.enabled);
|
||||
assert!(blade.actions.reforge.enabled);
|
||||
assert!(!blade.actions.use_item.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inventory_view_reports_disabled_reasons_for_locked_actions() {
|
||||
let mut state = base_game_state();
|
||||
state
|
||||
.as_object_mut()
|
||||
.expect("state should be object")
|
||||
.insert("inBattle".to_string(), Value::Bool(true));
|
||||
|
||||
let view = build_runtime_story_inventory(&state);
|
||||
let refined = view
|
||||
.forge_recipes
|
||||
.iter()
|
||||
.find(|recipe| recipe.id == "synthesis-refined-ingot")
|
||||
.expect("recipe should exist");
|
||||
assert!(!refined.can_craft);
|
||||
assert_eq!(
|
||||
refined.disabled_reason.as_deref(),
|
||||
Some("战斗中无法执行该操作。")
|
||||
);
|
||||
|
||||
let weapon_slot = view
|
||||
.equipment_slots
|
||||
.iter()
|
||||
.find(|slot| slot.slot_id == "weapon")
|
||||
.expect("weapon slot should exist");
|
||||
assert!(!weapon_slot.unequip.enabled);
|
||||
assert_eq!(
|
||||
weapon_slot.unequip.reason.as_deref(),
|
||||
Some("战斗中无法执行该操作。")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user