Architecture / traits & effects

The trait & effect architecture

A gene changes a horse’s coat through the three-phase pipeline. This page is the other half: the Minecraft-specific things a gene can make a horse do — walk on water, trail particles as it moves, be milked for a fluid. A data-driven gene taps that through an effects block; the full behaviour system it is a slice of is a design, recorded here so the slice has somewhere to grow into.

Status — built 2026-09-02, not play-tested

Implemented: the effects block is parsed and validated in common/ (GeneAbility, AbilityType, GeneSpecParser, HorseAbilities; unit-tested). The NeoForge translator (server/GeneAbilityHandler, server/GeneYieldHandler) executes traversal, emitter, mob_effect, yield and glow (a dynamic minecraft:light block plus a client-side full-bright coat layer). Defined but not executed yet: attribute (parsed and carried, logged once at runtime). Owner-confirmed in-game (2026-09-02): Waterborn's coat + trail and Suntouched's glow; the rest is unverified — see To be verified. Everything below the “full architecture” heading is unbuilt.

What a gene can do today: the effects block

A data-driven gene’s JSON may carry an effects array alongside its layers: a closed set of eight verbstraversal (a movement / survival flag), attribute (a condition-gated modifier), emitter (particles on a trigger), mob_effect (an aura on self or rider), yield (the horse hands something back on a right-click), glow (the horse emits world light and/or renders some coat parts full-bright), healing (an aura that mends what stands near it) and spread (ground cover creeping out from the hooves). Every entry also takes an optional when condition and a minDose (1 = any expressing copy, 2 = homozygous only).

Since 2026-09-04 this is not a data-driven-only path. A hand-written gene implements AbilityContribution and returns the same records, and HorseAbilities.activeFor collects both kinds together. That matters for this document’s argument rather than just for the plumbing: the shipped slice is no longer “the thing gene files can do” but “the thing any gene can do”, which is the shape the architecture below always assumed. The last two verbs were both written for built-in genes (healer, verdant) and are usable from JSON today, which is the property the shared vocabulary was chosen for.

Gene effects is the full reference — every verb’s parameters, the triggers, the condition flags, the execution and merge model, and the modular contract for adding a new effect (one AbilityType declaration; the parser never changes). The rest of this page is the wider architecture that effects is a first slice of.

Waterborn — the worked example

common/src/main/resources/horsegenetics/example-genes/waterborn.json. A magical gene whose one visible outcome takes either variant combination (Wtb n, 1 in 90 per allele) that does four things, each a different part of the system:

BehaviourExpressed as
Neon-blue stripes down the mane and tailan ordinary magical coat layer — a STRIPES mask on the HAIR parts, a TOWARD op at #1ec8ff
Walks on water (grown horses)effectstraversal walk_on_water, when: { flag: adult }
Blue particles wherever it walkseffectsemitter, trail at the feet, on_move, minecraft:dust at #1ec8ff, chance 0.7
Mares can be milked for watereffectsyield, on_interact minecraft:bucketminecraft:water_bucket, when mare & tamed, cooldown 2400
{
  "format": 2,
  "key": "example.waterborn",
  "name": "Waterborn",
  "phase": "magical",
  "priority": 215,
  "alleles": [
    { "token": "Wtb", "label": "Waterborn (Wtb)" },
    { "token": "n", "label": "Wild-type (n)" }
  ],
  "knobs": [
    { "name": "stripeSeed", "type": "seed" },
    { "name": "stripeSpacing", "min": 1.6, "max": 2.8 }
  ],
  "expressions": [
    {
      "id": "waterborn",
      "name": "Waterborn",
      "description": "Neon-blue streaks in the mane and tail, blue dust off the hooves, a grown horse walks over water, and a tamed mare can be milked for a bucket of it.",
      "when": [ "Wtb/Wtb", "Wtb/n" ],
      "layers": [
        {
          "name": "neon blue stripes down the mane and tail",
          "masks": [
            { "type": "STRIPES", "parts": [ "HAIR" ], "seed": "$stripeSeed",
              "spacing": "$stripeSpacing", "duty": 0.4, "warp": 0.8 }
          ],
          "op": { "type": "TOWARD", "color": "#1ec8ff", "strength": 92, "opacity": 100 }
        }
      ],
      "effects": [
        { "type": "traversal", "flag": "walk_on_water", "when": { "flag": "adult" } },
        {
          "type": "emitter", "kind": "particle", "shape": "trail", "anchor": "feet",
          "trigger": { "on_move": {} },
          "particle": "minecraft:dust", "color": "#1ec8ff", "chance": 0.7
        },
        {
          "type": "yield",
          "trigger": { "on_interact": "minecraft:bucket" },
          "consumes": "minecraft:bucket", "produces": "minecraft:water_bucket",
          "cooldown": 2400,
          "when": { "all": [ { "flag": "sex_female" }, { "flag": "tamed" } ] }
        }
      ]
    },
    { "id": "wild", "name": "Wild type", "description": "No blue streaks and no affinity for water.", "wildType": true }
  ],
  "founders": { "Wtb/Wtb": 0.0123457, "Wtb/n": 2.1975309, "n/n": 97.7901234 }
}
Waterborn ships loaded (as of 2026-09-02)

