From de141bb7cb6ad3260a9dff083fc7ad35dd2cf8cc Mon Sep 17 00:00:00 2001 From: rdbtCVS <87152661+rdbtCVS@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:11:51 +0000 Subject: [PATCH] Fix recipe book settings persistence on the server Send open/filter changes to the authoritative player's recipe book and cover packet settings plus save/reopen in both single-player modes. Co-Authored-By: OpenCode GPT-6.1 Sol --- .../examples/probe/recipe_book_fixture.rs | 23 ++++++- crates/horizon-client/src/game/agent.rs | 3 + crates/horizon-client/src/game/recipe_book.rs | 18 +++++ crates/horizon-client/src/lan/hub.rs | 10 +++ crates/horizon-client/tests/lan_bot.rs | 69 +++++++++++++++++++ crates/horizon-protocol/src/packets/play.rs | 1 + .../horizon-protocol/src/packets/recipes.rs | 27 ++++++++ .../parity/VanillaRecipeBookOracle.java | 21 +++++- tools/lan_e2e.py | 59 ++++++++++++++++ tools/test_recipe_book_parity.py | 11 ++- 10 files changed, 236 insertions(+), 6 deletions(-) diff --git a/crates/horizon-client/examples/probe/recipe_book_fixture.rs b/crates/horizon-client/examples/probe/recipe_book_fixture.rs index ce2afe624..2dda3b109 100644 --- a/crates/horizon-client/examples/probe/recipe_book_fixture.rs +++ b/crates/horizon-client/examples/probe/recipe_book_fixture.rs @@ -5,6 +5,10 @@ //! //! usage: recipe_book_fixture RESOURCES CASES OUTPUT use horizon_client::recipe_book::{BookType, RecipeIndex, Settings}; +use horizon_protocol::{ + buf::{Codec, Reader, Writer}, + packets::recipes::RecipeBookChangeSettings, +}; use horizon_world::nbt::Tag; use serde_json::Value; use std::{collections::BTreeSet, fs, path::Path}; @@ -93,8 +97,23 @@ pub fn main() -> horizon_client::Result<()> { _ => BookType::Smoker, } .index(); - book.open[i] = op["open"].as_bool().unwrap_or(false); - book.filtering[i] = op["filtering"].as_bool().unwrap_or(false); + let packet = RecipeBookChangeSettings { + book_type: i as i32, + open: op["open"].as_bool().unwrap_or(false), + filtering: op["filtering"].as_bool().unwrap_or(false), + }; + let mut wire = Writer::new(); + RecipeBookChangeSettings::write(&packet, &mut wire); + extra = format!( + " packet={}", + wire.data + .iter() + .map(|b| format!("{b:02x}")) + .collect::() + ); + let decoded = RecipeBookChangeSettings::read(&mut Reader::new(&wire.data))?; + book.open[decoded.book_type as usize] = decoded.open; + book.filtering[decoded.book_type as usize] = decoded.filtering; } "save" => { let tag = book.write(); diff --git a/crates/horizon-client/src/game/agent.rs b/crates/horizon-client/src/game/agent.rs index 0d2d218ab..1433e92e8 100644 --- a/crates/horizon-client/src/game/agent.rs +++ b/crates/horizon-client/src/game/agent.rs @@ -686,9 +686,12 @@ impl Game<'_> { // the recipe buttons of the page, page arrows, filter and tabs. let book = self.recipe_book_targets(); if !book.is_empty() { + let (open, filtering) = self.recipe_book_flags().unwrap_or_default(); v["recipe_book"] = json!({ "targets": book.iter().map(|t| t.rect).collect::>(), "known": self.recipe_book_known(), + "open": open, + "filtering": filtering, }); } v["slot_ids"] = json!( diff --git a/crates/horizon-client/src/game/recipe_book.rs b/crates/horizon-client/src/game/recipe_book.rs index 96e12d79b..bf0d1c370 100644 --- a/crates/horizon-client/src/game/recipe_book.rs +++ b/crates/horizon-client/src/game/recipe_book.rs @@ -590,6 +590,7 @@ impl Game<'_> { if inside(cursor, bx, by, 20.0, 18.0) { let open = !self.book_open(book_type); self.level.recipe_book.open[book_type.index()] = open; + self.send_book_settings(book_type); if !open { self.recipe_ui.overlay = None; } @@ -675,6 +676,7 @@ impl Game<'_> { if inside(cursor, xo + 110.0, yo + 12.0, 26.0, 16.0) { let filtering = !self.book_filtering(book_type); self.level.recipe_book.filtering[book_type.index()] = filtering; + self.send_book_settings(book_type); self.recipe_ui.page = 0; self.audio .play("minecraft:ui.button.click", None, 0.25, 1.0); @@ -695,6 +697,22 @@ impl Game<'_> { } false } + /// `RecipeBookComponent.sendUpdateSettings`: the server owns the saved book. + fn send_book_settings(&self, book_type: BookType) { + self.send( + horizon_protocol::packets::play::Serverbound::RecipeBookChangeSettings( + horizon_protocol::packets::recipes::RecipeBookChangeSettings { + book_type: book_type.index() as i32, + open: self.book_open(book_type), + filtering: self.book_filtering(book_type), + }, + ), + ); + } + pub(super) fn recipe_book_flags(&self) -> Option<(bool, bool)> { + let book = self.active_book()?; + Some((self.book_open(book), self.book_filtering(book))) + } /// How many displays the client's book holds. pub(super) fn recipe_book_known(&self) -> usize { self.recipe_ui.client.displays().len() diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index b462536b9..dc0dbe89d 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -1930,6 +1930,7 @@ impl Hub { | S::PlayerLoaded(_) | S::ChunkBatchReceived(_) | S::ClientInformation(_) + | S::RecipeBookChangeSettings(_) | S::ClientTickEnd(_) | S::KeepAlive(_) => false, S::MovePlayerPos(p) => self.moved_far(i, [p.x, p.y, p.z]), @@ -1996,6 +1997,15 @@ impl Hub { g.max_unacknowledged = 10; } S::ClientTickEnd(_) => self.guests[i].position_this_tick = false, + // ServerGamePacketListenerImpl.handleRecipeBookChangeSettingsPacket. + S::RecipeBookChangeSettings(p) => { + if let Some(book) = crate::recipe_book::BookType::ALL.get(p.book_type as usize) + { + let settings = &mut self.guests[i].recipe_book; + settings.open[book.index()] = p.open; + settings.filtering[book.index()] = p.filtering; + } + } S::ClientInformation(info) => { let g = &mut self.guests[i]; g.view_distance = diff --git a/crates/horizon-client/tests/lan_bot.rs b/crates/horizon-client/tests/lan_bot.rs index 34e8265a1..81ec192f5 100644 --- a/crates/horizon-client/tests/lan_bot.rs +++ b/crates/horizon-client/tests/lan_bot.rs @@ -278,6 +278,75 @@ fn run( handle.join().unwrap().unwrap() } +#[test] +fn recipe_settings_packets_update_only_the_players_book_and_survive_save() { + let Some(resources) = resources() else { + return; + }; + let mut s = server(&resources); + let port = s.hub.lan.port; + let _bot = run(&mut s, move || connect(port, "RecipeSettings", true)); + let deadline = Instant::now() + Duration::from_secs(5); + while s.hub.guests.is_empty() { + s.tick(&[]); + assert!(Instant::now() < deadline, "guest did not join"); + thread::yield_now(); + } + for book in 0..4 { + for flags in 0..4 { + let open = flags & 1 != 0; + let filtering = flags & 2 != 0; + let before = s.hub.guests[0].recipe_book.clone(); + // Vanilla's wire payload: RecipeBookType VarInt, open, filtering. + let packet = S::decode( + horizon_protocol::ids::play::serverbound::RECIPE_BOOK_CHANGE_SETTINGS, + &[book, u8::from(open), u8::from(filtering)], + ) + .unwrap(); + s.hub.guests[0].inbox.push_back(packet); + s.tick(&[]); + let settings = &s.hub.guests[0].recipe_book; + for i in 0..4 { + assert_eq!( + settings.open[i], + if i == usize::from(book) { + open + } else { + before.open[i] + } + ); + assert_eq!( + settings.filtering[i], + if i == usize::from(book) { + filtering + } else { + before.filtering[i] + } + ); + } + let mut saved = horizon_client::save::Level::default(); + horizon_client::lan::playerdata::store(&s.hub.guests[0], &mut saved); + let mut reopened = horizon_client::save::Level::default(); + reopened.read_player(&saved.player_tag()); + assert_eq!(reopened.recipe_book.open, settings.open); + assert_eq!(reopened.recipe_book.filtering, settings.filtering); + } + } + for book_type in [-1, 4] { + let mut wire = horizon_protocol::buf::Writer::new(); + wire.varint(book_type); + wire.bool(true); + wire.bool(true); + assert!( + S::decode( + horizon_protocol::ids::play::serverbound::RECIPE_BOOK_CHANGE_SETTINGS, + &wire.data, + ) + .is_err() + ); + } +} + #[test] fn sprint_commands_survive_keyboard_input_changes() { let Some(resources) = resources() else { diff --git a/crates/horizon-protocol/src/packets/play.rs b/crates/horizon-protocol/src/packets/play.rs index 67373cf4b..1c20ed36c 100644 --- a/crates/horizon-protocol/src/packets/play.rs +++ b/crates/horizon-protocol/src/packets/play.rs @@ -2219,6 +2219,7 @@ packet_enum!(Serverbound, crate::ids::play::serverbound { ChatCommandSigned(ChatCommandSigned) = CHAT_COMMAND_SIGNED, ChatSessionUpdate(ChatSessionUpdate) = CHAT_SESSION_UPDATE, PlaceRecipe(super::recipes::PlaceRecipe) = PLACE_RECIPE, + RecipeBookChangeSettings(super::recipes::RecipeBookChangeSettings) = RECIPE_BOOK_CHANGE_SETTINGS, ChunkBatchReceived(ChunkBatchReceived) = CHUNK_BATCH_RECEIVED, ClientCommand(ClientCommand) = CLIENT_COMMAND, ClientInformation(ClientInformation) = CLIENT_INFORMATION, diff --git a/crates/horizon-protocol/src/packets/recipes.rs b/crates/horizon-protocol/src/packets/recipes.rs index c289b8943..dcbe5ffa0 100644 --- a/crates/horizon-protocol/src/packets/recipes.rs +++ b/crates/horizon-protocol/src/packets/recipes.rs @@ -500,6 +500,33 @@ impl Codec for RecipeBookSettings { } } +/// ServerboundRecipeBookChangeSettingsPacket: the active book's open and +/// filtering flags, with its `RecipeBookType` ordinal as a VarInt. +#[derive(Clone, Debug, PartialEq)] +pub struct RecipeBookChangeSettings { + pub book_type: i32, + pub open: bool, + pub filtering: bool, +} +impl Codec for RecipeBookChangeSettings { + fn read(r: &mut Reader) -> Result { + let book_type = r.varint()?; + if !(0..4).contains(&book_type) { + return Err(crate::Error::new("invalid recipe book type")); + } + Ok(Self { + book_type, + open: r.bool()?, + filtering: r.bool()?, + }) + } + fn write(v: &Self, w: &mut Writer) { + w.varint(v.book_type); + w.bool(v.open); + w.bool(v.filtering); + } +} + /// ClientboundPlaceGhostRecipePacket: the recipe to show faded in a menu /// whose items were missing. #[derive(Clone, Debug, PartialEq)] diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaRecipeBookOracle.java b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaRecipeBookOracle.java index 60640010b..2cf9d7d48 100644 --- a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaRecipeBookOracle.java +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaRecipeBookOracle.java @@ -1,12 +1,15 @@ package dev.horizonlab.parity; import com.google.gson.*; +import io.netty.buffer.Unpooled; import java.io.PrintWriter; import java.nio.charset.StandardCharsets; import java.nio.file.*; import java.util.*; import net.minecraft.core.registries.Registries; import net.minecraft.nbt.*; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.protocol.game.ServerboundRecipeBookChangeSettingsPacket; import net.minecraft.resources.Identifier; import net.minecraft.resources.ResourceKey; import net.minecraft.server.level.ServerPlayer; @@ -102,8 +105,22 @@ public final class VanillaRecipeBookOracle { count = kind.equals("give") ? book.addRecipes(holders, player) : book.removeRecipes(holders, player); } case "seen" -> book.removeHighlight(key(op.get("id").getAsString())); - case "set" -> book.setBookSetting(RecipeBookType.valueOf(op.get("type").getAsString()), - op.get("open").getAsBoolean(), op.get("filtering").getAsBoolean()); + case "set" -> { + var packet = new ServerboundRecipeBookChangeSettingsPacket( + RecipeBookType.valueOf(op.get("type").getAsString()), + op.get("open").getAsBoolean(), op.get("filtering").getAsBoolean()); + var buf = new FriendlyByteBuf(Unpooled.buffer()); + try { + ServerboundRecipeBookChangeSettingsPacket.STREAM_CODEC.encode(buf, packet); + byte[] wire = new byte[buf.readableBytes()]; + buf.getBytes(buf.readerIndex(), wire); + extra = " packet=" + HexFormat.of().formatHex(wire); + var decoded = ServerboundRecipeBookChangeSettingsPacket.STREAM_CODEC.decode(buf); + book.setBookSetting(decoded.getBookType(), decoded.isOpen(), decoded.isFiltering()); + } finally { + buf.release(); + } + } case "save" -> { saved = (CompoundTag) ServerRecipeBook.Packed.CODEC.encodeStart(NbtOps.INSTANCE, book.pack()).getOrThrow(); extra = compound(saved); diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index 0ec7218d8..1866a6ee3 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -5877,6 +5877,65 @@ CHECKS += ['recipe_removal'] CHECK_FNS.update(recipe_removal=check_recipe_removal) +def check_recipe_settings(host, guest, out): + """RecipeBookComponent sends both settings to the server; each player's + own book keeps them when the authoritative world saves and reopens (#2290).""" + def inventory(game): + back_to_game(game) + game.raw('key', 'e', 'tap') + game.wait(5) + return game('screen')['recipe_book'] + + def flags(book): + return {'open': book['open'], 'filtering': book['filtering']} + + def set_flags(game, opened, filtering): + book = inventory(game) + if not book['open']: + game.raw('click-at', *centre(book['targets'][0])) + game.wait(5) + book = game('screen')['recipe_book'] + if book['filtering'] != filtering: + target = next(t for t in book['targets'] if t[2:] == [26.0, 16.0]) + game.raw('click-at', *centre(target)) + game.wait(5) + if not opened: + book = game('screen')['recipe_book'] + game.raw('click-at', *centre(book['targets'][0])) + game.wait(5) + actual = flags(game('screen')['recipe_book']) + assert actual == {'open': opened, 'filtering': filtering}, f'{game.name}: {actual}' + game.shot(out, 'recipe-settings-before') + back_to_game(game) + return actual + + # Deliberately different settings ensure neither player's book overwrites the other's. + before = {host.name: set_flags(host, True, True), + guest.name: set_flags(guest, False, True)} + host.wait(20) + guest.raw('disconnect') + host.raw('pause') + host.raw('click-button', 'Save and Quit to Title') + host.raw('wait-for', 'screen', 'title', '--timeout', 120) + host.raw('open', 'Lan') + host('wait_ready', seconds=400) + port = host.raw('lan', 'open', 0, 'commands')['port'] + guest.raw('connect', f'127.0.0.1:{port}', GUEST_NAME) + guest.raw('wait-for', 'loaded', '--timeout', 300) + guest.wait(20) + after = {} + for game in (host, guest): + after[game.name] = flags(inventory(game)) + game.shot(out, 'recipe-settings-reopened') + back_to_game(game) + assert after == before, f'saved recipe settings reset: before={before}, after={after}' + return {'before': before, 'reopened': after} + + +CHECKS += ['recipe_settings'] +CHECK_FNS.update(recipe_settings=check_recipe_settings) + + def check_shulker_bullets(host, guest, out): """ShulkerAttackGoal's bullets reach ClientPacketListener.handleAddEntity, then the existing projectile renderer and interpolation, with distinct ids from the server's entity counter.""" diff --git a/tools/test_recipe_book_parity.py b/tools/test_recipe_book_parity.py index c25f180ac..291adeb86 100644 --- a/tools/test_recipe_book_parity.py +++ b/tools/test_recipe_book_parity.py @@ -5,8 +5,9 @@ Each case runs a sequence of operations on a fresh book: awarding recipes (advancement rewards, /recipe give, crafting; special recipes are never learnt, and the count is the displays added), taking them back, the client having seen a highlighted recipe, the open and filter settings of the four -book types, and saving and loading the player's recipeBook compound (with -unknown ids dropped, and a compound missing a list ignored). The Java oracle +book types (through the serverbound settings packet's codec), and saving and +loading the player's recipeBook compound (with unknown ids dropped, and a +compound missing a list ignored). The Java oracle (VanillaRecipeBookOracle) drives ServerRecipeBook and its codec; examples/probe/recipe_book_fixture drives horizon_client::recipe_book. After each operation the count, the known and highlighted recipes, the settings and @@ -35,6 +36,12 @@ def main(): ids = sorted('minecraft:' + p.stem for p in (lib.RESOURCES / 'data/minecraft/recipe').glob('*.json')) rng = random.Random(args.seed) cases = [] + # Every book and flag combination crosses the wire, then survives a save/load. + for book_type in TYPES: + for opened in (False, True): + for filtering in (False, True): + cases.append({'ops': [{'op': 'set', 'type': book_type, 'open': opened, + 'filtering': filtering}, {'op': 'save'}, {'op': 'load'}]}) for _ in range(args.cases): ops, known = [], set() for _ in range(rng.randrange(1, 12)):