Honor custom durability on blocking items

Co-Authored-By: Codex <[email protected]>
This commit is contained in:
pehe1andCodex committed 2026-11-08 23:19:40 -03:00
1 parent db87c15e7c
commit 3b34446f84
5 files changed
+144 -14

No files matched your search

@@ -190,6 +190,9 @@ impl Game<'_> {
fn dispense_case(&mut self, case: &Value) -> Result<Vec<String>> {
let seed = case["seed"].as_i64().unwrap_or(0);
self.fixture_reset(seed);
if let Some(op) = case.get("blocking_wear") {
return self.fixture_blocking_wear(op);
}
// The oracle level answers one sky and one block light everywhere.
let light = case["light"].as_array();
let level = |i: usize, default: i64| {
@@ -662,6 +665,39 @@ impl Game<'_> {
/// source, the hurt with the weapon's armor effects, then `weapon_hit`
/// (the mace's smash, post-attack enchantments, the wear), as
/// `VanillaDispenserOracle.attack` prints them.
fn fixture_blocking_wear(&mut self, op: &Value) -> Result<Vec<String>> {
let stack = parse_stack(op["item"].as_str().ok_or("missing blocking stack")?)?
.ok_or("empty blocking stack")?;
// Reload the stack just as a player inventory does between sessions.
let saved = horizon_client::item_nbt::item_to_nbt(&stack);
self.inventory[self.selected] = horizon_client::item_nbt::item_from_nbt(&saved);
self.creative = op["creative"].as_bool().unwrap_or(false);
self.mode = if self.creative {
GameMode::Creative
} else {
GameMode::Survival
};
self.combat = Default::default();
self.using = None;
self.shield_cooldown = 0;
self.yaw = 0.0;
self.player = Some(PlayerMotion::new(
self.motion,
horizon_core::geometry::Vec3::new(0.0, 64.0, 0.0),
)?);
if !self.raise_shield(self.selected) || self.using.is_none() {
return Err("could not raise blocking stack".into());
}
let mut lines = Vec::new();
for _ in 0..2 {
self.hurt_player_from(4.0, Some([0.0, 64.0, 2.0]), "minecraft:arrow", Some(None));
let stack = self.inventory[self.selected].as_ref();
let (count, damage) = stack.map_or((0, 0), |stack| (stack.count, stack.damage));
lines.push(format!("blocking wear {count} {damage}"));
}
Ok(lines)
}
fn fixture_attack(&mut self, op: &Value, targets: &[Target]) -> Result<Vec<String>> {
let stack = parse_stack(op["item"].as_str().unwrap_or("-"))?;
self.mode = if op["creative"].as_bool().unwrap_or(false) {
+41 -10
View File
@@ -151,21 +151,27 @@ impl Game<'_> {
} else {
(base + factor * outcome.blocked).floor() as i32
};
if item_damage > 0 && !self.creative {
if item_damage > 0
&& !self.creative
&& let Some(max) = blocking_item_max_damage(
&stack,
self.items()
.get(&stack.item)
.map_or(0, |item| item.max_damage),
)
{
let loss = self.wear(&stack, item_damage as u32);
let max = self
.res
.items()
.get(&stack.item)
.map_or(0, |i| i.max_damage);
let cell = self.stack_in_mut(slot);
if let Some(s) = cell.as_mut()
&& max > 0
if loss > 0
&& let Some(s) = cell.as_mut()
{
s.damage += loss;
s.damage = s.damage.saturating_add(loss).min(max);
if s.damage >= max {
let broken = s.clone();
*cell = None;
s.count -= 1;
if s.count == 0 {
*cell = None;
}
self.using = None;
// ItemStack.hurtAndBreak: onEquippedItemBroken for the hand it was used from.
let hand = if slot == OFFHAND_SLOT {
@@ -298,3 +304,28 @@ impl Game<'_> {
}
}
}
/// `ItemStack.isDamageableItem` and `getMaxDamage` for the blocking stack.
fn blocking_item_max_damage(stack: &ItemStack, prototype_max: u32) -> Option<u32> {
let data = stack.data();
let component = |id| data.and_then(|data| data.other(id));
if stack.count == 0
|| component("minecraft:unbreakable").is_some()
|| component("!minecraft:max_damage").is_some()
|| component("!minecraft:damage").is_some()
{
return None;
}
// A non-damageable prototype supplies neither MAX_DAMAGE nor DAMAGE.
if prototype_max == 0
&& stack.damage == 0
&& !data.is_some_and(|data| data.explicit_zero_damage)
{
return None;
}
let max = match component("minecraft:max_damage") {
Some(value) => horizon_world::datafix::dynamic::as_int(Some(value), 0).max(0) as u32,
None => prototype_max,
};
(max > 0).then_some(max)
}
+6 -1
View File
@@ -882,7 +882,12 @@ pub fn encode_components_with_items(stack: &ItemStack, items: &crate::items::Ite
/// `encode_components` from the damage value and extra components.
pub fn components_tag(damage: u32, data: Option<&StackData>) -> Option<Tag> {
let mut entries: Vec<(&str, Tag)> = Vec::new();
if damage > 0 {
// A custom max damage can make zero DAMAGE a meaningful saved component.
if damage > 0
|| data.is_some_and(|data| {
data.explicit_zero_damage && data.other("minecraft:max_damage").is_some()
})
{
entries.push(("minecraft:damage", Tag::Int(damage as i32)));
}
if let Some(d) = data {
@@ -331,11 +331,44 @@ public final class VanillaDispenserOracle {
return ent;
}
/** Native BlocksAttacks.hurtBlockingItem, with a server player and live item components. */
static void blockingWear(VanillaBlockUpdateOracle.World world, JsonObject op, List<String> result) throws Exception {
var player = (BlockingWearer) VanillaBlockUpdateOracle.allocate.invoke(VanillaBlockUpdateOracle.unsafe, BlockingWearer.class);
player.world = world;
player.creative = op.has("creative") && op.get("creative").getAsBoolean();
var advancements = (net.minecraft.server.PlayerAdvancements) VanillaBlockUpdateOracle.allocate.invoke(
VanillaBlockUpdateOracle.unsafe, net.minecraft.server.PlayerAdvancements.class);
VanillaBlockUpdateOracle.set(net.minecraft.server.PlayerAdvancements.class, advancements, "activeTriggers", new HashMap<>());
VanillaBlockUpdateOracle.set(net.minecraft.server.level.ServerPlayer.class, player, "advancements", advancements);
ItemStack item = stack(op.get("item").getAsString());
var blocking = item.get(net.minecraft.core.component.DataComponents.BLOCKS_ATTACKS);
for (int i = 0; i < 2; i++) {
if (!item.isEmpty()) {
blocking.hurtBlockingItem(world, item, player, net.minecraft.world.InteractionHand.MAIN_HAND, 4);
}
result.add("blocking wear " + item.getCount() + " " + item.getDamageValue());
}
}
static final class BlockingWearer extends net.minecraft.server.level.ServerPlayer {
ServerLevel world;
boolean creative;
BlockingWearer() { super(null, null, null, null); }
@Override public ServerLevel level() { return world; }
@Override public boolean hasInfiniteMaterials() { return creative; }
@Override public void awardStat(net.minecraft.stats.Stat<?> stat, int count) {}
@Override public void onEquippedItemBroken(ItemStack stack, EquipmentSlot slot) {}
}
static void run(JsonObject f, List<String> result) throws Exception {
VanillaBlockUpdateOracle.World w = VanillaBlockUpdateOracle.build(f);
w.entities = new ArrayList<>();
w.registries = ParityData.access;
w.server.loot.delegate = ParityData.loot;
if (f.has("blocking_wear")) {
blockingWear(w, f.getAsJsonObject("blocking_wear"), result);
return;
}
VanillaBlockUpdateOracle.set(Level.class, w, "damageSources", new net.minecraft.world.damagesource.DamageSources(ParityData.access));
if (f.has("dim") && f.get("dim").getAsString().equals("nether")) {
// The Nether's dimension type and attributes (respawn anchors work, beds explode).
+28 -3
View File
@@ -15,6 +15,8 @@ import json
import os
import random
import subprocess
import tempfile
from pathlib import Path
try:
from . import block_update_harness as h, lab, test_rust_parity as base
from .integration_env import run_game
@@ -278,6 +280,22 @@ def vehicle_cases(states, seed, only):
def cases(facts, count, seed, only):
rng = random.Random(seed)
states = States(facts)
if not only or only in 'minecraft:stick':
# A stick starts with neither durability component. Check explicit zero,
# already worn stacks, missing components, unbreakable and creative.
for patch, creative in [
('"minecraft:max_damage":2,"minecraft:damage":0', False),
('"minecraft:max_damage":2,"minecraft:damage":1', False),
('"minecraft:max_damage":2', False),
('"minecraft:damage":0', False),
('"minecraft:max_damage":2,"minecraft:damage":0,"minecraft:unbreakable":{}', False),
('"minecraft:max_damage":2,"minecraft:damage":0', True),
]:
blocking = '"minecraft:blocks_attacks":{item_damage:{threshold:0.0f,base:1.0f,factor:0.0f}}'
item = '{id:"minecraft:stick",count:1,components:{' + ','.join(
(blocking, '"minecraft:max_stack_size":1', patch)) + '}}'
yield {'seed': seed, 'pos': [0, 0, 0], 'cells': [], 'items': [],
'blocking_wear': {'item': item, 'creative': creative}}
dragon_egg = plain('ender_dragon_spawn_egg')
if not only or only in dragon_egg:
# Both the unobstructed position and a fractional collision height must
@@ -400,7 +418,7 @@ def voice(line):
def run(command, **kwargs):
runner = run_game if str(command[0]).endswith('/horizon-client') else subprocess.run
runner = run_game if Path(command[0]).stem == 'horizon-client' else subprocess.run
done = runner(command, capture_output=True, text=True, **kwargs)
if done.returncode:
print((done.stdout + done.stderr)[-4000:])
@@ -468,8 +486,10 @@ def main():
str(resources), str(work / 'cases.jsonl'), str(work / 'native.txt')])
run(['cargo', 'build', '-q', '--profile', 'fast', '-p', 'horizon-client', '--bin', 'horizon-client'], cwd=lab.ROOT,
env={**os.environ, 'CARGO_BUILD_JOBS': os.environ.get('CARGO_BUILD_JOBS', '3')})
run([str(lab.cargo_target() / 'fast/horizon-client'), '--resources', str(resources)], cwd=work,
env=lab.game_env(HORIZON_DISPENSE_FIXTURE=f'{work / "cases.jsonl"},{work / "rust.txt"}'))
executable = lab.cargo_target() / ('fast/horizon-client.exe' if os.name == 'nt' else 'fast/horizon-client')
with tempfile.TemporaryDirectory(prefix='game-', dir=work) as game_dir:
run([str(executable), '--resources', str(resources), '--game-dir', game_dir], cwd=work,
env={**os.environ, 'HORIZON_DISPENSE_FIXTURE': f'{work / "cases.jsonl"},{work / "rust.txt"}'})
# Loot table names (D) are vanilla's bookkeeping; the drops themselves are compared.
ignore = tuple(prefix + ' ' for prefix in args.ignore.split(',') if prefix) + ('D ',)
native = h.split_cases((work / 'native.txt').read_text(), ignore)
@@ -495,6 +515,9 @@ def main():
a = [voice(line) for line in a if not line.startswith('rng ')]
b = [voice(line) for line in b if not line.startswith('rng ')]
if not same(a, b):
if 'blocking_wear' in case:
groups.setdefault('blocking wear', []).append((case, a, b))
continue
item = next(i[4] for i in case['items'] if i[:3] == [0, 0, 0])
block = facts.states[case['cells'][0][3]][0].split(':')[1]
groups.setdefault(f'{block} {item.split(chr(34))[1]}', []).append((case, a, b))
@@ -515,6 +538,8 @@ def main():
def describe(case, facts):
if 'blocking_wear' in case:
return json.dumps(case['blocking_wear'])
cells = ' '.join(f'{c[0]},{c[1]},{c[2]}={facts.describe(c[3])}' for c in case['cells'])
extra = ''
if case.get('entities'):