A copy of waterborn.json lives in the NeoForge module's resources (horsegenetics/genes/index.json + the file), so GeneSpecLoader.fromClasspath() registers it in the mod constructor. It is the first gene file to ship — done deliberately to make the effects work testable in-game — so the in-game genotype code is now 20 segments and the horse- dimension’s genotype space grows. :common:test is unaffected (that index is not on the test classpath, so coat-golden.txt and the 17-built-in count still hold). The other example-genes/*.json are still just examples — drop one in .minecraft/phc/genes/ to load it.

Where the work lives

The same split as the rest of the mod. common/ owns the vocabulary and the parse — what a flag is called, which ops exist, turning JSON into GeneAbility records, and deciding which abilities a genotype expresses (HorseAbilities.activeFor). It never imports Minecraft, so it ports to 1.12.2 unchanged. The NeoForge module owns the execution — it reads that list every tick and evaluates conditions, spawns particles, holds flags, swaps items. A version bump rewrites the translator and nothing else.


The full architecture

Revision 2 of the trait-system design. None of this section is built — it is the shape the effects block is a first slice of, kept so later work has a plan to follow rather than reinvent.

Core model

Horse      -> has a set of Traits
Trait      -> a named bundle of Components
Component  -> (Trigger, Condition, Selector, Effect)

Four axes describe any behaviour:

AxisQuestionExample
TriggerWhen does it fire?when a rider mounts
ConditionIs it active right now?daytime and sky-visible
SelectorWho or what does it act on?undead within 8 blocks
EffectWhat happens?deal 2 damage, set flee flag

Adding behaviour should mean writing a JSON file. Writing Java means you’ve hit a genuinely new primitive — which should be rare.

Components

1. Trigger

The firing vocabulary. Every component that isn’t continuous declares one.

That last one does a lot of work. Biome change, dimension change, weather change, light-level threshold crossing, mood transition, bond tier-up, day/night flip and pool-empty are all the same trigger: watch a condition source, fire on the edge. One implementation, one cached previous value per watched source, and every future condition source gets an edge trigger for free.

{ "trigger": { "on_change": "biome", "direction": "any" } }
{ "trigger": { "on_change": "is_raining", "direction": "rising" } }

Damage hooks are triggers, not a separate component. on_hurt fires with the incoming damage in context, and the damage-modifying effects (cancel_damage, reduce_damage, reflect_damage) are ordinary effect verbs. There is no separate hook system.

2. Condition

A composable predicate evaluated against a context snapshot. Every other component accepts an optional condition.

Conditions evaluate to a scalar 0.0–1.0, not a boolean. Boolean contexts treat > 0.5 as true. This lets effects ramp — a sun-powered horse gains speed gradually through the morning rather than snapping at dawn. curve remaps a scalar through a named easing function for non-linear response.

Integration conditions. Sources like claimed-chunk membership or region-protection state belong in optional compat submodules that register additional condition sources at load. A trait referencing an unregistered source is disabled with a load-time warning rather than crashing.

3. Selector

The targeting DSL. One implementation, used everywhere something needs to find entities or blocks.

last_attacker and owner_last_attacker are memory-backed — store a UUID with a timeout, never a hard entity reference. nearest plus a block tag covers finding the closest chest, bed, campfire or ore without a dedicated selector for each.

4. Effect

The verb vocabulary. Keep this list closed. It’s the main lever on long-term maintainability — everything else composes these.

