Files
Iris/docs/93 - API - Tree Feller.md
T
2026-08-08 00:29:48 -06:00

14 KiB

93 - API - Tree Feller

art.arcane.iris.api.tree lets another plugin drive the Iris tree feller and charge for it. The feller removes a whole Iris-generated tree when a sneaking survival player breaks one of its logs with an axe. Integrations can start a run Iris would not start, override durability rules, and reserve a cost per log with commit or refund. The feature is off by default (treeFeller.enabled = false); the standalone path also requires iris.treefeller. INTEGRATION_OVERRIDE bypasses both the enabled switch and the permission.

Build and service acquisition: 90 - API - Getting Started.md. Service: IrisTreeFellerService at ServicePriority.Normal.

Goal Use
Start a felling run Iris would not start, or price it tryFell with TreeFellerOptions.integrationOverride(...)
Avoid double-handling Iris-generated breaks during a run isManagedBreak
Ask whether a block belongs to an Iris tree isTreeBlock
package com.example.woodcutting;

import art.arcane.iris.api.tree.IrisTreeFellerService;
import org.bukkit.Bukkit;
import org.bukkit.plugin.RegisteredServiceProvider;

public final class FellerAccess {
    private FellerAccess() {
    }

    public static IrisTreeFellerService service() {
        RegisteredServiceProvider<IrisTreeFellerService> provider =
                Bukkit.getServicesManager().getRegistration(IrisTreeFellerService.class);
        return provider == null ? null : provider.getProvider();
    }
}
public interface IrisTreeFellerService {
    boolean tryFell(BlockBreakEvent event, TreeFellerOptions options);

    boolean isManagedBreak(BlockBreakEvent event);

    boolean isTreeBlock(Block block);
}

Lifecycle

your BlockBreakEvent handler
   |
   v
tryFell(event, options)         register a felling request against this break.
   |                            true = YOUR access is pending (first writer or same-access re-call).
   |                            Nothing removed yet; no hook fired.
   |
   |  (Iris re-checks at EventPriority.MONITOR)
   v
onActivationAccepted()          run is real. At most once, if at all.
   |
   |  (per LOG block, discovery order)
   v
reserveLogCost()   -> false     refuse; run ends; nothing reserved to refund.
   |
   | true
   v
   +--> commitLogCost()         log gone; charge final
   +--> refundLogCost()         log not removed; give cost back

Guarantees:

  • onActivationAccepted fires at most once per run, only after MONITOR re-validation: event not cancelled, block still the same Iris tree, tree not already claimed.
  • reserveLogCost once per log (not leaves). Leaves never reserve.
  • Reserve runs before axe durability charge.
  • Exactly one of commitLogCost or refundLogCost follows a true reserve, except the miss cases under Failure policy.
  • commitLogCost is final. No later refund for that log.
  • reserveLogCost false ends the whole run immediately.
  • One tree, one run, server-wide. A second player on the same tree gets the break cancelled with drops suppressed; no hooks for them.
  • No terminal callback. Count commits/refunds against activation if you need end-of-run accounting.

Threading

Call Thread
tryFell Thread delivering BlockBreakEvent (region thread owning the block)
isManagedBreak Any thread (set lookup)
isTreeBlock Region thread owning the block; can block on disk
onActivationAccepted Region thread of the broken block, inline in MONITOR
reserveLogCost Player entity path: entity scheduler on Folia; may run inline on Paper main when already primary
commitLogCost Same entity path (scheduled onto the player when not already inline)
refundLogCost Same entity path when a refund is delivered

Cost hooks may touch the feller's inventory/XP/effects. Do not read/write blocks from cost hooks on Folia (entity thread ≠ region).

onActivationAccepted is inside event dispatch — return promptly.

Do not block any of the four hooks. No I/O, no join, no long locks. Iris does not time out hanging hooks. Cache remote data (e.g. on join).

isTreeBlock is expensive

Reads Iris mantle for tree provenance. Cold mantle regions load from disk synchronously on your thread. Also reads block type/data — chunk must be loaded; call from the owning region thread.

Do not call per block in a loop, per tick, or over large areas. Use for blocks a player just interacted with.


Worked example: stamina per log

Hooks

package com.example.woodcutting;

import art.arcane.iris.api.tree.TreeFellerRunHooks;

