Skip to content
Pétrus Pradella edited this page Nov 2, 2026 · 12 revisions

Events

What this page covers: the ECEventBus - one platform-agnostic bus that every EverNifeCore plugin subscribes to - how an event of yours becomes visible to the Bukkit or Hytale server, the order things are delivered in, and the traps that come with mirroring one event into two worlds.

The bus lives in br.com.finalcraft.evernifecore.eventbus (common); the events it carries live under br.com.finalcraft.evernifecore.api.events.


The two halves of a post

ECEventBus.post(event) runs two phases, in this order:

  1. The local phase - every handler subscribed on the bus, in priority order.
  2. The native phase - the registered ECNativeAudiences, in registration order. Bukkit, Hytale and the Forge side of a hybrid server are audiences like any other, and anything else plugs into the same seam.
flowchart TD
    P["producer: bus.post(event)"] --> L["local phase<br/>every subscriber, priority order"]
    L --> G{"instanceof ECEvent<br/>and this bus mirrors?"}
    G -->|"no"| R["return the event"]
    G -->|"yes"| A1{"audience.hasListeners(eventClass)?"}
    A1 -->|"no"| A2["next audience"]
    A1 -->|"yes"| D["audience.dispatch(event)"]
    D --> A2
    A2 --> R
Loading

post returns the event, so a producer can read back whatever the subscribers changed on it. A subscriber that throws goes to the bus's exception handler and an audience that throws is logged; neither breaks the producer, and neither stops the ones queued behind it.

The audiences that ship

Three of them exist; a Bukkit server registers two, a Hytale server one.

Name Registered by What it does with the event
bukkit the Bukkit entry point fires it on the server, so a plain Bukkit listener hears it
forge the Bukkit entry point, right after bukkit holds the seam to the Forge side of a hybrid Bukkit+Forge server. Nothing crosses it yet, so its gate answers "no listeners" on every server, whatever the Forge side registered - a hybrid says once that the audience is wired and idle, and dispatch never runs. See Forge Hybrids
hytale the Hytale entry point dispatches on Hytale's registry, climbing the event's supertype chain

The order is the registration order, and on Bukkit it is deliberate: the server's own listeners hear an event before anything bridging outwards does.

Getting the bus

ECEventBus bus = EverNifeCore.getEventBus();   // same object as ECEventBus.global()

ECEventBus.global() is a static holder in common, created once per classloader. It exists before any platform loads, which is why it is not a registered provider: a re-instantiated platform cannot orphan the subscriptions taken against it.

ECEventBus.create() builds a scoped bus for a subsystem's own traffic. It never mirrors: an audience added to it is accepted and never called. ECEventBus.create(handler) gives it an exception handler of its own.


Two shapes of event, and the hierarchy is the policy

There is no mirrorsToPlatform() flag and no per-event configuration. What an event extends is the whole statement:

Declaration Reaches the bus subscribers Reaches the platforms
implements IECEvent yes never - by construction
extends ECEvent yes yes, when an audience has listeners

ECEvent is a platoverride stub: api-contracts holds an abstract class with no body, and each platform ships the real one under the same fully-qualified name. On Bukkit that real class is an org.bukkit.event.Event; on Hytale it is an IEvent<Void>.

In a platform-agnostic module you write both clauses:

public class ShopPurchaseEvent extends ECEvent implements IECEvent {

    private final PlayerData buyer;

    public ShopPurchaseEvent(PlayerData buyer) {
        this.buyer = buyer;
    }

    public PlayerData getBuyer() {
        return buyer;
    }
}

The stub deliberately does not implement IECEvent: that interface lives in common, which already depends on api-contracts, and the reverse dependency is a build cycle. Inside a platform module (minecraft, hytale) the real base already carries IECEvent, so extends ECEvent alone compiles - but the two-clause form compiles and runs everywhere, and the one-clause form does not. Write both unless you are certain the class will never leave the platform module.

An event that must stay inside your plugin - the hot one fired thousands of times a tick - stops at implements IECEvent. No audience can ever see it, and nothing has to remember to keep it out.

Cancellation

Cancelling goes through ECCancellable, which is a platoverride stub too - the real one extends Bukkit's Cancellable / Hytale's ICancellable, so a native listener cancels through the API it already knows and the bus sees it.

public class ShopPurchaseEvent extends ECEvent implements IECEvent, ECCancellable {

    private boolean cancelled;

    @Override
    public boolean isCancelled() {
        return cancelled;
    }

    @Override
    public void setCancelled(boolean cancelled) {
        this.cancelled = cancelled;
    }
}

A bare isCancelled/setCancelled pair without ECCancellable is cancellation nobody honours: the bus only steps a handler aside for an ECCancellable, and a platform only offers "cancel" for its own Cancellable. See Names an event may not take.


Subscribing

Functionally, with a handle

ECEventSubscription<ECPlayerQuitEvent> subscription =
        EverNifeCore.getEventBus().subscribe(ECPlayerQuitEvent.class, event -> {
            event.getPlayerData().markDirty();
        });

subscription.unsubscribe();   // idempotent; isActive() answers false afterwards

The subscriber is an ECEventSubscriber<T> - void handle(T event) throws Throwable - so a lambda may throw a checked exception without wrapping it; what happens to it is the bus's exception handler's call.

