From 791d2219dc25a17fe87e8d20777be58a664ce58a Mon Sep 17 00:00:00 2001 From: winlogon Date: Mon, 23 Jun 2025 02:13:05 +0200 Subject: [PATCH 1/3] chore: add initial interface for storage --- .../org/winlogon/infohub/ChoiceStorage.kt | 36 ++++++++++++++++++ .../org/winlogon/infohub/InfoHubLoader.kt | 37 ++++++++++++------- .../org/winlogon/infohub/InfoHubPlugin.kt | 3 -- 3 files changed, 60 insertions(+), 16 deletions(-) create mode 100644 src/main/kotlin/org/winlogon/infohub/ChoiceStorage.kt diff --git a/src/main/kotlin/org/winlogon/infohub/ChoiceStorage.kt b/src/main/kotlin/org/winlogon/infohub/ChoiceStorage.kt new file mode 100644 index 0000000..7863c7a --- /dev/null +++ b/src/main/kotlin/org/winlogon/infohub/ChoiceStorage.kt @@ -0,0 +1,36 @@ +package org.winlogon.infohub + +import net.kyori.adventure.util.TriState + +import java.nio.file.Path +import java.util.UUID + +interface ChoiceStorage { + // TODO: use my own Result library to check for specific errors? + fun init() + // TODO: should this return a nullable error enum? + fun checkConfig(): Boolean + fun getChoice(playerUuid: UUID): TriState + // TODO: should this return an error? + fun setChoice(playerUuid: UUID, choice: Boolean) +} + +sealed class DataSource { + data class Database(val config: DbConfig) : DataSource() + data class SQLite(val path: Path) : DataSource() + data class Local(val path: Path) : DataSource() +} + +/** Non-embedded RDBMS configuration */ +data class DbConfig( + // Should handle the URL type accordingly + val type: DatabaseType, + val username: String, + val password: String, + val maxConnections: Int +) + +enum class DatabaseType { + POSTGRESQL, + MYSQL, +} diff --git a/src/main/kotlin/org/winlogon/infohub/InfoHubLoader.kt b/src/main/kotlin/org/winlogon/infohub/InfoHubLoader.kt index 6d97c84..391fafd 100644 --- a/src/main/kotlin/org/winlogon/infohub/InfoHubLoader.kt +++ b/src/main/kotlin/org/winlogon/infohub/InfoHubLoader.kt @@ -8,26 +8,37 @@ import org.eclipse.aether.artifact.DefaultArtifact import org.eclipse.aether.graph.Dependency import org.eclipse.aether.repository.RemoteRepository -class ScalaPluginLoader : PluginLoader { +class InfoHubLoader : PluginLoader { override fun classloader(classpathBuilder: PluginClasspathBuilder) { val resolver = MavenLibraryResolver() - resolver.addRepository( - RemoteRepository.Builder( - "central", - "default", - "https://repo.maven.apache.org/maven2/" - ).build() + val repositories = mapOf( + "central" to "https://repo.maven.apache.org/maven2/" ) - resolver.addDependency( - Dependency( - DefaultArtifact("com.github.oshi:oshi-core-java11:6.8.0"), - null - ) + val dependencies = mapOf( + "com.github.oshi:oshi-core-java11" to "6.8.0" ) + repositories.forEach { (name, url) -> + resolver.addRepository( + RemoteRepository.Builder( + name, + "default", + url + ).build() + ) + } + + dependencies.forEach { (package, version) -> + resolver.addDependency( + Dependency( + DefaultArtifact("$package:$version"), + null + ) + ) + } + classpathBuilder.addLibrary(resolver) } } - diff --git a/src/main/kotlin/org/winlogon/infohub/InfoHubPlugin.kt b/src/main/kotlin/org/winlogon/infohub/InfoHubPlugin.kt index 3f83a8b..d67dad4 100644 --- a/src/main/kotlin/org/winlogon/infohub/InfoHubPlugin.kt +++ b/src/main/kotlin/org/winlogon/infohub/InfoHubPlugin.kt @@ -65,12 +65,9 @@ class InfoHubPlugin : JavaPlugin() { logger.info("Starting background hint sender") startSendingHints() - - logger.info("InfoHub has been enabled!") } override fun onDisable() { - logger.info("InfoHub has been disabled!") } private fun startSendingHints() { From ca2a2e1d3d566fbe89477749c1194b29ab4538d7 Mon Sep 17 00:00:00 2001 From: winlogon Date: Wed, 25 Jun 2025 01:12:59 +0200 Subject: [PATCH 2/3] feat: add DB and PDC storage with async support InfoHub now supports storing player hint preferences in either a database (MySQL/PostgreSQL), PDC, or both with automatic synchronization. This provides more flexibility for server admins and better performance with async operations. Summary of changes: - Add AsyncCraftr for abbreviating async task scheduling - Add CombinedChoiceStorage to sync between PDC and database - Add DatabaseChoiceStorage with HikariCP connection pooling - Add PdcChoiceStorage for persistent player data storage - Add storage configuration to config.yml - Implement ChoiceManager to handle storage operations - Improve player UUID handling for ignored players - Refactor InfoHubPlugin to support new storage system - Update ServerStats to use onLoad time for uptime - Update build.gradle.kts with new dependencies --- build.gradle.kts | 16 +- .../org/winlogon/infohub/ChoiceStorage.kt | 34 ++-- .../org/winlogon/infohub/InfoHubLoader.kt | 13 +- .../org/winlogon/infohub/InfoHubPlugin.kt | 188 +++++++++++++----- .../infohub/storage/CombinedChoiceStorage.kt | 27 +++ .../infohub/storage/DatabaseChoiceStorage.kt | 96 +++++++++ .../infohub/storage/PdcChoiceStorage.kt | 37 ++++ .../org/winlogon/infohub/utils/ServerStats.kt | 2 +- src/main/resources/config.yml | 12 ++ 9 files changed, 358 insertions(+), 67 deletions(-) create mode 100644 src/main/kotlin/org/winlogon/infohub/storage/CombinedChoiceStorage.kt create mode 100644 src/main/kotlin/org/winlogon/infohub/storage/DatabaseChoiceStorage.kt create mode 100644 src/main/kotlin/org/winlogon/infohub/storage/PdcChoiceStorage.kt diff --git a/build.gradle.kts b/build.gradle.kts index de4909c..9799530 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -55,16 +55,28 @@ repositories { maven { url = uri("https://repo.codemc.org/repository/maven-public/") } + + maven { + name = "winlogon-libs" + url = uri("https://maven.winlogon.org/releases/") + } + mavenCentral() } dependencies { - compileOnly("io.papermc.paper:paper-api:1.21.5-R0.1-SNAPSHOT") + compileOnly("io.papermc.paper:paper-api:1.21.6-R0.1-SNAPSHOT") compileOnly("dev.jorel:commandapi-bukkit-core:10.0.1") compileOnly("com.github.oshi:oshi-core-java11:6.8.0") + + compileOnly("com.zaxxer:HikariCP:6.3.0") + compileOnly("org.postgresql:postgresql:42.7.7") + compileOnly("com.mysql:mysql-connector-j:9.3.0") + + compileOnly("org.winlogon:asynccraftr:0.1.0") testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.11.4") - testImplementation("io.papermc.paper:paper-api:1.21.4-R0.1-SNAPSHOT") + testImplementation("io.papermc.paper:paper-api:1.21.6-R0.1-SNAPSHOT") testImplementation("org.junit.jupiter:junit-jupiter:5.11.4") } diff --git a/src/main/kotlin/org/winlogon/infohub/ChoiceStorage.kt b/src/main/kotlin/org/winlogon/infohub/ChoiceStorage.kt index 7863c7a..88c5c7d 100644 --- a/src/main/kotlin/org/winlogon/infohub/ChoiceStorage.kt +++ b/src/main/kotlin/org/winlogon/infohub/ChoiceStorage.kt @@ -1,36 +1,42 @@ package org.winlogon.infohub import net.kyori.adventure.util.TriState - import java.nio.file.Path import java.util.UUID +import java.time.Duration interface ChoiceStorage { - // TODO: use my own Result library to check for specific errors? - fun init() - // TODO: should this return a nullable error enum? - fun checkConfig(): Boolean + fun init() {} + fun isConfigOkay(): Boolean = true fun getChoice(playerUuid: UUID): TriState - // TODO: should this return an error? fun setChoice(playerUuid: UUID, choice: Boolean) } sealed class DataSource { - data class Database(val config: DbConfig) : DataSource() - data class SQLite(val path: Path) : DataSource() - data class Local(val path: Path) : DataSource() + data class Database( + val config: DatabaseMetadata, + val backupInterval: Duration? = null + ) : DataSource() + + data class World(val pdcConfig: PdcConfig) : DataSource() } -/** Non-embedded RDBMS configuration */ -data class DbConfig( - // Should handle the URL type accordingly +data class DatabaseMetadata( val type: DatabaseType, + val host: String, + val port: Int, + val database: String, val username: String, val password: String, - val maxConnections: Int + val table: String = "infohub_choices" +) + +data class PdcConfig( + val backupInterval: Duration?, + val usesDatabase: Boolean ) enum class DatabaseType { POSTGRESQL, - MYSQL, + MYSQL } diff --git a/src/main/kotlin/org/winlogon/infohub/InfoHubLoader.kt b/src/main/kotlin/org/winlogon/infohub/InfoHubLoader.kt index 391fafd..f3654eb 100644 --- a/src/main/kotlin/org/winlogon/infohub/InfoHubLoader.kt +++ b/src/main/kotlin/org/winlogon/infohub/InfoHubLoader.kt @@ -13,11 +13,16 @@ class InfoHubLoader : PluginLoader { val resolver = MavenLibraryResolver() val repositories = mapOf( - "central" to "https://repo.maven.apache.org/maven2/" + "central" to MavenLibraryResolver.MAVEN_CENTRAL_DEFAULT_MIRROR, + "winlogon-libs" to "https://maven.winlogon.org/releases/", ) val dependencies = mapOf( - "com.github.oshi:oshi-core-java11" to "6.8.0" + "com.github.oshi:oshi-core-java11" to "6.8.0", + "com.zaxxer:HikariCP" to "6.3.0", + "org.postgresql:postgresql" to "42.7.7", + "com.mysql:mysql-connector-j" to "9.3.0", + "org.winlogon:asynccraftr" to "0.1.0", ) repositories.forEach { (name, url) -> @@ -30,10 +35,10 @@ class InfoHubLoader : PluginLoader { ) } - dependencies.forEach { (package, version) -> + dependencies.forEach { (dependencyPackage, version) -> resolver.addDependency( Dependency( - DefaultArtifact("$package:$version"), + DefaultArtifact("$dependencyPackage:$version"), null ) ) diff --git a/src/main/kotlin/org/winlogon/infohub/InfoHubPlugin.kt b/src/main/kotlin/org/winlogon/infohub/InfoHubPlugin.kt index d67dad4..4741bb3 100644 --- a/src/main/kotlin/org/winlogon/infohub/InfoHubPlugin.kt +++ b/src/main/kotlin/org/winlogon/infohub/InfoHubPlugin.kt @@ -11,14 +11,25 @@ import dev.jorel.commandapi.executors.PlayerCommandExecutor import net.kyori.adventure.text.Component import net.kyori.adventure.text.minimessage.MiniMessage import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer +import net.kyori.adventure.util.TriState import org.bukkit.Bukkit import org.bukkit.command.CommandSender import org.bukkit.entity.Player import org.bukkit.plugin.java.JavaPlugin +import org.winlogon.asynccraftr.AsyncCraftr import org.winlogon.infohub.utils.HintHandler import org.winlogon.infohub.utils.ServerStats + +import org.winlogon.infohub.storage.CombinedChoiceStorage +import org.winlogon.infohub.storage.DatabaseChoiceStorage +import org.winlogon.infohub.storage.PdcChoiceStorage + +import java.time.Duration +import java.time.temporal.ChronoUnit +import java.util.UUID import java.util.function.Consumer +import kotlin.text.uppercase class InfoHubPlugin : JavaPlugin() { private lateinit var rules: List @@ -30,30 +41,59 @@ class InfoHubPlugin : JavaPlugin() { private var warnUserAboutPing: Boolean = false private val hintList: MutableList = mutableListOf() - private val ignoredPlayers: MutableList = mutableListOf() + private lateinit var choiceManager: ChoiceManager + private val ignoredPlayers: MutableSet = mutableSetOf() private var startTime: Long = 0 - private var isFolia: Boolean = false + public val isFolia: Boolean = try { + Class.forName("io.papermc.paper.threadedregions.RegionizedServer") + true + } catch(e: ClassNotFoundException) { + false + } private val miniMessage = MiniMessage.miniMessage() private val playerLogger = PlayerLogger() private val emojiList: List = listOf("💡", "📝", "🔍", "📌", "💬", "📖", "🎯") private val random = java.security.SecureRandom() + private val tableRegex = Regex("[a-zA-Z0-9_]+") override fun onLoad() { startTime = System.nanoTime() } + private fun setupChoiceStorage() { + val storageConfig = config.storageConfig + val storage: ChoiceStorage = when (storageConfig.mode) { + "pdc" -> createPdcStorage() + "database" -> createDatabaseStorage(storageConfig.databaseConfig) + "both" -> createCombinedStorage(storageConfig) + else -> throw IllegalArgumentException("Invalid storage mode: ${storageConfig.mode}") + } + + choiceManager = ChoiceManager(storage, this, storageConfig.backupInterval) + choiceManager.start() + } + + private fun createPdcStorage(): ChoiceStorage { + return PdcChoiceStorage(this) + } + + private fun createDatabaseStorage(dbConfig: DatabaseMetadata?): ChoiceStorage { + requireNotNull(dbConfig) { "Database configuration is required for database storage mode" } + return DatabaseChoiceStorage(dbConfig).apply { init() } + } + + private fun createCombinedStorage(storageConfig: StorageConfig): ChoiceStorage { + val pdcStorage = PdcChoiceStorage(this) + val dbStorage = createDatabaseStorage(storageConfig.databaseConfig) + return CombinedChoiceStorage(pdcStorage, dbStorage) + } + override fun onEnable() { saveDefaultConfig() reloadConfig() config = loadConfig() - isFolia = try { - Class.forName("io.papermc.paper.threadedregions.RegionizedServer") - true - } catch(e: ClassNotFoundException) { - false - } val hintConfig = HintConfig( hintList = config.hintList, @@ -64,6 +104,8 @@ class InfoHubPlugin : JavaPlugin() { registerCommands() logger.info("Starting background hint sender") + + setupChoiceStorage() startSendingHints() } @@ -71,37 +113,51 @@ class InfoHubPlugin : JavaPlugin() { } private fun startSendingHints() { - val baseDur = (60 * 1000) - // from 5 to 20 minutes as Minecraft ticks - val randDur = random.nextInt(5 * baseDur, 20 * baseDur) / 50 - val randomTime = randDur.toLong() - - val task = Runnable { - // run the hint task again after running it - hintHandler.sendRandomHint(Bukkit.getOnlinePlayers().toList(), ignoredPlayers) - startSendingHints() - } + val minutes = random.nextInt(5, 20) + val delay = Duration.ofMinutes(minutes.toLong()) - if (isFolia) { - val scheduler = Bukkit.getServer().getGlobalRegionScheduler() - // needed because the task is repeated - scheduler.runDelayed(this, Consumer { _ -> - task.run() - }, randomTime) - } else { - Bukkit.getScheduler().runTaskLaterAsynchronously(this, task, randomTime) - } + AsyncCraftr.runAsyncTaskLater(this, { + val allPlayers = Bukkit.getOnlinePlayers().toList() + val ignoredPlayersList = ignoredPlayers.mapNotNull { Bukkit.getPlayer(it) } + hintHandler.sendRandomHint(allPlayers, ignoredPlayersList) + startSendingHints() + }, delay) } private fun loadConfig(): Config { logger.info("Loading configuration...") val bukkitConfig = getConfig() + + // get configuration storage: and deeper from config.yml + val storageSection = bukkitConfig.getConfigurationSection("storage") + + val storageConfig = StorageConfig( + mode = storageSection?.getString("mode") ?: "pdc", + backupInterval = storageSection?.getString("backupInterval")?.let { + Duration.parse("PT${it.uppercase()}") + }, + databaseConfig = storageSection?.getConfigurationSection("database")?.let { dbSection -> + val databaseType = dbSection.getString("type") ?: "MYSQL" + DatabaseMetadata( + type = DatabaseType.valueOf(databaseType.uppercase()), + host = dbSection.getString("host") ?: "localhost", + port = dbSection.getInt("port", 3306), + database = dbSection.getString("database") ?: "minecraft", + username = dbSection.getString("username") ?: "root", + password = dbSection.getString("password") ?: "", + // get table name, and if it doesn't look correct, default to infohub_choices + table = dbSection.getString("table")?.takeIf { it.matches(tableRegex) } ?: "infohub_choices" + ) + } + ) + return Config( discordLink = bukkitConfig.getString("discord-link") ?: discordLink, rules = bukkitConfig.getStringList("rules").takeIf { it.isNotEmpty() } ?: emptyList(), helpMessage = bukkitConfig.getString("help-message") ?: helpMessage, warnUserAboutPing = bukkitConfig.getBoolean("warn-user-ping", warnUserAboutPing), - hintList = bukkitConfig.getStringList("hint-list").takeIf { it.isNotEmpty() } ?: emptyList() + hintList = bukkitConfig.getStringList("hint-list").takeIf { it.isNotEmpty() } ?: emptyList(), + storageConfig = storageConfig ) } @@ -117,13 +173,18 @@ class InfoHubPlugin : JavaPlugin() { CommandAPICommand("specs") .executes(CommandExecutor { sender, _ -> val specs = ServerStats.getSystemSpecs() - playerLogger.normal(sender, "Server Specs") - playerLogger.normal(sender, "- OS: ${specs.operatingSystem}") - playerLogger.normal(sender, "- Processor: ${specs.processor}") - playerLogger.normal(sender, "- Physical Cores: ${specs.physicalCores}") - playerLogger.normal(sender, "- Logical Cores: ${specs.logicalCores}") - playerLogger.normal(sender, "- Total Memory: ${specs.totalMemory} GB") - playerLogger.normal(sender, "- Available Memory: ${specs.availableMemory} GB") + playerLogger.normal( + sender, + arrayOf( + "Server Specs", + "- OS: ${specs.operatingSystem}", + "- Processor: ${specs.processor}", + "- Physical Cores: ${specs.physicalCores}", + "- Logical Cores: ${specs.logicalCores}", + "- Total Memory: ${specs.totalMemory} GB", + "- Available Memory: ${specs.availableMemory} GB" + ) + ) }) .register() @@ -174,24 +235,18 @@ class InfoHubPlugin : JavaPlugin() { }) .register() - CommandAPICommand("hint") + CommandAPICommand("hint") .withSubcommands( CommandAPICommand("disable") .executesPlayer(PlayerCommandExecutor { sender, _ -> - if (ignoredPlayers.contains(sender)) { - playerLogger.normal(sender, "Hints are already disabled for you.") - return@PlayerCommandExecutor - } - ignoredPlayers.add(sender) + choiceManager.setChoice(sender.uniqueId, false) + ignoredPlayers.add(sender.uniqueId) playerLogger.normal(sender, "Got it! Hints are now disabled for you.") }), CommandAPICommand("enable") .executesPlayer(PlayerCommandExecutor { sender, _ -> - if (!ignoredPlayers.contains(sender)) { - playerLogger.normal(sender, "Hints are already enabled for you.") - return@PlayerCommandExecutor - } - ignoredPlayers.remove(sender) + choiceManager.setChoice(sender.uniqueId, true) + ignoredPlayers.remove(sender.uniqueId) playerLogger.normal(sender, "Okay, hints are enabled now.") }) ) @@ -216,6 +271,11 @@ class PlayerLogger { player.sendMessage(formatMessage(prefix + message)) } + public fun normal(player: CommandSender, messages: Array) { + prefix = "" + messages.forEach { player.sendMessage(formatMessage(prefix + it)) } + } + public fun debug(player: CommandSender, message: String) { prefix = "[<#3590B2>DEBUG] " player.sendMessage(formatMessage(prefix + message)) @@ -237,15 +297,51 @@ class PlayerLogger { } } + data class Config( val discordLink: String, val rules: List, val helpMessage: String, val warnUserAboutPing: Boolean, val hintList: List, + val storageConfig: StorageConfig ) data class HintConfig( val hintList: List, val iconEmojis: List, ) + + +data class StorageConfig( + val mode: String, // "pdc", "database", or "both" + val databaseConfig: DatabaseMetadata? = null, + val backupInterval: Duration? = null +) + +class ChoiceManager( + private val storage: ChoiceStorage, + private val plugin: InfoHubPlugin, + private val backupInterval: Duration? +) { + private val taskId = -1 + + fun getChoice(playerUuid: UUID): TriState { + return storage.getChoice(playerUuid) + } + + fun setChoice(playerUuid: UUID, choice: Boolean) { + storage.setChoice(playerUuid, choice) + } + + fun start() { + if (backupInterval != null && storage is DatabaseChoiceStorage) { + AsyncCraftr.runAsyncTaskTimer( + plugin, + { storage.processQueue() }, + backupInterval, + backupInterval + ) + } + } +} diff --git a/src/main/kotlin/org/winlogon/infohub/storage/CombinedChoiceStorage.kt b/src/main/kotlin/org/winlogon/infohub/storage/CombinedChoiceStorage.kt new file mode 100644 index 0000000..421d920 --- /dev/null +++ b/src/main/kotlin/org/winlogon/infohub/storage/CombinedChoiceStorage.kt @@ -0,0 +1,27 @@ +package org.winlogon.infohub.storage + +import net.kyori.adventure.util.TriState +import org.bukkit.Bukkit +import org.bukkit.OfflinePlayer +import org.winlogon.infohub.ChoiceStorage +import java.util.UUID + +class CombinedChoiceStorage( + private val pdcStorage: ChoiceStorage, + private val dbStorage: ChoiceStorage +) : ChoiceStorage { + + override fun getChoice(playerUuid: UUID): TriState { + val pdcChoice = pdcStorage.getChoice(playerUuid) + return if (pdcChoice != TriState.NOT_SET) { + pdcChoice + } else { + dbStorage.getChoice(playerUuid) + } + } + + override fun setChoice(playerUuid: UUID, choice: Boolean) { + pdcStorage.setChoice(playerUuid, choice) + dbStorage.setChoice(playerUuid, choice) + } +} diff --git a/src/main/kotlin/org/winlogon/infohub/storage/DatabaseChoiceStorage.kt b/src/main/kotlin/org/winlogon/infohub/storage/DatabaseChoiceStorage.kt new file mode 100644 index 0000000..17c2b56 --- /dev/null +++ b/src/main/kotlin/org/winlogon/infohub/storage/DatabaseChoiceStorage.kt @@ -0,0 +1,96 @@ +package org.winlogon.infohub.storage + +import net.kyori.adventure.util.TriState +import org.winlogon.infohub.ChoiceStorage +import org.winlogon.infohub.DatabaseMetadata +import org.winlogon.infohub.DatabaseType +import java.sql.* +import java.util.UUID +import java.util.concurrent.ConcurrentLinkedQueue +import javax.sql.DataSource + +class DatabaseChoiceStorage(private val config: DatabaseMetadata) : ChoiceStorage { + private lateinit var dataSource: DataSource + private val taskQueue = ConcurrentLinkedQueue>() + + override fun init() { + val jdbcUrl = when (config.type) { + DatabaseType.MYSQL -> "jdbc:mysql://${config.host}:${config.port}/${config.database}" + DatabaseType.POSTGRESQL -> "jdbc:postgresql://${config.host}:${config.port}/${config.database}" + } + + dataSource = when (config.type) { + DatabaseType.MYSQL -> { + Class.forName("com.mysql.cj.jdbc.Driver") + com.zaxxer.hikari.HikariDataSource().apply { + setJdbcUrl(jdbcUrl) + username = config.username + password = config.password + maximumPoolSize = 3 + } + } + DatabaseType.POSTGRESQL -> { + Class.forName("org.postgresql.Driver") + org.postgresql.ds.PGSimpleDataSource().apply { + setURL(jdbcUrl) + user = config.username + password = config.password + } + } + } + + createTableIfNotExists() + } + + private fun createTableIfNotExists() { + dataSource.connection.use { conn -> + val sql = """ + CREATE TABLE IF NOT EXISTS ${config.table} ( + player_uuid VARCHAR(36) PRIMARY KEY, + enabled BOOLEAN NOT NULL + ) + """.trimIndent() + conn.createStatement().execute(sql) + } + } + + override fun getChoice(playerUuid: UUID): TriState { + dataSource.connection.use { conn -> + val sql = "SELECT enabled FROM ${config.table} WHERE player_uuid = ?" + conn.prepareStatement(sql).use { stmt -> + stmt.setString(1, playerUuid.toString()) + stmt.executeQuery().use { rs -> + return if (rs.next()) { + TriState.byBoolean(rs.getBoolean("enabled")) + } else { + TriState.NOT_SET + } + } + } + } + } + + override fun setChoice(playerUuid: UUID, choice: Boolean) { + taskQueue.add(playerUuid to choice) + } + + fun processQueue() { + while (taskQueue.isNotEmpty()) { + val (uuid, choice) = taskQueue.poll() + dataSource.connection.use { conn -> + val sql = """ + INSERT INTO ${config.table} (player_uuid, enabled) + VALUES (?, ?) + ON CONFLICT (player_uuid) + DO UPDATE SET enabled = EXCLUDED.enabled + """.trimIndent() + + conn.prepareStatement(sql).use { stmt -> + stmt.setString(1, uuid.toString()) + stmt.setBoolean(2, choice) + stmt.executeUpdate() + } + } + } + } +} diff --git a/src/main/kotlin/org/winlogon/infohub/storage/PdcChoiceStorage.kt b/src/main/kotlin/org/winlogon/infohub/storage/PdcChoiceStorage.kt new file mode 100644 index 0000000..45e73b0 --- /dev/null +++ b/src/main/kotlin/org/winlogon/infohub/storage/PdcChoiceStorage.kt @@ -0,0 +1,37 @@ +package org.winlogon.infohub.storage + +import net.kyori.adventure.util.TriState + +import org.bukkit.Bukkit +import org.bukkit.NamespacedKey +import org.bukkit.OfflinePlayer +import org.bukkit.persistence.PersistentDataType +import org.winlogon.infohub.ChoiceStorage +import org.winlogon.infohub.InfoHubPlugin + +import java.util.UUID + +class PdcChoiceStorage(private val plugin: InfoHubPlugin) : ChoiceStorage { + private val key = NamespacedKey(plugin, "${plugin.name.lowercase()}-hint-enabled") + private val logger = plugin.logger + + override fun getChoice(playerUuid: UUID): TriState { + val player = Bukkit.getOfflinePlayer(playerUuid) + val container = player.persistentDataContainer + + return if (container.has(key, PersistentDataType.BOOLEAN)) { + val value = container.get(key, PersistentDataType.BOOLEAN) ?: return TriState.NOT_SET + TriState.byBoolean(value) + } else { + TriState.NOT_SET + } + } + + override fun setChoice(playerUuid: UUID, choice: Boolean) { + val player = Bukkit.getPlayer(playerUuid) ?: run { + logger.warning("Failed to set PDC choice for offline player $playerUuid") + return + } + player.persistentDataContainer.set(key, PersistentDataType.BOOLEAN, choice) + } +} diff --git a/src/main/kotlin/org/winlogon/infohub/utils/ServerStats.kt b/src/main/kotlin/org/winlogon/infohub/utils/ServerStats.kt index d984db0..50a7900 100644 --- a/src/main/kotlin/org/winlogon/infohub/utils/ServerStats.kt +++ b/src/main/kotlin/org/winlogon/infohub/utils/ServerStats.kt @@ -48,7 +48,7 @@ object ServerStats { } /** - * Get the uptime of the server based on the time that passed from onEnable + * Get the uptime of the server based on the time that passed from onLoad * * @param start The start time of the server, from when this plugin is enabled * @param end The moment the command is run diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index fd97c0a..47971a8 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -21,3 +21,15 @@ hint-list: # Ideally it means someone's lagging a lot, and the measurement changes after some seconds-ish. # You can choose to warn users about this or not if a player has 0ms. warn-user-ping: false + +storage: + mode: "both" + backupInterval: 5m + database: + type: "MYSQL" + host: "localhost" + port: 3306 + database: "minecraft" + username: "root" + password: "password" + table: "player_choices" From bec0852bfb04fcf46b33d911b5c2e70208a6b60b Mon Sep 17 00:00:00 2001 From: winlogon Date: Mon, 30 Jun 2025 02:04:24 +0200 Subject: [PATCH 3/3] feat: cache players with redis --- build.gradle.kts | 1 + .../org/winlogon/infohub/InfoHubLoader.kt | 3 + .../org/winlogon/infohub/InfoHubPlugin.kt | 10 ++-- .../infohub/storage/PdcChoiceStorage.kt | 56 ++++++++++++++----- .../winlogon/infohub/storage/RedisManager.kt | 9 +++ .../infohub/storage/RedisPlayerCache.kt | 20 +++++++ src/main/resources/config.yml | 4 +- 7 files changed, 84 insertions(+), 19 deletions(-) create mode 100644 src/main/kotlin/org/winlogon/infohub/storage/RedisManager.kt create mode 100644 src/main/kotlin/org/winlogon/infohub/storage/RedisPlayerCache.kt diff --git a/build.gradle.kts b/build.gradle.kts index 9799530..33e0f29 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -72,6 +72,7 @@ dependencies { compileOnly("com.zaxxer:HikariCP:6.3.0") compileOnly("org.postgresql:postgresql:42.7.7") compileOnly("com.mysql:mysql-connector-j:9.3.0") + compileOnly("io.lettuce:lettuce-core:6.7.1.RELEASE") compileOnly("org.winlogon:asynccraftr:0.1.0") diff --git a/src/main/kotlin/org/winlogon/infohub/InfoHubLoader.kt b/src/main/kotlin/org/winlogon/infohub/InfoHubLoader.kt index f3654eb..9f7b48c 100644 --- a/src/main/kotlin/org/winlogon/infohub/InfoHubLoader.kt +++ b/src/main/kotlin/org/winlogon/infohub/InfoHubLoader.kt @@ -19,9 +19,12 @@ class InfoHubLoader : PluginLoader { val dependencies = mapOf( "com.github.oshi:oshi-core-java11" to "6.8.0", + "com.zaxxer:HikariCP" to "6.3.0", "org.postgresql:postgresql" to "42.7.7", "com.mysql:mysql-connector-j" to "9.3.0", + "io.lettuce:lettuce-core" to "6.7.1.RELEASE", + "org.winlogon:asynccraftr" to "0.1.0", ) diff --git a/src/main/kotlin/org/winlogon/infohub/InfoHubPlugin.kt b/src/main/kotlin/org/winlogon/infohub/InfoHubPlugin.kt index 4741bb3..8b77d67 100644 --- a/src/main/kotlin/org/winlogon/infohub/InfoHubPlugin.kt +++ b/src/main/kotlin/org/winlogon/infohub/InfoHubPlugin.kt @@ -76,7 +76,7 @@ class InfoHubPlugin : JavaPlugin() { } private fun createPdcStorage(): ChoiceStorage { - return PdcChoiceStorage(this) + return PdcChoiceStorage(this, config.storageConfig.redisUri) } private fun createDatabaseStorage(dbConfig: DatabaseMetadata?): ChoiceStorage { @@ -85,7 +85,7 @@ class InfoHubPlugin : JavaPlugin() { } private fun createCombinedStorage(storageConfig: StorageConfig): ChoiceStorage { - val pdcStorage = PdcChoiceStorage(this) + val pdcStorage = PdcChoiceStorage(this, config.storageConfig.redisUri) val dbStorage = createDatabaseStorage(storageConfig.databaseConfig) return CombinedChoiceStorage(pdcStorage, dbStorage) } @@ -148,7 +148,8 @@ class InfoHubPlugin : JavaPlugin() { // get table name, and if it doesn't look correct, default to infohub_choices table = dbSection.getString("table")?.takeIf { it.matches(tableRegex) } ?: "infohub_choices" ) - } + }, + redisUri = storageSection?.getString("redis-uri") ?: "redis://localhost:6379" ) return Config( @@ -316,7 +317,8 @@ data class HintConfig( data class StorageConfig( val mode: String, // "pdc", "database", or "both" val databaseConfig: DatabaseMetadata? = null, - val backupInterval: Duration? = null + val backupInterval: Duration? = null, + val redisUri: String ) class ChoiceManager( diff --git a/src/main/kotlin/org/winlogon/infohub/storage/PdcChoiceStorage.kt b/src/main/kotlin/org/winlogon/infohub/storage/PdcChoiceStorage.kt index 45e73b0..f599951 100644 --- a/src/main/kotlin/org/winlogon/infohub/storage/PdcChoiceStorage.kt +++ b/src/main/kotlin/org/winlogon/infohub/storage/PdcChoiceStorage.kt @@ -10,28 +10,58 @@ import org.winlogon.infohub.ChoiceStorage import org.winlogon.infohub.InfoHubPlugin import java.util.UUID +import java.util.concurrent.ConcurrentHashMap -class PdcChoiceStorage(private val plugin: InfoHubPlugin) : ChoiceStorage { +class PlayerInformationCache { + private val playerCache = ConcurrentHashMap() + + fun put(uuid: UUID, player: OfflinePlayer) { + playerCache.put(uuid, player) + } + + fun get(uuid: UUID): OfflinePlayer? { + return playerCache[uuid] + } + + fun evict(uuid: UUID) { + playerCache.remove(uuid) + } +} + +class PdcChoiceStorage( + private val plugin: InfoHubPlugin, + redisUri: String +) : ChoiceStorage { private val key = NamespacedKey(plugin, "${plugin.name.lowercase()}-hint-enabled") private val logger = plugin.logger + private val redisManager = RedisManager(redisUri) + private val cache = RedisPlayerCache(redisManager.conn.sync()) override fun getChoice(playerUuid: UUID): TriState { - val player = Bukkit.getOfflinePlayer(playerUuid) + // get the name from cache + val name = cache.get(playerUuid) + + // get the player from server cache (the player must have joined at this point) - see setChoice + val player = if (name != null) Bukkit.getOfflinePlayerIfCached(name)!! + else Bukkit.getOfflinePlayer(playerUuid) + + cache.put(playerUuid, player.uniqueId.toString()) + val container = player.persistentDataContainer - - return if (container.has(key, PersistentDataType.BOOLEAN)) { - val value = container.get(key, PersistentDataType.BOOLEAN) ?: return TriState.NOT_SET - TriState.byBoolean(value) - } else { - TriState.NOT_SET - } + return if (container.has(key, PersistentDataType.BOOLEAN)) { + // get whether the player has the hint enabled + container.get(key, PersistentDataType.BOOLEAN) + ?.let { TriState.byBoolean(it) } ?: TriState.NOT_SET + } else TriState.NOT_SET } override fun setChoice(playerUuid: UUID, choice: Boolean) { - val player = Bukkit.getPlayer(playerUuid) ?: run { - logger.warning("Failed to set PDC choice for offline player $playerUuid") - return + Bukkit.getPlayer(playerUuid)?.let { player -> + player.persistentDataContainer.set(key, PersistentDataType.BOOLEAN, choice) + cache.put(playerUuid, player.uniqueId.toString()) + } ?: run { + logger.warning("Failed to set choice for offline $playerUuid") } - player.persistentDataContainer.set(key, PersistentDataType.BOOLEAN, choice) } } + diff --git a/src/main/kotlin/org/winlogon/infohub/storage/RedisManager.kt b/src/main/kotlin/org/winlogon/infohub/storage/RedisManager.kt new file mode 100644 index 0000000..0116d59 --- /dev/null +++ b/src/main/kotlin/org/winlogon/infohub/storage/RedisManager.kt @@ -0,0 +1,9 @@ +package org.winlogon.infohub.storage + +import io.lettuce.core.RedisClient +import io.lettuce.core.api.StatefulRedisConnection + +class RedisManager(uri: String) { + private val client = RedisClient.create(uri) + val conn: StatefulRedisConnection = client.connect() +} diff --git a/src/main/kotlin/org/winlogon/infohub/storage/RedisPlayerCache.kt b/src/main/kotlin/org/winlogon/infohub/storage/RedisPlayerCache.kt new file mode 100644 index 0000000..a06e630 --- /dev/null +++ b/src/main/kotlin/org/winlogon/infohub/storage/RedisPlayerCache.kt @@ -0,0 +1,20 @@ +package org.winlogon.infohub.storage + +import io.lettuce.core.api.sync.RedisCommands +import java.util.UUID + +class RedisPlayerCache(redisCommands: RedisCommands) { + private val redis = redisCommands + + fun put(uuid: UUID, name: String) { + redis.set("player:$uuid", name) + } + + fun get(uuid: UUID): String? { + return redis.get("player:$uuid") + } + + fun evict(uuid: UUID) { + redis.del("player:$uuid") + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 47971a8..140df23 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -26,9 +26,9 @@ storage: mode: "both" backupInterval: 5m database: - type: "MYSQL" + type: "POSTGRESQL" host: "localhost" - port: 3306 + port: 5432 database: "minecraft" username: "root" password: "password"