M4 runtime story Rust migration wrap-up
This commit is contained in:
@@ -20,6 +20,7 @@ module-custom-world = { path = "../module-custom-world" }
|
||||
module-inventory = { path = "../module-inventory" }
|
||||
module-npc = { path = "../module-npc" }
|
||||
module-runtime = { path = "../module-runtime" }
|
||||
module-runtime-story-compat = { path = "../module-runtime-story-compat" }
|
||||
module-runtime-item = { path = "../module-runtime-item" }
|
||||
module-story = { path = "../module-story" }
|
||||
platform-auth = { path = "../platform-auth" }
|
||||
|
||||
@@ -9,17 +9,47 @@ use module_npc::{
|
||||
build_relation_state as build_module_npc_relation_state,
|
||||
};
|
||||
use module_runtime::RuntimeSnapshotRecord;
|
||||
use module_runtime_story_compat::{
|
||||
CONTINUE_ADVENTURE_FUNCTION_ID, CurrentEncounterNpcQuestContext, GeneratedStoryPayload,
|
||||
PendingQuestOfferContext, RuntimeStoryActionResponseParts,
|
||||
StoryResolution, add_player_currency, add_player_inventory_items,
|
||||
append_story_history, apply_equipment_loadout_to_state,
|
||||
battle_mode_text, build_battle_runtime_story_options, build_current_build_toast,
|
||||
build_status_patch,
|
||||
build_npc_gift_result_text,
|
||||
build_runtime_story_view_model,
|
||||
clear_encounter_only, clear_encounter_state, clone_inventory_item_with_quantity,
|
||||
current_encounter_id, current_encounter_name, current_world_type,
|
||||
ensure_inventory_action_available, ensure_json_object, equipment_slot_label,
|
||||
find_player_inventory_entry,
|
||||
format_now_rfc3339, grant_player_progression_experience, has_giftable_player_inventory,
|
||||
format_currency_text,
|
||||
increment_runtime_stat, normalize_equipped_item,
|
||||
normalize_equipment_slot_id, normalize_required_string, npc_buyback_price,
|
||||
npc_purchase_price, read_array_field, read_bool_field, read_field, read_i32_field,
|
||||
read_inventory_item_name, read_object_field, read_optional_string_field,
|
||||
read_player_equipment_item, read_required_string_field, read_runtime_session_id,
|
||||
read_u32_field, recruit_companion_to_party, remove_player_inventory_item,
|
||||
restore_player_resource,
|
||||
resolve_action_text, resolve_battle_action, resolve_equipment_slot_for_item,
|
||||
resolve_forge_craft_action,
|
||||
resolve_forge_dismantle_action, resolve_forge_reforge_action,
|
||||
resolve_npc_gift_affinity_gain, simple_story_resolution, trade_quantity_suffix,
|
||||
resolve_current_encounter_npc_state,
|
||||
build_disabled_runtime_story_option, build_runtime_story_option_from_story_option,
|
||||
build_static_runtime_story_option, build_story_option_from_runtime_option,
|
||||
write_bool_field, write_i32_field, write_null_field, write_player_equipment_item,
|
||||
write_string_field, write_u32_field,
|
||||
};
|
||||
use platform_llm::{LlmClient, LlmMessage, LlmTextRequest};
|
||||
use serde_json::{Map, Value, json};
|
||||
use shared_contracts::runtime_story::{
|
||||
RuntimeBattlePresentation, RuntimeStoryActionRequest, RuntimeStoryActionResponse,
|
||||
RuntimeStoryAiRequest, RuntimeStoryAiResponse, RuntimeStoryCompanionViewModel,
|
||||
RuntimeStoryEncounterViewModel, RuntimeStoryOptionInteraction, RuntimeStoryOptionView,
|
||||
RuntimeStoryPatch, RuntimeStoryPlayerViewModel, RuntimeStoryPresentation,
|
||||
RuntimeStorySnapshotPayload, RuntimeStoryStateResolveRequest, RuntimeStoryStatusViewModel,
|
||||
RuntimeStoryViewModel,
|
||||
RuntimeStoryAiRequest, RuntimeStoryAiResponse, RuntimeStoryOptionInteraction,
|
||||
RuntimeStoryOptionView, RuntimeStoryPatch, RuntimeStoryPresentation,
|
||||
RuntimeStorySnapshotPayload, RuntimeStoryStateResolveRequest,
|
||||
};
|
||||
use shared_kernel::{format_rfc3339, offset_datetime_to_unix_micros, parse_rfc3339};
|
||||
use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
@@ -30,22 +60,10 @@ use crate::{
|
||||
|
||||
#[path = "compat/ai.rs"]
|
||||
mod ai;
|
||||
#[path = "compat/battle.rs"]
|
||||
mod battle;
|
||||
#[path = "compat/battle_actions.rs"]
|
||||
mod battle_actions;
|
||||
#[path = "compat/core.rs"]
|
||||
mod core;
|
||||
#[path = "compat/equipment_actions.rs"]
|
||||
mod equipment_actions;
|
||||
#[path = "compat/forge.rs"]
|
||||
mod forge;
|
||||
#[path = "compat/forge_actions.rs"]
|
||||
mod forge_actions;
|
||||
#[path = "compat/game_state.rs"]
|
||||
mod game_state;
|
||||
#[path = "compat/npc_support.rs"]
|
||||
mod npc_support;
|
||||
#[path = "compat/npc_actions.rs"]
|
||||
mod npc_actions;
|
||||
#[path = "compat/presentation.rs"]
|
||||
@@ -54,50 +72,13 @@ mod presentation;
|
||||
mod quest_actions;
|
||||
|
||||
use self::{
|
||||
ai::*, battle::*, battle_actions::*, core::*, equipment_actions::*, forge::*,
|
||||
forge_actions::*, game_state::*, npc_actions::*, npc_support::*, presentation::*,
|
||||
quest_actions::*,
|
||||
ai::*, equipment_actions::*, game_state::*, npc_actions::*, presentation::*, quest_actions::*,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "compat/tests.rs"]
|
||||
mod tests;
|
||||
|
||||
const CONTINUE_ADVENTURE_FUNCTION_ID: &str = "story_continue_adventure";
|
||||
const MAX_TASK5_COMPANIONS: usize = 2;
|
||||
|
||||
struct StoryResolution {
|
||||
action_text: String,
|
||||
result_text: String,
|
||||
story_text: Option<String>,
|
||||
presentation_options: Option<Vec<RuntimeStoryOptionView>>,
|
||||
saved_current_story: Option<Value>,
|
||||
patches: Vec<RuntimeStoryPatch>,
|
||||
battle: Option<RuntimeBattlePresentation>,
|
||||
toast: Option<String>,
|
||||
}
|
||||
|
||||
struct GeneratedStoryPayload {
|
||||
story_text: String,
|
||||
history_result_text: String,
|
||||
presentation_options: Vec<RuntimeStoryOptionView>,
|
||||
saved_current_story: Value,
|
||||
}
|
||||
|
||||
struct CurrentEncounterNpcQuestContext {
|
||||
npc_id: String,
|
||||
npc_name: String,
|
||||
}
|
||||
|
||||
struct PendingQuestOfferContext {
|
||||
dialogue: Vec<Value>,
|
||||
turn_count: i32,
|
||||
custom_input_placeholder: String,
|
||||
quest: Value,
|
||||
quest_id: String,
|
||||
intro_text: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn resolve_runtime_story_state(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -482,19 +463,6 @@ fn validate_client_version(
|
||||
))
|
||||
}
|
||||
|
||||
struct RuntimeStoryActionResponseParts {
|
||||
requested_session_id: String,
|
||||
server_version: u32,
|
||||
snapshot: RuntimeStorySnapshotPayload,
|
||||
action_text: String,
|
||||
result_text: String,
|
||||
story_text: String,
|
||||
options: Vec<RuntimeStoryOptionView>,
|
||||
patches: Vec<RuntimeStoryPatch>,
|
||||
toast: Option<String>,
|
||||
battle: Option<RuntimeBattlePresentation>,
|
||||
}
|
||||
|
||||
fn resolve_runtime_story_choice_action(
|
||||
game_state: &mut Value,
|
||||
current_story: Option<&Value>,
|
||||
@@ -650,49 +618,6 @@ fn resolve_continue_adventure_action(
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
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",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn current_world_type(game_state: &Value) -> Option<String> {
|
||||
read_optional_string_field(game_state, "worldType")
|
||||
}
|
||||
|
||||
fn map_runtime_story_client_error(error: SpacetimeClientError) -> AppError {
|
||||
let (status, provider) = match error {
|
||||
SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "runtime-story"),
|
||||
|
||||
@@ -1,616 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
/// 兼容桥里的战斗动作仍走快照态,因此把每回合需要写回的字段先收口到这里,
|
||||
/// 避免技能、物品、旧 battle_* 分支继续把状态更新散落在各处。
|
||||
pub(super) struct BattleActionPlan {
|
||||
pub(super) action_text: String,
|
||||
pub(super) result_text: String,
|
||||
pub(super) damage_dealt: i32,
|
||||
pub(super) damage_taken: i32,
|
||||
pub(super) heal: i32,
|
||||
pub(super) mana_restore: i32,
|
||||
pub(super) mana_cost: i32,
|
||||
pub(super) cooldown_tick_turns: i32,
|
||||
pub(super) cooldown_bonus_turns: i32,
|
||||
pub(super) applied_skill_cooldown: Option<(String, i32)>,
|
||||
pub(super) build_buffs: Vec<Value>,
|
||||
pub(super) consumed_item_id: Option<String>,
|
||||
}
|
||||
|
||||
struct BattleSkillView {
|
||||
id: String,
|
||||
name: String,
|
||||
damage: i32,
|
||||
mana_cost: i32,
|
||||
cooldown_turns: i32,
|
||||
build_buffs: Vec<Value>,
|
||||
}
|
||||
|
||||
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>,
|
||||
}
|
||||
|
||||
pub(super) 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(super) 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));
|
||||
}
|
||||
|
||||
pub(super) fn apply_player_damage(game_state: &mut Value, damage: i32) {
|
||||
if damage <= 0 {
|
||||
return;
|
||||
}
|
||||
let hp = read_i32_field(game_state, "playerHp").unwrap_or(1);
|
||||
write_i32_field(game_state, "playerHp", (hp - damage).max(1));
|
||||
}
|
||||
|
||||
pub(super) 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 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)
|
||||
}
|
||||
|
||||
pub(super) 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()
|
||||
}
|
||||
|
||||
pub(super) 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));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn reduce_player_skill_cooldowns(game_state: &mut Value, turns: i32) {
|
||||
if turns <= 0 {
|
||||
return;
|
||||
}
|
||||
tick_player_skill_cooldowns(game_state, turns);
|
||||
}
|
||||
|
||||
pub(super) 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()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
pub(super) 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,
|
||||
"普通攻击",
|
||||
"你抓住当前窗口打出一记直接攻击,对方也立刻做出反击。",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) 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),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn battle_action_toast(
|
||||
function_id: &str,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Option<String> {
|
||||
if function_id != "inventory_use" {
|
||||
return None;
|
||||
}
|
||||
let item_name = request
|
||||
.action
|
||||
.payload
|
||||
.as_ref()
|
||||
.and_then(|payload| read_optional_string_field(payload, "itemId"));
|
||||
item_name.map(|_| "Build 增益已写回当前快照".to_string())
|
||||
}
|
||||
|
||||
pub(super) 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 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 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 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(" / ")
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) 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);
|
||||
}
|
||||
|
||||
apply_player_damage(game_state, plan.damage_taken);
|
||||
let target_hp = apply_target_damage(game_state, plan.damage_dealt);
|
||||
let outcome = if target_hp <= 0 {
|
||||
if battle_mode == "spar" {
|
||||
"spar_complete"
|
||||
} else {
|
||||
"victory"
|
||||
}
|
||||
} else {
|
||||
"ongoing"
|
||||
};
|
||||
|
||||
let victory_experience = if outcome == "victory" {
|
||||
battle_victory_experience_reward(game_state)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
if outcome != "ongoing" {
|
||||
write_bool_field(game_state, "inBattle", false);
|
||||
write_bool_field(game_state, "npcInteractionActive", false);
|
||||
write_null_field(game_state, "currentNpcBattleMode");
|
||||
write_string_field(
|
||||
game_state,
|
||||
"currentNpcBattleOutcome",
|
||||
if outcome == "spar_complete" {
|
||||
"spar_complete"
|
||||
} else {
|
||||
"fight_victory"
|
||||
},
|
||||
);
|
||||
if outcome == "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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.to_string(),
|
||||
},
|
||||
build_status_patch(game_state),
|
||||
];
|
||||
if outcome == "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 == "ongoing" {
|
||||
plan.result_text
|
||||
} else if outcome == "spar_complete" {
|
||||
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.to_string()),
|
||||
}),
|
||||
toast: battle_action_toast(function_id, request),
|
||||
})
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) 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(super) 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(super) 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(super) 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(super) 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(super) 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(super) 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(super) const MAX_PLAYER_LEVEL: i32 = 20;
|
||||
|
||||
pub(super) 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(super) 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(super) 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(super) 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(super) 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(super) 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(super) 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(super) 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(super) fn read_runtime_session_id(game_state: &Value) -> Option<String> {
|
||||
read_optional_string_field(game_state, "runtimeSessionId")
|
||||
}
|
||||
|
||||
pub(super) fn read_field<'a>(value: &'a Value, key: &str) -> Option<&'a Value> {
|
||||
value.as_object()?.get(key)
|
||||
}
|
||||
|
||||
pub(super) 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(super) 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(super) fn read_required_string_field(value: &Value, key: &str) -> Option<String> {
|
||||
normalize_required_string(read_field(value, key)?.as_str()?)
|
||||
}
|
||||
|
||||
pub(super) fn read_optional_string_field(value: &Value, key: &str) -> Option<String> {
|
||||
normalize_optional_string(read_field(value, key).and_then(Value::as_str))
|
||||
}
|
||||
|
||||
pub(super) fn read_bool_field(value: &Value, key: &str) -> Option<bool> {
|
||||
read_field(value, key).and_then(Value::as_bool)
|
||||
}
|
||||
|
||||
pub(super) 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(super) 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(super) fn write_i32_field(value: &mut Value, key: &str, field_value: i32) {
|
||||
ensure_json_object(value).insert(key.to_string(), json!(field_value));
|
||||
}
|
||||
|
||||
pub(super) fn write_u32_field(value: &mut Value, key: &str, field_value: u32) {
|
||||
ensure_json_object(value).insert(key.to_string(), json!(field_value));
|
||||
}
|
||||
|
||||
pub(super) 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(super) 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(super) fn write_null_field(value: &mut Value, key: &str) {
|
||||
ensure_json_object(value).insert(key.to_string(), Value::Null);
|
||||
}
|
||||
|
||||
pub(super) 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(super) fn normalize_required_string(value: &str) -> Option<String> {
|
||||
let trimmed = value.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||
}
|
||||
|
||||
pub(super) fn normalize_optional_string(value: Option<&str>) -> Option<String> {
|
||||
value.and_then(normalize_required_string)
|
||||
}
|
||||
|
||||
pub(super) fn format_now_rfc3339() -> String {
|
||||
format_rfc3339(OffsetDateTime::now_utc()).unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
|
||||
}
|
||||
@@ -1,409 +1,5 @@
|
||||
use super::*;
|
||||
|
||||
/// 这批定义只服务 compat bridge 的确定性锻造规则,
|
||||
/// 先在 `api-server` 内收口,后续再评估是否值得独立 crate。
|
||||
pub(super) struct ForgeRequirementDefinition {
|
||||
pub(super) quantity: i32,
|
||||
pub(super) matcher: ForgeRequirementMatcher,
|
||||
}
|
||||
|
||||
pub(super) enum ForgeRequirementMatcher {
|
||||
Named(&'static str),
|
||||
AnyMaterial,
|
||||
}
|
||||
|
||||
pub(super) struct ForgeRecipeDefinition {
|
||||
pub(super) id: &'static str,
|
||||
pub(super) name: &'static str,
|
||||
pub(super) currency_cost: i32,
|
||||
pub(super) requirements: Vec<ForgeRequirementDefinition>,
|
||||
}
|
||||
|
||||
pub(super) struct ReforgeCostDefinition {
|
||||
pub(super) currency_cost: i32,
|
||||
pub(super) requirements: Vec<ForgeRequirementDefinition>,
|
||||
}
|
||||
|
||||
pub(super) fn forge_recipe_definition(recipe_id: &str) -> Option<ForgeRecipeDefinition> {
|
||||
match recipe_id {
|
||||
"synthesis-refined-ingot" => Some(ForgeRecipeDefinition {
|
||||
id: "synthesis-refined-ingot",
|
||||
name: "压炼锭材",
|
||||
currency_cost: 18,
|
||||
requirements: vec![ForgeRequirementDefinition {
|
||||
quantity: 3,
|
||||
matcher: ForgeRequirementMatcher::AnyMaterial,
|
||||
}],
|
||||
}),
|
||||
"forge-duelist-blade" => Some(ForgeRecipeDefinition {
|
||||
id: "forge-duelist-blade",
|
||||
name: "锻造 百炼追风剑",
|
||||
currency_cost: 72,
|
||||
requirements: vec![
|
||||
ForgeRequirementDefinition {
|
||||
quantity: 2,
|
||||
matcher: ForgeRequirementMatcher::Named("精炼锭材"),
|
||||
},
|
||||
ForgeRequirementDefinition {
|
||||
quantity: 1,
|
||||
matcher: ForgeRequirementMatcher::Named("快剑精粹"),
|
||||
},
|
||||
],
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn reforge_cost_definition(slot_id: Option<&str>) -> ReforgeCostDefinition {
|
||||
if slot_id == Some("relic") {
|
||||
return ReforgeCostDefinition {
|
||||
currency_cost: 52,
|
||||
requirements: vec![ForgeRequirementDefinition {
|
||||
quantity: 1,
|
||||
matcher: ForgeRequirementMatcher::Named("凝光纱"),
|
||||
}],
|
||||
};
|
||||
}
|
||||
ReforgeCostDefinition {
|
||||
currency_cost: 46,
|
||||
requirements: vec![ForgeRequirementDefinition {
|
||||
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::AnyMaterial => {
|
||||
read_array_field(item, "tags")
|
||||
.into_iter()
|
||||
.filter_map(Value::as_str)
|
||||
.any(|tag| tag == "material")
|
||||
|| read_optional_string_field(item, "category")
|
||||
.is_some_and(|category| category.contains("材料"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) 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)
|
||||
}
|
||||
|
||||
pub(super) 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(super) 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(super) 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")
|
||||
}
|
||||
"forge-duelist-blade" => build_runtime_equipment_item(
|
||||
game_state,
|
||||
"百炼追风剑",
|
||||
"weapon",
|
||||
"epic",
|
||||
"为快剑与追身构筑准备的锻造兵刃。",
|
||||
"快剑",
|
||||
&["快剑", "突进", "追击"],
|
||||
&["快剑", "突进", "追击"],
|
||||
json!({
|
||||
"maxManaBonus": 10,
|
||||
"outgoingDamageBonus": 0.20
|
||||
}),
|
||||
),
|
||||
_ => 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(super) 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(super) 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(super) 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("、")
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
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}")
|
||||
}
|
||||
pub(super) use module_runtime_story_compat::{
|
||||
build_runtime_equipment_item, build_runtime_material_item,
|
||||
};
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) 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(super) 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(super) 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)),
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use module_runtime_story_compat::{build_runtime_equipment_item, build_runtime_material_item};
|
||||
|
||||
pub(super) fn current_npc_trade_context(game_state: &Value) -> Result<(String, String), String> {
|
||||
let encounter = read_object_field(game_state, "currentEncounter")
|
||||
@@ -17,80 +18,6 @@ pub(super) fn current_npc_trade_context(game_state: &Value) -> Result<(String, S
|
||||
Ok((npc_id, npc_name))
|
||||
}
|
||||
|
||||
pub(super) 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(super) fn battle_mode_text(value: &str) -> &'static str {
|
||||
if value == "spar" { "切磋" } else { "战斗" }
|
||||
}
|
||||
|
||||
pub(super) 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(super) 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(super) 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(super) 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(super) fn read_player_inventory_values(game_state: &Value) -> Vec<Value> {
|
||||
read_array_field(game_state, "playerInventory")
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) 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(super) 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(super) 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(super) fn current_npc_inventory_items<'a>(game_state: &'a Value) -> Vec<&'a Value> {
|
||||
let Some(npc_id) = current_encounter_id(game_state) else {
|
||||
return Vec::new();
|
||||
@@ -770,346 +697,3 @@ pub(super) fn remove_current_npc_inventory_item(
|
||||
entry.insert("quantity".to_string(), json!(next_quantity));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) 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(super) fn normalize_equipped_item(item: &Value) -> Value {
|
||||
clone_inventory_item_with_quantity(item, 1)
|
||||
}
|
||||
|
||||
pub(super) 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(super) 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(super) 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(super) 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(super) fn equipment_slot_label(slot_id: &str) -> &'static str {
|
||||
match slot_id {
|
||||
"weapon" => "武器",
|
||||
"armor" => "护甲",
|
||||
"relic" => "饰品",
|
||||
_ => "装备",
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) 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(super) 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(super) fn item_rarity_key(item: &Value) -> String {
|
||||
read_optional_string_field(item, "rarity").unwrap_or_else(|| "common".to_string())
|
||||
}
|
||||
|
||||
pub(super) 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(super) 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(super) 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(super) 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(super) 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(super) 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(super) 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}")
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ pub(super) fn resolve_npc_recruit_action(
|
||||
let released_companion_name = recruit_companion_to_party(
|
||||
game_state,
|
||||
npc_id.as_str(),
|
||||
npc_name.as_str(),
|
||||
current_affinity,
|
||||
release_npc_id.as_deref(),
|
||||
)?;
|
||||
let affinity_patch =
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) 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(super) 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(super) 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(super) 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(super) fn trade_quantity_suffix(quantity: i32) -> String {
|
||||
if quantity > 1 {
|
||||
format!(" x{quantity}")
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) 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 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(super) fn recruit_companion_to_party(
|
||||
game_state: &mut Value,
|
||||
npc_id: &str,
|
||||
_npc_name: &str,
|
||||
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,
|
||||
read_current_npc_affinity(game_state),
|
||||
);
|
||||
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_else(|| release_npc_id.clone());
|
||||
add_companion_if_absent(
|
||||
game_state,
|
||||
npc_id,
|
||||
None,
|
||||
read_current_npc_affinity(game_state),
|
||||
);
|
||||
Ok(Some(released_name))
|
||||
}
|
||||
@@ -53,33 +53,6 @@ pub(super) fn build_runtime_story_action_response(
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) 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),
|
||||
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",
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_dialogue_current_story(
|
||||
npc_name: &str,
|
||||
text: &str,
|
||||
@@ -158,57 +131,6 @@ pub(super) fn parse_dialogue_line(line: &str, npc_name: &str) -> Option<Value> {
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) 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(super) 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(super) 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))
|
||||
}
|
||||
|
||||
pub(super) fn build_runtime_story_options(
|
||||
current_story: Option<&Value>,
|
||||
game_state: &Value,
|
||||
@@ -237,43 +159,6 @@ pub(super) fn build_runtime_story_options(
|
||||
build_fallback_runtime_story_options(game_state)
|
||||
}
|
||||
|
||||
pub(super) 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(super) 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(super) fn build_fallback_runtime_story_options(
|
||||
game_state: &Value,
|
||||
) -> Vec<RuntimeStoryOptionView> {
|
||||
@@ -334,54 +219,6 @@ pub(super) fn build_fallback_runtime_story_options(
|
||||
]
|
||||
}
|
||||
|
||||
pub(super) 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(super) 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(super) 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(super) fn build_npc_runtime_story_option(
|
||||
function_id: &str,
|
||||
action_text: &str,
|
||||
@@ -864,16 +701,6 @@ pub(super) fn apply_quest_turn_in_rewards(game_state: &mut Value, quest: &Value)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) 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(super) fn build_legacy_current_story(
|
||||
story_text: &str,
|
||||
options: &[RuntimeStoryOptionView],
|
||||
@@ -885,27 +712,6 @@ pub(super) fn build_legacy_current_story(
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn build_story_option_from_runtime_option(option: &RuntimeStoryOptionView) -> Value {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn read_story_text(current_story: Option<&Value>) -> Option<String> {
|
||||
current_story.and_then(|story| read_optional_string_field(story, "text"))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user