feat: send all crafting recipes to players (#1999)

* feat: send all recipes to players

* chore: clippy and added config option
This commit is contained in:
RB007
2026-04-10 13:17:35 -04:00
committed by GitHub
parent b8c7067d21
commit a7ebad7eee
13 changed files with 933 additions and 11 deletions

View File

@@ -2,6 +2,7 @@ use fun::FunConfig;
use logging::LoggingConfig;
use pumpkin_util::world_seed::Seed;
use pumpkin_util::{Difficulty, GameMode, PermissionLvl, random};
use recipe::RecipeConfig;
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use std::net::SocketAddr;
@@ -11,6 +12,7 @@ use tracing::{debug, warn};
pub mod fun;
pub mod logging;
pub mod networking;
pub mod recipe;
pub mod resource_pack;
@@ -70,6 +72,8 @@ pub struct AdvancedConfiguration {
pub player_data: PlayerDataConfig,
/// Optional fun and experimental features.
pub fun: FunConfig,
/// Recipe-related configuration.
pub recipe: RecipeConfig,
}
/// Basic configuration for core server settings.

View File

@@ -0,0 +1,15 @@
use serde::{Deserialize, Serialize};
/// Recipe-related configuration.
#[derive(Deserialize, Serialize)]
#[serde(default)]
pub struct RecipeConfig {
/// Whether recipes are sent to clients, enabling the recipe book.
pub send_recipes: bool,
}
impl Default for RecipeConfig {
fn default() -> Self {
Self { send_recipes: true }
}
}

View File

@@ -13,7 +13,7 @@ query = []
[dependencies]
pumpkin-nbt.workspace = true
pumpkin-data = { workspace = true, features = ["packet", "item_id_remap", "entity_id_remap"] }
pumpkin-data = { workspace = true, features = ["packet", "item_id_remap", "entity_id_remap", "recipes"] }
pumpkin-macros.workspace = true
pumpkin-world.workspace = true
pumpkin-util.workspace = true

View File

@@ -49,6 +49,8 @@ mod player_info_update;
mod player_position;
mod player_remove;
mod player_spawn_position;
mod recipe_book_add;
mod recipe_book_settings;
mod remove_entities;
mod remove_mob_effect;
mod reset_score;
@@ -145,6 +147,8 @@ pub use player_info_update::*;
pub use player_position::*;
pub use player_remove::*;
pub use player_spawn_position::*;
pub use recipe_book_add::*;
pub use recipe_book_settings::*;
pub use remove_entities::*;
pub use remove_mob_effect::*;
pub use reset_score::*;

View File

@@ -0,0 +1,471 @@
use std::io::Write;
use pumpkin_data::item::Item;
use pumpkin_data::item_id_remap::remap_item_id_for_version;
use pumpkin_data::packet::clientbound::PLAY_RECIPE_BOOK_ADD;
use pumpkin_data::recipes::{
CookingRecipeType, CraftingRecipeTypes, RECIPES_COOKING, RECIPES_CRAFTING, RecipeCategoryTypes,
RecipeIngredientTypes, RecipeResultStruct,
};
use pumpkin_macros::java_packet;
use pumpkin_util::version::MinecraftVersion;
use crate::{ClientPacket, VarInt, WritingError, ser::NetworkWriteExt};
// Recipe Display type IDs
const RECIPE_DISPLAY_SHAPELESS: i32 = 0;
const RECIPE_DISPLAY_SHAPED: i32 = 1;
const RECIPE_DISPLAY_FURNACE: i32 = 2;
// Slot Display type IDs
const SLOT_DISPLAY_EMPTY: i32 = 0;
const SLOT_DISPLAY_ANY_FUEL: i32 = 1;
const SLOT_DISPLAY_ITEM: i32 = 4;
const SLOT_DISPLAY_COMPOSITE: i32 = 10;
// RecipeBookCategory IDs
const CATEGORY_CRAFTING_BUILDING: i32 = 0;
const CATEGORY_CRAFTING_REDSTONE: i32 = 1;
const CATEGORY_CRAFTING_EQUIPMENT: i32 = 2;
const CATEGORY_CRAFTING_MISC: i32 = 3;
const CATEGORY_FURNACE_FOOD: i32 = 4;
const CATEGORY_FURNACE_BLOCKS: i32 = 5;
const CATEGORY_FURNACE_MISC: i32 = 6;
const CATEGORY_BLAST_FURNACE_BLOCKS: i32 = 7;
const CATEGORY_BLAST_FURNACE_MISC: i32 = 8;
const CATEGORY_SMOKER_FOOD: i32 = 9;
const CATEGORY_CAMPFIRE: i32 = 12;
/// 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 replace: bool,
}
impl CRecipeBookAdd {
#[must_use]
pub const fn new(replace: bool) -> Self {
Self { replace }
}
}
fn item_id_versioned(item: &Item, version: MinecraftVersion) -> i32 {
remap_item_id_for_version(item.id, version) as i32
}
fn write_item_slot_display(
write: &mut impl Write,
item: &Item,
version: MinecraftVersion,
) -> Result<(), WritingError> {
write.write_var_int(&VarInt(SLOT_DISPLAY_ITEM))?;
write.write_var_int(&VarInt(item_id_versioned(item, version)))?;
Ok(())
}
fn write_empty_slot_display(write: &mut impl Write) -> Result<(), WritingError> {
write.write_var_int(&VarInt(SLOT_DISPLAY_EMPTY))?;
Ok(())
}
fn write_any_fuel_slot_display(write: &mut impl Write) -> Result<(), WritingError> {
write.write_var_int(&VarInt(SLOT_DISPLAY_ANY_FUEL))?;
Ok(())
}
fn write_ingredient_slot_display(
write: &mut impl Write,
ingredient: &RecipeIngredientTypes,
version: MinecraftVersion,
) -> Result<(), WritingError> {
match ingredient {
RecipeIngredientTypes::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)?;
}
}
RecipeIngredientTypes::Tagged(_tag) => {
// TODO: We lack registry access here to resolve tags to a TagSlotDisplay.
// Sending an empty slot prevents a client DecoderException, but will
// result in invisible ingredients in the recipe book.
write_empty_slot_display(write)?;
}
RecipeIngredientTypes::OneOf(ids) => {
let mut items: Vec<&Item> = Vec::new();
for id in *ids {
let key = id.strip_prefix("minecraft:").unwrap_or(id);
if let Some(item) = Item::from_registry_key(key) {
items.push(item);
}
}
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))?;
write.write_var_int(&VarInt(items.len() as i32))?;
for item in &items {
write_item_slot_display(write, item, version)?;
}
}
}
}
Ok(())
}
/// Write a single Ingredient as a `HolderSet`<Item> for craftingRequirements.
///
/// Vanilla wire format for `ByteBufCodecs.holderSet(Registries.ITEM)`:
/// VarInt(0) -> named tag reference (followed by `ResourceLocation`)
/// VarInt(n + 1) -> direct list of n item IDs
///
/// So an empty/absent ingredient writes VarInt(1), one item writes VarInt(2) + id, etc.
fn write_ingredient_holderset(
write: &mut impl Write,
ingredient: Option<&RecipeIngredientTypes>,
version: MinecraftVersion,
) -> Result<(), WritingError> {
match ingredient {
// Empty ingredient slot -> direct list of 0 items -> VarInt(0 + 1) = VarInt(1)
None => {
write.write_var_int(&VarInt(1))?;
}
Some(RecipeIngredientTypes::Simple(id)) => {
let key = id.strip_prefix("minecraft:").unwrap_or(id);
if let Some(item) = Item::from_registry_key(key) {
// 1 item -> VarInt(1 + 1) = VarInt(2)
write.write_var_int(&VarInt(2))?;
write.write_var_int(&VarInt(item_id_versioned(item, version)))?;
} else {
// Item not found -> empty direct list
write.write_var_int(&VarInt(1))?;
}
}
Some(RecipeIngredientTypes::Tagged(_tag)) => {
// No current recipes use Tagged; write empty direct list.
write.write_var_int(&VarInt(1))?;
}
Some(RecipeIngredientTypes::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();
// n items -> VarInt(n + 1)
write.write_var_int(&VarInt(items.len() as i32 + 1))?;
for id in &items {
write.write_var_int(&VarInt(*id))?;
}
}
}
Ok(())
}
/// Write the `craftingRequirements: Option<List<Ingredient>>` field (present).
/// Each slot is either `None` (empty grid cell) or `Some(ingredient)`.
fn write_crafting_requirements(
write: &mut impl Write,
slots: &[Option<&RecipeIngredientTypes>],
version: MinecraftVersion,
) -> Result<(), WritingError> {
write.write_bool(true)?; // present
write.write_var_int(&VarInt(slots.len() as i32))?;
for slot in slots {
write_ingredient_holderset(write, *slot, version)?;
}
Ok(())
}
fn write_result_slot_display(
write: &mut impl Write,
result: &RecipeResultStruct,
version: MinecraftVersion,
) -> Result<(), WritingError> {
let key = result.id.strip_prefix("minecraft:").unwrap_or(result.id);
if let Some(item) = Item::from_registry_key(key) {
write_item_slot_display(write, item, version)?;
} else {
write_empty_slot_display(write)?;
}
Ok(())
}
const fn crafting_category(cat: &RecipeCategoryTypes) -> i32 {
match cat {
RecipeCategoryTypes::Equipment => CATEGORY_CRAFTING_EQUIPMENT,
RecipeCategoryTypes::Building | RecipeCategoryTypes::Blocks => CATEGORY_CRAFTING_BUILDING,
RecipeCategoryTypes::Restone => CATEGORY_CRAFTING_REDSTONE,
RecipeCategoryTypes::Food | RecipeCategoryTypes::Misc => CATEGORY_CRAFTING_MISC,
}
}
/// Write a single `RecipeDisplayEntry` + flags byte.
/// Returns `Ok(true)` if written, `Ok(false)` if skipped (special recipe).
#[allow(clippy::too_many_lines, clippy::too_many_arguments)]
fn write_entry(
write: &mut impl Write,
display_id: i32,
version: MinecraftVersion,
crafting_table: &Item,
furnace: &Item,
blast_furnace: &Item,
smoker: &Item,
campfire: &Item,
crafting_recipe: Option<&CraftingRecipeTypes>,
cooking_recipe: Option<(&CookingRecipeType, i32)>,
) -> Result<bool, WritingError> {
if let Some(recipe) = crafting_recipe {
match recipe {
CraftingRecipeTypes::CraftingShaped {
category,
pattern,
key,
result,
..
} => {
// Compute width and height from pattern
let height = pattern.len() as i32;
let width = pattern.first().map_or(0, |r| r.len()) as i32;
// RecipeDisplayId
write.write_var_int(&VarInt(display_id))?;
// RecipeDisplay type = shaped (1)
write.write_var_int(&VarInt(RECIPE_DISPLAY_SHAPED))?;
// width, height
write.write_var_int(&VarInt(width))?;
write.write_var_int(&VarInt(height))?;
// ingredients: flat list, row by row
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_ingredient_slot_display(write, ingredient, version)?;
} else {
write_empty_slot_display(write)?;
}
}
}
// result
write_result_slot_display(write, result, version)?;
// craftingStation
write_item_slot_display(write, crafting_table, version)?;
// group: absent
write.write_bool(false)?;
// category
write.write_var_int(&VarInt(crafting_category(category)))?;
// craftingRequirements: one HolderSet per non-empty grid slot
// (Ingredient cannot be empty, so empty slots must be excluded)
{
let mut slots: Vec<Option<&RecipeIngredientTypes>> = Vec::new();
for row in *pattern {
for ch in row.chars() {
if ch != ' '
&& let Some((_, ing)) = key.iter().find(|(k, _)| *k == ch)
{
slots.push(Some(ing));
}
}
}
write_crafting_requirements(write, &slots, version)?;
};
// flags: 0 (no notification, no highlight)
write.write_u8(0)?;
}
CraftingRecipeTypes::CraftingShapeless {
category,
ingredients,
result,
..
} => {
// RecipeDisplayId
write.write_var_int(&VarInt(display_id))?;
// RecipeDisplay type = shapeless (0)
write.write_var_int(&VarInt(RECIPE_DISPLAY_SHAPELESS))?;
// ingredients list
write.write_var_int(&VarInt(ingredients.len() as i32))?;
for ing in *ingredients {
write_ingredient_slot_display(write, ing, version)?;
}
// result
write_result_slot_display(write, result, version)?;
// craftingStation
write_item_slot_display(write, crafting_table, version)?;
// group: absent
write.write_bool(false)?;
// category
write.write_var_int(&VarInt(crafting_category(category)))?;
// craftingRequirements: one HolderSet per ingredient
{
let slots: Vec<Option<&RecipeIngredientTypes>> =
ingredients.iter().map(Some).collect();
write_crafting_requirements(write, &slots, version)?;
};
// flags
write.write_u8(0)?;
}
CraftingRecipeTypes::CraftingTransmute {
category,
input,
material,
result,
..
} => {
// Transmute shown as shapeless with 2 ingredients
write.write_var_int(&VarInt(display_id))?;
write.write_var_int(&VarInt(RECIPE_DISPLAY_SHAPELESS))?;
// 2 ingredients
write.write_var_int(&VarInt(2))?;
write_ingredient_slot_display(write, input, version)?;
write_ingredient_slot_display(write, material, version)?;
write_result_slot_display(write, result, version)?;
write_item_slot_display(write, crafting_table, version)?;
write.write_bool(false)?;
write.write_var_int(&VarInt(crafting_category(category)))?;
// craftingRequirements: input + material
write_crafting_requirements(write, &[Some(input), Some(material)], version)?;
write.write_u8(0)?;
}
// Skip special/decorated_pot recipes as they have no useful display
CraftingRecipeTypes::CraftingDecoratedPot { .. }
| CraftingRecipeTypes::CraftingSpecial => {
return Ok(false);
}
}
return Ok(true);
}
if let Some((recipe, book_category)) = cooking_recipe {
let (cooking, station) = match recipe {
CookingRecipeType::Smelting(r) => (r, furnace),
CookingRecipeType::Blasting(r) => (r, blast_furnace),
CookingRecipeType::Smoking(r) => (r, smoker),
CookingRecipeType::CampfireCooking(r) => (r, campfire),
};
write.write_var_int(&VarInt(display_id))?;
// RecipeDisplay type = furnace (2)
write.write_var_int(&VarInt(RECIPE_DISPLAY_FURNACE))?;
// ingredient
write_ingredient_slot_display(write, &cooking.ingredient, version)?;
// fuel: AnyFuel
write_any_fuel_slot_display(write)?;
// result
write_result_slot_display(write, &cooking.result, version)?;
// craftingStation
write_item_slot_display(write, station, version)?;
// duration
write.write_var_int(&VarInt(cooking.cookingtime))?;
// experience
write.write_f32_be(cooking.experience)?;
// group: absent
write.write_bool(false)?;
// category
write.write_var_int(&VarInt(book_category))?;
// craftingRequirements: the single ingredient
write_crafting_requirements(write, &[Some(&cooking.ingredient)], version)?;
// flags
write.write_u8(0)?;
return Ok(true);
}
Ok(false)
}
impl ClientPacket for CRecipeBookAdd {
fn write_packet_data(
&self,
write: impl Write,
version: &MinecraftVersion,
) -> Result<(), WritingError> {
let mut write = write;
// Station items (these IDs are stable across all versions we support)
let crafting_table =
Item::from_registry_key("crafting_table").expect("crafting_table item must exist");
let furnace = Item::from_registry_key("furnace").expect("furnace item must exist");
let blast_furnace =
Item::from_registry_key("blast_furnace").expect("blast_furnace item must exist");
let smoker = Item::from_registry_key("smoker").expect("smoker item must exist");
let campfire = Item::from_registry_key("campfire").expect("campfire item must exist");
// First pass - count and skip CraftingSpecial and CraftingDecoratedPot entries.
let crafting_count: usize = RECIPES_CRAFTING
.iter()
.filter(|r| {
!matches!(
r,
CraftingRecipeTypes::CraftingSpecial
| CraftingRecipeTypes::CraftingDecoratedPot { .. }
)
})
.count();
let total = crafting_count + RECIPES_COOKING.len();
// Entry count (VarInt)
write.write_var_int(&VarInt(total as i32))?;
let mut display_id: i32 = 0;
// Write crafting recipes
for recipe in RECIPES_CRAFTING {
let written = write_entry(
&mut write,
display_id,
*version,
crafting_table,
furnace,
blast_furnace,
smoker,
campfire,
Some(recipe),
None,
)?;
if written {
display_id += 1;
}
}
// Write cooking recipes
for recipe in RECIPES_COOKING {
let book_category = match recipe {
CookingRecipeType::Smelting(r) => match r.category {
RecipeCategoryTypes::Food => CATEGORY_FURNACE_FOOD,
RecipeCategoryTypes::Blocks => CATEGORY_FURNACE_BLOCKS,
_ => CATEGORY_FURNACE_MISC,
},
CookingRecipeType::Blasting(r) => match r.category {
RecipeCategoryTypes::Blocks => CATEGORY_BLAST_FURNACE_BLOCKS,
_ => CATEGORY_BLAST_FURNACE_MISC,
},
CookingRecipeType::Smoking(_) => CATEGORY_SMOKER_FOOD,
CookingRecipeType::CampfireCooking(_) => CATEGORY_CAMPFIRE,
};
write_entry(
&mut write,
display_id,
*version,
crafting_table,
furnace,
blast_furnace,
smoker,
campfire,
None,
Some((recipe, book_category)),
)?;
display_id += 1;
}
// replace flag
write.write_bool(self.replace)?;
Ok(())
}
}

