feat(plugin-api): allow to register custom recipies

This commit is contained in:
Alexander Medvedev 2026-05-16 20:56:29 +02:00
parent cfeb5d80fc
commit e2ff17d6f0
26 changed files with 1170 additions and 438 deletions

View File

@ -535,8 +535,9 @@ pub fn build() -> TokenStream {
quote! {
use crate::tag::Taggable;
use crate::item::Item;
use serde::{Serialize, Deserialize};
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Serialize)]
pub enum CraftingRecipeTypes {
CraftingShaped {
category: RecipeCategoryTypes,
@ -566,7 +567,7 @@ pub fn build() -> TokenStream {
}
#[allow(dead_code)]
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Serialize)]
pub struct CookingRecipe {
/// Vanilla-compatible recipe ID (e.g., "minecraft:iron_ingot_from_smelting_iron_ore")
pub recipe_id: &'static str,
@ -578,14 +579,14 @@ pub fn build() -> TokenStream {
pub result: RecipeResultStruct,
}
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Serialize)]
pub enum CookingRecipeType {
Blasting(CookingRecipe),
Smelting(CookingRecipe),
Smoking(CookingRecipe),
CampfireCooking(CookingRecipe),
}
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Serialize)]
pub enum CookingRecipeKind {
Blasting,
Smelting,
@ -626,20 +627,20 @@ pub fn build() -> TokenStream {
}
}
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Serialize)]
pub struct StonecutterRecipe {
pub group: Option<&'static str>,
pub ingredient: RecipeIngredientTypes,
pub result: RecipeResultStruct,
}
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Serialize)]
pub struct RecipeResultStruct {
pub id: &'static str,
pub count: u8,
}
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Serialize)]
pub enum RecipeIngredientTypes {
Simple(&'static str),
Tagged(&'static str),
@ -664,7 +665,7 @@ pub fn build() -> TokenStream {
}
}
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Serialize)]
pub enum RecipeCategoryTypes {
Equipment,
Building,

View File

@ -84,6 +84,23 @@ fn parse_packet_file(path: &Path, interface: &mut Interface, variant: &mut Varia
if let Fields::Named(fields) = s.fields {
for field in fields.named {
let type_name = match &field.ty {
syn::Type::Path(p) => p.path.segments.last().unwrap().ident.to_string(),
syn::Type::Reference(r) => match &*r.elem {
syn::Type::Slice(s) => match &*s.elem {
syn::Type::Path(p) => p.path.segments.last().unwrap().ident.to_string(),
_ => String::new(),
},
syn::Type::Path(p) => p.path.segments.last().unwrap().ident.to_string(),
_ => String::new(),
},
_ => String::new(),
};
if type_name == "DynamicRecipe" {
continue;
}
let field_name = field.ident.as_ref().unwrap().to_string().to_kebab_case();
let field_type = map_type(&field.ty);
fields_list.push(Field::new(field_name, field_type));

View File

@ -125,6 +125,11 @@ fn parse_packet_file(
let wit_field = field_name.to_snake_case();
let (type_ident, is_ref, is_slice) = get_type_info(&field.ty);
if type_ident == "DynamicRecipe" {
field_inits.push_str(&format!(" {}: &[],\n", field_name));
continue;
}
if field_name == "type" {
field_name = "r#type".to_string();
}

View File

@ -1,7 +1,8 @@
/* This file is generated. Do not edit manually. */
use crate::item::Item;
use crate::tag::Taggable;
#[derive(Clone, Debug)]
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize)]
pub enum CraftingRecipeTypes {
CraftingShaped {
category: RecipeCategoryTypes,
@ -30,7 +31,7 @@ pub enum CraftingRecipeTypes {
CraftingSpecial,
}
#[allow(dead_code)]
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Serialize)]
pub struct CookingRecipe {
#[doc = r#" Vanilla-compatible recipe ID (e.g., "minecraft:iron_ingot_from_smelting_iron_ore")"#]
pub recipe_id: &'static str,
@ -41,14 +42,14 @@ pub struct CookingRecipe {
pub experience: f32,
pub result: RecipeResultStruct,
}
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Serialize)]
pub enum CookingRecipeType {
Blasting(CookingRecipe),
Smelting(CookingRecipe),
Smoking(CookingRecipe),
CampfireCooking(CookingRecipe),
}
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Serialize)]
pub enum CookingRecipeKind {
Blasting,
Smelting,
@ -85,18 +86,18 @@ impl CookingRecipeKind {
}
}
}
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Serialize)]
pub struct StonecutterRecipe {
pub group: Option<&'static str>,
pub ingredient: RecipeIngredientTypes,
pub result: RecipeResultStruct,
}
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Serialize)]
pub struct RecipeResultStruct {
pub id: &'static str,
pub count: u8,
}
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Serialize)]
pub enum RecipeIngredientTypes {
Simple(&'static str),
Tagged(&'static str),
@ -119,7 +120,7 @@ impl RecipeIngredientTypes {
}
}
}
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Serialize)]
pub enum RecipeCategoryTypes {
Equipment,
Building,

View File

@ -19,6 +19,7 @@ use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicU8, Ordering};
use super::recipe_provider::{GenericRecipe, RecipeProvider};
use super::recipes::{RecipeFinderScreenHandler, RecipeInputInventory};
use crate::crafting::crafting_inventory::CraftingInventory;
use crate::player::player_inventory::PlayerInventory;
@ -28,52 +29,42 @@ use crate::screen_handler::{
};
use crate::slot::{BoxFuture, NormalSlot, Slot};
use crossbeam_utils::atomic::AtomicCell;
use pumpkin_data::item_stack::ItemStack;
use pumpkin_data::recipes::{CraftingRecipeTypes, RECIPES_CRAFTING, RecipeResultStruct};
use pumpkin_data::recipes::{CraftingRecipeTypes, RECIPES_CRAFTING};
use pumpkin_data::screen::WindowType;
use pumpkin_data::tag;
use pumpkin_data::tag::Taggable;
use pumpkin_protocol::codec::recipe::{DynamicRecipe, OwnedCraftingRecipe};
use pumpkin_world::inventory::Inventory;
use tokio::sync::Mutex;
/// The result slot in a crafting screen.
///
/// This special slot displays the output of the current crafting recipe.
/// Unlike normal slots, it doesn't store items permanently - it calculates
/// the result dynamically based on the crafting grid contents.
///
/// # Note
///
/// This implementation differs from vanilla Minecraft. Instead of a separate
/// result inventory, we directly store the result in the slot. The slot
/// should never be modified directly - modifications to the crafting grid
/// automatically update the result.
pub struct ResultSlot {
/// The crafting inventory (grid) that provides recipe input.
pub inventory: Arc<dyn RecipeInputInventory>,
/// Protocol ID for this slot (assigned by screen handler).
pub id: AtomicU8,
/// The cached result item stack.
///
/// Updated when the crafting grid changes and a recipe matches.
pub result: Arc<Mutex<ItemStack>>,
/// Cached reference to the last matched recipe for quick re-matching.
recipe_cache: AtomicCell<Option<&'static CraftingRecipeTypes>>,
/// Provider for dynamic recipes.
pub recipe_provider: Option<Arc<dyn RecipeProvider>>,
}
pub struct RecipeResult {
pub item_id: String,
pub count: u8,
}
/// Checks if a recipe pattern is symmetrical horizontally.
///
/// Used to try both orientations when matching shaped recipes.
fn is_symmetrical_horizontally(pattern: &'static [&'static str]) -> bool {
fn is_symmetrical_horizontally(pattern: &[&str]) -> bool {
let width = pattern.first().map_or(0, |s| s.len());
for row in pattern {
if row.len() != width {
return false; // All rows must have the same length
return false;
}
for j in 0..width / 2 {
if row.chars().nth(j) != row.chars().nth(width - j - 1) {
return false; // Characters must match symmetrically
return false;
}
}
}
@ -81,26 +72,23 @@ fn is_symmetrical_horizontally(pattern: &'static [&'static str]) -> bool {
}
/// Checks if a crafting recipe matches the current inventory state.
///
/// Tries the recipe at all possible positions in the grid and handles
/// both orientations for asymmetrical recipes.
#[expect(clippy::too_many_lines)]
async fn recipe_matches<'a>(
recipe: &'static CraftingRecipeTypes,
async fn recipe_matches(
recipe: GenericRecipe<'_>,
input_height: usize,
input_width: usize,
top_x: usize,
top_y: usize,
count: usize,
inventory: &'a dyn RecipeInputInventory,
) -> Option<&'a RecipeResultStruct> {
inventory: &dyn RecipeInputInventory,
) -> Option<RecipeResult> {
match recipe {
CraftingRecipeTypes::CraftingShaped {
GenericRecipe::Vanilla(CraftingRecipeTypes::CraftingShaped {
key,
pattern,
result,
..
} => {
}) => {
if pattern.len() != input_height || pattern.first().unwrap().len() != input_width {
return None;
}
@ -118,11 +106,10 @@ async fn recipe_matches<'a>(
let y_offset = top_y;
let mut matched = true;
'outer: for y in 0..pattern.len() {
for x in 0..pattern[y].len() {
let current_key = pattern[y].chars().nth(x).unwrap();
'outer: for (y, row_str) in pattern.iter().enumerate() {
for (x, current_key) in row_str.chars().enumerate() {
let slot = inventory
.get_stack((y + y_offset) * inventory.get_height() + (x + x_offset))
.get_stack((y + y_offset) * inventory.get_width() + (x + x_offset))
.await;
if current_key == ' ' {
if !slot.lock().await.is_empty() {
@ -146,13 +133,11 @@ async fn recipe_matches<'a>(
}
}
// Check for asymmetrical recipes
if !matched && !is_symmetrical_horizontally(pattern) {
matched = true;
'outer: for y in 0..pattern.len() {
for x in 0..pattern[y].len() {
let current_key = pattern[y].chars().nth(x).unwrap();
let slot = inventory
.get_stack(
(y + y_offset) * inventory.get_height()
@ -166,14 +151,11 @@ async fn recipe_matches<'a>(
}
continue;
}
let ingredient = key
.iter()
.find_map(|(k, v)| (*k == current_key).then_some(v))
.expect("Crafting recipe used invalid key");
let slot = slot.lock().await;
if !ingredient.match_item(slot.item) {
matched = false;
break 'outer;
@ -182,75 +164,70 @@ async fn recipe_matches<'a>(
}
}
// TODO: Apply components
matched.then_some(result)
matched.then_some(RecipeResult {
item_id: result.id.to_string(),
count: result.count,
})
}
CraftingRecipeTypes::CraftingShapeless {
GenericRecipe::Vanilla(CraftingRecipeTypes::CraftingShapeless {
ingredients,
result,
..
} => {
}) => {
if count != ingredients.len() {
return None;
}
let mut ingredient_used = vec![false; ingredients.len()];
'next_slot: for i in 0..inventory.size() {
let slot = inventory.get_stack(i).await;
let slot = slot.lock().await;
if slot.is_empty() {
continue 'next_slot;
}
for i in 0..ingredients.len() {
if !ingredient_used[i] && ingredients[i].match_item(slot.item) {
ingredient_used[i] = true;
continue 'next_slot;
}
}
return None;
}
// TODO: Apply components
Some(result)
Some(RecipeResult {
item_id: result.id.to_string(),
count: result.count,
})
}
CraftingRecipeTypes::CraftingTransmute {
GenericRecipe::Vanilla(CraftingRecipeTypes::CraftingTransmute {
input,
material,
result,
..
} => {
}) => {
if count != 2 {
return None;
}
'item_stack: for i in 0..inventory.size() {
let slot = inventory.get_stack(i).await;
let slot = slot.lock().await;
if slot.is_empty() {
continue 'item_stack;
}
if !material.match_item(slot.item) && !input.match_item(slot.item) {
return None;
}
}
// TODO: Copy components
Some(result)
Some(RecipeResult {
item_id: result.id.to_string(),
count: result.count,
})
}
CraftingRecipeTypes::CraftingDecoratedPot { .. } => {
GenericRecipe::Vanilla(CraftingRecipeTypes::CraftingDecoratedPot { .. }) => {
if count != 4 || inventory.get_width() != 3 || inventory.get_height() != 3 {
return None;
}
for position in (1..=7).step_by(2) {
let slot = inventory.get_stack(position).await;
let slot = slot.lock().await;
if slot.is_empty()
|| !slot
.item
@ -259,34 +236,106 @@ async fn recipe_matches<'a>(
return None;
}
}
// TODO: Handle side textures
Some(&RecipeResultStruct {
id: "minecraft:decorated_pot",
Some(RecipeResult {
item_id: "minecraft:decorated_pot".to_string(),
count: 1,
})
}
CraftingRecipeTypes::CraftingSpecial => None,
GenericRecipe::Dynamic(OwnedCraftingRecipe::Shaped {
pattern,
key,
result,
..
}) => {
if pattern.len() != input_height || pattern.first().unwrap().len() != input_width {
return None;
}
if count
!= pattern
.iter()
.map(|l| l.chars().filter(|c| *c != ' ').count())
.sum::<usize>()
{
return None;
}
let x_offset = top_x;
let y_offset = top_y;
let mut matched = true;
'outer: for (y, row_str) in pattern.iter().enumerate() {
for (x, current_key) in row_str.chars().enumerate() {
let slot = inventory
.get_stack((y + y_offset) * inventory.get_width() + (x + x_offset))
.await;
if current_key == ' ' {
if !slot.lock().await.is_empty() {
matched = false;
break 'outer;
}
continue;
}
let ingredient = key
.iter()
.find(|(k, _)| *k == current_key)
.map(|(_, v)| v)
.expect("Crafting recipe used invalid key");
let slot = slot.lock().await;
if !ingredient.match_item(slot.item) {
matched = false;
break 'outer;
}
}
}
matched.then_some(RecipeResult {
item_id: result.item_id.clone(),
count: result.count,
})
}
GenericRecipe::Dynamic(OwnedCraftingRecipe::Shapeless {
ingredients,
result,
..
}) => {
if count != ingredients.len() {
return None;
}
let mut ingredient_used = vec![false; ingredients.len()];
'next_slot: for i in 0..inventory.size() {
let slot = inventory.get_stack(i).await;
let slot = slot.lock().await;
if slot.is_empty() {
continue 'next_slot;
}
for i in 0..ingredients.len() {
if !ingredient_used[i] && ingredients[i].match_item(slot.item) {
ingredient_used[i] = true;
continue 'next_slot;
}
}
return None;
}
Some(RecipeResult {
item_id: result.item_id.clone(),
count: result.count,
})
}
_ => None,
}
}
impl ResultSlot {
//fn stat_crafted(&self, _crafted_amount: u8, _player: &dyn InventoryPlayer) {}
/// Creates a new result slot for the given crafting inventory.
pub fn new(inventory: Arc<dyn RecipeInputInventory>) -> Self {
pub fn new(
inventory: Arc<dyn RecipeInputInventory>,
provider: Option<Arc<dyn RecipeProvider>>,
) -> Self {
Self {
inventory,
id: AtomicU8::new(0),
result: Arc::new(Mutex::new(ItemStack::EMPTY.clone())),
recipe_cache: AtomicCell::new(None),
recipe_provider: provider,
}
}
/// Matches the recipe in the crafting inventory and returns the result.
///
/// If no recipe matches, returns `None`.
async fn match_recipe(&self) -> Option<(&RecipeResultStruct, &'static CraftingRecipeTypes)> {
async fn match_recipe(&self) -> Option<RecipeResult> {
let mut count: usize = 0;
let inventory_width = self.inventory.get_width();
let mut top_x = 9;
@ -296,7 +345,6 @@ impl ResultSlot {
for i in 0..self.inventory.size() {
let x = i % inventory_width;
let y = i / inventory_width;
let slot = self.inventory.get_stack(i).await;
let slot = slot.lock().await;
if !slot.is_empty() {
@ -307,31 +355,15 @@ impl ResultSlot {
count += 1;
}
}
if count == 0 {
return None;
}
let input_width = bottom_x + 1 - top_x;
let input_height = bottom_y + 1 - top_y;
if let Some(cached_recipe) = self.recipe_cache.load()
&& let Some(result) = recipe_matches(
cached_recipe,
input_height,
input_width,
top_x,
top_y,
count,
&*self.inventory,
)
.await
{
return Some((result, cached_recipe));
}
for recipe in RECIPES_CRAFTING {
if let Some(result) = recipe_matches(
recipe,
GenericRecipe::Vanilla(recipe),
input_height,
input_width,
top_x,
@ -341,20 +373,44 @@ impl ResultSlot {
)
.await
{
self.recipe_cache.store(Some(recipe));
return Some((result, recipe));
return Some(result);
}
}
if let Some(provider) = &self.recipe_provider {
let dynamic = provider.get_dynamic_recipes().await;
for recipe in &dynamic {
if let DynamicRecipe::Crafting(crafting) = recipe
&& let Some(result) = recipe_matches(
GenericRecipe::Dynamic(crafting),
input_height,
input_width,
top_x,
top_y,
count,
&*self.inventory,
)
.await
{
return Some(result);
}
}
}
None
}
/// Refills the output slot with the current recipe result.
async fn refill_output(&self) -> ItemStack {
let result = self
.match_recipe()
.await
.map_or(ItemStack::EMPTY.clone(), |x| ItemStack::from(x.0));
let result = if let Some(matched) = self.match_recipe().await {
let key = matched
.item_id
.strip_prefix("minecraft:")
.unwrap_or(&matched.item_id);
let item = pumpkin_data::item::Item::from_registry_key(key)
.unwrap_or(&pumpkin_data::item::Item::AIR);
ItemStack::new(matched.count, item)
} else {
ItemStack::EMPTY.clone()
};
*self.result.lock().await = result.clone();
result
}
@ -364,26 +420,21 @@ impl Slot for ResultSlot {
fn get_inventory(&self) -> Arc<dyn Inventory> {
self.inventory.clone()
}
fn get_index(&self) -> usize {
999 // this slot does not belong to any inventory
999
}
fn set_id(&self, id: usize) {
self.id.store(id as u8, Ordering::Relaxed);
}
fn on_quick_move_crafted(
&self,
_stack: ItemStack,
_stack_prev: ItemStack,
) -> BoxFuture<'_, ()> {
Box::pin(async move {
// refill the result slot with the recipe result
self.refill_output().await;
})
}
fn on_take_item<'a>(
&'a self,
_player: &'a dyn InventoryPlayer,
@ -394,50 +445,39 @@ impl Slot for ResultSlot {
let slot = self.inventory.get_stack(i).await;
let mut stack = slot.lock().await;
if !stack.is_empty() {
//TODO: Handle remaining items.
stack.item_count -= 1;
}
}
// TODO
//self.stat_crafted(stack.item_count, player);
self.mark_dirty().await;
})
}
fn can_insert(&self, _stack: &ItemStack) -> BoxFuture<'_, bool> {
Box::pin(async move { false })
}
fn get_stack(&self) -> BoxFuture<'_, Arc<Mutex<ItemStack>>> {
Box::pin(async move { self.result.clone() })
}
fn get_cloned_stack(&self) -> BoxFuture<'_, ItemStack> {
Box::pin(async move { self.result.lock().await.clone() })
}
fn has_stack(&self) -> BoxFuture<'_, bool> {
Box::pin(async move { !self.result.lock().await.is_empty() })
}
fn set_stack(&self, _stack: ItemStack) -> BoxFuture<'_, ()> {
Box::pin(async move {
self.refill_output().await;
})
}
fn set_stack_prev(&self, _stack: ItemStack, _previous_stack: ItemStack) -> BoxFuture<'_, ()> {
Box::pin(async move {
self.refill_output().await;
})
}
fn mark_dirty(&self) -> BoxFuture<'_, ()> {
Box::pin(async move {
self.inventory.mark_dirty();
})
}
fn get_max_item_count(&self) -> BoxFuture<'_, u8> {
Box::pin(async move {
let mut count = u8::MAX;
@ -451,14 +491,10 @@ impl Slot for ResultSlot {
count
})
}
fn take_stack(&self, _amount: u8) -> BoxFuture<'_, ItemStack> {
Box::pin(async move {
if self.has_stack().await {
let stack = self.result.lock().await;
// Vanilla: net.minecraft.world.inventory.ResultContainer#removeItem
// Regardless of the amount, we always return the full stack
stack.clone()
self.result.lock().await.clone()
} else {
ItemStack::EMPTY.clone()
}
@ -478,7 +514,6 @@ impl ScreenHandlerListener for ResultSlot {
.contains(&(slot as usize))
{
let result = self.refill_output().await;
let next_revision = screen_handler.next_revision();
if let Some(sync_handler) = screen_handler.sync_handler.as_ref() {
sync_handler
@ -490,73 +525,52 @@ impl ScreenHandlerListener for ResultSlot {
}
}
/// Trait for crafting screen handlers.
///
/// Provides common functionality for crafting UIs including slot setup
/// and recipe result management.
// AbstractCraftingScreenHandler.java
pub trait CraftingScreenHandler<I: RecipeInputInventory>:
RecipeFinderScreenHandler + ScreenHandler
{
/// Adds the result slot and crafting grid slots to the screen handler.
fn add_recipe_slots<'a>(
&'a mut self,
crafing_inventory: Arc<dyn RecipeInputInventory>,
provider: Option<Arc<dyn RecipeProvider>>,
) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
Box::pin(async move {
let result_slot = Arc::new(ResultSlot::new(crafing_inventory.clone()));
let result_slot = Arc::new(ResultSlot::new(crafing_inventory.clone(), provider));
self.add_slot(result_slot.clone());
let width = crafing_inventory.get_width();
let height = crafing_inventory.get_height();
for i in 0..width {
for j in 0..height {
// Assuming j + i * width is the correct slot index calculation
let input_slot = NormalSlot::new(crafing_inventory.clone(), j + i * width);
self.add_slot(Arc::new(input_slot));
}
}
self.add_listener(result_slot).await;
})
}
}
/// Screen handler for the crafting table.
///
/// The crafting table provides a 3x3 crafting grid and displays
/// the result of the current recipe configuration.
// CraftingMenu
pub struct CraftingTableScreenHandler {
/// Core screen handler behavior (slots, sync ID, listeners).
behaviour: ScreenHandlerBehaviour,
/// The 3x3 crafting grid inventory.
crafting_inventory: Arc<dyn RecipeInputInventory>,
}
impl CraftingTableScreenHandler {
/// Creates a new crafting table screen handler.
///
/// # Arguments
/// - `sync_id` - The sync ID for client-server matching
/// - `player_inventory` - The player's inventory
pub async fn new(sync_id: u8, player_inventory: &Arc<PlayerInventory>) -> Self {
pub async fn new(
sync_id: u8,
player_inventory: &Arc<PlayerInventory>,
provider: Option<Arc<dyn RecipeProvider>>,
) -> Self {
let crafting_inventory: Arc<dyn RecipeInputInventory> =
Arc::new(CraftingInventory::new(3, 3));
let mut crafting_table_handler = Self {
behaviour: ScreenHandlerBehaviour::new(sync_id, Some(WindowType::Crafting)),
crafting_inventory: crafting_inventory.clone(),
};
crafting_table_handler
.add_recipe_slots(crafting_inventory)
.add_recipe_slots(crafting_inventory, provider)
.await;
// Add player inventory slots
let player_inventory: Arc<dyn Inventory> = player_inventory.clone();
crafting_table_handler.add_player_slots(&player_inventory);
crafting_table_handler
}
}
@ -567,33 +581,22 @@ impl ScreenHandler for CraftingTableScreenHandler {
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;
//TODO: this.craftingResultInventory.clear();
self.drop_inventory(player, self.crafting_inventory.clone())
.await;
})
}
/// Quick move logic for crafting table.
///
/// - Result slot (0): Move to player inventory (10-46)
/// - Crafting grid (1-9): Move to player inventory
/// - Player inventory (10-46): Move to crafting grid first, then shuffle within inventory
fn quick_move<'a>(
&'a mut self,
player: &'a dyn InventoryPlayer,
@ -601,74 +604,52 @@ impl ScreenHandler for CraftingTableScreenHandler {
) -> ItemStackFuture<'a> {
Box::pin(async move {
let slot = self.get_behaviour().slots[slot_index as usize].clone();
if slot.has_stack().await {
let slot_stack = slot.get_stack().await;
let mut slot_stack = slot_stack.lock().await;
let stack_prev = slot_stack.clone();
if slot_index == 0 {
// From crafting result slot - move to player inventory (slots 10-46)
if !self.insert_item(&mut slot_stack, 10, 46, true).await {
return ItemStack::EMPTY.clone();
}
} else if (1..=9).contains(&slot_index) {
// From crafting input slots - try to move to player inventory (slots 10-46)
if !self.insert_item(&mut slot_stack, 10, 46, false).await {
return ItemStack::EMPTY.clone();
}
} else if (10..46).contains(&slot_index) {
// From player inventory - try to move to crafting input slots first (1-9)
if !self.insert_item(&mut slot_stack, 1, 10, false).await {
// If that fails, try moving within player inventory
if slot_index < 37 {
// From main inventory to hotbar
if !self.insert_item(&mut slot_stack, 37, 46, false).await {
return ItemStack::EMPTY.clone();
}
} else {
// From hotbar to main inventory
if !self.insert_item(&mut slot_stack, 10, 37, false).await {
} else if !self.insert_item(&mut slot_stack, 10, 37, false).await {
return ItemStack::EMPTY.clone();
}
}
}
} else {
// Any other slot - try to move to player inventory
if !self.insert_item(&mut slot_stack, 10, 46, false).await {
} else if !self.insert_item(&mut slot_stack, 10, 46, false).await {
return ItemStack::EMPTY.clone();
}
}
let stack = slot_stack.clone();
drop(slot_stack); // release the lock before calling other methods
drop(slot_stack);
if stack.is_empty() {
slot.set_stack_prev(ItemStack::EMPTY.clone(), stack_prev.clone())
.await;
} else {
slot.mark_dirty().await;
}
if stack.item_count == stack_prev.item_count {
// Nothing changed
return ItemStack::EMPTY.clone();
}
slot.on_take_item(player, &stack).await;
if slot_index == 0 {
slot.on_quick_move_crafted(stack.clone(), stack_prev.clone())
.await;
// For crafting result slot, drop any remaining items
if !stack.is_empty() {
player.drop_item(stack, false).await;
}
}
return stack_prev;
}
ItemStack::EMPTY.clone()
})
}

