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

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

View File

@@ -0,0 +1,3 @@
//! runtime story 应用编排落位。
//!
//! 这里组合纯领域规则并返回后端投影真实保存、SSE 和模型调用由外层完成。

View File

@@ -0,0 +1,881 @@
use serde_json::{Map, Value, json};
use shared_contracts::runtime_story::{
RuntimeBattlePresentation, RuntimeStoryActionRequest, RuntimeStoryOptionView, RuntimeStoryPatch,
};
use crate::{
StoryResolution, append_active_build_buffs, build_status_patch, clear_encounter_only,
clear_encounter_state, current_encounter_id, current_encounter_name_from_battle,
ensure_json_object, first_hostile_npc_string_field, grant_player_progression_experience,
increment_runtime_stat, read_array_field, read_field, read_i32_field, read_object_field,
read_optional_string_field, remove_player_inventory_item, resolve_action_text,
write_bool_field, write_current_encounter_i32_field, write_first_hostile_npc_i32_field,
write_i32_field, write_null_field, write_string_field,
};
/// 战斗 compat 纯结算链已经不依赖 HTTP / AppState。
///
/// 这里同时承接 battle action 的状态结算、资源恢复和战斗选项编译,
/// 让 `api-server` 只保留 HTTP 外壳与最终响应拼装。
struct BattleActionPlan {
action_text: String,
result_text: String,
damage_dealt: i32,
damage_taken: i32,
heal: i32,
mana_restore: i32,
mana_cost: i32,
cooldown_tick_turns: i32,
cooldown_bonus_turns: i32,
applied_skill_cooldown: Option<(String, i32)>,
build_buffs: Vec<Value>,
consumed_item_id: Option<String>,
}
struct BattleSkillView {
id: String,
name: String,
damage: i32,
mana_cost: i32,
cooldown_turns: i32,
build_buffs: Vec<Value>,
}
pub struct BattleInventoryUseProfile {
hp_restore: i32,
mana_restore: i32,
cooldown_reduction: i32,
build_buffs: Vec<Value>,
}
struct BattleInventoryItemView {
id: String,
name: String,
quantity: i32,
use_profile: Option<BattleInventoryUseProfile>,
}
/// 兼容战斗结算的胜负状态。
///
/// 这里显式补齐失败分支,避免“玩家已死但敌方也被打空时”被错误归类成胜利。
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum BattleResolutionOutcome {
Ongoing,
Victory,
SparComplete,
Defeat,
}
impl BattleResolutionOutcome {
fn as_battle_outcome(self) -> &'static str {
match self {
Self::Ongoing => "ongoing",
Self::Victory => "victory",
Self::SparComplete => "spar_complete",
Self::Defeat => "defeat",
}
}
fn as_status_outcome(self) -> Option<&'static str> {
match self {
Self::Ongoing => None,
Self::Victory => Some("fight_victory"),
Self::SparComplete => Some("spar_complete"),
Self::Defeat => Some("fight_defeat"),
}
}
}
pub fn resolve_battle_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
function_id: &str,
) -> Result<StoryResolution, String> {
let target_id = current_encounter_id(game_state)
.or_else(|| first_hostile_npc_string_field(game_state, "id"))
.unwrap_or_else(|| "battle_target".to_string());
let target_name = current_encounter_name_from_battle(game_state);
let battle_mode = read_optional_string_field(game_state, "currentNpcBattleMode")
.unwrap_or_else(|| "fight".to_string());
if function_id == "battle_escape_breakout" {
clear_encounter_state(game_state);
return Ok(StoryResolution {
action_text: resolve_action_text("强行脱离战斗", request),
result_text: "你抓住空隙强行脱离战斗,把这一轮危险先甩在身后。".to_string(),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: vec![
RuntimeStoryPatch::BattleResolved {
function_id: function_id.to_string(),
target_id: Some(target_id.clone()),
damage_dealt: Some(0),
damage_taken: Some(0),
outcome: "escaped".to_string(),
},
build_status_patch(game_state),
RuntimeStoryPatch::EncounterChanged { encounter_id: None },
],
battle: Some(RuntimeBattlePresentation {
target_id: Some(target_id),
target_name: Some(target_name),
damage_dealt: Some(0),
damage_taken: Some(0),
outcome: Some("escaped".to_string()),
}),
toast: Some("已脱离战斗".to_string()),
});
}
let plan = build_battle_action_plan(game_state, request, function_id)?;
spend_player_mana(game_state, plan.mana_cost);
restore_player_resource(game_state, plan.heal, plan.mana_restore);
tick_player_skill_cooldowns(game_state, plan.cooldown_tick_turns);
reduce_player_skill_cooldowns(game_state, plan.cooldown_bonus_turns);
if let Some((skill_id, turns)) = plan.applied_skill_cooldown.as_ref() {
set_player_skill_cooldown(game_state, skill_id.as_str(), *turns);
}
if !plan.build_buffs.is_empty() {
append_active_build_buffs(game_state, plan.build_buffs.clone());
}
if let Some(item_id) = plan.consumed_item_id.as_ref() {
remove_player_inventory_item(game_state, item_id.as_str(), 1);
increment_runtime_stat(game_state, "itemsUsed", 1);
}
let player_hp = apply_player_damage(game_state, plan.damage_taken);
let target_hp = apply_target_damage(game_state, plan.damage_dealt);
let outcome = resolve_battle_resolution_outcome(player_hp, target_hp, battle_mode.as_str());
let victory_experience = if outcome == BattleResolutionOutcome::Victory {
battle_victory_experience_reward(game_state)
} else {
0
};
if outcome != BattleResolutionOutcome::Ongoing {
write_bool_field(game_state, "inBattle", false);
write_bool_field(game_state, "npcInteractionActive", false);
write_null_field(game_state, "currentNpcBattleMode");
if let Some(status_outcome) = outcome.as_status_outcome() {
write_string_field(game_state, "currentNpcBattleOutcome", status_outcome);
}
if outcome == BattleResolutionOutcome::Victory {
clear_encounter_only(game_state);
increment_runtime_stat(game_state, "hostileNpcsDefeated", 1);
if victory_experience > 0 {
grant_player_progression_experience(game_state, victory_experience, "hostile_npc");
}
} else if outcome == BattleResolutionOutcome::Defeat {
clear_encounter_state(game_state);
}
}
let mut patches = vec![
RuntimeStoryPatch::BattleResolved {
function_id: function_id.to_string(),
target_id: Some(target_id.clone()),
damage_dealt: Some(plan.damage_dealt),
damage_taken: Some(plan.damage_taken),
outcome: outcome.as_battle_outcome().to_string(),
},
build_status_patch(game_state),
];
if outcome == BattleResolutionOutcome::Victory {
patches.push(RuntimeStoryPatch::EncounterChanged { encounter_id: None });
}
Ok(StoryResolution {
action_text: resolve_action_text(plan.action_text.as_str(), request),
result_text: if outcome == BattleResolutionOutcome::Ongoing {
plan.result_text
} else if outcome == BattleResolutionOutcome::SparComplete {
format!("{target_name} 收住了最后一击,这场切磋已经分出结果。")
} else if outcome == BattleResolutionOutcome::Defeat {
format!("你在与 {target_name} 的交锋中被压制倒下,这场战斗以你的败北收束。")
} else {
format!("{target_name} 被你压制下去,眼前的战斗已经结束。")
},
story_text: None,
presentation_options: None,
saved_current_story: None,
patches,
battle: Some(RuntimeBattlePresentation {
target_id: Some(target_id),
target_name: Some(target_name),
damage_dealt: Some(plan.damage_dealt),
damage_taken: Some(plan.damage_taken),
outcome: Some(outcome.as_battle_outcome().to_string()),
}),
toast: battle_action_toast(function_id, request),
})
}
pub fn restore_player_resource(game_state: &mut Value, hp_restore: i32, mana_restore: i32) {
let max_hp = read_i32_field(game_state, "playerMaxHp")
.unwrap_or(1)
.max(1);
let max_mana = read_i32_field(game_state, "playerMaxMana")
.unwrap_or(0)
.max(0);
let hp = read_i32_field(game_state, "playerHp").unwrap_or(max_hp);
let mana = read_i32_field(game_state, "playerMana").unwrap_or(max_mana);
write_i32_field(game_state, "playerHp", (hp + hp_restore).clamp(0, max_hp));
write_i32_field(
game_state,
"playerMana",
(mana + mana_restore).clamp(0, max_mana),
);
}
pub fn build_battle_runtime_story_options(game_state: &Value) -> Vec<RuntimeStoryOptionView> {
let mut options = vec![
RuntimeStoryOptionView {
detail_text: Some(build_basic_attack_detail_text(game_state)),
..build_static_runtime_story_option("battle_attack_basic", "普通攻击", "combat")
},
RuntimeStoryOptionView {
detail_text: Some("回血 12 / 回蓝 9 / 冷却 -1".to_string()),
..build_static_runtime_story_option("battle_recover_breath", "恢复", "combat")
},
];
let preferred_item = pick_preferred_battle_inventory_item(game_state);
if let Some(item) = preferred_item {
let effect = item
.use_profile
.expect("preferred battle item must have use profile");
options.push(build_runtime_story_option_with_payload(
"inventory_use",
&format!("使用物品:{}", item.name),
"combat",
Some(build_battle_item_summary(&effect)),
json!({
"itemId": item.id
}),
));
} else {
options.push(build_disabled_runtime_story_option(
"inventory_use",
"使用物品",
"combat",
Some("当前没有可直接结算的战斗消耗品".to_string()),
"暂无可用物品",
None,
));
}
options.extend(build_battle_skill_runtime_story_options(game_state));
options.push(build_static_runtime_story_option(
"battle_escape_breakout",
"强行脱离战斗",
"combat",
));
options
}
fn spend_player_mana(game_state: &mut Value, mana_cost: i32) {
if mana_cost <= 0 {
return;
}
let mana = read_i32_field(game_state, "playerMana").unwrap_or(0);
write_i32_field(game_state, "playerMana", (mana - mana_cost).max(0));
}
fn apply_player_damage(game_state: &mut Value, damage: i32) -> i32 {
let hp = read_i32_field(game_state, "playerHp").unwrap_or(1).max(0);
let next_hp = if damage <= 0 {
hp
} else {
(hp - damage).max(0)
};
write_i32_field(game_state, "playerHp", next_hp);
next_hp
}
fn apply_target_damage(game_state: &mut Value, damage: i32) -> i32 {
let target_hp = read_object_field(game_state, "currentEncounter")
.and_then(|encounter| {
read_i32_field(encounter, "hp")
.or_else(|| read_i32_field(encounter, "currentHp"))
.or_else(|| read_i32_field(encounter, "targetHp"))
})
.or_else(|| {
read_array_field(game_state, "sceneHostileNpcs")
.first()
.and_then(|target| read_i32_field(target, "hp"))
})
.unwrap_or(24);
let next_hp = target_hp - damage.max(0);
write_current_encounter_i32_field(game_state, "hp", next_hp);
write_first_hostile_npc_i32_field(game_state, "hp", next_hp);
next_hp
}
fn resolve_battle_resolution_outcome(
player_hp: i32,
target_hp: i32,
battle_mode: &str,
) -> BattleResolutionOutcome {
// 中文注释:玩家死亡优先级高于敌方倒地。
// 这样即便同一回合双方都被打到 0也必须按玩家败北处理不能误发胜利。
if player_hp <= 0 {
return BattleResolutionOutcome::Defeat;
}
if target_hp <= 0 {
if battle_mode == "spar" {
BattleResolutionOutcome::SparComplete
} else {
BattleResolutionOutcome::Victory
}
} else {
BattleResolutionOutcome::Ongoing
}
}
fn read_player_skills(game_state: &Value) -> Vec<BattleSkillView> {
read_field(game_state, "playerCharacter")
.map(|character| read_array_field(character, "skills"))
.unwrap_or_default()
.into_iter()
.filter_map(|entry| {
let id = read_optional_string_field(entry, "id")?;
let name = read_optional_string_field(entry, "name").unwrap_or_else(|| id.clone());
Some(BattleSkillView {
id,
name,
damage: read_i32_field(entry, "damage").unwrap_or(14).max(0),
mana_cost: read_i32_field(entry, "manaCost").unwrap_or(0).max(0),
cooldown_turns: read_i32_field(entry, "cooldownTurns").unwrap_or(0).max(0),
build_buffs: read_array_field(entry, "buildBuffs")
.into_iter()
.cloned()
.collect(),
})
})
.collect()
}
fn find_player_skill_by_id(game_state: &Value, skill_id: &str) -> Option<BattleSkillView> {
read_player_skills(game_state)
.into_iter()
.find(|skill| skill.id == skill_id)
}
fn build_basic_attack_detail_text(game_state: &Value) -> String {
let strength = read_field(game_state, "playerCharacter")
.and_then(|character| read_field(character, "attributes"))
.and_then(|attributes| read_i32_field(attributes, "strength"))
.unwrap_or(8);
let agility = read_field(game_state, "playerCharacter")
.and_then(|character| read_field(character, "attributes"))
.and_then(|attributes| read_i32_field(attributes, "agility"))
.unwrap_or(0);
let preview_damage = ((strength * 85 + agility * 45) / 100).max(8);
format!("不耗蓝 / 伤害 {preview_damage}")
}
fn read_player_skill_cooldowns(game_state: &Value) -> std::collections::BTreeMap<String, i32> {
read_object_field(game_state, "playerSkillCooldowns")
.and_then(Value::as_object)
.map(|cooldowns| {
cooldowns
.iter()
.map(|(skill_id, turns)| {
(
skill_id.clone(),
turns
.as_i64()
.and_then(|value| i32::try_from(value).ok())
.unwrap_or(0)
.max(0),
)
})
.collect()
})
.unwrap_or_default()
}
fn build_battle_skill_runtime_story_options(game_state: &Value) -> Vec<RuntimeStoryOptionView> {
let cooldowns = read_player_skill_cooldowns(game_state);
let player_mana = read_i32_field(game_state, "playerMana").unwrap_or(0);
read_player_skills(game_state)
.into_iter()
.map(|skill| {
let detail_text = Some(format!(
"耗蓝 {} / 伤害 {} / 冷却 {}",
skill.mana_cost.max(0),
skill.damage.max(0),
skill.cooldown_turns.max(0)
));
let payload = Some(json!({
"skillId": skill.id
}));
let remaining_cooldown = cooldowns.get(skill.id.as_str()).copied().unwrap_or(0);
if remaining_cooldown > 0 {
return build_disabled_runtime_story_option(
"battle_use_skill",
&skill.name,
"combat",
detail_text,
format!("冷却中,还需 {} 回合", remaining_cooldown).as_str(),
payload,
);
}
if skill.mana_cost > player_mana {
return build_disabled_runtime_story_option(
"battle_use_skill",
&skill.name,
"combat",
detail_text,
"灵力不足",
payload,
);
}
RuntimeStoryOptionView {
detail_text,
payload,
..build_static_runtime_story_option("battle_use_skill", &skill.name, "combat")
}
})
.collect()
}
fn tick_player_skill_cooldowns(game_state: &mut Value, turns: i32) {
if turns <= 0 {
return;
}
let root = ensure_json_object(game_state);
let cooldowns = root
.entry("playerSkillCooldowns".to_string())
.or_insert_with(|| Value::Object(Map::new()));
if !cooldowns.is_object() {
*cooldowns = Value::Object(Map::new());
}
let cooldowns = cooldowns
.as_object_mut()
.expect("playerSkillCooldowns should be object");
for value in cooldowns.values_mut() {
let current = value
.as_i64()
.and_then(|number| i32::try_from(number).ok())
.unwrap_or(0);
*value = json!((current - turns).max(0));
}
}
fn reduce_player_skill_cooldowns(game_state: &mut Value, turns: i32) {
if turns <= 0 {
return;
}
tick_player_skill_cooldowns(game_state, turns);
}
fn set_player_skill_cooldown(game_state: &mut Value, skill_id: &str, turns: i32) {
let root = ensure_json_object(game_state);
let cooldowns = root
.entry("playerSkillCooldowns".to_string())
.or_insert_with(|| Value::Object(Map::new()));
if !cooldowns.is_object() {
*cooldowns = Value::Object(Map::new());
}
cooldowns
.as_object_mut()
.expect("playerSkillCooldowns should be object")
.insert(skill_id.to_string(), json!(turns.max(0)));
}
fn read_player_inventory_items(game_state: &Value) -> Vec<BattleInventoryItemView> {
read_array_field(game_state, "playerInventory")
.into_iter()
.filter_map(|entry| {
let id = read_optional_string_field(entry, "id")?;
let name = read_optional_string_field(entry, "name").unwrap_or_else(|| id.clone());
let use_profile =
read_field(entry, "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(),
});
Some(BattleInventoryItemView {
id,
name,
quantity: read_i32_field(entry, "quantity").unwrap_or(0).max(0),
use_profile,
})
})
.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,
) -> Option<BattleInventoryItemView> {
read_player_inventory_items(game_state)
.into_iter()
.find(|item| item.id == item_id)
}
/// 旧前端一次只展示一个“推荐”战斗物品,这里继续用确定性打分,避免展示面漂移。
fn pick_preferred_battle_inventory_item(game_state: &Value) -> Option<BattleInventoryItemView> {
let has_cooling_skill = read_player_skill_cooldowns(game_state)
.values()
.any(|remaining| *remaining > 0);
let player_hp = read_i32_field(game_state, "playerHp").unwrap_or(0);
let player_max_hp = read_i32_field(game_state, "playerMaxHp")
.unwrap_or(1)
.max(1);
let player_mana = read_i32_field(game_state, "playerMana").unwrap_or(0);
let player_max_mana = read_i32_field(game_state, "playerMaxMana")
.unwrap_or(1)
.max(1);
let hp_low = player_hp * 100 <= player_max_hp * 45;
let mana_low = player_mana * 100 <= player_max_mana * 45;
read_player_inventory_items(game_state)
.into_iter()
.filter(|item| item.quantity > 0 && item.use_profile.is_some())
.filter_map(|item| {
let effect = item.use_profile.as_ref()?;
let mut score = effect.build_buffs.len() as i32 * 8;
score += effect.hp_restore * if hp_low { 3 } else { 1 };
score += effect.mana_restore * if mana_low { 2 } else { 1 };
score += effect.cooldown_reduction * if has_cooling_skill { 18 } else { 6 };
Some((score, item))
})
.max_by(|left, right| {
left.0
.cmp(&right.0)
.then_with(|| left.1.name.cmp(&right.1.name).reverse())
})
.map(|(_, item)| item)
}
fn build_battle_item_summary(effect: &BattleInventoryUseProfile) -> String {
let mut parts = Vec::new();
if effect.hp_restore > 0 {
parts.push(format!("回血 {}", effect.hp_restore));
}
if effect.mana_restore > 0 {
parts.push(format!("回蓝 {}", effect.mana_restore));
}
if effect.cooldown_reduction > 0 {
parts.push(format!("冷却 -{}", effect.cooldown_reduction));
}
if !effect.build_buffs.is_empty() {
let buff_names = effect
.build_buffs
.iter()
.filter_map(|buff| read_optional_string_field(buff, "name"))
.collect::<Vec<_>>();
if !buff_names.is_empty() {
parts.push(format!("增益 {}", buff_names.join("")));
}
}
if parts.is_empty() {
"立即结算一次物品效果".to_string()
} else {
parts.join(" / ")
}
}
fn build_static_runtime_story_option(
function_id: &str,
action_text: &str,
scope: &str,
) -> RuntimeStoryOptionView {
RuntimeStoryOptionView {
function_id: function_id.to_string(),
action_text: action_text.to_string(),
detail_text: None,
scope: scope.to_string(),
interaction: None,
payload: None,
disabled: None,
reason: None,
}
}
fn build_runtime_story_option_with_payload(
function_id: &str,
action_text: &str,
scope: &str,
detail_text: Option<String>,
payload: Value,
) -> RuntimeStoryOptionView {
RuntimeStoryOptionView {
detail_text,
payload: Some(payload),
..build_static_runtime_story_option(function_id, action_text, scope)
}
}
fn build_disabled_runtime_story_option(
function_id: &str,
action_text: &str,
scope: &str,
detail_text: Option<String>,
reason: &str,
payload: Option<Value>,
) -> RuntimeStoryOptionView {
RuntimeStoryOptionView {
detail_text,
payload,
disabled: Some(true),
reason: Some(reason.to_string()),
..build_static_runtime_story_option(function_id, action_text, scope)
}
}
fn battle_victory_experience_reward(game_state: &Value) -> i32 {
let hostile = read_array_field(game_state, "sceneHostileNpcs")
.first()
.copied()
.or_else(|| read_field(game_state, "currentEncounter"));
let explicit_reward = hostile
.and_then(|entry| read_i32_field(entry, "experienceReward"))
.unwrap_or(0)
.max(0);
if explicit_reward > 0 {
return explicit_reward;
}
let level = hostile
.and_then(|entry| read_field(entry, "levelProfile"))
.and_then(|profile| read_i32_field(profile, "level"))
.unwrap_or(1)
.max(1);
12 + 6 * (level - 1)
}
fn battle_action_numbers(
function_id: &str,
) -> (i32, i32, i32, i32, i32, &'static str, &'static str) {
match function_id {
"battle_recover_breath" => (
0,
0,
8,
6,
0,
"恢复",
"你先稳住呼吸,把状态从危险边缘拉回一点。",
),
"battle_use_skill" => (
14,
4,
0,
0,
4,
"施放技能",
"你调动灵力打出一记更重的攻势,同时也承受了对方的反扑。",
),
"battle_all_in_crush" => (
22,
8,
0,
0,
6,
"全力压制",
"你把这一轮节奏全部压上去,试图用最强硬的方式打穿对方防线。",
),
"battle_feint_step" => (
6,
2,
0,
0,
0,
"佯攻换位",
"你用一次短促佯攻换开角度,虽然伤害有限,但避开了更重的反击。",
),
"battle_finisher_window" => (
18,
3,
0,
0,
3,
"抓住终结窗口",
"你抓住破绽打出决定性的一击,战斗天平明显向你倾斜。",
),
"battle_guard_break" => (
12,
5,
0,
0,
2,
"破开防守",
"你顶住压力破开对方防守,为后续行动争到更直接的窗口。",
),
"battle_probe_pressure" => (
5,
1,
0,
0,
0,
"试探压迫",
"你没有贸然压上,而是用轻攻测试对方反应。",
),
_ => (
10,
4,
0,
0,
0,
"普通攻击",
"你抓住当前窗口打出一记直接攻击,对方也立刻做出反击。",
),
}
}
fn build_battle_action_plan(
game_state: &Value,
request: &RuntimeStoryActionRequest,
function_id: &str,
) -> Result<BattleActionPlan, String> {
if function_id == "battle_use_skill" {
return build_skill_battle_action_plan(game_state, request);
}
if function_id == "inventory_use" {
return build_inventory_use_battle_action_plan(game_state, request);
}
let (damage_dealt, damage_taken, heal, mana_restore, mana_cost, action_text, result_text) =
battle_action_numbers(function_id);
Ok(BattleActionPlan {
action_text: action_text.to_string(),
result_text: result_text.to_string(),
damage_dealt,
damage_taken,
heal,
mana_restore,
mana_cost,
cooldown_tick_turns: 1,
cooldown_bonus_turns: 0,
applied_skill_cooldown: None,
build_buffs: Vec::new(),
consumed_item_id: None,
})
}
fn build_skill_battle_action_plan(
game_state: &Value,
request: &RuntimeStoryActionRequest,
) -> Result<BattleActionPlan, String> {
let payload = request
.action
.payload
.as_ref()
.ok_or_else(|| "battle_use_skill 缺少 skillId".to_string())?;
let skill_id = read_optional_string_field(payload, "skillId")
.ok_or_else(|| "battle_use_skill 缺少 skillId".to_string())?;
let skill = find_player_skill_by_id(game_state, skill_id.as_str())
.ok_or_else(|| format!("未找到技能:{skill_id}"))?;
let cooldowns = read_player_skill_cooldowns(game_state);
if cooldowns.get(skill_id.as_str()).copied().unwrap_or(0) > 0 {
return Err(format!("{} 仍在冷却中", skill.name));
}
if skill.mana_cost > read_i32_field(game_state, "playerMana").unwrap_or(0) {
return Err("当前灵力不足,无法执行这个战斗动作".to_string());
}
Ok(BattleActionPlan {
action_text: skill.name.clone(),
result_text: format!("{} 命中了敌人,这一轮技能效果已经直接结算。", skill.name),
damage_dealt: skill.damage.max(1),
damage_taken: 4,
heal: 0,
mana_restore: 0,
mana_cost: skill.mana_cost.max(0),
cooldown_tick_turns: 1,
cooldown_bonus_turns: 0,
applied_skill_cooldown: Some((skill.id, skill.cooldown_turns.max(0))),
build_buffs: skill.build_buffs,
consumed_item_id: None,
})
}
fn build_inventory_use_battle_action_plan(
game_state: &Value,
request: &RuntimeStoryActionRequest,
) -> Result<BattleActionPlan, String> {
let payload = request
.action
.payload
.as_ref()
.ok_or_else(|| "inventory_use 缺少 itemId".to_string())?;
let item_id = read_optional_string_field(payload, "itemId")
.ok_or_else(|| "inventory_use 缺少 itemId".to_string())?;
let item = find_player_inventory_item(game_state, item_id.as_str())
.ok_or_else(|| "未找到可用于战斗结算的物品".to_string())?;
if item.quantity <= 0 {
return Err("未找到可用于战斗结算的物品".to_string());
}
if item.use_profile.is_none() {
return Err(format!("{} 当前没有可直接结算的战斗效果", item.name));
}
let effect = item.use_profile.expect("use_profile should exist");
if effect.hp_restore <= 0
&& effect.mana_restore <= 0
&& effect.cooldown_reduction <= 0
&& effect.build_buffs.is_empty()
{
return Err(format!("{} 当前没有可直接结算的战斗效果", item.name));
}
Ok(BattleActionPlan {
action_text: format!("使用{}", item.name),
result_text: format!("你立刻用下{},当前回合的物品效果已经生效。", item.name),
damage_dealt: 0,
damage_taken: 8,
heal: effect.hp_restore.max(0),
mana_restore: effect.mana_restore.max(0),
mana_cost: 0,
cooldown_tick_turns: 1,
cooldown_bonus_turns: effect.cooldown_reduction.max(0),
applied_skill_cooldown: None,
build_buffs: effect.build_buffs,
consumed_item_id: Some(item.id),
})
}
fn battle_action_toast(function_id: &str, request: &RuntimeStoryActionRequest) -> Option<String> {
if function_id != "inventory_use" {
return None;
}
request
.action
.payload
.as_ref()
.and_then(|payload| read_optional_string_field(payload, "itemId"))
.map(|_| "Build 增益已写回当前快照".to_string())
}