View File

@@ -0,0 +1,58 @@
use std::io::Write;
use pumpkin_data::packet::clientbound::PLAY_RECIPE_BOOK_SETTINGS;
use pumpkin_macros::java_packet;
use pumpkin_util::version::MinecraftVersion;
use crate::{ClientPacket, WritingError, ser::NetworkWriteExt};
/// Sent by the server to update the player's recipe book open/filter state.
///
/// Wire format: 4 `TypeSettings` pairs (crafting, furnace, `blast_furnace`, smoker),
/// each pair is (`is_open`: bool, `is_filtering`: bool).
#[java_packet(PLAY_RECIPE_BOOK_SETTINGS)]
pub struct CRecipeBookSettings {
pub crafting_open: bool,
pub crafting_filtering: bool,
pub furnace_open: bool,
pub furnace_filtering: bool,
pub blast_furnace_open: bool,
pub blast_furnace_filtering: bool,
pub smoker_open: bool,
pub smoker_filtering: bool,
}
impl CRecipeBookSettings {
#[must_use]
pub const fn default_closed() -> Self {
Self {
crafting_open: false,
crafting_filtering: false,
furnace_open: false,
furnace_filtering: false,
blast_furnace_open: false,
blast_furnace_filtering: false,
smoker_open: false,
smoker_filtering: false,
}
}
}
impl ClientPacket for CRecipeBookSettings {
fn write_packet_data(
&self,
write: impl Write,
_version: &MinecraftVersion,
) -> Result<(), WritingError> {
let mut write = write;
write.write_bool(self.crafting_open)?;
write.write_bool(self.crafting_filtering)?;
write.write_bool(self.furnace_open)?;
write.write_bool(self.furnace_filtering)?;
write.write_bool(self.blast_furnace_open)?;
write.write_bool(self.blast_furnace_filtering)?;
write.write_bool(self.smoker_open)?;
write.write_bool(self.smoker_filtering)?;
Ok(())
}
}

