From c7d27a84a1bd05ee1f122967505e2d9765ebf3d7 Mon Sep 17 00:00:00 2001 From: GabrielNichols Date: Fri, 6 Nov 2026 19:10:19 -0300 Subject: [PATCH] Fill Recipes grids without teaching the recipe --- crates/horizon-client/src/game/lan_actions.rs | 3 + .../horizon-client/src/game/lan_crafting.rs | 81 +++++++++-- .../horizon-client/src/game/native_hooks.rs | 5 + .../horizon-client/src/game/native_panel.rs | 12 ++ .../src/game/native_panel_recipes.rs | 11 ++ crates/horizon-client/src/game/recipe_book.rs | 94 +++++++++++++ crates/horizon-client/src/game/remote.rs | 5 + crates/horizon-client/src/lan/hub.rs | 6 + crates/horizon-client/src/lan/recipes.rs | 4 + crates/horizon-client/src/menu/place.rs | 59 +++++++- docs/mods.md | 8 +- tools/lan_e2e.py | 129 ++++++++++-------- 12 files changed, 337 insertions(+), 80 deletions(-) diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 9b747d0ed4..255cbd53fd 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -2662,6 +2662,9 @@ impl Game<'_> { S::CustomPayload(p) if p.channel == horizon_client::lod_net::CHANNEL => { self.guest_far_terrain(host, conn, &p.data) } + S::CustomPayload(p) if p.channel == horizon_client::lan::recipes::TRANSFER_CHANNEL => { + self.guest_recipe_transfer(host, conn, &p.data); + } S::ChatCommandSigned(p) => self.guest_signed_command(host, conn, p), S::ChangeDifficulty(p) if host.hub.guests[gi].permission >= 2 => { if !self.level.difficulty_locked && (0..=3).contains(&p.difficulty) { diff --git a/crates/horizon-client/src/game/lan_crafting.rs b/crates/horizon-client/src/game/lan_crafting.rs index 8db2ec972f..4a2c3cdc4d 100644 --- a/crates/horizon-client/src/game/lan_crafting.rs +++ b/crates/horizon-client/src/game/lan_crafting.rs @@ -10,7 +10,10 @@ use horizon_client::{ furnace::FurnaceCooking, items::ItemStack, lan::{guest::Guest, menu::Slots}, - menu::{Env, Input, Kind, Menu}, + menu::{ + Env, Input, Kind, Menu, + place::{PostPlace, TransferError}, + }, }; use horizon_protocol::packets::play::{self, Clientbound as C}; @@ -441,7 +444,6 @@ impl Game<'_> { conn: u32, p: &horizon_protocol::packets::recipes::PlaceRecipe, ) { - use horizon_client::menu::place::PostPlace; let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else { return; }; @@ -470,7 +472,63 @@ impl Game<'_> { let (player, name) = (g.uuid, g.name.clone()); self.native_player_recipe_book_click(player, &name, &id, p.use_max); let creative = g.creative(); - let placed = if p.container == 0 { + let placed = self.lan_apply_recipe(host, gi, p.container, |menu, env| { + menu.place_recipe(env, &id, p.use_max, creative, None) + }); + if placed == Some(PostPlace::Ghost) { + host.hub.send_ghost_recipe(conn, p.container, &id); + } + } + + pub(super) fn guest_recipe_transfer(&mut self, host: &mut LanHost, conn: u32, data: &[u8]) { + use horizon_protocol::buf::{Reader, Writer}; + let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else { + return; + }; + let g = &host.hub.guests[gi]; + if g.brand != "horizon" || g.spectator() { + return; + } + let mut reader = Reader::new(data); + let (Ok(container), Ok(id)) = (reader.varint(), reader.string(512)) else { + return; + }; + if reader.remaining() != 0 { + return; + } + let result = if container == 0 && (g.menu.is_some() || g.creative()) { + Err(TransferError::Unavailable) + } else { + self.lan_apply_recipe(host, gi, container, |menu, env| { + menu.transfer_recipe(env, &id) + }) + .unwrap_or(Err(TransferError::Unavailable)) + }; + let mut reply = Writer::new(); + reply.varint(container); + reply.u8(match result { + Ok(()) => 0, + Err(TransferError::Missing) => 1, + Err(TransferError::NoRoom) => 2, + Err(TransferError::Unavailable) => 3, + }); + host.hub.guests[gi].send(&C::CustomPayload( + horizon_protocol::packets::configuration::CustomPayload { + channel: horizon_client::lan::recipes::TRANSFER_CHANNEL.into(), + data: reply.data, + }, + )); + } + + fn lan_apply_recipe( + &mut self, + host: &mut LanHost, + gi: usize, + container: i32, + apply: impl FnOnce(&mut Menu, &Env) -> R, + ) -> Option { + let conn = host.hub.guests[gi].conn; + let placed = if container == 0 { let crafting = host.play.entry(conn).or_default(); let mut menu = crafting .crafting @@ -482,7 +540,7 @@ impl Game<'_> { for (k, s) in grid.into_iter().enumerate() { menu.set_slot(env, 1 + k, s); } - menu.place_recipe(env, &id, p.use_max, creative, None) + apply(menu, env) }); let g = &mut host.hub.guests[gi]; for k in 0..4 { @@ -493,23 +551,16 @@ impl Game<'_> { host.hub.send_inventory(gi); placed } else { - let Some((window, state, mut menu, pos)) = host + let (window, state, mut menu, pos) = host .play .get_mut(&conn) - .and_then(|play| play.crafting.table.take_if(|(w, ..)| *w == p.container)) - else { - return; - }; - let placed = self.guest_menu_op(host, gi, &mut menu, |menu, env| { - menu.place_recipe(env, &id, p.use_max, creative, None) - }); + .and_then(|play| play.crafting.table.take_if(|(w, ..)| *w == container))?; + let placed = self.guest_menu_op(host, gi, &mut menu, apply); host.play.entry(conn).or_default().crafting.table = Some((window, state, menu, pos)); self.lan_send_table_content(host, conn); placed }; - if placed == PostPlace::Ghost { - host.hub.send_ghost_recipe(conn, p.container, &id); - } + Some(placed) } /// The guest opened a crafting table (`CraftingTableBlock.getMenuProvider`). diff --git a/crates/horizon-client/src/game/native_hooks.rs b/crates/horizon-client/src/game/native_hooks.rs index e47d09531f..3ff019cef0 100644 --- a/crates/horizon-client/src/game/native_hooks.rs +++ b/crates/horizon-client/src/game/native_hooks.rs @@ -9980,6 +9980,11 @@ impl Game<'_> { pub(super) fn native_place_recipe(&mut self, book_id: i32, all: bool) -> Result<(), String> { self.recipe_place(book_id, all) } + /// Move ingredients for a viewer recipe into the open crafting grid. + /// Unlike the recipe book click, this doesn't require learning the recipe. + pub(super) fn native_transfer_recipe(&mut self, book_id: i32) { + self.transfer_viewer_recipe(book_id); + } /// Creative mode only: gives the player `count` of `item` /// (`minecraft:diamond`) as taking it from the creative inventory does /// (`ServerboundSetCreativeModeSlot`): the selected slot first, then the diff --git a/crates/horizon-client/src/game/native_panel.rs b/crates/horizon-client/src/game/native_panel.rs index c87fabe512..f72186bf21 100644 --- a/crates/horizon-client/src/game/native_panel.rs +++ b/crates/horizon-client/src/game/native_panel.rs @@ -69,6 +69,7 @@ pub(super) struct Panel { searching: bool, /// The recipes shown instead of the items. pub(super) recipes: Option, + pub(super) transfer_warning: Option<(&'static str, &'static str)>, } impl Panel { @@ -116,6 +117,7 @@ impl Panel { /// nothing, except while filtering the item list with a non-empty query. fn native_changed(&mut self, keep_search: bool) { self.versions += 1; + self.transfer_warning = None; if let Some(shown) = self.native.as_mut() { shown.version = self.versions; if shown.items.is_empty() && shown.recipes.is_none() && !keep_search { @@ -499,10 +501,20 @@ impl Game<'_> { } /// The container screen closed: the search box lets go of the keys. pub(super) fn mod_browser_closed(&mut self) { + self.panel.transfer_warning = None; if let Some(search) = self.panel.search.as_mut() { search.focused = false; } } + pub(super) fn close_transferred_recipe(&mut self) { + if let Some(owner) = self.browser_sync() + && owner.native_id() == "recipes" + && self.panel.recipes.is_some() + { + self.mod_browser_closed(); + self.panel_event(&owner, BrowserEvent::Back); + } + } /// The item under the pointer on a container screen, for the mods' keys /// (the `Key` event): the panel's, else a slot's. pub(super) fn mod_browser_pointed(&mut self) -> Option { diff --git a/crates/horizon-client/src/game/native_panel_recipes.rs b/crates/horizon-client/src/game/native_panel_recipes.rs index fbf7770a13..5759337954 100644 --- a/crates/horizon-client/src/game/native_panel_recipes.rs +++ b/crates/horizon-client/src/game/native_panel_recipes.rs @@ -453,6 +453,17 @@ impl Game<'_> { ); } } + if let Some((key, fallback)) = self.panel.transfer_warning { + let warning = self.translate(key, fallback); + let width = self.gui.atlas.font.width(&warning); + horizon_client::widgets::record(&warning, [cursor.0, cursor.1, width, 9.0], false); + self.draw_tooltip_lines( + batch, + &[(warning, rgba(255, 100, 100, 255))], + cursor.0, + cursor.1, + ); + } } /// The tabs, on slots; the selected one framed. diff --git a/crates/horizon-client/src/game/recipe_book.rs b/crates/horizon-client/src/game/recipe_book.rs index 379dfe362c..a1b2bfd8bd 100644 --- a/crates/horizon-client/src/game/recipe_book.rs +++ b/crates/horizon-client/src/game/recipe_book.rs @@ -1019,6 +1019,100 @@ impl Game<'_> { self.recipe_ui.ghost.clear(); self.place_recipe(BookType::Crafting, index, all); } + pub(super) fn transfer_viewer_recipe(&mut self, display_id: i32) { + self.panel.transfer_warning = None; + if !self.mod_recipe_screen() || (self.creative && self.craft_size != 3) { + self.show_transfer_result(3); + return; + } + if self.remote.as_ref().is_some_and(|r| !r.recipe_transfer) { + // Older Horizon and vanilla servers still accept book clicks. + if self.recipe_place(display_id, false).is_err() { + self.show_transfer_result(4); + } + return; + } + self.ensure_book(); + let Some(id) = self + .recipe_ui + .book + .as_ref() + .and_then(|book| { + book.entries + .iter() + .find(|entry| entry.display_id == display_id) + }) + .map(|entry| entry.id.clone()) + else { + self.show_transfer_result(3); + return; + }; + if self.remote.is_some() { + let mut request = horizon_protocol::buf::Writer::new(); + request.varint(self.remote_menu_id()); + request.string(&id); + self.send(horizon_protocol::packets::play::Serverbound::CustomPayload( + horizon_protocol::packets::configuration::CustomPayload { + channel: horizon_client::lan::recipes::TRANSFER_CHANNEL.into(), + data: request.data, + }, + )); + return; + } + use horizon_client::menu::place::TransferError; + let mut result = Err(TransferError::Unavailable); + self.menu_session(|menu, env, _| { + result = menu.transfer_recipe(env, &id); + Some(result.is_ok()) + }); + self.show_transfer_result(match result { + Ok(()) => 0, + Err(TransferError::Missing) => 1, + Err(TransferError::NoRoom) => 2, + Err(TransferError::Unavailable) => 3, + }); + if result.is_ok() { + self.recipe_book_changed(); + } + } + + pub(super) fn remote_recipe_transfer(&mut self, data: &[u8]) { + if data.is_empty() { + if let Some(remote) = &mut self.remote { + remote.recipe_transfer = true; + } + return; + } + let mut reader = horizon_protocol::buf::Reader::new(data); + if let (Ok(container), Ok(result)) = (reader.varint(), reader.u8()) + && reader.remaining() == 0 + && container == self.remote_menu_id() + && self.mod_recipe_screen() + && self.panel.recipes.is_some() + { + self.show_transfer_result(result); + } + } + + fn show_transfer_result(&mut self, result: u8) { + if result == 0 { + self.close_transferred_recipe(); + } + self.panel.transfer_warning = match result { + 1 => Some(("horizon.recipe_transfer.missing", "Missing Items")), + 2 => Some(("horizon.recipe_transfer.no_room", "Inventory full")), + 3 => Some(( + "horizon.recipe_transfer.unavailable", + "Recipe does not fit this menu", + )), + 4 => Some(( + "horizon.recipe_transfer.unsupported", + "Server requires a known recipe", + )), + _ => None, + }; + } + /// `ServerboundPlaceRecipePacket`: the server places the recipe /// (`ServerPlaceRecipe` on the menu engine) and answers with a ghost /// recipe when the items are missing. diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index c61d7f1187..ae4f044f02 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -498,6 +498,7 @@ enum OwnProfile { pub(super) struct Remote { pub session: Session, pub brand: String, + pub(super) recipe_transfer: bool, /// This connection's configured feature flags, carried through reconfiguration. pub(super) features: Vec, /// The server announced it runs mods' quiet commands. @@ -1088,6 +1089,7 @@ impl Game<'_> { self.remote = Some(Remote { session, brand: "unknown".into(), + recipe_transfer: false, features: vec!["minecraft:vanilla".into()], quiet_commands: false, mod_net: false, @@ -3337,6 +3339,9 @@ impl Game<'_> { Err(e) => log::debug!("Horizon: a bad menu extra payload: {e}"), } } + C::CustomPayload(p) if p.channel == horizon_client::lan::recipes::TRANSFER_CHANNEL => { + self.remote_recipe_transfer(&p.data); + } // The server's recipes its native mods asked to send. C::CustomPayload(p) if p.channel == horizon_client::lan::recipes::CONTENT_CHANNEL => { self.remote_recipe_content(&p.data); diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index 7f6a3b8afb..281cde1e3b 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -1329,6 +1329,12 @@ impl Hub { // for, to a Horizon client only. if g.brand == "horizon" { send_recipe_content(g); + g.send(&C::CustomPayload( + horizon_protocol::packets::configuration::CustomPayload { + channel: super::recipes::TRANSFER_CHANNEL.into(), + data: Vec::new(), + }, + )); } // PlayerList.sendPlayerPermissionLevel, then the recipe book. self.send_permission_level(i); diff --git a/crates/horizon-client/src/lan/recipes.rs b/crates/horizon-client/src/lan/recipes.rs index 3ac21d53f1..dc6ce9a6ac 100644 --- a/crates/horizon-client/src/lan/recipes.rs +++ b/crates/horizon-client/src/lan/recipes.rs @@ -66,6 +66,10 @@ fn recipes(dir: &Path, namespace: &str, out: &mut BTreeMap) { } } +/// Horizon's optional recipe viewer transfer. An empty clientbound payload +/// advertises support. Requests carry a menu id and resource location. +pub const TRANSFER_CHANNEL: &str = "horizon:recipe_transfer"; + /// The recipes by id in vanilla's registration order (path, then namespace), /// the order `RecipeManager` numbers groups and lists the stonecutter's /// recipes in, and so the order the host's own tables keep. diff --git a/crates/horizon-client/src/menu/place.rs b/crates/horizon-client/src/menu/place.rs index 5deb1fdc2e..a24e9d5584 100644 --- a/crates/horizon-client/src/menu/place.rs +++ b/crates/horizon-client/src/menu/place.rs @@ -298,6 +298,14 @@ pub enum PostPlace { Ghost, } +/// Why a viewer transfer could not move a complete set of ingredients. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TransferError { + Missing, + NoRoom, + Unavailable, +} + /// The menu slots a placement works on. struct Grid { width: usize, @@ -384,7 +392,8 @@ impl Menu { } let ingredients: Vec<&Ingredient> = placement.ingredients.iter().collect(); let post = if stacked.try_pick(&ingredients, 1, None) { - self.place(env, id, &grid, &placement, &mut stacked, use_max); + let matches = self.recipe_in_grid(env, id); + self.place(env, &grid, &placement, &mut stacked, use_max, matches); PostPlace::Nothing } else { self.clear_grid(env, &grid); @@ -395,6 +404,51 @@ impl Menu { post } + /// Move one set of ingredients from the player's real inventory and grid. + /// The viewer leaves both alone on failure, including in creative mode. + pub fn transfer_recipe(&mut self, env: &Env, id: &str) -> Result<(), TransferError> { + if !matches!(self.kind, Kind::Inventory | Kind::Crafting) || self.carried.is_some() { + return Err(TransferError::Unavailable); + } + let grid = self.place_grid().ok_or(TransferError::Unavailable)?; + let placement = env + .recipes + .placement(id) + .ok_or(TransferError::Unavailable)?; + if placement.impossible() + || placement.ingredients.len() > grid.inputs.len() + || placement + .shaped + .is_some_and(|(w, h)| w > grid.width || h > grid.height) + { + return Err(TransferError::Unavailable); + } + let mut stacked = self.stacked_contents(env); + let ingredients: Vec<_> = placement.ingredients.iter().collect(); + if !stacked.try_pick(&ingredients, 1, None) { + return Err(TransferError::Missing); + } + // The vanilla placement can return leftover grid items as drops. + // A viewer transfer must keep the original slots if it cannot move + // every input without dropping anything. + let previous = self.containers.clone(); + let (drops, dropped) = (self.drops.len(), self.dropped.len()); + self.place(env, &grid, &placement, &mut stacked, false, false); + let no_room = self.dropped.len() != dropped; + if no_room || !self.recipe_in_grid(env, id) { + self.containers = previous; + self.drops.truncate(drops); + self.dropped.truncate(dropped); + return Err(if no_room { + TransferError::NoRoom + } else { + TransferError::Unavailable + }); + } + self.recompute(env); + Ok(()) + } + /// `recipeMatches`: the recipe already fills the grid. fn recipe_in_grid(&self, env: &Env, id: &str) -> bool { match self.kind { @@ -417,14 +471,13 @@ impl Menu { fn place( &mut self, env: &Env, - id: &str, grid: &Grid, placement: &Placement, stacked: &mut Stacked, use_max: bool, + matches: bool, ) { let ingredients: Vec<&Ingredient> = placement.ingredients.iter().collect(); - let matches = self.recipe_in_grid(env, id); let biggest = stacked.try_pick_all(&ingredients, i32::MAX, None); if matches { for &s in &grid.inputs { diff --git a/docs/mods.md b/docs/mods.md index 212a5fcc12..ac721c7322 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -71,10 +71,10 @@ Recipes mod and a matching `horizon-ctl`. This opt-in check fails if Recipes is LAN runs do not require it. The runner installs the same original `tools/fixtures/recipes_hmod` package in both scratch game -folders. It finds the output of an original recipe (flint to two echo shards), checks unknown recipes -and missing ingredients, fills the -crafting grid through Recipes' `+` button, takes the result, and checks authoritative inventory counts -before and after Save and Quit/rejoin. Logs, screenshots and `recipes-hmod.json` go under `--out`. +folders. It finds the output of an original recipe (flint to two echo shards) in Survival and Creative. +With the recipe book empty, it verifies that missing ingredients show a warning and preserve the +occupied grid. It then fills the grid through Recipes' `+`, takes the result, and checks authoritative +inventory counts before and after Save and Quit/rejoin. Logs, screenshots and `recipes-hmod.json` go under `--out`. As an assertion canary, changing only the fixture's result count from two to one must fail the custom result check; restore it before running the passing case. This is a content scenario, not general Java-mod compatibility or custom-item registration coverage. diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index 7b7c2bd96a..e6ddd99d5a 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -13215,15 +13215,9 @@ def prepare_recipes_hmod(*games): (settings / 'give').write_text('false') -def check_recipes_hmod(host, guest, out): - """Recipes' real panel -> normal server menu -> custom output -> save/rejoin (#9647). - - Opt in with --check recipes-hmod and --bin-dir pointing at a game compiled with the official - native Recipes mod. The fixture is ours; no Java mod, copied assets or synthetic game replies. - """ +def exercise_recipes_hmod(game, out, mode, supplied): + """Use the real Recipes panel and the authoritative crafting table.""" item = 'minecraft:echo_shard' - recipe = 'recipe_pilot:flint_to_echo_shard' - seen = {} def widget(game, predicate): matches = [w for w in game.raw('ui')['widgets'] if predicate(w)] @@ -13256,70 +13250,89 @@ def check_recipes_hmod(host, guest, out): def contents(game): return {'flint': server_count(game, 'flint'), 'echo_shard': server_count(game, item)} - for game, supplied in ((host, 4), (guest, 5)): - game('command', command='clear @s') - server_reply(game, 'recipe take @s *', 'recipe') + game('command', command=f'gamemode {mode}') + game.wait(10) + game.until(lambda: game('state')['gamemode'], lambda current: current == mode) + game('command', command='clear @s') + game('command', command='give @s minecraft:dirt 1') + table = None + if mode == 'creative': x, y, z = (math.floor(v) for v in pos_of(game)) table = (x, y, z - 2) game('command', command=f'setblock {table[0]} {table[1]} {table[2]} minecraft:crafting_table') game.wait(10) game.raw('interact', *table) - game.raw('wait-for', 'screen', 'inventory', '--timeout', 10) - assert game('screen').get('craft_size') == 3 + else: + game.raw('key', 'e', 'tap') + game.raw('wait-for', 'screen', 'inventory', '--timeout', 10) + assert game('screen').get('craft_size') == (3 if table else 2) - # The output exists in vanilla; its flint recipe is original .hmod content. - search(game, 'recipe_pilot:no_such_item') - assert not any(w.get('label') == 'Echo Shard' for w in game.raw('ui')['widgets']) - search(game, item) - game.shot(out, 'recipes-hmod-search') - unknown = page(game) - assert unknown['enabled'] is False, f'{game.name}: unknown recipe can be placed: {unknown}' - game.shot(out, 'recipes-hmod-unknown') - assert not any(i for i, _ in grid_of(game, 100, 109).values()) - assert not result_of(game)[0] + # Put an unrelated item in the grid so a refused transfer cannot pass by + # merely clearing an already empty grid. + game.raw('click-slot', hotbar_slot(game, 'minecraft:dirt')) + game.raw('click-slot', 100) + game.wait(10) + before = grid_of(game, 100, 109) + assert before[100] == ('minecraft:dirt', 1), before + assert not result_of(game)[0], f'{mode}: control ingredient must have no output' + server_reply(game, 'recipe take @s *', 'recipe') + game.until(lambda: game('screen')['recipe_book']['known'], lambda count: count == 0) - server_reply(game, f'recipe give @s {recipe}', 'recipe') - game.wait(10) - back = widget(game, lambda w: w.get('label') == 'Back') - game.raw('click', back['id']) - game.wait(5) - known = page(game) - assert known['enabled'] is True, f'{game.name}: learned custom recipe unavailable: {known}' - game.raw('click', known['id']) - game.wait(10) - missing = contents(game) - assert missing == {'flint': 0, 'echo_shard': 0}, missing - assert not any(i for i, _ in grid_of(game, 100, 109).values()) - assert not result_of(game)[0], f'{game.name}: missing ingredients fabricated a result' - game.shot(out, 'recipes-hmod-missing') + search(game, 'recipe_pilot:no_such_item') + assert not any(w.get('label') == 'Echo Shard' for w in game.raw('ui')['widgets']) + search(game, item) + transfer = page(game) + assert transfer['enabled'], f'{mode}: + must be available before unlock' + game.raw('click', transfer['id']) + game.until(lambda: game.raw('ui')['widgets'], + lambda widgets: any(w.get('label') == 'Missing Items' for w in widgets)) + assert grid_of(game, 100, 109) == before, f'{mode}: missing ingredients changed the grid' + assert not result_of(game)[0], f'{mode}: missing ingredients fabricated a result' + missing = contents(game) + assert missing == {'flint': 0, 'echo_shard': 0}, missing + game.shot(out, f'{mode}-missing') - # Supply the input through the server, then use only the panel's normal + action. - game('command', command=f'give @s minecraft:flint {supplied}') - game.wait(10) - known = widget(game, lambda w: (w.get('label') or '').startswith('+ ')) - game.raw('click', known['id']) - game.until(lambda: result_of(game), lambda r: bool(r[0])) - grid = [s for s in grid_of(game, 100, 109).values() if s[0]] - result = result_of(game) - assert grid == [('minecraft:flint', 1)], f'{game.name}: filled grid {grid}' - # Independent expected yield: changing the fixture to yield one must fail here. - assert result == (item, 2), f'{game.name}: custom result {result}' - game.shot(out, 'recipes-hmod-filled') - game.raw('click-slot', 200, 'shift') - game.wait(10) - assert not result_of(game)[0], f'{game.name}: consumed input left another result' - back_to_game(game) - crafted = contents(game) - assert crafted == {'flint': supplied - 1, 'echo_shard': 2}, crafted - seen[game.name] = {'missing': missing, 'grid': grid, 'result': result, 'crafted': crafted} + game('command', command=f'give @s minecraft:flint {supplied}') + server_reply(game, 'recipe take @s *', 'recipe') + game.until(lambda: game('screen')['recipe_book']['known'], lambda count: count == 0) + transfer = widget(game, lambda w: (w.get('label') or '').startswith('+ ')) + game.raw('click', transfer['id']) + game.until(lambda: result_of(game), lambda r: bool(r[0])) + grid = [s for s in grid_of(game, 100, 109).values() if s[0]] + result = result_of(game) + assert grid == [('minecraft:flint', 1)], f'{mode}: filled grid {grid}' + assert result == (item, 2), f'{mode}: custom result {result}' + assert game('screen')['recipe_book']['known'] == 0, f'{mode}: transfer learned a recipe' + assert server_count(game, 'dirt') == 1, f'{mode}: displaced ingredient was lost' + assert server_count(game, 'flint') == supplied - 1, f'{mode}: transfer fabricated an input' + assert not any(w.get('label') == 'Missing Items' for w in game.raw('ui')['widgets']) + game.wait(140) + game.shot(out, f'{mode}-filled') + game.raw('click-slot', 200, 'shift') + game.wait(10) + assert not result_of(game)[0], f'{mode}: consumed input left another result' + back_to_game(game) + crafted = contents(game) + assert crafted == {'flint': supplied - 1, 'echo_shard': 2}, crafted + if table: game('command', command=f'setblock {table[0]} {table[1]} {table[2]} minecraft:air') + return {'mode': mode, 'missing': missing, 'warning': 'Missing Items', + 'grid_unchanged_on_failure': True, 'known_before_and_after_fill': 0, + 'grid': grid, 'result': result, 'crafted': crafted} + +def check_recipes_hmod(host, guest, out): + """Exercise Survival and Creative through the real host/guest server.""" + seen = { + host.name: exercise_recipes_hmod(host, out, 'survival', 4), + guest.name: exercise_recipes_hmod(guest, out, 'creative', 5), + } save_and_reopen(host, guest) for game in (host, guest): - persisted = contents(game) + persisted = {'flint': server_count(game, 'flint'), 'echo_shard': server_count(game, 'echo_shard')} assert persisted == seen[game.name]['crafted'], f'{game.name}: after rejoin {persisted}' seen[game.name]['rejoined'] = persisted - game.shot(out, 'recipes-hmod-rejoined') + game.shot(out, f"{seen[game.name]['mode']}-rejoined") (out / 'recipes-hmod.json').write_text(json.dumps(seen, indent=2) + '\n') return seen