View File

@@ -0,0 +1,92 @@
use serde_json::json;
use shared_contracts::runtime_story::{
RuntimeStoryActionRequest, RuntimeStoryChoiceAction, RuntimeStoryPatch,
};
use crate::{
battle::resolve_battle_action, build_status_patch, read_bool_field, read_i32_field,
read_optional_string_field,
};
fn build_battle_fixture() -> serde_json::Value {
json!({
"inBattle": true,
"npcInteractionActive": false,
"playerHp": 4,
"playerMaxHp": 40,
"playerMana": 10,
"playerMaxMana": 10,
"playerSkillCooldowns": {},
"runtimeStats": {
"hostileNpcsDefeated": 0,
"itemsUsed": 0,
"questsAccepted": 0,
"scenesTraveled": 0,
"playTimeMs": 0,
"lastPlayTickAt": null
},
"currentNpcBattleMode": "fight",
"currentNpcBattleOutcome": null,
"currentEncounter": {
"kind": "npc",
"id": "npc_bandit_01",
"npcName": "断桥匪首",
"hostile": true,
"hp": 8,
"experienceReward": 24
},
"sceneHostileNpcs": [{
"id": "npc_bandit_01",
"name": "断桥匪首",
"hp": 8,
"maxHp": 80,
"experienceReward": 24
}]
})
}
fn build_request(function_id: &str, option_text: &str) -> RuntimeStoryActionRequest {
RuntimeStoryActionRequest {
session_id: "runtime-main".to_string(),
client_version: Some(0),
action: RuntimeStoryChoiceAction {
action_type: "story_choice".to_string(),
function_id: function_id.to_string(),
target_id: None,
payload: Some(json!({
"optionText": option_text
})),
},
snapshot: None,
}
}
#[test]
fn battle_resolution_prefers_player_defeat_when_both_sides_fall_in_same_turn() {
let request = build_request("battle_all_in_crush", "全力压制");
let mut game_state = build_battle_fixture();
let resolution = resolve_battle_action(&mut game_state, &request, "battle_all_in_crush")
.expect("battle action should resolve");
assert_eq!(read_i32_field(&game_state, "playerHp"), Some(0));
assert_eq!(
read_optional_string_field(&game_state, "currentNpcBattleOutcome"),
Some("fight_defeat".to_string())
);
assert_eq!(read_bool_field(&game_state, "inBattle"), Some(false));
assert!(resolution.result_text.contains("败北"));
assert!(matches!(
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())
);
}