View File

@@ -18,6 +18,7 @@ mod move_vehicle;
mod paddle_boat;
mod pick_item;
mod ping_request;
mod place_recipe;
mod player_abilities;
mod player_action;
mod player_command;
@@ -28,6 +29,8 @@ mod player_position;
mod player_position_rotation;
mod player_rotation;
mod player_session;
mod recipe_book_change_settings;
mod recipe_book_seen_recipe;
mod set_command_block;
mod set_creative_slot;
mod set_held_item;
@@ -56,6 +59,7 @@ pub use move_vehicle::*;
pub use paddle_boat::*;
pub use pick_item::*;
pub use ping_request::*;
pub use place_recipe::*;
pub use player_abilities::*;
pub use player_action::*;
pub use player_command::*;
@@ -66,6 +70,8 @@ pub use player_position::*;
pub use player_position_rotation::*;
pub use player_rotation::*;
pub use player_session::*;
pub use recipe_book_change_settings::*;
pub use recipe_book_seen_recipe::*;
pub use set_command_block::*;
pub use set_creative_slot::*;
pub use set_held_item::*;

View File

@@ -0,0 +1,13 @@
use pumpkin_data::packet::serverbound::PLAY_PLACE_RECIPE;
use pumpkin_macros::java_packet;
use serde::Deserialize;
use crate::VarInt;
#[derive(Deserialize)]
#[java_packet(PLAY_PLACE_RECIPE)]
pub struct SPlaceRecipe {
pub container_id: i8,
pub recipe_display_id: VarInt,
pub use_max_items: bool,
}