View File

@ -7,4 +7,5 @@
pub mod crafting_inventory;
pub mod crafting_screen_handler;
pub mod recipe_provider;
pub mod recipes;

View File

@ -0,0 +1,26 @@
use crate::slot::BoxFuture;
use pumpkin_protocol::codec::recipe::DynamicRecipe;
pub trait RecipeProvider: Send + Sync {
fn get_dynamic_recipes(&self) -> BoxFuture<'_, Vec<DynamicRecipe>>;
}
pub enum GenericRecipe<'a> {
Vanilla(&'a pumpkin_data::recipes::CraftingRecipeTypes),
Dynamic(&'a pumpkin_protocol::codec::recipe::OwnedCraftingRecipe),
}
pub enum IngredientRef<'a> {
Vanilla(&'a pumpkin_data::recipes::RecipeIngredientTypes),
Dynamic(&'a pumpkin_protocol::codec::recipe::OwnedRecipeIngredient),
}
impl IngredientRef<'_> {
#[must_use]
pub fn match_item(&self, item: &pumpkin_data::item::Item) -> bool {
match self {
Self::Vanilla(v) => v.match_item(item),
Self::Dynamic(d) => d.match_item(item),
}
}
}

View File

@ -81,6 +81,7 @@ impl PlayerScreenHandler {
player_inventory: &Arc<PlayerInventory>,
window_type: Option<WindowType>,
sync_id: u8,
provider: Option<Arc<dyn crate::crafting::recipe_provider::RecipeProvider>>,
) -> Self {
let crafting_inventory: Arc<dyn RecipeInputInventory> =
Arc::new(CraftingInventory::new(2, 2));
@ -91,7 +92,7 @@ impl PlayerScreenHandler {
};
player_screen_handler
.add_recipe_slots(crafting_inventory)
.add_recipe_slots(crafting_inventory, provider)
.await;
// Add armor slots (head, chest, legs, feet)

View File

@ -0,0 +1,67 @@
interface recipe {
use item-stack.{item-stack};
/// Represents an ingredient in a recipe.
variant ingredient {
/// A specific item.
item(string),
/// A tag group of items (e.g., "minecraft:logs").
tag(string),
}
/// Represents a shaped crafting recipe.
record shaped-recipe {
/// The pattern of the recipe (e.g., ["# #", " s ", "# #"]).
pattern: list<string>,
/// Mapping of characters in the pattern to ingredients.
key: list<tuple<string, ingredient>>,
/// The resulting item stack.
output: item-stack,
/// The recipe group (optional).
group: option<string>,
}
/// Represents a shapeless crafting recipe.
record shapeless-recipe {
/// The list of ingredients required.
ingredients: list<ingredient>,
/// The resulting item stack.
output: item-stack,
/// The recipe group (optional).
group: option<string>,
}
/// Represents a cooking recipe (Smelting, Blasting, Smoking, Campfire).
record cooking-recipe {
/// The ingredient to cook.
ingredient: ingredient,
/// The resulting item stack.
output: item-stack,
/// Experience points awarded.
experience: f32,
/// Cooking time in ticks.
cooking-time: u32,
/// The recipe group (optional).
group: option<string>,
}
/// Types of cooking stations.
enum cooking-type {
smelting,
blasting,
smoking,
campfire,
}
/// Common interface for registering recipes.
resource recipe-manager {
/// Registers a shaped crafting recipe.
register-shaped: func(id: string, recipe: shaped-recipe);
/// Registers a shapeless crafting recipe.
register-shapeless: func(id: string, recipe: shapeless-recipe);
/// Registers a cooking recipe.
register-cooking: func(id: string, station-type: cooking-type, recipe: cooking-recipe);
}
}

View File

@ -4,6 +4,7 @@ interface server {
use %world.{%world};
use text.{text-component};
use uuid.{uuid};
use recipe.{recipe-manager};
/// Represents the difficulty setting of a Minecraft server.
enum difficulty {
@ -114,5 +115,8 @@ interface server {
/// Returns the server's default gamemode.
get-default-gamemode: func() -> game-mode;
/// Returns the recipe manager to register custom recipes.
get-recipe-manager: func() -> recipe-manager;
}
}

View File

@ -5,6 +5,7 @@ pub mod item_stack_seralizer;
pub mod little_endian;
pub mod lp_vector_3d;
pub mod optional_int;
pub mod recipe;
mod u24_type;
pub mod uuid;
pub mod var_int;

View File

@ -0,0 +1,81 @@
use pumpkin_data::recipes::RecipeCategoryTypes;
use serde::Serialize;
use pumpkin_data::item::Item;
use pumpkin_data::tag::Taggable;
#[derive(Clone, Debug, Serialize)]
pub enum OwnedRecipeIngredient {
Simple(String),
Tagged(String),
OneOf(Vec<String>),
}
impl OwnedRecipeIngredient {
#[must_use]
pub fn match_item(&self, item: &Item) -> bool {
match self {
Self::Simple(id) => {
let name = format!("minecraft:{}", item.registry_key);
name == *id
}
Self::Tagged(tag) => item
.is_tagged_with(tag)
.expect("Crafting recipe used invalid tag"),
Self::OneOf(ids) => {
let name = format!("minecraft:{}", item.registry_key);
ids.contains(&name)
}
}
}
}
#[derive(Clone, Debug, Serialize)]
pub struct OwnedRecipeResult {
pub item_id: String,
pub count: u8,
// TODO: Add components/enchantments if needed for the display result
}
#[derive(Clone, Debug, Serialize)]
pub enum OwnedCraftingRecipe {
Shaped {
category: RecipeCategoryTypes,
group: Option<String>,
show_notification: bool,
key: Vec<(char, OwnedRecipeIngredient)>,
pattern: Vec<String>,
result: OwnedRecipeResult,
},
Shapeless {
category: RecipeCategoryTypes,
group: Option<String>,
ingredients: Vec<OwnedRecipeIngredient>,
result: OwnedRecipeResult,
},
}
#[derive(Clone, Debug, Serialize)]
pub struct OwnedCookingRecipe {
pub recipe_id: String,
pub category: RecipeCategoryTypes,
pub group: Option<String>,
pub ingredient: OwnedRecipeIngredient,
pub cooking_time: i32,
pub experience: f32,
pub result: OwnedRecipeResult,
}
#[derive(Clone, Debug, Serialize)]
pub enum OwnedCookingRecipeType {
Blasting(OwnedCookingRecipe),
Smelting(OwnedCookingRecipe),
Smoking(OwnedCookingRecipe),
CampfireCooking(OwnedCookingRecipe),
}
#[derive(Clone, Debug, Serialize)]
pub enum DynamicRecipe {
Crafting(OwnedCraftingRecipe),
Cooking(OwnedCookingRecipeType),
}

View File

@ -49,17 +49,23 @@ const CATEGORY_BLAST_FURNACE_MISC: i32 = 8;
const CATEGORY_SMOKER_FOOD: i32 = 9;
const CATEGORY_CAMPFIRE: i32 = 12;
use crate::codec::recipe::DynamicRecipe;
/// Clientbound packet that adds recipes to the client's recipe book.
/// `replace = true` means the client replaces its current recipe list.
#[java_packet(PLAY_RECIPE_BOOK_ADD)]
pub struct CRecipeBookAdd {
pub struct CRecipeBookAdd<'a> {
pub replace: bool,
pub dynamic_recipes: &'a [DynamicRecipe],
}
impl CRecipeBookAdd {
impl<'a> CRecipeBookAdd<'a> {
#[must_use]
pub const fn new(replace: bool) -> Self {
Self { replace }
pub const fn new(replace: bool, dynamic_recipes: &'a [DynamicRecipe]) -> Self {
Self {
replace,
dynamic_recipes,
}
}
}
@ -255,20 +261,6 @@ fn write_optional_var_int(write: &mut impl Write, value: Option<i32>) -> Result<
write.write_var_int(&VarInt(encoded))?;
Ok(())
}
fn resolve_group_id(
group_ids: &mut HashMap<&'static str, i32>,
next_group_id: &mut i32,
group: Option<&'static str>,
) -> Option<i32> {
let key = group?;
Some(*group_ids.entry(key).or_insert_with(|| {
let id = *next_group_id;
*next_group_id += 1;
id
}))
}
const fn entry_flags(replace: bool, notification: bool, highlight: bool) -> u8 {
if replace {
return 0;
@ -463,7 +455,10 @@ fn write_entry(
Ok(false)
}
impl ClientPacket for CRecipeBookAdd {
use std::borrow::Cow;
#[allow(clippy::too_many_lines)]
impl ClientPacket for CRecipeBookAdd<'_> {
fn write_packet_data(
&self,
write: impl Write,
@ -494,13 +489,14 @@ impl ClientPacket for CRecipeBookAdd {
)
})
.count();
let total = crafting_count + RECIPES_COOKING.len();
let dynamic_count = self.dynamic_recipes.len();
let total = crafting_count + RECIPES_COOKING.len() + dynamic_count;
// Entry count (VarInt)
write.write_var_int(&VarInt(total as i32))?;
let mut display_id: i32 = 0;
let mut group_ids: HashMap<&'static str, i32> = HashMap::new();
let mut group_ids: HashMap<Cow<'_, str>, i32> = HashMap::new();
let mut next_group_id: i32 = 0;
let highlight = !self.replace;
@ -511,13 +507,15 @@ impl ClientPacket for CRecipeBookAdd {
group,
show_notification,
..
} => (*group, *show_notification),
} => (group.map(Cow::Borrowed), *show_notification),
CraftingRecipeTypes::CraftingShapeless { group, .. }
| CraftingRecipeTypes::CraftingTransmute { group, .. } => (*group, true),
| CraftingRecipeTypes::CraftingTransmute { group, .. } => {
(group.map(Cow::Borrowed), true)
}
CraftingRecipeTypes::CraftingDecoratedPot { .. }
| CraftingRecipeTypes::CraftingSpecial => (None, true),
};
let group_id = resolve_group_id(&mut group_ids, &mut next_group_id, group);
let group_id = resolve_group_id_owned(&mut group_ids, &mut next_group_id, group);
let flags = entry_flags(self.replace, notification, highlight);
let written = write_entry(
&mut write,
@ -559,7 +557,11 @@ impl ClientPacket for CRecipeBookAdd {
CookingRecipeType::Smoking(r) => (CATEGORY_SMOKER_FOOD, r.group),
CookingRecipeType::CampfireCooking(r) => (CATEGORY_CAMPFIRE, r.group),
};
let group_id = resolve_group_id(&mut group_ids, &mut next_group_id, group);
let group_id = resolve_group_id_owned(
&mut group_ids,
&mut next_group_id,
group.map(Cow::Borrowed),
);
let flags = entry_flags(self.replace, true, highlight);
write_entry(
&mut write,
@ -578,8 +580,318 @@ impl ClientPacket for CRecipeBookAdd {
display_id += 1;
}
// Write dynamic recipes
for recipe in self.dynamic_recipes {
match recipe {
DynamicRecipe::Crafting(crafting) => {
let (group, flags) = match crafting {
crate::codec::recipe::OwnedCraftingRecipe::Shaped {
group,
show_notification,
..
} => (
group.as_deref().map(Cow::Borrowed),
entry_flags(self.replace, *show_notification, highlight),
),
crate::codec::recipe::OwnedCraftingRecipe::Shapeless { group, .. } => (
group.as_deref().map(Cow::Borrowed),
entry_flags(self.replace, true, highlight),
),
};
let group_id =
resolve_group_id_owned(&mut group_ids, &mut next_group_id, group);
write_dynamic_crafting_entry(
&mut write,
display_id,
*version,
group_id,
flags,
crafting_table,
crafting,
)?;
}
DynamicRecipe::Cooking(cooking) => {
let (book_category, group, owned_cooking) = match cooking {
crate::codec::recipe::OwnedCookingRecipeType::Smelting(r) => (
match r.category {
RecipeCategoryTypes::Food => CATEGORY_FURNACE_FOOD,
RecipeCategoryTypes::Blocks => CATEGORY_FURNACE_BLOCKS,
_ => CATEGORY_FURNACE_MISC,
},
r.group.as_deref().map(Cow::Borrowed),
r,
),
crate::codec::recipe::OwnedCookingRecipeType::Blasting(r) => (
match r.category {
RecipeCategoryTypes::Blocks => CATEGORY_BLAST_FURNACE_BLOCKS,
_ => CATEGORY_BLAST_FURNACE_MISC,
},
r.group.as_deref().map(Cow::Borrowed),
r,
),
crate::codec::recipe::OwnedCookingRecipeType::Smoking(r) => (
CATEGORY_SMOKER_FOOD,
r.group.as_deref().map(Cow::Borrowed),
r,
),
crate::codec::recipe::OwnedCookingRecipeType::CampfireCooking(r) => {
(CATEGORY_CAMPFIRE, r.group.as_deref().map(Cow::Borrowed), r)
}
};
let station = match cooking {
crate::codec::recipe::OwnedCookingRecipeType::Smelting(_) => furnace,
crate::codec::recipe::OwnedCookingRecipeType::Blasting(_) => blast_furnace,
crate::codec::recipe::OwnedCookingRecipeType::Smoking(_) => smoker,
crate::codec::recipe::OwnedCookingRecipeType::CampfireCooking(_) => {
campfire
}
};
let group_id =
resolve_group_id_owned(&mut group_ids, &mut next_group_id, group);
let flags = entry_flags(self.replace, true, highlight);
write_dynamic_cooking_entry(
&mut write,
display_id,
*version,
group_id,
flags,
station,
owned_cooking,
book_category,
)?;
}
}
display_id += 1;
}
// replace flag
write.write_bool(self.replace)?;
Ok(())
}
}
fn resolve_group_id_owned<'a>(
group_ids: &mut HashMap<Cow<'a, str>, i32>,
next_group_id: &mut i32,
group: Option<Cow<'a, str>>,
) -> Option<i32> {
let key = group?;
Some(*group_ids.entry(key).or_insert_with(|| {
let id = *next_group_id;
*next_group_id += 1;
id
}))
}
fn write_dynamic_ingredient_slot_display(
write: &mut impl Write,
ingredient: &crate::codec::recipe::OwnedRecipeIngredient,
version: JavaMinecraftVersion,
) -> Result<(), WritingError> {
match ingredient {
crate::codec::recipe::OwnedRecipeIngredient::Simple(id) => {
let key = id.strip_prefix("minecraft:").unwrap_or(id);
if let Some(item) = Item::from_registry_key(key) {
write_item_slot_display(write, item, version)?;
} else {
write_empty_slot_display(write)?;
}
}
crate::codec::recipe::OwnedRecipeIngredient::Tagged(_tag) => {
write_empty_slot_display(write)?;
}
crate::codec::recipe::OwnedRecipeIngredient::OneOf(ids) => {
let items: Vec<&Item> = ids
.iter()
.filter_map(|id| {
let key = id.strip_prefix("minecraft:").unwrap_or(id);
Item::from_registry_key(key)
})
.collect();
if items.is_empty() {
write_empty_slot_display(write)?;
} else if items.len() == 1 {
write_item_slot_display(write, items[0], version)?;
} else {
write.write_var_int(&VarInt(slot_display_composite_type(version)))?;
write.write_var_int(&VarInt(items.len() as i32))?;
for item in &items {
write_item_slot_display(write, item, version)?;
}
}
}
}
Ok(())
}
fn write_dynamic_ingredient_holderset(
write: &mut impl Write,
ingredient: Option<&crate::codec::recipe::OwnedRecipeIngredient>,
version: JavaMinecraftVersion,
) -> Result<(), WritingError> {
match ingredient {
None => {
write.write_var_int(&VarInt(1))?;
}
Some(crate::codec::recipe::OwnedRecipeIngredient::Simple(id)) => {
let key = id.strip_prefix("minecraft:").unwrap_or(id);
if let Some(item) = Item::from_registry_key(key) {
write.write_var_int(&VarInt(2))?;
write.write_var_int(&VarInt(item_id_versioned(item, version)))?;
} else {
write.write_var_int(&VarInt(1))?;
}
}
Some(crate::codec::recipe::OwnedRecipeIngredient::Tagged(_tag)) => {
write.write_var_int(&VarInt(1))?;
}
Some(crate::codec::recipe::OwnedRecipeIngredient::OneOf(ids)) => {
let items: Vec<i32> = ids
.iter()
.filter_map(|id| {
let key = id.strip_prefix("minecraft:").unwrap_or(id);
Item::from_registry_key(key).map(|item| item_id_versioned(item, version))
})
.collect();
write.write_var_int(&VarInt(items.len() as i32 + 1))?;
for id in &items {
write.write_var_int(&VarInt(*id))?;
}
}
}
Ok(())
}
fn write_dynamic_result_slot_display(
write: &mut impl Write,
result: &crate::codec::recipe::OwnedRecipeResult,
version: JavaMinecraftVersion,
) -> Result<(), WritingError> {
let key = result
.item_id
.strip_prefix("minecraft:")
.unwrap_or(&result.item_id);
if let Some(item) = Item::from_registry_key(key) {
write_item_stack_slot_display(write, item, result.count, version)?;
} else {
write_empty_slot_display(write)?;
}
Ok(())
}
fn write_dynamic_crafting_entry(
write: &mut impl Write,
display_id: i32,
version: JavaMinecraftVersion,
group_id: Option<i32>,
flags: u8,
crafting_table: &Item,
recipe: &crate::codec::recipe::OwnedCraftingRecipe,
) -> Result<(), WritingError> {
match recipe {
crate::codec::recipe::OwnedCraftingRecipe::Shaped {
category,
pattern,
key,
result,
..
} => {
let height = pattern.len() as i32;
let width = pattern.first().map_or(0, String::len) as i32;
write.write_var_int(&VarInt(display_id))?;
write.write_var_int(&VarInt(RECIPE_DISPLAY_SHAPED))?;
write.write_var_int(&VarInt(width))?;
write.write_var_int(&VarInt(height))?;
write.write_var_int(&VarInt(width * height))?;
for row in pattern {
for ch in row.chars() {
if ch == ' ' {
write_empty_slot_display(write)?;
} else if let Some((_, ingredient)) = key.iter().find(|(k, _)| *k == ch) {
write_dynamic_ingredient_slot_display(write, ingredient, version)?;
} else {
write_empty_slot_display(write)?;
}
}
}
write_dynamic_result_slot_display(write, result, version)?;
write_item_slot_display(write, crafting_table, version)?;
write_optional_var_int(write, group_id)?;
write.write_var_int(&VarInt(crafting_category(category)))?;
write.write_bool(true)?; // present
let mut non_empty_slots = 0;
for row in pattern {
for ch in row.chars() {
if ch != ' ' {
non_empty_slots += 1;
}
}
}
write.write_var_int(&VarInt(non_empty_slots))?;
for row in pattern {
for ch in row.chars() {
if ch != ' ' {
let ing = key.iter().find(|(k, _)| *k == ch).map(|(_, i)| i);
write_dynamic_ingredient_holderset(write, ing, version)?;
}
}
}
write.write_u8(flags)?;
}
crate::codec::recipe::OwnedCraftingRecipe::Shapeless {
category,
ingredients,
result,
..
} => {
write.write_var_int(&VarInt(display_id))?;
write.write_var_int(&VarInt(RECIPE_DISPLAY_SHAPELESS))?;
write.write_var_int(&VarInt(ingredients.len() as i32))?;
for ing in ingredients {
write_dynamic_ingredient_slot_display(write, ing, version)?;
}
write_dynamic_result_slot_display(write, result, version)?;
write_item_slot_display(write, crafting_table, version)?;
write_optional_var_int(write, group_id)?;
write.write_var_int(&VarInt(crafting_category(category)))?;
write.write_bool(true)?;
write.write_var_int(&VarInt(ingredients.len() as i32))?;
for ing in ingredients {
write_dynamic_ingredient_holderset(write, Some(ing), version)?;
}
write.write_u8(flags)?;
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn write_dynamic_cooking_entry(
write: &mut impl Write,
display_id: i32,
version: JavaMinecraftVersion,
group_id: Option<i32>,
flags: u8,
station: &Item,
cooking: &crate::codec::recipe::OwnedCookingRecipe,
book_category: i32,
) -> Result<(), WritingError> {
write.write_var_int(&VarInt(display_id))?;
write.write_var_int(&VarInt(RECIPE_DISPLAY_FURNACE))?;
write_dynamic_ingredient_slot_display(write, &cooking.ingredient, version)?;
write_any_fuel_slot_display(write)?;
write_dynamic_result_slot_display(write, &cooking.result, version)?;
write_item_slot_display(write, station, version)?;
write.write_var_int(&VarInt(cooking.cooking_time))?;
write.write_f32_be(cooking.experience)?;
write_optional_var_int(write, group_id)?;
write.write_var_int(&VarInt(book_category))?;
write_dynamic_ingredient_holderset(write, Some(&cooking.ingredient), version)?;
write.write_u8(flags)?;
Ok(())
}

View File

@ -19,7 +19,10 @@ impl BlockBehaviour for CraftingTableBlock {
fn normal_use<'a>(&'a self, args: NormalUseArgs<'a>) -> BlockFuture<'a, BlockActionResult> {
Box::pin(async move {
args.player
.open_handled_screen(&CraftingTableScreenFactory, Some(*args.position))
.open_handled_screen(
&CraftingTableScreenFactory(args.server.recipe_manager.clone()),
Some(*args.position),
)
.await;
BlockActionResult::Success
@ -27,7 +30,7 @@ impl BlockBehaviour for CraftingTableBlock {
}
}
struct CraftingTableScreenFactory;
struct CraftingTableScreenFactory(Arc<crate::server::RecipeManager>);
impl ScreenHandlerFactory for CraftingTableScreenFactory {
fn create_screen_handler<'a>(
@ -37,7 +40,9 @@ impl ScreenHandlerFactory for CraftingTableScreenFactory {
_player: &'a dyn InventoryPlayer,
) -> BoxFuture<'a, Option<SharedScreenHandler>> {
Box::pin(async move {
let handler = CraftingTableScreenHandler::new(sync_id, player_inventory).await;
let handler =
CraftingTableScreenHandler::new(sync_id, player_inventory, Some(self.0.clone()))
.await;
let concrete_arc = Arc::new(Mutex::new(handler));
Some(concrete_arc as SharedScreenHandler)

View File

@ -536,7 +536,13 @@ impl Player {
let ender_chest_inventory = Arc::new(EnderChestInventory::new());
let player_screen_handler = Arc::new(Mutex::new(
PlayerScreenHandler::new(&inventory, None, 0).await,
PlayerScreenHandler::new(
&inventory,
None,
0,
Some(world.server.upgrade().unwrap().recipe_manager.clone()),
)
.await,
));
// Initialize abilities based on gamemode (like vanilla's GameMode.setAbilities())

View File

@ -60,6 +60,7 @@ pub mod config;
pub mod handshake;
pub mod login;
pub mod play;
pub mod recipe_helper;
pub mod status;
use crate::entity::player::Player;
@ -945,8 +946,8 @@ impl JavaClient {
.await;
}
id if id == SPlaceRecipe::to_id(version) => {
self.handle_place_recipe(player, SPlaceRecipe::read(payload, &version)?)
.await;
let packet = SPlaceRecipe::read(payload, &version)?;
self.handle_place_recipe(server, player, packet).await;
}
id if id
== pumpkin_protocol::java::server::play::SCustomClickAction::to_id(version) =>

View File

@ -926,80 +926,19 @@ impl JavaClient {
}
#[allow(clippy::too_many_lines)]
pub async fn handle_place_recipe(&self, player: &Arc<Player>, packet: SPlaceRecipe) {
use pumpkin_data::recipes::{
CookingRecipeType, CraftingRecipeTypes, RECIPES_COOKING, RECIPES_CRAFTING,
RecipeIngredientTypes,
pub async fn handle_place_recipe(
&self,
server: &Arc<Server>,
player: &Arc<Player>,
packet: SPlaceRecipe,
) {
use super::recipe_helper::{
GenericIngredient, compute_biggest_craftable, take_n_ingredient,
};
use crate::server::recipe::DynamicRecipe;
use pumpkin_data::recipes::{CraftingRecipeTypes, RECIPES_COOKING, RECIPES_CRAFTING};
use pumpkin_data::screen::WindowType;
// Take `amount` items matching `ingredient` from inventory, committing to one item type.
async fn take_n_ingredient(
inventory: &PlayerInventory,
ingredient: &RecipeIngredientTypes,
amount: u8,
) -> ItemStack {
let mut remaining = amount;
let mut result: Option<ItemStack> = None;
for slot in &inventory.main_inventory {
if remaining == 0 {
break;
}
let mut stack = slot.lock().await;
if stack.is_empty() || !ingredient.match_item(stack.item) {
continue;
}
// Commit to the first item type encountered.
if result.as_ref().is_some_and(|r| r.item.id != stack.item.id) {
continue;
}
let take = remaining.min(stack.item_count);
let taken = stack.split(take);
remaining -= taken.item_count;
match &mut result {
None => result = Some(taken),
Some(r) => r.increment(taken.item_count),
}
}
result.unwrap_or_else(|| ItemStack::EMPTY.clone())
}
// Compute the maximum number of times all ingredients can be supplied from inventory.
// Each ingredient slot commits to one item type (no mixing within a slot).
async fn compute_biggest_craftable(
ingredients: &[&RecipeIngredientTypes],
inventory: &PlayerInventory,
) -> u8 {
// Aggregate inventory by item id -> (item, count).
let mut available: Vec<(&'static pumpkin_data::item::Item, u32)> = Vec::new();
for slot in &inventory.main_inventory {
let stack = slot.lock().await;
if !stack.is_empty() {
if let Some(e) = available.iter_mut().find(|(i, _)| i.id == stack.item.id) {
e.1 += u32::from(stack.item_count);
} else {
available.push((stack.item, u32::from(stack.item_count)));
}
}
}
// From 64 down to 1, find the highest amount where every slot can be satisfied.
'outer: for amount in (1u32..=64).rev() {
let mut budget = available.clone();
for ing in ingredients {
// Pick the first item type that satisfies this slot and has enough.
let Some(idx) = budget
.iter()
.position(|(item, count)| *count >= amount && ing.match_item(item))
else {
continue 'outer;
};
budget[idx].1 -= amount;
}
return amount as u8;
}
0
}
use pumpkin_inventory::crafting::recipe_provider::RecipeProvider;
let target_id = packet.recipe_display_id.0 as usize;
let use_max = packet.use_max_items;
@ -1015,6 +954,8 @@ impl JavaClient {
)
})
.count();
let cooking_display_count = RECIPES_COOKING.len();
let dynamic_recipes = server.recipe_manager.get_dynamic_recipes().await;
let (grid_width, crafting_inv) = {
let screen_handler_arc = player.current_screen_handler.lock().await.clone();
@ -1027,6 +968,9 @@ impl JavaClient {
(grid_width, handler.get_behaviour().slots[1].get_inventory())
};
let grid_size = grid_width * grid_width;
let mut ingredient_slots: Vec<Option<GenericIngredient<'_>>> = vec![None; grid_size];
if target_id < crafting_display_count {
// Crafting recipe
let mut counter = 0usize;
@ -1044,10 +988,6 @@ impl JavaClient {
});
let Some(recipe) = recipe else { return };
let grid_size = grid_width * grid_width;
// Map each grid position to its required ingredient (None = empty/unused).
let mut ingredient_slots: Vec<Option<&RecipeIngredientTypes>> = vec![None; grid_size];
match recipe {
CraftingRecipeTypes::CraftingShaped { pattern, key, .. } => {
for (row, row_str) in pattern.iter().enumerate() {
@ -1055,25 +995,67 @@ impl JavaClient {
if ch != ' '
&& let Some(ing) =
key.iter().find_map(|(k, v)| (*k == ch).then_some(v))
&& row * grid_width + col < grid_size
{
ingredient_slots[row * grid_width + col] = Some(ing);
ingredient_slots[row * grid_width + col] =
Some(GenericIngredient::Vanilla(ing));
}
}
}
}
CraftingRecipeTypes::CraftingShapeless { ingredients, .. } => {
for (i, ing) in ingredients.iter().enumerate().take(grid_size) {
ingredient_slots[i] = Some(ing);
ingredient_slots[i] = Some(GenericIngredient::Vanilla(ing));
}
}
CraftingRecipeTypes::CraftingTransmute {
input, material, ..
} => {
ingredient_slots[0] = Some(input);
ingredient_slots[1] = Some(material);
if grid_size >= 2 {
ingredient_slots[0] = Some(GenericIngredient::Vanilla(input));
ingredient_slots[1] = Some(GenericIngredient::Vanilla(material));
}
}
_ => return,
}
} else if target_id < crafting_display_count + cooking_display_count {
// TODO: cooking recipes
return;
} else {
let dynamic_id = target_id - crafting_display_count - cooking_display_count;
let Some(DynamicRecipe::Crafting(crafting)) = dynamic_recipes.get(dynamic_id) else {
return;
};
match crafting {
pumpkin_protocol::codec::recipe::OwnedCraftingRecipe::Shaped {
pattern,
key,
..
} => {
for (row, row_str) in pattern.iter().enumerate() {
for (col, ch) in row_str.chars().enumerate() {
if ch != ' '
&& let Some((_, ing)) = key.iter().find(|(k, _)| *k == ch)
&& row * grid_width + col < grid_size
{
ingredient_slots[row * grid_width + col] =
Some(GenericIngredient::Dynamic(ing));
}
}
}
}
pumpkin_protocol::codec::recipe::OwnedCraftingRecipe::Shapeless {
ingredients,
..
} => {
for (i, ing) in ingredients.iter().enumerate().take(grid_size) {
ingredient_slots[i] = Some(GenericIngredient::Dynamic(ing));
}
}
}
}
// Check if this exact recipe is already placed (determines stacking vs fresh fill).
let recipe_matches = {
@ -1125,7 +1107,7 @@ impl JavaClient {
}
// Determine how many of each ingredient to place per slot.
let active_ingredients: Vec<&RecipeIngredientTypes> =
let active_ingredients: Vec<GenericIngredient<'_>> =
ingredient_slots.iter().flatten().copied().collect();
let amount_to_craft = if use_max {
compute_biggest_craftable(&active_ingredients, &player.inventory).await
@ -1146,68 +1128,14 @@ impl JavaClient {
let Some(ingredient) = ing else { continue };
let taken = take_n_ingredient(&player.inventory, ingredient, amount_to_craft).await;
if !taken.is_empty() {
crafting_inv.set_stack(idx, taken).await;
}
}
} else {
// Cooking recipe
let cooking_index = target_id - crafting_display_count;
let Some(recipe) = RECIPES_COOKING.get(cooking_index) else {
return;
};
let furnace_inv = {
let screen_handler_arc = player.current_screen_handler.lock().await.clone();
let handler = screen_handler_arc.lock().await;
match handler.window_type() {
Some(WindowType::Furnace | WindowType::BlastFurnace | WindowType::Smoker) => {
handler.get_behaviour().slots[0].get_inventory()
}
_ => return,
}
};
let ingredient = match recipe {
CookingRecipeType::Smelting(r)
| CookingRecipeType::Blasting(r)
| CookingRecipeType::Smoking(r)
| CookingRecipeType::CampfireCooking(r) => &r.ingredient,
};
// Check if ingredient already matches (for stacking).
let (recipe_matches, current_count) = {
let slot_arc = furnace_inv.get_stack(0).await;
let stack = slot_arc.lock().await;
let matches = !stack.is_empty() && ingredient.match_item(stack.item);
let count = if matches { stack.item_count } else { 0 };
(matches, count)
};
// Always clear slot 0 first, returning item to inventory.
let old = furnace_inv.remove_stack(0).await;
if !old.is_empty() {
player.inventory.offer(old, false, player.as_ref()).await;
}
let amount_to_craft = if use_max {
compute_biggest_craftable(&[ingredient], &player.inventory).await
} else if recipe_matches {
current_count.saturating_add(1)
} else {
1
};
if amount_to_craft > 0 {
let taken = take_n_ingredient(&player.inventory, ingredient, amount_to_craft).await;
if !taken.is_empty() {
furnace_inv.set_stack(0, taken).await;
}
*crafting_inv.get_stack(idx).await.lock().await = taken;
}
}
let screen_handler_arc = player.current_screen_handler.lock().await.clone();
screen_handler_arc.lock().await.send_content_updates().await;
}
pub async fn handle_swing_arm(&self, player: &Arc<Player>, swing_arm: SSwingArm) {
player.update_last_action_time();
let Ok(hand) = Hand::try_from(swing_arm.hand.0) else {

View File

@ -0,0 +1,81 @@
use pumpkin_data::item::Item;
use pumpkin_data::item_stack::ItemStack;
use pumpkin_data::recipes::RecipeIngredientTypes;
use pumpkin_inventory::player::player_inventory::PlayerInventory;
use pumpkin_protocol::codec::recipe::OwnedRecipeIngredient;
#[derive(Clone, Copy)]
pub enum GenericIngredient<'a> {
Vanilla(&'a RecipeIngredientTypes),
Dynamic(&'a OwnedRecipeIngredient),
}
impl GenericIngredient<'_> {
#[must_use]
pub fn match_item(&self, item: &Item) -> bool {
match self {
Self::Vanilla(v) => v.match_item(item),
Self::Dynamic(d) => d.match_item(item),
}
}
}
pub async fn take_n_ingredient(
inventory: &PlayerInventory,
ingredient: &GenericIngredient<'_>,
count: u8,
) -> ItemStack {
let mut taken = 0u8;
let mut result: Option<ItemStack> = None;
for slot in &inventory.main_inventory {
let mut stack = slot.lock().await;
if !stack.is_empty() && ingredient.match_item(stack.item) {
let to_take = (count - taken).min(stack.item_count);
let sub_stack = stack.split(to_take);
taken += sub_stack.item_count;
match &mut result {
None => result = Some(sub_stack),
Some(r) => r.item_count += sub_stack.item_count,
}
if taken >= count {
break;
}
}
}
result.unwrap_or_else(|| ItemStack::EMPTY.clone())
}
pub async fn compute_biggest_craftable(
ingredients: &[GenericIngredient<'_>],
inventory: &PlayerInventory,
) -> u8 {
let mut available: Vec<(&'static Item, u32)> = Vec::new();
for slot in &inventory.main_inventory {
let stack = slot.lock().await;
if !stack.is_empty() {
if let Some(e) = available.iter_mut().find(|(i, _)| i.id == stack.item.id) {
e.1 += u32::from(stack.item_count);
} else {
available.push((stack.item, u32::from(stack.item_count)));
}
}
}
'outer: for amount in (1u32..=64).rev() {
let mut budget = available.clone();
for ing in ingredients {
let Some(idx) = budget
.iter()
.position(|(item, count)| *count >= amount && ing.match_item(item))
else {
continue 'outer;
};
budget[idx].1 -= amount;
}
return amount as u8;
}
0
}

View File

@ -28,7 +28,7 @@ use crate::{
api::gui::PluginGui,
loader::wasm::wasm_host::{WasmPlugin, args::OwnedArg},
},
server::Server,
server::{RecipeManager, Server},
world::World,
};
@ -54,6 +54,7 @@ pub type CommandSenderResource = WasmResource<CommandSender>;
pub type ConsumedArgsResource = WasmResource<OwnedConsumedArgs>;
pub type CommandNodeResource = WasmResource<NonLeafNodeBuilder>;
pub type ItemStackResource = WasmResource<Arc<Mutex<pumpkin_data::item_stack::ItemStack>>>;
pub type RecipeManagerResource = WasmResource<Arc<RecipeManager>>;
pub type OwnedConsumedArgs = HashMap<String, OwnedArg>;
@ -229,6 +230,16 @@ impl PluginHostState {
let resource = self.resource_table.push(ItemStackResource { provider })?;
Ok(wasmtime::component::Resource::new_own(resource.rep()))
}
pub fn add_recipe_manager<T>(
&mut self,
provider: Arc<RecipeManager>,
) -> wasmtime::Result<wasmtime::component::Resource<T>> {
let resource = self
.resource_table
.push(RecipeManagerResource { provider })?;
Ok(wasmtime::component::Resource::new_own(resource.rep()))
}
}
pub struct PluginHttpHooks {

View File

@ -348,6 +348,7 @@ pub fn serialize_java_packet(
ClientboundPacket::CRecipeBookAdd(data) => {
let p = pumpkin_protocol::java::client::play::CRecipeBookAdd {
replace: data.replace.try_into().unwrap(),
dynamic_recipes: &[],
};
let mut buf = Vec::new();
crate::net::java::JavaClient::write_packet_for_version(&p, version, &mut buf).unwrap();

View File

@ -22,6 +22,7 @@ pub mod java_dialogs;
pub mod logging;
pub mod permission;
pub mod player;
pub mod recipe;
pub mod scheduler;
pub mod scoreboard;
pub mod server;

View File

@ -0,0 +1,143 @@
use crate::plugin::loader::wasm::wasm_host::state::PluginHostState;
use crate::plugin::loader::wasm::wasm_host::wit::v0_1::pumpkin::plugin::recipe::{
CookingRecipe as WitCookingRecipe, CookingType as WitCookingType, Host as RecipeHost,
HostRecipeManager, Ingredient as WitIngredient, RecipeManager as WitRecipeManager,
ShapedRecipe as WitShapedRecipe, ShapelessRecipe as WitShapelessRecipe,
};
use pumpkin_data::recipes::RecipeCategoryTypes;
use pumpkin_protocol::codec::recipe::{
DynamicRecipe, OwnedCookingRecipe, OwnedCookingRecipeType, OwnedCraftingRecipe,
OwnedRecipeIngredient, OwnedRecipeResult,
};
use wasmtime::component::Resource;
impl RecipeHost for PluginHostState {}
impl HostRecipeManager for PluginHostState {
async fn register_shaped(
&mut self,
_res: Resource<WitRecipeManager>,
_id: String,
recipe: WitShapedRecipe,
) -> wasmtime::Result<()> {
let result_stack = self.get_item_stack(&recipe.output)?;
let result_stack = result_stack.lock().await;
let owned_recipe = OwnedCraftingRecipe::Shaped {
category: RecipeCategoryTypes::Misc, // TODO: Allow specifying category
group: recipe.group,
show_notification: true,
key: recipe
.key
.into_iter()
.map(|(k, ing)| (k.chars().next().unwrap_or(' '), to_owned_ingredient(ing)))
.collect(),
pattern: recipe.pattern,
result: OwnedRecipeResult {
item_id: result_stack.item.registry_key.to_string(),
count: result_stack.item_count,
},
};
let server = self
.server
.as_ref()
.ok_or_else(|| wasmtime::Error::msg("Server not available"))?;
server
.recipe_manager
.add_recipe(DynamicRecipe::Crafting(owned_recipe))
.await;
Ok(())
}
async fn register_shapeless(
&mut self,
_res: Resource<WitRecipeManager>,
_id: String,
recipe: WitShapelessRecipe,
) -> wasmtime::Result<()> {
let result_stack = self.get_item_stack(&recipe.output)?;
let result_stack = result_stack.lock().await;
let owned_recipe = OwnedCraftingRecipe::Shapeless {
category: RecipeCategoryTypes::Misc,
group: recipe.group,
ingredients: recipe
.ingredients
.into_iter()
.map(to_owned_ingredient)
.collect(),
result: OwnedRecipeResult {
item_id: result_stack.item.registry_key.to_string(),
count: result_stack.item_count,
},
};
let server = self
.server
.as_ref()
.ok_or_else(|| wasmtime::Error::msg("Server not available"))?;
server
.recipe_manager
.add_recipe(DynamicRecipe::Crafting(owned_recipe))
.await;
Ok(())
}
async fn register_cooking(
&mut self,
_res: Resource<WitRecipeManager>,
id: String,
station_type: WitCookingType,
recipe: WitCookingRecipe,
) -> wasmtime::Result<()> {
let result_stack = self.get_item_stack(&recipe.output)?;
let result_stack = result_stack.lock().await;
let owned_cooking = OwnedCookingRecipe {
recipe_id: id,
category: RecipeCategoryTypes::Misc,
group: recipe.group,
ingredient: to_owned_ingredient(recipe.ingredient),
cooking_time: recipe.cooking_time as i32,
experience: recipe.experience,
result: OwnedRecipeResult {
item_id: result_stack.item.registry_key.to_string(),
count: result_stack.item_count,
},
};
let dynamic_recipe = match station_type {
WitCookingType::Smelting => {
DynamicRecipe::Cooking(OwnedCookingRecipeType::Smelting(owned_cooking))
}
WitCookingType::Blasting => {
DynamicRecipe::Cooking(OwnedCookingRecipeType::Blasting(owned_cooking))
}
WitCookingType::Smoking => {
DynamicRecipe::Cooking(OwnedCookingRecipeType::Smoking(owned_cooking))
}
WitCookingType::Campfire => {
DynamicRecipe::Cooking(OwnedCookingRecipeType::CampfireCooking(owned_cooking))
}
};
let server = self
.server
.as_ref()
.ok_or_else(|| wasmtime::Error::msg("Server not available"))?;
server.recipe_manager.add_recipe(dynamic_recipe).await;
Ok(())
}
async fn drop(&mut self, _rep: Resource<WitRecipeManager>) -> wasmtime::Result<()> {
Ok(())
}
}
fn to_owned_ingredient(ing: WitIngredient) -> OwnedRecipeIngredient {
match ing {
WitIngredient::Item(id) => OwnedRecipeIngredient::Simple(id),
WitIngredient::Tag(tag) => OwnedRecipeIngredient::Tagged(tag),
}
}

View File

@ -2,6 +2,7 @@ use pumpkin_util::text::TextComponent;
use wasmtime::component::Resource;
use crate::command::CommandSender;
use crate::plugin::loader::wasm::wasm_host::wit::v0_1::pumpkin::plugin::recipe::RecipeManager as WitRecipeManager;
use pumpkin::plugin::server::CommandSender as WasmCommandSender;
use super::player::text_component_from_resource;
@ -380,6 +381,17 @@ impl pumpkin::plugin::server::HostServer for PluginHostState {
))
}
async fn get_recipe_manager(
&mut self,
_rep: Resource<Server>,
) -> wasmtime::Result<Resource<WitRecipeManager>> {
let server = self
.server
.as_ref()
.ok_or_else(|| wasmtime::Error::msg("Server not available"))?;
self.add_recipe_manager(server.recipe_manager.clone())
}
async fn drop(&mut self, rep: Resource<Server>) -> wasmtime::Result<()> {
self.resource_table
.delete::<ServerResource>(Resource::new_own(rep.rep()))

View File

@ -54,11 +54,14 @@ use tokio_util::task::TaskTracker;
mod connection_cache;
mod key_store;
pub mod recipe;
pub mod scheduler;
pub mod seasonal_events;
pub mod tick_rate_manager;
pub mod ticker;
pub use recipe::RecipeManager;
use crate::command::args::entities::{
EntityFilter, EntityFilterSort, EntitySelectorType, TargetSelector, ValueCondition,
};
@ -99,6 +102,7 @@ pub struct Server {
pub dimensions: Vec<Dimension>,
/// Assigns unique IDs to containers.
container_id: AtomicU32,
pub recipe_manager: Arc<recipe::RecipeManager>,
/// Assigns unique IDs to maps.
map_id: AtomicI32,
/// Mojang's public keys, used for chat session signing
@ -223,6 +227,7 @@ impl Server {
))),
permission_registry,
container_id: 0.into(),
recipe_manager: Arc::new(recipe::RecipeManager::new()),
map_id: level_info.load().map_id.into(),
worlds: ArcSwap::from_pointee(vec![]),
dimensions: vec![

View File

@ -0,0 +1,38 @@
use pumpkin_inventory::crafting::recipe_provider::RecipeProvider;
use pumpkin_inventory::slot::BoxFuture;
pub use pumpkin_protocol::codec::recipe::DynamicRecipe;
use tokio::sync::RwLock;
pub struct RecipeManager {
dynamic_recipes: RwLock<Vec<DynamicRecipe>>,
}
impl Default for RecipeManager {
fn default() -> Self {
Self::new()
}
}
impl RecipeManager {
#[must_use]
pub fn new() -> Self {
Self {
dynamic_recipes: RwLock::new(Vec::new()),
}
}
pub async fn add_recipe(&self, recipe: DynamicRecipe) {
let mut recipes = self.dynamic_recipes.write().await;
recipes.push(recipe);
}
pub async fn get_dynamic_recipes_internal(&self) -> Vec<DynamicRecipe> {
self.dynamic_recipes.read().await.clone()
}
}
impl RecipeProvider for RecipeManager {
fn get_dynamic_recipes(&self) -> BoxFuture<'_, Vec<DynamicRecipe>> {
Box::pin(async move { self.dynamic_recipes.read().await.clone() })
}
}

View File

@ -63,6 +63,7 @@ use pumpkin_data::{
world::{RAW, WorldEvent},
};
use pumpkin_data::{BlockDirection, BlockState, translation};
use pumpkin_inventory::crafting::recipe_provider::RecipeProvider;
use pumpkin_inventory::screen_handler::InventoryPlayer;
use pumpkin_nbt::{compound::NbtCompound, to_bytes_unnamed};
use pumpkin_protocol::bedrock::client::set_actor_data::{
@ -2265,8 +2266,9 @@ impl World {
java_client
.send_packet_now(&CRecipeBookSettings::default_closed())
.await;
let dynamic_recipes = server.recipe_manager.get_dynamic_recipes().await;
java_client
.send_packet_now(&CRecipeBookAdd::new(true))
.send_packet_now(&CRecipeBookAdd::new(true, &dynamic_recipes))
.await;
}