View File

@@ -0,0 +1,3 @@
//! runtime story 兼容写入命令过渡落位。
//!
//! 用于表达旧剧情动作解析、战斗动作、锻造动作和 NPC 互动等输入。

View File

@@ -0,0 +1,323 @@
use serde_json::{Map, Value, json};
use shared_kernel::format_rfc3339;
use time::OffsetDateTime;
/// Runtime story compat 的纯 JSON 快照工具层。
///
/// 这里不允许引入 HTTP、AppState 或持久化依赖,保证后续 battle/forge/npc/quest
/// 规则迁入独立 crate 时可以继续复用同一批状态读写函数。
pub fn clear_encounter_state(game_state: &mut Value) {
clear_encounter_only(game_state);
write_bool_field(game_state, "inBattle", false);
write_bool_field(game_state, "npcInteractionActive", false);
write_null_field(game_state, "currentNpcBattleMode");
}
pub fn clear_encounter_only(game_state: &mut Value) {
write_null_field(game_state, "currentEncounter");
let root = ensure_json_object(game_state);
root.insert("sceneHostileNpcs".to_string(), Value::Array(Vec::new()));
}
pub fn append_story_history(game_state: &mut Value, action_text: &str, result_text: &str) {
let root = ensure_json_object(game_state);
let story_history = root
.entry("storyHistory".to_string())
.or_insert_with(|| Value::Array(Vec::new()));
if !story_history.is_array() {
*story_history = Value::Array(Vec::new());
}
let entries = story_history
.as_array_mut()
.expect("storyHistory should be array");
entries.push(json!({
"text": action_text,
"historyRole": "action",
}));
entries.push(json!({
"text": result_text,
"historyRole": "result",
}));
}
pub fn increment_runtime_stat(game_state: &mut Value, key: &str, delta: i32) {
let root = ensure_json_object(game_state);
let stats = root
.entry("runtimeStats".to_string())
.or_insert_with(|| Value::Object(Map::new()));
if !stats.is_object() {
*stats = Value::Object(Map::new());
}
let stats = stats
.as_object_mut()
.expect("runtimeStats should be object");
let previous = stats
.get(key)
.and_then(Value::as_i64)
.and_then(|value| i32::try_from(value).ok())
.unwrap_or(0);
stats.insert(key.to_string(), json!((previous + delta).max(0)));
}
pub fn add_player_currency(game_state: &mut Value, delta: i32) {
let previous = read_i32_field(game_state, "playerCurrency").unwrap_or(0);
write_i32_field(
game_state,
"playerCurrency",
previous.saturating_add(delta.max(0)),
);
}
pub fn add_player_inventory_items(game_state: &mut Value, additions: Vec<Value>) {
if additions.is_empty() {
return;
}
let root = ensure_json_object(game_state);
let inventory = root
.entry("playerInventory".to_string())
.or_insert_with(|| Value::Array(Vec::new()));
if !inventory.is_array() {
*inventory = Value::Array(Vec::new());
}
let items = inventory
.as_array_mut()
.expect("playerInventory should be array");
items.extend(additions);
}
pub fn grant_player_progression_experience(game_state: &mut Value, amount: i32, source: &str) {
if amount <= 0 {
return;
}
let root = ensure_json_object(game_state);
let progression = root
.entry("playerProgression".to_string())
.or_insert_with(|| Value::Object(Map::new()));
if !progression.is_object() {
*progression = Value::Object(Map::new());
}
let progression = progression
.as_object_mut()
.expect("playerProgression should be object");
let previous_total_xp = progression
.get("totalXp")
.and_then(Value::as_i64)
.and_then(|value| i32::try_from(value).ok())
.unwrap_or(0)
.max(0);
let next_total_xp = previous_total_xp.saturating_add(amount);
let level = resolve_progression_level(next_total_xp);
let current_level_xp = next_total_xp.saturating_sub(cumulative_xp_required(level));
let xp_to_next_level = if level >= MAX_PLAYER_LEVEL {
0
} else {
xp_to_next_level_for(level)
};
progression.insert("level".to_string(), json!(level));
progression.insert("currentLevelXp".to_string(), json!(current_level_xp.max(0)));
progression.insert("totalXp".to_string(), json!(next_total_xp));
progression.insert("xpToNextLevel".to_string(), json!(xp_to_next_level.max(0)));
progression.insert("pendingLevelUps".to_string(), json!(0));
progression.insert(
"lastGrantedSource".to_string(),
Value::String(source.to_string()),
);
}
pub const MAX_PLAYER_LEVEL: i32 = 20;
pub fn xp_to_next_level_for(level: i32) -> i32 {
if level >= MAX_PLAYER_LEVEL {
0
} else {
let scale = (level - 1).max(0);
60 + 20 * scale + 8 * scale * scale
}
}
pub fn cumulative_xp_required(level: i32) -> i32 {
let mut total = 0;
let capped_level = level.clamp(1, MAX_PLAYER_LEVEL);
for current_level in 1..capped_level {
total += xp_to_next_level_for(current_level);
}
total
}
pub fn resolve_progression_level(total_xp: i32) -> i32 {
let normalized_total_xp = total_xp.max(0);
let mut resolved_level = 1;
for level in 2..=MAX_PLAYER_LEVEL {
if normalized_total_xp < cumulative_xp_required(level) {
break;
}
resolved_level = level;
}
resolved_level
}
pub fn append_active_build_buffs(game_state: &mut Value, additions: Vec<Value>) {
if additions.is_empty() {
return;
}
let root = ensure_json_object(game_state);
let buffs = root
.entry("activeBuildBuffs".to_string())
.or_insert_with(|| Value::Array(Vec::new()));
if !buffs.is_array() {
*buffs = Value::Array(Vec::new());
}
buffs
.as_array_mut()
.expect("activeBuildBuffs should be array")
.extend(additions);
}
pub fn remove_player_inventory_item(game_state: &mut Value, item_id: &str, quantity: i32) {
if quantity <= 0 {
return;
}
let root = ensure_json_object(game_state);
let inventory = root
.entry("playerInventory".to_string())
.or_insert_with(|| Value::Array(Vec::new()));
if !inventory.is_array() {
*inventory = Value::Array(Vec::new());
}
let items = inventory
.as_array_mut()
.expect("playerInventory should be array");
let Some(index) = items
.iter()
.position(|entry| read_optional_string_field(entry, "id").as_deref() == Some(item_id))
else {
return;
};
let current_quantity = read_i32_field(&items[index], "quantity")
.unwrap_or(0)
.max(0);
let next_quantity = current_quantity - quantity;
if next_quantity <= 0 {
items.remove(index);
return;
}
if let Some(item) = items[index].as_object_mut() {
item.insert("quantity".to_string(), json!(next_quantity));
}
}
pub fn write_current_encounter_i32_field(game_state: &mut Value, key: &str, value: i32) {
let root = ensure_json_object(game_state);
let Some(encounter) = root.get_mut("currentEncounter") else {
return;
};
if let Some(encounter) = encounter.as_object_mut() {
encounter.insert(key.to_string(), json!(value));
}
}
pub fn write_first_hostile_npc_i32_field(game_state: &mut Value, key: &str, value: i32) {
let root = ensure_json_object(game_state);
let Some(hostiles) = root.get_mut("sceneHostileNpcs") else {
return;
};
let Some(first) = hostiles.as_array_mut().and_then(|items| items.first_mut()) else {
return;
};
if let Some(first) = first.as_object_mut() {
first.insert(key.to_string(), json!(value));
}
}
pub fn first_hostile_npc_string_field(game_state: &Value, key: &str) -> Option<String> {
read_array_field(game_state, "sceneHostileNpcs")
.first()
.and_then(|target| read_optional_string_field(target, key))
}
pub fn read_runtime_session_id(game_state: &Value) -> Option<String> {
read_optional_string_field(game_state, "runtimeSessionId")
}
pub fn read_field<'a>(value: &'a Value, key: &str) -> Option<&'a Value> {
value.as_object()?.get(key)
}
pub fn read_object_field<'a>(value: &'a Value, key: &str) -> Option<&'a Value> {
let field = read_field(value, key)?;
field.is_object().then_some(field)
}
pub fn read_array_field<'a>(value: &'a Value, key: &str) -> Vec<&'a Value> {
read_field(value, key)
.and_then(Value::as_array)
.map(|items| items.iter().collect())
.unwrap_or_default()
}
pub fn read_required_string_field(value: &Value, key: &str) -> Option<String> {
normalize_required_string(read_field(value, key)?.as_str()?)
}
pub fn read_optional_string_field(value: &Value, key: &str) -> Option<String> {
normalize_optional_string(read_field(value, key).and_then(Value::as_str))
}
pub fn read_bool_field(value: &Value, key: &str) -> Option<bool> {
read_field(value, key).and_then(Value::as_bool)
}
pub fn read_i32_field(value: &Value, key: &str) -> Option<i32> {
read_field(value, key)
.and_then(Value::as_i64)
.and_then(|number| i32::try_from(number).ok())
}
pub fn read_u32_field(value: &Value, key: &str) -> Option<u32> {
read_field(value, key)
.and_then(Value::as_u64)
.and_then(|number| u32::try_from(number).ok())
}
pub fn write_i32_field(value: &mut Value, key: &str, field_value: i32) {
ensure_json_object(value).insert(key.to_string(), json!(field_value));
}
pub fn write_u32_field(value: &mut Value, key: &str, field_value: u32) {
ensure_json_object(value).insert(key.to_string(), json!(field_value));
}
pub fn write_bool_field(value: &mut Value, key: &str, field_value: bool) {
ensure_json_object(value).insert(key.to_string(), Value::Bool(field_value));
}
pub fn write_string_field(value: &mut Value, key: &str, field_value: &str) {
ensure_json_object(value).insert(key.to_string(), Value::String(field_value.to_string()));
}
pub fn write_null_field(value: &mut Value, key: &str) {
ensure_json_object(value).insert(key.to_string(), Value::Null);
}
pub fn ensure_json_object(value: &mut Value) -> &mut Map<String, Value> {
if !value.is_object() {
*value = Value::Object(Map::new());
}
value.as_object_mut().expect("value should be object")
}
pub fn normalize_required_string(value: &str) -> Option<String> {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
pub fn normalize_optional_string(value: Option<&str>) -> Option<String> {
value.and_then(normalize_required_string)
}
pub fn format_now_rfc3339() -> String {
format_rfc3339(OffsetDateTime::now_utc()).unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
}

View File

@@ -0,0 +1,4 @@
//! runtime story 兼容领域模型过渡落位。
//!
//! 当前 crate 用于旧运行时剧情桥的纯规则兼容。后续迁移时仍只能保留 JSON 规则、
//! 选项生成和视图模型转换,不引入 Axum、LLM 或 SpacetimeDB。

View File

@@ -0,0 +1,3 @@
//! runtime story 兼容领域错误过渡落位。
//!
//! 错误只表达兼容规则失败,不能直接绑定 HTTP 或数据库错误模型。

View File

@@ -0,0 +1,3 @@
//! runtime story 兼容领域事件过渡落位。
//!
//! 用于表达旧剧情快照变化、战斗表现变化和物品/成长待同步等事实。

View File

@@ -0,0 +1,597 @@
use serde_json::{Value, json};
use crate::{
equipment_slot_label, item_rarity_key, read_array_field, read_field, read_i32_field,
read_inventory_item_name, read_optional_string_field, read_u32_field,
remove_inventory_item_from_list, resolve_equipment_slot_for_item,
};
/// 这批定义只服务 runtime story compat 的确定性锻造链。
///
/// 当前仍然保持旧快照态结算口径,不引入 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>,
}
pub(crate) struct ReforgeCostDefinition {
pub(crate) currency_cost: i32,
pub(crate) requirements: Vec<ForgeRequirementDefinition>,
}
pub(crate) fn forge_recipe_definition(recipe_id: &str) -> Option<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,
}],
},
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("突进精粹"),
},
],
},
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 {
if slot_id == Some("relic") {
return ReforgeCostDefinition {
currency_cost: 52,
requirements: vec![ForgeRequirementDefinition {
id: "name:凝光纱",
label: "凝光纱",
quantity: 1,
matcher: ForgeRequirementMatcher::Named("凝光纱"),
}],
};
}
ReforgeCostDefinition {
currency_cost: 46,
requirements: vec![ForgeRequirementDefinition {
id: "name:精炼锭材",
label: "精炼锭材",
quantity: 1,
matcher: ForgeRequirementMatcher::Named("精炼锭材"),
}],
}
}
fn forge_requirement_matches(item: &Value, requirement: &ForgeRequirementDefinition) -> bool {
match requirement.matcher {
ForgeRequirementMatcher::Named(name) => {
read_optional_string_field(item, "name").as_deref() == Some(name)
}
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],
) -> Option<Vec<Value>> {
let mut next_inventory = inventory.to_vec();
for requirement in requirements {
let mut remaining = requirement.quantity.max(0);
let snapshot = next_inventory.clone();
for item in snapshot {
if remaining <= 0 {
break;
}
if !forge_requirement_matches(&item, requirement) {
continue;
}
let item_id = read_optional_string_field(&item, "id")?;
let item_quantity = read_i32_field(&item, "quantity").unwrap_or(0).max(0);
let consumed = remaining.min(item_quantity);
next_inventory =
remove_inventory_item_from_list(next_inventory, item_id.as_str(), consumed);
remaining -= consumed;
}
if remaining > 0 {
return None;
}
}
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,
quantity: i32,
tags: &[&str],
rarity: &str,
) -> Value {
let mut all_tags = vec!["material".to_string()];
all_tags.extend(tags.iter().map(|tag| (*tag).to_string()));
json!({
"id": generate_runtime_item_id(game_state, format!("forge-material:{name}").as_str()),
"category": "材料",
"name": name,
"quantity": quantity.max(1),
"rarity": rarity,
"tags": all_tags,
"buildProfile": {
"role": "工巧",
"tags": tags,
"synergy": tags,
"forgeRank": 0
}
})
}
pub fn build_runtime_equipment_item(
game_state: &Value,
name: &str,
slot_id: &str,
rarity: &str,
description: &str,
role: &str,
tags: &[&str],
synergy: &[&str],
stat_profile: Value,
) -> Value {
let slot_tag = match slot_id {
"weapon" => "weapon",
"armor" => "armor",
_ => "relic",
};
let mut next_tags = vec![slot_tag.to_string()];
next_tags.extend(tags.iter().map(|tag| (*tag).to_string()));
json!({
"id": generate_runtime_item_id(game_state, format!("forge-equip:{name}").as_str()),
"category": equipment_slot_label(slot_id),
"name": name,
"description": description,
"quantity": 1,
"rarity": rarity,
"tags": next_tags,
"equipmentSlotId": slot_id,
"statProfile": stat_profile,
"buildProfile": {
"role": role,
"tags": tags,
"synergy": synergy,
"forgeRank": 1
}
})
}
pub(crate) fn build_forge_recipe_result_item(
game_state: &Value,
recipe_id: &str,
_world_type: Option<&str>,
) -> Value {
match recipe_id {
"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,
"百炼追风剑",
"weapon",
"epic",
"为快剑与追身构筑准备的锻造兵刃。",
"快剑",
&["快剑", "突进", "追击"],
&["快剑", "突进", "追击"],
json!({
"maxManaBonus": 10,
"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"),
}
}
fn build_tag_essence_item(game_state: &Value, tag: &str) -> Value {
build_runtime_material_item(
game_state,
format!("{tag}精粹").as_str(),
1,
&[tag, "工巧"],
"rare",
)
}
pub(crate) fn build_dismantle_outputs(game_state: &Value, item: &Value) -> Option<Vec<Value>> {
let slot_id = resolve_equipment_slot_for_item(item);
if slot_id.is_none() && read_field(item, "buildProfile").is_none() {
return None;
}
let rarity_scale = match item_rarity_key(item).as_str() {
"legendary" => 5,
"epic" => 4,
"rare" => 3,
"uncommon" => 2,
_ => 1,
};
let mut outputs = Vec::new();
match slot_id {
Some("weapon") => outputs.push(build_runtime_material_item(
game_state,
"武器残片",
rarity_scale,
&["工巧", "重击"],
"uncommon",
)),
Some("armor") => outputs.push(build_runtime_material_item(
game_state,
"甲片",
rarity_scale,
&["工巧", "守御"],
"uncommon",
)),
Some("relic") => outputs.push(build_runtime_material_item(
game_state,
"灵饰碎片",
rarity_scale,
&["工巧", "法力"],
"uncommon",
)),
_ => outputs.push(build_runtime_material_item(
game_state,
"零散材料",
((rarity_scale + 1) / 2).max(1),
&["工巧"],
"uncommon",
)),
}
let mut build_tags = read_field(item, "buildProfile")
.map(|profile| {
let mut tags = read_array_field(profile, "tags")
.into_iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect::<Vec<_>>();
if let Some(role) = read_optional_string_field(profile, "role") {
tags.push(role);
}
tags
})
.unwrap_or_default();
build_tags.sort();
build_tags.dedup();
let tag_limit = if item_rarity_key(item) == "legendary" {
3
} else {
2
};
for tag in build_tags.into_iter().take(tag_limit) {
outputs.push(build_tag_essence_item(game_state, tag.as_str()));
}
Some(outputs)
}
pub(crate) fn build_reforged_item(game_state: &Value, item: &Value) -> Option<Value> {
let slot_id = resolve_equipment_slot_for_item(item)?;
let build_profile = read_field(item, "buildProfile")?;
let mut next_tags = read_array_field(build_profile, "tags")
.into_iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect::<Vec<_>>();
let extra_tag = match slot_id {
"weapon" => "追击",
"armor" => "护体",
_ => "法力",
};
next_tags.push(extra_tag.to_string());
next_tags.sort();
next_tags.dedup();
next_tags.truncate(3);
let source_name = read_inventory_item_name(item);
let next_name = if source_name.contains('·') && source_name.contains("重铸") {
source_name.clone()
} else {
format!("{source_name}·重铸")
};
let stat_profile = read_field(item, "statProfile");
let outgoing_damage_bonus = stat_profile
.and_then(|profile| read_field(profile, "outgoingDamageBonus"))
.and_then(Value::as_f64)
.unwrap_or(0.0);
let incoming_damage_multiplier = stat_profile
.and_then(|profile| read_field(profile, "incomingDamageMultiplier"))
.and_then(Value::as_f64);
let current_forge_rank = read_i32_field(build_profile, "forgeRank").unwrap_or(0);
let mut tags = read_array_field(item, "tags")
.into_iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect::<Vec<_>>();
tags.sort();
tags.dedup();
Some(json!({
"id": generate_runtime_item_id(game_state, format!("reforge:{source_name}").as_str()),
"category": read_optional_string_field(item, "category")
.unwrap_or_else(|| equipment_slot_label(slot_id).to_string()),
"name": next_name,
"description": read_optional_string_field(item, "description"),
"quantity": 1,
"rarity": item_rarity_key(item),
"tags": tags,
"equipmentSlotId": slot_id,
"statProfile": {
"maxHpBonus": stat_profile
.and_then(|profile| read_i32_field(profile, "maxHpBonus"))
.unwrap_or(0) + if slot_id == "armor" { 10 } else { 4 },
"maxManaBonus": stat_profile
.and_then(|profile| read_i32_field(profile, "maxManaBonus"))
.unwrap_or(0) + if slot_id == "relic" { 10 } else { 4 },
"outgoingDamageBonus": ((outgoing_damage_bonus + 0.03) * 1000.0).round() / 1000.0,
"incomingDamageMultiplier": if let Some(multiplier) = incoming_damage_multiplier {
(((multiplier - 0.03).max(0.72)) * 1000.0).round() / 1000.0
} else if slot_id == "armor" {
0.94
} else {
0.97
}
},
"buildProfile": {
"role": read_optional_string_field(build_profile, "role"),
"tags": next_tags,
"synergy": read_array_field(build_profile, "tags")
.into_iter()
.filter_map(Value::as_str)
.map(str::to_string)
.chain(std::iter::once(extra_tag.to_string()))
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>(),
"forgeRank": current_forge_rank + 1
}
}))
}
pub(crate) fn build_forge_success_text(
action: &str,
recipe_name: Option<&str>,
source_item_name: Option<&str>,
created_item_name: Option<&str>,
output_names: &[String],
currency_text: Option<String>,
) -> String {
match action {
"craft" => format!(
"你在工坊中完成了{},获得了{}{}",
recipe_name.unwrap_or("目标配方"),
created_item_name.unwrap_or("目标物品"),
currency_text
.map(|text| format!(",并支付了{text}"))
.unwrap_or_default()
),
"reforge" => format!(
"你消耗材料重新淬炼了{},最终得到{}{}",
source_item_name.unwrap_or("目标物品"),
created_item_name.unwrap_or("重铸产物"),
currency_text
.map(|text| format!(",并支付了{text}"))
.unwrap_or_default()
),
_ => format!(
"你拆解了{},回收出{}",
source_item_name.unwrap_or("目标物品"),
output_names.join("")
),
}
}
pub fn format_currency_text(value: i32, world_type: Option<&str>) -> String {
let currency_name = match world_type {
Some("XIANXIA") => "灵石",
Some("WUXIA") => "铜钱",
_ => "钱币",
};
format!("{value} {currency_name}")
}
fn generate_runtime_item_id(game_state: &Value, prefix: &str) -> String {
let version = read_u32_field(game_state, "runtimeActionVersion").unwrap_or(0);
let inventory_len = read_array_field(game_state, "playerInventory").len();
format!("{prefix}:{version}:{inventory_len}")
}

View File

@@ -0,0 +1,220 @@
use serde_json::Value;
use shared_contracts::runtime_story::RuntimeStoryActionRequest;
use crate::{
StoryResolution, add_inventory_items_to_list, build_current_build_toast, current_world_type,
ensure_inventory_action_available, find_player_inventory_entry, read_i32_field,
read_inventory_item_name, read_optional_string_field, read_player_inventory_values,
remove_inventory_item_from_list, resolve_action_text, resolve_equipment_slot_for_item,
write_i32_field, write_player_inventory_values,
};
use super::forge::{
apply_forge_requirements_if_possible, build_dismantle_outputs, build_forge_recipe_result_item,
build_forge_success_text, build_reforged_item, forge_recipe_definition, format_currency_text,
reforge_cost_definition,
};
/// 锻造动作编排已经不再依赖 `api-server` 的 HTTP 边界。
///
/// 这里继续沿用 compat 快照态结算,后续可直接被 `api-server` 外壳或真相态桥接层复用。
pub fn resolve_forge_craft_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
ensure_inventory_action_available(
game_state,
"缺少玩家角色,无法执行锻造配方。",
"战斗中无法使用工坊。",
)?;
let recipe_id = request
.action
.payload
.as_ref()
.and_then(|payload| read_optional_string_field(payload, "recipeId"))
.or_else(|| request.action.target_id.clone())
.ok_or_else(|| "forge_craft 缺少 recipeId".to_string())?;
let recipe = forge_recipe_definition(recipe_id.as_str())
.ok_or_else(|| "未找到目标锻造配方。".to_string())?;
let player_currency = read_i32_field(game_state, "playerCurrency").unwrap_or(0);
if player_currency < recipe.currency_cost {
return Err(format!("{} 当前材料或货币不足。", recipe.name));
}
let current_inventory = read_player_inventory_values(game_state);
let consumed_inventory = apply_forge_requirements_if_possible(
current_inventory.as_slice(),
recipe.requirements.as_slice(),
)
.ok_or_else(|| format!("{} 当前材料或货币不足。", recipe.name))?;
let created_item = build_forge_recipe_result_item(
game_state,
recipe.id,
current_world_type(game_state).as_deref(),
);
let next_inventory =
add_inventory_items_to_list(consumed_inventory, vec![created_item.clone()]);
write_i32_field(
game_state,
"playerCurrency",
player_currency.saturating_sub(recipe.currency_cost),
);
write_player_inventory_values(game_state, next_inventory);
Ok(StoryResolution {
action_text: resolve_action_text(
&format!("制作{}", read_inventory_item_name(&created_item)),
request,
),
result_text: build_forge_success_text(
"craft",
Some(recipe.name),
None,
Some(read_inventory_item_name(&created_item).as_str()),
&[],
Some(format_currency_text(
recipe.currency_cost,
current_world_type(game_state).as_deref(),
)),
),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: Vec::new(),
battle: None,
toast: Some(build_current_build_toast(game_state)),
})
}
pub fn resolve_forge_dismantle_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
ensure_inventory_action_available(
game_state,
"缺少玩家角色,无法执行拆解。",
"战斗中无法执行拆解。",
)?;
let item_id = request
.action
.payload
.as_ref()
.and_then(|payload| read_optional_string_field(payload, "itemId"))
.or_else(|| request.action.target_id.clone())
.ok_or_else(|| "forge_dismantle 缺少 itemId".to_string())?;
let item = find_player_inventory_entry(game_state, item_id.as_str())
.cloned()
.ok_or_else(|| "未找到可拆解的物品。".to_string())?;
if read_i32_field(&item, "quantity").unwrap_or(0) <= 0 {
return Err("未找到可拆解的物品。".to_string());
}
let outputs = build_dismantle_outputs(game_state, &item)
.ok_or_else(|| format!("{} 当前不支持拆解。", read_inventory_item_name(&item)))?;
let mut next_inventory = read_player_inventory_values(game_state);
next_inventory = remove_inventory_item_from_list(next_inventory, item_id.as_str(), 1);
next_inventory = add_inventory_items_to_list(next_inventory, outputs.clone());
write_player_inventory_values(game_state, next_inventory);
let output_names = outputs
.iter()
.map(read_inventory_item_name)
.collect::<Vec<_>>();
Ok(StoryResolution {
action_text: resolve_action_text(
&format!("拆解{}", read_inventory_item_name(&item)),
request,
),
result_text: build_forge_success_text(
"dismantle",
None,
Some(read_inventory_item_name(&item).as_str()),
None,
output_names.as_slice(),
None,
),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: Vec::new(),
battle: None,
toast: Some(build_current_build_toast(game_state)),
})
}
pub fn resolve_forge_reforge_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
ensure_inventory_action_available(
game_state,
"缺少玩家角色,无法执行重铸。",
"战斗中无法执行重铸。",
)?;
let item_id = request
.action
.payload
.as_ref()
.and_then(|payload| read_optional_string_field(payload, "itemId"))
.or_else(|| request.action.target_id.clone())
.ok_or_else(|| "forge_reforge 缺少 itemId".to_string())?;
let item = find_player_inventory_entry(game_state, item_id.as_str())
.cloned()
.ok_or_else(|| "未找到可重铸的物品。".to_string())?;
if read_i32_field(&item, "quantity").unwrap_or(0) <= 0 {
return Err("未找到可重铸的物品。".to_string());
}
let slot_id = resolve_equipment_slot_for_item(&item);
let reforge_cost = reforge_cost_definition(slot_id);
let player_currency = read_i32_field(game_state, "playerCurrency").unwrap_or(0);
if player_currency < reforge_cost.currency_cost {
return Err(format!(
"{} 当前不满足重铸条件。",
read_inventory_item_name(&item)
));
}
let reforged_item = build_reforged_item(game_state, &item)
.ok_or_else(|| format!("{} 当前不满足重铸条件。", read_inventory_item_name(&item)))?;
let base_inventory = remove_inventory_item_from_list(
read_player_inventory_values(game_state),
item_id.as_str(),
1,
);
let consumed_inventory = apply_forge_requirements_if_possible(
base_inventory.as_slice(),
reforge_cost.requirements.as_slice(),
)
.ok_or_else(|| format!("{} 当前不满足重铸条件。", read_inventory_item_name(&item)))?;
let next_inventory =
add_inventory_items_to_list(consumed_inventory, vec![reforged_item.clone()]);
write_player_inventory_values(game_state, next_inventory);
write_i32_field(
game_state,
"playerCurrency",
player_currency.saturating_sub(reforge_cost.currency_cost),
);
Ok(StoryResolution {
action_text: resolve_action_text(
&format!("重铸{}", read_inventory_item_name(&item)),
request,
),
result_text: build_forge_success_text(
"reforge",
None,
Some(read_inventory_item_name(&item).as_str()),
Some(read_inventory_item_name(&reforged_item).as_str()),
&[],
Some(format_currency_text(
reforge_cost.currency_cost,
current_world_type(game_state).as_deref(),
)),
),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: Vec::new(),
battle: None,
toast: Some(build_current_build_toast(game_state)),
})
}