View File

@@ -0,0 +1,13 @@
use pumpkin_data::packet::serverbound::PLAY_RECIPE_BOOK_CHANGE_SETTINGS;
use pumpkin_macros::java_packet;
use serde::Deserialize;
use crate::VarInt;
#[derive(Deserialize)]
#[java_packet(PLAY_RECIPE_BOOK_CHANGE_SETTINGS)]
pub struct SRecipeBookChangeSettings {
pub book_type: VarInt,
pub is_open: bool,
pub is_filtering: bool,
}

View File

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

View File

@@ -9,10 +9,11 @@ use pumpkin_protocol::java::server::play::{
SAttack, SChangeGameMode, SChatCommand, SChatMessage, SChunkBatch, SClickSlot, SClientCommand,
SClientInformationPlay, SClientTickEnd, SCloseContainer, SCommandSuggestion, SConfirmTeleport,
SCookieResponse as SPCookieResponse, SCustomPayload, SInteract, SKeepAlive, SMoveVehicle,
SPaddleBoat, SPickItemFromBlock, SPlayPingRequest, SPlayerAbilities, SPlayerAction,
SPlayerCommand, SPlayerInput, SPlayerLoaded, SPlayerPosition, SPlayerPositionRotation,
SPlayerRotation, SPlayerSession, SSetCommandBlock, SSetCreativeSlot, SSetHeldItem,
SSetPlayerGround, SSwingArm, SUpdateSign, SUseItem, SUseItemOn,
SPaddleBoat, SPickItemFromBlock, SPlaceRecipe, SPlayPingRequest, SPlayerAbilities,
SPlayerAction, SPlayerCommand, SPlayerInput, SPlayerLoaded, SPlayerPosition,
SPlayerPositionRotation, SPlayerRotation, SPlayerSession, SRecipeBookChangeSettings,
SRecipeBookSeenRecipe, SSetCommandBlock, SSetCreativeSlot, SSetHeldItem, SSetPlayerGround,
SSwingArm, SUpdateSign, SUseItem, SUseItemOn,
};
use pumpkin_protocol::packet::MultiVersionJavaPacket;
use pumpkin_protocol::{
@@ -856,6 +857,24 @@ impl JavaClient {
);
server.plugin_manager.fire(event).await;
}
id if id == SRecipeBookChangeSettings::to_id(version) => {
self.handle_recipe_book_change_settings(
player,
SRecipeBookChangeSettings::read(payload, &version)?,
)
.await;
}
id if id == SRecipeBookSeenRecipe::to_id(version) => {
self.handle_recipe_book_seen_recipe(
player,
SRecipeBookSeenRecipe::read(payload, &version)?,
)
.await;
}
id if id == SPlaceRecipe::to_id(version) => {
self.handle_place_recipe(player, SPlaceRecipe::read(payload, &version)?)
.await;
}
_ => {
warn!("Failed to handle player packet id {}", packet.id);
}

