mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-08 17:19:10 -10:00
The book learns a crafted recipe under its whole id
The crafting copy names a minecraft: recipe bare (Recipes::sources drops the namespace, folder and world pack alike), and award_crafted handed that bare name on: the book, the network copy, the advancements' JSON and the saved file all know the recipe as minecraft:<name>, so a recipe unlocked only by crafting it had no display — never in the book, not after the craft, not after rejoining. Vanilla recipes are usually unlocked first by their advancements, under the whole id, which hid it; a recipe_crafted criterion never matched the bare name either, for the same reason. crafting::qualified now fills the default namespace in, shared with the lan copy's own. The book learns and forgets recipes under their whole id whatever the caller passes (Settings::add_recipes, remove_recipes), and award_crafted qualifies before the recipe_crafted trigger and the book. Reading a saved book already qualified bare names; a test keeps that, so books saved before the fix show their recipes too. Fixes #7186. Co-Authored-By: ZCode AI agent (GLM-5.3)
This commit is contained in:
1 parent
3feb84a5ee
commit
c2e8cc0fe2
6 files changed
+145
-16
No files matched your search
@@ -1425,6 +1425,29 @@ mod tests {
|
||||
&& matches!(id, "minecraft:fireball" | "minecraft:arrow"))
|
||||
}
|
||||
}
|
||||
/// A `recipe_crafted` criterion names `minecraft:x`; the craft hands
|
||||
/// the recipe book and the trigger the same whole id (`award_crafted`
|
||||
/// qualifies). The bare name the crafting copy uses never matched —
|
||||
/// the second half of #7186.
|
||||
#[test]
|
||||
fn a_recipe_crafted_criterion_matches_the_whole_id_only() {
|
||||
let w = Flat;
|
||||
let criterion = serde_json::json!({"recipes": "minecraft:folder_recipe"});
|
||||
assert!(
|
||||
!id_matches(&criterion["recipes"], "recipe", "folder_recipe", &w),
|
||||
"the crafting copy's bare name never matched the criterion"
|
||||
);
|
||||
assert!(
|
||||
id_matches(
|
||||
&criterion["recipes"],
|
||||
"recipe",
|
||||
&crate::crafting::qualified("folder_recipe"),
|
||||
&w
|
||||
),
|
||||
"the whole id award_crafted passes matches"
|
||||
);
|
||||
}
|
||||
|
||||
/// `LockCode.canUnlock` for a chest with
|
||||
/// `lock:{items:"minecraft:gold_ingot"}`: a gold ingot opens it, an empty
|
||||
/// hand or another item does not, and a spectator always does.
|
||||
|
||||
@@ -1305,6 +1305,18 @@ fn matches_all(ingredients: &[Ingredient], stacks: &[&ItemStack]) -> bool {
|
||||
augment(i, ingredients, stacks, &mut owner, &mut seen)
|
||||
})
|
||||
}
|
||||
/// A resource id with its default namespace filled in — the whole id the
|
||||
/// book, the network copy and the advancements' JSON know a recipe by,
|
||||
/// whatever the caller passes. `sources` names a `minecraft:` recipe bare
|
||||
/// on purpose (vanilla's own ids); this puts the namespace back.
|
||||
pub fn qualified(id: &str) -> String {
|
||||
if id.contains(':') {
|
||||
id.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
}
|
||||
}
|
||||
|
||||
impl Recipes {
|
||||
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Result<Self> {
|
||||
let sources = Self::sources(root, overlay)?;
|
||||
|
||||
@@ -8746,9 +8746,15 @@ impl Game<'_> {
|
||||
/// (special ones never do).
|
||||
fn award_crafted(&mut self, crafts: &[(String, Vec<Option<ItemStack>>)]) {
|
||||
for (recipe, ingredients) in crafts {
|
||||
self.trigger_recipe_crafted(recipe, ingredients);
|
||||
// The whole id: the advancements' `recipe_crafted` criteria name
|
||||
// `minecraft:x`, and the crafting copy names the recipe bare.
|
||||
let recipe = horizon_client::crafting::qualified(recipe);
|
||||
self.trigger_recipe_crafted(&recipe, ingredients);
|
||||
}
|
||||
let recipes: Vec<String> = crafts.iter().map(|(r, _)| r.clone()).collect();
|
||||
let recipes: Vec<String> = crafts
|
||||
.iter()
|
||||
.map(|(r, _)| horizon_client::crafting::qualified(r))
|
||||
.collect();
|
||||
let index = &world_stations!(self).recipe_index;
|
||||
let (_, unlocked) = self.level.recipe_book.add_recipes(index, &recipes);
|
||||
self.recipe_unlocked(&unlocked);
|
||||
|
||||
@@ -716,6 +716,78 @@ mod tests {
|
||||
/// A recipe whose ingredient kind no loader knows stays out of the book:
|
||||
/// the server does not load it, and an empty ingredient slot in its
|
||||
/// place would mislead the client.
|
||||
/// The shared probe: a recipe the player crafts is learned by the
|
||||
/// name the crafting copy hands out, and rejoining sends what the
|
||||
/// book knows.
|
||||
fn crafted_recipe_reaches_the_book(name: &str, pack: bool) {
|
||||
let shaped = r#"{"type":"minecraft:crafting_shaped","pattern":["X"],
|
||||
"key":{"X":"minecraft:raw_copper"},
|
||||
"result":{"id":"minecraft:copper_ingot"}}"#;
|
||||
let root = if pack {
|
||||
resources(name, &[])
|
||||
} else {
|
||||
resources(name, &[(name, shaped)])
|
||||
};
|
||||
let packs = pack.then(|| {
|
||||
vec![(
|
||||
format!("recipe/minecraft:{name}"),
|
||||
vec![shaped.as_bytes().to_vec()],
|
||||
)]
|
||||
});
|
||||
let overlay = match &packs {
|
||||
Some(p) => horizon_world::loot::PackOverlay { files: p },
|
||||
None => horizon_world::loot::PackOverlay::NONE,
|
||||
};
|
||||
let sync = SyncData::load(&root, &overlay).unwrap();
|
||||
// What the craft hands the book: find_with over the crafting
|
||||
// copy, the way award_crafted calls it.
|
||||
let items = crate::items::Items::catalog(&root, &overlay).unwrap();
|
||||
let recipes = crate::crafting::Recipes::load(&root, &items, &overlay).unwrap();
|
||||
let grid = vec![Some(horizon_items::items::ItemStack {
|
||||
item: "minecraft:raw_copper".into(),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
})];
|
||||
let (found, _) = recipes
|
||||
.find_with(&grid, 1, &|_| None)
|
||||
.unwrap_or_else(|| panic!("{name}: the recipe matches a grid"));
|
||||
assert_eq!(
|
||||
found, name,
|
||||
"{name}: the crafting copy names it bare, folder and pack alike"
|
||||
);
|
||||
// The book learns what the craft awards; rejoining sends it.
|
||||
let mut book = crate::recipe_book::Settings::default();
|
||||
let (_, unlocked) = book.add_recipes(&sync.advancements.displays, &[found.to_owned()]);
|
||||
assert_eq!(
|
||||
unlocked,
|
||||
[format!("minecraft:{name}")],
|
||||
"{name}: the book knows it under its whole id"
|
||||
);
|
||||
let bytes = sync.advancements.initial_recipe_book(&book, &sync);
|
||||
let mut r = Reader::new(&bytes);
|
||||
let add =
|
||||
<horizon_protocol::packets::recipes::RecipeBookAdd as horizon_protocol::buf::Codec<
|
||||
horizon_protocol::packets::recipes::RecipeBookAdd,
|
||||
>>::read(&mut r)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!add.entries.is_empty(),
|
||||
"{name}: the crafted recipe reaches the book after rejoining"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_recipe_unlocked_by_crafting_shows_in_the_book() {
|
||||
crafted_recipe_reaches_the_book("folder_recipe", false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pack_recipe_unlocked_by_crafting_shows_in_the_book() {
|
||||
crafted_recipe_reaches_the_book("pack_recipe", true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recipe_with_an_unknown_ingredient_kind_stays_out_of_the_book() {
|
||||
let root = resources(
|
||||
|
||||
@@ -82,14 +82,7 @@ pub struct SyncData {
|
||||
pub advancements: super::advancements::ServerAdvancements,
|
||||
}
|
||||
|
||||
/// A resource id with its default namespace filled in.
|
||||
fn qualified(id: &str) -> String {
|
||||
if id.contains(':') {
|
||||
id.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
}
|
||||
}
|
||||
use crate::crafting::qualified;
|
||||
|
||||
/// Every `*.json` under `dir`, as (path without extension relative to dir, value), sorted;
|
||||
/// a file that can't be read or doesn't parse is left out. Through `json_folder`: from the
|
||||
|
||||
@@ -835,11 +835,15 @@ impl Settings {
|
||||
let mut displays = 0;
|
||||
let mut added = Vec::new();
|
||||
for id in ids {
|
||||
if !self.recipes.contains(id) && !index.special(id) {
|
||||
// The whole id: the crafting copy names a `minecraft:` recipe
|
||||
// bare, and every reader of the book (the network copy, the
|
||||
// advancements' JSON, the saved file) knows it qualified.
|
||||
let id = crate::crafting::qualified(id);
|
||||
if !self.recipes.contains(&id) && !index.special(&id) {
|
||||
self.recipes.insert(id.clone());
|
||||
self.to_be_displayed.insert(id.clone());
|
||||
displays += index.displays(id);
|
||||
added.push(id.clone());
|
||||
displays += index.displays(&id);
|
||||
added.push(id);
|
||||
}
|
||||
}
|
||||
(displays, added)
|
||||
@@ -879,9 +883,10 @@ impl Settings {
|
||||
pub fn remove_recipes(&mut self, index: &RecipeIndex, ids: &[String]) -> Vec<i32> {
|
||||
let mut displays = Vec::new();
|
||||
for id in ids {
|
||||
if self.recipes.remove(id) {
|
||||
self.to_be_displayed.remove(id);
|
||||
displays.extend(index.display_ids(id));
|
||||
let id = crate::crafting::qualified(id);
|
||||
if self.recipes.remove(&id) {
|
||||
self.to_be_displayed.remove(&id);
|
||||
displays.extend(index.display_ids(&id));
|
||||
}
|
||||
}
|
||||
displays
|
||||
@@ -959,6 +964,24 @@ impl Settings {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// A book saved before #7186's fix holds bare names: reading fills the
|
||||
/// namespace in, so those recipes show in the book too.
|
||||
#[test]
|
||||
fn a_book_saved_with_bare_names_reads_qualified() {
|
||||
use horizon_world::nbt::{Compound, Tag};
|
||||
let list = || Tag::List(vec![Tag::String("folder_recipe".into())]);
|
||||
let mut compound = Compound::new();
|
||||
compound.insert("recipes".into(), list());
|
||||
compound.insert("toBeDisplayed".into(), list());
|
||||
let tag = Tag::Compound(compound);
|
||||
let read = Settings::read_valid(Some(&tag), &|_| true);
|
||||
assert_eq!(
|
||||
read.recipes,
|
||||
["minecraft:folder_recipe".to_owned()].into_iter().collect()
|
||||
);
|
||||
assert!(read.to_be_displayed.contains("minecraft:folder_recipe"));
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
fn ing(items: &[&str]) -> Ingredient {
|
||||
|
||||
Reference in new issue
Block a user