View File

@@ -0,0 +1,417 @@
use serde_json::{Map, Value, json};
use crate::{
ensure_json_object, first_hostile_npc_string_field, read_array_field, read_bool_field,
read_field, read_i32_field, read_object_field, read_optional_string_field, write_i32_field,
};
/// 这批 helper 只负责 runtime story compat 的纯快照读写。
///
/// 目标是先把 encounter / inventory / equipment 的基础状态工具从 `api-server`
/// 边界模块里收口出来,后续 battle / forge / equipment 规则迁移时直接复用。
pub fn ensure_inventory_action_available(
game_state: &Value,
missing_character_message: &str,
battle_locked_message: &str,
) -> Result<(), String> {
if read_field(game_state, "playerCharacter").is_none() {
return Err(missing_character_message.to_string());
}
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
return Err(battle_locked_message.to_string());
}
Ok(())
}
pub fn battle_mode_text(value: &str) -> &'static str {
if value == "spar" { "切磋" } else { "战斗" }
}
pub fn current_encounter_name(game_state: &Value) -> String {
read_object_field(game_state, "currentEncounter")
.and_then(|encounter| {
read_optional_string_field(encounter, "npcName")
.or_else(|| read_optional_string_field(encounter, "name"))
})
.unwrap_or_else(|| "对方".to_string())
}
pub fn current_encounter_name_from_battle(game_state: &Value) -> String {
read_object_field(game_state, "currentEncounter")
.and_then(|encounter| {
read_optional_string_field(encounter, "npcName")
.or_else(|| read_optional_string_field(encounter, "name"))
})
.or_else(|| first_hostile_npc_string_field(game_state, "name"))
.unwrap_or_else(|| "眼前的敌人".to_string())
}
pub fn current_encounter_id(game_state: &Value) -> Option<String> {
read_object_field(game_state, "currentEncounter")
.and_then(|encounter| read_optional_string_field(encounter, "id"))
}
pub fn find_player_inventory_entry<'a>(game_state: &'a Value, item_id: &str) -> Option<&'a Value> {
read_array_field(game_state, "playerInventory")
.into_iter()
.find(|entry| read_optional_string_field(entry, "id").as_deref() == Some(item_id))
}
pub fn read_player_inventory_values(game_state: &Value) -> Vec<Value> {
read_array_field(game_state, "playerInventory")
.into_iter()
.cloned()
.collect()
}
pub fn write_player_inventory_values(game_state: &mut Value, items: Vec<Value>) {
ensure_json_object(game_state).insert("playerInventory".to_string(), Value::Array(items));
}
pub fn read_inventory_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 has_giftable_player_inventory(game_state: &Value) -> bool {
read_array_field(game_state, "playerInventory")
.into_iter()
.any(|item| read_i32_field(item, "quantity").unwrap_or(0) > 0)
}
pub fn clone_inventory_item_with_quantity(item: &Value, quantity: i32) -> Value {
let mut next_item = item.clone();
if let Some(entry) = next_item.as_object_mut() {
entry.insert("quantity".to_string(), json!(quantity.max(1)));
}
next_item
}
pub fn normalize_equipped_item(item: &Value) -> Value {
clone_inventory_item_with_quantity(item, 1)
}
pub fn add_inventory_items_to_list(mut base: Vec<Value>, additions: Vec<Value>) -> Vec<Value> {
for addition in additions {
let add_id = read_optional_string_field(&addition, "id");
let add_quantity = read_i32_field(&addition, "quantity").unwrap_or(1).max(1);
if let Some(add_id) = add_id {
if let Some(existing) = base.iter_mut().find(|item| {
read_optional_string_field(item, "id").as_deref() == Some(add_id.as_str())
}) {
let next_quantity =
read_i32_field(existing, "quantity").unwrap_or(0).max(0) + add_quantity;
if let Some(existing_object) = existing.as_object_mut() {
existing_object.insert("quantity".to_string(), json!(next_quantity));
}
continue;
}
}
base.push(addition);
}
base
}
pub fn remove_inventory_item_from_list(
mut base: Vec<Value>,
item_id: &str,
quantity: i32,
) -> Vec<Value> {
if quantity <= 0 {
return base;
}
let Some(index) = base
.iter()
.position(|entry| read_optional_string_field(entry, "id").as_deref() == Some(item_id))
else {
return base;
};
let current_quantity = read_i32_field(&base[index], "quantity").unwrap_or(0).max(0);
let next_quantity = current_quantity - quantity;
if next_quantity <= 0 {
base.remove(index);
return base;
}
if let Some(item) = base[index].as_object_mut() {
item.insert("quantity".to_string(), json!(next_quantity));
}
base
}
pub fn read_player_equipment_item(game_state: &Value, slot_id: &str) -> Option<Value> {
read_field(game_state, "playerEquipment")
.and_then(|equipment| read_field(equipment, slot_id))
.filter(|item| !item.is_null())
.cloned()
}
pub fn write_player_equipment_item(game_state: &mut Value, slot_id: &str, item: Option<Value>) {
let root = ensure_json_object(game_state);
let equipment = root
.entry("playerEquipment".to_string())
.or_insert_with(|| {
json!({
"weapon": null,
"armor": null,
"relic": null,
})
});
if !equipment.is_object() {
*equipment = json!({
"weapon": null,
"armor": null,
"relic": null,
});
}
equipment
.as_object_mut()
.expect("playerEquipment should be object")
.insert(slot_id.to_string(), item.unwrap_or(Value::Null));
}
pub fn equipment_slot_label(slot_id: &str) -> &'static str {
match slot_id {
"weapon" => "武器",
"armor" => "护甲",
"relic" => "饰品",
_ => "装备",
}
}
pub fn normalize_equipment_slot_id(slot_id: &str) -> Option<&'static str> {
let normalized = slot_id.trim().to_ascii_lowercase();
match normalized.as_str() {
"weapon" => Some("weapon"),
"armor" => Some("armor"),
"relic" | "accessory" => Some("relic"),
_ => {
// 兼容旧 payload 里直接传中文槽位名或物品类别文案的情况。
if slot_id.contains("武器")
|| slot_id.contains('剑')
|| slot_id.contains('弓')
|| slot_id.contains('刀')
|| slot_id.contains("拳套")
|| slot_id.contains("战刃")
|| slot_id.contains('枪')
|| slot_id.contains('刃')
{
return Some("weapon");
}
if slot_id.contains("护甲")
|| slot_id.contains('甲')
|| slot_id.contains("护臂")
|| slot_id.contains('衣')
|| slot_id.contains('袍')
|| slot_id.contains('铠')
{
return Some("armor");
}
if slot_id.contains("饰品")
|| slot_id.contains("护符")
|| slot_id.contains("徽章")
|| slot_id.contains('玉')
|| slot_id.contains('珠')
|| slot_id.contains('坠')
|| slot_id.contains('铃')
|| slot_id.contains('盘')
|| slot_id.contains('令')
|| slot_id.contains('匣')
{
return Some("relic");
}
None
}
}
}
pub fn resolve_equipment_slot_for_item(item: &Value) -> Option<&'static str> {
if let Some(slot_id) = read_optional_string_field(item, "equipmentSlotId") {
return match slot_id.as_str() {
"weapon" => Some("weapon"),
"armor" => Some("armor"),
"relic" => Some("relic"),
_ => None,
};
}
let tags = read_array_field(item, "tags")
.into_iter()
.filter_map(|tag| tag.as_str().map(|value| value.to_string()))
.collect::<Vec<_>>();
if tags.iter().any(|tag| tag == "weapon") {
return Some("weapon");
}
if tags.iter().any(|tag| tag == "armor") {
return Some("armor");
}
if tags.iter().any(|tag| tag == "relic") {
return Some("relic");
}
let category_text = read_optional_string_field(item, "category").unwrap_or_default();
let name_text = read_inventory_item_name(item);
let mixed_text = format!("{category_text} {name_text}");
if mixed_text.contains("武器") || mixed_text.contains("") || mixed_text.contains("") {
return Some("weapon");
}
if mixed_text.contains("护甲") || mixed_text.contains("") || mixed_text.contains("") {
return Some("armor");
}
if mixed_text.contains("饰品") || mixed_text.contains("护符") || mixed_text.contains("")
{
return Some("relic");
}
None
}
pub fn item_rarity_key(item: &Value) -> String {
read_optional_string_field(item, "rarity").unwrap_or_else(|| "common".to_string())
}
pub fn equipment_bonus_fallbacks(slot_id: &str, rarity: &str) -> (i32, i32, f64, f64) {
match slot_id {
"weapon" => {
let outgoing = match rarity {
"uncommon" => 0.10,
"rare" => 0.14,
"epic" => 0.20,
"legendary" => 0.28,
_ => 0.06,
};
(0, 0, outgoing, 1.0)
}
"armor" => {
let hp = match rarity {
"uncommon" => 22,
"rare" => 32,
"epic" => 44,
"legendary" => 58,
_ => 14,
};
let incoming = match rarity {
"uncommon" => 0.94,
"rare" => 0.90,
"epic" => 0.86,
"legendary" => 0.80,
_ => 0.97,
};
(hp, 0, 0.0, incoming)
}
_ => {
let mana = match rarity {
"uncommon" => 18,
"rare" => 28,
"epic" => 40,
"legendary" => 54,
_ => 10,
};
let outgoing = match rarity {
"uncommon" => 0.04,
"rare" => 0.06,
"epic" => 0.09,
"legendary" => 0.12,
_ => 0.02,
};
(0, mana, outgoing, 1.0)
}
}
}
pub fn equipment_item_bonuses(item: &Value, slot_id: &str) -> (i32, i32, f64, f64) {
let rarity = item_rarity_key(item);
let fallback = equipment_bonus_fallbacks(slot_id, rarity.as_str());
let stat_profile = read_field(item, "statProfile");
let hp_bonus = stat_profile
.and_then(|profile| read_i32_field(profile, "maxHpBonus"))
.unwrap_or(fallback.0);
let mana_bonus = stat_profile
.and_then(|profile| read_i32_field(profile, "maxManaBonus"))
.unwrap_or(fallback.1);
let outgoing_bonus = stat_profile
.and_then(|profile| read_field(profile, "outgoingDamageBonus"))
.and_then(Value::as_f64)
.unwrap_or(fallback.2);
let incoming_multiplier = stat_profile
.and_then(|profile| read_field(profile, "incomingDamageMultiplier"))
.and_then(Value::as_f64)
.unwrap_or(fallback.3);
(hp_bonus, mana_bonus, outgoing_bonus, incoming_multiplier)
}
pub fn read_equipment_total_bonuses(game_state: &Value) -> (i32, i32, f64, f64) {
let equipment = read_field(game_state, "playerEquipment");
let mut hp_bonus = 0;
let mut mana_bonus = 0;
let mut outgoing_bonus = 0.0;
let mut incoming_multiplier = 1.0;
for slot_id in ["weapon", "armor", "relic"] {
let Some(item) = equipment.and_then(|value| read_field(value, slot_id)) else {
continue;
};
if item.is_null() {
continue;
}
let (slot_hp, slot_mana, slot_outgoing, slot_incoming) =
equipment_item_bonuses(item, slot_id);
hp_bonus += slot_hp;
mana_bonus += slot_mana;
outgoing_bonus += slot_outgoing;
incoming_multiplier *= slot_incoming;
}
(hp_bonus, mana_bonus, outgoing_bonus, incoming_multiplier)
}
pub fn apply_equipment_loadout_to_state(game_state: &mut Value) {
let (hp_bonus, mana_bonus, _outgoing_bonus, _incoming_multiplier) =
read_equipment_total_bonuses(game_state);
let current_max_hp = read_i32_field(game_state, "playerMaxHp")
.unwrap_or(1)
.max(1);
let current_max_mana = read_i32_field(game_state, "playerMaxMana")
.unwrap_or(1)
.max(1);
let current_hp = read_i32_field(game_state, "playerHp").unwrap_or(current_max_hp);
let base_max_hp = current_max_hp
.saturating_sub(read_runtime_equipment_bonus_cache(game_state, "maxHpBonus"))
.max(1);
let base_max_mana = current_max_mana
.saturating_sub(read_runtime_equipment_bonus_cache(
game_state,
"maxManaBonus",
))
.max(1);
let next_max_hp = base_max_hp.saturating_add(hp_bonus).max(1);
let next_max_mana = base_max_mana.saturating_add(mana_bonus).max(1);
write_i32_field(game_state, "playerMaxHp", next_max_hp);
write_i32_field(game_state, "playerHp", current_hp.min(next_max_hp));
write_i32_field(game_state, "playerMaxMana", next_max_mana);
write_i32_field(game_state, "playerMana", next_max_mana);
write_runtime_equipment_bonus_cache(game_state, "maxHpBonus", hp_bonus);
write_runtime_equipment_bonus_cache(game_state, "maxManaBonus", mana_bonus);
}
pub fn read_runtime_equipment_bonus_cache(game_state: &Value, key: &str) -> i32 {
read_field(game_state, "runtimeEquipmentBonusCache")
.and_then(|cache| read_i32_field(cache, key))
.unwrap_or(0)
}
pub fn write_runtime_equipment_bonus_cache(game_state: &mut Value, key: &str, value: i32) {
let root = ensure_json_object(game_state);
let cache = root
.entry("runtimeEquipmentBonusCache".to_string())
.or_insert_with(|| Value::Object(Map::new()));
if !cache.is_object() {
*cache = Value::Object(Map::new());
}
cache
.as_object_mut()
.expect("runtimeEquipmentBonusCache should be object")
.insert(key.to_string(), json!(value));
}
pub fn build_current_build_toast(game_state: &Value) -> String {
let (_hp_bonus, _mana_bonus, outgoing_bonus, _incoming_multiplier) =
read_equipment_total_bonuses(game_state);
let build_multiplier = (1.0 + outgoing_bonus).max(1.0);
format!("当前 Build 倍率 x{build_multiplier:.2}")
}

