1
Fork 0
mirror of https://github.com/redstrate/Kawari.git synced 2025-07-17 10:47:44 +00:00

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!
This commit is contained in:
The Dax 2025-07-14 16:20:08 -04:00 committed by Joshua Goins
parent a5120bb9d6
commit 421a24aa6f
7 changed files with 115 additions and 31 deletions

View file

@ -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;

View file

@ -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);

View file

@ -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<const N: usize> {
pub slots: Vec<Item>,
#[serde(skip)]
pub kind: ContainerType,
}
impl<const N: usize> Default for GenericStorage<N> {
fn default() -> Self {
Self {
slots: vec![Item::default(); N],
kind: ContainerType::Invalid,
}
}
}

View file

@ -61,6 +61,12 @@ impl TryFrom<u8> 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<ItemDestinationInfo> {
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<ItemDestinationInfo> {
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) {

View file

@ -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!";

View file

@ -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();

View file

@ -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];