From 421a24aa6fcd530623ff70f92689dab72d8d0a20 Mon Sep 17 00:00:00 2001 From: The Dax Date: Mon, 14 Jul 2025 16:20:08 -0400 Subject: [PATCH] When adding items to the inventory, prefer existing stacks first before selecting empty slots. -This works for adding items to the inventory in any capacity, including debug and GM commands! --- src/bin/kawari-world.rs | 29 ++++++++++++----------- src/common/gamedata.rs | 8 +++++++ src/inventory/generic.rs | 4 ++++ src/inventory/mod.rs | 50 +++++++++++++++++++++++++++++++++++---- src/lib.rs | 4 ++++ src/world/chat_handler.rs | 22 ++++++++++------- src/world/connection.rs | 29 +++++++++++++++++++---- 7 files changed, 115 insertions(+), 31 deletions(-) diff --git a/src/bin/kawari-world.rs b/src/bin/kawari-world.rs index 244d24d..ff539fc 100644 --- a/src/bin/kawari-world.rs +++ b/src/bin/kawari-world.rs @@ -26,7 +26,7 @@ use kawari::world::{ ClientHandle, Event, FromServer, LuaPlayer, PlayerData, ServerHandle, StatusEffects, ToServer, WorldDatabase, handle_custom_ipc, server_main_loop, }; -use kawari::{RECEIVE_BUFFER_SIZE, TITLE_UNLOCK_BITMASK_SIZE}; +use kawari::{ERR_INVENTORY_ADD_FAILED, RECEIVE_BUFFER_SIZE, TITLE_UNLOCK_BITMASK_SIZE}; use mlua::{Function, Lua}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -895,22 +895,23 @@ async fn client_loop( if let Some(item_info) = result { if connection.player_data.inventory.currency.gil.quantity >= *item_quantity * item_info.price_mid { - connection.player_data.inventory.currency.gil.quantity -= *item_quantity * item_info.price_mid; - // TODO: We need to obtain information on where the item was added, as the client needs to be told. - // For now we hardcode it to the very first inventory slot. - //connection.player_data.inventory.add_in_next_free_slot(Item::new(*item_quantity, item_info.id)); - connection.player_data.inventory.add_in_slot(Item::new(*item_quantity, item_info.id), &ContainerType::Inventory0, 0); + if let Some(add_result) = connection.player_data.inventory.add_in_next_free_slot(Item::new(*item_quantity, item_info.id), item_info.stack_size) { + connection.player_data.inventory.currency.gil.quantity -= *item_quantity * item_info.price_mid; + connection.send_gilshop_item_update(0x07D0, 0, connection.player_data.inventory.currency.gil.quantity, 1).await; - connection.send_gilshop_item_update(0x07D0, 0, connection.player_data.inventory.currency.gil.quantity, 1).await; + connection.send_inventory_ack(u32::MAX, INVENTORY_ACTION_ACK_SHOP as u16).await; - connection.send_inventory_ack(u32::MAX, INVENTORY_ACTION_ACK_SHOP as u16).await; + connection.send_gilshop_item_update(add_result.container as u16, add_result.index, add_result.quantity, item_info.id).await; + connection.send_gilshop_ack(*event_id, item_info.id, *item_quantity, item_info.price_mid).await; - // TODO: This is hardcoded to the first item slot in the inventory, fix this - connection.send_gilshop_item_update(0, 0, *item_quantity, item_info.id).await; - connection.send_gilshop_ack(*event_id, item_info.id, *item_quantity, item_info.price_mid).await; - - let target_id = connection.player_data.target_actorid; - connection.event_scene(&target_id, *event_id, 10, 8193, vec![1, 100]).await; + let target_id = connection.player_data.target_actorid; + // See GenericShopkeeper.lua for information about this scene, the flags, and the params. + connection.event_scene(&target_id, *event_id, 10, 8193, vec![1, 100]).await; + } else { + tracing::error!(ERR_INVENTORY_ADD_FAILED); + connection.send_message(ERR_INVENTORY_ADD_FAILED).await; + connection.event_finish(*event_id).await; + } } else { connection.send_message("Insufficient gil to buy item. Nice try bypassing the client-side check!").await; connection.event_finish(*event_id).await; diff --git a/src/common/gamedata.rs b/src/common/gamedata.rs index 9315f04..a885bdf 100644 --- a/src/common/gamedata.rs +++ b/src/common/gamedata.rs @@ -49,7 +49,10 @@ pub struct ItemInfo { pub price_low: u32, /// The item's equip category. pub equip_category: u8, + /// The item's primary model id. pub primary_model_id: u64, + /// The item's max stack size. + pub stack_size: u32, } #[derive(Debug)] @@ -191,6 +194,10 @@ impl GameData { panic!("Unexpected type!"); }; + let physis::exd::ColumnData::UInt32(stack_size) = &matched_row.columns[20] else { + panic!("Unexpected type!"); + }; + let physis::exd::ColumnData::UInt32(price_mid) = &matched_row.columns[25] else { panic!("Unexpected type!"); }; @@ -210,6 +217,7 @@ impl GameData { price_low: *price_low, equip_category: *equip_category, primary_model_id: *primary_model_id, + stack_size: *stack_size, }; return Some(item_info); diff --git a/src/inventory/generic.rs b/src/inventory/generic.rs index 42be1ea..ba3ae13 100644 --- a/src/inventory/generic.rs +++ b/src/inventory/generic.rs @@ -1,3 +1,4 @@ +use crate::inventory::ContainerType; use serde::{Deserialize, Serialize}; use super::{Item, Storage}; @@ -5,12 +6,15 @@ use super::{Item, Storage}; #[derive(Debug, Clone, Deserialize, Serialize)] pub struct GenericStorage { pub slots: Vec, + #[serde(skip)] + pub kind: ContainerType, } impl Default for GenericStorage { fn default() -> Self { Self { slots: vec![Item::default(); N], + kind: ContainerType::Invalid, } } } diff --git a/src/inventory/mod.rs b/src/inventory/mod.rs index 112a712..4acf58b 100644 --- a/src/inventory/mod.rs +++ b/src/inventory/mod.rs @@ -61,6 +61,12 @@ impl TryFrom for ItemOperationKind { } } +pub struct ItemDestinationInfo { + pub container: ContainerType, + pub index: u16, + pub quantity: u32, +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Inventory { pub equipped: EquippedStorage, @@ -82,9 +88,16 @@ pub struct Inventory { impl Default for Inventory { fn default() -> Self { + // TODO: Set the ContainerType for the others if needed? + // Right now we only use this for adding items to the main inventory. + let mut pages = std::array::from_fn(|_| GenericStorage::default()); + pages[0].kind = ContainerType::Inventory0; + pages[1].kind = ContainerType::Inventory1; + pages[2].kind = ContainerType::Inventory2; + pages[3].kind = ContainerType::Inventory3; Self { equipped: EquippedStorage::default(), - pages: std::array::from_fn(|_| GenericStorage::default()), + pages, armoury_main_hand: GenericStorage::default(), armoury_head: GenericStorage::default(), armoury_body: GenericStorage::default(), @@ -273,15 +286,44 @@ impl Inventory { } } - pub fn add_in_next_free_slot(&mut self, item: Item) { + fn add_in_empty_slot(&mut self, item: Item) -> Option { for page in &mut self.pages { - for slot in &mut page.slots { + for (slot_index, slot) in page.slots.iter_mut().enumerate() { if slot.quantity == 0 { slot.clone_from(&item); - return; + return Some(ItemDestinationInfo { + container: page.kind, + index: slot_index as u16, + quantity: item.quantity, + }); } } } + None + } + + pub fn add_in_next_free_slot( + &mut self, + item: Item, + stack_size: u32, + ) -> Option { + if stack_size > 1 { + for page in &mut self.pages { + for (slot_index, slot) in page.slots.iter_mut().enumerate() { + if slot.id == item.id && slot.quantity + item.quantity <= stack_size { + slot.quantity += item.quantity; + return Some(ItemDestinationInfo { + container: page.kind, + index: slot_index as u16, + quantity: slot.quantity, + }); + } + } + } + } + + // If we didn't find any stacks, or the item isn't stackable, try again to find an empty inventory slot. + self.add_in_empty_slot(item) } pub fn add_in_slot(&mut self, item: Item, container_type: &ContainerType, index: u16) { diff --git a/src/lib.rs b/src/lib.rs index 06d0a93..9fa716b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -115,3 +115,7 @@ pub const INVENTORY_ACTION_ACK_SHOP: u8 = 6; /// In the past, many more values were used according to Sapphire: /// https://github.com/SapphireServer/Sapphire/blob/044bff026c01b4cc3a37cbc9b0881fadca3fc477/src/common/Common.h#L83 pub const INVENTORY_ACTION_ACK_GENERAL: u8 = 7; + +/// Error messages: TODO: this should probably be moved into its own universal mod/crate? +pub const ERR_INVENTORY_ADD_FAILED: &str = + "Unable to add item to inventory! Your inventory is full, or this is a bug in Kawari!"; diff --git a/src/world/chat_handler.rs b/src/world/chat_handler.rs index bf374cf..039dec0 100644 --- a/src/world/chat_handler.rs +++ b/src/world/chat_handler.rs @@ -1,5 +1,5 @@ use crate::{ - ITEM_CONDITION_MAX, + ERR_INVENTORY_ADD_FAILED, ITEM_CONDITION_MAX, common::ItemInfoQuery, inventory::{Item, Storage}, ipc::zone::{ChatMessage, GameMasterRank}, @@ -80,22 +80,28 @@ impl ChatHandler { } "!item" => { let (_, name) = chat_message.message.split_once(' ').unwrap(); - + let mut result = None; { let mut gamedata = connection.gamedata.lock().unwrap(); if let Some(item_info) = gamedata.get_item_info(ItemInfoQuery::ByName(name.to_string())) { - connection - .player_data - .inventory - .add_in_next_free_slot(Item::new(1, item_info.id)); + result = connection.player_data.inventory.add_in_next_free_slot( + Item::new(1, item_info.id), + item_info.stack_size, + ); } } - connection.send_inventory(false).await; - true + if result.is_some() { + connection.send_inventory(false).await; + true + } else { + tracing::error!(ERR_INVENTORY_ADD_FAILED); + connection.send_message(ERR_INVENTORY_ADD_FAILED).await; + true + } } "!reload" => { connection.reload_scripts(); diff --git a/src/world/connection.rs b/src/world/connection.rs index b55e9a8..6f14ad4 100644 --- a/src/world/connection.rs +++ b/src/world/connection.rs @@ -10,8 +10,10 @@ use tokio::net::TcpStream; use crate::{ CLASSJOB_ARRAY_SIZE, COMPLETED_LEVEQUEST_BITMASK_SIZE, COMPLETED_QUEST_BITMASK_SIZE, + ERR_INVENTORY_ADD_FAILED, common::{ - GameData, ObjectId, ObjectTypeId, Position, timestamp_secs, value_to_flag_byte_index_value, + GameData, ItemInfoQuery, ObjectId, ObjectTypeId, Position, timestamp_secs, + value_to_flag_byte_index_value, }, config::{WorldConfig, get_config}, inventory::{ContainerType, Inventory, Item, Storage}, @@ -860,10 +862,27 @@ impl ZoneConnection { } } Task::AddItem { id } => { - self.player_data - .inventory - .add_in_next_free_slot(Item::new(1, *id)); - self.send_inventory(false).await; + let item_info; + { + let mut game_data = self.gamedata.lock().unwrap(); + item_info = game_data.get_item_info(ItemInfoQuery::ById(*id)); + } + if item_info.is_some() { + if self + .player_data + .inventory + .add_in_next_free_slot(Item::new(1, *id), item_info.unwrap().stack_size) + .is_some() + { + self.send_inventory(false).await; + } else { + tracing::error!(ERR_INVENTORY_ADD_FAILED); + self.send_message(ERR_INVENTORY_ADD_FAILED).await; + } + } else { + tracing::error!(ERR_INVENTORY_ADD_FAILED); + self.send_message(ERR_INVENTORY_ADD_FAILED).await; + } } Task::CompleteAllQuests {} => { self.player_data.completed_quests = vec![0xFF; COMPLETED_QUEST_BITMASK_SIZE];