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 empty Optional and EcstacyApiAccessor.require() throws ApiNotAvailableException (a subclass of ApiNotReadyException, so existing handling still compiles). Everything else in the plugin is unaffected.

Dependency

build.gradle
repositories {
    maven { url = 'https://www.ecstacy.ac/repo' }
}

dependencies {
    compileOnly 'ac.ecstacy:ecstacy-api-bukkit:3.0.0'
}
pom.xml
<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

JAVA
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

JAVA
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 reload

ConnectionState 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

JAVA
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:

JAVA
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:

JAVA
boolean on = repo.enabled(Check.CrystalAura);
repo.enable(Check.CrystalAura, false);   // persisted locally and synced to the cloud

EcstacyUser

JAVA
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:

JAVA
if (user instanceof BukkitUser bukkit) {
    org.bukkit.entity.Player player = bukkit.player();
}

Events

JAVA
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):

JAVA
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.

ExamplePlugin.java
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());
    }
}
plugin.yml
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-bukkit into 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.

Last updated