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:
onActivationAcceptedfires at most once per run, only after MONITOR re-validation: event not cancelled, block still the same Iris tree, tree not already claimed.reserveLogCostonce per log (not leaves). Leaves never reserve.- Reserve runs before axe durability charge.
- Exactly one of
commitLogCostorrefundLogCostfollows a true reserve, except the miss cases under Failure policy. commitLogCostis final. No later refund for that log.reserveLogCostfalse 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/optionsnull; - event already cancelled;
- managed break with no pending (Iris probe / already finalizing path);
canUsefailed — 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 (
*_AXEmaterial 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/runHooks → NullPointerException; chance outside 0..100 → IllegalArgumentException.
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.