View File

@@ -0,0 +1,170 @@
mod application;
mod commands;
mod domain;
mod errors;
mod events;
use serde_json::Value;
use shared_contracts::runtime_story::{
RuntimeBattlePresentation, RuntimeStoryActionRequest, RuntimeStoryOptionView,
RuntimeStoryPatch, RuntimeStorySnapshotPayload,
};
pub mod battle;
#[cfg(test)]
mod battle_tests;
pub mod core;
pub mod forge;
pub mod forge_actions;
pub mod game_state;
pub mod npc_support;
pub mod options;
pub mod post_battle;
pub mod projection;
pub mod prompt_context;
pub mod story_engine;
pub mod view_model;
pub use battle::{
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,
append_story_history, clear_encounter_only, clear_encounter_state, cumulative_xp_required,
ensure_json_object, first_hostile_npc_string_field, format_now_rfc3339,
grant_player_progression_experience, increment_runtime_stat, normalize_optional_string,
normalize_required_string, read_array_field, read_bool_field, read_field, read_i32_field,
read_object_field, read_optional_string_field, read_required_string_field,
read_runtime_session_id, read_u32_field, remove_player_inventory_item,
resolve_progression_level, write_bool_field, write_current_encounter_i32_field,
write_first_hostile_npc_i32_field, write_i32_field, write_null_field, write_string_field,
write_u32_field, xp_to_next_level_for,
};
pub use forge::{build_runtime_equipment_item, build_runtime_material_item, format_currency_text};
pub use forge_actions::{
resolve_forge_craft_action, resolve_forge_dismantle_action, resolve_forge_reforge_action,
};
pub use game_state::{
add_inventory_items_to_list, apply_equipment_loadout_to_state, battle_mode_text,
build_current_build_toast, clone_inventory_item_with_quantity, current_encounter_id,
current_encounter_name, current_encounter_name_from_battle, ensure_inventory_action_available,
equipment_bonus_fallbacks, equipment_item_bonuses, equipment_slot_label,
find_player_inventory_entry, has_giftable_player_inventory, item_rarity_key,
normalize_equipment_slot_id, normalize_equipped_item, read_equipment_total_bonuses,
read_inventory_item_name, read_player_equipment_item, read_player_inventory_values,
read_runtime_equipment_bonus_cache, remove_inventory_item_from_list,
resolve_equipment_slot_for_item, write_player_equipment_item, write_player_inventory_values,
write_runtime_equipment_bonus_cache,
};
pub use npc_support::{
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 projection::{StoryRuntimeProjectionSource, build_story_runtime_projection};
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,
};
pub const CONTINUE_ADVENTURE_FUNCTION_ID: &str = "story_continue_adventure";
pub const MAX_TASK5_COMPANIONS: usize = 2;
pub struct StoryResolution {
pub action_text: String,
pub result_text: String,
pub story_text: Option<String>,
pub presentation_options: Option<Vec<RuntimeStoryOptionView>>,
pub saved_current_story: Option<Value>,
pub patches: Vec<RuntimeStoryPatch>,
pub battle: Option<RuntimeBattlePresentation>,
pub toast: Option<String>,
}
pub struct GeneratedStoryPayload {
pub story_text: String,
pub history_result_text: String,
pub presentation_options: Vec<RuntimeStoryOptionView>,
pub saved_current_story: Value,
}
pub struct CurrentEncounterNpcQuestContext {
pub npc_id: String,
pub npc_name: String,
}
pub struct PendingQuestOfferContext {
pub dialogue: Vec<Value>,
pub turn_count: i32,
pub custom_input_placeholder: String,
pub quest: Value,
pub quest_id: String,
pub intro_text: Option<String>,
}
pub struct RuntimeStoryActionResponseParts {
pub requested_session_id: String,
pub server_version: u32,
pub snapshot: RuntimeStorySnapshotPayload,
pub action_text: String,
pub result_text: String,
pub story_text: String,
pub options: Vec<RuntimeStoryOptionView>,
pub patches: Vec<RuntimeStoryPatch>,
pub toast: Option<String>,
pub battle: Option<RuntimeBattlePresentation>,
}
pub fn simple_story_resolution(
game_state: &Value,
action_text: String,
result_text: &str,
) -> StoryResolution {
StoryResolution {
action_text,
result_text: result_text.to_string(),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: vec![build_status_patch(game_state)],
battle: None,
toast: None,
}
}
pub fn resolve_action_text(default_text: &str, request: &RuntimeStoryActionRequest) -> String {
request
.action
.payload
.as_ref()
.and_then(|payload| read_optional_string_field(payload, "optionText"))
.unwrap_or_else(|| default_text.to_string())
}
pub fn build_status_patch(game_state: &Value) -> RuntimeStoryPatch {
RuntimeStoryPatch::StatusChanged {
in_battle: read_bool_field(game_state, "inBattle").unwrap_or(false),
npc_interaction_active: read_bool_field(game_state, "npcInteractionActive")
.unwrap_or(false),
current_npc_battle_mode: read_optional_string_field(game_state, "currentNpcBattleMode"),
current_npc_battle_outcome: read_optional_string_field(
game_state,
"currentNpcBattleOutcome",
),
}
}
pub fn current_world_type(game_state: &Value) -> Option<String> {
read_optional_string_field(game_state, "worldType")
}

