推进 server-rs DDD 分层与新接口接线
This commit is contained in:
3
server-rs/crates/module-runtime-story/src/application.rs
Normal file
3
server-rs/crates/module-runtime-story/src/application.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
//! runtime story 应用编排落位。
|
||||
//!
|
||||
//! 这里组合纯领域规则并返回后端投影;真实保存、SSE 和模型调用由外层完成。
|
||||
881
server-rs/crates/module-runtime-story/src/battle.rs
Normal file
881
server-rs/crates/module-runtime-story/src/battle.rs
Normal 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())
|
||||
}
|
||||
92
server-rs/crates/module-runtime-story/src/battle_tests.rs
Normal file
92
server-rs/crates/module-runtime-story/src/battle_tests.rs
Normal 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())
|
||||
);
|
||||
}
|
||||
3
server-rs/crates/module-runtime-story/src/commands.rs
Normal file
3
server-rs/crates/module-runtime-story/src/commands.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
//! runtime story 兼容写入命令过渡落位。
|
||||
//!
|
||||
//! 用于表达旧剧情动作解析、战斗动作、锻造动作和 NPC 互动等输入。
|
||||
323
server-rs/crates/module-runtime-story/src/core.rs
Normal file
323
server-rs/crates/module-runtime-story/src/core.rs
Normal 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())
|
||||
}
|
||||
4
server-rs/crates/module-runtime-story/src/domain.rs
Normal file
4
server-rs/crates/module-runtime-story/src/domain.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
//! runtime story 兼容领域模型过渡落位。
|
||||
//!
|
||||
//! 当前 crate 用于旧运行时剧情桥的纯规则兼容。后续迁移时仍只能保留 JSON 规则、
|
||||
//! 选项生成和视图模型转换,不引入 Axum、LLM 或 SpacetimeDB。
|
||||
3
server-rs/crates/module-runtime-story/src/errors.rs
Normal file
3
server-rs/crates/module-runtime-story/src/errors.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
//! runtime story 兼容领域错误过渡落位。
|
||||
//!
|
||||
//! 错误只表达兼容规则失败,不能直接绑定 HTTP 或数据库错误模型。
|
||||
3
server-rs/crates/module-runtime-story/src/events.rs
Normal file
3
server-rs/crates/module-runtime-story/src/events.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
//! runtime story 兼容领域事件过渡落位。
|
||||
//!
|
||||
//! 用于表达旧剧情快照变化、战斗表现变化和物品/成长待同步等事实。
|
||||
597
server-rs/crates/module-runtime-story/src/forge.rs
Normal file
597
server-rs/crates/module-runtime-story/src/forge.rs
Normal 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}")
|
||||
}
|
||||
220
server-rs/crates/module-runtime-story/src/forge_actions.rs
Normal file
220
server-rs/crates/module-runtime-story/src/forge_actions.rs
Normal 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)),
|
||||
})
|
||||
}
|
||||
417
server-rs/crates/module-runtime-story/src/game_state.rs
Normal file
417
server-rs/crates/module-runtime-story/src/game_state.rs
Normal 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}")
|
||||
}
|
||||
170
server-rs/crates/module-runtime-story/src/lib.rs
Normal file
170
server-rs/crates/module-runtime-story/src/lib.rs
Normal 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")
|
||||
}
|
||||
393
server-rs/crates/module-runtime-story/src/npc_support.rs
Normal file
393
server-rs/crates/module-runtime-story/src/npc_support.rs
Normal 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))
|
||||
}
|
||||
122
server-rs/crates/module-runtime-story/src/options.rs
Normal file
122
server-rs/crates/module-runtime-story/src/options.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
903
server-rs/crates/module-runtime-story/src/post_battle.rs
Normal file
903
server-rs/crates/module-runtime-story/src/post_battle.rs
Normal file
@@ -0,0 +1,903 @@
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::runtime_story::RuntimeStoryOptionView;
|
||||
|
||||
use crate::{
|
||||
CONTINUE_ADVENTURE_FUNCTION_ID, build_static_runtime_story_option,
|
||||
build_story_option_from_runtime_option, ensure_json_object, read_array_field, read_bool_field,
|
||||
read_field, read_i32_field, read_object_field, read_optional_string_field, write_bool_field,
|
||||
write_i32_field, write_null_field, write_string_field,
|
||||
};
|
||||
|
||||
const WUXIA_FIRST_SCENE_ID: &str = "wuxia-bamboo-road";
|
||||
const WUXIA_FIRST_SCENE_NAME: &str = "竹林古道";
|
||||
const WUXIA_FIRST_SCENE_DESCRIPTION: &str =
|
||||
"风过竹叶如刀鸣,窄道蜿蜒向深处,最适合藏伏毒物和游侠。";
|
||||
const XIANXIA_FIRST_SCENE_ID: &str = "xianxia-cloud-gate";
|
||||
const XIANXIA_FIRST_SCENE_NAME: &str = "云海仙门";
|
||||
const XIANXIA_FIRST_SCENE_DESCRIPTION: &str =
|
||||
"云阶在脚下翻涌,门阙后方灵光不断,来客与守门异物都极显眼。";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PostBattleFinalization {
|
||||
pub story_text: String,
|
||||
pub presentation_options: Vec<RuntimeStoryOptionView>,
|
||||
pub saved_current_story: Value,
|
||||
}
|
||||
|
||||
/// 战斗终局统一由后端收口,前端只负责播放 presentation。
|
||||
pub fn finalize_post_battle_resolution(
|
||||
game_state: &mut Value,
|
||||
result_text: &str,
|
||||
outcome: Option<&str>,
|
||||
fallback_options: Vec<RuntimeStoryOptionView>,
|
||||
) -> Option<PostBattleFinalization> {
|
||||
let outcome = outcome?;
|
||||
if !is_terminal_battle_outcome(outcome) {
|
||||
return None;
|
||||
}
|
||||
|
||||
if outcome == "defeat" {
|
||||
return Some(finalize_defeat_revive(game_state, fallback_options));
|
||||
}
|
||||
|
||||
if outcome == "victory" || outcome == "spar_complete" {
|
||||
return Some(finalize_victory_or_spar(
|
||||
game_state,
|
||||
result_text,
|
||||
fallback_options,
|
||||
));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn is_terminal_battle_outcome(outcome: &str) -> bool {
|
||||
matches!(outcome, "victory" | "spar_complete" | "defeat")
|
||||
}
|
||||
|
||||
/// 后端战斗后故事选项只返回可展示 DTO,不再让前端重算章节推进结果。
|
||||
pub fn resolve_post_battle_story_options(game_state: &Value) -> Vec<RuntimeStoryOptionView> {
|
||||
build_scene_travel_options(game_state)
|
||||
}
|
||||
|
||||
fn finalize_victory_or_spar(
|
||||
game_state: &mut Value,
|
||||
result_text: &str,
|
||||
fallback_options: Vec<RuntimeStoryOptionView>,
|
||||
) -> PostBattleFinalization {
|
||||
clear_post_battle_state(game_state);
|
||||
let is_last_act = is_current_scene_act_last(game_state);
|
||||
let next_act_state = if is_last_act {
|
||||
None
|
||||
} else {
|
||||
resolve_next_scene_act_runtime_state(game_state)
|
||||
};
|
||||
if let Some(next_act_state) = next_act_state {
|
||||
write_current_scene_act_state(game_state, next_act_state);
|
||||
}
|
||||
|
||||
let deferred_options = if fallback_options.is_empty() {
|
||||
build_scene_travel_options(game_state)
|
||||
} else {
|
||||
fallback_options
|
||||
};
|
||||
let options = if is_last_act {
|
||||
deferred_options.clone()
|
||||
} else {
|
||||
vec![continue_adventure_option()]
|
||||
};
|
||||
let saved_current_story = if is_last_act {
|
||||
build_plain_current_story(result_text, &deferred_options)
|
||||
} else {
|
||||
build_deferred_current_story(
|
||||
result_text,
|
||||
&deferred_options,
|
||||
current_scene_act_state(game_state),
|
||||
)
|
||||
};
|
||||
|
||||
PostBattleFinalization {
|
||||
story_text: result_text.to_string(),
|
||||
presentation_options: options,
|
||||
saved_current_story,
|
||||
}
|
||||
}
|
||||
|
||||
fn finalize_defeat_revive(
|
||||
game_state: &mut Value,
|
||||
_fallback_options: Vec<RuntimeStoryOptionView>,
|
||||
) -> PostBattleFinalization {
|
||||
let first_scene = resolve_first_scene(game_state);
|
||||
write_first_scene(game_state, &first_scene);
|
||||
write_null_field(game_state, "currentEncounter");
|
||||
write_bool_field(game_state, "npcInteractionActive", false);
|
||||
ensure_json_object(game_state).insert("sceneHostileNpcs".to_string(), Value::Array(Vec::new()));
|
||||
write_i32_field(game_state, "playerX", 0);
|
||||
write_string_field(game_state, "playerFacing", "right");
|
||||
let player_max_hp = read_i32_field(game_state, "playerMaxHp")
|
||||
.unwrap_or(1)
|
||||
.max(1);
|
||||
let player_max_mana = read_i32_field(game_state, "playerMaxMana")
|
||||
.unwrap_or(0)
|
||||
.max(0);
|
||||
write_i32_field(game_state, "playerHp", player_max_hp);
|
||||
write_i32_field(game_state, "playerMana", player_max_mana);
|
||||
write_bool_field(game_state, "inBattle", false);
|
||||
write_null_field(game_state, "currentBattleNpcId");
|
||||
write_null_field(game_state, "currentNpcBattleMode");
|
||||
write_null_field(game_state, "currentNpcBattleOutcome");
|
||||
write_null_field(game_state, "sparReturnEncounter");
|
||||
write_null_field(game_state, "sparPlayerHpBefore");
|
||||
write_null_field(game_state, "sparPlayerMaxHpBefore");
|
||||
write_null_field(game_state, "sparStoryHistoryBefore");
|
||||
write_string_field(game_state, "animationState", "idle");
|
||||
write_string_field(game_state, "playerActionMode", "idle");
|
||||
ensure_json_object(game_state)
|
||||
.insert("activeCombatEffects".to_string(), Value::Array(Vec::new()));
|
||||
write_bool_field(game_state, "scrollWorld", false);
|
||||
|
||||
if let Some(first_act_state) =
|
||||
build_initial_scene_act_runtime_state(game_state, &first_scene.id)
|
||||
{
|
||||
write_current_scene_act_state(game_state, first_act_state);
|
||||
}
|
||||
ensure_first_scene_encounter_preview(game_state);
|
||||
|
||||
let story_text = if first_scene.name.is_empty() {
|
||||
"你在战斗中倒下,随后重新醒来。".to_string()
|
||||
} else {
|
||||
format!("你在战斗中倒下,随后在{}重新醒来。", first_scene.name)
|
||||
};
|
||||
// 中文注释:败北复活后的正式选项必须基于复活后的首场景重新生成,
|
||||
// 不能沿用战斗结算前旧场景的 fallback options。
|
||||
let deferred_options = build_scene_travel_options(game_state);
|
||||
let saved_current_story = build_death_current_story(story_text.as_str(), &deferred_options);
|
||||
|
||||
PostBattleFinalization {
|
||||
story_text,
|
||||
presentation_options: vec![continue_adventure_option()],
|
||||
saved_current_story,
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_post_battle_state(game_state: &mut Value) {
|
||||
write_null_field(game_state, "currentEncounter");
|
||||
write_bool_field(game_state, "npcInteractionActive", false);
|
||||
ensure_json_object(game_state).insert("sceneHostileNpcs".to_string(), Value::Array(Vec::new()));
|
||||
write_bool_field(game_state, "inBattle", false);
|
||||
write_null_field(game_state, "currentBattleNpcId");
|
||||
write_null_field(game_state, "currentNpcBattleMode");
|
||||
write_null_field(game_state, "currentNpcBattleOutcome");
|
||||
write_null_field(game_state, "sparReturnEncounter");
|
||||
write_null_field(game_state, "sparPlayerHpBefore");
|
||||
write_null_field(game_state, "sparPlayerMaxHpBefore");
|
||||
write_null_field(game_state, "sparStoryHistoryBefore");
|
||||
write_string_field(game_state, "animationState", "idle");
|
||||
write_string_field(game_state, "playerActionMode", "idle");
|
||||
ensure_json_object(game_state)
|
||||
.insert("activeCombatEffects".to_string(), Value::Array(Vec::new()));
|
||||
write_bool_field(game_state, "scrollWorld", false);
|
||||
}
|
||||
|
||||
fn continue_adventure_option() -> RuntimeStoryOptionView {
|
||||
build_static_runtime_story_option(CONTINUE_ADVENTURE_FUNCTION_ID, "继续前进", "story")
|
||||
}
|
||||
|
||||
fn build_plain_current_story(text: &str, options: &[RuntimeStoryOptionView]) -> Value {
|
||||
json!({
|
||||
"text": text,
|
||||
"options": options.iter().map(build_story_option_from_runtime_option).collect::<Vec<_>>(),
|
||||
"streaming": false
|
||||
})
|
||||
}
|
||||
|
||||
fn build_deferred_current_story(
|
||||
text: &str,
|
||||
deferred_options: &[RuntimeStoryOptionView],
|
||||
deferred_act_state: Option<Value>,
|
||||
) -> Value {
|
||||
let mut story = json!({
|
||||
"text": text,
|
||||
"options": vec![build_story_option_from_runtime_option(&continue_adventure_option())],
|
||||
"deferredOptions": deferred_options
|
||||
.iter()
|
||||
.map(build_story_option_from_runtime_option)
|
||||
.collect::<Vec<_>>(),
|
||||
"streaming": false
|
||||
});
|
||||
if let Some(deferred_act_state) = deferred_act_state {
|
||||
if let Some(object) = story.as_object_mut() {
|
||||
object.insert(
|
||||
"deferredRuntimeState".to_string(),
|
||||
json!({
|
||||
"storyEngineMemory": {
|
||||
"currentSceneActState": deferred_act_state
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
story
|
||||
}
|
||||
|
||||
fn build_death_current_story(text: &str, deferred_options: &[RuntimeStoryOptionView]) -> Value {
|
||||
let mut story = json!({
|
||||
"text": text,
|
||||
"options": vec![build_story_option_from_runtime_option(&continue_adventure_option())],
|
||||
"streaming": false
|
||||
});
|
||||
if !deferred_options.is_empty() {
|
||||
if let Some(object) = story.as_object_mut() {
|
||||
object.insert(
|
||||
"deferredOptions".to_string(),
|
||||
Value::Array(
|
||||
deferred_options
|
||||
.iter()
|
||||
.map(build_story_option_from_runtime_option)
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
story
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct RuntimeScene {
|
||||
id: String,
|
||||
name: String,
|
||||
description: String,
|
||||
image_src: String,
|
||||
connected_scene_ids: Vec<String>,
|
||||
connections: Vec<Value>,
|
||||
forward_scene_id: Option<String>,
|
||||
treasure_hints: Vec<String>,
|
||||
npcs: Vec<Value>,
|
||||
}
|
||||
|
||||
fn resolve_first_scene(game_state: &Value) -> RuntimeScene {
|
||||
if let Some(profile) = read_object_field(game_state, "customWorldProfile") {
|
||||
return build_custom_first_scene(profile);
|
||||
}
|
||||
|
||||
match read_optional_string_field(game_state, "worldType").as_deref() {
|
||||
Some("XIANXIA") => RuntimeScene {
|
||||
id: XIANXIA_FIRST_SCENE_ID.to_string(),
|
||||
name: XIANXIA_FIRST_SCENE_NAME.to_string(),
|
||||
description: XIANXIA_FIRST_SCENE_DESCRIPTION.to_string(),
|
||||
image_src: read_object_field(game_state, "currentScenePreset")
|
||||
.and_then(|scene| read_optional_string_field(scene, "imageSrc"))
|
||||
.unwrap_or_default(),
|
||||
connected_scene_ids: vec![
|
||||
"xianxia-floating-isle".to_string(),
|
||||
"xianxia-celestial-corridor".to_string(),
|
||||
"xianxia-star-vessel".to_string(),
|
||||
],
|
||||
connections: vec![
|
||||
json!({
|
||||
"sceneId": "xianxia-celestial-corridor",
|
||||
"relativePosition": "forward",
|
||||
"summary": "沿主路继续深入前方区域"
|
||||
}),
|
||||
json!({
|
||||
"sceneId": "xianxia-floating-isle",
|
||||
"relativePosition": "left",
|
||||
"summary": "这里分出一条支路"
|
||||
}),
|
||||
json!({
|
||||
"sceneId": "xianxia-star-vessel",
|
||||
"relativePosition": "right",
|
||||
"summary": "这里还能转向另一条路"
|
||||
}),
|
||||
],
|
||||
forward_scene_id: Some("xianxia-celestial-corridor".to_string()),
|
||||
treasure_hints: vec![
|
||||
"云阶尽头的灵符匣".to_string(),
|
||||
"门阙阴影里的玉牌".to_string(),
|
||||
],
|
||||
npcs: Vec::new(),
|
||||
},
|
||||
_ => RuntimeScene {
|
||||
id: WUXIA_FIRST_SCENE_ID.to_string(),
|
||||
name: WUXIA_FIRST_SCENE_NAME.to_string(),
|
||||
description: WUXIA_FIRST_SCENE_DESCRIPTION.to_string(),
|
||||
image_src: read_object_field(game_state, "currentScenePreset")
|
||||
.and_then(|scene| read_optional_string_field(scene, "imageSrc"))
|
||||
.unwrap_or_default(),
|
||||
connected_scene_ids: vec![
|
||||
"wuxia-mountain-gate".to_string(),
|
||||
"wuxia-mist-woods".to_string(),
|
||||
"wuxia-ferry-bridge".to_string(),
|
||||
],
|
||||
connections: vec![
|
||||
json!({
|
||||
"sceneId": "wuxia-mountain-gate",
|
||||
"relativePosition": "forward",
|
||||
"summary": "沿主路继续深入前方区域"
|
||||
}),
|
||||
json!({
|
||||
"sceneId": "wuxia-mist-woods",
|
||||
"relativePosition": "left",
|
||||
"summary": "这里分出一条支路"
|
||||
}),
|
||||
json!({
|
||||
"sceneId": "wuxia-ferry-bridge",
|
||||
"relativePosition": "right",
|
||||
"summary": "这里还能转向另一条路"
|
||||
}),
|
||||
],
|
||||
forward_scene_id: Some("wuxia-mountain-gate".to_string()),
|
||||
treasure_hints: vec!["竹根旁半埋的刀鞘".to_string(), "倒竹间的旧药囊".to_string()],
|
||||
npcs: Vec::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn build_custom_first_scene(profile: &Value) -> RuntimeScene {
|
||||
let camp = read_object_field(profile, "camp");
|
||||
let scene_id = camp
|
||||
.and_then(|camp| read_optional_string_field(camp, "id"))
|
||||
.unwrap_or_else(|| "custom-scene-camp".to_string());
|
||||
let scene_name = camp
|
||||
.and_then(|camp| read_optional_string_field(camp, "name"))
|
||||
.or_else(|| read_optional_string_field(profile, "name").map(|name| format!("{name}营地")))
|
||||
.unwrap_or_else(|| "开局营地".to_string());
|
||||
let description = camp
|
||||
.and_then(|camp| read_optional_string_field(camp, "description"))
|
||||
.or_else(|| read_optional_string_field(profile, "summary"))
|
||||
.unwrap_or_else(|| "你重新回到了旅途起点。".to_string());
|
||||
let connections = if let Some(camp) = camp {
|
||||
read_array_field(camp, "connections")
|
||||
.into_iter()
|
||||
.filter_map(|connection| {
|
||||
let target_landmark_id =
|
||||
read_optional_string_field(connection, "targetLandmarkId")?;
|
||||
let scene_id =
|
||||
custom_landmark_runtime_scene_id(profile, target_landmark_id.as_str())?;
|
||||
Some(json!({
|
||||
"sceneId": scene_id,
|
||||
"relativePosition": read_optional_string_field(connection, "relativePosition")
|
||||
.unwrap_or_else(|| "forward".to_string()),
|
||||
"summary": read_optional_string_field(connection, "summary").unwrap_or_default()
|
||||
}))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let connected_scene_ids = connections
|
||||
.iter()
|
||||
.filter_map(|connection| read_optional_string_field(connection, "sceneId"))
|
||||
.collect::<Vec<_>>();
|
||||
let forward_scene_id = connections
|
||||
.iter()
|
||||
.find(|connection| {
|
||||
read_optional_string_field(connection, "relativePosition").as_deref() == Some("forward")
|
||||
})
|
||||
.and_then(|connection| read_optional_string_field(connection, "sceneId"))
|
||||
.or_else(|| connected_scene_ids.first().cloned());
|
||||
|
||||
RuntimeScene {
|
||||
id: "custom-scene-camp".to_string(),
|
||||
name: scene_name,
|
||||
description,
|
||||
image_src: camp
|
||||
.and_then(|camp| read_optional_string_field(camp, "imageSrc"))
|
||||
.unwrap_or_default(),
|
||||
connected_scene_ids,
|
||||
connections,
|
||||
forward_scene_id,
|
||||
treasure_hints: vec![format!(
|
||||
"{}地图残页",
|
||||
read_optional_string_field(profile, "name").unwrap_or_else(|| "当前世界".to_string())
|
||||
)],
|
||||
npcs: build_custom_scene_npcs_for_scene(profile, scene_id.as_str()),
|
||||
}
|
||||
}
|
||||
|
||||
fn custom_landmark_runtime_scene_id(profile: &Value, landmark_id: &str) -> Option<String> {
|
||||
read_array_field(profile, "landmarks")
|
||||
.into_iter()
|
||||
.position(|landmark| {
|
||||
read_optional_string_field(landmark, "id").as_deref() == Some(landmark_id)
|
||||
})
|
||||
.map(|index| format!("custom-scene-landmark-{}", index + 1))
|
||||
}
|
||||
|
||||
fn write_first_scene(game_state: &mut Value, scene: &RuntimeScene) {
|
||||
ensure_json_object(game_state).insert(
|
||||
"currentScenePreset".to_string(),
|
||||
json!({
|
||||
"id": scene.id,
|
||||
"name": scene.name,
|
||||
"description": scene.description,
|
||||
"imageSrc": scene.image_src,
|
||||
"connectedSceneIds": scene.connected_scene_ids,
|
||||
"connections": scene.connections,
|
||||
"forwardSceneId": scene.forward_scene_id,
|
||||
"treasureHints": scene.treasure_hints,
|
||||
"npcs": scene.npcs,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
fn ensure_first_scene_encounter_preview(game_state: &mut Value) {
|
||||
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
|
||||
return;
|
||||
}
|
||||
if !read_array_field(game_state, "sceneHostileNpcs").is_empty()
|
||||
|| read_field(game_state, "currentEncounter").is_some_and(|value| !value.is_null())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(profile) = read_object_field(game_state, "customWorldProfile") else {
|
||||
return;
|
||||
};
|
||||
let scene_id = read_object_field(game_state, "currentScenePreset")
|
||||
.and_then(|scene| read_optional_string_field(scene, "id"));
|
||||
let focus_npc_id = resolve_active_scene_act_focus_npc_id(profile, scene_id.as_deref());
|
||||
let Some(focus_npc_id) = focus_npc_id else {
|
||||
return;
|
||||
};
|
||||
let Some(npc) = find_custom_world_role(profile, focus_npc_id.as_str()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
ensure_json_object(game_state).insert(
|
||||
"currentEncounter".to_string(),
|
||||
build_encounter_from_role(&npc, 12.0),
|
||||
);
|
||||
}
|
||||
|
||||
fn build_scene_travel_options(game_state: &Value) -> Vec<RuntimeStoryOptionView> {
|
||||
let Some(current_scene) = read_object_field(game_state, "currentScenePreset") else {
|
||||
return vec![build_static_runtime_story_option(
|
||||
"idle_explore_forward",
|
||||
"继续向前探索",
|
||||
"story",
|
||||
)];
|
||||
};
|
||||
let current_scene_id = read_optional_string_field(current_scene, "id");
|
||||
let mut options = read_array_field(current_scene, "connections")
|
||||
.into_iter()
|
||||
.filter_map(|connection| {
|
||||
let scene_id = read_optional_string_field(connection, "sceneId")?;
|
||||
if current_scene_id.as_deref() == Some(scene_id.as_str()) {
|
||||
return None;
|
||||
}
|
||||
let relative_position = read_optional_string_field(connection, "relativePosition")
|
||||
.unwrap_or_else(|| "forward".to_string());
|
||||
let scene_name = resolve_scene_name(game_state, scene_id.as_str())
|
||||
.unwrap_or_else(|| scene_id.clone());
|
||||
Some(RuntimeStoryOptionView {
|
||||
payload: Some(json!({ "targetSceneId": scene_id })),
|
||||
..build_static_runtime_story_option(
|
||||
"idle_travel_next_scene",
|
||||
format!(
|
||||
"{},前往{}",
|
||||
direction_text(relative_position.as_str()),
|
||||
scene_name
|
||||
)
|
||||
.as_str(),
|
||||
"story",
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if options.is_empty() {
|
||||
options.push(build_static_runtime_story_option(
|
||||
"idle_explore_forward",
|
||||
"继续向前探索",
|
||||
"story",
|
||||
));
|
||||
}
|
||||
|
||||
options
|
||||
}
|
||||
|
||||
fn resolve_scene_name(game_state: &Value, scene_id: &str) -> Option<String> {
|
||||
if read_object_field(game_state, "currentScenePreset")
|
||||
.and_then(|scene| read_optional_string_field(scene, "id"))
|
||||
.as_deref()
|
||||
== Some(scene_id)
|
||||
{
|
||||
return read_object_field(game_state, "currentScenePreset")
|
||||
.and_then(|scene| read_optional_string_field(scene, "name"));
|
||||
}
|
||||
|
||||
let profile = read_object_field(game_state, "customWorldProfile")?;
|
||||
if scene_id == "custom-scene-camp"
|
||||
|| read_object_field(profile, "camp")
|
||||
.and_then(|camp| read_optional_string_field(camp, "id"))
|
||||
.as_deref()
|
||||
== Some(scene_id)
|
||||
{
|
||||
return read_object_field(profile, "camp")
|
||||
.and_then(|camp| read_optional_string_field(camp, "name"))
|
||||
.or_else(|| {
|
||||
read_optional_string_field(profile, "name").map(|name| format!("{name}营地"))
|
||||
});
|
||||
}
|
||||
read_array_field(profile, "landmarks")
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.find_map(|(index, landmark)| {
|
||||
let runtime_id = format!("custom-scene-landmark-{}", index + 1);
|
||||
if runtime_id == scene_id
|
||||
|| read_optional_string_field(landmark, "id").as_deref() == Some(scene_id)
|
||||
{
|
||||
read_optional_string_field(landmark, "name")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn direction_text(relative_position: &str) -> &'static str {
|
||||
match relative_position {
|
||||
"north" => "向北走",
|
||||
"south" => "向南走",
|
||||
"east" => "向东走",
|
||||
"west" => "向西走",
|
||||
"left" => "向左走",
|
||||
"right" => "向右走",
|
||||
"back" => "往回走",
|
||||
"up" => "向上走",
|
||||
"down" => "向下走",
|
||||
"inside" => "向内走",
|
||||
"outside" => "向外走",
|
||||
"portal" => "穿过通路",
|
||||
_ => "向前走",
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_next_scene_act_runtime_state(game_state: &Value) -> Option<Value> {
|
||||
let profile = read_object_field(game_state, "customWorldProfile")?;
|
||||
let scene_id = read_object_field(game_state, "currentScenePreset")
|
||||
.and_then(|scene| read_optional_string_field(scene, "id"));
|
||||
let scene_id_text = scene_id.as_deref()?;
|
||||
let chapter = resolve_scene_chapter_blueprint(profile, Some(scene_id_text))?;
|
||||
let acts = read_array_field(chapter, "acts");
|
||||
if acts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let runtime_state = build_initial_scene_act_runtime_state(game_state, scene_id_text)?;
|
||||
let current_act_id = read_optional_string_field(&runtime_state, "currentActId");
|
||||
let current_index = acts
|
||||
.iter()
|
||||
.position(|act| {
|
||||
read_optional_string_field(act, "id").as_deref() == current_act_id.as_deref()
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
read_i32_field(&runtime_state, "currentActIndex")
|
||||
.unwrap_or(0)
|
||||
.clamp(0, acts.len().saturating_sub(1) as i32) as usize
|
||||
});
|
||||
let active_act = acts[current_index];
|
||||
let next_act = acts.get(current_index + 1)?;
|
||||
let active_act_id = read_optional_string_field(active_act, "id")?;
|
||||
let next_act_id = read_optional_string_field(next_act, "id")?;
|
||||
let completed = append_unique_string(
|
||||
read_string_array_field(&runtime_state, "completedActIds"),
|
||||
active_act_id,
|
||||
);
|
||||
let visited = append_unique_string(
|
||||
read_string_array_field(&runtime_state, "visitedActIds"),
|
||||
next_act_id.clone(),
|
||||
);
|
||||
|
||||
Some(json!({
|
||||
"sceneId": read_optional_string_field(chapter, "sceneId")
|
||||
.unwrap_or_else(|| scene_id_text.to_string()),
|
||||
"chapterId": read_optional_string_field(chapter, "id").unwrap_or_default(),
|
||||
"currentActId": next_act_id,
|
||||
"currentActIndex": current_index + 1,
|
||||
"completedActIds": completed,
|
||||
"visitedActIds": visited,
|
||||
}))
|
||||
}
|
||||
|
||||
fn current_scene_act_state(game_state: &Value) -> Option<Value> {
|
||||
read_object_field(game_state, "storyEngineMemory")
|
||||
.and_then(|memory| read_object_field(memory, "currentSceneActState"))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn is_current_scene_act_last(game_state: &Value) -> bool {
|
||||
let Some(profile) = read_object_field(game_state, "customWorldProfile") else {
|
||||
return false;
|
||||
};
|
||||
let Some(scene_id) = read_object_field(game_state, "currentScenePreset")
|
||||
.and_then(|scene| read_optional_string_field(scene, "id"))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(chapter) = resolve_scene_chapter_blueprint(profile, Some(scene_id.as_str())) else {
|
||||
return false;
|
||||
};
|
||||
let acts = read_array_field(chapter, "acts");
|
||||
if acts.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let Some(runtime_state) = build_initial_scene_act_runtime_state(game_state, scene_id.as_str())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let current_act_id = read_optional_string_field(&runtime_state, "currentActId");
|
||||
let current_index = acts
|
||||
.iter()
|
||||
.position(|act| {
|
||||
read_optional_string_field(act, "id").as_deref() == current_act_id.as_deref()
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
read_i32_field(&runtime_state, "currentActIndex")
|
||||
.unwrap_or(0)
|
||||
.clamp(0, acts.len().saturating_sub(1) as i32) as usize
|
||||
});
|
||||
|
||||
current_index + 1 >= acts.len()
|
||||
}
|
||||
|
||||
fn write_current_scene_act_state(game_state: &mut Value, act_state: Value) {
|
||||
let root = ensure_json_object(game_state);
|
||||
let memory = root
|
||||
.entry("storyEngineMemory".to_string())
|
||||
.or_insert_with(|| {
|
||||
json!({
|
||||
"discoveredFactIds": [],
|
||||
"activeThreadIds": [],
|
||||
"resolvedScarIds": [],
|
||||
"recentCarrierIds": []
|
||||
})
|
||||
});
|
||||
if !memory.is_object() {
|
||||
*memory = json!({
|
||||
"discoveredFactIds": [],
|
||||
"activeThreadIds": [],
|
||||
"resolvedScarIds": [],
|
||||
"recentCarrierIds": []
|
||||
});
|
||||
}
|
||||
memory
|
||||
.as_object_mut()
|
||||
.expect("storyEngineMemory should be object")
|
||||
.insert("currentSceneActState".to_string(), act_state);
|
||||
}
|
||||
|
||||
fn build_initial_scene_act_runtime_state(game_state: &Value, scene_id: &str) -> Option<Value> {
|
||||
let profile = read_object_field(game_state, "customWorldProfile")?;
|
||||
let chapter = resolve_scene_chapter_blueprint(profile, Some(scene_id))?;
|
||||
let acts = read_array_field(chapter, "acts");
|
||||
if acts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let runtime_state = current_scene_act_state(game_state);
|
||||
if let Some(runtime_state) = runtime_state {
|
||||
let chapter_id = read_optional_string_field(chapter, "id");
|
||||
let current_act_id = read_optional_string_field(&runtime_state, "currentActId");
|
||||
if read_optional_string_field(&runtime_state, "chapterId") == chapter_id
|
||||
&& acts.iter().any(|act| {
|
||||
read_optional_string_field(act, "id").as_deref() == current_act_id.as_deref()
|
||||
})
|
||||
{
|
||||
return Some(json!({
|
||||
"sceneId": read_optional_string_field(&runtime_state, "sceneId")
|
||||
.unwrap_or_else(|| read_optional_string_field(chapter, "sceneId").unwrap_or_default()),
|
||||
"chapterId": read_optional_string_field(&runtime_state, "chapterId").unwrap_or_default(),
|
||||
"currentActId": current_act_id.unwrap_or_default(),
|
||||
"currentActIndex": read_i32_field(&runtime_state, "currentActIndex").unwrap_or(0).max(0),
|
||||
"completedActIds": read_string_array_field(&runtime_state, "completedActIds"),
|
||||
"visitedActIds": read_string_array_field(&runtime_state, "visitedActIds"),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
let first_act = acts[0];
|
||||
let first_act_id = read_optional_string_field(first_act, "id")?;
|
||||
Some(json!({
|
||||
"sceneId": read_optional_string_field(chapter, "sceneId").unwrap_or_else(|| scene_id.to_string()),
|
||||
"chapterId": read_optional_string_field(chapter, "id").unwrap_or_default(),
|
||||
"currentActId": first_act_id,
|
||||
"currentActIndex": 0,
|
||||
"completedActIds": [],
|
||||
"visitedActIds": [read_optional_string_field(first_act, "id").unwrap_or_default()],
|
||||
}))
|
||||
}
|
||||
|
||||
fn resolve_scene_chapter_blueprint<'a>(
|
||||
profile: &'a Value,
|
||||
scene_id: Option<&str>,
|
||||
) -> Option<&'a Value> {
|
||||
let scene_id = scene_id?;
|
||||
read_array_field(profile, "sceneChapterBlueprints")
|
||||
.into_iter()
|
||||
.find(|chapter| does_scene_match_chapter(profile, scene_id, chapter))
|
||||
}
|
||||
|
||||
fn does_scene_match_chapter(profile: &Value, scene_id: &str, chapter: &Value) -> bool {
|
||||
let aliases = resolve_scene_aliases(profile, scene_id);
|
||||
let mut chapter_scene_ids = Vec::new();
|
||||
if let Some(value) = read_optional_string_field(chapter, "sceneId") {
|
||||
chapter_scene_ids.push(value);
|
||||
}
|
||||
chapter_scene_ids.extend(read_string_array_field(chapter, "linkedLandmarkIds"));
|
||||
for act in read_array_field(chapter, "acts") {
|
||||
if let Some(value) = read_optional_string_field(act, "sceneId") {
|
||||
chapter_scene_ids.push(value);
|
||||
}
|
||||
}
|
||||
aliases
|
||||
.iter()
|
||||
.any(|alias| chapter_scene_ids.iter().any(|id| id == alias))
|
||||
}
|
||||
|
||||
fn resolve_scene_aliases(profile: &Value, scene_id: &str) -> Vec<String> {
|
||||
let mut aliases = vec![scene_id.to_string()];
|
||||
let camp_id = read_object_field(profile, "camp")
|
||||
.and_then(|camp| read_optional_string_field(camp, "id"))
|
||||
.unwrap_or_else(|| "custom-scene-camp".to_string());
|
||||
if scene_id == "custom-scene-camp" || scene_id == camp_id {
|
||||
aliases.push(camp_id);
|
||||
aliases.push("custom-scene-camp".to_string());
|
||||
}
|
||||
for (index, landmark) in read_array_field(profile, "landmarks")
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let runtime_scene_id = format!("custom-scene-landmark-{}", index + 1);
|
||||
if scene_id == runtime_scene_id
|
||||
|| read_optional_string_field(landmark, "id").as_deref() == Some(scene_id)
|
||||
{
|
||||
aliases.push(runtime_scene_id);
|
||||
if let Some(id) = read_optional_string_field(landmark, "id") {
|
||||
aliases.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
dedupe_strings(aliases)
|
||||
}
|
||||
|
||||
fn resolve_active_scene_act_focus_npc_id(
|
||||
profile: &Value,
|
||||
scene_id: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let chapter = resolve_scene_chapter_blueprint(profile, scene_id)?;
|
||||
let act_state = read_array_field(chapter, "acts").first().copied()?;
|
||||
read_optional_string_field(act_state, "oppositeNpcId")
|
||||
.or_else(|| read_optional_string_field(act_state, "primaryNpcId"))
|
||||
.or_else(|| {
|
||||
read_array_field(act_state, "encounterNpcIds")
|
||||
.first()
|
||||
.and_then(|id| id.as_str().map(str::to_string))
|
||||
})
|
||||
}
|
||||
|
||||
fn build_custom_scene_npcs_for_scene(profile: &Value, scene_id: &str) -> Vec<Value> {
|
||||
let Some(chapter) = resolve_scene_chapter_blueprint(profile, Some(scene_id)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(first_act) = read_array_field(chapter, "acts").first().copied() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut role_ids = Vec::new();
|
||||
if let Some(id) = read_optional_string_field(first_act, "primaryNpcId") {
|
||||
role_ids.push(id);
|
||||
}
|
||||
if let Some(id) = read_optional_string_field(first_act, "oppositeNpcId") {
|
||||
role_ids.push(id);
|
||||
}
|
||||
role_ids.extend(read_string_array_field(first_act, "encounterNpcIds"));
|
||||
dedupe_strings(role_ids)
|
||||
.into_iter()
|
||||
.filter_map(|role_id| find_custom_world_role(profile, role_id.as_str()))
|
||||
.map(|role| build_scene_npc_from_role(&role))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn find_custom_world_role(profile: &Value, role_id: &str) -> Option<Value> {
|
||||
read_array_field(profile, "storyNpcs")
|
||||
.into_iter()
|
||||
.chain(read_array_field(profile, "playableNpcs"))
|
||||
.find(|role| {
|
||||
read_optional_string_field(role, "id").as_deref() == Some(role_id)
|
||||
|| read_optional_string_field(role, "name").as_deref() == Some(role_id)
|
||||
|| read_optional_string_field(role, "title").as_deref() == Some(role_id)
|
||||
})
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn build_scene_npc_from_role(role: &Value) -> Value {
|
||||
json!({
|
||||
"id": read_optional_string_field(role, "id").unwrap_or_else(|| read_optional_string_field(role, "name").unwrap_or_else(|| "npc".to_string())),
|
||||
"name": read_optional_string_field(role, "name").unwrap_or_else(|| "当前角色".to_string()),
|
||||
"description": read_optional_string_field(role, "description").unwrap_or_default(),
|
||||
"avatar": read_optional_string_field(role, "name")
|
||||
.and_then(|name| name.chars().next().map(|ch| ch.to_string()))
|
||||
.unwrap_or_else(|| "角".to_string()),
|
||||
"role": read_optional_string_field(role, "role").unwrap_or_default(),
|
||||
"title": read_optional_string_field(role, "title"),
|
||||
"characterId": read_optional_string_field(role, "id"),
|
||||
"initialAffinity": read_i32_field(role, "initialAffinity").unwrap_or(0),
|
||||
"hostile": read_i32_field(role, "initialAffinity").unwrap_or(0) < 0,
|
||||
"functions": ["trade", "fight", "spar", "help", "chat", "recruit", "gift"],
|
||||
"recruitable": true,
|
||||
"backstory": read_optional_string_field(role, "backstory"),
|
||||
"personality": read_optional_string_field(role, "personality"),
|
||||
"motivation": read_optional_string_field(role, "motivation"),
|
||||
"combatStyle": read_optional_string_field(role, "combatStyle"),
|
||||
"relationshipHooks": read_field(role, "relationshipHooks").cloned().unwrap_or_else(|| json!([])),
|
||||
"tags": read_field(role, "tags").cloned().unwrap_or_else(|| json!([])),
|
||||
"backstoryReveal": read_field(role, "backstoryReveal").cloned(),
|
||||
"skills": read_field(role, "skills").cloned().unwrap_or_else(|| json!([])),
|
||||
"initialItems": read_field(role, "initialItems").cloned().unwrap_or_else(|| json!([])),
|
||||
"imageSrc": read_optional_string_field(role, "imageSrc"),
|
||||
"visual": read_field(role, "visual").cloned(),
|
||||
"narrativeProfile": read_field(role, "narrativeProfile").cloned(),
|
||||
"levelProfile": read_field(role, "levelProfile").cloned(),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_encounter_from_role(role: &Value, x_meters: f64) -> Value {
|
||||
json!({
|
||||
"id": read_optional_string_field(role, "id").unwrap_or_else(|| read_optional_string_field(role, "name").unwrap_or_else(|| "npc".to_string())),
|
||||
"kind": "npc",
|
||||
"characterId": read_optional_string_field(role, "id"),
|
||||
"npcName": read_optional_string_field(role, "name").unwrap_or_else(|| "当前角色".to_string()),
|
||||
"npcDescription": read_optional_string_field(role, "description").unwrap_or_default(),
|
||||
"npcAvatar": read_optional_string_field(role, "name")
|
||||
.and_then(|name| name.chars().next().map(|ch| ch.to_string()))
|
||||
.unwrap_or_else(|| "角".to_string()),
|
||||
"context": read_optional_string_field(role, "role").unwrap_or_default(),
|
||||
"xMeters": x_meters,
|
||||
"initialAffinity": read_i32_field(role, "initialAffinity").unwrap_or(0),
|
||||
"hostile": read_i32_field(role, "initialAffinity").unwrap_or(0) < 0,
|
||||
"title": read_optional_string_field(role, "title"),
|
||||
"backstory": read_optional_string_field(role, "backstory"),
|
||||
"personality": read_optional_string_field(role, "personality"),
|
||||
"motivation": read_optional_string_field(role, "motivation"),
|
||||
"combatStyle": read_optional_string_field(role, "combatStyle"),
|
||||
"relationshipHooks": read_field(role, "relationshipHooks").cloned().unwrap_or_else(|| json!([])),
|
||||
"tags": read_field(role, "tags").cloned().unwrap_or_else(|| json!([])),
|
||||
"backstoryReveal": read_field(role, "backstoryReveal").cloned(),
|
||||
"skills": read_field(role, "skills").cloned().unwrap_or_else(|| json!([])),
|
||||
"initialItems": read_field(role, "initialItems").cloned().unwrap_or_else(|| json!([])),
|
||||
"imageSrc": read_optional_string_field(role, "imageSrc"),
|
||||
"visual": read_field(role, "visual").cloned(),
|
||||
"narrativeProfile": read_field(role, "narrativeProfile").cloned(),
|
||||
"levelProfile": read_field(role, "levelProfile").cloned(),
|
||||
})
|
||||
}
|
||||
|
||||
fn read_string_array_field(value: &Value, key: &str) -> Vec<String> {
|
||||
read_field(value, key)
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|item| !item.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn append_unique_string(mut values: Vec<String>, value: String) -> Vec<String> {
|
||||
if !values.iter().any(|entry| entry == &value) {
|
||||
values.push(value);
|
||||
}
|
||||
values
|
||||
}
|
||||
|
||||
fn dedupe_strings(values: Vec<String>) -> Vec<String> {
|
||||
let mut result = Vec::new();
|
||||
for value in values {
|
||||
if !value.trim().is_empty() && !result.iter().any(|entry| entry == &value) {
|
||||
result.push(value);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
188
server-rs/crates/module-runtime-story/src/projection.rs
Normal file
188
server-rs/crates/module-runtime-story/src/projection.rs
Normal 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("关系有所变化。"));
|
||||
}
|
||||
}
|
||||
939
server-rs/crates/module-runtime-story/src/prompt_context.rs
Normal file
939
server-rs/crates/module-runtime-story/src/prompt_context.rs
Normal 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("最新世界变化"))
|
||||
);
|
||||
}
|
||||
}
|
||||
1569
server-rs/crates/module-runtime-story/src/story_engine.rs
Normal file
1569
server-rs/crates/module-runtime-story/src/story_engine.rs
Normal file
File diff suppressed because it is too large
Load Diff
504
server-rs/crates/module-runtime-story/src/view_model.rs
Normal file
504
server-rs/crates/module-runtime-story/src/view_model.rs
Normal 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("战斗中无法执行该操作。")
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user