GroupVerbs
Entityapply_effect, remove_effect, transfer_effect, damage, heal, modify_attribute, set_ai_flag, teleport, impulse, spawn_entity, spawn_area_cloud, set_entity_tag
Damage-context (only under on_hurt)cancel_damage, reduce_damage, reflect_damage
Worldtransform_block, place_block, break_block, place_structure, set_weather, strike_lightning, set_time, set_moon_phase (the last four config-gated)
Outputgive_item, consume_item, give_experience, play_sound, queue_sound_sequence, spawn_particles, emit_light, send_message (chat | action_bar | title | subtitle), open_screen
State & controlset_state, modify_pool, grant_trait, revoke_trait, modify_cooldown, store_waypoint, remove_waypoint, recall_waypoint, navigate_to, deny_spawn

set_state deliberately absorbs a large family of one-off verbs. Setting a follow target, a guard anchor, a patrol route, a model scale, an emissive overlay, a particle colour or a visual flag are all writing a state field that some other system reads. Reach for set_state before adding a verb.

5. Aura

Aura { radius, interval, mode: entity|block,
       selector, condition, effects[], max_targets }

Block-mode auras run a transform_block table across the selected volume. Contact effects are a radius of ~1 with a grounded condition. max_targets is not optional — an unbounded aura in a mob farm is a server-killer.

6. Emitter

Emitter { kind: light | particle | sound | block_trail,
          shape: point | ring | cone | sphere | trail | burst,
          anchor: entity | feet | head | eyes | forward_offset | impact_point | target,
          trigger, condition,
          color_source: fixed | dye_slot | bound(state_field),
          params }

shape and anchor do most of the work. A hoofprint is particle + point + feet. A landing shockwave is particle + ring + impact_point. A forward light beam is particle + cone + forward_offset. These are parameters, not separate emitter types. color_source binds visual output to state, so a dye-driven glow and a mood-driven glow are one emitter with a different binding.

7. Yield

Yield { trigger, condition, cooldown,
        consumes: item?,
        output: items | fluid | effect | entity | block | structure | area_cloud,
        roll_table: id? }

Interval production, container-based extraction and death drops are one system. consumes handles the empty-container pattern; roll_table supports weighted and conditional outputs.

8. Resource pools

Pool { id, min, max, initial,
       regen: { rate, interval, condition },
       decay: { rate, interval, condition },
       on_empty: effects[], on_full: effects[],
       persist: bool, visible: bool }

Stamina, hunger, thirst, body temperature, stored charge, distance travelled, aggression, alertness, curiosity and loyalty are all pools — and so are bond and mood intensity. Pools connect through three existing seams: conditions read them via threshold / curve, effects write them via modify_pool, abilities consume them via cost: { pool, amount }. Pools that drive AI feed goal weights through condition-scaled modifiers, so personality profiles and momentary emotion use the same pathway.

9. Memory

Memory { home, owner_last_death, own_death_site,
         visited[], notable_finds[], named[],
         last_attacker_uuid, owner_last_target_uuid, patrol_route[] }

Written by store_waypoint and by relationship tracking; read by Locator, by the teleport / navigate_to destination resolvers, and by Goals. Entity references are UUIDs with a timeout, never hard references.

10. Locator

Locator { resolver: biome | structure | block | entity_selector | item | waypoint(name),
          activation: interact(item) | passive | trigger,
          output: particle_trail | chat | action_bar | sound | write_to_item | navigate }

Death sites, bed locations, world spawn and home are all waypoint(name). The difference between pointing toward something and travelling to it is the navigate output, not a second system.

11. Ability

Ability { trigger, condition, cooldown,
          cost: { pool, amount }?,
          requires_item: item_or_tag?, consumes_item: bool,
          effects[] }

Cooldowns are keyed per ability ID and stored in horse state, not on the entity instance. An ability whose cost cannot be paid does not fire and emits on_interact_fail — that is how you build “the horse is too tired” feedback without special-casing it.

12. Attributes and traversal flags

A flat map, merged across all traits. Attributes — movement speed, jump strength, max health, armor, knockback resistance, scale. Traversal flagswalk_on_water, walk_on_lava, fire_immune, fall_immune, flight, underwater_breathing, swim_speed, water_averse, phasing. Both accept condition-gated modifiers, so a conditional buff is not a separate concept from a permanent one. (This component is the one the effects block implements first.)

13. Goal set

A library of parameterised AI goals. Traits contribute goals with weights; they do not contribute goal code.