View File

@@ -0,0 +1,393 @@
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_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 {
let rarity_score = match item_rarity_key(item).as_str() {
"legendary" => 5,
"epic" => 4,
"rare" => 3,
"uncommon" => 2,
_ => 1,
};
let tags = read_array_field(item, "tags")
.into_iter()
.filter_map(|tag| tag.as_str().map(|value| value.to_string()))
.collect::<Vec<_>>();
let mana_bonus = if tags.iter().any(|tag| tag == "mana") {
3
} else {
0
};
let healing_bonus = if tags.iter().any(|tag| tag == "healing") {
3
} else {
0
};
(4 + rarity_score * 3 + mana_bonus + healing_bonus).min(24)
}
pub fn build_npc_gift_result_text(
npc_name: &str,
item: &Value,
affinity_gain: i32,
next_affinity: i32,
) -> String {
let shift_text = if affinity_gain >= 12 {
"态度一下子软化了许多"
} else if affinity_gain >= 8 {
"态度明显和缓下来"
} else if affinity_gain >= 5 {
"态度比先前亲近了一些"
} else {
"态度略微放松了些"
};
let affinity_text = if next_affinity >= 90 {
"对你高度信赖,言谈间明显亲近,几乎已经把你当成自己人。"
} else if next_affinity >= 60 {
"对你已经建立起稳固信任,愿意进一步合作。"
} else if next_affinity >= 30 {
"对你的态度明显友善了许多,也更愿意正常交流。"
} else if next_affinity >= 15 {
"戒备开始松动,愿意试探性地配合你的节奏。"
} else if next_affinity >= 0 {
"仍保持明显距离,只会给出谨慎而有限的回应。"
} else {
"关系已经降到冰点,对你几乎不再保留善意。"
};
format!(
"{}收下了{}{}{}",
npc_name,
read_inventory_item_name(item),
shift_text,
affinity_text
)
}
fn inventory_item_value(item: &Value) -> i32 {
if let Some(explicit_value) = read_i32_field(item, "value") {
return explicit_value.max(8);
}
let rarity_base = match item_rarity_key(item).as_str() {
"legendary" => 168,
"epic" => 92,
"rare" => 48,
"uncommon" => 24,
_ => 12,
};
let category = read_optional_string_field(item, "category").unwrap_or_default();
let tags = read_array_field(item, "tags")
.into_iter()
.filter_map(|tag| tag.as_str().map(|value| value.to_string()))
.collect::<Vec<_>>();
let mut value = rarity_base;
if tags.iter().any(|tag| tag == "weapon") {
value += 14;
}
if tags.iter().any(|tag| tag == "armor") {
value += 12;
}
if tags.iter().any(|tag| tag == "relic") {
value += 16;
}
if tags.iter().any(|tag| tag == "mana") {
value += 8;
}
if tags.iter().any(|tag| tag == "healing") {
value += 8;
}
if tags.iter().any(|tag| tag == "material") {
value += 4;
}
if category.contains("专属") {
value += 10;
}
value.max(8)
}
fn discount_tier_for_affinity(affinity: i32) -> i32 {
if affinity >= 90 {
3
} else if affinity >= 60 {
2
} else if affinity >= 30 {
1
} else {
0
}
}
pub fn npc_purchase_price(item: &Value, affinity: i32) -> i32 {
let discount_multiplier = 1.0 - f64::from(discount_tier_for_affinity(affinity)) * 0.08;
(f64::from(inventory_item_value(item)) * discount_multiplier)
.round()
.max(6.0) as i32
}
pub fn npc_buyback_price(item: &Value, affinity: i32) -> i32 {
let buyback_multiplier = 0.4 + f64::from(discount_tier_for_affinity(affinity)) * 0.06;
(f64::from(inventory_item_value(item)) * buyback_multiplier)
.round()
.max(4.0) as i32
}
pub fn trade_quantity_suffix(quantity: i32) -> String {
if quantity > 1 {
format!(" x{quantity}")
} else {
String::new()
}
}
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,
character_id: Option<String>,
joined_at_affinity: i32,
) {
let root = ensure_json_object(game_state);
let companions = root
.entry("companions".to_string())
.or_insert_with(|| Value::Array(Vec::new()));
if !companions.is_array() {
*companions = Value::Array(Vec::new());
}
let items = companions
.as_array_mut()
.expect("companions should be array");
if items
.iter()
.any(|item| read_optional_string_field(item, "npcId").is_some_and(|value| value == npc_id))
{
return;
}
items.push(json!({
"npcId": npc_id,
"characterId": character_id,
"joinedAtAffinity": joined_at_affinity,
}));
}
fn remove_companion_by_npc_id(game_state: &mut Value, npc_id: &str) -> Option<Value> {
let root = ensure_json_object(game_state);
let companions = root
.entry("companions".to_string())
.or_insert_with(|| Value::Array(Vec::new()));
if !companions.is_array() {
*companions = Value::Array(Vec::new());
}
let items = companions
.as_array_mut()
.expect("companions should be array");
let index = items.iter().position(|item| {
read_optional_string_field(item, "npcId").is_some_and(|value| value == npc_id)
})?;
Some(items.remove(index))
}
/// compat bridge 先只维护一个轻量队伍名单,继续复用旧前端的满员换队语义。
pub fn recruit_companion_to_party(
game_state: &mut Value,
npc_id: &str,
joined_at_affinity: i32,
release_npc_id: Option<&str>,
) -> Result<Option<String>, String> {
let companion_count = read_array_field(game_state, "companions").len();
if companion_count < MAX_TASK5_COMPANIONS {
add_companion_if_absent(game_state, npc_id, None, joined_at_affinity);
return Ok(None);
}
let Some(release_npc_id) = release_npc_id.and_then(normalize_required_string) else {
return Err("队伍已满时必须明确指定一名离队同伴".to_string());
};
let released_companion = remove_companion_by_npc_id(game_state, release_npc_id.as_str())
.ok_or_else(|| "指定的离队同伴不存在,无法完成换队招募".to_string())?;
let released_name = read_optional_string_field(&released_companion, "displayName")
.or_else(|| read_optional_string_field(&released_companion, "name"))
.or_else(|| read_optional_string_field(&released_companion, "npcName"))
.unwrap_or(release_npc_id);
add_companion_if_absent(game_state, npc_id, None, joined_at_affinity);
Ok(Some(released_name))
}

