diff --git a/.editorconfig b/.editorconfig index f33f720bc..e4d08fc1b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -7,6 +7,7 @@ insert_final_newline = true [*.{java,kt,kts,gradle}] indent_style = tab tab_width = 4 +ij_continuation_indent_size = 8 [*.{ts,js,css}] indent_style = tab diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 73b74fd59..72f996d05 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -97,8 +97,7 @@ export default defineConfig({ { text: 'Controlify Entrypoint', link: '/developers/controlify-entrypoint' }, { text: 'Bindings API', link: '/developers/bindings-api' }, { text: 'Screen Operation API', link: '/developers/screen-operation-api' }, - { text: 'Guides API', link: '/developers/guide-api' }, - { text: 'Adaptive Trigger API', link: '/developers/adaptive-trigger-api' } + { text: 'Contextual API', link: '/developers/contextual-api' } ] }, { diff --git a/docs/content/developers/adaptive-trigger-api.md b/docs/content/developers/adaptive-trigger-api.md deleted file mode 100644 index 122d6b39d..000000000 --- a/docs/content/developers/adaptive-trigger-api.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -title: Adaptive Trigger API ---- - -# Adaptive Trigger API - -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: - -```java -public final class MyControlifyEntrypoint implements ControlifyEntrypoint { - @Override - public void onControlifyPreInit(PreInitContext context) { - TriggerEffectApi.registerUseItemEffect( - DataComponents.CHARGED_PROJECTILES, - projectiles -> new DualsenseTriggerEffect.Feedback( - (byte) 3, - (byte) Math.min(8, projectiles.items().size() + 1) - ) - ); - } -} -``` - -`TriggerEffectApi` has use-item and swing-item overloads for: - -- A `DataComponentType` and a `Function`, for effects computed from - the component value. -- A `DataComponentType` and a constant `DualsenseTriggerEffect`. -- An `Item` and a constant `DualsenseTriggerEffect`. - -Use `registerUseItemEffect` for the input bound to **Use Item** and `registerSwingItemEffect` for the input -bound to **Attack**. - -## Priority - -[Resource-pack rules](../resource-packs/adaptive-trigger-effects) are always evaluated before Java -registrations, allowing users and pack authors to override mod-provided effects. Among Java registrations, -component functions are checked in registration order before the exact-item fallback. Registering the same -component or item again replaces its previous effect. - -Use a resource-pack `off` rule when an effect should be explicitly disabled. An `off` result returned from -Java also disables the trigger, but is only reached when no resource rule matched. diff --git a/docs/content/developers/contextual-api.md b/docs/content/developers/contextual-api.md new file mode 100644 index 000000000..b295bcf43 --- /dev/null +++ b/docs/content/developers/contextual-api.md @@ -0,0 +1,119 @@ +--- +title: Contextual API +--- + +# Contextual API + +Controlify's contextual system powers button guides and adaptive trigger effects. A contextual domain combines: + +- a context object supplied at runtime; +- contributors which expose source facts, items, blocks, and entities; +- data-defined facts derived from those values; +- guide and trigger-effect rule sets evaluated against the resulting state. + +For the resource formats, see [Button Guides](../resource-packs/guides) and +[Adaptive Trigger Effects](../resource-packs/adaptive-trigger-effects). + +## Accessing built-in domains + +Domains are available during Controlify pre-initialization: + +```java +private ContextualDomain inGameDomain; + +@Override +public void onControlifyPreInit(PreInitContext context) { + this.inGameDomain = context.contextualDomains().inGame(); +} +``` + +Controlify provides `inGame()` and `container()` domains. + +## Contributing state + +A contributor writes values into named slots. Resource-pack facts and rules can then test those slots with +Minecraft's standard predicates. + +```java +@Override +public void onControlifyPreInit(PreInitContext context) { + context.contextualDomains().inGame().registerContributor((inGame, sink) -> { + sink.contributeFact( + Identifier.fromNamespaceAndPath("example", "holding_wand"), + inGame.player().getMainHandItem().is(EXAMPLE_WAND) + ); + + sink.contributeItem( + Identifier.fromNamespaceAndPath("example", "focus_item"), + inGame.player().getOffhandItem() + ); + }); +} +``` + +`ContextualStateSink` can contribute booleans with `contributeFact`, or values with `contributeItem`, +`contributeBlock`, and `contributeEntity`. Contributors run in registration order; a later contribution to the +same identifier replaces the earlier value. + +## Creating a domain + +Custom domains use a context type implementing `Context`: + +```java +public record SpellContext( + ControllerEntity controller, + GuideVerbosity verbosity, + ItemStack selectedSpell +) implements Context {} +``` + +Register the domain and its contributor during pre-initialization: + +```java +private ContextualDomain spellDomain; + +@Override +public void onControlifyPreInit(PreInitContext context) { + Identifier id = Identifier.fromNamespaceAndPath("example", "spells"); + this.spellDomain = context.contextualDomains().register(id, (spell, sink) -> { + sink.contributeItem( + Identifier.fromNamespaceAndPath("example", "selected_spell"), + spell.selectedSpell() + ); + }); +} +``` + +Controlify automatically registers the domain's fact resource loader. Rules for the domain use the same domain +identifier in their asset path. + +## Using a guide instance + +Create the instance once, update it when contextual state may have changed, and render it independently: + +```java +GuideInstance guide = spellDomain.createGuideInstance(minecraft.font); + +void tick(SpellContext context) { + guide.update(context); +} +``` + +Use `extractRenderState` for HUD-style rendering, or `renderable` when adding the guide to a screen. + +## Using a trigger-effect instance + +Trigger-effect instances are evaluated in the same way: + +```java +TriggerEffectInstance effects = spellDomain.createTriggerEffectInstance(); + +void tick(SpellContext context) { + effects.update(context); + DualsenseTriggerEffect left = effects.getLeftTriggerEffect(); + DualsenseTriggerEffect right = effects.getRightTriggerEffect(); +} +``` + +The trigger-effect API is experimental. A matching rule only applies when its `for` binding is currently bound to +the corresponding controller trigger. diff --git a/docs/content/developers/guide-api.md b/docs/content/developers/guide-api.md deleted file mode 100644 index 09711d912..000000000 --- a/docs/content/developers/guide-api.md +++ /dev/null @@ -1,119 +0,0 @@ ---- -title: Guide API ---- - -# Guide API - -_Learn how to use existing Guide domains, and register your own, for use in your own Screens._ - -::: info -For instructions on modifying rules for existing Guide domains, -see the [resource pack page](../resource-packs/guides) -::: - -## How does this API work? - -The Guide API has a few distinct concepts: - -- **Guide Domain**: Holds a list of valid facts in the domain, - for example, `controlify:in_game` is a domain which holds facts such as `controlify:on_ground`. -- **Guide Context**: Holds data which facts use to determine if they are true or false. -- **Guide Instance**: Applies to a specific domain, holds current state of the facts applied to a context and the rules which pass. - -## Registering facts to a `GuideDomain` - -You may want to add additional facts relating to your mod to an existing `GuideDomain`, such as `controlify:in_game`, -which you would then use in an embedded resource pack to add additional rules. - -You may want to create and render your own `GuideDomain`, and you need to register your own set of facts. - -```java -@Override -public void onControlifyPreInit(PreInitContext ctx) { - ctx.guides().inGame().registerFact( - Identifier.fromNamespaceAndPath("my_mod", "holding_wand"), - ctx -> ctx.player().isItemInMainHandModdedWand() - ); -} -``` - -Now resource packs can use your additional rule. Refer to the [resource pack page](../resource-packs/guides) -to create rules using resource packs. - -If your mod is not loaded or does not register a referenced fact for whatever reason, the state of the fact -will be `false`, and a warning will be issued on resource reload that an unknown fact was referenced. - -## Rendering a Guide - -To render a Guide, you must create a new `GuideInstance`, update its facts, and then finally render it. - -Because the only time you have API-safe access to existing Guide Domains is during pre-init, -you must capture a reference to it to use it later. - -```java -private GuideDomain inGameGuideDomain; - -@Override -public void onControlifyPreInit(PreInitContext ctx) { - this.inGameGuideDomain = ctx.guides().inGame(); -} -``` - -Once you have a reference to a domain, you can create an instance from it. - -```java -GuideInstance guideInstance = guideDomain.createInstance(); -``` - -Ruleset and fact resolution is completely independent of rendering, you may update the guide once every tick, -while you render it every frame. - -Every fact in a `GuideDomain` requires a specific context type (`FactCtx`), you must construct this type to be -able to update an instance. - -```java -void tick() { - guideInstance.update(new InGameCtx(...), font); -} -``` - -Then, you can render it. You can either use the `extractRenderState` method, or you can get a `Renderable` -which you can then add to a `Screen`. - -```java -class MyScreen extends Screen { - @Override - protected void init() { - boolean bottomAligned = true; // if false, top aligned - boolean textContrast = false; // adds a translucent background behind text - this.addRenderableOnly(this.guideInstance.renderable(bottomAligned, textContrast)); - } -} -``` - -## Registering your own domain - -It is very simple to register your own domain. -Once registered, Controlify handles resource reloading for you. - -Before registering a new domain, you need to decide whether it will use an existing fact context -(either `InGameCtx` or `ContainerCtx`), or your own. This is the data you give to the guide instance -each update, which the facts use to resolve their state. Here is an example fact context. - -```java -public record ModdedCtx( - ModClient modClient -) implements FactCtx {} -``` - -Each fact you register would then be able to reference `modClient` and therefore the rest of your mod's state. - -```java -@Override -public void onControlifyPreInit(PreInitContext ctx) { - GuideDomain domain = ctx.guides().registerCustomDomain(Identifier.fromNamespaceAndPath("my_mod", "mod_context")); - domain.registerFact(...); -} -``` - - diff --git a/docs/content/resource-packs/adaptive-trigger-effects.md b/docs/content/resource-packs/adaptive-trigger-effects.md index b2da28bb4..704faa85e 100644 --- a/docs/content/resource-packs/adaptive-trigger-effects.md +++ b/docs/content/resource-packs/adaptive-trigger-effects.md @@ -4,52 +4,80 @@ title: Adaptive Trigger Effects # Adaptive Trigger Effects -Controlify's DualSense adaptive trigger effects can be changed with a resource pack. Effects are applied -to the controller input bound to either **Use Item** or **Attack**. +Adaptive trigger effects are contextual rules. Each rule associates a Controlify binding with a contextual predicate +and a DualSense trigger effect. -## Creating trigger effect rules +## Trigger-effect files -Create either or both of these files in your resource pack: +A domain's trigger rules are loaded from: -- `assets/controlify/trigger_effect/use_item.json` -- `assets/controlify/trigger_effect/swing_item.json` +```text +assets//contextual/trigger_effect/.json +``` -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. +The built-in in-game domain uses: -On Fabric, you can reference the `c:` convention tags even when connected to a vanilla server. -NeoForge does not provide this capability, but when on Singleplayer or connected to a NeoForge server, -you are able to reference convention tags. +```text +assets/controlify/contextual/trigger_effect/in_game.json +``` -For example, a bow can increase resistance as it is pulled: +A rule set contains an optional `replace` flag and an ordered `rules` array: ```json { - "when": { - "items": "minecraft:bow" - }, - "effect": { - "type": "feedback_slope", - "start_position": 3, - "end_position": 9, - "start_strength": 2, - "end_strength": 8 - } + "replace": false, + "rules": [ + { + "for": "controlify:use", + "if": { + "slot": "controlify:active_item", + "item": { + "items": "minecraft:bow" + } + }, + "then": { + "type": "feedback_slope", + "start_position": 3, + "end_position": 9, + "start_strength": 2, + "end_strength": 8 + } + } + ] } ``` -Component presence can give every weapon the same trigger stop: +Each rule has: + +- `for`: the Controlify binding whose physical trigger receives the effect; +- `if`: a [contextual predicate](./guides#contextual-predicates); +- `then`: the trigger effect. + +The binding is not restricted to Use Item or Attack. A rule is considered when its binding is currently bound to the +left or right trigger. + +## Matching in-game items + +The in-game domain provides these item slots: + +- `controlify:active_item`: the item currently being used; +- `controlify:main_hand`; +- `controlify:off_hand`. + +For an Attack effect, test the main hand: ```json { - "when": { - "predicates": { - "minecraft:weapon": {} + "for": "controlify:attack", + "if": { + "slot": "controlify:main_hand", + "item": { + "predicates": { + "minecraft:weapon": {} + } } }, - "effect": { + "then": { "type": "weapon", "start_position": 3, "end_position": 5, @@ -58,193 +86,99 @@ Component presence can give every weapon the same trigger stop: } ``` -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 - } -} -``` +To retain Controlify's Use Item fallback behavior, put all active-item rules first and then repeat them for the +off-hand slot. This makes an active-item match win before any off-hand match. -Semantic component predicates can select a multi-zone effect: +Item predicates use Minecraft's standard item predicate format and can match item IDs, tags, exact component values, +component predicates, and stack counts: ```json { - "when": { - "items": "minecraft:shield", - "predicates": { - "minecraft:damage": { - "damage": { - "min": 1 + "for": "controlify:use", + "if": { + "slot": "controlify:off_hand", + "item": { + "items": "#example:heavy_tools", + "predicates": { + "minecraft:custom_data": { + "example": { + "trigger_effect": "heavy" + } } } } }, - "effect": { + "then": { "type": "feedback_multiple_position", - "strength": [0, 0, 2, 2, 3, 4, 5, 6, 7, 8] + "strength": [0, 0, 2, 3, 4, 5, 6, 7, 8, 8] } } ``` -Exact component values can distinguish an unloaded crossbow: +Contextual predicates can also combine facts, items, blocks, and entities with `all_of`, `none_of`, and `any_of`. -```json -{ - "when": { - "items": "minecraft:crossbow", - "components": { - "minecraft:charged_projectiles": [] - } - }, - "effect": { - "type": "feedback_slope", - "start_position": 2, - "end_position": 9, - "start_strength": 5, - "end_strength": 8 - } -} -``` - -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. +## Rule precedence and replacement -An empty `when` object matches every item and can be used as a final catch-all rule. +Higher-priority resource packs are evaluated before lower-priority packs. Rules within a file are evaluated in array +order. The first matching rule for a binding wins. -To remove an effect supplied by Controlify or a lower-priority pack, add a higher-priority rule with an -`off` effect: - -```json -[ - { - "when": { - "items": "minecraft:bow" - }, - "effect": { - "type": "off" - } - } -] -``` - -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, 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`: +Set `replace` to `true` to discard every lower-priority layer: ```json { - "values": [ - "minecraft:mace", - "minecraft:netherite_axe" - ] + "replace": true, + "rules": [] } ``` -The server resource pack can then contain -`assets/controlify/trigger_effect/swing_item.json`: +Use an `off` effect when a condition should explicitly disable a lower rule for the same binding: ```json -[ - { - "when": { - "items": "#example:heavy_weapons" - }, - "effect": { - "type": "weapon", - "start_position": 2, - "end_position": 8, - "strength": 6 +{ + "for": "controlify:use", + "if": { + "slot": "controlify:active_item", + "item": { + "items": "minecraft:bow" } + }, + "then": { + "type": "off" } -] +} ``` -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 +An invalid file layer is logged and skipped without aborting the resource reload. -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"}}] -``` +## Server-provided registries and tags -Its server resource pack can match that data with the `minecraft:custom_data` component predicate: +Trigger and fact resources are decoded against the client's static registries, then remapped against the current +world's registry access. This allows a server resource pack to reference tags supplied by the server's datapack: ```json -[ - { - "when": { - "items": "minecraft:carrot_on_a_stick", - "predicates": { - "minecraft:custom_data": { - "example": { - "trigger_effect": "heavy" - } +{ + "rules": [ + { + "for": "controlify:attack", + "if": { + "slot": "controlify:main_hand", + "item": { + "items": "#example:heavy_weapons" } + }, + "then": { + "type": "weapon", + "start_position": 2, + "end_position": 8, + "strength": 6 } - }, - "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. +Resolved rules are invalidated whenever client tags update, so datapack `/reload` changes are reflected without a +resource-pack reload. ## Effect formats @@ -261,14 +195,15 @@ values use `0` for off and `8` for maximum. | `feedback_slope` | `start_position`: 0–8; `end_position`: greater than the start and at most 9; `start_strength` and `end_strength`: 1–8. | | `vibration_multiple_position` | `frequency`: positive signed-byte frequency in hertz; `amplitude`: exactly 10 values, each 0–8. | -For example, independent resistance in each trigger zone is written as: +For example: ```json { - "type": "feedback_multiple_position", - "strength": [0, 0, 2, 2, 3, 4, 5, 6, 7, 8] + "type": "vibration_multiple_position", + "frequency": 20, + "amplitude": [0, 0, 2, 2, 3, 4, 5, 6, 7, 8] } ``` -An amplitude or strength of zero disables that zone. Effects whose entire amplitude/strength is zero, or -whose vibration frequency is not positive, behave as `off`. +Effects whose entire amplitude or strength is zero, and vibration effects whose frequency is not positive, behave as +`off`. diff --git a/docs/content/resource-packs/guides.md b/docs/content/resource-packs/guides.md index 5002e3d12..36a3eba03 100644 --- a/docs/content/resource-packs/guides.md +++ b/docs/content/resource-packs/guides.md @@ -1,173 +1,194 @@ --- -title: Guides +title: Button Guides --- -# Guides +# Button Guides -## What are guides? +Button guides show contextual binding hints on the HUD and container screens. The contextual system separates +reusable facts from ordered guide rules. -Guides are a feature of Controlify that shows hints to the player about what buttons to press to perform certain actions. +## Guide rule files -Since version `2.3.0`, guides are data-driven, meaning you can override the default guides with your own. +A domain's guide rules are loaded from: -
+```text +assets//contextual/guide/.json +``` -![The in-game button guide](/images/in-game-guide.webp) +For Controlify's built-in domains, these are: -![The container button guide](/images/container-guide.webp) +- `assets/controlify/contextual/guide/in_game.json` +- `assets/controlify/contextual/guide/container.json` -
+A rule set contains an optional `replace` flag and an ordered `rules` array: -## Concepts +```json +{ + "replace": false, + "rules": [ + { + "for": "controlify:jump", + "where": "left", + "if": "controlify:on_ground", + "then": { "translate": "key.jump" } + } + ] +} +``` -There are three main concepts you need to understand to create your own guides. +Each guide rule has: -1. **Fact** - - A fact is a piece of information about the current game state. - - For example, the fact `controlify:on_ground` is true when the player is on the ground. -2. **Rule** - - A rule contains a list of permitting facts, and a list of forbidding facts. - - If all permitting facts are true and no forbidding facts are true, the rule is applied if its binding is bound. - - For example, a rule for `controlify:jump` requires the fact `controlify:on_ground`, then show some text and the button glyph for the jump binding. -3. **Domain** - - A domain contains all loaded facts and rules for a specific type of guide. - - `controlify:in_game` is a domain that has in-game specific facts, and rules to show when the player is in-game. - - `controlify:container` is a domain that has facts and rules for when the player is in a container GUI, like the inventory or a chest. - - For example, you wouldn't have a rule for `controlify:on_ground` in the `controlify:container` domain, since it's irrelevant in a container GUI. +- `for`: the Controlify binding to display; +- `where`: `left` or `right`; +- `if`: a contextual predicate; +- `then`: a literal string or Minecraft text component. -## Creating custom rules +Use `true` for an unconditional rule. -Controlify only allows resource packs to add and override _rules_, not _facts_ or _domains_. +## Contextual predicates -If Controlify does not have a _fact_ for your specific use case, you must create a mod that adds the _fact_ to Controlify, -or submit a feature request to get it added to Controlify itself. +A fact identifier directly tests a boolean fact: +```json +"if": "controlify:on_ground" +``` -::: code-group -```json [assets/controlify/guides/in_game.json] -{ - "override": false, - "rules": [ - { - "for": "controlify:jump", - "where": "left", - "when": ["controlify:on_ground"], - "forbid": [], - "then": "Jump" - } - ] +Predicates can be combined. `all_of` requires every child, `none_of` rejects any matching child, and `any_of` +requires at least one matching child: + +```json +"if": { + "all_of": [ + "controlify:in_water", + "controlify:input_moving" + ], + "none_of": [ + "controlify:sprinting", + "controlify:in_vehicle" + ] } ``` -::: -This example shows a _rule_ that applies when the player is on the ground, and the `controlify:jump` binding is bound. -When the _rule_ applies, it shows the text "Jump" and the glyph for the `controlify:jump` binding. +Rules can also test named item, block, or entity slots with Minecraft's predicate formats: + +```json +"if": { + "slot": "controlify:main_hand", + "item": { + "items": "#minecraft:swords" + } +} +``` -As well as literal text such as `"Jump"`, Controlify supports the Minecraft -[text component format](https://minecraft.wiki/w/Text_component_format) which allows you to use translations and styling. -For example, you can use `"then": {"translate": "mypack.jump"}` to use a translation key sourced from your pack's language files. +```json +"if": { + "slot": "controlify:hit_result", + "entity": { + "type": "#minecraft:villager" + } +} +``` -A rule can be displayed either on the `left` or `right` side of the screen, this is defined by the `"where"` field. +Replace `item` with `block` or `entity` to select the corresponding predicate type. A missing slot does not +match. -Because `"override": false`, this resource pack will not override the default rules, but instead add an additional rule. -If you want to override the default rules, or any resource pack below yours, set `"override": true`. +## Rule precedence -### Stacking rules +Higher-priority resource packs are evaluated before lower-priority packs. Within a file, rules are evaluated in +array order. For each binding and guide location, the first matching rule wins. -You can stack multiple rules for the same binding, the first rule that succeeds will be applied, the rest will be ignored. -However, if the rules have different locations (e.g. `left` and `right`), they will both be applied. +Set `replace` to `true` to discard all guide-rule layers below that file: -::: code-group -```json [assets/controlify/guides/in_game.json] +```json { - "override": false, - "rules": [ - { - "for": "controlify:jump", - "where": "left", - "when": ["controlify:in_water"], - "forbid": [], - "then": "Swim Up" - }, - { - "for": "controlify:jump", - "where": "left", - "when": ["controlify:on_ground"], - "forbid": [], - "then": "Jump" - } + "replace": true, + "rules": [] +} +``` + +An invalid file layer is logged and skipped without aborting the resource reload. + +## Defining facts + +Data-defined facts for a domain use: + +```text +assets//contextual/facts/.json +``` + +The file is a map from fact identifiers to contextual predicates: + +```json +{ + "example:holding_sword": { + "slot": "controlify:main_hand", + "item": { + "items": "#minecraft:swords" + } + }, + "example:ready_to_attack": { + "all_of": [ + "example:holding_sword", + "controlify:looking_at_entity" ] + } } ``` -::: - -In this snippet, the first rule will apply when the player is in water, -and the second rule will apply when the player is on the ground. - -Even when the player is in water and touching the ground, only the first rule will apply, -because it is the first rule that matches its conditions. - -## Facts - -Controlify has a set of built-in facts that you can use in your rules. - -Below is a list of the built-in facts for each domain. - -### Common facts - -These facts are available in all domains. - -| ID | Description | -|----------------------------------------|-----------------------------------------------------------| -| `controlify:verbosity_full` | When the guide verbosity level is set to full. | -| `controlify:verbosity_reduced_or_more` | When the guide verbosity level is either full or reduced. | -| `controlify:verbosity_reduced_or_less` | When the guide verbosity is set to reduced or less. | -| `controlify:verbosity_minimal` | When the guide verbosity is set to minimal. | - - -### `controlify:in_game` - -| ID | Description | -|-----------------------------------------|-------------------------------------------------------------------------------------------------------------------------------| -| `controlify:on_ground` | When the player is on the ground. | -| `controlify:in_vehicle` | When the player is in a vehicle. | -| `controlify:riding_saddled_horse` | When the currently ridden vehicle is a horse with a saddle. | -| `controlify:riding_happy_ghast` | When the currently ridden vehicle is a Happy Ghast. | -| `controlify:flying` | When the player is currently in creative flight. | -| `controlify:elytra_flying` | When the player is currently gliding with an elytra. | -| `controlify:can_elytra_fly` | When the player is in a state where pressing jump will cause the elytra to deploy. | -| `controlify:in_liquid` | When the player is touching liquid, such as water or lava. | -| `controlify:in_water` | When the player is touching water. | -| `controlify:under_water` | When the player has their eyes underwater. | -| `controlify:in_lava` | When the player is touching lava. | -| `controlify:sneaking` | When the player is attempting to sneak (pressing the sneak key, or it is toggled on). | -| `controlify:is_toggle_sneak` | When the player is using toggle sneak (does not mean it is currently toggled on). | -| `controlify:is_toggle_sprint` | When the player is using toggle sprint (does not mean it is currently toggled on). | -| `controlify:sprinting` | When the player is attempting to sprint (pressing the sprint key, or it is toggled on). | -| `controlify:input_moving` | When the player is applying movement input—even if the player is not physically moving, if they're trying to, this fact goes. | -| `controlify:is_spectator` | When the player is in spectator mode. | -| `controlify:is_creative` | When the player is in creative mode. | -| `controlify:has_hearts` | When the player is not invulnerable. | -| `controlify:is_adventure` | When the player is in adventure mode. | -| `controlify:is_survival` | When the player is in survival mode. | -| `controlify:looking_at_entity` | When the player is currently looking at an entity and is in range to interact with it. | -| `controlify:looking_at_block` | When the player is currently looking at a block and is in range to interact or destroy it. | -| `controlify:looking_at_air` | When the player is neither looking at a block nor looking at an entity. | -| `controlify:has_item_in_either_hand` | When the player has an item in their main hand or their offhand. | -| `controlify:has_item_in_mainhand` | When the player has an item in their main hand. | -| `controlify:has_item_in_offhand` | When the player has an item in their offhand. | -| `controlify:has_multiple_items_in_hand` | When the player is holding an item stack with a count greater than one. | - -### `controlify:container` - -| ID | Description | -|---------------------------------------|----------------------------------------------------------------------------------------------------------------------------| -| `controlify:hovering_slot` | When the user is hovering their cursor over a slot. | -| `controlify:hovering_item` | When the user is hovering their cursor over an occupied slot. | -| `controlify:hovering_many_items` | When the user is hovering their cursor over an occupied slot which has more than one item in it. | -| `controlify:holding_item` | When the user has grabbed an item and is moving it around with their cursor. | -| `controlify:holding_many_items` | When the user has grabbed an item and is moving it around with their cursor, and that item has more than one in the stack. | -| `controlify:can_place_held_item` | When the container allows the player to place down their held item into the currently hovered slot. | -| `controlify:cursor_outside_container` | When the user is hovering their cursor outside the container interface. | -| `controlify:hovering_item_is_bundle` | When the user is hovering their cursor over a slot which is occupied with an item tagged as a bundle. | -| `controlify:selected_bundle_slot` | When the user is currently selecting an item from within the bundle they're hovering. | + +Facts may reference other data-defined facts. Controlify orders their evaluation automatically and rejects cycles. +Mods can expose additional source facts and slots through the [Contextual API](../developers/contextual-api). + +## Built-in domain state + +Every domain exposes these source facts: + +- `controlify:verbosity_full` +- `controlify:verbosity_reduced` +- `controlify:verbosity_minimal` + +The `controlify:in_game` domain exposes these slots: + +- entity `controlify:self`; +- entity or block `controlify:hit_result`; +- items `controlify:main_hand`, `controlify:off_hand`, and `controlify:active_item`. + +It also provides source or data-defined facts including: + +- `controlify:on_ground`, `controlify:in_vehicle`, `controlify:flying`, `controlify:creative_flying`, + `controlify:elytra_flying`; +- `controlify:riding_saddled_horse`, `controlify:riding_saddled_equine`, + `controlify:riding_saddled_camel`, `controlify:riding_saddled_nautilus`, and + `controlify:riding_happy_ghast`; +- `controlify:can_elytra_fly`, `controlify:in_liquid`, `controlify:in_water`, `controlify:under_water`; +- `controlify:sneaking`, `controlify:sprinting`, `controlify:input_moving`, `controlify:on_climbable`; +- `controlify:is_toggle_sneak`, `controlify:is_toggle_sprint`; +- `controlify:is_spectator`, `controlify:is_creative`, `controlify:is_survival`; +- `controlify:looking_at_entity`, `controlify:looking_at_block`, `controlify:looking_at_air`; +- `controlify:looking_at_villager`, `controlify:looking_at_merchant`, + `controlify:looking_at_rideable`, `controlify:looking_at_chest_boat`, and + `controlify:looking_at_container_minecart`; +- `controlify:looking_at_closed_openable`, `controlify:looking_at_open_openable`, + `controlify:looking_at_container`, `controlify:looking_at_button`, `controlify:looking_at_lever`, + `controlify:looking_at_bell`, and `controlify:looking_at_bed`; +- workstation target facts named `controlify:looking_at_crafting_table`, `controlify:looking_at_furnace`, + `controlify:looking_at_smoker`, `controlify:looking_at_brewing_stand`, + `controlify:looking_at_enchanting_table`, `controlify:looking_at_smithing_table`, + `controlify:looking_at_repair_station`, `controlify:looking_at_stonecutter`, + `controlify:looking_at_loom`, and `controlify:looking_at_cartography_table`; +- `controlify:has_item_in_main_hand`, `controlify:has_item_in_offhand`; +- `controlify:has_item_in_either_hand`, `controlify:has_multiple_items_in_main_hand`, + `controlify:main_hand_is_tool`; +- `controlify:main_hand_is_block_item`, `controlify:off_hand_is_block_item`, and + `controlify:fishing_hook_cast`. + +The built-in in-game guide uses the verbosity facts as tiers. Minimal includes movement and contextual attack/use +actions, reduced adds sprint, inventory, drop, and swap-hands hints, and full additionally includes radial-menu, +drop-stack, and pick-block hints. + +The `controlify:container` domain exposes item slots `controlify:hovering_slot`, `controlify:holding_item`, and +`controlify:bundle_selected`. Its built-in facts include: + +- `controlify:hovering_slot`, `controlify:hovering_item`, `controlify:hovering_many_items`; +- `controlify:holding_item`, `controlify:holding_many_items`; +- `controlify:hovering_item_is_bundle`, `controlify:selected_bundle_slot`; +- `controlify:can_place_held_item`, `controlify:can_pickup_slot`, `controlify:cursor_outside_container`. diff --git a/src/gametest/java/dev/isxander/controlify/gametest/tests/ButtonGuideTests.java b/src/gametest/java/dev/isxander/controlify/gametest/tests/ButtonGuideTests.java new file mode 100644 index 000000000..8dbdbf306 --- /dev/null +++ b/src/gametest/java/dev/isxander/controlify/gametest/tests/ButtonGuideTests.java @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.gametest.tests; + +import dev.isxander.controlify.api.contextual.InGameContext; +import dev.isxander.controlify.api.contextual.GuideLocation; +import dev.isxander.controlify.contextual.*; +import dev.isxander.controlify.gametest.framework.CEntityTypes; +import dev.isxander.controlify.gametest.framework.CTestUtil; +import dev.isxander.controlify.gametest.framework.TestUnitWorldContext; +import dev.isxander.controlify.gametest.framework.controller.ControlifyGameTestContext; +import dev.isxander.controlify.gui.guide.GuideInstanceImpl; +import dev.isxander.controlify.utils.CUtil; +import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest; +import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext; +import net.minecraft.commands.arguments.EntityAnchorArgument; +import net.minecraft.core.BlockPos; +import net.minecraft.world.phys.EntityHitResult; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@SuppressWarnings({"UnstableApiUsage", "unchecked"}) +public class ButtonGuideTests implements FabricClientGameTest { + @Override + public void runTest(ClientGameTestContext context) { + var controlify = new ControlifyGameTestContext(context); + + try (var controller = controlify.virtualControllerBuilder().withXbox().attach(); + var world = new TestUnitWorldContext(context); + var region = world.allocateRegion(5, 5, 5)) { + var villager = region.spawnWithNoFreeWill(CEntityTypes.VILLAGER, new BlockPos(1, 1, 3)); + context.waitTicks(2); + + var villagerPosition = region.computeOnServer((_, _) -> villager.getBoundingBox().getCenter()); + context.runOnClient(minecraft -> minecraft.player.lookAt(EntityAnchorArgument.Anchor.EYES, villagerPosition)); + context.waitTicks(2); + + context.runOnClient(minecraft -> { + if (!(minecraft.hitResult instanceof EntityHitResult)) { + throw new AssertionError("Expected the villager to be the current entity hit result, got " + minecraft.hitResult); + } + + var domain = (ContextualDomainImpl) ContextualDomains.INSTANCE.inGame(); + var ruleEngine = RuleSetManager.GUIDE_RULES + .getRuleEngine(minecraft.level, CUtil.rl("in_game")) + .orElseThrow(); + assertRuleLocation(ruleEngine, "inventory", GuideLocation.LEFT); + assertRuleLocation(ruleEngine, "radial_menu", GuideLocation.LEFT); + var inGameContext = InGameContext.create(minecraft, controller.getControllerEntity()); + var state = domain.calculateState(inGameContext, ruleEngine.factDependencies()); + assertFact(state, "looking_at_entity"); + assertFact(state, "looking_at_merchant"); + + var matchingRules = ruleEngine.evaluate(state); + assertBindingRule(matchingRules, "attack"); + assertBindingRule(matchingRules, "use"); + + var guide = (GuideInstanceImpl) domain.createGuideInstance(minecraft.font); + guide.update(inGameContext); + if (guide.rightGuides().lines().stream().noneMatch(line -> line.text().getString().contains("Trade"))) { + throw new AssertionError("Expected the rendered right guide to contain Trade: " + guide.rightGuides().lines()); + } + }); + + try (var _ = CTestUtil.applyResourcePack(context, CUtil.rl("legacy_console"))) { + context.runOnClient(minecraft -> { + var ruleEngine = RuleSetManager.GUIDE_RULES + .getRuleEngine(minecraft.level, CUtil.rl("in_game")) + .orElseThrow(); + + Set expectedBindings = Set.of("jump", "sneak", "drop", "inventory", "attack", "use"); + Set actualBindings = ruleEngine.rules().stream() + .map(rule -> rule.binding().bindId()) + .map(id -> id.getPath()) + .collect(Collectors.toSet()); + if (!actualBindings.equals(expectedBindings)) { + throw new AssertionError("Expected the Legacy Console guide rules to replace the defaults. Bindings: " + actualBindings); + } + + assertRuleLocation(ruleEngine, "jump", GuideLocation.LEFT); + assertRuleLocation(ruleEngine, "sneak", GuideLocation.LEFT); + assertRuleLocation(ruleEngine, "drop", GuideLocation.LEFT); + assertRuleLocation(ruleEngine, "inventory", GuideLocation.RIGHT); + assertRuleLocation(ruleEngine, "attack", GuideLocation.RIGHT); + assertRuleLocation(ruleEngine, "use", GuideLocation.RIGHT); + }); + } + } + + controlify.resetSettings(); + } + + private static void assertFact(ContextualState state, String path) { + var id = CUtil.rl(path); + if (!state.facts().getOrDefault(id, false)) { + throw new AssertionError("Expected contextual fact '" + id + "' to be true. Facts: " + state.facts()); + } + } + + private static void assertBindingRule( + List rules, + String bindingPath + ) { + var bindingId = CUtil.rl(bindingPath); + if (rules.stream().noneMatch(rule -> rule.binding().bindId().equals(bindingId))) { + throw new AssertionError("Expected a matching guide rule for '" + bindingId + "': " + rules); + } + } + + private static void assertRuleLocation( + RuleEngine ruleEngine, + String bindingPath, + GuideLocation expectedLocation + ) { + var bindingId = CUtil.rl(bindingPath); + List rules = ruleEngine.rules().stream() + .filter(rule -> rule.binding().bindId().equals(bindingId)) + .toList(); + if (rules.isEmpty() || rules.stream().anyMatch(rule -> rule.location() != expectedLocation)) { + throw new AssertionError( + "Expected all guide rules for '" + bindingId + "' to be on " + expectedLocation + ": " + rules + ); + } + } +} diff --git a/src/gametest/resources/fabric.mod.json b/src/gametest/resources/fabric.mod.json index 58bea3f9c..a6af3326e 100644 --- a/src/gametest/resources/fabric.mod.json +++ b/src/gametest/resources/fabric.mod.json @@ -18,7 +18,8 @@ "dev.isxander.controlify.gametest.tests.VirtualControllerInputTests", "dev.isxander.controlify.gametest.tests.KeyboardLayoutTests", "dev.isxander.controlify.gametest.tests.RumbleTests", - "dev.isxander.controlify.gametest.tests.AdaptiveTriggerTests" + "dev.isxander.controlify.gametest.tests.AdaptiveTriggerTests", + "dev.isxander.controlify.gametest.tests.ButtonGuideTests" ], "client": [ "dev.isxander.controlify.gametest.tests.KeyboardLayoutTests", diff --git a/src/gametest/resources/resourcepacks/adaptive_trigger_high_priority/assets/controlify/contextual/trigger_effect/in_game.json b/src/gametest/resources/resourcepacks/adaptive_trigger_high_priority/assets/controlify/contextual/trigger_effect/in_game.json new file mode 100644 index 000000000..24338631f --- /dev/null +++ b/src/gametest/resources/resourcepacks/adaptive_trigger_high_priority/assets/controlify/contextual/trigger_effect/in_game.json @@ -0,0 +1,19 @@ +{ + "rules": [ + { + "for": "controlify:attack", + "if": { + "slot": "controlify:main_hand", + "item": { + "items": "minecraft:diamond_sword" + } + }, + "then": { + "type": "vibration", + "position": 1, + "amplitude": 4, + "frequency": 11 + } + } + ] +} diff --git a/src/gametest/resources/resourcepacks/adaptive_trigger_high_priority/assets/controlify/trigger_effect/swing_item.json b/src/gametest/resources/resourcepacks/adaptive_trigger_high_priority/assets/controlify/trigger_effect/swing_item.json deleted file mode 100644 index 18c2e4619..000000000 --- a/src/gametest/resources/resourcepacks/adaptive_trigger_high_priority/assets/controlify/trigger_effect/swing_item.json +++ /dev/null @@ -1,13 +0,0 @@ -[ - { - "when": { - "items": "minecraft:diamond_sword" - }, - "effect": { - "type": "vibration", - "position": 1, - "amplitude": 4, - "frequency": 11 - } - } -] diff --git a/src/gametest/resources/resourcepacks/adaptive_trigger_low_priority/assets/controlify/contextual/trigger_effect/in_game.json b/src/gametest/resources/resourcepacks/adaptive_trigger_low_priority/assets/controlify/contextual/trigger_effect/in_game.json new file mode 100644 index 000000000..24a66359e --- /dev/null +++ b/src/gametest/resources/resourcepacks/adaptive_trigger_low_priority/assets/controlify/contextual/trigger_effect/in_game.json @@ -0,0 +1,18 @@ +{ + "rules": [ + { + "for": "controlify:attack", + "if": { + "slot": "controlify:main_hand", + "item": { + "items": "minecraft:diamond_sword" + } + }, + "then": { + "type": "feedback", + "position": 2, + "strength": 2 + } + } + ] +} diff --git a/src/gametest/resources/resourcepacks/adaptive_trigger_low_priority/assets/controlify/trigger_effect/swing_item.json b/src/gametest/resources/resourcepacks/adaptive_trigger_low_priority/assets/controlify/trigger_effect/swing_item.json deleted file mode 100644 index e42601ffd..000000000 --- a/src/gametest/resources/resourcepacks/adaptive_trigger_low_priority/assets/controlify/trigger_effect/swing_item.json +++ /dev/null @@ -1,12 +0,0 @@ -[ - { - "when": { - "items": "minecraft:diamond_sword" - }, - "effect": { - "type": "feedback", - "position": 2, - "strength": 2 - } - } -] diff --git a/src/gametest/resources/resourcepacks/adaptive_trigger_server_tag/assets/controlify/contextual/trigger_effect/in_game.json b/src/gametest/resources/resourcepacks/adaptive_trigger_server_tag/assets/controlify/contextual/trigger_effect/in_game.json new file mode 100644 index 000000000..4a7126983 --- /dev/null +++ b/src/gametest/resources/resourcepacks/adaptive_trigger_server_tag/assets/controlify/contextual/trigger_effect/in_game.json @@ -0,0 +1,19 @@ +{ + "rules": [ + { + "for": "controlify:attack", + "if": { + "slot": "controlify:main_hand", + "item": { + "items": "#controlify_test:adaptive_trigger_test" + } + }, + "then": { + "type": "weapon", + "start_position": 2, + "end_position": 8, + "strength": 6 + } + } + ] +} diff --git a/src/gametest/resources/resourcepacks/adaptive_trigger_server_tag/assets/controlify/trigger_effect/swing_item.json b/src/gametest/resources/resourcepacks/adaptive_trigger_server_tag/assets/controlify/trigger_effect/swing_item.json deleted file mode 100644 index 3d35e43fa..000000000 --- a/src/gametest/resources/resourcepacks/adaptive_trigger_server_tag/assets/controlify/trigger_effect/swing_item.json +++ /dev/null @@ -1,13 +0,0 @@ -[ - { - "when": { - "items": "#controlify_test:adaptive_trigger_test" - }, - "effect": { - "type": "weapon", - "start_position": 2, - "end_position": 8, - "strength": 6 - } - } -] diff --git a/src/main/java/dev/isxander/controlify/Controlify.java b/src/main/java/dev/isxander/controlify/Controlify.java index d22f9b828..a62d84882 100644 --- a/src/main/java/dev/isxander/controlify/Controlify.java +++ b/src/main/java/dev/isxander/controlify/Controlify.java @@ -9,9 +9,9 @@ import com.mojang.blaze3d.Blaze3D; import dev.isxander.controlify.api.ControlifyApi; import dev.isxander.controlify.api.bind.ControlifyBindApi; +import dev.isxander.controlify.api.contextual.ContextualDomainRegistry; import dev.isxander.controlify.api.entrypoint.InitContext; import dev.isxander.controlify.api.entrypoint.PreInitContext; -import dev.isxander.controlify.api.guide.*; import dev.isxander.controlify.bindings.BindContext; import dev.isxander.controlify.bindings.ControlifyBindApiImpl; import dev.isxander.controlify.bindings.ControlifyBindings; @@ -22,9 +22,10 @@ import dev.isxander.controlify.config.dto.profile.defaults.DefaultConfigManager; import dev.isxander.controlify.config.settings.device.DeviceSettings; import dev.isxander.controlify.config.settings.profile.ProfileSettings; +import dev.isxander.controlify.contextual.ContextualDomains; +import dev.isxander.controlify.contextual.RuleSetManager; import dev.isxander.controlify.controller.*; import dev.isxander.controlify.controller.dualsense.TriggerEffectManager; -import dev.isxander.controlify.controller.dualsense.TriggerEffectRegistry; import dev.isxander.controlify.controller.id.ControllerTypeManager; import dev.isxander.controlify.controller.input.ControllerState; import dev.isxander.controlify.controller.input.ControllerStateView; @@ -32,13 +33,10 @@ import dev.isxander.controlify.controller.rumble.RumbleComponent; import dev.isxander.controlify.controllermanager.ControllerManager; import dev.isxander.controlify.controllermanager.SDLControllerManager; -import dev.isxander.controlify.driver.dualsense.DualsenseTriggerEffect; import dev.isxander.controlify.driver.sdl.SDLNativesLoader; import dev.isxander.controlify.driver.steamdeck.SteamDeckMode; import dev.isxander.controlify.driver.steamdeck.SteamDeckUtil; import dev.isxander.controlify.font.InputFontMapper; -import dev.isxander.controlify.gui.guide.GuideDomainImpl; -import dev.isxander.controlify.gui.guide.GuideDomains; import dev.isxander.controlify.gui.screen.*; import dev.isxander.controlify.debug.DebugProperties; import dev.isxander.controlify.ingame.ControllerPlayerMovement; @@ -65,8 +63,6 @@ import net.minecraft.client.gui.screens.PauseScreen; import net.minecraft.client.multiplayer.ServerData; import net.minecraft.network.chat.Component; -import net.minecraft.resources.Identifier; -import net.minecraft.world.item.Items; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.lwjgl.glfw.GLFW; @@ -98,7 +94,6 @@ public class Controlify implements ControlifyApi { private DefaultConfigManager defaultConfigManager; private ControllerTypeManager controllerTypeManager; private KeyboardLayoutManager keyboardLayoutManager; - private TriggerEffectRegistry triggerEffectRegistry; private TriggerEffectManager triggerEffectManager; @@ -145,16 +140,16 @@ public void preInitialiseControlify() { this.defaultConfigManager = new DefaultConfigManager(); this.controllerTypeManager = new ControllerTypeManager(); this.keyboardLayoutManager = new KeyboardLayoutManager(); - this.triggerEffectRegistry = new TriggerEffectRegistry(); + PlatformClientUtil.registerAssetReloadListener(inputFontMapper); PlatformClientUtil.registerAssetReloadListener(defaultBindManager); PlatformClientUtil.registerAssetReloadListener(defaultConfigManager); PlatformClientUtil.registerAssetReloadListener(controllerTypeManager); PlatformClientUtil.registerAssetReloadListener(keyboardLayoutManager); - PlatformClientUtil.registerAssetReloadListener(triggerEffectRegistry); PlatformClientUtil.registerAssetReloadListener(RadialIconManager.INSTANCE); - PlatformClientUtil.registerAssetReloadListener(GuideDomains.IN_GAME); - PlatformClientUtil.registerAssetReloadListener(GuideDomains.CONTAINER); + PlatformClientUtil.registerAssetReloadListener(RuleSetManager.GUIDE_RULES); + PlatformClientUtil.registerAssetReloadListener(RuleSetManager.TRIGGER_EFFECT_RULES); + ContextualDomains.touch(); registerBuiltinPack("legacy_console"); @@ -190,11 +185,15 @@ public void preInitialiseControlify() { policy.set(ServerPolicy.fromBoolean(packet.allowed())); }); - PlatformClientUtil.registerClientDisconnected((client) -> { + PlatformClientUtil.registerClientDisconnected(_ -> { DebugLog.log("Disconnected from server, resetting server policies"); ServerPolicies.unsetAll(); }); - PlatformClientUtil.registerClientTagsUpdated(client -> triggerEffectRegistry.invalidateResolvedResourceRules()); + PlatformClientUtil.registerClientTagsUpdated(_ -> { + ContextualDomains.INSTANCE.invalidateResolvedFactGraphs(); + RuleSetManager.GUIDE_RULES.invalidateResolvedRuleEngines(); + RuleSetManager.TRIGGER_EFFECT_RULES.invalidateResolvedRuleEngines(); + }); PlatformClientUtil.addHudLayer(CUtil.rl("button_guide"), (graphics, deltaTracker) -> inGameButtonGuide().ifPresent(guide -> guide.extractRenderState(graphics, deltaTracker.getGameTimeDeltaPartialTick(false)))); @@ -208,33 +207,14 @@ public ControlifyBindApi bindings() { } @Override - public GuideDomainRegistry guides() { - return new GuideDomainRegistry() { - @Override - public GuideDomain inGame() { - return GuideDomains.IN_GAME; - } - - @Override - public GuideDomain container() { - return GuideDomains.CONTAINER; - } - - @Override - public GuideDomain registerCustom(Identifier domainId) { - GuideDomainImpl domain = new GuideDomainImpl<>(domainId); - GuideDomains.CUSTOM_DOMAINS.put(domainId, domain); - PlatformClientUtil.registerAssetReloadListener(domain); - return domain; - } - }; + public ContextualDomainRegistry contextualDomains() { + return ContextualDomains.INSTANCE; } }); } catch (Throwable e) { CUtil.LOGGER.error("Failed to run `onControlifyPreInit` on Controlify entrypoint: {}", entrypoint.getClass().getName(), e); } }); - GuideDomains.freeze(); } private void registerBuiltinPack(String id) { @@ -259,7 +239,7 @@ public void initializeControlify() { this.inGameInputHandler = null; // set when the current controller changes this.virtualMouseHandler = new VirtualMouseHandler(); - this.triggerEffectManager = new TriggerEffectManager(this.minecraft, this.triggerEffectRegistry); + this.triggerEffectManager = new TriggerEffectManager(); ControlifyEvents.CONTROLLER_CONNECTED.register(event -> this.onControllerAdded( event.controller(), event.hotplugged())); @@ -534,7 +514,8 @@ private void tickActiveController(ControllerEntity controller, boolean outOfFocu } boolean controllerInputSuppressed = outOfFocus || currentInputMode() == InputMode.KEYBOARD_MOUSE; - triggerEffectManager.applyTriggerEffects(controller, controllerInputSuppressed); + this.triggerEffectManager().tick(this.minecraft, controller); + this.triggerEffectManager().applyTriggerEffects(controller, controllerInputSuppressed); if (state.isGivingInput()) { minecraft.getFramerateLimitTracker().onInputReceived(); @@ -793,10 +774,6 @@ public TriggerEffectManager triggerEffectManager() { return triggerEffectManager; } - public TriggerEffectRegistry triggerEffectRegistry() { - return triggerEffectRegistry; - } - public Set thisTickBindContexts() { return this.thisTickContexts; } diff --git a/src/main/java/dev/isxander/controlify/api/guide/ContainerCtx.java b/src/main/java/dev/isxander/controlify/api/contextual/ContainerContext.java similarity index 73% rename from src/main/java/dev/isxander/controlify/api/guide/ContainerCtx.java rename to src/main/java/dev/isxander/controlify/api/contextual/ContainerContext.java index 329360609..7f6aba855 100644 --- a/src/main/java/dev/isxander/controlify/api/guide/ContainerCtx.java +++ b/src/main/java/dev/isxander/controlify/api/contextual/ContainerContext.java @@ -4,18 +4,20 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.api.guide; +package dev.isxander.controlify.api.contextual; import dev.isxander.controlify.controller.ControllerEntity; +import net.minecraft.world.entity.player.Player; import net.minecraft.world.inventory.Slot; import net.minecraft.world.item.ItemStack; import org.jetbrains.annotations.Nullable; -public record ContainerCtx( +public record ContainerContext( + Player player, @Nullable Slot hoveredSlot, ItemStack holdingItem, boolean cursorOutsideContainer, ControllerEntity controller, GuideVerbosity verbosity -) implements FactCtx { +) implements Context { } diff --git a/src/main/java/dev/isxander/controlify/api/guide/FactCtx.java b/src/main/java/dev/isxander/controlify/api/contextual/Context.java similarity index 76% rename from src/main/java/dev/isxander/controlify/api/guide/FactCtx.java rename to src/main/java/dev/isxander/controlify/api/contextual/Context.java index 362daf043..e77d7598f 100644 --- a/src/main/java/dev/isxander/controlify/api/guide/FactCtx.java +++ b/src/main/java/dev/isxander/controlify/api/contextual/Context.java @@ -4,11 +4,11 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.api.guide; +package dev.isxander.controlify.api.contextual; import dev.isxander.controlify.controller.ControllerEntity; -public interface FactCtx { +public interface Context { ControllerEntity controller(); GuideVerbosity verbosity(); diff --git a/src/main/java/dev/isxander/controlify/api/contextual/ContextualDomain.java b/src/main/java/dev/isxander/controlify/api/contextual/ContextualDomain.java new file mode 100644 index 000000000..fde17e2bf --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/contextual/ContextualDomain.java @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.contextual; + +import net.minecraft.client.gui.Font; +import net.minecraft.resources.Identifier; +import org.jetbrains.annotations.ApiStatus; + +public interface ContextualDomain { + Identifier id(); + + void registerContributor(ContextualStateContributor contributor); + + GuideInstance createGuideInstance(Font font); + + @ApiStatus.Experimental + TriggerEffectInstance createTriggerEffectInstance(); +} diff --git a/src/main/java/dev/isxander/controlify/api/contextual/ContextualDomainRegistry.java b/src/main/java/dev/isxander/controlify/api/contextual/ContextualDomainRegistry.java new file mode 100644 index 000000000..048bd6c36 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/contextual/ContextualDomainRegistry.java @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.contextual; + +import net.minecraft.resources.Identifier; + +public interface ContextualDomainRegistry { + ContextualDomain inGame(); + + ContextualDomain container(); + + ContextualDomain register(Identifier domainId); + + default ContextualDomain register(Identifier domainId, ContextualStateContributor contributor) { + var domain = this.register(domainId); + domain.registerContributor(contributor); + return domain; + } +} diff --git a/src/main/java/dev/isxander/controlify/api/contextual/ContextualInstance.java b/src/main/java/dev/isxander/controlify/api/contextual/ContextualInstance.java new file mode 100644 index 000000000..8e28bed75 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/contextual/ContextualInstance.java @@ -0,0 +1,11 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.contextual; + +public interface ContextualInstance { + void update(C context); +} diff --git a/src/main/java/dev/isxander/controlify/api/contextual/ContextualStateContributor.java b/src/main/java/dev/isxander/controlify/api/contextual/ContextualStateContributor.java new file mode 100644 index 000000000..5b0df668b --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/contextual/ContextualStateContributor.java @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.contextual; + +public interface ContextualStateContributor { + void contribute(C context, ContextualStateSink sink); + + default ContextualStateContributor andThen(ContextualStateContributor other) { + return combine(this, other); + } + + static ContextualStateContributor noop() { + return (context, sink) -> {}; + } + + @SafeVarargs + static ContextualStateContributor combine( + ContextualStateContributor... contributors + ) { + return (context, sink) -> { + for (var contributor : contributors) { + contributor.contribute(context, sink); + } + }; + } +} diff --git a/src/main/java/dev/isxander/controlify/api/contextual/ContextualStateSink.java b/src/main/java/dev/isxander/controlify/api/contextual/ContextualStateSink.java new file mode 100644 index 000000000..5e02c5b92 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/contextual/ContextualStateSink.java @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.contextual; + +import net.minecraft.resources.Identifier; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.item.ItemInstance; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.state.pattern.BlockInWorld; +import org.jetbrains.annotations.Nullable; + +public interface ContextualStateSink { + void contributeFact(Identifier id, boolean value); + + default void contributeFact(Identifier id) { + this.contributeFact(id, true); + } + + void contributeItem(Identifier slot, @Nullable ItemInstance stack); + + void contributeBlock(Identifier slot, @Nullable BlockInWorld block); + + void contributeEntity(Identifier slot, @Nullable Entity entity); +} diff --git a/src/main/java/dev/isxander/controlify/api/guide/GuideInstance.java b/src/main/java/dev/isxander/controlify/api/contextual/GuideInstance.java similarity index 78% rename from src/main/java/dev/isxander/controlify/api/guide/GuideInstance.java rename to src/main/java/dev/isxander/controlify/api/contextual/GuideInstance.java index fd34fb61c..b8e9eebeb 100644 --- a/src/main/java/dev/isxander/controlify/api/guide/GuideInstance.java +++ b/src/main/java/dev/isxander/controlify/api/contextual/GuideInstance.java @@ -4,17 +4,12 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.api.guide; +package dev.isxander.controlify.api.contextual; -import net.minecraft.client.gui.Font; import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.gui.components.Renderable; -public interface GuideInstance { - GuideDomain domain(); - - boolean update(T context, Font font); - +public interface GuideInstance extends ContextualInstance { void extractRenderState(GuiGraphicsExtractor graphics, boolean bottomAligned, boolean textContrast, int guiScale); default void extractRenderState(GuiGraphicsExtractor graphics, boolean bottomAligned, boolean textContrast) { diff --git a/src/main/java/dev/isxander/controlify/api/guide/ActionLocation.java b/src/main/java/dev/isxander/controlify/api/contextual/GuideLocation.java similarity index 58% rename from src/main/java/dev/isxander/controlify/api/guide/ActionLocation.java rename to src/main/java/dev/isxander/controlify/api/contextual/GuideLocation.java index 602c9933a..2a5d38d15 100644 --- a/src/main/java/dev/isxander/controlify/api/guide/ActionLocation.java +++ b/src/main/java/dev/isxander/controlify/api/contextual/GuideLocation.java @@ -4,22 +4,20 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.api.guide; +package dev.isxander.controlify.api.contextual; import com.mojang.serialization.Codec; import net.minecraft.util.StringRepresentable; import org.jetbrains.annotations.NotNull; -/** - * Whether the action should be on the left or right list. - */ -public enum ActionLocation implements StringRepresentable { +/// Whether the action should be on the left or right list. +public enum GuideLocation implements StringRepresentable { LEFT("left"), RIGHT("right"); private final String serialName; - ActionLocation(String serialName) { + GuideLocation(String serialName) { this.serialName = serialName; } @@ -28,6 +26,6 @@ public enum ActionLocation implements StringRepresentable { return this.serialName; } - public static final Codec CODEC = - StringRepresentable.fromEnum(ActionLocation::values); + public static final Codec CODEC = + StringRepresentable.fromEnum(GuideLocation::values); } diff --git a/src/main/java/dev/isxander/controlify/api/guide/GuideVerbosity.java b/src/main/java/dev/isxander/controlify/api/contextual/GuideVerbosity.java similarity index 95% rename from src/main/java/dev/isxander/controlify/api/guide/GuideVerbosity.java rename to src/main/java/dev/isxander/controlify/api/contextual/GuideVerbosity.java index 35a95dba5..8baba51d3 100644 --- a/src/main/java/dev/isxander/controlify/api/guide/GuideVerbosity.java +++ b/src/main/java/dev/isxander/controlify/api/contextual/GuideVerbosity.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.api.guide; +package dev.isxander.controlify.api.contextual; import com.mojang.serialization.Codec; import dev.isxander.yacl3.api.NameableEnum; diff --git a/src/main/java/dev/isxander/controlify/api/contextual/InGameContext.java b/src/main/java/dev/isxander/controlify/api/contextual/InGameContext.java new file mode 100644 index 000000000..bfcf9a0d4 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/contextual/InGameContext.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.contextual; + +import dev.isxander.controlify.controller.ControllerEntity; +import dev.isxander.controlify.mixins.feature.guide.ingame.MinecraftAccessor; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.world.phys.HitResult; +import org.jetbrains.annotations.ApiStatus; + +import java.util.Objects; + +public record InGameContext( + Minecraft client, + LocalPlayer player, + ClientLevel level, + HitResult hitResult, + ControllerEntity controller, + GuideVerbosity verbosity +) implements Context { + @ApiStatus.Internal + public static InGameContext create(Minecraft minecraft, ControllerEntity controller) { + var hitResult = minecraft.hitResult; + if (hitResult == null) { + ((MinecraftAccessor) minecraft).controlify$invokePick(1f); + hitResult = minecraft.hitResult; + } + + return new InGameContext( + minecraft, + Objects.requireNonNull(minecraft.player, "player"), + Objects.requireNonNull(minecraft.level, "level"), + hitResult, + controller, + controller.settings().generic.guide.verbosity + ); + } +} diff --git a/src/main/java/dev/isxander/controlify/api/contextual/TriggerEffectInstance.java b/src/main/java/dev/isxander/controlify/api/contextual/TriggerEffectInstance.java new file mode 100644 index 000000000..52551b404 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/contextual/TriggerEffectInstance.java @@ -0,0 +1,18 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.contextual; + +import dev.isxander.controlify.driver.dualsense.DualsenseTriggerEffect; +import org.jetbrains.annotations.ApiStatus; + +public interface TriggerEffectInstance extends ContextualInstance { + @ApiStatus.Experimental + DualsenseTriggerEffect getLeftTriggerEffect(); + + @ApiStatus.Experimental + DualsenseTriggerEffect getRightTriggerEffect(); +} diff --git a/src/main/java/dev/isxander/controlify/api/entrypoint/PreInitContext.java b/src/main/java/dev/isxander/controlify/api/entrypoint/PreInitContext.java index 5e3cf5ab3..af0a053a5 100644 --- a/src/main/java/dev/isxander/controlify/api/entrypoint/PreInitContext.java +++ b/src/main/java/dev/isxander/controlify/api/entrypoint/PreInitContext.java @@ -7,10 +7,10 @@ package dev.isxander.controlify.api.entrypoint; import dev.isxander.controlify.api.bind.ControlifyBindApi; -import dev.isxander.controlify.api.guide.GuideDomainRegistry; +import dev.isxander.controlify.api.contextual.ContextualDomainRegistry; public interface PreInitContext { ControlifyBindApi bindings(); - GuideDomainRegistry guides(); + ContextualDomainRegistry contextualDomains(); } diff --git a/src/main/java/dev/isxander/controlify/api/guide/CommonFacts.java b/src/main/java/dev/isxander/controlify/api/guide/CommonFacts.java deleted file mode 100644 index d2602fa13..000000000 --- a/src/main/java/dev/isxander/controlify/api/guide/CommonFacts.java +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.api.guide; - -import dev.isxander.controlify.gui.guide.GuideDomains; -import dev.isxander.controlify.utils.CUtil; -import net.minecraft.resources.Identifier; -import org.jetbrains.annotations.ApiStatus; - -/** - * Contains facts that can be used in any guide domain. - */ -public final class CommonFacts { - private CommonFacts() {} - - /** - * When the guide verbosity level is set to {@link GuideVerbosity#FULL FULL}. - * Used in {@link Rule#forbid()} clauses to only allow the rule to be applied when the verbosity - * is less than full. - */ - public static final Fact VERBOSITY_FULL = register( - CUtil.rl("verbosity_full"), - ctx -> ctx.verbosity() == GuideVerbosity.FULL - ); - /** - * When the guide verbosity level is either {@link GuideVerbosity#FULL FULL} or {@link GuideVerbosity#REDUCED REDUCED}. - * Used in {@link Rule#forbid()} clauses to only allow the rule to be applied when the verbosity is - * set to minimal. - */ - public static final Fact VERBOSITY_REDUCED_OR_MORE = register( - CUtil.rl("verbosity_reduced_or_more"), - ctx -> ctx.verbosity().getLevel() >= GuideVerbosity.REDUCED.getLevel() - ); - /** - * When the guide verbosity is set to {@link GuideVerbosity#REDUCED REDUCED} or less. - * Used in {@link Rule#when()} clauses to only allow the rule to be applied when the verbosity - * is set to reduced or minimal. - */ - public static final Fact VERBOSITY_REDUCED_OR_LESS = register( - CUtil.rl("verbosity_reduced_or_less"), - ctx -> ctx.verbosity().getLevel() <= GuideVerbosity.REDUCED.getLevel() - ); - /** - * When the guide verbosity is set to {@link GuideVerbosity#MINIMAL MINIMAL}. - * Used in {@link Rule#when()} clauses to only allow the rule to be applied when the verbosity - * is set to minimal. - */ - public static final Fact VERBOSITY_MINIMAL = register( - CUtil.rl("verbosity_minimal"), - ctx -> ctx.verbosity() == GuideVerbosity.MINIMAL - ); - - private static Fact register(Identifier id, FactProvider provider) { - var fact = Fact.of(id, provider); - - GuideDomains.IN_GAME.registerFact(fact); - GuideDomains.CONTAINER.registerFact(fact); - - return fact; - } - private static Fact register(Identifier id) { - return register(id, FactProvider.staticProvider(false)); - } - - @ApiStatus.Internal - public static void registerAll() { - // This method is used to ensure that all facts are registered - // when the class is loaded, so that they can be used in the guide. - // No-op, as all facts are registered statically. - } -} diff --git a/src/main/java/dev/isxander/controlify/api/guide/ContainerFacts.java b/src/main/java/dev/isxander/controlify/api/guide/ContainerFacts.java deleted file mode 100644 index e77ff8fff..000000000 --- a/src/main/java/dev/isxander/controlify/api/guide/ContainerFacts.java +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.api.guide; - -import dev.isxander.controlify.gui.guide.GuideDomains; -import dev.isxander.controlify.utils.CUtil; -import net.minecraft.resources.Identifier; -import net.minecraft.tags.ItemTags; -import net.minecraft.world.item.BundleItem; - -public final class ContainerFacts { - private ContainerFacts() {} - - /** When the user is hovering their cursor over a slot */ - public static final Fact HOVERING_SLOT = register( - CUtil.rl("hovering_slot"), - ctx -> ctx.hoveredSlot() != null - ); - /** When the user is hovering their cursor over an occupied slot */ - public static final Fact HOVERING_ITEM = register( - CUtil.rl("hovering_item"), - ctx -> ctx.hoveredSlot() != null && ctx.hoveredSlot().hasItem() - ); - /** When the user is hovering their cursor over an occupied slot which has more than one item in it */ - public static final Fact HOVERING_MANY_ITEMS = register( - CUtil.rl("hovering_many_items"), - ctx -> ctx.hoveredSlot() != null && ctx.hoveredSlot().getItem().getCount() > 1 - ); - /** When the user has grabbed an item and is moving it around with their cursor */ - public static final Fact HOLDING_ITEM = register( - CUtil.rl("holding_item"), - ctx -> !ctx.holdingItem().isEmpty() - ); - /** When the user has grabbed an item and is moving it around with their cursor, and that item has more than one in the stack */ - public static final Fact HOLDING_MANY_ITEMS = register( - CUtil.rl("holding_many_items"), - ctx -> ctx.holdingItem().getCount() > 1 - ); - /** When the container allows the player to place down their held item into the currently hovered slot */ - public static final Fact CAN_PLACE_HELD_ITEM = register( - CUtil.rl("can_place_held_item"), - ctx -> ctx.hoveredSlot() != null && ctx.hoveredSlot().mayPlace(ctx.holdingItem()) - ); - /** When the user is hovering their cursor outside the container interface */ - public static final Fact CURSOR_OUTSIDE_CONTAINER = register( - CUtil.rl("cursor_outside_container"), - ContainerCtx::cursorOutsideContainer - ); - /** When the user is hovering their cursor over a slot which is occupied with an item tagged by BUNDLES */ - public static final Fact HOVERING_ITEM_IS_BUNDLE = register( - CUtil.rl("hovering_item_is_bundle"), - ctx -> ctx.hoveredSlot() != null && ctx.hoveredSlot().getItem().is(ItemTags.BUNDLES) - ); - /** When the user is currently selecting an item from within the bundle they're hovering */ - public static final Fact SELECTED_BUNDLE_SLOT = register( - CUtil.rl("selected_bundle_slot"), - ctx -> ctx.hoveredSlot() != null && BundleItem.getSelectedItem(ctx.hoveredSlot().getItem()) != null - ); - - private static Fact register(Identifier id, FactProvider provider) { - var fact = Fact.of(id, provider); - - GuideDomains.CONTAINER.registerFact(fact); - - return fact; - } - private static Fact register(Identifier id) { - return register(id, FactProvider.staticProvider(false)); - } - - public static void registerAll() { - CommonFacts.registerAll(); - // This method is used to ensure that all facts are registered - // when the class is loaded, so that they can be used in the guide. - // No-op, as all facts are registered statically. - } -} diff --git a/src/main/java/dev/isxander/controlify/api/guide/Fact.java b/src/main/java/dev/isxander/controlify/api/guide/Fact.java deleted file mode 100644 index 2157ef9aa..000000000 --- a/src/main/java/dev/isxander/controlify/api/guide/Fact.java +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.api.guide; - -import net.minecraft.resources.Identifier; - -/** - * Represents a single fact that rules can depend on. - * - * @param provider the provider of the fact, which is a function that takes a domain context and returns true if the fact is true - */ -public record Fact(Identifier id, FactProvider provider) { - - public static Fact of(Identifier id, FactProvider provider) { - return new Fact<>(id, provider); - } - - public static Fact of(Identifier id) { - return new Fact<>(id, FactProvider.staticProvider(false)); - } -} diff --git a/src/main/java/dev/isxander/controlify/api/guide/FactProvider.java b/src/main/java/dev/isxander/controlify/api/guide/FactProvider.java deleted file mode 100644 index c055a407d..000000000 --- a/src/main/java/dev/isxander/controlify/api/guide/FactProvider.java +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.api.guide; - -@FunctionalInterface -public interface FactProvider { - boolean test(T t); - - static FactProvider staticProvider(boolean value) { - return t -> value; - } -} diff --git a/src/main/java/dev/isxander/controlify/api/guide/GuideDomain.java b/src/main/java/dev/isxander/controlify/api/guide/GuideDomain.java deleted file mode 100644 index 308f3cdbd..000000000 --- a/src/main/java/dev/isxander/controlify/api/guide/GuideDomain.java +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.api.guide; - -public interface GuideDomain { - void registerFact(Fact fact); - - void registerDynamicRule(Rule rule); - - GuideInstance createInstance(); -} diff --git a/src/main/java/dev/isxander/controlify/api/guide/GuideDomainRegistry.java b/src/main/java/dev/isxander/controlify/api/guide/GuideDomainRegistry.java deleted file mode 100644 index b73db82a4..000000000 --- a/src/main/java/dev/isxander/controlify/api/guide/GuideDomainRegistry.java +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.api.guide; - -import net.minecraft.resources.Identifier; - -public interface GuideDomainRegistry { - GuideDomain inGame(); - - GuideDomain container(); - - GuideDomain registerCustom(Identifier domainId); -} diff --git a/src/main/java/dev/isxander/controlify/api/guide/InGameCtx.java b/src/main/java/dev/isxander/controlify/api/guide/InGameCtx.java deleted file mode 100644 index 0d11c86fc..000000000 --- a/src/main/java/dev/isxander/controlify/api/guide/InGameCtx.java +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.api.guide; - -import dev.isxander.controlify.controller.ControllerEntity; -import net.minecraft.client.Minecraft; -import net.minecraft.client.multiplayer.ClientLevel; -import net.minecraft.client.player.LocalPlayer; -import net.minecraft.world.phys.HitResult; - -public record InGameCtx( - Minecraft client, - LocalPlayer player, - ClientLevel level, - HitResult hitResult, - ControllerEntity controller, - GuideVerbosity verbosity -) implements FactCtx { -} diff --git a/src/main/java/dev/isxander/controlify/api/guide/InGameFacts.java b/src/main/java/dev/isxander/controlify/api/guide/InGameFacts.java deleted file mode 100644 index ea5624a98..000000000 --- a/src/main/java/dev/isxander/controlify/api/guide/InGameFacts.java +++ /dev/null @@ -1,187 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.api.guide; - -import dev.isxander.controlify.gui.guide.GuideDomains; -import dev.isxander.controlify.mixins.feature.guide.ingame.PlayerAccessor; -import dev.isxander.controlify.utils.CUtil; -import net.minecraft.client.player.LocalPlayer; -import net.minecraft.resources.Identifier; -import net.minecraft.world.entity.EquipmentSlot; -import net.minecraft.world.entity.animal.happyghast.HappyGhast; -import net.minecraft.world.level.GameType; -import net.minecraft.world.phys.HitResult; -import net.minecraft.world.phys.Vec2; -import net.minecraft.world.entity.animal.equine.AbstractHorse; - -public final class InGameFacts { - private InGameFacts() {} - - /** When {@link LocalPlayer#onGround()} is true */ - public static final Fact ON_GROUND = register( - CUtil.rl("on_ground"), - ctx -> ctx.player().onGround() - ); - /** When {@link LocalPlayer#getVehicle()} is not null */ - public static final Fact IN_VEHICLE = register( - CUtil.rl("in_vehicle"), - ctx -> ctx.player().getVehicle() != null - ); - /** When the currently ridden vehicle is a horse with a saddle */ - public static final Fact RIDING_SADDLED_HORSE = register( - CUtil.rl("riding_saddled_horse"), - ctx -> ctx.player().getVehicle() instanceof AbstractHorse horse && horse.isSaddled() - ); - /** When the currently ridden vehicle is a Happy Ghast */ - public static final Fact RIDING_HAPPY_GHAST = register( - CUtil.rl("riding_happy_ghast"), - ctx -> ctx.player().getVehicle() instanceof HappyGhast - ); - /** When the player is currently in creative flight */ - public static final Fact FLYING = register( - CUtil.rl("flying"), - ctx -> ctx.player().getAbilities().flying - ); - /** When the player is currently gliding with an elytra */ - public static final Fact ELYTRA_FLYING = register( - CUtil.rl("elytra_flying"), - ctx -> ctx.player().isFallFlying() - ); - /** When the player is in a state where pressing jump will cause the elytra to deploy */ - public static final Fact CAN_ELYTRA_FLY = register( - CUtil.rl("can_elytra_fly"), - ctx -> ((PlayerAccessor) ctx.player()).controlify$callCanGlide() - && !ctx.player().onClimbable() - && !ctx.player().onGround() - && !ctx.player().isInLiquid() - && !ctx.player().isFallFlying() - ); - /** When the player is touching liquid, such as water or lava */ - public static final Fact IN_LIQUID = register( - CUtil.rl("in_liquid"), - ctx -> ctx.player().isInLiquid() - ); - /** When the player is touching water */ - public static final Fact IN_WATER = register( - CUtil.rl("in_water"), - ctx -> ctx.player().isInWater() - ); - /** When the player has their eyes underwater */ - public static final Fact UNDER_WATER = register( - CUtil.rl("under_water"), - ctx -> ctx.player().isUnderWater() - ); - /** When the player is touching lava */ - public static final Fact IN_LAVA = register( - CUtil.rl("in_lava"), - ctx -> ctx.player().isInLava() - ); - /** When the player is attempting to sneak (pressing the sneak key, or it is toggled on) */ - public static final Fact SNEAKING = register( - CUtil.rl("sneaking"), - ctx -> ctx.player().isShiftKeyDown() - ); - /** When the player is using toggle sneak (does not mean it is currently toggled on) */ - public static final Fact IS_TOGGLE_SNEAK = register( - CUtil.rl("is_toggle_sneak"), - ctx -> ctx.controller().settings().generic.toggleSneak - ); - /** When the player is using toggle sprint (does not mean it is currently toggled on) */ - public static final Fact IS_TOGGLE_SPRINT = register( - CUtil.rl("is_toggle_sprint"), - ctx -> ctx.controller().settings().generic.toggleSprint - ); - /** When the player is attempting to sprint (pressing the sprint key, or it is toggled on) */ - public static final Fact SPRINTING = register( - CUtil.rl("sprinting"), - ctx -> ctx.player().input.keyPresses.sprint() - ); - /** When the player is applying movement input - even if the player is not physically moving, if they're trying to, this fact goes */ - public static final Fact INPUT_MOVING = register( - CUtil.rl("input_moving"), - ctx -> !ctx.player().input.getMoveVector().equals(Vec2.ZERO) - ); - /** When the player is in {@link GameType#SPECTATOR spectator mode} */ - public static final Fact IS_SPECTATOR = register( - CUtil.rl("is_spectator"), - ctx -> ctx.player().isSpectator() - ); - /** When the player is in {@link GameType#CREATIVE creative mode} */ - public static final Fact IS_CREATIVE = register( - CUtil.rl("is_creative"), - ctx -> ctx.player().isCreative() - ); - /** When the player is not invulnerable */ - public static final Fact HAS_HEARTS = register( - CUtil.rl("has_hearts"), - ctx -> !ctx.player().getAbilities().invulnerable - ); - /** When the player is in {@link GameType#ADVENTURE adventure mode} */ - public static final Fact IS_ADVENTURE = register( - CUtil.rl("is_adventure"), - ctx -> ctx.player().gameMode() == GameType.ADVENTURE - ); - /** When the player is in {@link GameType#SURVIVAL survival mode} */ - public static final Fact IS_SURVIVAL = register( - CUtil.rl("is_survival"), - ctx -> ctx.player().gameMode() == GameType.SURVIVAL - ); - /** When the player is currently looking at an entity and is in range to interact with it */ - public static final Fact LOOKING_AT_ENTITY = register( - CUtil.rl("looking_at_entity"), - ctx -> ctx.hitResult().getType() == HitResult.Type.ENTITY - ); - /** When the player is currently looking at a block and is in range to interact or destroy it */ - public static final Fact LOOKING_AT_BLOCK = register( - CUtil.rl("looking_at_block"), - ctx -> ctx.hitResult().getType() == HitResult.Type.BLOCK - ); - /** When the player is neither {@link #LOOKING_AT_BLOCK looking at a block} or {@link #LOOKING_AT_ENTITY looking at an entity} */ - public static final Fact LOOKING_AT_AIR = register( - CUtil.rl("looking_at_air"), - ctx -> ctx.hitResult().getType() == HitResult.Type.MISS - ); - /** When the player has an item in their main hand or their offhand */ - public static final Fact HAS_ITEM_IN_EITHER_HAND = register( - CUtil.rl("has_item_in_either_hand"), - ctx -> ctx.player().hasItemInSlot(EquipmentSlot.MAINHAND) || ctx.player().hasItemInSlot(EquipmentSlot.OFFHAND) - ); - /** When the player has an item in their main hand. */ - public static final Fact HAS_ITEM_IN_MAINHAND = register( - CUtil.rl("has_item_in_mainhand"), - ctx -> ctx.player().hasItemInSlot(EquipmentSlot.MAINHAND) - ); - /** When the player has an item in their offhand. */ - public static final Fact HAS_ITEM_IN_OFFHAND = register( - CUtil.rl("has_item_in_offhand"), - ctx -> ctx.player().hasItemInSlot(EquipmentSlot.OFFHAND) - ); - /** When the player is holding an item stack with a count greater than one */ - public static final Fact HAS_MULTIPLE_ITEMS_IN_HAND = register( - CUtil.rl("has_multiple_items_in_hand"), - ctx -> { - var holdingItem = ctx.player().getInventory().getSelectedItem(); - return !holdingItem.isEmpty() && holdingItem.getCount() > 1; - } - ); - - private static Fact register(Identifier id, FactProvider provider) { - var fact = Fact.of(id, provider); - GuideDomains.IN_GAME.registerFact(fact); - return fact; - } - private static Fact register(Identifier id) { - return register(id, FactProvider.staticProvider(false)); - } - - public static void registerAll() { - CommonFacts.registerAll(); - // This method is used to ensure that all facts are registered - // when the class is loaded, so that they can be used in the guide. - // No-op, as all facts are registered statically. - } -} diff --git a/src/main/java/dev/isxander/controlify/api/guide/Rule.java b/src/main/java/dev/isxander/controlify/api/guide/Rule.java deleted file mode 100644 index 0d5b197c0..000000000 --- a/src/main/java/dev/isxander/controlify/api/guide/Rule.java +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.api.guide; - -import com.mojang.serialization.Codec; -import com.mojang.serialization.codecs.RecordCodecBuilder; -import dev.isxander.controlify.api.bind.InputBindingSupplier; -import dev.isxander.controlify.utils.codec.CExtraCodecs; -import net.minecraft.network.chat.Component; -import net.minecraft.network.chat.ComponentSerialization; -import net.minecraft.resources.Identifier; - -import java.util.Set; - -/** - * A rule is made up of a set of conditions ({@link #when()} and {@link #forbid()}) - * that must be satisfied in order for a button guide to be shown. - *

- * These rules are loaded dynamically from resource packs or can be created programmatically - * with {@link RuleBuilder}. - * - * @param binding the binding that this rule applies to - the glyph of which is shown in the guide - * @param where the location of the guide (left or right) - * @param when a set of conditions that all must be satisfied for the rule to apply - * @param forbid a set of conditions where any must not be satisfied for the rule to apply - * @param then the text component that is shown in the guide when the rule applies - * @see #builder() for a builder to create dynamic rules - */ -public record Rule( - InputBindingSupplier binding, - ActionLocation where, - Set when, - Set forbid, - Component then -) { - public static final Codec CODEC = RecordCodecBuilder.create( - instance -> instance.group( - InputBindingSupplier.CODEC.fieldOf("for").forGetter(Rule::binding), - ActionLocation.CODEC.fieldOf("where").forGetter(Rule::where), - CExtraCodecs.set(Identifier.CODEC).optionalFieldOf("when", Set.of()).forGetter(Rule::when), - CExtraCodecs.set(Identifier.CODEC).optionalFieldOf("forbid", Set.of()).forGetter(Rule::forbid), - ComponentSerialization.CODEC.fieldOf("then").forGetter(Rule::then) - ).apply(instance, Rule::new) - ); - - public static RuleBuilder builder() { - return new RuleBuilder(); - } -} diff --git a/src/main/java/dev/isxander/controlify/api/guide/RuleBuilder.java b/src/main/java/dev/isxander/controlify/api/guide/RuleBuilder.java deleted file mode 100644 index 318456444..000000000 --- a/src/main/java/dev/isxander/controlify/api/guide/RuleBuilder.java +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.api.guide; - -import dev.isxander.controlify.api.bind.InputBindingSupplier; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.Identifier; -import org.apache.commons.lang3.Validate; - -import java.util.Arrays; -import java.util.HashSet; -import java.util.Set; - -public final class RuleBuilder { - private InputBindingSupplier binding; - private ActionLocation where; - private final Set when = new HashSet<>(); - private final Set forbid = new HashSet<>(); - private Component then; - - RuleBuilder() {} - - public RuleBuilder binding(InputBindingSupplier binding) { - this.binding = binding; - return this; - } - - public RuleBuilder where(ActionLocation where) { - this.where = where; - return this; - } - - public RuleBuilder when(Identifier... when) { - this.when.addAll(Arrays.asList(when)); - return this; - } - - @SafeVarargs - public final StrictRuleBuilder when(Fact... when) { - return new StrictRuleBuilder().when(when); - } - - public RuleBuilder forbid(Identifier... forbid) { - this.forbid.addAll(Arrays.asList(forbid)); - return this; - } - - @SafeVarargs - public final StrictRuleBuilder forbid(Fact... forbid) { - return this.new StrictRuleBuilder().forbid(forbid); - } - - public RuleBuilder then(Component then) { - this.then = then; - return this; - } - - public StrictRuleBuilder strict() { - return this.new StrictRuleBuilder<>(); - } - - public Rule build() { - Validate.notNull(binding, "`binding` must not be null"); - Validate.notNull(where, "`where` must not be null"); - Validate.notNull(then, "`then` must not be null"); - return new Rule(binding, where, when, forbid, then); - } - - public final class StrictRuleBuilder { - private StrictRuleBuilder() {} - - public StrictRuleBuilder binding(InputBindingSupplier binding) { - RuleBuilder.this.binding(binding); - return this; - } - - public StrictRuleBuilder where(ActionLocation where) { - RuleBuilder.this.where(where); - return this; - } - - public StrictRuleBuilder when(Identifier... when) { - RuleBuilder.this.when(when); - return this; - } - - @SafeVarargs - public final StrictRuleBuilder when(Fact... when) { - for (Fact fact : when) { - RuleBuilder.this.when(fact.id()); - } - return this; - } - - public StrictRuleBuilder forbid(Identifier... when) { - RuleBuilder.this.forbid(when); - return this; - } - - @SafeVarargs - public final StrictRuleBuilder forbid(Fact... when) { - for (Fact fact : when) { - RuleBuilder.this.forbid(fact.id()); - } - return this; - } - - public StrictRuleBuilder then(Component then) { - RuleBuilder.this.then(then); - return this; - } - - public Rule build() { - return RuleBuilder.this.build(); - } - - public RuleBuilder lenient() { - return RuleBuilder.this; - } - } -} diff --git a/src/main/java/dev/isxander/controlify/api/triggereffect/TriggerEffectApi.java b/src/main/java/dev/isxander/controlify/api/triggereffect/TriggerEffectApi.java deleted file mode 100644 index 1eef74794..000000000 --- a/src/main/java/dev/isxander/controlify/api/triggereffect/TriggerEffectApi.java +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.api.triggereffect; - -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.driver.dualsense.DualsenseTriggerEffect; -import net.minecraft.core.component.DataComponentType; -import net.minecraft.world.item.Item; -import org.jetbrains.annotations.NotNull; - -import java.util.function.Function; - -public final class TriggerEffectApi { - - private TriggerEffectApi() { - } - - public static void registerUseItemEffect(DataComponentType componentType, Function effectFunction) { - Controlify.instance().triggerEffectRegistry().registerUseItemComponentEffect(componentType, effectFunction); - } - - public static void registerUseItemEffect(DataComponentType componentType, @NotNull DualsenseTriggerEffect effect) { - registerUseItemEffect(componentType, _ -> effect); - } - - public static void registerSwingItemEffect(DataComponentType componentType, Function effectFunction) { - Controlify.instance().triggerEffectRegistry().registerSwingItemComponentEffect(componentType, effectFunction); - } - - public static void registerSwingItemEffect(DataComponentType componentType, @NotNull DualsenseTriggerEffect effect) { - registerSwingItemEffect(componentType, _ -> effect); - } - - public static void registerUseItemEffect(Item item, @NotNull DualsenseTriggerEffect effect) { - Controlify.instance().triggerEffectRegistry().registerUseItemEffect(item, effect); - } - - public static void registerSwingItemEffect(Item item, @NotNull DualsenseTriggerEffect effect) { - Controlify.instance().triggerEffectRegistry().registerSwingItemEffect(item, effect); - } -} diff --git a/src/main/java/dev/isxander/controlify/bindings/InputBindingImpl.java b/src/main/java/dev/isxander/controlify/bindings/InputBindingImpl.java index 874c333c3..58b9be40e 100644 --- a/src/main/java/dev/isxander/controlify/bindings/InputBindingImpl.java +++ b/src/main/java/dev/isxander/controlify/bindings/InputBindingImpl.java @@ -217,7 +217,7 @@ public Input boundInput() { if (configInput != null) { return configInput; } - // Not in config means it's at the default (when keepDefaultBindings is false) + // Not in config means it's at the default (require keepDefaultBindings is false) // Return the dynamic default so all controllers stay in sync return defaultInput(); } diff --git a/src/main/java/dev/isxander/controlify/config/dto/profile/GenericControllerConfig.java b/src/main/java/dev/isxander/controlify/config/dto/profile/GenericControllerConfig.java index 3135f2ebb..7eccea863 100644 --- a/src/main/java/dev/isxander/controlify/config/dto/profile/GenericControllerConfig.java +++ b/src/main/java/dev/isxander/controlify/config/dto/profile/GenericControllerConfig.java @@ -8,7 +8,7 @@ import com.mojang.serialization.Codec; import com.mojang.serialization.codecs.RecordCodecBuilder; -import dev.isxander.controlify.api.guide.GuideVerbosity; +import dev.isxander.controlify.api.contextual.GuideVerbosity; public record GenericControllerConfig( boolean autoJump, diff --git a/src/main/java/dev/isxander/controlify/config/settings/profile/GenericControllerSettings.java b/src/main/java/dev/isxander/controlify/config/settings/profile/GenericControllerSettings.java index 6613f8b21..278455d1a 100644 --- a/src/main/java/dev/isxander/controlify/config/settings/profile/GenericControllerSettings.java +++ b/src/main/java/dev/isxander/controlify/config/settings/profile/GenericControllerSettings.java @@ -6,7 +6,7 @@ */ package dev.isxander.controlify.config.settings.profile; -import dev.isxander.controlify.api.guide.GuideVerbosity; +import dev.isxander.controlify.api.contextual.GuideVerbosity; import dev.isxander.controlify.config.dto.profile.GenericControllerConfig; public class GenericControllerSettings { diff --git a/src/main/java/dev/isxander/controlify/contextual/ContextualDebug.java b/src/main/java/dev/isxander/controlify/contextual/ContextualDebug.java new file mode 100644 index 000000000..8f80d80c0 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/contextual/ContextualDebug.java @@ -0,0 +1,236 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.contextual; + +import dev.isxander.controlify.debug.DebugProperties; +import dev.isxander.controlify.utils.CUtil; +import net.minecraft.resources.Identifier; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.item.ItemInstance; +import net.minecraft.world.level.block.state.pattern.BlockInWorld; +import org.jetbrains.annotations.Nullable; + +import java.util.Collection; +import java.util.Comparator; +import java.util.Map; +import java.util.Set; +import java.util.StringJoiner; + +final class ContextualDebug { + private static final Comparator IDENTIFIER_ORDER = Comparator.comparing(Identifier::toString); + + private ContextualDebug() { + } + + static boolean enabled() { + return DebugProperties.DEBUG_CONTEXTUAL_RULES; + } + + static void logStateStart( + Identifier domain, + ContextualState state, + @Nullable Collection requiredFacts + ) { + if (!enabled()) { + return; + } + + CUtil.LOGGER.log( + "[Contextual/{}] Resolving state\n required facts: {}\n source facts: {}\n item slots: {}\n block slots: {}\n entity slots: {}", + domain, + describeIdentifiers(requiredFacts), + describeMap(state.facts(), String::valueOf), + describeMap(state.items(), ContextualDebug::describeItem), + describeMap(state.blocks(), ContextualDebug::describeBlock), + describeMap(state.entities(), ContextualDebug::describeEntity) + ); + } + + static void logStateComplete(Identifier domain, ContextualState state) { + if (!enabled()) { + return; + } + + CUtil.LOGGER.log( + "[Contextual/{}] Resolved state\n facts: {}", + domain, + describeMap(state.facts(), String::valueOf) + ); + } + + static PredicateEvaluation evaluatePredicate(ContextualPredicate predicate, ContextualState state) { + var trace = new StringBuilder(); + boolean result = evaluatePredicate(predicate, state, trace, 0); + return new PredicateEvaluation(result, trace.toString()); + } + + static void logFact( + Identifier domain, + Identifier fact, + Set dependencies, + PredicateEvaluation evaluation + ) { + if (!enabled()) { + return; + } + + CUtil.LOGGER.log( + "[Contextual/{}] Fact '{}' = {}\n dependencies: {}\n{}", + domain, + fact, + evaluation.result(), + describeIdentifiers(dependencies), + evaluation.trace() + ); + } + + static void logRule( + @Nullable String context, + int index, + Object key, + ContextualPredicate predicate, + ContextualState state, + String outcome, + @Nullable PredicateEvaluation evaluation + ) { + if (!enabled() || context == null) { + return; + } + + String trace = evaluation == null ? " predicate was not evaluated" : evaluation.trace(); + CUtil.LOGGER.log( + "[Contextual/{}] Rule #{} key={} -> {}\n contributing facts: {}\n predicate: {}\n{}", + context, + index, + key, + outcome, + describeFactValues(predicate.factDependencies(), state), + predicate, + trace + ); + } + + static void logRuleResult(@Nullable String context, Collection matches) { + if (enabled() && context != null) { + CUtil.LOGGER.log("[Contextual/{}] Winning rules: {}", context, matches); + } + } + + private static boolean evaluatePredicate( + ContextualPredicate predicate, + ContextualState state, + StringBuilder trace, + int depth + ) { + int start = trace.length(); + String indent = " ".repeat(depth + 1); + boolean result; + + if (predicate instanceof ContextualPredicate.Compound compound) { + boolean allOf = evaluateGroup("all_of", compound.allOf().orElse(Set.of()), state, trace, depth + 1, Group.ALL); + boolean noneOf = evaluateGroup("none_of", compound.noneOf().orElse(Set.of()), state, trace, depth + 1, Group.NONE); + boolean anyOf = compound.anyOf().isEmpty() + || evaluateGroup("any_of", compound.anyOf().get(), state, trace, depth + 1, Group.ANY); + result = allOf && noneOf && anyOf; + trace.insert(start, indent + "compound -> " + result + '\n'); + return result; + } + + result = predicate.matches(state); + String detail = switch (predicate) { + case ContextualPredicate.Fact fact -> "fact " + fact.fact() + "=" + state.facts().getOrDefault(fact.fact(), false); + case ContextualPredicate.Item item -> "item slot " + item.slot() + "=" + describeItem(state.items().get(item.slot())) + + ", predicate=" + item.item(); + case ContextualPredicate.Block block -> "block slot " + block.slot() + "=" + describeBlock(state.blocks().get(block.slot())) + + ", predicate=" + block.block(); + case ContextualPredicate.Entity entity -> "entity slot " + entity.slot() + "=" + describeEntity(state.entities().get(entity.slot())) + + ", predicate=" + entity.entity(); + case ContextualPredicate.Static staticPredicate -> "static " + staticPredicate.value(); + case ContextualPredicate.Compound _ -> throw new AssertionError(); + }; + trace.append(indent).append(detail).append(" -> ").append(result).append('\n'); + return result; + } + + private static boolean evaluateGroup( + String name, + Set predicates, + ContextualState state, + StringBuilder trace, + int depth, + Group group + ) { + if (predicates.isEmpty()) { + return true; + } + + String indent = " ".repeat(depth + 1); + trace.append(indent).append(name).append(':').append('\n'); + boolean any = false; + boolean all = true; + for (ContextualPredicate child : predicates) { + boolean childResult = evaluatePredicate(child, state, trace, depth + 1); + any |= childResult; + all &= childResult; + } + + return switch (group) { + case ALL -> all; + case NONE -> !any; + case ANY -> any; + }; + } + + private static String describeFactValues(Set facts, ContextualState state) { + var values = new StringJoiner(", ", "{", "}"); + facts.stream().sorted(IDENTIFIER_ORDER).forEach(id -> + values.add(id + "=" + state.facts().getOrDefault(id, false)) + ); + return values.toString(); + } + + private static String describeIdentifiers(@Nullable Collection identifiers) { + if (identifiers == null) { + return ""; + } + + var result = new StringJoiner(", ", "[", "]"); + identifiers.stream().sorted(IDENTIFIER_ORDER).forEach(id -> result.add(id.toString())); + return result.toString(); + } + + private static String describeMap(Map map, java.util.function.Function describer) { + var result = new StringJoiner(", ", "{", "}"); + map.entrySet().stream() + .sorted(Map.Entry.comparingByKey(IDENTIFIER_ORDER)) + .forEach(entry -> result.add(entry.getKey() + "=" + describer.apply(entry.getValue()))); + return result.toString(); + } + + private static String describeItem(@Nullable ItemInstance item) { + return item == null ? "" : item.toString(); + } + + private static String describeBlock(@Nullable BlockInWorld block) { + return block == null ? "" : block.getState() + " at " + block.getPos(); + } + + private static String describeEntity(@Nullable Entity entity) { + return entity == null + ? "" + : entity.getType() + "#" + entity.getId() + " ('" + entity.getDisplayName().getString() + "') at " + entity.position(); + } + + record PredicateEvaluation(boolean result, String trace) { + } + + private enum Group { + ALL, + NONE, + ANY + } +} diff --git a/src/main/java/dev/isxander/controlify/contextual/ContextualDomainImpl.java b/src/main/java/dev/isxander/controlify/contextual/ContextualDomainImpl.java new file mode 100644 index 000000000..045575ca7 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/contextual/ContextualDomainImpl.java @@ -0,0 +1,225 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.contextual; + +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.JsonOps; +import dev.isxander.controlify.api.contextual.*; +import dev.isxander.controlify.controller.dualsense.TriggerEffectInstanceImpl; +import dev.isxander.controlify.gui.guide.GuideInstanceImpl; +import dev.isxander.controlify.platform.client.resource.SimpleControlifyReloadListener; +import dev.isxander.controlify.utils.CUtil; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.multiplayer.CacheSlot; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.multiplayer.ClientRegistryLayer; +import net.minecraft.core.HolderLookup; +import net.minecraft.core.RegistryAccess; +import net.minecraft.resources.FileToIdConverter; +import net.minecraft.resources.Identifier; +import net.minecraft.server.packs.resources.Resource; +import net.minecraft.server.packs.resources.ResourceManager; +import net.minecraft.util.PlaceholderLookupProvider; +import net.minecraft.util.RegistryContextSwapper; +import org.jetbrains.annotations.Nullable; + +import java.io.BufferedReader; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; + +public final class ContextualDomainImpl implements ContextualDomain, SimpleControlifyReloadListener { + public static final String DIRECTORY = "contextual/facts"; + private static final FileToIdConverter converter = FileToIdConverter.json(DIRECTORY); + + private final Identifier id; + private final CacheSlot resolvedFactGraphs = new CacheSlot<>(this::resolveFactGraph); + private ContextualStateContributor contributor = ContextualStateContributor.noop(); + private volatile Preparations preparations = Preparations.EMPTY; + private FactDependencyGraph factGraphWithoutLevel = FactDependencyGraph.empty(); + + public ContextualDomainImpl(Identifier id) { + this.id = Objects.requireNonNull(id, "id"); + this.registerContributor(ContextualStateContributors.COMMON); + } + + @Override + public Identifier id() { + return this.id; + } + + @Override + public void registerContributor(ContextualStateContributor contributor) { + Objects.requireNonNull(contributor, "contributor"); + + this.contributor = this.contributor.andThen(contributor); + } + + @Override + public GuideInstance createGuideInstance(Font font) { + RuleSetManager ruleSetManager = RuleSetManager.GUIDE_RULES; + + return new GuideInstanceImpl<>(this, () -> this.requireRuleEngine(ruleSetManager, "guide"), font); + } + + @Override + public TriggerEffectInstance createTriggerEffectInstance() { + RuleSetManager ruleSetManager = RuleSetManager.TRIGGER_EFFECT_RULES; + + return new TriggerEffectInstanceImpl<>(this, () -> this.requireRuleEngine(ruleSetManager, "trigger effect")); + } + + private > RuleEngine requireRuleEngine(RuleSetManager ruleSetManager, String type) { + return ruleSetManager.getRuleEngine(Minecraft.getInstance().level, this.id) + .orElseThrow(() -> new IllegalCallerException("This contextual domain has no " + type + " rules associated with it.")); + } + + @Override + public CompletableFuture load(ResourceManager manager, Executor executor) { + return CompletableFuture.supplyAsync(() -> { + List resources = manager.getResourceStack(converter.idToFile(this.id)); + var lookup = new PlaceholderLookupProvider(RegistryAccess.EMPTY); + var ops = lookup.createSerializationContext(JsonOps.INSTANCE); + + var factsMap = new HashMap(); + for (Resource resource : resources) { + try (BufferedReader reader = resource.openAsReader()) { + JsonElement json = JsonParser.parseReader(reader); + DataResult> result = FactDefinition.LIST_CODEC.parse(ops, json); + var facts = result.getOrThrow(error -> new IllegalArgumentException("Failed to parse contextual facts: " + error)); + + for (FactDefinition fact : facts) { + factsMap.remove(fact.id()); + factsMap.put(fact.id(), fact); + } + } catch (Exception e) { + CUtil.LOGGER.error( + "Failed to load contextual facts for domain '{}' from pack '{}'; skipping this layer", + e, + this.id, + resource.sourcePackId() + ); + } + } + + List facts = List.copyOf(factsMap.values()); + RegistryContextSwapper registrySwapper = lookup.hasRegisteredPlaceholders() + ? lookup.createSwapper() + : null; + Preparations preparations = new Preparations(facts, FactDependencyGraph.empty(), registrySwapper); + try { + FactDependencyGraph factGraph = resolveFactGraph( + preparations, + ClientRegistryLayer.createRegistryAccess().compositeAccess(), + false + ); + return new Preparations(facts, factGraph, registrySwapper); + } catch (Exception e) { + CUtil.LOGGER.error( + "Failed to compile contextual facts for domain '{}'; keeping the previous definitions", + e, + this.id + ); + return this.preparations; + } + }, executor); + } + + @Override + public CompletableFuture apply(Preparations data, ResourceManager manager, Executor executor) { + return CompletableFuture.runAsync(() -> this.apply(data), executor); + } + + private synchronized void apply(Preparations data) { + this.preparations = data; + this.factGraphWithoutLevel = data.factGraphWithoutLevel(); + this.resolvedFactGraphs.clear(); + } + + public synchronized void invalidateResolvedFactGraph() { + this.resolvedFactGraphs.clear(); + } + + private FactDependencyGraph resolveFactGraph(ClientLevel level) { + Preparations preparations = this.preparations; + try { + return resolveFactGraph(preparations, level.registryAccess(), true); + } catch (Exception e) { + CUtil.LOGGER.error( + "Failed to resolve contextual facts for domain '{}'; using client-static definitions", + e, + this.id + ); + return preparations.factGraphWithoutLevel(); + } + } + + private FactDependencyGraph resolveFactGraph( + Preparations preparations, + HolderLookup.Provider registries, + boolean logFailures + ) { + if (preparations.registrySwapper() == null) { + return FactDependencyGraph.compile(preparations.facts()); + } + + var resolvedFacts = new ArrayList(preparations.facts().size()); + for (FactDefinition fact : preparations.facts()) { + try { + ContextualPredicate predicate = preparations.registrySwapper() + .swapTo(ContextualPredicate.CODEC, fact.predicate(), registries) + .getOrThrow(); + resolvedFacts.add(new FactDefinition(fact.id(), predicate)); + } catch (Exception e) { + if (logFailures) { + CUtil.LOGGER.warn( + "Failed to resolve contextual fact '{}' for domain '{}'; leaving its registry references unresolved", + e, + fact.id(), + this.id + ); + } + resolvedFacts.add(fact); + } + } + + return FactDependencyGraph.compile(resolvedFacts); + } + + private synchronized FactDependencyGraph currentFactGraph(@Nullable ClientLevel level) { + return level == null ? this.factGraphWithoutLevel : this.resolvedFactGraphs.compute(level); + } + + @Override + public Identifier getReloadId() { + return this.id.withPrefix("reload/"); + } + + public ContextualState calculateState(C context, @Nullable Collection requiredFacts) { + Objects.requireNonNull(context, "context"); + + ContextualStateAccumulator state = new ContextualStateAccumulator(); + this.contributor.contribute(context, state); + ContextualDebug.logStateStart(this.id, state.snapshot(), requiredFacts); + + FactDependencyGraph graph = this.currentFactGraph(Minecraft.getInstance().level); + ContextualState resolvedState = graph.evaluate(state, requiredFacts, this.id); + ContextualDebug.logStateComplete(this.id, resolvedState); + return resolvedState; + } + + public record Preparations( + List facts, + FactDependencyGraph factGraphWithoutLevel, + @Nullable RegistryContextSwapper registrySwapper + ) { + private static final Preparations EMPTY = new Preparations(List.of(), FactDependencyGraph.empty(), null); + } +} diff --git a/src/main/java/dev/isxander/controlify/contextual/ContextualDomains.java b/src/main/java/dev/isxander/controlify/contextual/ContextualDomains.java new file mode 100644 index 000000000..f69f272b0 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/contextual/ContextualDomains.java @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.contextual; + +import dev.isxander.controlify.api.contextual.ContainerContext; +import dev.isxander.controlify.api.contextual.InGameContext; +import dev.isxander.controlify.api.contextual.Context; +import dev.isxander.controlify.api.contextual.ContextualDomain; +import dev.isxander.controlify.api.contextual.ContextualDomainRegistry; +import dev.isxander.controlify.platform.client.PlatformClientUtil; +import dev.isxander.controlify.utils.CUtil; +import net.minecraft.resources.Identifier; + +import java.util.ArrayList; +import java.util.List; + +public final class ContextualDomains implements ContextualDomainRegistry { + + public static final ContextualDomains INSTANCE = new ContextualDomains(); + + private final List> domains = new ArrayList<>(); + private final ContextualDomain inGame = register( + CUtil.rl("in_game"), + ContextualStateContributors.IN_GAME + ); + private final ContextualDomain container = register( + CUtil.rl("container"), + ContextualStateContributors.CONTAINER + ); + + @Override + public ContextualDomain inGame() { + return this.inGame; + } + + @Override + public ContextualDomain container() { + return this.container; + } + + @Override + public ContextualDomain register(Identifier domainId) { + var domain = new ContextualDomainImpl(domainId); + this.domains.add(domain); + PlatformClientUtil.registerAssetReloadListener(domain); + return domain; + } + + public void invalidateResolvedFactGraphs() { + this.domains.forEach(ContextualDomainImpl::invalidateResolvedFactGraph); + } + + public static void touch() {} + + private ContextualDomains() { + } +} diff --git a/src/main/java/dev/isxander/controlify/contextual/ContextualPredicate.java b/src/main/java/dev/isxander/controlify/contextual/ContextualPredicate.java new file mode 100644 index 000000000..b1d674da5 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/contextual/ContextualPredicate.java @@ -0,0 +1,194 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.contextual; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import dev.isxander.controlify.utils.codec.CExtraCodecs; +import dev.isxander.controlify.utils.predicates.ClientEntityPredicate; +import dev.isxander.controlify.utils.predicates.ClientPredicateUtils; +//? if >=26.2 { +import net.minecraft.advancements.predicates.*; +//?} else { +/*import net.minecraft.advancements.criterion.*; +*///?} +import net.minecraft.client.Minecraft; +import net.minecraft.resources.Identifier; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public sealed interface ContextualPredicate { + Codec CODEC = Codec.recursive("ContextualPredicate", self -> { + Codec compoundCodec = Compound.createCodec(self); + + return CExtraCodecs.fuzzy( + List.of(Fact.CODEC, Item.CODEC, Block.CODEC, Entity.CODEC, compoundCodec, Static.CODEC), + predicate -> switch (predicate) { + case Fact _ -> Fact.CODEC; + case Item _ -> Item.CODEC; + case Block _ -> Block.CODEC; + case Entity _ -> Entity.CODEC; + case Compound _ -> compoundCodec; + case Static _ -> Static.CODEC; + } + ); + }); + + Set factDependencies(); + + boolean matches(ContextualState state); + + record Compound( + Optional> allOf, + Optional> noneOf, + Optional> anyOf + ) implements ContextualPredicate { + private static Codec createCodec(Codec childCodec) { + Codec codec = RecordCodecBuilder.create(instance -> instance.group( + CExtraCodecs.set(childCodec).optionalFieldOf("all_of").forGetter(Compound::allOf), + CExtraCodecs.set(childCodec).optionalFieldOf("none_of").forGetter(Compound::noneOf), + CExtraCodecs.set(childCodec).optionalFieldOf("any_of").forGetter(Compound::anyOf) + ).apply(instance, Compound::new)); + + return codec.validate(compound -> { + if (compound.allOf.isEmpty() && compound.noneOf.isEmpty() && compound.anyOf.isEmpty()) { + return DataResult.error(() -> "Compound predicate must define at least one selector: 'all_of', 'none_of', 'any_of'"); + } + + return DataResult.success(compound); + }); + } + + @Override + public boolean matches(ContextualState state) { + if (this.allOf.isPresent() && !this.allOf.get().stream().allMatch(predicate -> predicate.matches(state))) { + return false; + } + + if (this.noneOf.isPresent() && this.noneOf.get().stream().anyMatch(predicate -> predicate.matches(state))) { + return false; + } + + if (this.anyOf.isPresent() && this.anyOf.get().stream().noneMatch(predicate -> predicate.matches(state))) { + return false; + } + + return true; + } + + @Override + public Set factDependencies() { + return Stream.concat(Stream.concat(this.allOf.stream(), this.noneOf.stream()), this.anyOf.stream()) + .flatMap(Collection::stream) + .flatMap(predicate -> predicate.factDependencies().stream()) + .collect(Collectors.toUnmodifiableSet()); + } + } + + record Fact(Identifier fact) implements ContextualPredicate { + private static final Codec FULL_CODEC = RecordCodecBuilder.create(instance -> instance.group( + Identifier.CODEC.fieldOf("fact").forGetter(Fact::fact) + ).apply(instance, Fact::new)); + + private static final Codec COMPACT_CODEC = Identifier.CODEC.xmap(Fact::new, Fact::fact); + + private static final Codec CODEC = FULL_CODEC.withAlternative(COMPACT_CODEC); + + @Override + public boolean matches(ContextualState state) { + return state.facts().getOrDefault(fact(), false); + } + + @Override + public Set factDependencies() { + return Set.of(fact()); + } + } + + record Item(Identifier slot, ItemPredicate item) implements ContextualPredicate { + private static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + Identifier.CODEC.fieldOf("slot").forGetter(Item::slot), + ItemPredicate.CODEC.fieldOf("item").forGetter(Item::item) + ).apply(instance, Item::new)); + + @Override + public boolean matches(ContextualState state) { + var itemInstance = state.items().get(slot()); + if (itemInstance == null) { + return false; + } + return ClientPredicateUtils.matches(item(), itemInstance); + } + + @Override + public Set factDependencies() { + return Set.of(); + } + } + + record Block(Identifier slot, BlockPredicate block) implements ContextualPredicate { + private static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + Identifier.CODEC.fieldOf("slot").forGetter(Block::slot), + BlockPredicate.CODEC.fieldOf("block").forGetter(Block::block) + ).apply(instance, Block::new)); + + @Override + public boolean matches(ContextualState state) { + var blockInWorld = state.blocks().get(slot()); + if (blockInWorld == null) { + return false; + } + return block().matches(blockInWorld); + } + + @Override + public Set factDependencies() { + return Set.of(); + } + } + + record Entity(Identifier slot, ClientEntityPredicate entity) implements ContextualPredicate { + private static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + Identifier.CODEC.fieldOf("slot").forGetter(Entity::slot), + ClientEntityPredicate.CODEC.fieldOf("entity").forGetter(Entity::entity) + ).apply(instance, Entity::new)); + + @Override + public boolean matches(ContextualState state) { + var entityInWorld = state.entities().get(slot()); + if (entityInWorld == null) { + return false; + } + return entity().matches(Minecraft.getInstance().player, entityInWorld); + } + + @Override + public Set factDependencies() { + return Set.of(); + } + } + + record Static(boolean value) implements ContextualPredicate { + private static final Codec CODEC = Codec.BOOL.xmap(Static::new, Static::value); + + @Override + public boolean matches(ContextualState state) { + return value(); + } + + @Override + public Set factDependencies() { + return Set.of(); + } + } +} diff --git a/src/main/java/dev/isxander/controlify/contextual/ContextualState.java b/src/main/java/dev/isxander/controlify/contextual/ContextualState.java new file mode 100644 index 000000000..5e36298c8 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/contextual/ContextualState.java @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.contextual; + +import net.minecraft.resources.Identifier; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.item.ItemInstance; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.state.pattern.BlockInWorld; + +import java.util.Map; + +public record ContextualState( + Map facts, + Map items, + Map blocks, + Map entities +) { +} diff --git a/src/main/java/dev/isxander/controlify/contextual/ContextualStateAccumulator.java b/src/main/java/dev/isxander/controlify/contextual/ContextualStateAccumulator.java new file mode 100644 index 000000000..a43ea2778 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/contextual/ContextualStateAccumulator.java @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.contextual; + +import dev.isxander.controlify.api.contextual.ContextualStateSink; +import net.minecraft.resources.Identifier; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.item.ItemInstance; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.state.pattern.BlockInWorld; +import org.jetbrains.annotations.Nullable; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +public final class ContextualStateAccumulator implements ContextualStateSink { + private final Map facts = new LinkedHashMap<>(); + private final Map items = new LinkedHashMap<>(); + private final Map blocks = new LinkedHashMap<>(); + private final Map entities = new LinkedHashMap<>(); + + public ContextualStateAccumulator() { + } + + public ContextualStateAccumulator(ContextualState state) { + this.facts.putAll(state.facts()); + this.items.putAll(state.items()); + this.blocks.putAll(state.blocks()); + this.entities.putAll(state.entities()); + } + + @Override + public void contributeFact(Identifier id, boolean value) { + this.facts.put(Objects.requireNonNull(id, "id"), value); + } + + @Override + public void contributeItem(Identifier slot, @Nullable ItemInstance item) { + if (item == null) { + return; + } + this.items.put(Objects.requireNonNull(slot, "slot"), item); + } + + @Override + public void contributeBlock(Identifier slot, @Nullable BlockInWorld block) { + if (block == null) { + return; + } + this.blocks.put(Objects.requireNonNull(slot, "slot"), block); + } + + @Override + public void contributeEntity(Identifier slot, @Nullable Entity entity) { + if (entity == null) { + return; + } + this.entities.put(Objects.requireNonNull(slot, "slot"), entity); + } + + public ContextualState view() { + return new ContextualState( + Collections.unmodifiableMap(this.facts), + Collections.unmodifiableMap(this.items), + Collections.unmodifiableMap(this.blocks), + Collections.unmodifiableMap(this.entities) + ); + } + + public ContextualState snapshot() { + return new ContextualState( + Map.copyOf(this.facts), + Map.copyOf(this.items), + Map.copyOf(this.blocks), + Map.copyOf(this.entities) + ); + } +} diff --git a/src/main/java/dev/isxander/controlify/contextual/ContextualStateContributors.java b/src/main/java/dev/isxander/controlify/contextual/ContextualStateContributors.java new file mode 100644 index 000000000..f2a0ad0a4 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/contextual/ContextualStateContributors.java @@ -0,0 +1,161 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.contextual; + +import dev.isxander.controlify.api.contextual.*; +import dev.isxander.controlify.mixins.feature.guide.ingame.PlayerAccessor; +import dev.isxander.controlify.utils.CUtil; +import net.minecraft.resources.Identifier; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.BlockItem; +import net.minecraft.world.item.BundleItem; +import net.minecraft.world.level.block.state.pattern.BlockInWorld; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.EntityHitResult; +import net.minecraft.world.phys.HitResult; +import net.minecraft.world.phys.Vec2; + +public final class ContextualStateContributors { + private static final Identifier SLOT_SELF = CUtil.rl("self"); + private static final Identifier SLOT_HIT_RESULT = CUtil.rl("hit_result"); + private static final Identifier SLOT_MAIN_HAND = CUtil.rl("main_hand"); + private static final Identifier SLOT_OFF_HAND = CUtil.rl("off_hand"); + private static final Identifier SLOT_ACTIVE_ITEM = CUtil.rl("active_item"); + private static final Identifier FACT_MAIN_HAND_IS_BLOCK_ITEM = CUtil.rl("main_hand_is_block_item"); + private static final Identifier FACT_OFF_HAND_IS_BLOCK_ITEM = CUtil.rl("off_hand_is_block_item"); + private static final Identifier FACT_FISHING_HOOK_CAST = CUtil.rl("fishing_hook_cast"); + private static final Identifier FACT_ON_CLIMBABLE = CUtil.rl("on_climbable"); + + private static final Identifier SLOT_HOVERING_SLOT = CUtil.rl("hovering_slot"); + private static final Identifier SLOT_HOLDING_ITEM = CUtil.rl("holding_item"); + private static final Identifier SLOT_BUNDLE_SELECTED = CUtil.rl("bundle_selected"); + + public static final ContextualStateContributor COMMON = (context, sink) -> { + sink.contributeFact( + CUtil.rl("verbosity_full"), + context.verbosity() == GuideVerbosity.FULL + ); + sink.contributeFact( + CUtil.rl("verbosity_reduced"), + context.verbosity().getLevel() >= GuideVerbosity.REDUCED.getLevel() + ); + sink.contributeFact( + CUtil.rl("verbosity_minimal"), + context.verbosity().getLevel() >= GuideVerbosity.MINIMAL.getLevel() + ); + }; + + public static final ContextualStateContributor IN_GAME = (context, sink) -> { + switch (context.hitResult()) { + case BlockHitResult hitResult when hitResult.getType() == HitResult.Type.BLOCK -> { + sink.contributeBlock( + SLOT_HIT_RESULT, + new BlockInWorld(context.level(), hitResult.getBlockPos(), false) + ); + } + case EntityHitResult hitResult -> { + sink.contributeEntity( + SLOT_HIT_RESULT, + hitResult.getEntity() + ); + } + default -> {} + } + + sink.contributeEntity(SLOT_SELF, context.player()); + + sink.contributeItem(SLOT_MAIN_HAND, context.player().getMainHandItem()); + sink.contributeItem(SLOT_OFF_HAND, context.player().getOffhandItem()); + sink.contributeItem(SLOT_ACTIVE_ITEM, context.player().getActiveItem()); + sink.contributeFact( + FACT_MAIN_HAND_IS_BLOCK_ITEM, + context.player().getMainHandItem().getItem() instanceof BlockItem + ); + sink.contributeFact( + FACT_OFF_HAND_IS_BLOCK_ITEM, + context.player().getOffhandItem().getItem() instanceof BlockItem + ); + sink.contributeFact(FACT_FISHING_HOOK_CAST, context.player().fishing != null); + sink.contributeFact(FACT_ON_CLIMBABLE, context.player().onClimbable()); + + sink.contributeFact( + CUtil.rl("can_elytra_fly"), + ((PlayerAccessor) context.player()).controlify$callCanGlide() + && !context.player().onClimbable() + && !context.player().onGround() + && !context.player().isInLiquid() + && !context.player().isFallFlying() + ); + + sink.contributeFact( + CUtil.rl("in_liquid"), + context.player().isInLiquid() + ); + + sink.contributeFact( + CUtil.rl("under_water"), + context.player().isUnderWater() + ); + + sink.contributeFact( + CUtil.rl("is_toggle_sneak"), + context.controller().settings().generic.toggleSneak + ); + sink.contributeFact( + CUtil.rl("is_toggle_sprint"), + context.controller().settings().generic.toggleSprint + ); + sink.contributeFact( + CUtil.rl("input_moving"), + !context.player().input.getMoveVector().equals(Vec2.ZERO) + ); + + sink.contributeFact( + CUtil.rl("has_hearts"), + !context.player().getAbilities().invulnerable + ); + }; + + public static final ContextualStateContributor CONTAINER = (context, sink) -> { + if (context.hoveredSlot() != null) { + Slot hoveredSlot = context.hoveredSlot(); + + sink.contributeItem( + SLOT_HOVERING_SLOT, + hoveredSlot.getItem() + ); + + sink.contributeFact( + CUtil.rl("can_place_held_item"), + hoveredSlot.mayPlace(context.holdingItem()) + ); + + sink.contributeFact( + CUtil.rl("can_pickup_slot"), + hoveredSlot.mayPickup(context.player()) + ); + + sink.contributeItem( + SLOT_BUNDLE_SELECTED, + BundleItem.getSelectedItem(hoveredSlot.getItem()) + ); + } + + sink.contributeItem( + SLOT_HOLDING_ITEM, + context.holdingItem() + ); + + sink.contributeFact( + CUtil.rl("cursor_outside_container"), + context.cursorOutsideContainer() + ); + }; + + private ContextualStateContributors() { + } +} diff --git a/src/main/java/dev/isxander/controlify/contextual/CyclicFactDependencyException.java b/src/main/java/dev/isxander/controlify/contextual/CyclicFactDependencyException.java new file mode 100644 index 000000000..7fa8df3f1 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/contextual/CyclicFactDependencyException.java @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.contextual; + +import net.minecraft.resources.Identifier; + +import java.util.List; +import java.util.stream.Collectors; + +public class CyclicFactDependencyException extends IllegalArgumentException { + private final List cycle; + + public CyclicFactDependencyException(List cycle) { + super("Cyclic contextual fact dependency: " + cycle.stream() + .map(Identifier::toString) + .collect(Collectors.joining(" -> "))); + this.cycle = List.copyOf(cycle); + } + + /// The cycle in traversal order, with the first identifier repeated at the end. + public List cycle() { + return this.cycle; + } +} diff --git a/src/main/java/dev/isxander/controlify/contextual/FactDefinition.java b/src/main/java/dev/isxander/controlify/contextual/FactDefinition.java new file mode 100644 index 000000000..32b4782ca --- /dev/null +++ b/src/main/java/dev/isxander/controlify/contextual/FactDefinition.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.contextual; + +import com.mojang.serialization.Codec; +import net.minecraft.resources.Identifier; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +public record FactDefinition( + Identifier id, + ContextualPredicate predicate +) { + public static final Codec> LIST_CODEC = Codec.unboundedMap(Identifier.CODEC, ContextualPredicate.CODEC) + .xmap( + map -> map.entrySet().stream() + .map(entry -> new FactDefinition(entry.getKey(), entry.getValue())) + .toList(), + defs -> defs.stream() + .collect(Collectors.toMap( + FactDefinition::id, + FactDefinition::predicate, + (first, second) -> second, + LinkedHashMap::new + )) + ); + + public static Map toMap(List list) { + return list.stream() + .collect(Collectors.toMap( + FactDefinition::id, + Function.identity(), + (first, second) -> second, + LinkedHashMap::new + )); + } +} diff --git a/src/main/java/dev/isxander/controlify/contextual/FactDependencyGraph.java b/src/main/java/dev/isxander/controlify/contextual/FactDependencyGraph.java new file mode 100644 index 000000000..bdfdae8c7 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/contextual/FactDependencyGraph.java @@ -0,0 +1,218 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.contextual; + +import net.minecraft.resources.Identifier; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/// A compiled evaluation plan for data-defined facts. +/// +/// References to other data-defined facts are graph edges. References which do not have a +/// definition are treated as source facts and are expected to be supplied by contributors. +public final class FactDependencyGraph { + private static final Comparator IDENTIFIER_ORDER = Comparator.comparing(Identifier::toString); + private static final FactDependencyGraph EMPTY = new FactDependencyGraph(Map.of(), Map.of(), List.of(), Set.of()); + + private final Map definitions; + private final Map> dependencies; + private final List evaluationOrder; + private final Set externalDependencies; + private final Map, List> evaluationPlans = new ConcurrentHashMap<>(); + + private FactDependencyGraph( + Map definitions, + Map> dependencies, + List evaluationOrder, + Set externalDependencies + ) { + this.definitions = definitions; + this.dependencies = dependencies; + this.evaluationOrder = evaluationOrder; + this.externalDependencies = externalDependencies; + } + + public static FactDependencyGraph empty() { + return EMPTY; + } + + /// Compiles fact definitions into a deterministic dependency-first evaluation order. + /// + /// @throws IllegalArgumentException if a definition id is duplicated + /// @throws CyclicFactDependencyException if the definitions contain a dependency cycle + public static FactDependencyGraph compile(Collection factDefinitions) { + Objects.requireNonNull(factDefinitions, "factDefinitions"); + + // put all definitions in a map + Map definitions = new LinkedHashMap<>(); + for (FactDefinition definition : factDefinitions) { + Objects.requireNonNull(definition, "factDefinition"); + Objects.requireNonNull(definition.id(), "factDefinition.id"); + Objects.requireNonNull(definition.predicate(), "factDefinition.predicate"); + if (definitions.putIfAbsent(definition.id(), definition) != null) { + throw new IllegalArgumentException("Duplicate contextual fact definition: " + definition.id()); + } + } + + // short circuit to empty instance + if (definitions.isEmpty()) { + return empty(); + } + + // get dependencies of each fact in a stable order + Map> dependencies = new LinkedHashMap<>(); + for (FactDefinition definition : definitions.values()) { + List sortedDependencies = new ArrayList<>(definition.predicate().factDependencies()); + sortedDependencies.sort(IDENTIFIER_ORDER); // ensure stable order + dependencies.put(definition.id(), Collections.unmodifiableSet(new LinkedHashSet<>(sortedDependencies))); + } + + // compile evaluation order for dependencies + Compiler compiler = new Compiler(definitions, dependencies); + for (Identifier id : definitions.keySet()) { + compiler.visit(id); // throws CyclicFactDependencyException + } + + return new FactDependencyGraph( + Collections.unmodifiableMap(new LinkedHashMap<>(definitions)), + Collections.unmodifiableMap(new LinkedHashMap<>(dependencies)), + List.copyOf(compiler.evaluationOrder), + Collections.unmodifiableSet(new LinkedHashSet<>(compiler.externalDependencies)) + ); + } + + public Map definitions() { + return this.definitions; + } + + /// Direct fact references for each data-defined fact, including source-fact references. + public Map> dependencies() { + return this.dependencies; + } + + /// Data-defined fact ids ordered so every data-defined dependency precedes its consumer. + public List evaluationOrder() { + return this.evaluationOrder; + } + + /// Referenced facts which have no data definition and must be supplied by contributors. + public Set externalDependencies() { + return this.externalDependencies; + } + + /// Resolves required data-defined facts and their transitive dependencies on top of a source state. + public ContextualState evaluate(ContextualStateAccumulator state, @Nullable Collection requiredFacts) { + return this.evaluate(state, requiredFacts, null); + } + + ContextualState evaluate( + ContextualStateAccumulator state, + @Nullable Collection requiredFacts, + @Nullable Identifier debugDomain + ) { + ContextualState view = state.view(); + for (Identifier id : this.evaluationPlan(requiredFacts)) { + FactDefinition definition = this.definitions().get(id); + if (debugDomain != null && ContextualDebug.enabled()) { + ContextualDebug.PredicateEvaluation evaluation = ContextualDebug.evaluatePredicate(definition.predicate(), view); + state.contributeFact(id, evaluation.result()); + ContextualDebug.logFact(debugDomain, id, this.dependencies.get(id), evaluation); + } else { + state.contributeFact(id, definition.predicate().matches(view)); + } + } + return state.snapshot(); + } + + private List evaluationPlan(@Nullable Collection requiredFacts) { + if (requiredFacts == null) { + return this.evaluationOrder; + } + if (requiredFacts.isEmpty() || this.evaluationOrder.isEmpty()) { + return List.of(); + } + + return this.evaluationPlans.computeIfAbsent(Set.copyOf(requiredFacts), this::createEvaluationPlan); + } + + private List createEvaluationPlan(Set requiredFacts) { + Set requiredClosure = new HashSet<>(requiredFacts); + for (int index = this.evaluationOrder.size() - 1; index >= 0; index--) { + Identifier id = this.evaluationOrder.get(index); + if (requiredClosure.contains(id)) { + requiredClosure.addAll(this.dependencies.get(id)); + } + } + + return this.evaluationOrder.stream() + .filter(requiredClosure::contains) + .toList(); + } + + private static final class Compiler { + private final Map definitions; + private final Map> dependencies; + private final Map visitStates = new HashMap<>(); + private final List path = new ArrayList<>(); + private final List evaluationOrder = new ArrayList<>(); + private final Set externalDependencies = new LinkedHashSet<>(); + + private Compiler( + Map definitions, + Map> dependencies + ) { + this.definitions = definitions; + this.dependencies = dependencies; + } + + private void visit(Identifier id) throws CyclicFactDependencyException { + VisitState state = this.visitStates.get(id); + if (state == VisitState.VISITED) { + return; + } + if (state == VisitState.VISITING) { + int cycleStart = this.path.indexOf(id); + List cycle = new ArrayList<>(this.path.subList(cycleStart, this.path.size())); + cycle.add(id); + throw new CyclicFactDependencyException(cycle); + } + + this.visitStates.put(id, VisitState.VISITING); + this.path.add(id); + + for (Identifier dependency : this.dependencies.get(id)) { + if (this.definitions.containsKey(dependency)) { + this.visit(dependency); + } else { + this.externalDependencies.add(dependency); + } + } + + this.path.removeLast(); + this.visitStates.put(id, VisitState.VISITED); + this.evaluationOrder.add(id); + } + + private enum VisitState { + VISITING, + VISITED + } + } +} diff --git a/src/main/java/dev/isxander/controlify/contextual/GuideRule.java b/src/main/java/dev/isxander/controlify/contextual/GuideRule.java new file mode 100644 index 000000000..8da435544 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/contextual/GuideRule.java @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.contextual; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import dev.isxander.controlify.api.bind.InputBindingSupplier; +import dev.isxander.controlify.api.contextual.GuideLocation; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.ComponentSerialization; +import net.minecraft.resources.Identifier; + +public record GuideRule( + InputBindingSupplier binding, + GuideLocation location, + ContextualPredicate predicate, + Component text +) implements Rule { + public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + InputBindingSupplier.CODEC.fieldOf("for").forGetter(GuideRule::binding), + GuideLocation.CODEC.fieldOf("where").forGetter(GuideRule::location), + ContextualPredicate.CODEC.fieldOf("if").forGetter(GuideRule::predicate), + ComponentSerialization.CODEC.fieldOf("then").forGetter(GuideRule::text) + ).apply(instance, GuideRule::new)); + + @Override + public Key key() { + return new Key(this.binding().bindId(), this.location()); + } + + public record Key( + Identifier binding, + GuideLocation location + ) {} +} diff --git a/src/main/java/dev/isxander/controlify/contextual/Rule.java b/src/main/java/dev/isxander/controlify/contextual/Rule.java new file mode 100644 index 000000000..9c092d6cc --- /dev/null +++ b/src/main/java/dev/isxander/controlify/contextual/Rule.java @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.contextual; + +public interface Rule { + /// Used to identify when multiple rules are overriding each other. + /// + /// For example, with guides this would be the binding and location. + /// + /// Allows deduplication and short-circuiting done by the rule engine. + K key(); + + ContextualPredicate predicate(); + + default boolean matches(ContextualState state) { + return this.predicate().matches(state); + } +} diff --git a/src/main/java/dev/isxander/controlify/contextual/RuleEngine.java b/src/main/java/dev/isxander/controlify/contextual/RuleEngine.java new file mode 100644 index 000000000..3c7fcfc26 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/contextual/RuleEngine.java @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.contextual; + +import net.minecraft.resources.Identifier; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.function.Predicate; + +/// An ordered set of contextual rules for one output type. +/// +/// Rules are considered in list order. The first matching rule for a key wins; a rule which does +/// not match does not consume its key, allowing a lower-priority fallback for that key to match. +public final class RuleEngine> { + private final List rules; + private final Set factDependencies; + + public RuleEngine(List rules) { + Objects.requireNonNull(rules, "rules"); + + List copiedRules = new ArrayList<>(rules.size()); + Set factDependencies = new LinkedHashSet<>(); + for (R rule : rules) { + Objects.requireNonNull(rule, "rule"); + Objects.requireNonNull(rule.key(), "rule.key"); + Objects.requireNonNull(rule.predicate(), "rule.predicate"); + copiedRules.add(rule); + factDependencies.addAll(rule.predicate().factDependencies()); + } + + this.rules = List.copyOf(copiedRules); + this.factDependencies = Set.copyOf(factDependencies); + } + + public List rules() { + return this.rules; + } + + public Set factDependencies() { + return this.factDependencies; + } + + /// Returns the winning rules in their original precedence order. + public List evaluate(ContextualState state, Predicate shouldEvaluate) { + return this.evaluate(state, shouldEvaluate, null); + } + + public List evaluate(ContextualState state, Predicate shouldEvaluate, @Nullable String debugContext) { + Objects.requireNonNull(state, "state"); + + List matches = new ArrayList<>(); + Set consumedKeys = new HashSet<>(); + for (int index = 0; index < this.rules.size(); index++) { + R rule = this.rules.get(index); + if (!shouldEvaluate.test(rule)) { + ContextualDebug.logRule(debugContext, index, rule.key(), rule.predicate(), state, "FILTERED", null); + continue; + } + + K key = rule.key(); + if (consumedKeys.contains(key)) { + ContextualDebug.logRule(debugContext, index, key, rule.predicate(), state, "SKIPPED (key already won)", null); + continue; + } + + boolean matchesRule; + ContextualDebug.PredicateEvaluation evaluation = null; + if (ContextualDebug.enabled() && debugContext != null) { + evaluation = ContextualDebug.evaluatePredicate(rule.predicate(), state); + matchesRule = evaluation.result(); + } else { + matchesRule = rule.matches(state); + } + + if (matchesRule) { + consumedKeys.add(key); + matches.add(rule); + } + ContextualDebug.logRule( + debugContext, + index, + key, + rule.predicate(), + state, + matchesRule ? "MATCHED" : "DID NOT MATCH", + evaluation + ); + } + + List result = List.copyOf(matches); + ContextualDebug.logRuleResult(debugContext, result); + return result; + } + + public List evaluate(ContextualState state) { + return this.evaluate(state, _ -> true); + } +} diff --git a/src/main/java/dev/isxander/controlify/contextual/RuleSet.java b/src/main/java/dev/isxander/controlify/contextual/RuleSet.java new file mode 100644 index 000000000..0f957ba16 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/contextual/RuleSet.java @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.contextual; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; + +import java.util.List; + +public record RuleSet>( + boolean replace, + List rules +) { + public static > Codec> createCodec(Codec ruleCodec) { + return RecordCodecBuilder.create(instance -> instance.group( + Codec.BOOL.optionalFieldOf("replace", false).forGetter(RuleSet::replace), + ruleCodec.listOf().fieldOf("rules").forGetter(RuleSet::rules) + ).apply(instance, RuleSet::new)); + } +} diff --git a/src/main/java/dev/isxander/controlify/contextual/RuleSetManager.java b/src/main/java/dev/isxander/controlify/contextual/RuleSetManager.java new file mode 100644 index 000000000..238adf9db --- /dev/null +++ b/src/main/java/dev/isxander/controlify/contextual/RuleSetManager.java @@ -0,0 +1,195 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.contextual; + +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.DynamicOps; +import com.mojang.serialization.JsonOps; +import dev.isxander.controlify.platform.client.resource.SimpleControlifyReloadListener; +import dev.isxander.controlify.utils.CUtil; +import net.minecraft.client.multiplayer.CacheSlot; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.multiplayer.ClientRegistryLayer; +import net.minecraft.core.HolderLookup; +import net.minecraft.core.RegistryAccess; +import net.minecraft.resources.FileToIdConverter; +import net.minecraft.resources.Identifier; +import net.minecraft.server.packs.resources.Resource; +import net.minecraft.server.packs.resources.ResourceManager; +import net.minecraft.util.PlaceholderLookupProvider; +import net.minecraft.util.RegistryContextSwapper; +import org.jetbrains.annotations.Nullable; + +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; + +public class RuleSetManager> implements SimpleControlifyReloadListener> { + public static final RuleSetManager GUIDE_RULES = + new RuleSetManager<>(GuideRule.CODEC, "guide"); + public static final RuleSetManager TRIGGER_EFFECT_RULES = + new RuleSetManager<>(TriggerEffectRule.CODEC, "trigger_effect"); + + private static final String DIRECTORY = "contextual/"; + + private final Codec> ruleSetCodec; + private final Codec ruleCodec; + private final String directory; + private final FileToIdConverter converter; + private final CacheSlot>> resolvedRuleEngines = + new CacheSlot<>(this::resolveRuleEngines); + + private volatile Preparations preparations; + private Map> ruleEnginesWithoutLevel; + + public RuleSetManager(Codec ruleCodec, String directory) { + this.ruleSetCodec = RuleSet.createCodec(ruleCodec); + this.ruleCodec = ruleCodec; + this.directory = DIRECTORY + directory; + this.converter = FileToIdConverter.json(this.directory); + this.preparations = new Preparations<>(Map.of(), null); + this.ruleEnginesWithoutLevel = Map.of(); + } + + /// With no level, the engine uses the client-static registry context captured during reload. + /// References to remote registries remain placeholders until the rules are requested with a level. + public synchronized Optional> getRuleEngine(@Nullable ClientLevel level, Identifier domainId) { + Objects.requireNonNull(domainId, "domainId"); + + Map> ruleEngines = level == null + ? this.ruleEnginesWithoutLevel + : this.resolvedRuleEngines.compute(level); + return Optional.ofNullable(ruleEngines.get(domainId)); + } + + @Override + public CompletableFuture> load(ResourceManager manager, Executor executor) { + return CompletableFuture.supplyAsync(() -> { + Map> ruleSetResourcesByDomain = converter.listMatchingResourceStacks(manager); + var lookup = new PlaceholderLookupProvider(RegistryAccess.EMPTY); + DynamicOps ops = lookup.createSerializationContext(JsonOps.INSTANCE); + + var rulesByDomain = new HashMap>(); + for (var entry : ruleSetResourcesByDomain.entrySet()) { + Identifier domainId = converter.fileToId(entry.getKey()); + List rules = parseRuleSets(domainId, entry.getValue(), ops); + rulesByDomain.put(domainId, rules); + } + + RegistryContextSwapper registrySwapper = lookup.hasRegisteredPlaceholders() + ? lookup.createSwapper() + : null; + return new Preparations<>(Map.copyOf(rulesByDomain), registrySwapper); + }, executor); + } + + // highest priority resource pack's first rule should be the head of the resultant list. + private List parseRuleSets(Identifier domainId, List ruleSetResources, DynamicOps ops) { + var rules = new ArrayList(); + + for (var ruleSetResource : ruleSetResources.reversed()) { + try (var reader = ruleSetResource.openAsReader()) { + JsonElement json = JsonParser.parseReader(reader); + DataResult> ruleSetResult = this.ruleSetCodec.parse(ops, json); + RuleSet ruleSet = ruleSetResult.getOrThrow(); + + rules.addAll(ruleSet.rules()); + + // if this rule set is a replace, then anything below it can be ignored + if (ruleSet.replace()) { + break; + } + } catch (Exception e) { + CUtil.LOGGER.error( + "Failed to load contextual {} rules for domain '{}' from pack '{}'; skipping this layer", + e, + this.directory, + domainId, + ruleSetResource.sourcePackId() + ); + } + } + + return List.copyOf(rules); + } + + private Map> resolveRuleEngines(ClientLevel level) { + return resolveRuleEngines(this.preparations, level.registryAccess(), true); + } + + private Map> resolveRuleEngines( + Preparations preparations, + HolderLookup.Provider registries, + boolean logFailures + ) { + if (preparations.registrySwapper() == null) { + return createRuleEngines(preparations.rulesByDomain()); + } + + var resolvedRulesByDomain = new HashMap>(preparations.rulesByDomain().size()); + for (var entry : preparations.rulesByDomain().entrySet()) { + var resolvedRules = new ArrayList(entry.getValue().size()); + for (R rule : entry.getValue()) { + try { + resolvedRules.add(preparations.registrySwapper() + .swapTo(this.ruleCodec, rule, registries) + .getOrThrow()); + } catch (Exception e) { + if (logFailures) { + CUtil.LOGGER.warn( + "Failed to resolve a contextual rule for domain '{}'; leaving its registry references unresolved", + e, + entry.getKey() + ); + } + resolvedRules.add(rule); + } + } + resolvedRulesByDomain.put(entry.getKey(), List.copyOf(resolvedRules)); + } + + return createRuleEngines(resolvedRulesByDomain); + } + + private Map> createRuleEngines(Map> rulesByDomain) { + var ruleEngines = new HashMap>(rulesByDomain.size()); + rulesByDomain.forEach((domainId, rules) -> ruleEngines.put(domainId, new RuleEngine<>(rules))); + return Map.copyOf(ruleEngines); + } + + @Override + public CompletableFuture apply(Preparations data, ResourceManager manager, Executor executor) { + return CompletableFuture.runAsync(() -> this.apply(data), executor); + } + + private synchronized void apply(Preparations data) { + this.preparations = data; + this.invalidateResolvedRuleEngines(); + } + + public synchronized void invalidateResolvedRuleEngines() { + this.ruleEnginesWithoutLevel = resolveRuleEngines( + this.preparations, + ClientRegistryLayer.createRegistryAccess().compositeAccess(), + false + ); + this.resolvedRuleEngines.clear(); + } + + @Override + public Identifier getReloadId() { + return CUtil.rl("reload/" + this.directory); + } + + public record Preparations>( + Map> rulesByDomain, + @Nullable RegistryContextSwapper registrySwapper + ) {} +} diff --git a/src/main/java/dev/isxander/controlify/contextual/TriggerEffectRule.java b/src/main/java/dev/isxander/controlify/contextual/TriggerEffectRule.java new file mode 100644 index 000000000..370401396 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/contextual/TriggerEffectRule.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.contextual; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import dev.isxander.controlify.api.bind.InputBindingSupplier; +import dev.isxander.controlify.controller.dualsense.TriggerEffectCodecs; +import dev.isxander.controlify.driver.dualsense.DualsenseTriggerEffect; +import net.minecraft.resources.Identifier; + +/// @param binding the binding supplier associated with this action. +/// if the binding is not bound to either trigger, it is ignored. +public record TriggerEffectRule( + InputBindingSupplier binding, + ContextualPredicate predicate, + DualsenseTriggerEffect effect +) implements Rule { + public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + InputBindingSupplier.CODEC.fieldOf("for").forGetter(TriggerEffectRule::binding), + ContextualPredicate.CODEC.fieldOf("if").forGetter(TriggerEffectRule::predicate), + TriggerEffectCodecs.CODEC.fieldOf("then").forGetter(TriggerEffectRule::effect) + ).apply(instance, TriggerEffectRule::new)); + + @Override + public Identifier key() { + return this.binding.bindId(); + } +} diff --git a/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectInstanceImpl.java b/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectInstanceImpl.java new file mode 100644 index 000000000..fe6bdbbef --- /dev/null +++ b/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectInstanceImpl.java @@ -0,0 +1,102 @@ +/* + * 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.bind.InputBinding; +import dev.isxander.controlify.api.contextual.Context; +import dev.isxander.controlify.api.contextual.TriggerEffectInstance; +import dev.isxander.controlify.contextual.ContextualDomainImpl; +import dev.isxander.controlify.contextual.ContextualState; +import dev.isxander.controlify.contextual.RuleEngine; +import dev.isxander.controlify.contextual.TriggerEffectRule; +import dev.isxander.controlify.controller.ControllerEntity; +import dev.isxander.controlify.controller.input.GamepadInputs; +import dev.isxander.controlify.driver.dualsense.DualsenseTriggerEffect; +import net.minecraft.resources.Identifier; + +import java.util.List; +import java.util.function.Supplier; + +public class TriggerEffectInstanceImpl implements TriggerEffectInstance { + private final ContextualDomainImpl domain; + private final Supplier> ruleEngineSupplier; + + private DualsenseTriggerEffect leftEffect = DualsenseTriggerEffect.Off.INSTANCE; + private DualsenseTriggerEffect rightEffect = DualsenseTriggerEffect.Off.INSTANCE; + + public TriggerEffectInstanceImpl( + ContextualDomainImpl domain, + Supplier> ruleEngineSupplier + ) { + this.domain = domain; + this.ruleEngineSupplier = ruleEngineSupplier; + } + + @Override + public void update(C context) { + RuleEngine ruleEngine = this.ruleEngineSupplier.get(); + boolean useTriggerEffects = context.controller().dualSense() + .map(ds -> ds.settings().triggerEffects) + .orElse(false); + if (!useTriggerEffects) { + this.leftEffect = DualsenseTriggerEffect.Off.INSTANCE; + this.rightEffect = DualsenseTriggerEffect.Off.INSTANCE; + return; + } + + ContextualState state = this.domain.calculateState(context, ruleEngine.factDependencies()); + List passedRules = ruleEngine.evaluate( + state, + rule -> getTrigger(context.controller(), rule) != Trigger.NEITHER, + "trigger_effect/" + this.domain.id() + ); + + // there should only be one each + this.leftEffect = passedRules.stream() + .filter(rule -> getTrigger(context.controller(), rule) == Trigger.LEFT) + .map(TriggerEffectRule::effect) + .findAny() + .orElse(DualsenseTriggerEffect.Off.INSTANCE); + this.rightEffect = passedRules.stream() + .filter(rule -> getTrigger(context.controller(), rule) == Trigger.RIGHT) + .map(TriggerEffectRule::effect) + .findAny() + .orElse(DualsenseTriggerEffect.Off.INSTANCE); + } + + @Override + public DualsenseTriggerEffect getLeftTriggerEffect() { + return this.leftEffect; + } + + @Override + public DualsenseTriggerEffect getRightTriggerEffect() { + return this.rightEffect; + } + + private Trigger getTrigger(ControllerEntity controller, TriggerEffectRule rule) { + InputBinding binding = rule.binding().onOrNull(controller); + + if (binding == null) { + return Trigger.NEITHER; + } + + List relevantInputs = binding.boundInput().getRelevantInputs(); + + if (relevantInputs.contains(GamepadInputs.LEFT_TRIGGER_AXIS)) { + return Trigger.LEFT; + } else if (relevantInputs.contains(GamepadInputs.RIGHT_TRIGGER_AXIS)) { + return Trigger.RIGHT; + } + + return Trigger.NEITHER; + } + + private enum Trigger { + LEFT, RIGHT, NEITHER + } +} diff --git a/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectManager.java b/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectManager.java index 3db803b81..1052acf8c 100644 --- a/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectManager.java +++ b/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectManager.java @@ -6,122 +6,62 @@ */ package dev.isxander.controlify.controller.dualsense; -import dev.isxander.controlify.bindings.ControlifyBindings; +import dev.isxander.controlify.api.contextual.InGameContext; +import dev.isxander.controlify.api.contextual.TriggerEffectInstance; +import dev.isxander.controlify.contextual.ContextualDomains; import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.controller.input.GamepadInputs; import dev.isxander.controlify.driver.dualsense.DualsenseTriggerEffect; import dev.isxander.controlify.utils.MinecraftUtil; import net.minecraft.client.Minecraft; -import net.minecraft.client.player.LocalPlayer; -import net.minecraft.resources.Identifier; -import net.minecraft.world.item.ItemStack; import java.util.*; public class TriggerEffectManager { - private final Minecraft minecraft; - private final TriggerEffectRegistry registry; - public TriggerEffectManager(Minecraft minecraft, TriggerEffectRegistry registry) { - this.minecraft = minecraft; - this.registry = registry; - } + private final TriggerEffectInstance instance; - public void applyTriggerEffects(ControllerEntity controller, boolean inputSuppressed) { - controller.dualSense().ifPresent(ds -> { - ds.setLeftTriggerEffect(this.getCurrentLeftTriggerEffect(controller, inputSuppressed)); - ds.setRightTriggerEffect(this.getCurrentRightTriggerEffect(controller, inputSuppressed)); - }); + public TriggerEffectManager() { + this.instance = ContextualDomains.INSTANCE.inGame().createTriggerEffectInstance(); } - public DualsenseTriggerEffect getCurrentLeftTriggerEffect(ControllerEntity controller, boolean inputSuppressed) { - if (!this.shouldUseTriggerEffects(controller, inputSuppressed)) { - return DualsenseTriggerEffect.Off.INSTANCE; - } - - LocalPlayer player = Objects.requireNonNull(this.minecraft.player); - - if (this.getUseItemTrigger(controller) == Trigger.LEFT) { - return this.getUseItemTriggerEffect(player) - .orElse(DualsenseTriggerEffect.Off.INSTANCE); - } - if (this.getSwingItemTrigger(controller) == Trigger.LEFT) { - return this.getSwingItemTriggerEffect(player) - .orElse(DualsenseTriggerEffect.Off.INSTANCE); - } - - return DualsenseTriggerEffect.Off.INSTANCE; + public void applyTriggerEffects( + ControllerEntity controller, + boolean inputSuppressed + ) { + applyTriggerEffects(controller, instance, inputSuppressed); } - public DualsenseTriggerEffect getCurrentRightTriggerEffect(ControllerEntity controller, boolean inputSuppressed) { - if (!this.shouldUseTriggerEffects(controller, inputSuppressed)) { - return DualsenseTriggerEffect.Off.INSTANCE; + public void tick(Minecraft minecraft, ControllerEntity controller) { + if (minecraft.player == null || minecraft.level == null) { + return; } - LocalPlayer player = Objects.requireNonNull(this.minecraft.player); - - if (this.getUseItemTrigger(controller) == Trigger.RIGHT) { - return this.getUseItemTriggerEffect(player) - .orElse(DualsenseTriggerEffect.Off.INSTANCE); - } - if (this.getSwingItemTrigger(controller) == Trigger.RIGHT) { - return this.getSwingItemTriggerEffect(player) - .orElse(DualsenseTriggerEffect.Off.INSTANCE); - } + this.instance.update( + InGameContext.create(minecraft, controller) + ); + } - return DualsenseTriggerEffect.Off.INSTANCE; + public static void applyTriggerEffects( + ControllerEntity controller, + TriggerEffectInstance instance, + boolean inputSuppressed + ) { + controller.dualSense().ifPresent(ds -> { + if (!shouldUseTriggerEffects(controller, inputSuppressed)) { + ds.setLeftTriggerEffect(DualsenseTriggerEffect.Off.INSTANCE); + ds.setRightTriggerEffect(DualsenseTriggerEffect.Off.INSTANCE); + return; + } + + ds.setLeftTriggerEffect(instance.getLeftTriggerEffect()); + ds.setRightTriggerEffect(instance.getRightTriggerEffect()); + }); } - public boolean shouldUseTriggerEffects(ControllerEntity controller, boolean inputSuppressed) { + public static boolean shouldUseTriggerEffects(ControllerEntity controller, boolean inputSuppressed) { return !inputSuppressed && MinecraftUtil.getScreen() == null - && this.minecraft.player != null + && Minecraft.getInstance().player != null && controller.dualSense().map(ds -> ds.settings().triggerEffects).orElse(false); } - - private Trigger getUseItemTrigger(ControllerEntity controller) { - List relevantInputs = ControlifyBindings.USE.on(controller).boundInput().getRelevantInputs(); - - if (relevantInputs.contains(GamepadInputs.LEFT_TRIGGER_AXIS)) { - return Trigger.LEFT; - } else if (relevantInputs.contains(GamepadInputs.RIGHT_TRIGGER_AXIS)) { - return Trigger.RIGHT; - } else { - return Trigger.NEITHER; - } - } - - private Trigger getSwingItemTrigger(ControllerEntity controller) { - List relevantInputs = ControlifyBindings.ATTACK.on(controller).boundInput().getRelevantInputs(); - - if (relevantInputs.contains(GamepadInputs.LEFT_TRIGGER_AXIS)) { - return Trigger.LEFT; - } else if (relevantInputs.contains(GamepadInputs.RIGHT_TRIGGER_AXIS)) { - return Trigger.RIGHT; - } else { - return Trigger.NEITHER; - } - } - - public Optional getUseItemTriggerEffect(LocalPlayer player) { - return this.getUseItemTriggerEffect(player.getActiveItem()) - .or(() -> this.getUseItemTriggerEffect(player.getOffhandItem())); - } - - public Optional getUseItemTriggerEffect(ItemStack stack) { - return this.registry.getUseItemEffect(stack); - } - - public Optional getSwingItemTriggerEffect(LocalPlayer player) { - return this.getSwingItemTriggerEffect(player.getMainHandItem()); - } - - public Optional getSwingItemTriggerEffect(ItemStack stack) { - return this.registry.getSwingItemEffect(stack); - } - - - private enum Trigger { - LEFT, RIGHT, NEITHER - } } diff --git a/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectRegistry.java b/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectRegistry.java deleted file mode 100644 index dd0145f9a..000000000 --- a/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectRegistry.java +++ /dev/null @@ -1,325 +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 com.google.gson.JsonElement; -import com.google.gson.JsonParser; -import com.mojang.logging.LogUtils; -import com.mojang.serialization.Codec; -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.resources.Identifier; -import net.minecraft.server.packs.resources.Resource; -import net.minecraft.server.packs.resources.ResourceManager; -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; -import org.slf4j.Logger; - -import java.io.BufferedReader; -import java.util.ArrayList; -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; - -public class TriggerEffectRegistry implements SimpleControlifyReloadListener { - private static final Logger LOGGER = LogUtils.getLogger(); - - private static final Identifier USE_ITEM_RESOURCE = CUtil.rl("trigger_effect/use_item.json"); - private static final Identifier SWING_ITEM_RESOURCE = CUtil.rl("trigger_effect/swing_item.json"); - private static final Identifier RELOAD_ID = CUtil.rl("trigger_effect"); - - private static final Codec RULE_CODEC = - RecordCodecBuilder.create(instance -> instance.group( - 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 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, Function> useItemComponentEffects = new LinkedHashMap<>(); - private final Map, Function> swingItemComponentEffects = new LinkedHashMap<>(); - private final Map useItemEffects = new LinkedHashMap<>(); - private final Map swingItemEffects = new LinkedHashMap<>(); - - @Override - public CompletableFuture load(ResourceManager manager, Executor executor) { - return CompletableFuture.supplyAsync(() -> new Preparations( - this.loadResourceStack(manager, USE_ITEM_RESOURCE), - this.loadResourceStack(manager, SWING_ITEM_RESOURCE) - ), executor); - } - - 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 layerRules = RULES_CODEC - .parse(ops, json) - .getOrThrow(); - 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", - id, - resource.sourcePackId(), - e - ); - } - } - - return List.copyOf(layers); - } - - @Override - public CompletableFuture apply(Preparations data, ResourceManager manager, Executor executor) { - return CompletableFuture.runAsync(() -> { - this.useItemResourceLayers = data.useItemLayers; - this.swingItemResourceLayers = data.swingItemLayers; - this.invalidateResolvedResourceRules(); - LOGGER.info( - "Loaded {} use-item and {} swing-item adaptive trigger effect rule layers", - this.useItemResourceLayers.size(), - this.swingItemResourceLayers.size() - ); - }, executor); - } - - public void registerUseItemComponentEffect( - DataComponentType componentType, - Function effectFunction - ) { - this.useItemComponentEffects.put(componentType, castEffectFunction(effectFunction)); - } - - public void registerSwingItemComponentEffect( - DataComponentType componentType, - Function effectFunction - ) { - this.swingItemComponentEffects.put(componentType, castEffectFunction(effectFunction)); - } - - public void registerUseItemEffect(Item item, DualsenseTriggerEffect effect) { - this.useItemEffects.put(item, effect); - } - - public void registerSwingItemEffect(Item item, DualsenseTriggerEffect effect) { - this.swingItemEffects.put(item, effect); - } - - public Optional getUseItemEffect(ItemStack stack) { - ResolvedRules rules = this.currentResourceRules(); - return findEffect( - stack, - rules.useItemRules(), - this.useItemResourceCache, - this.useItemComponentEffects, - this.useItemEffects - ); - } - - public Optional getSwingItemEffect(ItemStack stack) { - ResolvedRules rules = this.currentResourceRules(); - return findEffect( - stack, - rules.swingItemRules(), - this.swingItemResourceCache, - this.swingItemComponentEffects, - this.swingItemEffects - ); - } - - private Optional findEffect( - ItemStack stack, - List resourceRules, - Map resourceCache, - Map, Function> componentEffects, - Map itemEffects - ) { - Optional resourceEffect = this.findResourceEffect(stack, resourceRules, resourceCache); - if (resourceEffect.isPresent()) { - return resourceEffect; - } - - for (var entry : componentEffects.entrySet()) { - if (stack.has(entry.getKey())) { - @Nullable DualsenseTriggerEffect effect = entry.getValue().apply(stack.get(entry.getKey())); - if (effect != null) { - return Optional.of(effect); - } - } - } - - return Optional.ofNullable(itemEffects.get(stack.getItem())); - } - - private Optional findResourceEffect( - ItemStack stack, - List rules, - Map cache - ) { - 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); - } - - 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); - } - - public void invalidateResolvedResourceRules() { - this.resolvedRules.clear(); - this.invalidateResourceRuleCaches(); - } - - public void invalidateResourceRuleCaches() { - this.useItemResourceCache.clear(); - this.swingItemResourceCache.clear(); - } - - @SuppressWarnings("unchecked") - private static Function castEffectFunction( - Function effectFunction - ) { - return (Function) effectFunction; - } - - @Override - public Identifier getReloadId() { - return RELOAD_ID; - } - - private record Rule( - ItemPredicate when, - DualsenseTriggerEffect effect - ) { - public boolean matches(ItemStack stack) { - // reimplement `ItemPredicate#matches` with ClientTags fallback, to allow `c:` tags with vanilla servers - // neoforge does not implement ClientTags api, but this is okay as nobody runs NeoForge client with vanilla - // servers anyway - if (when.items().isPresent() && !CUtil.isInWithLocalFallback(stack, when.items().get())) { - return false; - } else { - return when.count().matches(stack.count()) && when.components().test(stack); - } - } - } - - private record PreparedLayer( - List rules, - String sourcePackId, - @Nullable RegistryContextSwapper registrySwapper - ) { - } - - private record ResolvedRules( - List useItemRules, - List swingItemRules - ) { - private static final ResolvedRules EMPTY = new ResolvedRules(List.of(), List.of()); - } - - private record CachedResourceMatch( - ItemStack snapshot, - Optional effect - ) { - } - - public static final class Preparations { - private final List useItemLayers; - private final List swingItemLayers; - - private Preparations(List useItemLayers, List swingItemLayers) { - this.useItemLayers = useItemLayers; - this.swingItemLayers = swingItemLayers; - } - } -} diff --git a/src/main/java/dev/isxander/controlify/debug/DebugProperties.java b/src/main/java/dev/isxander/controlify/debug/DebugProperties.java index dc5bdbe2f..b33753310 100644 --- a/src/main/java/dev/isxander/controlify/debug/DebugProperties.java +++ b/src/main/java/dev/isxander/controlify/debug/DebugProperties.java @@ -18,6 +18,9 @@ public class DebugProperties { private static final List> properties = new ArrayList<>(); public static final boolean DEBUG_LOGGING = boolProp("controlify.debug.logging", false, true); + /// Logs every contextual state contribution, derived fact evaluation, and rule evaluation. + /// This is intentionally separate from general debug logging because it is extremely verbose. + public static final boolean DEBUG_CONTEXTUAL_RULES = boolProp("controlify.debug.contextual_rules", false, false); /// Renders debug overlay for vmouse snapping public static final boolean DEBUG_SNAPPING = boolProp("controlify.debug.snapping", false, false); /// Forces all gamepads to be treated as a regular joystick diff --git a/src/main/java/dev/isxander/controlify/gui/guide/GuideDomainImpl.java b/src/main/java/dev/isxander/controlify/gui/guide/GuideDomainImpl.java deleted file mode 100644 index 51b908e7c..000000000 --- a/src/main/java/dev/isxander/controlify/gui/guide/GuideDomainImpl.java +++ /dev/null @@ -1,157 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.gui.guide; - -import com.google.gson.JsonElement; -import com.google.gson.JsonParser; -import com.mojang.serialization.DataResult; -import com.mojang.serialization.JsonOps; -import dev.isxander.controlify.api.guide.*; -import dev.isxander.controlify.platform.client.resource.SimpleControlifyReloadListener; -import net.minecraft.resources.FileToIdConverter; -import net.minecraft.resources.Identifier; -import net.minecraft.server.packs.resources.Resource; -import net.minecraft.server.packs.resources.ResourceManager; -import org.apache.commons.lang3.Validate; - -import java.io.BufferedReader; -import java.io.IOException; -import java.util.*; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Executor; - -public class GuideDomainImpl implements GuideDomain, SimpleControlifyReloadListener { - public static final String DIRECTORY = "guides"; - private static final FileToIdConverter converter = FileToIdConverter.json(DIRECTORY); - - /** The unique identifier of this domain */ - private final Identifier id; - - /** Facts registered via code */ - private final Map> facts = new HashMap<>(); - /** Registered dynamic rules - rules are dynamic if they were created in-code */ - private final List dynamicRules = new ArrayList<>(); - /** Whether facts and dynamic rules registries have been frozen */ - private boolean frozen = false; - - /** All rules loaded from resource packs as well as dynamic rules */ - private List rules; - /** Incremented on reload, so instances can check when they need to reset. */ - private int reloadEpoch; - - public GuideDomainImpl(Identifier id) { - this.id = id; - } - - public Identifier id() { - return this.id; - } - - @Override - public void registerFact(Fact fact) { - Validate.isTrue(!this.frozen, "Cannot register facts after the domain has been frozen"); - Validate.notNull(fact, "Fact cannot be null"); - Validate.isTrue(!this.facts.containsKey(fact.id()), "Fact with id %s already exists in domain %s", fact.id(), this.id); - - var invariantFact = new Fact(fact.id(), fact.provider()::test); - - this.facts.put(fact.id(), invariantFact); - } - - @Override - public void registerDynamicRule(Rule rule) { - Validate.isTrue(!this.frozen, "Cannot register dynamic rule after the domain has been frozen"); - Validate.notNull(rule, "Rule cannot be null"); - - this.dynamicRules.add(rule); - } - - @Override - public CompletableFuture load(ResourceManager manager, Executor executor) { - return CompletableFuture.supplyAsync(() -> { - List resources = manager.getResourceStack(converter.idToFile(this.id)); - - // add all rule sets to a list until we hit a replace = true - var ruleSets = new ArrayList(); - for (Resource resource : resources) { - try (BufferedReader reader = resource.openAsReader()) { - JsonElement element = JsonParser.parseReader(reader); - DataResult result = RuleSet.CODEC.parse(JsonOps.INSTANCE, element); - var ruleSet = result.getOrThrow(); - - ruleSets.add(ruleSet); - if (ruleSet.replace()) { - // if this rule set replaces all that's below it, we can skip parsing everything below - break; - } - } catch (IOException e) { - throw new IllegalStateException("Failed to load resource", e); - } - } - - // merge all the rules into a single list, where the topmost rules take precedence - List rules = ruleSets.stream() - .flatMap(rs -> rs.rules().stream()) - .toList(); - - return new Preparations(rules); - }, executor); - } - - @Override - public CompletableFuture apply(Preparations data, ResourceManager manager, Executor executor) { - return CompletableFuture.runAsync(() -> { - Validate.isTrue(this.frozen, "Cannot apply domain before the domain has been frozen"); - - int ruleCount = data.rules().size() + this.dynamicRules.size(); - - this.rules = new ArrayList<>(ruleCount); - this.rules.addAll(data.rules()); - this.rules.addAll(this.dynamicRules); - - this.reloadEpoch += 1; - }, executor); - } - - public boolean freeze() { - if (this.frozen) { - return false; // already frozen - } - this.frozen = true; - return true; - } - - public boolean isFrozen() { - return this.frozen; - } - - public List rules() { - return Collections.unmodifiableList(Validate.notNull(this.rules, "Rules have not been loaded yet for domain %s", this.id)); - } - - public Map> facts() { - return Collections.unmodifiableMap(Validate.notNull(this.facts, "Facts have not been loaded yet for domain %s", this.id)); - } - - public int currentReloadEpoch() { - return this.reloadEpoch; - } - - @Override - public Identifier getReloadId() { - return this.id.withPrefix("reload/"); - } - - @Override - public GuideInstance createInstance() { - return new GuideInstanceImpl<>(this); - } - - public record Preparations( - List rules - ) {} -} diff --git a/src/main/java/dev/isxander/controlify/gui/guide/GuideDomains.java b/src/main/java/dev/isxander/controlify/gui/guide/GuideDomains.java deleted file mode 100644 index aa1f4840a..000000000 --- a/src/main/java/dev/isxander/controlify/gui/guide/GuideDomains.java +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.gui.guide; - -import dev.isxander.controlify.api.guide.ContainerCtx; -import dev.isxander.controlify.api.guide.ContainerFacts; -import dev.isxander.controlify.api.guide.InGameCtx; -import dev.isxander.controlify.api.guide.InGameFacts; -import dev.isxander.controlify.utils.CUtil; -import net.minecraft.resources.Identifier; - -import java.util.HashMap; -import java.util.Map; - -public final class GuideDomains { - private GuideDomains() {} - - public static final GuideDomainImpl IN_GAME = new GuideDomainImpl<>(CUtil.rl("in_game")); - public static final GuideDomainImpl CONTAINER = new GuideDomainImpl<>(CUtil.rl("container")); - - public static final Map> CUSTOM_DOMAINS = new HashMap<>(); - - public static void freeze() { - IN_GAME.freeze(); - CONTAINER.freeze(); - CUSTOM_DOMAINS.values().forEach(GuideDomainImpl::freeze); - } - - static { - InGameFacts.registerAll(); - ContainerFacts.registerAll(); - } -} diff --git a/src/main/java/dev/isxander/controlify/gui/guide/GuideInstanceImpl.java b/src/main/java/dev/isxander/controlify/gui/guide/GuideInstanceImpl.java index 9967870f5..1b0444259 100644 --- a/src/main/java/dev/isxander/controlify/gui/guide/GuideInstanceImpl.java +++ b/src/main/java/dev/isxander/controlify/gui/guide/GuideInstanceImpl.java @@ -7,7 +7,13 @@ package dev.isxander.controlify.gui.guide; import dev.isxander.controlify.api.bind.InputBinding; -import dev.isxander.controlify.api.guide.*; +import dev.isxander.controlify.api.contextual.GuideLocation; +import dev.isxander.controlify.api.contextual.Context; +import dev.isxander.controlify.api.contextual.GuideInstance; +import dev.isxander.controlify.contextual.ContextualDomainImpl; +import dev.isxander.controlify.contextual.ContextualState; +import dev.isxander.controlify.contextual.GuideRule; +import dev.isxander.controlify.contextual.RuleEngine; import dev.isxander.controlify.controller.ControllerEntity; import dev.isxander.controlify.font.BindingFontHelper; import net.minecraft.client.Minecraft; @@ -15,185 +21,96 @@ import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.gui.components.Renderable; import net.minecraft.network.chat.Component; -import net.minecraft.resources.Identifier; -import org.apache.commons.lang3.Validate; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.Set; -import java.util.stream.Stream; +import java.util.List; +import java.util.function.Supplier; -public class GuideInstanceImpl implements GuideInstance { - private final GuideDomainImpl domain; - - /** The facts that need to be resolved each tick based on references */ - private Map resolvedFacts = Map.of(); +public class GuideInstanceImpl implements GuideInstance { + private final ContextualDomainImpl domain; + private final Supplier> ruleEngineSupplier; + private final Font font; private PrecomputedLines leftGuides = PrecomputedLines.EMPTY; private PrecomputedLines rightGuides = PrecomputedLines.EMPTY; - /** True after reset since font width/height may be different */ - private boolean precomputeInvalid = false; - private int reloadEpoch = 0; - - public GuideInstanceImpl(GuideDomainImpl domain) { - Validate.isTrue(domain.isFrozen(), "Cannot create Guide instance until domain has been frozen"); + public GuideInstanceImpl( + ContextualDomainImpl domain, + Supplier> ruleEngineSupplier, + Font font + ) { this.domain = domain; - this.reset(); - } - - @Override - public boolean update(T context, Font font) { - if (this.domain.currentReloadEpoch() != this.reloadEpoch) { - reset(); - } - - if (!this.updateFactResolution(context) && !this.precomputeInvalid) { - // no facts changed, no need to update guides - return false; - } - this.precomputeInvalid = false; - - precomputeGuideLayout(context, font); - - return true; - } - - @Override - public void extractRenderState(GuiGraphicsExtractor graphics, boolean bottomAligned, boolean textContrast, int guiScale) { - GuideRenderer.extractRenderState( - graphics, - this, - Minecraft.getInstance(), - bottomAligned, - textContrast, - guiScale - ); + this.ruleEngineSupplier = ruleEngineSupplier; + this.font = font; } @Override - public Renderable renderable(boolean bottomAligned, boolean textContrast, int guiScale) { - return new GuideRenderer.Renderable( - this, - Minecraft.getInstance(), - bottomAligned, - textContrast, - guiScale - ); - } - - /** - * Updates the fact resolution for all facts in this domain. - * @param context the context to use for fact resolution - * @return true if any facts changed, false otherwise - */ - private boolean updateFactResolution(T context) { - boolean changed = false; - for (Map.Entry entry : this.resolvedFacts.entrySet()) { - Identifier factId = entry.getKey(); - Fact fact = this.domain.facts().get(factId); // facts.get should never return null here - - boolean newValue = fact.provider().test(context); - changed |= (newValue != entry.getValue()); - - entry.setValue(newValue); - } + public void update(C context) { + RuleEngine ruleEngine = this.ruleEngineSupplier.get(); + ContextualState state = this.domain.calculateState(context, ruleEngine.factDependencies()); + List guides = ruleEngine.evaluate(state, _ -> true, "guide/" + this.domain.id()); - return changed; - } - - private void precomputeGuideLayout(T context, Font font) { var leftBuilder = new PrecomputedLines.Builder(); var rightBuilder = new PrecomputedLines.Builder(); - var leftConsumed = new HashSet(this.leftGuides.lines().size() + 5); - var rightConsumed = new HashSet(this.rightGuides.lines().size() + 5); - ControllerEntity controller = context.controller(); - for (Rule rule : this.domain.rules()) { - InputBinding binding = rule.binding().onOrNull(controller); + for (GuideRule guide : guides) { + InputBinding binding = guide.binding().onOrNull(controller); if (binding == null || binding.isUnbound()) { - // skip this rule + // skip this rule if the binding is not bound continue; } - // get the builder and consumed set based on the rule's location - PrecomputedLines.Builder builder; - Set consumedBinds; - switch (rule.where()) { - case LEFT -> { - builder = leftBuilder; - consumedBinds = leftConsumed; - } - case RIGHT -> { - builder = rightBuilder; - consumedBinds = rightConsumed; - } - default -> throw new IllegalStateException("Unexpected action location: " + rule.where()); - } - - // add the binding to the consumed set so we skip extra work for the same binding - if (consumedBinds.contains(binding.id())) { - continue; - } + PrecomputedLines.Builder builder = switch (guide.location()) { + case LEFT -> leftBuilder; + case RIGHT -> rightBuilder; + }; - // check if the rule's conditions are met - boolean whenPermits = rule.when().stream() - .allMatch(this.resolvedFacts::get); - boolean forbidPermits = rule.forbid().stream() - .noneMatch(this.resolvedFacts::get); + // put the glyph after or before the binding glyph based on the rule's location and font direction + boolean glyphAfter = font.isBidirectional() ^ (guide.location() == GuideLocation.RIGHT); - if (whenPermits && forbidPermits) { - consumedBinds.add(binding.id()); + // formulate the text to display + Component text = Component.empty() + .append(glyphAfter ? guide.text() : binding.inputGlyph()) + .append(" ") + .append(glyphAfter ? binding.inputGlyph() : guide.text()); - // put the glyph after or before the binding glyph based on the rule's location and font direction - boolean glyphAfter = font.isBidirectional() ^ (rule.where() == ActionLocation.RIGHT); + // precompute the width and height of the text + int ruleNameWidth = font.width(guide.text()); + int width = font.width(text); + int glyphWidth = width - ruleNameWidth; + int backgroundLeft = glyphAfter ? 0 : glyphWidth; + int backgroundRight = backgroundLeft + ruleNameWidth; - // formulate the text to display - Component text = Component.empty() - .append(glyphAfter ? rule.then() : binding.inputGlyph()) - .append(" ") - .append(glyphAfter ? binding.inputGlyph() : rule.then()); + int height = BindingFontHelper.getComponentHeight(font, binding.inputGlyph()); // use input glyph height only as it's bound to be the tallest - // precompute the width and height of the text - int ruleNameWidth = font.width(rule.then()); - int width = font.width(text); - int glyphWidth = width - ruleNameWidth; - int backgroundLeft = glyphAfter ? 0 : glyphWidth; - int backgroundRight = backgroundLeft + ruleNameWidth; - - int height = BindingFontHelper.getComponentHeight(font, binding.inputGlyph()); // use input glyph height only as it's bound to be the tallest - - builder.addLine(text, width, height, backgroundLeft, backgroundRight); - } + builder.addLine(text, width, height, backgroundLeft, backgroundRight); } this.leftGuides = leftBuilder.build(); this.rightGuides = rightBuilder.build(); } - /** - * When the reload epoch of the backing domain changes, - * this method is called which ensures all state is reset. - */ - private void reset() { - this.resolvedFacts = new HashMap<>(this.domain.rules().size() * 2); - - Stream when = this.domain.rules().stream() - .flatMap(rule -> rule.when().stream()); - Stream forbid = this.domain.rules().stream() - .flatMap(rule -> rule.forbid().stream()); - Stream.concat(when, forbid) - .distinct() - .forEach(factId -> { - Fact fact = this.domain.facts().get(factId); - Validate.notNull(fact, "Fact %s is not registered in domain %s", factId, this.domain.id()); - this.resolvedFacts.put(factId, false); - }); - - this.reloadEpoch = this.domain.currentReloadEpoch(); - this.precomputeInvalid = true; + @Override + public void extractRenderState(GuiGraphicsExtractor graphics, boolean bottomAligned, boolean textContrast, int guiScale) { + GuideRenderer.extractRenderState( + graphics, + this, + Minecraft.getInstance(), + bottomAligned, + textContrast, + guiScale + ); + } + + @Override + public Renderable renderable(boolean bottomAligned, boolean textContrast, int guiScale) { + return new GuideRenderer.Renderable( + this, + Minecraft.getInstance(), + bottomAligned, + textContrast, + guiScale + ); } public PrecomputedLines leftGuides() { @@ -203,9 +120,4 @@ public PrecomputedLines leftGuides() { public PrecomputedLines rightGuides() { return this.rightGuides; } - - @Override - public GuideDomain domain() { - return this.domain; - } } diff --git a/src/main/java/dev/isxander/controlify/gui/guide/InGameButtonGuide.java b/src/main/java/dev/isxander/controlify/gui/guide/InGameButtonGuide.java index 55b783105..e29e7182c 100644 --- a/src/main/java/dev/isxander/controlify/gui/guide/InGameButtonGuide.java +++ b/src/main/java/dev/isxander/controlify/gui/guide/InGameButtonGuide.java @@ -6,10 +6,11 @@ */ package dev.isxander.controlify.gui.guide; -import dev.isxander.controlify.api.guide.GuideInstance; +import dev.isxander.controlify.api.contextual.GuideInstance; import dev.isxander.controlify.config.settings.profile.GenericControllerSettings; +import dev.isxander.controlify.contextual.ContextualDomains; import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.api.guide.InGameCtx; +import dev.isxander.controlify.api.contextual.InGameContext; import dev.isxander.controlify.mixins.feature.guide.ingame.MinecraftAccessor; import dev.isxander.controlify.utils.MinecraftUtil; import net.minecraft.client.Minecraft; @@ -18,12 +19,12 @@ public class InGameButtonGuide { private final ControllerEntity controller; private final Minecraft minecraft; - private final GuideInstance guideInstance; + private final GuideInstance guideInstance; public InGameButtonGuide(ControllerEntity controller, Minecraft minecraft) { this.controller = controller; this.minecraft = minecraft; - this.guideInstance = GuideDomains.IN_GAME.createInstance(); + this.guideInstance = ContextualDomains.INSTANCE.inGame().createGuideInstance(minecraft.font); } public void extractRenderState(GuiGraphicsExtractor graphics, float tickDelta) { @@ -45,19 +46,8 @@ public void tick() { GenericControllerSettings.GuideSettings settings = controller.settings().generic.guide; if (settings.showIngameGuide) { - if (minecraft.hitResult == null) { - ((MinecraftAccessor) minecraft).controlify$invokePick(1f); - } this.guideInstance.update( - new InGameCtx( - minecraft, - minecraft.player, - minecraft.level, - minecraft.hitResult, - controller, - settings.verbosity - ), - minecraft.font + InGameContext.create(minecraft, controller) ); } } diff --git a/src/main/java/dev/isxander/controlify/gui/guide/RuleSet.java b/src/main/java/dev/isxander/controlify/gui/guide/RuleSet.java deleted file mode 100644 index e85c0224b..000000000 --- a/src/main/java/dev/isxander/controlify/gui/guide/RuleSet.java +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.gui.guide; - -import com.mojang.serialization.Codec; -import com.mojang.serialization.codecs.RecordCodecBuilder; -import dev.isxander.controlify.api.guide.Rule; - -import java.util.List; - -public record RuleSet( - boolean replace, - List rules -) { - public static final Codec CODEC = RecordCodecBuilder.create( - instance -> instance.group( - Codec.BOOL.optionalFieldOf("replace", false).forGetter(RuleSet::replace), - Rule.CODEC.listOf().fieldOf("rules").forGetter(RuleSet::rules) - ).apply(instance, RuleSet::new) - ); -} diff --git a/src/main/java/dev/isxander/controlify/gui/screen/ControllerConfigScreenFactory.java b/src/main/java/dev/isxander/controlify/gui/screen/ControllerConfigScreenFactory.java index 423f0e0d3..26a31aaff 100644 --- a/src/main/java/dev/isxander/controlify/gui/screen/ControllerConfigScreenFactory.java +++ b/src/main/java/dev/isxander/controlify/gui/screen/ControllerConfigScreenFactory.java @@ -7,7 +7,7 @@ package dev.isxander.controlify.gui.screen; import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.api.guide.GuideVerbosity; +import dev.isxander.controlify.api.contextual.GuideVerbosity; import dev.isxander.controlify.bindings.BindContext; import dev.isxander.controlify.bindings.ControlifyBindApiImpl; import dev.isxander.controlify.api.bind.InputBinding; diff --git a/src/main/java/dev/isxander/controlify/screenop/compat/vanilla/AbstractContainerScreenProcessor.java b/src/main/java/dev/isxander/controlify/screenop/compat/vanilla/AbstractContainerScreenProcessor.java index 1a6885efb..4753536c1 100644 --- a/src/main/java/dev/isxander/controlify/screenop/compat/vanilla/AbstractContainerScreenProcessor.java +++ b/src/main/java/dev/isxander/controlify/screenop/compat/vanilla/AbstractContainerScreenProcessor.java @@ -8,12 +8,12 @@ import dev.isxander.controlify.InputMode; import dev.isxander.controlify.api.ControlifyApi; -import dev.isxander.controlify.api.guide.ContainerCtx; -import dev.isxander.controlify.api.guide.GuideInstance; +import dev.isxander.controlify.api.contextual.ContainerContext; +import dev.isxander.controlify.api.contextual.GuideInstance; import dev.isxander.controlify.bindings.ControlifyBindings; +import dev.isxander.controlify.contextual.ContextualDomains; import dev.isxander.controlify.controller.ControllerEntity; import dev.isxander.controlify.controller.haptic.HapticEffects; -import dev.isxander.controlify.gui.guide.GuideDomains; import dev.isxander.controlify.gui.guide.GuideRenderer; import dev.isxander.controlify.mixins.feature.guide.screen.AbstractContainerScreenAccessor; import dev.isxander.controlify.mixins.feature.screenop.ScreenAccessor; @@ -32,7 +32,7 @@ public class AbstractContainerScreenProcessor> extends ScreenProcessor { - private final GuideInstance guideInstance; + private final GuideInstance guideInstance; private GuideRenderer.Renderable guideRenderable; private final Supplier hoveredSlot; @@ -50,14 +50,15 @@ public AbstractContainerScreenProcessor( this.hoveredSlot = hoveredSlot; this.clickSlotFunction = clickSlotFunction; this.doItemSlotActions = doItemSlotActions; - this.guideInstance = GuideDomains.CONTAINER.createInstance(); + this.guideInstance = ContextualDomains.INSTANCE.container().createGuideInstance(minecraft.font); } @Override protected void handleScreenVMouse(ControllerEntity controller, VirtualMouseHandler vmouse) { var accessor = (AbstractContainerScreenAccessor) screen; - var ctx = new ContainerCtx( + var ctx = new ContainerContext( + minecraft.player, hoveredSlot.get(), screen.getMenu().getCarried(), accessor.controlify$invokeHasClickedOutside( @@ -69,7 +70,7 @@ protected void handleScreenVMouse(ControllerEntity controller, VirtualMouseHandl controller, controller.settings().generic.guide.verbosity ); - this.guideInstance.update(ctx, minecraft.font); + this.guideInstance.update(ctx); Slot hoveredSlot = this.hoveredSlot.get(); if (hoveredSlot != null) { diff --git a/src/main/java/dev/isxander/controlify/utils/animation/api/AnimationSequence.java b/src/main/java/dev/isxander/controlify/utils/animation/api/AnimationSequence.java index 9c954e361..db303d8f3 100644 --- a/src/main/java/dev/isxander/controlify/utils/animation/api/AnimationSequence.java +++ b/src/main/java/dev/isxander/controlify/utils/animation/api/AnimationSequence.java @@ -10,7 +10,7 @@ /** * A sequence of {@link Animatable}s where each animatable is played one - * after the other, and is only finished when the last child finishes. + * after the other, and is only finishedwhenthe last child finishes. */ public non-sealed interface AnimationSequence extends Animatable { /** diff --git a/src/main/java/dev/isxander/controlify/utils/codec/CExtraCodecs.java b/src/main/java/dev/isxander/controlify/utils/codec/CExtraCodecs.java index dcd225c36..1dc3e2128 100644 --- a/src/main/java/dev/isxander/controlify/utils/codec/CExtraCodecs.java +++ b/src/main/java/dev/isxander/controlify/utils/codec/CExtraCodecs.java @@ -19,6 +19,11 @@ import java.util.stream.Stream; public final class CExtraCodecs { + public static Codec fuzzy( + List> codecs, + Function> encoderGetter) { + return new FuzzyCodec<>(codecs, encoderGetter); + } public static MapCodec fuzzyMap( List> codecs, diff --git a/src/main/java/dev/isxander/controlify/utils/codec/FuzzyCodec.java b/src/main/java/dev/isxander/controlify/utils/codec/FuzzyCodec.java new file mode 100644 index 000000000..110e3e7b2 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/utils/codec/FuzzyCodec.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.utils.codec; + +import com.mojang.datafixers.util.Pair; +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.Decoder; +import com.mojang.serialization.DynamicOps; +import com.mojang.serialization.Encoder; + +import java.util.List; +import java.util.function.Function; + +@SuppressWarnings("unchecked") +public class FuzzyCodec implements Codec { + private final List> codecs; + private final Function> encoderGetter; + + public FuzzyCodec(List> codecs, Function> encoderGetter) { + this.codecs = codecs; + this.encoderGetter = encoderGetter; + } + + @Override + public DataResult> decode(DynamicOps ops, T1 input) { + for (Decoder decoder : this.codecs) { + DataResult> result = decoder.decode(ops, input); + if (result.result().isPresent()) { + return (DataResult>) result; + } + } + + return DataResult.error(() -> "No matching codec found."); + } + + @Override + public DataResult encode(T input, DynamicOps ops, T1 prefix) { + Encoder encoder = (Encoder) this.encoderGetter.apply(input); + return encoder.encode(input, ops, prefix); + } +} diff --git a/src/main/java/dev/isxander/controlify/utils/predicates/ClientEntityPredicate.java b/src/main/java/dev/isxander/controlify/utils/predicates/ClientEntityPredicate.java new file mode 100644 index 000000000..55a92881c --- /dev/null +++ b/src/main/java/dev/isxander/controlify/utils/predicates/ClientEntityPredicate.java @@ -0,0 +1,144 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.utils.predicates; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +//? if >=26.2 { +import net.minecraft.advancements.predicates.*; +import net.minecraft.advancements.predicates.entity.*; +//?} else { +/*import net.minecraft.advancements.criterion.*; +*///?} +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.util.ExtraCodecs; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.phys.Vec3; +import net.minecraft.world.scores.Team; +import org.jetbrains.annotations.Nullable; + +import java.util.Optional; + +/// A subset of vanilla's entity predicate that works +/// for all entities on the client. The vanilla one requires server-side-only data. +/// +/// This predicate also queries client-sided tags via {@link ClientPredicateUtils}. +/// +/// This predicate is missing the following from its server-sided counterpart: +/// - `location` +/// - `nbt` +/// - `type_specific` +/// - `targeted_entity` +/// - data component matcher +/// +/// This predicate has additional conditions which its server-sided counterpart doesn't have: +/// - `game_mode` +public record ClientEntityPredicate( + Optional entityType, + Optional distanceToPlayer, + Optional movement, + Optional effects, + Optional flags, + Optional equipment, + Optional periodicTick, + Optional vehicle, + Optional passenger, + Optional team, + Optional slots, + Optional gameMode +) { + public static final Codec CODEC = Codec.recursive( + "ClientEntityPredicate", + subCodec -> RecordCodecBuilder.create(instance -> instance.group( + EntityTypePredicate.CODEC.optionalFieldOf("type").forGetter(ClientEntityPredicate::entityType), + DistancePredicate.CODEC.optionalFieldOf("distance").forGetter(ClientEntityPredicate::distanceToPlayer), + MovementPredicate.CODEC.optionalFieldOf("movement").forGetter(ClientEntityPredicate::movement), + MobEffectsPredicate.CODEC.optionalFieldOf("effects").forGetter(ClientEntityPredicate::effects), + EntityFlagsPredicate.CODEC.optionalFieldOf("flags").forGetter(ClientEntityPredicate::flags), + EntityEquipmentPredicate.CODEC.optionalFieldOf("equipment").forGetter(ClientEntityPredicate::equipment), + ExtraCodecs.POSITIVE_INT.optionalFieldOf("periodic_tick").forGetter(ClientEntityPredicate::periodicTick), + subCodec.optionalFieldOf("vehicle").forGetter(ClientEntityPredicate::vehicle), + subCodec.optionalFieldOf("passenger").forGetter(ClientEntityPredicate::passenger), + Codec.STRING.optionalFieldOf("team").forGetter(ClientEntityPredicate::team), + SlotsPredicate.CODEC.optionalFieldOf("slots").forGetter(ClientEntityPredicate::slots), + GameTypePredicate.CODEC.optionalFieldOf("game_mode").forGetter(ClientEntityPredicate::gameMode) + ).apply(instance, ClientEntityPredicate::new)) + ); + + public boolean matches(LocalPlayer player, @Nullable Entity entity) { + return this.matches(player.position(), entity); + } + + public boolean matches(@Nullable Vec3 position, Entity entity) { + if (entity == null) { + return false; + } + + if (this.entityType.isPresent() && !ClientPredicateUtils.matches(this.entityType.get(), entity)) { + return false; + } + + if (position == null) { + if (this.distanceToPlayer.isPresent()) { + return false; + } + } else if (this.distanceToPlayer.isPresent() + && !this.distanceToPlayer.get().matches(position.x, position.y, position.z, entity.getX(), entity.getY(), entity.getZ())) { + return false; + } + + if (this.movement.isPresent()) { + Vec3 knownMovement = entity.getKnownMovement(); + Vec3 velocity = knownMovement.scale(20.0); + if (!this.movement.get().matches(velocity.x, velocity.y, velocity.z, entity.fallDistance)) { + return false; + } + } + + if (this.effects.isPresent() && !effects.get().matches(entity)) { + return false; + } + + if (this.flags.isPresent() && !this.flags.get().matches(entity)) { + return false; + } + + if (this.equipment.isPresent() && !this.equipment.get().matches(entity)) { + return false; + } + + if (this.vehicle.isPresent() && !this.vehicle.get().matches(position, entity.getVehicle())) { + return false; + } + + if (this.passenger.isPresent() && entity.getPassengers().stream().noneMatch(p -> this.passenger.get().matches(position, p))) { + return false; + } + + if (this.periodicTick.isPresent() && entity.tickCount % this.periodicTick.get() != 0) { + return false; + } + + if (this.team.isPresent()) { + Team team = entity.getTeam(); + if (team == null || !this.team.get().equals(team.getName())) { + return false; + } + } + + if (this.slots.isPresent() && !ClientPredicateUtils.matches(this.slots.get(), entity)) { + return false; + } + + if (this.gameMode.isPresent() && ((!(entity instanceof Player player) || !this.gameMode.get().matches(player.gameMode())))) { + return false; + } + + return true; + } +} diff --git a/src/main/java/dev/isxander/controlify/utils/predicates/ClientPredicateUtils.java b/src/main/java/dev/isxander/controlify/utils/predicates/ClientPredicateUtils.java new file mode 100644 index 000000000..6e48b0cd2 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/utils/predicates/ClientPredicateUtils.java @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.utils.predicates; + +import dev.isxander.controlify.utils.CUtil; +import it.unimi.dsi.fastutil.ints.IntList; +//? if >=26.2 { +import net.minecraft.advancements.predicates.*; +import net.minecraft.advancements.predicates.entity.*; +//?} else { +/*import net.minecraft.advancements.criterion.*; +*///?} +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.SlotAccess; +import net.minecraft.world.entity.SlotProvider; +import net.minecraft.world.inventory.SlotRange; +import net.minecraft.world.item.ItemInstance; +import net.minecraft.world.item.ItemStack; + +import java.util.Map; + +public final class ClientPredicateUtils { + + private ClientPredicateUtils() { + } + + public static boolean matches(EntityTypePredicate predicate, Entity entity) { + return CUtil.isInWithLocalFallback(entity, predicate.types()); + } + + public static boolean matches(ItemPredicate predicate, ItemInstance item) { + if (predicate.items().isPresent() && !CUtil.isInWithLocalFallback(item, predicate.items().get())) { + return false; + } else { + return predicate.count().matches(item.count()) && predicate.components().test(item); + } + } + + public static boolean matches(SlotsPredicate predicate, SlotProvider slotProvider) { + for (Map.Entry entry : predicate.slots().entrySet()) { + if (!matchSlots(slotProvider, entry.getValue(), entry.getKey().slots())) { + return false; + } + } + + return true; + } + + private static boolean matchSlots(SlotProvider slotProvider, ItemPredicate test, IntList slots) { + for (int i = 0; i < slots.size(); i++) { + int slotId = slots.getInt(i); + SlotAccess slot = slotProvider.getSlot(slotId); + if (slot != null && ClientPredicateUtils.matches(test, slot.get())) { + return true; + } + } + + return false; + } +} diff --git a/src/main/java/dev/isxander/controlify/utils/predicates/EntityEquipmentPredicate.java b/src/main/java/dev/isxander/controlify/utils/predicates/EntityEquipmentPredicate.java new file mode 100644 index 000000000..971862f2b --- /dev/null +++ b/src/main/java/dev/isxander/controlify/utils/predicates/EntityEquipmentPredicate.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.utils.predicates; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import java.util.Optional; + +//? if >=26.2 { +import net.minecraft.advancements.predicates.ItemPredicate; +//?} else { +/*import net.minecraft.advancements.criterion.ItemPredicate; +*///?} +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EquipmentSlot; +import net.minecraft.world.entity.LivingEntity; +import org.jspecify.annotations.Nullable; + +public record EntityEquipmentPredicate( + Optional head, + Optional chest, + Optional legs, + Optional feet, + Optional body, + Optional mainhand, + Optional offhand, + Optional saddle +) { + public static final Codec CODEC = RecordCodecBuilder.create( + i -> i.group( + ItemPredicate.CODEC.optionalFieldOf("head").forGetter(EntityEquipmentPredicate::head), + ItemPredicate.CODEC.optionalFieldOf("chest").forGetter(EntityEquipmentPredicate::chest), + ItemPredicate.CODEC.optionalFieldOf("legs").forGetter(EntityEquipmentPredicate::legs), + ItemPredicate.CODEC.optionalFieldOf("feet").forGetter(EntityEquipmentPredicate::feet), + ItemPredicate.CODEC.optionalFieldOf("body").forGetter(EntityEquipmentPredicate::body), + ItemPredicate.CODEC.optionalFieldOf("mainhand").forGetter(EntityEquipmentPredicate::mainhand), + ItemPredicate.CODEC.optionalFieldOf("offhand").forGetter(EntityEquipmentPredicate::offhand), + ItemPredicate.CODEC.optionalFieldOf("saddle").forGetter(EntityEquipmentPredicate::saddle) + ) + .apply(i, EntityEquipmentPredicate::new) + ); + + public boolean matches(final @Nullable Entity entity) { + if (entity instanceof LivingEntity livingEntity) { + if (this.head.isPresent() && !ClientPredicateUtils.matches(this.head.get(), livingEntity.getItemBySlot(EquipmentSlot.HEAD))) { + return false; + } else if (this.chest.isPresent() && !ClientPredicateUtils.matches(this.chest.get(), livingEntity.getItemBySlot(EquipmentSlot.CHEST))) { + return false; + } else if (this.legs.isPresent() && !ClientPredicateUtils.matches(this.legs.get(), livingEntity.getItemBySlot(EquipmentSlot.LEGS))) { + return false; + } else if (this.feet.isPresent() && !ClientPredicateUtils.matches(this.feet.get(), livingEntity.getItemBySlot(EquipmentSlot.FEET))) { + return false; + } else if (this.body.isPresent() && !ClientPredicateUtils.matches(this.body.get(), livingEntity.getItemBySlot(EquipmentSlot.BODY))) { + return false; + } else if (this.mainhand.isPresent() && !ClientPredicateUtils.matches(this.mainhand.get(), livingEntity.getItemBySlot(EquipmentSlot.MAINHAND))) { + return false; + } else if (this.offhand.isPresent() && !ClientPredicateUtils.matches(this.offhand.get(), livingEntity.getItemBySlot(EquipmentSlot.OFFHAND))) { + return false; + } else if (this.saddle.isPresent() && !ClientPredicateUtils.matches(this.saddle.get(), livingEntity.getItemBySlot(EquipmentSlot.SADDLE))) { + return false; + } + + return true; + } + + return false; + } +} diff --git a/src/main/resources/assets/controlify/contextual/facts/container.json b/src/main/resources/assets/controlify/contextual/facts/container.json new file mode 100644 index 000000000..95017c130 --- /dev/null +++ b/src/main/resources/assets/controlify/contextual/facts/container.json @@ -0,0 +1,50 @@ +{ + "controlify:hovering_slot": { + "slot": "controlify:hovering_slot", + "item": {} + }, + "controlify:hovering_item": { + "slot": "controlify:hovering_slot", + "item": { + "count": { + "min": 1 + } + } + }, + "controlify:hovering_many_items": { + "slot": "controlify:hovering_slot", + "item": { + "count": { + "min": 2 + } + } + }, + "controlify:holding_item": { + "slot": "controlify:holding_item", + "item": { + "count": { + "min": 1 + } + } + }, + "controlify:holding_many_items": { + "slot": "controlify:holding_item", + "item": { + "count": { + "min": 2 + } + } + }, + "controlify:hovering_item_is_bundle": { + "slot": "controlify:hovering_slot", + "item": { + "predicates": { + "minecraft:bundle_contents": {} + } + } + }, + "controlify:selected_bundle_slot": { + "slot": "controlify:bundle_selected", + "item": {} + } +} diff --git a/src/main/resources/assets/controlify/contextual/facts/in_game.json b/src/main/resources/assets/controlify/contextual/facts/in_game.json new file mode 100644 index 000000000..27fc1ceea --- /dev/null +++ b/src/main/resources/assets/controlify/contextual/facts/in_game.json @@ -0,0 +1,321 @@ +{ + "controlify:on_ground": { + "slot": "controlify:self", + "entity": { "flags": { "is_on_ground": true } } + }, + "controlify:in_vehicle": { + "slot": "controlify:self", + "entity": { "vehicle": {} } + }, + "controlify:riding_saddled_horse": { + "slot": "controlify:self", + "entity": { + "vehicle": { + "type": "#minecraft:can_equip_saddle", + "equipment": { "saddle": { "count": { "min": 1 } } } + } + } + }, + "controlify:riding_saddled_equine": { + "slot": "controlify:self", + "entity": { + "vehicle": { + "type": ["minecraft:horse", "minecraft:skeleton_horse", "minecraft:zombie_horse", "minecraft:donkey", "minecraft:mule"], + "equipment": { "saddle": { "count": { "min": 1 } } } + } + } + }, + "controlify:riding_saddled_camel": { + "slot": "controlify:self", + "entity": { + "vehicle": { + "type": ["minecraft:camel", "minecraft:camel_husk"], + "equipment": { "saddle": { "count": { "min": 1 } } } + } + } + }, + "controlify:riding_saddled_nautilus": { + "slot": "controlify:self", + "entity": { + "vehicle": { + "type": ["minecraft:nautilus", "minecraft:zombie_nautilus"], + "equipment": { "saddle": { "count": { "min": 1 } } } + } + } + }, + "controlify:riding_happy_ghast": { + "slot": "controlify:self", + "entity": { + "vehicle": { + "type": "minecraft:happy_ghast", + "equipment": { + "body": { + "items": "#minecraft:harnesses", + "count": { "min": 1 } + } + } + } + } + }, + "controlify:flying": { + "slot": "controlify:self", + "entity": { "flags": { "is_flying": true } } + }, + "controlify:elytra_flying": { + "slot": "controlify:self", + "entity": { "flags": { "is_fall_flying": true } } + }, + "controlify:creative_flying": { + "all_of": ["controlify:flying"], + "none_of": ["controlify:elytra_flying"] + }, + "controlify:in_water": { + "slot": "controlify:self", + "entity": { "flags": { "is_in_water": true } } + }, + "controlify:sneaking": { + "slot": "controlify:self", + "entity": { "flags": { "is_sneaking": true } } + }, + "controlify:sprinting": { + "slot": "controlify:self", + "entity": { "flags": { "is_sprinting": true } } + }, + "controlify:is_spectator": { + "slot": "controlify:self", + "entity": { "game_mode": ["spectator"] } + }, + "controlify:is_creative": { + "slot": "controlify:self", + "entity": { "game_mode": ["creative"] } + }, + "controlify:is_survival": { + "slot": "controlify:self", + "entity": { "game_mode": ["survival"] } + }, + "controlify:looking_at_entity": { + "slot": "controlify:hit_result", + "entity": {} + }, + "controlify:looking_at_block": { + "slot": "controlify:hit_result", + "block": {} + }, + "controlify:looking_at_air": { + "none_of": ["controlify:looking_at_entity", "controlify:looking_at_block"] + }, + "controlify:looking_at_villager": { + "slot": "controlify:hit_result", + "entity": { "type": "minecraft:villager" } + }, + "controlify:looking_at_merchant": { + "slot": "controlify:hit_result", + "entity": { "type": ["minecraft:villager", "minecraft:wandering_trader"] } + }, + "controlify:looking_at_chest_boat": { + "slot": "controlify:hit_result", + "entity": { + "type": [ + "minecraft:acacia_chest_boat", + "minecraft:bamboo_chest_raft", + "minecraft:birch_chest_boat", + "minecraft:cherry_chest_boat", + "minecraft:dark_oak_chest_boat", + "minecraft:jungle_chest_boat", + "minecraft:mangrove_chest_boat", + "minecraft:oak_chest_boat", + "minecraft:pale_oak_chest_boat", + "minecraft:spruce_chest_boat" + ] + } + }, + "controlify:looking_at_container_minecart": { + "slot": "controlify:hit_result", + "entity": { "type": ["minecraft:chest_minecart", "minecraft:hopper_minecart"] } + }, + "controlify:looking_at_rideable": { + "any_of": [ + { + "slot": "controlify:hit_result", + "entity": { "type": "#minecraft:boat" } + }, + "controlify:looking_at_chest_boat", + { + "slot": "controlify:hit_result", + "entity": { "type": "minecraft:minecart" } + }, + { + "slot": "controlify:hit_result", + "entity": { + "type": "#minecraft:can_equip_saddle", + "equipment": { "saddle": { "count": { "min": 1 } } } + } + }, + { + "slot": "controlify:hit_result", + "entity": { + "type": "minecraft:happy_ghast", + "equipment": { + "body": { + "items": "#minecraft:harnesses", + "count": { "min": 1 } + } + } + } + } + ] + }, + "controlify:looking_at_closed_openable": { + "any_of": [ + { + "slot": "controlify:hit_result", + "block": { "blocks": "#minecraft:doors", "state": { "open": "false" } } + }, + { + "slot": "controlify:hit_result", + "block": { "blocks": "#minecraft:trapdoors", "state": { "open": "false" } } + }, + { + "slot": "controlify:hit_result", + "block": { "blocks": "#minecraft:fence_gates", "state": { "open": "false" } } + } + ], + "none_of": [ + { + "slot": "controlify:hit_result", + "block": { "blocks": ["minecraft:iron_door", "minecraft:iron_trapdoor"] } + } + ] + }, + "controlify:looking_at_open_openable": { + "any_of": [ + { + "slot": "controlify:hit_result", + "block": { "blocks": "#minecraft:doors", "state": { "open": "true" } } + }, + { + "slot": "controlify:hit_result", + "block": { "blocks": "#minecraft:trapdoors", "state": { "open": "true" } } + }, + { + "slot": "controlify:hit_result", + "block": { "blocks": "#minecraft:fence_gates", "state": { "open": "true" } } + } + ], + "none_of": [ + { + "slot": "controlify:hit_result", + "block": { "blocks": ["minecraft:iron_door", "minecraft:iron_trapdoor"] } + } + ] + }, + "controlify:looking_at_container": { + "any_of": [ + { + "slot": "controlify:hit_result", + "block": { + "blocks": [ + "minecraft:barrel", + "minecraft:chest", + "minecraft:dispenser", + "minecraft:dropper", + "minecraft:ender_chest", + "minecraft:hopper", + "minecraft:trapped_chest" + ] + } + }, + { + "slot": "controlify:hit_result", + "block": { "blocks": "#minecraft:shulker_boxes" } + }, + { + "slot": "controlify:hit_result", + "block": { "blocks": "#minecraft:copper_chests" } + } + ] + }, + "controlify:looking_at_button": { + "slot": "controlify:hit_result", + "block": { "blocks": "#minecraft:buttons" } + }, + "controlify:looking_at_lever": { + "slot": "controlify:hit_result", + "block": { "blocks": "minecraft:lever" } + }, + "controlify:looking_at_bell": { + "slot": "controlify:hit_result", + "block": { "blocks": "minecraft:bell" } + }, + "controlify:looking_at_bed": { + "slot": "controlify:hit_result", + "block": { "blocks": "#minecraft:beds" } + }, + "controlify:looking_at_crafting_table": { + "slot": "controlify:hit_result", + "block": { "blocks": "minecraft:crafting_table" } + }, + "controlify:looking_at_furnace": { + "slot": "controlify:hit_result", + "block": { "blocks": ["minecraft:furnace", "minecraft:blast_furnace"] } + }, + "controlify:looking_at_smoker": { + "slot": "controlify:hit_result", + "block": { "blocks": "minecraft:smoker" } + }, + "controlify:looking_at_brewing_stand": { + "slot": "controlify:hit_result", + "block": { "blocks": "minecraft:brewing_stand" } + }, + "controlify:looking_at_enchanting_table": { + "slot": "controlify:hit_result", + "block": { "blocks": "minecraft:enchanting_table" } + }, + "controlify:looking_at_smithing_table": { + "slot": "controlify:hit_result", + "block": { "blocks": "minecraft:smithing_table" } + }, + "controlify:looking_at_repair_station": { + "any_of": [ + { + "slot": "controlify:hit_result", + "block": { "blocks": "#minecraft:anvil" } + }, + { + "slot": "controlify:hit_result", + "block": { "blocks": "minecraft:grindstone" } + } + ] + }, + "controlify:looking_at_stonecutter": { + "slot": "controlify:hit_result", + "block": { "blocks": "minecraft:stonecutter" } + }, + "controlify:looking_at_loom": { + "slot": "controlify:hit_result", + "block": { "blocks": "minecraft:loom" } + }, + "controlify:looking_at_cartography_table": { + "slot": "controlify:hit_result", + "block": { "blocks": "minecraft:cartography_table" } + }, + "controlify:has_item_in_main_hand": { + "slot": "controlify:main_hand", + "item": { "count": { "min": 1 } } + }, + "controlify:has_item_in_offhand": { + "slot": "controlify:off_hand", + "item": { "count": { "min": 1 } } + }, + "controlify:has_item_in_either_hand": { + "any_of": ["controlify:has_item_in_main_hand", "controlify:has_item_in_offhand"] + }, + "controlify:has_multiple_items_in_main_hand": { + "slot": "controlify:main_hand", + "item": { "count": { "min": 2 } } + }, + "controlify:main_hand_is_tool": { + "slot": "controlify:main_hand", + "item": { "predicates": { "minecraft:tool": {} } } + } +} diff --git a/src/main/resources/assets/controlify/guides/container.json b/src/main/resources/assets/controlify/contextual/guide/container.json similarity index 57% rename from src/main/resources/assets/controlify/guides/container.json rename to src/main/resources/assets/controlify/contextual/guide/container.json index b2a7eb917..f945ccea7 100644 --- a/src/main/resources/assets/controlify/guides/container.json +++ b/src/main/resources/assets/controlify/contextual/guide/container.json @@ -3,75 +3,82 @@ { "for": "controlify:gui_back", "where": "left", + "if": true, "then": { "translate": "controlify.guide.container.exit" } }, - { "for": "controlify:inv_select", "where": "left", - "when": ["controlify:holding_many_items", "controlify:hovering_item", "controlify:can_place_held_item"], + "if": { "all_of": ["controlify:holding_many_items", "controlify:hovering_item", "controlify:can_place_held_item"] }, "then": { "translate": "controlify.guide.container.place_all" } }, { "for": "controlify:inv_select", "where": "left", - "when": ["controlify:holding_item", "controlify:hovering_item", "controlify:can_place_held_item"], + "if": { "all_of": ["controlify:holding_item", "controlify:hovering_item", "controlify:can_place_held_item"] }, "then": { "translate": "controlify.guide.container.place_one" } }, { "for": "controlify:inv_select", "where": "left", - "when": ["controlify:holding_item", "controlify:hovering_item"], - "forbid": ["controlify:can_place_held_item"], + "if": { + "all_of": ["controlify:holding_item", "controlify:hovering_item"], + "none_of": ["controlify:can_place_held_item"] + }, "then": { "translate": "controlify.guide.container.swap" } }, { "for": "controlify:inv_select", "where": "left", - "when": ["controlify:cursor_outside_container", "controlify:holding_item"], + "if": { "all_of": ["controlify:cursor_outside_container", "controlify:holding_item"] }, "then": { "translate": "controlify.guide.container.drop" } }, { "for": "controlify:inv_select", "where": "left", - "when": ["controlify:hovering_item"], - "forbid": ["controlify:holding_item"], + "if": { + "all_of": ["controlify:hovering_item"], + "none_of": ["controlify:holding_item"] + }, "then": { "translate": "controlify.guide.container.take" } }, - { "for": "controlify:drop_inventory", "where": "right", - "when": ["controlify:holding_item"], + "if": "controlify:holding_item", "then": { "translate": "controlify.guide.container.drop" } }, - { "for": "controlify:inv_quick_move", "where": "right", - "when": ["controlify:hovering_item"], - "forbid": ["controlify:holding_item"], + "if": { + "all_of": ["controlify:hovering_item"], + "none_of": ["controlify:holding_item"] + }, "then": { "translate": "controlify.guide.container.quick_move" } }, - { "for": "controlify:inv_take_half", "where": "right", - "when": ["controlify:hovering_item_is_bundle", "controlify:selected_bundle_slot"], - "forbid": ["controlify:holding_item"], + "if": { + "all_of": ["controlify:hovering_item_is_bundle", "controlify:selected_bundle_slot"], + "none_of": ["controlify:holding_item"] + }, "then": { "translate": "controlify.guide.container.take_from_bundle" } }, { "for": "controlify:inv_take_half", "where": "right", - "when": ["controlify:hovering_many_items"], - "forbid": ["controlify:holding_item"], + "if": { + "all_of": ["controlify:hovering_many_items"], + "none_of": ["controlify:holding_item"] + }, "then": { "translate": "controlify.guide.container.take_half" } }, { "for": "controlify:inv_take_half", "where": "right", - "when": ["controlify:hovering_slot", "controlify:holding_item", "controlify:can_place_held_item"], + "if": { "all_of": ["controlify:hovering_slot", "controlify:holding_item", "controlify:can_place_held_item"] }, "then": { "translate": "controlify.guide.container.take_one" } } ] diff --git a/src/main/resources/assets/controlify/contextual/guide/in_game.json b/src/main/resources/assets/controlify/contextual/guide/in_game.json new file mode 100644 index 000000000..cf0b937b4 --- /dev/null +++ b/src/main/resources/assets/controlify/contextual/guide/in_game.json @@ -0,0 +1,655 @@ +{ + "rules": [ + { + "for": "controlify:jump", + "where": "left", + "if": { "all_of": ["controlify:verbosity_minimal", "controlify:riding_happy_ghast"] }, + "then": { "translate": "controlify.guide.ingame.fly_up" } + }, + { + "for": "controlify:jump", + "where": "left", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + { "any_of": ["controlify:riding_saddled_camel", "controlify:riding_saddled_nautilus"] } + ] + }, + "then": { "translate": "controlify.guide.ingame.dash" } + }, + { + "for": "controlify:jump", + "where": "left", + "if": { "all_of": ["controlify:verbosity_minimal", "controlify:riding_saddled_equine"] }, + "then": { "translate": "key.jump" } + }, + { + "for": "controlify:jump", + "where": "left", + "if": { "all_of": ["controlify:verbosity_minimal", "controlify:creative_flying"] }, + "then": { "translate": "controlify.guide.ingame.fly_up" } + }, + { + "for": "controlify:jump", + "where": "left", + "if": { "all_of": ["controlify:verbosity_minimal", "controlify:in_water"] }, + "then": { "translate": "controlify.guide.ingame.swim_up" } + }, + { + "for": "controlify:jump", + "where": "left", + "if": { "all_of": ["controlify:verbosity_minimal", "controlify:on_climbable"] }, + "then": { "translate": "controlify.guide.ingame.climb_up" } + }, + { + "for": "controlify:jump", + "where": "left", + "if": { "all_of": ["controlify:verbosity_minimal", "controlify:on_ground"] }, + "then": { "translate": "key.jump" } + }, + { + "for": "controlify:jump", + "where": "left", + "if": { "all_of": ["controlify:verbosity_minimal", "controlify:can_elytra_fly"] }, + "then": { "translate": "controlify.guide.ingame.start_elytra" } + }, + { + "for": "controlify:sneak", + "where": "left", + "if": { "all_of": ["controlify:verbosity_minimal", "controlify:in_vehicle"] }, + "then": { "translate": "controlify.guide.ingame.dismount" } + }, + { + "for": "controlify:sneak", + "where": "left", + "if": { "all_of": ["controlify:verbosity_minimal", "controlify:creative_flying"] }, + "then": { "translate": "controlify.guide.ingame.fly_down" } + }, + { + "for": "controlify:sneak", + "where": "left", + "if": { + "all_of": ["controlify:verbosity_minimal", "controlify:in_water"], + "none_of": ["controlify:on_ground"] + }, + "then": { "translate": "controlify.guide.ingame.swim_down" } + }, + { + "for": "controlify:sneak", + "where": "left", + "if": { "all_of": ["controlify:verbosity_minimal", "controlify:on_climbable"] }, + "then": { "translate": "controlify.guide.ingame.climb_down" } + }, + { + "for": "controlify:sneak", + "where": "left", + "if": { "all_of": ["controlify:verbosity_minimal", "controlify:is_toggle_sneak", "controlify:sneaking"] }, + "then": { "translate": "controlify.guide.ingame.stop_sneaking" } + }, + { + "for": "controlify:sneak", + "where": "left", + "if": { + "all_of": ["controlify:verbosity_minimal", "controlify:is_toggle_sneak"], + "none_of": ["controlify:sneaking"] + }, + "then": { "translate": "controlify.guide.ingame.start_sneaking" } + }, + { + "for": "controlify:sneak", + "where": "left", + "if": { + "all_of": ["controlify:verbosity_minimal"], + "none_of": ["controlify:is_toggle_sneak", "controlify:sneaking"] + }, + "then": { "translate": "controlify.guide.ingame.sneak" } + }, + { + "for": "controlify:sprint", + "where": "left", + "if": { + "all_of": ["controlify:verbosity_reduced", "controlify:input_moving", "controlify:under_water"], + "none_of": ["controlify:sprinting", "controlify:in_vehicle", "controlify:flying"] + }, + "then": { "translate": "controlify.guide.ingame.start_swimming" } + }, + { + "for": "controlify:sprint", + "where": "left", + "if": { + "all_of": ["controlify:verbosity_reduced", "controlify:input_moving"], + "none_of": ["controlify:sprinting", "controlify:in_vehicle", "controlify:flying", "controlify:under_water"] + }, + "then": { "translate": "controlify.guide.ingame.start_sprinting" } + }, + { + "for": "controlify:sprint", + "where": "left", + "if": { + "all_of": ["controlify:verbosity_reduced", "controlify:sprinting", "controlify:is_toggle_sprint", "controlify:under_water"], + "none_of": ["controlify:in_vehicle", "controlify:flying"] + }, + "then": { "translate": "controlify.guide.ingame.stop_swimming" } + }, + { + "for": "controlify:sprint", + "where": "left", + "if": { + "all_of": ["controlify:verbosity_reduced", "controlify:sprinting", "controlify:is_toggle_sprint"], + "none_of": ["controlify:under_water", "controlify:in_vehicle", "controlify:flying"] + }, + "then": { "translate": "controlify.guide.ingame.stop_sprinting" } + }, + { + "for": "controlify:inventory", + "where": "left", + "if": "controlify:verbosity_reduced", + "then": { "translate": "controlify.guide.ingame.inventory" } + }, + { + "for": "controlify:radial_menu", + "where": "left", + "if": "controlify:verbosity_full", + "then": { "translate": "controlify.gui.radial_menu" } + }, + { + "for": "controlify:attack", + "where": "right", + "if": { "all_of": ["controlify:verbosity_minimal", "controlify:looking_at_entity", "controlify:is_spectator"] }, + "then": { "translate": "controlify.guide.ingame.spectate" } + }, + { + "for": "controlify:attack", + "where": "right", + "if": { + "all_of": ["controlify:verbosity_minimal", "controlify:looking_at_entity"], + "none_of": ["controlify:is_spectator"] + }, + "then": { "translate": "controlify.guide.ingame.attack" } + }, + { + "for": "controlify:attack", + "where": "right", + "if": { "all_of": ["controlify:verbosity_minimal", "controlify:looking_at_block", "controlify:main_hand_is_tool"] }, + "then": { "translate": "controlify.guide.ingame.mine" } + }, + { + "for": "controlify:attack", + "where": "right", + "if": { "all_of": ["controlify:verbosity_minimal", "controlify:looking_at_block"] }, + "then": { "translate": "controlify.guide.ingame.break" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { "all_of": ["controlify:verbosity_minimal", "controlify:looking_at_entity", "controlify:is_spectator"] }, + "then": { "translate": "controlify.guide.ingame.spectate" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { "all_of": ["controlify:verbosity_minimal", "controlify:looking_at_merchant"] }, + "then": { "translate": "controlify.guide.ingame.trade" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { "all_of": ["controlify:verbosity_minimal", "controlify:looking_at_chest_boat", "controlify:sneaking"] }, + "then": { "translate": "controlify.guide.ingame.open" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { "all_of": ["controlify:verbosity_minimal", "controlify:looking_at_container_minecart"] }, + "then": { "translate": "controlify.guide.ingame.open" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": ["controlify:verbosity_minimal", "controlify:looking_at_rideable"], + "none_of": ["controlify:sneaking"] + }, + "then": { "translate": "controlify.guide.ingame.ride" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": ["controlify:verbosity_minimal", "controlify:looking_at_closed_openable"], + "none_of": ["controlify:sneaking"] + }, + "then": { "translate": "controlify.guide.ingame.open" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": ["controlify:verbosity_minimal", "controlify:looking_at_open_openable"], + "none_of": ["controlify:sneaking"] + }, + "then": { "translate": "controlify.guide.ingame.close" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": ["controlify:verbosity_minimal", "controlify:looking_at_container"], + "none_of": ["controlify:sneaking"] + }, + "then": { "translate": "controlify.guide.ingame.open" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { "all_of": ["controlify:verbosity_minimal", "controlify:looking_at_button"] }, + "then": { "translate": "controlify.guide.ingame.press" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { "all_of": ["controlify:verbosity_minimal", "controlify:looking_at_lever"] }, + "then": { "translate": "controlify.guide.ingame.toggle" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { "all_of": ["controlify:verbosity_minimal", "controlify:looking_at_bell"] }, + "then": { "translate": "controlify.guide.ingame.ring" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": ["controlify:verbosity_minimal", "controlify:looking_at_bed"], + "none_of": ["controlify:sneaking"] + }, + "then": { "translate": "controlify.guide.ingame.sleep" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { "all_of": ["controlify:verbosity_minimal", "controlify:looking_at_crafting_table"], "none_of": ["controlify:sneaking"] }, + "then": { "translate": "controlify.guide.ingame.craft" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { "all_of": ["controlify:verbosity_minimal", "controlify:looking_at_furnace"], "none_of": ["controlify:sneaking"] }, + "then": { "translate": "controlify.guide.ingame.smelt" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { "all_of": ["controlify:verbosity_minimal", "controlify:looking_at_smoker"], "none_of": ["controlify:sneaking"] }, + "then": { "translate": "controlify.guide.ingame.cook" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { "all_of": ["controlify:verbosity_minimal", "controlify:looking_at_brewing_stand"], "none_of": ["controlify:sneaking"] }, + "then": { "translate": "controlify.guide.ingame.brew" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { "all_of": ["controlify:verbosity_minimal", "controlify:looking_at_enchanting_table"], "none_of": ["controlify:sneaking"] }, + "then": { "translate": "controlify.guide.ingame.enchant" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { "all_of": ["controlify:verbosity_minimal", "controlify:looking_at_smithing_table"], "none_of": ["controlify:sneaking"] }, + "then": { "translate": "controlify.guide.ingame.smith" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { "all_of": ["controlify:verbosity_minimal", "controlify:looking_at_repair_station"], "none_of": ["controlify:sneaking"] }, + "then": { "translate": "controlify.guide.ingame.repair" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { "all_of": ["controlify:verbosity_minimal", "controlify:looking_at_stonecutter"], "none_of": ["controlify:sneaking"] }, + "then": { "translate": "controlify.guide.ingame.cut_stone" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { "all_of": ["controlify:verbosity_minimal", "controlify:looking_at_loom"], "none_of": ["controlify:sneaking"] }, + "then": { "translate": "controlify.guide.ingame.weave" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { "all_of": ["controlify:verbosity_minimal", "controlify:looking_at_cartography_table"], "none_of": ["controlify:sneaking"] }, + "then": { "translate": "controlify.guide.ingame.edit_map" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + { "slot": "controlify:main_hand", "item": { "items": "#c:tools/bow" } } + ] + }, + "then": { "translate": "controlify.guide.ingame.shoot" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + { "slot": "controlify:main_hand", "item": { "predicates": { "minecraft:charged_projectiles": {} } } } + ], + "none_of": [ + { "slot": "controlify:main_hand", "item": { "components": { "minecraft:charged_projectiles": [] } } } + ] + }, + "then": { "translate": "controlify.guide.ingame.shoot" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + { "slot": "controlify:main_hand", "item": { "items": "minecraft:crossbow", "components": { "minecraft:charged_projectiles": [] } } } + ] + }, + "then": { "translate": "controlify.guide.ingame.load" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + { "slot": "controlify:main_hand", "item": { "predicates": { "minecraft:blocks_attacks": {} } } } + ] + }, + "then": { "translate": "controlify.guide.ingame.block" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + { "slot": "controlify:main_hand", "item": { "predicates": { "minecraft:consumable": {} } } } + ] + }, + "then": { "translate": "controlify.guide.ingame.consume" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + { + "any_of": [ + { "slot": "controlify:main_hand", "item": { "items": "#c:tools/trident" } }, + { "slot": "controlify:main_hand", "item": { "items": "#minecraft:spears" } }, + { "slot": "controlify:main_hand", "item": { "items": "#minecraft:eggs" } }, + { + "slot": "controlify:main_hand", + "item": { + "items": [ + "minecraft:snowball", + "minecraft:splash_potion", + "minecraft:lingering_potion", + "minecraft:experience_bottle", + "minecraft:ender_pearl", + "minecraft:wind_charge" + ] + } + } + ] + } + ] + }, + "then": { "translate": "controlify.guide.ingame.throw" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:fishing_hook_cast", + { "slot": "controlify:main_hand", "item": { "items": "#c:tools/fishing_rod" } } + ] + }, + "then": { "translate": "controlify.guide.ingame.reel_in" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + { "slot": "controlify:main_hand", "item": { "items": "#c:tools/fishing_rod" } } + ], + "none_of": ["controlify:fishing_hook_cast"] + }, + "then": { "translate": "controlify.guide.ingame.cast" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { "all_of": ["controlify:verbosity_minimal", { "slot": "controlify:main_hand", "item": { "items": "#c:tools/brush" } }] }, + "then": { "translate": "controlify.guide.ingame.brush" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { "all_of": ["controlify:verbosity_minimal", { "slot": "controlify:main_hand", "item": { "items": "minecraft:spyglass" } }] }, + "then": { "translate": "controlify.guide.ingame.zoom" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { "all_of": ["controlify:verbosity_minimal", { "slot": "controlify:main_hand", "item": { "predicates": { "minecraft:instrument": {} } } }] }, + "then": { "translate": "controlify.guide.ingame.play" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { "all_of": ["controlify:verbosity_minimal", { "slot": "controlify:main_hand", "item": { "predicates": { "minecraft:equippable": {} } } }] }, + "then": { "translate": "controlify.guide.ingame.equip" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { "all_of": ["controlify:verbosity_minimal", "controlify:looking_at_block", "controlify:main_hand_is_block_item"] }, + "then": { "translate": "controlify.guide.ingame.place" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + { "slot": "controlify:off_hand", "item": { "items": "#c:tools/bow" } } + ] + }, + "then": { "translate": "controlify.guide.ingame.shoot" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + { "slot": "controlify:off_hand", "item": { "predicates": { "minecraft:charged_projectiles": {} } } } + ], + "none_of": [ + { "slot": "controlify:off_hand", "item": { "components": { "minecraft:charged_projectiles": [] } } } + ] + }, + "then": { "translate": "controlify.guide.ingame.shoot" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + { "slot": "controlify:off_hand", "item": { "items": "minecraft:crossbow", "components": { "minecraft:charged_projectiles": [] } } } + ] + }, + "then": { "translate": "controlify.guide.ingame.load" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { "all_of": ["controlify:verbosity_minimal", { "slot": "controlify:off_hand", "item": { "predicates": { "minecraft:blocks_attacks": {} } } }] }, + "then": { "translate": "controlify.guide.ingame.block" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { "all_of": ["controlify:verbosity_minimal", { "slot": "controlify:off_hand", "item": { "predicates": { "minecraft:consumable": {} } } }] }, + "then": { "translate": "controlify.guide.ingame.consume" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + { + "any_of": [ + { "slot": "controlify:off_hand", "item": { "items": "#c:tools/trident" } }, + { "slot": "controlify:off_hand", "item": { "items": "#minecraft:spears" } }, + { "slot": "controlify:off_hand", "item": { "items": "#minecraft:eggs" } }, + { + "slot": "controlify:off_hand", + "item": { + "items": [ + "minecraft:snowball", + "minecraft:splash_potion", + "minecraft:lingering_potion", + "minecraft:experience_bottle", + "minecraft:ender_pearl", + "minecraft:wind_charge" + ] + } + } + ] + } + ] + }, + "then": { "translate": "controlify.guide.ingame.throw" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:fishing_hook_cast", + { "slot": "controlify:off_hand", "item": { "items": "#c:tools/fishing_rod" } } + ] + }, + "then": { "translate": "controlify.guide.ingame.reel_in" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + { "slot": "controlify:off_hand", "item": { "items": "#c:tools/fishing_rod" } } + ], + "none_of": ["controlify:fishing_hook_cast"] + }, + "then": { "translate": "controlify.guide.ingame.cast" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { "all_of": ["controlify:verbosity_minimal", { "slot": "controlify:off_hand", "item": { "items": "#c:tools/brush" } }] }, + "then": { "translate": "controlify.guide.ingame.brush" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { "all_of": ["controlify:verbosity_minimal", { "slot": "controlify:off_hand", "item": { "items": "minecraft:spyglass" } }] }, + "then": { "translate": "controlify.guide.ingame.zoom" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { "all_of": ["controlify:verbosity_minimal", { "slot": "controlify:off_hand", "item": { "predicates": { "minecraft:instrument": {} } } }] }, + "then": { "translate": "controlify.guide.ingame.play" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { "all_of": ["controlify:verbosity_minimal", { "slot": "controlify:off_hand", "item": { "predicates": { "minecraft:equippable": {} } } }] }, + "then": { "translate": "controlify.guide.ingame.equip" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { "all_of": ["controlify:verbosity_minimal", "controlify:looking_at_block", "controlify:off_hand_is_block_item"] }, + "then": { "translate": "controlify.guide.ingame.place" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": ["controlify:verbosity_minimal", "controlify:looking_at_entity"], + "none_of": ["controlify:is_spectator"] + }, + "then": { "translate": "controlify.guide.ingame.interact" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { "all_of": ["controlify:verbosity_minimal", "controlify:looking_at_block"] }, + "then": { "translate": "controlify.guide.ingame.use" } + }, + { + "for": "controlify:use", + "where": "right", + "if": { "all_of": ["controlify:verbosity_minimal", "controlify:has_item_in_either_hand"] }, + "then": { "translate": "controlify.guide.ingame.use" } + }, + { + "for": "controlify:drop", + "where": "right", + "if": { "all_of": ["controlify:verbosity_reduced", "controlify:has_item_in_main_hand"] }, + "then": { "translate": "controlify.guide.ingame.drop" } + }, + { + "for": "controlify:drop_stack", + "where": "right", + "if": { "all_of": ["controlify:verbosity_full", "controlify:has_multiple_items_in_main_hand"] }, + "then": { "translate": "controlify.binding.controlify.drop_stack" } + }, + { + "for": "controlify:swap_hands", + "where": "right", + "if": { "all_of": ["controlify:verbosity_reduced", "controlify:has_item_in_either_hand"] }, + "then": { "translate": "controlify.guide.ingame.swap_hands" } + }, + { + "for": "controlify:pick_block", + "where": "right", + "if": { "all_of": ["controlify:verbosity_full", "controlify:looking_at_block", "controlify:is_creative"] }, + "then": { "translate": "controlify.guide.ingame.pick_block" } + }, + { + "for": "controlify:pick_block_nbt", + "where": "right", + "if": { "all_of": ["controlify:verbosity_full", "controlify:looking_at_block", "controlify:is_creative"] }, + "then": { "translate": "controlify.binding.controlify.pick_block_nbt" } + } + ] +} diff --git a/src/main/resources/assets/controlify/contextual/trigger_effect/in_game.json b/src/main/resources/assets/controlify/contextual/trigger_effect/in_game.json new file mode 100644 index 000000000..cd16ab2f7 --- /dev/null +++ b/src/main/resources/assets/controlify/contextual/trigger_effect/in_game.json @@ -0,0 +1,553 @@ +{ + "rules": [ + { + "for": "controlify:use", + "if": { + "slot": "controlify:active_item", + "item": { + "items": "#c:tools/bow" + } + }, + "then": { + "type": "feedback_slope", + "start_position": 3, + "end_position": 9, + "start_strength": 2, + "end_strength": 8 + } + }, + { + "for": "controlify:use", + "if": { + "slot": "controlify:active_item", + "item": { + "components": { + "minecraft:charged_projectiles": [] + } + } + }, + "then": { + "type": "feedback_slope", + "start_position": 2, + "end_position": 9, + "start_strength": 5, + "end_strength": 8 + } + }, + { + "for": "controlify:use", + "if": { + "slot": "controlify:active_item", + "item": { + "predicates": { + "minecraft:charged_projectiles": {} + } + } + }, + "then": { + "type": "weapon", + "start_position": 3, + "end_position": 5, + "strength": 1 + } + }, + { + "for": "controlify:use", + "if": { + "slot": "controlify:active_item", + "item": { + "predicates": { + "minecraft:consumable": {} + } + } + }, + "then": { + "type": "feedback", + "position": 3, + "strength": 1 + } + }, + { + "for": "controlify:use", + "if": { + "slot": "controlify:active_item", + "item": { + "predicates": { + "minecraft:blocks_attacks": {} + } + } + }, + "then": { + "type": "feedback", + "position": 3, + "strength": 3 + } + }, + { + "for": "controlify:use", + "if": { + "slot": "controlify:active_item", + "item": { + "predicates": { + "minecraft:equippable": {} + } + } + }, + "then": { + "type": "weapon", + "start_position": 3, + "end_position": 5, + "strength": 1 + } + }, + { + "for": "controlify:use", + "if": { + "slot": "controlify:active_item", + "item": { + "predicates": { + "minecraft:kinetic_weapon": {} + } + } + }, + "then": { + "type": "feedback", + "position": 3, + "strength": 3 + } + }, + { + "for": "controlify:use", + "if": { + "slot": "controlify:active_item", + "item": { + "predicates": { + "minecraft:instrument": {} + } + } + }, + "then": { + "type": "feedback", + "position": 3, + "strength": 3 + } + }, + { + "for": "controlify:use", + "if": { + "slot": "controlify:active_item", + "item": { + "items": "#c:tools/brush" + } + }, + "then": { + "type": "vibration", + "position": 2, + "amplitude": 2, + "frequency": 15 + } + }, + { + "for": "controlify:use", + "if": { + "slot": "controlify:active_item", + "item": { + "items": "minecraft:spyglass" + } + }, + "then": { + "type": "feedback", + "position": 3, + "strength": 1 + } + }, + { + "for": "controlify:use", + "if": { + "slot": "controlify:active_item", + "item": { + "items": "#c:tools/trident" + } + }, + "then": { + "type": "feedback_slope", + "start_position": 2, + "end_position": 9, + "start_strength": 2, + "end_strength": 8 + } + }, + { + "for": "controlify:use", + "if": { + "slot": "controlify:active_item", + "item": { + "items": "#minecraft:eggs" + } + }, + "then": { + "type": "vibration", + "position": 3, + "amplitude": 6, + "frequency": 5 + } + }, + { + "for": "controlify:use", + "if": { + "slot": "controlify:active_item", + "item": { + "items": [ + "minecraft:snowball", + "minecraft:splash_potion", + "minecraft:lingering_potion", + "minecraft:experience_bottle" + ] + } + }, + "then": { + "type": "vibration", + "position": 3, + "amplitude": 6, + "frequency": 5 + } + }, + { + "for": "controlify:use", + "if": { + "slot": "controlify:active_item", + "item": { + "items": "#c:tools/fishing_rod" + } + }, + "then": { + "type": "weapon", + "start_position": 3, + "end_position": 5, + "strength": 1 + } + }, + { + "for": "controlify:use", + "if": { + "slot": "controlify:active_item", + "item": { + "predicates": { + "minecraft:bundle_contents": { + "items": { + "size": { + "min": 1 + } + } + } + } + } + }, + "then": { + "type": "feedback", + "position": 3, + "strength": 6 + } + }, + { + "for": "controlify:use", + "if": { + "slot": "controlify:active_item", + "item": { + "predicates": { + "minecraft:use_cooldown": {} + } + } + }, + "then": { + "type": "weapon", + "start_position": 3, + "end_position": 5, + "strength": 1 + } + }, + { + "for": "controlify:use", + "if": { + "slot": "controlify:off_hand", + "item": { + "items": "#c:tools/bow" + } + }, + "then": { + "type": "feedback_slope", + "start_position": 3, + "end_position": 9, + "start_strength": 2, + "end_strength": 8 + } + }, + { + "for": "controlify:use", + "if": { + "slot": "controlify:off_hand", + "item": { + "components": { + "minecraft:charged_projectiles": [] + } + } + }, + "then": { + "type": "feedback_slope", + "start_position": 2, + "end_position": 9, + "start_strength": 5, + "end_strength": 8 + } + }, + { + "for": "controlify:use", + "if": { + "slot": "controlify:off_hand", + "item": { + "predicates": { + "minecraft:charged_projectiles": {} + } + } + }, + "then": { + "type": "weapon", + "start_position": 3, + "end_position": 5, + "strength": 1 + } + }, + { + "for": "controlify:use", + "if": { + "slot": "controlify:off_hand", + "item": { + "predicates": { + "minecraft:consumable": {} + } + } + }, + "then": { + "type": "feedback", + "position": 3, + "strength": 1 + } + }, + { + "for": "controlify:use", + "if": { + "slot": "controlify:off_hand", + "item": { + "predicates": { + "minecraft:blocks_attacks": {} + } + } + }, + "then": { + "type": "feedback", + "position": 3, + "strength": 3 + } + }, + { + "for": "controlify:use", + "if": { + "slot": "controlify:off_hand", + "item": { + "predicates": { + "minecraft:equippable": {} + } + } + }, + "then": { + "type": "weapon", + "start_position": 3, + "end_position": 5, + "strength": 1 + } + }, + { + "for": "controlify:use", + "if": { + "slot": "controlify:off_hand", + "item": { + "predicates": { + "minecraft:kinetic_weapon": {} + } + } + }, + "then": { + "type": "feedback", + "position": 3, + "strength": 3 + } + }, + { + "for": "controlify:use", + "if": { + "slot": "controlify:off_hand", + "item": { + "predicates": { + "minecraft:instrument": {} + } + } + }, + "then": { + "type": "feedback", + "position": 3, + "strength": 3 + } + }, + { + "for": "controlify:use", + "if": { + "slot": "controlify:off_hand", + "item": { + "items": "#c:tools/brush" + } + }, + "then": { + "type": "vibration", + "position": 2, + "amplitude": 2, + "frequency": 15 + } + }, + { + "for": "controlify:use", + "if": { + "slot": "controlify:off_hand", + "item": { + "items": "minecraft:spyglass" + } + }, + "then": { + "type": "feedback", + "position": 3, + "strength": 1 + } + }, + { + "for": "controlify:use", + "if": { + "slot": "controlify:off_hand", + "item": { + "items": "#c:tools/trident" + } + }, + "then": { + "type": "feedback_slope", + "start_position": 2, + "end_position": 9, + "start_strength": 2, + "end_strength": 8 + } + }, + { + "for": "controlify:use", + "if": { + "slot": "controlify:off_hand", + "item": { + "items": "#minecraft:eggs" + } + }, + "then": { + "type": "vibration", + "position": 3, + "amplitude": 6, + "frequency": 5 + } + }, + { + "for": "controlify:use", + "if": { + "slot": "controlify:off_hand", + "item": { + "items": [ + "minecraft:snowball", + "minecraft:splash_potion", + "minecraft:lingering_potion", + "minecraft:experience_bottle" + ] + } + }, + "then": { + "type": "vibration", + "position": 3, + "amplitude": 6, + "frequency": 5 + } + }, + { + "for": "controlify:use", + "if": { + "slot": "controlify:off_hand", + "item": { + "items": "#c:tools/fishing_rod" + } + }, + "then": { + "type": "weapon", + "start_position": 3, + "end_position": 5, + "strength": 1 + } + }, + { + "for": "controlify:use", + "if": { + "slot": "controlify:off_hand", + "item": { + "predicates": { + "minecraft:bundle_contents": { + "items": { + "size": { + "min": 1 + } + } + } + } + } + }, + "then": { + "type": "feedback", + "position": 3, + "strength": 6 + } + }, + { + "for": "controlify:use", + "if": { + "slot": "controlify:off_hand", + "item": { + "predicates": { + "minecraft:use_cooldown": {} + } + } + }, + "then": { + "type": "weapon", + "start_position": 3, + "end_position": 5, + "strength": 1 + } + }, + { + "for": "controlify:attack", + "if": { + "slot": "controlify:main_hand", + "item": { + "predicates": { + "minecraft:weapon": {} + } + } + }, + "then": { + "type": "weapon", + "start_position": 3, + "end_position": 5, + "strength": 1 + } + } + ] +} diff --git a/src/main/resources/assets/controlify/guides/in_game.json b/src/main/resources/assets/controlify/guides/in_game.json deleted file mode 100644 index 800e6ada7..000000000 --- a/src/main/resources/assets/controlify/guides/in_game.json +++ /dev/null @@ -1,200 +0,0 @@ -{ - "rules": [ - { - "for": "controlify:jump", - "where": "left", - "when": ["controlify:riding_happy_ghast"], - "then": { "translate": "controlify.guide.ingame.fly_up" } - }, - { - "for": "controlify:jump", - "where": "left", - "when": ["controlify:riding_saddled_horse"], - "then": { "translate": "key.jump" } - }, - { - "for": "controlify:jump", - "where": "left", - "when": ["controlify:flying"], - "then": { "translate": "controlify.guide.ingame.fly_up" } - }, - { - "for": "controlify:jump", - "where": "left", - "when": ["controlify:in_water"], - "then": { "translate": "controlify.guide.ingame.swim_up" } - }, - { - "for": "controlify:jump", - "where": "left", - "when": ["controlify:on_ground"], - "then": { "translate": "key.jump" } - }, - { - "for": "controlify:jump", - "where": "left", - "when": ["controlify:can_elytra_fly"], - "then": { "translate": "controlify.guide.ingame.start_elytra" } - }, - - { - "for": "controlify:sneak", - "where": "left", - "when": ["controlify:in_vehicle"], - "then": { "translate": "controlify.guide.ingame.dismount" } - }, - { - "for": "controlify:sneak", - "where": "left", - "when": ["controlify:flying"], - "then": { "translate": "controlify.guide.ingame.fly_down" } - }, - { - "for": "controlify:sneak", - "where": "left", - "when": ["controlify:in_water"], - "forbid": ["controlify:on_ground"], - "then": { "translate": "controlify.guide.ingame.swim_down" } - }, - { - "for": "controlify:sneak", - "where": "left", - "when": ["controlify:is_toggle_sneak", "controlify:sneaking"], - "then": { "translate": "controlify.guide.ingame.stop_sneaking" } - }, - { - "for": "controlify:sneak", - "where": "left", - "when": ["controlify:is_toggle_sneak"], - "forbid": ["controlify:sneaking"], - "then": { "translate": "controlify.guide.ingame.start_sneaking" } - }, - { - "for": "controlify:sneak", - "where": "left", - "forbid": ["controlify:is_toggle_sneak", "controlify:sneaking"], - "then": { "translate": "controlify.guide.ingame.sneak" } - }, - - { - "for": "controlify:sprint", - "where": "left", - "when": ["controlify:input_moving", "controlify:under_water"], - "forbid": ["controlify:sprinting", "controlify:in_vehicle", "controlify:flying"], - "then": { "translate": "controlify.guide.ingame.start_swimming" } - }, - { - "for": "controlify:sprint", - "where": "left", - "when": ["controlify:input_moving"], - "forbid": ["controlify:sprinting", "controlify:in_vehicle"], - "then": { "translate": "controlify.guide.ingame.start_sprinting" } - }, - { - "for": "controlify:sprint", - "where": "left", - "when": ["controlify:sprinting", "controlify:is_toggle_sprint", "controlify:under_water", "controlify:in_vehicle"], - "then": { "translate": "controlify.guide.ingame.stop_swimming" } - }, - { - "for": "controlify:sprint", - "where": "left", - "when": ["controlify:sprinting", "controlify:is_toggle_sprint", "controlify:in_vehicle"], - "then": { "translate": "controlify.guide.ingame.stop_sprinting" } - }, - - - { - "for": "controlify:inventory", - "where": "right", - "then": { "translate": "controlify.guide.ingame.inventory" } - }, - - { - "for": "controlify:radial_menu", - "where": "right", - "then": { "translate": "controlify.gui.radial_menu" } - }, - - { - "for": "controlify:attack", - "where": "right", - "when": ["controlify:looking_at_entity", "controlify:is_spectator"], - "then": { "translate": "controlify.guide.ingame.spectate" } - }, - { - "for": "controlify:attack", - "where": "right", - "when": ["controlify:looking_at_entity"], - "forbid": ["controlify:is_spectator"], - "then": { "translate": "controlify.guide.ingame.attack" } - }, - { - "for": "controlify:attack", - "where": "right", - "when": ["controlify:looking_at_block"], - "then": { "translate": "controlify.guide.ingame.break" } - }, - - { - "for": "controlify:use", - "where": "right", - "when": ["controlify:looking_at_entity", "controlify:is_spectator"], - "then": { "translate": "controlify.guide.ingame.spectate" } - }, - { - "for": "controlify:use", - "where": "right", - "when": ["controlify:looking_at_entity"], - "forbid": ["controlify:is_spectator"], - "then": { "translate": "controlify.guide.ingame.interact" } - }, - { - "for": "controlify:use", - "where": "right", - "when": ["controlify:looking_at_block"], - "then": { "translate": "controlify.guide.ingame.use" } - }, - { - "for": "controlify:use", - "where": "right", - "when": ["controlify:has_item_in_either_hand"], - "then": { "translate": "controlify.guide.ingame.use" } - }, - - { - "for": "controlify:drop", - "where": "right", - "when": ["controlify:has_item_in_mainhand"], - "then": { "translate": "controlify.guide.ingame.drop" } - }, - - { - "for": "controlify:drop_stack", - "where": "right", - "when": ["controlify:has_multiple_items_in_hand"], - "then": { "translate": "controlify.binding.controlify.drop_stack" } - }, - - { - "for": "controlify:swap_hands", - "where": "right", - "when": ["controlify:has_item_in_either_hand"], - "then": { "translate": "controlify.guide.ingame.swap_hands" } - }, - - { - "for": "controlify:pick_block", - "where": "right", - "when": ["controlify:looking_at_block", "controlify:is_creative"], - "then": { "translate": "controlify.guide.ingame.pick_block" } - }, - - { - "for": "controlify:pick_block_nbt", - "where": "right", - "when": ["controlify:looking_at_block", "controlify:is_creative"], - "then": { "translate": "controlify.binding.controlify.pick_block_nbt" } - } - ] -} diff --git a/src/main/resources/assets/controlify/lang/en_us.json b/src/main/resources/assets/controlify/lang/en_us.json index d78e2bdb0..db3638dff 100644 --- a/src/main/resources/assets/controlify/lang/en_us.json +++ b/src/main/resources/assets/controlify/lang/en_us.json @@ -499,6 +499,40 @@ "controlify.guide.ingame.spectate": "Spectate", "controlify.guide.ingame.interact": "Interact", "controlify.guide.ingame.pick_block": "Pick Block", + "controlify.guide.ingame.trade": "Trade", + "controlify.guide.ingame.ride": "Ride", + "controlify.guide.ingame.open": "Open", + "controlify.guide.ingame.close": "Close", + "controlify.guide.ingame.press": "Press", + "controlify.guide.ingame.toggle": "Toggle", + "controlify.guide.ingame.ring": "Ring", + "controlify.guide.ingame.sleep": "Sleep", + "controlify.guide.ingame.craft": "Craft", + "controlify.guide.ingame.smelt": "Smelt", + "controlify.guide.ingame.cook": "Cook", + "controlify.guide.ingame.brew": "Brew", + "controlify.guide.ingame.enchant": "Enchant", + "controlify.guide.ingame.smith": "Smith", + "controlify.guide.ingame.repair": "Repair", + "controlify.guide.ingame.cut_stone": "Cut Stone", + "controlify.guide.ingame.weave": "Weave", + "controlify.guide.ingame.edit_map": "Edit Map", + "controlify.guide.ingame.block": "Block", + "controlify.guide.ingame.consume": "Consume", + "controlify.guide.ingame.shoot": "Shoot", + "controlify.guide.ingame.load": "Load", + "controlify.guide.ingame.throw": "Throw", + "controlify.guide.ingame.equip": "Equip", + "controlify.guide.ingame.brush": "Brush", + "controlify.guide.ingame.zoom": "Zoom", + "controlify.guide.ingame.play": "Play", + "controlify.guide.ingame.place": "Place", + "controlify.guide.ingame.cast": "Cast", + "controlify.guide.ingame.reel_in": "Reel In", + "controlify.guide.ingame.mine": "Mine", + "controlify.guide.ingame.dash": "Dash", + "controlify.guide.ingame.climb_up": "Climb Up", + "controlify.guide.ingame.climb_down": "Climb Down", "controlify.guide.container.place_all": "Place All", "controlify.guide.container.place_one": "Place", @@ -629,7 +663,7 @@ "controlify.extra_pack.legacy_console.name": "Legacy Console", - "controlify.extra_pack.legacy_console.desc": "Controlify - sets bind defaults/glyphs.", + "controlify.extra_pack.legacy_console.desc": "Applies Legacy Console-style bindings, glyphs, settings, and button guides.", "controlify.keyboard.special.shift": "Shift", "controlify.keyboard.special.shift_lock": "Shift Lock", diff --git a/src/main/resources/assets/controlify/trigger_effect/swing_item.json b/src/main/resources/assets/controlify/trigger_effect/swing_item.json deleted file mode 100644 index 76d92c043..000000000 --- a/src/main/resources/assets/controlify/trigger_effect/swing_item.json +++ /dev/null @@ -1,15 +0,0 @@ -[ - { - "when": { - "predicates": { - "minecraft:weapon": {} - } - }, - "effect": { - "type": "weapon", - "start_position": 3, - "end_position": 5, - "strength": 1 - } - } -] diff --git a/src/main/resources/assets/controlify/trigger_effect/use_item.json b/src/main/resources/assets/controlify/trigger_effect/use_item.json deleted file mode 100644 index cb3789bf3..000000000 --- a/src/main/resources/assets/controlify/trigger_effect/use_item.json +++ /dev/null @@ -1,200 +0,0 @@ -[ - { - "when": { - "items": "#c:tools/bow" - }, - "effect": { - "type": "feedback_slope", - "start_position": 3, - "end_position": 9, - "start_strength": 2, - "end_strength": 8 - } - }, - { - "when": { - "components": { - "minecraft:charged_projectiles": [] - } - }, - "effect": { - "type": "feedback_slope", - "start_position": 2, - "end_position": 9, - "start_strength": 5, - "end_strength": 8 - } - }, - { - "when": { - "predicates": { - "minecraft:charged_projectiles": {} - } - }, - "effect": { - "type": "weapon", - "start_position": 3, - "end_position": 5, - "strength": 1 - } - }, - { - "when": { - "predicates": { - "minecraft:consumable": {} - } - }, - "effect": { - "type": "feedback", - "position": 3, - "strength": 1 - } - }, - { - "when": { - "predicates": { - "minecraft:blocks_attacks": {} - } - }, - "effect": { - "type": "feedback", - "position": 3, - "strength": 3 - } - }, - { - "when": { - "predicates": { - "minecraft:equippable": {} - } - }, - "effect": { - "type": "weapon", - "start_position": 3, - "end_position": 5, - "strength": 1 - } - }, - { - "when": { - "predicates": { - "minecraft:kinetic_weapon": {} - } - }, - "effect": { - "type": "feedback", - "position": 3, - "strength": 3 - } - }, - { - "when": { - "predicates": { - "minecraft:instrument": {} - } - }, - "effect": { - "type": "feedback", - "position": 3, - "strength": 3 - } - }, - { - "when": { - "items": "#c:tools/brush" - }, - "effect": { - "type": "vibration", - "position": 2, - "amplitude": 2, - "frequency": 15 - } - }, - { - "when": { - "items": "minecraft:spyglass" - }, - "effect": { - "type": "feedback", - "position": 3, - "strength": 1 - } - }, - { - "when": { - "items": "#c:tools/trident" - }, - "effect": { - "type": "feedback_slope", - "start_position": 2, - "end_position": 9, - "start_strength": 2, - "end_strength": 8 - } - }, - { - "when": { - "items": "#minecraft:eggs" - }, - "effect": { - "type": "vibration", - "position": 3, - "amplitude": 6, - "frequency": 5 - } - }, - { - "when": { - "items": [ - "minecraft:snowball", - "minecraft:splash_potion", - "minecraft:lingering_potion", - "minecraft:experience_bottle" - ] - }, - "effect": { - "type": "vibration", - "position": 3, - "amplitude": 6, - "frequency": 5 - } - }, - { - "when": { - "items": "#c:tools/fishing_rod" - }, - "effect": { - "type": "weapon", - "start_position": 3, - "end_position": 5, - "strength": 1 - } - }, - { - "when": { - "predicates": { - "minecraft:bundle_contents": { - "items": { "size": { "min": 1 } } - } - } - }, - "effect": { - "type": "feedback", - "position": 3, - "strength": 6 - } - }, - { - "when": { - "predicates": { - "minecraft:use_cooldown": {} - } - }, - "effect": { - "type": "weapon", - "start_position": 3, - "end_position": 5, - "strength": 1 - } - } -] diff --git a/src/main/resources/resourcepacks/legacy_console/assets/controlify/contextual/guide/in_game.json b/src/main/resources/resourcepacks/legacy_console/assets/controlify/contextual/guide/in_game.json new file mode 100644 index 000000000..99a4c520c --- /dev/null +++ b/src/main/resources/resourcepacks/legacy_console/assets/controlify/contextual/guide/in_game.json @@ -0,0 +1,1194 @@ +{ + "replace": true, + "rules": [ + { + "for": "controlify:jump", + "where": "left", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:riding_happy_ghast" + ] + }, + "then": { + "translate": "controlify.guide.ingame.fly_up" + } + }, + { + "for": "controlify:jump", + "where": "left", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + { + "any_of": [ + "controlify:riding_saddled_camel", + "controlify:riding_saddled_nautilus" + ] + } + ] + }, + "then": { + "translate": "controlify.guide.ingame.dash" + } + }, + { + "for": "controlify:jump", + "where": "left", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:riding_saddled_equine" + ] + }, + "then": { + "translate": "key.jump" + } + }, + { + "for": "controlify:jump", + "where": "left", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:creative_flying" + ] + }, + "then": { + "translate": "controlify.guide.ingame.fly_up" + } + }, + { + "for": "controlify:jump", + "where": "left", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:in_water" + ] + }, + "then": { + "translate": "controlify.guide.ingame.swim_up" + } + }, + { + "for": "controlify:jump", + "where": "left", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:on_climbable" + ] + }, + "then": { + "translate": "controlify.guide.ingame.climb_up" + } + }, + { + "for": "controlify:jump", + "where": "left", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:on_ground" + ] + }, + "then": { + "translate": "key.jump" + } + }, + { + "for": "controlify:jump", + "where": "left", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:can_elytra_fly" + ] + }, + "then": { + "translate": "controlify.guide.ingame.start_elytra" + } + }, + { + "for": "controlify:sneak", + "where": "left", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:in_vehicle" + ] + }, + "then": { + "translate": "controlify.guide.ingame.dismount" + } + }, + { + "for": "controlify:sneak", + "where": "left", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:creative_flying" + ] + }, + "then": { + "translate": "controlify.guide.ingame.fly_down" + } + }, + { + "for": "controlify:sneak", + "where": "left", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:in_water" + ], + "none_of": [ + "controlify:on_ground" + ] + }, + "then": { + "translate": "controlify.guide.ingame.swim_down" + } + }, + { + "for": "controlify:sneak", + "where": "left", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:on_climbable" + ] + }, + "then": { + "translate": "controlify.guide.ingame.climb_down" + } + }, + { + "for": "controlify:inventory", + "where": "right", + "if": "controlify:verbosity_reduced", + "then": { + "translate": "controlify.guide.ingame.inventory" + } + }, + { + "for": "controlify:attack", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:looking_at_entity", + "controlify:is_spectator" + ] + }, + "then": { + "translate": "controlify.guide.ingame.spectate" + } + }, + { + "for": "controlify:attack", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:looking_at_entity" + ], + "none_of": [ + "controlify:is_spectator" + ] + }, + "then": { + "translate": "controlify.guide.ingame.attack" + } + }, + { + "for": "controlify:attack", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:looking_at_block", + "controlify:main_hand_is_tool" + ] + }, + "then": { + "translate": "controlify.guide.ingame.mine" + } + }, + { + "for": "controlify:attack", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:looking_at_block" + ] + }, + "then": { + "translate": "controlify.guide.ingame.break" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:looking_at_entity", + "controlify:is_spectator" + ] + }, + "then": { + "translate": "controlify.guide.ingame.spectate" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:looking_at_merchant" + ] + }, + "then": { + "translate": "controlify.guide.ingame.trade" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:looking_at_chest_boat", + "controlify:sneaking" + ] + }, + "then": { + "translate": "controlify.guide.ingame.open" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:looking_at_container_minecart" + ] + }, + "then": { + "translate": "controlify.guide.ingame.open" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:looking_at_rideable" + ], + "none_of": [ + "controlify:sneaking" + ] + }, + "then": { + "translate": "controlify.guide.ingame.ride" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:looking_at_closed_openable" + ], + "none_of": [ + "controlify:sneaking" + ] + }, + "then": { + "translate": "controlify.guide.ingame.open" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:looking_at_open_openable" + ], + "none_of": [ + "controlify:sneaking" + ] + }, + "then": { + "translate": "controlify.guide.ingame.close" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:looking_at_container" + ], + "none_of": [ + "controlify:sneaking" + ] + }, + "then": { + "translate": "controlify.guide.ingame.open" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:looking_at_button" + ] + }, + "then": { + "translate": "controlify.guide.ingame.press" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:looking_at_lever" + ] + }, + "then": { + "translate": "controlify.guide.ingame.toggle" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:looking_at_bell" + ] + }, + "then": { + "translate": "controlify.guide.ingame.ring" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:looking_at_bed" + ], + "none_of": [ + "controlify:sneaking" + ] + }, + "then": { + "translate": "controlify.guide.ingame.sleep" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:looking_at_crafting_table" + ], + "none_of": [ + "controlify:sneaking" + ] + }, + "then": { + "translate": "controlify.guide.ingame.craft" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:looking_at_furnace" + ], + "none_of": [ + "controlify:sneaking" + ] + }, + "then": { + "translate": "controlify.guide.ingame.smelt" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:looking_at_smoker" + ], + "none_of": [ + "controlify:sneaking" + ] + }, + "then": { + "translate": "controlify.guide.ingame.cook" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:looking_at_brewing_stand" + ], + "none_of": [ + "controlify:sneaking" + ] + }, + "then": { + "translate": "controlify.guide.ingame.brew" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:looking_at_enchanting_table" + ], + "none_of": [ + "controlify:sneaking" + ] + }, + "then": { + "translate": "controlify.guide.ingame.enchant" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:looking_at_smithing_table" + ], + "none_of": [ + "controlify:sneaking" + ] + }, + "then": { + "translate": "controlify.guide.ingame.smith" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:looking_at_repair_station" + ], + "none_of": [ + "controlify:sneaking" + ] + }, + "then": { + "translate": "controlify.guide.ingame.repair" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:looking_at_stonecutter" + ], + "none_of": [ + "controlify:sneaking" + ] + }, + "then": { + "translate": "controlify.guide.ingame.cut_stone" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:looking_at_loom" + ], + "none_of": [ + "controlify:sneaking" + ] + }, + "then": { + "translate": "controlify.guide.ingame.weave" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:looking_at_cartography_table" + ], + "none_of": [ + "controlify:sneaking" + ] + }, + "then": { + "translate": "controlify.guide.ingame.edit_map" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + { + "slot": "controlify:main_hand", + "item": { + "items": "#c:tools/bow" + } + } + ] + }, + "then": { + "translate": "controlify.guide.ingame.shoot" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + { + "slot": "controlify:main_hand", + "item": { + "predicates": { + "minecraft:charged_projectiles": {} + } + } + } + ], + "none_of": [ + { + "slot": "controlify:main_hand", + "item": { + "components": { + "minecraft:charged_projectiles": [] + } + } + } + ] + }, + "then": { + "translate": "controlify.guide.ingame.shoot" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + { + "slot": "controlify:main_hand", + "item": { + "items": "minecraft:crossbow", + "components": { + "minecraft:charged_projectiles": [] + } + } + } + ] + }, + "then": { + "translate": "controlify.guide.ingame.load" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + { + "slot": "controlify:main_hand", + "item": { + "predicates": { + "minecraft:blocks_attacks": {} + } + } + } + ] + }, + "then": { + "translate": "controlify.guide.ingame.block" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + { + "slot": "controlify:main_hand", + "item": { + "predicates": { + "minecraft:consumable": {} + } + } + } + ] + }, + "then": { + "translate": "controlify.guide.ingame.consume" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + { + "any_of": [ + { + "slot": "controlify:main_hand", + "item": { + "items": "#c:tools/trident" + } + }, + { + "slot": "controlify:main_hand", + "item": { + "items": "#minecraft:spears" + } + }, + { + "slot": "controlify:main_hand", + "item": { + "items": "#minecraft:eggs" + } + }, + { + "slot": "controlify:main_hand", + "item": { + "items": [ + "minecraft:snowball", + "minecraft:splash_potion", + "minecraft:lingering_potion", + "minecraft:experience_bottle", + "minecraft:ender_pearl", + "minecraft:wind_charge" + ] + } + } + ] + } + ] + }, + "then": { + "translate": "controlify.guide.ingame.throw" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:fishing_hook_cast", + { + "slot": "controlify:main_hand", + "item": { + "items": "#c:tools/fishing_rod" + } + } + ] + }, + "then": { + "translate": "controlify.guide.ingame.reel_in" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + { + "slot": "controlify:main_hand", + "item": { + "items": "#c:tools/fishing_rod" + } + } + ], + "none_of": [ + "controlify:fishing_hook_cast" + ] + }, + "then": { + "translate": "controlify.guide.ingame.cast" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + { + "slot": "controlify:main_hand", + "item": { + "items": "#c:tools/brush" + } + } + ] + }, + "then": { + "translate": "controlify.guide.ingame.brush" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + { + "slot": "controlify:main_hand", + "item": { + "items": "minecraft:spyglass" + } + } + ] + }, + "then": { + "translate": "controlify.guide.ingame.zoom" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + { + "slot": "controlify:main_hand", + "item": { + "predicates": { + "minecraft:instrument": {} + } + } + } + ] + }, + "then": { + "translate": "controlify.guide.ingame.play" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + { + "slot": "controlify:main_hand", + "item": { + "predicates": { + "minecraft:equippable": {} + } + } + } + ] + }, + "then": { + "translate": "controlify.guide.ingame.equip" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:looking_at_block", + "controlify:main_hand_is_block_item" + ] + }, + "then": { + "translate": "controlify.guide.ingame.place" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + { + "slot": "controlify:off_hand", + "item": { + "items": "#c:tools/bow" + } + } + ] + }, + "then": { + "translate": "controlify.guide.ingame.shoot" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + { + "slot": "controlify:off_hand", + "item": { + "predicates": { + "minecraft:charged_projectiles": {} + } + } + } + ], + "none_of": [ + { + "slot": "controlify:off_hand", + "item": { + "components": { + "minecraft:charged_projectiles": [] + } + } + } + ] + }, + "then": { + "translate": "controlify.guide.ingame.shoot" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + { + "slot": "controlify:off_hand", + "item": { + "items": "minecraft:crossbow", + "components": { + "minecraft:charged_projectiles": [] + } + } + } + ] + }, + "then": { + "translate": "controlify.guide.ingame.load" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + { + "slot": "controlify:off_hand", + "item": { + "predicates": { + "minecraft:blocks_attacks": {} + } + } + } + ] + }, + "then": { + "translate": "controlify.guide.ingame.block" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + { + "slot": "controlify:off_hand", + "item": { + "predicates": { + "minecraft:consumable": {} + } + } + } + ] + }, + "then": { + "translate": "controlify.guide.ingame.consume" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + { + "any_of": [ + { + "slot": "controlify:off_hand", + "item": { + "items": "#c:tools/trident" + } + }, + { + "slot": "controlify:off_hand", + "item": { + "items": "#minecraft:spears" + } + }, + { + "slot": "controlify:off_hand", + "item": { + "items": "#minecraft:eggs" + } + }, + { + "slot": "controlify:off_hand", + "item": { + "items": [ + "minecraft:snowball", + "minecraft:splash_potion", + "minecraft:lingering_potion", + "minecraft:experience_bottle", + "minecraft:ender_pearl", + "minecraft:wind_charge" + ] + } + } + ] + } + ] + }, + "then": { + "translate": "controlify.guide.ingame.throw" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:fishing_hook_cast", + { + "slot": "controlify:off_hand", + "item": { + "items": "#c:tools/fishing_rod" + } + } + ] + }, + "then": { + "translate": "controlify.guide.ingame.reel_in" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + { + "slot": "controlify:off_hand", + "item": { + "items": "#c:tools/fishing_rod" + } + } + ], + "none_of": [ + "controlify:fishing_hook_cast" + ] + }, + "then": { + "translate": "controlify.guide.ingame.cast" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + { + "slot": "controlify:off_hand", + "item": { + "items": "#c:tools/brush" + } + } + ] + }, + "then": { + "translate": "controlify.guide.ingame.brush" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + { + "slot": "controlify:off_hand", + "item": { + "items": "minecraft:spyglass" + } + } + ] + }, + "then": { + "translate": "controlify.guide.ingame.zoom" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + { + "slot": "controlify:off_hand", + "item": { + "predicates": { + "minecraft:instrument": {} + } + } + } + ] + }, + "then": { + "translate": "controlify.guide.ingame.play" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + { + "slot": "controlify:off_hand", + "item": { + "predicates": { + "minecraft:equippable": {} + } + } + } + ] + }, + "then": { + "translate": "controlify.guide.ingame.equip" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:looking_at_block", + "controlify:off_hand_is_block_item" + ] + }, + "then": { + "translate": "controlify.guide.ingame.place" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:looking_at_entity" + ], + "none_of": [ + "controlify:is_spectator" + ] + }, + "then": { + "translate": "controlify.guide.ingame.interact" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:looking_at_block" + ] + }, + "then": { + "translate": "controlify.guide.ingame.use" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": { + "all_of": [ + "controlify:verbosity_minimal", + "controlify:has_item_in_either_hand" + ] + }, + "then": { + "translate": "controlify.guide.ingame.use" + } + }, + { + "for": "controlify:drop", + "where": "left", + "if": { + "all_of": [ + "controlify:verbosity_reduced", + "controlify:has_item_in_main_hand" + ] + }, + "then": { + "translate": "controlify.guide.ingame.drop" + } + } + ] +} diff --git a/src/test/java/dev/isxander/controlify/contextual/FactDependencyGraphTest.java b/src/test/java/dev/isxander/controlify/contextual/FactDependencyGraphTest.java new file mode 100644 index 000000000..02cd302fa --- /dev/null +++ b/src/test/java/dev/isxander/controlify/contextual/FactDependencyGraphTest.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.contextual; + +import net.minecraft.resources.Identifier; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class FactDependencyGraphTest { + private static final Identifier ROOT = id("root"); + private static final Identifier MIDDLE = id("middle"); + private static final Identifier LEAF = id("leaf"); + private static final Identifier UNUSED = id("unused"); + private static final Identifier SOURCE = id("source"); + private static final Identifier FROM_SOURCE = id("from_source"); + + @Test + void evaluatesTransitiveDependenciesBeforeRequiredFact() { + FactDependencyGraph graph = FactDependencyGraph.compile(List.of( + new FactDefinition(LEAF, new ContextualPredicate.Fact(MIDDLE)), + new FactDefinition(MIDDLE, new ContextualPredicate.Fact(ROOT)), + new FactDefinition(ROOT, new ContextualPredicate.Static(true)), + new FactDefinition(UNUSED, new ContextualPredicate.Static(true)) + )); + + ContextualState state = graph.evaluate(new ContextualStateAccumulator(), Set.of(LEAF)); + + assertTrue(state.facts().get(ROOT)); + assertTrue(state.facts().get(MIDDLE)); + assertTrue(state.facts().get(LEAF)); + assertFalse(state.facts().containsKey(UNUSED)); + } + + @Test + void resolvesExternalDependenciesFromSourceState() { + FactDependencyGraph graph = FactDependencyGraph.compile(List.of( + new FactDefinition(FROM_SOURCE, new ContextualPredicate.Fact(SOURCE)) + )); + ContextualStateAccumulator accumulator = new ContextualStateAccumulator(); + accumulator.contributeFact(SOURCE, true); + + ContextualState state = graph.evaluate(accumulator, Set.of(FROM_SOURCE)); + + assertEquals(Set.of(SOURCE, FROM_SOURCE), state.facts().keySet()); + assertTrue(state.facts().get(FROM_SOURCE)); + } + + @Test + void evaluatesNoDefinitionsWhenNoneAreRequired() { + FactDependencyGraph graph = FactDependencyGraph.compile(List.of( + new FactDefinition(ROOT, new ContextualPredicate.Static(true)) + )); + + ContextualState state = graph.evaluate(new ContextualStateAccumulator(), Set.of()); + + assertTrue(state.facts().isEmpty()); + } + + private static Identifier id(String path) { + return Identifier.fromNamespaceAndPath("test", path); + } +}