feat: add Villager trading

currently job selection is missing
This commit is contained in:
Alexander Medvedev 2026-05-01 14:34:05 +02:00
parent 7fe705c7eb
commit fdd2684432
31 changed files with 5943 additions and 60 deletions

File diff suppressed because one or more lines are too long

View File

@ -1015,6 +1015,7 @@ version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"indexmap",
"itoa",
"memchr",
"serde",

View File

@ -12,7 +12,7 @@ heck = "0.5"
md5 = "0.8"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_json = { version = "1.0", features = ["preserve_order"] }
serde_json5 = "0.2"
rayon = "1.11.0"

View File

@ -57,6 +57,7 @@ mod tag;
mod tracked_data;
mod translations;
mod version;
mod villager;
mod world_event;
/// Output directory where all generated Rust source files are written.
@ -101,6 +102,7 @@ pub fn main() {
(entity_status::build, "entity_status.rs"),
(tag::build, "tag.rs"),
(noise_router::build, "noise_router.rs"),
(villager::build, "villager.rs"),
(
flower_pot_transformations::build,
"flower_pot_transformations.rs",

View File

@ -0,0 +1,328 @@
use heck::{ToPascalCase, ToShoutySnakeCase};
use indexmap::IndexMap;
use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use serde::Deserialize;
use std::fs;
#[derive(Deserialize)]
struct VillagerDataJson {
professions: IndexMap<String, ProfessionJson>,
types: IndexMap<String, String>,
trade_sets: IndexMap<String, TradeSetJson>,
villager_trades: IndexMap<String, TradeJson>,
}
#[derive(Deserialize)]
struct ProfessionJson {
name: NameJson,
requested_items: Vec<String>,
work_sound: Option<String>,
trade_sets: IndexMap<String, String>,
}
#[derive(Deserialize)]
struct NameJson {
translate: String,
}
#[derive(Deserialize)]
struct TradeSetJson {
trades: String, // Tag like "#minecraft:armorer/level_1"
amount: f32,
}
#[derive(Deserialize)]
struct TradeJson {
wants: TradeItemJson,
wants_b: Option<TradeItemJson>,
gives: TradeItemJson,
max_uses: Option<f32>,
xp: Option<f32>,
price_multiplier: Option<f32>,
}
#[derive(Deserialize)]
struct TradeItemJson {
id: String,
count: Option<f32>,
}
pub fn build() -> TokenStream {
let data: VillagerDataJson =
serde_json::from_str(&fs::read_to_string("../assets/villager_data.json").unwrap())
.expect("Failed to parse villager_data.json");
let mut profession_variants = Vec::new();
let mut type_variants = Vec::new();
let mut work_sounds = Vec::new();
let mut requested_items = Vec::new();
let mut profession_names = Vec::new();
let mut profession_from_i32 = Vec::new();
let mut type_from_i32 = Vec::new();
let mut trade_set_data = Vec::new();
let mut generated_trade_sets = IndexMap::new();
// Helper to format a trade into TokenStream
let format_trade = |trade: &TradeJson| {
let wants_item = format_ident!(
"{}",
trade.wants.id.strip_prefix("minecraft:").unwrap_or(&trade.wants.id).to_shouty_snake_case()
);
let wants_count = trade.wants.count.unwrap_or(1.0) as i32;
let wants = quote! { VillagerTradeItem { item: &crate::item::Item::#wants_item, count: #wants_count } };
let wants_b = if let Some(b) = &trade.wants_b {
let item = format_ident!(
"{}",
b.id.strip_prefix("minecraft:").unwrap_or(&b.id).to_shouty_snake_case()
);
let count = b.count.unwrap_or(1.0) as i32;
quote! { Some(VillagerTradeItem { item: &crate::item::Item::#item, count: #count }) }
} else {
quote! { None }
};
let gives_item = format_ident!(
"{}",
trade.gives.id.strip_prefix("minecraft:").unwrap_or(&trade.gives.id).to_shouty_snake_case()
);
let gives_count = trade.gives.count.unwrap_or(1.0) as i32;
let gives = quote! { VillagerTradeItem { item: &crate::item::Item::#gives_item, count: #gives_count } };
let max_uses = trade.max_uses.unwrap_or(16.0) as i32;
let xp = trade.xp.unwrap_or(2.0) as i32;
let price_multiplier = trade.price_multiplier.unwrap_or(0.05);
quote! {
VillagerTrade {
wants: #wants,
wants_b: #wants_b,
gives: #gives,
max_uses: #max_uses,
xp: #xp,
price_multiplier: #price_multiplier,
}
}
};
// Pre-process all trade sets mentioned in trade_sets map
for (set_key, set_data) in &data.trade_sets {
let tag = &set_data.trades;
if !tag.starts_with("#minecraft:") {
continue;
}
let tag_content = tag.strip_prefix("#minecraft:").unwrap();
let parts: Vec<&str> = tag_content.split('/').collect();
if parts.len() < 2 {
continue;
}
let prof = parts[0];
let level_str = parts[1].strip_prefix("level_").unwrap_or(parts[1]);
let mut matching_trades = Vec::new();
let prefix = format!("{prof}/{level_str}/");
for (key, trade) in &data.villager_trades {
if key.starts_with(&prefix) {
matching_trades.push(format_trade(trade));
}
}
// Fallback for smiths
if matching_trades.is_empty() && (prof == "armorer" || prof == "toolsmith" || prof == "weaponsmith") {
let smith_prefix = format!("smith/{level_str}/");
for (key, trade) in &data.villager_trades {
if key.starts_with(&smith_prefix) {
matching_trades.push(format_trade(trade));
}
}
}
if !matching_trades.is_empty() {
let ident_name = tag_content.replace('/', "_").to_shouty_snake_case();
let ident = format_ident!("TRADES_{}", ident_name);
trade_set_data.push(quote! {
pub const #ident: &[VillagerTrade] = &[
#(#matching_trades),*
];
});
generated_trade_sets.insert(tag.clone(), ident);
}
}
let mut profession_trade_sets = Vec::new();
for (i, (name, prof_data)) in data.professions.iter().enumerate() {
let ident = format_ident!("{}", name.to_pascal_case());
profession_variants.push(quote! { #ident });
let sound = if let Some(sound) = &prof_data.work_sound {
let sound_ident = format_ident!(
"{}",
sound
.strip_prefix("minecraft:")
.unwrap_or(sound)
.replace('.', "_")
.to_pascal_case()
);
quote! { Some(crate::sound::Sound::#sound_ident) }
} else {
quote! { None }
};
work_sounds.push(quote! { Self::#ident => #sound });
let items: Vec<_> = prof_data
.requested_items
.iter()
.map(|i| {
let item_ident = format_ident!(
"{}",
i.strip_prefix("minecraft:")
.unwrap_or(i)
.to_shouty_snake_case()
);
quote! { &crate::item::Item::#item_ident }
})
.collect();
requested_items.push(quote! { Self::#ident => &[#(#items),*] });
let translate = &prof_data.name.translate;
profession_names.push(quote! { Self::#ident => #translate });
let i = i as i32;
profession_from_i32.push(quote! { #i => Some(Self::#ident) });
let mut level_matches = Vec::new();
for (level_str, set_key) in &prof_data.trade_sets {
let level = level_str.parse::<i32>().unwrap();
let set_key_clean = set_key.strip_prefix("minecraft:").unwrap_or(set_key);
if let Some(set) = data.trade_sets.get(set_key_clean) {
if let Some(trades_ident) = generated_trade_sets.get(&set.trades) {
let amount = set.amount as i32;
level_matches.push(quote! { #level => Some(VillagerTradeSet { trades: #trades_ident, amount: #amount }) });
}
}
}
profession_trade_sets.push(quote! {
Self::#ident => match level {
#(#level_matches,)*
_ => None,
}
});
}
for (i, name) in data.types.keys().enumerate() {
let ident = format_ident!("{}", name.to_pascal_case());
type_variants.push(quote! { #ident });
let i = i as i32;
type_from_i32.push(quote! { #i => Some(Self::#ident) });
}
quote! {
use serde::Serialize;
#[derive(Clone, Copy, PartialEq)]
pub struct VillagerTradeItem {
pub item: &'static crate::item::Item,
pub count: i32,
}
#[derive(Clone, Copy, PartialEq)]
pub struct VillagerTrade {
pub wants: VillagerTradeItem,
pub wants_b: Option<VillagerTradeItem>,
pub gives: VillagerTradeItem,
pub max_uses: i32,
pub xp: i32,
pub price_multiplier: f32,
}
#[derive(Clone, Copy, PartialEq)]
pub struct VillagerTradeSet {
pub trades: &'static [VillagerTrade],
pub amount: i32,
}
#(#trade_set_data)*
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
#[repr(i32)]
pub enum VillagerProfession {
#(#profession_variants),*
}
impl VillagerProfession {
#[must_use]
pub fn from_i32(id: i32) -> Option<Self> {
match id {
#(#profession_from_i32,)*
_ => None,
}
}
#[must_use]
pub fn work_sound(&self) -> Option<crate::sound::Sound> {
match self {
#(#work_sounds),*
}
}
#[must_use]
pub fn requested_items(&self) -> &'static [&'static crate::item::Item] {
match self {
#(#requested_items),*
}
}
#[must_use]
pub fn translation_key(&self) -> &'static str {
match self {
#(#profession_names),*
}
}
#[must_use]
pub fn trade_set(&self, level: i32) -> Option<VillagerTradeSet> {
match self {
#(#profession_trade_sets,)*
}
}
}
impl TryFrom<i32> for VillagerProfession {
type Error = ();
fn try_from(value: i32) -> Result<Self, Self::Error> {
Self::from_i32(value).ok_or(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
#[repr(i32)]
pub enum VillagerType {
#(#type_variants),*
}
impl VillagerType {
#[must_use]
pub fn from_i32(id: i32) -> Option<Self> {
match id {
#(#type_from_i32,)*
_ => None,
}
}
}
impl TryFrom<i32> for VillagerType {
type Error = ();
fn try_from(value: i32) -> Result<Self, Self::Error> {
Self::from_i32(value).ok_or(())
}
}
}
}

View File

@ -42,6 +42,7 @@ default = [
"recipe_remainder",
"chunk_gen_settings",
"carver",
"villager",
]
item = []
@ -78,6 +79,7 @@ potion = ["potion_brewing"]
recipe_remainder = []
chunk_gen_settings = []
carver = []
villager = ["item", "sound"]
noise_parameter = []
biome = []

File diff suppressed because it is too large Load Diff

View File

@ -294,3 +294,8 @@ pub mod chunk_gen_settings;
#[rustfmt::skip]
#[path = "generated/carver.rs"]
pub mod carver;
#[cfg(feature = "villager")]
#[rustfmt::skip]
#[path = "generated/villager.rs"]
pub mod villager;

View File

@ -103,6 +103,10 @@ impl ScreenHandler for BrewingScreenHandler {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn get_behaviour(&self) -> &ScreenHandlerBehaviour {
&self.behaviour
}

View File

@ -568,6 +568,10 @@ impl ScreenHandler for CraftingTableScreenHandler {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn get_behaviour(&self) -> &ScreenHandlerBehaviour {
&self.behaviour
}

View File

@ -136,6 +136,10 @@ impl ScreenHandler for FurnaceLikeScreenHandler {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn get_behaviour(&self) -> &ScreenHandlerBehaviour {
&self.behaviour
}

View File

@ -167,6 +167,10 @@ impl ScreenHandler for GenericContainerScreenHandler {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn get_behaviour(&self) -> &ScreenHandlerBehaviour {
&self.behaviour
}

View File

@ -111,6 +111,10 @@ impl ScreenHandler for GUIScreenHandler {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn get_behaviour(&self) -> &ScreenHandlerBehaviour {
&self.behaviour
}

View File

@ -40,6 +40,7 @@ mod error;
pub mod furnace_like;
pub mod generic_container_screen_handler;
pub mod gui_builder;
pub mod merchant;
pub mod player;
pub mod screen_handler;
pub mod slot;

View File

@ -0,0 +1,222 @@
use std::any::Any;
use std::sync::Arc;
use pumpkin_data::{item_stack::ItemStack, screen::WindowType};
use pumpkin_world::inventory::Inventory;
use crate::{
player::player_inventory::PlayerInventory,
screen_handler::{
InventoryPlayer, ItemStackFuture, ScreenHandler, ScreenHandlerBehaviour,
ScreenHandlerFuture,
},
slot::NormalSlot,
};
pub struct MerchantScreenHandler {
pub inventory: Arc<dyn Inventory>,
behaviour: ScreenHandlerBehaviour,
selected_offer: usize,
pub offers: Vec<pumpkin_protocol::java::client::play::MerchantOffer>,
}
impl MerchantScreenHandler {
pub async fn new(
sync_id: u8,
player_inventory: &Arc<PlayerInventory>,
inventory: Arc<dyn Inventory>,
offers: Vec<pumpkin_protocol::java::client::play::MerchantOffer>,
) -> Self {
let mut handler = Self {
inventory: inventory.clone(),
behaviour: ScreenHandlerBehaviour::new(sync_id, Some(WindowType::Merchant)),
selected_offer: 0,
offers,
};
inventory.on_open().await;
// Merchant specific slots: 2 input, 1 output
for i in 0..3 {
handler.add_slot(Arc::new(NormalSlot::new(inventory.clone(), i)));
}
let player_inventory: Arc<dyn Inventory> = player_inventory.clone();
handler.add_player_slots(&player_inventory);
handler
}
pub async fn set_selected_offer(&mut self, index: usize) {
self.selected_offer = index;
self.update_result_slot().await;
self.send_content_updates().await;
}
pub async fn update_result_slot(&mut self) {
if self.selected_offer >= self.offers.len() {
self.inventory.set_stack(2, ItemStack::EMPTY.clone()).await;
return;
}
let offer = &self.offers[self.selected_offer];
let input_a = self.inventory.get_stack(0).await;
let input_a = input_a.lock().await;
let input_b = self.inventory.get_stack(1).await;
let input_b = input_b.lock().await;
let match_a = input_a.are_items_and_components_equal(&offer.base_cost_a.0)
&& input_a.item_count >= offer.base_cost_a.0.item_count;
let match_b = offer.cost_b.as_ref().map_or_else(
|| input_b.is_empty(),
|cost_b| {
input_b.are_items_and_components_equal(&cost_b.0)
&& input_b.item_count >= cost_b.0.item_count
},
);
if match_a && match_b {
self.inventory.set_stack(2, (*offer.output.0).clone()).await;
} else {
self.inventory.set_stack(2, ItemStack::EMPTY.clone()).await;
}
}
}
impl ScreenHandler for MerchantScreenHandler {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn get_behaviour(&self) -> &ScreenHandlerBehaviour {
&self.behaviour
}
fn get_behaviour_mut(&mut self) -> &mut ScreenHandlerBehaviour {
&mut self.behaviour
}
fn on_closed<'a>(&'a mut self, player: &'a dyn InventoryPlayer) -> ScreenHandlerFuture<'a, ()> {
Box::pin(async move {
self.default_on_closed(player).await;
self.inventory.on_close().await;
// 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).await;
if !stack.is_empty() {
player.drop_item(stack, false).await;
}
}
// Clear output slot
self.inventory.set_stack(2, ItemStack::EMPTY.clone()).await;
})
}
fn quick_move<'a>(
&'a mut self,
_player: &'a dyn InventoryPlayer,
slot_index: i32,
) -> ItemStackFuture<'a> {
Box::pin(async move {
let mut stack_left = ItemStack::EMPTY.clone();
let slot = self.get_behaviour().slots[slot_index as usize].clone();
if slot.has_stack().await {
let slot_stack_lock = slot.get_stack().await;
let slot_stack_guard = slot_stack_lock.lock().await;
stack_left = slot_stack_guard.clone();
drop(slot_stack_guard);
let mut slot_stack_mut = slot_stack_lock.lock().await;
if slot_index < 3 {
// From merchant slots to player inventory
if !self
.insert_item(
&mut slot_stack_mut,
3,
self.get_behaviour().slots.len() as i32,
true,
)
.await
{
return ItemStack::EMPTY.clone();
}
} else {
// From player inventory to merchant inputs (0 and 1)
if !self.insert_item(&mut slot_stack_mut, 0, 2, false).await {
return ItemStack::EMPTY.clone();
}
}
if slot_stack_mut.is_empty() {
drop(slot_stack_mut);
slot.set_stack(ItemStack::EMPTY.clone()).await;
} else {
drop(slot_stack_mut);
slot.mark_dirty().await;
}
}
stack_left
})
}
fn on_slot_click<'a>(
&'a mut self,
slot_index: i32,
button: i32,
action_type: pumpkin_protocol::java::server::play::SlotActionType,
player: &'a dyn InventoryPlayer,
) -> ScreenHandlerFuture<'a, ()> {
Box::pin(async move {
if slot_index == 2 {
// Special handling for taking from output slot
let result_slot = self.get_behaviour().slots[2].clone();
if result_slot.has_stack().await {
let result_stack = result_slot.get_cloned_stack().await;
if !result_stack.is_empty() {
// Consume inputs
let offer = &self.offers[self.selected_offer];
let input_a = self.inventory.get_stack(0).await;
let mut input_a = input_a.lock().await;
input_a.decrement(offer.base_cost_a.0.item_count);
if input_a.is_empty() {
*input_a = ItemStack::EMPTY.clone();
}
drop(input_a);
self.get_behaviour().slots[0].mark_dirty().await;
if let Some(cost_b) = &offer.cost_b {
let input_b = self.inventory.get_stack(1).await;
let mut input_b = input_b.lock().await;
input_b.decrement(cost_b.0.item_count);
if input_b.is_empty() {
*input_b = ItemStack::EMPTY.clone();
}
drop(input_b);
self.get_behaviour().slots[1].mark_dirty().await;
}
// Award XP
player.award_experience(offer.xp).await;
}
}
}
self.internal_on_slot_click(slot_index, button, action_type, player)
.await;
if slot_index == 0 || slot_index == 1 || slot_index == 2 {
self.update_result_slot().await;
self.send_content_updates().await;
}
})
}
}

View File

@ -0,0 +1 @@
pub mod merchant_screen_handler;

View File

@ -121,6 +121,10 @@ impl ScreenHandler for PlayerScreenHandler {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn get_behaviour(&self) -> &ScreenHandlerBehaviour {
&self.behaviour
}

View File

@ -223,6 +223,9 @@ pub trait ScreenHandler: Send + Sync {
/// Returns this screen handler as an Any reference.
fn as_any(&self) -> &dyn Any;
/// Returns this screen handler as a mutable Any reference.
fn as_any_mut(&mut self) -> &mut dyn Any;
/// Gets the sync ID for this screen handler.
fn sync_id(&self) -> u8 {
self.get_behaviour().sync_id

View File

@ -13,6 +13,7 @@ use serde::{
};
use std::borrow::Cow;
#[derive(Clone)]
pub struct ItemStackSerializer<'a>(pub Cow<'a, ItemStack>);
fn item_component_counts(stack: &ItemStack) -> (u8, u8) {

View File

@ -0,0 +1,52 @@
use pumpkin_data::packet::clientbound::PLAY_MERCHANT_OFFERS;
use pumpkin_macros::java_packet;
use serde::{Deserialize, Serialize};
use crate::VarInt;
use crate::codec::item_stack_seralizer::ItemStackSerializer;
#[derive(Serialize, Deserialize, Clone)]
pub struct MerchantOffer {
pub base_cost_a: ItemStackSerializer<'static>,
pub output: ItemStackSerializer<'static>,
pub cost_b: Option<ItemStackSerializer<'static>>,
pub is_disabled: bool,
pub uses: i32,
pub max_uses: i32,
pub xp: i32,
pub special_price: i32,
pub price_multiplier: f32,
pub demand: i32,
}
#[derive(Serialize)]
#[java_packet(PLAY_MERCHANT_OFFERS)]
pub struct CMerchantOffers {
pub window_id: VarInt,
pub offers: Vec<MerchantOffer>,
pub villager_level: VarInt,
pub experience: VarInt,
pub is_regular_villager: bool,
pub can_restock: bool,
}
impl CMerchantOffers {
#[must_use]
pub const fn new(
window_id: VarInt,
offers: Vec<MerchantOffer>,
villager_level: VarInt,
experience: VarInt,
is_regular_villager: bool,
can_restock: bool,
) -> Self {
Self {
window_id,
offers,
villager_level,
experience,
is_regular_villager,
can_restock,
}
}
}

View File

@ -37,6 +37,7 @@ mod keep_alive;
mod level_event;
mod light_update;
mod login;
mod merchant_offers;
mod multi_block_update;
mod open_screen;
mod open_sign_editor;
@ -136,6 +137,7 @@ pub use keep_alive::*;
pub use level_event::*;
pub use light_update::*;
pub use login::*;
pub use merchant_offers::*;
pub use multi_block_update::*;
pub use open_screen::*;
pub use open_sign_editor::*;

View File

@ -31,6 +31,7 @@ mod player_rotation;
mod player_session;
mod recipe_book_change_settings;
mod recipe_book_seen_recipe;
mod select_trade;
mod set_command_block;
mod set_creative_slot;
mod set_held_item;
@ -72,6 +73,7 @@ pub use player_rotation::*;
pub use player_session::*;
pub use recipe_book_change_settings::*;
pub use recipe_book_seen_recipe::*;
pub use select_trade::*;
pub use set_command_block::*;
pub use set_creative_slot::*;
pub use set_held_item::*;

View File

@ -0,0 +1,11 @@
use pumpkin_data::packet::serverbound::PLAY_SELECT_TRADE;
use pumpkin_macros::java_packet;
use serde::Deserialize;
use crate::VarInt;
#[derive(Deserialize)]
#[java_packet(PLAY_SELECT_TRADE)]
pub struct SSelectTrade {
pub selected_slot: VarInt,
}

View File

@ -6,8 +6,10 @@ use pumpkin_data::item_stack::ItemStack;
#[expect(clippy::module_inception)]
mod inventory;
mod simple_inventory;
pub use inventory::*;
pub use simple_inventory::*;
// These are some utility functions found in Inventories.java
pub async fn split_stack(stacks: &[Arc<Mutex<ItemStack>>], slot: usize, amount: u8) -> ItemStack {

View File

@ -0,0 +1,73 @@
use crate::inventory::{Clearable, Inventory, InventoryFuture, split_stack};
use pumpkin_data::item_stack::ItemStack;
use std::any::Any;
use std::sync::Arc;
use tokio::sync::Mutex;
pub struct SimpleInventory {
pub stacks: Vec<Arc<Mutex<ItemStack>>>,
}
impl SimpleInventory {
pub fn new(size: usize) -> Self {
Self {
stacks: (0..size)
.map(|_| Arc::new(Mutex::new(ItemStack::EMPTY.clone())))
.collect(),
}
}
}
impl Clearable for SimpleInventory {
fn clear(&self) -> InventoryFuture<'_, ()> {
Box::pin(async move {
for stack in &self.stacks {
*stack.lock().await = ItemStack::EMPTY.clone();
}
})
}
}
impl Inventory for SimpleInventory {
fn size(&self) -> usize {
self.stacks.len()
}
fn is_empty(&self) -> InventoryFuture<'_, bool> {
Box::pin(async move {
for stack in &self.stacks {
if !stack.lock().await.is_empty() {
return false;
}
}
true
})
}
fn get_stack(&self, slot: usize) -> InventoryFuture<'_, Arc<Mutex<ItemStack>>> {
Box::pin(async move { self.stacks[slot].clone() })
}
fn remove_stack(&self, slot: usize) -> InventoryFuture<'_, ItemStack> {
Box::pin(async move {
let mut stack = self.stacks[slot].lock().await;
let result = stack.clone();
*stack = ItemStack::EMPTY.clone();
result
})
}
fn remove_stack_specific(&self, slot: usize, amount: u8) -> InventoryFuture<'_, ItemStack> {
Box::pin(async move { split_stack(&self.stacks, slot, amount).await })
}
fn set_stack(&self, slot: usize, stack: ItemStack) -> InventoryFuture<'_, ()> {
Box::pin(async move {
*self.stacks[slot].lock().await = stack;
})
}
fn as_any(&self) -> &dyn Any {
self
}
}

View File

@ -1,55 +0,0 @@
use std::sync::{Arc, Weak};
use pumpkin_data::entity::EntityType;
use crate::entity::{
Entity, NBTStorage,
ai::goal::{
look_around::RandomLookAroundGoal, look_at_entity::LookAtEntityGoal, swim::SwimGoal,
wander_around::WanderAroundGoal,
},
mob::{Mob, MobEntity},
};
pub struct VillagerEntity {
pub mob_entity: MobEntity,
}
impl VillagerEntity {
pub async fn new(entity: Entity) -> Arc<Self> {
let mob_entity = MobEntity::new(entity);
let villager = Self { mob_entity };
let mob_arc = Arc::new(villager);
let mob_weak: Weak<dyn Mob> = {
let mob_arc: Arc<dyn Mob> = mob_arc.clone();
Arc::downgrade(&mob_arc)
};
{
let mut goal_selector = mob_arc.mob_entity.goals_selector.lock().await;
goal_selector.add_goal(0, Box::new(SwimGoal::default()));
// Villagers have complex AI, but for now we give them basic movement
goal_selector.add_goal(1, Box::new(WanderAroundGoal::new(0.6)));
goal_selector.add_goal(
2,
LookAtEntityGoal::with_default(mob_weak.clone(), &EntityType::PLAYER, 8.0),
);
goal_selector.add_goal(
3,
LookAtEntityGoal::with_default(mob_weak, &EntityType::VILLAGER, 8.0),
);
goal_selector.add_goal(4, Box::new(RandomLookAroundGoal::default()));
};
mob_arc
}
}
impl NBTStorage for VillagerEntity {}
impl Mob for VillagerEntity {
fn get_mob_entity(&self) -> &MobEntity {
&self.mob_entity
}
}

View File

@ -0,0 +1,48 @@
use pumpkin_data::item::Item;
pub use pumpkin_data::villager::{VillagerProfession, VillagerType};
use pumpkin_protocol::codec::var_int::VarInt;
use serde::Serialize;
pub const BREEDING_FOOD_THRESHOLD: i32 = 12;
#[must_use]
pub const fn get_food_points(item: &Item) -> i32 {
match item.id {
id if id == Item::BREAD.id => 4,
id if id == Item::POTATO.id => 1,
id if id == Item::CARROT.id => 1,
id if id == Item::BEETROOT.id => 1,
_ => 0,
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)]
#[repr(i32)]
pub enum GossipType {
MajorNegative = 0,
MinorNegative = 1,
MajorPositive = 2,
MinorPositive = 3,
Trading = 4,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct VillagerData {
pub r#type: VillagerType,
pub profession: VillagerProfession,
pub level: i32,
}
impl Serialize for VillagerData {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeStruct;
let mut state = serializer.serialize_struct("VillagerData", 3)?;
state.serialize_field("type", &VarInt(self.r#type as i32))?;
state.serialize_field("profession", &VarInt(self.profession as i32))?;
state.serialize_field("level", &VarInt(self.level))?;
state.end()
}
}

View File

@ -0,0 +1,459 @@
use std::collections::HashMap;
use std::sync::atomic::{AtomicI32, AtomicI64, Ordering};
use std::sync::{Arc, Weak};
use uuid::Uuid;
use pumpkin_data::entity::EntityType;
use pumpkin_data::item_stack::ItemStack;
use pumpkin_data::meta_data_type::MetaDataType;
use pumpkin_data::tracked_data::TrackedData;
use pumpkin_inventory::merchant::merchant_screen_handler::MerchantScreenHandler;
use pumpkin_inventory::screen_handler::{
BoxFuture, InventoryPlayer, ScreenHandlerFactory, SharedScreenHandler,
};
use pumpkin_protocol::codec::var_int::VarInt;
use pumpkin_protocol::java::client::play::{CMerchantOffers, Metadata};
use pumpkin_util::text::TextComponent;
use pumpkin_world::inventory::SimpleInventory;
use tokio::sync::Mutex;
use crate::entity::player::Player;
use crate::entity::{
Entity, EntityBase, NBTStorage,
ai::goal::{
avoid_entity::AvoidEntityGoal, look_around::RandomLookAroundGoal,
look_at_entity::LookAtEntityGoal, swim::SwimGoal, wander_around::WanderAroundGoal,
},
mob::{Mob, MobEntity},
};
pub mod data;
pub use data::{
BREEDING_FOOD_THRESHOLD, GossipType, VillagerData, VillagerProfession, VillagerType,
get_food_points,
};
pub struct VillagerEntity {
pub mob_entity: MobEntity,
pub villager_data: Mutex<VillagerData>,
pub food_level: AtomicI32,
pub xp: AtomicI32,
pub last_restock_time: AtomicI64,
pub restocks_today: AtomicI32,
pub gossips: Mutex<HashMap<Uuid, HashMap<GossipType, i32>>>,
pub inventory: Arc<Mutex<Vec<Arc<Mutex<ItemStack>>>>>,
pub merchant_inventory: Arc<SimpleInventory>,
pub offers: Mutex<Vec<pumpkin_protocol::java::client::play::MerchantOffer>>,
pub self_arc: Mutex<Option<Weak<Self>>>,
}
impl VillagerEntity {
#[expect(clippy::too_many_lines)]
pub async fn new(entity: Entity) -> Arc<Self> {
let mob_entity = MobEntity::new(entity);
let villager_data = VillagerData {
r#type: VillagerType::Plains,
profession: VillagerProfession::None,
level: 1,
};
let inventory = Arc::new(Mutex::new(
(0..8)
.map(|_| Arc::new(Mutex::new(ItemStack::EMPTY.clone())))
.collect(),
));
let villager = Self {
mob_entity,
villager_data: Mutex::new(villager_data),
food_level: AtomicI32::new(0),
xp: AtomicI32::new(0),
last_restock_time: AtomicI64::new(0),
restocks_today: AtomicI32::new(0),
gossips: Mutex::new(HashMap::new()),
inventory,
merchant_inventory: Arc::new(SimpleInventory::new(3)),
offers: Mutex::new(Vec::new()),
self_arc: Mutex::new(None),
};
let mob_arc = Arc::new(villager);
*mob_arc.self_arc.lock().await = Some(Arc::downgrade(&mob_arc));
let mob_weak: Weak<dyn Mob> = {
let mob_arc: Arc<dyn Mob> = mob_arc.clone();
Arc::downgrade(&mob_arc)
};
{
let mut goal_selector = mob_arc.mob_entity.goals_selector.lock().await;
goal_selector.add_goal(0, Box::new(SwimGoal::default()));
// Villagers avoid threats
goal_selector.add_goal(
1,
Box::new(AvoidEntityGoal::new(&EntityType::ZOMBIE, 8.0, 0.5, 0.5)),
);
goal_selector.add_goal(
1,
Box::new(AvoidEntityGoal::new(
&EntityType::ZOMBIE_VILLAGER,
8.0,
0.5,
0.5,
)),
);
goal_selector.add_goal(
1,
Box::new(AvoidEntityGoal::new(&EntityType::HUSK, 8.0, 0.5, 0.5)),
);
goal_selector.add_goal(
1,
Box::new(AvoidEntityGoal::new(&EntityType::DROWNED, 8.0, 0.5, 0.5)),
);
goal_selector.add_goal(
1,
Box::new(AvoidEntityGoal::new(&EntityType::PILLAGER, 12.0, 0.5, 0.5)),
);
goal_selector.add_goal(
1,
Box::new(AvoidEntityGoal::new(
&EntityType::VINDICATOR,
12.0,
0.5,
0.5,
)),
);
goal_selector.add_goal(
1,
Box::new(AvoidEntityGoal::new(&EntityType::EVOKER, 12.0, 0.5, 0.5)),
);
goal_selector.add_goal(
1,
Box::new(AvoidEntityGoal::new(&EntityType::RAVAGER, 12.0, 0.5, 0.5)),
);
goal_selector.add_goal(
1,
Box::new(AvoidEntityGoal::new(&EntityType::VEX, 12.0, 0.5, 0.5)),
);
// Basic movement and looking (Vanilla uses 0.5 speed)
goal_selector.add_goal(2, Box::new(WanderAroundGoal::new(0.5)));
goal_selector.add_goal(
3,
LookAtEntityGoal::with_default(mob_weak.clone(), &EntityType::PLAYER, 8.0),
);
goal_selector.add_goal(
4,
LookAtEntityGoal::with_default(mob_weak, &EntityType::VILLAGER, 8.0),
);
goal_selector.add_goal(5, Box::new(RandomLookAroundGoal::default()));
};
// Send initial metadata
mob_arc
.get_entity()
.send_meta_data(&[Metadata::new(
TrackedData::VILLAGER_DATA,
MetaDataType::VILLAGER_DATA,
villager_data,
)])
.await;
mob_arc
}
pub async fn count_food_points_in_inventory(&self) -> i32 {
let inventory = self.inventory.lock().await;
let mut total = 0;
for stack_mutex in inventory.iter() {
let stack = stack_mutex.lock().await;
if !stack.is_empty() {
total += get_food_points(stack.get_item()) * stack.item_count as i32;
}
}
total
}
pub async fn eat_until_full(&self) {
if self.food_level.load(Ordering::Relaxed) >= BREEDING_FOOD_THRESHOLD {
return;
}
let inventory = self.inventory.lock().await;
for stack_mutex in inventory.iter() {
let mut stack = stack_mutex.lock().await;
if !stack.is_empty() {
let points = get_food_points(stack.get_item());
if points > 0 {
while stack.item_count > 0
&& self.food_level.load(Ordering::Relaxed) < BREEDING_FOOD_THRESHOLD
{
self.food_level.fetch_add(points, Ordering::Relaxed);
stack.item_count -= 1;
}
if stack.item_count == 0 {
*stack = ItemStack::EMPTY.clone();
}
if self.food_level.load(Ordering::Relaxed) >= BREEDING_FOOD_THRESHOLD {
break;
}
}
}
}
}
pub async fn set_villager_data(&self, data: VillagerData) {
let mut villager_data = self.villager_data.lock().await;
let old_profession = villager_data.profession;
*villager_data = data;
self.get_entity()
.send_meta_data(&[Metadata::new(
TrackedData::VILLAGER_DATA,
MetaDataType::VILLAGER_DATA,
data,
)])
.await;
if old_profession != data.profession {
self.generate_trades(data.profession, data.level).await;
if let Some(sound) = data.profession.work_sound() {
self.get_entity().play_sound(sound).await;
}
}
}
pub async fn generate_trades(&self, profession: VillagerProfession, level: i32) {
use pumpkin_protocol::codec::item_stack_seralizer::ItemStackSerializer;
use rand::seq::IndexedRandom;
use std::borrow::Cow;
let mut offers = self.offers.lock().await;
offers.clear();
if let Some(trade_set) = profession.trade_set(level) {
let mut rng = rand::rng();
let chosen_trades = trade_set.trades.sample(&mut rng, trade_set.amount as usize);
for trade in chosen_trades {
offers.push(pumpkin_protocol::java::client::play::MerchantOffer {
base_cost_a: ItemStackSerializer(Cow::Owned(ItemStack::new(
trade.wants.count as u8,
trade.wants.item,
))),
output: ItemStackSerializer(Cow::Owned(ItemStack::new(
trade.gives.count as u8,
trade.gives.item,
))),
cost_b: trade.wants_b.as_ref().map(|b| {
ItemStackSerializer(Cow::Owned(ItemStack::new(b.count as u8, b.item)))
}),
is_disabled: false,
uses: 0,
max_uses: trade.max_uses,
xp: trade.xp,
special_price: 0,
price_multiplier: trade.price_multiplier,
demand: 0,
});
}
}
}
pub async fn set_unhappy(&self) {
let entity = self.get_entity();
entity
.world
.load()
.send_entity_status(entity, pumpkin_data::entity::EntityStatus::VillagerAngry)
.await;
entity
.play_sound(pumpkin_data::sound::Sound::EntityVillagerNo)
.await;
}
pub async fn open_trading_screen(&self, player: &Player) {
let self_weak = self.self_arc.lock().await;
if let Some(self_arc) = self_weak.as_ref().and_then(std::sync::Weak::upgrade) {
player.open_handled_screen(&*self_arc, None).await;
let offers = self.offers.lock().await;
let villager_data = self.villager_data.lock().await;
player
.client
.enqueue_packet(&CMerchantOffers::new(
player.screen_handler_sync_id.load(Ordering::Relaxed).into(),
offers.clone(),
VarInt(villager_data.level),
VarInt(self.xp.load(Ordering::Relaxed)),
true,
true,
))
.await;
}
}
}
impl ScreenHandlerFactory for VillagerEntity {
fn create_screen_handler<'a>(
&'a self,
sync_id: u8,
player_inventory: &'a Arc<pumpkin_inventory::player::player_inventory::PlayerInventory>,
_player: &'a dyn InventoryPlayer,
) -> BoxFuture<'a, Option<SharedScreenHandler>> {
Box::pin(async move {
let offers = self.offers.lock().await;
let handler = MerchantScreenHandler::new(
sync_id,
player_inventory,
self.merchant_inventory.clone(),
offers.clone(),
)
.await;
Some(Arc::new(Mutex::new(handler)) as SharedScreenHandler)
})
}
fn get_display_name(&self) -> TextComponent {
// TODO: Localized name based on profession
TextComponent::text("Villager")
}
}
impl NBTStorage for VillagerEntity {
fn write_nbt<'a>(
&'a self,
nbt: &'a mut pumpkin_nbt::pnbt::PNbtCompound,
) -> crate::entity::NbtFuture<'a, ()> {
Box::pin(async move {
self.mob_entity.living_entity.write_nbt(nbt).await;
let data = self.villager_data.lock().await;
nbt.put_int(data.r#type as i32);
nbt.put_int(data.profession as i32);
nbt.put_int(data.level);
nbt.put_int(self.food_level.load(Ordering::Relaxed));
nbt.put_int(self.xp.load(Ordering::Relaxed));
nbt.put_long(self.last_restock_time.load(Ordering::Relaxed));
nbt.put_int(self.restocks_today.load(Ordering::Relaxed));
// Inventory
let inventory = self.inventory.lock().await;
nbt.put_int(inventory.len() as i32);
for stack_mutex in inventory.iter() {
let stack = stack_mutex.lock().await;
stack.write_item_stack_pnbt(nbt);
}
// Gossips (Simplified: just save counts per UUID and type)
let gossips = self.gossips.lock().await;
nbt.put_int(gossips.len() as i32);
for (uuid, types) in gossips.iter() {
nbt.put_uuid(uuid);
nbt.put_int(types.len() as i32);
for (gtype, value) in types {
nbt.put_int(*gtype as i32);
nbt.put_int(*value);
}
}
})
}
fn read_nbt_non_mut<'a>(
&'a self,
nbt: &'a mut pumpkin_nbt::pnbt::PNbtCompound,
) -> crate::entity::NbtFuture<'a, ()> {
Box::pin(async move {
self.mob_entity.living_entity.read_nbt_non_mut(nbt).await;
let mut data = self.villager_data.lock().await;
if let Ok(t) = nbt.get_int() {
data.r#type = VillagerType::try_from(t).unwrap_or(VillagerType::Plains);
}
if let Ok(p) = nbt.get_int() {
data.profession =
VillagerProfession::try_from(p).unwrap_or(VillagerProfession::None);
}
if let Ok(l) = nbt.get_int() {
data.level = l;
}
if let Ok(food) = nbt.get_int() {
self.food_level.store(food, Ordering::Relaxed);
}
if let Ok(xp) = nbt.get_int() {
self.xp.store(xp, Ordering::Relaxed);
}
if let Ok(restock) = nbt.get_long() {
self.last_restock_time.store(restock, Ordering::Relaxed);
}
if let Ok(today) = nbt.get_int() {
self.restocks_today.store(today, Ordering::Relaxed);
}
// Inventory
if let Ok(inv_len) = nbt.get_int() {
let mut inventory = self.inventory.lock().await;
inventory.clear();
for _ in 0..inv_len {
let stack =
ItemStack::read_item_stack_pnbt(nbt).unwrap_or(ItemStack::EMPTY.clone());
inventory.push(Arc::new(Mutex::new(stack)));
}
}
// Gossips
if let Ok(gossip_len) = nbt.get_int() {
let mut gossips = self.gossips.lock().await;
gossips.clear();
for _ in 0..gossip_len {
if let Ok(uuid) = nbt.get_uuid()
&& let Ok(types_len) = nbt.get_int()
{
let mut types = HashMap::new();
for _ in 0..types_len {
if let (Ok(gtype), Ok(val)) = (nbt.get_int(), nbt.get_int()) {
let gossip_type = match gtype {
0 => GossipType::MajorNegative,
1 => GossipType::MinorNegative,
2 => GossipType::MajorPositive,
3 => GossipType::MinorPositive,
4 => GossipType::Trading,
_ => continue,
};
types.insert(gossip_type, val);
}
}
gossips.insert(uuid, types);
}
}
}
})
}
}
impl Mob for VillagerEntity {
fn get_mob_entity(&self) -> &MobEntity {
&self.mob_entity
}
fn mob_interact<'a>(
&'a self,
player: &'a crate::entity::player::Player,
_item_stack: &'a mut pumpkin_data::item_stack::ItemStack,
) -> crate::entity::EntityBaseFuture<'a, bool> {
Box::pin(async move {
if self.get_entity().age.load(Ordering::Relaxed) < 0 {
self.set_unhappy().await;
return true;
}
let offers = self.offers.lock().await;
if offers.is_empty() {
self.set_unhappy().await;
return true;
}
drop(offers);
self.open_trading_screen(player).await;
true
})
}
}

View File

@ -12,8 +12,8 @@ use pumpkin_protocol::java::server::play::{
SPaddleBoat, SPickItemFromBlock, SPlaceRecipe, SPlayPingRequest, SPlayerAbilities,
SPlayerAction, SPlayerCommand, SPlayerInput, SPlayerLoaded, SPlayerPosition,
SPlayerPositionRotation, SPlayerRotation, SPlayerSession, SRecipeBookChangeSettings,
SRecipeBookSeenRecipe, SSetCommandBlock, SSetCreativeSlot, SSetHeldItem, SSetPlayerGround,
SSwingArm, SUpdateSign, SUseItem, SUseItemOn,
SRecipeBookSeenRecipe, SSelectTrade, SSetCommandBlock, SSetCreativeSlot, SSetHeldItem,
SSetPlayerGround, SSwingArm, SUpdateSign, SUseItem, SUseItemOn,
};
use pumpkin_protocol::packet::MultiVersionJavaPacket;
use pumpkin_protocol::{
@ -891,6 +891,10 @@ impl JavaClient {
self.handle_place_recipe(player, SPlaceRecipe::read(payload, &version)?)
.await;
}
id if id == SSelectTrade::to_id(version) => {
self.handle_select_trade(player, SSelectTrade::read(payload, &version)?)
.await;
}
_ => {
warn!("Failed to handle player packet id {}", packet.id);
}

View File

@ -46,6 +46,7 @@ use pumpkin_data::item_stack::ItemStack;
use pumpkin_data::sound::{Sound, SoundCategory};
use pumpkin_data::{Block, BlockDirection, BlockState, translation};
use pumpkin_inventory::InventoryError;
use pumpkin_inventory::merchant::merchant_screen_handler::MerchantScreenHandler;
use pumpkin_inventory::player::player_inventory::PlayerInventory;
use pumpkin_inventory::screen_handler::{InventoryPlayer, ScreenHandler};
use pumpkin_macros::send_cancellable;
@ -62,8 +63,8 @@ use pumpkin_protocol::java::server::play::{
SKeepAlive, SMoveVehicle, SPaddleBoat, SPickItemFromBlock, SPlaceRecipe, SPlayPingRequest,
SPlayerAbilities, SPlayerAction, SPlayerCommand, SPlayerInput, SPlayerPosition,
SPlayerPositionRotation, SPlayerRotation, SPlayerSession, SRecipeBookChangeSettings,
SRecipeBookSeenRecipe, SSetCommandBlock, SSetCreativeSlot, SSetHeldItem, SSetPlayerGround,
SSwingArm, SUpdateSign, SUseItem, SUseItemOn, Status,
SRecipeBookSeenRecipe, SSelectTrade, SSetCommandBlock, SSetCreativeSlot, SSetHeldItem,
SSetPlayerGround, SSwingArm, SUpdateSign, SUseItem, SUseItemOn, Status,
};
use pumpkin_util::math::boundingbox::BoundingBox;
use pumpkin_util::math::vector3::Vector3;
@ -2765,4 +2766,17 @@ impl JavaClient {
self.enqueue_packet(&COpenSignEditor::new(block_position, is_front_text))
.await;
}
pub async fn handle_select_trade(&self, player: &Arc<Player>, packet: SSelectTrade) {
let screen_handler = player.current_screen_handler.lock().await;
let mut screen_handler = screen_handler.lock().await;
if let Some(merchant) = screen_handler
.as_any_mut()
.downcast_mut::<MerchantScreenHandler>()
{
merchant
.set_selected_offer(packet.selected_slot.0 as usize)
.await;
}
}
}

View File

@ -140,6 +140,10 @@ impl ScreenHandler for PluginScreenHandler {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn get_behaviour(&self) -> &ScreenHandlerBehaviour {
&self.behaviour
}