Files
Iris/docs/92 - API - World Events.md
T
2026-08-08 00:29:48 -06:00

12 KiB

92 - API - World Events

IrisWorldEngineEvent reports when an Iris world's engine becomes usable, is rebuilt under you, or is about to stop being usable. IrisPregenerationEvent reports pregeneration job progress. Both are pure observation: not cancellable, and handlers cannot change Iris's next step. Prefer IrisWorldEngineEvent over WorldLoadEvent when you care about the generator: a world exists before its Iris engine can answer, and still exists after the engine is told to close.

Build setup: 90 - API - Getting Started.md. No service lookup — register a Listener in onEnable; Bukkit unregisters on your disable.

Each event has its own HandlerList. No shared base class. Neither implements Cancellable; ignoreCancelled = true does nothing useful.


World engine lifecycle

public enum IrisWorldPhase {
    ENGINE_READY,
    ENGINE_HOTLOADED,
    ENGINE_CLOSING
}
ENGINE_READY        engine registered and answering; terrain queries work from here
   |
   +--> ENGINE_HOTLOADED   pack reloaded; same world/engine object, pack contents may change
   |                       any number of times, or never
   v
ENGINE_CLOSING      engine about to tear down; last call

Guarantees:

  • ENGINE_READY fires at most once per world registration, keyed on world UUID. Unload + load again gets a new ready.
  • ENGINE_CLOSING is never delivered without a prior ENGINE_READY for that world (ledger-gated).
  • ENGINE_CLOSING is dispatched before Iris starts closing the generator.
  • Engine replacement: ENGINE_CLOSING for the old, later ENGINE_READY for the new — never two consecutive ready without closing between them.
  • On Iris shutdown, every announced-ready world gets closing before worker pool drain and generator close.
  • ENGINE_HOTLOADED is not deduplicated and is not part of ready/closing pairing. Treat pack-derived caches from ENGINE_READY as stale when it arrives.

What ENGINE_CLOSING does not promise

Closing fires before the generator closes, but during full plugin shutdown the terrain service may already be withdrawn. Do not run terrain queries in a closing handler. Capture state at ENGINE_READY; use closing only to drop it. Queries during closing return absent without throwing.


The world engine event

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 (constructor rejects nulls).

getInfo() may be empty if Iris could not describe the engine at dispatch (generator already closing, engine closed, or describe threw — logged; event still delivered). Do not call Optional#get() unconditionally.

IrisWorldInfo fields: 91 - API - Terrain.md.

Threading

Handlers always run on the main thread. On Folia, that is the global region thread.

Dispatch:

  • Raised on the primary thread: event called inline before the raiser continues.
  • Raised off-thread (e.g. file-watcher hotload): scheduled to main/global region on a later tick via Iris's event path.

Blocking is forbidden on this thread: no I/O, no CompletableFuture#join, no waiting on another scheduler.


Worked example: cache pack metadata per world

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 -> {
            }
        }
    }
}

default is required because enums can grow: 90 - API - Getting Started.md. Map is concurrent because readers may be off the event thread.


Pregeneration

public enum IrisPregenPhase {
    STARTED,
    TICK,
    PAUSED,
    RESUMED,
    SAVING,
    COMPLETED,
    CANCELLED
}
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 never null; constructor rejects nulls.

Phase order

STARTED  ->  TICK  ->  TICK  ->  ...  ->  COMPLETED
                        |
                        +-- PAUSED  ->  TICK  ->  ...  ->  RESUMED  ->  TICK  ->  ...
                        |
                        +-- SAVING (at most once, near end)
                        |
                        +-- CANCELLED (instead of COMPLETED if stopped early)
  • One job at a time, server-wide. No job id on the event; IrisPregenProgress names the world.
  • STARTED once per job, immediately before first TICK.
  • TICK once per second while the job exists, including while paused.
  • PAUSED / RESUMED on transition only, each followed by a TICK.
  • SAVING at most once per job.
  • Exactly one of COMPLETED or CANCELLED is terminal. No phase after the terminal.

Threading

Handlers always run on the main / Folia global region thread. Pregen ticks on a worker; phases are scheduled (up to about one tick of skew). Fire-and-forget: a throwing handler is logged and skipped; the job does not wait. Do not block the tick thread.

IrisPregenProgress

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 Meaning
worldName Never null; falls back to worldIdentity
worldIdentity World's namespaced key string
percent 0.0 .. 100.0
generatedChunks Finished chunks
totalChunks Job total
remainingChunks Still to do
failedChunks Could not generate
chunksPerSecond Current rate
etaMillis Estimated remaining ms
elapsedMillis Since job start
method Never null; "" if unknown
paused Job paused

Constructor sanitises:

  • percent clamped to 0..100; non-finite → 0
  • chunksPerSecond ≥ 0; non-finite → 0
  • chunk and time counters ≥ 0
  • null worldNameworldIdentity; null method""
  • null worldIdentity throws NullPointerException at construction — delivered instances always identify a world

etaMillis is 0 until enough chunks complete for an estimate. Non-zero failedChunks on COMPLETED means holes remain.

Operator pregen surface: 07 - Pregeneration.md.


Worked example: boss bar

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;
    }
}

The minimum: world usable once

@EventHandler
public void onEngine(IrisWorldEngineEvent event) {
    if (event.getPhase() == IrisWorldPhase.ENGINE_READY) {
        prepare(event.getWorld());
    }
}

Do not set ignoreCancelled = true.


Failure policy

Situation Behaviour
Your handler throws Logged; remaining handlers run; Iris lifecycle continues
Iris cannot describe world for a phase Logged; event still delivered with empty getInfo()
Event dispatch itself throws Logged with phase and world; registration/teardown proceeds
Pregen sink not registered No IrisPregenerationEvent (before enable completes / after disable starts)
Pregen handler throws Logged; job not slowed/paused/stopped
Iris shuts down mid-pregen Terminal phase CANCELLED
Iris shuts down with worlds registered Every announced world gets ENGINE_CLOSING before worker drain

No listener quarantine. Iris never silently stalls a lifecycle step because a third party failed.


Configuration

No configuration keys. Events are always on while Iris is enabled; no per-world gate.


Enum reference

IrisWorldPhase

Constant Meaning Fires
ENGINE_READY Engine registered and answering Once per world registration
ENGINE_HOTLOADED Pack data reloaded in place Any number of times, or never; not ledger-paired
ENGINE_CLOSING Engine about to tear down Once per registration, always after a ready

IrisPregenPhase

Constant Meaning Fires
STARTED Job began Once, before first TICK
TICK Progress sample Once per second while job exists
PAUSED Job paused Transition only + following TICK
RESUMED Job resumed Transition only + following TICK
SAVING Flushing to disk At most once
COMPLETED Reached chunk total Terminal; exclusive with CANCELLED
CANCELLED Stopped before total Terminal; exclusive with COMPLETED

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