505 lines
19 KiB
Rust
505 lines
19 KiB
Rust
use serde_json::{Value, json};
|
||
|
||
use shared_contracts::runtime_story::{
|
||
RuntimeStoryCompanionViewModel, RuntimeStoryEncounterViewModel, RuntimeStoryEquipmentSlotView,
|
||
RuntimeStoryForgeRecipeView, RuntimeStoryForgeRequirementView, RuntimeStoryInventoryActionView,
|
||
RuntimeStoryInventoryItemActionsView, RuntimeStoryInventoryItemView,
|
||
RuntimeStoryInventoryViewModel, RuntimeStoryOptionView, RuntimeStoryPlayerViewModel,
|
||
RuntimeStoryStatusViewModel, RuntimeStoryViewModel,
|
||
};
|
||
|
||
use crate::{
|
||
battle::inventory_item_has_usable_effect, build_runtime_npc_interaction_view,
|
||
equipment_slot_label, read_array_field, read_bool_field, read_field, read_i32_field,
|
||
read_object_field, read_optional_string_field, read_player_equipment_item,
|
||
read_player_inventory_values, read_required_string_field, remove_inventory_item_from_list,
|
||
resolve_equipment_slot_for_item,
|
||
};
|
||
|
||
use super::forge::{
|
||
apply_forge_requirements_if_possible, count_matching_forge_requirement,
|
||
forge_recipe_definitions, format_currency_text, reforge_cost_definition,
|
||
};
|
||
|
||
/// 运行时故事 view-model 只依赖快照 JSON 与共享 contract,可脱离 HTTP 层独立编译。
|
||
pub fn build_runtime_story_view_model(
|
||
game_state: &Value,
|
||
options: &[RuntimeStoryOptionView],
|
||
) -> RuntimeStoryViewModel {
|
||
RuntimeStoryViewModel {
|
||
player: RuntimeStoryPlayerViewModel {
|
||
hp: read_i32_field(game_state, "playerHp").unwrap_or(0),
|
||
max_hp: read_i32_field(game_state, "playerMaxHp").unwrap_or(1),
|
||
mana: read_i32_field(game_state, "playerMana").unwrap_or(0),
|
||
max_mana: read_i32_field(game_state, "playerMaxMana").unwrap_or(1),
|
||
},
|
||
encounter: build_runtime_story_encounter(game_state),
|
||
companions: build_runtime_story_companions(game_state),
|
||
inventory: build_runtime_story_inventory(game_state),
|
||
available_options: options.to_vec(),
|
||
status: RuntimeStoryStatusViewModel {
|
||
in_battle: read_bool_field(game_state, "inBattle").unwrap_or(false),
|
||
npc_interaction_active: read_bool_field(game_state, "npcInteractionActive")
|
||
.unwrap_or(false),
|
||
current_npc_battle_mode: read_optional_string_field(game_state, "currentNpcBattleMode"),
|
||
current_npc_battle_outcome: read_optional_string_field(
|
||
game_state,
|
||
"currentNpcBattleOutcome",
|
||
),
|
||
},
|
||
npc_interaction: build_runtime_npc_interaction_view(game_state),
|
||
}
|
||
}
|
||
|
||
pub fn build_runtime_story_inventory(game_state: &Value) -> RuntimeStoryInventoryViewModel {
|
||
let player_currency = read_i32_field(game_state, "playerCurrency").unwrap_or(0);
|
||
let world_type = read_optional_string_field(game_state, "worldType");
|
||
let in_battle = read_bool_field(game_state, "inBattle").unwrap_or(false);
|
||
let inventory_items = read_player_inventory_values(game_state);
|
||
|
||
RuntimeStoryInventoryViewModel {
|
||
player_currency,
|
||
currency_text: format_currency_text(player_currency, world_type.as_deref()),
|
||
in_battle,
|
||
backpack_items: inventory_items
|
||
.iter()
|
||
.map(|item| build_inventory_item_view(game_state, item))
|
||
.collect(),
|
||
equipment_slots: ["weapon", "armor", "relic"]
|
||
.into_iter()
|
||
.map(|slot_id| build_equipment_slot_view(game_state, slot_id))
|
||
.collect(),
|
||
forge_recipes: forge_recipe_definitions()
|
||
.into_iter()
|
||
.map(|recipe| {
|
||
let requirements = recipe
|
||
.requirements
|
||
.iter()
|
||
.map(|requirement| RuntimeStoryForgeRequirementView {
|
||
id: requirement.id.to_string(),
|
||
label: requirement.label.to_string(),
|
||
quantity: requirement.quantity,
|
||
owned: count_matching_forge_requirement(
|
||
inventory_items.as_slice(),
|
||
requirement,
|
||
),
|
||
})
|
||
.collect::<Vec<_>>();
|
||
let disabled_reason = forge_recipe_disabled_reason(
|
||
game_state,
|
||
player_currency,
|
||
requirements.as_slice(),
|
||
recipe.currency_cost,
|
||
);
|
||
let can_craft = disabled_reason.is_none();
|
||
|
||
RuntimeStoryForgeRecipeView {
|
||
id: recipe.id.to_string(),
|
||
name: recipe.name.to_string(),
|
||
kind: recipe.kind.to_string(),
|
||
description: recipe.description.to_string(),
|
||
result_label: recipe.result_label.to_string(),
|
||
currency_cost: recipe.currency_cost,
|
||
currency_text: format_currency_text(
|
||
recipe.currency_cost,
|
||
world_type.as_deref(),
|
||
),
|
||
requirements,
|
||
can_craft,
|
||
disabled_reason: disabled_reason.clone(),
|
||
action: build_inventory_action(
|
||
"forge_craft",
|
||
format!("制作{}", recipe.result_label),
|
||
Some(json!({ "recipeId": recipe.id })),
|
||
can_craft,
|
||
disabled_reason,
|
||
),
|
||
}
|
||
})
|
||
.collect(),
|
||
}
|
||
}
|
||
|
||
fn build_inventory_item_view(game_state: &Value, item: &Value) -> RuntimeStoryInventoryItemView {
|
||
RuntimeStoryInventoryItemView {
|
||
item: item.clone(),
|
||
actions: RuntimeStoryInventoryItemActionsView {
|
||
use_item: build_use_item_action(game_state, item),
|
||
equip: build_equip_item_action(game_state, item),
|
||
dismantle: build_dismantle_item_action(game_state, item),
|
||
reforge: build_reforge_item_action(game_state, item),
|
||
},
|
||
}
|
||
}
|
||
|
||
fn build_equipment_slot_view(game_state: &Value, slot_id: &str) -> RuntimeStoryEquipmentSlotView {
|
||
let item = read_player_equipment_item(game_state, slot_id);
|
||
let item_name = item
|
||
.as_ref()
|
||
.and_then(|value| read_optional_string_field(value, "name"))
|
||
.unwrap_or_else(|| equipment_slot_label(slot_id).to_string());
|
||
let disabled_reason = inventory_non_battle_gate_reason(game_state).or_else(|| {
|
||
item.is_none()
|
||
.then(|| format!("{}位当前没有装备。", equipment_slot_label(slot_id)))
|
||
});
|
||
let enabled = disabled_reason.is_none();
|
||
|
||
RuntimeStoryEquipmentSlotView {
|
||
slot_id: slot_id.to_string(),
|
||
label: equipment_slot_label(slot_id).to_string(),
|
||
item,
|
||
unequip: build_inventory_action(
|
||
"equipment_unequip",
|
||
format!("卸下{item_name}"),
|
||
Some(json!({ "slotId": slot_id })),
|
||
enabled,
|
||
disabled_reason,
|
||
),
|
||
}
|
||
}
|
||
|
||
fn build_use_item_action(game_state: &Value, item: &Value) -> RuntimeStoryInventoryActionView {
|
||
let item_id = read_optional_string_field(item, "id");
|
||
let item_name = read_item_name(item);
|
||
let disabled_reason = if read_field(game_state, "playerCharacter").is_none() {
|
||
Some("缺少玩家角色,无法使用物品。".to_string())
|
||
} else if !read_bool_field(game_state, "inBattle").unwrap_or(false) {
|
||
Some("当前物品使用需要在战斗动作中结算。".to_string())
|
||
} else if read_i32_field(item, "quantity").unwrap_or(0) <= 0 {
|
||
Some("物品数量不足。".to_string())
|
||
} else if !inventory_item_has_usable_effect(item) {
|
||
Some("该物品当前没有可直接使用的效果。".to_string())
|
||
} else {
|
||
None
|
||
};
|
||
let enabled = disabled_reason.is_none();
|
||
|
||
build_inventory_action(
|
||
"inventory_use",
|
||
format!("使用{item_name}"),
|
||
item_id.map(|item_id| json!({ "itemId": item_id })),
|
||
enabled,
|
||
disabled_reason,
|
||
)
|
||
}
|
||
|
||
fn build_equip_item_action(game_state: &Value, item: &Value) -> RuntimeStoryInventoryActionView {
|
||
let item_id = read_optional_string_field(item, "id");
|
||
let item_name = read_item_name(item);
|
||
let disabled_reason = inventory_non_battle_gate_reason(game_state).or_else(|| {
|
||
if read_i32_field(item, "quantity").unwrap_or(0) <= 0 {
|
||
Some("物品数量不足。".to_string())
|
||
} else if resolve_equipment_slot_for_item(item).is_none() {
|
||
Some("该物品不能装备。".to_string())
|
||
} else {
|
||
None
|
||
}
|
||
});
|
||
let enabled = disabled_reason.is_none();
|
||
|
||
build_inventory_action(
|
||
"equipment_equip",
|
||
format!("装备{item_name}"),
|
||
item_id.map(|item_id| json!({ "itemId": item_id })),
|
||
enabled,
|
||
disabled_reason,
|
||
)
|
||
}
|
||
|
||
fn build_dismantle_item_action(
|
||
game_state: &Value,
|
||
item: &Value,
|
||
) -> RuntimeStoryInventoryActionView {
|
||
let item_id = read_optional_string_field(item, "id");
|
||
let item_name = read_item_name(item);
|
||
let disabled_reason = inventory_non_battle_gate_reason(game_state).or_else(|| {
|
||
if read_i32_field(item, "quantity").unwrap_or(0) <= 0 {
|
||
Some("物品数量不足。".to_string())
|
||
} else if resolve_equipment_slot_for_item(item).is_none()
|
||
&& read_field(item, "buildProfile").is_none()
|
||
{
|
||
Some("该物品不能拆解。".to_string())
|
||
} else {
|
||
None
|
||
}
|
||
});
|
||
let enabled = disabled_reason.is_none();
|
||
|
||
build_inventory_action(
|
||
"forge_dismantle",
|
||
format!("拆解{item_name}"),
|
||
item_id.map(|item_id| json!({ "itemId": item_id })),
|
||
enabled,
|
||
disabled_reason,
|
||
)
|
||
}
|
||
|
||
fn build_reforge_item_action(game_state: &Value, item: &Value) -> RuntimeStoryInventoryActionView {
|
||
let item_id = read_optional_string_field(item, "id");
|
||
let item_name = read_item_name(item);
|
||
let disabled_reason = inventory_non_battle_gate_reason(game_state).or_else(|| {
|
||
let Some(slot_id) = resolve_equipment_slot_for_item(item) else {
|
||
return Some("该物品不能重铸。".to_string());
|
||
};
|
||
if read_i32_field(item, "quantity").unwrap_or(0) <= 0 {
|
||
return Some("物品数量不足。".to_string());
|
||
}
|
||
if read_field(item, "buildProfile").is_none() {
|
||
return Some("该物品不能重铸。".to_string());
|
||
}
|
||
|
||
let cost = reforge_cost_definition(Some(slot_id));
|
||
let player_currency = read_i32_field(game_state, "playerCurrency").unwrap_or(0);
|
||
if player_currency < cost.currency_cost {
|
||
return Some("货币不足。".to_string());
|
||
}
|
||
let Some(item_id) = read_optional_string_field(item, "id") else {
|
||
return Some("目标物品缺少 id。".to_string());
|
||
};
|
||
let base_inventory = remove_inventory_item_from_list(
|
||
read_player_inventory_values(game_state),
|
||
item_id.as_str(),
|
||
1,
|
||
);
|
||
if apply_forge_requirements_if_possible(
|
||
base_inventory.as_slice(),
|
||
cost.requirements.as_slice(),
|
||
)
|
||
.is_none()
|
||
{
|
||
return Some("材料不足。".to_string());
|
||
}
|
||
None
|
||
});
|
||
let enabled = disabled_reason.is_none();
|
||
|
||
build_inventory_action(
|
||
"forge_reforge",
|
||
format!("重铸{item_name}"),
|
||
item_id.map(|item_id| json!({ "itemId": item_id })),
|
||
enabled,
|
||
disabled_reason,
|
||
)
|
||
}
|
||
|
||
fn forge_recipe_disabled_reason(
|
||
game_state: &Value,
|
||
player_currency: i32,
|
||
requirements: &[RuntimeStoryForgeRequirementView],
|
||
currency_cost: i32,
|
||
) -> Option<String> {
|
||
inventory_non_battle_gate_reason(game_state).or_else(|| {
|
||
if player_currency < currency_cost {
|
||
Some("货币不足。".to_string())
|
||
} else if requirements
|
||
.iter()
|
||
.any(|requirement| requirement.owned < requirement.quantity)
|
||
{
|
||
Some("材料不足。".to_string())
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
}
|
||
|
||
fn inventory_non_battle_gate_reason(game_state: &Value) -> Option<String> {
|
||
if read_field(game_state, "playerCharacter").is_none() {
|
||
return Some("缺少玩家角色,无法操作背包。".to_string());
|
||
}
|
||
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
|
||
return Some("战斗中无法执行该操作。".to_string());
|
||
}
|
||
None
|
||
}
|
||
|
||
fn build_inventory_action(
|
||
function_id: &str,
|
||
action_text: String,
|
||
payload: Option<Value>,
|
||
enabled: bool,
|
||
reason: Option<String>,
|
||
) -> RuntimeStoryInventoryActionView {
|
||
RuntimeStoryInventoryActionView {
|
||
function_id: function_id.to_string(),
|
||
action_text,
|
||
payload,
|
||
enabled,
|
||
reason: if enabled { None } else { reason },
|
||
}
|
||
}
|
||
|
||
fn read_item_name(item: &Value) -> String {
|
||
read_optional_string_field(item, "name")
|
||
.or_else(|| read_optional_string_field(item, "id"))
|
||
.unwrap_or_else(|| "未命名物品".to_string())
|
||
}
|
||
|
||
pub fn build_runtime_story_companions(game_state: &Value) -> Vec<RuntimeStoryCompanionViewModel> {
|
||
read_array_field(game_state, "companions")
|
||
.into_iter()
|
||
.filter_map(|entry| {
|
||
let npc_id = read_required_string_field(entry, "npcId")?;
|
||
Some(RuntimeStoryCompanionViewModel {
|
||
npc_id,
|
||
character_id: read_optional_string_field(entry, "characterId"),
|
||
joined_at_affinity: read_i32_field(entry, "joinedAtAffinity").unwrap_or(0),
|
||
})
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
pub fn build_runtime_story_encounter(game_state: &Value) -> Option<RuntimeStoryEncounterViewModel> {
|
||
let encounter = read_object_field(game_state, "currentEncounter")?;
|
||
let npc_name = read_required_string_field(encounter, "npcName")
|
||
.or_else(|| read_required_string_field(encounter, "name"))
|
||
.unwrap_or_else(|| "当前遭遇".to_string());
|
||
let encounter_id =
|
||
read_required_string_field(encounter, "id").unwrap_or_else(|| npc_name.clone());
|
||
let npc_state = resolve_current_encounter_npc_state(game_state, &encounter_id, &npc_name);
|
||
|
||
Some(RuntimeStoryEncounterViewModel {
|
||
id: encounter_id,
|
||
kind: read_required_string_field(encounter, "kind").unwrap_or_else(|| "npc".to_string()),
|
||
npc_name,
|
||
hostile: read_bool_field(encounter, "hostile").unwrap_or(false),
|
||
affinity: npc_state.and_then(|state| read_i32_field(state, "affinity")),
|
||
recruited: npc_state.and_then(|state| read_bool_field(state, "recruited")),
|
||
interaction_active: read_bool_field(game_state, "npcInteractionActive").unwrap_or(false),
|
||
battle_mode: read_optional_string_field(game_state, "currentNpcBattleMode"),
|
||
})
|
||
}
|
||
|
||
pub fn resolve_current_encounter_npc_state<'a>(
|
||
game_state: &'a Value,
|
||
encounter_id: &str,
|
||
npc_name: &str,
|
||
) -> Option<&'a Value> {
|
||
let npc_states = read_object_field(game_state, "npcStates")?;
|
||
|
||
npc_states
|
||
.get(encounter_id)
|
||
.or_else(|| npc_states.get(npc_name))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn base_game_state() -> Value {
|
||
json!({
|
||
"worldType": "WUXIA",
|
||
"playerCharacter": {
|
||
"id": "hero-1",
|
||
"name": "沈砺"
|
||
},
|
||
"playerCurrency": 90,
|
||
"playerInventory": [
|
||
{
|
||
"id": "scrap-a",
|
||
"category": "材料",
|
||
"name": "旧铜片",
|
||
"quantity": 2,
|
||
"rarity": "common",
|
||
"tags": ["material", "工巧"]
|
||
},
|
||
{
|
||
"id": "scrap-b",
|
||
"category": "材料",
|
||
"name": "风化铁片",
|
||
"quantity": 1,
|
||
"rarity": "common",
|
||
"tags": ["material", "守御"]
|
||
},
|
||
{
|
||
"id": "duelist-blade",
|
||
"category": "武器",
|
||
"name": "百炼追风剑",
|
||
"quantity": 1,
|
||
"rarity": "epic",
|
||
"tags": ["weapon", "快剑", "突进"],
|
||
"equipmentSlotId": "weapon",
|
||
"buildProfile": {
|
||
"role": "快剑",
|
||
"tags": ["快剑", "突进"],
|
||
"forgeRank": 1
|
||
}
|
||
},
|
||
{
|
||
"id": "refined-ingot",
|
||
"category": "材料",
|
||
"name": "精炼锭材",
|
||
"quantity": 1,
|
||
"rarity": "rare",
|
||
"tags": ["material", "工巧", "守御"]
|
||
}
|
||
],
|
||
"playerEquipment": {
|
||
"weapon": null,
|
||
"armor": null,
|
||
"relic": null
|
||
},
|
||
"inBattle": false,
|
||
"npcInteractionActive": false,
|
||
"companions": []
|
||
})
|
||
}
|
||
|
||
#[test]
|
||
fn inventory_view_compiles_forge_recipe_availability_on_server() {
|
||
let view = build_runtime_story_inventory(&base_game_state());
|
||
|
||
let refined = view
|
||
.forge_recipes
|
||
.iter()
|
||
.find(|recipe| recipe.id == "synthesis-refined-ingot")
|
||
.expect("refined ingot recipe should exist");
|
||
assert!(refined.can_craft);
|
||
assert_eq!(refined.requirements[0].owned, 4);
|
||
assert!(refined.action.enabled);
|
||
|
||
let blade = view
|
||
.backpack_items
|
||
.iter()
|
||
.find(|item| {
|
||
read_optional_string_field(&item.item, "id").as_deref() == Some("duelist-blade")
|
||
})
|
||
.expect("blade item view should exist");
|
||
assert!(blade.actions.equip.enabled);
|
||
assert!(blade.actions.dismantle.enabled);
|
||
assert!(blade.actions.reforge.enabled);
|
||
assert!(!blade.actions.use_item.enabled);
|
||
}
|
||
|
||
#[test]
|
||
fn inventory_view_reports_disabled_reasons_for_locked_actions() {
|
||
let mut state = base_game_state();
|
||
state
|
||
.as_object_mut()
|
||
.expect("state should be object")
|
||
.insert("inBattle".to_string(), Value::Bool(true));
|
||
|
||
let view = build_runtime_story_inventory(&state);
|
||
let refined = view
|
||
.forge_recipes
|
||
.iter()
|
||
.find(|recipe| recipe.id == "synthesis-refined-ingot")
|
||
.expect("recipe should exist");
|
||
assert!(!refined.can_craft);
|
||
assert_eq!(
|
||
refined.disabled_reason.as_deref(),
|
||
Some("战斗中无法执行该操作。")
|
||
);
|
||
|
||
let weapon_slot = view
|
||
.equipment_slots
|
||
.iter()
|
||
.find(|slot| slot.slot_id == "weapon")
|
||
.expect("weapon slot should exist");
|
||
assert!(!weapon_slot.unequip.enabled);
|
||
assert_eq!(
|
||
weapon_slot.unequip.reason.as_deref(),
|
||
Some("战斗中无法执行该操作。")
|
||
);
|
||
}
|
||
}
|