mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-08-27 04:37:47 +00:00
518 lines
22 KiB
Markdown
518 lines
22 KiB
Markdown
# Iris tree feller API
|
|
|
|
`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, block by block, when a sneaking survival player
|
|
breaks one of its logs with an axe. This API lets you turn it on for a player who would not
|
|
otherwise be allowed it, override the durability rules, and take something from that player for each
|
|
log removed — with a reservation you can get back if the log turns out not to be removable.
|
|
|
|
There are two things you can do, and they are independent:
|
|
|
|
| You want to… | Use |
|
|
|---|---|
|
|
| start a felling run that Iris would not have started, or price it | `IrisTreeFellerService#tryFell` with `TreeFellerOptions.integrationOverride(...)` |
|
|
| avoid double-handling the block breaks Iris generates while felling | `IrisTreeFellerService#isManagedBreak` |
|
|
| ask whether a block belongs to an Iris tree at all | `IrisTreeFellerService#isTreeBlock` |
|
|
|
|
**The tree feller is off by default.** `treeFeller.enabled` in Iris's settings is `false` out of the
|
|
box, and the standalone path additionally requires the `iris.treefeller` permission. An
|
|
`INTEGRATION_OVERRIDE` request bypasses **both** — that is what the mode is for, and it means your
|
|
plugin is now the thing that decides who may fell trees.
|
|
|
|
---
|
|
|
|
## Depending on Iris and acquiring the service
|
|
|
|
See [README.md](README.md#depending-on-iris) for the build and plugin-descriptor setup. The service
|
|
is registered with the Bukkit `ServicesManager` at `ServicePriority.Normal`.
|
|
|
|
```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);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## The lifecycle
|
|
|
|
```
|
|
your BlockBreakEvent handler
|
|
|
|
|
v
|
|
tryFell(event, options) register a felling request against this break.
|
|
| Returns true when YOUR request is pending. Nothing has
|
|
| happened yet and no hook has fired.
|
|
|
|
|
| (Iris re-checks everything at EventPriority.MONITOR)
|
|
v
|
|
onActivationAccepted() the run is real. Fires exactly once, if at all.
|
|
|
|
|
| (per LOG block, in the order the tree comes apart)
|
|
v
|
|
reserveLogCost() -> false you refuse. The run ends. Nothing to give back.
|
|
|
|
|
| true
|
|
v
|
|
+--> commitLogCost() the log is gone. The charge is yours. FINAL.
|
|
+--> refundLogCost() the log could not be removed. Give it back.
|
|
```
|
|
|
|
Rules Iris guarantees:
|
|
|
|
- `onActivationAccepted` fires **at most once per run**, and only after Iris has re-validated the
|
|
break at `MONITOR`: the event was not cancelled, the block still resolves to the same Iris tree,
|
|
and no other run already claims that tree.
|
|
- `reserveLogCost` is called **once per log block**, not once per run. A twelve-log tree calls it up
|
|
to twelve times. **Leaves never reserve** — they are removed without consulting you.
|
|
- `reserveLogCost` is called **before** Iris charges the axe's durability, so a refusal costs the
|
|
player nothing at all.
|
|
- Exactly **one** of `commitLogCost` or `refundLogCost` follows a `reserveLogCost` that returned
|
|
`true`, with the one exception described under [Failure policy](#failure-policy).
|
|
- **`commitLogCost` is final.** There is no reversal after it, and Iris will not call
|
|
`refundLogCost` for a block it has already committed.
|
|
- A `reserveLogCost` that returns `false` ends the whole run immediately. It does not skip that log
|
|
and continue.
|
|
- A tree can only be felled by one run at a time, server-wide. A second player breaking the same
|
|
tree while a run is in flight has their break cancelled with drops suppressed, and no hook of
|
|
yours is called for it.
|
|
|
|
There is **no terminal callback.** `TreeFellerRunHooks` has no "the run finished" method. If your
|
|
accounting needs to know when a run ended, count `commitLogCost` and `refundLogCost` calls against
|
|
the `onActivationAccepted` that opened the run, and treat a run with no activity as over.
|
|
|
|
---
|
|
|
|
## Threading
|
|
|
|
Three different threads are involved and the distinction matters, because two of them are region
|
|
threads on Folia and one is an entity scheduler.
|
|
|
|
| Call | Thread |
|
|
|---|---|
|
|
| `tryFell` | You call it. It must be the thread delivering the `BlockBreakEvent` — the region thread that owns the broken block |
|
|
| `isManagedBreak` | Any thread. It is a set lookup and touches nothing else |
|
|
| `isTreeBlock` | The region thread that owns the block. It reads block state **and** can block on disk — see below |
|
|
| `onActivationAccepted` | The region thread that owns the broken block, inline in the `MONITOR` dispatch |
|
|
| `reserveLogCost` | The **feller's entity scheduler thread** |
|
|
| `commitLogCost` | The feller's entity scheduler thread |
|
|
| `refundLogCost` | The feller's entity scheduler thread |
|
|
|
|
The three cost hooks run on the player's entity scheduler, which is the thread that owns that player
|
|
on Folia. Reading and mutating the feller's inventory, experience and effects is legal there. The
|
|
player's *world* is not yours on that thread — do not read or write blocks from a cost hook.
|
|
|
|
`onActivationAccepted` runs on the block's region thread, inline inside the `BlockBreakEvent`
|
|
dispatch at `MONITOR`. Blocks and the player are both legal to touch there, but you are inside event
|
|
dispatch: return promptly.
|
|
|
|
**Do not block, in any of the four.** No I/O, no `CompletableFuture#join`, no locks held across the
|
|
call. Iris does not interrupt a hook that hangs and does not time it out; the contract is the only
|
|
protection. If a cost decision needs remote data, cache it — prime it on `PlayerJoinEvent`.
|
|
|
|
### `isTreeBlock` is the expensive one
|
|
|
|
`isTreeBlock` reads Iris's mantle — the generator's persistent per-region metadata store — to find
|
|
out whether the block was placed by an Iris tree. If the mantle region covering that block is not
|
|
resident in memory, **this call loads it from disk, synchronously, on your thread.** It also reads
|
|
the block's type and block data, so the chunk must be loaded and you must be on the region thread
|
|
that owns it.
|
|
|
|
Concretely: on a first touch in a cold area it does a filesystem stat, and possibly a full region
|
|
load and decompress, before it answers. On a warm area it is a couple of map lookups.
|
|
|
|
Do not call it per block in a loop, per tick, or on a large area. Nothing else in this API touches
|
|
the mantle; if you are calling `isTreeBlock` speculatively rather than about a block a player just
|
|
interacted with, you are using it wrong.
|
|
|
|
---
|
|
|
|
## Worked example: charging stamina per log
|
|
|
|
A plugin with its own stamina pool. It lets players fell trees regardless of Iris's permission and
|
|
enabled switch, charges 4 stamina per log, gives it back when a log turns out not to be removable,
|
|
and preserves the axe 50% of the time.
|
|
|
|
### The 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);
|
|
}
|
|
}
|
|
```
|
|
|
|
`TreeFellerRunHooks` declares all four methods and none of them has a default, so an implementation
|
|
must provide all four even when three are empty. `TreeFellerRunHooks.NONE` is the shared no-op
|
|
implementation whose `reserveLogCost` returns `true`; use it when you want the override behaviour
|
|
without a cost.
|
|
|
|
The hooks instance is **per run**, not per plugin. Build a new one for each `tryFell` call and put
|
|
the feller's identity in it — Iris hands the same instance back for every callback of that run and
|
|
never inspects it, so it is the natural place to carry run state.
|
|
|
|
### The 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);
|
|
}
|
|
}
|
|
```
|
|
|
|
The `isManagedBreak` guard is not optional. While a run is in progress Iris fires a
|
|
`BlockBreakEvent` for **every block it removes**, so that protection plugins and loggers see the
|
|
removals. Without the guard your listener would call `tryFell` on Iris's own break events and your
|
|
stamina check would run once per block of the tree.
|
|
|
|
`EventPriority.HIGH` is a deliberate choice, and it is load-bearing. It is after the priorities a
|
|
protection plugin normally uses to cancel, and — the part that matters — strictly before `HIGHEST`,
|
|
which is where Iris asks for its own standalone run. A break is claimed by the first `tryFell` that
|
|
succeeds against it, so a handler at `HIGHEST` or `MONITOR` can find that Iris has already taken it.
|
|
See [What `tryFell` actually promises](#what-tryfell-actually-promises).
|
|
|
|
Registration is ordinary:
|
|
|
|
```java
|
|
@Override
|
|
public void onEnable() {
|
|
getServer().getPluginManager().registerEvents(new WoodcuttingListener(pool), this);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## The minimum: turn the feller on, charge nothing
|
|
|
|
If you only want players in your woodcutting class to fell trees, with Iris's own durability
|
|
behaviour and no cost:
|
|
|
|
```java
|
|
IrisTreeFellerService feller = FellerAccess.service();
|
|
|
|
if (feller != null && !feller.isManagedBreak(event) && classes.isWoodcutter(event.getPlayer())) {
|
|
feller.tryFell(event, TreeFellerOptions.integrationOverride(0, TreeFellerRunHooks.NONE));
|
|
}
|
|
```
|
|
|
|
Put that in a `BlockBreakEvent` handler at a priority earlier than `HIGHEST` — Iris asks for its own
|
|
standalone run at `HIGHEST`, and the first request to succeed claims the break.
|
|
|
|
`TreeFellerRunHooks.NONE` never refuses and never charges. A `durabilityPreservationChance` of `0`
|
|
means every log costs one point of axe durability, which is vanilla-equivalent.
|
|
|
|
`TreeFellerOptions.standalone()` exists for completeness — it is the request Iris makes for itself —
|
|
and there is almost never a reason for a third party to pass it. It respects the enabled switch and
|
|
the permission, so it can only ever do what Iris would already have done.
|
|
|
|
---
|
|
|
|
## What `tryFell` actually promises
|
|
|
|
```java
|
|
boolean tryFell(BlockBreakEvent event, TreeFellerOptions options);
|
|
```
|
|
|
|
`true` means **your felling request is pending against this break**. It does not mean a tree will
|
|
fall — Iris re-validates everything at `MONITOR` and can still drop the request there.
|
|
|
|
**A break is claimed by the first `tryFell` that succeeds against it.** The moment a request is
|
|
accepted, Iris marks that `BlockBreakEvent` as managed, and every later `tryFell` for the same event
|
|
returns `false` immediately, without looking at your `access` at all. There is no displacement and no
|
|
last-writer-wins: whoever asks first, in event-priority order, owns the break.
|
|
|
|
| State when you call | Your `access` | Result |
|
|
|---|---|---|
|
|
| Nothing pending | either | Your request becomes pending. Returns `true` |
|
|
| A request already accepted for this break | either | The existing one stays. Returns `false` |
|
|
|
|
That has one consequence you must design around. **Iris makes its own `STANDALONE` request from a
|
|
listener at `EventPriority.HIGHEST`.** If your handler runs at `HIGHEST` and happens to be registered
|
|
after Iris's, or at `MONITOR`, Iris has already claimed the break and your override is refused. Call
|
|
`tryFell` from a handler at a priority strictly earlier than `HIGHEST` — `LOWEST`, `LOW`, `NORMAL` or
|
|
`HIGH` — and your `INTEGRATION_OVERRIDE` is the one that lands. `HIGH` is the usual choice.
|
|
|
|
Two plugins that both want to override the same break resolve the same way: the earlier priority
|
|
wins, and the later one gets `false` and knows it lost. Nothing is silently discarded.
|
|
|
|
`false` means no request of yours is pending. Iris returns `false` when:
|
|
|
|
- the service is disabled, or `event` or `options` is `null`;
|
|
- the event is already cancelled;
|
|
- the event is one Iris is already managing — either a break another request has already claimed, or
|
|
one of the per-block probe events Iris fires during a run. `isManagedBreak` answers both;
|
|
- `canUse` failed — for `STANDALONE` that means `treeFeller.enabled` is `false` or the player lacks
|
|
`iris.treefeller`; an `INTEGRATION_OVERRIDE` never fails this check;
|
|
- the break is not a fellable candidate.
|
|
|
|
`true` is still not a run. The `MONITOR` re-validation drops the request if the event was cancelled
|
|
after you asked, if the block no longer resolves to the same Iris tree, or if another run already
|
|
claims that tree — and in none of those cases does a hook fire. Open your run state in
|
|
`onActivationAccepted`, not at `tryFell`.
|
|
|
|
### What makes a break a candidate
|
|
|
|
An `INTEGRATION_OVERRIDE` bypasses the enabled switch and the permission. It does **not** bypass any
|
|
of these, and there is no option to:
|
|
|
|
- the player is in `GameMode.SURVIVAL`;
|
|
- the player is sneaking;
|
|
- the broken block is tagged `Tag.LOGS`;
|
|
- the item in the player's main hand is an axe;
|
|
- the block carries Iris tree provenance in the mantle — it was placed by an Iris tree, has not been
|
|
replaced since, and is not part of a structure.
|
|
|
|
A tree the player planted with a vanilla sapling is not an Iris tree and will never fell. Neither is
|
|
a log a player placed by hand: Iris clears the provenance record for a block as soon as it is broken
|
|
or built over.
|
|
|
|
---
|
|
|
|
## How a run comes apart
|
|
|
|
Once activated, Iris discovers the tree by walking the mantle provenance markers outward from the
|
|
broken block in all 26 directions, breadth-first. Members are then removed in that discovery order —
|
|
the block the player broke first, then outward — with ties broken by Y, then X, then Z.
|
|
|
|
Discovery is bounded. If any bound is hit the discovery is **incomplete**, and Iris falls back to
|
|
removing only the block the player actually broke:
|
|
|
|
| Bound | Value |
|
|
|---|---|
|
|
| Members collected | 131 072 |
|
|
| Positions visited | 1 000 000 |
|
|
| Distance from the broken block on any axis | 256 blocks |
|
|
|
|
Removal is paced: Iris removes a batch of blocks, then yields for a tick before the next batch, so a
|
|
large tree takes several ticks and does not stall a region. Batch size scales with the tree.
|
|
|
|
A run ends immediately, with no further hooks, when the player:
|
|
|
|
- stops sneaking,
|
|
- changes their held hotbar slot,
|
|
- swaps their hands,
|
|
- goes offline, leaves survival mode, or changes world,
|
|
- breaks their axe (the run ends after the log that broke it is committed),
|
|
- or replaces the axe in that slot with a different item.
|
|
|
|
Each removed block fires its own `BlockBreakEvent`, marked so that `isManagedBreak` returns `true`
|
|
for it during dispatch. Other plugins can cancel that event to protect a block. A cancelled probe on
|
|
a **log** refunds that log's reservation and ends the run; a cancelled probe on a **leaf** has no
|
|
reservation to give back and the run simply carries on to the next member. Drops for each block are
|
|
computed with the axe **as it was before that block's durability charge**, so enchantments like Silk
|
|
Touch and Fortune apply normally.
|
|
|
|
The original break event is cancelled by Iris with drops and experience suppressed, because Iris
|
|
delivers them itself per block instead.
|
|
|
|
---
|
|
|
|
## What the options carry
|
|
|
|
```java
|
|
public record TreeFellerOptions(
|
|
TreeFellerAccess access,
|
|
int durabilityPreservationChance,
|
|
TreeFellerRunHooks runHooks) {
|
|
|
|
public static TreeFellerOptions standalone();
|
|
|
|
public static TreeFellerOptions integrationOverride(
|
|
int durabilityPreservationChance,
|
|
TreeFellerRunHooks runHooks);
|
|
}
|
|
```
|
|
|
|
The canonical constructor throws `NullPointerException` for a null `access` or `runHooks`, and
|
|
`IllegalArgumentException` for a `durabilityPreservationChance` outside `0 .. 100`. Both factory
|
|
methods go through it, so `TreeFellerOptions.integrationOverride(101, hooks)` throws at the call
|
|
site rather than clamping silently.
|
|
|
|
`durabilityPreservationChance` is a percentage: the chance that removing one log costs the axe no
|
|
durability at all. `0` charges every log; `100` never charges. It is rolled independently per log.
|
|
An unbreakable axe is never charged whatever the value.
|
|
|
|
**The value is only honoured for `INTEGRATION_OVERRIDE`.** A `STANDALONE` request ignores whatever
|
|
you passed and uses `treeFeller.durabilityPreservationChance` from Iris's settings —
|
|
`TreeFellerOptions.standalone()` hard-codes `0` in the record for exactly that reason.
|
|
|
|
```java
|
|
public interface TreeFellerRunHooks {
|
|
TreeFellerRunHooks NONE;
|
|
|
|
void onActivationAccepted();
|
|
|
|
boolean reserveLogCost();
|
|
|
|
void commitLogCost();
|
|
|
|
void refundLogCost();
|
|
}
|
|
```
|
|
|
|
Iris never calls anything else on your hooks object — not `equals`, not `hashCode`, not `toString`.
|
|
It holds the reference for the duration of the run and drops it when the run ends.
|
|
|
|
---
|
|
|
|
## Failure policy
|
|
|
|
Iris assumes a hooks implementation will throw, refuse late, or be handed a player who logs out
|
|
mid-run.
|
|
|
|
| Misbehaviour | What Iris does |
|
|
|---|---|
|
|
| `onActivationAccepted` throws | Logged with the stack trace. **The run continues** — activation is a notification, not a veto |
|
|
| `reserveLogCost` throws | Logged, treated as `false`. The run ends. Nothing is refunded, because nothing was reserved |
|
|
| `reserveLogCost` returns `false` | Not a fault. The run ends cleanly at that log |
|
|
| `commitLogCost` throws | Logged. The run ends. **The block is already gone and is not restored** |
|
|
| `refundLogCost` throws | Logged. The run ends |
|
|
| A hook blocks for a long time | Nothing. Iris does not time hooks out, does not warn, and cannot interrupt them |
|
|
| `tryFell` is passed a null event or options | Returns `false`. No hook is called |
|
|
| Two plugins request an override for one break | The one whose handler ran first wins. The other gets `false` and no hook of its own fires |
|
|
| Resolving the candidate throws | Logged. `tryFell` returns `false` |
|
|
| `isTreeBlock` throws | Logged. Returns `false` |
|
|
| Iris is disabled mid-run | Every active run is finished immediately. **No refund is issued for anything outstanding** |
|
|
|
|
**Iris does not quarantine a misbehaving integration.** There is no fault limit, no disable-after-N,
|
|
and no automatic unregistration. A hooks implementation that throws on every log will be logged on
|
|
every log, forever.
|
|
|
|
### The one place a refund can be missed
|
|
|
|
A refund is delivered by scheduling onto the feller's entity scheduler. If that scheduling fails —
|
|
the player has logged out, or been removed from the world, between the reservation and the failure
|
|
that triggers the refund — Iris finishes the run **without calling `refundLogCost`**. The same
|
|
applies to a plugin shutdown that ends runs in flight.
|
|
|
|
The exposure is at most one log's worth of cost per run, and only in the window between reserving a
|
|
log and resolving it, which is a single block removal. If a stricter guarantee matters to you, do
|
|
not settle the charge inside the hooks: accumulate reservations in your own per-run state keyed by
|
|
the feller, and reconcile on `PlayerQuitEvent` and on your own `onDisable`. The hooks tell you what
|
|
happened; they are not a transaction log you can rely on being complete across a disconnect.
|
|
|
|
---
|
|
|
|
## Configuration
|
|
|
|
`plugins/Iris/settings.json`:
|
|
|
|
| Key | Default | Meaning |
|
|
|---|---|---|
|
|
| `treeFeller.enabled` | `false` | Master switch for the **standalone** path only. When `false`, Iris never fells a tree on its own. An `INTEGRATION_OVERRIDE` request is unaffected |
|
|
| `treeFeller.durabilityPreservationChance` | `0` | Percentage chance a log costs no axe durability, for the standalone path only. Clamped to `0 .. 100` on read |
|
|
|
|
Permission, declared in the plugin descriptor:
|
|
|
|
| Node | Default | Meaning |
|
|
|---|---|---|
|
|
| `iris.treefeller` | `op` | Required for the standalone path. An `INTEGRATION_OVERRIDE` request does not check it |
|
|
|
|
---
|
|
|
|
## Enum reference
|
|
|
|
### `TreeFellerAccess`
|
|
|
|
| Constant | Enabled switch | `iris.treefeller` | `durabilityPreservationChance` source |
|
|
|---|---|---|---|
|
|
| `STANDALONE` | Required | Required | Iris settings; the value in your options is ignored |
|
|
| `INTEGRATION_OVERRIDE` | Bypassed | Bypassed | The value in your options |
|
|
|
|
Neither mode bypasses the candidate checks — survival, sneaking, an axe, a log, and Iris tree
|
|
provenance.
|
|
|
|
Write a `default` arm when switching over this enum; see
|
|
[README.md](README.md#switching-over-the-enums).
|