From bb5d457b13a64f5e76bd5ea48aca950c6da92d7e Mon Sep 17 00:00:00 2001 From: Paldiu Date: Sat, 18 Jul 2026 23:44:45 -0500 Subject: [PATCH 01/48] Laying groundwork part 1 Removed support for majority of languages, only vetting MySQL, SQLite, and PostgresSQL (encouraged) as supported types. Added imports for the actual jdbc impls and also the hikari and reactor imports. added these to library loader. This is tons of extra code that realistically will never be used by us, but we still want to support mysql / sqlite in case other users want to use this without setting up a pgsql server as that can be more complex than a sqlite instance. --- build.gradle | 13 +- .../COMMAND-SYSTEM-CHANGELOG.md | 0 .../totalfreedommod/TFMLibraryLoader.java | 7 +- .../sql/ConnectionHandler.java | 117 +------ .../totalfreedommod/sql/DataManager.java | 171 ---------- .../totalfreedommod/sql/FreedomDatabase.java | 7 - .../totalfreedommod/sql/SQLProperties.java | 87 +---- .../sql/adapter/AdapterFactory.java | 28 +- .../sql/adapter/DatabaseAdapter.java | 15 +- .../sql/adapter/h2/H2Adapter.java | 318 ------------------ .../adapter/mysql/MySQLStrikeRepository.java | 20 +- src/main/resources/paper-plugin.yml | 2 +- 12 files changed, 40 insertions(+), 745 deletions(-) rename COMMAND-SYSTEM-CHANGELOG.md => docs/COMMAND-SYSTEM-CHANGELOG.md (100%) delete mode 100644 src/main/java/me/totalfreedom/totalfreedommod/sql/DataManager.java delete mode 100644 src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/h2/H2Adapter.java diff --git a/build.gradle b/build.gradle index 6bb9149a1..ee67abe13 100644 --- a/build.gradle +++ b/build.gradle @@ -40,9 +40,9 @@ repositories { dependencies { compileOnly 'io.papermc.paper:paper-api:26.1.2.build.61-stable' - compileOnly 'org.projectlombok:lombok:1.18.42' - annotationProcessor 'org.projectlombok:lombok:1.18.42' - compileOnly 'org.apache.commons:commons-lang3:3.14.0' + compileOnly 'org.projectlombok:lombok:1.18.42' // slated for removal + annotationProcessor 'org.projectlombok:lombok:1.18.42' // slated for removal + // compileOnly 'org.apache.commons:commons-lang3:3.14.0' this is supplied by paper, we don't need the import. compileOnly 'commons-io:commons-io:2.16.1' compileOnly 'net.kyori:adventure-text-serializer-ansi:4.17.0' compileOnly 'net.milkbowl.vault:VaultUnlockedAPI:2.16' @@ -50,6 +50,13 @@ dependencies { compileOnly('com.sk89q.worldedit:worldedit-core:7.3.10') { transitive = false } compileOnly 'org.apache.sshd:sshd-core:2.17.1' + + //sql section + compileOnly 'org.postgresql:postgresql:42.7.7' + compileOnly 'org.xerial:sqlite-jdbc:3.49.1.0' + compileOnly 'com.mysql:mysql-connector-j:9.3.0' + compileOnly 'io.projectreactor:reactor-core:3.7.6' + compileOnly 'com.zaxxer:HikariCP:6.3.0' compileOnly 'org.jline:jline:3.28.0' compileOnly 'org.apache.logging.log4j:log4j-core:2.24.3' diff --git a/COMMAND-SYSTEM-CHANGELOG.md b/docs/COMMAND-SYSTEM-CHANGELOG.md similarity index 100% rename from COMMAND-SYSTEM-CHANGELOG.md rename to docs/COMMAND-SYSTEM-CHANGELOG.md diff --git a/src/main/java/me/totalfreedom/totalfreedommod/TFMLibraryLoader.java b/src/main/java/me/totalfreedom/totalfreedommod/TFMLibraryLoader.java index a23cffdd8..adb6b8575 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/TFMLibraryLoader.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/TFMLibraryLoader.java @@ -18,7 +18,12 @@ public class TFMLibraryLoader implements PluginLoader private static final String[] LIBRARIES = { "net.dv8tion:JDA:5.6.1", "org.apache.sshd:sshd-core:2.17.1", - "net.i2p.crypto:eddsa:0.3.0" + "net.i2p.crypto:eddsa:0.3.0", + "org.postgresql:postgresql:42.7.7", + "org.xerial:sqlite-jdbc:3.49.1.0", + "com.mysql:mysql-connector-j:9.3.0", + "io.projectreactor:reactor-core:3.7.6", + "com.zaxxer:HikariCP:6.3.0" }; @Override diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/ConnectionHandler.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/ConnectionHandler.java index 8ffc24312..6684fa322 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/ConnectionHandler.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/ConnectionHandler.java @@ -5,7 +5,6 @@ import java.sql.SQLException; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.sql.SQLProperties.DatabaseType; @@ -17,21 +16,16 @@ /** * Handles database connections for all supported database types. - * Supports: SQLite, MySQL, MariaDB, PostgreSQL, H2, MongoDB, Redis + * Supports: SQLite, MySQL, PostgreSQL */ public class ConnectionHandler { - private final TotalFreedomMod plugin; private final SQLProperties sqlProperties; private Connection connection = null; private final ExecutorService dbExecutor; - - // For NoSQL databases, we'll store the client reference - private Object noSqlClient = null; public ConnectionHandler(@NotNull final TotalFreedomMod plugin) { - this.plugin = plugin; this.sqlProperties = new SQLProperties(plugin); // Use a single-thread executor for DB operations to avoid blocking main thread this.dbExecutor = Executors.newSingleThreadExecutor(r -> { @@ -51,8 +45,7 @@ public SQLProperties getSqlProperties() } /** - * Get or create a JDBC connection for SQL databases. - * For NoSQL databases (MongoDB, Redis), this will throw an exception. + * Get or create a JDBC connection for the configured database. */ @NotNull public CompletableFuture getConnection() @@ -61,14 +54,6 @@ public CompletableFuture getConnection() try { DatabaseType dbType = sqlProperties.getDatabaseType(); - - // Check if this is a NoSQL database - if (dbType.isNoSQL()) - { - throw new SQLException("Database type " + dbType.getName() + - " is a NoSQL database and does not use JDBC connections. " + - "Use getNoSqlClient() instead."); - } if (connection == null || connection.isClosed()) { @@ -113,75 +98,6 @@ public CompletableFuture getConnection() }, dbExecutor); } - /** - * Get NoSQL client for MongoDB or Redis. - * Returns null for SQL databases. - * - * Note: Implementation depends on the NoSQL driver being used. - * This method provides a placeholder for NoSQL client initialization. - */ - @Nullable - public CompletableFuture getNoSqlClient() - { - return CompletableFuture.supplyAsync(() -> { - DatabaseType dbType = sqlProperties.getDatabaseType(); - - if (!dbType.isNoSQL()) - { - FLog.warning("getNoSqlClient() called for SQL database type: " + dbType.getName()); - return null; - } - - if (noSqlClient != null) - { - return noSqlClient; - } - - String url = sqlProperties.getJdbcUrl(); - FLog.info("Connecting to NoSQL database: " + dbType.getName()); - - switch (dbType) - { - case MONGODB: - // MongoDB client initialization - // Requires: org.mongodb:mongodb-driver-sync dependency - // noSqlClient = MongoClients.create(url); - FLog.warning("MongoDB support requires mongodb-driver-sync dependency. " + - "Add implementation 'org.mongodb:mongodb-driver-sync:4.11.1' to build.gradle"); - throw new UnsupportedOperationException( - "MongoDB client not implemented. Add MongoDB driver dependency."); - - case REDIS: - // Redis client initialization - // Requires: redis.clients:jedis dependency - // noSqlClient = new JedisPool(host, port); - FLog.warning("Redis support requires jedis dependency. " + - "Add implementation 'redis.clients:jedis:5.1.0' to build.gradle"); - throw new UnsupportedOperationException( - "Redis client not implemented. Add Jedis driver dependency."); - - default: - throw new IllegalStateException("Unknown NoSQL type: " + dbType); - } - }, dbExecutor); - } - - /** - * Check if the current database configuration uses JDBC. - */ - public boolean isJdbcDatabase() - { - return sqlProperties.isJdbcDatabase(); - } - - /** - * Check if the current database configuration is NoSQL. - */ - public boolean isNoSQL() - { - return sqlProperties.isNoSQL(); - } - /** * Get the configured database type. */ @@ -212,22 +128,6 @@ public void shutdown() FLog.warning("Failed to close database connection: " + e.getMessage()); } } - - // Close NoSQL client - if (noSqlClient != null) - { - try - { - // MongoDB: ((MongoClient) noSqlClient).close(); - // Redis: ((JedisPool) noSqlClient).close(); - FLog.info("NoSQL client closed."); - } - catch (Exception e) - { - FLog.warning("Failed to close NoSQL client: " + e.getMessage()); - } - noSqlClient = null; - } } /** @@ -238,17 +138,8 @@ public CompletableFuture testConnection() return CompletableFuture.supplyAsync(() -> { try { - if (isNoSQL()) - { - // For NoSQL, just check if we can get a client - getNoSqlClient().join(); - return true; - } - else - { - Connection conn = getConnection().join(); - return conn != null && !conn.isClosed() && conn.isValid(5); - } + Connection conn = getConnection().join(); + return conn != null && !conn.isClosed() && conn.isValid(5); } catch (Exception e) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/DataManager.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/DataManager.java deleted file mode 100644 index afd70676b..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/DataManager.java +++ /dev/null @@ -1,171 +0,0 @@ -package me.totalfreedom.totalfreedommod.sql; - -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; - -public class DataManager { - - private final TotalFreedomMod plugin; - private final ConnectionHandler connectionHandler; - private final StatementHandler statementHandler; - - public DataManager(TotalFreedomMod plugin) { - this.plugin = plugin; - this.connectionHandler = new ConnectionHandler(plugin); - this.statementHandler = new StatementHandler(connectionHandler); - } - - public void initialize() { - try { - connectionHandler.getConnection().join(); - runMigrations(); - } - catch (SQLException ex) - { - plugin.getLogger().severe("Failed to initialize database: " + ex.getMessage()); - } - catch (Exception ex) - { - Throwable cause = ex.getCause() != null ? ex.getCause() : ex; - plugin.getLogger().severe("Failed to initialize database: " + cause.getMessage()); - } - } - - private void runMigrations() throws SQLException { - createMigrationTable(); - - if (!hasMigrationRun("001_create_admins_table")) { - migrateAdmins(); - recordMigration("001_create_admins_table"); - } - - if (!hasMigrationRun("002_create_bans_table")) { - migrateBans(); - recordMigration("002_create_bans_table"); - } - - if (!hasMigrationRun("003_create_permbans_table")) { - migratePermbans(); - recordMigration("003_create_permbans_table"); - } - } - - private void createMigrationTable() throws SQLException { - String sql = """ - CREATE TABLE IF NOT EXISTS migrations ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - migration_name TEXT NOT NULL UNIQUE, - applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - """; - statementHandler.executeUpdate(sql); - } - - private boolean hasMigrationRun(String migrationName) throws SQLException { - String sql = "SELECT COUNT(*) FROM migrations WHERE migration_name = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, migrationName); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - private void recordMigration(String migrationName) throws SQLException { - String sql = "INSERT INTO migrations (migration_name) VALUES (?)"; - statementHandler.executeUpdate(sql, migrationName); - } - - private void migrateAdmins() throws SQLException { - // Admins table with UUID as primary identifier - // uuid, username, active, rank, ips[], last_login, login_message - String adminsSql = """ - CREATE TABLE IF NOT EXISTS admins ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - uuid TEXT NOT NULL UNIQUE, - username TEXT NOT NULL, - rank TEXT NOT NULL DEFAULT 'SUPER_ADMIN', - active BOOLEAN DEFAULT TRUE, - last_login TEXT, - login_message TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - """; - statementHandler.executeUpdate(adminsSql); - - // Separate table for admin IPs (one-to-many relationship) - String adminIpsSql = """ - CREATE TABLE IF NOT EXISTS admin_ips ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - admin_id INTEGER NOT NULL, - ip TEXT NOT NULL, - FOREIGN KEY (admin_id) REFERENCES admins(id) ON DELETE CASCADE, - UNIQUE(admin_id, ip) - ) - """; - statementHandler.executeUpdate(adminIpsSql); - plugin.getLogger().info("Created admins and admin_ips tables"); - } - - private void migrateBans() throws SQLException { - // Bans table with UUID as primary identifier - // uuid, username (cached), ips[], by, reason, expiry_unix - String bansSql = """ - CREATE TABLE IF NOT EXISTS bans ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - uuid TEXT, - username TEXT, - by TEXT, - reason TEXT, - expiry_unix INTEGER DEFAULT -1, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - """; - statementHandler.executeUpdate(bansSql); - - // Separate table for ban IPs (one-to-many relationship) - String banIpsSql = """ - CREATE TABLE IF NOT EXISTS ban_ips ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - ban_id INTEGER NOT NULL, - ip TEXT NOT NULL, - FOREIGN KEY (ban_id) REFERENCES bans(id) ON DELETE CASCADE, - UNIQUE(ban_id, ip) - ) - """; - statementHandler.executeUpdate(banIpsSql); - plugin.getLogger().info("Created bans and ban_ips tables"); - } - - private void migratePermbans() throws SQLException { - // Permbans table with UUID as primary identifier - // uuid, username (cached), ips[] - String sql = """ - CREATE TABLE IF NOT EXISTS permbans ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - uuid TEXT NOT NULL UNIQUE, - username TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - """; - statementHandler.executeUpdate(sql); - - // Separate table for permban IPs (one-to-many relationship) - String permbanIpsSql = """ - CREATE TABLE IF NOT EXISTS permban_ips ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - permban_id INTEGER NOT NULL, - ip TEXT NOT NULL, - FOREIGN KEY (permban_id) REFERENCES permbans(id) ON DELETE CASCADE, - UNIQUE(permban_id, ip) - ) - """; - statementHandler.executeUpdate(permbanIpsSql); - plugin.getLogger().info("Created permbans and permban_ips tables"); - } - - public void close() { - statementHandler.close(); - } -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/FreedomDatabase.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/FreedomDatabase.java index 8d73d6dc7..c7d94b6a9 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/FreedomDatabase.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/FreedomDatabase.java @@ -74,13 +74,6 @@ public void initialize() throws SQLException SQLProperties properties = connectionHandler.getSqlProperties(); DatabaseType dbType = properties.getDatabaseType(); - if (dbType.isNoSQL()) - { - FLog.warning("NoSQL database type '" + dbType.getName() + "' is not yet fully supported."); - FLog.warning("Please use a SQL database (sqlite, mysql, mariadb, postgresql, h2) instead."); - return; - } - // Wait for connection try { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/SQLProperties.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/SQLProperties.java index 7e6ff02e2..1545f0da6 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/SQLProperties.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/SQLProperties.java @@ -13,7 +13,7 @@ /** * Handles SQL database configuration properties. - * Supports: sqlite, mysql, mariadb, postgresql, h2, mongodb, redis + * Supports: sqlite, mysql, postgresql */ public final class SQLProperties { @@ -24,11 +24,7 @@ public enum DatabaseType { SQLITE("sqlite", 0, "org.sqlite.JDBC", false), MYSQL("mysql", 3306, "com.mysql.cj.jdbc.Driver", true), - MARIADB("mariadb", 3306, "org.mariadb.jdbc.Driver", true), - POSTGRESQL("postgresql", 5432, "org.postgresql.Driver", true), - H2("h2", 9092, "org.h2.Driver", false), - MONGODB("mongodb", 27017, "mongodb.jdbc.MongoDriver", true), - REDIS("redis", 6379, null, true); // Redis doesn't use JDBC + POSTGRESQL("postgresql", 5432, "org.postgresql.Driver", true); private final String name; private final int defaultPort; @@ -60,14 +56,9 @@ public static DatabaseType fromString(String type) return SQLITE; // Default fallback } - public boolean isNoSQL() - { - return this == MONGODB || this == REDIS; - } - public boolean isEmbedded() { - return this == SQLITE || this == H2; + return this == SQLITE; } } @@ -138,8 +129,6 @@ private String getDefaultDatabaseName(DatabaseType type) return switch (type) { case SQLITE -> "totalfreedom.db"; - case H2 -> "./totalfreedom"; - case REDIS -> "0"; default -> "totalfreedom"; }; } @@ -260,58 +249,12 @@ public String getJdbcUrl() appendOptions(url); break; - case MARIADB: - // jdbc:mariadb://host:port/database - url.append("mariadb://").append(host).append(":").append(port).append("/").append(databaseName); - appendOptions(url); - break; - case POSTGRESQL: // jdbc:postgresql://host:port/database url.append("postgresql://").append(host).append(":").append(port).append("/").append(databaseName); appendOptions(url); break; - case H2: - // jdbc:h2:./database (file) or jdbc:h2:mem:database (memory) or jdbc:h2:tcp://host:port/database (server) - if (databaseName.startsWith("mem:") || databaseName.startsWith("./") || databaseName.startsWith("/")) - { - url.append("h2:").append(databaseName); - } - else if (host != null && !host.isEmpty() && !host.equals("localhost") && port > 0) - { - url.append("h2:tcp://").append(host).append(":").append(port).append("/").append(databaseName); - } - else - { - url.append("h2:").append(databaseName); - } - appendOptions(url, ";"); - break; - - case MONGODB: - // mongodb://host:port/database - url.setLength(0); // Clear "jdbc:" - url.append("mongodb://"); - if (hasCredentials()) - { - url.append(username).append(":").append(password).append("@"); - } - url.append(host).append(":").append(port).append("/").append(databaseName); - appendOptions(url); - break; - - case REDIS: - // redis://host:port/database - url.setLength(0); // Clear "jdbc:" - url.append("redis://"); - if (hasCredentials()) - { - url.append(":").append(password).append("@"); - } - url.append(host).append(":").append(port).append("/").append(databaseName); - break; - default: throw new IllegalArgumentException("Unsupported database type: " + databaseType); } @@ -327,14 +270,6 @@ private void appendOptions(StringBuilder url) appendOptions(url, "?", "&"); } - /** - * Append options using custom separators (e.g., H2 uses ; instead of & and ?) - */ - private void appendOptions(StringBuilder url, String separator) - { - appendOptions(url, separator, separator); - } - private void appendOptions(StringBuilder url, String firstSeparator, String separator) { if (additionalOptions.isEmpty()) @@ -351,22 +286,6 @@ private void appendOptions(StringBuilder url, String firstSeparator, String sepa } } - /** - * Check if the configured database type uses standard JDBC. - */ - public boolean isJdbcDatabase() - { - return databaseType != DatabaseType.REDIS; - } - - /** - * Check if the configured database type is a NoSQL database. - */ - public boolean isNoSQL() - { - return databaseType.isNoSQL(); - } - /** * Check if the configured database type is embedded (no server needed). */ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/AdapterFactory.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/AdapterFactory.java index 37d8f24da..f94f1b282 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/AdapterFactory.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/AdapterFactory.java @@ -4,7 +4,6 @@ import me.totalfreedom.totalfreedommod.sql.ConnectionHandler; import me.totalfreedom.totalfreedommod.sql.SQLProperties; import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.h2.H2Adapter; import me.totalfreedom.totalfreedommod.sql.adapter.mysql.MySQLAdapter; import me.totalfreedom.totalfreedommod.sql.adapter.postgresql.PostgreSQLAdapter; import me.totalfreedom.totalfreedommod.sql.adapter.sqlite.SQLiteAdapter; @@ -14,16 +13,11 @@ * Factory class for creating database adapters based on the configured database type. * This factory handles the instantiation of the correct adapter implementation * based on the DatabaseType enum from SQLProperties. - * - * Supported SQL databases: + * + * Supported databases: * - SQLite (default, embedded) * - MySQL (standalone server) - * - MariaDB (MySQL-compatible, uses MySQL adapter) * - PostgreSQL (standalone server) - * - H2 (embedded, MySQL-compatible) - * - * NoSQL databases (MongoDB, Redis) are not supported by this factory - * and require separate handling through ConnectionHandler.getNoSqlClient(). */ public final class AdapterFactory { @@ -56,15 +50,8 @@ public static DatabaseAdapter createAdapter( { case SQLITE -> new SQLiteAdapter(plugin, connectionHandler, statementHandler); case MYSQL -> new MySQLAdapter(plugin, connectionHandler, statementHandler); - case MARIADB -> { - FLog.info("MariaDB selected, using MySQL adapter (compatible)"); - yield new MySQLAdapter(plugin, connectionHandler, statementHandler); - } case POSTGRESQL -> new PostgreSQLAdapter(plugin, connectionHandler, statementHandler); - case H2 -> new H2Adapter(plugin, connectionHandler, statementHandler); - case MONGODB, REDIS -> throw new UnsupportedOperationException( - "NoSQL database '" + type.name() + "' is not supported by the SQL adapter system. " + - "Use ConnectionHandler.getNoSqlClient() for NoSQL operations."); + default -> null; }; } @@ -78,8 +65,8 @@ public static boolean isSqlDatabase(SQLProperties.DatabaseType type) { return switch (type) { - case SQLITE, MYSQL, MARIADB, POSTGRESQL, H2 -> true; - case MONGODB, REDIS -> false; + case SQLITE, MYSQL, POSTGRESQL -> true; + default -> false; }; } @@ -95,10 +82,9 @@ public static Class getAdapterClass(SQLProperties.Dat return switch (type) { case SQLITE -> SQLiteAdapter.class; - case MYSQL, MARIADB -> MySQLAdapter.class; + case MYSQL -> MySQLAdapter.class; case POSTGRESQL -> PostgreSQLAdapter.class; - case H2 -> H2Adapter.class; - case MONGODB, REDIS -> null; + default -> null; }; } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DatabaseAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DatabaseAdapter.java index b2e3d22e0..2589f00cf 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DatabaseAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DatabaseAdapter.java @@ -91,9 +91,8 @@ public void shutdown() /** * Get the auto-increment syntax for primary keys. * SQLite: INTEGER PRIMARY KEY AUTOINCREMENT - * MySQL/MariaDB: INT AUTO_INCREMENT + * MySQL: INT AUTO_INCREMENT * PostgreSQL: SERIAL - * H2: INT AUTO_INCREMENT */ public abstract String autoIncrementSyntax(); @@ -105,7 +104,7 @@ public void shutdown() /** * Get the text/string type for this database. * SQLite: TEXT - * MySQL/MariaDB: TEXT or VARCHAR + * MySQL: TEXT or VARCHAR * PostgreSQL: TEXT */ public abstract String textType(); @@ -113,7 +112,7 @@ public void shutdown() /** * Get the timestamp/datetime type for this database. * SQLite: TEXT (stored as ISO string) - * MySQL/MariaDB: DATETIME + * MySQL: DATETIME * PostgreSQL: TIMESTAMP */ public abstract String timestampType(); @@ -121,7 +120,7 @@ public void shutdown() /** * Get the boolean type for this database. * SQLite: INTEGER (0/1) - * MySQL/MariaDB: TINYINT(1) + * MySQL: TINYINT(1) * PostgreSQL: BOOLEAN */ public abstract String booleanType(); @@ -129,7 +128,7 @@ public void shutdown() /** * Get the INSERT IGNORE / INSERT OR IGNORE syntax prefix. * SQLite: INSERT OR IGNORE - * MySQL/MariaDB: INSERT IGNORE + * MySQL: INSERT IGNORE * PostgreSQL: INSERT (use ON CONFLICT DO NOTHING suffix) */ public abstract String insertIgnoreSyntax(); @@ -137,7 +136,7 @@ public void shutdown() /** * Quote an identifier (table name, column name) for this database. * SQLite: No quoting or double quotes - * MySQL/MariaDB: `identifier` (backticks) + * MySQL: `identifier` (backticks) * PostgreSQL: "identifier" (double quotes) */ public abstract String quoteIdentifier(String identifier); @@ -145,7 +144,7 @@ public void shutdown() /** * Get the current timestamp function. * SQLite: CURRENT_TIMESTAMP or datetime('now') - * MySQL/MariaDB: NOW() + * MySQL: NOW() * PostgreSQL: CURRENT_TIMESTAMP */ public abstract String currentTimestamp(); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/h2/H2Adapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/h2/H2Adapter.java deleted file mode 100644 index 05e2451cc..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/h2/H2Adapter.java +++ /dev/null @@ -1,318 +0,0 @@ -package me.totalfreedom.totalfreedommod.sql.adapter.h2; - -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.sql.ConnectionHandler; -import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.AdminRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.BanRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; -import me.totalfreedom.totalfreedommod.sql.adapter.DiscordLinkRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.PermbanRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.StrikeRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.mysql.MySQLAdminRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.mysql.MySQLBanRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.mysql.MySQLDiscordLinkRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.mysql.MySQLPermbanRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.mysql.MySQLStrikeRepository; -import me.totalfreedom.totalfreedommod.util.FLog; - -import java.sql.SQLException; - -/** - * H2 database adapter. - * H2 is mostly MySQL-compatible, so we reuse MySQL repositories. - * Key differences: - * - Uses IDENTITY instead of AUTO_INCREMENT (but AUTO_INCREMENT also works) - * - Uses MERGE for upsert operations (but INSERT IGNORE can be simulated) - * - Double quotes (") for identifier quoting by default (can be configured) - */ -public class H2Adapter extends DatabaseAdapter -{ - private MySQLAdminRepository adminRepository; - private MySQLBanRepository banRepository; - private MySQLPermbanRepository permbanRepository; - private MySQLStrikeRepository strikeRepository; - private MySQLDiscordLinkRepository discordLinkRepository; - - public H2Adapter(TotalFreedomMod plugin, ConnectionHandler connectionHandler, StatementHandler statementHandler) - { - super(plugin, connectionHandler, statementHandler); - } - - // ============================================ - // SQL Dialect Methods - // ============================================ - - @Override - public String autoIncrementSyntax() - { - // H2 supports AUTO_INCREMENT for MySQL compatibility - return "INT AUTO_INCREMENT"; - } - - @Override - public String primaryKeySyntax() - { - return "PRIMARY KEY"; - } - - @Override - public String textType() - { - return "VARCHAR(65535)"; // H2 doesn't have TEXT by default, use large VARCHAR - } - - @Override - public String timestampType() - { - return "TIMESTAMP"; - } - - @Override - public String booleanType() - { - return "BOOLEAN"; - } - - @Override - public String insertIgnoreSyntax() - { - // H2: MERGE INTO for upsert, but we'll use a workaround - return "INSERT"; - } - - @Override - public String quoteIdentifier(String identifier) - { - return "\"" + identifier + "\""; - } - - @Override - public String currentTimestamp() - { - return "CURRENT_TIMESTAMP()"; - } - - @Override - public String caseInsensitiveLike() - { - return "ILIKE"; // H2 supports ILIKE - } - - // ============================================ - // Migration Methods - // ============================================ - - @Override - public void runMigrations() throws SQLException - { - FLog.info("[H2] Running database migrations..."); - - createMigrationTable(); - createAdminsTable(); - createAdminIpsTable(); - createBansTable(); - createBanIpsTable(); - createPermbansTable(); - createPermbanIpsTable(); - createStrikesTable(); - createDiscordLinksTable(); - - FLog.info("[H2] Database migrations complete."); - } - - private void createMigrationTable() throws SQLException - { - String sql = """ - CREATE TABLE IF NOT EXISTS "migrations" ( - "id" INT AUTO_INCREMENT PRIMARY KEY, - "version" VARCHAR(50) NOT NULL UNIQUE, - "applied_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP() - ) - """; - statementHandler.executeUpdate(sql); - } - - private void createAdminsTable() throws SQLException - { - String sql = """ - CREATE TABLE IF NOT EXISTS "admins" ( - "id" INT AUTO_INCREMENT PRIMARY KEY, - "uuid" VARCHAR(36) NOT NULL UNIQUE, - "username" VARCHAR(16) NOT NULL, - "rank" VARCHAR(32) NOT NULL, - "active" BOOLEAN DEFAULT TRUE, - "last_login" TIMESTAMP, - "login_message" VARCHAR(65535) - ) - """; - statementHandler.executeUpdate(sql); - - // Create indexes - try { statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_admins_username ON \"admins\"(\"username\")"); } catch (SQLException ignored) {} - try { statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_admins_active ON \"admins\"(\"active\")"); } catch (SQLException ignored) {} - } - - private void createAdminIpsTable() throws SQLException - { - String sql = """ - CREATE TABLE IF NOT EXISTS "admin_ips" ( - "id" INT AUTO_INCREMENT PRIMARY KEY, - "admin_id" INT NOT NULL, - "ip" VARCHAR(45) NOT NULL, - UNIQUE ("admin_id", "ip"), - FOREIGN KEY ("admin_id") REFERENCES "admins"("id") ON DELETE CASCADE - ) - """; - statementHandler.executeUpdate(sql); - try { statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_admin_ips_ip ON \"admin_ips\"(\"ip\")"); } catch (SQLException ignored) {} - } - - private void createBansTable() throws SQLException - { - String sql = """ - CREATE TABLE IF NOT EXISTS "bans" ( - "id" INT AUTO_INCREMENT PRIMARY KEY, - "uuid" VARCHAR(36), - "username" VARCHAR(16), - "banned_by" VARCHAR(16), - "banned_by_uuid" VARCHAR(36), - "reason" VARCHAR(65535), - "expire_at" TIMESTAMP - ) - """; - statementHandler.executeUpdate(sql); - - try { statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_bans_uuid ON \"bans\"(\"uuid\")"); } catch (SQLException ignored) {} - try { statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_bans_username ON \"bans\"(\"username\")"); } catch (SQLException ignored) {} - try { statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_bans_expire ON \"bans\"(\"expire_at\")"); } catch (SQLException ignored) {} - } - - private void createBanIpsTable() throws SQLException - { - String sql = """ - CREATE TABLE IF NOT EXISTS "ban_ips" ( - "id" INT AUTO_INCREMENT PRIMARY KEY, - "ban_id" INT NOT NULL, - "ip" VARCHAR(45) NOT NULL, - UNIQUE ("ban_id", "ip"), - FOREIGN KEY ("ban_id") REFERENCES "bans"("id") ON DELETE CASCADE - ) - """; - statementHandler.executeUpdate(sql); - try { statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_ban_ips_ip ON \"ban_ips\"(\"ip\")"); } catch (SQLException ignored) {} - } - - private void createPermbansTable() throws SQLException - { - String sql = """ - CREATE TABLE IF NOT EXISTS "permbans" ( - "id" INT AUTO_INCREMENT PRIMARY KEY, - "uuid" VARCHAR(36), - "username" VARCHAR(16), - "reason" VARCHAR(65535) - ) - """; - statementHandler.executeUpdate(sql); - - try { statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_permbans_uuid ON \"permbans\"(\"uuid\")"); } catch (SQLException ignored) {} - try { statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_permbans_username ON \"permbans\"(\"username\")"); } catch (SQLException ignored) {} - } - - private void createPermbanIpsTable() throws SQLException - { - String sql = """ - CREATE TABLE IF NOT EXISTS "permban_ips" ( - "id" INT AUTO_INCREMENT PRIMARY KEY, - "permban_id" INT NOT NULL, - "ip" VARCHAR(45) NOT NULL, - UNIQUE ("permban_id", "ip"), - FOREIGN KEY ("permban_id") REFERENCES "permbans"("id") ON DELETE CASCADE - ) - """; - statementHandler.executeUpdate(sql); - try { statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_permban_ips_ip ON \"permban_ips\"(\"ip\")"); } catch (SQLException ignored) {} - } - - private void createStrikesTable() throws SQLException - { - String sql = """ - CREATE TABLE IF NOT EXISTS "strikes" ( - "ip" VARCHAR(45) PRIMARY KEY, - "strike_count" INT NOT NULL DEFAULT 0, - "last_strike_unix" BIGINT NOT NULL DEFAULT 0, - "last_username" VARCHAR(16), - "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP() - ) - """; - statementHandler.executeUpdate(sql); - } - - private void createDiscordLinksTable() throws SQLException - { - String sql = """ - CREATE TABLE IF NOT EXISTS "discord_links" ( - "id" INT AUTO_INCREMENT PRIMARY KEY, - "admin_uuid" VARCHAR(36) NOT NULL UNIQUE, - "discord_user_id" VARCHAR(32) NOT NULL UNIQUE, - "linked_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP() - ) - """; - statementHandler.executeUpdate(sql); - } - - // ============================================ - // Repository Getters - // H2 is MySQL-compatible, so we reuse MySQL repositories - // ============================================ - - @Override - public AdminRepository getAdminRepository() - { - if (adminRepository == null) - { - adminRepository = new MySQLAdminRepository(plugin, statementHandler); - } - return adminRepository; - } - - @Override - public BanRepository getBanRepository() - { - if (banRepository == null) - { - banRepository = new MySQLBanRepository(plugin, statementHandler); - } - return banRepository; - } - - @Override - public PermbanRepository getPermbanRepository() - { - if (permbanRepository == null) - { - permbanRepository = new MySQLPermbanRepository(plugin, statementHandler); - } - return permbanRepository; - } - - @Override - public StrikeRepository getStrikeRepository() - { - if (strikeRepository == null) - { - strikeRepository = new MySQLStrikeRepository(plugin, statementHandler); - } - return strikeRepository; - } - - @Override - public DiscordLinkRepository getDiscordLinkRepository() - { - if (discordLinkRepository == null) - { - discordLinkRepository = new MySQLDiscordLinkRepository(plugin, statementHandler); - } - return discordLinkRepository; - } -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLStrikeRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLStrikeRepository.java index b04fd3963..f234f5656 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLStrikeRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLStrikeRepository.java @@ -44,9 +44,6 @@ public Map loadAll() throws SQLException @Override public void upsert(StrikeRecord r) throws SQLException { - // MySQL/MariaDB dialect. H2 supports ON DUPLICATE KEY UPDATE in MySQL mode, - // which is the default when connection URL uses MODE=MySQL; the H2 adapter - // does not set that, so use MERGE-compatible form via standard upsert below. String sql = """ INSERT INTO strikes (ip, strike_count, last_strike_unix, last_username) VALUES (?, ?, ?, ?) @@ -55,21 +52,8 @@ INSERT INTO strikes (ip, strike_count, last_strike_unix, last_username) last_strike_unix = VALUES(last_strike_unix), last_username = VALUES(last_username) """; - try - { - statementHandler.executeUpdate(sql, - r.getIp(), r.getCount(), r.getLastStrikeUnix(), r.getLastUsername()); - } - catch (SQLException ex) - { - // H2 in default mode rejects ON DUPLICATE KEY UPDATE; fall back to MERGE. - String merge = """ - MERGE INTO strikes (ip, strike_count, last_strike_unix, last_username) - KEY(ip) VALUES (?, ?, ?, ?) - """; - statementHandler.executeUpdate(merge, - r.getIp(), r.getCount(), r.getLastStrikeUnix(), r.getLastUsername()); - } + statementHandler.executeUpdate(sql, + r.getIp(), r.getCount(), r.getLastStrikeUnix(), r.getLastUsername()); } @Override diff --git a/src/main/resources/paper-plugin.yml b/src/main/resources/paper-plugin.yml index d2087bb4a..21672591c 100644 --- a/src/main/resources/paper-plugin.yml +++ b/src/main/resources/paper-plugin.yml @@ -16,7 +16,7 @@ dependencies: required: false join-classpath: true WorldEdit: - load: BEFORE + load: AFTER required: false join-classpath: true TF-WorldEdit: From 1eb9c4bb65797754e89cc1ccb4c38ed2a3c56d77 Mon Sep 17 00:00:00 2001 From: Paldiu Date: Sun, 19 Jul 2026 01:02:11 -0500 Subject: [PATCH 02/48] Groundwork establishment part 2 SQL groundwork is now mostly completed. we can now start working on migrating to json, converting all the config files into database schemas / tables, etc I'll craft up a doc on how to use this from a dev standpoint later on. --- .../totalfreedommod/TotalFreedomMod.java | 2 +- .../totalfreedommod/admin/AdminList.java | 66 +++-- .../totalfreedommod/banning/BanManager.java | 11 +- .../totalfreedommod/banning/PermbanList.java | 6 +- .../totalfreedommod/banning/StrikeList.java | 35 +-- .../blocking/BlockBlocker.java | 2 +- .../blocking/EventBlocker.java | 1 + .../totalfreedommod/sql/AccessController.java | 106 ++++++++ .../sql/ConnectionHandler.java | 250 +++++++++--------- .../totalfreedommod/sql/FreedomDatabase.java | 11 +- .../totalfreedommod/sql/StatementHandler.java | 203 +++++++++++--- .../sql/YamlMigrationService.java | 37 +-- .../sql/adapter/AdminRepository.java | 27 +- .../sql/adapter/BanRepository.java | 27 +- .../sql/adapter/DatabaseAdapter.java | 2 +- .../sql/adapter/PermbanRepository.java | 23 +- .../sql/adapter/StrikeRepository.java | 10 +- .../adapter/mysql/MySQLAdminRepository.java | 55 ++-- .../sql/adapter/mysql/MySQLBanRepository.java | 61 ++--- .../adapter/mysql/MySQLPermbanRepository.java | 57 ++-- .../adapter/mysql/MySQLStrikeRepository.java | 31 +-- .../postgresql/PostgreSQLAdminRepository.java | 55 ++-- .../postgresql/PostgreSQLBanRepository.java | 59 ++--- .../PostgreSQLPermbanRepository.java | 57 ++-- .../PostgreSQLStrikeRepository.java | 31 +-- .../adapter/sqlite/SQLiteAdminRepository.java | 55 ++-- .../adapter/sqlite/SQLiteBanRepository.java | 59 ++--- .../sqlite/SQLitePermbanRepository.java | 57 ++-- .../sqlite/SQLiteStrikeRepository.java | 32 +-- 29 files changed, 713 insertions(+), 715 deletions(-) create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/sql/AccessController.java diff --git a/src/main/java/me/totalfreedom/totalfreedommod/TotalFreedomMod.java b/src/main/java/me/totalfreedom/totalfreedommod/TotalFreedomMod.java index 75e49d320..51d3e96da 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/TotalFreedomMod.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/TotalFreedomMod.java @@ -382,7 +382,7 @@ private void runYamlMigrations() try { YamlMigrationService migrationService = new YamlMigrationService(this, dm); - migrationService.runMigrations().join(); + migrationService.runMigrations().block(); // Reload admin list after migration to pick up SQL data if (al != null) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java b/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java index d42609ac7..9e2c4e6b6 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java @@ -9,7 +9,6 @@ import java.util.Map; import java.util.Set; import java.util.UUID; -import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import lombok.Getter; import me.totalfreedom.totalfreedommod.FreedomService; @@ -23,6 +22,8 @@ import java.io.File; import java.io.IOException; import org.bukkit.Bukkit; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; import org.bukkit.command.CommandSender; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.YamlConfiguration; @@ -58,7 +59,7 @@ public class AdminList extends FreedomService // Flag to track if SQL is available private boolean usingSql = false; private final Object persistenceLock = new Object(); - private CompletableFuture persistenceChain = CompletableFuture.completedFuture(null); + private Mono persistenceChain = Mono.empty(); public AdminList(TotalFreedomMod plugin) { @@ -163,7 +164,7 @@ private void loadFromSql() try { AdminRepository repo = plugin.dm.getAdminRepository(); - List admins = repo.findAll().join(); + List admins = repo.findAll().block(); allAdmins.clear(); for (Admin admin : admins) @@ -331,15 +332,16 @@ public void saveAdminAsync(Admin admin) synchronized (persistenceLock) { persistenceChain = persistenceChain - .handle((ignored, throwable) -> null) - .thenCompose(ignored -> plugin.dm.getAdminRepository().save(finalUuid, snapshot).thenAccept(id -> - { - })) - .exceptionally(ex -> + .onErrorResume(ignored -> Mono.empty()) + .then(plugin.dm.getAdminRepository().save(finalUuid, snapshot)) + .onErrorResume(ex -> { FLog.warning("Failed to save admin " + snapshot.getName() + " to SQL: " + ex.getMessage()); - return null; - }); + return Mono.empty(); + }) + .then() + .cache(); + persistenceChain.subscribe(); } } @@ -385,7 +387,7 @@ private void saveToSql() } admin.setUuid(uuid); } - repo.save(uuid, admin).join(); + repo.save(uuid, admin).block(); } FLog.debug("Saved " + allAdmins.size() + " admins to SQL database"); } @@ -714,33 +716,27 @@ private void removeAdminFromSql(Admin admin) synchronized (persistenceLock) { persistenceChain = persistenceChain - .handle((ignored, throwable) -> null) - .thenCompose(ignored -> - { - if (uuid != null) - { - return plugin.dm.getAdminRepository().deleteByUuid(uuid).thenAccept(deleted -> - { - }); - } - - return CompletableFuture.runAsync(() -> - { - try - { - plugin.dm.getAdminRepository().deleteByUsername(name); - } - catch (Exception ex) + .onErrorResume(ignored -> Mono.empty()) + .then(uuid != null + ? plugin.dm.getAdminRepository().deleteByUuid(uuid).then() + : Mono.fromRunnable(() -> { - throw new RuntimeException(ex); - } - }); - }) - .exceptionally(ex -> + try + { + plugin.dm.getAdminRepository().deleteByUsername(name); + } + catch (Exception ex) + { + throw new RuntimeException(ex); + } + }).subscribeOn(Schedulers.boundedElastic())) + .onErrorResume(ex -> { FLog.warning("Failed to remove admin " + name + " from SQL: " + ex.getMessage()); - return null; - }); + return Mono.empty(); + }) + .cache(); + persistenceChain.subscribe(); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java index 2c18a992f..611a3219a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java @@ -49,6 +49,7 @@ public BanManager(TotalFreedomMod plugin) } @Override + @SuppressWarnings("unchecked") protected void onStart() { // Try to load from SQL database first @@ -75,7 +76,7 @@ private void loadFromSql() try { BanRepository repo = plugin.dm.getBanRepository(); - List loadedBans = repo.findAll().join(); + List loadedBans = repo.findAll().block(); synchronized (lock) { @@ -233,10 +234,10 @@ private void writeAllToSql(List snapshot) { BanRepository repo = plugin.dm.getBanRepository(); // Clear and re-add all (simple approach for now) - repo.deleteAll().join(); + repo.deleteAll().block(); for (Ban ban : snapshot) { - repo.save(ban).join(); + repo.save(ban).block(); } FLog.debug("Saved " + snapshot.size() + " bans to SQL database"); } @@ -455,7 +456,7 @@ private void saveBanToSql(Ban ban) { try { - plugin.dm.getBanRepository().save(ban).join(); + plugin.dm.getBanRepository().save(ban).block(); } catch (Exception ex) { @@ -480,7 +481,7 @@ private void removeBanFromSql(Ban ban) { if (ban.getUuid() != null) { - plugin.dm.getBanRepository().deleteByUuid(ban.getUuid()).join(); + plugin.dm.getBanRepository().deleteByUuid(ban.getUuid()).block(); } else if (ban.hasUsername()) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java index e6681016e..9c7f61526 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java @@ -65,7 +65,7 @@ private void loadFromSql() try { PermbanRepository repo = plugin.dm.getPermbanRepository(); - List loadedPermbans = repo.findAll().join(); + List loadedPermbans = repo.findAll().block(); synchronized (lock) { @@ -170,7 +170,7 @@ private void saveAllToSql() PermbanRepository repo = plugin.dm.getPermbanRepository(); for (PermBan permban : snapshot) { - repo.save(permban).join(); + repo.save(permban).block(); } FLog.debug("Saved " + snapshot.size() + " permbans to SQL database"); } @@ -348,7 +348,7 @@ private void savePermbanToSql(PermBan permban) { try { - plugin.dm.getPermbanRepository().save(permban).join(); + plugin.dm.getPermbanRepository().save(permban).block(); } catch (Exception ex) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java index f4c2379ab..aeccfddb7 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java @@ -77,7 +77,7 @@ private void loadFromSql() try { StrikeRepository repo = plugin.dm.getStrikeRepository(); - Map loaded = repo.loadAllAsync().join(); + Map loaded = repo.loadAllAsync().block(); strikes.putAll(loaded); usingSql = true; } @@ -135,14 +135,9 @@ private void pruneDecayed() removed++; if (usingSql && plugin.dm != null && plugin.dm.isInitialized()) { - try - { - plugin.dm.getStrikeRepository().deleteByIpAsync(e.getKey()); - } - catch (Exception ex) - { - FLog.warning("Failed to prune decayed strike for " + e.getKey() + ": " + ex.getMessage()); - } + plugin.dm.getStrikeRepository().deleteByIpAsync(e.getKey()) + .subscribe(deleted -> {}, ex -> + FLog.warning("Failed to prune decayed strike for " + e.getKey() + ": " + ex.getMessage())); } } } @@ -213,14 +208,9 @@ public synchronized boolean clear(String ip) } if (usingSql && plugin.dm != null && plugin.dm.isInitialized()) { - try - { - plugin.dm.getStrikeRepository().deleteByIpAsync(ip); - } - catch (Exception ex) - { - FLog.warning("Failed to clear strike from SQL: " + ex.getMessage()); - } + plugin.dm.getStrikeRepository().deleteByIpAsync(ip) + .subscribe(deleted -> {}, ex -> + FLog.warning("Failed to clear strike from SQL: " + ex.getMessage())); } else { @@ -238,14 +228,9 @@ private void persist(StrikeRecord r) { if (usingSql && plugin.dm != null && plugin.dm.isInitialized()) { - try - { - plugin.dm.getStrikeRepository().upsertAsync(r); - } - catch (Exception ex) - { - FLog.warning("Failed to persist strike to SQL: " + ex.getMessage()); - } + plugin.dm.getStrikeRepository().upsertAsync(r) + .subscribe(null, ex -> + FLog.warning("Failed to persist strike to SQL: " + ex.getMessage())); } else { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/BlockBlocker.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/BlockBlocker.java index d062bdedd..b3ed7990b 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/BlockBlocker.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/BlockBlocker.java @@ -9,7 +9,6 @@ import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Material; -import org.bukkit.entity.Entity; import org.bukkit.entity.FallingBlock; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; @@ -142,6 +141,7 @@ public void onBlockPlace(BlockPlaceEvent event) event.setCancelled(true); break; } + default: break; } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/EventBlocker.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/EventBlocker.java index 791e134cb..07ae721aa 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/EventBlocker.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/EventBlocker.java @@ -196,6 +196,7 @@ public void onEntityDamage(EntityDamageEvent event) return; } } + default: break; } if (ConfigEntry.ENABLE_PET_PROTECT.getBoolean()) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/AccessController.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/AccessController.java new file mode 100644 index 000000000..e1152f779 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/AccessController.java @@ -0,0 +1,106 @@ +package me.totalfreedom.totalfreedommod.sql; + +import java.util.concurrent.Semaphore; +import java.util.concurrent.atomic.AtomicBoolean; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +/** + * This is a fair, non-blocking access controller that ensures our queries on the connection pool + * don't overload our available connections. + * + * The {@link Semaphore} utilizes a FIFO waiting queue. + * The query that has been waiting longest always receives the next available permit. + * Permit release is guaranteed on completion, error, and cancellation. + */ +public final class AccessController +{ + private final Semaphore semaphore; + + /** + * @param permits maximum number of concurrently executing queries. + * Should always match the HikariCP maximum pool size. + */ + public AccessController(final int permits) + { + this.semaphore = new Semaphore(permits, true); + } + + /** + * Guard a single-result query. The permit is held for the entire duration of the query, + * including the time spent mapping the result to the return type. + */ + public Mono guard(final Mono query) + { + return Mono.usingWhen( + acquire(), + ignored -> query, + ignored -> release()); + } + + /** + * Guard a multi-row query or stream of results. + * The permit is held for the entire duration of the flux. + */ + public Flux guard(final Flux query) + { + return Flux.usingWhen( + acquire(), + ignored -> query, + ignored -> release(), + (ignored, err) -> release(), + ignored -> release()); + } + + public int availablePermits() + { + return semaphore.availablePermits(); + } + + /** Number of queries waiting for permit. */ + public int queueLength() + { + return semaphore.getQueueLength(); + } + + private Mono acquire() + { + return Mono.create(sink -> + { + final Thread thread = Thread.currentThread(); + final AtomicBoolean cancelled = new AtomicBoolean(false); + sink.onCancel(() -> + { + cancelled.set(true); + thread.interrupt(); + }); + try + { + semaphore.acquire(); + if (cancelled.get()) + { + semaphore.release(); + } + else + { + sink.success(Boolean.TRUE); + } + } + catch (final InterruptedException e) + { + sink.error(e); + } + finally + { + Thread.interrupted(); + } + }).subscribeOn(Schedulers.boundedElastic()); + } + + private Mono release() + { + return Mono.fromRunnable(semaphore::release); + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/ConnectionHandler.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/ConnectionHandler.java index 6684fa322..11ff80afc 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/ConnectionHandler.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/ConnectionHandler.java @@ -1,38 +1,34 @@ package me.totalfreedom.totalfreedommod.sql; import java.sql.Connection; -import java.sql.DriverManager; import java.sql.SQLException; +import org.apache.commons.lang3.exception.ExceptionUtils; import org.jetbrains.annotations.NotNull; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.sql.SQLProperties.DatabaseType; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.sql.SQLProperties.DatabaseType; import me.totalfreedom.totalfreedommod.util.FLog; /** - * Handles database connections for all supported database types. + * Contains the HikariCP connection pool for the configured database. * Supports: SQLite, MySQL, PostgreSQL */ public class ConnectionHandler { + private static final int DEFAULT_POOL_SIZE = 10; + private static final int SQLITE_POOL_SIZE = 1; + private final SQLProperties sqlProperties; - private Connection connection = null; - private final ExecutorService dbExecutor; + private volatile HikariDataSource dataSource; + private volatile AccessController accessController; public ConnectionHandler(@NotNull final TotalFreedomMod plugin) { this.sqlProperties = new SQLProperties(plugin); - // Use a single-thread executor for DB operations to avoid blocking main thread - this.dbExecutor = Executors.newSingleThreadExecutor(r -> { - Thread t = new Thread(r, "TFM-Database"); - t.setDaemon(true); - return t; - }); } /** @@ -45,135 +41,153 @@ public SQLProperties getSqlProperties() } /** - * Get or create a JDBC connection for the configured database. + * Build the HikariCP connection pool for the configured database. + * Blocks until Hikari's own initial-connection test succeeds or fails. + * + * @apiNote SQLite is a single-writer embedded engine, so it is pinned to a single pooled connection regardless of configuration. */ - @NotNull - public CompletableFuture getConnection() + public void connect() throws SQLException { - return CompletableFuture.supplyAsync(() -> { - try - { - DatabaseType dbType = sqlProperties.getDatabaseType(); + DatabaseType dbType = sqlProperties.getDatabaseType(); + + HikariConfig config = new HikariConfig(); + config.setJdbcUrl(sqlProperties.getJdbcUrl()); + config.setPoolName("TFM-" + dbType.getName()); + + String driverClass = sqlProperties.getDriverClass(); + if (driverClass != null && !driverClass.isEmpty()) + { + config.setDriverClassName(driverClass); + } - if (connection == null || connection.isClosed()) + if (sqlProperties.hasCredentials()) + { + config.setUsername(sqlProperties.getUsername()); + config.setPassword(sqlProperties.getPassword()); + } + + if (dbType == DatabaseType.SQLITE) + { + config.setMaximumPoolSize(SQLITE_POOL_SIZE); + config.setConnectionTestQuery("SELECT 1"); + // SQLite pragmas are set per-connection so we need to make sure it doesn't accidentally get recycled + config.setMaxLifetime(0); + } + else + { + config.setMaximumPoolSize(DEFAULT_POOL_SIZE); + config.setMinimumIdle(1); + } + + try + { + FLog.info(String.format( + "Connecting to database: %s at %s", + dbType.getName(), + maskPassword(config.getJdbcUrl()) + )); + this.dataSource = new HikariDataSource(config); + this.accessController = new AccessController(dataSource.getMaximumPoolSize()); + + if (dbType == DatabaseType.SQLITE) + { + try (Connection connection = dataSource.getConnection()) { - // Load JDBC driver if specified - String driverClass = sqlProperties.getDriverClass(); - if (driverClass != null && !driverClass.isEmpty()) - { - try - { - Class.forName(driverClass); - } - catch (ClassNotFoundException e) - { - FLog.warning("JDBC driver not found: " + driverClass + - ". Attempting to connect anyway..."); - } - } - - String url = sqlProperties.getJdbcUrl(); - FLog.info("Connecting to database: " + dbType.getName() + " at " + maskPassword(url)); - - connection = DriverManager.getConnection(url, sqlProperties.getConnectionProperties()); - - // Apply SQLite-specific pragmas if applicable - if (dbType == DatabaseType.SQLITE) - { - sqlProperties.applySqlitePragmas(connection); - } - - FLog.info("Database connection established (" + dbType.getName() + ")"); + sqlProperties.applySqlitePragmas(connection); + } + catch (SQLException e) + { + throw e; // this should be handled downstream, not here. + } + catch (Exception e) + { + throw new SQLException("Failed to apply SQLite pragmas", e); // this should also be handled downstream. } - return connection; - } - catch (SQLException e) - { - throw new RuntimeException("Failed to get database connection", e); - } - catch (Exception e) - { - throw new RuntimeException("Failed to initialize database", e); } - }, dbExecutor); + + FLog.info(String.format( + "Database connection pool established (%s, poolSize %d)", + dbType.getName(), + dataSource.getMaximumPoolSize() + )); + } + catch (SQLException e) + { + throw e; + } + catch (Exception e) + { + throw new SQLException("Failed to initialize database connection pool", e); + } } /** - * Get the configured database type. + * Borrow a pooled connection. Callers are responsible for closing it, which + * returns it to the pool rather than actually closing the physical connection. */ @NotNull - public DatabaseType getDatabaseType() + public Connection borrowConnection() throws SQLException { - return sqlProperties.getDatabaseType(); + if (dataSource == null) + { + throw new IllegalStateException("Connection pool not initialized. Call connect() first."); + } + return dataSource.getConnection(); } /** - * Shutdown the connection handler, closing all connections. + * Semaphore holder which limits concurrent async queries to the pool's maximum connection count. */ - public void shutdown() + @NotNull + public AccessController getAccessController() { - FLog.info("Shutting down database connection handler..."); - dbExecutor.shutdown(); - - // Close JDBC connection - if (connection != null) + if (accessController == null) { - try - { - connection.close(); - FLog.info("Database connection closed."); - } - catch (SQLException e) - { - FLog.warning("Failed to close database connection: " + e.getMessage()); - } + throw new IllegalStateException("Connection pool not initialized. Call connect() first."); } + return accessController; } - /** - * Test the database connection. - */ - public CompletableFuture testConnection() + @NotNull + public DatabaseType getDatabaseType() { - return CompletableFuture.supplyAsync(() -> { - try - { - Connection conn = getConnection().join(); - return conn != null && !conn.isClosed() && conn.isValid(5); - } - catch (Exception e) - { - FLog.severe("Database connection test failed: " + e.getMessage()); - return false; - } - }, dbExecutor); + return sqlProperties.getDatabaseType(); + } + + public boolean isConnected() + { + return dataSource != null && !dataSource.isClosed(); + } + + public boolean testConnection() + { + try (Connection connection = borrowConnection()) + { + return connection.isValid(5); + } + catch (Exception e) + { + FLog.severe(String.format( + "Database connection test failed: %s", + ExceptionUtils.getRootCauseMessage(e) + )); + return false; + } + } + + public void shutdown() + { + FLog.info("Shutting down database connection handler..."); + if (dataSource != null && !dataSource.isClosed()) + { + dataSource.close(); + FLog.info("Database connection pool closed."); + } } - /** - * Mask password in connection URL for logging. - */ private String maskPassword(String url) { - // Mask any password patterns like :password@ or password=xxx return url.replaceAll(":[^:@/]+@", ":****@") .replaceAll("password=[^&;]+", "password=****"); } - - public CompletableFuture closeConnection() { - return CompletableFuture.runAsync(() -> { - if (connection != null) - { - try - { - connection.close(); - FLog.info("Database connection closed."); - } - catch (SQLException e) - { - FLog.warning("Failed to close database connection: " + e.getMessage()); - } - connection = null; - } - }, dbExecutor); - } -} \ No newline at end of file +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/FreedomDatabase.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/FreedomDatabase.java index c7d94b6a9..3d0b96dac 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/FreedomDatabase.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/FreedomDatabase.java @@ -74,15 +74,8 @@ public void initialize() throws SQLException SQLProperties properties = connectionHandler.getSqlProperties(); DatabaseType dbType = properties.getDatabaseType(); - // Wait for connection - try - { - connectionHandler.getConnection().join(); - } - catch (Exception ex) - { - throw new SQLException("Failed to establish database connection", ex); - } + // Build the connection pool + connectionHandler.connect(); // Create statement handler statementHandler = new StatementHandler(connectionHandler); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/StatementHandler.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/StatementHandler.java index 98ab25ebc..0cbdb9299 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/StatementHandler.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/StatementHandler.java @@ -1,12 +1,25 @@ package me.totalfreedom.totalfreedommod.sql; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Proxy; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.Collection; -import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Callable; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +/** + * Executes SQL against connections borrowed from {@link ConnectionHandler}'s pool. + * + * The {@link PreparedStatement}/{@link ResultSet} returned by {@link #prepareStatement} + * and {@link #executeQuery} are wrapped so that closing them via try-with-resources + * also returns the borrowed pooled {@link Connection}. Callers do not need to change how they consume + * these methods, but the underlying connection is no longer held open indefinitely. + */ public class StatementHandler { private final ConnectionHandler connectionHandler; @@ -16,29 +29,35 @@ public StatementHandler(ConnectionHandler connectionHandler) this.connectionHandler = connectionHandler; } - private Connection getConnection() throws SQLException + public PreparedStatement prepareStatement(String sql, Object... params) throws SQLException { + Connection connection = connectionHandler.borrowConnection(); + PreparedStatement statement; try { - return connectionHandler.getConnection().join(); + statement = connection.prepareStatement(sql); + setParameters(statement, params); } - catch (Exception e) + catch (SQLException e) { - throw new SQLException("Failed to obtain connection", e); + closeQuietly(connection); + throw e; } - } - - public PreparedStatement prepareStatement(String sql, Object... params) throws SQLException - { - PreparedStatement statement = getConnection().prepareStatement(sql); - setParameters(statement, params); - return statement; + return closingStatementProxy(statement, connection); } public ResultSet executeQuery(String sql, Object... params) throws SQLException { PreparedStatement statement = prepareStatement(sql, params); - return statement.executeQuery(); + try + { + return closingResultSetProxy(statement.executeQuery(), statement); + } + catch (SQLException e) + { + closeQuietly(statement); + throw e; + } } public int executeUpdate(String sql, Object... params) throws SQLException @@ -49,34 +68,58 @@ public int executeUpdate(String sql, Object... params) throws SQLException } } - public CompletableFuture executeQueryAsync(String sql, Object... params) + /** + * Runs an INSERT and returns the first generated key, or -1 if none was generated. + */ + public long executeUpdateReturnKey(String sql, Object... params) throws SQLException { - return CompletableFuture.supplyAsync(() -> + Connection connection = connectionHandler.borrowConnection(); + try (PreparedStatement statement = connection.prepareStatement(sql, java.sql.Statement.RETURN_GENERATED_KEYS)) { - try + setParameters(statement, params); + statement.executeUpdate(); + try (ResultSet keys = statement.getGeneratedKeys()) { - return executeQuery(sql, params); + return keys.next() ? keys.getLong(1) : -1L; } - catch (SQLException e) - { - throw new RuntimeException(e); - } - }); + } + finally + { + closeQuietly(connection); + } } - public CompletableFuture executeUpdateAsync(String sql, Object... params) + public Mono supplyMono(Callable work) { - return CompletableFuture.supplyAsync(() -> - { - try - { - return executeUpdate(sql, params); - } - catch (SQLException e) - { - throw new RuntimeException(e); - } - }); + return connectionHandler.getAccessController().guard( + Mono.fromCallable(work).subscribeOn(Schedulers.boundedElastic())); + } + + public Mono runMono(SqlRunnable work) + { + return connectionHandler.getAccessController().guard( + Mono.fromRunnable(() -> + { + try + { + work.run(); + } + catch (SQLException e) + { + throw new RuntimeException(e); + } + }).subscribeOn(Schedulers.boundedElastic())); + } + + @FunctionalInterface + public interface SqlRunnable + { + void run() throws SQLException; + } + + public void close() + { + connectionHandler.shutdown(); } private void setParameters(PreparedStatement statement, Object... params) throws SQLException @@ -96,18 +139,18 @@ private int setParameter(PreparedStatement statement, int index, Object param) t return index + 1; } - if (param instanceof String[]) + if (param instanceof String[] ss) { - for (String s : (String[]) param) + for (String s : (String[]) ss) { statement.setString(index++, s); } return index; } - if (param instanceof Collection) + if (param instanceof Collection coll) { - for (Object item : (Collection) param) + for (Object item : coll) { if (item instanceof String) { @@ -121,11 +164,11 @@ private int setParameter(PreparedStatement statement, int index, Object param) t return index; } - if (param instanceof String) + if (param instanceof String s) { - statement.setString(index, (String) param); + statement.setString(index, s); } - else if (param instanceof Integer || param.getClass() == int.class) + else if (param instanceof Integer || param.getClass() == int.class) // no instanceof capture available here { statement.setInt(index, (Integer) param); } @@ -161,8 +204,80 @@ else if (param instanceof Short || param.getClass() == short.class) return index + 1; } - public void close() + private static void closeQuietly(AutoCloseable closeable) { - connectionHandler.shutdown(); + try + { + closeable.close(); + } + catch (Exception ignored) {} + } + + /** + * Wraps a PreparedStatement so that closing it also returns the pooled Connection + * that produced it, without changing anything about how callers use the statement. + */ + private static PreparedStatement closingStatementProxy(PreparedStatement target, Connection connection) + { + return (PreparedStatement) Proxy.newProxyInstance( + StatementHandler.class.getClassLoader(), + new Class[] { PreparedStatement.class }, + (proxy, method, args) -> + { + if ("close".equals(method.getName())) + { + try + { + target.close(); + } + finally + { + connection.close(); + } + return null; + } + try + { + return method.invoke(target, args); + } + catch (InvocationTargetException e) + { + throw e.getCause(); + } + }); + } + + /** + * Wraps a ResultSet so that closing it also closes the proxied PreparedStatement + * that produced it, cascading through to the pooled Connection. + */ + private static ResultSet closingResultSetProxy(ResultSet target, PreparedStatement statement) + { + return (ResultSet) Proxy.newProxyInstance( + StatementHandler.class.getClassLoader(), + new Class[] { ResultSet.class }, + (proxy, method, args) -> + { + if ("close".equals(method.getName())) + { + try + { + target.close(); + } + finally + { + statement.close(); + } + return null; + } + try + { + return method.invoke(target, args); + } + catch (InvocationTargetException e) + { + throw e.getCause(); + } + }); } -} \ No newline at end of file +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/YamlMigrationService.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/YamlMigrationService.java index c379d6a7f..f6f11198b 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/YamlMigrationService.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/YamlMigrationService.java @@ -4,7 +4,6 @@ import me.totalfreedom.totalfreedommod.admin.Admin; import me.totalfreedom.totalfreedommod.banning.Ban; import me.totalfreedom.totalfreedommod.banning.PermBan; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.sql.adapter.AdminRepository; import me.totalfreedom.totalfreedommod.sql.adapter.BanRepository; import me.totalfreedom.totalfreedommod.sql.adapter.PermbanRepository; @@ -16,9 +15,11 @@ import java.io.File; import java.util.List; import java.util.UUID; -import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicInteger; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + /** * Service for migrating data from YAML files to SQL database. * Handles one-time migration of admins.yml, bans.yml, and permbans.yml. @@ -47,11 +48,11 @@ public YamlMigrationService(TotalFreedomMod plugin, FreedomDatabase databaseMana /** * Run all migrations if they haven't been completed yet. - * @return CompletableFuture that completes when all migrations are done + * @return Mono that completes when all migrations are done */ - public CompletableFuture runMigrations() + public Mono runMigrations() { - return CompletableFuture.runAsync(() -> { + return Mono.fromRunnable(() -> { try { FLog.info("Checking for YAML data migrations..."); @@ -73,7 +74,7 @@ public CompletableFuture runMigrations() FLog.severe("Error during YAML migrations: " + ex.getMessage()); ex.printStackTrace(); } - }); + }).subscribeOn(Schedulers.boundedElastic()); } /** @@ -93,7 +94,7 @@ private void migrateAdmins() // Check if we already have admins in the database try { - List existingAdmins = repo.findAll().join(); + List existingAdmins = repo.findAll().block(); if (!existingAdmins.isEmpty()) { FLog.info("Database already contains " + existingAdmins.size() + " admins, skipping YAML migration"); @@ -136,7 +137,7 @@ private void migrateAdmins() // Generate UUID if not present (legacy data) UUID uuid = generateUuidForAdmin(admin); - repo.save(uuid, admin).join(); + repo.save(uuid, admin).block(); migrated.incrementAndGet(); } catch (Exception ex) @@ -169,7 +170,7 @@ private void migrateBans() // Check if we already have bans in the database try { - List existingBans = repo.findAll().join(); + List existingBans = repo.findAll().block(); if (!existingBans.isEmpty()) { FLog.info("Database already contains " + existingBans.size() + " bans, skipping YAML migration"); @@ -209,7 +210,7 @@ private void migrateBans() continue; } - repo.save(ban).join(); + repo.save(ban).block(); migrated.incrementAndGet(); } catch (Exception ex) @@ -242,7 +243,7 @@ private void migratePermbans() // Check if we already have permbans in the database try { - List existingPermbans = repo.findAll().join(); + List existingPermbans = repo.findAll().block(); if (!existingPermbans.isEmpty()) { FLog.info("Database already contains " + existingPermbans.size() + " permbans, skipping YAML migration"); @@ -274,7 +275,7 @@ private void migratePermbans() // Generate UUID for name permban.setUuid(FUtil.usernameToUuid(name)); - repo.save(permban).join(); + repo.save(permban).block(); migrated.incrementAndGet(); } catch (Exception ex) @@ -339,17 +340,17 @@ private void backupFile(File file) * Force re-migration of all YAML data. * WARNING: This will clear existing database data and re-import from YAML. */ - public CompletableFuture forceMigration() + public Mono forceMigration() { - return CompletableFuture.runAsync(() -> { + return Mono.fromRunnable(() -> { FLog.warning("Force migration requested - this will overwrite database data!"); // Clear existing data try { - databaseManager.getAdminRepository().deleteAll().join(); - databaseManager.getBanRepository().deleteAll().join(); - databaseManager.getPermbanRepository().deleteAll().join(); + databaseManager.getAdminRepository().deleteAll().block(); + databaseManager.getBanRepository().deleteAll().block(); + databaseManager.getPermbanRepository().deleteAll().block(); } catch (Exception ex) { @@ -364,7 +365,7 @@ public CompletableFuture forceMigration() migrateAdmins(); migrateBans(); migratePermbans(); - }); + }).subscribeOn(Schedulers.boundedElastic()); } /** diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/AdminRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/AdminRepository.java index 3ffadacad..a0722d4a3 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/AdminRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/AdminRepository.java @@ -7,7 +7,8 @@ import java.util.List; import java.util.Map; import java.util.UUID; -import java.util.concurrent.CompletableFuture; + +import reactor.core.publisher.Mono; /** * Abstract repository interface for Admin data. @@ -164,31 +165,31 @@ public interface AdminRepository // Async Operations // ============================================ - CompletableFuture> loadAllAsync(); + Mono> loadAllAsync(); - CompletableFuture insertAsync(UUID uuid, Admin admin); + Mono insertAsync(UUID uuid, Admin admin); - CompletableFuture updateAsync(UUID uuid, Admin admin); + Mono updateAsync(UUID uuid, Admin admin); + + Mono deleteAsync(UUID uuid); - CompletableFuture deleteAsync(UUID uuid); - /** * Save admin asynchronously (upsert). */ - CompletableFuture save(UUID uuid, Admin admin); - + Mono save(UUID uuid, Admin admin); + /** * Find all admins asynchronously. */ - CompletableFuture> findAll(); - + Mono> findAll(); + /** * Delete admin by UUID asynchronously. */ - CompletableFuture deleteByUuid(UUID uuid); - + Mono deleteByUuid(UUID uuid); + /** * Delete all admins asynchronously. */ - CompletableFuture deleteAll(); + Mono deleteAll(); } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/BanRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/BanRepository.java index cd4b37d00..0dcbb46c9 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/BanRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/BanRepository.java @@ -6,7 +6,8 @@ import java.util.Date; import java.util.List; import java.util.UUID; -import java.util.concurrent.CompletableFuture; + +import reactor.core.publisher.Mono; /** * Repository interface for Ban data. @@ -162,31 +163,31 @@ public interface BanRepository // Async Operations // ============================================ - CompletableFuture> loadAllAsync(); + Mono> loadAllAsync(); - CompletableFuture insertAsync(Ban ban); + Mono insertAsync(Ban ban); - CompletableFuture updateAsync(Ban ban); + Mono updateAsync(Ban ban); + + Mono deleteAsync(UUID uuid); - CompletableFuture deleteAsync(UUID uuid); - /** * Save ban asynchronously (insert or update). */ - CompletableFuture save(Ban ban); - + Mono save(Ban ban); + /** * Find all bans asynchronously. */ - CompletableFuture> findAll(); - + Mono> findAll(); + /** * Delete ban by UUID asynchronously. */ - CompletableFuture deleteByUuid(UUID uuid); - + Mono deleteByUuid(UUID uuid); + /** * Delete all bans asynchronously. */ - CompletableFuture deleteAll(); + Mono deleteAll(); } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DatabaseAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DatabaseAdapter.java index 2589f00cf..6d857865d 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DatabaseAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DatabaseAdapter.java @@ -52,7 +52,7 @@ public void initialize() throws SQLException */ public void shutdown() { - connectionHandler.closeConnection(); + connectionHandler.shutdown(); } // ============================================ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/PermbanRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/PermbanRepository.java index 71219999d..6c9f3c628 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/PermbanRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/PermbanRepository.java @@ -5,7 +5,8 @@ import java.sql.SQLException; import java.util.List; import java.util.UUID; -import java.util.concurrent.CompletableFuture; + +import reactor.core.publisher.Mono; /** * Repository interface for Permban data. @@ -140,26 +141,26 @@ public interface PermbanRepository // Async Operations // ============================================ - CompletableFuture> loadAllAsync(); + Mono> loadAllAsync(); - CompletableFuture insertAsync(PermBan permban); + Mono insertAsync(PermBan permban); - CompletableFuture updateAsync(PermBan permban); + Mono updateAsync(PermBan permban); + + Mono deleteAsync(UUID uuid); - CompletableFuture deleteAsync(UUID uuid); - /** * Save permban asynchronously (insert or update). */ - CompletableFuture save(PermBan permban); - + Mono save(PermBan permban); + /** * Find all permbans asynchronously. */ - CompletableFuture> findAll(); - + Mono> findAll(); + /** * Delete all permbans asynchronously. */ - CompletableFuture deleteAll(); + Mono deleteAll(); } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/StrikeRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/StrikeRepository.java index 7eb2ce11b..f7f51cfaa 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/StrikeRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/StrikeRepository.java @@ -2,8 +2,8 @@ import java.sql.SQLException; import java.util.Map; -import java.util.concurrent.CompletableFuture; import me.totalfreedom.totalfreedommod.banning.StrikeRecord; +import reactor.core.publisher.Mono; public interface StrikeRepository { @@ -15,11 +15,11 @@ public interface StrikeRepository void deleteAllSync() throws SQLException; - CompletableFuture> loadAllAsync(); + Mono> loadAllAsync(); - CompletableFuture upsertAsync(StrikeRecord record); + Mono upsertAsync(StrikeRecord record); - CompletableFuture deleteByIpAsync(String ip); + Mono deleteByIpAsync(String ip); - CompletableFuture deleteAll(); + Mono deleteAll(); } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdminRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdminRepository.java index a8a14cd10..47d6ebd5c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdminRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdminRepository.java @@ -5,14 +5,14 @@ import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.sql.StatementHandler; import me.totalfreedom.totalfreedommod.sql.adapter.AdminRepository; -import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FUtil; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.*; -import java.util.concurrent.CompletableFuture; + +import reactor.core.publisher.Mono; /** * MySQL/MariaDB implementation of AdminRepository. @@ -391,72 +391,51 @@ public void deleteAllSync() throws SQLException // ============================================ @Override - public CompletableFuture> loadAllAsync() + public Mono> loadAllAsync() { - return CompletableFuture.supplyAsync(() -> { - try { return loadAll(); } - catch (SQLException e) { FLog.severe("Failed to load admins: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(this::loadAll); } @Override - public CompletableFuture insertAsync(UUID uuid, Admin admin) + public Mono insertAsync(UUID uuid, Admin admin) { - return CompletableFuture.supplyAsync(() -> { - try { return insert(uuid, admin); } - catch (SQLException e) { FLog.severe("Failed to insert admin: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(() -> insert(uuid, admin)); } @Override - public CompletableFuture updateAsync(UUID uuid, Admin admin) + public Mono updateAsync(UUID uuid, Admin admin) { - return CompletableFuture.supplyAsync(() -> { - try { return update(uuid, admin); } - catch (SQLException e) { FLog.severe("Failed to update admin: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(() -> update(uuid, admin)); } @Override - public CompletableFuture deleteAsync(UUID uuid) + public Mono deleteAsync(UUID uuid) { - return CompletableFuture.supplyAsync(() -> { - try { return delete(uuid); } - catch (SQLException e) { FLog.severe("Failed to delete admin: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(() -> delete(uuid)); } @Override - public CompletableFuture save(UUID uuid, Admin admin) + public Mono save(UUID uuid, Admin admin) { - return CompletableFuture.supplyAsync(() -> { - try { return saveOrUpdate(uuid, admin); } - catch (SQLException e) { FLog.severe("Failed to save admin: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(() -> saveOrUpdate(uuid, admin)); } @Override - public CompletableFuture> findAll() + public Mono> findAll() { - return CompletableFuture.supplyAsync(() -> { - try { return new ArrayList<>(loadAll().values()); } - catch (SQLException e) { FLog.severe("Failed to find all admins: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(() -> new ArrayList<>(loadAll().values())); } @Override - public CompletableFuture deleteByUuid(UUID uuid) + public Mono deleteByUuid(UUID uuid) { return deleteAsync(uuid); } @Override - public CompletableFuture deleteAll() + public Mono deleteAll() { - return CompletableFuture.runAsync(() -> { - try { deleteAllSync(); } - catch (SQLException e) { FLog.severe("Failed to delete all admins: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.runMono(this::deleteAllSync); } // ============================================ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLBanRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLBanRepository.java index 6f0466600..dfac64c11 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLBanRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLBanRepository.java @@ -4,14 +4,14 @@ import me.totalfreedom.totalfreedommod.banning.Ban; import me.totalfreedom.totalfreedommod.sql.StatementHandler; import me.totalfreedom.totalfreedommod.sql.adapter.BanRepository; -import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FUtil; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.*; -import java.util.concurrent.CompletableFuture; + +import reactor.core.publisher.Mono; /** * MySQL/MariaDB implementation of BanRepository. @@ -420,78 +420,59 @@ public void deleteAllSync() throws SQLException // ============================================ @Override - public CompletableFuture> loadAllAsync() + public Mono> loadAllAsync() { - return CompletableFuture.supplyAsync(() -> { - try { return loadAll(); } - catch (SQLException e) { FLog.severe("Failed to load bans: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(this::loadAll); } @Override - public CompletableFuture insertAsync(Ban ban) + public Mono insertAsync(Ban ban) { - return CompletableFuture.supplyAsync(() -> { - try { return insert(ban); } - catch (SQLException e) { FLog.severe("Failed to insert ban: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(() -> insert(ban)); } @Override - public CompletableFuture updateAsync(Ban ban) + public Mono updateAsync(Ban ban) { - return CompletableFuture.supplyAsync(() -> { - try { return update(ban); } - catch (SQLException e) { FLog.severe("Failed to update ban: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(() -> update(ban)); } @Override - public CompletableFuture deleteAsync(UUID uuid) + public Mono deleteAsync(UUID uuid) { - return CompletableFuture.supplyAsync(() -> { - try { return delete(uuid); } - catch (SQLException e) { FLog.severe("Failed to delete ban: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(() -> delete(uuid)); } @Override - public CompletableFuture save(Ban ban) + public Mono save(Ban ban) { - return CompletableFuture.supplyAsync(() -> { - try + return statementHandler.supplyMono(() -> { + // Check if ban exists by UUID + if (ban.getUuid() != null && getBanId(ban.getUuid()) > 0) { - // Check if ban exists by UUID - if (ban.getUuid() != null && getBanId(ban.getUuid()) > 0) - { - update(ban); - return getBanId(ban.getUuid()); - } - return insert(ban); + update(ban); + return getBanId(ban.getUuid()); } - catch (SQLException e) { FLog.severe("Failed to save ban: " + e.getMessage()); throw new RuntimeException(e); } + return insert(ban); }); } @Override - public CompletableFuture> findAll() + public Mono> findAll() { return loadAllAsync(); } @Override - public CompletableFuture deleteByUuid(UUID uuid) + public Mono deleteByUuid(UUID uuid) { return deleteAsync(uuid); } @Override - public CompletableFuture deleteAll() + public Mono deleteAll() { - return CompletableFuture.runAsync(() -> { - try { deleteAllSync(); } - catch (SQLException e) { FLog.severe("Failed to delete all bans: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.runMono(this::deleteAllSync); } // ============================================ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLPermbanRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLPermbanRepository.java index a5530f677..5ee5c0581 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLPermbanRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLPermbanRepository.java @@ -4,13 +4,13 @@ import me.totalfreedom.totalfreedommod.banning.PermBan; import me.totalfreedom.totalfreedommod.sql.StatementHandler; import me.totalfreedom.totalfreedommod.sql.adapter.PermbanRepository; -import me.totalfreedom.totalfreedommod.util.FLog; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.*; -import java.util.concurrent.CompletableFuture; + +import reactor.core.publisher.Mono; /** * MySQL/MariaDB implementation of PermbanRepository. @@ -348,71 +348,52 @@ public void deleteAllSync() throws SQLException // ============================================ @Override - public CompletableFuture> loadAllAsync() + public Mono> loadAllAsync() { - return CompletableFuture.supplyAsync(() -> { - try { return loadAll(); } - catch (SQLException e) { FLog.severe("Failed to load permbans: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(this::loadAll); } @Override - public CompletableFuture insertAsync(PermBan permban) + public Mono insertAsync(PermBan permban) { - return CompletableFuture.supplyAsync(() -> { - try { return insert(permban); } - catch (SQLException e) { FLog.severe("Failed to insert permban: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(() -> insert(permban)); } @Override - public CompletableFuture updateAsync(PermBan permban) + public Mono updateAsync(PermBan permban) { - return CompletableFuture.supplyAsync(() -> { - try { return update(permban); } - catch (SQLException e) { FLog.severe("Failed to update permban: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(() -> update(permban)); } @Override - public CompletableFuture deleteAsync(UUID uuid) + public Mono deleteAsync(UUID uuid) { - return CompletableFuture.supplyAsync(() -> { - try { return delete(uuid); } - catch (SQLException e) { FLog.severe("Failed to delete permban: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(() -> delete(uuid)); } @Override - public CompletableFuture save(PermBan permban) + public Mono save(PermBan permban) { - return CompletableFuture.supplyAsync(() -> { - try + return statementHandler.supplyMono(() -> { + if (permban.getUuid() != null && getPermbanId(permban.getUuid()) > 0) { - if (permban.getUuid() != null && getPermbanId(permban.getUuid()) > 0) - { - update(permban); - return getPermbanId(permban.getUuid()); - } - return insert(permban); + update(permban); + return getPermbanId(permban.getUuid()); } - catch (SQLException e) { FLog.severe("Failed to save permban: " + e.getMessage()); throw new RuntimeException(e); } + return insert(permban); }); } @Override - public CompletableFuture> findAll() + public Mono> findAll() { return loadAllAsync(); } @Override - public CompletableFuture deleteAll() + public Mono deleteAll() { - return CompletableFuture.runAsync(() -> { - try { deleteAllSync(); } - catch (SQLException e) { FLog.severe("Failed to delete all permbans: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.runMono(this::deleteAllSync); } // ============================================ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLStrikeRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLStrikeRepository.java index f234f5656..4741e9037 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLStrikeRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLStrikeRepository.java @@ -4,12 +4,11 @@ import java.sql.SQLException; import java.util.HashMap; import java.util.Map; -import java.util.concurrent.CompletableFuture; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.banning.StrikeRecord; import me.totalfreedom.totalfreedommod.sql.StatementHandler; import me.totalfreedom.totalfreedommod.sql.adapter.StrikeRepository; -import me.totalfreedom.totalfreedommod.util.FLog; +import reactor.core.publisher.Mono; public class MySQLStrikeRepository implements StrikeRepository { @@ -69,38 +68,26 @@ public void deleteAllSync() throws SQLException } @Override - public CompletableFuture> loadAllAsync() + public Mono> loadAllAsync() { - return CompletableFuture.supplyAsync(() -> { - try { return loadAll(); } - catch (SQLException e) { FLog.severe("Failed to load strikes: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(this::loadAll); } @Override - public CompletableFuture upsertAsync(StrikeRecord r) + public Mono upsertAsync(StrikeRecord r) { - return CompletableFuture.runAsync(() -> { - try { upsert(r); } - catch (SQLException e) { FLog.severe("Failed to upsert strike: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.runMono(() -> upsert(r)); } @Override - public CompletableFuture deleteByIpAsync(String ip) + public Mono deleteByIpAsync(String ip) { - return CompletableFuture.supplyAsync(() -> { - try { return deleteByIp(ip); } - catch (SQLException e) { FLog.severe("Failed to delete strike: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(() -> deleteByIp(ip)); } @Override - public CompletableFuture deleteAll() + public Mono deleteAll() { - return CompletableFuture.runAsync(() -> { - try { deleteAllSync(); } - catch (SQLException e) { FLog.severe("Failed to clear strikes: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.runMono(this::deleteAllSync); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdminRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdminRepository.java index 5ee756545..201e8d6b4 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdminRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdminRepository.java @@ -5,14 +5,14 @@ import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.sql.StatementHandler; import me.totalfreedom.totalfreedommod.sql.adapter.AdminRepository; -import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FUtil; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.*; -import java.util.concurrent.CompletableFuture; + +import reactor.core.publisher.Mono; /** * PostgreSQL implementation of AdminRepository. @@ -392,72 +392,51 @@ public void deleteAllSync() throws SQLException // ============================================ @Override - public CompletableFuture> loadAllAsync() + public Mono> loadAllAsync() { - return CompletableFuture.supplyAsync(() -> { - try { return loadAll(); } - catch (SQLException e) { FLog.severe("Failed to load admins: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(this::loadAll); } @Override - public CompletableFuture insertAsync(UUID uuid, Admin admin) + public Mono insertAsync(UUID uuid, Admin admin) { - return CompletableFuture.supplyAsync(() -> { - try { return insert(uuid, admin); } - catch (SQLException e) { FLog.severe("Failed to insert admin: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(() -> insert(uuid, admin)); } @Override - public CompletableFuture updateAsync(UUID uuid, Admin admin) + public Mono updateAsync(UUID uuid, Admin admin) { - return CompletableFuture.supplyAsync(() -> { - try { return update(uuid, admin); } - catch (SQLException e) { FLog.severe("Failed to update admin: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(() -> update(uuid, admin)); } @Override - public CompletableFuture deleteAsync(UUID uuid) + public Mono deleteAsync(UUID uuid) { - return CompletableFuture.supplyAsync(() -> { - try { return delete(uuid); } - catch (SQLException e) { FLog.severe("Failed to delete admin: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(() -> delete(uuid)); } @Override - public CompletableFuture save(UUID uuid, Admin admin) + public Mono save(UUID uuid, Admin admin) { - return CompletableFuture.supplyAsync(() -> { - try { return saveOrUpdate(uuid, admin); } - catch (SQLException e) { FLog.severe("Failed to save admin: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(() -> saveOrUpdate(uuid, admin)); } @Override - public CompletableFuture> findAll() + public Mono> findAll() { - return CompletableFuture.supplyAsync(() -> { - try { return new ArrayList<>(loadAll().values()); } - catch (SQLException e) { FLog.severe("Failed to find all admins: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(() -> new ArrayList<>(loadAll().values())); } @Override - public CompletableFuture deleteByUuid(UUID uuid) + public Mono deleteByUuid(UUID uuid) { return deleteAsync(uuid); } @Override - public CompletableFuture deleteAll() + public Mono deleteAll() { - return CompletableFuture.runAsync(() -> { - try { deleteAllSync(); } - catch (SQLException e) { FLog.severe("Failed to delete all admins: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.runMono(this::deleteAllSync); } // ============================================ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLBanRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLBanRepository.java index 4deb55392..5a1788b95 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLBanRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLBanRepository.java @@ -4,14 +4,14 @@ import me.totalfreedom.totalfreedommod.banning.Ban; import me.totalfreedom.totalfreedommod.sql.StatementHandler; import me.totalfreedom.totalfreedommod.sql.adapter.BanRepository; -import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FUtil; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.*; -import java.util.concurrent.CompletableFuture; + +import reactor.core.publisher.Mono; /** * PostgreSQL implementation of BanRepository. @@ -417,77 +417,58 @@ public void deleteAllSync() throws SQLException // ============================================ @Override - public CompletableFuture> loadAllAsync() + public Mono> loadAllAsync() { - return CompletableFuture.supplyAsync(() -> { - try { return loadAll(); } - catch (SQLException e) { FLog.severe("Failed to load bans: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(this::loadAll); } @Override - public CompletableFuture insertAsync(Ban ban) + public Mono insertAsync(Ban ban) { - return CompletableFuture.supplyAsync(() -> { - try { return insert(ban); } - catch (SQLException e) { FLog.severe("Failed to insert ban: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(() -> insert(ban)); } @Override - public CompletableFuture updateAsync(Ban ban) + public Mono updateAsync(Ban ban) { - return CompletableFuture.supplyAsync(() -> { - try { return update(ban); } - catch (SQLException e) { FLog.severe("Failed to update ban: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(() -> update(ban)); } @Override - public CompletableFuture deleteAsync(UUID uuid) + public Mono deleteAsync(UUID uuid) { - return CompletableFuture.supplyAsync(() -> { - try { return delete(uuid); } - catch (SQLException e) { FLog.severe("Failed to delete ban: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(() -> delete(uuid)); } @Override - public CompletableFuture save(Ban ban) + public Mono save(Ban ban) { - return CompletableFuture.supplyAsync(() -> { - try + return statementHandler.supplyMono(() -> { + if (ban.getUuid() != null && getBanId(ban.getUuid()) > 0) { - if (ban.getUuid() != null && getBanId(ban.getUuid()) > 0) - { - update(ban); - return getBanId(ban.getUuid()); - } - return insert(ban); + update(ban); + return getBanId(ban.getUuid()); } - catch (SQLException e) { FLog.severe("Failed to save ban: " + e.getMessage()); throw new RuntimeException(e); } + return insert(ban); }); } @Override - public CompletableFuture> findAll() + public Mono> findAll() { return loadAllAsync(); } @Override - public CompletableFuture deleteByUuid(UUID uuid) + public Mono deleteByUuid(UUID uuid) { return deleteAsync(uuid); } @Override - public CompletableFuture deleteAll() + public Mono deleteAll() { - return CompletableFuture.runAsync(() -> { - try { deleteAllSync(); } - catch (SQLException e) { FLog.severe("Failed to delete all bans: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.runMono(this::deleteAllSync); } // ============================================ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLPermbanRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLPermbanRepository.java index 50b86901d..e9cdcf25c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLPermbanRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLPermbanRepository.java @@ -4,13 +4,13 @@ import me.totalfreedom.totalfreedommod.banning.PermBan; import me.totalfreedom.totalfreedommod.sql.StatementHandler; import me.totalfreedom.totalfreedommod.sql.adapter.PermbanRepository; -import me.totalfreedom.totalfreedommod.util.FLog; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.*; -import java.util.concurrent.CompletableFuture; + +import reactor.core.publisher.Mono; /** * PostgreSQL implementation of PermbanRepository. @@ -345,71 +345,52 @@ public void deleteAllSync() throws SQLException // ============================================ @Override - public CompletableFuture> loadAllAsync() + public Mono> loadAllAsync() { - return CompletableFuture.supplyAsync(() -> { - try { return loadAll(); } - catch (SQLException e) { FLog.severe("Failed to load permbans: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(this::loadAll); } @Override - public CompletableFuture insertAsync(PermBan permban) + public Mono insertAsync(PermBan permban) { - return CompletableFuture.supplyAsync(() -> { - try { return insert(permban); } - catch (SQLException e) { FLog.severe("Failed to insert permban: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(() -> insert(permban)); } @Override - public CompletableFuture updateAsync(PermBan permban) + public Mono updateAsync(PermBan permban) { - return CompletableFuture.supplyAsync(() -> { - try { return update(permban); } - catch (SQLException e) { FLog.severe("Failed to update permban: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(() -> update(permban)); } @Override - public CompletableFuture deleteAsync(UUID uuid) + public Mono deleteAsync(UUID uuid) { - return CompletableFuture.supplyAsync(() -> { - try { return delete(uuid); } - catch (SQLException e) { FLog.severe("Failed to delete permban: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(() -> delete(uuid)); } @Override - public CompletableFuture save(PermBan permban) + public Mono save(PermBan permban) { - return CompletableFuture.supplyAsync(() -> { - try + return statementHandler.supplyMono(() -> { + if (permban.getUuid() != null && getPermbanId(permban.getUuid()) > 0) { - if (permban.getUuid() != null && getPermbanId(permban.getUuid()) > 0) - { - update(permban); - return getPermbanId(permban.getUuid()); - } - return insert(permban); + update(permban); + return getPermbanId(permban.getUuid()); } - catch (SQLException e) { FLog.severe("Failed to save permban: " + e.getMessage()); throw new RuntimeException(e); } + return insert(permban); }); } @Override - public CompletableFuture> findAll() + public Mono> findAll() { return loadAllAsync(); } @Override - public CompletableFuture deleteAll() + public Mono deleteAll() { - return CompletableFuture.runAsync(() -> { - try { deleteAllSync(); } - catch (SQLException e) { FLog.severe("Failed to delete all permbans: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.runMono(this::deleteAllSync); } // ============================================ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLStrikeRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLStrikeRepository.java index 6b8b655d1..8cb4324e1 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLStrikeRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLStrikeRepository.java @@ -4,12 +4,11 @@ import java.sql.SQLException; import java.util.HashMap; import java.util.Map; -import java.util.concurrent.CompletableFuture; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.banning.StrikeRecord; import me.totalfreedom.totalfreedommod.sql.StatementHandler; import me.totalfreedom.totalfreedommod.sql.adapter.StrikeRepository; -import me.totalfreedom.totalfreedommod.util.FLog; +import reactor.core.publisher.Mono; public class PostgreSQLStrikeRepository implements StrikeRepository { @@ -69,38 +68,26 @@ public void deleteAllSync() throws SQLException } @Override - public CompletableFuture> loadAllAsync() + public Mono> loadAllAsync() { - return CompletableFuture.supplyAsync(() -> { - try { return loadAll(); } - catch (SQLException e) { FLog.severe("Failed to load strikes: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(this::loadAll); } @Override - public CompletableFuture upsertAsync(StrikeRecord r) + public Mono upsertAsync(StrikeRecord r) { - return CompletableFuture.runAsync(() -> { - try { upsert(r); } - catch (SQLException e) { FLog.severe("Failed to upsert strike: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.runMono(() -> upsert(r)); } @Override - public CompletableFuture deleteByIpAsync(String ip) + public Mono deleteByIpAsync(String ip) { - return CompletableFuture.supplyAsync(() -> { - try { return deleteByIp(ip); } - catch (SQLException e) { FLog.severe("Failed to delete strike: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(() -> deleteByIp(ip)); } @Override - public CompletableFuture deleteAll() + public Mono deleteAll() { - return CompletableFuture.runAsync(() -> { - try { deleteAllSync(); } - catch (SQLException e) { FLog.severe("Failed to clear strikes: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.runMono(this::deleteAllSync); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdminRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdminRepository.java index a23d6de0b..55c6aceb8 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdminRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdminRepository.java @@ -5,14 +5,14 @@ import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.sql.StatementHandler; import me.totalfreedom.totalfreedommod.sql.adapter.AdminRepository; -import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FUtil; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.*; -import java.util.concurrent.CompletableFuture; + +import reactor.core.publisher.Mono; /** * SQLite implementation of AdminRepository. @@ -394,72 +394,51 @@ public void deleteAllSync() throws SQLException // ============================================ @Override - public CompletableFuture> loadAllAsync() + public Mono> loadAllAsync() { - return CompletableFuture.supplyAsync(() -> { - try { return loadAll(); } - catch (SQLException e) { FLog.severe("Failed to load admins: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(this::loadAll); } @Override - public CompletableFuture insertAsync(UUID uuid, Admin admin) + public Mono insertAsync(UUID uuid, Admin admin) { - return CompletableFuture.supplyAsync(() -> { - try { return insert(uuid, admin); } - catch (SQLException e) { FLog.severe("Failed to insert admin: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(() -> insert(uuid, admin)); } @Override - public CompletableFuture updateAsync(UUID uuid, Admin admin) + public Mono updateAsync(UUID uuid, Admin admin) { - return CompletableFuture.supplyAsync(() -> { - try { return update(uuid, admin); } - catch (SQLException e) { FLog.severe("Failed to update admin: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(() -> update(uuid, admin)); } @Override - public CompletableFuture deleteAsync(UUID uuid) + public Mono deleteAsync(UUID uuid) { - return CompletableFuture.supplyAsync(() -> { - try { return delete(uuid); } - catch (SQLException e) { FLog.severe("Failed to delete admin: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(() -> delete(uuid)); } @Override - public CompletableFuture save(UUID uuid, Admin admin) + public Mono save(UUID uuid, Admin admin) { - return CompletableFuture.supplyAsync(() -> { - try { return saveOrUpdate(uuid, admin); } - catch (SQLException e) { FLog.severe("Failed to save admin: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(() -> saveOrUpdate(uuid, admin)); } @Override - public CompletableFuture> findAll() + public Mono> findAll() { - return CompletableFuture.supplyAsync(() -> { - try { return new ArrayList<>(loadAll().values()); } - catch (SQLException e) { FLog.severe("Failed to find all admins: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(() -> new ArrayList<>(loadAll().values())); } @Override - public CompletableFuture deleteByUuid(UUID uuid) + public Mono deleteByUuid(UUID uuid) { return deleteAsync(uuid); } @Override - public CompletableFuture deleteAll() + public Mono deleteAll() { - return CompletableFuture.runAsync(() -> { - try { deleteAllSync(); } - catch (SQLException e) { FLog.severe("Failed to delete all admins: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.runMono(this::deleteAllSync); } // ============================================ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteBanRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteBanRepository.java index d7bd253cb..dde32e9ce 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteBanRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteBanRepository.java @@ -4,14 +4,14 @@ import me.totalfreedom.totalfreedommod.banning.Ban; import me.totalfreedom.totalfreedommod.sql.StatementHandler; import me.totalfreedom.totalfreedommod.sql.adapter.BanRepository; -import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FUtil; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.*; -import java.util.concurrent.CompletableFuture; + +import reactor.core.publisher.Mono; /** * SQLite implementation of BanRepository. @@ -422,77 +422,58 @@ public void deleteAllSync() throws SQLException // ============================================ @Override - public CompletableFuture> loadAllAsync() + public Mono> loadAllAsync() { - return CompletableFuture.supplyAsync(() -> { - try { return loadAll(); } - catch (SQLException e) { FLog.severe("Failed to load bans: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(this::loadAll); } @Override - public CompletableFuture insertAsync(Ban ban) + public Mono insertAsync(Ban ban) { - return CompletableFuture.supplyAsync(() -> { - try { return insert(ban); } - catch (SQLException e) { FLog.severe("Failed to insert ban: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(() -> insert(ban)); } @Override - public CompletableFuture updateAsync(Ban ban) + public Mono updateAsync(Ban ban) { - return CompletableFuture.supplyAsync(() -> { - try { return update(ban); } - catch (SQLException e) { FLog.severe("Failed to update ban: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(() -> update(ban)); } @Override - public CompletableFuture deleteAsync(UUID uuid) + public Mono deleteAsync(UUID uuid) { - return CompletableFuture.supplyAsync(() -> { - try { return delete(uuid); } - catch (SQLException e) { FLog.severe("Failed to delete ban: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(() -> delete(uuid)); } @Override - public CompletableFuture save(Ban ban) + public Mono save(Ban ban) { - return CompletableFuture.supplyAsync(() -> { - try + return statementHandler.supplyMono(() -> { + if (ban.getUuid() != null && getBanId(ban.getUuid()) > 0) { - if (ban.getUuid() != null && getBanId(ban.getUuid()) > 0) - { - update(ban); - return getBanId(ban.getUuid()); - } - return insert(ban); + update(ban); + return getBanId(ban.getUuid()); } - catch (SQLException e) { FLog.severe("Failed to save ban: " + e.getMessage()); throw new RuntimeException(e); } + return insert(ban); }); } @Override - public CompletableFuture> findAll() + public Mono> findAll() { return loadAllAsync(); } @Override - public CompletableFuture deleteByUuid(UUID uuid) + public Mono deleteByUuid(UUID uuid) { return deleteAsync(uuid); } @Override - public CompletableFuture deleteAll() + public Mono deleteAll() { - return CompletableFuture.runAsync(() -> { - try { deleteAllSync(); } - catch (SQLException e) { FLog.severe("Failed to delete all bans: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.runMono(this::deleteAllSync); } // ============================================ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLitePermbanRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLitePermbanRepository.java index 4f4bd19d0..e614dc85a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLitePermbanRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLitePermbanRepository.java @@ -4,13 +4,13 @@ import me.totalfreedom.totalfreedommod.banning.PermBan; import me.totalfreedom.totalfreedommod.sql.StatementHandler; import me.totalfreedom.totalfreedommod.sql.adapter.PermbanRepository; -import me.totalfreedom.totalfreedommod.util.FLog; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.*; -import java.util.concurrent.CompletableFuture; + +import reactor.core.publisher.Mono; /** * SQLite implementation of PermbanRepository. @@ -350,71 +350,52 @@ public void deleteAllSync() throws SQLException // ============================================ @Override - public CompletableFuture> loadAllAsync() + public Mono> loadAllAsync() { - return CompletableFuture.supplyAsync(() -> { - try { return loadAll(); } - catch (SQLException e) { FLog.severe("Failed to load permbans: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(this::loadAll); } @Override - public CompletableFuture insertAsync(PermBan permban) + public Mono insertAsync(PermBan permban) { - return CompletableFuture.supplyAsync(() -> { - try { return insert(permban); } - catch (SQLException e) { FLog.severe("Failed to insert permban: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(() -> insert(permban)); } @Override - public CompletableFuture updateAsync(PermBan permban) + public Mono updateAsync(PermBan permban) { - return CompletableFuture.supplyAsync(() -> { - try { return update(permban); } - catch (SQLException e) { FLog.severe("Failed to update permban: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(() -> update(permban)); } @Override - public CompletableFuture deleteAsync(UUID uuid) + public Mono deleteAsync(UUID uuid) { - return CompletableFuture.supplyAsync(() -> { - try { return delete(uuid); } - catch (SQLException e) { FLog.severe("Failed to delete permban: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(() -> delete(uuid)); } @Override - public CompletableFuture save(PermBan permban) + public Mono save(PermBan permban) { - return CompletableFuture.supplyAsync(() -> { - try + return statementHandler.supplyMono(() -> { + if (permban.getUuid() != null && getPermbanId(permban.getUuid()) > 0) { - if (permban.getUuid() != null && getPermbanId(permban.getUuid()) > 0) - { - update(permban); - return getPermbanId(permban.getUuid()); - } - return insert(permban); + update(permban); + return getPermbanId(permban.getUuid()); } - catch (SQLException e) { FLog.severe("Failed to save permban: " + e.getMessage()); throw new RuntimeException(e); } + return insert(permban); }); } @Override - public CompletableFuture> findAll() + public Mono> findAll() { return loadAllAsync(); } @Override - public CompletableFuture deleteAll() + public Mono deleteAll() { - return CompletableFuture.runAsync(() -> { - try { deleteAllSync(); } - catch (SQLException e) { FLog.severe("Failed to delete all permbans: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.runMono(this::deleteAllSync); } // ============================================ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteStrikeRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteStrikeRepository.java index ddad905c5..ea8551c1e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteStrikeRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteStrikeRepository.java @@ -1,16 +1,14 @@ package me.totalfreedom.totalfreedommod.sql.adapter.sqlite; -import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.HashMap; import java.util.Map; -import java.util.concurrent.CompletableFuture; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.banning.StrikeRecord; import me.totalfreedom.totalfreedommod.sql.StatementHandler; import me.totalfreedom.totalfreedommod.sql.adapter.StrikeRepository; -import me.totalfreedom.totalfreedommod.util.FLog; +import reactor.core.publisher.Mono; public class SQLiteStrikeRepository implements StrikeRepository { @@ -70,38 +68,26 @@ public void deleteAllSync() throws SQLException } @Override - public CompletableFuture> loadAllAsync() + public Mono> loadAllAsync() { - return CompletableFuture.supplyAsync(() -> { - try { return loadAll(); } - catch (SQLException e) { FLog.severe("Failed to load strikes: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(this::loadAll); } @Override - public CompletableFuture upsertAsync(StrikeRecord r) + public Mono upsertAsync(StrikeRecord r) { - return CompletableFuture.runAsync(() -> { - try { upsert(r); } - catch (SQLException e) { FLog.severe("Failed to upsert strike: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.runMono(() -> upsert(r)); } @Override - public CompletableFuture deleteByIpAsync(String ip) + public Mono deleteByIpAsync(String ip) { - return CompletableFuture.supplyAsync(() -> { - try { return deleteByIp(ip); } - catch (SQLException e) { FLog.severe("Failed to delete strike: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.supplyMono(() -> deleteByIp(ip)); } @Override - public CompletableFuture deleteAll() + public Mono deleteAll() { - return CompletableFuture.runAsync(() -> { - try { deleteAllSync(); } - catch (SQLException e) { FLog.severe("Failed to clear strikes: " + e.getMessage()); throw new RuntimeException(e); } - }); + return statementHandler.runMono(this::deleteAllSync); } } From d9da8bfc77d65ab278ba72c39cce06b1792afb54 Mon Sep 17 00:00:00 2001 From: Paldiu Date: Mon, 20 Jul 2026 13:43:37 -0500 Subject: [PATCH 03/48] Repository compaction, addendums Compacted all the individual hand rolled implementations per language and just ran everything through the appropriate adapter. Created new repositories to cover a total of 9 repositories, admins, bans, permbans, strikes, discord links, ranks, protected areas, saved flags, playerdata Removed lombok in some classes (PlayerData, ProtectArea) --- .../totalfreedommod/BackupManager.java | 127 +++-- .../totalfreedommod/ProtectArea.java | 41 +- .../totalfreedommod/player/PlayerData.java | 122 ++++- .../sql/adapter/DatabaseAdapter.java | 89 ++- .../sql/adapter/PlayerRepository.java | 53 ++ .../sql/adapter/ProtectedAreaRepository.java | 47 ++ .../sql/adapter/RankRepository.java | 51 ++ .../sql/adapter/SavedFlagRepository.java | 28 + .../GenericAdminRepository.java} | 217 ++++---- .../GenericBanRepository.java} | 219 ++++---- .../generic/GenericDiscordLinkRepository.java | 81 +++ .../GenericPermbanRepository.java} | 174 +++--- .../generic/GenericPlayerRepository.java | 298 +++++++++++ .../GenericProtectedAreaRepository.java | 224 ++++++++ .../generic/GenericRankRepository.java | 295 ++++++++++ .../generic/GenericSavedFlagRepository.java | 96 ++++ .../generic/GenericStrikeRepository.java | 107 ++++ .../sql/adapter/mysql/MySQLAdapter.java | 234 +++++++- .../adapter/mysql/MySQLAdminRepository.java | 473 ---------------- .../sql/adapter/mysql/MySQLBanRepository.java | 505 ----------------- .../mysql/MySQLDiscordLinkRepository.java | 67 --- .../adapter/mysql/MySQLPermbanRepository.java | 420 --------------- .../adapter/mysql/MySQLStrikeRepository.java | 93 ---- .../adapter/postgresql/PostgreSQLAdapter.java | 228 +++++++- .../PostgreSQLDiscordLinkRepository.java | 67 --- .../PostgreSQLStrikeRepository.java | 93 ---- .../sql/adapter/sqlite/SQLiteAdapter.java | 222 +++++++- .../adapter/sqlite/SQLiteAdminRepository.java | 478 ----------------- .../adapter/sqlite/SQLiteBanRepository.java | 506 ------------------ .../sqlite/SQLiteDiscordLinkRepository.java | 67 --- .../sqlite/SQLitePermbanRepository.java | 422 --------------- .../sqlite/SQLiteStrikeRepository.java | 93 ---- 32 files changed, 2461 insertions(+), 3776 deletions(-) create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/PlayerRepository.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/ProtectedAreaRepository.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/RankRepository.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/SavedFlagRepository.java rename src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/{postgresql/PostgreSQLAdminRepository.java => generic/GenericAdminRepository.java} (60%) rename src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/{postgresql/PostgreSQLBanRepository.java => generic/GenericBanRepository.java} (61%) create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericDiscordLinkRepository.java rename src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/{postgresql/PostgreSQLPermbanRepository.java => generic/GenericPermbanRepository.java} (62%) create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericProtectedAreaRepository.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericRankRepository.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericSavedFlagRepository.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericStrikeRepository.java delete mode 100644 src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdminRepository.java delete mode 100644 src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLBanRepository.java delete mode 100644 src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLDiscordLinkRepository.java delete mode 100644 src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLPermbanRepository.java delete mode 100644 src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLStrikeRepository.java delete mode 100644 src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLDiscordLinkRepository.java delete mode 100644 src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLStrikeRepository.java delete mode 100644 src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdminRepository.java delete mode 100644 src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteBanRepository.java delete mode 100644 src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteDiscordLinkRepository.java delete mode 100644 src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLitePermbanRepository.java delete mode 100644 src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteStrikeRepository.java diff --git a/src/main/java/me/totalfreedom/totalfreedommod/BackupManager.java b/src/main/java/me/totalfreedom/totalfreedommod/BackupManager.java index 484b5bfc4..ecbc0a8fe 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/BackupManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/BackupManager.java @@ -1,16 +1,27 @@ package me.totalfreedom.totalfreedommod; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.reflect.TypeToken; + import java.io.File; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FUtil; -import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; import java.io.IOException; +import java.lang.reflect.Type; +import java.util.HashMap; +import java.util.Map; + import me.totalfreedom.totalfreedommod.framework.PluginComponent; -import org.bukkit.configuration.file.YamlConfiguration; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.FUtil; + import org.bukkit.util.FileUtil; public class BackupManager extends PluginComponent { + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + private static final Type BACKUP_DATA_TYPE = new TypeToken>() {}.getType(); public BackupManager(TotalFreedomMod plugin) { @@ -25,91 +36,79 @@ public void createBackups(String file) public void createBackups(String file, boolean onlyWeekly) { final String save = file.split("\\.")[0]; - final File configFile = new File(plugin.getDataFolder(), "backup/backup.yml"); - if (!configFile.exists()) - { - try - { - configFile.getParentFile().mkdirs(); - configFile.createNewFile(); - } - catch (IOException ex) - { - FLog.severe("Could not create backup.yml"); - } - } - final YamlConfiguration config = YamlConfiguration.loadConfiguration(configFile); + final File configFile = new File(plugin.getDataFolder(), "backup/backup.json"); + final Map data = loadBackupData(configFile); + final BackupEntry entry = data.getOrDefault(save, new BackupEntry(null, null)); - // Weekly - if (!config.isInt(save + ".weekly")) + Long weekly = entry.weekly(); + if (weekly == null || weekly + 3600L * 24 * 7 < FUtil.getUnixTime()) { performBackup(file, "weekly"); - config.set(save + ".weekly", FUtil.getUnixTime()); + weekly = FUtil.getUnixTime(); } - else - { - int lastBackupWeekly = config.getInt(save + ".weekly"); - if (lastBackupWeekly + 3600 * 24 * 7 < FUtil.getUnixTime()) - { - performBackup(file, "weekly"); - config.set(save + ".weekly", FUtil.getUnixTime()); - } + Long daily = entry.daily(); + if (!onlyWeekly && (daily == null || daily + 3600L * 24 < FUtil.getUnixTime())) + { + performBackup(file, "daily"); + daily = FUtil.getUnixTime(); } - if (onlyWeekly) - { - try + data.put(save, new BackupEntry(weekly, daily)); + saveBackupData(configFile, data); + } + + private void performBackup(String file, String type) + { + FLog.info("Backing up " + file + " to " + file + "." + type + ".bak"); + final File backupFolder = new File(plugin.getDataFolder(), "backup"); + + if (!backupFolder.exists()) { - config.save(configFile); + backupFolder.mkdirs(); } - catch (IOException ex) + + final File oldYaml = new File(plugin.getDataFolder(), file); + final File newYaml = new File(backupFolder, file + "." + type + ".bak"); + FileUtil.copy(oldYaml, newYaml); + } + + private Map loadBackupData(File configFile) + { + if (!configFile.exists()) { - FLog.severe("Could not save backup.yml"); - } - return; + return new HashMap<>(); } - // Daily - if (!config.isInt(save + ".daily")) + try (FileReader reader = new FileReader(configFile)) { - performBackup(file, "daily"); - config.set(save + ".daily", FUtil.getUnixTime()); + Map data = GSON.fromJson(reader, BACKUP_DATA_TYPE); + return data != null ? data : new HashMap<>(); } - else + catch (IOException ex) { - int lastBackupDaily = config.getInt(save + ".daily"); - - if (lastBackupDaily + 3600 * 24 < FUtil.getUnixTime()) - { - performBackup(file, "daily"); - config.set(save + ".daily", FUtil.getUnixTime()); - } + FLog.severe("Could not read backup.json: " + ex.getMessage()); + return new HashMap<>(); } + } + private void saveBackupData(File configFile, Map data) + { try { - config.save(configFile); + configFile.getParentFile().mkdirs(); + try (FileWriter writer = new FileWriter(configFile)) + { + GSON.toJson(data, BACKUP_DATA_TYPE, writer); + } } catch (IOException ex) { - FLog.severe("Could not save backup.yml"); + FLog.severe("Could not save backup.json: " + ex.getMessage()); } } - private void performBackup(String file, String type) + private record BackupEntry(Long weekly, Long daily) { - FLog.info("Backing up " + file + " to " + file + "." + type + ".bak"); - final File backupFolder = new File(plugin.getDataFolder(), "backup"); - - if (!backupFolder.exists()) - { - backupFolder.mkdirs(); - } - - final File oldYaml = new File(plugin.getDataFolder(), file); - final File newYaml = new File(backupFolder, file + "." + type + ".bak"); - FileUtil.copy(oldYaml, newYaml); } - } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java b/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java index e0a2d1c43..58ba6f9d1 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java @@ -2,8 +2,6 @@ import com.google.common.collect.Maps; -import lombok.Getter; - import java.io.File; import java.io.FileInputStream; import java.io.IOException; @@ -858,9 +856,7 @@ private void sweepItems() public static class ProtectedRegion { - @Getter private UUID uuid; - @Getter private String name; private Vector min; private Vector max; @@ -903,6 +899,43 @@ public ProtectedRegion(final UUID uuid, this.max = new Vector(maxX, maxY, maxZ); } + public UUID getUuid() + { + return uuid; + } + + public String getName() + { + return name; + } + + /** + * Raw world reference, usable for persistence without requiring the world to + * currently be loaded in Bukkit (unlike {@link #getWorld()}). + */ + public UUID getWorldUUID() + { + return worldUUID; + } + + /** + * Raw minimum corner, usable for persistence without requiring the world to + * currently be loaded in Bukkit (unlike {@link #getMinimumPoint()}). + */ + public Vector getMinVector() + { + return min; + } + + /** + * Raw maximum corner, usable for persistence without requiring the world to + * currently be loaded in Bukkit (unlike {@link #getMaximumPoint()}). + */ + public Vector getMaxVector() + { + return max; + } + public World getWorld() throws CantFindWorldException { if (this.world != null) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerData.java b/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerData.java index 4dede4fce..1a03e6335 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerData.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerData.java @@ -4,8 +4,6 @@ import com.google.common.collect.Lists; import java.util.Collections; import java.util.List; -import lombok.Getter; -import lombok.Setter; import me.totalfreedom.totalfreedommod.PluginProvider; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.util.AdventureUtil; @@ -25,34 +23,16 @@ public class PlayerData implements ConfigLoadable, ConfigSavable, Validatable // max number of IP addresses retained per player public static final int MAX_IPS = 2; - @Getter - @Setter private String username; - @Getter - @Setter private long firstJoinUnix; - @Getter - @Setter private long lastJoinUnix; - @Getter - @Setter private boolean potionSpy; private CommandSpyMode commandSpyMode = CommandSpyMode.OFF; - @Getter - @Setter private boolean muted; - @Getter - @Setter private boolean frozen; - @Getter - @Setter private boolean commandsBlocked; - @Getter - @Setter private String savedTag; - @Getter private Component nickname; - @Getter private int strikes; private final List ips = Lists.newArrayList(); @@ -66,6 +46,96 @@ public PlayerData(String username) this.username = username; } + public String getUsername() + { + return username; + } + + public void setUsername(String username) + { + this.username = username; + } + + public long getFirstJoinUnix() + { + return firstJoinUnix; + } + + public void setFirstJoinUnix(long firstJoinUnix) + { + this.firstJoinUnix = firstJoinUnix; + } + + public long getLastJoinUnix() + { + return lastJoinUnix; + } + + public void setLastJoinUnix(long lastJoinUnix) + { + this.lastJoinUnix = lastJoinUnix; + } + + public boolean isPotionSpy() + { + return potionSpy; + } + + public void setPotionSpy(boolean potionSpy) + { + this.potionSpy = potionSpy; + } + + public boolean isMuted() + { + return muted; + } + + public void setMuted(boolean muted) + { + this.muted = muted; + } + + public boolean isFrozen() + { + return frozen; + } + + public void setFrozen(boolean frozen) + { + this.frozen = frozen; + } + + public boolean isCommandsBlocked() + { + return commandsBlocked; + } + + public void setCommandsBlocked(boolean commandsBlocked) + { + this.commandsBlocked = commandsBlocked; + } + + public String getSavedTag() + { + return savedTag; + } + + public void setSavedTag(String savedTag) + { + this.savedTag = savedTag; + } + + public Component getNickname() + { + return nickname; + } + + public int getStrikes() + { + return strikes; + } + @Override public void loadFrom(ConfigurationSection cs) { @@ -191,6 +261,18 @@ public void setNickname(Component nickname) plugin.pl.saveData(this); } + /** + * Set the nickname without the display/save side effects {@link #setNickname} has. + * Used when hydrating a PlayerData from storage (repository load), where the player + * is not necessarily online and a save-after-load would be redundant. + */ + public void setNicknameRaw(Component nickname) + { + this.nickname = nickname; + if (!hasCustomNickname()) + this.nickname = null; + } + public Component getDisplayedNickname() { if (!hasCustomNickname()) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DatabaseAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DatabaseAdapter.java index 6d857865d..a2b44d8ea 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DatabaseAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DatabaseAdapter.java @@ -84,6 +84,26 @@ public void shutdown() */ public abstract DiscordLinkRepository getDiscordLinkRepository(); + /** + * Get the rank repository for this database type. + */ + public abstract RankRepository getRankRepository(); + + /** + * Get the protected area repository for this database type. + */ + public abstract ProtectedAreaRepository getProtectedAreaRepository(); + + /** + * Get the saved flag repository for this database type. + */ + public abstract SavedFlagRepository getSavedFlagRepository(); + + /** + * Get the per-player data repository for this database type. + */ + public abstract PlayerRepository getPlayerRepository(); + // ============================================ // SQL Dialect Methods (override for differences) // ============================================ @@ -125,14 +145,39 @@ public void shutdown() */ public abstract String booleanType(); + /** + * Get the JSON column type for this database. + * SQLite: TEXT (no native JSON type; validate/query with the JSON1 extension's + * json_valid()/json_extract() if needed) + * MySQL: JSON + * PostgreSQL: JSONB + */ + public abstract String jsonType(); + + /** + * Get the bind-parameter placeholder for a value being written into a native + * JSON column. PostgreSQL requires an explicit cast since it won't implicitly + * convert a bound String to jsonb. + * SQLite/MySQL: "?" + * PostgreSQL: "?::jsonb" + */ + public abstract String jsonParamPlaceholder(); + /** * Get the INSERT IGNORE / INSERT OR IGNORE syntax prefix. * SQLite: INSERT OR IGNORE * MySQL: INSERT IGNORE - * PostgreSQL: INSERT (use ON CONFLICT DO NOTHING suffix) + * PostgreSQL: INSERT (use insertIgnoreSuffix() for ON CONFLICT DO NOTHING) */ public abstract String insertIgnoreSyntax(); + /** + * Get the trailing clause (if any) needed to make an INSERT a no-op on conflict. + * SQLite/MySQL: "" (handled entirely by the insertIgnoreSyntax() prefix) + * PostgreSQL: " ON CONFLICT DO NOTHING" + */ + public abstract String insertIgnoreSuffix(); + /** * Quote an identifier (table name, column name) for this database. * SQLite: No quoting or double quotes @@ -142,18 +187,46 @@ public void shutdown() public abstract String quoteIdentifier(String identifier); /** - * Get the current timestamp function. - * SQLite: CURRENT_TIMESTAMP or datetime('now') + * Get the current timestamp function, for use as a literal insert value. + * SQLite: CURRENT_TIMESTAMP * MySQL: NOW() * PostgreSQL: CURRENT_TIMESTAMP */ public abstract String currentTimestamp(); /** - * Get the case-insensitive LIKE operator. - * SQLite: LIKE (case-insensitive by default) - * MySQL: LIKE (depends on collation) - * PostgreSQL: ILIKE + * Get the bind-parameter placeholder for a value being written into a native + * timestamp column. PostgreSQL requires an explicit cast since it won't + * implicitly convert a bound String to timestamp. + * SQLite/MySQL: "?" + * PostgreSQL: "?::timestamp" + */ + public abstract String timestampParamPlaceholder(); + + /** + * Build a case-insensitive equality comparison between a (already-quoted) column + * reference and a bind-parameter placeholder. + * SQLite/MySQL: LOWER(columnRef) = LOWER(paramPlaceholder) + * PostgreSQL: columnRef ILIKE paramPlaceholder + */ + public abstract String caseInsensitiveEquals(String columnRef, String paramPlaceholder); + + /** + * Build a boolean expression comparing a stored (already-quoted) timestamp column + * against the current time. SQLite stores timestamps as formatted TEXT, so both + * sides need datetime() normalization; MySQL/PostgreSQL compare native + * DATETIME/TIMESTAMP columns directly. + * + * @param columnRef the already-quoted column reference + * @param operator e.g. ">", "<=" + */ + public abstract String compareToNow(String columnRef, String operator); + + /** + * Build the upsert clause appended after an INSERT ... VALUES (...) to update the + * given columns from the incoming row on a conflict against conflictColumn. + * MySQL: ON DUPLICATE KEY UPDATE col = VALUES(col), ... + * SQLite/PostgreSQL: ON CONFLICT(conflictColumn) DO UPDATE SET col = EXCLUDED.col, ... */ - public abstract String caseInsensitiveLike(); + public abstract String upsertClause(String conflictColumn, String... updateColumns); } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/PlayerRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/PlayerRepository.java new file mode 100644 index 000000000..6dbe1c3ad --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/PlayerRepository.java @@ -0,0 +1,53 @@ +package me.totalfreedom.totalfreedommod.sql.adapter; + +import me.totalfreedom.totalfreedommod.player.PlayerData; + +import java.sql.SQLException; +import java.util.List; +import java.util.Map; + +import reactor.core.publisher.Mono; + +/** + * Repository interface for per-player data. + * + * A player's own {@code username} is its primary key, so no generated-key handling is needed. + */ +public interface PlayerRepository +{ + void insert(PlayerData data) throws SQLException; + + /** + * Insert IPs for a player, preserving list order (oldest first) so it round-trips + * through {@link #getIps} correctly. + */ + void insertIps(String username, List ips) throws SQLException; + + void addIp(String username, String ip) throws SQLException; + + Map loadAll() throws SQLException; + + PlayerData findByUsername(String username) throws SQLException; + + boolean exists(String username) throws SQLException; + + List getIps(String username) throws SQLException; + + boolean update(PlayerData data) throws SQLException; + + void syncIps(String username, List ips) throws SQLException; + + void saveOrUpdate(PlayerData data) throws SQLException; + + boolean delete(String username) throws SQLException; + + void deleteAllSync() throws SQLException; + + Mono> loadAllAsync(); + + Mono save(PlayerData data); + + Mono deleteAsync(String username); + + Mono deleteAll(); +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/ProtectedAreaRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/ProtectedAreaRepository.java new file mode 100644 index 000000000..b24956557 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/ProtectedAreaRepository.java @@ -0,0 +1,47 @@ +package me.totalfreedom.totalfreedommod.sql.adapter; + +import me.totalfreedom.totalfreedommod.ProtectArea.ProtectedRegion; + +import java.sql.SQLException; +import java.util.List; +import java.util.UUID; + +import reactor.core.publisher.Mono; + +/** + * Repository interface for ProtectedRegion data. + * + * A region's own {@code uuid} is its primary key, so no generated-key handling is needed. + */ +public interface ProtectedAreaRepository +{ + void insert(ProtectedRegion region) throws SQLException; + + /** + * Load all regions. Rows whose stored world UUID fails to parse are skipped + * with a warning, matching {@code ProtectArea}'s existing YAML-load behavior. + */ + List loadAll() throws SQLException; + + ProtectedRegion findByUuid(UUID uuid) throws SQLException; + + ProtectedRegion findByName(String name) throws SQLException; + + boolean exists(UUID uuid) throws SQLException; + + boolean update(ProtectedRegion region) throws SQLException; + + void saveOrUpdate(ProtectedRegion region) throws SQLException; + + boolean delete(UUID uuid) throws SQLException; + + void deleteAllSync() throws SQLException; + + Mono> loadAllAsync(); + + Mono save(ProtectedRegion region); + + Mono deleteAsync(UUID uuid); + + Mono deleteAll(); +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/RankRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/RankRepository.java new file mode 100644 index 000000000..843070f50 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/RankRepository.java @@ -0,0 +1,51 @@ +package me.totalfreedom.totalfreedommod.sql.adapter; + +import me.totalfreedom.totalfreedommod.rank.CustomRank; + +import java.sql.SQLException; +import java.util.Map; +import java.util.Set; + +import reactor.core.publisher.Mono; + +/** + * Repository interface for CustomRank data. + * + * A rank's own {@code id} string is its primary key, so no generated-key handling is needed. + */ +public interface RankRepository +{ + void insert(CustomRank rank) throws SQLException; + + void insertPermissions(String rankId, Set permissions) throws SQLException; + + void addPermission(String rankId, String permission) throws SQLException; + + Map loadAll() throws SQLException; + + CustomRank findById(String id) throws SQLException; + + boolean exists(String id) throws SQLException; + + Set getPermissions(String rankId) throws SQLException; + + boolean update(CustomRank rank) throws SQLException; + + void syncPermissions(String rankId, Set permissions) throws SQLException; + + void saveOrUpdate(CustomRank rank) throws SQLException; + + boolean delete(String id) throws SQLException; + + boolean removePermission(String rankId, String permission) throws SQLException; + + void deleteAllSync() throws SQLException; + + Mono> loadAllAsync(); + + Mono save(CustomRank rank); + + Mono deleteAsync(String id); + + Mono deleteAll(); +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/SavedFlagRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/SavedFlagRepository.java new file mode 100644 index 000000000..be1ef47aa --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/SavedFlagRepository.java @@ -0,0 +1,28 @@ +package me.totalfreedom.totalfreedommod.sql.adapter; + +import java.sql.SQLException; +import java.util.Map; + +import reactor.core.publisher.Mono; + +/** + * Repository interface for saved flag data. + */ +public interface SavedFlagRepository +{ + Map loadAll() throws SQLException; + + void upsert(String flagName, boolean enabled) throws SQLException; + + boolean delete(String flagName) throws SQLException; + + void deleteAllSync() throws SQLException; + + Mono> loadAllAsync(); + + Mono upsertAsync(String flagName, boolean enabled); + + Mono deleteAsync(String flagName); + + Mono deleteAll(); +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdminRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericAdminRepository.java similarity index 60% rename from src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdminRepository.java rename to src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericAdminRepository.java index 201e8d6b4..73b620f95 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdminRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericAdminRepository.java @@ -1,10 +1,10 @@ -package me.totalfreedom.totalfreedommod.sql.adapter.postgresql; +package me.totalfreedom.totalfreedommod.sql.adapter.generic; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.admin.Admin; import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.sql.StatementHandler; import me.totalfreedom.totalfreedommod.sql.adapter.AdminRepository; +import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; import me.totalfreedom.totalfreedommod.util.FUtil; import java.sql.PreparedStatement; @@ -15,54 +15,70 @@ import reactor.core.publisher.Mono; /** - * PostgreSQL implementation of AdminRepository. - * Uses PostgreSQL-specific SQL syntax including: - * - ILIKE for case-insensitive comparisons - * - ON CONFLICT DO NOTHING for upsert operations - * - Double quotes for identifier escaping - * - Native BOOLEAN type + * All dialect differences are resolved through the {@link DatabaseAdapter} passed in. */ -public class PostgreSQLAdminRepository implements AdminRepository +public class GenericAdminRepository implements AdminRepository { - private final TotalFreedomMod plugin; private final StatementHandler statementHandler; - - public PostgreSQLAdminRepository(TotalFreedomMod plugin, StatementHandler statementHandler) + private final DatabaseAdapter adapter; + + private final String tblAdmins; + private final String tblAdminIps; + private final String colId; + private final String colUuid; + private final String colUsername; + private final String colRank; + private final String colActive; + private final String colLastLogin; + private final String colLoginMessage; + private final String colCustomRank; + private final String colAdminId; + private final String colIp; + private final String selectColumns; + + public GenericAdminRepository(StatementHandler statementHandler, DatabaseAdapter adapter) { - this.plugin = plugin; this.statementHandler = statementHandler; - } + this.adapter = adapter; - // ============================================ - // CREATE Operations - // ============================================ + this.tblAdmins = adapter.quoteIdentifier("admins"); + this.tblAdminIps = adapter.quoteIdentifier("admin_ips"); + this.colId = adapter.quoteIdentifier("id"); + this.colUuid = adapter.quoteIdentifier("uuid"); + this.colUsername = adapter.quoteIdentifier("username"); + this.colRank = adapter.quoteIdentifier("rank"); + this.colActive = adapter.quoteIdentifier("active"); + this.colLastLogin = adapter.quoteIdentifier("last_login"); + this.colLoginMessage = adapter.quoteIdentifier("login_message"); + this.colCustomRank = adapter.quoteIdentifier("custom_rank"); + this.colAdminId = adapter.quoteIdentifier("admin_id"); + this.colIp = adapter.quoteIdentifier("ip"); + this.selectColumns = String.format("%s, %s, %s, %s, %s, %s, %s, %s", + colId, colUuid, colUsername, colRank, colActive, colLastLogin, colLoginMessage, colCustomRank); + } @Override public int insert(UUID uuid, Admin admin) throws SQLException { - String sql = """ - INSERT INTO "admins" ("uuid", "username", "rank", "active", "last_login", "login_message") - VALUES (?, ?, ?, ?, ?::timestamp, ?) - RETURNING "id" - """; + String sql = String.format("INSERT INTO %s (%s, %s, %s, %s, %s, %s, %s) VALUES (?, ?, ?, ?, %s, ?, ?)", + tblAdmins, colUuid, colUsername, colRank, colActive, colLastLogin, colLoginMessage, colCustomRank, + adapter.timestampParamPlaceholder()); - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, + long adminId = statementHandler.executeUpdateReturnKey(sql, uuid.toString(), admin.getName(), admin.getRank().toString(), admin.isActive(), FUtil.dateToString(admin.getLastLogin()), - admin.getLoginMessage()); - ResultSet rs = stmt.executeQuery()) + admin.getLoginMessage(), + admin.getCustomRankId()); + + if (adminId < 0) { - if (rs.next()) - { - int adminId = rs.getInt("id"); - insertIps(adminId, admin.getIps()); - return adminId; - } + return -1; } - return -1; + insertIps((int) adminId, admin.getIps()); + return (int) adminId; } @Override @@ -70,8 +86,8 @@ public void insertIps(int adminId, List ips) throws SQLException { if (ips == null || ips.isEmpty()) return; - // PostgreSQL: ON CONFLICT DO NOTHING - String sql = "INSERT INTO \"admin_ips\" (\"admin_id\", \"ip\") VALUES (?, ?) ON CONFLICT DO NOTHING"; + String sql = String.format("%s INTO %s (%s, %s) VALUES (?, ?)%s", + adapter.insertIgnoreSyntax(), tblAdminIps, colAdminId, colIp, adapter.insertIgnoreSuffix()); for (String ip : ips) { statementHandler.executeUpdate(sql, adminId, ip); @@ -81,53 +97,30 @@ public void insertIps(int adminId, List ips) throws SQLException @Override public void addIp(int adminId, String ip) throws SQLException { - String sql = "INSERT INTO \"admin_ips\" (\"admin_id\", \"ip\") VALUES (?, ?) ON CONFLICT DO NOTHING"; + String sql = String.format("%s INTO %s (%s, %s) VALUES (?, ?)%s", + adapter.insertIgnoreSyntax(), tblAdminIps, colAdminId, colIp, adapter.insertIgnoreSuffix()); statementHandler.executeUpdate(sql, adminId, ip); } - // ============================================ - // READ Operations - // ============================================ - @Override public Map loadAll() throws SQLException { Map admins = new HashMap<>(); Map adminById = new HashMap<>(); - String sql = "SELECT \"id\", \"uuid\", \"username\", \"rank\", \"active\", \"last_login\", \"login_message\" FROM \"admins\""; + String sql = String.format("SELECT %s FROM %s", selectColumns, tblAdmins); try (ResultSet rs = statementHandler.executeQuery(sql)) { while (rs.next()) { int id = rs.getInt("id"); - String username = rs.getString("username"); - String rankStr = rs.getString("rank"); - boolean active = rs.getBoolean("active"); - String lastLoginStr = rs.getString("last_login"); - String loginMessage = rs.getString("login_message"); - - String configKey = username.toLowerCase(); - Admin admin = new Admin(configKey); - admin.setName(username); - admin.setRank(Rank.findRank(rankStr)); - admin.setActive(active); - admin.setLastLogin(FUtil.stringToDate(lastLoginStr)); - admin.setLoginMessage(loginMessage); - - UUID dbUuid = FUtil.parseUuid(rs.getString("uuid")); - if (dbUuid != null) - { - admin.setUuid(dbUuid); - } - - admins.put(configKey, admin); + Admin admin = loadAdminFromRow(rs); + admins.put(admin.getName().toLowerCase(), admin); adminById.put(id, admin); } } - // Load IPs - String ipSql = "SELECT \"admin_id\", \"ip\" FROM \"admin_ips\""; + String ipSql = String.format("SELECT %s, %s FROM %s", colAdminId, colIp, tblAdminIps); try (ResultSet rs = statementHandler.executeQuery(ipSql)) { while (rs.next()) @@ -148,7 +141,7 @@ public Map loadAll() throws SQLException @Override public Admin findByUuid(UUID uuid) throws SQLException { - String sql = "SELECT \"id\", \"uuid\", \"username\", \"rank\", \"active\", \"last_login\", \"login_message\" FROM \"admins\" WHERE \"uuid\" = ?"; + String sql = String.format("SELECT %s FROM %s WHERE %s = ?", selectColumns, tblAdmins, colUuid); try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); ResultSet rs = stmt.executeQuery()) { @@ -163,8 +156,8 @@ public Admin findByUuid(UUID uuid) throws SQLException @Override public Admin findByUsername(String username) throws SQLException { - // PostgreSQL: ILIKE for case-insensitive comparison - String sql = "SELECT \"id\", \"uuid\", \"username\", \"rank\", \"active\", \"last_login\", \"login_message\" FROM \"admins\" WHERE \"username\" ILIKE ?"; + String sql = String.format("SELECT %s FROM %s WHERE %s", + selectColumns, tblAdmins, adapter.caseInsensitiveEquals(colUsername, "?")); try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); ResultSet rs = stmt.executeQuery()) { @@ -179,12 +172,10 @@ public Admin findByUsername(String username) throws SQLException @Override public Admin findByIp(String ip) throws SQLException { - String sql = """ - SELECT a."id", a."uuid", a."username", a."rank", a."active", a."last_login", a."login_message" - FROM "admins" a - INNER JOIN "admin_ips" ai ON a."id" = ai."admin_id" - WHERE ai."ip" = ? - """; + String sql = String.format( + "SELECT a.%s, a.%s, a.%s, a.%s, a.%s, a.%s, a.%s, a.%s FROM %s a INNER JOIN %s ai ON a.%s = ai.%s WHERE ai.%s = ?", + colId, colUuid, colUsername, colRank, colActive, colLastLogin, colLoginMessage, colCustomRank, + tblAdmins, tblAdminIps, colId, colAdminId, colIp); try (PreparedStatement stmt = statementHandler.prepareStatement(sql, ip); ResultSet rs = stmt.executeQuery()) { @@ -199,7 +190,8 @@ public Admin findByIp(String ip) throws SQLException @Override public int getAdminId(String username) throws SQLException { - String sql = "SELECT \"id\" FROM \"admins\" WHERE \"username\" ILIKE ?"; + String sql = String.format("SELECT %s FROM %s WHERE %s", + colId, tblAdmins, adapter.caseInsensitiveEquals(colUsername, "?")); try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); ResultSet rs = stmt.executeQuery()) { @@ -211,7 +203,7 @@ public int getAdminId(String username) throws SQLException @Override public int getAdminIdByUuid(UUID uuid) throws SQLException { - String sql = "SELECT \"id\" FROM \"admins\" WHERE \"uuid\" = ?"; + String sql = String.format("SELECT %s FROM %s WHERE %s = ?", colId, tblAdmins, colUuid); try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); ResultSet rs = stmt.executeQuery()) { @@ -224,7 +216,7 @@ public int getAdminIdByUuid(UUID uuid) throws SQLException public List getIps(int adminId) throws SQLException { List ips = new ArrayList<>(); - String sql = "SELECT \"ip\" FROM \"admin_ips\" WHERE \"admin_id\" = ?"; + String sql = String.format("SELECT %s FROM %s WHERE %s = ?", colIp, tblAdminIps, colAdminId); try (PreparedStatement stmt = statementHandler.prepareStatement(sql, adminId); ResultSet rs = stmt.executeQuery()) { @@ -239,7 +231,8 @@ public List getIps(int adminId) throws SQLException @Override public boolean exists(String username) throws SQLException { - String sql = "SELECT COUNT(*) FROM \"admins\" WHERE \"username\" ILIKE ?"; + String sql = String.format("SELECT COUNT(*) FROM %s WHERE %s", + tblAdmins, adapter.caseInsensitiveEquals(colUsername, "?")); try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); ResultSet rs = stmt.executeQuery()) { @@ -250,7 +243,7 @@ public boolean exists(String username) throws SQLException @Override public boolean existsByUuid(UUID uuid) throws SQLException { - String sql = "SELECT COUNT(*) FROM \"admins\" WHERE \"uuid\" = ?"; + String sql = String.format("SELECT COUNT(*) FROM %s WHERE %s = ?", tblAdmins, colUuid); try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); ResultSet rs = stmt.executeQuery()) { @@ -261,7 +254,8 @@ public boolean existsByUuid(UUID uuid) throws SQLException @Override public UUID getUuidByUsername(String username) throws SQLException { - String sql = "SELECT \"uuid\" FROM \"admins\" WHERE \"username\" ILIKE ?"; + String sql = String.format("SELECT %s FROM %s WHERE %s", + colUuid, tblAdmins, adapter.caseInsensitiveEquals(colUsername, "?")); try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); ResultSet rs = stmt.executeQuery()) { @@ -273,18 +267,12 @@ public UUID getUuidByUsername(String username) throws SQLException return null; } - // ============================================ - // UPDATE Operations - // ============================================ - @Override public boolean update(UUID uuid, Admin admin) throws SQLException { - String sql = """ - UPDATE "admins" - SET "username" = ?, "rank" = ?, "active" = ?, "last_login" = ?::timestamp, "login_message" = ? - WHERE "uuid" = ? - """; + String sql = String.format("UPDATE %s SET %s = ?, %s = ?, %s = ?, %s = %s, %s = ?, %s = ? WHERE %s = ?", + tblAdmins, colUsername, colRank, colActive, colLastLogin, adapter.timestampParamPlaceholder(), + colLoginMessage, colCustomRank, colUuid); int rows = statementHandler.executeUpdate(sql, admin.getName(), @@ -292,6 +280,7 @@ public boolean update(UUID uuid, Admin admin) throws SQLException admin.isActive(), FUtil.dateToString(admin.getLastLogin()), admin.getLoginMessage(), + admin.getCustomRankId(), uuid.toString()); return rows > 0; @@ -300,35 +289,38 @@ public boolean update(UUID uuid, Admin admin) throws SQLException @Override public boolean updateRank(String username, String rank) throws SQLException { - String sql = "UPDATE \"admins\" SET \"rank\" = ? WHERE \"username\" ILIKE ?"; + String sql = String.format("UPDATE %s SET %s = ? WHERE %s", + tblAdmins, colRank, adapter.caseInsensitiveEquals(colUsername, "?")); return statementHandler.executeUpdate(sql, rank, username) > 0; } @Override public boolean updateActive(String username, boolean active) throws SQLException { - String sql = "UPDATE \"admins\" SET \"active\" = ? WHERE \"username\" ILIKE ?"; + String sql = String.format("UPDATE %s SET %s = ? WHERE %s", + tblAdmins, colActive, adapter.caseInsensitiveEquals(colUsername, "?")); return statementHandler.executeUpdate(sql, active, username) > 0; } @Override public boolean updateLastLogin(String username, Date lastLogin) throws SQLException { - String sql = "UPDATE \"admins\" SET \"last_login\" = ?::timestamp WHERE \"username\" ILIKE ?"; + String sql = String.format("UPDATE %s SET %s = %s WHERE %s", + tblAdmins, colLastLogin, adapter.timestampParamPlaceholder(), adapter.caseInsensitiveEquals(colUsername, "?")); return statementHandler.executeUpdate(sql, FUtil.dateToString(lastLogin), username) > 0; } @Override public boolean updateUsername(UUID uuid, String newUsername) throws SQLException { - String sql = "UPDATE \"admins\" SET \"username\" = ? WHERE \"uuid\" = ?"; + String sql = String.format("UPDATE %s SET %s = ? WHERE %s = ?", tblAdmins, colUsername, colUuid); return statementHandler.executeUpdate(sql, newUsername, uuid.toString()) > 0; } @Override public void syncIps(int adminId, List ips) throws SQLException { - statementHandler.executeUpdate("DELETE FROM \"admin_ips\" WHERE \"admin_id\" = ?", adminId); + statementHandler.executeUpdate(String.format("DELETE FROM %s WHERE %s = ?", tblAdminIps, colAdminId), adminId); insertIps(adminId, ips); } @@ -348,49 +340,41 @@ public int saveOrUpdate(UUID uuid, Admin admin) throws SQLException } } - // ============================================ - // DELETE Operations - // ============================================ - @Override public boolean delete(UUID uuid) throws SQLException { - String sql = "DELETE FROM \"admins\" WHERE \"uuid\" = ?"; + String sql = String.format("DELETE FROM %s WHERE %s = ?", tblAdmins, colUuid); return statementHandler.executeUpdate(sql, uuid.toString()) > 0; } @Override public boolean deleteByUsername(String username) throws SQLException { - String sql = "DELETE FROM \"admins\" WHERE \"username\" ILIKE ?"; + String sql = String.format("DELETE FROM %s WHERE %s", tblAdmins, adapter.caseInsensitiveEquals(colUsername, "?")); return statementHandler.executeUpdate(sql, username) > 0; } @Override public boolean removeIp(int adminId, String ip) throws SQLException { - String sql = "DELETE FROM \"admin_ips\" WHERE \"admin_id\" = ? AND \"ip\" = ?"; + String sql = String.format("DELETE FROM %s WHERE %s = ? AND %s = ?", tblAdminIps, colAdminId, colIp); return statementHandler.executeUpdate(sql, adminId, ip) > 0; } @Override public boolean clearIps(int adminId) throws SQLException { - String sql = "DELETE FROM \"admin_ips\" WHERE \"admin_id\" = ?"; + String sql = String.format("DELETE FROM %s WHERE %s = ?", tblAdminIps, colAdminId); return statementHandler.executeUpdate(sql, adminId) > 0; } @Override public void deleteAllSync() throws SQLException { - statementHandler.executeUpdate("DELETE FROM \"admin_ips\""); - statementHandler.executeUpdate("DELETE FROM \"admins\""); + statementHandler.executeUpdate(String.format("DELETE FROM %s", tblAdminIps)); + statementHandler.executeUpdate(String.format("DELETE FROM %s", tblAdmins)); } - // ============================================ - // Async Operations - // ============================================ - @Override public Mono> loadAllAsync() { @@ -439,26 +423,30 @@ public Mono deleteAll() return statementHandler.runMono(this::deleteAllSync); } - // ============================================ - // Helper Methods - // ============================================ - private Admin loadAdminFromResultSet(ResultSet rs) throws SQLException { - int id = rs.getInt("id"); + Admin admin = loadAdminFromRow(rs); + List ips = getIps(rs.getInt("id")); + admin.addIps(ips); + return admin; + } + + private Admin loadAdminFromRow(ResultSet rs) throws SQLException + { String username = rs.getString("username"); String rankStr = rs.getString("rank"); boolean active = rs.getBoolean("active"); String lastLoginStr = rs.getString("last_login"); String loginMessage = rs.getString("login_message"); + String customRankId = rs.getString("custom_rank"); - String configKey = username.toLowerCase(); - Admin admin = new Admin(configKey); + Admin admin = new Admin(username.toLowerCase()); admin.setName(username); admin.setRank(Rank.findRank(rankStr)); admin.setActive(active); admin.setLastLogin(FUtil.stringToDate(lastLoginStr)); admin.setLoginMessage(loginMessage); + admin.setCustomRankId(customRankId); UUID dbUuid = FUtil.parseUuid(rs.getString("uuid")); if (dbUuid != null) @@ -466,9 +454,6 @@ private Admin loadAdminFromResultSet(ResultSet rs) throws SQLException admin.setUuid(dbUuid); } - List ips = getIps(id); - admin.addIps(ips); - return admin; } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLBanRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericBanRepository.java similarity index 61% rename from src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLBanRepository.java rename to src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericBanRepository.java index 5a1788b95..668eacfed 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLBanRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericBanRepository.java @@ -1,9 +1,9 @@ -package me.totalfreedom.totalfreedommod.sql.adapter.postgresql; +package me.totalfreedom.totalfreedommod.sql.adapter.generic; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.banning.Ban; import me.totalfreedom.totalfreedommod.sql.StatementHandler; import me.totalfreedom.totalfreedommod.sql.adapter.BanRepository; +import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; import me.totalfreedom.totalfreedommod.util.FUtil; import java.sql.PreparedStatement; @@ -14,50 +14,67 @@ import reactor.core.publisher.Mono; /** - * PostgreSQL implementation of BanRepository. - * Uses PostgreSQL-specific SQL syntax. + * All dialect differences are resolved through the {@link DatabaseAdapter} passed in. */ -public class PostgreSQLBanRepository implements BanRepository +public class GenericBanRepository implements BanRepository { - private final TotalFreedomMod plugin; private final StatementHandler statementHandler; - - public PostgreSQLBanRepository(TotalFreedomMod plugin, StatementHandler statementHandler) + private final DatabaseAdapter adapter; + + private final String tblBans; + private final String tblBanIps; + private final String colId; + private final String colUuid; + private final String colUsername; + private final String colBannedBy; + private final String colBannedByUuid; + private final String colReason; + private final String colExpireAt; + private final String colBanId; + private final String colIp; + private final String selectColumns; + + public GenericBanRepository(StatementHandler statementHandler, DatabaseAdapter adapter) { - this.plugin = plugin; this.statementHandler = statementHandler; - } + this.adapter = adapter; - // ============================================ - // CREATE Operations - // ============================================ + this.tblBans = adapter.quoteIdentifier("bans"); + this.tblBanIps = adapter.quoteIdentifier("ban_ips"); + this.colId = adapter.quoteIdentifier("id"); + this.colUuid = adapter.quoteIdentifier("uuid"); + this.colUsername = adapter.quoteIdentifier("username"); + this.colBannedBy = adapter.quoteIdentifier("banned_by"); + this.colBannedByUuid = adapter.quoteIdentifier("banned_by_uuid"); + this.colReason = adapter.quoteIdentifier("reason"); + this.colExpireAt = adapter.quoteIdentifier("expire_at"); + this.colBanId = adapter.quoteIdentifier("ban_id"); + this.colIp = adapter.quoteIdentifier("ip"); + this.selectColumns = String.format("%s, %s, %s, %s, %s, %s, %s", + colId, colUuid, colUsername, colBannedBy, colBannedByUuid, colReason, colExpireAt); + } @Override public int insert(Ban ban) throws SQLException { - String sql = """ - INSERT INTO "bans" ("uuid", "username", "banned_by", "banned_by_uuid", "reason", "expire_at") - VALUES (?, ?, ?, ?, ?, ?::timestamp) - RETURNING "id" - """; + String sql = String.format("INSERT INTO %s (%s, %s, %s, %s, %s, %s) VALUES (?, ?, ?, ?, ?, %s)", + tblBans, colUuid, colUsername, colBannedBy, colBannedByUuid, colReason, colExpireAt, + adapter.timestampParamPlaceholder()); - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, + long banId = statementHandler.executeUpdateReturnKey(sql, ban.getUuid() != null ? ban.getUuid().toString() : null, ban.getUsername(), ban.getBannedBy(), ban.getBannedByUuid() != null ? ban.getBannedByUuid().toString() : null, ban.getReason(), ban.getExpireAt() != null ? FUtil.dateToString(ban.getExpireAt()) : null); - ResultSet rs = stmt.executeQuery()) + + if (banId < 0) { - if (rs.next()) - { - int banId = rs.getInt("id"); - insertIps(banId, ban.getIps()); - return banId; - } + return -1; } - return -1; + insertIps((int) banId, ban.getIps()); + return (int) banId; } @Override @@ -65,7 +82,8 @@ public void insertIps(int banId, List ips) throws SQLException { if (ips == null || ips.isEmpty()) return; - String sql = "INSERT INTO \"ban_ips\" (\"ban_id\", \"ip\") VALUES (?, ?) ON CONFLICT DO NOTHING"; + String sql = String.format("%s INTO %s (%s, %s) VALUES (?, ?)%s", + adapter.insertIgnoreSyntax(), tblBanIps, colBanId, colIp, adapter.insertIgnoreSuffix()); for (String ip : ips) { statementHandler.executeUpdate(sql, banId, ip); @@ -75,48 +93,30 @@ public void insertIps(int banId, List ips) throws SQLException @Override public void addIp(int banId, String ip) throws SQLException { - String sql = "INSERT INTO \"ban_ips\" (\"ban_id\", \"ip\") VALUES (?, ?) ON CONFLICT DO NOTHING"; + String sql = String.format("%s INTO %s (%s, %s) VALUES (?, ?)%s", + adapter.insertIgnoreSyntax(), tblBanIps, colBanId, colIp, adapter.insertIgnoreSuffix()); statementHandler.executeUpdate(sql, banId, ip); } - // ============================================ - // READ Operations - // ============================================ - @Override public List loadAll() throws SQLException { List bans = new ArrayList<>(); Map banById = new HashMap<>(); - String sql = "SELECT \"id\", \"uuid\", \"username\", \"banned_by\", \"banned_by_uuid\", \"reason\", \"expire_at\" FROM \"bans\""; + String sql = String.format("SELECT %s FROM %s", selectColumns, tblBans); try (ResultSet rs = statementHandler.executeQuery(sql)) { while (rs.next()) { int id = rs.getInt("id"); - String uuidStr = rs.getString("uuid"); - String username = rs.getString("username"); - String bannedBy = rs.getString("banned_by"); - String bannedByUuidStr = rs.getString("banned_by_uuid"); - String reason = rs.getString("reason"); - String expireAtStr = rs.getString("expire_at"); - - Ban ban = new Ban(); - ban.setUuid(uuidStr != null ? UUID.fromString(uuidStr) : null); - ban.setUsername(username); - ban.setBannedBy(bannedBy); - ban.setBannedByUuid(bannedByUuidStr != null ? UUID.fromString(bannedByUuidStr) : null); - ban.setReason(reason); - ban.setExpireAt(expireAtStr != null ? FUtil.stringToDate(expireAtStr) : null); - + Ban ban = loadBanFromRow(rs); bans.add(ban); banById.put(id, ban); } } - // Load IPs - String ipSql = "SELECT \"ban_id\", \"ip\" FROM \"ban_ips\""; + String ipSql = String.format("SELECT %s, %s FROM %s", colBanId, colIp, tblBanIps); try (ResultSet rs = statementHandler.executeQuery(ipSql)) { while (rs.next()) @@ -137,7 +137,7 @@ public List loadAll() throws SQLException @Override public Ban findByUuid(UUID uuid) throws SQLException { - String sql = "SELECT \"id\", \"uuid\", \"username\", \"banned_by\", \"banned_by_uuid\", \"reason\", \"expire_at\" FROM \"bans\" WHERE \"uuid\" = ?"; + String sql = String.format("SELECT %s FROM %s WHERE %s = ?", selectColumns, tblBans, colUuid); try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); ResultSet rs = stmt.executeQuery()) { @@ -152,7 +152,8 @@ public Ban findByUuid(UUID uuid) throws SQLException @Override public Ban findByUsername(String username) throws SQLException { - String sql = "SELECT \"id\", \"uuid\", \"username\", \"banned_by\", \"banned_by_uuid\", \"reason\", \"expire_at\" FROM \"bans\" WHERE \"username\" ILIKE ?"; + String sql = String.format("SELECT %s FROM %s WHERE %s", + selectColumns, tblBans, adapter.caseInsensitiveEquals(colUsername, "?")); try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); ResultSet rs = stmt.executeQuery()) { @@ -167,12 +168,10 @@ public Ban findByUsername(String username) throws SQLException @Override public Ban findByIp(String ip) throws SQLException { - String sql = """ - SELECT b."id", b."uuid", b."username", b."banned_by", b."banned_by_uuid", b."reason", b."expire_at" - FROM "bans" b - INNER JOIN "ban_ips" bi ON b."id" = bi."ban_id" - WHERE bi."ip" = ? - """; + String sql = String.format( + "SELECT b.%s, b.%s, b.%s, b.%s, b.%s, b.%s, b.%s FROM %s b INNER JOIN %s bi ON b.%s = bi.%s WHERE bi.%s = ?", + colId, colUuid, colUsername, colBannedBy, colBannedByUuid, colReason, colExpireAt, + tblBans, tblBanIps, colId, colBanId, colIp); try (PreparedStatement stmt = statementHandler.prepareStatement(sql, ip); ResultSet rs = stmt.executeQuery()) { @@ -187,12 +186,8 @@ public Ban findByIp(String ip) throws SQLException @Override public List findActiveBans() throws SQLException { - // PostgreSQL: CURRENT_TIMESTAMP for current time - String sql = """ - SELECT "id", "uuid", "username", "banned_by", "banned_by_uuid", "reason", "expire_at" - FROM "bans" - WHERE "expire_at" IS NULL OR "expire_at" > CURRENT_TIMESTAMP - """; + String sql = String.format("SELECT %s FROM %s WHERE %s IS NULL OR %s", + selectColumns, tblBans, colExpireAt, adapter.compareToNow(colExpireAt, ">")); List bans = new ArrayList<>(); try (ResultSet rs = statementHandler.executeQuery(sql)) { @@ -207,11 +202,8 @@ public List findActiveBans() throws SQLException @Override public List findExpiredBans() throws SQLException { - String sql = """ - SELECT "id", "uuid", "username", "banned_by", "banned_by_uuid", "reason", "expire_at" - FROM "bans" - WHERE "expire_at" IS NOT NULL AND "expire_at" <= CURRENT_TIMESTAMP - """; + String sql = String.format("SELECT %s FROM %s WHERE %s IS NOT NULL AND %s", + selectColumns, tblBans, colExpireAt, adapter.compareToNow(colExpireAt, "<=")); List bans = new ArrayList<>(); try (ResultSet rs = statementHandler.executeQuery(sql)) { @@ -226,7 +218,7 @@ public List findExpiredBans() throws SQLException @Override public int getBanId(UUID uuid) throws SQLException { - String sql = "SELECT \"id\" FROM \"bans\" WHERE \"uuid\" = ?"; + String sql = String.format("SELECT %s FROM %s WHERE %s = ?", colId, tblBans, colUuid); try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); ResultSet rs = stmt.executeQuery()) { @@ -238,7 +230,8 @@ public int getBanId(UUID uuid) throws SQLException @Override public int getBanIdByUsername(String username) throws SQLException { - String sql = "SELECT \"id\" FROM \"bans\" WHERE \"username\" ILIKE ?"; + String sql = String.format("SELECT %s FROM %s WHERE %s", + colId, tblBans, adapter.caseInsensitiveEquals(colUsername, "?")); try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); ResultSet rs = stmt.executeQuery()) { @@ -251,7 +244,7 @@ public int getBanIdByUsername(String username) throws SQLException public List getIps(int banId) throws SQLException { List ips = new ArrayList<>(); - String sql = "SELECT \"ip\" FROM \"ban_ips\" WHERE \"ban_id\" = ?"; + String sql = String.format("SELECT %s FROM %s WHERE %s = ?", colIp, tblBanIps, colBanId); try (PreparedStatement stmt = statementHandler.prepareStatement(sql, banId); ResultSet rs = stmt.executeQuery()) { @@ -266,10 +259,8 @@ public List getIps(int banId) throws SQLException @Override public boolean isBanned(UUID uuid) throws SQLException { - String sql = """ - SELECT COUNT(*) FROM "bans" - WHERE "uuid" = ? AND ("expire_at" IS NULL OR "expire_at" > CURRENT_TIMESTAMP) - """; + String sql = String.format("SELECT COUNT(*) FROM %s WHERE %s = ? AND (%s IS NULL OR %s)", + tblBans, colUuid, colExpireAt, adapter.compareToNow(colExpireAt, ">")); try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); ResultSet rs = stmt.executeQuery()) { @@ -280,10 +271,8 @@ SELECT COUNT(*) FROM "bans" @Override public boolean isBannedByUsername(String username) throws SQLException { - String sql = """ - SELECT COUNT(*) FROM "bans" - WHERE "username" ILIKE ? AND ("expire_at" IS NULL OR "expire_at" > CURRENT_TIMESTAMP) - """; + String sql = String.format("SELECT COUNT(*) FROM %s WHERE %s AND (%s IS NULL OR %s)", + tblBans, adapter.caseInsensitiveEquals(colUsername, "?"), colExpireAt, adapter.compareToNow(colExpireAt, ">")); try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); ResultSet rs = stmt.executeQuery()) { @@ -294,11 +283,9 @@ SELECT COUNT(*) FROM "bans" @Override public boolean isBannedByIp(String ip) throws SQLException { - String sql = """ - SELECT COUNT(*) FROM "bans" b - INNER JOIN "ban_ips" bi ON b."id" = bi."ban_id" - WHERE bi."ip" = ? AND (b."expire_at" IS NULL OR b."expire_at" > CURRENT_TIMESTAMP) - """; + String sql = String.format( + "SELECT COUNT(*) FROM %s b INNER JOIN %s bi ON b.%s = bi.%s WHERE bi.%s = ? AND (b.%s IS NULL OR %s)", + tblBans, tblBanIps, colId, colBanId, colIp, colExpireAt, adapter.compareToNow("b." + colExpireAt, ">")); try (PreparedStatement stmt = statementHandler.prepareStatement(sql, ip); ResultSet rs = stmt.executeQuery()) { @@ -306,18 +293,12 @@ SELECT COUNT(*) FROM "bans" b } } - // ============================================ - // UPDATE Operations - // ============================================ - @Override public boolean update(Ban ban) throws SQLException { - String sql = """ - UPDATE "bans" - SET "username" = ?, "banned_by" = ?, "banned_by_uuid" = ?, "reason" = ?, "expire_at" = ?::timestamp - WHERE "uuid" = ? - """; + String sql = String.format("UPDATE %s SET %s = ?, %s = ?, %s = ?, %s = ?, %s = %s WHERE %s = ?", + tblBans, colUsername, colBannedBy, colBannedByUuid, colReason, colExpireAt, + adapter.timestampParamPlaceholder(), colUuid); int rows = statementHandler.executeUpdate(sql, ban.getUsername(), @@ -333,46 +314,43 @@ public boolean update(Ban ban) throws SQLException @Override public boolean updateReason(UUID uuid, String reason) throws SQLException { - String sql = "UPDATE \"bans\" SET \"reason\" = ? WHERE \"uuid\" = ?"; + String sql = String.format("UPDATE %s SET %s = ? WHERE %s = ?", tblBans, colReason, colUuid); return statementHandler.executeUpdate(sql, reason, uuid.toString()) > 0; } @Override public boolean updateExpiry(UUID uuid, Date expireAt) throws SQLException { - String sql = "UPDATE \"bans\" SET \"expire_at\" = ?::timestamp WHERE \"uuid\" = ?"; + String sql = String.format("UPDATE %s SET %s = %s WHERE %s = ?", + tblBans, colExpireAt, adapter.timestampParamPlaceholder(), colUuid); return statementHandler.executeUpdate(sql, expireAt != null ? FUtil.dateToString(expireAt) : null, uuid.toString()) > 0; } @Override public void syncIps(int banId, List ips) throws SQLException { - statementHandler.executeUpdate("DELETE FROM \"ban_ips\" WHERE \"ban_id\" = ?", banId); + statementHandler.executeUpdate(String.format("DELETE FROM %s WHERE %s = ?", tblBanIps, colBanId), banId); insertIps(banId, ips); } - // ============================================ - // DELETE Operations - // ============================================ - @Override public boolean delete(UUID uuid) throws SQLException { - String sql = "DELETE FROM \"bans\" WHERE \"uuid\" = ?"; + String sql = String.format("DELETE FROM %s WHERE %s = ?", tblBans, colUuid); return statementHandler.executeUpdate(sql, uuid.toString()) > 0; } @Override public boolean deleteByUsername(String username) throws SQLException { - String sql = "DELETE FROM \"bans\" WHERE \"username\" ILIKE ?"; + String sql = String.format("DELETE FROM %s WHERE %s", tblBans, adapter.caseInsensitiveEquals(colUsername, "?")); return statementHandler.executeUpdate(sql, username) > 0; } @Override public boolean deleteByIp(String ip) throws SQLException { - String selectSql = "SELECT \"ban_id\" FROM \"ban_ips\" WHERE \"ip\" = ?"; + String selectSql = String.format("SELECT %s FROM %s WHERE %s = ?", colBanId, tblBanIps, colIp); List banIds = new ArrayList<>(); try (PreparedStatement stmt = statementHandler.prepareStatement(selectSql, ip); ResultSet rs = stmt.executeQuery()) @@ -383,9 +361,10 @@ public boolean deleteByIp(String ip) throws SQLException } } + String deleteSql = String.format("DELETE FROM %s WHERE %s = ?", tblBans, colId); for (int banId : banIds) { - statementHandler.executeUpdate("DELETE FROM \"bans\" WHERE \"id\" = ?", banId); + statementHandler.executeUpdate(deleteSql, banId); } return !banIds.isEmpty(); @@ -394,28 +373,25 @@ public boolean deleteByIp(String ip) throws SQLException @Override public boolean removeIp(int banId, String ip) throws SQLException { - String sql = "DELETE FROM \"ban_ips\" WHERE \"ban_id\" = ? AND \"ip\" = ?"; + String sql = String.format("DELETE FROM %s WHERE %s = ? AND %s = ?", tblBanIps, colBanId, colIp); return statementHandler.executeUpdate(sql, banId, ip) > 0; } @Override public int deleteExpiredBans() throws SQLException { - String sql = "DELETE FROM \"bans\" WHERE \"expire_at\" IS NOT NULL AND \"expire_at\" <= CURRENT_TIMESTAMP"; + String sql = String.format("DELETE FROM %s WHERE %s IS NOT NULL AND %s", + tblBans, colExpireAt, adapter.compareToNow(colExpireAt, "<=")); return statementHandler.executeUpdate(sql); } @Override public void deleteAllSync() throws SQLException { - statementHandler.executeUpdate("DELETE FROM \"ban_ips\""); - statementHandler.executeUpdate("DELETE FROM \"bans\""); + statementHandler.executeUpdate(String.format("DELETE FROM %s", tblBanIps)); + statementHandler.executeUpdate(String.format("DELETE FROM %s", tblBans)); } - // ============================================ - // Async Operations - // ============================================ - @Override public Mono> loadAllAsync() { @@ -471,13 +447,15 @@ public Mono deleteAll() return statementHandler.runMono(this::deleteAllSync); } - // ============================================ - // Helper Methods - // ============================================ - private Ban loadBanFromResultSet(ResultSet rs) throws SQLException { - int id = rs.getInt("id"); + Ban ban = loadBanFromRow(rs); + ban.setIps(getIps(rs.getInt("id"))); + return ban; + } + + private Ban loadBanFromRow(ResultSet rs) throws SQLException + { String uuidStr = rs.getString("uuid"); String username = rs.getString("username"); String bannedBy = rs.getString("banned_by"); @@ -493,9 +471,6 @@ private Ban loadBanFromResultSet(ResultSet rs) throws SQLException ban.setReason(reason); ban.setExpireAt(expireAtStr != null ? FUtil.stringToDate(expireAtStr) : null); - List ips = getIps(id); - ban.setIps(ips); - return ban; } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericDiscordLinkRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericDiscordLinkRepository.java new file mode 100644 index 000000000..3fac5f1f1 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericDiscordLinkRepository.java @@ -0,0 +1,81 @@ +package me.totalfreedom.totalfreedommod.sql.adapter.generic; + +import me.totalfreedom.totalfreedommod.sql.StatementHandler; +import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; +import me.totalfreedom.totalfreedommod.sql.adapter.DiscordLinkRepository; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.UUID; + +/** + * All dialect differences are resolved through the {@link DatabaseAdapter} passed in. + */ +public class GenericDiscordLinkRepository implements DiscordLinkRepository +{ + private final StatementHandler statementHandler; + + private final String insertSql; + private final String selectDiscordIdSql; + private final String selectAdminUuidSql; + private final String deleteByAdminUuidSql; + private final String deleteByDiscordUserIdSql; + + public GenericDiscordLinkRepository(StatementHandler statementHandler, DatabaseAdapter adapter) + { + this.statementHandler = statementHandler; + + String tblDiscordLinks = adapter.quoteIdentifier("discord_links"); + String colAdminUuid = adapter.quoteIdentifier("admin_uuid"); + String colDiscordUserId = adapter.quoteIdentifier("discord_user_id"); + String colLinkedAt = adapter.quoteIdentifier("linked_at"); + + this.insertSql = String.format("INSERT INTO %s (%s, %s, %s) VALUES (?, ?, %s)", + tblDiscordLinks, colAdminUuid, colDiscordUserId, colLinkedAt, adapter.currentTimestamp()); + this.selectDiscordIdSql = String.format("SELECT %s FROM %s WHERE %s = ?", colDiscordUserId, tblDiscordLinks, colAdminUuid); + this.selectAdminUuidSql = String.format("SELECT %s FROM %s WHERE %s = ?", colAdminUuid, tblDiscordLinks, colDiscordUserId); + this.deleteByAdminUuidSql = String.format("DELETE FROM %s WHERE %s = ?", tblDiscordLinks, colAdminUuid); + this.deleteByDiscordUserIdSql = String.format("DELETE FROM %s WHERE %s = ?", tblDiscordLinks, colDiscordUserId); + } + + @Override + public void insert(UUID adminUuid, String discordUserId) throws SQLException + { + statementHandler.executeUpdate(insertSql, adminUuid.toString(), discordUserId); + } + + @Override + public String findDiscordIdByAdminUuid(UUID adminUuid) throws SQLException + { + try (ResultSet rs = statementHandler.executeQuery(selectDiscordIdSql, adminUuid.toString())) + { + return rs.next() ? rs.getString(1) : null; + } + } + + @Override + public UUID findAdminUuidByDiscordId(String discordUserId) throws SQLException + { + try (ResultSet rs = statementHandler.executeQuery(selectAdminUuidSql, discordUserId)) + { + if (!rs.next()) + { + return null; + } + String raw = rs.getString(1); + return raw == null ? null : UUID.fromString(raw); + } + } + + @Override + public boolean deleteByAdminUuid(UUID adminUuid) throws SQLException + { + return statementHandler.executeUpdate(deleteByAdminUuidSql, adminUuid.toString()) > 0; + } + + @Override + public boolean deleteByDiscordUserId(String discordUserId) throws SQLException + { + return statementHandler.executeUpdate(deleteByDiscordUserIdSql, discordUserId) > 0; + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLPermbanRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPermbanRepository.java similarity index 62% rename from src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLPermbanRepository.java rename to src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPermbanRepository.java index e9cdcf25c..63166d292 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLPermbanRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPermbanRepository.java @@ -1,8 +1,8 @@ -package me.totalfreedom.totalfreedommod.sql.adapter.postgresql; +package me.totalfreedom.totalfreedommod.sql.adapter.generic; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.banning.PermBan; import me.totalfreedom.totalfreedommod.sql.StatementHandler; +import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; import me.totalfreedom.totalfreedommod.sql.adapter.PermbanRepository; import java.sql.PreparedStatement; @@ -13,47 +13,56 @@ import reactor.core.publisher.Mono; /** - * PostgreSQL implementation of PermbanRepository. - * Uses PostgreSQL-specific SQL syntax. + * All dialect differences are resolved through the {@link DatabaseAdapter} passed in. */ -public class PostgreSQLPermbanRepository implements PermbanRepository +public class GenericPermbanRepository implements PermbanRepository { - private final TotalFreedomMod plugin; private final StatementHandler statementHandler; - - public PostgreSQLPermbanRepository(TotalFreedomMod plugin, StatementHandler statementHandler) + private final DatabaseAdapter adapter; + + private final String tblPermbans; + private final String tblPermbanIps; + private final String colId; + private final String colUuid; + private final String colUsername; + private final String colReason; + private final String colPermbanId; + private final String colIp; + private final String selectColumns; + + public GenericPermbanRepository(StatementHandler statementHandler, DatabaseAdapter adapter) { - this.plugin = plugin; this.statementHandler = statementHandler; + this.adapter = adapter; + + this.tblPermbans = adapter.quoteIdentifier("permbans"); + this.tblPermbanIps = adapter.quoteIdentifier("permban_ips"); + this.colId = adapter.quoteIdentifier("id"); + this.colUuid = adapter.quoteIdentifier("uuid"); + this.colUsername = adapter.quoteIdentifier("username"); + this.colReason = adapter.quoteIdentifier("reason"); + this.colPermbanId = adapter.quoteIdentifier("permban_id"); + this.colIp = adapter.quoteIdentifier("ip"); + this.selectColumns = String.format("%s, %s, %s, %s", colId, colUuid, colUsername, colReason); } - // ============================================ - // CREATE Operations - // ============================================ - @Override public int insert(PermBan permban) throws SQLException { - String sql = """ - INSERT INTO "permbans" ("uuid", "username", "reason") - VALUES (?, ?, ?) - RETURNING "id" - """; + String sql = String.format("INSERT INTO %s (%s, %s, %s) VALUES (?, ?, ?)", + tblPermbans, colUuid, colUsername, colReason); - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, + long permbanId = statementHandler.executeUpdateReturnKey(sql, permban.getUuid() != null ? permban.getUuid().toString() : null, permban.getUsername(), permban.getReason()); - ResultSet rs = stmt.executeQuery()) + + if (permbanId < 0) { - if (rs.next()) - { - int permbanId = rs.getInt("id"); - insertIps(permbanId, permban.getIps()); - return permbanId; - } + return -1; } - return -1; + insertIps((int) permbanId, permban.getIps()); + return (int) permbanId; } @Override @@ -61,7 +70,8 @@ public void insertIps(int permbanId, List ips) throws SQLException { if (ips == null || ips.isEmpty()) return; - String sql = "INSERT INTO \"permban_ips\" (\"permban_id\", \"ip\") VALUES (?, ?) ON CONFLICT DO NOTHING"; + String sql = String.format("%s INTO %s (%s, %s) VALUES (?, ?)%s", + adapter.insertIgnoreSyntax(), tblPermbanIps, colPermbanId, colIp, adapter.insertIgnoreSuffix()); for (String ip : ips) { statementHandler.executeUpdate(sql, permbanId, ip); @@ -71,42 +81,30 @@ public void insertIps(int permbanId, List ips) throws SQLException @Override public void addIp(int permbanId, String ip) throws SQLException { - String sql = "INSERT INTO \"permban_ips\" (\"permban_id\", \"ip\") VALUES (?, ?) ON CONFLICT DO NOTHING"; + String sql = String.format("%s INTO %s (%s, %s) VALUES (?, ?)%s", + adapter.insertIgnoreSyntax(), tblPermbanIps, colPermbanId, colIp, adapter.insertIgnoreSuffix()); statementHandler.executeUpdate(sql, permbanId, ip); } - // ============================================ - // READ Operations - // ============================================ - @Override public List loadAll() throws SQLException { List permbans = new ArrayList<>(); Map permbanById = new HashMap<>(); - String sql = "SELECT \"id\", \"uuid\", \"username\", \"reason\" FROM \"permbans\""; + String sql = String.format("SELECT %s FROM %s", selectColumns, tblPermbans); try (ResultSet rs = statementHandler.executeQuery(sql)) { while (rs.next()) { int id = rs.getInt("id"); - String uuidStr = rs.getString("uuid"); - String username = rs.getString("username"); - String reason = rs.getString("reason"); - - PermBan permban = new PermBan(); - permban.setUuid(uuidStr != null ? UUID.fromString(uuidStr) : null); - permban.setUsername(username); - permban.setReason(reason); - + PermBan permban = loadPermbanFromRow(rs); permbans.add(permban); permbanById.put(id, permban); } } - // Load IPs - String ipSql = "SELECT \"permban_id\", \"ip\" FROM \"permban_ips\""; + String ipSql = String.format("SELECT %s, %s FROM %s", colPermbanId, colIp, tblPermbanIps); try (ResultSet rs = statementHandler.executeQuery(ipSql)) { while (rs.next()) @@ -127,7 +125,7 @@ public List loadAll() throws SQLException @Override public PermBan findByUuid(UUID uuid) throws SQLException { - String sql = "SELECT \"id\", \"uuid\", \"username\", \"reason\" FROM \"permbans\" WHERE \"uuid\" = ?"; + String sql = String.format("SELECT %s FROM %s WHERE %s = ?", selectColumns, tblPermbans, colUuid); try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); ResultSet rs = stmt.executeQuery()) { @@ -142,7 +140,8 @@ public PermBan findByUuid(UUID uuid) throws SQLException @Override public PermBan findByUsername(String username) throws SQLException { - String sql = "SELECT \"id\", \"uuid\", \"username\", \"reason\" FROM \"permbans\" WHERE \"username\" ILIKE ?"; + String sql = String.format("SELECT %s FROM %s WHERE %s", + selectColumns, tblPermbans, adapter.caseInsensitiveEquals(colUsername, "?")); try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); ResultSet rs = stmt.executeQuery()) { @@ -157,12 +156,9 @@ public PermBan findByUsername(String username) throws SQLException @Override public PermBan findByIp(String ip) throws SQLException { - String sql = """ - SELECT p."id", p."uuid", p."username", p."reason" - FROM "permbans" p - INNER JOIN "permban_ips" pi ON p."id" = pi."permban_id" - WHERE pi."ip" = ? - """; + String sql = String.format( + "SELECT p.%s, p.%s, p.%s, p.%s FROM %s p INNER JOIN %s pi ON p.%s = pi.%s WHERE pi.%s = ?", + colId, colUuid, colUsername, colReason, tblPermbans, tblPermbanIps, colId, colPermbanId, colIp); try (PreparedStatement stmt = statementHandler.prepareStatement(sql, ip); ResultSet rs = stmt.executeQuery()) { @@ -177,7 +173,7 @@ public PermBan findByIp(String ip) throws SQLException @Override public int getPermbanId(UUID uuid) throws SQLException { - String sql = "SELECT \"id\" FROM \"permbans\" WHERE \"uuid\" = ?"; + String sql = String.format("SELECT %s FROM %s WHERE %s = ?", colId, tblPermbans, colUuid); try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); ResultSet rs = stmt.executeQuery()) { @@ -189,7 +185,8 @@ public int getPermbanId(UUID uuid) throws SQLException @Override public int getPermbanIdByUsername(String username) throws SQLException { - String sql = "SELECT \"id\" FROM \"permbans\" WHERE \"username\" ILIKE ?"; + String sql = String.format("SELECT %s FROM %s WHERE %s", + colId, tblPermbans, adapter.caseInsensitiveEquals(colUsername, "?")); try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); ResultSet rs = stmt.executeQuery()) { @@ -202,7 +199,7 @@ public int getPermbanIdByUsername(String username) throws SQLException public List getIps(int permbanId) throws SQLException { List ips = new ArrayList<>(); - String sql = "SELECT \"ip\" FROM \"permban_ips\" WHERE \"permban_id\" = ?"; + String sql = String.format("SELECT %s FROM %s WHERE %s = ?", colIp, tblPermbanIps, colPermbanId); try (PreparedStatement stmt = statementHandler.prepareStatement(sql, permbanId); ResultSet rs = stmt.executeQuery()) { @@ -217,7 +214,7 @@ public List getIps(int permbanId) throws SQLException @Override public boolean isPermBanned(UUID uuid) throws SQLException { - String sql = "SELECT COUNT(*) FROM \"permbans\" WHERE \"uuid\" = ?"; + String sql = String.format("SELECT COUNT(*) FROM %s WHERE %s = ?", tblPermbans, colUuid); try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); ResultSet rs = stmt.executeQuery()) { @@ -228,7 +225,8 @@ public boolean isPermBanned(UUID uuid) throws SQLException @Override public boolean isPermBannedByUsername(String username) throws SQLException { - String sql = "SELECT COUNT(*) FROM \"permbans\" WHERE \"username\" ILIKE ?"; + String sql = String.format("SELECT COUNT(*) FROM %s WHERE %s", + tblPermbans, adapter.caseInsensitiveEquals(colUsername, "?")); try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); ResultSet rs = stmt.executeQuery()) { @@ -239,11 +237,8 @@ public boolean isPermBannedByUsername(String username) throws SQLException @Override public boolean isPermBannedByIp(String ip) throws SQLException { - String sql = """ - SELECT COUNT(*) FROM "permbans" p - INNER JOIN "permban_ips" pi ON p."id" = pi."permban_id" - WHERE pi."ip" = ? - """; + String sql = String.format("SELECT COUNT(*) FROM %s p INNER JOIN %s pi ON p.%s = pi.%s WHERE pi.%s = ?", + tblPermbans, tblPermbanIps, colId, colPermbanId, colIp); try (PreparedStatement stmt = statementHandler.prepareStatement(sql, ip); ResultSet rs = stmt.executeQuery()) { @@ -251,18 +246,11 @@ SELECT COUNT(*) FROM "permbans" p } } - // ============================================ - // UPDATE Operations - // ============================================ - @Override public boolean update(PermBan permban) throws SQLException { - String sql = """ - UPDATE "permbans" - SET "username" = ?, "reason" = ? - WHERE "uuid" = ? - """; + String sql = String.format("UPDATE %s SET %s = ?, %s = ? WHERE %s = ?", + tblPermbans, colUsername, colReason, colUuid); int rows = statementHandler.executeUpdate(sql, permban.getUsername(), @@ -275,39 +263,35 @@ public boolean update(PermBan permban) throws SQLException @Override public boolean updateReason(UUID uuid, String reason) throws SQLException { - String sql = "UPDATE \"permbans\" SET \"reason\" = ? WHERE \"uuid\" = ?"; + String sql = String.format("UPDATE %s SET %s = ? WHERE %s = ?", tblPermbans, colReason, colUuid); return statementHandler.executeUpdate(sql, reason, uuid.toString()) > 0; } @Override public void syncIps(int permbanId, List ips) throws SQLException { - statementHandler.executeUpdate("DELETE FROM \"permban_ips\" WHERE \"permban_id\" = ?", permbanId); + statementHandler.executeUpdate(String.format("DELETE FROM %s WHERE %s = ?", tblPermbanIps, colPermbanId), permbanId); insertIps(permbanId, ips); } - // ============================================ - // DELETE Operations - // ============================================ - @Override public boolean delete(UUID uuid) throws SQLException { - String sql = "DELETE FROM \"permbans\" WHERE \"uuid\" = ?"; + String sql = String.format("DELETE FROM %s WHERE %s = ?", tblPermbans, colUuid); return statementHandler.executeUpdate(sql, uuid.toString()) > 0; } @Override public boolean deleteByUsername(String username) throws SQLException { - String sql = "DELETE FROM \"permbans\" WHERE \"username\" ILIKE ?"; + String sql = String.format("DELETE FROM %s WHERE %s", tblPermbans, adapter.caseInsensitiveEquals(colUsername, "?")); return statementHandler.executeUpdate(sql, username) > 0; } @Override public boolean deleteByIp(String ip) throws SQLException { - String selectSql = "SELECT \"permban_id\" FROM \"permban_ips\" WHERE \"ip\" = ?"; + String selectSql = String.format("SELECT %s FROM %s WHERE %s = ?", colPermbanId, tblPermbanIps, colIp); List permbanIds = new ArrayList<>(); try (PreparedStatement stmt = statementHandler.prepareStatement(selectSql, ip); ResultSet rs = stmt.executeQuery()) @@ -318,9 +302,10 @@ public boolean deleteByIp(String ip) throws SQLException } } + String deleteSql = String.format("DELETE FROM %s WHERE %s = ?", tblPermbans, colId); for (int permbanId : permbanIds) { - statementHandler.executeUpdate("DELETE FROM \"permbans\" WHERE \"id\" = ?", permbanId); + statementHandler.executeUpdate(deleteSql, permbanId); } return !permbanIds.isEmpty(); @@ -329,21 +314,17 @@ public boolean deleteByIp(String ip) throws SQLException @Override public boolean removeIp(int permbanId, String ip) throws SQLException { - String sql = "DELETE FROM \"permban_ips\" WHERE \"permban_id\" = ? AND \"ip\" = ?"; + String sql = String.format("DELETE FROM %s WHERE %s = ? AND %s = ?", tblPermbanIps, colPermbanId, colIp); return statementHandler.executeUpdate(sql, permbanId, ip) > 0; } @Override public void deleteAllSync() throws SQLException { - statementHandler.executeUpdate("DELETE FROM \"permban_ips\""); - statementHandler.executeUpdate("DELETE FROM \"permbans\""); + statementHandler.executeUpdate(String.format("DELETE FROM %s", tblPermbanIps)); + statementHandler.executeUpdate(String.format("DELETE FROM %s", tblPermbans)); } - // ============================================ - // Async Operations - // ============================================ - @Override public Mono> loadAllAsync() { @@ -393,13 +374,15 @@ public Mono deleteAll() return statementHandler.runMono(this::deleteAllSync); } - // ============================================ - // Helper Methods - // ============================================ - private PermBan loadPermbanFromResultSet(ResultSet rs) throws SQLException { - int id = rs.getInt("id"); + PermBan permban = loadPermbanFromRow(rs); + permban.setIps(getIps(rs.getInt("id"))); + return permban; + } + + private PermBan loadPermbanFromRow(ResultSet rs) throws SQLException + { String uuidStr = rs.getString("uuid"); String username = rs.getString("username"); String reason = rs.getString("reason"); @@ -409,9 +392,6 @@ private PermBan loadPermbanFromResultSet(ResultSet rs) throws SQLException permban.setUsername(username); permban.setReason(reason); - List ips = getIps(id); - permban.setIps(ips); - return permban; } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java new file mode 100644 index 000000000..521cdc093 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java @@ -0,0 +1,298 @@ +package me.totalfreedom.totalfreedommod.sql.adapter.generic; + +import me.totalfreedom.totalfreedommod.player.CommandSpyMode; +import me.totalfreedom.totalfreedommod.player.PlayerData; +import me.totalfreedom.totalfreedommod.sql.StatementHandler; +import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; +import me.totalfreedom.totalfreedommod.sql.adapter.PlayerRepository; +import me.totalfreedom.totalfreedommod.util.AdventureUtil; + +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.*; + +import reactor.core.publisher.Mono; + +/** + * All dialect differences are resolved through the {@link DatabaseAdapter} passed in. + */ +public class GenericPlayerRepository implements PlayerRepository +{ + private final StatementHandler statementHandler; + private final DatabaseAdapter adapter; + + private final String tblPlayers; + private final String tblPlayerIps; + private final String colUsername; + private final String colFirstJoin; + private final String colLastJoin; + private final String colPotionSpy; + private final String colCommandSpyMode; + private final String colMuted; + private final String colFrozen; + private final String colCommandsBlocked; + private final String colStrikes; + private final String colSavedTag; + private final String colNickname; + private final String colId; + private final String colPlayerUsername; + private final String colIp; + private final String selectColumns; + + public GenericPlayerRepository(StatementHandler statementHandler, DatabaseAdapter adapter) + { + this.statementHandler = statementHandler; + this.adapter = adapter; + + this.tblPlayers = adapter.quoteIdentifier("players"); + this.tblPlayerIps = adapter.quoteIdentifier("player_ips"); + this.colUsername = adapter.quoteIdentifier("username"); + this.colFirstJoin = adapter.quoteIdentifier("first_join_unix"); + this.colLastJoin = adapter.quoteIdentifier("last_join_unix"); + this.colPotionSpy = adapter.quoteIdentifier("potion_spy"); + this.colCommandSpyMode = adapter.quoteIdentifier("command_spy_mode"); + this.colMuted = adapter.quoteIdentifier("muted"); + this.colFrozen = adapter.quoteIdentifier("frozen"); + this.colCommandsBlocked = adapter.quoteIdentifier("commands_blocked"); + this.colStrikes = adapter.quoteIdentifier("strikes"); + this.colSavedTag = adapter.quoteIdentifier("saved_tag"); + this.colNickname = adapter.quoteIdentifier("nickname"); + this.colId = adapter.quoteIdentifier("id"); + this.colPlayerUsername = adapter.quoteIdentifier("username"); + this.colIp = adapter.quoteIdentifier("ip"); + this.selectColumns = String.format("%s, %s, %s, %s, %s, %s, %s, %s, %s, %s", + colUsername, colFirstJoin, colLastJoin, colPotionSpy, colCommandSpyMode, colMuted, colFrozen, + colCommandsBlocked, colStrikes, colSavedTag) + + ", " + colNickname; + } + + @Override + public void insert(PlayerData data) throws SQLException + { + String sql = String.format("INSERT INTO %s (%s) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", tblPlayers, selectColumns); + + statementHandler.executeUpdate(sql, + data.getUsername(), + data.getFirstJoinUnix(), + data.getLastJoinUnix(), + data.isPotionSpy(), + data.getCommandSpyMode().getName(), + data.isMuted(), + data.isFrozen(), + data.isCommandsBlocked(), + data.getStrikes(), + data.getSavedTag(), + serializeNickname(data)); + + insertIps(data.getUsername(), data.getIps()); + } + + @Override + public void insertIps(String username, List ips) throws SQLException + { + if (ips == null || ips.isEmpty()) return; + + String sql = String.format("%s INTO %s (%s, %s) VALUES (?, ?)%s", + adapter.insertIgnoreSyntax(), tblPlayerIps, colPlayerUsername, colIp, adapter.insertIgnoreSuffix()); + for (String ip : ips) + { + statementHandler.executeUpdate(sql, username, ip); + } + } + + @Override + public void addIp(String username, String ip) throws SQLException + { + String sql = String.format("%s INTO %s (%s, %s) VALUES (?, ?)%s", + adapter.insertIgnoreSyntax(), tblPlayerIps, colPlayerUsername, colIp, adapter.insertIgnoreSuffix()); + statementHandler.executeUpdate(sql, username, ip); + } + + @Override + public Map loadAll() throws SQLException + { + Map players = new HashMap<>(); + + String sql = String.format("SELECT %s FROM %s", selectColumns, tblPlayers); + try (ResultSet rs = statementHandler.executeQuery(sql)) + { + while (rs.next()) + { + PlayerData data = loadPlayerFromRow(rs); + players.put(data.getUsername(), data); + } + } + + String ipSql = String.format("SELECT %s, %s FROM %s ORDER BY %s ASC", colPlayerUsername, colIp, tblPlayerIps, colId); + try (ResultSet rs = statementHandler.executeQuery(ipSql)) + { + while (rs.next()) + { + PlayerData data = players.get(rs.getString("username")); + if (data != null) + { + data.addIp(rs.getString("ip")); + } + } + } + + return players; + } + + @Override + public PlayerData findByUsername(String username) throws SQLException + { + String sql = String.format("SELECT %s FROM %s WHERE %s = ?", selectColumns, tblPlayers, colUsername); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); + ResultSet rs = stmt.executeQuery()) + { + if (rs.next()) + { + PlayerData data = loadPlayerFromRow(rs); + getIps(data.getUsername()).forEach(data::addIp); + return data; + } + } + return null; + } + + @Override + public boolean exists(String username) throws SQLException + { + String sql = String.format("SELECT COUNT(*) FROM %s WHERE %s = ?", tblPlayers, colUsername); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); + ResultSet rs = stmt.executeQuery()) + { + return rs.next() && rs.getInt(1) > 0; + } + } + + @Override + public List getIps(String username) throws SQLException + { + List ips = new ArrayList<>(); + String sql = String.format("SELECT %s FROM %s WHERE %s = ? ORDER BY %s ASC", colIp, tblPlayerIps, colPlayerUsername, colId); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); + ResultSet rs = stmt.executeQuery()) + { + while (rs.next()) + { + ips.add(rs.getString("ip")); + } + } + return ips; + } + + @Override + public boolean update(PlayerData data) throws SQLException + { + String sql = String.format("UPDATE %s SET %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ? WHERE %s = ?", + tblPlayers, colFirstJoin, colLastJoin, colPotionSpy, colCommandSpyMode, colMuted, colFrozen, + colCommandsBlocked, colStrikes, colSavedTag, colUsername); + + int rows = statementHandler.executeUpdate(sql, + data.getFirstJoinUnix(), + data.getLastJoinUnix(), + data.isPotionSpy(), + data.getCommandSpyMode().getName(), + data.isMuted(), + data.isFrozen(), + data.isCommandsBlocked(), + data.getStrikes(), + data.getSavedTag(), + data.getUsername()); + + statementHandler.executeUpdate( + String.format("UPDATE %s SET %s = ? WHERE %s = ?", tblPlayers, colNickname, colUsername), + serializeNickname(data), data.getUsername()); + + return rows > 0; + } + + @Override + public void syncIps(String username, List ips) throws SQLException + { + statementHandler.executeUpdate(String.format("DELETE FROM %s WHERE %s = ?", tblPlayerIps, colPlayerUsername), username); + insertIps(username, ips); + } + + @Override + public void saveOrUpdate(PlayerData data) throws SQLException + { + if (exists(data.getUsername())) + { + update(data); + syncIps(data.getUsername(), data.getIps()); + } + else + { + insert(data); + } + } + + @Override + public boolean delete(String username) throws SQLException + { + String sql = String.format("DELETE FROM %s WHERE %s = ?", tblPlayers, colUsername); + return statementHandler.executeUpdate(sql, username) > 0; + } + + @Override + public void deleteAllSync() throws SQLException + { + statementHandler.executeUpdate(String.format("DELETE FROM %s", tblPlayerIps)); + statementHandler.executeUpdate(String.format("DELETE FROM %s", tblPlayers)); + } + + @Override + public Mono> loadAllAsync() + { + return statementHandler.supplyMono(this::loadAll); + } + + @Override + public Mono save(PlayerData data) + { + return statementHandler.runMono(() -> saveOrUpdate(data)); + } + + @Override + public Mono deleteAsync(String username) + { + return statementHandler.supplyMono(() -> delete(username)); + } + + @Override + public Mono deleteAll() + { + return statementHandler.runMono(this::deleteAllSync); + } + + private PlayerData loadPlayerFromRow(ResultSet rs) throws SQLException + { + PlayerData data = new PlayerData(rs.getString("username")); + data.setFirstJoinUnix(rs.getLong("first_join_unix")); + data.setLastJoinUnix(rs.getLong("last_join_unix")); + data.setPotionSpy(rs.getBoolean("potion_spy")); + data.setCommandSpyMode(CommandSpyMode.fromString(rs.getString("command_spy_mode"))); + data.setMuted(rs.getBoolean("muted")); + data.setFrozen(rs.getBoolean("frozen")); + data.setCommandsBlocked(rs.getBoolean("commands_blocked")); + data.setStrikes(rs.getInt("strikes")); + data.setSavedTag(rs.getString("saved_tag")); + + String rawNickname = rs.getString("nickname"); + if (rawNickname != null && !rawNickname.isEmpty()) + { + data.setNicknameRaw(AdventureUtil.legacyToComponent(rawNickname)); + } + + return data; + } + + private static String serializeNickname(PlayerData data) + { + return data.getNickname() != null ? AdventureUtil.componentToLegacy(data.getNickname()) : null; + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericProtectedAreaRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericProtectedAreaRepository.java new file mode 100644 index 000000000..ff2fa0805 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericProtectedAreaRepository.java @@ -0,0 +1,224 @@ +package me.totalfreedom.totalfreedommod.sql.adapter.generic; + +import me.totalfreedom.totalfreedommod.ProtectArea.ProtectedRegion; +import me.totalfreedom.totalfreedommod.ProtectArea.ProtectedRegion.CantFindWorldException; +import me.totalfreedom.totalfreedommod.sql.StatementHandler; +import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; +import me.totalfreedom.totalfreedommod.sql.adapter.ProtectedAreaRepository; +import me.totalfreedom.totalfreedommod.util.FLog; + +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import reactor.core.publisher.Mono; + +/** + * All dialect differences are resolved through the {@link DatabaseAdapter} passed in. + */ +public class GenericProtectedAreaRepository implements ProtectedAreaRepository +{ + private final StatementHandler statementHandler; + + private final String tblProtectedAreas; + private final String colUuid; + private final String colName; + private final String colMinX; + private final String colMinY; + private final String colMinZ; + private final String colMaxX; + private final String colMaxY; + private final String colMaxZ; + private final String colWorldUuid; + private final String selectColumns; + + public GenericProtectedAreaRepository(StatementHandler statementHandler, DatabaseAdapter adapter) + { + this.statementHandler = statementHandler; + + this.tblProtectedAreas = adapter.quoteIdentifier("protected_areas"); + this.colUuid = adapter.quoteIdentifier("uuid"); + this.colName = adapter.quoteIdentifier("name"); + this.colMinX = adapter.quoteIdentifier("min_x"); + this.colMinY = adapter.quoteIdentifier("min_y"); + this.colMinZ = adapter.quoteIdentifier("min_z"); + this.colMaxX = adapter.quoteIdentifier("max_x"); + this.colMaxY = adapter.quoteIdentifier("max_y"); + this.colMaxZ = adapter.quoteIdentifier("max_z"); + this.colWorldUuid = adapter.quoteIdentifier("world_uuid"); + this.selectColumns = String.format("%s, %s, %s, %s, %s, %s, %s, %s, %s", + colUuid, colName, colMinX, colMinY, colMinZ, colMaxX, colMaxY, colMaxZ, colWorldUuid); + } + + @Override + public void insert(ProtectedRegion region) throws SQLException + { + String sql = String.format("INSERT INTO %s (%s) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", tblProtectedAreas, selectColumns); + statementHandler.executeUpdate(sql, + region.getUuid().toString(), + region.getName(), + region.getMinVector().getBlockX(), + region.getMinVector().getBlockY(), + region.getMinVector().getBlockZ(), + region.getMaxVector().getBlockX(), + region.getMaxVector().getBlockY(), + region.getMaxVector().getBlockZ(), + region.getWorldUUID().toString()); + } + + @Override + public List loadAll() throws SQLException + { + List regions = new ArrayList<>(); + String sql = String.format("SELECT %s FROM %s", selectColumns, tblProtectedAreas); + try (ResultSet rs = statementHandler.executeQuery(sql)) + { + while (rs.next()) + { + ProtectedRegion region = tryLoadRegionFromRow(rs); + if (region != null) + { + regions.add(region); + } + } + } + return regions; + } + + @Override + public ProtectedRegion findByUuid(UUID uuid) throws SQLException + { + String sql = String.format("SELECT %s FROM %s WHERE %s = ?", selectColumns, tblProtectedAreas, colUuid); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); + ResultSet rs = stmt.executeQuery()) + { + if (rs.next()) + { + return tryLoadRegionFromRow(rs); + } + } + return null; + } + + @Override + public ProtectedRegion findByName(String name) throws SQLException + { + String sql = String.format("SELECT %s FROM %s WHERE %s = ?", selectColumns, tblProtectedAreas, colName); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, name); + ResultSet rs = stmt.executeQuery()) + { + if (rs.next()) + { + return tryLoadRegionFromRow(rs); + } + } + return null; + } + + @Override + public boolean exists(UUID uuid) throws SQLException + { + String sql = String.format("SELECT COUNT(*) FROM %s WHERE %s = ?", tblProtectedAreas, colUuid); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); + ResultSet rs = stmt.executeQuery()) + { + return rs.next() && rs.getInt(1) > 0; + } + } + + @Override + public boolean update(ProtectedRegion region) throws SQLException + { + String sql = String.format("UPDATE %s SET %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ? WHERE %s = ?", + tblProtectedAreas, colName, colMinX, colMinY, colMinZ, colMaxX, colMaxY, colMaxZ, colWorldUuid, colUuid); + + int rows = statementHandler.executeUpdate(sql, + region.getName(), + region.getMinVector().getBlockX(), + region.getMinVector().getBlockY(), + region.getMinVector().getBlockZ(), + region.getMaxVector().getBlockX(), + region.getMaxVector().getBlockY(), + region.getMaxVector().getBlockZ(), + region.getWorldUUID().toString(), + region.getUuid().toString()); + + return rows > 0; + } + + @Override + public void saveOrUpdate(ProtectedRegion region) throws SQLException + { + if (exists(region.getUuid())) + { + update(region); + } + else + { + insert(region); + } + } + + @Override + public boolean delete(UUID uuid) throws SQLException + { + String sql = String.format("DELETE FROM %s WHERE %s = ?", tblProtectedAreas, colUuid); + return statementHandler.executeUpdate(sql, uuid.toString()) > 0; + } + + @Override + public void deleteAllSync() throws SQLException + { + statementHandler.executeUpdate(String.format("DELETE FROM %s", tblProtectedAreas)); + } + + @Override + public Mono> loadAllAsync() + { + return statementHandler.supplyMono(this::loadAll); + } + + @Override + public Mono save(ProtectedRegion region) + { + return statementHandler.runMono(() -> saveOrUpdate(region)); + } + + @Override + public Mono deleteAsync(UUID uuid) + { + return statementHandler.supplyMono(() -> delete(uuid)); + } + + @Override + public Mono deleteAll() + { + return statementHandler.runMono(this::deleteAllSync); + } + + private ProtectedRegion tryLoadRegionFromRow(ResultSet rs) throws SQLException + { + UUID uuid = UUID.fromString(rs.getString("uuid")); + String name = rs.getString("name"); + int minX = rs.getInt("min_x"); + int minY = rs.getInt("min_y"); + int minZ = rs.getInt("min_z"); + int maxX = rs.getInt("max_x"); + int maxY = rs.getInt("max_y"); + int maxZ = rs.getInt("max_z"); + String worldUuid = rs.getString("world_uuid"); + + try + { + return new ProtectedRegion(uuid, name, minX, minY, minZ, maxX, maxY, maxZ, worldUuid); + } + catch (CantFindWorldException ex) + { + FLog.warning(ex.getMessage()); + return null; + } + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericRankRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericRankRepository.java new file mode 100644 index 000000000..b2ac8fee5 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericRankRepository.java @@ -0,0 +1,295 @@ +package me.totalfreedom.totalfreedommod.sql.adapter.generic; + +import me.totalfreedom.totalfreedommod.rank.CustomRank; +import me.totalfreedom.totalfreedommod.sql.StatementHandler; +import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; +import me.totalfreedom.totalfreedommod.sql.adapter.RankRepository; +import net.kyori.adventure.text.format.NamedTextColor; + +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.*; + +import reactor.core.publisher.Mono; + +/** + * All dialect differences are resolved through the {@link DatabaseAdapter} passed in. + */ +public class GenericRankRepository implements RankRepository +{ + private final StatementHandler statementHandler; + private final DatabaseAdapter adapter; + + private final String tblRanks; + private final String tblRankPermissions; + private final String colId; + private final String colName; + private final String colDeterminer; + private final String colAbbreviation; + private final String colLevel; + private final String colColor; + private final String colAdmin; + private final String colConsoleOnly; + private final String colPrefix; + private final String colInheritFrom; + private final String colRankId; + private final String colPermission; + private final String selectColumns; + + public GenericRankRepository(StatementHandler statementHandler, DatabaseAdapter adapter) + { + this.statementHandler = statementHandler; + this.adapter = adapter; + + this.tblRanks = adapter.quoteIdentifier("ranks"); + this.tblRankPermissions = adapter.quoteIdentifier("rank_permissions"); + this.colId = adapter.quoteIdentifier("id"); + this.colName = adapter.quoteIdentifier("name"); + this.colDeterminer = adapter.quoteIdentifier("determiner"); + this.colAbbreviation = adapter.quoteIdentifier("abbreviation"); + this.colLevel = adapter.quoteIdentifier("level"); + this.colColor = adapter.quoteIdentifier("color"); + this.colAdmin = adapter.quoteIdentifier("admin"); + this.colConsoleOnly = adapter.quoteIdentifier("console_only"); + this.colPrefix = adapter.quoteIdentifier("prefix"); + this.colInheritFrom = adapter.quoteIdentifier("inherit_from"); + this.colRankId = adapter.quoteIdentifier("rank_id"); + this.colPermission = adapter.quoteIdentifier("permission"); + this.selectColumns = String.format("%s, %s, %s, %s, %s, %s, %s, %s, %s, %s", + colId, colName, colDeterminer, colAbbreviation, colLevel, colColor, colAdmin, colConsoleOnly, + colPrefix, colInheritFrom); + } + + @Override + public void insert(CustomRank rank) throws SQLException + { + String sql = String.format("INSERT INTO %s (%s) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + tblRanks, selectColumns); + + statementHandler.executeUpdate(sql, + rank.getId(), + rank.getName(), + rank.getDeterminer(), + rank.getAbbreviation(), + rank.getLevel(), + serializeColor(rank.getColor()), + rank.isAdmin(), + rank.isConsoleOnly(), + rank.getPrefix(), + rank.getInheritFrom()); + + insertPermissions(rank.getId(), rank.getPermissions()); + } + + @Override + public void insertPermissions(String rankId, Set permissions) throws SQLException + { + if (permissions == null || permissions.isEmpty()) return; + + String sql = String.format("%s INTO %s (%s, %s) VALUES (?, ?)%s", + adapter.insertIgnoreSyntax(), tblRankPermissions, colRankId, colPermission, adapter.insertIgnoreSuffix()); + for (String permission : permissions) + { + statementHandler.executeUpdate(sql, rankId, permission); + } + } + + @Override + public void addPermission(String rankId, String permission) throws SQLException + { + String sql = String.format("%s INTO %s (%s, %s) VALUES (?, ?)%s", + adapter.insertIgnoreSyntax(), tblRankPermissions, colRankId, colPermission, adapter.insertIgnoreSuffix()); + statementHandler.executeUpdate(sql, rankId, permission); + } + + @Override + public Map loadAll() throws SQLException + { + Map ranks = new LinkedHashMap<>(); + + String sql = String.format("SELECT %s FROM %s", selectColumns, tblRanks); + try (ResultSet rs = statementHandler.executeQuery(sql)) + { + while (rs.next()) + { + CustomRank rank = loadRankFromRow(rs); + ranks.put(rank.getId(), rank); + } + } + + String permSql = String.format("SELECT %s, %s FROM %s", colRankId, colPermission, tblRankPermissions); + try (ResultSet rs = statementHandler.executeQuery(permSql)) + { + while (rs.next()) + { + CustomRank rank = ranks.get(rs.getString("rank_id")); + if (rank != null) + { + rank.addPermission(rs.getString("permission")); + } + } + } + + return ranks; + } + + @Override + public CustomRank findById(String id) throws SQLException + { + String sql = String.format("SELECT %s FROM %s WHERE %s = ?", selectColumns, tblRanks, colId); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, id); + ResultSet rs = stmt.executeQuery()) + { + if (rs.next()) + { + CustomRank rank = loadRankFromRow(rs); + getPermissions(rank.getId()).forEach(rank::addPermission); + return rank; + } + } + return null; + } + + @Override + public boolean exists(String id) throws SQLException + { + String sql = String.format("SELECT COUNT(*) FROM %s WHERE %s = ?", tblRanks, colId); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, id); + ResultSet rs = stmt.executeQuery()) + { + return rs.next() && rs.getInt(1) > 0; + } + } + + @Override + public Set getPermissions(String rankId) throws SQLException + { + Set permissions = new HashSet<>(); + String sql = String.format("SELECT %s FROM %s WHERE %s = ?", colPermission, tblRankPermissions, colRankId); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, rankId); + ResultSet rs = stmt.executeQuery()) + { + while (rs.next()) + { + permissions.add(rs.getString("permission")); + } + } + return permissions; + } + + @Override + public boolean update(CustomRank rank) throws SQLException + { + String sql = String.format("UPDATE %s SET %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ? WHERE %s = ?", + tblRanks, colName, colDeterminer, colAbbreviation, colLevel, colColor, colAdmin, colConsoleOnly, + colPrefix, colInheritFrom, colId); + + int rows = statementHandler.executeUpdate(sql, + rank.getName(), + rank.getDeterminer(), + rank.getAbbreviation(), + rank.getLevel(), + serializeColor(rank.getColor()), + rank.isAdmin(), + rank.isConsoleOnly(), + rank.getPrefix(), + rank.getInheritFrom(), + rank.getId()); + + return rows > 0; + } + + @Override + public void syncPermissions(String rankId, Set permissions) throws SQLException + { + statementHandler.executeUpdate(String.format("DELETE FROM %s WHERE %s = ?", tblRankPermissions, colRankId), rankId); + insertPermissions(rankId, permissions); + } + + @Override + public void saveOrUpdate(CustomRank rank) throws SQLException + { + if (exists(rank.getId())) + { + update(rank); + syncPermissions(rank.getId(), rank.getPermissions()); + } + else + { + insert(rank); + } + } + + @Override + public boolean delete(String id) throws SQLException + { + String sql = String.format("DELETE FROM %s WHERE %s = ?", tblRanks, colId); + return statementHandler.executeUpdate(sql, id) > 0; + } + + @Override + public boolean removePermission(String rankId, String permission) throws SQLException + { + String sql = String.format("DELETE FROM %s WHERE %s = ? AND %s = ?", tblRankPermissions, colRankId, colPermission); + return statementHandler.executeUpdate(sql, rankId, permission) > 0; + } + + @Override + public void deleteAllSync() throws SQLException + { + statementHandler.executeUpdate(String.format("DELETE FROM %s", tblRankPermissions)); + statementHandler.executeUpdate(String.format("DELETE FROM %s", tblRanks)); + } + + @Override + public Mono> loadAllAsync() + { + return statementHandler.supplyMono(this::loadAll); + } + + @Override + public Mono save(CustomRank rank) + { + return statementHandler.runMono(() -> saveOrUpdate(rank)); + } + + @Override + public Mono deleteAsync(String id) + { + return statementHandler.supplyMono(() -> delete(id)); + } + + @Override + public Mono deleteAll() + { + return statementHandler.runMono(this::deleteAllSync); + } + + private CustomRank loadRankFromRow(ResultSet rs) throws SQLException + { + String id = rs.getString("id"); + CustomRank rank = new CustomRank(id); + rank.setName(rs.getString("name")); + rank.setDeterminer(rs.getString("determiner")); + rank.setAbbreviation(rs.getString("abbreviation")); + rank.setLevel(rs.getInt("level")); + rank.setColor(parseColor(rs.getString("color"))); + rank.setAdmin(rs.getBoolean("admin")); + rank.setConsoleOnly(rs.getBoolean("console_only")); + rank.setPrefix(rs.getString("prefix")); + rank.setInheritFrom(rs.getString("inherit_from")); + return rank; + } + + private static String serializeColor(NamedTextColor color) + { + return NamedTextColor.NAMES.keyOrThrow(color); + } + + private static NamedTextColor parseColor(String name) + { + NamedTextColor color = name == null ? null : NamedTextColor.NAMES.value(name.toLowerCase()); + return color != null ? color : NamedTextColor.WHITE; + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericSavedFlagRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericSavedFlagRepository.java new file mode 100644 index 000000000..ebe8c0506 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericSavedFlagRepository.java @@ -0,0 +1,96 @@ +package me.totalfreedom.totalfreedommod.sql.adapter.generic; + +import me.totalfreedom.totalfreedommod.sql.StatementHandler; +import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; +import me.totalfreedom.totalfreedommod.sql.adapter.SavedFlagRepository; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.HashMap; +import java.util.Map; + +import reactor.core.publisher.Mono; + +/** + * All dialect differences are resolved through the {@link DatabaseAdapter} passed in. + */ +public class GenericSavedFlagRepository implements SavedFlagRepository +{ + private final StatementHandler statementHandler; + + private final String tblSavedFlags; + private final String colFlagName; + private final String colEnabled; + private final String selectSql; + private final String upsertSql; + + public GenericSavedFlagRepository(StatementHandler statementHandler, DatabaseAdapter adapter) + { + this.statementHandler = statementHandler; + + this.tblSavedFlags = adapter.quoteIdentifier("saved_flags"); + this.colFlagName = adapter.quoteIdentifier("flag_name"); + this.colEnabled = adapter.quoteIdentifier("enabled"); + + this.selectSql = String.format("SELECT %s, %s FROM %s", colFlagName, colEnabled, tblSavedFlags); + this.upsertSql = String.format("INSERT INTO %s (%s, %s) VALUES (?, ?) %s", + tblSavedFlags, colFlagName, colEnabled, adapter.upsertClause(colFlagName, colEnabled)); + } + + @Override + public Map loadAll() throws SQLException + { + Map flags = new HashMap<>(); + try (ResultSet rs = statementHandler.executeQuery(selectSql)) + { + while (rs.next()) + { + flags.put(rs.getString("flag_name"), rs.getBoolean("enabled")); + } + } + return flags; + } + + @Override + public void upsert(String flagName, boolean enabled) throws SQLException + { + statementHandler.executeUpdate(upsertSql, flagName, enabled); + } + + @Override + public boolean delete(String flagName) throws SQLException + { + String sql = String.format("DELETE FROM %s WHERE %s = ?", tblSavedFlags, colFlagName); + return statementHandler.executeUpdate(sql, flagName) > 0; + } + + @Override + public void deleteAllSync() throws SQLException + { + statementHandler.executeUpdate(String.format("DELETE FROM %s", tblSavedFlags)); + } + + @Override + public Mono> loadAllAsync() + { + return statementHandler.supplyMono(this::loadAll); + } + + @Override + public Mono upsertAsync(String flagName, boolean enabled) + { + return statementHandler.runMono(() -> upsert(flagName, enabled)); + } + + @Override + public Mono deleteAsync(String flagName) + { + return statementHandler.supplyMono(() -> delete(flagName)); + } + + @Override + public Mono deleteAll() + { + return statementHandler.runMono(this::deleteAllSync); + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericStrikeRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericStrikeRepository.java new file mode 100644 index 000000000..6b2e18fc0 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericStrikeRepository.java @@ -0,0 +1,107 @@ +package me.totalfreedom.totalfreedommod.sql.adapter.generic; + +import me.totalfreedom.totalfreedommod.banning.StrikeRecord; +import me.totalfreedom.totalfreedommod.sql.StatementHandler; +import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; +import me.totalfreedom.totalfreedommod.sql.adapter.StrikeRepository; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.HashMap; +import java.util.Map; + +import reactor.core.publisher.Mono; + +/** + * All dialect differences are resolved through the {@link DatabaseAdapter} passed in. + */ +public class GenericStrikeRepository implements StrikeRepository +{ + private final StatementHandler statementHandler; + + private final String tblStrikes; + private final String colIp; + private final String colStrikeCount; + private final String colLastStrikeUnix; + private final String colLastUsername; + private final String upsertSql; + private final String selectSql; + + public GenericStrikeRepository(StatementHandler statementHandler, DatabaseAdapter adapter) + { + this.statementHandler = statementHandler; + + this.tblStrikes = adapter.quoteIdentifier("strikes"); + this.colIp = adapter.quoteIdentifier("ip"); + this.colStrikeCount = adapter.quoteIdentifier("strike_count"); + this.colLastStrikeUnix = adapter.quoteIdentifier("last_strike_unix"); + this.colLastUsername = adapter.quoteIdentifier("last_username"); + + this.selectSql = String.format("SELECT %s, %s, %s, %s FROM %s", + colIp, colStrikeCount, colLastStrikeUnix, colLastUsername, tblStrikes); + this.upsertSql = String.format("INSERT INTO %s (%s, %s, %s, %s) VALUES (?, ?, ?, ?) %s", + tblStrikes, colIp, colStrikeCount, colLastStrikeUnix, colLastUsername, + adapter.upsertClause(colIp, colStrikeCount, colLastStrikeUnix, colLastUsername)); + } + + @Override + public Map loadAll() throws SQLException + { + Map out = new HashMap<>(); + try (ResultSet rs = statementHandler.executeQuery(selectSql)) + { + while (rs.next()) + { + String ip = rs.getString("ip"); + int count = rs.getInt("strike_count"); + long last = rs.getLong("last_strike_unix"); + String username = rs.getString("last_username"); + out.put(ip, new StrikeRecord(ip, count, last, username)); + } + } + return out; + } + + @Override + public void upsert(StrikeRecord r) throws SQLException + { + statementHandler.executeUpdate(upsertSql, r.getIp(), r.getCount(), r.getLastStrikeUnix(), r.getLastUsername()); + } + + @Override + public boolean deleteByIp(String ip) throws SQLException + { + String sql = String.format("DELETE FROM %s WHERE %s = ?", tblStrikes, colIp); + return statementHandler.executeUpdate(sql, ip) > 0; + } + + @Override + public void deleteAllSync() throws SQLException + { + statementHandler.executeUpdate(String.format("DELETE FROM %s", tblStrikes)); + } + + @Override + public Mono> loadAllAsync() + { + return statementHandler.supplyMono(this::loadAll); + } + + @Override + public Mono upsertAsync(StrikeRecord r) + { + return statementHandler.runMono(() -> upsert(r)); + } + + @Override + public Mono deleteByIpAsync(String ip) + { + return statementHandler.supplyMono(() -> deleteByIp(ip)); + } + + @Override + public Mono deleteAll() + { + return statementHandler.runMono(this::deleteAllSync); + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdapter.java index 194fb0098..e9c73b9fe 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdapter.java @@ -3,15 +3,13 @@ import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.sql.ConnectionHandler; import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.AdminRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.BanRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; -import me.totalfreedom.totalfreedommod.sql.adapter.DiscordLinkRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.PermbanRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.StrikeRepository; +import me.totalfreedom.totalfreedommod.sql.adapter.*; +import me.totalfreedom.totalfreedommod.sql.adapter.generic.*; import me.totalfreedom.totalfreedommod.util.FLog; import java.sql.SQLException; +import java.util.stream.Collectors; +import java.util.stream.Stream; /** * MySQL-specific database adapter. @@ -25,11 +23,15 @@ */ public class MySQLAdapter extends DatabaseAdapter { - private MySQLAdminRepository adminRepository; - private MySQLBanRepository banRepository; - private MySQLPermbanRepository permbanRepository; - private MySQLStrikeRepository strikeRepository; - private MySQLDiscordLinkRepository discordLinkRepository; + private AdminRepository adminRepository; + private BanRepository banRepository; + private PermbanRepository permbanRepository; + private StrikeRepository strikeRepository; + private DiscordLinkRepository discordLinkRepository; + private RankRepository rankRepository; + private ProtectedAreaRepository protectedAreaRepository; + private SavedFlagRepository savedFlagRepository; + private PlayerRepository playerRepository; public MySQLAdapter(TotalFreedomMod plugin, ConnectionHandler connectionHandler, StatementHandler statementHandler) { @@ -70,12 +72,30 @@ public String booleanType() return "TINYINT(1)"; } + @Override + public String jsonType() + { + return "JSON"; + } + + @Override + public String jsonParamPlaceholder() + { + return "?"; + } + @Override public String insertIgnoreSyntax() { return "INSERT IGNORE"; } + @Override + public String insertIgnoreSuffix() + { + return ""; + } + @Override public String quoteIdentifier(String identifier) { @@ -89,10 +109,31 @@ public String currentTimestamp() } @Override - public String caseInsensitiveLike() + public String timestampParamPlaceholder() { - // MySQL is case-insensitive by default with utf8_general_ci collation - return "LIKE"; + return "?"; + } + + @Override + public String caseInsensitiveEquals(String columnRef, String paramPlaceholder) + { + return String.format("LOWER(%s) = LOWER(%s)", columnRef, paramPlaceholder); + } + + @Override + public String compareToNow(String columnRef, String operator) + { + return String.format("%s %s NOW()", columnRef, operator); + } + + // MySQL's ON DUPLICATE KEY UPDATE infers the conflicting row from whatever key was violated. + @Override + public String upsertClause(String conflictColumn, String... updateColumns) + { + String assignments = Stream.of(updateColumns) + .map(col -> String.format("%s = VALUES(%s)", col, col)) + .collect(Collectors.joining(", ")); + return String.format("ON DUPLICATE KEY UPDATE %s", assignments); } // ============================================ @@ -113,6 +154,12 @@ public void runMigrations() throws SQLException createPermbanIpsTable(); createStrikesTable(); createDiscordLinksTable(); + createRanksTable(); + createRankPermissionsTable(); + createProtectedAreasTable(); + createSavedFlagsTable(); + createPlayersTable(); + createPlayerIpsTable(); FLog.info("[MySQL] Database migrations complete."); } @@ -140,11 +187,22 @@ private void createAdminsTable() throws SQLException `active` TINYINT(1) DEFAULT 1, `last_login` DATETIME, `login_message` TEXT, + `custom_rank` VARCHAR(64), INDEX `idx_admins_username` (`username`), INDEX `idx_admins_active` (`active`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci """; statementHandler.executeUpdate(sql); + + // Migration for tables created before custom_rank existed. + try + { + statementHandler.executeUpdate("ALTER TABLE `admins` ADD COLUMN `custom_rank` VARCHAR(64)"); + } + catch (SQLException ignored) + { + // Column already exists. + } } private void createAdminIpsTable() throws SQLException @@ -253,6 +311,104 @@ private void createDiscordLinksTable() throws SQLException statementHandler.executeUpdate(sql); } + private void createRanksTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS `ranks` ( + `id` VARCHAR(64) PRIMARY KEY, + `name` VARCHAR(64) NOT NULL, + `determiner` VARCHAR(8) NOT NULL DEFAULT 'a', + `abbreviation` VARCHAR(16), + `level` INT NOT NULL DEFAULT 0, + `color` VARCHAR(32) NOT NULL DEFAULT 'white', + `admin` TINYINT(1) NOT NULL DEFAULT 0, + `console_only` TINYINT(1) NOT NULL DEFAULT 0, + `prefix` VARCHAR(64), + `inherit_from` VARCHAR(64), + INDEX `idx_ranks_level` (`level`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """; + statementHandler.executeUpdate(sql); + } + + private void createRankPermissionsTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS `rank_permissions` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `rank_id` VARCHAR(64) NOT NULL, + `permission` VARCHAR(128) NOT NULL, + UNIQUE KEY `uk_rank_permission` (`rank_id`, `permission`), + FOREIGN KEY (`rank_id`) REFERENCES `ranks`(`id`) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """; + statementHandler.executeUpdate(sql); + } + + private void createProtectedAreasTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS `protected_areas` ( + `uuid` VARCHAR(36) PRIMARY KEY, + `name` VARCHAR(64) NOT NULL, + `min_x` INT NOT NULL, + `min_y` INT NOT NULL, + `min_z` INT NOT NULL, + `max_x` INT NOT NULL, + `max_y` INT NOT NULL, + `max_z` INT NOT NULL, + `world_uuid` VARCHAR(36) NOT NULL, + INDEX `idx_protected_areas_name` (`name`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """; + statementHandler.executeUpdate(sql); + } + + private void createSavedFlagsTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS `saved_flags` ( + `flag_name` VARCHAR(64) PRIMARY KEY, + `enabled` TINYINT(1) NOT NULL DEFAULT 0 + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """; + statementHandler.executeUpdate(sql); + } + + private void createPlayersTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS `players` ( + `username` VARCHAR(16) PRIMARY KEY, + `first_join_unix` BIGINT NOT NULL DEFAULT 0, + `last_join_unix` BIGINT NOT NULL DEFAULT 0, + `potion_spy` TINYINT(1) NOT NULL DEFAULT 0, + `command_spy_mode` VARCHAR(16) NOT NULL DEFAULT 'off', + `muted` TINYINT(1) NOT NULL DEFAULT 0, + `frozen` TINYINT(1) NOT NULL DEFAULT 0, + `commands_blocked` TINYINT(1) NOT NULL DEFAULT 0, + `strikes` INT NOT NULL DEFAULT 0, + `saved_tag` TEXT, + `nickname` TEXT + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """; + statementHandler.executeUpdate(sql); + } + + private void createPlayerIpsTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS `player_ips` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `username` VARCHAR(16) NOT NULL, + `ip` VARCHAR(45) NOT NULL, + UNIQUE KEY `uk_player_ip` (`username`, `ip`), + FOREIGN KEY (`username`) REFERENCES `players`(`username`) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """; + statementHandler.executeUpdate(sql); + } + // ============================================ // Repository Getters // ============================================ @@ -262,7 +418,7 @@ public AdminRepository getAdminRepository() { if (adminRepository == null) { - adminRepository = new MySQLAdminRepository(plugin, statementHandler); + adminRepository = new GenericAdminRepository(statementHandler, this); } return adminRepository; } @@ -272,7 +428,7 @@ public BanRepository getBanRepository() { if (banRepository == null) { - banRepository = new MySQLBanRepository(plugin, statementHandler); + banRepository = new GenericBanRepository(statementHandler, this); } return banRepository; } @@ -282,7 +438,7 @@ public PermbanRepository getPermbanRepository() { if (permbanRepository == null) { - permbanRepository = new MySQLPermbanRepository(plugin, statementHandler); + permbanRepository = new GenericPermbanRepository(statementHandler, this); } return permbanRepository; } @@ -292,7 +448,7 @@ public StrikeRepository getStrikeRepository() { if (strikeRepository == null) { - strikeRepository = new MySQLStrikeRepository(plugin, statementHandler); + strikeRepository = new GenericStrikeRepository(statementHandler, this); } return strikeRepository; } @@ -302,8 +458,48 @@ public DiscordLinkRepository getDiscordLinkRepository() { if (discordLinkRepository == null) { - discordLinkRepository = new MySQLDiscordLinkRepository(plugin, statementHandler); + discordLinkRepository = new GenericDiscordLinkRepository(statementHandler, this); } return discordLinkRepository; } + + @Override + public RankRepository getRankRepository() + { + if (rankRepository == null) + { + rankRepository = new GenericRankRepository(statementHandler, this); + } + return rankRepository; + } + + @Override + public ProtectedAreaRepository getProtectedAreaRepository() + { + if (protectedAreaRepository == null) + { + protectedAreaRepository = new GenericProtectedAreaRepository(statementHandler, this); + } + return protectedAreaRepository; + } + + @Override + public SavedFlagRepository getSavedFlagRepository() + { + if (savedFlagRepository == null) + { + savedFlagRepository = new GenericSavedFlagRepository(statementHandler, this); + } + return savedFlagRepository; + } + + @Override + public PlayerRepository getPlayerRepository() + { + if (playerRepository == null) + { + playerRepository = new GenericPlayerRepository(statementHandler, this); + } + return playerRepository; + } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdminRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdminRepository.java deleted file mode 100644 index 47d6ebd5c..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdminRepository.java +++ /dev/null @@ -1,473 +0,0 @@ -package me.totalfreedom.totalfreedommod.sql.adapter.mysql; - -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.admin.Admin; -import me.totalfreedom.totalfreedommod.rank.Rank; -import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.AdminRepository; -import me.totalfreedom.totalfreedommod.util.FUtil; - -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.*; - -import reactor.core.publisher.Mono; - -/** - * MySQL/MariaDB implementation of AdminRepository. - * Uses MySQL-specific SQL syntax. - */ -public class MySQLAdminRepository implements AdminRepository -{ - private final TotalFreedomMod plugin; - private final StatementHandler statementHandler; - - public MySQLAdminRepository(TotalFreedomMod plugin, StatementHandler statementHandler) - { - this.plugin = plugin; - this.statementHandler = statementHandler; - } - - // ============================================ - // CREATE Operations - // ============================================ - - @Override - public int insert(UUID uuid, Admin admin) throws SQLException - { - String sql = """ - INSERT INTO `admins` (`uuid`, `username`, `rank`, `active`, `last_login`, `login_message`) - VALUES (?, ?, ?, ?, ?, ?) - """; - - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, - uuid.toString(), - admin.getName(), - admin.getRank().toString(), - admin.isActive() ? 1 : 0, - FUtil.dateToString(admin.getLastLogin()), - admin.getLoginMessage())) - { - stmt.executeUpdate(); - - try (ResultSet rs = stmt.getGeneratedKeys()) - { - if (rs.next()) - { - int adminId = rs.getInt(1); - insertIps(adminId, admin.getIps()); - return adminId; - } - } - } - return -1; - } - - @Override - public void insertIps(int adminId, List ips) throws SQLException - { - if (ips == null || ips.isEmpty()) return; - - // MySQL: INSERT IGNORE - String sql = "INSERT IGNORE INTO `admin_ips` (`admin_id`, `ip`) VALUES (?, ?)"; - for (String ip : ips) - { - statementHandler.executeUpdate(sql, adminId, ip); - } - } - - @Override - public void addIp(int adminId, String ip) throws SQLException - { - String sql = "INSERT IGNORE INTO `admin_ips` (`admin_id`, `ip`) VALUES (?, ?)"; - statementHandler.executeUpdate(sql, adminId, ip); - } - - // ============================================ - // READ Operations - // ============================================ - - @Override - public Map loadAll() throws SQLException - { - Map admins = new HashMap<>(); - Map adminById = new HashMap<>(); - - String sql = "SELECT `id`, `uuid`, `username`, `rank`, `active`, `last_login`, `login_message` FROM `admins`"; - try (ResultSet rs = statementHandler.executeQuery(sql)) - { - while (rs.next()) - { - int id = rs.getInt("id"); - String username = rs.getString("username"); - String rankStr = rs.getString("rank"); - boolean active = rs.getInt("active") == 1; - String lastLoginStr = rs.getString("last_login"); - String loginMessage = rs.getString("login_message"); - - String configKey = username.toLowerCase(); - Admin admin = new Admin(configKey); - admin.setName(username); - admin.setRank(Rank.findRank(rankStr)); - admin.setActive(active); - admin.setLastLogin(FUtil.stringToDate(lastLoginStr)); - admin.setLoginMessage(loginMessage); - - UUID dbUuid = FUtil.parseUuid(rs.getString("uuid")); - if (dbUuid != null) - { - admin.setUuid(dbUuid); - } - - admins.put(configKey, admin); - adminById.put(id, admin); - } - } - - // Load IPs - String ipSql = "SELECT `admin_id`, `ip` FROM `admin_ips`"; - try (ResultSet rs = statementHandler.executeQuery(ipSql)) - { - while (rs.next()) - { - int adminId = rs.getInt("admin_id"); - String ip = rs.getString("ip"); - Admin admin = adminById.get(adminId); - if (admin != null) - { - admin.addIp(ip); - } - } - } - - return admins; - } - - @Override - public Admin findByUuid(UUID uuid) throws SQLException - { - String sql = "SELECT `id`, `uuid`, `username`, `rank`, `active`, `last_login`, `login_message` FROM `admins` WHERE `uuid` = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadAdminFromResultSet(rs); - } - } - return null; - } - - @Override - public Admin findByUsername(String username) throws SQLException - { - // MySQL: case-insensitive with collation, but using LOWER() for consistency - String sql = "SELECT `id`, `uuid`, `username`, `rank`, `active`, `last_login`, `login_message` FROM `admins` WHERE LOWER(`username`) = LOWER(?)"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadAdminFromResultSet(rs); - } - } - return null; - } - - @Override - public Admin findByIp(String ip) throws SQLException - { - String sql = """ - SELECT a.`id`, a.`uuid`, a.`username`, a.`rank`, a.`active`, a.`last_login`, a.`login_message` - FROM `admins` a - INNER JOIN `admin_ips` ai ON a.`id` = ai.`admin_id` - WHERE ai.`ip` = ? - """; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, ip); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadAdminFromResultSet(rs); - } - } - return null; - } - - @Override - public int getAdminId(String username) throws SQLException - { - String sql = "SELECT `id` FROM `admins` WHERE LOWER(`username`) = LOWER(?)"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) return rs.getInt("id"); - } - return -1; - } - - @Override - public int getAdminIdByUuid(UUID uuid) throws SQLException - { - String sql = "SELECT `id` FROM `admins` WHERE `uuid` = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) return rs.getInt("id"); - } - return -1; - } - - @Override - public List getIps(int adminId) throws SQLException - { - List ips = new ArrayList<>(); - String sql = "SELECT `ip` FROM `admin_ips` WHERE `admin_id` = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, adminId); - ResultSet rs = stmt.executeQuery()) - { - while (rs.next()) - { - ips.add(rs.getString("ip")); - } - } - return ips; - } - - @Override - public boolean exists(String username) throws SQLException - { - String sql = "SELECT COUNT(*) FROM `admins` WHERE LOWER(`username`) = LOWER(?)"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - @Override - public boolean existsByUuid(UUID uuid) throws SQLException - { - String sql = "SELECT COUNT(*) FROM `admins` WHERE `uuid` = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - @Override - public UUID getUuidByUsername(String username) throws SQLException - { - String sql = "SELECT `uuid` FROM `admins` WHERE LOWER(`username`) = LOWER(?)"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return UUID.fromString(rs.getString("uuid")); - } - } - return null; - } - - // ============================================ - // UPDATE Operations - // ============================================ - - @Override - public boolean update(UUID uuid, Admin admin) throws SQLException - { - String sql = """ - UPDATE `admins` - SET `username` = ?, `rank` = ?, `active` = ?, `last_login` = ?, `login_message` = ? - WHERE `uuid` = ? - """; - - int rows = statementHandler.executeUpdate(sql, - admin.getName(), - admin.getRank().toString(), - admin.isActive() ? 1 : 0, - FUtil.dateToString(admin.getLastLogin()), - admin.getLoginMessage(), - uuid.toString()); - - return rows > 0; - } - - @Override - public boolean updateRank(String username, String rank) throws SQLException - { - String sql = "UPDATE `admins` SET `rank` = ? WHERE LOWER(`username`) = LOWER(?)"; - return statementHandler.executeUpdate(sql, rank, username) > 0; - } - - @Override - public boolean updateActive(String username, boolean active) throws SQLException - { - String sql = "UPDATE `admins` SET `active` = ? WHERE LOWER(`username`) = LOWER(?)"; - return statementHandler.executeUpdate(sql, active ? 1 : 0, username) > 0; - } - - @Override - public boolean updateLastLogin(String username, Date lastLogin) throws SQLException - { - String sql = "UPDATE `admins` SET `last_login` = ? WHERE LOWER(`username`) = LOWER(?)"; - return statementHandler.executeUpdate(sql, FUtil.dateToString(lastLogin), username) > 0; - } - - @Override - public boolean updateUsername(UUID uuid, String newUsername) throws SQLException - { - String sql = "UPDATE `admins` SET `username` = ? WHERE `uuid` = ?"; - return statementHandler.executeUpdate(sql, newUsername, uuid.toString()) > 0; - } - - @Override - public void syncIps(int adminId, List ips) throws SQLException - { - statementHandler.executeUpdate("DELETE FROM `admin_ips` WHERE `admin_id` = ?", adminId); - insertIps(adminId, ips); - } - - @Override - public int saveOrUpdate(UUID uuid, Admin admin) throws SQLException - { - if (existsByUuid(uuid)) - { - update(uuid, admin); - int adminId = getAdminIdByUuid(uuid); - syncIps(adminId, admin.getIps()); - return adminId; - } - else - { - return insert(uuid, admin); - } - } - - // ============================================ - // DELETE Operations - // ============================================ - - @Override - public boolean delete(UUID uuid) throws SQLException - { - String sql = "DELETE FROM `admins` WHERE `uuid` = ?"; - return statementHandler.executeUpdate(sql, uuid.toString()) > 0; - } - - @Override - public boolean deleteByUsername(String username) throws SQLException - { - String sql = "DELETE FROM `admins` WHERE LOWER(`username`) = LOWER(?)"; - return statementHandler.executeUpdate(sql, username) > 0; - } - - @Override - public boolean removeIp(int adminId, String ip) throws SQLException - { - String sql = "DELETE FROM `admin_ips` WHERE `admin_id` = ? AND `ip` = ?"; - return statementHandler.executeUpdate(sql, adminId, ip) > 0; - } - - @Override - public boolean clearIps(int adminId) throws SQLException - { - String sql = "DELETE FROM `admin_ips` WHERE `admin_id` = ?"; - return statementHandler.executeUpdate(sql, adminId) > 0; - } - - @Override - public void deleteAllSync() throws SQLException - { - statementHandler.executeUpdate("DELETE FROM `admin_ips`"); - statementHandler.executeUpdate("DELETE FROM `admins`"); - } - - // ============================================ - // Async Operations - // ============================================ - - @Override - public Mono> loadAllAsync() - { - return statementHandler.supplyMono(this::loadAll); - } - - @Override - public Mono insertAsync(UUID uuid, Admin admin) - { - return statementHandler.supplyMono(() -> insert(uuid, admin)); - } - - @Override - public Mono updateAsync(UUID uuid, Admin admin) - { - return statementHandler.supplyMono(() -> update(uuid, admin)); - } - - @Override - public Mono deleteAsync(UUID uuid) - { - return statementHandler.supplyMono(() -> delete(uuid)); - } - - @Override - public Mono save(UUID uuid, Admin admin) - { - return statementHandler.supplyMono(() -> saveOrUpdate(uuid, admin)); - } - - @Override - public Mono> findAll() - { - return statementHandler.supplyMono(() -> new ArrayList<>(loadAll().values())); - } - - @Override - public Mono deleteByUuid(UUID uuid) - { - return deleteAsync(uuid); - } - - @Override - public Mono deleteAll() - { - return statementHandler.runMono(this::deleteAllSync); - } - - // ============================================ - // Helper Methods - // ============================================ - - private Admin loadAdminFromResultSet(ResultSet rs) throws SQLException - { - int id = rs.getInt("id"); - String username = rs.getString("username"); - String rankStr = rs.getString("rank"); - boolean active = rs.getInt("active") == 1; - String lastLoginStr = rs.getString("last_login"); - String loginMessage = rs.getString("login_message"); - - String configKey = username.toLowerCase(); - Admin admin = new Admin(configKey); - admin.setName(username); - admin.setRank(Rank.findRank(rankStr)); - admin.setActive(active); - admin.setLastLogin(FUtil.stringToDate(lastLoginStr)); - admin.setLoginMessage(loginMessage); - - UUID dbUuid = FUtil.parseUuid(rs.getString("uuid")); - if (dbUuid != null) - { - admin.setUuid(dbUuid); - } - - List ips = getIps(id); - admin.addIps(ips); - - return admin; - } -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLBanRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLBanRepository.java deleted file mode 100644 index dfac64c11..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLBanRepository.java +++ /dev/null @@ -1,505 +0,0 @@ -package me.totalfreedom.totalfreedommod.sql.adapter.mysql; - -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.banning.Ban; -import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.BanRepository; -import me.totalfreedom.totalfreedommod.util.FUtil; - -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.*; - -import reactor.core.publisher.Mono; - -/** - * MySQL/MariaDB implementation of BanRepository. - * Uses MySQL-specific SQL syntax. - */ -public class MySQLBanRepository implements BanRepository -{ - private final TotalFreedomMod plugin; - private final StatementHandler statementHandler; - - public MySQLBanRepository(TotalFreedomMod plugin, StatementHandler statementHandler) - { - this.plugin = plugin; - this.statementHandler = statementHandler; - } - - // ============================================ - // CREATE Operations - // ============================================ - - @Override - public int insert(Ban ban) throws SQLException - { - String sql = """ - INSERT INTO `bans` (`uuid`, `username`, `banned_by`, `banned_by_uuid`, `reason`, `expire_at`) - VALUES (?, ?, ?, ?, ?, ?) - """; - - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, - ban.getUuid() != null ? ban.getUuid().toString() : null, - ban.getUsername(), - ban.getBannedBy(), - ban.getBannedByUuid() != null ? ban.getBannedByUuid().toString() : null, - ban.getReason(), - ban.getExpireAt() != null ? FUtil.dateToString(ban.getExpireAt()) : null)) - { - stmt.executeUpdate(); - - try (ResultSet rs = stmt.getGeneratedKeys()) - { - if (rs.next()) - { - int banId = rs.getInt(1); - insertIps(banId, ban.getIps()); - return banId; - } - } - } - return -1; - } - - @Override - public void insertIps(int banId, List ips) throws SQLException - { - if (ips == null || ips.isEmpty()) return; - - String sql = "INSERT IGNORE INTO `ban_ips` (`ban_id`, `ip`) VALUES (?, ?)"; - for (String ip : ips) - { - statementHandler.executeUpdate(sql, banId, ip); - } - } - - @Override - public void addIp(int banId, String ip) throws SQLException - { - String sql = "INSERT IGNORE INTO `ban_ips` (`ban_id`, `ip`) VALUES (?, ?)"; - statementHandler.executeUpdate(sql, banId, ip); - } - - // ============================================ - // READ Operations - // ============================================ - - @Override - public List loadAll() throws SQLException - { - List bans = new ArrayList<>(); - Map banById = new HashMap<>(); - - String sql = "SELECT `id`, `uuid`, `username`, `banned_by`, `banned_by_uuid`, `reason`, `expire_at` FROM `bans`"; - try (ResultSet rs = statementHandler.executeQuery(sql)) - { - while (rs.next()) - { - int id = rs.getInt("id"); - String uuidStr = rs.getString("uuid"); - String username = rs.getString("username"); - String bannedBy = rs.getString("banned_by"); - String bannedByUuidStr = rs.getString("banned_by_uuid"); - String reason = rs.getString("reason"); - String expireAtStr = rs.getString("expire_at"); - - Ban ban = new Ban(); - ban.setUuid(uuidStr != null ? UUID.fromString(uuidStr) : null); - ban.setUsername(username); - ban.setBannedBy(bannedBy); - ban.setBannedByUuid(bannedByUuidStr != null ? UUID.fromString(bannedByUuidStr) : null); - ban.setReason(reason); - ban.setExpireAt(expireAtStr != null ? FUtil.stringToDate(expireAtStr) : null); - - bans.add(ban); - banById.put(id, ban); - } - } - - // Load IPs - String ipSql = "SELECT `ban_id`, `ip` FROM `ban_ips`"; - try (ResultSet rs = statementHandler.executeQuery(ipSql)) - { - while (rs.next()) - { - int banId = rs.getInt("ban_id"); - String ip = rs.getString("ip"); - Ban ban = banById.get(banId); - if (ban != null) - { - ban.addIp(ip); - } - } - } - - return bans; - } - - @Override - public Ban findByUuid(UUID uuid) throws SQLException - { - String sql = "SELECT `id`, `uuid`, `username`, `banned_by`, `banned_by_uuid`, `reason`, `expire_at` FROM `bans` WHERE `uuid` = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadBanFromResultSet(rs); - } - } - return null; - } - - @Override - public Ban findByUsername(String username) throws SQLException - { - String sql = "SELECT `id`, `uuid`, `username`, `banned_by`, `banned_by_uuid`, `reason`, `expire_at` FROM `bans` WHERE LOWER(`username`) = LOWER(?)"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadBanFromResultSet(rs); - } - } - return null; - } - - @Override - public Ban findByIp(String ip) throws SQLException - { - String sql = """ - SELECT b.`id`, b.`uuid`, b.`username`, b.`banned_by`, b.`banned_by_uuid`, b.`reason`, b.`expire_at` - FROM `bans` b - INNER JOIN `ban_ips` bi ON b.`id` = bi.`ban_id` - WHERE bi.`ip` = ? - """; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, ip); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadBanFromResultSet(rs); - } - } - return null; - } - - @Override - public List findActiveBans() throws SQLException - { - // MySQL: NOW() for current time - String sql = """ - SELECT `id`, `uuid`, `username`, `banned_by`, `banned_by_uuid`, `reason`, `expire_at` - FROM `bans` - WHERE `expire_at` IS NULL OR `expire_at` > NOW() - """; - List bans = new ArrayList<>(); - try (ResultSet rs = statementHandler.executeQuery(sql)) - { - while (rs.next()) - { - bans.add(loadBanFromResultSet(rs)); - } - } - return bans; - } - - @Override - public List findExpiredBans() throws SQLException - { - String sql = """ - SELECT `id`, `uuid`, `username`, `banned_by`, `banned_by_uuid`, `reason`, `expire_at` - FROM `bans` - WHERE `expire_at` IS NOT NULL AND `expire_at` <= NOW() - """; - List bans = new ArrayList<>(); - try (ResultSet rs = statementHandler.executeQuery(sql)) - { - while (rs.next()) - { - bans.add(loadBanFromResultSet(rs)); - } - } - return bans; - } - - @Override - public int getBanId(UUID uuid) throws SQLException - { - String sql = "SELECT `id` FROM `bans` WHERE `uuid` = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) return rs.getInt("id"); - } - return -1; - } - - @Override - public int getBanIdByUsername(String username) throws SQLException - { - String sql = "SELECT `id` FROM `bans` WHERE LOWER(`username`) = LOWER(?)"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) return rs.getInt("id"); - } - return -1; - } - - @Override - public List getIps(int banId) throws SQLException - { - List ips = new ArrayList<>(); - String sql = "SELECT `ip` FROM `ban_ips` WHERE `ban_id` = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, banId); - ResultSet rs = stmt.executeQuery()) - { - while (rs.next()) - { - ips.add(rs.getString("ip")); - } - } - return ips; - } - - @Override - public boolean isBanned(UUID uuid) throws SQLException - { - String sql = """ - SELECT COUNT(*) FROM `bans` - WHERE `uuid` = ? AND (`expire_at` IS NULL OR `expire_at` > NOW()) - """; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - @Override - public boolean isBannedByUsername(String username) throws SQLException - { - String sql = """ - SELECT COUNT(*) FROM `bans` - WHERE LOWER(`username`) = LOWER(?) AND (`expire_at` IS NULL OR `expire_at` > NOW()) - """; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - @Override - public boolean isBannedByIp(String ip) throws SQLException - { - String sql = """ - SELECT COUNT(*) FROM `bans` b - INNER JOIN `ban_ips` bi ON b.`id` = bi.`ban_id` - WHERE bi.`ip` = ? AND (b.`expire_at` IS NULL OR b.`expire_at` > NOW()) - """; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, ip); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - // ============================================ - // UPDATE Operations - // ============================================ - - @Override - public boolean update(Ban ban) throws SQLException - { - String sql = """ - UPDATE `bans` - SET `username` = ?, `banned_by` = ?, `banned_by_uuid` = ?, `reason` = ?, `expire_at` = ? - WHERE `uuid` = ? - """; - - int rows = statementHandler.executeUpdate(sql, - ban.getUsername(), - ban.getBannedBy(), - ban.getBannedByUuid() != null ? ban.getBannedByUuid().toString() : null, - ban.getReason(), - ban.getExpireAt() != null ? FUtil.dateToString(ban.getExpireAt()) : null, - ban.getUuid().toString()); - - return rows > 0; - } - - @Override - public boolean updateReason(UUID uuid, String reason) throws SQLException - { - String sql = "UPDATE `bans` SET `reason` = ? WHERE `uuid` = ?"; - return statementHandler.executeUpdate(sql, reason, uuid.toString()) > 0; - } - - @Override - public boolean updateExpiry(UUID uuid, Date expireAt) throws SQLException - { - String sql = "UPDATE `bans` SET `expire_at` = ? WHERE `uuid` = ?"; - return statementHandler.executeUpdate(sql, expireAt != null ? FUtil.dateToString(expireAt) : null, uuid.toString()) > 0; - } - - @Override - public void syncIps(int banId, List ips) throws SQLException - { - statementHandler.executeUpdate("DELETE FROM `ban_ips` WHERE `ban_id` = ?", banId); - insertIps(banId, ips); - } - - // ============================================ - // DELETE Operations - // ============================================ - - @Override - public boolean delete(UUID uuid) throws SQLException - { - String sql = "DELETE FROM `bans` WHERE `uuid` = ?"; - return statementHandler.executeUpdate(sql, uuid.toString()) > 0; - } - - @Override - public boolean deleteByUsername(String username) throws SQLException - { - String sql = "DELETE FROM `bans` WHERE LOWER(`username`) = LOWER(?)"; - return statementHandler.executeUpdate(sql, username) > 0; - } - - @Override - public boolean deleteByIp(String ip) throws SQLException - { - String selectSql = "SELECT `ban_id` FROM `ban_ips` WHERE `ip` = ?"; - List banIds = new ArrayList<>(); - try (PreparedStatement stmt = statementHandler.prepareStatement(selectSql, ip); - ResultSet rs = stmt.executeQuery()) - { - while (rs.next()) - { - banIds.add(rs.getInt("ban_id")); - } - } - - for (int banId : banIds) - { - statementHandler.executeUpdate("DELETE FROM `bans` WHERE `id` = ?", banId); - } - - return !banIds.isEmpty(); - } - - @Override - public boolean removeIp(int banId, String ip) throws SQLException - { - String sql = "DELETE FROM `ban_ips` WHERE `ban_id` = ? AND `ip` = ?"; - return statementHandler.executeUpdate(sql, banId, ip) > 0; - } - - @Override - public int deleteExpiredBans() throws SQLException - { - String sql = "DELETE FROM `bans` WHERE `expire_at` IS NOT NULL AND `expire_at` <= NOW()"; - return statementHandler.executeUpdate(sql); - } - - @Override - public void deleteAllSync() throws SQLException - { - statementHandler.executeUpdate("DELETE FROM `ban_ips`"); - statementHandler.executeUpdate("DELETE FROM `bans`"); - } - - // ============================================ - // Async Operations - // ============================================ - - @Override - public Mono> loadAllAsync() - { - return statementHandler.supplyMono(this::loadAll); - } - - @Override - public Mono insertAsync(Ban ban) - { - return statementHandler.supplyMono(() -> insert(ban)); - } - - @Override - public Mono updateAsync(Ban ban) - { - return statementHandler.supplyMono(() -> update(ban)); - } - - @Override - public Mono deleteAsync(UUID uuid) - { - return statementHandler.supplyMono(() -> delete(uuid)); - } - - @Override - public Mono save(Ban ban) - { - return statementHandler.supplyMono(() -> { - // Check if ban exists by UUID - if (ban.getUuid() != null && getBanId(ban.getUuid()) > 0) - { - update(ban); - return getBanId(ban.getUuid()); - } - return insert(ban); - }); - } - - @Override - public Mono> findAll() - { - return loadAllAsync(); - } - - @Override - public Mono deleteByUuid(UUID uuid) - { - return deleteAsync(uuid); - } - - @Override - public Mono deleteAll() - { - return statementHandler.runMono(this::deleteAllSync); - } - - // ============================================ - // Helper Methods - // ============================================ - - private Ban loadBanFromResultSet(ResultSet rs) throws SQLException - { - int id = rs.getInt("id"); - String uuidStr = rs.getString("uuid"); - String username = rs.getString("username"); - String bannedBy = rs.getString("banned_by"); - String bannedByUuidStr = rs.getString("banned_by_uuid"); - String reason = rs.getString("reason"); - String expireAtStr = rs.getString("expire_at"); - - Ban ban = new Ban(); - ban.setUuid(uuidStr != null ? UUID.fromString(uuidStr) : null); - ban.setUsername(username); - ban.setBannedBy(bannedBy); - ban.setBannedByUuid(bannedByUuidStr != null ? UUID.fromString(bannedByUuidStr) : null); - ban.setReason(reason); - ban.setExpireAt(expireAtStr != null ? FUtil.stringToDate(expireAtStr) : null); - - List ips = getIps(id); - ban.setIps(ips); - - return ban; - } -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLDiscordLinkRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLDiscordLinkRepository.java deleted file mode 100644 index 7bcc62673..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLDiscordLinkRepository.java +++ /dev/null @@ -1,67 +0,0 @@ -package me.totalfreedom.totalfreedommod.sql.adapter.mysql; - -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.UUID; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.DiscordLinkRepository; - -public class MySQLDiscordLinkRepository implements DiscordLinkRepository -{ - private final TotalFreedomMod plugin; - private final StatementHandler statementHandler; - - public MySQLDiscordLinkRepository(TotalFreedomMod plugin, StatementHandler statementHandler) - { - this.plugin = plugin; - this.statementHandler = statementHandler; - } - - @Override - public void insert(UUID adminUuid, String discordUserId) throws SQLException - { - statementHandler.executeUpdate( - "INSERT INTO `discord_links` (`admin_uuid`, `discord_user_id`, `linked_at`) VALUES (?, ?, NOW())", - adminUuid.toString(), discordUserId); - } - - @Override - public String findDiscordIdByAdminUuid(UUID adminUuid) throws SQLException - { - try (ResultSet rs = statementHandler.executeQuery( - "SELECT `discord_user_id` FROM `discord_links` WHERE `admin_uuid` = ?", adminUuid.toString())) - { - return rs.next() ? rs.getString(1) : null; - } - } - - @Override - public UUID findAdminUuidByDiscordId(String discordUserId) throws SQLException - { - try (ResultSet rs = statementHandler.executeQuery( - "SELECT `admin_uuid` FROM `discord_links` WHERE `discord_user_id` = ?", discordUserId)) - { - if (!rs.next()) - { - return null; - } - String raw = rs.getString(1); - return raw == null ? null : UUID.fromString(raw); - } - } - - @Override - public boolean deleteByAdminUuid(UUID adminUuid) throws SQLException - { - return statementHandler.executeUpdate( - "DELETE FROM `discord_links` WHERE `admin_uuid` = ?", adminUuid.toString()) > 0; - } - - @Override - public boolean deleteByDiscordUserId(String discordUserId) throws SQLException - { - return statementHandler.executeUpdate( - "DELETE FROM `discord_links` WHERE `discord_user_id` = ?", discordUserId) > 0; - } -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLPermbanRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLPermbanRepository.java deleted file mode 100644 index 5ee5c0581..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLPermbanRepository.java +++ /dev/null @@ -1,420 +0,0 @@ -package me.totalfreedom.totalfreedommod.sql.adapter.mysql; - -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.banning.PermBan; -import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.PermbanRepository; - -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.*; - -import reactor.core.publisher.Mono; - -/** - * MySQL/MariaDB implementation of PermbanRepository. - * Uses MySQL-specific SQL syntax. - */ -public class MySQLPermbanRepository implements PermbanRepository -{ - private final TotalFreedomMod plugin; - private final StatementHandler statementHandler; - - public MySQLPermbanRepository(TotalFreedomMod plugin, StatementHandler statementHandler) - { - this.plugin = plugin; - this.statementHandler = statementHandler; - } - - // ============================================ - // CREATE Operations - // ============================================ - - @Override - public int insert(PermBan permban) throws SQLException - { - String sql = """ - INSERT INTO `permbans` (`uuid`, `username`, `reason`) - VALUES (?, ?, ?) - """; - - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, - permban.getUuid() != null ? permban.getUuid().toString() : null, - permban.getUsername(), - permban.getReason())) - { - stmt.executeUpdate(); - - try (ResultSet rs = stmt.getGeneratedKeys()) - { - if (rs.next()) - { - int permbanId = rs.getInt(1); - insertIps(permbanId, permban.getIps()); - return permbanId; - } - } - } - return -1; - } - - @Override - public void insertIps(int permbanId, List ips) throws SQLException - { - if (ips == null || ips.isEmpty()) return; - - String sql = "INSERT IGNORE INTO `permban_ips` (`permban_id`, `ip`) VALUES (?, ?)"; - for (String ip : ips) - { - statementHandler.executeUpdate(sql, permbanId, ip); - } - } - - @Override - public void addIp(int permbanId, String ip) throws SQLException - { - String sql = "INSERT IGNORE INTO `permban_ips` (`permban_id`, `ip`) VALUES (?, ?)"; - statementHandler.executeUpdate(sql, permbanId, ip); - } - - // ============================================ - // READ Operations - // ============================================ - - @Override - public List loadAll() throws SQLException - { - List permbans = new ArrayList<>(); - Map permbanById = new HashMap<>(); - - String sql = "SELECT `id`, `uuid`, `username`, `reason` FROM `permbans`"; - try (ResultSet rs = statementHandler.executeQuery(sql)) - { - while (rs.next()) - { - int id = rs.getInt("id"); - String uuidStr = rs.getString("uuid"); - String username = rs.getString("username"); - String reason = rs.getString("reason"); - - PermBan permban = new PermBan(); - permban.setUuid(uuidStr != null ? UUID.fromString(uuidStr) : null); - permban.setUsername(username); - permban.setReason(reason); - - permbans.add(permban); - permbanById.put(id, permban); - } - } - - // Load IPs - String ipSql = "SELECT `permban_id`, `ip` FROM `permban_ips`"; - try (ResultSet rs = statementHandler.executeQuery(ipSql)) - { - while (rs.next()) - { - int permbanId = rs.getInt("permban_id"); - String ip = rs.getString("ip"); - PermBan permban = permbanById.get(permbanId); - if (permban != null) - { - permban.addIp(ip); - } - } - } - - return permbans; - } - - @Override - public PermBan findByUuid(UUID uuid) throws SQLException - { - String sql = "SELECT `id`, `uuid`, `username`, `reason` FROM `permbans` WHERE `uuid` = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadPermbanFromResultSet(rs); - } - } - return null; - } - - @Override - public PermBan findByUsername(String username) throws SQLException - { - String sql = "SELECT `id`, `uuid`, `username`, `reason` FROM `permbans` WHERE LOWER(`username`) = LOWER(?)"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadPermbanFromResultSet(rs); - } - } - return null; - } - - @Override - public PermBan findByIp(String ip) throws SQLException - { - String sql = """ - SELECT p.`id`, p.`uuid`, p.`username`, p.`reason` - FROM `permbans` p - INNER JOIN `permban_ips` pi ON p.`id` = pi.`permban_id` - WHERE pi.`ip` = ? - """; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, ip); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadPermbanFromResultSet(rs); - } - } - return null; - } - - @Override - public int getPermbanId(UUID uuid) throws SQLException - { - String sql = "SELECT `id` FROM `permbans` WHERE `uuid` = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) return rs.getInt("id"); - } - return -1; - } - - @Override - public int getPermbanIdByUsername(String username) throws SQLException - { - String sql = "SELECT `id` FROM `permbans` WHERE LOWER(`username`) = LOWER(?)"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) return rs.getInt("id"); - } - return -1; - } - - @Override - public List getIps(int permbanId) throws SQLException - { - List ips = new ArrayList<>(); - String sql = "SELECT `ip` FROM `permban_ips` WHERE `permban_id` = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, permbanId); - ResultSet rs = stmt.executeQuery()) - { - while (rs.next()) - { - ips.add(rs.getString("ip")); - } - } - return ips; - } - - @Override - public boolean isPermBanned(UUID uuid) throws SQLException - { - String sql = "SELECT COUNT(*) FROM `permbans` WHERE `uuid` = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - @Override - public boolean isPermBannedByUsername(String username) throws SQLException - { - String sql = "SELECT COUNT(*) FROM `permbans` WHERE LOWER(`username`) = LOWER(?)"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - @Override - public boolean isPermBannedByIp(String ip) throws SQLException - { - String sql = """ - SELECT COUNT(*) FROM `permbans` p - INNER JOIN `permban_ips` pi ON p.`id` = pi.`permban_id` - WHERE pi.`ip` = ? - """; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, ip); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - // ============================================ - // UPDATE Operations - // ============================================ - - @Override - public boolean update(PermBan permban) throws SQLException - { - String sql = """ - UPDATE `permbans` - SET `username` = ?, `reason` = ? - WHERE `uuid` = ? - """; - - int rows = statementHandler.executeUpdate(sql, - permban.getUsername(), - permban.getReason(), - permban.getUuid().toString()); - - return rows > 0; - } - - @Override - public boolean updateReason(UUID uuid, String reason) throws SQLException - { - String sql = "UPDATE `permbans` SET `reason` = ? WHERE `uuid` = ?"; - return statementHandler.executeUpdate(sql, reason, uuid.toString()) > 0; - } - - @Override - public void syncIps(int permbanId, List ips) throws SQLException - { - statementHandler.executeUpdate("DELETE FROM `permban_ips` WHERE `permban_id` = ?", permbanId); - insertIps(permbanId, ips); - } - - // ============================================ - // DELETE Operations - // ============================================ - - @Override - public boolean delete(UUID uuid) throws SQLException - { - String sql = "DELETE FROM `permbans` WHERE `uuid` = ?"; - return statementHandler.executeUpdate(sql, uuid.toString()) > 0; - } - - @Override - public boolean deleteByUsername(String username) throws SQLException - { - String sql = "DELETE FROM `permbans` WHERE LOWER(`username`) = LOWER(?)"; - return statementHandler.executeUpdate(sql, username) > 0; - } - - @Override - public boolean deleteByIp(String ip) throws SQLException - { - String selectSql = "SELECT `permban_id` FROM `permban_ips` WHERE `ip` = ?"; - List permbanIds = new ArrayList<>(); - try (PreparedStatement stmt = statementHandler.prepareStatement(selectSql, ip); - ResultSet rs = stmt.executeQuery()) - { - while (rs.next()) - { - permbanIds.add(rs.getInt("permban_id")); - } - } - - for (int permbanId : permbanIds) - { - statementHandler.executeUpdate("DELETE FROM `permbans` WHERE `id` = ?", permbanId); - } - - return !permbanIds.isEmpty(); - } - - @Override - public boolean removeIp(int permbanId, String ip) throws SQLException - { - String sql = "DELETE FROM `permban_ips` WHERE `permban_id` = ? AND `ip` = ?"; - return statementHandler.executeUpdate(sql, permbanId, ip) > 0; - } - - @Override - public void deleteAllSync() throws SQLException - { - statementHandler.executeUpdate("DELETE FROM `permban_ips`"); - statementHandler.executeUpdate("DELETE FROM `permbans`"); - } - - // ============================================ - // Async Operations - // ============================================ - - @Override - public Mono> loadAllAsync() - { - return statementHandler.supplyMono(this::loadAll); - } - - @Override - public Mono insertAsync(PermBan permban) - { - return statementHandler.supplyMono(() -> insert(permban)); - } - - @Override - public Mono updateAsync(PermBan permban) - { - return statementHandler.supplyMono(() -> update(permban)); - } - - @Override - public Mono deleteAsync(UUID uuid) - { - return statementHandler.supplyMono(() -> delete(uuid)); - } - - @Override - public Mono save(PermBan permban) - { - return statementHandler.supplyMono(() -> { - if (permban.getUuid() != null && getPermbanId(permban.getUuid()) > 0) - { - update(permban); - return getPermbanId(permban.getUuid()); - } - return insert(permban); - }); - } - - @Override - public Mono> findAll() - { - return loadAllAsync(); - } - - @Override - public Mono deleteAll() - { - return statementHandler.runMono(this::deleteAllSync); - } - - // ============================================ - // Helper Methods - // ============================================ - - private PermBan loadPermbanFromResultSet(ResultSet rs) throws SQLException - { - int id = rs.getInt("id"); - String uuidStr = rs.getString("uuid"); - String username = rs.getString("username"); - String reason = rs.getString("reason"); - - PermBan permban = new PermBan(); - permban.setUuid(uuidStr != null ? UUID.fromString(uuidStr) : null); - permban.setUsername(username); - permban.setReason(reason); - - List ips = getIps(id); - permban.setIps(ips); - - return permban; - } -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLStrikeRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLStrikeRepository.java deleted file mode 100644 index 4741e9037..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLStrikeRepository.java +++ /dev/null @@ -1,93 +0,0 @@ -package me.totalfreedom.totalfreedommod.sql.adapter.mysql; - -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.HashMap; -import java.util.Map; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.banning.StrikeRecord; -import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.StrikeRepository; -import reactor.core.publisher.Mono; - -public class MySQLStrikeRepository implements StrikeRepository -{ - private final TotalFreedomMod plugin; - private final StatementHandler statementHandler; - - public MySQLStrikeRepository(TotalFreedomMod plugin, StatementHandler statementHandler) - { - this.plugin = plugin; - this.statementHandler = statementHandler; - } - - @Override - public Map loadAll() throws SQLException - { - Map out = new HashMap<>(); - String sql = "SELECT ip, strike_count, last_strike_unix, last_username FROM strikes"; - try (ResultSet rs = statementHandler.executeQuery(sql)) - { - while (rs.next()) - { - String ip = rs.getString("ip"); - int count = rs.getInt("strike_count"); - long last = rs.getLong("last_strike_unix"); - String username = rs.getString("last_username"); - out.put(ip, new StrikeRecord(ip, count, last, username)); - } - } - return out; - } - - @Override - public void upsert(StrikeRecord r) throws SQLException - { - String sql = """ - INSERT INTO strikes (ip, strike_count, last_strike_unix, last_username) - VALUES (?, ?, ?, ?) - ON DUPLICATE KEY UPDATE - strike_count = VALUES(strike_count), - last_strike_unix = VALUES(last_strike_unix), - last_username = VALUES(last_username) - """; - statementHandler.executeUpdate(sql, - r.getIp(), r.getCount(), r.getLastStrikeUnix(), r.getLastUsername()); - } - - @Override - public boolean deleteByIp(String ip) throws SQLException - { - return statementHandler.executeUpdate("DELETE FROM strikes WHERE ip = ?", ip) > 0; - } - - @Override - public void deleteAllSync() throws SQLException - { - statementHandler.executeUpdate("DELETE FROM strikes"); - } - - @Override - public Mono> loadAllAsync() - { - return statementHandler.supplyMono(this::loadAll); - } - - @Override - public Mono upsertAsync(StrikeRecord r) - { - return statementHandler.runMono(() -> upsert(r)); - } - - @Override - public Mono deleteByIpAsync(String ip) - { - return statementHandler.supplyMono(() -> deleteByIp(ip)); - } - - @Override - public Mono deleteAll() - { - return statementHandler.runMono(this::deleteAllSync); - } -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java index 54cd9e68f..925ef7e46 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java @@ -3,15 +3,13 @@ import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.sql.ConnectionHandler; import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.AdminRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.BanRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; -import me.totalfreedom.totalfreedommod.sql.adapter.DiscordLinkRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.PermbanRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.StrikeRepository; +import me.totalfreedom.totalfreedommod.sql.adapter.*; +import me.totalfreedom.totalfreedommod.sql.adapter.generic.*; import me.totalfreedom.totalfreedommod.util.FLog; import java.sql.SQLException; +import java.util.stream.Collectors; +import java.util.stream.Stream; /** * PostgreSQL-specific database adapter. @@ -24,11 +22,15 @@ */ public class PostgreSQLAdapter extends DatabaseAdapter { - private PostgreSQLAdminRepository adminRepository; - private PostgreSQLBanRepository banRepository; - private PostgreSQLPermbanRepository permbanRepository; - private PostgreSQLStrikeRepository strikeRepository; - private PostgreSQLDiscordLinkRepository discordLinkRepository; + private AdminRepository adminRepository; + private BanRepository banRepository; + private PermbanRepository permbanRepository; + private StrikeRepository strikeRepository; + private DiscordLinkRepository discordLinkRepository; + private RankRepository rankRepository; + private ProtectedAreaRepository protectedAreaRepository; + private SavedFlagRepository savedFlagRepository; + private PlayerRepository playerRepository; public PostgreSQLAdapter(TotalFreedomMod plugin, ConnectionHandler connectionHandler, StatementHandler statementHandler) { @@ -69,6 +71,18 @@ public String booleanType() return "BOOLEAN"; } + @Override + public String jsonType() + { + return "JSONB"; + } + + @Override + public String jsonParamPlaceholder() + { + return "?::jsonb"; + } + @Override public String insertIgnoreSyntax() { @@ -76,6 +90,12 @@ public String insertIgnoreSyntax() return "INSERT"; // Base INSERT, ON CONFLICT added per-statement } + @Override + public String insertIgnoreSuffix() + { + return " ON CONFLICT DO NOTHING"; + } + @Override public String quoteIdentifier(String identifier) { @@ -89,17 +109,31 @@ public String currentTimestamp() } @Override - public String caseInsensitiveLike() + public String timestampParamPlaceholder() { - return "ILIKE"; + // PostgreSQL won't implicitly convert a bound String to timestamp. + return "?::timestamp"; } - /** - * PostgreSQL-specific ON CONFLICT DO NOTHING clause - */ - public String onConflictDoNothing() + @Override + public String caseInsensitiveEquals(String columnRef, String paramPlaceholder) + { + return String.format("%s ILIKE %s", columnRef, paramPlaceholder); + } + + @Override + public String compareToNow(String columnRef, String operator) { - return "ON CONFLICT DO NOTHING"; + return String.format("%s %s CURRENT_TIMESTAMP", columnRef, operator); + } + + @Override + public String upsertClause(String conflictColumn, String... updateColumns) + { + String assignments = Stream.of(updateColumns) + .map(col -> String.format("%s = EXCLUDED.%s", col, col)) + .collect(Collectors.joining(", ")); + return String.format("ON CONFLICT(%s) DO UPDATE SET %s", conflictColumn, assignments); } // ============================================ @@ -120,6 +154,12 @@ public void runMigrations() throws SQLException createPermbanIpsTable(); createStrikesTable(); createDiscordLinksTable(); + createRanksTable(); + createRankPermissionsTable(); + createProtectedAreasTable(); + createSavedFlagsTable(); + createPlayersTable(); + createPlayerIpsTable(); FLog.info("[PostgreSQL] Database migrations complete."); } @@ -146,11 +186,15 @@ private void createAdminsTable() throws SQLException "rank" VARCHAR(32) NOT NULL, "active" BOOLEAN DEFAULT TRUE, "last_login" TIMESTAMP, - "login_message" TEXT + "login_message" TEXT, + "custom_rank" VARCHAR(64) ) """; statementHandler.executeUpdate(sql); + // Migration for tables created before custom_rank existed. + statementHandler.executeUpdate("ALTER TABLE \"admins\" ADD COLUMN IF NOT EXISTS \"custom_rank\" VARCHAR(64)"); + // Create indexes separately (PostgreSQL style) statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_admins_username ON \"admins\"(\"username\")"); statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_admins_active ON \"admins\"(\"active\")"); @@ -261,6 +305,102 @@ private void createDiscordLinksTable() throws SQLException statementHandler.executeUpdate(sql); } + private void createRanksTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS "ranks" ( + "id" VARCHAR(64) PRIMARY KEY, + "name" VARCHAR(64) NOT NULL, + "determiner" VARCHAR(8) NOT NULL DEFAULT 'a', + "abbreviation" VARCHAR(16), + "level" INTEGER NOT NULL DEFAULT 0, + "color" VARCHAR(32) NOT NULL DEFAULT 'white', + "admin" BOOLEAN NOT NULL DEFAULT FALSE, + "console_only" BOOLEAN NOT NULL DEFAULT FALSE, + "prefix" VARCHAR(64), + "inherit_from" VARCHAR(64) + ) + """; + statementHandler.executeUpdate(sql); + statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_ranks_level ON \"ranks\"(\"level\")"); + } + + private void createRankPermissionsTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS "rank_permissions" ( + "id" SERIAL PRIMARY KEY, + "rank_id" VARCHAR(64) NOT NULL REFERENCES "ranks"("id") ON DELETE CASCADE, + "permission" VARCHAR(128) NOT NULL, + UNIQUE ("rank_id", "permission") + ) + """; + statementHandler.executeUpdate(sql); + } + + private void createProtectedAreasTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS "protected_areas" ( + "uuid" VARCHAR(36) PRIMARY KEY, + "name" VARCHAR(64) NOT NULL, + "min_x" INTEGER NOT NULL, + "min_y" INTEGER NOT NULL, + "min_z" INTEGER NOT NULL, + "max_x" INTEGER NOT NULL, + "max_y" INTEGER NOT NULL, + "max_z" INTEGER NOT NULL, + "world_uuid" VARCHAR(36) NOT NULL + ) + """; + statementHandler.executeUpdate(sql); + statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_protected_areas_name ON \"protected_areas\"(\"name\")"); + } + + private void createSavedFlagsTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS "saved_flags" ( + "flag_name" VARCHAR(64) PRIMARY KEY, + "enabled" BOOLEAN NOT NULL DEFAULT FALSE + ) + """; + statementHandler.executeUpdate(sql); + } + + private void createPlayersTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS "players" ( + "username" VARCHAR(16) PRIMARY KEY, + "first_join_unix" BIGINT NOT NULL DEFAULT 0, + "last_join_unix" BIGINT NOT NULL DEFAULT 0, + "potion_spy" BOOLEAN NOT NULL DEFAULT FALSE, + "command_spy_mode" VARCHAR(16) NOT NULL DEFAULT 'off', + "muted" BOOLEAN NOT NULL DEFAULT FALSE, + "frozen" BOOLEAN NOT NULL DEFAULT FALSE, + "commands_blocked" BOOLEAN NOT NULL DEFAULT FALSE, + "strikes" INTEGER NOT NULL DEFAULT 0, + "saved_tag" TEXT, + "nickname" TEXT + ) + """; + statementHandler.executeUpdate(sql); + } + + private void createPlayerIpsTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS "player_ips" ( + "id" SERIAL PRIMARY KEY, + "username" VARCHAR(16) NOT NULL REFERENCES "players"("username") ON DELETE CASCADE, + "ip" VARCHAR(45) NOT NULL, + UNIQUE ("username", "ip") + ) + """; + statementHandler.executeUpdate(sql); + } + // ============================================ // Repository Getters // ============================================ @@ -270,7 +410,7 @@ public AdminRepository getAdminRepository() { if (adminRepository == null) { - adminRepository = new PostgreSQLAdminRepository(plugin, statementHandler); + adminRepository = new GenericAdminRepository(statementHandler, this); } return adminRepository; } @@ -280,7 +420,7 @@ public BanRepository getBanRepository() { if (banRepository == null) { - banRepository = new PostgreSQLBanRepository(plugin, statementHandler); + banRepository = new GenericBanRepository(statementHandler, this); } return banRepository; } @@ -290,7 +430,7 @@ public PermbanRepository getPermbanRepository() { if (permbanRepository == null) { - permbanRepository = new PostgreSQLPermbanRepository(plugin, statementHandler); + permbanRepository = new GenericPermbanRepository(statementHandler, this); } return permbanRepository; } @@ -300,7 +440,7 @@ public StrikeRepository getStrikeRepository() { if (strikeRepository == null) { - strikeRepository = new PostgreSQLStrikeRepository(plugin, statementHandler); + strikeRepository = new GenericStrikeRepository(statementHandler, this); } return strikeRepository; } @@ -310,8 +450,48 @@ public DiscordLinkRepository getDiscordLinkRepository() { if (discordLinkRepository == null) { - discordLinkRepository = new PostgreSQLDiscordLinkRepository(plugin, statementHandler); + discordLinkRepository = new GenericDiscordLinkRepository(statementHandler, this); } return discordLinkRepository; } + + @Override + public RankRepository getRankRepository() + { + if (rankRepository == null) + { + rankRepository = new GenericRankRepository(statementHandler, this); + } + return rankRepository; + } + + @Override + public ProtectedAreaRepository getProtectedAreaRepository() + { + if (protectedAreaRepository == null) + { + protectedAreaRepository = new GenericProtectedAreaRepository(statementHandler, this); + } + return protectedAreaRepository; + } + + @Override + public SavedFlagRepository getSavedFlagRepository() + { + if (savedFlagRepository == null) + { + savedFlagRepository = new GenericSavedFlagRepository(statementHandler, this); + } + return savedFlagRepository; + } + + @Override + public PlayerRepository getPlayerRepository() + { + if (playerRepository == null) + { + playerRepository = new GenericPlayerRepository(statementHandler, this); + } + return playerRepository; + } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLDiscordLinkRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLDiscordLinkRepository.java deleted file mode 100644 index 8658e0223..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLDiscordLinkRepository.java +++ /dev/null @@ -1,67 +0,0 @@ -package me.totalfreedom.totalfreedommod.sql.adapter.postgresql; - -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.UUID; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.DiscordLinkRepository; - -public class PostgreSQLDiscordLinkRepository implements DiscordLinkRepository -{ - private final TotalFreedomMod plugin; - private final StatementHandler statementHandler; - - public PostgreSQLDiscordLinkRepository(TotalFreedomMod plugin, StatementHandler statementHandler) - { - this.plugin = plugin; - this.statementHandler = statementHandler; - } - - @Override - public void insert(UUID adminUuid, String discordUserId) throws SQLException - { - statementHandler.executeUpdate( - "INSERT INTO \"discord_links\" (\"admin_uuid\", \"discord_user_id\", \"linked_at\") VALUES (?, ?, CURRENT_TIMESTAMP)", - adminUuid.toString(), discordUserId); - } - - @Override - public String findDiscordIdByAdminUuid(UUID adminUuid) throws SQLException - { - try (ResultSet rs = statementHandler.executeQuery( - "SELECT \"discord_user_id\" FROM \"discord_links\" WHERE \"admin_uuid\" = ?", adminUuid.toString())) - { - return rs.next() ? rs.getString(1) : null; - } - } - - @Override - public UUID findAdminUuidByDiscordId(String discordUserId) throws SQLException - { - try (ResultSet rs = statementHandler.executeQuery( - "SELECT \"admin_uuid\" FROM \"discord_links\" WHERE \"discord_user_id\" = ?", discordUserId)) - { - if (!rs.next()) - { - return null; - } - String raw = rs.getString(1); - return raw == null ? null : UUID.fromString(raw); - } - } - - @Override - public boolean deleteByAdminUuid(UUID adminUuid) throws SQLException - { - return statementHandler.executeUpdate( - "DELETE FROM \"discord_links\" WHERE \"admin_uuid\" = ?", adminUuid.toString()) > 0; - } - - @Override - public boolean deleteByDiscordUserId(String discordUserId) throws SQLException - { - return statementHandler.executeUpdate( - "DELETE FROM \"discord_links\" WHERE \"discord_user_id\" = ?", discordUserId) > 0; - } -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLStrikeRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLStrikeRepository.java deleted file mode 100644 index 8cb4324e1..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLStrikeRepository.java +++ /dev/null @@ -1,93 +0,0 @@ -package me.totalfreedom.totalfreedommod.sql.adapter.postgresql; - -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.HashMap; -import java.util.Map; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.banning.StrikeRecord; -import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.StrikeRepository; -import reactor.core.publisher.Mono; - -public class PostgreSQLStrikeRepository implements StrikeRepository -{ - private final TotalFreedomMod plugin; - private final StatementHandler statementHandler; - - public PostgreSQLStrikeRepository(TotalFreedomMod plugin, StatementHandler statementHandler) - { - this.plugin = plugin; - this.statementHandler = statementHandler; - } - - @Override - public Map loadAll() throws SQLException - { - Map out = new HashMap<>(); - String sql = "SELECT ip, strike_count, last_strike_unix, last_username FROM strikes"; - try (ResultSet rs = statementHandler.executeQuery(sql)) - { - while (rs.next()) - { - String ip = rs.getString("ip"); - int count = rs.getInt("strike_count"); - long last = rs.getLong("last_strike_unix"); - String username = rs.getString("last_username"); - out.put(ip, new StrikeRecord(ip, count, last, username)); - } - } - return out; - } - - @Override - public void upsert(StrikeRecord r) throws SQLException - { - String sql = """ - INSERT INTO strikes (ip, strike_count, last_strike_unix, last_username) - VALUES (?, ?, ?, ?) - ON CONFLICT (ip) DO UPDATE SET - strike_count = EXCLUDED.strike_count, - last_strike_unix = EXCLUDED.last_strike_unix, - last_username = EXCLUDED.last_username - """; - statementHandler.executeUpdate(sql, - r.getIp(), r.getCount(), r.getLastStrikeUnix(), r.getLastUsername()); - } - - @Override - public boolean deleteByIp(String ip) throws SQLException - { - return statementHandler.executeUpdate("DELETE FROM strikes WHERE ip = ?", ip) > 0; - } - - @Override - public void deleteAllSync() throws SQLException - { - statementHandler.executeUpdate("DELETE FROM strikes"); - } - - @Override - public Mono> loadAllAsync() - { - return statementHandler.supplyMono(this::loadAll); - } - - @Override - public Mono upsertAsync(StrikeRecord r) - { - return statementHandler.runMono(() -> upsert(r)); - } - - @Override - public Mono deleteByIpAsync(String ip) - { - return statementHandler.supplyMono(() -> deleteByIp(ip)); - } - - @Override - public Mono deleteAll() - { - return statementHandler.runMono(this::deleteAllSync); - } -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java index 68bf84433..7300d67d9 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java @@ -3,15 +3,13 @@ import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.sql.ConnectionHandler; import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.AdminRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.BanRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; -import me.totalfreedom.totalfreedommod.sql.adapter.DiscordLinkRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.PermbanRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.StrikeRepository; +import me.totalfreedom.totalfreedommod.sql.adapter.*; +import me.totalfreedom.totalfreedommod.sql.adapter.generic.*; import me.totalfreedom.totalfreedommod.util.FLog; import java.sql.SQLException; +import java.util.stream.Collectors; +import java.util.stream.Stream; /** * SQLite-specific database adapter. @@ -23,11 +21,15 @@ */ public class SQLiteAdapter extends DatabaseAdapter { - private SQLiteAdminRepository adminRepository; - private SQLiteBanRepository banRepository; - private SQLitePermbanRepository permbanRepository; - private SQLiteStrikeRepository strikeRepository; - private SQLiteDiscordLinkRepository discordLinkRepository; + private AdminRepository adminRepository; + private BanRepository banRepository; + private PermbanRepository permbanRepository; + private StrikeRepository strikeRepository; + private DiscordLinkRepository discordLinkRepository; + private RankRepository rankRepository; + private ProtectedAreaRepository protectedAreaRepository; + private SavedFlagRepository savedFlagRepository; + private PlayerRepository playerRepository; public SQLiteAdapter(TotalFreedomMod plugin, ConnectionHandler connectionHandler, StatementHandler statementHandler) { @@ -68,12 +70,30 @@ public String booleanType() return "INTEGER"; // SQLite uses 0/1 for boolean } + @Override + public String jsonType() + { + return "TEXT"; // No native JSON type; JSON1 extension functions operate on TEXT + } + + @Override + public String jsonParamPlaceholder() + { + return "?"; + } + @Override public String insertIgnoreSyntax() { return "INSERT OR IGNORE"; } + @Override + public String insertIgnoreSuffix() + { + return ""; + } + @Override public String quoteIdentifier(String identifier) { @@ -87,9 +107,31 @@ public String currentTimestamp() } @Override - public String caseInsensitiveLike() + public String timestampParamPlaceholder() + { + return "?"; + } + + @Override + public String caseInsensitiveEquals(String columnRef, String paramPlaceholder) + { + return String.format("LOWER(%s) = LOWER(%s)", columnRef, paramPlaceholder); + } + + @Override + public String compareToNow(String columnRef, String operator) + { + // expire_at is stored as formatted TEXT; datetime() normalizes both sides for comparison. + return String.format("datetime(%s) %s datetime('now')", columnRef, operator); + } + + @Override + public String upsertClause(String conflictColumn, String... updateColumns) { - return "LIKE"; // SQLite LIKE is case-insensitive by default + String assignments = Stream.of(updateColumns) + .map(col -> String.format("%s = EXCLUDED.%s", col, col)) + .collect(Collectors.joining(", ")); + return String.format("ON CONFLICT(%s) DO UPDATE SET %s", conflictColumn, assignments); } // ============================================ @@ -113,6 +155,12 @@ public void runMigrations() throws SQLException createPermbanIpsTable(); createStrikesTable(); createDiscordLinksTable(); + createRanksTable(); + createRankPermissionsTable(); + createProtectedAreasTable(); + createSavedFlagsTable(); + createPlayersTable(); + createPlayerIpsTable(); FLog.info("[SQLite] Database migrations complete."); } @@ -268,6 +316,104 @@ CREATE TABLE IF NOT EXISTS discord_links ( statementHandler.executeUpdate(sql); } + private void createRanksTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS ranks ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + determiner TEXT NOT NULL DEFAULT 'a', + abbreviation TEXT, + level INTEGER NOT NULL DEFAULT 0, + color TEXT NOT NULL DEFAULT 'white', + admin INTEGER NOT NULL DEFAULT 0, + console_only INTEGER NOT NULL DEFAULT 0, + prefix TEXT, + inherit_from TEXT + ) + """; + statementHandler.executeUpdate(sql); + statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_ranks_level ON ranks(level)"); + } + + private void createRankPermissionsTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS rank_permissions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rank_id TEXT NOT NULL, + permission TEXT NOT NULL, + UNIQUE (rank_id, permission), + FOREIGN KEY (rank_id) REFERENCES ranks(id) ON DELETE CASCADE + ) + """; + statementHandler.executeUpdate(sql); + } + + private void createProtectedAreasTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS protected_areas ( + uuid TEXT PRIMARY KEY, + name TEXT NOT NULL, + min_x INTEGER NOT NULL, + min_y INTEGER NOT NULL, + min_z INTEGER NOT NULL, + max_x INTEGER NOT NULL, + max_y INTEGER NOT NULL, + max_z INTEGER NOT NULL, + world_uuid TEXT NOT NULL + ) + """; + statementHandler.executeUpdate(sql); + statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_protected_areas_name ON protected_areas(name)"); + } + + private void createSavedFlagsTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS saved_flags ( + flag_name TEXT PRIMARY KEY, + enabled INTEGER NOT NULL DEFAULT 0 + ) + """; + statementHandler.executeUpdate(sql); + } + + private void createPlayersTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS players ( + username TEXT PRIMARY KEY, + first_join_unix INTEGER NOT NULL DEFAULT 0, + last_join_unix INTEGER NOT NULL DEFAULT 0, + potion_spy INTEGER NOT NULL DEFAULT 0, + command_spy_mode TEXT NOT NULL DEFAULT 'off', + muted INTEGER NOT NULL DEFAULT 0, + frozen INTEGER NOT NULL DEFAULT 0, + commands_blocked INTEGER NOT NULL DEFAULT 0, + strikes INTEGER NOT NULL DEFAULT 0, + saved_tag TEXT, + nickname TEXT + ) + """; + statementHandler.executeUpdate(sql); + } + + private void createPlayerIpsTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS player_ips ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + ip TEXT NOT NULL, + UNIQUE (username, ip), + FOREIGN KEY (username) REFERENCES players(username) ON DELETE CASCADE + ) + """; + statementHandler.executeUpdate(sql); + } + // ============================================ // Repository Getters // ============================================ @@ -277,7 +423,7 @@ public AdminRepository getAdminRepository() { if (adminRepository == null) { - adminRepository = new SQLiteAdminRepository(plugin, statementHandler); + adminRepository = new GenericAdminRepository(statementHandler, this); } return adminRepository; } @@ -287,7 +433,7 @@ public BanRepository getBanRepository() { if (banRepository == null) { - banRepository = new SQLiteBanRepository(plugin, statementHandler); + banRepository = new GenericBanRepository(statementHandler, this); } return banRepository; } @@ -297,7 +443,7 @@ public PermbanRepository getPermbanRepository() { if (permbanRepository == null) { - permbanRepository = new SQLitePermbanRepository(plugin, statementHandler); + permbanRepository = new GenericPermbanRepository(statementHandler, this); } return permbanRepository; } @@ -307,7 +453,7 @@ public StrikeRepository getStrikeRepository() { if (strikeRepository == null) { - strikeRepository = new SQLiteStrikeRepository(plugin, statementHandler); + strikeRepository = new GenericStrikeRepository(statementHandler, this); } return strikeRepository; } @@ -317,8 +463,48 @@ public DiscordLinkRepository getDiscordLinkRepository() { if (discordLinkRepository == null) { - discordLinkRepository = new SQLiteDiscordLinkRepository(plugin, statementHandler); + discordLinkRepository = new GenericDiscordLinkRepository(statementHandler, this); } return discordLinkRepository; } + + @Override + public RankRepository getRankRepository() + { + if (rankRepository == null) + { + rankRepository = new GenericRankRepository(statementHandler, this); + } + return rankRepository; + } + + @Override + public ProtectedAreaRepository getProtectedAreaRepository() + { + if (protectedAreaRepository == null) + { + protectedAreaRepository = new GenericProtectedAreaRepository(statementHandler, this); + } + return protectedAreaRepository; + } + + @Override + public SavedFlagRepository getSavedFlagRepository() + { + if (savedFlagRepository == null) + { + savedFlagRepository = new GenericSavedFlagRepository(statementHandler, this); + } + return savedFlagRepository; + } + + @Override + public PlayerRepository getPlayerRepository() + { + if (playerRepository == null) + { + playerRepository = new GenericPlayerRepository(statementHandler, this); + } + return playerRepository; + } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdminRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdminRepository.java deleted file mode 100644 index 55c6aceb8..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdminRepository.java +++ /dev/null @@ -1,478 +0,0 @@ -package me.totalfreedom.totalfreedommod.sql.adapter.sqlite; - -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.admin.Admin; -import me.totalfreedom.totalfreedommod.rank.Rank; -import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.AdminRepository; -import me.totalfreedom.totalfreedommod.util.FUtil; - -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.*; - -import reactor.core.publisher.Mono; - -/** - * SQLite implementation of AdminRepository. - * Uses SQLite-specific SQL syntax. - */ -public class SQLiteAdminRepository implements AdminRepository -{ - private final TotalFreedomMod plugin; - private final StatementHandler statementHandler; - - public SQLiteAdminRepository(TotalFreedomMod plugin, StatementHandler statementHandler) - { - this.plugin = plugin; - this.statementHandler = statementHandler; - } - - // ============================================ - // CREATE Operations - // ============================================ - - @Override - public int insert(UUID uuid, Admin admin) throws SQLException - { - String sql = """ - INSERT INTO admins (uuid, username, rank, active, last_login, login_message, custom_rank) - VALUES (?, ?, ?, ?, ?, ?, ?) - """; - - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, - uuid.toString(), - admin.getName(), - admin.getRank().toString(), - admin.isActive() ? 1 : 0, - FUtil.dateToString(admin.getLastLogin()), - admin.getLoginMessage(), - admin.getCustomRankId())) - { - stmt.executeUpdate(); - - try (ResultSet rs = stmt.getGeneratedKeys()) - { - if (rs.next()) - { - int adminId = rs.getInt(1); - insertIps(adminId, admin.getIps()); - return adminId; - } - } - } - return -1; - } - - @Override - public void insertIps(int adminId, List ips) throws SQLException - { - if (ips == null || ips.isEmpty()) return; - - // SQLite: INSERT OR IGNORE - String sql = "INSERT OR IGNORE INTO admin_ips (admin_id, ip) VALUES (?, ?)"; - for (String ip : ips) - { - statementHandler.executeUpdate(sql, adminId, ip); - } - } - - @Override - public void addIp(int adminId, String ip) throws SQLException - { - String sql = "INSERT OR IGNORE INTO admin_ips (admin_id, ip) VALUES (?, ?)"; - statementHandler.executeUpdate(sql, adminId, ip); - } - - // ============================================ - // READ Operations - // ============================================ - - @Override - public Map loadAll() throws SQLException - { - Map admins = new HashMap<>(); - Map adminById = new HashMap<>(); - - String sql = "SELECT id, uuid, username, rank, active, last_login, login_message, custom_rank FROM admins"; - try (ResultSet rs = statementHandler.executeQuery(sql)) - { - while (rs.next()) - { - int id = rs.getInt("id"); - String username = rs.getString("username"); - String rankStr = rs.getString("rank"); - boolean active = rs.getInt("active") == 1; - String lastLoginStr = rs.getString("last_login"); - String loginMessage = rs.getString("login_message"); - String customRankId = rs.getString("custom_rank"); - - String configKey = username.toLowerCase(); - Admin admin = new Admin(configKey); - admin.setName(username); - admin.setRank(Rank.findRank(rankStr)); - admin.setActive(active); - admin.setLastLogin(FUtil.stringToDate(lastLoginStr)); - admin.setLoginMessage(loginMessage); - admin.setCustomRankId(customRankId); - - UUID dbUuid = FUtil.parseUuid(rs.getString("uuid")); - if (dbUuid != null) - { - admin.setUuid(dbUuid); - } - - admins.put(configKey, admin); - adminById.put(id, admin); - } - } - - // Load IPs - String ipSql = "SELECT admin_id, ip FROM admin_ips"; - try (ResultSet rs = statementHandler.executeQuery(ipSql)) - { - while (rs.next()) - { - int adminId = rs.getInt("admin_id"); - String ip = rs.getString("ip"); - Admin admin = adminById.get(adminId); - if (admin != null) - { - admin.addIp(ip); - } - } - } - - return admins; - } - - @Override - public Admin findByUuid(UUID uuid) throws SQLException - { - String sql = "SELECT id, uuid, username, rank, active, last_login, login_message, custom_rank FROM admins WHERE uuid = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadAdminFromResultSet(rs); - } - } - return null; - } - - @Override - public Admin findByUsername(String username) throws SQLException - { - String sql = "SELECT id, uuid, username, rank, active, last_login, login_message, custom_rank FROM admins WHERE LOWER(username) = LOWER(?)"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadAdminFromResultSet(rs); - } - } - return null; - } - - @Override - public Admin findByIp(String ip) throws SQLException - { - String sql = """ - SELECT a.id, a.uuid, a.username, a.rank, a.active, a.last_login, a.login_message, a.custom_rank - FROM admins a - INNER JOIN admin_ips ai ON a.id = ai.admin_id - WHERE ai.ip = ? - """; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, ip); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadAdminFromResultSet(rs); - } - } - return null; - } - - @Override - public int getAdminId(String username) throws SQLException - { - String sql = "SELECT id FROM admins WHERE LOWER(username) = LOWER(?)"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) return rs.getInt("id"); - } - return -1; - } - - @Override - public int getAdminIdByUuid(UUID uuid) throws SQLException - { - String sql = "SELECT id FROM admins WHERE uuid = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) return rs.getInt("id"); - } - return -1; - } - - @Override - public List getIps(int adminId) throws SQLException - { - List ips = new ArrayList<>(); - String sql = "SELECT ip FROM admin_ips WHERE admin_id = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, adminId); - ResultSet rs = stmt.executeQuery()) - { - while (rs.next()) - { - ips.add(rs.getString("ip")); - } - } - return ips; - } - - @Override - public boolean exists(String username) throws SQLException - { - String sql = "SELECT COUNT(*) FROM admins WHERE LOWER(username) = LOWER(?)"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - @Override - public boolean existsByUuid(UUID uuid) throws SQLException - { - String sql = "SELECT COUNT(*) FROM admins WHERE uuid = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - @Override - public UUID getUuidByUsername(String username) throws SQLException - { - String sql = "SELECT uuid FROM admins WHERE LOWER(username) = LOWER(?)"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return UUID.fromString(rs.getString("uuid")); - } - } - return null; - } - - // ============================================ - // UPDATE Operations - // ============================================ - - @Override - public boolean update(UUID uuid, Admin admin) throws SQLException - { - String sql = """ - UPDATE admins - SET username = ?, rank = ?, active = ?, last_login = ?, login_message = ?, custom_rank = ? - WHERE uuid = ? - """; - - int rows = statementHandler.executeUpdate(sql, - admin.getName(), - admin.getRank().toString(), - admin.isActive() ? 1 : 0, - FUtil.dateToString(admin.getLastLogin()), - admin.getLoginMessage(), - admin.getCustomRankId(), - uuid.toString()); - - return rows > 0; - } - - @Override - public boolean updateRank(String username, String rank) throws SQLException - { - String sql = "UPDATE admins SET rank = ? WHERE LOWER(username) = LOWER(?)"; - return statementHandler.executeUpdate(sql, rank, username) > 0; - } - - @Override - public boolean updateActive(String username, boolean active) throws SQLException - { - String sql = "UPDATE admins SET active = ? WHERE LOWER(username) = LOWER(?)"; - return statementHandler.executeUpdate(sql, active ? 1 : 0, username) > 0; - } - - @Override - public boolean updateLastLogin(String username, Date lastLogin) throws SQLException - { - String sql = "UPDATE admins SET last_login = ? WHERE LOWER(username) = LOWER(?)"; - return statementHandler.executeUpdate(sql, FUtil.dateToString(lastLogin), username) > 0; - } - - @Override - public boolean updateUsername(UUID uuid, String newUsername) throws SQLException - { - String sql = "UPDATE admins SET username = ? WHERE uuid = ?"; - return statementHandler.executeUpdate(sql, newUsername, uuid.toString()) > 0; - } - - @Override - public void syncIps(int adminId, List ips) throws SQLException - { - statementHandler.executeUpdate("DELETE FROM admin_ips WHERE admin_id = ?", adminId); - insertIps(adminId, ips); - } - - @Override - public int saveOrUpdate(UUID uuid, Admin admin) throws SQLException - { - if (existsByUuid(uuid)) - { - update(uuid, admin); - int adminId = getAdminIdByUuid(uuid); - syncIps(adminId, admin.getIps()); - return adminId; - } - else - { - return insert(uuid, admin); - } - } - - // ============================================ - // DELETE Operations - // ============================================ - - @Override - public boolean delete(UUID uuid) throws SQLException - { - String sql = "DELETE FROM admins WHERE uuid = ?"; - return statementHandler.executeUpdate(sql, uuid.toString()) > 0; - } - - @Override - public boolean deleteByUsername(String username) throws SQLException - { - String sql = "DELETE FROM admins WHERE LOWER(username) = LOWER(?)"; - return statementHandler.executeUpdate(sql, username) > 0; - } - - @Override - public boolean removeIp(int adminId, String ip) throws SQLException - { - String sql = "DELETE FROM admin_ips WHERE admin_id = ? AND ip = ?"; - return statementHandler.executeUpdate(sql, adminId, ip) > 0; - } - - @Override - public boolean clearIps(int adminId) throws SQLException - { - String sql = "DELETE FROM admin_ips WHERE admin_id = ?"; - return statementHandler.executeUpdate(sql, adminId) > 0; - } - - @Override - public void deleteAllSync() throws SQLException - { - statementHandler.executeUpdate("DELETE FROM admin_ips"); - statementHandler.executeUpdate("DELETE FROM admins"); - } - - // ============================================ - // Async Operations - // ============================================ - - @Override - public Mono> loadAllAsync() - { - return statementHandler.supplyMono(this::loadAll); - } - - @Override - public Mono insertAsync(UUID uuid, Admin admin) - { - return statementHandler.supplyMono(() -> insert(uuid, admin)); - } - - @Override - public Mono updateAsync(UUID uuid, Admin admin) - { - return statementHandler.supplyMono(() -> update(uuid, admin)); - } - - @Override - public Mono deleteAsync(UUID uuid) - { - return statementHandler.supplyMono(() -> delete(uuid)); - } - - @Override - public Mono save(UUID uuid, Admin admin) - { - return statementHandler.supplyMono(() -> saveOrUpdate(uuid, admin)); - } - - @Override - public Mono> findAll() - { - return statementHandler.supplyMono(() -> new ArrayList<>(loadAll().values())); - } - - @Override - public Mono deleteByUuid(UUID uuid) - { - return deleteAsync(uuid); - } - - @Override - public Mono deleteAll() - { - return statementHandler.runMono(this::deleteAllSync); - } - - // ============================================ - // Helper Methods - // ============================================ - - private Admin loadAdminFromResultSet(ResultSet rs) throws SQLException - { - int id = rs.getInt("id"); - String username = rs.getString("username"); - String rankStr = rs.getString("rank"); - boolean active = rs.getInt("active") == 1; - String lastLoginStr = rs.getString("last_login"); - String loginMessage = rs.getString("login_message"); - String customRankId = rs.getString("custom_rank"); - - String configKey = username.toLowerCase(); - Admin admin = new Admin(configKey); - admin.setName(username); - admin.setRank(Rank.findRank(rankStr)); - admin.setActive(active); - admin.setLastLogin(FUtil.stringToDate(lastLoginStr)); - admin.setLoginMessage(loginMessage); - admin.setCustomRankId(customRankId); - - UUID dbUuid = FUtil.parseUuid(rs.getString("uuid")); - if (dbUuid != null) - { - admin.setUuid(dbUuid); - } - - List ips = getIps(id); - admin.addIps(ips); - - return admin; - } -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteBanRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteBanRepository.java deleted file mode 100644 index dde32e9ce..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteBanRepository.java +++ /dev/null @@ -1,506 +0,0 @@ -package me.totalfreedom.totalfreedommod.sql.adapter.sqlite; - -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.banning.Ban; -import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.BanRepository; -import me.totalfreedom.totalfreedommod.util.FUtil; - -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.*; - -import reactor.core.publisher.Mono; - -/** - * SQLite implementation of BanRepository. - * Uses SQLite-specific SQL syntax. - */ -public class SQLiteBanRepository implements BanRepository -{ - private final TotalFreedomMod plugin; - private final StatementHandler statementHandler; - - public SQLiteBanRepository(TotalFreedomMod plugin, StatementHandler statementHandler) - { - this.plugin = plugin; - this.statementHandler = statementHandler; - } - - // ============================================ - // CREATE Operations - // ============================================ - - @Override - public int insert(Ban ban) throws SQLException - { - String sql = """ - INSERT INTO bans (uuid, username, banned_by, banned_by_uuid, reason, expire_at) - VALUES (?, ?, ?, ?, ?, ?) - """; - - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, - ban.getUuid() != null ? ban.getUuid().toString() : null, - ban.getUsername(), - ban.getBannedBy(), - ban.getBannedByUuid() != null ? ban.getBannedByUuid().toString() : null, - ban.getReason(), - ban.getExpireAt() != null ? FUtil.dateToString(ban.getExpireAt()) : null)) - { - stmt.executeUpdate(); - - try (ResultSet rs = stmt.getGeneratedKeys()) - { - if (rs.next()) - { - int banId = rs.getInt(1); - insertIps(banId, ban.getIps()); - return banId; - } - } - } - return -1; - } - - @Override - public void insertIps(int banId, List ips) throws SQLException - { - if (ips == null || ips.isEmpty()) return; - - String sql = "INSERT OR IGNORE INTO ban_ips (ban_id, ip) VALUES (?, ?)"; - for (String ip : ips) - { - statementHandler.executeUpdate(sql, banId, ip); - } - } - - @Override - public void addIp(int banId, String ip) throws SQLException - { - String sql = "INSERT OR IGNORE INTO ban_ips (ban_id, ip) VALUES (?, ?)"; - statementHandler.executeUpdate(sql, banId, ip); - } - - // ============================================ - // READ Operations - // ============================================ - - @Override - public List loadAll() throws SQLException - { - List bans = new ArrayList<>(); - Map banById = new HashMap<>(); - - String sql = "SELECT id, uuid, username, banned_by, banned_by_uuid, reason, expire_at FROM bans"; - try (ResultSet rs = statementHandler.executeQuery(sql)) - { - while (rs.next()) - { - int id = rs.getInt("id"); - String uuidStr = rs.getString("uuid"); - String username = rs.getString("username"); - String bannedBy = rs.getString("banned_by"); - String bannedByUuidStr = rs.getString("banned_by_uuid"); - String reason = rs.getString("reason"); - String expireAtStr = rs.getString("expire_at"); - - Ban ban = new Ban(); - ban.setUuid(uuidStr != null ? UUID.fromString(uuidStr) : null); - ban.setUsername(username); - ban.setBannedBy(bannedBy); - ban.setBannedByUuid(bannedByUuidStr != null ? UUID.fromString(bannedByUuidStr) : null); - ban.setReason(reason); - ban.setExpireAt(expireAtStr != null ? FUtil.stringToDate(expireAtStr) : null); - - bans.add(ban); - banById.put(id, ban); - } - } - - // Load IPs - String ipSql = "SELECT ban_id, ip FROM ban_ips"; - try (ResultSet rs = statementHandler.executeQuery(ipSql)) - { - while (rs.next()) - { - int banId = rs.getInt("ban_id"); - String ip = rs.getString("ip"); - Ban ban = banById.get(banId); - if (ban != null) - { - ban.addIp(ip); - } - } - } - - return bans; - } - - @Override - public Ban findByUuid(UUID uuid) throws SQLException - { - String sql = "SELECT id, uuid, username, banned_by, banned_by_uuid, reason, expire_at FROM bans WHERE uuid = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadBanFromResultSet(rs); - } - } - return null; - } - - @Override - public Ban findByUsername(String username) throws SQLException - { - String sql = "SELECT id, uuid, username, banned_by, banned_by_uuid, reason, expire_at FROM bans WHERE LOWER(username) = LOWER(?)"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadBanFromResultSet(rs); - } - } - return null; - } - - @Override - public Ban findByIp(String ip) throws SQLException - { - String sql = """ - SELECT b.id, b.uuid, b.username, b.banned_by, b.banned_by_uuid, b.reason, b.expire_at - FROM bans b - INNER JOIN ban_ips bi ON b.id = bi.ban_id - WHERE bi.ip = ? - """; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, ip); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadBanFromResultSet(rs); - } - } - return null; - } - - @Override - public List findActiveBans() throws SQLException - { - // Active bans: expire_at is NULL (permanent) or expire_at > current time - String sql = """ - SELECT id, uuid, username, banned_by, banned_by_uuid, reason, expire_at - FROM bans - WHERE expire_at IS NULL OR datetime(expire_at) > datetime('now') - """; - List bans = new ArrayList<>(); - try (ResultSet rs = statementHandler.executeQuery(sql)) - { - while (rs.next()) - { - bans.add(loadBanFromResultSet(rs)); - } - } - return bans; - } - - @Override - public List findExpiredBans() throws SQLException - { - String sql = """ - SELECT id, uuid, username, banned_by, banned_by_uuid, reason, expire_at - FROM bans - WHERE expire_at IS NOT NULL AND datetime(expire_at) <= datetime('now') - """; - List bans = new ArrayList<>(); - try (ResultSet rs = statementHandler.executeQuery(sql)) - { - while (rs.next()) - { - bans.add(loadBanFromResultSet(rs)); - } - } - return bans; - } - - @Override - public int getBanId(UUID uuid) throws SQLException - { - String sql = "SELECT id FROM bans WHERE uuid = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) return rs.getInt("id"); - } - return -1; - } - - @Override - public int getBanIdByUsername(String username) throws SQLException - { - String sql = "SELECT id FROM bans WHERE LOWER(username) = LOWER(?)"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) return rs.getInt("id"); - } - return -1; - } - - @Override - public List getIps(int banId) throws SQLException - { - List ips = new ArrayList<>(); - String sql = "SELECT ip FROM ban_ips WHERE ban_id = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, banId); - ResultSet rs = stmt.executeQuery()) - { - while (rs.next()) - { - ips.add(rs.getString("ip")); - } - } - return ips; - } - - @Override - public boolean isBanned(UUID uuid) throws SQLException - { - String sql = """ - SELECT COUNT(*) FROM bans - WHERE uuid = ? AND (expire_at IS NULL OR datetime(expire_at) > datetime('now')) - """; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - @Override - public boolean isBannedByUsername(String username) throws SQLException - { - String sql = """ - SELECT COUNT(*) FROM bans - WHERE LOWER(username) = LOWER(?) AND (expire_at IS NULL OR datetime(expire_at) > datetime('now')) - """; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - @Override - public boolean isBannedByIp(String ip) throws SQLException - { - String sql = """ - SELECT COUNT(*) FROM bans b - INNER JOIN ban_ips bi ON b.id = bi.ban_id - WHERE bi.ip = ? AND (b.expire_at IS NULL OR datetime(b.expire_at) > datetime('now')) - """; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, ip); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - // ============================================ - // UPDATE Operations - // ============================================ - - @Override - public boolean update(Ban ban) throws SQLException - { - String sql = """ - UPDATE bans - SET username = ?, banned_by = ?, banned_by_uuid = ?, reason = ?, expire_at = ? - WHERE uuid = ? - """; - - int rows = statementHandler.executeUpdate(sql, - ban.getUsername(), - ban.getBannedBy(), - ban.getBannedByUuid() != null ? ban.getBannedByUuid().toString() : null, - ban.getReason(), - ban.getExpireAt() != null ? FUtil.dateToString(ban.getExpireAt()) : null, - ban.getUuid().toString()); - - return rows > 0; - } - - @Override - public boolean updateReason(UUID uuid, String reason) throws SQLException - { - String sql = "UPDATE bans SET reason = ? WHERE uuid = ?"; - return statementHandler.executeUpdate(sql, reason, uuid.toString()) > 0; - } - - @Override - public boolean updateExpiry(UUID uuid, Date expireAt) throws SQLException - { - String sql = "UPDATE bans SET expire_at = ? WHERE uuid = ?"; - return statementHandler.executeUpdate(sql, expireAt != null ? FUtil.dateToString(expireAt) : null, uuid.toString()) > 0; - } - - @Override - public void syncIps(int banId, List ips) throws SQLException - { - statementHandler.executeUpdate("DELETE FROM ban_ips WHERE ban_id = ?", banId); - insertIps(banId, ips); - } - - // ============================================ - // DELETE Operations - // ============================================ - - @Override - public boolean delete(UUID uuid) throws SQLException - { - String sql = "DELETE FROM bans WHERE uuid = ?"; - return statementHandler.executeUpdate(sql, uuid.toString()) > 0; - } - - @Override - public boolean deleteByUsername(String username) throws SQLException - { - String sql = "DELETE FROM bans WHERE LOWER(username) = LOWER(?)"; - return statementHandler.executeUpdate(sql, username) > 0; - } - - @Override - public boolean deleteByIp(String ip) throws SQLException - { - // First get the ban IDs for the given IP - String selectSql = "SELECT ban_id FROM ban_ips WHERE ip = ?"; - List banIds = new ArrayList<>(); - try (PreparedStatement stmt = statementHandler.prepareStatement(selectSql, ip); - ResultSet rs = stmt.executeQuery()) - { - while (rs.next()) - { - banIds.add(rs.getInt("ban_id")); - } - } - - // Delete each ban - for (int banId : banIds) - { - statementHandler.executeUpdate("DELETE FROM bans WHERE id = ?", banId); - } - - return !banIds.isEmpty(); - } - - @Override - public boolean removeIp(int banId, String ip) throws SQLException - { - String sql = "DELETE FROM ban_ips WHERE ban_id = ? AND ip = ?"; - return statementHandler.executeUpdate(sql, banId, ip) > 0; - } - - @Override - public int deleteExpiredBans() throws SQLException - { - String sql = "DELETE FROM bans WHERE expire_at IS NOT NULL AND datetime(expire_at) <= datetime('now')"; - return statementHandler.executeUpdate(sql); - } - - @Override - public void deleteAllSync() throws SQLException - { - statementHandler.executeUpdate("DELETE FROM ban_ips"); - statementHandler.executeUpdate("DELETE FROM bans"); - } - - // ============================================ - // Async Operations - // ============================================ - - @Override - public Mono> loadAllAsync() - { - return statementHandler.supplyMono(this::loadAll); - } - - @Override - public Mono insertAsync(Ban ban) - { - return statementHandler.supplyMono(() -> insert(ban)); - } - - @Override - public Mono updateAsync(Ban ban) - { - return statementHandler.supplyMono(() -> update(ban)); - } - - @Override - public Mono deleteAsync(UUID uuid) - { - return statementHandler.supplyMono(() -> delete(uuid)); - } - - @Override - public Mono save(Ban ban) - { - return statementHandler.supplyMono(() -> { - if (ban.getUuid() != null && getBanId(ban.getUuid()) > 0) - { - update(ban); - return getBanId(ban.getUuid()); - } - return insert(ban); - }); - } - - @Override - public Mono> findAll() - { - return loadAllAsync(); - } - - @Override - public Mono deleteByUuid(UUID uuid) - { - return deleteAsync(uuid); - } - - @Override - public Mono deleteAll() - { - return statementHandler.runMono(this::deleteAllSync); - } - - // ============================================ - // Helper Methods - // ============================================ - - private Ban loadBanFromResultSet(ResultSet rs) throws SQLException - { - int id = rs.getInt("id"); - String uuidStr = rs.getString("uuid"); - String username = rs.getString("username"); - String bannedBy = rs.getString("banned_by"); - String bannedByUuidStr = rs.getString("banned_by_uuid"); - String reason = rs.getString("reason"); - String expireAtStr = rs.getString("expire_at"); - - Ban ban = new Ban(); - ban.setUuid(uuidStr != null ? UUID.fromString(uuidStr) : null); - ban.setUsername(username); - ban.setBannedBy(bannedBy); - ban.setBannedByUuid(bannedByUuidStr != null ? UUID.fromString(bannedByUuidStr) : null); - ban.setReason(reason); - ban.setExpireAt(expireAtStr != null ? FUtil.stringToDate(expireAtStr) : null); - - List ips = getIps(id); - ban.setIps(ips); - - return ban; - } -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteDiscordLinkRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteDiscordLinkRepository.java deleted file mode 100644 index 599c6d5c9..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteDiscordLinkRepository.java +++ /dev/null @@ -1,67 +0,0 @@ -package me.totalfreedom.totalfreedommod.sql.adapter.sqlite; - -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.UUID; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.DiscordLinkRepository; - -public class SQLiteDiscordLinkRepository implements DiscordLinkRepository -{ - private final TotalFreedomMod plugin; - private final StatementHandler statementHandler; - - public SQLiteDiscordLinkRepository(TotalFreedomMod plugin, StatementHandler statementHandler) - { - this.plugin = plugin; - this.statementHandler = statementHandler; - } - - @Override - public void insert(UUID adminUuid, String discordUserId) throws SQLException - { - statementHandler.executeUpdate( - "INSERT INTO discord_links (admin_uuid, discord_user_id, linked_at) VALUES (?, ?, CURRENT_TIMESTAMP)", - adminUuid.toString(), discordUserId); - } - - @Override - public String findDiscordIdByAdminUuid(UUID adminUuid) throws SQLException - { - try (ResultSet rs = statementHandler.executeQuery( - "SELECT discord_user_id FROM discord_links WHERE admin_uuid = ?", adminUuid.toString())) - { - return rs.next() ? rs.getString(1) : null; - } - } - - @Override - public UUID findAdminUuidByDiscordId(String discordUserId) throws SQLException - { - try (ResultSet rs = statementHandler.executeQuery( - "SELECT admin_uuid FROM discord_links WHERE discord_user_id = ?", discordUserId)) - { - if (!rs.next()) - { - return null; - } - String raw = rs.getString(1); - return raw == null ? null : UUID.fromString(raw); - } - } - - @Override - public boolean deleteByAdminUuid(UUID adminUuid) throws SQLException - { - return statementHandler.executeUpdate( - "DELETE FROM discord_links WHERE admin_uuid = ?", adminUuid.toString()) > 0; - } - - @Override - public boolean deleteByDiscordUserId(String discordUserId) throws SQLException - { - return statementHandler.executeUpdate( - "DELETE FROM discord_links WHERE discord_user_id = ?", discordUserId) > 0; - } -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLitePermbanRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLitePermbanRepository.java deleted file mode 100644 index e614dc85a..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLitePermbanRepository.java +++ /dev/null @@ -1,422 +0,0 @@ -package me.totalfreedom.totalfreedommod.sql.adapter.sqlite; - -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.banning.PermBan; -import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.PermbanRepository; - -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.*; - -import reactor.core.publisher.Mono; - -/** - * SQLite implementation of PermbanRepository. - * Uses SQLite-specific SQL syntax. - */ -public class SQLitePermbanRepository implements PermbanRepository -{ - private final TotalFreedomMod plugin; - private final StatementHandler statementHandler; - - public SQLitePermbanRepository(TotalFreedomMod plugin, StatementHandler statementHandler) - { - this.plugin = plugin; - this.statementHandler = statementHandler; - } - - // ============================================ - // CREATE Operations - // ============================================ - - @Override - public int insert(PermBan permban) throws SQLException - { - String sql = """ - INSERT INTO permbans (uuid, username, reason) - VALUES (?, ?, ?) - """; - - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, - permban.getUuid() != null ? permban.getUuid().toString() : null, - permban.getUsername(), - permban.getReason())) - { - stmt.executeUpdate(); - - try (ResultSet rs = stmt.getGeneratedKeys()) - { - if (rs.next()) - { - int permbanId = rs.getInt(1); - insertIps(permbanId, permban.getIps()); - return permbanId; - } - } - } - return -1; - } - - @Override - public void insertIps(int permbanId, List ips) throws SQLException - { - if (ips == null || ips.isEmpty()) return; - - String sql = "INSERT OR IGNORE INTO permban_ips (permban_id, ip) VALUES (?, ?)"; - for (String ip : ips) - { - statementHandler.executeUpdate(sql, permbanId, ip); - } - } - - @Override - public void addIp(int permbanId, String ip) throws SQLException - { - String sql = "INSERT OR IGNORE INTO permban_ips (permban_id, ip) VALUES (?, ?)"; - statementHandler.executeUpdate(sql, permbanId, ip); - } - - // ============================================ - // READ Operations - // ============================================ - - @Override - public List loadAll() throws SQLException - { - List permbans = new ArrayList<>(); - Map permbanById = new HashMap<>(); - - String sql = "SELECT id, uuid, username, reason FROM permbans"; - try (ResultSet rs = statementHandler.executeQuery(sql)) - { - while (rs.next()) - { - int id = rs.getInt("id"); - String uuidStr = rs.getString("uuid"); - String username = rs.getString("username"); - String reason = rs.getString("reason"); - - PermBan permban = new PermBan(); - permban.setUuid(uuidStr != null ? UUID.fromString(uuidStr) : null); - permban.setUsername(username); - permban.setReason(reason); - - permbans.add(permban); - permbanById.put(id, permban); - } - } - - // Load IPs - String ipSql = "SELECT permban_id, ip FROM permban_ips"; - try (ResultSet rs = statementHandler.executeQuery(ipSql)) - { - while (rs.next()) - { - int permbanId = rs.getInt("permban_id"); - String ip = rs.getString("ip"); - PermBan permban = permbanById.get(permbanId); - if (permban != null) - { - permban.addIp(ip); - } - } - } - - return permbans; - } - - @Override - public PermBan findByUuid(UUID uuid) throws SQLException - { - String sql = "SELECT id, uuid, username, reason FROM permbans WHERE uuid = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadPermbanFromResultSet(rs); - } - } - return null; - } - - @Override - public PermBan findByUsername(String username) throws SQLException - { - String sql = "SELECT id, uuid, username, reason FROM permbans WHERE LOWER(username) = LOWER(?)"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadPermbanFromResultSet(rs); - } - } - return null; - } - - @Override - public PermBan findByIp(String ip) throws SQLException - { - String sql = """ - SELECT p.id, p.uuid, p.username, p.reason - FROM permbans p - INNER JOIN permban_ips pi ON p.id = pi.permban_id - WHERE pi.ip = ? - """; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, ip); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadPermbanFromResultSet(rs); - } - } - return null; - } - - @Override - public int getPermbanId(UUID uuid) throws SQLException - { - String sql = "SELECT id FROM permbans WHERE uuid = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) return rs.getInt("id"); - } - return -1; - } - - @Override - public int getPermbanIdByUsername(String username) throws SQLException - { - String sql = "SELECT id FROM permbans WHERE LOWER(username) = LOWER(?)"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) return rs.getInt("id"); - } - return -1; - } - - @Override - public List getIps(int permbanId) throws SQLException - { - List ips = new ArrayList<>(); - String sql = "SELECT ip FROM permban_ips WHERE permban_id = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, permbanId); - ResultSet rs = stmt.executeQuery()) - { - while (rs.next()) - { - ips.add(rs.getString("ip")); - } - } - return ips; - } - - @Override - public boolean isPermBanned(UUID uuid) throws SQLException - { - String sql = "SELECT COUNT(*) FROM permbans WHERE uuid = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - @Override - public boolean isPermBannedByUsername(String username) throws SQLException - { - String sql = "SELECT COUNT(*) FROM permbans WHERE LOWER(username) = LOWER(?)"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - @Override - public boolean isPermBannedByIp(String ip) throws SQLException - { - String sql = """ - SELECT COUNT(*) FROM permbans p - INNER JOIN permban_ips pi ON p.id = pi.permban_id - WHERE pi.ip = ? - """; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, ip); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - // ============================================ - // UPDATE Operations - // ============================================ - - @Override - public boolean update(PermBan permban) throws SQLException - { - String sql = """ - UPDATE permbans - SET username = ?, reason = ? - WHERE uuid = ? - """; - - int rows = statementHandler.executeUpdate(sql, - permban.getUsername(), - permban.getReason(), - permban.getUuid().toString()); - - return rows > 0; - } - - @Override - public boolean updateReason(UUID uuid, String reason) throws SQLException - { - String sql = "UPDATE permbans SET reason = ? WHERE uuid = ?"; - return statementHandler.executeUpdate(sql, reason, uuid.toString()) > 0; - } - - @Override - public void syncIps(int permbanId, List ips) throws SQLException - { - statementHandler.executeUpdate("DELETE FROM permban_ips WHERE permban_id = ?", permbanId); - insertIps(permbanId, ips); - } - - // ============================================ - // DELETE Operations - // ============================================ - - @Override - public boolean delete(UUID uuid) throws SQLException - { - String sql = "DELETE FROM permbans WHERE uuid = ?"; - return statementHandler.executeUpdate(sql, uuid.toString()) > 0; - } - - @Override - public boolean deleteByUsername(String username) throws SQLException - { - String sql = "DELETE FROM permbans WHERE LOWER(username) = LOWER(?)"; - return statementHandler.executeUpdate(sql, username) > 0; - } - - @Override - public boolean deleteByIp(String ip) throws SQLException - { - // First get the permban IDs for the given IP - String selectSql = "SELECT permban_id FROM permban_ips WHERE ip = ?"; - List permbanIds = new ArrayList<>(); - try (PreparedStatement stmt = statementHandler.prepareStatement(selectSql, ip); - ResultSet rs = stmt.executeQuery()) - { - while (rs.next()) - { - permbanIds.add(rs.getInt("permban_id")); - } - } - - // Delete each permban - for (int permbanId : permbanIds) - { - statementHandler.executeUpdate("DELETE FROM permbans WHERE id = ?", permbanId); - } - - return !permbanIds.isEmpty(); - } - - @Override - public boolean removeIp(int permbanId, String ip) throws SQLException - { - String sql = "DELETE FROM permban_ips WHERE permban_id = ? AND ip = ?"; - return statementHandler.executeUpdate(sql, permbanId, ip) > 0; - } - - @Override - public void deleteAllSync() throws SQLException - { - statementHandler.executeUpdate("DELETE FROM permban_ips"); - statementHandler.executeUpdate("DELETE FROM permbans"); - } - - // ============================================ - // Async Operations - // ============================================ - - @Override - public Mono> loadAllAsync() - { - return statementHandler.supplyMono(this::loadAll); - } - - @Override - public Mono insertAsync(PermBan permban) - { - return statementHandler.supplyMono(() -> insert(permban)); - } - - @Override - public Mono updateAsync(PermBan permban) - { - return statementHandler.supplyMono(() -> update(permban)); - } - - @Override - public Mono deleteAsync(UUID uuid) - { - return statementHandler.supplyMono(() -> delete(uuid)); - } - - @Override - public Mono save(PermBan permban) - { - return statementHandler.supplyMono(() -> { - if (permban.getUuid() != null && getPermbanId(permban.getUuid()) > 0) - { - update(permban); - return getPermbanId(permban.getUuid()); - } - return insert(permban); - }); - } - - @Override - public Mono> findAll() - { - return loadAllAsync(); - } - - @Override - public Mono deleteAll() - { - return statementHandler.runMono(this::deleteAllSync); - } - - // ============================================ - // Helper Methods - // ============================================ - - private PermBan loadPermbanFromResultSet(ResultSet rs) throws SQLException - { - int id = rs.getInt("id"); - String uuidStr = rs.getString("uuid"); - String username = rs.getString("username"); - String reason = rs.getString("reason"); - - PermBan permban = new PermBan(); - permban.setUuid(uuidStr != null ? UUID.fromString(uuidStr) : null); - permban.setUsername(username); - permban.setReason(reason); - - List ips = getIps(id); - permban.setIps(ips); - - return permban; - } -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteStrikeRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteStrikeRepository.java deleted file mode 100644 index ea8551c1e..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteStrikeRepository.java +++ /dev/null @@ -1,93 +0,0 @@ -package me.totalfreedom.totalfreedommod.sql.adapter.sqlite; - -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.HashMap; -import java.util.Map; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.banning.StrikeRecord; -import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.StrikeRepository; -import reactor.core.publisher.Mono; - -public class SQLiteStrikeRepository implements StrikeRepository -{ - private final TotalFreedomMod plugin; - private final StatementHandler statementHandler; - - public SQLiteStrikeRepository(TotalFreedomMod plugin, StatementHandler statementHandler) - { - this.plugin = plugin; - this.statementHandler = statementHandler; - } - - @Override - public Map loadAll() throws SQLException - { - Map out = new HashMap<>(); - String sql = "SELECT ip, strike_count, last_strike_unix, last_username FROM strikes"; - try (ResultSet rs = statementHandler.executeQuery(sql)) - { - while (rs.next()) - { - String ip = rs.getString("ip"); - int count = rs.getInt("strike_count"); - long last = rs.getLong("last_strike_unix"); - String username = rs.getString("last_username"); - out.put(ip, new StrikeRecord(ip, count, last, username)); - } - } - return out; - } - - @Override - public void upsert(StrikeRecord r) throws SQLException - { - String sql = """ - INSERT INTO strikes (ip, strike_count, last_strike_unix, last_username) - VALUES (?, ?, ?, ?) - ON CONFLICT(ip) DO UPDATE SET - strike_count = excluded.strike_count, - last_strike_unix = excluded.last_strike_unix, - last_username = excluded.last_username - """; - statementHandler.executeUpdate(sql, - r.getIp(), r.getCount(), r.getLastStrikeUnix(), r.getLastUsername()); - } - - @Override - public boolean deleteByIp(String ip) throws SQLException - { - return statementHandler.executeUpdate("DELETE FROM strikes WHERE ip = ?", ip) > 0; - } - - @Override - public void deleteAllSync() throws SQLException - { - statementHandler.executeUpdate("DELETE FROM strikes"); - } - - @Override - public Mono> loadAllAsync() - { - return statementHandler.supplyMono(this::loadAll); - } - - @Override - public Mono upsertAsync(StrikeRecord r) - { - return statementHandler.runMono(() -> upsert(r)); - } - - @Override - public Mono deleteByIpAsync(String ip) - { - return statementHandler.supplyMono(() -> deleteByIp(ip)); - } - - @Override - public Mono deleteAll() - { - return statementHandler.runMono(this::deleteAllSync); - } -} From 6e023f58dd1f00d7165882bbd078ebfd9d2f895a Mon Sep 17 00:00:00 2001 From: Paldiu Date: Mon, 27 Jul 2026 14:03:54 -0500 Subject: [PATCH 04/48] pushign this shit because i need it OFF MY STASH --- .../totalfreedommod/ConfigConverter.java | 39 ++- .../totalfreedommod/ProtectArea.java | 222 +++++++++++--- .../totalfreedommod/SavedFlags.java | 187 +++++++++--- .../totalfreedommod/admin/Admin.java | 17 +- .../totalfreedommod/admin/AdminList.java | 189 +++++++----- .../totalfreedommod/banning/Ban.java | 100 +++++-- .../totalfreedommod/banning/BanManager.java | 149 +++++++--- .../totalfreedommod/banning/PermbanList.java | 156 ++++++++-- .../totalfreedommod/banning/StrikeList.java | 121 +++++--- .../totalfreedommod/banning/StrikeRecord.java | 50 +++- .../discord/DiscordBridge.java | 5 + .../discord/DiscordCommands.java | 3 + .../discord/DiscordLinkJsonSync.java | 96 ++++++ .../totalfreedommod/player/PlayerData.java | 23 +- .../totalfreedommod/player/PlayerList.java | 212 ++++++++++--- .../totalfreedommod/rank/CustomRank.java | 76 +++-- .../totalfreedommod/rank/RankManager.java | 178 +++++++++-- .../totalfreedommod/sql/FreedomDatabase.java | 40 +++ .../sql/YamlMigrationService.java | 280 ++++++++++++++++++ .../sql/adapter/AdminRepository.java | 6 + .../sql/adapter/BanRepository.java | 6 + .../sql/adapter/DiscordLinkRepository.java | 13 + .../sql/adapter/PermbanRepository.java | 6 + .../sql/adapter/PlayerRepository.java | 7 + .../sql/adapter/ProtectedAreaRepository.java | 6 + .../sql/adapter/RankRepository.java | 6 + .../sql/adapter/SavedFlagRepository.java | 6 + .../sql/adapter/StrikeRepository.java | 6 + .../generic/GenericAdminRepository.java | 26 +- .../adapter/generic/GenericBanRepository.java | 28 +- .../generic/GenericDiscordLinkRepository.java | 41 ++- .../generic/GenericPermbanRepository.java | 26 +- .../generic/GenericPlayerRepository.java | 26 +- .../GenericProtectedAreaRepository.java | 28 +- .../generic/GenericRankRepository.java | 26 +- .../generic/GenericSavedFlagRepository.java | 24 +- .../generic/GenericStrikeRepository.java | 25 +- .../sql/adapter/mysql/MySQLAdapter.java | 54 +++- .../adapter/postgresql/PostgreSQLAdapter.java | 38 ++- .../sql/adapter/sqlite/SQLiteAdapter.java | 63 ++-- .../totalfreedommod/ssh/SshIdentityStore.java | 4 +- .../util/ConfigInterfaces.java | 13 - .../totalfreedommod/util/JsonUtil.java | 44 +++ src/main/resources/admins.json | 33 +++ src/main/resources/admins.yml | 31 -- src/main/resources/bans.json | 1 + src/main/resources/bans.yml | 3 - src/main/resources/permbans.json | 10 + src/main/resources/permbans.yml | 10 - src/main/resources/ranks.json | 161 ++++++++++ src/main/resources/ranks.yml | 189 ------------ src/main/resources/version.json | 3 + src/main/resources/version.yml | 4 - 53 files changed, 2355 insertions(+), 761 deletions(-) create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordLinkJsonSync.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/util/JsonUtil.java create mode 100644 src/main/resources/admins.json delete mode 100644 src/main/resources/admins.yml create mode 100644 src/main/resources/bans.json delete mode 100644 src/main/resources/bans.yml create mode 100644 src/main/resources/permbans.json delete mode 100644 src/main/resources/permbans.yml create mode 100644 src/main/resources/ranks.json delete mode 100644 src/main/resources/ranks.yml create mode 100644 src/main/resources/version.json delete mode 100644 src/main/resources/version.yml diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ConfigConverter.java b/src/main/java/me/totalfreedom/totalfreedommod/ConfigConverter.java index 68641bd4b..1b377e550 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ConfigConverter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ConfigConverter.java @@ -2,16 +2,21 @@ import com.google.common.collect.Lists; import com.google.common.io.Files; +import com.google.gson.reflect.TypeToken; import java.io.File; +import java.io.FileWriter; import java.io.IOException; import java.util.Date; +import java.util.HashMap; import java.util.List; +import java.util.Map; import me.totalfreedom.totalfreedommod.admin.Admin; import me.totalfreedom.totalfreedommod.admin.AdminList; +import me.totalfreedom.totalfreedommod.banning.PermBan; import me.totalfreedom.totalfreedommod.banning.PermbanList; import me.totalfreedom.totalfreedommod.rank.Rank; -import me.totalfreedom.totalfreedommod.rank.RankManager; import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.JsonUtil; import me.totalfreedom.totalfreedommod.framework.PluginComponent; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.YamlConfiguration; @@ -108,7 +113,7 @@ public void convert() */ public void convertRanksYaml() { - File ranksFile = new File(plugin.getDataFolder(), RankManager.RANKS_FILENAME); + File ranksFile = new File(plugin.getDataFolder(), "ranks.yml"); if (!ranksFile.exists()) { return; @@ -244,15 +249,15 @@ private void convertSuperadmins(File oldFile) conversions.add(admin); } - File newYamlFile = new File(plugin.getDataFolder(), AdminList.CONFIG_FILENAME); - YamlConfiguration newYaml = YamlConfiguration.loadConfiguration(newYamlFile); + File newJsonFile = new File(plugin.getDataFolder(), AdminList.CONFIG_FILENAME); + Map converted = new HashMap<>(); for (Admin admin : conversions) { - admin.saveTo(newYaml.createSection(admin.getName().toLowerCase())); + converted.put(admin.getName().toLowerCase(), admin); } - try + try (FileWriter writer = new FileWriter(newJsonFile)) { - newYaml.save(newYamlFile); + JsonUtil.GSON.toJson(converted, new TypeToken>() {}.getType(), writer); } catch (IOException ex) { @@ -270,16 +275,26 @@ private void convertPermbans(File oldFile) return; } - try + final YamlConfiguration oldYaml = YamlConfiguration.loadConfiguration(oldFile); + final Map converted = new HashMap<>(); + for (String name : oldYaml.getKeys(false)) + { + final String lowerName = name.toLowerCase().trim(); + final PermBan permban = new PermBan(); + permban.setUsername(lowerName); + permban.setIps(oldYaml.getStringList(name)); + converted.put(lowerName, permban); + } + + try (FileWriter writer = new FileWriter(new File(plugin.getDataFolder(), PermbanList.CONFIG_FILENAME))) { - Files.copy(oldFile, new File(plugin.getDataFolder(), PermbanList.CONFIG_FILENAME)); - FLog.info("Converted permban list"); + JsonUtil.GSON.toJson(converted, new TypeToken>() {}.getType(), writer); + FLog.info("Converted " + converted.size() + " permbans"); } catch (IOException ex) { - FLog.warning("Could not copy old permban list!"); + FLog.warning("Could not save converted permban list!"); } - } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java b/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java index 419c6f95d..0efdec902 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java @@ -1,12 +1,17 @@ package me.totalfreedom.totalfreedommod; import com.google.common.collect.Maps; +import com.google.gson.reflect.TypeToken; import java.io.File; import java.io.FileInputStream; +import java.io.FileReader; +import java.io.FileWriter; import java.io.IOException; import java.io.ObjectInputStream; import java.io.Serializable; +import java.lang.reflect.Type; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -14,8 +19,10 @@ import me.totalfreedom.totalfreedommod.ProtectArea.ProtectedRegion.CantFindWorldException; import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.sql.adapter.ProtectedAreaRepository; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FTask; +import me.totalfreedom.totalfreedommod.util.JsonUtil; import org.bukkit.Bukkit; import org.bukkit.Location; @@ -61,13 +68,17 @@ public class ProtectArea extends FreedomService { - public static final String DATA_FILENAME = "protectedareas.yml"; + public static final String DATA_FILENAME = "protectedareas.json"; + public static final String LEGACY_YAML_FILENAME = "protectedareas.yml"; public static final String LEGACY_DATA_FILENAME = "protectedareas.dat"; public static final double MAX_RADIUS = 50.0; // How often (in ticks) to sweep loose items out of protected areas. private static final long ITEM_SWEEP_RATE = 40L; + private static final Type PROTECTED_AREA_LIST_TYPE = new TypeToken>() {}.getType(); // private final Map areas = Maps.newHashMap(); + private File dataFile; + private boolean usingSql = false; private BukkitTask itemSweepTask; public ProtectArea(TotalFreedomMod plugin) @@ -83,30 +94,140 @@ protected void onStart() return; } - File ymlFile = new File(plugin.getDataFolder(), DATA_FILENAME); - File legacyFile = new File(plugin.getDataFolder(), LEGACY_DATA_FILENAME); + dataFile = new File(plugin.getDataFolder(), DATA_FILENAME); - if (legacyFile.exists() && !ymlFile.exists()) + if (plugin.dm != null && plugin.dm.isInitialized()) { - migrateLegacyData(legacyFile, ymlFile); + loadFromSql(); + } + else + { + loadFromJsonOrLegacy(); } - - loadFromYaml(ymlFile); itemSweepTask = Bukkit.getScheduler().runTaskTimer( plugin, FTask.guard("ProtectArea/sweepItems", this::sweepItems), ITEM_SWEEP_RATE, ITEM_SWEEP_RATE); } + private void loadFromSql() + { + try + { + ProtectedAreaRepository repo = plugin.dm.getProtectedAreaRepository(); + List loaded = repo.loadAllAsync().block(); + usingSql = true; + + if (loaded.isEmpty() && !dataFile.exists()) + { + File legacyFile = new File(plugin.getDataFolder(), LEGACY_DATA_FILENAME); + if (legacyFile.exists()) + { + migrateLegacyData(legacyFile); + } + return; + } + + areas.clear(); + loaded.forEach(region -> areas.put(region.getUuid(), region)); + FLog.info("Loaded " + areas.size() + " protected area(s) from SQL database."); + + reconcileFromJsonIfNewer(repo); + } + catch (Exception ex) + { + FLog.warning("Failed to load protected areas from SQL, falling back to JSON: " + ex.getMessage()); + usingSql = false; + loadFromJsonOrLegacy(); + } + } + + private void loadFromJsonOrLegacy() + { + if (!dataFile.exists()) + { + File legacyFile = new File(plugin.getDataFolder(), LEGACY_DATA_FILENAME); + if (legacyFile.exists()) + { + migrateLegacyData(legacyFile); + } + return; + } + + loadFromJson(); + } + + private void loadFromJson() + { + areas.clear(); + try + { + readJsonAreas().forEach(region -> areas.put(region.getUuid(), region)); + } + catch (IOException ex) + { + FLog.severe("Failed to read " + DATA_FILENAME + ": " + ex.getMessage()); + } + FLog.info("Loaded " + areas.size() + " protected area(s)."); + } + + private List readJsonAreas() throws IOException + { + try (FileReader reader = new FileReader(dataFile)) + { + List loaded = JsonUtil.GSON.fromJson(reader, PROTECTED_AREA_LIST_TYPE); + return loaded != null ? loaded : new ArrayList<>(); + } + } + + /** + * If protectedareas.json was written more recently than the database's last update, re-import it into SQL. + */ + private void reconcileFromJsonIfNewer(ProtectedAreaRepository repo) + { + if (!dataFile.exists()) + { + return; + } + + try + { + Long sqlUpdatedAt = repo.getMaxUpdatedAt(); + if (sqlUpdatedAt != null && dataFile.lastModified() <= sqlUpdatedAt) + { + return; + } + + List jsonAreas = readJsonAreas(); + if (jsonAreas.isEmpty()) + { + return; + } + + FLog.info(DATA_FILENAME + " is newer than the database; re-importing " + jsonAreas.size() + " protected area(s) from it."); + for (ProtectedRegion region : jsonAreas) + { + repo.saveOrUpdate(region); + } + + areas.clear(); + jsonAreas.forEach(region -> areas.put(region.getUuid(), region)); + } + catch (Exception ex) + { + FLog.warning("Failed to reconcile " + DATA_FILENAME + " into the database: " + ex.getMessage()); + } + } + @SuppressWarnings("unchecked") - private void migrateLegacyData(File legacyFile, File ymlFile) + private void migrateLegacyData(File legacyFile) { - FLog.info("Migrating protected areas from legacy .dat format to .yml format..."); + FLog.info("Migrating protected areas from legacy .dat format..."); try (FileInputStream fis = new FileInputStream(legacyFile); ObjectInputStream ois = new ObjectInputStream(fis)) { - HashMap legacyAreas = + HashMap legacyAreas = (HashMap) ois.readObject(); - + areas.clear(); for (Map.Entry entry : legacyAreas.entrySet()) { @@ -120,9 +241,9 @@ private void migrateLegacyData(File legacyFile, File ymlFile) legacy.worldUUID.toString() )); } - + save(); - + File oldFile = new File(legacyFile.getParent(), LEGACY_DATA_FILENAME + ".old"); if (legacyFile.renameTo(oldFile)) { @@ -140,10 +261,14 @@ private void migrateLegacyData(File legacyFile, File ymlFile) } } + /** + * Reads the pre-JSON {@code protectedareas.yml} format. Retained only for the one-time + * legacy-install migration path (not called during normal startup). + */ private void loadFromYaml(File file) { areas.clear(); - + if (!file.exists()) { return; @@ -153,7 +278,7 @@ private void loadFromYaml(File file) { YamlConfiguration config = YamlConfiguration.loadConfiguration(file); ConfigurationSection areasSection = config.getConfigurationSection("areas"); - + if (areasSection == null) { return; @@ -207,36 +332,51 @@ protected void onStop() public void save() { - try + if (usingSql) + { + saveToSql(); + } + else { - YamlConfiguration config = new YamlConfiguration(); - ConfigurationSection areasSection = config.createSection("areas"); + saveToJson(); + } + } + + private void saveToSql() + { + if (plugin.dm == null || !plugin.dm.isInitialized()) + { + FLog.warning("SQL not available, falling back to JSON save for protected areas"); + saveToJson(); + return; + } - for (Map.Entry entry : areas.entrySet()) + try + { + ProtectedAreaRepository repo = plugin.dm.getProtectedAreaRepository(); + for (ProtectedRegion region : areas.values()) { - ConfigurationSection areaSection = areasSection.createSection(entry.getKey().toString()); - ProtectedRegion region = entry.getValue(); - - areaSection.set("name", region.getName()); - try - { - areaSection.set("min_x", region.getMinimumPoint().getBlockX()); - areaSection.set("min_y", region.getMinimumPoint().getBlockY()); - areaSection.set("min_z", region.getMinimumPoint().getBlockZ()); - areaSection.set("max_x", region.getMaximumPoint().getBlockX()); - areaSection.set("max_y", region.getMaximumPoint().getBlockY()); - areaSection.set("max_z", region.getMaximumPoint().getBlockZ()); - areaSection.set("world", region.getWorld().getUID().toString()); - } - catch (CantFindWorldException ex) - { - FLog.warning(String.format("Failed to save protected area '%s' (%s) because the UUID of the world it's in was invalid", - region.getName(), - region.getUuid())); - } + repo.save(region).block(); } + } + catch (Exception ex) + { + FLog.severe("Could not save protected areas to SQL: " + ex.getMessage()); + } - config.save(new File(plugin.getDataFolder(), DATA_FILENAME)); + saveToJson(); + } + + private void saveToJson() + { + if (dataFile == null) + { + dataFile = new File(plugin.getDataFolder(), DATA_FILENAME); + } + + try (FileWriter writer = new FileWriter(dataFile)) + { + JsonUtil.GSON.toJson(new ArrayList<>(areas.values()), PROTECTED_AREA_LIST_TYPE, writer); } catch (IOException ex) { @@ -862,7 +1002,7 @@ public static class ProtectedRegion private Vector min; private Vector max; private UUID worldUUID; - private World world; + private transient World world; public ProtectedRegion(final UUID uuid, final String name, final Location min, final Location max, final World world) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/SavedFlags.java b/src/main/java/me/totalfreedom/totalfreedommod/SavedFlags.java index 18d7a9077..707a922f8 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/SavedFlags.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/SavedFlags.java @@ -1,21 +1,33 @@ package me.totalfreedom.totalfreedommod; +import com.google.gson.reflect.TypeToken; import java.io.File; import java.io.FileInputStream; +import java.io.FileReader; +import java.io.FileWriter; import java.io.IOException; import java.io.ObjectInputStream; +import java.lang.reflect.Type; +import java.sql.SQLException; import java.util.HashMap; import java.util.Map; +import me.totalfreedom.totalfreedommod.sql.adapter.SavedFlagRepository; import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.JsonUtil; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.YamlConfiguration; public class SavedFlags extends FreedomService { - public static final String DATA_FILENAME = "savedflags.yml"; + public static final String DATA_FILENAME = "savedflags.json"; + public static final String LEGACY_YAML_FILENAME = "savedflags.yml"; public static final String LEGACY_DATA_FILENAME = "savedflags.dat"; + private static final Type FLAGS_MAP_TYPE = new TypeToken>() {}.getType(); + + private boolean usingSql = false; + public SavedFlags(TotalFreedomMod plugin) { super(plugin); @@ -24,12 +36,19 @@ public SavedFlags(TotalFreedomMod plugin) @Override protected void onStart() { - File ymlFile = new File(plugin.getDataFolder(), DATA_FILENAME); + usingSql = plugin.dm != null && plugin.dm.isInitialized(); + + File dataFile = new File(plugin.getDataFolder(), DATA_FILENAME); File legacyFile = new File(plugin.getDataFolder(), LEGACY_DATA_FILENAME); - if (legacyFile.exists() && !ymlFile.exists()) + if (legacyFile.exists() && !dataFile.exists()) + { + migrateLegacyData(legacyFile, dataFile); + } + + if (usingSql) { - migrateLegacyData(legacyFile, ymlFile); + reconcileFromJsonIfNewer(); } } @@ -39,23 +58,30 @@ protected void onStop() } @SuppressWarnings("unchecked") - private void migrateLegacyData(File legacyFile, File ymlFile) + private void migrateLegacyData(File legacyFile, File dataFile) { - FLog.info("Migrating saved flags from legacy .dat format to .yml format..."); + FLog.info("Migrating saved flags from legacy .dat format..."); try (FileInputStream fis = new FileInputStream(legacyFile); ObjectInputStream ois = new ObjectInputStream(fis)) { HashMap legacyFlags = (HashMap) ois.readObject(); - YamlConfiguration config = new YamlConfiguration(); - ConfigurationSection flagsSection = config.createSection("flags"); - - for (Map.Entry entry : legacyFlags.entrySet()) + if (usingSql) { - flagsSection.set(entry.getKey(), entry.getValue()); + try + { + SavedFlagRepository repo = plugin.dm.getSavedFlagRepository(); + for (Map.Entry entry : legacyFlags.entrySet()) + { + repo.upsert(entry.getKey(), entry.getValue()); + } + } + catch (SQLException ex) + { + FLog.severe("Could not save migrated flags to SQL: " + ex.getMessage()); + } } - - config.save(ymlFile); + saveToJson(legacyFlags); File oldFile = new File(legacyFile.getParent(), LEGACY_DATA_FILENAME + ".old"); if (legacyFile.renameTo(oldFile)) @@ -74,11 +100,13 @@ private void migrateLegacyData(File legacyFile, File ymlFile) } } - public Map getSavedFlags() + /** + * Reads the pre-JSON {@code savedflags.yml} format. Retained only for the one-time + * legacy-install migration path (not called during normal startup). + */ + private Map loadLegacyYaml(File file) { Map flags = new HashMap<>(); - - File file = new File(PluginProvider.get().getDataFolder(), DATA_FILENAME); if (!file.exists()) { return flags; @@ -98,6 +126,69 @@ public Map getSavedFlags() } } catch (Exception ex) + { + FLog.severe("Failed to load legacy saved flags: " + ex.getMessage()); + FLog.severe(ex); + } + + return flags; + } + + /** + * If savedflags.json was written more recently than the database's last update, re-import it into SQL. + */ + private void reconcileFromJsonIfNewer() + { + File dataFile = new File(plugin.getDataFolder(), DATA_FILENAME); + if (!dataFile.exists()) + { + return; + } + + try + { + SavedFlagRepository repo = plugin.dm.getSavedFlagRepository(); + Long sqlUpdatedAt = repo.getMaxUpdatedAt(); + if (sqlUpdatedAt != null && dataFile.lastModified() <= sqlUpdatedAt) + { + return; + } + + Map jsonFlags = readJsonFlags(dataFile); + if (jsonFlags.isEmpty()) + { + return; + } + + FLog.info(DATA_FILENAME + " is newer than the database; re-importing " + jsonFlags.size() + " flag(s) from it."); + for (Map.Entry entry : jsonFlags.entrySet()) + { + repo.upsert(entry.getKey(), entry.getValue()); + } + } + catch (Exception ex) + { + FLog.warning("Failed to reconcile " + DATA_FILENAME + " into the database: " + ex.getMessage()); + } + } + + private Map readJsonFlags(File file) + { + Map flags = new HashMap<>(); + if (!file.exists()) + { + return flags; + } + + try (FileReader reader = new FileReader(file)) + { + Map loaded = JsonUtil.GSON.fromJson(reader, FLAGS_MAP_TYPE); + if (loaded != null) + { + flags.putAll(loaded); + } + } + catch (Exception ex) { FLog.severe("Failed to load saved flags: " + ex.getMessage()); FLog.severe(ex); @@ -106,6 +197,37 @@ public Map getSavedFlags() return flags; } + private void saveToJson(Map flags) + { + File file = new File(plugin.getDataFolder(), DATA_FILENAME); + try (FileWriter writer = new FileWriter(file)) + { + JsonUtil.GSON.toJson(flags, FLAGS_MAP_TYPE, writer); + } + catch (IOException ex) + { + FLog.severe("Failed to save saved flags: " + ex.getMessage()); + FLog.severe(ex); + } + } + + public Map getSavedFlags() + { + if (usingSql && plugin.dm != null && plugin.dm.isInitialized()) + { + try + { + return plugin.dm.getSavedFlagRepository().loadAll(); + } + catch (SQLException ex) + { + FLog.severe("Failed to load saved flags from SQL: " + ex.getMessage()); + } + } + + return readJsonFlags(new File(PluginProvider.get().getDataFolder(), DATA_FILENAME)); + } + public boolean getSavedFlag(String flag) throws Exception { Boolean flagValue = null; @@ -132,32 +254,25 @@ public boolean getSavedFlag(String flag) throws Exception public void setSavedFlag(String flag, boolean value) { - Map flags = getSavedFlags(); - - if (flags == null) - { - flags = new HashMap<>(); - } - - flags.put(flag, value); - - try + if (usingSql && plugin.dm != null && plugin.dm.isInitialized()) { - YamlConfiguration config = new YamlConfiguration(); - ConfigurationSection flagsSection = config.createSection("flags"); - - for (Map.Entry entry : flags.entrySet()) + try { - flagsSection.set(entry.getKey(), entry.getValue()); + plugin.dm.getSavedFlagRepository().upsert(flag, value); + } + catch (SQLException ex) + { + FLog.severe("Could not save flag '" + flag + "' to SQL: " + ex.getMessage()); } - - config.save(new File(plugin.getDataFolder(), DATA_FILENAME)); } - catch (IOException ex) + + Map flags = getSavedFlags(); + if (flags == null) { - FLog.severe("Failed to save saved flags: " + ex.getMessage()); - FLog.severe(ex); + flags = new HashMap<>(); } + flags.put(flag, value); + saveToJson(flags); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/admin/Admin.java b/src/main/java/me/totalfreedom/totalfreedommod/admin/Admin.java index 9eb789451..e5325ce60 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/admin/Admin.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/admin/Admin.java @@ -1,19 +1,17 @@ package me.totalfreedom.totalfreedommod.admin; -import com.google.common.base.Preconditions; import com.google.common.collect.Lists; import java.util.Date; import java.util.List; import java.util.UUID; import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.ConfigInterfaces.ConfigLoadable; -import me.totalfreedom.totalfreedommod.util.ConfigInterfaces.ConfigSavable; import me.totalfreedom.totalfreedommod.util.ConfigInterfaces.Validatable; import me.totalfreedom.totalfreedommod.util.FUtil; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.entity.Player; -public class Admin implements ConfigLoadable, ConfigSavable, Validatable +public class Admin implements ConfigLoadable, Validatable { private UUID uuid; @@ -77,19 +75,6 @@ public void loadFrom(ConfigurationSection cs) customRankId = cs.getString("custom_rank", null); } - @Override - public void saveTo(ConfigurationSection cs) - { - Preconditions.checkArgument(isValid(), "Could not save admin entry: " + name + ". Entry not valid!"); - cs.set("username", name); - cs.set("active", active); - cs.set("rank", rank.toString()); - cs.set("ips", Lists.newArrayList(ips)); - cs.set("last_login", FUtil.dateToString(lastLogin)); - cs.set("login_message", loginMessage); - cs.set("custom_rank", customRankId); - } - public boolean isAtLeast(Rank pRank) { return rank.isAtLeast(pRank); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java b/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java index 9e2c4e6b6..8a052dc69 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java @@ -3,6 +3,7 @@ import com.google.common.base.Function; import com.google.common.collect.Maps; import com.google.common.collect.Sets; +import com.google.gson.reflect.TypeToken; import java.util.ArrayList; import java.util.Date; import java.util.List; @@ -19,14 +20,16 @@ import me.totalfreedom.totalfreedommod.sql.adapter.AdminRepository; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FUtil; +import me.totalfreedom.totalfreedommod.util.JsonUtil; import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; import java.io.IOException; +import java.lang.reflect.Type; import org.bukkit.Bukkit; import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; import org.bukkit.command.CommandSender; -import org.bukkit.configuration.ConfigurationSection; -import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; @@ -37,7 +40,9 @@ public class AdminList extends FreedomService { - public static final String CONFIG_FILENAME = "admins.yml"; + public static final String CONFIG_FILENAME = "admins.json"; + + private static final Type ADMIN_MAP_TYPE = new TypeToken>() {}.getType(); private static final long LAST_LOGIN_DEBOUNCE_MS = 5L * 60L * 1000L; @@ -46,7 +51,7 @@ public class AdminList extends FreedomService // Only active admins below @Getter private final Set activeAdmins = Sets.newHashSet(); - + // UUID-based lookup table private final Map uuidTable = Maps.newHashMap(); private final Map nameTable = Maps.newHashMap(); @@ -54,8 +59,7 @@ public class AdminList extends FreedomService private final Set onlineAdminPlayers = Sets.newHashSet(); // private final File configFile; - private YamlConfiguration config; - + // Flag to track if SQL is available private boolean usingSql = false; private final Object persistenceLock = new Object(); @@ -66,7 +70,6 @@ public AdminList(TotalFreedomMod plugin) super(plugin); this.configFile = new File(plugin.getDataFolder(), CONFIG_FILENAME); - this.config = YamlConfiguration.loadConfiguration(configFile); } @Override @@ -101,7 +104,7 @@ public void load() } else { - loadFromYaml(); + loadFromJson(); } if (ConfigEntry.ADMINLIST_USE_UUID_ONLY.getBoolean()) @@ -177,11 +180,87 @@ private void loadFromSql() usingSql = true; updateTables(); FLog.info("Loaded " + allAdmins.size() + " admins from SQL database (" + nameTable.size() + " active, " + ipTable.size() + " IPs)"); + + reconcileFromJsonIfNewer(repo); } catch (Exception ex) { - FLog.warning("Failed to load admins from SQL, falling back to YAML: " + ex.getMessage()); - loadFromYaml(); + FLog.warning("Failed to load admins from SQL, falling back to JSON: " + ex.getMessage()); + loadFromJson(); + } + } + + /** + * If admins.json was written more recently than the database's last update (e.g. edited + * by hand, or restored from backup while SQL was unavailable), re-import it into SQL. + */ + private void reconcileFromJsonIfNewer(AdminRepository repo) + { + if (!configFile.exists()) + { + return; + } + + try + { + Long sqlUpdatedAt = repo.getMaxUpdatedAt(); + if (sqlUpdatedAt != null && configFile.lastModified() <= sqlUpdatedAt) + { + return; + } + + Map jsonAdmins = readJsonAdmins(); + if (jsonAdmins.isEmpty()) + { + return; + } + + FLog.info("admins.json is newer than the database; re-importing " + jsonAdmins.size() + " admin(s) from it."); + for (Admin admin : jsonAdmins.values()) + { + if (!admin.isValid()) + { + continue; + } + UUID uuid = resolveUuid(admin); + admin.setUuid(uuid); + repo.save(uuid, admin).block(); + } + + allAdmins.clear(); + allAdmins.putAll(jsonAdmins); + updateTables(); + } + catch (Exception ex) + { + FLog.warning("Failed to reconcile " + CONFIG_FILENAME + " into the database: " + ex.getMessage()); + } + } + + /** + * Resolve a UUID for an admin missing one: Mojang lookup by name, falling back to an + * offline-derived UUID. + */ + private UUID resolveUuid(Admin admin) + { + if (admin.getUuid() != null) + { + return admin.getUuid(); + } + UUID uuid = FUtil.usernameToUuid(admin.getName()); + if (uuid == null) + { + uuid = UUID.nameUUIDFromBytes(("OfflinePlayer:" + admin.getName().toLowerCase()).getBytes(StandardCharsets.UTF_8)); + } + return uuid; + } + + private Map readJsonAdmins() throws IOException + { + try (FileReader reader = new FileReader(configFile)) + { + Map admins = JsonUtil.GSON.fromJson(reader, ADMIN_MAP_TYPE); + return admins != null ? admins : Maps.newHashMap(); } } @@ -209,9 +288,9 @@ private Admin fixConfigKey(Admin admin, String key) } /** - * Load admins from YAML file (fallback). + * Load admins from JSON file (fallback). */ - private void loadFromYaml() + private void loadFromJson() { if (!configFile.exists()) { @@ -225,33 +304,29 @@ private void loadFromYaml() FLog.severe("Could not create " + CONFIG_FILENAME); } } - config = YamlConfiguration.loadConfiguration(configFile); allAdmins.clear(); - for (String key : config.getKeys(false)) + try { - ConfigurationSection section = config.getConfigurationSection(key); - if (section == null) + for (Map.Entry entry : readJsonAdmins().entrySet()) { - FLog.warning("Invalid admin list format: " + key); - continue; - } - - Admin admin = new Admin(key); - admin.loadFrom(section); - - if (!admin.isValid()) - { - FLog.warning("Could not load admin: " + key + ". Missing details!"); - continue; + Admin admin = entry.getValue(); + if (admin == null || !admin.isValid()) + { + FLog.warning("Could not load admin: " + entry.getKey() + ". Missing details!"); + continue; + } + allAdmins.put(entry.getKey(), admin); } - - allAdmins.put(key, admin); + } + catch (IOException ex) + { + FLog.severe("Could not read " + CONFIG_FILENAME + ": " + ex.getMessage()); } usingSql = false; updateTables(); - FLog.info("Loaded " + allAdmins.size() + " admins from YAML (" + nameTable.size() + " active, " + ipTable.size() + " IPs)"); + FLog.info("Loaded " + allAdmins.size() + " admins from JSON (" + nameTable.size() + " active, " + ipTable.size() + " IPs)"); } public synchronized void save() @@ -262,7 +337,7 @@ public synchronized void save() } else { - saveToYaml(); + saveToJson(); } } @@ -282,7 +357,7 @@ public void saveAsync() if (!plugin.isEnabled()) { - saveToYaml(); + saveToJson(); return; } @@ -290,7 +365,7 @@ public void saveAsync() { synchronized (AdminList.this) { - saveToYaml(); + saveToJson(); } }); } @@ -339,6 +414,7 @@ public void saveAdminAsync(Admin admin) FLog.warning("Failed to save admin " + snapshot.getName() + " to SQL: " + ex.getMessage()); return Mono.empty(); }) + .then(Mono.fromRunnable(this::saveToJson)) .then() .cache(); persistenceChain.subscribe(); @@ -367,7 +443,7 @@ private void saveToSql() if (plugin.dm == null || !plugin.dm.isInitialized()) { FLog.warning("SQL not available, falling back to YAML save"); - saveToYaml(); + saveToJson(); return; } @@ -390,34 +466,23 @@ private void saveToSql() repo.save(uuid, admin).block(); } FLog.debug("Saved " + allAdmins.size() + " admins to SQL database"); + saveToJson(); } catch (Exception ex) { FLog.warning("Failed to save admins to SQL: " + ex.getMessage()); - // Don't fall back to YAML here - we don't want to create conflicting data + // Don't fall back to JSON here - we don't want to create conflicting data } } /** - * Save all admins to YAML file (fallback). + * Save all admins to the JSON file (fallback, and the write-through snapshot when using SQL). */ - private void saveToYaml() + private void saveToJson() { - // Clear the config - for (String key : config.getKeys(false)) + try (FileWriter writer = new FileWriter(configFile)) { - config.set(key, null); - } - - for (Admin admin : allAdmins.values()) - { - ConfigurationSection section = config.createSection(admin.getConfigKey()); - admin.saveTo(section); - } - - try - { - config.save(configFile); + JsonUtil.GSON.toJson(allAdmins, ADMIN_MAP_TYPE, writer); } catch (IOException ex) { @@ -617,15 +682,7 @@ public boolean addAdmin(Admin admin) } else { - admin.saveTo(config.createSection(key)); - try - { - config.save(configFile); - } - catch (IOException ex) - { - FLog.severe("Could not save " + CONFIG_FILENAME); - } + saveToJson(); } refreshWorldEditBypassForAdmin(admin); @@ -656,15 +713,7 @@ public boolean removeAdmin(Admin admin) } else { - config.set(admin.getConfigKey(), null); - try - { - config.save(configFile); - } - catch (IOException ex) - { - FLog.severe("Could not save " + CONFIG_FILENAME); - } + saveToJson(); } refreshWorldEditBypassForAdmin(admin); @@ -735,6 +784,8 @@ private void removeAdminFromSql(Admin admin) FLog.warning("Failed to remove admin " + name + " from SQL: " + ex.getMessage()); return Mono.empty(); }) + .then(Mono.fromRunnable(this::saveToJson)) + .then() .cache(); persistenceChain.subscribe(); } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/Ban.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/Ban.java index 51cee87a6..1cf8b2fa0 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/Ban.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/Ban.java @@ -9,11 +9,8 @@ import java.util.List; import java.util.Set; import java.util.UUID; -import lombok.Getter; -import lombok.Setter; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.util.ConfigInterfaces.ConfigLoadable; -import me.totalfreedom.totalfreedommod.util.ConfigInterfaces.ConfigSavable; import me.totalfreedom.totalfreedommod.util.ConfigInterfaces.Validatable; import me.totalfreedom.totalfreedommod.util.FUtil; import net.kyori.adventure.text.Component; @@ -22,46 +19,96 @@ import org.bukkit.configuration.ConfigurationSection; import org.bukkit.entity.Player; -public class Ban implements ConfigLoadable, ConfigSavable, Validatable +public class Ban implements ConfigLoadable, Validatable { public static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd \'at\' HH:mm:ss z"); // UUID support for SQL storage - @Getter - @Setter private UUID uuid = null; - - @Getter - @Setter private UUID bannedByUuid = null; - - @Getter - @Setter private String username = null; - @Getter private final List ips = Lists.newArrayList(); - @Getter - @Setter private String by = null; - @Getter - @Setter private String reason = null; // Unformatted, &[0-9,a-f] instead of ChatColor - @Getter - @Setter private long expiryUnix = -1; + public UUID getUuid() + { + return uuid; + } + + public void setUuid(UUID uuid) + { + this.uuid = uuid; + } + + public UUID getBannedByUuid() + { + return bannedByUuid; + } + + public void setBannedByUuid(UUID bannedByUuid) + { + this.bannedByUuid = bannedByUuid; + } + + public String getUsername() + { + return username; + } + + public void setUsername(String username) + { + this.username = username; + } + + public List getIps() + { + return ips; + } + + public String getBy() + { + return by; + } + + public void setBy(String by) + { + this.by = by; + } + + public String getReason() + { + return reason; + } + + public void setReason(String reason) + { + this.reason = reason; + } + + public long getExpiryUnix() + { + return expiryUnix; + } + + public void setExpiryUnix(long expiryUnix) + { + this.expiryUnix = expiryUnix; + } + // SQL repository alias accessors public String getBannedBy() { return by; } - + public void setBannedBy(String bannedBy) { this.by = bannedBy; } - + public Date getExpireAt() { return expiryUnix > 0 ? FUtil.getUnixDate(expiryUnix) : null; @@ -286,17 +333,6 @@ public void loadFrom(ConfigurationSection cs) dedupeIps(); } - @Override - public void saveTo(ConfigurationSection cs) - { - dedupeIps(); - cs.set("username", username); - cs.set("ips", ips.isEmpty() ? null : ips); - cs.set("by", by); - cs.set("reason", reason); - cs.set("expiry_unix", expiryUnix > 0 ? expiryUnix : null); - } - @Override public boolean isValid() { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java index 611a3219a..d16510c65 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java @@ -3,6 +3,7 @@ import com.google.common.collect.Lists; import com.google.common.collect.Maps; import com.google.common.collect.Sets; +import com.google.gson.reflect.TypeToken; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -17,9 +18,12 @@ import me.totalfreedom.totalfreedommod.sql.adapter.BanRepository; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FUtil; +import me.totalfreedom.totalfreedommod.util.JsonUtil; import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; import java.io.IOException; -import org.bukkit.configuration.file.YamlConfiguration; +import java.lang.reflect.Type; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; @@ -28,6 +32,7 @@ public class BanManager extends FreedomService { + private static final Type BAN_LIST_TYPE = new TypeToken>() {}.getType(); private final Set bans = Sets.newHashSet(); private final Map ipBans = Maps.newHashMap(); @@ -45,7 +50,7 @@ public class BanManager extends FreedomService public BanManager(TotalFreedomMod plugin) { super(plugin); - this.configFile = new File(plugin.getDataFolder(), "bans.yml"); + this.configFile = new File(plugin.getDataFolder(), "bans.json"); } @Override @@ -59,15 +64,15 @@ protected void onStart() } else { - loadFromYaml(); + loadFromJson(); } - + // Load unbannable usernames unbannableUsernames.clear(); unbannableUsernames.addAll((Collection) ConfigEntry.FAMOUS_PLAYERS.getList()); FLog.info("Loaded " + unbannableUsernames.size() + " unbannable usernames."); } - + /** * Load bans from SQL database. */ @@ -86,18 +91,76 @@ private void loadFromSql() updateViews(); FLog.info("Loaded " + ipBans.size() + " IP bans and " + nameBans.size() + " username bans from SQL database."); } + + reconcileFromJsonIfNewer(repo); } catch (Exception ex) { - FLog.warning("Failed to load bans from SQL, falling back to YAML: " + ex.getMessage()); - loadFromYaml(); + FLog.warning("Failed to load bans from SQL, falling back to JSON: " + ex.getMessage()); + loadFromJson(); } } - + /** - * Load bans from YAML file (fallback). + * If bans.json was written more recently than the database's last update, re-import it into SQL. */ - private void loadFromYaml() + private void reconcileFromJsonIfNewer(BanRepository repo) + { + if (!configFile.exists()) + { + return; + } + + try + { + Long sqlUpdatedAt = repo.getMaxUpdatedAt(); + if (sqlUpdatedAt != null && configFile.lastModified() <= sqlUpdatedAt) + { + return; + } + + List jsonBans = readJsonBans(); + if (jsonBans.isEmpty()) + { + return; + } + + FLog.info("bans.json is newer than the database; re-importing " + jsonBans.size() + " ban(s) from it."); + for (Ban ban : jsonBans) + { + if (!ban.isValid()) + { + continue; + } + repo.save(ban).block(); + } + + synchronized (lock) + { + bans.clear(); + bans.addAll(jsonBans); + updateViews(); + } + } + catch (Exception ex) + { + FLog.warning("Failed to reconcile bans.json into the database: " + ex.getMessage()); + } + } + + private List readJsonBans() throws IOException + { + try (FileReader reader = new FileReader(configFile)) + { + List loaded = JsonUtil.GSON.fromJson(reader, BAN_LIST_TYPE); + return loaded != null ? loaded : new ArrayList<>(); + } + } + + /** + * Load bans from the JSON file (fallback). + */ + private void loadFromJson() { if (!configFile.exists()) { @@ -108,37 +171,33 @@ private void loadFromYaml() } catch (IOException ex) { - FLog.severe("Could not create bans.yml"); + FLog.severe("Could not create bans.json"); } } - final YamlConfiguration loaded = YamlConfiguration.loadConfiguration(configFile); synchronized (lock) { bans.clear(); - for (String id : loaded.getKeys(false)) + try { - if (!loaded.isConfigurationSection(id)) - { - FLog.warning("Could not load username ban: " + id + ". Invalid format!"); - continue; - } - - Ban ban = new Ban(); - ban.loadFrom(loaded.getConfigurationSection(id)); - - if (!ban.isValid()) + for (Ban ban : readJsonBans()) { - FLog.warning("Not adding username ban: " + id + ". Missing information."); - continue; + if (!ban.isValid()) + { + FLog.warning("Not adding username ban: " + ban.getUsername() + ". Missing information."); + continue; + } + bans.add(ban); } - - bans.add(ban); + } + catch (IOException ex) + { + FLog.severe("Could not read bans.json: " + ex.getMessage()); } usingSql = false; updateViews(); - FLog.info("Loaded " + ipBans.size() + " IP bans and " + nameBans.size() + " username bans from YAML."); + FLog.info("Loaded " + ipBans.size() + " IP bans and " + nameBans.size() + " username bans from JSON."); } } @@ -183,7 +242,7 @@ public void saveAll() } else { - writeAllToYaml(snapshot); + writeAllToJson(snapshot); } } } @@ -225,8 +284,8 @@ private void writeAllToSql(List snapshot) { if (plugin.dm == null || !plugin.dm.isInitialized()) { - FLog.warning("SQL not available, falling back to YAML save"); - writeAllToYaml(snapshot); + FLog.warning("SQL not available, falling back to JSON save"); + writeAllToJson(snapshot); return; } @@ -240,6 +299,7 @@ private void writeAllToSql(List snapshot) repo.save(ban).block(); } FLog.debug("Saved " + snapshot.size() + " bans to SQL database"); + writeAllToJson(snapshot); } catch (Exception ex) { @@ -248,23 +308,18 @@ private void writeAllToSql(List snapshot) } /** - * Write the given snapshot of bans to the YAML file. Must be called under persistenceLock. + * Write the given snapshot of bans to the JSON file (fallback, and the write-through + * snapshot when using SQL). Must be called under persistenceLock. */ - private void writeAllToYaml(List snapshot) + private void writeAllToJson(List snapshot) { - final YamlConfiguration out = new YamlConfiguration(); - for (Ban ban : snapshot) + try (FileWriter writer = new FileWriter(configFile)) { - ban.saveTo(out.createSection(String.valueOf(ban.hashCode()))); - } - - try - { - out.save(configFile); + JsonUtil.GSON.toJson(snapshot, BAN_LIST_TYPE, writer); } catch (IOException ex) { - FLog.severe("Could not save bans.yml"); + FLog.severe("Could not save bans.json"); } } @@ -457,6 +512,7 @@ private void saveBanToSql(Ban ban) try { plugin.dm.getBanRepository().save(ban).block(); + writeAllToJson(currentBansSnapshot()); } catch (Exception ex) { @@ -487,6 +543,7 @@ else if (ban.hasUsername()) { plugin.dm.getBanRepository().deleteByUsername(ban.getUsername()); } + writeAllToJson(currentBansSnapshot()); } catch (Exception ex) { @@ -580,6 +637,14 @@ public void onPlayerJoin(PlayerJoinEvent event) player.setOp(true); } + private List currentBansSnapshot() + { + synchronized (lock) + { + return new ArrayList<>(bans); + } + } + // Must be called while holding 'lock'. private void updateViews() { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java index 9c7f61526..7c1a48001 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java @@ -2,6 +2,7 @@ import com.google.common.collect.Maps; import com.google.common.collect.Sets; +import com.google.gson.reflect.TypeToken; import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -13,11 +14,14 @@ import me.totalfreedom.totalfreedommod.sql.adapter.PermbanRepository; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FUtil; +import me.totalfreedom.totalfreedommod.util.JsonUtil; import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; import java.io.IOException; +import java.lang.reflect.Type; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; -import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.player.AsyncPlayerPreLoginEvent; @@ -25,13 +29,16 @@ public class PermbanList extends FreedomService { - public static final String CONFIG_FILENAME = "permbans.yml"; + public static final String CONFIG_FILENAME = "permbans.json"; + + private static final Type PERMBAN_MAP_TYPE = new TypeToken>() {}.getType(); private final Set permbannedNames = Sets.newHashSet(); private final Set permbannedIps = Sets.newHashSet(); - + // Store full PermBan objects for SQL operations private final Map permbansByName = Maps.newHashMap(); + private final File configFile; private final Object lock = new Object(); private final Object persistenceLock = new Object(); @@ -41,6 +48,7 @@ public class PermbanList extends FreedomService public PermbanList(TotalFreedomMod plugin) { super(plugin); + this.configFile = new File(plugin.getDataFolder(), CONFIG_FILENAME); } @Override @@ -53,10 +61,10 @@ protected void onStart() } else { - loadFromYaml(); + loadFromJson(); } } - + /** * Load permbans from SQL database. */ @@ -84,20 +92,80 @@ private void loadFromSql() usingSql = true; FLog.info("Loaded " + permbannedIps.size() + " perm IP bans and " + permbannedNames.size() + " perm username bans from SQL database."); } + + reconcileFromJsonIfNewer(repo); } catch (Exception ex) { - FLog.warning("Failed to load permbans from SQL, falling back to YAML: " + ex.getMessage()); - loadFromYaml(); + FLog.warning("Failed to load permbans from SQL, falling back to JSON: " + ex.getMessage()); + loadFromJson(); } } - + /** - * Load permbans from YAML file (fallback). + * If permbans.json was written more recently than the database's last update, re-import it into SQL. */ - private void loadFromYaml() + private void reconcileFromJsonIfNewer(PermbanRepository repo) + { + if (!configFile.exists()) + { + return; + } + + try + { + Long sqlUpdatedAt = repo.getMaxUpdatedAt(); + if (sqlUpdatedAt != null && configFile.lastModified() <= sqlUpdatedAt) + { + return; + } + + Map jsonPermbans = readJsonPermbans(); + if (jsonPermbans.isEmpty()) + { + return; + } + + FLog.info("permbans.json is newer than the database; re-importing " + jsonPermbans.size() + " permban(s) from it."); + for (PermBan permban : jsonPermbans.values()) + { + repo.save(permban).block(); + } + + synchronized (lock) + { + permbannedNames.clear(); + permbannedIps.clear(); + permbansByName.clear(); + for (PermBan permban : jsonPermbans.values()) + { + String name = permban.getUsername().toLowerCase().trim(); + permbannedNames.add(name); + permbannedIps.addAll(permban.getIps()); + permbansByName.put(name, permban); + } + } + } + catch (Exception ex) + { + FLog.warning("Failed to reconcile permbans.json into the database: " + ex.getMessage()); + } + } + + private Map readJsonPermbans() throws IOException + { + try (FileReader reader = new FileReader(configFile)) + { + Map loaded = JsonUtil.GSON.fromJson(reader, PERMBAN_MAP_TYPE); + return loaded != null ? loaded : Maps.newHashMap(); + } + } + + /** + * Load permbans from the JSON file (fallback). + */ + private void loadFromJson() { - final File configFile = new File(plugin.getDataFolder(), CONFIG_FILENAME); if (!configFile.exists()) { try @@ -110,7 +178,6 @@ private void loadFromYaml() FLog.severe("Could not create " + CONFIG_FILENAME); } } - final YamlConfiguration config = YamlConfiguration.loadConfiguration(configFile); synchronized (lock) { @@ -118,35 +185,57 @@ private void loadFromYaml() permbannedIps.clear(); permbansByName.clear(); - for (String name : config.getKeys(false)) + try { - String lowerName = name.toLowerCase().trim(); - permbannedNames.add(lowerName); - List ips = config.getStringList(name); - permbannedIps.addAll(ips); - - // Create PermBan object - PermBan permban = new PermBan(); - permban.setUsername(lowerName); - permban.setIps(ips); - permbansByName.put(lowerName, permban); + for (Map.Entry entry : readJsonPermbans().entrySet()) + { + PermBan permban = entry.getValue(); + permbannedNames.add(entry.getKey()); + permbannedIps.addAll(permban.getIps()); + permbansByName.put(entry.getKey(), permban); + } + } + catch (IOException ex) + { + FLog.severe("Could not read " + CONFIG_FILENAME + ": " + ex.getMessage()); } usingSql = false; - FLog.info("Loaded " + permbannedIps.size() + " perm IP bans and " + permbannedNames.size() + " perm username bans from YAML."); + FLog.info("Loaded " + permbannedIps.size() + " perm IP bans and " + permbannedNames.size() + " perm username bans from JSON."); + } + } + + private void saveToJson() + { + final Map snapshot; + synchronized (lock) + { + snapshot = Maps.newHashMap(permbansByName); + } + + try (FileWriter writer = new FileWriter(configFile)) + { + JsonUtil.GSON.toJson(snapshot, PERMBAN_MAP_TYPE, writer); + } + catch (IOException ex) + { + FLog.severe("Could not save " + CONFIG_FILENAME); } } @Override protected void onStop() { - // Save if using SQL if (usingSql) { saveAllToSql(); } + else + { + saveToJson(); + } } - + /** * Save all permbans to SQL database. */ @@ -173,6 +262,7 @@ private void saveAllToSql() repo.save(permban).block(); } FLog.debug("Saved " + snapshot.size() + " permbans to SQL database"); + saveToJson(); } catch (Exception ex) { @@ -206,6 +296,10 @@ public void addPermban(PermBan permban) { savePermbanToSqlAsync(permban); } + else + { + saveToJson(); + } } /** @@ -234,6 +328,10 @@ public boolean removePermban(String username) { removePermbanFromSqlAsync(name); } + else + { + saveToJson(); + } return true; } @@ -287,6 +385,10 @@ public List removePermbansByIp(String ip) removePermbanFromSqlAsync(name.toLowerCase().trim()); } } + else if (!removedNames.isEmpty()) + { + saveToJson(); + } return removedNames; } @@ -349,6 +451,7 @@ private void savePermbanToSql(PermBan permban) try { plugin.dm.getPermbanRepository().save(permban).block(); + saveToJson(); } catch (Exception ex) { @@ -382,6 +485,7 @@ private void removePermbanFromSql(String name) try { plugin.dm.getPermbanRepository().deleteByUsername(name); + saveToJson(); } catch (Exception ex) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java index aeccfddb7..5cfb85cda 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java @@ -1,8 +1,12 @@ package me.totalfreedom.totalfreedommod.banning; import com.google.common.collect.Maps; +import com.google.gson.reflect.TypeToken; import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; import java.io.IOException; +import java.lang.reflect.Type; import java.util.Collections; import java.util.HashMap; import java.util.Iterator; @@ -12,23 +16,23 @@ import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.sql.adapter.StrikeRepository; import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.JsonUtil; import org.bukkit.configuration.ConfigurationSection; -import org.bukkit.configuration.file.YamlConfiguration; +import reactor.core.publisher.Mono; public class StrikeList extends FreedomService { + private static final Type STRIKE_MAP_TYPE = new TypeToken>() {}.getType(); private final Map strikes = Maps.newHashMap(); private final File configFile; - private YamlConfiguration config; private boolean usingSql = false; private boolean persistEnabled = true; public StrikeList(TotalFreedomMod plugin) { super(plugin); - this.configFile = new File(plugin.getDataFolder(), "strikes.yml"); - this.config = YamlConfiguration.loadConfiguration(configFile); + this.configFile = new File(plugin.getDataFolder(), "strikes.json"); } @Override @@ -52,7 +56,7 @@ protected void onStart() } else { - loadFromYaml(); + loadFromJson(); } pruneDecayed(); @@ -68,7 +72,7 @@ protected void onStop() } if (!usingSql) { - saveAllToYaml(); + saveToJson(); } } @@ -80,15 +84,65 @@ private void loadFromSql() Map loaded = repo.loadAllAsync().block(); strikes.putAll(loaded); usingSql = true; + + reconcileFromJsonIfNewer(repo); } catch (Exception ex) { - FLog.warning("Failed to load strikes from SQL, falling back to YAML: " + ex.getMessage()); - loadFromYaml(); + FLog.warning("Failed to load strikes from SQL, falling back to JSON: " + ex.getMessage()); + loadFromJson(); } } - private void loadFromYaml() + /** + * If strikes.json was written more recently than the database's last update, re-import it into SQL. + */ + private void reconcileFromJsonIfNewer(StrikeRepository repo) + { + if (!configFile.exists()) + { + return; + } + + try + { + Long sqlUpdatedAt = repo.getMaxUpdatedAt(); + if (sqlUpdatedAt != null && configFile.lastModified() <= sqlUpdatedAt) + { + return; + } + + Map jsonStrikes = readJsonStrikes(); + if (jsonStrikes.isEmpty()) + { + return; + } + + FLog.info("strikes.json is newer than the database; re-importing " + jsonStrikes.size() + " strike record(s) from it."); + for (StrikeRecord r : jsonStrikes.values()) + { + repo.upsertAsync(r).block(); + } + + strikes.clear(); + strikes.putAll(jsonStrikes); + } + catch (Exception ex) + { + FLog.warning("Failed to reconcile strikes.json into the database: " + ex.getMessage()); + } + } + + private Map readJsonStrikes() throws IOException + { + try (FileReader reader = new FileReader(configFile)) + { + Map loaded = JsonUtil.GSON.fromJson(reader, STRIKE_MAP_TYPE); + return loaded != null ? loaded : Maps.newHashMap(); + } + } + + private void loadFromJson() { if (!configFile.exists()) { @@ -99,21 +153,17 @@ private void loadFromYaml() } catch (IOException ex) { - FLog.severe("Could not create strikes.yml"); + FLog.severe("Could not create strikes.json"); } } - config = YamlConfiguration.loadConfiguration(configFile); - for (String ip : config.getKeys(false)) + try { - if (!config.isConfigurationSection(ip)) - { - continue; - } - ConfigurationSection cs = config.getConfigurationSection(ip); - StrikeRecord r = new StrikeRecord(ip); - r.loadFrom(cs); - strikes.put(ip, r); + strikes.putAll(readJsonStrikes()); + } + catch (IOException ex) + { + FLog.severe("Could not read strikes.json: " + ex.getMessage()); } usingSql = false; } @@ -136,6 +186,7 @@ private void pruneDecayed() if (usingSql && plugin.dm != null && plugin.dm.isInitialized()) { plugin.dm.getStrikeRepository().deleteByIpAsync(e.getKey()) + .then(Mono.fromRunnable(this::saveToJson)) .subscribe(deleted -> {}, ex -> FLog.warning("Failed to prune decayed strike for " + e.getKey() + ": " + ex.getMessage())); } @@ -143,7 +194,7 @@ private void pruneDecayed() } if (removed > 0 && !usingSql) { - saveAllToYamlAsync(); + saveToJsonAsync(); } if (removed > 0) { @@ -209,12 +260,13 @@ public synchronized boolean clear(String ip) if (usingSql && plugin.dm != null && plugin.dm.isInitialized()) { plugin.dm.getStrikeRepository().deleteByIpAsync(ip) + .then(Mono.fromRunnable(this::saveToJson)) .subscribe(deleted -> {}, ex -> FLog.warning("Failed to clear strike from SQL: " + ex.getMessage())); } else { - saveAllToYamlAsync(); + saveToJsonAsync(); } return true; } @@ -229,42 +281,35 @@ private void persist(StrikeRecord r) if (usingSql && plugin.dm != null && plugin.dm.isInitialized()) { plugin.dm.getStrikeRepository().upsertAsync(r) + .then(Mono.fromRunnable(this::saveToJson)) .subscribe(null, ex -> FLog.warning("Failed to persist strike to SQL: " + ex.getMessage())); } else { - saveAllToYamlAsync(); + saveToJsonAsync(); } } - private void saveAllToYamlAsync() + private void saveToJsonAsync() { if (!plugin.isEnabled()) { - saveAllToYaml(); + saveToJson(); return; } - plugin.getServer().getScheduler().runTaskAsynchronously(plugin, this::saveAllToYaml); + plugin.getServer().getScheduler().runTaskAsynchronously(plugin, this::saveToJson); } - private synchronized void saveAllToYaml() + private synchronized void saveToJson() { - for (String key : config.getKeys(false)) - { - config.set(key, null); - } - for (StrikeRecord r : strikes.values()) - { - r.saveTo(config.createSection(r.getIp())); - } - try + try (FileWriter writer = new FileWriter(configFile)) { - config.save(configFile); + JsonUtil.GSON.toJson(strikes, STRIKE_MAP_TYPE, writer); } catch (IOException ex) { - FLog.severe("Could not save strikes.yml"); + FLog.severe("Could not save strikes.json"); } } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeRecord.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeRecord.java index 3c111226a..cd73f284a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeRecord.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeRecord.java @@ -1,22 +1,13 @@ package me.totalfreedom.totalfreedommod.banning; -import lombok.Getter; -import lombok.Setter; import org.bukkit.configuration.ConfigurationSection; public class StrikeRecord { - @Getter private final String ip; - @Getter - @Setter private int count; - @Getter - @Setter private long lastStrikeUnix; - @Getter - @Setter private String lastUsername; public StrikeRecord(String ip) @@ -24,6 +15,41 @@ public StrikeRecord(String ip) this.ip = ip; } + public String getIp() + { + return ip; + } + + public int getCount() + { + return count; + } + + public void setCount(int count) + { + this.count = count; + } + + public long getLastStrikeUnix() + { + return lastStrikeUnix; + } + + public void setLastStrikeUnix(long lastStrikeUnix) + { + this.lastStrikeUnix = lastStrikeUnix; + } + + public String getLastUsername() + { + return lastUsername; + } + + public void setLastUsername(String lastUsername) + { + this.lastUsername = lastUsername; + } + public StrikeRecord(String ip, int count, long lastStrikeUnix, String lastUsername) { this.ip = ip; @@ -53,10 +79,4 @@ public void loadFrom(ConfigurationSection cs) this.lastUsername = cs.getString("last_username", null); } - public void saveTo(ConfigurationSection cs) - { - cs.set("count", count); - cs.set("last_strike_unix", lastStrikeUnix); - cs.set("last_username", lastUsername); - } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordBridge.java b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordBridge.java index f3da91b9a..6f0f46c8e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordBridge.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordBridge.java @@ -69,6 +69,11 @@ protected void onStart() return; } + if (plugin.dm != null && plugin.dm.isInitialized()) + { + DiscordLinkJsonSync.reconcileFromJsonIfNewer(plugin, plugin.dm.getDiscordLinkRepository()); + } + String token = ConfigEntry.DISCORD_TOKEN.getString(); if (token == null || token.isBlank()) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordCommands.java b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordCommands.java index 8a829e3a1..803f03d8f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordCommands.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordCommands.java @@ -92,6 +92,8 @@ private void handleLink(SlashCommandInteractionEvent event) return; } + DiscordLinkJsonSync.writeSnapshot(plugin, repo); + event.reply("Linked as **" + admin.getName() + "** (" + admin.getRank().getName() + ").") .setEphemeral(true).queue(); FLog.info("[Discord] Linked admin " + admin.getName() + " ↔ Discord user " + event.getUser().getId() + "."); @@ -115,6 +117,7 @@ private void handleUnlink(SlashCommandInteractionEvent event) } if (removed) { + DiscordLinkJsonSync.writeSnapshot(plugin, repo); event.reply("Link removed.").setEphemeral(true).queue(); FLog.info("[Discord] Unlinked Discord user " + discordUserId + "."); } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordLinkJsonSync.java b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordLinkJsonSync.java new file mode 100644 index 000000000..4cb843301 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordLinkJsonSync.java @@ -0,0 +1,96 @@ +package me.totalfreedom.totalfreedommod.discord; + +import com.google.gson.reflect.TypeToken; +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.lang.reflect.Type; +import java.util.Map; +import java.util.UUID; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.sql.adapter.DiscordLinkRepository; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.JsonUtil; + +/** + * JSON write-through + startup reconciliation for admin-uuid to Discord-user-id links. + * There is no in-memory manager for this domain (DiscordCommands talks to the repository + * directly), so this holds the snapshot file logic on its own. + */ +final class DiscordLinkJsonSync +{ + static final String DATA_FILENAME = "discord_links.json"; + + private static final Type LINKS_MAP_TYPE = new TypeToken>() {}.getType(); + + private DiscordLinkJsonSync() + { + } + + /** + * Rewrites discord_links.json from the database's current state. Call after any + * successful link/unlink write. + */ + static void writeSnapshot(TotalFreedomMod plugin, DiscordLinkRepository repo) + { + try + { + Map links = repo.loadAll(); + File file = new File(plugin.getDataFolder(), DATA_FILENAME); + try (FileWriter writer = new FileWriter(file)) + { + JsonUtil.GSON.toJson(links, LINKS_MAP_TYPE, writer); + } + } + catch (Exception ex) + { + FLog.severe("Failed to save " + DATA_FILENAME + ": " + ex.getMessage()); + } + } + + /** + * If discord_links.json was written more recently than the database's last update, re-import it into SQL. + */ + static void reconcileFromJsonIfNewer(TotalFreedomMod plugin, DiscordLinkRepository repo) + { + File file = new File(plugin.getDataFolder(), DATA_FILENAME); + if (!file.exists()) + { + return; + } + + try + { + Long sqlUpdatedAt = repo.getMaxUpdatedAt(); + if (sqlUpdatedAt != null && file.lastModified() <= sqlUpdatedAt) + { + return; + } + + Map jsonLinks; + try (FileReader reader = new FileReader(file)) + { + Map loaded = JsonUtil.GSON.fromJson(reader, LINKS_MAP_TYPE); + jsonLinks = loaded != null ? loaded : Map.of(); + } + + if (jsonLinks.isEmpty()) + { + return; + } + + FLog.info(DATA_FILENAME + " is newer than the database; re-importing " + jsonLinks.size() + " discord link(s) from it."); + for (Map.Entry entry : jsonLinks.entrySet()) + { + UUID adminUuid = UUID.fromString(entry.getKey()); + repo.deleteByAdminUuid(adminUuid); + repo.deleteByDiscordUserId(entry.getValue()); + repo.insert(adminUuid, entry.getValue()); + } + } + catch (Exception ex) + { + FLog.warning("Failed to reconcile " + DATA_FILENAME + " into the database: " + ex.getMessage()); + } + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerData.java b/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerData.java index 41e5fb711..714eb4f49 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerData.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerData.java @@ -1,6 +1,5 @@ package me.totalfreedom.totalfreedommod.player; -import com.google.common.base.Preconditions; import com.google.common.collect.Lists; import java.util.Collections; import java.util.List; @@ -8,7 +7,6 @@ import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.util.AdventureUtil; import me.totalfreedom.totalfreedommod.util.ConfigInterfaces.ConfigLoadable; -import me.totalfreedom.totalfreedommod.util.ConfigInterfaces.ConfigSavable; import me.totalfreedom.totalfreedommod.util.ConfigInterfaces.Validatable; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; @@ -17,7 +15,7 @@ import org.bukkit.configuration.ConfigurationSection; import org.bukkit.entity.Player; -public class PlayerData implements ConfigLoadable, ConfigSavable, Validatable +public class PlayerData implements ConfigLoadable, Validatable { public static final int MAX_STRIKES = 3; // max number of IP addresses retained per player @@ -165,25 +163,6 @@ public void loadFrom(ConfigurationSection cs) } } - @Override - public void saveTo(ConfigurationSection cs) - { - Preconditions.checkArgument(isValid(), "Could not save player entry: " + username + ". Entry not valid!"); - cs.set("username", username); - cs.set("ips", ips); - cs.set("first_join", firstJoinUnix); - cs.set("last_join", lastJoinUnix); - cs.set("potion_spy", potionSpy); - cs.set("command_spy", isCommandSpy()); - cs.set("command_spy_mode", commandSpyMode.getName()); - cs.set("muted", muted); - cs.set("frozen", frozen); - cs.set("commands_blocked", commandsBlocked); - cs.set("strikes", strikes); - cs.set("saved_tag", savedTag); - cs.set("nickname", nickname != null ? AdventureUtil.componentToLegacy(nickname) : null); - } - public boolean isCommandSpy() { return commandSpyMode != CommandSpyMode.OFF; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerList.java b/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerList.java index 1db587bf1..a2bbe0745 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerList.java @@ -2,14 +2,17 @@ import com.google.common.collect.Maps; import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; import java.util.Arrays; import java.util.Collection; import java.util.Map; -import lombok.Getter; import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.sql.adapter.PlayerRepository; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FUtil; +import me.totalfreedom.totalfreedommod.util.JsonUtil; import java.io.IOException; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; @@ -27,13 +30,20 @@ public class PlayerList extends FreedomService public static final long AUTO_PURGE_TICKS = 20L * 60L * 5L; // - @Getter public final Map playerMap = Maps.newHashMap(); // key: lowercase username - @Getter public final Map dataMap = Maps.newHashMap(); // key: lowercase username private final File configFolder; - - // Manual getter - Lombok @Getter not processing reliably + + public Map getPlayerMap() + { + return playerMap; + } + + public Map getDataMap() + { + return dataMap; + } + public File getConfigFolder() { return configFolder; @@ -46,6 +56,11 @@ public PlayerList(TotalFreedomMod plugin) this.configFolder = new File(plugin.getDataFolder(), "players"); } + private boolean usingSql() + { + return plugin.dm != null && plugin.dm.isInitialized(); + } + @Override protected void onStart() { @@ -92,11 +107,27 @@ public void saveAsync() private void saveOne(PlayerData data) { - final YamlConfiguration config = getConfig(data); - data.saveTo(config); - try + if (usingSql()) + { + try + { + plugin.dm.getPlayerRepository().save(data).block(); + } + catch (Exception ex) + { + FLog.severe("Could not save player data for " + data.getUsername() + " to SQL: " + ex.getMessage()); + } + } + + saveToJson(data); + } + + private void saveToJson(PlayerData data) + { + final File configFile = getConfigFile(data.getUsername().toLowerCase()); + try (FileWriter writer = new FileWriter(configFile)) { - config.save(getConfigFile(data.getUsername().toLowerCase())); + JsonUtil.GSON.toJson(data, PlayerData.class, writer); } catch (IOException ex) { @@ -200,16 +231,7 @@ public PlayerData getData(Player player) dataMap.put(player.getName().toLowerCase(), data); // Save player - YamlConfiguration config = getConfig(data); - data.saveTo(config); - try - { - config.save(getConfigFile(data.getUsername().toLowerCase())); - } - catch (IOException ex) - { - FLog.severe("Could not save player data for " + data.getUsername()); - } + saveOne(data); } return data; @@ -220,18 +242,91 @@ public PlayerData getData(String username) { username = username.toLowerCase(); - // Check if the player is a known player + if (usingSql()) + { + PlayerData data = loadFromSql(username); + if (data != null) + { + return data; + } + } + + return loadFromJson(username); + } + + private PlayerData loadFromSql(String username) + { + try + { + PlayerRepository repo = plugin.dm.getPlayerRepository(); + reconcileFromJsonIfNewer(username, repo); + + PlayerData data = repo.findByUsername(username); + if (data == null) + { + return null; + } + + if (Bukkit.getPlayerExact(data.getUsername()) != null) + { + dataMap.put(data.getUsername().toLowerCase(), data); + } + + return data; + } + catch (Exception ex) + { + FLog.warning("Failed to load player data for " + username + " from SQL, falling back to JSON: " + ex.getMessage()); + return null; + } + } + + /** + * If this player's JSON snapshot was written more recently than the database's last update + * for them, re-import it into SQL. + */ + private void reconcileFromJsonIfNewer(String username, PlayerRepository repo) + { + final File jsonFile = getConfigFile(username); + if (!jsonFile.exists()) + { + return; + } + + try + { + Long sqlUpdatedAt = repo.getUpdatedAt(username); + if (sqlUpdatedAt != null && jsonFile.lastModified() <= sqlUpdatedAt) + { + return; + } + + PlayerData jsonData = readJsonPlayer(username); + if (jsonData == null || !jsonData.isValid()) + { + return; + } + + FLog.info(jsonFile.getName() + " is newer than the database; re-importing player data for " + username + " from it."); + repo.saveOrUpdate(jsonData); + } + catch (Exception ex) + { + FLog.warning("Failed to reconcile player data for " + username + " into the database: " + ex.getMessage()); + } + } + + private PlayerData loadFromJson(String username) + { final File configFile = getConfigFile(username); if (!configFile.exists()) { return null; } - // Create and load entry - final PlayerData data = new PlayerData(username); - data.loadFrom(getConfig(data)); + final PlayerData data = readJsonPlayer(username); - if (!data.isValid()) + if (data == null || !data.isValid()) { FLog.warning("Could not load player data entry: " + username + ". Entry is not valid!"); configFile.delete(); @@ -247,13 +342,57 @@ public PlayerData getData(String username) return data; } + private PlayerData readJsonPlayer(String username) + { + final File configFile = getConfigFile(username); + try (FileReader reader = new FileReader(configFile)) + { + return JsonUtil.GSON.fromJson(reader, PlayerData.class); + } + catch (Exception ex) + { + FLog.severe("Could not read player data for " + username + ": " + ex.getMessage()); + return null; + } + } + + /** + * Reads the pre-JSON per-player {@code players/.yml} format. Retained only for the + * one-time legacy-install migration path (not called during normal startup). + */ + private PlayerData loadLegacyYaml(String username) + { + final File legacyFile = new File(getConfigFolder(), username + ".yml"); + if (!legacyFile.exists()) + { + return null; + } + + final YamlConfiguration config = YamlConfiguration.loadConfiguration(legacyFile); + final PlayerData data = new PlayerData(username); + data.loadFrom(config); + return data; + } + public Collection getAllData() { + if (usingSql()) + { + try + { + return plugin.dm.getPlayerRepository().loadAll().values(); + } + catch (Exception ex) + { + FLog.warning("Failed to load all player data from SQL, falling back to JSON: " + ex.getMessage()); + } + } + return Arrays.stream(configFolder.listFiles()) .filter(file -> file != null) .map(File::getName) - .filter(name -> name.endsWith(".yml")) - .map(name -> getData(name.substring(0, name.length() - ".yml".length()))).toList(); + .filter(name -> name.endsWith(".json")) + .map(name -> getData(name.substring(0, name.length() - ".json".length()))).toList(); } @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) @@ -329,19 +468,24 @@ public int purgeAllData() deleted += file.delete() ? 1 : 0; } + if (usingSql()) + { + try + { + plugin.dm.getPlayerRepository().deleteAll().block(); + } + catch (Exception ex) + { + FLog.severe("Could not purge player data from SQL: " + ex.getMessage()); + } + } + dataMap.clear(); return deleted; } protected File getConfigFile(String name) { - return new File(getConfigFolder(), name + ".yml"); - } - - protected YamlConfiguration getConfig(PlayerData data) - { - final File configFile = getConfigFile(data.getUsername().toLowerCase()); - final YamlConfiguration config = YamlConfiguration.loadConfiguration(configFile); - return config; + return new File(getConfigFolder(), name + ".json"); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/rank/CustomRank.java b/src/main/java/me/totalfreedom/totalfreedommod/rank/CustomRank.java index 1b0999baf..2663058fb 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/rank/CustomRank.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/rank/CustomRank.java @@ -2,8 +2,6 @@ import java.util.HashSet; import java.util.Set; -import lombok.Getter; -import lombok.Setter; import me.totalfreedom.totalfreedommod.util.AdventureUtil; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; @@ -14,12 +12,10 @@ * Represents a configurable rank in the TFM permission system. * Unlike the built-in Rank enum, CustomRank instances can be created, * modified, and persisted at runtime. - * + * * The permission system is internal and does NOT use Bukkit permission nodes, * because all players on TotalFreedom servers have OP status. */ -@Getter -@Setter public class CustomRank implements Displayable, Comparable { /** @@ -81,9 +77,11 @@ public class CustomRank implements Displayable, Comparable private String inheritFrom = null; /** - * Flattened permissions including inherited permissions. + * Flattened permissions including inherited permissions. Computed at runtime by + * RankManager.resolveInheritance(), not raw stored data, so it's excluded from + * JSON serialization. */ - private Set resolvedPermissions = new HashSet<>(); + private transient Set resolvedPermissions = new HashSet<>(); // Cached components for performance private transient Component cachedColoredTag; @@ -146,23 +144,6 @@ public void loadFrom(ConfigurationSection cs) invalidateCache(); } - /** - * Save rank data to a configuration section. - */ - public void saveTo(ConfigurationSection cs) - { - cs.set("name", name); - cs.set("determiner", determiner); - cs.set("abbreviation", abbreviation); - cs.set("level", level); - cs.set("color", color.toString()); - cs.set("admin", admin); - cs.set("console_only", consoleOnly); - cs.set("prefix", prefix); - cs.set("inherit", inheritFrom); - cs.set("permissions", permissions.isEmpty() ? null : permissions.stream().toList()); - } - /** * Parse a color name to NamedTextColor. */ @@ -357,32 +338,67 @@ public String getId() { return id; } - + + public void setName(String name) + { + this.name = name; + } + public String getDeterminer() { return determiner; } - + + public void setDeterminer(String determiner) + { + this.determiner = determiner; + } + public String getAbbreviation() { return abbreviation; } - + + public void setAbbreviation(String abbreviation) + { + this.abbreviation = abbreviation; + } + public int getLevel() { return level; } - + + public void setLevel(int level) + { + this.level = level; + } + + public void setColor(NamedTextColor color) + { + this.color = color; + } + public boolean isAdmin() { return admin; } - + + public void setAdmin(boolean admin) + { + this.admin = admin; + } + public boolean isConsoleOnly() { return consoleOnly; } - + + public void setConsoleOnly(boolean consoleOnly) + { + this.consoleOnly = consoleOnly; + } + public Set getPermissions() { return permissions; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java index cbe143ccc..a12e51125 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java @@ -1,8 +1,12 @@ package me.totalfreedom.totalfreedommod.rank; import com.google.common.collect.Maps; +import com.google.gson.reflect.TypeToken; import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; import java.io.IOException; +import java.lang.reflect.Type; import java.util.ArrayList; import java.util.Comparator; import java.util.HashSet; @@ -19,10 +23,12 @@ import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchContext; import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchSession; import me.totalfreedom.totalfreedommod.player.FPlayer; +import me.totalfreedom.totalfreedommod.sql.adapter.RankRepository; import me.totalfreedom.totalfreedommod.util.AdventureUtil; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FTask; import me.totalfreedom.totalfreedommod.util.FUtil; +import me.totalfreedom.totalfreedommod.util.JsonUtil; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.event.HoverEvent; @@ -51,7 +57,9 @@ public class RankManager extends FreedomService { - public static final String RANKS_FILENAME = "ranks.yml"; + public static final String RANKS_FILENAME = "ranks.json"; + + private static final Type RANK_MAP_TYPE = new TypeToken>() {}.getType(); /** * All custom ranks, keyed by ID. @@ -63,10 +71,7 @@ public class RankManager extends FreedomService */ private File ranksFile; - /** - * YAML configuration for ranks. - */ - private YamlConfiguration ranksConfig; + private boolean usingSql = false; /** * Chat input handler for interactive menus. @@ -112,12 +117,56 @@ protected void onStop() } /** - * Load custom ranks from ranks.yml. + * Load custom ranks from SQL (falling back to ranks.json). */ public void loadRanks() { ranksFile = new File(plugin.getDataFolder(), RANKS_FILENAME); + if (plugin.dm != null && plugin.dm.isInitialized()) + { + loadFromSql(); + } + else + { + loadFromJsonOrDefaults(); + } + } + + private void loadFromSql() + { + try + { + RankRepository repo = plugin.dm.getRankRepository(); + Map loaded = repo.loadAllAsync().block(); + usingSql = true; + + if (loaded.isEmpty() && !ranksFile.exists()) + { + createDefaultRanks(); + migrateConfigRanks(); + return; + } + + customRanks.clear(); + customRanks.putAll(loaded); + validateEssentialRanks(); + resolveInheritance(); + updateAllPlayerTeams(); + FLog.info("Loaded " + customRanks.size() + " custom ranks from SQL database."); + + reconcileFromJsonIfNewer(repo); + } + catch (Exception ex) + { + FLog.warning("Failed to load ranks from SQL, falling back to JSON: " + ex.getMessage()); + usingSql = false; + loadFromJsonOrDefaults(); + } + } + + private void loadFromJsonOrDefaults() + { if (!ranksFile.exists()) { createDefaultRanks(); @@ -125,24 +174,75 @@ public void loadRanks() return; } - ranksConfig = YamlConfiguration.loadConfiguration(ranksFile); - customRanks.clear(); + loadFromJson(); + } - for (String key : ranksConfig.getKeys(false)) + private void loadFromJson() + { + customRanks.clear(); + try { - ConfigurationSection section = ranksConfig.getConfigurationSection(key); - if (section == null) continue; - - CustomRank rank = new CustomRank(key); - rank.loadFrom(section); - customRanks.put(key.toLowerCase(), rank); + customRanks.putAll(readJsonRanks()); + } + catch (IOException ex) + { + FLog.severe("Could not read " + RANKS_FILENAME + ": " + ex.getMessage()); } validateEssentialRanks(); resolveInheritance(); updateAllPlayerTeams(); FLog.info("Loaded " + customRanks.size() + " custom ranks."); + } + + private Map readJsonRanks() throws IOException + { + try (FileReader reader = new FileReader(ranksFile)) + { + Map loaded = JsonUtil.GSON.fromJson(reader, RANK_MAP_TYPE); + return loaded != null ? loaded : Maps.newLinkedHashMap(); + } + } + + /** + * If ranks.json was written more recently than the database's last update, re-import it into SQL. + */ + private void reconcileFromJsonIfNewer(RankRepository repo) + { + if (!ranksFile.exists()) + { + return; + } + + try + { + Long sqlUpdatedAt = repo.getMaxUpdatedAt(); + if (sqlUpdatedAt != null && ranksFile.lastModified() <= sqlUpdatedAt) + { + return; + } + + Map jsonRanks = readJsonRanks(); + if (jsonRanks.isEmpty()) + { + return; + } + FLog.info("ranks.json is newer than the database; re-importing " + jsonRanks.size() + " rank(s) from it."); + for (CustomRank rank : jsonRanks.values()) + { + repo.saveOrUpdate(rank); + } + + customRanks.clear(); + customRanks.putAll(jsonRanks); + resolveInheritance(); + updateAllPlayerTeams(); + } + catch (Exception ex) + { + FLog.warning("Failed to reconcile ranks.json into the database: " + ex.getMessage()); + } } private static final String[] ESSENTIAL_RANKS = { @@ -319,32 +419,60 @@ private void removeConfigRanks() } /** - * Save custom ranks to ranks.yml. + * Save custom ranks to SQL (or ranks.json if SQL is unavailable). */ public void saveRanks() { - if (ranksFile == null) + if (usingSql) { - ranksFile = new File(plugin.getDataFolder(), RANKS_FILENAME); + saveToSql(); } + else + { + saveToJson(); + } + } - ranksConfig = new YamlConfiguration(); - - for (CustomRank rank : customRanks.values()) + private void saveToSql() + { + if (plugin.dm == null || !plugin.dm.isInitialized()) { - ConfigurationSection section = ranksConfig.createSection(rank.getId()); - rank.saveTo(section); + FLog.warning("SQL not available, falling back to JSON save for ranks"); + saveToJson(); + return; } try { - ranksConfig.save(ranksFile); + RankRepository repo = plugin.dm.getRankRepository(); + for (CustomRank rank : customRanks.values()) + { + repo.save(rank).block(); + } + } + catch (Exception ex) + { + FLog.severe("Could not save ranks to SQL: " + ex.getMessage()); + } + + saveToJson(); + } + + private void saveToJson() + { + if (ranksFile == null) + { + ranksFile = new File(plugin.getDataFolder(), RANKS_FILENAME); + } + + try (FileWriter writer = new FileWriter(ranksFile)) + { + JsonUtil.GSON.toJson(customRanks, RANK_MAP_TYPE, writer); } catch (IOException ex) { FLog.severe("Could not save " + RANKS_FILENAME + ": " + ex.getMessage()); } - } private void resolveInheritance() diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/FreedomDatabase.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/FreedomDatabase.java index 3d0b96dac..17eab2836 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/FreedomDatabase.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/FreedomDatabase.java @@ -9,6 +9,10 @@ import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; import me.totalfreedom.totalfreedommod.sql.adapter.DiscordLinkRepository; import me.totalfreedom.totalfreedommod.sql.adapter.PermbanRepository; +import me.totalfreedom.totalfreedommod.sql.adapter.PlayerRepository; +import me.totalfreedom.totalfreedommod.sql.adapter.ProtectedAreaRepository; +import me.totalfreedom.totalfreedommod.sql.adapter.RankRepository; +import me.totalfreedom.totalfreedommod.sql.adapter.SavedFlagRepository; import me.totalfreedom.totalfreedommod.sql.adapter.StrikeRepository; import me.totalfreedom.totalfreedommod.util.FLog; @@ -202,6 +206,42 @@ public DiscordLinkRepository getDiscordLinkRepository() return adapter.getDiscordLinkRepository(); } + public RankRepository getRankRepository() + { + if (adapter == null) + { + throw new IllegalStateException("Database not initialized"); + } + return adapter.getRankRepository(); + } + + public ProtectedAreaRepository getProtectedAreaRepository() + { + if (adapter == null) + { + throw new IllegalStateException("Database not initialized"); + } + return adapter.getProtectedAreaRepository(); + } + + public SavedFlagRepository getSavedFlagRepository() + { + if (adapter == null) + { + throw new IllegalStateException("Database not initialized"); + } + return adapter.getSavedFlagRepository(); + } + + public PlayerRepository getPlayerRepository() + { + if (adapter == null) + { + throw new IllegalStateException("Database not initialized"); + } + return adapter.getPlayerRepository(); + } + /** * Get the database type. */ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/YamlMigrationService.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/YamlMigrationService.java index f6f11198b..af869f054 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/YamlMigrationService.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/YamlMigrationService.java @@ -1,12 +1,20 @@ package me.totalfreedom.totalfreedommod.sql; import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.ProtectArea.ProtectedRegion; +import me.totalfreedom.totalfreedommod.ProtectArea.ProtectedRegion.CantFindWorldException; import me.totalfreedom.totalfreedommod.admin.Admin; import me.totalfreedom.totalfreedommod.banning.Ban; import me.totalfreedom.totalfreedommod.banning.PermBan; +import me.totalfreedom.totalfreedommod.player.PlayerData; +import me.totalfreedom.totalfreedommod.rank.CustomRank; import me.totalfreedom.totalfreedommod.sql.adapter.AdminRepository; import me.totalfreedom.totalfreedommod.sql.adapter.BanRepository; import me.totalfreedom.totalfreedommod.sql.adapter.PermbanRepository; +import me.totalfreedom.totalfreedommod.sql.adapter.PlayerRepository; +import me.totalfreedom.totalfreedommod.sql.adapter.ProtectedAreaRepository; +import me.totalfreedom.totalfreedommod.sql.adapter.RankRepository; +import me.totalfreedom.totalfreedommod.sql.adapter.SavedFlagRepository; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FUtil; import org.bukkit.configuration.ConfigurationSection; @@ -39,6 +47,10 @@ public class YamlMigrationService private static final String ADMINS_FILE = "admins.yml"; private static final String BANS_FILE = "bans.yml"; private static final String PERMBANS_FILE = "permbans.yml"; + private static final String RANKS_FILE = "ranks.yml"; + private static final String PROTECTED_AREAS_FILE = "protectedareas.yml"; + private static final String SAVED_FLAGS_FILE = "savedflags.yml"; + private static final String PLAYERS_DIR = "players"; public YamlMigrationService(TotalFreedomMod plugin, FreedomDatabase databaseManager) { @@ -66,6 +78,10 @@ public Mono runMigrations() migrateAdmins(); migrateBans(); migratePermbans(); + migrateRanks(); + migrateProtectedAreas(); + migrateSavedFlags(); + migratePlayers(); FLog.info("YAML data migration check complete"); } @@ -291,6 +307,270 @@ private void migratePermbans() backupFile(permbansFile); } + /** + * Migrate custom ranks from ranks.yml to database. + */ + private void migrateRanks() + { + File ranksFile = new File(plugin.getDataFolder(), RANKS_FILE); + if (!ranksFile.exists()) + { + FLog.info("No ranks.yml found, skipping rank migration"); + return; + } + + RankRepository repo = databaseManager.getRankRepository(); + + try + { + if (!repo.loadAll().isEmpty()) + { + FLog.info("Database already contains ranks, skipping YAML migration"); + return; + } + } + catch (Exception ex) + { + FLog.warning("Could not check existing ranks: " + ex.getMessage()); + } + + FLog.info("Migrating ranks from " + RANKS_FILE + "..."); + + YamlConfiguration config = YamlConfiguration.loadConfiguration(ranksFile); + AtomicInteger migrated = new AtomicInteger(0); + AtomicInteger failed = new AtomicInteger(0); + + for (String key : config.getKeys(false)) + { + ConfigurationSection section = config.getConfigurationSection(key); + if (section == null) + { + FLog.warning("Invalid rank entry: " + key); + failed.incrementAndGet(); + continue; + } + + try + { + CustomRank rank = new CustomRank(key); + rank.loadFrom(section); + repo.saveOrUpdate(rank); + migrated.incrementAndGet(); + } + catch (Exception ex) + { + FLog.warning("Failed to migrate rank " + key + ": " + ex.getMessage()); + failed.incrementAndGet(); + } + } + + FLog.info("Rank migration complete: " + migrated.get() + " migrated, " + failed.get() + " failed"); + + backupFile(ranksFile); + } + + /** + * Migrate protected areas from protectedareas.yml to database. + */ + private void migrateProtectedAreas() + { + File areasFile = new File(plugin.getDataFolder(), PROTECTED_AREAS_FILE); + if (!areasFile.exists()) + { + FLog.info("No protectedareas.yml found, skipping protected area migration"); + return; + } + + ProtectedAreaRepository repo = databaseManager.getProtectedAreaRepository(); + + try + { + if (!repo.loadAll().isEmpty()) + { + FLog.info("Database already contains protected areas, skipping YAML migration"); + return; + } + } + catch (Exception ex) + { + FLog.warning("Could not check existing protected areas: " + ex.getMessage()); + } + + FLog.info("Migrating protected areas from " + PROTECTED_AREAS_FILE + "..."); + + YamlConfiguration config = YamlConfiguration.loadConfiguration(areasFile); + ConfigurationSection areasSection = config.getConfigurationSection("areas"); + AtomicInteger migrated = new AtomicInteger(0); + AtomicInteger failed = new AtomicInteger(0); + + if (areasSection != null) + { + for (String id : areasSection.getKeys(false)) + { + ConfigurationSection areaSection = areasSection.getConfigurationSection(id); + if (areaSection == null) + { + FLog.warning("Invalid protected area entry: " + id); + failed.incrementAndGet(); + continue; + } + + try + { + UUID uuid = UUID.fromString(id); + String name = areaSection.getString("name"); + int minX = areaSection.getInt("min_x"); + int minY = areaSection.getInt("min_y"); + int minZ = areaSection.getInt("min_z"); + int maxX = areaSection.getInt("max_x"); + int maxY = areaSection.getInt("max_y"); + int maxZ = areaSection.getInt("max_z"); + String worldUUID = areaSection.getString("world"); + + ProtectedRegion region = new ProtectedRegion(uuid, name, minX, minY, minZ, maxX, maxY, maxZ, worldUUID); + repo.saveOrUpdate(region); + migrated.incrementAndGet(); + } + catch (CantFindWorldException | IllegalArgumentException ex) + { + FLog.warning("Failed to migrate protected area " + id + ": " + ex.getMessage()); + failed.incrementAndGet(); + } + catch (Exception ex) + { + FLog.warning("Failed to migrate protected area " + id + ": " + ex.getMessage()); + failed.incrementAndGet(); + } + } + } + + FLog.info("Protected area migration complete: " + migrated.get() + " migrated, " + failed.get() + " failed"); + + backupFile(areasFile); + } + + /** + * Migrate saved flags from savedflags.yml to database. + */ + private void migrateSavedFlags() + { + File flagsFile = new File(plugin.getDataFolder(), SAVED_FLAGS_FILE); + if (!flagsFile.exists()) + { + FLog.info("No savedflags.yml found, skipping saved flag migration"); + return; + } + + SavedFlagRepository repo = databaseManager.getSavedFlagRepository(); + + try + { + if (!repo.loadAll().isEmpty()) + { + FLog.info("Database already contains saved flags, skipping YAML migration"); + return; + } + } + catch (Exception ex) + { + FLog.warning("Could not check existing saved flags: " + ex.getMessage()); + } + + FLog.info("Migrating saved flags from " + SAVED_FLAGS_FILE + "..."); + + YamlConfiguration config = YamlConfiguration.loadConfiguration(flagsFile); + ConfigurationSection flagsSection = config.getConfigurationSection("flags"); + AtomicInteger migrated = new AtomicInteger(0); + + if (flagsSection != null) + { + for (String key : flagsSection.getKeys(false)) + { + try + { + repo.upsert(key, flagsSection.getBoolean(key)); + migrated.incrementAndGet(); + } + catch (Exception ex) + { + FLog.warning("Failed to migrate saved flag " + key + ": " + ex.getMessage()); + } + } + } + + FLog.info("Saved flag migration complete: " + migrated.get() + " migrated"); + + backupFile(flagsFile); + } + + /** + * Migrate per-player data from players/*.yml to database. + */ + private void migratePlayers() + { + File playersDir = new File(plugin.getDataFolder(), PLAYERS_DIR); + File[] files = playersDir.listFiles((dir, name) -> name.endsWith(".yml")); + if (files == null || files.length == 0) + { + FLog.info("No players/*.yml found, skipping player data migration"); + return; + } + + PlayerRepository repo = databaseManager.getPlayerRepository(); + + try + { + if (!repo.loadAll().isEmpty()) + { + FLog.info("Database already contains player data, skipping YAML migration"); + return; + } + } + catch (Exception ex) + { + FLog.warning("Could not check existing player data: " + ex.getMessage()); + } + + FLog.info("Migrating player data from players/*.yml..."); + + AtomicInteger migrated = new AtomicInteger(0); + AtomicInteger failed = new AtomicInteger(0); + + for (File file : files) + { + String username = file.getName().substring(0, file.getName().length() - ".yml".length()); + + try + { + YamlConfiguration config = YamlConfiguration.loadConfiguration(file); + PlayerData data = new PlayerData(username); + data.loadFrom(config); + + if (!data.isValid()) + { + FLog.warning("Invalid player data for: " + username); + failed.incrementAndGet(); + continue; + } + + repo.saveOrUpdate(data); + migrated.incrementAndGet(); + } + catch (Exception ex) + { + FLog.warning("Failed to migrate player data for " + username + ": " + ex.getMessage()); + failed.incrementAndGet(); + } + } + + FLog.info("Player data migration complete: " + migrated.get() + " migrated, " + failed.get() + " failed"); + + for (File file : files) + { + backupFile(file); + } + } + /** * Generate or retrieve UUID for an admin. */ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/AdminRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/AdminRepository.java index a0722d4a3..1d8f6ba4f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/AdminRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/AdminRepository.java @@ -161,6 +161,12 @@ public interface AdminRepository */ void deleteAllSync() throws SQLException; + /** + * Epoch millis of the most recently updated admin row, or null if the table is empty. + * Used to compare SQL freshness against the admins.json snapshot's last-modified time. + */ + Long getMaxUpdatedAt() throws SQLException; + // ============================================ // Async Operations // ============================================ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/BanRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/BanRepository.java index 0dcbb46c9..aead02b84 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/BanRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/BanRepository.java @@ -129,6 +129,12 @@ public interface BanRepository */ void deleteAllSync() throws SQLException; + /** + * Epoch millis of the most recently updated ban row, or null if the table is empty. + * Used to compare SQL freshness against the bans.json snapshot's last-modified time. + */ + Long getMaxUpdatedAt() throws SQLException; + // ============================================ // DELETE Operations // ============================================ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DiscordLinkRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DiscordLinkRepository.java index 3c92e4578..90ce13a64 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DiscordLinkRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DiscordLinkRepository.java @@ -1,10 +1,17 @@ package me.totalfreedom.totalfreedommod.sql.adapter; import java.sql.SQLException; +import java.util.Map; import java.util.UUID; public interface DiscordLinkRepository { + /** + * @return all links, keyed by admin UUID string, valued by Discord user id. + * Used to write the discord_links.json snapshot. + */ + Map loadAll() throws SQLException; + /** * Insert a new link. Fails if either side is already linked. Callers * should remove any existing link first via {@link #deleteByAdminUuid} or @@ -31,4 +38,10 @@ public interface DiscordLinkRepository * @return true if a row was deleted. */ boolean deleteByDiscordUserId(String discordUserId) throws SQLException; + + /** + * Epoch millis of the most recently updated link row, or null if the table is empty. + * Used to compare SQL freshness against the discord_links.json snapshot's last-modified time. + */ + Long getMaxUpdatedAt() throws SQLException; } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/PermbanRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/PermbanRepository.java index 6c9f3c628..1bacb3ce5 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/PermbanRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/PermbanRepository.java @@ -137,6 +137,12 @@ public interface PermbanRepository */ void deleteAllSync() throws SQLException; + /** + * Epoch millis of the most recently updated permban row, or null if the table is empty. + * Used to compare SQL freshness against the permbans.json snapshot's last-modified time. + */ + Long getMaxUpdatedAt() throws SQLException; + // ============================================ // Async Operations // ============================================ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/PlayerRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/PlayerRepository.java index 6dbe1c3ad..6724d4cc3 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/PlayerRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/PlayerRepository.java @@ -43,6 +43,13 @@ public interface PlayerRepository void deleteAllSync() throws SQLException; + /** + * Epoch millis of this player's row's last update, or null if no such row exists. + * Used to compare SQL freshness against that player's {@code players/.json} + * snapshot file's last-modified time. + */ + Long getUpdatedAt(String username) throws SQLException; + Mono> loadAllAsync(); Mono save(PlayerData data); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/ProtectedAreaRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/ProtectedAreaRepository.java index b24956557..98dee6375 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/ProtectedAreaRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/ProtectedAreaRepository.java @@ -37,6 +37,12 @@ public interface ProtectedAreaRepository void deleteAllSync() throws SQLException; + /** + * Epoch millis of the most recently updated region row, or null if the table is empty. + * Used to compare SQL freshness against the protectedareas.json snapshot's last-modified time. + */ + Long getMaxUpdatedAt() throws SQLException; + Mono> loadAllAsync(); Mono save(ProtectedRegion region); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/RankRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/RankRepository.java index 843070f50..2c4778660 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/RankRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/RankRepository.java @@ -41,6 +41,12 @@ public interface RankRepository void deleteAllSync() throws SQLException; + /** + * Epoch millis of the most recently updated rank row, or null if the table is empty. + * Used to compare SQL freshness against the ranks.json snapshot's last-modified time. + */ + Long getMaxUpdatedAt() throws SQLException; + Mono> loadAllAsync(); Mono save(CustomRank rank); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/SavedFlagRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/SavedFlagRepository.java index be1ef47aa..1dc71b400 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/SavedFlagRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/SavedFlagRepository.java @@ -18,6 +18,12 @@ public interface SavedFlagRepository void deleteAllSync() throws SQLException; + /** + * Epoch millis of the most recently updated flag row, or null if the table is empty. + * Used to compare SQL freshness against the savedflags.json snapshot's last-modified time. + */ + Long getMaxUpdatedAt() throws SQLException; + Mono> loadAllAsync(); Mono upsertAsync(String flagName, boolean enabled); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/StrikeRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/StrikeRepository.java index f7f51cfaa..9e75143ea 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/StrikeRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/StrikeRepository.java @@ -15,6 +15,12 @@ public interface StrikeRepository void deleteAllSync() throws SQLException; + /** + * Epoch millis of the most recently updated strike row, or null if the table is empty. + * Used to compare SQL freshness against the strikes.json snapshot's last-modified time. + */ + Long getMaxUpdatedAt() throws SQLException; + Mono> loadAllAsync(); Mono upsertAsync(StrikeRecord record); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericAdminRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericAdminRepository.java index 73b620f95..6ce4cebc0 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericAdminRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericAdminRepository.java @@ -10,6 +10,7 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Timestamp; import java.util.*; import reactor.core.publisher.Mono; @@ -34,6 +35,7 @@ public class GenericAdminRepository implements AdminRepository private final String colCustomRank; private final String colAdminId; private final String colIp; + private final String colUpdatedAt; private final String selectColumns; public GenericAdminRepository(StatementHandler statementHandler, DatabaseAdapter adapter) @@ -53,6 +55,7 @@ public GenericAdminRepository(StatementHandler statementHandler, DatabaseAdapter this.colCustomRank = adapter.quoteIdentifier("custom_rank"); this.colAdminId = adapter.quoteIdentifier("admin_id"); this.colIp = adapter.quoteIdentifier("ip"); + this.colUpdatedAt = adapter.quoteIdentifier("updated_at"); this.selectColumns = String.format("%s, %s, %s, %s, %s, %s, %s, %s", colId, colUuid, colUsername, colRank, colActive, colLastLogin, colLoginMessage, colCustomRank); } @@ -60,9 +63,9 @@ public GenericAdminRepository(StatementHandler statementHandler, DatabaseAdapter @Override public int insert(UUID uuid, Admin admin) throws SQLException { - String sql = String.format("INSERT INTO %s (%s, %s, %s, %s, %s, %s, %s) VALUES (?, ?, ?, ?, %s, ?, ?)", + String sql = String.format("INSERT INTO %s (%s, %s, %s, %s, %s, %s, %s, %s) VALUES (?, ?, ?, ?, %s, ?, ?, %s)", tblAdmins, colUuid, colUsername, colRank, colActive, colLastLogin, colLoginMessage, colCustomRank, - adapter.timestampParamPlaceholder()); + colUpdatedAt, adapter.timestampParamPlaceholder(), adapter.currentTimestamp()); long adminId = statementHandler.executeUpdateReturnKey(sql, uuid.toString(), @@ -270,9 +273,9 @@ public UUID getUuidByUsername(String username) throws SQLException @Override public boolean update(UUID uuid, Admin admin) throws SQLException { - String sql = String.format("UPDATE %s SET %s = ?, %s = ?, %s = ?, %s = %s, %s = ?, %s = ? WHERE %s = ?", + String sql = String.format("UPDATE %s SET %s = ?, %s = ?, %s = ?, %s = %s, %s = ?, %s = ?, %s = %s WHERE %s = ?", tblAdmins, colUsername, colRank, colActive, colLastLogin, adapter.timestampParamPlaceholder(), - colLoginMessage, colCustomRank, colUuid); + colLoginMessage, colCustomRank, colUpdatedAt, adapter.currentTimestamp(), colUuid); int rows = statementHandler.executeUpdate(sql, admin.getName(), @@ -375,6 +378,21 @@ public void deleteAllSync() throws SQLException statementHandler.executeUpdate(String.format("DELETE FROM %s", tblAdmins)); } + @Override + public Long getMaxUpdatedAt() throws SQLException + { + String sql = String.format("SELECT MAX(%s) FROM %s", colUpdatedAt, tblAdmins); + try (ResultSet rs = statementHandler.executeQuery(sql)) + { + if (rs.next()) + { + Timestamp ts = rs.getTimestamp(1); + return ts != null ? ts.getTime() : null; + } + } + return null; + } + @Override public Mono> loadAllAsync() { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericBanRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericBanRepository.java index 668eacfed..9612c0513 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericBanRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericBanRepository.java @@ -9,6 +9,7 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Timestamp; import java.util.*; import reactor.core.publisher.Mono; @@ -32,6 +33,7 @@ public class GenericBanRepository implements BanRepository private final String colExpireAt; private final String colBanId; private final String colIp; + private final String colUpdatedAt; private final String selectColumns; public GenericBanRepository(StatementHandler statementHandler, DatabaseAdapter adapter) @@ -50,6 +52,7 @@ public GenericBanRepository(StatementHandler statementHandler, DatabaseAdapter a this.colExpireAt = adapter.quoteIdentifier("expire_at"); this.colBanId = adapter.quoteIdentifier("ban_id"); this.colIp = adapter.quoteIdentifier("ip"); + this.colUpdatedAt = adapter.quoteIdentifier("updated_at"); this.selectColumns = String.format("%s, %s, %s, %s, %s, %s, %s", colId, colUuid, colUsername, colBannedBy, colBannedByUuid, colReason, colExpireAt); } @@ -57,9 +60,9 @@ public GenericBanRepository(StatementHandler statementHandler, DatabaseAdapter a @Override public int insert(Ban ban) throws SQLException { - String sql = String.format("INSERT INTO %s (%s, %s, %s, %s, %s, %s) VALUES (?, ?, ?, ?, ?, %s)", - tblBans, colUuid, colUsername, colBannedBy, colBannedByUuid, colReason, colExpireAt, - adapter.timestampParamPlaceholder()); + String sql = String.format("INSERT INTO %s (%s, %s, %s, %s, %s, %s, %s) VALUES (?, ?, ?, ?, ?, %s, %s)", + tblBans, colUuid, colUsername, colBannedBy, colBannedByUuid, colReason, colExpireAt, colUpdatedAt, + adapter.timestampParamPlaceholder(), adapter.currentTimestamp()); long banId = statementHandler.executeUpdateReturnKey(sql, ban.getUuid() != null ? ban.getUuid().toString() : null, @@ -296,9 +299,9 @@ public boolean isBannedByIp(String ip) throws SQLException @Override public boolean update(Ban ban) throws SQLException { - String sql = String.format("UPDATE %s SET %s = ?, %s = ?, %s = ?, %s = ?, %s = %s WHERE %s = ?", + String sql = String.format("UPDATE %s SET %s = ?, %s = ?, %s = ?, %s = ?, %s = %s, %s = %s WHERE %s = ?", tblBans, colUsername, colBannedBy, colBannedByUuid, colReason, colExpireAt, - adapter.timestampParamPlaceholder(), colUuid); + adapter.timestampParamPlaceholder(), colUpdatedAt, adapter.currentTimestamp(), colUuid); int rows = statementHandler.executeUpdate(sql, ban.getUsername(), @@ -392,6 +395,21 @@ public void deleteAllSync() throws SQLException statementHandler.executeUpdate(String.format("DELETE FROM %s", tblBans)); } + @Override + public Long getMaxUpdatedAt() throws SQLException + { + String sql = String.format("SELECT MAX(%s) FROM %s", colUpdatedAt, tblBans); + try (ResultSet rs = statementHandler.executeQuery(sql)) + { + if (rs.next()) + { + Timestamp ts = rs.getTimestamp(1); + return ts != null ? ts.getTime() : null; + } + } + return null; + } + @Override public Mono> loadAllAsync() { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericDiscordLinkRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericDiscordLinkRepository.java index 3fac5f1f1..2055f978f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericDiscordLinkRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericDiscordLinkRepository.java @@ -6,6 +6,9 @@ import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.UUID; /** @@ -20,6 +23,8 @@ public class GenericDiscordLinkRepository implements DiscordLinkRepository private final String selectAdminUuidSql; private final String deleteByAdminUuidSql; private final String deleteByDiscordUserIdSql; + private final String maxUpdatedAtSql; + private final String selectAllSql; public GenericDiscordLinkRepository(StatementHandler statementHandler, DatabaseAdapter adapter) { @@ -29,13 +34,31 @@ public GenericDiscordLinkRepository(StatementHandler statementHandler, DatabaseA String colAdminUuid = adapter.quoteIdentifier("admin_uuid"); String colDiscordUserId = adapter.quoteIdentifier("discord_user_id"); String colLinkedAt = adapter.quoteIdentifier("linked_at"); + String colUpdatedAt = adapter.quoteIdentifier("updated_at"); - this.insertSql = String.format("INSERT INTO %s (%s, %s, %s) VALUES (?, ?, %s)", - tblDiscordLinks, colAdminUuid, colDiscordUserId, colLinkedAt, adapter.currentTimestamp()); + this.insertSql = String.format("INSERT INTO %s (%s, %s, %s, %s) VALUES (?, ?, %s, %s)", + tblDiscordLinks, colAdminUuid, colDiscordUserId, colLinkedAt, colUpdatedAt, + adapter.currentTimestamp(), adapter.currentTimestamp()); this.selectDiscordIdSql = String.format("SELECT %s FROM %s WHERE %s = ?", colDiscordUserId, tblDiscordLinks, colAdminUuid); this.selectAdminUuidSql = String.format("SELECT %s FROM %s WHERE %s = ?", colAdminUuid, tblDiscordLinks, colDiscordUserId); this.deleteByAdminUuidSql = String.format("DELETE FROM %s WHERE %s = ?", tblDiscordLinks, colAdminUuid); this.deleteByDiscordUserIdSql = String.format("DELETE FROM %s WHERE %s = ?", tblDiscordLinks, colDiscordUserId); + this.maxUpdatedAtSql = String.format("SELECT MAX(%s) FROM %s", colUpdatedAt, tblDiscordLinks); + this.selectAllSql = String.format("SELECT %s, %s FROM %s", colAdminUuid, colDiscordUserId, tblDiscordLinks); + } + + @Override + public Map loadAll() throws SQLException + { + Map links = new LinkedHashMap<>(); + try (ResultSet rs = statementHandler.executeQuery(selectAllSql)) + { + while (rs.next()) + { + links.put(rs.getString(1), rs.getString(2)); + } + } + return links; } @Override @@ -78,4 +101,18 @@ public boolean deleteByDiscordUserId(String discordUserId) throws SQLException { return statementHandler.executeUpdate(deleteByDiscordUserIdSql, discordUserId) > 0; } + + @Override + public Long getMaxUpdatedAt() throws SQLException + { + try (ResultSet rs = statementHandler.executeQuery(maxUpdatedAtSql)) + { + if (rs.next()) + { + Timestamp ts = rs.getTimestamp(1); + return ts != null ? ts.getTime() : null; + } + } + return null; + } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPermbanRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPermbanRepository.java index 63166d292..2234b249e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPermbanRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPermbanRepository.java @@ -8,6 +8,7 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Timestamp; import java.util.*; import reactor.core.publisher.Mono; @@ -28,6 +29,7 @@ public class GenericPermbanRepository implements PermbanRepository private final String colReason; private final String colPermbanId; private final String colIp; + private final String colUpdatedAt; private final String selectColumns; public GenericPermbanRepository(StatementHandler statementHandler, DatabaseAdapter adapter) @@ -43,14 +45,15 @@ public GenericPermbanRepository(StatementHandler statementHandler, DatabaseAdapt this.colReason = adapter.quoteIdentifier("reason"); this.colPermbanId = adapter.quoteIdentifier("permban_id"); this.colIp = adapter.quoteIdentifier("ip"); + this.colUpdatedAt = adapter.quoteIdentifier("updated_at"); this.selectColumns = String.format("%s, %s, %s, %s", colId, colUuid, colUsername, colReason); } @Override public int insert(PermBan permban) throws SQLException { - String sql = String.format("INSERT INTO %s (%s, %s, %s) VALUES (?, ?, ?)", - tblPermbans, colUuid, colUsername, colReason); + String sql = String.format("INSERT INTO %s (%s, %s, %s, %s) VALUES (?, ?, ?, %s)", + tblPermbans, colUuid, colUsername, colReason, colUpdatedAt, adapter.currentTimestamp()); long permbanId = statementHandler.executeUpdateReturnKey(sql, permban.getUuid() != null ? permban.getUuid().toString() : null, @@ -249,8 +252,8 @@ public boolean isPermBannedByIp(String ip) throws SQLException @Override public boolean update(PermBan permban) throws SQLException { - String sql = String.format("UPDATE %s SET %s = ?, %s = ? WHERE %s = ?", - tblPermbans, colUsername, colReason, colUuid); + String sql = String.format("UPDATE %s SET %s = ?, %s = ?, %s = %s WHERE %s = ?", + tblPermbans, colUsername, colReason, colUpdatedAt, adapter.currentTimestamp(), colUuid); int rows = statementHandler.executeUpdate(sql, permban.getUsername(), @@ -325,6 +328,21 @@ public void deleteAllSync() throws SQLException statementHandler.executeUpdate(String.format("DELETE FROM %s", tblPermbans)); } + @Override + public Long getMaxUpdatedAt() throws SQLException + { + String sql = String.format("SELECT MAX(%s) FROM %s", colUpdatedAt, tblPermbans); + try (ResultSet rs = statementHandler.executeQuery(sql)) + { + if (rs.next()) + { + Timestamp ts = rs.getTimestamp(1); + return ts != null ? ts.getTime() : null; + } + } + return null; + } + @Override public Mono> loadAllAsync() { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java index 521cdc093..0fe50038e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java @@ -10,6 +10,7 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Timestamp; import java.util.*; import reactor.core.publisher.Mono; @@ -38,6 +39,7 @@ public class GenericPlayerRepository implements PlayerRepository private final String colId; private final String colPlayerUsername; private final String colIp; + private final String colUpdatedAt; private final String selectColumns; public GenericPlayerRepository(StatementHandler statementHandler, DatabaseAdapter adapter) @@ -61,6 +63,7 @@ public GenericPlayerRepository(StatementHandler statementHandler, DatabaseAdapte this.colId = adapter.quoteIdentifier("id"); this.colPlayerUsername = adapter.quoteIdentifier("username"); this.colIp = adapter.quoteIdentifier("ip"); + this.colUpdatedAt = adapter.quoteIdentifier("updated_at"); this.selectColumns = String.format("%s, %s, %s, %s, %s, %s, %s, %s, %s, %s", colUsername, colFirstJoin, colLastJoin, colPotionSpy, colCommandSpyMode, colMuted, colFrozen, colCommandsBlocked, colStrikes, colSavedTag) @@ -70,7 +73,8 @@ public GenericPlayerRepository(StatementHandler statementHandler, DatabaseAdapte @Override public void insert(PlayerData data) throws SQLException { - String sql = String.format("INSERT INTO %s (%s) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", tblPlayers, selectColumns); + String sql = String.format("INSERT INTO %s (%s, %s) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, %s)", + tblPlayers, selectColumns, colUpdatedAt, adapter.currentTimestamp()); statementHandler.executeUpdate(sql, data.getUsername(), @@ -187,9 +191,9 @@ public List getIps(String username) throws SQLException @Override public boolean update(PlayerData data) throws SQLException { - String sql = String.format("UPDATE %s SET %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ? WHERE %s = ?", + String sql = String.format("UPDATE %s SET %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = %s WHERE %s = ?", tblPlayers, colFirstJoin, colLastJoin, colPotionSpy, colCommandSpyMode, colMuted, colFrozen, - colCommandsBlocked, colStrikes, colSavedTag, colUsername); + colCommandsBlocked, colStrikes, colSavedTag, colUpdatedAt, adapter.currentTimestamp(), colUsername); int rows = statementHandler.executeUpdate(sql, data.getFirstJoinUnix(), @@ -245,6 +249,22 @@ public void deleteAllSync() throws SQLException statementHandler.executeUpdate(String.format("DELETE FROM %s", tblPlayers)); } + @Override + public Long getUpdatedAt(String username) throws SQLException + { + String sql = String.format("SELECT %s FROM %s WHERE %s = ?", colUpdatedAt, tblPlayers, colUsername); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); + ResultSet rs = stmt.executeQuery()) + { + if (rs.next()) + { + Timestamp ts = rs.getTimestamp(1); + return ts != null ? ts.getTime() : null; + } + } + return null; + } + @Override public Mono> loadAllAsync() { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericProtectedAreaRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericProtectedAreaRepository.java index ff2fa0805..a9cb3445a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericProtectedAreaRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericProtectedAreaRepository.java @@ -10,6 +10,7 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Timestamp; import java.util.ArrayList; import java.util.List; import java.util.UUID; @@ -22,6 +23,7 @@ public class GenericProtectedAreaRepository implements ProtectedAreaRepository { private final StatementHandler statementHandler; + private final DatabaseAdapter adapter; private final String tblProtectedAreas; private final String colUuid; @@ -33,11 +35,13 @@ public class GenericProtectedAreaRepository implements ProtectedAreaRepository private final String colMaxY; private final String colMaxZ; private final String colWorldUuid; + private final String colUpdatedAt; private final String selectColumns; public GenericProtectedAreaRepository(StatementHandler statementHandler, DatabaseAdapter adapter) { this.statementHandler = statementHandler; + this.adapter = adapter; this.tblProtectedAreas = adapter.quoteIdentifier("protected_areas"); this.colUuid = adapter.quoteIdentifier("uuid"); @@ -49,6 +53,7 @@ public GenericProtectedAreaRepository(StatementHandler statementHandler, Databas this.colMaxY = adapter.quoteIdentifier("max_y"); this.colMaxZ = adapter.quoteIdentifier("max_z"); this.colWorldUuid = adapter.quoteIdentifier("world_uuid"); + this.colUpdatedAt = adapter.quoteIdentifier("updated_at"); this.selectColumns = String.format("%s, %s, %s, %s, %s, %s, %s, %s, %s", colUuid, colName, colMinX, colMinY, colMinZ, colMaxX, colMaxY, colMaxZ, colWorldUuid); } @@ -56,7 +61,8 @@ public GenericProtectedAreaRepository(StatementHandler statementHandler, Databas @Override public void insert(ProtectedRegion region) throws SQLException { - String sql = String.format("INSERT INTO %s (%s) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", tblProtectedAreas, selectColumns); + String sql = String.format("INSERT INTO %s (%s, %s) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, %s)", + tblProtectedAreas, selectColumns, colUpdatedAt, adapter.currentTimestamp()); statementHandler.executeUpdate(sql, region.getUuid().toString(), region.getName(), @@ -132,8 +138,9 @@ public boolean exists(UUID uuid) throws SQLException @Override public boolean update(ProtectedRegion region) throws SQLException { - String sql = String.format("UPDATE %s SET %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ? WHERE %s = ?", - tblProtectedAreas, colName, colMinX, colMinY, colMinZ, colMaxX, colMaxY, colMaxZ, colWorldUuid, colUuid); + String sql = String.format("UPDATE %s SET %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = %s WHERE %s = ?", + tblProtectedAreas, colName, colMinX, colMinY, colMinZ, colMaxX, colMaxY, colMaxZ, colWorldUuid, + colUpdatedAt, adapter.currentTimestamp(), colUuid); int rows = statementHandler.executeUpdate(sql, region.getName(), @@ -175,6 +182,21 @@ public void deleteAllSync() throws SQLException statementHandler.executeUpdate(String.format("DELETE FROM %s", tblProtectedAreas)); } + @Override + public Long getMaxUpdatedAt() throws SQLException + { + String sql = String.format("SELECT MAX(%s) FROM %s", colUpdatedAt, tblProtectedAreas); + try (ResultSet rs = statementHandler.executeQuery(sql)) + { + if (rs.next()) + { + Timestamp ts = rs.getTimestamp(1); + return ts != null ? ts.getTime() : null; + } + } + return null; + } + @Override public Mono> loadAllAsync() { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericRankRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericRankRepository.java index b2ac8fee5..ff67877b1 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericRankRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericRankRepository.java @@ -9,6 +9,7 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Timestamp; import java.util.*; import reactor.core.publisher.Mono; @@ -35,6 +36,7 @@ public class GenericRankRepository implements RankRepository private final String colInheritFrom; private final String colRankId; private final String colPermission; + private final String colUpdatedAt; private final String selectColumns; public GenericRankRepository(StatementHandler statementHandler, DatabaseAdapter adapter) @@ -56,6 +58,7 @@ public GenericRankRepository(StatementHandler statementHandler, DatabaseAdapter this.colInheritFrom = adapter.quoteIdentifier("inherit_from"); this.colRankId = adapter.quoteIdentifier("rank_id"); this.colPermission = adapter.quoteIdentifier("permission"); + this.colUpdatedAt = adapter.quoteIdentifier("updated_at"); this.selectColumns = String.format("%s, %s, %s, %s, %s, %s, %s, %s, %s, %s", colId, colName, colDeterminer, colAbbreviation, colLevel, colColor, colAdmin, colConsoleOnly, colPrefix, colInheritFrom); @@ -64,8 +67,8 @@ public GenericRankRepository(StatementHandler statementHandler, DatabaseAdapter @Override public void insert(CustomRank rank) throws SQLException { - String sql = String.format("INSERT INTO %s (%s) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - tblRanks, selectColumns); + String sql = String.format("INSERT INTO %s (%s, %s) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, %s)", + tblRanks, selectColumns, colUpdatedAt, adapter.currentTimestamp()); statementHandler.executeUpdate(sql, rank.getId(), @@ -181,9 +184,9 @@ public Set getPermissions(String rankId) throws SQLException @Override public boolean update(CustomRank rank) throws SQLException { - String sql = String.format("UPDATE %s SET %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ? WHERE %s = ?", + String sql = String.format("UPDATE %s SET %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = %s WHERE %s = ?", tblRanks, colName, colDeterminer, colAbbreviation, colLevel, colColor, colAdmin, colConsoleOnly, - colPrefix, colInheritFrom, colId); + colPrefix, colInheritFrom, colUpdatedAt, adapter.currentTimestamp(), colId); int rows = statementHandler.executeUpdate(sql, rank.getName(), @@ -242,6 +245,21 @@ public void deleteAllSync() throws SQLException statementHandler.executeUpdate(String.format("DELETE FROM %s", tblRanks)); } + @Override + public Long getMaxUpdatedAt() throws SQLException + { + String sql = String.format("SELECT MAX(%s) FROM %s", colUpdatedAt, tblRanks); + try (ResultSet rs = statementHandler.executeQuery(sql)) + { + if (rs.next()) + { + Timestamp ts = rs.getTimestamp(1); + return ts != null ? ts.getTime() : null; + } + } + return null; + } + @Override public Mono> loadAllAsync() { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericSavedFlagRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericSavedFlagRepository.java index ebe8c0506..e2ea80bca 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericSavedFlagRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericSavedFlagRepository.java @@ -6,6 +6,7 @@ import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Timestamp; import java.util.HashMap; import java.util.Map; @@ -21,8 +22,10 @@ public class GenericSavedFlagRepository implements SavedFlagRepository private final String tblSavedFlags; private final String colFlagName; private final String colEnabled; + private final String colUpdatedAt; private final String selectSql; private final String upsertSql; + private final String maxUpdatedAtSql; public GenericSavedFlagRepository(StatementHandler statementHandler, DatabaseAdapter adapter) { @@ -31,10 +34,13 @@ public GenericSavedFlagRepository(StatementHandler statementHandler, DatabaseAda this.tblSavedFlags = adapter.quoteIdentifier("saved_flags"); this.colFlagName = adapter.quoteIdentifier("flag_name"); this.colEnabled = adapter.quoteIdentifier("enabled"); + this.colUpdatedAt = adapter.quoteIdentifier("updated_at"); this.selectSql = String.format("SELECT %s, %s FROM %s", colFlagName, colEnabled, tblSavedFlags); - this.upsertSql = String.format("INSERT INTO %s (%s, %s) VALUES (?, ?) %s", - tblSavedFlags, colFlagName, colEnabled, adapter.upsertClause(colFlagName, colEnabled)); + this.upsertSql = String.format("INSERT INTO %s (%s, %s, %s) VALUES (?, ?, %s) %s", + tblSavedFlags, colFlagName, colEnabled, colUpdatedAt, adapter.currentTimestamp(), + adapter.upsertClause(colFlagName, colEnabled, colUpdatedAt)); + this.maxUpdatedAtSql = String.format("SELECT MAX(%s) FROM %s", colUpdatedAt, tblSavedFlags); } @Override @@ -70,6 +76,20 @@ public void deleteAllSync() throws SQLException statementHandler.executeUpdate(String.format("DELETE FROM %s", tblSavedFlags)); } + @Override + public Long getMaxUpdatedAt() throws SQLException + { + try (ResultSet rs = statementHandler.executeQuery(maxUpdatedAtSql)) + { + if (rs.next()) + { + Timestamp ts = rs.getTimestamp(1); + return ts != null ? ts.getTime() : null; + } + } + return null; + } + @Override public Mono> loadAllAsync() { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericStrikeRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericStrikeRepository.java index 6b2e18fc0..be6b34419 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericStrikeRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericStrikeRepository.java @@ -7,6 +7,7 @@ import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Timestamp; import java.util.HashMap; import java.util.Map; @@ -24,6 +25,7 @@ public class GenericStrikeRepository implements StrikeRepository private final String colStrikeCount; private final String colLastStrikeUnix; private final String colLastUsername; + private final String colUpdatedAt; private final String upsertSql; private final String selectSql; @@ -36,12 +38,14 @@ public GenericStrikeRepository(StatementHandler statementHandler, DatabaseAdapte this.colStrikeCount = adapter.quoteIdentifier("strike_count"); this.colLastStrikeUnix = adapter.quoteIdentifier("last_strike_unix"); this.colLastUsername = adapter.quoteIdentifier("last_username"); + this.colUpdatedAt = adapter.quoteIdentifier("updated_at"); this.selectSql = String.format("SELECT %s, %s, %s, %s FROM %s", colIp, colStrikeCount, colLastStrikeUnix, colLastUsername, tblStrikes); - this.upsertSql = String.format("INSERT INTO %s (%s, %s, %s, %s) VALUES (?, ?, ?, ?) %s", - tblStrikes, colIp, colStrikeCount, colLastStrikeUnix, colLastUsername, - adapter.upsertClause(colIp, colStrikeCount, colLastStrikeUnix, colLastUsername)); + this.upsertSql = String.format("INSERT INTO %s (%s, %s, %s, %s, %s) VALUES (?, ?, ?, ?, %s) %s", + tblStrikes, colIp, colStrikeCount, colLastStrikeUnix, colLastUsername, colUpdatedAt, + adapter.currentTimestamp(), + adapter.upsertClause(colIp, colStrikeCount, colLastStrikeUnix, colLastUsername, colUpdatedAt)); } @Override @@ -81,6 +85,21 @@ public void deleteAllSync() throws SQLException statementHandler.executeUpdate(String.format("DELETE FROM %s", tblStrikes)); } + @Override + public Long getMaxUpdatedAt() throws SQLException + { + String sql = String.format("SELECT MAX(%s) FROM %s", colUpdatedAt, tblStrikes); + try (ResultSet rs = statementHandler.executeQuery(sql)) + { + if (rs.next()) + { + Timestamp ts = rs.getTimestamp(1); + return ts != null ? ts.getTime() : null; + } + } + return null; + } + @Override public Mono> loadAllAsync() { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdapter.java index e9c73b9fe..0999ff2c8 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdapter.java @@ -188,21 +188,16 @@ private void createAdminsTable() throws SQLException `last_login` DATETIME, `login_message` TEXT, `custom_rank` VARCHAR(64), + `updated_at` DATETIME NOT NULL DEFAULT NOW(), INDEX `idx_admins_username` (`username`), INDEX `idx_admins_active` (`active`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci """; statementHandler.executeUpdate(sql); - // Migration for tables created before custom_rank existed. - try - { - statementHandler.executeUpdate("ALTER TABLE `admins` ADD COLUMN `custom_rank` VARCHAR(64)"); - } - catch (SQLException ignored) - { - // Column already exists. - } + // Migration for tables created before custom_rank/updated_at existed. + addColumnIfMissing("admins", "custom_rank", "VARCHAR(64)"); + addColumnIfMissing("admins", "updated_at", "DATETIME NOT NULL DEFAULT NOW()"); } private void createAdminIpsTable() throws SQLException @@ -231,12 +226,14 @@ private void createBansTable() throws SQLException `banned_by_uuid` VARCHAR(36), `reason` TEXT, `expire_at` DATETIME, + `updated_at` DATETIME NOT NULL DEFAULT NOW(), INDEX `idx_bans_uuid` (`uuid`), INDEX `idx_bans_username` (`username`), INDEX `idx_bans_expire` (`expire_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci """; statementHandler.executeUpdate(sql); + addColumnIfMissing("bans", "updated_at", "DATETIME NOT NULL DEFAULT NOW()"); } private void createBanIpsTable() throws SQLException @@ -262,11 +259,13 @@ private void createPermbansTable() throws SQLException `uuid` VARCHAR(36), `username` VARCHAR(16), `reason` TEXT, + `updated_at` DATETIME NOT NULL DEFAULT NOW(), INDEX `idx_permbans_uuid` (`uuid`), INDEX `idx_permbans_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci """; statementHandler.executeUpdate(sql); + addColumnIfMissing("permbans", "updated_at", "DATETIME NOT NULL DEFAULT NOW()"); } private void createPermbanIpsTable() throws SQLException @@ -292,10 +291,12 @@ private void createStrikesTable() throws SQLException `strike_count` INT NOT NULL DEFAULT 0, `last_strike_unix` BIGINT NOT NULL DEFAULT 0, `last_username` VARCHAR(16), - `created_at` DATETIME DEFAULT NOW() + `created_at` DATETIME DEFAULT NOW(), + `updated_at` DATETIME NOT NULL DEFAULT NOW() ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci """; statementHandler.executeUpdate(sql); + addColumnIfMissing("strikes", "updated_at", "DATETIME NOT NULL DEFAULT NOW()"); } private void createDiscordLinksTable() throws SQLException @@ -305,10 +306,12 @@ private void createDiscordLinksTable() throws SQLException `id` INT AUTO_INCREMENT PRIMARY KEY, `admin_uuid` VARCHAR(36) NOT NULL UNIQUE, `discord_user_id` VARCHAR(32) NOT NULL UNIQUE, - `linked_at` DATETIME NOT NULL DEFAULT NOW() + `linked_at` DATETIME NOT NULL DEFAULT NOW(), + `updated_at` DATETIME NOT NULL DEFAULT NOW() ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci """; statementHandler.executeUpdate(sql); + addColumnIfMissing("discord_links", "updated_at", "DATETIME NOT NULL DEFAULT NOW()"); } private void createRanksTable() throws SQLException @@ -325,10 +328,12 @@ private void createRanksTable() throws SQLException `console_only` TINYINT(1) NOT NULL DEFAULT 0, `prefix` VARCHAR(64), `inherit_from` VARCHAR(64), + `updated_at` DATETIME NOT NULL DEFAULT NOW(), INDEX `idx_ranks_level` (`level`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci """; statementHandler.executeUpdate(sql); + addColumnIfMissing("ranks", "updated_at", "DATETIME NOT NULL DEFAULT NOW()"); } private void createRankPermissionsTable() throws SQLException @@ -358,10 +363,12 @@ private void createProtectedAreasTable() throws SQLException `max_y` INT NOT NULL, `max_z` INT NOT NULL, `world_uuid` VARCHAR(36) NOT NULL, + `updated_at` DATETIME NOT NULL DEFAULT NOW(), INDEX `idx_protected_areas_name` (`name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci """; statementHandler.executeUpdate(sql); + addColumnIfMissing("protected_areas", "updated_at", "DATETIME NOT NULL DEFAULT NOW()"); } private void createSavedFlagsTable() throws SQLException @@ -369,10 +376,12 @@ private void createSavedFlagsTable() throws SQLException String sql = """ CREATE TABLE IF NOT EXISTS `saved_flags` ( `flag_name` VARCHAR(64) PRIMARY KEY, - `enabled` TINYINT(1) NOT NULL DEFAULT 0 + `enabled` TINYINT(1) NOT NULL DEFAULT 0, + `updated_at` DATETIME NOT NULL DEFAULT NOW() ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci """; statementHandler.executeUpdate(sql); + addColumnIfMissing("saved_flags", "updated_at", "DATETIME NOT NULL DEFAULT NOW()"); } private void createPlayersTable() throws SQLException @@ -389,10 +398,12 @@ private void createPlayersTable() throws SQLException `commands_blocked` TINYINT(1) NOT NULL DEFAULT 0, `strikes` INT NOT NULL DEFAULT 0, `saved_tag` TEXT, - `nickname` TEXT + `nickname` TEXT, + `updated_at` DATETIME NOT NULL DEFAULT NOW() ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci """; statementHandler.executeUpdate(sql); + addColumnIfMissing("players", "updated_at", "DATETIME NOT NULL DEFAULT NOW()"); } private void createPlayerIpsTable() throws SQLException @@ -409,6 +420,23 @@ FOREIGN KEY (`username`) REFERENCES `players`(`username`) ON DELETE CASCADE statementHandler.executeUpdate(sql); } + /** + * Add a column to a table created before that column existed. Ignores the error + * when the column is already present (MySQL has no ADD COLUMN IF NOT EXISTS + * before 8.0.29, so this stays a try/catch like the rest of this file's migrations). + */ + private void addColumnIfMissing(String table, String column, String definition) + { + try + { + statementHandler.executeUpdate(String.format("ALTER TABLE `%s` ADD COLUMN `%s` %s", table, column, definition)); + } + catch (SQLException ignored) + { + // Column already exists. + } + } + // ============================================ // Repository Getters // ============================================ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java index 925ef7e46..7864b90d9 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java @@ -187,13 +187,15 @@ private void createAdminsTable() throws SQLException "active" BOOLEAN DEFAULT TRUE, "last_login" TIMESTAMP, "login_message" TEXT, - "custom_rank" VARCHAR(64) + "custom_rank" VARCHAR(64), + "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ) """; statementHandler.executeUpdate(sql); - // Migration for tables created before custom_rank existed. + // Migration for tables created before custom_rank/updated_at existed. statementHandler.executeUpdate("ALTER TABLE \"admins\" ADD COLUMN IF NOT EXISTS \"custom_rank\" VARCHAR(64)"); + statementHandler.executeUpdate("ALTER TABLE \"admins\" ADD COLUMN IF NOT EXISTS \"updated_at\" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP"); // Create indexes separately (PostgreSQL style) statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_admins_username ON \"admins\"(\"username\")"); @@ -224,10 +226,12 @@ private void createBansTable() throws SQLException "banned_by" VARCHAR(16), "banned_by_uuid" VARCHAR(36), "reason" TEXT, - "expire_at" TIMESTAMP + "expire_at" TIMESTAMP, + "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ) """; statementHandler.executeUpdate(sql); + statementHandler.executeUpdate("ALTER TABLE \"bans\" ADD COLUMN IF NOT EXISTS \"updated_at\" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP"); statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_bans_uuid ON \"bans\"(\"uuid\")"); statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_bans_username ON \"bans\"(\"username\")"); @@ -255,10 +259,12 @@ private void createPermbansTable() throws SQLException "id" SERIAL PRIMARY KEY, "uuid" VARCHAR(36), "username" VARCHAR(16), - "reason" TEXT + "reason" TEXT, + "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ) """; statementHandler.executeUpdate(sql); + statementHandler.executeUpdate("ALTER TABLE \"permbans\" ADD COLUMN IF NOT EXISTS \"updated_at\" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP"); statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_permbans_uuid ON \"permbans\"(\"uuid\")"); statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_permbans_username ON \"permbans\"(\"username\")"); @@ -286,10 +292,12 @@ private void createStrikesTable() throws SQLException "strike_count" INTEGER NOT NULL DEFAULT 0, "last_strike_unix" BIGINT NOT NULL DEFAULT 0, "last_username" VARCHAR(16), - "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP + "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ) """; statementHandler.executeUpdate(sql); + statementHandler.executeUpdate("ALTER TABLE \"strikes\" ADD COLUMN IF NOT EXISTS \"updated_at\" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP"); } private void createDiscordLinksTable() throws SQLException @@ -299,10 +307,12 @@ private void createDiscordLinksTable() throws SQLException "id" SERIAL PRIMARY KEY, "admin_uuid" VARCHAR(36) NOT NULL UNIQUE, "discord_user_id" VARCHAR(32) NOT NULL UNIQUE, - "linked_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + "linked_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ) """; statementHandler.executeUpdate(sql); + statementHandler.executeUpdate("ALTER TABLE \"discord_links\" ADD COLUMN IF NOT EXISTS \"updated_at\" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP"); } private void createRanksTable() throws SQLException @@ -318,10 +328,12 @@ private void createRanksTable() throws SQLException "admin" BOOLEAN NOT NULL DEFAULT FALSE, "console_only" BOOLEAN NOT NULL DEFAULT FALSE, "prefix" VARCHAR(64), - "inherit_from" VARCHAR(64) + "inherit_from" VARCHAR(64), + "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ) """; statementHandler.executeUpdate(sql); + statementHandler.executeUpdate("ALTER TABLE \"ranks\" ADD COLUMN IF NOT EXISTS \"updated_at\" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP"); statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_ranks_level ON \"ranks\"(\"level\")"); } @@ -350,10 +362,12 @@ private void createProtectedAreasTable() throws SQLException "max_x" INTEGER NOT NULL, "max_y" INTEGER NOT NULL, "max_z" INTEGER NOT NULL, - "world_uuid" VARCHAR(36) NOT NULL + "world_uuid" VARCHAR(36) NOT NULL, + "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ) """; statementHandler.executeUpdate(sql); + statementHandler.executeUpdate("ALTER TABLE \"protected_areas\" ADD COLUMN IF NOT EXISTS \"updated_at\" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP"); statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_protected_areas_name ON \"protected_areas\"(\"name\")"); } @@ -362,10 +376,12 @@ private void createSavedFlagsTable() throws SQLException String sql = """ CREATE TABLE IF NOT EXISTS "saved_flags" ( "flag_name" VARCHAR(64) PRIMARY KEY, - "enabled" BOOLEAN NOT NULL DEFAULT FALSE + "enabled" BOOLEAN NOT NULL DEFAULT FALSE, + "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ) """; statementHandler.executeUpdate(sql); + statementHandler.executeUpdate("ALTER TABLE \"saved_flags\" ADD COLUMN IF NOT EXISTS \"updated_at\" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP"); } private void createPlayersTable() throws SQLException @@ -382,10 +398,12 @@ private void createPlayersTable() throws SQLException "commands_blocked" BOOLEAN NOT NULL DEFAULT FALSE, "strikes" INTEGER NOT NULL DEFAULT 0, "saved_tag" TEXT, - "nickname" TEXT + "nickname" TEXT, + "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ) """; statementHandler.executeUpdate(sql); + statementHandler.executeUpdate("ALTER TABLE \"players\" ADD COLUMN IF NOT EXISTS \"updated_at\" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP"); } private void createPlayerIpsTable() throws SQLException diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java index 7300d67d9..1d72eea83 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java @@ -188,20 +188,15 @@ CREATE TABLE IF NOT EXISTS admins ( active INTEGER DEFAULT 1, last_login TEXT, login_message TEXT, - custom_rank TEXT + custom_rank TEXT, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) """; statementHandler.executeUpdate(sql); - // Migration for existing tables - try - { - statementHandler.executeUpdate("ALTER TABLE admins ADD COLUMN custom_rank TEXT"); - } - catch (SQLException ignored) - { - // Column already exists or table doesn't exist yet (handled by CREATE TABLE IF NOT EXISTS) - } + // Migration for tables created before custom_rank/updated_at existed. + addColumnIfMissing("admins", "custom_rank", "TEXT"); + addColumnIfMissing("admins", "updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"); // Create indexes statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_admins_username ON admins(username)"); @@ -233,10 +228,12 @@ CREATE TABLE IF NOT EXISTS bans ( banned_by TEXT, banned_by_uuid TEXT, reason TEXT, - expire_at TEXT + expire_at TEXT, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) """; statementHandler.executeUpdate(sql); + addColumnIfMissing("bans", "updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"); statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_bans_uuid ON bans(uuid)"); statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_bans_username ON bans(username)"); @@ -265,10 +262,12 @@ CREATE TABLE IF NOT EXISTS permbans ( id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT, username TEXT, - reason TEXT + reason TEXT, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) """; statementHandler.executeUpdate(sql); + addColumnIfMissing("permbans", "updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"); statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_permbans_uuid ON permbans(uuid)"); statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_permbans_username ON permbans(username)"); @@ -297,10 +296,12 @@ CREATE TABLE IF NOT EXISTS strikes ( strike_count INTEGER NOT NULL DEFAULT 0, last_strike_unix INTEGER NOT NULL DEFAULT 0, last_username TEXT, - created_at TEXT DEFAULT CURRENT_TIMESTAMP + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) """; statementHandler.executeUpdate(sql); + addColumnIfMissing("strikes", "updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"); } private void createDiscordLinksTable() throws SQLException @@ -310,10 +311,12 @@ CREATE TABLE IF NOT EXISTS discord_links ( id INTEGER PRIMARY KEY AUTOINCREMENT, admin_uuid TEXT NOT NULL UNIQUE, discord_user_id TEXT NOT NULL UNIQUE, - linked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + linked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) """; statementHandler.executeUpdate(sql); + addColumnIfMissing("discord_links", "updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"); } private void createRanksTable() throws SQLException @@ -329,10 +332,12 @@ CREATE TABLE IF NOT EXISTS ranks ( admin INTEGER NOT NULL DEFAULT 0, console_only INTEGER NOT NULL DEFAULT 0, prefix TEXT, - inherit_from TEXT + inherit_from TEXT, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) """; statementHandler.executeUpdate(sql); + addColumnIfMissing("ranks", "updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"); statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_ranks_level ON ranks(level)"); } @@ -362,10 +367,12 @@ CREATE TABLE IF NOT EXISTS protected_areas ( max_x INTEGER NOT NULL, max_y INTEGER NOT NULL, max_z INTEGER NOT NULL, - world_uuid TEXT NOT NULL + world_uuid TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) """; statementHandler.executeUpdate(sql); + addColumnIfMissing("protected_areas", "updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"); statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_protected_areas_name ON protected_areas(name)"); } @@ -374,10 +381,12 @@ private void createSavedFlagsTable() throws SQLException String sql = """ CREATE TABLE IF NOT EXISTS saved_flags ( flag_name TEXT PRIMARY KEY, - enabled INTEGER NOT NULL DEFAULT 0 + enabled INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) """; statementHandler.executeUpdate(sql); + addColumnIfMissing("saved_flags", "updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"); } private void createPlayersTable() throws SQLException @@ -394,10 +403,12 @@ CREATE TABLE IF NOT EXISTS players ( commands_blocked INTEGER NOT NULL DEFAULT 0, strikes INTEGER NOT NULL DEFAULT 0, saved_tag TEXT, - nickname TEXT + nickname TEXT, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) """; statementHandler.executeUpdate(sql); + addColumnIfMissing("players", "updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"); } private void createPlayerIpsTable() throws SQLException @@ -414,6 +425,22 @@ FOREIGN KEY (username) REFERENCES players(username) ON DELETE CASCADE statementHandler.executeUpdate(sql); } + /** + * Add a column to a table created before that column existed. Ignores the error + * when the column is already present (older SQLite has no ADD COLUMN IF NOT EXISTS). + */ + private void addColumnIfMissing(String table, String column, String definition) + { + try + { + statementHandler.executeUpdate(String.format("ALTER TABLE %s ADD COLUMN %s %s", table, column, definition)); + } + catch (SQLException ignored) + { + // Column already exists. + } + } + // ============================================ // Repository Getters // ============================================ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshIdentityStore.java b/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshIdentityStore.java index 8f6cbf927..dbf2c3771 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshIdentityStore.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshIdentityStore.java @@ -1,10 +1,10 @@ package me.totalfreedom.totalfreedommod.ssh; import com.google.gson.Gson; -import com.google.gson.GsonBuilder; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.JsonUtil; import java.io.File; import java.io.FileReader; @@ -21,7 +21,7 @@ public class SshIdentityStore { private static final DateTimeFormatter LOGIN_FMT = DateTimeFormatter.ofPattern("dd-MM-yy HH:mm"); - private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + private static final Gson GSON = JsonUtil.GSON; private final File directory; private final Map identities = new ConcurrentHashMap<>(); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/util/ConfigInterfaces.java b/src/main/java/me/totalfreedom/totalfreedommod/util/ConfigInterfaces.java index 9f3e9be41..ec25de1e0 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/util/ConfigInterfaces.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/util/ConfigInterfaces.java @@ -22,19 +22,6 @@ public interface ConfigLoadable void loadFrom(ConfigurationSection cs); } - /** - * Interface for objects that can be saved to a configuration section. - */ - public interface ConfigSavable - { - /** - * Saves data to a configuration section. - * - * @param cs The configuration section to save to - */ - void saveTo(ConfigurationSection cs); - } - /** * Interface for objects that can validate their own state. */ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/util/JsonUtil.java b/src/main/java/me/totalfreedom/totalfreedommod/util/JsonUtil.java new file mode 100644 index 000000000..70e78cec7 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/util/JsonUtil.java @@ -0,0 +1,44 @@ +package me.totalfreedom.totalfreedommod.util; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSerializer; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + +import java.util.Date; +import java.util.UUID; + +/** + * Shared Gson instance for the plugin's JSON-backed persistence (SQL write-through + * snapshots, bundled resource files). Registers adapters for the types Gson can't + * serialize sensibly on its own via reflection. + */ +public final class JsonUtil +{ + public static final Gson GSON = new GsonBuilder() + .setPrettyPrinting() + .registerTypeAdapter(UUID.class, (JsonSerializer) (src, type, ctx) -> new JsonPrimitive(src.toString())) + .registerTypeAdapter(UUID.class, (JsonDeserializer) (json, type, ctx) -> UUID.fromString(json.getAsString())) + .registerTypeAdapter(NamedTextColor.class, (JsonSerializer) (src, type, ctx) -> + new JsonPrimitive(NamedTextColor.NAMES.keyOrThrow(src))) + .registerTypeAdapter(NamedTextColor.class, (JsonDeserializer) (json, type, ctx) -> + { + NamedTextColor color = NamedTextColor.NAMES.value(json.getAsString().toLowerCase()); + return color != null ? color : NamedTextColor.WHITE; + }) + .registerTypeAdapter(Date.class, (JsonSerializer) (src, type, ctx) -> new JsonPrimitive(FUtil.dateToString(src))) + .registerTypeAdapter(Date.class, (JsonDeserializer) (json, type, ctx) -> FUtil.stringToDate(json.getAsString())) + .registerTypeAdapter(Component.class, (JsonSerializer) (src, type, ctx) -> + new JsonPrimitive(AdventureUtil.componentToLegacy(src))) + .registerTypeAdapter(Component.class, (JsonDeserializer) (json, type, ctx) -> + AdventureUtil.legacyToComponent(json.getAsString())) + .create(); + + private JsonUtil() + { + } +} diff --git a/src/main/resources/admins.json b/src/main/resources/admins.json new file mode 100644 index 000000000..7e6fb5249 --- /dev/null +++ b/src/main/resources/admins.json @@ -0,0 +1,33 @@ +{ + "prozza": { + "username": "Prozza", + "active": true, + "rank": "SENIOR_ADMIN", + "ips": [ + "127.0.0.1" + ], + "last_login": "Wed, 2 Apr 2016 16:08:39 +0200", + "login_message": "the &5Lead Developer&b!" + }, + "madgeek1450": { + "username": "Madgeek1450", + "active": true, + "rank": "SENIOR_ADMIN", + "ips": [ + "1.2.3.4", + "8.8.8.8" + ], + "last_login": "Wed, 2 Apr 2016 16:08:39 +0200", + "login_message": "the &4Co-Founder&b and &6Master-ass-kicker&b." + }, + "markbyron": { + "username": "markbyron", + "active": true, + "rank": "SENIOR_ADMIN", + "ips": [ + "8.8.4.4" + ], + "last_login": "Wed, 2 Apr 2016 16:08:39 +0200", + "login_message": "the &dOwner&b." + } +} diff --git a/src/main/resources/admins.yml b/src/main/resources/admins.yml deleted file mode 100644 index 970288111..000000000 --- a/src/main/resources/admins.yml +++ /dev/null @@ -1,31 +0,0 @@ -# -# TotalFreedomMod 5.0 Admin List -# - -prozza: - username: Prozza - active: true - rank: SENIOR_ADMIN - ips: - - 127.0.0.1 - last_login: Wed, 2 Apr 2016 16:08:39 +0200 - login_message: 'the &5Lead Developer&b!' - -madgeek1450: - username: Madgeek1450 - active: true - rank: SENIOR_ADMIN - ips: - - 1.2.3.4 - - 8.8.8.8 - last_login: Wed, 2 Apr 2016 16:08:39 +0200 - login_message: 'the &4Co-Founder&b and &6Master-ass-kicker&b.' - -markbyron: - username: markbyron - active: true - rank: SENIOR_ADMIN - ips: - - 8.8.4.4 - last_login: Wed, 2 Apr 2016 16:08:39 +0200 - login_message: 'the &dOwner&b.' diff --git a/src/main/resources/bans.json b/src/main/resources/bans.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/src/main/resources/bans.json @@ -0,0 +1 @@ +{} diff --git a/src/main/resources/bans.yml b/src/main/resources/bans.yml deleted file mode 100644 index cea37eadb..000000000 --- a/src/main/resources/bans.yml +++ /dev/null @@ -1,3 +0,0 @@ -# -# TotalFreedomMod 5.0 Player Bans -# diff --git a/src/main/resources/permbans.json b/src/main/resources/permbans.json new file mode 100644 index 000000000..e4b5568f6 --- /dev/null +++ b/src/main/resources/permbans.json @@ -0,0 +1,10 @@ +{ + "badplayer1": [ + "123.123.123.123", + "321.321.321.321" + ], + "badplayer2": [ + "111.111.111.111" + ], + "badplayer3": [] +} diff --git a/src/main/resources/permbans.yml b/src/main/resources/permbans.yml deleted file mode 100644 index 012f8d580..000000000 --- a/src/main/resources/permbans.yml +++ /dev/null @@ -1,10 +0,0 @@ -# -# TotalFreedomMod 5.0 Permanent Bans -# - -badplayer1: - - 123.123.123.123 - - 321.321.321.321 -badplayer2: - - 111.111.111.111 -badplayer3: [] diff --git a/src/main/resources/ranks.json b/src/main/resources/ranks.json new file mode 100644 index 000000000..068f552ad --- /dev/null +++ b/src/main/resources/ranks.json @@ -0,0 +1,161 @@ +{ + "impostor": { + "name": "Impostor", + "abbreviation": "Imp", + "prefix": "&8[&eImp&8] ", + "level": -1, + "color": "dark_gray", + "determiner": "an", + "admin": false, + "console_only": false, + "permissions": [ + "tfm.player.list", + "tfm.admin.overlord" + ] + }, + "non_op": { + "name": "Player", + "abbreviation": "", + "prefix": "", + "level": 0, + "color": "white", + "determiner": "a", + "admin": false, + "console_only": false, + "permissions": [ + "tfm.player.localspawn", + "tfm.player.radar", + "tfm.player.rank", + "tfm.player.spawn", + "tfm.player.list", + "tfm.world.flatlands", + "tfm.server.info" + ] + }, + "op": { + "name": "Operator", + "abbreviation": "OP", + "prefix": "&8[&cOP&8] ", + "level": 1, + "color": "green", + "determiner": "an", + "admin": false, + "console_only": false, + "inherit": "non_op", + "permissions": [ + "tfm.player.*", + "tfm.world.adminworld", + "tfm.admin.banlist", + "tfm.fun.hack", + "tfm.fun.landmine", + "tfm.fun.mp44", + "tfm.fun.spawnmob", + "tfm.fun.tossmob", + "tfm.server.whitelist", + "tfm.manage.saconfig" + ] + }, + "super_admin": { + "name": "Super Admin", + "abbreviation": "SA", + "prefix": "&8[&bSA&8] ", + "level": 2, + "color": "gold", + "determiner": "a", + "admin": true, + "console_only": false, + "inherit": "op", + "permissions": [ + "tfm.fun.*", + "tfm.world.*", + "tfm.admin.adminchat", + "tfm.admin.announce", + "tfm.admin.ban", + "tfm.admin.baninfo", + "tfm.admin.banlist", + "tfm.admin.blockcmd", + "tfm.admin.blockredstone", + "tfm.admin.cage", + "tfm.admin.cmdspy", + "tfm.admin.consolesay", + "tfm.admin.denick", + "tfm.admin.deop", + "tfm.admin.disguisetoggle", + "tfm.admin.findip", + "tfm.admin.freeze", + "tfm.admin.gcmd", + "tfm.admin.invis", + "tfm.admin.kick", + "tfm.admin.mute", + "tfm.admin.myadmin", + "tfm.admin.nickclean", + "tfm.admin.opall", + "tfm.admin.opme", + "tfm.admin.potspy", + "tfm.admin.premium", + "tfm.admin.purgeall", + "tfm.admin.ro", + "tfm.admin.say", + "tfm.admin.strike", + "tfm.admin.undisguiseall", + "tfm.admin.warn", + "tfm.admin.whohas", + "tfm.admin.wildcard", + "tfm.admin.gamemode", + "tfm.admin.protectregion", + "tfm.server.*", + "tfm.manage.saconfig" + ] + }, + "senior_admin": { + "name": "Senior Admin", + "abbreviation": "SrA", + "prefix": "&8[&6SrA&8] ", + "level": 3, + "color": "light_purple", + "determiner": "a", + "admin": true, + "console_only": false, + "inherit": "super_admin", + "permissions": [ + "tfm.admin.senior.*", + "tfm.admin.telnet.*", + "tfm.manage.*", + "tfm.manage.ssh", + "tfm.manage.telnet" + ] + }, + "developer": { + "name": "Developer", + "abbreviation": "Dev", + "prefix": "&8[&5Dev&8] ", + "level": 4, + "color": "dark_purple", + "determiner": "a", + "admin": true, + "console_only": false, + "inherit": "senior_admin" + }, + "owner": { + "name": "Owner", + "abbreviation": "Owner", + "prefix": "&8[&9Owner&8] ", + "level": 4, + "color": "blue", + "determiner": "the", + "admin": true, + "console_only": false, + "inherit": "senior_admin" + }, + "executive": { + "name": "Executive", + "abbreviation": "Exec", + "prefix": "&8[&eExec&8] ", + "level": 4, + "color": "yellow", + "determiner": "an", + "admin": true, + "console_only": false, + "inherit": "senior_admin" + } +} diff --git a/src/main/resources/ranks.yml b/src/main/resources/ranks.yml deleted file mode 100644 index 162557e8c..000000000 --- a/src/main/resources/ranks.yml +++ /dev/null @@ -1,189 +0,0 @@ -# TotalFreedomMod Ranks Configuration -# This file defines all custom ranks and their properties. -# -# Format: -# rankid: -# name: Display Name -# abbreviation: TAG -# prefix: "&8[&bTAG&8] " (chat prefix with color codes) -# level: (higher = more authority) -# color: -# determiner: a/an -# admin: true/false -# console_only: true/false -# inherit: (inherit all permissions from another rank) -# permissions: -# - permission.node -# - another.permission -# -# Available colors: -# black, dark_blue, dark_green, dark_aqua, dark_red, dark_purple, -# gold, gray, dark_gray, blue, green, aqua, red, light_purple, yellow, white -# -# Permission nodes are TFM-internal only (NOT Bukkit permission nodes). -# Since all players have OP, Bukkit permissions don't apply. -# Use these to restrict TFM features based on rank. -# -# Permission Categories: -# tfm.player.* - Basic player commands (NON_OP/OP level) -# tfm.fun.* - Fun commands (smite, doom, orbit, etc.) -# tfm.admin.* - Admin actions (ban, kick, mute, freeze, cage) -# tfm.admin.senior.* - Senior admin only actions -# tfm.server.* - Server management commands -# tfm.world.* - World management commands -# tfm.manage.* - Management commands (ranks, config) -# - -impostor: - name: Impostor - abbreviation: Imp - prefix: "&8[&eImp&8] " - level: -1 - color: dark_gray - determiner: an - admin: false - console_only: false - permissions: - - tfm.player.list - - tfm.admin.overlord - -non_op: - name: Player - abbreviation: "" - prefix: "" - level: 0 - color: white - determiner: a - admin: false - console_only: false - permissions: - - tfm.player.localspawn - - tfm.player.radar - - tfm.player.rank - - tfm.player.spawn - - tfm.player.list - - tfm.world.flatlands - - tfm.server.info - -op: - name: Operator - abbreviation: OP - prefix: "&8[&cOP&8] " - level: 1 - color: green - determiner: an - admin: false - console_only: false - inherit: non_op - permissions: - - tfm.player.* - - tfm.world.adminworld - - tfm.admin.banlist - - tfm.fun.hack - - tfm.fun.landmine - - tfm.fun.mp44 - - tfm.fun.spawnmob - - tfm.fun.tossmob - - tfm.server.whitelist - - tfm.manage.saconfig - -super_admin: - name: Super Admin - abbreviation: SA - prefix: "&8[&bSA&8] " - level: 2 - color: gold - determiner: a - admin: true - console_only: false - inherit: op - permissions: - - tfm.fun.* - - tfm.world.* - - tfm.admin.adminchat - - tfm.admin.announce - - tfm.admin.ban - - tfm.admin.baninfo - - tfm.admin.banlist - - tfm.admin.blockcmd - - tfm.admin.blockredstone - - tfm.admin.cage - - tfm.admin.cmdspy - - tfm.admin.consolesay - - tfm.admin.denick - - tfm.admin.deop - - tfm.admin.disguisetoggle - - tfm.admin.findip - - tfm.admin.freeze - - tfm.admin.gcmd - - tfm.admin.invis - - tfm.admin.kick - - tfm.admin.mute - - tfm.admin.myadmin - - tfm.admin.nickclean - - tfm.admin.opall - - tfm.admin.opme - - tfm.admin.potspy - - tfm.admin.premium - - tfm.admin.purgeall - - tfm.admin.ro - - tfm.admin.say - - tfm.admin.strike - - tfm.admin.undisguiseall - - tfm.admin.warn - - tfm.admin.whohas - - tfm.admin.wildcard - - tfm.admin.gamemode - - tfm.admin.protectregion - - tfm.server.* - - tfm.manage.saconfig - -senior_admin: - name: Senior Admin - abbreviation: SrA - prefix: "&8[&6SrA&8] " - level: 3 - color: light_purple - determiner: a - admin: true - console_only: false - inherit: super_admin - permissions: - - tfm.admin.senior.* - - tfm.admin.telnet.* - - tfm.manage.* - - tfm.manage.ssh - - tfm.manage.telnet - -developer: - name: Developer - abbreviation: Dev - prefix: "&8[&5Dev&8] " - level: 4 - color: dark_purple - determiner: a - admin: true - console_only: false - inherit: senior_admin - -owner: - name: Owner - abbreviation: Owner - prefix: "&8[&9Owner&8] " - level: 4 - color: blue - determiner: the - admin: true - console_only: false - inherit: senior_admin - -executive: - name: Executive - abbreviation: Exec - prefix: "&8[&eExec&8] " - level: 4 - color: yellow - determiner: an - admin: true - console_only: false - inherit: senior_admin diff --git a/src/main/resources/version.json b/src/main/resources/version.json new file mode 100644 index 000000000..61a2092b1 --- /dev/null +++ b/src/main/resources/version.json @@ -0,0 +1,3 @@ +{ + "version": 1 +} diff --git a/src/main/resources/version.yml b/src/main/resources/version.yml deleted file mode 100644 index 012544e68..000000000 --- a/src/main/resources/version.yml +++ /dev/null @@ -1,4 +0,0 @@ -# -# Config version -# -version: 1 From b5af338fc9752d5cd26c9c10bf5b62458a89e309 Mon Sep 17 00:00:00 2001 From: Paldiu Date: Thu, 30 Jul 2026 01:26:52 -0500 Subject: [PATCH 05/48] adjust stuff --- README.md | 2 +- build.gradle | 40 +- .../totalfreedommod/admin/AdminList.java | 1232 +++++++++-------- src/main/resources/ranks.yml | 190 +++ 4 files changed, 852 insertions(+), 612 deletions(-) create mode 100644 src/main/resources/ranks.yml diff --git a/README.md b/README.md index e863bc285..745999d64 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # TotalFreedomMod r03 ![Release](https://img.shields.io/github/v/release/tfreedomorg/TotalFreedomMod?include_prereleases&style=plastic) -![Version](https://img.shields.io/badge/version-26.7-green?style=plastic) +![Version](https://img.shields.io/badge/version-26.7.1-green?style=plastic) ![License](https://img.shields.io/badge/license-TFGL%20v2.0-orange?style=plastic) ![Code size](https://img.shields.io/github/languages/code-size/tfreedomorg/TotalFreedomMod?style=plastic) diff --git a/build.gradle b/build.gradle index 5c46d0e0d..de2e7901e 100644 --- a/build.gradle +++ b/build.gradle @@ -4,7 +4,7 @@ plugins { } group = 'me.totalfreedom' -version = '26.7.1' +version = '26.9' java { toolchain { @@ -39,35 +39,43 @@ repositories { } dependencies { - compileOnly 'io.papermc.paper:paper-api:26.1.2.build.61-stable' - compileOnly 'org.projectlombok:lombok:1.18.42' // slated for removal - annotationProcessor 'org.projectlombok:lombok:1.18.42' // slated for removal - // compileOnly 'org.apache.commons:commons-lang3:3.14.0' this is supplied by paper, we don't need the import. + // deprecated + compileOnly 'org.projectlombok:lombok:1.18.42' + annotationProcessor 'org.projectlombok:lombok:1.18.42' compileOnly 'commons-io:commons-io:2.16.1' - compileOnly 'net.kyori:adventure-text-serializer-ansi:4.17.0' compileOnly 'net.milkbowl.vault:VaultUnlockedAPI:2.16' + + // paper + compileOnly 'io.papermc.paper:paper-api:26.1.2.build.61-stable' + compileOnly 'net.kyori:adventure-text-serializer-ansi:4.17.0' + + // plugins compileOnly('com.sk89q.worldedit:worldedit-bukkit:7.3.10') { transitive = false } compileOnly('com.sk89q.worldedit:worldedit-core:7.3.10') { transitive = false } + compileOnly "net.coreprotect:coreprotect:22.4" + // packets + compileOnly 'com.github.retrooper:packetevents-spigot:2.12.1' + + // discord stuff, TBR w/ Discord4J <3 + compileOnly 'net.dv8tion:JDA:5.6.1' + + // ssh compileOnly 'org.apache.sshd:sshd-core:2.17.1' - //sql section + // sql + compileOnly 'io.projectreactor:reactor-core:3.7.6' compileOnly 'org.postgresql:postgresql:42.7.7' compileOnly 'org.xerial:sqlite-jdbc:3.49.1.0' compileOnly 'com.mysql:mysql-connector-j:9.3.0' - compileOnly 'io.projectreactor:reactor-core:3.7.6' compileOnly 'com.zaxxer:HikariCP:6.3.0' + // logging compileOnly 'org.jline:jline:3.28.0' compileOnly 'org.apache.logging.log4j:log4j-core:2.24.3' - - compileOnly 'net.dv8tion:JDA:5.6.1' - - compileOnly 'com.github.retrooper:packetevents-spigot:2.12.1' - - compileOnly "net.coreprotect:coreprotect:22.4" } +// ... is this really necessary??? configurations.all { resolutionStrategy { force 'io.papermc.paper:paper-api:26.1.2.build.61-stable' @@ -143,8 +151,8 @@ processResources { } expand( - buildAuthor: project.findProperty('buildAuthor') ?: 'Ivanomoly', - buildCodeName: project.findProperty('buildCodeName') ?: 'Polaris', + buildAuthor: project.findProperty('buildAuthor') ?: 'TotalFreedom', // Collective Effort :) + buildCodeName: project.findProperty('buildCodeName') ?: 'Sirius', buildNumber: buildNumber, buildVersion: project.version ) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java b/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java index bebdb5cd2..525ee9586 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java @@ -4,6 +4,14 @@ import com.google.common.collect.Maps; import com.google.common.collect.Sets; import com.google.gson.reflect.TypeToken; +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.sql.SQLException; +import java.time.Duration; import java.util.ArrayList; import java.util.Date; import java.util.List; @@ -11,24 +19,15 @@ import java.util.Set; import java.util.UUID; import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.rank.Rank; -import java.nio.charset.StandardCharsets; import me.totalfreedom.totalfreedommod.sql.adapter.AdminRepository; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FUtil; import me.totalfreedom.totalfreedommod.util.JsonUtil; -import java.io.File; -import java.io.FileReader; -import java.io.FileWriter; -import java.io.IOException; -import java.lang.reflect.Type; import org.bukkit.Bukkit; -import reactor.core.publisher.Mono; -import reactor.core.scheduler.Schedulers; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; @@ -36,6 +35,9 @@ import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.plugin.ServicePriority; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; public class AdminList extends FreedomService { @@ -60,11 +62,16 @@ public class AdminList extends FreedomService // private final File configFile; - // Flag to track if SQL is available - private boolean usingSql = false; private final Object persistenceLock = new Object(); + + // Serialises every queued write so a stale snapshot can never land after a + // newer one. Guarded by persistenceLock; collapsed back to Mono.empty() once + // the tail completes so the operator chain cannot grow without bound. private Mono persistenceChain = Mono.empty(); + // Flag to track if SQL is available + private boolean usingSql = false; + public AdminList(TotalFreedomMod plugin) { super(plugin); @@ -116,234 +123,6 @@ public void load() } } - /** - * Best-effort UUID backfill for admin records loaded without a stored UUID. - */ - private void getMissingUuids() - { - int resolved = 0; - int offlineDerived = 0; - boolean mojangLookup = ConfigEntry.ADMINLIST_MOJANG_UUID_LOOKUP.getBoolean(); - final List backfilled = new ArrayList<>(); - - for (Admin admin : allAdmins.values()) - { - if (admin.getUuid() != null) - { - continue; - } - - UUID uuid = FUtil.usernameToUuid(admin.getName()); - if (uuid != null) - { - resolved++; - } - else - { - uuid = UUID.nameUUIDFromBytes(("OfflinePlayer:" + admin.getName().toLowerCase()).getBytes(StandardCharsets.UTF_8)); - offlineDerived++; - } - admin.setUuid(uuid); - backfilled.add(admin); - } - - if (backfilled.isEmpty()) - { - return; - } - - FLog.info("UUID backfill: " + resolved + " resolved via Mojang, " + offlineDerived + " offline-derived"); - if (offlineDerived > 0 && !mojangLookup) - { - FLog.warning("use_uuid_only is enabled but mojang_uuid_lookup is disabled; " - + offlineDerived + " admin record(s) fell back to offline-derived UUIDs and " - + "will not match premium accounts on login"); - } - - updateTables(); - - // SQL can persist just the rows we touched; YAML is a whole-file format - // so one bulk write beats N rewrites of the same file. - if (usingSql) - { - backfilled.forEach(this::saveAdminAsync); - } - else - { - saveAsync(); - } - } - - /** - * Load admins from SQL database. - */ - private void loadFromSql() - { - try - { - AdminRepository repo = plugin.dm.getAdminRepository(); - List admins = repo.findAll().block(); - - allAdmins.clear(); - for (Admin admin : admins) - { - String key = admin.getName().toLowerCase(); - admin = fixConfigKey(admin, key); - allAdmins.put(key, admin); - } - - usingSql = true; - updateTables(); - FLog.info("Loaded " + allAdmins.size() + " admins from SQL database (" + nameTable.size() + " active, " + ipTable.size() + " IPs)"); - - reconcileFromJsonIfNewer(repo); - } - catch (Exception ex) - { - FLog.warning("Failed to load admins from SQL, falling back to JSON: " + ex.getMessage()); - loadFromJson(); - } - } - - /** - * If admins.json was written more recently than the database's last update (e.g. edited - * by hand, or restored from backup while SQL was unavailable), re-import it into SQL. - */ - private void reconcileFromJsonIfNewer(AdminRepository repo) - { - if (!configFile.exists()) - { - return; - } - - try - { - Long sqlUpdatedAt = repo.getMaxUpdatedAt(); - if (sqlUpdatedAt != null && configFile.lastModified() <= sqlUpdatedAt) - { - return; - } - - Map jsonAdmins = readJsonAdmins(); - if (jsonAdmins.isEmpty()) - { - return; - } - - FLog.info("admins.json is newer than the database; re-importing " + jsonAdmins.size() + " admin(s) from it."); - for (Admin admin : jsonAdmins.values()) - { - if (!admin.isValid()) - { - continue; - } - UUID uuid = resolveUuid(admin); - admin.setUuid(uuid); - repo.save(uuid, admin).block(); - } - - allAdmins.clear(); - allAdmins.putAll(jsonAdmins); - updateTables(); - } - catch (Exception ex) - { - FLog.warning("Failed to reconcile " + CONFIG_FILENAME + " into the database: " + ex.getMessage()); - } - } - - /** - * Resolve a UUID for an admin missing one: Mojang lookup by name, falling back to an - * offline-derived UUID. - */ - private UUID resolveUuid(Admin admin) - { - if (admin.getUuid() != null) - { - return admin.getUuid(); - } - UUID uuid = FUtil.usernameToUuid(admin.getName()); - if (uuid == null) - { - uuid = UUID.nameUUIDFromBytes(("OfflinePlayer:" + admin.getName().toLowerCase()).getBytes(StandardCharsets.UTF_8)); - } - return uuid; - } - - private Map readJsonAdmins() throws IOException - { - try (FileReader reader = new FileReader(configFile)) - { - Map admins = JsonUtil.GSON.fromJson(reader, ADMIN_MAP_TYPE); - return admins != null ? admins : Maps.newHashMap(); - } - } - - /** - * Fix the config key on an admin (needed when loading from SQL). - */ - private Admin fixConfigKey(Admin admin, String key) - { - // Use reflection or create new admin to set configKey - // Since configKey is private with no setter, we need to recreate - if (admin.getConfigKey() == null || !admin.getConfigKey().equals(key)) - { - Admin fixed = new Admin(key); - fixed.setUuid(admin.getUuid()); - fixed.setName(admin.getName()); - fixed.setRank(admin.getRank()); - fixed.setActive(admin.isActive()); - fixed.setLastLogin(admin.getLastLogin()); - fixed.setLoginMessage(admin.getLoginMessage()); - fixed.setCustomRankId(admin.getCustomRankId()); - fixed.addIps(admin.getIps()); - return fixed; - } - return admin; - } - - /** - * Load admins from JSON file (fallback). - */ - private void loadFromJson() - { - if (!configFile.exists()) - { - try - { - configFile.getParentFile().mkdirs(); - configFile.createNewFile(); - } - catch (IOException ex) - { - FLog.severe("Could not create " + CONFIG_FILENAME); - } - } - - allAdmins.clear(); - try - { - for (Map.Entry entry : readJsonAdmins().entrySet()) - { - Admin admin = entry.getValue(); - if (admin == null || !admin.isValid()) - { - FLog.warning("Could not load admin: " + entry.getKey() + ". Missing details!"); - continue; - } - allAdmins.put(entry.getKey(), admin); - } - } - catch (IOException ex) - { - FLog.severe("Could not read " + CONFIG_FILENAME + ": " + ex.getMessage()); - } - - usingSql = false; - updateTables(); - FLog.info("Loaded " + allAdmins.size() + " admins from JSON (" + nameTable.size() + " active, " + ipTable.size() + " IPs)"); - } - /** * Blocking write of every admin record. This is the shutdown flush - it is * called from {@link #onStop()} so nothing is lost when the server stops. @@ -372,7 +151,7 @@ public synchronized void save() */ public void awaitPendingWrites(long timeoutMs) { - final CompletableFuture pending; + final Mono pending; synchronized (persistenceLock) { pending = persistenceChain; @@ -380,62 +159,58 @@ public void awaitPendingWrites(long timeoutMs) try { - pending.get(timeoutMs, TimeUnit.MILLISECONDS); - } - catch (TimeoutException ex) - { - FLog.warning("Timed out after " + timeoutMs + "ms waiting for pending admin writes; flushing anyway"); + pending.block(Duration.ofMillis(timeoutMs)); } - catch (InterruptedException ex) + catch (IllegalStateException ex) { - Thread.currentThread().interrupt(); + // Reactor answers a blocking-read timeout with IllegalStateException. + FLog.warning(String.format("Gave up after %dms waiting for pending admin writes (%s); flushing anyway", + timeoutMs, ex.getMessage())); } - catch (Exception ex) + catch (RuntimeException ex) { - FLog.warning("A queued admin write failed before shutdown: " + ex.getMessage()); + FLog.warning(String.format("A queued admin write failed before shutdown: %s", ex.getMessage())); } } /** - * Persist every admin record on a worker thread. Under SQL this is a - * fan-out: one queued write per admin, whether or not it changed. + * Persist every admin record off the main thread. Under SQL the whole + * list goes out as one queued batch, written serially so the connection pool + * is not swamped. *

- * Only call this when the whole list is genuinely dirty, or when running on - * YAML (a whole-file format that cannot be written piecemeal). If a single - * entry changed - a login, an IP edit, a rank change - call + * Only call this when the whole list is genuinely dirty. If a single entry + * changed - a login, an IP edit, a rank change - call * {@link #saveAdminAsync(Admin)} instead, which queues just that row. */ public void saveAsync() { if (usingSql) { - for (Admin admin : List.copyOf(allAdmins.values())) - { - saveAdminAsync(admin); - } + queueSqlWrites(allAdmins.values() + .stream() + .map(this::pendingWrite) + .toList()); return; } // Render on this thread while we still own the maps, then hand the // finished text to the worker. Serialising inside the async task would // read allAdmins off-thread while the main thread is free to mutate it. - final String data = serialiseAdmins(); + final String json = serialiseAdmins(); if (!plugin.isEnabled()) { - saveToJson(); + writeJson(json); return; } - plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> - { - synchronized (AdminList.this) - { - saveToJson(); - } - }); + enqueue(writeJsonAsync(json)); } + /** + * Queue a single admin row for an off-thread write, followed by a refresh of + * the JSON snapshot. Safe to call from commands and event handlers. + */ public void saveAdminAsync(Admin admin) { if (admin == null) @@ -449,223 +224,68 @@ public void saveAdminAsync(Admin admin) return; } - if (plugin.dm == null || !plugin.dm.isInitialized()) - { - FLog.warning("SQL not available; admin change was not saved for " + admin.getName()); - return; - } - - final Admin snapshot = copyAdmin(admin); + queueSqlWrites(List.of(pendingWrite(admin))); + } - synchronized (persistenceLock) - { - persistenceChain = persistenceChain - .onErrorResume(ignored -> Mono.empty()) - .then(plugin.dm.getAdminRepository().save(finalUuid, snapshot)) - .onErrorResume(ex -> - { - FLog.warning("Failed to save admin " + snapshot.getName() + " to SQL: " + ex.getMessage()); - return Mono.empty(); - }) - .then(Mono.fromRunnable(this::saveToJson)) - .then() - .cache(); - persistenceChain.subscribe(); - } + public synchronized boolean isAdminSync(CommandSender sender) + { + return isAdmin(sender); } - /** - * Resolve the UUID for a queued write. Runs on the persistence chain rather - * than the caller, because {@link FUtil#usernameToUuid} can make a blocking - * Mojang request with a 5s connect and 5s read timeout - that must never - * land on the main thread during play. The resolved value is handed back to - * the live entry on the main thread, which owns the lookup tables. - */ - private CompletableFuture resolveUuid(Admin live, Admin snapshot) + public boolean isAdmin(CommandSender sender) { - if (snapshot.getUuid() != null) + if (!(sender instanceof Player)) { - return CompletableFuture.completedFuture(snapshot.getUuid()); + return true; } - UUID resolved = FUtil.usernameToUuid(snapshot.getName()); - if (resolved == null) + Admin admin = getAdmin((Player) sender); + + return admin != null && admin.isActive(); + } + + public boolean isSeniorAdmin(CommandSender sender) + { + Admin admin = getAdmin(sender); + if (admin == null) { - resolved = UUID.nameUUIDFromBytes(("OfflinePlayer:" + snapshot.getName().toLowerCase()).getBytes(StandardCharsets.UTF_8)); + return false; } - snapshot.setUuid(resolved); + return admin.getRank().ordinal() >= Rank.SENIOR_ADMIN.ordinal(); + } - final UUID finalResolved = resolved; - try + public Admin getAdmin(CommandSender sender) + { + if (sender instanceof Player player) // this instead of two separate methods. { - if (plugin.isEnabled()) + if (ConfigEntry.ADMINLIST_USE_UUID_ONLY.getBoolean()) { - plugin.getServer().getScheduler().runTask(plugin, () -> + Admin uuidAdmin = uuidTable.get(player.getUniqueId()); + if (uuidAdmin == null || !uuidAdmin.isActive()) + { + return null; + } + // Rewrite the stored display name if Mojang has changed it. + if (!uuidAdmin.getName().equalsIgnoreCase(player.getName())) { - if (live.getUuid() == null) + final String oldKey = uuidAdmin.getName().toLowerCase(); + uuidAdmin.setName(player.getName()); + final String newKey = uuidAdmin.getName().toLowerCase(); + if (!oldKey.equals(newKey)) { - live.setUuid(finalResolved); - uuidTable.put(finalResolved, live); - } - }); - } - } - catch (RuntimeException ex) - { - // Plugin disabled between the check and the schedule; Bukkit answers - // that with IllegalPluginAccessException. The snapshot already holds - // the UUID, so let the write below proceed regardless. - FLog.debug("Could not sync resolved UUID back to " + snapshot.getName() + "; server is stopping"); - } - - return CompletableFuture.completedFuture(finalResolved); - } - - private Admin copyAdmin(Admin admin) - { - Admin copy = new Admin(admin.getConfigKey()); - copy.setUuid(admin.getUuid()); - copy.setName(admin.getName()); - copy.setRank(admin.getRank()); - copy.setActive(admin.isActive()); - copy.setLastLogin(admin.getLastLogin() == null ? null : new Date(admin.getLastLogin().getTime())); - copy.setLoginMessage(admin.getLoginMessage()); - copy.setCustomRankId(admin.getCustomRankId()); - copy.addIps(new ArrayList<>(admin.getIps())); - return copy; - } - - /** - * Save all admins to SQL database. - */ - private void saveToSql() - { - if (plugin.dm == null || !plugin.dm.isInitialized()) - { - FLog.warning("SQL not available, falling back to YAML save"); - saveToJson(); - return; - } - - final AdminRepository repo = plugin.dm.getAdminRepository(); - int saved = 0; - int failed = 0; - - // Isolate per admin: this is the shutdown flush, so one unwritable row - // must not take the rest of the list down with it. - for (Admin admin : allAdmins.values()) - { - try - { - UUID uuid = admin.getUuid(); - if (uuid == null) - { - // Generate UUID if not present. Blocking Mojang lookup is - // acceptable here - this only runs at startup/shutdown. - uuid = FUtil.usernameToUuid(admin.getName()); - if (uuid == null) - { - uuid = UUID.nameUUIDFromBytes(("OfflinePlayer:" + admin.getName().toLowerCase()).getBytes(StandardCharsets.UTF_8)); - } - admin.setUuid(uuid); - } - repo.save(uuid, admin).block(); - } - FLog.debug("Saved " + allAdmins.size() + " admins to SQL database"); - saveToJson(); - } - - // Don't fall back to YAML on failure - we don't want conflicting data. - if (failed > 0) - { - FLog.warning("Failed to save admins to SQL: " + ex.getMessage()); - // Don't fall back to JSON here - we don't want to create conflicting data - } - } - - /** - * Save all admins to the JSON file (fallback, and the write-through snapshot when using SQL). - */ - private void saveToJson() - { - try (FileWriter writer = new FileWriter(configFile)) - { - JsonUtil.GSON.toJson(allAdmins, ADMIN_MAP_TYPE, writer); - } - catch (IOException ex) - { - FLog.severe("Could not save " + CONFIG_FILENAME); - } - } - - /** - * Save all admins to YAML file (fallback). Blocking - startup/shutdown only. - */ - private void saveToYaml() - { - writeYaml(serialiseAdmins()); - } - - public synchronized boolean isAdminSync(CommandSender sender) - { - return isAdmin(sender); - } - - public boolean isAdmin(CommandSender sender) - { - if (!(sender instanceof Player)) - { - return true; - } - - Admin admin = getAdmin((Player) sender); - - return admin != null && admin.isActive(); - } - - public boolean isSeniorAdmin(CommandSender sender) - { - Admin admin = getAdmin(sender); - if (admin == null) - { - return false; - } - - return admin.getRank().ordinal() >= Rank.SENIOR_ADMIN.ordinal(); - } - - public Admin getAdmin(CommandSender sender) - { - if (sender instanceof Player player) // this instead of two separate methods. - { - if (ConfigEntry.ADMINLIST_USE_UUID_ONLY.getBoolean()) - { - Admin uuidAdmin = uuidTable.get(player.getUniqueId()); - if (uuidAdmin == null || !uuidAdmin.isActive()) - { - return null; - } - // Rewrite the stored display name if Mojang has changed it. - if (!uuidAdmin.getName().equalsIgnoreCase(player.getName())) - { - final String oldKey = uuidAdmin.getName().toLowerCase(); - uuidAdmin.setName(player.getName()); - final String newKey = uuidAdmin.getName().toLowerCase(); - if (!oldKey.equals(newKey)) - { - nameTable.remove(oldKey); - nameTable.put(newKey, uuidAdmin); + nameTable.remove(oldKey); + nameTable.put(newKey, uuidAdmin); } saveAdminAsync(uuidAdmin); } return uuidAdmin; } - + // Find admin final String ip = player.getAddress().getAddress().getHostAddress(); Admin admin = getEntryByName(player.getName()); - + // Admin by name if (admin != null) { @@ -682,10 +302,11 @@ public Admin getAdmin(CommandSender sender) } return admin; } - - // Impostor + + // Impostor: the name is ours but the IP is not. Fall through to + // the IP lookup, which will not match this entry. } - + // Admin by ip admin = getEntryByIp(ip); if (admin != null) @@ -704,8 +325,8 @@ public Admin getAdmin(CommandSender sender) } saveAdminAsync(admin); } - - return null; + + return admin; } return getEntryByName(sender.getName()); @@ -729,15 +350,12 @@ public Admin getEntryByIpFuzzy(String needleIp) return directAdmin; } - for (String ip : ipTable.keySet()) - { - if (FUtil.fuzzyIpMatch(needleIp, ip, 3)) - { - return ipTable.get(ip); - } - } - - return null; + return ipTable.entrySet() + .stream() + .filter(entry -> FUtil.fuzzyIpMatch(needleIp, entry.getKey(), 3)) + .map(Map.Entry::getValue) + .findFirst() + .orElse(null); } public void updateLastLogin(Player player) @@ -775,14 +393,14 @@ public boolean isIdentityMatched(Player player) } Admin admin = getAdmin(player); - return admin == null ? false : admin.getName().equalsIgnoreCase(player.getName()); + return admin != null && admin.getName().equalsIgnoreCase(player.getName()); } public boolean addAdmin(Admin admin) { if (!admin.isValid()) { - FLog.warning("Could not add admin: " + admin.getConfigKey() + " Admin is missing details!"); + FLog.warning(String.format("Could not add admin: %s Admin is missing details!", admin.getConfigKey())); return false; } @@ -795,25 +413,17 @@ public boolean addAdmin(Admin admin) // Save admin if (usingSql) { - saveAdminToSql(admin); + saveAdminAsync(admin); } else { - saveToJson(); + saveAsync(); } refreshWorldEditBypassForAdmin(admin); return true; } - /** - * Save a single admin to SQL database. - */ - private void saveAdminToSql(Admin admin) - { - saveAdminAsync(admin); - } - public boolean removeAdmin(Admin admin) { // Remove admin, update views @@ -830,84 +440,13 @@ public boolean removeAdmin(Admin admin) } else { - saveToJson(); + saveAsync(); } refreshWorldEditBypassForAdmin(admin); return true; } - private void refreshWorldEditBypassForAdmin(Admin admin) - { - if (plugin.web == null) - { - return; - } - try - { - org.bukkit.entity.Player online = null; - final UUID uuid = admin.getUuid(); - if (uuid != null) - { - online = plugin.getServer().getPlayer(uuid); - } - if (online == null && admin.getName() != null) - { - online = plugin.getServer().getPlayerExact(admin.getName()); - } - if (online != null) - { - plugin.web.refreshBypassNegation(online); - } - } - catch (Throwable t) - { - FLog.warning("Failed to refresh WorldEdit bypass negation: " + t.getMessage()); - } - } - - /** - * Remove admin from SQL database. - */ - private void removeAdminFromSql(Admin admin) - { - if (plugin.dm == null || !plugin.dm.isInitialized()) - { - return; - } - - UUID uuid = admin.getUuid(); - String name = admin.getName(); - - synchronized (persistenceLock) - { - persistenceChain = persistenceChain - .onErrorResume(ignored -> Mono.empty()) - .then(uuid != null - ? plugin.dm.getAdminRepository().deleteByUuid(uuid).then() - : Mono.fromRunnable(() -> - { - try - { - plugin.dm.getAdminRepository().deleteByUsername(name); - } - catch (Exception ex) - { - throw new RuntimeException(ex); - } - }).subscribeOn(Schedulers.boundedElastic())) - .onErrorResume(ex -> - { - FLog.warning("Failed to remove admin " + name + " from SQL: " + ex.getMessage()); - return Mono.empty(); - }) - .then(Mono.fromRunnable(this::saveToJson)) - .then() - .cache(); - persistenceChain.subscribe(); - } - } - /** * Refresh the IP lookup table for a single admin. Use this instead of * {@link #updateTables()} when only one entry's IP list has changed. @@ -937,42 +476,36 @@ public void updateTables() uuidTable.clear(); onlineAdminPlayers.clear(); - for (Admin admin : allAdmins.values()) + allAdmins.values().forEach(admin -> { // Always populate UUID table if (admin.getUuid() != null) { uuidTable.put(admin.getUuid(), admin); } - + if (!admin.isActive()) { - continue; + return; } activeAdmins.add(admin); nameTable.put(admin.getName().toLowerCase(), admin); - - for (String ip : admin.getIps()) - { - ipTable.put(ip, admin); - } - - } + admin.getIps().forEach(ip -> ipTable.put(ip, admin)); + }); // Re-populate online-admin cache from currently-online players. - for (Player online : Bukkit.getOnlinePlayers()) + Bukkit.getOnlinePlayers() + .stream() + .filter(this::isAdmin) + .forEach(onlineAdminPlayers::add); + + if (plugin.wm != null && plugin.wm.adminworld != null) { - if (isAdmin(online)) - { - onlineAdminPlayers.add(online); - } + plugin.wm.adminworld.wipeAccessCache(); } - - plugin.wm.adminworld.wipeAccessCache(); - } - + public Map getAllAdmins() { return allAdmins; @@ -1024,30 +557,539 @@ public void onPlayerQuit(PlayerQuitEvent event) public void deactivateOldEntries(boolean verbose) { - for (Admin admin : allAdmins.values()) + final long threshold = ConfigEntry.ADMINLIST_CLEAN_THESHOLD_HOURS.getInteger(); + + allAdmins.values() + .stream() + .filter(Admin::isActive) + .filter(admin -> !admin.getRank().isAtLeast(Rank.SENIOR_ADMIN)) + // A record with no recorded login has nothing to age out against. + .filter(admin -> admin.getLastLogin() != null) + .filter(admin -> inactiveHours(admin) >= threshold) + .forEach(admin -> + { + if (verbose) + { + FUtil.adminAction("TotalFreedomMod", String.format( + "Deactivating superadmin %s, inactive for %d hours", + admin.getName(), inactiveHours(admin)), true); + } + + admin.setActive(false); + saveAdminAsync(admin); + }); + + updateTables(); + } + + /** + * Best-effort UUID backfill for admin records loaded without a stored UUID. + * Runs at startup only: {@link FUtil#usernameToUuid} may make a blocking + * Mojang request, which must never happen during play. + */ + private void getMissingUuids() + { + final List backfilled = allAdmins.values() + .stream() + .filter(admin -> admin.getUuid() == null) + .map(AdminList::backfillUuid) + .toList(); + + if (backfilled.isEmpty()) { - if (!admin.isActive() || admin.getRank().isAtLeast(Rank.SENIOR_ADMIN)) - { - continue; - } + return; + } - final Date lastLogin = admin.getLastLogin(); - final long lastLoginHours = TimeUnit.HOURS.convert(new Date().getTime() - lastLogin.getTime(), TimeUnit.MILLISECONDS); + final long resolved = backfilled.stream() + .filter(UuidBackfill::fromLookup) + .count(); + final long offlineDerived = backfilled.size() - resolved; - if (lastLoginHours < ConfigEntry.ADMINLIST_CLEAN_THESHOLD_HOURS.getInteger()) - { - continue; - } + FLog.info(String.format("UUID backfill: %d resolved via lookup, %d offline-derived", resolved, offlineDerived)); + + if (offlineDerived > 0 && !ConfigEntry.ADMINLIST_MOJANG_UUID_LOOKUP.getBoolean()) + { + FLog.warning(String.format("use_uuid_only is enabled but mojang_uuid_lookup is disabled; " + + "%d admin record(s) fell back to offline-derived UUIDs and " + + "will not match premium accounts on login", offlineDerived)); + } + + updateTables(); + saveAsync(); + } + + /** + * Load admins from SQL database. + */ + private void loadFromSql() + { + try + { + final AdminRepository repo = plugin.dm.getAdminRepository(); + final List admins = repo.findAll().block(); - if (verbose) + allAdmins.clear(); + if (admins != null) { - FUtil.adminAction("TotalFreedomMod", "Deactivating superadmin " + admin.getName() + ", inactive for " + lastLoginHours + " hours", true); + admins.forEach(admin -> + { + final String key = admin.getName().toLowerCase(); + allAdmins.put(key, fixConfigKey(admin, key)); + }); } - admin.setActive(false); - saveAdminAsync(admin); - } + usingSql = true; + updateTables(); + FLog.info(String.format("Loaded %d admins from SQL database (%d active, %d IPs)", + allAdmins.size(), nameTable.size(), ipTable.size())); - updateTables(); + reconcileFromJsonIfNewer(repo); + } + catch (Exception ex) + { + FLog.warning(String.format("Failed to load admins from SQL, falling back to JSON: %s", ex.getMessage())); + loadFromJson(); + } } + + /** + * If admins.json was written more recently than the database's last update (e.g. edited + * by hand, or restored from backup while SQL was unavailable), re-import it into SQL. + */ + private void reconcileFromJsonIfNewer(AdminRepository repo) + { + if (!configFile.exists()) + { + return; + } + + try + { + final Long sqlUpdatedAt = repo.getMaxUpdatedAt(); + if (sqlUpdatedAt != null && configFile.lastModified() <= sqlUpdatedAt) + { + return; + } + + final Map jsonAdmins = readJsonAdmins(); + if (jsonAdmins.isEmpty()) + { + return; + } + + FLog.info(String.format("admins.json is newer than the database; re-importing %d admin(s) from it.", + jsonAdmins.size())); + + Flux.fromIterable(jsonAdmins.values()) + .filter(Admin::isValid) + .concatMap(admin -> repo.save(resolveUuidFor(admin), admin)) + .blockLast(); + + allAdmins.clear(); + allAdmins.putAll(jsonAdmins); + updateTables(); + } + catch (Exception ex) + { + FLog.warning(String.format("Failed to reconcile %s into the database: %s", + CONFIG_FILENAME, ex.getMessage())); + } + } + + private Map readJsonAdmins() throws IOException + { + try (FileReader reader = new FileReader(configFile)) + { + Map admins = JsonUtil.GSON.fromJson(reader, ADMIN_MAP_TYPE); + return admins != null ? admins : Maps.newHashMap(); + } + } + + /** + * Fix the config key on an admin (needed when loading from SQL). + */ + private Admin fixConfigKey(Admin admin, String key) + { + // configKey is only set through the constructor, so a mismatch means + // rebuilding the entry under the right key. + if (admin.getConfigKey() == null || !admin.getConfigKey().equals(key)) + { + Admin fixed = new Admin(key); + fixed.setUuid(admin.getUuid()); + fixed.setName(admin.getName()); + fixed.setRank(admin.getRank()); + fixed.setActive(admin.isActive()); + fixed.setLastLogin(admin.getLastLogin()); + fixed.setLoginMessage(admin.getLoginMessage()); + fixed.setCustomRankId(admin.getCustomRankId()); + fixed.addIps(admin.getIps()); + return fixed; + } + return admin; + } + + /** + * Load admins from JSON file (fallback). + */ + private void loadFromJson() + { + if (!configFile.exists()) + { + try + { + configFile.getParentFile().mkdirs(); + configFile.createNewFile(); + } + catch (IOException ex) + { + FLog.severe(String.format("Could not create %s", CONFIG_FILENAME)); + } + } + + allAdmins.clear(); + try + { + readJsonAdmins().forEach((key, admin) -> + { + if (admin == null || !admin.isValid()) + { + FLog.warning(String.format("Could not load admin: %s. Missing details!", key)); + return; + } + allAdmins.put(key, admin); + }); + } + catch (IOException ex) + { + FLog.severe(String.format("Could not read %s: %s", CONFIG_FILENAME, ex.getMessage())); + } + + usingSql = false; + updateTables(); + FLog.info(String.format("Loaded %d admins from JSON (%d active, %d IPs)", + allAdmins.size(), nameTable.size(), ipTable.size())); + } + + /** + * Append {@code work} to the persistence chain and subscribe. Every queued + * write runs after the one before it, so a batch can never be reordered + * behind a single-row update that was requested later. + */ + private void enqueue(Mono work) + { + synchronized (persistenceLock) + { + final Mono queued = persistenceChain + .onErrorResume(ignored -> Mono.empty()) + .then(work) + .cache(); + + persistenceChain = queued; + queued.doFinally(signal -> collapseChain(queued)).subscribe(); + } + } + + /** + * Drop the retained operator chain once its tail has completed. The chain is + * strictly sequential, so a completed tail means every write before it is + * done and nothing needs to wait on them any more. + */ + private void collapseChain(Mono completed) + { + synchronized (persistenceLock) + { + if (persistenceChain == completed) + { + persistenceChain = Mono.empty(); + } + } + } + + /** + * Queue a batch of admin rows for an off-thread SQL write, followed by one + * refresh of the JSON snapshot. + */ + private void queueSqlWrites(List batch) + { + if (batch.isEmpty()) + { + return; + } + + if (plugin.dm == null || !plugin.dm.isInitialized()) + { + FLog.warning(String.format("SQL not available; %d admin change(s) were not saved", batch.size())); + return; + } + + final AdminRepository repo = plugin.dm.getAdminRepository(); + + // Render the snapshot here, while we still own the maps on the calling + // thread. Serialising inside the queued task would read allAdmins + // off-thread while the main thread is free to mutate it. + final String json = serialiseAdmins(); + + enqueue(Flux.fromIterable(batch) + .concatMap(pending -> resolveUuidAsync(pending.live(), pending.snapshot()) + .flatMap(uuid -> repo.save(uuid, pending.snapshot())) + .onErrorResume(ex -> + { + FLog.warning(String.format("Failed to save admin %s to SQL: %s", + pending.snapshot().getName(), ex.getMessage())); + return Mono.empty(); + })) + .then(writeJsonAsync(json))); + } + + /** + * Remove admin from SQL database. + */ + private void removeAdminFromSql(Admin admin) + { + if (plugin.dm == null || !plugin.dm.isInitialized()) + { + FLog.warning(String.format("SQL not available; removal of admin %s was not saved", admin.getName())); + return; + } + + final AdminRepository repo = plugin.dm.getAdminRepository(); + final UUID uuid = admin.getUuid(); + final String name = admin.getName(); + final String json = serialiseAdmins(); + + final Mono delete = uuid != null + ? repo.deleteByUuid(uuid).then() + : Mono.fromRunnable(() -> + { + try + { + repo.deleteByUsername(name); + } + catch (SQLException ex) + { + throw new IllegalStateException(ex); + } + }).subscribeOn(Schedulers.boundedElastic()); + + enqueue(delete + .onErrorResume(ex -> + { + FLog.warning(String.format("Failed to remove admin %s from SQL: %s", name, ex.getMessage())); + return Mono.empty(); + }) + .then(writeJsonAsync(json))); + } + + /** + * Blocking write of every admin record to SQL. Startup/shutdown only. + */ + private void saveToSql() + { + if (plugin.dm == null || !plugin.dm.isInitialized()) + { + FLog.warning("SQL not available, falling back to the JSON snapshot"); + saveToJson(); + return; + } + + final AdminRepository repo = plugin.dm.getAdminRepository(); + + // Isolate per admin: this is the shutdown flush, so one unwritable row + // must not take the rest of the list down with it. + final Long failed = Flux.fromIterable(List.copyOf(allAdmins.values())) + .concatMap(admin -> Mono.fromCallable(() -> resolveUuidFor(admin)) + .flatMap(uuid -> repo.save(uuid, admin)) + .thenReturn(Boolean.TRUE) + .onErrorResume(ex -> + { + FLog.warning(String.format("Failed to save admin %s to SQL: %s", + admin.getName(), ex.getMessage())); + return Mono.just(Boolean.FALSE); + })) + .filter(Boolean.FALSE::equals) + .count() + .block(); + + FLog.debug(String.format("Flushed %d admin(s) to SQL (%d failed)", + allAdmins.size(), failed == null ? 0L : failed)); + + // Write the snapshot either way, so a later SQL-less start has something + // to read. Note we deliberately do not treat JSON as the authority when + // SQL rejects a row - that would create two conflicting sources of truth. + saveToJson(); + } + + /** + * Blocking write of the JSON snapshot. Startup/shutdown and main-thread + * fallbacks only; queued writes go through {@link #writeJsonAsync(String)}. + */ + private void saveToJson() + { + writeJson(serialiseAdmins()); + } + + private String serialiseAdmins() + { + return JsonUtil.GSON.toJson(allAdmins, ADMIN_MAP_TYPE); + } + + private Mono writeJsonAsync(String json) + { + return Mono.fromRunnable(() -> writeJson(json)) + .subscribeOn(Schedulers.boundedElastic()); + } + + private void writeJson(String json) + { + try (FileWriter writer = new FileWriter(configFile)) + { + writer.write(json); + } + catch (IOException ex) + { + FLog.severe(String.format("Could not save %s: %s", CONFIG_FILENAME, ex.getMessage())); + } + } + + private PendingWrite pendingWrite(Admin live) + { + return new PendingWrite(live, copyAdmin(live)); + } + + private Admin copyAdmin(Admin admin) + { + Admin copy = new Admin(admin.getConfigKey()); + copy.setUuid(admin.getUuid()); + copy.setName(admin.getName()); + copy.setRank(admin.getRank()); + copy.setActive(admin.isActive()); + copy.setLastLogin(admin.getLastLogin() == null ? null : new Date(admin.getLastLogin().getTime())); + copy.setLoginMessage(admin.getLoginMessage()); + copy.setCustomRankId(admin.getCustomRankId()); + copy.addIps(new ArrayList<>(admin.getIps())); + return copy; + } + + /** + * Resolve the UUID for a queued write. The lookup runs on the persistence + * chain rather than the caller, because {@link FUtil#usernameToUuid} can make + * a blocking Mojang request with a 5s connect and 5s read timeout - that must + * never land on the main thread during play. The resolved value is handed + * back to the live entry on the main thread, which owns the lookup tables. + */ + private Mono resolveUuidAsync(Admin live, Admin snapshot) + { + if (snapshot.getUuid() != null) + { + return Mono.just(snapshot.getUuid()); + } + + return Mono.fromCallable(() -> resolveUuidFor(snapshot)) + .subscribeOn(Schedulers.boundedElastic()) + .doOnNext(resolved -> syncResolvedUuid(live, resolved)); + } + + /** + * Hand a UUID resolved off-thread back to the live entry and the lookup + * table, both of which belong to the main thread. + */ + private void syncResolvedUuid(Admin live, UUID resolved) + { + if (!plugin.isEnabled()) + { + return; + } + + try + { + plugin.getServer().getScheduler().runTask(plugin, () -> + { + if (live.getUuid() == null) + { + live.setUuid(resolved); + } + uuidTable.putIfAbsent(resolved, live); + }); + } + catch (RuntimeException ex) + { + // Plugin disabled between the check and the schedule; Bukkit answers + // that with IllegalPluginAccessException. The snapshot already holds + // the UUID, so the write itself is unaffected. + FLog.debug(String.format("Could not sync resolved UUID back to %s; server is stopping", live.getName())); + } + } + + /** + * Resolve and store a UUID on {@code admin}: Mojang lookup by name, falling + * back to an offline-derived UUID. Blocking - never call this on the main + * thread outside startup or shutdown. + */ + private UUID resolveUuidFor(Admin admin) + { + if (admin.getUuid() != null) + { + return admin.getUuid(); + } + + final UUID looked = FUtil.usernameToUuid(admin.getName()); + final UUID resolved = looked != null ? looked : offlineUuid(admin.getName()); + admin.setUuid(resolved); + return resolved; + } + + private void refreshWorldEditBypassForAdmin(Admin admin) + { + if (plugin.web == null) + { + return; + } + try + { + Player online = null; + final UUID uuid = admin.getUuid(); + if (uuid != null) + { + online = plugin.getServer().getPlayer(uuid); + } + if (online == null && admin.getName() != null) + { + online = plugin.getServer().getPlayerExact(admin.getName()); + } + if (online != null) + { + plugin.web.refreshBypassNegation(online); + } + } + catch (Throwable t) + { + FLog.warning(String.format("Failed to refresh WorldEdit bypass negation: %s", t.getMessage())); + } + } + + private long inactiveHours(Admin admin) + { + return TimeUnit.HOURS.convert(new Date().getTime() - admin.getLastLogin().getTime(), TimeUnit.MILLISECONDS); + } + + private static UuidBackfill backfillUuid(Admin admin) + { + final UUID looked = FUtil.usernameToUuid(admin.getName()); + admin.setUuid(looked != null ? looked : offlineUuid(admin.getName())); + return new UuidBackfill(admin, looked != null); + } + + private static UUID offlineUuid(String name) + { + return UUID.nameUUIDFromBytes(String.format("OfflinePlayer:%s", name.toLowerCase()) + .getBytes(StandardCharsets.UTF_8)); + } + + /** + * A live entry paired with the immutable snapshot that will actually be + * written, so a mutation on the main thread cannot change a row mid-write. + */ + private record PendingWrite(Admin live, Admin snapshot) {} + + private record UuidBackfill(Admin admin, boolean fromLookup) {} } diff --git a/src/main/resources/ranks.yml b/src/main/resources/ranks.yml new file mode 100644 index 000000000..bc50ec781 --- /dev/null +++ b/src/main/resources/ranks.yml @@ -0,0 +1,190 @@ +# TotalFreedomMod Ranks Configuration +# This file defines all custom ranks and their properties. +# +# Format: +# rankid: +# name: Display Name +# abbreviation: TAG +# prefix: "&8[&bTAG&8] " (chat prefix with color codes) +# level: (higher = more authority) +# color: +# determiner: a/an +# admin: true/false +# console_only: true/false +# inherit: (inherit all permissions from another rank) +# permissions: +# - permission.node +# - another.permission +# +# Available colors: +# black, dark_blue, dark_green, dark_aqua, dark_red, dark_purple, +# gold, gray, dark_gray, blue, green, aqua, red, light_purple, yellow, white +# +# Permission nodes are TFM-internal only (NOT Bukkit permission nodes). +# Since all players have OP, Bukkit permissions don't apply. +# Use these to restrict TFM features based on rank. +# +# Permission Categories: +# tfm.player.* - Basic player commands (NON_OP/OP level) +# tfm.fun.* - Fun commands (smite, doom, orbit, etc.) +# tfm.admin.* - Admin actions (ban, kick, mute, freeze, cage) +# tfm.admin.senior.* - Senior admin only actions +# tfm.server.* - Server management commands +# tfm.world.* - World management commands +# tfm.manage.* - Management commands (ranks, config) +# + +impostor: + name: Impostor + abbreviation: Imp + prefix: "&8[&eImp&8] " + level: -1 + color: dark_gray + determiner: an + admin: false + console_only: false + permissions: + - tfm.player.list + - tfm.admin.overlord + +non_op: + name: Player + abbreviation: "" + prefix: "" + level: 0 + color: white + determiner: a + admin: false + console_only: false + permissions: + - tfm.player.localspawn + - tfm.player.radar + - tfm.player.rank + - tfm.player.spawn + - tfm.player.list + - tfm.player.joinmessages + - tfm.world.flatlands + - tfm.server.info + +op: + name: Operator + abbreviation: OP + prefix: "&8[&cOP&8] " + level: 1 + color: green + determiner: an + admin: false + console_only: false + inherit: non_op + permissions: + - tfm.player.* + - tfm.world.adminworld + - tfm.admin.banlist + - tfm.fun.hack + - tfm.fun.landmine + - tfm.fun.mp44 + - tfm.fun.spawnmob + - tfm.fun.tossmob + - tfm.server.whitelist + - tfm.manage.saconfig + +super_admin: + name: Super Admin + abbreviation: SA + prefix: "&8[&bSA&8] " + level: 2 + color: gold + determiner: a + admin: true + console_only: false + inherit: op + permissions: + - tfm.fun.* + - tfm.world.* + - tfm.admin.adminchat + - tfm.admin.announce + - tfm.admin.ban + - tfm.admin.baninfo + - tfm.admin.banlist + - tfm.admin.blockcmd + - tfm.admin.blockredstone + - tfm.admin.cage + - tfm.admin.cmdspy + - tfm.admin.consolesay + - tfm.admin.denick + - tfm.admin.deop + - tfm.admin.disguisetoggle + - tfm.admin.findip + - tfm.admin.freeze + - tfm.admin.gcmd + - tfm.admin.invis + - tfm.admin.kick + - tfm.admin.mute + - tfm.admin.myadmin + - tfm.admin.nickclean + - tfm.admin.opall + - tfm.admin.opme + - tfm.admin.potspy + - tfm.admin.premium + - tfm.admin.purgeall + - tfm.admin.ro + - tfm.admin.say + - tfm.admin.strike + - tfm.admin.undisguiseall + - tfm.admin.warn + - tfm.admin.whohas + - tfm.admin.wildcard + - tfm.admin.gamemode + - tfm.admin.protectregion + - tfm.server.* + - tfm.manage.saconfig + +senior_admin: + name: Senior Admin + abbreviation: SrA + prefix: "&8[&6SrA&8] " + level: 3 + color: light_purple + determiner: a + admin: true + console_only: false + inherit: super_admin + permissions: + - tfm.admin.senior.* + - tfm.admin.telnet.* + - tfm.manage.* + - tfm.manage.ssh + - tfm.manage.telnet + +developer: + name: Developer + abbreviation: Dev + prefix: "&8[&5Dev&8] " + level: 4 + color: dark_purple + determiner: a + admin: true + console_only: false + inherit: senior_admin + +owner: + name: Owner + abbreviation: Owner + prefix: "&8[&9Owner&8] " + level: 4 + color: blue + determiner: the + admin: true + console_only: false + inherit: senior_admin + +executive: + name: Executive + abbreviation: Exec + prefix: "&8[&eExec&8] " + level: 4 + color: yellow + determiner: an + admin: true + console_only: false + inherit: senior_admin \ No newline at end of file From 2791c3c980506795c475c8141090316bd7657414 Mon Sep 17 00:00:00 2001 From: Paldiu Date: Thu, 30 Jul 2026 01:38:59 -0500 Subject: [PATCH 06/48] Update AdminList.java --- .../totalfreedommod/admin/AdminList.java | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java b/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java index 525ee9586..05e5aebdd 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java @@ -124,7 +124,7 @@ public void load() } /** - * Blocking write of every admin record. This is the shutdown flush - it is + * Blocking write of every admin record. This is the shutdown flush which is * called from {@link #onStop()} so nothing is lost when the server stops. *

* Do not call this from a command or event handler: under SQL it is a @@ -178,9 +178,8 @@ public void awaitPendingWrites(long timeoutMs) * list goes out as one queued batch, written serially so the connection pool * is not swamped. *

- * Only call this when the whole list is genuinely dirty. If a single entry - * changed - a login, an IP edit, a rank change - call - * {@link #saveAdminAsync(Admin)} instead, which queues just that row. + * Only call this when the whole list is genuinely dirty. If only a single entry + * changed, call {@link #saveAdminAsync(Admin)} instead, which queues just that row. */ public void saveAsync() { @@ -710,8 +709,6 @@ private Map readJsonAdmins() throws IOException */ private Admin fixConfigKey(Admin admin, String key) { - // configKey is only set through the constructor, so a mismatch means - // rebuilding the entry under the right key. if (admin.getConfigKey() == null || !admin.getConfigKey().equals(key)) { Admin fixed = new Admin(key); @@ -894,8 +891,6 @@ private void saveToSql() final AdminRepository repo = plugin.dm.getAdminRepository(); - // Isolate per admin: this is the shutdown flush, so one unwritable row - // must not take the rest of the list down with it. final Long failed = Flux.fromIterable(List.copyOf(allAdmins.values())) .concatMap(admin -> Mono.fromCallable(() -> resolveUuidFor(admin)) .flatMap(uuid -> repo.save(uuid, admin)) @@ -913,9 +908,7 @@ private void saveToSql() FLog.debug(String.format("Flushed %d admin(s) to SQL (%d failed)", allAdmins.size(), failed == null ? 0L : failed)); - // Write the snapshot either way, so a later SQL-less start has something - // to read. Note we deliberately do not treat JSON as the authority when - // SQL rejects a row - that would create two conflicting sources of truth. + // Write the snapshot either way, so a later SQL-less start has something to read. saveToJson(); } @@ -973,8 +966,7 @@ private Admin copyAdmin(Admin admin) /** * Resolve the UUID for a queued write. The lookup runs on the persistence * chain rather than the caller, because {@link FUtil#usernameToUuid} can make - * a blocking Mojang request with a 5s connect and 5s read timeout - that must - * never land on the main thread during play. The resolved value is handed + * a blocking Mojang request with a 5s connect and 5s read timeout. The resolved value is handed * back to the live entry on the main thread, which owns the lookup tables. */ private Mono resolveUuidAsync(Admin live, Admin snapshot) From b7f84c69bcb7586ae921ae27c7b573fd8370231e Mon Sep 17 00:00:00 2001 From: Paldiu Date: Thu, 30 Jul 2026 16:33:11 -0500 Subject: [PATCH 07/48] Should be the final push, will run through after work tonight before cutting a testing build for this work. --- .../totalfreedommod/ProtectArea.java | 355 ++++-------------- .../totalfreedommod/TotalFreedomMod.java | 38 +- .../totalfreedommod/banning/BanManager.java | 270 ++++++++----- .../totalfreedommod/banning/PermbanList.java | 217 +++++------ .../totalfreedommod/banning/StrikeList.java | 207 ++++++---- .../cmd/Command_sqlstatus.java | 48 +++ .../totalfreedommod/sql/AccessController.java | 56 ++- .../sql/ConnectionHandler.java | 77 +++- .../totalfreedommod/sql/FreedomDatabase.java | 67 +++- .../totalfreedommod/sql/StatementHandler.java | 69 +++- 10 files changed, 799 insertions(+), 605 deletions(-) create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_sqlstatus.java diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java b/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java index 0efdec902..0ea211a3b 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java @@ -3,19 +3,9 @@ import com.google.common.collect.Maps; import com.google.gson.reflect.TypeToken; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileReader; -import java.io.FileWriter; -import java.io.IOException; -import java.io.ObjectInputStream; -import java.io.Serializable; +import java.io.*; import java.lang.reflect.Type; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.UUID; +import java.util.*; import me.totalfreedom.totalfreedommod.ProtectArea.ProtectedRegion.CantFindWorldException; import me.totalfreedom.totalfreedommod.config.ConfigEntry; @@ -30,53 +20,30 @@ import org.bukkit.block.Block; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.YamlConfiguration; -import org.bukkit.entity.Entity; -import org.bukkit.entity.Item; -import org.bukkit.entity.LivingEntity; -import org.bukkit.entity.Player; +import org.bukkit.entity.*; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; -import org.bukkit.event.block.BlockBreakEvent; -import org.bukkit.event.block.BlockBurnEvent; -import org.bukkit.event.block.BlockExplodeEvent; -import org.bukkit.event.block.BlockFadeEvent; -import org.bukkit.event.block.BlockFromToEvent; -import org.bukkit.event.block.BlockIgniteEvent; -import org.bukkit.event.block.BlockPistonExtendEvent; -import org.bukkit.event.block.BlockPistonRetractEvent; -import org.bukkit.event.block.BlockPlaceEvent; -import org.bukkit.event.block.BlockSpreadEvent; -import org.bukkit.event.block.SignChangeEvent; -import org.bukkit.event.entity.AreaEffectCloudApplyEvent; -import org.bukkit.event.entity.EntityChangeBlockEvent; -import org.bukkit.event.entity.EntityDamageEvent; -import org.bukkit.event.entity.EntityExplodeEvent; -import org.bukkit.event.entity.EntityPickupItemEvent; -import org.bukkit.event.entity.LingeringPotionSplashEvent; -import org.bukkit.event.entity.PotionSplashEvent; -import org.bukkit.event.hanging.HangingBreakByEntityEvent; -import org.bukkit.event.hanging.HangingPlaceEvent; +import org.bukkit.event.block.*; +import org.bukkit.event.entity.*; +import org.bukkit.event.hanging.*; import org.bukkit.event.inventory.InventoryPickupItemEvent; -import org.bukkit.event.player.PlayerBucketEmptyEvent; -import org.bukkit.event.player.PlayerBucketFillEvent; -import org.bukkit.event.player.PlayerInteractEvent; -import org.bukkit.event.player.PlayerInteractEntityEvent; +import org.bukkit.event.player.*; import org.bukkit.event.vehicle.VehicleDestroyEvent; import org.bukkit.scheduler.BukkitTask; import org.bukkit.util.Vector; public class ProtectArea extends FreedomService { + private static final long ITEM_SWEEP_RATE = 40L; + private static final Type PROTECTED_AREA_LIST_TYPE = new TypeToken>() {}.getType(); public static final String DATA_FILENAME = "protectedareas.json"; public static final String LEGACY_YAML_FILENAME = "protectedareas.yml"; public static final String LEGACY_DATA_FILENAME = "protectedareas.dat"; public static final double MAX_RADIUS = 50.0; - // How often (in ticks) to sweep loose items out of protected areas. - private static final long ITEM_SWEEP_RATE = 40L; - private static final Type PROTECTED_AREA_LIST_TYPE = new TypeToken>() {}.getType(); - // + private final Map areas = Maps.newHashMap(); + private File dataFile; private boolean usingSql = false; private BukkitTask itemSweepTask; @@ -90,20 +57,14 @@ public ProtectArea(TotalFreedomMod plugin) protected void onStart() { if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { return; - } dataFile = new File(plugin.getDataFolder(), DATA_FILENAME); if (plugin.dm != null && plugin.dm.isInitialized()) - { loadFromSql(); - } else - { loadFromJsonOrLegacy(); - } itemSweepTask = Bukkit.getScheduler().runTaskTimer( plugin, FTask.guard("ProtectArea/sweepItems", this::sweepItems), ITEM_SWEEP_RATE, ITEM_SWEEP_RATE); @@ -121,9 +82,8 @@ private void loadFromSql() { File legacyFile = new File(plugin.getDataFolder(), LEGACY_DATA_FILENAME); if (legacyFile.exists()) - { migrateLegacyData(legacyFile); - } + return; } @@ -147,9 +107,8 @@ private void loadFromJsonOrLegacy() { File legacyFile = new File(plugin.getDataFolder(), LEGACY_DATA_FILENAME); if (legacyFile.exists()) - { migrateLegacyData(legacyFile); - } + return; } @@ -185,29 +144,21 @@ private List readJsonAreas() throws IOException private void reconcileFromJsonIfNewer(ProtectedAreaRepository repo) { if (!dataFile.exists()) - { return; - } try { Long sqlUpdatedAt = repo.getMaxUpdatedAt(); if (sqlUpdatedAt != null && dataFile.lastModified() <= sqlUpdatedAt) - { return; - } List jsonAreas = readJsonAreas(); if (jsonAreas.isEmpty()) - { return; - } FLog.info(DATA_FILENAME + " is newer than the database; re-importing " + jsonAreas.size() + " protected area(s) from it."); for (ProtectedRegion region : jsonAreas) - { repo.saveOrUpdate(region); - } areas.clear(); jsonAreas.forEach(region -> areas.put(region.getUuid(), region)); @@ -246,13 +197,10 @@ private void migrateLegacyData(File legacyFile) File oldFile = new File(legacyFile.getParent(), LEGACY_DATA_FILENAME + ".old"); if (legacyFile.renameTo(oldFile)) - { FLog.info("Migration complete. Legacy file renamed to " + LEGACY_DATA_FILENAME + ".old"); - } else - { FLog.warning("Migration complete but could not rename legacy file."); - } + } catch (Exception ex) { @@ -261,18 +209,13 @@ private void migrateLegacyData(File legacyFile) } } - /** - * Reads the pre-JSON {@code protectedareas.yml} format. Retained only for the one-time - * legacy-install migration path (not called during normal startup). - */ + @Deprecated private void loadFromYaml(File file) { areas.clear(); if (!file.exists()) - { return; - } try { @@ -280,17 +223,13 @@ private void loadFromYaml(File file) ConfigurationSection areasSection = config.getConfigurationSection("areas"); if (areasSection == null) - { return; - } for (String id : areasSection.getKeys(false)) { ConfigurationSection areaSection = areasSection.getConfigurationSection(id); if (areaSection == null) - { continue; - } UUID uuid = UUID.fromString(id); String name = areaSection.getString("name"); @@ -330,16 +269,18 @@ protected void onStop() save(); } + public void reload() + { + onStop(); + onStart(); + } + public void save() { if (usingSql) - { saveToSql(); - } else - { saveToJson(); - } } private void saveToSql() @@ -370,9 +311,7 @@ private void saveToSql() private void saveToJson() { if (dataFile == null) - { dataFile = new File(plugin.getDataFolder(), DATA_FILENAME); - } try (FileWriter writer = new FileWriter(dataFile)) { @@ -389,83 +328,57 @@ private void saveToJson() public void onBlockBreak(BlockBreakEvent event) { if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { return; - } final Player player = event.getPlayer(); if (plugin.al.isAdmin(player)) - { return; - } final Location location = event.getBlock().getLocation(); if (isInProtectedArea(location)) - { event.setCancelled(true); - } } @EventHandler(priority = EventPriority.NORMAL) public void onBlockPlace(BlockPlaceEvent event) { if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { return; - } final Player player = event.getPlayer(); if (plugin.al.isAdmin(player)) - { return; - } final Location location = event.getBlock().getLocation(); if (isInProtectedArea(location)) - { event.setCancelled(true); - } } // Entity explosions (TNT, Creepers, Withers, etc.) @EventHandler(priority = EventPriority.NORMAL) public void onEntityExplode(EntityExplodeEvent event) { - if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { - return; - } - - event.blockList().removeIf(block -> isInProtectedArea(block.getLocation())); + if (ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) + event.blockList().removeIf(block -> isInProtectedArea(block.getLocation())); } // Block explosions (beds in nether, respawn anchors) @EventHandler(priority = EventPriority.NORMAL) public void onBlockExplode(BlockExplodeEvent event) { - if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { - return; - } - - event.blockList().removeIf(block -> isInProtectedArea(block.getLocation())); + if (ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) + event.blockList().removeIf(block -> isInProtectedArea(block.getLocation())); } // Enderman picking up blocks, falling blocks, etc. @EventHandler(priority = EventPriority.NORMAL) public void onEntityChangeBlock(EntityChangeBlockEvent event) { - if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { - return; - } - - if (isInProtectedArea(event.getBlock().getLocation())) - { + if (ConfigEntry.PROTECTAREA_ENABLED.getBoolean() + && isInProtectedArea(event.getBlock().getLocation())) event.setCancelled(true); - } } // Water/lava bucket placement @@ -473,20 +386,14 @@ public void onEntityChangeBlock(EntityChangeBlockEvent event) public void onBucketEmpty(PlayerBucketEmptyEvent event) { if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { return; - } final Player player = event.getPlayer(); if (plugin.al.isAdmin(player)) - { return; - } if (isInProtectedArea(event.getBlock().getLocation())) - { event.setCancelled(true); - } } // Water/lava bucket removal @@ -494,20 +401,14 @@ public void onBucketEmpty(PlayerBucketEmptyEvent event) public void onBucketFill(PlayerBucketFillEvent event) { if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { return; - } final Player player = event.getPlayer(); if (plugin.al.isAdmin(player)) - { return; - } if (isInProtectedArea(event.getBlock().getLocation())) - { event.setCancelled(true); - } } // Fire starting @@ -515,70 +416,43 @@ public void onBucketFill(PlayerBucketFillEvent event) public void onBlockIgnite(BlockIgniteEvent event) { if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { return; - } final Player player = event.getPlayer(); if (player != null && plugin.al.isAdmin(player)) - { return; - } if (isInProtectedArea(event.getBlock().getLocation())) - { event.setCancelled(true); - } } // Fire spread @EventHandler(priority = EventPriority.NORMAL) public void onBlockSpread(BlockSpreadEvent event) { - if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { - return; - } - - // Only block fire spread - if (event.getSource().getType() == org.bukkit.Material.FIRE) - { - if (isInProtectedArea(event.getBlock().getLocation())) - { - event.setCancelled(true); - } - } + if (ConfigEntry.PROTECTAREA_ENABLED.getBoolean() + && (event.getSource().getType() == org.bukkit.Material.FIRE) + && isInProtectedArea(event.getBlock().getLocation())) + event.setCancelled(true); } // Blocks burning @EventHandler(priority = EventPriority.NORMAL) public void onBlockBurn(BlockBurnEvent event) { - if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { - return; - } - - if (isInProtectedArea(event.getBlock().getLocation())) - { - event.setCancelled(true); - } + if (ConfigEntry.PROTECTAREA_ENABLED.getBoolean() + && isInProtectedArea(event.getBlock().getLocation())) + event.setCancelled(true); } // Water/lava flow @EventHandler(priority = EventPriority.NORMAL) public void onBlockFromTo(BlockFromToEvent event) { - if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { - return; - } - - // Check if liquid is flowing INTO a protected area from outside - if (!isInProtectedArea(event.getBlock().getLocation()) && isInProtectedArea(event.getToBlock().getLocation())) - { - event.setCancelled(true); - } + if (ConfigEntry.PROTECTAREA_ENABLED.getBoolean() + && !isInProtectedArea(event.getBlock().getLocation()) + && isInProtectedArea(event.getToBlock().getLocation())) + event.setCancelled(true); } // Piston extend @@ -586,9 +460,7 @@ public void onBlockFromTo(BlockFromToEvent event) public void onPistonExtend(BlockPistonExtendEvent event) { if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { return; - } for (Block block : event.getBlocks()) { @@ -605,18 +477,14 @@ public void onPistonExtend(BlockPistonExtendEvent event) public void onPistonRetract(BlockPistonRetractEvent event) { if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { return; - } for (Block block : event.getBlocks()) - { if (isInProtectedArea(block.getLocation())) { event.setCancelled(true); return; } - } } // Placing paintings, item frames, etc. @@ -624,20 +492,14 @@ public void onPistonRetract(BlockPistonRetractEvent event) public void onHangingPlace(HangingPlaceEvent event) { if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { return; - } final Player player = event.getPlayer(); if (player != null && plugin.al.isAdmin(player)) - { return; - } if (isInProtectedArea(event.getEntity().getLocation())) - { event.setCancelled(true); - } } // Breaking paintings, item frames by entity @@ -645,24 +507,18 @@ public void onHangingPlace(HangingPlaceEvent event) public void onHangingBreak(HangingBreakByEntityEvent event) { if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { return; - } Entity remover = event.getRemover(); if (remover instanceof Player) { Player player = (Player) remover; if (plugin.al.isAdmin(player)) - { return; - } } if (isInProtectedArea(event.getEntity().getLocation())) - { event.setCancelled(true); - } } // Vehicle destruction (minecarts, boats) @@ -670,39 +526,27 @@ public void onHangingBreak(HangingBreakByEntityEvent event) public void onVehicleDestroy(VehicleDestroyEvent event) { if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { return; - } Entity attacker = event.getAttacker(); if (attacker instanceof Player) { Player player = (Player) attacker; if (plugin.al.isAdmin(player)) - { return; - } } if (isInProtectedArea(event.getVehicle().getLocation())) - { event.setCancelled(true); - } } // Block fade (ice melting, snow melting, etc.) - protect structure integrity @EventHandler(priority = EventPriority.NORMAL) public void onBlockFade(BlockFadeEvent event) { - if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { - return; - } - - if (isInProtectedArea(event.getBlock().getLocation())) - { - event.setCancelled(true); - } + if (ConfigEntry.PROTECTAREA_ENABLED.getBoolean() + && isInProtectedArea(event.getBlock().getLocation())) + event.setCancelled(true); } // Sign text editing (Minecraft 1.20+ allows editing signs after placement) @@ -710,20 +554,14 @@ public void onBlockFade(BlockFadeEvent event) public void onSignChange(SignChangeEvent event) { if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { return; - } final Player player = event.getPlayer(); if (plugin.al.isAdmin(player)) - { return; - } if (isInProtectedArea(event.getBlock().getLocation())) - { event.setCancelled(true); - } } // Player interact (crop trampling, etc.) @@ -733,15 +571,11 @@ public void onPlayerInteract(PlayerInteractEvent event) final Player player = event.getPlayer(); Block block = event.getClickedBlock(); if (block == null) - { return; - } final Location location = block.getLocation(); if (!shouldBlockInteraction(player, location)) - { return; - } if (event.getAction() == org.bukkit.event.block.Action.PHYSICAL) { @@ -750,13 +584,9 @@ public void onPlayerInteract(PlayerInteractEvent event) } // block right-click interactions - if (event.getAction() == org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK) - { - if (event.getItem() != null) - { - event.setCancelled(true); - } - } + if ((event.getAction() == org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK) + && event.getItem() != null) + event.setCancelled(true); } @EventHandler(priority = EventPriority.NORMAL) @@ -766,137 +596,88 @@ public void onPlayerInteractEntity(PlayerInteractEntityEvent event) final Location location = event.getRightClicked().getLocation(); if (shouldBlockInteraction(player, location)) - { event.setCancelled(true); - } } @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) public void onPlayerDamage(EntityDamageEvent event) { - if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { - return; - } - - if (!ConfigEntry.PROTECTAREA_PROTECT_PLAYERS.getBoolean()) - { - return; - } - - if (!(event.getEntity() instanceof Player)) - { + if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean() + || !ConfigEntry.PROTECTAREA_PROTECT_PLAYERS.getBoolean() + || !(event.getEntity() instanceof Player)) return; - } if (isInProtectedArea(event.getEntity().getLocation())) - { event.setCancelled(true); - } } @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) public void onPotionSplash(PotionSplashEvent event) { - if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { - return; - } - - if (!ConfigEntry.PROTECTAREA_BLOCK_POTIONS.getBoolean()) - { - return; - } + if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean() + || !ConfigEntry.PROTECTAREA_BLOCK_POTIONS.getBoolean()) + return; for (LivingEntity affected : event.getAffectedEntities()) - { if (affected instanceof Player && isInProtectedArea(affected.getLocation())) - { event.setIntensity(affected, 0.0D); - } - } } @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) public void onLingeringPotionSplash(LingeringPotionSplashEvent event) { - if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { - return; - } - - if (!ConfigEntry.PROTECTAREA_BLOCK_POTIONS.getBoolean()) - { - return; - } + if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean() + || !ConfigEntry.PROTECTAREA_BLOCK_POTIONS.getBoolean()) + return; if (isInProtectedArea(event.getEntity().getLocation())) - { event.setCancelled(true); - } } @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) public void onAreaEffectCloudApply(AreaEffectCloudApplyEvent event) { - if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { - return; - } + if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean() + || !ConfigEntry.PROTECTAREA_BLOCK_POTIONS.getBoolean()) + return; - if (!ConfigEntry.PROTECTAREA_BLOCK_POTIONS.getBoolean()) - { - return; - } - - event.getAffectedEntities().removeIf( - entity -> entity instanceof Player && isInProtectedArea(entity.getLocation())); + event.getAffectedEntities() + .removeIf(entity -> + entity instanceof Player && isInProtectedArea(entity.getLocation()) + ); } @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) public void onItemPickup(EntityPickupItemEvent event) { - if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean() || !ConfigEntry.PROTECTAREA_BLOCK_ITEMS.getBoolean()) - { - return; - } - - if (event.getEntity() instanceof Player player && plugin.al.isAdmin(player)) - { - return; - } + if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean() + || !ConfigEntry.PROTECTAREA_BLOCK_ITEMS.getBoolean() + || (event.getEntity() instanceof Player player + && plugin.al.isAdmin(player))) + return; if (isInProtectedArea(event.getItem().getLocation())) - { event.setCancelled(true); - } } @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) public void onInventoryPickupItem(InventoryPickupItemEvent event) { - if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean() || !ConfigEntry.PROTECTAREA_BLOCK_ITEMS.getBoolean()) - { - return; - } + if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean() + || !ConfigEntry.PROTECTAREA_BLOCK_ITEMS.getBoolean()) + return; if (isInProtectedArea(event.getItem().getLocation())) - { event.setCancelled(true); - } } private boolean shouldBlockInteraction(Player player, Location location) { if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { return false; - } if (player != null && plugin.al.isAdmin(player)) - { return false; - } return isInProtectedArea(location); } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/TotalFreedomMod.java b/src/main/java/me/totalfreedom/totalfreedommod/TotalFreedomMod.java index 9e3898c8f..b056e3bf7 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/TotalFreedomMod.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/TotalFreedomMod.java @@ -190,9 +190,6 @@ public void onEnable() configConverter.convertAdminConsoleRanks(); - // Run YAML to SQL migrations after database and admin list are ready - runYamlMigrations(); - rm = services.registerService(RankManager.class); // Console sender whitelist. This first read only resolves bindings that name a legacy @@ -274,6 +271,9 @@ public void onEnable() tl = services.registerService(TabList.class); services.start(); + // Run YAML to SQL migrations now that every service (including the database) has started + runYamlMigrations(); + // Start bridges bridges = new ServiceManager<>(this); cpb = bridges.registerService(CoreProtectBridge.class); @@ -376,8 +376,10 @@ public String formattedVersion() } /** - * Run YAML to SQL migrations for admins, bans, and permbans. - * This converts existing YAML files to the new SQL database format. + * Run YAML to SQL migrations for every domain that has one. Converts existing YAML/dat files to the new SQL database format. + *

+ * Must run after {@code services.start()}: {@link me.totalfreedom.totalfreedommod.sql.FreedomDatabase#onStart()} + * is what actually calls {@code initialize()}, so {@code dm.isInitialized()} cannot be true any earlier. */ private void runYamlMigrations() { @@ -386,17 +388,37 @@ private void runYamlMigrations() FLog.info("Database not initialized, skipping YAML migrations"); return; } - + try { YamlMigrationService migrationService = new YamlMigrationService(this, dm); migrationService.runMigrations().block(); - - // Reload admin list after migration to pick up SQL data + + // Reload each domain after migration so in-memory state picks up freshly-migrated SQL data. if (al != null) { al.load(); } + if (bm != null) + { + bm.reload(); + } + if (pm != null) + { + pm.reload(); + } + if (sl != null) + { + sl.reload(); + } + if (rm != null) + { + rm.loadRanks(); + } + if (pa != null) + { + pa.reload(); + } } catch (Exception ex) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java index d16510c65..0cf17aabc 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java @@ -4,6 +4,7 @@ import com.google.common.collect.Maps; import com.google.common.collect.Sets; import com.google.gson.reflect.TypeToken; +import java.time.Duration; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -29,22 +30,24 @@ import org.bukkit.event.EventPriority; import org.bukkit.event.player.AsyncPlayerPreLoginEvent; import org.bukkit.event.player.PlayerJoinEvent; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; public class BanManager extends FreedomService { private static final Type BAN_LIST_TYPE = new TypeToken>() {}.getType(); + private static final long SHUTDOWN_FLUSH_TIMEOUT_MS = 10L * 1000L; private final Set bans = Sets.newHashSet(); private final Map ipBans = Maps.newHashMap(); private final Map nameBans = Maps.newHashMap(); private final List unbannableUsernames = Lists.newArrayList(); - // private final File configFile; - private final Object lock = new Object(); private final Object persistenceLock = new Object(); - // Flag to track if SQL is available + private Mono persistenceChain = Mono.empty(); private boolean usingSql = false; public BanManager(TotalFreedomMod plugin) @@ -57,17 +60,11 @@ public BanManager(TotalFreedomMod plugin) @SuppressWarnings("unchecked") protected void onStart() { - // Try to load from SQL database first if (plugin.dm != null && plugin.dm.isInitialized()) - { loadFromSql(); - } else - { loadFromJson(); - } - // Load unbannable usernames unbannableUsernames.clear(); unbannableUsernames.addAll((Collection) ConfigEntry.FAMOUS_PLAYERS.getList()); FLog.info("Loaded " + unbannableUsernames.size() + " unbannable usernames."); @@ -107,31 +104,24 @@ private void loadFromSql() private void reconcileFromJsonIfNewer(BanRepository repo) { if (!configFile.exists()) - { return; - } try { Long sqlUpdatedAt = repo.getMaxUpdatedAt(); if (sqlUpdatedAt != null && configFile.lastModified() <= sqlUpdatedAt) - { return; - } List jsonBans = readJsonBans(); if (jsonBans.isEmpty()) - { return; - } FLog.info("bans.json is newer than the database; re-importing " + jsonBans.size() + " ban(s) from it."); for (Ban ban : jsonBans) { if (!ban.isValid()) - { continue; - } + repo.save(ban).block(); } @@ -204,10 +194,70 @@ private void loadFromJson() @Override protected void onStop() { + awaitPendingWrites(SHUTDOWN_FLUSH_TIMEOUT_MS); saveAll(); FLog.info("Saved " + bans.size() + " player bans"); } + public void reload() + { + onStop(); + onStart(); + } + + public void awaitPendingWrites(long timeoutMs) + { + final Mono pending; + synchronized (persistenceLock) + { + pending = persistenceChain; + } + + try + { + pending.block(Duration.ofMillis(timeoutMs)); + } + catch (IllegalStateException ex) + { + FLog.warning(String.format("Gave up after %dms waiting for pending ban writes (%s); flushing anyway", + timeoutMs, ex.getMessage())); + } + catch (RuntimeException ex) + { + FLog.warning("A queued ban write failed before shutdown: " + ex.getMessage()); + } + } + + /** + * Append {@code work} to the persistence chain and subscribe. Every queued write runs + * after the one before it, so a batch can never be reordered behind a single-row update + * that was requested later. + */ + private void enqueue(Mono work) + { + synchronized (persistenceLock) + { + final Mono queued = persistenceChain + .onErrorResume(ignored -> Mono.empty()) + .then(work) + .cache(); + + persistenceChain = queued; + queued.doFinally(signal -> collapseChain(queued)).subscribe(); + } + } + + private void collapseChain(Mono completed) + { + synchronized (persistenceLock) + { + if (persistenceChain == completed) + { + persistenceChain = Mono.empty(); + } + } + } + public Set getAllBans() { return Collections.unmodifiableSet(bans); @@ -223,6 +273,11 @@ public Collection getUsernameBans() return Collections.unmodifiableCollection(nameBans.values()); } + /** + * Blocking write of every ban record. Startup/shutdown only. Single-entry changes belong + * on {@link #saveBanToSqlAsync(Ban)}; whole-list changes reachable from a command or event + * handler belong on {@link #saveAllAsync()}. + */ public void saveAll() { final boolean sql; @@ -234,52 +289,126 @@ public void saveAll() snapshot = new ArrayList<>(bans); } - synchronized (persistenceLock) + if (sql) { - if (sql) - { - writeAllToSql(snapshot); - } - else - { - writeAllToJson(snapshot); - } + writeAllToSql(snapshot); + } + else + { + writeAllToJson(snapshot); } } public void saveAllAsync() { - if (!plugin.isEnabled()) + final boolean sql; + final List snapshot; + synchronized (lock) + { + updateViews(); + sql = usingSql; + snapshot = new ArrayList<>(bans); + } + + if (!sql) + { + if (!plugin.isEnabled()) + { + writeAllToJson(snapshot); + return; + } + enqueue(writeJsonAsync(snapshot)); + return; + } + + if (plugin.dm == null || !plugin.dm.isInitialized()) { - saveAll(); + FLog.warning("SQL not available, falling back to JSON save"); + enqueue(writeJsonAsync(snapshot)); return; } - plugin.getServer().getScheduler().runTaskAsynchronously(plugin, this::saveAll); + + final BanRepository repo = plugin.dm.getBanRepository(); + + enqueue(repo.deleteAll() + .onErrorResume(ex -> + { + FLog.warning("Failed to clear bans before rewrite: " + ex.getMessage()); + return Mono.empty(); + }) + .thenMany(Flux.fromIterable(snapshot) + .concatMap(ban -> repo.save(ban) + .onErrorResume(ex -> + { + FLog.warning("Failed to save ban to SQL: " + ex.getMessage()); + return Mono.empty(); + }))) + .then(writeJsonAsync(snapshot))); } + /** + * Queue a single ban for an off-thread SQL write, followed by a refresh of the JSON + * snapshot. Safe to call from commands and event handlers. + */ private void saveBanToSqlAsync(Ban ban) { - if (!plugin.isEnabled()) + if (plugin.dm == null || !plugin.dm.isInitialized()) { - saveBanToSql(ban); + FLog.warning("SQL not available; ban change was not saved"); return; } - plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> saveBanToSql(ban)); + + final BanRepository repo = plugin.dm.getBanRepository(); + final List snapshot = currentBansSnapshot(); + + enqueue(repo.save(ban) + .onErrorResume(ex -> + { + FLog.warning("Failed to save ban to SQL: " + ex.getMessage()); + return Mono.empty(); + }) + .then(writeJsonAsync(snapshot))); } + /** + * Queue a single ban removal for an off-thread SQL write, followed by a refresh of the + * JSON snapshot. Safe to call from commands and event handlers. + */ private void removeBanFromSqlAsync(Ban ban) { - if (!plugin.isEnabled()) + if (plugin.dm == null || !plugin.dm.isInitialized()) { - removeBanFromSql(ban); + FLog.warning("SQL not available; ban removal was not saved"); return; } - plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> removeBanFromSql(ban)); + + final BanRepository repo = plugin.dm.getBanRepository(); + final List snapshot = currentBansSnapshot(); + + final Mono delete; + if (ban.getUuid() != null) + { + delete = repo.deleteByUuid(ban.getUuid()); + } + else if (ban.hasUsername()) + { + delete = Mono.fromCallable(() -> repo.deleteByUsername(ban.getUsername())) + .subscribeOn(Schedulers.boundedElastic()); + } + else + { + delete = Mono.just(Boolean.FALSE); + } + + enqueue(delete + .onErrorResume(ex -> + { + FLog.warning("Failed to remove ban from SQL: " + ex.getMessage()); + return Mono.empty(); + }) + .then(writeJsonAsync(snapshot))); } - /** - * Write the given snapshot of bans to the SQL database. Must be called under persistenceLock. - */ private void writeAllToSql(List snapshot) { if (plugin.dm == null || !plugin.dm.isInitialized()) @@ -292,7 +421,6 @@ private void writeAllToSql(List snapshot) try { BanRepository repo = plugin.dm.getBanRepository(); - // Clear and re-add all (simple approach for now) repo.deleteAll().block(); for (Ban ban : snapshot) { @@ -308,8 +436,7 @@ private void writeAllToSql(List snapshot) } /** - * Write the given snapshot of bans to the JSON file (fallback, and the write-through - * snapshot when using SQL). Must be called under persistenceLock. + * Write the given snapshot of bans to the JSON file (fallback, and the write-through snapshot when using SQL). */ private void writeAllToJson(List snapshot) { @@ -323,6 +450,12 @@ private void writeAllToJson(List snapshot) } } + private Mono writeJsonAsync(List snapshot) + { + return Mono.fromRunnable(() -> writeAllToJson(snapshot)) + .subscribeOn(Schedulers.boundedElastic()); + } + public Ban getByIp(String ip) { synchronized (lock) @@ -497,61 +630,6 @@ private void cancelWorldEditFor(Ban ban) } } - /** - * Save a single ban to SQL database. - */ - private void saveBanToSql(Ban ban) - { - if (plugin.dm == null || !plugin.dm.isInitialized()) - { - return; - } - - synchronized (persistenceLock) - { - try - { - plugin.dm.getBanRepository().save(ban).block(); - writeAllToJson(currentBansSnapshot()); - } - catch (Exception ex) - { - FLog.warning("Failed to save ban to SQL: " + ex.getMessage()); - } - } - } - - /** - * Remove a ban from SQL database. - */ - private void removeBanFromSql(Ban ban) - { - if (plugin.dm == null || !plugin.dm.isInitialized()) - { - return; - } - - synchronized (persistenceLock) - { - try - { - if (ban.getUuid() != null) - { - plugin.dm.getBanRepository().deleteByUuid(ban.getUuid()).block(); - } - else if (ban.hasUsername()) - { - plugin.dm.getBanRepository().deleteByUsername(ban.getUsername()); - } - writeAllToJson(currentBansSnapshot()); - } - catch (Exception ex) - { - FLog.warning("Failed to remove ban from SQL: " + ex.getMessage()); - } - } - } - public boolean removeBan(Ban ban) { final boolean removed; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java index 7c1a48001..e04f44fa4 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java @@ -3,6 +3,7 @@ import com.google.common.collect.Maps; import com.google.common.collect.Sets; import com.google.gson.reflect.TypeToken; +import java.time.Duration; import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -25,24 +26,24 @@ import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.player.AsyncPlayerPreLoginEvent; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; public class PermbanList extends FreedomService { public static final String CONFIG_FILENAME = "permbans.json"; - private static final Type PERMBAN_MAP_TYPE = new TypeToken>() {}.getType(); + private static final long SHUTDOWN_FLUSH_TIMEOUT_MS = 10L * 1000L; private final Set permbannedNames = Sets.newHashSet(); private final Set permbannedIps = Sets.newHashSet(); - - // Store full PermBan objects for SQL operations private final Map permbansByName = Maps.newHashMap(); private final File configFile; private final Object lock = new Object(); private final Object persistenceLock = new Object(); - // Flag to track if SQL is available + private Mono persistenceChain = Mono.empty(); private boolean usingSql = false; public PermbanList(TotalFreedomMod plugin) @@ -54,15 +55,10 @@ public PermbanList(TotalFreedomMod plugin) @Override protected void onStart() { - // Try to load from SQL database first if (plugin.dm != null && plugin.dm.isInitialized()) - { loadFromSql(); - } else - { loadFromJson(); - } } /** @@ -108,29 +104,21 @@ private void loadFromSql() private void reconcileFromJsonIfNewer(PermbanRepository repo) { if (!configFile.exists()) - { return; - } try { Long sqlUpdatedAt = repo.getMaxUpdatedAt(); if (sqlUpdatedAt != null && configFile.lastModified() <= sqlUpdatedAt) - { return; - } Map jsonPermbans = readJsonPermbans(); if (jsonPermbans.isEmpty()) - { return; - } FLog.info("permbans.json is newer than the database; re-importing " + jsonPermbans.size() + " permban(s) from it."); for (PermBan permban : jsonPermbans.values()) - { repo.save(permban).block(); - } synchronized (lock) { @@ -226,25 +214,85 @@ private void saveToJson() @Override protected void onStop() { + awaitPendingWrites(SHUTDOWN_FLUSH_TIMEOUT_MS); + if (usingSql) - { saveAllToSql(); - } else - { saveToJson(); + + } + + /** + * Wait for queued async writes to land, up to {@code timeoutMs}. Without this a shutdown + * flush can race the queue and let an older queued snapshot overwrite the state we just wrote. + */ + public void awaitPendingWrites(long timeoutMs) + { + final Mono pending; + synchronized (persistenceLock) + { + pending = persistenceChain; + } + + try + { + pending.block(Duration.ofMillis(timeoutMs)); + } + catch (IllegalStateException ex) + { + FLog.warning(String.format("Gave up after %dms waiting for pending permban writes (%s); flushing anyway", + timeoutMs, ex.getMessage())); + } + catch (RuntimeException ex) + { + FLog.warning("A queued permban write failed before shutdown: " + ex.getMessage()); } } /** - * Save all permbans to SQL database. + * Append {@code work} to the persistence chain and subscribe. Every queued write runs + * after the one before it, so a batch can never be reordered behind a single-row update + * that was requested later. + */ + private void enqueue(Mono work) + { + synchronized (persistenceLock) + { + final Mono queued = persistenceChain + .onErrorResume(ignored -> Mono.empty()) + .then(work) + .cache(); + + persistenceChain = queued; + queued.doFinally(signal -> collapseChain(queued)).subscribe(); + } + } + + private void collapseChain(Mono completed) + { + synchronized (persistenceLock) + { + if (persistenceChain == completed) + { + persistenceChain = Mono.empty(); + } + } + } + + private Mono writeJsonAsync() + { + return Mono.fromRunnable(this::saveToJson) + .subscribeOn(Schedulers.boundedElastic()); + } + + /** + * Blocking write of every permban record to SQL. Startup/shutdown only. */ private void saveAllToSql() { if (plugin.dm == null || !plugin.dm.isInitialized()) - { return; - } final List snapshot; synchronized (lock) @@ -252,22 +300,18 @@ private void saveAllToSql() snapshot = new ArrayList<>(permbansByName.values()); } - synchronized (persistenceLock) + try { - try - { - PermbanRepository repo = plugin.dm.getPermbanRepository(); - for (PermBan permban : snapshot) - { - repo.save(permban).block(); - } - FLog.debug("Saved " + snapshot.size() + " permbans to SQL database"); - saveToJson(); - } - catch (Exception ex) - { - FLog.warning("Failed to save permbans to SQL: " + ex.getMessage()); - } + PermbanRepository repo = plugin.dm.getPermbanRepository(); + for (PermBan permban : snapshot) + repo.save(permban).block(); + + FLog.debug("Saved " + snapshot.size() + " permbans to SQL database"); + saveToJson(); + } + catch (Exception ex) + { + FLog.warning("Failed to save permbans to SQL: " + ex.getMessage()); } } @@ -293,13 +337,9 @@ public void addPermban(PermBan permban) } if (sql) - { savePermbanToSqlAsync(permban); - } else - { saveToJson(); - } } /** @@ -314,24 +354,17 @@ public boolean removePermban(String username) { permban = permbansByName.remove(name); if (permban == null) - { return false; - } permbannedNames.remove(name); - // Remove IPs associated with this permban permbannedIps.removeAll(permban.getIps()); sql = usingSql; } if (sql) - { removePermbanFromSqlAsync(name); - } else - { saveToJson(); - } return true; } @@ -358,9 +391,7 @@ public List removePermbansByIp(String ip) } if (!matches) - { continue; - } final String name = permban.getUsername().toLowerCase().trim(); permbansByName.remove(name); @@ -371,24 +402,17 @@ public List removePermbansByIp(String ip) // Rebuild the IP view so IPs shared with surviving permbans are retained. permbannedIps.clear(); for (PermBan permban : permbansByName.values()) - { permbannedIps.addAll(permban.getIps()); - } sql = usingSql; } if (sql) - { for (String name : removedNames) - { - removePermbanFromSqlAsync(name.toLowerCase().trim()); - } - } + removePermbanFromSqlAsync(name.toLowerCase().trim()); // why does this look so nice without brackets lmao + else if (!removedNames.isEmpty()) - { saveToJson(); - } return removedNames; } @@ -427,71 +451,50 @@ public Set getPermbannedIps() } /** - * Save a single permban to SQL. + * Queue a single permban for an off-thread SQL write, followed by a refresh of the JSON + * snapshot. Safe to call from commands and event handlers. */ private void savePermbanToSqlAsync(PermBan permban) - { - if (!plugin.isEnabled()) - { - savePermbanToSql(permban); - return; - } - plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> savePermbanToSql(permban)); - } - - private void savePermbanToSql(PermBan permban) { if (plugin.dm == null || !plugin.dm.isInitialized()) { + FLog.warning("SQL not available; permban change was not saved"); return; } - synchronized (persistenceLock) - { - try - { - plugin.dm.getPermbanRepository().save(permban).block(); - saveToJson(); - } - catch (Exception ex) - { - FLog.warning("Failed to save permban to SQL: " + ex.getMessage()); - } - } + final PermbanRepository repo = plugin.dm.getPermbanRepository(); + + enqueue(repo.save(permban) + .onErrorResume(ex -> + { + FLog.warning("Failed to save permban to SQL: " + ex.getMessage()); + return Mono.empty(); + }) + .then(writeJsonAsync())); } - + /** - * Remove a single permban from SQL. + * Queue a single permban removal for an off-thread SQL write, followed by a refresh of + * the JSON snapshot. Safe to call from commands and event handlers. */ private void removePermbanFromSqlAsync(String name) - { - if (!plugin.isEnabled()) - { - removePermbanFromSql(name); - return; - } - plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> removePermbanFromSql(name)); - } - - private void removePermbanFromSql(String name) { if (plugin.dm == null || !plugin.dm.isInitialized()) { + FLog.warning("SQL not available; permban removal was not saved"); return; } - synchronized (persistenceLock) - { - try - { - plugin.dm.getPermbanRepository().deleteByUsername(name); - saveToJson(); - } - catch (Exception ex) - { - FLog.warning("Failed to remove permban from SQL: " + ex.getMessage()); - } - } + final PermbanRepository repo = plugin.dm.getPermbanRepository(); + + enqueue(Mono.fromCallable(() -> repo.deleteByUsername(name)) + .subscribeOn(Schedulers.boundedElastic()) + .onErrorResume(ex -> + { + FLog.warning("Failed to remove permban from SQL: " + ex.getMessage()); + return Mono.empty(); + }) + .then(writeJsonAsync())); } @EventHandler(priority = EventPriority.LOWEST) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java index 5cfb85cda..12a6a4a21 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java @@ -7,9 +7,12 @@ import java.io.FileWriter; import java.io.IOException; import java.lang.reflect.Type; +import java.time.Duration; +import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.Iterator; +import java.util.List; import java.util.Map; import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; @@ -17,17 +20,22 @@ import me.totalfreedom.totalfreedommod.sql.adapter.StrikeRepository; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.JsonUtil; -import org.bukkit.configuration.ConfigurationSection; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; public class StrikeList extends FreedomService { private static final Type STRIKE_MAP_TYPE = new TypeToken>() {}.getType(); + private static final long SHUTDOWN_FLUSH_TIMEOUT_MS = 10L * 1000L; private final Map strikes = Maps.newHashMap(); private final File configFile; + private final Object persistenceLock = new Object(); + private boolean usingSql = false; private boolean persistEnabled = true; + private Mono persistenceChain = Mono.empty(); public StrikeList(TotalFreedomMod plugin) { @@ -66,16 +74,92 @@ protected void onStart() @Override protected void onStop() { - if (!persistEnabled) - { + if (!persistEnabled) return; + + awaitPendingWrites(SHUTDOWN_FLUSH_TIMEOUT_MS); + + if (!usingSql) + saveToJson(); + + } + + /** + * Re-run the startup load. Used after a one-time YAML-to-SQL migration so this manager's + * in-memory state picks up the freshly-migrated rows. + */ + public void reload() + { + onStop(); + onStart(); + } + + /** + * Wait for queued async writes to land, up to {@code timeoutMs}. Without this a shutdown + * flush can race the queue and let an older queued snapshot overwrite the state we just wrote. + */ + public void awaitPendingWrites(long timeoutMs) + { + final Mono pending; + synchronized (persistenceLock) + { + pending = persistenceChain; } - if (!usingSql) + + try { - saveToJson(); + pending.block(Duration.ofMillis(timeoutMs)); + } + catch (IllegalStateException ex) + { + FLog.warning(String.format("Gave up after %dms waiting for pending strike writes (%s); flushing anyway", + timeoutMs, ex.getMessage())); + } + catch (RuntimeException ex) + { + FLog.warning("A queued strike write failed before shutdown: " + ex.getMessage()); + } + } + + /** + * Append {@code work} to the persistence chain and subscribe. Every queued write runs + * after the one before it, so a decay-prune can never race a concurrent re-strike on the + * same IP into landing out of order. + */ + private void enqueue(Mono work) + { + synchronized (persistenceLock) + { + final Mono queued = persistenceChain + .onErrorResume(ignored -> Mono.empty()) + .then(work) + .cache(); + + persistenceChain = queued; + queued.doFinally(signal -> collapseChain(queued)).subscribe(); + } + } + + /** + * Drop the retained operator chain once its tail has completed. The chain is strictly + * sequential, so a completed tail means every write before it is done and nothing needs + * to wait on them any more. + */ + private void collapseChain(Mono completed) + { + synchronized (persistenceLock) + { + if (persistenceChain == completed) + persistenceChain = Mono.empty(); } } + private Mono writeJsonAsync() + { + return Mono.fromRunnable(this::saveToJson) + .subscribeOn(Schedulers.boundedElastic()); + } + private void loadFromSql() { try @@ -100,29 +184,21 @@ private void loadFromSql() private void reconcileFromJsonIfNewer(StrikeRepository repo) { if (!configFile.exists()) - { return; - } try { Long sqlUpdatedAt = repo.getMaxUpdatedAt(); if (sqlUpdatedAt != null && configFile.lastModified() <= sqlUpdatedAt) - { return; - } Map jsonStrikes = readJsonStrikes(); if (jsonStrikes.isEmpty()) - { return; - } FLog.info("strikes.json is newer than the database; re-importing " + jsonStrikes.size() + " strike record(s) from it."); for (StrikeRecord r : jsonStrikes.values()) - { repo.upsertAsync(r).block(); - } strikes.clear(); strikes.putAll(jsonStrikes); @@ -172,34 +248,46 @@ private void pruneDecayed() { final int decayHours = decayHours(); if (decayHours <= 0) - { return; - } - int removed = 0; + + final List prunedIps = new ArrayList<>(); for (Iterator> it = strikes.entrySet().iterator(); it.hasNext(); ) { Map.Entry e = it.next(); if (e.getValue().effectiveCount(decayHours) == 0) { it.remove(); - removed++; - if (usingSql && plugin.dm != null && plugin.dm.isInitialized()) - { - plugin.dm.getStrikeRepository().deleteByIpAsync(e.getKey()) - .then(Mono.fromRunnable(this::saveToJson)) - .subscribe(deleted -> {}, ex -> - FLog.warning("Failed to prune decayed strike for " + e.getKey() + ": " + ex.getMessage())); - } + prunedIps.add(e.getKey()); } } - if (removed > 0 && !usingSql) + + if (prunedIps.isEmpty()) + return; + + if (usingSql && plugin.dm != null && plugin.dm.isInitialized()) { - saveToJsonAsync(); + final StrikeRepository repo = plugin.dm.getStrikeRepository(); + enqueue(Flux.fromIterable(prunedIps) + .concatMap(ip -> repo.deleteByIpAsync(ip) + .onErrorResume(ex -> + { + FLog.warning(String.format( + "Failed to prune decayed strike for %s: %s", + ip, + ex.getMessage() + ) + ); + return Mono.empty(); + }) + ) + .then(writeJsonAsync())); } - if (removed > 0) + else { - FLog.info("Pruned " + removed + " decayed strike record(s)."); + enqueue(writeJsonAsync()); } + + FLog.info("Pruned " + prunedIps.size() + " decayed strike record(s)."); } private int decayHours() @@ -214,25 +302,22 @@ public synchronized int recordStrikeAndGet(String ip, String username) final int decay = decayHours(); int base = 0; if (r != null) - { base = r.effectiveCount(decay); - } + if (r == null) { r = new StrikeRecord(ip); strikes.put(ip, r); } + r.setCount(base + 1); r.setLastStrikeUnix(System.currentTimeMillis() / 1000L); if (username != null) - { r.setLastUsername(username); - } if (persistEnabled) - { persist(r); - } + return r.getCount(); } @@ -240,9 +325,8 @@ public synchronized int peek(String ip) { StrikeRecord r = strikes.get(ip); if (r == null) - { return 0; - } + return r.effectiveCount(decayHours()); } @@ -250,24 +334,24 @@ public synchronized boolean clear(String ip) { StrikeRecord removed = strikes.remove(ip); if (removed == null) - { return false; - } + if (!persistEnabled) - { return true; - } + if (usingSql && plugin.dm != null && plugin.dm.isInitialized()) { - plugin.dm.getStrikeRepository().deleteByIpAsync(ip) - .then(Mono.fromRunnable(this::saveToJson)) - .subscribe(deleted -> {}, ex -> - FLog.warning("Failed to clear strike from SQL: " + ex.getMessage())); - } - else - { - saveToJsonAsync(); + final StrikeRepository repo = plugin.dm.getStrikeRepository(); + enqueue(repo.deleteByIpAsync(ip) + .onErrorResume(ex -> + { + FLog.warning("Failed to clear strike from SQL: " + ex.getMessage()); + return Mono.empty(); + }) + .then(writeJsonAsync())); } + else enqueue(writeJsonAsync()); // else on same line maybe??? that is nice tbh + return true; } @@ -280,25 +364,16 @@ private void persist(StrikeRecord r) { if (usingSql && plugin.dm != null && plugin.dm.isInitialized()) { - plugin.dm.getStrikeRepository().upsertAsync(r) - .then(Mono.fromRunnable(this::saveToJson)) - .subscribe(null, ex -> - FLog.warning("Failed to persist strike to SQL: " + ex.getMessage())); - } - else - { - saveToJsonAsync(); - } - } - - private void saveToJsonAsync() - { - if (!plugin.isEnabled()) - { - saveToJson(); - return; + final StrikeRepository repo = plugin.dm.getStrikeRepository(); + enqueue(repo.upsertAsync(r) + .onErrorResume(ex -> + { + FLog.warning("Failed to persist strike to SQL: " + ex.getMessage()); + return Mono.empty(); + }) + .then(writeJsonAsync())); } - plugin.getServer().getScheduler().runTaskAsynchronously(plugin, this::saveToJson); + else enqueue(writeJsonAsync()); } private synchronized void saveToJson() diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_sqlstatus.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_sqlstatus.java new file mode 100644 index 000000000..a4e3d012f --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_sqlstatus.java @@ -0,0 +1,48 @@ +package me.totalfreedom.totalfreedommod.cmd; + +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; +import me.totalfreedom.totalfreedommod.rank.Rank; +import me.totalfreedom.totalfreedommod.sql.ConnectionHandler.PoolStats; +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + +import org.bukkit.command.CommandSender; + +@Command(name = "sqlstatus", description = "Show database connection pool health.", usage = "/sqlstatus") +@Permission(level = Rank.SENIOR_ADMIN, source = SourceType.BOTH, permission = "tfm.admin.sqlstatus") +public class Command_sqlstatus extends FCommand +{ + @Callback + public void info(CommandSender sender) + { + if (plugin().dm == null || !plugin().dm.isInitialized()) + { + msg(sender, "Database is not initialized; every domain is running on its JSON fallback."); + return; + } + + final PoolStats stats = plugin().dm.getPoolStats(); + if (stats == null) + { + msg(sender, "Could not read connection pool stats."); + return; + } + + msg(sender, + """ + Database: + Pool: active connections, idle connections, total connections (max ) + Threads waiting on a connection: + Fairness queue: permit(s) available, permit(s) waiting. + """, + Placeholder.unparsed("db_type", stats.databaseType()), + Formatter.number("db_actives", stats.activeConnections()), + Formatter.number("db_idle", stats.idleConnections()), + Formatter.number("db_total", stats.totalConnections()), + Formatter.number("db_max_pool_size", stats.maxPoolSize()), + Formatter.number("thread_await", stats.threadsAwaitingConnection()), + Formatter.number("permits_open", stats.availablePermits()), + Formatter.number("permits_waiting", stats.queueLength()) + ); + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/AccessController.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/AccessController.java index e1152f779..3bc383f16 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/AccessController.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/AccessController.java @@ -1,31 +1,43 @@ package me.totalfreedom.totalfreedommod.sql; +import java.sql.SQLException; import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import reactor.core.scheduler.Schedulers; +import reactor.core.scheduler.Scheduler; /** * This is a fair, non-blocking access controller that ensures our queries on the connection pool * don't overload our available connections. * * The {@link Semaphore} utilizes a FIFO waiting queue. - * The query that has been waiting longest always receives the next available permit. + * The query that has been waiting longest always receives the next available permit. * Permit release is guaranteed on completion, error, and cancellation. + * + * The same semaphore backs both the reactive {@link #guard} path and the synchronous + * {@link #acquireSync()}/{@link #releaseSync()} pair, so {@link #availablePermits()} and + * {@link #queueLength()} reflect total demand regardless of which path callers use. */ public final class AccessController { + // Matches HikariCP's own default connection timeout. + private static final long ACQUIRE_TIMEOUT_SECONDS = 30L; + private final Semaphore semaphore; + private final Scheduler scheduler; /** - * @param permits maximum number of concurrently executing queries. + * @param permits maximum number of concurrently executing queries. * Should always match the HikariCP maximum pool size. + * @param scheduler dedicated scheduler to acquire permits on, sized off the same pool. */ - public AccessController(final int permits) + public AccessController(final int permits, final Scheduler scheduler) { this.semaphore = new Semaphore(permits, true); + this.scheduler = scheduler; } /** @@ -54,6 +66,36 @@ public Flux guard(final Flux query) ignored -> release()); } + /** + * Acquire a permit for a synchronous unit of work, + * for the handful of call sites that don't go through the reactive {@link #guard} path. + *

+ * Waits at most {@link #ACQUIRE_TIMEOUT_SECONDS} rather than blocking forever. + * Always paired with {@link #releaseSync()}, typically in a {@code finally} block. + */ + public void acquireSync() throws SQLException + { + final boolean acquired; + try + { + acquired = semaphore.tryAcquire(ACQUIRE_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + catch (InterruptedException ex) + { + Thread.currentThread().interrupt(); + throw new SQLException("Interrupted while waiting for a permit", ex); + } + + if (!acquired) + throw new SQLException(String.format( + "Timed out after %ds waiting for a permit", ACQUIRE_TIMEOUT_SECONDS)); + } + + public void releaseSync() + { + semaphore.release(); + } + public int availablePermits() { return semaphore.availablePermits(); @@ -80,13 +122,9 @@ private Mono acquire() { semaphore.acquire(); if (cancelled.get()) - { semaphore.release(); - } else - { sink.success(Boolean.TRUE); - } } catch (final InterruptedException e) { @@ -96,7 +134,7 @@ private Mono acquire() { Thread.interrupted(); } - }).subscribeOn(Schedulers.boundedElastic()); + }).subscribeOn(scheduler); } private Mono release() diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/ConnectionHandler.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/ConnectionHandler.java index 11ff80afc..bc20e80bf 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/ConnectionHandler.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/ConnectionHandler.java @@ -8,11 +8,15 @@ import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; +import com.zaxxer.hikari.HikariPoolMXBean; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.sql.SQLProperties.DatabaseType; import me.totalfreedom.totalfreedommod.util.FLog; +import reactor.core.scheduler.Scheduler; +import reactor.core.scheduler.Schedulers; + /** * Contains the HikariCP connection pool for the configured database. * Supports: SQLite, MySQL, PostgreSQL @@ -22,9 +26,19 @@ public class ConnectionHandler private static final int DEFAULT_POOL_SIZE = 10; private static final int SQLITE_POOL_SIZE = 1; + /** + * A thread waiting to acquire an {@link AccessController} permit blocks for the duration of awaiting, + * so for SQLite's pool of 1, a scheduler capped at 1 thread would let only one caller + * ever be "waiting" at a time and starve every other concurrent request. + */ + private static final int SCHEDULER_POOL_MULTIPLIER = 4; + private static final int SCHEDULER_MIN_THREADS = 16; + private static final int SCHEDULER_QUEUED_TASK_CAP = 100_000; + private final SQLProperties sqlProperties; private volatile HikariDataSource dataSource; private volatile AccessController accessController; + private volatile Scheduler scheduler; public ConnectionHandler(@NotNull final TotalFreedomMod plugin) { @@ -87,7 +101,9 @@ public void connect() throws SQLException maskPassword(config.getJdbcUrl()) )); this.dataSource = new HikariDataSource(config); - this.accessController = new AccessController(dataSource.getMaximumPoolSize()); + final int schedulerThreads = Math.max(dataSource.getMaximumPoolSize() * SCHEDULER_POOL_MULTIPLIER, SCHEDULER_MIN_THREADS); + this.scheduler = Schedulers.newBoundedElastic(schedulerThreads, SCHEDULER_QUEUED_TASK_CAP, "tfm-sql-" + dbType.getName()); + this.accessController = new AccessController(dataSource.getMaximumPoolSize(), scheduler); if (dbType == DatabaseType.SQLITE) { @@ -148,12 +164,67 @@ public AccessController getAccessController() return accessController; } + /** + * Dedicated Reactor scheduler for SQL work, sized off the pool's actual max size plus additional headroom to avoid starvation. + *

+ * Use {@link SCHEDULER_POOL_MULTIPLIER} rather than sharing the JVM-wide + * default {@link Schedulers#boundedElastic()} with unrelated plugin async work. + */ + @NotNull + public Scheduler getScheduler() + { + if (scheduler == null) + { + throw new IllegalStateException("Connection pool not initialized. Call connect() first."); + } + return scheduler; + } + @NotNull public DatabaseType getDatabaseType() { return sqlProperties.getDatabaseType(); } + /** + * Snapshot of live pool and fairness-queue stats, for admin-facing diagnostics. + */ + public record PoolStats( + String databaseType, + int maxPoolSize, + int activeConnections, + int idleConnections, + int totalConnections, + int threadsAwaitingConnection, + int availablePermits, + int queueLength) + { + } + + /** + * Read current pool health directly off Hikari's own {@link HikariPoolMXBean}, plus the + * {@link AccessController} fairness-queue counters. + */ + @NotNull + public PoolStats getPoolStats() + { + if (dataSource == null || accessController == null) + { + throw new IllegalStateException("Connection pool not initialized. Call connect() first."); + } + + final HikariPoolMXBean poolMXBean = dataSource.getHikariPoolMXBean(); + return new PoolStats( + getDatabaseType().getName(), + dataSource.getMaximumPoolSize(), + poolMXBean.getActiveConnections(), + poolMXBean.getIdleConnections(), + poolMXBean.getTotalConnections(), + poolMXBean.getThreadsAwaitingConnection(), + accessController.availablePermits(), + accessController.queueLength()); + } + public boolean isConnected() { return dataSource != null && !dataSource.isClosed(); @@ -178,6 +249,10 @@ public boolean testConnection() public void shutdown() { FLog.info("Shutting down database connection handler..."); + if (scheduler != null) + { + scheduler.dispose(); + } if (dataSource != null && !dataSource.isClosed()) { dataSource.close(); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/FreedomDatabase.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/FreedomDatabase.java index 17eab2836..b4f808355 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/FreedomDatabase.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/FreedomDatabase.java @@ -17,6 +17,10 @@ import me.totalfreedom.totalfreedommod.util.FLog; import java.sql.SQLException; +import java.time.Duration; + +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; /** * Central database management service. @@ -28,6 +32,12 @@ */ public class FreedomDatabase extends FreedomService { + /** + * Upper bound on how long {@link #initialize()} waits for the connection pool and schema + * migrations to finish. The actual work runs on a background thread either way. + */ + private static final long INIT_TIMEOUT_SECONDS = 45L; + private ConnectionHandler connectionHandler; private StatementHandler statementHandler; private DatabaseAdapter adapter; @@ -60,6 +70,13 @@ protected void onStop() /** * Initialize the database connection and adapter. + *

+ * The actual connection-pool bootstrap and schema migration run on a background thread, + * not the calling thread. This is called synchronously from {@link #onStart()} during + * {@code onEnable}, which runs on the main thread, and a slow or unreachable host must + * not hang the whole server boot. The wait is bounded by {@link #INIT_TIMEOUT_SECONDS}; + * if it elapses, this throws and every domain falls back to its JSON snapshot, same as + * any other connection failure. */ public void initialize() throws SQLException { @@ -71,25 +88,36 @@ public void initialize() throws SQLException FLog.info("Initializing database..."); - // Create connection handler connectionHandler = new ConnectionHandler(plugin); - - // Check database type SQLProperties properties = connectionHandler.getSqlProperties(); DatabaseType dbType = properties.getDatabaseType(); - // Build the connection pool - connectionHandler.connect(); - - // Create statement handler - statementHandler = new StatementHandler(connectionHandler); - - // Create the adapter using the factory - adapter = AdapterFactory.createAdapter(plugin, properties, connectionHandler, statementHandler); + final DatabaseAdapter built; + try + { + built = Mono.fromCallable(() -> + { + connectionHandler.connect(); + statementHandler = new StatementHandler(connectionHandler); + DatabaseAdapter created = AdapterFactory.createAdapter(plugin, properties, connectionHandler, statementHandler); + created.initialize(); + return created; + }) + .subscribeOn(Schedulers.boundedElastic()) + .block(Duration.ofSeconds(INIT_TIMEOUT_SECONDS)); + } + catch (Exception ex) + { + throw new SQLException(String.format( + "Database did not finish initializing within %ds", INIT_TIMEOUT_SECONDS), ex); + } - // Initialize the adapter (runs migrations) - adapter.initialize(); + if (built == null) + { + throw new SQLException("Database initialization completed with no adapter"); + } + adapter = built; initialized = true; FLog.info("Database initialized successfully (" + dbType.getName() + ")"); } @@ -242,6 +270,19 @@ public PlayerRepository getPlayerRepository() return adapter.getPlayerRepository(); } + /** + * Snapshot of live connection pool and fairness-queue health, or {@code null} if the + * database isn't initialized. + */ + public ConnectionHandler.PoolStats getPoolStats() + { + if (!initialized || connectionHandler == null) + { + return null; + } + return connectionHandler.getPoolStats(); + } + /** * Get the database type. */ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/StatementHandler.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/StatementHandler.java index 0cbdb9299..bbce8357f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/StatementHandler.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/StatementHandler.java @@ -10,7 +10,6 @@ import java.util.concurrent.Callable; import reactor.core.publisher.Mono; -import reactor.core.scheduler.Schedulers; /** * Executes SQL against connections borrowed from {@link ConnectionHandler}'s pool. @@ -29,9 +28,27 @@ public StatementHandler(ConnectionHandler connectionHandler) this.connectionHandler = connectionHandler; } + /** + * Prepares a statement, guarded by the same {@link AccessController} permit used by the + * reactive path so synchronous and async callers draw from one fairly-queued pool of + * concurrent queries. The permit is released when the returned statement is closed OR when a ResultSet is obtained. + */ public PreparedStatement prepareStatement(String sql, Object... params) throws SQLException { - Connection connection = connectionHandler.borrowConnection(); + final AccessController accessController = connectionHandler.getAccessController(); + accessController.acquireSync(); + + Connection connection; + try + { + connection = connectionHandler.borrowConnection(); + } + catch (SQLException e) + { + accessController.releaseSync(); + throw e; + } + PreparedStatement statement; try { @@ -41,9 +58,10 @@ public PreparedStatement prepareStatement(String sql, Object... params) throws S catch (SQLException e) { closeQuietly(connection); + accessController.releaseSync(); throw e; } - return closingStatementProxy(statement, connection); + return closingStatementProxy(statement, connection, accessController); } public ResultSet executeQuery(String sql, Object... params) throws SQLException @@ -68,31 +86,37 @@ public int executeUpdate(String sql, Object... params) throws SQLException } } - /** - * Runs an INSERT and returns the first generated key, or -1 if none was generated. - */ public long executeUpdateReturnKey(String sql, Object... params) throws SQLException { - Connection connection = connectionHandler.borrowConnection(); - try (PreparedStatement statement = connection.prepareStatement(sql, java.sql.Statement.RETURN_GENERATED_KEYS)) + final AccessController accessController = connectionHandler.getAccessController(); + accessController.acquireSync(); + try { - setParameters(statement, params); - statement.executeUpdate(); - try (ResultSet keys = statement.getGeneratedKeys()) + Connection connection = connectionHandler.borrowConnection(); + try (PreparedStatement statement = connection.prepareStatement(sql, java.sql.Statement.RETURN_GENERATED_KEYS)) { - return keys.next() ? keys.getLong(1) : -1L; + setParameters(statement, params); + statement.executeUpdate(); + try (ResultSet keys = statement.getGeneratedKeys()) + { + return keys.next() ? keys.getLong(1) : -1L; + } + } + finally + { + closeQuietly(connection); } } finally { - closeQuietly(connection); + accessController.releaseSync(); } } public Mono supplyMono(Callable work) { return connectionHandler.getAccessController().guard( - Mono.fromCallable(work).subscribeOn(Schedulers.boundedElastic())); + Mono.fromCallable(work).subscribeOn(connectionHandler.getScheduler())); } public Mono runMono(SqlRunnable work) @@ -108,7 +132,7 @@ public Mono runMono(SqlRunnable work) { throw new RuntimeException(e); } - }).subscribeOn(Schedulers.boundedElastic())); + }).subscribeOn(connectionHandler.getScheduler())); } @FunctionalInterface @@ -215,9 +239,11 @@ private static void closeQuietly(AutoCloseable closeable) /** * Wraps a PreparedStatement so that closing it also returns the pooled Connection - * that produced it, without changing anything about how callers use the statement. + * that produced it and releases the {@link AccessController} permit acquired in + * {@link #prepareStatement}, without changing anything about how callers use the statement. */ - private static PreparedStatement closingStatementProxy(PreparedStatement target, Connection connection) + private static PreparedStatement closingStatementProxy(PreparedStatement target, Connection connection, + AccessController accessController) { return (PreparedStatement) Proxy.newProxyInstance( StatementHandler.class.getClassLoader(), @@ -232,7 +258,14 @@ private static PreparedStatement closingStatementProxy(PreparedStatement target, } finally { - connection.close(); + try + { + connection.close(); + } + finally + { + accessController.releaseSync(); + } } return null; } From 88137cbf5dd9945a98ec68a41adbf9a49ac35de6 Mon Sep 17 00:00:00 2001 From: Paldiu Date: Fri, 31 Jul 2026 01:26:26 -0500 Subject: [PATCH 08/48] Final pass-through before PR can be reviewed --- .../totalfreedommod/ConfigConverter.java | 2 +- .../totalfreedommod/ProtectArea.java | 207 +++++++++------ .../totalfreedommod/SavedFlags.java | 216 ++++++++-------- .../totalfreedommod/TotalFreedomMod.java | 66 +---- .../totalfreedommod/admin/AdminList.java | 213 +++++++-------- .../totalfreedommod/banning/BanManager.java | 183 ++++++------- .../totalfreedommod/banning/PermbanList.java | 244 ++++++++++-------- .../totalfreedommod/banning/StrikeList.java | 192 +++++++------- .../cmd/Command_rankconfig.java | 4 +- .../discord/DiscordBridge.java | 6 +- .../discord/DiscordLinkJsonSync.java | 71 ++--- .../totalfreedommod/player/PlayerList.java | 98 ++++--- .../totalfreedommod/rank/RankManager.java | 215 ++++++++------- .../totalfreedommod/sql/AccessController.java | 127 +++++---- .../totalfreedommod/sql/FreedomDatabase.java | 153 +++++++---- .../totalfreedommod/sql/PersistenceQueue.java | 90 +++++++ .../totalfreedommod/sql/StatementHandler.java | 26 +- .../sql/YamlMigrationService.java | 97 +++++-- .../sql/adapter/DatabaseAdapter.java | 5 + .../sql/adapter/DiscordLinkRepository.java | 12 + .../sql/adapter/MigrationRepository.java | 35 +++ .../generic/GenericAdminRepository.java | 80 +++--- .../adapter/generic/GenericBanRepository.java | 125 ++++----- .../generic/GenericDiscordLinkRepository.java | 25 ++ .../generic/GenericMigrationRepository.java | 77 ++++++ .../generic/GenericPermbanRepository.java | 79 +++--- .../sql/adapter/mysql/MySQLAdapter.java | 11 + .../adapter/postgresql/PostgreSQLAdapter.java | 11 + .../sql/adapter/sqlite/SQLiteAdapter.java | 11 + src/main/resources/ranks.yml | 190 -------------- 30 files changed, 1561 insertions(+), 1310 deletions(-) create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/sql/PersistenceQueue.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/MigrationRepository.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericMigrationRepository.java delete mode 100644 src/main/resources/ranks.yml diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ConfigConverter.java b/src/main/java/me/totalfreedom/totalfreedommod/ConfigConverter.java index 1b377e550..3697169c2 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ConfigConverter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ConfigConverter.java @@ -193,7 +193,7 @@ public void convertAdminConsoleRanks() if (migrated > 0) { - plugin.al.save(); + plugin.al.saveAsync(); FLog.info("Remapped " + migrated + " admin(s) from deprecated console ranks."); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java b/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java index 0ea211a3b..6bda7f822 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java @@ -9,6 +9,7 @@ import me.totalfreedom.totalfreedommod.ProtectArea.ProtectedRegion.CantFindWorldException; import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.sql.PersistenceQueue; import me.totalfreedom.totalfreedommod.sql.adapter.ProtectedAreaRepository; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FTask; @@ -30,11 +31,15 @@ import org.bukkit.event.player.*; import org.bukkit.event.vehicle.VehicleDestroyEvent; import org.bukkit.scheduler.BukkitTask; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; import org.bukkit.util.Vector; public class ProtectArea extends FreedomService { private static final long ITEM_SWEEP_RATE = 40L; + private static final long SHUTDOWN_FLUSH_TIMEOUT_MS = 10L * 1000L; private static final Type PROTECTED_AREA_LIST_TYPE = new TypeToken>() {}.getType(); public static final String DATA_FILENAME = "protectedareas.json"; @@ -44,6 +49,8 @@ public class ProtectArea extends FreedomService private final Map areas = Maps.newHashMap(); + private final PersistenceQueue writes = new PersistenceQueue("protected area"); + private File dataFile; private boolean usingSql = false; private BukkitTask itemSweepTask; @@ -61,44 +68,61 @@ protected void onStart() dataFile = new File(plugin.getDataFolder(), DATA_FILENAME); - if (plugin.dm != null && plugin.dm.isInitialized()) - loadFromSql(); - else - loadFromJsonOrLegacy(); + load(); + plugin.dm.whenReady(this::load); itemSweepTask = Bukkit.getScheduler().runTaskTimer( plugin, FTask.guard("ProtectArea/sweepItems", this::sweepItems), ITEM_SWEEP_RATE, ITEM_SWEEP_RATE); } - private void loadFromSql() + /** + * Populate the area list from SQL where it is available and the JSON snapshot otherwise. + * Never blocks: the SQL read runs off-thread and is applied back on the main thread. + */ + public void load() { - try + if (dataFile == null) + dataFile = new File(plugin.getDataFolder(), DATA_FILENAME); + + if (plugin.dm != null && plugin.dm.isInitialized()) { - ProtectedAreaRepository repo = plugin.dm.getProtectedAreaRepository(); - List loaded = repo.loadAllAsync().block(); - usingSql = true; + loadFromSqlAsync(); + return; + } - if (loaded.isEmpty() && !dataFile.exists()) - { - File legacyFile = new File(plugin.getDataFolder(), LEGACY_DATA_FILENAME); - if (legacyFile.exists()) - migrateLegacyData(legacyFile); + loadFromJsonOrLegacy(); + } - return; - } + private void loadFromSqlAsync() + { + final ProtectedAreaRepository repo = plugin.dm.getProtectedAreaRepository(); + plugin.dm.readAsync("ProtectArea/loadFromSql", repo.loadAllAsync(), + loaded -> applyLoadedAreas(repo, loaded), + () -> + { + usingSql = false; + loadFromJsonOrLegacy(); + }); + } - areas.clear(); - loaded.forEach(region -> areas.put(region.getUuid(), region)); - FLog.info("Loaded " + areas.size() + " protected area(s) from SQL database."); + private void applyLoadedAreas(final ProtectedAreaRepository repo, final List loaded) + { + usingSql = true; - reconcileFromJsonIfNewer(repo); - } - catch (Exception ex) + if (loaded.isEmpty() && !dataFile.exists()) { - FLog.warning("Failed to load protected areas from SQL, falling back to JSON: " + ex.getMessage()); - usingSql = false; - loadFromJsonOrLegacy(); + final File legacyFile = new File(plugin.getDataFolder(), LEGACY_DATA_FILENAME); + if (legacyFile.exists()) + migrateLegacyData(legacyFile); + + return; } + + areas.clear(); + loaded.forEach(region -> areas.put(region.getUuid(), region)); + FLog.info(String.format("Loaded %d protected area(s) from SQL database.", areas.size())); + + reconcileFromJsonIfNewer(repo); } private void loadFromJsonOrLegacy() @@ -139,34 +163,60 @@ private List readJsonAreas() throws IOException } /** - * If protectedareas.json was written more recently than the database's last update, re-import it into SQL. + * If protectedareas.json was written more recently than the database's last update, re-import + * it into SQL. The comparison and the re-import both ride the write queue off the main thread. */ - private void reconcileFromJsonIfNewer(ProtectedAreaRepository repo) + private void reconcileFromJsonIfNewer(final ProtectedAreaRepository repo) { if (!dataFile.exists()) + { + // Nothing to reconcile against, but SQL now has rows that no snapshot covers. + writes.enqueue(writeJsonAsync()); return; + } + final List jsonAreas; try { - Long sqlUpdatedAt = repo.getMaxUpdatedAt(); - if (sqlUpdatedAt != null && dataFile.lastModified() <= sqlUpdatedAt) - return; - - List jsonAreas = readJsonAreas(); - if (jsonAreas.isEmpty()) - return; - - FLog.info(DATA_FILENAME + " is newer than the database; re-importing " + jsonAreas.size() + " protected area(s) from it."); - for (ProtectedRegion region : jsonAreas) - repo.saveOrUpdate(region); - - areas.clear(); - jsonAreas.forEach(region -> areas.put(region.getUuid(), region)); + jsonAreas = readJsonAreas(); } - catch (Exception ex) + catch (IOException ex) { - FLog.warning("Failed to reconcile " + DATA_FILENAME + " into the database: " + ex.getMessage()); + FLog.warning(String.format("Failed to read %s: %s", DATA_FILENAME, ex.getMessage())); + return; } + + if (jsonAreas.isEmpty()) + return; + + final long fileModified = dataFile.lastModified(); + + writes.enqueue(Mono.fromCallable(() -> + { + final Long sqlUpdatedAt = repo.getMaxUpdatedAt(); + return sqlUpdatedAt == null || fileModified > sqlUpdatedAt; + }) + .subscribeOn(Schedulers.boundedElastic()) + .filter(Boolean::booleanValue) + .flatMapMany(ignored -> + { + FLog.info(String.format("%s is newer than the database; re-importing %d protected area(s) from it.", + DATA_FILENAME, jsonAreas.size())); + return Flux.fromIterable(jsonAreas) + .concatMap(repo::save); + }) + .then(Mono.fromRunnable(() -> plugin.dm.sync("ProtectArea/applyReconciled", () -> + { + areas.clear(); + jsonAreas.forEach(region -> areas.put(region.getUuid(), region)); + }))) + .onErrorResume(ex -> + { + FLog.warning(String.format("Failed to reconcile %s into the database: %s", + DATA_FILENAME, ex.getMessage())); + return Mono.empty(); + }) + .then()); } @SuppressWarnings("unchecked") @@ -266,56 +316,63 @@ protected void onStop() itemSweepTask.cancel(); itemSweepTask = null; } - save(); - } - public void reload() - { - onStop(); - onStart(); + // Let the queue drain first, then flush: a queued write landing after the flush would + // restore a stale snapshot. + save(); + awaitPendingWrites(SHUTDOWN_FLUSH_TIMEOUT_MS); } + /** + * Queue a write of every protected area to SQL, followed by a refresh of the + * protectedareas.json snapshot. Falls back to a JSON-only write when SQL is unavailable. + * Safe from a command handler: the SQL round trips run off the main thread. + */ public void save() { - if (usingSql) - saveToSql(); - else - saveToJson(); - } - - private void saveToSql() - { - if (plugin.dm == null || !plugin.dm.isInitialized()) + if (!usingSql || plugin.dm == null || !plugin.dm.isInitialized()) { - FLog.warning("SQL not available, falling back to JSON save for protected areas"); - saveToJson(); + writes.enqueue(writeJsonAsync()); return; } - try - { - ProtectedAreaRepository repo = plugin.dm.getProtectedAreaRepository(); - for (ProtectedRegion region : areas.values()) - { - repo.save(region).block(); - } - } - catch (Exception ex) - { - FLog.severe("Could not save protected areas to SQL: " + ex.getMessage()); - } + final ProtectedAreaRepository repo = plugin.dm.getProtectedAreaRepository(); + final List snapshot = new ArrayList<>(areas.values()); + + writes.enqueue(Flux.fromIterable(snapshot) + .concatMap(region -> repo.save(region) + .onErrorResume(ex -> + { + FLog.severe(String.format("Could not save protected area %s to SQL: %s", + region.getName(), ex.getMessage())); + return Mono.empty(); + })) + .then(writeJsonAsync())); + } - saveToJson(); + /** + * Wait for queued protected-area writes to land, up to {@code timeoutMs}. + */ + public void awaitPendingWrites(long timeoutMs) + { + writes.await(timeoutMs); + } + + private Mono writeJsonAsync() + { + final List snapshot = new ArrayList<>(areas.values()); + return Mono.fromRunnable(() -> writeJson(snapshot)) + .subscribeOn(Schedulers.boundedElastic()); } - private void saveToJson() + private void writeJson(final List snapshot) { if (dataFile == null) dataFile = new File(plugin.getDataFolder(), DATA_FILENAME); try (FileWriter writer = new FileWriter(dataFile)) { - JsonUtil.GSON.toJson(new ArrayList<>(areas.values()), PROTECTED_AREA_LIST_TYPE, writer); + JsonUtil.GSON.toJson(snapshot, PROTECTED_AREA_LIST_TYPE, writer); } catch (IOException ex) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/SavedFlags.java b/src/main/java/me/totalfreedom/totalfreedommod/SavedFlags.java index 707a922f8..b298e8a11 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/SavedFlags.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/SavedFlags.java @@ -8,14 +8,18 @@ import java.io.IOException; import java.io.ObjectInputStream; import java.lang.reflect.Type; -import java.sql.SQLException; +import java.util.Collections; import java.util.HashMap; import java.util.Map; +import me.totalfreedom.totalfreedommod.sql.PersistenceQueue; import me.totalfreedom.totalfreedommod.sql.adapter.SavedFlagRepository; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.JsonUtil; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.YamlConfiguration; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; public class SavedFlags extends FreedomService { @@ -25,6 +29,14 @@ public class SavedFlags extends FreedomService public static final String LEGACY_DATA_FILENAME = "savedflags.dat"; private static final Type FLAGS_MAP_TYPE = new TypeToken>() {}.getType(); + private static final long SHUTDOWN_FLUSH_TIMEOUT_MS = 10L * 1000L; + + private final PersistenceQueue writes = new PersistenceQueue("saved flag"); + + /** + * Authoritative in-memory view. Kept current so reads never touch JDBC on the calling thread. + */ + private final Map flags = new HashMap<>(); private boolean usingSql = false; @@ -36,25 +48,43 @@ public SavedFlags(TotalFreedomMod plugin) @Override protected void onStart() { - usingSql = plugin.dm != null && plugin.dm.isInitialized(); - - File dataFile = new File(plugin.getDataFolder(), DATA_FILENAME); - File legacyFile = new File(plugin.getDataFolder(), LEGACY_DATA_FILENAME); + final File dataFile = new File(plugin.getDataFolder(), DATA_FILENAME); + final File legacyFile = new File(plugin.getDataFolder(), LEGACY_DATA_FILENAME); if (legacyFile.exists() && !dataFile.exists()) { migrateLegacyData(legacyFile, dataFile); } - if (usingSql) - { - reconcileFromJsonIfNewer(); - } + flags.putAll(readJsonFlags(dataFile)); + plugin.dm.whenReady(this::load); } @Override protected void onStop() { + writes.await(SHUTDOWN_FLUSH_TIMEOUT_MS); + } + + /** + * Refresh the in-memory view from SQL, then reconcile the JSON snapshot back into it if the + * file is the newer of the two. Never blocks. + */ + public void load() + { + if (plugin.dm == null || !plugin.dm.isInitialized()) + return; + + final SavedFlagRepository repo = plugin.dm.getSavedFlagRepository(); + plugin.dm.readAsync("SavedFlags/loadFromSql", repo.loadAllAsync(), + loaded -> + { + usingSql = true; + flags.clear(); + flags.putAll(loaded); + reconcileFromJsonIfNewer(repo); + }, + () -> usingSql = false); } @SuppressWarnings("unchecked") @@ -64,23 +94,10 @@ private void migrateLegacyData(File legacyFile, File dataFile) try (FileInputStream fis = new FileInputStream(legacyFile); ObjectInputStream ois = new ObjectInputStream(fis)) { - HashMap legacyFlags = (HashMap) ois.readObject(); + final HashMap legacyFlags = (HashMap) ois.readObject(); - if (usingSql) - { - try - { - SavedFlagRepository repo = plugin.dm.getSavedFlagRepository(); - for (Map.Entry entry : legacyFlags.entrySet()) - { - repo.upsert(entry.getKey(), entry.getValue()); - } - } - catch (SQLException ex) - { - FLog.severe("Could not save migrated flags to SQL: " + ex.getMessage()); - } - } + // Writing the snapshot is enough: the database is still coming up, and the + // reconcile pass on ready folds this file into SQL because it is the newer of the two. saveToJson(legacyFlags); File oldFile = new File(legacyFile.getParent(), LEGACY_DATA_FILENAME + ".old"); @@ -135,41 +152,51 @@ private Map loadLegacyYaml(File file) } /** - * If savedflags.json was written more recently than the database's last update, re-import it into SQL. + * If savedflags.json was written more recently than the database's last update, re-import it + * into SQL. The comparison and the re-import both ride the write queue off the main thread. */ - private void reconcileFromJsonIfNewer() + private void reconcileFromJsonIfNewer(final SavedFlagRepository repo) { - File dataFile = new File(plugin.getDataFolder(), DATA_FILENAME); + final File dataFile = new File(plugin.getDataFolder(), DATA_FILENAME); if (!dataFile.exists()) { + // Nothing to reconcile against, but SQL now has rows that no snapshot covers. + writes.enqueue(writeJsonAsync()); return; } - try - { - SavedFlagRepository repo = plugin.dm.getSavedFlagRepository(); - Long sqlUpdatedAt = repo.getMaxUpdatedAt(); - if (sqlUpdatedAt != null && dataFile.lastModified() <= sqlUpdatedAt) - { - return; - } + final Map jsonFlags = readJsonFlags(dataFile); + if (jsonFlags.isEmpty()) + return; - Map jsonFlags = readJsonFlags(dataFile); - if (jsonFlags.isEmpty()) - { - return; - } + final long fileModified = dataFile.lastModified(); - FLog.info(DATA_FILENAME + " is newer than the database; re-importing " + jsonFlags.size() + " flag(s) from it."); - for (Map.Entry entry : jsonFlags.entrySet()) - { - repo.upsert(entry.getKey(), entry.getValue()); - } - } - catch (Exception ex) - { - FLog.warning("Failed to reconcile " + DATA_FILENAME + " into the database: " + ex.getMessage()); - } + writes.enqueue(Mono.fromCallable(() -> + { + final Long sqlUpdatedAt = repo.getMaxUpdatedAt(); + return sqlUpdatedAt == null || fileModified > sqlUpdatedAt; + }) + .subscribeOn(Schedulers.boundedElastic()) + .filter(Boolean::booleanValue) + .flatMapMany(ignored -> + { + FLog.info(String.format("%s is newer than the database; re-importing %d flag(s) from it.", + DATA_FILENAME, jsonFlags.size())); + return Flux.fromIterable(jsonFlags.entrySet()) + .concatMap(entry -> repo.upsertAsync(entry.getKey(), entry.getValue())); + }) + .then(Mono.fromRunnable(() -> plugin.dm.sync("SavedFlags/applyReconciled", () -> + { + flags.clear(); + flags.putAll(jsonFlags); + }))) + .onErrorResume(ex -> + { + FLog.warning(String.format("Failed to reconcile %s into the database: %s", + DATA_FILENAME, ex.getMessage())); + return Mono.empty(); + }) + .then()); } private Map readJsonFlags(File file) @@ -197,82 +224,65 @@ private Map readJsonFlags(File file) return flags; } - private void saveToJson(Map flags) + private void saveToJson(final Map snapshot) { - File file = new File(plugin.getDataFolder(), DATA_FILENAME); + final File file = new File(plugin.getDataFolder(), DATA_FILENAME); try (FileWriter writer = new FileWriter(file)) { - JsonUtil.GSON.toJson(flags, FLAGS_MAP_TYPE, writer); + JsonUtil.GSON.toJson(snapshot, FLAGS_MAP_TYPE, writer); } catch (IOException ex) { - FLog.severe("Failed to save saved flags: " + ex.getMessage()); + FLog.severe(String.format("Failed to save saved flags: %s", ex.getMessage())); FLog.severe(ex); } } - public Map getSavedFlags() + private Mono writeJsonAsync() { - if (usingSql && plugin.dm != null && plugin.dm.isInitialized()) - { - try - { - return plugin.dm.getSavedFlagRepository().loadAll(); - } - catch (SQLException ex) - { - FLog.severe("Failed to load saved flags from SQL: " + ex.getMessage()); - } - } + final Map snapshot = new HashMap<>(flags); + return Mono.fromRunnable(() -> saveToJson(snapshot)) + .subscribeOn(Schedulers.boundedElastic()); + } - return readJsonFlags(new File(PluginProvider.get().getDataFolder(), DATA_FILENAME)); + /** + * The current flags, served from memory. Never touches JDBC on the calling thread. + */ + public Map getSavedFlags() + { + return Collections.unmodifiableMap(flags); } public boolean getSavedFlag(String flag) throws Exception { - Boolean flagValue = null; - - Map flags = getSavedFlags(); - - if (flags != null) - { - if (flags.containsKey(flag)) - { - flagValue = flags.get(flag); - } - } - - if (flagValue != null) - { - return flagValue; - } - else - { + final Boolean flagValue = flags.get(flag); + if (flagValue == null) throw new Exception(); - } + + return flagValue; } + /** + * Set a flag in memory and queue the SQL write plus a refresh of the JSON snapshot. Safe + * from a command handler: the round trip runs off the main thread. + */ public void setSavedFlag(String flag, boolean value) { - if (usingSql && plugin.dm != null && plugin.dm.isInitialized()) - { - try - { - plugin.dm.getSavedFlagRepository().upsert(flag, value); - } - catch (SQLException ex) - { - FLog.severe("Could not save flag '" + flag + "' to SQL: " + ex.getMessage()); - } - } + flags.put(flag, value); - Map flags = getSavedFlags(); - if (flags == null) + if (!usingSql || plugin.dm == null || !plugin.dm.isInitialized()) { - flags = new HashMap<>(); + writes.enqueue(writeJsonAsync()); + return; } - flags.put(flag, value); - saveToJson(flags); + + writes.enqueue(plugin.dm.getSavedFlagRepository().upsertAsync(flag, value) + .onErrorResume(ex -> + { + FLog.severe(String.format("Could not save flag '%s' to SQL: %s", flag, ex.getMessage())); + return Mono.empty(); + }) + .then(writeJsonAsync())); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/TotalFreedomMod.java b/src/main/java/me/totalfreedom/totalfreedommod/TotalFreedomMod.java index b056e3bf7..e16a8c17e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/TotalFreedomMod.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/TotalFreedomMod.java @@ -48,7 +48,6 @@ import me.totalfreedom.totalfreedommod.rank.ConsoleSenderRegistry; import me.totalfreedom.totalfreedommod.rank.RankManager; import me.totalfreedom.totalfreedommod.sql.FreedomDatabase; -import me.totalfreedom.totalfreedommod.sql.YamlMigrationService; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FUtil; import me.totalfreedom.totalfreedommod.util.MethodTimer; @@ -77,7 +76,6 @@ public class TotalFreedomMod extends JavaPlugin public AdminList al; // AdminList - Manages admin list and permissions public RankManager rm; // RankManager - Handles player ranks and display public ConsoleSenderRegistry csr; // ConsoleSenderRegistry - Maps console senders to appropriate rank - // public CommandLoader cl; // CommandLoader - Loads and registers commands (LEGACY) public CommandLoader cmdl; // CmdLoader - Loads and registers Brigadier commands public CommandBlocker cb; // CommandBlocker - Blocks specific commands public SweepScheduler sweepScheduler; // SweepScheduler - Shared budgeted world/chunk sweep walker @@ -180,11 +178,13 @@ public void onEnable() // Start services services = new ServiceManager<>(this); - sf = services.registerService(SavedFlags.class); - - // Initialize database manager first (before services that depend on it) + + // Initialize database manager first (before services that depend on it). Services stop + // in reverse registration order, so everything registered after this also flushes its + // pending writes before the connection pool closes. dm = services.registerService(FreedomDatabase.class); - + + sf = services.registerService(SavedFlags.class); wm = services.registerService(WorldManager.class); al = services.registerService(AdminList.class); @@ -271,9 +271,6 @@ public void onEnable() tl = services.registerService(TabList.class); services.start(); - // Run YAML to SQL migrations now that every service (including the database) has started - runYamlMigrations(); - // Start bridges bridges = new ServiceManager<>(this); cpb = bridges.registerService(CoreProtectBridge.class); @@ -375,55 +372,4 @@ public String formattedVersion() } } - /** - * Run YAML to SQL migrations for every domain that has one. Converts existing YAML/dat files to the new SQL database format. - *

- * Must run after {@code services.start()}: {@link me.totalfreedom.totalfreedommod.sql.FreedomDatabase#onStart()} - * is what actually calls {@code initialize()}, so {@code dm.isInitialized()} cannot be true any earlier. - */ - private void runYamlMigrations() - { - if (dm == null || !dm.isInitialized()) - { - FLog.info("Database not initialized, skipping YAML migrations"); - return; - } - - try - { - YamlMigrationService migrationService = new YamlMigrationService(this, dm); - migrationService.runMigrations().block(); - - // Reload each domain after migration so in-memory state picks up freshly-migrated SQL data. - if (al != null) - { - al.load(); - } - if (bm != null) - { - bm.reload(); - } - if (pm != null) - { - pm.reload(); - } - if (sl != null) - { - sl.reload(); - } - if (rm != null) - { - rm.loadRanks(); - } - if (pa != null) - { - pa.reload(); - } - } - catch (Exception ex) - { - FLog.warning("Error during YAML migrations: " + ex.getMessage()); - } - } - } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java b/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java index 05e5aebdd..0713a4095 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java @@ -23,6 +23,7 @@ import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.rank.Rank; +import me.totalfreedom.totalfreedommod.sql.PersistenceQueue; import me.totalfreedom.totalfreedommod.sql.adapter.AdminRepository; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FUtil; @@ -62,12 +63,8 @@ public class AdminList extends FreedomService // private final File configFile; - private final Object persistenceLock = new Object(); - - // Serialises every queued write so a stale snapshot can never land after a - // newer one. Guarded by persistenceLock; collapsed back to Mono.empty() once - // the tail completes so the operator chain cannot grow without bound. - private Mono persistenceChain = Mono.empty(); + // Serialises every queued write so a stale snapshot can never land after a newer one. + private final PersistenceQueue writes = new PersistenceQueue("admin"); // Flag to track if SQL is available private boolean usingSql = false; @@ -83,6 +80,7 @@ public AdminList(TotalFreedomMod plugin) protected void onStart() { load(); + plugin.dm.whenReady(this::load); server.getServicesManager().register(Function.class, new Function() { @@ -105,22 +103,21 @@ protected void onStop() save(); } + /** + * Populate the list from SQL where it is available and the JSON snapshot otherwise. Never + * blocks: the SQL read runs off-thread and its result is applied back on the main thread, + * so this is safe from a command handler as well as from startup. + */ public void load() { - // Try to load from SQL database first if (plugin.dm != null && plugin.dm.isInitialized()) { - loadFromSql(); - } - else - { - loadFromJson(); + loadFromSqlAsync(); + return; } - if (ConfigEntry.ADMINLIST_USE_UUID_ONLY.getBoolean()) - { - getMissingUuids(); - } + loadFromJson(); + backfillUuidsIfEnabled(); } /** @@ -145,32 +142,11 @@ public synchronized void save() } /** - * Wait for queued {@link #saveAdminAsync(Admin)} writes to land, up to - * {@code timeoutMs}. Without this a shutdown flush can race the queue and - * let an older queued snapshot overwrite the state we just wrote. + * Wait for queued {@link #saveAdminAsync(Admin)} writes to land, up to {@code timeoutMs}. */ public void awaitPendingWrites(long timeoutMs) { - final Mono pending; - synchronized (persistenceLock) - { - pending = persistenceChain; - } - - try - { - pending.block(Duration.ofMillis(timeoutMs)); - } - catch (IllegalStateException ex) - { - // Reactor answers a blocking-read timeout with IllegalStateException. - FLog.warning(String.format("Gave up after %dms waiting for pending admin writes (%s); flushing anyway", - timeoutMs, ex.getMessage())); - } - catch (RuntimeException ex) - { - FLog.warning(String.format("A queued admin write failed before shutdown: %s", ex.getMessage())); - } + writes.await(timeoutMs); } /** @@ -618,81 +594,106 @@ private void getMissingUuids() } /** - * Load admins from SQL database. + * Read every admin off-thread and apply the result on the main thread, dropping back to + * the JSON snapshot if the read fails. */ - private void loadFromSql() + private void loadFromSqlAsync() { - try - { - final AdminRepository repo = plugin.dm.getAdminRepository(); - final List admins = repo.findAll().block(); - - allAdmins.clear(); - if (admins != null) - { - admins.forEach(admin -> + final AdminRepository repo = plugin.dm.getAdminRepository(); + plugin.dm.readAsync("AdminList/loadFromSql", repo.findAll(), + admins -> applyLoadedAdmins(repo, admins), + () -> { - final String key = admin.getName().toLowerCase(); - allAdmins.put(key, fixConfigKey(admin, key)); + loadFromJson(); + backfillUuidsIfEnabled(); }); - } - - usingSql = true; - updateTables(); - FLog.info(String.format("Loaded %d admins from SQL database (%d active, %d IPs)", - allAdmins.size(), nameTable.size(), ipTable.size())); + } - reconcileFromJsonIfNewer(repo); - } - catch (Exception ex) + private void applyLoadedAdmins(final AdminRepository repo, final List admins) + { + allAdmins.clear(); + admins.forEach(admin -> { - FLog.warning(String.format("Failed to load admins from SQL, falling back to JSON: %s", ex.getMessage())); - loadFromJson(); - } + final String key = admin.getName().toLowerCase(); + allAdmins.put(key, fixConfigKey(admin, key)); + }); + + usingSql = true; + updateTables(); + FLog.info(String.format("Loaded %d admins from SQL database (%d active, %d IPs)", + allAdmins.size(), nameTable.size(), ipTable.size())); + + reconcileFromJsonIfNewer(repo); + backfillUuidsIfEnabled(); + } + + private void backfillUuidsIfEnabled() + { + if (ConfigEntry.ADMINLIST_USE_UUID_ONLY.getBoolean()) + getMissingUuids(); } /** * If admins.json was written more recently than the database's last update (e.g. edited * by hand, or restored from backup while SQL was unavailable), re-import it into SQL. + * The comparison and the re-import both ride the write queue off the main thread. */ - private void reconcileFromJsonIfNewer(AdminRepository repo) + private void reconcileFromJsonIfNewer(final AdminRepository repo) { if (!configFile.exists()) { + // Nothing to reconcile against, but SQL now has rows that no snapshot covers. + enqueue(writeJsonAsync(serialiseAdmins())); return; } + final Map jsonAdmins; try { - final Long sqlUpdatedAt = repo.getMaxUpdatedAt(); - if (sqlUpdatedAt != null && configFile.lastModified() <= sqlUpdatedAt) - { - return; - } + jsonAdmins = readJsonAdmins(); + } + catch (IOException ex) + { + FLog.warning(String.format("Failed to read %s: %s", CONFIG_FILENAME, ex.getMessage())); + return; + } - final Map jsonAdmins = readJsonAdmins(); - if (jsonAdmins.isEmpty()) - { - return; - } + if (jsonAdmins.isEmpty()) + return; - FLog.info(String.format("admins.json is newer than the database; re-importing %d admin(s) from it.", - jsonAdmins.size())); + final long fileModified = configFile.lastModified(); - Flux.fromIterable(jsonAdmins.values()) - .filter(Admin::isValid) - .concatMap(admin -> repo.save(resolveUuidFor(admin), admin)) - .blockLast(); + enqueue(Mono.fromCallable(() -> + { + final Long sqlUpdatedAt = repo.getMaxUpdatedAt(); + return sqlUpdatedAt == null || fileModified > sqlUpdatedAt; + }) + .subscribeOn(Schedulers.boundedElastic()) + .filter(Boolean::booleanValue) + .flatMapMany(ignored -> + { + FLog.info(String.format("%s is newer than the database; re-importing %d admin(s) from it.", + CONFIG_FILENAME, jsonAdmins.size())); + return Flux.fromIterable(jsonAdmins.values()) + .filter(Admin::isValid) + .concatMap(admin -> repo.save(resolveUuidFor(admin), admin)); + }) + .then(Mono.fromRunnable(() -> plugin.dm.sync("AdminList/applyReconciled", + () -> applyReconciledAdmins(jsonAdmins)))) + .onErrorResume(ex -> + { + FLog.warning(String.format("Failed to reconcile %s into the database: %s", + CONFIG_FILENAME, ex.getMessage())); + return Mono.empty(); + }) + .then()); + } - allAdmins.clear(); - allAdmins.putAll(jsonAdmins); - updateTables(); - } - catch (Exception ex) - { - FLog.warning(String.format("Failed to reconcile %s into the database: %s", - CONFIG_FILENAME, ex.getMessage())); - } + private void applyReconciledAdmins(final Map jsonAdmins) + { + allAdmins.clear(); + allAdmins.putAll(jsonAdmins); + updateTables(); } private Map readJsonAdmins() throws IOException @@ -767,39 +768,9 @@ private void loadFromJson() allAdmins.size(), nameTable.size(), ipTable.size())); } - /** - * Append {@code work} to the persistence chain and subscribe. Every queued - * write runs after the one before it, so a batch can never be reordered - * behind a single-row update that was requested later. - */ private void enqueue(Mono work) { - synchronized (persistenceLock) - { - final Mono queued = persistenceChain - .onErrorResume(ignored -> Mono.empty()) - .then(work) - .cache(); - - persistenceChain = queued; - queued.doFinally(signal -> collapseChain(queued)).subscribe(); - } - } - - /** - * Drop the retained operator chain once its tail has completed. The chain is - * strictly sequential, so a completed tail means every write before it is - * done and nothing needs to wait on them any more. - */ - private void collapseChain(Mono completed) - { - synchronized (persistenceLock) - { - if (persistenceChain == completed) - { - persistenceChain = Mono.empty(); - } - } + writes.enqueue(work); } /** diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java index 0cf17aabc..771930b4c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java @@ -16,6 +16,7 @@ import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.player.PlayerData; +import me.totalfreedom.totalfreedommod.sql.PersistenceQueue; import me.totalfreedom.totalfreedommod.sql.adapter.BanRepository; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FUtil; @@ -45,9 +46,8 @@ public class BanManager extends FreedomService private final List unbannableUsernames = Lists.newArrayList(); private final File configFile; private final Object lock = new Object(); - private final Object persistenceLock = new Object(); + private final PersistenceQueue writes = new PersistenceQueue("ban"); - private Mono persistenceChain = Mono.empty(); private boolean usingSql = false; public BanManager(TotalFreedomMod plugin) @@ -60,81 +60,113 @@ public BanManager(TotalFreedomMod plugin) @SuppressWarnings("unchecked") protected void onStart() { - if (plugin.dm != null && plugin.dm.isInitialized()) - loadFromSql(); - else - loadFromJson(); + load(); + plugin.dm.whenReady(this::load); unbannableUsernames.clear(); unbannableUsernames.addAll((Collection) ConfigEntry.FAMOUS_PLAYERS.getList()); - FLog.info("Loaded " + unbannableUsernames.size() + " unbannable usernames."); + FLog.info(String.format("Loaded %d unbannable usernames.", unbannableUsernames.size())); } /** - * Load bans from SQL database. + * Populate the ban list from SQL where it is available and the JSON snapshot otherwise. + * Never blocks: the SQL read runs off-thread and is applied back on the main thread. */ - private void loadFromSql() + public void load() { - try + if (plugin.dm != null && plugin.dm.isInitialized()) { - BanRepository repo = plugin.dm.getBanRepository(); - List loadedBans = repo.findAll().block(); + loadFromSqlAsync(); + return; + } - synchronized (lock) - { - bans.clear(); - bans.addAll(loadedBans); - usingSql = true; - updateViews(); - FLog.info("Loaded " + ipBans.size() + " IP bans and " + nameBans.size() + " username bans from SQL database."); - } + loadFromJson(); + } - reconcileFromJsonIfNewer(repo); - } - catch (Exception ex) + private void loadFromSqlAsync() + { + final BanRepository repo = plugin.dm.getBanRepository(); + plugin.dm.readAsync("BanManager/loadFromSql", repo.findAll(), + loaded -> applyLoadedBans(repo, loaded), + this::loadFromJson); + } + + private void applyLoadedBans(final BanRepository repo, final List loaded) + { + synchronized (lock) { - FLog.warning("Failed to load bans from SQL, falling back to JSON: " + ex.getMessage()); - loadFromJson(); + bans.clear(); + bans.addAll(loaded); + usingSql = true; + updateViews(); + FLog.info(String.format("Loaded %d IP bans and %d username bans from SQL database.", + ipBans.size(), nameBans.size())); } + + reconcileFromJsonIfNewer(repo); } /** - * If bans.json was written more recently than the database's last update, re-import it into SQL. + * If bans.json was written more recently than the database's last update, re-import it into + * SQL. The comparison and the re-import both ride the write queue off the main thread. */ - private void reconcileFromJsonIfNewer(BanRepository repo) + private void reconcileFromJsonIfNewer(final BanRepository repo) { if (!configFile.exists()) + { + // Nothing to reconcile against, but SQL now has rows that no snapshot covers. + enqueue(writeJsonAsync(new ArrayList<>(bans))); return; + } + final List jsonBans; try { - Long sqlUpdatedAt = repo.getMaxUpdatedAt(); - if (sqlUpdatedAt != null && configFile.lastModified() <= sqlUpdatedAt) - return; + jsonBans = readJsonBans(); + } + catch (IOException ex) + { + FLog.warning(String.format("Failed to read bans.json: %s", ex.getMessage())); + return; + } - List jsonBans = readJsonBans(); - if (jsonBans.isEmpty()) - return; + if (jsonBans.isEmpty()) + return; - FLog.info("bans.json is newer than the database; re-importing " + jsonBans.size() + " ban(s) from it."); - for (Ban ban : jsonBans) - { - if (!ban.isValid()) - continue; + final long fileModified = configFile.lastModified(); - repo.save(ban).block(); - } + enqueue(Mono.fromCallable(() -> + { + final Long sqlUpdatedAt = repo.getMaxUpdatedAt(); + return sqlUpdatedAt == null || fileModified > sqlUpdatedAt; + }) + .subscribeOn(Schedulers.boundedElastic()) + .filter(Boolean::booleanValue) + .flatMapMany(ignored -> + { + FLog.info(String.format("bans.json is newer than the database; re-importing %d ban(s) from it.", + jsonBans.size())); + return Flux.fromIterable(jsonBans) + .filter(Ban::isValid) + .concatMap(repo::save); + }) + .then(Mono.fromRunnable(() -> plugin.dm.sync("BanManager/applyReconciled", + () -> applyReconciledBans(jsonBans)))) + .onErrorResume(ex -> + { + FLog.warning(String.format("Failed to reconcile bans.json into the database: %s", ex.getMessage())); + return Mono.empty(); + }) + .then()); + } - synchronized (lock) - { - bans.clear(); - bans.addAll(jsonBans); - updateViews(); - } - } - catch (Exception ex) + private void applyReconciledBans(final List jsonBans) + { + synchronized (lock) { - FLog.warning("Failed to reconcile bans.json into the database: " + ex.getMessage()); + bans.clear(); + bans.addAll(jsonBans); + updateViews(); } } @@ -199,63 +231,14 @@ protected void onStop() FLog.info("Saved " + bans.size() + " player bans"); } - public void reload() - { - onStop(); - onStart(); - } - public void awaitPendingWrites(long timeoutMs) { - final Mono pending; - synchronized (persistenceLock) - { - pending = persistenceChain; - } - - try - { - pending.block(Duration.ofMillis(timeoutMs)); - } - catch (IllegalStateException ex) - { - FLog.warning(String.format("Gave up after %dms waiting for pending ban writes (%s); flushing anyway", - timeoutMs, ex.getMessage())); - } - catch (RuntimeException ex) - { - FLog.warning("A queued ban write failed before shutdown: " + ex.getMessage()); - } + writes.await(timeoutMs); } - /** - * Append {@code work} to the persistence chain and subscribe. Every queued write runs - * after the one before it, so a batch can never be reordered behind a single-row update - * that was requested later. - */ private void enqueue(Mono work) { - synchronized (persistenceLock) - { - final Mono queued = persistenceChain - .onErrorResume(ignored -> Mono.empty()) - .then(work) - .cache(); - - persistenceChain = queued; - queued.doFinally(signal -> collapseChain(queued)).subscribe(); - } - } - - private void collapseChain(Mono completed) - { - synchronized (persistenceLock) - { - if (persistenceChain == completed) - { - persistenceChain = Mono.empty(); - } - } + writes.enqueue(work); } public Set getAllBans() diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java index e04f44fa4..df014da61 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java @@ -3,8 +3,8 @@ import com.google.common.collect.Maps; import com.google.common.collect.Sets; import com.google.gson.reflect.TypeToken; -import java.time.Duration; import java.util.ArrayList; +import java.util.Collection; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -12,6 +12,7 @@ import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.sql.PersistenceQueue; import me.totalfreedom.totalfreedommod.sql.adapter.PermbanRepository; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FUtil; @@ -26,6 +27,7 @@ import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.player.AsyncPlayerPreLoginEvent; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; @@ -41,9 +43,8 @@ public class PermbanList extends FreedomService private final Map permbansByName = Maps.newHashMap(); private final File configFile; private final Object lock = new Object(); - private final Object persistenceLock = new Object(); + private final PersistenceQueue writes = new PersistenceQueue("permban"); - private Mono persistenceChain = Mono.empty(); private boolean usingSql = false; public PermbanList(TotalFreedomMod plugin) @@ -55,91 +56,126 @@ public PermbanList(TotalFreedomMod plugin) @Override protected void onStart() { - if (plugin.dm != null && plugin.dm.isInitialized()) - loadFromSql(); - else - loadFromJson(); + load(); + plugin.dm.whenReady(this::load); } /** - * Load permbans from SQL database. + * Populate the permban list from SQL where it is available and the JSON snapshot otherwise. + * Never blocks: the SQL read runs off-thread and is applied back on the main thread. */ - private void loadFromSql() + public void load() { - try + if (plugin.dm != null && plugin.dm.isInitialized()) { - PermbanRepository repo = plugin.dm.getPermbanRepository(); - List loadedPermbans = repo.findAll().block(); - - synchronized (lock) - { - permbannedNames.clear(); - permbannedIps.clear(); - permbansByName.clear(); + loadFromSqlAsync(); + return; + } - for (PermBan permban : loadedPermbans) - { - String name = permban.getUsername().toLowerCase().trim(); - permbannedNames.add(name); - permbannedIps.addAll(permban.getIps()); - permbansByName.put(name, permban); - } + loadFromJson(); + } - usingSql = true; - FLog.info("Loaded " + permbannedIps.size() + " perm IP bans and " + permbannedNames.size() + " perm username bans from SQL database."); - } + private void loadFromSqlAsync() + { + final PermbanRepository repo = plugin.dm.getPermbanRepository(); + plugin.dm.readAsync("PermbanList/loadFromSql", repo.findAll(), + loaded -> applyLoadedPermbans(repo, loaded), + this::loadFromJson); + } - reconcileFromJsonIfNewer(repo); - } - catch (Exception ex) + private void applyLoadedPermbans(final PermbanRepository repo, final List loaded) + { + synchronized (lock) { - FLog.warning("Failed to load permbans from SQL, falling back to JSON: " + ex.getMessage()); - loadFromJson(); + replaceViews(loaded); + usingSql = true; + FLog.info(String.format("Loaded %d perm IP bans and %d perm username bans from SQL database.", + permbannedIps.size(), permbannedNames.size())); } + + reconcileFromJsonIfNewer(repo); } /** - * If permbans.json was written more recently than the database's last update, re-import it into SQL. + * If permbans.json was written more recently than the database's last update, re-import it + * into SQL. The comparison and the re-import both ride the write queue off the main thread. */ - private void reconcileFromJsonIfNewer(PermbanRepository repo) + private void reconcileFromJsonIfNewer(final PermbanRepository repo) { if (!configFile.exists()) + { + // Nothing to reconcile against, but SQL now has rows that no snapshot covers. + enqueue(writeJsonAsync()); return; + } + final Map jsonPermbans; try { - Long sqlUpdatedAt = repo.getMaxUpdatedAt(); - if (sqlUpdatedAt != null && configFile.lastModified() <= sqlUpdatedAt) - return; + jsonPermbans = readJsonPermbans(); + } + catch (IOException ex) + { + FLog.warning(String.format("Failed to read %s: %s", CONFIG_FILENAME, ex.getMessage())); + return; + } - Map jsonPermbans = readJsonPermbans(); - if (jsonPermbans.isEmpty()) - return; + if (jsonPermbans.isEmpty()) + return; - FLog.info("permbans.json is newer than the database; re-importing " + jsonPermbans.size() + " permban(s) from it."); - for (PermBan permban : jsonPermbans.values()) - repo.save(permban).block(); + final long fileModified = configFile.lastModified(); - synchronized (lock) - { - permbannedNames.clear(); - permbannedIps.clear(); - permbansByName.clear(); - for (PermBan permban : jsonPermbans.values()) + enqueue(Mono.fromCallable(() -> { - String name = permban.getUsername().toLowerCase().trim(); - permbannedNames.add(name); - permbannedIps.addAll(permban.getIps()); - permbansByName.put(name, permban); - } - } - } - catch (Exception ex) + final Long sqlUpdatedAt = repo.getMaxUpdatedAt(); + return sqlUpdatedAt == null || fileModified > sqlUpdatedAt; + }) + .subscribeOn(Schedulers.boundedElastic()) + .filter(Boolean::booleanValue) + .flatMapMany(ignored -> + { + FLog.info(String.format("%s is newer than the database; re-importing %d permban(s) from it.", + CONFIG_FILENAME, jsonPermbans.size())); + return Flux.fromIterable(jsonPermbans.values()) + .concatMap(repo::save); + }) + .then(Mono.fromRunnable(() -> plugin.dm.sync("PermbanList/applyReconciled", + () -> applyReconciledPermbans(jsonPermbans.values())))) + .onErrorResume(ex -> + { + FLog.warning(String.format("Failed to reconcile %s into the database: %s", + CONFIG_FILENAME, ex.getMessage())); + return Mono.empty(); + }) + .then()); + } + + private void applyReconciledPermbans(final Collection jsonPermbans) + { + synchronized (lock) { - FLog.warning("Failed to reconcile permbans.json into the database: " + ex.getMessage()); + replaceViews(jsonPermbans); } } + /** + * Rebuild the name/IP lookup views from {@code source}. Callers hold {@link #lock}. + */ + private void replaceViews(final Collection source) + { + permbannedNames.clear(); + permbannedIps.clear(); + permbansByName.clear(); + + source.forEach(permban -> + { + final String name = permban.getUsername().toLowerCase().trim(); + permbannedNames.add(name); + permbannedIps.addAll(permban.getIps()); + permbansByName.put(name, permban); + }); + } + private Map readJsonPermbans() throws IOException { try (FileReader reader = new FileReader(configFile)) @@ -224,60 +260,16 @@ protected void onStop() } /** - * Wait for queued async writes to land, up to {@code timeoutMs}. Without this a shutdown - * flush can race the queue and let an older queued snapshot overwrite the state we just wrote. + * Wait for queued async writes to land, up to {@code timeoutMs}. */ public void awaitPendingWrites(long timeoutMs) { - final Mono pending; - synchronized (persistenceLock) - { - pending = persistenceChain; - } - - try - { - pending.block(Duration.ofMillis(timeoutMs)); - } - catch (IllegalStateException ex) - { - FLog.warning(String.format("Gave up after %dms waiting for pending permban writes (%s); flushing anyway", - timeoutMs, ex.getMessage())); - } - catch (RuntimeException ex) - { - FLog.warning("A queued permban write failed before shutdown: " + ex.getMessage()); - } + writes.await(timeoutMs); } - /** - * Append {@code work} to the persistence chain and subscribe. Every queued write runs - * after the one before it, so a batch can never be reordered behind a single-row update - * that was requested later. - */ private void enqueue(Mono work) { - synchronized (persistenceLock) - { - final Mono queued = persistenceChain - .onErrorResume(ignored -> Mono.empty()) - .then(work) - .cache(); - - persistenceChain = queued; - queued.doFinally(signal -> collapseChain(queued)).subscribe(); - } - } - - private void collapseChain(Mono completed) - { - synchronized (persistenceLock) - { - if (persistenceChain == completed) - { - persistenceChain = Mono.empty(); - } - } + writes.enqueue(work); } private Mono writeJsonAsync() @@ -315,12 +307,46 @@ private void saveAllToSql() } } + /** + * Flush pending changes and re-read the list. Safe from a command handler: the flush is + * queued rather than awaited, and {@link #load()} applies its result asynchronously. + */ public void reload() { - onStop(); - onStart(); + saveAsync(); + load(); } - + + /** + * Queue a write of every permban record, followed by a refresh of the JSON snapshot. + */ + public void saveAsync() + { + if (!usingSql || plugin.dm == null || !plugin.dm.isInitialized()) + { + enqueue(writeJsonAsync()); + return; + } + + final List snapshot; + synchronized (lock) + { + snapshot = new ArrayList<>(permbansByName.values()); + } + + final PermbanRepository repo = plugin.dm.getPermbanRepository(); + enqueue(Flux.fromIterable(snapshot) + .concatMap(permban -> repo.save(permban) + .onErrorResume(ex -> + { + FLog.warning(String.format("Failed to save permban %s to SQL: %s", + permban.getUsername(), ex.getMessage())); + return Mono.empty(); + })) + .then(writeJsonAsync())); + } + + /** * Add a permban. */ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java index 12a6a4a21..86a6f1de0 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java @@ -7,7 +7,6 @@ import java.io.FileWriter; import java.io.IOException; import java.lang.reflect.Type; -import java.time.Duration; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -17,6 +16,7 @@ import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.sql.PersistenceQueue; import me.totalfreedom.totalfreedommod.sql.adapter.StrikeRepository; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.JsonUtil; @@ -31,11 +31,10 @@ public class StrikeList extends FreedomService private final Map strikes = Maps.newHashMap(); private final File configFile; - private final Object persistenceLock = new Object(); + private final PersistenceQueue writes = new PersistenceQueue("strike"); private boolean usingSql = false; private boolean persistEnabled = true; - private Mono persistenceChain = Mono.empty(); public StrikeList(TotalFreedomMod plugin) { @@ -58,17 +57,27 @@ protected void onStart() return; } + load(); + plugin.dm.whenReady(this::load); + } + + /** + * Populate the strike map from SQL where it is available and the JSON snapshot otherwise. + * Never blocks: the SQL read runs off-thread and is applied back on the main thread. + */ + public void load() + { + if (!persistEnabled) + return; + if (plugin.dm != null && plugin.dm.isInitialized()) { - loadFromSql(); - } - else - { - loadFromJson(); + loadFromSqlAsync(); + return; } - pruneDecayed(); - FLog.info("Loaded " + strikes.size() + " strike records."); + loadFromJson(); + finishLoad(); } @Override @@ -85,73 +94,16 @@ protected void onStop() } /** - * Re-run the startup load. Used after a one-time YAML-to-SQL migration so this manager's - * in-memory state picks up the freshly-migrated rows. - */ - public void reload() - { - onStop(); - onStart(); - } - - /** - * Wait for queued async writes to land, up to {@code timeoutMs}. Without this a shutdown - * flush can race the queue and let an older queued snapshot overwrite the state we just wrote. + * Wait for queued async writes to land, up to {@code timeoutMs}. */ public void awaitPendingWrites(long timeoutMs) { - final Mono pending; - synchronized (persistenceLock) - { - pending = persistenceChain; - } - - try - { - pending.block(Duration.ofMillis(timeoutMs)); - } - catch (IllegalStateException ex) - { - FLog.warning(String.format("Gave up after %dms waiting for pending strike writes (%s); flushing anyway", - timeoutMs, ex.getMessage())); - } - catch (RuntimeException ex) - { - FLog.warning("A queued strike write failed before shutdown: " + ex.getMessage()); - } + writes.await(timeoutMs); } - /** - * Append {@code work} to the persistence chain and subscribe. Every queued write runs - * after the one before it, so a decay-prune can never race a concurrent re-strike on the - * same IP into landing out of order. - */ private void enqueue(Mono work) { - synchronized (persistenceLock) - { - final Mono queued = persistenceChain - .onErrorResume(ignored -> Mono.empty()) - .then(work) - .cache(); - - persistenceChain = queued; - queued.doFinally(signal -> collapseChain(queued)).subscribe(); - } - } - - /** - * Drop the retained operator chain once its tail has completed. The chain is strictly - * sequential, so a completed tail means every write before it is done and nothing needs - * to wait on them any more. - */ - private void collapseChain(Mono completed) - { - synchronized (persistenceLock) - { - if (persistenceChain == completed) - persistenceChain = Mono.empty(); - } + writes.enqueue(work); } private Mono writeJsonAsync() @@ -160,53 +112,89 @@ private Mono writeJsonAsync() .subscribeOn(Schedulers.boundedElastic()); } - private void loadFromSql() + private void loadFromSqlAsync() { - try - { - StrikeRepository repo = plugin.dm.getStrikeRepository(); - Map loaded = repo.loadAllAsync().block(); - strikes.putAll(loaded); - usingSql = true; + final StrikeRepository repo = plugin.dm.getStrikeRepository(); + plugin.dm.readAsync("StrikeList/loadFromSql", repo.loadAllAsync(), + loaded -> applyLoadedStrikes(repo, loaded), + () -> + { + loadFromJson(); + finishLoad(); + }); + } - reconcileFromJsonIfNewer(repo); - } - catch (Exception ex) - { - FLog.warning("Failed to load strikes from SQL, falling back to JSON: " + ex.getMessage()); - loadFromJson(); - } + private void applyLoadedStrikes(final StrikeRepository repo, final Map loaded) + { + strikes.clear(); + strikes.putAll(loaded); + usingSql = true; + + reconcileFromJsonIfNewer(repo); + finishLoad(); + } + + private void finishLoad() + { + pruneDecayed(); + FLog.info(String.format("Loaded %d strike records.", strikes.size())); } /** - * If strikes.json was written more recently than the database's last update, re-import it into SQL. + * If strikes.json was written more recently than the database's last update, re-import it + * into SQL. The comparison and the re-import both ride the write queue off the main thread. */ - private void reconcileFromJsonIfNewer(StrikeRepository repo) + private void reconcileFromJsonIfNewer(final StrikeRepository repo) { if (!configFile.exists()) + { + // Nothing to reconcile against, but SQL now has rows that no snapshot covers. + enqueue(writeJsonAsync()); return; + } + final Map jsonStrikes; try { - Long sqlUpdatedAt = repo.getMaxUpdatedAt(); - if (sqlUpdatedAt != null && configFile.lastModified() <= sqlUpdatedAt) - return; - - Map jsonStrikes = readJsonStrikes(); - if (jsonStrikes.isEmpty()) - return; - - FLog.info("strikes.json is newer than the database; re-importing " + jsonStrikes.size() + " strike record(s) from it."); - for (StrikeRecord r : jsonStrikes.values()) - repo.upsertAsync(r).block(); - - strikes.clear(); - strikes.putAll(jsonStrikes); + jsonStrikes = readJsonStrikes(); } - catch (Exception ex) + catch (IOException ex) { - FLog.warning("Failed to reconcile strikes.json into the database: " + ex.getMessage()); + FLog.warning(String.format("Failed to read strikes.json: %s", ex.getMessage())); + return; } + + if (jsonStrikes.isEmpty()) + return; + + final long fileModified = configFile.lastModified(); + + enqueue(Mono.fromCallable(() -> + { + final Long sqlUpdatedAt = repo.getMaxUpdatedAt(); + return sqlUpdatedAt == null || fileModified > sqlUpdatedAt; + }) + .subscribeOn(Schedulers.boundedElastic()) + .filter(Boolean::booleanValue) + .flatMapMany(ignored -> + { + FLog.info(String.format("strikes.json is newer than the database; re-importing %d " + + "strike record(s) from it.", jsonStrikes.size())); + return Flux.fromIterable(jsonStrikes.values()) + .concatMap(repo::upsertAsync); + }) + .then(Mono.fromRunnable(() -> plugin.dm.sync("StrikeList/applyReconciled", () -> + { + strikes.clear(); + strikes.putAll(jsonStrikes); + }))) + .onErrorResume(ex -> + { + FLog.warning(String.format("Failed to reconcile strikes.json into the database: %s", + ex.getMessage())); + return Mono.empty(); + }) + .then()); } private Map readJsonStrikes() throws IOException diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rankconfig.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rankconfig.java index e685a8af6..5c7a6caa8 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rankconfig.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rankconfig.java @@ -196,7 +196,7 @@ public void setRank(CommandSender sender, Player target, String rank) } admin.setCustomRankId(null); - plugin().al.save(); + plugin().al.saveAsync(); msg(sender, "Cleared custom rank for ", Placeholder.unparsed("player", target.getName())); return; } @@ -217,7 +217,7 @@ public void setRank(CommandSender sender, Player target, String rank) } admin.setCustomRankId(rankId); - plugin().al.save(); + plugin().al.saveAsync(); adminAction( sender, diff --git a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordBridge.java b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordBridge.java index 6f0f46c8e..fd90df780 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordBridge.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordBridge.java @@ -69,10 +69,8 @@ protected void onStart() return; } - if (plugin.dm != null && plugin.dm.isInitialized()) - { - DiscordLinkJsonSync.reconcileFromJsonIfNewer(plugin, plugin.dm.getDiscordLinkRepository()); - } + plugin.dm.whenReady(() -> + DiscordLinkJsonSync.reconcileFromJsonIfNewer(plugin, plugin.dm.getDiscordLinkRepository())); String token = ConfigEntry.DISCORD_TOKEN.getString(); if (token == null || token.isBlank()) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordLinkJsonSync.java b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordLinkJsonSync.java index 4cb843301..40d904466 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordLinkJsonSync.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordLinkJsonSync.java @@ -8,10 +8,14 @@ import java.util.Map; import java.util.UUID; import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.sql.PersistenceQueue; import me.totalfreedom.totalfreedommod.sql.adapter.DiscordLinkRepository; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.JsonUtil; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + /** * JSON write-through + startup reconciliation for admin-uuid to Discord-user-id links. * There is no in-memory manager for this domain (DiscordCommands talks to the repository @@ -22,6 +26,7 @@ final class DiscordLinkJsonSync static final String DATA_FILENAME = "discord_links.json"; private static final Type LINKS_MAP_TYPE = new TypeToken>() {}.getType(); + private static final PersistenceQueue WRITES = new PersistenceQueue("discord link"); private DiscordLinkJsonSync() { @@ -49,48 +54,50 @@ static void writeSnapshot(TotalFreedomMod plugin, DiscordLinkRepository repo) } /** - * If discord_links.json was written more recently than the database's last update, re-import it into SQL. + * If discord_links.json was written more recently than the database's last update, re-import + * it into SQL. Runs entirely off the main thread, so this is safe to call from {@code onStart}. */ static void reconcileFromJsonIfNewer(TotalFreedomMod plugin, DiscordLinkRepository repo) { - File file = new File(plugin.getDataFolder(), DATA_FILENAME); + final File file = new File(plugin.getDataFolder(), DATA_FILENAME); if (!file.exists()) - { return; - } - try + final Map jsonLinks; + try (FileReader reader = new FileReader(file)) { - Long sqlUpdatedAt = repo.getMaxUpdatedAt(); - if (sqlUpdatedAt != null && file.lastModified() <= sqlUpdatedAt) - { - return; - } - - Map jsonLinks; - try (FileReader reader = new FileReader(file)) - { - Map loaded = JsonUtil.GSON.fromJson(reader, LINKS_MAP_TYPE); - jsonLinks = loaded != null ? loaded : Map.of(); - } - - if (jsonLinks.isEmpty()) - { - return; - } - - FLog.info(DATA_FILENAME + " is newer than the database; re-importing " + jsonLinks.size() + " discord link(s) from it."); - for (Map.Entry entry : jsonLinks.entrySet()) - { - UUID adminUuid = UUID.fromString(entry.getKey()); - repo.deleteByAdminUuid(adminUuid); - repo.deleteByDiscordUserId(entry.getValue()); - repo.insert(adminUuid, entry.getValue()); - } + final Map loaded = JsonUtil.GSON.fromJson(reader, LINKS_MAP_TYPE); + jsonLinks = loaded != null ? loaded : Map.of(); } catch (Exception ex) { - FLog.warning("Failed to reconcile " + DATA_FILENAME + " into the database: " + ex.getMessage()); + FLog.warning(String.format("Failed to read %s: %s", DATA_FILENAME, ex.getMessage())); + return; } + + if (jsonLinks.isEmpty()) + return; + + final long fileModified = file.lastModified(); + + WRITES.enqueue(repo.getMaxUpdatedAtAsync() + .map(sqlUpdatedAt -> fileModified > sqlUpdatedAt) + .defaultIfEmpty(Boolean.TRUE) + .filter(Boolean::booleanValue) + .flatMap(ignored -> + { + FLog.info(String.format("%s is newer than the database; re-importing %d discord link(s) from it.", + DATA_FILENAME, jsonLinks.size())); + return Flux.fromIterable(jsonLinks.entrySet()) + .concatMap(entry -> repo.relinkAsync(UUID.fromString(entry.getKey()), entry.getValue())) + .then(); + }) + .onErrorResume(ex -> + { + FLog.warning(String.format("Failed to reconcile %s into the database: %s", + DATA_FILENAME, ex.getMessage())); + return Mono.empty(); + }) + .then()); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerList.java b/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerList.java index 686f8b739..4389f4ef0 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerList.java @@ -6,9 +6,11 @@ import java.io.FileWriter; import java.util.Arrays; import java.util.Collection; +import java.util.List; import java.util.Map; import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.sql.PersistenceQueue; import me.totalfreedom.totalfreedommod.sql.adapter.PlayerRepository; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FUtil; @@ -22,17 +24,23 @@ import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; +import org.bukkit.event.player.AsyncPlayerPreLoginEvent; import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerQuitEvent; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; public class PlayerList extends FreedomService { public static final long AUTO_PURGE_TICKS = 20L * 60L * 5L; + + private static final long SHUTDOWN_FLUSH_TIMEOUT_MS = 10L * 1000L; // public final Map playerMap = Maps.newHashMap(); // key: lowercase username public final Map dataMap = Maps.newHashMap(); // key: lowercase username private final File configFolder; + private final PersistenceQueue writes = new PersistenceQueue("player data"); public Map getPlayerMap() { @@ -78,48 +86,48 @@ protected void onStart() protected void onStop() { save(); + writes.await(SHUTDOWN_FLUSH_TIMEOUT_MS); } + /** + * Queue a write of every loaded player record. + */ public void save() { - for (PlayerData data : dataMap.values()) - { - saveOne(data); - } + List.copyOf(dataMap.values()).forEach(this::saveOne); } public void saveAsync() { - if (!plugin.isEnabled()) - { - save(); - return; - } - final java.util.List snapshot = new java.util.ArrayList<>(dataMap.values()); - plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> - { - for (PlayerData data : snapshot) - { - saveOne(data); - } - }); + save(); } - private void saveOne(PlayerData data) + /** + * Queue one player's SQL write plus its JSON snapshot refresh. Safe from an event handler: + * the round trip runs off the main thread. + */ + private void saveOne(final PlayerData data) { - if (usingSql()) + if (!usingSql()) { - try - { - plugin.dm.getPlayerRepository().save(data).block(); - } - catch (Exception ex) - { - FLog.severe("Could not save player data for " + data.getUsername() + " to SQL: " + ex.getMessage()); - } + writes.enqueue(writeJsonAsync(data)); + return; } - saveToJson(data); + writes.enqueue(plugin.dm.getPlayerRepository().save(data) + .onErrorResume(ex -> + { + FLog.severe(String.format("Could not save player data for %s to SQL: %s", + data.getUsername(), ex.getMessage())); + return Mono.empty(); + }) + .then(writeJsonAsync(data))); + } + + private Mono writeJsonAsync(final PlayerData data) + { + return Mono.fromRunnable(() -> saveToJson(data)) + .subscribeOn(Schedulers.boundedElastic()); } private void saveToJson(PlayerData data) @@ -434,10 +442,26 @@ public void onPlayerJoin(PlayerJoinEvent event) } if (plugin.isEnabled()) { - plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> saveOne(data)); + saveOne(data); } } + /** + * Warm {@link #dataMap} before the player reaches the main thread. This event already runs + * off it, so the lookup that {@code onPlayerJoin} would otherwise make against the database + * happens here instead and finds a cached entry on join. + */ + @EventHandler(priority = EventPriority.MONITOR) + public void onPreLogin(AsyncPlayerPreLoginEvent event) + { + if (event.getLoginResult() != AsyncPlayerPreLoginEvent.Result.ALLOWED) + return; + + final PlayerData data = getData(event.getName()); + if (data != null) + dataMap.put(data.getUsername().toLowerCase(), data); + } + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onPlayerQuit(PlayerQuitEvent event) { @@ -447,7 +471,7 @@ public void onPlayerQuit(PlayerQuitEvent event) if (data != null && plugin.isEnabled()) { - plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> saveOne(data)); + saveOne(data); } } @@ -471,14 +495,12 @@ public int purgeAllData() if (usingSql()) { - try - { - plugin.dm.getPlayerRepository().deleteAll().block(); - } - catch (Exception ex) - { - FLog.severe("Could not purge player data from SQL: " + ex.getMessage()); - } + writes.enqueue(plugin.dm.getPlayerRepository().deleteAll() + .onErrorResume(ex -> + { + FLog.severe(String.format("Could not purge player data from SQL: %s", ex.getMessage())); + return Mono.empty(); + })); } dataMap.clear(); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java index 3c1e62ab0..01f62df84 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java @@ -10,6 +10,7 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -24,6 +25,7 @@ import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchContext; import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchSession; import me.totalfreedom.totalfreedommod.player.FPlayer; +import me.totalfreedom.totalfreedommod.sql.PersistenceQueue; import me.totalfreedom.totalfreedommod.sql.adapter.RankRepository; import me.totalfreedom.totalfreedommod.util.AdventureUtil; import me.totalfreedom.totalfreedommod.util.FLog; @@ -55,12 +57,16 @@ import org.bukkit.scoreboard.Scoreboard; import org.bukkit.scoreboard.ScoreboardManager; import org.bukkit.scoreboard.Team; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; public class RankManager extends FreedomService { public static final String RANKS_FILENAME = "ranks.json"; private static final Type RANK_MAP_TYPE = new TypeToken>() {}.getType(); + private static final long SHUTDOWN_FLUSH_TIMEOUT_MS = 10L * 1000L; /** * All custom ranks, keyed by ID. @@ -72,6 +78,8 @@ public class RankManager extends FreedomService */ private File ranksFile; + private final PersistenceQueue writes = new PersistenceQueue("rank"); + private boolean usingSql = false; /** @@ -113,8 +121,10 @@ protected void onStart() @Override protected void onStop() { - // Save ranks before shutdown + // Save ranks before shutdown, then let the queue drain: a queued write landing after + // the flush would restore a stale snapshot. saveRanks(); + awaitPendingWrites(SHUTDOWN_FLUSH_TIMEOUT_MS); // Stop persistent monitor if (persistentMonitorTask != null) @@ -128,7 +138,9 @@ protected void onStop() } /** - * Load custom ranks from SQL (falling back to ranks.json). + * Load custom ranks from SQL, falling back to ranks.json. Never blocks: the SQL read runs + * off-thread and its result is applied back on the main thread, so this is safe from a + * command handler as well as from startup. */ public void loadRanks() { @@ -136,44 +148,44 @@ public void loadRanks() if (plugin.dm != null && plugin.dm.isInitialized()) { - loadFromSql(); - } - else - { - loadFromJsonOrDefaults(); + loadFromSqlAsync(); + return; } + + loadFromJsonOrDefaults(); } - private void loadFromSql() + private void loadFromSqlAsync() { - try - { - RankRepository repo = plugin.dm.getRankRepository(); - Map loaded = repo.loadAllAsync().block(); - usingSql = true; - - if (loaded.isEmpty() && !ranksFile.exists()) - { - createDefaultRanks(); - migrateConfigRanks(); - return; - } + final RankRepository repo = plugin.dm.getRankRepository(); + plugin.dm.readAsync("RankManager/loadFromSql", repo.loadAllAsync(), + loaded -> applyLoadedRanks(repo, loaded), + () -> + { + usingSql = false; + loadFromJsonOrDefaults(); + }); + } - customRanks.clear(); - customRanks.putAll(loaded); - validateEssentialRanks(); - resolveInheritance(); - updateAllPlayerTeams(); - FLog.info("Loaded " + customRanks.size() + " custom ranks from SQL database."); + private void applyLoadedRanks(final RankRepository repo, final Map loaded) + { + usingSql = true; - reconcileFromJsonIfNewer(repo); - } - catch (Exception ex) + if (loaded.isEmpty() && !ranksFile.exists()) { - FLog.warning("Failed to load ranks from SQL, falling back to JSON: " + ex.getMessage()); - usingSql = false; - loadFromJsonOrDefaults(); + createDefaultRanks(); + migrateConfigRanks(); + return; } + + customRanks.clear(); + customRanks.putAll(loaded); + validateEssentialRanks(); + resolveInheritance(); + updateAllPlayerTeams(); + FLog.info(String.format("Loaded %d custom ranks from SQL database.", customRanks.size())); + + reconcileFromJsonIfNewer(repo); } private void loadFromJsonOrDefaults() @@ -216,44 +228,65 @@ private Map readJsonRanks() throws IOException } /** - * If ranks.json was written more recently than the database's last update, re-import it into SQL. + * If ranks.json was written more recently than the database's last update, re-import it into + * SQL. The comparison and the re-import both ride the write queue off the main thread. */ - private void reconcileFromJsonIfNewer(RankRepository repo) + private void reconcileFromJsonIfNewer(final RankRepository repo) { if (!ranksFile.exists()) { + // Nothing to reconcile against, but SQL now has rows that no snapshot covers. + writes.enqueue(writeJsonAsync()); return; } + final Map jsonRanks; try { - Long sqlUpdatedAt = repo.getMaxUpdatedAt(); - if (sqlUpdatedAt != null && ranksFile.lastModified() <= sqlUpdatedAt) - { - return; - } - - Map jsonRanks = readJsonRanks(); - if (jsonRanks.isEmpty()) - { - return; - } - - FLog.info("ranks.json is newer than the database; re-importing " + jsonRanks.size() + " rank(s) from it."); - for (CustomRank rank : jsonRanks.values()) - { - repo.saveOrUpdate(rank); - } - - customRanks.clear(); - customRanks.putAll(jsonRanks); - resolveInheritance(); - updateAllPlayerTeams(); + jsonRanks = readJsonRanks(); } - catch (Exception ex) + catch (IOException ex) { - FLog.warning("Failed to reconcile ranks.json into the database: " + ex.getMessage()); + FLog.warning(String.format("Failed to read %s: %s", RANKS_FILENAME, ex.getMessage())); + return; } + + if (jsonRanks.isEmpty()) + return; + + final long fileModified = ranksFile.lastModified(); + + writes.enqueue(Mono.fromCallable(() -> + { + final Long sqlUpdatedAt = repo.getMaxUpdatedAt(); + return sqlUpdatedAt == null || fileModified > sqlUpdatedAt; + }) + .subscribeOn(Schedulers.boundedElastic()) + .filter(Boolean::booleanValue) + .flatMapMany(ignored -> + { + FLog.info(String.format("%s is newer than the database; re-importing %d rank(s) from it.", + RANKS_FILENAME, jsonRanks.size())); + return Flux.fromIterable(jsonRanks.values()) + .concatMap(repo::save); + }) + .then(Mono.fromRunnable(() -> plugin.dm.sync("RankManager/applyReconciled", + () -> applyReconciledRanks(jsonRanks)))) + .onErrorResume(ex -> + { + FLog.warning(String.format("Failed to reconcile %s into the database: %s", + RANKS_FILENAME, ex.getMessage())); + return Mono.empty(); + }) + .then()); + } + + private void applyReconciledRanks(final Map jsonRanks) + { + customRanks.clear(); + customRanks.putAll(jsonRanks); + resolveInheritance(); + updateAllPlayerTeams(); } private static final String[] ESSENTIAL_RANKS = { @@ -430,46 +463,48 @@ private void removeConfigRanks() } /** - * Save custom ranks to SQL (or ranks.json if SQL is unavailable). + * Queue a write of every custom rank to SQL, followed by a refresh of the ranks.json + * snapshot. Falls back to a JSON-only write when SQL is unavailable. Safe from a command + * handler: the SQL round trips run off the main thread. */ public void saveRanks() { - if (usingSql) + if (!usingSql || plugin.dm == null || !plugin.dm.isInitialized()) { - saveToSql(); - } - else - { - saveToJson(); + writes.enqueue(writeJsonAsync()); + return; } + + final RankRepository repo = plugin.dm.getRankRepository(); + final List snapshot = new ArrayList<>(customRanks.values()); + + writes.enqueue(Flux.fromIterable(snapshot) + .concatMap(rank -> repo.save(rank) + .onErrorResume(ex -> + { + FLog.severe(String.format("Could not save rank %s to SQL: %s", + rank.getId(), ex.getMessage())); + return Mono.empty(); + })) + .then(writeJsonAsync())); } - private void saveToSql() + /** + * Wait for queued rank writes to land, up to {@code timeoutMs}. + */ + public void awaitPendingWrites(long timeoutMs) { - if (plugin.dm == null || !plugin.dm.isInitialized()) - { - FLog.warning("SQL not available, falling back to JSON save for ranks"); - saveToJson(); - return; - } - - try - { - RankRepository repo = plugin.dm.getRankRepository(); - for (CustomRank rank : customRanks.values()) - { - repo.save(rank).block(); - } - } - catch (Exception ex) - { - FLog.severe("Could not save ranks to SQL: " + ex.getMessage()); - } + writes.await(timeoutMs); + } - saveToJson(); + private Mono writeJsonAsync() + { + final Map snapshot = new LinkedHashMap<>(customRanks); + return Mono.fromRunnable(() -> writeJson(snapshot)) + .subscribeOn(Schedulers.boundedElastic()); } - private void saveToJson() + private void writeJson(final Map snapshot) { if (ranksFile == null) { @@ -478,11 +513,11 @@ private void saveToJson() try (FileWriter writer = new FileWriter(ranksFile)) { - JsonUtil.GSON.toJson(customRanks, RANK_MAP_TYPE, writer); + JsonUtil.GSON.toJson(snapshot, RANK_MAP_TYPE, writer); } catch (IOException ex) { - FLog.severe("Could not save " + RANKS_FILENAME + ": " + ex.getMessage()); + FLog.severe(String.format("Could not save %s: %s", RANKS_FILENAME, ex.getMessage())); } } @@ -1655,7 +1690,7 @@ public Component formatLoginMessage(Player player) .replace("%coloredrank%", ""); admin.setLoginMessage(loginMessage); - plugin.al.save(); + plugin.al.saveAsync(); plugin.al.updateTables(); } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/AccessController.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/AccessController.java index 3bc383f16..e7f612b70 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/AccessController.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/AccessController.java @@ -1,11 +1,12 @@ package me.totalfreedom.totalfreedommod.sql; import java.sql.SQLException; +import java.util.concurrent.Callable; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import reactor.core.publisher.Flux; +import me.totalfreedom.totalfreedommod.util.FLog; + import reactor.core.publisher.Mono; import reactor.core.scheduler.Scheduler; @@ -17,9 +18,12 @@ * The query that has been waiting longest always receives the next available permit. * Permit release is guaranteed on completion, error, and cancellation. * - * The same semaphore backs both the reactive {@link #guard} path and the synchronous - * {@link #acquireSync()}/{@link #releaseSync()} pair, so {@link #availablePermits()} and - * {@link #queueLength()} reflect total demand regardless of which path callers use. + * A permit is owned by a thread, not by a call, and acquisition is re-entrant: nested + * acquires bump a depth counter and only the outermost release hands the permit back. A + * repository operation takes one permit via {@link #guard(Callable)} and the statements it + * issues re-enter it, so an operation never waits on a permit it is already holding. + *

+ * {@link #acquireSync()} and its matching {@link #releaseSync()} must run on the same thread. */ public final class AccessController { @@ -29,10 +33,13 @@ public final class AccessController private final Semaphore semaphore; private final Scheduler scheduler; + /** How many times the current thread has acquired without releasing. */ + private final ThreadLocal holdDepth = ThreadLocal.withInitial(() -> new int[1]); + /** * @param permits maximum number of concurrently executing queries. * Should always match the HikariCP maximum pool size. - * @param scheduler dedicated scheduler to acquire permits on, sized off the same pool. + * @param scheduler dedicated scheduler to run guarded work on, sized off the same pool. */ public AccessController(final int permits, final Scheduler scheduler) { @@ -41,40 +48,47 @@ public AccessController(final int permits, final Scheduler scheduler) } /** - * Guard a single-result query. The permit is held for the entire duration of the query, - * including the time spent mapping the result to the return type. + * Run a unit of database work on the scheduler holding exactly one permit, however many + * statements it issues, released on completion, error, and cancellation alike. The permit + * is taken on the thread that runs {@code work} so the nested acquires inside it re-enter. + * + * @return a {@link Mono} that is empty when {@code work} returns {@code null}. */ - public Mono guard(final Mono query) + public Mono guard(final Callable work) { - return Mono.usingWhen( - acquire(), - ignored -> query, - ignored -> release()); + return Mono.fromCallable(() -> callGuarded(work)).subscribeOn(scheduler); } - /** - * Guard a multi-row query or stream of results. - * The permit is held for the entire duration of the flux. - */ - public Flux guard(final Flux query) + private T callGuarded(final Callable work) throws Exception { - return Flux.usingWhen( - acquire(), - ignored -> query, - ignored -> release(), - (ignored, err) -> release(), - ignored -> release()); + acquireSync(); + try + { + return work.call(); + } + finally + { + releaseSync(); + } } /** - * Acquire a permit for a synchronous unit of work, - * for the handful of call sites that don't go through the reactive {@link #guard} path. - *

- * Waits at most {@link #ACQUIRE_TIMEOUT_SECONDS} rather than blocking forever. + * Acquire a permit for a synchronous unit of work, for the call sites that reach JDBC + * directly instead of going through {@link #guard(Callable)}. + *

+ * Returns immediately if this thread already holds the permit. Otherwise waits at most + * {@link #ACQUIRE_TIMEOUT_SECONDS} rather than blocking forever. * Always paired with {@link #releaseSync()}, typically in a {@code finally} block. */ public void acquireSync() throws SQLException { + final int[] depth = holdDepth.get(); + if (depth[0] > 0) + { + depth[0]++; + return; + } + final boolean acquired; try { @@ -89,11 +103,31 @@ public void acquireSync() throws SQLException if (!acquired) throw new SQLException(String.format( "Timed out after %ds waiting for a permit", ACQUIRE_TIMEOUT_SECONDS)); + + depth[0] = 1; } + /** + * Release one level of this thread's hold. The permit goes back to the semaphore only when + * the outermost hold is released. + */ public void releaseSync() { - semaphore.release(); + final int[] depth = holdDepth.get(); + if (depth[0] == 0) + { + // Releasing here would inflate the semaphore past the pool size. + FLog.severe("AccessController.releaseSync() on a thread holding no permit; " + + "acquire and release must be paired on the same thread"); + holdDepth.remove(); + return; + } + + if (--depth[0] == 0) + { + holdDepth.remove(); + semaphore.release(); + } } public int availablePermits() @@ -106,39 +140,4 @@ public int queueLength() { return semaphore.getQueueLength(); } - - private Mono acquire() - { - return Mono.create(sink -> - { - final Thread thread = Thread.currentThread(); - final AtomicBoolean cancelled = new AtomicBoolean(false); - sink.onCancel(() -> - { - cancelled.set(true); - thread.interrupt(); - }); - try - { - semaphore.acquire(); - if (cancelled.get()) - semaphore.release(); - else - sink.success(Boolean.TRUE); - } - catch (final InterruptedException e) - { - sink.error(e); - } - finally - { - Thread.interrupted(); - } - }).subscribeOn(scheduler); - } - - private Mono release() - { - return Mono.fromRunnable(semaphore::release); - } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/FreedomDatabase.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/FreedomDatabase.java index b4f808355..e737d2738 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/FreedomDatabase.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/FreedomDatabase.java @@ -8,6 +8,7 @@ import me.totalfreedom.totalfreedommod.sql.adapter.BanRepository; import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; import me.totalfreedom.totalfreedommod.sql.adapter.DiscordLinkRepository; +import me.totalfreedom.totalfreedommod.sql.adapter.MigrationRepository; import me.totalfreedom.totalfreedommod.sql.adapter.PermbanRepository; import me.totalfreedom.totalfreedommod.sql.adapter.PlayerRepository; import me.totalfreedom.totalfreedommod.sql.adapter.ProtectedAreaRepository; @@ -15,9 +16,11 @@ import me.totalfreedom.totalfreedommod.sql.adapter.SavedFlagRepository; import me.totalfreedom.totalfreedommod.sql.adapter.StrikeRepository; import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.FTask; -import java.sql.SQLException; -import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; @@ -32,16 +35,18 @@ */ public class FreedomDatabase extends FreedomService { - /** - * Upper bound on how long {@link #initialize()} waits for the connection pool and schema - * migrations to finish. The actual work runs on a background thread either way. - */ - private static final long INIT_TIMEOUT_SECONDS = 45L; - private ConnectionHandler connectionHandler; private StatementHandler statementHandler; private DatabaseAdapter adapter; - private boolean initialized = false; + private volatile boolean initialized = false; + + /** + * Callbacks waiting on the database, and whether they have already been drained. Both are + * touched only on the main thread, so registering during {@code onStart} cannot race the + * drain that happens once the background bootstrap finishes. + */ + private final List readyCallbacks = new ArrayList<>(); + private boolean readyFired = false; public FreedomDatabase(TotalFreedomMod plugin) { @@ -51,15 +56,7 @@ public FreedomDatabase(TotalFreedomMod plugin) @Override protected void onStart() { - try - { - initialize(); - } - catch (Exception ex) - { - FLog.severe("Failed to initialize database: " + ex.getMessage()); - ex.printStackTrace(); - } + initializeAsync(); } @Override @@ -69,16 +66,12 @@ protected void onStop() } /** - * Initialize the database connection and adapter. - *

- * The actual connection-pool bootstrap and schema migration run on a background thread, - * not the calling thread. This is called synchronously from {@link #onStart()} during - * {@code onEnable}, which runs on the main thread, and a slow or unreachable host must - * not hang the whole server boot. The wait is bounded by {@link #INIT_TIMEOUT_SECONDS}; - * if it elapses, this throws and every domain falls back to its JSON snapshot, same as - * any other connection failure. + * Build the connection pool, run schema migrations, then fold any legacy YAML data in, + * entirely on a background thread. {@code onEnable} does not wait for any of it: every + * domain starts on its JSON snapshot and swaps to SQL through {@link #whenReady(Runnable)} + * once this finishes, so an unreachable database host delays nothing but the swap. */ - public void initialize() throws SQLException + public void initializeAsync() { if (initialized) { @@ -86,40 +79,84 @@ public void initialize() throws SQLException return; } - FLog.info("Initializing database..."); + FLog.info("Initializing database in the background..."); connectionHandler = new ConnectionHandler(plugin); - SQLProperties properties = connectionHandler.getSqlProperties(); - DatabaseType dbType = properties.getDatabaseType(); + final SQLProperties properties = connectionHandler.getSqlProperties(); + final DatabaseType dbType = properties.getDatabaseType(); - final DatabaseAdapter built; - try + Mono.fromCallable(() -> { - built = Mono.fromCallable(() -> + connectionHandler.connect(); + statementHandler = new StatementHandler(connectionHandler); + DatabaseAdapter created = AdapterFactory.createAdapter(plugin, properties, connectionHandler, statementHandler); + created.initialize(); + return created; + }) + .subscribeOn(Schedulers.boundedElastic()) + .doOnNext(built -> { - connectionHandler.connect(); - statementHandler = new StatementHandler(connectionHandler); - DatabaseAdapter created = AdapterFactory.createAdapter(plugin, properties, connectionHandler, statementHandler); - created.initialize(); - return created; + adapter = built; + initialized = true; + FLog.info(String.format("Database initialized successfully (%s)", dbType.getName())); }) - .subscribeOn(Schedulers.boundedElastic()) - .block(Duration.ofSeconds(INIT_TIMEOUT_SECONDS)); - } - catch (Exception ex) + // Migrations have to finish before any domain reads SQL, so they ride this + // chain rather than registering as another ready callback. + .then(Mono.defer(() -> new YamlMigrationService(plugin, this).runMigrations())) + .subscribe( + ignored -> {}, + ex -> FLog.severe(String.format( + "Failed to initialize database, every domain stays on its JSON fallback: %s", + ex.getMessage())), + () -> sync("FreedomDatabase/ready", this::fireReady)); + } + + /** + * Register {@code callback} to run on the main thread once the database is up and its YAML + * migrations have finished, or immediately if that has already happened. Callbacks run in + * registration order and are skipped entirely if the database never comes up. + *

+ * Main thread only. + */ + public void whenReady(final Runnable callback) + { + if (readyFired) { - throw new SQLException(String.format( - "Database did not finish initializing within %ds", INIT_TIMEOUT_SECONDS), ex); + callback.run(); + return; } + readyCallbacks.add(callback); + } - if (built == null) + /** + * Run {@code query} off the main thread and hand its result to {@code apply} back on it. + * A failed or empty query logs against {@code label} and runs {@code onFailure} instead, + * also on the main thread. + */ + public void readAsync(final String label, final Mono query, final Consumer apply, + final Runnable onFailure) + { + query.switchIfEmpty(Mono.error(new IllegalStateException("query returned no result"))) + .subscribe( + result -> sync(label, () -> apply.accept(result)), + ex -> + { + FLog.warning(String.format("%s failed: %s", label, ex.getMessage())); + sync(label, onFailure); + }); + } + + /** + * Run {@code body} on the main thread, or inline if the plugin is already disabled. + */ + public void sync(final String label, final Runnable body) + { + if (!plugin.isEnabled()) { - throw new SQLException("Database initialization completed with no adapter"); + FTask.run(label, body); + return; } - - adapter = built; - initialized = true; - FLog.info("Database initialized successfully (" + dbType.getName() + ")"); + plugin.getServer().getScheduler().runTask(plugin, FTask.guard(label, body)); } /** @@ -270,6 +307,15 @@ public PlayerRepository getPlayerRepository() return adapter.getPlayerRepository(); } + public MigrationRepository getMigrationRepository() + { + if (adapter == null) + { + throw new IllegalStateException("Database not initialized"); + } + return adapter.getMigrationRepository(); + } + /** * Snapshot of live connection pool and fairness-queue health, or {@code null} if the * database isn't initialized. @@ -294,4 +340,11 @@ public DatabaseType getDatabaseType() } return connectionHandler.getSqlProperties().getDatabaseType(); } + + private void fireReady() + { + readyFired = true; + readyCallbacks.forEach(callback -> FTask.run("FreedomDatabase/readyCallback", callback)); + readyCallbacks.clear(); + } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/PersistenceQueue.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/PersistenceQueue.java new file mode 100644 index 000000000..30e4ae40c --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/PersistenceQueue.java @@ -0,0 +1,90 @@ +package me.totalfreedom.totalfreedommod.sql; + +import java.time.Duration; + +import me.totalfreedom.totalfreedommod.util.FLog; + +import reactor.core.publisher.Mono; + +/** + * Serialises one domain's persistence work onto a single ordered chain. Each queued unit runs + * after the one before it, so a whole-list batch can never land behind a single-row update that + * was requested later. + *

+ * Shared by every SQL-primary/JSON-fallback domain: {@code AdminList}, {@code BanManager}, + * {@code PermbanList}, {@code StrikeList}, {@code RankManager}, {@code ProtectArea} and + * {@code SavedFlags}. + */ +public final class PersistenceQueue +{ + private final String domain; + private final Object lock = new Object(); + private Mono chain = Mono.empty(); + + /** + * @param domain name this queue uses when it logs, e.g. {@code "admin"}. + */ + public PersistenceQueue(final String domain) + { + this.domain = domain; + } + + /** + * Append {@code work} to the chain and subscribe. + */ + public void enqueue(final Mono work) + { + synchronized (lock) + { + final Mono queued = chain + .onErrorResume(ignored -> Mono.empty()) + .then(work) + .cache(); + + chain = queued; + queued.doFinally(signal -> collapse(queued)).subscribe(); + } + } + + /** + * Wait for queued writes to land, up to {@code timeoutMs}. Called before a shutdown flush, + * which would otherwise race the queue and let an older queued snapshot overwrite the state + * just written. + */ + public void await(final long timeoutMs) + { + final Mono pending; + synchronized (lock) + { + pending = chain; + } + + try + { + pending.block(Duration.ofMillis(timeoutMs)); + } + catch (IllegalStateException ex) + { + // Reactor answers a blocking-read timeout with IllegalStateException. + FLog.warning(String.format("Gave up after %dms waiting for pending %s writes (%s); flushing anyway", + timeoutMs, domain, ex.getMessage())); + } + catch (RuntimeException ex) + { + FLog.warning(String.format("A queued %s write failed before shutdown: %s", domain, ex.getMessage())); + } + } + + /** + * Drop the retained operator chain once its tail completes. The chain is strictly + * sequential, so a completed tail means every write before it is done. + */ + private void collapse(final Mono completed) + { + synchronized (lock) + { + if (chain == completed) + chain = Mono.empty(); + } + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/StatementHandler.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/StatementHandler.java index bbce8357f..2c75a3b85 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/StatementHandler.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/StatementHandler.java @@ -113,26 +113,24 @@ public long executeUpdateReturnKey(String sql, Object... params) throws SQLExcep } } + /** + * Run {@code work} on the SQL scheduler under a single {@link AccessController} permit. + * The statements {@code work} issues re-enter that same permit rather than queueing for + * one of their own, so a repository operation costs exactly one permit no matter how many + * round trips it makes. + */ public Mono supplyMono(Callable work) { - return connectionHandler.getAccessController().guard( - Mono.fromCallable(work).subscribeOn(connectionHandler.getScheduler())); + return connectionHandler.getAccessController().guard(work); } public Mono runMono(SqlRunnable work) { - return connectionHandler.getAccessController().guard( - Mono.fromRunnable(() -> - { - try - { - work.run(); - } - catch (SQLException e) - { - throw new RuntimeException(e); - } - }).subscribeOn(connectionHandler.getScheduler())); + return connectionHandler.getAccessController().guard(() -> + { + work.run(); + return null; + }).then(); } @FunctionalInterface diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/YamlMigrationService.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/YamlMigrationService.java index af869f054..14ea799d9 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/YamlMigrationService.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/YamlMigrationService.java @@ -13,6 +13,7 @@ import me.totalfreedom.totalfreedommod.sql.adapter.PermbanRepository; import me.totalfreedom.totalfreedommod.sql.adapter.PlayerRepository; import me.totalfreedom.totalfreedommod.sql.adapter.ProtectedAreaRepository; +import me.totalfreedom.totalfreedommod.sql.adapter.MigrationRepository; import me.totalfreedom.totalfreedommod.sql.adapter.RankRepository; import me.totalfreedom.totalfreedommod.sql.adapter.SavedFlagRepository; import me.totalfreedom.totalfreedommod.util.FLog; @@ -21,9 +22,12 @@ import org.bukkit.configuration.file.YamlConfiguration; import java.io.File; +import java.sql.SQLException; import java.util.List; +import java.util.Set; import java.util.UUID; import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; @@ -52,6 +56,21 @@ public class YamlMigrationService private static final String SAVED_FLAGS_FILE = "savedflags.yml"; private static final String PLAYERS_DIR = "players"; + /** + * Ledger keys, one per domain. Recorded in the {@code migrations} table once that domain's + * YAML import has run, whether or not it found anything to import. + */ + private static final String V_ADMINS = "yaml-import:admins"; + private static final String V_BANS = "yaml-import:bans"; + private static final String V_PERMBANS = "yaml-import:permbans"; + private static final String V_RANKS = "yaml-import:ranks"; + private static final String V_PROTECTED_AREAS = "yaml-import:protected_areas"; + private static final String V_SAVED_FLAGS = "yaml-import:saved_flags"; + private static final String V_PLAYERS = "yaml-import:players"; + + private static final Set ALL_VERSIONS = Set.of( + V_ADMINS, V_BANS, V_PERMBANS, V_RANKS, V_PROTECTED_AREAS, V_SAVED_FLAGS, V_PLAYERS); + public YamlMigrationService(TotalFreedomMod plugin, FreedomDatabase databaseManager) { this.plugin = plugin; @@ -75,13 +94,7 @@ public Mono runMigrations() return; } - migrateAdmins(); - migrateBans(); - migratePermbans(); - migrateRanks(); - migrateProtectedAreas(); - migrateSavedFlags(); - migratePlayers(); + runAllMigrations(); FLog.info("YAML data migration check complete"); } @@ -93,6 +106,47 @@ public Mono runMigrations() }).subscribeOn(Schedulers.boundedElastic()); } + /** + * Run every domain's one-time YAML import, in the order their tables reference each other, + * skipping any already recorded in the ledger. + */ + private void runAllMigrations() throws SQLException + { + final MigrationRepository ledger = databaseManager.getMigrationRepository(); + final Set applied = ledger.findApplied(); + + if (applied.containsAll(ALL_VERSIONS)) + { + FLog.info("YAML imports already applied to this database; nothing to migrate."); + return; + } + + runOnce(ledger, applied, V_ADMINS, this::migrateAdmins); + runOnce(ledger, applied, V_BANS, this::migrateBans); + runOnce(ledger, applied, V_PERMBANS, this::migratePermbans); + runOnce(ledger, applied, V_RANKS, this::migrateRanks); + runOnce(ledger, applied, V_PROTECTED_AREAS, this::migrateProtectedAreas); + runOnce(ledger, applied, V_SAVED_FLAGS, this::migrateSavedFlags); + runOnce(ledger, applied, V_PLAYERS, this::migratePlayers); + } + + /** + * Run {@code body} unless {@code version} is already in the ledger, then record it. + *

+ * The version is recorded even when the domain had no YAML to import, so an install that + * never had the file stops re-checking for it. A {@code body} that throws is left + * unrecorded and retried on the next start. + */ + private void runOnce(final MigrationRepository ledger, final Set applied, + final String version, final Runnable body) throws SQLException + { + if (applied.contains(version)) + return; + + body.run(); + ledger.markApplied(version); + } + /** * Migrate admins from admins.yml to database. */ @@ -625,12 +679,20 @@ public Mono forceMigration() return Mono.fromRunnable(() -> { FLog.warning("Force migration requested - this will overwrite database data!"); - // Clear existing data try { + // Clear existing data databaseManager.getAdminRepository().deleteAll().block(); databaseManager.getBanRepository().deleteAll().block(); databaseManager.getPermbanRepository().deleteAll().block(); + databaseManager.getStrikeRepository().deleteAll().block(); + databaseManager.getRankRepository().deleteAll().block(); + databaseManager.getProtectedAreaRepository().deleteAll().block(); + databaseManager.getSavedFlagRepository().deleteAll().block(); + databaseManager.getPlayerRepository().deleteAll().block(); + + // Forget the ledger, otherwise every import below is skipped as already applied + databaseManager.getMigrationRepository().clear(); } catch (Exception ex) { @@ -641,10 +703,14 @@ public Mono forceMigration() // Restore backup files if they exist restoreBackupFiles(); - // Run migrations - migrateAdmins(); - migrateBans(); - migratePermbans(); + try + { + runAllMigrations(); + } + catch (SQLException ex) + { + FLog.severe(String.format("Force migration failed: %s", ex.getMessage())); + } }).subscribeOn(Schedulers.boundedElastic()); } @@ -653,11 +719,10 @@ public Mono forceMigration() */ private void restoreBackupFiles() { - File dataFolder = plugin.getDataFolder(); + final File dataFolder = plugin.getDataFolder(); - restoreBackupFile(new File(dataFolder, ADMINS_FILE + ".migrated"), new File(dataFolder, ADMINS_FILE)); - restoreBackupFile(new File(dataFolder, BANS_FILE + ".migrated"), new File(dataFolder, BANS_FILE)); - restoreBackupFile(new File(dataFolder, PERMBANS_FILE + ".migrated"), new File(dataFolder, PERMBANS_FILE)); + Stream.of(ADMINS_FILE, BANS_FILE, PERMBANS_FILE, RANKS_FILE, PROTECTED_AREAS_FILE, SAVED_FLAGS_FILE) + .forEach(name -> restoreBackupFile(new File(dataFolder, name + ".migrated"), new File(dataFolder, name))); } private void restoreBackupFile(File backup, File original) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DatabaseAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DatabaseAdapter.java index a2b44d8ea..3700267e9 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DatabaseAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DatabaseAdapter.java @@ -104,6 +104,11 @@ public void shutdown() */ public abstract PlayerRepository getPlayerRepository(); + /** + * Get the applied-migration ledger for this database type. + */ + public abstract MigrationRepository getMigrationRepository(); + // ============================================ // SQL Dialect Methods (override for differences) // ============================================ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DiscordLinkRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DiscordLinkRepository.java index 90ce13a64..ad4d85d41 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DiscordLinkRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DiscordLinkRepository.java @@ -4,6 +4,8 @@ import java.util.Map; import java.util.UUID; +import reactor.core.publisher.Mono; + public interface DiscordLinkRepository { /** @@ -44,4 +46,14 @@ public interface DiscordLinkRepository * Used to compare SQL freshness against the discord_links.json snapshot's last-modified time. */ Long getMaxUpdatedAt() throws SQLException; + + Mono> loadAllAsync(); + + /** + * Replace any existing link for either side, then link {@code adminUuid} to + * {@code discordUserId}. + */ + Mono relinkAsync(UUID adminUuid, String discordUserId); + + Mono getMaxUpdatedAtAsync(); } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/MigrationRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/MigrationRepository.java new file mode 100644 index 000000000..f0ff45788 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/MigrationRepository.java @@ -0,0 +1,35 @@ +package me.totalfreedom.totalfreedommod.sql.adapter; + +import java.sql.SQLException; +import java.util.Set; + +import reactor.core.publisher.Mono; + +/** + * Ledger of one-off data migrations that have already been applied to this database, backed by + * the {@code migrations} table every adapter creates. + *

+ * Keyed by an opaque version string rather than by table contents, so a migration whose source + * data was empty still counts as done and is not retried on every boot. + */ +public interface MigrationRepository +{ + /** + * Version keys already recorded as applied. + */ + Set findApplied() throws SQLException; + + /** + * Record {@code version} as applied. Does nothing if it already is. + */ + void markApplied(String version) throws SQLException; + + /** + * Forget every recorded version, so the migrations they guard run again. + */ + void clear() throws SQLException; + + Mono> findAppliedAsync(); + + Mono markAppliedAsync(String version); +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericAdminRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericAdminRepository.java index 6ce4cebc0..ad4841e02 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericAdminRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericAdminRepository.java @@ -123,37 +123,61 @@ public Map loadAll() throws SQLException } } + attachIps(adminById); + + return admins; + } + + /** + * Fold the {@code admin_ips} child rows into admins that have already been read. + * One flat query, run after the caller's cursor is closed: a nested read would hold two + * connections at once, which SQLite's single-connection pool cannot serve. + */ + private void attachIps(Map adminById) throws SQLException + { + if (adminById.isEmpty()) + return; + String ipSql = String.format("SELECT %s, %s FROM %s", colAdminId, colIp, tblAdminIps); try (ResultSet rs = statementHandler.executeQuery(ipSql)) { while (rs.next()) { - int adminId = rs.getInt("admin_id"); - String ip = rs.getString("ip"); - Admin admin = adminById.get(adminId); + Admin admin = adminById.get(rs.getInt("admin_id")); if (admin != null) { - admin.addIp(ip); + admin.addIp(rs.getString("ip")); } } } + } - return admins; + /** + * Read a single admin and its IPs, closing the row's statement before looking the IPs up. + */ + private Admin findOne(String sql, Object... params) throws SQLException + { + final Admin admin; + final int adminId; + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, params); + ResultSet rs = stmt.executeQuery()) + { + if (!rs.next()) + return null; + + admin = loadAdminFromRow(rs); + adminId = rs.getInt("id"); + } + + admin.addIps(getIps(adminId)); + return admin; } @Override public Admin findByUuid(UUID uuid) throws SQLException { String sql = String.format("SELECT %s FROM %s WHERE %s = ?", selectColumns, tblAdmins, colUuid); - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadAdminFromResultSet(rs); - } - } - return null; + return findOne(sql, uuid.toString()); } @Override @@ -161,15 +185,7 @@ public Admin findByUsername(String username) throws SQLException { String sql = String.format("SELECT %s FROM %s WHERE %s", selectColumns, tblAdmins, adapter.caseInsensitiveEquals(colUsername, "?")); - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadAdminFromResultSet(rs); - } - } - return null; + return findOne(sql, username); } @Override @@ -179,15 +195,7 @@ public Admin findByIp(String ip) throws SQLException "SELECT a.%s, a.%s, a.%s, a.%s, a.%s, a.%s, a.%s, a.%s FROM %s a INNER JOIN %s ai ON a.%s = ai.%s WHERE ai.%s = ?", colId, colUuid, colUsername, colRank, colActive, colLastLogin, colLoginMessage, colCustomRank, tblAdmins, tblAdminIps, colId, colAdminId, colIp); - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, ip); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadAdminFromResultSet(rs); - } - } - return null; + return findOne(sql, ip); } @Override @@ -441,14 +449,6 @@ public Mono deleteAll() return statementHandler.runMono(this::deleteAllSync); } - private Admin loadAdminFromResultSet(ResultSet rs) throws SQLException - { - Admin admin = loadAdminFromRow(rs); - List ips = getIps(rs.getInt("id")); - admin.addIps(ips); - return admin; - } - private Admin loadAdminFromRow(ResultSet rs) throws SQLException { String username = rs.getString("username"); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericBanRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericBanRepository.java index 9612c0513..30bd0996a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericBanRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericBanRepository.java @@ -119,37 +119,83 @@ public List loadAll() throws SQLException } } + attachIps(banById); + + return bans; + } + + /** + * Fold the {@code ban_ips} child rows into bans that have already been read. + * One flat query, run after the caller's cursor is closed: a nested read would hold two + * connections at once, which SQLite's single-connection pool cannot serve. + */ + private void attachIps(Map banById) throws SQLException + { + if (banById.isEmpty()) + return; + String ipSql = String.format("SELECT %s, %s FROM %s", colBanId, colIp, tblBanIps); try (ResultSet rs = statementHandler.executeQuery(ipSql)) { while (rs.next()) { - int banId = rs.getInt("ban_id"); - String ip = rs.getString("ip"); - Ban ban = banById.get(banId); + Ban ban = banById.get(rs.getInt("ban_id")); if (ban != null) { - ban.addIp(ip); + ban.addIp(rs.getString("ip")); } } } - - return bans; } - @Override - public Ban findByUuid(UUID uuid) throws SQLException + /** + * Read a single ban and its IPs, closing the row's statement before looking the IPs up. + */ + private Ban findOne(String sql, Object... params) throws SQLException { - String sql = String.format("SELECT %s FROM %s WHERE %s = ?", selectColumns, tblBans, colUuid); - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); + final Ban ban; + final int banId; + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, params); ResultSet rs = stmt.executeQuery()) { - if (rs.next()) + if (!rs.next()) + return null; + + ban = loadBanFromRow(rs); + banId = rs.getInt("id"); + } + + ban.setIps(getIps(banId)); + return ban; + } + + /** + * Read every ban matching {@code sql}, then attach IPs once the cursor is closed. + */ + private List findMany(final String sql) throws SQLException + { + List bans = new ArrayList<>(); + Map banById = new HashMap<>(); + + try (ResultSet rs = statementHandler.executeQuery(sql)) + { + while (rs.next()) { - return loadBanFromResultSet(rs); + Ban ban = loadBanFromRow(rs); + bans.add(ban); + banById.put(rs.getInt("id"), ban); } } - return null; + + attachIps(banById); + return bans; + } + + @Override + public Ban findByUuid(UUID uuid) throws SQLException + { + String sql = String.format("SELECT %s FROM %s WHERE %s = ?", selectColumns, tblBans, colUuid); + return findOne(sql, uuid.toString()); } @Override @@ -157,15 +203,7 @@ public Ban findByUsername(String username) throws SQLException { String sql = String.format("SELECT %s FROM %s WHERE %s", selectColumns, tblBans, adapter.caseInsensitiveEquals(colUsername, "?")); - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadBanFromResultSet(rs); - } - } - return null; + return findOne(sql, username); } @Override @@ -175,47 +213,21 @@ public Ban findByIp(String ip) throws SQLException "SELECT b.%s, b.%s, b.%s, b.%s, b.%s, b.%s, b.%s FROM %s b INNER JOIN %s bi ON b.%s = bi.%s WHERE bi.%s = ?", colId, colUuid, colUsername, colBannedBy, colBannedByUuid, colReason, colExpireAt, tblBans, tblBanIps, colId, colBanId, colIp); - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, ip); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadBanFromResultSet(rs); - } - } - return null; + return findOne(sql, ip); } @Override public List findActiveBans() throws SQLException { - String sql = String.format("SELECT %s FROM %s WHERE %s IS NULL OR %s", - selectColumns, tblBans, colExpireAt, adapter.compareToNow(colExpireAt, ">")); - List bans = new ArrayList<>(); - try (ResultSet rs = statementHandler.executeQuery(sql)) - { - while (rs.next()) - { - bans.add(loadBanFromResultSet(rs)); - } - } - return bans; + return findMany(String.format("SELECT %s FROM %s WHERE %s IS NULL OR %s", + selectColumns, tblBans, colExpireAt, adapter.compareToNow(colExpireAt, ">"))); } @Override public List findExpiredBans() throws SQLException { - String sql = String.format("SELECT %s FROM %s WHERE %s IS NOT NULL AND %s", - selectColumns, tblBans, colExpireAt, adapter.compareToNow(colExpireAt, "<=")); - List bans = new ArrayList<>(); - try (ResultSet rs = statementHandler.executeQuery(sql)) - { - while (rs.next()) - { - bans.add(loadBanFromResultSet(rs)); - } - } - return bans; + return findMany(String.format("SELECT %s FROM %s WHERE %s IS NOT NULL AND %s", + selectColumns, tblBans, colExpireAt, adapter.compareToNow(colExpireAt, "<="))); } @Override @@ -465,13 +477,6 @@ public Mono deleteAll() return statementHandler.runMono(this::deleteAllSync); } - private Ban loadBanFromResultSet(ResultSet rs) throws SQLException - { - Ban ban = loadBanFromRow(rs); - ban.setIps(getIps(rs.getInt("id"))); - return ban; - } - private Ban loadBanFromRow(ResultSet rs) throws SQLException { String uuidStr = rs.getString("uuid"); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericDiscordLinkRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericDiscordLinkRepository.java index 2055f978f..a14a732ba 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericDiscordLinkRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericDiscordLinkRepository.java @@ -11,6 +11,8 @@ import java.util.Map; import java.util.UUID; +import reactor.core.publisher.Mono; + /** * All dialect differences are resolved through the {@link DatabaseAdapter} passed in. */ @@ -115,4 +117,27 @@ public Long getMaxUpdatedAt() throws SQLException } return null; } + + @Override + public Mono> loadAllAsync() + { + return statementHandler.supplyMono(this::loadAll); + } + + @Override + public Mono relinkAsync(UUID adminUuid, String discordUserId) + { + return statementHandler.runMono(() -> + { + deleteByAdminUuid(adminUuid); + deleteByDiscordUserId(discordUserId); + insert(adminUuid, discordUserId); + }); + } + + @Override + public Mono getMaxUpdatedAtAsync() + { + return statementHandler.supplyMono(this::getMaxUpdatedAt); + } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericMigrationRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericMigrationRepository.java new file mode 100644 index 000000000..3ffc12e0f --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericMigrationRepository.java @@ -0,0 +1,77 @@ +package me.totalfreedom.totalfreedommod.sql.adapter.generic; + +import me.totalfreedom.totalfreedommod.sql.StatementHandler; +import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; +import me.totalfreedom.totalfreedommod.sql.adapter.MigrationRepository; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.LinkedHashSet; +import java.util.Set; + +import reactor.core.publisher.Mono; + +/** + * All dialect differences are resolved through the {@link DatabaseAdapter} passed in. + */ +public class GenericMigrationRepository implements MigrationRepository +{ + private final StatementHandler statementHandler; + + private final String selectAllSql; + private final String insertSql; + private final String deleteAllSql; + + public GenericMigrationRepository(StatementHandler statementHandler, DatabaseAdapter adapter) + { + this.statementHandler = statementHandler; + + final String tblMigrations = adapter.quoteIdentifier("migrations"); + final String colVersion = adapter.quoteIdentifier("version"); + final String colAppliedAt = adapter.quoteIdentifier("applied_at"); + + this.selectAllSql = String.format("SELECT %s FROM %s", colVersion, tblMigrations); + this.insertSql = String.format("%s INTO %s (%s, %s) VALUES (?, %s)%s", + adapter.insertIgnoreSyntax(), tblMigrations, colVersion, colAppliedAt, + adapter.currentTimestamp(), adapter.insertIgnoreSuffix()); + this.deleteAllSql = String.format("DELETE FROM %s", tblMigrations); + } + + @Override + public Set findApplied() throws SQLException + { + final Set versions = new LinkedHashSet<>(); + try (ResultSet rs = statementHandler.executeQuery(selectAllSql)) + { + while (rs.next()) + { + versions.add(rs.getString(1)); + } + } + return versions; + } + + @Override + public void markApplied(String version) throws SQLException + { + statementHandler.executeUpdate(insertSql, version); + } + + @Override + public void clear() throws SQLException + { + statementHandler.executeUpdate(deleteAllSql); + } + + @Override + public Mono> findAppliedAsync() + { + return statementHandler.supplyMono(this::findApplied); + } + + @Override + public Mono markAppliedAsync(String version) + { + return statementHandler.runMono(() -> markApplied(version)); + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPermbanRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPermbanRepository.java index 2234b249e..3e09f67da 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPermbanRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPermbanRepository.java @@ -107,37 +107,61 @@ public List loadAll() throws SQLException } } + attachIps(permbanById); + + return permbans; + } + + /** + * Fold the {@code permban_ips} child rows into permbans that have already been read. + * One flat query, run after the caller's cursor is closed: a nested read would hold two + * connections at once, which SQLite's single-connection pool cannot serve. + */ + private void attachIps(Map permbanById) throws SQLException + { + if (permbanById.isEmpty()) + return; + String ipSql = String.format("SELECT %s, %s FROM %s", colPermbanId, colIp, tblPermbanIps); try (ResultSet rs = statementHandler.executeQuery(ipSql)) { while (rs.next()) { - int permbanId = rs.getInt("permban_id"); - String ip = rs.getString("ip"); - PermBan permban = permbanById.get(permbanId); + PermBan permban = permbanById.get(rs.getInt("permban_id")); if (permban != null) { - permban.addIp(ip); + permban.addIp(rs.getString("ip")); } } } + } - return permbans; + /** + * Read a single permban and its IPs, closing the row's statement before looking the IPs up. + */ + private PermBan findOne(String sql, Object... params) throws SQLException + { + final PermBan permban; + final int permbanId; + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, params); + ResultSet rs = stmt.executeQuery()) + { + if (!rs.next()) + return null; + + permban = loadPermbanFromRow(rs); + permbanId = rs.getInt("id"); + } + + permban.setIps(getIps(permbanId)); + return permban; } @Override public PermBan findByUuid(UUID uuid) throws SQLException { String sql = String.format("SELECT %s FROM %s WHERE %s = ?", selectColumns, tblPermbans, colUuid); - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadPermbanFromResultSet(rs); - } - } - return null; + return findOne(sql, uuid.toString()); } @Override @@ -145,15 +169,7 @@ public PermBan findByUsername(String username) throws SQLException { String sql = String.format("SELECT %s FROM %s WHERE %s", selectColumns, tblPermbans, adapter.caseInsensitiveEquals(colUsername, "?")); - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadPermbanFromResultSet(rs); - } - } - return null; + return findOne(sql, username); } @Override @@ -162,15 +178,7 @@ public PermBan findByIp(String ip) throws SQLException String sql = String.format( "SELECT p.%s, p.%s, p.%s, p.%s FROM %s p INNER JOIN %s pi ON p.%s = pi.%s WHERE pi.%s = ?", colId, colUuid, colUsername, colReason, tblPermbans, tblPermbanIps, colId, colPermbanId, colIp); - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, ip); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadPermbanFromResultSet(rs); - } - } - return null; + return findOne(sql, ip); } @Override @@ -392,13 +400,6 @@ public Mono deleteAll() return statementHandler.runMono(this::deleteAllSync); } - private PermBan loadPermbanFromResultSet(ResultSet rs) throws SQLException - { - PermBan permban = loadPermbanFromRow(rs); - permban.setIps(getIps(rs.getInt("id"))); - return permban; - } - private PermBan loadPermbanFromRow(ResultSet rs) throws SQLException { String uuidStr = rs.getString("uuid"); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdapter.java index 0999ff2c8..598406047 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdapter.java @@ -32,6 +32,7 @@ public class MySQLAdapter extends DatabaseAdapter private ProtectedAreaRepository protectedAreaRepository; private SavedFlagRepository savedFlagRepository; private PlayerRepository playerRepository; + private MigrationRepository migrationRepository; public MySQLAdapter(TotalFreedomMod plugin, ConnectionHandler connectionHandler, StatementHandler statementHandler) { @@ -530,4 +531,14 @@ public PlayerRepository getPlayerRepository() } return playerRepository; } + + @Override + public MigrationRepository getMigrationRepository() + { + if (migrationRepository == null) + { + migrationRepository = new GenericMigrationRepository(statementHandler, this); + } + return migrationRepository; + } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java index 7864b90d9..9e3e15f79 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java @@ -31,6 +31,7 @@ public class PostgreSQLAdapter extends DatabaseAdapter private ProtectedAreaRepository protectedAreaRepository; private SavedFlagRepository savedFlagRepository; private PlayerRepository playerRepository; + private MigrationRepository migrationRepository; public PostgreSQLAdapter(TotalFreedomMod plugin, ConnectionHandler connectionHandler, StatementHandler statementHandler) { @@ -512,4 +513,14 @@ public PlayerRepository getPlayerRepository() } return playerRepository; } + + @Override + public MigrationRepository getMigrationRepository() + { + if (migrationRepository == null) + { + migrationRepository = new GenericMigrationRepository(statementHandler, this); + } + return migrationRepository; + } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java index 1d72eea83..db06e91f8 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java @@ -30,6 +30,7 @@ public class SQLiteAdapter extends DatabaseAdapter private ProtectedAreaRepository protectedAreaRepository; private SavedFlagRepository savedFlagRepository; private PlayerRepository playerRepository; + private MigrationRepository migrationRepository; public SQLiteAdapter(TotalFreedomMod plugin, ConnectionHandler connectionHandler, StatementHandler statementHandler) { @@ -534,4 +535,14 @@ public PlayerRepository getPlayerRepository() } return playerRepository; } + + @Override + public MigrationRepository getMigrationRepository() + { + if (migrationRepository == null) + { + migrationRepository = new GenericMigrationRepository(statementHandler, this); + } + return migrationRepository; + } } diff --git a/src/main/resources/ranks.yml b/src/main/resources/ranks.yml deleted file mode 100644 index bc50ec781..000000000 --- a/src/main/resources/ranks.yml +++ /dev/null @@ -1,190 +0,0 @@ -# TotalFreedomMod Ranks Configuration -# This file defines all custom ranks and their properties. -# -# Format: -# rankid: -# name: Display Name -# abbreviation: TAG -# prefix: "&8[&bTAG&8] " (chat prefix with color codes) -# level: (higher = more authority) -# color: -# determiner: a/an -# admin: true/false -# console_only: true/false -# inherit: (inherit all permissions from another rank) -# permissions: -# - permission.node -# - another.permission -# -# Available colors: -# black, dark_blue, dark_green, dark_aqua, dark_red, dark_purple, -# gold, gray, dark_gray, blue, green, aqua, red, light_purple, yellow, white -# -# Permission nodes are TFM-internal only (NOT Bukkit permission nodes). -# Since all players have OP, Bukkit permissions don't apply. -# Use these to restrict TFM features based on rank. -# -# Permission Categories: -# tfm.player.* - Basic player commands (NON_OP/OP level) -# tfm.fun.* - Fun commands (smite, doom, orbit, etc.) -# tfm.admin.* - Admin actions (ban, kick, mute, freeze, cage) -# tfm.admin.senior.* - Senior admin only actions -# tfm.server.* - Server management commands -# tfm.world.* - World management commands -# tfm.manage.* - Management commands (ranks, config) -# - -impostor: - name: Impostor - abbreviation: Imp - prefix: "&8[&eImp&8] " - level: -1 - color: dark_gray - determiner: an - admin: false - console_only: false - permissions: - - tfm.player.list - - tfm.admin.overlord - -non_op: - name: Player - abbreviation: "" - prefix: "" - level: 0 - color: white - determiner: a - admin: false - console_only: false - permissions: - - tfm.player.localspawn - - tfm.player.radar - - tfm.player.rank - - tfm.player.spawn - - tfm.player.list - - tfm.player.joinmessages - - tfm.world.flatlands - - tfm.server.info - -op: - name: Operator - abbreviation: OP - prefix: "&8[&cOP&8] " - level: 1 - color: green - determiner: an - admin: false - console_only: false - inherit: non_op - permissions: - - tfm.player.* - - tfm.world.adminworld - - tfm.admin.banlist - - tfm.fun.hack - - tfm.fun.landmine - - tfm.fun.mp44 - - tfm.fun.spawnmob - - tfm.fun.tossmob - - tfm.server.whitelist - - tfm.manage.saconfig - -super_admin: - name: Super Admin - abbreviation: SA - prefix: "&8[&bSA&8] " - level: 2 - color: gold - determiner: a - admin: true - console_only: false - inherit: op - permissions: - - tfm.fun.* - - tfm.world.* - - tfm.admin.adminchat - - tfm.admin.announce - - tfm.admin.ban - - tfm.admin.baninfo - - tfm.admin.banlist - - tfm.admin.blockcmd - - tfm.admin.blockredstone - - tfm.admin.cage - - tfm.admin.cmdspy - - tfm.admin.consolesay - - tfm.admin.denick - - tfm.admin.deop - - tfm.admin.disguisetoggle - - tfm.admin.findip - - tfm.admin.freeze - - tfm.admin.gcmd - - tfm.admin.invis - - tfm.admin.kick - - tfm.admin.mute - - tfm.admin.myadmin - - tfm.admin.nickclean - - tfm.admin.opall - - tfm.admin.opme - - tfm.admin.potspy - - tfm.admin.premium - - tfm.admin.purgeall - - tfm.admin.ro - - tfm.admin.say - - tfm.admin.strike - - tfm.admin.undisguiseall - - tfm.admin.warn - - tfm.admin.whohas - - tfm.admin.wildcard - - tfm.admin.gamemode - - tfm.admin.protectregion - - tfm.server.* - - tfm.manage.saconfig - -senior_admin: - name: Senior Admin - abbreviation: SrA - prefix: "&8[&6SrA&8] " - level: 3 - color: light_purple - determiner: a - admin: true - console_only: false - inherit: super_admin - permissions: - - tfm.admin.senior.* - - tfm.admin.telnet.* - - tfm.manage.* - - tfm.manage.ssh - - tfm.manage.telnet - -developer: - name: Developer - abbreviation: Dev - prefix: "&8[&5Dev&8] " - level: 4 - color: dark_purple - determiner: a - admin: true - console_only: false - inherit: senior_admin - -owner: - name: Owner - abbreviation: Owner - prefix: "&8[&9Owner&8] " - level: 4 - color: blue - determiner: the - admin: true - console_only: false - inherit: senior_admin - -executive: - name: Executive - abbreviation: Exec - prefix: "&8[&eExec&8] " - level: 4 - color: yellow - determiner: an - admin: true - console_only: false - inherit: senior_admin \ No newline at end of file From ceebdd4e55564b0f59cbcca2fc672f616329ab56 Mon Sep 17 00:00:00 2001 From: Paldiu Date: Mon, 3 Aug 2026 03:46:01 -0500 Subject: [PATCH 09/48] sqlite fixes --- .../sql/YamlMigrationService.java | 50 +++++++++---- .../sql/adapter/sqlite/SQLiteAdapter.java | 70 ++++++++++++++----- 2 files changed, 88 insertions(+), 32 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/YamlMigrationService.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/YamlMigrationService.java index 14ea799d9..8001d9b28 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/YamlMigrationService.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/YamlMigrationService.java @@ -135,7 +135,8 @@ private void runAllMigrations() throws SQLException *

* The version is recorded even when the domain had no YAML to import, so an install that * never had the file stops re-checking for it. A {@code body} that throws is left - * unrecorded and retried on the next start. + * unrecorded and retried on the next start; the remaining domains still run, since one + * unimportable file should not strand every other domain's data. */ private void runOnce(final MigrationRepository ledger, final Set applied, final String version, final Runnable body) throws SQLException @@ -143,7 +144,17 @@ private void runOnce(final MigrationRepository ledger, final Set applied if (applied.contains(version)) return; - body.run(); + try + { + body.run(); + } + catch (RuntimeException ex) + { + FLog.warning(String.format("YAML import '%s' did not complete, will retry on next start: %s", + version, ex.getMessage())); + return; + } + ledger.markApplied(version); } @@ -219,8 +230,7 @@ private void migrateAdmins() FLog.info("Admin migration complete: " + migrated.get() + " migrated, " + failed.get() + " failed"); - // Backup the old file - backupFile(adminsFile); + finishMigration(failed.get(), adminsFile); } /** @@ -292,8 +302,7 @@ private void migrateBans() FLog.info("Ban migration complete: " + migrated.get() + " migrated, " + failed.get() + " failed"); - // Backup the old file - backupFile(bansFile); + finishMigration(failed.get(), bansFile); } /** @@ -357,8 +366,7 @@ private void migratePermbans() FLog.info("Permban migration complete: " + migrated.get() + " migrated, " + failed.get() + " failed"); - // Backup the old file - backupFile(permbansFile); + finishMigration(failed.get(), permbansFile); } /** @@ -420,7 +428,7 @@ private void migrateRanks() FLog.info("Rank migration complete: " + migrated.get() + " migrated, " + failed.get() + " failed"); - backupFile(ranksFile); + finishMigration(failed.get(), ranksFile); } /** @@ -500,7 +508,7 @@ private void migrateProtectedAreas() FLog.info("Protected area migration complete: " + migrated.get() + " migrated, " + failed.get() + " failed"); - backupFile(areasFile); + finishMigration(failed.get(), areasFile); } /** @@ -619,10 +627,7 @@ private void migratePlayers() FLog.info("Player data migration complete: " + migrated.get() + " migrated, " + failed.get() + " failed"); - for (File file : files) - { - backupFile(file); - } + finishMigration(failed.get(), files); } /** @@ -641,6 +646,23 @@ private UUID generateUuidForAdmin(Admin admin) return UUID.nameUUIDFromBytes(("OfflinePlayer:" + admin.getName().toLowerCase()).getBytes()); } + /** + * Retire the imported YAML now that every entry landed in the database. + *

+ * A domain that lost even one entry keeps its file under the original name and aborts, so + * {@link #runOnce} leaves the version unrecorded and the whole domain is retried on the next + * start rather than silently stranding the data in a {@code .migrated} file. + */ + private void finishMigration(final int failed, final File... sources) + { + if (failed > 0) + throw new IllegalStateException(String.format("%d entr%s could not be imported", + failed, failed == 1 ? "y" : "ies")); + + Stream.of(sources) + .forEach(this::backupFile); + } + /** * Backup a file by renaming it with .migrated extension. */ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java index db06e91f8..74b5fc2e1 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java @@ -7,6 +7,7 @@ import me.totalfreedom.totalfreedommod.sql.adapter.generic.*; import me.totalfreedom.totalfreedommod.util.FLog; +import java.sql.ResultSet; import java.sql.SQLException; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -21,6 +22,13 @@ */ public class SQLiteAdapter extends DatabaseAdapter { + /** + * Constant stand-in for CURRENT_TIMESTAMP, which SQLite will not accept as the default of a + * column added by ALTER TABLE. Rows carrying it are backfilled immediately after the column + * is added. + */ + private static final String EPOCH_TIMESTAMP = "1970-01-01 00:00:00"; + private AdminRepository adminRepository; private BanRepository banRepository; private PermbanRepository permbanRepository; @@ -197,7 +205,7 @@ CREATE TABLE IF NOT EXISTS admins ( // Migration for tables created before custom_rank/updated_at existed. addColumnIfMissing("admins", "custom_rank", "TEXT"); - addColumnIfMissing("admins", "updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"); + addTimestampColumnIfMissing("admins", "updated_at"); // Create indexes statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_admins_username ON admins(username)"); @@ -234,7 +242,7 @@ CREATE TABLE IF NOT EXISTS bans ( ) """; statementHandler.executeUpdate(sql); - addColumnIfMissing("bans", "updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"); + addTimestampColumnIfMissing("bans", "updated_at"); statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_bans_uuid ON bans(uuid)"); statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_bans_username ON bans(username)"); @@ -268,7 +276,7 @@ CREATE TABLE IF NOT EXISTS permbans ( ) """; statementHandler.executeUpdate(sql); - addColumnIfMissing("permbans", "updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"); + addTimestampColumnIfMissing("permbans", "updated_at"); statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_permbans_uuid ON permbans(uuid)"); statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_permbans_username ON permbans(username)"); @@ -302,7 +310,7 @@ CREATE TABLE IF NOT EXISTS strikes ( ) """; statementHandler.executeUpdate(sql); - addColumnIfMissing("strikes", "updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"); + addTimestampColumnIfMissing("strikes", "updated_at"); } private void createDiscordLinksTable() throws SQLException @@ -317,7 +325,7 @@ CREATE TABLE IF NOT EXISTS discord_links ( ) """; statementHandler.executeUpdate(sql); - addColumnIfMissing("discord_links", "updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"); + addTimestampColumnIfMissing("discord_links", "updated_at"); } private void createRanksTable() throws SQLException @@ -338,7 +346,7 @@ CREATE TABLE IF NOT EXISTS ranks ( ) """; statementHandler.executeUpdate(sql); - addColumnIfMissing("ranks", "updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"); + addTimestampColumnIfMissing("ranks", "updated_at"); statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_ranks_level ON ranks(level)"); } @@ -373,7 +381,7 @@ CREATE TABLE IF NOT EXISTS protected_areas ( ) """; statementHandler.executeUpdate(sql); - addColumnIfMissing("protected_areas", "updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"); + addTimestampColumnIfMissing("protected_areas", "updated_at"); statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_protected_areas_name ON protected_areas(name)"); } @@ -387,7 +395,7 @@ CREATE TABLE IF NOT EXISTS saved_flags ( ) """; statementHandler.executeUpdate(sql); - addColumnIfMissing("saved_flags", "updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"); + addTimestampColumnIfMissing("saved_flags", "updated_at"); } private void createPlayersTable() throws SQLException @@ -409,7 +417,7 @@ CREATE TABLE IF NOT EXISTS players ( ) """; statementHandler.executeUpdate(sql); - addColumnIfMissing("players", "updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"); + addTimestampColumnIfMissing("players", "updated_at"); } private void createPlayerIpsTable() throws SQLException @@ -427,19 +435,45 @@ FOREIGN KEY (username) REFERENCES players(username) ON DELETE CASCADE } /** - * Add a column to a table created before that column existed. Ignores the error - * when the column is already present (older SQLite has no ADD COLUMN IF NOT EXISTS). + * Add a column to a table created before that column existed. SQLite has no + * ADD COLUMN IF NOT EXISTS, so presence is checked up front rather than by + * swallowing the resulting error. */ - private void addColumnIfMissing(String table, String column, String definition) + private void addColumnIfMissing(final String table, final String column, final String definition) throws SQLException { - try - { - statementHandler.executeUpdate(String.format("ALTER TABLE %s ADD COLUMN %s %s", table, column, definition)); - } - catch (SQLException ignored) + if (columnExists(table, column)) + return; + + statementHandler.executeUpdate(String.format("ALTER TABLE %s ADD COLUMN %s %s", table, column, definition)); + } + + /** + * Add a missing timestamp column to a table created before that column existed. + * SQLite rejects a non-constant default such as CURRENT_TIMESTAMP in ALTER TABLE ADD COLUMN, + * so the column is added with a constant default and existing rows are then backfilled with + * the current time. + */ + private void addTimestampColumnIfMissing(final String table, final String column) throws SQLException + { + if (columnExists(table, column)) + return; + + statementHandler.executeUpdate(String.format("ALTER TABLE %s ADD COLUMN %s TEXT NOT NULL DEFAULT '%s'", + table, column, EPOCH_TIMESTAMP)); + statementHandler.executeUpdate(String.format("UPDATE %s SET %s = CURRENT_TIMESTAMP", table, column)); + } + + private boolean columnExists(final String table, final String column) throws SQLException + { + try (ResultSet columns = statementHandler.executeQuery(String.format("PRAGMA table_info(%s)", table))) { - // Column already exists. + while (columns.next()) + { + if (column.equalsIgnoreCase(columns.getString("name"))) + return true; + } } + return false; } // ============================================ From 63c96a855ee6bb1638afe0c730a5491862d9a533 Mon Sep 17 00:00:00 2001 From: gamingto12 Date: Mon, 3 Aug 2026 18:47:08 -0400 Subject: [PATCH 10/48] adds sign text + book text to filter --- .../totalfreedommod/TextFilterService.java | 65 ++++++++++++++++--- 1 file changed, 57 insertions(+), 8 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/TextFilterService.java b/src/main/java/me/totalfreedom/totalfreedommod/TextFilterService.java index 770f2ccbc..ddf18549d 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/TextFilterService.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/TextFilterService.java @@ -16,7 +16,10 @@ import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; +import org.bukkit.event.block.SignChangeEvent; import org.bukkit.event.player.PlayerCommandPreprocessEvent; +import org.bukkit.event.player.PlayerEditBookEvent; +import org.bukkit.inventory.meta.BookMeta; public class TextFilterService extends FreedomService { @@ -42,16 +45,14 @@ protected void onStop() @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) public void onAsyncChat(AsyncChatEvent event) { - if (!shouldFilter()) + if (isFilterDisabled()) { return; } final String message = MessageUtils.toPlainText(event.message()); if (!matchesFilter(message)) - { return; - } event.setCancelled(true); Bukkit.getScheduler().runTask(plugin, () -> temporarilyBan(event.getPlayer())); @@ -60,16 +61,64 @@ public void onAsyncChat(AsyncChatEvent event) @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) public void onPlayerCommandPreprocess(PlayerCommandPreprocessEvent event) { - if (!shouldFilter()) - { + if (isFilterDisabled()) return; - } if (!matchesFilter(event.getMessage())) + return; + + event.setCancelled(true); + temporarilyBan(event.getPlayer()); + } + + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onSignEdit(SignChangeEvent event) + { + if (isFilterDisabled()) + return; + + final StringBuilder builder = new StringBuilder(); + + for (int i = 0; i < event.lines().size(); i++) { + if (!builder.isEmpty()) + { + builder.append(Component.newline()); + } + builder.append(event.line(i)); + } + + if (!matchesFilter(builder.toString())) return; + + event.setCancelled(true); + temporarilyBan(event.getPlayer()); + } + + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onBookEdit(PlayerEditBookEvent event) + { + final BookMeta meta = event.getNewBookMeta(); + final StringBuilder builder = new StringBuilder(); + + if (isFilterDisabled()) + return; + + if (meta.hasTitle()) + { + builder.append(MessageUtils.toPlainText(meta.title())); + } + + for (Component page : meta.pages()) + { + if (!builder.isEmpty()) + builder.append(Component.newline()); + builder.append(MessageUtils.toPlainText(page)); } + if (!matchesFilter(builder.toString())) + return; + event.setCancelled(true); temporarilyBan(event.getPlayer()); } @@ -98,9 +147,9 @@ private void reloadFilters() FLog.info("Loaded " + filters.size() + " text filter regex pattern(s)."); } - private boolean shouldFilter() + private boolean isFilterDisabled() { - return ConfigEntry.TEXT_FILTER_ENABLED.getBoolean(true) && !filters.isEmpty(); + return !ConfigEntry.TEXT_FILTER_ENABLED.getBoolean(true) || filters.isEmpty(); } private boolean matchesFilter(String text) From 2decf5885e07c6dc4c71618a0af2c5094c4857a3 Mon Sep 17 00:00:00 2001 From: shrimp Date: Mon, 3 Aug 2026 21:38:24 -0600 Subject: [PATCH 11/48] tab completion update --- .../cmd/CommandCandidates.java | 40 +++++++++++ .../cmd/Command_adminchat.java | 8 +++ .../totalfreedommod/cmd/Command_announce.java | 8 +++ .../totalfreedommod/cmd/Command_ban.java | 6 ++ .../totalfreedommod/cmd/Command_banip.java | 6 ++ .../totalfreedommod/cmd/Command_banname.java | 6 ++ .../totalfreedommod/cmd/Command_chat.java | 8 +++ .../totalfreedommod/cmd/Command_gchat.java | 9 +++ .../totalfreedommod/cmd/Command_gcmd.java | 9 +++ .../totalfreedommod/cmd/Command_kick.java | 8 +++ .../cmd/Command_rankconfig.java | 54 +++++++++++++++ .../totalfreedommod/cmd/Command_rawsay.java | 8 +++ .../totalfreedommod/cmd/Command_realname.java | 9 +++ .../totalfreedommod/cmd/Command_report.java | 14 ++++ .../totalfreedommod/cmd/Command_say.java | 8 +++ .../totalfreedommod/cmd/Command_smite.java | 9 +++ .../totalfreedommod/cmd/Command_stfu.java | 6 ++ .../totalfreedommod/cmd/Command_warn.java | 9 +++ .../totalfreedommod/cmd/Command_wildcard.java | 6 ++ .../totalfreedommod/cmd/NameCandidates.java | 37 ++++++++++ .../cmd/internal/CommandProcessor.java | 68 +++++++++++++++++-- .../cmd/internal/annotation/Completer.java | 46 ++++++++++++- 22 files changed, 373 insertions(+), 9 deletions(-) create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/cmd/CommandCandidates.java diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/CommandCandidates.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/CommandCandidates.java new file mode 100644 index 000000000..8ebb1719a --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/CommandCandidates.java @@ -0,0 +1,40 @@ +package me.totalfreedom.totalfreedommod.cmd; + +import java.util.List; + +import org.bukkit.Server; +import org.bukkit.command.CommandSender; + +/** + * Tab-completion source for arguments that are themselves a command line, as in {@code /wildcard} + * and {@code /gcmd}. + *

+ * Completion is delegated to the server's own command map, so the inner command gets the same + * suggestions it would get if it had been typed directly, arguments included. + */ +final class CommandCandidates +{ + + private CommandCandidates() + { + } + + /** + * Completions for the final word of {@code typed}, an entire inner command line, as seen by + * {@code sender}. + *

+ * The whole line has to be passed in because that is what decides the candidates: the words + * before the cursor are what tells the command map whether a command name or one of that + * command's arguments is being typed. The completions come back covering the final word only, + * which pairs with + * {@link me.totalfreedom.totalfreedommod.cmd.internal.annotation.Completer.Scope#ARGUMENT_TO_WORD}. + * + * @param sender the sender the suggestions are filtered for, not the player the command would + * eventually run as + */ + static List inner(Server server, CommandSender sender, String typed) + { + final List completions = server.getCommandMap().tabComplete(sender, typed); + return completions == null ? List.of() : completions; + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adminchat.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adminchat.java index b5506f155..fe586de5a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adminchat.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adminchat.java @@ -1,5 +1,7 @@ package me.totalfreedom.totalfreedommod.cmd; +import java.util.List; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.player.FPlayer; import me.totalfreedom.totalfreedommod.rank.Rank; @@ -26,6 +28,12 @@ public void toggle(Player sender) msg(sender, "Toggled Admin Chat .", Formatter.booleanChoice("mode", mode)); } + @Completer(value = "", position = 0) + public List completeMessage(CommandSender sender, String partial) + { + return NameCandidates.onlineTyped(server(), partial); + } + @Callback public void sendMessage(CommandSender sender, @Greedy String message) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_announce.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_announce.java index 21a1cf925..397b8f2a9 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_announce.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_announce.java @@ -1,5 +1,7 @@ package me.totalfreedom.totalfreedommod.cmd; +import java.util.List; + import org.bukkit.command.CommandSender; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; @@ -9,6 +11,12 @@ @Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.announce") public class Command_announce extends FCommand { + @Completer(value = "", position = 0) + public List completeContent(CommandSender sender, String partial) + { + return NameCandidates.onlineTyped(server(), partial); + } + @Callback public void broadcast(CommandSender sender, @Greedy String content) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_ban.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_ban.java index 44749343a..62edbb1ff 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_ban.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_ban.java @@ -26,6 +26,12 @@ public List completeTarget(CommandSender sender, String partial) return NameCandidates.online(server(), partial); } + @Completer(value = "", position = 1) + public List completeReason(CommandSender sender, String partial) + { + return NameCandidates.onlineTyped(server(), partial); + } + @Callback public void ban(CommandSender sender, String name, @Greedy String reason, @Switch("s") boolean silent, @Switch("nrb") boolean noRollback) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banip.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banip.java index 50162c33e..163ac6dee 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banip.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banip.java @@ -25,6 +25,12 @@ public void banIps(CommandSender sender, @Resolve(value = "IPs", strategy = "all banIpsWithReason(sender, addressList, null); } + @Completer(value = "", position = 1) + public List completeReason(CommandSender sender, String partial) + { + return NameCandidates.onlineTyped(server(), partial); + } + @Callback public void banIpsWithReason(CommandSender sender, @Resolve(value = "IPs", strategy = "allowPlayers,all") List addressList, @Greedy String reason) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banname.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banname.java index b612dfefb..6a71fc21e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banname.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banname.java @@ -26,6 +26,12 @@ public void banName(CommandSender sender, String name) banNameWithReason(sender, name, null); } + @Completer(value = "", position = 1) + public List completeReason(CommandSender sender, String partial) + { + return NameCandidates.onlineTyped(server(), partial); + } + @Callback public void banNameWithReason(CommandSender sender, String name, @Greedy String reason) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_chat.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_chat.java index 3393e18d7..d9563b22a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_chat.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_chat.java @@ -1,5 +1,7 @@ package me.totalfreedom.totalfreedommod.cmd; +import java.util.List; + import org.bukkit.entity.Player; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; @@ -13,6 +15,12 @@ @Permission(permission = "tfm.player.chat", level = Rank.NON_OP, source = SourceType.ONLY_IN_GAME) public final class Command_chat extends FCommand { + @Completer(value = "", position = 0) + public List completeMessage(final Player player, final String partial) + { + return NameCandidates.onlineTyped(server(), partial); + } + @Callback public void chat(final Player player, final @Greedy String message) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gchat.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gchat.java index 649cbd7af..38d4f753a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gchat.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gchat.java @@ -1,7 +1,10 @@ package me.totalfreedom.totalfreedommod.cmd; +import java.util.List; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Completer; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Greedy; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; import me.totalfreedom.totalfreedommod.rank.Rank; @@ -13,6 +16,12 @@ @Permission(permission = "tfm.admin.gchat", level = Rank.SUPER_ADMIN) public class Command_gchat extends FCommand { + @Completer(value = "", position = 1) + public List completeMessage(CommandSender sender, String partial) + { + return NameCandidates.onlineTyped(server(), partial); + } + @Callback public void sendMessageAsSomeoneElse(CommandSender sender, Player player, @Greedy String message) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gcmd.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gcmd.java index 6364e6079..5dff8cfe9 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gcmd.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gcmd.java @@ -1,7 +1,10 @@ package me.totalfreedom.totalfreedommod.cmd; +import java.util.List; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Completer; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Greedy; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; import me.totalfreedom.totalfreedommod.rank.Rank; @@ -13,6 +16,12 @@ @Permission(permission = "tfm.admin.gcmd", level = Rank.SUPER_ADMIN) public class Command_gcmd extends FCommand { + @Completer(value = "", position = 1, scope = Completer.Scope.ARGUMENT_TO_WORD) + public List completeCommand(CommandSender sender, String partial) + { + return CommandCandidates.inner(server(), sender, partial); + } + @Callback public void runAsOtherPlayer(CommandSender sender, Player player, @Greedy String command) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_kick.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_kick.java index 58484ad0b..9aabca1f2 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_kick.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_kick.java @@ -1,5 +1,7 @@ package me.totalfreedom.totalfreedommod.cmd; +import java.util.List; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.rank.Rank; @@ -18,6 +20,12 @@ public void kickNoReason(CommandSender sender, Player player, @Switch("s") boole kick(sender, player, null, silent); } + @Completer(value = "", position = 1) + public List completeReason(CommandSender sender, String partial) + { + return NameCandidates.onlineTyped(server(), partial); + } + @Callback public void kick(CommandSender sender, Player player, @Greedy String reason, @Switch("s") boolean silent) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rankconfig.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rankconfig.java index 5c7a6caa8..2b037db25 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rankconfig.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rankconfig.java @@ -1,6 +1,9 @@ package me.totalfreedom.totalfreedommod.cmd; +import java.util.ArrayList; +import java.util.Arrays; import java.util.List; +import java.util.stream.Stream; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; @@ -22,6 +25,12 @@ @Permission(permission = "tfm.manage.ranks", level = Rank.SENIOR_ADMIN) public class Command_rankconfig extends FCommand { + private static final List COLOR_NAMES = Stream.concat( + NamedTextColor.NAMES.keys().stream(), + Stream.of("purple", "orange", "grey", "dark_grey", "cyan", "dark_cyan", "pink", "magenta")) + .sorted() + .toList(); + @Callback public void menu(CommandSender sender) { @@ -179,6 +188,51 @@ public List completeSetRank(CommandSender sender, String partial) return rankIdCandidates(partial); } + @Completer(value = "set", position = 2, scope = Completer.Scope.ARGUMENT) + public List completeSetValue(CommandSender sender, String partial, List priorArgs) + { + final Property property = parseProperty(priorArgs.get(1)); + if (property == null) + { + return List.of(); + } + + return switch (property) + { + case COLOR -> FuzzyMatch.filter(COLOR_NAMES, partial); + case ADMIN, CONSOLE -> FuzzyMatch.filter(List.of("true", "false"), partial); + case INHERIT -> FuzzyMatch.filter(inheritCandidates(priorArgs.get(0)), partial); + case REMPERM -> FuzzyMatch.filter(heldPermissions(priorArgs.get(0)), partial); + default -> List.of(); + }; + } + + private List inheritCandidates(String rankId) + { + final List candidates = new ArrayList<>(List.of("none")); + plugin().rm.getCustomRanksSorted() + .stream() + .map(CustomRank::getId) + .filter(id -> !id.equalsIgnoreCase(rankId.trim())) + .forEach(candidates::add); + + return candidates; + } + + private List heldPermissions(String rankId) + { + final CustomRank rank = plugin().rm.getCustomRank(rankId.trim().toLowerCase()); + return rank == null ? List.of() : rank.getPermissions().stream().sorted().toList(); + } + + private static Property parseProperty(String typed) + { + return Arrays.stream(Property.values()) + .filter(property -> property.name().equalsIgnoreCase(typed.trim())) + .findFirst() + .orElse(null); + } + @Callback @Subcommand("setrank") public void setRank(CommandSender sender, Player target, String rank) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rawsay.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rawsay.java index 26434bfeb..26885d476 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rawsay.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rawsay.java @@ -1,5 +1,7 @@ package me.totalfreedom.totalfreedommod.cmd; +import java.util.List; + import org.bukkit.command.CommandSender; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; @@ -10,6 +12,12 @@ @Permission(permission = "tfm.admin.senior.rawsay", level = Rank.SENIOR_ADMIN) public class Command_rawsay extends FCommand { + @Completer(value = "", position = 0) + public List completeMessage(CommandSender sender, String partial) + { + return NameCandidates.onlineTyped(server(), partial); + } + @Callback public void rawsay(CommandSender sender, @Greedy String message) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_realname.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_realname.java index 861282066..16a76f95c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_realname.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_realname.java @@ -1,7 +1,10 @@ package me.totalfreedom.totalfreedommod.cmd; +import java.util.List; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Completer; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Greedy; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; import me.totalfreedom.totalfreedommod.player.PlayerData; @@ -15,6 +18,12 @@ @Permission(level = Rank.OP, permission = "tfm.player.realname") public class Command_realname extends FCommand { + @Completer(value = "", position = 0, scope = Completer.Scope.ARGUMENT) + public List completeNickname(CommandSender sender, String partial) + { + return NameCandidates.onlineNicknames(plugin(), server(), partial); + } + @Callback public void realname(CommandSender sender, @Greedy String nickname) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_report.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_report.java index 2c705e3ce..77825f7e5 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_report.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_report.java @@ -1,5 +1,7 @@ package me.totalfreedom.totalfreedommod.cmd; +import java.util.List; + import me.totalfreedom.totalfreedommod.admin.Admin; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import org.bukkit.OfflinePlayer; @@ -50,6 +52,18 @@ private void doReport(Player player, OfflinePlayer target, String reason) } } + @Completer(value = "", position = 0) + public List completeTarget(Player sender, String partial) + { + return NameCandidates.online(server(), partial); + } + + @Completer(value = "", position = 1) + public List completeReason(Player sender, String partial) + { + return NameCandidates.onlineTyped(server(), partial); + } + @Callback public void report(Player sender, String playerName, @Greedy String reason) { Player player = getPlayer(playerName); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_say.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_say.java index aee009869..434eb3f73 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_say.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_say.java @@ -1,5 +1,7 @@ package me.totalfreedom.totalfreedommod.cmd; +import java.util.List; + import org.bukkit.command.CommandSender; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; @@ -15,6 +17,12 @@ @Permission(permission = "tfm.admin.say", level = Rank.SUPER_ADMIN) public class Command_say extends FCommand { + @Completer(value = "", position = 0) + public List completeMessage(CommandSender sender, String partial) + { + return NameCandidates.onlineTyped(server(), partial); + } + @Callback public void say(CommandSender sender, @Greedy String message) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_smite.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_smite.java index 69ca37ffb..dea6f2e9c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_smite.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_smite.java @@ -1,7 +1,10 @@ package me.totalfreedom.totalfreedommod.cmd; +import java.util.List; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Completer; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Greedy; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; import me.totalfreedom.totalfreedommod.config.ConfigEntry; @@ -24,6 +27,12 @@ public void smiteNoReason(CommandSender sender, Player player) smite(sender, player, null); } + @Completer(value = "", position = 1) + public List completeReason(CommandSender sender, String partial) + { + return NameCandidates.onlineTyped(server(), partial); + } + @Callback public void smite(CommandSender sender, Player player, @Greedy String reason) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_stfu.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_stfu.java index 9c5c7452f..0dc75d30a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_stfu.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_stfu.java @@ -83,6 +83,12 @@ public void mutePlayer(CommandSender sender, Player player) mutePlayerWithReason(sender, player, null); } + @Completer(value = "", position = 1) + public List completeReason(CommandSender sender, String partial) + { + return NameCandidates.onlineTyped(server(), partial); + } + @Callback public void mutePlayerWithReason(CommandSender sender, Player player, @Greedy String reason) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_warn.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_warn.java index 8d53a0439..0f8b7d752 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_warn.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_warn.java @@ -1,7 +1,10 @@ package me.totalfreedom.totalfreedommod.cmd; +import java.util.List; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Completer; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Greedy; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; import me.totalfreedom.totalfreedommod.config.ConfigEntry; @@ -15,6 +18,12 @@ @Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.warn") public class Command_warn extends FCommand { + @Completer(value = "", position = 1) + public List completeReason(CommandSender sender, String partial) + { + return NameCandidates.onlineTyped(server(), partial); + } + @Callback public void warnPlayer(CommandSender sender, Player player, @Greedy String reason) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_wildcard.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_wildcard.java index ca1deaf97..c9aed2fca 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_wildcard.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_wildcard.java @@ -25,6 +25,12 @@ public class Command_wildcard extends FCommand "crash" ); + @Completer(value = "", position = 0, scope = Completer.Scope.ARGUMENT_TO_WORD) + public List completeCommand(CommandSender sender, String partial) + { + return CommandCandidates.inner(server(), sender, partial); + } + @Callback public void wildcard(CommandSender sender, @Greedy String command) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/NameCandidates.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/NameCandidates.java index 5fc5857e3..9984b1ebb 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/NameCandidates.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/NameCandidates.java @@ -5,6 +5,7 @@ import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.banning.Ban; import me.totalfreedom.totalfreedommod.cmd.internal.FuzzyMatch; +import me.totalfreedom.totalfreedommod.util.AdventureUtil; import org.bukkit.Bukkit; import org.bukkit.OfflinePlayer; @@ -23,10 +24,28 @@ final class NameCandidates { + private static final int MIN_TYPED_PREFIX = 3; + private NameCandidates() { } + static List onlineTyped(Server server, String partial) + { + if (partial.length() < MIN_TYPED_PREFIX) + { + return List.of(); + } + + final String prefix = partial.toLowerCase(); + return server.getOnlinePlayers() + .stream() + .map(Player::getName) + .filter(name -> name.toLowerCase().startsWith(prefix)) + .sorted() + .toList(); + } + static List online(Server server, String partial) { return FuzzyMatch.filter( @@ -38,6 +57,24 @@ static List online(Server server, String partial) partial); } + /** + * Plain-text nicknames of the online players who have one, for arguments that are matched + * against nicknames rather than usernames. + */ + static List onlineNicknames(TotalFreedomMod plugin, Server server, String partial) + { + return FuzzyMatch.filter( + server.getOnlinePlayers() + .stream() + .map(player -> plugin.pl.getData(player).getNickname()) + .filter(nickname -> nickname != null) + .map(nickname -> AdventureUtil.componentToPlainText(nickname).trim()) + .filter(nickname -> !nickname.isEmpty()) + .sorted() + .toList(), + partial); + } + static List banned(TotalFreedomMod plugin, String partial) { return FuzzyMatch.filter( diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/CommandProcessor.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/CommandProcessor.java index ed389ddde..5e9d76ee2 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/CommandProcessor.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/CommandProcessor.java @@ -7,6 +7,7 @@ import com.mojang.brigadier.builder.RequiredArgumentBuilder; import com.mojang.brigadier.context.CommandContext; import com.mojang.brigadier.suggestion.SuggestionProvider; +import com.mojang.brigadier.suggestion.SuggestionsBuilder; import io.papermc.paper.command.brigadier.Commands; import io.papermc.paper.command.brigadier.CommandSourceStack; import io.papermc.paper.command.brigadier.argument.ArgumentTypes; @@ -312,7 +313,7 @@ private LiteralArgumentBuilder buildBranch(SubcommandNode no private static boolean isValidCompleterSignature(Method method) { Class[] types = method.getParameterTypes(); - return types.length == 2 + return (types.length == 2 || (types.length == 3 && types[2] == List.class)) && ArgumentResolver.isSenderType(types[0]) && types[1] == String.class && method.getReturnType() == List.class; @@ -444,8 +445,17 @@ private int sendUsage(CommandContext ctx) return 1; } - private SuggestionProvider buildSuggestionProvider(Method completerMethod) + /** + * @param priorArgNames argument-node names of the positional parameters ahead of this one, in + * order, supplied to completers that declare the third parameter + */ + private SuggestionProvider buildSuggestionProvider(Method completerMethod, boolean greedy, List priorArgNames) { + Completer.Scope scope = completerMethod.getAnnotation(Completer.class).scope(); + boolean replacesWord = greedy && scope != Completer.Scope.ARGUMENT; + boolean seesWholeArgument = greedy && scope != Completer.Scope.WORD; + boolean wantsPriorArgs = completerMethod.getParameterCount() == 3; + return (ctx, builder) -> { CommandSender sender = ctx.getSource().getSender(); @@ -453,21 +463,67 @@ private SuggestionProvider buildSuggestionProvider(Method co { return builder.buildFuture(); } + + SuggestionsBuilder target = currentWord(builder, replacesWord); + String typed = seesWholeArgument ? builder.getRemaining() : target.getRemaining(); try { - List suggestions = (List) completerMethod.invoke(command, sender, builder.getRemaining()); - suggestions.forEach(builder::suggest); + List suggestions = wantsPriorArgs + ? (List) completerMethod.invoke(command, sender, typed, priorArgs(ctx, priorArgNames)) + : (List) completerMethod.invoke(command, sender, typed); + suggestions.forEach(target::suggest); } catch (Exception e) { Throwable cause = e.getCause() != null ? e.getCause() : e; FLog.severe(String.format("Error in completer %s: \n%s", completerMethod.getName(), ExceptionUtils.getRootCauseMessage(cause))); } - return builder.buildFuture(); + return target.buildFuture(); }; } + private static SuggestionsBuilder currentWord(SuggestionsBuilder builder, boolean greedy) + { + if (!greedy) + { + return builder; + } + + int lastSpace = builder.getRemaining().lastIndexOf(' '); + return lastSpace < 0 ? builder : builder.createOffset(builder.getStart() + lastSpace + 1); + } + /** + * Text typed for the arguments named in {@code names}, in that order, for a completer that + * asked to see them. + *

+ * Values are read back off the parsed nodes rather than out of the resolved arguments, so a + * completer sees the raw input even for arguments whose type would fail to resolve it. A name + * that has not been parsed yet yields and empty string, which happens only when the client asks + * about a position it has not reached. + */ + /** + * Argument-node names of the positional parameters ahead of {@code position}, in order + */ + private static List argumentNames(List positionalParams, int position) + { + return positionalParams.subList(0, position) + .stream() + .map(Parameter::getName) + .toList(); + } + + private static List priorArgs(CommandContext ctx, List names) + { + Map typed = new HashMap<>(); + ctx.getNodes().forEach(parsed -> typed.put(parsed.getNode().getName(), parsed.getRange().get(ctx.getInput()))); + + return names.stream() + .map(name -> typed.getOrDefault(name,"")) + .toList(); + } + + /** * Default suggester for an argument with no explicit {@link Completer}: fuzzy-matches the partial * input against {@code candidates}, falling back to the enum's constant names when the parameter * is enum-typed and no candidates were produced. @@ -696,7 +752,7 @@ private void attachSwitchLevel( Method completer = completers.get(new CompleterKey(subPath, position)); Supplier> candidates = candidatesFor(param); if (completer != null) { - arg.suggests(buildSuggestionProvider(completer)); + arg.suggests(buildSuggestionProvider(completer, greedy, argumentNames(positionalParams, position))); } else if (candidates != null || type.isEnum()) { arg.suggests(buildCandidateSuggestionProvider(candidates, type)); } else if (ArgumentResolver.isPlayerArgType(type)) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Completer.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Completer.java index 049a54430..b35b5eb91 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Completer.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Completer.java @@ -15,9 +15,13 @@ * parameters: the sender and any {@link Switch}-annotated parameters are excluded, since switches * become literal branches rather than argument nodes. *

- * The annotated method must return {@code List} and accept exactly two parameters: - * the same sender type as the handler, followed by the partially-typed input ({@code String}). - * The returned list is used as-is. Consider {@link me.totalfreedom.totalfreedommod.cmd.internal.FuzzyMatch#filter FuzzyMatch#filter(List, String)} + * The annotated method must return {@code List} and accept the same sender type as the + * handler, followed by the partially-typed input ({@code String}), and optionally a third + * {@code List} parameter receiving the text already typed for the preceding positional + * arguments (see {@link #position()} for what counts as one). That third parameter is how a + * completer whose candidates depend on an earlier argument gets at it: {@code /rankconfig set + * } completes {@code value} differently per {@code property}. + * The returned list is used as is. Consider {@link me.totalfreedom.totalfreedommod.cmd.internal.FuzzyMatch#filter FuzzyMatch#filter(List, String)} * for subsequence fuzzy matching against a candidate list, which also happens to be automatically applied to enum-typed arguments with no {@code @Completer}. */ @Retention(RetentionPolicy.RUNTIME) @@ -27,4 +31,40 @@ { String value(); int position(); + + /** + * What a completer is shown and what its suggestions overwrite, for an argument that holds the + * rest of the line. + *

+ * The two are worth choosing separately because a suggestion is displayed as the text it would + * replace: widening the replacement to the whole argument also makes the popup spell out the + * whole argument, which ready badly when only the last word is really being completed. + */ + enum Scope + { + /** + * The word under the cursor, replacing just that word. Right for free text that merely + * mentions completable things, e.g. a chat message naming a player. + */ + WORD, + + /** + * The whole argument, replacing all of it. Right when the argument is a single value that + * happens to tolerate spaces, e.g. a nickname. + */ + ARGUMENT, + + /** + * The whole argument, replacing only its final word. Right when the argument is itself + * structured, e.g. a command line whose completions depend on the words before the one + * being typed. + */ + ARGUMENT_TO_WORD + } + + /** + * How much of a {@link Greedy} argument the completer works on. Ignored for non-greedy + * arguments, which are one word wide already. + */ + Scope scope() default Scope.WORD; } From 4349b018fc0420fed8150bdbb005d0e3b5af401c Mon Sep 17 00:00:00 2001 From: shrimp Date: Tue, 4 Aug 2026 09:48:18 -0600 Subject: [PATCH 12/48] Fix SQLite login deadlock in findByUsername --- .../adapter/generic/GenericPlayerRepository.java | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java index 0fe50038e..d3e568523 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java @@ -147,18 +147,24 @@ public Map loadAll() throws SQLException @Override public PlayerData findByUsername(String username) throws SQLException { + PlayerData data = null; String sql = String.format("SELECT %s FROM %s WHERE %s = ?", selectColumns, tblPlayers, colUsername); try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); ResultSet rs = stmt.executeQuery()) { if (rs.next()) { - PlayerData data = loadPlayerFromRow(rs); - getIps(data.getUsername()).forEach(data::addIp); - return data; + data = loadPlayerFromRow(rs); } } - return null; + + if (data == null) + { + return null; + } + + getIps(data.getUsername()).forEach(data::addIp); + return data; } @Override From 00b3eb48e73f8bcf38173aa5fc42a56c0738c82c Mon Sep 17 00:00:00 2001 From: shrimp Date: Tue, 4 Aug 2026 10:09:05 -0600 Subject: [PATCH 13/48] Fix rank lookup deadlock and updated_at migration on SQLite --- .../generic/GenericRankRepository.java | 14 +++-- .../sql/adapter/sqlite/SQLiteAdapter.java | 54 ++++++++++++++----- 2 files changed, 51 insertions(+), 17 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericRankRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericRankRepository.java index ff67877b1..2c34ede37 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericRankRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericRankRepository.java @@ -140,18 +140,24 @@ public Map loadAll() throws SQLException @Override public CustomRank findById(String id) throws SQLException { + CustomRank rank = null; String sql = String.format("SELECT %s FROM %s WHERE %s = ?", selectColumns, tblRanks, colId); try (PreparedStatement stmt = statementHandler.prepareStatement(sql, id); ResultSet rs = stmt.executeQuery()) { if (rs.next()) { - CustomRank rank = loadRankFromRow(rs); - getPermissions(rank.getId()).forEach(rank::addPermission); - return rank; + rank = loadRankFromRow(rs); } } - return null; + + if (rank == null) + { + return null; + } + + getPermissions(rank.getId()).forEach(rank::addPermission); + return rank; } @Override diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java index db06e91f8..a435d56f8 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java @@ -8,6 +8,7 @@ import me.totalfreedom.totalfreedommod.util.FLog; import java.sql.SQLException; +import java.util.Locale; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -197,7 +198,7 @@ CREATE TABLE IF NOT EXISTS admins ( // Migration for tables created before custom_rank/updated_at existed. addColumnIfMissing("admins", "custom_rank", "TEXT"); - addColumnIfMissing("admins", "updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"); + addTimeStampColumnIfMissing("admins", "updated_at"); // Create indexes statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_admins_username ON admins(username)"); @@ -234,7 +235,7 @@ CREATE TABLE IF NOT EXISTS bans ( ) """; statementHandler.executeUpdate(sql); - addColumnIfMissing("bans", "updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"); + addTimeStampColumnIfMissing("bans", "updated_at"); statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_bans_uuid ON bans(uuid)"); statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_bans_username ON bans(username)"); @@ -268,7 +269,7 @@ CREATE TABLE IF NOT EXISTS permbans ( ) """; statementHandler.executeUpdate(sql); - addColumnIfMissing("permbans", "updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"); + addTimeStampColumnIfMissing("permbans", "updated_at"); statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_permbans_uuid ON permbans(uuid)"); statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_permbans_username ON permbans(username)"); @@ -302,7 +303,7 @@ CREATE TABLE IF NOT EXISTS strikes ( ) """; statementHandler.executeUpdate(sql); - addColumnIfMissing("strikes", "updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"); + addTimeStampColumnIfMissing("strikes", "updated_at"); } private void createDiscordLinksTable() throws SQLException @@ -317,7 +318,7 @@ CREATE TABLE IF NOT EXISTS discord_links ( ) """; statementHandler.executeUpdate(sql); - addColumnIfMissing("discord_links", "updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"); + addTimeStampColumnIfMissing("discord_links", "updated_at"); } private void createRanksTable() throws SQLException @@ -338,7 +339,7 @@ CREATE TABLE IF NOT EXISTS ranks ( ) """; statementHandler.executeUpdate(sql); - addColumnIfMissing("ranks", "updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"); + addTimeStampColumnIfMissing("ranks", "updated_at"); statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_ranks_level ON ranks(level)"); } @@ -373,7 +374,7 @@ CREATE TABLE IF NOT EXISTS protected_areas ( ) """; statementHandler.executeUpdate(sql); - addColumnIfMissing("protected_areas", "updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"); + addTimeStampColumnIfMissing("protected_areas", "updated_at"); statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_protected_areas_name ON protected_areas(name)"); } @@ -387,7 +388,7 @@ CREATE TABLE IF NOT EXISTS saved_flags ( ) """; statementHandler.executeUpdate(sql); - addColumnIfMissing("saved_flags", "updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"); + addTimeStampColumnIfMissing("saved_flags", "updated_at"); } private void createPlayersTable() throws SQLException @@ -409,7 +410,7 @@ CREATE TABLE IF NOT EXISTS players ( ) """; statementHandler.executeUpdate(sql); - addColumnIfMissing("players", "updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"); + addTimeStampColumnIfMissing("players", "updated_at"); } private void createPlayerIpsTable() throws SQLException @@ -428,20 +429,47 @@ FOREIGN KEY (username) REFERENCES players(username) ON DELETE CASCADE /** * Add a column to a table created before that column existed. Ignores the error - * when the column is already present (older SQLite has no ADD COLUMN IF NOT EXISTS). + * when the column is already present (older SQLite has no ADD COLUMN IF NOT EXISTS), + * and reports anything else rather than leaving the table unmigrated. + * + * @return whether the column was actually added */ - private void addColumnIfMissing(String table, String column, String definition) + private boolean addColumnIfMissing(String table, String column, String definition) { try { statementHandler.executeUpdate(String.format("ALTER TABLE %s ADD COLUMN %s %s", table, column, definition)); + return true; } - catch (SQLException ignored) + catch (SQLException ex) { - // Column already exists. + final String message = ex.getMessage() != null ? ex.getMessage().toLowerCase(Locale.ROOT) : ""; + if (!message.contains("duplicate column name")) + { + FLog.warning(String.format("Could not add column %s to %s: %s", column, table, ex.getMessage())); + } + return false; } } + /** + * Add a timestamp column to a table created before that column existed. + *

+ * SQLite rejects ADD COLUMN with a non-constant default such as CURRENT_TIMESTAMP, and + * rejects NOT NULL without a default, so the column is added nullable and backfilled. + * Tables created fresh still declare it NOT NULL DEFAULT CURRENT_TIMESTAMP. + */ + private void addTimeStampColumnIfMissing(String table, String column) throws SQLException + { + if (!addColumnIfMissing(table, column, "TEXT")) + { + return; + } + + statementHandler.executeUpdate(String.format( + "UPDATE %s SET %s = CURRENT_TIMESTAMP WHERE %s IS NULL", table, column, column)); + } + // ============================================ // Repository Getters // ============================================ From 16b74c16da8e502ac17929dfa655239ec0e44819 Mon Sep 17 00:00:00 2001 From: shrimp Date: Tue, 4 Aug 2026 10:49:13 -0600 Subject: [PATCH 14/48] Restore SQLiteAdapter and return Optional from findByUsername --- .../totalfreedommod/player/PlayerList.java | 22 +++---- .../sql/adapter/PlayerRepository.java | 3 +- .../generic/GenericPlayerRepository.java | 6 +- .../sql/adapter/sqlite/SQLiteAdapter.java | 63 +++++++++---------- 4 files changed, 45 insertions(+), 49 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerList.java b/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerList.java index 4389f4ef0..8cb3b7b44 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerList.java @@ -270,18 +270,16 @@ private PlayerData loadFromSql(String username) PlayerRepository repo = plugin.dm.getPlayerRepository(); reconcileFromJsonIfNewer(username, repo); - PlayerData data = repo.findByUsername(username); - if (data == null) - { - return null; - } - - if (Bukkit.getPlayerExact(data.getUsername()) != null) - { - dataMap.put(data.getUsername().toLowerCase(), data); - } - - return data; + return repo.findByUsername(username) + .map(data -> + { + if (Bukkit.getPlayerExact(data.getUsername()) != null) + { + dataMap.put(data.getUsername().toLowerCase(), data); + } + return data; + }) + .orElse(null); } catch (Exception ex) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/PlayerRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/PlayerRepository.java index 6724d4cc3..78046e996 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/PlayerRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/PlayerRepository.java @@ -5,6 +5,7 @@ import java.sql.SQLException; import java.util.List; import java.util.Map; +import java.util.Optional; import reactor.core.publisher.Mono; @@ -27,7 +28,7 @@ public interface PlayerRepository Map loadAll() throws SQLException; - PlayerData findByUsername(String username) throws SQLException; + Optional findByUsername(String username) throws SQLException; boolean exists(String username) throws SQLException; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java index d3e568523..b25eb5834 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java @@ -145,7 +145,7 @@ public Map loadAll() throws SQLException } @Override - public PlayerData findByUsername(String username) throws SQLException + public Optional findByUsername(String username) throws SQLException { PlayerData data = null; String sql = String.format("SELECT %s FROM %s WHERE %s = ?", selectColumns, tblPlayers, colUsername); @@ -160,11 +160,11 @@ public PlayerData findByUsername(String username) throws SQLException if (data == null) { - return null; + return Optional.empty(); } getIps(data.getUsername()).forEach(data::addIp); - return data; + return Optional.of(data); } @Override diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java index b5dc88219..74b5fc2e1 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java @@ -9,7 +9,6 @@ import java.sql.ResultSet; import java.sql.SQLException; -import java.util.Locale; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -436,47 +435,45 @@ FOREIGN KEY (username) REFERENCES players(username) ON DELETE CASCADE } /** - * Add a column to a table created before that column existed. Ignores the error - * when the column is already present (older SQLite has no ADD COLUMN IF NOT EXISTS), - * and reports anything else rather than leaving the table unmigrated. - * - * @return whether the column was actually added + * Add a column to a table created before that column existed. SQLite has no + * ADD COLUMN IF NOT EXISTS, so presence is checked up front rather than by + * swallowing the resulting error. */ - private boolean addColumnIfMissing(String table, String column, String definition) + private void addColumnIfMissing(final String table, final String column, final String definition) throws SQLException { - try - { - statementHandler.executeUpdate(String.format("ALTER TABLE %s ADD COLUMN %s %s", table, column, definition)); - return true; - } - catch (SQLException ex) - { - final String message = ex.getMessage() != null ? ex.getMessage().toLowerCase(Locale.ROOT) : ""; - if (!message.contains("duplicate column name")) - { - FLog.warning(String.format("Could not add column %s to %s: %s", column, table, ex.getMessage())); - } - return false; - } - return false; + if (columnExists(table, column)) + return; + + statementHandler.executeUpdate(String.format("ALTER TABLE %s ADD COLUMN %s %s", table, column, definition)); } /** - * Add a timestamp column to a table created before that column existed. - *

- * SQLite rejects ADD COLUMN with a non-constant default such as CURRENT_TIMESTAMP, and - * rejects NOT NULL without a default, so the column is added nullable and backfilled. - * Tables created fresh still declare it NOT NULL DEFAULT CURRENT_TIMESTAMP. + * Add a missing timestamp column to a table created before that column existed. + * SQLite rejects a non-constant default such as CURRENT_TIMESTAMP in ALTER TABLE ADD COLUMN, + * so the column is added with a constant default and existing rows are then backfilled with + * the current time. */ - private void addTimeStampColumnIfMissing(String table, String column) throws SQLException + private void addTimestampColumnIfMissing(final String table, final String column) throws SQLException { - if (!addColumnIfMissing(table, column, "TEXT")) - { + if (columnExists(table, column)) return; - } - statementHandler.executeUpdate(String.format( - "UPDATE %s SET %s = CURRENT_TIMESTAMP WHERE %s IS NULL", table, column, column)); + statementHandler.executeUpdate(String.format("ALTER TABLE %s ADD COLUMN %s TEXT NOT NULL DEFAULT '%s'", + table, column, EPOCH_TIMESTAMP)); + statementHandler.executeUpdate(String.format("UPDATE %s SET %s = CURRENT_TIMESTAMP", table, column)); + } + + private boolean columnExists(final String table, final String column) throws SQLException + { + try (ResultSet columns = statementHandler.executeQuery(String.format("PRAGMA table_info(%s)", table))) + { + while (columns.next()) + { + if (column.equalsIgnoreCase(columns.getString("name"))) + return true; + } + } + return false; } // ============================================ From bd314b0dd4172c3612b89faf0351353c42ea55b4 Mon Sep 17 00:00:00 2001 From: Paldiu Date: Tue, 4 Aug 2026 13:05:34 -0500 Subject: [PATCH 15/48] jesus christ i forgot to commit while working :( --- .../totalfreedommod/ChatManager.java | 37 +- .../totalfreedommod/CommandSpy.java | 5 +- .../totalfreedommod/ConfigConverter.java | 139 +++- .../totalfreedommod/JoinLeaveMessages.java | 7 +- .../totalfreedommod/TotalFreedomMod.java | 6 + .../totalfreedommod/admin/Admin.java | 55 +- .../totalfreedommod/admin/AdminList.java | 41 +- .../blocking/command/CommandBlockerRank.java | 3 +- .../cmd/Command_adminchat.java | 5 +- .../cmd/Command_admininfo.java | 3 +- .../cmd/Command_adminworld.java | 11 +- .../cmd/Command_adventure.java | 5 +- .../totalfreedommod/cmd/Command_aeclear.java | 3 +- .../totalfreedommod/cmd/Command_announce.java | 3 +- .../cmd/Command_autoclear.java | 3 +- .../totalfreedommod/cmd/Command_autotp.java | 3 +- .../totalfreedommod/cmd/Command_ban.java | 3 +- .../totalfreedommod/cmd/Command_banip.java | 3 +- .../totalfreedommod/cmd/Command_banlist.java | 3 +- .../totalfreedommod/cmd/Command_banname.java | 3 +- .../totalfreedommod/cmd/Command_blockcmd.java | 3 +- .../totalfreedommod/cmd/Command_cage.java | 3 +- .../totalfreedommod/cmd/Command_cake.java | 3 +- .../totalfreedommod/cmd/Command_chat.java | 3 +- .../cmd/Command_cleanchat.java | 3 +- .../totalfreedommod/cmd/Command_cmdspy.java | 3 +- .../cmd/Command_consolesay.java | 3 +- .../totalfreedommod/cmd/Command_cookie.java | 3 +- .../totalfreedommod/cmd/Command_crash.java | 3 +- .../totalfreedommod/cmd/Command_creative.java | 5 +- .../totalfreedommod/cmd/Command_deafen.java | 3 +- .../totalfreedommod/cmd/Command_deop.java | 3 +- .../cmd/Command_disguisetoggle.java | 3 +- .../totalfreedommod/cmd/Command_doom.java | 3 +- .../cmd/Command_entitywipe.java | 3 +- .../totalfreedommod/cmd/Command_expel.java | 3 +- .../totalfreedommod/cmd/Command_findip.java | 3 +- .../cmd/Command_flatlands.java | 3 +- .../totalfreedommod/cmd/Command_freeze.java | 3 +- .../totalfreedommod/cmd/Command_fuckoff.java | 3 +- .../totalfreedommod/cmd/Command_gchat.java | 3 +- .../totalfreedommod/cmd/Command_gcmd.java | 5 +- .../totalfreedommod/cmd/Command_invis.java | 3 +- .../cmd/Command_joinmessages.java | 3 +- .../totalfreedommod/cmd/Command_jumppads.java | 3 +- .../totalfreedommod/cmd/Command_kick.java | 3 +- .../totalfreedommod/cmd/Command_landmine.java | 3 +- .../totalfreedommod/cmd/Command_link.java | 3 +- .../totalfreedommod/cmd/Command_list.java | 5 +- .../cmd/Command_localspawn.java | 3 +- .../totalfreedommod/cmd/Command_lockup.java | 3 +- .../cmd/Command_moblimiter.java | 3 +- .../totalfreedommod/cmd/Command_mobpurge.java | 3 +- .../totalfreedommod/cmd/Command_mp44.java | 3 +- .../totalfreedommod/cmd/Command_myadmin.java | 3 +- .../totalfreedommod/cmd/Command_nickname.java | 8 +- .../totalfreedommod/cmd/Command_nicknyan.java | 3 +- .../totalfreedommod/cmd/Command_orbit.java | 3 +- .../totalfreedommod/cmd/Command_permban.java | 3 +- .../cmd/Command_permbanlist.java | 3 +- .../cmd/Command_plugincontrol.java | 3 +- .../cmd/Command_potionspy.java | 3 +- .../totalfreedommod/cmd/Command_premium.java | 3 +- .../cmd/Command_protectarea.java | 7 +- .../totalfreedommod/cmd/Command_purgeall.java | 3 +- .../totalfreedommod/cmd/Command_radar.java | 3 +- .../totalfreedommod/cmd/Command_rank.java | 12 +- .../cmd/Command_rankconfig.java | 15 +- .../totalfreedommod/cmd/Command_rawsay.java | 3 +- .../totalfreedommod/cmd/Command_realname.java | 3 +- .../totalfreedommod/cmd/Command_ro.java | 3 +- .../totalfreedommod/cmd/Command_saconfig.java | 155 ++-- .../totalfreedommod/cmd/Command_say.java | 3 +- .../totalfreedommod/cmd/Command_setspawn.java | 3 +- .../totalfreedommod/cmd/Command_settings.java | 3 +- .../totalfreedommod/cmd/Command_smite.java | 3 +- .../totalfreedommod/cmd/Command_spawn.java | 3 +- .../totalfreedommod/cmd/Command_spawnmob.java | 3 +- .../cmd/Command_sqlstatus.java | 3 +- .../totalfreedommod/cmd/Command_sshtotp.java | 3 +- .../totalfreedommod/cmd/Command_stfu.java | 3 +- .../totalfreedommod/cmd/Command_stop.java | 3 +- .../totalfreedommod/cmd/Command_strikes.java | 3 +- .../totalfreedommod/cmd/Command_survival.java | 5 +- .../totalfreedommod/cmd/Command_tag.java | 6 - .../totalfreedommod/cmd/Command_tempban.java | 3 +- .../totalfreedommod/cmd/Command_title.java | 192 +++++ .../cmd/Command_totalfreedommod.java | 5 +- .../totalfreedommod/cmd/Command_trail.java | 3 +- .../totalfreedommod/cmd/Command_unban.java | 3 +- .../totalfreedommod/cmd/Command_unbanip.java | 3 +- .../cmd/Command_undisguiseall.java | 3 +- .../totalfreedommod/cmd/Command_warn.java | 3 +- .../cmd/Command_whitelist.java | 15 +- .../totalfreedommod/cmd/Command_whohas.java | 3 +- .../totalfreedommod/cmd/Command_wildcard.java | 3 +- .../cmd/Command_wipeflatlands.java | 3 +- .../cmd/Command_wipeuserdata.java | 3 +- .../totalfreedommod/cmd/FCommand.java | 10 - .../cmd/internal/CommandProcessor.java | 2 +- .../cmd/internal/PermissionGate.java | 192 ++--- .../cmd/internal/annotation/Permission.java | 22 +- .../discord/DiscordCommands.java | 2 +- .../{rank => display}/Displayable.java | 2 +- .../httpd/module/Module_help.java | 39 +- .../httpd/module/Module_players.java | 15 +- .../totalfreedommod/player/PlayerData.java | 58 ++ .../rank/ConsoleSenderRegistry.java | 214 +++-- .../totalfreedommod/rank/CustomRank.java | 93 ++- .../totalfreedommod/rank/PermissionTrie.java | 173 ++++ .../totalfreedommod/rank/Rank.java | 176 ----- .../totalfreedommod/rank/RankManager.java | 741 ++++-------------- .../totalfreedommod/rank/RankRegistry.java | 304 +++++++ .../totalfreedommod/rank/RankResolver.java | 35 + .../totalfreedommod/rank/RankRole.java | 81 ++ .../totalfreedommod/sql/FreedomDatabase.java | 10 + .../sql/adapter/DatabaseAdapter.java | 5 + .../sql/adapter/TitleRepository.java | 54 ++ .../generic/GenericAdminRepository.java | 29 +- .../generic/GenericPlayerRepository.java | 37 +- .../generic/GenericRankRepository.java | 52 +- .../generic/GenericTitleRepository.java | 304 +++++++ .../sql/adapter/mysql/MySQLAdapter.java | 49 +- .../adapter/postgresql/PostgreSQLAdapter.java | 48 +- .../sql/adapter/sqlite/SQLiteAdapter.java | 49 +- .../totalfreedommod/title/Title.java | 376 +++++++++ .../totalfreedommod/title/TitleManager.java | 564 +++++++++++++ .../totalfreedommod/util/PlayerListUtil.java | 13 +- .../vault/PermissionService.java | 29 +- .../totalfreedommod/world/AdminWorld.java | 12 +- src/main/resources/ranks.json | 72 +- src/main/resources/titles.json | 44 ++ 132 files changed, 3307 insertions(+), 1570 deletions(-) create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_title.java rename src/main/java/me/totalfreedom/totalfreedommod/{rank => display}/Displayable.java (94%) create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/rank/PermissionTrie.java delete mode 100644 src/main/java/me/totalfreedom/totalfreedommod/rank/Rank.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/rank/RankRegistry.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/rank/RankResolver.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/rank/RankRole.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/TitleRepository.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericTitleRepository.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/title/Title.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/title/TitleManager.java create mode 100644 src/main/resources/titles.json diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ChatManager.java b/src/main/java/me/totalfreedom/totalfreedommod/ChatManager.java index 47d495c70..ac991f737 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ChatManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ChatManager.java @@ -1,33 +1,33 @@ package me.totalfreedom.totalfreedommod; import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.display.Displayable; import me.totalfreedom.totalfreedommod.player.FPlayer; import me.totalfreedom.totalfreedommod.player.PlayerData; import me.totalfreedom.totalfreedommod.util.AdventureUtil; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FTask; -import me.totalfreedom.totalfreedommod.util.FSync; -import me.totalfreedom.totalfreedommod.util.ChatMentionUtil; -import me.totalfreedom.totalfreedommod.util.FUtil; +import me.totalfreedom.totalfreedommod.util.*; import me.totalfreedom.totalfreedommod.vault.VaultProviderRegistry; import static me.totalfreedom.totalfreedommod.util.FUtil.playerMsg; + import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.event.HoverEvent; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.serializer.ansi.ANSIComponentSerializer; import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; + import org.bukkit.Bukkit; import org.bukkit.OfflinePlayer; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; -import io.papermc.paper.event.player.AsyncChatEvent; -import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.server.PluginEnableEvent; import org.bukkit.plugin.Plugin; +import io.papermc.paper.event.player.AsyncChatEvent; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; + public class ChatManager extends FreedomService { // The maximum message length that the Java Minecraft client can currently handle. @@ -329,32 +329,13 @@ private Component getPlayerCustomTag(Player player) * Gets the configured prefix for a display rank/title. * Returns null if not configured (will use default). */ - private String getConfigPrefix(me.totalfreedom.totalfreedommod.rank.Displayable display) { + private String getConfigPrefix(Displayable display) { if (display instanceof me.totalfreedom.totalfreedommod.rank.CustomRank) { me.totalfreedom.totalfreedommod.rank.CustomRank custom = (me.totalfreedom.totalfreedommod.rank.CustomRank) display; if (custom.getPrefix() != null && !custom.getPrefix().isEmpty()) { return custom.getPrefix(); } } - if (display instanceof me.totalfreedom.totalfreedommod.rank.Rank) { - me.totalfreedom.totalfreedommod.rank.Rank rank = (me.totalfreedom.totalfreedommod.rank.Rank) display; - switch (rank) { - case IMPOSTOR: - return ConfigEntry.VAULT_PREFIX_IMPOSTOR.getString(); - case NON_OP: - return ConfigEntry.VAULT_PREFIX_NON_OP.getString(); - case OP: - return ConfigEntry.VAULT_PREFIX_OP.getString(); - case SUPER_ADMIN: - return ConfigEntry.VAULT_PREFIX_SUPER_ADMIN.getString(); - case SENIOR_ADMIN: - return ConfigEntry.VAULT_PREFIX_SENIOR_ADMIN.getString(); - case SENIOR_CONSOLE: - return ConfigEntry.VAULT_PREFIX_SENIOR_CONSOLE.getString(); - default: - return null; - } - } return null; } @@ -380,7 +361,7 @@ private String buildPrefixSection(Player player) return ""; } - me.totalfreedom.totalfreedommod.rank.Displayable display = plugin.rm.getDisplay(player); + Displayable display = plugin.rm.getDisplay(player); String rankPrefix = ""; if (display != null) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/CommandSpy.java b/src/main/java/me/totalfreedom/totalfreedommod/CommandSpy.java index 7f6d6f893..3ef59c002 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/CommandSpy.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/CommandSpy.java @@ -1,8 +1,8 @@ package me.totalfreedom.totalfreedommod; +import me.totalfreedom.totalfreedommod.display.Displayable; import me.totalfreedom.totalfreedommod.player.CommandSpyMode; import me.totalfreedom.totalfreedommod.player.FPlayer; -import me.totalfreedom.totalfreedommod.rank.Displayable; import me.totalfreedom.totalfreedommod.util.AdventureUtil; import me.totalfreedom.totalfreedommod.util.FUtil; import net.kyori.adventure.text.Component; @@ -70,9 +70,6 @@ public void onPlayerCommandPreprocess(PlayerCommandPreprocessEvent event) String prefix = AdventureUtil.componentToPlainText(display.getColoredTag()).trim(); if (prefix.isEmpty()) { - // A rank is free to report no tag at all; fall back to the empty string rather than - // letting a null escape into the isEmpty() below, which would throw once per command - // and bury the console in "Could not pass event PlayerCommandPreprocessEvent" traces. final String tag = display.getTag(); prefix = tag != null ? tag : ""; } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ConfigConverter.java b/src/main/java/me/totalfreedom/totalfreedommod/ConfigConverter.java index 3697169c2..6069f92d6 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ConfigConverter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ConfigConverter.java @@ -14,7 +14,9 @@ import me.totalfreedom.totalfreedommod.admin.AdminList; import me.totalfreedom.totalfreedommod.banning.PermBan; import me.totalfreedom.totalfreedommod.banning.PermbanList; -import me.totalfreedom.totalfreedommod.rank.Rank; +import me.totalfreedom.totalfreedommod.player.PlayerData; +import me.totalfreedom.totalfreedommod.rank.CustomRank; +import me.totalfreedom.totalfreedommod.rank.RankRole; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.JsonUtil; import me.totalfreedom.totalfreedommod.framework.PluginComponent; @@ -26,6 +28,12 @@ public class ConfigConverter extends PluginComponent public static final int CURRENT_CONFIG_VERSION = 1; + /** + * Suffix shared by the retired console-only rank ids ({@code senior_console}, + * {@code telnet_console}), matched so that any of them is remapped without naming each. + */ + private static final String CONSOLE_RANK_SUFFIX = "_console"; + public ConfigConverter(TotalFreedomMod plugin) { super(plugin); @@ -165,7 +173,12 @@ public void convertRanksYaml() } /** - * Remap admins still assigned to deprecated {@code *_CONSOLE} legacy ranks. + * Remaps admins still assigned to one of the retired {@code *_console} ranks. + *

+ * Console-only ranks existed so that a shared channel could be given standing of its own. That + * is now the job of the {@code host_senders:} binding, which the attributed console sender + * resolves per channel, so an admin holding one is remapped to the equivalent real rank rather + * than left pointing at a rank id the registry no longer knows. */ public void convertAdminConsoleRanks() { @@ -174,30 +187,108 @@ public void convertAdminConsoleRanks() return; } - int migrated = 0; - for (Admin admin : plugin.al.getAllAdmins().values()) + final long migrated = plugin.al.getAllAdmins().values() + .stream() + .filter(admin -> admin.getRankId() != null) + .filter(admin -> admin.getRankId().endsWith(CONSOLE_RANK_SUFFIX)) + .peek(admin -> FLog.info(String.format("Remapped admin '%s' from retired rank '%s' to '%s'.", + admin.getName(), admin.getRankId(), seniorRankId()))) + .peek(admin -> admin.setRankId(seniorRankId())) + .count(); + + if (migrated > 0) { - Rank rank = admin.getRank(); - Rank newRank = null; - if (rank == Rank.SENIOR_CONSOLE) - { - newRank = Rank.SENIOR_ADMIN; - } - if (newRank != null) - { - admin.setRank(newRank); - migrated++; - FLog.info("Remapped admin '" + admin.getName() + "' from " + rank.name() + " to " + newRank.name()); - } + plugin.al.saveAsync(); + FLog.info(String.format("Remapped %d admin(s) from retired console ranks.", migrated)); } + } + + /** + * Moves admins off the cosmetic ranks that are now titles. + *

+ * Developer, Owner and Executive were ranks only because the old ladder had no other way to + * show them: each had to be seated above Senior Admin to display, which handed its holder every + * permission underneath. They are titles now, so a holder keeps the recognition without the + * ladder position, and their actual authority drops to the senior rank they were really + * exercising. Without this pass their stored rank id would no longer resolve and the registry + * would quietly seat them at the default admin rank instead. + */ + public void convertCosmeticRankHolders() + { + if (plugin.al == null || plugin.rm == null || plugin.tm == null || plugin.pl == null) + { + return; + } + + final String senior = seniorRankId(); + if (senior == null) + { + return; + } + + final long migrated = plugin.al.getAllAdmins().values() + .stream() + .filter(admin -> admin.getRankId() != null) + // A rank id that no longer names a rank but does name a title is exactly one of + // these; anything else unresolvable is left alone for an operator to look at. + .filter(admin -> plugin.rm.getCustomRank(admin.getRankId()) == null) + .filter(admin -> plugin.tm.hasTitle(admin.getRankId())) + .peek(admin -> grantTitleOffline(admin.getName(), admin.getRankId())) + .peek(admin -> FLog.info(String.format( + "Moved admin '%s' from retired rank '%s' to the '%s' title, rank '%s'.", + admin.getName(), admin.getRankId(), admin.getRankId(), senior))) + .peek(admin -> admin.setRankId(senior)) + .count(); if (migrated > 0) { plugin.al.saveAsync(); - FLog.info("Remapped " + migrated + " admin(s) from deprecated console ranks."); + FLog.info(String.format("Converted %d admin(s) from cosmetic ranks to titles.", migrated)); } } + /** + * Records a title against a player profile without needing them online, since a migration runs + * at startup when nobody is. + */ + private void grantTitleOffline(final String username, final String titleId) + { + final PlayerData data = plugin.pl.getData(username); + + if (data != null && data.addTitle(titleId)) + { + plugin.pl.saveData(data); + } + } + + /** + * The rank a senior admin should hold, resolved as the least privileged rank that is granted + * senior standing. Derived from {@code ranks.json} rather than named, so a renamed or + * operator-defined senior rank is still found. + */ + private String seniorRankId() + { + return plugin.rm == null + ? null + : plugin.rm.getRegistry() + .requiredFor(AdminList.SENIOR_STATUS_NODE) + .map(CustomRank::getId) + .orElseGet(this::adminDefaultRankId); + } + + /** + * The rank a plain admin should hold, taken from whichever rank fills that role. + */ + private String adminDefaultRankId() + { + return plugin.rm == null + ? null + : plugin.rm.getRegistry() + .byRole(RankRole.ADMIN_DEFAULT) + .map(CustomRank::getId) + .orElse(null); + } + private void convertSuperadmins(File oldFile) { if (!oldFile.exists() || !oldFile.isFile()) @@ -226,22 +317,16 @@ private void convertSuperadmins(File oldFile) } String username = asec.getString("last_login_name"); - Rank rank; - if (asec.getBoolean("is_senior_admin")) - { - rank = Rank.SENIOR_ADMIN; - } - else - { - rank = Rank.SUPER_ADMIN; - } + final String rankId = asec.getBoolean("is_senior_admin") + ? seniorRankId() + : adminDefaultRankId(); List ips = asec.getStringList("ips"); String loginMessage = asec.getString("custom_login_message"); boolean active = asec.getBoolean("is_activated"); Admin admin = new Admin(username); admin.setName(username); - admin.setRank(rank); + admin.setRankId(rankId); admin.addIps(ips); admin.setLoginMessage(loginMessage); admin.setActive(active); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/JoinLeaveMessages.java b/src/main/java/me/totalfreedom/totalfreedommod/JoinLeaveMessages.java index e372ceba9..f3c07d26d 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/JoinLeaveMessages.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/JoinLeaveMessages.java @@ -44,9 +44,14 @@ public void onPlayerQuit(final PlayerQuitEvent event) broadcast(player, " has left the game."); } + /** + * Whether this player's join and leave are worth announcing: staff, or anyone carrying an + * announceable title. Titles replaced the hardcoded developer list this used to consult. + */ private boolean isAdminOrDeveloper(final Player player) { - return plugin.al.isAdmin(player) || FUtil.DEVELOPERS.contains(player.getName()); + return plugin.al.isAdmin(player) + || (plugin.tm != null && plugin.tm.getDisplayTitle(player) != null); } /** diff --git a/src/main/java/me/totalfreedom/totalfreedommod/TotalFreedomMod.java b/src/main/java/me/totalfreedom/totalfreedommod/TotalFreedomMod.java index e16a8c17e..736213e23 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/TotalFreedomMod.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/TotalFreedomMod.java @@ -47,6 +47,7 @@ import me.totalfreedom.totalfreedommod.player.PlayerList; import me.totalfreedom.totalfreedommod.rank.ConsoleSenderRegistry; import me.totalfreedom.totalfreedommod.rank.RankManager; +import me.totalfreedom.totalfreedommod.title.TitleManager; import me.totalfreedom.totalfreedommod.sql.FreedomDatabase; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FUtil; @@ -75,6 +76,7 @@ public class TotalFreedomMod extends JavaPlugin public WorldManager wm; // WorldManager - Manages world operations public AdminList al; // AdminList - Manages admin list and permissions public RankManager rm; // RankManager - Handles player ranks and display + public TitleManager tm; // TitleManager - Flat, non-inheriting capability grants shown alongside ranks public ConsoleSenderRegistry csr; // ConsoleSenderRegistry - Maps console senders to appropriate rank public CommandLoader cmdl; // CmdLoader - Loads and registers Brigadier commands public CommandBlocker cb; // CommandBlocker - Blocks specific commands @@ -191,6 +193,10 @@ public void onEnable() configConverter.convertAdminConsoleRanks(); rm = services.registerService(RankManager.class); + tm = services.registerService(TitleManager.class); + + // Runs after both registries exist: it reads ranks and titles to decide what to move. + configConverter.convertCosmeticRankHolders(); // Console sender whitelist. This first read only resolves bindings that name a legacy // rank, since registerService constructs RankManager without starting it and no custom diff --git a/src/main/java/me/totalfreedom/totalfreedommod/admin/Admin.java b/src/main/java/me/totalfreedom/totalfreedommod/admin/Admin.java index e5325ce60..403e48aa2 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/admin/Admin.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/admin/Admin.java @@ -4,7 +4,6 @@ import java.util.Date; import java.util.List; import java.util.UUID; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.ConfigInterfaces.ConfigLoadable; import me.totalfreedom.totalfreedommod.util.ConfigInterfaces.Validatable; import me.totalfreedom.totalfreedommod.util.FUtil; @@ -18,8 +17,14 @@ public class Admin implements ConfigLoadable, Validatable private String configKey; private String name; private boolean active = true; - private Rank rank = Rank.SUPER_ADMIN; - private String customRankId = null; + + /** + * The id of the rank this admin holds, resolved against {@code ranks.json}. Stored as an id + * rather than as a fixed tier so that operator-defined ranks are first-class: an admin may hold + * any rank the registry knows, not just one of a handful the plugin ships with. + */ + private String rankId = null; + private final List ips = Lists.newArrayList(); private Date lastLogin = new Date(); private String loginMessage = null; @@ -46,8 +51,7 @@ public String toString() .append("- IPs: ").append(String.join(", ", ips)).append("\n") .append("- Last Login: ").append(FUtil.dateToString(lastLogin)).append("\n") .append("- Custom Login Message: ").append(loginMessage).append("\n") - .append("- Rank: ").append(rank.getName()).append("\n") - .append("- Custom Rank: ").append(customRankId != null ? customRankId : "none").append("\n") + .append("- Rank: ").append(rankId).append("\n") .append("- Is Active: ").append(active); return output.toString(); @@ -66,18 +70,30 @@ public void loadFrom(ConfigurationSection cs) { name = cs.getString("username", configKey); active = cs.getBoolean("active", true); - rank = Rank.findRank(cs.getString("rank")); + rankId = normaliseRankId(cs.getString("custom_rank"), cs.getString("rank")); ips.clear(); ips.addAll(cs.getStringList("ips")); lastLogin = FUtil.stringToDate(cs.getString("last_login")); loginMessage = cs.getString("login_message", null); - customRankId = cs.getString("custom_rank", null); } - public boolean isAtLeast(Rank pRank) + /** + * Folds the two rank fields records used to carry into the single id used now. + *

+ * {@code custom_rank} was the operator-assigned rank and took precedence over {@code rank}, + * which held one of the fixed tiers, so it is preferred here too. A tier name is lowercased to + * become an id, which is the convention the shipped {@code ranks.json} follows. + */ + private static String normaliseRankId(final String customRank, final String legacyRank) { - return rank.isAtLeast(pRank); + if (customRank != null && !customRank.isBlank()) + return customRank.toLowerCase(); + + if (legacyRank != null && !legacyRank.isBlank()) + return legacyRank.toLowerCase(); + + return null; } public boolean hasLoginMessage() @@ -126,19 +142,14 @@ public void setUuid(UUID uuid) this.uuid = uuid; } - public Rank getRank() - { - return rank; - } - - public String getCustomRankId() + public String getRankId() { - return customRankId; + return rankId; } - public void setCustomRankId(String customRankId) + public void setRankId(String rankId) { - this.customRankId = customRankId; + this.rankId = rankId == null ? null : rankId.toLowerCase(); } public String getName() @@ -181,11 +192,6 @@ public String getLoginMessage() return loginMessage; } - public void setRank(Rank rank) - { - this.rank = rank; - } - public void setLoginMessage(String loginMessage) { this.loginMessage = loginMessage; @@ -199,9 +205,10 @@ public void setActive(boolean active) @Override public boolean isValid() { + // rankId is deliberately not required: an unset id means "whatever fills the admin-default + // role", which the registry resolves, so an older record without one is still valid. return configKey != null && name != null - && rank != null && !ips.isEmpty() && lastLogin != null; } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java b/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java index 0713a4095..d527157ea 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java @@ -22,7 +22,6 @@ import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.sql.PersistenceQueue; import me.totalfreedom.totalfreedommod.sql.adapter.AdminRepository; import me.totalfreedom.totalfreedommod.util.FLog; @@ -45,6 +44,13 @@ public class AdminList extends FreedomService public static final String CONFIG_FILENAME = "admins.json"; + /** + * The node that marks a rank as senior. Senior standing is a capability granted by + * {@code ranks.json} rather than a fixed tier, so an operator-defined rank can hold it and a + * rename or re-tier of the shipped ranks does not strand this check. + */ + public static final String SENIOR_STATUS_NODE = "tfm.admin.senior.status"; + private static final Type ADMIN_MAP_TYPE = new TypeToken>() {}.getType(); private static final long LAST_LOGIN_DEBOUNCE_MS = 5L * 60L * 1000L; @@ -219,15 +225,28 @@ public boolean isAdmin(CommandSender sender) return admin != null && admin.isActive(); } + /** + * Whether {@code sender} counts as a senior admin. + *

+ * Asked as a capability rather than as a rank comparison, because no rank is named in code any + * more: whichever ranks {@code ranks.json} grants {@link #SENIOR_STATUS_NODE} to are the senior + * ones, including any the operator defined themselves. + */ public boolean isSeniorAdmin(CommandSender sender) { - Admin admin = getAdmin(sender); - if (admin == null) - { - return false; - } + return isAdmin(sender) && plugin.rm.hasPermission(sender, SENIOR_STATUS_NODE); + } - return admin.getRank().ordinal() >= Rank.SENIOR_ADMIN.ordinal(); + /** + * The same test applied to a stored profile rather than to a live sender, for the cleanup pass + * that runs over admins who are not online to be asked. + */ + public boolean grantsSeniorStatus(Admin admin) + { + return plugin.rm.getRegistry() + .byId(admin.getRankId()) + .map(rank -> plugin.rm.getRegistry().satisfies(rank, SENIOR_STATUS_NODE)) + .orElse(false); } public Admin getAdmin(CommandSender sender) @@ -537,7 +556,7 @@ public void deactivateOldEntries(boolean verbose) allAdmins.values() .stream() .filter(Admin::isActive) - .filter(admin -> !admin.getRank().isAtLeast(Rank.SENIOR_ADMIN)) + .filter(admin -> !grantsSeniorStatus(admin)) // A record with no recorded login has nothing to age out against. .filter(admin -> admin.getLastLogin() != null) .filter(admin -> inactiveHours(admin) >= threshold) @@ -715,11 +734,10 @@ private Admin fixConfigKey(Admin admin, String key) Admin fixed = new Admin(key); fixed.setUuid(admin.getUuid()); fixed.setName(admin.getName()); - fixed.setRank(admin.getRank()); + fixed.setRankId(admin.getRankId()); fixed.setActive(admin.isActive()); fixed.setLastLogin(admin.getLastLogin()); fixed.setLoginMessage(admin.getLoginMessage()); - fixed.setCustomRankId(admin.getCustomRankId()); fixed.addIps(admin.getIps()); return fixed; } @@ -925,11 +943,10 @@ private Admin copyAdmin(Admin admin) Admin copy = new Admin(admin.getConfigKey()); copy.setUuid(admin.getUuid()); copy.setName(admin.getName()); - copy.setRank(admin.getRank()); + copy.setRankId(admin.getRankId()); copy.setActive(admin.isActive()); copy.setLastLogin(admin.getLastLogin() == null ? null : new Date(admin.getLastLogin().getTime())); copy.setLoginMessage(admin.getLoginMessage()); - copy.setCustomRankId(admin.getCustomRankId()); copy.addIps(new ArrayList<>(admin.getIps())); return copy; } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/command/CommandBlockerRank.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/command/CommandBlockerRank.java index 6bbc04fe9..932aa2e1d 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/command/CommandBlockerRank.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/command/CommandBlockerRank.java @@ -2,7 +2,6 @@ import me.totalfreedom.totalfreedommod.PluginProvider; import me.totalfreedom.totalfreedommod.admin.Admin; -import me.totalfreedom.totalfreedommod.rank.Rank; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; @@ -43,7 +42,7 @@ public static CommandBlockerRank fromSender(CommandSender sender) Admin admin = PluginProvider.get().al.getAdmin(sender); if (admin != null) { - if (admin.getRank() == Rank.SENIOR_ADMIN) + if (PluginProvider.get().al.isSeniorAdmin(sender)) { return SENIOR; } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adminchat.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adminchat.java index b5506f155..ed0819a93 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adminchat.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adminchat.java @@ -2,7 +2,6 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.player.FPlayer; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; @@ -11,12 +10,12 @@ description = "AdminChat - Talk privately with other admins. Using the command by itself will toggle AdminChat on and off for all messages.", usage = "/ [message...]", aliases = {"o", "ac"}) -@Permission(level = Rank.SUPER_ADMIN, source = SourceType.BOTH, permission = "tfm.admin.adminchat") +@Permission(source = SourceType.BOTH, permission = "tfm.admin.adminchat") public class Command_adminchat extends FCommand { @Callback - @Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.adminchat", source = SourceType.ONLY_IN_GAME) + @Permission(permission = "tfm.admin.adminchat", source = SourceType.ONLY_IN_GAME) public void toggle(Player sender) { final FPlayer fplayer = plugin().pl.getPlayer(sender); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_admininfo.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_admininfo.java index 3b6a66f6d..0103c1fe8 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_admininfo.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_admininfo.java @@ -6,13 +6,12 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.FUtil; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.JoinConfiguration; @Command(name = "admininfo", description = "Information on how to apply for admin.", usage = "/admininfo", aliases={"si", "ai", "staffinfo"}) -@Permission(level = Rank.OP, source = SourceType.BOTH, permission = "tfm.player.admininfo") +@Permission(source = SourceType.BOTH, permission = "tfm.player.admininfo") public class Command_admininfo extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adminworld.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adminworld.java index fc25c616c..52a190ea6 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adminworld.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adminworld.java @@ -6,7 +6,6 @@ import org.bukkit.entity.Player; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.world.WorldTime; import me.totalfreedom.totalfreedommod.world.WorldWeather; @@ -44,7 +43,7 @@ else if (plugin().wm.adminworld.canAccessWorld(player)) @Callback @Subcommand("guest add") - @Permission(level = Rank.SUPER_ADMIN, source = SourceType.ONLY_IN_GAME, permission = "tfm.world.adminworld") + @Permission(source = SourceType.ONLY_IN_GAME, permission = "tfm.world.adminworld.manage") public void addGuest(Player sender, Player target) { if (plugin().wm.adminworld.addGuest(target, sender)) @@ -74,7 +73,7 @@ public void listGuests(CommandSender sender) @Callback @Subcommand("guest remove") - @Permission(level = Rank.SUPER_ADMIN, permission = "tfm.world.adminworld") + @Permission(permission = "tfm.world.adminworld.manage") public void removeGuest(CommandSender sender, Player target) { if (plugin().wm.adminworld.removeGuest(target)) @@ -91,7 +90,7 @@ public void removeGuest(CommandSender sender, Player target) @Callback @Subcommand("guest purge") - @Permission(level = Rank.SUPER_ADMIN, permission = "tfm.world.adminworld") + @Permission(permission = "tfm.world.adminworld.manage") public void purgeGuests(CommandSender sender) { plugin().wm.adminworld.purgeGuestList(); @@ -100,7 +99,7 @@ public void purgeGuests(CommandSender sender) @Callback @Subcommand("time") - @Permission(level = Rank.SUPER_ADMIN, permission = "tfm.world.adminworld") + @Permission(permission = "tfm.world.adminworld.manage") public void setTime(CommandSender sender, WorldTime time) { plugin().wm.adminworld.setTimeOfDay(time); @@ -110,7 +109,7 @@ public void setTime(CommandSender sender, WorldTime time) @Callback @Subcommand("weather") - @Permission(level = Rank.SUPER_ADMIN, permission = "tfm.world.adminworld") + @Permission(permission = "tfm.world.adminworld.manage") public void setWeather(CommandSender sender, WorldWeather weather) { plugin().wm.adminworld.setWeatherMode(weather); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adventure.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adventure.java index ef88ad1e5..70af37bc4 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adventure.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adventure.java @@ -7,7 +7,6 @@ import org.bukkit.entity.Player; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; @Command(name = "adventure", description = "Quickly change your own gamemode to adventure, or define someone's username to change theirs.", usage = "/adventure [-a | name]", aliases = {"gma"}) @Permission(permission = "tfm.admin.gamemode") @@ -23,7 +22,7 @@ public void changeGamemodeSelf(Player player) @Callback @Subcommand("-a") // a switch wouldn't be really appropriate here due to the nature of the @Permission annotation - @Permission(permission = "tfm.admin.gamemode", level = Rank.SUPER_ADMIN) + @Permission(permission = "tfm.admin.gamemode") public void changeGamemodeAll(CommandSender sender) { adminAction(sender, "Changing everyone's gamemode to Adventure"); @@ -31,7 +30,7 @@ public void changeGamemodeAll(CommandSender sender) } @Callback - @Permission(permission = "tfm.admin.gamemode", level = Rank.SUPER_ADMIN) + @Permission(permission = "tfm.admin.gamemode") public void changeGamemodeOther(CommandSender sender, Player target) { target.setGameMode(GameMode.ADVENTURE); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_aeclear.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_aeclear.java index 8e91af000..81fe7dfc6 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_aeclear.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_aeclear.java @@ -1,6 +1,5 @@ package me.totalfreedom.totalfreedommod.cmd; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @@ -11,7 +10,7 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; @Command(name = "aeclear", description = "Removes all area-of-effect clouds on the server.", usage = "/aeclear", aliases = {"aec"}) -@Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.aeclear") +@Permission(permission = "tfm.admin.aeclear") public class Command_aeclear extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_announce.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_announce.java index 21a1cf925..29e525df8 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_announce.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_announce.java @@ -3,10 +3,9 @@ import org.bukkit.command.CommandSender; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; @Command(name = "announce", description = "Make an announcement", usage = "/announce ") -@Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.announce") +@Permission(permission = "tfm.admin.announce") public class Command_announce extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_autoclear.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_autoclear.java index 10837eee2..08acf7993 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_autoclear.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_autoclear.java @@ -6,10 +6,9 @@ import org.bukkit.command.CommandSender; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; @Command(name = "autoclear", description = "Toggle whether or not a player has their inventory automatically cleared when they join.", usage = "/autoclear ") -@Permission(permission = "tfm.admin.autoclear", level = Rank.SUPER_ADMIN) +@Permission(permission = "tfm.admin.autoclear") public class Command_autoclear extends FCommand { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_autotp.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_autotp.java index 5fbc7e6ad..f8fdbbe16 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_autotp.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_autotp.java @@ -6,10 +6,9 @@ import org.bukkit.command.CommandSender; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; @Command(name = "autotp", description = "Toggle whether or not a player is automatically teleported when they join.", usage = "/autotp ") -@Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.autotp") +@Permission(permission = "tfm.admin.autotp") public class Command_autotp extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_ban.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_ban.java index 44749343a..447d2eb88 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_ban.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_ban.java @@ -12,11 +12,10 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.player.PlayerData; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.FUtil; @Command(name = "ban", description = "Bans an online or previously known player and their known IP addresses.", usage = "/ [-s] [-nrb] [reason]", aliases = {"gtfo"}) -@Permission(permission = "tfm.admin.ban", level = Rank.SUPER_ADMIN) +@Permission(permission = "tfm.admin.ban") public class Command_ban extends FCommand { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banip.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banip.java index 50162c33e..9b7f3e7bd 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banip.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banip.java @@ -11,10 +11,9 @@ import me.totalfreedom.totalfreedommod.banning.Ban; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; @Command(name = "banip", description = "Bans an IP address or all known IP addresses for a player.", usage = "/banip [reason]") -@Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.ban") +@Permission(permission = "tfm.admin.ban") public class Command_banip extends FCommand { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banlist.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banlist.java index e8ba98eee..d858bb624 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banlist.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banlist.java @@ -10,7 +10,6 @@ import me.totalfreedom.totalfreedommod.banning.Ban; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.FUtil; @Command(name = "banlist", description = "Shows all banned players and IP addresses. Senior Admins may optionally use 'purge' to clear the list.", usage = "/banlist [purge]") @@ -62,7 +61,7 @@ public void showBanList(CommandSender sender) @Callback @Subcommand("purge") - @Permission(level = Rank.SENIOR_ADMIN, permission = "tfm.admin.banlist") + @Permission(permission = "tfm.admin.banlist.purge") public void purgeBans(CommandSender sender) { // Ok so apparently plugin().bm.purge() purges the banlist then returns an int to count how many bans were purged. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banname.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banname.java index b612dfefb..b36abcda3 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banname.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banname.java @@ -8,10 +8,9 @@ import java.util.List; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; @Command(name = "banname", description = "Bans the specified name.", usage = "/banname [reason]") -@Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.ban") +@Permission(permission = "tfm.admin.ban") public class Command_banname extends FCommand { @Completer(value = "", position = 0) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_blockcmd.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_blockcmd.java index 745acfe50..513b9fa28 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_blockcmd.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_blockcmd.java @@ -7,10 +7,9 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.player.FPlayer; -import me.totalfreedom.totalfreedommod.rank.Rank; @Command(name = "blockcmd", description = "Block all commands for a specific player.", usage = "/ <-a | purge | >", aliases = {"blockcommands","blockcommand","bc","bcmd"}) -@Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.blockcmd") +@Permission(permission = "tfm.admin.blockcmd") public class Command_blockcmd extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cage.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cage.java index 257b44d0a..063c21f7e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cage.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cage.java @@ -8,11 +8,10 @@ import me.totalfreedom.totalfreedommod.caging.CageData; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.player.FPlayer; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @Command(name = "cage", description = "Place a cage around someone.", usage = "/cage (-s) [ ] | purge") -@Permission(permission = "tfm.admin.cage", level = Rank.SUPER_ADMIN) +@Permission(permission = "tfm.admin.cage") public class Command_cage extends FCommand { @Callback // /cage [-s] | Will auto-toggle, but switch -s can be used to guarantee uncaging. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cake.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cake.java index 8bb568dc3..d632950fb 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cake.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cake.java @@ -10,10 +10,9 @@ import org.bukkit.inventory.meta.ItemMeta; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; @Command(name = "cake", description = "For the people that are still alive.", usage = "/cake") -@Permission(level = Rank.SUPER_ADMIN, source = SourceType.BOTH, permission = "tfm.fun.cake") +@Permission(source = SourceType.BOTH, permission = "tfm.fun.cake") public class Command_cake extends FCommand { public static final String CAKE_LYRICS = "But there's no sense crying over every mistake. You just keep on trying till you run out of cake."; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_chat.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_chat.java index 3393e18d7..55c8a3421 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_chat.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_chat.java @@ -3,14 +3,13 @@ import org.bukkit.entity.Player; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; @Command( name = "chat", description = "Send a chat message without needing to verify your ID with Microsoft.", usage = "/chat ", aliases = {"c","fuckofcom"}) -@Permission(permission = "tfm.player.chat", level = Rank.NON_OP, source = SourceType.ONLY_IN_GAME) +@Permission(permission = "tfm.player.chat", source = SourceType.ONLY_IN_GAME) public final class Command_chat extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cleanchat.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cleanchat.java index 70a6fa236..c67a0872f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cleanchat.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cleanchat.java @@ -3,10 +3,9 @@ import org.bukkit.command.CommandSender; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; @Command(name = "cleanchat", description = "Clears the chat.", usage = "/cleanchat", aliases = {"cc", "clearchat"}) -@Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.cleanchat") +@Permission(permission = "tfm.admin.cleanchat") public class Command_cleanchat extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cmdspy.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cmdspy.java index d08f55bb2..c27f58904 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cmdspy.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cmdspy.java @@ -8,10 +8,9 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.player.CommandSpyMode; -import me.totalfreedom.totalfreedommod.rank.Rank; @Command(name = "cmdspy", description = "Spy on commands", usage = "/ [admins | ops | all]", aliases = {"commandspy", "cspy"}) -@Permission(permission = "tfm.admin.cmdspy", source = SourceType.ONLY_IN_GAME, level = Rank.SUPER_ADMIN) +@Permission(permission = "tfm.admin.cmdspy", source = SourceType.ONLY_IN_GAME) public class Command_cmdspy extends FCommand { // doing this to show ajax why i don't like using var keyword :) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_consolesay.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_consolesay.java index d9ba7d2e8..6daa8b384 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_consolesay.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_consolesay.java @@ -4,12 +4,11 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchContext; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.FUtil; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @Command(name = "consolesay", description = "Send a chat message with chat formatting over SSH.", usage = "/ ", aliases = {"csay"}) -@Permission(level = Rank.SUPER_ADMIN, source = SourceType.ONLY_CONSOLE, permission = "tfm.admin.consolesay") +@Permission(source = SourceType.ONLY_CONSOLE, permission = "tfm.admin.consolesay") public class Command_consolesay extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cookie.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cookie.java index 1b8048d9a..46986eab3 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cookie.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cookie.java @@ -11,12 +11,11 @@ import org.bukkit.inventory.meta.ItemMeta; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.FUtil; import net.kyori.adventure.text.Component; @Command(name = "cookie", description = "For those who have no friends.", usage = "/cookie") -@Permission(permission = "tfm.fun.cookie", level = Rank.SUPER_ADMIN) +@Permission(permission = "tfm.fun.cookie") public class Command_cookie extends FCommand { public static final String COOKIE_LYRICS = "Imagine that you have zero cookies and you split them evenly among zero friends. How many cookies does each person get? See? It doesn't make sense. And Cookie Monster is sad that there are no cookies, and you are sad that you have no friends."; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_crash.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_crash.java index 7f6f1b3ca..fc11fae09 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_crash.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_crash.java @@ -6,11 +6,10 @@ import org.bukkit.entity.Player; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.Component; @Command(name = "crash", description = "Crashes the specified player", usage = "/crash ", aliases = {"fuckup"}) -@Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.fuckup") +@Permission(permission = "tfm.admin.fuckup") public class Command_crash extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_creative.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_creative.java index 3a7a2caa0..aebb46d12 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_creative.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_creative.java @@ -6,7 +6,6 @@ import org.bukkit.entity.Player; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @Command( @@ -28,7 +27,7 @@ public void changeGamemodeSelf(Player player) @Callback @Subcommand("-a") - @Permission(permission = "tfm.admin.gamemode", level = Rank.SUPER_ADMIN) + @Permission(permission = "tfm.admin.gamemode") public void changeGamemodeAll(CommandSender sender) { Bukkit.getOnlinePlayers().forEach(player -> player.setGameMode(GameMode.CREATIVE)); @@ -36,7 +35,7 @@ public void changeGamemodeAll(CommandSender sender) } @Callback - @Permission(permission = "tfm.admin.gamemode", level = Rank.SUPER_ADMIN) + @Permission(permission = "tfm.admin.gamemode") public void changeGamemodeOther(CommandSender sender, Player target) { target.setGameMode(GameMode.CREATIVE); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_deafen.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_deafen.java index 71f0bcf18..ebdc0d52b 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_deafen.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_deafen.java @@ -1,7 +1,6 @@ package me.totalfreedom.totalfreedommod.cmd; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import org.bukkit.Location; import org.bukkit.Registry; import org.bukkit.Sound; @@ -11,7 +10,7 @@ import java.util.List; import java.util.Random; -@Permission(level = Rank.SENIOR_ADMIN, permission = "tfm.admin.senior.deafen") +@Permission(permission = "tfm.admin.senior.deafen") @Command(name = "deafen", description = "Make some noise.", usage = "/") public class Command_deafen extends FCommand { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_deop.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_deop.java index b99c1bc9b..4826bce7a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_deop.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_deop.java @@ -4,11 +4,10 @@ import org.bukkit.command.CommandSender; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @Command(name = "deop", description = "Deop a player.", usage = "/deop ") -@Permission(permission = "tfm.admin.deop", level = Rank.SUPER_ADMIN) +@Permission(permission = "tfm.admin.deop") public class Command_deop extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_disguisetoggle.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_disguisetoggle.java index 6ea414259..a896d3df7 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_disguisetoggle.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_disguisetoggle.java @@ -3,11 +3,10 @@ import org.bukkit.command.CommandSender; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; @Command(name = "disguisetoggle", description = "Toggle the disguise plugin", usage = "/disguisetoggle", aliases = {"dtoggle"}) -@Permission(permission = "tfm.admin.disguisetoggle", level = Rank.SUPER_ADMIN) +@Permission(permission = "tfm.admin.disguisetoggle") public class Command_disguisetoggle extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_doom.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_doom.java index 83aa95b51..920f79f52 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_doom.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_doom.java @@ -3,7 +3,6 @@ import me.totalfreedom.totalfreedommod.admin.Admin; import me.totalfreedom.totalfreedommod.banning.Ban; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; @@ -12,7 +11,7 @@ import org.bukkit.entity.Player; import org.bukkit.util.Vector; -@Permission(level = Rank.SENIOR_ADMIN, source = SourceType.ONLY_CONSOLE, permission = "tfm.fun.doom") +@Permission(source = SourceType.ONLY_CONSOLE, permission = "tfm.fun.doom") @Command(name = "doom", description = "For the bad admins", usage = "/doom ") public class Command_doom extends FCommand { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_entitywipe.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_entitywipe.java index 5f977ec3f..dbcb5e03b 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_entitywipe.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_entitywipe.java @@ -4,12 +4,11 @@ import org.bukkit.command.CommandSender; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @Command(name = "entitywipe", description = "Remove various server entities that may cause lag, such as dropped items, minecarts, and boats.", usage = "/entitywipe [world]", aliases = {"ew", "rd"}) -@Permission(permission = "tfm.server.entitywipe", level = Rank.SUPER_ADMIN) +@Permission(permission = "tfm.server.entitywipe") public class Command_entitywipe extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_expel.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_expel.java index f34310a5a..69ec1c5fe 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_expel.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_expel.java @@ -3,7 +3,6 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.FUtil; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.JoinConfiguration; @@ -17,7 +16,7 @@ import java.util.List; @Command(name = "expel", description = "Push people away from you.", usage = "/expel [radius] [strength]") -@Permission(permission = "tfm.fun.expel", level = Rank.SUPER_ADMIN, source = SourceType.ONLY_IN_GAME) +@Permission(permission = "tfm.fun.expel", source = SourceType.ONLY_IN_GAME) public class Command_expel extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_findip.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_findip.java index dbc64f747..2e093c425 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_findip.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_findip.java @@ -4,12 +4,11 @@ import org.bukkit.entity.Player; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @Command(name = "findip", description = "Shows all IPs registered to a player.", usage = "/findip [player]", aliases = {"ips", "ip"}) -@Permission(permission = "tfm.admin.findip", level = Rank.SUPER_ADMIN) +@Permission(permission = "tfm.admin.findip") public class Command_findip extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_flatlands.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_flatlands.java index 3bcf210a4..df4c7909b 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_flatlands.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_flatlands.java @@ -4,10 +4,9 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.rank.Rank; @Command(name = "flatlands", description = "Goto the flatlands.", usage = "/flatlands") -@Permission(permission = "tfm.world.flatlands", level = Rank.NON_OP, source = SourceType.ONLY_IN_GAME) +@Permission(permission = "tfm.world.flatlands", source = SourceType.ONLY_IN_GAME) public class Command_flatlands extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_freeze.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_freeze.java index 34cf6083c..60a0f26af 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_freeze.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_freeze.java @@ -5,7 +5,6 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Resolve; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Subcommand; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @@ -14,7 +13,7 @@ @Command(name = "freeze", description = "Freeze players. Append \"on\" or \"off\" at the end to set a specific state.", usage = "/freeze <[on | off] | [on | off]>", aliases = {"fr"}) -@Permission(permission = "tfm.admin.freeze", level = Rank.SUPER_ADMIN) +@Permission(permission = "tfm.admin.freeze") public class Command_freeze extends FCommand { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_fuckoff.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_fuckoff.java index 0cbaeced1..00c4cb059 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_fuckoff.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_fuckoff.java @@ -2,12 +2,11 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.player.FPlayer; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import org.bukkit.entity.Player; -@Permission(level = Rank.SENIOR_ADMIN, source = SourceType.ONLY_IN_GAME, permission = "tfm.admin.senior.fuckoff") +@Permission(source = SourceType.ONLY_IN_GAME, permission = "tfm.admin.senior.fuckoff") @Command(name = "fuckoff", description = "You'll never even see it coming.", usage = "/fuckoff ") public class Command_fuckoff extends FCommand { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gchat.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gchat.java index 649cbd7af..af767c96c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gchat.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gchat.java @@ -4,13 +4,12 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Greedy; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; @Command(name = "gchat", description = "Send a chat message as someone else.", usage = "/gchat ") -@Permission(permission = "tfm.admin.gchat", level = Rank.SUPER_ADMIN) +@Permission(permission = "tfm.admin.gchat") public class Command_gchat extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gcmd.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gcmd.java index 6364e6079..6d58b01c4 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gcmd.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gcmd.java @@ -4,13 +4,12 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Greedy; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; @Command(name = "gcmd", description = "Send a command as someone else.", usage = "/gcmd ") -@Permission(permission = "tfm.admin.gcmd", level = Rank.SUPER_ADMIN) +@Permission(permission = "tfm.admin.gcmd") public class Command_gcmd extends FCommand { @Callback @@ -22,7 +21,7 @@ public void runAsOtherPlayer(CommandSender sender, Player player, @Greedy String return; } - if (isAdmin(player) && !plugin().rm.getRank(sender).isAtLeast(Rank.SENIOR_ADMIN)) + if (isAdmin(player) && !plugin().rm.hasPermission(sender, "tfm.admin.senior.gcmd")) { msg(sender, "This command can't be used on other admins."); return; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_invis.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_invis.java index 151eddba1..0a8fe24fb 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_invis.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_invis.java @@ -6,7 +6,6 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Subcommand; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.event.HoverEvent; @@ -17,7 +16,7 @@ import org.bukkit.potion.PotionEffectType; @Command(name = "invis", description = "Shows (and optionally clears) invisisible players", usage = "/invis [clear]") -@Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.invis") +@Permission(permission = "tfm.admin.invis") public class Command_invis extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_joinmessages.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_joinmessages.java index ddad03283..6764599d9 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_joinmessages.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_joinmessages.java @@ -5,10 +5,9 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.player.FPlayer; import me.totalfreedom.totalfreedommod.player.PlayerData; -import me.totalfreedom.totalfreedommod.rank.Rank; @Command(name = "joinmessages", description = "Toggle visibility of other players' join/leave messages.", usage = "/joinmessages", aliases = {"jlm", "togglejoinmessages"}) -@Permission(permission = "tfm.player.joinmessages", level = Rank.NON_OP, source = SourceType.ONLY_IN_GAME) +@Permission(permission = "tfm.player.joinmessages", source = SourceType.ONLY_IN_GAME) public class Command_joinmessages extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_jumppads.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_jumppads.java index 31e556c6d..0d3264dc8 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_jumppads.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_jumppads.java @@ -2,7 +2,6 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.fun.Jumppads; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @@ -10,7 +9,7 @@ import org.bukkit.command.CommandSender; import java.util.stream.Stream; -@Permission(level = Rank.SUPER_ADMIN, source = SourceType.BOTH, permission = "tfm.fun.jumppads") +@Permission(source = SourceType.BOTH, permission = "tfm.fun.jumppads") @Command(name = "jumppads", description = "Manage jumppads", usage = "/ < | info | mode | strength >", aliases = "launchpads,jp") public class Command_jumppads extends FCommand { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_kick.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_kick.java index 58484ad0b..f77d108fe 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_kick.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_kick.java @@ -2,13 +2,12 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; -@Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.kick") +@Permission(permission = "tfm.admin.kick") @Command(name = "kick", aliases = "k", description = "Kick a player.", usage = "/ [-s] [reason]") public class Command_kick extends FCommand { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_landmine.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_landmine.java index 8723ae942..fcc22a2b5 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_landmine.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_landmine.java @@ -6,7 +6,6 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Subcommand; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.fun.Landminer.Landmine; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import org.bukkit.Material; @@ -15,7 +14,7 @@ import org.bukkit.entity.Player; @Command(name = "landmine", description = "Set a landmine trap.", usage = "/") -@Permission(level = Rank.OP, source = SourceType.ONLY_IN_GAME, permission = "tfm.fun.landmine") +@Permission(source = SourceType.ONLY_IN_GAME, permission = "tfm.fun.landmine") public class Command_landmine extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_link.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_link.java index c77ccb1f2..80d9f0b4f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_link.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_link.java @@ -4,12 +4,11 @@ import me.totalfreedom.totalfreedommod.admin.Admin; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @Command(name = "link", description = "Generate a one-time code for admins to link their Discord account.", usage = "/link") -@Permission(permission = "tfm.admin.discordlink", level = Rank.SUPER_ADMIN, source = SourceType.ONLY_IN_GAME) +@Permission(permission = "tfm.admin.discordlink", source = SourceType.ONLY_IN_GAME) public class Command_link extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_list.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_list.java index ec0d601cb..63939f391 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_list.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_list.java @@ -10,8 +10,7 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchContext; -import me.totalfreedom.totalfreedommod.rank.Displayable; -import me.totalfreedom.totalfreedommod.rank.Rank; +import me.totalfreedom.totalfreedommod.display.Displayable; import me.totalfreedom.totalfreedommod.util.PlayerListUtil; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; @@ -19,7 +18,7 @@ import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @Command(name = "list", description = "Lists the real names of all online players.", usage = "/list [-a | -i | -f]", aliases = {"who"}) -@Permission(permission = "tfm.player.list", level = Rank.IMPOSTOR) +@Permission(permission = "tfm.player.list") public class Command_list extends FCommand { private enum ListFilter diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_localspawn.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_localspawn.java index d87f1c8b7..a77e286d3 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_localspawn.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_localspawn.java @@ -3,11 +3,10 @@ import org.bukkit.entity.Player; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @Command(name = "localspawn", description = "Teleport to the spawn point for the current world.", usage = "/localspawn", aliases = {"worldspawn", "gotospawn"}) -@Permission(permission = "tfm.player.localspawn", level = Rank.NON_OP, source = SourceType.ONLY_IN_GAME) +@Permission(permission = "tfm.player.localspawn", source = SourceType.ONLY_IN_GAME) public class Command_localspawn extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_lockup.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_lockup.java index d65543735..f822237ae 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_lockup.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_lockup.java @@ -9,7 +9,6 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Subcommand; import me.totalfreedom.totalfreedommod.player.FPlayer; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.FTask; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @@ -19,7 +18,7 @@ import org.bukkit.scheduler.BukkitTask; @Command(name = "lockup", description = "Block target's minecraft input. This is evil, and I never should have wrote it.", usage = "/ on | off>>") -@Permission(level = Rank.SENIOR_ADMIN, source = SourceType.ONLY_CONSOLE, permission = "tfm.admin.senior.lockup") +@Permission(source = SourceType.ONLY_CONSOLE, permission = "tfm.admin.senior.lockup") public class Command_lockup extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_moblimiter.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_moblimiter.java index fa6221127..a5dbb3f04 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_moblimiter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_moblimiter.java @@ -2,7 +2,6 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @@ -11,7 +10,7 @@ import org.bukkit.command.CommandSender; import org.bukkit.entity.EntityType; -@Permission(level = Rank.SUPER_ADMIN, permission = "tfm.server.moblimiter") +@Permission(permission = "tfm.server.moblimiter") @Command(name = "moblimiter", description = "Control the MobLimiter.", usage = "/ < | limit | >>") public class Command_moblimiter extends FCommand { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_mobpurge.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_mobpurge.java index 289342b82..649087811 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_mobpurge.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_mobpurge.java @@ -14,12 +14,11 @@ import org.bukkit.scheduler.BukkitRunnable; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @Command(name = "mobpurge", description = "Purge all mobs in all worlds.", usage = "/mobpurge [world] [chunkX chunkZ | batchSize]", aliases = {"mp"}) -@Permission(permission = "tfm.server.mobpurge", level = Rank.SUPER_ADMIN) +@Permission(permission = "tfm.server.mobpurge") public class Command_mobpurge extends FCommand { public static final int DEFAULT_BATCH_SIZE = 200; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_mp44.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_mp44.java index 84539e78b..eb59f80a3 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_mp44.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_mp44.java @@ -5,14 +5,13 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Subcommand; import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.rank.Rank; import org.bukkit.Material; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; @Command(name = "mp44", description = "Modern weaponry, FTW. Use 'draw' to start firing, 'sling' to stop firing.", usage = "/ ") -@Permission(level = Rank.OP, source = SourceType.ONLY_IN_GAME, permission = "tfm.fun.mp44") +@Permission(source = SourceType.ONLY_IN_GAME, permission = "tfm.fun.mp44") public class Command_mp44 extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_myadmin.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_myadmin.java index 468966971..cd9767963 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_myadmin.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_myadmin.java @@ -5,11 +5,10 @@ import me.totalfreedom.totalfreedommod.admin.Admin; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import org.bukkit.entity.Player; -@Permission(level = Rank.SUPER_ADMIN, source = SourceType.ONLY_IN_GAME, permission = "tfm.admin.myadmin") +@Permission(source = SourceType.ONLY_IN_GAME, permission = "tfm.admin.myadmin") @Command(name = "myadmin", description = "Manage my admin entry", usage = "/myadmin | setlogin | clearlogin>") public class Command_myadmin extends FCommand { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_nickname.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_nickname.java index cd0b04c7a..1d9d5298f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_nickname.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_nickname.java @@ -13,7 +13,6 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.player.PlayerData; import me.totalfreedom.totalfreedommod.rank.CustomRank; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.AdventureUtil; import me.totalfreedom.totalfreedommod.util.FUtil; import net.kyori.adventure.text.Component; @@ -187,7 +186,7 @@ public void clearAll(CommandSender sender) @Callback @Subcommand("clean") - @Permission(permission = "tfm.player.nickname", level = Rank.SUPER_ADMIN) + @Permission(permission = "tfm.admin.nickclean") public void clean(CommandSender sender) { adminAction(sender, "Cleaning all nicknames"); @@ -262,11 +261,6 @@ public static boolean containsForbidden(String plainText) { final List terms = new ArrayList<>(FORBIDDEN_WORDS); - Stream.of(Rank.values()) - .filter(r -> r.isAdmin()) - .filter(r -> !r.getTag().isEmpty()) - .forEach(r -> terms.add(r.getTag())); - PluginProvider.get() .rm .getCustomRanksSorted() diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_nicknyan.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_nicknyan.java index 5b91525f6..797fc9b61 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_nicknyan.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_nicknyan.java @@ -8,7 +8,6 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Subcommand; import me.totalfreedom.totalfreedommod.player.PlayerData; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.AdventureUtil; import me.totalfreedom.totalfreedommod.util.FUtil; import net.kyori.adventure.text.Component; @@ -17,7 +16,7 @@ import org.bukkit.entity.Player; @Command(name = "nicknyan", description = "Essentials Interface Command - Nyanify your nickname.", usage = "/ < | off>") -@Permission(level = Rank.OP, source = SourceType.ONLY_IN_GAME, permission = "tfm.player.nicknyan") +@Permission(source = SourceType.ONLY_IN_GAME, permission = "tfm.player.nicknyan") public class Command_nicknyan extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_orbit.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_orbit.java index 1abfef834..fae713311 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_orbit.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_orbit.java @@ -5,7 +5,6 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; import me.totalfreedom.totalfreedommod.player.FPlayer; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.GameMode; import org.bukkit.command.CommandSender; @@ -13,7 +12,7 @@ import org.bukkit.util.Vector; @Command(name = "orbit", description = "POW!!! Right in the kisser! One of these days Alice, straight to the Moon!", usage = "/orbit ") -@Permission(permission = "tfm.fun.orbit", level = Rank.SUPER_ADMIN) +@Permission(permission = "tfm.fun.orbit") public class Command_orbit extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_permban.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_permban.java index b95df40ba..8e743dfb6 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_permban.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_permban.java @@ -15,13 +15,12 @@ import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchContext; import me.totalfreedom.totalfreedommod.player.PlayerData; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.FUtil; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @Command(name = "permban", description = "Manage permanently banned players and IPs.", usage = "/permban [ip...] | remove | reload>") -@Permission(permission = "tfm.admin.ban.perm", level = Rank.SUPER_ADMIN, source = SourceType.ONLY_CONSOLE) +@Permission(permission = "tfm.admin.ban.perm", source = SourceType.ONLY_CONSOLE) public class Command_permban extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_permbanlist.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_permbanlist.java index 3936a0c6d..94f582451 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_permbanlist.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_permbanlist.java @@ -6,13 +6,12 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import org.bukkit.command.CommandSender; @Command(name = "permbanlist", aliases = "pbanlist", description = "Shows all permanently banned players and IP addresses.", usage = "/ [page]") -@Permission(level = Rank.OP, permission = "tfm.admin.banlist") +@Permission(permission = "tfm.admin.banlist") public class Command_permbanlist extends FCommand { private static final int ENTRIES_PER_PAGE = 10; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_plugincontrol.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_plugincontrol.java index a6178bb9f..efbe5f48b 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_plugincontrol.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_plugincontrol.java @@ -2,7 +2,6 @@ import io.papermc.paper.plugin.configuration.PluginMeta; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @@ -11,7 +10,7 @@ import org.bukkit.plugin.Plugin; import org.bukkit.plugin.PluginManager; -@Permission(level = Rank.SENIOR_ADMIN, source = SourceType.ONLY_CONSOLE, permission = "tfm.admin.telnet.plugincontrol") +@Permission(source = SourceType.ONLY_CONSOLE, permission = "tfm.admin.telnet.plugincontrol") @Command(name = "plugincontrol", aliases = "plc", description = "Manage plugins", usage = "/ < > | list>") public class Command_plugincontrol extends FCommand { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_potionspy.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_potionspy.java index a01e4b862..e283229d5 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_potionspy.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_potionspy.java @@ -4,11 +4,10 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.player.PlayerData; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; @Command(name = "potionspy", description = "Spy on potion usage", usage = "/potionspy", aliases = {"potspy"}) -@Permission(permission = "tfm.admin.potspy", level = Rank.SUPER_ADMIN, source = SourceType.ONLY_IN_GAME) +@Permission(permission = "tfm.admin.potspy", source = SourceType.ONLY_IN_GAME) public class Command_potionspy extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_premium.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_premium.java index a36e40795..123300050 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_premium.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_premium.java @@ -9,7 +9,6 @@ import java.util.List; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.FLog; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @@ -17,7 +16,7 @@ import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; -@Permission(level = Rank.SUPER_ADMIN, source = SourceType.BOTH, permission = "tfm.admin.premium") +@Permission(source = SourceType.BOTH, permission = "tfm.admin.premium") @Command(name = "premium", description = "Validates if a given account is premium.", usage = "/premium ", aliases = "prem") public class Command_premium extends FCommand { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_protectarea.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_protectarea.java index feb889dd6..0dd05ecb6 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_protectarea.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_protectarea.java @@ -9,7 +9,6 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Subcommand; import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @@ -20,7 +19,7 @@ description = "Manage protected regions so that only superadmins can directly modify blocks within them. WorldEdit and other such plugins might bypass this.", usage = "/ | info | update | delete >", aliases = {"protectregion", "protect"}) -@Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.protectregion") +@Permission(permission = "tfm.admin.protectregion") public class Command_protectarea extends FCommand { @Callback @@ -54,7 +53,7 @@ public void clear(final CommandSender sender) @Callback @Subcommand("create") - @Permission(level = Rank.SUPER_ADMIN, source = SourceType.ONLY_IN_GAME, permission = "tfm.admin.protectregion") + @Permission(source = SourceType.ONLY_IN_GAME, permission = "tfm.admin.protectregion") public void create(final Player sender, final String name) { checkEnabled(); @@ -76,7 +75,7 @@ public void create(final Player sender, final String name) @Callback @Subcommand("update") - @Permission(level = Rank.SUPER_ADMIN, source = SourceType.ONLY_IN_GAME, permission = "tfm.admin.protectregion") + @Permission(source = SourceType.ONLY_IN_GAME, permission = "tfm.admin.protectregion") public void update(final Player sender, final ProtectedRegion region) { checkEnabled(); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_purgeall.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_purgeall.java index ada15bb68..9bac550a6 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_purgeall.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_purgeall.java @@ -4,13 +4,12 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; import me.totalfreedom.totalfreedommod.player.FPlayer; -import me.totalfreedom.totalfreedommod.rank.Rank; import org.bukkit.command.CommandSender; import org.bukkit.potion.PotionEffect; @Command(name = "purgeall", description = "Superadmin command - Purge everything! (except for bans).", usage = "/") -@Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.purgeall") +@Permission(permission = "tfm.admin.purgeall") public class Command_purgeall extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_radar.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_radar.java index 67eccb835..2705dc1f0 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_radar.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_radar.java @@ -3,7 +3,6 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.Location; @@ -13,7 +12,7 @@ import java.util.List; @Command(name = "radar", description = "Shows nearby people sorted by distance.", usage = "/radar [radius]") -@Permission(permission = "tfm.player.radar", level = Rank.OP, source = SourceType.ONLY_IN_GAME) +@Permission(permission = "tfm.player.radar", source = SourceType.ONLY_IN_GAME) public class Command_radar extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rank.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rank.java index cff402ec5..0761393c7 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rank.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rank.java @@ -3,8 +3,8 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; -import me.totalfreedom.totalfreedommod.rank.Displayable; -import me.totalfreedom.totalfreedommod.rank.Rank; +import me.totalfreedom.totalfreedommod.display.Displayable; +import me.totalfreedom.totalfreedommod.rank.CustomRank; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; @@ -12,7 +12,7 @@ import org.bukkit.entity.Player; @Command(name = "rank", description = "Shows ranks", usage = "/ [player]") -@Permission(level = Rank.NON_OP, permission = "tfm.player.rank") +@Permission(permission = "tfm.player.rank") public class Command_rank extends FCommand { @Callback @@ -31,12 +31,14 @@ public void querySelfOrAll(CommandSender sender) public void queryPlayer(CommandSender sender, Player player) { final Displayable display = plugin().rm.getDisplay(player); - final Rank rank = plugin().rm.getRank(player); + final CustomRank rank = plugin().rm.getEffectiveRank(player); Component result = Component.text(player.getName() + " is ", NamedTextColor.AQUA) .append(display.getColoredLoginMessage()); - if (rank != display) + // The display may be a cosmetic rank (developer, owner) that differs from the rank the + // player actually acts at, in which case both are worth showing. + if (rank != null && rank != display) { result = result.append(Component.text(" (", NamedTextColor.AQUA)) .append(rank.getColoredName()) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rankconfig.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rankconfig.java index 5c7a6caa8..92d0232f3 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rankconfig.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rankconfig.java @@ -9,7 +9,7 @@ import me.totalfreedom.totalfreedommod.cmd.internal.FuzzyMatch; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.rank.CustomRank; -import me.totalfreedom.totalfreedommod.rank.Rank; +import me.totalfreedom.totalfreedommod.rank.RankRole; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @@ -19,7 +19,7 @@ usage = "/rankconfig [list | create | edit | delete | set | setrank | reload | save]", aliases = {"rankconf", "rankcfg"} ) -@Permission(permission = "tfm.manage.ranks", level = Rank.SENIOR_ADMIN) +@Permission(permission = "tfm.manage.ranks") public class Command_rankconfig extends FCommand { @Callback @@ -125,7 +125,6 @@ public void set(CommandSender sender, String rank, Property property, @Greedy St case DETERMINER -> target.setDeterminer(value); case COLOR -> target.setColor(parseColor(value)); case ADMIN -> target.setAdmin(isTruthy(value)); - case CONSOLE -> target.setConsoleOnly(isTruthy(value)); case ADDPERM -> target.addPermission(value); case REMPERM -> target.removePermission(value); case LEVEL -> @@ -195,9 +194,11 @@ public void setRank(CommandSender sender, Player target, String rank) return; } - admin.setCustomRankId(null); + admin.setRankId(plugin().rm.getRegistry().byRole(RankRole.ADMIN_DEFAULT) + .map(CustomRank::getId) + .orElse(null)); plugin().al.saveAsync(); - msg(sender, "Cleared custom rank for ", Placeholder.unparsed("player", target.getName())); + msg(sender, "Reset to the baseline admin rank.", Placeholder.unparsed("player", target.getName())); return; } @@ -216,7 +217,7 @@ public void setRank(CommandSender sender, Player target, String rank) return; } - admin.setCustomRankId(rankId); + admin.setRankId(rankId); plugin().al.saveAsync(); adminAction( @@ -305,6 +306,6 @@ private static NamedTextColor parseColor(String name) private enum Property { - NAME, ABBREVIATION, LEVEL, COLOR, DETERMINER, ADMIN, CONSOLE, PREFIX, INHERIT, ADDPERM, REMPERM + NAME, ABBREVIATION, LEVEL, COLOR, DETERMINER, ADMIN, PREFIX, INHERIT, ADDPERM, REMPERM } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rawsay.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rawsay.java index 26434bfeb..95609d6a4 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rawsay.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rawsay.java @@ -3,11 +3,10 @@ import org.bukkit.command.CommandSender; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.FUtil; @Command(name = "rawsay", description = "Broadcasts the given message. Supports colors.", usage = "/rawsay ") -@Permission(permission = "tfm.admin.senior.rawsay", level = Rank.SENIOR_ADMIN) +@Permission(permission = "tfm.admin.senior.rawsay") public class Command_rawsay extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_realname.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_realname.java index 861282066..164ebabfa 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_realname.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_realname.java @@ -5,14 +5,13 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Greedy; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; import me.totalfreedom.totalfreedommod.player.PlayerData; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.AdventureUtil; import net.kyori.adventure.text.Component; import org.bukkit.command.CommandSender; @Command(name = "realname", description = "Finds the real name of a nicknamed player", usage = "/ ") -@Permission(level = Rank.OP, permission = "tfm.player.realname") +@Permission(permission = "tfm.player.realname") public class Command_realname extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_ro.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_ro.java index 0bec796cd..f1aada78d 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_ro.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_ro.java @@ -5,7 +5,6 @@ import java.util.Set; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @@ -15,7 +14,7 @@ import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; -@Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.ro") +@Permission(permission = "tfm.admin.ro") @Command(name = "ro", description = "Remove all blocks of a certain type in the radius of certain players.", usage = "/ [radius] [players]") public class Command_ro extends FCommand { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_saconfig.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_saconfig.java index 563b2c834..873965bdd 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_saconfig.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_saconfig.java @@ -20,7 +20,6 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Subcommand; import me.totalfreedom.totalfreedommod.player.FPlayer; import me.totalfreedom.totalfreedommod.rank.CustomRank; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @@ -36,63 +35,27 @@ // super admins entirely. tfm.admin.* legacy-maps to SUPER_ADMIN and matches this gate. The // mutating handlers below keep tfm.manage.saconfig, which now differs from this node and so is // actually tested rather than skipped as a repeat of the parent. -@Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.saconfig") +@Permission(permission = "tfm.admin.saconfig") public class Command_saconfig extends FCommand { @Callback @Subcommand("setrank") - @Permission(permission = "tfm.manage.saconfig", source = SourceType.ONLY_CONSOLE, level = Rank.SENIOR_ADMIN) - public void setRank(CommandSender sender, Player target, String rankInput) + @Permission(permission = "tfm.manage.saconfig", source = SourceType.ONLY_CONSOLE) + public void setRank(CommandSender sender, Player target, String rankInput) { - final CustomRank custom = plugin().rm != null ? plugin().rm.getCustomRank(rankInput) : null; - final Rank rank; - final String displayName; + final CustomRank rank = plugin().rm == null ? null : plugin().rm.getCustomRank(rankInput); - if (custom != null) + if (rank == null) { - if (custom.isConsoleOnly()) - { - msg(sender, "You cannot set players to a console rank"); - return; - } - if (!custom.isAdmin()) - { - msg(sender, "Rank \"\" is not an admin rank.", - Placeholder.unparsed("rank", custom.getName())); - return; - } - rank = resolveLegacyTier(custom); - if (rank == null) - { - msg(sender, "Rank \"\" has no legacy tier; set or inherit from super_admin or senior_admin.", - Placeholder.unparsed("rank", custom.getName())); - return; - } - displayName = custom.getName(); - } - else - { - try - { - rank = Rank.valueOf(rankInput.toUpperCase()); - } - catch (IllegalArgumentException ignored) - { - msg(sender, "Unknown rank: ", Placeholder.unparsed("rank", rankInput)); - return; - } - if (rank.isConsole()) - { - msg(sender, "You cannot set players to a console rank"); - return; - } - displayName = rank.getName(); + msg(sender, "Unknown rank: ", Placeholder.unparsed("rank", rankInput)); + return; } - if (!rank.isAtLeast(Rank.SUPER_ADMIN)) + if (!rank.isAdmin()) { - msg(sender, "Rank must be superadmin or higher."); + msg(sender, "Rank \"\" is not an admin rank.", + Placeholder.unparsed("rank", rank.getName())); return; } @@ -106,17 +69,16 @@ public void setRank(CommandSender sender, Player target, String rankInput) adminAction(sender, "Setting 's rank to ", Placeholder.unparsed("player", admin.getName()), - Placeholder.unparsed("rank", displayName)); + Placeholder.unparsed("rank", rank.getName())); - admin.setRank(rank); - admin.setCustomRankId(custom != null ? custom.getId() : null); + admin.setRankId(rank.getId()); plugin().al.updateTables(); plugin().al.saveAdminAsync(admin); plugin().rm.updatePlayerTeam(target); msg(sender, "Set 's rank to .", Placeholder.unparsed("player", admin.getName()), - Placeholder.unparsed("rank", displayName)); + Placeholder.unparsed("rank", rank.getName())); } @Callback @@ -140,7 +102,7 @@ public boolean getInfo(CommandSender sender, Player target) @Callback @Subcommand("add") - @Permission(permission = "tfm.manage.saconfig", source = SourceType.ONLY_CONSOLE, level = Rank.SENIOR_ADMIN) + @Permission(permission = "tfm.manage.saconfig", source = SourceType.ONLY_CONSOLE) public boolean addUser(CommandSender sender, Player target) { if (plugin().al.isAdmin(target)) @@ -200,7 +162,7 @@ public boolean addUser(CommandSender sender, Player target) @Callback @Subcommand("remove") - @Permission(permission = "tfm.manage.saconfig", source = SourceType.ONLY_CONSOLE, level = Rank.SENIOR_ADMIN) + @Permission(permission = "tfm.manage.saconfig", source = SourceType.ONLY_CONSOLE) public boolean removeUser(CommandSender sender, String username) { final Admin admin = plugin().al.getEntryByName(username); @@ -239,7 +201,7 @@ public boolean reload(CommandSender sender) @Callback @Subcommand("clean") - @Permission(permission = "tfm.manage.saconfig", level = Rank.SENIOR_ADMIN, source = SourceType.ONLY_CONSOLE) + @Permission(permission = "tfm.manage.saconfig", source = SourceType.ONLY_CONSOLE) public boolean clean(CommandSender sender) { adminAction(sender, "Cleaning admin list"); @@ -265,17 +227,12 @@ public List completeSetrankAdmin(CommandSender sender, String partial) @Completer(value = "setrank", position = 1) public List completeSetrankRank(CommandSender sender, String partial) { - final Stream rankNames = Stream.of(Rank.values()) - .filter(rank -> rank.isAdmin() && !rank.isConsole()) - .map(rank -> rank.name().toLowerCase(Locale.ROOT)); - - final Stream customRankIds = plugin().rm.getCustomRanks() - .values() - .stream() - .filter(custom -> custom.isAdmin() && !custom.isConsoleOnly()) - .map(CustomRank::getId); - - final List candidates = Stream.concat(rankNames, customRankIds).toList(); + final List candidates = plugin().rm.getCustomRanks() + .values() + .stream() + .filter(CustomRank::isAdmin) + .map(CustomRank::getId) + .toList(); return FuzzyMatch.filter(candidates, partial); } @@ -319,52 +276,28 @@ private void getAdminList(CommandSender sender) return; } - final Map> byRank = activeAdmins.stream().collect( - Collectors.groupingBy(a -> a.getRank(), () -> new EnumMap<>(Rank.class), Collectors.toList()) - ); - - Stream.of(Rank.values()) - .filter(r -> r.isAdmin() && !r.isConsole()) - .filter(byRank::containsKey) - .sorted(Comparator.comparingInt(Rank::getLevel).reversed()) - .forEach(rank -> { - List bucket = byRank.get(rank); - String joinedAdmins = bucket.stream() - .sorted(Comparator.comparing(a -> a.getName().toLowerCase())) - .map(admin -> { - CustomRank customRank = admin.getCustomRankId() == null - ? null - : plugin().rm.getCustomRank(admin.getCustomRankId()); - - if (customRank != null) { - String tagText = customRank.getName(); - String colorTag = customRank.getColor().asHexString(); - return String.format("<%s>%s %s", colorTag, tagText, admin.getName()); - } - return String.format("%s", admin.getName()); - }) - .collect(Collectors.joining(", ")); - - String rankColorTag = "<" + rank.getColor().asHexString() + ">"; - String line = rankColorTag + rank.getName() + "s: " + joinedAdmins; - - msg(sender, line); - }); + // Group by the rank each admin actually holds. Ranks come from the registry rather than a + // fixed set, so an operator-defined rank gets its own line instead of being folded into a + // tier that happens to sit near it. + final Map> byRank = activeAdmins.stream() + .filter(admin -> plugin().rm.getCustomRank(admin.getRankId()) != null) + .collect(Collectors.groupingBy(admin -> plugin().rm.getCustomRank(admin.getRankId()), + Collectors.toList())); + + byRank.keySet() + .stream() + .sorted(Comparator.comparingInt(CustomRank::getLevel).reversed()) + .forEach(rank -> + { + final String joinedAdmins = byRank.get(rank) + .stream() + .sorted(Comparator.comparing(admin -> admin.getName().toLowerCase())) + .map(admin -> String.format("%s", admin.getName())) + .collect(Collectors.joining(", ")); + + msg(sender, String.format("<%s>%ss: %s", + rank.getColor().asHexString(), rank.getName(), joinedAdmins)); + }); } - private Rank resolveLegacyTier(CustomRank custom) - { - return Stream.iterate(custom, Objects::nonNull, current -> plugin().rm.getCustomRank(current.getInheritFrom())) - .limit(32) - .map(current -> { - try { - return Rank.valueOf(current.getId().toUpperCase(Locale.ROOT)); - } catch (IllegalArgumentException ignored) { - return null; - } - }) - .filter(Objects::nonNull) - .findFirst() - .orElse(null); - } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_say.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_say.java index aee009869..5d65b9012 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_say.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_say.java @@ -4,7 +4,6 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.ChatMentionUtil; import me.totalfreedom.totalfreedommod.util.FUtil; import net.kyori.adventure.text.Component; @@ -12,7 +11,7 @@ import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @Command(name = "say", description = "Broadcasts the given message as the console, includes sender name.", usage = "/say ") -@Permission(permission = "tfm.admin.say", level = Rank.SUPER_ADMIN) +@Permission(permission = "tfm.admin.say") public class Command_say extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_setspawn.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_setspawn.java index a57967334..de3eef007 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_setspawn.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_setspawn.java @@ -4,12 +4,11 @@ import org.bukkit.entity.Player; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.FUtil; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @Command(name = "setspawn", description = "Set the server spawn to your current location.", usage = "/setspawn") -@Permission(permission = "tfm.world.setspawn", level = Rank.SUPER_ADMIN, source = SourceType.ONLY_IN_GAME) +@Permission(permission = "tfm.world.setspawn", source = SourceType.ONLY_IN_GAME) public class Command_setspawn extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_settings.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_settings.java index bdad0775b..de0b5092e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_settings.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_settings.java @@ -11,14 +11,13 @@ import me.totalfreedom.totalfreedommod.PluginProvider; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import static me.totalfreedom.totalfreedommod.config.ConfigEntry.*; @Command(name = "settings", description = "Modify TotalFreedom Server Settings / Flags", usage = "/settings [toggle | set] [ | ]", aliases = {"toggle", "set", "tfset"}) -@Permission(permission = "tfm.server.settings", level = Rank.SUPER_ADMIN) +@Permission(permission = "tfm.server.settings") public class Command_settings extends FCommand { private static final double DEFAULT_EXPLOSION_RADIUS = 4.0; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_smite.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_smite.java index 69ca37ffb..f694b524b 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_smite.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_smite.java @@ -5,7 +5,6 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Greedy; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.FUtil; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.GameMode; @@ -15,7 +14,7 @@ import org.bukkit.entity.Player; @Command(name = "smite", description = "Someone being a little bitch? Smite them down...", usage = "/smite [reason]") -@Permission(permission = "tfm.fun.smite", level = Rank.SUPER_ADMIN) +@Permission(permission = "tfm.fun.smite") public class Command_smite extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_spawn.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_spawn.java index d40dc2189..862eb5ca6 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_spawn.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_spawn.java @@ -3,11 +3,10 @@ import org.bukkit.entity.Player; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @Command(name = "spawn", description = "Teleport to the server spawn.", usage = "/spawn [player]") -@Permission(permission = "tfm.player.spawn", level = Rank.NON_OP, source = SourceType.ONLY_IN_GAME) +@Permission(permission = "tfm.player.spawn", source = SourceType.ONLY_IN_GAME) public class Command_spawn extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_spawnmob.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_spawnmob.java index 7fd23bb99..f88718da0 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_spawnmob.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_spawnmob.java @@ -3,7 +3,6 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.Location; @@ -12,7 +11,7 @@ import org.bukkit.event.entity.CreatureSpawnEvent; @Command(name = "spawnmob", description = "Spawns any mob.", usage = "/spawnmob [amount]") -@Permission(permission = "tfm.fun.spawnmob", level = Rank.OP, source = SourceType.ONLY_IN_GAME) +@Permission(permission = "tfm.fun.spawnmob", source = SourceType.ONLY_IN_GAME) public class Command_spawnmob extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_sqlstatus.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_sqlstatus.java index a4e3d012f..1f1387233 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_sqlstatus.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_sqlstatus.java @@ -1,7 +1,6 @@ package me.totalfreedom.totalfreedommod.cmd; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.sql.ConnectionHandler.PoolStats; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @@ -9,7 +8,7 @@ import org.bukkit.command.CommandSender; @Command(name = "sqlstatus", description = "Show database connection pool health.", usage = "/sqlstatus") -@Permission(level = Rank.SENIOR_ADMIN, source = SourceType.BOTH, permission = "tfm.admin.sqlstatus") +@Permission(source = SourceType.BOTH, permission = "tfm.admin.sqlstatus") public class Command_sqlstatus extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_sshtotp.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_sshtotp.java index 71ab3d3ee..c3ae5c8bb 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_sshtotp.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_sshtotp.java @@ -1,7 +1,6 @@ package me.totalfreedom.totalfreedommod.cmd; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.ssh.SshIdentity; import me.totalfreedom.totalfreedommod.ssh.SshQrServer; import me.totalfreedom.totalfreedommod.ssh.TotpUtil; @@ -12,7 +11,7 @@ import java.util.UUID; -@Permission(level = Rank.SENIOR_ADMIN, source = SourceType.ONLY_CONSOLE, permission = "tfm.ssh.totp") +@Permission(source = SourceType.ONLY_CONSOLE, permission = "tfm.ssh.totp") @Command( name = "sshtotp", description = "Generate a TOTP secret for an SSH identity and serve a one-time QR setup page.", diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_stfu.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_stfu.java index 9c5c7452f..3d64183ad 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_stfu.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_stfu.java @@ -2,7 +2,6 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.player.FPlayer; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @@ -13,7 +12,7 @@ import java.util.List; import java.util.Objects; -@Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.mute") +@Permission(permission = "tfm.admin.mute") @Command(name = "stfu", aliases = "mute", description = "Mutes a player with brute force.", usage = "/ < [reason] | list | purge | all>") public class Command_stfu extends FCommand { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_stop.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_stop.java index 9828efd1f..f9e4ac621 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_stop.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_stop.java @@ -2,10 +2,9 @@ import org.bukkit.command.CommandSender; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; @Command(name = "stop", description = "Kicks everyone and stops the server.", usage = "/stop") -@Permission(permission = "tfm.server.stop", level = Rank.SENIOR_ADMIN, source = SourceType.ONLY_CONSOLE) +@Permission(permission = "tfm.server.stop", source = SourceType.ONLY_CONSOLE) public class Command_stop extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_strikes.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_strikes.java index 240dcda8d..e1b22e028 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_strikes.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_strikes.java @@ -2,14 +2,13 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.player.PlayerData; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; -@Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.strike") +@Permission(permission = "tfm.admin.strike") @Command(name = "strikes", aliases = "strike", description = "Manages the strikes for a player.", usage = "/ ") public class Command_strikes extends FCommand { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_survival.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_survival.java index 1eb8b3eb4..a6a138a74 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_survival.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_survival.java @@ -6,7 +6,6 @@ import org.bukkit.entity.Player; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @Command( @@ -28,7 +27,7 @@ public void changeGamemodeSelf(Player player) @Callback @Subcommand("-a") - @Permission(permission = "tfm.admin.gamemode", level = Rank.SUPER_ADMIN) + @Permission(permission = "tfm.admin.gamemode") public void changeGamemodeAll(CommandSender sender) { Bukkit.getOnlinePlayers().forEach(player -> player.setGameMode(GameMode.SURVIVAL)); @@ -36,7 +35,7 @@ public void changeGamemodeAll(CommandSender sender) } @Callback - @Permission(permission = "tfm.admin.gamemode", level = Rank.SUPER_ADMIN) + @Permission(permission = "tfm.admin.gamemode") public void changeGamemodeOther(CommandSender sender, Player target) { target.setGameMode(GameMode.SURVIVAL); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_tag.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_tag.java index fb9740383..a91390924 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_tag.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_tag.java @@ -15,7 +15,6 @@ import me.totalfreedom.totalfreedommod.player.FPlayer; import me.totalfreedom.totalfreedommod.player.PlayerData; import me.totalfreedom.totalfreedommod.rank.CustomRank; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.AdventureUtil; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; @@ -189,11 +188,6 @@ public static boolean containsForbidden(String plainText) { final List terms = new ArrayList<>(FORBIDDEN_WORDS); - Stream.of(Rank.values()) - .filter(Rank::isAdmin) - .filter(r -> !r.getTag().isEmpty()) - .forEach(r -> terms.add(r.getTag())); - PluginProvider.get() .rm .getCustomRanksSorted() diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_tempban.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_tempban.java index 75923c0c0..5cee363d4 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_tempban.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_tempban.java @@ -11,7 +11,6 @@ import me.totalfreedom.totalfreedommod.cmd.resolver.DateOffsetArgumentResolver; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.player.PlayerData; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.FUtil; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; @@ -23,7 +22,7 @@ @Command(name = "tempban", aliases = {"tban", "noob"}, description = "Temporarily bans an online or previously known player.", usage = "/ [-s] [-rb] [duration] [reason]") -@Permission(permission = "tfm.admin.ban", level = Rank.SUPER_ADMIN) +@Permission(permission = "tfm.admin.ban") public class Command_tempban extends FCommand { private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd 'at' HH:mm:ss z"); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_title.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_title.java new file mode 100644 index 000000000..cfd5a89a1 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_title.java @@ -0,0 +1,192 @@ +package me.totalfreedom.totalfreedommod.cmd; + +import java.util.List; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Completer; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Subcommand; +import me.totalfreedom.totalfreedommod.title.Title; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +/** + * Grants, revokes and inspects titles. + *

+ * Granting is guarded by {@code tfm.manage.titles} rather than by the node the title itself + * carries: a title is a way to hand out a capability, so being able to hand out any of them is a + * strictly larger privilege than holding one. + */ +@Command( + name = "title", + description = "View and manage player titles.", + usage = "/title [list | info | of <player> | grant <player> <title> | revoke <player> <title>]", + aliases = {"titles"} +) +@Permission(permission = "tfm.player.titles") +public class Command_title extends FCommand +{ + + @Callback + public void list(CommandSender sender) + { + final List<Title> titles = plugin().tm.getTitlesSorted(); + + if (titles.isEmpty()) + { + msg(sender, "<gray>No titles are configured."); + return; + } + + msg(sender, "<gold>Titles:"); + titles.forEach(title -> msg(sender, "<line>", + MessageUtils.component("line", Component.text(" ") + .append(title.getColoredTag()) + .append(Component.text(" ")) + .append(title.getColoredName()) + .append(Component.text(" (" + title.getId() + ")", NamedTextColor.DARK_GRAY))))); + } + + @Callback + @Subcommand("list") + public void listExplicit(CommandSender sender) + { + list(sender); + } + + @Callback + @Subcommand("info") + public void info(CommandSender sender, String titleId) + { + final Title title = plugin().tm.getTitle(titleId); + + if (title == null) + { + msg(sender, "<red>No such title: <title>", Placeholder.unparsed("title", titleId)); + return; + } + + msg(sender, "<line>", MessageUtils.component("line", Component.text("Title: ", NamedTextColor.GOLD) + .append(title.getColoredTag()) + .append(Component.text(" ")) + .append(title.getColoredName()))); + msg(sender, "<gray> ID: <id>", Placeholder.unparsed("id", title.getId())); + msg(sender, "<gray> Display weight: <weight>", Placeholder.unparsed("weight", String.valueOf(title.getWeight()))); + + if (title.getPermissions().isEmpty()) + { + msg(sender, "<gray> Grants: <dark_gray><italic>nothing (display only)"); + return; + } + + msg(sender, "<gray> Grants:"); + title.getPermissions() + .stream() + .sorted() + .forEach(permission -> msg(sender, "<gray> - <node>", Placeholder.unparsed("node", permission))); + } + + @Callback + @Subcommand("of") + public void of(CommandSender sender, Player target) + { + final List<Title> held = plugin().tm.getHeldTitles(target); + + if (held.isEmpty()) + { + msg(sender, "<gray><player> holds no titles.", Placeholder.unparsed("player", target.getName())); + return; + } + + msg(sender, "<gray><player>'s titles:", Placeholder.unparsed("player", target.getName())); + held.forEach(title -> msg(sender, "<line>", + MessageUtils.component("line", Component.text(" ") + .append(title.getColoredTag()) + .append(Component.text(" ")) + .append(title.getColoredName())))); + } + + @Callback + @Subcommand("grant") + @Permission(permission = "tfm.manage.titles") + public void grant(CommandSender sender, Player target, String titleId) + { + final Title title = plugin().tm.getTitle(titleId); + + if (title == null) + { + msg(sender, "<red>No such title: <title>", Placeholder.unparsed("title", titleId)); + return; + } + + if (!plugin().tm.grantTitle(target, title.getId())) + { + msg(sender, "<red><player> already holds that title.", + Placeholder.unparsed("player", target.getName())); + return; + } + + adminAction(sender, "<aqua>Granted <title> to <player>", + Placeholder.unparsed("title", title.getName()), + Placeholder.unparsed("player", target.getName())); + + msg(target, "<green>You have been granted the <title> title.", + Placeholder.unparsed("title", title.getName())); + } + + @Callback + @Subcommand("revoke") + @Permission(permission = "tfm.manage.titles") + public void revoke(CommandSender sender, Player target, String titleId) + { + if (!plugin().tm.revokeTitle(target, titleId)) + { + msg(sender, "<red><player> does not hold that title.", + Placeholder.unparsed("player", target.getName())); + return; + } + + adminAction(sender, "<aqua>Revoked <title> from <player>", + Placeholder.unparsed("title", titleId), + Placeholder.unparsed("player", target.getName())); + + msg(target, "<gray>Your <title> title has been revoked.", + Placeholder.unparsed("title", titleId)); + } + + @Completer(value = "info", position = 0) + public List<String> completeInfo(CommandSender sender, String partial) + { + return matching(plugin().tm.getTitleIds(), partial); + } + + @Completer(value = "grant", position = 1) + public List<String> completeGrant(CommandSender sender, String partial) + { + return matching(plugin().tm.getTitleIds(), partial); + } + + /** + * Completes only titles the target already holds, so revoking offers the set that can actually + * be revoked rather than every title that exists. + */ + @Completer(value = "revoke", position = 1) + public List<String> completeRevoke(CommandSender sender, String partial) + { + return matching(plugin().tm.getTitleIds(), partial); + } + + private static List<String> matching(Iterable<String> candidates, String partial) + { + final String prefix = partial == null ? "" : partial.toLowerCase(); + + return java.util.stream.StreamSupport.stream(candidates.spliterator(), false) + .filter(id -> id.startsWith(prefix)) + .sorted() + .toList(); + } + +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_totalfreedommod.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_totalfreedommod.java index fae072722..dbb32e0d0 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_totalfreedommod.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_totalfreedommod.java @@ -8,7 +8,6 @@ import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.discord.DiscordBridge; import me.totalfreedom.totalfreedommod.player.FPlayer; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.command.CommandSender; @@ -16,11 +15,11 @@ * See https://github.com/TotalFreedom/License - This file may not be edited or removed. */ @Command(name = "totalfreedommod", description = "Shows information about TotalFreedomMod or reloads it", usage = "/totalfreedommod [reload]", aliases = {"tfm"}) -@Permission(permission = "tfm.server.info", level = Rank.NON_OP) +@Permission(permission = "tfm.server.info") public class Command_totalfreedommod extends FCommand { @Subcommand("reload") - @Permission(level = Rank.SUPER_ADMIN, permission = "tfm.server.info") + @Permission(permission = "tfm.server.reload") @Callback public void reloadPlugin(CommandSender sender) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_trail.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_trail.java index 53c9ea32c..8be7658a3 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_trail.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_trail.java @@ -3,10 +3,9 @@ import org.bukkit.entity.Player; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; @Command(name = "trail", description = "Pretty rainbow trails.", usage = "/trail [on | off]") -@Permission(permission = "tfm.fun.trail", level = Rank.SUPER_ADMIN, source = SourceType.ONLY_IN_GAME) +@Permission(permission = "tfm.fun.trail", source = SourceType.ONLY_IN_GAME) public class Command_trail extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_unban.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_unban.java index 7905cf98b..a07aa9043 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_unban.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_unban.java @@ -9,11 +9,10 @@ import me.totalfreedom.totalfreedommod.banning.Ban; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.player.PlayerData; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @Command(name = "unban", description = "Unbans an online or offline player and linked IP addresses.", usage = "/unban [-s] [-r] <player>") -@Permission(permission = "tfm.admin.ban", level = Rank.SUPER_ADMIN) +@Permission(permission = "tfm.admin.ban") public class Command_unban extends FCommand { @Completer(value = "", position = 0) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_unbanip.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_unbanip.java index d1dfc9846..b0b1ff71c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_unbanip.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_unbanip.java @@ -5,12 +5,11 @@ import org.bukkit.command.CommandSender; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.FUtil; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @Command(name = "unbanip", description = "Unbans an IP address.", usage = "/unbanip <ip>") -@Permission(permission = "tfm.admin.ban", level = Rank.SUPER_ADMIN) +@Permission(permission = "tfm.admin.ban") public class Command_unbanip extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_undisguiseall.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_undisguiseall.java index 3a6c402c4..baab6c238 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_undisguiseall.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_undisguiseall.java @@ -3,10 +3,9 @@ import org.bukkit.command.CommandSender; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; @Command(name = "undisguiseall", description = "Undisguise all players on the server", usage = "/undisguiseall", aliases = {"uall"}) -@Permission(permission = "tfm.admin.undisguiseall", level = Rank.SUPER_ADMIN) +@Permission(permission = "tfm.admin.undisguiseall") public class Command_undisguiseall extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_warn.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_warn.java index 8d53a0439..df06c8fb2 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_warn.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_warn.java @@ -5,14 +5,13 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Greedy; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; @Command(name = "warn", description = "Warns a player.", usage = "/<command> <player> <reason>") -@Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.warn") +@Permission(permission = "tfm.admin.warn") public class Command_warn extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_whitelist.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_whitelist.java index bc424f9a3..617ba704a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_whitelist.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_whitelist.java @@ -3,7 +3,6 @@ import java.util.List; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.FUtil; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @@ -12,7 +11,7 @@ import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; -@Permission(level = Rank.OP, permission = "tfm.server.whitelist") +@Permission(permission = "tfm.server.whitelist") @Command(name = "whitelist", description = "Manage the whitelist.", usage = "/<command> <on | off | list | count | add <player> | remove <player> | addall | purge>") public class Command_whitelist extends FCommand { @@ -49,7 +48,7 @@ public void count(CommandSender sender) @Callback @Subcommand("on") - @Permission(level = Rank.SUPER_ADMIN, permission = "tfm.server.whitelist") + @Permission(permission = "tfm.server.whitelist.manage") public void on(CommandSender sender) { adminAction(sender, "<aqua>Turning the whitelist on."); @@ -58,7 +57,7 @@ public void on(CommandSender sender) @Callback @Subcommand("off") - @Permission(level = Rank.SUPER_ADMIN, permission = "tfm.server.whitelist") + @Permission(permission = "tfm.server.whitelist.manage") public void off(CommandSender sender) { adminAction(sender, "<aqua>Turning the whitelist off."); @@ -83,7 +82,7 @@ public List<String> completeRemove(CommandSender sender, String partial) @Callback @Subcommand("add") - @Permission(level = Rank.SUPER_ADMIN, permission = "tfm.server.whitelist") + @Permission(permission = "tfm.server.whitelist.manage") public void add(CommandSender sender, String name) { final OfflinePlayer player = resolveOfflinePlayer(name); @@ -94,7 +93,7 @@ public void add(CommandSender sender, String name) @Callback @Subcommand("remove") - @Permission(level = Rank.SUPER_ADMIN, permission = "tfm.server.whitelist") + @Permission(permission = "tfm.server.whitelist.manage") public void remove(CommandSender sender, String name) { final OfflinePlayer player = resolveOfflinePlayer(name); @@ -111,7 +110,7 @@ public void remove(CommandSender sender, String name) @Callback @Subcommand("addall") - @Permission(level = Rank.SUPER_ADMIN, permission = "tfm.server.whitelist") + @Permission(permission = "tfm.server.whitelist.manage") public void addAll(CommandSender sender) { adminAction(sender, "<aqua>Adding all online players to the whitelist."); @@ -131,7 +130,7 @@ public void addAll(CommandSender sender) @Callback @Subcommand("purge") - @Permission(level = Rank.SUPER_ADMIN, source = SourceType.ONLY_CONSOLE, permission = "tfm.server.whitelist") + @Permission(source = SourceType.ONLY_CONSOLE, permission = "tfm.server.whitelist.manage") public void purge(CommandSender sender) { adminAction(sender, "<aqua>Whitelist purging is temporarily disabled."); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_whohas.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_whohas.java index 57395105f..fa2757e2d 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_whohas.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_whohas.java @@ -3,7 +3,6 @@ import java.util.List; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.event.HoverEvent; @@ -13,7 +12,7 @@ import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; -@Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.whohas") +@Permission(permission = "tfm.admin.whohas") @Command(name = "whohas", aliases = "wh", description = "See who has a block and optionally clears the item.", usage = "/<command> [-clear] <item>") public class Command_whohas extends FCommand { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_wildcard.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_wildcard.java index ca1deaf97..69097eaf0 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_wildcard.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_wildcard.java @@ -5,12 +5,11 @@ import java.util.Objects; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.ssh.AttributedConsoleSender; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.command.CommandSender; -@Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.wildcard") +@Permission(permission = "tfm.admin.wildcard") @Command( name = "wildcard", description = "Run any command on all users, username placeholder = ?.", diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_wipeflatlands.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_wipeflatlands.java index e124d7237..ab6e397be 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_wipeflatlands.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_wipeflatlands.java @@ -3,10 +3,9 @@ import org.bukkit.command.CommandSender; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; @Command(name = "wipeflatlands", description = "Wipe the flatlands map. Requires manual restart after command is used.", usage = "/wipeflatlands") -@Permission(permission = "tfm.admin.senior.wipeflatlands", level = Rank.SENIOR_ADMIN, source = SourceType.ONLY_CONSOLE) +@Permission(permission = "tfm.admin.senior.wipeflatlands", source = SourceType.ONLY_CONSOLE) public class Command_wipeflatlands extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_wipeuserdata.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_wipeuserdata.java index 85e1f15df..e53877155 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_wipeuserdata.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_wipeuserdata.java @@ -5,11 +5,10 @@ import org.bukkit.command.CommandSender; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.FUtil; @Command(name = "wipeuserdata", description = "Removes essentials playerdata", usage = "/wipeuserdata") -@Permission(permission = "tfm.admin.senior.wipeuserdata", level = Rank.SENIOR_ADMIN, source = SourceType.ONLY_CONSOLE) +@Permission(permission = "tfm.admin.senior.wipeuserdata", source = SourceType.ONLY_CONSOLE) public class Command_wipeuserdata extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/FCommand.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/FCommand.java index 0a64e7775..29cf9d4b2 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/FCommand.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/FCommand.java @@ -13,7 +13,6 @@ import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.player.FPlayer; import me.totalfreedom.totalfreedommod.player.PlayerData; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.FTask; import me.totalfreedom.totalfreedommod.util.FUtil; import net.kyori.adventure.text.Component; @@ -84,15 +83,6 @@ protected void checkPlayer(CommandSender sender) } } - @Deprecated - protected void checkRank(CommandSender sender, Rank rank) - { - if (!plugin().rm.getRank(sender).isAtLeast(rank)) - { - noPerms(); - } - } - protected void adminAction(CommandSender sender, String action, Object... refs) { FUtil.adminAction(sender, MessageUtils.parse(String.format(action, refs))); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/CommandProcessor.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/CommandProcessor.java index ed389ddde..c094d5fc2 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/CommandProcessor.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/CommandProcessor.java @@ -732,7 +732,7 @@ private com.mojang.brigadier.Command<CommandSourceStack> buildDispatch( CommandSourceStack source = ctx.getSource(); CommandSender sender = PermissionGate.resolveSender(source.getSender()); - if (!PermissionGate.test(plugin, sender, methodPermission, classPermission, true)) + if (!PermissionGate.test(plugin, sender, methodPermission, true)) { return 0; } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/PermissionGate.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/PermissionGate.java index f5694874a..5b05080e3 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/PermissionGate.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/PermissionGate.java @@ -5,7 +5,6 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchContext; import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchSession; -import me.totalfreedom.totalfreedommod.rank.CustomRank; import me.totalfreedom.totalfreedommod.ssh.AttributedConsoleSender; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; @@ -14,6 +13,13 @@ /** * Enforces {@link Permission} declarations. + * <p> + * A declaration carries only a permission node, never a rank. The tier a node requires is derived + * from {@code ranks.json}, so this class does not compare rank levels itself: it asks + * {@code RankManager} whether the sender satisfies the node and lets the rank registry decide what + * that means. A handler that needs to be stricter than its class does so by declaring a distinct + * node, which removes the old special case where a repeated node had to be ignored so that a + * stricter {@code level()} could take over. */ public final class PermissionGate { @@ -25,158 +31,110 @@ public final class PermissionGate private PermissionGate() {} - public static CommandSender resolveSender(CommandSender raw) + public static CommandSender resolveSender(final CommandSender raw) { - RemoteDispatchSession session = RemoteDispatchContext.getActiveSession(); + final RemoteDispatchSession session = RemoteDispatchContext.getActiveSession(); if (session != null && !(raw instanceof Player)) - { return new AttributedConsoleSender(raw, session.getDisplayName()); - } + return raw; } /** * Tests whether {@code sender} may execute a handler guarded by {@code perm}. * - * @param sendMsg whether to message the sender on failure (pass {@code false} for - * silent checks such as Brigadier {@code requires()} / tab visibility) + * @param sendMsg whether to message the sender on failure (pass {@code false} for silent checks + * such as Brigadier {@code requires()} / tab visibility) */ - public static boolean test(TotalFreedomMod plugin, CommandSender sender, Permission perm, boolean sendMsg) + public static boolean test(final TotalFreedomMod plugin, final CommandSender sender, + final Permission perm, final boolean sendMsg) { - return test(plugin, sender, perm, null, sendMsg); + if (perm == null) + return true; + + if (!passesChannelGate(plugin, sender, sendMsg)) + return false; + + if (!passesSourceGate(sender, perm, sendMsg)) + return false; + + final boolean allowed = plugin.rm.hasPermission(sender, perm.permission()); + if (!allowed && sendMsg) + sender.sendMessage(Component.text(perm.message(), NamedTextColor.RED)); + + return allowed; } /** - * Tests whether {@code sender} may execute a subcommand handler guarded by {@code perm}, - * declared inside a command whose class-level guard is {@code parent}. - * <p> - * When a handler repeats its parent's permission node, that node cannot tell the two apart: - * anyone who passed the parent gate holds it already, so checking it again always succeeds - * and a stricter {@code level()} on the handler is silently ignored. In that case the node is - * skipped and {@link Permission#level()} decides, which is the only field that still - * discriminates. The node itself is not lost - the parent's own gate already enforced it - * before the sender could reach this handler. - * - * @param parent the class-level guard, or {@code null} when {@code perm} is itself the - * class-level guard and there is no outer scope to compare against + * Whether the sender is permitted to issue commands over the channel it arrived on at all, + * independently of what the command itself requires. */ - public static boolean test(TotalFreedomMod plugin, CommandSender sender, Permission perm, Permission parent, boolean sendMsg) + private static boolean passesChannelGate(final TotalFreedomMod plugin, final CommandSender sender, + final boolean sendMsg) { - if (perm == null) - { - return true; - } + final RemoteDispatchSession dispatch = RemoteDispatchContext.getActiveSession(); - RemoteDispatchSession dispatch = RemoteDispatchContext.getActiveSession(); if (dispatch != null) { - if (dispatch.isIdentified()) + if (!dispatch.isIdentified()) + return true; + + final String node = switch (dispatch.getChannel()) { - String permNode = switch (dispatch.getChannel()) - { - case SSH -> "tfm.manage.ssh"; - case DISCORD -> "tfm.manage.discord"; - }; - String channelLabel = switch (dispatch.getChannel()) - { - case SSH -> "SSH"; - case DISCORD -> "Discord"; - }; - if (!plugin.rm.hasPermission(sender, permNode)) - { - if (sendMsg) - { - sender.sendMessage(Component.text("You do not have permission to run commands via " + channelLabel + ".", NamedTextColor.RED)); - } - return false; - } - } - } - else if (!(sender instanceof Player) && plugin.al.getEntryByName(sender.getName()) != null) - { - if (!plugin.rm.hasPermission(sender, "tfm.manage.telnet")) + case SSH -> "tfm.manage.ssh"; + case DISCORD -> "tfm.manage.discord"; + }; + final String label = switch (dispatch.getChannel()) { - if (sendMsg) - { - sender.sendMessage(Component.text("You do not have permission to run commands via telnet.", NamedTextColor.RED)); - } - return false; - } + case SSH -> "SSH"; + case DISCORD -> "Discord"; + }; + + return requireChannelNode(plugin, sender, node, label, sendMsg); } - final Player player = sender instanceof Player p ? p : null; + if (!(sender instanceof Player) && plugin.al.getEntryByName(sender.getName()) != null) + return requireChannelNode(plugin, sender, "tfm.manage.telnet", "telnet", sendMsg); + + return true; + } + + private static boolean requireChannelNode(final TotalFreedomMod plugin, final CommandSender sender, + final String node, final String label, final boolean sendMsg) + { + if (plugin.rm.hasPermission(sender, node)) + return true; + + if (sendMsg) + sender.sendMessage(Component.text( + String.format("You do not have permission to run commands via %s.", label), + NamedTextColor.RED)); + + return false; + } - if (perm.source() == SourceType.ONLY_CONSOLE && player != null) + private static boolean passesSourceGate(final CommandSender sender, final Permission perm, + final boolean sendMsg) + { + final boolean isPlayer = sender instanceof Player; + + if (perm.source() == SourceType.ONLY_CONSOLE && isPlayer) { if (sendMsg) - { sender.sendMessage(ONLY_CONSOLE_MESSAGE); - } + return false; } - if (perm.source() == SourceType.ONLY_IN_GAME && player == null) + if (perm.source() == SourceType.ONLY_IN_GAME && !isPlayer) { if (sendMsg) - { sender.sendMessage(ONLY_PLAYER_MESSAGE); - } - return false; - } - String tfmPermission = perm.permission(); - if (tfmPermission != null && !tfmPermission.isEmpty() && !repeatsParentNode(perm, parent)) - { - boolean result = plugin.rm.hasPermission(sender, tfmPermission); - if (!result && sendMsg) - { - sender.sendMessage(Component.text(perm.message(), NamedTextColor.RED)); - } - return result; - } - - if (player != null) - { - boolean result = plugin.rm.getRank(player).isAtLeast(perm.level()); - if (!result && sendMsg) - { - sender.sendMessage(Component.text(perm.message(), NamedTextColor.RED)); - } - return result; + return false; } - // getEffectiveRank resolves how this sender earned its rank: an identified SSH or Discord - // session becomes that admin's own profile rank, custom rank included, while a host - // channel becomes its host_senders binding. Falling back to getRank keeps senders it - // cannot place working on the legacy scale. - final CustomRank effective = plugin.rm.getEffectiveRank(sender); - - // A CustomRank's level is an operator-defined scale that need not line up with - // Rank.ordinal(), so testing one straight against perm.level() can be off by a tier and - // deny a sender its own rank. Resolve the requirement through the same registry the - // sender's rank came from, so both sides are read off one scale. - final CustomRank required = plugin.rm.getCustomRankForLegacy(perm.level()); - final boolean result = effective != null && required != null - ? effective.isAtLeast(required) - : plugin.rm.getRank(sender).isAtLeast(perm.level()); - if (!result && sendMsg) - { - sender.sendMessage(Component.text(perm.message(), NamedTextColor.RED)); - } - return result; + return true; } - /** - * Whether {@code perm} is a handler-level guard that declares the same node as its parent. - * <p> - * The identity check matters: a handler with no {@code @Permission} of its own is handed the - * class-level annotation itself, and that is not a repeat - there is no distinct handler - * level to defer to, so the node check must stand. - */ - private static boolean repeatsParentNode(Permission perm, Permission parent) - { - return parent != null - && parent != perm - && perm.permission().equals(parent.permission()); - } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Permission.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Permission.java index f4ffca437..62b5ea828 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Permission.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Permission.java @@ -6,22 +6,32 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import me.totalfreedom.totalfreedommod.cmd.SourceType; -import me.totalfreedom.totalfreedommod.rank.Rank; /** * Declares who may run a command (or a single subcommand handler) and from where. * <p> - * Place on an {@code FCommand} class. The check is also wired into the Brigadier root node's {@code requires()}, - * so unauthorized senders don't see the command in tab completion. - * Place on a handler method to indicate a permission for that specific subcommand (this overrides the parent on a permissible check). + * Place on an {@code FCommand} class. The check is also wired into the Brigadier root node's + * {@code requires()}, so unauthorized senders don't see the command in tab completion. Place on a + * handler method to guard that specific subcommand, which overrides the class-level declaration. + * <p> + * A declaration names only the internal permission node it needs; it never names a rank. The rank + * required to reach a handler is derived from {@code ranks.json} by resolving which is the least + * privileged rank that grants the node, so re-tiering a command is a config edit rather than a code + * change. Two consequences follow, and both matter when adding a node: + * <ul> + * <li>A handler that must be stricter than its parent needs its own <em>distinct</em> node. A node + * shared with the parent resolves to the same rank for both, silently dropping the raise.</li> + * <li>A node no rank grants is unreachable rather than open. A typo therefore denies everyone, + * which is the safe direction, but it will not surface until someone tries the command.</li> + * </ul> + * These nodes are internal to TFM and are deliberately never registered as Bukkit permissions: + * every player on a TotalFreedom server is opped, so a Bukkit node would effectively grant itself. */ @Retention(RetentionPolicy.RUNTIME) @Target({ElementType.TYPE, ElementType.METHOD}) @Documented public @interface Permission { - Rank level() default Rank.OP; - SourceType source() default SourceType.BOTH; String permission(); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordCommands.java b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordCommands.java index 803f03d8f..dea62ea0d 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordCommands.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordCommands.java @@ -94,7 +94,7 @@ private void handleLink(SlashCommandInteractionEvent event) DiscordLinkJsonSync.writeSnapshot(plugin, repo); - event.reply("Linked as **" + admin.getName() + "** (" + admin.getRank().getName() + ").") + event.reply("Linked as **" + admin.getName() + "** (" + admin.getRankId() + ").") .setEphemeral(true).queue(); FLog.info("[Discord] Linked admin " + admin.getName() + " ↔ Discord user " + event.getUser().getId() + "."); } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/rank/Displayable.java b/src/main/java/me/totalfreedom/totalfreedommod/display/Displayable.java similarity index 94% rename from src/main/java/me/totalfreedom/totalfreedommod/rank/Displayable.java rename to src/main/java/me/totalfreedom/totalfreedommod/display/Displayable.java index 7000e67df..ff7894c07 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/rank/Displayable.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/display/Displayable.java @@ -1,4 +1,4 @@ -package me.totalfreedom.totalfreedommod.rank; +package me.totalfreedom.totalfreedommod.display; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_help.java b/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_help.java index 96b27b726..2d93ba77b 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_help.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_help.java @@ -13,10 +13,14 @@ import me.totalfreedom.totalfreedommod.cmd.CommandRegistry; import me.totalfreedom.totalfreedommod.cmd.FCommand; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; +import me.totalfreedom.totalfreedommod.display.Displayable; +import me.totalfreedom.totalfreedommod.rank.CustomRank; +import me.totalfreedom.totalfreedommod.PluginProvider; + import static me.totalfreedom.totalfreedommod.httpd.HTMLGenerationTools.heading; import static me.totalfreedom.totalfreedommod.httpd.HTMLGenerationTools.paragraph; import me.totalfreedom.totalfreedommod.httpd.NanoHTTPD; -import me.totalfreedom.totalfreedommod.rank.Displayable; + import org.bukkit.command.Command; import org.bukkit.command.CommandMap; import org.bukkit.command.PluginIdentifiableCommand; @@ -87,7 +91,15 @@ public String getBody() continue; } - Displayable tfmCommandLevel = perm.level(); + // The tier is derived from ranks.json rather than declared, so a command whose node + // no rank grants has no heading to sit under and is listed without one. + Displayable tfmCommandLevel = requiredRank(perm); + if (tfmCommandLevel == null) + { + responseBody.append(buildDescription(command)); + continue; + } + if (lastTfmCommandLevel == null || lastTfmCommandLevel != tfmCommandLevel) { responseBody.append("</ul>\r\n").append(heading(tfmCommandLevel.getName(), 3)).append("<ul>\r\n"); @@ -102,6 +114,19 @@ public String getBody() return responseBody.toString(); } + /** + * The least privileged rank that grants {@code perm}'s node, which is the tier the command + * actually requires, or {@code null} when no rank grants it. + */ + private static CustomRank requiredRank(Permission perm) + { + final TotalFreedomMod plugin = PluginProvider.get(); + + return plugin == null || plugin.rm == null + ? null + : plugin.rm.getRegistry().requiredFor(perm.permission()).orElse(null); + } + private static String buildDescription(Command command) { // Fall back to annotation data from the FCommand registry. @@ -183,7 +208,15 @@ public int compare(Command a, Command b) return a.getName().compareTo(b.getName()); } - return pa.level().getName().compareTo(pb.level().getName()); + final CustomRank ra = requiredRank(pa); + final CustomRank rb = requiredRank(pb); + + if (ra == null || rb == null) + { + return a.getName().compareTo(b.getName()); + } + + return Integer.compare(ra.getLevel(), rb.getLevel()); } } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_players.java b/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_players.java index a5dfb27a1..34238c01a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_players.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_players.java @@ -43,14 +43,15 @@ public NanoHTTPD.Response getResponse() { final String username = admin.getName(); - switch (admin.getRank()) + // Buckets are by capability rather than by a named rank, so an operator-defined rank + // lands in the right one instead of vanishing from the feed. + if (plugin.al.grantsSeniorStatus(admin)) { - case SUPER_ADMIN: - superadmins.add(username); - break; - case SENIOR_ADMIN: - senioradmins.add(username); - break; + senioradmins.add(username); + } + else + { + superadmins.add(username); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerData.java b/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerData.java index 842ebe317..b62f19876 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerData.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerData.java @@ -1,8 +1,11 @@ package me.totalfreedom.totalfreedommod.player; import com.google.common.collect.Lists; +import com.google.common.collect.Sets; +import java.util.Collection; import java.util.Collections; import java.util.List; +import java.util.Set; import me.totalfreedom.totalfreedommod.PluginProvider; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.util.AdventureUtil; @@ -35,6 +38,15 @@ public class PlayerData implements ConfigLoadable, Validatable private int strikes; private final List<String> ips = Lists.newArrayList(); + /** + * Ids of the titles this player holds. + * <p> + * A player may hold several at once; their capabilities are the union of what those titles + * grant, and no title implies any other. Stored as ids rather than as resolved titles so that + * deleting a title simply stops it applying instead of stranding every holder. + */ + private final Set<String> titles = Sets.newLinkedHashSet(); + public PlayerData(Player player) { this(player.getName()); @@ -152,6 +164,7 @@ public void loadFrom(ConfigurationSection cs) this.commandsBlocked = cs.getBoolean("commands_blocked", false); this.joinLeaveMessagesEnabled = cs.getBoolean("join_leave_messages", true); this.strikes = cs.getInt("strikes", 0); + setTitles(cs.getStringList("titles")); this.savedTag = cs.getString("saved_tag"); if (this.savedTag != null && this.savedTag.isEmpty()) { @@ -195,6 +208,51 @@ public void setJoinLeaveMessagesEnabled(boolean joinLeaveMessagesEnabled) this.joinLeaveMessagesEnabled = joinLeaveMessagesEnabled; } + /** + * The ids of every title this player holds. Unmodifiable: use {@link #addTitle(String)} and + * {@link #removeTitle(String)} so ids stay normalised. + */ + public Set<String> getTitles() + { + return Collections.unmodifiableSet(titles); + } + + /** + * Grants a title. Returns {@code false} when it was already held. + */ + public boolean addTitle(String titleId) + { + return titleId != null && titles.add(titleId.toLowerCase()); + } + + /** + * Revokes a title. Returns {@code false} when it was not held. + */ + public boolean removeTitle(String titleId) + { + return titleId != null && titles.remove(titleId.toLowerCase()); + } + + public boolean hasTitle(String titleId) + { + return titleId != null && titles.contains(titleId.toLowerCase()); + } + + /** + * Replaces the whole set, normalising ids. Used by the persistence layer on load. + */ + public void setTitles(Collection<String> titleIds) + { + titles.clear(); + if (titleIds != null) + { + titleIds.stream() + .filter(id -> id != null && !id.isBlank()) + .map(String::toLowerCase) + .forEach(titles::add); + } + } + public List<String> getIps() { return Collections.unmodifiableList(ips); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/rank/ConsoleSenderRegistry.java b/src/main/java/me/totalfreedom/totalfreedommod/rank/ConsoleSenderRegistry.java index de85d48b1..d79e6a58e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/rank/ConsoleSenderRegistry.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/rank/ConsoleSenderRegistry.java @@ -9,32 +9,30 @@ import me.totalfreedom.totalfreedommod.util.FLog; /** - * Loads and exposes the {@code host_senders:} whitelist that binds shared-secret / - * no-identity senders to a specific rank. - * - * Reload by calling {@link #load()} + * Loads and exposes the {@code host_senders:} whitelist that binds shared-secret / no-identity + * senders to a specific rank. + * <p> + * Reload by calling {@link #load()}. */ public class ConsoleSenderRegistry { /** * Console-class channels that reach the server through the host itself rather than through a - * user identity. Console access is the privilege, so these sit at {@link Rank#SENIOR_ADMIN} - * and {@code host_senders:} may raise them but never lower them. A binding below the floor is - * a misconfiguration that silently locks the panel out of senior-gated commands, which is - * precisely how these channels were broken before. + * user identity. Console access is itself the privilege, so these hold the floor named by + * {@link RankRole#CONSOLE_FLOOR}, which {@code host_senders:} may raise but never lower. * <p> - * SSH and Discord are deliberately absent: they carry a real identity and are resolved per - * user by {@link RankManager#getEffectiveRank}, falling back to their {@code host_senders:} - * binding only when the session proved no identity. + * SSH and Discord are deliberately absent: they carry a real identity and are resolved per user + * by {@link RankRegistry}, falling back to their {@code host_senders:} binding only when the + * session proved no identity. */ private static final Set<String> HOST_CHANNELS = Set.of("rcon", "remotebukkit", "console"); - private static final Rank HOST_CHANNEL_FLOOR = Rank.SENIOR_ADMIN; + private static final String ENTRY_SEPARATOR = ":"; private final TotalFreedomMod plugin; + private final Map<String, String> senderToRankId = new HashMap<>(); - private final Map<String, Rank> senderToLegacyRank = new HashMap<>(); public ConsoleSenderRegistry(TotalFreedomMod plugin) { @@ -44,158 +42,128 @@ public ConsoleSenderRegistry(TotalFreedomMod plugin) public void load() { senderToRankId.clear(); - senderToLegacyRank.clear(); - List<?> raw = ConfigEntry.HOST_SENDERS.getList(); + final List<?> raw = ConfigEntry.HOST_SENDERS.getList(); if (raw == null) { - FLog.warning("Host sender whitelist (config 'host_senders:') is missing. Identity-less senders will be denied, " - + "except the host channels, which hold their floor."); + FLog.warning("Host sender whitelist (config 'host_senders:') is missing. Identity-less senders " + + "will be denied, except the host channels, which hold their floor."); applyHostChannelFloor(); return; } - for (Object obj : raw) - { - if (!(obj instanceof String)) - { - FLog.warning("Console whitelist entry is not a string, skipping: " + obj); - continue; - } - String entry = ((String) obj).trim(); - int colon = entry.indexOf(':'); - if (colon <= 0 || colon == entry.length() - 1) - { - FLog.warning("Console whitelist entry is malformed (expected '<rank_id>:<sender_name>'), skipping: " + entry); - continue; - } + raw.forEach(this::loadEntry); + applyHostChannelFloor(); - String rankId = entry.substring(0, colon).trim(); - String senderName = entry.substring(colon + 1).trim().toLowerCase(); + FLog.info(String.format("Loaded %d console whitelist binding(s).", senderToRankId.size())); + } - Rank legacyRank = parseLegacyRank(rankId); - if (legacyRank == null && !isKnownCustomRank(rankId)) - { - FLog.warning("Console whitelist entry references unknown rank '" + rankId + "', skipping: " + entry); - continue; - } + public String getRankIdForSender(String senderName) + { + return senderName == null ? null : senderToRankId.get(senderName.toLowerCase()); + } - String existingId = senderToRankId.put(senderName, rankId.toLowerCase()); - if (existingId != null && !existingId.equalsIgnoreCase(rankId)) - { - FLog.warning("Console whitelist binds sender '" + senderName + "' to multiple ranks; using " + rankId); - } - if (legacyRank != null) - { - senderToLegacyRank.put(senderName, legacyRank); - } + public boolean isWhitelisted(String senderName) + { + return getRankIdForSender(senderName) != null; + } + + /** + * Parses one {@code <rank_id>:<sender_name>} entry, rejecting anything that is malformed or + * names a rank the registry does not know. + */ + private void loadEntry(final Object obj) + { + if (!(obj instanceof String)) + { + FLog.warning(String.format("Console whitelist entry is not a string, skipping: %s", obj)); + return; } - applyHostChannelFloor(); + final String entry = ((String) obj).trim(); + final int colon = entry.indexOf(ENTRY_SEPARATOR); + if (colon <= 0 || colon == entry.length() - 1) + { + FLog.warning(String.format( + "Console whitelist entry is malformed (expected '<rank_id>:<sender_name>'), skipping: %s", entry)); + return; + } + + final String rankId = entry.substring(0, colon).trim().toLowerCase(); + final String senderName = entry.substring(colon + 1).trim().toLowerCase(); - FLog.info("Loaded " + senderToRankId.size() + " console whitelist binding(s)."); + if (!isKnownRank(rankId)) + { + FLog.warning(String.format("Console whitelist entry references unknown rank '%s', skipping: %s", + rankId, entry)); + return; + } + + final String existing = senderToRankId.put(senderName, rankId); + if (existing != null && !existing.equals(rankId)) + { + FLog.warning(String.format("Console whitelist binds sender '%s' to multiple ranks; using %s", + senderName, rankId)); + } } /** * Raises every {@link #HOST_CHANNELS host channel} to {@link #HOST_CHANNEL_FLOOR}, whether it - * was bound too low or left out of {@code host_senders:} entirely. A binding above the floor is - * left alone, so operators can still hand a host channel a higher custom rank. + * was bound too low or left out of {@code host_senders:} entirely. A binding at or above the + * floor is left alone, so an operator can still hand a host channel a higher custom rank. */ private void applyHostChannelFloor() { + final CustomRank floor = hostChannelFloor(); + if (floor == null) + { + FLog.warning("No rank fills the console floor role, so host channels keep whatever " + + "'host_senders:' bound them to."); + return; + } + HOST_CHANNELS.forEach(channel -> { - Rank bound = senderToLegacyRank.get(channel); - if (bound != null && bound.isAtLeast(HOST_CHANNEL_FLOOR)) - { - return; - } + final String boundId = senderToRankId.get(channel); - // A custom rank has no legacy entry; only override it when it genuinely sits lower, - // so 'executive:rcon' and friends survive. - String boundId = senderToRankId.get(channel); - if (bound == null && boundId != null && outranksFloor(boundId)) - { + if (boundId != null && outranksFloor(boundId, floor)) return; - } if (boundId != null) { - FLog.warning("Console whitelist binds host channel '" + channel + "' to '" + boundId - + "', below the " + HOST_CHANNEL_FLOOR.name().toLowerCase() + " floor for host channels; raising it."); + FLog.warning(String.format( + "Console whitelist binds host channel '%s' to '%s', below the '%s' floor for host " + + "channels; raising it.", channel, boundId, floor.getId())); } - senderToRankId.put(channel, HOST_CHANNEL_FLOOR.name().toLowerCase()); - senderToLegacyRank.put(channel, HOST_CHANNEL_FLOOR); + senderToRankId.put(channel, floor.getId()); }); } /** - * Whether the custom rank {@code rankId} sits at or above the host-channel floor, compared on - * the registry's own level scale so operator-defined numbering is honoured. + * The rank host channels are floored at, taken from whichever rank fills that role. */ - private boolean outranksFloor(String rankId) + private CustomRank hostChannelFloor() { - if (plugin.rm == null) - { - return false; - } - - CustomRank bound = plugin.rm.getCustomRank(rankId); - CustomRank floor = plugin.rm.getCustomRankForLegacy(HOST_CHANNEL_FLOOR); - - return bound != null && floor != null && bound.isAtLeast(floor); - } - - public String getRankIdForSender(String senderName) - { - if (senderName == null) - { - return null; - } - return senderToRankId.get(senderName.toLowerCase()); + return plugin.rm == null + ? null + : plugin.rm.getRegistry().byRole(RankRole.CONSOLE_FLOOR).orElse(null); } - public Rank getRankForSender(String senderName) + /** + * Whether {@code rankId} sits at or above the host-channel floor, compared on the registry's own + * level scale so operator-defined numbering is honoured. + */ + private boolean outranksFloor(final String rankId, final CustomRank floor) { - if (senderName == null) - { - return null; - } - return senderToLegacyRank.get(senderName.toLowerCase()); - } + final CustomRank bound = plugin.rm.getCustomRank(rankId); - public boolean isWhitelisted(String senderName) - { - return getRankIdForSender(senderName) != null; + return bound != null && bound.isAtLeast(floor); } - private boolean isKnownCustomRank(String rankId) + private boolean isKnownRank(final String rankId) { - return plugin.rm != null && plugin.rm.getCustomRank(rankId.toLowerCase()) != null; + return plugin.rm != null && plugin.rm.getCustomRank(rankId) != null; } - private static Rank parseLegacyRank(String id) - { - if (id == null || id.isEmpty()) - { - return null; - } - String key = id.toUpperCase(); - try - { - Rank rank = Rank.valueOf(key); - switch (rank) - { - case SENIOR_CONSOLE: - return Rank.SENIOR_ADMIN; - default: - return rank; - } - } - catch (IllegalArgumentException ex) - { - return null; - } - } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/rank/CustomRank.java b/src/main/java/me/totalfreedom/totalfreedommod/rank/CustomRank.java index 2663058fb..cbc0fbcbe 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/rank/CustomRank.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/rank/CustomRank.java @@ -1,7 +1,10 @@ package me.totalfreedom.totalfreedommod.rank; +import java.util.EnumSet; import java.util.HashSet; import java.util.Set; + +import me.totalfreedom.totalfreedommod.display.Displayable; import me.totalfreedom.totalfreedommod.util.AdventureUtil; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; @@ -54,11 +57,6 @@ public class CustomRank implements Displayable, Comparable<CustomRank> */ private boolean admin = false; - /** - * Whether this rank is a console-only variant. - */ - private boolean consoleOnly = false; - /** * Internal permissions granted to this rank. * These are TFM-specific permission strings, NOT Bukkit permission nodes. @@ -75,6 +73,13 @@ public class CustomRank implements Displayable, Comparable<CustomRank> * ID of parent rank to inherit permissions from. */ private String inheritFrom = null; + + /** + * Jobs this rank fills for the plugin, declared in configuration so that no rank has to be + * named in code. Roles are not inherited: a role names exactly one rank, and letting a child + * pick one up from its parent would make two ranks claim it. + */ + private Set<RankRole> roles = EnumSet.noneOf(RankRole.class); /** * Flattened permissions including inherited permissions. Computed at runtime by @@ -93,28 +98,43 @@ public class CustomRank implements Displayable, Comparable<CustomRank> */ public CustomRank(String id) { - this.id = id.toLowerCase().replace(" ", "_"); + this.id = normalizeId(id); this.name = id; this.abbreviation = id.length() > 3 ? id.substring(0, 3).toUpperCase() : id.toUpperCase(); this.level = 0; invalidateCache(); } - + /** - * Creates a CustomRank from an existing Rank enum (for migration). + * Gson deserialises through this constructor rather than allocating the object unsafely, which + * is what keeps the field initialisers above running. Without it an entry that omits + * {@code determiner}, {@code color}, {@code permissions} or {@code roles} comes back null + * instead of carrying its documented default. + * <p> + * The id is deliberately left unset here: a rank carries its id as the key it is filed under + * rather than as a property of the entry, so {@link #assignId(String)} stamps it after the read. */ - public static CustomRank fromLegacyRank(Rank rank) - { - CustomRank custom = new CustomRank(rank.name().toLowerCase()); - custom.setName(rank.getName()); - custom.setDeterminer(rank.getDeterminer()); - custom.setAbbreviation(rank.getTag().replace("[", "").replace("]", "")); - custom.setLevel(rank.getLevel()); - custom.setColor(rank.getColor()); - custom.setAdmin(rank.isAdmin()); - custom.setConsoleOnly(rank.isConsole()); + private CustomRank() {} - return custom; + /** + * Stamps the id this rank was filed under, normalising it exactly as the public constructor + * does so that a rank's id always matches the key it is stored against. + */ + public void assignId(final String key) + { + this.id = normalizeId(key); + invalidateCache(); + } + + /** + * Ids reach SQL as a primary key and are used to build scoreboard team names, so anything + * outside the safe set is stripped rather than stored. + */ + public static String normalizeId(final String raw) + { + return raw.toLowerCase() + .replace(' ', '_') + .replaceAll("[^a-z0-9_\\-]", ""); } /** @@ -131,7 +151,6 @@ public void loadFrom(ConfigurationSection cs) this.color = parseColor(colorName); this.admin = cs.getBoolean("admin", false); - this.consoleOnly = cs.getBoolean("console_only", false); this.prefix = cs.getString("prefix", null); this.inheritFrom = cs.getString("inherit", null); @@ -216,15 +235,6 @@ public boolean isAtLeast(CustomRank other) return this.level >= other.level; } - /** - * Check if this rank is at least as high as a legacy Rank. - */ - public boolean isAtLeast(Rank legacyRank) - { - if (legacyRank == null) return true; - return this.level >= legacyRank.getLevel(); - } - // ======================================================================== // Displayable Implementation // ======================================================================== @@ -389,16 +399,6 @@ public void setAdmin(boolean admin) this.admin = admin; } - public boolean isConsoleOnly() - { - return consoleOnly; - } - - public void setConsoleOnly(boolean consoleOnly) - { - this.consoleOnly = consoleOnly; - } - public Set<String> getPermissions() { return permissions; @@ -427,6 +427,21 @@ public String getInheritFrom() { return inheritFrom; } + + public Set<RankRole> getRoles() + { + return roles == null ? EnumSet.noneOf(RankRole.class) : roles; + } + + public void setRoles(Set<RankRole> roles) + { + this.roles = roles == null ? EnumSet.noneOf(RankRole.class) : EnumSet.copyOf(roles); + } + + public boolean hasRole(RankRole role) + { + return roles != null && roles.contains(role); + } public void setInheritFrom(String inheritFrom) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/rank/PermissionTrie.java b/src/main/java/me/totalfreedom/totalfreedommod/rank/PermissionTrie.java new file mode 100644 index 000000000..21ca1849c --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/rank/PermissionTrie.java @@ -0,0 +1,173 @@ +package me.totalfreedom.totalfreedommod.rank; + +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * An index of which ranks grant which internal permission nodes. + * <p> + * Permission nodes are dotted paths ({@code tfm.admin.ban}) and a rank may grant them either + * exactly or through a trailing wildcard ({@code tfm.admin.*}, or a bare {@code *} for everything). + * Answering "which ranks grant this node" by testing every rank against every wildcard prefix is + * what the old {@code hasCustomRankPermission} did on each check; indexing the nodes by segment + * instead turns that into a single walk whose length is the depth of the node being asked about, + * regardless of how many ranks or patterns exist. + * <p> + * The index is what lets a command declare only its permission node and have the required rank + * <em>derived</em> from {@code ranks.json}: see {@link #lowestGranting(String, Map)}. Because it is + * built from each rank's <em>resolved</em> permissions, inheritance is already folded in by the + * time a node reaches the index. + * <p> + * Not thread-safe. Rebuild it on the main thread whenever ranks change. + */ +public final class PermissionTrie +{ + + /** + * The wildcard suffix that makes a pattern cover everything beneath its prefix. + */ + private static final String WILDCARD = "*"; + + private static final String SEPARATOR = "\\."; + + private final Node root = new Node(); + + /** + * Discards the current index and rebuilds it from {@code ranks}, reading each rank's resolved + * permissions so that inherited grants are indexed alongside directly declared ones. + */ + public void rebuild(final Map<String, CustomRank> ranks) + { + root.children.clear(); + root.exact.clear(); + root.wildcard.clear(); + + ranks.values() + .forEach(rank -> rank.getResolvedPermissions() + .forEach(permission -> insert(permission, rank.getId()))); + } + + /** + * The ids of every rank that grants {@code permission}, whether exactly or through a wildcard. + * Returns an empty set when no rank grants it, which is the signal that a node is unreachable + * and its command therefore ungrantable. + */ + public Set<String> grantingRanks(final String permission) + { + if (permission == null || permission.isEmpty()) + return Set.of(); + + final String[] segments = permission.toLowerCase().split(SEPARATOR); + final Set<String> granting = new HashSet<>(root.wildcard); + + Node current = root; + for (int i = 0; i < segments.length; i++) + { + current = current.children.get(segments[i]); + if (current == null) + return Collections.unmodifiableSet(granting); + + // A node's wildcard set holds the ranks granting "<path-to-here>.*", which covers + // anything strictly below it. The terminal segment is skipped so that "a.b.c.*" is not + // read as granting "a.b.c" itself, matching how the nodes are written in ranks.json. + if (i < segments.length - 1) + granting.addAll(current.wildcard); + } + + granting.addAll(current.exact); + + return Collections.unmodifiableSet(granting); + } + + /** + * Whether {@code rankId} grants {@code permission}. + */ + public boolean grants(final String rankId, final String permission) + { + return rankId != null && grantingRanks(permission).contains(rankId.toLowerCase()); + } + + /** + * The cheapest rank that grants {@code permission}, or {@code null} when no rank does. + * <p> + * This is the inversion the command gate depends on. A handler declares only the node it needs; + * the tier required to reach that handler is whatever the least privileged rank holding it + * happens to be, so moving a permission between ranks in {@code ranks.json} moves the gate with + * it and no annotation has to name a rank. Where two ranks genuinely differ in what they may do + * with the same feature, they are expected to hold distinct nodes rather than share one, since + * a shared node necessarily resolves to the lower of the two. + * + * @param ranks the registry to resolve ids against, so levels are read off one scale + */ + public CustomRank lowestGranting(final String permission, final Map<String, CustomRank> ranks) + { + return grantingRanks(permission).stream() + .map(ranks::get) + .filter(rank -> rank != null) + .min(Comparator.comparingInt(CustomRank::getLevel)) + .orElse(null); + } + + /** + * Indexes a single pattern, storing it against the node for its prefix when it ends in a + * wildcard and against the node for the whole path otherwise. + */ + private void insert(final String permission, final String rankId) + { + if (permission == null || permission.isEmpty()) + return; + + final String pattern = permission.toLowerCase(); + + if (WILDCARD.equals(pattern)) + { + root.wildcard.add(rankId); + return; + } + + final String[] segments = pattern.split(SEPARATOR); + final boolean trailingWildcard = WILDCARD.equals(segments[segments.length - 1]); + final int depth = trailingWildcard ? segments.length - 1 : segments.length; + + Node current = root; + for (int i = 0; i < depth; i++) + { + current = current.children.computeIfAbsent(segments[i], ignored -> new Node()); + } + + if (trailingWildcard) + { + current.wildcard.add(rankId); + } + else + { + current.exact.add(rankId); + } + } + + /** + * One dotted segment of the index. Mutable and deliberately package-private in behaviour: it is + * an implementation detail of the surrounding trie and never escapes it. + */ + private static final class Node + { + + private final Map<String, Node> children = new HashMap<>(); + + /** + * Ranks granting the exact path ending at this node. + */ + private final Set<String> exact = new HashSet<>(); + + /** + * Ranks granting this node's path followed by {@code .*}. + */ + private final Set<String> wildcard = new HashSet<>(); + + } + +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/rank/Rank.java b/src/main/java/me/totalfreedom/totalfreedommod/rank/Rank.java deleted file mode 100644 index 15cb567fc..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/rank/Rank.java +++ /dev/null @@ -1,176 +0,0 @@ -package me.totalfreedom.totalfreedommod.rank; - -import lombok.Getter; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; -import net.kyori.adventure.text.format.TextDecoration; - -@Getter -public enum Rank implements Displayable -{ - - IMPOSTOR("an", "Impostor", Type.PLAYER, "Imp", NamedTextColor.YELLOW), - NON_OP("a", "Non-Op", Type.PLAYER, "", NamedTextColor.GREEN), - OP("an", "Op", Type.PLAYER, "OP", NamedTextColor.RED), - SUPER_ADMIN("a", "Super Admin", Type.ADMIN, "SA", NamedTextColor.AQUA), - SENIOR_ADMIN("a", "Senior Admin", Type.ADMIN, "SrA", NamedTextColor.GOLD), - /** - * @deprecated use {@link #SENIOR_ADMIN} directly; console-class senders now resolve to admin ranks via {@code ConsoleSenderRegistry}. - */ - @Deprecated - SENIOR_CONSOLE("the", "Console", Type.ADMIN_CONSOLE, "Console", NamedTextColor.DARK_PURPLE); - private final Type type; - private final String name; - private final String determiner; - private final String tag; - private final Component coloredTag; - private final NamedTextColor color; - - private Rank(String determiner, String name, Type type, String abbr, NamedTextColor color) - { - this.type = type; - this.name = name; - this.determiner = determiner; - this.tag = abbr.isEmpty() ? "" : "[" + abbr + "]"; - this.color = color; - - // Build colored tag as Component - if (abbr.isEmpty()) - { - this.coloredTag = Component.empty(); - } - else - { - this.coloredTag = Component.text("[") - .color(NamedTextColor.DARK_GRAY) - .append(Component.text(abbr).color(color)) - .append(Component.text("]").color(NamedTextColor.DARK_GRAY)) - .append(Component.text("").color(color)); - } - } - - @Override - public Component getColoredName() - { - return Component.text(name).color(color); - } - - @Override - public Component getColoredLoginMessage() - { - return Component.text(determiner + " ") - .append(Component.text(name).color(color).decorate(TextDecoration.ITALIC)); - } - - public boolean isConsole() - { - return getType() == Type.ADMIN_CONSOLE; - } - - public int getLevel() - { - return ordinal(); - } - - public boolean isAtLeast(Rank rank) - { - if (getLevel() < rank.getLevel()) - { - return false; - } - - if (!hasConsoleVariant() || !rank.hasConsoleVariant()) - { - return true; - } - - return getConsoleVariant().getLevel() >= rank.getConsoleVariant().getLevel(); - } - - public boolean isAdmin() - { - return getType() == Type.ADMIN || getType() == Type.ADMIN_CONSOLE; - } - - // Manual getters - Lombok @Getter not processing on enum fields - public Type getType() - { - return type; - } - - public String getName() - { - return name; - } - - @Override - public Component getColoredTag() - { - return coloredTag; - } - - @Override - public NamedTextColor getColor() - { - return color; - } - - @Deprecated - public boolean hasConsoleVariant() - { - return getConsoleVariant() != null; - } - - @Deprecated - public Rank getConsoleVariant() - { - switch (this) - { - case SENIOR_ADMIN: - case SENIOR_CONSOLE: - return SENIOR_CONSOLE; - default: - return null; - } - } - - @Deprecated - public Rank getPlayerVariant() - { - switch (this) - { - case SENIOR_ADMIN: - case SENIOR_CONSOLE: - return SENIOR_ADMIN; - default: - return null; - } - } - - public static Rank findRank(String string) - { - try - { - return Rank.valueOf(string.toUpperCase()); - } - catch (Exception ignored) - { - } - - return Rank.NON_OP; - } - - public static enum Type - { - - PLAYER, - ADMIN, - ADMIN_CONSOLE; - - public boolean isAdmin() - { - return this != PLAYER; - } - } - -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java index 01f62df84..9a4ff774e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java @@ -24,6 +24,7 @@ import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchContext; import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchSession; +import me.totalfreedom.totalfreedommod.display.Displayable; import me.totalfreedom.totalfreedommod.player.FPlayer; import me.totalfreedom.totalfreedommod.sql.PersistenceQueue; import me.totalfreedom.totalfreedommod.sql.adapter.RankRepository; @@ -73,6 +74,12 @@ public class RankManager extends FreedomService */ private final Map<String, CustomRank> customRanks = Maps.newLinkedHashMap(); + /** + * Resolves senders to ranks and permission nodes to the tier they require. Reads + * {@link #customRanks} live, so a reload is visible without rebuilding it. + */ + private final RankRegistry registry; + /** * File for storing custom ranks. */ @@ -90,6 +97,16 @@ public class RankManager extends FreedomService public RankManager(TotalFreedomMod plugin) { super(plugin); + this.registry = new RankRegistry(plugin, customRanks); + } + + /** + * The rank registry, which is the only supported way to ask what rank something holds or what + * tier a permission node requires. + */ + public RankRegistry getRegistry() + { + return registry; } private BukkitRunnable persistentMonitorTask = null; @@ -173,14 +190,16 @@ private void applyLoadedRanks(final RankRepository repo, final Map<String, Custo if (loaded.isEmpty() && !ranksFile.exists()) { - createDefaultRanks(); - migrateConfigRanks(); + installBundledRanks(); + + // The bundled set only reached memory. Push it so a database that started empty ends + // up holding the shipped ranks rather than staying empty until someone edits one. + saveRanks(); return; } customRanks.clear(); customRanks.putAll(loaded); - validateEssentialRanks(); resolveInheritance(); updateAllPlayerTeams(); FLog.info(String.format("Loaded %d custom ranks from SQL database.", customRanks.size())); @@ -192,14 +211,44 @@ private void loadFromJsonOrDefaults() { if (!ranksFile.exists()) { - createDefaultRanks(); - migrateConfigRanks(); + installBundledRanks(); return; } loadFromJson(); } + /** + * Writes the {@code ranks.json} bundled with the plugin into the data folder and loads it. + * <p> + * Ranks are defined entirely by that file; nothing in code knows a default tier any more, so a + * first run copies the shipped definitions rather than synthesising them. If the resource is + * somehow missing the registry is left empty, which denies every guarded command instead of + * inventing ranks that the operator never approved. + */ + private void installBundledRanks() + { + try + { + plugin.saveResource(RANKS_FILENAME, false); + } + catch (IllegalArgumentException ex) + { + FLog.severe(String.format("No bundled %s to install: %s", RANKS_FILENAME, ex.getMessage())); + return; + } + + if (!ranksFile.exists()) + { + FLog.severe(String.format("Could not install a default %s; all guarded commands will be denied.", + RANKS_FILENAME)); + return; + } + + FLog.info(String.format("Installed the default %s.", RANKS_FILENAME)); + loadFromJson(); + } + private void loadFromJson() { customRanks.clear(); @@ -212,7 +261,6 @@ private void loadFromJson() FLog.severe("Could not read " + RANKS_FILENAME + ": " + ex.getMessage()); } - validateEssentialRanks(); resolveInheritance(); updateAllPlayerTeams(); FLog.info("Loaded " + customRanks.size() + " custom ranks."); @@ -222,11 +270,33 @@ private Map<String, CustomRank> readJsonRanks() throws IOException { try (FileReader reader = new FileReader(ranksFile)) { - Map<String, CustomRank> loaded = JsonUtil.GSON.fromJson(reader, RANK_MAP_TYPE); - return loaded != null ? loaded : Maps.newLinkedHashMap(); + return stampIds(JsonUtil.GSON.fromJson(reader, RANK_MAP_TYPE)); } } + /** + * Re-files deserialised ranks under their own normalised id. + * <p> + * A JSON entry carries its id as the key it sits under rather than as a field, so a freshly + * deserialised rank has none. Stamping it here and re-keying the map on the result keeps the + * key and the rank's own id in agreement even where normalisation rewrites the key. + */ + private static Map<String, CustomRank> stampIds(final Map<String, CustomRank> loaded) + { + if (loaded == null) + return Maps.newLinkedHashMap(); + + final Map<String, CustomRank> keyed = Maps.newLinkedHashMap(); + + loaded.forEach((key, rank) -> + { + rank.assignId(key); + keyed.put(rank.getId(), rank); + }); + + return keyed; + } + /** * If ranks.json was written more recently than the database's last update, re-import it into * SQL. The comparison and the re-import both ride the write queue off the main thread. @@ -289,178 +359,6 @@ private void applyReconciledRanks(final Map<String, CustomRank> jsonRanks) updateAllPlayerTeams(); } - private static final String[] ESSENTIAL_RANKS = { - "non_op", "op", "super_admin", "senior_admin" - }; - - private void validateEssentialRanks() - { - boolean modified = false; - for (String rankId : ESSENTIAL_RANKS) - { - if (!customRanks.containsKey(rankId)) - { - FLog.warning("Essential rank '" + rankId + "' missing from ranks.yml, recreating..."); - Rank legacyRank = Rank.findRank(rankId); - CustomRank custom = CustomRank.fromLegacyRank(legacyRank); - customRanks.put(rankId, custom); - modified = true; - } - } - if (modified) - { - saveRanks(); - FLog.info("Repaired ranks.yml with missing essential ranks."); - } - } - - /** - * Create default ranks from the legacy Rank enum. - */ - private void createDefaultRanks() - { - customRanks.clear(); - - for (Rank legacyRank : Rank.values()) - { - CustomRank custom = CustomRank.fromLegacyRank(legacyRank); - - // Add default permissions based on rank type - switch (legacyRank) - { - case SENIOR_ADMIN: - case SENIOR_CONSOLE: - custom.addPermission("tfm.manage.ranks"); - custom.addPermission("tfm.admin.senior"); - // Fall through - custom.addPermission("tfm.admin.telnet"); - custom.addPermission("tfm.admin.ban.perm"); - // Fall through - case SUPER_ADMIN: - custom.addPermission("tfm.admin.ban"); - custom.addPermission("tfm.admin.kick"); - custom.addPermission("tfm.admin.mute"); - custom.addPermission("tfm.admin.freeze"); - custom.addPermission("tfm.admin.cage"); - custom.addPermission("tfm.fun.smite"); - custom.addPermission("tfm.fun.doom"); - custom.addPermission("tfm.world.gamerule"); - break; - case OP: - custom.addPermission("tfm.player.op"); - break; - default: - break; - } - - customRanks.put(custom.getId(), custom); - } - - resolveInheritance(); - saveRanks(); - FLog.info("Created default ranks configuration."); - } - - private void migrateConfigRanks() - { - applyConfigPrefix("impostor", ConfigEntry.VAULT_PREFIX_IMPOSTOR); - applyConfigPrefix("non_op", ConfigEntry.VAULT_PREFIX_NON_OP); - applyConfigPrefix("op", ConfigEntry.VAULT_PREFIX_OP); - applyConfigPrefix("super_admin", ConfigEntry.VAULT_PREFIX_SUPER_ADMIN); - applyConfigPrefix("senior_admin", ConfigEntry.VAULT_PREFIX_SENIOR_ADMIN); - applyConfigPrefix("senior_console", ConfigEntry.VAULT_PREFIX_SENIOR_CONSOLE); - applyConfigPrefix("developer", ConfigEntry.VAULT_PREFIX_DEVELOPER); - applyConfigPrefix("owner", ConfigEntry.VAULT_PREFIX_OWNER); - - List<String> owners = ConfigEntry.SERVER_OWNERS.getStringList(); - if (owners != null && !owners.isEmpty()) - { - int found = 0; - for (String ownerName : owners) - { - if (ownerName != null && !ownerName.trim().isEmpty()) - { - if (plugin.al.getEntryByName(ownerName.trim()) != null) - { - found++; - } - } - } - if (found > 0) - { - FLog.info("Found " + found + " owner(s) from config.yml. They will display with the owner rank."); - } - } - - saveRanks(); - removeConfigRanks(); - FLog.info("Migrated rank configuration from config.yml to ranks.yml."); - } - - private void applyConfigPrefix(String rankId, ConfigEntry entry) - { - String prefix = entry.getString(); - if (prefix != null && !prefix.isEmpty()) - { - CustomRank rank = getCustomRank(rankId); - if (rank != null) - { - rank.setPrefix(prefix); - } - } - } - - private void removeConfigRanks() - { - File configFile = new File(plugin.getDataFolder(), "config.yml"); - if (!configFile.exists()) - { - return; - } - - try - { - YamlConfiguration config = YamlConfiguration.loadConfiguration(configFile); - boolean modified = false; - - if (config.contains("server.owners")) - { - config.set("server.owners", null); - modified = true; - } - - String[] prefixKeys = { - "chat.prefix.impostor", "chat.prefix.non_op", "chat.prefix.op", - "chat.prefix.super_admin", "chat.prefix.senior_admin", - "chat.prefix.senior_console", - "chat.prefix.developer", "chat.prefix.owner" - }; - - for (String key : prefixKeys) - { - if (config.contains(key)) - { - config.set(key, null); - modified = true; - } - } - - ConfigurationSection prefixSection = config.getConfigurationSection("chat.prefix"); - if (prefixSection != null && prefixSection.getKeys(false).isEmpty()) - { - config.set("chat.prefix", null); - } - - if (modified) - { - config.save(configFile); - } - } - catch (IOException ex) - { - FLog.warning("Could not update config.yml: " + ex.getMessage()); - } - } /** * Queue a write of every custom rank to SQL, followed by a refresh of the ranks.json @@ -521,13 +419,16 @@ private void writeJson(final Map<String, CustomRank> snapshot) } } + /** + * Flattens each rank's inherited permissions, then rebuilds the registry's permission index so + * that the tier a node requires is recomputed from what the ranks now grant. + */ private void resolveInheritance() { - for (CustomRank rank : customRanks.values()) - { - Set<String> resolved = collectPermissions(rank, new HashSet<>()); - rank.setResolvedPermissions(resolved); - } + customRanks.values() + .forEach(rank -> rank.setResolvedPermissions(collectPermissions(rank, new HashSet<>()))); + + registry.reindex(); } private Set<String> collectPermissions(CustomRank rank, Set<String> visited) @@ -576,24 +477,14 @@ private CustomRank getAssignedAdminRank(Player player) return null; } - Admin admin = plugin.al.getAdmin(player); + final Admin admin = plugin.al.getAdmin(player); if (admin == null || !admin.isActive()) { return null; } - if (admin.getCustomRankId() != null) - { - CustomRank customRank = getCustomRank(admin.getCustomRankId()); - - if (customRank != null) - { - return customRank; - } - } - - return getCustomRankForLegacy(admin.getRank()); + return getCustomRank(admin.getRankId()); } public void updatePlayerTeam(Player player) @@ -605,16 +496,13 @@ public void updatePlayerTeam(Player player) return; } - Scoreboard scoreboard = manager.getMainScoreboard(); - Team currentTeam = scoreboard.getEntryTeam(player.getName()); - CustomRank rank = getAssignedAdminRank(player); - final boolean admin = rank != null && rank.isAdmin(); - - if (rank == null) - { - rank = CustomRank.fromLegacyRank(Rank.OP); // potential NPE, averting by setting to OP, should be an optional but that's outside of the scope. Rank system will get it's own dedicated branch scope. - } + final Scoreboard scoreboard = manager.getMainScoreboard(); + final Team currentTeam = scoreboard.getEntryTeam(player.getName()); + final CustomRank rank = getAssignedAdminRank(player); + // Only an admin rank earns its own team; everyone else shares the default one, so an + // unresolvable rank is an ordinary outcome here rather than something to substitute for. + final boolean admin = rank != null && rank.isAdmin(); final String teamName = admin ? createTeamName(rank) : DEFAULT_TEAM_NAME; if (currentTeam != null && !currentTeam.getName().equals(teamName)) @@ -689,14 +577,33 @@ public void setCustomRank(CustomRank rank) */ public boolean removeCustomRank(String id) { - CustomRank removed = customRanks.remove(id.toLowerCase()); - if (removed != null) + if (id == null) + return false; + + final CustomRank removed = customRanks.remove(CustomRank.normalizeId(id)); + if (removed == null) + return false; + + // saveRanks() only writes the ranks that survive, so without an explicit delete the row + // stays behind in SQL and the rank returns on the next load. + if (usingSql && plugin.dm != null && plugin.dm.isInitialized()) { - saveRanks(); - updateAllPlayerTeams(); - return true; + writes.enqueue(plugin.dm.getRankRepository() + .deleteAsync(removed.getId()) + .onErrorResume(ex -> + { + FLog.severe(String.format("Could not delete rank %s from SQL: %s", + removed.getId(), ex.getMessage())); + return Mono.empty(); + }) + .then()); } - return false; + + resolveInheritance(); + saveRanks(); + updateAllPlayerTeams(); + + return true; } /** @@ -712,141 +619,19 @@ public boolean hasCustomRank(String id) // ======================================================================== /** - * Check if a sender has a specific TFM permission. - * This does NOT use Bukkit permission nodes - it's purely internal. + * Whether {@code sender} may exercise an internal TFM permission node. + * <p> + * These are TFM's own nodes and are never registered with Bukkit: every player on a + * TotalFreedom server is opped, so a Bukkit node would grant itself. The answer is delegated to + * the {@link RankRegistry}, which resolves how this sender earned its rank and compares that + * against the tier the node requires, both read off {@code ranks.json}. * - * @param sender The command sender - * @param permission The TFM permission string (e.g., "tfm.admin.ban") - * @return true if the sender has the permission + * @param sender the command sender + * @param permission the internal node, for example {@code tfm.admin.ban} */ public boolean hasPermission(CommandSender sender, String permission) { - if (!(sender instanceof Player)) - { - if (sender instanceof BlockCommandSender || sender instanceof CommandMinecart) - { - return false; - } - - // getEffectiveRank already knows how this sender earned its rank. An identified SSH or - // Discord session resolves to that admin's own profile; a host channel resolves to its - // binding. That covers what the dispatch-excluded lookup here used to miss, namely - // that a remote user's custom rank was invisible to the permission check. - CustomRank effective = getEffectiveRank(sender); - if (effective != null && hasCustomRankPermission(effective, permission)) - { - return true; - } - - Rank rank = getRank(sender); - CustomRank customRank = getCustomRankForLegacy(rank); - if (customRank != null && hasCustomRankPermission(customRank, permission)) - { - return true; - } - return checkLegacyPermission(rank, permission); - } - - Player player = (Player) sender; - - // Check if admin - Admin admin = plugin.al.getAdmin(player); - if (admin != null && admin.isActive()) - { - // Try custom rank ID assigned to the admin first - if (admin.getCustomRankId() != null) - { - CustomRank custom = getCustomRank(admin.getCustomRankId()); - if (custom != null) - { - if (hasCustomRankPermission(custom, permission)) - { - return true; - } - } - } - - // Fallback to custom rank derived from legacy rank - CustomRank customRank = getCustomRankForLegacy(admin.getRank()); - if (customRank != null) - { - if (hasCustomRankPermission(customRank, permission)) - { - return true; - } - } - - // Legacy fallback: check rank level - return checkLegacyPermission(admin.getRank(), permission); - } - - // Non-admins: check if they have a custom rank assigned (for future expansion) - // For now, non-admins only have basic player permissions - CustomRank opRank = getCustomRank("op"); - if (player.isOp() && opRank != null) - { - return hasCustomRankPermission(opRank, permission); - } - - return false; - } - - private boolean hasCustomRankPermission(CustomRank rank, String permission) - { - if (rank.hasPermission(permission)) - { - return true; - } - - String[] parts = permission.split("\\."); - StringBuilder wildcard = new StringBuilder(); - for (int i = 0; i < parts.length - 1; i++) - { - wildcard.append(parts[i]).append("."); - if (rank.hasPermission(wildcard + "*")) - { - return true; - } - } - - return false; - } - - /** - * Check permission based on legacy rank level. - */ - private boolean checkLegacyPermission(Rank rank, String permission) - { - // Map common permissions to rank levels - if (permission.startsWith("tfm.manage.")) - { - return rank.isAtLeast(Rank.SENIOR_ADMIN); - } - if (permission.startsWith("tfm.admin.senior") || permission.equals("tfm.admin.ban.perm")) - { - return rank.isAtLeast(Rank.SENIOR_ADMIN); - } - if (permission.startsWith("tfm.admin.telnet")) - { - return rank.isAtLeast(Rank.SENIOR_ADMIN); - } - if (permission.startsWith("tfm.admin.")) - { - return rank.isAtLeast(Rank.SUPER_ADMIN); - } - if (permission.startsWith("tfm.fun.")) - { - return rank.isAtLeast(Rank.SUPER_ADMIN); - } - return false; - } - - /** - * Get the custom rank that corresponds to a legacy Rank enum. - */ - public CustomRank getCustomRankForLegacy(Rank legacyRank) - { - return getCustomRank(legacyRank.name().toLowerCase()); + return registry.satisfies(sender, permission); } /** @@ -1142,7 +927,6 @@ public Component buildEditMenu(CustomRank rank) builder.append(buildEditableProperty("Color", rank.getColor().toString(), "/rankconfig set " + rank.getId() + " color")); builder.append(buildEditableProperty("Determiner", rank.getDeterminer(), "/rankconfig set " + rank.getId() + " determiner")); builder.append(buildEditableProperty("Is Admin", String.valueOf(rank.isAdmin()), "/rankconfig set " + rank.getId() + " admin")); - builder.append(buildEditableProperty("Console Only", String.valueOf(rank.isConsoleOnly()), "/rankconfig set " + rank.getId() + " console")); builder.append(buildEditableProperty("Inherit From", rank.getInheritFrom() != null ? rank.getInheritFrom() : "(none)", "/rankconfig set " + rank.getId() + " inherit")); builder.append(Component.text("\n")); @@ -1317,257 +1101,57 @@ public void run() } } - public Displayable getDisplay(CommandSender sender) - { - if (!(sender instanceof Player)) - { - Rank rank = getRank(sender); - CustomRank custom = getCustomRankForLegacy(rank); - return custom != null ? custom : rank; - } - - final Player player = (Player) sender; - - if (plugin.al.isAdminImpostor(player)) - { - CustomRank impostorRank = getCustomRank("impostor"); - return impostorRank != null ? impostorRank : Rank.IMPOSTOR; - } - - if (FUtil.DEVELOPERS.contains(player.getName())) - { - CustomRank devRank = getCustomRank("developer"); - if (devRank != null) return devRank; - } - - final Rank rank = getRank(player); - - if (ConfigEntry.SERVER_OWNERS.getList().contains(player.getName())) - { - CustomRank ownerRank = getCustomRank("owner"); - if (ownerRank != null) return ownerRank; - } - - Admin admin = plugin.al.getAdmin(player); - if (admin != null && admin.isActive() && admin.getCustomRankId() != null) - { - CustomRank custom = getCustomRank(admin.getCustomRankId()); - if (custom != null) - { - return custom; - } - } - - CustomRank customRank = getCustomRankForLegacy(rank); - return customRank != null ? customRank : rank; - } - - /** - * Resolves the rank a sender actually acts at, as a {@link CustomRank}. - * <p> - * This is the identity-aware view the permission gate tests against, and it is the only place - * that knows how a sender earns its rank: - * <ul> - * <li>SSH and Discord carry a proven identity (an SSH public key, or a {@code discord_links} - * row), so an identified session resolves to that admin's own profile rank, custom rank - * included. A session that proved nothing, meaning password-only SSH, falls back to the - * channel's {@code host_senders:} binding, which grants no identity and so no profile.</li> - * <li>Host channels (RCON, RemoteBukkit, console) carry no identity at all and resolve to - * their binding, which {@link ConsoleSenderRegistry} floors at senior admin.</li> - * </ul> - * Returning a {@link CustomRank} rather than a {@link Rank} matters: custom ranks carry - * operator-defined levels that need not line up with {@link Rank#ordinal()}, and an admin - * holding {@code executive} or {@code owner} would otherwise be demoted to their legacy tier - * the moment they acted through a console channel. - * - * @return the sender's effective rank, or {@code null} when no rank could be resolved (the - * caller should then fall back to {@link #getRank(CommandSender)} on the legacy scale) - */ - public CustomRank getEffectiveRank(CommandSender sender) - { - if (sender instanceof Player player) - { - CustomRank assigned = getAssignedAdminRank(player); - if (assigned != null) - { - return assigned; - } - return getCustomRankForLegacy(getRank(player)); - } - - if (sender instanceof BlockCommandSender || sender instanceof CommandMinecart) - { - return getCustomRankForLegacy(Rank.NON_OP); - } - - RemoteDispatchSession dispatch = RemoteDispatchContext.getActiveSession(); - if (dispatch != null) - { - CustomRank identity = resolveDispatchIdentity(dispatch); - if (identity != null) - { - return identity; - } - - String channel = dispatch.getChannel() == RemoteDispatchSession.Channel.DISCORD ? "discord" : "ssh"; - return getBoundRank(channel); - } - - Admin admin = plugin.al.getEntryByName(sender.getName()); - if (admin != null && admin.isActive()) - { - return rankOf(admin); - } - - CustomRank bound = getBoundRank(sender.getName()); - return bound != null ? bound : getCustomRankForLegacy(Rank.NON_OP); - } - /** - * The admin behind an identified dispatch session, as a {@link CustomRank}, or {@code null} - * when the session proved no identity or the name no longer maps to an active admin. + * The rank whose name, colour and tag should be shown for {@code sender}. * <p> - * SSH additionally honours {@code ssh.inherit_rank}: with it off, even a public-key session is - * held to the flat {@code host_senders:} tier. + * Display is not the same question as permission. A few identities are recognised here purely + * so they read correctly in chat, and none of them grants anything: the impostor marker, the + * hardcoded developer list, and the owners named in config. Each is honoured only when a rank + * of that name actually exists in the registry, so an operator who removes one simply gets the + * sender's real rank instead. */ - private CustomRank resolveDispatchIdentity(RemoteDispatchSession dispatch) + public Displayable getDisplay(CommandSender sender) { - if (!dispatch.isIdentified()) + if (!(sender instanceof Player player)) { - return null; + return registry.forSender(sender).orElse(null); } - if (dispatch.getChannel() == RemoteDispatchSession.Channel.SSH - && !ConfigEntry.SSH_INHERIT_RANK.getBoolean()) + if (plugin.al.isAdminImpostor(player)) { - return null; + return registry.byRole(RankRole.IMPOSTOR).orElse(null); } - Admin admin = plugin.al.getEntryByName(dispatch.getUsername()); - return admin != null && admin.isActive() ? rankOf(admin) : null; - } + // A held title outranks the player's rank for display purposes: a title is the identity + // people recognise ("Master Builder"), while the rank underneath is only what they may do. + final Displayable title = plugin.tm == null ? null : plugin.tm.getDisplayTitle(player); - /** - * An admin's rank, preferring the custom rank pinned to their profile over their legacy tier. - */ - private CustomRank rankOf(Admin admin) - { - if (admin.getCustomRankId() != null) - { - CustomRank custom = getCustomRank(admin.getCustomRankId()); - if (custom != null) - { - return custom; - } - } - return getCustomRankForLegacy(admin.getRank()); + return title != null ? title : registry.forSender(player).orElse(null); } /** - * The custom rank bound to a sender name by {@code host_senders:}, resolving a legacy rank id - * through the registry so both naming styles work. + * The login line announcing an impostor, falling back to plain wording when the registry has no + * impostor rank to style it with. */ - private CustomRank getBoundRank(String senderName) + private Component impostorLoginMessage() { - String boundRankId = plugin.csr.getRankIdForSender(senderName); - if (boundRankId == null) - { - return null; - } - - CustomRank bound = getCustomRank(boundRankId); - if (bound != null) - { - return bound; - } - - Rank legacy = plugin.csr.getRankForSender(senderName); - return legacy != null ? getCustomRankForLegacy(legacy) : null; + return registry.byRole(RankRole.IMPOSTOR) + .map(CustomRank::getColoredLoginMessage) + .orElseGet(() -> Component.text("an Impostor").color(NamedTextColor.YELLOW)); } /** - * Places a custom rank on the legacy ladder by level, so callers that still speak {@link Rank} - * get a sane answer for operator-defined ranks. Compared on the registry's own scale, so it - * holds whatever numbering the operator chose. + * Resolves the rank a sender actually acts at. + * <p> + * Delegates to {@link RankRegistry}, which is the single place that knows how a sender earns a + * rank: an identified SSH or Discord session resolves to that admin's own profile, while a host + * channel carries no identity and resolves to its {@code host_senders:} binding. + * + * @return the sender's effective rank, or {@code null} when none could be resolved */ - public Rank toLegacyRank(CustomRank custom) - { - if (custom == null) - { - return Rank.NON_OP; - } - - return Stream.of(Rank.values()) - .filter(candidate -> !candidate.isConsole()) - .filter(candidate -> - { - CustomRank equivalent = getCustomRankForLegacy(candidate); - return equivalent != null && custom.getLevel() >= equivalent.getLevel(); - }) - .max(Comparator.comparingInt(Rank::getLevel)) - .orElse(Rank.NON_OP); - } - - public Rank getRank(CommandSender sender) + public CustomRank getEffectiveRank(CommandSender sender) { - if (sender instanceof Player player) - { - if (plugin.al.isAdminImpostor(player)) - { - return Rank.IMPOSTOR; - } - - final Admin entry = plugin.al.getAdmin(player); - if (entry != null) - { - return entry.getRank(); - } - - return player.isOp() ? Rank.OP : Rank.NON_OP; - } - - if (sender instanceof BlockCommandSender || sender instanceof CommandMinecart) - { - return Rank.NON_OP; - } - - RemoteDispatchSession dispatch = RemoteDispatchContext.getActiveSession(); - if (dispatch != null) - { - CustomRank identity = resolveDispatchIdentity(dispatch); - if (identity != null) - { - return toLegacyRank(identity); - } - - String channel = dispatch.getChannel() == RemoteDispatchSession.Channel.DISCORD ? "discord" : "ssh"; - Rank fallback = plugin.csr.getRankForSender(channel); - if (fallback != null) - { - return fallback; - } - - CustomRank bound = getBoundRank(channel); - return bound != null ? toLegacyRank(bound) : Rank.NON_OP; - } - - Admin admin = plugin.al.getEntryByName(sender.getName()); - if (admin != null) - { - return admin.getRank(); - } - - Rank rank = plugin.csr.getRankForSender(sender.getName()); - if (rank != null) - { - return rank; - } - - // A host channel may be bound to a custom rank with no legacy equivalent; place it on the - // ladder by level rather than assuming a tier, which used to hand every such sender - // SUPER_ADMIN regardless of what it was actually bound to. - CustomRank bound = getBoundRank(sender.getName()); - return bound != null ? toLegacyRank(bound) : Rank.NON_OP; + return registry.forSender(sender).orElse(null); } @EventHandler(priority = EventPriority.LOWEST) @@ -1623,7 +1207,7 @@ public void onPlayerJoin(PlayerJoinEvent event) { Component impostorMsg = Component.text(player.getName() + " is ") .color(NamedTextColor.AQUA) - .append(Rank.IMPOSTOR.getColoredLoginMessage()); + .append(impostorLoginMessage()); FUtil.bcastMsg(impostorMsg); if (plugin.db != null) { @@ -1645,8 +1229,9 @@ public void onPlayerJoin(PlayerJoinEvent event) return; } - // Set display - if (isAdmin || FUtil.DEVELOPERS.contains(player.getName())) + // Announce admins, and anyone holding a title worth announcing. The hardcoded developer + // list used to stand in for the latter; a title says the same thing as data instead. + if (isAdmin || (plugin.tm != null && plugin.tm.getDisplayTitle(player) != null)) { final Displayable display = getDisplay(player); Component loginMsg = formatLoginMessage(player); @@ -1698,7 +1283,7 @@ public Component formatLoginMessage(Player player) AdventureUtil.formatWithPlaceholders( loginMessage, Placeholder.unparsed("name", player.getName()), - Placeholder.unparsed("rank", admin.getRank().getName()), + Placeholder.unparsed("rank", display.getName()), Placeholder.component("colored_rank", display.getColoredName()) )); } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/rank/RankRegistry.java b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankRegistry.java new file mode 100644 index 000000000..ff0e30ef8 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankRegistry.java @@ -0,0 +1,304 @@ +package me.totalfreedom.totalfreedommod.rank; + +import java.util.Comparator; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.admin.Admin; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchContext; +import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchSession; +import org.bukkit.command.BlockCommandSender; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.entity.minecart.CommandMinecart; + +/** + * The single place that answers "what rank is this?" and "what rank does this permission need?". + * <p> + * Ranks are defined entirely by {@code ranks.json}; nothing in code declares a tier. That inversion + * is the point of this class. A command names only the internal permission node it needs, and the + * tier required to reach it is derived by asking which is the least privileged rank that grants + * that node, so re-tiering a feature is a config edit rather than a code change. + * <p> + * Two consequences of deriving the tier are worth knowing: + * <ul> + * <li>A node granted to a low rank is reachable by everyone at or above it. Two ranks that should + * differ in what they may do with one feature must therefore hold <em>distinct</em> nodes; a + * node they share necessarily resolves to the lower of the two.</li> + * <li>A node no rank grants is unreachable, and its command is ungrantable rather than + * universally allowed. Denying is the safe direction for a typo.</li> + * </ul> + * The registry reads the live rank map owned by {@link RankManager} rather than copying it, so a + * reload is visible here immediately; {@link #reindex()} rebuilds the permission index to match. + */ +public final class RankRegistry +{ + + private final TotalFreedomMod plugin; + + /** + * The live rank map owned by {@link RankManager}, read but never mutated here. + */ + private final Map<String, CustomRank> ranks; + + private final PermissionTrie trie = new PermissionTrie(); + + /** + * Resolves any console-class sender: an identified SSH or Discord session becomes the admin + * behind it, and anything else falls back to its {@code host_senders:} binding. + */ + private final RankResolver<CommandSender> consoleResolver; + + private final RankResolver<Player> playerResolver; + + public RankRegistry(final TotalFreedomMod plugin, final Map<String, CustomRank> ranks) + { + this.plugin = plugin; + this.ranks = ranks; + this.playerResolver = this::resolvePlayer; + this.consoleResolver = ((RankResolver<CommandSender>) this::resolveDispatch) + .orElse(this::resolveAdminByName) + .orElse(this::resolveBoundSender); + } + + /** + * Rebuilds the permission index from the current rank map. Call after ranks are loaded or after + * inheritance is re-resolved, on the main thread. + */ + public void reindex() + { + trie.rebuild(ranks); + } + + public PermissionTrie getTrie() + { + return trie; + } + + /** + * The rank registered under {@code id}, if any. + */ + public Optional<CustomRank> byId(final String id) + { + return id == null ? Optional.empty() : Optional.ofNullable(ranks.get(id.toLowerCase())); + } + + /** + * The rank filling {@code role}, preferring one that declares it in {@code ranks.json} and + * otherwise deriving it from the shape of the registry. + * <p> + * Nothing in code names a rank, so an operator may rename, re-tier or delete any of them and + * the roles follow. When two ranks declare the same role the lower-level one wins, which keeps + * the answer stable rather than depending on map order. + */ + public Optional<CustomRank> byRole(final RankRole role) + { + final Optional<CustomRank> declared = ranks.values() + .stream() + .filter(rank -> rank.hasRole(role)) + .min(Comparator.comparingInt(CustomRank::getLevel)); + + return declared.isPresent() ? declared : derivedRole(role); + } + + /** + * The fallback used when no rank declares {@code role}. + */ + private Optional<CustomRank> derivedRole(final RankRole role) + { + return switch (role) + { + case DEFAULT -> ordinaryRanks().min(Comparator.comparingInt(CustomRank::getLevel)); + case DEFAULT_OP -> ordinaryRanks().max(Comparator.comparingInt(CustomRank::getLevel)); + case IMPOSTOR -> ranks.values() + .stream() + .min(Comparator.comparingInt(CustomRank::getLevel)); + case ADMIN_DEFAULT -> adminRanks().min(Comparator.comparingInt(CustomRank::getLevel)); + case CONSOLE_FLOOR -> byRole(RankRole.ADMIN_DEFAULT); + }; + } + + /** + * Ranks a normal player can hold: neither staff nor the impostor marker. + */ + private Stream<CustomRank> ordinaryRanks() + { + final Optional<CustomRank> impostor = ranks.values() + .stream() + .filter(rank -> rank.hasRole(RankRole.IMPOSTOR)) + .findFirst(); + + return ranks.values() + .stream() + .filter(rank -> !rank.isAdmin()) + .filter(rank -> impostor.isEmpty() || !rank.equals(impostor.get())); + } + + private Stream<CustomRank> adminRanks() + { + return ranks.values() + .stream() + .filter(CustomRank::isAdmin); + } + + /** + * The rank every unplaceable sender falls back to. + */ + public Optional<CustomRank> floor() + { + return byRole(RankRole.DEFAULT); + } + + /** + * Whether the rank {@code rankId} sits at or above the rank filling {@code role}. An unknown + * rank answers {@code false}, so a stale id never clears a floor it was not granted. + */ + public boolean isAtLeast(final String rankId, final RankRole role) + { + final Optional<CustomRank> held = byId(rankId); + final Optional<CustomRank> floor = byRole(role); + + return held.isPresent() && floor.isPresent() + && held.get().getLevel() >= floor.get().getLevel(); + } + + /** + * The least privileged rank that grants {@code permission}, which is the tier a command guarded + * by that node actually requires. Empty when no rank grants it, meaning the node is unreachable. + */ + public Optional<CustomRank> requiredFor(final String permission) + { + return Optional.ofNullable(trie.lowestGranting(permission, ranks)); + } + + /** + * The rank {@code sender} acts at, falling back to {@link #floor()} when it cannot be placed. + * <p> + * Command blocks and command minecarts are deliberately pinned to the floor: they are world + * state that any player can create, so treating them as an identity would let anyone mint one. + */ + public Optional<CustomRank> forSender(final CommandSender sender) + { + if (sender == null) + return Optional.empty(); + + if (sender instanceof Player player) + return playerResolver.resolve(player).or(this::floor); + + if (sender instanceof BlockCommandSender || sender instanceof CommandMinecart) + return floor(); + + return consoleResolver.resolve(sender).or(this::floor); + } + + /** + * Whether {@code sender} may exercise {@code permission}, by rank or by title. + * <p> + * The two halves answer deliberately different questions: + * <ul> + * <li><b>Rank</b> is a tier comparison. The requirement resolves to the cheapest rank holding + * the node and any rank at or above that level passes, both sides read off the registry's + * own level scale.</li> + * <li><b>Title</b> is an exact match against that title's own grant list, with no tier + * involved. That asymmetry is the point: a title hands its holder one specific capability + * without implying anything else, so an ordinary operator can be trusted with a single + * permission rather than promoted past everything beneath it.</li> + * </ul> + */ + public boolean satisfies(final CommandSender sender, final String permission) + { + if (forSender(sender).map(rank -> satisfies(rank, permission)).orElse(false)) + return true; + + return plugin.tm != null && plugin.tm.grants(sender, permission); + } + + /** + * Whether a rank sits at or above the tier {@code permission} requires. + */ + public boolean satisfies(final CustomRank holder, final String permission) + { + if (holder == null || permission == null || permission.isEmpty()) + return false; + + return requiredFor(permission).map(required -> holder.getLevel() >= required.getLevel()) + .orElse(false); + } + + /** + * The admin behind an identified dispatch session. + * <p> + * SSH and Discord prove a real identity, so a session that carries one resolves to that admin's + * own rank rather than to the flat tier the channel is bound to. A password-only SSH session + * proves nothing and declines here, as does any session when {@code ssh.inherit_rank} is off. + */ + private Optional<CustomRank> resolveDispatch(final CommandSender sender) + { + final RemoteDispatchSession dispatch = RemoteDispatchContext.getActiveSession(); + if (dispatch == null) + return Optional.empty(); + + if (dispatch.isIdentified() + && !(dispatch.getChannel() == RemoteDispatchSession.Channel.SSH + && !ConfigEntry.SSH_INHERIT_RANK.getBoolean())) + { + final Optional<CustomRank> identity = adminRank(plugin.al.getEntryByName(dispatch.getUsername())); + if (identity.isPresent()) + return identity; + } + + final String channel = dispatch.getChannel() == RemoteDispatchSession.Channel.DISCORD + ? "discord" + : "ssh"; + + return boundRank(channel); + } + + private Optional<CustomRank> resolveAdminByName(final CommandSender sender) + { + return adminRank(plugin.al.getEntryByName(sender.getName())); + } + + private Optional<CustomRank> resolveBoundSender(final CommandSender sender) + { + return boundRank(sender.getName()); + } + + private Optional<CustomRank> resolvePlayer(final Player player) + { + if (plugin.al.isAdminImpostor(player)) + return byRole(RankRole.IMPOSTOR); + + final Optional<CustomRank> assigned = adminRank(plugin.al.getAdmin(player)); + if (assigned.isPresent()) + return assigned; + + return byRole(player.isOp() ? RankRole.DEFAULT_OP : RankRole.DEFAULT); + } + + /** + * An active admin's rank, or empty when the profile is missing or deactivated. + */ + private Optional<CustomRank> adminRank(final Admin admin) + { + if (admin == null || !admin.isActive()) + return Optional.empty(); + + final Optional<CustomRank> held = byId(admin.getRankId()); + + return held.isPresent() ? held : byRole(RankRole.ADMIN_DEFAULT); + } + + /** + * The rank bound to a no-identity sender by {@code host_senders:}. + */ + private Optional<CustomRank> boundRank(final String senderName) + { + return plugin.csr == null + ? Optional.empty() + : byId(plugin.csr.getRankIdForSender(senderName)); + } + +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/rank/RankResolver.java b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankResolver.java new file mode 100644 index 000000000..1c5698d99 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankResolver.java @@ -0,0 +1,35 @@ +package me.totalfreedom.totalfreedommod.rank; + +import java.util.Optional; + +/** + * Resolves the rank held by some kind of actor. + * <p> + * Rank is earned differently depending on what is asking. A player carries an admin profile; an SSH + * session carries a proven public key; an RCON channel carries no identity at all and falls back to + * what {@code host_senders:} bound it to. Expressing each of those as a resolver over its own + * subject type keeps the rules for one kind of actor in one place, instead of as another branch in + * a single method that has to know about all of them at once. + * + * @param <T> the kind of actor this resolver understands + */ +@FunctionalInterface +public interface RankResolver<T> +{ + + /** + * The rank {@code subject} holds, or empty when this resolver cannot place it. Empty is a + * routine answer rather than an error: it means "not mine to answer", and the caller is + * expected to fall through to another resolver or to a floor. + */ + Optional<CustomRank> resolve(T subject); + + /** + * A resolver that consults this one first and falls back to {@code next} when it declines. + */ + default RankResolver<T> orElse(final RankResolver<T> next) + { + return subject -> resolve(subject).or(() -> next.resolve(subject)); + } + +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/rank/RankRole.java b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankRole.java new file mode 100644 index 000000000..6b6d68966 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankRole.java @@ -0,0 +1,81 @@ +package me.totalfreedom.totalfreedommod.rank; + +import java.util.Arrays; +import java.util.Optional; + +/** + * A job the plugin needs some rank to do, bound to an actual rank by {@code ranks.json}. + * <p> + * A few code paths cannot be expressed as a permission check because they have to answer "which + * rank?" rather than "may they?": what an unidentified sender falls back to, what a newly added + * admin starts as, which rank marks an impostor. Naming those ranks in code would break the moment + * an operator renamed or deleted one, so a rank instead declares the roles it fills: + * <pre> + * "senior_admin": { ..., "roles": ["console_floor"] } + * </pre> + * Roles are a closed set because each one exists to satisfy a specific call site. Every role has a + * structural fallback in {@link RankRegistry#byRole}, so a config that declares none still resolves + * sensibly; declaring them only removes the guesswork. + */ +public enum RankRole +{ + + /** + * The rank an ordinary, non-opped player holds, and the floor any sender falls back to when it + * cannot be placed. Falls back to the lowest-level rank that is neither an admin nor the + * impostor marker. + */ + DEFAULT("default"), + + /** + * The rank an opped but non-admin player holds. Falls back to the highest-level rank that is + * neither an admin nor the impostor marker. + */ + DEFAULT_OP("default_op"), + + /** + * The display-only rank shown for a player flagged as impersonating an admin. Falls back to the + * lowest-level rank overall, which is where an impostor marker naturally sits. + */ + IMPOSTOR("impostor"), + + /** + * The rank a newly added admin receives, and the one an admin is reset to. Falls back to the + * lowest-level rank marked {@code admin}. + */ + ADMIN_DEFAULT("admin_default"), + + /** + * The floor held by console channels that prove no user identity (RCON, RemoteBukkit, the + * server console). Console access is itself the privilege, so {@code host_senders:} may raise a + * channel above this but never below it. Falls back to {@link #ADMIN_DEFAULT}. + */ + CONSOLE_FLOOR("console_floor"); + + private final String id; + + RankRole(final String id) + { + this.id = id; + } + + public String getId() + { + return id; + } + + /** + * The role written as {@code id} in configuration, or empty when the name is not a known role. + * Unknown names are ignored rather than rejected so that a config written for a newer build + * still loads on an older one. + */ + public static Optional<RankRole> fromId(final String id) + { + return id == null + ? Optional.empty() + : Arrays.stream(values()) + .filter(role -> role.id.equalsIgnoreCase(id.trim())) + .findFirst(); + } + +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/FreedomDatabase.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/FreedomDatabase.java index e737d2738..339ad97e2 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/FreedomDatabase.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/FreedomDatabase.java @@ -13,6 +13,7 @@ import me.totalfreedom.totalfreedommod.sql.adapter.PlayerRepository; import me.totalfreedom.totalfreedommod.sql.adapter.ProtectedAreaRepository; import me.totalfreedom.totalfreedommod.sql.adapter.RankRepository; +import me.totalfreedom.totalfreedommod.sql.adapter.TitleRepository; import me.totalfreedom.totalfreedommod.sql.adapter.SavedFlagRepository; import me.totalfreedom.totalfreedommod.sql.adapter.StrikeRepository; import me.totalfreedom.totalfreedommod.util.FLog; @@ -280,6 +281,15 @@ public RankRepository getRankRepository() return adapter.getRankRepository(); } + public TitleRepository getTitleRepository() + { + if (adapter == null) + { + throw new IllegalStateException("Database not initialized"); + } + return adapter.getTitleRepository(); + } + public ProtectedAreaRepository getProtectedAreaRepository() { if (adapter == null) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DatabaseAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DatabaseAdapter.java index 3700267e9..9dd98fbe8 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DatabaseAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DatabaseAdapter.java @@ -89,6 +89,11 @@ public void shutdown() */ public abstract RankRepository getRankRepository(); + /** + * Repository for titles, the flat capability grants that sit alongside ranks. + */ + public abstract TitleRepository getTitleRepository(); + /** * Get the protected area repository for this database type. */ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/TitleRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/TitleRepository.java new file mode 100644 index 000000000..2c93ed64d --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/TitleRepository.java @@ -0,0 +1,54 @@ +package me.totalfreedom.totalfreedommod.sql.adapter; + +import java.sql.SQLException; +import java.util.Map; +import java.util.Set; +import me.totalfreedom.totalfreedommod.title.Title; +import reactor.core.publisher.Mono; + +/** + * Repository interface for {@link Title} data. + * <p> + * A title's own {@code id} string is its primary key, so no generated-key handling is needed. The + * shape mirrors {@link RankRepository} deliberately: titles carry a permission set that lives in a + * child table for the same reasons rank permissions do, so the two persist the same way even though + * a title has no level and no parent. + */ +public interface TitleRepository +{ + void insert(Title title) throws SQLException; + + void insertPermissions(String titleId, Set<String> permissions) throws SQLException; + + void addPermission(String titleId, String permission) throws SQLException; + + Map<String, Title> loadAll() throws SQLException; + + Title findById(String id) throws SQLException; + + boolean exists(String id) throws SQLException; + + Set<String> getPermissions(String titleId) throws SQLException; + + boolean update(Title title) throws SQLException; + + void syncPermissions(String titleId, Set<String> permissions) throws SQLException; + + void saveOrUpdate(Title title) throws SQLException; + + boolean delete(String id) throws SQLException; + + boolean removePermission(String titleId, String permission) throws SQLException; + + /** + * Epoch millis of the most recently updated title row, or null if the table is empty. + * Used to compare SQL freshness against the titles.json snapshot's last-modified time. + */ + Long getMaxUpdatedAt() throws SQLException; + + Mono<Map<String, Title>> loadAllAsync(); + + Mono<Void> save(Title title); + + Mono<Boolean> deleteAsync(String id); +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericAdminRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericAdminRepository.java index ad4841e02..4f8ca49f3 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericAdminRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericAdminRepository.java @@ -1,7 +1,6 @@ package me.totalfreedom.totalfreedommod.sql.adapter.generic; import me.totalfreedom.totalfreedommod.admin.Admin; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.sql.StatementHandler; import me.totalfreedom.totalfreedommod.sql.adapter.AdminRepository; import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; @@ -70,11 +69,11 @@ public int insert(UUID uuid, Admin admin) throws SQLException long adminId = statementHandler.executeUpdateReturnKey(sql, uuid.toString(), admin.getName(), - admin.getRank().toString(), + admin.getRankId(), admin.isActive(), FUtil.dateToString(admin.getLastLogin()), admin.getLoginMessage(), - admin.getCustomRankId()); + null); if (adminId < 0) { @@ -287,11 +286,11 @@ public boolean update(UUID uuid, Admin admin) throws SQLException int rows = statementHandler.executeUpdate(sql, admin.getName(), - admin.getRank().toString(), + admin.getRankId(), admin.isActive(), FUtil.dateToString(admin.getLastLogin()), admin.getLoginMessage(), - admin.getCustomRankId(), + null, uuid.toString()); return rows > 0; @@ -449,6 +448,22 @@ public Mono<Void> deleteAll() return statementHandler.runMono(this::deleteAllSync); } + /** + * Picks the rank id out of the two columns admin rows may still carry. + * <p> + * {@code custom_rank} held the operator-assigned rank and took precedence over {@code rank}, + * which held a fixed tier name, so it is preferred here as well. Rows are rewritten with the id + * in {@code rank} and {@code custom_rank} cleared, so this only matters until a row is next + * saved. A tier name lowercases into an id, which is the convention {@code ranks.json} uses. + */ + private static String resolveRankId(final String customRank, final String legacyRank) + { + if (customRank != null && !customRank.isBlank()) + return customRank.toLowerCase(); + + return legacyRank == null || legacyRank.isBlank() ? null : legacyRank.toLowerCase(); + } + private Admin loadAdminFromRow(ResultSet rs) throws SQLException { String username = rs.getString("username"); @@ -456,15 +471,13 @@ private Admin loadAdminFromRow(ResultSet rs) throws SQLException boolean active = rs.getBoolean("active"); String lastLoginStr = rs.getString("last_login"); String loginMessage = rs.getString("login_message"); - String customRankId = rs.getString("custom_rank"); Admin admin = new Admin(username.toLowerCase()); admin.setName(username); - admin.setRank(Rank.findRank(rankStr)); + admin.setRankId(resolveRankId(rs.getString("custom_rank"), rankStr)); admin.setActive(active); admin.setLastLogin(FUtil.stringToDate(lastLoginStr)); admin.setLoginMessage(loginMessage); - admin.setCustomRankId(customRankId); UUID dbUuid = FUtil.parseUuid(rs.getString("uuid")); if (dbUuid != null) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java index 0fe50038e..440cb369e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java @@ -35,6 +35,7 @@ public class GenericPlayerRepository implements PlayerRepository private final String colCommandsBlocked; private final String colStrikes; private final String colSavedTag; + private final String colTitles; private final String colNickname; private final String colId; private final String colPlayerUsername; @@ -59,21 +60,22 @@ public GenericPlayerRepository(StatementHandler statementHandler, DatabaseAdapte this.colCommandsBlocked = adapter.quoteIdentifier("commands_blocked"); this.colStrikes = adapter.quoteIdentifier("strikes"); this.colSavedTag = adapter.quoteIdentifier("saved_tag"); + this.colTitles = adapter.quoteIdentifier("titles"); this.colNickname = adapter.quoteIdentifier("nickname"); this.colId = adapter.quoteIdentifier("id"); this.colPlayerUsername = adapter.quoteIdentifier("username"); this.colIp = adapter.quoteIdentifier("ip"); this.colUpdatedAt = adapter.quoteIdentifier("updated_at"); - this.selectColumns = String.format("%s, %s, %s, %s, %s, %s, %s, %s, %s, %s", + this.selectColumns = String.format("%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s", colUsername, colFirstJoin, colLastJoin, colPotionSpy, colCommandSpyMode, colMuted, colFrozen, - colCommandsBlocked, colStrikes, colSavedTag) + colCommandsBlocked, colStrikes, colSavedTag, colTitles) + ", " + colNickname; } @Override public void insert(PlayerData data) throws SQLException { - String sql = String.format("INSERT INTO %s (%s, %s) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, %s)", + String sql = String.format("INSERT INTO %s (%s, %s) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, %s)", tblPlayers, selectColumns, colUpdatedAt, adapter.currentTimestamp()); statementHandler.executeUpdate(sql, @@ -87,6 +89,7 @@ public void insert(PlayerData data) throws SQLException data.isCommandsBlocked(), data.getStrikes(), data.getSavedTag(), + serializeTitles(data), serializeNickname(data)); insertIps(data.getUsername(), data.getIps()); @@ -191,9 +194,9 @@ public List<String> getIps(String username) throws SQLException @Override public boolean update(PlayerData data) throws SQLException { - String sql = String.format("UPDATE %s SET %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = %s WHERE %s = ?", + String sql = String.format("UPDATE %s SET %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = %s WHERE %s = ?", tblPlayers, colFirstJoin, colLastJoin, colPotionSpy, colCommandSpyMode, colMuted, colFrozen, - colCommandsBlocked, colStrikes, colSavedTag, colUpdatedAt, adapter.currentTimestamp(), colUsername); + colCommandsBlocked, colStrikes, colSavedTag, colTitles, colUpdatedAt, adapter.currentTimestamp(), colUsername); int rows = statementHandler.executeUpdate(sql, data.getFirstJoinUnix(), @@ -205,6 +208,7 @@ public boolean update(PlayerData data) throws SQLException data.isCommandsBlocked(), data.getStrikes(), data.getSavedTag(), + serializeTitles(data), data.getUsername()); statementHandler.executeUpdate( @@ -301,6 +305,7 @@ private PlayerData loadPlayerFromRow(ResultSet rs) throws SQLException data.setCommandsBlocked(rs.getBoolean("commands_blocked")); data.setStrikes(rs.getInt("strikes")); data.setSavedTag(rs.getString("saved_tag")); + data.setTitles(parseTitles(rs.getString("titles"))); String rawNickname = rs.getString("nickname"); if (rawNickname != null && !rawNickname.isEmpty()) @@ -311,6 +316,28 @@ private PlayerData loadPlayerFromRow(ResultSet rs) throws SQLException return data; } + /** + * Held titles are stored as a comma-separated list in one column rather than as a child table: + * a player holds a handful at most and they are always read with the player row itself, so a + * join would cost more than it saves. + */ + private static String serializeTitles(PlayerData data) + { + final Set<String> titles = data.getTitles(); + + return titles.isEmpty() ? null : String.join(",", titles); + } + + private static List<String> parseTitles(String stored) + { + return stored == null || stored.isBlank() + ? List.of() + : Arrays.stream(stored.split(",")) + .map(String::trim) + .filter(id -> !id.isEmpty()) + .toList(); + } + private static String serializeNickname(PlayerData data) { return data.getNickname() != null ? AdventureUtil.componentToLegacy(data.getNickname()) : null; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericRankRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericRankRepository.java index ff67877b1..e0a895f87 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericRankRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericRankRepository.java @@ -1,6 +1,7 @@ package me.totalfreedom.totalfreedommod.sql.adapter.generic; import me.totalfreedom.totalfreedommod.rank.CustomRank; +import me.totalfreedom.totalfreedommod.rank.RankRole; import me.totalfreedom.totalfreedommod.sql.StatementHandler; import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; import me.totalfreedom.totalfreedommod.sql.adapter.RankRepository; @@ -11,6 +12,7 @@ import java.sql.SQLException; import java.sql.Timestamp; import java.util.*; +import java.util.stream.Collectors; import reactor.core.publisher.Mono; @@ -31,9 +33,9 @@ public class GenericRankRepository implements RankRepository private final String colLevel; private final String colColor; private final String colAdmin; - private final String colConsoleOnly; private final String colPrefix; private final String colInheritFrom; + private final String colRoles; private final String colRankId; private final String colPermission; private final String colUpdatedAt; @@ -53,15 +55,15 @@ public GenericRankRepository(StatementHandler statementHandler, DatabaseAdapter this.colLevel = adapter.quoteIdentifier("level"); this.colColor = adapter.quoteIdentifier("color"); this.colAdmin = adapter.quoteIdentifier("admin"); - this.colConsoleOnly = adapter.quoteIdentifier("console_only"); this.colPrefix = adapter.quoteIdentifier("prefix"); this.colInheritFrom = adapter.quoteIdentifier("inherit_from"); + this.colRoles = adapter.quoteIdentifier("roles"); this.colRankId = adapter.quoteIdentifier("rank_id"); this.colPermission = adapter.quoteIdentifier("permission"); this.colUpdatedAt = adapter.quoteIdentifier("updated_at"); this.selectColumns = String.format("%s, %s, %s, %s, %s, %s, %s, %s, %s, %s", - colId, colName, colDeterminer, colAbbreviation, colLevel, colColor, colAdmin, colConsoleOnly, - colPrefix, colInheritFrom); + colId, colName, colDeterminer, colAbbreviation, colLevel, colColor, colAdmin, + colPrefix, colInheritFrom, colRoles); } @Override @@ -78,9 +80,9 @@ public void insert(CustomRank rank) throws SQLException rank.getLevel(), serializeColor(rank.getColor()), rank.isAdmin(), - rank.isConsoleOnly(), rank.getPrefix(), - rank.getInheritFrom()); + rank.getInheritFrom(), + serializeRoles(rank.getRoles())); insertPermissions(rank.getId(), rank.getPermissions()); } @@ -185,8 +187,8 @@ public Set<String> getPermissions(String rankId) throws SQLException public boolean update(CustomRank rank) throws SQLException { String sql = String.format("UPDATE %s SET %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = %s WHERE %s = ?", - tblRanks, colName, colDeterminer, colAbbreviation, colLevel, colColor, colAdmin, colConsoleOnly, - colPrefix, colInheritFrom, colUpdatedAt, adapter.currentTimestamp(), colId); + tblRanks, colName, colDeterminer, colAbbreviation, colLevel, colColor, colAdmin, + colPrefix, colInheritFrom, colRoles, colUpdatedAt, adapter.currentTimestamp(), colId); int rows = statementHandler.executeUpdate(sql, rank.getName(), @@ -195,9 +197,9 @@ public boolean update(CustomRank rank) throws SQLException rank.getLevel(), serializeColor(rank.getColor()), rank.isAdmin(), - rank.isConsoleOnly(), rank.getPrefix(), rank.getInheritFrom(), + serializeRoles(rank.getRoles()), rank.getId()); return rows > 0; @@ -294,12 +296,42 @@ private CustomRank loadRankFromRow(ResultSet rs) throws SQLException rank.setLevel(rs.getInt("level")); rank.setColor(parseColor(rs.getString("color"))); rank.setAdmin(rs.getBoolean("admin")); - rank.setConsoleOnly(rs.getBoolean("console_only")); rank.setPrefix(rs.getString("prefix")); rank.setInheritFrom(rs.getString("inherit_from")); + rank.setRoles(parseRoles(rs.getString("roles"))); return rank; } + /** + * Roles are stored as a comma-separated list in one column rather than as a child table: the + * set is tiny, closed, and always read with the rank it belongs to, so a join would cost more + * than it saves. + */ + private static String serializeRoles(Set<RankRole> roles) + { + return roles == null || roles.isEmpty() + ? null + : roles.stream() + .map(RankRole::getId) + .collect(Collectors.joining(",")); + } + + /** + * Reads roles back, ignoring any name this build does not know so that a database written by a + * newer version still loads. + */ + private static Set<RankRole> parseRoles(String stored) + { + if (stored == null || stored.isBlank()) + return EnumSet.noneOf(RankRole.class); + + return Arrays.stream(stored.split(",")) + .map(RankRole::fromId) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.toCollection(() -> EnumSet.noneOf(RankRole.class))); + } + private static String serializeColor(NamedTextColor color) { return NamedTextColor.NAMES.keyOrThrow(color); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericTitleRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericTitleRepository.java new file mode 100644 index 000000000..091253de2 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericTitleRepository.java @@ -0,0 +1,304 @@ +package me.totalfreedom.totalfreedommod.sql.adapter.generic; + +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import me.totalfreedom.totalfreedommod.sql.StatementHandler; +import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; +import me.totalfreedom.totalfreedommod.sql.adapter.TitleRepository; +import me.totalfreedom.totalfreedommod.title.Title; +import net.kyori.adventure.text.format.NamedTextColor; +import reactor.core.publisher.Mono; + +/** + * All dialect differences are resolved through the {@link DatabaseAdapter} passed in. + */ +public class GenericTitleRepository implements TitleRepository +{ + + private final StatementHandler statementHandler; + private final DatabaseAdapter adapter; + + private final String tblTitles; + private final String tblTitlePermissions; + private final String colId; + private final String colName; + private final String colDeterminer; + private final String colAbbreviation; + private final String colColor; + private final String colPrefix; + private final String colWeight; + private final String colAnnounce; + private final String colTitleId; + private final String colPermission; + private final String colUpdatedAt; + private final String selectColumns; + + public GenericTitleRepository(StatementHandler statementHandler, DatabaseAdapter adapter) + { + this.statementHandler = statementHandler; + this.adapter = adapter; + + this.tblTitles = adapter.quoteIdentifier("titles"); + this.tblTitlePermissions = adapter.quoteIdentifier("title_permissions"); + this.colId = adapter.quoteIdentifier("id"); + this.colName = adapter.quoteIdentifier("name"); + this.colDeterminer = adapter.quoteIdentifier("determiner"); + this.colAbbreviation = adapter.quoteIdentifier("abbreviation"); + this.colColor = adapter.quoteIdentifier("color"); + this.colPrefix = adapter.quoteIdentifier("prefix"); + this.colWeight = adapter.quoteIdentifier("weight"); + this.colAnnounce = adapter.quoteIdentifier("announce"); + this.colTitleId = adapter.quoteIdentifier("title_id"); + this.colPermission = adapter.quoteIdentifier("permission"); + this.colUpdatedAt = adapter.quoteIdentifier("updated_at"); + this.selectColumns = String.format("%s, %s, %s, %s, %s, %s, %s, %s", + colId, colName, colDeterminer, colAbbreviation, colColor, colPrefix, colWeight, colAnnounce); + } + + @Override + public void insert(Title title) throws SQLException + { + String sql = String.format("INSERT INTO %s (%s, %s) VALUES (?, ?, ?, ?, ?, ?, ?, ?, %s)", + tblTitles, selectColumns, colUpdatedAt, adapter.currentTimestamp()); + + statementHandler.executeUpdate(sql, + title.getId(), + title.getName(), + title.getDeterminer(), + title.getAbbreviation(), + serializeColor(title.getColor()), + title.getPrefix(), + title.getWeight(), + title.isAnnounce()); + + insertPermissions(title.getId(), title.getPermissions()); + } + + @Override + public void insertPermissions(String titleId, Set<String> permissions) throws SQLException + { + if (permissions == null || permissions.isEmpty()) return; + + String sql = String.format("%s INTO %s (%s, %s) VALUES (?, ?)%s", + adapter.insertIgnoreSyntax(), tblTitlePermissions, colTitleId, colPermission, + adapter.insertIgnoreSuffix()); + + for (String permission : permissions) + { + statementHandler.executeUpdate(sql, titleId, permission); + } + } + + @Override + public void addPermission(String titleId, String permission) throws SQLException + { + String sql = String.format("%s INTO %s (%s, %s) VALUES (?, ?)%s", + adapter.insertIgnoreSyntax(), tblTitlePermissions, colTitleId, colPermission, + adapter.insertIgnoreSuffix()); + statementHandler.executeUpdate(sql, titleId, permission); + } + + @Override + public Map<String, Title> loadAll() throws SQLException + { + Map<String, Title> titles = new LinkedHashMap<>(); + + String sql = String.format("SELECT %s FROM %s", selectColumns, tblTitles); + try (ResultSet rs = statementHandler.executeQuery(sql)) + { + while (rs.next()) + { + Title title = loadTitleFromRow(rs); + titles.put(title.getId(), title); + } + } + + // One flat read of the child table rather than a query per title: the cursor above is + // already closed, and a nested read would hold two connections at once, which SQLite's + // single-connection pool cannot serve. + String permSql = String.format("SELECT %s, %s FROM %s", colTitleId, colPermission, tblTitlePermissions); + try (ResultSet rs = statementHandler.executeQuery(permSql)) + { + while (rs.next()) + { + Title title = titles.get(rs.getString("title_id")); + if (title != null) + { + title.addPermission(rs.getString("permission")); + } + } + } + + return titles; + } + + @Override + public Title findById(String id) throws SQLException + { + String sql = String.format("SELECT %s FROM %s WHERE %s = ?", selectColumns, tblTitles, colId); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, id); + ResultSet rs = stmt.executeQuery()) + { + if (rs.next()) + { + Title title = loadTitleFromRow(rs); + getPermissions(title.getId()).forEach(title::addPermission); + return title; + } + } + return null; + } + + @Override + public boolean exists(String id) throws SQLException + { + String sql = String.format("SELECT COUNT(*) FROM %s WHERE %s = ?", tblTitles, colId); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, id); + ResultSet rs = stmt.executeQuery()) + { + return rs.next() && rs.getInt(1) > 0; + } + } + + @Override + public Set<String> getPermissions(String titleId) throws SQLException + { + Set<String> permissions = new HashSet<>(); + String sql = String.format("SELECT %s FROM %s WHERE %s = ?", colPermission, tblTitlePermissions, colTitleId); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, titleId); + ResultSet rs = stmt.executeQuery()) + { + while (rs.next()) + { + permissions.add(rs.getString("permission")); + } + } + return permissions; + } + + @Override + public boolean update(Title title) throws SQLException + { + String sql = String.format( + "UPDATE %s SET %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = %s WHERE %s = ?", + tblTitles, colName, colDeterminer, colAbbreviation, colColor, colPrefix, colWeight, + colAnnounce, colUpdatedAt, adapter.currentTimestamp(), colId); + + int rows = statementHandler.executeUpdate(sql, + title.getName(), + title.getDeterminer(), + title.getAbbreviation(), + serializeColor(title.getColor()), + title.getPrefix(), + title.getWeight(), + title.isAnnounce(), + title.getId()); + + return rows > 0; + } + + @Override + public void syncPermissions(String titleId, Set<String> permissions) throws SQLException + { + statementHandler.executeUpdate( + String.format("DELETE FROM %s WHERE %s = ?", tblTitlePermissions, colTitleId), titleId); + insertPermissions(titleId, permissions); + } + + @Override + public void saveOrUpdate(Title title) throws SQLException + { + if (exists(title.getId())) + { + update(title); + syncPermissions(title.getId(), title.getPermissions()); + } + else + { + insert(title); + } + } + + @Override + public boolean delete(String id) throws SQLException + { + String sql = String.format("DELETE FROM %s WHERE %s = ?", tblTitles, colId); + return statementHandler.executeUpdate(sql, id) > 0; + } + + @Override + public boolean removePermission(String titleId, String permission) throws SQLException + { + String sql = String.format("DELETE FROM %s WHERE %s = ? AND %s = ?", + tblTitlePermissions, colTitleId, colPermission); + return statementHandler.executeUpdate(sql, titleId, permission) > 0; + } + + @Override + public Long getMaxUpdatedAt() throws SQLException + { + String sql = String.format("SELECT MAX(%s) FROM %s", colUpdatedAt, tblTitles); + try (ResultSet rs = statementHandler.executeQuery(sql)) + { + if (rs.next()) + { + Timestamp ts = rs.getTimestamp(1); + return ts != null ? ts.getTime() : null; + } + } + return null; + } + + @Override + public Mono<Map<String, Title>> loadAllAsync() + { + return statementHandler.supplyMono(this::loadAll); + } + + @Override + public Mono<Void> save(Title title) + { + return statementHandler.runMono(() -> saveOrUpdate(title)); + } + + @Override + public Mono<Boolean> deleteAsync(String id) + { + return statementHandler.supplyMono(() -> delete(id)); + } + + private Title loadTitleFromRow(ResultSet rs) throws SQLException + { + Title title = new Title(rs.getString("id")); + title.setName(rs.getString("name")); + title.setDeterminer(rs.getString("determiner")); + title.setAbbreviation(rs.getString("abbreviation")); + title.setColor(parseColor(rs.getString("color"))); + title.setPrefix(rs.getString("prefix")); + title.setWeight(rs.getInt("weight")); + title.setAnnounce(rs.getBoolean("announce")); + return title; + } + + private static String serializeColor(NamedTextColor color) + { + return NamedTextColor.NAMES.keyOrThrow(color); + } + + private static NamedTextColor parseColor(String name) + { + if (name == null) + return NamedTextColor.WHITE; + + final NamedTextColor color = NamedTextColor.NAMES.value(name.toLowerCase()); + + return color != null ? color : NamedTextColor.WHITE; + } + +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdapter.java index 598406047..2ffb3e1d8 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdapter.java @@ -29,6 +29,7 @@ public class MySQLAdapter extends DatabaseAdapter private StrikeRepository strikeRepository; private DiscordLinkRepository discordLinkRepository; private RankRepository rankRepository; + private TitleRepository titleRepository; private ProtectedAreaRepository protectedAreaRepository; private SavedFlagRepository savedFlagRepository; private PlayerRepository playerRepository; @@ -157,6 +158,8 @@ public void runMigrations() throws SQLException createDiscordLinksTable(); createRanksTable(); createRankPermissionsTable(); + createTitlesTable(); + createTitlePermissionsTable(); createProtectedAreasTable(); createSavedFlagsTable(); createPlayersTable(); @@ -326,15 +329,16 @@ private void createRanksTable() throws SQLException `level` INT NOT NULL DEFAULT 0, `color` VARCHAR(32) NOT NULL DEFAULT 'white', `admin` TINYINT(1) NOT NULL DEFAULT 0, - `console_only` TINYINT(1) NOT NULL DEFAULT 0, `prefix` VARCHAR(64), `inherit_from` VARCHAR(64), + `roles` VARCHAR(255), `updated_at` DATETIME NOT NULL DEFAULT NOW(), INDEX `idx_ranks_level` (`level`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci """; statementHandler.executeUpdate(sql); addColumnIfMissing("ranks", "updated_at", "DATETIME NOT NULL DEFAULT NOW()"); + addColumnIfMissing("ranks", "roles", "VARCHAR(255)"); } private void createRankPermissionsTable() throws SQLException @@ -351,6 +355,38 @@ FOREIGN KEY (`rank_id`) REFERENCES `ranks`(`id`) ON DELETE CASCADE statementHandler.executeUpdate(sql); } + private void createTitlesTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS `titles` ( + `id` VARCHAR(64) PRIMARY KEY, + `name` VARCHAR(64) NOT NULL, + `determiner` VARCHAR(8) NOT NULL DEFAULT 'a', + `abbreviation` VARCHAR(16), + `color` VARCHAR(32) NOT NULL DEFAULT 'white', + `prefix` VARCHAR(64), + `weight` INT NOT NULL DEFAULT 0, + `announce` TINYINT(1) NOT NULL DEFAULT 1, + `updated_at` DATETIME NOT NULL DEFAULT NOW() + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """; + statementHandler.executeUpdate(sql); + } + + private void createTitlePermissionsTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS `title_permissions` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `title_id` VARCHAR(64) NOT NULL, + `permission` VARCHAR(128) NOT NULL, + UNIQUE KEY `uk_title_permission` (`title_id`, `permission`), + FOREIGN KEY (`title_id`) REFERENCES `titles`(`id`) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """; + statementHandler.executeUpdate(sql); + } + private void createProtectedAreasTable() throws SQLException { String sql = """ @@ -405,6 +441,7 @@ private void createPlayersTable() throws SQLException """; statementHandler.executeUpdate(sql); addColumnIfMissing("players", "updated_at", "DATETIME NOT NULL DEFAULT NOW()"); + addColumnIfMissing("players", "titles", "TEXT"); } private void createPlayerIpsTable() throws SQLException @@ -492,6 +529,16 @@ public DiscordLinkRepository getDiscordLinkRepository() return discordLinkRepository; } + @Override + public TitleRepository getTitleRepository() + { + if (titleRepository == null) + { + titleRepository = new GenericTitleRepository(statementHandler, this); + } + return titleRepository; + } + @Override public RankRepository getRankRepository() { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java index 9e3e15f79..49ca35cb2 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java @@ -28,6 +28,7 @@ public class PostgreSQLAdapter extends DatabaseAdapter private StrikeRepository strikeRepository; private DiscordLinkRepository discordLinkRepository; private RankRepository rankRepository; + private TitleRepository titleRepository; private ProtectedAreaRepository protectedAreaRepository; private SavedFlagRepository savedFlagRepository; private PlayerRepository playerRepository; @@ -157,6 +158,8 @@ public void runMigrations() throws SQLException createDiscordLinksTable(); createRanksTable(); createRankPermissionsTable(); + createTitlesTable(); + createTitlePermissionsTable(); createProtectedAreasTable(); createSavedFlagsTable(); createPlayersTable(); @@ -327,14 +330,15 @@ private void createRanksTable() throws SQLException "level" INTEGER NOT NULL DEFAULT 0, "color" VARCHAR(32) NOT NULL DEFAULT 'white', "admin" BOOLEAN NOT NULL DEFAULT FALSE, - "console_only" BOOLEAN NOT NULL DEFAULT FALSE, "prefix" VARCHAR(64), "inherit_from" VARCHAR(64), + "roles" VARCHAR(255), "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ) """; statementHandler.executeUpdate(sql); statementHandler.executeUpdate("ALTER TABLE \"ranks\" ADD COLUMN IF NOT EXISTS \"updated_at\" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP"); + statementHandler.executeUpdate("ALTER TABLE \"ranks\" ADD COLUMN IF NOT EXISTS \"roles\" VARCHAR(255)"); statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_ranks_level ON \"ranks\"(\"level\")"); } @@ -351,6 +355,37 @@ private void createRankPermissionsTable() throws SQLException statementHandler.executeUpdate(sql); } + private void createTitlesTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS "titles" ( + "id" VARCHAR(64) PRIMARY KEY, + "name" VARCHAR(64) NOT NULL, + "determiner" VARCHAR(8) NOT NULL DEFAULT 'a', + "abbreviation" VARCHAR(16), + "color" VARCHAR(32) NOT NULL DEFAULT 'white', + "prefix" VARCHAR(64), + "weight" INTEGER NOT NULL DEFAULT 0, + "announce" BOOLEAN NOT NULL DEFAULT TRUE, + "updated_at" TIMESTAMP NOT NULL DEFAULT NOW() + ) + """; + statementHandler.executeUpdate(sql); + } + + private void createTitlePermissionsTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS "title_permissions" ( + "id" SERIAL PRIMARY KEY, + "title_id" VARCHAR(64) NOT NULL REFERENCES "titles"("id") ON DELETE CASCADE, + "permission" VARCHAR(128) NOT NULL, + UNIQUE ("title_id", "permission") + ) + """; + statementHandler.executeUpdate(sql); + } + private void createProtectedAreasTable() throws SQLException { String sql = """ @@ -405,6 +440,7 @@ private void createPlayersTable() throws SQLException """; statementHandler.executeUpdate(sql); statementHandler.executeUpdate("ALTER TABLE \"players\" ADD COLUMN IF NOT EXISTS \"updated_at\" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP"); + statementHandler.executeUpdate("ALTER TABLE \"players\" ADD COLUMN IF NOT EXISTS \"titles\" TEXT"); } private void createPlayerIpsTable() throws SQLException @@ -474,6 +510,16 @@ public DiscordLinkRepository getDiscordLinkRepository() return discordLinkRepository; } + @Override + public TitleRepository getTitleRepository() + { + if (titleRepository == null) + { + titleRepository = new GenericTitleRepository(statementHandler, this); + } + return titleRepository; + } + @Override public RankRepository getRankRepository() { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java index 74b5fc2e1..98f80dd20 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java @@ -35,6 +35,7 @@ public class SQLiteAdapter extends DatabaseAdapter private StrikeRepository strikeRepository; private DiscordLinkRepository discordLinkRepository; private RankRepository rankRepository; + private TitleRepository titleRepository; private ProtectedAreaRepository protectedAreaRepository; private SavedFlagRepository savedFlagRepository; private PlayerRepository playerRepository; @@ -166,6 +167,8 @@ public void runMigrations() throws SQLException createDiscordLinksTable(); createRanksTable(); createRankPermissionsTable(); + createTitlesTable(); + createTitlePermissionsTable(); createProtectedAreasTable(); createSavedFlagsTable(); createPlayersTable(); @@ -339,14 +342,15 @@ CREATE TABLE IF NOT EXISTS ranks ( level INTEGER NOT NULL DEFAULT 0, color TEXT NOT NULL DEFAULT 'white', admin INTEGER NOT NULL DEFAULT 0, - console_only INTEGER NOT NULL DEFAULT 0, prefix TEXT, inherit_from TEXT, + roles TEXT, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) """; statementHandler.executeUpdate(sql); addTimestampColumnIfMissing("ranks", "updated_at"); + addColumnIfMissing("ranks", "roles", "TEXT"); statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_ranks_level ON ranks(level)"); } @@ -364,6 +368,38 @@ FOREIGN KEY (rank_id) REFERENCES ranks(id) ON DELETE CASCADE statementHandler.executeUpdate(sql); } + private void createTitlesTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS titles ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + determiner TEXT NOT NULL DEFAULT 'a', + abbreviation TEXT, + color TEXT NOT NULL DEFAULT 'white', + prefix TEXT, + weight INTEGER NOT NULL DEFAULT 0, + announce INTEGER NOT NULL DEFAULT 1, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """; + statementHandler.executeUpdate(sql); + } + + private void createTitlePermissionsTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS title_permissions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title_id TEXT NOT NULL, + permission TEXT NOT NULL, + UNIQUE (title_id, permission), + FOREIGN KEY (title_id) REFERENCES titles(id) ON DELETE CASCADE + ) + """; + statementHandler.executeUpdate(sql); + } + private void createProtectedAreasTable() throws SQLException { String sql = """ @@ -418,6 +454,7 @@ CREATE TABLE IF NOT EXISTS players ( """; statementHandler.executeUpdate(sql); addTimestampColumnIfMissing("players", "updated_at"); + addColumnIfMissing("players", "titles", "TEXT"); } private void createPlayerIpsTable() throws SQLException @@ -530,6 +567,16 @@ public DiscordLinkRepository getDiscordLinkRepository() return discordLinkRepository; } + @Override + public TitleRepository getTitleRepository() + { + if (titleRepository == null) + { + titleRepository = new GenericTitleRepository(statementHandler, this); + } + return titleRepository; + } + @Override public RankRepository getRankRepository() { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/title/Title.java b/src/main/java/me/totalfreedom/totalfreedommod/title/Title.java new file mode 100644 index 000000000..7eef70f74 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/title/Title.java @@ -0,0 +1,376 @@ +package me.totalfreedom.totalfreedommod.title; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Collectors; +import me.totalfreedom.totalfreedommod.display.Displayable; +import me.totalfreedom.totalfreedommod.util.AdventureUtil; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; + +/** + * A recognition a player holds alongside their rank, carrying its own display and its own narrow + * set of capabilities. + * <p> + * A title is deliberately <em>not</em> a rank, and the difference is the whole point of the type. + * Ranks form a ladder: they carry a level, they inherit from one another, and holding one implies + * holding everything below it. That is what forced a Master Builder to be seated above Super Admin + * merely to reach the admin world, dragging every intervening permission along with it. + * <p> + * A title has no level and no parent. It grants exactly the nodes listed on it and nothing else, so + * it can hand a trusted operator one specific capability without moving them up the ladder. The + * practical consequences: + * <ul> + * <li>Title permissions are matched <em>exactly</em> (wildcards included), never by tier. Holding + * a title never implies holding another title's grants.</li> + * <li>A player may hold several titles at once; their capabilities are the union.</li> + * <li>{@link #getWeight()} orders titles for <em>display</em> only. A heavier title is shown in + * preference to a lighter one and confers nothing extra.</li> + * </ul> + */ +public class Title implements Displayable, Comparable<Title> +{ + + /** + * Unique identifier for this title (lowercase, no spaces). + */ + private String id; + + /** + * Display name shown in chat and menus. + */ + private String name; + + /** + * Grammatical determiner (a, an, the). + */ + private String determiner = "a"; + + /** + * Short abbreviation shown in tag brackets. + */ + private String abbreviation; + + /** + * Colour used for this title's display. + */ + private NamedTextColor color = NamedTextColor.WHITE; + + /** + * Custom prefix string for chat display, for example {@code "&8[&5Dev&8] "}. + */ + private String prefix = null; + + /** + * Display priority when a player holds more than one title. Higher wins. This orders + * appearance only and grants nothing: see the class javadoc. + */ + private int weight = 0; + + /** + * Capabilities this title grants, as internal TFM permission strings. Matched exactly, never + * inherited from anything, which is what keeps a title from escalating its holder. + */ + private Set<String> permissions = new HashSet<>(); + + /** + * Whether this title may be announced in the join message in place of the player's rank. + */ + private boolean announce = true; + + private transient Component cachedColoredTag; + private transient Component cachedColoredName; + private transient Component cachedColoredLoginMessage; + + public Title(String id) + { + this.id = normalizeId(id); + this.name = id; + this.abbreviation = id.length() > 3 ? id.substring(0, 3).toUpperCase() : id.toUpperCase(); + invalidateCache(); + } + + /** + * Gson deserialises through this constructor rather than allocating the object unsafely, which + * is what keeps the field initialisers above running. Without it an entry that omits + * {@code announce} or {@code permissions} comes back as {@code false} and {@code null} instead + * of carrying its documented default. + * <p> + * The id is deliberately left unset here: a title carries its id as the key it is filed under + * rather than as a property of the entry, so {@link #assignId(String)} stamps it after the read. + */ + private Title() {} + + /** + * Stamps the id this title was filed under, normalising it exactly as the public constructor + * does so that a title's id always matches the key it is stored against. + */ + public void assignId(final String key) + { + this.id = normalizeId(key); + invalidateCache(); + } + + /** + * Ids reach SQL as a primary key and reach {@code players.titles} as one entry in a + * comma-separated list, so anything that would split that list is stripped rather than stored. + */ + public static String normalizeId(final String raw) + { + return raw.toLowerCase() + .replace(' ', '_') + .replaceAll("[^a-z0-9_\\-]", ""); + } + + /** + * Whether this title grants {@code permission}. + * <p> + * Exact and wildcard matches only. There is deliberately no tier comparison here: a title's + * grants are a closed list, so a node absent from it is denied no matter what else the holder + * has. + */ + public boolean grants(String permission) + { + if (permission == null || permission.isEmpty()) + return false; + + final String node = permission.toLowerCase(); + + if (permissions.contains(node) || permissions.contains("*")) + return true; + + final String[] parts = node.split("\\."); + final StringBuilder prefixBuilder = new StringBuilder(); + + for (int i = 0; i < parts.length - 1; i++) + { + prefixBuilder.append(parts[i]).append('.'); + if (permissions.contains(prefixBuilder + "*")) + return true; + } + + return false; + } + + public void addPermission(String permission) + { + permissions.add(permission.toLowerCase()); + } + + public void removePermission(String permission) + { + permissions.remove(permission.toLowerCase()); + } + + /** + * Invalidate cached components. Call after any property change. + */ + public final void invalidateCache() + { + cachedColoredTag = null; + cachedColoredName = null; + cachedColoredLoginMessage = null; + } + + // ======================================================================== + // Displayable Implementation + // ======================================================================== + + @Override + public String getName() + { + return name; + } + + @Override + public String getTag() + { + return abbreviation == null || abbreviation.isEmpty() ? "" : "[" + abbreviation + "]"; + } + + @Override + public NamedTextColor getColor() + { + return color; + } + + @Override + public Component getColoredName() + { + if (cachedColoredName == null) + { + cachedColoredName = AdventureUtil.format(name).colorIfAbsent(color); + } + return cachedColoredName; + } + + @Override + public Component getColoredTag() + { + if (cachedColoredTag == null) + { + if (prefix != null && !prefix.isEmpty()) + { + cachedColoredTag = AdventureUtil.format(prefix.trim()); + } + else if (abbreviation == null || abbreviation.isEmpty()) + { + cachedColoredTag = Component.empty(); + } + else + { + cachedColoredTag = Component.text("[") + .color(NamedTextColor.DARK_GRAY) + .append(AdventureUtil.format(abbreviation).colorIfAbsent(color)) + .append(Component.text("]").color(NamedTextColor.DARK_GRAY)); + } + } + return cachedColoredTag; + } + + @Override + public Component getColoredLoginMessage() + { + if (cachedColoredLoginMessage == null) + { + cachedColoredLoginMessage = Component.text(determiner + " ") + .append(AdventureUtil.format(name) + .colorIfAbsent(color) + .decorate(TextDecoration.ITALIC)); + } + return cachedColoredLoginMessage; + } + + // ======================================================================== + // Comparable Implementation + // ======================================================================== + + /** + * Orders by display weight, heaviest first, so the natural order is the order titles should be + * preferred in when a player holds several. + */ + @Override + public int compareTo(Title other) + { + final int byWeight = Integer.compare(other.weight, this.weight); + + // Tie-break on id so that ordering only calls two titles equal when equals() does. A sorted + // set would otherwise treat every same-weight title as a duplicate and keep just one. + return byWeight != 0 ? byWeight : id.compareTo(other.id); + } + + @Override + public boolean equals(Object obj) + { + if (this == obj) return true; + if (!(obj instanceof Title other)) return false; + return id.equals(other.id); + } + + @Override + public int hashCode() + { + return id.hashCode(); + } + + @Override + public String toString() + { + return String.format("Title{id=%s, name=%s, weight=%d}", id, name, weight); + } + + // ======================================================================== + // Accessors + // ======================================================================== + + public String getId() + { + return id; + } + + public void setName(String name) + { + this.name = name; + invalidateCache(); + } + + public String getDeterminer() + { + return determiner; + } + + public void setDeterminer(String determiner) + { + this.determiner = determiner; + invalidateCache(); + } + + public String getAbbreviation() + { + return abbreviation; + } + + public void setAbbreviation(String abbreviation) + { + this.abbreviation = abbreviation; + invalidateCache(); + } + + public void setColor(NamedTextColor color) + { + this.color = color; + invalidateCache(); + } + + public String getPrefix() + { + return prefix; + } + + public void setPrefix(String prefix) + { + this.prefix = prefix; + invalidateCache(); + } + + public int getWeight() + { + return weight; + } + + public void setWeight(int weight) + { + this.weight = weight; + } + + public Set<String> getPermissions() + { + return Collections.unmodifiableSet(permissions); + } + + /** + * Replaces the grant list, lower-casing as {@link #addPermission(String)} does so that a set + * installed through here still matches in {@link #grants(String)}. + */ + public void setPermissions(Set<String> permissions) + { + this.permissions = permissions == null + ? new HashSet<>() + : permissions.stream() + .map(String::toLowerCase) + .collect(Collectors.toCollection(HashSet::new)); + } + + public boolean isAnnounce() + { + return announce; + } + + public void setAnnounce(boolean announce) + { + this.announce = announce; + } + +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/title/TitleManager.java b/src/main/java/me/totalfreedom/totalfreedommod/title/TitleManager.java new file mode 100644 index 000000000..238ee710c --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/title/TitleManager.java @@ -0,0 +1,564 @@ +package me.totalfreedom.totalfreedommod.title; + +import com.google.common.collect.Maps; +import com.google.gson.reflect.TypeToken; +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.display.Displayable; +import me.totalfreedom.totalfreedommod.player.PlayerData; +import me.totalfreedom.totalfreedommod.sql.PersistenceQueue; +import me.totalfreedom.totalfreedommod.sql.adapter.TitleRepository; +import me.totalfreedom.totalfreedommod.util.AdventureUtil; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.JsonUtil; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +/** + * Owns the title registry and answers what a player's titles let them do and how they are shown. + * <p> + * Titles sit alongside ranks rather than inside them. Where {@code RankManager} resolves a ladder, + * this resolves a flat set: a player holds zero or more titles, their capabilities are the union of + * what those titles list, and nothing here compares levels or walks a parent chain. That is what + * lets an ordinary operator be handed one specific capability without being promoted to reach it. + * <p> + * Storage mirrors ranks: SQL is authoritative when available, with {@code titles.json} kept as a + * readable snapshot and used as the source when there is no database. + */ +public class TitleManager extends FreedomService +{ + + public static final String TITLES_FILENAME = "titles.json"; + + private static final Type TITLE_MAP_TYPE = new TypeToken<Map<String, Title>>() {}.getType(); + + private static final long SHUTDOWN_FLUSH_TIMEOUT_MS = 10L * 1000L; + + /** + * All titles, keyed by id. + */ + private final Map<String, Title> titles = Maps.newLinkedHashMap(); + + private final PersistenceQueue writes = new PersistenceQueue("title"); + + private File titlesFile; + + private boolean usingSql = false; + + public TitleManager(TotalFreedomMod plugin) + { + super(plugin); + } + + @Override + protected void onStart() + { + loadTitles(); + } + + @Override + protected void onStop() + { + saveTitles(); + writes.await(SHUTDOWN_FLUSH_TIMEOUT_MS); + } + + /** + * Load titles from SQL, falling back to {@code titles.json}. Never blocks: the SQL read runs + * off-thread and its result is applied back on the main thread. + */ + public void loadTitles() + { + titlesFile = new File(plugin.getDataFolder(), TITLES_FILENAME); + + if (plugin.dm != null && plugin.dm.isInitialized()) + { + loadFromSqlAsync(); + return; + } + + loadFromJsonOrDefaults(); + } + + // ======================================================================== + // Lookup + // ======================================================================== + + public Title getTitle(String id) + { + return id == null ? null : titles.get(id.toLowerCase()); + } + + public Map<String, Title> getTitles() + { + return Collections.unmodifiableMap(titles); + } + + /** + * All titles ordered for display, heaviest first. + */ + public List<Title> getTitlesSorted() + { + return titles.values() + .stream() + .sorted() + .toList(); + } + + public boolean hasTitle(String id) + { + return id != null && titles.containsKey(id.toLowerCase()); + } + + /** + * The titles {@code player} actually holds, skipping any id that no longer resolves so that a + * deleted title simply stops applying rather than breaking every lookup for its holders. + */ + public List<Title> getHeldTitles(Player player) + { + if (player == null) + return List.of(); + + final PlayerData data = plugin.pl.getData(player); + + return data == null ? List.of() : resolve(data.getTitles()); + } + + /** + * Resolves a set of stored ids into titles, ordered for display. + */ + public List<Title> resolve(Collection<String> ids) + { + return ids == null + ? List.of() + : ids.stream() + .map(this::getTitle) + .filter(title -> title != null) + .sorted() + .toList(); + } + + /** + * The title that should represent {@code player} on screen, or {@code null} when they hold + * none. The heaviest held title wins; see {@link Title#getWeight()}. + */ + public Title getDisplayTitle(Player player) + { + return getHeldTitles(player).stream() + .filter(Title::isAnnounce) + .findFirst() + .orElse(null); + } + + // ======================================================================== + // Permissions + // ======================================================================== + + /** + * Whether any title {@code sender} holds grants {@code permission}. + * <p> + * Only players can hold titles: a title is attached to a player profile, and a console channel + * has no profile to attach one to. Console standing comes from its {@code host_senders:} + * binding instead, which is a rank question rather than a title one. + */ + public boolean grants(CommandSender sender, String permission) + { + return sender instanceof Player player && grants(player, permission); + } + + public boolean grants(Player player, String permission) + { + return getHeldTitles(player).stream() + .anyMatch(title -> title.grants(permission)); + } + + /** + * Grants a title to a player. Returns false when the title does not exist or is already held. + */ + public boolean grantTitle(Player player, String titleId) + { + final Title title = getTitle(titleId); + if (title == null) + return false; + + final PlayerData data = plugin.pl.getData(player); + if (data == null || !data.addTitle(title.getId())) + return false; + + plugin.pl.saveData(data); + refreshDisplay(player); + + return true; + } + + /** + * Revokes a title from a player. Returns false when they did not hold it. + */ + public boolean revokeTitle(Player player, String titleId) + { + final PlayerData data = plugin.pl.getData(player); + if (data == null || titleId == null || !data.removeTitle(titleId.toLowerCase())) + return false; + + plugin.pl.saveData(data); + refreshDisplay(player); + + return true; + } + + /** + * Re-applies the player's tag after their titles changed. + * <p> + * A tag the player set by hand is left alone: they chose it deliberately, and having a title + * grant silently overwrite it would be surprising. + */ + private void refreshDisplay(Player player) + { + if (plugin.rm == null) + return; + + plugin.rm.updatePlayerTeam(player); + + // A title can open the admin world, and that check is cached per player for 30s. Drop the + // cache now so a grant or revoke takes effect immediately rather than on the next sweep. + if (plugin.wm != null && plugin.wm.adminworld != null) + { + plugin.wm.adminworld.wipeAccessCache(); + } + + final PlayerData data = plugin.pl.getData(player); + if (data == null || data.getSavedTag() != null) + return; + + final Displayable display = plugin.rm.getDisplay(player); + if (display != null) + { + plugin.pl.getPlayer(player) + .setTag(AdventureUtil.componentToLegacySection(display.getColoredTag())); + } + } + + // ======================================================================== + // Mutation + // ======================================================================== + + public void setTitle(Title title) + { + titles.put(title.getId(), title); + saveTitles(); + } + + public boolean removeTitle(String id) + { + if (id == null) + return false; + + final Title removed = titles.remove(Title.normalizeId(id)); + if (removed == null) + return false; + + // saveTitles() only writes the titles that survive, so without an explicit delete the row + // stays behind in SQL and the title returns on the next load. + if (usingSql && plugin.dm != null && plugin.dm.isInitialized()) + { + writes.enqueue(plugin.dm.getTitleRepository() + .deleteAsync(removed.getId()) + .onErrorResume(ex -> + { + FLog.severe(String.format("Could not delete title %s from SQL: %s", + removed.getId(), ex.getMessage())); + return Mono.empty(); + }) + .then()); + } + + saveTitles(); + + return true; + } + + // ======================================================================== + // Persistence + // ======================================================================== + + private void loadFromSqlAsync() + { + final TitleRepository repo = plugin.dm.getTitleRepository(); + plugin.dm.readAsync("TitleManager/loadFromSql", repo.loadAllAsync(), + loaded -> applyLoadedTitles(repo, loaded), + () -> + { + usingSql = false; + loadFromJsonOrDefaults(); + }); + } + + private void applyLoadedTitles(final TitleRepository repo, final Map<String, Title> loaded) + { + usingSql = true; + + if (loaded.isEmpty() && !titlesFile.exists()) + { + installBundledTitles(); + + // The bundled set only reached memory. Push it so a database that started empty ends + // up holding the shipped titles rather than staying empty until someone edits one. + saveTitles(); + return; + } + + titles.clear(); + titles.putAll(loaded); + FLog.info(String.format("Loaded %d titles from SQL database.", titles.size())); + + reconcileFromJsonIfNewer(repo); + } + + /** + * Re-imports {@code titles.json} when it is newer than anything in the table, which is also + * what seeds a database that has never held a title. Without it an empty table plus an existing + * snapshot loads as zero titles, and the shipped set is silently lost on the second start. + */ + private void reconcileFromJsonIfNewer(final TitleRepository repo) + { + if (!titlesFile.exists()) + { + // Nothing to reconcile against, but SQL now has rows that no snapshot covers. + writes.enqueue(writeJsonAsync()); + return; + } + + final Map<String, Title> jsonTitles; + try + { + jsonTitles = readJsonTitles(); + } + catch (IOException ex) + { + FLog.warning(String.format("Failed to read %s: %s", TITLES_FILENAME, ex.getMessage())); + return; + } + + if (jsonTitles.isEmpty()) + return; + + final long fileModified = titlesFile.lastModified(); + + writes.enqueue(Mono.fromCallable(() -> + { + final Long sqlUpdatedAt = repo.getMaxUpdatedAt(); + return sqlUpdatedAt == null || fileModified > sqlUpdatedAt; + }) + .subscribeOn(Schedulers.boundedElastic()) + .filter(Boolean::booleanValue) + .flatMapMany(ignored -> + { + FLog.info(String.format("%s is newer than the database; re-importing %d title(s) from it.", + TITLES_FILENAME, jsonTitles.size())); + return Flux.fromIterable(jsonTitles.values()) + .concatMap(repo::save); + }) + .then(Mono.fromRunnable(() -> plugin.dm.sync("TitleManager/applyReconciled", + () -> applyReconciledTitles(jsonTitles)))) + .onErrorResume(ex -> + { + FLog.warning(String.format("Failed to reconcile %s into the database: %s", + TITLES_FILENAME, ex.getMessage())); + return Mono.empty(); + }) + .then()); + } + + private void applyReconciledTitles(final Map<String, Title> jsonTitles) + { + titles.clear(); + titles.putAll(jsonTitles); + } + + private void loadFromJsonOrDefaults() + { + if (!titlesFile.exists()) + { + installBundledTitles(); + return; + } + + loadFromJson(); + } + + /** + * Writes the bundled {@code titles.json} into the data folder and loads it. Titles are defined + * entirely by configuration, so a first run copies the shipped file rather than synthesising + * anything; if the resource is missing the registry stays empty, which grants nothing. + */ + private void installBundledTitles() + { + try + { + plugin.saveResource(TITLES_FILENAME, false); + } + catch (IllegalArgumentException ex) + { + FLog.severe(String.format("No bundled %s to install: %s", TITLES_FILENAME, ex.getMessage())); + return; + } + + if (!titlesFile.exists()) + { + FLog.severe(String.format("Could not install a default %s; no titles will be available.", + TITLES_FILENAME)); + return; + } + + FLog.info(String.format("Installed the default %s.", TITLES_FILENAME)); + loadFromJson(); + } + + private void loadFromJson() + { + titles.clear(); + + try + { + titles.putAll(readJsonTitles()); + } + catch (IOException ex) + { + FLog.severe(String.format("Could not read %s: %s", TITLES_FILENAME, ex.getMessage())); + } + + FLog.info(String.format("Loaded %d titles.", titles.size())); + } + + private Map<String, Title> readJsonTitles() throws IOException + { + try (FileReader reader = new FileReader(titlesFile)) + { + return stampIds(JsonUtil.GSON.fromJson(reader, TITLE_MAP_TYPE)); + } + } + + /** + * Re-files deserialised titles under their own normalised id. + * <p> + * A JSON entry carries its id as the key it sits under rather than as a field, so a freshly + * deserialised title has none. Stamping it here and re-keying the map on the result keeps the + * key and the title's own id in agreement even where normalisation rewrites the key. + */ + private static Map<String, Title> stampIds(final Map<String, Title> loaded) + { + if (loaded == null) + return Map.of(); + + final Map<String, Title> keyed = new LinkedHashMap<>(); + + loaded.forEach((key, title) -> + { + title.assignId(key); + keyed.put(title.getId(), title); + }); + + return keyed; + } + + /** + * Queue a write of every title to SQL, followed by a refresh of the JSON snapshot. Falls back + * to a JSON-only write when SQL is unavailable. Safe from a command handler: the SQL round + * trips run off the main thread. + */ + public void saveTitles() + { + if (!usingSql || plugin.dm == null || !plugin.dm.isInitialized()) + { + writes.enqueue(writeJsonAsync()); + return; + } + + final TitleRepository repo = plugin.dm.getTitleRepository(); + final List<Title> snapshot = new ArrayList<>(titles.values()); + + writes.enqueue(Flux.fromIterable(snapshot) + .concatMap(title -> repo.save(title) + .onErrorResume(ex -> + { + FLog.severe(String.format("Could not save title %s to SQL: %s", + title.getId(), ex.getMessage())); + return Mono.empty(); + })) + .then(writeJsonAsync())); + } + + public void awaitPendingWrites(long timeoutMs) + { + writes.await(timeoutMs); + } + + private Mono<Void> writeJsonAsync() + { + final Map<String, Title> snapshot = new LinkedHashMap<>(titles); + + return Mono.<Void>fromRunnable(() -> writeJson(snapshot)) + .subscribeOn(Schedulers.boundedElastic()); + } + + private void writeJson(final Map<String, Title> snapshot) + { + if (titlesFile == null) + { + titlesFile = new File(plugin.getDataFolder(), TITLES_FILENAME); + } + + try (FileWriter writer = new FileWriter(titlesFile)) + { + JsonUtil.GSON.toJson(snapshot, TITLE_MAP_TYPE, writer); + } + catch (IOException ex) + { + FLog.severe(String.format("Could not save %s: %s", TITLES_FILENAME, ex.getMessage())); + } + } + + /** + * Every title id currently registered, for tab completion. + */ + public Set<String> getTitleIds() + { + return Collections.unmodifiableSet(titles.keySet()); + } + + /** + * The ids a player holds that still resolve to a registered title. + */ + public Set<String> getHeldTitleIds(Player player) + { + return getHeldTitles(player).stream() + .map(Title::getId) + .collect(Collectors.toSet()); + } + + /** + * The title filling a given id, wrapped for callers that would rather not null-check. + */ + public Optional<Title> find(String id) + { + return Optional.ofNullable(getTitle(id)); + } + +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/util/PlayerListUtil.java b/src/main/java/me/totalfreedom/totalfreedommod/util/PlayerListUtil.java index ddde3e04c..1e38bc757 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/util/PlayerListUtil.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/util/PlayerListUtil.java @@ -12,7 +12,7 @@ import me.totalfreedom.totalfreedommod.PluginProvider; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.rank.CustomRank; -import me.totalfreedom.totalfreedommod.rank.Rank; +import me.totalfreedom.totalfreedommod.rank.RankRole; /** * Plaintext online player list shared by automated console contexts. @@ -49,7 +49,12 @@ public static String buildRankList() // Build a map from ranks to players that have that rank, with OP as a default final Map<CustomRank, List<Player>> playerRanks = new HashMap<>(); - final var defaultRank = CustomRank.fromLegacyRank(Rank.OP); + final var defaultRank = plugin.rm.getRegistry().byRole(RankRole.DEFAULT_OP).orElse(null); + // Without an op rank in the registry there is no bucket to default anyone into, so fall + // back to the plain listing rather than dropping unranked players from the output. + if (defaultRank == null) + return buildPlainList(); + playerRanks.put(defaultRank, new ArrayList<>()); for (final var player : players) { @@ -57,9 +62,7 @@ public static String buildRankList() // If they're an admin, we can pull their rank if (admin != null) { - var rank = plugin.rm.getCustomRank(admin.getCustomRankId()); - if (rank == null) - rank = CustomRank.fromLegacyRank(admin.getRank()); + final var rank = plugin.rm.getCustomRank(admin.getRankId()); if (rank == null) { // Give them OP if somehow they don't have a rank diff --git a/src/main/java/me/totalfreedom/totalfreedommod/vault/PermissionService.java b/src/main/java/me/totalfreedom/totalfreedommod/vault/PermissionService.java index fdecddfb2..22c17f0e7 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/vault/PermissionService.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/vault/PermissionService.java @@ -1,12 +1,11 @@ package me.totalfreedom.totalfreedommod.vault; import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.rank.Rank; +import me.totalfreedom.totalfreedommod.rank.CustomRank; import net.milkbowl.vault.permission.Permission; import org.bukkit.OfflinePlayer; import org.bukkit.entity.Player; -import java.util.Arrays; public class PermissionService extends Permission { private final TotalFreedomMod plugin; @@ -68,8 +67,8 @@ public boolean groupRemove(String world, String group, String permission) { public boolean playerInGroup(String world, String player, String group) { Player p = plugin.getServer().getPlayerExact(player); if (p != null && p.isOnline()) { - Rank rank = plugin.rm.getRank(p); - return rank != null && rank.name().equalsIgnoreCase(group); + CustomRank rank = plugin.rm.getEffectiveRank(p); + return rank != null && rank.getId().equalsIgnoreCase(group); } return false; } @@ -88,9 +87,9 @@ public boolean playerRemoveGroup(String world, String player, String group) { public String[] getPlayerGroups(String world, String player) { Player p = plugin.getServer().getPlayerExact(player); if (p != null && p.isOnline()) { - Rank rank = plugin.rm.getRank(p); + CustomRank rank = plugin.rm.getEffectiveRank(p); if (rank != null) { - return new String[] { rank.name() }; + return new String[] { rank.getId() }; } } return new String[0]; @@ -100,9 +99,9 @@ public String[] getPlayerGroups(String world, String player) { public String getPrimaryGroup(String world, String player) { Player p = plugin.getServer().getPlayerExact(player); if (p != null && p.isOnline()) { - Rank rank = plugin.rm.getRank(p); + CustomRank rank = plugin.rm.getEffectiveRank(p); if (rank != null) { - return rank.name(); + return rank.getId(); } } return "default"; @@ -110,9 +109,9 @@ public String getPrimaryGroup(String world, String player) { @Override public String[] getGroups() { - return Arrays.stream(Rank.values()) - .map(Rank::name) - .toArray(String[]::new); + // Groups are whatever ranks.json defines, so an operator-defined rank is visible to Vault + // consumers on the same footing as one the plugin ships with. + return plugin.rm.getCustomRanks().keySet().toArray(String[]::new); } @Override @@ -124,9 +123,9 @@ public boolean hasGroupSupport() { public String getPrimaryGroup(String world, OfflinePlayer player) { Player p = plugin.getServer().getPlayer(player.getUniqueId()); if (p != null && p.isOnline()) { - Rank rank = plugin.rm.getRank(p); + CustomRank rank = plugin.rm.getEffectiveRank(p); if (rank != null) { - return rank.name(); + return rank.getId(); } } return "default"; @@ -136,9 +135,9 @@ public String getPrimaryGroup(String world, OfflinePlayer player) { public String[] getPlayerGroups(String world, OfflinePlayer player) { Player p = plugin.getServer().getPlayer(player.getUniqueId()); if (p != null && p.isOnline()) { - Rank rank = plugin.rm.getRank(p); + CustomRank rank = plugin.rm.getEffectiveRank(p); if (rank != null) { - return new String[] { rank.name() }; + return new String[] { rank.getId() }; } } return new String[0]; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/AdminWorld.java b/src/main/java/me/totalfreedom/totalfreedommod/world/AdminWorld.java index 1891d81b2..70bccaf64 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/AdminWorld.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/AdminWorld.java @@ -21,6 +21,13 @@ public final class AdminWorld extends CustomWorld { private static final long CACHE_CLEAR_FREQUENCY = 30L * 1000L; //30 seconds, milliseconds + + /** + * The permission that opens the admin world. Held by staff through their rank and by trusted + * players through a title, which is the whole reason entry is a permission check rather than an + * admin check. + */ + private static final String ADMIN_WORLD_NODE = "tfm.world.adminworld"; private static final long TP_COOLDOWN_TIME = 500L; //0.5 seconds, milliseconds private static final String GENERATION_PARAMETERS = ConfigEntry.FLATLANDS_GENERATE_PARAMS.getString(); // @@ -187,7 +194,10 @@ public boolean canAccessWorld(final Player player) Boolean cached = accessCache.get(player); if (cached == null) { - boolean canAccess = plugin.al.isAdmin(player); + // Asked as a permission rather than as "is this an admin", so that a title can open the + // world to someone trusted without promoting them. Admins still pass on their rank; + // a titled player passes on the title's own grant and gains nothing else by it. + boolean canAccess = plugin.rm.hasPermission(player, ADMIN_WORLD_NODE); if (!canAccess) { Player supervisor = guestList.get(player); diff --git a/src/main/resources/ranks.json b/src/main/resources/ranks.json index 068f552ad..e7dd72cf4 100644 --- a/src/main/resources/ranks.json +++ b/src/main/resources/ranks.json @@ -7,7 +7,9 @@ "color": "dark_gray", "determiner": "an", "admin": false, - "console_only": false, + "roles": [ + "impostor" + ], "permissions": [ "tfm.player.list", "tfm.admin.overlord" @@ -21,7 +23,9 @@ "color": "white", "determiner": "a", "admin": false, - "console_only": false, + "roles": [ + "default" + ], "permissions": [ "tfm.player.localspawn", "tfm.player.radar", @@ -40,11 +44,12 @@ "color": "green", "determiner": "an", "admin": false, - "console_only": false, + "roles": [ + "default_op" + ], "inherit": "non_op", "permissions": [ "tfm.player.*", - "tfm.world.adminworld", "tfm.admin.banlist", "tfm.fun.hack", "tfm.fun.landmine", @@ -63,7 +68,9 @@ "color": "gold", "determiner": "a", "admin": true, - "console_only": false, + "roles": [ + "admin_default" + ], "inherit": "op", "permissions": [ "tfm.fun.*", @@ -103,6 +110,18 @@ "tfm.admin.wildcard", "tfm.admin.gamemode", "tfm.admin.protectregion", + "tfm.admin.aeclear", + "tfm.admin.autoclear", + "tfm.admin.autotp", + "tfm.admin.cleanchat", + "tfm.admin.discordlink", + "tfm.admin.fuckup", + "tfm.admin.gchat", + "tfm.admin.saconfig", + "tfm.admin.sqlstatus", + "tfm.server.reload", + "tfm.server.whitelist.manage", + "tfm.world.adminworld.manage", "tfm.server.*", "tfm.manage.saconfig" ] @@ -115,47 +134,20 @@ "color": "light_purple", "determiner": "a", "admin": true, - "console_only": false, + "roles": [ + "console_floor" + ], "inherit": "super_admin", "permissions": [ "tfm.admin.senior.*", "tfm.admin.telnet.*", "tfm.manage.*", "tfm.manage.ssh", - "tfm.manage.telnet" + "tfm.manage.telnet", + "tfm.admin.ban.perm", + "tfm.admin.banlist.purge", + "tfm.ssh.totp", + "tfm.admin.senior.status" ] - }, - "developer": { - "name": "Developer", - "abbreviation": "Dev", - "prefix": "&8[&5Dev&8] ", - "level": 4, - "color": "dark_purple", - "determiner": "a", - "admin": true, - "console_only": false, - "inherit": "senior_admin" - }, - "owner": { - "name": "Owner", - "abbreviation": "Owner", - "prefix": "&8[&9Owner&8] ", - "level": 4, - "color": "blue", - "determiner": "the", - "admin": true, - "console_only": false, - "inherit": "senior_admin" - }, - "executive": { - "name": "Executive", - "abbreviation": "Exec", - "prefix": "&8[&eExec&8] ", - "level": 4, - "color": "yellow", - "determiner": "an", - "admin": true, - "console_only": false, - "inherit": "senior_admin" } } diff --git a/src/main/resources/titles.json b/src/main/resources/titles.json new file mode 100644 index 000000000..c8a72cc1d --- /dev/null +++ b/src/main/resources/titles.json @@ -0,0 +1,44 @@ +{ + "master_builder": { + "name": "Master Builder", + "abbreviation": "MB", + "prefix": "&8[&aMB&8] ", + "color": "green", + "determiner": "a", + "weight": 10, + "announce": true, + "permissions": [ + "tfm.world.adminworld" + ] + }, + "developer": { + "name": "Developer", + "abbreviation": "Dev", + "prefix": "&8[&5Dev&8] ", + "color": "dark_purple", + "determiner": "a", + "weight": 20, + "announce": true, + "permissions": [] + }, + "executive": { + "name": "Executive", + "abbreviation": "Exec", + "prefix": "&8[&eExec&8] ", + "color": "yellow", + "determiner": "an", + "weight": 30, + "announce": true, + "permissions": [] + }, + "owner": { + "name": "Owner", + "abbreviation": "Owner", + "prefix": "&8[&9Owner&8] ", + "color": "blue", + "determiner": "the", + "weight": 40, + "announce": true, + "permissions": [] + } +} From cba736f813759388d78c81feb7bb720b45fc0b52 Mon Sep 17 00:00:00 2001 From: shrimp <luke560@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:04:49 -0600 Subject: [PATCH 16/48] Add /signspy command and service --- .../totalfreedom/totalfreedommod/SignSpy.java | 316 ++++++++++++++++++ .../totalfreedommod/TotalFreedomMod.java | 2 + .../totalfreedommod/cmd/Command_signspy.java | 26 ++ .../totalfreedommod/player/PlayerData.java | 12 + 4 files changed, 356 insertions(+) create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/SignSpy.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_signspy.java diff --git a/src/main/java/me/totalfreedom/totalfreedommod/SignSpy.java b/src/main/java/me/totalfreedom/totalfreedommod/SignSpy.java new file mode 100644 index 000000000..622c82397 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/SignSpy.java @@ -0,0 +1,316 @@ +package me.totalfreedom.totalfreedommod; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import io.papermc.paper.math.Position; +import me.totalfreedom.totalfreedommod.player.PlayerData; +import me.totalfreedom.totalfreedommod.rank.Displayable; +import me.totalfreedom.totalfreedommod.util.AdventureUtil; +import me.totalfreedom.totalfreedommod.util.FTask; +import me.totalfreedom.totalfreedommod.util.FUtil; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickCallback; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.Bukkit; +import org.bukkit.DyeColor; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.block.Sign; +import org.bukkit.block.TileState; +import org.bukkit.block.data.BlockData; +import org.bukkit.block.sign.Side; +import org.bukkit.block.sign.SignSide; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.block.SignChangeEvent; +import org.bukkit.event.player.PlayerQuitEvent; + +public class SignSpy extends FreedomService +{ + private static final int LINES_PER_SIDE = 4; + private static final Duration VIEW_LIFETIME = Duration.ofMinutes(10); + // The client closes the sign editor on its own if the faked block entity disappears under it, + // so the revert doubles as a force-close deadline rather than running right after opening. + private static final long REVERT_DELAY_TICKS = 20L * 60L; + private static final int FAKE_SIGN_DEPTH = 4; + + private Map<UUID, Location> pendingReverts; + + private record SignSnapshot(BlockData blockData, Side side, + List<Component> lines, DyeColor color, boolean glowing, + List<Component> otherLines, DyeColor otherColor, boolean otherGlowing) + { + } + + private static Side opposite(final Side side) + { + return side == Side.FRONT ? Side.BACK : Side.FRONT; + } + + public SignSpy(TotalFreedomMod plugin) + { + super(plugin); + } + + @Override + protected void onStart() + { + this.pendingReverts = new HashMap<>(); + } + + @Override + protected void onStop() + { + for (final UUID viewerId : List.copyOf(pendingReverts.keySet())) + { + revertPending(viewerId); + } + this.pendingReverts = null; + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onSignChange(SignChangeEvent event) + { + final Player editor = event.getPlayer(); + + // The event fires before the edit is applied, so the block state still holds the + // pre-edit text; diff against it so the chat line shows what actually changed. + final Sign oldState = event.getBlock().getState() instanceof Sign sign ? sign : null; + final SignSide oldSide = oldState != null ? oldState.getSide(event.getSide()) : null; + + String changedLine = ""; + String firstLine = ""; + int nonEmptyLines = 0; + for (int i = 0; i < LINES_PER_SIDE; i++) + { + final Component line = event.line(i); + final String plain = line != null ? AdventureUtil.componentToPlainText(line).trim() : ""; + if (plain.isEmpty()) + { + continue; + } + if (nonEmptyLines == 0) + { + firstLine = plain; + } + nonEmptyLines++; + + final String oldPlain = oldSide != null + ? AdventureUtil.componentToPlainText(oldSide.line(i)).trim() : ""; + if (changedLine.isEmpty() && !plain.equals(oldPlain)) + { + changedLine = plain; + } + } + final String displayLine = !changedLine.isEmpty() ? changedLine : firstLine; + + int otherNonEmptyLines = 0; + if (oldState != null) + { + final SignSide otherSide = oldState.getSide(opposite(event.getSide())); + for (int i = 0; i < LINES_PER_SIDE; i++) + { + if (!AdventureUtil.componentToPlainText(otherSide.line(i)).trim().isEmpty()) + { + otherNonEmptyLines++; + } + } + } + + final String sideName = event.getSide() == Side.FRONT ? "front" : "back"; + Component message = Component.empty(); + if (plugin.al.isAdmin(editor)) + { + final Displayable display = plugin.rm.getDisplay(editor); + String prefix = AdventureUtil.componentToPlainText(display.getColoredTag()).trim(); + if (prefix.isEmpty()) + { + final String tag = display.getTag(); + prefix = tag != null ? tag : ""; + } + if (!prefix.isEmpty()) + { + message = Component.text(prefix + " ", display.getColor()); + } + } + message = message.append(Component.text( + editor.getName() + " edited sign (" + sideName + "): '" + displayLine + "'", + NamedTextColor.GRAY)); + if (otherNonEmptyLines > 0) + { + final SignSnapshot snapshot = snapshot(event, oldState); + message = message.append(Component.text(" [View: ", NamedTextColor.GRAY)) + .append(viewButton("Front", "Click to view the front of the sign", + snapshot, Side.FRONT)) + .append(Component.text(" | ", NamedTextColor.GRAY)) + .append(viewButton("Back", "Click to view the back of the sign", + snapshot, Side.BACK)) + .append(Component.text("]", NamedTextColor.GRAY)); + } + else if (nonEmptyLines > 1) + { + final SignSnapshot snapshot = snapshot(event, oldState); + message = message.append(viewButton(" [See more]", "Click to view the full sign", + snapshot, snapshot.side())); + } + + for (final Player admin : plugin.al.getOnlineAdmins()) + { + if (admin.equals(editor)) + { + continue; + } + final PlayerData data = plugin.pl.getData(admin); + if (data == null || !data.isSignSpy()) + { + continue; + } + FUtil.playerMsg(admin, message); + } + } + + @EventHandler + public void onPlayerQuit(PlayerQuitEvent event) + { + if (pendingReverts != null) + { + pendingReverts.remove(event.getPlayer().getUniqueId()); + } + } + + private SignSnapshot snapshot(final SignChangeEvent event, final Sign oldState) + { + DyeColor color = DyeColor.BLACK; + boolean glowing = false; + DyeColor otherColor = DyeColor.BLACK; + boolean otherGlowing = false; + final List<Component> otherLines = new ArrayList<>(LINES_PER_SIDE); + // Dye and glow are applied by separate interactions, never by the edit itself, so the + // pre-edit state is the correct source for them; only the text comes from the event. + if (oldState != null) + { + final SignSide side = oldState.getSide(event.getSide()); + color = side.getColor() != null ? side.getColor() : DyeColor.BLACK; + glowing = side.isGlowingText(); + final SignSide otherSide = oldState.getSide(opposite(event.getSide())); + otherColor = otherSide.getColor() != null ? otherSide.getColor() : DyeColor.BLACK; + otherGlowing = otherSide.isGlowingText(); + for (int i = 0; i < LINES_PER_SIDE; i++) + { + otherLines.add(otherSide.line(i)); + } + } + while (otherLines.size() < LINES_PER_SIDE) + { + otherLines.add(Component.empty()); + } + + final List<Component> lines = new ArrayList<>(LINES_PER_SIDE); + for (int i = 0; i < LINES_PER_SIDE; i++) + { + final Component line = event.line(i); + lines.add(line != null ? line : Component.empty()); + } + + return new SignSnapshot(event.getBlock().getBlockData().clone(), event.getSide(), + List.copyOf(lines), color, glowing, List.copyOf(otherLines), otherColor, otherGlowing); + } + + /** + * A callback click event reaches the server as an opaque payload rather than a command, so + * unlike runCommand it needs no client-side command parse and never prompts the click + * confirmation screen added in 1.21.6. + */ + private Component viewButton(final String label, final String hover, + final SignSnapshot snapshot, final Side displaySide) + { + return Component.text(label, NamedTextColor.YELLOW) + .clickEvent(ClickEvent.callback( + audience -> + { + if (audience instanceof Player viewer) + { + Bukkit.getScheduler().runTask(plugin, FTask.guard("SignSpy/openView", + () -> openView(viewer, snapshot, displaySide))); + } + }, + ClickCallback.Options.builder() + .uses(ClickCallback.UNLIMITED_USES) + .lifetime(VIEW_LIFETIME) + .build())) + .hoverEvent(HoverEvent.showText(Component.text(hover, NamedTextColor.GRAY))); + } + + private void openView(final Player viewer, final SignSnapshot snapshot, final Side displaySide) + { + if (!viewer.isOnline()) + { + return; + } + + revertPending(viewer.getUniqueId()); + + final World world = viewer.getWorld(); + final Location loc = viewer.getLocation().toBlockLocation(); + loc.setY(Math.max(world.getMinHeight(), loc.getBlockY() - FAKE_SIGN_DEPTH)); + + final BlockData blockData = snapshot.blockData(); + final Sign state = (Sign) blockData.createBlockState(); + final SignSide sideState = state.getSide(snapshot.side()); + final SignSide otherSideState = state.getSide(opposite(snapshot.side())); + for (int i = 0; i < LINES_PER_SIDE; i++) + { + sideState.line(i, snapshot.lines().get(i)); + otherSideState.line(i, snapshot.otherLines().get(i)); + } + sideState.setColor(snapshot.color()); + sideState.setGlowingText(snapshot.glowing()); + otherSideState.setColor(snapshot.otherColor()); + otherSideState.setGlowingText(snapshot.otherGlowing()); + + viewer.sendBlockChange(loc, blockData); + viewer.sendBlockUpdate(loc, state); + viewer.openVirtualSign(Position.block(loc), displaySide); + + pendingReverts.put(viewer.getUniqueId(), loc); + Bukkit.getScheduler().runTaskLater(plugin, + FTask.guard("SignSpy/revertFakeSign", () -> revertIfCurrent(viewer.getUniqueId(), loc)), + REVERT_DELAY_TICKS); + } + + private void revertIfCurrent(final UUID viewerId, final Location faked) + { + if (pendingReverts != null && faked.equals(pendingReverts.get(viewerId))) + { + revertPending(viewerId); + } + } + + private void revertPending(final UUID viewerId) + { + final Location faked = pendingReverts.remove(viewerId); + if (faked == null) + { + return; + } + final Player viewer = Bukkit.getPlayer(viewerId); + if (viewer == null || !viewer.getWorld().equals(faked.getWorld())) + { + // A world change forces a full chunk resend, which cleans the ghost block up anyway. + return; + } + viewer.sendBlockChange(faked, faked.getBlock().getBlockData()); + if (faked.getBlock().getState() instanceof TileState tile) + { + viewer.sendBlockUpdate(faked, tile); + } + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/TotalFreedomMod.java b/src/main/java/me/totalfreedom/totalfreedommod/TotalFreedomMod.java index e16a8c17e..d57186f73 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/TotalFreedomMod.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/TotalFreedomMod.java @@ -104,6 +104,7 @@ public class TotalFreedomMod extends JavaPlugin public GameRuleHandler gr; // GameRuleHandler - Manages game rules public CommandSpy cs; // CommandSpy - Logs and monitors command usage public PotionSpy ps; // PotionSpy - Logs and monitors potion usage + public SignSpy ss; // SignSpy - Logs and monitors sign edits public Cager ca; // Cager - Creates cages around players public Freezer fm; // Freezer - Freezes players in place public Orbiter or; // Orbiter - Makes players orbit around a point @@ -239,6 +240,7 @@ public void onEnable() // Single admin utils cs = services.registerService(CommandSpy.class); ps = services.registerService(PotionSpy.class); + ss = services.registerService(SignSpy.class); ca = services.registerService(Cager.class); fm = services.registerService(Freezer.class); or = services.registerService(Orbiter.class); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_signspy.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_signspy.java new file mode 100644 index 000000000..e206548f9 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_signspy.java @@ -0,0 +1,26 @@ +package me.totalfreedom.totalfreedommod.cmd; + +import org.bukkit.entity.Player; + +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; +import me.totalfreedom.totalfreedommod.player.PlayerData; +import me.totalfreedom.totalfreedommod.rank.Rank; +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; + +@Command(name = "signspy", description = "Spy on sign edits", usage = "/signspy", aliases = {"sspy"}) +@Permission(permission = "tfm.admin.signspy", level = Rank.SUPER_ADMIN, source = SourceType.ONLY_IN_GAME) +public class Command_signspy extends FCommand +{ + @Callback + public void toggle(final Player player) + { + final PlayerData data = plugin().pl.getData(player); + data.setSignSpy(!data.isSignSpy()); + plugin().pl.saveAsync(); + msg( + player, + "<gray>SignSpy <status:enabled:disabled>.", + Formatter.booleanChoice("status", data.isSignSpy()) + ); + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerData.java b/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerData.java index 842ebe317..e3b6533c1 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerData.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerData.java @@ -25,6 +25,7 @@ public class PlayerData implements ConfigLoadable, Validatable private long firstJoinUnix; private long lastJoinUnix; private boolean potionSpy; + private boolean signSpy; private CommandSpyMode commandSpyMode = CommandSpyMode.OFF; private boolean muted; private boolean frozen; @@ -145,6 +146,7 @@ public void loadFrom(ConfigurationSection cs) this.firstJoinUnix = cs.getLong("first_join", 0); this.lastJoinUnix = cs.getLong("last_join", 0); this.potionSpy = cs.getBoolean("potion_spy", false); + this.signSpy = cs.getBoolean("sign_spy", false); final boolean legacyCommandSpy = cs.getBoolean("command_spy", false); this.commandSpyMode = CommandSpyMode.fromString(cs.getString("command_spy_mode", legacyCommandSpy ? "ops" : "off")); this.muted = cs.getBoolean("muted", false); @@ -185,6 +187,16 @@ public void setCommandSpyMode(CommandSpyMode commandSpyMode) this.commandSpyMode = commandSpyMode == null ? CommandSpyMode.OFF : commandSpyMode; } + public boolean isSignSpy() + { + return signSpy; + } + + public void setSignSpy(final boolean signSpy) + { + this.signSpy = signSpy; + } + public boolean isJoinLeaveMessagesEnabled() { return joinLeaveMessagesEnabled; From 5b8ced02ee84c81598e9e421009c45f3d4f5bcc7 Mon Sep 17 00:00:00 2001 From: shrimp <luke560@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:37:44 -0600 Subject: [PATCH 17/48] Suppress no-op sign edits and blank placements --- .../totalfreedom/totalfreedommod/SignSpy.java | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/SignSpy.java b/src/main/java/me/totalfreedom/totalfreedommod/SignSpy.java index 622c82397..0e1b31535 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/SignSpy.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/SignSpy.java @@ -89,10 +89,21 @@ public void onSignChange(SignChangeEvent event) String changedLine = ""; String firstLine = ""; int nonEmptyLines = 0; + boolean changed = false; for (int i = 0; i < LINES_PER_SIDE; i++) { final Component line = event.line(i); final String plain = line != null ? AdventureUtil.componentToPlainText(line).trim() : ""; + final String oldPlain = oldSide != null + ? AdventureUtil.componentToPlainText(oldSide.line(i)).trim() : ""; + if (!plain.equals(oldPlain)) + { + changed = true; + if (changedLine.isEmpty() && !plain.isEmpty()) + { + changedLine = plain; + } + } if (plain.isEmpty()) { continue; @@ -102,14 +113,15 @@ public void onSignChange(SignChangeEvent event) firstLine = plain; } nonEmptyLines++; + } - final String oldPlain = oldSide != null - ? AdventureUtil.componentToPlainText(oldSide.line(i)).trim() : ""; - if (changedLine.isEmpty() && !plain.equals(oldPlain)) - { - changedLine = plain; - } + // Covers no-op edits and blank placements alike: submitting the editor without altering + // any line is not worth logging. + if (!changed) + { + return; } + final String displayLine = !changedLine.isEmpty() ? changedLine : firstLine; int otherNonEmptyLines = 0; From 7a31d11c22286864acd71795f16179c56c63725c Mon Sep 17 00:00:00 2001 From: shrimp <luke560@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:25:04 -0600 Subject: [PATCH 18/48] Rework chat format --- .../totalfreedom/totalfreedommod/SignSpy.java | 129 +++++++++++++----- 1 file changed, 96 insertions(+), 33 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/SignSpy.java b/src/main/java/me/totalfreedom/totalfreedommod/SignSpy.java index 0e1b31535..44914b96b 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/SignSpy.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/SignSpy.java @@ -25,6 +25,7 @@ import org.bukkit.block.Sign; import org.bukkit.block.TileState; import org.bukkit.block.data.BlockData; +import org.bukkit.block.data.type.WallSign; import org.bukkit.block.sign.Side; import org.bukkit.block.sign.SignSide; import org.bukkit.entity.Player; @@ -41,15 +42,29 @@ public class SignSpy extends FreedomService // so the revert doubles as a force-close deadline rather than running right after opening. private static final long REVERT_DELAY_TICKS = 20L * 60L; private static final int FAKE_SIGN_DEPTH = 4; + // The sign editor caps a line at 90 pixels rather than at a character count, which is about 15 + // characters of ordinary text and about 45 of the narrowest. Anything past this came from a + // client that skipped the editor, so cutting it costs no legitimate text and stops a 384 + // character line, the packet's own limit, from flooding chat. + private static final int MAX_LINE_CHARS = 50; + // A four line sign of narrow text still runs long, so the whole summary gets a budget too. + private static final int SUMMARY_CHARS = 90; + // Trailing room this small only fits a stub of the next line, which reads worse than dropping it. + private static final int MIN_CHANGE_CHARS = 8; private Map<UUID, Location> pendingReverts; private record SignSnapshot(BlockData blockData, Side side, - List<Component> lines, DyeColor color, boolean glowing, + List<Component> lines, List<Component> oldLines, + DyeColor color, boolean glowing, List<Component> otherLines, DyeColor otherColor, boolean otherGlowing) { } + private record LineChange(boolean added, String text) + { + } + private static Side opposite(final Side side) { return side == Side.FRONT ? Side.BACK : Side.FRONT; @@ -86,10 +101,11 @@ public void onSignChange(SignChangeEvent event) final Sign oldState = event.getBlock().getState() instanceof Sign sign ? sign : null; final SignSide oldSide = oldState != null ? oldState.getSide(event.getSide()) : null; - String changedLine = ""; - String firstLine = ""; + // Written and cleared lines are listed in the order they appear on the sign, so the chat + // line reads like the edit itself rather than a summary of it. + final List<LineChange> changes = new ArrayList<>(LINES_PER_SIDE); int nonEmptyLines = 0; - boolean changed = false; + int deletedLines = 0; for (int i = 0; i < LINES_PER_SIDE; i++) { final Component line = event.line(i); @@ -98,32 +114,29 @@ public void onSignChange(SignChangeEvent event) ? AdventureUtil.componentToPlainText(oldSide.line(i)).trim() : ""; if (!plain.equals(oldPlain)) { - changed = true; - if (changedLine.isEmpty() && !plain.isEmpty()) + if (plain.isEmpty()) { - changedLine = plain; + deletedLines++; + changes.add(new LineChange(false, oldPlain)); + } + else + { + changes.add(new LineChange(true, plain)); } } - if (plain.isEmpty()) - { - continue; - } - if (nonEmptyLines == 0) + if (!plain.isEmpty()) { - firstLine = plain; + nonEmptyLines++; } - nonEmptyLines++; } // Covers no-op edits and blank placements alike: submitting the editor without altering // any line is not worth logging. - if (!changed) + if (changes.isEmpty()) { return; } - final String displayLine = !changedLine.isEmpty() ? changedLine : firstLine; - int otherNonEmptyLines = 0; if (oldState != null) { @@ -137,7 +150,13 @@ public void onSignChange(SignChangeEvent event) } } - final String sideName = event.getSide() == Side.FRONT ? "front" : "back"; + // Naming the side only says something when the other side is written on too; a wall sign + // has just the one reachable side, and a blank back is nothing to distinguish from. + final boolean bothSides = otherNonEmptyLines > 0 + && !(event.getBlock().getBlockData() instanceof WallSign); + final String context = bothSides + ? (event.getSide() == Side.FRONT ? " (front)" : " (back)") : ""; + Component message = Component.empty(); if (plugin.al.isAdmin(editor)) { @@ -154,24 +173,61 @@ public void onSignChange(SignChangeEvent event) } } message = message.append(Component.text( - editor.getName() + " edited sign (" + sideName + "): '" + displayLine + "'", - NamedTextColor.GRAY)); - if (otherNonEmptyLines > 0) + editor.getName() + " edited sign" + context, NamedTextColor.GRAY)); + + final List<LineChange> shown = new ArrayList<>(changes.size()); + int budget = SUMMARY_CHARS; + for (final LineChange change : changes) + { + if (budget < MIN_CHANGE_CHARS && !shown.isEmpty()) + { + break; + } + final String text = change.text(); + final int room = Math.min(budget, MAX_LINE_CHARS); + shown.add(text.length() > room + ? new LineChange(change.added(), text.substring(0, room)) : change); + budget -= Math.min(text.length(), room); + } + + boolean firstChange = true; + for (final LineChange change : shown) + { + message = message.append(Component.text(firstChange ? " " : ", ", NamedTextColor.GRAY)) + .append(Component.text(change.added() ? "+" : "-", + change.added() ? NamedTextColor.GREEN : NamedTextColor.RED)) + .append(Component.text("'" + change.text() + "'", NamedTextColor.GRAY)); + firstChange = false; + } + + if (deletedLines > 0) + { + final SignSnapshot snapshot = snapshot(event, oldState); + message = message.append(Component.text(" [", NamedTextColor.GRAY)) + .append(viewButton("Before", "Click to view the sign before this edit", + snapshot, snapshot.side(), true)) + .append(Component.text(" | ", NamedTextColor.GRAY)) + .append(viewButton("After", "Click to view the sign after this edit", + snapshot, snapshot.side(), false)) + .append(Component.text("]", NamedTextColor.GRAY)); + } + else if (bothSides) { final SignSnapshot snapshot = snapshot(event, oldState); - message = message.append(Component.text(" [View: ", NamedTextColor.GRAY)) + message = message.append(Component.text(" [", NamedTextColor.GRAY)) .append(viewButton("Front", "Click to view the front of the sign", - snapshot, Side.FRONT)) + snapshot, Side.FRONT, false)) .append(Component.text(" | ", NamedTextColor.GRAY)) .append(viewButton("Back", "Click to view the back of the sign", - snapshot, Side.BACK)) + snapshot, Side.BACK, false)) .append(Component.text("]", NamedTextColor.GRAY)); } - else if (nonEmptyLines > 1) + else { final SignSnapshot snapshot = snapshot(event, oldState); - message = message.append(viewButton(" [See more]", "Click to view the full sign", - snapshot, snapshot.side())); + message = message.append(Component.text(" ", NamedTextColor.GRAY)) + .append(viewButton("[View]", "Click to view the full sign", + snapshot, snapshot.side(), false)); } for (final Player admin : plugin.al.getOnlineAdmins()) @@ -205,6 +261,7 @@ private SignSnapshot snapshot(final SignChangeEvent event, final Sign oldState) DyeColor otherColor = DyeColor.BLACK; boolean otherGlowing = false; final List<Component> otherLines = new ArrayList<>(LINES_PER_SIDE); + final List<Component> oldLines = new ArrayList<>(LINES_PER_SIDE); // Dye and glow are applied by separate interactions, never by the edit itself, so the // pre-edit state is the correct source for them; only the text comes from the event. if (oldState != null) @@ -218,11 +275,13 @@ private SignSnapshot snapshot(final SignChangeEvent event, final Sign oldState) for (int i = 0; i < LINES_PER_SIDE; i++) { otherLines.add(otherSide.line(i)); + oldLines.add(side.line(i)); } } while (otherLines.size() < LINES_PER_SIDE) { otherLines.add(Component.empty()); + oldLines.add(Component.empty()); } final List<Component> lines = new ArrayList<>(LINES_PER_SIDE); @@ -233,7 +292,8 @@ private SignSnapshot snapshot(final SignChangeEvent event, final Sign oldState) } return new SignSnapshot(event.getBlock().getBlockData().clone(), event.getSide(), - List.copyOf(lines), color, glowing, List.copyOf(otherLines), otherColor, otherGlowing); + List.copyOf(lines), List.copyOf(oldLines), color, glowing, + List.copyOf(otherLines), otherColor, otherGlowing); } /** @@ -241,8 +301,8 @@ private SignSnapshot snapshot(final SignChangeEvent event, final Sign oldState) * unlike runCommand it needs no client-side command parse and never prompts the click * confirmation screen added in 1.21.6. */ - private Component viewButton(final String label, final String hover, - final SignSnapshot snapshot, final Side displaySide) + private Component viewButton(final String label, final String hover, final SignSnapshot snapshot, + final Side displaySide, final boolean beforeEdit) { return Component.text(label, NamedTextColor.YELLOW) .clickEvent(ClickEvent.callback( @@ -251,7 +311,7 @@ private Component viewButton(final String label, final String hover, if (audience instanceof Player viewer) { Bukkit.getScheduler().runTask(plugin, FTask.guard("SignSpy/openView", - () -> openView(viewer, snapshot, displaySide))); + () -> openView(viewer, snapshot, displaySide, beforeEdit))); } }, ClickCallback.Options.builder() @@ -261,7 +321,8 @@ private Component viewButton(final String label, final String hover, .hoverEvent(HoverEvent.showText(Component.text(hover, NamedTextColor.GRAY))); } - private void openView(final Player viewer, final SignSnapshot snapshot, final Side displaySide) + private void openView(final Player viewer, final SignSnapshot snapshot, final Side displaySide, + final boolean beforeEdit) { if (!viewer.isOnline()) { @@ -278,9 +339,11 @@ private void openView(final Player viewer, final SignSnapshot snapshot, final Si final Sign state = (Sign) blockData.createBlockState(); final SignSide sideState = state.getSide(snapshot.side()); final SignSide otherSideState = state.getSide(opposite(snapshot.side())); + // The other side is untouched by the edit, so it reads the same before and after. + final List<Component> editedLines = beforeEdit ? snapshot.oldLines() : snapshot.lines(); for (int i = 0; i < LINES_PER_SIDE; i++) { - sideState.line(i, snapshot.lines().get(i)); + sideState.line(i, editedLines.get(i)); otherSideState.line(i, snapshot.otherLines().get(i)); } sideState.setColor(snapshot.color()); From 68f0b2f0a1f1786b91cc986231478bbc4d0a9777 Mon Sep 17 00:00:00 2001 From: shrimp <luke560@users.noreply.github.com> Date: Sun, 2 Aug 2026 07:22:50 -0600 Subject: [PATCH 19/48] remove comments --- .../totalfreedom/totalfreedommod/SignSpy.java | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/SignSpy.java b/src/main/java/me/totalfreedom/totalfreedommod/SignSpy.java index 44914b96b..663dc542a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/SignSpy.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/SignSpy.java @@ -38,18 +38,10 @@ public class SignSpy extends FreedomService { private static final int LINES_PER_SIDE = 4; private static final Duration VIEW_LIFETIME = Duration.ofMinutes(10); - // The client closes the sign editor on its own if the faked block entity disappears under it, - // so the revert doubles as a force-close deadline rather than running right after opening. private static final long REVERT_DELAY_TICKS = 20L * 60L; private static final int FAKE_SIGN_DEPTH = 4; - // The sign editor caps a line at 90 pixels rather than at a character count, which is about 15 - // characters of ordinary text and about 45 of the narrowest. Anything past this came from a - // client that skipped the editor, so cutting it costs no legitimate text and stops a 384 - // character line, the packet's own limit, from flooding chat. private static final int MAX_LINE_CHARS = 50; - // A four line sign of narrow text still runs long, so the whole summary gets a budget too. private static final int SUMMARY_CHARS = 90; - // Trailing room this small only fits a stub of the next line, which reads worse than dropping it. private static final int MIN_CHANGE_CHARS = 8; private Map<UUID, Location> pendingReverts; @@ -96,13 +88,9 @@ public void onSignChange(SignChangeEvent event) { final Player editor = event.getPlayer(); - // The event fires before the edit is applied, so the block state still holds the - // pre-edit text; diff against it so the chat line shows what actually changed. final Sign oldState = event.getBlock().getState() instanceof Sign sign ? sign : null; final SignSide oldSide = oldState != null ? oldState.getSide(event.getSide()) : null; - // Written and cleared lines are listed in the order they appear on the sign, so the chat - // line reads like the edit itself rather than a summary of it. final List<LineChange> changes = new ArrayList<>(LINES_PER_SIDE); int nonEmptyLines = 0; int deletedLines = 0; @@ -130,8 +118,6 @@ public void onSignChange(SignChangeEvent event) } } - // Covers no-op edits and blank placements alike: submitting the editor without altering - // any line is not worth logging. if (changes.isEmpty()) { return; @@ -150,8 +136,6 @@ public void onSignChange(SignChangeEvent event) } } - // Naming the side only says something when the other side is written on too; a wall sign - // has just the one reachable side, and a blank back is nothing to distinguish from. final boolean bothSides = otherNonEmptyLines > 0 && !(event.getBlock().getBlockData() instanceof WallSign); final String context = bothSides @@ -262,8 +246,6 @@ private SignSnapshot snapshot(final SignChangeEvent event, final Sign oldState) boolean otherGlowing = false; final List<Component> otherLines = new ArrayList<>(LINES_PER_SIDE); final List<Component> oldLines = new ArrayList<>(LINES_PER_SIDE); - // Dye and glow are applied by separate interactions, never by the edit itself, so the - // pre-edit state is the correct source for them; only the text comes from the event. if (oldState != null) { final SignSide side = oldState.getSide(event.getSide()); @@ -296,11 +278,6 @@ private SignSnapshot snapshot(final SignChangeEvent event, final Sign oldState) List.copyOf(otherLines), otherColor, otherGlowing); } - /** - * A callback click event reaches the server as an opaque payload rather than a command, so - * unlike runCommand it needs no client-side command parse and never prompts the click - * confirmation screen added in 1.21.6. - */ private Component viewButton(final String label, final String hover, final SignSnapshot snapshot, final Side displaySide, final boolean beforeEdit) { @@ -379,7 +356,6 @@ private void revertPending(final UUID viewerId) final Player viewer = Bukkit.getPlayer(viewerId); if (viewer == null || !viewer.getWorld().equals(faked.getWorld())) { - // A world change forces a full chunk resend, which cleans the ghost block up anyway. return; } viewer.sendBlockChange(faked, faked.getBlock().getBlockData()); From 3447e69be37aa968fff7d54b736ac659c0f64744 Mon Sep 17 00:00:00 2001 From: shrimp <luke560@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:22:37 -0600 Subject: [PATCH 20/48] Add bookspy, streamline spy modes --- .../totalfreedom/totalfreedommod/BookSpy.java | 275 ++++++++++++++++++ .../totalfreedommod/CommandSpy.java | 10 +- .../totalfreedommod/PotionSpy.java | 4 +- .../totalfreedom/totalfreedommod/SignSpy.java | 26 +- .../totalfreedommod/TotalFreedomMod.java | 2 + .../totalfreedommod/cmd/Command_bookspy.java | 51 ++++ .../totalfreedommod/cmd/Command_cmdspy.java | 12 +- .../cmd/Command_potionspy.java | 44 ++- .../totalfreedommod/cmd/Command_signspy.java | 41 ++- .../player/CommandSpyMode.java | 34 --- .../totalfreedommod/player/FPlayer.java | 12 +- .../totalfreedommod/player/PlayerData.java | 63 ++-- .../totalfreedommod/player/SpyMode.java | 44 +++ .../generic/GenericPlayerRepository.java | 39 ++- .../sql/adapter/mysql/MySQLAdapter.java | 7 +- .../adapter/postgresql/PostgreSQLAdapter.java | 7 +- .../sql/adapter/sqlite/SQLiteAdapter.java | 7 +- src/main/resources/ranks.json | 2 + 18 files changed, 558 insertions(+), 122 deletions(-) create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/BookSpy.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_bookspy.java delete mode 100644 src/main/java/me/totalfreedom/totalfreedommod/player/CommandSpyMode.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/player/SpyMode.java diff --git a/src/main/java/me/totalfreedom/totalfreedommod/BookSpy.java b/src/main/java/me/totalfreedom/totalfreedommod/BookSpy.java new file mode 100644 index 000000000..2f9fefe1a --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/BookSpy.java @@ -0,0 +1,275 @@ +package me.totalfreedom.totalfreedommod; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +import me.totalfreedom.totalfreedommod.player.PlayerData; +import me.totalfreedom.totalfreedommod.rank.Displayable; +import me.totalfreedom.totalfreedommod.util.*; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickCallback; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.player.PlayerEditBookEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.BookMeta; + +public class BookSpy extends FreedomService +{ + private static final Duration VIEW_LIFETIME = Duration.ofMinutes(10); + private static final int SUMMARY_CHARS = 60; + private static final int MAX_CHANGE_CHARS = 30; + private static final int MIN_CHANGE_CHARS = 8; + private static final int MAX_LISTED_PAGES = 5; + private static final Component UNTITLED = Component.text("Untitled"); + + private record BookSnapshot(Component title, String author, List<Component> newPages, int firstChangedPage) + { + } + + private record PageChange(int page, boolean added, String text) + { + } + + public BookSpy(TotalFreedomMod plugin) + { + super(plugin); + } + + @Override + protected void onStart() + { + } + + @Override + protected void onStop() + { + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onPlayerEditBook(PlayerEditBookEvent event) + { + final Player editor = event.getPlayer(); + final BookMeta oldMeta = event.getPreviousBookMeta(); + final BookMeta newMeta = event.getNewBookMeta(); + final List<Component> oldPages = List.copyOf(oldMeta.pages()); + final List<Component> newPages = List.copyOf(newMeta.pages()); + + if (!event.isSigning() && oldPages.equals(newPages)) + { + return; + } + + if (!event.isSigning() && isBlank(newPages)) + { + return; + } + + final boolean editorIsAdmin = plugin.al.isAdmin(editor); + final Component title = newMeta.hasTitle() ? newMeta.title() : UNTITLED; + final List<PageChange> changes = diff(oldPages, newPages); + final BookSnapshot snapshot = new BookSnapshot(title, editor.getName(), newPages, + changes.isEmpty() ? 0 : changes.get(0).page()); + + Component message = Component.empty(); + if (editorIsAdmin) + { + final Displayable display = plugin.rm.getDisplay(editor); + String prefix = AdventureUtil.componentToPlainText(display.getColoredTag()).trim(); + if (prefix.isEmpty()) + { + // A rank is free to report no tag at all, so fall back to the empty string + // rather than letting a null reach the isEmpty() check below + final String tag = display.getTag(); + prefix = tag != null ? tag : ""; + } + if (!prefix.isEmpty()) + { + message = Component.text(String.format("%s ", prefix), display.getColor()); + } + } + + final StringBuilder action = new StringBuilder(editor.getName()); + if (event.isSigning()) + { + action.append(String.format(" signed book '%s'", AdventureUtil.componentToPlainText(title).trim())); + } + else + { + action.append(" edited book"); + } + if (!changes.isEmpty()) + { + action.append(String.format(" (%s)", pageLabel(changes))); + } + message = message.append(Component.text(action.toString(), NamedTextColor.GRAY)); + + int budget = SUMMARY_CHARS; + int shown = 0; + for (final PageChange change : changes) + { + if (shown > 0 && budget < MIN_CHANGE_CHARS) + { + break; + } + final int room = Math.min(budget, MAX_CHANGE_CHARS); + final String text = change.text().length() > room + ? String.format("%s[...]", change.text().substring(0, room)) : change.text(); + message = message.append(Component.text(shown == 0 ? " " : ", ", NamedTextColor.GRAY)) + .append(Component.text(change.added() ? "+" : "-", + change.added() ? NamedTextColor.GREEN : NamedTextColor.RED)) + .append(Component.text(String.format("'%s'", text), NamedTextColor.GRAY)); + budget -= text.length(); + shown++; + } + + if (shown < changes.size()) + { + message = message.append(Component.text(String.format(" (and %d more)", changes.size() - shown), + NamedTextColor.GRAY)); + } + + message = message.append(Component.text(" [", NamedTextColor.GRAY)); + if (!isBlank(oldPages)) + { + message = message.append(viewButton("See Edit", "Click to read the first edited page", snapshot, false)) + .append(Component.text(" | ", NamedTextColor.GRAY)); + } + message = message.append(viewButton("Read Book", "Click to read the whole book from page 1", snapshot, true)) + .append(Component.text("]", NamedTextColor.GRAY)); + + for (final Player admin : plugin.al.getOnlineAdmins()) + { + if (admin.equals(editor)) + { + continue; + } + final PlayerData data = plugin.pl.getData(admin); + if (data == null || !data.getBookSpyMode().shows(editorIsAdmin)) + { + continue; + } + FUtil.playerMsg(admin, message); + } + } + + private static boolean isBlank(final List<Component> pages) + { + return pages.stream() + .map(AdventureUtil::componentToPlainText) + .map(String::trim) + .allMatch(String::isEmpty); + } + + private static List<PageChange> diff(final List<Component> oldPages, final List<Component> newPages) + { + final int pageCount = Math.max(oldPages.size(), newPages.size()); + final List<PageChange> changes = new ArrayList<>(pageCount); + for (int i = 0; i < pageCount; i++) + { + final List<String> before = lines(oldPages, i); + final List<String> now = lines(newPages, i); + if (before.equals(now)) + { + continue; + } + for (final String line : now) + { + if (!before.contains(line)) + { + changes.add(new PageChange(i, true, line)); + } + } + for (final String line : before) + { + if (!now.contains(line)) + { + changes.add(new PageChange(i, false, line)); + } + } + } + return changes; + } + + private static List<String> lines(final List<Component> pages, final int index) + { + if (index >= pages.size()) + { + return List.of(); + } + return AdventureUtil.componentToPlainText(pages.get(index)) + .lines() + .map(String::trim) + .filter(line -> !line.isEmpty()) + .toList(); + } + + private static String pageLabel(final List<PageChange> changes) + { + final List<Integer> pages = changes.stream() + .map(PageChange::page) + .distinct() + .toList(); + + final StringBuilder label = new StringBuilder(pages.size() == 1 ? "page " : "pages "); + label.append(String.join(", ", pages.stream() + .limit(MAX_LISTED_PAGES) + .map(page -> String.valueOf(page + 1)) + .toList())); + if (pages.size() > MAX_LISTED_PAGES) + { + label.append(String.format(" and %d more", pages.size() - MAX_LISTED_PAGES)); + } + return label.toString(); + } + + private Component viewButton(final String label, final String hover, final BookSnapshot snapshot, + final boolean wholeBook) + { + return Component.text(label, NamedTextColor.YELLOW) + .clickEvent(ClickEvent.callback( + audience -> + { + if (audience instanceof Player viewer) + { + Bukkit.getScheduler().runTask(plugin, FTask.guard("BookSpy/openBook", + () -> openBook(viewer, snapshot, wholeBook))); + } + }, + ClickCallback.Options.builder() + .uses(ClickCallback.UNLIMITED_USES) + .lifetime(VIEW_LIFETIME) + .build())) + .hoverEvent(HoverEvent.showText(Component.text(hover, NamedTextColor.GRAY))); + } + + private void openBook(final Player viewer, final BookSnapshot snapshot, final boolean wholeBook) + { + if (!viewer.isOnline()) return; + + final List<Component> source = snapshot.newPages(); + // openBook cannot be told which page to open at, so the copy handed to the viewer + // starts at the edited page instead. + final int start = wholeBook ? 0 : Math.min(snapshot.firstChangedPage(), Math.max(source.size() - 1, 0)); + final List<Component> pages = source.isEmpty() + ? List.of(Component.empty()) : List.copyOf(source.subList(start, source.size())); + + final ItemStack book = new ItemStack(Material.WRITTEN_BOOK); + book.editMeta(BookMeta.class, meta -> + { + meta.title(snapshot.title()); + meta.author(Component.text(snapshot.author())); + meta.pages(pages); + }); + + viewer.closeInventory(); + viewer.openBook(book); + } +} \ No newline at end of file diff --git a/src/main/java/me/totalfreedom/totalfreedommod/CommandSpy.java b/src/main/java/me/totalfreedom/totalfreedommod/CommandSpy.java index 7f6d6f893..42283b195 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/CommandSpy.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/CommandSpy.java @@ -1,6 +1,6 @@ package me.totalfreedom.totalfreedommod; -import me.totalfreedom.totalfreedommod.player.CommandSpyMode; +import me.totalfreedom.totalfreedommod.player.SpyMode; import me.totalfreedom.totalfreedommod.player.FPlayer; import me.totalfreedom.totalfreedommod.rank.Displayable; import me.totalfreedom.totalfreedommod.util.AdventureUtil; @@ -49,13 +49,7 @@ public void onPlayerCommandPreprocess(PlayerCommandPreprocessEvent event) continue; } - final CommandSpyMode mode = playerData.getCommandSpyMode(); - if (mode == CommandSpyMode.ADMINS && !senderIsAdmin) - { - continue; - } - - if (mode == CommandSpyMode.OPS && senderIsAdmin) + if (!playerData.getCommandSpyMode().shows(senderIsAdmin)) { continue; } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/PotionSpy.java b/src/main/java/me/totalfreedom/totalfreedommod/PotionSpy.java index eb46f8493..b17f5b171 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/PotionSpy.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/PotionSpy.java @@ -61,6 +61,8 @@ public void onPotionThrown(ProjectileLaunchEvent e) if (source == null || !(source instanceof final Player thrower)) return; + final boolean throwerIsAdmin = plugin.al.isAdmin(thrower); + // Grab old data about the offender, if it exists final Pair<Integer, Long> offenderData = offenders.getOrDefault(thrower, Pair.of(0, System.currentTimeMillis())); final int amount = offenderData.getLeft() + 1; @@ -77,7 +79,7 @@ public void onPotionThrown(ProjectileLaunchEvent e) final PlayerData data = plugin.pl.getData(player); if (data == null) continue; - if (!data.isPotionSpy()) + if (!data.getPotionSpyMode().shows(throwerIsAdmin)) continue; // Issue the message along 3^n, so less messages occur over time diff --git a/src/main/java/me/totalfreedom/totalfreedommod/SignSpy.java b/src/main/java/me/totalfreedom/totalfreedommod/SignSpy.java index 663dc542a..456eb5c61 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/SignSpy.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/SignSpy.java @@ -1,11 +1,7 @@ package me.totalfreedom.totalfreedommod; import java.time.Duration; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.UUID; +import java.util.*; import io.papermc.paper.math.Position; import me.totalfreedom.totalfreedommod.player.PlayerData; @@ -18,10 +14,7 @@ import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.event.HoverEvent; import net.kyori.adventure.text.format.NamedTextColor; -import org.bukkit.Bukkit; -import org.bukkit.DyeColor; -import org.bukkit.Location; -import org.bukkit.World; +import org.bukkit.*; import org.bukkit.block.Sign; import org.bukkit.block.TileState; import org.bukkit.block.data.BlockData; @@ -141,8 +134,10 @@ public void onSignChange(SignChangeEvent event) final String context = bothSides ? (event.getSide() == Side.FRONT ? " (front)" : " (back)") : ""; + final boolean editorIsAdmin = plugin.al.isAdmin(editor); + Component message = Component.empty(); - if (plugin.al.isAdmin(editor)) + if (editorIsAdmin) { final Displayable display = plugin.rm.getDisplay(editor); String prefix = AdventureUtil.componentToPlainText(display.getColoredTag()).trim(); @@ -170,7 +165,7 @@ public void onSignChange(SignChangeEvent event) final String text = change.text(); final int room = Math.min(budget, MAX_LINE_CHARS); shown.add(text.length() > room - ? new LineChange(change.added(), text.substring(0, room)) : change); + ? new LineChange(change.added(), String.format("%s[...]", text.substring(0, room))) : change); budget -= Math.min(text.length(), room); } @@ -209,9 +204,10 @@ else if (bothSides) else { final SignSnapshot snapshot = snapshot(event, oldState); - message = message.append(Component.text(" ", NamedTextColor.GRAY)) - .append(viewButton("[View]", "Click to view the full sign", - snapshot, snapshot.side(), false)); + message = message.append(Component.text(" [", NamedTextColor.GRAY)) + .append(viewButton("View", "Click to view the full sign", + snapshot, snapshot.side(), false)) + .append(Component.text("]", NamedTextColor.GRAY)); } for (final Player admin : plugin.al.getOnlineAdmins()) @@ -221,7 +217,7 @@ else if (bothSides) continue; } final PlayerData data = plugin.pl.getData(admin); - if (data == null || !data.isSignSpy()) + if (data == null || !data.getSignSpyMode().shows(editorIsAdmin)) { continue; } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/TotalFreedomMod.java b/src/main/java/me/totalfreedom/totalfreedommod/TotalFreedomMod.java index d57186f73..5454cedff 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/TotalFreedomMod.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/TotalFreedomMod.java @@ -105,6 +105,7 @@ public class TotalFreedomMod extends JavaPlugin public CommandSpy cs; // CommandSpy - Logs and monitors command usage public PotionSpy ps; // PotionSpy - Logs and monitors potion usage public SignSpy ss; // SignSpy - Logs and monitors sign edits + public BookSpy bs; // BookSpy - Logs and monitors book edits public Cager ca; // Cager - Creates cages around players public Freezer fm; // Freezer - Freezes players in place public Orbiter or; // Orbiter - Makes players orbit around a point @@ -241,6 +242,7 @@ public void onEnable() cs = services.registerService(CommandSpy.class); ps = services.registerService(PotionSpy.class); ss = services.registerService(SignSpy.class); + bs = services.registerService(BookSpy.class); ca = services.registerService(Cager.class); fm = services.registerService(Freezer.class); or = services.registerService(Orbiter.class); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_bookspy.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_bookspy.java new file mode 100644 index 000000000..e588144b7 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_bookspy.java @@ -0,0 +1,51 @@ +package me.totalfreedom.totalfreedommod.cmd; + +import java.util.List; +import java.util.Locale; +import java.util.stream.Stream; + +import org.bukkit.entity.Player; + +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; +import me.totalfreedom.totalfreedommod.player.PlayerData; +import me.totalfreedom.totalfreedommod.player.SpyMode; +import me.totalfreedom.totalfreedommod.rank.Rank; + +@Command(name = "bookspy", description = "Spy on book edits", usage = "/bookspy [ops | admins | all | off]", aliases = {"bspy"}) +@Permission(permission = "tfm.admin.bookspy", level = Rank.SUPER_ADMIN, source = SourceType.ONLY_IN_GAME) +public class Command_bookspy extends FCommand +{ + @Callback + public void toggle(final Player player) + { + final PlayerData data = plugin().pl.getData(player); + bookSpy(player, data.isBookSpy() ? SpyMode.OFF : SpyMode.OPS); + } + + @Callback + public void bookSpy(final Player player, final SpyMode mode) + { + final PlayerData data = plugin().pl.getData(player); + + data.setBookSpyMode(mode); + plugin().pl.saveAsync(); + + switch (mode) + { + case OFF -> msg(player, "<gray>BookSpy disabled."); + case OPS -> msg(player, "<gray>BookSpy set to <green>OPS</green> mode. You will only see non-admins' book edits."); + case ADMINS -> msg(player, "<gray>BookSpy set to <green>ADMINS</green> mode. You will only see admins' book edits."); + case ALL -> msg(player, "<gray>BookSpy set to <green>ALL</green> mode. You will see both non-admins' and admins' book edits."); + } + } + + @Completer(value = "", position = 0) + public List<String> completeMode(final Player player, final String partial) + { + final String lower = partial.toLowerCase(Locale.ROOT); + + return Stream.of("ops", "admins", "all", "off") + .filter(mode -> mode.startsWith(lower)) + .toList(); + } +} \ No newline at end of file diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cmdspy.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cmdspy.java index d08f55bb2..53fc8ac87 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cmdspy.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cmdspy.java @@ -7,10 +7,10 @@ import org.bukkit.entity.Player; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.player.CommandSpyMode; +import me.totalfreedom.totalfreedommod.player.SpyMode; import me.totalfreedom.totalfreedommod.rank.Rank; -@Command(name = "cmdspy", description = "Spy on commands", usage = "/<command> [admins | ops | all]", aliases = {"commandspy", "cspy"}) +@Command(name = "cmdspy", description = "Spy on commands", usage = "/cmdspy [ops | admins | all | off]", aliases = {"commandspy", "cspy"}) @Permission(permission = "tfm.admin.cmdspy", source = SourceType.ONLY_IN_GAME, level = Rank.SUPER_ADMIN) public class Command_cmdspy extends FCommand { @@ -19,11 +19,11 @@ public class Command_cmdspy extends FCommand public void toggle(final Player player) { final var pd = plugin().pl.getPlayer(player); - commandSpy(player, pd.cmdspyEnabled() ? CommandSpyMode.OFF : CommandSpyMode.ALL); + commandSpy(player, pd.cmdspyEnabled() ? SpyMode.OFF : SpyMode.ALL); } @Callback - public void commandSpy(final Player player, final CommandSpyMode mode) // should auto resolve enums + public void commandSpy(final Player player, final SpyMode mode) // should auto resolve enums { final var fp = plugin().pl.getPlayer(player); final var pd = plugin().pl.getData(player); @@ -34,7 +34,7 @@ public void commandSpy(final Player player, final CommandSpyMode mode) // should switch (mode) { - case OFF -> msg(player, "<red>CommandSpy disabled."); + case OFF -> msg(player, "<gray>CommandSpy disabled."); case ADMINS -> msg(player, "<gray>CommandSpy set to <green>ADMINS</green> mode. You will only see admins' commands."); case OPS -> msg(player, "<gray>CommandSpy set to <green>OPS</green> mode. You will only see OPs' commands."); case ALL -> msg(player, "<gray>CommandSpy set to <green>ALL</green> mode. You will see both OPs' and admins' commands."); @@ -46,7 +46,7 @@ public List<String> completeMode(final Player player, final String partial) { final String lower = partial.toLowerCase(Locale.ROOT); - return Stream.of("admins", "ops", "all", "off") + return Stream.of("ops", "admins", "all", "off") .filter(mode -> mode.startsWith(lower)) .toList(); } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_potionspy.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_potionspy.java index a01e4b862..db57e1f02 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_potionspy.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_potionspy.java @@ -1,25 +1,51 @@ package me.totalfreedom.totalfreedommod.cmd; +import java.util.List; +import java.util.Locale; +import java.util.stream.Stream; + import org.bukkit.entity.Player; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.player.PlayerData; +import me.totalfreedom.totalfreedommod.player.SpyMode; import me.totalfreedom.totalfreedommod.rank.Rank; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -@Command(name = "potionspy", description = "Spy on potion usage", usage = "/potionspy", aliases = {"potspy"}) +@Command(name = "potionspy", description = "Spy on potion usage", usage = "/potionspy [ops | admins | all | off]", aliases = {"potspy"}) @Permission(permission = "tfm.admin.potspy", level = Rank.SUPER_ADMIN, source = SourceType.ONLY_IN_GAME) public class Command_potionspy extends FCommand { @Callback - public void toggle(Player player) + public void toggle(final Player player) + { + final PlayerData data = plugin().pl.getData(player); + potionSpy(player, data.isPotionSpy() ? SpyMode.OFF : SpyMode.ALL); + } + + @Callback + public void potionSpy(final Player player, final SpyMode mode) { final PlayerData data = plugin().pl.getData(player); - data.setPotionSpy(!data.isPotionSpy()); - msg( - player, - "<gray>PotionSpy <status:enabled:disabled>.", - Formatter.booleanChoice("status", data.isPotionSpy()) - ); + + data.setPotionSpyMode(mode); + plugin().pl.saveAsync(); + + switch (mode) + { + case OFF -> msg(player, "<gray>PotionSpy disabled."); + case OPS -> msg(player, "<gray>PotionSpy set to <green>OPS</green> mode. You will only see non-admins' potions."); + case ADMINS -> msg(player, "<gray>PotionSpy set to <green>ADMINS</green> mode. You will only see admins' potions."); + case ALL -> msg(player, "<gray>PotionSpy set to <green>ALL</green> mode. You will see both non-admins' and admins' potions."); + } + } + + @Completer(value = "", position = 0) + public List<String> completeMode(final Player player, final String partial) + { + final String lower = partial.toLowerCase(Locale.ROOT); + + return Stream.of("ops", "admins", "all", "off") + .filter(mode -> mode.startsWith(lower)) + .toList(); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_signspy.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_signspy.java index e206548f9..0526d7979 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_signspy.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_signspy.java @@ -1,13 +1,17 @@ package me.totalfreedom.totalfreedommod.cmd; +import java.util.List; +import java.util.Locale; +import java.util.stream.Stream; + import org.bukkit.entity.Player; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.player.PlayerData; +import me.totalfreedom.totalfreedommod.player.SpyMode; import me.totalfreedom.totalfreedommod.rank.Rank; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -@Command(name = "signspy", description = "Spy on sign edits", usage = "/signspy", aliases = {"sspy"}) +@Command(name = "signspy", description = "Spy on sign edits", usage = "/signspy [ops | admins | all | off]", aliases = {"sspy"}) @Permission(permission = "tfm.admin.signspy", level = Rank.SUPER_ADMIN, source = SourceType.ONLY_IN_GAME) public class Command_signspy extends FCommand { @@ -15,12 +19,33 @@ public class Command_signspy extends FCommand public void toggle(final Player player) { final PlayerData data = plugin().pl.getData(player); - data.setSignSpy(!data.isSignSpy()); + signSpy(player, data.isSignSpy() ? SpyMode.OFF : SpyMode.OPS); + } + + @Callback + public void signSpy(final Player player, final SpyMode mode) + { + final PlayerData data = plugin().pl.getData(player); + + data.setSignSpyMode(mode); plugin().pl.saveAsync(); - msg( - player, - "<gray>SignSpy <status:enabled:disabled>.", - Formatter.booleanChoice("status", data.isSignSpy()) - ); + + switch (mode) + { + case OFF -> msg(player, "<gray>SignSpy disabled."); + case OPS -> msg(player, "<gray>SignSpy set to <green>OPS</green> mode. You will only see non-admins' sign edits."); + case ADMINS -> msg(player, "<gray>SignSpy set to <green>ADMINS</green> mode. You will only see admins' sign edits."); + case ALL -> msg(player, "<gray>SignSpy set to <green>ALL</green> mode. You will see both non-admins' and admins' sign edits."); + } + } + + @Completer(value = "", position = 0) + public List<String> completeMode(final Player player, final String partial) + { + final String lower = partial.toLowerCase(Locale.ROOT); + + return Stream.of("ops", "admins", "all", "off") + .filter(mode -> mode.startsWith(lower)) + .toList(); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/player/CommandSpyMode.java b/src/main/java/me/totalfreedom/totalfreedommod/player/CommandSpyMode.java deleted file mode 100644 index 2bbb35bf1..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/player/CommandSpyMode.java +++ /dev/null @@ -1,34 +0,0 @@ -package me.totalfreedom.totalfreedommod.player; - -import java.util.Locale; - -public enum CommandSpyMode -{ - - OFF, - ADMINS, - OPS, - ALL; - - public static CommandSpyMode fromString(String value) - { - if (value == null) - { - return OFF; - } - - try - { - return valueOf(value.toUpperCase(Locale.ROOT)); - } - catch (IllegalArgumentException ex) - { - return OFF; - } - } - - public String getName() - { - return name().toLowerCase(Locale.ROOT); - } -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/player/FPlayer.java b/src/main/java/me/totalfreedom/totalfreedommod/player/FPlayer.java index 0cd0afbaa..0fd8911f7 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/player/FPlayer.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/player/FPlayer.java @@ -84,7 +84,7 @@ public static void refreshConfig() @Getter @Setter private boolean superadminIdVerified = false; - private CommandSpyMode commandSpyMode = CommandSpyMode.OFF; + private SpyMode commandSpyMode = SpyMode.OFF; private boolean joinLeaveMessagesEnabled = true; private Component tag = null; private String tagInternal = null; @@ -436,22 +436,22 @@ public void setCommandsBlocked(boolean commandsBlocked) public void setCommandSpy(boolean enabled) { - this.commandSpyMode = enabled ? CommandSpyMode.ALL : CommandSpyMode.OFF; + this.commandSpyMode = enabled ? SpyMode.ALL : SpyMode.OFF; } public boolean cmdspyEnabled() { - return commandSpyMode != CommandSpyMode.OFF; + return commandSpyMode != SpyMode.OFF; } - public CommandSpyMode getCommandSpyMode() + public SpyMode getCommandSpyMode() { return commandSpyMode; } - public void setCommandSpyMode(CommandSpyMode commandSpyMode) + public void setCommandSpyMode(SpyMode commandSpyMode) { - this.commandSpyMode = commandSpyMode == null ? CommandSpyMode.OFF : commandSpyMode; + this.commandSpyMode = commandSpyMode == null ? SpyMode.OFF : commandSpyMode; } public boolean joinLeaveMessagesEnabled() diff --git a/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerData.java b/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerData.java index e3b6533c1..828880614 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerData.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerData.java @@ -24,9 +24,10 @@ public class PlayerData implements ConfigLoadable, Validatable private String username; private long firstJoinUnix; private long lastJoinUnix; - private boolean potionSpy; - private boolean signSpy; - private CommandSpyMode commandSpyMode = CommandSpyMode.OFF; + private SpyMode potionSpyMode = SpyMode.OFF; + private SpyMode signSpyMode = SpyMode.OFF; + private SpyMode bookSpyMode = SpyMode.OFF; + private SpyMode commandSpyMode = SpyMode.OFF; private boolean muted; private boolean frozen; private boolean commandsBlocked; @@ -78,12 +79,17 @@ public void setLastJoinUnix(long lastJoinUnix) public boolean isPotionSpy() { - return potionSpy; + return getPotionSpyMode() != SpyMode.OFF; } - public void setPotionSpy(boolean potionSpy) + public SpyMode getPotionSpyMode() { - this.potionSpy = potionSpy; + return potionSpyMode == null ? SpyMode.OFF : potionSpyMode; + } + + public void setPotionSpyMode(SpyMode potionSpyMode) + { + this.potionSpyMode = potionSpyMode == null ? SpyMode.OFF : potionSpyMode; } public boolean isMuted() @@ -145,10 +151,11 @@ public void loadFrom(ConfigurationSection cs) trimIps(); this.firstJoinUnix = cs.getLong("first_join", 0); this.lastJoinUnix = cs.getLong("last_join", 0); - this.potionSpy = cs.getBoolean("potion_spy", false); - this.signSpy = cs.getBoolean("sign_spy", false); + this.potionSpyMode = SpyMode.fromStorage(cs.getString("potion_spy_mode", cs.getBoolean("potion_spy", false) ? "all" : "off")); + this.signSpyMode = SpyMode.fromStorage(cs.getString("sign_spy_mode", cs.getBoolean("sign_spy", false) ? "all" : "off")); + this.bookSpyMode = SpyMode.fromStorage(cs.getString("book_spy_mode", "off")); final boolean legacyCommandSpy = cs.getBoolean("command_spy", false); - this.commandSpyMode = CommandSpyMode.fromString(cs.getString("command_spy_mode", legacyCommandSpy ? "ops" : "off")); + this.commandSpyMode = SpyMode.fromStorage(cs.getString("command_spy_mode", legacyCommandSpy ? "ops" : "off")); this.muted = cs.getBoolean("muted", false); this.frozen = cs.getBoolean("frozen", false); this.commandsBlocked = cs.getBoolean("commands_blocked", false); @@ -169,32 +176,52 @@ public void loadFrom(ConfigurationSection cs) public boolean isCommandSpy() { - return commandSpyMode != CommandSpyMode.OFF; + return getCommandSpyMode() != SpyMode.OFF; } public void setCommandSpy(boolean commandSpy) { - this.commandSpyMode = commandSpy ? CommandSpyMode.ALL : CommandSpyMode.OFF; + this.commandSpyMode = commandSpy ? SpyMode.ALL : SpyMode.OFF; } - public CommandSpyMode getCommandSpyMode() + public SpyMode getCommandSpyMode() { - return commandSpyMode; + return commandSpyMode == null ? SpyMode.OFF : commandSpyMode; } - public void setCommandSpyMode(CommandSpyMode commandSpyMode) + public void setCommandSpyMode(SpyMode commandSpyMode) { - this.commandSpyMode = commandSpyMode == null ? CommandSpyMode.OFF : commandSpyMode; + this.commandSpyMode = commandSpyMode == null ? SpyMode.OFF : commandSpyMode; } public boolean isSignSpy() { - return signSpy; + return getSignSpyMode() != SpyMode.OFF; + } + + public SpyMode getSignSpyMode() + { + return signSpyMode == null ? SpyMode.OFF : signSpyMode; + } + + public void setSignSpyMode(SpyMode signSpyMode) + { + this.signSpyMode = signSpyMode == null ? SpyMode.OFF : signSpyMode; + } + + public boolean isBookSpy() + { + return getBookSpyMode() != SpyMode.OFF; + } + + public SpyMode getBookSpyMode() + { + return bookSpyMode == null ? SpyMode.OFF : bookSpyMode; } - public void setSignSpy(final boolean signSpy) + public void setBookSpyMode(SpyMode bookSpyMode) { - this.signSpy = signSpy; + this.bookSpyMode = bookSpyMode == null ? SpyMode.OFF : bookSpyMode; } public boolean isJoinLeaveMessagesEnabled() diff --git a/src/main/java/me/totalfreedom/totalfreedommod/player/SpyMode.java b/src/main/java/me/totalfreedom/totalfreedommod/player/SpyMode.java new file mode 100644 index 000000000..3ae08addc --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/player/SpyMode.java @@ -0,0 +1,44 @@ +package me.totalfreedom.totalfreedommod.player; + +import java.util.Locale; + +/** + * Filter shared by the spy services deciding whose activity a spy is shown. + */ +public enum SpyMode +{ + OFF, + OPS, + ADMINS, + ALL; + + public static SpyMode fromStorage(String value) + { + if (value == null) return OFF; + + try + { + return valueOf(value.toUpperCase(Locale.ROOT)); + } + catch (IllegalArgumentException ex) + { + return OFF; + } + } + + public boolean shows(boolean senderIsAdmin) + { + return switch (this) + { + case OFF -> false; + case OPS -> !senderIsAdmin; + case ADMINS -> senderIsAdmin; + case ALL -> true; + }; + } + + public String getName() + { + return name().toLowerCase(Locale.ROOT); + } +} \ No newline at end of file diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java index 0fe50038e..e1eb1d6e4 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java @@ -1,6 +1,6 @@ package me.totalfreedom.totalfreedommod.sql.adapter.generic; -import me.totalfreedom.totalfreedommod.player.CommandSpyMode; +import me.totalfreedom.totalfreedommod.player.SpyMode; import me.totalfreedom.totalfreedommod.player.PlayerData; import me.totalfreedom.totalfreedommod.sql.StatementHandler; import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; @@ -28,8 +28,10 @@ public class GenericPlayerRepository implements PlayerRepository private final String colUsername; private final String colFirstJoin; private final String colLastJoin; - private final String colPotionSpy; + private final String colPotionSpyMode; private final String colCommandSpyMode; + private final String colSignSpyMode; + private final String colBookSpyMode; private final String colMuted; private final String colFrozen; private final String colCommandsBlocked; @@ -52,8 +54,10 @@ public GenericPlayerRepository(StatementHandler statementHandler, DatabaseAdapte this.colUsername = adapter.quoteIdentifier("username"); this.colFirstJoin = adapter.quoteIdentifier("first_join_unix"); this.colLastJoin = adapter.quoteIdentifier("last_join_unix"); - this.colPotionSpy = adapter.quoteIdentifier("potion_spy"); + this.colPotionSpyMode = adapter.quoteIdentifier("potion_spy_mode"); this.colCommandSpyMode = adapter.quoteIdentifier("command_spy_mode"); + this.colSignSpyMode = adapter.quoteIdentifier("sign_spy_mode"); + this.colBookSpyMode = adapter.quoteIdentifier("book_spy_mode"); this.colMuted = adapter.quoteIdentifier("muted"); this.colFrozen = adapter.quoteIdentifier("frozen"); this.colCommandsBlocked = adapter.quoteIdentifier("commands_blocked"); @@ -64,24 +68,26 @@ public GenericPlayerRepository(StatementHandler statementHandler, DatabaseAdapte this.colPlayerUsername = adapter.quoteIdentifier("username"); this.colIp = adapter.quoteIdentifier("ip"); this.colUpdatedAt = adapter.quoteIdentifier("updated_at"); - this.selectColumns = String.format("%s, %s, %s, %s, %s, %s, %s, %s, %s, %s", - colUsername, colFirstJoin, colLastJoin, colPotionSpy, colCommandSpyMode, colMuted, colFrozen, - colCommandsBlocked, colStrikes, colSavedTag) + this.selectColumns = String.format("%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s", + colUsername, colFirstJoin, colLastJoin, colPotionSpyMode, colCommandSpyMode, colSignSpyMode, + colBookSpyMode, colMuted, colFrozen, colCommandsBlocked, colStrikes, colSavedTag) + ", " + colNickname; } @Override public void insert(PlayerData data) throws SQLException { - String sql = String.format("INSERT INTO %s (%s, %s) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, %s)", + String sql = String.format("INSERT INTO %s (%s, %s) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, %s)", tblPlayers, selectColumns, colUpdatedAt, adapter.currentTimestamp()); statementHandler.executeUpdate(sql, data.getUsername(), data.getFirstJoinUnix(), data.getLastJoinUnix(), - data.isPotionSpy(), + data.getPotionSpyMode().getName(), data.getCommandSpyMode().getName(), + data.getSignSpyMode().getName(), + data.getBookSpyMode().getName(), data.isMuted(), data.isFrozen(), data.isCommandsBlocked(), @@ -191,15 +197,18 @@ public List<String> getIps(String username) throws SQLException @Override public boolean update(PlayerData data) throws SQLException { - String sql = String.format("UPDATE %s SET %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = %s WHERE %s = ?", - tblPlayers, colFirstJoin, colLastJoin, colPotionSpy, colCommandSpyMode, colMuted, colFrozen, - colCommandsBlocked, colStrikes, colSavedTag, colUpdatedAt, adapter.currentTimestamp(), colUsername); + String sql = String.format("UPDATE %s SET %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = %s WHERE %s = ?", + tblPlayers, colFirstJoin, colLastJoin, colPotionSpyMode, colCommandSpyMode, colSignSpyMode, + colBookSpyMode, colMuted, colFrozen, colCommandsBlocked, colStrikes, colSavedTag, + colUpdatedAt, adapter.currentTimestamp(), colUsername); int rows = statementHandler.executeUpdate(sql, data.getFirstJoinUnix(), data.getLastJoinUnix(), - data.isPotionSpy(), + data.getPotionSpyMode().getName(), data.getCommandSpyMode().getName(), + data.getSignSpyMode().getName(), + data.getBookSpyMode().getName(), data.isMuted(), data.isFrozen(), data.isCommandsBlocked(), @@ -294,8 +303,10 @@ private PlayerData loadPlayerFromRow(ResultSet rs) throws SQLException PlayerData data = new PlayerData(rs.getString("username")); data.setFirstJoinUnix(rs.getLong("first_join_unix")); data.setLastJoinUnix(rs.getLong("last_join_unix")); - data.setPotionSpy(rs.getBoolean("potion_spy")); - data.setCommandSpyMode(CommandSpyMode.fromString(rs.getString("command_spy_mode"))); + data.setPotionSpyMode(SpyMode.fromStorage(rs.getString("potion_spy_mode"))); + data.setCommandSpyMode(SpyMode.fromStorage(rs.getString("command_spy_mode"))); + data.setSignSpyMode(SpyMode.fromStorage(rs.getString("sign_spy_mode"))); + data.setBookSpyMode(SpyMode.fromStorage(rs.getString("book_spy_mode"))); data.setMuted(rs.getBoolean("muted")); data.setFrozen(rs.getBoolean("frozen")); data.setCommandsBlocked(rs.getBoolean("commands_blocked")); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdapter.java index 598406047..6b59edcea 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdapter.java @@ -392,8 +392,10 @@ private void createPlayersTable() throws SQLException `username` VARCHAR(16) PRIMARY KEY, `first_join_unix` BIGINT NOT NULL DEFAULT 0, `last_join_unix` BIGINT NOT NULL DEFAULT 0, - `potion_spy` TINYINT(1) NOT NULL DEFAULT 0, + `potion_spy_mode` VARCHAR(16) NOT NULL DEFAULT 'off', `command_spy_mode` VARCHAR(16) NOT NULL DEFAULT 'off', + `sign_spy_mode` VARCHAR(16) NOT NULL DEFAULT 'off', + `book_spy_mode` VARCHAR(16) NOT NULL DEFAULT 'off', `muted` TINYINT(1) NOT NULL DEFAULT 0, `frozen` TINYINT(1) NOT NULL DEFAULT 0, `commands_blocked` TINYINT(1) NOT NULL DEFAULT 0, @@ -405,6 +407,9 @@ private void createPlayersTable() throws SQLException """; statementHandler.executeUpdate(sql); addColumnIfMissing("players", "updated_at", "DATETIME NOT NULL DEFAULT NOW()"); + addColumnIfMissing("players", "potion_spy_mode", "VARCHAR(16) NOT NULL DEFAULT 'off'"); + addColumnIfMissing("players", "sign_spy_mode", "VARCHAR(16) NOT NULL DEFAULT 'off'"); + addColumnIfMissing("players", "book_spy_mode", "VARCHAR(16) NOT NULL DEFAULT 'off'"); } private void createPlayerIpsTable() throws SQLException diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java index 9e3e15f79..03ae0fa99 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java @@ -392,8 +392,10 @@ private void createPlayersTable() throws SQLException "username" VARCHAR(16) PRIMARY KEY, "first_join_unix" BIGINT NOT NULL DEFAULT 0, "last_join_unix" BIGINT NOT NULL DEFAULT 0, - "potion_spy" BOOLEAN NOT NULL DEFAULT FALSE, + "potion_spy_mode" VARCHAR(16) NOT NULL DEFAULT 'off', "command_spy_mode" VARCHAR(16) NOT NULL DEFAULT 'off', + "sign_spy_mode" VARCHAR(16) NOT NULL DEFAULT 'off', + "book_spy_mode" VARCHAR(16) NOT NULL DEFAULT 'off', "muted" BOOLEAN NOT NULL DEFAULT FALSE, "frozen" BOOLEAN NOT NULL DEFAULT FALSE, "commands_blocked" BOOLEAN NOT NULL DEFAULT FALSE, @@ -405,6 +407,9 @@ private void createPlayersTable() throws SQLException """; statementHandler.executeUpdate(sql); statementHandler.executeUpdate("ALTER TABLE \"players\" ADD COLUMN IF NOT EXISTS \"updated_at\" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP"); + statementHandler.executeUpdate("ALTER TABLE \"players\" ADD COLUMN IF NOT EXISTS \"potion_spy_mode\" VARCHAR(16) NOT NULL DEFAULT 'off'"); + statementHandler.executeUpdate("ALTER TABLE \"players\" ADD COLUMN IF NOT EXISTS \"sign_spy_mode\" VARCHAR(16) NOT NULL DEFAULT 'off'"); + statementHandler.executeUpdate("ALTER TABLE \"players\" ADD COLUMN IF NOT EXISTS \"book_spy_mode\" VARCHAR(16) NOT NULL DEFAULT 'off'"); } private void createPlayerIpsTable() throws SQLException diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java index 74b5fc2e1..30f4b3048 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java @@ -405,8 +405,10 @@ CREATE TABLE IF NOT EXISTS players ( username TEXT PRIMARY KEY, first_join_unix INTEGER NOT NULL DEFAULT 0, last_join_unix INTEGER NOT NULL DEFAULT 0, - potion_spy INTEGER NOT NULL DEFAULT 0, + potion_spy_mode TEXT NOT NULL DEFAULT 'off', command_spy_mode TEXT NOT NULL DEFAULT 'off', + sign_spy_mode TEXT NOT NULL DEFAULT 'off', + book_spy_mode TEXT NOT NULL DEFAULT 'off', muted INTEGER NOT NULL DEFAULT 0, frozen INTEGER NOT NULL DEFAULT 0, commands_blocked INTEGER NOT NULL DEFAULT 0, @@ -418,6 +420,9 @@ CREATE TABLE IF NOT EXISTS players ( """; statementHandler.executeUpdate(sql); addTimestampColumnIfMissing("players", "updated_at"); + addColumnIfMissing("players", "potion_spy_mode", "TEXT NOT NULL DEFAULT 'off'"); + addColumnIfMissing("players", "sign_spy_mode", "TEXT NOT NULL DEFAULT 'off'"); + addColumnIfMissing("players", "book_spy_mode", "TEXT NOT NULL DEFAULT 'off'"); } private void createPlayerIpsTable() throws SQLException diff --git a/src/main/resources/ranks.json b/src/main/resources/ranks.json index 068f552ad..c972f0208 100644 --- a/src/main/resources/ranks.json +++ b/src/main/resources/ranks.json @@ -75,6 +75,7 @@ "tfm.admin.banlist", "tfm.admin.blockcmd", "tfm.admin.blockredstone", + "tfm.admin.bookspy", "tfm.admin.cage", "tfm.admin.cmdspy", "tfm.admin.consolesay", @@ -96,6 +97,7 @@ "tfm.admin.purgeall", "tfm.admin.ro", "tfm.admin.say", + "tfm.admin.signspy", "tfm.admin.strike", "tfm.admin.undisguiseall", "tfm.admin.warn", From 3a53fda0cd0e5e749e0493264f314aad5944fff6 Mon Sep 17 00:00:00 2001 From: Paldiu <pawereus@gmail.com> Date: Tue, 4 Aug 2026 13:29:46 -0500 Subject: [PATCH 21/48] update wording --- .../java/me/totalfreedom/totalfreedommod/ConfigConverter.java | 4 +--- .../java/me/totalfreedom/totalfreedommod/admin/Admin.java | 4 ++-- .../java/me/totalfreedom/totalfreedommod/admin/AdminList.java | 4 ++-- .../me/totalfreedom/totalfreedommod/cmd/Command_saconfig.java | 2 +- .../totalfreedommod/httpd/module/Module_players.java | 4 +--- .../totalfreedommod/rank/ConsoleSenderRegistry.java | 4 ++-- .../me/totalfreedom/totalfreedommod/rank/RankManager.java | 4 ++-- .../me/totalfreedom/totalfreedommod/rank/RankRegistry.java | 2 +- .../java/me/totalfreedom/totalfreedommod/rank/RankRole.java | 2 +- .../me/totalfreedom/totalfreedommod/sql/PersistenceQueue.java | 2 +- .../sql/adapter/generic/GenericAdminRepository.java | 2 +- 11 files changed, 15 insertions(+), 19 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ConfigConverter.java b/src/main/java/me/totalfreedom/totalfreedommod/ConfigConverter.java index 6069f92d6..954d183b0 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ConfigConverter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ConfigConverter.java @@ -229,8 +229,6 @@ public void convertCosmeticRankHolders() final long migrated = plugin.al.getAllAdmins().values() .stream() .filter(admin -> admin.getRankId() != null) - // A rank id that no longer names a rank but does name a title is exactly one of - // these; anything else unresolvable is left alone for an operator to look at. .filter(admin -> plugin.rm.getCustomRank(admin.getRankId()) == null) .filter(admin -> plugin.tm.hasTitle(admin.getRankId())) .peek(admin -> grantTitleOffline(admin.getName(), admin.getRankId())) @@ -264,7 +262,7 @@ private void grantTitleOffline(final String username, final String titleId) /** * The rank a senior admin should hold, resolved as the least privileged rank that is granted * senior standing. Derived from {@code ranks.json} rather than named, so a renamed or - * operator-defined senior rank is still found. + * defined senior rank is still found. */ private String seniorRankId() { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/admin/Admin.java b/src/main/java/me/totalfreedom/totalfreedommod/admin/Admin.java index 403e48aa2..b933f16c8 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/admin/Admin.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/admin/Admin.java @@ -20,7 +20,7 @@ public class Admin implements ConfigLoadable, Validatable /** * The id of the rank this admin holds, resolved against {@code ranks.json}. Stored as an id - * rather than as a fixed tier so that operator-defined ranks are first-class: an admin may hold + * rather than as a fixed tier so that defined ranks are first-class: an admin may hold * any rank the registry knows, not just one of a handful the plugin ships with. */ private String rankId = null; @@ -81,7 +81,7 @@ public void loadFrom(ConfigurationSection cs) /** * Folds the two rank fields records used to carry into the single id used now. * <p> - * {@code custom_rank} was the operator-assigned rank and took precedence over {@code rank}, + * {@code custom_rank} was the assigned rank and took precedence over {@code rank}, * which held one of the fixed tiers, so it is preferred here too. A tier name is lowercased to * become an id, which is the convention the shipped {@code ranks.json} follows. */ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java b/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java index d527157ea..c287cfaf6 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java @@ -46,7 +46,7 @@ public class AdminList extends FreedomService /** * The node that marks a rank as senior. Senior standing is a capability granted by - * {@code ranks.json} rather than a fixed tier, so an operator-defined rank can hold it and a + * {@code ranks.json} rather than a fixed tier, so a defined rank can hold it and a * rename or re-tier of the shipped ranks does not strand this check. */ public static final String SENIOR_STATUS_NODE = "tfm.admin.senior.status"; @@ -230,7 +230,7 @@ public boolean isAdmin(CommandSender sender) * <p> * Asked as a capability rather than as a rank comparison, because no rank is named in code any * more: whichever ranks {@code ranks.json} grants {@link #SENIOR_STATUS_NODE} to are the senior - * ones, including any the operator defined themselves. + * ones, including any custom definitions. */ public boolean isSeniorAdmin(CommandSender sender) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_saconfig.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_saconfig.java index 873965bdd..3a88dada8 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_saconfig.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_saconfig.java @@ -277,7 +277,7 @@ private void getAdminList(CommandSender sender) } // Group by the rank each admin actually holds. Ranks come from the registry rather than a - // fixed set, so an operator-defined rank gets its own line instead of being folded into a + // fixed set, so a custom defined rank gets its own line instead of being folded into a // tier that happens to sit near it. final Map<CustomRank, List<Admin>> byRank = activeAdmins.stream() .filter(admin -> plugin().rm.getCustomRank(admin.getRankId()) != null) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_players.java b/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_players.java index 34238c01a..08a5b266c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_players.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_players.java @@ -42,9 +42,7 @@ public NanoHTTPD.Response getResponse() for (Admin admin : plugin.al.getActiveAdmins()) { final String username = admin.getName(); - - // Buckets are by capability rather than by a named rank, so an operator-defined rank - // lands in the right one instead of vanishing from the feed. + if (plugin.al.grantsSeniorStatus(admin)) { senioradmins.add(username); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/rank/ConsoleSenderRegistry.java b/src/main/java/me/totalfreedom/totalfreedommod/rank/ConsoleSenderRegistry.java index d79e6a58e..a030ef4c7 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/rank/ConsoleSenderRegistry.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/rank/ConsoleSenderRegistry.java @@ -110,7 +110,7 @@ private void loadEntry(final Object obj) /** * Raises every {@link #HOST_CHANNELS host channel} to {@link #HOST_CHANNEL_FLOOR}, whether it * was bound too low or left out of {@code host_senders:} entirely. A binding at or above the - * floor is left alone, so an operator can still hand a host channel a higher custom rank. + * floor is left alone, so any console user can still hand a host channel a higher custom rank. */ private void applyHostChannelFloor() { @@ -152,7 +152,7 @@ private CustomRank hostChannelFloor() /** * Whether {@code rankId} sits at or above the host-channel floor, compared on the registry's own - * level scale so operator-defined numbering is honoured. + * level scale so custom defined numbering is honoured. */ private boolean outranksFloor(final String rankId, final CustomRank floor) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java index 9a4ff774e..641dcaf40 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java @@ -224,7 +224,7 @@ private void loadFromJsonOrDefaults() * Ranks are defined entirely by that file; nothing in code knows a default tier any more, so a * first run copies the shipped definitions rather than synthesising them. If the resource is * somehow missing the registry is left empty, which denies every guarded command instead of - * inventing ranks that the operator never approved. + * inventing ranks that the team never approved. */ private void installBundledRanks() { @@ -1107,7 +1107,7 @@ public void run() * Display is not the same question as permission. A few identities are recognised here purely * so they read correctly in chat, and none of them grants anything: the impostor marker, the * hardcoded developer list, and the owners named in config. Each is honoured only when a rank - * of that name actually exists in the registry, so an operator who removes one simply gets the + * of that name actually exists in the registry, so a staff member who removes one simply gets the * sender's real rank instead. */ public Displayable getDisplay(CommandSender sender) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/rank/RankRegistry.java b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankRegistry.java index ff0e30ef8..690f8b73d 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/rank/RankRegistry.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankRegistry.java @@ -89,7 +89,7 @@ public Optional<CustomRank> byId(final String id) * The rank filling {@code role}, preferring one that declares it in {@code ranks.json} and * otherwise deriving it from the shape of the registry. * <p> - * Nothing in code names a rank, so an operator may rename, re-tier or delete any of them and + * Nothing in code names a rank, so a designated staff member may rename, re-tier or delete any of them and * the roles follow. When two ranks declare the same role the lower-level one wins, which keeps * the answer stable rather than depending on map order. */ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/rank/RankRole.java b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankRole.java index 6b6d68966..14274d511 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/rank/RankRole.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankRole.java @@ -9,7 +9,7 @@ * A few code paths cannot be expressed as a permission check because they have to answer "which * rank?" rather than "may they?": what an unidentified sender falls back to, what a newly added * admin starts as, which rank marks an impostor. Naming those ranks in code would break the moment - * an operator renamed or deleted one, so a rank instead declares the roles it fills: + * a custom renamed or deleted one, so a rank instead declares the roles it fills: * <pre> * "senior_admin": { ..., "roles": ["console_floor"] } * </pre> diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/PersistenceQueue.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/PersistenceQueue.java index 30e4ae40c..39263a990 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/PersistenceQueue.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/PersistenceQueue.java @@ -76,7 +76,7 @@ public void await(final long timeoutMs) } /** - * Drop the retained operator chain once its tail completes. The chain is strictly + * Drop the retained chain once its tail completes. The chain is strictly * sequential, so a completed tail means every write before it is done. */ private void collapse(final Mono<Void> completed) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericAdminRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericAdminRepository.java index 4f8ca49f3..72912bd0f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericAdminRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericAdminRepository.java @@ -451,7 +451,7 @@ public Mono<Void> deleteAll() /** * Picks the rank id out of the two columns admin rows may still carry. * <p> - * {@code custom_rank} held the operator-assigned rank and took precedence over {@code rank}, + * {@code custom_rank} held the custom-assigned rank and took precedence over {@code rank}, * which held a fixed tier name, so it is preferred here as well. Rows are rewritten with the id * in {@code rank} and {@code custom_rank} cleared, so this only matters until a row is next * saved. A tier name lowercases into an id, which is the convention {@code ranks.json} uses. From 6e3d1f65748c692f56468f88c47c30cd2a841021 Mon Sep 17 00:00:00 2001 From: Paldiu <pawereus@gmail.com> Date: Tue, 4 Aug 2026 13:30:43 -0500 Subject: [PATCH 22/48] Update Title.java --- .../totalfreedom/totalfreedommod/title/Title.java | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/title/Title.java b/src/main/java/me/totalfreedom/totalfreedommod/title/Title.java index 7eef70f74..9efc4b708 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/title/Title.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/title/Title.java @@ -174,10 +174,6 @@ public final void invalidateCache() cachedColoredLoginMessage = null; } - // ======================================================================== - // Displayable Implementation - // ======================================================================== - @Override public String getName() { @@ -243,10 +239,6 @@ public Component getColoredLoginMessage() return cachedColoredLoginMessage; } - // ======================================================================== - // Comparable Implementation - // ======================================================================== - /** * Orders by display weight, heaviest first, so the natural order is the order titles should be * preferred in when a player holds several. @@ -280,11 +272,7 @@ public String toString() { return String.format("Title{id=%s, name=%s, weight=%d}", id, name, weight); } - - // ======================================================================== - // Accessors - // ======================================================================== - + public String getId() { return id; From 81f3ad8b8c438111a03b29b781843620411e9fd8 Mon Sep 17 00:00:00 2001 From: shrimp <luke560@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:45:32 -0600 Subject: [PATCH 23/48] Screen book pages before relaying to admins --- .../totalfreedom/totalfreedommod/BookSpy.java | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/BookSpy.java b/src/main/java/me/totalfreedom/totalfreedommod/BookSpy.java index 2f9fefe1a..c32ac1116 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/BookSpy.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/BookSpy.java @@ -4,6 +4,7 @@ import java.util.ArrayList; import java.util.List; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.player.PlayerData; import me.totalfreedom.totalfreedommod.rank.Displayable; import me.totalfreedom.totalfreedommod.util.*; @@ -28,7 +29,9 @@ public class BookSpy extends FreedomService private static final int MAX_CHANGE_CHARS = 30; private static final int MIN_CHANGE_CHARS = 8; private static final int MAX_LISTED_PAGES = 5; + private static final int MAX_BOOK_PAGES = 100; private static final Component UNTITLED = Component.text("Untitled"); + private static final Component UNSAFE_PAGE = Component.text("[unsafe page withheld]"); private record BookSnapshot(Component title, String author, List<Component> newPages, int firstChangedPage) { @@ -59,8 +62,8 @@ public void onPlayerEditBook(PlayerEditBookEvent event) final Player editor = event.getPlayer(); final BookMeta oldMeta = event.getPreviousBookMeta(); final BookMeta newMeta = event.getNewBookMeta(); - final List<Component> oldPages = List.copyOf(oldMeta.pages()); - final List<Component> newPages = List.copyOf(newMeta.pages()); + final List<Component> oldPages = sanitize(oldMeta.pages()); + final List<Component> newPages = sanitize(newMeta.pages()); if (!event.isSigning() && oldPages.equals(newPages)) { @@ -73,7 +76,8 @@ public void onPlayerEditBook(PlayerEditBookEvent event) } final boolean editorIsAdmin = plugin.al.isAdmin(editor); - final Component title = newMeta.hasTitle() ? newMeta.title() : UNTITLED; + final Component rawTitle = newMeta.hasTitle() ? newMeta.title() : UNTITLED; + final Component title = isCursed(rawTitle) ? UNTITLED : rawTitle; final List<PageChange> changes = diff(oldPages, newPages); final BookSnapshot snapshot = new BookSnapshot(title, editor.getName(), newPages, changes.isEmpty() ? 0 : changes.get(0).page()); @@ -160,6 +164,24 @@ public void onPlayerEditBook(PlayerEditBookEvent event) } } + /** + * Drop the component graph of any page that fails inspection, and cap the page count. + * A spy reads this content back through the view buttons, so a book crafted to carry a + * malicious graph must not be relayed to their client verbatim. + */ + private static List<Component> sanitize(final List<Component> pages) + { + return pages.stream() + .limit(MAX_BOOK_PAGES) + .map(page -> isCursed(page) ? UNSAFE_PAGE : page) + .toList(); + } + + private static boolean isCursed(final Component component) + { + return ComponentScanner.isCursed(component, ConfigEntry.maxComponentNodes()); + } + private static boolean isBlank(final List<Component> pages) { return pages.stream() From 71bb8c98ebbab2305e914bc27ce560d815dac20e Mon Sep 17 00:00:00 2001 From: Paldiu <pawereus@gmail.com> Date: Wed, 5 Aug 2026 03:20:30 -0500 Subject: [PATCH 24/48] Convert JDA to Discord4J Based. See PR for full changelog --- build.gradle | 10 +- .../totalfreedommod/TFMLibraryLoader.java | 19 +- .../totalfreedommod/config/ConfigEntry.java | 3 + .../discord/AbstractDiscordChatRelay.java | 538 +++++++------- .../discord/DiscordAdminchatRelay.java | 2 +- .../discord/DiscordBridge.java | 655 ++++++++++-------- .../discord/DiscordChatRelay.java | 2 +- .../discord/DiscordCommands.java | 187 +++-- .../discord/DiscordConnection.java | 170 +++++ .../discord/DiscordConsoleRelay.java | 348 +++++++--- .../discord/DiscordLinkJsonSync.java | 38 +- .../discord/DiscordSession.java | 54 ++ .../util/CallbackLogAppender.java | 28 +- src/main/resources/config.yml | 11 + 14 files changed, 1281 insertions(+), 784 deletions(-) create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordConnection.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordSession.java diff --git a/build.gradle b/build.gradle index de2e7901e..532ced0b4 100644 --- a/build.gradle +++ b/build.gradle @@ -57,14 +57,14 @@ dependencies { // packets compileOnly 'com.github.retrooper:packetevents-spigot:2.12.1' - // discord stuff, TBR w/ Discord4J <3 - compileOnly 'net.dv8tion:JDA:5.6.1' - + // discord stuff + compileOnly 'com.discord4j:discord4j-core:3.3.2' + // ssh compileOnly 'org.apache.sshd:sshd-core:2.17.1' - + // sql - compileOnly 'io.projectreactor:reactor-core:3.7.6' + compileOnly 'io.projectreactor:reactor-core:3.8.3' compileOnly 'org.postgresql:postgresql:42.7.7' compileOnly 'org.xerial:sqlite-jdbc:3.49.1.0' compileOnly 'com.mysql:mysql-connector-j:9.3.0' diff --git a/src/main/java/me/totalfreedom/totalfreedommod/TFMLibraryLoader.java b/src/main/java/me/totalfreedom/totalfreedommod/TFMLibraryLoader.java index adb6b8575..f68bb257c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/TFMLibraryLoader.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/TFMLibraryLoader.java @@ -16,13 +16,13 @@ public class TFMLibraryLoader implements PluginLoader { private static final String[] LIBRARIES = { - "net.dv8tion:JDA:5.6.1", + "com.discord4j:discord4j-core:3.3.2", "org.apache.sshd:sshd-core:2.17.1", - "net.i2p.crypto:eddsa:0.3.0", + "net.i2p.crypto:eddsa:0.3.0", "org.postgresql:postgresql:42.7.7", "org.xerial:sqlite-jdbc:3.49.1.0", "com.mysql:mysql-connector-j:9.3.0", - "io.projectreactor:reactor-core:3.7.6", + "io.projectreactor:reactor-core:3.8.3", "com.zaxxer:HikariCP:6.3.0" }; @@ -30,15 +30,16 @@ public class TFMLibraryLoader implements PluginLoader public void classloader(@NotNull PluginClasspathBuilder classpathBuilder) { MavenLibraryResolver resolver = new MavenLibraryResolver(); + for (String coordinates : LIBRARIES) - { resolver.addDependency(new Dependency(new DefaultArtifact(coordinates), null)); - } + resolver.addRepository(new RemoteRepository.Builder( - "central", - "default", - MavenLibraryResolver.MAVEN_CENTRAL_DEFAULT_MIRROR - ).build()); + "central", + "default", + MavenLibraryResolver.MAVEN_CENTRAL_DEFAULT_MIRROR) + .build()); + classpathBuilder.addLibrary(resolver); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/config/ConfigEntry.java b/src/main/java/me/totalfreedom/totalfreedommod/config/ConfigEntry.java index b401921d2..e667a9314 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/config/ConfigEntry.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/config/ConfigEntry.java @@ -61,7 +61,10 @@ public enum ConfigEntry DISCORD_ADMINCHAT_FORMAT(String.class, "discord.adminchat_format"), DISCORD_ADMINCHAT_CHANNEL_FORMAT(String.class, "discord.adminchat_channel_format"), DISCORD_CONSOLE_FLUSH(Integer.class, "discord.console_flush"), + DISCORD_CONSOLE_QUEUE_LIMIT(Integer.class, "discord.console_queue_limit"), DISCORD_LINK_CODE_TTL(Integer.class, "discord.link_code_ttl"), + DISCORD_RECONNECT_INTERVAL_SECONDS(Integer.class, "discord.reconnect.interval_seconds"), + DISCORD_RECONNECT_MAX_ATTEMPTS(Integer.class, "discord.reconnect.max_attempts"), DISCORD_SERVER_STARTUP_MESSAGE(String.class, "discord.messages.server_startup"), DISCORD_SERVER_SHUTDOWN_MESSAGE(String.class, "discord.messages.server_shutdown"), DISCORD_PLAYER_JOIN_MESSAGE(String.class, "discord.messages.player_join"), diff --git a/src/main/java/me/totalfreedom/totalfreedommod/discord/AbstractDiscordChatRelay.java b/src/main/java/me/totalfreedom/totalfreedommod/discord/AbstractDiscordChatRelay.java index fa8f9c4eb..3ea87d83c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/discord/AbstractDiscordChatRelay.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/discord/AbstractDiscordChatRelay.java @@ -1,24 +1,29 @@ package me.totalfreedom.totalfreedommod.discord; -import java.awt.Color; -import java.util.Collections; +import java.time.Duration; import java.util.List; import java.util.Locale; -import java.util.concurrent.TimeUnit; +import java.util.Optional; import java.util.function.Consumer; +import java.util.function.Supplier; import java.util.regex.Matcher; import java.util.regex.Pattern; + +import discord4j.common.util.Snowflake; +import discord4j.core.GatewayDiscordClient; +import discord4j.core.event.domain.message.MessageCreateEvent; +import discord4j.core.object.entity.Attachment; +import discord4j.core.object.entity.Member; +import discord4j.core.object.entity.Message; +import discord4j.core.object.entity.Role; +import discord4j.core.object.entity.User; +import discord4j.core.spec.MessageCreateSpec; +import discord4j.rest.util.AllowedMentions; +import discord4j.rest.util.Color; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.util.AdventureUtil; import me.totalfreedom.totalfreedommod.util.ChatMentionUtil; import me.totalfreedom.totalfreedommod.util.FLog; -import net.dv8tion.jda.api.entities.Member; -import net.dv8tion.jda.api.entities.Message; -import net.dv8tion.jda.api.entities.Role; -import net.dv8tion.jda.api.entities.User; -import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; -import net.dv8tion.jda.api.events.message.MessageReceivedEvent; -import net.dv8tion.jda.api.hooks.ListenerAdapter; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.event.HoverEvent; @@ -26,22 +31,25 @@ import net.kyori.adventure.text.format.Style; import net.kyori.adventure.text.format.TextColor; import net.kyori.adventure.text.format.TextDecoration; -import org.bukkit.Bukkit; -import org.jetbrains.annotations.NotNull; +import reactor.core.publisher.Mono; /** * Bidirectional chat relay for the chat channel chosen by the extending subclass. */ -public abstract class AbstractDiscordChatRelay extends ListenerAdapter +public abstract class AbstractDiscordChatRelay { protected static final int DISCORD_MAX_MESSAGE_LENGTH = 1900; protected static final int MINECRAFT_MAX_MESSAGE_LENGTH = 200; - private static final Pattern URL_PATTERN = Pattern.compile( - "(?i)\\b(?:https?://|www\\.)[-a-z0-9+&@#/%?=~_|!:,.;]*[-a-z0-9+&@#/%=~_|]"); + private static final int NO_ROLE_COLOR = Role.DEFAULT_COLOR.getRGB(); + private static final String DEFAULT_CHAT_FORMAT = "&9[Discord] &r{user}{reply}&7: &f{message}"; + private static final Pattern TEMPLATE_PLACEHOLDER = Pattern.compile("\\{(user|role|rolecolor|message|reply)}"); private static final Pattern MEDIA_EXTENSION = Pattern.compile( - "(?i)\\.(?:png|jpe?g|gif|webp|bmp|tiff?|svg|mp4|m4v|mov|webm|avi|mkv|mp3|wav|ogg|oga|m4a|flac)(?:$|[?#])"); + "(?i)\\.(?:png|jpe?g|gif|webp|bmp|tiff?|svg|mp4|m4v|mov|webm|avi|mkv|mp3|wav|ogg|oga|m4a|flac)(?:$|[?#])"); + private static final Pattern URL_PATTERN = Pattern.compile( + "(?i)\\b(?:https?://|www\\.)[-a-z0-9+&@#/%?=~_|!:,.;]*[-a-z0-9+&@#/%=~_|]"); + private static final NamedTextColor[] MINECRAFT_COLORS = { NamedTextColor.BLACK, NamedTextColor.DARK_BLUE, @@ -61,163 +69,205 @@ public abstract class AbstractDiscordChatRelay extends ListenerAdapter NamedTextColor.WHITE }; - private final TextChannel channel; - private final String channelFormat; - private final String chatFormat; + /** + * Resolved per send rather than captured at construction, so a reconnect that produces a new + * session does not leave this relay writing into a dead connection. + */ + private final Supplier<Optional<Snowflake>> channelSupplier; + private final Optional<String> channelFormat; + private final Optional<String> chatFormat; private final Consumer<Component> chatAction; private final TotalFreedomMod plugin; private final DiscordBridge bridge; - public AbstractDiscordChatRelay(TextChannel channel, String channelFormat, String chatFormat, Consumer<Component> chatAction, TotalFreedomMod plugin, DiscordBridge bridge) + public AbstractDiscordChatRelay(Supplier<Optional<Snowflake>> channelSupplier, String channelFormat, String chatFormat, Consumer<Component> chatAction, TotalFreedomMod plugin, DiscordBridge bridge) { - this.channel = channel; - this.channelFormat = channelFormat; - this.chatFormat = chatFormat; + this.channelSupplier = channelSupplier; + this.channelFormat = configured(channelFormat); + this.chatFormat = configured(chatFormat); this.chatAction = chatAction; this.plugin = plugin; this.bridge = bridge; } - public void sendMessageToDiscord(Component rendered) + private static Optional<String> configured(final String value) { - String body = DiscordMarkdown.render(rendered); - if (body.isBlank()) - { - return; - } + return Optional.ofNullable(value).filter(text -> !text.isBlank()); + } - if (channelFormat != null && !channelFormat.isBlank()) - { - body = channelFormat.replace("{message}", body); - } + /** + * Every message is handled inside its own {@code onErrorResume}. + * An error reaching the outer {@link reactor.core.publisher.Flux} terminates it, and a + * terminated event stream is a relay that has silently stopped while the gateway stays up. + * Recovering per message means one malformed message costs one message. + */ + public Mono<Void> bind(final GatewayDiscordClient gateway) + { + return gateway.on(MessageCreateEvent.class) + .filter(this::isRelayable) + .flatMap(event -> handleMessage(event) + .onErrorResume(thrown -> + { + FLog.warning(String.format("[Discord] Dropped an inbound message: %s", + DiscordConnection.describeFailure(thrown))); + return Mono.empty(); + })) + .then(); + } - if (body.length() > DISCORD_MAX_MESSAGE_LENGTH) - { - body = body.substring(0, DISCORD_MAX_MESSAGE_LENGTH) + "…"; - } + public void sendMessageToDiscord(Component rendered) + { + configured(DiscordMarkdown.render(rendered)) + .map(body -> channelFormat + .map(format -> format.replace("{message}", body)) + .orElse(body)) + .map(AbstractDiscordChatRelay::truncateForDiscord) + .ifPresent(body -> sendToRelayChannel(body, "forward chat to Discord")); + } - sendToRelayChannel(body, "forward chat to Discord"); + private static String truncateForDiscord(final String body) + { + return body.length() > DISCORD_MAX_MESSAGE_LENGTH + ? body.substring(0, DISCORD_MAX_MESSAGE_LENGTH) + "…" + : body; } public void sendSystemMessageToDiscord(String message) { - if (message == null || message.isBlank()) + configured(message).ifPresent(text -> + sendToRelayChannel(sanitizeForDiscord(text), "send system message to Discord")); + } + + /** + * Blocking send used on shutdown, where a queued message would be dropped when the connection + * goes away. + * <p> + * This is the one place the bridge blocks deliberately. + */ + public void sendSystemMessageNow(String message, Duration timeout) + { + configured(message).ifPresent(text -> { - return; - } + try + { + createMessage(sanitizeForDiscord(text)).block(timeout); + } + catch (Exception ex) + { + FLog.warning("[Discord] Failed to send system message to Discord: " + ex.getMessage()); + } + }); + } + + private boolean isRelayable(final MessageCreateEvent event) + { + if (event.getGuildId().isEmpty()) + return false; - sendToRelayChannel(sanitizeForDiscord(message), "send system message to Discord"); + final Optional<User> author = event.getMessage().getAuthor(); + if (author.isEmpty() || author.get().isBot()) + return false; + + final Optional<Snowflake> target = channelSupplier.get(); + return target.isPresent() && target.get().equals(event.getMessage().getChannelId()); } - public void sendSystemMessageToDiscordNow(String message, long timeout, TimeUnit unit) + private Mono<Void> handleMessage(final MessageCreateEvent event) { - if (message == null || message.isBlank()) - { - return; - } + final Message discordMessage = event.getMessage(); + final String content = discordMessage.getContent(); + final List<Attachment> attachments = List.copyOf(discordMessage.getAttachments()); + if (content.isBlank() && attachments.isEmpty()) + return Mono.empty(); + + final String finalTemplate = chatFormat.orElse(DEFAULT_CHAT_FORMAT); + final Optional<Member> member = event.getMember(); + final String displayName = resolveDisplayName(member, discordMessage.getAuthor()); + final String truncatedContent = content.length() > MINECRAFT_MAX_MESSAGE_LENGTH + ? content.substring(0, MINECRAFT_MAX_MESSAGE_LENGTH) + : content; + final Component discordContent = buildDiscordContent(truncatedContent, attachments); + + return Mono.zip(resolveRole(member), resolveReplyName(discordMessage)) + .flatMap(resolved -> relayToMinecraft(finalTemplate, + displayName, + resolved.getT1(), + discordContent, + resolved.getT2())); + } - TextChannel channel = bridge.getPublicChannel(); - if (channel == null) - { - return; - } + /** + * Function to thread hop to main thread + */ + private Mono<Void> relayToMinecraft(String template, String displayName, DiscordRole role, Component discordContent, Optional<String> replyName) + { + return Mono.fromRunnable(() -> + { + Component mentionedContent = ChatMentionUtil.highlightAndPing(plugin, discordContent, false); + Component component = buildDiscordMessage( + template, + displayName, + role, + mentionedContent, + replyName); + chatAction.accept(component); + }) + .subscribeOn(bridge.mainThread()) + .then(); + } - try - { - channel.sendMessage(sanitizeForDiscord(message)) - .setAllowedMentions(Collections.emptyList()) - .submit().get(timeout, unit); - } - catch (InterruptedException ex) - { - Thread.currentThread().interrupt(); - FLog.warning("[Discord] Interrupted while sending system message to Discord."); - } - catch (Exception ex) - { - FLog.warning("[Discord] Failed to send system message to Discord: " + ex.getMessage()); - } + /** + * The display name of whoever wrote the message being replied to, absent when this message is + * not a reply or the referenced author cannot be resolved. + */ + private static Mono<Optional<String>> resolveReplyName(final Message message) + { + return message.getReferencedMessage() + .map(referenced -> referenced.getAuthorAsMember() + .map(Member::getDisplayName) + .switchIfEmpty(Mono.justOrEmpty(referenced.getAuthor().map(User::getUsername))) + .map(Optional::of) + .defaultIfEmpty(Optional.<String>empty())) + .orElseGet(() -> Mono.just(Optional.empty())); } - @Override - public void onMessageReceived(@NotNull MessageReceivedEvent event) + private static Mono<DiscordRole> resolveRole(final Optional<Member> member) { - if (event.getAuthor().isBot() || event.getAuthor().isSystem()) - { - return; - } - if (channel == null || !event.isFromGuild()) - { - return; - } - if (!event.getChannel().getId().equals(channel.getId())) - { - return; - } + return member.map(present -> present.getRoles() + .collectList() + .map(AbstractDiscordChatRelay::pickRole)) + .orElseGet(() -> Mono.just(defaultRole())) + .onErrorReturn(defaultRole()); + } - Message discordMessage = event.getMessage(); - String content = discordMessage.getContentDisplay(); - List<Message.Attachment> attachments = discordMessage.getAttachments(); - if (content.isBlank() && attachments.isEmpty()) - { - return; - } + private static DiscordRole defaultRole() + { + return new DiscordRole("everyone", NamedTextColor.GRAY); + } - String template = chatFormat; - if (template == null || template.isBlank()) - { - template = "&9[Discord] &r{user}{reply}&7: &f{message}"; - } + private static DiscordRole pickRole(final List<Role> roles) + { + if (roles.isEmpty()) + return defaultRole(); - User author = event.getAuthor(); - String displayName = resolveDisplayName(event.getMember(), author); - DiscordRole role = resolveRole(event.getMember()); - String truncatedContent = content.length() > MINECRAFT_MAX_MESSAGE_LENGTH - ? content.substring(0, MINECRAFT_MAX_MESSAGE_LENGTH) - : content; - Component discordContent = buildDiscordContent(truncatedContent, attachments); - String finalTemplate = template; - Message referencedMessage = discordMessage.getReferencedMessage(); - - if (referencedMessage == null) - { - relayToMinecraft(finalTemplate, displayName, role, discordContent, ""); - return; - } + final Role selectedRole = roles.stream() + .filter(role -> colorOf(role).isPresent()) + .findFirst() + .orElseGet(() -> roles.get(0)); - Member referencedMember = referencedMessage.getMember(); - if (referencedMember == null) - { - referencedMember = event.getGuild().getMemberById(referencedMessage.getAuthor().getIdLong()); - } + final NamedTextColor minecraftColor = colorOf(selectedRole).map(color -> closestMinecraftColor(color.getRGB() & 0xFFFFFF)) + .orElse(NamedTextColor.GRAY); - if (referencedMember != null) - { - relayToMinecraft( - finalTemplate, - displayName, - role, - discordContent, - resolveDisplayName(referencedMember, referencedMessage.getAuthor())); - return; - } + return new DiscordRole(selectedRole.getName(), minecraftColor); + } - event.getGuild().retrieveMemberById(referencedMessage.getAuthor().getIdLong()).queue( - member -> relayToMinecraft( - finalTemplate, - displayName, - role, - discordContent, - resolveDisplayName(member, referencedMessage.getAuthor())), - failure -> relayToMinecraft( - finalTemplate, - displayName, - role, - discordContent, - referencedMessage.getAuthor().getName())); + private static Optional<Color> colorOf(final Role role) + { + return Optional.ofNullable(role.getPrimaryColor()) + .filter(color -> color.getRGB() != NO_ROLE_COLOR); } - private static Component buildDiscordContent(String content, List<Message.Attachment> attachments) + private static Component buildDiscordContent(String content, List<Attachment> attachments) { Component result = Component.empty(); Matcher matcher = URL_PATTERN.matcher(content); @@ -226,9 +276,7 @@ private static Component buildDiscordContent(String content, List<Message.Attach while (matcher.find()) { if (matcher.start() > last) - { result = result.append(Component.text(content.substring(last, matcher.start()))); - } String displayedUrl = matcher.group(); String href = displayedUrl.regionMatches(true, 0, "http", 0, 4) @@ -239,16 +287,13 @@ private static Component buildDiscordContent(String content, List<Message.Attach } if (last < content.length()) - { result = result.append(Component.text(content.substring(last))); - } - for (Message.Attachment attachment : attachments) + for (Attachment attachment : attachments) { if (!AdventureUtil.componentToPlainText(result).isBlank()) - { result = result.append(Component.space()); - } + result = result.append(maskedLink(attachment.getUrl(), true)); } @@ -258,8 +303,8 @@ private static Component buildDiscordContent(String content, List<Message.Attach private static Component maskedLink(String url, boolean media) { return Component.text(media ? "[Media]" : "[Link]", NamedTextColor.YELLOW) - .clickEvent(ClickEvent.openUrl(url)) - .hoverEvent(HoverEvent.showText(Component.text(url))); + .clickEvent(ClickEvent.openUrl(url)) + .hoverEvent(HoverEvent.showText(Component.text(url))); } private static boolean isMediaUrl(String url) @@ -267,56 +312,11 @@ private static boolean isMediaUrl(String url) return MEDIA_EXTENSION.matcher(url).find(); } - private static String resolveDisplayName(Member member, User user) - { - if (member != null && member.getNickname() != null && !member.getNickname().isBlank()) - { - return member.getNickname(); - } - return user.getName(); - } - - private void relayToMinecraft(String template, String displayName, DiscordRole role, Component discordContent, String replyName) + private static String resolveDisplayName(Optional<Member> member, Optional<User> user) { - Bukkit.getScheduler().runTask(plugin, () -> - { - Component mentionedContent = ChatMentionUtil.highlightAndPing(plugin, discordContent, false); - Component component = buildDiscordMessage( - template, - displayName, - role, - mentionedContent, - replyName); - chatAction.accept(component); - }); - } - - private static DiscordRole resolveRole(Member member) - { - if (member == null || member.getRoles().isEmpty()) - { - return new DiscordRole("everyone", NamedTextColor.GRAY); - } - - Role selectedRole = null; - for (Role role : member.getRoles()) - { - if (role.getColor() != null) - { - selectedRole = role; - break; - } - } - if (selectedRole == null) - { - selectedRole = member.getRoles().get(0); - } - - Color discordColor = selectedRole.getColor(); - NamedTextColor minecraftColor = discordColor == null - ? NamedTextColor.GRAY - : closestMinecraftColor(discordColor.getRGB() & 0xFFFFFF); - return new DiscordRole(selectedRole.getName(), minecraftColor); + return member.map(Member::getDisplayName) + .or(() -> user.map(User::getUsername)) + .orElse("unknown"); } private static NamedTextColor closestMinecraftColor(int rgb) @@ -350,15 +350,16 @@ private static NamedTextColor closestMinecraftColor(int rgb) return closest; } - private static Component buildDiscordMessage(String template, String user, DiscordRole role, Component message, String replyName) + private static Component buildDiscordMessage(String template, String user, DiscordRole role, Component message, Optional<String> replyName) { Matcher matcher = TEMPLATE_PLACEHOLDER.matcher(template); LegacyStyleState styleState = new LegacyStyleState(); Component result = Component.empty(); - Component reply = replyName == null || replyName.isBlank() - ? Component.empty() - : Component.text(" \u21aa Replying to ", NamedTextColor.GRAY) - .append(Component.text(replyName, NamedTextColor.AQUA)); + Component reply = replyName.filter(name -> !name.isBlank()) + .map(name -> Component.text(" ↪ Replying to ", NamedTextColor.GRAY) + .append(Component.text(name, NamedTextColor.AQUA))) + .map(Component.class::cast) + .orElseGet(Component::empty); boolean hasReplyPlaceholder = template.contains("{reply}"); boolean hasMessagePlaceholder = false; int last = 0; @@ -373,9 +374,8 @@ private static Component buildDiscordMessage(String template, String user, Disco case "user": result = result.append(Component.text(user).style(styleState.style())); if (!hasReplyPlaceholder) - { result = result.append(reply); - } + break; case "role": result = result.append(Component.text(role.name).style(styleState.style())); @@ -399,18 +399,15 @@ private static Component buildDiscordMessage(String template, String user, Disco result = result.append(styledLiteral(template.substring(last), styleState)); if (!hasMessagePlaceholder) - { result = result.append(Component.text().style(styleState.style()).append(message).build()); - } + return result; } private static Component styledLiteral(String literal, LegacyStyleState state) { if (literal.isEmpty()) - { return Component.empty(); - } Component component = Component.text().style(state.style()).append(AdventureUtil.format(literal)).build(); state.consume(literal); @@ -422,32 +419,38 @@ private static String sanitizeForDiscord(String input) return input.replace("`", "'"); } - private void failRelayChannelSend(final String failureDescription, final Throwable err) + private void failRelayChannelSend(final String failureDescription, final Optional<Throwable> err) { - FLog.warning("[Discord] Failed to " + failureDescription + (err != null ? ": " + err.getMessage() : "")); + FLog.warning(String.format("[Discord] Failed to %s: %s", + failureDescription, + err.map(thrown -> ": " + thrown.getMessage()).orElse(""))); } - private void sendToRelayChannel(final String body, final String failureDescription) + /** + * Build the send for {@code body} against whichever channel this relay currently points at. + */ + private Mono<Message> createMessage(final String body) { - if (channel == null || body == null) - { - failRelayChannelSend(failureDescription, null); - return; - } + final Optional<Snowflake> target = channelSupplier.get(); + + return Mono.justOrEmpty(configured(body)) + .flatMap(text -> Mono.justOrEmpty(bridge.getSession()) + .flatMap(current -> current.channel(target)) + .flatMap(channel -> channel.createMessage(MessageCreateSpec.builder() + .content(text) + .allowedMentions(AllowedMentions.suppressAll()) + .build()) + )); + } - try - { - channel.sendMessage(body) - .setAllowedMentions(Collections.emptyList()) - .queue( - null, - err -> failRelayChannelSend(failureDescription, err) - ); - } - catch (java.util.concurrent.RejectedExecutionException ex) - { - failRelayChannelSend(failureDescription, ex); - } + /** + * Fire-and-forget send with an explicit error consumer. + */ + private void sendToRelayChannel(final String body, final String failureDescription) + { + // The no-op onNext is a placeholder for Reactor's three-argument subscribe, which has no overload taking only an error consumer. + createMessage(body).subscribe(sent -> {}, + err -> failRelayChannelSend(failureDescription, Optional.of(err))); } private static final class DiscordRole @@ -464,7 +467,8 @@ private DiscordRole(String name, NamedTextColor color) private static final class LegacyStyleState { - private TextColor color; + private Optional<TextColor> color = Optional.empty(); + private boolean obfuscated; private boolean bold; private boolean strikethrough; @@ -473,37 +477,30 @@ private static final class LegacyStyleState private void setColor(TextColor color) { - this.color = color; + this.color = Optional.of(color); clearDecorations(); } private Style style() { Style.Builder builder = Style.style(); - if (color != null) - { - builder.color(color); - } + color.ifPresent(builder::color); + if (obfuscated) - { builder.decoration(TextDecoration.OBFUSCATED, TextDecoration.State.TRUE); - } + if (bold) - { builder.decoration(TextDecoration.BOLD, TextDecoration.State.TRUE); - } + if (strikethrough) - { builder.decoration(TextDecoration.STRIKETHROUGH, TextDecoration.State.TRUE); - } + if (underlined) - { builder.decoration(TextDecoration.UNDERLINED, TextDecoration.State.TRUE); - } + if (italic) - { builder.decoration(TextDecoration.ITALIC, TextDecoration.State.TRUE); - } + return builder.build(); } @@ -513,9 +510,7 @@ private void consume(String text) { char marker = text.charAt(i); if (marker != '&' && marker != '§') - { continue; - } char code = Character.toLowerCase(text.charAt(i + 1)); if (code == '#' && i + 7 < text.length()) @@ -523,7 +518,7 @@ private void consume(String text) String hex = text.substring(i + 2, i + 8); if (hex.matches("[0-9a-fA-F]{6}")) { - color = TextColor.color(Integer.parseInt(hex, 16)); + color = Optional.of(TextColor.color(Integer.parseInt(hex, 16))); clearDecorations(); i += 7; } @@ -548,15 +543,15 @@ private void consume(String text) } if (valid) { - color = TextColor.color(Integer.parseInt(hex.toString(), 16)); + color = Optional.of(TextColor.color(Integer.parseInt(hex.toString(), 16))); clearDecorations(); i += 13; } continue; } - NamedTextColor namedColor = legacyColor(code); - if (namedColor != null) + final Optional<TextColor> namedColor = legacyColor(code); + if (namedColor.isPresent()) { color = namedColor; clearDecorations(); @@ -582,7 +577,7 @@ private void consume(String text) italic = true; break; case 'r': - color = null; + color = Optional.empty(); clearDecorations(); break; default: @@ -601,28 +596,31 @@ private void clearDecorations() italic = false; } - private static NamedTextColor legacyColor(char code) + /** + * The colour a legacy code selects, absent when the character is not a colour code at all. + */ + private static Optional<TextColor> legacyColor(char code) { - switch (code) + return switch (code) { - case '0': return NamedTextColor.BLACK; - case '1': return NamedTextColor.DARK_BLUE; - case '2': return NamedTextColor.DARK_GREEN; - case '3': return NamedTextColor.DARK_AQUA; - case '4': return NamedTextColor.DARK_RED; - case '5': return NamedTextColor.DARK_PURPLE; - case '6': return NamedTextColor.GOLD; - case '7': return NamedTextColor.GRAY; - case '8': return NamedTextColor.DARK_GRAY; - case '9': return NamedTextColor.BLUE; - case 'a': return NamedTextColor.GREEN; - case 'b': return NamedTextColor.AQUA; - case 'c': return NamedTextColor.RED; - case 'd': return NamedTextColor.LIGHT_PURPLE; - case 'e': return NamedTextColor.YELLOW; - case 'f': return NamedTextColor.WHITE; - default: return null; - } + case '0' -> Optional.of(NamedTextColor.BLACK); + case '1' -> Optional.of(NamedTextColor.DARK_BLUE); + case '2' -> Optional.of(NamedTextColor.DARK_GREEN); + case '3' -> Optional.of(NamedTextColor.DARK_AQUA); + case '4' -> Optional.of(NamedTextColor.DARK_RED); + case '5' -> Optional.of(NamedTextColor.DARK_PURPLE); + case '6' -> Optional.of(NamedTextColor.GOLD); + case '7' -> Optional.of(NamedTextColor.GRAY); + case '8' -> Optional.of(NamedTextColor.DARK_GRAY); + case '9' -> Optional.of(NamedTextColor.BLUE); + case 'a' -> Optional.of(NamedTextColor.GREEN); + case 'b' -> Optional.of(NamedTextColor.AQUA); + case 'c' -> Optional.of(NamedTextColor.RED); + case 'd' -> Optional.of(NamedTextColor.LIGHT_PURPLE); + case 'e' -> Optional.of(NamedTextColor.YELLOW); + case 'f' -> Optional.of(NamedTextColor.WHITE); + default -> Optional.empty(); + }; } } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordAdminchatRelay.java b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordAdminchatRelay.java index 433ff2154..227d866c8 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordAdminchatRelay.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordAdminchatRelay.java @@ -13,7 +13,7 @@ public class DiscordAdminchatRelay extends AbstractDiscordChatRelay { public DiscordAdminchatRelay(TotalFreedomMod plugin, DiscordBridge bridge) { - super(bridge.getAdminchatChannel(), + super(bridge::currentAdminchatChannel, ConfigEntry.DISCORD_ADMINCHAT_CHANNEL_FORMAT.getString(), ConfigEntry.DISCORD_ADMINCHAT_FORMAT.getString(), component -> { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordBridge.java b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordBridge.java index fd90df780..f7ea80b9d 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordBridge.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordBridge.java @@ -1,270 +1,369 @@ package me.totalfreedom.totalfreedommod.discord; import io.papermc.paper.event.player.AsyncChatEvent; +import java.security.SecureRandom; +import java.time.Duration; +import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; + +import discord4j.common.util.Snowflake; +import discord4j.core.GatewayDiscordClient; +import discord4j.core.event.domain.lifecycle.ReadyEvent; +import discord4j.core.object.entity.channel.GuildMessageChannel; +import discord4j.discordjson.json.ApplicationCommandOptionData; +import discord4j.discordjson.json.ApplicationCommandRequest; import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FTask; -import net.dv8tion.jda.api.JDA; -import net.dv8tion.jda.api.JDABuilder; -import net.dv8tion.jda.api.entities.Guild; -import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; -import net.dv8tion.jda.api.events.session.ReadyEvent; -import net.dv8tion.jda.api.hooks.ListenerAdapter; -import net.dv8tion.jda.api.interactions.commands.OptionType; -import net.dv8tion.jda.api.interactions.commands.build.Commands; -import net.dv8tion.jda.api.requests.GatewayIntent; -import net.dv8tion.jda.api.utils.MemberCachePolicy; -import net.dv8tion.jda.api.utils.cache.CacheFlag; import net.kyori.adventure.audience.Audience; import net.kyori.adventure.text.Component; +import org.bukkit.Bukkit; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerQuitEvent; -import org.jetbrains.annotations.NotNull; +import org.bukkit.scheduler.BukkitTask; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Scheduler; +import reactor.core.scheduler.Schedulers; /** - * Built-in Discord bridge (owns the JDA instance, chat/console relay and slash-command listeners). + * Owns the Discord4J client, the chat/console relays and the slash-command handlers. + * <p> + * The connection itself is owned by {@link DiscordConnection}, which scopes one gateway session to + * one pipeline and reconnects within a budget. Startup is a subscription rather than a blocking + * call, so a slow or unreachable Discord never blocks server boot. + * <p> + * The live connection is published as one immutable {@link DiscordSession} behind a volatile field. + * Readers on Reactor threads and the console flush task take a snapshot and either use a whole + * connection or find none, and reconnecting swaps the lot in a single assignment. + * <p> + * Everything downstream of a gateway event runs on a Reactor thread. Anything that touches the + * server has to hop to the main thread first, and the single convention for that is + * {@link #mainThread()}, applied with {@code subscribeOn} to a {@code fromRunnable} or + * {@code fromCallable} holding the server call. + * + * @see DiscordConnection */ public class DiscordBridge extends FreedomService { public static volatile boolean reloading = false; - private JDA jda; - private Guild guild; - private TextChannel publicChannel; - private TextChannel adminchatChannel; - private TextChannel consoleChannel; - - private DiscordChatRelay chatRelay; - private DiscordAdminchatRelay adminchatRelay; - private DiscordConsoleRelay consoleRelay; - private DiscordCommands commands; + private static final SecureRandom CODE_RANDOM = new SecureRandom(); + private static final String CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + private static final int CODE_LENGTH = 8; + private static final int DEFAULT_LINK_CODE_TTL_SECONDS = 300; + private static final Duration SHUTDOWN_MESSAGE_TIMEOUT = Duration.ofSeconds(5); + + private static final List<ApplicationCommandRequest> SLASH_COMMANDS = List.of( + ApplicationCommandRequest.builder() + .name("list") + .description("Show a list of players on the server.") + .build(), + ApplicationCommandRequest.builder() + .name("link") + .description("Link your Minecraft account by entering a code from the in-game /link command.") + .addOption(ApplicationCommandOptionData.builder() + .name("code") + .description("The 8-character code shown in-game.") + .type(3) + .required(true) + .build()) + .build(), + ApplicationCommandRequest.builder() + .name("unlink") + .description("Unlink your Minecraft account from your current Discord account.") + .build()); private final Map<String, PendingLink> pendingLinks = new ConcurrentHashMap<>(); - private int linkCodeTtlSeconds; + private final DiscordConnection connection = new DiscordConnection(); + private final Scheduler mainThread; + + /** + * The live connection, empty while disconnected. Volatile because it is written by the connect + * pipeline and read from Reactor threads and the console flush task. + */ + private volatile Optional<DiscordSession> session = Optional.empty(); + private volatile boolean started; + private volatile Optional<DiscordChatRelay> chatRelay = Optional.empty(); + private volatile Optional<DiscordAdminchatRelay> adminchatRelay = Optional.empty(); + private volatile Optional<DiscordConsoleRelay> consoleRelay = Optional.empty(); + private volatile Optional<BukkitTask> cleanupTask = Optional.empty(); + private volatile int linkCodeTtlSeconds; + private volatile boolean startedWhileReloading; public DiscordBridge(TotalFreedomMod plugin) { super(plugin); + this.mainThread = Schedulers.fromExecutor(Bukkit.getScheduler().getMainThreadExecutor(plugin)); } @Override protected void onStart() { - final boolean wasReloading = reloading; + startedWhileReloading = reloading; if (!Boolean.TRUE.equals(ConfigEntry.DISCORD_ENABLED.getBoolean())) - { return; - } plugin.dm.whenReady(() -> DiscordLinkJsonSync.reconcileFromJsonIfNewer(plugin, plugin.dm.getDiscordLinkRepository())); - String token = ConfigEntry.DISCORD_TOKEN.getString(); - if (token == null || token.isBlank()) + final Optional<String> token = configured(ConfigEntry.DISCORD_TOKEN.getString()); + if (token.isEmpty()) { FLog.warning("[Discord] discord.enabled is true but discord.token is empty; bridge will not start."); return; } - String guildId = ConfigEntry.DISCORD_GUILD_ID.getString(); - if (guildId == null || guildId.isBlank()) + if (configured(ConfigEntry.DISCORD_GUILD_ID.getString()).isEmpty()) { FLog.warning("[Discord] discord.guild_id is empty; bridge will not start."); return; } - Integer ttl = ConfigEntry.DISCORD_LINK_CODE_TTL.getInteger(); - linkCodeTtlSeconds = ttl == null || ttl <= 0 ? 300 : ttl; - - try - { - jda = JDABuilder.createDefault(token) - .enableIntents(GatewayIntent.GUILD_MESSAGES, - GatewayIntent.MESSAGE_CONTENT, - GatewayIntent.DIRECT_MESSAGES) - .disableCache(CacheFlag.VOICE_STATE, CacheFlag.EMOJI, CacheFlag.STICKER, - CacheFlag.SCHEDULED_EVENTS, CacheFlag.ACTIVITY, - CacheFlag.CLIENT_STATUS, CacheFlag.ONLINE_STATUS) - .setMemberCachePolicy(MemberCachePolicy.NONE) - .addEventListeners(new ReadyListener(guildId)) - .build(); - - jda.awaitReady(); - } - catch (Exception ex) - { - FLog.severe("[Discord] Failed to start JDA client: " + ex.getMessage()); - FLog.severe(ex); - jda = null; - return; - } - - guild = jda.getGuildById(guildId); - if (guild == null) - { - FLog.warning("[Discord] Bot is not a member of guild " + guildId + "; bridge will not start."); - shutdownJdaQuietly(); - return; - } - - publicChannel = resolveChannel(ConfigEntry.DISCORD_PUBLIC_CHANNEL_ID.getString(), "public_channel_id"); - adminchatChannel = resolveChannel(ConfigEntry.DISCORD_ADMINCHAT_CHANNEL_ID.getString(), "adminchat_channel_id"); - consoleChannel = resolveChannel(ConfigEntry.DISCORD_CONSOLE_CHANNEL_ID.getString(), "console_channel_id"); - - commands = new DiscordCommands(plugin, this); - chatRelay = new DiscordChatRelay(plugin, this); - adminchatRelay = new DiscordAdminchatRelay(plugin, this); - consoleRelay = new DiscordConsoleRelay(plugin, this); - - jda.addEventListener(commands, chatRelay, adminchatRelay, consoleRelay); - - guild.updateCommands().addCommands( - Commands.slash("list", "Show a list of players on the server."), - Commands.slash("link", "Link your Minecraft account by entering a code from the in-game /link command.") - .addOption(OptionType.STRING, "code", "The 8-character code shown in-game.", true), - Commands.slash("unlink", "Unlink your Minecraft account from your current Discord account.") - ).queue( - ok -> FLog.info("[Discord] Registered slash commands on guild " + guild.getName() + "."), - err -> FLog.warning("[Discord] Failed to register slash commands: " + err.getMessage()) - ); + linkCodeTtlSeconds = Optional.ofNullable(ConfigEntry.DISCORD_LINK_CODE_TTL.getInteger()) + .filter(ttl -> ttl > 0) + .orElse(DEFAULT_LINK_CODE_TTL_SECONDS); + started = true; - consoleRelay.attachAppender(); - - // Periodic cleanup of expired pending link codes. - plugin.getServer().getScheduler().runTaskTimerAsynchronously(plugin, + cleanupTask = Optional.of(plugin.getServer().getScheduler().runTaskTimerAsynchronously(plugin, FTask.guard("DiscordBridge/cleanupPendingLinks", this::cleanupPendingLinks), - 20L * 60L, 20L * 60L); - - plugin.getServer().getScheduler().runTask(plugin, () -> - { - if (chatRelay != null && publicChannel != null) - { - ConfigEntry entry = wasReloading - ? ConfigEntry.DISCORD_PLUGIN_RELOAD_MESSAGE - : ConfigEntry.DISCORD_SERVER_STARTUP_MESSAGE; - chatRelay.sendSystemMessageToDiscord(getConfiguredMessage(entry)); - } - }); + 20L * 60L, 20L * 60L)); - FLog.info("[Discord] Bridge ready. Guild: " + guild.getName() - + " | public: " + (publicChannel == null ? "(none)" : publicChannel.getName()) - + " | adminchat: " + (adminchatChannel == null ? "(none)" : adminchatChannel.getName()) - + " | console: " + (consoleChannel == null ? "(none)" : consoleChannel.getName())); + connection.start(token.get(), this::onConnected); } @Override protected void onStop() { - if (consoleRelay != null) - { - consoleRelay.detachAppender(); - } + started = false; + cleanupTask.ifPresent(BukkitTask::cancel); + cleanupTask = Optional.empty(); + + consoleRelay.ifPresent(DiscordConsoleRelay::detachAppender); - if (chatRelay != null && publicChannel != null && !reloading) + if (!reloading) { - chatRelay.sendSystemMessageToDiscordNow(getConfiguredMessage(ConfigEntry.DISCORD_SERVER_SHUTDOWN_MESSAGE), - 5L, TimeUnit.SECONDS); + chatRelay.ifPresent(relay -> getConfiguredMessage(ConfigEntry.DISCORD_SERVER_SHUTDOWN_MESSAGE) + .ifPresent(message -> relay.sendSystemMessageNow(message, SHUTDOWN_MESSAGE_TIMEOUT))); } - shutdownJdaQuietly(); - - chatRelay = null; - adminchatRelay = null; - consoleRelay = null; - commands = null; - publicChannel = null; - adminchatChannel = null; - consoleChannel = null; - guild = null; + connection.stop(); + session = Optional.empty(); + chatRelay = Optional.empty(); + adminchatRelay = Optional.empty(); + consoleRelay = Optional.empty(); pendingLinks.clear(); } - @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) - public void onAsyncChat(AsyncChatEvent event) + /** + * Resolves the guild and channels, publishes the session, then returns the relay pipelines + * joined together. Those never complete on their own, which is what holds the gateway open; + * when the connection drops they terminate, the returned {@link Mono} completes, and + * {@link DiscordConnection} turns that into a retry. + * <p> + * A guild that cannot be resolved is an error rather than a quiet exit, so a bot that was + * removed from the guild spends its reconnect budget and reports instead of sitting connected + * and idle. + */ + private Mono<Void> onConnected(final GatewayDiscordClient gateway) { - if (chatRelay == null || publicChannel == null) - { - return; - } - Player player = event.getPlayer(); - Component rendered; - try - { - rendered = event.renderer().render(player, player.displayName(), event.message(), Audience.empty()); - } - catch (Exception ex) - { - FLog.warning("[Discord] Chat renderer threw, falling back to plain: " + ex.getMessage()); - rendered = Component.text(player.getName() + ": ").append(event.message()); - } - chatRelay.sendMessageToDiscord(rendered); + if (!started) + return Mono.empty(); + + final String rawGuildId = ConfigEntry.DISCORD_GUILD_ID.getString(); + + return Mono.justOrEmpty(parseSnowflake(rawGuildId, "guild_id")) + .switchIfEmpty(Mono.error(() -> + new IllegalStateException(String.format("guild_id '%s' is not a valid id", rawGuildId)))) + .flatMap(guildId -> gateway.getGuildById(guildId) + .switchIfEmpty(Mono.error(() -> new IllegalStateException( + String.format("bot is not a member of guild %s", rawGuildId)))) + .flatMap(guild -> resolveSession(gateway, guildId, guild.getName()))) + .flatMap(resolved -> publishSession(gateway, resolved)); } - public void sendBroadcastMessage(String senderName, String message, ConfigEntry configEntry) + private Mono<DiscordSession> resolveSession(final GatewayDiscordClient gateway, final Snowflake guildId, + final String guildName) { - if (chatRelay == null || publicChannel == null) - { - return; - } + return Mono.zip( + resolveChannel(gateway, ConfigEntry.DISCORD_PUBLIC_CHANNEL_ID.getString(), "public_channel_id"), + resolveChannel(gateway, ConfigEntry.DISCORD_ADMINCHAT_CHANNEL_ID.getString(), "adminchat_channel_id"), + resolveChannel(gateway, ConfigEntry.DISCORD_CONSOLE_CHANNEL_ID.getString(), "console_channel_id")) + .map(resolved -> new DiscordSession( + gateway, + guildId, + guildName, + resolved.getT1().id(), + resolved.getT2().id(), + resolved.getT3().id(), + String.format("public: %s | adminchat: %s | console: %s", + resolved.getT1().name(), resolved.getT2().name(), resolved.getT3().name()))); + } + + private Mono<Void> publishSession(final GatewayDiscordClient gateway, final DiscordSession opened) + { + if (!started) + return Mono.empty(); + + final DiscordCommands openedCommands = new DiscordCommands(plugin, this); + final DiscordChatRelay openedChat = new DiscordChatRelay(plugin, this); + final DiscordAdminchatRelay openedAdminchat = new DiscordAdminchatRelay(plugin, this); + final DiscordConsoleRelay openedConsole = new DiscordConsoleRelay(plugin, this); + + chatRelay = Optional.of(openedChat); + adminchatRelay = Optional.of(openedAdminchat); + consoleRelay = Optional.of(openedConsole); + + session = Optional.of(opened); + + final Mono<Void> ready = gateway.on(ReadyEvent.class) + .doOnNext(event -> + { + connection.reportConnected(); + FLog.info(String.format("[Discord] Gateway ready. Bot: %s", + event.getSelf().getUsername())); + }) + .then(); + + return Mono.when( + registerSlashCommands(gateway, opened) + .then(Mono.fromRunnable(() -> announceReady(opened))), + ready, + openedCommands.bind(gateway), + openedChat.bind(gateway), + openedAdminchat.bind(gateway), + openedConsole.bind(gateway)); + } + + private void announceReady(final DiscordSession opened) + { + connection.reportConnected(); + + consoleRelay.ifPresent(DiscordConsoleRelay::attachAppender); - String template = getConfiguredMessage(configEntry); - if (template == null) + FLog.info(String.format("[Discord] Bridge ready. Guild: %s | %s", + opened.guildName(), opened.describeChannels())); + + final ConfigEntry greeting = startedWhileReloading + ? ConfigEntry.DISCORD_PLUGIN_RELOAD_MESSAGE + : ConfigEntry.DISCORD_SERVER_STARTUP_MESSAGE; + startedWhileReloading = false; + + if (opened.publicChannel().isPresent()) { - return; + chatRelay.ifPresent(relay -> getConfiguredMessage(greeting) + .ifPresent(relay::sendSystemMessageToDiscord)); } + } - String msg = template.replace("{sender}", senderName) - .replace("{message}", message); + private Mono<Void> registerSlashCommands(final GatewayDiscordClient gateway, final DiscordSession opened) + { + return gateway.getRestClient().getApplicationId() + .flatMapMany(applicationId -> gateway.getRestClient().getApplicationService() + .bulkOverwriteGuildApplicationCommand(applicationId, opened.guildId().asLong(), SLASH_COMMANDS)) + .then(Mono.fromRunnable(() -> FLog.info( + String.format("[Discord] Registered slash commands on guild %s.", opened.guildName())))) + .onErrorResume(thrown -> + { + FLog.warning(String.format("[Discord] Failed to register slash commands: %s", + DiscordConnection.describeFailure(thrown))); + return Mono.empty(); + }) + .then(); + } - chatRelay.sendSystemMessageToDiscord(msg); + private Mono<ResolvedChannel> resolveChannel(final GatewayDiscordClient gateway, final String rawId, + final String configKey) + { + if (configured(rawId).isEmpty()) + return Mono.just(ResolvedChannel.none()); + + return Mono.justOrEmpty(parseSnowflake(rawId, configKey)) + .flatMap(id -> gateway.getChannelById(id) + .ofType(GuildMessageChannel.class) + .map(channel -> new ResolvedChannel(Optional.of(id), channel.getName())) + .switchIfEmpty(Mono.fromSupplier(() -> + { + FLog.warning(String.format( + "[Discord] %s '%s' is not a text channel in the configured guild.", + configKey, rawId)); + return ResolvedChannel.none(); + }))) + .defaultIfEmpty(ResolvedChannel.none()) + .onErrorResume(thrown -> + { + FLog.warning(String.format( + "[Discord] Could not resolve %s '%s': %s", + configKey, rawId, DiscordConnection.describeFailure(thrown))); + return Mono.just(ResolvedChannel.none()); + }); } - public void sendActionMessage(String senderName, String playerName, String reason, ConfigEntry configEntry) + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onAsyncChat(AsyncChatEvent event) { - if (chatRelay == null || publicChannel == null) - { + if (currentPublicChannel().isEmpty()) return; - } - String template = getConfiguredMessage(configEntry); - if (template == null) + chatRelay.ifPresent(relay -> { - return; - } + final Player player = event.getPlayer(); + Component rendered; + try + { + rendered = event.renderer().render(player, player.displayName(), event.message(), Audience.empty()); + } + catch (Exception ex) + { + FLog.warning(String.format( + "[Discord] Chat renderer threw, falling back to plain: %s", + ex.getMessage())); + rendered = Component.text(player.getName() + ": ").append(event.message()); + } + relay.sendMessageToDiscord(rendered); + }); + } - String message = template.replace("{sender}", senderName == null ? "CONSOLE" : senderName) - .replace("{player}", playerName == null ? "null" : playerName) - .replace("{reason}", reason == null || reason.isBlank() ? "No reason provided." : reason); + public void sendBroadcastMessage(String senderName, String message, ConfigEntry configEntry) + { + getConfiguredMessage(configEntry).ifPresent(template -> sendToPublicRelay(template.replace("{sender}", senderName).replace("{message}", message))); + } - chatRelay.sendSystemMessageToDiscord(message); + public void sendActionMessage(String senderName, String playerName, String reason, ConfigEntry configEntry) + { + getConfiguredMessage(configEntry).ifPresent(template -> sendToPublicRelay(template.replace("{sender}", Optional.ofNullable(senderName).orElse("CONSOLE")) + .replace("{player}", Optional.ofNullable(playerName).orElse("null")) + .replace("{reason}", configured(reason).orElse("No reason provided.")))); } public void relayAdminchatMessage(CommandSender sender, Component tag, Component message) { - if (adminchatRelay == null || adminchatChannel == null) - { + if (currentAdminchatChannel().isEmpty()) return; - } - Component rendered = sender instanceof Player - ? tag.append(Component.text(" " + sender.getName() + ": ")).append(message) - : Component.text(sender.getName() + " ") - .append(tag) - .append(Component.text(": ")) - .append(message); - adminchatRelay.sendMessageToDiscord(rendered); + + adminchatRelay.ifPresent(relay -> + { + final Component rendered = sender instanceof Player + ? tag.append(Component.text(" " + sender.getName() + ": ")).append(message) + : Component.text(sender.getName() + " ") + .append(tag) + .append(Component.text(": ")) + .append(message); + + relay.sendMessageToDiscord(rendered); + }); } @EventHandler(priority = EventPriority.MONITOR) @@ -279,188 +378,146 @@ public void onPlayerQuit(PlayerQuitEvent event) sendPlayerStatusMessage(event.getPlayer().getName(), ConfigEntry.DISCORD_PLAYER_LEAVE_MESSAGE); } - private void sendPlayerStatusMessage(String playerName, ConfigEntry configEntry) + public void relayLoginMessage(Component message) { - if (chatRelay == null || publicChannel == null) - { - return; - } - - String template = getConfiguredMessage(configEntry); - if (template == null) - { - return; - } + Optional.ofNullable(message) + .map(DiscordMarkdown::render) + .filter(rendered -> !rendered.isBlank()) + .ifPresent(this::sendToPublicRelay); + } + + public Optional<DiscordSession> getSession() + { + return session; + } - chatRelay.sendSystemMessageToDiscord(template.replace("{player}", playerName)); + /** + * The server main thread as a Reactor scheduler. Every pipeline that ends in a call touching + * the server publishes onto this first. + */ + public Scheduler mainThread() + { + return mainThread; } - public void relayLoginMessage(Component message) + public Optional<Snowflake> currentPublicChannel() { - if (message == null) - { - return; - } - if (chatRelay == null || publicChannel == null) - { - return; - } - String rendered = DiscordMarkdown.render(message); - if (rendered.isBlank()) - { - return; - } - chatRelay.sendSystemMessageToDiscord(rendered); + return getSession().flatMap(DiscordSession::publicChannel); } - private String getConfiguredMessage(ConfigEntry configEntry) + public Optional<Snowflake> currentAdminchatChannel() { - String message = configEntry.getString(); - return message == null || message.isBlank() ? null : message; + return getSession().flatMap(DiscordSession::adminchatChannel); } - private TextChannel resolveChannel(String id, String configKey) + public Optional<Snowflake> currentConsoleChannel() { - if (id == null || id.isBlank()) - { - return null; - } - TextChannel channel = guild.getTextChannelById(id); - if (channel == null) - { - FLog.warning("[Discord] " + configKey + " '" + id + "' is not a text channel in " + guild.getName() + "."); - } - return channel; + return getSession().flatMap(DiscordSession::consoleChannel); } - private void shutdownJdaQuietly() + public boolean isReady() { - if (jda == null) - { - return; - } - try - { - jda.shutdown(); - if (!jda.awaitShutdown(java.time.Duration.ofSeconds(10))) - { - jda.shutdownNow(); - } - } - catch (Exception ex) - { - FLog.warning("[Discord] Error during JDA shutdown: " + ex.getMessage()); - } - finally - { - jda = null; - } + return session.isPresent(); } - public JDA getJda() + public String createPendingLink(UUID adminUuid) { - return jda; + final long expiryMs = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(linkCodeTtlSeconds); + String code; + + do code = generateCode(); + while (Optional.ofNullable(pendingLinks.putIfAbsent(code, new PendingLink(adminUuid, expiryMs))).isPresent()); + + return code; } - public TextChannel getPublicChannel() + /** + * Consume {@code code}: returns the admin UUID it was registered for and removes the entry. + * Returns empty if the code is unknown or expired. + */ + public Optional<UUID> consumePendingLink(String code) { - return publicChannel; + return Optional.ofNullable(code) + .map(String::toUpperCase) + .flatMap(upper -> Optional.ofNullable(pendingLinks.remove(upper))) + .filter(link -> link.expiresAtMs() >= System.currentTimeMillis()) + .map(PendingLink::adminUuid); } - public TextChannel getAdminchatChannel() + public int getLinkCodeTtlSeconds() { - return adminchatChannel; + return linkCodeTtlSeconds; } - public TextChannel getConsoleChannel() + private void sendPlayerStatusMessage(String playerName, ConfigEntry configEntry) { - return consoleChannel; + getConfiguredMessage(configEntry).ifPresent(template -> sendToPublicRelay(template.replace("{player}", playerName))); } - public boolean isReady() + private void sendToPublicRelay(final String message) { - return jda != null && guild != null; + if (currentPublicChannel().isEmpty()) + return; + + configured(message).ifPresent(text -> + chatRelay.ifPresent(relay -> relay.sendSystemMessageToDiscord(text))); } - /** - * Register a pending link code. Returns the generated code. - */ - public String createPendingLink(UUID adminUuid) + private Optional<String> getConfiguredMessage(ConfigEntry configEntry) { - long expiryMs = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(linkCodeTtlSeconds); - String code; - do - { - code = generateCode(); - } - while (pendingLinks.putIfAbsent(code, new PendingLink(adminUuid, expiryMs)) != null); - return code; + return configured(configEntry.getString()); } /** - * Consume {@code code}: returns the admin UUID it was registered for and - * removes the entry. Returns null if the code is unknown or expired. + * A configured string that is actually set. Absent for both a missing key and a blank value, + * which the config layer does not distinguish. */ - public UUID consumePendingLink(String code) + private static Optional<String> configured(final String value) { - if (code == null) - { - return null; - } - PendingLink link = pendingLinks.remove(code.toUpperCase()); - if (link == null) - { - return null; - } - if (link.expiresAtMs() < System.currentTimeMillis()) - { - return null; - } - return link.adminUuid(); + return Optional.ofNullable(value).filter(text -> !text.isBlank()); } - public int getLinkCodeTtlSeconds() + private static Optional<Snowflake> parseSnowflake(final String raw, final String configKey) { - return linkCodeTtlSeconds; + return configured(raw).flatMap(text -> + { + try + { + return Optional.of(Snowflake.of(text.trim())); + } + catch (NumberFormatException ex) + { + FLog.warning(String.format("[Discord] %s '%s' is not a valid Discord id.", configKey, raw)); + return Optional.empty(); + } + }); } private void cleanupPendingLinks() { - long now = System.currentTimeMillis(); - pendingLinks.entrySet().removeIf(e -> e.getValue().expiresAtMs() < now); + final long now = System.currentTimeMillis(); + pendingLinks.entrySet().removeIf(entry -> entry.getValue().expiresAtMs() < now); } private static String generateCode() { - final String alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; - java.util.Random r = new java.util.Random(); - StringBuilder sb = new StringBuilder(8); - for (int i = 0; i < 8; i++) - { - sb.append(alphabet.charAt(r.nextInt(alphabet.length()))); - } - return sb.toString(); + final StringBuilder builder = new StringBuilder(CODE_LENGTH); + + for (int i = 0; i < CODE_LENGTH; i++) + builder.append(CODE_ALPHABET.charAt(CODE_RANDOM.nextInt(CODE_ALPHABET.length()))); + + return builder.toString(); } private record PendingLink(UUID adminUuid, long expiresAtMs) { } - private static final class ReadyListener extends ListenerAdapter + private record ResolvedChannel(Optional<Snowflake> id, String name) { - private final String guildId; - - ReadyListener(String guildId) - { - this.guildId = guildId; - } - - @Override - public void onReady(@NotNull ReadyEvent event) + private static ResolvedChannel none() { - FLog.info("[Discord] JDA gateway ready. Bot: " - + event.getJDA().getSelfUser().getName() - + " | configured guild id: " + guildId); + return new ResolvedChannel(Optional.empty(), "(none)"); } } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordChatRelay.java b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordChatRelay.java index a5e00d30a..4ef7803bc 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordChatRelay.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordChatRelay.java @@ -11,7 +11,7 @@ public class DiscordChatRelay extends AbstractDiscordChatRelay { public DiscordChatRelay(TotalFreedomMod plugin, DiscordBridge bridge) { - super(bridge.getPublicChannel(), + super(bridge::currentPublicChannel, ConfigEntry.DISCORD_CHANNEL_FORMAT.getString(), ConfigEntry.DISCORD_CHAT_FORMAT.getString(), component -> FUtil.bcastMsg(component), diff --git a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordCommands.java b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordCommands.java index dea62ea0d..af6cdf3f9 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordCommands.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordCommands.java @@ -1,22 +1,31 @@ package me.totalfreedom.totalfreedommod.discord; import java.sql.SQLException; +import java.util.Optional; import java.util.UUID; + +import discord4j.core.GatewayDiscordClient; +import discord4j.core.event.domain.interaction.ChatInputInteractionEvent; +import discord4j.core.object.command.ApplicationCommandInteractionOption; +import discord4j.core.object.command.ApplicationCommandInteractionOptionValue; +import discord4j.core.spec.InteractionApplicationCommandCallbackSpec; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.admin.Admin; import me.totalfreedom.totalfreedommod.sql.adapter.DiscordLinkRepository; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.PlayerListUtil; -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.hooks.ListenerAdapter; -import net.dv8tion.jda.api.interactions.commands.OptionMapping; -import org.bukkit.Bukkit; -import org.jetbrains.annotations.NotNull; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; /** - * JDA slash command handlers for {@code /list}, {@code /link}, {@code /unlink}. + * Slash command handlers for {@code /list}, {@code /link}, {@code /unlink}. + * <p> + * Two threading rules run through all of this. Repository work is JDBC and blocks, so it goes onto + * {@code boundedElastic} rather than running where the interaction arrived; and anything reading + * server state hops to the main thread through {@link DiscordBridge#mainThread()}. Neither was a + * concern under JDA, whose listener threads tolerated both. */ -public class DiscordCommands extends ListenerAdapter +public class DiscordCommands { private final TotalFreedomMod plugin; @@ -28,82 +37,112 @@ public DiscordCommands(TotalFreedomMod plugin, DiscordBridge bridge) this.bridge = bridge; } - @Override - public void onSlashCommandInteraction(@NotNull SlashCommandInteractionEvent event) + /** + * The command half of the connection. Each interaction gets its own error boundary: an escaping + * error would terminate the stream, and Discord would then show every later command timing out + * with nothing in the log to say why. + */ + public Mono<Void> bind(final GatewayDiscordClient gateway) { - switch (event.getName()) + return gateway.on(ChatInputInteractionEvent.class) + .flatMap(event -> handle(event).onErrorResume(thrown -> + { + FLog.warning(String.format( + "[Discord] /%s failed: %s", + event.getCommandName(), + DiscordConnection.describeFailure(thrown) + )); + return Mono.empty(); + })) + .then(); + } + + private Mono<Void> handle(final ChatInputInteractionEvent event) + { + return switch (event.getCommandName()) { case "list" -> handleList(event); case "link" -> handleLink(event); case "unlink" -> handleUnlink(event); - default -> - { - } - } + default -> Mono.empty(); + }; } - private void handleList(SlashCommandInteractionEvent event) + private Mono<Void> handleList(final ChatInputInteractionEvent event) { - event.deferReply().queue(); - Bukkit.getScheduler().runTask(plugin, () -> - { - String body = "```\n" + PlayerListUtil.buildRankList() + "\n```"; - event.getHook().sendMessage(body).queue(); - }); + return event.deferReply() + .then(Mono.fromCallable(() -> "```\n" + PlayerListUtil.buildRankList() + "\n```") + .subscribeOn(bridge.mainThread())) + .flatMap(body -> event.createFollowup(body).then()); } - private void handleLink(SlashCommandInteractionEvent event) + private Mono<Void> handleLink(final ChatInputInteractionEvent event) { - OptionMapping codeOption = event.getOption("code"); - if (codeOption == null) - { - event.reply("Missing `code` argument.").setEphemeral(true).queue(); - return; - } - String code = codeOption.getAsString().trim().toUpperCase(); - UUID adminUuid = bridge.consumePendingLink(code); - if (adminUuid == null) - { - event.reply("That code is unknown or expired. Run `/link` in-game to get a fresh one.") - .setEphemeral(true).queue(); - return; - } + final Optional<String> code = event.getOption("code") + .flatMap(ApplicationCommandInteractionOption::getValue) + .map(ApplicationCommandInteractionOptionValue::asString); - Admin admin = plugin.al.getAdminByUuid(adminUuid); - if (admin == null) - { - event.reply("Internal error: admin record for the code is gone. Try again.") - .setEphemeral(true).queue(); - return; - } + if (code.isEmpty()) + return replyPrivately(event, "Missing `code` argument."); + + final Optional<UUID> pendingUuid = bridge.consumePendingLink(code.get().trim().toUpperCase()); + if (pendingUuid.isEmpty()) + return replyPrivately(event, "That code is unknown or expired. Run `/link` in-game to get a fresh one."); + + final UUID adminUuid = pendingUuid.get(); + final Optional<Admin> admin = Optional.ofNullable(plugin.al.getAdminByUuid(adminUuid)); + if (admin.isEmpty()) + return replyPrivately(event, "Internal error: admin record for the code is gone. Try again."); + + final Admin linkedAdmin = admin.get(); + final String discordUserId = event.getInteraction().getUser().getId().asString(); + + return Mono.fromCallable(() -> persistLink(linkedAdmin, adminUuid, discordUserId)) + .subscribeOn(Schedulers.boundedElastic()) + .flatMap(saved -> saved + ? replyPrivately(event, + "Linked as **" + linkedAdmin.getName() + "** (" + linkedAdmin.getRankId() + ").") + : replyPrivately(event, "Couldn't save the link — see server log. Try again.")); + } - DiscordLinkRepository repo = plugin.dm.getDiscordLinkRepository(); + private boolean persistLink(final Admin admin, final UUID adminUuid, final String discordUserId) + { + final DiscordLinkRepository repo = plugin.dm.getDiscordLinkRepository(); try { repo.deleteByAdminUuid(adminUuid); - repo.deleteByDiscordUserId(event.getUser().getId()); - repo.insert(adminUuid, event.getUser().getId()); + repo.deleteByDiscordUserId(discordUserId); + repo.insert(adminUuid, discordUserId); } catch (SQLException ex) { FLog.warning("[Discord] /link failed for " + admin.getName() + ": " + ex.getMessage()); - event.reply("Couldn't save the link — see server log. Try again.") - .setEphemeral(true).queue(); - return; + return false; } DiscordLinkJsonSync.writeSnapshot(plugin, repo); + FLog.info("[Discord] Linked admin " + admin.getName() + " ↔ Discord user " + discordUserId + "."); + return true; + } - event.reply("Linked as **" + admin.getName() + "** (" + admin.getRankId() + ").") - .setEphemeral(true).queue(); - FLog.info("[Discord] Linked admin " + admin.getName() + " ↔ Discord user " + event.getUser().getId() + "."); + private Mono<Void> handleUnlink(final ChatInputInteractionEvent event) + { + final String discordUserId = event.getInteraction().getUser().getId().asString(); + + return Mono.fromCallable(() -> removeLink(discordUserId)) + .subscribeOn(Schedulers.boundedElastic()) + .flatMap(outcome -> replyPrivately(event, switch (outcome) + { + case REMOVED -> "Link removed."; + case NOT_LINKED -> "You aren't linked."; + case FAILED -> "Couldn't remove the link — see server log."; + })); } - private void handleUnlink(SlashCommandInteractionEvent event) + private UnlinkOutcome removeLink(final String discordUserId) { - String discordUserId = event.getUser().getId(); - DiscordLinkRepository repo = plugin.dm.getDiscordLinkRepository(); - boolean removed; + final DiscordLinkRepository repo = plugin.dm.getDiscordLinkRepository(); + final boolean removed; try { removed = repo.deleteByDiscordUserId(discordUserId); @@ -111,19 +150,29 @@ private void handleUnlink(SlashCommandInteractionEvent event) catch (SQLException ex) { FLog.warning("[Discord] /unlink failed for " + discordUserId + ": " + ex.getMessage()); - event.reply("Couldn't remove the link — see server log.") - .setEphemeral(true).queue(); - return; - } - if (removed) - { - DiscordLinkJsonSync.writeSnapshot(plugin, repo); - event.reply("Link removed.").setEphemeral(true).queue(); - FLog.info("[Discord] Unlinked Discord user " + discordUserId + "."); - } - else - { - event.reply("You aren't linked.").setEphemeral(true).queue(); + return UnlinkOutcome.FAILED; } + + if (!removed) + return UnlinkOutcome.NOT_LINKED; + + DiscordLinkJsonSync.writeSnapshot(plugin, repo); + FLog.info("[Discord] Unlinked Discord user " + discordUserId + "."); + return UnlinkOutcome.REMOVED; + } + + private static Mono<Void> replyPrivately(final ChatInputInteractionEvent event, final String message) + { + return event.reply(InteractionApplicationCommandCallbackSpec.builder() + .content(message) + .ephemeral(true) + .build()); + } + + private enum UnlinkOutcome + { + REMOVED, + NOT_LINKED, + FAILED } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordConnection.java b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordConnection.java new file mode 100644 index 000000000..13a364477 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordConnection.java @@ -0,0 +1,170 @@ +package me.totalfreedom.totalfreedommod.discord; + +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; + +import discord4j.core.DiscordClient; +import discord4j.core.GatewayDiscordClient; +import discord4j.gateway.intent.IntentSet; +import discord4j.gateway.intent.Intent; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.FLog; +import reactor.core.Disposable; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; +import reactor.util.retry.Retry; + +/** + * Owns the bridge's gateway connection and its reconnect budget. + * <p> + * Attempts are spaced by {@code discord.reconnect.interval_seconds} and capped at + * {@code discord.reconnect.max_attempts} <em>consecutive</em> failures. Any successful connection + * resets the count, so a server that drops once an hour retries forever, while one that cannot + * reach Discord at all stops after the cap and stays down until restarted rather than reconnecting + * in a loop against a token or an outage that is not going to recover on its own. + */ +public final class DiscordConnection +{ + private static final int MIN_INTERVAL_SECONDS = 5; + + private final int maxAttempts; + private final int retrySeconds; + private final Duration retryInterval; + private final AtomicInteger consecutiveFailures = new AtomicInteger(); + private final AtomicBoolean stopping = new AtomicBoolean(); + private final AtomicBoolean givenUp = new AtomicBoolean(); + + private volatile Optional<Disposable> subscription = Optional.empty(); + + public DiscordConnection() + { + this.maxAttempts = Math.max(1, ConfigEntry.DISCORD_RECONNECT_MAX_ATTEMPTS.getInteger(5)); + this.retrySeconds = Math.max(MIN_INTERVAL_SECONDS, + ConfigEntry.DISCORD_RECONNECT_INTERVAL_SECONDS.getInteger(30)); + this.retryInterval = Duration.ofSeconds(retrySeconds); + } + + /** + * Open the gateway and keep it open, reconnecting within budget. + * + * @param token bot token + * @param handler builds the per-connection work + */ + public void start(final String token, final Function<GatewayDiscordClient, Mono<Void>> handler) + { + subscription = Optional.of(DiscordClient.builder(token) + .build() + .gateway() + .setEnabledIntents(IntentSet.of(Intent.GUILD_MESSAGES, + Intent.MESSAGE_CONTENT, + Intent.DIRECT_MESSAGES)) + .withGateway(handler::apply) + .then(Mono.error(DiscordConnection::disconnected)) + .retryWhen(budget()) + .doOnError(this::giveUp) + .onErrorComplete() + .subscribeOn(Schedulers.boundedElastic()) + .subscribe() + ); + } + + /** + * Disposing the subscription cancels the scope, and {@code withGateway} logs out as part of + * unwinding it. Cancellation is not an error signal, so this does not reach the retry spec + * even before {@link #stopping} is consulted. + */ + public void stop() + { + stopping.set(true); + + final Optional<Disposable> current = subscription; + subscription = Optional.empty(); + + current.filter(active -> !active.isDisposed()) + .ifPresent(Disposable::dispose); + } + + /** + * A connection reached ready. Clears the budget so an unrelated drop later gets a full set of + * attempts of its own. + */ + public void reportConnected() + { + final int spent = consecutiveFailures.getAndSet(0); + + if (spent > 0) + FLog.info(String.format("[Discord] Reconnected after %d failed attempt(s).", spent)); + } + + /** + * Whether the budget is spent. The bridge stays down for the rest of this run once true. + */ + public boolean hasGivenUp() + { + return givenUp.get(); + } + + public boolean isStopping() + { + return stopping.get(); + } + + /** + * {@code fixedDelay} with an unbounded cap plus a filter, rather than a bounded cap, because + * the cap has to count <em>consecutive</em> failures. Reactor's counter never resets for a + * source that emits nothing, so the budget check lives in the filter against a counter the + * bridge resets on every successful connection. + */ + private Retry budget() + { + return Retry.fixedDelay(Long.MAX_VALUE, retryInterval) + .filter(thrown -> + { + if (stopping.get() || givenUp.get()) + return false; + + final int attempt = consecutiveFailures.incrementAndGet(); + if (attempt >= maxAttempts) + return false; + + FLog.warning(String.format("[Discord] Connection failure %d/%d (%s); retrying in %ds.", + attempt, maxAttempts, describeFailure(thrown), retrySeconds)); + return true; + }); + } + + private void giveUp(final Throwable thrown) + { + if (stopping.get() || !givenUp.compareAndSet(false, true)) + return; + + FLog.severe(String.format( + """ + [Discord] Giving up after %d consecutive failed connection attempts %ds apart. + Last failure: %s. + """, + + consecutiveFailures.get(), + retrySeconds, + describeFailure(thrown) + )); + } + + private static Throwable disconnected() + { + return new IllegalStateException("gateway closed"); + } + + public static String describeFailure(final Throwable thrown) + { + return Optional.ofNullable(thrown) + .map(failure -> Optional.ofNullable(failure.getMessage()) + .filter(message -> !message.isBlank()) + .map(message -> String.format("%s: %s", failure.getClass().getSimpleName(), message)) + .orElseGet(() -> failure.getClass().getSimpleName())) + .orElse("unknown failure"); + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordConsoleRelay.java b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordConsoleRelay.java index 79399ccc3..3690642f8 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordConsoleRelay.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordConsoleRelay.java @@ -3,7 +3,15 @@ import java.sql.SQLException; import java.util.ArrayDeque; import java.util.Deque; +import java.util.Optional; import java.util.UUID; + +import discord4j.common.util.Snowflake; +import discord4j.core.GatewayDiscordClient; +import discord4j.core.event.domain.message.MessageCreateEvent; +import discord4j.core.object.emoji.Emoji; +import discord4j.core.object.entity.Message; +import discord4j.core.object.entity.User; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.admin.Admin; import me.totalfreedom.totalfreedommod.config.ConfigEntry; @@ -12,194 +20,318 @@ import me.totalfreedom.totalfreedommod.util.CallbackLogAppender; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FTask; -import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; -import net.dv8tion.jda.api.events.message.MessageReceivedEvent; -import net.dv8tion.jda.api.hooks.ListenerAdapter; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.core.Logger; import org.bukkit.Bukkit; import org.bukkit.scheduler.BukkitTask; -import org.jetbrains.annotations.NotNull; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; /** * Streams server log output into the Discord console channel and dispatches * plain Minecraft commands typed into that channel as the linked admin. */ -public class DiscordConsoleRelay extends ListenerAdapter +public class DiscordConsoleRelay { /** Discord hard message-length limit. We pack lines into chunks below this. */ private static final int DISCORD_MAX_MESSAGE_LENGTH = 1900; + /** Length of the {@code ```ansi\n} ... {@code \n```} wrapper each chunk is sent inside. */ + private static final int CODE_FENCE_LENGTH = 12; + + /** Longest chunk that still fits inside the fence. */ + private static final int MAX_CHUNK_LENGTH = DISCORD_MAX_MESSAGE_LENGTH - CODE_FENCE_LENGTH; + + private static final int DEFAULT_QUEUE_LIMIT = 2000; + private static final int DEFAULT_FLUSH_MS = 1500; + private static final int MIN_FLUSH_MS = 250; + + /** + * Loggers whose output should never be relayed. + */ + private static final String[] EXCLUDED_LOGGERS = {"discord4j", "reactor", "io.netty"}; + + /** + * Set while this relay logs its own failures. + */ + private static final ThreadLocal<Boolean> SUPPRESS_CAPTURE = ThreadLocal.withInitial(() -> Boolean.FALSE); + private final TotalFreedomMod plugin; private final DiscordBridge bridge; private final Deque<String> pendingLines = new ArrayDeque<>(); private final Object pendingLock = new Object(); - private CallbackLogAppender logAppender; - private BukkitTask flushTask; + private final int queueLimit; + + /** Lines discarded because the queue was full, reported in the next successful flush. */ + private int droppedLines; + + private volatile Optional<CallbackLogAppender> logAppender = Optional.empty(); + private volatile Optional<BukkitTask> flushTask = Optional.empty(); public DiscordConsoleRelay(TotalFreedomMod plugin, DiscordBridge bridge) { this.plugin = plugin; this.bridge = bridge; + this.queueLimit = Math.max(64, ConfigEntry.DISCORD_CONSOLE_QUEUE_LIMIT.getInteger(DEFAULT_QUEUE_LIMIT)); + } + + public Mono<Void> bind(final GatewayDiscordClient gateway) + { + return gateway.on(MessageCreateEvent.class) + .filter(this::isConsoleCommand) + .flatMap(event -> handleCommand(event).onErrorResume(thrown -> + { + warnWithoutCapture(String.format("[Discord] Console command failed: %s", + DiscordConnection.describeFailure(thrown))); + return Mono.empty(); + })) + .then(); } void attachAppender() { - if (bridge.getConsoleChannel() == null) - { + if (bridge.currentConsoleChannel().isEmpty()) return; - } - Integer flushConfig = ConfigEntry.DISCORD_CONSOLE_FLUSH.getInteger(); - int flushMs = flushConfig == null || flushConfig < 250 ? 1500 : flushConfig; - long ticks = Math.max(1L, flushMs / 50L); - logAppender = new CallbackLogAppender("DiscordConsoleAppender", (line, level) -> + final int flushMs = Optional.ofNullable(ConfigEntry.DISCORD_CONSOLE_FLUSH.getInteger()) + .filter(configured -> configured >= MIN_FLUSH_MS) + .orElse(DEFAULT_FLUSH_MS); + final long ticks = Math.max(1L, flushMs / 50L); + + final CallbackLogAppender appender = new CallbackLogAppender("DiscordConsoleAppender", (line, level) -> enqueue(line)).excludeLoggers(EXCLUDED_LOGGERS); + + appender.start(); + + ((Logger) LogManager.getRootLogger()).addAppender(appender); + logAppender = Optional.of(appender); + + flushTask = Optional.of(Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, + FTask.guard("DiscordConsoleRelay/flush", this::flush), ticks, ticks)); + } + + void detachAppender() + { + logAppender.ifPresent(appender -> { - synchronized (pendingLock) - { - pendingLines.add(line); - } + ((Logger) LogManager.getRootLogger()).removeAppender(appender); + appender.stop(); }); - logAppender.start(); - ((Logger) LogManager.getRootLogger()).addAppender(logAppender); + logAppender = Optional.empty(); + + flushTask.ifPresent(BukkitTask::cancel); + flushTask = Optional.empty(); + + synchronized (pendingLock) + { + pendingLines.clear(); + droppedLines = 0; + } + } + + private boolean isConsoleCommand(final MessageCreateEvent event) + { + if (event.getGuildId().isEmpty()) + return false; - flushTask = Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, - FTask.guard("DiscordConsoleRelay/flush", this::flush), ticks, ticks); + final Optional<User> author = event.getMessage().getAuthor(); + if (author.isEmpty() || author.get().isBot()) + return false; + + final Optional<Snowflake> channel = bridge.currentConsoleChannel(); + return channel.isPresent() && channel.get().equals(event.getMessage().getChannelId()); } - void detachAppender() + /** + * Resolve the author to a linked admin and run their command. + * <p> + * The repository lookup is JDBC, so it is pushed onto {@code boundedElastic} rather than run + * where the event arrived. + */ + private Mono<Void> handleCommand(final MessageCreateEvent event) + { + final Message message = event.getMessage(); + final String content = message.getContent().trim(); + if (content.isEmpty()) + return Mono.empty(); + + final String commandLine = content.startsWith("/") ? content.substring(1) : content; + if (commandLine.isEmpty()) + return Mono.empty(); + + return Mono.justOrEmpty(message.getAuthor() + .map(User::getId) + .map(Snowflake::asString)) + .flatMap(discordUserId -> Mono.fromCallable(() -> resolveAdmin(discordUserId)) + .subscribeOn(Schedulers.boundedElastic())) + .flatMap(resolution -> resolution.admin() + .map(admin -> dispatch(admin, commandLine)) + .orElseGet(() -> react(message, resolution.reaction()))); + } + + private AdminResolution resolveAdmin(final String discordUserId) { - if (logAppender != null) + final Optional<UUID> adminUuid; + try { - ((Logger) LogManager.getRootLogger()).removeAppender(logAppender); - logAppender.stop(); - logAppender = null; + adminUuid = Optional.ofNullable(plugin.dm.getDiscordLinkRepository().findAdminUuidByDiscordId(discordUserId)); } - if (flushTask != null) + catch (SQLException ex) + { + warnWithoutCapture("[Discord] discord_links lookup failed: " + ex.getMessage()); + return AdminResolution.lookupFailed(); + } + + return adminUuid.flatMap(uuid -> Optional.ofNullable(plugin.al.getAdminByUuid(uuid))) + .filter(Admin::isActive) + .map(AdminResolution::linked) + .orElseGet(AdminResolution::notLinked); + } + + private Mono<Void> dispatch(final Admin admin, final String commandLine) + { + final String displayName = "Discord@" + admin.getName(); + final RemoteDispatchSession session = new RemoteDispatchSession(RemoteDispatchSession.Channel.DISCORD, + admin.getName(), + displayName, + true); + + return Mono.<Void>fromRunnable(() -> + { + FLog.info("[Discord: " + admin.getName() + "] /" + commandLine); + RemoteDispatchContext.dispatch(session, commandLine); + }) + .subscribeOn(bridge.mainThread()); + } + + /** + * Queue one captured log line, dropping the oldest when the queue is full. + */ + private void enqueue(String line) + { + if (Boolean.TRUE.equals(SUPPRESS_CAPTURE.get())) { - flushTask.cancel(); - flushTask = null; + return; } + synchronized (pendingLock) { - pendingLines.clear(); + while (pendingLines.size() >= queueLimit) + { + pendingLines.pollFirst(); + droppedLines++; + } + pendingLines.addLast(line); } } private void flush() { - TextChannel channel = bridge.getConsoleChannel(); - if (channel == null) - { + final Optional<DiscordSession> current = bridge.getSession(); + final Optional<Snowflake> channel = bridge.currentConsoleChannel(); + if (current.isEmpty() || channel.isEmpty()) return; - } + String chunk = drainChunk(); + if (chunk.isEmpty()) + return; + + String body = "```ansi\n" + chunk + "\n```"; + current.get() + .channel(channel) + .flatMap(target -> target.createMessage(body)) + .subscribe( + sent -> + { + }, + err -> warnWithoutCapture("[Discord] Console flush failed: " + err.getMessage())); + } + + /** + * Take as many queued lines as fit in one message. + */ + private String drainChunk() + { StringBuilder chunk = new StringBuilder(); + int dropped; + synchronized (pendingLock) { + dropped = droppedLines; + droppedLines = 0; + + if (dropped > 0) + chunk.append(String.format("... %d line(s) dropped, console output is falling behind ...", dropped)); + while (!pendingLines.isEmpty()) { - String line = pendingLines.peekFirst(); - // Reserve 12 chars for the ```ansi\n ... \n``` wrapper. - int candidate = chunk.length() + line.length() + 1; - if (candidate > DISCORD_MAX_MESSAGE_LENGTH - 12) - { + String line = truncateToFit(pendingLines.peekFirst()); + + if (!chunk.isEmpty() && chunk.length() + 1 + line.length() > MAX_CHUNK_LENGTH) break; - } + pendingLines.pollFirst(); + if (!chunk.isEmpty()) - { chunk.append('\n'); - } - // Truncate single oversized lines. - if (line.length() > DISCORD_MAX_MESSAGE_LENGTH - 16) - { - line = line.substring(0, DISCORD_MAX_MESSAGE_LENGTH - 16) + "…"; - } + chunk.append(line); } } - if (chunk.isEmpty()) - { - return; - } - String body = "```ansi\n" + chunk + "\n```"; - channel.sendMessage(body).queue( - null, - err -> FLog.warning("[Discord] Console flush failed: " + err.getMessage()) - ); + return chunk.toString(); } - @Override - public void onMessageReceived(@NotNull MessageReceivedEvent event) + private static String truncateToFit(String line) { - if (event.getAuthor().isBot() || event.getAuthor().isSystem()) - { - return; - } - TextChannel channel = bridge.getConsoleChannel(); - if (channel == null || !event.isFromGuild()) - { - return; - } - if (!event.getChannel().getId().equals(channel.getId())) - { - return; - } - - String content = event.getMessage().getContentRaw().trim(); - if (content.isEmpty()) - { - return; - } + return line.length() <= MAX_CHUNK_LENGTH + ? line + : line.substring(0, MAX_CHUNK_LENGTH - 1) + "…"; + } - String discordUserId = event.getAuthor().getId(); - UUID adminUuid; + /** + * Log a relay failure without that log line being captured and queued for delivery to the + * channel that just failed. + */ + private static void warnWithoutCapture(String message) + { + SUPPRESS_CAPTURE.set(Boolean.TRUE); try { - adminUuid = plugin.dm.getDiscordLinkRepository().findAdminUuidByDiscordId(discordUserId); + FLog.warning(message); } - catch (SQLException ex) + finally { - FLog.warning("[Discord] discord_links lookup failed: " + ex.getMessage()); - event.getMessage().addReaction(net.dv8tion.jda.api.entities.emoji.Emoji.fromUnicode("⚠️")).queue(); - return; + SUPPRESS_CAPTURE.set(Boolean.FALSE); } + } - if (adminUuid == null) - { - event.getMessage().addReaction(net.dv8tion.jda.api.entities.emoji.Emoji.fromUnicode("❌")).queue(); - return; - } + private static Mono<Void> react(final Message message, final String emoji) + { + return message.addReaction(Emoji.unicode(emoji)) + .onErrorResume(ignored -> Mono.empty()); + } - Admin admin = plugin.al.getAdminByUuid(adminUuid); - if (admin == null || !admin.isActive()) + /** + * Either the admin a console message should run as, or the reaction to leave on it saying why it will not run. + */ + private record AdminResolution(Optional<Admin> admin, String reaction) + { + private static AdminResolution linked(final Admin admin) { - event.getMessage().addReaction(net.dv8tion.jda.api.entities.emoji.Emoji.fromUnicode("❌")).queue(); - return; + return new AdminResolution(Optional.of(admin), ""); } - String commandLine = content.startsWith("/") ? content.substring(1) : content; - if (commandLine.isEmpty()) + private static AdminResolution notLinked() { - return; + return new AdminResolution(Optional.empty(), "❌"); } - String displayName = "Discord@" + admin.getName(); - RemoteDispatchSession session = new RemoteDispatchSession( - RemoteDispatchSession.Channel.DISCORD, - admin.getName(), - displayName, - true); - - Bukkit.getScheduler().runTask(plugin, () -> + private static AdminResolution lookupFailed() { - FLog.info("[Discord: " + admin.getName() + "] /" + commandLine); - RemoteDispatchContext.dispatch(session, commandLine); - }); + return new AdminResolution(Optional.empty(), "⚠️"); + } } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordLinkJsonSync.java b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordLinkJsonSync.java index 40d904466..a834862a8 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordLinkJsonSync.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordLinkJsonSync.java @@ -18,8 +18,6 @@ /** * JSON write-through + startup reconciliation for admin-uuid to Discord-user-id links. - * There is no in-memory manager for this domain (DiscordCommands talks to the repository - * directly), so this holds the snapshot file logic on its own. */ final class DiscordLinkJsonSync { @@ -81,23 +79,23 @@ static void reconcileFromJsonIfNewer(TotalFreedomMod plugin, DiscordLinkReposito final long fileModified = file.lastModified(); WRITES.enqueue(repo.getMaxUpdatedAtAsync() - .map(sqlUpdatedAt -> fileModified > sqlUpdatedAt) - .defaultIfEmpty(Boolean.TRUE) - .filter(Boolean::booleanValue) - .flatMap(ignored -> - { - FLog.info(String.format("%s is newer than the database; re-importing %d discord link(s) from it.", - DATA_FILENAME, jsonLinks.size())); - return Flux.fromIterable(jsonLinks.entrySet()) - .concatMap(entry -> repo.relinkAsync(UUID.fromString(entry.getKey()), entry.getValue())) - .then(); - }) - .onErrorResume(ex -> - { - FLog.warning(String.format("Failed to reconcile %s into the database: %s", - DATA_FILENAME, ex.getMessage())); - return Mono.empty(); - }) - .then()); + .map(sqlUpdatedAt -> fileModified > sqlUpdatedAt) + .defaultIfEmpty(Boolean.TRUE) + .filter(Boolean::booleanValue) + .flatMap(ignored -> + { + FLog.info(String.format("%s is newer than the database; re-importing %d discord link(s) from it.", + DATA_FILENAME, jsonLinks.size())); + return Flux.fromIterable(jsonLinks.entrySet()) + .concatMap(entry -> repo.relinkAsync(UUID.fromString(entry.getKey()), entry.getValue())) + .then(); + }) + .onErrorResume(ex -> + { + FLog.warning(String.format("Failed to reconcile %s into the database: %s", + DATA_FILENAME, ex.getMessage())); + return Mono.empty(); + }) + .then()); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordSession.java b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordSession.java new file mode 100644 index 000000000..4cf6b86bb --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordSession.java @@ -0,0 +1,54 @@ +package me.totalfreedom.totalfreedommod.discord; + +import java.util.Optional; + +import discord4j.common.util.Snowflake; +import discord4j.core.GatewayDiscordClient; +import discord4j.core.object.entity.channel.MessageChannel; +import reactor.core.publisher.Mono; + +/** + * The bridge publishes this through one volatile reference and never mutates it. + * One reference means a reader either sees a whole connection or sees none, and reconnecting is a single assignment rather than five. + * <p> + * Channels are held as {@link Snowflake} ids rather than entity objects. Discord4J entities carry a + * reference to the client that produced them, so holding one across a reconnect would leave a relay + * writing through a gateway that no longer exists. + * + * @param gateway the client this connection belongs to + * @param guildId configured guild, already verified to exist + * @param guildName resolved once for log lines, so logging never needs a fetch + * @param publicChannel public chat relay channel, absent when unconfigured or unresolvable + * @param adminchatChannel admin chat relay channel, absent when unconfigured or unresolvable + * @param consoleChannel console relay channel, absent when unconfigured or unresolvable + * @param channelSummary resolved channel names for the startup log line + */ +public record DiscordSession( + GatewayDiscordClient gateway, + Snowflake guildId, + String guildName, + Optional<Snowflake> publicChannel, + Optional<Snowflake> adminchatChannel, + Optional<Snowflake> consoleChannel, + String channelSummary) +{ + /** + * Resolve one of this connection's channels for sending. + * <p> + * Empty rather than an error when the channel is unconfigured or has since been deleted. + */ + public Mono<MessageChannel> channel(final Optional<Snowflake> id) + { + return id.map(gateway::getChannelById) + .orElseGet(Mono::empty) + .ofType(MessageChannel.class); + } + + /** + * Describes the resolved channels for the startup log line. + */ + public String describeChannels() + { + return channelSummary; + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/util/CallbackLogAppender.java b/src/main/java/me/totalfreedom/totalfreedommod/util/CallbackLogAppender.java index 47d27e348..6cb8251be 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/util/CallbackLogAppender.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/util/CallbackLogAppender.java @@ -2,6 +2,9 @@ import java.text.SimpleDateFormat; import java.util.Date; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Stream; import org.apache.logging.log4j.Level; import org.apache.logging.log4j.core.LogEvent; import org.apache.logging.log4j.core.appender.AbstractAppender; @@ -23,17 +26,31 @@ public interface LogLineConsumer private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("HH:mm:ss"); private final LogLineConsumer consumer; + private volatile String[] excludedLoggerPrefixes = {}; public CallbackLogAppender(String name, LogLineConsumer consumer) { super(name, null, PatternLayout.createDefaultLayout(), true, Property.EMPTY_ARRAY); - this.consumer = consumer; + this.consumer = Objects.requireNonNull(consumer, "consumer"); + } + + /** + * Drop events from loggers whose name starts with any of {@code prefixes}. + * + * @return this appender, so the exclusions can be set where it is constructed + */ + public CallbackLogAppender excludeLoggers(String... prefixes) + { + this.excludedLoggerPrefixes = Optional.ofNullable(prefixes) + .map(String[]::clone) + .orElseGet(() -> new String[0]); + return this; } @Override public void append(LogEvent event) { - if (consumer == null) + if (isExcluded(event.getLoggerName())) { return; } @@ -58,4 +75,11 @@ public void append(LogEvent event) { } } + + private boolean isExcluded(String loggerName) + { + return Optional.ofNullable(loggerName) + .filter(name -> Stream.of(excludedLoggerPrefixes).anyMatch(name::startsWith)) + .isPresent(); + } } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 9772bb421..0ba71a2ed 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -662,9 +662,20 @@ discord: # Console-channel log flush interval, in milliseconds (ms). console_flush: 1500 + # Maximum console lines held while waiting to be flushed. + console_queue_limit: 2000 + # How long an in-game /link code remains valid before expiring, in seconds. link_code_ttl: 300 + # Recovery after the gateway drops. + reconnect: + # Seconds between reconnect attempts. + interval_seconds: 30 + + # Consecutive failed attempts before the bridge gives up and stays down until the server is restarted. + max_attempts: 5 + # Messages posted to the public relay for server and player events. messages: server_startup: '***Server is now online.***' From d3d304494fc29fd19d642d6007273596d4e56c46 Mon Sep 17 00:00:00 2001 From: Paldiu <pawereus@gmail.com> Date: Wed, 5 Aug 2026 03:34:50 -0500 Subject: [PATCH 25/48] add table for signspy --- .../totalfreedom/totalfreedommod/SignSpy.java | 2 +- .../totalfreedommod/cmd/Command_signspy.java | 3 +-- .../generic/GenericPlayerRepository.java | 20 ++++++++++++------- .../sql/adapter/mysql/MySQLAdapter.java | 2 ++ .../adapter/postgresql/PostgreSQLAdapter.java | 3 +++ .../sql/adapter/sqlite/SQLiteAdapter.java | 2 ++ 6 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/SignSpy.java b/src/main/java/me/totalfreedom/totalfreedommod/SignSpy.java index 663dc542a..cb91d179c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/SignSpy.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/SignSpy.java @@ -9,7 +9,7 @@ import io.papermc.paper.math.Position; import me.totalfreedom.totalfreedommod.player.PlayerData; -import me.totalfreedom.totalfreedommod.rank.Displayable; +import me.totalfreedom.totalfreedommod.display.Displayable; import me.totalfreedom.totalfreedommod.util.AdventureUtil; import me.totalfreedom.totalfreedommod.util.FTask; import me.totalfreedom.totalfreedommod.util.FUtil; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_signspy.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_signspy.java index e206548f9..962872c01 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_signspy.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_signspy.java @@ -4,11 +4,10 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.player.PlayerData; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; @Command(name = "signspy", description = "Spy on sign edits", usage = "/signspy", aliases = {"sspy"}) -@Permission(permission = "tfm.admin.signspy", level = Rank.SUPER_ADMIN, source = SourceType.ONLY_IN_GAME) +@Permission(permission = "tfm.admin.signspy", source = SourceType.ONLY_IN_GAME) public class Command_signspy extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java index 440cb369e..86eee6554 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java @@ -29,6 +29,7 @@ public class GenericPlayerRepository implements PlayerRepository private final String colFirstJoin; private final String colLastJoin; private final String colPotionSpy; + private final String colSignSpy; private final String colCommandSpyMode; private final String colMuted; private final String colFrozen; @@ -54,6 +55,7 @@ public GenericPlayerRepository(StatementHandler statementHandler, DatabaseAdapte this.colFirstJoin = adapter.quoteIdentifier("first_join_unix"); this.colLastJoin = adapter.quoteIdentifier("last_join_unix"); this.colPotionSpy = adapter.quoteIdentifier("potion_spy"); + this.colSignSpy = adapter.quoteIdentifier("sign_spy"); this.colCommandSpyMode = adapter.quoteIdentifier("command_spy_mode"); this.colMuted = adapter.quoteIdentifier("muted"); this.colFrozen = adapter.quoteIdentifier("frozen"); @@ -66,16 +68,16 @@ public GenericPlayerRepository(StatementHandler statementHandler, DatabaseAdapte this.colPlayerUsername = adapter.quoteIdentifier("username"); this.colIp = adapter.quoteIdentifier("ip"); this.colUpdatedAt = adapter.quoteIdentifier("updated_at"); - this.selectColumns = String.format("%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s", - colUsername, colFirstJoin, colLastJoin, colPotionSpy, colCommandSpyMode, colMuted, colFrozen, - colCommandsBlocked, colStrikes, colSavedTag, colTitles) + this.selectColumns = String.format("%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s", + colUsername, colFirstJoin, colLastJoin, colPotionSpy, colSignSpy, colCommandSpyMode, colMuted, + colFrozen, colCommandsBlocked, colStrikes, colSavedTag, colTitles) + ", " + colNickname; } @Override public void insert(PlayerData data) throws SQLException { - String sql = String.format("INSERT INTO %s (%s, %s) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, %s)", + String sql = String.format("INSERT INTO %s (%s, %s) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, %s)", tblPlayers, selectColumns, colUpdatedAt, adapter.currentTimestamp()); statementHandler.executeUpdate(sql, @@ -83,6 +85,7 @@ public void insert(PlayerData data) throws SQLException data.getFirstJoinUnix(), data.getLastJoinUnix(), data.isPotionSpy(), + data.isSignSpy(), data.getCommandSpyMode().getName(), data.isMuted(), data.isFrozen(), @@ -194,14 +197,16 @@ public List<String> getIps(String username) throws SQLException @Override public boolean update(PlayerData data) throws SQLException { - String sql = String.format("UPDATE %s SET %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = %s WHERE %s = ?", - tblPlayers, colFirstJoin, colLastJoin, colPotionSpy, colCommandSpyMode, colMuted, colFrozen, - colCommandsBlocked, colStrikes, colSavedTag, colTitles, colUpdatedAt, adapter.currentTimestamp(), colUsername); + String sql = String.format("UPDATE %s SET %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = %s WHERE %s = ?", + tblPlayers, colFirstJoin, colLastJoin, colPotionSpy, colSignSpy, colCommandSpyMode, colMuted, + colFrozen, colCommandsBlocked, colStrikes, colSavedTag, colTitles, colUpdatedAt, + adapter.currentTimestamp(), colUsername); int rows = statementHandler.executeUpdate(sql, data.getFirstJoinUnix(), data.getLastJoinUnix(), data.isPotionSpy(), + data.isSignSpy(), data.getCommandSpyMode().getName(), data.isMuted(), data.isFrozen(), @@ -299,6 +304,7 @@ private PlayerData loadPlayerFromRow(ResultSet rs) throws SQLException data.setFirstJoinUnix(rs.getLong("first_join_unix")); data.setLastJoinUnix(rs.getLong("last_join_unix")); data.setPotionSpy(rs.getBoolean("potion_spy")); + data.setSignSpy(rs.getBoolean("sign_spy")); data.setCommandSpyMode(CommandSpyMode.fromString(rs.getString("command_spy_mode"))); data.setMuted(rs.getBoolean("muted")); data.setFrozen(rs.getBoolean("frozen")); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdapter.java index 2ffb3e1d8..5d47e2d31 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdapter.java @@ -429,6 +429,7 @@ private void createPlayersTable() throws SQLException `first_join_unix` BIGINT NOT NULL DEFAULT 0, `last_join_unix` BIGINT NOT NULL DEFAULT 0, `potion_spy` TINYINT(1) NOT NULL DEFAULT 0, + `sign_spy` TINYINT(1) NOT NULL DEFAULT 0, `command_spy_mode` VARCHAR(16) NOT NULL DEFAULT 'off', `muted` TINYINT(1) NOT NULL DEFAULT 0, `frozen` TINYINT(1) NOT NULL DEFAULT 0, @@ -442,6 +443,7 @@ private void createPlayersTable() throws SQLException statementHandler.executeUpdate(sql); addColumnIfMissing("players", "updated_at", "DATETIME NOT NULL DEFAULT NOW()"); addColumnIfMissing("players", "titles", "TEXT"); + addColumnIfMissing("players", "sign_spy", "TINYINT(1) NOT NULL DEFAULT 0"); } private void createPlayerIpsTable() throws SQLException diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java index 49ca35cb2..54668358f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java @@ -428,6 +428,7 @@ private void createPlayersTable() throws SQLException "first_join_unix" BIGINT NOT NULL DEFAULT 0, "last_join_unix" BIGINT NOT NULL DEFAULT 0, "potion_spy" BOOLEAN NOT NULL DEFAULT FALSE, + "sign_spy" BOOLEAN NOT NULL DEFAULT FALSE, "command_spy_mode" VARCHAR(16) NOT NULL DEFAULT 'off', "muted" BOOLEAN NOT NULL DEFAULT FALSE, "frozen" BOOLEAN NOT NULL DEFAULT FALSE, @@ -441,6 +442,8 @@ private void createPlayersTable() throws SQLException statementHandler.executeUpdate(sql); statementHandler.executeUpdate("ALTER TABLE \"players\" ADD COLUMN IF NOT EXISTS \"updated_at\" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP"); statementHandler.executeUpdate("ALTER TABLE \"players\" ADD COLUMN IF NOT EXISTS \"titles\" TEXT"); + statementHandler.executeUpdate( + "ALTER TABLE \"players\" ADD COLUMN IF NOT EXISTS \"sign_spy\" BOOLEAN NOT NULL DEFAULT FALSE"); } private void createPlayerIpsTable() throws SQLException diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java index 98f80dd20..b12db1c31 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java @@ -442,6 +442,7 @@ CREATE TABLE IF NOT EXISTS players ( first_join_unix INTEGER NOT NULL DEFAULT 0, last_join_unix INTEGER NOT NULL DEFAULT 0, potion_spy INTEGER NOT NULL DEFAULT 0, + sign_spy INTEGER NOT NULL DEFAULT 0, command_spy_mode TEXT NOT NULL DEFAULT 'off', muted INTEGER NOT NULL DEFAULT 0, frozen INTEGER NOT NULL DEFAULT 0, @@ -455,6 +456,7 @@ CREATE TABLE IF NOT EXISTS players ( statementHandler.executeUpdate(sql); addTimestampColumnIfMissing("players", "updated_at"); addColumnIfMissing("players", "titles", "TEXT"); + addColumnIfMissing("players", "sign_spy", "INTEGER NOT NULL DEFAULT 0"); } private void createPlayerIpsTable() throws SQLException From 95ee36ec543e21f7f29425960bdef8b6e7938baa Mon Sep 17 00:00:00 2001 From: Paldiu <pawereus@gmail.com> Date: Wed, 5 Aug 2026 14:39:07 -0500 Subject: [PATCH 26/48] permission fixes, reconciliation fixes, prevent use of disciplinary commands on admins --- .../totalfreedommod/ProtectArea.java | 63 +++--- .../totalfreedommod/SavedFlags.java | 67 +++---- .../totalfreedommod/admin/AdminList.java | 13 +- .../totalfreedommod/banning/BanManager.java | 12 +- .../totalfreedommod/banning/PermbanList.java | 45 +++-- .../totalfreedommod/banning/StrikeList.java | 51 ++--- .../totalfreedommod/cmd/Command_ban.java | 3 + .../totalfreedommod/cmd/Command_banip.java | 10 + .../totalfreedommod/cmd/Command_banname.java | 8 + .../totalfreedommod/cmd/Command_cage.java | 3 + .../totalfreedommod/cmd/Command_crash.java | 5 +- .../totalfreedommod/cmd/Command_deafen.java | 9 +- .../totalfreedommod/cmd/Command_deop.java | 3 + .../totalfreedommod/cmd/Command_doom.java | 7 +- .../totalfreedommod/cmd/Command_freeze.java | 3 + .../totalfreedommod/cmd/Command_gchat.java | 5 +- .../totalfreedommod/cmd/Command_gcmd.java | 15 +- .../totalfreedommod/cmd/Command_kick.java | 5 +- .../totalfreedommod/cmd/Command_lockup.java | 12 +- .../totalfreedommod/cmd/Command_orbit.java | 3 + .../totalfreedommod/cmd/Command_permban.java | 27 +-- .../cmd/Command_plugincontrol.java | 2 +- .../totalfreedommod/cmd/Command_saconfig.java | 6 - .../totalfreedommod/cmd/Command_smite.java | 7 +- .../totalfreedommod/cmd/Command_stfu.java | 25 +-- .../totalfreedommod/cmd/Command_tempban.java | 6 +- .../totalfreedommod/cmd/Command_warn.java | 5 +- .../totalfreedommod/cmd/FCommand.java | 52 +++++ .../totalfreedommod/rank/RankManager.java | 188 +++++------------- .../totalfreedommod/sql/FreedomDatabase.java | 5 + .../sql/adapter/TitleRepository.java | 4 + .../generic/GenericTitleRepository.java | 13 ++ .../totalfreedommod/title/TitleManager.java | 7 +- src/main/resources/ranks.json | 47 ++--- 34 files changed, 357 insertions(+), 379 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java b/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java index 6bda7f822..9b896ed89 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java @@ -97,12 +97,12 @@ private void loadFromSqlAsync() { final ProtectedAreaRepository repo = plugin.dm.getProtectedAreaRepository(); plugin.dm.readAsync("ProtectArea/loadFromSql", repo.loadAllAsync(), - loaded -> applyLoadedAreas(repo, loaded), - () -> - { - usingSql = false; - loadFromJsonOrLegacy(); - }); + loaded -> applyLoadedAreas(repo, loaded), + () -> + { + usingSql = false; + loadFromJsonOrLegacy(); + }); } private void applyLoadedAreas(final ProtectedAreaRepository repo, final List<ProtectedRegion> loaded) @@ -192,31 +192,32 @@ private void reconcileFromJsonIfNewer(final ProtectedAreaRepository repo) final long fileModified = dataFile.lastModified(); writes.enqueue(Mono.fromCallable(() -> - { - final Long sqlUpdatedAt = repo.getMaxUpdatedAt(); - return sqlUpdatedAt == null || fileModified > sqlUpdatedAt; - }) - .subscribeOn(Schedulers.boundedElastic()) - .filter(Boolean::booleanValue) - .flatMapMany(ignored -> - { - FLog.info(String.format("%s is newer than the database; re-importing %d protected area(s) from it.", - DATA_FILENAME, jsonAreas.size())); - return Flux.fromIterable(jsonAreas) - .concatMap(repo::save); - }) - .then(Mono.fromRunnable(() -> plugin.dm.sync("ProtectArea/applyReconciled", () -> - { - areas.clear(); - jsonAreas.forEach(region -> areas.put(region.getUuid(), region)); - }))) - .onErrorResume(ex -> - { - FLog.warning(String.format("Failed to reconcile %s into the database: %s", - DATA_FILENAME, ex.getMessage())); - return Mono.empty(); - }) - .then()); + { + final Long sqlUpdatedAt = repo.getMaxUpdatedAt(); + return sqlUpdatedAt == null || fileModified > sqlUpdatedAt; + }) + .subscribeOn(Schedulers.boundedElastic()) + .filter(Boolean::booleanValue) + .flatMapMany(ignored -> + { + FLog.info(String.format("%s is newer than the database; rebuilding it from the file's %d protected area(s).", + DATA_FILENAME, jsonAreas.size())); + return repo.deleteAll() + .thenMany(Flux.fromIterable(jsonAreas) + .concatMap(repo::save)); + }) + .then(Mono.fromRunnable(() -> plugin.dm.sync("ProtectArea/applyReconciled", () -> + { + areas.clear(); + jsonAreas.forEach(region -> areas.put(region.getUuid(), region)); + }))) + .onErrorResume(ex -> + { + FLog.warning(String.format("Failed to reconcile %s into the database: %s", + DATA_FILENAME, ex.getMessage())); + return Mono.empty(); + }) + .then()); } @SuppressWarnings("unchecked") diff --git a/src/main/java/me/totalfreedom/totalfreedommod/SavedFlags.java b/src/main/java/me/totalfreedom/totalfreedommod/SavedFlags.java index b298e8a11..274cd4a65 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/SavedFlags.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/SavedFlags.java @@ -172,48 +172,45 @@ private void reconcileFromJsonIfNewer(final SavedFlagRepository repo) final long fileModified = dataFile.lastModified(); writes.enqueue(Mono.fromCallable(() -> - { - final Long sqlUpdatedAt = repo.getMaxUpdatedAt(); - return sqlUpdatedAt == null || fileModified > sqlUpdatedAt; - }) - .subscribeOn(Schedulers.boundedElastic()) - .filter(Boolean::booleanValue) - .flatMapMany(ignored -> - { - FLog.info(String.format("%s is newer than the database; re-importing %d flag(s) from it.", - DATA_FILENAME, jsonFlags.size())); - return Flux.fromIterable(jsonFlags.entrySet()) - .concatMap(entry -> repo.upsertAsync(entry.getKey(), entry.getValue())); - }) - .then(Mono.fromRunnable(() -> plugin.dm.sync("SavedFlags/applyReconciled", () -> - { - flags.clear(); - flags.putAll(jsonFlags); - }))) - .onErrorResume(ex -> - { - FLog.warning(String.format("Failed to reconcile %s into the database: %s", - DATA_FILENAME, ex.getMessage())); - return Mono.empty(); - }) - .then()); + { + final Long sqlUpdatedAt = repo.getMaxUpdatedAt(); + return sqlUpdatedAt == null || fileModified > sqlUpdatedAt; + }) + .subscribeOn(Schedulers.boundedElastic()) + .filter(Boolean::booleanValue) + .flatMapMany(ignored -> + { + FLog.info(String.format("%s is newer than the database; rebuilding it from the file's %d flag(s).", + DATA_FILENAME, jsonFlags.size())); + return repo.deleteAll() + .thenMany(Flux.fromIterable(jsonFlags.entrySet()) + .concatMap(entry -> repo.upsertAsync(entry.getKey(), entry.getValue()))); + }) + .then(Mono.fromRunnable(() -> plugin.dm.sync("SavedFlags/applyReconciled", () -> + { + flags.clear(); + flags.putAll(jsonFlags); + }))) + .onErrorResume(ex -> + { + FLog.warning(String.format("Failed to reconcile %s into the database: %s", + DATA_FILENAME, ex.getMessage())); + return Mono.empty(); + }) + .then()); } private Map<String, Boolean> readJsonFlags(File file) { Map<String, Boolean> flags = new HashMap<>(); if (!file.exists()) - { return flags; - } try (FileReader reader = new FileReader(file)) { Map<String, Boolean> loaded = JsonUtil.GSON.fromJson(reader, FLAGS_MAP_TYPE); if (loaded != null) - { flags.putAll(loaded); - } } catch (Exception ex) { @@ -277,12 +274,12 @@ public void setSavedFlag(String flag, boolean value) } writes.enqueue(plugin.dm.getSavedFlagRepository().upsertAsync(flag, value) - .onErrorResume(ex -> - { - FLog.severe(String.format("Could not save flag '%s' to SQL: %s", flag, ex.getMessage())); - return Mono.empty(); - }) - .then(writeJsonAsync())); + .onErrorResume(ex -> + { + FLog.severe(String.format("Could not save flag '%s' to SQL: %s", flag, ex.getMessage())); + return Mono.empty(); + }) + .then(writeJsonAsync())); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java b/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java index c287cfaf6..7ebf6c1d5 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java @@ -656,6 +656,10 @@ private void backfillUuidsIfEnabled() * If admins.json was written more recently than the database's last update (e.g. edited * by hand, or restored from backup while SQL was unavailable), re-import it into SQL. * The comparison and the re-import both ride the write queue off the main thread. + * <p> + * The import replaces the table rather than merging into it, so an entry removed from the file + * by hand is removed from SQL too instead of reappearing on the next start. An empty or + * unreadable file is ignored, so a truncated snapshot cannot empty the table. */ private void reconcileFromJsonIfNewer(final AdminRepository repo) { @@ -691,11 +695,12 @@ private void reconcileFromJsonIfNewer(final AdminRepository repo) .filter(Boolean::booleanValue) .flatMapMany(ignored -> { - FLog.info(String.format("%s is newer than the database; re-importing %d admin(s) from it.", + FLog.info(String.format("%s is newer than the database; rebuilding it from the file's %d admin(s).", CONFIG_FILENAME, jsonAdmins.size())); - return Flux.fromIterable(jsonAdmins.values()) - .filter(Admin::isValid) - .concatMap(admin -> repo.save(resolveUuidFor(admin), admin)); + return repo.deleteAll() + .thenMany(Flux.fromIterable(jsonAdmins.values()) + .filter(Admin::isValid) + .concatMap(admin -> repo.save(resolveUuidFor(admin), admin))); }) .then(Mono.fromRunnable(() -> plugin.dm.sync("AdminList/applyReconciled", () -> applyReconciledAdmins(jsonAdmins)))) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java index 771930b4c..0cd8b25cb 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java @@ -109,6 +109,9 @@ private void applyLoadedBans(final BanRepository repo, final List<Ban> loaded) /** * If bans.json was written more recently than the database's last update, re-import it into * SQL. The comparison and the re-import both ride the write queue off the main thread. + * <p> + * The import replaces the table rather than merging into it, so a ban deleted from the file by + * hand does not come back. An empty or unreadable file is ignored. */ private void reconcileFromJsonIfNewer(final BanRepository repo) { @@ -144,11 +147,12 @@ private void reconcileFromJsonIfNewer(final BanRepository repo) .filter(Boolean::booleanValue) .flatMapMany(ignored -> { - FLog.info(String.format("bans.json is newer than the database; re-importing %d ban(s) from it.", + FLog.info(String.format("bans.json is newer than the database; rebuilding it from the file's %d ban(s).", jsonBans.size())); - return Flux.fromIterable(jsonBans) - .filter(Ban::isValid) - .concatMap(repo::save); + return repo.deleteAll() + .thenMany(Flux.fromIterable(jsonBans) + .filter(Ban::isValid) + .concatMap(repo::save)); }) .then(Mono.fromRunnable(() -> plugin.dm.sync("BanManager/applyReconciled", () -> applyReconciledBans(jsonBans)))) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java index df014da61..81417fcf2 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java @@ -126,28 +126,29 @@ private void reconcileFromJsonIfNewer(final PermbanRepository repo) final long fileModified = configFile.lastModified(); enqueue(Mono.fromCallable(() -> - { - final Long sqlUpdatedAt = repo.getMaxUpdatedAt(); - return sqlUpdatedAt == null || fileModified > sqlUpdatedAt; - }) - .subscribeOn(Schedulers.boundedElastic()) - .filter(Boolean::booleanValue) - .flatMapMany(ignored -> - { - FLog.info(String.format("%s is newer than the database; re-importing %d permban(s) from it.", - CONFIG_FILENAME, jsonPermbans.size())); - return Flux.fromIterable(jsonPermbans.values()) - .concatMap(repo::save); - }) - .then(Mono.fromRunnable(() -> plugin.dm.sync("PermbanList/applyReconciled", - () -> applyReconciledPermbans(jsonPermbans.values())))) - .onErrorResume(ex -> - { - FLog.warning(String.format("Failed to reconcile %s into the database: %s", - CONFIG_FILENAME, ex.getMessage())); - return Mono.empty(); - }) - .then()); + { + final Long sqlUpdatedAt = repo.getMaxUpdatedAt(); + return sqlUpdatedAt == null || fileModified > sqlUpdatedAt; + }) + .subscribeOn(Schedulers.boundedElastic()) + .filter(Boolean::booleanValue) + .flatMapMany(ignored -> + { + FLog.info(String.format("%s is newer than the database; rebuilding it from the file's %d permban(s).", + CONFIG_FILENAME, jsonPermbans.size())); + return repo.deleteAll() + .thenMany(Flux.fromIterable(jsonPermbans.values()) + .concatMap(repo::save)); + }) + .then(Mono.fromRunnable(() -> plugin.dm.sync("PermbanList/applyReconciled", + () -> applyReconciledPermbans(jsonPermbans.values())))) + .onErrorResume(ex -> + { + FLog.warning(String.format("Failed to reconcile %s into the database: %s", + CONFIG_FILENAME, ex.getMessage())); + return Mono.empty(); + }) + .then()); } private void applyReconciledPermbans(final Collection<PermBan> jsonPermbans) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java index 86a6f1de0..2770f4933 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java @@ -170,31 +170,32 @@ private void reconcileFromJsonIfNewer(final StrikeRepository repo) final long fileModified = configFile.lastModified(); enqueue(Mono.fromCallable(() -> - { - final Long sqlUpdatedAt = repo.getMaxUpdatedAt(); - return sqlUpdatedAt == null || fileModified > sqlUpdatedAt; - }) - .subscribeOn(Schedulers.boundedElastic()) - .filter(Boolean::booleanValue) - .flatMapMany(ignored -> - { - FLog.info(String.format("strikes.json is newer than the database; re-importing %d " - + "strike record(s) from it.", jsonStrikes.size())); - return Flux.fromIterable(jsonStrikes.values()) - .concatMap(repo::upsertAsync); - }) - .then(Mono.fromRunnable(() -> plugin.dm.sync("StrikeList/applyReconciled", () -> - { - strikes.clear(); - strikes.putAll(jsonStrikes); - }))) - .onErrorResume(ex -> - { - FLog.warning(String.format("Failed to reconcile strikes.json into the database: %s", - ex.getMessage())); - return Mono.empty(); - }) - .then()); + { + final Long sqlUpdatedAt = repo.getMaxUpdatedAt(); + return sqlUpdatedAt == null || fileModified > sqlUpdatedAt; + }) + .subscribeOn(Schedulers.boundedElastic()) + .filter(Boolean::booleanValue) + .flatMapMany(ignored -> + { + FLog.info(String.format("strikes.json is newer than the database; rebuilding it from the file's %d strike record(s).", + jsonStrikes.size())); + return repo.deleteAll() + .thenMany(Flux.fromIterable(jsonStrikes.values()) + .concatMap(repo::upsertAsync)); + }) + .then(Mono.fromRunnable(() -> plugin.dm.sync("StrikeList/applyReconciled", () -> + { + strikes.clear(); + strikes.putAll(jsonStrikes); + }))) + .onErrorResume(ex -> + { + FLog.warning(String.format("Failed to reconcile strikes.json into the database: %s", + ex.getMessage())); + return Mono.empty(); + }) + .then()); } private Map<String, StrikeRecord> readJsonStrikes() throws IOException diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_ban.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_ban.java index 447d2eb88..ee1043ceb 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_ban.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_ban.java @@ -50,6 +50,9 @@ private void doBan(CommandSender sender, String name, String reason, boolean sil name = BanCommandUtil.getCanonicalName(name, player, data); + if (isProtectedAdminByName(sender, name)) + return; + if (plugin().bm.getByUsername(name) != null) { msg(sender, "<gray><player> is already banned.", diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banip.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banip.java index 9b7f3e7bd..babf5a0e7 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banip.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banip.java @@ -27,6 +27,16 @@ public void banIps(CommandSender sender, @Resolve(value = "IPs", strategy = "all @Callback public void banIpsWithReason(CommandSender sender, @Resolve(value = "IPs", strategy = "allowPlayers,all") List<InetAddress> addressList, @Greedy String reason) { + final boolean reachesAdmin = addressList.stream() + .map(InetAddress::getHostAddress) + .anyMatch(ip -> isProtectedAdminByIp(sender, ip)); + + if (reachesAdmin) + { + msg(sender, "<red>You cannot IP-ban another admin."); + return; + } + adminAction(sender, "<red>Banning <count> address<plural:es:><include_reason:\" - Reason: <yellow><reason>\":\"\">", Formatter.number("count", addressList.size()), Formatter.booleanChoice("plural", addressList.size() != 1), diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banname.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banname.java index b36abcda3..aa0562010 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banname.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banname.java @@ -28,11 +28,18 @@ public void banName(CommandSender sender, String name) @Callback public void banNameWithReason(CommandSender sender, String name, @Greedy String reason) { + if (isProtectedAdminByName(sender, name)) + return; + if (plugin().bm.getByUsername(name) != null) { msg(sender, "<gray><name> is already banned.", Placeholder.unparsed("name", name)); return; } + + if (reason == null || reason.isEmpty()) + reason = "This username has been banned."; + final Ban ban = Ban.forPlayerName(name, sender, null, reason); plugin().bm.addBan(ban); @@ -40,6 +47,7 @@ public void banNameWithReason(CommandSender sender, String name, @Greedy String adminAction(sender, "<red>Banning the username <name>", Placeholder.unparsed("name", name)); + final Player player = server().getPlayer(name); if (player != null) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cage.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cage.java index 063c21f7e..eef1b9c43 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cage.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cage.java @@ -45,6 +45,9 @@ public void purge(final CommandSender sender) @Callback // /cage <player> <outer_mat> <inner_mat> - No support for switch here because why would you supply a -s and then define materials? would default to switch above anyways. public void cage(final CommandSender sender, final Player player, final Material outer, final Material inner) { + if (isProtectedAdmin(sender, player)) + return; + final CageData data = plugin().pl.getPlayer(player).getCageData(); final Location loc = player.getLocation().clone().add(0, 1, 0); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_crash.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_crash.java index fc11fae09..aace666e4 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_crash.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_crash.java @@ -15,11 +15,8 @@ public class Command_crash extends FCommand @Callback public void crash(final CommandSender sender, final Player player) { - if (plugin().al.isAdmin(player)) - { - msg(sender, "<gray>This command cannot be used on other admins."); + if (isProtectedAdmin(sender, player)) return; - } player.spawnParticle( Particle.ASH, diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_deafen.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_deafen.java index ebdc0d52b..5552f7c73 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_deafen.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_deafen.java @@ -22,8 +22,8 @@ public class Command_deafen extends FCommand private static Location randomOffset(Location a, double magnitude) { return a.clone().add(random.nextDouble(-magnitude, magnitude), - random.nextDouble(-magnitude, magnitude), - random.nextDouble(-magnitude, magnitude)); + random.nextDouble(-magnitude, magnitude), + random.nextDouble(-magnitude, magnitude)); } private static Sound getRandomSound() @@ -47,13 +47,10 @@ public void deafenNoArgument(CommandSender sender) server().getOnlinePlayers().forEach(this::playNoiseSequence); } - // cuz why not make it able to target one player? @Callback public void deafenPlayer(CommandSender sender, Player player) { - for (int x = 0; x <= server().getOnlinePlayers().size(); x++) // using player size since that's how the other method functions - { + for (int x = 0; x <= server().getOnlinePlayers().size(); x++) playNoiseSequence(player); - } } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_deop.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_deop.java index 4826bce7a..4f08196c8 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_deop.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_deop.java @@ -13,6 +13,9 @@ public class Command_deop extends FCommand @Callback public void deop(CommandSender sender, OfflinePlayer player) { + if (isProtectedAdminByName(sender, player.getName())) + return; + adminAction(sender, "<aqua>De-opping <player>", Placeholder.unparsed("player", player.getName())); player.setOp(false); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_doom.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_doom.java index 920f79f52..190fcafca 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_doom.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_doom.java @@ -11,7 +11,12 @@ import org.bukkit.entity.Player; import org.bukkit.util.Vector; -@Permission(source = SourceType.ONLY_CONSOLE, permission = "tfm.fun.doom") +/** + * Every other disciplinary or destructive command refuses an admin target through + * {@link FCommand#isProtectedAdmin} or {@link FCommand#isProtectedAdminByName}. + * This one is the way an admin is removed, so it carries no such guard and is fenced by reach instead at console only. + */ +@Permission(source = SourceType.ONLY_CONSOLE, permission = "tfm.admin.senior.doom") @Command(name = "doom", description = "For the bad admins", usage = "/doom <player>") public class Command_doom extends FCommand { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_freeze.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_freeze.java index 60a0f26af..ef1826260 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_freeze.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_freeze.java @@ -72,6 +72,9 @@ public void togglePlayerFreeze(CommandSender sender, Player player) @Callback public void setFreezeForPlayer(CommandSender sender, Player player, @Resolve("Boolean") boolean value) { + if (value && isProtectedAdmin(sender, player)) + return; + plugin().pl.getPlayer(player).getFreezeData().setFrozen(value); msg(sender, "<gray><player> has been <state>.", diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gchat.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gchat.java index af767c96c..24bbfa221 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gchat.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gchat.java @@ -27,11 +27,8 @@ public void sendMessageAsSomeoneElse(CommandSender sender, Player player, @Greed return; } - if (isAdmin(player)) - { - msg(sender, "<gray>This command cannot be used on other admins."); + if (isProtectedAdmin(sender, player)) return; - } msg(sender, "<gray>Sending chat as <yellow><name><gray>: <white><message>", Placeholder.unparsed("name", player.getName()), diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gcmd.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gcmd.java index 6d58b01c4..70de6bf63 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gcmd.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gcmd.java @@ -17,15 +17,12 @@ public void runAsOtherPlayer(CommandSender sender, Player player, @Greedy String { if (plugin().cb.isCommandBlocked(command, sender)) { - msg(sender, "<red>Did you really think that was going to work?"); + msg(sender, "<red>You cannot run blocked commands on another player."); return; } - if (isAdmin(player) && !plugin().rm.hasPermission(sender, "tfm.admin.senior.gcmd")) - { - msg(sender, "<red>This command can't be used on other admins."); + if (isProtectedAdmin(sender, player)) return; - } msg(sender, "<gray>Sending command as <yellow><player><gray>: <white><command>", Placeholder.unparsed("player", player.getName()), @@ -33,14 +30,10 @@ public void runAsOtherPlayer(CommandSender sender, Player player, @Greedy String try { - if (server().getCommandMap().dispatch(player, command)) - { + if (server().getCommandMap().dispatch(player, command)) msg(sender, "<green>Command sent."); - } - else - { + else msg(sender, "<red>Unknown error sending command."); - } } catch (Throwable ex) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_kick.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_kick.java index f77d108fe..3b3aa5245 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_kick.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_kick.java @@ -20,11 +20,8 @@ public void kickNoReason(CommandSender sender, Player player, @Switch("s") boole @Callback public void kick(CommandSender sender, Player player, @Greedy String reason, @Switch("s") boolean silent) { - if (isAdmin(player)) - { - msg(sender, "<red>This command cannot be used on other admins."); + if (isProtectedAdmin(sender, player)) return; - } final String kickMessage = reason != null ? "<red>You have been kicked from the server.\n<red>Kicked by: <gold><sender>\n<red>Reason: <gold><reason>" diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_lockup.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_lockup.java index f822237ae..d8fa5cad8 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_lockup.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_lockup.java @@ -27,7 +27,10 @@ public void lockAll(CommandSender sender) { adminAction(sender, "<red>Locking up all players"); - server().getOnlinePlayers().forEach(this::startLockup); + server().getOnlinePlayers() + .stream() + .filter(player -> !isAdmin(player)) + .forEach(this::startLockup); msg(sender, "<gray>Locked up all players."); } @@ -68,6 +71,9 @@ public void toggle(CommandSender sender, String name, String state) if (state.equalsIgnoreCase("on")) { + if (isProtectedAdmin(sender, player)) + return; + adminAction(sender, "<red>Locking up <player>", Placeholder.unparsed("player", player.getName())); startLockup(player); msg(sender, "<gray>Locked up <player>.", Placeholder.unparsed("player", player.getName())); @@ -109,13 +115,9 @@ public void run() FTask.run("Command_lockup/lockup", () -> { if (player.isOnline()) - { player.openInventory(player.getInventory()); - } else - { cancelLockup(playerdata); - } }); } }.runTaskTimer(plugin(), 0L, 5L)); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_orbit.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_orbit.java index fae713311..17e5f6553 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_orbit.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_orbit.java @@ -21,6 +21,9 @@ public void setOrbit(CommandSender sender, Player player) final FPlayer target = plugin().pl.getPlayer(player); if (!target.isOrbiting()) { + if (isProtectedAdmin(sender, player)) + return; + player.setGameMode(GameMode.SURVIVAL); final double strength = 10.0; target.startOrbiting(strength); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_permban.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_permban.java index 8e743dfb6..a4fe165a5 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_permban.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_permban.java @@ -84,21 +84,19 @@ public void add(CommandSender sender, String name, @Greedy String extraIps) final PlayerData data = BanCommandUtil.getData(plugin(), name, online); final String canonicalName = BanCommandUtil.getCanonicalName(name, online, data); + if (isProtectedAdminByName(sender, canonicalName)) + return; + final Set<String> ips = new LinkedHashSet<>(BanCommandUtil.getIps(online, data)); for (String ip : extraIps.trim().split("\\s+")) { if (ip.isEmpty()) - { continue; - } + if (isValidIpOrRange(ip)) - { ips.add(ip); - } else - { msg(sender, "<red>Ignoring invalid IP/range: <ip>", Placeholder.unparsed("ip", ip)); - } } UUID uuid = online != null ? online.getUniqueId() : FUtil.usernameToUuid(canonicalName); @@ -108,13 +106,9 @@ public void add(CommandSender sender, String name, @Greedy String extraIps) final boolean existed = permban != null; if (permban == null) // this caused a semantic issue with nullability. replaced with a proper null check instead of the cached boolean. - { permban = new PermBan(uuid, canonicalName, "Permbanned by " + sender.getName()); - } else if (uuid != null && !permban.hasUuid()) - { permban.setUuid(uuid); - } final int before = permban.getIps().size(); permban.addIps(new ArrayList<>(ips)); @@ -151,9 +145,7 @@ else if (uuid != null && !permban.hasUuid()) } if (online != null) - { kickPlayer(online, permbanKickMessage()); - } } @Callback @@ -177,10 +169,9 @@ private void doRemove(CommandSender sender, String target) if (isValidIpOrRange(target)) { final List<String> removed = plugin().pm.removePermbansByIp(target); + if (removed.isEmpty()) - { msg(sender, "<red>No permbans matched the IP <target>.", Placeholder.unparsed("target", target)); - } else { adminAction( @@ -214,9 +205,7 @@ private void doRemove(CommandSender sender, String target) private void requireLocalConsole(CommandSender sender) { if (RemoteDispatchContext.isActive() || !sender.getName().equalsIgnoreCase("CONSOLE")) - { throw new CommandFailException("This command can only be used from the server panel console."); - } } private String permbanKickMessage() @@ -230,13 +219,11 @@ private static boolean isValidIpOrRange(String ip) { final String[] parts = ip.split("\\."); if (parts.length != 4) - { return false; - } return Stream.of(parts) - .filter(part -> !part.equals("*")) - .allMatch(Command_permban::isValidOctet); + .filter(part -> !part.equals("*")) + .allMatch(Command_permban::isValidOctet); } private static boolean isValidOctet(String part) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_plugincontrol.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_plugincontrol.java index efbe5f48b..51149db23 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_plugincontrol.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_plugincontrol.java @@ -10,7 +10,7 @@ import org.bukkit.plugin.Plugin; import org.bukkit.plugin.PluginManager; -@Permission(source = SourceType.ONLY_CONSOLE, permission = "tfm.admin.telnet.plugincontrol") +@Permission(source = SourceType.ONLY_CONSOLE, permission = "tfm.admin.senior.plugincontrol") @Command(name = "plugincontrol", aliases = "plc", description = "Manage plugins", usage = "/<command> <<enable | disable | reload> <pluginname>> | list>") public class Command_plugincontrol extends FCommand { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_saconfig.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_saconfig.java index 3a88dada8..067b55c98 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_saconfig.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_saconfig.java @@ -29,12 +29,6 @@ @Command(name = "saconfig", description = "Manage admins.", usage = "/<command> <list | clean | reload | setrank <username> <rank> | <add | remove | info> <username>>") -// The read-only subcommands sit at SUPER_ADMIN, so they cannot be gated behind a tfm.manage.* -// node: RankManager#checkLegacyPermission maps that whole namespace to SENIOR_ADMIN for ranks -// that hold no explicit grant, which vetoes the level declared here and hides the command from -// super admins entirely. tfm.admin.* legacy-maps to SUPER_ADMIN and matches this gate. The -// mutating handlers below keep tfm.manage.saconfig, which now differs from this node and so is -// actually tested rather than skipped as a repeat of the parent. @Permission(permission = "tfm.admin.saconfig") public class Command_saconfig extends FCommand { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_smite.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_smite.java index f694b524b..00b9c947d 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_smite.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_smite.java @@ -26,12 +26,13 @@ public void smiteNoReason(CommandSender sender, Player player) @Callback public void smite(CommandSender sender, Player player, @Greedy String reason) { + if (isProtectedAdmin(sender, player)) + return; + FUtil.bcastMsg("<red><player> has been a naughty, naughty boy.", Placeholder.unparsed("player", player.getName())); if (reason != null) - { FUtil.bcastMsg(" <yellow>Reason: <reason>", Placeholder.unparsed("reason", reason)); - } plugin().db.sendActionMessage(sender.getName(), player.getName(), reason, ConfigEntry.DISCORD_PLAYER_SMITE_MESSAGE); @@ -48,13 +49,11 @@ public void smite(CommandSender sender, Player player, @Greedy String reason) final Location targetPos = player.getLocation(); final World world = player.getWorld(); for (int x = -1; x <= 1; x++) - { for (int z = -1; z <= 1; z++) { final Location strike_pos = new Location(world, targetPos.getBlockX() + x, targetPos.getBlockY(), targetPos.getBlockZ() + z); world.strikeLightning(strike_pos); } - } // Kill player.setHealth(0.0); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_stfu.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_stfu.java index 3d64183ad..add52102f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_stfu.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_stfu.java @@ -87,11 +87,8 @@ public void mutePlayerWithReason(CommandSender sender, Player player, @Greedy St { final FPlayer fplayer = fplayer(player); - if (isAdmin(player)) - { - msg(sender, "<gray>This command cannot be used on other admins."); + if (isProtectedAdmin(sender, player)) return; - } if (fplayer.isMuted()) { @@ -102,28 +99,20 @@ public void mutePlayerWithReason(CommandSender sender, Player player, @Greedy St else { if (reason != null) - { - adminAction( - sender, "<red>Muting <player><newline> Reason: <yellow><reason>", + adminAction(sender, "<red>Muting <player><newline> Reason: <yellow><reason>", Placeholder.unparsed("player", player.getName()), - MessageUtils.parsed("reason", reason) - ); - } + MessageUtils.parsed("reason", reason)); else - { - adminAction(sender, "<red>Muting <player>", Placeholder.unparsed("player", player.getName())); - } + adminAction(sender, "<red>Muting <player>", + Placeholder.unparsed("player", player.getName())); fplayer.setMuted(true); if (reason != null) - { - msg(player, "<red>You have been muted. Reason: <yellow><reason>", MessageUtils.parsed("reason", reason)); - } + msg(player, "<red>You have been muted. Reason: <yellow><reason>", + MessageUtils.parsed("reason", reason)); else - { msg(player, "<red>You have been muted."); - } } } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_tempban.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_tempban.java index 5cee363d4..a72bb59df 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_tempban.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_tempban.java @@ -94,6 +94,9 @@ private void tempBan(CommandSender sender, String name, Date expiry, String reas final PlayerData data = BanCommandUtil.getData(plugin(), name, player); final String canonicalName = BanCommandUtil.getCanonicalName(name, player, data); + if (isProtectedAdminByName(sender, canonicalName)) + return; + if (plugin().bm.getByUsername(canonicalName) != null) { msg(sender, "<gray><player> is already banned.", Placeholder.unparsed("player", canonicalName)); @@ -136,9 +139,8 @@ private void tempBan(CommandSender sender, String name, Date expiry, String reas .forEach(target -> { if (!silent) - { smitePlayer(target); - } + target.kick(ban.bakeKickMessage()); }); } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_warn.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_warn.java index df06c8fb2..90b045363 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_warn.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_warn.java @@ -23,11 +23,8 @@ public void warnPlayer(CommandSender sender, Player player, @Greedy String reaso return; } - if (isAdmin(player)) - { - msg(sender, "<gray>This command cannot be used on other admins."); + if (isProtectedAdmin(sender, player)) return; - } adminAction(sender, "<red>Warning <player>", Placeholder.unparsed("player", player.getName())); MessageUtils.broadcast("<red> Reason: <yellow><reason>", MessageUtils.parsed("reason", reason)); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/FCommand.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/FCommand.java index 29cf9d4b2..1f3339aa2 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/FCommand.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/FCommand.java @@ -49,6 +49,9 @@ public abstract class FCommand public static final Component NOT_FROM_CONSOLE = Component.text("This command may not be used from the console.", NamedTextColor.GRAY); public static final Component PLAYER_NOT_FOUND = Component.text("Player not found!", NamedTextColor.GRAY); + // Eventually the above components will be converted to use this same MM string format. + public static final String ADMIN_PROTECTED_MESSAGE = "<red>This command cannot be used on other admins."; + protected final TotalFreedomMod plugin() { return PluginProvider.get(); @@ -177,6 +180,55 @@ protected boolean isAdmin(CommandSender sender) return plugin().al.isAdmin(sender); } + /** + * Whether {@code target} is an admin, and so out of reach of a disciplinary or destructive + * command. Tells {@code sender} why when it is, so a caller only has to return. + */ + protected boolean isProtectedAdmin(final CommandSender sender, final Player target) + { + if (target == null || !plugin().al.isAdmin(target)) + return false; + + msg(sender, ADMIN_PROTECTED_MESSAGE); + return true; + } + + /** + * The same test for the commands whose target is a bare name and may be offline or unknown. + */ + protected boolean isProtectedAdminByName(final CommandSender sender, final String targetName) + { + if (targetName == null || targetName.isEmpty()) + return false; + + final Player online = server().getPlayerExact(targetName); + if (online != null) + return isProtectedAdmin(sender, online); + + final Admin listed = plugin().al.getEntryByName(targetName); + if (listed == null || !listed.isActive()) + return false; + + msg(sender, ADMIN_PROTECTED_MESSAGE); + return true; + } + + /** + * The same test for an address, so that an IP ban cannot reach an admin through one of theirs. + */ + protected boolean isProtectedAdminByIp(final CommandSender sender, final String ip) + { + if (ip == null || ip.isEmpty()) + return false; + + final Admin listed = plugin().al.getEntryByIpFuzzy(ip); + if (listed == null || !listed.isActive()) + return false; + + msg(sender, ADMIN_PROTECTED_MESSAGE); + return true; + } + protected Admin getAdmin(CommandSender sender) { return plugin().al.getAdmin(sender); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java index 641dcaf40..b316a6844 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java @@ -114,13 +114,8 @@ public RankRegistry getRegistry() @Override protected void onStart() { - // Load custom ranks loadRanks(); - // The console registry is built during onEnable, before any service starts, so its first - // read happened with no ranks in memory and every binding naming a custom rank was thrown - // away as unknown. Re-read it now that the ranks exist, which is also what lets the - // host-channel floor compare against real rank levels. if (plugin.csr != null) { plugin.csr.load(); @@ -300,6 +295,9 @@ private static Map<String, CustomRank> stampIds(final Map<String, CustomRank> lo /** * If ranks.json was written more recently than the database's last update, re-import it into * SQL. The comparison and the re-import both ride the write queue off the main thread. + * <p> + * The import replaces the table rather than merging into it, so a rank deleted from the file is + * deleted from SQL as well. An empty or unreadable file is ignored. */ private void reconcileFromJsonIfNewer(final RankRepository repo) { @@ -327,28 +325,29 @@ private void reconcileFromJsonIfNewer(final RankRepository repo) final long fileModified = ranksFile.lastModified(); writes.enqueue(Mono.fromCallable(() -> - { - final Long sqlUpdatedAt = repo.getMaxUpdatedAt(); - return sqlUpdatedAt == null || fileModified > sqlUpdatedAt; - }) - .subscribeOn(Schedulers.boundedElastic()) - .filter(Boolean::booleanValue) - .flatMapMany(ignored -> - { - FLog.info(String.format("%s is newer than the database; re-importing %d rank(s) from it.", - RANKS_FILENAME, jsonRanks.size())); - return Flux.fromIterable(jsonRanks.values()) - .concatMap(repo::save); - }) - .then(Mono.fromRunnable(() -> plugin.dm.sync("RankManager/applyReconciled", - () -> applyReconciledRanks(jsonRanks)))) - .onErrorResume(ex -> - { - FLog.warning(String.format("Failed to reconcile %s into the database: %s", - RANKS_FILENAME, ex.getMessage())); - return Mono.empty(); - }) - .then()); + { + final Long sqlUpdatedAt = repo.getMaxUpdatedAt(); + return sqlUpdatedAt == null || fileModified > sqlUpdatedAt; + }) + .subscribeOn(Schedulers.boundedElastic()) + .filter(Boolean::booleanValue) + .flatMapMany(ignored -> + { + FLog.info(String.format("%s is newer than the database; rebuilding it from the file's %d rank(s).", + RANKS_FILENAME, jsonRanks.size())); + return repo.deleteAll() + .thenMany(Flux.fromIterable(jsonRanks.values()) + .concatMap(repo::save)); + }) + .then(Mono.fromRunnable(() -> plugin.dm.sync("RankManager/applyReconciled", + () -> applyReconciledRanks(jsonRanks)))) + .onErrorResume(ex -> + { + FLog.warning(String.format("Failed to reconcile %s into the database: %s", + RANKS_FILENAME, ex.getMessage())); + return Mono.empty(); + }) + .then()); } private void applyReconciledRanks(final Map<String, CustomRank> jsonRanks) @@ -377,14 +376,15 @@ public void saveRanks() final List<CustomRank> snapshot = new ArrayList<>(customRanks.values()); writes.enqueue(Flux.fromIterable(snapshot) - .concatMap(rank -> repo.save(rank) - .onErrorResume(ex -> - { - FLog.severe(String.format("Could not save rank %s to SQL: %s", - rank.getId(), ex.getMessage())); - return Mono.empty(); - })) - .then(writeJsonAsync())); + .concatMap(rank -> repo.save(rank) + .onErrorResume(ex -> + { + FLog.severe(String.format("Could not save rank %s to SQL: %s", + rank.getId(), + ex.getMessage())); + return Mono.empty(); + })) + .then(writeJsonAsync())); } /** @@ -399,15 +399,13 @@ private Mono<Void> writeJsonAsync() { final Map<String, CustomRank> snapshot = new LinkedHashMap<>(customRanks); return Mono.<Void>fromRunnable(() -> writeJson(snapshot)) - .subscribeOn(Schedulers.boundedElastic()); + .subscribeOn(Schedulers.boundedElastic()); } private void writeJson(final Map<String, CustomRank> snapshot) { if (ranksFile == null) - { ranksFile = new File(plugin.getDataFolder(), RANKS_FILENAME); - } try (FileWriter writer = new FileWriter(ranksFile)) { @@ -433,7 +431,8 @@ private void resolveInheritance() private Set<String> collectPermissions(CustomRank rank, Set<String> visited) { - if (rank == null) return Set.of(); + if (rank == null) + return Set.of(); if (visited.contains(rank.getId())) { @@ -448,13 +447,9 @@ private Set<String> collectPermissions(CustomRank rank, Set<String> visited) { CustomRank parent = customRanks.get(rank.getInheritFrom().toLowerCase()); if (parent == null) - { FLog.warning("Rank '" + rank.getId() + "' inherits from non-existent rank: " + rank.getInheritFrom()); - } else - { perms.addAll(collectPermissions(parent, visited)); - } } return perms; @@ -463,9 +458,8 @@ private Set<String> collectPermissions(CustomRank rank, Set<String> visited) public CustomRank getCustomRank(String id) { if (id == null) - { return null; - } + return customRanks.get(id.toLowerCase()); } @@ -473,16 +467,12 @@ public CustomRank getCustomRank(String id) private CustomRank getAssignedAdminRank(Player player) { if (plugin.al.isAdminImpostor(player)) - { return null; - } final Admin admin = plugin.al.getAdmin(player); if (admin == null || !admin.isActive()) - { return null; - } return getCustomRank(admin.getRankId()); } @@ -492,31 +482,24 @@ public void updatePlayerTeam(Player player) ScoreboardManager manager = server.getScoreboardManager(); if (manager == null) - { return; - } final Scoreboard scoreboard = manager.getMainScoreboard(); final Team currentTeam = scoreboard.getEntryTeam(player.getName()); final CustomRank rank = getAssignedAdminRank(player); - // Only an admin rank earns its own team; everyone else shares the default one, so an - // unresolvable rank is an ordinary outcome here rather than something to substitute for. final boolean admin = rank != null && rank.isAdmin(); final String teamName = admin ? createTeamName(rank) : DEFAULT_TEAM_NAME; if (currentTeam != null && !currentTeam.getName().equals(teamName)) - { currentTeam.removeEntry(player.getName()); - } Team team = scoreboard.getTeam(teamName); if (team == null) - { team = scoreboard.registerNewTeam(teamName); - } + // this npe warning can be ignored since boolean admin already validates that rank won't be null if true team.color(admin ? rank.getColor() : NamedTextColor.WHITE); team.prefix(Component.empty()); team.addEntry(player.getName()); @@ -531,9 +514,7 @@ private String createTeamName(CustomRank rank) rank.getId().replaceAll("[^A-Za-z0-9_\\-]", "_")); if (name.length() > 16) - { name = name.substring(0, 16); - } return name; } @@ -541,9 +522,7 @@ private String createTeamName(CustomRank rank) public void updateAllPlayerTeams() { for (Player player : server.getOnlinePlayers()) - { updatePlayerTeam(player); - } } /** @@ -584,19 +563,18 @@ public boolean removeCustomRank(String id) if (removed == null) return false; - // saveRanks() only writes the ranks that survive, so without an explicit delete the row - // stays behind in SQL and the rank returns on the next load. if (usingSql && plugin.dm != null && plugin.dm.isInitialized()) { writes.enqueue(plugin.dm.getRankRepository() - .deleteAsync(removed.getId()) - .onErrorResume(ex -> - { - FLog.severe(String.format("Could not delete rank %s from SQL: %s", - removed.getId(), ex.getMessage())); - return Mono.empty(); - }) - .then()); + .deleteAsync(removed.getId()) + .onErrorResume(ex -> + { + FLog.severe(String.format("Could not delete rank %s from SQL: %s", + removed.getId(), + ex.getMessage())); + return Mono.empty(); + }) + .then()); } resolveInheritance(); @@ -614,17 +592,11 @@ public boolean hasCustomRank(String id) return customRanks.containsKey(id.toLowerCase()); } - // ======================================================================== - // Permission System (Internal, NOT Bukkit-based) - // ======================================================================== - /** * Whether {@code sender} may exercise an internal TFM permission node. * <p> - * These are TFM's own nodes and are never registered with Bukkit: every player on a - * TotalFreedom server is opped, so a Bukkit node would grant itself. The answer is delegated to - * the {@link RankRegistry}, which resolves how this sender earned its rank and compares that - * against the tier the node requires, both read off {@code ranks.json}. + * These are TFM's own nodes and are never registered with Bukkit + * because if we registered with Bukkit then OPs would have these nodes too. * * @param sender the command sender * @param permission the internal node, for example {@code tfm.admin.ban} @@ -642,10 +614,6 @@ public boolean canManageRanks(CommandSender sender) return hasPermission(sender, "tfm.manage.ranks"); } - // ======================================================================== - // Chat Input Handler (Inner Class) - // ======================================================================== - /** * Get the chat input handler for interactive menus. */ @@ -677,22 +645,17 @@ public void awaitInput(Player player, Component prompt, Consumer<String> callbac { UUID uuid = player.getUniqueId(); - // Cancel any existing pending input cancelInput(player); - // Send prompt player.sendMessage(Component.empty()); player.sendMessage(prompt); player.sendMessage(Component.text("Type your response in chat, or type 'cancel' to abort.") .color(NamedTextColor.GRAY).decorate(TextDecoration.ITALIC)); - // Register pending input PendingInput pending = new PendingInput(callback, System.currentTimeMillis()); pendingInputs.put(uuid, pending); - // Schedule timeout if specified if (timeoutSeconds > 0) - { new BukkitRunnable() { @Override @@ -703,14 +666,12 @@ public void run() { pendingInputs.remove(uuid); Player p = server.getPlayer(uuid); + if (p != null && p.isOnline()) - { p.sendMessage(Component.text("Input timed out.").color(NamedTextColor.RED)); - } } } }.runTaskLater(plugin, timeoutSeconds * 20L); - } } /** @@ -742,9 +703,7 @@ public boolean processChat(Player player, String message) PendingInput pending = pendingInputs.remove(uuid); if (pending == null) - { return false; - } // Check for cancel if (message.equalsIgnoreCase("cancel")) @@ -753,7 +712,6 @@ public boolean processChat(Player player, String message) return true; } - // Invoke callback try { pending.callback().accept(message); @@ -783,32 +741,17 @@ private record PendingInput(Consumer<String> callback, long timestamp) } } - // ======================================================================== - // Chat Event Handler (for input capture) - // ======================================================================== - @EventHandler(priority = EventPriority.LOWEST) public void onPlayerChat(AsyncChatEvent event) { Player player = event.getPlayer(); - // Check if this player has pending input if (chatInputHandler.hasPendingInput(player)) { - // Extract plain text from the Component message final String message = PlainTextComponentSerializer.plainText().serialize(event.message()); - // Process on main thread to avoid async issues - new BukkitRunnable() - { - @Override - public void run() - { - chatInputHandler.processChat(player, message); - } - }.runTask(plugin); + FTask.run("chatInputHandler#processChat", () -> chatInputHandler.processChat(player, message)); - // Cancel the chat event so the message isn't broadcast event.setCancelled(true); } } @@ -826,16 +769,10 @@ public void onPlayerQuit(PlayerQuitEvent event) Team team = manager.getMainScoreboard().getEntryTeam(event.getPlayer().getName()); if (team != null) - { team.removeEntry(event.getPlayer().getName()); - } } } - // ======================================================================== - // Interactive Menu Builder (for /rankconfig) - // ======================================================================== - /** * Build the main rank configuration menu. */ @@ -992,10 +929,6 @@ private Component buildEditableProperty(String label, String value, String comma .append(Component.text("\n")); } - // ======================================================================== - // Original RankManager Methods (preserved) - // ======================================================================== - private void startPersistentMonitor() { final int interval = ConfigEntry.AUTO_OP_MONITOR_INTERVAL.getInteger(); @@ -1021,15 +954,11 @@ public void run() { // Skip admins and players who should not be OP if (plugin.al.isAdmin(player) || plugin.al.isAdminImpostor(player)) - { continue; - } // Re-OP players who lost OP status if (!player.isOp()) - { ensureOp(player); - } } }); } @@ -1044,27 +973,20 @@ public void run() private void ensureOp(Player player) { if (player == null || !player.isOnline()) - { return; - } + // Skip admins and impostors if (plugin.al.isAdmin(player) || plugin.al.isAdminImpostor(player)) - { return; - } // Only ensure OP if auto-OP is enabled if (!ConfigEntry.AUTO_OP_ENABLED.getBoolean()) - { return; - } // Set OP if not already set if (!player.isOp()) - { player.setOp(true); - } // Aggressively refresh permissions immediately try @@ -1079,7 +1001,6 @@ private void ensureOp(Player player) // Schedule multiple delayed recalculations to catch plugins that cache late // This ensures WorldEdit, Essentials, etc. pick up the OP status for (long delay : new long[]{2L, 5L, 10L, 20L}) // 100ms, 250ms, 500ms, 1s - { new BukkitRunnable() { @Override @@ -1098,7 +1019,6 @@ public void run() } } }.runTaskLater(plugin, delay); - } } /** diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/FreedomDatabase.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/FreedomDatabase.java index 339ad97e2..915fe015c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/FreedomDatabase.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/FreedomDatabase.java @@ -183,6 +183,11 @@ public void shutdown() } initialized = false; + + // Cleared so a tfm reload queues the domains' swap callbacks again instead of running them immediately against a pool that is still being rebuilt. + readyFired = false; + readyCallbacks.clear(); + FLog.info("Database shutdown complete"); } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/TitleRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/TitleRepository.java index 2c93ed64d..6c67a97a3 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/TitleRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/TitleRepository.java @@ -40,6 +40,8 @@ public interface TitleRepository boolean removePermission(String titleId, String permission) throws SQLException; + void deleteAllSync() throws SQLException; + /** * Epoch millis of the most recently updated title row, or null if the table is empty. * Used to compare SQL freshness against the titles.json snapshot's last-modified time. @@ -51,4 +53,6 @@ public interface TitleRepository Mono<Void> save(Title title); Mono<Boolean> deleteAsync(String id); + + Mono<Void> deleteAll(); } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericTitleRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericTitleRepository.java index 091253de2..84fbd84e5 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericTitleRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericTitleRepository.java @@ -273,6 +273,19 @@ public Mono<Boolean> deleteAsync(String id) return statementHandler.supplyMono(() -> delete(id)); } + @Override + public void deleteAllSync() throws SQLException + { + statementHandler.executeUpdate(String.format("DELETE FROM %s", tblTitlePermissions)); + statementHandler.executeUpdate(String.format("DELETE FROM %s", tblTitles)); + } + + @Override + public Mono<Void> deleteAll() + { + return statementHandler.runMono(this::deleteAllSync); + } + private Title loadTitleFromRow(ResultSet rs) throws SQLException { Title title = new Title(rs.getString("id")); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/title/TitleManager.java b/src/main/java/me/totalfreedom/totalfreedommod/title/TitleManager.java index 238ee710c..542c3d062 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/title/TitleManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/title/TitleManager.java @@ -370,10 +370,11 @@ private void reconcileFromJsonIfNewer(final TitleRepository repo) .filter(Boolean::booleanValue) .flatMapMany(ignored -> { - FLog.info(String.format("%s is newer than the database; re-importing %d title(s) from it.", + FLog.info(String.format("%s is newer than the database; rebuilding it from the file's %d title(s).", TITLES_FILENAME, jsonTitles.size())); - return Flux.fromIterable(jsonTitles.values()) - .concatMap(repo::save); + return repo.deleteAll() + .thenMany(Flux.fromIterable(jsonTitles.values()) + .concatMap(repo::save)); }) .then(Mono.fromRunnable(() -> plugin.dm.sync("TitleManager/applyReconciled", () -> applyReconciledTitles(jsonTitles)))) diff --git a/src/main/resources/ranks.json b/src/main/resources/ranks.json index b023c5686..4cac4c18b 100644 --- a/src/main/resources/ranks.json +++ b/src/main/resources/ranks.json @@ -11,8 +11,7 @@ "impostor" ], "permissions": [ - "tfm.player.list", - "tfm.admin.overlord" + "tfm.player.list" ] }, "non_op": { @@ -56,8 +55,7 @@ "tfm.fun.mp44", "tfm.fun.spawnmob", "tfm.fun.tossmob", - "tfm.server.whitelist", - "tfm.manage.saconfig" + "tfm.server.whitelist" ] }, "super_admin": { @@ -75,57 +73,48 @@ "permissions": [ "tfm.fun.*", "tfm.world.*", + "tfm.server.*", "tfm.admin.adminchat", + "tfm.admin.aeclear", "tfm.admin.announce", + "tfm.admin.autoclear", + "tfm.admin.autotp", "tfm.admin.ban", "tfm.admin.baninfo", - "tfm.admin.banlist", "tfm.admin.blockcmd", - "tfm.admin.blockredstone", "tfm.admin.bookspy", "tfm.admin.cage", + "tfm.admin.cleanchat", "tfm.admin.cmdspy", "tfm.admin.consolesay", - "tfm.admin.denick", "tfm.admin.deop", + "tfm.admin.discordlink", "tfm.admin.disguisetoggle", "tfm.admin.findip", "tfm.admin.freeze", + "tfm.admin.fuckup", + "tfm.admin.gamemode", + "tfm.admin.gchat", "tfm.admin.gcmd", "tfm.admin.invis", "tfm.admin.kick", "tfm.admin.mute", "tfm.admin.myadmin", "tfm.admin.nickclean", - "tfm.admin.opall", - "tfm.admin.opme", "tfm.admin.potspy", "tfm.admin.premium", + "tfm.admin.protectregion", "tfm.admin.purgeall", "tfm.admin.ro", + "tfm.admin.saconfig", "tfm.admin.say", "tfm.admin.signspy", + "tfm.admin.sqlstatus", "tfm.admin.strike", "tfm.admin.undisguiseall", "tfm.admin.warn", "tfm.admin.whohas", - "tfm.admin.wildcard", - "tfm.admin.gamemode", - "tfm.admin.protectregion", - "tfm.admin.aeclear", - "tfm.admin.autoclear", - "tfm.admin.autotp", - "tfm.admin.cleanchat", - "tfm.admin.discordlink", - "tfm.admin.fuckup", - "tfm.admin.gchat", - "tfm.admin.saconfig", - "tfm.admin.sqlstatus", - "tfm.server.reload", - "tfm.server.whitelist.manage", - "tfm.world.adminworld.manage", - "tfm.server.*", - "tfm.manage.saconfig" + "tfm.admin.wildcard" ] }, "senior_admin": { @@ -142,14 +131,10 @@ "inherit": "super_admin", "permissions": [ "tfm.admin.senior.*", - "tfm.admin.telnet.*", "tfm.manage.*", - "tfm.manage.ssh", - "tfm.manage.telnet", "tfm.admin.ban.perm", "tfm.admin.banlist.purge", - "tfm.ssh.totp", - "tfm.admin.senior.status" + "tfm.ssh.totp" ] } } From d824c6ca9f270e596dba06ba398209d9559c9405 Mon Sep 17 00:00:00 2001 From: Paldiu <pawereus@gmail.com> Date: Wed, 5 Aug 2026 15:04:36 -0500 Subject: [PATCH 27/48] Import Formatting Plugin --- build.gradle | 9 +++++++++ rewrite.yml | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 rewrite.yml diff --git a/build.gradle b/build.gradle index 532ced0b4..e257fd945 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,7 @@ plugins { id 'java' id 'com.gorylenko.gradle-git-properties' version '2.5.7' + id 'org.openrewrite.rewrite' version '7.38.0' } group = 'me.totalfreedom' @@ -12,6 +13,14 @@ java { } } +rewrite { + activeRecipe( + "org.openrewrite.java.RemoveUnusedImports", + "org.openrewrite.java.OrderImports" + ) + activeStyle("me.totalfreedom.totalfreedommod.WildcardStyle") +} + repositories { mavenCentral() maven { diff --git a/rewrite.yml b/rewrite.yml new file mode 100644 index 000000000..b0df2d1ca --- /dev/null +++ b/rewrite.yml @@ -0,0 +1,41 @@ +--- +type: specs.openrewrite.org/v1beta/style +name: me.totalfreedom.totalfreedommod.WildcardStyle +styleConfigs: + - org.openrewrite.java.style.ImportLayoutStyle: + classCountToUseStarImport: 3 + nameCountToUseStarImport: 3 + layout: + # JDK + - import java.* + - import javax.* + - <blank line> + # server platform + - import com.destroystokyo.* + - import com.mojang.* + - import io.papermc.* + - import org.bukkit.* + - <blank line> + # adventure + - import net.kyori.* + - <blank line> + # third-party plugins + - import com.sk89q.* + - import net.coreprotect.* + - import net.milkbowl.* + - <blank line> + # github imports + - import com.github.* + - <blank line> + # reactive + - import discord4j.* + - import io.netty.* + - import reactor.* + - <blank line> + # project + - import me.totalfreedom.* + - <blank line> + # everything else + - import all other imports + - <blank line> + - import static all other imports From f34d425f74ece91b90a1800eda185958c27cfb00 Mon Sep 17 00:00:00 2001 From: Paldiu <pawereus@gmail.com> Date: Wed, 5 Aug 2026 15:15:42 -0500 Subject: [PATCH 28/48] Update rewrite.yml --- rewrite.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rewrite.yml b/rewrite.yml index b0df2d1ca..299970e96 100644 --- a/rewrite.yml +++ b/rewrite.yml @@ -3,8 +3,8 @@ type: specs.openrewrite.org/v1beta/style name: me.totalfreedom.totalfreedommod.WildcardStyle styleConfigs: - org.openrewrite.java.style.ImportLayoutStyle: - classCountToUseStarImport: 3 - nameCountToUseStarImport: 3 + classCountToUseStarImport: 5 + nameCountToUseStarImport: 5 layout: # JDK - import java.* From 874b1f4a1d4384e8507ce98a5b8efa1a4c138058 Mon Sep 17 00:00:00 2001 From: Paldiu <pawereus@gmail.com> Date: Wed, 5 Aug 2026 15:16:48 -0500 Subject: [PATCH 29/48] Update rewrite.yml --- rewrite.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/rewrite.yml b/rewrite.yml index 299970e96..f389fc4f7 100644 --- a/rewrite.yml +++ b/rewrite.yml @@ -3,6 +3,7 @@ type: specs.openrewrite.org/v1beta/style name: me.totalfreedom.totalfreedommod.WildcardStyle styleConfigs: - org.openrewrite.java.style.ImportLayoutStyle: + # anything lower than 5 will cause conflicts, 5 or higher is a good standard. classCountToUseStarImport: 5 nameCountToUseStarImport: 5 layout: From 0c9aad2332dc1ffe41205accd98b8b129296ba86 Mon Sep 17 00:00:00 2001 From: Paldiu <pawereus@gmail.com> Date: Wed, 5 Aug 2026 15:17:15 -0500 Subject: [PATCH 30/48] Complete import reorganization and pruning --- .../totalfreedommod/Announcer.java | 11 ++-- .../totalfreedommod/AntiDrop.java | 11 ++-- .../totalfreedommod/AntiNuke.java | 10 +-- .../totalfreedommod/AntiSpam.java | 17 +++-- .../totalfreedommod/AutoEject.java | 11 ++-- .../totalfreedommod/AutoKick.java | 7 +- .../totalfreedommod/BackupManager.java | 10 +-- .../totalfreedom/totalfreedommod/BookSpy.java | 20 +++--- .../totalfreedommod/ChatManager.java | 33 +++++----- .../totalfreedommod/CommandSpy.java | 16 +++-- .../totalfreedommod/ConfigConverter.java | 15 +++-- .../totalfreedommod/EntityWiper.java | 31 ++------- .../totalfreedommod/FreedomService.java | 3 +- .../totalfreedom/totalfreedommod/Fuckoff.java | 4 +- .../totalfreedommod/GameModeGuard.java | 11 ++-- .../totalfreedommod/GameRuleHandler.java | 24 +++---- .../totalfreedommod/JoinLeaveMessages.java | 10 +-- .../totalfreedommod/LoginProcess.java | 22 +++---- .../totalfreedommod/MovementValidator.java | 7 +- .../totalfreedom/totalfreedommod/Muter.java | 19 +++--- .../totalfreedom/totalfreedommod/Orbiter.java | 3 +- .../totalfreedommod/PotionSpy.java | 8 ++- .../totalfreedommod/ProtectArea.java | 25 +++---- .../totalfreedommod/SavedFlags.java | 21 +++--- .../totalfreedommod/ServerPing.java | 12 ++-- .../totalfreedom/totalfreedommod/SignSpy.java | 22 ++++--- .../totalfreedommod/SpawnManager.java | 5 +- .../totalfreedommod/SpectatorBlocker.java | 30 +++++---- .../totalfreedommod/TFMLibraryLoader.java | 1 + .../totalfreedommod/TextFilterService.java | 19 +++--- .../totalfreedommod/TotalFreedomMod.java | 38 ++++------- .../totalfreedommod/admin/Admin.java | 9 ++- .../totalfreedommod/admin/AdminList.java | 28 ++++---- .../totalfreedommod/banning/Ban.java | 16 +++-- .../totalfreedommod/banning/BanManager.java | 38 ++++++----- .../totalfreedommod/banning/PermBan.java | 6 +- .../totalfreedommod/banning/PermbanList.java | 37 ++++++----- .../totalfreedommod/banning/StrikeList.java | 13 ++-- .../blocking/BlockBlocker.java | 18 ++--- .../blocking/EventBlocker.java | 32 ++------- .../blocking/InteractBlocker.java | 14 ++-- .../totalfreedommod/blocking/MobBlocker.java | 18 ++--- .../blocking/PotionBlocker.java | 11 ++-- .../blocking/command/CommandBlocker.java | 26 ++++---- .../blocking/command/CommandBlockerEntry.java | 12 ++-- .../blocking/command/CommandBlockerRank.java | 5 +- .../entity/EntityMetaPacketGuard.java | 11 ++-- .../blocking/entity/EntityNameValidator.java | 19 +++--- .../blocking/entity/EntitySizeGuard.java | 15 +++-- .../blocking/entity/ProjectileGuard.java | 25 ++++--- .../blocking/entity/TextDisplayGuard.java | 18 ++--- .../blocking/entity/WaypointGuard.java | 18 ++--- .../blocking/item/ConsoleSpamFilter.java | 1 + .../blocking/item/EntityDataRules.java | 1 + .../blocking/item/ItemScanner.java | 30 +++------ .../blocking/item/ItemValidator.java | 53 ++++++--------- .../blocking/item/RawNbtInspector.java | 1 + .../blocking/packet/CrashPacketListener.java | 47 ++++++-------- .../blocking/packet/CrashPacketService.java | 12 ++-- .../blocking/sign/SignValidator.java | 23 ++++--- .../blocking/spawner/SpawnerValidator.java | 16 +++-- .../blocking/sweep/SweepScheduler.java | 10 +-- .../blocking/sweep/TileEntityVisitor.java | 1 + .../bridge/CoreProtectBridge.java | 21 +++--- .../bridge/EssentialsBridge.java | 5 +- .../bridge/LibsDisguisesBridge.java | 8 ++- .../bridge/WorldEditBridge.java | 10 +-- .../totalfreedommod/bridge/WorldEditHook.java | 65 +++++++++---------- .../totalfreedommod/caging/CageData.java | 6 +- .../totalfreedommod/caging/Cager.java | 12 ++-- .../totalfreedommod/cmd/BanCommandUtil.java | 3 +- .../totalfreedommod/cmd/CommandLoader.java | 22 ++++--- .../totalfreedommod/cmd/CommandRegistry.java | 1 - .../cmd/Command_adminchat.java | 14 ++-- .../cmd/Command_admininfo.java | 7 +- .../cmd/Command_adminworld.java | 3 +- .../cmd/Command_adventure.java | 3 +- .../totalfreedommod/cmd/Command_aeclear.java | 5 +- .../cmd/Command_attributelist.java | 7 +- .../cmd/Command_autoclear.java | 5 +- .../totalfreedommod/cmd/Command_autotp.java | 5 +- .../totalfreedommod/cmd/Command_ban.java | 5 +- .../totalfreedommod/cmd/Command_banip.java | 5 +- .../totalfreedommod/cmd/Command_banlist.java | 3 +- .../totalfreedommod/cmd/Command_banname.java | 7 +- .../totalfreedommod/cmd/Command_blockcmd.java | 7 +- .../totalfreedommod/cmd/Command_cage.java | 3 +- .../cmd/Command_consolesay.java | 3 +- .../totalfreedommod/cmd/Command_cookie.java | 3 +- .../totalfreedommod/cmd/Command_crash.java | 5 +- .../totalfreedommod/cmd/Command_creative.java | 11 ++-- .../totalfreedommod/cmd/Command_deafen.java | 7 +- .../totalfreedommod/cmd/Command_deop.java | 3 +- .../cmd/Command_disguisetoggle.java | 3 +- .../totalfreedommod/cmd/Command_doom.java | 13 ++-- .../totalfreedommod/cmd/Command_enchant.java | 11 ++-- .../cmd/Command_entitywipe.java | 3 +- .../totalfreedommod/cmd/Command_expel.java | 18 ++--- .../totalfreedommod/cmd/Command_findip.java | 3 +- .../totalfreedommod/cmd/Command_freeze.java | 11 ++-- .../totalfreedommod/cmd/Command_fuckoff.java | 5 +- .../totalfreedommod/cmd/Command_gchat.java | 8 ++- .../totalfreedommod/cmd/Command_gcmd.java | 8 ++- .../totalfreedommod/cmd/Command_invis.java | 15 +++-- .../totalfreedommod/cmd/Command_jumppads.java | 10 +-- .../totalfreedommod/cmd/Command_kick.java | 9 +-- .../totalfreedommod/cmd/Command_landmine.java | 13 ++-- .../totalfreedommod/cmd/Command_link.java | 5 +- .../totalfreedommod/cmd/Command_list.java | 9 +-- .../cmd/Command_localspawn.java | 3 +- .../totalfreedommod/cmd/Command_lockup.java | 13 ++-- .../cmd/Command_moblimiter.java | 11 ++-- .../totalfreedommod/cmd/Command_mobpurge.java | 10 +-- .../totalfreedommod/cmd/Command_mp44.java | 8 +-- .../totalfreedommod/cmd/Command_myadmin.java | 6 +- .../totalfreedommod/cmd/Command_nickname.java | 21 +++--- .../totalfreedommod/cmd/Command_nicknyan.java | 9 +-- .../totalfreedommod/cmd/Command_op.java | 3 +- .../totalfreedommod/cmd/Command_opall.java | 3 +- .../totalfreedommod/cmd/Command_ops.java | 3 +- .../totalfreedommod/cmd/Command_orbit.java | 13 ++-- .../totalfreedommod/cmd/Command_permban.java | 5 +- .../cmd/Command_permbanlist.java | 7 +- .../cmd/Command_plugincontrol.java | 9 +-- .../totalfreedommod/cmd/Command_potion.java | 9 +-- .../totalfreedommod/cmd/Command_premium.java | 10 +-- .../cmd/Command_protectarea.java | 17 ++--- .../totalfreedommod/cmd/Command_purgeall.java | 6 +- .../totalfreedommod/cmd/Command_radar.java | 16 +++-- .../totalfreedommod/cmd/Command_rank.java | 11 ++-- .../cmd/Command_rankconfig.java | 13 ++-- .../totalfreedommod/cmd/Command_realname.java | 7 +- .../totalfreedommod/cmd/Command_report.java | 5 +- .../totalfreedommod/cmd/Command_ro.java | 9 +-- .../totalfreedommod/cmd/Command_saconfig.java | 15 +++-- .../totalfreedommod/cmd/Command_say.java | 7 +- .../totalfreedommod/cmd/Command_setspawn.java | 3 +- .../totalfreedommod/cmd/Command_settings.java | 5 +- .../totalfreedommod/cmd/Command_smite.java | 14 ++-- .../totalfreedommod/cmd/Command_spawn.java | 3 +- .../totalfreedommod/cmd/Command_spawnmob.java | 12 ++-- .../cmd/Command_sqlstatus.java | 7 +- .../totalfreedommod/cmd/Command_sshtotp.java | 15 +++-- .../totalfreedommod/cmd/Command_stfu.java | 15 +++-- .../totalfreedommod/cmd/Command_stop.java | 1 + .../totalfreedommod/cmd/Command_strikes.java | 9 +-- .../totalfreedommod/cmd/Command_survival.java | 11 ++-- .../totalfreedommod/cmd/Command_tag.java | 7 +- .../totalfreedommod/cmd/Command_tempban.java | 16 ++--- .../totalfreedommod/cmd/Command_title.java | 21 +++--- .../totalfreedommod/cmd/Command_tossmob.java | 13 ++-- .../cmd/Command_totalfreedommod.java | 6 +- .../totalfreedommod/cmd/Command_unban.java | 3 +- .../totalfreedommod/cmd/Command_unbanip.java | 3 +- .../totalfreedommod/cmd/Command_warn.java | 9 +-- .../cmd/Command_whitelist.java | 11 ++-- .../totalfreedommod/cmd/Command_whohas.java | 9 +-- .../totalfreedommod/cmd/Command_wildcard.java | 8 ++- .../totalfreedommod/cmd/FCommand.java | 24 +++---- .../totalfreedommod/cmd/MessageUtils.java | 3 +- .../totalfreedommod/cmd/NameCandidates.java | 8 +-- .../cmd/internal/ArgumentResolver.java | 8 +-- .../cmd/internal/CommandProcessor.java | 40 ++++++------ .../cmd/internal/PermissionGate.java | 10 +-- .../cmd/internal/annotation/Callback.java | 6 +- .../cmd/internal/annotation/Command.java | 6 +- .../cmd/internal/annotation/Completer.java | 6 +- .../cmd/internal/annotation/Cooldown.java | 8 +-- .../cmd/internal/annotation/Greedy.java | 6 +- .../cmd/internal/annotation/Permission.java | 7 +- .../cmd/internal/annotation/Resolve.java | 6 +- .../cmd/internal/annotation/Subcommand.java | 6 +- .../cmd/internal/annotation/Switch.java | 6 +- ...AbstractParameterizedArgumentResolver.java | 7 +- .../resolver/DateOffsetArgumentResolver.java | 4 +- .../resolver/EnchantmentArgumentResolver.java | 5 +- .../resolver/EntityTypeArgumentResolver.java | 5 +- .../cmd/resolver/EnumArgumentResolver.java | 6 +- .../cmd/resolver/InetAddressListResolver.java | 14 ++-- .../cmd/resolver/InetAddressResolver.java | 4 +- .../cmd/resolver/KeyArgumentResolver.java | 3 +- .../resolver/MaterialArgumentResolver.java | 5 +- .../MaterialQueryArgumentProvider.java | 6 +- .../OfflinePlayerArgumentResolver.java | 7 +- .../cmd/resolver/PlayerArgumentResolver.java | 8 +-- .../resolver/PlayerListArgumentResolver.java | 6 +- .../PotionEffectTypeArgumentResolver.java | 5 +- .../cmd/resolver/WeatherArgumentResolver.java | 9 +-- .../resolver/WorldTimeArgumentResolver.java | 9 +-- .../totalfreedommod/config/ConfigEntry.java | 4 +- .../totalfreedommod/config/MainConfig.java | 9 ++- .../discord/AbstractDiscordChatRelay.java | 18 ++--- .../discord/DiscordBridge.java | 32 ++++----- .../discord/DiscordCommands.java | 5 +- .../discord/DiscordConnection.java | 7 +- .../discord/DiscordConsoleRelay.java | 11 ++-- .../discord/DiscordLinkJsonSync.java | 8 ++- .../discord/DiscordMarkdown.java | 1 + .../disguise/DisallowedDisguises.java | 4 +- .../framework/AbstractService.java | 3 +- .../framework/PluginComponent.java | 3 +- .../framework/PluginListener.java | 3 +- .../framework/ServiceManager.java | 4 +- .../totalfreedommod/freeze/FreezeData.java | 15 +++-- .../totalfreedommod/freeze/Freezer.java | 10 +-- .../totalfreedommod/fun/ItemFun.java | 16 +++-- .../totalfreedommod/fun/Jumppads.java | 17 ++--- .../totalfreedommod/fun/Landminer.java | 11 ++-- .../totalfreedommod/fun/MP44.java | 5 +- .../totalfreedommod/fun/Trailer.java | 5 +- .../httpd/HTMLGenerationTools.java | 3 +- .../totalfreedommod/httpd/HTTPDaemon.java | 9 +-- .../httpd/ModuleExecutable.java | 9 ++- .../totalfreedommod/httpd/NanoHTTPD.java | 33 ++-------- .../httpd/module/HTTPDModule.java | 5 +- .../httpd/module/Module_file.java | 14 +--- .../httpd/module/Module_help.java | 17 ++--- .../httpd/module/Module_list.java | 6 +- .../httpd/module/Module_permbans.java | 1 + .../httpd/module/Module_players.java | 10 +-- .../httpd/module/Module_schematic.java | 9 +-- .../totalfreedommod/player/FPlayer.java | 26 ++++---- .../totalfreedommod/player/PlayerData.java | 17 +++-- .../totalfreedommod/player/PlayerList.java | 29 +++++---- .../rank/ConsoleSenderRegistry.java | 1 + .../totalfreedommod/rank/CustomRank.java | 8 ++- .../totalfreedommod/rank/PermissionTrie.java | 7 +- .../totalfreedommod/rank/RankManager.java | 49 +++++++------- .../totalfreedommod/rank/RankRegistry.java | 10 +-- .../totalfreedommod/sql/AccessController.java | 4 +- .../sql/ConnectionHandler.java | 15 ++--- .../totalfreedommod/sql/FreedomDatabase.java | 26 ++------ .../totalfreedommod/sql/PersistenceQueue.java | 4 +- .../totalfreedommod/sql/SQLProperties.java | 8 +-- .../sql/YamlMigrationService.java | 36 +++++----- .../sql/adapter/AdminRepository.java | 4 +- .../sql/adapter/BanRepository.java | 4 +- .../sql/adapter/DatabaseAdapter.java | 4 +- .../sql/adapter/PermbanRepository.java | 4 +- .../sql/adapter/PlayerRepository.java | 4 +- .../sql/adapter/ProtectedAreaRepository.java | 4 +- .../sql/adapter/RankRepository.java | 4 +- .../sql/adapter/StrikeRepository.java | 4 +- .../sql/adapter/TitleRepository.java | 4 +- .../generic/GenericAdminRepository.java | 12 ++-- .../adapter/generic/GenericBanRepository.java | 12 ++-- .../generic/GenericDiscordLinkRepository.java | 8 +-- .../generic/GenericMigrationRepository.java | 8 +-- .../generic/GenericPermbanRepository.java | 10 +-- .../generic/GenericPlayerRepository.java | 14 ++-- .../GenericProtectedAreaRepository.java | 14 ++-- .../generic/GenericRankRepository.java | 15 +++-- .../generic/GenericSavedFlagRepository.java | 8 +-- .../generic/GenericStrikeRepository.java | 10 +-- .../generic/GenericTitleRepository.java | 7 +- .../sql/adapter/mysql/MySQLAdapter.java | 8 +-- .../adapter/postgresql/PostgreSQLAdapter.java | 8 +-- .../sql/adapter/sqlite/SQLiteAdapter.java | 10 +-- .../ssh/AttributedConsoleSender.java | 12 ++-- .../ssh/SshCommandCompleter.java | 7 +- .../ssh/SshConsoleCommandFactory.java | 18 +++-- .../ssh/SshConsoleShellFactory.java | 24 +++---- .../totalfreedommod/ssh/SshDaemon.java | 2 + .../ssh/SshHandshakeAuthenticator.java | 5 +- .../totalfreedommod/ssh/SshIdentityStore.java | 13 ++-- .../ssh/SshPasswordAuthenticator.java | 2 + .../ssh/SshPublicKeyAuthenticator.java | 5 +- .../totalfreedommod/ssh/SshQrServer.java | 11 ++-- .../totalfreedommod/ssh/TotpUtil.java | 4 +- .../totalfreedommod/tablist/TabList.java | 16 +++-- .../totalfreedommod/title/Title.java | 6 +- .../totalfreedommod/title/TitleManager.java | 18 +++-- .../totalfreedommod/util/AdventureUtil.java | 1 + .../util/CallbackLogAppender.java | 1 + .../totalfreedommod/util/ChatMentionUtil.java | 17 ++--- .../util/ComponentScanner.java | 1 + .../util/DetectionReporter.java | 9 ++- .../totalfreedommod/util/FSync.java | 10 +-- .../totalfreedommod/util/FUtil.java | 24 ++++--- .../totalfreedommod/util/JsonUtil.java | 10 +-- .../totalfreedommod/util/MaterialHelper.java | 1 + .../totalfreedommod/vault/ChatService.java | 10 +-- .../vault/PermissionService.java | 8 ++- .../vault/VaultProviderRegistry.java | 5 +- .../totalfreedommod/world/AdminWorld.java | 14 ++-- .../world/CleanroomChunkGenerator.java | 1 + .../totalfreedommod/world/CustomWorld.java | 15 +++-- .../totalfreedommod/world/Flatlands.java | 18 ++--- .../totalfreedommod/world/WorldManager.java | 15 +++-- .../totalfreedommod/world/WorldTime.java | 1 + .../totalfreedommod/world/WorldWeather.java | 1 + 291 files changed, 1672 insertions(+), 1531 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/Announcer.java b/src/main/java/me/totalfreedom/totalfreedommod/Announcer.java index 3a2ce8ecc..8f36b5911 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/Announcer.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/Announcer.java @@ -1,14 +1,17 @@ package me.totalfreedom.totalfreedommod; -import com.google.common.collect.Lists; import java.util.Collections; import java.util.List; -import lombok.Getter; + +import org.bukkit.scheduler.BukkitRunnable; +import org.bukkit.scheduler.BukkitTask; + import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.util.FTask; import me.totalfreedom.totalfreedommod.util.FUtil; -import org.bukkit.scheduler.BukkitRunnable; -import org.bukkit.scheduler.BukkitTask; + +import com.google.common.collect.Lists; +import lombok.Getter; public class Announcer extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/AntiDrop.java b/src/main/java/me/totalfreedom/totalfreedommod/AntiDrop.java index 06cf2cfec..458772d55 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/AntiDrop.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/AntiDrop.java @@ -2,10 +2,7 @@ import java.util.Iterator; import java.util.List; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.player.FPlayer; -import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.format.NamedTextColor; + import org.bukkit.entity.Item; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; @@ -14,6 +11,12 @@ import org.bukkit.event.player.PlayerDropItemEvent; import org.bukkit.inventory.ItemStack; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.player.FPlayer; +import me.totalfreedom.totalfreedommod.util.FUtil; + public class AntiDrop extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/AntiNuke.java b/src/main/java/me/totalfreedom/totalfreedommod/AntiNuke.java index 2ec7e3817..617f7ab5f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/AntiNuke.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/AntiNuke.java @@ -1,9 +1,5 @@ package me.totalfreedom.totalfreedommod; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.player.FPlayer; -import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Location; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; @@ -11,6 +7,12 @@ import org.bukkit.event.block.BlockBreakEvent; import org.bukkit.event.block.BlockPlaceEvent; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.player.FPlayer; +import me.totalfreedom.totalfreedommod.util.FUtil; + public class AntiNuke extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/AntiSpam.java b/src/main/java/me/totalfreedom/totalfreedommod/AntiSpam.java index 205477a68..fc28a67d7 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/AntiSpam.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/AntiSpam.java @@ -1,22 +1,25 @@ package me.totalfreedom.totalfreedommod; -import io.papermc.paper.event.player.AsyncChatEvent; import java.util.Locale; import java.util.TreeSet; import java.util.regex.Matcher; import java.util.regex.Pattern; + +import io.papermc.paper.event.player.AsyncChatEvent; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.player.PlayerCommandPreprocessEvent; + +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; + import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.player.ChatSpamData; import me.totalfreedom.totalfreedommod.player.FPlayer; import me.totalfreedom.totalfreedommod.util.AdventureUtil; import me.totalfreedom.totalfreedommod.util.FSync; import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.format.NamedTextColor; -import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; -import org.bukkit.entity.Player; -import org.bukkit.event.EventHandler; -import org.bukkit.event.EventPriority; -import org.bukkit.event.player.PlayerCommandPreprocessEvent; public class AntiSpam extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/AutoEject.java b/src/main/java/me/totalfreedom/totalfreedommod/AutoEject.java index fc8810c70..6e51bf6b9 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/AutoEject.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/AutoEject.java @@ -3,14 +3,17 @@ import java.util.Calendar; import java.util.Date; import java.util.GregorianCalendar; + +import org.bukkit.Bukkit; +import org.bukkit.GameMode; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.format.NamedTextColor; + import me.totalfreedom.totalfreedommod.banning.Ban; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.format.NamedTextColor; -import org.bukkit.Bukkit; -import org.bukkit.GameMode; -import org.bukkit.entity.Player; public class AutoEject extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/AutoKick.java b/src/main/java/me/totalfreedom/totalfreedommod/AutoKick.java index 77195cdbd..f8f105b99 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/AutoKick.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/AutoKick.java @@ -1,12 +1,13 @@ package me.totalfreedom.totalfreedommod; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.FTask; -import me.totalfreedom.totalfreedommod.util.FUtil; import org.bukkit.entity.Player; import org.bukkit.scheduler.BukkitRunnable; import org.bukkit.scheduler.BukkitTask; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.FTask; +import me.totalfreedom.totalfreedommod.util.FUtil; + public class AutoKick extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/BackupManager.java b/src/main/java/me/totalfreedom/totalfreedommod/BackupManager.java index ecbc0a8fe..58f46f0f7 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/BackupManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/BackupManager.java @@ -1,9 +1,5 @@ package me.totalfreedom.totalfreedommod; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.google.gson.reflect.TypeToken; - import java.io.File; import java.io.FileReader; import java.io.FileWriter; @@ -12,11 +8,15 @@ import java.util.HashMap; import java.util.Map; +import org.bukkit.util.FileUtil; + import me.totalfreedom.totalfreedommod.framework.PluginComponent; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FUtil; -import org.bukkit.util.FileUtil; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.reflect.TypeToken; public class BackupManager extends PluginComponent<TotalFreedomMod> { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/BookSpy.java b/src/main/java/me/totalfreedom/totalfreedommod/BookSpy.java index 76a182525..57c1ab4b5 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/BookSpy.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/BookSpy.java @@ -4,15 +4,6 @@ import java.util.ArrayList; import java.util.List; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.player.PlayerData; -import me.totalfreedom.totalfreedommod.display.Displayable; -import me.totalfreedom.totalfreedommod.util.*; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.event.ClickCallback; -import net.kyori.adventure.text.event.ClickEvent; -import net.kyori.adventure.text.event.HoverEvent; -import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Bukkit; import org.bukkit.Material; import org.bukkit.entity.Player; @@ -22,6 +13,17 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.BookMeta; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickCallback; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.display.Displayable; +import me.totalfreedom.totalfreedommod.player.PlayerData; +import me.totalfreedom.totalfreedommod.util.*; + public class BookSpy extends FreedomService { private static final Duration VIEW_LIFETIME = Duration.ofMinutes(10); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ChatManager.java b/src/main/java/me/totalfreedom/totalfreedommod/ChatManager.java index ac991f737..00298d86e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ChatManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ChatManager.java @@ -1,21 +1,6 @@ package me.totalfreedom.totalfreedommod; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.display.Displayable; -import me.totalfreedom.totalfreedommod.player.FPlayer; -import me.totalfreedom.totalfreedommod.player.PlayerData; -import me.totalfreedom.totalfreedommod.util.AdventureUtil; -import me.totalfreedom.totalfreedommod.util.*; -import me.totalfreedom.totalfreedommod.vault.VaultProviderRegistry; -import static me.totalfreedom.totalfreedommod.util.FUtil.playerMsg; - -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.event.ClickEvent; -import net.kyori.adventure.text.event.HoverEvent; -import net.kyori.adventure.text.format.NamedTextColor; -import net.kyori.adventure.text.serializer.ansi.ANSIComponentSerializer; -import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; - +import io.papermc.paper.event.player.AsyncChatEvent; import org.bukkit.Bukkit; import org.bukkit.OfflinePlayer; import org.bukkit.command.CommandSender; @@ -25,9 +10,23 @@ import org.bukkit.event.server.PluginEnableEvent; import org.bukkit.plugin.Plugin; -import io.papermc.paper.event.player.AsyncChatEvent; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.serializer.ansi.ANSIComponentSerializer; +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.display.Displayable; +import me.totalfreedom.totalfreedommod.player.FPlayer; +import me.totalfreedom.totalfreedommod.player.PlayerData; +import me.totalfreedom.totalfreedommod.util.*; +import me.totalfreedom.totalfreedommod.vault.VaultProviderRegistry; + +import static me.totalfreedom.totalfreedommod.util.FUtil.playerMsg; + public class ChatManager extends FreedomService { // The maximum message length that the Java Minecraft client can currently handle. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/CommandSpy.java b/src/main/java/me/totalfreedom/totalfreedommod/CommandSpy.java index 41e019649..81f1c933f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/CommandSpy.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/CommandSpy.java @@ -1,17 +1,19 @@ package me.totalfreedom.totalfreedommod; -import me.totalfreedom.totalfreedommod.display.Displayable; -import me.totalfreedom.totalfreedommod.player.SpyMode; -import me.totalfreedom.totalfreedommod.player.FPlayer; -import me.totalfreedom.totalfreedommod.util.AdventureUtil; -import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.player.PlayerCommandPreprocessEvent; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.display.Displayable; +import me.totalfreedom.totalfreedommod.player.FPlayer; +import me.totalfreedom.totalfreedommod.player.SpyMode; +import me.totalfreedom.totalfreedommod.util.AdventureUtil; +import me.totalfreedom.totalfreedommod.util.FUtil; + public class CommandSpy extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ConfigConverter.java b/src/main/java/me/totalfreedom/totalfreedommod/ConfigConverter.java index 954d183b0..b6e4bcf4e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ConfigConverter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ConfigConverter.java @@ -1,8 +1,5 @@ package me.totalfreedom.totalfreedommod; -import com.google.common.collect.Lists; -import com.google.common.io.Files; -import com.google.gson.reflect.TypeToken; import java.io.File; import java.io.FileWriter; import java.io.IOException; @@ -10,18 +7,24 @@ import java.util.HashMap; import java.util.List; import java.util.Map; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; + import me.totalfreedom.totalfreedommod.admin.Admin; import me.totalfreedom.totalfreedommod.admin.AdminList; import me.totalfreedom.totalfreedommod.banning.PermBan; import me.totalfreedom.totalfreedommod.banning.PermbanList; +import me.totalfreedom.totalfreedommod.framework.PluginComponent; import me.totalfreedom.totalfreedommod.player.PlayerData; import me.totalfreedom.totalfreedommod.rank.CustomRank; import me.totalfreedom.totalfreedommod.rank.RankRole; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.JsonUtil; -import me.totalfreedom.totalfreedommod.framework.PluginComponent; -import org.bukkit.configuration.ConfigurationSection; -import org.bukkit.configuration.file.YamlConfiguration; + +import com.google.common.collect.Lists; +import com.google.common.io.Files; +import com.google.gson.reflect.TypeToken; public class ConfigConverter extends PluginComponent<TotalFreedomMod> { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/EntityWiper.java b/src/main/java/me/totalfreedom/totalfreedommod/EntityWiper.java index f34d3f11a..af2053529 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/EntityWiper.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/EntityWiper.java @@ -1,37 +1,20 @@ package me.totalfreedom.totalfreedommod; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashMap; -import java.util.Iterator; -import java.util.List; -import java.util.Map; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.FTask; -import me.totalfreedom.totalfreedommod.util.FUtil; +import java.util.*; + import org.bukkit.Bukkit; import org.bukkit.Chunk; import org.bukkit.World; -import org.bukkit.entity.AreaEffectCloud; -import org.bukkit.entity.ArmorStand; -import org.bukkit.entity.Boat; -import org.bukkit.entity.EnderCrystal; -import org.bukkit.entity.EnderSignal; -import org.bukkit.entity.Entity; -import org.bukkit.entity.ExperienceOrb; -import org.bukkit.entity.Explosive; -import org.bukkit.entity.FallingBlock; -import org.bukkit.entity.Firework; -import org.bukkit.entity.Item; -import org.bukkit.entity.Minecart; -import org.bukkit.entity.Projectile; -import org.bukkit.entity.ThrownExpBottle; -import org.bukkit.entity.ThrownPotion; +import org.bukkit.entity.*; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.entity.ItemSpawnEvent; import org.bukkit.scheduler.BukkitTask; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.FTask; +import me.totalfreedom.totalfreedommod.util.FUtil; + public class EntityWiper extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/FreedomService.java b/src/main/java/me/totalfreedom/totalfreedommod/FreedomService.java index 5e7eb7d69..5a2bf8437 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/FreedomService.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/FreedomService.java @@ -1,8 +1,9 @@ package me.totalfreedom.totalfreedommod; -import me.totalfreedom.totalfreedommod.framework.AbstractService; import org.bukkit.event.Listener; +import me.totalfreedom.totalfreedommod.framework.AbstractService; + public abstract class FreedomService extends AbstractService<TotalFreedomMod> implements Listener { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/Fuckoff.java b/src/main/java/me/totalfreedom/totalfreedommod/Fuckoff.java index b9c252cc6..7c83eaac1 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/Fuckoff.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/Fuckoff.java @@ -2,7 +2,7 @@ import java.util.HashSet; import java.util.Set; -import me.totalfreedom.totalfreedommod.player.FPlayer; + import org.bukkit.Location; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; @@ -11,6 +11,8 @@ import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.util.Vector; +import me.totalfreedom.totalfreedommod.player.FPlayer; + public class Fuckoff extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/GameModeGuard.java b/src/main/java/me/totalfreedom/totalfreedommod/GameModeGuard.java index f6ea7f786..3eb71aa50 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/GameModeGuard.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/GameModeGuard.java @@ -3,16 +3,19 @@ import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.format.NamedTextColor; + import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.player.PlayerGameModeChangeEvent; import org.bukkit.event.player.PlayerQuitEvent; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.FUtil; + public class GameModeGuard extends FreedomService { private final Map<UUID, RateWindow> changeWindows = new ConcurrentHashMap<>(); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/GameRuleHandler.java b/src/main/java/me/totalfreedom/totalfreedommod/GameRuleHandler.java index b9ae10acd..2203b9e9e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/GameRuleHandler.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/GameRuleHandler.java @@ -1,12 +1,12 @@ package me.totalfreedom.totalfreedommod; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + import io.papermc.paper.event.world.WorldGameRuleChangeEvent; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FTask; -import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.key.Key; -import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Bukkit; import org.bukkit.GameRule; import org.bukkit.Registry; @@ -19,11 +19,13 @@ import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.scheduler.BukkitTask; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.FTask; +import me.totalfreedom.totalfreedommod.util.FUtil; public class GameRuleHandler extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/JoinLeaveMessages.java b/src/main/java/me/totalfreedom/totalfreedommod/JoinLeaveMessages.java index f3c07d26d..41e79a35f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/JoinLeaveMessages.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/JoinLeaveMessages.java @@ -1,15 +1,17 @@ package me.totalfreedom.totalfreedommod; -import me.totalfreedom.totalfreedommod.cmd.MessageUtils; -import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerQuitEvent; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + +import me.totalfreedom.totalfreedommod.cmd.MessageUtils; +import me.totalfreedom.totalfreedommod.util.FUtil; + public class JoinLeaveMessages extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/LoginProcess.java b/src/main/java/me/totalfreedom/totalfreedommod/LoginProcess.java index 357bad1fa..da9da17c4 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/LoginProcess.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/LoginProcess.java @@ -3,26 +3,26 @@ import java.util.ArrayList; import java.util.List; import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; import java.util.regex.Pattern; -import me.totalfreedom.totalfreedommod.admin.Admin; -import me.totalfreedom.totalfreedommod.cmd.MessageUtils; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.FSync; -import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; +import com.destroystokyo.paper.profile.PlayerProfile; +import org.bukkit.Location; +import org.bukkit.World; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.player.AsyncPlayerPreLoginEvent; import org.bukkit.event.player.PlayerJoinEvent; -import com.destroystokyo.paper.profile.PlayerProfile; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; -import java.util.concurrent.ThreadLocalRandom; -import org.bukkit.Location; -import org.bukkit.World; +import me.totalfreedom.totalfreedommod.admin.Admin; +import me.totalfreedom.totalfreedommod.cmd.MessageUtils; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.FSync; +import me.totalfreedom.totalfreedommod.util.FUtil; public class LoginProcess extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/MovementValidator.java b/src/main/java/me/totalfreedom/totalfreedommod/MovementValidator.java index 3e981280f..cd0eadbb2 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/MovementValidator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/MovementValidator.java @@ -1,8 +1,7 @@ package me.totalfreedom.totalfreedommod; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.FLog; +import io.papermc.paper.event.player.PlayerClientLoadedWorldEvent; import org.bukkit.Location; import org.bukkit.World; import org.bukkit.WorldBorder; @@ -10,7 +9,9 @@ import org.bukkit.event.EventPriority; import org.bukkit.event.player.PlayerTeleportEvent; import org.bukkit.event.world.WorldLoadEvent; -import io.papermc.paper.event.player.PlayerClientLoadedWorldEvent; + +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.FLog; public class MovementValidator extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/Muter.java b/src/main/java/me/totalfreedom/totalfreedommod/Muter.java index 301851286..aeb401299 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/Muter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/Muter.java @@ -1,21 +1,24 @@ package me.totalfreedom.totalfreedommod; import java.util.List; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.player.FPlayer; -import me.totalfreedom.totalfreedommod.util.AdventureUtil; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FSync; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; + +import io.papermc.paper.event.player.AsyncChatEvent; import org.bukkit.command.Command; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; -import io.papermc.paper.event.player.AsyncChatEvent; import org.bukkit.event.player.AsyncPlayerChatEvent; import org.bukkit.event.player.PlayerCommandPreprocessEvent; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.player.FPlayer; +import me.totalfreedom.totalfreedommod.util.AdventureUtil; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.FSync; + public class Muter extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/Orbiter.java b/src/main/java/me/totalfreedom/totalfreedommod/Orbiter.java index fdf8d49ab..37f4a3e45 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/Orbiter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/Orbiter.java @@ -1,12 +1,13 @@ package me.totalfreedom.totalfreedommod; -import me.totalfreedom.totalfreedommod.player.FPlayer; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.player.PlayerMoveEvent; import org.bukkit.util.Vector; +import me.totalfreedom.totalfreedommod.player.FPlayer; + public class Orbiter extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/PotionSpy.java b/src/main/java/me/totalfreedom/totalfreedommod/PotionSpy.java index b17f5b171..220227f1c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/PotionSpy.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/PotionSpy.java @@ -3,7 +3,6 @@ import java.util.HashMap; import java.util.Map; -import org.apache.commons.lang3.tuple.Pair; import org.bukkit.Bukkit; import org.bukkit.entity.Player; import org.bukkit.entity.Projectile; @@ -13,12 +12,15 @@ import org.bukkit.event.entity.ProjectileLaunchEvent; import org.bukkit.projectiles.ProjectileSource; -import me.totalfreedom.totalfreedommod.player.PlayerData; -import me.totalfreedom.totalfreedommod.util.FUtil; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.format.NamedTextColor; +import me.totalfreedom.totalfreedommod.player.PlayerData; +import me.totalfreedom.totalfreedommod.util.FUtil; + +import org.apache.commons.lang3.tuple.Pair; + public class PotionSpy extends FreedomService { private static final long POTION_SPY_RESET_TIMEOUT = 60000L; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java b/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java index 9b896ed89..170b71312 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java @@ -1,20 +1,9 @@ package me.totalfreedom.totalfreedommod; -import com.google.common.collect.Maps; -import com.google.gson.reflect.TypeToken; - import java.io.*; import java.lang.reflect.Type; import java.util.*; -import me.totalfreedom.totalfreedommod.ProtectArea.ProtectedRegion.CantFindWorldException; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.sql.PersistenceQueue; -import me.totalfreedom.totalfreedommod.sql.adapter.ProtectedAreaRepository; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FTask; -import me.totalfreedom.totalfreedommod.util.JsonUtil; - import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.World; @@ -31,10 +20,22 @@ import org.bukkit.event.player.*; import org.bukkit.event.vehicle.VehicleDestroyEvent; import org.bukkit.scheduler.BukkitTask; +import org.bukkit.util.Vector; + import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; -import org.bukkit.util.Vector; + +import me.totalfreedom.totalfreedommod.ProtectArea.ProtectedRegion.CantFindWorldException; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.sql.PersistenceQueue; +import me.totalfreedom.totalfreedommod.sql.adapter.ProtectedAreaRepository; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.FTask; +import me.totalfreedom.totalfreedommod.util.JsonUtil; + +import com.google.common.collect.Maps; +import com.google.gson.reflect.TypeToken; public class ProtectArea extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/SavedFlags.java b/src/main/java/me/totalfreedom/totalfreedommod/SavedFlags.java index 274cd4a65..a1a949bb1 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/SavedFlags.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/SavedFlags.java @@ -1,26 +1,25 @@ package me.totalfreedom.totalfreedommod; -import com.google.gson.reflect.TypeToken; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileReader; -import java.io.FileWriter; -import java.io.IOException; -import java.io.ObjectInputStream; +import java.io.*; import java.lang.reflect.Type; import java.util.Collections; import java.util.HashMap; import java.util.Map; -import me.totalfreedom.totalfreedommod.sql.PersistenceQueue; -import me.totalfreedom.totalfreedommod.sql.adapter.SavedFlagRepository; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.JsonUtil; + import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.YamlConfiguration; + import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; +import me.totalfreedom.totalfreedommod.sql.PersistenceQueue; +import me.totalfreedom.totalfreedommod.sql.adapter.SavedFlagRepository; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.JsonUtil; + +import com.google.gson.reflect.TypeToken; + public class SavedFlags extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ServerPing.java b/src/main/java/me/totalfreedom/totalfreedommod/ServerPing.java index c7f65eca6..4ab28ea6a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ServerPing.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ServerPing.java @@ -1,15 +1,17 @@ package me.totalfreedom.totalfreedommod; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.AdventureUtil; -import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Bukkit; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.server.ServerListPingEvent; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.AdventureUtil; +import me.totalfreedom.totalfreedommod.util.FUtil; + public class ServerPing extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/SignSpy.java b/src/main/java/me/totalfreedom/totalfreedommod/SignSpy.java index cb5f19f66..7ad8f59ee 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/SignSpy.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/SignSpy.java @@ -4,16 +4,6 @@ import java.util.*; import io.papermc.paper.math.Position; -import me.totalfreedom.totalfreedommod.player.PlayerData; -import me.totalfreedom.totalfreedommod.display.Displayable; -import me.totalfreedom.totalfreedommod.util.AdventureUtil; -import me.totalfreedom.totalfreedommod.util.FTask; -import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.event.ClickCallback; -import net.kyori.adventure.text.event.ClickEvent; -import net.kyori.adventure.text.event.HoverEvent; -import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.*; import org.bukkit.block.Sign; import org.bukkit.block.TileState; @@ -27,6 +17,18 @@ import org.bukkit.event.block.SignChangeEvent; import org.bukkit.event.player.PlayerQuitEvent; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickCallback; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.display.Displayable; +import me.totalfreedom.totalfreedommod.player.PlayerData; +import me.totalfreedom.totalfreedommod.util.AdventureUtil; +import me.totalfreedom.totalfreedommod.util.FTask; +import me.totalfreedom.totalfreedommod.util.FUtil; + public class SignSpy extends FreedomService { private static final int LINES_PER_SIDE = 4; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/SpawnManager.java b/src/main/java/me/totalfreedom/totalfreedommod/SpawnManager.java index 67f19b6ce..982475a9c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/SpawnManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/SpawnManager.java @@ -1,7 +1,5 @@ package me.totalfreedom.totalfreedommod; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.FLog; import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.World; @@ -11,6 +9,9 @@ import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerRespawnEvent; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.FLog; + public class SpawnManager extends FreedomService { public SpawnManager(TotalFreedomMod plugin) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/SpectatorBlocker.java b/src/main/java/me/totalfreedom/totalfreedommod/SpectatorBlocker.java index 90dcf2130..8091c3c14 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/SpectatorBlocker.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/SpectatorBlocker.java @@ -1,23 +1,12 @@ package me.totalfreedom.totalfreedommod; -import com.destroystokyo.paper.event.player.PlayerStartSpectatingEntityEvent; -import com.github.retrooper.packetevents.PacketEvents; -import com.github.retrooper.packetevents.event.PacketListenerAbstract; -import com.github.retrooper.packetevents.event.PacketListenerCommon; -import com.github.retrooper.packetevents.event.PacketListenerPriority; -import com.github.retrooper.packetevents.event.PacketSendEvent; -import com.github.retrooper.packetevents.protocol.packettype.PacketType; -import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerPlayerInfoUpdate; import java.util.ArrayList; import java.util.List; import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FTask; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; + +import com.destroystokyo.paper.event.player.PlayerStartSpectatingEntityEvent; import org.bukkit.GameMode; import org.bukkit.entity.Entity; import org.bukkit.entity.Player; @@ -30,6 +19,21 @@ import org.bukkit.plugin.Plugin; import org.bukkit.scheduler.BukkitTask; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + +import com.github.retrooper.packetevents.PacketEvents; +import com.github.retrooper.packetevents.event.PacketListenerAbstract; +import com.github.retrooper.packetevents.event.PacketListenerCommon; +import com.github.retrooper.packetevents.event.PacketListenerPriority; +import com.github.retrooper.packetevents.event.PacketSendEvent; +import com.github.retrooper.packetevents.protocol.packettype.PacketType; +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerPlayerInfoUpdate; + +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.FTask; + public class SpectatorBlocker extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/TFMLibraryLoader.java b/src/main/java/me/totalfreedom/totalfreedommod/TFMLibraryLoader.java index f68bb257c..1510e771e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/TFMLibraryLoader.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/TFMLibraryLoader.java @@ -3,6 +3,7 @@ import io.papermc.paper.plugin.loader.PluginClasspathBuilder; import io.papermc.paper.plugin.loader.PluginLoader; import io.papermc.paper.plugin.loader.library.impl.MavenLibraryResolver; + import org.eclipse.aether.artifact.DefaultArtifact; import org.eclipse.aether.graph.Dependency; import org.eclipse.aether.repository.RemoteRepository; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/TextFilterService.java b/src/main/java/me/totalfreedom/totalfreedommod/TextFilterService.java index 770f2ccbc..dc5c593c3 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/TextFilterService.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/TextFilterService.java @@ -1,23 +1,26 @@ package me.totalfreedom.totalfreedommod; -import io.papermc.paper.event.player.AsyncChatEvent; import java.util.ArrayList; import java.util.List; import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; -import me.totalfreedom.totalfreedommod.banning.Ban; -import me.totalfreedom.totalfreedommod.cmd.MessageUtils; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + +import io.papermc.paper.event.player.AsyncChatEvent; import org.bukkit.Bukkit; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.player.PlayerCommandPreprocessEvent; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + +import me.totalfreedom.totalfreedommod.banning.Ban; +import me.totalfreedom.totalfreedommod.cmd.MessageUtils; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.FUtil; + public class TextFilterService extends FreedomService { private List<Pattern> filters = List.of(); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/TotalFreedomMod.java b/src/main/java/me/totalfreedom/totalfreedommod/TotalFreedomMod.java index ccc248b00..e3d96426d 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/TotalFreedomMod.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/TotalFreedomMod.java @@ -1,32 +1,25 @@ package me.totalfreedom.totalfreedommod; -import me.totalfreedom.totalfreedommod.fun.Trailer; -import me.totalfreedom.totalfreedommod.tablist.TabList; -import me.totalfreedom.totalfreedommod.world.CleanroomChunkGenerator; import java.io.File; import java.io.InputStream; import java.util.Properties; + +import org.bukkit.generator.ChunkGenerator; +import org.bukkit.plugin.java.JavaPlugin; + import me.totalfreedom.totalfreedommod.admin.AdminList; import me.totalfreedom.totalfreedommod.banning.BanManager; import me.totalfreedom.totalfreedommod.banning.PermbanList; import me.totalfreedom.totalfreedommod.banning.StrikeList; -import me.totalfreedom.totalfreedommod.blocking.BlockBlocker; -import me.totalfreedom.totalfreedommod.blocking.EventBlocker; -import me.totalfreedom.totalfreedommod.blocking.InteractBlocker; -import me.totalfreedom.totalfreedommod.blocking.MobBlocker; -import me.totalfreedom.totalfreedommod.blocking.PotionBlocker; +import me.totalfreedom.totalfreedommod.blocking.*; import me.totalfreedom.totalfreedommod.blocking.command.CommandBlocker; -import me.totalfreedom.totalfreedommod.blocking.sweep.SweepScheduler; -import me.totalfreedom.totalfreedommod.blocking.entity.EntityNameValidator; -import me.totalfreedom.totalfreedommod.blocking.entity.EntitySizeGuard; -import me.totalfreedom.totalfreedommod.blocking.entity.TextDisplayGuard; -import me.totalfreedom.totalfreedommod.blocking.entity.ProjectileGuard; -import me.totalfreedom.totalfreedommod.blocking.entity.WaypointGuard; +import me.totalfreedom.totalfreedommod.blocking.entity.*; import me.totalfreedom.totalfreedommod.blocking.item.ConsoleSpamFilter; -import me.totalfreedom.totalfreedommod.blocking.packet.CrashPacketService; import me.totalfreedom.totalfreedommod.blocking.item.ItemValidator; +import me.totalfreedom.totalfreedommod.blocking.packet.CrashPacketService; import me.totalfreedom.totalfreedommod.blocking.sign.SignValidator; import me.totalfreedom.totalfreedommod.blocking.spawner.SpawnerValidator; +import me.totalfreedom.totalfreedommod.blocking.sweep.SweepScheduler; import me.totalfreedom.totalfreedommod.bridge.CoreProtectBridge; import me.totalfreedom.totalfreedommod.bridge.EssentialsBridge; import me.totalfreedom.totalfreedommod.bridge.LibsDisguisesBridge; @@ -36,26 +29,23 @@ import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.config.MainConfig; import me.totalfreedom.totalfreedommod.discord.DiscordBridge; +import me.totalfreedom.totalfreedommod.framework.ServiceManager; import me.totalfreedom.totalfreedommod.freeze.Freezer; -import me.totalfreedom.totalfreedommod.fun.ItemFun; -import me.totalfreedom.totalfreedommod.fun.Jumppads; -import me.totalfreedom.totalfreedommod.fun.Landminer; -import me.totalfreedom.totalfreedommod.fun.MP44; +import me.totalfreedom.totalfreedommod.fun.*; import me.totalfreedom.totalfreedommod.httpd.HTTPDaemon; -import me.totalfreedom.totalfreedommod.ssh.SshDaemon; import me.totalfreedom.totalfreedommod.player.FPlayer; import me.totalfreedom.totalfreedommod.player.PlayerList; import me.totalfreedom.totalfreedommod.rank.ConsoleSenderRegistry; import me.totalfreedom.totalfreedommod.rank.RankManager; -import me.totalfreedom.totalfreedommod.title.TitleManager; import me.totalfreedom.totalfreedommod.sql.FreedomDatabase; +import me.totalfreedom.totalfreedommod.ssh.SshDaemon; +import me.totalfreedom.totalfreedommod.tablist.TabList; +import me.totalfreedom.totalfreedommod.title.TitleManager; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FUtil; import me.totalfreedom.totalfreedommod.util.MethodTimer; -import me.totalfreedom.totalfreedommod.framework.ServiceManager; +import me.totalfreedom.totalfreedommod.world.CleanroomChunkGenerator; import me.totalfreedom.totalfreedommod.world.WorldManager; -import org.bukkit.generator.ChunkGenerator; -import org.bukkit.plugin.java.JavaPlugin; public class TotalFreedomMod extends JavaPlugin { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/admin/Admin.java b/src/main/java/me/totalfreedom/totalfreedommod/admin/Admin.java index b933f16c8..e256e3456 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/admin/Admin.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/admin/Admin.java @@ -1,14 +1,17 @@ package me.totalfreedom.totalfreedommod.admin; -import com.google.common.collect.Lists; import java.util.Date; import java.util.List; import java.util.UUID; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.entity.Player; + import me.totalfreedom.totalfreedommod.util.ConfigInterfaces.ConfigLoadable; import me.totalfreedom.totalfreedommod.util.ConfigInterfaces.Validatable; import me.totalfreedom.totalfreedommod.util.FUtil; -import org.bukkit.configuration.ConfigurationSection; -import org.bukkit.entity.Player; + +import com.google.common.collect.Lists; public class Admin implements ConfigLoadable, Validatable { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java b/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java index 7ebf6c1d5..578af6eee 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java @@ -1,9 +1,5 @@ package me.totalfreedom.totalfreedommod.admin; -import com.google.common.base.Function; -import com.google.common.collect.Maps; -import com.google.common.collect.Sets; -import com.google.gson.reflect.TypeToken; import java.io.File; import java.io.FileReader; import java.io.FileWriter; @@ -19,14 +15,7 @@ import java.util.Set; import java.util.UUID; import java.util.concurrent.TimeUnit; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.sql.PersistenceQueue; -import me.totalfreedom.totalfreedommod.sql.adapter.AdminRepository; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FUtil; -import me.totalfreedom.totalfreedommod.util.JsonUtil; + import org.bukkit.Bukkit; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; @@ -35,10 +24,25 @@ import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.plugin.ServicePriority; + import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.sql.PersistenceQueue; +import me.totalfreedom.totalfreedommod.sql.adapter.AdminRepository; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.FUtil; +import me.totalfreedom.totalfreedommod.util.JsonUtil; + +import com.google.common.base.Function; +import com.google.common.collect.Maps; +import com.google.common.collect.Sets; +import com.google.gson.reflect.TypeToken; + public class AdminList extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/Ban.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/Ban.java index 1cf8b2fa0..8ab55743d 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/Ban.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/Ban.java @@ -1,6 +1,5 @@ package me.totalfreedom.totalfreedommod.banning; -import com.google.common.collect.Lists; import java.text.SimpleDateFormat; import java.util.Arrays; import java.util.Date; @@ -9,15 +8,20 @@ import java.util.List; import java.util.Set; import java.util.UUID; + +import org.bukkit.command.CommandSender; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.util.ConfigInterfaces.ConfigLoadable; import me.totalfreedom.totalfreedommod.util.ConfigInterfaces.Validatable; import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; -import org.bukkit.command.CommandSender; -import org.bukkit.configuration.ConfigurationSection; -import org.bukkit.entity.Player; + +import com.google.common.collect.Lists; public class Ban implements ConfigLoadable, Validatable { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java index 0cd8b25cb..f29f07249 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java @@ -1,9 +1,10 @@ package me.totalfreedom.totalfreedommod.banning; -import com.google.common.collect.Lists; -import com.google.common.collect.Maps; -import com.google.common.collect.Sets; -import com.google.gson.reflect.TypeToken; +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.lang.reflect.Type; import java.time.Duration; import java.util.ArrayList; import java.util.Collection; @@ -12,6 +13,17 @@ import java.util.List; import java.util.Map; import java.util.Set; + +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.player.AsyncPlayerPreLoginEvent; +import org.bukkit.event.player.PlayerJoinEvent; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.config.ConfigEntry; @@ -21,19 +33,11 @@ import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FUtil; import me.totalfreedom.totalfreedommod.util.JsonUtil; -import java.io.File; -import java.io.FileReader; -import java.io.FileWriter; -import java.io.IOException; -import java.lang.reflect.Type; -import org.bukkit.entity.Player; -import org.bukkit.event.EventHandler; -import org.bukkit.event.EventPriority; -import org.bukkit.event.player.AsyncPlayerPreLoginEvent; -import org.bukkit.event.player.PlayerJoinEvent; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; -import reactor.core.scheduler.Schedulers; + +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; +import com.google.common.collect.Sets; +import com.google.gson.reflect.TypeToken; public class BanManager extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/PermBan.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/PermBan.java index a49b6a288..97e0f525f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/PermBan.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/PermBan.java @@ -1,12 +1,12 @@ package me.totalfreedom.totalfreedommod.banning; -import lombok.Getter; -import lombok.Setter; - import java.util.ArrayList; import java.util.List; import java.util.UUID; +import lombok.Getter; +import lombok.Setter; + /** * Model class representing a permanent ban. * Used for SQL database storage. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java index 81417fcf2..b17ed0fd2 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java @@ -1,14 +1,28 @@ package me.totalfreedom.totalfreedommod.banning; -import com.google.common.collect.Maps; -import com.google.common.collect.Sets; -import com.google.gson.reflect.TypeToken; +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.lang.reflect.Type; import java.util.ArrayList; import java.util.Collection; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; + +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.player.AsyncPlayerPreLoginEvent; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.config.ConfigEntry; @@ -17,19 +31,10 @@ import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FUtil; import me.totalfreedom.totalfreedommod.util.JsonUtil; -import java.io.File; -import java.io.FileReader; -import java.io.FileWriter; -import java.io.IOException; -import java.lang.reflect.Type; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; -import org.bukkit.event.EventHandler; -import org.bukkit.event.EventPriority; -import org.bukkit.event.player.AsyncPlayerPreLoginEvent; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; -import reactor.core.scheduler.Schedulers; + +import com.google.common.collect.Maps; +import com.google.common.collect.Sets; +import com.google.gson.reflect.TypeToken; public class PermbanList extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java index 2770f4933..a9b4f4785 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java @@ -1,7 +1,5 @@ package me.totalfreedom.totalfreedommod.banning; -import com.google.common.collect.Maps; -import com.google.gson.reflect.TypeToken; import java.io.File; import java.io.FileReader; import java.io.FileWriter; @@ -13,6 +11,11 @@ import java.util.Iterator; import java.util.List; import java.util.Map; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.config.ConfigEntry; @@ -20,9 +23,9 @@ import me.totalfreedom.totalfreedommod.sql.adapter.StrikeRepository; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.JsonUtil; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; -import reactor.core.scheduler.Schedulers; + +import com.google.common.collect.Maps; +import com.google.gson.reflect.TypeToken; public class StrikeList extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/BlockBlocker.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/BlockBlocker.java index b3ed7990b..66b3e637e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/BlockBlocker.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/BlockBlocker.java @@ -1,13 +1,5 @@ package me.totalfreedom.totalfreedommod.blocking; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.blocking.sign.SignBlocks; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Material; import org.bukkit.entity.FallingBlock; import org.bukkit.entity.Player; @@ -18,6 +10,16 @@ import org.bukkit.event.entity.EntitySpawnEvent; import org.bukkit.inventory.ItemStack; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.blocking.sign.SignBlocks; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.FUtil; + public class BlockBlocker extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/EventBlocker.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/EventBlocker.java index 07ae721aa..d10bacef1 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/EventBlocker.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/EventBlocker.java @@ -1,23 +1,9 @@ package me.totalfreedom.totalfreedommod.blocking; import io.papermc.paper.event.block.BlockPreDispenseEvent; -import me.totalfreedom.totalfreedommod.EntityWiper; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; import org.bukkit.GameMode; import org.bukkit.Material; -import org.bukkit.entity.ArmorStand; -import org.bukkit.entity.Boat; -import org.bukkit.entity.Entity; -import org.bukkit.entity.EntityType; -import org.bukkit.entity.FallingBlock; -import org.bukkit.entity.Hanging; -import org.bukkit.entity.Item; -import org.bukkit.entity.Minecart; -import org.bukkit.entity.Player; -import org.bukkit.entity.Projectile; -import org.bukkit.entity.Tameable; +import org.bukkit.entity.*; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.block.BlockBurnEvent; @@ -30,21 +16,17 @@ import org.bukkit.event.block.BlockRedstoneEvent; import org.bukkit.event.block.BlockSpreadEvent; import org.bukkit.event.block.LeavesDecayEvent; -import org.bukkit.event.entity.EntityCombustEvent; -import org.bukkit.event.entity.EntityDamageEvent; -import org.bukkit.event.entity.EntityDeathEvent; -import org.bukkit.event.entity.EntityRegainHealthEvent; -import org.bukkit.event.entity.EntityExplodeEvent; -import org.bukkit.event.entity.EntitySpawnEvent; -import org.bukkit.event.entity.ExplosionPrimeEvent; -import org.bukkit.event.entity.ProjectileHitEvent; -import org.bukkit.event.entity.SpawnerSpawnEvent; -import org.bukkit.event.entity.TrialSpawnerSpawnEvent; +import org.bukkit.event.entity.*; import org.bukkit.event.inventory.InventoryMoveItemEvent; import org.bukkit.event.inventory.InventoryPickupItemEvent; import org.bukkit.event.player.PlayerDropItemEvent; import org.bukkit.event.world.PortalCreateEvent; +import me.totalfreedom.totalfreedommod.EntityWiper; +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; + public class EventBlocker extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/InteractBlocker.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/InteractBlocker.java index 97bdb3782..70b3a32dd 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/InteractBlocker.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/InteractBlocker.java @@ -1,11 +1,5 @@ package me.totalfreedom.totalfreedommod.blocking; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.player.FPlayer; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Material; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; @@ -13,6 +7,14 @@ import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.inventory.ItemStack; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.player.FPlayer; + public class InteractBlocker extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/MobBlocker.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/MobBlocker.java index 7bad99b49..4b59d04fa 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/MobBlocker.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/MobBlocker.java @@ -1,12 +1,7 @@ package me.totalfreedom.totalfreedommod.blocking; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.FLog; -import net.kyori.adventure.key.Key; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; +import java.util.*; + import org.bukkit.Location; import org.bukkit.block.Block; import org.bukkit.entity.*; @@ -21,7 +16,14 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.SpawnEggMeta; -import java.util.*; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.FLog; public class MobBlocker extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/PotionBlocker.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/PotionBlocker.java index b51aeb13a..cb8dde654 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/PotionBlocker.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/PotionBlocker.java @@ -1,10 +1,7 @@ package me.totalfreedom.totalfreedommod.blocking; import java.util.Collection; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; + import org.bukkit.entity.Arrow; import org.bukkit.entity.Player; import org.bukkit.entity.ThrownPotion; @@ -19,6 +16,12 @@ import org.bukkit.potion.PotionEffectType; import org.bukkit.projectiles.ProjectileSource; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; + public class PotionBlocker extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/command/CommandBlocker.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/command/CommandBlocker.java index ab15989e2..4698bfb9e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/command/CommandBlocker.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/command/CommandBlocker.java @@ -1,13 +1,20 @@ package me.totalfreedom.totalfreedommod.blocking.command; -import com.google.common.collect.Lists; -import com.google.common.collect.Maps; import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; + +import org.bukkit.command.*; +import org.bukkit.entity.Player; +import org.bukkit.entity.minecart.CommandMinecart; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.player.PlayerCommandPreprocessEvent; +import org.bukkit.event.server.ServerCommandEvent; + import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.blocking.command.CommandBlockerEntry.PatternToken; @@ -15,18 +22,9 @@ import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FUtil; -import org.bukkit.command.Command; -import org.bukkit.command.CommandMap; -import org.bukkit.command.BlockCommandSender; -import org.bukkit.command.CommandSender; -import org.bukkit.command.ConsoleCommandSender; -import org.bukkit.command.RemoteConsoleCommandSender; -import org.bukkit.entity.minecart.CommandMinecart; -import org.bukkit.event.EventHandler; -import org.bukkit.event.EventPriority; -import org.bukkit.event.server.ServerCommandEvent; -import org.bukkit.event.player.PlayerCommandPreprocessEvent; -import org.bukkit.entity.Player; + +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; public class CommandBlocker extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/command/CommandBlockerEntry.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/command/CommandBlockerEntry.java index 3f96e3d31..ea8f4c2f9 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/command/CommandBlockerEntry.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/command/CommandBlockerEntry.java @@ -3,13 +3,17 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; -import lombok.Getter; -import me.totalfreedom.totalfreedommod.PluginProvider; -import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.format.NamedTextColor; + import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.PluginProvider; +import me.totalfreedom.totalfreedommod.util.FUtil; + +import lombok.Getter; + public class CommandBlockerEntry { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/command/CommandBlockerRank.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/command/CommandBlockerRank.java index 932aa2e1d..9fdad871e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/command/CommandBlockerRank.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/command/CommandBlockerRank.java @@ -1,10 +1,11 @@ package me.totalfreedom.totalfreedommod.blocking.command; -import me.totalfreedom.totalfreedommod.PluginProvider; -import me.totalfreedom.totalfreedommod.admin.Admin; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import me.totalfreedom.totalfreedommod.PluginProvider; +import me.totalfreedom.totalfreedommod.admin.Admin; + public enum CommandBlockerRank { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/EntityMetaPacketGuard.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/EntityMetaPacketGuard.java index 44194ab74..b150c8aa6 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/EntityMetaPacketGuard.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/EntityMetaPacketGuard.java @@ -1,16 +1,19 @@ package me.totalfreedom.totalfreedommod.blocking.entity; +import java.util.List; +import java.util.Optional; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer; + import com.github.retrooper.packetevents.protocol.attribute.Attribute; import com.github.retrooper.packetevents.protocol.attribute.Attributes; import com.github.retrooper.packetevents.protocol.entity.data.EntityData; import com.github.retrooper.packetevents.protocol.entity.data.EntityDataType; import com.github.retrooper.packetevents.protocol.entity.data.EntityDataTypes; import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerUpdateAttributes; -import java.util.List; -import java.util.Optional; + import me.totalfreedom.totalfreedommod.util.ComponentScanner; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer; public final class EntityMetaPacketGuard { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/EntityNameValidator.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/EntityNameValidator.java index 15e4d653d..428b41a23 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/EntityNameValidator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/EntityNameValidator.java @@ -1,7 +1,17 @@ package me.totalfreedom.totalfreedommod.blocking.entity; -import com.destroystokyo.paper.event.entity.EntityAddToWorldEvent; import java.util.regex.Pattern; + +import com.destroystokyo.paper.event.entity.EntityAddToWorldEvent; +import org.bukkit.entity.Entity; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.player.PlayerCommandPreprocessEvent; +import org.bukkit.event.server.ServerCommandEvent; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.blocking.sweep.EntityVisitor; @@ -11,13 +21,6 @@ import me.totalfreedom.totalfreedommod.util.DetectionReporter; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; -import org.bukkit.entity.Entity; -import org.bukkit.event.EventHandler; -import org.bukkit.event.EventPriority; -import org.bukkit.event.player.PlayerCommandPreprocessEvent; -import org.bukkit.event.server.ServerCommandEvent; /** * Strips format bomb / oversized custom names from entities before the client diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/EntitySizeGuard.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/EntitySizeGuard.java index 911ee5316..6b8bb9804 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/EntitySizeGuard.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/EntitySizeGuard.java @@ -1,13 +1,6 @@ package me.totalfreedom.totalfreedommod.blocking.entity; import com.destroystokyo.paper.event.entity.EntityAddToWorldEvent; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.blocking.sweep.EntityVisitor; -import me.totalfreedom.totalfreedommod.blocking.sweep.SweepContext; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.DetectionReporter; -import me.totalfreedom.totalfreedommod.util.FLog; import org.bukkit.Art; import org.bukkit.attribute.Attribute; import org.bukkit.attribute.AttributeInstance; @@ -20,6 +13,14 @@ import org.bukkit.event.entity.CreatureSpawnEvent; import org.bukkit.event.entity.EntityTransformEvent; +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.blocking.sweep.EntityVisitor; +import me.totalfreedom.totalfreedommod.blocking.sweep.SweepContext; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.DetectionReporter; +import me.totalfreedom.totalfreedommod.util.FLog; + /** * Hard cap on Slime#setSize and the SCALE attribute. * Prevents tick stalls in Entity#checkInsideBlocks. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/ProjectileGuard.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/ProjectileGuard.java index 21e71d077..74309f72a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/ProjectileGuard.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/ProjectileGuard.java @@ -1,7 +1,18 @@ package me.totalfreedom.totalfreedommod.blocking.entity; -import com.destroystokyo.paper.event.entity.EntityAddToWorldEvent; import java.util.Set; + +import com.destroystokyo.paper.event.entity.EntityAddToWorldEvent; +import org.bukkit.entity.*; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.entity.EntityMountEvent; +import org.bukkit.event.entity.EntitySpawnEvent; +import org.bukkit.event.entity.ProjectileLaunchEvent; +import org.bukkit.util.Vector; + +import net.kyori.adventure.text.format.NamedTextColor; + import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.blocking.sweep.EntityVisitor; @@ -11,18 +22,6 @@ import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FTask; import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.format.NamedTextColor; -import org.bukkit.entity.AbstractWindCharge; -import org.bukkit.entity.EntityType; -import org.bukkit.entity.Entity; -import org.bukkit.entity.Player; -import org.bukkit.entity.Projectile; -import org.bukkit.event.EventHandler; -import org.bukkit.event.EventPriority; -import org.bukkit.event.entity.EntityMountEvent; -import org.bukkit.event.entity.EntitySpawnEvent; -import org.bukkit.event.entity.ProjectileLaunchEvent; -import org.bukkit.util.Vector; /** * Removes projectiles that load chunks or otherwise threaten server stability diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/TextDisplayGuard.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/TextDisplayGuard.java index 3e65d5dd4..b88420b05 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/TextDisplayGuard.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/TextDisplayGuard.java @@ -1,6 +1,16 @@ package me.totalfreedom.totalfreedommod.blocking.entity; import com.destroystokyo.paper.event.entity.EntityAddToWorldEvent; +import org.bukkit.entity.Display; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Interaction; +import org.bukkit.entity.TextDisplay; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.entity.EntitySpawnEvent; + +import net.kyori.adventure.text.Component; + import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.blocking.sweep.EntityVisitor; @@ -9,14 +19,6 @@ import me.totalfreedom.totalfreedommod.util.ComponentScanner; import me.totalfreedom.totalfreedommod.util.DetectionReporter; import me.totalfreedom.totalfreedommod.util.FLog; -import net.kyori.adventure.text.Component; -import org.bukkit.entity.Display; -import org.bukkit.entity.Entity; -import org.bukkit.entity.Interaction; -import org.bukkit.entity.TextDisplay; -import org.bukkit.event.EventHandler; -import org.bukkit.event.EventPriority; -import org.bukkit.event.entity.EntitySpawnEvent; /** * Removes certain text display entities that can be used to crash clients. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/WaypointGuard.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/WaypointGuard.java index b3faa3418..fe6f08c9c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/WaypointGuard.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/WaypointGuard.java @@ -1,14 +1,8 @@ package me.totalfreedom.totalfreedommod.blocking.entity; -import com.destroystokyo.paper.event.entity.EntityAddToWorldEvent; import java.util.ArrayList; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.blocking.sweep.EntityVisitor; -import me.totalfreedom.totalfreedommod.blocking.sweep.SweepContext; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.DetectionReporter; -import me.totalfreedom.totalfreedommod.util.FLog; + +import com.destroystokyo.paper.event.entity.EntityAddToWorldEvent; import org.bukkit.attribute.Attribute; import org.bukkit.attribute.AttributeInstance; import org.bukkit.attribute.AttributeModifier; @@ -18,6 +12,14 @@ import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.blocking.sweep.EntityVisitor; +import me.totalfreedom.totalfreedommod.blocking.sweep.SweepContext; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.DetectionReporter; +import me.totalfreedom.totalfreedommod.util.FLog; + public class WaypointGuard extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/item/ConsoleSpamFilter.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/item/ConsoleSpamFilter.java index 84a116bf2..8e8b823f8 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/item/ConsoleSpamFilter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/item/ConsoleSpamFilter.java @@ -3,6 +3,7 @@ import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.config.ConfigEntry; + import org.apache.logging.log4j.Level; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Marker; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/item/EntityDataRules.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/item/EntityDataRules.java index 16e1b1243..81d698ff0 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/item/EntityDataRules.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/item/EntityDataRules.java @@ -4,6 +4,7 @@ import java.util.Locale; import java.util.Optional; import java.util.Set; + import org.bukkit.Material; final class EntityDataRules diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/item/ItemScanner.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/item/ItemScanner.java index d29ac97eb..8232d49f3 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/item/ItemScanner.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/item/ItemScanner.java @@ -1,31 +1,13 @@ package me.totalfreedom.totalfreedommod.blocking.item; +import java.util.List; + import io.papermc.paper.datacomponent.DataComponentTypes; -import io.papermc.paper.datacomponent.item.BannerPatternLayers; -import io.papermc.paper.datacomponent.item.BlocksAttacks; -import io.papermc.paper.datacomponent.item.BundleContents; -import io.papermc.paper.datacomponent.item.ChargedProjectiles; -import io.papermc.paper.datacomponent.item.CustomModelData; -import io.papermc.paper.datacomponent.item.Equippable; -import io.papermc.paper.datacomponent.item.Fireworks; -import io.papermc.paper.datacomponent.item.ItemAttributeModifiers; -import io.papermc.paper.datacomponent.item.ItemContainerContents; -import io.papermc.paper.datacomponent.item.ItemLore; -import io.papermc.paper.datacomponent.item.JukeboxPlayable; -import io.papermc.paper.datacomponent.item.PotionContents; -import io.papermc.paper.datacomponent.item.SuspiciousStewEffects; -import io.papermc.paper.datacomponent.item.UseRemainder; -import io.papermc.paper.datacomponent.item.WritableBookContent; -import io.papermc.paper.datacomponent.item.WrittenBookContent; +import io.papermc.paper.datacomponent.item.*; import io.papermc.paper.datacomponent.item.attribute.AttributeModifierDisplay; import io.papermc.paper.datacomponent.item.blocksattacks.DamageReduction; import io.papermc.paper.registry.set.RegistryKeySet; import io.papermc.paper.text.Filtered; -import java.util.List; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.ComponentScanner; -import net.kyori.adventure.key.Key; -import net.kyori.adventure.text.Component; import org.bukkit.FireworkEffect; import org.bukkit.JukeboxSong; import org.bukkit.MusicInstrument; @@ -37,6 +19,12 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.ItemType; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.text.Component; + +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.ComponentScanner; + final class ItemScanner { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/item/ItemValidator.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/item/ItemValidator.java index 77580ee37..1f33cb4ec 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/item/ItemValidator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/item/ItemValidator.java @@ -1,23 +1,13 @@ package me.totalfreedom.totalfreedommod.blocking.item; -import io.papermc.paper.datacomponent.DataComponentTypes; -import io.papermc.paper.event.block.BlockPreDispenseEvent; import java.util.Collection; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.function.Predicate; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.blocking.sweep.EntityVisitor; -import me.totalfreedom.totalfreedommod.blocking.sweep.SweepContext; -import me.totalfreedom.totalfreedommod.blocking.sweep.TileEntityVisitor; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.DetectionReporter; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FTask; -import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.format.NamedTextColor; + +import io.papermc.paper.datacomponent.DataComponentTypes; +import io.papermc.paper.event.block.BlockPreDispenseEvent; import org.bukkit.Chunk; import org.bukkit.Material; import org.bukkit.World; @@ -27,7 +17,6 @@ import org.bukkit.entity.Item; import org.bukkit.entity.LivingEntity; import org.bukkit.entity.Player; -import org.bukkit.inventory.EntityEquipment; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.block.Action; @@ -35,28 +24,24 @@ import org.bukkit.event.block.BlockPlaceEvent; import org.bukkit.event.entity.EntityPickupItemEvent; import org.bukkit.event.entity.ItemSpawnEvent; -import org.bukkit.event.inventory.InventoryClickEvent; -import org.bukkit.event.inventory.InventoryCreativeEvent; -import org.bukkit.event.inventory.InventoryDragEvent; -import org.bukkit.event.inventory.InventoryMoveItemEvent; -import org.bukkit.event.inventory.InventoryOpenEvent; -import org.bukkit.event.inventory.InventoryPickupItemEvent; -import org.bukkit.event.inventory.PrepareAnvilEvent; -import org.bukkit.event.inventory.PrepareItemCraftEvent; -import org.bukkit.event.player.PlayerCommandPreprocessEvent; -import org.bukkit.event.player.PlayerDropItemEvent; -import org.bukkit.event.player.PlayerInteractEntityEvent; -import org.bukkit.event.player.PlayerInteractEvent; -import org.bukkit.event.player.PlayerItemConsumeEvent; -import org.bukkit.event.player.PlayerItemHeldEvent; -import org.bukkit.event.player.PlayerJoinEvent; -import org.bukkit.event.player.PlayerSwapHandItemsEvent; +import org.bukkit.event.inventory.*; +import org.bukkit.event.player.*; import org.bukkit.event.server.ServerCommandEvent; import org.bukkit.event.world.LootGenerateEvent; -import org.bukkit.inventory.EquipmentSlot; -import org.bukkit.inventory.Inventory; -import org.bukkit.inventory.InventoryHolder; -import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.*; + +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.blocking.sweep.EntityVisitor; +import me.totalfreedom.totalfreedommod.blocking.sweep.SweepContext; +import me.totalfreedom.totalfreedommod.blocking.sweep.TileEntityVisitor; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.DetectionReporter; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.FTask; +import me.totalfreedom.totalfreedommod.util.FUtil; public class ItemValidator extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/item/RawNbtInspector.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/item/RawNbtInspector.java index 41ae8bf6e..0d5e37294 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/item/RawNbtInspector.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/item/RawNbtInspector.java @@ -7,6 +7,7 @@ import java.util.Collection; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; + import org.bukkit.Material; import org.bukkit.inventory.ItemStack; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/packet/CrashPacketListener.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/packet/CrashPacketListener.java index afffd4f53..72a9eb790 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/packet/CrashPacketListener.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/packet/CrashPacketListener.java @@ -1,9 +1,22 @@ package me.totalfreedom.totalfreedommod.blocking.packet; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.format.NamedTextColor; + import com.github.retrooper.packetevents.event.PacketListenerAbstract; import com.github.retrooper.packetevents.event.PacketListenerPriority; import com.github.retrooper.packetevents.event.PacketReceiveEvent; import com.github.retrooper.packetevents.event.PacketSendEvent; +import com.github.retrooper.packetevents.protocol.component.ComponentTypes; +import com.github.retrooper.packetevents.protocol.component.builtin.item.ItemAttributeModifiers; import com.github.retrooper.packetevents.protocol.entity.data.EntityData; import com.github.retrooper.packetevents.protocol.nbt.NBTCompound; import com.github.retrooper.packetevents.protocol.packettype.PacketType; @@ -13,32 +26,11 @@ import com.github.retrooper.packetevents.protocol.recipe.data.MerchantOffer; import com.github.retrooper.packetevents.protocol.world.chunk.Column; import com.github.retrooper.packetevents.util.Vector3d; +import com.github.retrooper.packetevents.wrapper.play.client.*; +import com.github.retrooper.packetevents.wrapper.play.server.*; + import io.netty.buffer.ByteBuf; -import com.github.retrooper.packetevents.protocol.component.ComponentTypes; -import com.github.retrooper.packetevents.protocol.component.builtin.item.ItemAttributeModifiers; -import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientChatCommand; -import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientChatCommandUnsigned; -import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientChatMessage; -import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientCreativeInventoryAction; -import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientPlayerPosition; -import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientPlayerPositionAndRotation; -import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientTabComplete; -import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientVehicleMove; -import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerBlockEntityData; -import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerChunkData; -import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerEntityEquipment; -import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerEntityMetadata; -import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerSetSlot; -import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerSpawnLivingEntity; -import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerUpdateAttributes; -import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerMerchantOffers; -import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerWindowItems; -import io.github.retrooper.packetevents.util.SpigotConversionUtil; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.blocking.entity.EntityMetaPacketGuard; import me.totalfreedom.totalfreedommod.blocking.item.ContainerPacketGuard; @@ -49,9 +41,8 @@ import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FSync; import me.totalfreedom.totalfreedommod.util.FTask; -import net.kyori.adventure.text.format.NamedTextColor; -import org.bukkit.Bukkit; -import org.bukkit.entity.Player; + +import io.github.retrooper.packetevents.util.SpigotConversionUtil; final class CrashPacketListener extends PacketListenerAbstract { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/packet/CrashPacketService.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/packet/CrashPacketService.java index 9577342b8..4972c44d7 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/packet/CrashPacketService.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/packet/CrashPacketService.java @@ -1,17 +1,19 @@ package me.totalfreedom.totalfreedommod.blocking.packet; +import org.bukkit.event.EventHandler; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.event.player.PlayerRespawnEvent; +import org.bukkit.event.player.PlayerTeleportEvent; +import org.bukkit.plugin.Plugin; + import com.github.retrooper.packetevents.PacketEvents; import com.github.retrooper.packetevents.event.PacketListenerCommon; + import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.blocking.entity.EntityMetaPacketGuard; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.util.FLog; -import org.bukkit.event.EventHandler; -import org.bukkit.event.player.PlayerQuitEvent; -import org.bukkit.event.player.PlayerRespawnEvent; -import org.bukkit.event.player.PlayerTeleportEvent; -import org.bukkit.plugin.Plugin; public class CrashPacketService extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/sign/SignValidator.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/sign/SignValidator.java index ca2b335d6..aa1239a5e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/sign/SignValidator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/sign/SignValidator.java @@ -1,18 +1,9 @@ package me.totalfreedom.totalfreedommod.blocking.sign; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.blocking.sweep.SweepContext; -import me.totalfreedom.totalfreedommod.blocking.sweep.TileEntityVisitor; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.ComponentScanner; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FTask; -import me.totalfreedom.totalfreedommod.util.FUtil; import java.util.HashSet; import java.util.Set; import java.util.function.Predicate; -import net.kyori.adventure.text.format.NamedTextColor; + import org.bukkit.Bukkit; import org.bukkit.Chunk; import org.bukkit.Material; @@ -31,6 +22,18 @@ import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.scheduler.BukkitTask; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.blocking.sweep.SweepContext; +import me.totalfreedom.totalfreedommod.blocking.sweep.TileEntityVisitor; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.ComponentScanner; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.FTask; +import me.totalfreedom.totalfreedommod.util.FUtil; + public class SignValidator extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/spawner/SpawnerValidator.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/spawner/SpawnerValidator.java index b48bdf7b3..e95c6a998 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/spawner/SpawnerValidator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/spawner/SpawnerValidator.java @@ -1,13 +1,7 @@ package me.totalfreedom.totalfreedommod.blocking.spawner; import java.util.function.Predicate; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.blocking.sweep.SweepContext; -import me.totalfreedom.totalfreedommod.blocking.sweep.TileEntityVisitor; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.DetectionReporter; -import me.totalfreedom.totalfreedommod.util.FLog; + import org.bukkit.Material; import org.bukkit.block.Block; import org.bukkit.block.BlockState; @@ -23,6 +17,14 @@ import org.bukkit.event.entity.SpawnerSpawnEvent; import org.bukkit.event.entity.TrialSpawnerSpawnEvent; +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.blocking.sweep.SweepContext; +import me.totalfreedom.totalfreedommod.blocking.sweep.TileEntityVisitor; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.DetectionReporter; +import me.totalfreedom.totalfreedommod.util.FLog; + public class SpawnerValidator extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/sweep/SweepScheduler.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/sweep/SweepScheduler.java index 9c1e1068f..41c232d07 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/sweep/SweepScheduler.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/sweep/SweepScheduler.java @@ -8,10 +8,7 @@ import java.util.Map; import java.util.Set; import java.util.function.BooleanSupplier; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FTask; + import org.bukkit.Bukkit; import org.bukkit.Chunk; import org.bukkit.World; @@ -21,6 +18,11 @@ import org.bukkit.event.EventPriority; import org.bukkit.event.world.ChunkLoadEvent; +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.FTask; + public class SweepScheduler extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/sweep/TileEntityVisitor.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/sweep/TileEntityVisitor.java index 51b262bd7..561098cc9 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/sweep/TileEntityVisitor.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/sweep/TileEntityVisitor.java @@ -1,6 +1,7 @@ package me.totalfreedom.totalfreedommod.blocking.sweep; import java.util.function.Predicate; + import org.bukkit.block.Block; import org.bukkit.block.BlockState; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/bridge/CoreProtectBridge.java b/src/main/java/me/totalfreedom/totalfreedommod/bridge/CoreProtectBridge.java index 4133872f0..98516318f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/bridge/CoreProtectBridge.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/bridge/CoreProtectBridge.java @@ -3,15 +3,6 @@ import java.util.Collections; import java.util.List; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FUtil; -import net.coreprotect.CoreProtect; -import net.coreprotect.CoreProtectAPI; -import net.coreprotect.utility.Util; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.block.Block; @@ -20,6 +11,18 @@ import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.plugin.Plugin; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + +import net.coreprotect.CoreProtect; +import net.coreprotect.CoreProtectAPI; +import net.coreprotect.utility.Util; + +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.FUtil; + public class CoreProtectBridge extends FreedomService { private static final int ROLLBACK_TIME = 2592000; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/bridge/EssentialsBridge.java b/src/main/java/me/totalfreedom/totalfreedommod/bridge/EssentialsBridge.java index 878167f54..6ce4fc0ae 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/bridge/EssentialsBridge.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/bridge/EssentialsBridge.java @@ -1,10 +1,11 @@ package me.totalfreedom.totalfreedommod.bridge; +import org.bukkit.Bukkit; +import org.bukkit.plugin.Plugin; + import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.util.FLog; -import org.bukkit.Bukkit; -import org.bukkit.plugin.Plugin; public class EssentialsBridge extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/bridge/LibsDisguisesBridge.java b/src/main/java/me/totalfreedom/totalfreedommod/bridge/LibsDisguisesBridge.java index fc2272e16..92f21ab1f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/bridge/LibsDisguisesBridge.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/bridge/LibsDisguisesBridge.java @@ -1,13 +1,15 @@ package me.totalfreedom.totalfreedommod.bridge; import java.lang.reflect.Method; + +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; + import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.disguise.DisallowedDisguises; import me.totalfreedom.totalfreedommod.util.FLog; -import org.bukkit.entity.Entity; -import org.bukkit.entity.Player; -import org.bukkit.plugin.Plugin; /** * Bridge to LibsDisguises plugin. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/bridge/WorldEditBridge.java b/src/main/java/me/totalfreedom/totalfreedommod/bridge/WorldEditBridge.java index f7c1867c6..becfdb0b3 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/bridge/WorldEditBridge.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/bridge/WorldEditBridge.java @@ -1,10 +1,5 @@ package me.totalfreedom.totalfreedommod.bridge; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.FLog; - import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; @@ -13,6 +8,11 @@ import org.bukkit.entity.Player; import org.bukkit.plugin.Plugin; +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.FLog; + public class WorldEditBridge extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/bridge/WorldEditHook.java b/src/main/java/me/totalfreedom/totalfreedommod/bridge/WorldEditHook.java index 1e5355e37..0c94c37d5 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/bridge/WorldEditHook.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/bridge/WorldEditHook.java @@ -1,13 +1,31 @@ package me.totalfreedom.totalfreedommod.bridge; -import com.google.common.eventbus.Subscribe; -import com.sk89q.worldedit.EditSession; -import com.sk89q.worldedit.EmptyClipboardException; -import com.sk89q.worldedit.IncompleteRegionException; -import com.sk89q.worldedit.LocalSession; -import com.sk89q.worldedit.MaxChangedBlocksException; -import com.sk89q.worldedit.WorldEdit; -import com.sk89q.worldedit.WorldEditException; +import java.io.IOException; +import java.io.OutputStream; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.HandlerList; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerCommandPreprocessEvent; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.permissions.PermissionAttachment; +import org.bukkit.scheduler.BukkitTask; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + +import com.sk89q.worldedit.*; import com.sk89q.worldedit.bukkit.BukkitAdapter; import com.sk89q.worldedit.bukkit.WorldEditPlugin; import com.sk89q.worldedit.event.extent.EditSessionEvent; @@ -24,40 +42,15 @@ import com.sk89q.worldedit.world.block.BlockType; import com.sk89q.worldedit.world.registry.BlockMaterial; import com.sk89q.worldedit.world.registry.LegacyMapper; -import java.io.IOException; -import java.io.OutputStream; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Set; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.regex.Matcher; -import java.util.regex.Pattern; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.blocking.sweep.SweepContext; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FTask; import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; -import org.bukkit.Bukkit; -import org.bukkit.World; -import org.bukkit.entity.Player; -import org.bukkit.event.EventHandler; -import org.bukkit.event.EventPriority; -import org.bukkit.event.HandlerList; -import org.bukkit.event.Listener; -import org.bukkit.event.player.PlayerCommandPreprocessEvent; -import org.bukkit.event.player.PlayerJoinEvent; -import org.bukkit.event.player.PlayerQuitEvent; -import org.bukkit.permissions.PermissionAttachment; -import org.bukkit.scheduler.BukkitTask; + +import com.google.common.eventbus.Subscribe; /** * Loaded only after WorldEditBridge has confirmed the WorldEdit plugin is diff --git a/src/main/java/me/totalfreedom/totalfreedommod/caging/CageData.java b/src/main/java/me/totalfreedom/totalfreedommod/caging/CageData.java index 788a12792..1d2ed6710 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/caging/CageData.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/caging/CageData.java @@ -1,9 +1,9 @@ package me.totalfreedom.totalfreedommod.caging; -import io.papermc.paper.datacomponent.item.ResolvableProfile; import java.util.ArrayList; import java.util.List; -import me.totalfreedom.totalfreedommod.player.FPlayer; + +import io.papermc.paper.datacomponent.item.ResolvableProfile; import org.bukkit.GameMode; import org.bukkit.Location; import org.bukkit.Material; @@ -11,6 +11,8 @@ import org.bukkit.block.Skull; import org.bukkit.entity.Player; +import me.totalfreedom.totalfreedommod.player.FPlayer; + public class CageData { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/caging/Cager.java b/src/main/java/me/totalfreedom/totalfreedommod/caging/Cager.java index afa559a6d..79e71a08a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/caging/Cager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/caging/Cager.java @@ -1,10 +1,5 @@ package me.totalfreedom.totalfreedommod.caging; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.player.FPlayer; -import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Location; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; @@ -16,6 +11,13 @@ import org.bukkit.event.player.PlayerMoveEvent; import org.bukkit.event.player.PlayerQuitEvent; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.player.FPlayer; +import me.totalfreedom.totalfreedommod.util.FUtil; + public class Cager extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/BanCommandUtil.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/BanCommandUtil.java index 53131c5e3..f3a147e29 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/BanCommandUtil.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/BanCommandUtil.java @@ -5,12 +5,13 @@ import java.util.List; import java.util.Set; +import org.bukkit.entity.Player; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.banning.Ban; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.player.PlayerData; import me.totalfreedom.totalfreedommod.util.FUtil; -import org.bukkit.entity.Player; final class BanCommandUtil { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/CommandLoader.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/CommandLoader.java index 28891d69b..fc9bd4835 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/CommandLoader.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/CommandLoader.java @@ -14,6 +14,18 @@ import io.papermc.paper.registry.RegistryAccess; import io.papermc.paper.registry.RegistryKey; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.OfflinePlayer; +import org.bukkit.Registry; +import org.bukkit.World; +import org.bukkit.enchantments.Enchantment; +import org.bukkit.entity.EntityType; +import org.bukkit.plugin.Plugin; +import org.bukkit.potion.PotionEffectType; + +import net.kyori.adventure.key.Key; + import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.ProtectArea.ProtectedRegion; import me.totalfreedom.totalfreedommod.TotalFreedomMod; @@ -22,18 +34,8 @@ import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.world.WorldTime; import me.totalfreedom.totalfreedommod.world.WorldWeather; -import net.kyori.adventure.key.Key; import org.apache.commons.lang3.exception.ExceptionUtils; -import org.bukkit.Bukkit; -import org.bukkit.Material; -import org.bukkit.OfflinePlayer; -import org.bukkit.Registry; -import org.bukkit.World; -import org.bukkit.enchantments.Enchantment; -import org.bukkit.entity.EntityType; -import org.bukkit.plugin.Plugin; -import org.bukkit.potion.PotionEffectType; /** * Registers the custom argument resolvers into the {@link ResolverRegistry} and auto-discovers {@link FCommand} declarations. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/CommandRegistry.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/CommandRegistry.java index 12a7f35c7..fe1e97652 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/CommandRegistry.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/CommandRegistry.java @@ -1,6 +1,5 @@ package me.totalfreedom.totalfreedommod.cmd; -import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.List; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adminchat.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adminchat.java index ed0819a93..2c3863dd7 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adminchat.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adminchat.java @@ -1,15 +1,17 @@ package me.totalfreedom.totalfreedommod.cmd; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.player.FPlayer; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; + +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; +import me.totalfreedom.totalfreedommod.player.FPlayer; + @Command(name = "adminchat", - description = "AdminChat - Talk privately with other admins. Using the command by itself will toggle AdminChat on and off for all messages.", - usage = "/<command> [message...]", - aliases = {"o", "ac"}) + description = "AdminChat - Talk privately with other admins. Using the command by itself will toggle AdminChat on and off for all messages.", + usage = "/<command> [message...]", + aliases = {"o", "ac"}) @Permission(source = SourceType.BOTH, permission = "tfm.admin.adminchat") public class Command_adminchat extends FCommand { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_admininfo.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_admininfo.java index 0103c1fe8..986673998 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_admininfo.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_admininfo.java @@ -4,13 +4,14 @@ import org.bukkit.command.CommandSender; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.JoinConfiguration; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.JoinConfiguration; -@Command(name = "admininfo", description = "Information on how to apply for admin.", usage = "/admininfo", aliases={"si", "ai", "staffinfo"}) +@Command(name = "admininfo", description = "Information on how to apply for admin.", usage = "/admininfo", aliases = {"si", "ai", "staffinfo"}) @Permission(source = SourceType.BOTH, permission = "tfm.player.admininfo") public class Command_admininfo extends FCommand { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adminworld.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adminworld.java index 52a190ea6..876facc3a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adminworld.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adminworld.java @@ -1,10 +1,11 @@ package me.totalfreedom.totalfreedommod.cmd; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.World; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.world.WorldTime; import me.totalfreedom.totalfreedommod.world.WorldWeather; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adventure.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adventure.java index 70af37bc4..df3a46909 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adventure.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adventure.java @@ -1,11 +1,12 @@ package me.totalfreedom.totalfreedommod.cmd; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.Bukkit; import org.bukkit.GameMode; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; @Command(name = "adventure", description = "Quickly change your own gamemode to adventure, or define someone's username to change theirs.", usage = "/adventure [-a | name]", aliases = {"gma"}) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_aeclear.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_aeclear.java index 81fe7dfc6..c472cf566 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_aeclear.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_aeclear.java @@ -1,12 +1,13 @@ package me.totalfreedom.totalfreedommod.cmd; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.command.CommandSender; import org.bukkit.entity.AreaEffectCloud; import org.bukkit.entity.Entity; +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; @Command(name = "aeclear", description = "Removes all area-of-effect clouds on the server.", usage = "/aeclear", aliases = {"aec"}) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_attributelist.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_attributelist.java index 8b53d12fe..d526942f8 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_attributelist.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_attributelist.java @@ -3,14 +3,15 @@ import java.util.Comparator; import java.util.List; +import org.bukkit.NamespacedKey; +import org.bukkit.Registry; +import org.bukkit.command.CommandSender; + import net.kyori.adventure.text.Component; import net.kyori.adventure.text.JoinConfiguration; import net.kyori.adventure.text.TextComponent; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; -import org.bukkit.NamespacedKey; -import org.bukkit.Registry; -import org.bukkit.command.CommandSender; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_autoclear.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_autoclear.java index 08acf7993..47a0e45f7 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_autoclear.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_autoclear.java @@ -1,10 +1,11 @@ package me.totalfreedom.totalfreedommod.cmd; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.OfflinePlayer; import org.bukkit.command.CommandSender; +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; @Command(name = "autoclear", description = "Toggle whether or not a player has their inventory automatically cleared when they join.", usage = "/autoclear <player>") diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_autotp.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_autotp.java index f8fdbbe16..72612db9b 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_autotp.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_autotp.java @@ -1,10 +1,11 @@ package me.totalfreedom.totalfreedommod.cmd; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.OfflinePlayer; import org.bukkit.command.CommandSender; +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; @Command(name = "autotp", description = "Toggle whether or not a player is automatically teleported when they join.", usage = "/autotp <player>") diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_ban.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_ban.java index ee1043ceb..15ac31a2d 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_ban.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_ban.java @@ -2,12 +2,13 @@ import java.util.List; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.GameMode; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.banning.Ban; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.config.ConfigEntry; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banip.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banip.java index babf5a0e7..2856af20b 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banip.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banip.java @@ -4,11 +4,12 @@ import java.util.List; import java.util.Objects; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.banning.Ban; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banlist.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banlist.java index d858bb624..7abd68518 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banlist.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banlist.java @@ -4,9 +4,10 @@ import java.util.List; import java.util.TreeSet; +import org.bukkit.command.CommandSender; + import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; -import org.bukkit.command.CommandSender; import me.totalfreedom.totalfreedommod.banning.Ban; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banname.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banname.java index aa0562010..e4d2275b1 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banname.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banname.java @@ -1,12 +1,13 @@ package me.totalfreedom.totalfreedommod.cmd; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import java.util.List; + import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; -import me.totalfreedom.totalfreedommod.banning.Ban; -import java.util.List; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import me.totalfreedom.totalfreedommod.banning.Ban; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; @Command(name = "banname", description = "Bans the specified name.", usage = "/banname <name> [reason]") diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_blockcmd.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_blockcmd.java index 513b9fa28..cb46094ce 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_blockcmd.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_blockcmd.java @@ -1,14 +1,15 @@ package me.totalfreedom.totalfreedommod.cmd; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.player.FPlayer; -@Command(name = "blockcmd", description = "Block all commands for a specific player.", usage = "/<command> <-a | purge | <player>>", aliases = {"blockcommands","blockcommand","bc","bcmd"}) +@Command(name = "blockcmd", description = "Block all commands for a specific player.", usage = "/<command> <-a | purge | <player>>", aliases = {"blockcommands", "blockcommand", "bc", "bcmd"}) @Permission(permission = "tfm.admin.blockcmd") public class Command_blockcmd extends FCommand { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cage.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cage.java index eef1b9c43..c03bfcd0b 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cage.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cage.java @@ -5,10 +5,11 @@ import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.caging.CageData; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.player.FPlayer; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @Command(name = "cage", description = "Place a cage around someone.", usage = "/cage (-s) <player> [<outer_mat> <inner_mat>] | purge") @Permission(permission = "tfm.admin.cage") diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_consolesay.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_consolesay.java index 6daa8b384..f4c927522 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_consolesay.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_consolesay.java @@ -2,10 +2,11 @@ import org.bukkit.command.CommandSender; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchContext; import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @Command(name = "consolesay", description = "Send a chat message with chat formatting over SSH.", usage = "/<command> <message>", aliases = {"csay"}) @Permission(source = SourceType.ONLY_CONSOLE, permission = "tfm.admin.consolesay") diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cookie.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cookie.java index 46986eab3..30229da06 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cookie.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cookie.java @@ -10,9 +10,10 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.ItemMeta; +import net.kyori.adventure.text.Component; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.Component; @Command(name = "cookie", description = "For those who have no friends.", usage = "/cookie") @Permission(permission = "tfm.fun.cookie") diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_crash.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_crash.java index aace666e4..453bd6b1c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_crash.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_crash.java @@ -1,12 +1,13 @@ package me.totalfreedom.totalfreedommod.cmd; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.Particle; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; @Command(name = "crash", description = "Crashes the specified player", usage = "/crash <player>", aliases = {"fuckup"}) @Permission(permission = "tfm.admin.fuckup") diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_creative.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_creative.java index aebb46d12..eb58f9a6f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_creative.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_creative.java @@ -5,14 +5,15 @@ import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; + @Command( - name = "creative", - description = "Quickly change your own gamemode to creative, or define someone's username to change theirs.", - usage = "/creative <-a | [partialname]>", - aliases = {"gmc"} + name = "creative", + description = "Quickly change your own gamemode to creative, or define someone's username to change theirs.", + usage = "/creative <-a | [partialname]>", + aliases = {"gmc"} ) @Permission(permission = "tfm.player.creative") public class Command_creative extends FCommand diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_deafen.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_deafen.java index 5552f7c73..3132e2950 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_deafen.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_deafen.java @@ -1,14 +1,15 @@ package me.totalfreedom.totalfreedommod.cmd; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; +import java.util.List; +import java.util.Random; + import org.bukkit.Location; import org.bukkit.Registry; import org.bukkit.Sound; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; -import java.util.List; -import java.util.Random; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; @Permission(permission = "tfm.admin.senior.deafen") @Command(name = "deafen", description = "Make some noise.", usage = "/<command>") diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_deop.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_deop.java index 4f08196c8..acd83cac0 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_deop.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_deop.java @@ -3,9 +3,10 @@ import org.bukkit.OfflinePlayer; import org.bukkit.command.CommandSender; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; + @Command(name = "deop", description = "Deop a player.", usage = "/deop <player>") @Permission(permission = "tfm.admin.deop") public class Command_deop extends FCommand diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_disguisetoggle.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_disguisetoggle.java index a896d3df7..843dfd68a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_disguisetoggle.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_disguisetoggle.java @@ -2,9 +2,10 @@ import org.bukkit.command.CommandSender; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; + @Command(name = "disguisetoggle", description = "Toggle the disguise plugin", usage = "/disguisetoggle", aliases = {"dtoggle"}) @Permission(permission = "tfm.admin.disguisetoggle") public class Command_disguisetoggle extends FCommand diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_doom.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_doom.java index 190fcafca..274297148 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_doom.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_doom.java @@ -1,16 +1,17 @@ package me.totalfreedom.totalfreedommod.cmd; -import me.totalfreedom.totalfreedommod.admin.Admin; -import me.totalfreedom.totalfreedommod.banning.Ban; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; -import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; - import org.bukkit.GameMode; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; import org.bukkit.util.Vector; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; + +import me.totalfreedom.totalfreedommod.admin.Admin; +import me.totalfreedom.totalfreedommod.banning.Ban; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; + /** * Every other disciplinary or destructive command refuses an admin target through * {@link FCommand#isProtectedAdmin} or {@link FCommand#isProtectedAdminByName}. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_enchant.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_enchant.java index b0bb55cbc..0574fbac7 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_enchant.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_enchant.java @@ -1,17 +1,18 @@ package me.totalfreedom.totalfreedommod.cmd; +import java.util.List; + import io.papermc.paper.registry.RegistryAccess; import io.papermc.paper.registry.RegistryKey; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; - import org.bukkit.Material; import org.bukkit.enchantments.Enchantment; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; -import java.util.List; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; @Permission(source = SourceType.ONLY_IN_GAME, permission = "tfm.player.enchant") @Command(name = "enchant", description = "Enchant items.", usage = "/<command> <list | addall | reset | add <enchantment> [level] | remove <enchantment>>") diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_entitywipe.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_entitywipe.java index dbcb5e03b..cec1ab740 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_entitywipe.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_entitywipe.java @@ -3,10 +3,11 @@ import org.bukkit.World; import org.bukkit.command.CommandSender; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; + @Command(name = "entitywipe", description = "Remove various server entities that may cause lag, such as dropped items, minecarts, and boats.", usage = "/entitywipe [world]", aliases = {"ew", "rd"}) @Permission(permission = "tfm.server.entitywipe") public class Command_entitywipe extends FCommand diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_expel.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_expel.java index 69ec1c5fe..7dc3557e0 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_expel.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_expel.java @@ -1,19 +1,21 @@ package me.totalfreedom.totalfreedommod.cmd; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; -import me.totalfreedom.totalfreedommod.util.FUtil; +import java.util.List; + +import org.bukkit.Location; +import org.bukkit.entity.Player; +import org.bukkit.util.Vector; + import net.kyori.adventure.text.Component; import net.kyori.adventure.text.JoinConfiguration; import net.kyori.adventure.text.event.HoverEvent; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import org.bukkit.Location; -import org.bukkit.entity.Player; -import org.bukkit.util.Vector; -import java.util.List; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; +import me.totalfreedom.totalfreedommod.util.FUtil; @Command(name = "expel", description = "Push people away from you.", usage = "/expel [radius] [strength]") @Permission(permission = "tfm.fun.expel", source = SourceType.ONLY_IN_GAME) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_findip.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_findip.java index 2e093c425..6626278ff 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_findip.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_findip.java @@ -3,10 +3,11 @@ import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; + @Command(name = "findip", description = "Shows all IPs registered to a player.", usage = "/findip [player]", aliases = {"ips", "ip"}) @Permission(permission = "tfm.admin.findip") public class Command_findip extends FCommand diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_freeze.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_freeze.java index ef1826260..b4745117a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_freeze.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_freeze.java @@ -1,15 +1,16 @@ package me.totalfreedom.totalfreedommod.cmd; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Resolve; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Subcommand; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; - -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; @Command(name = "freeze", description = "Freeze players. Append \"on\" or \"off\" at the end to set a specific state.", usage = "/freeze <[on | off] | <player> [on | off]>", aliases = {"fr"}) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_fuckoff.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_fuckoff.java index 67673e7c9..9db486145 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_fuckoff.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_fuckoff.java @@ -1,9 +1,10 @@ package me.totalfreedom.totalfreedommod.cmd; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; +import org.bukkit.entity.Player; + import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import org.bukkit.entity.Player; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; @Permission(source = SourceType.ONLY_IN_GAME, permission = "tfm.admin.senior.fuckoff") @Command(name = "fuckoff", description = "You'll never even see it coming.", usage = "/fuckoff <on [radius (default=25)] | off>") diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gchat.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gchat.java index 24bbfa221..e9ce18faf 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gchat.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gchat.java @@ -1,12 +1,14 @@ package me.totalfreedom.totalfreedommod.cmd; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Greedy; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; @Command(name = "gchat", description = "Send a chat message as someone else.", usage = "/gchat <player> <message>") @Permission(permission = "tfm.admin.gchat") diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gcmd.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gcmd.java index 70de6bf63..e08dfcc14 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gcmd.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gcmd.java @@ -1,12 +1,14 @@ package me.totalfreedom.totalfreedommod.cmd; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Greedy; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; @Command(name = "gcmd", description = "Send a command as someone else.", usage = "/gcmd <player> <command>") @Permission(permission = "tfm.admin.gcmd") diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_invis.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_invis.java index 0a8fe24fb..69d5af31d 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_invis.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_invis.java @@ -2,18 +2,19 @@ import java.util.List; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Subcommand; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.potion.PotionEffectType; + import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.event.HoverEvent; import net.kyori.adventure.text.format.NamedTextColor; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; -import org.bukkit.potion.PotionEffectType; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Subcommand; @Command(name = "invis", description = "Shows (and optionally clears) invisisible players", usage = "/invis [clear]") @Permission(permission = "tfm.admin.invis") diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_jumppads.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_jumppads.java index 0d3264dc8..4e0230507 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_jumppads.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_jumppads.java @@ -1,13 +1,15 @@ package me.totalfreedom.totalfreedommod.cmd; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.fun.Jumppads; +import java.util.stream.Stream; + +import org.bukkit.command.CommandSender; + import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; -import org.bukkit.command.CommandSender; -import java.util.stream.Stream; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; +import me.totalfreedom.totalfreedommod.fun.Jumppads; @Permission(source = SourceType.BOTH, permission = "tfm.fun.jumppads") @Command(name = "jumppads", description = "Manage jumppads", usage = "/<command> <<on | off> | info | mode <mode> | strength <strength>>", aliases = "launchpads,jp") diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_kick.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_kick.java index 3b3aa5245..ef4faae31 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_kick.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_kick.java @@ -1,12 +1,13 @@ package me.totalfreedom.totalfreedommod.cmd; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; - import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; + @Permission(permission = "tfm.admin.kick") @Command(name = "kick", aliases = "k", description = "Kick a player.", usage = "/<command> [-s] <player> [reason]") public class Command_kick extends FCommand diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_landmine.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_landmine.java index fcc22a2b5..c4d9b7d0b 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_landmine.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_landmine.java @@ -1,17 +1,18 @@ package me.totalfreedom.totalfreedommod.cmd; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Subcommand; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.fun.Landminer.Landmine; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; - -import org.bukkit.Material; -import org.bukkit.block.Block; -import org.bukkit.block.BlockFace; -import org.bukkit.entity.Player; @Command(name = "landmine", description = "Set a landmine trap.", usage = "/<command>") @Permission(source = SourceType.ONLY_IN_GAME, permission = "tfm.fun.landmine") diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_link.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_link.java index 80d9f0b4f..55842b70a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_link.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_link.java @@ -2,11 +2,12 @@ import org.bukkit.entity.Player; -import me.totalfreedom.totalfreedommod.admin.Admin; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import me.totalfreedom.totalfreedommod.admin.Admin; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; + @Command(name = "link", description = "Generate a one-time code for admins to link their Discord account.", usage = "/link") @Permission(permission = "tfm.admin.discordlink", source = SourceType.ONLY_IN_GAME) public class Command_link extends FCommand diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_list.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_list.java index 63939f391..c6ffc8dea 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_list.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_list.java @@ -7,15 +7,16 @@ import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchContext; import me.totalfreedom.totalfreedommod.display.Displayable; import me.totalfreedom.totalfreedommod.util.PlayerListUtil; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @Command(name = "list", description = "Lists the real names of all online players.", usage = "/list [-a | -i | -f]", aliases = {"who"}) @Permission(permission = "tfm.player.list") diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_localspawn.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_localspawn.java index a77e286d3..66079ddb6 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_localspawn.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_localspawn.java @@ -2,9 +2,10 @@ import org.bukkit.entity.Player; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; + @Command(name = "localspawn", description = "Teleport to the spawn point for the current world.", usage = "/localspawn", aliases = {"worldspawn", "gotospawn"}) @Permission(permission = "tfm.player.localspawn", source = SourceType.ONLY_IN_GAME) public class Command_localspawn extends FCommand diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_lockup.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_lockup.java index d8fa5cad8..3f36388b0 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_lockup.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_lockup.java @@ -2,6 +2,13 @@ import java.util.List; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.scheduler.BukkitRunnable; +import org.bukkit.scheduler.BukkitTask; + +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.cmd.internal.FuzzyMatch; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; @@ -10,12 +17,6 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Subcommand; import me.totalfreedom.totalfreedommod.player.FPlayer; import me.totalfreedom.totalfreedommod.util.FTask; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; - -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; -import org.bukkit.scheduler.BukkitRunnable; -import org.bukkit.scheduler.BukkitTask; @Command(name = "lockup", description = "Block target's minecraft input. This is evil, and I never should have wrote it.", usage = "/<command> <all | purge | <<partialname> on | off>>") @Permission(source = SourceType.ONLY_CONSOLE, permission = "tfm.admin.senior.lockup") diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_moblimiter.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_moblimiter.java index a5dbb3f04..ff787a78d 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_moblimiter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_moblimiter.java @@ -1,14 +1,15 @@ package me.totalfreedom.totalfreedommod.cmd; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import org.bukkit.GameRules; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.EntityType; + import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; -import org.bukkit.GameRules; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.EntityType; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; @Permission(permission = "tfm.server.moblimiter") @Command(name = "moblimiter", description = "Control the MobLimiter.", usage = "/<command> <<on | off> | limit <limit> | <allow | block <type>>>") diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_mobpurge.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_mobpurge.java index 649087811..5652f5f67 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_mobpurge.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_mobpurge.java @@ -5,18 +5,14 @@ import org.bukkit.Bukkit; import org.bukkit.World; import org.bukkit.command.CommandSender; -import org.bukkit.entity.Ambient; -import org.bukkit.entity.Creature; -import org.bukkit.entity.EnderDragon; -import org.bukkit.entity.Entity; -import org.bukkit.entity.Ghast; -import org.bukkit.entity.Slime; +import org.bukkit.entity.*; import org.bukkit.scheduler.BukkitRunnable; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; + @Command(name = "mobpurge", description = "Purge all mobs in all worlds.", usage = "/mobpurge [world] [chunkX chunkZ | batchSize]", aliases = {"mp"}) @Permission(permission = "tfm.server.mobpurge") public class Command_mobpurge extends FCommand diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_mp44.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_mp44.java index eb59f80a3..321581947 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_mp44.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_mp44.java @@ -1,15 +1,15 @@ package me.totalfreedom.totalfreedommod.cmd; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Subcommand; import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import org.bukkit.Material; -import org.bukkit.entity.Player; -import org.bukkit.inventory.ItemStack; - @Command(name = "mp44", description = "Modern weaponry, FTW. Use 'draw' to start firing, 'sling' to stop firing.", usage = "/<command> <draw | sling>") @Permission(source = SourceType.ONLY_IN_GAME, permission = "tfm.fun.mp44") public class Command_mp44 extends FCommand diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_myadmin.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_myadmin.java index cd9767963..a64692f79 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_myadmin.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_myadmin.java @@ -3,10 +3,12 @@ import java.net.InetAddress; import java.util.List; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; + import me.totalfreedom.totalfreedommod.admin.Admin; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import org.bukkit.entity.Player; @Permission(source = SourceType.ONLY_IN_GAME, permission = "tfm.admin.myadmin") @Command(name = "myadmin", description = "Manage my admin entry", usage = "/myadmin <clearips | clearip <ip> | setlogin <message> | clearlogin>") diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_nickname.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_nickname.java index 1d9d5298f..427457b36 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_nickname.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_nickname.java @@ -9,12 +9,6 @@ import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; -import me.totalfreedom.totalfreedommod.PluginProvider; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.player.PlayerData; -import me.totalfreedom.totalfreedommod.rank.CustomRank; -import me.totalfreedom.totalfreedommod.util.AdventureUtil; -import me.totalfreedom.totalfreedommod.util.FUtil; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.Style; @@ -22,11 +16,18 @@ import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import me.totalfreedom.totalfreedommod.PluginProvider; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; +import me.totalfreedom.totalfreedommod.player.PlayerData; +import me.totalfreedom.totalfreedommod.rank.CustomRank; +import me.totalfreedom.totalfreedommod.util.AdventureUtil; +import me.totalfreedom.totalfreedommod.util.FUtil; + @Command( - name = "nickname", - description = "Manages player nicknames", - usage = "/nickname <<nickname..> | set <player> <nickname..> | clean | clear [player] | clearall>", - aliases = {"nick"} + name = "nickname", + description = "Manages player nicknames", + usage = "/nickname <<nickname..> | set <player> <nickname..> | clean | clear [player] | clearall>", + aliases = {"nick"} ) @Permission(permission = "tfm.player.nickname") public class Command_nickname extends FCommand diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_nicknyan.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_nicknyan.java index 797fc9b61..d05d2a38a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_nicknyan.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_nicknyan.java @@ -3,6 +3,11 @@ import java.util.ArrayList; import java.util.List; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.JoinConfiguration; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; @@ -10,10 +15,6 @@ import me.totalfreedom.totalfreedommod.player.PlayerData; import me.totalfreedom.totalfreedommod.util.AdventureUtil; import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.JoinConfiguration; - -import org.bukkit.entity.Player; @Command(name = "nicknyan", description = "Essentials Interface Command - Nyanify your nickname.", usage = "/<command> <<nick> | off>") @Permission(source = SourceType.ONLY_IN_GAME, permission = "tfm.player.nicknyan") diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_op.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_op.java index 38ebf399b..1d506771a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_op.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_op.java @@ -3,9 +3,10 @@ import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; + @Command(name = "op", description = "Makes a player operator", usage = "/op [player]") @Permission(permission = "tfm.player.op") public class Command_op extends FCommand diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_opall.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_opall.java index 58aa2d608..ab2f71a0b 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_opall.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_opall.java @@ -3,9 +3,10 @@ import org.bukkit.GameMode; import org.bukkit.command.CommandSender; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; + @Command(name = "opall", description = "Op everyone on the server, optionally changing everyone's gamemode at the same time.", usage = "/opall [-c | -s]") @Permission(permission = "tfm.player.opall") public class Command_opall extends FCommand diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_ops.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_ops.java index 4def8cdc7..f6327b955 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_ops.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_ops.java @@ -3,9 +3,10 @@ import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; + @Command(name = "ops", description = "Manage operators", usage = "/ops") @Permission(permission = "tfm.player.ops") public class Command_ops extends FCommand diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_orbit.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_orbit.java index 17e5f6553..f4935e495 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_orbit.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_orbit.java @@ -1,16 +1,17 @@ package me.totalfreedom.totalfreedommod.cmd; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; - -import me.totalfreedom.totalfreedommod.player.FPlayer; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.GameMode; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; import org.bukkit.util.Vector; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; +import me.totalfreedom.totalfreedommod.player.FPlayer; + @Command(name = "orbit", description = "POW!!! Right in the kisser! One of these days Alice, straight to the Moon!", usage = "/orbit <target>") @Permission(permission = "tfm.fun.orbit") public class Command_orbit extends FCommand diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_permban.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_permban.java index a4fe165a5..8f153a4eb 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_permban.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_permban.java @@ -10,14 +10,15 @@ import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.banning.PermBan; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchContext; import me.totalfreedom.totalfreedommod.player.PlayerData; import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @Command(name = "permban", description = "Manage permanently banned players and IPs.", usage = "/permban <add <name> [ip...] | remove <name|ip> | reload>") @Permission(permission = "tfm.admin.ban.perm", source = SourceType.ONLY_CONSOLE) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_permbanlist.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_permbanlist.java index 94f582451..24838c23f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_permbanlist.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_permbanlist.java @@ -3,12 +3,13 @@ import java.util.ArrayList; import java.util.List; +import org.bukkit.command.CommandSender; + +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; - -import org.bukkit.command.CommandSender; @Command(name = "permbanlist", aliases = "pbanlist", description = "Shows all permanently banned players and IP addresses.", usage = "/<command> [page]") @Permission(permission = "tfm.admin.banlist") diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_plugincontrol.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_plugincontrol.java index 51149db23..bc475ffe6 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_plugincontrol.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_plugincontrol.java @@ -1,14 +1,15 @@ package me.totalfreedom.totalfreedommod.cmd; import io.papermc.paper.plugin.configuration.PluginMeta; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; +import org.bukkit.command.CommandSender; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.PluginManager; + import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; -import org.bukkit.command.CommandSender; -import org.bukkit.plugin.Plugin; -import org.bukkit.plugin.PluginManager; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; @Permission(source = SourceType.ONLY_CONSOLE, permission = "tfm.admin.senior.plugincontrol") @Command(name = "plugincontrol", aliases = "plc", description = "Manage plugins", usage = "/<command> <<enable | disable | reload> <pluginname>> | list>") diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_potion.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_potion.java index de368a536..cfe7c3be6 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_potion.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_potion.java @@ -7,7 +7,6 @@ import org.bukkit.entity.Player; import org.bukkit.potion.PotionEffectType; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.event.HoverEvent; @@ -15,10 +14,12 @@ import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; + @Command( - name = "potion", - description = "Manipulate potion effects. Duration is measured in server ticks (~20 ticks per second).", - usage = "/potion <list | clear [player] | clearall | add <type> <duration> <amplifier> [player] | remove <type> [player]>" + name = "potion", + description = "Manipulate potion effects. Duration is measured in server ticks (~20 ticks per second).", + usage = "/potion <list | clear [player] | clearall | add <type> <duration> <amplifier> [player] | remove <type> [player]>" ) @Permission(permission = "tfm.player.potion") public class Command_potion extends FCommand diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_premium.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_premium.java index 123300050..1b6a50f3c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_premium.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_premium.java @@ -5,16 +5,16 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; - import java.util.List; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.util.FLog; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; +import me.totalfreedom.totalfreedommod.util.FLog; @Permission(source = SourceType.BOTH, permission = "tfm.admin.premium") @Command(name = "premium", description = "Validates if a given account is premium.", usage = "/premium <player>", aliases = "prem") diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_protectarea.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_protectarea.java index 0dd05ecb6..161a40bbb 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_protectarea.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_protectarea.java @@ -2,6 +2,12 @@ import java.util.List; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.ProtectArea.ProtectedRegion; import me.totalfreedom.totalfreedommod.bridge.WorldEditBridge; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; @@ -10,15 +16,10 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Subcommand; import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; - @Command(name = "protectarea", - description = "Manage protected regions so that only superadmins can directly modify blocks within them. WorldEdit and other such plugins might bypass this.", - usage = "/<command> <list | clear | create <name> | info <region> | update <region> | delete <region>>", - aliases = {"protectregion", "protect"}) + description = "Manage protected regions so that only superadmins can directly modify blocks within them. WorldEdit and other such plugins might bypass this.", + usage = "/<command> <list | clear | create <name> | info <region> | update <region> | delete <region>>", + aliases = {"protectregion", "protect"}) @Permission(permission = "tfm.admin.protectregion") public class Command_protectarea extends FCommand { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_purgeall.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_purgeall.java index 9bac550a6..562e425b0 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_purgeall.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_purgeall.java @@ -1,13 +1,13 @@ package me.totalfreedom.totalfreedommod.cmd; +import org.bukkit.command.CommandSender; +import org.bukkit.potion.PotionEffect; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; import me.totalfreedom.totalfreedommod.player.FPlayer; -import org.bukkit.command.CommandSender; -import org.bukkit.potion.PotionEffect; - @Command(name = "purgeall", description = "Superadmin command - Purge everything! (except for bans).", usage = "/<command>") @Permission(permission = "tfm.admin.purgeall") public class Command_purgeall extends FCommand diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_radar.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_radar.java index 2705dc1f0..52d4ab8f5 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_radar.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_radar.java @@ -1,15 +1,17 @@ package me.totalfreedom.totalfreedommod.cmd; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import java.util.Comparator; +import java.util.List; + import org.bukkit.Location; import org.bukkit.entity.Player; -import java.util.Comparator; -import java.util.List; +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; @Command(name = "radar", description = "Shows nearby people sorted by distance.", usage = "/radar [radius]") @Permission(permission = "tfm.player.radar", source = SourceType.ONLY_IN_GAME) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rank.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rank.java index 0761393c7..bfcc8deee 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rank.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rank.java @@ -1,15 +1,16 @@ package me.totalfreedom.totalfreedommod.cmd; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; import me.totalfreedom.totalfreedommod.display.Displayable; import me.totalfreedom.totalfreedommod.rank.CustomRank; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; - -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; @Command(name = "rank", description = "Shows ranks", usage = "/<command> [player]") @Permission(permission = "tfm.player.rank") diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rankconfig.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rankconfig.java index 92d0232f3..0b68e7d81 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rankconfig.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rankconfig.java @@ -5,19 +5,20 @@ import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.admin.Admin; import me.totalfreedom.totalfreedommod.cmd.internal.FuzzyMatch; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.rank.CustomRank; import me.totalfreedom.totalfreedommod.rank.RankRole; -import net.kyori.adventure.text.format.NamedTextColor; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @Command( - name = "rankconfig", - description = "Configure custom ranks.", - usage = "/rankconfig [list | create <id> | edit <rank> | delete <rank> | set <rank> <property> <value> | setrank <player> <rank> | reload | save]", - aliases = {"rankconf", "rankcfg"} + name = "rankconfig", + description = "Configure custom ranks.", + usage = "/rankconfig [list | create <id> | edit <rank> | delete <rank> | set <rank> <property> <value> | setrank <player> <rank> | reload | save]", + aliases = {"rankconf", "rankcfg"} ) @Permission(permission = "tfm.manage.ranks") public class Command_rankconfig extends FCommand diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_realname.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_realname.java index 164ebabfa..41ba0fad7 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_realname.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_realname.java @@ -1,14 +1,15 @@ package me.totalfreedom.totalfreedommod.cmd; +import org.bukkit.command.CommandSender; + +import net.kyori.adventure.text.Component; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Greedy; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; import me.totalfreedom.totalfreedommod.player.PlayerData; import me.totalfreedom.totalfreedommod.util.AdventureUtil; -import net.kyori.adventure.text.Component; - -import org.bukkit.command.CommandSender; @Command(name = "realname", description = "Finds the real name of a nicknamed player", usage = "/<command> <nickname..>") @Permission(permission = "tfm.player.realname") diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_report.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_report.java index 2c705e3ce..751549a53 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_report.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_report.java @@ -1,10 +1,11 @@ package me.totalfreedom.totalfreedommod.cmd; -import me.totalfreedom.totalfreedommod.admin.Admin; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import org.bukkit.OfflinePlayer; import org.bukkit.entity.Player; +import me.totalfreedom.totalfreedommod.admin.Admin; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; + @Permission(source = SourceType.ONLY_IN_GAME, permission = "tfm.player.report") @Command(name = "report", description = "Report a player for admins to see.", usage = "/report <player> <reason>") public class Command_report extends FCommand diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_ro.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_ro.java index f1aada78d..b6b883716 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_ro.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_ro.java @@ -4,16 +4,17 @@ import java.util.List; import java.util.Set; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; - import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.block.Block; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; + @Permission(permission = "tfm.admin.ro") @Command(name = "ro", description = "Remove all blocks of a certain type in the radius of certain players.", usage = "/<command> <blocks> [radius] [players]") public class Command_ro extends FCommand diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_saconfig.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_saconfig.java index 067b55c98..024c09ed8 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_saconfig.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_saconfig.java @@ -11,6 +11,13 @@ import java.util.stream.Collectors; import java.util.stream.Stream; +import org.bukkit.Bukkit; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.admin.Admin; import me.totalfreedom.totalfreedommod.cmd.internal.FuzzyMatch; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; @@ -21,14 +28,8 @@ import me.totalfreedom.totalfreedommod.player.FPlayer; import me.totalfreedom.totalfreedommod.rank.CustomRank; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; -import org.bukkit.Bukkit; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; - @Command(name = "saconfig", description = "Manage admins.", - usage = "/<command> <list | clean | reload | setrank <username> <rank> | <add | remove | info> <username>>") + usage = "/<command> <list | clean | reload | setrank <username> <rank> | <add | remove | info> <username>>") @Permission(permission = "tfm.admin.saconfig") public class Command_saconfig extends FCommand { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_say.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_say.java index 5d65b9012..620cb4292 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_say.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_say.java @@ -2,13 +2,14 @@ import org.bukkit.command.CommandSender; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.util.ChatMentionUtil; import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @Command(name = "say", description = "Broadcasts the given message as the console, includes sender name.", usage = "/say <message>") @Permission(permission = "tfm.admin.say") diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_setspawn.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_setspawn.java index de3eef007..ede7cd927 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_setspawn.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_setspawn.java @@ -3,9 +3,10 @@ import org.bukkit.Location; import org.bukkit.entity.Player; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @Command(name = "setspawn", description = "Set the server spawn to your current location.", usage = "/setspawn") @Permission(permission = "tfm.world.setspawn", source = SourceType.ONLY_IN_GAME) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_settings.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_settings.java index de0b5092e..4c11e19e2 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_settings.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_settings.java @@ -8,11 +8,12 @@ import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.PluginProvider; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import static me.totalfreedom.totalfreedommod.config.ConfigEntry.*; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_smite.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_smite.java index 00b9c947d..e17f062bc 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_smite.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_smite.java @@ -1,17 +1,19 @@ package me.totalfreedom.totalfreedommod.cmd; +import org.bukkit.GameMode; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Greedy; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; -import org.bukkit.GameMode; -import org.bukkit.Location; -import org.bukkit.World; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; @Command(name = "smite", description = "Someone being a little bitch? Smite them down...", usage = "/smite <player> [reason]") @Permission(permission = "tfm.fun.smite") diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_spawn.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_spawn.java index 862eb5ca6..aa7b868f7 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_spawn.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_spawn.java @@ -2,9 +2,10 @@ import org.bukkit.entity.Player; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; + @Command(name = "spawn", description = "Teleport to the server spawn.", usage = "/spawn [player]") @Permission(permission = "tfm.player.spawn", source = SourceType.ONLY_IN_GAME) public class Command_spawn extends FCommand diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_spawnmob.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_spawnmob.java index f88718da0..29b0103da 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_spawnmob.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_spawnmob.java @@ -1,15 +1,17 @@ package me.totalfreedom.totalfreedommod.cmd; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.Location; import org.bukkit.entity.EntityType; import org.bukkit.entity.Player; import org.bukkit.event.entity.CreatureSpawnEvent; +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; + @Command(name = "spawnmob", description = "Spawns any mob.", usage = "/spawnmob <type> [amount]") @Permission(permission = "tfm.fun.spawnmob", source = SourceType.ONLY_IN_GAME) public class Command_spawnmob extends FCommand diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_sqlstatus.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_sqlstatus.java index 1f1387233..f10d2baee 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_sqlstatus.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_sqlstatus.java @@ -1,11 +1,12 @@ package me.totalfreedom.totalfreedommod.cmd; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.sql.ConnectionHandler.PoolStats; +import org.bukkit.command.CommandSender; + import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; -import org.bukkit.command.CommandSender; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; +import me.totalfreedom.totalfreedommod.sql.ConnectionHandler.PoolStats; @Command(name = "sqlstatus", description = "Show database connection pool health.", usage = "/sqlstatus") @Permission(source = SourceType.BOTH, permission = "tfm.admin.sqlstatus") diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_sshtotp.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_sshtotp.java index c3ae5c8bb..c2c26839e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_sshtotp.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_sshtotp.java @@ -1,22 +1,23 @@ package me.totalfreedom.totalfreedommod.cmd; +import java.util.UUID; + +import org.bukkit.command.CommandSender; + +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.ssh.SshIdentity; import me.totalfreedom.totalfreedommod.ssh.SshQrServer; import me.totalfreedom.totalfreedommod.ssh.TotpUtil; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; - -import org.bukkit.command.CommandSender; - -import java.util.UUID; @Permission(source = SourceType.ONLY_CONSOLE, permission = "tfm.ssh.totp") @Command( name = "sshtotp", description = "Generate a TOTP secret for an SSH identity and serve a one-time QR setup page.", usage = "/sshtotp <identity>" - ) +) public class Command_sshtotp extends FCommand { /** diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_stfu.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_stfu.java index add52102f..7ba3c6f1b 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_stfu.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_stfu.java @@ -1,16 +1,17 @@ package me.totalfreedom.totalfreedommod.cmd; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.player.FPlayer; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import java.util.List; +import java.util.Objects; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; -import java.util.List; -import java.util.Objects; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; +import me.totalfreedom.totalfreedommod.player.FPlayer; @Permission(permission = "tfm.admin.mute") @Command(name = "stfu", aliases = "mute", description = "Mutes a player with brute force.", usage = "/<command> <<player> [reason] | list | purge | all>") diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_stop.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_stop.java index f9e4ac621..996b6b570 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_stop.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_stop.java @@ -1,6 +1,7 @@ package me.totalfreedom.totalfreedommod.cmd; import org.bukkit.command.CommandSender; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; @Command(name = "stop", description = "Kicks everyone and stops the server.", usage = "/stop") diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_strikes.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_strikes.java index e1b22e028..47290a9e8 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_strikes.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_strikes.java @@ -1,12 +1,13 @@ package me.totalfreedom.totalfreedommod.cmd; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.player.PlayerData; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; +import me.totalfreedom.totalfreedommod.player.PlayerData; @Permission(permission = "tfm.admin.strike") @Command(name = "strikes", aliases = "strike", description = "Manages the strikes for a player.", usage = "/<command> <add | remove | clear> <player>") diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_survival.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_survival.java index a6a138a74..8b643d5c0 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_survival.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_survival.java @@ -5,14 +5,15 @@ import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; + @Command( - name = "survival", - description = "Quickly change your own gamemode to survival, or define someone's username to change theirs.", - usage = "/survival <-a | [player]>", - aliases = {"gms"} + name = "survival", + description = "Quickly change your own gamemode to survival, or define someone's username to change theirs.", + usage = "/survival <-a | [player]>", + aliases = {"gms"} ) @Permission(permission = "tfm.player.survival") public class Command_survival extends FCommand diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_tag.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_tag.java index a91390924..d44ba3730 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_tag.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_tag.java @@ -9,6 +9,10 @@ import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.PluginProvider; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.config.ConfigEntry; @@ -16,9 +20,6 @@ import me.totalfreedom.totalfreedommod.player.PlayerData; import me.totalfreedom.totalfreedommod.rank.CustomRank; import me.totalfreedom.totalfreedommod.util.AdventureUtil; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @Command(name = "tag", description = "Sets yourself a prefix", usage = "/tag [-s[ave]] <set <tag..> | list | off | clear <player> | clearall>") @Permission(permission = "tfm.player.tag") diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_tempban.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_tempban.java index a72bb59df..a2d01c72c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_tempban.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_tempban.java @@ -5,6 +5,12 @@ import java.util.List; import java.util.Objects; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.banning.Ban; import me.totalfreedom.totalfreedommod.cmd.internal.FuzzyMatch; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; @@ -13,15 +19,9 @@ import me.totalfreedom.totalfreedommod.player.PlayerData; import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; - -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; - @Command(name = "tempban", aliases = {"tban", "noob"}, - description = "Temporarily bans an online or previously known player.", - usage = "/<command> [-s] [-rb] <player> [duration] [reason]") + description = "Temporarily bans an online or previously known player.", + usage = "/<command> [-s] [-rb] <player> [duration] [reason]") @Permission(permission = "tfm.admin.ban") public class Command_tempban extends FCommand { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_title.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_title.java index cfd5a89a1..966d13ec6 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_title.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_title.java @@ -1,17 +1,20 @@ package me.totalfreedom.totalfreedommod.cmd; import java.util.List; + +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Completer; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Subcommand; import me.totalfreedom.totalfreedommod.title.Title; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; /** * Grants, revokes and inspects titles. @@ -21,10 +24,10 @@ * strictly larger privilege than holding one. */ @Command( - name = "title", - description = "View and manage player titles.", - usage = "/title [list | info <title> | of <player> | grant <player> <title> | revoke <player> <title>]", - aliases = {"titles"} + name = "title", + description = "View and manage player titles.", + usage = "/title [list | info <title> | of <player> | grant <player> <title> | revoke <player> <title>]", + aliases = {"titles"} ) @Permission(permission = "tfm.player.titles") public class Command_title extends FCommand diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_tossmob.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_tossmob.java index 1d5a6b9a8..b1e88d6e1 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_tossmob.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_tossmob.java @@ -1,10 +1,5 @@ package me.totalfreedom.totalfreedommod.cmd; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import net.kyori.adventure.text.format.NamedTextColor; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; - import java.util.List; import java.util.Locale; import java.util.stream.Stream; @@ -14,9 +9,15 @@ import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; + @Permission(source = SourceType.ONLY_IN_GAME, permission = "tfm.fun.tossmob") @Command( - name = "tossmob", + name = "tossmob", description = "Throw a mob in the direction you are facing when you left click with a stick.", usage = "/<command> <mobtype [speed] | off | list>") public class Command_tossmob extends FCommand diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_totalfreedommod.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_totalfreedommod.java index dbb32e0d0..af12fde1f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_totalfreedommod.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_totalfreedommod.java @@ -1,5 +1,9 @@ package me.totalfreedom.totalfreedommod.cmd; +import org.bukkit.command.CommandSender; + +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; @@ -8,8 +12,6 @@ import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.discord.DiscordBridge; import me.totalfreedom.totalfreedommod.player.FPlayer; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; -import org.bukkit.command.CommandSender; /* * See https://github.com/TotalFreedom/License - This file may not be edited or removed. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_unban.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_unban.java index a07aa9043..cb8506101 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_unban.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_unban.java @@ -6,10 +6,11 @@ import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.banning.Ban; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.player.PlayerData; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @Command(name = "unban", description = "Unbans an online or offline player and linked IP addresses.", usage = "/unban [-s] [-r] <player>") @Permission(permission = "tfm.admin.ban") diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_unbanip.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_unbanip.java index b0b1ff71c..ff1c42fa5 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_unbanip.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_unbanip.java @@ -4,9 +4,10 @@ import org.bukkit.command.CommandSender; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @Command(name = "unbanip", description = "Unbans an IP address.", usage = "/unbanip <ip>") @Permission(permission = "tfm.admin.ban") diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_warn.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_warn.java index 90b045363..98e382bb9 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_warn.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_warn.java @@ -1,14 +1,15 @@ package me.totalfreedom.totalfreedommod.cmd; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Greedy; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; - -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; @Command(name = "warn", description = "Warns a player.", usage = "/<command> <player> <reason>") @Permission(permission = "tfm.admin.warn") diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_whitelist.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_whitelist.java index 617ba704a..1964ad62a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_whitelist.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_whitelist.java @@ -2,15 +2,16 @@ import java.util.List; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; - import org.bukkit.OfflinePlayer; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; +import me.totalfreedom.totalfreedommod.util.FUtil; + @Permission(permission = "tfm.server.whitelist") @Command(name = "whitelist", description = "Manage the whitelist.", usage = "/<command> <on | off | list | count | add <player> | remove <player> | addall | purge>") public class Command_whitelist extends FCommand diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_whohas.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_whohas.java index fa2757e2d..d86143b84 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_whohas.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_whohas.java @@ -2,15 +2,16 @@ import java.util.List; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; +import org.bukkit.Material; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.event.HoverEvent; import net.kyori.adventure.text.format.NamedTextColor; -import org.bukkit.Material; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; @Permission(permission = "tfm.admin.whohas") @Command(name = "whohas", aliases = "wh", description = "See who has a block and optionally clears the item.", usage = "/<command> [-clear] <item>") diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_wildcard.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_wildcard.java index 69097eaf0..faec09329 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_wildcard.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_wildcard.java @@ -4,15 +4,17 @@ import java.util.List; import java.util.Objects; +import org.bukkit.command.CommandSender; + +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.ssh.AttributedConsoleSender; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; -import org.bukkit.command.CommandSender; @Permission(permission = "tfm.admin.wildcard") @Command( name = "wildcard", - description = "Run any command on all users, username placeholder = ?.", + description = "Run any command on all users, username placeholder = ?.", usage = "/wildcard <command> (use ? to insert each player's username)") public class Command_wildcard extends FCommand { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/FCommand.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/FCommand.java index 1f3339aa2..9ead97b5e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/FCommand.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/FCommand.java @@ -7,6 +7,18 @@ import java.util.concurrent.TimeUnit; import java.util.function.Consumer; +import io.papermc.paper.threadedregions.scheduler.ScheduledTask; +import org.bukkit.Location; +import org.bukkit.Server; +import org.bukkit.World; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.scheduler.BukkitTask; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; + import me.totalfreedom.totalfreedommod.PluginProvider; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.admin.Admin; @@ -15,18 +27,6 @@ import me.totalfreedom.totalfreedommod.player.PlayerData; import me.totalfreedom.totalfreedommod.util.FTask; import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; -import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; - -import org.bukkit.Location; -import org.bukkit.Server; -import org.bukkit.World; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; -import org.bukkit.scheduler.BukkitTask; - -import io.papermc.paper.threadedregions.scheduler.ScheduledTask; /** * Base class for command declarations in the new command framework. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/MessageUtils.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/MessageUtils.java index 8ccd4eb42..da65e80e8 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/MessageUtils.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/MessageUtils.java @@ -8,7 +8,6 @@ import org.bukkit.Bukkit; import org.bukkit.command.CommandSender; -import me.totalfreedom.totalfreedommod.util.AdventureUtil; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.JoinConfiguration; import net.kyori.adventure.text.format.NamedTextColor; @@ -17,6 +16,8 @@ import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import me.totalfreedom.totalfreedommod.util.AdventureUtil; + /** * Utility class for formatting messages using Kyori Adventure MiniMessage. * Supports named colors, hex colors, click/hover events, and placeholders. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/NameCandidates.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/NameCandidates.java index 5fc5857e3..65eeb15d7 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/NameCandidates.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/NameCandidates.java @@ -2,15 +2,15 @@ import java.util.List; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.banning.Ban; -import me.totalfreedom.totalfreedommod.cmd.internal.FuzzyMatch; - import org.bukkit.Bukkit; import org.bukkit.OfflinePlayer; import org.bukkit.Server; import org.bukkit.entity.Player; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.banning.Ban; +import me.totalfreedom.totalfreedommod.cmd.internal.FuzzyMatch; + /** * Shared tab-completion sources for commands that take a player name as a plain {@code String}. * <p> diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/ArgumentResolver.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/ArgumentResolver.java index 458d40044..296d4f6ab 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/ArgumentResolver.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/ArgumentResolver.java @@ -1,12 +1,6 @@ package me.totalfreedom.totalfreedommod.cmd.internal; -import com.mojang.brigadier.arguments.ArgumentType; -import com.mojang.brigadier.arguments.BoolArgumentType; -import com.mojang.brigadier.arguments.DoubleArgumentType; -import com.mojang.brigadier.arguments.FloatArgumentType; -import com.mojang.brigadier.arguments.IntegerArgumentType; -import com.mojang.brigadier.arguments.LongArgumentType; -import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.arguments.*; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/CommandProcessor.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/CommandProcessor.java index c094d5fc2..1f8cc2842 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/CommandProcessor.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/CommandProcessor.java @@ -1,5 +1,19 @@ package me.totalfreedom.totalfreedommod.cmd.internal; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; +import java.util.stream.IntStream; + import com.mojang.brigadier.arguments.ArgumentType; import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.builder.ArgumentBuilder; @@ -7,11 +21,17 @@ import com.mojang.brigadier.builder.RequiredArgumentBuilder; import com.mojang.brigadier.context.CommandContext; import com.mojang.brigadier.suggestion.SuggestionProvider; -import io.papermc.paper.command.brigadier.Commands; import io.papermc.paper.command.brigadier.CommandSourceStack; +import io.papermc.paper.command.brigadier.Commands; import io.papermc.paper.command.brigadier.argument.ArgumentTypes; import io.papermc.paper.command.brigadier.argument.resolvers.selector.PlayerSelectorArgumentResolver; import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.cmd.CommandFailException; import me.totalfreedom.totalfreedommod.cmd.FCommand; @@ -28,26 +48,8 @@ import me.totalfreedom.totalfreedommod.cmd.resolver.AbstractArgumentResolver; import me.totalfreedom.totalfreedommod.cmd.resolver.ArgumentResolutionException; import me.totalfreedom.totalfreedommod.util.FLog; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; import org.apache.commons.lang3.exception.ExceptionUtils; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; - -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.lang.reflect.Parameter; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.function.Supplier; -import java.util.stream.IntStream; /** * Builds Brigadier command node trees from {@link FCommand} declarations and wires them diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/PermissionGate.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/PermissionGate.java index 5b05080e3..d2568e817 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/PermissionGate.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/PermissionGate.java @@ -1,15 +1,17 @@ package me.totalfreedom.totalfreedommod.cmd.internal; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.cmd.SourceType; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchContext; import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchSession; import me.totalfreedom.totalfreedommod.ssh.AttributedConsoleSender; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; /** * Enforces {@link Permission} declarations. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Callback.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Callback.java index a7f47c8c1..55bec4408 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Callback.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Callback.java @@ -1,10 +1,6 @@ package me.totalfreedom.totalfreedommod.cmd.internal.annotation; -import java.lang.annotation.Documented; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; +import java.lang.annotation.*; /** * Marks a method as a command handler. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Command.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Command.java index 9a7d12e55..41085be41 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Command.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Command.java @@ -1,10 +1,6 @@ package me.totalfreedom.totalfreedommod.cmd.internal.annotation; -import java.lang.annotation.Documented; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; +import java.lang.annotation.*; @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Completer.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Completer.java index 049a54430..3f4d8c4e6 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Completer.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Completer.java @@ -1,10 +1,6 @@ package me.totalfreedom.totalfreedommod.cmd.internal.annotation; -import java.lang.annotation.Documented; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; +import java.lang.annotation.*; /** * Overrides tab-completion for a specific argument position on a subcommand. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Cooldown.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Cooldown.java index 06acaadc7..e8834d7a4 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Cooldown.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Cooldown.java @@ -1,12 +1,8 @@ package me.totalfreedom.totalfreedommod.cmd.internal.annotation; -import me.totalfreedom.totalfreedommod.cmd.internal.CooldownUnit; +import java.lang.annotation.*; -import java.lang.annotation.Documented; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; +import me.totalfreedom.totalfreedommod.cmd.internal.CooldownUnit; /** * Applies a per-player cooldown to the annotated subcommand handler. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Greedy.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Greedy.java index 270234f49..2d99c0735 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Greedy.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Greedy.java @@ -1,10 +1,6 @@ package me.totalfreedom.totalfreedommod.cmd.internal.annotation; -import java.lang.annotation.Documented; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; +import java.lang.annotation.*; /** * Marks the final handler parameter as variable-length. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Permission.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Permission.java index 62b5ea828..18bcd3bb1 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Permission.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Permission.java @@ -1,10 +1,7 @@ package me.totalfreedom.totalfreedommod.cmd.internal.annotation; -import java.lang.annotation.Documented; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; +import java.lang.annotation.*; + import me.totalfreedom.totalfreedommod.cmd.SourceType; /** diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Resolve.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Resolve.java index f1c56412d..7abcfdf05 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Resolve.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Resolve.java @@ -1,10 +1,6 @@ package me.totalfreedom.totalfreedommod.cmd.internal.annotation; -import java.lang.annotation.Documented; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; +import java.lang.annotation.*; /** * Routes a handler parameter through a custom argument resolver. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Subcommand.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Subcommand.java index 300a764f8..ffc0a309a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Subcommand.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Subcommand.java @@ -1,10 +1,6 @@ package me.totalfreedom.totalfreedommod.cmd.internal.annotation; -import java.lang.annotation.Documented; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; +import java.lang.annotation.*; @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Switch.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Switch.java index 67b595c65..8be92f40a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Switch.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Switch.java @@ -1,10 +1,6 @@ package me.totalfreedom.totalfreedommod.cmd.internal.annotation; -import java.lang.annotation.Documented; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; +import java.lang.annotation.*; /** * Marks a {@code boolean} handler parameter as an optional command-line switch (e.g. {@code -s}). diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/AbstractParameterizedArgumentResolver.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/AbstractParameterizedArgumentResolver.java index 71c5cbd2b..06a3766ec 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/AbstractParameterizedArgumentResolver.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/AbstractParameterizedArgumentResolver.java @@ -1,11 +1,12 @@ package me.totalfreedom.totalfreedommod.cmd.resolver; -import com.google.common.collect.ImmutableMap; -import me.totalfreedom.totalfreedommod.util.FUtil; - import java.util.Arrays; import java.util.Map; +import me.totalfreedom.totalfreedommod.util.FUtil; + +import com.google.common.collect.ImmutableMap; + public interface AbstractParameterizedArgumentResolver<T> extends AbstractArgumentResolver<T> { T resolve(String arg, Map<String, Object> parameters); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/DateOffsetArgumentResolver.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/DateOffsetArgumentResolver.java index dbaf75258..b565d120b 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/DateOffsetArgumentResolver.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/DateOffsetArgumentResolver.java @@ -1,10 +1,10 @@ package me.totalfreedom.totalfreedommod.cmd.resolver; -import me.totalfreedom.totalfreedommod.util.FUtil; - import java.util.Date; import java.util.List; +import me.totalfreedom.totalfreedommod.util.FUtil; + public class DateOffsetArgumentResolver implements AbstractArgumentResolver<Date> { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/EnchantmentArgumentResolver.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/EnchantmentArgumentResolver.java index e3ee1c013..a6e1ea4ec 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/EnchantmentArgumentResolver.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/EnchantmentArgumentResolver.java @@ -2,11 +2,12 @@ import io.papermc.paper.registry.RegistryAccess; import io.papermc.paper.registry.RegistryKey; -import net.kyori.adventure.key.InvalidKeyException; -import net.kyori.adventure.key.Key; import org.bukkit.NamespacedKey; import org.bukkit.enchantments.Enchantment; +import net.kyori.adventure.key.InvalidKeyException; +import net.kyori.adventure.key.Key; + public class EnchantmentArgumentResolver implements AbstractArgumentResolver<Enchantment> { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/EntityTypeArgumentResolver.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/EntityTypeArgumentResolver.java index a17864705..1fa9a3d16 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/EntityTypeArgumentResolver.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/EntityTypeArgumentResolver.java @@ -2,12 +2,13 @@ import io.papermc.paper.registry.RegistryAccess; import io.papermc.paper.registry.RegistryKey; -import net.kyori.adventure.key.InvalidKeyException; -import net.kyori.adventure.key.Key; import org.bukkit.NamespacedKey; import org.bukkit.entity.EntityType; import org.bukkit.entity.Player; +import net.kyori.adventure.key.InvalidKeyException; +import net.kyori.adventure.key.Key; + public class EntityTypeArgumentResolver implements AbstractArgumentResolver<EntityType> { @Override diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/EnumArgumentResolver.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/EnumArgumentResolver.java index ca42d8696..59fdf7fb3 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/EnumArgumentResolver.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/EnumArgumentResolver.java @@ -1,13 +1,13 @@ package me.totalfreedom.totalfreedommod.cmd.resolver; -import com.google.common.base.Enums; -import com.google.common.base.Optional; - import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.Map; import java.util.function.Function; +import com.google.common.base.Enums; +import com.google.common.base.Optional; + public class EnumArgumentResolver implements AbstractParameterizedArgumentResolver<Enum<?>> { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/InetAddressListResolver.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/InetAddressListResolver.java index 066f6f4d9..a7eb11140 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/InetAddressListResolver.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/InetAddressListResolver.java @@ -1,16 +1,18 @@ package me.totalfreedom.totalfreedommod.cmd.resolver; -import com.google.common.net.InetAddresses; -import me.totalfreedom.totalfreedommod.PluginProvider; -import me.totalfreedom.totalfreedommod.cmd.internal.ResolverRegistry; -import me.totalfreedom.totalfreedommod.player.PlayerData; -import org.bukkit.entity.Player; - import java.net.InetAddress; import java.util.List; import java.util.Map; import java.util.Objects; +import org.bukkit.entity.Player; + +import me.totalfreedom.totalfreedommod.PluginProvider; +import me.totalfreedom.totalfreedommod.cmd.internal.ResolverRegistry; +import me.totalfreedom.totalfreedommod.player.PlayerData; + +import com.google.common.net.InetAddresses; + public class InetAddressListResolver implements AbstractParameterizedArgumentResolver<List<InetAddress>> { @Override diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/InetAddressResolver.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/InetAddressResolver.java index ccbae5638..0dbcd656d 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/InetAddressResolver.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/InetAddressResolver.java @@ -1,9 +1,9 @@ package me.totalfreedom.totalfreedommod.cmd.resolver; -import com.google.common.net.InetAddresses; - import java.net.InetAddress; +import com.google.common.net.InetAddresses; + public class InetAddressResolver implements AbstractArgumentResolver<InetAddress> { @Override diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/KeyArgumentResolver.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/KeyArgumentResolver.java index 9989c386c..bfbaab66b 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/KeyArgumentResolver.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/KeyArgumentResolver.java @@ -1,8 +1,9 @@ package me.totalfreedom.totalfreedommod.cmd.resolver; +import org.bukkit.NamespacedKey; + import net.kyori.adventure.key.InvalidKeyException; import net.kyori.adventure.key.Key; -import org.bukkit.NamespacedKey; public class KeyArgumentResolver implements AbstractArgumentResolver<Key> { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/MaterialArgumentResolver.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/MaterialArgumentResolver.java index 3a3def1d9..9f8faece3 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/MaterialArgumentResolver.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/MaterialArgumentResolver.java @@ -1,11 +1,12 @@ package me.totalfreedom.totalfreedommod.cmd.resolver; -import net.kyori.adventure.key.InvalidKeyException; -import net.kyori.adventure.key.Key; import org.bukkit.Material; import org.bukkit.NamespacedKey; import org.bukkit.Registry; +import net.kyori.adventure.key.InvalidKeyException; +import net.kyori.adventure.key.Key; + public class MaterialArgumentResolver implements AbstractArgumentResolver<Material> { @Override diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/MaterialQueryArgumentProvider.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/MaterialQueryArgumentProvider.java index a46431c5a..56b3fe86c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/MaterialQueryArgumentProvider.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/MaterialQueryArgumentProvider.java @@ -1,13 +1,13 @@ package me.totalfreedom.totalfreedommod.cmd.resolver; -import org.bukkit.Material; -import org.bukkit.Registry; - import java.util.List; import java.util.Map; import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; +import org.bukkit.Material; +import org.bukkit.Registry; + public class MaterialQueryArgumentProvider implements AbstractParameterizedArgumentResolver<List<Material>> { @Override diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/OfflinePlayerArgumentResolver.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/OfflinePlayerArgumentResolver.java index 5d2b8a73f..59f1408e2 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/OfflinePlayerArgumentResolver.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/OfflinePlayerArgumentResolver.java @@ -1,12 +1,13 @@ package me.totalfreedom.totalfreedommod.cmd.resolver; -import me.totalfreedom.totalfreedommod.cmd.FCommand; +import java.util.List; +import java.util.UUID; + import org.bukkit.Bukkit; import org.bukkit.OfflinePlayer; import org.bukkit.entity.Player; -import java.util.List; -import java.util.UUID; +import me.totalfreedom.totalfreedommod.cmd.FCommand; public class OfflinePlayerArgumentResolver implements AbstractArgumentResolver<OfflinePlayer> { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/PlayerArgumentResolver.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/PlayerArgumentResolver.java index 287debcdb..356768200 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/PlayerArgumentResolver.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/PlayerArgumentResolver.java @@ -1,13 +1,13 @@ package me.totalfreedom.totalfreedommod.cmd.resolver; -import me.totalfreedom.totalfreedommod.util.AdventureUtil; +import java.util.List; +import java.util.UUID; + import org.bukkit.Bukkit; import org.bukkit.entity.Player; import me.totalfreedom.totalfreedommod.cmd.FCommand; - -import java.util.List; -import java.util.UUID; +import me.totalfreedom.totalfreedommod.util.AdventureUtil; public class PlayerArgumentResolver implements AbstractArgumentResolver<Player> { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/PlayerListArgumentResolver.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/PlayerListArgumentResolver.java index 7e186734a..6607e259c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/PlayerListArgumentResolver.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/PlayerListArgumentResolver.java @@ -1,12 +1,12 @@ package me.totalfreedom.totalfreedommod.cmd.resolver; -import org.bukkit.Bukkit; -import org.bukkit.entity.Player; - import java.util.ArrayList; import java.util.List; import java.util.UUID; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + public class PlayerListArgumentResolver implements AbstractArgumentResolver<List<Player>> { private List<Player> resolveDefault(String arg) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/PotionEffectTypeArgumentResolver.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/PotionEffectTypeArgumentResolver.java index 7699b0fb3..6dbe35d6c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/PotionEffectTypeArgumentResolver.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/PotionEffectTypeArgumentResolver.java @@ -1,11 +1,12 @@ package me.totalfreedom.totalfreedommod.cmd.resolver; -import net.kyori.adventure.key.InvalidKeyException; -import net.kyori.adventure.key.Key; import org.bukkit.NamespacedKey; import org.bukkit.Registry; import org.bukkit.potion.PotionEffectType; +import net.kyori.adventure.key.InvalidKeyException; +import net.kyori.adventure.key.Key; + public class PotionEffectTypeArgumentResolver implements AbstractArgumentResolver<PotionEffectType> { @Override diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/WeatherArgumentResolver.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/WeatherArgumentResolver.java index 7d02e4bc0..9811a4b01 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/WeatherArgumentResolver.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/WeatherArgumentResolver.java @@ -1,12 +1,13 @@ package me.totalfreedom.totalfreedommod.cmd.resolver; -import me.totalfreedom.totalfreedommod.cmd.MessageUtils; -import me.totalfreedom.totalfreedommod.world.WorldWeather; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; - import java.util.Arrays; import java.util.List; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + +import me.totalfreedom.totalfreedommod.cmd.MessageUtils; +import me.totalfreedom.totalfreedommod.world.WorldWeather; + public class WeatherArgumentResolver implements AbstractArgumentResolver<WorldWeather> { @Override diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/WorldTimeArgumentResolver.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/WorldTimeArgumentResolver.java index 4f0fcd4bf..d3fc4a7df 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/WorldTimeArgumentResolver.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/WorldTimeArgumentResolver.java @@ -1,12 +1,13 @@ package me.totalfreedom.totalfreedommod.cmd.resolver; -import me.totalfreedom.totalfreedommod.cmd.MessageUtils; -import me.totalfreedom.totalfreedommod.world.WorldTime; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; - import java.util.Arrays; import java.util.List; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + +import me.totalfreedom.totalfreedommod.cmd.MessageUtils; +import me.totalfreedom.totalfreedommod.world.WorldTime; + public class WorldTimeArgumentResolver implements AbstractArgumentResolver<WorldTime> { @Override diff --git a/src/main/java/me/totalfreedom/totalfreedommod/config/ConfigEntry.java b/src/main/java/me/totalfreedom/totalfreedommod/config/ConfigEntry.java index e667a9314..8609877cd 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/config/ConfigEntry.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/config/ConfigEntry.java @@ -3,9 +3,11 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; -import me.totalfreedom.totalfreedommod.PluginProvider; + import org.bukkit.configuration.ConfigurationSection; +import me.totalfreedom.totalfreedommod.PluginProvider; + public enum ConfigEntry { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/config/MainConfig.java b/src/main/java/me/totalfreedom/totalfreedommod/config/MainConfig.java index 2f95c425b..7851e5f32 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/config/MainConfig.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/config/MainConfig.java @@ -7,12 +7,15 @@ import java.io.InputStreamReader; import java.util.EnumMap; import java.util.List; + +import org.bukkit.configuration.InvalidConfigurationException; +import org.bukkit.configuration.file.YamlConfiguration; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.framework.PluginComponent; +import me.totalfreedom.totalfreedommod.util.FLog; + import org.apache.commons.io.FileUtils; -import org.bukkit.configuration.InvalidConfigurationException; -import org.bukkit.configuration.file.YamlConfiguration; public class MainConfig extends PluginComponent<TotalFreedomMod> { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/discord/AbstractDiscordChatRelay.java b/src/main/java/me/totalfreedom/totalfreedommod/discord/AbstractDiscordChatRelay.java index 3ea87d83c..d7a2458cd 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/discord/AbstractDiscordChatRelay.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/discord/AbstractDiscordChatRelay.java @@ -9,6 +9,14 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.Style; +import net.kyori.adventure.text.format.TextColor; +import net.kyori.adventure.text.format.TextDecoration; + import discord4j.common.util.Snowflake; import discord4j.core.GatewayDiscordClient; import discord4j.core.event.domain.message.MessageCreateEvent; @@ -20,18 +28,12 @@ import discord4j.core.spec.MessageCreateSpec; import discord4j.rest.util.AllowedMentions; import discord4j.rest.util.Color; +import reactor.core.publisher.Mono; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.util.AdventureUtil; import me.totalfreedom.totalfreedommod.util.ChatMentionUtil; import me.totalfreedom.totalfreedommod.util.FLog; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.event.ClickEvent; -import net.kyori.adventure.text.event.HoverEvent; -import net.kyori.adventure.text.format.NamedTextColor; -import net.kyori.adventure.text.format.Style; -import net.kyori.adventure.text.format.TextColor; -import net.kyori.adventure.text.format.TextDecoration; -import reactor.core.publisher.Mono; /** * Bidirectional chat relay for the chat channel chosen by the extending subclass. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordBridge.java b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordBridge.java index f7ea80b9d..efdef4482 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordBridge.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordBridge.java @@ -1,6 +1,5 @@ package me.totalfreedom.totalfreedommod.discord; -import io.papermc.paper.event.player.AsyncChatEvent; import java.security.SecureRandom; import java.time.Duration; import java.util.List; @@ -10,20 +9,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; -import discord4j.common.util.Snowflake; -import discord4j.core.GatewayDiscordClient; -import discord4j.core.event.domain.lifecycle.ReadyEvent; -import discord4j.core.object.entity.channel.GuildMessageChannel; -import discord4j.discordjson.json.ApplicationCommandOptionData; -import discord4j.discordjson.json.ApplicationCommandRequest; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FTask; -import net.kyori.adventure.audience.Audience; -import net.kyori.adventure.text.Component; - +import io.papermc.paper.event.player.AsyncChatEvent; import org.bukkit.Bukkit; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; @@ -32,10 +18,26 @@ import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.scheduler.BukkitTask; + +import net.kyori.adventure.audience.Audience; +import net.kyori.adventure.text.Component; + +import discord4j.common.util.Snowflake; +import discord4j.core.GatewayDiscordClient; +import discord4j.core.event.domain.lifecycle.ReadyEvent; +import discord4j.core.object.entity.channel.GuildMessageChannel; +import discord4j.discordjson.json.ApplicationCommandOptionData; +import discord4j.discordjson.json.ApplicationCommandRequest; import reactor.core.publisher.Mono; import reactor.core.scheduler.Scheduler; import reactor.core.scheduler.Schedulers; +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.FTask; + /** * Owns the Discord4J client, the chat/console relays and the slash-command handlers. * <p> diff --git a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordCommands.java b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordCommands.java index af6cdf3f9..e3e07f4af 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordCommands.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordCommands.java @@ -9,13 +9,14 @@ import discord4j.core.object.command.ApplicationCommandInteractionOption; import discord4j.core.object.command.ApplicationCommandInteractionOptionValue; import discord4j.core.spec.InteractionApplicationCommandCallbackSpec; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.admin.Admin; import me.totalfreedom.totalfreedommod.sql.adapter.DiscordLinkRepository; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.PlayerListUtil; -import reactor.core.publisher.Mono; -import reactor.core.scheduler.Schedulers; /** * Slash command handlers for {@code /list}, {@code /link}, {@code /unlink}. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordConnection.java b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordConnection.java index 13a364477..ee2bc3495 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordConnection.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordConnection.java @@ -8,15 +8,16 @@ import discord4j.core.DiscordClient; import discord4j.core.GatewayDiscordClient; -import discord4j.gateway.intent.IntentSet; import discord4j.gateway.intent.Intent; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.FLog; +import discord4j.gateway.intent.IntentSet; import reactor.core.Disposable; import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; import reactor.util.retry.Retry; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.FLog; + /** * Owns the bridge's gateway connection and its reconnect budget. * <p> diff --git a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordConsoleRelay.java b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordConsoleRelay.java index 3690642f8..896b88953 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordConsoleRelay.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordConsoleRelay.java @@ -6,12 +6,18 @@ import java.util.Optional; import java.util.UUID; +import org.bukkit.Bukkit; +import org.bukkit.scheduler.BukkitTask; + import discord4j.common.util.Snowflake; import discord4j.core.GatewayDiscordClient; import discord4j.core.event.domain.message.MessageCreateEvent; import discord4j.core.object.emoji.Emoji; import discord4j.core.object.entity.Message; import discord4j.core.object.entity.User; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.admin.Admin; import me.totalfreedom.totalfreedommod.config.ConfigEntry; @@ -20,12 +26,9 @@ import me.totalfreedom.totalfreedommod.util.CallbackLogAppender; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FTask; + import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.core.Logger; -import org.bukkit.Bukkit; -import org.bukkit.scheduler.BukkitTask; -import reactor.core.publisher.Mono; -import reactor.core.scheduler.Schedulers; /** * Streams server log output into the Discord console channel and dispatches diff --git a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordLinkJsonSync.java b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordLinkJsonSync.java index a834862a8..0c7c0906c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordLinkJsonSync.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordLinkJsonSync.java @@ -1,20 +1,22 @@ package me.totalfreedom.totalfreedommod.discord; -import com.google.gson.reflect.TypeToken; import java.io.File; import java.io.FileReader; import java.io.FileWriter; import java.lang.reflect.Type; import java.util.Map; import java.util.UUID; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.sql.PersistenceQueue; import me.totalfreedom.totalfreedommod.sql.adapter.DiscordLinkRepository; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.JsonUtil; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; +import com.google.gson.reflect.TypeToken; /** * JSON write-through + startup reconciliation for admin-uuid to Discord-user-id links. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordMarkdown.java b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordMarkdown.java index 9891d02c1..4d02bea04 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordMarkdown.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordMarkdown.java @@ -2,6 +2,7 @@ import java.util.EnumSet; import java.util.Set; + import net.kyori.adventure.text.Component; import net.kyori.adventure.text.TextComponent; import net.kyori.adventure.text.format.TextDecoration; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/disguise/DisallowedDisguises.java b/src/main/java/me/totalfreedom/totalfreedommod/disguise/DisallowedDisguises.java index fde901fe2..a54b2516c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/disguise/DisallowedDisguises.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/disguise/DisallowedDisguises.java @@ -3,11 +3,13 @@ import java.util.HashSet; import java.util.List; import java.util.Set; + +import org.bukkit.entity.EntityType; + import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.util.FLog; -import org.bukkit.entity.EntityType; /** * Manages forbidden disguise types and global disguise state. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/framework/AbstractService.java b/src/main/java/me/totalfreedom/totalfreedommod/framework/AbstractService.java index 1b2727062..52f942d18 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/framework/AbstractService.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/framework/AbstractService.java @@ -1,8 +1,9 @@ package me.totalfreedom.totalfreedommod.framework; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; import org.bukkit.Server; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; + /** * Base class for all services. * Services are components that have a lifecycle (onStart/onStop). diff --git a/src/main/java/me/totalfreedom/totalfreedommod/framework/PluginComponent.java b/src/main/java/me/totalfreedom/totalfreedommod/framework/PluginComponent.java index e6c9f1afa..821455d3b 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/framework/PluginComponent.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/framework/PluginComponent.java @@ -1,8 +1,9 @@ package me.totalfreedom.totalfreedommod.framework; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; import org.bukkit.Server; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; + /** * Base class for plugin components. * Provides access to the plugin and server instances. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/framework/PluginListener.java b/src/main/java/me/totalfreedom/totalfreedommod/framework/PluginListener.java index 35ff88a71..662c507ef 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/framework/PluginListener.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/framework/PluginListener.java @@ -1,8 +1,9 @@ package me.totalfreedom.totalfreedommod.framework; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; import org.bukkit.event.Listener; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; + /** * Base class for plugin listeners. * Extends PluginComponent and implements Listener. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/framework/ServiceManager.java b/src/main/java/me/totalfreedom/totalfreedommod/framework/ServiceManager.java index 9ca7ac5f0..00605e427 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/framework/ServiceManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/framework/ServiceManager.java @@ -2,9 +2,11 @@ import java.util.ArrayList; import java.util.List; + +import org.bukkit.event.Listener; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.util.FLog; -import org.bukkit.event.Listener; /** * Manages the lifecycle of services. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/freeze/FreezeData.java b/src/main/java/me/totalfreedom/totalfreedommod/freeze/FreezeData.java index 8ae65a626..5587d8453 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/freeze/FreezeData.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/freeze/FreezeData.java @@ -1,16 +1,19 @@ package me.totalfreedom.totalfreedommod.freeze; -import lombok.Getter; +import org.bukkit.GameMode; +import org.bukkit.Location; +import org.bukkit.entity.Player; +import org.bukkit.scheduler.BukkitTask; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.player.FPlayer; import me.totalfreedom.totalfreedommod.player.PlayerData; -import static me.totalfreedom.totalfreedommod.player.FPlayer.AUTO_PURGE_TICKS; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FUtil; -import org.bukkit.GameMode; -import org.bukkit.Location; -import org.bukkit.entity.Player; -import org.bukkit.scheduler.BukkitTask; + +import lombok.Getter; + +import static me.totalfreedom.totalfreedommod.player.FPlayer.AUTO_PURGE_TICKS; public class FreezeData { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/freeze/Freezer.java b/src/main/java/me/totalfreedom/totalfreedommod/freeze/Freezer.java index 3b503c7c8..26eb30a4f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/freeze/Freezer.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/freeze/Freezer.java @@ -1,15 +1,17 @@ package me.totalfreedom.totalfreedommod.freeze; -import lombok.Getter; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.util.FUtil; import org.bukkit.Location; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.player.PlayerMoveEvent; +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.util.FUtil; + +import lombok.Getter; + public class Freezer extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/fun/ItemFun.java b/src/main/java/me/totalfreedom/totalfreedommod/fun/ItemFun.java index d8f0f2163..be6fff54f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/fun/ItemFun.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/fun/ItemFun.java @@ -6,13 +6,6 @@ import io.papermc.paper.registry.RegistryAccess; import io.papermc.paper.registry.RegistryKey; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.player.FPlayer; -import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.key.Key; -import net.kyori.adventure.text.Component; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.Registry; @@ -27,6 +20,15 @@ import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.util.Vector; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.text.Component; + +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.player.FPlayer; +import me.totalfreedom.totalfreedommod.util.FUtil; + public class ItemFun extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/fun/Jumppads.java b/src/main/java/me/totalfreedom/totalfreedommod/fun/Jumppads.java index 6a3db5c13..42fb9601b 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/fun/Jumppads.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/fun/Jumppads.java @@ -1,15 +1,7 @@ package me.totalfreedom.totalfreedommod.fun; -import com.google.common.collect.Maps; - import java.util.*; -import lombok.Getter; -import lombok.Setter; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.FLog; import org.bukkit.GameMode; import org.bukkit.Tag; import org.bukkit.block.Block; @@ -18,6 +10,15 @@ import org.bukkit.event.player.PlayerMoveEvent; import org.bukkit.util.Vector; +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.FLog; + +import com.google.common.collect.Maps; +import lombok.Getter; +import lombok.Setter; + public class Jumppads extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/fun/Landminer.java b/src/main/java/me/totalfreedom/totalfreedommod/fun/Landminer.java index b19402999..29767ea55 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/fun/Landminer.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/fun/Landminer.java @@ -3,10 +3,7 @@ import java.util.ArrayList; import java.util.Iterator; import java.util.List; -import lombok.Getter; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; + import org.bukkit.GameMode; import org.bukkit.Location; import org.bukkit.Material; @@ -17,6 +14,12 @@ import org.bukkit.event.player.PlayerMoveEvent; import org.bukkit.util.Vector; +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; + +import lombok.Getter; + public class Landminer extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/fun/MP44.java b/src/main/java/me/totalfreedom/totalfreedommod/fun/MP44.java index 763aa2783..d2a42e383 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/fun/MP44.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/fun/MP44.java @@ -1,11 +1,12 @@ package me.totalfreedom.totalfreedommod.fun; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.player.PlayerQuitEvent; +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; + public class MP44 extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/fun/Trailer.java b/src/main/java/me/totalfreedom/totalfreedommod/fun/Trailer.java index 3a278a3a3..35f302595 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/fun/Trailer.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/fun/Trailer.java @@ -3,8 +3,6 @@ import java.util.*; import java.util.concurrent.atomic.AtomicInteger; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; import org.bukkit.Material; import org.bukkit.block.Block; import org.bukkit.entity.Player; @@ -12,6 +10,9 @@ import org.bukkit.event.EventPriority; import org.bukkit.event.player.PlayerMoveEvent; +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; + public class Trailer extends FreedomService { private final Material[] woolColors = new Material[] { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/httpd/HTMLGenerationTools.java b/src/main/java/me/totalfreedom/totalfreedommod/httpd/HTMLGenerationTools.java index a980a94d7..b2463c043 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/httpd/HTMLGenerationTools.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/httpd/HTMLGenerationTools.java @@ -1,10 +1,11 @@ package me.totalfreedom.totalfreedommod.httpd; -import com.google.common.html.HtmlEscapers; import java.util.Collection; import java.util.Iterator; import java.util.Map; +import com.google.common.html.HtmlEscapers; + public class HTMLGenerationTools { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/httpd/HTTPDaemon.java b/src/main/java/me/totalfreedom/totalfreedommod/httpd/HTTPDaemon.java index 0638c7b13..ff147a54f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/httpd/HTTPDaemon.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/httpd/HTTPDaemon.java @@ -7,17 +7,12 @@ import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; + import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.httpd.NanoHTTPD.Response; -import me.totalfreedom.totalfreedommod.httpd.module.HTTPDModule; -import me.totalfreedom.totalfreedommod.httpd.module.Module_file; -import me.totalfreedom.totalfreedommod.httpd.module.Module_help; -import me.totalfreedom.totalfreedommod.httpd.module.Module_list; -import me.totalfreedom.totalfreedommod.httpd.module.Module_permbans; -import me.totalfreedom.totalfreedommod.httpd.module.Module_players; -import me.totalfreedom.totalfreedommod.httpd.module.Module_schematic; +import me.totalfreedom.totalfreedommod.httpd.module.*; import me.totalfreedom.totalfreedommod.util.FLog; public class HTTPDaemon extends FreedomService diff --git a/src/main/java/me/totalfreedom/totalfreedommod/httpd/ModuleExecutable.java b/src/main/java/me/totalfreedom/totalfreedommod/httpd/ModuleExecutable.java index 0934351aa..10be6a5fb 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/httpd/ModuleExecutable.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/httpd/ModuleExecutable.java @@ -5,13 +5,16 @@ import java.util.concurrent.Callable; import java.util.logging.Level; import java.util.logging.Logger; -import lombok.Getter; + +import org.bukkit.Bukkit; + import me.totalfreedom.totalfreedommod.PluginProvider; import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.framework.PluginComponent; import me.totalfreedom.totalfreedommod.httpd.module.HTTPDModule; import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.framework.PluginComponent; -import org.bukkit.Bukkit; + +import lombok.Getter; public abstract class ModuleExecutable { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/httpd/NanoHTTPD.java b/src/main/java/me/totalfreedom/totalfreedommod/httpd/NanoHTTPD.java index efe0600c7..657aed8e4 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/httpd/NanoHTTPD.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/httpd/NanoHTTPD.java @@ -1,37 +1,12 @@ package me.totalfreedom.totalfreedommod.httpd; -import java.io.BufferedReader; -import java.io.ByteArrayInputStream; -import java.io.Closeable; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.OutputStream; -import java.io.PrintWriter; -import java.io.RandomAccessFile; -import java.io.SequenceInputStream; -import java.io.UnsupportedEncodingException; -import java.net.InetSocketAddress; -import java.net.ServerSocket; -import java.net.Socket; -import java.net.SocketException; -import java.net.URLDecoder; +import java.io.*; +import java.net.*; import java.nio.ByteBuffer; import java.nio.channels.FileChannel; import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Calendar; -import java.util.Date; -import java.util.HashMap; -import java.util.Iterator; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.StringTokenizer; -import java.util.TimeZone; +import java.util.*; + import me.totalfreedom.totalfreedommod.util.FLog; /** diff --git a/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/HTTPDModule.java b/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/HTTPDModule.java index fc6c575ff..56b63b8da 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/HTTPDModule.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/HTTPDModule.java @@ -5,15 +5,16 @@ import java.util.List; import java.util.Map; -import com.google.common.html.HtmlEscapers; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.framework.PluginComponent; import me.totalfreedom.totalfreedommod.httpd.HTTPDPageBuilder; import me.totalfreedom.totalfreedommod.httpd.NanoHTTPD.HTTPSession; import me.totalfreedom.totalfreedommod.httpd.NanoHTTPD.Method; import me.totalfreedom.totalfreedommod.httpd.NanoHTTPD.Response; import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.framework.PluginComponent; + +import com.google.common.html.HtmlEscapers; public abstract class HTTPDModule extends PluginComponent<TotalFreedomMod> { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_file.java b/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_file.java index 076b526bc..5dbbd917f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_file.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_file.java @@ -1,17 +1,9 @@ package me.totalfreedom.totalfreedommod.httpd.module; -import java.io.File; -import java.io.FileInputStream; -import java.io.FilenameFilter; -import java.io.IOException; -import java.io.UnsupportedEncodingException; +import java.io.*; import java.net.URLEncoder; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.StringTokenizer; +import java.util.*; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.httpd.HTTPDaemon; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_help.java b/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_help.java index 2d93ba77b..d30243caf 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_help.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_help.java @@ -1,6 +1,5 @@ package me.totalfreedom.totalfreedommod.httpd.module; -import com.google.common.collect.Lists; import java.util.Collection; import java.util.Collections; import java.util.Comparator; @@ -9,22 +8,24 @@ import java.util.List; import java.util.Map; +import org.bukkit.command.Command; +import org.bukkit.command.CommandMap; +import org.bukkit.command.PluginIdentifiableCommand; +import org.bukkit.command.SimpleCommandMap; + +import me.totalfreedom.totalfreedommod.PluginProvider; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.cmd.CommandRegistry; import me.totalfreedom.totalfreedommod.cmd.FCommand; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; import me.totalfreedom.totalfreedommod.display.Displayable; +import me.totalfreedom.totalfreedommod.httpd.NanoHTTPD; import me.totalfreedom.totalfreedommod.rank.CustomRank; -import me.totalfreedom.totalfreedommod.PluginProvider; + +import com.google.common.collect.Lists; import static me.totalfreedom.totalfreedommod.httpd.HTMLGenerationTools.heading; import static me.totalfreedom.totalfreedommod.httpd.HTMLGenerationTools.paragraph; -import me.totalfreedom.totalfreedommod.httpd.NanoHTTPD; - -import org.bukkit.command.Command; -import org.bukkit.command.CommandMap; -import org.bukkit.command.PluginIdentifiableCommand; -import org.bukkit.command.SimpleCommandMap; public class Module_help extends HTTPDModule { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_list.java b/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_list.java index 80087c951..417a23080 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_list.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_list.java @@ -1,11 +1,13 @@ package me.totalfreedom.totalfreedommod.httpd.module; import java.util.Collection; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.httpd.NanoHTTPD; + import org.bukkit.Bukkit; import org.bukkit.entity.Player; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.httpd.NanoHTTPD; + public class Module_list extends HTTPDModule { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_permbans.java b/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_permbans.java index 0e44761f9..96db8d914 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_permbans.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_permbans.java @@ -1,6 +1,7 @@ package me.totalfreedom.totalfreedommod.httpd.module; import java.io.File; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.banning.PermbanList; import me.totalfreedom.totalfreedommod.httpd.HTTPDaemon; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_players.java b/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_players.java index 08a5b266c..bacbb99f0 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_players.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_players.java @@ -1,13 +1,15 @@ package me.totalfreedom.totalfreedommod.httpd.module; -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.admin.Admin; import me.totalfreedom.totalfreedommod.httpd.NanoHTTPD; import me.totalfreedom.totalfreedommod.util.FUtil; -import org.bukkit.Bukkit; -import org.bukkit.entity.Player; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; public class Module_players extends HTTPDModule { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_schematic.java b/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_schematic.java index 81e75ee5d..baec7b9a2 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_schematic.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_schematic.java @@ -2,13 +2,9 @@ import java.io.File; import java.io.IOException; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.Comparator; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.regex.Pattern; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.admin.Admin; import me.totalfreedom.totalfreedommod.config.ConfigEntry; @@ -19,6 +15,7 @@ import me.totalfreedom.totalfreedommod.httpd.NanoHTTPD.Method; import me.totalfreedom.totalfreedommod.httpd.NanoHTTPD.Response; import me.totalfreedom.totalfreedommod.util.FLog; + import org.apache.commons.io.FileUtils; public class Module_schematic extends HTTPDModule diff --git a/src/main/java/me/totalfreedom/totalfreedommod/player/FPlayer.java b/src/main/java/me/totalfreedom/totalfreedommod/player/FPlayer.java index 0fd8911f7..1473ff97b 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/player/FPlayer.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/player/FPlayer.java @@ -2,17 +2,7 @@ import java.util.ArrayList; import java.util.List; -import lombok.Getter; -import lombok.Setter; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.caging.CageData; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.freeze.FreezeData; -import me.totalfreedom.totalfreedommod.util.AdventureUtil; -import me.totalfreedom.totalfreedommod.util.FTask; -import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; + import org.bukkit.Bukkit; import org.bukkit.entity.Arrow; import org.bukkit.entity.EntityType; @@ -21,6 +11,20 @@ import org.bukkit.scheduler.BukkitRunnable; import org.bukkit.scheduler.BukkitTask; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.caging.CageData; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.freeze.FreezeData; +import me.totalfreedom.totalfreedommod.util.AdventureUtil; +import me.totalfreedom.totalfreedommod.util.FTask; +import me.totalfreedom.totalfreedommod.util.FUtil; + +import lombok.Getter; +import lombok.Setter; + public class FPlayer { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerData.java b/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerData.java index 1d4be632b..360b74b98 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerData.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerData.java @@ -1,22 +1,25 @@ package me.totalfreedom.totalfreedommod.player; -import com.google.common.collect.Lists; -import com.google.common.collect.Sets; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Set; + +import org.bukkit.Bukkit; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + import me.totalfreedom.totalfreedommod.PluginProvider; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.util.AdventureUtil; import me.totalfreedom.totalfreedommod.util.ConfigInterfaces.ConfigLoadable; import me.totalfreedom.totalfreedommod.util.ConfigInterfaces.Validatable; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; -import org.bukkit.Bukkit; -import org.bukkit.configuration.ConfigurationSection; -import org.bukkit.entity.Player; +import com.google.common.collect.Lists; +import com.google.common.collect.Sets; public class PlayerData implements ConfigLoadable, Validatable { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerList.java b/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerList.java index 8cb3b7b44..fc53fe603 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerList.java @@ -1,35 +1,40 @@ package me.totalfreedom.totalfreedommod.player; -import com.google.common.collect.Maps; import java.io.File; import java.io.FileReader; import java.io.FileWriter; +import java.io.IOException; import java.util.Arrays; import java.util.Collection; import java.util.List; import java.util.Map; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.sql.PersistenceQueue; -import me.totalfreedom.totalfreedommod.sql.adapter.PlayerRepository; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FUtil; -import me.totalfreedom.totalfreedommod.util.JsonUtil; -import java.io.IOException; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; -import org.bukkit.configuration.file.YamlConfiguration; + import org.bukkit.Bukkit; import org.bukkit.OfflinePlayer; +import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.player.AsyncPlayerPreLoginEvent; import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerQuitEvent; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.sql.PersistenceQueue; +import me.totalfreedom.totalfreedommod.sql.adapter.PlayerRepository; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.FUtil; +import me.totalfreedom.totalfreedommod.util.JsonUtil; + +import com.google.common.collect.Maps; + public class PlayerList extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/rank/ConsoleSenderRegistry.java b/src/main/java/me/totalfreedom/totalfreedommod/rank/ConsoleSenderRegistry.java index a030ef4c7..d490efbb3 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/rank/ConsoleSenderRegistry.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/rank/ConsoleSenderRegistry.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.Map; import java.util.Set; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.util.FLog; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/rank/CustomRank.java b/src/main/java/me/totalfreedom/totalfreedommod/rank/CustomRank.java index cbc0fbcbe..b811f9788 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/rank/CustomRank.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/rank/CustomRank.java @@ -4,12 +4,14 @@ import java.util.HashSet; import java.util.Set; -import me.totalfreedom.totalfreedommod.display.Displayable; -import me.totalfreedom.totalfreedommod.util.AdventureUtil; +import org.bukkit.configuration.ConfigurationSection; + import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.TextDecoration; -import org.bukkit.configuration.ConfigurationSection; + +import me.totalfreedom.totalfreedommod.display.Displayable; +import me.totalfreedom.totalfreedommod.util.AdventureUtil; /** * Represents a configurable rank in the TFM permission system. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/rank/PermissionTrie.java b/src/main/java/me/totalfreedom/totalfreedommod/rank/PermissionTrie.java index 21ca1849c..8248f512e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/rank/PermissionTrie.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/rank/PermissionTrie.java @@ -1,11 +1,6 @@ package me.totalfreedom.totalfreedommod.rank; -import java.util.Collections; -import java.util.Comparator; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.Set; +import java.util.*; /** * An index of which ranks grant which internal permission nodes. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java index b316a6844..b6d877731 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java @@ -1,7 +1,5 @@ package me.totalfreedom.totalfreedommod.rank; -import com.google.common.collect.Maps; -import com.google.gson.reflect.TypeToken; import java.io.File; import java.io.FileReader; import java.io.FileWriter; @@ -18,28 +16,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.function.Consumer; import java.util.stream.Stream; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.admin.Admin; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchContext; -import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchSession; -import me.totalfreedom.totalfreedommod.display.Displayable; -import me.totalfreedom.totalfreedommod.player.FPlayer; -import me.totalfreedom.totalfreedommod.sql.PersistenceQueue; -import me.totalfreedom.totalfreedommod.sql.adapter.RankRepository; -import me.totalfreedom.totalfreedommod.util.AdventureUtil; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FTask; -import me.totalfreedom.totalfreedommod.util.FUtil; -import me.totalfreedom.totalfreedommod.util.JsonUtil; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.event.ClickEvent; -import net.kyori.adventure.text.event.HoverEvent; -import net.kyori.adventure.text.format.NamedTextColor; -import net.kyori.adventure.text.format.TextDecoration; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; -import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; + import io.papermc.paper.event.player.AsyncChatEvent; import org.bukkit.GameMode; import org.bukkit.command.BlockCommandSender; @@ -58,10 +35,34 @@ import org.bukkit.scoreboard.Scoreboard; import org.bukkit.scoreboard.ScoreboardManager; import org.bukkit.scoreboard.Team; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; + import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.admin.Admin; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchContext; +import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchSession; +import me.totalfreedom.totalfreedommod.display.Displayable; +import me.totalfreedom.totalfreedommod.player.FPlayer; +import me.totalfreedom.totalfreedommod.sql.PersistenceQueue; +import me.totalfreedom.totalfreedommod.sql.adapter.RankRepository; +import me.totalfreedom.totalfreedommod.util.*; + +import com.google.common.collect.Maps; +import com.google.gson.reflect.TypeToken; + public class RankManager extends FreedomService { public static final String RANKS_FILENAME = "ranks.json"; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/rank/RankRegistry.java b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankRegistry.java index 690f8b73d..d42e0ef7a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/rank/RankRegistry.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankRegistry.java @@ -4,15 +4,17 @@ import java.util.Map; import java.util.Optional; import java.util.stream.Stream; + +import org.bukkit.command.BlockCommandSender; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.entity.minecart.CommandMinecart; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.admin.Admin; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchContext; import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchSession; -import org.bukkit.command.BlockCommandSender; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; -import org.bukkit.entity.minecart.CommandMinecart; /** * The single place that answers "what rank is this?" and "what rank does this permission need?". diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/AccessController.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/AccessController.java index e7f612b70..e6a4af5f5 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/AccessController.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/AccessController.java @@ -5,11 +5,11 @@ import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; -import me.totalfreedom.totalfreedommod.util.FLog; - import reactor.core.publisher.Mono; import reactor.core.scheduler.Scheduler; +import me.totalfreedom.totalfreedommod.util.FLog; + /** * This is a fair, non-blocking access controller that ensures our queries on the connection pool * don't overload our available connections. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/ConnectionHandler.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/ConnectionHandler.java index bc20e80bf..22da7829d 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/ConnectionHandler.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/ConnectionHandler.java @@ -3,19 +3,18 @@ import java.sql.Connection; import java.sql.SQLException; -import org.apache.commons.lang3.exception.ExceptionUtils; -import org.jetbrains.annotations.NotNull; - -import com.zaxxer.hikari.HikariConfig; -import com.zaxxer.hikari.HikariDataSource; -import com.zaxxer.hikari.HikariPoolMXBean; +import reactor.core.scheduler.Scheduler; +import reactor.core.scheduler.Schedulers; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.sql.SQLProperties.DatabaseType; import me.totalfreedom.totalfreedommod.util.FLog; -import reactor.core.scheduler.Scheduler; -import reactor.core.scheduler.Schedulers; +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import com.zaxxer.hikari.HikariPoolMXBean; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.jetbrains.annotations.NotNull; /** * Contains the HikariCP connection pool for the configured database. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/FreedomDatabase.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/FreedomDatabase.java index 915fe015c..3e8320da0 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/FreedomDatabase.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/FreedomDatabase.java @@ -1,24 +1,5 @@ package me.totalfreedom.totalfreedommod.sql; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.sql.SQLProperties.DatabaseType; -import me.totalfreedom.totalfreedommod.sql.adapter.AdapterFactory; -import me.totalfreedom.totalfreedommod.sql.adapter.AdminRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.BanRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; -import me.totalfreedom.totalfreedommod.sql.adapter.DiscordLinkRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.MigrationRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.PermbanRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.PlayerRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.ProtectedAreaRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.RankRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.TitleRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.SavedFlagRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.StrikeRepository; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FTask; - import java.util.ArrayList; import java.util.List; import java.util.function.Consumer; @@ -26,6 +7,13 @@ import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.sql.SQLProperties.DatabaseType; +import me.totalfreedom.totalfreedommod.sql.adapter.*; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.FTask; + /** * Central database management service. * Handles database initialization, adapter creation, and provides access to repositories. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/PersistenceQueue.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/PersistenceQueue.java index 39263a990..43c3583a2 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/PersistenceQueue.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/PersistenceQueue.java @@ -2,10 +2,10 @@ import java.time.Duration; -import me.totalfreedom.totalfreedommod.util.FLog; - import reactor.core.publisher.Mono; +import me.totalfreedom.totalfreedommod.util.FLog; + /** * Serialises one domain's persistence work onto a single ordered chain. Each queued unit runs * after the one before it, so a whole-list batch can never land behind a single-row update that diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/SQLProperties.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/SQLProperties.java index 1545f0da6..873686124 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/SQLProperties.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/SQLProperties.java @@ -1,16 +1,16 @@ package me.totalfreedom.totalfreedommod.sql; +import java.io.File; import java.sql.Connection; import java.sql.PreparedStatement; -import java.util.Map; import java.util.HashMap; +import java.util.Map; import java.util.Properties; -import java.io.File; - -import org.jetbrains.annotations.NotNull; import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import org.jetbrains.annotations.NotNull; + /** * Handles SQL database configuration properties. * Supports: sqlite, mysql, postgresql diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/YamlMigrationService.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/YamlMigrationService.java index 8001d9b28..a8d59ce77 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/YamlMigrationService.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/YamlMigrationService.java @@ -1,26 +1,5 @@ package me.totalfreedom.totalfreedommod.sql; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.ProtectArea.ProtectedRegion; -import me.totalfreedom.totalfreedommod.ProtectArea.ProtectedRegion.CantFindWorldException; -import me.totalfreedom.totalfreedommod.admin.Admin; -import me.totalfreedom.totalfreedommod.banning.Ban; -import me.totalfreedom.totalfreedommod.banning.PermBan; -import me.totalfreedom.totalfreedommod.player.PlayerData; -import me.totalfreedom.totalfreedommod.rank.CustomRank; -import me.totalfreedom.totalfreedommod.sql.adapter.AdminRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.BanRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.PermbanRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.PlayerRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.ProtectedAreaRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.MigrationRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.RankRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.SavedFlagRepository; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FUtil; -import org.bukkit.configuration.ConfigurationSection; -import org.bukkit.configuration.file.YamlConfiguration; - import java.io.File; import java.sql.SQLException; import java.util.List; @@ -29,9 +8,24 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Stream; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; + import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; +import me.totalfreedom.totalfreedommod.ProtectArea.ProtectedRegion; +import me.totalfreedom.totalfreedommod.ProtectArea.ProtectedRegion.CantFindWorldException; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.admin.Admin; +import me.totalfreedom.totalfreedommod.banning.Ban; +import me.totalfreedom.totalfreedommod.banning.PermBan; +import me.totalfreedom.totalfreedommod.player.PlayerData; +import me.totalfreedom.totalfreedommod.rank.CustomRank; +import me.totalfreedom.totalfreedommod.sql.adapter.*; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.FUtil; + /** * Service for migrating data from YAML files to SQL database. * Handles one-time migration of admins.yml, bans.yml, and permbans.yml. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/AdminRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/AdminRepository.java index 1d8f6ba4f..5c690efaf 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/AdminRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/AdminRepository.java @@ -1,7 +1,5 @@ package me.totalfreedom.totalfreedommod.sql.adapter; -import me.totalfreedom.totalfreedommod.admin.Admin; - import java.sql.SQLException; import java.util.Date; import java.util.List; @@ -10,6 +8,8 @@ import reactor.core.publisher.Mono; +import me.totalfreedom.totalfreedommod.admin.Admin; + /** * Abstract repository interface for Admin data. * Each database type implements this with database-specific queries. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/BanRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/BanRepository.java index aead02b84..ca9ca3799 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/BanRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/BanRepository.java @@ -1,7 +1,5 @@ package me.totalfreedom.totalfreedommod.sql.adapter; -import me.totalfreedom.totalfreedommod.banning.Ban; - import java.sql.SQLException; import java.util.Date; import java.util.List; @@ -9,6 +7,8 @@ import reactor.core.publisher.Mono; +import me.totalfreedom.totalfreedommod.banning.Ban; + /** * Repository interface for Ban data. * Each database type implements this with database-specific queries. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DatabaseAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DatabaseAdapter.java index 9dd98fbe8..4245372ed 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DatabaseAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DatabaseAdapter.java @@ -1,11 +1,11 @@ package me.totalfreedom.totalfreedommod.sql.adapter; +import java.sql.SQLException; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.sql.ConnectionHandler; import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import java.sql.SQLException; - /** * Abstract base class for database adapters. * Each database type (SQLite, MySQL, PostgreSQL, etc.) has its own implementation diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/PermbanRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/PermbanRepository.java index 1bacb3ce5..ef019631d 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/PermbanRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/PermbanRepository.java @@ -1,13 +1,13 @@ package me.totalfreedom.totalfreedommod.sql.adapter; -import me.totalfreedom.totalfreedommod.banning.PermBan; - import java.sql.SQLException; import java.util.List; import java.util.UUID; import reactor.core.publisher.Mono; +import me.totalfreedom.totalfreedommod.banning.PermBan; + /** * Repository interface for Permban data. * Each database type implements this with database-specific queries. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/PlayerRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/PlayerRepository.java index 78046e996..d5ccde3f8 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/PlayerRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/PlayerRepository.java @@ -1,7 +1,5 @@ package me.totalfreedom.totalfreedommod.sql.adapter; -import me.totalfreedom.totalfreedommod.player.PlayerData; - import java.sql.SQLException; import java.util.List; import java.util.Map; @@ -9,6 +7,8 @@ import reactor.core.publisher.Mono; +import me.totalfreedom.totalfreedommod.player.PlayerData; + /** * Repository interface for per-player data. * diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/ProtectedAreaRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/ProtectedAreaRepository.java index 98dee6375..65d9c91d0 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/ProtectedAreaRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/ProtectedAreaRepository.java @@ -1,13 +1,13 @@ package me.totalfreedom.totalfreedommod.sql.adapter; -import me.totalfreedom.totalfreedommod.ProtectArea.ProtectedRegion; - import java.sql.SQLException; import java.util.List; import java.util.UUID; import reactor.core.publisher.Mono; +import me.totalfreedom.totalfreedommod.ProtectArea.ProtectedRegion; + /** * Repository interface for ProtectedRegion data. * diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/RankRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/RankRepository.java index 2c4778660..a917323c3 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/RankRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/RankRepository.java @@ -1,13 +1,13 @@ package me.totalfreedom.totalfreedommod.sql.adapter; -import me.totalfreedom.totalfreedommod.rank.CustomRank; - import java.sql.SQLException; import java.util.Map; import java.util.Set; import reactor.core.publisher.Mono; +import me.totalfreedom.totalfreedommod.rank.CustomRank; + /** * Repository interface for CustomRank data. * diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/StrikeRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/StrikeRepository.java index 9e75143ea..038ae9799 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/StrikeRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/StrikeRepository.java @@ -2,9 +2,11 @@ import java.sql.SQLException; import java.util.Map; -import me.totalfreedom.totalfreedommod.banning.StrikeRecord; + import reactor.core.publisher.Mono; +import me.totalfreedom.totalfreedommod.banning.StrikeRecord; + public interface StrikeRepository { Map<String, StrikeRecord> loadAll() throws SQLException; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/TitleRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/TitleRepository.java index 6c67a97a3..c79d35bb6 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/TitleRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/TitleRepository.java @@ -3,9 +3,11 @@ import java.sql.SQLException; import java.util.Map; import java.util.Set; -import me.totalfreedom.totalfreedommod.title.Title; + import reactor.core.publisher.Mono; +import me.totalfreedom.totalfreedommod.title.Title; + /** * Repository interface for {@link Title} data. * <p> diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericAdminRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericAdminRepository.java index 72912bd0f..71a694803 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericAdminRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericAdminRepository.java @@ -1,11 +1,5 @@ package me.totalfreedom.totalfreedommod.sql.adapter.generic; -import me.totalfreedom.totalfreedommod.admin.Admin; -import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.AdminRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; -import me.totalfreedom.totalfreedommod.util.FUtil; - import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; @@ -14,6 +8,12 @@ import reactor.core.publisher.Mono; +import me.totalfreedom.totalfreedommod.admin.Admin; +import me.totalfreedom.totalfreedommod.sql.StatementHandler; +import me.totalfreedom.totalfreedommod.sql.adapter.AdminRepository; +import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; +import me.totalfreedom.totalfreedommod.util.FUtil; + /** * All dialect differences are resolved through the {@link DatabaseAdapter} passed in. */ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericBanRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericBanRepository.java index 30bd0996a..c4dfd412f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericBanRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericBanRepository.java @@ -1,11 +1,5 @@ package me.totalfreedom.totalfreedommod.sql.adapter.generic; -import me.totalfreedom.totalfreedommod.banning.Ban; -import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.BanRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; -import me.totalfreedom.totalfreedommod.util.FUtil; - import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; @@ -14,6 +8,12 @@ import reactor.core.publisher.Mono; +import me.totalfreedom.totalfreedommod.banning.Ban; +import me.totalfreedom.totalfreedommod.sql.StatementHandler; +import me.totalfreedom.totalfreedommod.sql.adapter.BanRepository; +import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; +import me.totalfreedom.totalfreedommod.util.FUtil; + /** * All dialect differences are resolved through the {@link DatabaseAdapter} passed in. */ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericDiscordLinkRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericDiscordLinkRepository.java index a14a732ba..fc231f2b3 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericDiscordLinkRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericDiscordLinkRepository.java @@ -1,9 +1,5 @@ package me.totalfreedom.totalfreedommod.sql.adapter.generic; -import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; -import me.totalfreedom.totalfreedommod.sql.adapter.DiscordLinkRepository; - import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Timestamp; @@ -13,6 +9,10 @@ import reactor.core.publisher.Mono; +import me.totalfreedom.totalfreedommod.sql.StatementHandler; +import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; +import me.totalfreedom.totalfreedommod.sql.adapter.DiscordLinkRepository; + /** * All dialect differences are resolved through the {@link DatabaseAdapter} passed in. */ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericMigrationRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericMigrationRepository.java index 3ffc12e0f..b18d03f4b 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericMigrationRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericMigrationRepository.java @@ -1,9 +1,5 @@ package me.totalfreedom.totalfreedommod.sql.adapter.generic; -import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; -import me.totalfreedom.totalfreedommod.sql.adapter.MigrationRepository; - import java.sql.ResultSet; import java.sql.SQLException; import java.util.LinkedHashSet; @@ -11,6 +7,10 @@ import reactor.core.publisher.Mono; +import me.totalfreedom.totalfreedommod.sql.StatementHandler; +import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; +import me.totalfreedom.totalfreedommod.sql.adapter.MigrationRepository; + /** * All dialect differences are resolved through the {@link DatabaseAdapter} passed in. */ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPermbanRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPermbanRepository.java index 3e09f67da..4683d4064 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPermbanRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPermbanRepository.java @@ -1,10 +1,5 @@ package me.totalfreedom.totalfreedommod.sql.adapter.generic; -import me.totalfreedom.totalfreedommod.banning.PermBan; -import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; -import me.totalfreedom.totalfreedommod.sql.adapter.PermbanRepository; - import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; @@ -13,6 +8,11 @@ import reactor.core.publisher.Mono; +import me.totalfreedom.totalfreedommod.banning.PermBan; +import me.totalfreedom.totalfreedommod.sql.StatementHandler; +import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; +import me.totalfreedom.totalfreedommod.sql.adapter.PermbanRepository; + /** * All dialect differences are resolved through the {@link DatabaseAdapter} passed in. */ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java index 372d7fff6..08a3ee0eb 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java @@ -1,12 +1,5 @@ package me.totalfreedom.totalfreedommod.sql.adapter.generic; -import me.totalfreedom.totalfreedommod.player.SpyMode; -import me.totalfreedom.totalfreedommod.player.PlayerData; -import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; -import me.totalfreedom.totalfreedommod.sql.adapter.PlayerRepository; -import me.totalfreedom.totalfreedommod.util.AdventureUtil; - import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; @@ -15,6 +8,13 @@ import reactor.core.publisher.Mono; +import me.totalfreedom.totalfreedommod.player.PlayerData; +import me.totalfreedom.totalfreedommod.player.SpyMode; +import me.totalfreedom.totalfreedommod.sql.StatementHandler; +import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; +import me.totalfreedom.totalfreedommod.sql.adapter.PlayerRepository; +import me.totalfreedom.totalfreedommod.util.AdventureUtil; + /** * All dialect differences are resolved through the {@link DatabaseAdapter} passed in. */ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericProtectedAreaRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericProtectedAreaRepository.java index a9cb3445a..ee8ecb69f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericProtectedAreaRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericProtectedAreaRepository.java @@ -1,12 +1,5 @@ package me.totalfreedom.totalfreedommod.sql.adapter.generic; -import me.totalfreedom.totalfreedommod.ProtectArea.ProtectedRegion; -import me.totalfreedom.totalfreedommod.ProtectArea.ProtectedRegion.CantFindWorldException; -import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; -import me.totalfreedom.totalfreedommod.sql.adapter.ProtectedAreaRepository; -import me.totalfreedom.totalfreedommod.util.FLog; - import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; @@ -17,6 +10,13 @@ import reactor.core.publisher.Mono; +import me.totalfreedom.totalfreedommod.ProtectArea.ProtectedRegion; +import me.totalfreedom.totalfreedommod.ProtectArea.ProtectedRegion.CantFindWorldException; +import me.totalfreedom.totalfreedommod.sql.StatementHandler; +import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; +import me.totalfreedom.totalfreedommod.sql.adapter.ProtectedAreaRepository; +import me.totalfreedom.totalfreedommod.util.FLog; + /** * All dialect differences are resolved through the {@link DatabaseAdapter} passed in. */ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericRankRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericRankRepository.java index cc88c701e..60c5efc00 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericRankRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericRankRepository.java @@ -1,12 +1,5 @@ package me.totalfreedom.totalfreedommod.sql.adapter.generic; -import me.totalfreedom.totalfreedommod.rank.CustomRank; -import me.totalfreedom.totalfreedommod.rank.RankRole; -import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; -import me.totalfreedom.totalfreedommod.sql.adapter.RankRepository; -import net.kyori.adventure.text.format.NamedTextColor; - import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; @@ -14,8 +7,16 @@ import java.util.*; import java.util.stream.Collectors; +import net.kyori.adventure.text.format.NamedTextColor; + import reactor.core.publisher.Mono; +import me.totalfreedom.totalfreedommod.rank.CustomRank; +import me.totalfreedom.totalfreedommod.rank.RankRole; +import me.totalfreedom.totalfreedommod.sql.StatementHandler; +import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; +import me.totalfreedom.totalfreedommod.sql.adapter.RankRepository; + /** * All dialect differences are resolved through the {@link DatabaseAdapter} passed in. */ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericSavedFlagRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericSavedFlagRepository.java index e2ea80bca..3f22dfcad 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericSavedFlagRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericSavedFlagRepository.java @@ -1,9 +1,5 @@ package me.totalfreedom.totalfreedommod.sql.adapter.generic; -import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; -import me.totalfreedom.totalfreedommod.sql.adapter.SavedFlagRepository; - import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Timestamp; @@ -12,6 +8,10 @@ import reactor.core.publisher.Mono; +import me.totalfreedom.totalfreedommod.sql.StatementHandler; +import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; +import me.totalfreedom.totalfreedommod.sql.adapter.SavedFlagRepository; + /** * All dialect differences are resolved through the {@link DatabaseAdapter} passed in. */ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericStrikeRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericStrikeRepository.java index be6b34419..16e3ab262 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericStrikeRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericStrikeRepository.java @@ -1,10 +1,5 @@ package me.totalfreedom.totalfreedommod.sql.adapter.generic; -import me.totalfreedom.totalfreedommod.banning.StrikeRecord; -import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; -import me.totalfreedom.totalfreedommod.sql.adapter.StrikeRepository; - import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Timestamp; @@ -13,6 +8,11 @@ import reactor.core.publisher.Mono; +import me.totalfreedom.totalfreedommod.banning.StrikeRecord; +import me.totalfreedom.totalfreedommod.sql.StatementHandler; +import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; +import me.totalfreedom.totalfreedommod.sql.adapter.StrikeRepository; + /** * All dialect differences are resolved through the {@link DatabaseAdapter} passed in. */ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericTitleRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericTitleRepository.java index 84fbd84e5..566942717 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericTitleRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericTitleRepository.java @@ -8,12 +8,15 @@ import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; + +import net.kyori.adventure.text.format.NamedTextColor; + +import reactor.core.publisher.Mono; + import me.totalfreedom.totalfreedommod.sql.StatementHandler; import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; import me.totalfreedom.totalfreedommod.sql.adapter.TitleRepository; import me.totalfreedom.totalfreedommod.title.Title; -import net.kyori.adventure.text.format.NamedTextColor; -import reactor.core.publisher.Mono; /** * All dialect differences are resolved through the {@link DatabaseAdapter} passed in. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdapter.java index ff73ed3ca..34ec939eb 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdapter.java @@ -1,5 +1,9 @@ package me.totalfreedom.totalfreedommod.sql.adapter.mysql; +import java.sql.SQLException; +import java.util.stream.Collectors; +import java.util.stream.Stream; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.sql.ConnectionHandler; import me.totalfreedom.totalfreedommod.sql.StatementHandler; @@ -7,10 +11,6 @@ import me.totalfreedom.totalfreedommod.sql.adapter.generic.*; import me.totalfreedom.totalfreedommod.util.FLog; -import java.sql.SQLException; -import java.util.stream.Collectors; -import java.util.stream.Stream; - /** * MySQL-specific database adapter. * Works with both MySQL and MariaDB as they share SQL syntax. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java index 06a7a0f7c..81f157410 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java @@ -1,5 +1,9 @@ package me.totalfreedom.totalfreedommod.sql.adapter.postgresql; +import java.sql.SQLException; +import java.util.stream.Collectors; +import java.util.stream.Stream; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.sql.ConnectionHandler; import me.totalfreedom.totalfreedommod.sql.StatementHandler; @@ -7,10 +11,6 @@ import me.totalfreedom.totalfreedommod.sql.adapter.generic.*; import me.totalfreedom.totalfreedommod.util.FLog; -import java.sql.SQLException; -import java.util.stream.Collectors; -import java.util.stream.Stream; - /** * PostgreSQL-specific database adapter. * Uses PostgreSQL-specific SQL features like: diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java index 260332741..b29c66b1f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java @@ -1,5 +1,10 @@ package me.totalfreedom.totalfreedommod.sql.adapter.sqlite; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.stream.Collectors; +import java.util.stream.Stream; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.sql.ConnectionHandler; import me.totalfreedom.totalfreedommod.sql.StatementHandler; @@ -7,11 +12,6 @@ import me.totalfreedom.totalfreedommod.sql.adapter.generic.*; import me.totalfreedom.totalfreedommod.util.FLog; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.stream.Collectors; -import java.util.stream.Stream; - /** * SQLite-specific database adapter. * Uses SQLite-specific SQL features like: diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ssh/AttributedConsoleSender.java b/src/main/java/me/totalfreedom/totalfreedommod/ssh/AttributedConsoleSender.java index 0fdece568..8f79a22f3 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ssh/AttributedConsoleSender.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ssh/AttributedConsoleSender.java @@ -3,12 +3,6 @@ import java.util.Set; import java.util.UUID; -import net.kyori.adventure.audience.Audience; -import net.kyori.adventure.audience.ForwardingAudience; -import net.kyori.adventure.audience.MessageType; -import net.kyori.adventure.identity.Identity; -import net.kyori.adventure.text.Component; - import org.bukkit.Server; import org.bukkit.command.CommandSender; import org.bukkit.permissions.Permission; @@ -16,6 +10,12 @@ import org.bukkit.permissions.PermissionAttachmentInfo; import org.bukkit.plugin.Plugin; +import net.kyori.adventure.audience.Audience; +import net.kyori.adventure.audience.ForwardingAudience; +import net.kyori.adventure.audience.MessageType; +import net.kyori.adventure.identity.Identity; +import net.kyori.adventure.text.Component; + /** * Wraps a console-class {@link CommandSender} so that remote channels (SSH, Discord) can * attribute output to the identity that issued the command, while every other call is diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshCommandCompleter.java b/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshCommandCompleter.java index e8c7f8e06..2ed952696 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshCommandCompleter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshCommandCompleter.java @@ -1,11 +1,14 @@ package me.totalfreedom.totalfreedommod.ssh; import java.util.List; -import java.util.concurrent.ExecutionException; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; + +import org.bukkit.Bukkit; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.util.FLog; -import org.bukkit.Bukkit; + import org.jline.reader.Candidate; import org.jline.reader.Completer; import org.jline.reader.LineReader; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshConsoleCommandFactory.java b/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshConsoleCommandFactory.java index f74acfde0..ddd769e9a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshConsoleCommandFactory.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshConsoleCommandFactory.java @@ -1,11 +1,19 @@ package me.totalfreedom.totalfreedommod.ssh; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; + +import org.bukkit.Bukkit; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchContext; import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchSession; import me.totalfreedom.totalfreedommod.util.CallbackLogAppender; import me.totalfreedom.totalfreedommod.util.FLog; + import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.core.Logger; import org.apache.sshd.server.Environment; @@ -13,16 +21,6 @@ import org.apache.sshd.server.channel.ChannelSession; import org.apache.sshd.server.command.Command; import org.apache.sshd.server.command.CommandFactory; -import org.bukkit.Bukkit; - -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.io.OutputStreamWriter; -import java.io.PrintWriter; -import java.nio.charset.StandardCharsets; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Executor; public class SshConsoleCommandFactory implements CommandFactory { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshConsoleShellFactory.java b/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshConsoleShellFactory.java index d00987520..526dc080b 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshConsoleShellFactory.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshConsoleShellFactory.java @@ -1,37 +1,39 @@ package me.totalfreedom.totalfreedommod.ssh; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; + +import org.bukkit.Bukkit; + +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchContext; import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchSession; import me.totalfreedom.totalfreedommod.util.CallbackLogAppender; import me.totalfreedom.totalfreedommod.util.FLog; -import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import org.apache.commons.lang3.exception.ExceptionUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.core.Logger; import org.apache.sshd.server.Environment; import org.apache.sshd.server.ExitCallback; -import org.apache.sshd.server.session.ServerSession; import org.apache.sshd.server.channel.ChannelSession; import org.apache.sshd.server.command.Command; +import org.apache.sshd.server.session.ServerSession; import org.apache.sshd.server.shell.ShellFactory; -import org.bukkit.Bukkit; +import org.jline.reader.EndOfFileException; import org.jline.reader.LineReader; import org.jline.reader.LineReaderBuilder; import org.jline.reader.UserInterruptException; -import org.jline.reader.EndOfFileException; import org.jline.terminal.Terminal; import org.jline.terminal.impl.ExternalTerminal; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.nio.charset.StandardCharsets; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Executor; - /** * Creates interactive console shell sessions for SSH clients. * Each session gets its own JLine LineReader with tab completion and log streaming. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshDaemon.java b/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshDaemon.java index ebfe6f054..7ae0586ce 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshDaemon.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshDaemon.java @@ -3,10 +3,12 @@ import java.io.File; import java.io.IOException; import java.nio.file.Path; + import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.util.FLog; + import org.apache.sshd.common.AttributeRepository; import org.apache.sshd.server.SshServer; import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshHandshakeAuthenticator.java b/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshHandshakeAuthenticator.java index e76187da5..358db5720 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshHandshakeAuthenticator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshHandshakeAuthenticator.java @@ -1,12 +1,13 @@ package me.totalfreedom.totalfreedommod.ssh; +import java.util.List; + import me.totalfreedom.totalfreedommod.util.FLog; + import org.apache.sshd.server.auth.keyboard.InteractiveChallenge; import org.apache.sshd.server.auth.keyboard.KeyboardInteractiveAuthenticator; import org.apache.sshd.server.session.ServerSession; -import java.util.List; - public class SshHandshakeAuthenticator implements KeyboardInteractiveAuthenticator { private final SshIdentityStore identityStore; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshIdentityStore.java b/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshIdentityStore.java index dbf2c3771..74d045c37 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshIdentityStore.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshIdentityStore.java @@ -1,11 +1,5 @@ package me.totalfreedom.totalfreedommod.ssh; -import com.google.gson.Gson; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.JsonUtil; - import java.io.File; import java.io.FileReader; import java.io.FileWriter; @@ -18,6 +12,13 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.JsonUtil; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + public class SshIdentityStore { private static final DateTimeFormatter LOGIN_FMT = DateTimeFormatter.ofPattern("dd-MM-yy HH:mm"); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshPasswordAuthenticator.java b/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshPasswordAuthenticator.java index 7202f49df..125662ac1 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshPasswordAuthenticator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshPasswordAuthenticator.java @@ -2,8 +2,10 @@ import java.util.HashMap; import java.util.Map; + import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.util.FLog; + import org.apache.sshd.server.auth.password.PasswordAuthenticator; import org.apache.sshd.server.session.ServerSession; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshPublicKeyAuthenticator.java b/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshPublicKeyAuthenticator.java index 067a6eae9..04ce6e41c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshPublicKeyAuthenticator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshPublicKeyAuthenticator.java @@ -1,14 +1,15 @@ package me.totalfreedom.totalfreedommod.ssh; +import java.security.PublicKey; + import me.totalfreedom.totalfreedommod.util.FLog; + import org.apache.sshd.common.config.keys.AuthorizedKeyEntry; import org.apache.sshd.common.config.keys.KeyUtils; import org.apache.sshd.common.config.keys.PublicKeyEntryResolver; import org.apache.sshd.server.auth.pubkey.PublickeyAuthenticator; import org.apache.sshd.server.session.ServerSession; -import java.security.PublicKey; - public class SshPublicKeyAuthenticator implements PublickeyAuthenticator { private final SshIdentityStore identityStore; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshQrServer.java b/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshQrServer.java index e6063901e..b82b3beda 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshQrServer.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshQrServer.java @@ -1,10 +1,5 @@ package me.totalfreedom.totalfreedommod.ssh; -import com.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpServer; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.FLog; - import java.io.IOException; import java.io.InputStream; import java.net.InetSocketAddress; @@ -18,6 +13,12 @@ import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.FLog; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; + /** * A single, shared HTTP daemon that serves one-time TOTP QR setup pages. * Requests are held in a bounded FIFO queue keyed by token; a request is popped and diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ssh/TotpUtil.java b/src/main/java/me/totalfreedom/totalfreedommod/ssh/TotpUtil.java index ddaabb800..2e8361918 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ssh/TotpUtil.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ssh/TotpUtil.java @@ -1,12 +1,12 @@ package me.totalfreedom.totalfreedommod.ssh; -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; import java.net.URLEncoder; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.security.SecureRandom; import java.util.Arrays; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; public final class TotpUtil { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/tablist/TabList.java b/src/main/java/me/totalfreedom/totalfreedommod/tablist/TabList.java index 31ae92722..17843a396 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/tablist/TabList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/tablist/TabList.java @@ -1,5 +1,14 @@ package me.totalfreedom.totalfreedommod.tablist; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.scheduler.BukkitTask; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.TextComponent; + import me.totalfreedom.totalfreedommod.ChatManager; import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; @@ -8,13 +17,6 @@ import me.totalfreedom.totalfreedommod.util.AdventureUtil; import me.totalfreedom.totalfreedommod.util.FTask; import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.TextComponent; -import org.bukkit.entity.Player; -import org.bukkit.event.EventHandler; -import org.bukkit.event.EventPriority; -import org.bukkit.event.player.PlayerJoinEvent; -import org.bukkit.scheduler.BukkitTask; public class TabList extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/title/Title.java b/src/main/java/me/totalfreedom/totalfreedommod/title/Title.java index 9efc4b708..59bd5ea48 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/title/Title.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/title/Title.java @@ -4,12 +4,14 @@ import java.util.HashSet; import java.util.Set; import java.util.stream.Collectors; -import me.totalfreedom.totalfreedommod.display.Displayable; -import me.totalfreedom.totalfreedommod.util.AdventureUtil; + import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.TextDecoration; +import me.totalfreedom.totalfreedommod.display.Displayable; +import me.totalfreedom.totalfreedommod.util.AdventureUtil; + /** * A recognition a player holds alongside their rank, carrying its own display and its own narrow * set of capabilities. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/title/TitleManager.java b/src/main/java/me/totalfreedom/totalfreedommod/title/TitleManager.java index 542c3d062..35caa946e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/title/TitleManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/title/TitleManager.java @@ -1,7 +1,5 @@ package me.totalfreedom.totalfreedommod.title; -import com.google.common.collect.Maps; -import com.google.gson.reflect.TypeToken; import java.io.File; import java.io.FileReader; import java.io.FileWriter; @@ -16,6 +14,14 @@ import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; + +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.display.Displayable; @@ -25,11 +31,9 @@ import me.totalfreedom.totalfreedommod.util.AdventureUtil; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.JsonUtil; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; -import reactor.core.scheduler.Schedulers; + +import com.google.common.collect.Maps; +import com.google.gson.reflect.TypeToken; /** * Owns the title registry and answers what a player's titles let them do and how they are shown. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/util/AdventureUtil.java b/src/main/java/me/totalfreedom/totalfreedommod/util/AdventureUtil.java index 3f29203ec..154b5ab0f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/util/AdventureUtil.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/util/AdventureUtil.java @@ -1,6 +1,7 @@ package me.totalfreedom.totalfreedommod.util; import java.util.regex.Pattern; + import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.event.HoverEvent; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/util/CallbackLogAppender.java b/src/main/java/me/totalfreedom/totalfreedommod/util/CallbackLogAppender.java index 6cb8251be..d20ab4a27 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/util/CallbackLogAppender.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/util/CallbackLogAppender.java @@ -5,6 +5,7 @@ import java.util.Objects; import java.util.Optional; import java.util.stream.Stream; + import org.apache.logging.log4j.Level; import org.apache.logging.log4j.core.LogEvent; import org.apache.logging.log4j.core.appender.AbstractAppender; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/util/ChatMentionUtil.java b/src/main/java/me/totalfreedom/totalfreedommod/util/ChatMentionUtil.java index 334141799..2d75c5404 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/util/ChatMentionUtil.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/util/ChatMentionUtil.java @@ -1,23 +1,18 @@ package me.totalfreedom.totalfreedommod.util; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Set; -import java.util.UUID; +import java.util.*; import java.util.regex.Pattern; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import net.kyori.adventure.text.Component; + import org.bukkit.Bukkit; import org.bukkit.Sound; import org.bukkit.SoundCategory; import org.bukkit.entity.Player; + +import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; + public final class ChatMentionUtil { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/util/ComponentScanner.java b/src/main/java/me/totalfreedom/totalfreedommod/util/ComponentScanner.java index e320f94ec..0b7d46efb 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/util/ComponentScanner.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/util/ComponentScanner.java @@ -3,6 +3,7 @@ import java.util.ArrayDeque; import java.util.Deque; import java.util.IdentityHashMap; + import net.kyori.adventure.text.Component; import net.kyori.adventure.text.TranslatableComponent; import net.kyori.adventure.text.TranslationArgument; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/util/DetectionReporter.java b/src/main/java/me/totalfreedom/totalfreedommod/util/DetectionReporter.java index ab144b684..3d39c8e88 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/util/DetectionReporter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/util/DetectionReporter.java @@ -2,12 +2,15 @@ import java.util.function.Consumer; import java.util.function.LongSupplier; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; + import org.bukkit.Bukkit; import org.bukkit.entity.Player; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.TotalFreedomMod; + public final class DetectionReporter { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/util/FSync.java b/src/main/java/me/totalfreedom/totalfreedommod/util/FSync.java index e7a9c8a39..9d252fe13 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/util/FSync.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/util/FSync.java @@ -1,12 +1,14 @@ package me.totalfreedom.totalfreedommod.util; -import me.totalfreedom.totalfreedommod.PluginProvider; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.PluginProvider; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; + public class FSync { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/util/FUtil.java b/src/main/java/me/totalfreedom/totalfreedommod/util/FUtil.java index b4cffce23..4af9e9da7 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/util/FUtil.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/util/FUtil.java @@ -1,7 +1,5 @@ package me.totalfreedom.totalfreedommod.util; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; import java.io.BufferedReader; import java.io.File; import java.io.InputStreamReader; @@ -25,14 +23,7 @@ import java.util.UUID; import java.util.regex.Matcher; import java.util.regex.Pattern; -import me.totalfreedom.totalfreedommod.PluginProvider; -import me.totalfreedom.totalfreedommod.cmd.MessageUtils; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; -import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; -import net.kyori.adventure.text.serializer.ansi.ANSIComponentSerializer; -import org.apache.commons.io.FileUtils; + import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.OfflinePlayer; @@ -40,6 +31,19 @@ import org.bukkit.entity.Player; import org.bukkit.scheduler.BukkitTask; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; +import net.kyori.adventure.text.serializer.ansi.ANSIComponentSerializer; + +import me.totalfreedom.totalfreedommod.PluginProvider; +import me.totalfreedom.totalfreedommod.cmd.MessageUtils; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.apache.commons.io.FileUtils; + public class FUtil { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/util/JsonUtil.java b/src/main/java/me/totalfreedom/totalfreedommod/util/JsonUtil.java index 70e78cec7..ca69c6338 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/util/JsonUtil.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/util/JsonUtil.java @@ -1,16 +1,12 @@ package me.totalfreedom.totalfreedommod.util; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.google.gson.JsonDeserializer; -import com.google.gson.JsonPrimitive; -import com.google.gson.JsonSerializer; +import java.util.Date; +import java.util.UUID; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; -import java.util.Date; -import java.util.UUID; +import com.google.gson.*; /** * Shared Gson instance for the plugin's JSON-backed persistence (SQL write-through diff --git a/src/main/java/me/totalfreedom/totalfreedommod/util/MaterialHelper.java b/src/main/java/me/totalfreedom/totalfreedommod/util/MaterialHelper.java index 4ce34df35..1da3848c0 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/util/MaterialHelper.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/util/MaterialHelper.java @@ -2,6 +2,7 @@ import java.util.HashMap; import java.util.Map; + import org.bukkit.Material; /** diff --git a/src/main/java/me/totalfreedom/totalfreedommod/vault/ChatService.java b/src/main/java/me/totalfreedom/totalfreedommod/vault/ChatService.java index 44875029d..36e808f0c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/vault/ChatService.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/vault/ChatService.java @@ -1,12 +1,14 @@ package me.totalfreedom.totalfreedommod.vault; -import me.totalfreedom.totalfreedommod.ChatManager; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import net.milkbowl.vault.chat.Chat; -import net.milkbowl.vault.permission.Permission; import org.bukkit.OfflinePlayer; import org.bukkit.entity.Player; +import net.milkbowl.vault.chat.Chat; +import net.milkbowl.vault.permission.Permission; + +import me.totalfreedom.totalfreedommod.ChatManager; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; + /** * Vault Chat Provider implementation for TotalFreedomMod. * Provides prefixes to other plugins via the Vault Chat API. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/vault/PermissionService.java b/src/main/java/me/totalfreedom/totalfreedommod/vault/PermissionService.java index 22c17f0e7..1e84d7fa1 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/vault/PermissionService.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/vault/PermissionService.java @@ -1,11 +1,13 @@ package me.totalfreedom.totalfreedommod.vault; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.rank.CustomRank; -import net.milkbowl.vault.permission.Permission; import org.bukkit.OfflinePlayer; import org.bukkit.entity.Player; +import net.milkbowl.vault.permission.Permission; + +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.rank.CustomRank; + public class PermissionService extends Permission { private final TotalFreedomMod plugin; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/vault/VaultProviderRegistry.java b/src/main/java/me/totalfreedom/totalfreedommod/vault/VaultProviderRegistry.java index 1847c7329..d7218d173 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/vault/VaultProviderRegistry.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/vault/VaultProviderRegistry.java @@ -1,11 +1,12 @@ package me.totalfreedom.totalfreedommod.vault; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.util.FLog; import org.bukkit.plugin.Plugin; import org.bukkit.plugin.RegisteredServiceProvider; import org.bukkit.plugin.ServicePriority; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.util.FLog; + public final class VaultProviderRegistry { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/AdminWorld.java b/src/main/java/me/totalfreedom/totalfreedommod/world/AdminWorld.java index 70bccaf64..d5e5f68cb 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/AdminWorld.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/AdminWorld.java @@ -1,14 +1,8 @@ package me.totalfreedom.totalfreedommod.world; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.Iterator; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.Map.Entry; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.FLog; + import org.bukkit.Bukkit; import org.bukkit.World; import org.bukkit.WorldCreator; @@ -17,6 +11,10 @@ import org.bukkit.entity.Player; import org.bukkit.event.player.PlayerMoveEvent; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.FLog; + public final class AdminWorld extends CustomWorld { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/CleanroomChunkGenerator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/CleanroomChunkGenerator.java index ec15e0fc0..ae90be088 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/CleanroomChunkGenerator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/CleanroomChunkGenerator.java @@ -19,6 +19,7 @@ import java.util.Random; import java.util.logging.Logger; + import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.Material; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/CustomWorld.java b/src/main/java/me/totalfreedom/totalfreedommod/world/CustomWorld.java index 7e158cd67..adaaff3cc 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/CustomWorld.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/CustomWorld.java @@ -1,11 +1,5 @@ package me.totalfreedom.totalfreedommod.world; -import lombok.Getter; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.framework.PluginComponent; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Bukkit; import org.bukkit.Material; import org.bukkit.World; @@ -13,6 +7,15 @@ import org.bukkit.block.BlockFace; import org.bukkit.entity.Player; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.framework.PluginComponent; +import me.totalfreedom.totalfreedommod.util.FLog; + +import lombok.Getter; + public abstract class CustomWorld extends PluginComponent<TotalFreedomMod> { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/Flatlands.java b/src/main/java/me/totalfreedom/totalfreedommod/world/Flatlands.java index 3141b94e0..2c3e9b097 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/Flatlands.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/Flatlands.java @@ -1,19 +1,19 @@ package me.totalfreedom.totalfreedommod.world; import java.io.File; + +import org.bukkit.*; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.util.FLog; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; + import org.apache.commons.io.FileUtils; -import org.bukkit.Bukkit; -import org.bukkit.Material; -import org.bukkit.World; -import org.bukkit.WorldCreator; -import org.bukkit.WorldType; -import org.bukkit.block.Block; -import org.bukkit.block.BlockFace; public class Flatlands extends CustomWorld { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/WorldManager.java b/src/main/java/me/totalfreedom/totalfreedommod/world/WorldManager.java index 9ca8ec2f2..cd4287143 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/WorldManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/WorldManager.java @@ -1,11 +1,5 @@ package me.totalfreedom.totalfreedommod.world; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.player.FPlayer; -import static me.totalfreedom.totalfreedommod.util.FUtil.playerMsg; -import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Bukkit; import org.bukkit.World; import org.bukkit.entity.Player; @@ -16,6 +10,15 @@ import org.bukkit.event.weather.ThunderChangeEvent; import org.bukkit.event.weather.WeatherChangeEvent; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.player.FPlayer; + +import static me.totalfreedom.totalfreedommod.util.FUtil.playerMsg; + public class WorldManager extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/WorldTime.java b/src/main/java/me/totalfreedom/totalfreedommod/world/WorldTime.java index aeda737ef..71a3842a9 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/WorldTime.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/WorldTime.java @@ -2,6 +2,7 @@ import java.util.Arrays; import java.util.List; + import org.bukkit.World; public enum WorldTime diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/WorldWeather.java b/src/main/java/me/totalfreedom/totalfreedommod/world/WorldWeather.java index 0bfa3c787..dc450e426 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/WorldWeather.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/WorldWeather.java @@ -2,6 +2,7 @@ import java.util.Arrays; import java.util.List; + import org.bukkit.World; public enum WorldWeather From 208a17b310dd5e298aeb7e7e18911e18fffabe83 Mon Sep 17 00:00:00 2001 From: Paldiu <pawereus@gmail.com> Date: Wed, 5 Aug 2026 15:28:15 -0500 Subject: [PATCH 31/48] reconcile with pr --- .../me/totalfreedom/totalfreedommod/cmd/Command_adminchat.java | 1 - .../java/me/totalfreedom/totalfreedommod/cmd/Command_kick.java | 1 - .../me/totalfreedom/totalfreedommod/cmd/Command_rankconfig.java | 2 +- .../java/me/totalfreedom/totalfreedommod/cmd/Command_smite.java | 1 - 4 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adminchat.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adminchat.java index b5b661786..a786cebca 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adminchat.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adminchat.java @@ -3,7 +3,6 @@ import java.util.List; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.player.FPlayer; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_kick.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_kick.java index b8617c19b..5d8d86f84 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_kick.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_kick.java @@ -3,7 +3,6 @@ import java.util.List; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rankconfig.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rankconfig.java index 2b39c30c5..dcb388744 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rankconfig.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rankconfig.java @@ -200,7 +200,7 @@ public List<String> completeSetValue(CommandSender sender, String partial, List< return switch (property) { case COLOR -> FuzzyMatch.filter(COLOR_NAMES, partial); - case ADMIN, CONSOLE -> FuzzyMatch.filter(List.of("true", "false"), partial); + case ADMIN -> FuzzyMatch.filter(List.of("true", "false"), partial); case INHERIT -> FuzzyMatch.filter(inheritCandidates(priorArgs.get(0)), partial); case REMPERM -> FuzzyMatch.filter(heldPermissions(priorArgs.get(0)), partial); default -> List.of(); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_smite.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_smite.java index 854645197..0e1b4134d 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_smite.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_smite.java @@ -7,7 +7,6 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Greedy; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.FUtil; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.GameMode; From 1fe700d53be8f0ee40cf74d3320465898823f266 Mon Sep 17 00:00:00 2001 From: Paldiu <pawereus@gmail.com> Date: Wed, 5 Aug 2026 15:29:54 -0500 Subject: [PATCH 32/48] literally git is such a fucking ballsack --- .../totalfreedommod/cmd/Command_adminchat.java | 8 +++++--- .../totalfreedommod/cmd/Command_gchat.java | 4 +++- .../totalfreedommod/cmd/Command_gcmd.java | 4 +++- .../totalfreedommod/cmd/Command_kick.java | 8 +++++--- .../totalfreedommod/cmd/Command_realname.java | 4 +++- .../totalfreedommod/cmd/Command_report.java | 3 ++- .../totalfreedommod/cmd/Command_smite.java | 16 +++++++++------- .../totalfreedommod/cmd/Command_warn.java | 4 +++- .../totalfreedommod/cmd/NameCandidates.java | 9 +++++---- .../cmd/internal/CommandProcessor.java | 2 +- 10 files changed, 39 insertions(+), 23 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adminchat.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adminchat.java index a786cebca..fc3511fcb 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adminchat.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adminchat.java @@ -1,12 +1,14 @@ package me.totalfreedom.totalfreedommod.cmd; import java.util.List; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.player.FPlayer; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; + import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; + +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; +import me.totalfreedom.totalfreedommod.player.FPlayer; @Command(name = "adminchat", diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gchat.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gchat.java index f78cb8c41..d9abd6ed8 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gchat.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gchat.java @@ -1,9 +1,11 @@ package me.totalfreedom.totalfreedommod.cmd; +import java.util.List; + import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; + import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; -import java.util.List; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gcmd.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gcmd.java index 73fa0326c..3d9852672 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gcmd.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gcmd.java @@ -1,9 +1,11 @@ package me.totalfreedom.totalfreedommod.cmd; +import java.util.List; + import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; + import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; -import java.util.List; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_kick.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_kick.java index 5d8d86f84..2e238d7c1 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_kick.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_kick.java @@ -1,12 +1,14 @@ package me.totalfreedom.totalfreedommod.cmd; import java.util.List; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; @Permission(permission = "tfm.admin.kick") diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_realname.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_realname.java index 60349aff9..ad8242cd0 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_realname.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_realname.java @@ -1,8 +1,10 @@ package me.totalfreedom.totalfreedommod.cmd; +import java.util.List; + import org.bukkit.command.CommandSender; + import net.kyori.adventure.text.Component; -import java.util.List; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_report.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_report.java index bd155ae50..5aca7da52 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_report.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_report.java @@ -1,8 +1,9 @@ package me.totalfreedom.totalfreedommod.cmd; +import java.util.List; + import org.bukkit.OfflinePlayer; import org.bukkit.entity.Player; -import java.util.List; import me.totalfreedom.totalfreedommod.admin.Admin; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_smite.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_smite.java index 0e1b4134d..92ef70e86 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_smite.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_smite.java @@ -1,6 +1,15 @@ package me.totalfreedom.totalfreedommod.cmd; import java.util.List; + +import org.bukkit.GameMode; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Completer; @@ -8,13 +17,6 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; -import org.bukkit.GameMode; -import org.bukkit.Location; -import org.bukkit.World; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; - @Command(name = "smite", description = "Someone being a little bitch? Smite them down...", usage = "/smite <player> [reason]") diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_warn.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_warn.java index b91fae9f5..567e77bf8 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_warn.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_warn.java @@ -1,9 +1,11 @@ package me.totalfreedom.totalfreedommod.cmd; +import java.util.List; + import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; + import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; -import java.util.List; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/NameCandidates.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/NameCandidates.java index 532855498..787af1575 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/NameCandidates.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/NameCandidates.java @@ -2,15 +2,16 @@ import java.util.List; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.banning.Ban; -import me.totalfreedom.totalfreedommod.cmd.internal.FuzzyMatch; -import me.totalfreedom.totalfreedommod.util.AdventureUtil; import org.bukkit.Bukkit; import org.bukkit.OfflinePlayer; import org.bukkit.Server; import org.bukkit.entity.Player; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.banning.Ban; +import me.totalfreedom.totalfreedommod.cmd.internal.FuzzyMatch; +import me.totalfreedom.totalfreedommod.util.AdventureUtil; + /** * Shared tab-completion sources for commands that take a player name as a plain {@code String}. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/CommandProcessor.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/CommandProcessor.java index d710a1233..43a145c6c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/CommandProcessor.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/CommandProcessor.java @@ -22,8 +22,8 @@ import com.mojang.brigadier.context.CommandContext; import com.mojang.brigadier.suggestion.SuggestionProvider; import com.mojang.brigadier.suggestion.SuggestionsBuilder; -import io.papermc.paper.command.brigadier.Commands; import io.papermc.paper.command.brigadier.CommandSourceStack; +import io.papermc.paper.command.brigadier.Commands; import io.papermc.paper.command.brigadier.argument.ArgumentTypes; import io.papermc.paper.command.brigadier.argument.resolvers.selector.PlayerSelectorArgumentResolver; import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents; From dcb2198ada2f670953ef77df79a85f1e31fe4326 Mon Sep 17 00:00:00 2001 From: shrimp <luke560@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:21:48 -0600 Subject: [PATCH 33/48] Persist /jlm toggle and restore its non-op permission --- .../generic/GenericPlayerRepository.java | 17 +++++++++++------ .../sql/adapter/mysql/MySQLAdapter.java | 2 ++ .../adapter/postgresql/PostgreSQLAdapter.java | 2 ++ .../sql/adapter/sqlite/SQLiteAdapter.java | 2 ++ src/main/resources/ranks.json | 1 + 5 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java index 08a3ee0eb..42b87d736 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java @@ -35,6 +35,7 @@ public class GenericPlayerRepository implements PlayerRepository private final String colMuted; private final String colFrozen; private final String colCommandsBlocked; + private final String colJoinLeaveMessages; private final String colStrikes; private final String colSavedTag; private final String colTitles; @@ -62,6 +63,7 @@ public GenericPlayerRepository(StatementHandler statementHandler, DatabaseAdapte this.colMuted = adapter.quoteIdentifier("muted"); this.colFrozen = adapter.quoteIdentifier("frozen"); this.colCommandsBlocked = adapter.quoteIdentifier("commands_blocked"); + this.colJoinLeaveMessages = adapter.quoteIdentifier("join_leave_messages"); this.colStrikes = adapter.quoteIdentifier("strikes"); this.colSavedTag = adapter.quoteIdentifier("saved_tag"); this.colTitles = adapter.quoteIdentifier("titles"); @@ -70,16 +72,16 @@ public GenericPlayerRepository(StatementHandler statementHandler, DatabaseAdapte this.colPlayerUsername = adapter.quoteIdentifier("username"); this.colIp = adapter.quoteIdentifier("ip"); this.colUpdatedAt = adapter.quoteIdentifier("updated_at"); - this.selectColumns = String.format("%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s", + this.selectColumns = String.format("%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s", colUsername, colFirstJoin, colLastJoin, colPotionSpyMode, colCommandSpyMode, colSignSpyMode, - colBookSpyMode, colMuted, colFrozen, colCommandsBlocked, colStrikes, colSavedTag, colTitles) - + ", " + colNickname; + colBookSpyMode, colMuted, colFrozen, colCommandsBlocked, colJoinLeaveMessages, colStrikes, + colSavedTag, colTitles) + ", " + colNickname; } @Override public void insert(PlayerData data) throws SQLException { - String sql = String.format("INSERT INTO %s (%s, %s) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, %s)", + String sql = String.format("INSERT INTO %s (%s, %s) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, %s)", tblPlayers, selectColumns, colUpdatedAt, adapter.currentTimestamp()); statementHandler.executeUpdate(sql, @@ -93,6 +95,7 @@ public void insert(PlayerData data) throws SQLException data.isMuted(), data.isFrozen(), data.isCommandsBlocked(), + data.isJoinLeaveMessagesEnabled(), data.getStrikes(), data.getSavedTag(), serializeTitles(data), @@ -206,9 +209,9 @@ public List<String> getIps(String username) throws SQLException @Override public boolean update(PlayerData data) throws SQLException { - String sql = String.format("UPDATE %s SET %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = %s WHERE %s = ?", + String sql = String.format("UPDATE %s SET %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = %s WHERE %s = ?", tblPlayers, colFirstJoin, colLastJoin, colPotionSpyMode, colCommandSpyMode, colSignSpyMode, - colBookSpyMode, colMuted, colFrozen, colCommandsBlocked, colStrikes, colSavedTag, colTitles, + colBookSpyMode, colMuted, colFrozen, colCommandsBlocked, colJoinLeaveMessages, colStrikes, colSavedTag, colTitles, colUpdatedAt, adapter.currentTimestamp(), colUsername); int rows = statementHandler.executeUpdate(sql, @@ -221,6 +224,7 @@ public boolean update(PlayerData data) throws SQLException data.isMuted(), data.isFrozen(), data.isCommandsBlocked(), + data.isJoinLeaveMessagesEnabled(), data.getStrikes(), data.getSavedTag(), serializeTitles(data), @@ -320,6 +324,7 @@ private PlayerData loadPlayerFromRow(ResultSet rs) throws SQLException data.setMuted(rs.getBoolean("muted")); data.setFrozen(rs.getBoolean("frozen")); data.setCommandsBlocked(rs.getBoolean("commands_blocked")); + data.setJoinLeaveMessagesEnabled(rs.getBoolean("join_leave_messages")); data.setStrikes(rs.getInt("strikes")); data.setSavedTag(rs.getString("saved_tag")); data.setTitles(parseTitles(rs.getString("titles"))); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdapter.java index 34ec939eb..b52084b11 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdapter.java @@ -435,6 +435,7 @@ private void createPlayersTable() throws SQLException `muted` TINYINT(1) NOT NULL DEFAULT 0, `frozen` TINYINT(1) NOT NULL DEFAULT 0, `commands_blocked` TINYINT(1) NOT NULL DEFAULT 0, + `join_leave_messages` TINYINT(1) NOT NULL DEFAULT 1, `strikes` INT NOT NULL DEFAULT 0, `saved_tag` TEXT, `nickname` TEXT, @@ -447,6 +448,7 @@ private void createPlayersTable() throws SQLException addColumnIfMissing("players", "potion_spy_mode", "VARCHAR(16) NOT NULL DEFAULT 'off'"); addColumnIfMissing("players", "sign_spy_mode", "VARCHAR(16) NOT NULL DEFAULT 'off'"); addColumnIfMissing("players", "book_spy_mode", "VARCHAR(16) NOT NULL DEFAULT 'off'"); + addColumnIfMissing("players", "join_leave_messages", "TINYINT(1) NOT NULL DEFAULT 1"); } private void createPlayerIpsTable() throws SQLException diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java index 81f157410..33d038efc 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java @@ -434,6 +434,7 @@ private void createPlayersTable() throws SQLException "muted" BOOLEAN NOT NULL DEFAULT FALSE, "frozen" BOOLEAN NOT NULL DEFAULT FALSE, "commands_blocked" BOOLEAN NOT NULL DEFAULT FALSE, + "join_leave_messages" BOOLEAN NOT NULL DEFAULT TRUE, "strikes" INTEGER NOT NULL DEFAULT 0, "saved_tag" TEXT, "nickname" TEXT, @@ -446,6 +447,7 @@ private void createPlayersTable() throws SQLException statementHandler.executeUpdate("ALTER TABLE \"players\" ADD COLUMN IF NOT EXISTS \"potion_spy_mode\" VARCHAR(16) NOT NULL DEFAULT 'off'"); statementHandler.executeUpdate("ALTER TABLE \"players\" ADD COLUMN IF NOT EXISTS \"sign_spy_mode\" VARCHAR(16) NOT NULL DEFAULT 'off'"); statementHandler.executeUpdate("ALTER TABLE \"players\" ADD COLUMN IF NOT EXISTS \"book_spy_mode\" VARCHAR(16) NOT NULL DEFAULT 'off'"); + statementHandler.executeUpdate("ALTER TABLE \"players\" ADD COLUMN IF NOT EXISTS \"join_leave_messages\" BOOLEAN NOT NULL DEFAULT TRUE"); } private void createPlayerIpsTable() throws SQLException diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java index b29c66b1f..3eecb8d64 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java @@ -448,6 +448,7 @@ CREATE TABLE IF NOT EXISTS players ( muted INTEGER NOT NULL DEFAULT 0, frozen INTEGER NOT NULL DEFAULT 0, commands_blocked INTEGER NOT NULL DEFAULT 0, + join_leave_messages INTEGER NOT NULL DEFAULT 1, strikes INTEGER NOT NULL DEFAULT 0, saved_tag TEXT, nickname TEXT, @@ -460,6 +461,7 @@ CREATE TABLE IF NOT EXISTS players ( addColumnIfMissing("players", "potion_spy_mode", "TEXT NOT NULL DEFAULT 'off'"); addColumnIfMissing("players", "sign_spy_mode", "TEXT NOT NULL DEFAULT 'off'"); addColumnIfMissing("players", "book_spy_mode", "TEXT NOT NULL DEFAULT 'off'"); + addColumnIfMissing("players", "join_leave_messages", "INTEGER NOT NULL DEFAULT 1"); } private void createPlayerIpsTable() throws SQLException diff --git a/src/main/resources/ranks.json b/src/main/resources/ranks.json index 4cac4c18b..91050b21d 100644 --- a/src/main/resources/ranks.json +++ b/src/main/resources/ranks.json @@ -31,6 +31,7 @@ "tfm.player.rank", "tfm.player.spawn", "tfm.player.list", + "tfm.player.joinmessages", "tfm.world.flatlands", "tfm.server.info" ] From 02e8ee6e6d83312c71dbbb8415fb2022cb82e2ac Mon Sep 17 00:00:00 2001 From: shrimp <luke560@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:40:15 -0600 Subject: [PATCH 34/48] Fix json overwriting sql state on every startup --- .../totalfreedommod/admin/AdminList.java | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java b/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java index 578af6eee..bcf33436b 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java @@ -32,6 +32,8 @@ import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.rank.CustomRank; +import me.totalfreedom.totalfreedommod.rank.RankRole; import me.totalfreedom.totalfreedommod.sql.PersistenceQueue; import me.totalfreedom.totalfreedommod.sql.adapter.AdminRepository; import me.totalfreedom.totalfreedommod.util.FLog; @@ -697,17 +699,17 @@ private void reconcileFromJsonIfNewer(final AdminRepository repo) }) .subscribeOn(Schedulers.boundedElastic()) .filter(Boolean::booleanValue) - .flatMapMany(ignored -> + .flatMap(ignored -> { FLog.info(String.format("%s is newer than the database; rebuilding it from the file's %d admin(s).", - CONFIG_FILENAME, jsonAdmins.size())); + CONFIG_FILENAME, jsonAdmins.size())); return repo.deleteAll() .thenMany(Flux.fromIterable(jsonAdmins.values()) .filter(Admin::isValid) - .concatMap(admin -> repo.save(resolveUuidFor(admin), admin))); + .concatMap(admin -> repo.save(resolveUuidFor(admin), copyAdmin(admin)))) + .then(Mono.<Void>fromRunnable(() -> plugin.dm.sync("AdminList/applyReconciled", () -> + applyReconciledAdmins(jsonAdmins)))); }) - .then(Mono.fromRunnable(() -> plugin.dm.sync("AdminList/applyReconciled", - () -> applyReconciledAdmins(jsonAdmins)))) .onErrorResume(ex -> { FLog.warning(String.format("Failed to reconcile %s into the database: %s", @@ -952,7 +954,7 @@ private Admin copyAdmin(Admin admin) Admin copy = new Admin(admin.getConfigKey()); copy.setUuid(admin.getUuid()); copy.setName(admin.getName()); - copy.setRankId(admin.getRankId()); + copy.setRankId(effectiveRankId(admin)); copy.setActive(admin.isActive()); copy.setLastLogin(admin.getLastLogin() == null ? null : new Date(admin.getLastLogin().getTime())); copy.setLoginMessage(admin.getLoginMessage()); @@ -960,6 +962,21 @@ private Admin copyAdmin(Admin admin) return copy; } + /** + * The rank id to store for an admin. An unset id means "whatever fills the default admin role", + * which a NOT NULL column cannot express, so the role is resolved to a concrete id at write time. + */ + private String effectiveRankId(final Admin admin) + { + if (admin.getRankId() != null) + return admin.getRankId(); + + return plugin.rm.getRegistry() + .byRole(RankRole.ADMIN_DEFAULT) + .map(CustomRank::getId) + .orElse(null); + } + /** * Resolve the UUID for a queued write. The lookup runs on the persistence * chain rather than the caller, because {@link FUtil#usernameToUuid} can make From f21ad522d08934636b90efd94175c32b04a06601 Mon Sep 17 00:00:00 2001 From: shrimp <luke560@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:18:02 -0600 Subject: [PATCH 35/48] Load ranks from SQL and fix role deserialisation --- .../totalfreedommod/ProtectArea.java | 23 ++++++++++--------- .../totalfreedommod/SavedFlags.java | 23 ++++++++++--------- .../totalfreedommod/admin/AdminList.java | 4 ++-- .../totalfreedommod/banning/BanManager.java | 10 ++++---- .../totalfreedommod/banning/PermbanList.java | 8 +++---- .../totalfreedommod/banning/StrikeList.java | 17 +++++++------- .../totalfreedommod/rank/RankManager.java | 16 ++++++------- 7 files changed, 52 insertions(+), 49 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java b/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java index 170b71312..ba150d538 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java @@ -199,19 +199,20 @@ private void reconcileFromJsonIfNewer(final ProtectedAreaRepository repo) }) .subscribeOn(Schedulers.boundedElastic()) .filter(Boolean::booleanValue) - .flatMapMany(ignored -> + .flatMap(ignored -> { - FLog.info(String.format("%s is newer than the database; rebuilding it from the file's %d protected area(s).", - DATA_FILENAME, jsonAreas.size())); - return repo.deleteAll() - .thenMany(Flux.fromIterable(jsonAreas) - .concatMap(repo::save)); + FLog.info(String.format("%s is newer than the database; rebuilding it from the file's %d protected area(s).", + DATA_FILENAME, jsonAreas.size())); + return repo.deleteAll() + .thenMany(Flux.fromIterable(jsonAreas) + .concatMap(repo::save)) + .then(Mono.<Void>fromRunnable(() -> plugin.dm.sync("ProtectArea/applyReconciled", + () -> + { + areas.clear(); + jsonAreas.forEach(region -> areas.put(region.getUuid(), region)); + }))); }) - .then(Mono.fromRunnable(() -> plugin.dm.sync("ProtectArea/applyReconciled", () -> - { - areas.clear(); - jsonAreas.forEach(region -> areas.put(region.getUuid(), region)); - }))) .onErrorResume(ex -> { FLog.warning(String.format("Failed to reconcile %s into the database: %s", diff --git a/src/main/java/me/totalfreedom/totalfreedommod/SavedFlags.java b/src/main/java/me/totalfreedom/totalfreedommod/SavedFlags.java index a1a949bb1..191901cfe 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/SavedFlags.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/SavedFlags.java @@ -177,19 +177,20 @@ private void reconcileFromJsonIfNewer(final SavedFlagRepository repo) }) .subscribeOn(Schedulers.boundedElastic()) .filter(Boolean::booleanValue) - .flatMapMany(ignored -> + .flatMap(ignored -> { - FLog.info(String.format("%s is newer than the database; rebuilding it from the file's %d flag(s).", - DATA_FILENAME, jsonFlags.size())); - return repo.deleteAll() - .thenMany(Flux.fromIterable(jsonFlags.entrySet()) - .concatMap(entry -> repo.upsertAsync(entry.getKey(), entry.getValue()))); + FLog.info(String.format("%s is newer than the database; rebuilding it from the file's %d flag(s).", + DATA_FILENAME, jsonFlags.size())); + return repo.deleteAll() + .thenMany(Flux.fromIterable(jsonFlags.entrySet()) + .concatMap(entry -> repo.upsertAsync(entry.getKey(), entry.getValue()))) + .then(Mono.<Void>fromRunnable(() -> plugin.dm.sync("SavedFlags/applyReconciled", + () -> + { + flags.clear(); + flags.putAll(jsonFlags); + }))); }) - .then(Mono.fromRunnable(() -> plugin.dm.sync("SavedFlags/applyReconciled", () -> - { - flags.clear(); - flags.putAll(jsonFlags); - }))) .onErrorResume(ex -> { FLog.warning(String.format("Failed to reconcile %s into the database: %s", diff --git a/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java b/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java index bcf33436b..76d13b93f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java @@ -707,8 +707,8 @@ private void reconcileFromJsonIfNewer(final AdminRepository repo) .thenMany(Flux.fromIterable(jsonAdmins.values()) .filter(Admin::isValid) .concatMap(admin -> repo.save(resolveUuidFor(admin), copyAdmin(admin)))) - .then(Mono.<Void>fromRunnable(() -> plugin.dm.sync("AdminList/applyReconciled", () -> - applyReconciledAdmins(jsonAdmins)))); + .then(Mono.<Void>fromRunnable(() -> plugin.dm.sync("AdminList/applyReconciled", + () -> applyReconciledAdmins(jsonAdmins)))); }) .onErrorResume(ex -> { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java index f29f07249..8f5c0a52a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java @@ -149,17 +149,17 @@ private void reconcileFromJsonIfNewer(final BanRepository repo) }) .subscribeOn(Schedulers.boundedElastic()) .filter(Boolean::booleanValue) - .flatMapMany(ignored -> + .flatMap(ignored -> { FLog.info(String.format("bans.json is newer than the database; rebuilding it from the file's %d ban(s).", - jsonBans.size())); + jsonBans.size())); return repo.deleteAll() .thenMany(Flux.fromIterable(jsonBans) .filter(Ban::isValid) - .concatMap(repo::save)); + .concatMap(repo::save)) + .then(Mono.<Void>fromRunnable(() -> plugin.dm.sync("BanManager/applyReconciled", + () -> applyReconciledBans(jsonBans)))); }) - .then(Mono.fromRunnable(() -> plugin.dm.sync("BanManager/applyReconciled", - () -> applyReconciledBans(jsonBans)))) .onErrorResume(ex -> { FLog.warning(String.format("Failed to reconcile bans.json into the database: %s", ex.getMessage())); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java index b17ed0fd2..b6ced8bd0 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java @@ -137,16 +137,16 @@ private void reconcileFromJsonIfNewer(final PermbanRepository repo) }) .subscribeOn(Schedulers.boundedElastic()) .filter(Boolean::booleanValue) - .flatMapMany(ignored -> + .flatMap(ignored -> { FLog.info(String.format("%s is newer than the database; rebuilding it from the file's %d permban(s).", CONFIG_FILENAME, jsonPermbans.size())); return repo.deleteAll() .thenMany(Flux.fromIterable(jsonPermbans.values()) - .concatMap(repo::save)); + .concatMap(repo::save)) + .then(Mono.<Void>fromRunnable(() -> plugin.dm.sync("PermbanList/applyReconciled", + () -> applyReconciledPermbans(jsonPermbans.values())))); }) - .then(Mono.fromRunnable(() -> plugin.dm.sync("PermbanList/applyReconciled", - () -> applyReconciledPermbans(jsonPermbans.values())))) .onErrorResume(ex -> { FLog.warning(String.format("Failed to reconcile %s into the database: %s", diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java index a9b4f4785..0cb090f92 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java @@ -179,19 +179,20 @@ private void reconcileFromJsonIfNewer(final StrikeRepository repo) }) .subscribeOn(Schedulers.boundedElastic()) .filter(Boolean::booleanValue) - .flatMapMany(ignored -> + .flatMap(ignored -> { - FLog.info(String.format("strikes.json is newer than the database; rebuilding it from the file's %d strike record(s).", + FLog.info(String.format("strikes.json is newer than the database; rebuilding it from the file's %d strike record(s).", jsonStrikes.size())); return repo.deleteAll() .thenMany(Flux.fromIterable(jsonStrikes.values()) - .concatMap(repo::upsertAsync)); + .concatMap(repo::upsertAsync)) + .then(Mono.<Void>fromRunnable(() -> plugin.dm.sync("StrikeList/applyReconciled", + () -> + { + strikes.clear(); + strikes.putAll(jsonStrikes); + }))); }) - .then(Mono.fromRunnable(() -> plugin.dm.sync("StrikeList/applyReconciled", () -> - { - strikes.clear(); - strikes.putAll(jsonStrikes); - }))) .onErrorResume(ex -> { FLog.warning(String.format("Failed to reconcile strikes.json into the database: %s", diff --git a/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java index b6d877731..2b51e9316 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java @@ -332,16 +332,16 @@ private void reconcileFromJsonIfNewer(final RankRepository repo) }) .subscribeOn(Schedulers.boundedElastic()) .filter(Boolean::booleanValue) - .flatMapMany(ignored -> + .flatMap(ignored -> { - FLog.info(String.format("%s is newer than the database; rebuilding it from the file's %d rank(s).", - RANKS_FILENAME, jsonRanks.size())); - return repo.deleteAll() - .thenMany(Flux.fromIterable(jsonRanks.values()) - .concatMap(repo::save)); + FLog.info(String.format("%s is newer than the database; rebuilding it from the file's %d rank(s).", + RANKS_FILENAME, jsonRanks.size())); + return repo.deleteAll() + .thenMany(Flux.fromIterable(jsonRanks.values()) + .concatMap(repo::save)) + .then(Mono.<Void>fromRunnable(() -> plugin.dm.sync("RankManager/applyReconciled", + () -> applyReconciledRanks(jsonRanks)))); }) - .then(Mono.fromRunnable(() -> plugin.dm.sync("RankManager/applyReconciled", - () -> applyReconciledRanks(jsonRanks)))) .onErrorResume(ex -> { FLog.warning(String.format("Failed to reconcile %s into the database: %s", From b5269f4e657e73774814353148ae47a1094c0273 Mon Sep 17 00:00:00 2001 From: shrimp <luke560@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:41:20 -0600 Subject: [PATCH 36/48] Fix rank registration --- .../me/totalfreedom/totalfreedommod/rank/RankManager.java | 1 + .../sql/adapter/generic/GenericRankRepository.java | 1 + .../java/me/totalfreedom/totalfreedommod/util/JsonUtil.java | 6 ++++++ 3 files changed, 8 insertions(+) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java index 2b51e9316..51021e5b2 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java @@ -116,6 +116,7 @@ public RankRegistry getRegistry() protected void onStart() { loadRanks(); + plugin.dm.whenReady(this::loadRanks); if (plugin.csr != null) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericRankRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericRankRepository.java index 60c5efc00..9dc0da9d5 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericRankRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericRankRepository.java @@ -319,6 +319,7 @@ private static String serializeRoles(Set<RankRole> roles) return roles == null || roles.isEmpty() ? null : roles.stream() + .filter(Objects::nonNull) .map(RankRole::getId) .collect(Collectors.joining(",")); } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/util/JsonUtil.java b/src/main/java/me/totalfreedom/totalfreedommod/util/JsonUtil.java index ca69c6338..f0102fe5e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/util/JsonUtil.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/util/JsonUtil.java @@ -6,6 +6,8 @@ import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; +import me.totalfreedom.totalfreedommod.rank.RankRole; + import com.google.gson.*; /** @@ -32,6 +34,10 @@ public final class JsonUtil new JsonPrimitive(AdventureUtil.componentToLegacy(src))) .registerTypeAdapter(Component.class, (JsonDeserializer<Component>) (json, type, ctx) -> AdventureUtil.legacyToComponent(json.getAsString())) + .registerTypeAdapter(RankRole.class, (JsonSerializer<RankRole>) (src, type, ctx) -> + new JsonPrimitive(src.getId())) + .registerTypeAdapter(RankRole.class, (JsonDeserializer<RankRole>) (json, type, ctx) -> + RankRole.fromId(json.getAsString()).orElse(null)) .create(); private JsonUtil() From 377e079f385669b30bcc9d36a15a947376d63dc8 Mon Sep 17 00:00:00 2001 From: shrimp <luke560@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:20:58 -0600 Subject: [PATCH 37/48] Fix timestamps --- .../sql/adapter/DatabaseAdapter.java | 13 +++++++++++++ .../adapter/generic/GenericAdminRepository.java | 3 +-- .../adapter/generic/GenericBanRepository.java | 3 +-- .../generic/GenericDiscordLinkRepository.java | 5 +++-- .../generic/GenericPermbanRepository.java | 3 +-- .../generic/GenericPlayerRepository.java | 3 +-- .../generic/GenericProtectedAreaRepository.java | 3 +-- .../adapter/generic/GenericRankRepository.java | 3 +-- .../generic/GenericSavedFlagRepository.java | 5 +++-- .../generic/GenericStrikeRepository.java | 5 +++-- .../adapter/generic/GenericTitleRepository.java | 3 +-- .../sql/adapter/sqlite/SQLiteAdapter.java | 17 +++++++++++++++++ 12 files changed, 46 insertions(+), 20 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DatabaseAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DatabaseAdapter.java index 4245372ed..edbd0d1a6 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DatabaseAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DatabaseAdapter.java @@ -1,6 +1,8 @@ package me.totalfreedom.totalfreedommod.sql.adapter; import java.sql.SQLException; +import java.sql.ResultSet; +import java.sql.Timestamp; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.sql.ConnectionHandler; @@ -204,6 +206,17 @@ public void shutdown() */ public abstract String currentTimestamp(); + /** + * Reads a timestamp column as epoch millis. Overridden where the dialect's + * stored value is not in the JVM's own timezone. + */ + public Long readTimestamp(final ResultSet rs, final int index) throws SQLException + { + final Timestamp ts = rs.getTimestamp(index); + + return ts != null ? ts.getTime() : null; + } + /** * Get the bind-parameter placeholder for a value being written into a native * timestamp column. PostgreSQL requires an explicit cast since it won't diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericAdminRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericAdminRepository.java index 71a694803..44a1b4471 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericAdminRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericAdminRepository.java @@ -393,8 +393,7 @@ public Long getMaxUpdatedAt() throws SQLException { if (rs.next()) { - Timestamp ts = rs.getTimestamp(1); - return ts != null ? ts.getTime() : null; + return adapter.readTimestamp(rs, 1); } } return null; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericBanRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericBanRepository.java index c4dfd412f..a2fbdb4fa 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericBanRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericBanRepository.java @@ -415,8 +415,7 @@ public Long getMaxUpdatedAt() throws SQLException { if (rs.next()) { - Timestamp ts = rs.getTimestamp(1); - return ts != null ? ts.getTime() : null; + return adapter.readTimestamp(rs, 1); } } return null; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericDiscordLinkRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericDiscordLinkRepository.java index fc231f2b3..4c79d39bb 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericDiscordLinkRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericDiscordLinkRepository.java @@ -19,6 +19,7 @@ public class GenericDiscordLinkRepository implements DiscordLinkRepository { private final StatementHandler statementHandler; + private final DatabaseAdapter adapter; private final String insertSql; private final String selectDiscordIdSql; @@ -31,6 +32,7 @@ public class GenericDiscordLinkRepository implements DiscordLinkRepository public GenericDiscordLinkRepository(StatementHandler statementHandler, DatabaseAdapter adapter) { this.statementHandler = statementHandler; + this.adapter = adapter; String tblDiscordLinks = adapter.quoteIdentifier("discord_links"); String colAdminUuid = adapter.quoteIdentifier("admin_uuid"); @@ -111,8 +113,7 @@ public Long getMaxUpdatedAt() throws SQLException { if (rs.next()) { - Timestamp ts = rs.getTimestamp(1); - return ts != null ? ts.getTime() : null; + return adapter.readTimestamp(rs, 1); } } return null; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPermbanRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPermbanRepository.java index 4683d4064..5bd17bc95 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPermbanRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPermbanRepository.java @@ -344,8 +344,7 @@ public Long getMaxUpdatedAt() throws SQLException { if (rs.next()) { - Timestamp ts = rs.getTimestamp(1); - return ts != null ? ts.getTime() : null; + return adapter.readTimestamp(rs, 1); } } return null; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java index 42b87d736..f5e904a4f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java @@ -281,8 +281,7 @@ public Long getUpdatedAt(String username) throws SQLException { if (rs.next()) { - Timestamp ts = rs.getTimestamp(1); - return ts != null ? ts.getTime() : null; + return adapter.readTimestamp(rs, 1); } } return null; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericProtectedAreaRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericProtectedAreaRepository.java index ee8ecb69f..289c3e7f6 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericProtectedAreaRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericProtectedAreaRepository.java @@ -190,8 +190,7 @@ public Long getMaxUpdatedAt() throws SQLException { if (rs.next()) { - Timestamp ts = rs.getTimestamp(1); - return ts != null ? ts.getTime() : null; + return adapter.readTimestamp(rs, 1); } } return null; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericRankRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericRankRepository.java index 9dc0da9d5..23d7cdcfe 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericRankRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericRankRepository.java @@ -262,8 +262,7 @@ public Long getMaxUpdatedAt() throws SQLException { if (rs.next()) { - Timestamp ts = rs.getTimestamp(1); - return ts != null ? ts.getTime() : null; + return adapter.readTimestamp(rs, 1); } } return null; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericSavedFlagRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericSavedFlagRepository.java index 3f22dfcad..457ad3afe 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericSavedFlagRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericSavedFlagRepository.java @@ -18,6 +18,7 @@ public class GenericSavedFlagRepository implements SavedFlagRepository { private final StatementHandler statementHandler; + private final DatabaseAdapter adapter; private final String tblSavedFlags; private final String colFlagName; @@ -30,6 +31,7 @@ public class GenericSavedFlagRepository implements SavedFlagRepository public GenericSavedFlagRepository(StatementHandler statementHandler, DatabaseAdapter adapter) { this.statementHandler = statementHandler; + this.adapter = adapter; this.tblSavedFlags = adapter.quoteIdentifier("saved_flags"); this.colFlagName = adapter.quoteIdentifier("flag_name"); @@ -83,8 +85,7 @@ public Long getMaxUpdatedAt() throws SQLException { if (rs.next()) { - Timestamp ts = rs.getTimestamp(1); - return ts != null ? ts.getTime() : null; + return adapter.readTimestamp(rs, 1); } } return null; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericStrikeRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericStrikeRepository.java index 16e3ab262..d52e0749c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericStrikeRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericStrikeRepository.java @@ -19,6 +19,7 @@ public class GenericStrikeRepository implements StrikeRepository { private final StatementHandler statementHandler; + private final DatabaseAdapter adapter; private final String tblStrikes; private final String colIp; @@ -32,6 +33,7 @@ public class GenericStrikeRepository implements StrikeRepository public GenericStrikeRepository(StatementHandler statementHandler, DatabaseAdapter adapter) { this.statementHandler = statementHandler; + this.adapter = adapter; this.tblStrikes = adapter.quoteIdentifier("strikes"); this.colIp = adapter.quoteIdentifier("ip"); @@ -93,8 +95,7 @@ public Long getMaxUpdatedAt() throws SQLException { if (rs.next()) { - Timestamp ts = rs.getTimestamp(1); - return ts != null ? ts.getTime() : null; + return adapter.readTimestamp(rs, 1); } } return null; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericTitleRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericTitleRepository.java index 566942717..a923030a7 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericTitleRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericTitleRepository.java @@ -251,8 +251,7 @@ public Long getMaxUpdatedAt() throws SQLException { if (rs.next()) { - Timestamp ts = rs.getTimestamp(1); - return ts != null ? ts.getTime() : null; + return adapter.readTimestamp(rs, 1); } } return null; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java index 3eecb8d64..26f277c09 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java @@ -2,6 +2,9 @@ import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.Calendar; +import java.util.TimeZone; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -116,6 +119,20 @@ public String currentTimestamp() return "CURRENT_TIMESTAMP"; } + /** + * SQLite writes CURRENT_TIMESTAMP as UTC text with no zone, which the driver + * would otherwise read in the JVM's timezone and place hours away from when + * the row was actually written. + */ + @Override + public Long readTimestamp(final ResultSet rs, final int index) throws SQLException + { + final Timestamp ts = rs.getTimestamp(index, Calendar.getInstance(TimeZone.getTimeZone("UTC"))); + + return ts != null ? ts.getTime() : null; + } + + @Override public String timestampParamPlaceholder() { From f48689e6fcce5a1e74dc1dd00342284626deaa94 Mon Sep 17 00:00:00 2001 From: shrimp <luke560@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:04:15 -0600 Subject: [PATCH 38/48] I'm ready to be a single mother --- .../totalfreedommod/ProtectArea.java | 14 +++-- .../totalfreedommod/admin/AdminList.java | 14 +++-- .../totalfreedommod/banning/BanManager.java | 54 +++++++++++++++---- .../totalfreedommod/banning/PermbanList.java | 52 +++++++++++++----- .../totalfreedommod/rank/RankManager.java | 9 ++-- .../sql/adapter/BanRepository.java | 5 ++ .../sql/adapter/PermbanRepository.java | 5 ++ .../adapter/generic/GenericBanRepository.java | 6 +++ .../generic/GenericPermbanRepository.java | 6 +++ 9 files changed, 131 insertions(+), 34 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java b/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java index ba150d538..55f336bed 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java @@ -3,6 +3,7 @@ import java.io.*; import java.lang.reflect.Type; import java.util.*; +import java.util.stream.Collectors; import org.bukkit.Bukkit; import org.bukkit.Location; @@ -203,9 +204,16 @@ private void reconcileFromJsonIfNewer(final ProtectedAreaRepository repo) { FLog.info(String.format("%s is newer than the database; rebuilding it from the file's %d protected area(s).", DATA_FILENAME, jsonAreas.size())); - return repo.deleteAll() - .thenMany(Flux.fromIterable(jsonAreas) - .concatMap(repo::save)) + final Set<UUID> keep = jsonAreas.stream() + .map(ProtectedRegion::getUuid) + .collect(Collectors.toSet()); + return Flux.fromIterable(jsonAreas) + .concatMap(repo::save) + .then(repo.loadAllAsync()) + .flatMapMany(Flux::fromIterable) + .map(ProtectedRegion::getUuid) + .filter(uuid -> !keep.contains(uuid)) + .concatMap(repo::deleteAsync) .then(Mono.<Void>fromRunnable(() -> plugin.dm.sync("ProtectArea/applyReconciled", () -> { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java b/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java index 76d13b93f..d9cec0b90 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java @@ -703,11 +703,15 @@ private void reconcileFromJsonIfNewer(final AdminRepository repo) { FLog.info(String.format("%s is newer than the database; rebuilding it from the file's %d admin(s).", CONFIG_FILENAME, jsonAdmins.size())); - return repo.deleteAll() - .thenMany(Flux.fromIterable(jsonAdmins.values()) - .filter(Admin::isValid) - .concatMap(admin -> repo.save(resolveUuidFor(admin), copyAdmin(admin)))) - .then(Mono.<Void>fromRunnable(() -> plugin.dm.sync("AdminList/applyReconciled", + return Flux.fromIterable(jsonAdmins.values()) + .filter(Admin::isValid) + .concatMap(admin -> repo.save(resolveUuidFor(admin), copyAdmin(admin))) + .then(repo.findAll()) + .flatMapMany(Flux::fromIterable) + .filter(existing -> existing.getUuid() != null + && !jsonAdmins.containsKey(existing.getName().toLowerCase())) + .concatMap(stale -> repo.deleteByUuid(stale.getUuid())) + .then(Mono.<Void>fromRunnable(() -> plugin.dm.sync("AdminList/applyReconciled", () -> applyReconciledAdmins(jsonAdmins)))); }) .onErrorResume(ex -> diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java index 8f5c0a52a..aaca08ec0 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java @@ -6,13 +6,8 @@ import java.io.IOException; import java.lang.reflect.Type; import java.time.Duration; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.Iterator; -import java.util.List; -import java.util.Map; -import java.util.Set; +import java.util.*; +import java.util.stream.Collectors; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; @@ -153,10 +148,31 @@ private void reconcileFromJsonIfNewer(final BanRepository repo) { FLog.info(String.format("bans.json is newer than the database; rebuilding it from the file's %d ban(s).", jsonBans.size())); - return repo.deleteAll() - .thenMany(Flux.fromIterable(jsonBans) - .filter(Ban::isValid) - .concatMap(repo::save)) + final Set<UUID> keepUuids = jsonBans.stream() + .map(Ban::getUuid) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + final Set<String> keepIps = jsonBans.stream() + .flatMap(ban -> ban.getIps().stream()) + .collect(Collectors.toSet()); + return repo.loadAllAsync() + .flatMapMany(existing -> + { + final Set<String> existingIps = existing.stream() + .filter(row -> row.getUuid() == null) + .flatMap(row -> row.getIps().stream()) + .collect(Collectors.toSet()); + return Flux.fromIterable(jsonBans) + .filter(Ban::isValid) + .filter(ban -> ban.getUuid() != null + || ban.getIps().stream().noneMatch(existingIps::contains)) + .concatMap(repo::save) + .thenMany(Flux.fromIterable(existing) + .filter(row -> !isKept(row, keepUuids, keepIps)) + .concatMap(stale -> stale.getUuid() != null + ? repo.deleteAsync(stale.getUuid()) + : repo.deleteByIpAsync(stale.getIps().get(0)))); + }) .then(Mono.<Void>fromRunnable(() -> plugin.dm.sync("BanManager/applyReconciled", () -> applyReconciledBans(jsonBans)))); }) @@ -168,6 +184,22 @@ private void reconcileFromJsonIfNewer(final BanRepository repo) .then()); } + /** + * Whether a row in the database is still described by the snapshot. A ban with + * no uuid is an IP ban, so it is matched on its addresses instead. A row with + * neither is left alone, since there is no key by which to remove it safely. + */ + private static boolean isKept(final Ban existing, final Set<UUID> keepUuids, final Set<String> keepIps) + { + if (existing.getUuid() != null) + return keepUuids.contains(existing.getUuid()); + + if (existing.getIps().isEmpty()) + return true; + + return existing.getIps().stream().anyMatch(keepIps::contains); + } + private void applyReconciledBans(final List<Ban> jsonBans) { synchronized (lock) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java index b6ced8bd0..79c6a7a82 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java @@ -5,12 +5,8 @@ import java.io.FileWriter; import java.io.IOException; import java.lang.reflect.Type; -import java.util.ArrayList; -import java.util.Collection; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; +import java.util.*; +import java.util.stream.Collectors; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; @@ -141,9 +137,21 @@ private void reconcileFromJsonIfNewer(final PermbanRepository repo) { FLog.info(String.format("%s is newer than the database; rebuilding it from the file's %d permban(s).", CONFIG_FILENAME, jsonPermbans.size())); - return repo.deleteAll() - .thenMany(Flux.fromIterable(jsonPermbans.values()) - .concatMap(repo::save)) + final Set<UUID> keepUuids = jsonPermbans.values().stream() + .map(PermBan::getUuid) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + final Set<String> keepIps = jsonPermbans.values().stream() + .flatMap(permban -> permban.getIps().stream()) + .collect(Collectors.toSet()); + return Flux.fromIterable(jsonPermbans.values()) + .concatMap(repo::save) + .then(repo.loadAllAsync()) + .flatMapMany(Flux::fromIterable) + .filter(existing -> !isKept(existing, keepUuids, keepIps)) + .concatMap(stale -> stale.getUuid() != null + ? repo.deleteAsync(stale.getUuid()) + : repo.deleteByIpAsync(stale.getIps().get(0))) .then(Mono.<Void>fromRunnable(() -> plugin.dm.sync("PermbanList/applyReconciled", () -> applyReconciledPermbans(jsonPermbans.values())))); }) @@ -156,6 +164,22 @@ private void reconcileFromJsonIfNewer(final PermbanRepository repo) .then()); } + /** + * Whether a row in the database is still described by the snapshot. A permban with + * no uuid is an IP permban, so it is matched on its addresses instead. A row with + * neither is left alone, since there is no key to remove it safely. + */ + private static boolean isKept(final PermBan existing, final Set<UUID> keepUuids, final Set<String> keepIps) + { + if (existing.getUuid() != null) + return keepUuids.contains(existing.getUuid()); + + if (existing.getIps().isEmpty()) + return true; + + return existing.getIps().stream().anyMatch(keepIps::contains); + } + private void applyReconciledPermbans(final Collection<PermBan> jsonPermbans) { synchronized (lock) @@ -175,10 +199,14 @@ private void replaceViews(final Collection<PermBan> source) source.forEach(permban -> { - final String name = permban.getUsername().toLowerCase().trim(); - permbannedNames.add(name); + final String name = permban.hasUsername() ? permban.getUsername().toLowerCase().trim() + : null; + if (name != null) + { + permbannedNames.add(name); + permbansByName.put(name, permban); + } permbannedIps.addAll(permban.getIps()); - permbansByName.put(name, permban); }); } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java index 51021e5b2..0420d44ea 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java @@ -337,9 +337,12 @@ private void reconcileFromJsonIfNewer(final RankRepository repo) { FLog.info(String.format("%s is newer than the database; rebuilding it from the file's %d rank(s).", RANKS_FILENAME, jsonRanks.size())); - return repo.deleteAll() - .thenMany(Flux.fromIterable(jsonRanks.values()) - .concatMap(repo::save)) + return Flux.fromIterable(jsonRanks.values()) + .concatMap(repo::save) + .then(repo.loadAllAsync()) + .flatMapMany(existing -> Flux.fromIterable(existing.keySet())) + .filter(id -> !jsonRanks.containsKey(id)) + .concatMap(repo::deleteAsync) .then(Mono.<Void>fromRunnable(() -> plugin.dm.sync("RankManager/applyReconciled", () -> applyReconciledRanks(jsonRanks)))); }) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/BanRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/BanRepository.java index ca9ca3799..08400e841 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/BanRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/BanRepository.java @@ -192,6 +192,11 @@ public interface BanRepository */ Mono<Boolean> deleteByUuid(UUID uuid); + /** + * Delete every ban carrying {@code ip}, off the main thread. + */ + Mono<Boolean> deleteByIpAsync(String ip); + /** * Delete all bans asynchronously. */ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/PermbanRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/PermbanRepository.java index ef019631d..e076cb331 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/PermbanRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/PermbanRepository.java @@ -165,6 +165,11 @@ public interface PermbanRepository */ Mono<List<PermBan>> findAll(); + /** + * Delete every permban carrying {@code ip}, off the main thread. + */ + Mono<Boolean> deleteByIpAsync(String ip); + /** * Delete all permbans asynchronously. */ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericBanRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericBanRepository.java index a2fbdb4fa..0cfe72618 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericBanRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericBanRepository.java @@ -445,6 +445,12 @@ public Mono<Boolean> deleteAsync(UUID uuid) return statementHandler.supplyMono(() -> delete(uuid)); } + @Override + public Mono<Boolean> deleteByIpAsync(String ip) + { + return statementHandler.supplyMono(() -> deleteByIp(ip)); + } + @Override public Mono<Integer> save(Ban ban) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPermbanRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPermbanRepository.java index 5bd17bc95..3950cc7be 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPermbanRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPermbanRepository.java @@ -374,6 +374,12 @@ public Mono<Boolean> deleteAsync(UUID uuid) return statementHandler.supplyMono(() -> delete(uuid)); } + @Override + public Mono<Boolean> deleteByIpAsync(String ip) + { + return statementHandler.supplyMono(() -> deleteByIp(ip)); + } + @Override public Mono<Integer> save(PermBan permban) { From d263f0f25bf59b32c4a3bd54f7f5449548f83831 Mon Sep 17 00:00:00 2001 From: shrimp <luke560@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:52:40 -0600 Subject: [PATCH 39/48] Write THEN prune in strike/saved-flag reconciles --- .../me/totalfreedom/totalfreedommod/SavedFlags.java | 11 +++++++---- .../totalfreedommod/banning/BanManager.java | 4 ++-- .../totalfreedommod/banning/StrikeList.java | 9 ++++++--- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/SavedFlags.java b/src/main/java/me/totalfreedom/totalfreedommod/SavedFlags.java index 191901cfe..2ebe99b76 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/SavedFlags.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/SavedFlags.java @@ -181,11 +181,14 @@ private void reconcileFromJsonIfNewer(final SavedFlagRepository repo) { FLog.info(String.format("%s is newer than the database; rebuilding it from the file's %d flag(s).", DATA_FILENAME, jsonFlags.size())); - return repo.deleteAll() - .thenMany(Flux.fromIterable(jsonFlags.entrySet()) - .concatMap(entry -> repo.upsertAsync(entry.getKey(), entry.getValue()))) + return repo.loadAllAsync() + .flatMapMany(existing -> Flux.fromIterable(jsonFlags.entrySet()) + .concatMap(entry -> repo.upsertAsync(entry.getKey(), entry.getValue())) + .thenMany(Flux.fromIterable(existing.keySet()) + .filter(flag -> !jsonFlags.containsKey(flag)) + .concatMap(repo::deleteAsync))) .then(Mono.<Void>fromRunnable(() -> plugin.dm.sync("SavedFlags/applyReconciled", - () -> + () -> { flags.clear(); flags.putAll(jsonFlags); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java index aaca08ec0..f375ea511 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java @@ -170,8 +170,8 @@ private void reconcileFromJsonIfNewer(final BanRepository repo) .thenMany(Flux.fromIterable(existing) .filter(row -> !isKept(row, keepUuids, keepIps)) .concatMap(stale -> stale.getUuid() != null - ? repo.deleteAsync(stale.getUuid()) - : repo.deleteByIpAsync(stale.getIps().get(0)))); + ? repo.deleteAsync(stale.getUuid()) + : repo.deleteByIpAsync(stale.getIps().get(0)))); }) .then(Mono.<Void>fromRunnable(() -> plugin.dm.sync("BanManager/applyReconciled", () -> applyReconciledBans(jsonBans)))); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java index 0cb090f92..109a9738f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java @@ -183,9 +183,12 @@ private void reconcileFromJsonIfNewer(final StrikeRepository repo) { FLog.info(String.format("strikes.json is newer than the database; rebuilding it from the file's %d strike record(s).", jsonStrikes.size())); - return repo.deleteAll() - .thenMany(Flux.fromIterable(jsonStrikes.values()) - .concatMap(repo::upsertAsync)) + return repo.loadAllAsync() + .flatMapMany(existing -> Flux.fromIterable(jsonStrikes.values()) + .concatMap(repo::upsertAsync) + .thenMany(Flux.fromIterable(existing.keySet()) + .filter(ip -> !jsonStrikes.containsKey(ip)) + .concatMap(repo::deleteByIpAsync))) .then(Mono.<Void>fromRunnable(() -> plugin.dm.sync("StrikeList/applyReconciled", () -> { From 6b8fec6193402d3c724e23263441196c4835eb89 Mon Sep 17 00:00:00 2001 From: shrimp <luke560@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:47:16 -0600 Subject: [PATCH 40/48] Stop rewriting the whole bans table on every change --- .../totalfreedommod/banning/BanManager.java | 88 ++++++++++--------- 1 file changed, 47 insertions(+), 41 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java index f375ea511..103abcd73 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java @@ -148,33 +148,8 @@ private void reconcileFromJsonIfNewer(final BanRepository repo) { FLog.info(String.format("bans.json is newer than the database; rebuilding it from the file's %d ban(s).", jsonBans.size())); - final Set<UUID> keepUuids = jsonBans.stream() - .map(Ban::getUuid) - .filter(Objects::nonNull) - .collect(Collectors.toSet()); - final Set<String> keepIps = jsonBans.stream() - .flatMap(ban -> ban.getIps().stream()) - .collect(Collectors.toSet()); - return repo.loadAllAsync() - .flatMapMany(existing -> - { - final Set<String> existingIps = existing.stream() - .filter(row -> row.getUuid() == null) - .flatMap(row -> row.getIps().stream()) - .collect(Collectors.toSet()); - return Flux.fromIterable(jsonBans) - .filter(Ban::isValid) - .filter(ban -> ban.getUuid() != null - || ban.getIps().stream().noneMatch(existingIps::contains)) - .concatMap(repo::save) - .thenMany(Flux.fromIterable(existing) - .filter(row -> !isKept(row, keepUuids, keepIps)) - .concatMap(stale -> stale.getUuid() != null - ? repo.deleteAsync(stale.getUuid()) - : repo.deleteByIpAsync(stale.getIps().get(0)))); - }) - .then(Mono.<Void>fromRunnable(() -> plugin.dm.sync("BanManager/applyReconciled", - () -> applyReconciledBans(jsonBans)))); + return syncToSql(repo, jsonBans).then(Mono.<Void>fromRunnable(() -> plugin.dm.sync("BanManager/applyReconciled", + () -> applyReconciledBans(jsonBans)))); }) .onErrorResume(ex -> { @@ -200,6 +175,44 @@ private static boolean isKept(final Ban existing, final Set<UUID> keepUuids, fin return existing.getIps().stream().anyMatch(keepIps::contains); } + /** + * Make the database match {@code desired}: write every record, then delete + * only the rows it no longer describes. The table is never emptied first, + * so a failed write leaves the existing rows untouched. A ban with no uuid + * is an IP ban, so it is matched and removed by address. + */ + private Mono<Void> syncToSql(final BanRepository repo, final Collection<Ban> desired) + { + final Set<UUID> keepUuids = desired.stream() + .map(Ban::getUuid) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + final Set<String> keepIps = desired.stream() + .flatMap(ban -> ban.getIps().stream()) + .collect(Collectors.toSet()); + + return repo.loadAllAsync() + .flatMapMany(existing -> + { + final Set<String> existingIps = existing.stream() + .filter(row -> row.getUuid() == null) + .flatMap(row -> row.getIps().stream()) + .collect(Collectors.toSet()); + + return Flux.fromIterable(desired) + .filter(Ban::isValid) + .filter(ban -> ban.getUuid() != null + || ban.getIps().stream().noneMatch(existingIps::contains)) + .concatMap(repo::save) + .thenMany(Flux.fromIterable(existing) + .filter(row -> !isKept(row, keepUuids, keepIps)) + .concatMap(stale -> stale.getUuid() != null + ? repo.deleteAsync(stale.getUuid()) + : repo.deleteByIpAsync(stale.getIps().get(0)))); + }) + .then(); + } + private void applyReconciledBans(final List<Ban> jsonBans) { synchronized (lock) @@ -353,19 +366,12 @@ public void saveAllAsync() final BanRepository repo = plugin.dm.getBanRepository(); - enqueue(repo.deleteAll() + enqueue(syncToSql(repo, snapshot) .onErrorResume(ex -> { - FLog.warning("Failed to clear bans before rewrite: " + ex.getMessage()); + FLog.warning("Failed to write bans to SQL: " + ex.getMessage()); return Mono.empty(); }) - .thenMany(Flux.fromIterable(snapshot) - .concatMap(ban -> repo.save(ban) - .onErrorResume(ex -> - { - FLog.warning("Failed to save ban to SQL: " + ex.getMessage()); - return Mono.<Integer>empty(); - }))) .then(writeJsonAsync(snapshot))); } @@ -418,6 +424,10 @@ else if (ban.hasUsername()) delete = Mono.fromCallable(() -> repo.deleteByUsername(ban.getUsername())) .subscribeOn(Schedulers.boundedElastic()); } + else if (ban.hasIps()) + { + delete = repo.deleteByIpAsync(ban.getIps().get(0)); + } else { delete = Mono.just(Boolean.FALSE); @@ -444,11 +454,7 @@ private void writeAllToSql(List<Ban> snapshot) try { BanRepository repo = plugin.dm.getBanRepository(); - repo.deleteAll().block(); - for (Ban ban : snapshot) - { - repo.save(ban).block(); - } + syncToSql(repo, snapshot).block(); FLog.debug("Saved " + snapshot.size() + " bans to SQL database"); writeAllToJson(snapshot); } From 13bf233466aab0ea9f92b38f4471fe6a3d328a07 Mon Sep 17 00:00:00 2001 From: shrimp <luke560@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:08:36 -0600 Subject: [PATCH 41/48] Match player rows case-insensitively --- .../generic/GenericPlayerRepository.java | 21 ++++++++++++------- .../adapter/postgresql/PostgreSQLAdapter.java | 2 +- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java index f5e904a4f..08a5c1a2a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java @@ -160,7 +160,8 @@ public Map<String, PlayerData> loadAll() throws SQLException public Optional<PlayerData> findByUsername(String username) throws SQLException { PlayerData data = null; - String sql = String.format("SELECT %s FROM %s WHERE %s = ?", selectColumns, tblPlayers, colUsername); + String sql = String.format("SELECT %s FROM %s WHERE %s", selectColumns, tblPlayers, + adapter.caseInsensitiveEquals(colUsername, "?")); try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); ResultSet rs = stmt.executeQuery()) { @@ -182,7 +183,8 @@ public Optional<PlayerData> findByUsername(String username) throws SQLException @Override public boolean exists(String username) throws SQLException { - String sql = String.format("SELECT COUNT(*) FROM %s WHERE %s = ?", tblPlayers, colUsername); + String sql = String.format("SELECT COUNT (*) FROM %s WHERE %s", tblPlayers, + adapter.caseInsensitiveEquals(colUsername, "?")); try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); ResultSet rs = stmt.executeQuery()) { @@ -194,7 +196,8 @@ public boolean exists(String username) throws SQLException public List<String> getIps(String username) throws SQLException { List<String> ips = new ArrayList<>(); - String sql = String.format("SELECT %s FROM %s WHERE %s = ? ORDER BY %s ASC", colIp, tblPlayerIps, colPlayerUsername, colId); + String sql = String.format("SELECT %s FROM %s WHERE %s ORDER BY %s ASC", colIp, tblPlayerIps, + adapter.caseInsensitiveEquals(colPlayerUsername, "?"), colId); try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); ResultSet rs = stmt.executeQuery()) { @@ -231,7 +234,8 @@ public boolean update(PlayerData data) throws SQLException data.getUsername()); statementHandler.executeUpdate( - String.format("UPDATE %s SET %s = ? WHERE %s = ?", tblPlayers, colNickname, colUsername), + String.format("UPDATE %s SET %s = ? WHERE %s", tblPlayers, colNickname, + adapter.caseInsensitiveEquals(colPlayerUsername, "?")), serializeNickname(data), data.getUsername()); return rows > 0; @@ -240,7 +244,8 @@ public boolean update(PlayerData data) throws SQLException @Override public void syncIps(String username, List<String> ips) throws SQLException { - statementHandler.executeUpdate(String.format("DELETE FROM %s WHERE %s = ?", tblPlayerIps, colPlayerUsername), username); + statementHandler.executeUpdate(String.format("DELETE FROM %s WHERE %s", tblPlayerIps, + adapter.caseInsensitiveEquals(colPlayerUsername, "?")), username); insertIps(username, ips); } @@ -261,7 +266,8 @@ public void saveOrUpdate(PlayerData data) throws SQLException @Override public boolean delete(String username) throws SQLException { - String sql = String.format("DELETE FROM %s WHERE %s = ?", tblPlayers, colUsername); + String sql = String.format("DELETE FROM %s WHERE %s", tblPlayers, + adapter.caseInsensitiveEquals(colUsername, "?")); return statementHandler.executeUpdate(sql, username) > 0; } @@ -275,7 +281,8 @@ public void deleteAllSync() throws SQLException @Override public Long getUpdatedAt(String username) throws SQLException { - String sql = String.format("SELECT %s FROM %s WHERE %s = ?", colUpdatedAt, tblPlayers, colUsername); + String sql = String.format("SELECT %s FROM %s WHERE %s", colUpdatedAt, tblPlayers, + adapter.caseInsensitiveEquals(colUsername, "?")); try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); ResultSet rs = stmt.executeQuery()) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java index 33d038efc..3da5dbde6 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java @@ -120,7 +120,7 @@ public String timestampParamPlaceholder() @Override public String caseInsensitiveEquals(String columnRef, String paramPlaceholder) { - return String.format("%s ILIKE %s", columnRef, paramPlaceholder); + return String.format("LOWER(%s) = LOWER(%s)", columnRef, paramPlaceholder); } @Override From 406a9168bce3fc82bf1b4a78f5c7e9abad70d1a1 Mon Sep 17 00:00:00 2001 From: shrimp <luke560@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:28:21 -0600 Subject: [PATCH 42/48] Give uuidless bans a key so they can be updated --- .../totalfreedommod/banning/BanManager.java | 7 -- .../adapter/generic/GenericBanRepository.java | 65 +++++++++++++++++-- 2 files changed, 58 insertions(+), 14 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java index 103abcd73..68c3f3ff8 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java @@ -194,15 +194,8 @@ private Mono<Void> syncToSql(final BanRepository repo, final Collection<Ban> des return repo.loadAllAsync() .flatMapMany(existing -> { - final Set<String> existingIps = existing.stream() - .filter(row -> row.getUuid() == null) - .flatMap(row -> row.getIps().stream()) - .collect(Collectors.toSet()); - return Flux.fromIterable(desired) .filter(Ban::isValid) - .filter(ban -> ban.getUuid() != null - || ban.getIps().stream().noneMatch(existingIps::contains)) .concatMap(repo::save) .thenMany(Flux.fromIterable(existing) .filter(row -> !isKept(row, keepUuids, keepIps)) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericBanRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericBanRepository.java index 0cfe72618..484c5107e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericBanRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericBanRepository.java @@ -454,16 +454,67 @@ public Mono<Boolean> deleteByIpAsync(String ip) @Override public Mono<Integer> save(Ban ban) { - return statementHandler.supplyMono(() -> { - if (ban.getUuid() != null && getBanId(ban.getUuid()) > 0) - { - update(ban); - return getBanId(ban.getUuid()); - } - return insert(ban); + return statementHandler.supplyMono(() -> + { + final int existing = findBanId(ban); + if (existing <= 0) + return insert(ban); + + updateById(existing, ban); + syncIps(existing, ban.getIps()); + return existing; }); } + private int findBanId(final Ban ban) throws SQLException + { + if (ban.getUuid() != null) + return getBanId(ban.getUuid()); + + if (ban.hasUsername()) + return getBanIdByUsername(ban.getUsername()); + + for (final String ip : ban.getIps()) + { + final int id = getBanIdByIp(ip); + if (id > 0) + return id; + } + + return -1; + } + + private int getBanIdByIp(final String ip) throws SQLException + { + String sql = String.format( + "SELECT b.%s FROM %s b INNER JOIN %s i ON b.%s = i.%s WHERE i.%s = ? AND b.%s IS NULL AND b.%s IS NULL", + colId, tblBans, tblBanIps, colId, colBanId, colIp, colUuid, colUsername); + + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, ip); + ResultSet rs = stmt.executeQuery()) + { + if (rs.next()) + return rs.getInt(1); + } + + return -1; + } + + private boolean updateById(final int banId, final Ban ban) throws SQLException + { + String sql = String.format("UPDATE %s SET %s = ?, %s = ?, %s = ?, %s = ?, %s = %s, %s = %s WHERE %s = ?", + tblBans, colUsername, colBannedBy, colBannedByUuid, colReason, colExpireAt, + adapter.timestampParamPlaceholder(), colUpdatedAt, adapter.currentTimestamp(), colId); + + return statementHandler.executeUpdate(sql, + ban.getUsername(), + ban.getBannedBy(), + ban.getBannedByUuid() != null ? ban.getBannedByUuid().toString() : null, + ban.getReason(), + ban.getExpireAt() != null ? FUtil.dateToString(ban.getExpireAt()) : null, + banId) > 0; + } + @Override public Mono<List<Ban>> findAll() { From c0e68258d9841ae2cf2eb4d9ceeba5cd7d47c2da Mon Sep 17 00:00:00 2001 From: shrimp <luke560@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:49:33 -0600 Subject: [PATCH 43/48] Compare snapshot and row timestamps at storage resolution --- .../totalfreedommod/ProtectArea.java | 6 ++---- .../totalfreedommod/SavedFlags.java | 3 ++- .../totalfreedommod/admin/AdminList.java | 2 +- .../totalfreedommod/banning/BanManager.java | 2 +- .../totalfreedommod/banning/PermbanList.java | 2 +- .../totalfreedommod/banning/StrikeList.java | 3 ++- .../discord/DiscordLinkJsonSync.java | 3 ++- .../totalfreedommod/player/PlayerList.java | 4 +--- .../totalfreedommod/rank/RankManager.java | 2 +- .../totalfreedommod/title/TitleManager.java | 6 ++---- .../totalfreedommod/util/FUtil.java | 20 +++++++++++++++++++ 11 files changed, 35 insertions(+), 18 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java b/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java index 55f336bed..9ffe89f30 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java @@ -31,9 +31,7 @@ import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.sql.PersistenceQueue; import me.totalfreedom.totalfreedommod.sql.adapter.ProtectedAreaRepository; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FTask; -import me.totalfreedom.totalfreedommod.util.JsonUtil; +import me.totalfreedom.totalfreedommod.util.*; import com.google.common.collect.Maps; import com.google.gson.reflect.TypeToken; @@ -196,7 +194,7 @@ private void reconcileFromJsonIfNewer(final ProtectedAreaRepository repo) writes.enqueue(Mono.fromCallable(() -> { final Long sqlUpdatedAt = repo.getMaxUpdatedAt(); - return sqlUpdatedAt == null || fileModified > sqlUpdatedAt; + return FUtil.isSnapshotNewer(fileModified, sqlUpdatedAt); }) .subscribeOn(Schedulers.boundedElastic()) .filter(Boolean::booleanValue) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/SavedFlags.java b/src/main/java/me/totalfreedom/totalfreedommod/SavedFlags.java index 2ebe99b76..0a5a64a78 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/SavedFlags.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/SavedFlags.java @@ -17,6 +17,7 @@ import me.totalfreedom.totalfreedommod.sql.adapter.SavedFlagRepository; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.JsonUtil; +import me.totalfreedom.totalfreedommod.util.FUtil; import com.google.gson.reflect.TypeToken; @@ -173,7 +174,7 @@ private void reconcileFromJsonIfNewer(final SavedFlagRepository repo) writes.enqueue(Mono.fromCallable(() -> { final Long sqlUpdatedAt = repo.getMaxUpdatedAt(); - return sqlUpdatedAt == null || fileModified > sqlUpdatedAt; + return FUtil.isSnapshotNewer(fileModified, sqlUpdatedAt); }) .subscribeOn(Schedulers.boundedElastic()) .filter(Boolean::booleanValue) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java b/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java index d9cec0b90..d15cab777 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java @@ -695,7 +695,7 @@ private void reconcileFromJsonIfNewer(final AdminRepository repo) enqueue(Mono.fromCallable(() -> { final Long sqlUpdatedAt = repo.getMaxUpdatedAt(); - return sqlUpdatedAt == null || fileModified > sqlUpdatedAt; + return FUtil.isSnapshotNewer(fileModified, sqlUpdatedAt); }) .subscribeOn(Schedulers.boundedElastic()) .filter(Boolean::booleanValue) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java index 68c3f3ff8..2b9736ed5 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java @@ -140,7 +140,7 @@ private void reconcileFromJsonIfNewer(final BanRepository repo) enqueue(Mono.fromCallable(() -> { final Long sqlUpdatedAt = repo.getMaxUpdatedAt(); - return sqlUpdatedAt == null || fileModified > sqlUpdatedAt; + return FUtil.isSnapshotNewer(fileModified, sqlUpdatedAt); }) .subscribeOn(Schedulers.boundedElastic()) .filter(Boolean::booleanValue) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java index 79c6a7a82..e8d8e0ed0 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java @@ -129,7 +129,7 @@ private void reconcileFromJsonIfNewer(final PermbanRepository repo) enqueue(Mono.fromCallable(() -> { final Long sqlUpdatedAt = repo.getMaxUpdatedAt(); - return sqlUpdatedAt == null || fileModified > sqlUpdatedAt; + return FUtil.isSnapshotNewer(fileModified, sqlUpdatedAt); }) .subscribeOn(Schedulers.boundedElastic()) .filter(Boolean::booleanValue) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java index 109a9738f..6f7209a9d 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java @@ -23,6 +23,7 @@ import me.totalfreedom.totalfreedommod.sql.adapter.StrikeRepository; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.JsonUtil; +import me.totalfreedom.totalfreedommod.util.FUtil; import com.google.common.collect.Maps; import com.google.gson.reflect.TypeToken; @@ -175,7 +176,7 @@ private void reconcileFromJsonIfNewer(final StrikeRepository repo) enqueue(Mono.fromCallable(() -> { final Long sqlUpdatedAt = repo.getMaxUpdatedAt(); - return sqlUpdatedAt == null || fileModified > sqlUpdatedAt; + return FUtil.isSnapshotNewer(fileModified, sqlUpdatedAt); }) .subscribeOn(Schedulers.boundedElastic()) .filter(Boolean::booleanValue) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordLinkJsonSync.java b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordLinkJsonSync.java index 0c7c0906c..dc9cd413c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordLinkJsonSync.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordLinkJsonSync.java @@ -15,6 +15,7 @@ import me.totalfreedom.totalfreedommod.sql.adapter.DiscordLinkRepository; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.JsonUtil; +import me.totalfreedom.totalfreedommod.util.FUtil; import com.google.gson.reflect.TypeToken; @@ -81,7 +82,7 @@ static void reconcileFromJsonIfNewer(TotalFreedomMod plugin, DiscordLinkReposito final long fileModified = file.lastModified(); WRITES.enqueue(repo.getMaxUpdatedAtAsync() - .map(sqlUpdatedAt -> fileModified > sqlUpdatedAt) + .map(sqlUpdatedAt -> FUtil.isSnapshotNewer(fileModified, sqlUpdatedAt)) .defaultIfEmpty(Boolean.TRUE) .filter(Boolean::booleanValue) .flatMap(ignored -> diff --git a/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerList.java b/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerList.java index fc53fe603..403294941 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerList.java @@ -308,10 +308,8 @@ private void reconcileFromJsonIfNewer(String username, PlayerRepository repo) try { Long sqlUpdatedAt = repo.getUpdatedAt(username); - if (sqlUpdatedAt != null && jsonFile.lastModified() <= sqlUpdatedAt) - { + if (!FUtil.isSnapshotNewer(jsonFile.lastModified(), sqlUpdatedAt)) return; - } PlayerData jsonData = readJsonPlayer(username); if (jsonData == null || !jsonData.isValid()) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java index 0420d44ea..c224cfa52 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java @@ -329,7 +329,7 @@ private void reconcileFromJsonIfNewer(final RankRepository repo) writes.enqueue(Mono.fromCallable(() -> { final Long sqlUpdatedAt = repo.getMaxUpdatedAt(); - return sqlUpdatedAt == null || fileModified > sqlUpdatedAt; + return FUtil.isSnapshotNewer(fileModified, sqlUpdatedAt); }) .subscribeOn(Schedulers.boundedElastic()) .filter(Boolean::booleanValue) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/title/TitleManager.java b/src/main/java/me/totalfreedom/totalfreedommod/title/TitleManager.java index 35caa946e..166cf148e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/title/TitleManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/title/TitleManager.java @@ -28,9 +28,7 @@ import me.totalfreedom.totalfreedommod.player.PlayerData; import me.totalfreedom.totalfreedommod.sql.PersistenceQueue; import me.totalfreedom.totalfreedommod.sql.adapter.TitleRepository; -import me.totalfreedom.totalfreedommod.util.AdventureUtil; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.JsonUtil; +import me.totalfreedom.totalfreedommod.util.*; import com.google.common.collect.Maps; import com.google.gson.reflect.TypeToken; @@ -368,7 +366,7 @@ private void reconcileFromJsonIfNewer(final TitleRepository repo) writes.enqueue(Mono.fromCallable(() -> { final Long sqlUpdatedAt = repo.getMaxUpdatedAt(); - return sqlUpdatedAt == null || fileModified > sqlUpdatedAt; + return FUtil.isSnapshotNewer(fileModified, sqlUpdatedAt); }) .subscribeOn(Schedulers.boundedElastic()) .filter(Boolean::booleanValue) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/util/FUtil.java b/src/main/java/me/totalfreedom/totalfreedommod/util/FUtil.java index 4af9e9da7..1b38f6769 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/util/FUtil.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/util/FUtil.java @@ -286,6 +286,26 @@ public static Date stringToDate(String dateString) } } + /** + * The resolution at which the databases store timestamps. SQLite and MySQL both keep whole seconds, + * so anything finer than this is an artifact of truncation rather than a real difference. + */ + private static final long TIMESTAMP_RESOLUTION_MS = 1000L; + + /** + * Whether a JSON snapshot should be treated as newer than the database it mirrors. + * <p> + * A write-through save writes its row and then its snapshot, so the file is always a few + * milliseconds later than the row, and the stored timestamp is truncated to the second on + * top of that. Comparing them directly therefore reports "newer" after almost every save, + * which would rebuild the whole domain on each startup. Only a gap wider than the storage + * resolution means the file was actually edited or restored behind the plugin's back. + */ + public static boolean isSnapshotNewer(final long fileModified, final Long sqlUpdatedAt) + { + return sqlUpdatedAt == null || fileModified > sqlUpdatedAt + TIMESTAMP_RESOLUTION_MS; + } + public static boolean fuzzyIpMatch(String a, String b, int octets) { boolean match = true; From 9b5531b835e9d06d66c4e52ca231cefb2fd66cc0 Mon Sep 17 00:00:00 2001 From: shrimp <luke560@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:00:13 -0600 Subject: [PATCH 44/48] Refresh an empty snapshot from database --- .../java/me/totalfreedom/totalfreedommod/ProtectArea.java | 3 +++ .../java/me/totalfreedom/totalfreedommod/SavedFlags.java | 3 +++ .../me/totalfreedom/totalfreedommod/admin/AdminList.java | 5 +++++ .../me/totalfreedom/totalfreedommod/banning/BanManager.java | 3 +++ .../me/totalfreedom/totalfreedommod/banning/PermbanList.java | 3 +++ .../me/totalfreedom/totalfreedommod/banning/StrikeList.java | 3 +++ .../me/totalfreedom/totalfreedommod/rank/RankManager.java | 3 +++ .../me/totalfreedom/totalfreedommod/title/TitleManager.java | 3 +++ 8 files changed, 26 insertions(+) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java b/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java index 9ffe89f30..e6ea71529 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java @@ -187,7 +187,10 @@ private void reconcileFromJsonIfNewer(final ProtectedAreaRepository repo) } if (jsonAreas.isEmpty()) + { + writes.enqueue(writeJsonAsync()); return; + } final long fileModified = dataFile.lastModified(); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/SavedFlags.java b/src/main/java/me/totalfreedom/totalfreedommod/SavedFlags.java index 0a5a64a78..54a439e67 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/SavedFlags.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/SavedFlags.java @@ -167,7 +167,10 @@ private void reconcileFromJsonIfNewer(final SavedFlagRepository repo) final Map<String, Boolean> jsonFlags = readJsonFlags(dataFile); if (jsonFlags.isEmpty()) + { + writes.enqueue(writeJsonAsync()); return; + } final long fileModified = dataFile.lastModified(); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java b/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java index d15cab777..2b99c7a49 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java @@ -688,7 +688,12 @@ private void reconcileFromJsonIfNewer(final AdminRepository repo) } if (jsonAdmins.isEmpty()) + { + // An empty snapshot described nothing, but SQL may hold rows it should be covering. + // Refresh it rather than leaving the fallback with no admins. + enqueue(writeJsonAsync(serialiseAdmins())); return; + } final long fileModified = configFile.lastModified(); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java index 2b9736ed5..7118a28b8 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java @@ -133,7 +133,10 @@ private void reconcileFromJsonIfNewer(final BanRepository repo) } if (jsonBans.isEmpty()) + { + enqueue(writeJsonAsync(new ArrayList<>(bans))); return; + } final long fileModified = configFile.lastModified(); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java index e8d8e0ed0..e5e88bfe8 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java @@ -122,7 +122,10 @@ private void reconcileFromJsonIfNewer(final PermbanRepository repo) } if (jsonPermbans.isEmpty()) + { + enqueue(writeJsonAsync()); return; + } final long fileModified = configFile.lastModified(); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java index 6f7209a9d..18341f43a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java @@ -169,7 +169,10 @@ private void reconcileFromJsonIfNewer(final StrikeRepository repo) } if (jsonStrikes.isEmpty()) + { + enqueue(writeJsonAsync()); return; + } final long fileModified = configFile.lastModified(); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java index c224cfa52..0665cd0e6 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java @@ -322,7 +322,10 @@ private void reconcileFromJsonIfNewer(final RankRepository repo) } if (jsonRanks.isEmpty()) + { + writes.enqueue(writeJsonAsync()); return; + } final long fileModified = ranksFile.lastModified(); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/title/TitleManager.java b/src/main/java/me/totalfreedom/totalfreedommod/title/TitleManager.java index 166cf148e..a6d698c8e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/title/TitleManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/title/TitleManager.java @@ -359,7 +359,10 @@ private void reconcileFromJsonIfNewer(final TitleRepository repo) } if (jsonTitles.isEmpty()) + { + writes.enqueue(writeJsonAsync()); return; + } final long fileModified = titlesFile.lastModified(); From 8c053be0945c5ed8bfed1a21cc6d54cb4e9c51bd Mon Sep 17 00:00:00 2001 From: shrimp <luke560@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:05:15 -0600 Subject: [PATCH 45/48] Require a usename on permban rows --- .../totalfreedommod/sql/adapter/mysql/MySQLAdapter.java | 2 +- .../sql/adapter/postgresql/PostgreSQLAdapter.java | 2 +- .../totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java | 5 ++++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdapter.java index b52084b11..da7832194 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdapter.java @@ -261,7 +261,7 @@ private void createPermbansTable() throws SQLException CREATE TABLE IF NOT EXISTS `permbans` ( `id` INT AUTO_INCREMENT PRIMARY KEY, `uuid` VARCHAR(36), - `username` VARCHAR(16), + `username` VARCHAR(16) NOT NULL, `reason` TEXT, `updated_at` DATETIME NOT NULL DEFAULT NOW(), INDEX `idx_permbans_uuid` (`uuid`), diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java index 3da5dbde6..0cb413be2 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java @@ -262,7 +262,7 @@ private void createPermbansTable() throws SQLException CREATE TABLE IF NOT EXISTS "permbans" ( "id" SERIAL PRIMARY KEY, "uuid" VARCHAR(36), - "username" VARCHAR(16), + "username" VARCHAR(16) NOT NULL, "reason" TEXT, "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java index 26f277c09..7e1b51ab4 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java @@ -284,13 +284,16 @@ FOREIGN KEY (ban_id) REFERENCES bans(id) ON DELETE CASCADE statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_ban_ips_ip ON ban_ips(ip)"); } + /** + * A permban is always keyed by username, ensure it's not null. + */ private void createPermbansTable() throws SQLException { String sql = """ CREATE TABLE IF NOT EXISTS permbans ( id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT, - username TEXT, + username TEXT NOT NULL, reason TEXT, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) From e7021b9d1df92b79a37bed4818b8f08cabe48823 Mon Sep 17 00:00:00 2001 From: shrimp <luke560@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:13:12 -0600 Subject: [PATCH 46/48] Re-resolve console bindings when the rank set changes --- .../totalfreedommod/rank/RankManager.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java index 0665cd0e6..c07563051 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java @@ -199,11 +199,27 @@ private void applyLoadedRanks(final RankRepository repo, final Map<String, Custo customRanks.putAll(loaded); resolveInheritance(); updateAllPlayerTeams(); + refreshConsoleBindings(); FLog.info(String.format("Loaded %d custom ranks from SQL database.", customRanks.size())); reconcileFromJsonIfNewer(repo); } + /** + * Re-resolve the console whitelist against the rank set that is now in memory. + * <p> + * The first read happens before this service starts, when no custom ranks are loaded, + * so bindings that name one are skipped with a warning. {@code onStart} rereads it + * once the JSON ranks are in, but the swap to SQL and the snapshot reconcile both + * land later and asynchronously, and until now neither told the registry that the + * ranks had changed. + */ + private void refreshConsoleBindings() + { + if (plugin.csr != null) + plugin.csr.load(); + } + private void loadFromJsonOrDefaults() { if (!ranksFile.exists()) @@ -364,6 +380,7 @@ private void applyReconciledRanks(final Map<String, CustomRank> jsonRanks) customRanks.putAll(jsonRanks); resolveInheritance(); updateAllPlayerTeams(); + refreshConsoleBindings(); } From 22d6fc82368a7b5573dd3181174c84f19769499f Mon Sep 17 00:00:00 2001 From: shrimp <luke560@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:32:23 -0600 Subject: [PATCH 47/48] restrict /opall gamemode switches to admins --- .../me/totalfreedom/totalfreedommod/cmd/Command_opall.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_opall.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_opall.java index ab2f71a0b..654beda6d 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_opall.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_opall.java @@ -14,6 +14,11 @@ public class Command_opall extends FCommand @Callback public void opall(CommandSender sender, @Switch("c") boolean creative, @Switch("s") boolean survival) { + if ((creative || survival) && !isAdmin(sender)) + { + throw new CommandFailException("<gray>Only admins may change everyone's gamemode with -c or -s."); + } + if (creative && survival) { throw new CommandFailException("<gray>Cannot use both -c and -s at the same time."); From fd84197fba469992e4fcbe0a24d086d2bd8825c2 Mon Sep 17 00:00:00 2001 From: shrimp <luke560@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:10:33 -0600 Subject: [PATCH 48/48] Fix titles never reaching SQL; add title completers and self-grant guard --- .../totalfreedommod/cmd/Command_title.java | 28 ++++++++++++++++--- .../totalfreedommod/title/TitleManager.java | 11 ++++---- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_title.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_title.java index 966d13ec6..8b5a01653 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_title.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_title.java @@ -1,6 +1,7 @@ package me.totalfreedom.totalfreedommod.cmd; import java.util.List; +import java.util.Set; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; @@ -117,6 +118,12 @@ public void of(CommandSender sender, Player target) @Permission(permission = "tfm.manage.titles") public void grant(CommandSender sender, Player target, String titleId) { + if (sender instanceof Player granter && granter.getUniqueId().equals(target.getUniqueId())) + { + msg(sender, "<red>You cannot grant yourself a title."); + return; + } + final Title title = plugin().tm.getTitle(titleId); if (title == null) @@ -167,9 +174,20 @@ public List<String> completeInfo(CommandSender sender, String partial) } @Completer(value = "grant", position = 1) - public List<String> completeGrant(CommandSender sender, String partial) + public List<String> completeGrant(CommandSender sender, String partial, List<String> priorArgs) { - return matching(plugin().tm.getTitleIds(), partial); + final Player target = server().getPlayerExact(priorArgs.get(0)); + + if (target == null) + return List.of(); + + final Set<String> held = plugin().tm.getHeldTitleIds(target); + final List<String> grantable = plugin().tm.getTitleIds() + .stream() + .filter(id -> !held.contains(id)) + .toList(); + + return matching(grantable, partial); } /** @@ -177,9 +195,11 @@ public List<String> completeGrant(CommandSender sender, String partial) * be revoked rather than every title that exists. */ @Completer(value = "revoke", position = 1) - public List<String> completeRevoke(CommandSender sender, String partial) + public List<String> completeRevoke(CommandSender sender, String partial, List<String> priorArgs) { - return matching(plugin().tm.getTitleIds(), partial); + final Player target = server().getPlayerExact(priorArgs.get(0)); + + return target == null ? List.of() : matching(plugin().tm.getHeldTitleIds(target), partial); } private static List<String> matching(Iterable<String> candidates, String partial) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/title/TitleManager.java b/src/main/java/me/totalfreedom/totalfreedommod/title/TitleManager.java index a6d698c8e..6858788a4 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/title/TitleManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/title/TitleManager.java @@ -73,6 +73,7 @@ public TitleManager(TotalFreedomMod plugin) protected void onStart() { loadTitles(); + plugin.dm.whenReady(this::loadTitles); } @Override @@ -373,16 +374,16 @@ private void reconcileFromJsonIfNewer(final TitleRepository repo) }) .subscribeOn(Schedulers.boundedElastic()) .filter(Boolean::booleanValue) - .flatMapMany(ignored -> + .flatMap(ignored -> { FLog.info(String.format("%s is newer than the database; rebuilding it from the file's %d title(s).", - TITLES_FILENAME, jsonTitles.size())); + TITLES_FILENAME, jsonTitles.size())); return repo.deleteAll() .thenMany(Flux.fromIterable(jsonTitles.values()) - .concatMap(repo::save)); + .concatMap(repo::save)) + .then(Mono.<Void>fromRunnable(() -> plugin.dm.sync("TitleManager/applyReconciled", + () -> applyReconciledTitles(jsonTitles)))); }) - .then(Mono.fromRunnable(() -> plugin.dm.sync("TitleManager/applyReconciled", - () -> applyReconciledTitles(jsonTitles)))) .onErrorResume(ex -> { FLog.warning(String.format("Failed to reconcile %s into the database: %s",