GoalParameters
Followselector, min distance, max distance
Watchselector, range
Stalkselector, min/max distance, break_line_of_sight
Patrolroute (waypoints from Memory), radius, speed
StayAtanchor (state field), leash length
Fleeselector, range
Attackselector
SeekItem / SeekEntity / SeekBlockfilter or selector or predicate, radius, stop distance
Herdselector, cohesion weight, separation weight
Investigatetrigger source, radius, curiosity weight
Idlewander weight

Personality is a named weight profile over this library plus threshold adjustments — not separate behaviour code. A stationary guard is StayAt(anchor_state) + Attack(hostile).

14. Interaction table

Right-click dispatch: held item or item tag → component ID. Fires on_interact_fail when nothing matches. Also holds the diet table:

item_tag -> { accepted, pool_deltas{},
              effects_on_horse[], effects_on_rider[],
              refusal_message }

15. Inventory

Inventory { slots, pickup: none|all|filter, filter[], display_slot }

display_slot renders a single item visibly on the horse. Item-seeking is a pickup filter paired with the SeekItem goal.

16. State

State { trait_ids[], granted_traits[] (with expiry),
        pools{}, personality_id, cooldowns{}, memory,
        visual{} (dye colors, scale, overlays),
        navigation{} (follow target, guard anchor, patrol route),
        discovered_traits[] }

Stored as a NeoForge Data Attachment. The read source for bound() emitter colours, pool-based conditions, goal navigation targets, and the render layer.

Trait relations

Trait { id, cost,
        requires: [trait_id],   // inert unless all present
        conflicts: [trait_id],  // mutually exclusive; higher priority wins
        grants: [trait_id],     // implies another trait
        replaces: [trait_id],   // supersedes a weaker version
        priority: int,
        ...components }

Resolution order: expand grants transitively → apply replaces → drop traits with unmet requires → resolve conflicts by priority → enforce the trait point budget. Cycles are a load-time error. replaces is what makes tiered traits clean — a strong variant supersedes the weak one rather than stacking.

Trait scaling

Any numeric field in a component accepts a scaling block instead of a literal:

{ "value": { "base": 1.0, "scale_by": "bond", "curve": "ease_out", "max": 3.0 } }

scale_by names a condition source or a pool. Same scalar machinery as conditions, applied to magnitude rather than gating.

Trait discovery

Trait { discovery: { hidden_until: condition, hint: message_id?, reveal_on: trigger[] } }

A hidden trait is fully active — it just doesn’t appear in the stat display until revealed by witnessing the effect, reaching a bond threshold, using a specific item, or breeding a horse that expresses it. A horse whose full capability is legible from a book on first tame is a spreadsheet; one whose traits emerge through use is a character.

Visual legibility convention: every trait should declare at least one emitter, message or otherwise perceivable effect. A trait with no observable signature is a design error.

Variants and spawn rules

A variant is a spawn preset: a spawn condition plus a trait list. Variants contain no mechanics of their own.

{
  "id": "nether",
  "spawn": {
    "dimension": "minecraft:the_nether",
    "biomes": ["#minecraft:is_nether"],
    "light_level": { "max": 7 },
    "near_blocks": [{ "block": "#minecraft:base_stone_nether", "radius": 4 }],
    "near_entities": [{ "selector": { "entity_tag": "hostile" }, "max": 2, "radius": 16 }],
    "group_size": { "min": 1, "max": 3 },
    "weight": 4
  },
  "traits": ["lava_walker", "fire_immune", "ember_hooves", "blaze_yield"]
}

Merge policy

A horse carries several traits at once, so resolution rules are defined up front.

ComponentRule
AttributesAdditive modifiers, vanilla operation semantics; cap the total per attribute
AurasAll stack; deduplicate identical (selector, effect) pairs
Traversal flagsBoolean OR, except mutually exclusive pairs resolved by trait priority
EmittersAll run; light level takes the maximum, not the sum
AbilitiesCooldowns keyed per ability ID, never global
GoalsPriority = base priority + trait weight; document the numeric bands
PoolsA pool declared by multiple traits takes the widest bounds and the sum of regen/decay rates
DietA rejection anywhere overrides any acceptance
YieldsIndependent cooldowns; no shared budget unless configured
Damage effectscancel_damage wins over reductions; reductions apply multiplicatively; reflection uses post-reduction damage
Granted traitsMerge normally, but never re-trigger on_tame or other lifecycle triggers

