mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge pull request #9406 from TheMrZZ/fix/crafting-recipe-selection-order
🤖 Agent: The recipe order now follows ResourceManagerRegistryLoadTask's sort by file id (path, then namespace). It runs once per load, and find_with is unchanged, so lookups cost nothing more. The prepared 26.3 recipe folder has no '-' or '.' names, so vanilla's own recipes keep their order. Main merged in, both fixture sets kept.
This commit is contained in:
7 files changed
+240
-9
No files matched your search
@@ -1717,6 +1717,14 @@ impl Recipes {
|
||||
crafting.push((name, recipe));
|
||||
}
|
||||
}
|
||||
// ResourceManagerRegistryLoadTask registers resource-file Identifiers by
|
||||
// path, then namespace; RecipeMap preserves that first-match order.
|
||||
// Include .json: the resource file, rather than the bare recipe id, is sorted.
|
||||
// Other recipe types retain their existing table order.
|
||||
crafting.sort_by_cached_key(|(name, _)| {
|
||||
let (namespace, path) = name.split_once(':').unwrap_or(("minecraft", name.as_str()));
|
||||
(format!("{path}.json"), namespace.to_owned())
|
||||
});
|
||||
let facts = items
|
||||
.items
|
||||
.iter()
|
||||
@@ -2312,6 +2320,58 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// ResourceManagerRegistryLoadTask registers by resource-file Identifier,
|
||||
/// and RecipeMap keeps that order when taking the first matching recipe.
|
||||
#[test]
|
||||
fn crafting_selection_follows_resource_file_order() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-recipe-order-{}", std::process::id()));
|
||||
fs::create_dir_all(root.join("data/minecraft/recipe")).unwrap();
|
||||
let items = Items::for_tests(HashMap::new());
|
||||
let recipe = |item: &str| {
|
||||
serde_json::json!({
|
||||
"type": "minecraft:crafting_shapeless", "ingredients": ["minecraft:flint"],
|
||||
"result": {"id": item}
|
||||
})
|
||||
};
|
||||
let cases = [
|
||||
(vec!["minecraft:z_pick", "parity:a_pick"], "parity:a_pick"),
|
||||
(
|
||||
vec!["a_priority:z_pick", "z_priority:a_pick"],
|
||||
"z_priority:a_pick",
|
||||
),
|
||||
(
|
||||
vec!["z_priority:equal", "a_priority:equal"],
|
||||
"a_priority:equal",
|
||||
),
|
||||
// File names include .json: '-' sorts before the parent's '.'.
|
||||
(
|
||||
vec!["parity:prefix", "parity:prefix-early"],
|
||||
"parity:prefix-early",
|
||||
),
|
||||
(vec!["z_pick", "parity:a_pick"], "parity:a_pick"),
|
||||
];
|
||||
for (ids, expected) in cases {
|
||||
for reverse in [false, true] {
|
||||
let mut sources: Vec<_> = ids
|
||||
.iter()
|
||||
.map(|id| (id.to_string(), recipe("minecraft:stick")))
|
||||
.collect();
|
||||
if reverse {
|
||||
sources.reverse();
|
||||
}
|
||||
let recipes =
|
||||
Recipes::from_sources(&root, &items, &PackOverlay::NONE, sources).unwrap();
|
||||
let (id, result) = recipes
|
||||
.find(&[Some(ItemStack::new("minecraft:flint", 1))], 1)
|
||||
.unwrap();
|
||||
assert_eq!(id, expected, "input order: {ids:?}, reversed={reverse}");
|
||||
assert_eq!(&*result.item, "minecraft:stick");
|
||||
}
|
||||
}
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// A recipe whose `neoforge:conditions` hold loads, one whose don't is
|
||||
/// silently absent, from the root's own files and a pack's alike.
|
||||
#[test]
|
||||
|
||||
@@ -520,6 +520,17 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
"crafter_tick" => {
|
||||
let [x, y, z] =
|
||||
std::array::from_fn(|i| op["pos"][i].as_i64().unwrap_or(0) as i32);
|
||||
self.harness_owner(|g| {
|
||||
let state = g.world.block(x, y, z);
|
||||
if &*g.res.facts().get(state).name != "minecraft:crafter" {
|
||||
return Err("command harness: crafter tick without a crafter".into());
|
||||
}
|
||||
g.dispense_from(horizon_core::position::BlockPos::new(x, y, z), state)
|
||||
})?;
|
||||
}
|
||||
"break" => {
|
||||
let pos = std::array::from_fn(|i| op["pos"][i].as_i64().unwrap_or(0) as i32);
|
||||
self.harness_owner(|g| g.break_block(pos))?;
|
||||
|
||||
@@ -92,9 +92,8 @@ impl Game<'_> {
|
||||
return Ok(());
|
||||
};
|
||||
let grid: Vec<Option<ItemStack>> = crafter.items.to_vec();
|
||||
// RecipeCache.get: the first crafting recipe that matches, assembled.
|
||||
// RecipeCache.get: query the active world recipe manager, then assemble its first match.
|
||||
let found = self
|
||||
.res
|
||||
.recipes()
|
||||
.find(&grid, 3)
|
||||
.map(|(name, result)| (name.to_owned(), result))
|
||||
@@ -182,7 +181,7 @@ impl Game<'_> {
|
||||
(0..3).all(|i| (e[i] - c[i]).abs() <= 8.5 + 1.0)
|
||||
});
|
||||
if near {
|
||||
let id = format!("minecraft:{recipe}");
|
||||
let id = horizon_client::crafting::qualified(recipe);
|
||||
let me = self.player_entity_for("minecraft:crafter_recipe_crafted");
|
||||
self.trigger("minecraft:crafter_recipe_crafted", move |c, w| {
|
||||
let ctx = adv::Entities {
|
||||
|
||||
@@ -426,6 +426,14 @@ public final class VanillaCommandExecOracle {
|
||||
player.gameMode.useItemOn(player, player.level(), player.getMainHandItem(),
|
||||
net.minecraft.world.InteractionHand.MAIN_HAND, hit);
|
||||
}
|
||||
case "crafter_tick" -> {
|
||||
JsonArray p = op.getAsJsonArray("pos");
|
||||
BlockPos pos = new BlockPos(p.get(0).getAsInt(), p.get(1).getAsInt(), p.get(2).getAsInt());
|
||||
var state = player.level().getBlockState(pos);
|
||||
if (!state.is(net.minecraft.world.level.block.Blocks.CRAFTER))
|
||||
throw new IllegalStateException("crafter tick without a crafter");
|
||||
state.tick(player.level(), pos, player.level().getRandom());
|
||||
}
|
||||
case "break" -> {
|
||||
JsonArray pos = op.getAsJsonArray("pos");
|
||||
player.gameMode.destroyBlock(new BlockPos(pos.get(0).getAsInt(), pos.get(1).getAsInt(), pos.get(2).getAsInt()));
|
||||
|
||||
@@ -1794,6 +1794,10 @@ def progression():
|
||||
|
||||
|
||||
def advancement_reward_fixtures(group_names):
|
||||
if 'crafting recipe priority' in group_names:
|
||||
return {f'h295:{name}': {'criteria': {'crafted': {
|
||||
'trigger': 'minecraft:crafter_recipe_crafted', 'conditions': {'recipes': recipe}}}}
|
||||
for name, recipe in [('custom_crafted', 'parity:aaa_star'), ('wrong_recipe', 'parity:zzz_star')]}
|
||||
if 'advancement reward functions' not in group_names:
|
||||
return {}
|
||||
def advancement(function=None):
|
||||
@@ -4951,6 +4955,107 @@ def bell_server_glow():
|
||||
return ops + setup('/scoreboard objectives remove bell_clock')
|
||||
|
||||
|
||||
def crafting_priority_recipes(group_names):
|
||||
if 'crafting recipe priority' not in group_names:
|
||||
return {}
|
||||
def recipe(inputs, item, count=1):
|
||||
return {'type': 'minecraft:crafting_shapeless', 'ingredients': ['minecraft:' + x for x in inputs],
|
||||
'result': {'id': 'minecraft:' + item, 'count': count}}
|
||||
# Write deliberately out of selection order; resource registration decides priority.
|
||||
return {'parity:zzz_star': recipe(['gunpowder', 'red_dye'], 'emerald'),
|
||||
'parity:aaa_star': recipe(['gunpowder', 'blue_dye'], 'diamond'),
|
||||
'a_priority:z_h295_pick': recipe(['gold_nugget', 'paper'], 'diamond'),
|
||||
'z_priority:a_h295_pick': recipe(['gold_nugget', 'paper'], 'emerald'),
|
||||
'z_priority:h295_equal': recipe(['coal', 'paper'], 'emerald'),
|
||||
'a_priority:h295_equal': recipe(['coal', 'paper'], 'gold_nugget', 3),
|
||||
'parity:h295_resource': recipe(['clay_ball', 'paper'], 'brick'),
|
||||
'parity:h295_resource-early': recipe(['clay_ball', 'paper'], 'nether_brick'),
|
||||
'minecraft:oak_planks': recipe(['oak_log'], 'gold_ingot', 2),
|
||||
'parity:zzzz_h295_unique': recipe(['apple', 'amethyst_shard'], 'quartz', 5)}
|
||||
|
||||
|
||||
def crafting_recipe_priority(world=True):
|
||||
"""Fresh natural crafters distinguish world selection, resource order and ID replacement."""
|
||||
ops = setup('/forceload add 0 0 15 15', '/fill 0 199 0 15 199 15 stone',
|
||||
'/tp @s 8.5 200 8.5 0 0', '/clear @s', '/recipe take @s *',
|
||||
'/scoreboard objectives add recipe_order dummy',
|
||||
'/data modify storage h295:identity uuid set from entity @s UUID')
|
||||
ops += [cmd('/execute if loaded 4 200 4', expect_result=1),
|
||||
cmd('/execute if entity @s[type=player,nbt={Pos:[8.5d,200d,8.5d],Health:20f}]', expect_result=1)]
|
||||
if world:
|
||||
for id in crafting_priority_recipes(['crafting recipe priority']):
|
||||
ops += [cmd('/recipe give @s ' + id, expect_result=1)]
|
||||
cases = [('early', ['gunpowder', 'blue_dye'], 'diamond', 1),
|
||||
('late', ['gunpowder', 'red_dye'], 'firework_star', 1),
|
||||
('path_before_namespace', ['gold_nugget', 'paper'], 'emerald', 1),
|
||||
('same_path_namespace', ['coal', 'paper'], 'gold_nugget', 3),
|
||||
('resource_file_prefix', ['clay_ball', 'paper'], 'nether_brick', 1),
|
||||
('base_id_replacement', ['oak_log'], 'gold_ingot', 2),
|
||||
('late_world_only', ['apple', 'amethyst_shard'], 'quartz', 5),
|
||||
('ordinary', ['birch_log'], 'birch_planks', 4)]
|
||||
else:
|
||||
cases = [('base_star', ['gunpowder', 'blue_dye'], 'firework_star', 1),
|
||||
('base_planks', ['oak_log'], 'oak_planks', 4),
|
||||
('ordinary', ['birch_log'], 'birch_planks', 4)]
|
||||
for label, inputs, output, count in cases:
|
||||
ops += setup('/setblock 4 200 5 air', '/setblock 4 200 4 air', '/setblock 4 200 3 air',
|
||||
'/setblock 4 200 3 chest[facing=south]', '/setblock 4 200 4 crafter[orientation=north_up]')
|
||||
for i, item in enumerate(inputs):
|
||||
ops += setup(f'/item replace block 4 200 4 container.{i} with minecraft:{item} 1')
|
||||
ops += [cmd('/execute if entity @s[type=player,nbt={Pos:[8.5d,200d,8.5d],Health:20f}]', expect_result=1),
|
||||
cmd('/execute if block 4 199 4 stone', expect_result=1),
|
||||
cmd('/execute if block 4 200 4 crafter[orientation=north_up,triggered=false]', expect_result=1),
|
||||
cmd('/execute if block 4 200 3 chest[facing=south]', expect_result=1),
|
||||
cmd('/data get block 4 200 4 id'), cmd('/data get block 4 200 4 Items'),
|
||||
cmd('/data get block 4 200 4 disabled_slots'), cmd('/data get block 4 200 3 Items')]
|
||||
for i, item in enumerate(inputs):
|
||||
ops += [cmd(f'/execute if items block 4 200 4 container.{i} minecraft:{item}', expect_result=1)]
|
||||
ops += setup('/execute store result score start recipe_order run time query gametime')
|
||||
ops += [cmd('/setblock 4 200 5 redstone_block'),
|
||||
cmd('/execute if block 4 200 4 crafter[triggered=true]', expect_result=1),
|
||||
{'op': 'tick', 'n': 10}, cmd('/data get block 4 200 4 Items'),
|
||||
cmd('/execute unless data block 4 200 4 Items[]', expect_result=1),
|
||||
cmd('/data get block 4 200 3 Items'),
|
||||
cmd(f'/execute if items block 4 200 3 container.0 minecraft:{output}', expect_result=count),
|
||||
cmd(f'/execute if data block 4 200 3 Items[{{Slot:0b,id:"minecraft:{output}",count:{count}}}]', expect_result=1)]
|
||||
ops += setup('/execute store result score elapsed recipe_order run time query gametime',
|
||||
'/scoreboard players operation elapsed recipe_order -= start recipe_order')
|
||||
ops += [cmd('/scoreboard players get elapsed recipe_order', expect_result=10)]
|
||||
if world:
|
||||
# Open-air ejection exercises the existing trigger route, independently
|
||||
# of insertion into a chest. The wrong recipe is a negative control.
|
||||
for advancement in ['h295:custom_crafted', 'h295:wrong_recipe']:
|
||||
ops += [cmd('/advancement grant @s only ' + advancement, expect_result=1),
|
||||
cmd('/advancement revoke @s only ' + advancement, expect_result=1),
|
||||
cmd('/execute unless entity @s[advancements={' + advancement + '=true}]', expect_result=1)]
|
||||
ops += setup('/kill @e[type=item]', '/setblock 4 200 5 air', '/setblock 4 200 4 air',
|
||||
'/setblock 4 200 3 air', '/setblock 4 200 4 crafter[orientation=north_up]',
|
||||
'/item replace block 4 200 4 container.0 with gunpowder 1',
|
||||
'/item replace block 4 200 4 container.1 with blue_dye 1')
|
||||
ops += [cmd('/execute if entity @s[type=player,nbt={Pos:[8.5d,200d,8.5d],Health:20f}]', expect_result=1),
|
||||
cmd('/execute if block 4 199 4 stone', expect_result=1),
|
||||
cmd('/execute if block 4 200 3 air', expect_result=1),
|
||||
cmd('/execute if block 4 200 4 crafter[orientation=north_up,triggered=false]', expect_result=1),
|
||||
cmd('/execute if items block 4 200 4 container.0 gunpowder', expect_result=1),
|
||||
cmd('/execute if items block 4 200 4 container.1 blue_dye', expect_result=1)]
|
||||
ops += setup('/execute store result score start recipe_order run time query gametime')
|
||||
# Invoke the real block tick in the connected owner's context. Ordinary
|
||||
# server ticks have no acting player in this harness; recipient dispatch
|
||||
# is a separate route and is not repaired by recipe-ID normalization.
|
||||
ops += [cmd('/setblock 4 200 5 redstone_block'),
|
||||
{'op': 'crafter_tick', 'pos': [4, 200, 4]}, {'op': 'tick', 'n': 10},
|
||||
cmd('/execute unless data block 4 200 4 Items[]', expect_result=1),
|
||||
cmd('/execute if entity @e[type=item,nbt={Item:{id:"minecraft:diamond",count:1}}]', expect_result=1),
|
||||
cmd('/execute if entity @s[advancements={h295:custom_crafted=true}]', expect_result=1),
|
||||
cmd('/execute unless entity @s[advancements={h295:wrong_recipe=true}]', expect_result=1)]
|
||||
ops += setup('/execute store result score elapsed recipe_order run time query gametime',
|
||||
'/scoreboard players operation elapsed recipe_order -= start recipe_order')
|
||||
ops += [cmd('/scoreboard players get elapsed recipe_order', expect_result=10)]
|
||||
ops += setup('/execute store success score identity_changed recipe_order run data modify storage h295:identity uuid set from entity @s UUID')
|
||||
ops += [cmd('/scoreboard players get identity_changed recipe_order', expect_result=0)]
|
||||
return ops + setup('/setblock 4 200 5 air', '/scoreboard objectives remove recipe_order')
|
||||
|
||||
|
||||
def advancement_reward_functions():
|
||||
"""Function-only impossible advancements isolate reward dispatch from triggers and XP/loot."""
|
||||
ops = setup('/forceload add 0 0 15 15', '/setblock 8 229 8 stone',
|
||||
@@ -5172,8 +5277,8 @@ def groups():
|
||||
('setup', group_setup()),
|
||||
('advancement world loot rewards', advancement_world_loot_rewards()),
|
||||
('advancement empty loot reference', advancement_empty_loot_reference()),
|
||||
|
||||
|
||||
('crafting recipe priority', crafting_recipe_priority()),
|
||||
('crafting base recipe control', crafting_recipe_priority(False)),
|
||||
('item stack-size override merges', stack_limit_merges(cmd, setup)),
|
||||
('moving piston command data', moving_piston_data(cmd, setup)),
|
||||
('mob team command names', mob_team_command_names()),
|
||||
|
||||
@@ -39,8 +39,8 @@ except ImportError:
|
||||
SEED = 1
|
||||
|
||||
|
||||
def install_datapack_fixtures(world, predicates, functions, jar, advancements=None, loot_tables=None):
|
||||
if not predicates and not functions and not advancements and not loot_tables:
|
||||
def install_datapack_fixtures(world, predicates, functions, jar, advancements=None, loot_tables=None, recipes=None):
|
||||
if not predicates and not functions and not advancements and not loot_tables and not recipes:
|
||||
return
|
||||
with zipfile.ZipFile(jar) as archive:
|
||||
version = json.loads(archive.read('version.json'))['pack_version']
|
||||
@@ -73,6 +73,13 @@ def install_datapack_fixtures(world, predicates, functions, jar, advancements=No
|
||||
target.write_text(json.dumps(table), encoding='utf-8')
|
||||
|
||||
|
||||
for identifier, recipe in (recipes or {}).items():
|
||||
namespace, path = identifier.split(':', 1)
|
||||
target = pack / 'data' / namespace / 'recipe' / (path + '.json')
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(json.dumps(recipe), encoding='utf-8')
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--prepare', action='store_true')
|
||||
@@ -146,6 +153,11 @@ def main():
|
||||
groups = [(name, corpus_module.advancement_world_loot_rewards(
|
||||
named_table=args.advancement_loot_named_diagnostic, include_missing_ack=True)
|
||||
if name == 'advancement world loot rewards' else ops) for name, ops in groups]
|
||||
# These overlays replace built-in recipes: run each in its own world, never
|
||||
# beside the generic corpus's fixed vanilla recipe-count controls.
|
||||
isolated_recipes = {'crafting recipe priority', 'crafting base recipe control'}
|
||||
if not args.only:
|
||||
groups = [(name, ops) for name, ops in groups if name not in isolated_recipes]
|
||||
if args.loaded_fixture:
|
||||
groups = [('setup', corpus_module.group_setup()),
|
||||
('loaded ticket removal', corpus_module.loaded_ticket_removal(args.loaded_block_diagnostic))]
|
||||
@@ -212,6 +224,8 @@ def main():
|
||||
if args.slot_codec_diagnostic:
|
||||
groups.append(('slot source codec diagnostic', [corpus_module.cmd(
|
||||
'/item fill block 4 200 4 {type:"minecraft:slot_range",slots:"container.99"} with diamond')]))
|
||||
if any(name in isolated_recipes for name, _ in groups) and sum(name != 'setup' for name, _ in groups) != 1:
|
||||
parser.error('crafting recipe fixtures require --only with one exact group')
|
||||
script = []
|
||||
for _, lines in groups:
|
||||
script.extend(lines)
|
||||
@@ -220,6 +234,7 @@ def main():
|
||||
ensure_ascii=False) + '\n' for op in script), encoding='utf-8')
|
||||
predicates = corpus_module.predicate_selector_fixtures([name for name, _ in groups])
|
||||
functions = corpus_module.function_return_fixtures([name for name, _ in groups])
|
||||
recipes = corpus_module.crafting_priority_recipes([name for name, _ in groups])
|
||||
advancements = corpus_module.advancement_reward_fixtures([name for name, _ in groups])
|
||||
loot_advancements, loot_tables = corpus_module.advancement_loot_fixtures([name for name, _ in groups])
|
||||
advancements.update(loot_advancements)
|
||||
@@ -232,7 +247,7 @@ def main():
|
||||
vanilla_dir = build / 'vanilla'
|
||||
shutil.rmtree(vanilla_dir, ignore_errors=True)
|
||||
vanilla_dir.mkdir()
|
||||
install_datapack_fixtures(vanilla_dir / 'universe/harness', predicates, functions, jars['server'], advancements, loot_tables)
|
||||
install_datapack_fixtures(vanilla_dir / 'universe/harness', predicates, functions, jars['server'], advancements, loot_tables, recipes=recipes)
|
||||
java_options = []
|
||||
if args.orb_water_fixture:
|
||||
(build / 'orb-water-reference.json').unlink(missing_ok=True)
|
||||
@@ -270,7 +285,7 @@ def main():
|
||||
rust_dir = build / 'rust'
|
||||
shutil.rmtree(rust_dir, ignore_errors=True)
|
||||
rust_dir.mkdir()
|
||||
install_datapack_fixtures(rust_dir / 'saves/harness', predicates, functions, jars['server'], advancements, loot_tables)
|
||||
install_datapack_fixtures(rust_dir / 'saves/harness', predicates, functions, jars['server'], advancements, loot_tables, recipes=recipes)
|
||||
# The oracle's Services resolve no profile (/fetchprofile fails on the next tick): the game gets no services
|
||||
# either, a discovery address that refuses at once, rather than Mojang's over the network.
|
||||
env = lab.game_env(HORIZON_PLAYER_NAME="Player", HORIZON_COMMAND_SCRIPT=script_path,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Natural crafter first-match priority, in separate world-pack and vanilla worlds.
|
||||
|
||||
Reuse the actual Game / pinned Java command runner, but keep the overriding
|
||||
recipe pack out of every other command group's registry and count controls.
|
||||
"""
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--binary', type=Path, help='use this built Game binary')
|
||||
parser.add_argument('--prepare', action='store_true')
|
||||
args = parser.parse_args()
|
||||
runner = Path(__file__).with_name('test_command_exec_parity.py')
|
||||
build = runner.resolve().parents[1] / '.local/crafting-priority-parity'
|
||||
for phase, group in [('world', 'crafting recipe priority'), ('base', 'crafting base recipe control')]:
|
||||
command = [sys.executable, str(runner), '--only', group, '--work-dir', str(build / phase)]
|
||||
if args.binary:
|
||||
command += ['--binary', str(args.binary.resolve())]
|
||||
if args.prepare:
|
||||
command.append('--prepare')
|
||||
result = subprocess.run(command, check=False)
|
||||
if result.returncode:
|
||||
return result.returncode
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user