mirror of
https://github.com/horizon-rust/horizon-mods.git
synced 2026-11-07 11:05:58 -10:00
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:
1 parent
f539b5a584
commit
d6a1ddd703
22 files changed
+1160
-8
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+7
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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" },
|
||||
]
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"))));
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user