Author SHA1 Message Date
MineTec 6bfb14db2b added LoginLog appliance recording login attempts with IP 2026-10-14 14:38:54 +02:00
MineTec e3de258124 added shared database access and file based migrations to core 2026-10-14 14:38:54 +02:00
MineTec d08fd927dc Merge pull request 'added Anti Player Loot despawn' (#16) from feature/AntiPlayerLootDespawn into master
Reviewed-on: #16
2026-10-14 10:31:28 +00:00
MineTec 067a6f41dc Merge branch 'master' into feature/AntiPlayerLootDespawn 2026-10-14 12:31:17 +02:00
MineTec 5b7910b697 Renamed method AboveMaxAge to isItemAboveMaxAge 2026-10-14 12:31:05 +02:00
jannis 1180af64a1 moved logic from listener to appliance 2026-10-14 01:23:32 +02:00
jannis 73fa6649af fixed issues in AntiPlayerLootDespawn 2026-10-14 00:14:08 +02:00
MineTec 9dbee7d63b Merge pull request 'added New Year Firework' (#15) from feature/NewYearFirework into master
Reviewed-on: #15
2026-10-13 21:58:03 +00:00
MineTec 2c8c4fbf87 Merge branch 'master' into feature/NewYearFirework 2026-10-13 21:57:52 +00:00
jannis 252758797d fixed issues in NewYearFirework and QuarzRecipe 2026-10-13 22:52:42 +02:00
MineTec 46ee734bbc Refactored game rule handling in ProjectStart to use a unified GameRuleSetting record, eliminating redundant methods and improving code clarity. 2026-10-13 22:41:14 +02:00
jannis a1ab06a80d fixed game rules 2026-10-13 22:29:33 +02:00
MineTec 1b79e35bd2 Merge branch 'master' into feature/NewYearFirework 2026-10-13 20:20:51 +00:00
MineTec c3b30e4329 Merge branch 'master' into feature/AntiPlayerLootDespawn 2026-10-13 20:05:19 +00:00
jannis cf3fd2f38f changed old gamerules to new and added quarz recipe 2026-10-13 21:41:00 +02:00
jannis c119913071 added Anti Player Loot despawn 2026-10-08 22:46:05 +02:00
MineTecandClaude Opus 5.5 7d0a28b015 updated to Paper 26.2, Java 25 and Gradle 9.6.1
- paper-api 26.2.build.129-stable (all modules), Java toolchain 25, shadow 9.6.1
- fixed NoSuchFieldError on join/quit/chat: Adventure 5 ClickEvent API
  (ClickEvent.suggestCommand / openUrl instead of ClickEvent.Action constants)
- fixed NBTComponent generics in AntiFormattedBook
- replaced javax.annotation with org.jetbrains.annotations (no longer provided by paper-api)
- local.gradle.example: Gradle 9 compatible (archiveFile, providers.exec)

The previous state is tagged as paper-1.21.10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:44:13 +02:00
jannis 62a42e561e added New Year Firework 2026-10-06 20:48:33 +02:00
30 changed files with 751 additions and 34 deletions

No files matched your search

+2 -2
View File
@@ -1,6 +1,6 @@
plugins {
id 'java'
id 'com.gradleup.shadow' version "8.3.5"
id 'com.gradleup.shadow' version "9.6.1"
}
allprojects {
@@ -29,7 +29,7 @@ subprojects {
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
languageVersion = JavaLanguageVersion.of(25)
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
dependencies {
implementation project(':core')
compileOnly 'io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT'
compileOnly 'io.papermc.paper:paper-api:26.2.build.129-stable'
compileOnly 'org.geysermc.floodgate:api:2.2.4-SNAPSHOT'
implementation 'org.apache.httpcomponents:httpclient:4.5.14'
implementation 'com.sparkjava:spark-core:2.9.4'
@@ -25,7 +25,7 @@ public class ChatMessages extends Appliance {
public Component addReportActions(Component message, String username) {
return message
.hoverEvent(HoverEvent.showText(Component.text("Klicke, um diesen Spieler zu reporten").color(NamedTextColor.GOLD)))
.clickEvent(ClickEvent.clickEvent(ClickEvent.Action.SUGGEST_COMMAND, String.format("/report %s ", username)));
.clickEvent(ClickEvent.suggestCommand(String.format("/report %s ", username)));
}
@Override
@@ -85,7 +85,7 @@ public class Report extends Appliance {
.append(
Component
.text(createdReport.data().url(), NamedTextColor.GRAY)
.clickEvent(ClickEvent.clickEvent(ClickEvent.Action.OPEN_URL, createdReport.data().url()))
.clickEvent(ClickEvent.openUrl(createdReport.data().url()))
)
.appendNewline()
.appendNewline()
@@ -32,7 +32,7 @@ public class AntiFormattedBook extends Appliance {
if (component instanceof TextComponent tc && tc.content().indexOf(SECTION) >= 0) return true;
if (component instanceof NBTComponent<?, ?> nbt) {
if (component instanceof NBTComponent<?> nbt) {
if (nbt.separator() != null && this.hasFormattingDeep(nbt.separator())) return true;
}
@@ -0,0 +1,31 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.loginLog;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.net.InetAddress;
import java.time.Instant;
import java.util.UUID;
record LoginAttempt(
@NotNull Instant at,
@NotNull UUID uuid,
@NotNull String name,
@NotNull String ip,
boolean bedrock,
@NotNull String stage,
@NotNull String result,
@Nullable String reason
) {
static final String STAGE_PRE_LOGIN = "PRE_LOGIN";
static final String STAGE_LOGIN = "LOGIN";
static final String RESULT_ABORTED = "ABORTED";
static String ipOf(@Nullable InetAddress address) {
return address == null ? "unknown" : address.getHostAddress();
}
LoginAttempt withOutcome(@NotNull String stage, @NotNull String result, @Nullable String reason) {
return new LoginAttempt(this.at, this.uuid, this.name, this.ip, this.bedrock, stage, result, reason);
}
}
@@ -0,0 +1,147 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.loginLog;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import eu.mhsl.craftattack.spawn.core.database.Database;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import org.bukkit.Bukkit;
import org.bukkit.event.Listener;
import org.bukkit.scheduler.BukkitTask;
import org.geysermc.floodgate.api.FloodgateApi;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.net.InetAddress;
import java.sql.PreparedStatement;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Records every login attempt (allowed or not) with its IP address in the table login_attempts
* (schema: resources/migrations/loginLog). Basis for detecting ban evasion.
* Never blocks or delays a login: writes run on the database thread, failures are only logged.
*/
public class LoginLog extends Appliance {
private static final int MAX_REASON_LENGTH = 255;
private static final Duration PENDING_TIMEOUT = Duration.ofSeconds(60);
private static final long TICKS_PER_MINUTE = 20L * 60;
private final Map<UUID, LoginAttempt> pendingAttempts = new ConcurrentHashMap<>();
private final List<BukkitTask> tasks = new ArrayList<>();
private Database database;
private int retentionMonths;
public LoginLog() {
super("loginLog");
}
@Override
public void onEnable() {
this.database = Main.instance().getDatabase();
if(!this.database.isAvailable()) {
Main.logger().warning("LoginLog: no database configured, login attempts are not recorded");
return;
}
this.retentionMonths = this.localConfig().getInt("retentionMonths", 16);
this.tasks.add(Bukkit.getScheduler().runTaskTimerAsynchronously(
Main.instance(), this::flushStaleAttempts, TICKS_PER_MINUTE, TICKS_PER_MINUTE
));
this.tasks.add(Bukkit.getScheduler().runTaskTimerAsynchronously(
Main.instance(), this::deleteExpired, TICKS_PER_MINUTE, TICKS_PER_MINUTE * 60 * 24
));
}
@Override
public void onDisable() {
this.tasks.forEach(BukkitTask::cancel);
}
void onPreLogin(UUID uuid, String name, @Nullable InetAddress address, String result, @Nullable Component kickMessage) {
if(!this.database.isAvailable()) return;
LoginAttempt attempt = new LoginAttempt(
Instant.now(), uuid, name, LoginAttempt.ipOf(address), this.isBedrock(uuid),
LoginAttempt.STAGE_PRE_LOGIN, result, plainText(kickMessage)
);
// allowed attempts are written after the login event, so they carry the final result
if(kickMessage == null) this.pendingAttempts.put(uuid, attempt);
else this.write(attempt);
}
void onLogin(UUID uuid, String name, @Nullable InetAddress address, String result, @Nullable Component kickMessage) {
if(!this.database.isAvailable()) return;
LoginAttempt pending = this.pendingAttempts.remove(uuid);
LoginAttempt attempt = pending != null
? pending
: new LoginAttempt(Instant.now(), uuid, name, LoginAttempt.ipOf(address), this.isBedrock(uuid), "", "", null);
this.write(attempt.withOutcome(LoginAttempt.STAGE_LOGIN, result, plainText(kickMessage)));
}
private void flushStaleAttempts() {
Instant threshold = Instant.now().minus(PENDING_TIMEOUT);
this.pendingAttempts.values().removeIf(attempt -> {
if(attempt.at().isAfter(threshold)) return false;
this.write(attempt.withOutcome(LoginAttempt.STAGE_PRE_LOGIN, LoginAttempt.RESULT_ABORTED, null));
return true;
});
}
private void write(LoginAttempt attempt) {
String sql = "INSERT INTO login_attempts (at, uuid, name, ip, bedrock, stage, result, reason) VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
this.database.execute("recording login attempt of " + attempt.name(), connection -> {
try(PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setObject(1, LocalDateTime.ofInstant(attempt.at(), ZoneOffset.UTC));
statement.setString(2, attempt.uuid().toString());
statement.setString(3, attempt.name());
statement.setString(4, attempt.ip());
statement.setBoolean(5, attempt.bedrock());
statement.setString(6, attempt.stage());
statement.setString(7, attempt.result());
statement.setString(8, attempt.reason());
statement.executeUpdate();
}
});
}
private void deleteExpired() {
String sql = "DELETE FROM login_attempts WHERE at < UTC_TIMESTAMP() - INTERVAL ? MONTH";
this.database.execute("deleting expired login attempts", connection -> {
try(PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setInt(1, this.retentionMonths);
int deleted = statement.executeUpdate();
if(deleted > 0) Main.logger().info(String.format("LoginLog: deleted %d expired login attempts", deleted));
}
});
}
private boolean isBedrock(UUID uuid) {
try {
return FloodgateApi.getInstance().isFloodgatePlayer(uuid);
} catch(Throwable e) {
return false;
}
}
private static @Nullable String plainText(@Nullable Component component) {
if(component == null) return null;
String text = PlainTextComponentSerializer.plainText().serialize(component)
.strip()
.replaceAll("\\s*\\n\\s*", " | ");
return text.length() > MAX_REASON_LENGTH ? text.substring(0, MAX_REASON_LENGTH) : text;
}
@Override
protected @NotNull List<Listener> listeners() {
return List.of(new LoginLogListener());
}
}
@@ -0,0 +1,34 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.loginLog;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.player.AsyncPlayerPreLoginEvent;
import org.bukkit.event.player.PlayerLoginEvent;
/**
* Listens on MONITOR priority, so the final result of all other checks (whitelist, bans, maintenance, ...) is recorded.
*/
class LoginLogListener extends ApplianceListener<LoginLog> {
@EventHandler(priority = EventPriority.MONITOR)
public void onPreLogin(AsyncPlayerPreLoginEvent event) {
this.getAppliance().onPreLogin(
event.getUniqueId(),
event.getName(),
event.getAddress(),
event.getLoginResult().name(),
event.getLoginResult() == AsyncPlayerPreLoginEvent.Result.ALLOWED ? null : event.kickMessage()
);
}
@EventHandler(priority = EventPriority.MONITOR)
public void onLogin(PlayerLoginEvent event) {
this.getAppliance().onLogin(
event.getPlayer().getUniqueId(),
event.getPlayer().getName(),
event.getAddress(),
event.getResult().name(),
event.getResult() == PlayerLoginEvent.Result.ALLOWED ? null : event.kickMessage()
);
}
}
@@ -0,0 +1,15 @@
-- Every login attempt (allowed or not) with the player's IP, basis for detecting ban evasion.
CREATE TABLE login_attempts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
at DATETIME(3) NOT NULL COMMENT 'UTC',
uuid CHAR(36) NOT NULL,
name VARCHAR(64) NOT NULL,
ip VARCHAR(45) NOT NULL,
bedrock BOOLEAN NOT NULL,
stage VARCHAR(16) NOT NULL COMMENT 'PRE_LOGIN or LOGIN',
result VARCHAR(32) NOT NULL COMMENT 'Bukkit login result, or ABORTED',
reason VARCHAR(255) NULL COMMENT 'kick message as plain text',
KEY idx_at (at),
KEY idx_uuid (uuid),
KEY idx_ip (ip)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+1 -1
View File
@@ -1,5 +1,5 @@
dependencies {
compileOnly 'io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT'
compileOnly 'io.papermc.paper:paper-api:26.2.build.129-stable'
compileOnly 'org.geysermc.floodgate:api:2.2.4-SNAPSHOT'
implementation 'org.apache.httpcomponents:httpclient:4.5.14'
implementation 'com.sparkjava:spark-core:2.9.4'
@@ -4,6 +4,7 @@ import eu.mhsl.craftattack.spawn.core.api.client.RepositoryLoader;
import eu.mhsl.craftattack.spawn.core.api.server.HttpServer;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import eu.mhsl.craftattack.spawn.core.config.Configuration;
import eu.mhsl.craftattack.spawn.core.database.Database;
import org.bukkit.Bukkit;
import org.bukkit.event.HandlerList;
import org.bukkit.plugin.java.JavaPlugin;
@@ -23,6 +24,7 @@ public final class Main extends JavaPlugin {
private List<Appliance> appliances;
private RepositoryLoader repositoryLoader;
private Database database;
private Reflections reflections;
@Override
@@ -42,6 +44,8 @@ public final class Main extends JavaPlugin {
Configuration.readConfig();
List<String> disabledAppliances = Configuration.pluginConfig.getStringList("disabledAppliances");
this.database = new Database(Configuration.cfg.getConfigurationSection("database"));
Main.logger().info("Initializing reflections...");
this.reflections = new Reflections(projectPackage);
@@ -94,6 +98,8 @@ public final class Main extends JavaPlugin {
HandlerList.unregisterAll(this);
Bukkit.getScheduler().cancelTasks(this);
Main.logger().info("Disabled " + this.appliances.size() + " appliances!");
if(this.database != null) this.database.close();
}
public Appliance restartAppliance(Class<? extends Appliance> applianceClass) {
@@ -138,6 +144,10 @@ public final class Main extends JavaPlugin {
return this.appliances;
}
public Database getDatabase() {
return this.database;
}
public RepositoryLoader getRepositoryLoader() {
return this.repositoryLoader;
}
@@ -4,6 +4,7 @@ import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.api.client.Repository;
import eu.mhsl.craftattack.spawn.core.api.server.HttpServer;
import eu.mhsl.craftattack.spawn.core.config.Configuration;
import eu.mhsl.craftattack.spawn.core.database.Migrations;
import org.bukkit.Bukkit;
import org.bukkit.command.PluginCommand;
import org.bukkit.configuration.ConfigurationSection;
@@ -107,6 +108,7 @@ public abstract class Appliance {
}
public void enableSequence(JavaPlugin plugin) {
Migrations.apply(Main.instance().getDatabase(), this.migrationsName());
this.onEnable();
this.initialize(plugin);
}
@@ -116,6 +118,14 @@ public abstract class Appliance {
this.destruct(plugin);
}
/**
* Name of the folder containing this appliance's SQL migrations: {@code migrations/<name>/} in the resources.
*/
private String migrationsName() {
String name = this.getClass().getSimpleName();
return Character.toLowerCase(name.charAt(0)) + name.substring(1);
}
protected <T extends Appliance> T queryAppliance(Class<T> clazz) {
return Main.instance().getAppliance(clazz);
}
@@ -0,0 +1,131 @@
package eu.mhsl.craftattack.spawn.core.database;
import eu.mhsl.craftattack.spawn.core.Main;
import org.bukkit.configuration.ConfigurationSection;
import org.jetbrains.annotations.Nullable;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
/**
* Shared MariaDB access for all appliances. Configured in the "database" section of the config.
* All statements run on a single background thread in submission order, so callers (e.g. login events)
* are never blocked. Without configuration the database is unavailable and all calls are ignored.
*/
public class Database {
@FunctionalInterface
public interface SqlAction {
void run(Connection connection) throws SQLException;
}
@FunctionalInterface
public interface SqlQuery<T> {
T run(Connection connection) throws SQLException;
}
private final @Nullable String jdbcUrl;
private final String user;
private final String password;
private final ThreadPoolExecutor executor = new ThreadPoolExecutor(
1, 1, 0, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(1000),
runnable -> new Thread(runnable, "Spawn-Database"),
(runnable, executor) -> Main.logger().warning("Database queue full, dropping statement")
);
private @Nullable Connection connection;
public Database(@Nullable ConfigurationSection config) {
String host = config == null ? "" : config.getString("host", "");
if(host.isBlank()) {
this.jdbcUrl = null;
this.user = "";
this.password = "";
Main.logger().info("No database configured, database features are disabled");
return;
}
this.jdbcUrl = String.format(
"jdbc:mysql://%s:%d/%s?connectTimeout=3000&socketTimeout=10000&allowMultiQueries=true",
host,
config.getInt("port", 3306),
config.getString("name", "craftattack")
);
this.user = config.getString("user", "");
this.password = config.getString("password", "");
}
public boolean isAvailable() {
return this.jdbcUrl != null;
}
/**
* Runs the action in the background without waiting for it. Errors are logged.
*/
public void execute(String description, SqlAction action) {
if(!this.isAvailable()) return;
this.executor.execute(() -> {
try {
action.run(this.getConnection());
} catch(SQLException e) {
this.handleError(description, e);
}
});
}
/**
* Runs the query in the background. The future fails if the database is unavailable or the query fails.
*/
public <T> CompletableFuture<T> query(SqlQuery<T> query) {
CompletableFuture<T> future = new CompletableFuture<>();
if(!this.isAvailable()) {
future.completeExceptionally(new IllegalStateException("No database configured"));
return future;
}
this.executor.execute(() -> {
try {
future.complete(query.run(this.getConnection()));
} catch(SQLException e) {
this.closeConnection();
future.completeExceptionally(e);
}
});
return future;
}
public void close() {
this.executor.shutdown();
try {
if(!this.executor.awaitTermination(5, TimeUnit.SECONDS))
Main.logger().warning("Database: pending statements were not finished before shutdown");
} catch(InterruptedException e) {
Thread.currentThread().interrupt();
}
this.closeConnection();
}
private Connection getConnection() throws SQLException {
if(this.connection == null || !this.connection.isValid(2))
this.connection = DriverManager.getConnection(this.jdbcUrl, this.user, this.password);
return this.connection;
}
private void closeConnection() {
if(this.connection == null) return;
try {
this.connection.close();
} catch(SQLException ignored) {
}
this.connection = null;
}
private void handleError(String description, SQLException e) {
Main.logger().log(Level.WARNING, String.format("Database: %s failed: %s", description, e.getMessage()));
this.closeConnection();
}
}
@@ -0,0 +1,138 @@
package eu.mhsl.craftattack.spawn.core.database;
import eu.mhsl.craftattack.spawn.core.Main;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Applies SQL migration files shipped in the plugin jar.
* Files live in {@code migrations/<appliance>/NNN_description.sql} and run in ascending order of NNN.
* Applied versions are tracked per appliance in the table {@code schema_migrations}.
*/
public class Migrations {
private static final String DIRECTORY = "migrations/";
private static final Pattern FILE_PATTERN = Pattern.compile("^([^/]+)/(\\d+)_[^/]+\\.sql$");
private static Map<String, List<Migration>> migrationsByAppliance;
private record Migration(int version, String fileName, String path) {
}
/**
* Queues all pending migrations of the appliance on the database thread,
* so they run before any statement the appliance submits afterwards.
*/
public static void apply(Database database, String appliance) {
List<Migration> migrations = findMigrations().getOrDefault(appliance, List.of());
if(migrations.isEmpty()) return;
if(!database.isAvailable()) {
Main.logger().warning(String.format("Appliance '%s' has migrations, but no database is configured", appliance));
return;
}
database.execute("migrations of " + appliance, connection -> {
createMigrationTable(connection);
Set<Integer> applied = appliedVersions(connection, appliance);
for(Migration migration : migrations) {
if(applied.contains(migration.version())) continue;
Main.logger().info(String.format("Applying migration %s/%s", appliance, migration.fileName()));
try(Statement statement = connection.createStatement()) {
statement.execute(readResource(migration.path()));
}
markApplied(connection, appliance, migration);
}
});
}
private static synchronized Map<String, List<Migration>> findMigrations() {
if(migrationsByAppliance != null) return migrationsByAppliance;
Map<String, List<Migration>> migrations = new HashMap<>();
try(JarFile jar = new JarFile(pluginJar())) {
jar.stream()
.map(JarEntry::getName)
.filter(name -> name.startsWith(DIRECTORY))
.forEach(path -> {
Matcher matcher = FILE_PATTERN.matcher(path.substring(DIRECTORY.length()));
if(!matcher.matches()) return;
String fileName = path.substring(path.lastIndexOf('/') + 1);
migrations.computeIfAbsent(matcher.group(1), appliance -> new ArrayList<>())
.add(new Migration(Integer.parseInt(matcher.group(2)), fileName, path));
});
} catch(IOException e) {
throw new IllegalStateException("Could not read migrations from plugin jar", e);
}
migrations.values().forEach(list -> list.sort(Comparator.comparingInt(Migration::version)));
migrationsByAppliance = migrations;
return migrations;
}
private static void createMigrationTable(Connection connection) throws SQLException {
try(Statement statement = connection.createStatement()) {
statement.execute("""
CREATE TABLE IF NOT EXISTS schema_migrations (
appliance VARCHAR(64) NOT NULL,
version INT NOT NULL,
file_name VARCHAR(255) NOT NULL,
applied_at DATETIME NOT NULL DEFAULT UTC_TIMESTAMP(),
PRIMARY KEY (appliance, version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""");
}
}
private static Set<Integer> appliedVersions(Connection connection, String appliance) throws SQLException {
Set<Integer> versions = new HashSet<>();
try(PreparedStatement statement = connection.prepareStatement("SELECT version FROM schema_migrations WHERE appliance = ?")) {
statement.setString(1, appliance);
try(ResultSet result = statement.executeQuery()) {
while(result.next()) versions.add(result.getInt(1));
}
}
return versions;
}
private static void markApplied(Connection connection, String appliance, Migration migration) throws SQLException {
try(PreparedStatement statement = connection.prepareStatement("INSERT INTO schema_migrations (appliance, version, file_name) VALUES (?, ?, ?)")) {
statement.setString(1, appliance);
statement.setInt(2, migration.version());
statement.setString(3, migration.fileName());
statement.executeUpdate();
}
}
private static String readResource(String path) throws SQLException {
try(InputStream stream = Main.class.getClassLoader().getResourceAsStream(path)) {
if(stream == null) throw new SQLException("Migration file not found: " + path);
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
} catch(IOException e) {
throw new SQLException("Could not read migration file " + path, e);
}
}
private static File pluginJar() {
try {
return new File(Main.class.getProtectionDomain().getCodeSource().getLocation().toURI());
} catch(Exception e) {
throw new IllegalStateException("Could not locate plugin jar", e);
}
}
}
@@ -32,7 +32,7 @@ public class IteratorUtil {
}
public static void times(int times, Runnable callback) {
IntStream.range(0, times).forEach(value -> callback.run());
IntStream.range(0, times).forEach(_ -> callback.run());
}
public static <T> void iterateListInGlobal(int sectionStart, List<T> list, BiConsumer<Integer, T> callback) {
+10
View File
@@ -7,6 +7,16 @@ api:
secret: secret
baseurl: https://mhsl.eu/craftattack/api/
database:
host:
port: 3306
name: craftattack
user:
password:
loginLog:
retentionMonths: 16
worldMuseum:
uuid:
connect-server-name: worldmuseum
+1 -1
View File
@@ -2,7 +2,7 @@ dependencies {
implementation project(':core')
implementation project(':common')
compileOnly 'io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT'
compileOnly 'io.papermc.paper:paper-api:26.2.build.129-stable'
compileOnly 'org.geysermc.floodgate:api:2.2.4-SNAPSHOT'
implementation 'org.apache.httpcomponents:httpclient:4.5.14'
implementation 'com.sparkjava:spark-core:2.9.4'
@@ -0,0 +1,48 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.gameplay.newYearFirework;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import org.bukkit.Color;
import org.bukkit.FireworkEffect;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Firework;
import org.bukkit.entity.Player;
import org.bukkit.event.Listener;
import org.bukkit.inventory.meta.FireworkMeta;
import org.bukkit.persistence.PersistentDataType;
import org.jetbrains.annotations.NotNull;
import java.time.LocalDate;
import java.util.List;
import java.util.Locale;
public class NewYearFirework extends Appliance {
private static final String namespace = NewYearFirework.class.getSimpleName().toLowerCase(Locale.ROOT);
public static NamespacedKey fireworkKey = new NamespacedKey(namespace, "newyear_firework".toLowerCase());
public void spawnFirework(Player player) {
for (int i = 0; i < 5; i++) {
Firework firework = player.getWorld().spawn(player.getLocation(), Firework.class);
firework.getPersistentDataContainer().set(fireworkKey, PersistentDataType.BOOLEAN, true);
FireworkMeta fireworkMeta = firework.getFireworkMeta();
fireworkMeta.addEffect(FireworkEffect.builder()
.with(FireworkEffect.Type.BALL)
.withColor(Color.AQUA, Color.RED, Color.LIME, Color.ORANGE, Color.YELLOW)
.withFade(Color.FUCHSIA, Color.WHITE)
.trail(true)
.flicker(true)
.build()
);
fireworkMeta.setPower((int) (i % 3 + 0.25));
firework.setFireworkMeta(fireworkMeta);
}
}
public boolean shouldSpawnFirework() {
return LocalDate.now().getDayOfYear() == 1;
}
@Override
protected @NotNull List<Listener> listeners() {
return List.of(new NewYearFireworkListener());
}
}
@@ -0,0 +1,21 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.gameplay.newYearFirework;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import org.bukkit.entity.Firework;
import org.bukkit.event.EventHandler;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.player.PlayerJoinEvent;
class NewYearFireworkListener extends ApplianceListener<NewYearFirework> {
@EventHandler
public void onPlayerJoin(PlayerJoinEvent event) {
if(!this.getAppliance().shouldSpawnFirework()) return;
this.getAppliance().spawnFirework(event.getPlayer());
}
@EventHandler
public void onDamage(EntityDamageByEntityEvent event) {
if(!(event.getDamager() instanceof Firework firework)) return;
if(!firework.getPersistentDataContainer().has(NewYearFirework.fireworkKey)) return;
event.setCancelled(true);
}
}
@@ -0,0 +1,42 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.gameplay.quarzRecipe;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Player;
import org.bukkit.event.Listener;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.ShapelessRecipe;
import org.jetbrains.annotations.NotNull;
import java.util.List;
public class QuarzRecipe extends Appliance {
private NamespacedKey recipeKey;
@Override
public void onEnable() {
this.recipeKey = new NamespacedKey(Main.instance(), "quarz_block_to_quarz");
ShapelessRecipe recipe = new ShapelessRecipe(this.recipeKey, new ItemStack(Material.QUARTZ, 4));
recipe.addIngredient(Material.QUARTZ_BLOCK);
Bukkit.addRecipe(recipe);
Bukkit.getOnlinePlayers().forEach(this::unlock);
}
@Override
public void onDisable() {
Bukkit.removeRecipe(this.recipeKey);
}
public void unlock(Player player) {
player.discoverRecipe(this.recipeKey);
}
@Override
protected @NotNull List<Listener> listeners() {
return List.of(new QuarzRecipeListener());
}
}
@@ -0,0 +1,12 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.gameplay.quarzRecipe;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import org.bukkit.event.EventHandler;
import org.bukkit.event.player.PlayerJoinEvent;
class QuarzRecipeListener extends ApplianceListener<QuarzRecipe> {
@EventHandler
public void onPlayerJoin(PlayerJoinEvent event) {
this.getAppliance().unlock(event.getPlayer());
}
}
@@ -0,0 +1,45 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.metaGameplay.antiPlayerLootDespawn;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Item;
import org.bukkit.event.Listener;
import org.bukkit.inventory.ItemStack;
import org.bukkit.persistence.PersistentDataType;
import org.jetbrains.annotations.NotNull;
import java.util.List;
import java.util.Locale;
public class AntiPlayerLootDespawn extends Appliance {
private static final int extraCycles = 5;
private static final String namespace = AntiPlayerLootDespawnListener.class.getSimpleName().toLowerCase(Locale.ROOT);
public static NamespacedKey cyclesKey = new NamespacedKey(namespace, "item_deaths_cycles".toLowerCase());
public void markItems(List<ItemStack> item) {
item.forEach(itemStack ->
itemStack.editPersistentDataContainer(persistentDataContainer ->
persistentDataContainer.set(cyclesKey, PersistentDataType.INTEGER, extraCycles)
)
);
}
public boolean isItemAboveMaxAge(Item item) {
ItemStack itemStack = item.getItemStack();
int cycles = itemStack.getPersistentDataContainer()
.getOrDefault(AntiPlayerLootDespawn.cyclesKey, PersistentDataType.INTEGER, 0);
itemStack.editPersistentDataContainer(data ->
data.set(AntiPlayerLootDespawn.cyclesKey, PersistentDataType.INTEGER, cycles - 1)
);
item.setItemStack(itemStack);
return cycles > 0;
}
@Override
protected @NotNull List<Listener> listeners() {
return List.of(new AntiPlayerLootDespawnListener());
}
}
@@ -0,0 +1,19 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.metaGameplay.antiPlayerLootDespawn;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import org.bukkit.event.EventHandler;
import org.bukkit.event.entity.ItemDespawnEvent;
import org.bukkit.event.entity.PlayerDeathEvent;
class AntiPlayerLootDespawnListener extends ApplianceListener<AntiPlayerLootDespawn> {
@EventHandler
public void onDeath(PlayerDeathEvent event) {
this.getAppliance().markItems(event.getDrops());
}
@EventHandler
public void onItemDespawn(ItemDespawnEvent event) {
event.setCancelled(this.getAppliance().isItemAboveMaxAge(event.getEntity()));
}
}
@@ -20,7 +20,7 @@ import org.bukkit.event.Event;
import org.bukkit.event.Listener;
import org.jetbrains.annotations.NotNull;
import javax.annotation.Nullable;
import org.jetbrains.annotations.Nullable;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentSkipListMap;
@@ -26,10 +26,8 @@ import org.bukkit.inventory.ItemStack;
import org.jetbrains.annotations.NotNull;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import static java.util.Map.entry;
import static org.bukkit.Sound.MUSIC_DISC_LAVA_CHICKEN;
public class ProjectStart extends Appliance {
@@ -57,17 +55,23 @@ public class ProjectStart extends Appliance {
)
);
private final Map<GameRule<Boolean>, Boolean> gameRulesAfterStart = Map.ofEntries(
entry(GameRule.DO_DAYLIGHT_CYCLE, true),
entry(GameRule.DO_INSOMNIA, true),
entry(GameRule.DISABLE_RAIDS, false),
entry(GameRule.DO_FIRE_TICK, true),
entry(GameRule.DO_ENTITY_DROPS, true),
entry(GameRule.DO_PATROL_SPAWNING, true),
entry(GameRule.DO_TRADER_SPAWNING, true),
entry(GameRule.DO_WEATHER_CYCLE, true),
entry(GameRule.FALL_DAMAGE, true),
entry(GameRule.FIRE_DAMAGE, true)
private record GameRuleSetting<T>(GameRule<T> rule, T before, T after) {
void apply(boolean afterStart) {
IteratorUtil.worlds(world -> world.setGameRule(this.rule, afterStart ? this.after : this.before));
}
}
private final List<GameRuleSetting<?>> gameRules = List.of(
new GameRuleSetting<>(GameRules.ADVANCE_TIME, false, true),
new GameRuleSetting<>(GameRules.SPAWN_PHANTOMS, false, true),
new GameRuleSetting<>(GameRules.RAIDS, false, true),
new GameRuleSetting<>(GameRules.ENTITY_DROPS, false, true),
new GameRuleSetting<>(GameRules.SPAWN_PATROLS, false, true),
new GameRuleSetting<>(GameRules.SPAWN_WANDERING_TRADERS, false, true),
new GameRuleSetting<>(GameRules.ADVANCE_WEATHER, false, true),
new GameRuleSetting<>(GameRules.FALL_DAMAGE, false, true),
new GameRuleSetting<>(GameRules.FIRE_DAMAGE, false, true),
new GameRuleSetting<>(GameRules.FIRE_SPREAD_RADIUS_AROUND_PLAYER, 0, -1)
);
public ProjectStart() {
@@ -76,7 +80,7 @@ public class ProjectStart extends Appliance {
this.countdown.addCustomAnnouncement(
new Countdown.CustomAnnouncements(
counter -> counter == this.startMusicAt,
counter -> this.glassLocation
_ -> this.glassLocation
.getWorld()
.playSound(this.glassLocation, MUSIC_DISC_LAVA_CHICKEN, SoundCategory.RECORDS, 500f, 1f)
)
@@ -121,7 +125,7 @@ public class ProjectStart extends Appliance {
this.setEnabled(false);
IteratorUtil.worlds(World::getWorldBorder, worldBorder -> worldBorder.setSize(worldBorder.getMaxSize()));
IteratorUtil.worlds(world -> IteratorUtil.setGameRules(this.gameRulesAfterStart, false));
this.gameRules.forEach(setting -> setting.apply(true));
Bukkit.getWorlds().getFirst().setFullTime(0);
this.netherFireLocations.forEach(location -> Objects.requireNonNull(this.startWorld).getBlockAt(location).setType(Material.FIRE));
@@ -168,7 +172,7 @@ public class ProjectStart extends Appliance {
worldBorder.setDamageAmount(0);
});
IteratorUtil.worlds(world -> world, world -> IteratorUtil.setGameRules(this.gameRulesAfterStart, true));
this.gameRules.forEach(setting -> setting.apply(false));
this.resetAdvancements();
this.blockCycle.reset();
+1 -1
View File
@@ -2,7 +2,7 @@ dependencies {
implementation project(':common')
implementation project(':core')
compileOnly 'io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT'
compileOnly 'io.papermc.paper:paper-api:26.2.build.129-stable'
compileOnly 'org.geysermc.floodgate:api:2.2.4-SNAPSHOT'
implementation 'org.apache.httpcomponents:httpclient:4.5.14'
implementation 'com.sparkjava:spark-core:2.9.4'
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
+4 -4
View File
@@ -1,6 +1,6 @@
tasks.register('deployVaroPlugin', Copy) {
dependsOn ":varo:shadowJar"
from { project(":varo").shadowJar.archivePath }
from { project(":varo").shadowJar.archiveFile }
into file('path') // path to plugins folder
rename { fileName -> "varo.jar" }
}
@@ -10,15 +10,15 @@ tasks.register("uploadVaroPlugin") {
doLast {
def jarFile = project(":varo").tasks.named("shadowJar").get().outputs.files.singleFile
exec {
providers.exec {
commandLine "scp", "-4", "-P", "22", jarFile.absolutePath, "user@host:path/varo.jar"
}
}.result.get()
}
}
tasks.register('deployCraftAttackPlugin', Copy) {
dependsOn ":craftattack:shadowJar"
from { project(":craftattack").shadowJar.archivePath }
from { project(":craftattack").shadowJar.archiveFile }
into file('path') // path to plugins folder
rename { fileName -> "craftattack.jar" }
}
+1 -1
View File
@@ -2,7 +2,7 @@ dependencies {
implementation project(':core')
implementation project(':common')
compileOnly 'io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT'
compileOnly 'io.papermc.paper:paper-api:26.2.build.129-stable'
implementation 'org.apache.httpcomponents:httpclient:4.5.14'
implementation 'com.sparkjava:spark-core:2.9.4'
}
@@ -11,7 +11,7 @@ import org.bukkit.event.player.PlayerItemConsumeEvent;
import org.bukkit.inventory.meta.PotionMeta;
import org.bukkit.potion.PotionEffect;
import javax.annotation.Nullable;
import org.jetbrains.annotations.Nullable;
import java.util.Collection;
class ForbiddenPotionsListener extends ApplianceListener<ForbiddenItems> {