Files
Genarrative/server-rs/crates/module-runtime-story/src/view_model.rs

505 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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("战斗中无法执行该操作。")
);
}
}