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

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

View File

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