import java.util.UUID;

public final class StaminaFellHooks implements TreeFellerRunHooks {
    private static final int COST_PER_LOG = 4;

    private final StaminaPool pool;
    private final UUID fellerId;

    public StaminaFellHooks(StaminaPool pool, UUID fellerId) {
        this.pool = pool;
        this.fellerId = fellerId;
    }

    @Override
    public void onActivationAccepted() {
        pool.beginRun(fellerId);
    }

    @Override
    public boolean reserveLogCost() {
        return pool.withdraw(fellerId, COST_PER_LOG);
    }

    @Override
    public void commitLogCost() {
        pool.recordSpend(fellerId, COST_PER_LOG);
    }

    @Override
    public void refundLogCost() {
        pool.deposit(fellerId, COST_PER_LOG);
    }
}

All four methods are required (no defaults). TreeFellerRunHooks.NONE is the shared no-op with reserveLogCost always true.

Hooks are per run, not per plugin. Build a new instance per tryFell and store feller identity in it. Iris holds the reference for the run and never inspects it beyond the four methods.

Listener

package com.example.woodcutting;

import art.arcane.iris.api.tree.IrisTreeFellerService;
import art.arcane.iris.api.tree.TreeFellerOptions;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.BlockBreakEvent;

public final class WoodcuttingListener implements Listener {
    private static final int PRESERVE_PERCENT = 50;

    private final StaminaPool pool;

    public WoodcuttingListener(StaminaPool pool) {
        this.pool = pool;
    }

    @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
    public void onBreak(BlockBreakEvent event) {
        IrisTreeFellerService feller = FellerAccess.service();

        if (feller == null || feller.isManagedBreak(event)) {
            return;
        }

        Player player = event.getPlayer();

        if (!pool.hasWoodcutting(player.getUniqueId())) {
            return;
        }

        TreeFellerOptions options = TreeFellerOptions.integrationOverride(
                PRESERVE_PERCENT, new StaminaFellHooks(pool, player.getUniqueId()));

        feller.tryFell(event, options);
    }
}

isManagedBreak is required: Iris fires a BlockBreakEvent for every block it removes during a run so protection plugins see removals. Without the guard you re-enter tryFell on every member.

EventPriority.HIGH is the usual choice: after typical protection cancels, before Iris's own standalone request at HIGHEST. See pending rules below.

@Override
public void onEnable() {
    getServer().getPluginManager().registerEvents(new WoodcuttingListener(pool), this);
}

Minimum: override only, no charge

IrisTreeFellerService feller = FellerAccess.service();

if (feller != null && !feller.isManagedBreak(event) && classes.isWoodcutter(event.getPlayer())) {
    feller.tryFell(event, TreeFellerOptions.integrationOverride(0, TreeFellerRunHooks.NONE));
}

durabilityPreservationChance of 0 charges every log (vanilla-like). 100 never charges durability for logs. Unbreakable axes are never charged.

TreeFellerOptions.standalone() is Iris's own request shape; third parties almost never need it. It still requires treeFeller.enabled and iris.treefeller.


What tryFell promises

boolean tryFell(BlockBreakEvent event, TreeFellerOptions options);

true means a pending request is associated with this break for your call path — not that a tree will fall. Iris re-validates at MONITOR and may drop the request with no hooks.

Pending request precedence

Pending state is keyed by the BlockBreakEvent instance.

Existing pending New request Result
none any Accept; store request; return true
STANDALONE STANDALONE Keep existing; return true
STANDALONE INTEGRATION_OVERRIDE Replace the pending standalone request; return true
INTEGRATION_OVERRIDE INTEGRATION_OVERRIDE Keep the first override; return true
INTEGRATION_OVERRIDE STANDALONE Keep the override; return false
managed internal probe (no pending, already managed) any return false

INTEGRATION_OVERRIDE has precedence over Iris's pending standalone request. The first accepted override keeps its hooks and options; later overrides are idempotently accepted without replacing it. Iris registers its standalone request at EventPriority.HIGHEST and finalizes at MONITOR, so integrations should submit overrides no later than HIGHEST and avoid MONITOR ordering races.

Open run state in onActivationAccepted, not at tryFell.

