Files
Genarrative/server-rs/crates/module-inventory/src/lib.rs
kdletters cbc27bad4a
Some checks failed
CI / verify (push) Has been cancelled
init with react+axum+spacetimedb
2026-04-26 18:06:23 +08:00

1064 lines
38 KiB
Rust

use std::{error::Error, fmt};
use serde::{Deserialize, Serialize};
use shared_kernel::{
build_prefixed_seed_id, format_timestamp_micros,
normalize_optional_string as normalize_shared_optional_string, normalize_required_string,
normalize_string_list as normalize_shared_string_list,
};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
pub const INVENTORY_SLOT_ID_PREFIX: &str = "invslot_";
pub const INVENTORY_MUTATION_ID_PREFIX: &str = "invmut_";
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum InventoryContainerKind {
Backpack,
Equipment,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum InventoryItemRarity {
Common,
Uncommon,
Rare,
Epic,
Legendary,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum InventoryEquipmentSlot {
Weapon,
Armor,
Relic,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum InventoryItemSourceKind {
StoryReward,
QuestReward,
TreasureReward,
NpcGift,
NpcTrade,
CombatDrop,
ForgeCraft,
ForgeReforge,
ManualPatch,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct InventoryItemSnapshot {
pub item_id: String,
pub category: String,
pub name: String,
pub description: Option<String>,
pub quantity: u32,
pub rarity: InventoryItemRarity,
pub tags: Vec<String>,
pub stackable: bool,
pub stack_key: String,
pub equipment_slot_id: Option<InventoryEquipmentSlot>,
pub source_kind: InventoryItemSourceKind,
pub source_reference_id: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct InventorySlotSnapshot {
pub slot_id: String,
pub runtime_session_id: String,
pub story_session_id: Option<String>,
pub actor_user_id: String,
pub container_kind: InventoryContainerKind,
pub slot_key: String,
pub item_id: String,
pub category: String,
pub name: String,
pub description: Option<String>,
pub quantity: u32,
pub rarity: InventoryItemRarity,
pub tags: Vec<String>,
pub stackable: bool,
pub stack_key: String,
pub equipment_slot_id: Option<InventoryEquipmentSlot>,
pub source_kind: InventoryItemSourceKind,
pub source_reference_id: Option<String>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct GrantInventoryItemInput {
pub slot_id: String,
pub item: InventoryItemSnapshot,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConsumeInventoryItemInput {
pub slot_id: String,
pub quantity: u32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct EquipInventoryItemInput {
pub slot_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct UnequipInventoryItemInput {
pub slot_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum InventoryMutation {
GrantItem(GrantInventoryItemInput),
ConsumeItem(ConsumeInventoryItemInput),
EquipItem(EquipInventoryItemInput),
UnequipItem(UnequipInventoryItemInput),
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct InventoryMutationInput {
pub mutation_id: String,
pub runtime_session_id: String,
pub story_session_id: Option<String>,
pub actor_user_id: String,
pub mutation: InventoryMutation,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeInventoryStateQueryInput {
pub runtime_session_id: String,
pub actor_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeInventoryStateSnapshot {
pub runtime_session_id: String,
pub actor_user_id: String,
pub backpack_items: Vec<InventorySlotSnapshot>,
pub equipment_items: Vec<InventorySlotSnapshot>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeInventoryStateProcedureResult {
pub ok: bool,
pub snapshot: Option<RuntimeInventoryStateSnapshot>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RuntimeInventorySlotRecord {
pub slot_id: String,
pub container_kind: String,
pub slot_key: String,
pub item_id: String,
pub category: String,
pub name: String,
pub description: Option<String>,
pub quantity: u32,
pub rarity: String,
pub tags: Vec<String>,
pub stackable: bool,
pub stack_key: String,
pub equipment_slot_id: Option<String>,
pub source_kind: String,
pub source_reference_id: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RuntimeInventoryStateRecord {
pub runtime_session_id: String,
pub actor_user_id: String,
pub backpack_items: Vec<RuntimeInventorySlotRecord>,
pub equipment_items: Vec<RuntimeInventorySlotRecord>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct InventoryMutationOutcome {
pub next_slots: Vec<InventorySlotSnapshot>,
pub changed: bool,
pub updated_slot_ids: Vec<String>,
pub removed_slot_ids: Vec<String>,
pub affected_equipment_slot: Option<InventoryEquipmentSlot>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum InventoryMutationFieldError {
MissingMutationId,
MissingRuntimeSessionId,
MissingActorUserId,
MissingSlotId,
MissingItemId,
MissingCategory,
MissingName,
InvalidQuantity,
MissingStackKey,
NonStackableItemMustStaySingleQuantity,
EquipmentItemCannotStack,
SlotScopeMismatch,
ItemNotFound,
ItemNotInBackpack,
ItemNotEquipped,
InsufficientQuantity,
ItemNotEquippable,
}
impl InventoryEquipmentSlot {
pub fn as_str(self) -> &'static str {
match self {
Self::Weapon => "weapon",
Self::Armor => "armor",
Self::Relic => "relic",
}
}
}
pub fn generate_inventory_slot_id(seed_micros: i64) -> String {
build_prefixed_seed_id(INVENTORY_SLOT_ID_PREFIX, seed_micros)
}
pub fn generate_inventory_mutation_id(seed_micros: i64) -> String {
build_prefixed_seed_id(INVENTORY_MUTATION_ID_PREFIX, seed_micros)
}
pub fn normalize_optional_text(value: Option<String>) -> Option<String> {
normalize_shared_optional_string(value)
}
pub fn normalize_string_list(values: Vec<String>) -> Vec<String> {
normalize_shared_string_list(values)
}
pub fn build_runtime_inventory_state_query_input(
runtime_session_id: String,
actor_user_id: String,
) -> Result<RuntimeInventoryStateQueryInput, InventoryMutationFieldError> {
let input = RuntimeInventoryStateQueryInput {
runtime_session_id: normalize_required_text(
runtime_session_id,
InventoryMutationFieldError::MissingRuntimeSessionId,
)?,
actor_user_id: normalize_required_text(
actor_user_id,
InventoryMutationFieldError::MissingActorUserId,
)?,
};
Ok(input)
}
pub fn build_runtime_inventory_state_snapshot(
input: RuntimeInventoryStateQueryInput,
slots: Vec<InventorySlotSnapshot>,
) -> RuntimeInventoryStateSnapshot {
let mut backpack_items = Vec::new();
let mut equipment_items = Vec::new();
for slot in slots {
match slot.container_kind {
InventoryContainerKind::Backpack => backpack_items.push(slot),
InventoryContainerKind::Equipment => equipment_items.push(slot),
}
}
backpack_items.sort_by(|left, right| {
left.slot_key
.cmp(&right.slot_key)
.then(left.slot_id.cmp(&right.slot_id))
});
equipment_items.sort_by(|left, right| {
equipment_slot_order(left.equipment_slot_id)
.cmp(&equipment_slot_order(right.equipment_slot_id))
.then(left.slot_id.cmp(&right.slot_id))
});
RuntimeInventoryStateSnapshot {
runtime_session_id: input.runtime_session_id,
actor_user_id: input.actor_user_id,
backpack_items,
equipment_items,
}
}
pub fn apply_inventory_mutation(
current_slots: Vec<InventorySlotSnapshot>,
input: InventoryMutationInput,
) -> Result<InventoryMutationOutcome, InventoryMutationFieldError> {
let _mutation_id = normalize_required_text(
input.mutation_id,
InventoryMutationFieldError::MissingMutationId,
)?;
let runtime_session_id = normalize_required_text(
input.runtime_session_id,
InventoryMutationFieldError::MissingRuntimeSessionId,
)?;
let actor_user_id = normalize_required_text(
input.actor_user_id,
InventoryMutationFieldError::MissingActorUserId,
)?;
let story_session_id = normalize_optional_text(input.story_session_id);
let mut slots = current_slots;
for slot in &slots {
if slot.runtime_session_id != runtime_session_id || slot.actor_user_id != actor_user_id {
return Err(InventoryMutationFieldError::SlotScopeMismatch);
}
}
let outcome = match input.mutation {
InventoryMutation::GrantItem(grant) => apply_grant_item(
&mut slots,
runtime_session_id,
story_session_id,
actor_user_id,
grant,
input.updated_at_micros,
)?,
InventoryMutation::ConsumeItem(consume) => {
apply_consume_item(&mut slots, consume, input.updated_at_micros)?
}
InventoryMutation::EquipItem(equip) => {
apply_equip_item(&mut slots, equip, input.updated_at_micros)?
}
InventoryMutation::UnequipItem(unequip) => {
apply_unequip_item(&mut slots, unequip, input.updated_at_micros)?
}
};
Ok(InventoryMutationOutcome {
next_slots: sort_inventory_slots(slots),
changed: outcome.changed,
updated_slot_ids: sort_string_list(outcome.updated_slot_ids),
removed_slot_ids: sort_string_list(outcome.removed_slot_ids),
affected_equipment_slot: outcome.affected_equipment_slot,
})
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct InventoryMutationInternalOutcome {
changed: bool,
updated_slot_ids: Vec<String>,
removed_slot_ids: Vec<String>,
affected_equipment_slot: Option<InventoryEquipmentSlot>,
}
fn apply_grant_item(
slots: &mut Vec<InventorySlotSnapshot>,
runtime_session_id: String,
story_session_id: Option<String>,
actor_user_id: String,
grant: GrantInventoryItemInput,
updated_at_micros: i64,
) -> Result<InventoryMutationInternalOutcome, InventoryMutationFieldError> {
let slot_id =
normalize_required_text(grant.slot_id, InventoryMutationFieldError::MissingSlotId)?;
let item = normalize_inventory_item_snapshot(grant.item)?;
if item.stackable {
if let Some(existing) = slots.iter_mut().find(|slot| {
slot.container_kind == InventoryContainerKind::Backpack
&& slot.stackable
&& slot.item_id == item.item_id
&& slot.stack_key == item.stack_key
}) {
existing.category = item.category;
existing.name = item.name;
existing.description = item.description;
existing.quantity += item.quantity;
existing.rarity = item.rarity;
existing.tags = item.tags;
existing.stackable = item.stackable;
existing.stack_key = item.stack_key;
existing.equipment_slot_id = item.equipment_slot_id;
existing.source_kind = item.source_kind;
existing.source_reference_id = item.source_reference_id;
existing.updated_at_micros = updated_at_micros;
return Ok(InventoryMutationInternalOutcome {
changed: true,
updated_slot_ids: vec![existing.slot_id.clone()],
removed_slot_ids: vec![],
affected_equipment_slot: None,
});
}
}
slots.push(InventorySlotSnapshot {
slot_id: slot_id.clone(),
runtime_session_id,
story_session_id,
actor_user_id,
container_kind: InventoryContainerKind::Backpack,
slot_key: build_backpack_slot_key(&slot_id),
item_id: item.item_id,
category: item.category,
name: item.name,
description: item.description,
quantity: item.quantity,
rarity: item.rarity,
tags: item.tags,
stackable: item.stackable,
stack_key: item.stack_key,
equipment_slot_id: item.equipment_slot_id,
source_kind: item.source_kind,
source_reference_id: item.source_reference_id,
created_at_micros: updated_at_micros,
updated_at_micros,
});
Ok(InventoryMutationInternalOutcome {
changed: true,
updated_slot_ids: vec![slot_id],
removed_slot_ids: vec![],
affected_equipment_slot: None,
})
}
fn apply_consume_item(
slots: &mut Vec<InventorySlotSnapshot>,
consume: ConsumeInventoryItemInput,
updated_at_micros: i64,
) -> Result<InventoryMutationInternalOutcome, InventoryMutationFieldError> {
let slot_id =
normalize_required_text(consume.slot_id, InventoryMutationFieldError::MissingSlotId)?;
if consume.quantity == 0 {
return Err(InventoryMutationFieldError::InvalidQuantity);
}
let slot_index = slots
.iter()
.position(|slot| slot.slot_id == slot_id)
.ok_or(InventoryMutationFieldError::ItemNotFound)?;
if slots[slot_index].container_kind != InventoryContainerKind::Backpack {
return Err(InventoryMutationFieldError::ItemNotInBackpack);
}
if slots[slot_index].quantity < consume.quantity {
return Err(InventoryMutationFieldError::InsufficientQuantity);
}
if slots[slot_index].quantity == consume.quantity {
slots.remove(slot_index);
return Ok(InventoryMutationInternalOutcome {
changed: true,
updated_slot_ids: vec![],
removed_slot_ids: vec![slot_id],
affected_equipment_slot: None,
});
}
slots[slot_index].quantity -= consume.quantity;
slots[slot_index].updated_at_micros = updated_at_micros;
Ok(InventoryMutationInternalOutcome {
changed: true,
updated_slot_ids: vec![slots[slot_index].slot_id.clone()],
removed_slot_ids: vec![],
affected_equipment_slot: None,
})
}
fn apply_equip_item(
slots: &mut [InventorySlotSnapshot],
equip: EquipInventoryItemInput,
updated_at_micros: i64,
) -> Result<InventoryMutationInternalOutcome, InventoryMutationFieldError> {
let slot_id =
normalize_required_text(equip.slot_id, InventoryMutationFieldError::MissingSlotId)?;
let source_index = slots
.iter()
.position(|slot| slot.slot_id == slot_id)
.ok_or(InventoryMutationFieldError::ItemNotFound)?;
let target_slot = slots[source_index]
.equipment_slot_id
.ok_or(InventoryMutationFieldError::ItemNotEquippable)?;
if slots[source_index].stackable {
return Err(InventoryMutationFieldError::EquipmentItemCannotStack);
}
if slots[source_index].quantity != 1 {
return Err(InventoryMutationFieldError::NonStackableItemMustStaySingleQuantity);
}
if slots[source_index].container_kind != InventoryContainerKind::Backpack {
if slots[source_index].container_kind == InventoryContainerKind::Equipment {
return Ok(InventoryMutationInternalOutcome {
changed: false,
updated_slot_ids: vec![],
removed_slot_ids: vec![],
affected_equipment_slot: Some(target_slot),
});
}
return Err(InventoryMutationFieldError::ItemNotInBackpack);
}
let occupied_index = slots.iter().position(|slot| {
slot.container_kind == InventoryContainerKind::Equipment
&& slot.slot_key == build_equipment_slot_key(target_slot)
});
let mut updated_slot_ids = vec![slot_id.clone()];
if let Some(occupied_index) = occupied_index {
// 首版装备互换直接在同一条 slot 真相记录上切容器,不生成临时副本。
slots[occupied_index].container_kind = InventoryContainerKind::Backpack;
slots[occupied_index].slot_key = build_backpack_slot_key(&slots[occupied_index].slot_id);
slots[occupied_index].updated_at_micros = updated_at_micros;
updated_slot_ids.push(slots[occupied_index].slot_id.clone());
}
slots[source_index].container_kind = InventoryContainerKind::Equipment;
slots[source_index].slot_key = build_equipment_slot_key(target_slot);
slots[source_index].updated_at_micros = updated_at_micros;
Ok(InventoryMutationInternalOutcome {
changed: true,
updated_slot_ids,
removed_slot_ids: vec![],
affected_equipment_slot: Some(target_slot),
})
}
fn apply_unequip_item(
slots: &mut [InventorySlotSnapshot],
unequip: UnequipInventoryItemInput,
updated_at_micros: i64,
) -> Result<InventoryMutationInternalOutcome, InventoryMutationFieldError> {
let slot_id =
normalize_required_text(unequip.slot_id, InventoryMutationFieldError::MissingSlotId)?;
let slot_index = slots
.iter()
.position(|slot| slot.slot_id == slot_id)
.ok_or(InventoryMutationFieldError::ItemNotFound)?;
if slots[slot_index].container_kind != InventoryContainerKind::Equipment {
return Err(InventoryMutationFieldError::ItemNotEquipped);
}
let affected_equipment_slot = slots[slot_index].equipment_slot_id;
slots[slot_index].container_kind = InventoryContainerKind::Backpack;
slots[slot_index].slot_key = build_backpack_slot_key(&slot_id);
slots[slot_index].updated_at_micros = updated_at_micros;
Ok(InventoryMutationInternalOutcome {
changed: true,
updated_slot_ids: vec![slot_id],
removed_slot_ids: vec![],
affected_equipment_slot,
})
}
fn normalize_inventory_item_snapshot(
item: InventoryItemSnapshot,
) -> Result<InventoryItemSnapshot, InventoryMutationFieldError> {
let item_id =
normalize_required_text(item.item_id, InventoryMutationFieldError::MissingItemId)?;
let category =
normalize_required_text(item.category, InventoryMutationFieldError::MissingCategory)?;
let name = normalize_required_text(item.name, InventoryMutationFieldError::MissingName)?;
if item.quantity == 0 {
return Err(InventoryMutationFieldError::InvalidQuantity);
}
if !item.stackable && item.quantity != 1 {
return Err(InventoryMutationFieldError::NonStackableItemMustStaySingleQuantity);
}
if item.equipment_slot_id.is_some() && item.stackable {
return Err(InventoryMutationFieldError::EquipmentItemCannotStack);
}
let stack_key = if item.stackable {
normalize_required_text(item.stack_key, InventoryMutationFieldError::MissingStackKey)?
} else {
normalize_optional_text(Some(item.stack_key)).unwrap_or_else(|| item_id.clone())
};
Ok(InventoryItemSnapshot {
item_id,
category,
name,
description: normalize_optional_text(item.description),
quantity: item.quantity,
rarity: item.rarity,
tags: normalize_string_list(item.tags),
stackable: item.stackable,
stack_key,
equipment_slot_id: item.equipment_slot_id,
source_kind: item.source_kind,
source_reference_id: normalize_optional_text(item.source_reference_id),
})
}
fn normalize_required_text(
value: String,
error: InventoryMutationFieldError,
) -> Result<String, InventoryMutationFieldError> {
normalize_required_string(value).ok_or(error)
}
fn sort_inventory_slots(mut slots: Vec<InventorySlotSnapshot>) -> Vec<InventorySlotSnapshot> {
slots.sort_by(|left, right| {
container_order(left.container_kind)
.cmp(&container_order(right.container_kind))
.then(left.slot_key.cmp(&right.slot_key))
.then(left.slot_id.cmp(&right.slot_id))
});
slots
}
fn sort_string_list(mut values: Vec<String>) -> Vec<String> {
values.sort();
values
}
fn container_order(kind: InventoryContainerKind) -> u8 {
match kind {
InventoryContainerKind::Equipment => 0,
InventoryContainerKind::Backpack => 1,
}
}
fn equipment_slot_order(slot: Option<InventoryEquipmentSlot>) -> u8 {
match slot {
Some(InventoryEquipmentSlot::Weapon) => 0,
Some(InventoryEquipmentSlot::Armor) => 1,
Some(InventoryEquipmentSlot::Relic) => 2,
None => 3,
}
}
fn build_backpack_slot_key(slot_id: &str) -> String {
slot_id.to_string()
}
fn build_equipment_slot_key(slot: InventoryEquipmentSlot) -> String {
slot.as_str().to_string()
}
pub fn build_runtime_inventory_state_record(
snapshot: RuntimeInventoryStateSnapshot,
) -> RuntimeInventoryStateRecord {
RuntimeInventoryStateRecord {
runtime_session_id: snapshot.runtime_session_id,
actor_user_id: snapshot.actor_user_id,
backpack_items: snapshot
.backpack_items
.into_iter()
.map(build_runtime_inventory_slot_record)
.collect(),
equipment_items: snapshot
.equipment_items
.into_iter()
.map(build_runtime_inventory_slot_record)
.collect(),
}
}
fn build_runtime_inventory_slot_record(slot: InventorySlotSnapshot) -> RuntimeInventorySlotRecord {
RuntimeInventorySlotRecord {
slot_id: slot.slot_id,
container_kind: format_inventory_container_kind(slot.container_kind).to_string(),
slot_key: slot.slot_key,
item_id: slot.item_id,
category: slot.category,
name: slot.name,
description: slot.description,
quantity: slot.quantity,
rarity: format_inventory_item_rarity(slot.rarity).to_string(),
tags: slot.tags,
stackable: slot.stackable,
stack_key: slot.stack_key,
equipment_slot_id: slot
.equipment_slot_id
.map(|value| value.as_str().to_string()),
source_kind: format_inventory_item_source_kind(slot.source_kind).to_string(),
source_reference_id: slot.source_reference_id,
created_at: format_timestamp_micros(slot.created_at_micros),
updated_at: format_timestamp_micros(slot.updated_at_micros),
}
}
fn format_inventory_container_kind(value: InventoryContainerKind) -> &'static str {
match value {
InventoryContainerKind::Backpack => "backpack",
InventoryContainerKind::Equipment => "equipment",
}
}
fn format_inventory_item_rarity(value: InventoryItemRarity) -> &'static str {
match value {
InventoryItemRarity::Common => "common",
InventoryItemRarity::Uncommon => "uncommon",
InventoryItemRarity::Rare => "rare",
InventoryItemRarity::Epic => "epic",
InventoryItemRarity::Legendary => "legendary",
}
}
fn format_inventory_item_source_kind(value: InventoryItemSourceKind) -> &'static str {
match value {
InventoryItemSourceKind::StoryReward => "story_reward",
InventoryItemSourceKind::QuestReward => "quest_reward",
InventoryItemSourceKind::TreasureReward => "treasure_reward",
InventoryItemSourceKind::NpcGift => "npc_gift",
InventoryItemSourceKind::NpcTrade => "npc_trade",
InventoryItemSourceKind::CombatDrop => "combat_drop",
InventoryItemSourceKind::ForgeCraft => "forge_craft",
InventoryItemSourceKind::ForgeReforge => "forge_reforge",
InventoryItemSourceKind::ManualPatch => "manual_patch",
}
}
impl fmt::Display for InventoryMutationFieldError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingMutationId => f.write_str("inventory_mutation.mutation_id 不能为空"),
Self::MissingRuntimeSessionId => {
f.write_str("inventory_mutation.runtime_session_id 不能为空")
}
Self::MissingActorUserId => f.write_str("inventory_mutation.actor_user_id 不能为空"),
Self::MissingSlotId => f.write_str("inventory_slot.slot_id 不能为空"),
Self::MissingItemId => f.write_str("inventory_item.item_id 不能为空"),
Self::MissingCategory => f.write_str("inventory_item.category 不能为空"),
Self::MissingName => f.write_str("inventory_item.name 不能为空"),
Self::InvalidQuantity => f.write_str("inventory_item.quantity 必须大于 0"),
Self::MissingStackKey => f.write_str("可堆叠物品必须提供 stack_key"),
Self::NonStackableItemMustStaySingleQuantity => {
f.write_str("不可堆叠物品必须固定为单槽位单数量")
}
Self::EquipmentItemCannotStack => f.write_str("可装备物品不能标记为 stackable"),
Self::SlotScopeMismatch => {
f.write_str("当前 inventory_slot 不属于本次 mutation 作用域")
}
Self::ItemNotFound => f.write_str("目标 inventory_slot 不存在"),
Self::ItemNotInBackpack => f.write_str("目标物品当前不在背包中"),
Self::ItemNotEquipped => f.write_str("目标物品当前不在装备位上"),
Self::InsufficientQuantity => f.write_str("当前背包数量不足,无法完成扣减"),
Self::ItemNotEquippable => f.write_str("目标物品当前不可装备"),
}
}
}
impl Error for InventoryMutationFieldError {}
#[cfg(test)]
mod tests {
use super::*;
fn build_stackable_item(quantity: u32) -> InventoryItemSnapshot {
InventoryItemSnapshot {
item_id: "consumable_heal_potion".to_string(),
category: "消耗品".to_string(),
name: "疗伤药".to_string(),
description: Some("用于恢复少量气血。".to_string()),
quantity,
rarity: InventoryItemRarity::Common,
tags: vec!["healing".to_string()],
stackable: true,
stack_key: "heal_potion".to_string(),
equipment_slot_id: None,
source_kind: InventoryItemSourceKind::TreasureReward,
source_reference_id: Some("treasure_001".to_string()),
}
}
fn build_weapon_item(slot_id: &str, name: &str) -> InventorySlotSnapshot {
InventorySlotSnapshot {
slot_id: slot_id.to_string(),
runtime_session_id: "runtime_001".to_string(),
story_session_id: Some("storysess_001".to_string()),
actor_user_id: "user_001".to_string(),
container_kind: InventoryContainerKind::Backpack,
slot_key: slot_id.to_string(),
item_id: format!("weapon:{slot_id}"),
category: "武器".to_string(),
name: name.to_string(),
description: Some("测试武器".to_string()),
quantity: 1,
rarity: InventoryItemRarity::Rare,
tags: vec!["weapon".to_string(), "快剑".to_string()],
stackable: false,
stack_key: format!("weapon:{slot_id}"),
equipment_slot_id: Some(InventoryEquipmentSlot::Weapon),
source_kind: InventoryItemSourceKind::StoryReward,
source_reference_id: Some("storyevt_001".to_string()),
created_at_micros: 1,
updated_at_micros: 1,
}
}
fn build_mutation_input(mutation: InventoryMutation) -> InventoryMutationInput {
InventoryMutationInput {
mutation_id: "invmut_001".to_string(),
runtime_session_id: "runtime_001".to_string(),
story_session_id: Some("storysess_001".to_string()),
actor_user_id: "user_001".to_string(),
mutation,
updated_at_micros: 10,
}
}
#[test]
fn grant_item_merges_existing_stackable_slot() {
let current = vec![InventorySlotSnapshot {
slot_id: "invslot_existing".to_string(),
runtime_session_id: "runtime_001".to_string(),
story_session_id: Some("storysess_001".to_string()),
actor_user_id: "user_001".to_string(),
container_kind: InventoryContainerKind::Backpack,
slot_key: "invslot_existing".to_string(),
item_id: "consumable_heal_potion".to_string(),
category: "消耗品".to_string(),
name: "疗伤药".to_string(),
description: None,
quantity: 2,
rarity: InventoryItemRarity::Common,
tags: vec!["healing".to_string()],
stackable: true,
stack_key: "heal_potion".to_string(),
equipment_slot_id: None,
source_kind: InventoryItemSourceKind::TreasureReward,
source_reference_id: Some("treasure_000".to_string()),
created_at_micros: 1,
updated_at_micros: 1,
}];
let outcome = apply_inventory_mutation(
current,
build_mutation_input(InventoryMutation::GrantItem(GrantInventoryItemInput {
slot_id: "invslot_new".to_string(),
item: build_stackable_item(3),
})),
)
.expect("grant should merge stackable row");
assert!(outcome.changed);
assert_eq!(outcome.next_slots.len(), 1);
assert_eq!(outcome.next_slots[0].quantity, 5);
assert_eq!(
outcome.updated_slot_ids,
vec!["invslot_existing".to_string()]
);
}
#[test]
fn grant_non_stackable_item_requires_single_quantity() {
let error = apply_inventory_mutation(
vec![],
build_mutation_input(InventoryMutation::GrantItem(GrantInventoryItemInput {
slot_id: "invslot_weapon".to_string(),
item: InventoryItemSnapshot {
item_id: "weapon_001".to_string(),
category: "武器".to_string(),
name: "试作短剑".to_string(),
description: None,
quantity: 2,
rarity: InventoryItemRarity::Rare,
tags: vec!["weapon".to_string()],
stackable: false,
stack_key: String::new(),
equipment_slot_id: Some(InventoryEquipmentSlot::Weapon),
source_kind: InventoryItemSourceKind::StoryReward,
source_reference_id: None,
},
})),
)
.expect_err("non-stackable item quantity must stay single");
assert_eq!(
error,
InventoryMutationFieldError::NonStackableItemMustStaySingleQuantity
);
}
#[test]
fn consume_item_removes_slot_when_quantity_exhausted() {
let current = vec![InventorySlotSnapshot {
slot_id: "invslot_potion".to_string(),
runtime_session_id: "runtime_001".to_string(),
story_session_id: Some("storysess_001".to_string()),
actor_user_id: "user_001".to_string(),
container_kind: InventoryContainerKind::Backpack,
slot_key: "invslot_potion".to_string(),
item_id: "consumable_heal_potion".to_string(),
category: "消耗品".to_string(),
name: "疗伤药".to_string(),
description: None,
quantity: 1,
rarity: InventoryItemRarity::Common,
tags: vec!["healing".to_string()],
stackable: true,
stack_key: "heal_potion".to_string(),
equipment_slot_id: None,
source_kind: InventoryItemSourceKind::TreasureReward,
source_reference_id: None,
created_at_micros: 1,
updated_at_micros: 1,
}];
let outcome = apply_inventory_mutation(
current,
build_mutation_input(InventoryMutation::ConsumeItem(ConsumeInventoryItemInput {
slot_id: "invslot_potion".to_string(),
quantity: 1,
})),
)
.expect("consume should remove exhausted slot");
assert!(outcome.next_slots.is_empty());
assert_eq!(outcome.removed_slot_ids, vec!["invslot_potion".to_string()]);
}
#[test]
fn equip_item_swaps_existing_equipment_back_to_backpack() {
let equipped = InventorySlotSnapshot {
container_kind: InventoryContainerKind::Equipment,
slot_key: InventoryEquipmentSlot::Weapon.as_str().to_string(),
..build_weapon_item("invslot_old_weapon", "旧佩剑")
};
let backpack_weapon = build_weapon_item("invslot_new_weapon", "逐风短剑");
let outcome = apply_inventory_mutation(
vec![equipped, backpack_weapon],
build_mutation_input(InventoryMutation::EquipItem(EquipInventoryItemInput {
slot_id: "invslot_new_weapon".to_string(),
})),
)
.expect("equip should swap weapon");
assert!(outcome.changed);
assert_eq!(
outcome.affected_equipment_slot,
Some(InventoryEquipmentSlot::Weapon)
);
let weapon_slot = outcome
.next_slots
.iter()
.find(|slot| slot.slot_id == "invslot_new_weapon")
.expect("new weapon slot should exist");
assert_eq!(
weapon_slot.container_kind,
InventoryContainerKind::Equipment
);
assert_eq!(weapon_slot.slot_key, "weapon");
let old_weapon_slot = outcome
.next_slots
.iter()
.find(|slot| slot.slot_id == "invslot_old_weapon")
.expect("old weapon slot should exist");
assert_eq!(
old_weapon_slot.container_kind,
InventoryContainerKind::Backpack
);
assert_eq!(old_weapon_slot.slot_key, "invslot_old_weapon");
}
#[test]
fn unequip_item_moves_equipment_back_to_backpack() {
let equipped = InventorySlotSnapshot {
container_kind: InventoryContainerKind::Equipment,
slot_key: InventoryEquipmentSlot::Relic.as_str().to_string(),
equipment_slot_id: Some(InventoryEquipmentSlot::Relic),
..build_weapon_item("invslot_relic", "旧誓护符")
};
let outcome = apply_inventory_mutation(
vec![equipped],
build_mutation_input(InventoryMutation::UnequipItem(UnequipInventoryItemInput {
slot_id: "invslot_relic".to_string(),
})),
)
.expect("unequip should move relic back to backpack");
assert!(outcome.changed);
assert_eq!(
outcome.affected_equipment_slot,
Some(InventoryEquipmentSlot::Relic)
);
assert_eq!(outcome.next_slots.len(), 1);
assert_eq!(
outcome.next_slots[0].container_kind,
InventoryContainerKind::Backpack
);
assert_eq!(outcome.next_slots[0].slot_key, "invslot_relic");
}
#[test]
fn build_runtime_inventory_state_query_input_trims_scope_fields() {
let input = build_runtime_inventory_state_query_input(
" runtime_001 ".to_string(),
" user_001 ".to_string(),
)
.expect("query input should build");
assert_eq!(input.runtime_session_id, "runtime_001");
assert_eq!(input.actor_user_id, "user_001");
}
#[test]
fn build_runtime_inventory_state_snapshot_splits_backpack_and_equipment() {
let snapshot = build_runtime_inventory_state_snapshot(
RuntimeInventoryStateQueryInput {
runtime_session_id: "runtime_001".to_string(),
actor_user_id: "user_001".to_string(),
},
vec![
InventorySlotSnapshot {
container_kind: InventoryContainerKind::Equipment,
slot_key: "weapon".to_string(),
..build_weapon_item("invslot_weapon", "逐风短剑")
},
InventorySlotSnapshot {
slot_id: "invslot_potion".to_string(),
runtime_session_id: "runtime_001".to_string(),
story_session_id: Some("storysess_001".to_string()),
actor_user_id: "user_001".to_string(),
container_kind: InventoryContainerKind::Backpack,
slot_key: "invslot_potion".to_string(),
item_id: "consumable_heal_potion".to_string(),
category: "消耗品".to_string(),
name: "疗伤药".to_string(),
description: Some("用于恢复少量气血。".to_string()),
quantity: 2,
rarity: InventoryItemRarity::Common,
tags: vec!["healing".to_string()],
stackable: true,
stack_key: "heal_potion".to_string(),
equipment_slot_id: None,
source_kind: InventoryItemSourceKind::TreasureReward,
source_reference_id: Some("treasure_001".to_string()),
created_at_micros: 1,
updated_at_micros: 2,
},
],
);
assert_eq!(snapshot.backpack_items.len(), 1);
assert_eq!(snapshot.equipment_items.len(), 1);
assert_eq!(snapshot.backpack_items[0].slot_id, "invslot_potion");
assert_eq!(snapshot.equipment_items[0].slot_id, "invslot_weapon");
}
}