Recipes as a native mod (native/recipes, hook points v2) (#24)

* Recipes as a native mod (native/recipes, hooks 2)

Horizon's official Recipes viewer, ported from the WebAssembly mod to a
horizon-mixins bundle on the native hook points, API 2: the item panel
beside container screens, search (#tag, @namespace), recipes and uses per
station, + to fill the crafting grid, and "Click gives items" in creative.
It keeps the WebAssembly mod's setting file (give) and keys
(key.recipes.recipe, key.recipes.uses), and rests when turned off in
Options > Mods.

The viewer is runtime.rs (also the crate's library, with its tests); the
game's tables and panel events go through glue.rs, a second module.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Native mods: pin all five to Horizon 8f91683 (hook points v2)

Zoom, FPS Counter, Keystrokes, Minimap and Recipes all name the commit
they were tested on: main after horizon-rust/horizon#3435, which brought
hook points v2 (the item panel and recipe tables). One commit for all of
them, so the `together` job builds the set without --horizon. The four
older mods still need only hooks 1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 authored and GitHub committed 2026-10-29 02:23:19 +01:00
1 parent f539b5a584
commit d6a1ddd703
22 files changed
+1160 -8

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
"game": "26.3",
"hooks": 1,
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "01d0f45c2d08d2b7686a3c44b02f574a3df72542",
"horizon_revision": "8f91683323ac1165e7d76b3037e18f30fe647205",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
}
@@ -6,7 +6,7 @@ priority = 100
# The commit it was tested on. It uses only the native hook points (hooks 1 in
# compatibility.json): the launcher builds it on any Horizon that has them.
[compatibility]
horizon_revision = "01d0f45c2d08d2b7686a3c44b02f574a3df72542"
horizon_revision = "8f91683323ac1165e7d76b3037e18f30fe647205"
[[module]]
id = "runtime"
+1 -1
View File
@@ -2,7 +2,7 @@
"game": "26.3",
"hooks": 1,
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "01d0f45c2d08d2b7686a3c44b02f574a3df72542",
"horizon_revision": "8f91683323ac1165e7d76b3037e18f30fe647205",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
}
@@ -6,7 +6,7 @@ priority = 100
# The commit it was tested on. It uses only the native hook points (hooks 1 in
# compatibility.json): the launcher builds it on any Horizon that has them.
[compatibility]
horizon_revision = "01d0f45c2d08d2b7686a3c44b02f574a3df72542"
horizon_revision = "8f91683323ac1165e7d76b3037e18f30fe647205"
[[module]]
id = "runtime"
+1 -1
View File
@@ -2,7 +2,7 @@
"game": "26.3",
"hooks": 1,
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "01d0f45c2d08d2b7686a3c44b02f574a3df72542",
"horizon_revision": "8f91683323ac1165e7d76b3037e18f30fe647205",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
}
@@ -6,7 +6,7 @@ priority = 100
# The commit it was tested on. It uses only the native hook points (hooks 1 in
# compatibility.json): the launcher builds it on any Horizon that has them.
[compatibility]
horizon_revision = "01d0f45c2d08d2b7686a3c44b02f574a3df72542"
horizon_revision = "8f91683323ac1165e7d76b3037e18f30fe647205"
[[module]]
id = "runtime"
+7
View File
@@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "horizon-mod-recipes"
version = "2.0.0"
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "horizon-mod-recipes"
version = "2.0.0"
edition = "2024"
license = "MIT"
publish = false
description = "Horizon's official Recipes viewer, a native mod: every item beside your inventory, its recipes and uses."
# The mod's logic and its tests: the same file the bundle adds to the game.
[lib]
path = "mixins/recipes/runtime.rs"
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Ludovic Marie and the Horizon contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+51
View File
@@ -0,0 +1,51 @@
# Recipes (Horizon native mod)
Horizon's official recipe viewer: every item beside your inventory, with a search box (`#tag` searches a tag). R over an
item shows its recipes, one tab per station; U shows its uses. The + under a crafting recipe you know fills the
crafting grid, as the recipe book does. In creative mode, turn on "Click gives items" (Options > Mods > Recipes >
Settings): a click on an item in the panel gives you one, a right click a stack. Change R and U on the mod's page in
Options > Mods.
It is a [horizon-mixins](https://github.com/horizon-rust/horizon-mixins) bundle (`mixins/recipes/`) that the Horizon
launcher builds into the game. It replaces the WebAssembly Recipes 1.x and keeps its settings: the same file
(`horizon-mods/.horizon-mod-data/recipes/give`) and the same keys (`key.recipes.recipe`, `key.recipes.uses` in
`horizon-mods.txt`).
## Install
In the Horizon launcher: Mods → Recipes → Install, then Settings → Game → Native mods (experimental) → Build. The
launcher downloads its own Rust and builds the game with your native mods; see Horizon's
[native mods](https://github.com/horizon-rust/horizon/blob/main/docs/native-mods.md).
## Made on the hook points
The bundle hooks only Horizon's native hook points (`crates/horizon-client/src/game/native_hooks.rs`), which keep
their names and signatures across releases. It needs the item panel and the recipe tables, so `compatibility.json`
names version 2 (`"hooks": 2`): the launcher builds Recipes on any Horizon with that version or a later one, the
player's own game by default, together with any other mod. `horizon_revision` is only the commit it was last tested
on.
| File | What it is |
|---|---|
| `mixins/recipes/horizon-mixin.toml` | The bundle: two modules and the HEAD hooks |
| `mixins/recipes/runtime.rs` | The viewer: search, pages per station, history, and its tests; added to the game as `game::official_recipes`, and this crate's library |
| `mixins/recipes/glue.rs` | The viewer's edges in the game (`game::official_recipes_glue`): the game's tables and panel events in, the panel, give and place out |
| `mixins/recipes/*.rs` | One small helper per hook |
| `proof.py` (with `../tools/game.py`) | The proof: the built game driven with `horizon-ctl --offscreen` on fresh, temporary data |
## Build and prove
```sh
(cd native/recipes && cargo test) # the viewer
python3 native/tools/native.py check recipes # apply on the tested Horizon and cargo check the game
python3 native/tools/native.py check all # with the other native mods, as a player's set
python3 native/tools/native.py package recipes # native/target/recipes-<version>.hnmod for the registry
python3 native/tools/native.py proof recipes --resources /path/to/.local/horizon-client/26.3
```
The proof needs resources prepared from your own Minecraft 26.3 (`horizon-prepare`); none are in this repository.
It seeds the WebAssembly mod's setting ("Click gives items" on) in a temporary game folder, checks that Options > Mods
shows the mod, its keys and the setting, then opens the inventory: the panel shows items, a click on one in creative
mode gives it, and with the setting turned off the same click opens its recipe page.
License: MIT.
+8
View File
@@ -0,0 +1,8 @@
{
"game": "26.3",
"hooks": 2,
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "8f91683323ac1165e7d76b3037e18f30fe647205",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
}
+10
View File
@@ -0,0 +1,10 @@
// A click on the mod's settings page: "Click gives items" flips, kept where
// the WebAssembly mod kept it.
fn official_recipes_click(game: &mut crate::game::Game<'_>, mod_id: &str, button: usize) {
use crate::game::official_recipes as m;
if mod_id != m::ID || button != 0 {
return;
}
let give = !m::gives(game.native_setting(m::ID, "give"));
game.native_set_setting(m::ID, "give", if give { "true" } else { "false" });
}
+12
View File
@@ -0,0 +1,12 @@
// The player's click, search or key on the panel, when it is this mod's.
fn official_recipes_event(
game: &mut crate::game::Game<'_>,
mod_id: &str,
event: &crate::game::native_hooks::NativePanelEvent,
) {
use crate::game::official_recipes as m;
if mod_id != m::ID || !game.native_on(m::ID) {
return;
}
crate::game::official_recipes_glue::event(game, event);
}
+4
View File
@@ -0,0 +1,4 @@
// Each frame: the item panel follows the world and the mod's on/off.
fn official_recipes_frame(game: &mut crate::game::Game<'_>) {
crate::game::official_recipes_glue::frame(game);
}
+194
View File
@@ -0,0 +1,194 @@
// The viewer's edges in the game (not in the Cargo library): the game's
// tables and events converted to the viewer's types, and what the viewer
// asks for done through the native hook points (API 2).
use crate::game::Game;
use crate::game::native_hooks::{
NativeItem, NativePanelEvent, NativeRecipe, NativeRecipeKind, NativeRecipeLayout,
NativeRecipeStack, NativeRecipeView, NativeStack,
};
use crate::game::official_recipes as m;
fn item(i: &NativeItem) -> m::Item {
m::Item {
id: i.id.clone(),
name: i.name.clone(),
tags: i.tags.clone(),
}
}
fn ingredient(s: &NativeRecipeStack) -> m::Ingredient {
m::Ingredient {
item: s.item,
count: s.count,
components: s.components.clone(),
}
}
fn recipe(r: &NativeRecipe) -> m::Recipe {
let kind = match r.kind {
NativeRecipeKind::CraftingShaped => m::Kind::CraftingShaped,
NativeRecipeKind::CraftingShapeless => m::Kind::CraftingShapeless,
NativeRecipeKind::Smelting => m::Kind::Smelting,
NativeRecipeKind::Blasting => m::Kind::Blasting,
NativeRecipeKind::Smoking => m::Kind::Smoking,
NativeRecipeKind::Campfire => m::Kind::Campfire,
NativeRecipeKind::Stonecutting => m::Kind::Stonecutting,
NativeRecipeKind::Smithing => m::Kind::Smithing,
NativeRecipeKind::Brewing => m::Kind::Brewing,
};
m::Recipe {
kind,
width: r.width,
height: r.height,
slots: r
.slots
.iter()
.map(|s| s.iter().map(ingredient).collect())
.collect(),
results: r.results.iter().map(ingredient).collect(),
cooking_time: r.cooking_time,
experience: r.experience,
book_id: r.book_id,
}
}
fn native(s: &m::Stack) -> NativeStack {
NativeStack {
item: s.item.clone(),
count: s.count,
components: s.components.clone(),
}
}
fn stack(s: &NativeStack) -> m::Stack {
m::Stack {
item: s.item.clone(),
count: s.count,
components: s.components.clone(),
}
}
fn view(v: &m::View) -> NativeRecipeView {
NativeRecipeView {
layout: match v.layout {
m::Layout::Grid => NativeRecipeLayout::Grid,
m::Layout::Cooking => NativeRecipeLayout::Cooking,
m::Layout::Single => NativeRecipeLayout::Single,
m::Layout::Smithing => NativeRecipeLayout::Smithing,
m::Layout::Brewing => NativeRecipeLayout::Brewing,
},
width: v.width,
height: v.height,
slots: v
.slots
.iter()
.map(|s| s.iter().map(native).collect())
.collect(),
results: v.results.iter().map(native).collect(),
note: v.note.clone(),
action: v.action,
}
}
/// Loads the world's tables once a world is open, and drops them (and the
/// panel) when it closes or the player turns the mod off.
pub(super) fn frame(game: &mut Game<'_>) {
let tables = if game.native_on(m::ID) {
game.native_recipes()
} else {
None
};
match (tables, m::loaded()) {
(Some(t), false) => {
let (viewer, out) = m::Viewer::new(
t.items.iter().map(item).collect(),
t.recipes.iter().map(recipe).collect(),
);
m::set_viewer(Some(viewer));
apply(game, out);
}
(None, true) => {
m::set_viewer(None);
game.native_panel_recipes(m::ID, "", Vec::new(), 0, Vec::new());
game.native_panel_items(m::ID, Vec::new());
}
_ => {}
}
}
/// The player's action on the panel, as the WebAssembly mod handled it.
pub(super) fn event(game: &mut Game<'_>, event: &NativePanelEvent) {
let event = match event {
NativePanelEvent::Search(text) => m::Event::Search(text.clone()),
NativePanelEvent::Clicked { index, right } => m::Event::Clicked {
index: *index as usize,
right: *right,
},
NativePanelEvent::Key { key, stack: s } => {
let [[recipe, _, recipe_default], [uses, _, uses_default]] = m::KEYS;
let uses = if *key == game.native_mod_key(m::ID, recipe, recipe_default) {
false
} else if *key == game.native_mod_key(m::ID, uses, uses_default) {
true
} else {
return;
};
m::Event::Key {
uses,
stack: stack(s),
}
}
NativePanelEvent::SlotClicked {
stack: s, right, ..
} => m::Event::SlotClicked {
stack: stack(s),
right: *right,
},
NativePanelEvent::Tab(i) => m::Event::Tab(*i as usize),
NativePanelEvent::Action(i) => m::Event::Action(*i as usize),
NativePanelEvent::Back => m::Event::Back,
_ => return,
};
let give = m::gives(game.native_setting(m::ID, "give"));
let known = game.native_known_recipes();
let outs = m::with_viewer(|v| v.handle(event.clone(), give, true, &known)).unwrap_or_default();
for out in outs {
if let m::Out::Give { item, count } = &out {
// Not in creative mode: the click shows the item's page, as with the setting off.
if game.native_give(item, *count).is_err() {
let again = m::with_viewer(|v| v.handle(event.clone(), false, false, &known));
for out in again.unwrap_or_default() {
apply(game, out);
}
}
continue;
}
apply(game, out);
}
}
fn apply(game: &mut Game<'_>, out: m::Out) {
match out {
m::Out::Items(items) => game.native_panel_items(m::ID, items.iter().map(native).collect()),
m::Out::Recipes {
title,
tabs,
tab,
views,
} => game.native_panel_recipes(
m::ID,
&title,
tabs.iter().map(native).collect(),
tab as u32,
views.iter().map(view).collect(),
),
m::Out::Give { item, count } => {
let _ = game.native_give(&item, count);
}
m::Out::Place(book) => {
if let Err(e) = game.native_place_recipe(book, false) {
game.native_chat(&e);
}
}
}
}
@@ -0,0 +1,72 @@
[mod]
id = "recipes"
version = "2.0.0"
priority = 100
# The commit it was tested on. It uses only the native hook points (hooks 2 in
# compatibility.json): the launcher builds it on any Horizon that has them.
[compatibility]
horizon_revision = "8f91683323ac1165e7d76b3037e18f30fe647205"
[[module]]
id = "runtime"
file = "crates/horizon-client/src/game.rs"
name = "official_recipes"
source = "runtime.rs"
[[module]]
id = "glue"
file = "crates/horizon-client/src/game.rs"
name = "official_recipes_glue"
source = "glue.rs"
[[injection]]
id = "list"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_mods"
helper = "list.rs"
bindings = [
{ name = "mods", ty = "&mut Vec<crate::game::native_hooks::NativeMod>", value = "&mut *mods" },
]
[[injection]]
id = "settings"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_settings"
helper = "settings.rs"
bindings = [
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
{ name = "mod_id", ty = "&str", value = "mod_id" },
{ name = "buttons", ty = "&mut Vec<String>", value = "&mut *buttons" },
]
[[injection]]
id = "click"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_settings_click"
helper = "click.rs"
bindings = [
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
{ name = "mod_id", ty = "&str", value = "mod_id" },
{ name = "button", ty = "usize", value = "button" },
]
[[injection]]
id = "frame"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_frame"
helper = "frame.rs"
bindings = [
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
]
[[injection]]
id = "event"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_panel_event"
helper = "event.rs"
bindings = [
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
{ name = "mod_id", ty = "&str", value = "mod_id" },
{ name = "event", ty = "&crate::game::native_hooks::NativePanelEvent", value = "event" },
]
+10
View File
@@ -0,0 +1,10 @@
// The mod's row in Options > Mods, with its two keys.
fn official_recipes_listed(mods: &mut Vec<crate::game::native_hooks::NativeMod>) {
use crate::game::official_recipes as m;
mods.push(crate::game::native_hooks::NativeMod {
id: m::ID.into(),
title: format!("{} {}", m::NAME, m::VERSION),
description: m::DESCRIPTION.into(),
keys: m::KEYS.iter().map(|k| k.map(String::from)).collect(),
});
}
+669
View File
@@ -0,0 +1,669 @@
// Horizon's official recipe viewer, formerly the WebAssembly mod: every item
// in the panel the game draws beside container screens, with a search box. R
// over an item, the panel's or any slot's, shows its recipes, U its uses; a
// click does the same, left for recipes and right for uses. A recipe page has
// a tab per station, and + beside a crafting recipe the player knows fills the
// grid as the recipe book does. In creative mode, with "Click gives items" on,
// a click on a panel item gives one, a right click a stack. Its setting and
// keys are the WebAssembly mod's (`horizon-mods/.horizon-mod-data/recipes/give`,
// `key.recipes.recipe` and `key.recipes.uses` in horizon-mods.txt), so a
// player keeps them. This file is both the mod's Cargo library (its tests) and
// the module the bundle adds to the game; the game's types are converted at
// the edges (frame.rs, event.rs).
/// The mod's id (its settings folder), name and version.
pub const ID: &str = "recipes";
pub const NAME: &str = "Recipes";
pub const VERSION: &str = "2.0.0";
pub const DESCRIPTION: &str = "Every item beside your inventory, with a search box. R over an item shows its recipes, U its uses, and + fills the crafting grid with a recipe you know. Horizon's own.";
/// Its keys: name, label, default (the WebAssembly mod's).
pub const KEYS: [[&str; 3]; 2] = [
["recipe", "Show recipes", "key.keyboard.r"],
["uses", "Show uses", "key.keyboard.u"],
];
/// What a recipe page holds at most, as the panel takes.
pub const MAX_RECIPES: usize = 256;
pub const MAX_STACKS: usize = 64;
/// A right click's gift in creative mode (cut to one stack by the game).
pub const STACK: u32 = 99;
/// The stations, in the order of their tabs, with each one's icon.
pub const STATIONS: [&str; 8] = [
"minecraft:crafting_table",
"minecraft:furnace",
"minecraft:blast_furnace",
"minecraft:smoker",
"minecraft:campfire",
"minecraft:stonecutter",
"minecraft:smithing_table",
"minecraft:brewing_stand",
];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
CraftingShaped,
CraftingShapeless,
Smelting,
Blasting,
Smoking,
Campfire,
Stonecutting,
Smithing,
Brewing,
}
fn station(kind: Kind) -> usize {
match kind {
Kind::CraftingShaped | Kind::CraftingShapeless => 0,
Kind::Smelting => 1,
Kind::Blasting => 2,
Kind::Smoking => 3,
Kind::Campfire => 4,
Kind::Stonecutting => 5,
Kind::Smithing => 6,
Kind::Brewing => 7,
}
}
/// An item the game knows.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Item {
pub id: String,
pub name: String,
pub tags: Vec<String>,
}
/// An item of the tables by its index, as recipes name them.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Ingredient {
pub item: u32,
pub count: u32,
pub components: String,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Recipe {
pub kind: Kind,
pub width: u32,
pub height: u32,
pub slots: Vec<Vec<Ingredient>>,
pub results: Vec<Ingredient>,
pub cooking_time: u32,
pub experience: f32,
/// The recipe book's id, or -1.
pub book_id: i32,
}
/// An item as the panel shows it.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Stack {
pub item: String,
pub count: u32,
pub components: String,
}
fn stack(item: &str) -> Stack {
Stack {
item: item.into(),
count: 1,
components: String::new(),
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Layout {
Grid,
Cooking,
Single,
Smithing,
Brewing,
}
/// A recipe as the panel lays it out.
#[derive(Clone, Debug, PartialEq)]
pub struct View {
pub layout: Layout,
pub width: u32,
pub height: u32,
pub slots: Vec<Vec<Stack>>,
pub results: Vec<Stack>,
pub note: String,
/// + beside it: none, or whether the book holds it.
pub action: Option<bool>,
}
/// What the player did on the panel.
#[derive(Clone, Debug, PartialEq)]
pub enum Event {
Search(String),
/// A panel item, by its index in the items shown.
Clicked {
index: usize,
right: bool,
},
/// The recipes key (`uses` false) or the uses key over an item.
Key {
uses: bool,
stack: Stack,
},
/// A slot of a recipe shown.
SlotClicked {
stack: Stack,
right: bool,
},
Tab(usize),
Action(usize),
Back,
}
/// What the game is to do.
#[derive(Clone, Debug, PartialEq)]
pub enum Out {
/// Show these items in the panel.
Items(Vec<Stack>),
/// Show this recipe page; an empty one goes back to the items.
Recipes {
title: String,
tabs: Vec<Stack>,
tab: usize,
views: Vec<View>,
},
/// Creative mode: give the player this item.
Give { item: String, count: u32 },
/// Place this recipe book id in the open grid.
Place(i32),
}
/// A recipe page: an item's recipes or its uses, on one station's tab.
#[derive(Clone, Debug, PartialEq)]
struct Page {
item: Stack,
uses: bool,
tab: usize,
}
/// The viewer's state while a world is open.
#[derive(Default)]
pub struct Viewer {
items: Vec<Item>,
all: Vec<Recipe>,
/// The panel's items now: indexes into `items`, after the search.
listed: Vec<usize>,
search: String,
/// The page shown, then the ones Back returns to.
history: Vec<Page>,
/// The book id behind each recipe the page shows (-1: none).
shown: Vec<i32>,
}
impl Viewer {
/// The world's tables: the panel lists every item.
pub fn new(items: Vec<Item>, all: Vec<Recipe>) -> (Self, Out) {
let mut me = Viewer {
items,
all,
..Viewer::default()
};
let out = me.list();
(me, out)
}
fn id(&self, s: &Ingredient) -> &str {
self.items
.get(s.item as usize)
.map_or("minecraft:air", |i| &i.id)
}
fn convert(&self, s: &Ingredient) -> Stack {
Stack {
item: self.id(s).into(),
count: s.count,
components: s.components.clone(),
}
}
/// The same item, and the same components when `want` names some.
fn matches(&self, s: &Ingredient, want: &Stack) -> bool {
self.id(s) == want.item && (want.components.is_empty() || s.components == want.components)
}
fn name<'a>(&'a self, item: &'a str) -> &'a str {
self.items
.iter()
.find(|i| i.id == item)
.map_or(item, |i| &i.name)
}
/// The items the search keeps: every word must be in the name or the
/// id; `@minecraft` keeps a namespace, `#logs` a tag.
fn list(&mut self) -> Out {
let words: Vec<String> = self
.search
.to_lowercase()
.split_whitespace()
.map(String::from)
.collect();
let keeps = |item: &Item| {
words.iter().all(|w| {
if let Some(ns) = w.strip_prefix('@') {
item.id.split(':').next().is_some_and(|n| n.starts_with(ns))
} else if let Some(tag) = w.strip_prefix('#') {
item.tags.iter().any(|t| t.contains(tag))
} else {
item.name.to_lowercase().contains(w.as_str()) || item.id.contains(w.as_str())
}
})
};
self.listed = (self.items.iter().enumerate())
.filter(|(_, i)| i.id != "minecraft:air" && keeps(i))
.map(|(n, _)| n)
.collect();
Out::Items(
self.listed
.iter()
.map(|&n| stack(&self.items[n].id))
.collect(),
)
}
/// The recipes a page lists, all stations together.
fn found(&self, page: &Page) -> Vec<&Recipe> {
(self.all.iter())
.filter(|r| {
if page.uses {
r.slots
.iter()
.flatten()
.any(|s| self.matches(s, &page.item))
} else {
r.results.iter().any(|s| self.matches(s, &page.item))
}
})
.collect()
}
/// Opens a page, pushing it on the history; nothing when it is empty.
fn open(&mut self, item: Stack, uses: bool, known: &[i32]) -> Option<Out> {
let item = Stack { count: 1, ..item };
let page = Page { item, uses, tab: 0 };
if self.found(&page).is_empty() {
return None;
}
self.history.push(page);
Some(self.show(known))
}
/// The page on top of the history, or no page when there is none.
fn show(&mut self, known: &[i32]) -> Out {
let Some(mut page) = self.history.last().cloned() else {
self.shown.clear();
return Out::Recipes {
title: String::new(),
tabs: Vec::new(),
tab: 0,
views: Vec::new(),
};
};
let found = self.found(&page);
let stations: Vec<usize> = (0..STATIONS.len())
.filter(|&s| found.iter().any(|r| station(r.kind) == s))
.collect();
page.tab = page.tab.min(stations.len().saturating_sub(1));
let on_tab = |r: &&&Recipe| stations.get(page.tab) == Some(&station(r.kind));
let on: Vec<&Recipe> = found
.iter()
.filter(on_tab)
.copied()
.take(MAX_RECIPES)
.collect();
let total = found.iter().filter(on_tab).count();
let views = on.iter().map(|r| self.view(r, known)).collect();
let mut title = format!(
"{}: {}",
if page.uses { "Uses" } else { "Recipes" },
self.name(&page.item.item)
);
if total > on.len() {
title.push_str(&format!(" ({} of {total})", on.len()));
}
let tabs = stations.iter().map(|&s| stack(STATIONS[s])).collect();
self.shown = on.iter().map(|r| r.book_id).collect();
if let Some(top) = self.history.last_mut() {
top.tab = page.tab;
}
Out::Recipes {
title,
tabs,
tab: page.tab,
views,
}
}
fn view(&self, r: &Recipe, known: &[i32]) -> View {
let mut slots: Vec<Vec<Stack>> = (r.slots.iter())
.map(|s| s.iter().take(MAX_STACKS).map(|s| self.convert(s)).collect())
.collect();
let results = (r.results.iter().take(MAX_STACKS))
.map(|s| self.convert(s))
.collect();
let (layout, width, height, note) = match r.kind {
Kind::CraftingShaped => (Layout::Grid, r.width, r.height, String::new()),
Kind::CraftingShapeless => {
let n = slots.len() as u32;
let width = match n {
0 | 1 => 1,
2..=4 => 2,
_ => 3,
};
let height = n.div_ceil(width).max(1);
slots.resize((width * height) as usize, Vec::new());
(Layout::Grid, width, height, "Shapeless".into())
}
Kind::Smelting | Kind::Blasting | Kind::Smoking | Kind::Campfire => {
let seconds = r.cooking_time as f32 / 20.0;
let note = format!("{seconds} s, {} XP", (r.experience * 100.0).round() / 100.0);
(Layout::Cooking, 1, 1, note)
}
Kind::Stonecutting => (Layout::Single, 1, 1, String::new()),
Kind::Smithing => (Layout::Smithing, 3, 1, String::new()),
Kind::Brewing => (Layout::Brewing, 2, 1, String::new()),
};
// + only for what the recipe book shows; greyed until the book has it.
let action = (r.book_id >= 0).then(|| known.contains(&r.book_id));
View {
layout,
width,
height,
slots,
results,
note,
action,
}
}
/// The player's action; `give` is the setting, `creative` whether the
/// game would give. `known` is the recipe book's ids now.
pub fn handle(&mut self, event: Event, give: bool, creative: bool, known: &[i32]) -> Vec<Out> {
match event {
Event::Search(text) => {
self.search = text;
vec![self.list()]
}
Event::Clicked { index, right } => {
let Some(&n) = self.listed.get(index) else {
return Vec::new();
};
let item = self.items[n].id.clone();
if give && creative {
let count = if right { STACK } else { 1 };
return vec![Out::Give { item, count }];
}
self.open(stack(&item), right, known).into_iter().collect()
}
Event::Key { uses, stack } => self.open(stack, uses, known).into_iter().collect(),
Event::SlotClicked { stack, right } => {
self.open(stack, right, known).into_iter().collect()
}
Event::Tab(i) => {
if let Some(top) = self.history.last_mut() {
top.tab = i;
}
vec![self.show(known)]
}
Event::Action(i) => match self.shown.get(i) {
Some(&book) if book >= 0 => vec![Out::Place(book)],
_ => Vec::new(),
},
Event::Back => {
self.history.pop();
vec![self.show(known)]
}
}
}
}
/// "Click gives items" from its stored value (the WebAssembly mod's
/// `true`/`false`); off by default.
pub fn gives(stored: Option<String>) -> bool {
stored.is_some_and(|v| v.trim() == "true")
}
/// The settings page's button.
pub fn give_label(give: bool) -> String {
format!(
"Click gives items (creative): {}",
if give { "ON" } else { "OFF" }
)
}
std::thread_local! {
/// The viewer while a world is open; the game is one thread.
static VIEWER: std::cell::RefCell<Option<Viewer>> = const { std::cell::RefCell::new(None) };
}
/// Runs `f` on the viewer, if a world is open.
pub fn with_viewer<R>(f: impl FnOnce(&mut Viewer) -> R) -> Option<R> {
VIEWER.with(|v| v.borrow_mut().as_mut().map(f))
}
/// Whether the viewer holds a world's tables.
pub fn loaded() -> bool {
VIEWER.with(|v| v.borrow().is_some())
}
/// Keeps the viewer of a world just opened (or none when it closed).
pub fn set_viewer(viewer: Option<Viewer>) {
VIEWER.with(|v| *v.borrow_mut() = viewer);
}
#[cfg(test)]
mod tests {
use super::*;
fn tables() -> (Vec<Item>, Vec<Recipe>) {
let item = |id: &str, name: &str, tags: &[&str]| Item {
id: id.into(),
name: name.into(),
tags: tags.iter().map(|t| t.to_string()).collect(),
};
let items = vec![
item("minecraft:air", "Air", &[]),
item("minecraft:oak_log", "Oak Log", &["minecraft:logs"]),
item("minecraft:oak_planks", "Oak Planks", &["minecraft:planks"]),
item("minecraft:stick", "Stick", &[]),
item("minecraft:charcoal", "Charcoal", &[]),
];
let one = |item| Ingredient {
item,
count: 1,
components: String::new(),
};
let planks = Recipe {
kind: Kind::CraftingShapeless,
width: 0,
height: 0,
slots: vec![vec![one(1)]],
results: vec![Ingredient { count: 4, ..one(2) }],
cooking_time: 0,
experience: 0.0,
book_id: 7,
};
let sticks = Recipe {
kind: Kind::CraftingShaped,
width: 1,
height: 2,
slots: vec![vec![one(2)], vec![one(2)]],
results: vec![Ingredient { count: 4, ..one(3) }],
book_id: 8,
..planks.clone()
};
let charcoal = Recipe {
kind: Kind::Smelting,
width: 0,
height: 0,
slots: vec![vec![one(1)]],
results: vec![one(4)],
cooking_time: 200,
experience: 0.15,
book_id: 9,
};
(items, vec![planks, sticks, charcoal])
}
fn ids(out: &Out) -> Vec<&str> {
match out {
Out::Items(items) => items.iter().map(|s| s.item.as_str()).collect(),
_ => panic!("{out:?}"),
}
}
#[test]
fn lists_every_item_but_air_and_searches_names_ids_namespaces_and_tags() {
let (items, all) = tables();
let (mut v, out) = Viewer::new(items, all);
assert_eq!(ids(&out).len(), 4);
let search = |v: &mut Viewer, text: &str| {
ids(&v.handle(Event::Search(text.into()), false, false, &[])[0]).len()
};
assert_eq!(search(&mut v, "oak"), 2);
assert_eq!(search(&mut v, "#logs"), 1);
assert_eq!(search(&mut v, "@mine stick"), 1);
assert_eq!(search(&mut v, "@other"), 0);
}
#[test]
fn a_click_shows_recipes_and_a_right_click_uses_with_a_tab_per_station() {
let (items, all) = tables();
let (mut v, _) = Viewer::new(items, all);
// Oak Log is listed first: it has no recipe, so nothing opens.
assert!(
v.handle(
Event::Clicked {
index: 0,
right: false
},
false,
false,
&[]
)
.is_empty()
);
let uses = v.handle(
Event::Clicked {
index: 0,
right: true,
},
false,
false,
&[8],
);
let Out::Recipes {
title,
tabs,
tab,
views,
} = &uses[0]
else {
panic!()
};
assert_eq!(title, "Uses: Oak Log");
assert_eq!(
tabs.iter().map(|t| t.item.as_str()).collect::<Vec<_>>(),
[STATIONS[0], STATIONS[1]]
);
assert_eq!(*tab, 0);
// Shapeless planks laid out in a 1 x 1 grid; the book doesn't hold it yet.
assert_eq!(
(views[0].layout, views[0].width, views[0].note.as_str()),
(Layout::Grid, 1, "Shapeless")
);
assert_eq!(views[0].action, Some(false));
let furnace = v.handle(Event::Tab(1), false, false, &[]);
let Out::Recipes { views, .. } = &furnace[0] else {
panic!()
};
assert_eq!(
(views[0].layout, views[0].note.as_str()),
(Layout::Cooking, "10 s, 0.15 XP")
);
assert_eq!(
v.handle(Event::Action(0), false, false, &[]),
[Out::Place(9)]
);
// Back with one page shown: no page, the items again.
let back = v.handle(Event::Back, false, false, &[]);
assert!(matches!(&back[0], Out::Recipes { views, .. } if views.is_empty()));
}
#[test]
fn keys_and_slot_clicks_open_pages_and_back_returns() {
let (items, all) = tables();
let (mut v, _) = Viewer::new(items, all);
let stick = Stack {
count: 64,
..stack("minecraft:stick")
};
let out = v.handle(
Event::Key {
uses: false,
stack: stick,
},
false,
false,
&[8],
);
let Out::Recipes { title, views, .. } = &out[0] else {
panic!()
};
assert_eq!(title, "Recipes: Stick");
assert_eq!(views[0].action, Some(true));
let planks = v.handle(
Event::SlotClicked {
stack: stack("minecraft:oak_planks"),
right: false,
},
false,
false,
&[],
);
assert!(matches!(&planks[0], Out::Recipes { title, .. } if title == "Recipes: Oak Planks"));
let back = v.handle(Event::Back, false, false, &[]);
assert!(matches!(&back[0], Out::Recipes { title, .. } if title == "Recipes: Stick"));
}
#[test]
fn in_creative_with_the_setting_a_click_gives() {
let (items, all) = tables();
let (mut v, _) = Viewer::new(items, all);
let give = |v: &mut Viewer, right, give, creative| {
v.handle(Event::Clicked { index: 2, right }, give, creative, &[])
};
assert_eq!(
give(&mut v, false, true, true),
[Out::Give {
item: "minecraft:stick".into(),
count: 1
}]
);
assert_eq!(
give(&mut v, true, true, true),
[Out::Give {
item: "minecraft:stick".into(),
count: STACK
}]
);
// Off, or not in creative: the page as usual.
assert!(matches!(
&give(&mut v, false, false, true)[0],
Out::Recipes { .. }
));
assert!(matches!(
&give(&mut v, false, true, false)[0],
Out::Recipes { .. }
));
}
#[test]
fn the_setting_reads_the_webassembly_mods_value() {
assert!(gives(Some("true".into())));
assert!(!gives(Some("false".into())));
assert!(!gives(None));
assert_eq!(give_label(true), "Click gives items (creative): ON");
}
}
+12
View File
@@ -0,0 +1,12 @@
// The mod's settings page: the WebAssembly mod's one button.
fn official_recipes_page(
game: &mut crate::game::Game<'_>,
mod_id: &str,
buttons: &mut Vec<String>,
) {
use crate::game::official_recipes as m;
if mod_id != m::ID {
return;
}
buttons.push(m::give_label(m::gives(game.native_setting(m::ID, "give"))));
}
+71
View File
@@ -0,0 +1,71 @@
"""Recipes in a running modded game: the WebAssembly mod's setting is read and written as it was, the panel
beside a crafting table's screen shows items (container screens show it; the creative inventory doesn't), a
click on one gives it in creative mode, and with the setting off the same click opens its recipe page."""
import json
import time
from game import Game, difference
DATA = "horizon-mods/.horizon-mod-data/recipes"
# A player's setting from the WebAssembly mod: "Click gives items" on.
FILES = {f"{DATA}/give": "true"}
SLOT = 18
def first_item(g):
"""The panel's first item (Stone, the first item of the registry): its centre in GUI pixels, and the panel's
part of the screen."""
ui = g.run("ui")
width = ui["gui_size"][0]
stone = next(w for w in ui["widgets"] if w.get("label") == "Stone" and w.get("kind") == "button")
x, y, w, h = stone["bounds"]
return x + w / 2, y + h / 2, ((x - 2 * SLOT) / width, 0.0, 1.0, 1.0)
def table(g):
"""Opens the crafting table placed beside the player."""
x, y, z = (int(c // 1) for c in g.run("status")["player"]["position"])
if not getattr(g, "table", None):
g.run("command", f"setblock {x + 2} {y} {z} minecraft:crafting_table")
g.table = (x + 2, y, z)
time.sleep(1)
g.run("interact", *map(str, g.table))
time.sleep(1)
assert g.run("screen").get("screen") not in ("playing", "creative"), g.run("screen")
def run(ctl, resources, out):
with Game(ctl, resources, out, FILES) as g:
g.run("options", "mods")
assert "Recipes 2.0.0: ON" in g.labels(), g.labels()
g.run("click", g.ids()["Settings"])
page = g.labels()
assert "Click gives items (creative): ON" in page, page
g.play()
g.run("command", "clear @s")
table(g)
x, y, panel = first_item(g)
shown = g.shot("1-panel")
g.run("click-at", str(x), str(y))
time.sleep(1)
given = g.run("inventory").get("counts", {})
assert given, "a click on the panel's first item gave nothing"
g.play()
g.run("options", "mods")
g.run("click", g.ids()["Settings"])
g.run("click", g.ids()["Click gives items (creative): ON"])
stored = (g.home / DATA / "give").read_text()
g.play()
table(g)
before = g.shot("2-items")
g.run("click-at", str(x), str(y))
time.sleep(1)
after = g.shot("3-recipe-page")
results = {
"given": given,
"give_file_after_click": stored,
"diff_panel": round(difference(before, after, panel), 1),
"diff_panel_first_shot": round(difference(shown, before, panel), 1),
}
assert stored == "false", results
assert results["diff_panel"] > 4, results
print(json.dumps({"proof": "recipes", "ok": True, **results}, indent=2))
+1 -1
View File
@@ -2,7 +2,7 @@
"game": "26.3",
"hooks": 1,
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "01d0f45c2d08d2b7686a3c44b02f574a3df72542",
"horizon_revision": "8f91683323ac1165e7d76b3037e18f30fe647205",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
}
+1 -1
View File
@@ -6,7 +6,7 @@ priority = 100
# The commit it was tested on. It uses only the native hook points (hooks 1 in
# compatibility.json): the launcher builds it on any Horizon that has them.
[compatibility]
horizon_revision = "01d0f45c2d08d2b7686a3c44b02f574a3df72542"
horizon_revision = "8f91683323ac1165e7d76b3037e18f30fe647205"
[[module]]
id = "runtime"