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 <noreply@opencode.ai>
This commit is contained in:
rdbtCVS
2026-10-26 17:11:51 +00:00
parent 93e3096e06
commit de141bb7cb
10 changed files with 236 additions and 6 deletions
@@ -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::<String>()
);
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();
+3
View File
@@ -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::<Vec<_>>(),
"known": self.recipe_book_known(),
"open": open,
"filtering": filtering,
});
}
v["slot_ids"] = json!(
@@ -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()
+10
View File
@@ -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 =
+69
View File
@@ -278,6 +278,75 @@ fn run<T: Send + 'static>(
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 {
@@ -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,
@@ -500,6 +500,33 @@ impl Codec<RecipeBookSettings> 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<RecipeBookChangeSettings> for RecipeBookChangeSettings {
fn read(r: &mut Reader) -> Result<Self> {
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)]
@@ -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);
+59
View File
@@ -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."""
+9 -2
View File
@@ -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)):