mirror of
https://github.com/redstrate/Kawari.git
synced 2025-07-23 21:17:45 +00:00
Implement selling to NPC shops, and its sibling feature, buybacks.
-These had to be co-developed simultaneously. You can't have one without the other, they're that intertwined. -The Lua API was extended extensively to allow for us to pull this off. Some changes include support omitting sending forced client updates for gil and items, and allowing access to the buyback list and queueing updates for it. -Added various enums to reduce the amount of magic numbers everywhere. -The buyback list API is put into its own new file: buyback.rs. -Refactored more portions of the buy and sell code into connection.rs to reduce ipc boilerplate everywhere. -More will be refactored in the future. -The generic shopkeeper has changed so much that it is now its own dedicated script, GilshopKeeper.lua.
This commit is contained in:
parent
c6035f563b
commit
5d2ba057d7
11 changed files with 610 additions and 163 deletions
|
@ -332,6 +332,7 @@ common_events = {
|
|||
-- NPC shops that accept gil for purchasing items
|
||||
generic_gil_shops = {
|
||||
262157, -- Tanie <Florist>, New Gridania
|
||||
262190, -- Blue Lily <Independent Apothecary>, Limsa Lominsa: The Lower Decks
|
||||
262197, -- Gerulf <Independent Culinarian>, Limsa Lominsa: The Lower Decks
|
||||
262735, -- Sorcha <Independent Jeweler> (Limsa Lominsa: The Lower Decks), Battlecraft Accessories
|
||||
262736, -- Sorcha <Independent Jeweler> (Limsa Lominsa: The Lower Decks), Fieldcraft/Tradecraft Accessories
|
||||
|
@ -412,7 +413,7 @@ for _, event_id in pairs(generic_anetshards) do
|
|||
end
|
||||
|
||||
for _, event_id in pairs(generic_gil_shops) do
|
||||
registerEvent(event_id, "events/common/GenericShopkeeper.lua") --TODO: It might be okay to combine gil shops with battle currency shops, still unclear
|
||||
registerEvent(event_id, "events/common/GilShopkeeper.lua")
|
||||
end
|
||||
|
||||
for _, event_id in pairs(generic_currency_exchange) do
|
||||
|
|
|
@ -1,37 +0,0 @@
|
|||
-- TODO: actually implement this menu
|
||||
|
||||
-- Scene 00000: NPC greeting (usually an animation, sometimes text too?)
|
||||
-- Scene 00010: Displays shop interface
|
||||
-- Scene 00255: Unknown, but this was also observed when capturing gil shop transaction packets. When used standalone it softlocks.
|
||||
|
||||
SCENE_GREETING = 00000
|
||||
SCENE_SHOW_SHOP = 00010
|
||||
SCENE_SHOP_END = 00255
|
||||
function onTalk(target, player)
|
||||
--[[ Params observed for SCENE_GREETING:
|
||||
Gil shops: [0, 1]
|
||||
Non- shops: [1, 0]
|
||||
MGP shops: [1, 100]
|
||||
It's unclear what these mean since shops seem to open fine without these.
|
||||
]]
|
||||
player:play_scene(target, EVENT_ID, SCENE_GREETING, 8192, {0})
|
||||
end
|
||||
|
||||
function onReturn(scene, results, player)
|
||||
--[[ Retail sends 221 zeroes as u32s as the params to the shop cutscene, but it opens fine with a single zero u32.
|
||||
Perhaps they are leftovers from earlier expansions? According to Sapphire, the params used to be significantly more complex.
|
||||
Historically, it also seems cutscene 00040 was used instead of 00010 as it is now.
|
||||
When the shop scene finishes and returns control to the server, the server will then have the client play scene 255 with no params.
|
||||
]]
|
||||
if scene == SCENE_GREETING then
|
||||
params = {}
|
||||
for i=1,221 do
|
||||
params[i] = 0
|
||||
end
|
||||
player:play_scene(player.id, EVENT_ID, SCENE_SHOW_SHOP, 1 | 0x2000, params)
|
||||
elseif scene == SCENE_SHOW_SHOP then
|
||||
player:play_scene(player.id, EVENT_ID, SCENE_SHOP_END, 1 | 0x2000, {})
|
||||
else
|
||||
player:finish_event(EVENT_ID)
|
||||
end
|
||||
end
|
43
resources/scripts/events/common/GilShopkeeper.lua
Normal file
43
resources/scripts/events/common/GilShopkeeper.lua
Normal file
|
@ -0,0 +1,43 @@
|
|||
-- Scene 00000: NPC greeting (usually an animation, sometimes text too?)
|
||||
-- Scene 00010: Displays shop interface
|
||||
-- Scene 00255: Unknown, but this was also observed when capturing gil shop transaction packets. When used standalone it softlocks.
|
||||
|
||||
SCENE_GREETING = 00000
|
||||
SCENE_SHOW_SHOP = 00010
|
||||
SCENE_SHOP_END = 00255
|
||||
function onTalk(target, player)
|
||||
--[[ Params observed for SCENE_GREETING:
|
||||
Gil shops: [0, 1]
|
||||
Non- shops: [1, 0]
|
||||
MGP shops: [1, 100]
|
||||
It's unclear what these mean since shops seem to open and function fine without these.
|
||||
]]
|
||||
player:play_scene(target, EVENT_ID, SCENE_GREETING, 8192, {0, 1})
|
||||
end
|
||||
|
||||
function onReturn(scene, results, player)
|
||||
--[[ Retail uses 221 or 222 u32s as the params to the shop cutscene, representing the buyback list and 1 or 2 additional parameters,
|
||||
but it opens fine with a single zero u32 when the buyback list is empty.
|
||||
22 u32s are used to represent the ten buyback items. Most of these values are still unknown in meaning, but they likely relate to melds, crafting signature, durability, and more.
|
||||
Historically, it seems cutscene 00040 was used instead of 00010 as it is now.
|
||||
When the client concludes business with the shop, the scene finishes and returns control to the server. The server will then have the client play scene 255 with no params.
|
||||
]]
|
||||
if scene == SCENE_GREETING then
|
||||
local buyback_list <const> = player:get_buyback_list(EVENT_ID, true)
|
||||
player:play_scene(player.id, EVENT_ID, SCENE_SHOW_SHOP, 1 | 0x2000, buyback_list)
|
||||
elseif scene == SCENE_SHOW_SHOP then
|
||||
local BUYBACK <const> = 3
|
||||
if #results > 0 and results[1] == BUYBACK then -- It shouldn't even be possible to get into a situation where results[1] isn't BUYBACK, but we'll leave it as a guard.
|
||||
local item_index <const> = results[2]
|
||||
player:do_gilshop_buyback(EVENT_ID, item_index)
|
||||
local buyback_list = player:get_buyback_list(EVENT_ID, false)
|
||||
buyback_list[1] = BUYBACK
|
||||
buyback_list[2] = 100 -- Unknown what this 100 represents: a terminator, perhaps? For sell mode it's 0, while buy and buyback are both 100.
|
||||
player:play_scene(player.id, EVENT_ID, SCENE_SHOW_SHOP, 1 | 0x2000, buyback_list)
|
||||
elseif #results == 0 then -- The player closed the shop window.
|
||||
player:play_scene(player.id, EVENT_ID, SCENE_SHOP_END, 1 | 0x2000, {})
|
||||
end
|
||||
else
|
||||
player:finish_event(EVENT_ID)
|
||||
end
|
||||
end
|
|
@ -3,13 +3,17 @@ use std::sync::{Arc, Mutex};
|
|||
use std::time::{Duration, Instant};
|
||||
|
||||
use kawari::common::Position;
|
||||
use kawari::common::{GameData, timestamp_secs};
|
||||
use kawari::common::{GameData, ItemInfoQuery, timestamp_secs};
|
||||
use kawari::config::get_config;
|
||||
use kawari::inventory::{ContainerType, Item, ItemOperationKind};
|
||||
use kawari::inventory::{
|
||||
BuyBackItem, ContainerType, CurrencyKind, Item, ItemOperationKind, get_container_type,
|
||||
};
|
||||
use kawari::ipc::chat::{ServerChatIpcData, ServerChatIpcSegment};
|
||||
use kawari::ipc::zone::{
|
||||
ActorControlCategory, ActorControlSelf, PlayerEntry, PlayerSpawn, PlayerStatus, SocialList,
|
||||
ActorControlCategory, ActorControlSelf, ItemOperation, PlayerEntry, PlayerSpawn, PlayerStatus,
|
||||
SocialList,
|
||||
};
|
||||
|
||||
use kawari::ipc::zone::{
|
||||
ClientTriggerCommand, ClientZoneIpcData, EventStart, GameMasterRank, OnlineStatus,
|
||||
ServerZoneIpcData, ServerZoneIpcSegment, SocialListRequestType,
|
||||
|
@ -26,7 +30,9 @@ use kawari::world::{
|
|||
ClientHandle, Event, FromServer, LuaPlayer, PlayerData, ServerHandle, StatusEffects, ToServer,
|
||||
WorldDatabase, handle_custom_ipc, server_main_loop,
|
||||
};
|
||||
use kawari::{ERR_INVENTORY_ADD_FAILED, RECEIVE_BUFFER_SIZE, TITLE_UNLOCK_BITMASK_SIZE};
|
||||
use kawari::{
|
||||
ERR_INVENTORY_ADD_FAILED, LogMessageType, RECEIVE_BUFFER_SIZE, TITLE_UNLOCK_BITMASK_SIZE,
|
||||
};
|
||||
|
||||
use mlua::{Function, Lua};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
@ -342,7 +348,7 @@ async fn client_loop(
|
|||
ClientZoneIpcData::FinishLoading { .. } => {
|
||||
let common = connection.get_player_common_spawn(connection.exit_position, connection.exit_rotation);
|
||||
|
||||
// tell the server we loaded into the zone, so it can start sending us acors
|
||||
// tell the server we loaded into the zone, so it can start sending us actors
|
||||
connection.handle.send(ToServer::ZoneLoaded(connection.id, connection.zone.as_ref().unwrap().id, common.clone())).await;
|
||||
|
||||
let chara_details = database.find_chara_make(connection.player_data.content_id);
|
||||
|
@ -825,28 +831,6 @@ async fn client_loop(
|
|||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
connection
|
||||
.send_segment(PacketSegment {
|
||||
source_actor: connection.player_data.actor_id,
|
||||
target_actor: connection.player_data.actor_id,
|
||||
segment_type: SegmentType::Ipc,
|
||||
data: SegmentData::Ipc { data: ipc },
|
||||
})
|
||||
.await;
|
||||
|
||||
let ipc = ServerZoneIpcSegment {
|
||||
op_code: ServerZoneIpcType::InventoryTransactionFinish,
|
||||
timestamp: timestamp_secs(),
|
||||
data: ServerZoneIpcData::InventoryTransactionFinish {
|
||||
sequence: connection.player_data.item_sequence,
|
||||
sequence_repeat: connection.player_data.item_sequence,
|
||||
unk1: 0x90,
|
||||
unk2: 0x200,
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
connection
|
||||
.send_segment(PacketSegment {
|
||||
source_actor: connection.player_data.actor_id,
|
||||
|
@ -855,6 +839,7 @@ async fn client_loop(
|
|||
data: SegmentData::Ipc { data: ipc },
|
||||
})
|
||||
.await;
|
||||
connection.send_inventory_transaction_finish(0x90, 0x200).await;
|
||||
}
|
||||
|
||||
connection.player_data.item_sequence += 1;
|
||||
|
@ -876,12 +861,12 @@ async fn client_loop(
|
|||
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) {
|
||||
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(ContainerType::Currency as u16, 0, connection.player_data.inventory.currency.gil.quantity, CurrencyKind::Gil as u32).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;
|
||||
connection.send_gilshop_ack(*event_id, item_info.id, *item_quantity, item_info.price_mid, LogMessageType::ItemBought).await;
|
||||
|
||||
let target_id = connection.player_data.target_actorid;
|
||||
// See GenericShopkeeper.lua for information about this scene, the flags, and the params.
|
||||
|
@ -900,9 +885,113 @@ async fn client_loop(
|
|||
connection.event_finish(*event_id).await;
|
||||
}
|
||||
} else if *buy_sell_mode == SELL {
|
||||
// TODO: Implement selling items back to shops
|
||||
connection.send_message("Selling items to shops is not yet implemented. Cancelling event...").await;
|
||||
connection.event_finish(*event_id).await;
|
||||
let storage = get_container_type(*item_index).unwrap();
|
||||
let index = *item_quantity;
|
||||
let result;
|
||||
let quantity;
|
||||
{
|
||||
let item = connection.player_data.inventory.get_item(storage, index as u16);
|
||||
let mut game_data = connection.gamedata.lock().unwrap();
|
||||
result = game_data.get_item_info(ItemInfoQuery::ById(item.id));
|
||||
quantity = item.quantity;
|
||||
}
|
||||
|
||||
if let Some(item_info) = result {
|
||||
let bb_item = BuyBackItem {
|
||||
id: item_info.id,
|
||||
quantity,
|
||||
price_low: item_info.price_low,
|
||||
stack_size: item_info.stack_size,
|
||||
};
|
||||
connection.player_data.buyback_list.push_item(*event_id, bb_item);
|
||||
|
||||
connection.player_data.inventory.currency.gil.quantity += quantity * item_info.price_low;
|
||||
connection.send_gilshop_item_update(ContainerType::Currency as u16, 0, connection.player_data.inventory.currency.gil.quantity, CurrencyKind::Gil as u32).await;
|
||||
connection.send_gilshop_item_update(storage as u16, index as u16, 0, 0).await;
|
||||
|
||||
// TODO: Refactor InventoryTransactions into connection.rs
|
||||
let ipc = ServerZoneIpcSegment {
|
||||
op_code: ServerZoneIpcType::InventoryTransaction,
|
||||
timestamp: timestamp_secs(),
|
||||
data: ServerZoneIpcData::InventoryTransaction {
|
||||
sequence: connection.player_data.item_sequence,
|
||||
operation_type: ItemOperationKind::UpdateCurrency,
|
||||
src_actor_id: connection.player_data.actor_id,
|
||||
src_storage_id: ContainerType::Currency,
|
||||
src_container_index: 0,
|
||||
src_stack: connection.player_data.inventory.currency.gil.quantity,
|
||||
src_catalog_id: CurrencyKind::Gil as u32,
|
||||
dst_actor_id: INVALID_ACTOR_ID,
|
||||
dummy_container: ContainerType::DiscardingItemSentinel,
|
||||
dst_storage_id: ContainerType::DiscardingItemSentinel,
|
||||
dst_container_index: u16::MAX,
|
||||
dst_stack: 0,
|
||||
dst_catalog_id: 0,
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
connection
|
||||
.send_segment(PacketSegment {
|
||||
source_actor: connection.player_data.actor_id,
|
||||
target_actor: connection.player_data.actor_id,
|
||||
segment_type: SegmentType::Ipc,
|
||||
data: SegmentData::Ipc { data: ipc },
|
||||
})
|
||||
.await;
|
||||
|
||||
// Process the server's inventory first.
|
||||
let action = ItemOperation {
|
||||
operation_type: ItemOperationKind::Discard,
|
||||
src_storage_id: storage,
|
||||
src_container_index: index as u16,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
connection.player_data.inventory.process_action(&action);
|
||||
|
||||
let ipc = ServerZoneIpcSegment {
|
||||
op_code: ServerZoneIpcType::InventoryTransaction,
|
||||
timestamp: timestamp_secs(),
|
||||
data: ServerZoneIpcData::InventoryTransaction {
|
||||
sequence: connection.player_data.item_sequence,
|
||||
operation_type: ItemOperationKind::Discard,
|
||||
src_actor_id: connection.player_data.actor_id,
|
||||
src_storage_id: storage,
|
||||
src_container_index: index as u16,
|
||||
src_stack: quantity,
|
||||
src_catalog_id: item_info.id,
|
||||
dst_actor_id: INVALID_ACTOR_ID,
|
||||
dummy_container: ContainerType::DiscardingItemSentinel,
|
||||
dst_storage_id: ContainerType::DiscardingItemSentinel,
|
||||
dst_container_index: u16::MAX,
|
||||
dst_stack: 0,
|
||||
dst_catalog_id: 0,
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
connection
|
||||
.send_segment(PacketSegment {
|
||||
source_actor: connection.player_data.actor_id,
|
||||
target_actor: connection.player_data.actor_id,
|
||||
segment_type: SegmentType::Ipc,
|
||||
data: SegmentData::Ipc { data: ipc },
|
||||
})
|
||||
.await;
|
||||
|
||||
connection.send_inventory_transaction_finish(0x100, 0x300).await;
|
||||
|
||||
connection.send_gilshop_ack(*event_id, item_info.id, quantity, item_info.price_low, LogMessageType::ItemSold).await;
|
||||
|
||||
let target_id = connection.player_data.target_actorid;
|
||||
|
||||
let mut params = connection.player_data.buyback_list.as_scene_params(*event_id, false);
|
||||
params[0] = SELL;
|
||||
params[1] = 0; // The "terminator" is 0 for sell mode.
|
||||
connection.event_scene(&target_id, *event_id, 10, 8193, params).await;
|
||||
} else {
|
||||
connection.send_message("Unable to find shop item, this is a bug in Kawari!").await;
|
||||
connection.event_finish(*event_id).await;
|
||||
}
|
||||
} else {
|
||||
tracing::error!("Received unknown transaction mode {buy_sell_mode}!");
|
||||
connection.event_finish(*event_id).await;
|
||||
|
|
78
src/inventory/buyback.rs
Normal file
78
src/inventory/buyback.rs
Normal file
|
@ -0,0 +1,78 @@
|
|||
use std::collections::{HashMap, VecDeque};
|
||||
|
||||
const BUYBACK_LIST_SIZE: usize = 10;
|
||||
const BUYBACK_PARAM_COUNT: usize = 22;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct BuyBackItem {
|
||||
pub id: u32,
|
||||
pub quantity: u32,
|
||||
pub price_low: u32,
|
||||
// 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 stack_size: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct BuyBackList {
|
||||
list: HashMap<u32, VecDeque<BuyBackItem>>,
|
||||
}
|
||||
|
||||
impl BuyBackList {
|
||||
pub fn push_item(&mut self, shop_id: u32, item: BuyBackItem) {
|
||||
let vec = self.list.entry(shop_id).or_default();
|
||||
vec.push_front(item);
|
||||
vec.truncate(BUYBACK_LIST_SIZE);
|
||||
}
|
||||
|
||||
pub fn remove_item(&mut self, shop_id: u32, index: u32) {
|
||||
let Some(vec) = self.list.get_mut(&shop_id) else {
|
||||
tracing::warn!(
|
||||
"Attempting to remove an item from a BuyBackList that doesn't have any items! This is likely a bug!"
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
vec.remove(index as usize);
|
||||
}
|
||||
|
||||
pub fn get_buyback_item(&self, shop_id: u32, index: u32) -> Option<&BuyBackItem> {
|
||||
let vec = self.list.get(&shop_id)?;
|
||||
|
||||
vec.get(index as usize)
|
||||
}
|
||||
|
||||
pub fn as_scene_params(&mut self, shop_id: u32, shop_intro: bool) -> Vec<u32> {
|
||||
let mut params = Vec::<u32>::new();
|
||||
|
||||
/* Adjust the params array to be the appropriate size based on what is happening.
|
||||
* The caller is responsible for editing the extra params, as our duty here is to convert our stored information
|
||||
* into u32s so the game client can digest it.
|
||||
* When the shop is first opened we allocate one extra parameter, and all other actions require 2.*/
|
||||
let mut offset: usize;
|
||||
if shop_intro {
|
||||
params.resize(BUYBACK_LIST_SIZE * BUYBACK_PARAM_COUNT + 1, 0u32);
|
||||
offset = 1;
|
||||
} else {
|
||||
params.resize(BUYBACK_LIST_SIZE * BUYBACK_PARAM_COUNT + 2, 0u32);
|
||||
offset = 2;
|
||||
}
|
||||
|
||||
self.list.entry(shop_id).or_default();
|
||||
let shop_buyback_items = self.list.get(&shop_id).unwrap();
|
||||
if !shop_buyback_items.is_empty() {
|
||||
for item in shop_buyback_items {
|
||||
params[offset] = item.id;
|
||||
params[offset + 1] = item.quantity;
|
||||
params[offset + 2] = item.price_low;
|
||||
params[offset + 5] = shop_id;
|
||||
params[offset + 8] = 0x7530_0000; // TODO: What is this? It's not static either, it can change if items have melds or a crafter signature, so right now it's unknown.
|
||||
// TODO: Fill in the rest of the information as it becomes known
|
||||
offset += BUYBACK_PARAM_COUNT;
|
||||
}
|
||||
}
|
||||
|
||||
params
|
||||
}
|
||||
}
|
|
@ -2,6 +2,43 @@ use serde::{Deserialize, Serialize};
|
|||
|
||||
use super::{Item, Storage};
|
||||
|
||||
// TODO: Add society currencies, this is just a good baseline
|
||||
#[repr(u32)]
|
||||
pub enum CurrencyKind {
|
||||
Gil = 1,
|
||||
FireShard,
|
||||
IceShard,
|
||||
WindShard,
|
||||
EarthShard,
|
||||
LightningShard,
|
||||
WaterShard,
|
||||
FireCrystal,
|
||||
IceCrystal,
|
||||
WindCrystal,
|
||||
EarthCrystal,
|
||||
LightningCrystal,
|
||||
WaterCrystal,
|
||||
FireCluster,
|
||||
IceCluster,
|
||||
WindCluster,
|
||||
EarthCluster,
|
||||
LightningCluster,
|
||||
WaterCluster,
|
||||
StormSeal,
|
||||
SerpentSeal,
|
||||
FlameSeal,
|
||||
WolfMark = 25,
|
||||
AlliedSeal,
|
||||
TomestonePoetics,
|
||||
MGP = 29,
|
||||
TomestoneHelio = 47,
|
||||
TomestoneMaths,
|
||||
CenturioSeal = 10307,
|
||||
Venture = 21072,
|
||||
SackOfNuts = 26533,
|
||||
TrophyCrystal = 36656,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Deserialize, Serialize, Debug)]
|
||||
pub struct CurrencyStorage {
|
||||
pub gil: Item,
|
||||
|
@ -10,7 +47,7 @@ pub struct CurrencyStorage {
|
|||
impl Default for CurrencyStorage {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
gil: Item::new(0, 1),
|
||||
gil: Item::new(0, CurrencyKind::Gil as u32),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,9 @@ use serde::{Deserialize, Serialize};
|
|||
|
||||
use crate::ipc::zone::ItemOperation;
|
||||
|
||||
mod buyback;
|
||||
pub use buyback::{BuyBackItem, BuyBackList};
|
||||
|
||||
mod equipped;
|
||||
pub use equipped::EquippedStorage;
|
||||
|
||||
|
@ -19,11 +22,12 @@ mod storage;
|
|||
pub use storage::{ContainerType, Storage};
|
||||
|
||||
mod currency;
|
||||
pub use currency::CurrencyKind;
|
||||
pub use currency::CurrencyStorage;
|
||||
|
||||
use crate::{
|
||||
INVENTORY_ACTION_COMBINE_STACK, INVENTORY_ACTION_DISCARD, INVENTORY_ACTION_EXCHANGE,
|
||||
INVENTORY_ACTION_MOVE, INVENTORY_ACTION_SPLIT_STACK,
|
||||
INVENTORY_ACTION_MOVE, INVENTORY_ACTION_SPLIT_STACK, INVENTORY_ACTION_UPDATE_CURRENCY,
|
||||
};
|
||||
|
||||
const MAX_NORMAL_STORAGE: usize = 35;
|
||||
|
@ -34,6 +38,8 @@ const MAX_LARGE_STORAGE: usize = 50;
|
|||
#[brw(repr = u8)]
|
||||
#[repr(u8)]
|
||||
pub enum ItemOperationKind {
|
||||
/// The operation opcode/type when updating the currency storage.
|
||||
UpdateCurrency = INVENTORY_ACTION_UPDATE_CURRENCY,
|
||||
/// The operation opcode/type when discarding an item from the inventory.
|
||||
Discard = INVENTORY_ACTION_DISCARD,
|
||||
#[default]
|
||||
|
@ -138,6 +144,37 @@ pub struct InventoryIterator<'a> {
|
|||
curr: u16,
|
||||
}
|
||||
|
||||
pub fn get_container_type(container_index: u32) -> Option<ContainerType> {
|
||||
match container_index {
|
||||
// inventory
|
||||
0 => Some(ContainerType::Inventory0),
|
||||
1 => Some(ContainerType::Inventory1),
|
||||
2 => Some(ContainerType::Inventory2),
|
||||
3 => Some(ContainerType::Inventory3),
|
||||
|
||||
// armory
|
||||
4 => Some(ContainerType::ArmoryOffWeapon),
|
||||
5 => Some(ContainerType::ArmoryHead),
|
||||
6 => Some(ContainerType::ArmoryBody),
|
||||
7 => Some(ContainerType::ArmoryHand),
|
||||
8 => Some(ContainerType::ArmoryLeg),
|
||||
9 => Some(ContainerType::ArmoryFoot),
|
||||
10 => Some(ContainerType::ArmoryEarring),
|
||||
11 => Some(ContainerType::ArmoryNeck),
|
||||
12 => Some(ContainerType::ArmoryWrist),
|
||||
13 => Some(ContainerType::ArmoryRing),
|
||||
14 => Some(ContainerType::ArmorySoulCrystal),
|
||||
15 => Some(ContainerType::ArmoryWeapon),
|
||||
|
||||
// equipped
|
||||
16 => Some(ContainerType::Equipped),
|
||||
|
||||
// currency
|
||||
17 => Some(ContainerType::Currency),
|
||||
_ => panic!("Inventory iterator invalid or the client sent a very weird packet!"),
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Iterator for InventoryIterator<'a> {
|
||||
type Item = (ContainerType, &'a dyn Storage);
|
||||
|
||||
|
@ -149,34 +186,7 @@ impl<'a> Iterator for InventoryIterator<'a> {
|
|||
return None;
|
||||
}
|
||||
|
||||
let container_type = match curr {
|
||||
// inventory
|
||||
0 => ContainerType::Inventory0,
|
||||
1 => ContainerType::Inventory1,
|
||||
2 => ContainerType::Inventory2,
|
||||
3 => ContainerType::Inventory3,
|
||||
|
||||
// armory
|
||||
4 => ContainerType::ArmoryOffWeapon,
|
||||
5 => ContainerType::ArmoryHead,
|
||||
6 => ContainerType::ArmoryBody,
|
||||
7 => ContainerType::ArmoryHand,
|
||||
8 => ContainerType::ArmoryLeg,
|
||||
9 => ContainerType::ArmoryFoot,
|
||||
10 => ContainerType::ArmoryEarring,
|
||||
11 => ContainerType::ArmoryNeck,
|
||||
12 => ContainerType::ArmoryWrist,
|
||||
13 => ContainerType::ArmoryRing,
|
||||
14 => ContainerType::ArmorySoulCrystal,
|
||||
15 => ContainerType::ArmoryWeapon,
|
||||
|
||||
// equipped
|
||||
16 => ContainerType::Equipped,
|
||||
|
||||
// currency
|
||||
17 => ContainerType::Currency,
|
||||
_ => panic!("Inventory iterator invalid!"),
|
||||
};
|
||||
let container_type = get_container_type(curr as u32).unwrap();
|
||||
|
||||
Some((
|
||||
container_type,
|
||||
|
@ -226,7 +236,7 @@ impl Inventory {
|
|||
container.get_slot_mut(storage_index)
|
||||
}
|
||||
|
||||
fn get_item(&self, storage_id: ContainerType, storage_index: u16) -> Item {
|
||||
pub fn get_item(&self, storage_id: ContainerType, storage_index: u16) -> Item {
|
||||
let container = self.get_container(&storage_id);
|
||||
*container.get_slot(storage_index)
|
||||
}
|
||||
|
@ -283,6 +293,7 @@ impl Inventory {
|
|||
let src_slot = self.get_item_mut(action.src_storage_id, action.src_container_index);
|
||||
src_slot.clone_from(&dst_item);
|
||||
}
|
||||
_ => todo!(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -510,10 +510,12 @@ pub enum ServerZoneIpcData {
|
|||
#[br(pre_assert(*magic == ServerZoneIpcType::GilShopTransactionAck))]
|
||||
GilShopTransactionAck {
|
||||
event_id: u32,
|
||||
/// Always 0x697 when buying?
|
||||
/// When buying: 0x697
|
||||
/// When selling: 0x698
|
||||
/// When buying back: 0x699
|
||||
message_type: u32,
|
||||
/// Always 3 at gil shops, regardless of the interactions going on
|
||||
unk1: u32,
|
||||
/// Always 3 when buying?
|
||||
unk2: u32,
|
||||
item_id: u32,
|
||||
item_quantity: u32,
|
||||
#[brw(pad_after = 8)]
|
||||
|
@ -525,17 +527,13 @@ pub enum ServerZoneIpcData {
|
|||
/// Starts from zero and increases by one for each of these packets during this gameplay session
|
||||
sequence: u32,
|
||||
#[brw(pad_before = 4)]
|
||||
/// On the first pass, it's 0x07D0, and on the second pass it's the destination storage id
|
||||
unk1_and_dst_storage_id: u16,
|
||||
/// On the first pass it's 0x0000, and on the second pass it's the slot number within the destination storage
|
||||
unk2_and_dst_slot: u16,
|
||||
/// On the first pass, it's the player's remaining gil after the purchase, and on the second pass it's the number of items in that slot after purchase
|
||||
gil_and_item_quantity: u32,
|
||||
/// On the first pass it's 0x0000_0001 and on the second pass it's the item id
|
||||
unk3_and_item_id: u32,
|
||||
dst_storage_id: u16,
|
||||
dst_container_index: u16,
|
||||
dst_stack: u32,
|
||||
dst_catalog_id: u32,
|
||||
#[brw(pad_before = 12, pad_after = 28)]
|
||||
/// Always 0x7530_0000
|
||||
unk4: u32,
|
||||
/// Always 0x7530_0000, this number appears elsewhere in buybacks so it's probably flags, but what they mean is completely unknown for now
|
||||
unk1: u32,
|
||||
},
|
||||
#[br(pre_assert(*magic == ServerZoneIpcType::EffectResult))]
|
||||
EffectResult(EffectResult),
|
||||
|
@ -1043,8 +1041,8 @@ mod tests {
|
|||
ServerZoneIpcType::GilShopTransactionAck,
|
||||
ServerZoneIpcData::GilShopTransactionAck {
|
||||
event_id: 0,
|
||||
message_type: 0,
|
||||
unk1: 0,
|
||||
unk2: 0,
|
||||
item_id: 0,
|
||||
item_quantity: 0,
|
||||
total_sale_cost: 0,
|
||||
|
@ -1054,11 +1052,11 @@ mod tests {
|
|||
ServerZoneIpcType::UpdateInventorySlot,
|
||||
ServerZoneIpcData::UpdateInventorySlot {
|
||||
sequence: 0,
|
||||
unk1_and_dst_storage_id: 0,
|
||||
unk2_and_dst_slot: 0,
|
||||
gil_and_item_quantity: 0,
|
||||
unk3_and_item_id: 0,
|
||||
unk4: 0,
|
||||
dst_storage_id: 0,
|
||||
dst_container_index: 0,
|
||||
dst_stack: 0,
|
||||
dst_catalog_id: 0,
|
||||
unk1: 0,
|
||||
},
|
||||
),
|
||||
(
|
||||
|
|
11
src/lib.rs
11
src/lib.rs
|
@ -93,6 +93,9 @@ pub const INVALID_ACTOR_ID: u32 = 0xE000_0000;
|
|||
|
||||
// These operation codes/types change regularly, so update them when needed!
|
||||
|
||||
/// The operation opcode/type when updating the currency storage.
|
||||
pub const INVENTORY_ACTION_UPDATE_CURRENCY: u8 = 144;
|
||||
|
||||
/// The operation opcode/type when discarding an item from the inventory.
|
||||
pub const INVENTORY_ACTION_DISCARD: u8 = 145;
|
||||
|
||||
|
@ -116,6 +119,14 @@ pub const INVENTORY_ACTION_ACK_SHOP: u8 = 6;
|
|||
/// https://github.com/SapphireServer/Sapphire/blob/044bff026c01b4cc3a37cbc9b0881fadca3fc477/src/common/Common.h#L83
|
||||
pub const INVENTORY_ACTION_ACK_GENERAL: u8 = 7;
|
||||
|
||||
// TODO: Where should this be moved to...?
|
||||
#[repr(u32)]
|
||||
pub enum LogMessageType {
|
||||
ItemBought = 0x697,
|
||||
ItemSold = 0x698,
|
||||
ItemBoughtBack = 0x699,
|
||||
}
|
||||
|
||||
/// 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!";
|
||||
|
|
|
@ -10,13 +10,13 @@ use tokio::net::TcpStream;
|
|||
|
||||
use crate::{
|
||||
CLASSJOB_ARRAY_SIZE, COMPLETED_LEVEQUEST_BITMASK_SIZE, COMPLETED_QUEST_BITMASK_SIZE,
|
||||
ERR_INVENTORY_ADD_FAILED,
|
||||
ERR_INVENTORY_ADD_FAILED, LogMessageType,
|
||||
common::{
|
||||
GameData, INVALID_OBJECT_ID, ItemInfoQuery, ObjectId, ObjectTypeId, Position,
|
||||
timestamp_secs, value_to_flag_byte_index_value,
|
||||
},
|
||||
config::{WorldConfig, get_config},
|
||||
inventory::{ContainerType, Inventory, Item, Storage},
|
||||
inventory::{BuyBackList, ContainerType, Inventory, Item, Storage},
|
||||
ipc::{
|
||||
chat::ServerChatIpcSegment,
|
||||
zone::{
|
||||
|
@ -90,6 +90,8 @@ pub struct PlayerData {
|
|||
pub shop_sequence: u32,
|
||||
/// Store the target actor id for the purpose of chaining cutscenes.
|
||||
pub target_actorid: ObjectTypeId,
|
||||
/// The server-side copy of NPC shop buyback lists.
|
||||
pub buyback_list: BuyBackList,
|
||||
}
|
||||
|
||||
/// Various obsfucation-related bits like the seeds and keys for this connection.
|
||||
|
@ -186,6 +188,7 @@ impl ZoneConnection {
|
|||
self.player_data.curr_mp = 10000;
|
||||
self.player_data.max_mp = 10000;
|
||||
self.player_data.item_sequence = 0;
|
||||
self.player_data.shop_sequence = 0;
|
||||
|
||||
tracing::info!("Client {actor_id} is initializing zone session...");
|
||||
|
||||
|
@ -434,6 +437,9 @@ impl ZoneConnection {
|
|||
.await;
|
||||
}
|
||||
|
||||
// Clear the server's copy of the buyback list.
|
||||
self.player_data.buyback_list = BuyBackList::default();
|
||||
|
||||
// Init Zone
|
||||
{
|
||||
let config = get_config();
|
||||
|
@ -878,9 +884,14 @@ impl ZoneConnection {
|
|||
self.player_data.inventory.currency.get_slot_mut(0).quantity += *amount;
|
||||
self.send_inventory(false).await;
|
||||
}
|
||||
Task::RemoveGil { amount } => {
|
||||
Task::RemoveGil {
|
||||
amount,
|
||||
send_client_update,
|
||||
} => {
|
||||
self.player_data.inventory.currency.get_slot_mut(0).quantity -= *amount;
|
||||
self.send_inventory(false).await;
|
||||
if *send_client_update {
|
||||
self.send_inventory(false).await;
|
||||
}
|
||||
}
|
||||
Task::UnlockOrchestrion { id, on } => {
|
||||
// id == 0 means "all"
|
||||
|
@ -904,7 +915,11 @@ impl ZoneConnection {
|
|||
.await;
|
||||
}
|
||||
}
|
||||
Task::AddItem { id } => {
|
||||
Task::AddItem {
|
||||
id,
|
||||
quantity,
|
||||
send_client_update,
|
||||
} => {
|
||||
let item_info;
|
||||
{
|
||||
let mut game_data = self.gamedata.lock().unwrap();
|
||||
|
@ -914,10 +929,15 @@ impl ZoneConnection {
|
|||
if self
|
||||
.player_data
|
||||
.inventory
|
||||
.add_in_next_free_slot(Item::new(1, *id), item_info.unwrap().stack_size)
|
||||
.add_in_next_free_slot(
|
||||
Item::new(*quantity, *id),
|
||||
item_info.unwrap().stack_size,
|
||||
)
|
||||
.is_some()
|
||||
{
|
||||
self.send_inventory(false).await;
|
||||
if *send_client_update {
|
||||
self.send_inventory(false).await;
|
||||
}
|
||||
} else {
|
||||
tracing::error!(ERR_INVENTORY_ADD_FAILED);
|
||||
self.send_message(ERR_INVENTORY_ADD_FAILED).await;
|
||||
|
@ -940,6 +960,9 @@ impl ZoneConnection {
|
|||
})
|
||||
.await;
|
||||
}
|
||||
Task::UpdateBuyBackList { list } => {
|
||||
self.player_data.buyback_list = list.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
player.queued_tasks.clear();
|
||||
|
@ -1065,14 +1088,15 @@ impl ZoneConnection {
|
|||
item_id: u32,
|
||||
item_quantity: u32,
|
||||
price_per_item: u32,
|
||||
message_type: LogMessageType,
|
||||
) {
|
||||
let ipc = ServerZoneIpcSegment {
|
||||
op_code: ServerZoneIpcType::GilShopTransactionAck,
|
||||
timestamp: timestamp_secs(),
|
||||
data: ServerZoneIpcData::GilShopTransactionAck {
|
||||
event_id,
|
||||
unk1: 0x697,
|
||||
unk2: 3,
|
||||
message_type: message_type as u32,
|
||||
unk1: 3,
|
||||
item_id,
|
||||
item_quantity,
|
||||
total_sale_cost: item_quantity * price_per_item,
|
||||
|
@ -1091,21 +1115,21 @@ impl ZoneConnection {
|
|||
|
||||
pub async fn send_gilshop_item_update(
|
||||
&mut self,
|
||||
unk1_and_dst_storage_id: u16,
|
||||
unk2_and_dst_slot: u16,
|
||||
gil_and_item_quantity: u32,
|
||||
unk3_and_item_id: u32,
|
||||
dst_storage_id: u16,
|
||||
dst_container_index: u16,
|
||||
dst_stack: u32,
|
||||
dst_catalog_id: u32,
|
||||
) {
|
||||
let ipc = ServerZoneIpcSegment {
|
||||
op_code: ServerZoneIpcType::UpdateInventorySlot,
|
||||
timestamp: timestamp_secs(),
|
||||
data: ServerZoneIpcData::UpdateInventorySlot {
|
||||
sequence: self.player_data.shop_sequence,
|
||||
unk1_and_dst_storage_id,
|
||||
unk2_and_dst_slot,
|
||||
gil_and_item_quantity,
|
||||
unk3_and_item_id,
|
||||
unk4: 0x7530_0000,
|
||||
dst_storage_id,
|
||||
dst_container_index,
|
||||
dst_stack,
|
||||
dst_catalog_id,
|
||||
unk1: 0x7530_0000,
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
@ -1121,6 +1145,28 @@ impl ZoneConnection {
|
|||
self.player_data.shop_sequence += 1;
|
||||
}
|
||||
|
||||
pub async fn send_inventory_transaction_finish(&mut self, unk1: u32, unk2: u32) {
|
||||
let ipc = ServerZoneIpcSegment {
|
||||
op_code: ServerZoneIpcType::InventoryTransactionFinish,
|
||||
timestamp: timestamp_secs(),
|
||||
data: ServerZoneIpcData::InventoryTransactionFinish {
|
||||
sequence: self.player_data.item_sequence,
|
||||
sequence_repeat: self.player_data.item_sequence,
|
||||
unk1,
|
||||
unk2,
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
self.send_segment(PacketSegment {
|
||||
source_actor: self.player_data.actor_id,
|
||||
target_actor: self.player_data.actor_id,
|
||||
segment_type: SegmentType::Ipc,
|
||||
data: SegmentData::Ipc { data: ipc },
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
pub async fn begin_log_out(&mut self) {
|
||||
self.gracefully_logged_out = true;
|
||||
|
||||
|
|
220
src/world/lua.rs
220
src/world/lua.rs
|
@ -1,12 +1,15 @@
|
|||
use mlua::{FromLua, Lua, LuaSerdeExt, UserData, UserDataFields, UserDataMethods, Value};
|
||||
|
||||
use crate::INVENTORY_ACTION_ACK_SHOP;
|
||||
use crate::{
|
||||
LogMessageType,
|
||||
common::{
|
||||
INVALID_OBJECT_ID, ObjectId, ObjectTypeId, Position, timestamp_secs,
|
||||
workdefinitions::RemakeMode, write_quantized_rotation,
|
||||
},
|
||||
config::get_config,
|
||||
inventory::{CurrencyStorage, EquippedStorage, GenericStorage, Inventory, Item},
|
||||
inventory::{
|
||||
BuyBackList, ContainerType, CurrencyKind, CurrencyStorage, EquippedStorage, GenericStorage,
|
||||
Inventory, Item,
|
||||
},
|
||||
ipc::zone::{
|
||||
ActionEffect, ActorControlCategory, ActorControlSelf, DamageElement, DamageKind,
|
||||
DamageType, EffectKind, EventScene, ServerZoneIpcData, ServerZoneIpcSegment, Warp,
|
||||
|
@ -15,29 +18,68 @@ use crate::{
|
|||
packet::{PacketSegment, SegmentData, SegmentType},
|
||||
world::ExtraLuaState,
|
||||
};
|
||||
use mlua::{FromLua, Lua, LuaSerdeExt, UserData, UserDataFields, UserDataMethods, Value};
|
||||
|
||||
use super::{PlayerData, StatusEffects, Zone, connection::TeleportQuery};
|
||||
|
||||
pub enum Task {
|
||||
ChangeTerritory { zone_id: u16 },
|
||||
ChangeTerritory {
|
||||
zone_id: u16,
|
||||
},
|
||||
SetRemakeMode(RemakeMode),
|
||||
Warp { warp_id: u32 },
|
||||
Warp {
|
||||
warp_id: u32,
|
||||
},
|
||||
BeginLogOut,
|
||||
FinishEvent { handler_id: u32 },
|
||||
SetClassJob { classjob_id: u8 },
|
||||
WarpAetheryte { aetheryte_id: u32 },
|
||||
FinishEvent {
|
||||
handler_id: u32,
|
||||
},
|
||||
SetClassJob {
|
||||
classjob_id: u8,
|
||||
},
|
||||
WarpAetheryte {
|
||||
aetheryte_id: u32,
|
||||
},
|
||||
ReloadScripts,
|
||||
ToggleInvisibility { invisible: bool },
|
||||
Unlock { id: u32 },
|
||||
UnlockAetheryte { id: u32, on: bool },
|
||||
SetLevel { level: i32 },
|
||||
ChangeWeather { id: u16 },
|
||||
AddGil { amount: u32 },
|
||||
RemoveGil { amount: u32 },
|
||||
UnlockOrchestrion { id: u16, on: bool },
|
||||
AddItem { id: u32 },
|
||||
ToggleInvisibility {
|
||||
invisible: bool,
|
||||
},
|
||||
Unlock {
|
||||
id: u32,
|
||||
},
|
||||
UnlockAetheryte {
|
||||
id: u32,
|
||||
on: bool,
|
||||
},
|
||||
SetLevel {
|
||||
level: i32,
|
||||
},
|
||||
ChangeWeather {
|
||||
id: u16,
|
||||
},
|
||||
AddGil {
|
||||
amount: u32,
|
||||
},
|
||||
RemoveGil {
|
||||
amount: u32,
|
||||
send_client_update: bool,
|
||||
},
|
||||
UnlockOrchestrion {
|
||||
id: u16,
|
||||
on: bool,
|
||||
},
|
||||
AddItem {
|
||||
id: u32,
|
||||
quantity: u32,
|
||||
send_client_update: bool,
|
||||
},
|
||||
CompleteAllQuests {},
|
||||
UnlockContent { id: u16 },
|
||||
UnlockContent {
|
||||
id: u16,
|
||||
},
|
||||
UpdateBuyBackList {
|
||||
list: BuyBackList,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
|
@ -262,8 +304,11 @@ impl LuaPlayer {
|
|||
self.queued_tasks.push(Task::AddGil { amount });
|
||||
}
|
||||
|
||||
fn remove_gil(&mut self, amount: u32) {
|
||||
self.queued_tasks.push(Task::RemoveGil { amount });
|
||||
fn remove_gil(&mut self, amount: u32, send_client_update: bool) {
|
||||
self.queued_tasks.push(Task::RemoveGil {
|
||||
amount,
|
||||
send_client_update,
|
||||
});
|
||||
}
|
||||
|
||||
fn unlock_orchestrion(&mut self, unlocked: u32, id: u16) {
|
||||
|
@ -273,8 +318,12 @@ impl LuaPlayer {
|
|||
});
|
||||
}
|
||||
|
||||
fn add_item(&mut self, id: u32) {
|
||||
self.queued_tasks.push(Task::AddItem { id });
|
||||
fn add_item(&mut self, id: u32, quantity: u32, send_client_update: bool) {
|
||||
self.queued_tasks.push(Task::AddItem {
|
||||
id,
|
||||
quantity,
|
||||
send_client_update,
|
||||
});
|
||||
}
|
||||
|
||||
fn complete_all_quests(&mut self) {
|
||||
|
@ -284,6 +333,112 @@ impl LuaPlayer {
|
|||
fn unlock_content(&mut self, id: u16) {
|
||||
self.queued_tasks.push(Task::UnlockContent { id });
|
||||
}
|
||||
|
||||
fn get_buyback_list(&mut self, shop_id: u32, shop_intro: bool) -> Vec<u32> {
|
||||
let ret = self
|
||||
.player_data
|
||||
.buyback_list
|
||||
.as_scene_params(shop_id, shop_intro);
|
||||
if !shop_intro {
|
||||
self.queued_tasks.push(Task::UpdateBuyBackList {
|
||||
list: self.player_data.buyback_list.clone(),
|
||||
})
|
||||
}
|
||||
ret
|
||||
}
|
||||
|
||||
fn do_gilshop_buyback(&mut self, shop_id: u32, buyback_index: u32) {
|
||||
let bb_item;
|
||||
{
|
||||
let Some(tmp_bb_item) = self
|
||||
.player_data
|
||||
.buyback_list
|
||||
.get_buyback_item(shop_id, buyback_index)
|
||||
else {
|
||||
let error = "Invalid buyback index, ignoring buyback action!";
|
||||
self.send_message(error, 0);
|
||||
tracing::warn!(error);
|
||||
return;
|
||||
};
|
||||
bb_item = tmp_bb_item.clone();
|
||||
}
|
||||
|
||||
// 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 Some(item_dst_info) = self
|
||||
.player_data
|
||||
.inventory
|
||||
.add_in_next_free_slot(item_to_restore, bb_item.stack_size)
|
||||
else {
|
||||
let error = "Your inventory is full. Unable to restore item.";
|
||||
self.send_message(error, 0);
|
||||
tracing::warn!(error);
|
||||
return;
|
||||
};
|
||||
|
||||
// This is a no-op since we can't edit PlayerData from the Lua side,
|
||||
// but we need to do it here so the shopkeeper script doesn't see stale data.
|
||||
self.player_data
|
||||
.buyback_list
|
||||
.remove_item(shop_id, buyback_index);
|
||||
|
||||
// Queue up the item restoration, but we're not going to send an entire inventory update to the client.
|
||||
self.add_item(bb_item.id, item_dst_info.quantity, false);
|
||||
|
||||
// Queue up the player's adjusted gil, but we're not going to send an entire inventory update to the client.
|
||||
let cost = item_dst_info.quantity * bb_item.price_low;
|
||||
let new_gil = self.player_data.inventory.currency.gil.quantity - cost;
|
||||
self.remove_gil(cost, false);
|
||||
|
||||
let shop_packets_to_send = vec![
|
||||
(
|
||||
ServerZoneIpcType::UpdateInventorySlot,
|
||||
ServerZoneIpcData::UpdateInventorySlot {
|
||||
sequence: self.player_data.shop_sequence,
|
||||
dst_storage_id: ContainerType::Currency as u16,
|
||||
dst_container_index: 0,
|
||||
dst_stack: new_gil,
|
||||
dst_catalog_id: CurrencyKind::Gil as u32,
|
||||
unk1: 0x7530_0000,
|
||||
},
|
||||
),
|
||||
(
|
||||
ServerZoneIpcType::InventoryActionAck,
|
||||
ServerZoneIpcData::InventoryActionAck {
|
||||
sequence: u32::MAX,
|
||||
action_type: INVENTORY_ACTION_ACK_SHOP as u16,
|
||||
},
|
||||
),
|
||||
(
|
||||
ServerZoneIpcType::UpdateInventorySlot,
|
||||
ServerZoneIpcData::UpdateInventorySlot {
|
||||
sequence: self.player_data.shop_sequence,
|
||||
dst_storage_id: item_dst_info.container as u16,
|
||||
dst_container_index: item_dst_info.index,
|
||||
dst_stack: item_dst_info.quantity,
|
||||
dst_catalog_id: bb_item.id,
|
||||
unk1: 0x7530_0000,
|
||||
},
|
||||
),
|
||||
(
|
||||
ServerZoneIpcType::GilShopTransactionAck,
|
||||
ServerZoneIpcData::GilShopTransactionAck {
|
||||
event_id: shop_id,
|
||||
message_type: LogMessageType::ItemBoughtBack as u32,
|
||||
unk1: 3,
|
||||
item_id: bb_item.id,
|
||||
item_quantity: item_dst_info.quantity,
|
||||
total_sale_cost: item_dst_info.quantity * bb_item.price_low,
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
// Finally, queue up the packets required to make the magic happen.
|
||||
for (op_code, data) in shop_packets_to_send {
|
||||
self.create_segment_self(op_code, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UserData for LuaPlayer {
|
||||
|
@ -400,15 +555,17 @@ impl UserData for LuaPlayer {
|
|||
Ok(())
|
||||
});
|
||||
methods.add_method_mut("remove_gil", |_, this, amount: u32| {
|
||||
this.remove_gil(amount);
|
||||
// Can't think of any situations where we wouldn't want to force a client currency update after using debug commands.
|
||||
this.remove_gil(amount, true);
|
||||
Ok(())
|
||||
});
|
||||
methods.add_method_mut("unlock_orchestrion", |_, this, (unlock, id): (u32, u16)| {
|
||||
this.unlock_orchestrion(unlock, id);
|
||||
Ok(())
|
||||
});
|
||||
methods.add_method_mut("add_item", |_, this, id: u32| {
|
||||
this.add_item(id);
|
||||
methods.add_method_mut("add_item", |_, this, (id, quantity): (u32, u32)| {
|
||||
// Can't think of any situations where we wouldn't want to force a client inventory update after using debug commands.
|
||||
this.add_item(id, quantity, true);
|
||||
Ok(())
|
||||
});
|
||||
methods.add_method_mut("complete_all_quests", |_, this, _: ()| {
|
||||
|
@ -419,6 +576,19 @@ impl UserData for LuaPlayer {
|
|||
this.unlock_content(id);
|
||||
Ok(())
|
||||
});
|
||||
methods.add_method_mut(
|
||||
"get_buyback_list",
|
||||
|_, this, (shop_id, shop_intro): (u32, bool)| {
|
||||
Ok(this.get_buyback_list(shop_id, shop_intro))
|
||||
},
|
||||
);
|
||||
methods.add_method_mut(
|
||||
"do_gilshop_buyback",
|
||||
|_, this, (shop_id, buyback_index): (u32, u32)| {
|
||||
this.do_gilshop_buyback(shop_id, buyback_index);
|
||||
Ok(())
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn add_fields<F: UserDataFields<Self>>(fields: &mut F) {
|
||||
|
|
Loading…
Add table
Reference in a new issue