Repository navigation
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.
ECEventBus.post(event) runs two phases, in this order:
- The local phase - every handler subscribed on the bus, in priority order.
-
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
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.
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.
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.
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.
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.
ECEventSubscription<ECPlayerQuitEvent> subscription =
EverNifeCore.getEventBus().subscribe(ECPlayerQuitEvent.class, event -> {
event.getPlayerData().markDirty();
});
subscription.unsubscribe(); // idempotent; isActive() answers false afterwardsThe 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.
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.
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
ECPluginReloadEventhears bothPreandPost; - subscribing to an interface your events carry hears every one of them;
- subscribing to
IECEventitself 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.
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) { }
-1is the sentinel meaning "no raw value given", so it is the oneshortyou cannot ask for - a handler declaringpriorityValue = -1gets thepriority()step instead. Use0or-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.
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"]
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.
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.
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 baseActionEventswitches every source on,FLUIDincluded. -
ActionSupportsays what this platform raises:isServed(source),hasPre(source),isNativeCommandSource(). A definition that needs a source callsActionSupport.requireServed(source, needsPre, what)at load; on a platform without it that throwsUnsupportedSourceException, 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
COMMANDcomes fromPlayerCommandPreprocessEvent, every plugin's commands. Hytale has no command event, so theFinalCMDexecutor raises it for EverNifeCore commands only: the.Prepast the command's permission, the.Postafter 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.
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
});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.
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) |
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() |
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.
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'sEvent; redeclaring it is aVerifyErrorthe moment the class loads; -
getEventName,getHandlers,callEvent- how the server finds and fires listeners; -
isCancelled/setCancelledwithout implementingECCancellable.
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.
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.
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
IECEventis 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.
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.
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:
-
onFirstListeneralso 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 askhasListenersitself 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.
-
onLastListenerGoneis 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()andisActive()are the rest of the handle. -
The owned form is drained on shutdown.
watchListeners(plugin, ...)ties the watch to a plugin, andunsubscribeAll(plugin)stops it - the same drain the owned subscriptions get. Prefer it inside a plugin; a watch taken without an owner is yours tostop().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.
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.
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);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 |
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.
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()runsLOGGINGand 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;onWatchFailureis 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
FailingExceptionHandlerandRecordingExceptionHandler, and@ECoreTestinstalls 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.
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
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
ECPlayerFullyLoggedInEventon the bus and keeps a Bukkit@EventHandlerfor 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.
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, notECEvent. An audience that wraps the event rather than casting it needs nothing fromapi-contractson its classpath. -
hasListenerstakes the class, not an instance - the bus asks it before any event exists, forhasListeners(Class)and for the listener watches. An audience that can see its own native registrations callsECEventBus.global().refreshListenerWatches()when they change, so a watch follows them; one that cannot answers by polling, and says so in its own documentation. -
addNativeAudienceis idempotent byname(): 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. -
dispatchreceives the same instance the producer posted, and is only called afterhasListenerssaid 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 - adefaultthat answers empty. Override it when the native side can name its listeners (plugin listenerClass @PRIORITY, one line each) and/ecore eventsshows them; presence is stillhasListeners' to decide. -
getNativeAudiences()returns the current list, in delivery order.
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.
-
Platform Abstraction - the platoverride pattern
ECEventandECCancellableare 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,RecordingExceptionHandlerandECEventConformance. - 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
forgeaudience, the inboundForgeListenerAPI, and which hybrids either of them has been run on.
EverNifeCore · Home · made by Petrus Pradella
Getting Started
Commands & Text
Player Data & Storage
- PlayerData & PDSections
- Accounts
- Storage Backends
- Inline Backends for Plugins
- Block Data (SVWorldDataManager)
- Legacy Data Migration
- Cooldowns
Config & Minecraft Systems
- Configuration
- Logging & Debug Modules
- Events
- Regions
- Groups
- Notifications
- NPCs
- Logic
- Quests
- Provenance
- Scheduler & Threading
- Items & NBT
- Screens
- Dialogues
- Menus
- Integrations
- Economy
- Boosters
- Deliveries
- Audit Log
- Dynamic Values
- Player Ignore
- Version Compatibility
Architecture & Reference