View File

@@ -0,0 +1,122 @@
use serde_json::Value;
use shared_contracts::runtime_story::{RuntimeStoryOptionInteraction, RuntimeStoryOptionView};
use crate::{read_bool_field, read_field, read_optional_string_field, read_required_string_field};
/// 这批 helper 只负责 runtime story option 的纯 DTO 编译,不触碰 HTTP / AppState。
pub fn infer_option_scope(function_id: &str) -> &'static str {
if function_id.starts_with("battle_") || function_id == "inventory_use" {
"combat"
} else if function_id.starts_with("npc_") {
"npc"
} else {
"story"
}
}
pub fn build_static_runtime_story_option(
function_id: &str,
action_text: &str,
scope: &str,
) -> RuntimeStoryOptionView {
RuntimeStoryOptionView {
function_id: function_id.to_string(),
action_text: action_text.to_string(),
detail_text: None,
scope: scope.to_string(),
interaction: None,
payload: None,
disabled: None,
reason: None,
}
}
pub fn build_runtime_story_option_with_payload(
function_id: &str,
action_text: &str,
scope: &str,
detail_text: Option<String>,
payload: Value,
) -> RuntimeStoryOptionView {
RuntimeStoryOptionView {
detail_text,
payload: Some(payload),
..build_static_runtime_story_option(function_id, action_text, scope)
}
}
pub fn build_disabled_runtime_story_option(
function_id: &str,
action_text: &str,
scope: &str,
detail_text: Option<String>,
reason: &str,
payload: Option<Value>,
) -> RuntimeStoryOptionView {
RuntimeStoryOptionView {
detail_text,
payload,
disabled: Some(true),
reason: Some(reason.to_string()),
..build_static_runtime_story_option(function_id, action_text, scope)
}
}
pub fn build_runtime_story_option_from_story_option(
value: &Value,
) -> Option<RuntimeStoryOptionView> {
let function_id = read_required_string_field(value, "functionId")?;
let action_text = read_required_string_field(value, "actionText")
.or_else(|| read_required_string_field(value, "text"))
.unwrap_or_else(|| function_id.clone());
Some(RuntimeStoryOptionView {
scope: infer_option_scope(function_id.as_str()).to_string(),
detail_text: read_optional_string_field(value, "detailText"),
interaction: build_runtime_story_option_interaction(read_field(value, "interaction")),
payload: read_field(value, "runtimePayload")
.or_else(|| read_field(value, "payload"))
.cloned(),
disabled: read_bool_field(value, "disabled"),
reason: read_optional_string_field(value, "disabledReason")
.or_else(|| read_optional_string_field(value, "reason")),
function_id,
action_text,
})
}
pub fn build_runtime_story_option_interaction(
value: Option<&Value>,
) -> Option<RuntimeStoryOptionInteraction> {
let interaction = value?;
match read_required_string_field(interaction, "kind")?.as_str() {
"npc" => Some(RuntimeStoryOptionInteraction::Npc {
npc_id: read_required_string_field(interaction, "npcId")?,
action: read_required_string_field(interaction, "action")?,
quest_id: read_optional_string_field(interaction, "questId"),
}),
_ => None,
}
}
pub fn build_story_option_from_runtime_option(option: &RuntimeStoryOptionView) -> Value {
serde_json::json!({
"functionId": option.function_id,
"actionText": option.action_text,
"text": option.action_text,
"detailText": option.detail_text,
"visuals": {
"playerAnimation": "idle",
"playerMoveMeters": 0,
"playerOffsetY": 0,
"playerFacing": "right",
"scrollWorld": false,
"monsterChanges": []
},
"interaction": option.interaction,
"runtimePayload": option.payload,
"disabled": option.disabled,
"disabledReason": option.reason,
})
}

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,188 @@
use serde_json::{Value, to_value};
use shared_contracts::{
runtime_story::RuntimeStoryOptionView,
story::{
StoryEventPayload, StoryRuntimeActorProjection, StoryRuntimeInventoryProjection,
StoryRuntimeOptionProjection, StoryRuntimeProjectionResponse, StoryRuntimeStatusProjection,
StorySessionPayload,
},
};
use crate::{
current_encounter_id, read_bool_field, read_i32_field, read_optional_string_field,
view_model::build_runtime_story_inventory,
};
pub struct StoryRuntimeProjectionSource {
pub story_session: StorySessionPayload,
pub story_events: Vec<StoryEventPayload>,
pub game_state: Value,
pub options: Vec<RuntimeStoryOptionView>,
pub server_version: u32,
pub current_narrative_text: Option<String>,
pub action_result_text: Option<String>,
pub toast: Option<String>,
}
/// 将领域快照折成前端可直接消费的新 story runtime 投影。
pub fn build_story_runtime_projection(
source: StoryRuntimeProjectionSource,
) -> StoryRuntimeProjectionResponse {
let inventory = build_runtime_story_inventory(&source.game_state);
StoryRuntimeProjectionResponse {
story_session: source.story_session,
story_events: source.story_events,
server_version: source.server_version,
actor: StoryRuntimeActorProjection {
hp: read_i32_field(&source.game_state, "playerHp").unwrap_or(0),
max_hp: read_i32_field(&source.game_state, "playerMaxHp").unwrap_or(1),
mana: read_i32_field(&source.game_state, "playerMana").unwrap_or(0),
max_mana: read_i32_field(&source.game_state, "playerMaxMana").unwrap_or(1),
currency: inventory.player_currency,
currency_text: inventory.currency_text.clone(),
},
inventory: StoryRuntimeInventoryProjection {
backpack_items: inventory
.backpack_items
.into_iter()
.map(|item| to_value(item).expect("runtime inventory item should serialize"))
.collect(),
equipment_slots: inventory
.equipment_slots
.into_iter()
.map(|slot| to_value(slot).expect("runtime equipment slot should serialize"))
.collect(),
forge_recipes: inventory
.forge_recipes
.into_iter()
.map(|recipe| to_value(recipe).expect("runtime forge recipe should serialize"))
.collect(),
},
options: source
.options
.into_iter()
.map(build_story_runtime_option_projection)
.collect(),
status: StoryRuntimeStatusProjection {
in_battle: read_bool_field(&source.game_state, "inBattle").unwrap_or(false),
npc_interaction_active: read_bool_field(&source.game_state, "npcInteractionActive")
.unwrap_or(false),
current_encounter_id: current_encounter_id(&source.game_state),
current_npc_battle_mode: read_optional_string_field(
&source.game_state,
"currentNpcBattleMode",
),
current_npc_battle_outcome: read_optional_string_field(
&source.game_state,
"currentNpcBattleOutcome",
),
},
current_narrative_text: source.current_narrative_text,
action_result_text: source.action_result_text,
toast: source.toast,
}
}
fn build_story_runtime_option_projection(
option: RuntimeStoryOptionView,
) -> StoryRuntimeOptionProjection {
let disabled = option.disabled.unwrap_or(false);
StoryRuntimeOptionProjection {
function_id: option.function_id,
action_text: option.action_text,
detail_text: option.detail_text,
scope: option.scope,
payload: option.payload,
enabled: !disabled,
reason: option.reason,
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
fn story_session() -> StorySessionPayload {
StorySessionPayload {
story_session_id: "storysess_1".to_string(),
runtime_session_id: "runtime_1".to_string(),
actor_user_id: "user_1".to_string(),
world_profile_id: "profile_1".to_string(),
initial_prompt: "进入营地".to_string(),
opening_summary: Some("营地开场".to_string()),
latest_narrative_text: "篝火仍然亮着。".to_string(),
latest_choice_function_id: Some("npc_chat".to_string()),
status: "active".to_string(),
version: 3,
created_at: "1.000000Z".to_string(),
updated_at: "3.000000Z".to_string(),
}
}
#[test]
fn projection_builds_frontend_ready_story_runtime_shape() {
let projection = build_story_runtime_projection(StoryRuntimeProjectionSource {
story_session: story_session(),
story_events: vec![StoryEventPayload {
event_id: "storyevt_1".to_string(),
story_session_id: "storysess_1".to_string(),
event_kind: "story_continued".to_string(),
narrative_text: "篝火仍然亮着。".to_string(),
choice_function_id: Some("npc_chat".to_string()),
created_at: "3.000000Z".to_string(),
}],
game_state: json!({
"worldType": "WUXIA",
"playerCharacter": { "id": "hero-1", "name": "沈砺" },
"playerHp": 28,
"playerMaxHp": 40,
"playerMana": 12,
"playerMaxMana": 20,
"playerCurrency": 80,
"playerInventory": [{
"id": "potion-1",
"category": "消耗品",
"name": "疗伤药",
"quantity": 2,
"rarity": "common",
"tags": ["healing"]
}],
"playerEquipment": { "weapon": null, "armor": null, "relic": null },
"currentEncounter": { "id": "npc_firekeeper", "npcName": "守火人" },
"inBattle": false,
"npcInteractionActive": true
}),
options: vec![RuntimeStoryOptionView {
function_id: "npc_chat".to_string(),
action_text: "继续交谈".to_string(),
detail_text: Some("围绕当前话题继续推进关系判断。".to_string()),
scope: "npc".to_string(),
interaction: None,
payload: Some(json!({ "npcId": "npc_firekeeper" })),
disabled: None,
reason: None,
}],
server_version: 3,
current_narrative_text: Some("守火人示意你继续说。".to_string()),
action_result_text: None,
toast: Some("关系有所变化。".to_string()),
});
assert_eq!(projection.story_session.story_session_id, "storysess_1");
assert_eq!(projection.actor.hp, 28);
assert_eq!(projection.actor.currency_text, "80 铜钱");
assert_eq!(projection.inventory.backpack_items.len(), 1);
assert_eq!(projection.options[0].function_id, "npc_chat");
assert!(projection.options[0].enabled);
assert_eq!(
projection.status.current_encounter_id.as_deref(),
Some("npc_firekeeper")
);
assert_eq!(projection.toast.as_deref(), Some("关系有所变化。"));
}
}

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

@@ -0,0 +1,504 @@
use serde_json::{Value, json};
use shared_contracts::runtime_story::{
RuntimeStoryCompanionViewModel, RuntimeStoryEncounterViewModel, RuntimeStoryEquipmentSlotView,
RuntimeStoryForgeRecipeView, RuntimeStoryForgeRequirementView, RuntimeStoryInventoryActionView,
RuntimeStoryInventoryItemActionsView, RuntimeStoryInventoryItemView,
RuntimeStoryInventoryViewModel, RuntimeStoryOptionView, RuntimeStoryPlayerViewModel,
RuntimeStoryStatusViewModel, RuntimeStoryViewModel,
};
use crate::{
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 层独立编译。
pub fn build_runtime_story_view_model(
game_state: &Value,
options: &[RuntimeStoryOptionView],
) -> RuntimeStoryViewModel {
RuntimeStoryViewModel {
player: RuntimeStoryPlayerViewModel {
hp: read_i32_field(game_state, "playerHp").unwrap_or(0),
max_hp: read_i32_field(game_state, "playerMaxHp").unwrap_or(1),
mana: read_i32_field(game_state, "playerMana").unwrap_or(0),
max_mana: read_i32_field(game_state, "playerMaxMana").unwrap_or(1),
},
encounter: build_runtime_story_encounter(game_state),
companions: build_runtime_story_companions(game_state),
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),
npc_interaction_active: read_bool_field(game_state, "npcInteractionActive")
.unwrap_or(false),
current_npc_battle_mode: read_optional_string_field(game_state, "currentNpcBattleMode"),
current_npc_battle_outcome: read_optional_string_field(
game_state,
"currentNpcBattleOutcome",
),
},
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()
.filter_map(|entry| {
let npc_id = read_required_string_field(entry, "npcId")?;
Some(RuntimeStoryCompanionViewModel {
npc_id,
character_id: read_optional_string_field(entry, "characterId"),
joined_at_affinity: read_i32_field(entry, "joinedAtAffinity").unwrap_or(0),
})
})
.collect()
}
pub fn build_runtime_story_encounter(game_state: &Value) -> Option<RuntimeStoryEncounterViewModel> {
let encounter = read_object_field(game_state, "currentEncounter")?;
let npc_name = read_required_string_field(encounter, "npcName")
.or_else(|| read_required_string_field(encounter, "name"))
.unwrap_or_else(|| "当前遭遇".to_string());
let encounter_id =
read_required_string_field(encounter, "id").unwrap_or_else(|| npc_name.clone());
let npc_state = resolve_current_encounter_npc_state(game_state, &encounter_id, &npc_name);
Some(RuntimeStoryEncounterViewModel {
id: encounter_id,
kind: read_required_string_field(encounter, "kind").unwrap_or_else(|| "npc".to_string()),
npc_name,
hostile: read_bool_field(encounter, "hostile").unwrap_or(false),
affinity: npc_state.and_then(|state| read_i32_field(state, "affinity")),
recruited: npc_state.and_then(|state| read_bool_field(state, "recruited")),
interaction_active: read_bool_field(game_state, "npcInteractionActive").unwrap_or(false),
battle_mode: read_optional_string_field(game_state, "currentNpcBattleMode"),
})
}
pub fn resolve_current_encounter_npc_state<'a>(
game_state: &'a Value,
encounter_id: &str,
npc_name: &str,
) -> Option<&'a Value> {
let npc_states = read_object_field(game_state, "npcStates")?;
npc_states
.get(encounter_id)
.or_else(|| npc_states.get(npc_name))
}
#[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("战斗中无法执行该操作。")
);
}
}