View File

@@ -55,10 +55,11 @@ use pumpkin_protocol::java::server::play::{
Action, ActionType, CommandBlockMode, FLAG_ON_GROUND, SAttack, SChangeGameMode, SChatCommand,
SChatMessage, SChunkBatch, SClientCommand, SClientInformationPlay, SCloseContainer,
SCommandSuggestion, SConfirmTeleport, SCookieResponse as SPCookieResponse, SInteract,
SKeepAlive, SMoveVehicle, SPaddleBoat, SPickItemFromBlock, SPlayPingRequest, SPlayerAbilities,
SPlayerAction, SPlayerCommand, SPlayerInput, SPlayerPosition, SPlayerPositionRotation,
SPlayerRotation, SPlayerSession, SSetCommandBlock, SSetCreativeSlot, SSetHeldItem,
SSetPlayerGround, SSwingArm, SUpdateSign, SUseItem, SUseItemOn, Status,
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,
};
use pumpkin_util::math::boundingbox::BoundingBox;
use pumpkin_util::math::vector3::Vector3;
@@ -267,7 +268,7 @@ impl JavaClient {
let delta = Vector3::new(pos.x - last_pos.x, pos.y - last_pos.y, pos.z - last_pos.z);
let entity_id = player.entity_id();
// Teleport when more than 8 blocks (-8..=7.999755859375) (checking 8²)
// Teleport when more than 8 blocks (-8..=7.999755859375)
if delta.length_squared() < 64.0 {
return false;
}
@@ -855,6 +856,300 @@ impl JavaClient {
}
}
#[allow(clippy::unused_async)]
pub async fn handle_recipe_book_change_settings(
&self,
_player: &Arc<Player>,
_packet: SRecipeBookChangeSettings,
) {
// Client is updating its recipe book filter/open state; no server action needed.
}
#[allow(clippy::unused_async)]
pub async fn handle_recipe_book_seen_recipe(
&self,
_player: &Arc<Player>,
_packet: SRecipeBookSeenRecipe,
) {
// Client acknowledged a recipe display; no server action needed.
}
#[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,
};
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
}
let target_id = packet.recipe_display_id.0 as usize;
let use_max = packet.use_max_items;
// Count crafting display IDs.
let crafting_display_count = RECIPES_CRAFTING
.iter()
.filter(|r| {
!matches!(
r,
CraftingRecipeTypes::CraftingSpecial
| CraftingRecipeTypes::CraftingDecoratedPot { .. }
)
})
.count();
let screen_handler_arc = player.current_screen_handler.lock().await.clone();
let mut handler = screen_handler_arc.lock().await;
if target_id < crafting_display_count {
// Crafting recipe
let mut counter = 0usize;
let recipe = RECIPES_CRAFTING.iter().find(|r| {
if matches!(
r,
CraftingRecipeTypes::CraftingSpecial
| CraftingRecipeTypes::CraftingDecoratedPot { .. }
) {
return false;
}
let found = counter == target_id;
counter += 1;
found
});
let Some(recipe) = recipe else { return };
let grid_width: usize = match handler.window_type() {
Some(WindowType::Crafting) => 3,
None => 2, // player inventory 2x2
_ => 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() {
for (col, ch) in row_str.chars().enumerate() {
if ch != ' '
&& let Some(ing) =
key.iter().find_map(|(k, v)| (*k == ch).then_some(v))
{
ingredient_slots[row * grid_width + col] = Some(ing);
}
}
}
}
CraftingRecipeTypes::CraftingShapeless { ingredients, .. } => {
for (i, ing) in ingredients.iter().enumerate().take(grid_size) {
ingredient_slots[i] = Some(ing);
}
}
CraftingRecipeTypes::CraftingTransmute {
input, material, ..
} => {
ingredient_slots[0] = Some(input);
ingredient_slots[1] = Some(material);
}
_ => return,
}
let crafting_inv = handler.get_behaviour().slots[1].get_inventory();
// Check if this exact recipe is already placed (determines stacking vs fresh fill).
let recipe_matches = {
let mut ok = true;
for (idx, ing) in ingredient_slots.iter().enumerate() {
let slot_arc = crafting_inv.get_stack(idx).await;
let stack = slot_arc.lock().await;
match ing {
None => {
if !stack.is_empty() {
ok = false;
break;
}
}
Some(ingredient) => {
if stack.is_empty() || !ingredient.match_item(stack.item) {
ok = false;
break;
}
}
}
}
ok
};
// Read minimum count from occupied slots before clearing (needed for stacking).
let current_min = if recipe_matches && !use_max {
let mut min = u8::MAX;
for (idx, ing) in ingredient_slots.iter().enumerate() {
if ing.is_some() {
let slot_arc = crafting_inv.get_stack(idx).await;
let stack = slot_arc.lock().await;
if !stack.is_empty() {
min = min.min(stack.item_count);
}
}
}
if min == u8::MAX { 0 } else { min }
} else {
0
};
// Always clear the grid first, returning items to inventory.
for i in 0..grid_size {
let stack = crafting_inv.remove_stack(i).await;
if !stack.is_empty() {
player.inventory.offer(stack, false, player.as_ref()).await;
}
}
// Determine how many of each ingredient to place per slot.
let active_ingredients: Vec<&RecipeIngredientTypes> =
ingredient_slots.iter().flatten().copied().collect();
let amount_to_craft = if use_max {
compute_biggest_craftable(&active_ingredients, &player.inventory).await
} else if recipe_matches {
current_min.saturating_add(1)
} else {
1
};
if amount_to_craft == 0 {
handler.send_content_updates().await;
return;
}
// Fill each grid slot with exactly `amount_to_craft` matching items.
for (idx, ing) in ingredient_slots.iter().enumerate() {
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;
};
match handler.window_type() {
Some(WindowType::Furnace | WindowType::BlastFurnace | WindowType::Smoker) => {}
_ => return,
}
let ingredient = match recipe {
CookingRecipeType::Smelting(r)
| CookingRecipeType::Blasting(r)
| CookingRecipeType::Smoking(r)
| CookingRecipeType::CampfireCooking(r) => &r.ingredient,
};
let furnace_inv = handler.get_behaviour().slots[0].get_inventory();
// 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;
}
}
}
handler.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