Trait budget. Each trait carries a cost. Spawn rules and breeding enforce a cap set in config, not code. Temporarily granted traits bypass the budget but carry an expiry.

Domain and integration split

Consistent with the pure-domain common/ module and thin NeoForge translator.

Pure Java, zero Minecraft imports: condition evaluation against a ContextSnapshot interface; selector definitions and predicate logic; trigger edge detection and previous-value bookkeeping; trait registry, JSON parsing, relation resolution, merge policy; resource-pool math; cooldown bookkeeping; personality weight profiles; yield roll tables and diet lookup; trait budget and discovery-state arithmetic.

NeoForge translator layer: populating ContextSnapshot from the level; executing selectors as entity and block queries; the effect verb executor; goal adapters onto vanilla Goal; trigger dispatch from game events; data attachments, networking, rendering, particles.

Build order

The spec’s order, annotated with where the gene layer sits. The shipped slice took a shortcut — it built parts of steps 2, 3 and 9 without steps 1 and 4 — which is why several later items are blocked on structural work rather than on their own complexity.

#StepState
1Trait registry, JSON loading, State attachmentPartial — gene JSON loads and validates; no trait IDs, minimal State
2Effect executor — start with apply_effect, damage, heal, play_sound, spawn_particles, give_item, set_state, modify_poolPartial — a different eight: particles, traversal, yield items; no damage, heal, sound, state or pools
3Condition enginePartial — boolean flags, translator-side
4Trigger dispatch, including on_changePartial — four triggers, no edge detection
5Selector and AuraNot started
6Emitter, with shape and anchor from the startMostly — shape and anchor are in, narrower sets
7Resource poolsNot started
8Attributes and traversal flagsTraversal live; attributes parse only
9YieldLive, narrowed
10Interaction and diet tablesNot started
11Trait relations and budget (before the trait count passes ~20)Blocked on step 1
12Goal library and personality profilesNot started
13Memory and LocatorNot started
14Ability and cooldownsNot started — yield holds the cooldown pattern
15InventoryNot started
16Trait discoveryNot started
17Variants and spawn rules — pure data by this pointNot started

Retrofitting anchors onto emitters later would mean rewriting every emitter definition, which is why step 6 was done properly. The same argument applies to step 1 now: every trait-set feature is waiting on it.


Structured sound signalling

Pattern { steps: [ { pitch, volume, sound, delay_ticks } ] }

A SignalBinding maps a trigger to a pattern. The encoder is small. The hard part is designing a pattern vocabulary players can learn under ambient noise with no reference chart — keep the initial set to three or four signals with obviously different rhythms, since rhythm survives a noisy soundscape and pitch does not.

Deliberately excluded

Recorded so these don’t get re-proposed — each is already expressible.

ProposedWhere it lives instead
on_biome_change, on_dimension_change, on_weather_changeon_change(condition_source)
is_crouchingDuplicate of rider_sneaking
is_in_loaded_chunkNot a condition. If the chunk isn’t ticking, nothing evaluates.
has_ridden_distanceA pool plus threshold
nearest_chest, nearest_bedBlock selector with sort: nearest
items_in_radiusEntity selector with entity_tag: items
nearest_player_death, spawn_location, bed_locationMemory waypoints via waypoint(name)
clone_entityspawn_entity with source-data copying
set_light_levelemit_light
send_action_bar, send_titlesend_message with a channel
set_follow_target, set_guard_position, set_patrol_pointsset_state on navigation fields
set_model_scale, set_emissive_texture, set_particle_colorset_state on visual fields, read by the renderer
set_aura, toggle_abilitygrant_trait / revoke_trait with duration
light_pulse, shockwave, directional_beam, footstep_effectEmitter shape + anchor combinations
colored_auraEmitter with color_source: bound(state)
drop_sequenceA yield on an interval trigger
Guard goalStayAt + Attack composed
Separate DamageHook componentTriggers plus damage-context effect verbs

Invariants

  1. New behaviour is data. If a feature request requires Java, it’s a new primitive — an architectural decision, not a task.
  2. The effect vocabulary is closed. Growth happens through composition. Reach for set_state before adding a verb.
  3. Conditions gate everything. No component implements its own situational logic.
  4. One selector implementation. No component hand-rolls entity lookup.
  5. One trigger vocabulary. No component invents its own firing mechanism.
  6. Domain logic never imports Minecraft. The translator layer is a translator, nothing more.
  7. Every radius effect has a target cap. No exceptions.
  8. Every trait has a perceivable signature. A trait players can’t observe is a bug.
  9. Entity references are UUIDs with a timeout. Never a hard reference held across ticks.

