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

396 lines
14 KiB
Markdown

# 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` |
```java
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();
}
}
```
```java
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
```java
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
```java
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.
```java
@Override
public void onEnable() {
getServer().getPluginManager().registerEvents(new WoodcuttingListener(pool), this);
}
```
---
## Minimum: override only, no charge
```java
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
```java
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
```java
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.
```java
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`.