Three forms, and everything beyond the defaults goes through one value object:

Form What it does
subscribe(Class, subscriber) NORMAL priority, delivered even when cancelled, subtypes included, nobody owns it
subscribe(ECPluginData, Class, subscriber) the same, owned by the plugin: dropped on its shutdown - the form to use inside a plugin
subscribe(Class, ECSubscribeOptions, subscriber) whatever the options say

ECSubscribeOptions is immutable: defaults() is one shared instance, ownedBy(plugin) is the defaults owned by a plugin, and every withX hands back a copy (or the same instance when nothing changed):

Option Effect
withPriority(ECEventPriority) / withPriority(short) delivery order, lowest first; a tie keeps the order the subscriptions were taken in, whatever class each one named
withIgnoreCancelled(true) the subscriber steps aside once the event - an ECCancellable - is cancelled
withExact(true) the subscriber hears the class it named only, no subtype of it; hasListeners(subtype) does not count it
withPlugin(plugin) the owner, for the shutdown drain and for the operator to see
EverNifeCore.getEventBus().subscribe(ShopPurchaseEvent.class,
        ECSubscribeOptions.ownedBy(getPluginData()).withPriority(ECEventPriority.LATE).withIgnoreCancelled(true),
        event -> ledger.record(event));

Prefer the owned forms inside a plugin. The default IECPluginBootstrap.onECPluginShutdownPre() calls unsubscribeAll(getPluginData()) alongside the listener and command cleanup, so an owned subscription cannot outlive the plugin that took it:

EverNifeCore.getEventBus().subscribe(getPluginData(), ECPlayerFullyLoggedInEvent.class, event -> {
    event.getPlayer().sendMessage("welcome back");
});

If you override onECPluginShutdownPre(), call super.onECPluginShutdownPre() or you take that drain away.

Annotated, on a listener

Any ECListener registered through ECListener.register(...) is handed to the bus automatically, in the plugin's name - its @ECEventHandler methods taking an IECEvent are subscribed, and unregisterThis() drops them:

public class ShopListener implements ECListener {

    @ECEventHandler(priority = ECEventPriority.LAST)
    public void onLogin(ECPlayerFullyLoggedInEvent event) {
        Player player = event.getPlayer().getDelegate(Player.class);
        //...
    }
}

The annotation carries the same choices as the options - priority, priorityValue, ignoreCancelled, exact - and they are read into an ECSubscribeOptions too, so an annotated subscription and a functional one answer the same questions when listed.

For an object that is not an ECListener, bus.register(listener) / bus.register(plugin, listener) scan it by hand and hand back one ECEventSubscription per method taken; bus.unregister(listener) drops them. The scan only takes methods whose single parameter is an IECEvent; anything else is left to the platform route below.

Subscribing to a supertype or a marker interface

A subscription hears the class it named and every subtype of it - superclasses and interfaces count, each once however many paths lead to it:

  • subscribing to ECPluginReloadEvent hears both Pre and Post;
  • subscribing to an interface your events carry hears every one of them;
  • subscribing to IECEvent itself hears everything.

withExact(true) (or @ECEventHandler(exact = true)) is the one way out of that: the class named and nothing below it.

Under the hood the bus keeps its subscriptions under the class each one named and assembles the plan of a concrete event class from the lists of its ancestors, once, until the next (un)subscribe. A subscription taken while a delivery is running only hears the next post: the plan a post iterates is a snapshot, so subscribing (or posting again) from inside a subscriber is safe.


Priority

ECEventPriority is a five-step scale over short values, lowest first:

Step Value Bukkit equivalent
FIRST -21844 LOWEST
EARLY -10922 LOW
NORMAL 0 NORMAL
LATE 10922 HIGH
LAST 21844 HIGHEST

Handlers at the same priority run in the order they subscribed - whatever class each one named.

On the functional route the priority is ECSubscribeOptions.withPriority(ECEventPriority), or withPriority(short) for a value between the steps. @ECEventHandler takes the same pair as priority() and a raw priorityValue(), for the handler that has to sit right before or after another one; the raw value wins when set. Every route that registers an @ECEventHandler - the bus, the Bukkit route and the Hytale route - resolves it through the same ECEventPriority.of(annotation), so none of them can honour only half of it.

@ECEventHandler(priorityValue = -30000)   // earlier than FIRST
public void veryEarly(ECPlayerQuitEvent event) { }

-1 is the sentinel meaning "no raw value given", so it is the one short you cannot ask for - a handler declaring priorityValue = -1 gets the priority() step instead. Use 0 or -2.

toBukkitPriority cuts the EC scale at the midpoints between its own steps. MONITOR is unreachable on purpose: it is Bukkit's watch-never-change slot, and an EC handler asking for LAST is asking to be last among handlers, not to stop being one. McECEventHandlers.toBukkitPriority(short) is public for a plugin building a bridge of its own. On Hytale the short is passed straight through, because Hytale uses the same scale.


Where an @ECEventHandler method ends up

One annotation, three destinations. The parameter type decides, and it is asked in this order:

flowchart TD
    M["@ECEventHandler method<br/>on a registered ECListener"] --> Q1{"parameter is an IECEvent?"}
    Q1 -->|"yes"| BUS["subscribed on the ECEventBus"]
    Q1 -->|"no"| Q2{"parameter is the platform's<br/>own event type?"}
    Q2 -->|"yes"| NAT["registered on the native bus<br/>Bukkit registerEvent / Hytale registerGlobal"]
    Q2 -->|"no"| ERR["logged, and the WHOLE listener<br/>is rolled back"]
Loading

IECEvent is asked first, and that is not an accident: an ECEvent is both an IECEvent and a platform event, so registering it on the native bus as well would deliver it twice - once from the bus, once from the mirror the bus feeds.

Parameter type Registered by Notes
IECEvent subtype ECEventBus the platform route skips it
org.bukkit.event.Event pluginManager.registerEvent(...) executor filters by the parameter type - the very class when exact = true
Hytale IEvent eventRegistry.registerGlobal(...) ignoreCancelled honoured for ICancellable
anything else nothing severe log + RuntimeException, listener rolled back

The rollback is total. A listener with one unregisterable @ECEventHandler method gets HandlerList.unregisterAll(listener) (Hytale: every registration undone) and the registration throws - the plain @EventHandler methods it also had go with it. Half a listener answering events is worse than one that plainly did not load.

Bukkit's own @EventHandler keeps working exactly as before, on the same listener if you like. The Bukkit route exists because a @ECEventHandler method carries no @EventHandler, so the server's own scan never sees it.


The canonical events

Three events ship with the core. All of them are extends ECEvent, so a plain platform listener hears them too.

Event Fired when
api.events.player.ECPlayerFullyLoggedInEvent the player is in-game and their PlayerData is attached
api.events.player.ECPlayerQuitEvent the player leaves, before the detach and flush
api.events.reload.ECPluginReloadEvent.Pre / .Post around an ECPlugin reload

ECPlayerFullyLoggedInEvent is the point where PlayerData.getPlayer() answers. Each platform decides when that point is reached - the join plus a tick on Bukkit, the connect plus the loaded data on Hytale - and a player who left in between is never announced at all. getPlayerData(), getPlayer() (an FPlayer) and getPDSection(Class) (the section, waited for) are on it.

isExternalAuthLogin() answers whether the login only completed once an external authentication flow did - AuthMe on Bukkit today. It is a platform-agnostic question: a platform with no such flow answers false.

ECPlayerQuitEvent is fired while the data is still attached and its sections still cached, so a handler can read it - and whatever it dirties there rides the flush that immediately follows. That ordering is the whole reason the event exists at that spot.

ECPluginReloadEvent declares the platoverride clause on the outer class, and Pre/Post extend it. Subscribing to the outer type hears both halves.

Action events

Every game event the core translates - for the regions and for anyone else - is posted on the bus as a <Source>ActionEvent (br.com.finalcraft.evernifecore.action.event), on Bukkit and on Hytale alike. Each source has a pair: .Pre, cancellable, posted after the regions allowed the action and before its outcome (cancelling it cancels the native event), and .Post, posted after the outcome, never for a cancelled native event. Subscribing to the outer class hears both halves.

