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

Implement better support for buying from gil shops

-You can now purchase multiple things in a row
-It shows the proper log messages
-For the moment all items go to the very first inventory slot, and overwrite each other
This commit is contained in:
The Dax 2025-07-13 13:10:45 -04:00 committed by Joshua Goins
parent efa07bb87f
commit eb9d08866e
6 changed files with 239 additions and 19 deletions

View file

@ -294,6 +294,16 @@
"name": "LevequestCompleteList",
"opcode": 371,
"size": 232
},
{
"name": "GilShopTransactionAck",
"opcode": 974,
"size": 32
},
{
"name": "GilShopRelatedUnk",
"opcode": 435,
"size": 64
}
],
"ClientZoneIpcType": [

View file

@ -4,24 +4,34 @@
-- 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:
--[[ 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, 00000, 8192, {0})
player:play_scene(target, EVENT_ID, SCENE_GREETING, 8192, {0})
end
function onReturn(scene, results, player)
if scene == 0 then
--[[ 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.
]]
player:play_scene(player.id, EVENT_ID, 00010, 1 | 0x2000, {0})
elseif scene == 10 then
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

View file

@ -36,7 +36,7 @@ use tokio::sync::mpsc::{Receiver, UnboundedReceiver, UnboundedSender, channel, u
use tokio::sync::oneshot;
use tokio::task::JoinHandle;
use kawari::INVENTORY_ACTION_ACK_GENERAL;
use kawari::{INVENTORY_ACTION_ACK_GENERAL, INVENTORY_ACTION_ACK_SHOP};
fn spawn_main_loop() -> (ServerHandle, JoinHandle<()>) {
let (send, recv) = channel(64);
@ -910,29 +910,42 @@ async fn client_loop(
}
if let Some(item_info) = result {
if connection.player_data.inventory.currency.gil.quantity >= item_info.price_mid {
// TODO: send the proper response packets!
connection.player_data.inventory.currency.gil.quantity -= item_info.price_mid;
connection.player_data.inventory.add_in_next_free_slot(Item::new(1, item_info.id));
connection.send_inventory(false).await;
// TODO: send an actual system notice, this is just a placeholder to provide feedback that the player actually bought something.
connection.send_message(&format!("You obtained one or more items: {} (id: {})!", item_info.name, item_info.id)).await;
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);
connection.send_gilshop_unk(0x07D0, 0, connection.player_data.inventory.currency.gil.quantity, 1).await;
connection.send_inventory_ack(u32::MAX, INVENTORY_ACTION_ACK_SHOP as u16).await;
// TODO: This is hardcoded to the first item slot in the inventory, fix this
connection.send_gilshop_unk(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;
} else {
connection.send_message("Insufficient gil to buy item. Nice try bypassing the client-side check!").await;
connection.event_finish(*event_id).await;
}
} else {
connection.send_message("Unable to find shop item, this is a bug in Kawari!").await;
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;
} else {
tracing::error!("Received unknown transaction mode {buy_sell_mode}!");
connection.event_finish(*event_id).await;
}
// Cancel the event for now so the client doesn't get stuck
connection.event_finish(*event_id).await;
}
ClientZoneIpcData::StartTalkEvent { actor_id, event_id } => {
connection.player_data.target_actorid = *actor_id;
// load event
{
let ipc = ServerZoneIpcSegment {

View file

@ -284,6 +284,12 @@ impl Inventory {
}
}
pub fn add_in_slot(&mut self, item: Item, container_type: &ContainerType, index: u16) {
let container = self.get_container_mut(container_type);
let slot = container.get_slot_mut(index);
slot.clone_from(&item);
}
fn get_container_mut(&mut self, container_type: &ContainerType) -> &mut dyn Storage {
match container_type {
ContainerType::Inventory0 => &mut self.pages[0],

View file

@ -483,6 +483,37 @@ pub enum ServerZoneIpcData {
#[bw(pad_size_to = 6)]
unk2: Vec<u8>,
},
#[brw(little)]
#[br(pre_assert(*magic == ServerZoneIpcType::GilShopTransactionAck))]
GilShopTransactionAck {
event_id: u32,
/// Always 0x697 when buying?
unk1: u32,
/// Always 3 when buying?
unk2: u32,
item_id: u32,
item_quantity: u32,
#[brw(pad_after = 8)]
total_sale_cost: u32,
},
#[brw(little)]
#[br(pre_assert(*magic == ServerZoneIpcType::GilShopRelatedUnk))]
GilShopRelatedUnk {
/// 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,
#[brw(pad_before = 12, pad_after = 28)]
/// Always 0x7530_0000
unk4: u32,
},
Unknown {
#[br(count = size - 32)]
unk: Vec<u8>,
@ -970,6 +1001,28 @@ mod tests {
unk2: Vec::default(),
},
),
(
ServerZoneIpcType::GilShopTransactionAck,
ServerZoneIpcData::GilShopTransactionAck {
event_id: 0,
unk1: 0,
unk2: 0,
item_id: 0,
item_quantity: 0,
total_sale_cost: 0,
},
),
(
ServerZoneIpcType::GilShopRelatedUnk,
ServerZoneIpcData::GilShopRelatedUnk {
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,
},
),
];
for (opcode, data) in &ipc_types {

View file

@ -20,10 +20,10 @@ use crate::{
zone::{
ActionEffect, ActionRequest, ActionResult, ActorControl, ActorControlCategory,
ActorControlSelf, ActorControlTarget, ClientZoneIpcSegment, CommonSpawn, Config,
ContainerInfo, CurrencyInfo, DisplayFlag, EffectKind, Equip, GameMasterRank, InitZone,
ItemInfo, Move, NpcSpawn, ObjectKind, PlayerStats, PlayerSubKind, QuestActiveList,
ServerZoneIpcData, ServerZoneIpcSegment, StatusEffect, StatusEffectList,
UpdateClassInfo, Warp, WeatherChange,
ContainerInfo, CurrencyInfo, DisplayFlag, EffectKind, Equip, EventScene,
GameMasterRank, InitZone, ItemInfo, Move, NpcSpawn, ObjectKind, PlayerStats,
PlayerSubKind, QuestActiveList, ServerZoneIpcData, ServerZoneIpcSegment, StatusEffect,
StatusEffectList, UpdateClassInfo, Warp, WeatherChange,
},
},
opcodes::ServerZoneIpcType,
@ -84,6 +84,9 @@ pub struct PlayerData {
pub aetherytes: Vec<u8>,
pub completed_quests: Vec<u8>,
pub item_sequence: u32,
pub shop_sequence: u32,
/// Store the target actor id for the purpose of chaining cutscenes.
pub target_actorid: ObjectTypeId,
}
/// Various obsfucation-related bits like the seeds and keys for this connection.
@ -888,7 +891,49 @@ impl ZoneConnection {
}
}
pub async fn event_scene(
&mut self,
target: &ObjectTypeId,
event_id: u32,
scene: u16,
scene_flags: u32,
params: Vec<u32>,
) {
let scene = EventScene {
actor_id: *target,
event_id,
scene,
scene_flags,
params_count: params.len() as u8,
params,
..Default::default()
};
if let Some((op_code, data)) = scene.package_scene() {
let ipc = ServerZoneIpcSegment {
op_code,
timestamp: timestamp_secs(),
data,
..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;
} else {
tracing::error!(
"Unable to play event {event_id}, scene {:?}, scene_flags {scene_flags}!",
scene
);
self.event_finish(event_id).await;
}
}
pub async fn event_finish(&mut self, handler_id: u32) {
self.player_data.target_actorid = ObjectTypeId::default();
// sent event finish
{
let ipc = ServerZoneIpcSegment {
@ -931,6 +976,89 @@ impl ZoneConnection {
}
}
pub async fn send_inventory_ack(&mut self, sequence: u32, action_type: u16) {
let ipc = ServerZoneIpcSegment {
op_code: ServerZoneIpcType::InventoryActionAck,
timestamp: timestamp_secs(),
data: ServerZoneIpcData::InventoryActionAck {
sequence,
action_type,
},
..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;
self.player_data.item_sequence += 1;
}
pub async fn send_gilshop_ack(
&mut self,
event_id: u32,
item_id: u32,
item_quantity: u32,
price_per_item: u32,
) {
let ipc = ServerZoneIpcSegment {
op_code: ServerZoneIpcType::GilShopTransactionAck,
timestamp: timestamp_secs(),
data: ServerZoneIpcData::GilShopTransactionAck {
event_id,
unk1: 0x697,
unk2: 3,
item_id,
item_quantity,
total_sale_cost: item_quantity * price_per_item,
},
..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 send_gilshop_unk(
&mut self,
unk1_and_dst_storage_id: u16,
unk2_and_dst_slot: u16,
gil_and_item_quantity: u32,
unk3_and_item_id: u32,
) {
let ipc = ServerZoneIpcSegment {
op_code: ServerZoneIpcType::GilShopRelatedUnk,
timestamp: timestamp_secs(),
data: ServerZoneIpcData::GilShopRelatedUnk {
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,
},
..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;
self.player_data.shop_sequence += 1;
}
pub async fn begin_log_out(&mut self) {
self.gracefully_logged_out = true;