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:
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)):
|
||||
|
||||
Reference in New Issue
Block a user