mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-08-27 12:41:43 +00:00
API
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
# Iris API
|
||||
|
||||
`art.arcane.iris.api` is the surface another plugin compiles against. It answers three questions:
|
||||
what does Iris terrain look like at a coordinate, when does an Iris world engine come up and go
|
||||
down, and how do I hand an axe-swing to the Iris tree feller and get told what it cost. It is built
|
||||
from Bukkit types, `java.*` types and its own types only — no VolmLib, no Adventure, no shaded
|
||||
types — so it links against a plain Spigot or Paper compile classpath. A test in the Iris build
|
||||
walks every class in the package and fails the build if any exported signature mentions anything
|
||||
else.
|
||||
|
||||
| Package | What it is for | Document |
|
||||
|---|---|---|
|
||||
| `art.arcane.iris.api.terrain` | Ask what the generator says about a coordinate: is this an Iris world, what biome, what region, how high is the surface, what kind of surface | [terrain.md](terrain.md) |
|
||||
| `art.arcane.iris.api.world` | Learn when an engine becomes usable and when it stops being usable | [world-events.md](world-events.md) |
|
||||
| `art.arcane.iris.api.pregen` | Follow a pregeneration job | [world-events.md](world-events.md) |
|
||||
| `art.arcane.iris.api.tree` | Drive the tree feller and charge for it | [tree-feller.md](tree-feller.md) |
|
||||
|
||||
PlaceholderAPI keys are not a compile surface, but they are a contract an operator depends on:
|
||||
[placeholders.md](placeholders.md).
|
||||
|
||||
Anything outside `art.arcane.iris.api` is internal. `art.arcane.iris.core.*`,
|
||||
`art.arcane.iris.engine.*`, `art.arcane.iris.util.*` and `art.arcane.iris.spi.*` change without
|
||||
notice and without a deprecation cycle. If you find yourself importing `Engine`, `IrisBiome` or
|
||||
`IrisToolbelt`, you are outside the contract.
|
||||
|
||||
---
|
||||
|
||||
## Platform limitation
|
||||
|
||||
`art.arcane.iris.api` ships in the **Bukkit plugin jar only**. The Fabric, Forge and NeoForge mod
|
||||
jars contain the same generator but not this package — there is no Bukkit `World`, no
|
||||
`ServicesManager` and no `Event` bus to hang it on. A mod that wants generator data uses the mod
|
||||
loader's own registries.
|
||||
|
||||
The mod jars carry a separate, unrelated surface at `art.arcane.iris.modded.api`, for supplying
|
||||
custom block data to the generator from a mod. It is not covered by these documents, is absent from
|
||||
the Bukkit plugin jar, and shares no types with `art.arcane.iris.api`.
|
||||
|
||||
Everything in these documents assumes Paper, Purpur, Leaf, Canvas, Folia or Spigot, Minecraft 26.2,
|
||||
Java 25.
|
||||
|
||||
---
|
||||
|
||||
## Depending on Iris
|
||||
|
||||
Iris is not published to Maven Central. Two routes work.
|
||||
|
||||
**Against the jar you already have.** This is the route that cannot go wrong: the jar you compile
|
||||
against is the jar you run against.
|
||||
|
||||
```gradle
|
||||
dependencies {
|
||||
compileOnly(files('libs/Iris.jar'))
|
||||
}
|
||||
```
|
||||
|
||||
**Against JitPack.** This is what Volmit's own plugins do. `transitive = false` is required — the
|
||||
Iris build declares a large dependency graph you do not want on your compile classpath.
|
||||
|
||||
```gradle
|
||||
repositories {
|
||||
maven { url = uri('https://jitpack.io') }
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly('com.github.VolmitSoftware:Iris:<tag-or-branch-SNAPSHOT>') {
|
||||
changing = true
|
||||
transitive = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Bukkit plugin (`plugin.yml`):
|
||||
|
||||
```yaml
|
||||
softdepend: [Iris]
|
||||
```
|
||||
|
||||
Paper plugin (`paper-plugin.yml`):
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
server:
|
||||
Iris:
|
||||
load: BEFORE
|
||||
required: false
|
||||
join-classpath: true
|
||||
```
|
||||
|
||||
`join-classpath: true` is mandatory on Paper. Plugin classloaders are isolated, and without it you
|
||||
get `NoClassDefFoundError` on `art.arcane.iris.api.*` even though the classes ship unrelocated.
|
||||
|
||||
Iris declares `load: STARTUP` and registers its services during its own `onEnable`. Do not resolve
|
||||
an Iris service in a static initialiser or a constructor. Resolve it lazily, at the point of use,
|
||||
and handle `null` — see below.
|
||||
|
||||
---
|
||||
|
||||
## Acquiring a service
|
||||
|
||||
Two services are registered with the Bukkit `ServicesManager` at `ServicePriority.Normal`:
|
||||
`IrisTerrainService` and `IrisTreeFellerService`. Both are unregistered on Iris shutdown.
|
||||
|
||||
```java
|
||||
package com.example.integration;
|
||||
|
||||
import art.arcane.iris.api.terrain.IrisTerrainService;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.plugin.RegisteredServiceProvider;
|
||||
|
||||
public final class IrisLookup {
|
||||
private IrisLookup() {
|
||||
}
|
||||
|
||||
public static IrisTerrainService terrain() {
|
||||
RegisteredServiceProvider<IrisTerrainService> provider =
|
||||
Bukkit.getServicesManager().getRegistration(IrisTerrainService.class);
|
||||
return provider == null ? null : provider.getProvider();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Resolve on every use, or cache and invalidate on `PluginDisableEvent`. A cached reference to a
|
||||
service whose plugin has been disabled does not throw — every terrain query answers "absent" and
|
||||
every tree-feller call returns `false` — but it will never answer usefully again, and the
|
||||
replacement instance registered by a later enable is a different object.
|
||||
|
||||
Neither service is a functional interface and neither is meant to be implemented by a third party.
|
||||
`ServicesManager#getRegistration` hands back the highest-priority registration, so registering your
|
||||
own `IrisTerrainService` above `Normal` shadows Iris's for every other plugin on the server. Do not.
|
||||
It does not shadow it for Iris — Iris resolves its own services from an internal registry, so its
|
||||
PlaceholderAPI expansion keeps reading the real one, and the two would then disagree.
|
||||
|
||||
---
|
||||
|
||||
## The shared library is not relocated
|
||||
|
||||
Iris bundles `art.arcane.volmlib` **unrelocated**, at its real package name. Several sibling Volmit
|
||||
plugins do relocate it — Adapt shades it to `art.arcane.adapt.util.arcane.volmlib`, React to
|
||||
`art.arcane.react.util.arcane.volmlib`. Three consequences, in order of how likely they are to bite:
|
||||
|
||||
1. **You do not need VolmLib to use this API.** No type in `art.arcane.iris.api` mentions it. You
|
||||
never import it, never shade it, never declare it.
|
||||
|
||||
2. **If you also use VolmLib yourself, shade and relocate your own copy.** Do not compile against
|
||||
`art.arcane.volmlib` expecting Iris's copy to satisfy it at runtime. Under Paper's isolated
|
||||
classloaders you would need `join-classpath: true` on the Iris dependency and you would be
|
||||
binding to whatever VolmLib version Iris happens to ship, which changes on Iris's release
|
||||
schedule and not yours. Relocating your copy costs nothing and removes the coupling entirely.
|
||||
|
||||
3. **A relocated sibling and Iris do not share those classes.** `art.arcane.adapt.util.arcane.volmlib.X`
|
||||
and `art.arcane.volmlib.X` are unrelated types to the JVM. Never pass an object obtained from one
|
||||
plugin's shaded copy into another's; the cast fails at runtime, not at compile time.
|
||||
|
||||
---
|
||||
|
||||
## Threading, at a glance
|
||||
|
||||
This suite runs on Folia, where region threads own chunks and entity schedulers own entities. Each
|
||||
document states its own contract; this is the summary.
|
||||
|
||||
| Call | Which thread may call it | Where the callback lands |
|
||||
|---|---|---|
|
||||
| Every `IrisTerrainService` read | Any thread, including async | Returns inline |
|
||||
| `IrisColumnSink.accept` | — | The thread that called `sampleColumns` |
|
||||
| `IrisTreeFellerService.tryFell` | The region thread delivering the `BlockBreakEvent` | Returns inline |
|
||||
| `IrisTreeFellerService.isManagedBreak` | Any thread | Returns inline |
|
||||
| `IrisTreeFellerService.isTreeBlock` | The region thread owning the block; it can also block on disk — see [tree-feller.md](tree-feller.md#istreeblock-is-the-expensive-one) | Returns inline |
|
||||
| `TreeFellerRunHooks.onActivationAccepted` | — | The region thread that owns the broken block |
|
||||
| `TreeFellerRunHooks.reserveLogCost` / `commitLogCost` / `refundLogCost` | — | The feller's entity scheduler thread |
|
||||
| `IrisWorldEngineEvent` handlers | — | Main thread; on Folia, the global region thread |
|
||||
| `IrisPregenerationEvent` handlers | — | Main thread; on Folia, the global region thread |
|
||||
|
||||
"Any thread" is claimed for the terrain reads because they are justified in doing so: they read the
|
||||
world's generator reference and evaluate cached procedural noise, and touch no chunk, no block
|
||||
state, no entity and no mantle storage. See [terrain.md](terrain.md#threading) for the full
|
||||
argument. It is not a claim any other part of this API makes.
|
||||
|
||||
---
|
||||
|
||||
## Switching over the enums
|
||||
|
||||
`IrisSurfaceKind`, `IrisColumnField`, `IrisWorldPhase`, `IrisPregenPhase` and `TreeFellerAccess` may
|
||||
gain constants in a future release. A `switch` **expression** over them is exhaustive, so it stops
|
||||
compiling — and throws `IncompatibleClassChangeError` on an already-compiled jar — the moment one is
|
||||
added.
|
||||
|
||||
**Always write a `default` arm** in third-party code:
|
||||
|
||||
```java
|
||||
String label = switch (kind) {
|
||||
case LAND -> "land";
|
||||
case OCEAN -> "water";
|
||||
default -> "";
|
||||
};
|
||||
```
|
||||
@@ -0,0 +1,216 @@
|
||||
# Iris placeholders
|
||||
|
||||
Iris registers the `iris` PlaceholderAPI expansion when PlaceholderAPI is enabled. It publishes
|
||||
sixteen keys: seven in the world family, describing the generator around the reading player, and
|
||||
nine in the pregeneration family, describing the server's running pregeneration job.
|
||||
|
||||
This is an operator-facing contract, not a compile surface. Nothing here needs a dependency, a
|
||||
`softdepend`, or a line of Java. If you are writing a plugin rather than a scoreboard, the same data
|
||||
is available with more precision through the [terrain API](terrain.md) and the
|
||||
[pregeneration events](world-events.md).
|
||||
|
||||
**The pre-2.0 underscore keys are gone.** There is no alias and no dual-accept window. If you are
|
||||
upgrading an existing board, go straight to the [migration table](#migration-from-the-pre-20-keys).
|
||||
|
||||
---
|
||||
|
||||
## The value grammar
|
||||
|
||||
Every key follows the same rules, so a board never has to special-case Iris:
|
||||
|
||||
- Paths are **dot-separated and lowercase** and never contain an underscore. Iris lowercases the
|
||||
path before resolving it, so `%iris_WORLD.BIOME%` works, but write it lowercase.
|
||||
- Values are **plain text**: no colour codes, no unit suffixes, no `%` character, `.` as the decimal
|
||||
separator, no thousands grouping.
|
||||
- Any section sign or `%` character that appears inside a pack-authored name — a biome display name,
|
||||
a world name — is stripped before you see it, so a pack cannot inject formatting or a nested
|
||||
placeholder into your board.
|
||||
|
||||
There are exactly three possible answers:
|
||||
|
||||
| Answer | When | What PlaceholderAPI shows |
|
||||
|---|---|---|
|
||||
| A value | The key is known and has data | The value |
|
||||
| `---` | The key is known and has no data right now | `---` |
|
||||
| Nothing | The key is not one Iris publishes | The literal `%iris_...%` |
|
||||
|
||||
The third row is deliberate. A typo stays visible on the board instead of quietly rendering as
|
||||
blank, which is why there is no catch-all fallback.
|
||||
|
||||
A real zero is `0`, never `---`. `---` means "no reading", not "zero".
|
||||
|
||||
---
|
||||
|
||||
## World keys
|
||||
|
||||
| Placeholder | Value |
|
||||
|---|---|
|
||||
| `%iris_available%` | `true` when the Iris terrain service is live, `false` otherwise |
|
||||
| `%iris_world.available%` | `true` when the reading player is in an Iris world and a reading exists |
|
||||
| `%iris_world.biome%` | Surface biome display name at the player, for example `Hot Desert Dunes` |
|
||||
| `%iris_world.biome-key%` | Surface biome load key, for example `desert/hot-dunes` |
|
||||
| `%iris_world.region%` | Region display name at the player |
|
||||
| `%iris_world.region-key%` | Region load key |
|
||||
| `%iris_world.dimension%` | Dimension (pack) load key of the player's world |
|
||||
|
||||
`%iris_available%` is the only world-family key that does not need a player. It answers for the
|
||||
console and for an offline player.
|
||||
|
||||
Every other `world.*` key needs a tracked online player. For the console, an offline player, or a
|
||||
player Iris has no position for yet, `world.available` is `false` and the rest are `---`.
|
||||
|
||||
### They are surface readings, and they are cached
|
||||
|
||||
`world.biome`, `world.biome-key`, `world.region` and `world.region-key` describe the **surface** at
|
||||
the player's block column — the biome and region the generator places at ground level. A player
|
||||
standing in a cave under an overhang reads the biome of the sky above them, not the cave they are
|
||||
in. That is what a board reader means by "what biome am I in".
|
||||
|
||||
The reading is rebuilt at most **once per second per player**, and only when something actually reads
|
||||
one of these keys. Consequences:
|
||||
|
||||
- A whole board of `world.*` keys costs one rebuild per player per second, however many of them are
|
||||
on it.
|
||||
- A value can lag a sprinting player by up to a second.
|
||||
- A board that nobody is looking at costs nothing.
|
||||
|
||||
Position tracking has two speeds. Walking republishes a player's column at most once per second, and
|
||||
not at all while they stand still. Anything that is not walking — joining, respawning, changing
|
||||
world, stepping through a portal, or **any** teleport including `/iris goto`, `/tp`, an ender pearl
|
||||
and a random teleport — publishes immediately. A player who arrives somewhere and stops moving
|
||||
therefore never keeps showing the biome of where they came from.
|
||||
|
||||
---
|
||||
|
||||
## Pregeneration keys
|
||||
|
||||
| Placeholder | Value |
|
||||
|---|---|
|
||||
| `%iris_pregen.available%` | `true` while a pregeneration job is running |
|
||||
| `%iris_pregen.world%` | World name the running job is pregenerating |
|
||||
| `%iris_pregen.percent%` | Completion, `0.00` to `100.00`, with no `%` character |
|
||||
| `%iris_pregen.eta%` | Estimated seconds remaining, whole number |
|
||||
| `%iris_pregen.eta-text%` | The same estimate as `45s`, `2m 5s` or `1h 30m` |
|
||||
| `%iris_pregen.chunks%` | Chunks generated so far |
|
||||
| `%iris_pregen.total%` | Chunks in the job |
|
||||
| `%iris_pregen.chunks-per-second%` | Current rate, two decimal places |
|
||||
| `%iris_pregen.paused%` | `true` while the job is paused |
|
||||
|
||||
`pregen.*` is **global**, not per player. Iris runs one pregeneration job per server, so these keys
|
||||
read the same for everyone, including the console. `%iris_pregen.world%` says which world it is.
|
||||
|
||||
The snapshot is published when the job reports progress, once per second, and cleared the moment the
|
||||
job completes or is cancelled. After that every `pregen.*` key except `pregen.available` reads `---`,
|
||||
and `pregen.available` reads `false`. There is no lingering "last job" state to mistake for a running
|
||||
one.
|
||||
|
||||
`pregen.eta` and `pregen.eta-text` are two renderings of the same estimate: use `eta` for arithmetic
|
||||
and `eta-text` for display. Both read `0` and `0s` respectively before the job has generated enough
|
||||
chunks to estimate from.
|
||||
|
||||
---
|
||||
|
||||
## Availability
|
||||
|
||||
The expansion is registered only if PlaceholderAPI is enabled when Iris starts. It sets `persist()`,
|
||||
so it survives `/papi reload` without Iris restarting.
|
||||
|
||||
`%iris_available%` distinguishes "Iris is installed but its terrain service is not up" from "Iris is
|
||||
not installed at all" — in the second case the expansion does not exist, no key resolves, and every
|
||||
`%iris_...%` on the board renders literally. Gate a conditional board on `%iris_available%` if you
|
||||
want it to disappear cleanly rather than show `---` rows on a server where Iris is present but still
|
||||
starting.
|
||||
|
||||
Iris never gates a placeholder on a permission. A placeholder has no permission context to check
|
||||
against — the player reading a scoreboard is not necessarily the player the value describes — so
|
||||
values that should not be public are not published at all. That is why there is no seed key.
|
||||
|
||||
---
|
||||
|
||||
## Migration from the pre-2.0 keys
|
||||
|
||||
The old underscore keys are gone. There is no alias and no dual-accept window: an old key now
|
||||
renders literally, so it is visible rather than silently wrong. This table is complete — every key
|
||||
the old expansion published appears in it.
|
||||
|
||||
| Old key | New key | Why |
|
||||
|---|---|---|
|
||||
| `%iris_biome_name%` | `%iris_world.biome%` | Renamed onto the dot grammar |
|
||||
| `%iris_biome_id%` | `%iris_world.biome-key%` | Renamed; `id` was always the load key |
|
||||
| `%iris_region_name%` | `%iris_world.region%` | Renamed onto the dot grammar |
|
||||
| `%iris_region_id%` | `%iris_world.region-key%` | Renamed; `id` was always the load key |
|
||||
| `%iris_biome_file%` | removed | Rendered an absolute server path into player-visible text, and threw on packs with no backing file |
|
||||
| `%iris_region_file%` | removed | Same as `biome_file` |
|
||||
| `%iris_world_seed%` | removed | Handed the world seed to anyone who could read a scoreboard, and a placeholder has no permission context to gate on |
|
||||
| `%iris_terrain_height%` | removed | Reported the *generated* height, before objects and player edits, so it disagreed with the block under the player's feet |
|
||||
| `%iris_terrain_slope%` | removed | Three extra noise samples per read for an unformatted pack-authoring diagnostic |
|
||||
| `%iris_world_mode%` | removed | Studio or Production; a studio world exists for seconds during authoring and is never on a live board |
|
||||
| `%iris_world_speed%` | removed | Mutated engine rate-window state every time it was read. `%iris_pregen.chunks-per-second%` answers the same question from a snapshot |
|
||||
|
||||
There is one behaviour change inside the four renames, and it will be visible on a board that has
|
||||
been in service for a while. The old keys sampled **two blocks above the player's feet** and asked
|
||||
for the biome at that exact Y, which meant a player standing under an overhang or inside a cave read
|
||||
the *cave* biome. `%iris_world.biome%` and `%iris_world.biome-key%` are always the surface biome for
|
||||
the column. If your board is checked against a screenshot from before the rename, expect
|
||||
underground readings to differ.
|
||||
|
||||
`%iris_world.dimension%` is new. It has no pre-2.0 equivalent.
|
||||
|
||||
The three removals worth a replacement plan:
|
||||
|
||||
- **`world_seed`** has no replacement and will not get one. A plugin that legitimately needs the seed
|
||||
can read it from `IrisWorldInfo.seed()` through the [terrain API](terrain.md), where there is a
|
||||
caller to hold responsible.
|
||||
- **`terrain_height`** has no replacement. If you want the ground height for a coordinate, use
|
||||
`IrisTerrainService#surfaceHeight`, which is the same number with its limitations documented. If
|
||||
you want the block under the player, use the player's own Y.
|
||||
- **`world_speed`** is replaced by `%iris_pregen.chunks-per-second%` for the pregeneration case,
|
||||
which is what it was almost always used for. There is no per-world live generation rate key.
|
||||
|
||||
---
|
||||
|
||||
## Failure policy
|
||||
|
||||
| Situation | What Iris shows |
|
||||
|---|---|
|
||||
| An unknown path | Nothing. PlaceholderAPI re-emits the literal `%iris_...%` |
|
||||
| A known path with no data | `---` |
|
||||
| No player context, on a `world.*` key | `---`, and `world.available` is `false` |
|
||||
| Player is not in an Iris world | `---`, and `world.available` is `false` |
|
||||
| The terrain service is not registered | `---`, `world.available` is `false`, `%iris_available%` is `false` |
|
||||
| No pregeneration job running | `---`, and `pregen.available` is `false` |
|
||||
| A resolver throws | `---`, and one warning is logged naming the exact placeholder |
|
||||
|
||||
A resolver that throws is logged **once per distinct path**, up to 64 distinct paths, so a broken
|
||||
key cannot flood the console from a scoreboard that re-renders every tick. The value shown is always
|
||||
`---` — a failure never renders a stack trace, a class name, or an empty string.
|
||||
|
||||
Iris does not disable a placeholder after repeated failures. There is no fault limit and no
|
||||
quarantine; a key that fails keeps being asked and keeps answering `---`.
|
||||
|
||||
---
|
||||
|
||||
## Key reference
|
||||
|
||||
The full published list, as PlaceholderAPI reports it under `/papi info iris`:
|
||||
|
||||
```
|
||||
available
|
||||
pregen.available
|
||||
pregen.chunks
|
||||
pregen.chunks-per-second
|
||||
pregen.eta
|
||||
pregen.eta-text
|
||||
pregen.paused
|
||||
pregen.percent
|
||||
pregen.total
|
||||
pregen.world
|
||||
world.available
|
||||
world.biome
|
||||
world.biome-key
|
||||
world.dimension
|
||||
world.region
|
||||
world.region-key
|
||||
```
|
||||
|
||||
Prefix each with `%iris_` and suffix with `%`.
|
||||
@@ -0,0 +1,584 @@
|
||||
# Iris terrain query API
|
||||
|
||||
`art.arcane.iris.api.terrain` answers what the Iris generator says about a coordinate: whether a
|
||||
world is an Iris world at all, what biome and region the pack places there, how high the terrain
|
||||
generates and whether that surface is land, shore, ocean or nothing. It is a read of the
|
||||
**generator**, not of the world. It never loads a chunk, never forces generation, never reads a
|
||||
placed block, and never tells you what a player has since built.
|
||||
|
||||
Everything here is cheap and non-blocking, and this document says exactly how cheap and exactly why
|
||||
non-blocking, because a terrain API where the reader has to guess is a terrain API that ends up in a
|
||||
per-tick loop.
|
||||
|
||||
---
|
||||
|
||||
## 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` for the duration of the
|
||||
Iris plugin's enabled lifetime.
|
||||
|
||||
```java
|
||||
package com.example.integration;
|
||||
|
||||
import art.arcane.iris.api.terrain.IrisTerrainService;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.plugin.RegisteredServiceProvider;
|
||||
|
||||
public final class TerrainAccess {
|
||||
private TerrainAccess() {
|
||||
}
|
||||
|
||||
public static IrisTerrainService service() {
|
||||
RegisteredServiceProvider<IrisTerrainService> provider =
|
||||
Bukkit.getServicesManager().getRegistration(IrisTerrainService.class);
|
||||
return provider == null ? null : provider.getProvider();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
There is no `Iris` class to import, no static accessor and no reflection. If the registration is
|
||||
missing, Iris is absent or has not enabled yet; that is a `null` and not an exception.
|
||||
|
||||
---
|
||||
|
||||
## The read surface
|
||||
|
||||
```java
|
||||
public interface IrisTerrainService {
|
||||
boolean isIrisWorld(World world);
|
||||
|
||||
Optional<IrisWorldInfo> worldInfo(World world);
|
||||
|
||||
OptionalInt surfaceHeight(World world, int blockX, int blockZ);
|
||||
|
||||
IrisSurfaceKind surfaceKind(World world, int blockX, int blockZ);
|
||||
|
||||
Optional<String> surfaceBiomeKey(World world, int blockX, int blockZ);
|
||||
|
||||
Optional<String> surfaceBiomeName(World world, int blockX, int blockZ);
|
||||
|
||||
Optional<String> biomeKey(World world, int blockX, int blockY, int blockZ);
|
||||
|
||||
Optional<String> regionKey(World world, int blockX, int blockZ);
|
||||
|
||||
Optional<String> regionName(World world, int blockX, int blockZ);
|
||||
|
||||
int maxSampleColumns();
|
||||
|
||||
int maxSampleChunks();
|
||||
|
||||
boolean sampleColumns(World world, IrisColumnQuery query, IrisColumnSink sink);
|
||||
}
|
||||
```
|
||||
|
||||
All coordinates are **absolute block coordinates in world space**, including `blockY` and including
|
||||
the value returned by `surfaceHeight`. There is no engine-space offset for a caller to apply.
|
||||
|
||||
`*Key` returns a pack load key — `desert/hot-dunes`, `overworld` — which is stable, lowercase and
|
||||
what you store. `*Name` returns the author's display string — `Hot Desert Dunes` — which is what you
|
||||
show and which can change when the pack author edits it. Both are `Optional` and both are empty when
|
||||
the underlying value is absent or the empty string.
|
||||
|
||||
---
|
||||
|
||||
## Cost and blocking
|
||||
|
||||
This is the whole story. Read it before you write a loop.
|
||||
|
||||
Iris's generator is a stack of procedural noise streams. Every read below evaluates that stack for
|
||||
one column and memoises the result in a shared per-chunk noise cache. A **cold** column runs the
|
||||
pack's noise; a **warm** column is an array index. Nothing on this page reads chunk storage, reads a
|
||||
block, loads a region file, takes a lock, waits on a future, or asks the server to generate
|
||||
anything.
|
||||
|
||||
| Call | Cost when cold | Cost when warm | Forces generation | Can block | When the data is not there |
|
||||
|---|---|---|---|---|---|
|
||||
| `isIrisWorld` | one `World#getGenerator()` and an `instanceof` | same | No | No | `false` |
|
||||
| `worldInfo` | field reads off the live engine and dimension | same | No | No | `Optional.empty()` |
|
||||
| `surfaceHeight` | one height sample, which pulls the region and base-biome streams for that column | array read | No | No | `OptionalInt.empty()` |
|
||||
| `surfaceKind` | one height sample, plus one surface-biome sample **only** for columns above fluid level | array read | No | No | `IrisSurfaceKind.UNKNOWN` |
|
||||
| `surfaceBiomeKey` / `surfaceBiomeName` | one surface-biome sample, which pulls height, base biome and region | array read | No | No | `Optional.empty()` |
|
||||
| `biomeKey` at or near the surface | as `surfaceBiomeKey`, plus one height sample to decide surface vs cave | array read | No | No | `Optional.empty()` |
|
||||
| `biomeKey` well below the surface | the above, plus the cave-biome stream and the dimension's carving resolution | array reads | No | No | `Optional.empty()` |
|
||||
| `regionKey` / `regionName` | one region sample — the cheapest of the biome family | array read | No | No | `Optional.empty()` |
|
||||
| `maxSampleColumns` / `maxSampleChunks` | reads two settings fields | same | No | No | a positive number, always |
|
||||
| `sampleColumns` | one of the above per column, in chunk-local order | array reads | No | No | `false`, sink untouched |
|
||||
|
||||
Two consequences that matter more than the per-call cost:
|
||||
|
||||
**Calling in a tight main-thread loop is survivable but wasteful.** Nothing will deadlock and
|
||||
nothing will stall on I/O. What you will do is evict the generator's own working set: the noise
|
||||
cache is shared with live chunk generation, and a scan across unrelated coordinates pushes out the
|
||||
columns the generator was about to reuse. The visible symptom is chunk generation slowing down, not
|
||||
your loop slowing down. Use `sampleColumns` for anything wider than a handful of columns — it walks
|
||||
chunk by chunk so each cached chunk is filled and finished with before the next one starts.
|
||||
|
||||
**These values are the generator's opinion, not the world's.** `surfaceHeight` is the height of the
|
||||
generated terrain column. It does not include objects, decorations, structures, trees, snow, or
|
||||
anything a player has placed or broken since. In an already-generated world the block at that Y may
|
||||
be different, and in a world that has never generated there you still get an answer, because the
|
||||
answer comes from noise and not from storage. If you need the real block, use Bukkit's
|
||||
`World#getHighestBlockYAt` and accept its chunk-loading cost. If you need to know where the pack
|
||||
*intends* the ground to be — which is the useful question for a pregeneration planner, a map
|
||||
renderer or a spawn picker — use this.
|
||||
|
||||
### Surface height, precisely
|
||||
|
||||
`surfaceHeight` returns the absolute Y of the **topmost generated terrain block**. A player stands
|
||||
at `surfaceHeight + 1`. Fluid is ignored: under an ocean you get the sea floor, not the water
|
||||
surface. Compare against `IrisWorldInfo.fluidHeight()` to tell the difference, or use
|
||||
`surfaceKind`, which does exactly that comparison for you.
|
||||
|
||||
---
|
||||
|
||||
## Threading
|
||||
|
||||
**Every read on this interface may be called from any thread, including an async task.** That is an
|
||||
unusual claim in a Folia-aware suite and it is made deliberately, so here is the justification:
|
||||
|
||||
- The only Bukkit call Iris makes on your behalf is `World#getGenerator()`, an accessor on the world
|
||||
object itself. No chunk is touched, no block state is read, no entity is looked at, no world list
|
||||
is walked.
|
||||
- Everything after that is engine-internal noise evaluation over concurrent caches. There is no
|
||||
region-owned state involved, so there is no region thread with a claim on it.
|
||||
- No method here takes a lock you can contend on, calls `join`, or schedules onto another thread.
|
||||
|
||||
There is nothing to gain from hopping to a region thread first, and on Folia there is no region
|
||||
thread that would be the *correct* one for a coordinate scan spanning many regions anyway. Run wide
|
||||
scans on your own async executor.
|
||||
|
||||
The one rule: **`IrisColumnSink.accept` runs on the thread that called `sampleColumns`, inline,
|
||||
once per column.** If you called from an async thread, your sink is on that async thread and must
|
||||
not touch Bukkit state. If you called from a region thread, your sink is holding that region thread
|
||||
for the entire walk. Collect into a local structure inside the sink and do the Bukkit work
|
||||
afterwards.
|
||||
|
||||
---
|
||||
|
||||
## Column sampling
|
||||
|
||||
`sampleColumns` is the bulk read. It walks a rectangle at a stride, chunk by chunk, and pushes each
|
||||
column into your sink.
|
||||
|
||||
```java
|
||||
public record IrisColumnQuery(
|
||||
int minBlockX,
|
||||
int minBlockZ,
|
||||
int maxBlockX,
|
||||
int maxBlockZ,
|
||||
int strideBlocks,
|
||||
EnumSet<IrisColumnField> fields) {
|
||||
|
||||
public static IrisColumnQuery rect(
|
||||
int minBlockX,
|
||||
int minBlockZ,
|
||||
int maxBlockX,
|
||||
int maxBlockZ,
|
||||
int strideBlocks,
|
||||
EnumSet<IrisColumnField> fields);
|
||||
|
||||
public long columnCount();
|
||||
|
||||
public long chunkCount();
|
||||
|
||||
public EnumSet<IrisColumnField> fields();
|
||||
}
|
||||
```
|
||||
|
||||
The bounds are **inclusive on both ends**. The sampled lattice is anchored at
|
||||
`(minBlockX, minBlockZ)` and steps by `strideBlocks`; a stride of `1` visits every column.
|
||||
|
||||
The constructor rejects, with `IllegalArgumentException`:
|
||||
|
||||
- an empty `fields` set,
|
||||
- `maxBlockX < minBlockX` or `maxBlockZ < minBlockZ`,
|
||||
- `strideBlocks < 1`.
|
||||
|
||||
`fields` is defensively copied on the way in and on every call to `fields()`, so a set you mutate
|
||||
after construction does not change the query, and a set you get back and mutate does not either.
|
||||
`fields()` allocates a fresh `EnumSet` each call — hoist it out of loops.
|
||||
|
||||
`columnCount()` and `chunkCount()` saturate at `Long.MAX_VALUE` instead of overflowing, so a query
|
||||
over the whole coordinate space reports an absurd number rather than a negative one.
|
||||
|
||||
### The hard limits
|
||||
|
||||
```java
|
||||
int maxSampleColumns();
|
||||
int maxSampleChunks();
|
||||
```
|
||||
|
||||
Both are derived from the generator's noise cache size, so a large-cache server permits larger
|
||||
queries and a small-cache server permits smaller ones. The rule is fixed:
|
||||
|
||||
```
|
||||
maxSampleChunks = max(64, noiseCacheSize / 4)
|
||||
maxSampleColumns = maxSampleChunks * 256
|
||||
```
|
||||
|
||||
With the default `noiseCacheSize` of 1024 that is **256 chunks and 65 536 columns**. The divisor of
|
||||
four is the point of the whole mechanism: one API query may never consume more than a quarter of the
|
||||
cache the live generator is using.
|
||||
|
||||
**A query that exceeds either limit returns `false` and never calls your sink — not once.** There is
|
||||
no partial answer, no truncation, no exception, and no log line. If you get `false` before any
|
||||
column arrives, check the counts.
|
||||
|
||||
The two limits are checked independently, and this is where callers get caught:
|
||||
|
||||
```java
|
||||
IrisColumnQuery wide = IrisColumnQuery.rect(
|
||||
0, 0, 6399, 6399, 64, EnumSet.of(IrisColumnField.SURFACE_KIND));
|
||||
```
|
||||
|
||||
That query reports `columnCount() == 10_000`, well under the 65 536 column limit, and
|
||||
`chunkCount() == 160_000`, far over the 256 chunk limit. It is refused.
|
||||
|
||||
**`chunkCount()` counts the chunk span of the rectangle, not the chunks you actually sample.**
|
||||
Striding does not reduce it. A coarse sweep across a large area is refused on chunks even though it
|
||||
touches very few columns. Split it into tiles, or accept a smaller rectangle:
|
||||
|
||||
```java
|
||||
long maxColumns = terrain.maxSampleColumns();
|
||||
long maxChunks = terrain.maxSampleChunks();
|
||||
|
||||
if (query.columnCount() > maxColumns || query.chunkCount() > maxChunks) {
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
Ask the service every time. Both values change when an operator edits the setting and reloads.
|
||||
|
||||
### The sink
|
||||
|
||||
```java
|
||||
@FunctionalInterface
|
||||
public interface IrisColumnSink {
|
||||
void accept(int blockX, int blockZ, int surfaceHeight, IrisSurfaceKind kind, String biomeKey);
|
||||
}
|
||||
```
|
||||
|
||||
Every column produces exactly one `accept`. What arrives depends on the `fields` you asked for, and
|
||||
the placeholders for fields you did **not** ask for are not distinguishable from real data:
|
||||
|
||||
| Field requested | Parameter | If you asked for it | If you did not |
|
||||
|---|---|---|---|
|
||||
| `SURFACE_HEIGHT` | `surfaceHeight` | absolute world Y of the topmost terrain block | `-1` |
|
||||
| `SURFACE_KIND` | `kind` | `LAND`, `SHORE`, `OCEAN` or `VOID` | `IrisSurfaceKind.UNKNOWN` |
|
||||
| `BIOME_KEY` | `biomeKey` | the biome load key | `null` |
|
||||
|
||||
`-1` is a legal absolute Y in any world with a negative minimum height, so **never treat `-1` as
|
||||
"absent"**. Branch on your own field set, which you already have. `biomeKey` may also be `null` when
|
||||
you *did* ask for it, if the column resolves to no biome; test for `null` regardless.
|
||||
|
||||
Requesting fewer fields genuinely costs less. `SURFACE_KIND` alone does not evaluate the biome
|
||||
stream for a column that is at or below fluid level, because the classification is already decided.
|
||||
Ask for `BIOME_KEY` and every column pays for the biome stream.
|
||||
|
||||
### Visit order
|
||||
|
||||
Columns arrive grouped by chunk. The walk iterates chunks with Z as the outer loop and X as the
|
||||
inner loop, and within each chunk iterates its lattice points the same way, Z outer and X inner.
|
||||
Order is deterministic for a given query, but it is **not** a row-major sweep of the rectangle: you
|
||||
receive all of one chunk's columns before any of the next chunk's. If your consumer needs raster
|
||||
order, sort afterwards or index into an array by `(blockX, blockZ)`.
|
||||
|
||||
### The return value
|
||||
|
||||
`sampleColumns` returns `true` if and only if every column in the query was delivered. It returns
|
||||
`false` when:
|
||||
|
||||
- `world`, `query` or `sink` is `null`, or the world has no live Iris engine — sink untouched;
|
||||
- a limit was exceeded — sink untouched;
|
||||
- **your sink threw** — the walk stops at that column;
|
||||
- **the engine closed underneath the walk** — the walk stops at that column.
|
||||
|
||||
In the last two cases the columns already delivered were delivered. `false` does not mean "nothing
|
||||
happened"; it means "do not trust this result set as complete". Treat a `false` as a signal to
|
||||
discard the partial data, not as a signal that there is none.
|
||||
|
||||
---
|
||||
|
||||
## Worked example: finding the flattest buildable spot
|
||||
|
||||
A plugin that places a settlement wants the flattest patch of land inside a radius, and wants none
|
||||
of that work on a region thread. It samples on an async task, then hands the answer to the player's
|
||||
entity scheduler, which is the correct thread to touch a player on Folia and on Paper alike.
|
||||
|
||||
```java
|
||||
package com.example.settlement;
|
||||
|
||||
import art.arcane.iris.api.terrain.IrisColumnField;
|
||||
import art.arcane.iris.api.terrain.IrisColumnQuery;
|
||||
import art.arcane.iris.api.terrain.IrisColumnSink;
|
||||
import art.arcane.iris.api.terrain.IrisSurfaceKind;
|
||||
import art.arcane.iris.api.terrain.IrisTerrainService;
|
||||
import art.arcane.iris.api.terrain.IrisWorldInfo;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.bukkit.plugin.RegisteredServiceProvider;
|
||||
|
||||
import java.util.EnumSet;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
public final class SettlementSiteFinder {
|
||||
private static final int RADIUS_BLOCKS = 512;
|
||||
private static final int STRIDE_BLOCKS = 8;
|
||||
|
||||
private final Plugin plugin;
|
||||
private final Executor background;
|
||||
|
||||
public SettlementSiteFinder(Plugin plugin, Executor background) {
|
||||
this.plugin = plugin;
|
||||
this.background = background;
|
||||
}
|
||||
|
||||
public void findFor(Player player) {
|
||||
World world = player.getWorld();
|
||||
Location origin = player.getLocation();
|
||||
int centreX = origin.getBlockX();
|
||||
int centreZ = origin.getBlockZ();
|
||||
|
||||
background.execute(() -> {
|
||||
String result = search(world, centreX, centreZ);
|
||||
player.getScheduler().run(plugin, task -> player.sendMessage(result), null);
|
||||
});
|
||||
}
|
||||
|
||||
private String search(World world, int centreX, int centreZ) {
|
||||
IrisTerrainService terrain = service();
|
||||
|
||||
if (terrain == null || !terrain.isIrisWorld(world)) {
|
||||
return "That world is not generated by Iris.";
|
||||
}
|
||||
|
||||
Optional<IrisWorldInfo> info = terrain.worldInfo(world);
|
||||
|
||||
if (info.isEmpty()) {
|
||||
return "The Iris engine for that world is not available right now.";
|
||||
}
|
||||
|
||||
IrisColumnQuery query = IrisColumnQuery.rect(
|
||||
centreX - RADIUS_BLOCKS,
|
||||
centreZ - RADIUS_BLOCKS,
|
||||
centreX + RADIUS_BLOCKS,
|
||||
centreZ + RADIUS_BLOCKS,
|
||||
STRIDE_BLOCKS,
|
||||
EnumSet.of(IrisColumnField.SURFACE_HEIGHT, IrisColumnField.SURFACE_KIND));
|
||||
|
||||
if (query.columnCount() > terrain.maxSampleColumns()
|
||||
|| query.chunkCount() > terrain.maxSampleChunks()) {
|
||||
return "That search area is larger than this server allows.";
|
||||
}
|
||||
|
||||
int fluidHeight = info.get().fluidHeight();
|
||||
Best best = new Best();
|
||||
|
||||
IrisColumnSink sink = (int blockX, int blockZ, int surfaceHeight, IrisSurfaceKind kind, String biomeKey) -> {
|
||||
if (kind != IrisSurfaceKind.LAND || surfaceHeight <= fluidHeight) {
|
||||
return;
|
||||
}
|
||||
|
||||
long score = (long) Math.abs(surfaceHeight - fluidHeight) * 1024L
|
||||
+ Math.abs(blockX - centreX) + Math.abs(blockZ - centreZ);
|
||||
|
||||
if (score < best.score) {
|
||||
best.score = score;
|
||||
best.x = blockX;
|
||||
best.y = surfaceHeight;
|
||||
best.z = blockZ;
|
||||
}
|
||||
};
|
||||
|
||||
if (!terrain.sampleColumns(world, query, sink)) {
|
||||
return "The terrain scan did not complete. Try again.";
|
||||
}
|
||||
|
||||
if (best.score == Long.MAX_VALUE) {
|
||||
return "No dry land within " + RADIUS_BLOCKS + " blocks.";
|
||||
}
|
||||
|
||||
return "Best site: " + best.x + ", " + (best.y + 1) + ", " + best.z;
|
||||
}
|
||||
|
||||
private IrisTerrainService service() {
|
||||
RegisteredServiceProvider<IrisTerrainService> provider =
|
||||
plugin.getServer().getServicesManager().getRegistration(IrisTerrainService.class);
|
||||
return provider == null ? null : provider.getProvider();
|
||||
}
|
||||
|
||||
private static final class Best {
|
||||
private long score = Long.MAX_VALUE;
|
||||
private int x;
|
||||
private int y;
|
||||
private int z;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`Best` needs no synchronisation: the sink runs inline on the thread that called `sampleColumns`, so
|
||||
every `accept` for this walk is on the background thread that started it, and no other thread reads
|
||||
the holder until the walk has returned. The `+ 1` on the reported Y is the standing height, since
|
||||
`surfaceHeight` is the topmost solid block. `player.getScheduler()` is Paper's entity scheduler and
|
||||
is the correct hop on both Paper and Folia; on Folia it resumes on whichever region owns the player
|
||||
at that moment, which may not be the region they were in when the scan started.
|
||||
|
||||
---
|
||||
|
||||
## The minimum: one coordinate
|
||||
|
||||
Most integrations want one biome name at one place. That is three lines and needs none of the above.
|
||||
|
||||
```java
|
||||
IrisTerrainService terrain = service();
|
||||
|
||||
String biome = terrain == null
|
||||
? "unknown"
|
||||
: terrain.surfaceBiomeName(player.getWorld(), player.getLocation().getBlockX(),
|
||||
player.getLocation().getBlockZ()).orElse("unknown");
|
||||
```
|
||||
|
||||
`surfaceBiomeName` on a non-Iris world, a null world, a closing engine or a disabled Iris returns
|
||||
`Optional.empty()`. You do not need to call `isIrisWorld` first unless you want to distinguish
|
||||
"not an Iris world" from "Iris has nothing to say".
|
||||
|
||||
---
|
||||
|
||||
## What `IrisWorldInfo` tells you
|
||||
|
||||
```java
|
||||
public record IrisWorldInfo(
|
||||
String dimensionKey,
|
||||
String worldIdentity,
|
||||
long seed,
|
||||
int minHeight,
|
||||
int maxHeight,
|
||||
int fluidHeight,
|
||||
boolean studio) {
|
||||
|
||||
public int height();
|
||||
}
|
||||
```
|
||||
|
||||
| Component | What it is |
|
||||
|---|---|
|
||||
| `dimensionKey` | Pack load key of the dimension, for example `overworld` |
|
||||
| `worldIdentity` | The world's namespaced key rendered as a string, for example `minecraft:overworld` |
|
||||
| `seed` | The raw seed the engine was built with |
|
||||
| `minHeight` | Absolute Y of the world floor, for example `-64` |
|
||||
| `maxHeight` | Absolute Y of the world ceiling, exclusive, for example `320` |
|
||||
| `fluidHeight` | Absolute Y of the pack's sea level |
|
||||
| `studio` | `true` only for a transient studio world |
|
||||
| `height()` | `maxHeight - minHeight` |
|
||||
|
||||
`minHeight`, `maxHeight` and `fluidHeight` are all absolute world Y, directly comparable with
|
||||
`surfaceHeight` and with `blockY`. The record's own constructor rejects a null `dimensionKey` or
|
||||
`worldIdentity` with `NullPointerException` and a non-positive height range with
|
||||
`IllegalArgumentException`, so an instance you receive is always internally consistent.
|
||||
|
||||
`worldIdentity` is the string form of the world's `NamespacedKey`, and it is the key Iris itself
|
||||
persists per-world state under. It is the right key for you to persist too, because it is namespaced
|
||||
and unambiguous where a bare name is not. It is **not** independent of the world's name: outside the
|
||||
three vanilla dimensions the server derives the key from the world folder, so renaming that folder
|
||||
changes `worldIdentity` exactly as it changes `World#getName()`.
|
||||
|
||||
`studio` is `true` for a world Iris created for pack authoring — those exist for seconds and are
|
||||
deleted, so a persistence layer should skip them.
|
||||
|
||||
`seed` is the generator seed. Treat it as privileged: it is enough to reproduce the entire world
|
||||
offline, including every ore vein and structure. Iris deliberately does not expose it through
|
||||
PlaceholderAPI for that reason. Do not put it anywhere a player can read.
|
||||
|
||||
---
|
||||
|
||||
## Failure policy
|
||||
|
||||
Iris assumes the caller will pass nulls, hand it a world it does not own, keep a stale service
|
||||
reference, and throw from a sink.
|
||||
|
||||
| Situation | What Iris does |
|
||||
|---|---|
|
||||
| `world` is `null` | Every query answers absent; `sampleColumns` returns `false` |
|
||||
| The world has no Iris generator | Same |
|
||||
| Iris is disabled, or disabled between your two calls | Same. Nothing throws |
|
||||
| The generator is closing, or the engine is closed | `isIrisWorld` still returns **`true`**; every other query answers absent |
|
||||
| A query throws inside the engine | Counted, logged with the stack trace, answered as absent |
|
||||
| `query` or `sink` is `null` | `sampleColumns` returns `false`, sink never called |
|
||||
| The query exceeds `maxSampleColumns` or `maxSampleChunks` | `sampleColumns` returns `false`, sink never called, nothing logged |
|
||||
| Your sink throws | Walk aborts at that column, fault counted and logged, `sampleColumns` returns `false`. Columns already delivered stay delivered |
|
||||
| The engine closes mid-walk | Walk stops at that column, `sampleColumns` returns `false` |
|
||||
|
||||
Two deliberate asymmetries worth internalising:
|
||||
|
||||
**`isIrisWorld` does not check liveness.** It answers "was this world created by Iris", not "can
|
||||
Iris answer questions about it right now". During world unload and during plugin shutdown you will
|
||||
see `isIrisWorld(world) == true` alongside `worldInfo(world).isEmpty()`. That is correct behaviour,
|
||||
not a race you can win. Code that branches on `isIrisWorld` and then dereferences an
|
||||
`Optional#get()` will throw eventually; use `orElse` or check the `Optional`.
|
||||
|
||||
**Iris never quarantines a caller.** There is no fault limit and no disable-after-N. A sink that
|
||||
throws on every column will be logged and refused on every call, forever, and will never be muted or
|
||||
blacklisted. The internal fault counters exist only to throttle the log line to at most one report
|
||||
per minute per category — the count in that line tells you how many faults have occurred in total,
|
||||
so a "3 faults" line followed by a "9000 faults" line means you have a loop, not two incidents.
|
||||
|
||||
Nothing in this API ever throws a checked exception, and nothing throws an unchecked one except the
|
||||
argument validation on `IrisColumnQuery` and `IrisWorldInfo` constructors described above.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
`plugins/Iris/settings.json`:
|
||||
|
||||
| Key | Default | Effect on this API |
|
||||
|---|---|---|
|
||||
| `performance.noiseCacheSize` | `1024` | The chunk capacity of the shared noise cache. `maxSampleChunks` is `max(64, this / 4)` and `maxSampleColumns` is `maxSampleChunks * 256`. Raising it raises both limits and the memory the generator holds |
|
||||
|
||||
There is no on/off switch for the terrain API and no per-world gate. It answers for every world with
|
||||
a live Iris engine, and answers absent for everything else.
|
||||
|
||||
---
|
||||
|
||||
## Enum reference
|
||||
|
||||
### `IrisSurfaceKind`
|
||||
|
||||
Returned by `surfaceKind` and delivered to `IrisColumnSink`.
|
||||
|
||||
| Constant | Meaning | Test Iris applies |
|
||||
|---|---|---|
|
||||
| `LAND` | Dry ground | Surface above sea level, and the biome is not a shore biome |
|
||||
| `SHORE` | Beach or bank | Surface above sea level, and the pack classifies the biome as shore |
|
||||
| `OCEAN` | Under water | Surface at or below `IrisWorldInfo.fluidHeight()` |
|
||||
| `VOID` | Nothing generated | Surface at or below `IrisWorldInfo.minHeight()` |
|
||||
| `UNKNOWN` | No answer | Not an Iris world, the engine is unavailable, a query faulted, or `SURFACE_KIND` was not requested |
|
||||
|
||||
**`VOID` is tested first and wins.** A column at or below `minHeight()` reports `VOID` whatever the
|
||||
sea level is; only a column above the floor is then tested against the fluid level, and only a column
|
||||
above the fluid level is then tested for a shore biome. The four are mutually exclusive.
|
||||
|
||||
`OCEAN` is inclusive at the boundary: a column whose topmost terrain block sits exactly at sea level
|
||||
reports `OCEAN` even though no water block is generated above it. If that one-block distinction
|
||||
matters, compare `surfaceHeight` against `fluidHeight` yourself.
|
||||
|
||||
`UNKNOWN` is overloaded on purpose — it is the single "no data" value, so a caller never has to
|
||||
handle both a sentinel and an exception. Distinguish the causes with `isIrisWorld` and `worldInfo`
|
||||
if you need to.
|
||||
|
||||
### `IrisColumnField`
|
||||
|
||||
Selects what `sampleColumns` computes and passes to the sink. At least one is required.
|
||||
|
||||
| Constant | Fills | Extra work |
|
||||
|---|---|---|
|
||||
| `SURFACE_HEIGHT` | the `surfaceHeight` parameter | one height sample per column |
|
||||
| `SURFACE_KIND` | the `kind` parameter | one height sample, plus a biome sample only for columns above sea level |
|
||||
| `BIOME_KEY` | the `biomeKey` parameter | one biome sample per column, unconditionally |
|
||||
|
||||
`SURFACE_HEIGHT` and `SURFACE_KIND` share their height sample — asking for both costs barely more
|
||||
than asking for either.
|
||||
@@ -0,0 +1,517 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,459 @@
|
||||
# Iris world engine and pregeneration events
|
||||
|
||||
Two Bukkit events tell you what Iris is doing over time. `IrisWorldEngineEvent` marks the points at
|
||||
which an Iris world's engine becomes usable, is rebuilt under you, or is about to stop being usable.
|
||||
`IrisPregenerationEvent` reports the progress of a pregeneration job. Both are pure observation:
|
||||
neither is cancellable, and nothing you do in a handler changes what Iris does next.
|
||||
|
||||
Use `IrisWorldEngineEvent` instead of `WorldLoadEvent` if you care about the *generator* rather than
|
||||
the world. A world exists before its Iris engine is ready to answer questions, and it still exists
|
||||
after the engine has been told to close.
|
||||
|
||||
---
|
||||
|
||||
## Depending on Iris
|
||||
|
||||
See [README.md](README.md#depending-on-iris) for the build and plugin-descriptor setup. Events need
|
||||
no service lookup — register a `Listener` in your `onEnable` as usual and Bukkit unregisters you
|
||||
when your plugin disables.
|
||||
|
||||
Both events have their own `HandlerList`. There is no shared base class and no common interface;
|
||||
`IrisWorldEngineEvent` and `IrisPregenerationEvent` extend `org.bukkit.event.Event` directly.
|
||||
|
||||
Neither implements `Cancellable`. `ignoreCancelled = true` on a handler for either is meaningless
|
||||
and will not do what you expect.
|
||||
|
||||
---
|
||||
|
||||
## The world engine lifecycle
|
||||
|
||||
```java
|
||||
public enum IrisWorldPhase {
|
||||
ENGINE_READY,
|
||||
ENGINE_HOTLOADED,
|
||||
ENGINE_CLOSING
|
||||
}
|
||||
```
|
||||
|
||||
```
|
||||
ENGINE_READY the engine for this world is registered and answering.
|
||||
| Terrain queries work from here on.
|
||||
|
|
||||
+--> ENGINE_HOTLOADED the pack was edited and the engine rebuilt in place.
|
||||
| Same world, same engine object, different pack contents.
|
||||
| Can fire any number of times, or never.
|
||||
|
|
||||
v
|
||||
ENGINE_CLOSING the engine is about to be torn down. Last call.
|
||||
```
|
||||
|
||||
Guarantees Iris makes:
|
||||
|
||||
- `ENGINE_READY` fires **at most once per world** for a given registration. It is keyed on the
|
||||
world's UUID, so a world that unloads and loads again gets a fresh `ENGINE_READY`.
|
||||
- `ENGINE_CLOSING` is **never delivered without a preceding `ENGINE_READY`** for that world. If Iris
|
||||
never announced a world ready, it never announces it closing.
|
||||
- `ENGINE_CLOSING` is dispatched **before** Iris starts closing the generator, not after. When your
|
||||
handler runs, the engine has not been shut down yet.
|
||||
- If Iris replaces a world's engine — the generator was swapped out and a new one registered — you
|
||||
get `ENGINE_CLOSING` for the old one, and a later `ENGINE_READY` when the replacement finishes
|
||||
registering. You never get two consecutive `ENGINE_READY` without a `CLOSING` between them.
|
||||
- On Iris shutdown, **every** world that was announced ready is announced closing, before Iris drains
|
||||
its worker pool and before any generator is closed.
|
||||
- `ENGINE_HOTLOADED` is not deduplicated and does not participate in the ready/closing pairing. It
|
||||
is a notification that the pack data behind a live engine was reloaded and the engine rebuilt
|
||||
around it. The world, the world object and the seed are unchanged; the pack contents may not be.
|
||||
Treat any pack-derived value you cached at `ENGINE_READY` as stale when it arrives.
|
||||
|
||||
### The one thing `ENGINE_CLOSING` does not promise
|
||||
|
||||
`ENGINE_CLOSING` is fired before the *generator* closes, but during a full plugin shutdown the
|
||||
terrain service may already have been withdrawn by the time your handler runs — Iris tears down its
|
||||
services in an unspecified order. So:
|
||||
|
||||
> Do not treat `ENGINE_CLOSING` as a window in which to run terrain queries. Capture whatever you
|
||||
> need at `ENGINE_READY` and use `ENGINE_CLOSING` only to drop it.
|
||||
|
||||
A terrain query in a closing handler does not throw. It returns absent, which is worse, because it
|
||||
looks like data.
|
||||
|
||||
---
|
||||
|
||||
## The event
|
||||
|
||||
```java
|
||||
public class IrisWorldEngineEvent extends Event {
|
||||
public IrisWorldEngineEvent(World world, IrisWorldPhase phase, IrisWorldInfo info);
|
||||
|
||||
public static HandlerList getHandlerList();
|
||||
|
||||
public World getWorld();
|
||||
|
||||
public IrisWorldPhase getPhase();
|
||||
|
||||
public Optional<IrisWorldInfo> getInfo();
|
||||
|
||||
@Override
|
||||
public HandlerList getHandlers();
|
||||
}
|
||||
```
|
||||
|
||||
`getWorld()` and `getPhase()` are never `null` — the constructor rejects both.
|
||||
|
||||
`getInfo()` is `Optional` and can be empty. It is empty when Iris could not describe the engine at
|
||||
dispatch time: the generator was already closing, the engine was already closed, or building the
|
||||
description threw (which is logged with a stack trace, and does not suppress the event). Handle the
|
||||
empty case; do not call `get()` unconditionally.
|
||||
|
||||
`IrisWorldInfo` is documented in [terrain.md](terrain.md#what-irisworldinfo-tells-you). The short
|
||||
version is that it carries the dimension load key, the world's namespaced identity, the seed, the
|
||||
world height bounds, the pack's sea level, and whether this is a transient studio world.
|
||||
|
||||
### Threading
|
||||
|
||||
**Handlers always run on the main thread. On Folia, that is the global region thread.**
|
||||
|
||||
Iris raises these phases from several places — the world load and unload handlers, its own enable
|
||||
and disable, and a pack hotload that can originate from a file watcher thread. The dispatch
|
||||
normalises all of them:
|
||||
|
||||
- Raised from the primary thread: the event is called **inline**, before the raising code continues.
|
||||
A `WorldLoadEvent` handler of yours that registers state, and an `ENGINE_READY` handler that reads
|
||||
it, will see a consistent picture.
|
||||
- Raised from any other thread: the event is handed to the server scheduler and delivered on the
|
||||
main or global region thread on a later tick.
|
||||
|
||||
So your handler is always on a thread where touching Bukkit is legal, and never on the file-watcher
|
||||
or worker thread that caused the phase.
|
||||
|
||||
What is forbidden: blocking. These phases run on the thread the server ticks on. No I/O, no
|
||||
`CompletableFuture#join`, no waiting on another scheduler. If you need to persist something, hand it
|
||||
to your own executor.
|
||||
|
||||
---
|
||||
|
||||
## Worked example: caching pack metadata per world
|
||||
|
||||
A plugin that shows the dimension a player is in wants that string without asking Iris for it on
|
||||
every render. It captures it once when the engine is ready and drops it when the engine closes.
|
||||
|
||||
```java
|
||||
package com.example.hud;
|
||||
|
||||
import art.arcane.iris.api.terrain.IrisWorldInfo;
|
||||
import art.arcane.iris.api.world.IrisWorldEngineEvent;
|
||||
import art.arcane.iris.api.world.IrisWorldPhase;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public final class IrisWorldRegistry implements Listener {
|
||||
private final Map<UUID, String> dimensionKeys = new ConcurrentHashMap<>();
|
||||
|
||||
public String dimensionKeyOf(World world) {
|
||||
return dimensionKeys.get(world.getUID());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onEngine(IrisWorldEngineEvent event) {
|
||||
UUID worldId = event.getWorld().getUID();
|
||||
|
||||
switch (event.getPhase()) {
|
||||
case ENGINE_READY, ENGINE_HOTLOADED -> {
|
||||
Optional<IrisWorldInfo> info = event.getInfo();
|
||||
|
||||
if (info.isEmpty()) {
|
||||
dimensionKeys.remove(worldId);
|
||||
return;
|
||||
}
|
||||
|
||||
dimensionKeys.put(worldId, info.get().dimensionKey());
|
||||
}
|
||||
case ENGINE_CLOSING -> dimensionKeys.remove(worldId);
|
||||
default -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`ENGINE_HOTLOADED` is handled alongside `ENGINE_READY` because a hotload can change the pack's
|
||||
dimension key. The `default` arm is there because the enum can grow; see
|
||||
[README.md](README.md#switching-over-the-enums).
|
||||
|
||||
The map is a `ConcurrentHashMap` even though the handler is single-threaded, because
|
||||
`dimensionKeyOf` is read from wherever your HUD renders.
|
||||
|
||||
---
|
||||
|
||||
## Pregeneration
|
||||
|
||||
```java
|
||||
public enum IrisPregenPhase {
|
||||
STARTED,
|
||||
TICK,
|
||||
PAUSED,
|
||||
RESUMED,
|
||||
SAVING,
|
||||
COMPLETED,
|
||||
CANCELLED
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
public class IrisPregenerationEvent extends Event {
|
||||
public IrisPregenerationEvent(IrisPregenPhase phase, IrisPregenProgress progress);
|
||||
|
||||
public static HandlerList getHandlerList();
|
||||
|
||||
public IrisPregenPhase getPhase();
|
||||
|
||||
public IrisPregenProgress getProgress();
|
||||
|
||||
@Override
|
||||
public HandlerList getHandlers();
|
||||
}
|
||||
```
|
||||
|
||||
Both accessors are never `null`; the constructor rejects both.
|
||||
|
||||
### The order phases arrive in
|
||||
|
||||
```
|
||||
STARTED -> TICK -> TICK -> ... -> COMPLETED
|
||||
|
|
||||
+-- PAUSED -> TICK -> ... -> RESUMED -> TICK -> ...
|
||||
|
|
||||
+-- SAVING (once, near the end)
|
||||
|
|
||||
+-- CANCELLED (instead of COMPLETED, if the job was stopped early)
|
||||
```
|
||||
|
||||
- **One job at a time, server-wide.** Iris runs a single pregeneration job per server. There is no
|
||||
job identifier on the event because there is nothing to disambiguate; `IrisPregenProgress` names
|
||||
the world the running job is working on.
|
||||
- `STARTED` is dispatched exactly once per job, immediately before that job's first `TICK`, in that
|
||||
order.
|
||||
- `TICK` fires **once per second** while the job runs. It fires while paused too.
|
||||
- `PAUSED` and `RESUMED` fire on the transition only, each immediately followed by a `TICK`. A job
|
||||
that is never paused never emits either.
|
||||
- `SAVING` fires at most once per job.
|
||||
- Exactly one of `COMPLETED` or `CANCELLED` is dispatched, and it is terminal. `COMPLETED` means the
|
||||
job reached its chunk total; `CANCELLED` means it stopped before that, whether by operator action
|
||||
or by shutdown. **No phase is ever dispatched for a job after its terminal phase.**
|
||||
|
||||
### Threading
|
||||
|
||||
**Handlers always run on the main thread. On Folia, that is the global region thread.**
|
||||
|
||||
The pregenerator ticks on its own worker thread, so every pregeneration phase is scheduled rather
|
||||
than called inline. It arrives on a later tick than the moment the numbers were sampled. For a
|
||||
progress bar this is invisible; for anything that correlates pregeneration against another timeline,
|
||||
assume up to one tick of skew.
|
||||
|
||||
Do not block. The job does not wait for your handler — the dispatch is fire-and-forget and a
|
||||
throwing handler is logged and skipped — but you are on the server's tick thread and everything else
|
||||
does wait for you.
|
||||
|
||||
### What `IrisPregenProgress` tells you
|
||||
|
||||
```java
|
||||
public record IrisPregenProgress(
|
||||
String worldName,
|
||||
String worldIdentity,
|
||||
double percent,
|
||||
long generatedChunks,
|
||||
long totalChunks,
|
||||
long remainingChunks,
|
||||
long failedChunks,
|
||||
double chunksPerSecond,
|
||||
long etaMillis,
|
||||
long elapsedMillis,
|
||||
String method,
|
||||
boolean paused) {
|
||||
}
|
||||
```
|
||||
|
||||
| Component | What it is |
|
||||
|---|---|
|
||||
| `worldName` | Never null; falls back to `worldIdentity` |
|
||||
| `worldIdentity` | The world's namespaced key rendered as a string |
|
||||
| `percent` | `0.0` to `100.0` |
|
||||
| `generatedChunks` | Chunks the job has finished |
|
||||
| `totalChunks` | Chunks in the job |
|
||||
| `remainingChunks` | Chunks still to do |
|
||||
| `failedChunks` | Chunks the job could not generate |
|
||||
| `chunksPerSecond` | Current rate |
|
||||
| `etaMillis` | Estimated milliseconds remaining |
|
||||
| `elapsedMillis` | Milliseconds since the job started |
|
||||
| `method` | Never null; `""` when unknown |
|
||||
| `paused` | `true` while the job is paused |
|
||||
|
||||
The record's constructor sanitises everything before you see it, so you never have to defend against
|
||||
the generator's arithmetic:
|
||||
|
||||
- `percent` is clamped to `0.0 .. 100.0`. `NaN` and infinity become `0.0`.
|
||||
- `chunksPerSecond` is clamped to at least `0.0`. `NaN` and infinity become `0.0`.
|
||||
- `generatedChunks`, `totalChunks`, `remainingChunks`, `failedChunks`, `etaMillis` and
|
||||
`elapsedMillis` are clamped to at least `0`.
|
||||
- `worldName` falls back to `worldIdentity` when the world has no name.
|
||||
- `method` becomes `""` rather than `null`.
|
||||
|
||||
The only rejection is a `null` `worldIdentity`, which throws `NullPointerException` at construction —
|
||||
so an instance delivered to you always identifies a world.
|
||||
|
||||
`etaMillis` is an estimate derived from the running rate and is `0` before enough chunks have
|
||||
completed to compute one. `failedChunks` counts chunks the job could not generate; a non-zero value
|
||||
on `COMPLETED` means the job finished with holes.
|
||||
|
||||
---
|
||||
|
||||
## Worked example: mirroring pregeneration into a boss bar
|
||||
|
||||
```java
|
||||
package com.example.pregenbar;
|
||||
|
||||
import art.arcane.iris.api.pregen.IrisPregenProgress;
|
||||
import art.arcane.iris.api.pregen.IrisPregenerationEvent;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.boss.BarColor;
|
||||
import org.bukkit.boss.BarStyle;
|
||||
import org.bukkit.boss.BossBar;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
|
||||
public final class PregenBar implements Listener {
|
||||
private BossBar bar;
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onPregen(IrisPregenerationEvent event) {
|
||||
IrisPregenProgress progress = event.getProgress();
|
||||
|
||||
switch (event.getPhase()) {
|
||||
case STARTED -> open(progress);
|
||||
case TICK, PAUSED, RESUMED, SAVING -> update(progress);
|
||||
case COMPLETED, CANCELLED -> close();
|
||||
default -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void open(IrisPregenProgress progress) {
|
||||
close();
|
||||
bar = Bukkit.createBossBar(
|
||||
"Pregenerating " + progress.worldName(), BarColor.BLUE, BarStyle.SEGMENTED_10);
|
||||
|
||||
for (Player player : Bukkit.getOnlinePlayers()) {
|
||||
bar.addPlayer(player);
|
||||
}
|
||||
|
||||
update(progress);
|
||||
}
|
||||
|
||||
private void update(IrisPregenProgress progress) {
|
||||
if (bar == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
bar.setProgress(progress.percent() / 100.0D);
|
||||
bar.setColor(progress.paused() ? BarColor.YELLOW : BarColor.BLUE);
|
||||
bar.setTitle(progress.worldName()
|
||||
+ " " + progress.generatedChunks() + "/" + progress.totalChunks()
|
||||
+ " at " + Math.round(progress.chunksPerSecond()) + "/s");
|
||||
}
|
||||
|
||||
private void close() {
|
||||
if (bar == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
bar.removeAll();
|
||||
bar = null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`bar` needs no synchronisation: every phase is delivered on the same thread.
|
||||
|
||||
`percent()` is already clamped, so dividing by 100 always yields a legal boss-bar progress value.
|
||||
|
||||
---
|
||||
|
||||
## The minimum: knowing a world is usable
|
||||
|
||||
If all you want is "run this once, when Iris can answer for this world":
|
||||
|
||||
```java
|
||||
@EventHandler
|
||||
public void onEngine(IrisWorldEngineEvent event) {
|
||||
if (event.getPhase() == IrisWorldPhase.ENGINE_READY) {
|
||||
prepare(event.getWorld());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
No `switch`, no `Optional`, no service lookup. Do not add `ignoreCancelled = true`; the event is not
|
||||
cancellable.
|
||||
|
||||
---
|
||||
|
||||
## Failure policy
|
||||
|
||||
| Situation | What Iris does |
|
||||
|---|---|
|
||||
| Your handler throws | Logged with the stack trace. The remaining handlers still run, and Iris's own lifecycle continues unaffected |
|
||||
| Iris cannot describe a world for a phase | The failure is logged and the event is **still delivered**, with `getInfo()` empty |
|
||||
| The event dispatch itself throws | Logged, naming the phase and world. The engine registration or teardown that raised it proceeds |
|
||||
| The pregeneration sink is not registered | No `IrisPregenerationEvent` is fired at all. This is the state before Iris finishes enabling and after it starts disabling |
|
||||
| A pregeneration handler throws | Logged, naming the phase. The job is not slowed, paused or stopped |
|
||||
| Iris shuts down mid-pregeneration | The job's terminal phase is `CANCELLED` |
|
||||
| Iris shuts down with worlds registered | Every announced world receives `ENGINE_CLOSING` before the worker pool drains |
|
||||
|
||||
Iris does not quarantine a listener. A handler that throws on every event will be logged on every
|
||||
event, forever. There is no fault limit and no automatic unregistration.
|
||||
|
||||
Iris never suppresses a lifecycle phase because a third party misbehaved. A logged failure is always
|
||||
accompanied by delivery, or by the lifecycle step proceeding without delivery — never by a silent
|
||||
stall.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
There are no configuration keys for either event. They are always on when Iris is enabled, cannot be
|
||||
disabled, and have no per-world gate.
|
||||
|
||||
---
|
||||
|
||||
## Enum reference
|
||||
|
||||
### `IrisWorldPhase`
|
||||
|
||||
| Constant | Meaning | Fires |
|
||||
|---|---|---|
|
||||
| `ENGINE_READY` | The engine is registered and answering queries | Once per world registration |
|
||||
| `ENGINE_HOTLOADED` | A live engine's pack data was reloaded in place | Any number of times, or never. It is dispatched straight from the hotload, not through the ready/closing bookkeeping, so it is not paired with either |
|
||||
| `ENGINE_CLOSING` | The engine is about to be torn down | Once per world registration, always after a `READY` |
|
||||
|
||||
### `IrisPregenPhase`
|
||||
|
||||
| Constant | Meaning | Fires |
|
||||
|---|---|---|
|
||||
| `STARTED` | A job began | Once per job, immediately before its first `TICK` |
|
||||
| `TICK` | Periodic progress sample | Once per second while the job exists, including while paused |
|
||||
| `PAUSED` | The job was paused | On the transition only, followed by a `TICK` |
|
||||
| `RESUMED` | The job was resumed | On the transition only, followed by a `TICK` |
|
||||
| `SAVING` | The job is flushing to disk | At most once per job |
|
||||
| `COMPLETED` | The job reached its chunk total | Terminal; mutually exclusive with `CANCELLED` |
|
||||
| `CANCELLED` | The job stopped before its total | Terminal; mutually exclusive with `COMPLETED` |
|
||||
|
||||
Write a `default` arm when switching over either; see
|
||||
[README.md](README.md#switching-over-the-enums).
|
||||
Reference in New Issue
Block a user