1
Fork 0
mirror of https://github.com/redstrate/Kawari.git synced 2025-07-23 21:17:45 +00:00

Implement basic support for item levels and start working on migrating to using Item instead of BuyBackItem (#122)

This commit is contained in:
thedax 2025-07-17 21:45:30 -04:00 committed by GitHub
parent 747844e07e
commit 49f7b584b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 258 additions and 47 deletions

View file

@ -166,7 +166,12 @@ async fn client_loop(
// initialize player data if it doesn't exist'
if connection.player_data.actor_id == 0 {
connection.player_data = database.find_player_data(actor_id);
let player_data;
{
let mut game_data = connection.gamedata.lock().unwrap();
player_data = database.find_player_data(actor_id, &mut game_data);
}
connection.player_data = player_data;
}
if connection_type == ConnectionType::Zone {
@ -327,6 +332,12 @@ async fn client_loop(
.await;
}
connection.actor_control_self(ActorControlSelf {
category: ActorControlCategory::SetItemLevel {
level: connection.player_data.inventory.equipped.calculate_item_level() as u32,
}
}).await;
connection.send_quest_information().await;
let zone_id = connection.player_data.zone_id;
@ -806,6 +817,11 @@ async fn client_loop(
// if updated equipped items, we have to process that
if action.src_storage_id == ContainerType::Equipped || action.dst_storage_id == ContainerType::Equipped {
connection.inform_equip().await;
connection.actor_control_self(ActorControlSelf {
category: ActorControlCategory::SetItemLevel {
level: connection.player_data.inventory.equipped.calculate_item_level() as u32,
}
}).await;
}
if action.operation_type == ItemOperationKind::Discard {
@ -859,7 +875,7 @@ async fn client_loop(
if let Some(item_info) = result {
if connection.player_data.inventory.currency.gil.quantity >= *item_quantity * item_info.price_mid {
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) {
if let Some(add_result) = connection.player_data.inventory.add_in_next_free_slot(Item::new(item_info.clone(), *item_quantity)) {
connection.player_data.inventory.currency.gil.quantity -= *item_quantity * item_info.price_mid;
connection.send_gilshop_item_update(ContainerType::Currency as u16, 0, connection.player_data.inventory.currency.gil.quantity, CurrencyKind::Gil as u32).await;
@ -901,6 +917,7 @@ async fn client_loop(
id: item_info.id,
quantity,
price_low: item_info.price_low,
item_level: item_info.item_level,
stack_size: item_info.stack_size,
};
connection.player_data.buyback_list.push_item(*event_id, bb_item);

View file

@ -37,7 +37,7 @@ impl Default for GameData {
}
/// Struct detailing various information about an item, pulled from the Items sheet.
#[derive(Default)]
#[derive(Default, Clone)]
pub struct ItemInfo {
/// The item's textual name.
pub name: String,
@ -53,6 +53,8 @@ pub struct ItemInfo {
pub primary_model_id: u64,
/// The item's max stack size.
pub stack_size: u32,
/// The item's item level.
pub item_level: u16,
}
#[derive(Debug)]
@ -190,6 +192,10 @@ impl GameData {
panic!("Unexpected type!");
};
let physis::exd::ColumnData::UInt16(item_level) = &matched_row.columns[11] else {
panic!("Unexpected type!");
};
let physis::exd::ColumnData::UInt8(equip_category) = &matched_row.columns[17] else {
panic!("Unexpected type!");
};
@ -218,6 +224,7 @@ impl GameData {
equip_category: *equip_category,
primary_model_id: *primary_model_id,
stack_size: *stack_size,
item_level: *item_level,
};
return Some(item_info);

View file

@ -3,6 +3,7 @@ use std::collections::{HashMap, VecDeque};
const BUYBACK_LIST_SIZE: usize = 10;
const BUYBACK_PARAM_COUNT: usize = 22;
// TODO: Deprecate this type, Item can now be expanded to support everything we'll need
#[derive(Clone, Debug, Default)]
pub struct BuyBackItem {
pub id: u32,
@ -11,6 +12,7 @@ pub struct BuyBackItem {
// TODO: there are 22 total things the server keeps track of and sends back to the client, we should implement these!
// Not every value is not fully understood but they appeared to be related to item quality, materia melds, the crafter's name (if applicable), spiritbond/durability, and maybe more.
/// Fields beyond this comment are not part of the 22 datapoints the server sends to the client, but we need them for later item restoration.
pub item_level: u16,
pub stack_size: u32,
}
@ -76,3 +78,16 @@ impl BuyBackList {
params
}
}
// TODO: Once BBItem is deprecated, remove this. This is a transitional impl as we migrate to using Item.
impl BuyBackItem {
pub fn as_item_info(&self) -> crate::common::ItemInfo {
crate::common::ItemInfo {
id: self.id,
item_level: self.item_level,
stack_size: self.stack_size,
price_low: self.price_low,
..Default::default()
}
}
}

View file

@ -1,3 +1,5 @@
use crate::common::ItemInfo;
use serde::{Deserialize, Serialize};
use super::{Item, Storage};
@ -39,6 +41,17 @@ pub enum CurrencyKind {
TrophyCrystal = 36656,
}
// TODO: Should we just pull this from the Item sheet?
// Otherwise, should we not use Default and instead use new with a GameData parameter?
pub enum CurrencyStack {
_CompanySeal = 90000,
_ElementalCrystal = 9999,
Gil = 999_999_999,
_MGP = 9_999_999,
_Tomestone = 2000,
_Pvp = 20000,
}
#[derive(Clone, Copy, Deserialize, Serialize, Debug)]
pub struct CurrencyStorage {
pub gil: Item,
@ -47,7 +60,14 @@ pub struct CurrencyStorage {
impl Default for CurrencyStorage {
fn default() -> Self {
Self {
gil: Item::new(0, CurrencyKind::Gil as u32),
gil: Item::new(
ItemInfo {
id: CurrencyKind::Gil as u32,
stack_size: CurrencyStack::Gil as u32,
..Default::default()
},
0,
),
}
}
}

View file

@ -20,6 +20,39 @@ pub struct EquippedStorage {
pub soul_crystal: Item,
}
impl EquippedStorage {
/// Calculates the player's item level.
/// TODO: This is not accurate, for several reasons.
/// First, it does not take into account if the main hand is a one or two hander.
/// Second, it does not take into account if body armour occupies multiple slots or not (e.g. Herklaedi: cannot equip anything to hands, legs, or feet).
/// There is currently no known way of properly figuring those out. Presumably, the information is somewhere in the Items sheet.
pub fn calculate_item_level(&self) -> u16 {
const DIVISOR: u16 = 13;
const INDEX_BELT: u32 = 5;
const INDEX_SOUL_CRYSTAL: u32 = 13;
let mut level = self.main_hand.item_level;
if !self.off_hand.is_empty_slot() {
level += self.off_hand.item_level;
} else {
// Main hand counts twice if off hand is empty. See comments above why this isn't always correct.
level += self.main_hand.item_level;
}
for index in 2..self.max_slots() {
if index == INDEX_BELT || index == INDEX_SOUL_CRYSTAL {
continue;
}
let item = self.get_slot(index as u16);
level += item.item_level;
}
std::cmp::min(level / DIVISOR, 9999)
}
}
impl Storage for EquippedStorage {
fn max_slots(&self) -> u32 {
14
@ -48,6 +81,7 @@ impl Storage for EquippedStorage {
2 => &mut self.head,
3 => &mut self.body,
4 => &mut self.hands,
5 => &mut self.belt,
6 => &mut self.legs,
7 => &mut self.feet,
8 => &mut self.ears,

View file

@ -1,21 +1,32 @@
use crate::common::ItemInfo;
use crate::ITEM_CONDITION_MAX;
use serde::{Deserialize, Serialize};
/// Represents an item, or if the quanity is zero an empty slot.
/// Represents an item, or if the quantity is zero, an empty slot.
#[derive(Default, Copy, Clone, Serialize, Deserialize, Debug)]
pub struct Item {
pub quantity: u32,
pub id: u32,
pub condition: u16,
pub glamour_catalog_id: u32,
#[serde(skip)]
pub item_level: u16,
#[serde(skip)]
pub stack_size: u32,
#[serde(skip)]
pub price_low: u32,
}
impl Item {
pub fn new(quantity: u32, id: u32) -> Self {
pub fn new(item_info: ItemInfo, quantity: u32) -> Self {
Self {
quantity,
id,
id: item_info.id,
condition: ITEM_CONDITION_MAX,
item_level: item_info.item_level,
stack_size: item_info.stack_size,
price_low: item_info.price_low,
..Default::default()
}
}
@ -27,4 +38,8 @@ impl Item {
}
self.id
}
pub fn is_empty_slot(&self) -> bool {
self.quantity == 0
}
}

View file

@ -1,4 +1,4 @@
use crate::common::GameData;
use crate::common::{GameData, ItemInfoQuery};
use binrw::binrw;
use icarus::{ClassJob::ClassJobSheet, Race::RaceSheet};
use physis::common::Language;
@ -171,7 +171,9 @@ pub fn get_container_type(container_index: u32) -> Option<ContainerType> {
// currency
17 => Some(ContainerType::Currency),
_ => panic!("Inventory iterator invalid or the client sent a very weird packet!"),
_ => panic!(
"Inventory iterator invalid or the client sent a very weird packet! {container_index}"
),
}
}
@ -201,15 +203,45 @@ impl Inventory {
let sheet = ClassJobSheet::read_from(&mut game_data.resource, Language::English).unwrap();
let row = sheet.get_row(classjob_id as u32).unwrap();
self.equipped.main_hand =
Item::new(1, *row.ItemStartingWeapon().into_i32().unwrap() as u32);
let main_hand_id = *row.ItemStartingWeapon().into_i32().unwrap() as u32;
self.equipped.main_hand = Item::new(
game_data
.get_item_info(ItemInfoQuery::ById(main_hand_id))
.unwrap(),
1,
);
// TODO: don't hardcode
self.equipped.ears = Item::new(1, 0x00003b1b);
self.equipped.neck = Item::new(1, 0x00003b1a);
self.equipped.wrists = Item::new(1, 0x00003b1c);
self.equipped.right_ring = Item::new(1, 0x0000114a);
self.equipped.left_ring = Item::new(1, 0x00003b1d);
self.equipped.ears = Item::new(
game_data
.get_item_info(ItemInfoQuery::ById(0x3b1b))
.unwrap(),
1,
);
self.equipped.neck = Item::new(
game_data
.get_item_info(ItemInfoQuery::ById(0x3b1a))
.unwrap(),
1,
);
self.equipped.wrists = Item::new(
game_data
.get_item_info(ItemInfoQuery::ById(0x3b1c))
.unwrap(),
1,
);
self.equipped.right_ring = Item::new(
game_data
.get_item_info(ItemInfoQuery::ById(0x114a))
.unwrap(),
1,
);
self.equipped.left_ring = Item::new(
game_data
.get_item_info(ItemInfoQuery::ById(0x3b1d))
.unwrap(),
1,
);
}
/// Equip the starting items for a given race
@ -217,17 +249,46 @@ impl Inventory {
let sheet = RaceSheet::read_from(&mut game_data.resource, Language::English).unwrap();
let row = sheet.get_row(race_id as u32).unwrap();
if gender == 0 {
self.equipped.body = Item::new(1, *row.RSEMBody().into_i32().unwrap() as u32);
self.equipped.hands = Item::new(1, *row.RSEMHands().into_i32().unwrap() as u32);
self.equipped.legs = Item::new(1, *row.RSEMLegs().into_i32().unwrap() as u32);
self.equipped.feet = Item::new(1, *row.RSEMFeet().into_i32().unwrap() as u32);
let ids: Vec<u32> = if gender == 0 {
vec![
*row.RSEMBody().into_i32().unwrap() as u32,
*row.RSEMHands().into_i32().unwrap() as u32,
*row.RSEMLegs().into_i32().unwrap() as u32,
*row.RSEMFeet().into_i32().unwrap() as u32,
]
} else {
self.equipped.body = Item::new(1, *row.RSEFBody().into_i32().unwrap() as u32);
self.equipped.hands = Item::new(1, *row.RSEFHands().into_i32().unwrap() as u32);
self.equipped.legs = Item::new(1, *row.RSEFLegs().into_i32().unwrap() as u32);
self.equipped.feet = Item::new(1, *row.RSEFFeet().into_i32().unwrap() as u32);
}
vec![
*row.RSEFBody().into_i32().unwrap() as u32,
*row.RSEFHands().into_i32().unwrap() as u32,
*row.RSEFLegs().into_i32().unwrap() as u32,
*row.RSEFFeet().into_i32().unwrap() as u32,
]
};
self.equipped.body = Item::new(
game_data
.get_item_info(ItemInfoQuery::ById(ids[0]))
.unwrap(),
1,
);
self.equipped.hands = Item::new(
game_data
.get_item_info(ItemInfoQuery::ById(ids[1]))
.unwrap(),
1,
);
self.equipped.legs = Item::new(
game_data
.get_item_info(ItemInfoQuery::ById(ids[2]))
.unwrap(),
1,
);
self.equipped.feet = Item::new(
game_data
.get_item_info(ItemInfoQuery::ById(ids[3]))
.unwrap(),
1,
);
}
/// Helper functions to reduce boilerplate
@ -313,15 +374,11 @@ impl Inventory {
None
}
pub fn add_in_next_free_slot(
&mut self,
item: Item,
stack_size: u32,
) -> Option<ItemDestinationInfo> {
if stack_size > 1 {
pub fn add_in_next_free_slot(&mut self, item: Item) -> Option<ItemDestinationInfo> {
if item.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 {
if slot.id == item.id && slot.quantity + item.quantity <= item.stack_size {
slot.quantity += item.quantity;
return Some(ItemDestinationInfo {
container: page.kind,

View file

@ -87,10 +87,10 @@ impl ChatHandler {
if let Some(item_info) =
gamedata.get_item_info(ItemInfoQuery::ByName(name.to_string()))
{
result = connection.player_data.inventory.add_in_next_free_slot(
Item::new(1, item_info.id),
item_info.stack_size,
);
result = connection
.player_data
.inventory
.add_in_next_free_slot(Item::new(item_info, 1));
}
}

View file

@ -478,6 +478,13 @@ impl ZoneConnection {
})
.await;
}
self.actor_control_self(ActorControlSelf {
category: ActorControlCategory::SetItemLevel {
level: self.player_data.inventory.equipped.calculate_item_level() as u32,
},
})
.await;
}
pub async fn warp(&mut self, warp_id: u32) {
@ -929,10 +936,7 @@ impl ZoneConnection {
if self
.player_data
.inventory
.add_in_next_free_slot(
Item::new(*quantity, *id),
item_info.unwrap().stack_size,
)
.add_in_next_free_slot(Item::new(item_info.unwrap(), *quantity))
.is_some()
{
if *send_client_update {

View file

@ -7,7 +7,7 @@ use crate::{
AETHERYTE_UNLOCK_BITMASK_SIZE, CLASSJOB_ARRAY_SIZE, COMPLETED_QUEST_BITMASK_SIZE,
UNLOCK_BITMASK_SIZE,
common::{
CustomizeData, GameData, Position,
CustomizeData, GameData, ItemInfoQuery, Position,
workdefinitions::{CharaMake, ClientSelectData, RemakeMode},
},
inventory::{Inventory, Item, Storage},
@ -182,7 +182,7 @@ impl WorldDatabase {
game_data,
);
let mut player_data = self.find_player_data(actor_id);
let mut player_data = self.find_player_data(actor_id, game_data);
// import jobs
for classjob in &character.classjob_levels {
@ -208,6 +208,7 @@ impl WorldDatabase {
id: item.id,
condition: item.condition,
glamour_catalog_id: item.glamour_id,
..Default::default()
};
}
};
@ -278,7 +279,7 @@ impl WorldDatabase {
tracing::info!("{} added to the world!", character.name);
}
pub fn find_player_data(&self, actor_id: u32) -> PlayerData {
pub fn find_player_data(&self, actor_id: u32, game_data: &mut GameData) -> PlayerData {
let connection = self.connection.lock().unwrap();
let mut stmt = connection
@ -291,7 +292,7 @@ impl WorldDatabase {
stmt = connection
.prepare("SELECT pos_x, pos_y, pos_z, rotation, zone_id, inventory, gm_rank, classjob_id, classjob_levels, classjob_exp, unlocks, aetherytes, completed_quests FROM character_data WHERE content_id = ?1")
.unwrap();
let player_data: PlayerData = stmt
let mut player_data: PlayerData = stmt
.query_row((content_id,), |row| {
Ok(PlayerData {
actor_id,
@ -317,9 +318,50 @@ impl WorldDatabase {
})
.unwrap();
// Before we're finished, we need to populate the items in the inventory with additional static information that we don't bother caching in the db.
self.prepare_player_inventory(&mut player_data.inventory, game_data);
player_data
}
// TODO: Should this and prepare_player_inventory be instead placed somewhere in the inventory modules?
fn prepare_items_in_container(&self, container: &mut impl Storage, data: &mut GameData) {
for index in 0..container.max_slots() {
let item = container.get_slot_mut(index as u16);
if item.is_empty_slot() {
continue;
}
if let Some(info) = data.get_item_info(ItemInfoQuery::ById(item.id)) {
item.item_level = info.item_level;
item.stack_size = info.stack_size;
item.price_low = info.price_low;
// TODO: There will be much more in the future.
}
}
}
fn prepare_player_inventory(&self, inventory: &mut Inventory, data: &mut GameData) {
// TODO: implement iter_mut for Inventory so all of this can be reduced down
for index in 0..inventory.pages.len() {
self.prepare_items_in_container(&mut inventory.pages[index], data);
}
self.prepare_items_in_container(&mut inventory.equipped, data);
self.prepare_items_in_container(&mut inventory.armoury_main_hand, data);
self.prepare_items_in_container(&mut inventory.armoury_body, data);
self.prepare_items_in_container(&mut inventory.armoury_hands, data);
self.prepare_items_in_container(&mut inventory.armoury_legs, data);
self.prepare_items_in_container(&mut inventory.armoury_feet, data);
self.prepare_items_in_container(&mut inventory.armoury_off_hand, data);
self.prepare_items_in_container(&mut inventory.armoury_earring, data);
self.prepare_items_in_container(&mut inventory.armoury_necklace, data);
self.prepare_items_in_container(&mut inventory.armoury_bracelet, data);
self.prepare_items_in_container(&mut inventory.armoury_rings, data);
// Skip soul crystals
}
/// Commit the dynamic player data back to the database
pub fn commit_player_data(&self, data: &PlayerData) {
let connection = self.connection.lock().unwrap();

View file

@ -365,11 +365,11 @@ impl LuaPlayer {
// This is a no-op since we can't edit PlayerData from the Lua side, but we can queue it up afterward.
// We *need* this information, though.
let item_to_restore = Item::new(bb_item.quantity, bb_item.id);
let item_to_restore = Item::new(bb_item.as_item_info(), bb_item.quantity);
let Some(item_dst_info) = self
.player_data
.inventory
.add_in_next_free_slot(item_to_restore, bb_item.stack_size)
.add_in_next_free_slot(item_to_restore)
else {
let error = "Your inventory is full. Unable to restore item.";
self.send_message(error, 0);