Divergences from the spec

Places where the shipped gene layer and the architecture spec disagree. Each is either a deliberate narrowing, a rename that should be reconciled, or a genuine gap worth a decision. None of them should be quietly harmonised on one side without a note here.

DivergenceKindNote
The charges verbAdditionThe spec has no notion of one trait modifying another trait’s cooldown. It exists because two loci that must cooperate cannot see each other’s epigenome - a gene is handed its own values and nobody else’s. Naming a yield kind is the only version of “milked more than once a day” that works without breaking that. If the spec grows trait IDs and relations, this is the first thing that should move onto them.
The breath verbAdditionThe spec has underwater_breathing as a boolean traversal flag and no graded form. Same argument as water_movement_efficiency one row below: it is a magnitude, and a magnitude is not a flag.
The on_death and item_drop verbsAdditionThe spec models death effects under spawn_entity / impulse and drops under an unbuilt roll-table system. Both are far larger than what two genes needed, so these are deliberately narrow: three world effects and five drops, all closed sets. If the roll tables are ever built, item_drop becomes a preset over them.
The mob_aura verbAdditionThe spec would express this as set_ai_flag plus a selector, and cannot yet, because selectors are unbuilt. When they land this should be re-read as a selector plus two AI flags rather than kept as its own verb.
The combat verbAdditionArguably should be an attribute on attack_damage. It is not, because the gene’s number is absolute: a modifier would make a horse’s sheet read as an adjustment to a baseline the reader has to go and look up, and the baseline is itself a gene constant.
yield has a kindAdditionPurely so charges can name it. The spec has no equivalent; it would use a trait ID.
minDose exists on every effectAdditionGenetics-specific. The spec’s trait model has no dose concept, and nothing in it needs one.
The glow verbAdditionThe spec models a light as emitter kind:light and has no emissive-render concept at all. glow folds “emit world light” and “render these coat parts full-bright” into one verb because they are the two halves of one visible trait. If the spec’s emitter ever grows a real light kind, the light half should move there and glow keep only the emissive parts.
glow has a client-render halfAdditionEvery other verb is translator-only. glow.parts is drawn by client/EmissiveCoatLayer, so “add a verb” is no longer always just a server case.
sex_female / sex_male / adult / baby flagsAdditionGenetics and vanilla age; is_baby exists in the spec only as an entity_state predicate on a selected entity, not as a self condition.
Conditions are boolean, not 0–1 scalarsNarrowingKnown. Blocks ramping effects and curve. See the scalar model.
Conditions evaluate in the translator, not against ContextSnapshotGapBreaks the domain/translator split for condition logic. See Domain and integration split.
anchor: body vs the spec’s entityRenameSame thing, two names. Pick one before external gene packs exist.
Emitter shapes lack cone and sphereNarrowingNo blocker; both are pure geometry.
water_movement_efficiency is an attribute here, a traversal flag in the specRenameThe gene layer is arguably right — it is a magnitude, and traversal flags are booleans. Fix the spec, not the code.
flight and phasing traversal flags absentGapBoth are large behavioural changes; absence is probably correct for now.
on_interact takes an item ID, not an item tagNarrowingThe spec says item_or_tag everywhere an item appears. Tag support is a translator-side lookup.
yield.produces accepts a recognised item set onlyNarrowingThe spec allows fluid, effect, entity, block, structure and area-cloud outputs, plus roll tables.
No trait IDs, cost, relations or budgetGapThe structural prerequisite for most of the unbuilt half of this page.
No per-attribute capGapThe spec requires one. attribute is now wired and the cap is still absent, which is exactly the order this row warned against.
walk_on_water + water_averse unresolvedGapThe spec resolves mutually exclusive flags by trait priority; there is no priority here.
AbilityType.CONDITION_FLAGS is a closed constantGapBlocks the spec’s load-time registration of integration condition sources.

Source: common/genetics/spec/GeneAbility.java, common/genetics/spec/AbilityType.java, common/genetics/spec/HorseAbilities.java, neoforge-26.1.2/…/server/GeneAbilityHandler.java, …/server/GeneYieldHandler.java