From 1cfa7e83d11584445139cac1f3a7753e43cd28ff Mon Sep 17 00:00:00 2001 From: Xander Date: Thu, 30 Jul 2026 15:15:14 +0100 Subject: [PATCH 1/2] dualsense trigger predicates --- docs/developers/adaptive-trigger-api.mdx | 16 +- .../adaptive-trigger-effects.mdx | 73 ++++- .../dev/isxander/controlify/Controlify.java | 3 +- .../dualsense/BuiltinTriggerEffects.java | 27 -- .../dualsense/TriggerEffectRegistry.java | 278 ++++++++++++++++-- .../controlify/trigger_effect/swing_item.json | 4 +- .../controlify/trigger_effect/use_item.json | 49 ++- 7 files changed, 364 insertions(+), 86 deletions(-) delete mode 100644 src/main/java/dev/isxander/controlify/controller/dualsense/BuiltinTriggerEffects.java diff --git a/docs/developers/adaptive-trigger-api.mdx b/docs/developers/adaptive-trigger-api.mdx index d66d3687e..eb0f48111 100644 --- a/docs/developers/adaptive-trigger-api.mdx +++ b/docs/developers/adaptive-trigger-api.mdx @@ -2,8 +2,9 @@ title: Adaptive Trigger API --- -Resource packs should be used for fixed adaptive trigger effects. The Java API is available for effects -that depend on the value stored in an item's data component. +Resource packs should be used for adaptive trigger effects based on item identities, tags, or serialized +data-component values. The Java API is available when an effect needs custom code or cannot be expressed +with resource-pack matching. Register effects during Controlify pre-initialization: @@ -13,13 +14,10 @@ public final class MyControlifyEntrypoint implements ControlifyEntrypoint { public void onControlifyPreInit(PreInitContext context) { TriggerEffectApi.registerUseItemEffect( DataComponents.CHARGED_PROJECTILES, - projectiles -> projectiles.isEmpty() - ? new DualsenseTriggerEffect.FeedbackSlope( - (byte) 2, (byte) 9, (byte) 5, (byte) 8 - ) - : new DualsenseTriggerEffect.Weapon( - (byte) 3, (byte) 5, (byte) 1 - ) + projectiles -> new DualsenseTriggerEffect.Feedback( + (byte) 3, + (byte) Math.min(8, projectiles.items().size() + 1) + ) ); } } diff --git a/docs/resource-packs/adaptive-trigger-effects.mdx b/docs/resource-packs/adaptive-trigger-effects.mdx index 4619a061a..fc23fbba7 100644 --- a/docs/resource-packs/adaptive-trigger-effects.mdx +++ b/docs/resource-packs/adaptive-trigger-effects.mdx @@ -12,13 +12,15 @@ Create either or both of these files in your resource pack: - `assets/controlify/trigger_effect/use_item.json` - `assets/controlify/trigger_effect/swing_item.json` -Each file contains an array of rules. A rule selects either one item with `forItem` or one data component -with `forComponent`, then defines the effect to apply. Exactly one selector must be present. +Each file contains an array of rules. Every rule has a `when` selector and the `effect` to apply. A selector +can test items, data components, or both. When both are present, both must match. ```json [ { - "forItem": "minecraft:bow", + "when": { + "items": "minecraft:bow" + }, "effect": { "type": "feedback_slope", "start_position": 3, @@ -28,7 +30,10 @@ with `forComponent`, then defines the effect to apply. Exactly one selector must } }, { - "forComponent": "minecraft:consumable", + "when": { + "items": ["minecraft:apple", "#minecraft:fox_food"], + "components": ["minecraft:consumable"] + }, "effect": { "type": "feedback", "position": 3, @@ -38,15 +43,60 @@ with `forComponent`, then defines the effect to apply. Exactly one selector must ] ``` -Item rules match that exact item. Component rules match any item stack containing that data component. +`items` accepts one item selector or an array of selectors. Plain identifiers select an exact item, while +identifiers prefixed with `#` select an item tag. An item matches when it is selected by any entry. + +`components` can be an array of component identifiers. This form requires every listed component to be +present, without inspecting its value: + +```json +{ + "when": { + "components": ["minecraft:weapon", "minecraft:damage"] + }, + "effect": { + "type": "weapon", + "start_position": 3, + "end_position": 5, + "strength": 1 + } +} +``` + +To match component contents, use an object whose keys are component identifiers and whose values are NBT +patterns: + +```json +{ + "when": { + "items": "minecraft:crossbow", + "components": { + "minecraft:charged_projectiles": [ + { "id": "minecraft:firework_rocket" } + ] + } + }, + "effect": { + "type": "weapon", + "start_position": 3, + "end_position": 5, + "strength": 4 + } +} +``` + +The component value is serialized through its data-component codec before matching. Objects are recursive +subsets: fields omitted from the pattern may have any value. Lists are unordered subsets: every pattern +element must match an element in the component value. Scalar values match exactly, and an empty list matches +only an empty list. + Use-item rules check the player's active item and then their offhand item. Swing-item rules check the main-hand item. ## Rule priority and stacking The files are additive across resource packs. Rules in the highest-priority pack are checked first, followed -by lower-priority packs. Within each file, rules are checked from top to bottom. The first matching rule wins, -whether it uses `forItem` or `forComponent`. +by lower-priority packs. Within each file, rules are checked from top to bottom. The first matching rule wins. To remove an effect supplied by Controlify or a lower-priority pack, add a higher-priority rule with an `off` effect: @@ -54,7 +104,9 @@ To remove an effect supplied by Controlify or a lower-priority pack, add a highe ```json [ { - "forItem": "minecraft:bow", + "when": { + "items": "minecraft:bow" + }, "effect": { "type": "off" } @@ -65,8 +117,9 @@ To remove an effect supplied by Controlify or a lower-priority pack, add a highe A matching `off` rule stops evaluation, so rules from lower-priority packs and effects registered by mods will not be used. -If a file has invalid JSON, an unknown item or component, an invalid effect, or a rule with both or neither -selector, Controlify logs the error and skips that entire file layer. Other resource packs continue to work. +If a file has invalid JSON, an unknown item or component, a transient component in a value matcher, an +invalid effect, or a rule without any conditions, Controlify logs the error and skips that entire file layer. +Other resource packs continue to work. ## Effect formats diff --git a/src/main/java/dev/isxander/controlify/Controlify.java b/src/main/java/dev/isxander/controlify/Controlify.java index 0e4b64e70..74c963546 100644 --- a/src/main/java/dev/isxander/controlify/Controlify.java +++ b/src/main/java/dev/isxander/controlify/Controlify.java @@ -22,7 +22,6 @@ import dev.isxander.controlify.config.settings.device.DeviceSettings; import dev.isxander.controlify.config.settings.profile.ProfileSettings; import dev.isxander.controlify.controller.*; -import dev.isxander.controlify.controller.dualsense.BuiltinTriggerEffects; import dev.isxander.controlify.controller.dualsense.TriggerEffectManager; import dev.isxander.controlify.controller.dualsense.TriggerEffectRegistry; import dev.isxander.controlify.controller.id.ControllerTypeManager; @@ -145,7 +144,6 @@ public void preInitialiseControlify() { this.controllerTypeManager = new ControllerTypeManager(); this.keyboardLayoutManager = new KeyboardLayoutManager(); this.triggerEffectRegistry = new TriggerEffectRegistry(); - BuiltinTriggerEffects.register(); PlatformClientUtil.registerAssetReloadListener(inputFontMapper); PlatformClientUtil.registerAssetReloadListener(defaultBindManager); PlatformClientUtil.registerAssetReloadListener(defaultConfigManager); @@ -192,6 +190,7 @@ public void preInitialiseControlify() { PlatformClientUtil.registerClientDisconnected((client) -> { DebugLog.log("Disconnected from server, resetting server policies"); ServerPolicies.unsetAll(); + triggerEffectRegistry.invalidateResourceRuleCaches(); }); PlatformClientUtil.addHudLayer(CUtil.rl("button_guide"), (graphics, deltaTracker) -> diff --git a/src/main/java/dev/isxander/controlify/controller/dualsense/BuiltinTriggerEffects.java b/src/main/java/dev/isxander/controlify/controller/dualsense/BuiltinTriggerEffects.java deleted file mode 100644 index 10b7593a1..000000000 --- a/src/main/java/dev/isxander/controlify/controller/dualsense/BuiltinTriggerEffects.java +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.controller.dualsense; - -import dev.isxander.controlify.api.triggereffect.TriggerEffectApi; -import dev.isxander.controlify.driver.dualsense.DualsenseTriggerEffect; -import net.minecraft.core.component.DataComponents; - -public final class BuiltinTriggerEffects { - private BuiltinTriggerEffects() { - } - - public static void register() { - var quickClick = new DualsenseTriggerEffect.Weapon((byte) 3, (byte) 5, (byte) 1); - - TriggerEffectApi.registerUseItemEffect( - DataComponents.CHARGED_PROJECTILES, - chargedProjectiles -> chargedProjectiles.isEmpty() - ? new DualsenseTriggerEffect.FeedbackSlope((byte) 2, (byte) 9, (byte) 5, (byte) 8) - : quickClick - ); - } -} diff --git a/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectRegistry.java b/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectRegistry.java index d22d2fc37..c6c7d66ca 100644 --- a/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectRegistry.java +++ b/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectRegistry.java @@ -8,19 +8,30 @@ import com.google.gson.JsonElement; import com.google.gson.JsonParser; +import com.mojang.datafixers.util.Either; import com.mojang.logging.LogUtils; import com.mojang.serialization.Codec; import com.mojang.serialization.DataResult; +import com.mojang.serialization.Dynamic; +import com.mojang.serialization.DynamicOps; import com.mojang.serialization.JsonOps; import com.mojang.serialization.codecs.RecordCodecBuilder; import dev.isxander.controlify.driver.dualsense.DualsenseTriggerEffect; import dev.isxander.controlify.platform.client.resource.SimpleControlifyReloadListener; import dev.isxander.controlify.utils.CUtil; +import net.minecraft.client.Minecraft; +import net.minecraft.core.HolderLookup; +import net.minecraft.core.RegistryAccess; import net.minecraft.core.component.DataComponentType; import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.nbt.NbtOps; +import net.minecraft.nbt.NbtUtils; +import net.minecraft.nbt.Tag; import net.minecraft.resources.Identifier; import net.minecraft.server.packs.resources.Resource; import net.minecraft.server.packs.resources.ResourceManager; +import net.minecraft.tags.TagKey; import net.minecraft.world.item.Item; import net.minecraft.world.item.ItemStack; import org.jetbrains.annotations.Nullable; @@ -28,10 +39,12 @@ import java.io.BufferedReader; import java.util.ArrayList; +import java.util.IdentityHashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.WeakHashMap; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; import java.util.function.Function; @@ -43,20 +56,51 @@ public class TriggerEffectRegistry implements SimpleControlifyReloadListener SERIALIZED_RULE_CODEC = RecordCodecBuilder.create(instance -> instance.group( - Identifier.CODEC.optionalFieldOf("forItem").forGetter(SerializedRule::forItem), - Identifier.CODEC.optionalFieldOf("forComponent").forGetter(SerializedRule::forComponent), - TriggerEffectCodecs.CODEC.fieldOf("effect").forGetter(SerializedRule::effect) - ).apply(instance, SerializedRule::new)).comapFlatMap( - rule -> rule.forItem().isPresent() == rule.forComponent().isPresent() - ? DataResult.error(() -> "Exactly one of 'forItem' and 'forComponent' must be specified") - : DataResult.success(rule), - Function.identity() + private static final Codec> ITEM_SELECTORS_CODEC = Codec.either( + Codec.STRING, + Codec.STRING.listOf(1, Integer.MAX_VALUE) + ).xmap( + either -> either.map(List::of, Function.identity()), + Either::right ); + private static final Codec NBT_PATTERN_CODEC = Codec.PASSTHROUGH.xmap( + dynamic -> dynamic.convert(NbtOps.INSTANCE).getValue(), + tag -> new Dynamic<>(NbtOps.INSTANCE, tag) + ); + + private static final Codec> COMPONENT_MATCHERS_CODEC = Codec + .unboundedMap(Identifier.CODEC, NBT_PATTERN_CODEC) + .validate(matchers -> matchers.isEmpty() + ? DataResult.error(() -> "At least one component matcher must be specified") + : DataResult.success(matchers)); + + private static final Codec, Map>> COMPONENTS_CODEC = Codec.either( + Identifier.CODEC.listOf(1, Integer.MAX_VALUE), + COMPONENT_MATCHERS_CODEC + ); + + private static final Codec SERIALIZED_SELECTOR_CODEC = + RecordCodecBuilder.create(instance -> instance.group( + ITEM_SELECTORS_CODEC.optionalFieldOf("items", List.of()).forGetter(SerializedSelector::items), + COMPONENTS_CODEC.optionalFieldOf("components").forGetter(SerializedSelector::components) + ).apply(instance, SerializedSelector::new)) + .validate(selector -> selector.items().isEmpty() && selector.components().isEmpty() + ? DataResult.error(() -> "At least one item or component condition must be specified") + : DataResult.success(selector)); + + private static final Codec SERIALIZED_RULE_CODEC = + RecordCodecBuilder.create(instance -> instance.group( + SERIALIZED_SELECTOR_CODEC.fieldOf("when").forGetter(SerializedRule::when), + TriggerEffectCodecs.CODEC.fieldOf("effect").forGetter(SerializedRule::effect) + ).apply(instance, SerializedRule::new)); + private List useItemResourceRules = List.of(); private List swingItemResourceRules = List.of(); + private final Map useItemResourceCache = new WeakHashMap<>(); + private final Map swingItemResourceCache = new WeakHashMap<>(); + private final Map, Function> useItemComponentEffects = new LinkedHashMap<>(); private final Map, Function> swingItemComponentEffects = new LinkedHashMap<>(); private final Map useItemEffects = new LinkedHashMap<>(); @@ -99,21 +143,58 @@ private List loadResourceStack(ResourceManager manager, Identifier id) { } private Rule resolveRule(SerializedRule rule) { - if (rule.forItem().isPresent()) { - Identifier itemId = rule.forItem().orElseThrow(); + List itemConditions = new ArrayList<>(rule.when().items().size()); + for (String itemSelector : rule.when().items()) { + boolean tag = itemSelector.startsWith("#"); + String idString = tag ? itemSelector.substring(1) : itemSelector; + Identifier itemId = Identifier.tryParse(idString); + if (itemId == null) { + throw new IllegalArgumentException("Invalid item selector '" + itemSelector + "'"); + } + + if (tag) { + itemConditions.add(new TagItemCondition(TagKey.create(Registries.ITEM, itemId))); + continue; + } + if (!BuiltInRegistries.ITEM.containsKey(itemId)) { throw new IllegalArgumentException("Unknown item '" + itemId + "'"); } Item item = BuiltInRegistries.ITEM.getValue(itemId); - return new ItemRule(item, rule.effect()); + itemConditions.add(new ExactItemCondition(item)); } - Identifier componentId = rule.forComponent().orElseThrow(); + List componentConditions = rule.when().components() + .map(components -> components.map( + ids -> ids.stream().map(this::resolvePresenceComponent).toList(), + matchers -> matchers.entrySet().stream().map(this::resolveMatchingComponent).toList() + )) + .orElseGet(List::of); + + return new Rule( + List.copyOf(itemConditions), + componentConditions, + rule.effect() + ); + } + + private ComponentCondition resolvePresenceComponent(Identifier componentId) { + return new PresenceComponentCondition(this.resolveComponentType(componentId)); + } + + private ComponentCondition resolveMatchingComponent(Map.Entry entry) { + DataComponentType componentType = this.resolveComponentType(entry.getKey()); + if (componentType.isTransient()) { + throw new IllegalArgumentException("Data component type '" + entry.getKey() + "' is not persistent"); + } + return new MatchingComponentCondition(componentType, entry.getValue()); + } + + private DataComponentType resolveComponentType(Identifier componentId) { if (!BuiltInRegistries.DATA_COMPONENT_TYPE.containsKey(componentId)) { throw new IllegalArgumentException("Unknown data component type '" + componentId + "'"); } - DataComponentType componentType = BuiltInRegistries.DATA_COMPONENT_TYPE.getValue(componentId); - return new ComponentRule(componentType, rule.effect()); + return BuiltInRegistries.DATA_COMPONENT_TYPE.getValue(componentId); } @Override @@ -121,6 +202,7 @@ public CompletableFuture apply(Preparations data, ResourceManager manager, return CompletableFuture.runAsync(() -> { this.useItemResourceRules = data.useItemRules; this.swingItemResourceRules = data.swingItemRules; + this.invalidateResourceRuleCaches(); LOGGER.info( "Loaded {} use-item and {} swing-item adaptive trigger effect rules", this.useItemResourceRules.size(), @@ -155,6 +237,7 @@ public Optional getUseItemEffect(ItemStack stack) { return findEffect( stack, this.useItemResourceRules, + this.useItemResourceCache, this.useItemComponentEffects, this.useItemEffects ); @@ -164,21 +247,22 @@ public Optional getSwingItemEffect(ItemStack stack) { return findEffect( stack, this.swingItemResourceRules, + this.swingItemResourceCache, this.swingItemComponentEffects, this.swingItemEffects ); } - private static Optional findEffect( + private Optional findEffect( ItemStack stack, List resourceRules, + Map resourceCache, Map, Function> componentEffects, Map itemEffects ) { - for (Rule rule : resourceRules) { - if (rule.matches(stack)) { - return Optional.of(rule.effect()); - } + Optional resourceEffect = this.findResourceEffect(stack, resourceRules, resourceCache); + if (resourceEffect.isPresent()) { + return resourceEffect; } for (var entry : componentEffects.entrySet()) { @@ -193,6 +277,40 @@ private static Optional findEffect( return Optional.ofNullable(itemEffects.get(stack.getItem())); } + private Optional findResourceEffect( + ItemStack stack, + List rules, + Map cache + ) { + CachedResourceMatches cached = cache.get(stack); + if (cached == null || !ItemStack.isSameItemSameComponents(stack, cached.snapshot())) { + cached = new CachedResourceMatches( + stack.copy(), + new IdentityHashMap<>(), + new IdentityHashMap<>() + ); + cache.put(stack, cached); + } + + MatchContext context = new MatchContext(stack, cached); + return rules.stream() + .filter(rule -> rule.matches(context)) + .map(Rule::effect) + .findFirst(); + } + + private static HolderLookup.Provider registryAccess() { + var connection = Minecraft.getInstance().getConnection(); + return connection != null + ? connection.registryAccess() + : RegistryAccess.fromRegistryOfRegistries(BuiltInRegistries.REGISTRY); + } + + public void invalidateResourceRuleCaches() { + this.useItemResourceCache.clear(); + this.swingItemResourceCache.clear(); + } + @SuppressWarnings("unchecked") private static Function castEffectFunction( Function effectFunction @@ -205,34 +323,132 @@ public Identifier getReloadId() { return RELOAD_ID; } + private record SerializedSelector( + List items, + Optional, Map>> components + ) { + } + private record SerializedRule( - Optional forItem, - Optional forComponent, + SerializedSelector when, DualsenseTriggerEffect effect ) { } - private sealed interface Rule permits ItemRule, ComponentRule { - boolean matches(ItemStack stack); + private record Rule( + List itemConditions, + List componentConditions, + DualsenseTriggerEffect effect + ) { + boolean matches(MatchContext context) { + return (this.itemConditions.isEmpty() || this.itemConditions.stream().anyMatch(condition -> condition.matches(context.stack()))) + && this.componentConditions.stream().allMatch(condition -> condition.matches(context)); + } + } - DualsenseTriggerEffect effect(); + private sealed interface ItemCondition permits ExactItemCondition, TagItemCondition { + boolean matches(ItemStack stack); } - private record ItemRule(Item item, DualsenseTriggerEffect effect) implements Rule { + private record ExactItemCondition(Item item) implements ItemCondition { @Override public boolean matches(ItemStack stack) { return stack.getItem() == this.item; } } - private record ComponentRule( - DataComponentType componentType, - DualsenseTriggerEffect effect - ) implements Rule { + private record TagItemCondition(TagKey tag) implements ItemCondition { @Override public boolean matches(ItemStack stack) { - return stack.has(this.componentType); + return stack.is(this.tag); + } + } + + private sealed interface ComponentCondition permits PresenceComponentCondition, MatchingComponentCondition { + boolean matches(MatchContext context); + } + + private record PresenceComponentCondition(DataComponentType componentType) implements ComponentCondition { + @Override + public boolean matches(MatchContext context) { + return context.stack().has(this.componentType); + } + } + + private record MatchingComponentCondition( + DataComponentType componentType, + Tag pattern + ) implements ComponentCondition { + @Override + public boolean matches(MatchContext context) { + return context.matches(this); + } + } + + private static final class MatchContext { + private final ItemStack stack; + private final CachedResourceMatches cachedMatches; + @Nullable + private DynamicOps serializationContext; + + private MatchContext(ItemStack stack, CachedResourceMatches cachedMatches) { + this.stack = stack; + this.cachedMatches = cachedMatches; } + + private ItemStack stack() { + return this.stack; + } + + private boolean matches(MatchingComponentCondition condition) { + return this.cachedMatches.conditionMatches().computeIfAbsent( + condition, + key -> this.componentTag(key.componentType()) + .map(value -> NbtUtils.compareNbt(key.pattern(), value, true)) + .orElse(false) + ); + } + + private Optional componentTag(DataComponentType componentType) { + return this.cachedMatches.componentTags().computeIfAbsent(componentType, type -> { + Object value = this.stack.get(type); + if (value == null) { + return Optional.empty(); + } + + try { + return Optional.of(encodeComponent(type, value, this.serializationContext())); + } catch (Exception e) { + LOGGER.error("Failed to encode item component {} for adaptive trigger matching", type, e); + return Optional.empty(); + } + }); + } + + private DynamicOps serializationContext() { + if (this.serializationContext == null) { + this.serializationContext = registryAccess().createSerializationContext(NbtOps.INSTANCE); + } + return this.serializationContext; + } + + @SuppressWarnings("unchecked") + private static Tag encodeComponent( + DataComponentType componentType, + Object value, + DynamicOps serializationContext + ) { + return ((Codec) componentType.codecOrThrow()) + .encodeStart(serializationContext, value) + .getOrThrow(); + } + } + + private record CachedResourceMatches( + ItemStack snapshot, + Map, Optional> componentTags, + Map conditionMatches + ) { } public static final class Preparations { diff --git a/src/main/resources/assets/controlify/trigger_effect/swing_item.json b/src/main/resources/assets/controlify/trigger_effect/swing_item.json index 55d109da8..212f87ca1 100644 --- a/src/main/resources/assets/controlify/trigger_effect/swing_item.json +++ b/src/main/resources/assets/controlify/trigger_effect/swing_item.json @@ -1,6 +1,8 @@ [ { - "forComponent": "minecraft:weapon", + "when": { + "components": ["minecraft:weapon"] + }, "effect": { "type": "weapon", "start_position": 3, diff --git a/src/main/resources/assets/controlify/trigger_effect/use_item.json b/src/main/resources/assets/controlify/trigger_effect/use_item.json index 8172e5408..affd320b2 100644 --- a/src/main/resources/assets/controlify/trigger_effect/use_item.json +++ b/src/main/resources/assets/controlify/trigger_effect/use_item.json @@ -1,6 +1,8 @@ [ { - "forItem": "minecraft:bow", + "when": { + "items": "minecraft:bow" + }, "effect": { "type": "feedback_slope", "start_position": 3, @@ -10,7 +12,34 @@ } }, { - "forComponent": "minecraft:consumable", + "when": { + "components": { + "minecraft:charged_projectiles": [] + } + }, + "effect": { + "type": "feedback_slope", + "start_position": 2, + "end_position": 9, + "start_strength": 5, + "end_strength": 8 + } + }, + { + "when": { + "components": ["minecraft:charged_projectiles"] + }, + "effect": { + "type": "weapon", + "start_position": 3, + "end_position": 5, + "strength": 1 + } + }, + { + "when": { + "components": ["minecraft:consumable"] + }, "effect": { "type": "feedback", "position": 3, @@ -18,7 +47,9 @@ } }, { - "forComponent": "minecraft:blocks_attacks", + "when": { + "components": ["minecraft:blocks_attacks"] + }, "effect": { "type": "feedback", "position": 3, @@ -26,7 +57,9 @@ } }, { - "forComponent": "minecraft:equippable", + "when": { + "components": ["minecraft:equippable"] + }, "effect": { "type": "weapon", "start_position": 3, @@ -35,7 +68,9 @@ } }, { - "forComponent": "minecraft:kinetic_weapon", + "when": { + "components": ["minecraft:kinetic_weapon"] + }, "effect": { "type": "feedback", "position": 3, @@ -43,7 +78,9 @@ } }, { - "forComponent": "minecraft:instrument", + "when": { + "components": ["minecraft:instrument"] + }, "effect": { "type": "feedback", "position": 3, From 061ebce1ce1daa08dc778e59dc476cc3649f8f62 Mon Sep 17 00:00:00 2001 From: Xander Date: Thu, 30 Jul 2026 17:17:05 +0100 Subject: [PATCH 2/2] use ItemPredicate for trigger effects, supporting dynamic registries provided by servers --- .../adaptive-trigger-effects.mdx | 215 +++++++--- .../client/FabricPlatformClientImpl.java | 11 + .../dev/isxander/controlify/Controlify.java | 2 +- .../dualsense/TriggerEffectRegistry.java | 379 ++++++------------ .../platform/client/PlatformClientUtil.java | 4 + .../client/PlatformClientUtilImpl.java | 2 + .../controlify/trigger_effect/swing_item.json | 4 +- .../controlify/trigger_effect/use_item.json | 24 +- .../client/NeoforgePlatformClientImpl.java | 8 + 9 files changed, 331 insertions(+), 318 deletions(-) diff --git a/docs/resource-packs/adaptive-trigger-effects.mdx b/docs/resource-packs/adaptive-trigger-effects.mdx index fc23fbba7..dbaff364e 100644 --- a/docs/resource-packs/adaptive-trigger-effects.mdx +++ b/docs/resource-packs/adaptive-trigger-effects.mdx @@ -12,47 +12,36 @@ Create either or both of these files in your resource pack: - `assets/controlify/trigger_effect/use_item.json` - `assets/controlify/trigger_effect/swing_item.json` -Each file contains an array of rules. Every rule has a `when` selector and the `effect` to apply. A selector -can test items, data components, or both. When both are present, both must match. +Each file contains an array of rules. Every rule has a `when` item predicate and the `effect` to apply. +The `when` object uses Minecraft's standard +[item predicate format](https://minecraft.wiki/w/Template:Nbt_inherit/conditions/item/template). When +multiple conditions are present, all of them must match. + +For example, a bow can increase resistance as it is pulled: ```json -[ - { - "when": { - "items": "minecraft:bow" - }, - "effect": { - "type": "feedback_slope", - "start_position": 3, - "end_position": 9, - "start_strength": 2, - "end_strength": 8 - } +{ + "when": { + "items": "minecraft:bow" }, - { - "when": { - "items": ["minecraft:apple", "#minecraft:fox_food"], - "components": ["minecraft:consumable"] - }, - "effect": { - "type": "feedback", - "position": 3, - "strength": 1 - } + "effect": { + "type": "feedback_slope", + "start_position": 3, + "end_position": 9, + "start_strength": 2, + "end_strength": 8 } -] +} ``` -`items` accepts one item selector or an array of selectors. Plain identifiers select an exact item, while -identifiers prefixed with `#` select an item tag. An item matches when it is selected by any entry. - -`components` can be an array of component identifiers. This form requires every listed component to be -present, without inspecting its value: +Component presence can give every weapon the same trigger stop: ```json { "when": { - "components": ["minecraft:weapon", "minecraft:damage"] + "predicates": { + "minecraft:weapon": {} + } }, "effect": { "type": "weapon", @@ -63,33 +52,84 @@ present, without inspecting its value: } ``` -To match component contents, use an object whose keys are component identifiers and whose values are NBT -patterns: +Item tags and component conditions can be combined: + +```json +{ + "when": { + "items": "#minecraft:fox_food", + "predicates": { + "minecraft:consumable": {} + } + }, + "effect": { + "type": "feedback", + "position": 3, + "strength": 1 + } +} +``` + +Stack counts can select a vibration effect: + +```json +{ + "when": { + "items": "minecraft:snowball", + "count": { + "min": 8 + } + }, + "effect": { + "type": "vibration", + "position": 3, + "amplitude": 4, + "frequency": 30 + } +} +``` + +Semantic component predicates can select a multi-zone effect: + +```json +{ + "when": { + "items": "minecraft:shield", + "predicates": { + "minecraft:damage": { + "damage": { + "min": 1 + } + } + } + }, + "effect": { + "type": "feedback_multiple_position", + "strength": [0, 0, 2, 2, 3, 4, 5, 6, 7, 8] + } +} +``` + +Exact component values can distinguish an unloaded crossbow: ```json { "when": { "items": "minecraft:crossbow", "components": { - "minecraft:charged_projectiles": [ - { "id": "minecraft:firework_rocket" } - ] + "minecraft:charged_projectiles": [] } }, "effect": { - "type": "weapon", - "start_position": 3, - "end_position": 5, - "strength": 4 + "type": "feedback_slope", + "start_position": 2, + "end_position": 9, + "start_strength": 5, + "end_strength": 8 } } ``` -The component value is serialized through its data-component codec before matching. Objects are recursive -subsets: fields omitted from the pattern may have any value. Lists are unordered subsets: every pattern -element must match an element in the component value. Scalar values match exactly, and an empty list matches -only an empty list. - Use-item rules check the player's active item and then their offhand item. Swing-item rules check the main-hand item. @@ -98,6 +138,8 @@ main-hand item. The files are additive across resource packs. Rules in the highest-priority pack are checked first, followed by lower-priority packs. Within each file, rules are checked from top to bottom. The first matching rule wins. +An empty `when` object matches every item and can be used as a final catch-all rule. + To remove an effect supplied by Controlify or a lower-priority pack, add a higher-priority rule with an `off` effect: @@ -117,9 +159,86 @@ To remove an effect supplied by Controlify or a lower-priority pack, add a highe A matching `off` rule stops evaluation, so rules from lower-priority packs and effects registered by mods will not be used. -If a file has invalid JSON, an unknown item or component, a transient component in a value matcher, an -invalid effect, or a rule without any conditions, Controlify logs the error and skips that entire file layer. -Other resource packs continue to work. +If a file has invalid JSON, an unknown item, tag, component, or predicate, an invalid component or predicate +value, or an invalid effect, Controlify logs the error and skips that entire file layer. Other resource packs +continue to work. + +## Using trigger effects from a server + +A server can include trigger effect files in the resource pack it sends to players. The server itself does +not need Controlify: clients with Controlify apply the rules, while other clients ignore the additional +assets. This is useful for giving a server's custom items their own trigger effects. + +### Matching an item tag + +For example, a server datapack can define +`data/example/tags/item/heavy_weapons.json`: + +```json +{ + "values": [ + "minecraft:mace", + "minecraft:netherite_axe" + ] +} +``` + +The server resource pack can then contain +`assets/controlify/trigger_effect/swing_item.json`: + +```json +[ + { + "when": { + "items": "#example:heavy_weapons" + }, + "effect": { + "type": "weapon", + "start_position": 2, + "end_position": 8, + "strength": 6 + } + } +] +``` + +The item tag is synchronized to clients when they join and whenever the server reloads its datapacks, so the +resource-pack rule follows changes made with `/reload`. + +### Matching custom item data + +Items which share a vanilla item type can instead be distinguished by their `minecraft:custom_data` +component. For example, a server can create a custom heavy weapon with: + +```mcfunction +/give @s minecraft:carrot_on_a_stick[minecraft:custom_data={example:{trigger_effect:"heavy"}}] +``` + +Its server resource pack can match that data with the `minecraft:custom_data` component predicate: + +```json +[ + { + "when": { + "items": "minecraft:carrot_on_a_stick", + "predicates": { + "minecraft:custom_data": { + "example": { + "trigger_effect": "heavy" + } + } + } + }, + "effect": { + "type": "feedback_multiple_position", + "strength": [0, 0, 2, 3, 4, 5, 6, 7, 8, 8] + } + } +] +``` + +The custom-data predicate is a partial match. The item may contain other custom data in addition to the +fields in the rule. ## Effect formats diff --git a/src/fabric/java/dev/isxander/controlify/fabric/platform/client/FabricPlatformClientImpl.java b/src/fabric/java/dev/isxander/controlify/fabric/platform/client/FabricPlatformClientImpl.java index 8e5ff2bff..ea4fc5ead 100644 --- a/src/fabric/java/dev/isxander/controlify/fabric/platform/client/FabricPlatformClientImpl.java +++ b/src/fabric/java/dev/isxander/controlify/fabric/platform/client/FabricPlatformClientImpl.java @@ -21,11 +21,13 @@ import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; import net.fabricmc.fabric.api.client.rendering.v1.hud.HudElementRegistry; import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents; +import net.fabricmc.fabric.api.event.lifecycle.v1.CommonLifecycleEvents; import net.fabricmc.fabric.api.networking.v1.FriendlyByteBufs; import net.fabricmc.fabric.api.resource.v1.ResourceLoader; import net.fabricmc.fabric.api.resource.v1.pack.PackActivationType; import net.fabricmc.loader.api.FabricLoader; import net.minecraft.client.KeyMapping; +import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.gui.navigation.ScreenRectangle; import net.minecraft.client.gui.screens.inventory.CreativeModeInventoryScreen; @@ -64,6 +66,15 @@ public void registerClientDisconnected(DisconnectedEvent event) { }); } + @Override + public void registerClientTagsUpdated(LifecycleEvent event) { + CommonLifecycleEvents.TAGS_LOADED.register((registries, client) -> { + if (client) { + event.onLifecycle(Minecraft.getInstance()); + } + }); + } + @Override public void registerAssetReloadListener(ControlifyReloadListener reloadListener) { ResourceLoader.get(PackType.CLIENT_RESOURCES).registerReloadListener(reloadListener.getReloadId(), reloadListener); diff --git a/src/main/java/dev/isxander/controlify/Controlify.java b/src/main/java/dev/isxander/controlify/Controlify.java index 74c963546..906131cec 100644 --- a/src/main/java/dev/isxander/controlify/Controlify.java +++ b/src/main/java/dev/isxander/controlify/Controlify.java @@ -190,8 +190,8 @@ public void preInitialiseControlify() { PlatformClientUtil.registerClientDisconnected((client) -> { DebugLog.log("Disconnected from server, resetting server policies"); ServerPolicies.unsetAll(); - triggerEffectRegistry.invalidateResourceRuleCaches(); }); + PlatformClientUtil.registerClientTagsUpdated(client -> triggerEffectRegistry.invalidateResolvedResourceRules()); PlatformClientUtil.addHudLayer(CUtil.rl("button_guide"), (graphics, deltaTracker) -> inGameButtonGuide().ifPresent(guide -> guide.extractRenderState(graphics, deltaTracker.getGameTimeDeltaPartialTick(false)))); diff --git a/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectRegistry.java b/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectRegistry.java index c6c7d66ca..14a75ae7f 100644 --- a/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectRegistry.java +++ b/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectRegistry.java @@ -8,30 +8,30 @@ import com.google.gson.JsonElement; import com.google.gson.JsonParser; -import com.mojang.datafixers.util.Either; import com.mojang.logging.LogUtils; import com.mojang.serialization.Codec; -import com.mojang.serialization.DataResult; -import com.mojang.serialization.Dynamic; import com.mojang.serialization.DynamicOps; import com.mojang.serialization.JsonOps; import com.mojang.serialization.codecs.RecordCodecBuilder; import dev.isxander.controlify.driver.dualsense.DualsenseTriggerEffect; import dev.isxander.controlify.platform.client.resource.SimpleControlifyReloadListener; import dev.isxander.controlify.utils.CUtil; +//? if >=26.2 { +import net.minecraft.advancements.predicates.ItemPredicate; +//?} else { +/*import net.minecraft.advancements.criterion.ItemPredicate; +*///?} import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.CacheSlot; +import net.minecraft.client.multiplayer.ClientLevel; import net.minecraft.core.HolderLookup; import net.minecraft.core.RegistryAccess; import net.minecraft.core.component.DataComponentType; -import net.minecraft.core.registries.BuiltInRegistries; -import net.minecraft.core.registries.Registries; -import net.minecraft.nbt.NbtOps; -import net.minecraft.nbt.NbtUtils; -import net.minecraft.nbt.Tag; import net.minecraft.resources.Identifier; import net.minecraft.server.packs.resources.Resource; import net.minecraft.server.packs.resources.ResourceManager; -import net.minecraft.tags.TagKey; +import net.minecraft.util.PlaceholderLookupProvider; +import net.minecraft.util.RegistryContextSwapper; import net.minecraft.world.item.Item; import net.minecraft.world.item.ItemStack; import org.jetbrains.annotations.Nullable; @@ -39,7 +39,6 @@ import java.io.BufferedReader; import java.util.ArrayList; -import java.util.IdentityHashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -56,50 +55,19 @@ public class TriggerEffectRegistry implements SimpleControlifyReloadListener> ITEM_SELECTORS_CODEC = Codec.either( - Codec.STRING, - Codec.STRING.listOf(1, Integer.MAX_VALUE) - ).xmap( - either -> either.map(List::of, Function.identity()), - Either::right - ); - - private static final Codec NBT_PATTERN_CODEC = Codec.PASSTHROUGH.xmap( - dynamic -> dynamic.convert(NbtOps.INSTANCE).getValue(), - tag -> new Dynamic<>(NbtOps.INSTANCE, tag) - ); - - private static final Codec> COMPONENT_MATCHERS_CODEC = Codec - .unboundedMap(Identifier.CODEC, NBT_PATTERN_CODEC) - .validate(matchers -> matchers.isEmpty() - ? DataResult.error(() -> "At least one component matcher must be specified") - : DataResult.success(matchers)); - - private static final Codec, Map>> COMPONENTS_CODEC = Codec.either( - Identifier.CODEC.listOf(1, Integer.MAX_VALUE), - COMPONENT_MATCHERS_CODEC - ); - - private static final Codec SERIALIZED_SELECTOR_CODEC = - RecordCodecBuilder.create(instance -> instance.group( - ITEM_SELECTORS_CODEC.optionalFieldOf("items", List.of()).forGetter(SerializedSelector::items), - COMPONENTS_CODEC.optionalFieldOf("components").forGetter(SerializedSelector::components) - ).apply(instance, SerializedSelector::new)) - .validate(selector -> selector.items().isEmpty() && selector.components().isEmpty() - ? DataResult.error(() -> "At least one item or component condition must be specified") - : DataResult.success(selector)); - - private static final Codec SERIALIZED_RULE_CODEC = + private static final Codec RULE_CODEC = RecordCodecBuilder.create(instance -> instance.group( - SERIALIZED_SELECTOR_CODEC.fieldOf("when").forGetter(SerializedRule::when), - TriggerEffectCodecs.CODEC.fieldOf("effect").forGetter(SerializedRule::effect) - ).apply(instance, SerializedRule::new)); + ItemPredicate.CODEC.fieldOf("when").forGetter(Rule::when), + TriggerEffectCodecs.CODEC.fieldOf("effect").forGetter(Rule::effect) + ).apply(instance, Rule::new)); + private static final Codec> RULES_CODEC = RULE_CODEC.listOf(); - private List useItemResourceRules = List.of(); - private List swingItemResourceRules = List.of(); + private List useItemResourceLayers = List.of(); + private List swingItemResourceLayers = List.of(); + private final CacheSlot resolvedRules = new CacheSlot<>(this::resolveResourceRules); - private final Map useItemResourceCache = new WeakHashMap<>(); - private final Map swingItemResourceCache = new WeakHashMap<>(); + private final Map useItemResourceCache = new WeakHashMap<>(); + private final Map swingItemResourceCache = new WeakHashMap<>(); private final Map, Function> useItemComponentEffects = new LinkedHashMap<>(); private final Map, Function> swingItemComponentEffects = new LinkedHashMap<>(); @@ -114,21 +82,22 @@ public CompletableFuture load(ResourceManager manager, Executor ex ), executor); } - private List loadResourceStack(ResourceManager manager, Identifier id) { - List rules = new ArrayList<>(); + private List loadResourceStack(ResourceManager manager, Identifier id) { + List layers = new ArrayList<>(); for (Resource resource : manager.getResourceStack(id).reversed()) { try (BufferedReader reader = resource.openAsReader()) { + PlaceholderLookupProvider lookup = new PlaceholderLookupProvider(RegistryAccess.EMPTY); + DynamicOps ops = lookup.createSerializationContext(JsonOps.INSTANCE); JsonElement json = JsonParser.parseReader(reader); - List serializedRules = SERIALIZED_RULE_CODEC.listOf() - .parse(JsonOps.INSTANCE, json) + List layerRules = RULES_CODEC + .parse(ops, json) .getOrThrow(); - - List layerRules = new ArrayList<>(serializedRules.size()); - for (SerializedRule serializedRule : serializedRules) { - layerRules.add(this.resolveRule(serializedRule)); - } - rules.addAll(layerRules); + layers.add(new PreparedLayer( + List.copyOf(layerRules), + resource.sourcePackId(), + lookup.hasRegisteredPlaceholders() ? lookup.createSwapper() : null + )); } catch (Exception e) { LOGGER.error( "Failed to load adaptive trigger effects from {} in pack {}; skipping this layer", @@ -139,74 +108,19 @@ private List loadResourceStack(ResourceManager manager, Identifier id) { } } - return List.copyOf(rules); - } - - private Rule resolveRule(SerializedRule rule) { - List itemConditions = new ArrayList<>(rule.when().items().size()); - for (String itemSelector : rule.when().items()) { - boolean tag = itemSelector.startsWith("#"); - String idString = tag ? itemSelector.substring(1) : itemSelector; - Identifier itemId = Identifier.tryParse(idString); - if (itemId == null) { - throw new IllegalArgumentException("Invalid item selector '" + itemSelector + "'"); - } - - if (tag) { - itemConditions.add(new TagItemCondition(TagKey.create(Registries.ITEM, itemId))); - continue; - } - - if (!BuiltInRegistries.ITEM.containsKey(itemId)) { - throw new IllegalArgumentException("Unknown item '" + itemId + "'"); - } - Item item = BuiltInRegistries.ITEM.getValue(itemId); - itemConditions.add(new ExactItemCondition(item)); - } - - List componentConditions = rule.when().components() - .map(components -> components.map( - ids -> ids.stream().map(this::resolvePresenceComponent).toList(), - matchers -> matchers.entrySet().stream().map(this::resolveMatchingComponent).toList() - )) - .orElseGet(List::of); - - return new Rule( - List.copyOf(itemConditions), - componentConditions, - rule.effect() - ); - } - - private ComponentCondition resolvePresenceComponent(Identifier componentId) { - return new PresenceComponentCondition(this.resolveComponentType(componentId)); - } - - private ComponentCondition resolveMatchingComponent(Map.Entry entry) { - DataComponentType componentType = this.resolveComponentType(entry.getKey()); - if (componentType.isTransient()) { - throw new IllegalArgumentException("Data component type '" + entry.getKey() + "' is not persistent"); - } - return new MatchingComponentCondition(componentType, entry.getValue()); - } - - private DataComponentType resolveComponentType(Identifier componentId) { - if (!BuiltInRegistries.DATA_COMPONENT_TYPE.containsKey(componentId)) { - throw new IllegalArgumentException("Unknown data component type '" + componentId + "'"); - } - return BuiltInRegistries.DATA_COMPONENT_TYPE.getValue(componentId); + return List.copyOf(layers); } @Override public CompletableFuture apply(Preparations data, ResourceManager manager, Executor executor) { return CompletableFuture.runAsync(() -> { - this.useItemResourceRules = data.useItemRules; - this.swingItemResourceRules = data.swingItemRules; - this.invalidateResourceRuleCaches(); + this.useItemResourceLayers = data.useItemLayers; + this.swingItemResourceLayers = data.swingItemLayers; + this.invalidateResolvedResourceRules(); LOGGER.info( - "Loaded {} use-item and {} swing-item adaptive trigger effect rules", - this.useItemResourceRules.size(), - this.swingItemResourceRules.size() + "Loaded {} use-item and {} swing-item adaptive trigger effect rule layers", + this.useItemResourceLayers.size(), + this.swingItemResourceLayers.size() ); }, executor); } @@ -234,9 +148,10 @@ public void registerSwingItemEffect(Item item, DualsenseTriggerEffect effect) { } public Optional getUseItemEffect(ItemStack stack) { + ResolvedRules rules = this.currentResourceRules(); return findEffect( stack, - this.useItemResourceRules, + rules.useItemRules(), this.useItemResourceCache, this.useItemComponentEffects, this.useItemEffects @@ -244,9 +159,10 @@ public Optional getUseItemEffect(ItemStack stack) { } public Optional getSwingItemEffect(ItemStack stack) { + ResolvedRules rules = this.currentResourceRules(); return findEffect( stack, - this.swingItemResourceRules, + rules.swingItemRules(), this.swingItemResourceCache, this.swingItemComponentEffects, this.swingItemEffects @@ -256,7 +172,7 @@ public Optional getSwingItemEffect(ItemStack stack) { private Optional findEffect( ItemStack stack, List resourceRules, - Map resourceCache, + Map resourceCache, Map, Function> componentEffects, Map itemEffects ) { @@ -280,30 +196,68 @@ private Optional findEffect( private Optional findResourceEffect( ItemStack stack, List rules, - Map cache + Map cache ) { - CachedResourceMatches cached = cache.get(stack); - if (cached == null || !ItemStack.isSameItemSameComponents(stack, cached.snapshot())) { - cached = new CachedResourceMatches( - stack.copy(), - new IdentityHashMap<>(), - new IdentityHashMap<>() - ); + CachedResourceMatch cached = cache.get(stack); + if (cached == null || !ItemStack.matches(stack, cached.snapshot())) { + Optional effect = rules.stream() + .filter(rule -> rule.matches(stack)) + .map(Rule::effect) + .findFirst(); + cached = new CachedResourceMatch(stack.copy(), effect); cache.put(stack, cached); } - MatchContext context = new MatchContext(stack, cached); - return rules.stream() - .filter(rule -> rule.matches(context)) - .map(Rule::effect) - .findFirst(); + return cached.effect(); + } + + private ResolvedRules currentResourceRules() { + ClientLevel level = Minecraft.getInstance().level; + return level == null ? ResolvedRules.EMPTY : this.resolvedRules.compute(level); + } + + private ResolvedRules resolveResourceRules(ClientLevel level) { + HolderLookup.Provider registries = level.registryAccess(); + List useItemRules = this.resolveResourceLayers(this.useItemResourceLayers, USE_ITEM_RESOURCE, registries); + List swingItemRules = this.resolveResourceLayers(this.swingItemResourceLayers, SWING_ITEM_RESOURCE, registries); + this.invalidateResourceRuleCaches(); + LOGGER.info( + "Resolved {} use-item and {} swing-item adaptive trigger effect rules for the current world", + useItemRules.size(), + swingItemRules.size() + ); + return new ResolvedRules(useItemRules, swingItemRules); + } + + private List resolveResourceLayers( + List layers, + Identifier id, + HolderLookup.Provider registries + ) { + List rules = new ArrayList<>(); + + for (PreparedLayer layer : layers) { + try { + List resolvedRules = layer.registrySwapper() == null + ? layer.rules() + : layer.registrySwapper().swapTo(RULES_CODEC, layer.rules(), registries).getOrThrow(); + rules.addAll(resolvedRules); + } catch (Exception e) { + LOGGER.error( + "Failed to resolve adaptive trigger effects from {} in pack {} for the current world; skipping this layer", + id, + layer.sourcePackId(), + e + ); + } + } + + return List.copyOf(rules); } - private static HolderLookup.Provider registryAccess() { - var connection = Minecraft.getInstance().getConnection(); - return connection != null - ? connection.registryAccess() - : RegistryAccess.fromRegistryOfRegistries(BuiltInRegistries.REGISTRY); + public void invalidateResolvedResourceRules() { + this.resolvedRules.clear(); + this.invalidateResourceRuleCaches(); } public void invalidateResourceRuleCaches() { @@ -323,141 +277,42 @@ public Identifier getReloadId() { return RELOAD_ID; } - private record SerializedSelector( - List items, - Optional, Map>> components - ) { - } - - private record SerializedRule( - SerializedSelector when, - DualsenseTriggerEffect effect - ) { - } - private record Rule( - List itemConditions, - List componentConditions, + ItemPredicate when, DualsenseTriggerEffect effect ) { - boolean matches(MatchContext context) { - return (this.itemConditions.isEmpty() || this.itemConditions.stream().anyMatch(condition -> condition.matches(context.stack()))) - && this.componentConditions.stream().allMatch(condition -> condition.matches(context)); - } - } - - private sealed interface ItemCondition permits ExactItemCondition, TagItemCondition { - boolean matches(ItemStack stack); - } - - private record ExactItemCondition(Item item) implements ItemCondition { - @Override - public boolean matches(ItemStack stack) { - return stack.getItem() == this.item; - } - } - - private record TagItemCondition(TagKey tag) implements ItemCondition { - @Override public boolean matches(ItemStack stack) { - return stack.is(this.tag); - } - } - - private sealed interface ComponentCondition permits PresenceComponentCondition, MatchingComponentCondition { - boolean matches(MatchContext context); - } - - private record PresenceComponentCondition(DataComponentType componentType) implements ComponentCondition { - @Override - public boolean matches(MatchContext context) { - return context.stack().has(this.componentType); + return this.when.test(stack); } } - private record MatchingComponentCondition( - DataComponentType componentType, - Tag pattern - ) implements ComponentCondition { - @Override - public boolean matches(MatchContext context) { - return context.matches(this); - } + private record PreparedLayer( + List rules, + String sourcePackId, + @Nullable RegistryContextSwapper registrySwapper + ) { } - private static final class MatchContext { - private final ItemStack stack; - private final CachedResourceMatches cachedMatches; - @Nullable - private DynamicOps serializationContext; - - private MatchContext(ItemStack stack, CachedResourceMatches cachedMatches) { - this.stack = stack; - this.cachedMatches = cachedMatches; - } - - private ItemStack stack() { - return this.stack; - } - - private boolean matches(MatchingComponentCondition condition) { - return this.cachedMatches.conditionMatches().computeIfAbsent( - condition, - key -> this.componentTag(key.componentType()) - .map(value -> NbtUtils.compareNbt(key.pattern(), value, true)) - .orElse(false) - ); - } - - private Optional componentTag(DataComponentType componentType) { - return this.cachedMatches.componentTags().computeIfAbsent(componentType, type -> { - Object value = this.stack.get(type); - if (value == null) { - return Optional.empty(); - } - - try { - return Optional.of(encodeComponent(type, value, this.serializationContext())); - } catch (Exception e) { - LOGGER.error("Failed to encode item component {} for adaptive trigger matching", type, e); - return Optional.empty(); - } - }); - } - - private DynamicOps serializationContext() { - if (this.serializationContext == null) { - this.serializationContext = registryAccess().createSerializationContext(NbtOps.INSTANCE); - } - return this.serializationContext; - } - - @SuppressWarnings("unchecked") - private static Tag encodeComponent( - DataComponentType componentType, - Object value, - DynamicOps serializationContext - ) { - return ((Codec) componentType.codecOrThrow()) - .encodeStart(serializationContext, value) - .getOrThrow(); - } + private record ResolvedRules( + List useItemRules, + List swingItemRules + ) { + private static final ResolvedRules EMPTY = new ResolvedRules(List.of(), List.of()); } - private record CachedResourceMatches( + private record CachedResourceMatch( ItemStack snapshot, - Map, Optional> componentTags, - Map conditionMatches + Optional effect ) { } public static final class Preparations { - private final List useItemRules; - private final List swingItemRules; + private final List useItemLayers; + private final List swingItemLayers; - private Preparations(List useItemRules, List swingItemRules) { - this.useItemRules = useItemRules; - this.swingItemRules = swingItemRules; + private Preparations(List useItemLayers, List swingItemLayers) { + this.useItemLayers = useItemLayers; + this.swingItemLayers = swingItemLayers; } } } diff --git a/src/main/java/dev/isxander/controlify/platform/client/PlatformClientUtil.java b/src/main/java/dev/isxander/controlify/platform/client/PlatformClientUtil.java index 521afebee..11fb9b456 100644 --- a/src/main/java/dev/isxander/controlify/platform/client/PlatformClientUtil.java +++ b/src/main/java/dev/isxander/controlify/platform/client/PlatformClientUtil.java @@ -44,6 +44,10 @@ public static void registerClientDisconnected(DisconnectedEvent event) { IMPL.registerClientDisconnected(event); } + public static void registerClientTagsUpdated(LifecycleEvent event) { + IMPL.registerClientTagsUpdated(event); + } + public static void registerAssetReloadListener(ControlifyReloadListener reloadListener) { IMPL.registerAssetReloadListener(reloadListener); } diff --git a/src/main/java/dev/isxander/controlify/platform/client/PlatformClientUtilImpl.java b/src/main/java/dev/isxander/controlify/platform/client/PlatformClientUtilImpl.java index 73e90a90b..8441641cc 100644 --- a/src/main/java/dev/isxander/controlify/platform/client/PlatformClientUtilImpl.java +++ b/src/main/java/dev/isxander/controlify/platform/client/PlatformClientUtilImpl.java @@ -31,6 +31,8 @@ public interface PlatformClientUtilImpl { void registerClientDisconnected(DisconnectedEvent event); + void registerClientTagsUpdated(LifecycleEvent event); + void registerAssetReloadListener(ControlifyReloadListener reloadListener); void registerBuiltinResourcePack(Identifier id, Component displayName); diff --git a/src/main/resources/assets/controlify/trigger_effect/swing_item.json b/src/main/resources/assets/controlify/trigger_effect/swing_item.json index 212f87ca1..76d92c043 100644 --- a/src/main/resources/assets/controlify/trigger_effect/swing_item.json +++ b/src/main/resources/assets/controlify/trigger_effect/swing_item.json @@ -1,7 +1,9 @@ [ { "when": { - "components": ["minecraft:weapon"] + "predicates": { + "minecraft:weapon": {} + } }, "effect": { "type": "weapon", diff --git a/src/main/resources/assets/controlify/trigger_effect/use_item.json b/src/main/resources/assets/controlify/trigger_effect/use_item.json index affd320b2..b284fa995 100644 --- a/src/main/resources/assets/controlify/trigger_effect/use_item.json +++ b/src/main/resources/assets/controlify/trigger_effect/use_item.json @@ -27,7 +27,9 @@ }, { "when": { - "components": ["minecraft:charged_projectiles"] + "predicates": { + "minecraft:charged_projectiles": {} + } }, "effect": { "type": "weapon", @@ -38,7 +40,9 @@ }, { "when": { - "components": ["minecraft:consumable"] + "predicates": { + "minecraft:consumable": {} + } }, "effect": { "type": "feedback", @@ -48,7 +52,9 @@ }, { "when": { - "components": ["minecraft:blocks_attacks"] + "predicates": { + "minecraft:blocks_attacks": {} + } }, "effect": { "type": "feedback", @@ -58,7 +64,9 @@ }, { "when": { - "components": ["minecraft:equippable"] + "predicates": { + "minecraft:equippable": {} + } }, "effect": { "type": "weapon", @@ -69,7 +77,9 @@ }, { "when": { - "components": ["minecraft:kinetic_weapon"] + "predicates": { + "minecraft:kinetic_weapon": {} + } }, "effect": { "type": "feedback", @@ -79,7 +89,9 @@ }, { "when": { - "components": ["minecraft:instrument"] + "predicates": { + "minecraft:instrument": {} + } }, "effect": { "type": "feedback", diff --git a/src/neoforge/java/dev/isxander/controlify/neoforge/platform/client/NeoforgePlatformClientImpl.java b/src/neoforge/java/dev/isxander/controlify/neoforge/platform/client/NeoforgePlatformClientImpl.java index 4b1eeed78..131950fa4 100644 --- a/src/neoforge/java/dev/isxander/controlify/neoforge/platform/client/NeoforgePlatformClientImpl.java +++ b/src/neoforge/java/dev/isxander/controlify/neoforge/platform/client/NeoforgePlatformClientImpl.java @@ -35,6 +35,7 @@ import net.neoforged.neoforge.common.NeoForge; import net.neoforged.neoforge.event.AddPackFindersEvent; import net.neoforged.neoforge.event.GameShuttingDownEvent; +import net.neoforged.neoforge.event.TagsUpdatedEvent; import org.jetbrains.annotations.Nullable; import java.util.Arrays; @@ -73,6 +74,13 @@ public void registerClientDisconnected(DisconnectedEvent event) { }); } + @Override + public void registerClientTagsUpdated(LifecycleEvent event) { + NeoForge.EVENT_BUS.addListener(e -> { + event.onLifecycle(Minecraft.getInstance()); + }); + } + @Override public void registerAssetReloadListener(ControlifyReloadListener reloadListener) { getModEventBus().addListener(e -> {