@@ -67,7 +67,9 @@ use pumpkin_protocol::bedrock::client::set_actor_data::{
};
use pumpkin_protocol::bedrock::client::start_game::{CStartGame, ServerTelemetryData};
use pumpkin_protocol::bedrock::frame_set::FrameSet;
use pumpkin_protocol::java::client::play::{CPlayerSpawnPosition, CSystemChatMessage};
use pumpkin_protocol::java::client::play::{
CPlayerSpawnPosition, CRecipeBookAdd, CRecipeBookSettings, CSystemChatMessage,
};
use pumpkin_protocol::java::client::play::{CSetEntityMetadata, Metadata};
use pumpkin_protocol::{
BClientPacket, ClientPacket, IdOr, SoundEvent,
@@ -2074,6 +2076,17 @@ impl World {
player.send_active_effects().await;
self.send_player_equipment(player).await;
if let crate::net::ClientPlatform::Java(java_client) = &player.client
&& server.advanced_config.recipe.send_recipes
{
java_client
.send_packet_now(&CRecipeBookSettings::default_closed())
.await;
java_client
.send_packet_now(&CRecipeBookAdd::new(true))
.await;
}
let msg_comp = TextComponent::translate(
translation::MULTIPLAYER_PLAYER_JOINED,
[TextComponent::text(player.gameprofile.name.clone())],