Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,8 @@ Requires being logged in as `th0rgal` or having access to the `lfglabs` scope.
- **Build**: `./gradlew build`
- **Modules**: `core/` (shared logic), `bukkit/` (Paper/Spigot entry)
- **Version**: Set in `plugin/gradle.properties`
- **PacketEvents**: Downloaded automatically at runtime via [Hopper](https://github.com/oraxen/hopper)
- **PacketEvents**: Downloaded and loaded automatically at runtime via [Hopper](https://github.com/oraxen/hopper) - no restart required
- **Hopper**: Runtime dependency downloader (shaded into JAR, relocated)
- **Note**: First server start downloads PacketEvents and may require a restart

### API (`api/`)
- **Database**: PostgreSQL (schema in `api/schema.sql`)
Expand Down
7 changes: 3 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,9 @@ AsyncAnticheat keeps detection off-server: the plugin captures packets and strea

1. Download the latest JAR from [Releases](https://github.com/oraxen/asyncanticheat/releases)
2. Place in your server's `plugins/` folder
3. Start the server - [PacketEvents](https://github.com/retrooper/packetevents) will be downloaded automatically via [Hopper](https://github.com/oraxen/hopper)
4. Restart if prompted (only on first install when PacketEvents is downloaded)
5. Configure `plugins/AsyncAnticheat/config.yml` with your API key
6. View detections at [asyncanticheat.com/dashboard](https://asyncanticheat.com/dashboard)
3. Start the server - [PacketEvents](https://github.com/retrooper/packetevents) will be downloaded and loaded automatically via [Hopper](https://github.com/oraxen/hopper) (no restart required!)
4. Configure `plugins/AsyncAnticheat/config.yml` with your API key
5. View detections at [asyncanticheat.com/dashboard](https://asyncanticheat.com/dashboard)

Full setup guide: [asyncanticheat.com/docs](https://asyncanticheat.com/docs)

Expand Down
4 changes: 2 additions & 2 deletions plugin/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ project(":bukkit") {
// PacketEvents (compileOnly - downloaded at runtime via Hopper)
compileOnly("com.github.retrooper:packetevents-spigot:2.7.0")

// Hopper - runtime dependency downloader (paper module includes bukkit)
implementation("md.thomas.hopper:hopper-paper:1.4.1")
// Hopper - runtime dependency downloader
implementation("md.thomas.hopper:hopper-bukkit:1.4.1")

// bStats - plugin metrics
implementation("org.bstats:bstats-bukkit:3.0.2")
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package md.thomas.asyncanticheat.bukkit;

import md.thomas.hopper.Dependency;
import md.thomas.hopper.FailurePolicy;
import md.thomas.hopper.LogLevel;
import md.thomas.hopper.bukkit.BukkitHopper;
import md.thomas.hopper.version.UpdatePolicy;
import org.bukkit.Bukkit;
import org.bukkit.plugin.Plugin;
import org.jetbrains.annotations.NotNull;

import java.io.File;
import java.util.logging.Logger;
import java.util.regex.Pattern;

/**
* Handles automatic downloading of PacketEvents using Hopper.
* <p>
* Downloaded plugins are automatically loaded at runtime without requiring a server restart.
*/
public final class AacHopper {

private static boolean downloadComplete = false;
private static boolean requiresRestart = false;
private static boolean enabled = true;

// Matches: packetevents.jar, packetevents-spigot-2.7.0.jar, PacketEvents-2.7.0.jar
private static final Pattern PACKETEVENTS_PATTERN = Pattern.compile(
"(?i)^packetevents([-_][\\w.-]*)?\\.jar$"
);

private AacHopper() {}

/**
* Registers PacketEvents dependency with Hopper.
* Should be called in the plugin constructor.
*
* @param plugin the plugin instance
*/
public static void register(@NotNull Plugin plugin) {
Logger logger = plugin.getLogger();

// Check if auto-download is disabled via system property (config not loaded in constructor)
if (Boolean.getBoolean("asyncanticheat.skipDependencyDownload")) {
enabled = false;
logger.info("Auto-download of dependencies is disabled");
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Static enabled flag not reset on plugin reload

The static enabled field is initialized to true but only set to false when the skipDependencyDownload system property is true. If the plugin is reloaded without a server restart (e.g., via a plugin manager), and the property was previously set to true but is now false/unset, enabled remains false from the previous load. This causes dependency downloading to be incorrectly skipped because the register() method never resets enabled back to true when the property is false.

Fix in Cursor Fix in Web


BukkitHopper.register(plugin, deps -> {
// Check if PacketEvents is already installed
boolean hasPacketEvents = pluginJarExists(PACKETEVENTS_PATTERN);

if (!hasPacketEvents) {
// Primary source: Modrinth (auto-detects platform)
deps.require(Dependency.modrinth("packetevents")
.name("PacketEvents")
.minVersion("2.7.0")
.updatePolicy(UpdatePolicy.MINOR)
.onFailure(FailurePolicy.FAIL)
.build());

// Fallback source: GitHub releases
deps.require(Dependency.github("retrooper/packetevents")
.name("PacketEvents")
.minVersion("2.7.0")
.assetPattern("*-spigot-*.jar")
.updatePolicy(UpdatePolicy.MINOR)
.onFailure(FailurePolicy.WARN_SKIP)
.build());
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GitHub fallback unreachable when Modrinth download fails

The dependency registration intends to use GitHub as a fallback for Modrinth, but this won't work as coded. The Modrinth dependency uses FailurePolicy.FAIL which causes the entire download process to fail immediately if Modrinth is unreachable or fails. The GitHub dependency marked as "Fallback source" with FailurePolicy.WARN_SKIP would never be tried in that scenario. For proper fallback behavior, the Modrinth dependency would need WARN_SKIP so the process continues to try GitHub if Modrinth fails.

Fix in Cursor Fix in Web

});
}

/**
* Downloads all registered dependencies and automatically loads them.
* Should be called in the plugin's onLoad() method.
*
* @param plugin the plugin instance
* @return true if all dependencies are satisfied and loaded
*/
public static boolean download(@NotNull Plugin plugin) {
if (!enabled) {
downloadComplete = true;
return true;
}

Logger logger = plugin.getLogger();
BukkitHopper.DownloadAndLoadResult result = BukkitHopper.downloadAndLoad(plugin, LogLevel.QUIET);

downloadComplete = true;
requiresRestart = !result.noRestartRequired();

if (requiresRestart) {
logger.warning("Some dependencies require a server restart to load:");
for (var failed : result.loadResult().failed()) {
logger.warning(" - " + failed.path().getFileName() + ": " + failed.error());
}
}

return !requiresRestart;
}

/**
* @return true if a restart is required to load newly downloaded dependencies
*/
public static boolean requiresRestart() {
return requiresRestart;
}

/**
* @return true if the download phase has completed
*/
public static boolean isDownloadComplete() {
return downloadComplete;
}

/**
* Checks if a plugin jar file exists in the plugins folder using a regex pattern.
*/
private static boolean pluginJarExists(Pattern pattern) {
File pluginsFolder = Bukkit.getPluginsFolder();
if (pluginsFolder == null || !pluginsFolder.exists()) {
return false;
}

File[] files = pluginsFolder.listFiles((dir, name) ->
pattern.matcher(name).matches()
);

return files != null && files.length > 0;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,57 +6,58 @@
import md.thomas.asyncanticheat.core.AcLogger;
import md.thomas.asyncanticheat.core.AsyncAnticheatService;
import org.bstats.bukkit.Metrics;
import org.bukkit.Bukkit;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.command.PluginCommand;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.plugin.java.JavaPlugin;
import org.jetbrains.annotations.NotNull;

import java.util.List;

public final class AsyncAnticheatBukkitPlugin extends JavaPlugin {

private AsyncAnticheatService service;
private RecordingManager recordingManager;
private BukkitPlayerExemptionTracker exemptionTracker;
private SchedulerUtil.ScheduledTask stateTask;
private Command registeredCommand;
private boolean packetEventsInitialized = false;

public AsyncAnticheatBukkitPlugin() {
// Register dependencies with Hopper for auto-download
AacHopper.register(this);
}

@Override
public void onLoad() {
// PacketEvents is downloaded by AacBootstrap before this class loads (Paper)
// or must be manually installed (Spigot)
// Download PacketEvents if needed (loads at runtime, no restart required)
AacHopper.download(this);

// Initialize PacketEvents
try {
PacketEvents.setAPI(SpigotPacketEventsBuilder.build(this));
PacketEvents.getAPI().load();
packetEventsInitialized = true;
} catch (Throwable t) {
getLogger().severe("[AsyncAnticheat] Failed to load PacketEvents: " + t.getMessage());
getLogger().severe("[AsyncAnticheat] PacketEvents is required. Install it from https://modrinth.com/plugin/packetevents");
getLogger().severe("Failed to load PacketEvents: " + t.getMessage());
getLogger().severe("PacketEvents is required. Install it from https://modrinth.com/plugin/packetevents");
}
}

@Override
public void onEnable() {
if (!packetEventsInitialized) {
getLogger().severe("[AsyncAnticheat] PacketEvents not available. Disabling plugin.");
getLogger().severe("PacketEvents not available. Disabling plugin.");
getServer().getPluginManager().disablePlugin(this);
return;
}

final AcLogger logger = new BukkitLogger(getLogger());
service = new AsyncAnticheatService(getDataFolder(), logger);

// Initialize exemption tracker with config
// Tracks player states like creative mode, flying, dead, etc. based on NCP patterns
exemptionTracker = new BukkitPlayerExemptionTracker(service.getConfig().getExemptionConfig());
getServer().getPluginManager().registerEvents(exemptionTracker, this);

// Initialize PacketEvents (load was called in onLoad)
if (packetEventsInitialized) {
try {
Expand All @@ -66,14 +67,14 @@ public void onEnable() {
PacketListenerPriority.LOW
);
} catch (Throwable t) {
logger.error("[AsyncAnticheat] Failed to initialize PacketEvents (Bukkit).", t);
logger.error("Failed to initialize PacketEvents.", t);
packetEventsInitialized = false;
}
}

// Initialize recording manager for in-game cheat recording
recordingManager = new RecordingManager(this, service.getConfig(), service.getServerId());

// Ensure recordings are stopped immediately on logout
getServer().getPluginManager().registerEvents(new Listener() {
@EventHandler
Expand All @@ -85,7 +86,6 @@ public void onQuit(PlayerQuitEvent event) {
}, this);

// Register main /aac command with subcommands
// Use CommandMap directly for Paper plugin compatibility
registerCommand();

// Initialize bStats metrics (plugin ID: 20187)
Expand Down Expand Up @@ -131,11 +131,6 @@ public void onDisable() {
recordingManager.stopAll();
recordingManager = null;
}
// Unregister command to prevent leak on reload
if (registeredCommand != null) {
registeredCommand.unregister(Bukkit.getCommandMap());
registeredCommand = null;
}
if (service != null) {
service.stop();
service = null;
Expand All @@ -155,32 +150,11 @@ public BukkitPlayerExemptionTracker getExemptionTracker() {
private void registerCommand() {
final BukkitMainCommand mainCmd = new BukkitMainCommand(service, recordingManager);

// Try Spigot-style registration first (plugin.yml defines the command)
// This works on Spigot servers where getCommand() returns the registered command
// Register command from plugin.yml
PluginCommand pluginCmd = getCommand("aac");
if (pluginCmd != null) {
pluginCmd.setExecutor(mainCmd);
pluginCmd.setTabCompleter(mainCmd);
return;
}

// Fall back to CommandMap registration for Paper plugins
// Paper plugins use paper-plugin.yml which doesn't support getCommand()
registeredCommand = new Command("aac", "AsyncAnticheat main command", "/aac [token|record|status]", List.of("asyncanticheat")) {
@Override
public boolean execute(@NotNull CommandSender sender, @NotNull String commandLabel, @NotNull String[] args) {
return mainCmd.onCommand(sender, this, commandLabel, args);
}

@Override
public @NotNull List<String> tabComplete(@NotNull CommandSender sender, @NotNull String alias, @NotNull String[] args) {
List<String> result = mainCmd.onTabComplete(sender, this, alias, args);
return result != null ? result : List.of();
}
};

Bukkit.getCommandMap().register("asyncanticheat", registeredCommand);
}
}


34 changes: 0 additions & 34 deletions plugin/bukkit/src/main/resources/paper-plugin.yml

This file was deleted.

2 changes: 1 addition & 1 deletion plugin/gradle.properties
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
pluginVersion=0.3.0
pluginVersion=0.4.1


Loading