false when:

  • service disabled, or event/options null;
  • event already cancelled;
  • managed break with no pending (Iris probe / already finalizing path);
  • canUse failed — standalone needs enabled + permission; override never fails this;
  • break is not a fellable candidate;
  • candidate resolve throws (logged).

Candidate checks (no bypass)

INTEGRATION_OVERRIDE skips enabled switch and permission only. Still required:

  • GameMode.SURVIVAL
  • player sneaking
  • broken block tagged Tag.LOGS
  • main-hand item is an axe (*_AXE material name)
  • Iris tree provenance in mantle: placed by an Iris tree, not replaced since, not structure-aware

Vanilla saplings and hand-placed logs never fell. Provenance clears when a block is broken or built over.


How a run comes apart

Discovery walks mantle provenance outward from the broken block in 26 directions, BFS. Members remove in discovery order (trigger first; ties Y then X then Z).

Bounds — any hit marks discovery incomplete; Iris falls back to only the trigger block:

Bound Value
Members collected 131 072
Positions visited 1 000 000
Distance from trigger on any axis 256 blocks

Removal is paced in batches with tick yields. Batch size scales with tree size.

Run ends immediately (no further hooks) when the player:

  • stops sneaking,
  • changes held hotbar slot,
  • swaps hands,
  • goes offline, leaves survival, or changes world,
  • breaks the axe (after that log's commit),
  • or replaces the axe in that slot with a different item.

Each removed block fires a BlockBreakEvent with isManagedBreak == true. Cancelled log probes refund that log's reservation and end the run; cancelled leaf probes continue. Drops use the axe as it was before that block's durability charge (Silk Touch / Fortune apply). The original break is cancelled with drops and XP suppressed; Iris delivers per block.


Options

public record TreeFellerOptions(
        TreeFellerAccess access,
        int durabilityPreservationChance,
        TreeFellerRunHooks runHooks) {

    public static TreeFellerOptions standalone();

    public static TreeFellerOptions integrationOverride(
            int durabilityPreservationChance,
            TreeFellerRunHooks runHooks);
}

Canonical constructor: null access/runHooksNullPointerException; chance outside 0..100IllegalArgumentException.

durabilityPreservationChance is percent chance a log costs no durability. Rolled per log.

Honoured only for INTEGRATION_OVERRIDE. Standalone uses treeFeller.durabilityPreservationChance from settings; standalone() hard-codes 0 in the record for that reason.

public interface TreeFellerRunHooks {
    TreeFellerRunHooks NONE;

    void onActivationAccepted();

    boolean reserveLogCost();

    void commitLogCost();

    void refundLogCost();
}

Iris never calls equals/hashCode/toString on hooks.


Failure policy

Misbehaviour Behaviour
onActivationAccepted throws Logged; run continues (notification, not veto)
reserveLogCost throws Logged as false; run ends; nothing refunded
reserveLogCost returns false Run ends cleanly
commitLogCost throws Logged; run ends; block already gone
refundLogCost throws Logged; run ends
Hook blocks a long time Nothing — no timeout
null event or options tryFell → false
Two overrides for one break First stored override's hooks stay; later override returns true without replacing
Candidate resolve throws Logged; false
isTreeBlock throws Logged; false
Iris disabled mid-run Active runs finish immediately; no refund for outstanding reserves

No integration quarantine.

Missed refund

Refunds schedule onto the feller's entity path. If scheduling fails (logout/removal) or shutdown ends runs, refundLogCost may not run. Exposure is at most one log cost per run in the reserve→resolve window. For strict accounting, accumulate in your own state and reconcile on quit/onDisable.


Configuration

plugins/Iris/settings.json:

Key Default Meaning
treeFeller.enabled false Standalone path only. Override ignores it
treeFeller.durabilityPreservationChance 0 Standalone durability preserve %; clamped 0..100 on read
Permission Default Meaning
iris.treefeller op Standalone only. Override does not check it

Related operator surface: 28 - Integrations.md, 04 - Commands & Permissions.md.


Enum reference

TreeFellerAccess

Constant Enabled switch iris.treefeller Durability chance source
STANDALONE Required Required Settings; value in options ignored
INTEGRATION_OVERRIDE Bypassed Bypassed Value in options

Neither mode bypasses survival, sneak, axe, log tag, or mantle provenance.

Default arms: 90 - API - Getting Started.md.