mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Resolve enchantment-dependent component item models
Co-Authored-By: Codex GPT-6.1 <[email protected]>
This commit is contained in:
1 parent
3c68c05d0a
commit
c6c043e8b5
4 files changed
+238
-15
No files matched your search
@@ -16,6 +16,7 @@ use std::{
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
mod component_predicate;
|
||||
mod special;
|
||||
pub use special::{SpecialKind, SpecialModel};
|
||||
|
||||
@@ -628,7 +629,7 @@ pub enum ConditionProperty {
|
||||
ExtendedView,
|
||||
KeybindDown(String),
|
||||
ViewEntity,
|
||||
/// A `component` predicate, evaluated by the host.
|
||||
/// A single `component` predicate; external predicate types use the host.
|
||||
Component(Value),
|
||||
}
|
||||
impl ConditionProperty {
|
||||
@@ -651,13 +652,7 @@ impl ConditionProperty {
|
||||
"minecraft:extended_view" => Self::ExtendedView,
|
||||
"minecraft:keybind_down" => Self::KeybindDown(string(required(m, "keybind")?)?),
|
||||
"minecraft:view_entity" => Self::ViewEntity,
|
||||
"minecraft:component" => {
|
||||
let predicate = required(m, "predicate")?;
|
||||
if object(predicate)?.len() != 1 {
|
||||
return Err(err("a single component predicate is expected"));
|
||||
}
|
||||
Self::Component(predicate.clone())
|
||||
}
|
||||
"minecraft:component" => Self::Component(component_predicate::decode(m)?),
|
||||
_ => return Err(err("unknown condition property")),
|
||||
})
|
||||
}
|
||||
@@ -964,10 +959,14 @@ pub trait ItemEnv {
|
||||
fn bundle_fullness(&self, _stack: &StackView, _components: &Components) -> f32 {
|
||||
0.0
|
||||
}
|
||||
/// `ItemStack.getMaxStackSize` when the prototype has no component.
|
||||
/// Additional component predicate types supplied by the host.
|
||||
fn component_matches(&self, _stack: &StackView, _predicate: &Value) -> bool {
|
||||
false
|
||||
}
|
||||
/// Membership in the level's enchantment registry tags.
|
||||
fn enchantment_in_tag(&self, _enchantment: &str, _tag: &str) -> bool {
|
||||
false
|
||||
}
|
||||
fn is_keybind_down(&self, _keybind: &str) -> bool {
|
||||
false
|
||||
}
|
||||
@@ -1335,7 +1334,8 @@ impl Resolve<'_> {
|
||||
ConditionProperty::KeybindDown(key) => self.env.is_keybind_down(key),
|
||||
ConditionProperty::ViewEntity => owner.is_some_and(|o| o.is_view_entity),
|
||||
ConditionProperty::Component(predicate) => {
|
||||
self.env.component_matches(self.stack, predicate)
|
||||
component_predicate::matches(predicate, self.components, self.env)
|
||||
.unwrap_or_else(|| self.env.component_matches(self.stack, predicate))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
//! Component conditions used by enchantment-dependent item models.
|
||||
use super::*;
|
||||
|
||||
/// `DataComponentPredicate.singleCodec`: a single predicate's type discriminator
|
||||
/// (`predicate`) and its wrapped codec's separate `value` field.
|
||||
pub(super) fn decode(m: &Map<String, Value>) -> Result<Value> {
|
||||
let id = component_type(required(m, "predicate")?)?;
|
||||
let mut value = required(m, "value")?.clone();
|
||||
if matches!(
|
||||
id.as_str(),
|
||||
"minecraft:enchantments" | "minecraft:stored_enchantments"
|
||||
) {
|
||||
let mut entries = Vec::new();
|
||||
for entry in list(&value)? {
|
||||
let mut entry = object(entry)?.clone();
|
||||
if let Some(selector) = get(&entry, "enchantments") {
|
||||
let selector = match selector {
|
||||
Value::Array(ids) => Value::Array(
|
||||
ids.iter()
|
||||
.map(ident)
|
||||
.map(|r| r.map(Value::String))
|
||||
.collect::<Result<_>>()?,
|
||||
),
|
||||
Value::String(name) if name.starts_with('#') => {
|
||||
Value::String(format!("#{}", identifier(&name[1..])?))
|
||||
}
|
||||
_ => Value::String(ident(selector)?),
|
||||
};
|
||||
entry.insert("enchantments".into(), selector);
|
||||
}
|
||||
if let Some(levels) = get(&entry, "levels") {
|
||||
let levels = if levels.is_object() {
|
||||
let mut bounds = Map::new();
|
||||
for key in ["min", "max"] {
|
||||
if let Some(n) = get(object(levels)?, key) {
|
||||
bounds.insert(key.into(), Value::from(int(n)?));
|
||||
}
|
||||
}
|
||||
// MinMaxBounds.Bounds.validateSwappedBoundsInCodec.
|
||||
if bounds
|
||||
.get("min")
|
||||
.and_then(Value::as_i64)
|
||||
.zip(bounds.get("max").and_then(Value::as_i64))
|
||||
.is_some_and(|(min, max)| min > max)
|
||||
{
|
||||
return Err(err("minimum exceeds maximum"));
|
||||
}
|
||||
Value::Object(bounds)
|
||||
} else {
|
||||
Value::from(int(levels)?)
|
||||
};
|
||||
entry.insert("levels".into(), levels);
|
||||
}
|
||||
entries.push(Value::Object(entry));
|
||||
}
|
||||
value = Value::Array(entries);
|
||||
}
|
||||
Ok(Value::Object(Map::from_iter([(id, value)])))
|
||||
}
|
||||
|
||||
/// `EnchantmentsPredicate.matches` and `EnchantmentPredicate.containedIn`:
|
||||
/// all entries must match; each entry needs one selected, present enchantment
|
||||
/// with a matching level. An empty predicate list still needs the component.
|
||||
pub(super) fn matches(
|
||||
predicate: &Value,
|
||||
components: &Components,
|
||||
env: &dyn ItemEnv,
|
||||
) -> Option<bool> {
|
||||
let (id, expected) = predicate.as_object()?.iter().next()?;
|
||||
if !matches!(
|
||||
id.as_str(),
|
||||
"minecraft:enchantments" | "minecraft:stored_enchantments"
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let Some(have) = components.get(id).and_then(Value::as_object) else {
|
||||
return Some(false);
|
||||
};
|
||||
Some(expected.as_array()?.iter().all(|entry| {
|
||||
let Some(entry) = entry.as_object() else {
|
||||
return false;
|
||||
};
|
||||
have.iter().any(|(id, level)| {
|
||||
let level = level.as_i64().unwrap_or(0);
|
||||
let selector = get(entry, "enchantments");
|
||||
let selected = match selector {
|
||||
None => true,
|
||||
Some(Value::String(s)) => s
|
||||
.strip_prefix('#')
|
||||
.map_or(s == id, |tag| env.enchantment_in_tag(id, tag)),
|
||||
Some(Value::Array(ids)) => ids.iter().any(|s| s.as_str() == Some(id)),
|
||||
_ => false,
|
||||
};
|
||||
selected
|
||||
&& if selector.is_some() {
|
||||
matches_enchantment(level, get(entry, "levels"))
|
||||
} else {
|
||||
level_matches(level, get(entry, "levels"))
|
||||
}
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
/// `EnchantmentPredicate.matchesEnchantment`: a named enchantment needs a
|
||||
/// nonzero level even when its bounds accept zero.
|
||||
fn matches_enchantment(level: i64, bounds: Option<&Value>) -> bool {
|
||||
level != 0 && level_matches(level, bounds)
|
||||
}
|
||||
|
||||
/// `MinMaxBounds.Ints.matches`: inclusive bounds, or an exact integer.
|
||||
fn level_matches(level: i64, bounds: Option<&Value>) -> bool {
|
||||
match bounds {
|
||||
None => true,
|
||||
Some(Value::Number(n)) => n.as_i64() == Some(level),
|
||||
Some(Value::Object(bounds)) => {
|
||||
get(bounds, "min")
|
||||
.and_then(Value::as_i64)
|
||||
.is_none_or(|min| level >= min)
|
||||
&& get(bounds, "max")
|
||||
.and_then(Value::as_i64)
|
||||
.is_none_or(|max| level <= max)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
struct Tags;
|
||||
impl ItemEnv for Tags {
|
||||
fn enchantment_in_tag(&self, enchantment: &str, tag: &str) -> bool {
|
||||
enchantment == "minecraft:density" && tag == "horizon:mace"
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unnamed_predicates_inspect_entries_but_named_ones_require_a_nonzero_level() {
|
||||
let components = Components::from_iter([(
|
||||
"minecraft:enchantments".into(),
|
||||
json!({"minecraft:density":0}),
|
||||
)]);
|
||||
assert_eq!(
|
||||
matches(&json!({"minecraft:enchantments":[{}]}), &components, &NoEnv),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
matches(
|
||||
&json!({"minecraft:enchantments":[{"enchantments":"minecraft:density"}]}),
|
||||
&components,
|
||||
&NoEnv
|
||||
),
|
||||
Some(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enchantment_selectors_use_the_hosts_registry_tags() {
|
||||
let condition = json!({"predicate":"enchantments", "value":[
|
||||
{"enchantments":"#horizon:mace", "levels":{"min":3}}
|
||||
]});
|
||||
let predicate = decode(condition.as_object().unwrap()).unwrap();
|
||||
let mut components = Components::from_iter([(
|
||||
"minecraft:enchantments".into(),
|
||||
json!({"minecraft:density":3}),
|
||||
)]);
|
||||
assert_eq!(matches(&predicate, &components, &Tags), Some(true));
|
||||
assert_eq!(matches(&predicate, &components, &NoEnv), Some(false));
|
||||
components.insert(
|
||||
"minecraft:enchantments".into(),
|
||||
json!({"minecraft:density":2}),
|
||||
);
|
||||
assert_eq!(matches(&predicate, &components, &Tags), Some(false));
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@ pub(super) struct Env<'a, 'g> {
|
||||
game: &'a Game<'g>,
|
||||
}
|
||||
impl ItemEnv for Env<'_, '_> {
|
||||
fn enchantment_in_tag(&self, enchantment: &str, tag: &str) -> bool {
|
||||
self.game.stations().enchantments.in_tag(tag, enchantment)
|
||||
}
|
||||
fn potion_effect_color(&self, contents: &Value) -> Option<i32> {
|
||||
// `PotionContents.getColorOr`: the effect mix when any effect is visible.
|
||||
let id = contents["potion"].as_str();
|
||||
|
||||
@@ -3,14 +3,16 @@
|
||||
|
||||
The vanilla side (VanillaItemModelOracle) decodes every `items/*.json` with the
|
||||
client codec, discovers and bakes all models with `ModelBakery` against real
|
||||
PNG sprites, and resolves stacks through `ItemStackRenderState` layers. The
|
||||
Rust side (`item_model_fixture`) must pick the same layers, tints, foil,
|
||||
PNG sprites, and resolves stacks through `ItemStackRenderState` layers.
|
||||
Pass `--component-conditions` to run only the synthetic component-condition corpus.
|
||||
The Rust side (`item_model_fixture`) must pick the same layers, tints, foil,
|
||||
transformations, special-renderer configurations and baked quads."""
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import random
|
||||
import subprocess
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
try:
|
||||
@@ -246,6 +248,38 @@ def definitions():
|
||||
return rows
|
||||
|
||||
|
||||
def component_definitions():
|
||||
"""Original fixtures for ComponentMatches' discriminator and enchantment rules."""
|
||||
leaf = lambda name: {'type': 'minecraft:model', 'model': 'minecraft:item/' + name}
|
||||
def condition(value, predicate='minecraft:enchantments'):
|
||||
return {'type': 'minecraft:condition', 'property': 'minecraft:component',
|
||||
'predicate': predicate, 'value': value,
|
||||
'on_true': leaf('diamond'), 'on_false': leaf('stick')}
|
||||
values = [[], [{}], [{'enchantments': 'minecraft:density'}],
|
||||
[{'enchantments': 'density', 'levels': {'min': 3}}],
|
||||
[{'enchantments': ['minecraft:density', 'minecraft:breach'], 'levels': {'max': 4}}],
|
||||
[{'enchantments': [], 'levels': {'min': 0}}],
|
||||
[{'enchantments': 'minecraft:wind_burst', 'levels': 2}],
|
||||
[{'levels': {'min': 2, 'max': 4}}], [{'levels': {'max': 0}}],
|
||||
[{'levels': {}}],
|
||||
[{'enchantments': 'minecraft:breach'}, {'enchantments': 'minecraft:wind_burst'}]]
|
||||
rows = {f'horizon:component_enchant_{i}': {'model': condition(value)} for i, value in enumerate(values)}
|
||||
rows['horizon:component_stored'] = {'model': condition([{'enchantments': 'minecraft:density'}],
|
||||
'minecraft:stored_enchantments')}
|
||||
rows['horizon:component_composite'] = {'model': {'type': 'minecraft:composite', 'models': [
|
||||
condition([{'enchantments': 'minecraft:density'}]),
|
||||
dict(condition([{'enchantments': 'minecraft:wind_burst'}]), on_false={'type': 'minecraft:empty'})]}}
|
||||
rows['horizon:component_nested'] = {'model': dict(condition([{'enchantments': 'minecraft:density'}]),
|
||||
on_false=condition([{'enchantments': 'minecraft:breach'}]))}
|
||||
rows['horizon:component_missing_value'] = {'model': condition([])}
|
||||
del rows['horizon:component_missing_value']['model']['value']
|
||||
rows['horizon:component_old_object'] = {'model': dict(condition([]), predicate={'minecraft:enchantments': []})}
|
||||
rows['horizon:component_bad_list'] = {'model': condition({})}
|
||||
rows['horizon:component_bad_levels'] = {'model': condition([{'levels': 'bad'}])}
|
||||
rows['horizon:component_reversed_levels'] = {'model': condition([{'levels': {'min': 5, 'max': 2}}])}
|
||||
return {name: json.dumps(row) for name, row in rows.items()}
|
||||
|
||||
|
||||
def definition_cases(rows):
|
||||
cases = []
|
||||
stacks = [('minecraft:diamond_sword', {'minecraft:damage': 100}, 1), ('minecraft:diamond_sword', {'minecraft:damage': 1560}, 1),
|
||||
@@ -260,6 +294,13 @@ def definition_cases(rows):
|
||||
('minecraft:bundle', {'minecraft:bundle_contents': []}, 1),
|
||||
('minecraft:stone', {'minecraft:enchantments': {'minecraft:sharpness': 1}}, 1),
|
||||
('minecraft:stone', {'minecraft:custom_name': '"x"'}, 1)]
|
||||
stacks += [
|
||||
('minecraft:mace', {}, 1),
|
||||
('minecraft:mace', {'minecraft:enchantments': {'minecraft:density': 5}}, 1),
|
||||
('minecraft:mace', {'minecraft:enchantments': {'minecraft:breach': 4, 'minecraft:wind_burst': 2}}, 1),
|
||||
('minecraft:enchanted_book', {'minecraft:stored_enchantments': {'minecraft:density': 3}}, 1),
|
||||
('minecraft:mace', {'!minecraft:enchantments': {}}, 1),
|
||||
('minecraft:mace', {'minecraft:enchantments': {'minecraft:density': 0}}, 1)]
|
||||
for name in rows:
|
||||
for stack_index, (item, components, count) in enumerate(stacks):
|
||||
for context in ('gui', 'head', 'firstperson_lefthand', 'ground'):
|
||||
@@ -314,8 +355,10 @@ def main():
|
||||
_, directory = lab.config()
|
||||
prototypes_path = lab.LOCAL / 'horizon-client' / lock['minecraft']['version'] / 'items.json'
|
||||
prototypes = json.loads(prototypes_path.read_text())
|
||||
cases = corpus(prototypes)
|
||||
rows = definitions()
|
||||
focused = '--component-conditions' in sys.argv
|
||||
cases = [] if focused else corpus(prototypes)
|
||||
rows = {} if focused else definitions()
|
||||
rows.update(component_definitions())
|
||||
cases += definition_cases(rows)
|
||||
inputs = build / 'cases.jsonl'; inputs.write_text(''.join(json.dumps(c) + '\n' for c in cases))
|
||||
(build / 'cases.jsonl.definitions').write_text(''.join(json.dumps({'name': n, 'json': j}) + '\n' for n, j in rows.items()))
|
||||
@@ -407,7 +450,7 @@ def main():
|
||||
'rustc': subprocess.check_output(['rustc', '--version'], text=True).strip(),
|
||||
'cases': len(cases), 'compared': compared, 'rejected_by_vanilla': skipped, 'definitions_decoded': decoded,
|
||||
'definitions_unbaked': unbaked, 'sources_sha256': tested,
|
||||
'scope': 'Actual ClientItem codec, ModelDiscovery, ModelBakery item baking and ItemModelResolver semantics against real sprites in a unit atlas. Every bundled items/*.json and synthetic definitions; every item prototype in every display context with representative components. Layer content, tints, foil, composed transformation matrices (numeric equality), special-renderer codec forms and arguments, baked quads by float bits, render properties. Not covered: GPU rendering, atlas packing, level-dependent needles (see the needle tests), player/keybind/team-based properties that need a live client.'}
|
||||
'scope': ('Synthetic component-condition corpus only. ' if focused else '') + 'Actual ClientItem codec, ModelDiscovery, ModelBakery item baking and ItemModelResolver semantics against real sprites in a unit atlas. Every bundled items/*.json is decoded. Full mode resolves every item prototype in every display context; focused mode resolves synthetic component conditions on representative stacks. Layer content, tints, foil, composed transformation matrices (numeric equality), special-renderer codec forms and arguments, baked quads by float bits, render properties. Not covered: GPU rendering, atlas packing, level-dependent needles (see the needle tests), player/keybind/team-based properties that need a live client.'}
|
||||
lab.write_json(report_path, report)
|
||||
print(f'Item models: {compared:,} resolved stacks match ({skipped:,} rejected by vanilla, {decoded:,} definitions); {report_path}', flush=True)
|
||||
|
||||
|
||||
Reference in new issue
Block a user