use std::{ any::Any, sync::{ Arc, atomic::{AtomicBool, AtomicU8, Ordering}, }, }; use crate::inventory::Inventory; use pumpkin_data::{ item_stack::ItemStack, screen::WindowType, statistic::{CustomStatistic, StatisticCategory}, }; use crate::{ player::player_inventory::PlayerInventory, screen_handler::{InventoryPlayer, ScreenHandler, ScreenHandlerBehaviour, offer_or_drop_stack}, slot::{NormalSlot, Slot}, }; type MerchantValidityCheck = Box bool + Send + Sync>; pub struct MerchantScreenHandler { pub inventory: Arc, behaviour: ScreenHandlerBehaviour, selected_offer: usize, active_offer: Option, pub offers: Vec, pub on_trade: Option>, pub on_trade_updated: Option>, pub on_close: Option>, pub validity_check: Option, result_taken: Arc, } impl MerchantScreenHandler { pub fn new( sync_id: u8, player_inventory: &Arc, inventory: Arc, offers: Vec, ) -> Self { let result_taken = Arc::new(AtomicBool::new(false)); let mut behaviour = ScreenHandlerBehaviour::new(sync_id, Some(WindowType::Merchant)); behaviour.container_slots = 3; let mut handler = Self { inventory, behaviour, selected_offer: 0, active_offer: None, offers, on_trade: None, on_trade_updated: None, on_close: None, validity_check: None, result_taken: result_taken.clone(), }; handler.inventory.on_open(); for i in 0..2 { handler.add_slot(Arc::new(NormalSlot::new(handler.inventory.clone(), i))); } handler.add_slot(Arc::new(MerchantResultSlot::new( handler.inventory.clone(), 2, result_taken, ))); let player_inventory: Arc = player_inventory.clone(); handler.add_player_slots(&player_inventory); handler } pub fn set_selected_offer(&mut self, index: usize) { self.selected_offer = index; self.try_move_items(index); self.update_result_slot(); self.notify_trade_updated(); self.send_content_updates(); } fn try_move_items(&mut self, index: usize) { let Some(offer) = self.offers.get(index) else { return; }; let cost_a = offer.base_cost_a.0.as_ref().clone(); let cost_b = offer.cost_b.as_ref().map(|cost| cost.0.as_ref().clone()); let player_slots_end = self.get_behaviour().slots.len() as i32; for index in 0..2 { let mut stack = self.inventory.get_stack(index); if !stack.is_empty() && !self.insert_item(&mut stack, 3, player_slots_end, true) { return; } self.inventory.set_stack(index, stack); } if !self.inventory.get_stack(0).is_empty() || !self.inventory.get_stack(1).is_empty() { return; } self.move_from_inventory_to_payment_slot(0, &cost_a); if let Some(cost_b) = cost_b { self.move_from_inventory_to_payment_slot(1, &cost_b); } } fn adjusted_cost_a(offer: &pumpkin_protocol::java::client::play::MerchantOffer) -> ItemStack { let mut cost = offer.base_cost_a.0.as_ref().clone(); let demand = i32::from(cost.item_count).saturating_mul(offer.demand) as f32; let demand_bonus = (demand * offer.price_multiplier).floor().max(0.0) as i32; let count = i32::from(cost.item_count) .saturating_add(demand_bonus) .saturating_add(offer.special_price) .clamp(1, i32::from(cost.get_max_stack_size())); cost.set_count(count as u8); cost } fn matches_cost(cost: &ItemStack, input: &ItemStack) -> bool { !input.is_empty() && cost.are_items_and_components_equal(input) && input.item_count >= cost.item_count } fn satisfies( offer: &pumpkin_protocol::java::client::play::MerchantOffer, input_a: &ItemStack, input_b: &ItemStack, ) -> bool { Self::matches_cost(&Self::adjusted_cost_a(offer), input_a) && offer.cost_b.as_ref().map_or_else( || input_b.is_empty(), |cost_b| Self::matches_cost(&cost_b.0, input_b), ) } fn input_order( offer: &pumpkin_protocol::java::client::play::MerchantOffer, input_a: &ItemStack, input_b: &ItemStack, ) -> Option { if Self::satisfies(offer, input_a, input_b) { Some(false) } else if Self::satisfies(offer, input_b, input_a) { Some(true) } else { None } } fn matching_offer(&self, input_a: &ItemStack, input_b: &ItemStack) -> Option { if self.selected_offer > 0 && self.selected_offer < self.offers.len() { return Self::satisfies(&self.offers[self.selected_offer], input_a, input_b) .then_some(self.selected_offer); } self.offers .iter() .position(|offer| Self::satisfies(offer, input_a, input_b)) } fn find_active_offer(&self, input_a: &ItemStack, input_b: &ItemStack) -> Option { self.matching_offer(input_a, input_b) .filter(|&index| !self.offers[index].is_out_of_stock()) .or_else(|| { self.matching_offer(input_b, input_a) .filter(|&index| !self.offers[index].is_out_of_stock()) }) } fn consume_payment(&self, slot: usize, count: u8) { let mut stack = self.inventory.get_stack(slot); stack.decrement(count); if stack.is_empty() { stack = ItemStack::EMPTY.clone(); } self.inventory.set_stack(slot, stack); self.get_behaviour().slots[slot].mark_dirty(); } fn complete_trade(&mut self, player: &dyn InventoryPlayer) -> bool { let Some(offer_index) = self.active_offer else { return false; }; let Some(offer) = self.offers.get(offer_index) else { return false; }; if offer.is_out_of_stock() { return false; } let input_a = self.inventory.get_stack(0); let input_b = self.inventory.get_stack(1); let Some(swapped) = Self::input_order(offer, &input_a, &input_b) else { return false; }; let cost_a = Self::adjusted_cost_a(offer).item_count; let cost_b = offer.cost_b.as_ref().map(|cost| cost.0.item_count); self.consume_payment(usize::from(swapped), cost_a); if let Some(cost_b) = cost_b { self.consume_payment(usize::from(!swapped), cost_b); } self.offers[offer_index].uses += 1; if let Some(on_trade) = &self.on_trade { on_trade(offer_index); } player.increment_stat( StatisticCategory::Custom, CustomStatistic::TradedWithVillager as i32, 1, ); true } /// Completes a trade selected through Bedrock's item-stack request protocol. /// /// Bedrock moves the result directly between named containers instead of /// sending Java's slot-click action, so the caller has already validated /// that the result can be inserted into its destination. pub fn complete_bedrock_trade(&mut self, player: &dyn InventoryPlayer) -> bool { if !self.complete_trade(player) { return false; } self.update_result_slot(); self.notify_trade_updated(); self.send_content_updates(); true } fn can_fully_insert(&self, stack: &ItemStack, start: usize, end: usize) -> bool { let mut remaining = stack.item_count; for slot in &self.get_behaviour().slots[start..end] { if !slot.can_insert(stack) { continue; } let existing = slot.get_cloned_stack(); let capacity = if existing.is_empty() { slot.get_max_item_count_for_stack(stack) } else if existing.are_items_and_components_equal(stack) && stack.are_items_and_components_equal(&existing) { slot.get_max_item_count_for_stack(&existing) .saturating_sub(existing.item_count) } else { 0 }; remaining = remaining.saturating_sub(capacity); if remaining == 0 { return true; } } false } fn move_from_inventory_to_payment_slot(&self, payment_slot: usize, cost: &ItemStack) { let mut payment = self.inventory.get_stack(payment_slot); for slot in &self.get_behaviour().slots[3..] { let mut source = slot.get_stack(); if source.is_empty() || source.item.id != cost.item.id || !cost.are_items_and_components_equal(&source) || (!payment.is_empty() && (!source.are_items_and_components_equal(&payment) || !payment.are_items_and_components_equal(&source))) { continue; } if payment.is_empty() { payment = source.copy_with_count(0); } let moved = source .item_count .min(source.get_max_stack_size() - payment.item_count); payment.increment(moved); source.decrement(moved); if source.is_empty() { source = ItemStack::EMPTY.clone(); } slot.set_stack(source); if payment.item_count == payment.get_max_stack_size() { break; } } self.inventory.set_stack(payment_slot, payment); } pub fn update_result_slot(&mut self) { let input_a = self.inventory.get_stack(0); let input_b = self.inventory.get_stack(1); self.active_offer = self.find_active_offer(&input_a, &input_b); if let Some(index) = self.active_offer { self.inventory .set_stack(2, (*self.offers[index].output.0).clone()); } else { self.inventory.set_stack(2, ItemStack::EMPTY.clone()); } } fn notify_trade_updated(&self) { if self.inventory.get_stack(0).is_empty() && self.inventory.get_stack(1).is_empty() { return; } if let Some(on_trade_updated) = &self.on_trade_updated { on_trade_updated(self.active_offer.is_some()); } } } impl ScreenHandler for MerchantScreenHandler { fn as_any(&self) -> &dyn Any { self } fn as_any_mut(&mut self) -> &mut dyn Any { self } fn can_use(&self, player: &dyn InventoryPlayer) -> bool { self.validity_check .as_ref() .is_none_or(|check| check(player)) } fn get_behaviour(&self) -> &ScreenHandlerBehaviour { &self.behaviour } fn get_behaviour_mut(&mut self) -> &mut ScreenHandlerBehaviour { &mut self.behaviour } fn on_closed(&mut self, player: &dyn InventoryPlayer) { if let Some(on_close) = &self.on_close { on_close(); } self.default_on_closed(player); self.inventory.on_close(); // Vanilla drops items from merchant container on close for i in 0..2 { // Drop inputs only, output is virtual/ghost in some sense or just cleared let stack = self.inventory.remove_stack(i); if !stack.is_empty() { offer_or_drop_stack(player, stack); } } // Clear output slot self.inventory.set_stack(2, ItemStack::EMPTY.clone()); } fn quick_move(&mut self, player: &dyn InventoryPlayer, slot_index: i32) -> ItemStack { const PLAYER_INVENTORY_START: i32 = 3; const PLAYER_HOTBAR_START: i32 = 30; let slot = self.get_behaviour().slots[slot_index as usize].clone(); let mut stack = slot.get_cloned_stack(); if stack.is_empty() { return ItemStack::EMPTY.clone(); } let original = stack.clone(); let player_slots_end = self.get_behaviour().slots.len() as i32; if slot_index == 2 { if !self.can_fully_insert( &stack, PLAYER_INVENTORY_START as usize, player_slots_end as usize, ) || !self.insert_item(&mut stack, PLAYER_INVENTORY_START, player_slots_end, true) { return ItemStack::EMPTY.clone(); } } else if slot_index < PLAYER_INVENTORY_START { if !self.insert_item(&mut stack, PLAYER_INVENTORY_START, player_slots_end, false) { return ItemStack::EMPTY.clone(); } } else if slot_index < PLAYER_HOTBAR_START { if !self.insert_item(&mut stack, PLAYER_HOTBAR_START, player_slots_end, false) { return ItemStack::EMPTY.clone(); } } else if !self.insert_item( &mut stack, PLAYER_INVENTORY_START, PLAYER_HOTBAR_START, false, ) { return ItemStack::EMPTY.clone(); } if stack.item_count == original.item_count { return ItemStack::EMPTY.clone(); } if stack.is_empty() { slot.set_stack(ItemStack::EMPTY.clone()); } else { slot.set_stack(stack.clone()); } let mut taken = original.clone(); taken.set_count(original.item_count - stack.item_count); slot.on_take_item(player, &taken); if slot_index == 2 { slot.on_quick_move_crafted(stack, original.clone()); self.result_taken.store(false, Ordering::Relaxed); if !self.complete_trade(player) { return ItemStack::EMPTY.clone(); } self.update_result_slot(); } original } fn on_slot_click( &mut self, slot_index: i32, button: i32, action_type: pumpkin_protocol::java::server::play::SlotActionType, player: &dyn InventoryPlayer, ) { if slot_index == 2 { self.update_result_slot(); } self.result_taken.store(false, Ordering::Relaxed); self.internal_on_slot_click(slot_index, button, action_type, player); if self.result_taken.swap(false, Ordering::Relaxed) { self.complete_trade(player); } self.update_result_slot(); self.notify_trade_updated(); self.send_content_updates(); } } struct MerchantResultSlot { inventory: Arc, index: usize, id: AtomicU8, result_taken: Arc, } impl MerchantResultSlot { fn new(inventory: Arc, index: usize, result_taken: Arc) -> Self { Self { inventory, index, id: AtomicU8::new(0), result_taken, } } } impl Slot for MerchantResultSlot { fn get_inventory(&self) -> Arc { self.inventory.clone() } fn get_index(&self) -> usize { self.index } fn set_id(&self, id: usize) { self.id.store(id as u8, Ordering::Relaxed); } fn take_stack(&self, _amount: u8) -> ItemStack { self.inventory.remove_stack(self.index) } fn on_take_item(&self, _player: &dyn InventoryPlayer, _stack: &ItemStack) { self.result_taken.store(true, Ordering::Relaxed); self.mark_dirty(); } fn can_insert(&self, _stack: &ItemStack) -> bool { false } fn mark_dirty(&self) { self.inventory.mark_dirty(); } } #[cfg(test)] mod tests { use std::{ borrow::Cow, sync::atomic::{AtomicI32, AtomicUsize, Ordering}, }; use crate::inventory::SimpleInventory; use pumpkin_data::{data_component_impl::EquipmentSlot, item::Item}; use pumpkin_protocol::{ codec::item_stack_seralizer::ItemStackSerializer, java::{ client::play::{ CSetContainerContent, CSetContainerProperty, CSetContainerSlot, CSetCursorItem, CSetPlayerInventory, CSetSelectedSlot, }, server::play::SlotActionType, }, }; use std::sync::Mutex; use crate::{entity_equipment::EntityEquipment, screen_handler::InventoryPlayer}; use super::*; fn single_cost_offer( cost: &'static Item, cost_count: u8, output: &'static Item, max_uses: i32, ) -> pumpkin_protocol::java::client::play::MerchantOffer { pumpkin_protocol::java::client::play::MerchantOffer { base_cost_a: ItemStackSerializer(Cow::Owned(ItemStack::new(cost_count, cost))), output: ItemStackSerializer(Cow::Owned(ItemStack::new(1, output))), cost_b: None, reward_exp: true, uses: 0, max_uses, xp: 2, special_price: 0, price_multiplier: 0.05, demand: 0, } } fn bookshelf_offer() -> pumpkin_protocol::java::client::play::MerchantOffer { single_cost_offer(&Item::EMERALD, 9, &Item::BOOKSHELF, 12) } struct TestPlayer { inventory: Arc, experience: AtomicI32, traded: AtomicI32, } impl TestPlayer { fn new(inventory: Arc) -> Self { Self { inventory, experience: AtomicI32::new(0), traded: AtomicI32::new(0), } } } impl InventoryPlayer for TestPlayer { fn as_any(&self) -> &dyn Any { self } fn drop_item(&self, _item: ItemStack, _retain_ownership: bool) {} fn get_inventory(&self) -> Arc { self.inventory.clone() } fn has_infinite_materials(&self) -> bool { false } fn is_creative(&self) -> bool { false } fn experience_level(&self) -> i32 { 0 } fn add_experience_levels(&self, _levels: i32) {} fn enchantment_seed(&self) -> i32 { 0 } fn set_enchantment_seed(&self, _seed: i32) {} fn enqueue_inventory_packet( &self, _packet: &CSetContainerContent, _window_type: Option, ) { } fn enqueue_slot_packet( &self, _packet: &CSetContainerSlot, _window_type: Option, _total_slots: usize, ) { } fn enqueue_cursor_packet(&self, _packet: &CSetCursorItem) {} fn enqueue_property_packet(&self, _packet: &CSetContainerProperty) {} fn enqueue_slot_set_packet(&self, _packet: &CSetPlayerInventory) {} fn enqueue_set_held_item_packet(&self, _packet: &CSetSelectedSlot) {} fn enqueue_equipment_change(&self, _slot: &EquipmentSlot, _stack: &ItemStack) {} fn award_experience(&self, amount: i32) { self.experience.fetch_add(amount, Ordering::Relaxed); } fn increment_stat(&self, category: StatisticCategory, stat_id: i32, amount: i32) { if category == StatisticCategory::Custom && stat_id == CustomStatistic::TradedWithVillager as i32 { self.traded.fetch_add(amount, Ordering::Relaxed); } } fn play_block_sound(&self, _sound: pumpkin_data::sound::Sound, _pitch: f32) {} } fn inventories() -> (Arc, Arc) { ( Arc::new(PlayerInventory::new( Arc::new(Mutex::new(EntityEquipment::new())), Arc::new(rustc_hash::FxHashMap::default()), )), Arc::new(SimpleInventory::new(3)), ) } #[test] fn selecting_bookshelf_trade_moves_payment_and_creates_result() { let (player_inventory, merchant_inventory) = inventories(); player_inventory.set_stack(0, ItemStack::new(12, &Item::EMERALD)); let mut handler = MerchantScreenHandler::new( 1, &player_inventory, merchant_inventory.clone(), vec![bookshelf_offer()], ); handler.set_selected_offer(0); let payment = merchant_inventory.get_stack(0); assert_eq!(payment.item_count, 12); let result = merchant_inventory.get_stack(2); assert_eq!(result.item.id, Item::BOOKSHELF.id); assert!(player_inventory.get_stack(0).is_empty()); } #[test] fn taking_result_commits_payment_after_delivery() { let (player_inventory, merchant_inventory) = inventories(); merchant_inventory.set_stack(0, ItemStack::new(12, &Item::EMERALD)); let player = TestPlayer::new(player_inventory.clone()); let mut handler = MerchantScreenHandler::new( 1, &player_inventory, merchant_inventory.clone(), vec![bookshelf_offer()], ); let trade_count = Arc::new(AtomicUsize::new(0)); handler.on_trade = Some(Box::new({ let trade_count = trade_count.clone(); move |_| { trade_count.fetch_add(1, Ordering::Relaxed); } })); handler.update_result_slot(); handler.on_slot_click(2, 0, SlotActionType::Pickup, &player); assert_eq!(handler.offers[0].uses, 1); assert_eq!(merchant_inventory.get_stack(0).item_count, 3); assert_eq!( handler .get_behaviour() .cursor_stack .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .item .id, Item::BOOKSHELF.id ); assert_eq!(player.traded.load(Ordering::Relaxed), 1); assert_eq!(trade_count.load(Ordering::Relaxed), 1); assert_eq!(player.experience.load(Ordering::Relaxed), 0); } #[test] fn bedrock_result_request_commits_the_trade() { let (player_inventory, merchant_inventory) = inventories(); merchant_inventory.set_stack(0, ItemStack::new(12, &Item::EMERALD)); let player = TestPlayer::new(player_inventory.clone()); let mut handler = MerchantScreenHandler::new( 1, &player_inventory, merchant_inventory.clone(), vec![bookshelf_offer()], ); let trade_count = Arc::new(AtomicUsize::new(0)); handler.on_trade = Some(Box::new({ let trade_count = trade_count.clone(); move |_| { trade_count.fetch_add(1, Ordering::Relaxed); } })); handler.update_result_slot(); assert!(handler.complete_bedrock_trade(&player)); assert_eq!(handler.offers[0].uses, 1); assert_eq!(merchant_inventory.get_stack(0).item_count, 3); assert_eq!(player.traded.load(Ordering::Relaxed), 1); assert_eq!(trade_count.load(Ordering::Relaxed), 1); assert!(merchant_inventory.get_stack(2).is_empty()); } #[test] fn selection_hint_zero_resolves_matching_offer() { let (player_inventory, merchant_inventory) = inventories(); merchant_inventory.set_stack(0, ItemStack::new(64, &Item::PAPER)); let player = TestPlayer::new(player_inventory.clone()); let mut handler = MerchantScreenHandler::new( 1, &player_inventory, merchant_inventory.clone(), vec![ bookshelf_offer(), single_cost_offer(&Item::PAPER, 24, &Item::EMERALD, 16), ], ); let traded_offer = Arc::new(AtomicUsize::new(usize::MAX)); handler.on_trade = Some(Box::new({ let traded_offer = traded_offer.clone(); move |offer_index| { traded_offer.store(offer_index, Ordering::Relaxed); } })); handler.update_result_slot(); handler.on_slot_click(2, 0, SlotActionType::Pickup, &player); assert_eq!(handler.offers[0].uses, 0); assert_eq!(handler.offers[1].uses, 1); assert_eq!(traded_offer.load(Ordering::Relaxed), 1); assert_eq!(merchant_inventory.get_stack(0).item_count, 40); assert_eq!( handler .get_behaviour() .cursor_stack .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .item .id, Item::EMERALD.id ); } #[test] fn invalid_payment_does_not_notify_the_merchant() { let (player_inventory, merchant_inventory) = inventories(); merchant_inventory.set_stack(0, ItemStack::new(8, &Item::EMERALD)); let player = TestPlayer::new(player_inventory.clone()); let mut handler = MerchantScreenHandler::new( 1, &player_inventory, merchant_inventory.clone(), vec![bookshelf_offer()], ); let trade_count = Arc::new(AtomicUsize::new(0)); handler.on_trade = Some(Box::new({ let trade_count = trade_count.clone(); move |_| { trade_count.fetch_add(1, Ordering::Relaxed); } })); handler.on_slot_click(2, 0, SlotActionType::Pickup, &player); assert_eq!(handler.offers[0].uses, 0); assert_eq!(trade_count.load(Ordering::Relaxed), 0); assert_eq!(player.traded.load(Ordering::Relaxed), 0); assert_eq!(merchant_inventory.get_stack(0).item_count, 8); assert!( handler .get_behaviour() .cursor_stack .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .is_empty() ); } #[test] fn full_inventory_does_not_commit_quick_moved_trade() { let (player_inventory, merchant_inventory) = inventories(); player_inventory .main_inventory .write() .unwrap_or_else(std::sync::PoisonError::into_inner) .fill_with(|| ItemStack::new(64, &Item::COBBLESTONE)); merchant_inventory.set_stack(0, ItemStack::new(9, &Item::EMERALD)); let player = TestPlayer::new(player_inventory.clone()); let mut handler = MerchantScreenHandler::new( 1, &player_inventory, merchant_inventory.clone(), vec![bookshelf_offer()], ); handler.update_result_slot(); handler.on_slot_click(2, 0, SlotActionType::QuickMove, &player); assert_eq!(handler.offers[0].uses, 0); assert_eq!(merchant_inventory.get_stack(0).item_count, 9); assert_eq!(merchant_inventory.get_stack(2).item.id, Item::BOOKSHELF.id); assert_eq!(player.traded.load(Ordering::Relaxed), 0); } #[test] fn quick_move_repeats_while_payment_and_stock_remain() { let (player_inventory, merchant_inventory) = inventories(); merchant_inventory.set_stack(0, ItemStack::new(64, &Item::EMERALD)); let player = TestPlayer::new(player_inventory.clone()); let mut handler = MerchantScreenHandler::new( 1, &player_inventory, merchant_inventory.clone(), vec![bookshelf_offer()], ); handler.update_result_slot(); handler.on_slot_click(2, 0, SlotActionType::QuickMove, &player); assert_eq!(handler.offers[0].uses, 7); assert_eq!(player.traded.load(Ordering::Relaxed), 7); assert_eq!(merchant_inventory.get_stack(0).item_count, 1); assert_eq!(player_inventory.get_stack(8).item_count, 7); } #[test] fn swapped_payment_slots_are_accepted_and_consumed() { let (player_inventory, merchant_inventory) = inventories(); let mut offer = bookshelf_offer(); offer.base_cost_a = ItemStackSerializer(Cow::Owned(ItemStack::new(5, &Item::EMERALD))); offer.cost_b = Some(ItemStackSerializer(Cow::Owned(ItemStack::new( 1, &Item::BOOK, )))); merchant_inventory.set_stack(0, ItemStack::new(1, &Item::BOOK)); merchant_inventory.set_stack(1, ItemStack::new(5, &Item::EMERALD)); let player = TestPlayer::new(player_inventory.clone()); let mut handler = MerchantScreenHandler::new( 1, &player_inventory, merchant_inventory.clone(), vec![offer], ); handler.update_result_slot(); handler.on_slot_click(2, 0, SlotActionType::Pickup, &player); assert_eq!(handler.offers[0].uses, 1); assert!(merchant_inventory.get_stack(0).is_empty()); assert!(merchant_inventory.get_stack(1).is_empty()); } }