Developer API
The public API is a small, platform-agnostic surface: server metadata, a player repository, and an
event bus. It is compileOnly: the implementation ships inside the anticheat.
Plan requirement. The developer API is included with the SMP and Network plans. On a plan without it,
EcstacyApiAccessor.access()returns an emptyOptionalandEcstacyApiAccessor.require()throwsApiNotAvailableException(a subclass ofApiNotReadyException, so existing handling still compiles). Everything else in the plugin is unaffected.
Dependency
repositories {
maven { url = 'https://www.ecstacy.ac/repo' }
}
dependencies {
compileOnly 'ac.ecstacy:ecstacy-api-bukkit:3.0.0'
}<repositories>
<repository>
<id>ecstacy</id>
<url>https://www.ecstacy.ac/repo</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>ac.ecstacy</groupId>
<artifactId>ecstacy-api-bukkit</artifactId>
<version>3.0.0</version>
<scope>provided</scope>
</dependency>
</dependencies>The API targets Java 21.
Important
Add softdepend: [EcstacyAC-Spigot] to your plugin.yml, not depend. Your plugin should
still load on a server without the anticheat. EcstacyApiAccessor.access() returning empty is
the supported way to find out.
Entry point
import ac.ecstacy.api.EcstacyApi;
import ac.ecstacy.api.EcstacyApiAccessor;
EcstacyApiAccessor.access().ifPresent(api -> {
api.server(); // server metadata
api.repo(); // player lookups and data
api.events(); // event bus
});access() returns an Optional that is empty until Ecstacy has finished loading. If you would
rather fail loudly, EcstacyApiAccessor.require() throws ApiNotReadyException instead.
Warning
Do not call access() in your plugin's constructor or onLoad(). Ecstacy may not be enabled
yet. Resolve it in onEnable(), or lazily on first use.
Server
Server server = api.server();
UUID licence = server.uuid();
String name = server.name(); // settings.yml name:
String mcVersion = server.version(); // "1.21.4"
double tps = server.tps();
ServerPlatform p = server.platform(); // PAPER, FOLIA, SPIGOT, MINESTOM, PURPUR, PUFFERFISH
String os = server.os(); // "linux x86_64"
ConnectionState s = server.state();
server.reload(); // same as /ecstacy reloadConnectionState is one of CONNECTED, DISCONNECTED, RECONNECTING, FAILED or DISABLED.
DISABLED means the plugin is off or the licence was rejected. No further attempts will be made.
Repository
Repository repo = api.repo();
EcstacyUser user = repo.user(uuid); // null if offline or untracked
Collection<EcstacyUser> all = repo.online();
int count = repo.onlineCount();The cloud-backed lookups are asynchronous:
repo.profile(uuid).thenAccept(profile -> { /* reputation + fingerprint */ });
repo.reputation(uuid).thenAccept(rep -> { /* rep.rank(): TRUSTED / WATCH / HIGH_RISK */ });
repo.flags(uuid).thenAccept(flags -> { /* max 100 */ });
repo.logs(uuid).thenAccept(logs -> { /* max 100 */ });Caution
profile, reputation, flags and logs contact the cloud and are rate-limited. Never call
them per tick, per packet, or in a loop over the online players. Cache what you need on join.
Check state can be read and written:
boolean on = repo.enabled(Check.CrystalAura);
repo.enable(Check.CrystalAura, false); // persisted locally and synced to the cloudEcstacyUser
EcstacyUser user = repo.user(uuid);
user.uuid(); user.name(); user.entityId();
user.ping(); user.protocol(); user.locale(); user.bedrock();
user.vl("CrystalAura"); // current violation level, -1 if untracked
user.maxVl("CrystalAura"); // configured threshold, -1 if untracked
user.client(); // ClientVersion. Nullable until resolved
user.exempt(); // exempt from all checks?
user.exempt(true);
user.kick("reason");
user.ban("reason");Platform-specific subtypes expose the native player object:
if (user instanceof BukkitUser bukkit) {
org.bukkit.entity.Player player = bukkit.player();
}Events
api.events().listen(FlagEvent.class, event ->
getLogger().info(event.user().name() + " flagged for " + event.checkName()));With a priority, where 0 runs first and 1000 last (default 500):
RegisteredListener handle = api.events().listen(PunishEvent.class, event -> { /* ... */ }, 100);
api.events().ignore(handle);The full event catalogue, including which events are cancellable and what cancelling actually does, is on Events.
A complete plugin
Everything above, assembled: resolve the API on enable, degrade quietly when it is absent, cache the expensive lookup on join, and act on flags.
public final class ExamplePlugin extends JavaPlugin implements Listener {
private EcstacyApi ecstacy;
private final Map<UUID, Rank> ranks = new ConcurrentHashMap<>();
@Override
public void onEnable() {
// Not onLoad(), and not the constructor - Ecstacy may not be enabled yet.
EcstacyApiAccessor.access().ifPresentOrElse(
api -> {
this.ecstacy = api;
api.events().listen(FlagEvent.class, this::onFlag);
getLogger().info("Hooked into Ecstacy on " + api.server().name());
},
() -> getLogger().info("Ecstacy not present - running without it."));
getServer().getPluginManager().registerEvents(this, this);
}
@EventHandler
public void onJoin(PlayerJoinEvent event) {
if (ecstacy == null) return;
UUID uuid = event.getPlayer().getUniqueId();
// Cloud-backed and rate-limited: once per join, never per tick.
ecstacy.repo().reputation(uuid)
.thenAccept(rep -> ranks.put(uuid, rep.rank()));
}
@EventHandler
public void onQuit(PlayerQuitEvent event) {
ranks.remove(event.getPlayer().getUniqueId());
}
private void onFlag(FlagEvent event) {
if (!event.punishable()) return;
getLogger().warning(event.user().name() + " reached "
+ event.vl() + "/" + event.maxVl() + " on " + event.checkName());
}
}name: ExamplePlugin
main: com.example.ExamplePlugin
api-version: '1.21'
softdepend: [EcstacyAC-Spigot]softdepend, not depend. Your plugin should load on a server without the anticheat, and
access() returning empty is the supported way to find that out.
Cost of each call
The split that matters is local versus cloud-backed.
| Call | Cost | Safe to call |
|---|---|---|
server(), repo(), events() |
Field access | Anywhere |
server().name(), .tps(), .platform(), .state() |
Local | Anywhere |
repo().user(), .online(), .onlineCount() |
Local map lookup | Anywhere |
user.vl(), .maxVl(), .ping(), .exempt() |
Local | Anywhere |
repo().enabled(Check) |
Local | Anywhere |
repo().enable(Check, …) |
Persists to disk and syncs to the cloud | On an operator action, not on a timer |
repo().profile(), .reputation(), .flags(), .logs() |
Network, rate-limited, returns a future | Once per join, or on an explicit command |
The rule that catches people: never loop repo().reputation(…) over repo().online(). On a
full server that is one rate-limited network call per player, every time the loop runs. Cache it
on join and drop it on quit, as the example above does.
Version compatibility
The artifact is compileOnly / provided, so the implementation you run against is whatever
version of the anticheat is installed. Not the version you compiled with. Two practical
consequences:
- Do not shade it. Bundling
ecstacy-api-bukkitinto your jar puts a second copy of the interfaces on the classpath and the cast fails at runtime. - New methods appear before you compile against them. Compile against the oldest API version you intend to support; a newer anticheat still satisfies it.
Types marked @ComingSoon are declared and stable enough to compile against, but are not fired
or populated yet. MitigateEvent is the current example. Compiling against one is safe;
depending on it firing is not.
Common mistakes
| Symptom | Cause |
|---|---|
access() always empty |
Called in onLoad() or the constructor. Resolve in onEnable() |
ApiNotAvailableException |
The licence plan does not include the API. SMP or Network required |
NoClassDefFoundError at runtime |
The dependency was implementation, or the jar was shaded |
repo.user(uuid) returns null |
The player is offline or untracked. It is not an error |
| Rate-limit errors under load | A cloud-backed lookup is being called per tick or per player |
user.client() is null |
The client version has not resolved yet. It resolves shortly after join |
The HTTP alternative
For anything running outside the Minecraft server. A Discord bot, a web panel, monitoring. Use the HTTP API instead. It reads the same data over the network, with a rate limit and a 30-second cache, and needs no plugin.