ECEventBus.global().subscribe(BreakActionEvent.Post.class, event -> {
    BlockContext block = event.getContext();
    FPlayer who = block.getPlayer();                       // the player behind the actor, null for a mob or the world
    if (who != null && block.getBlockKey().matches("minecraft:*_ore")) { ... }
});
Source Event Context .Pre on Bukkit On Hytale
BREAK, PLACE BreakActionEvent, PlaceActionEvent BlockContext yes yes
INTERACT InteractActionEvent BlockContext / EntityContext yes yes
DAMAGE DamageActionEvent DamageContext (getActor() the attacker, getVictim()) yes yes
KILL KillActionEvent DamageContext whose victim died no .Post only
SPAWN SpawnActionEvent SpawnContext (getReason() the platform's word) yes yes
EXPLOSION, FLUID, FIRE_SPREAD, VEHICLE, FLIGHT_TOGGLE ExplosionActionEvent, ... their own yes not raised
RESPAWN RespawnActionEvent SimpleContext no yes, .Pre keeps the player dead
ITEM_PICKUP, ITEM_DROP PickupActionEvent, DropActionEvent ItemContext (getItemEntityId(), getItem()) yes yes
CRAFT CraftActionEvent ItemContext (getAmount() the whole craft, shift-click included) yes yes
SMELT, XP_GAIN SmeltActionEvent, XpGainActionEvent ItemContext, XpContext no not raised
FISH, CONSUME, ENCHANT FishActionEvent, ... ItemContext yes not raised
TAME, BREED (1.12+), SHEAR TameActionEvent, ... EntityContext yes not raised
COMMAND CommandActionEvent CommandContext (getCommandLine(), getNames()) yes yes, for FinalCMD commands
EXPLORE ExploreActionEvent ExploreContext (the chunk left and the one entered) yes .Post only

MOVE (the region sessions), TICK and NONE have no action event.

ItemContext.getItem() is the item itself as an ItemSpec: name and lore, and the platform's stack in getNativeItem(), with no reach into the native event. The stack is copied on the first call, not when the context is built - ask inside the listener, while the event still runs. It is null for a Bukkit smelt and a Hytale craft, whose events carry no stack.

  • Built only while somebody listens, and the source only while somebody listens. An event is built only when it has a subscriber, and the native listener (Bukkit) or the System's work (Hytale) of a source exists only while a region flag or a subscriber needs it (EventSourceUsage, 3 min idle). Subscribe to the class of the source you need: a subscriber of the base ActionEvent switches every source on, FLUID included.
  • ActionSupport says what this platform raises: isServed(source), hasPre(source), isNativeCommandSource(). A definition that needs a source calls ActionSupport.requireServed(source, needsPre, what) at load; on a platform without it that throws UnsupportedSourceException, naming the definition, the source and the server.
  • Thread: the native event's - the server thread on Bukkit, the world's thread on Hytale.
  • Commands: on Bukkit COMMAND comes from PlayerCommandPreprocessEvent, every plugin's commands. Hytale has no command event, so the FinalCMD executor raises it for EverNifeCore commands only: the .Pre past the command's permission, the .Post after it ran. On Bukkit it never posts, so a command is posted once.
  • What placed, spawned or dropped something is answered by Provenance, a subscriber of these events.

These replace the Bukkit-only chunk, craft and damage events of 3.0, which are gone: listen to ExploreActionEvent.Post, CraftActionEvent.Post and DamageActionEvent.

Account events (bus-only)

The account/identity layer (Accounts, in common) posts three events as its link/unlink/merge operations complete. Unlike the events above they are bus-only - implements IECEvent, never extends ECEvent - so a bus subscription hears them and no platform @EventHandler does. They are posted on the account pipeline's completion thread, which is not the main thread, and only when something listens (postIfListened).

Event Fired when Carries
api.events.account.ECAccountLinkedEvent an identity is attached to an account (a linkExternal adopt) getAccountId(), getLinkedMember(), getMembersAfter(), getActor()
api.events.account.ECAccountMergedEvent two accounts fuse into one (/ecaccount link, or a transitive linkExternal) getCanonicalAccountId(), getTargetAccountId(), getSourceAccountId(), getMembersAfter(), getActor()
api.events.account.ECAccountUnlinkedEvent a member leaves an account (/ecaccount unlink, unlinkExternal) getAccountId(), getUnlinkedMember(), getMembersAfter(), getActor()

getActor() is an AccountActor - the origin the caller stated. AccountActor.Kind is ADMIN, INTEGRATION or SYSTEM, with an optional detail (the admin name, the integration id); build one with AccountActor.admin(name), AccountActor.integration(id) or AccountActor.system(). The same origin is recorded on the audit stamp of the identity it linked - AccountMember.getLinkedBy() / getLinkedAt(), alongside Account.getCreatedAt() set when an explicit account is minted.

Because they never mirror, subscribe on the bus - a Bukkit @EventHandler will not hear them:

EverNifeCore.getEventBus().subscribe(getPluginData(), ECAccountMergedEvent.class, event -> {
    // event.getActor(), event.getMembersAfter(), ... - remember: not on the main thread
});

Region events

The region system posts its own family, all extends ECEvent (package region.event, base RegionEvent with getRegion() and getOwner()), each only when something listens (postIfListened) and on the thread of whoever made the change. The bus carries every plugin's regions, so a subscriber that cares about its own filters by getOwner().

Event Fired when Carries
RegionCreatedEvent a region was created and is already indexed - not cancellable, a plugin refuses with a RegionPolicy getOverlaps()
RegionRemovedEvent a region left the index getCause(): API and EXPIRED delete it from storage, OWNER_DISABLED and WORLD_UNLOADED only unindex it
RegionRedefinedEvent its shape changed getOldShape(), getNewShape()
RegionChangedEvent a property with no event of its own changed getType(): PRIORITY, PARENT, TAGS, METADATA, EXPIRES_AT, DISPLAY_NAME, DESCRIPTION, FLAG_SETTINGS (the region's own settings override of one flag was set or removed)
RegionFlagChangedEvent a local flag value of the region or of one of its groups changed getFlag(), getGroup() (null for the region's own), getOldValue(), getNewValue(), getCause() (SET, REMOVED, EXPIRED)
RegionEnterEvent / RegionLeaveEvent a player is about to cross a border (leaves are posted before enters) getPlayer(), getFrom(), getTo(), getCause(), getNativeEvent(Class), get/setMessage
RegionCrossedEvent a crossing took effect: the session already counts the player in the regions entered getPlayer(), getFrom() (null on a join), getTo() (null on a quit), getCause(), getLeft(), getEntered(), getNativeEvent(Class) - never cancellable, not a RegionEvent: it carries N regions
RegionTerrainEvent a terrain snapshot or restore step ran getType() (SNAPSHOT, RESTORE_START, RESTORE_TICK, RESTORE_END), isJournal(), getBlocks(), getNanos()
RegionLoadFailedEvent a load of an owner's regions failed (the manager tries again by its policy) getOwner(), getWorld(), getError() - not a RegionEvent: there is no region

The two border events extend RegionBoundaryEvent and are ECCancellable, but only a crossing whose MoveCause.isCancellable() - MOVE, TELEPORT, VEHICLE - can be stopped; a JOIN, QUIT, RESPAWN, WORLD_CHANGE or a region created, removed or changed around the player already happened. Each declares its own Bukkit handler list, so a plain @EventHandler hears it too.

Dialogue events

The dialogue engine posts its family from dialogue.event, all extends ECEvent through the base DialogueEvent (getSession(), getPlayer(), getDialogue(), getCaller()), each only when something listens.

Event Fired when Carries
DialogueStartedEvent a session is about to open - cancellable: cancelled, no session and Dialogues.start answers null -
DialogueChoiceEvent an option was picked, before its gate is paid - cancellable: nothing charged, the node stays getNode(), getOption()
DialogueEndedEvent a session ended, once, with the first cause getCause() (DialogueEndCause)

Gate events (logic)

The logic engine posts its own family from logic.audit, all extends ECEvent through the base LogicEvent (getContext(), and through it getCaller(), getOwner(), getPlayer()), each only when something listens and on the thread of whoever checked, paid or applied.

Event Fired when Carries
GateCheckEvent after Gate.check getResult()
GatePrePayEvent before the first cost is taken - cancellable: a cancelled pay ends DENIED and takes nothing getGate(), getChecked() (the check's result)
CostTakeEvent after each take getCost(), isTaken()
CostRefundEvent after each refund getCost(), isRefunded()
EffectApplyEvent before each effect applies - cancellable; setEffect(Effect) replaces what runs getEffect()
GatePaidEvent a pay ended PAID getResult()
GateDeniedEvent a check or pay ended DENIED or REFUNDED getResult()

Threading

The producer's contract: build and post on the same thread.

ECEvent() snapshots the thread that built it - on Bukkit, !Bukkit.isPrimaryThread() becomes the event's async flag. ECEvent(boolean async) declares it explicitly, for an event that is asynchronous by nature whichever thread happens to build it. (Hytale has no per-event async flag: it accepts the value and ignores it.)

public class BackgroundSyncEvent extends ECEvent implements IECEvent {
    public BackgroundSyncEvent() {
        super(true);   // always asynchronous, wherever it is built
    }
}

When the flag and the posting thread disagree, the Bukkit audience logs the mismatch with the producer's stack and skips only the native phase - the local phase already ran, and its subscribers already had the event. Skipping is the lesser evil: a sync event fired off the main thread does not throw on the old servers, it silently serializes every caller on the plugin manager's global monitor.

See Scheduler & Threading for getting back onto the main thread.


Names an event may not take

On Bukkit, an ECEvent is an org.bukkit.event.Event. Its plumbing is off limits, and a clash is not a compile error - it is a server that fails at classload, or an event nobody hears.

An ECEvent subtype must not declare:

  • isAsynchronous - final on Bukkit's Event; redeclaring it is a VerifyError the moment the class loads;
  • getEventName, getHandlers, callEvent - how the server finds and fires listeners;
  • isCancelled / setCancelled without implementing ECCancellable.

getHandlerList is the one Bukkit member an event may declare - and should, for a gate of its own (see the Bukkit side). Only in one shape, though: the server finds it by name up the hierarchy, invokes it static and casts what comes back, so it must be public, static, take no parameter and return something. Anything else is a listener that fails to register on the server.

Your events are written against the stub, which has none of those members, so nothing but a test stands between a harmless-looking method and a refused class. The Testing Toolkit publishes the guard - it checks the reserved names, the cancellation pair and the getHandlerList shape. Point it at your own events:

@Test
void myEventsDeclareNothingReserved() {
    assertEquals(Collections.emptyList(), ECEventConformance.checkAll(
            ShopPurchaseEvent.class,
            ShopRestockEvent.class));
}

ECEventConformance.check(Class) returns one line per violation and an empty list when the type conforms - it reports, it does not throw. It checks the classes you name; there is no classpath scan, so a new event is only guarded once it is added to that list.


Asking before building: hasListeners, postIfListened and the listener watch

An event that costs something to build should not be built for nobody. The bus can say who is there per event class and before any event exists, and it offers that answer in two shapes: a question to ask on the spot, and a pair of actions to run when the answer changes.

hasListeners(Class) - would a post of exactly this type reach anyone?

if (EverNifeCore.getEventBus().hasListeners(ShopStockSnapshotEvent.class)) {
    //...
}

It answers yes when a handler is subscribed to that exact type, to a supertype of it, or to a marker interface it carries (an exact one only for that very type) - the same match a post runs - and, on a mirroring bus and only for an ECEvent, when a native audience answers yes for that class. A handler subscribed to a subtype does not count: it would not hear that post.

  • A bus from ECEventBus.create() never mirrors, so its presence is that of its own subscribers alone.
  • A plain IECEvent is never mirrored either, so no audience is asked about it even on the global bus.

One index lookup plus one question per audience: cheap enough to ask on every occasion.

postIfListened(Class, Supplier) - build only on a yes

public class ShopStockSnapshotEvent extends ECEvent implements IECEvent {

    public static Object getHandlerList() {
        return ECEvent.getHandlerListOf(ShopStockSnapshotEvent.class);
    }

    private final Map<String, Integer> stock;

    public ShopStockSnapshotEvent(Map<String, Integer> stock) {
        this.stock = stock;
    }

    public Map<String, Integer> getStock() {
        return stock;
    }
}
EverNifeCore.getEventBus().postIfListened(ShopStockSnapshotEvent.class,
        () -> new ShopStockSnapshotEvent(scanAllShops()));

Nobody subscribed on the bus, no Bukkit @EventHandler, no Hytale consumer? scanAllShops() never runs and the call returns null. With a listener it builds, posts and returns the event, so a producer can read back what the handlers changed - null is the one thing to check for.

watchListeners(...) - switch the source off while nobody cares

The if only saves the event. A watch saves the whole machine behind it: onFirstListener runs when the first listener of the type appears, onLastListenerGone when the last one leaves.

private ECListenerWatch stockWatch;
private ScheduledFuture<?> stockTask;

@Override
public void onECPluginEnable() {
    stockWatch = EverNifeCore.getEventBus().watchListeners(getPluginData(),
            () -> stockTask = ECScheduler.getScheduler()
                    .scheduleAtFixedRate(this::publishSnapshot, 30, 30, TimeUnit.SECONDS),
            () -> stockTask.cancel(false),
            ShopStockSnapshotEvent.class);
}

The two callbacks come first and the event types last, as varargs: watchListeners(onFirst, onLast, types...) for a watch nobody owns, watchListeners(plugin, onFirst, onLast, types...) for one the plugin's shutdown drains.

What the contract guarantees:

  • onFirstListener also fires immediately when somebody already listens at the moment the watch is taken. A watch is born absent and is evaluated at once, so a producer never has to ask hasListeners itself first.
  • Exactly one callback per transition, on the thread that caused it and outside every lock of the bus - so a callback may subscribe, unsubscribe or register a native listener of its own without deadlocking.
  • A callback that throws goes to the bus's exception handler, and the watch keeps the state it had just moved to: it does not retry, and the next transition still fires.
  • onLastListenerGone is nullable, for a producer with nothing to undo.
  • stop() is silent and idempotent: no callback fires for a stopped watch, whatever the presence does afterwards. getEventTypes(), hasListeners() and isActive() are the rest of the handle.
  • The owned form is drained on shutdown. watchListeners(plugin, ...) ties the watch to a plugin, and unsubscribeAll(plugin) stops it - the same drain the owned subscriptions get. Prefer it inside a plugin; a watch taken without an owner is yours to stop(). getPlugin() on the handle says whose it is.

For a producer of a family, name several types and the watch is present while any of them has a listener - and it must name the concrete types actually posted, not the base:

EverNifeCore.getEventBus().watchListeners(getPluginData(),
        arenaTicker::start,
        arenaTicker::stop,
        ArenaStartEvent.class, ArenaEndEvent.class);

Someone subscribing to the base ArenaEvent still opens that watch, because hasListeners counts a supertype subscriber. Watching the base instead would be the broken direction: a subscriber of ArenaStartEvent alone is a subtype, and would not open it.

ECListener.registerWhileListened(...) - the producer idiom

When the expensive source is a native listener, the watch is already written for you: the listener is registered on the first listener of what it produces and unregistered with the last.

ECListener.registerWhileListened(ecPluginData, new ArenaHitListener(), ArenaHitEvent.class);

On a server where nobody asked for ArenaHitEvent, the ArenaHitListener (say, on EntityDamageByEntityEvent) is not registered at all. It returns the ECListenerWatch, and the watch belongs to the plugin, so shutdown drains it. The core's own action events go one step further: their listeners follow EventSourceUsage, so a region flag and a bus subscriber keep the same listener alive.

refreshListenerWatches() - telling the bus the native side moved

The bus re-evaluates the watches on its own mutations. It cannot see a native registration, so an audience that can observe its own calls it - on Bukkit, ECHandlerList reports every registration and unregistration, which is why a watch follows a Bukkit @EventHandler exactly as it follows a bus subscriber. It is idempotent: a call that changes nothing costs one pass and fires nothing.

A test drives the same seam by hand, through the Testing Toolkit's RecordingAudience:

ECEventBus bus = EventBuses.mirroring();
RecordingAudience audience = new RecordingAudience();
audience.setHasListeners(false);
bus.addNativeAudience(audience);

List<String> log = new ArrayList<>();
bus.watchListeners(() -> log.add("first"), () -> log.add("last"), SampleEvent.class);
assertEquals(Collections.emptyList(), log);

audience.setHasListeners(true);
bus.refreshListenerWatches();          //what a real native audience does when its listeners change
assertEquals(Collections.singletonList("first"), log);

The Bukkit side: one handler list per event, if the event asks for one

On Bukkit an ECEvent is an org.bukkit.event.Event, and the server finds the HandlerList a listener registers into by walking up the hierarchy to the first class that declares a static getHandlerList. Every EC event may declare one, and should - that is what buys it a gate that answers for that event alone.

In a platform-agnostic module the return type is Object, because the module cannot name HandlerList:

public static Object getHandlerList() {
    return ECEvent.getHandlerListOf(ShopStockSnapshotEvent.class);
}

Inside the minecraft module the compiler sees the base's own static HandlerList getHandlerList() and demands a compatible return type, so the same line reads:

public static HandlerList getHandlerList() {
    return (HandlerList) ECEvent.getHandlerListOf(ArenaStartEvent.class);
}

Declare it once, on the class native listeners subscribe to. A subclass that declares its own leaves the family: a Bukkit listener on the parent stops hearing it, because the parent's list is no longer the one it registers into. ECPluginReloadEvent declares it on the outer class only, so Pre and Post share it and a Bukkit listener on the base hears both - the same thing subscribing to the base does on the bus. The bus itself has no such split: a subscription hears the whole subtree (unless it asked for exact), whatever the lists look like.

An event that declares nothing falls back to the family list of ECEvent, shared by every other undeclared EC event. That still works, but the gate becomes conservative: it opens for your event as soon as a sibling has a listener, and the post costs a callEvent whose executors then filter by the handler's parameter type - so a listener still only ever hears its own event.

Registration, dispatch and the gate all resolve through the same lookup (McHandlerLists), computed the way the plugin manager computes it, so the three can never disagree about which list an event uses. A getHandlerList of the wrong shape is reported once, in a message that names the fix, and the family list stands in for it.

For an event fired often enough that any of this matters:

Option How What you give up
Give it a list of its own declare getHandlerList as above nothing, unless it is a subclass - then it leaves its parent's family
Keep it off the mirror implements IECEvent, do not extend ECEvent no platform listener can ever hear it
Skip the mirror per post bus.postLocal(event) the platforms miss that post; the type stays mirrorable
Keep it native a plain platform event, outside the bus it is not portable to the other platform

The Hytale side

Hytale dispatches by exact class, so the audience climbs the chain from the concrete class up to (and not including) ECEvent and asks each level for its dispatcher - which is also what keeps "subscribe to the base, hear the family" true there. The gate is per class and by poll: asking dispatchFor(...).hasListener() is a map read and creates nothing on the server's bus. Polling is what the platform allows - Hytale offers no callback for a registration, so a bus subscription or an @ECEventHandler drives the listener watches, while a raw registerGlobal straight on the server is seen the next time somebody asks and never pushes a watch by itself.


When a subscriber throws

A subscriber that throws never breaks the producer and never stops the subscribers queued behind it. What its failure becomes is the bus's ECEventExceptionHandler:

public interface ECEventExceptionHandler {
    void onSubscriberFailure(ECEventSubscription<?> subscription, IECEvent event, Throwable failure);
    default void onWatchFailure(ECListenerWatch watch, boolean onFirstListener, Throwable failure) { ... }
    ECEventExceptionHandler LOGGING = ...;   // SEVERE on the core log, subscription and event named, stack attached
}
  • The global bus logs. ECEventBus.global() runs LOGGING and no plugin can change that: the line names the subscription - ShopListener#onLogin on ECPlayerFullyLoggedInEvent [LATE, plugin=Shop]
    • so an operator sees whose subscriber broke without a debugger.
  • A scoped bus chooses. ECEventBus.create(handler) gives a subsystem's own bus a policy of its own - a metric, a plugin's own logger, a failed test. One abstract method, so a lambda is a handler; onWatchFailure is there for the listener watch callbacks and defaults to logging.
  • The bus does not guard the handler. Whatever the handler throws propagates to the producer. That is the point of a test handler that throws: the test fails at the post that drove the broken subscriber. The Testing Toolkit ships FailingExceptionHandler and RecordingExceptionHandler, and @ECoreTest installs the failing one on the global bus for the length of every test class.
ECEventBus scoped = ECEventBus.create((subscription, event, failure) ->
        metrics.count("bus.failure", subscription.getOptions().getPlugin()));

Failures of an audience (its hasListeners or dispatch) are platform code, not a subscriber's, and stay on the core log.


Who is listening

A subscription is an object: the ECEventSubscription handed back by subscribe(...) and register(...) is also the view the bus lists.

Member Answers
getEventType() the type it named - not the concrete types it ends up hearing
getOptions() priority, isIgnoringCancelled(), isExact(), getPlugin()
getSubscriber() the ECEventSubscriber handed to subscribe, or the listener object register scanned
isActive() / unsubscribe() the handle half
toString() one line: ShopListener#onLogin on ECPlayerFullyLoggedInEvent [LATE, plugin=Shop]
ECEventBus bus = EverNifeCore.getEventBus();
bus.getSubscriptions();                                  // every live subscription, in the order taken
bus.getSubscriptions(ECPlayerFullyLoggedInEvent.class);  // what a post of exactly that type reaches, in delivery order
bus.getListenerWatches();                                // every active watch: types, owner, present or absent
bus.unsubscribeIf(subscription -> subscription.getOptions().getPriority() > ECEventPriority.LATE.getValue());

getSubscriptions(Class) counts what hasListeners(Class) counts - a subscription to a subtype is not in a post of the base - and asks nobody on the native side. unsubscribeIf(Predicate) is the one removal by criterion (unregister(listener) and unsubscribeAll(plugin) are cases of it); it hands back what it removed, and a watch whose last listener left that way hears it before the call returns.

The native side answers through the audience: ECNativeAudience.describeListeners(Class) is one line per native listener a post would reach - on Bukkit, plugin listenerClass @PRIORITY for every @EventHandler in the list the event registers into; Hytale does not enumerate its consumers and the Forge audience has nothing crossing, so both answer empty. hasListeners stays the authority on presence.

/ecore events [type] (permission evernifecore.command.events) puts all of that on an operator's screen. Without a type: one line per subscribed type with its count (the full name and each subscription on hover) and one per listener watch. With a type - the full class name, or the simple name of a subscribed one - the bus subscriptions in delivery order, then per audience listening or silent and the native listeners it can name. A type that only implements IECEvent is not put to the audiences at all: no platform can ever see it.

/ecore events ECPlayerFullyLoggedInEvent
  br.com.finalcraft.evernifecore.api.events.player.ECPlayerFullyLoggedInEvent
  bus: 2 subscription(s), in delivery order
   - ShopListener#onLogin on ECPlayerFullyLoggedInEvent [EARLY, plugin=Shop]
   - PartyPlugin$$Lambda/0x... on ECPlayerFullyLoggedInEvent [NORMAL, plugin=SimpleParty]
  bukkit: listening
   - BetterClans br.com.finalcraft.betterclans.listeners.LoginListener @NORMAL
  forge: silent

Migrating consumers: per event, never per plugin

While the ecosystem moves from platform-native events to the bus, a producer and a consumer can disagree about which world an event lives in. The failure mode is double delivery:

A plugin that both subscribes to ECPlayerFullyLoggedInEvent on the bus and keeps a Bukkit @EventHandler for the same event runs its logic twice - the bus delivers once, and the mirror makes the server deliver again.

The action events are in exactly that position: posted through the bus and mirrored to the server, so a plugin that keeps a Bukkit @EventHandler for CraftActionEvent.Post and adds a subscribe(CraftActionEvent.Post.class, ...) alongside it hears every craft twice.

The rule that avoids it: migrate consumers one event at a time, not one plugin at a time. For a given event, move every listener of it to the bus in the same change, and delete the native one. Two events of the same plugin may sit in different states without any harm; two listeners of the same event may not.

The same hazard is why the @ECEventHandler router asks IECEvent first: within one listener it picks the bus and stays there.


Writing your own native audience

ECNativeAudience is the seam Bukkit, Hytale and the Forge bridge themselves go through. Anything else that speaks a native bus - a test double, a metrics tap, another modding platform - registers the same way:

public class MyAudience implements ECNativeAudience {

    @Override
    public String name() {
        return "my-bus";
    }

    @Override
    public boolean hasListeners(Class<? extends IECEvent> eventType) {
        return MyNativeBus.hasSubscribers(eventType);
    }

    @Override
    public void dispatch(IECEvent event) {
        MyNativeBus.publish(event);
    }
}
ECEventBus.global().addNativeAudience(new MyAudience());     // in your enable
ECEventBus.global().removeNativeAudience("my-bus");          // in your shutdown
  • The SPI is typed on IECEvent, not ECEvent. An audience that wraps the event rather than casting it needs nothing from api-contracts on its classpath.
  • hasListeners takes the class, not an instance - the bus asks it before any event exists, for hasListeners(Class) and for the listener watches. An audience that can see its own native registrations calls ECEventBus.global().refreshListenerWatches() when they change, so a watch follows them; one that cannot answers by polling, and says so in its own documentation.
  • addNativeAudience is idempotent by name(): re-registering replaces the audience in place, keeping delivery order stable across a re-enable instead of stacking a second copy that would deliver everything twice.
  • dispatch receives the same instance the producer posted, and is only called after hasListeners said yes. An audience is expected to swallow and log its own errors; the bus catches whatever escapes anyway, and the remaining audiences still run.
  • describeListeners(Class) is optional - a default that answers empty. Override it when the native side can name its listeners (plugin listenerClass @PRIORITY, one line each) and /ecore events shows them; presence is still hasListeners' to decide.
  • getNativeAudiences() returns the current list, in delivery order.

Lifetime and reloads

The global bus is a static holder: it lives as long as its classloader does. Each platform loader registers its audiences in onECPluginEnablePost() and removes them in onECPluginShutdownPre() - two on Bukkit (bukkit and forge), one on Hytale - so a normal enable/disable cycle leaves no stale audience behind. The same shutdown hook calls unsubscribeAll(getPluginData()), which is the one drain for a plugin: it drops the subscriptions it owns and stops the listener watches it owns, subscriptions first, so a watch closed by their departure still hears it before detaching.

Reloading the core itself with a plugin manager (plugman and friends) is not supported. It builds a new classloader, and with it a second ECEventBus.global() that knows none of the earlier subscriptions - producers post into one bus while the subscribers wait on the other. Restart the server; this is the same reason Installation says never to /reload.


See also

  • Platform Abstraction - the platoverride pattern ECEvent and ECCancellable are built on.
  • PlayerData & PDSections - what the login and quit events hand you.
  • Scheduler & Threading - main thread, async work, and the tick.
  • Testing Toolkit - RecordingAudience, EventBuses.mirroring(), FailingExceptionHandler, RecordingExceptionHandler and ECEventConformance.
  • Hytale Platform - the Hytale audience and its exact-class dispatch.
  • Provenance - who placed, spawned or dropped something, recorded from the action events.
  • Forge Hybrids - the forge audience, the inbound ForgeListener API, and which hybrids either of them has been run on.

Clone this wiki locally