This commit is contained in:
2026-04-28 19:36:39 +08:00
parent a9febe7678
commit f0471a4f8d
206 changed files with 18456 additions and 10133 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View 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
}

View File

@@ -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("最新世界变化"))
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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("战斗中无法执行该操作。")
);
}
}