From 83132bf2a4881904d5213c3392cd98531ad77988 Mon Sep 17 00:00:00 2001 From: HaHaWTH <102713261+HaHaWTH@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:41:06 +0800 Subject: [PATCH 1/2] experimental: tiered detection --- common/build.gradle.kts | 2 +- gradle.properties | 4 + paper/build.gradle.kts | 13 +- .../asw/bukkit/AdvancedSensitiveWords.java | 115 +++- .../java/io/wdsj/asw/bukkit/api/CoreApi.java | 4 +- .../bukkit/command/AswCommandRegistrar.java | 55 +- .../asw/bukkit/command/AswCommandService.java | 91 ++- .../bukkit/command/CommandPermissions.java | 3 + .../io/wdsj/asw/bukkit/method/CharIgnore.java | 21 +- .../bukkit/persistence/DataSourceFactory.java | 68 +++ .../asw/bukkit/persistence/FlushPolicy.java | 15 + .../asw/bukkit/persistence/StorageConfig.java | 78 +++ .../asw/bukkit/persistence/StorageType.java | 6 + .../bukkit/persistence/WriteBackCache.java | 458 +++++++++++++++ .../persistence/WriteBackRepository.java | 88 +++ .../asw/bukkit/playergroup/GroupModule.java | 10 + .../bukkit/playergroup/GroupModuleMode.java | 7 + .../playergroup/PlayerActivitySnapshot.java | 22 + .../asw/bukkit/playergroup/PlayerGroup.java | 19 + .../playergroup/PlayerGroupService.java | 549 ++++++++++++++++++ .../bukkit/playergroup/PlayerGroupSource.java | 6 + .../bukkit/playergroup/PlayerGroupState.java | 21 + .../PlayerGroupStateRepository.java | 168 ++++++ .../bukkit/playergroup/PlayerGroupStatus.java | 9 + .../asw/bukkit/service/ListenerService.java | 11 + .../setting/ChineseMessagesConfiguration.java | 15 + .../bukkit/setting/MessagesConfiguration.java | 23 + .../setting/PaperConfigurationService.java | 83 +++ .../asw/bukkit/setting/PluginMessages.java | 8 + .../asw/bukkit/setting/PluginSettings.java | 2 + .../bukkit/setting/SettingsConfiguration.java | 148 ++++- .../wdsj/asw/bukkit/listener/AnvilListener.kt | 11 +- .../wdsj/asw/bukkit/listener/BookListener.kt | 25 +- .../asw/bukkit/listener/BroadcastListener.kt | 6 +- .../asw/bukkit/listener/CommandListener.kt | 31 +- .../bukkit/listener/PlayerGroupListener.kt | 13 + .../asw/bukkit/listener/PlayerItemListener.kt | 19 +- .../bukkit/listener/PlayerLoginListener.kt | 4 +- .../asw/bukkit/listener/QuitDataCleaner.kt | 2 + .../wdsj/asw/bukkit/listener/SignListener.kt | 19 +- .../listener/paper/PaperChatListener.kt | 31 +- .../asw/bukkit/util/PlayerProcessingGuard.kt | 7 + paper/src/main/resources/plugin.yml | 14 + .../persistence/WriteBackCacheStressTest.java | 261 +++++++++ 44 files changed, 2496 insertions(+), 69 deletions(-) create mode 100644 paper/src/main/java/io/wdsj/asw/bukkit/persistence/DataSourceFactory.java create mode 100644 paper/src/main/java/io/wdsj/asw/bukkit/persistence/FlushPolicy.java create mode 100644 paper/src/main/java/io/wdsj/asw/bukkit/persistence/StorageConfig.java create mode 100644 paper/src/main/java/io/wdsj/asw/bukkit/persistence/StorageType.java create mode 100644 paper/src/main/java/io/wdsj/asw/bukkit/persistence/WriteBackCache.java create mode 100644 paper/src/main/java/io/wdsj/asw/bukkit/persistence/WriteBackRepository.java create mode 100644 paper/src/main/java/io/wdsj/asw/bukkit/playergroup/GroupModule.java create mode 100644 paper/src/main/java/io/wdsj/asw/bukkit/playergroup/GroupModuleMode.java create mode 100644 paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerActivitySnapshot.java create mode 100644 paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroup.java create mode 100644 paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroupService.java create mode 100644 paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroupSource.java create mode 100644 paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroupState.java create mode 100644 paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroupStateRepository.java create mode 100644 paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroupStatus.java create mode 100644 paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/PlayerGroupListener.kt create mode 100644 paper/src/test/java/io/wdsj/asw/bukkit/persistence/WriteBackCacheStressTest.java diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 5416a570..7b11e47f 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -12,7 +12,7 @@ java { dependencies { compileOnly("org.jetbrains:annotations:26.1.0") compileOnly("com.google.code.gson:gson:2.12.1") - compileOnly("com.google.guava:guava:33.4.0-jre") + compileOnly("com.google.guava:guava:${property("guavaVersion")}") } buildConfig { diff --git a/gradle.properties b/gradle.properties index dee1f2d8..40017098 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,6 +6,10 @@ pluginVersion=2.1.0 versionChannel=release # dependencies +paperVersion=1.21.11-R0.1-SNAPSHOT cloudVersion=2.0.0-beta.15 langchain4jVersion=1.16.3 jmhVersion=1.37 +caffeineVersion=3.2.4 +hikariCPVersion=7.1.0 +guavaVersion=33.4.0-jre diff --git a/paper/build.gradle.kts b/paper/build.gradle.kts index 24b44434..45e68ae7 100644 --- a/paper/build.gradle.kts +++ b/paper/build.gradle.kts @@ -36,13 +36,15 @@ dependencies { implementation("org.incendo:cloud-paper:${property("cloudVersion")}") implementation("org.incendo:cloud-minecraft-extras:${property("cloudVersion")}") - compileOnly("io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT") + compileOnly("io.papermc.paper:paper-api:${property("paperVersion")}") compileOnly("me.clip:placeholderapi:2.12.2") compileOnly("org.geysermc.floodgate:api:2.2.5-SNAPSHOT") compileOnly("fr.xephi:authme:5.7.0-SNAPSHOT") compileOnly("com.github.retrooper:packetevents-spigot:2.13.0") compileOnly("org.jetbrains:annotations:26.1.0") compileOnly("com.google.guava:guava:33.4.0-jre") + compileOnly("com.github.ben-manes.caffeine:caffeine:${property("caffeineVersion")}") + compileOnly("com.zaxxer:HikariCP:${property("hikariCPVersion")}") compileOnly("dev.langchain4j:langchain4j-open-ai:${property("langchain4jVersion")}") compileOnly("dev.langchain4j:langchain4j-anthropic:${property("langchain4jVersion")}") compileOnly("dev.langchain4j:langchain4j-http-client-jdk:${property("langchain4jVersion")}") @@ -55,15 +57,18 @@ dependencies { runtimeOnly("org.snakeyaml:snakeyaml-engine:2.7") testImplementation("org.junit.jupiter:junit-jupiter:6.1.1") - testImplementation("io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT") + testImplementation("io.papermc.paper:paper-api:${property("paperVersion")}") testImplementation("dev.langchain4j:langchain4j-open-ai:${property("langchain4jVersion")}") testImplementation("dev.langchain4j:langchain4j-anthropic:${property("langchain4jVersion")}") testImplementation("dev.langchain4j:langchain4j-http-client-jdk:${property("langchain4jVersion")}") + testImplementation("com.zaxxer:HikariCP:${property("hikariCPVersion")}") + testImplementation("com.github.ben-manes.caffeine:caffeine:${property("caffeineVersion")}") + testRuntimeOnly("org.slf4j:slf4j-nop:2.0.17") testRuntimeOnly("org.junit.platform:junit-platform-launcher:6.1.0") add(jmh.implementationConfigurationName, "org.openjdk.jmh:jmh-core:${property("jmhVersion")}") add(jmh.annotationProcessorConfigurationName, "org.openjdk.jmh:jmh-generator-annprocess:${property("jmhVersion")}") - add(jmh.runtimeOnlyConfigurationName, "com.google.guava:guava:33.4.0-jre") + add(jmh.runtimeOnlyConfigurationName, "com.google.guava:guava:${property("guavaVersion")}") } configurations.named(jmh.implementationConfigurationName) { @@ -105,6 +110,8 @@ tasks.processResources { "pluginDescription" to pluginDescription, "gitCommitShort" to rootProject.extra["gitCommitShort"], "langchain4jVersion" to project.property("langchain4jVersion"), + "caffeineVersion" to project.property("caffeineVersion"), + "hikariCPVersion" to project.property("hikariCPVersion"), ) inputs.properties(properties) filesMatching("plugin.yml") { diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/AdvancedSensitiveWords.java b/paper/src/main/java/io/wdsj/asw/bukkit/AdvancedSensitiveWords.java index b297622c..95231ba2 100644 --- a/paper/src/main/java/io/wdsj/asw/bukkit/AdvancedSensitiveWords.java +++ b/paper/src/main/java/io/wdsj/asw/bukkit/AdvancedSensitiveWords.java @@ -5,17 +5,20 @@ import com.github.Anon8281.universalScheduler.scheduling.tasks.MyScheduledTask; import com.github.houbb.sensitive.word.api.IWordAllow; import com.github.houbb.sensitive.word.api.IWordDeny; +import com.github.houbb.sensitive.word.api.IWordResult; import com.github.houbb.sensitive.word.api.IWordResultCondition; import com.github.houbb.sensitive.word.bs.SensitiveWordBs; import com.github.houbb.sensitive.word.support.allow.WordAllows; import com.github.houbb.sensitive.word.support.check.WordChecks; import com.github.houbb.sensitive.word.support.deny.WordDenys; +import com.github.houbb.sensitive.word.support.result.WordResultHandlers; import com.github.houbb.sensitive.word.support.resultcondition.WordResultConditions; import com.github.houbb.sensitive.word.support.tag.WordTags; import io.wdsj.asw.bukkit.command.AswCommandRegistrar; import io.wdsj.asw.bukkit.ai.LlmChatDetectionService; import io.wdsj.asw.bukkit.core.condition.WordResultConditionNumMatch; import io.wdsj.asw.bukkit.integration.placeholder.ASWExpansion; +import io.wdsj.asw.bukkit.playergroup.PlayerGroupService; import io.wdsj.asw.bukkit.service.chat.antispam.ChatAntiSpamService; import io.wdsj.asw.bukkit.manage.punish.PlayerAltController; import io.wdsj.asw.bukkit.manage.punish.PlayerShadowController; @@ -44,6 +47,10 @@ import org.bukkit.plugin.java.JavaPlugin; import org.slf4j.Logger; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; + import static io.wdsj.asw.bukkit.util.LoggingUtils.purgeLog; import static io.wdsj.asw.bukkit.util.TimingUtils.resetStatistics; import static io.wdsj.asw.bukkit.util.Utils.*; @@ -52,6 +59,7 @@ public final class AdvancedSensitiveWords extends JavaPlugin { public static volatile boolean isInitialized = false; public static SensitiveWordBs sensitiveWordBs; + public static SensitiveWordBs networkSensitiveWordBs; public static boolean isAuthMeAvailable; public static final String PLUGIN_VERSION = PluginBuildInfo.VERSION; private static AdvancedSensitiveWords instance; @@ -62,6 +70,7 @@ public final class AdvancedSensitiveWords extends JavaPlugin { private PaperConfigurationService configurationService; private volatile Updater.UpdateResult updateResult = Updater.UpdateResult.noUpdate(); private AswCommandRegistrar commandRegistrar; + private PlayerGroupService playerGroupService; public static TaskScheduler getScheduler() { return scheduler; } @@ -85,6 +94,10 @@ public LlmChatDetectionService getLlmChatDetectionService() { return listenerService.getLlmChatDetectionService(); } + public PlayerGroupService getPlayerGroupService() { + return playerGroupService; + } + public static T setting(SettingKey key) { return instance.configurationService.get(key); } @@ -112,6 +125,7 @@ public void onEnable() { BookCache.initialize(); doInitTasks(); if (configurationService.get(PluginSettings.PURGE_LOG_FILE)) purgeLog(); + initializePlayerGroups(); listenerService = new ListenerService(this); listenerService.registerListeners(); commandRegistrar = new AswCommandRegistrar(this); @@ -134,6 +148,7 @@ public void doInitTasks() { IWordAllow wA = WordAllows.chains(WordAllows.defaults(), new WordAllow(), new ExternalWordAllow(this)); isInitialized = false; sensitiveWordBs = null; + networkSensitiveWordBs = null; IWordResultCondition condition = createWordResultCondition(); getScheduler().runTaskAsynchronously(() -> { IWordDeny wordDeny = createWordDeny(); @@ -145,20 +160,20 @@ public void doInitTasks() { .ignoreEnglishStyle(configurationService.get(PluginSettings.IGNORE_ENGLISH_STYLE)) .ignoreRepeat(configurationService.get(PluginSettings.IGNORE_REPEAT)) .enableNumCheck(configurationService.get(PluginSettings.ENABLE_NUM_CHECK)) - .enableEmailCheck(configurationService.get(PluginSettings.ENABLE_EMAIL_CHECK)) - .enableUrlCheck(configurationService.get(PluginSettings.ENABLE_URL_CHECK)) + .enableEmailCheck(false) + .enableUrlCheck(false) .enableWordCheck(configurationService.get(PluginSettings.ENABLE_WORD_CHECK)) .wordResultCondition(condition) - .wordCheckUrl(configurationService.get(PluginSettings.URL_CHECK_NO_PREFIX) ? WordChecks.urlNoPrefix() : WordChecks.url()) .wordDeny(wordDeny) .wordAllow(wA) .numCheckLen(configurationService.get(PluginSettings.NUM_CHECK_LEN)) .wordReplace(new WordReplace()) .wordTag(WordTags.none()) - .charIgnore(new CharIgnore()) - .enableIpv4Check(configurationService.get(PluginSettings.ENABLE_IP_CHECK)) + .charIgnore(new CharIgnore(configurationService.get(PluginSettings.IGNORE_CHAR))) + .enableIpv4Check(false) .wordFailFast(configurationService.get(PluginSettings.FAIL_FAST)) .init(); + networkSensitiveWordBs = createNetworkSensitiveWordBs(); isInitialized = true; }); } @@ -178,13 +193,27 @@ public void onDisable() { ViolationCounter.INSTANCE.resetAllViolations(); SchedulingUtils.cancelTaskSafely(violationResetTask); if (permCache != null) permCache.disable(); - if (isInitialized) sensitiveWordBs.destroy(); + if (playerGroupService != null) { + playerGroupService.close(); + playerGroupService = null; + } + if (isInitialized) { + sensitiveWordBs.destroy(); + if (networkSensitiveWordBs != null) { + networkSensitiveWordBs.destroy(); + } + } commandRegistrar = null; LOGGER.info("AdvancedSensitiveWords is disabled."); } public void reloadPluginConfiguration() { configurationService.reload(); + if (playerGroupService == null && configurationService.get(PluginSettings.PLAYER_GROUPS_ENABLED)) { + initializePlayerGroups(); + } else if (playerGroupService != null) { + playerGroupService.reloadConfiguration(); + } if (listenerService != null) { listenerService.reloadConfiguration(); } @@ -220,6 +249,19 @@ private void scheduleViolationResetTask() { violationResetTask = new ViolationResetTask().runTaskTimerAsynchronously(this, resetIntervalTicks, resetIntervalTicks); } + private void initializePlayerGroups() { + if (!configurationService.get(PluginSettings.PLAYER_GROUPS_ENABLED)) { + return; + } + try { + playerGroupService = new PlayerGroupService(this); + playerGroupService.start(); + } catch (Exception exception) { + LOGGER.error("Failed to initialize player group storage.", exception); + throw new IllegalStateException("Unable to initialize player group storage", exception); + } + } + private void checkForUpdatesAsync() { if (!configurationService.get(PluginSettings.CHECK_FOR_UPDATE)) { return; @@ -282,6 +324,34 @@ private IWordResultCondition createWordResultCondition() { }; } + private SensitiveWordBs createNetworkSensitiveWordBs() { + boolean enableEmail = configurationService.get(PluginSettings.ENABLE_EMAIL_CHECK); + boolean enableUrl = configurationService.get(PluginSettings.ENABLE_URL_CHECK); + boolean enableIp = configurationService.get(PluginSettings.ENABLE_IP_CHECK); + if (!enableEmail && !enableUrl && !enableIp) { + return null; + } + return SensitiveWordBs.newInstance() + .ignoreCase(configurationService.get(PluginSettings.IGNORE_CASE)) + .ignoreWidth(configurationService.get(PluginSettings.IGNORE_WIDTH)) + .ignoreNumStyle(configurationService.get(PluginSettings.IGNORE_NUM_STYLE)) + .ignoreChineseStyle(configurationService.get(PluginSettings.IGNORE_CHINESE_STYLE)) + .ignoreEnglishStyle(configurationService.get(PluginSettings.IGNORE_ENGLISH_STYLE)) + .ignoreRepeat(configurationService.get(PluginSettings.IGNORE_REPEAT)) + .enableNumCheck(false) + .enableEmailCheck(enableEmail) + .enableUrlCheck(enableUrl) + .enableWordCheck(false) + .wordResultCondition(WordResultConditions.alwaysTrue()) + .wordCheckUrl(configurationService.get(PluginSettings.URL_CHECK_NO_PREFIX) ? WordChecks.urlNoPrefix() : WordChecks.url()) + .wordReplace(new WordReplace()) + .wordTag(WordTags.none()) + .charIgnore(new CharIgnore(configurationService.get(PluginSettings.IGNORE_CHAR), CharIgnore.NETWORK_SYNTAX_CHARS)) + .enableIpv4Check(enableIp) + .wordFailFast(configurationService.get(PluginSettings.FAIL_FAST)) + .init(); + } + private IWordDeny createWordDeny() { boolean enableDefaultWords = configurationService.get(PluginSettings.ENABLE_DEFAULT_WORDS); boolean enableOnlineWords = configurationService.get(PluginSettings.ENABLE_ONLINE_WORDS); @@ -297,4 +367,37 @@ private IWordDeny createWordDeny() { return WordDenys.chains(new WordDeny(), new ExternalWordDeny(this)); } + public static List findAllSensitive(String text) { + LinkedHashSet results = new LinkedHashSet<>(); + SensitiveWordBs wordBs = sensitiveWordBs; + if (wordBs != null) { + results.addAll(wordBs.findAll(text)); + } + SensitiveWordBs networkWordBs = networkSensitiveWordBs; + if (networkWordBs != null) { + results.addAll(networkWordBs.findAll(text)); + } + return List.copyOf(results); + } + + public static List findAllSensitiveRaw(String text) { + List results = new ArrayList<>(); + SensitiveWordBs wordBs = sensitiveWordBs; + if (wordBs != null) { + results.addAll(wordBs.findAll(text, WordResultHandlers.raw())); + } + SensitiveWordBs networkWordBs = networkSensitiveWordBs; + if (networkWordBs != null) { + results.addAll(networkWordBs.findAll(text, WordResultHandlers.raw())); + } + return results; + } + + public static String replaceSensitive(String text) { + SensitiveWordBs wordBs = sensitiveWordBs; + String result = wordBs == null ? text : wordBs.replace(text); + SensitiveWordBs networkWordBs = networkSensitiveWordBs; + return networkWordBs == null ? result : networkWordBs.replace(result); + } + } diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/api/CoreApi.java b/paper/src/main/java/io/wdsj/asw/bukkit/api/CoreApi.java index 39fab786..17a047d5 100644 --- a/paper/src/main/java/io/wdsj/asw/bukkit/api/CoreApi.java +++ b/paper/src/main/java/io/wdsj/asw/bukkit/api/CoreApi.java @@ -28,7 +28,7 @@ public boolean isInitialized() { * @see #isInitialized() */ public List findSensitiveWords(String text) { - return AdvancedSensitiveWords.sensitiveWordBs.findAll(text); + return AdvancedSensitiveWords.findAllSensitive(text); } /** @@ -41,6 +41,6 @@ public List findSensitiveWords(String text) { * @see #isInitialized() */ public String replaceSensitiveWords(String text) { - return AdvancedSensitiveWords.sensitiveWordBs.replace(text); + return AdvancedSensitiveWords.replaceSensitive(text); } } diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/command/AswCommandRegistrar.java b/paper/src/main/java/io/wdsj/asw/bukkit/command/AswCommandRegistrar.java index 8d64690f..7ad61ca1 100644 --- a/paper/src/main/java/io/wdsj/asw/bukkit/command/AswCommandRegistrar.java +++ b/paper/src/main/java/io/wdsj/asw/bukkit/command/AswCommandRegistrar.java @@ -1,6 +1,7 @@ package io.wdsj.asw.bukkit.command; import io.wdsj.asw.bukkit.AdvancedSensitiveWords; +import io.wdsj.asw.bukkit.playergroup.PlayerGroup; import io.wdsj.asw.bukkit.setting.PluginMessages; import io.wdsj.asw.bukkit.type.ModuleType; import io.wdsj.asw.bukkit.util.message.MessageUtils; @@ -38,13 +39,23 @@ public final class AswCommandRegistrar { .toList() ); private static final ParserDescriptor VIOLATION_MODULE_PARSER = - StringParser.stringParser().flatMapSuccess(ModuleType.class, (context, value) -> { + StringParser.stringParser().flatMapSuccess(ModuleType.class, (ignored, value) -> { ModuleType moduleType = ModuleType.parseViolationModule(value); if (moduleType == null) { return ArgumentParseResult.failureFuture(new ViolationModuleParseException(value)); } return ArgumentParseResult.successFuture(moduleType); }); + private static final SuggestionProvider PLAYER_GROUP_SUGGESTIONS = + SuggestionProvider.suggestingStrings("newbie", "player"); + private static final ParserDescriptor PLAYER_GROUP_PARSER = + StringParser.stringParser().flatMapSuccess(PlayerGroup.class, (ignored, value) -> { + PlayerGroup group = PlayerGroup.parse(value); + if (group == null) { + return ArgumentParseResult.failureFuture(new PlayerGroupParseException(value)); + } + return ArgumentParseResult.successFuture(group); + }); private final AdvancedSensitiveWords plugin; private final AswCommandService commandService; @@ -70,6 +81,7 @@ public void register() { registerReloadCommands(); registerWordCommands(); registerPlayerCommands(); + registerGroupCommands(); } private void registerRootCommand() { @@ -192,6 +204,32 @@ private void registerPlayerCommands() { ))); } + private void registerGroupCommands() { + commandManager.command(root() + .literal("group", Description.of("Manage activity groups")) + .literal("info", Description.of("Show a player's activity group")) + .required("player", PlayerParser.playerParser()) + .permission(permission(CommandPermissions.GROUP_INFO)) + .handler(context -> commandService.showPlayerGroupInfo(sender(context), context.get("player")))); + commandManager.command(root() + .literal("group", Description.of("Manage activity groups")) + .literal("set", Description.of("Set a manual activity group")) + .required("player", PlayerParser.playerParser()) + .required("group", PLAYER_GROUP_PARSER, PLAYER_GROUP_SUGGESTIONS) + .permission(permission(CommandPermissions.GROUP_SET)) + .handler(context -> commandService.setPlayerGroup( + sender(context), + context.get("player"), + context.get("group") + ))); + commandManager.command(root() + .literal("group", Description.of("Manage activity groups")) + .literal("clear", Description.of("Clear a manual activity group")) + .required("player", PlayerParser.playerParser()) + .permission(permission(CommandPermissions.GROUP_CLEAR)) + .handler(context -> commandService.clearPlayerGroup(sender(context), context.get("player")))); + } + private void registerExceptionHandlers() { commandManager.exceptionController().registerHandler(NoPermissionException.class, context -> MessageUtils.sendMessage(sender(context.context()), PluginMessages.NO_PERMISSION)); @@ -204,6 +242,10 @@ private void registerExceptionHandlers() { MessageUtils.sendMessage(sender(context.context()), PluginMessages.INVALID_VIOLATION_MODULE); return; } + if (context.exception().getCause() instanceof PlayerGroupParseException) { + MessageUtils.sendMessage(sender(context.context()), PluginMessages.NOT_ENOUGH_ARGS); + return; + } MessageUtils.sendMessage(sender(context.context()), PluginMessages.PLAYER_NOT_FOUND); }); } @@ -235,4 +277,15 @@ public Throwable fillInStackTrace() { return this; } } + + private static final class PlayerGroupParseException extends IllegalArgumentException { + private PlayerGroupParseException(String value) { + super("Unknown player group: " + value); + } + + @Override + public Throwable fillInStackTrace() { + return this; + } + } } diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/command/AswCommandService.java b/paper/src/main/java/io/wdsj/asw/bukkit/command/AswCommandService.java index 78ff2bcb..6c10d10e 100644 --- a/paper/src/main/java/io/wdsj/asw/bukkit/command/AswCommandService.java +++ b/paper/src/main/java/io/wdsj/asw/bukkit/command/AswCommandService.java @@ -7,6 +7,10 @@ import io.wdsj.asw.bukkit.api.moderation.LlmModerationCategory; import io.wdsj.asw.bukkit.manage.punish.PunishmentService; import io.wdsj.asw.bukkit.manage.punish.ViolationCounter; +import io.wdsj.asw.bukkit.playergroup.PlayerActivitySnapshot; +import io.wdsj.asw.bukkit.playergroup.PlayerGroup; +import io.wdsj.asw.bukkit.playergroup.PlayerGroupService; +import io.wdsj.asw.bukkit.playergroup.PlayerGroupStatus; import io.wdsj.asw.bukkit.setting.PluginMessages; import io.wdsj.asw.bukkit.setting.PluginSettings; import io.wdsj.asw.bukkit.setting.PaperConfigurationService; @@ -48,6 +52,9 @@ public void reloadAll(CommandSender sender) { plugin.reloadPluginConfiguration(); AdvancedSensitiveWords.sensitiveWordBs.destroy(); + if (AdvancedSensitiveWords.networkSensitiveWordBs != null) { + AdvancedSensitiveWords.networkSensitiveWordBs.destroy(); + } plugin.doInitTasks(); if (configuration.get(PluginSettings.BOOK_CACHE_CLEAR_ON_RELOAD) && configuration.get(PluginSettings.BOOK_CACHE)) { @@ -103,7 +110,7 @@ public void test(CommandSender sender, String text) { return; } - List censoredWords = AdvancedSensitiveWords.sensitiveWordBs.findAll(text); + List censoredWords = AdvancedSensitiveWords.findAllSensitive(text); if (censoredWords.isEmpty()) { MessageUtils.sendMessage(sender, PluginMessages.MESSAGE_ON_COMMAND_TEST_PASS); return; @@ -111,7 +118,7 @@ public void test(CommandSender sender, String text) { String message = MessageUtils.retrieveMessage(PluginMessages.MESSAGE_ON_COMMAND_TEST) .replace("%original_msg%", text) - .replace("%processed_msg%", AdvancedSensitiveWords.sensitiveWordBs.replace(text)) + .replace("%processed_msg%", AdvancedSensitiveWords.replaceSensitive(text)) .replace("%censored_list%", censoredWords.toString()); MessageUtils.sendMessage(sender, message); } @@ -171,6 +178,59 @@ public void resetPlayerViolations(CommandSender sender, Player player, ModuleTyp MessageUtils.sendMessage(sender, message); } + public void showPlayerGroupInfo(CommandSender sender, Player player) { + PlayerGroupService service = plugin.getPlayerGroupService(); + if (service == null) { + MessageUtils.sendMessage(sender, PluginMessages.PLAYER_GROUP_DISABLED); + return; + } + service.statusAsync(player).whenComplete((status, exception) -> + SchedulingUtils.runSyncIfNotOnMainThread(() -> { + if (exception != null) { + MessageUtils.sendMessage(sender, PluginMessages.PLAYER_GROUP_DISABLED); + return; + } + MessageUtils.sendMessage(sender, formatGroupInfo(player, status)); + })); + } + + public void setPlayerGroup(CommandSender sender, Player player, PlayerGroup group) { + PlayerGroupService service = plugin.getPlayerGroupService(); + if (service == null) { + MessageUtils.sendMessage(sender, PluginMessages.PLAYER_GROUP_DISABLED); + return; + } + service.setManualGroup(player, group, sender).whenComplete((ignored, exception) -> + SchedulingUtils.runSyncIfNotOnMainThread(() -> { + if (exception != null) { + MessageUtils.sendMessage(sender, PluginMessages.PLAYER_GROUP_STORAGE_ERROR); + return; + } + String message = MessageUtils.retrieveMessage(PluginMessages.PLAYER_GROUP_SET) + .replace("%player%", player.getName()) + .replace("%group%", group.name()); + MessageUtils.sendMessage(sender, message); + })); + } + + public void clearPlayerGroup(CommandSender sender, Player player) { + PlayerGroupService service = plugin.getPlayerGroupService(); + if (service == null) { + MessageUtils.sendMessage(sender, PluginMessages.PLAYER_GROUP_DISABLED); + return; + } + service.clearManualGroup(player).whenComplete((ignored, exception) -> + SchedulingUtils.runSyncIfNotOnMainThread(() -> { + if (exception != null) { + MessageUtils.sendMessage(sender, PluginMessages.PLAYER_GROUP_STORAGE_ERROR); + return; + } + String message = MessageUtils.retrieveMessage(PluginMessages.PLAYER_GROUP_CLEAR) + .replace("%player%", player.getName()); + MessageUtils.sendMessage(sender, message); + })); + } + public void teleportToReportedLocation(CommandSender sender, UUID worldId, double x, double y, double z) { if (!(sender instanceof Player player)) { return; @@ -229,4 +289,31 @@ private static String formatCategoryPolicies(Map ignoredCharacters; + + public CharIgnore(String ignoredCharacters) { + this(ignoredCharacters, ""); + } + + public CharIgnore(String ignoredCharacters, String protectedCharacters) { + Set ignored = new HashSet<>(StringUtil.toCharSet(ignoredCharacters == null ? "" : ignoredCharacters)); + ignored.removeAll(StringUtil.toCharSet(protectedCharacters == null ? "" : protectedCharacters)); + this.ignoredCharacters = Set.copyOf(ignored); + } -public class CharIgnore implements ISensitiveWordCharIgnore { @Override public boolean ignore(int i, String string, InnerSensitiveWordContext innerSensitiveWordContext) { - Set SET = StringUtil.toCharSet(setting(PluginSettings.IGNORE_CHAR)); char c = string.charAt(i); - return SET.contains(c); + return ignoredCharacters.contains(c); } } diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/persistence/DataSourceFactory.java b/paper/src/main/java/io/wdsj/asw/bukkit/persistence/DataSourceFactory.java new file mode 100644 index 00000000..35edfe3d --- /dev/null +++ b/paper/src/main/java/io/wdsj/asw/bukkit/persistence/DataSourceFactory.java @@ -0,0 +1,68 @@ +package io.wdsj.asw.bukkit.persistence; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; + +public final class DataSourceFactory { + private DataSourceFactory() { + } + + public static HikariDataSource create(StorageConfig storage) throws SQLException { + suppressHikariLifecycleLogs(); + HikariConfig config = new HikariConfig(); + config.setPoolName(storage.poolName()); + config.setMaximumPoolSize(storage.maximumPoolSize()); + config.setMinimumIdle(storage.minimumIdle()); + config.setConnectionTimeout(storage.connectionTimeout().toMillis()); + + if (storage.type() == StorageType.SQLITE) { + Path file = storage.sqliteFile().toAbsolutePath(); + Path parent = file.getParent(); + try { + if (parent != null) { + Files.createDirectories(parent); + } + } catch (Exception exception) { + throw new SQLException("Unable to create SQLite parent directory: " + parent, exception); + } + config.setJdbcUrl("jdbc:sqlite:" + file); + config.setDriverClassName("org.sqlite.JDBC"); + config.setMaximumPoolSize(Math.min(storage.maximumPoolSize(), 3)); + if (storage.sqliteWal()) { + config.setConnectionInitSql("PRAGMA synchronous=NORMAL"); + } + } else { + config.setJdbcUrl("jdbc:mysql://" + storage.host() + ":" + storage.port() + "/" + storage.database() + + "?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=UTC"); + config.setUsername(storage.username()); + config.setPassword(storage.password()); + config.setDriverClassName("com.mysql.cj.jdbc.Driver"); + } + + HikariDataSource dataSource = new HikariDataSource(config); + if (storage.type() == StorageType.SQLITE && storage.sqliteWal()) { + try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement()) { + statement.execute("PRAGMA journal_mode=WAL"); + } + } + return dataSource; + } + + private static void suppressHikariLifecycleLogs() { + System.setProperty("org.slf4j.simpleLogger.log.com.zaxxer.hikari", "warn"); + try { + Class levelClass = Class.forName("org.apache.logging.log4j.Level"); + Object warn = levelClass.getField("WARN").get(null); + Class configuratorClass = Class.forName("org.apache.logging.log4j.core.config.Configurator"); + configuratorClass.getMethod("setLevel", String.class, levelClass) + .invoke(null, "com.zaxxer.hikari", warn); + } catch (Throwable ignored) { + } + } +} diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/persistence/FlushPolicy.java b/paper/src/main/java/io/wdsj/asw/bukkit/persistence/FlushPolicy.java new file mode 100644 index 00000000..c554b356 --- /dev/null +++ b/paper/src/main/java/io/wdsj/asw/bukkit/persistence/FlushPolicy.java @@ -0,0 +1,15 @@ +package io.wdsj.asw.bukkit.persistence; + +public enum FlushPolicy { + /** + * Default write-back behavior. Writes only update memory and mark keys dirty; data is flushed + * by the periodic task, explicit flush calls, invalidation, or cache close. + */ + PERIODIC, + + /** + * Writes still update memory first, but each {@code put/delete} also queues an immediate + * asynchronous flush for the changed key. Periodic flush remains enabled as a retry fallback. + */ + ASYNC_AFTER_WRITE +} diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/persistence/StorageConfig.java b/paper/src/main/java/io/wdsj/asw/bukkit/persistence/StorageConfig.java new file mode 100644 index 00000000..aa4a92ac --- /dev/null +++ b/paper/src/main/java/io/wdsj/asw/bukkit/persistence/StorageConfig.java @@ -0,0 +1,78 @@ +package io.wdsj.asw.bukkit.persistence; + +import java.nio.file.Path; +import java.time.Duration; +import java.util.Objects; + +public record StorageConfig( + StorageType type, + Path sqliteFile, + String host, + int port, + String database, + String username, + String password, + String poolName, + int maximumPoolSize, + int minimumIdle, + Duration connectionTimeout, + boolean sqliteWal +) { + public StorageConfig { + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(poolName, "poolName"); + Objects.requireNonNull(connectionTimeout, "connectionTimeout"); + if (maximumPoolSize < 1 || minimumIdle < 0 || minimumIdle > maximumPoolSize) { + throw new IllegalArgumentException("Invalid Hikari pool size settings"); + } + if (connectionTimeout.isNegative() || connectionTimeout.isZero()) { + throw new IllegalArgumentException("connectionTimeout must be positive"); + } + if (type == StorageType.SQLITE && sqliteFile == null) { + throw new IllegalArgumentException("sqliteFile is required for SQLITE storage"); + } + if (type == StorageType.MYSQL) { + if (isBlank(host) || port <= 0 || isBlank(database) || username == null || password == null) { + throw new IllegalArgumentException("host, port, database, username and password are required for MYSQL storage"); + } + } + } + + public static StorageConfig sqlite(Path file, String poolName) { + return new StorageConfig( + StorageType.SQLITE, + file, + "", + 0, + "", + "", + "", + poolName, + 3, + 1, + Duration.ofSeconds(5), + true + ); + } + + public static StorageConfig mysql(String host, int port, String database, String username, String password, String poolName) { + return new StorageConfig( + StorageType.MYSQL, + null, + host, + port, + database, + username, + password, + poolName, + 10, + 2, + Duration.ofSeconds(5), + false + ); + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/persistence/StorageType.java b/paper/src/main/java/io/wdsj/asw/bukkit/persistence/StorageType.java new file mode 100644 index 00000000..61725153 --- /dev/null +++ b/paper/src/main/java/io/wdsj/asw/bukkit/persistence/StorageType.java @@ -0,0 +1,6 @@ +package io.wdsj.asw.bukkit.persistence; + +public enum StorageType { + SQLITE, + MYSQL +} diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/persistence/WriteBackCache.java b/paper/src/main/java/io/wdsj/asw/bukkit/persistence/WriteBackCache.java new file mode 100644 index 00000000..9e67da82 --- /dev/null +++ b/paper/src/main/java/io/wdsj/asw/bukkit/persistence/WriteBackCache.java @@ -0,0 +1,458 @@ +package io.wdsj.asw.bukkit.persistence; + +import com.github.benmanes.caffeine.cache.AsyncLoadingCache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.LoadingCache; +import com.github.benmanes.caffeine.cache.RemovalCause; +import com.github.benmanes.caffeine.cache.RemovalListener; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Function; + +import it.unimi.dsi.fastutil.objects.Object2ObjectLinkedOpenHashMap; +import org.jetbrains.annotations.Blocking; +import org.slf4j.Logger; + +/** + * Caffeine based write-back/write-behind cache. + * + *

Runtime code should treat this cache as the source of truth. Calls to {@link #put(Object, Object)} + * and {@link #delete(Object)} update memory first and mark the key dirty. Dirty keys are later + * persisted by the periodic flush task, explicit flush calls, invalidation, or {@link #close()}.

+ * + *

All reads and writes for the same data domain must go through this cache after it is created. + * Writing directly to the database bypasses the in-memory source of truth and can be overwritten by + * a later dirty cache flush. Reading directly from the database can also observe stale data because + * write-back entries may not have been flushed yet.

+ * + *

Concurrent callers are supported. Dirty entries carry a monotonic version so an older flush + * cannot clear a newer write for the same key.

+ * + *

Use {@link FlushPolicy#PERIODIC} for frequently changing data. Use + * {@link FlushPolicy#ASYNC_AFTER_WRITE}, {@link #putAndFlushAsync(Object, Object)}, or + * {@link #deleteAndFlushAsync(Object)} for writes that should be queued for persistence immediately + * without blocking the caller.

+ */ +@SuppressWarnings({"OptionalAssignedToNull", "OptionalUsedAsFieldOrParameterType", "unused"}) +public final class WriteBackCache implements AutoCloseable { + private final String name; + private final WriteBackRepository repository; + private final Logger logger; + private final Duration shutdownTimeout; + private final FlushPolicy flushPolicy; + private final ExecutorService readExecutor; + private final ScheduledExecutorService writeExecutor; + private final ConcurrentMap dirtyVersions = new ConcurrentHashMap<>(); + private final AtomicLong versionSequence = new AtomicLong(); + private final AsyncLoadingCache> cache; + private final LoadingCache> syncCache; + private final AtomicBoolean closed = new AtomicBoolean(); + + public WriteBackCache( + String name, + WriteBackRepository repository, + Logger logger, + Caffeine cacheBuilder, + Duration flushInterval, + Duration shutdownTimeout, + FlushPolicy flushPolicy + ) { + this.name = name; + this.repository = repository; + this.logger = logger; + this.shutdownTimeout = shutdownTimeout; + this.flushPolicy = flushPolicy; + this.readExecutor = Executors.newVirtualThreadPerTaskExecutor(); + this.writeExecutor = Executors.newSingleThreadScheduledExecutor(runnable -> { + Thread thread = new Thread(runnable, name + "-writeback"); + thread.setDaemon(true); + return thread; + }); + + RemovalListener> removalListener = (key, value, cause) -> { + Long version = key == null ? null : dirtyVersions.get(key); + if (key != null && value != null && version != null && cause != RemovalCause.REPLACED) { + flushSnapshotAsync(key, value, version); + } + }; + this.cache = buildCache(cacheBuilder, removalListener); + this.syncCache = cache.synchronous(); + + long flushMillis = Math.max(1000L, flushInterval.toMillis()); + writeExecutor.scheduleAtFixedRate(this::flushDirtySafely, flushMillis, flushMillis, TimeUnit.MILLISECONDS); + } + + public static WriteBackCache create( + String name, + WriteBackRepository repository, + Logger logger, + Duration flushInterval + ) { + return new WriteBackCache<>( + name, + repository, + logger, + Caffeine.newBuilder().expireAfterAccess(30L, TimeUnit.MINUTES), + flushInterval, + Duration.ofSeconds(10L), + FlushPolicy.PERIODIC + ); + } + + /** + * Asynchronously reads an entry through the Caffeine cache loader. + * + *

The returned callbacks run on the thread that completes the future unless the caller uses + * an async continuation with an explicit executor. Do not access Bukkit world/entity APIs from + * those callbacks without switching back to the correct server thread.

+ */ + public CompletableFuture> getAsync(K key) { + return cache.get(key).handle((value, exception) -> { + if (exception != null) { + logger.error("{} failed to load cache entry {}.", name, key, exception); + throw new CompletionException(name + " failed to load cache entry " + key, exception); + } + return value; + }); + } + + /** + * Asynchronously reads several entries through the cache. + * + *

This is for key-based batch reads. Each returned map entry contains the loaded value or + * {@link Optional#empty()} when that key does not exist in durable storage. The map preserves + * the iteration order of {@code keys}.

+ * + *

Like {@link #getAsync(Object)}, continuations run on the completion thread unless the + * caller uses an explicit executor.

+ */ + public CompletableFuture>> getAllAsync(Collection keys) { + return cache.getAll(keys).handle((values, exception) -> { + if (exception != null) { + logger.error("{} failed to load cache entries {}.", name, keys, exception); + throw new CompletionException(name + " failed to load cache entries", exception); + } + Map> result = new Object2ObjectLinkedOpenHashMap<>(keys.size()); + for (K key : keys) { + result.put(key, values.getOrDefault(key, Optional.empty())); + } + return result; + }); + } + + /** + * Runs an arbitrary asynchronous read query. + * + *

Use this for reads that are not naturally addressed by cache key, such as "latest rows", + * "created between two timestamps", ranking queries, or moderation review lists.

+ * + *

This method does not flush dirty cache entries first. It is suitable for eventually + * consistent dashboards or queries that tolerate write-behind delay. If the query must observe + * pending cached writes, call {@link #flushDirtyAsync()} first and then compose into this + * method.

+ */ + public CompletableFuture queryAsync(Callable query) { + return CompletableFuture.supplyAsync(() -> { + try { + return query.call(); + } catch (Exception exception) { + throw new IllegalStateException(name + " query failed", exception); + } + }, readExecutor); + } + + public Optional getIfCached(K key) { + Optional value = syncCache.getIfPresent(key); + return value == null ? Optional.empty() : value; + } + + /** + * Returns a point-in-time snapshot of entries currently resident in memory. + * + *

This method does not load missing keys from durable storage. It is intended for cache + * maintenance tasks such as pruning expired elements inside cached aggregate values. Mutating + * the returned map has no effect; use {@link #put(Object, Object)} or {@link #delete(Object)} + * for changes so dirty tracking remains correct.

+ */ + public Map> cachedEntriesSnapshot() { + return new LinkedHashMap<>(syncCache.asMap()); + } + + public void put(K key, V value) { + ensureOpen(); + syncCache.put(key, Optional.of(value)); + markDirty(key); + if (flushPolicy == FlushPolicy.ASYNC_AFTER_WRITE) { + flushKeyAsync(key); + } + } + + /** + * Updates the cache and immediately queues an asynchronous flush for this key. + * + *

This is useful for high-value writes such as economy transactions, bans, or completed + * moderation actions while keeping the default cache policy periodic.

+ */ + public CompletableFuture putAndFlushAsync(K key, V value) { + put(key, value); + return flushKeyFuture(key); + } + + /** + * Loads the current value asynchronously, computes the replacement in memory, and marks the key + * dirty. With {@link FlushPolicy#ASYNC_AFTER_WRITE}, the changed key is also queued for flush. + */ + public CompletableFuture> computeAsync(K key, Function, Optional> remapper) { + ensureOpen(); + return getAsync(key).thenApply(current -> { + Optional next = remapper.apply(current); + if (next.isPresent()) { + put(key, next.get()); + } else { + delete(key); + } + return next; + }); + } + + public void delete(K key) { + ensureOpen(); + syncCache.put(key, Optional.empty()); + markDirty(key); + if (flushPolicy == FlushPolicy.ASYNC_AFTER_WRITE) { + flushKeyAsync(key); + } + } + + /** + * Marks an entry deleted in memory and immediately queues an asynchronous delete flush. + */ + public CompletableFuture deleteAndFlushAsync(K key) { + delete(key); + return flushKeyFuture(key); + } + + /** + * Invalidates an entry without blocking the caller on database I/O. + * + *

If the entry is dirty, Caffeine's removal listener queues an asynchronous flush on the + * write-behind executor. This is the method to use from latency-sensitive paths such as player + * quit handlers. Use {@link #invalidateAndFlushBlocking(Object)} only when the caller can safely + * block.

+ */ + public void invalidate(K key) { + syncCache.invalidate(key); + syncCache.cleanUp(); + } + + /** + * Queues a flush for the key and invalidates the entry after that flush task has run. + * + *

The returned future completes on the write-behind executor. Use an explicit scheduler or + * executor before touching server APIs from continuations.

+ */ + public CompletableFuture invalidateAndFlushAsync(K key) { + ensureOpen(); + CompletableFuture future = new CompletableFuture<>(); + writeExecutor.execute(() -> { + try { + flushKeyBlocking(key); + syncCache.invalidate(key); + syncCache.cleanUp(); + future.complete(null); + } catch (Exception exception) { + future.completeExceptionally(exception); + } + }); + return future; + } + + /** + * Flushes a dirty entry synchronously before invalidating it. + * + *

This may perform blocking database I/O on the caller thread. It is suitable for controlled + * shutdown or maintenance code, not for the Minecraft main thread or Folia region threads.

+ */ + public void invalidateAndFlushBlocking(K key) { + flushKeyBlocking(key); + syncCache.invalidate(key); + syncCache.cleanUp(); + } + + public CompletableFuture flushDirtyAsync() { + CompletableFuture future = new CompletableFuture<>(); + writeExecutor.execute(() -> { + try { + flushDirty(); + future.complete(null); + } catch (Exception exception) { + future.completeExceptionally(exception); + } + }); + return future; + } + + @Blocking + public void flushDirtyBlocking() { + flushDirtySafely(); + } + + private void flushDirty() { + try { + flushDirtyThrowing(); + } catch (Exception exception) { + logger.error("{} failed to flush dirty entries.", name, exception); + } + } + + private void flushDirtyThrowing() throws Exception { + Object2ObjectLinkedOpenHashMap> snapshots = collectDirtySnapshots(); + if (snapshots.isEmpty()) { + return; + } + + Object2ObjectLinkedOpenHashMap> entries = new Object2ObjectLinkedOpenHashMap<>(); + for (Map.Entry> entry : snapshots.object2ObjectEntrySet()) { + entries.put(entry.getKey(), entry.getValue().value()); + } + repository.flush(entries); + for (Map.Entry> entry : snapshots.object2ObjectEntrySet()) { + dirtyVersions.remove(entry.getKey(), entry.getValue().version()); + } + } + + private void flushDirtySafely() { + flushDirty(); + } + + private void flushKeyAsync(K key) { + writeExecutor.execute(() -> flushKeyBlocking(key)); + } + + private void flushSnapshotAsync(K key, Optional value, long version) { + writeExecutor.execute(() -> { + try { + flushSnapshotBlocking(key, value, version); + } catch (Exception exception) { + logger.error("{} failed to save cache entry {}.", name, key, exception); + } + }); + } + + private CompletableFuture flushKeyFuture(K key) { + CompletableFuture future = new CompletableFuture<>(); + writeExecutor.execute(() -> { + try { + flushKeyBlocking(key); + future.complete(null); + } catch (Exception exception) { + future.completeExceptionally(exception); + } + }); + return future; + } + + private void flushKeyBlocking(K key) { + try { + flushKeyThrowing(key); + } catch (Exception exception) { + logger.error("{} failed to save cache entry {}.", name, key, exception); + } + } + + private void flushKeyThrowing(K key) throws Exception { + Optional value = syncCache.getIfPresent(key); + Long version = dirtyVersions.get(key); + if (value == null || version == null) { + return; + } + flushSnapshotBlocking(key, value, version); + } + + private void flushSnapshotBlocking(K key, Optional value, long version) throws Exception { + repository.flush(Map.of(key, value)); + dirtyVersions.remove(key, version); + } + + private void markDirty(K key) { + dirtyVersions.put(key, versionSequence.incrementAndGet()); + } + + private Object2ObjectLinkedOpenHashMap> collectDirtySnapshots() { + Object2ObjectLinkedOpenHashMap> snapshots = new Object2ObjectLinkedOpenHashMap<>(); + for (K key : new ArrayList<>(dirtyVersions.keySet())) { + Optional value = syncCache.getIfPresent(key); + Long version = dirtyVersions.get(key); + if (value != null && version != null) { + snapshots.put(key, new VersionedSnapshot<>(value, version)); + } + } + return snapshots; + } + + private Optional loadFromRepository(K key) { + try { + return repository.load(key); + } catch (Exception exception) { + throw new CompletionException(exception); + } + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private AsyncLoadingCache> buildCache( + Caffeine cacheBuilder, + RemovalListener> removalListener + ) { + return ((Caffeine>) (Caffeine) cacheBuilder) + .executor(readExecutor) + .removalListener(removalListener) + .buildAsync((key, executor) -> CompletableFuture.supplyAsync(() -> loadFromRepository(key), executor)); + } + + private void ensureOpen() { + if (closed.get()) { + throw new IllegalStateException(name + " cache is closed"); + } + } + + @Override + public void close() { + if (!closed.compareAndSet(false, true)) { + return; + } + logger.info("Shutting down {} cache...", name); + flushDirtySafely(); + syncCache.invalidateAll(); + syncCache.cleanUp(); + flushDirtySafely(); + writeExecutor.shutdown(); + readExecutor.shutdown(); + try { + if (!writeExecutor.awaitTermination(shutdownTimeout.toMillis(), TimeUnit.MILLISECONDS)) { + logger.warn("{} writeback executor did not stop within {}.", name, shutdownTimeout); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + } + } + + private record VersionedSnapshot(Optional value, long version) { + } + +} diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/persistence/WriteBackRepository.java b/paper/src/main/java/io/wdsj/asw/bukkit/persistence/WriteBackRepository.java new file mode 100644 index 00000000..43504485 --- /dev/null +++ b/paper/src/main/java/io/wdsj/asw/bukkit/persistence/WriteBackRepository.java @@ -0,0 +1,88 @@ +package io.wdsj.asw.bukkit.persistence; + +import java.util.Map; +import java.util.Optional; + +/** + * Storage adapter used by {@link WriteBackCache}. + * + *

The cache is the authoritative runtime view. Callers should read and mutate + * data through {@code WriteBackCache}; the repository is only called by the cache to load a + * missing entry or to flush dirty cached data back to persistent storage.

+ * + *

Implementation contract:

+ *
    + *
  • {@link #load(Object)} must be side effect free. It should only read one key from the + * backing store and return {@link Optional#empty()} when the row/document does not exist.
  • + *
  • {@link #save(Object, Object)} must be an idempotent upsert. It may be called multiple + * times for the same value because periodic flush, invalidation flush and shutdown flush can + * overlap in timing. Use {@code INSERT ... ON CONFLICT/ON DUPLICATE KEY UPDATE} or equivalent.
  • + *
  • {@link #delete(Object)} should remove the row/document for the key. Override it when + * callers use {@link WriteBackCache#delete(Object)}; otherwise the default no-op is acceptable + * for caches that never delete entries.
  • + *
  • {@link #flush(Map)} receives a complete dirty snapshot and may persist it in one + * transaction or JDBC batch. The default implementation loops over {@link #save(Object, Object)} + * and {@link #delete(Object)} for compatibility.
  • + *
+ * + * @param stable cache key type, for example {@code UUID}, {@code String}, or {@code Long} + * @param immutable or defensively-copied value type stored in the cache + */ +public interface WriteBackRepository { + /** + * Loads one entry from persistent storage. + * + * @param key key to load + * @return value if the backing store contains it, otherwise {@link Optional#empty()} + * @throws Exception when storage access fails + */ + Optional load(K key) throws Exception; + + /** + * Persists one cache entry. + * + *

This must behave as an upsert and should write the complete value, not a partial diff. + * The cache removes the key from the dirty set only after this method returns successfully.

+ * + * @param key key to save + * @param value complete value to persist + * @throws Exception when storage access fails + */ + void save(K key, V value) throws Exception; + + /** + * Removes one cache entry from durable storage. + * + *

The default implementation is a no-op for holders that never delete data. Override this + * when {@link WriteBackCache#delete(Object)} is part of the holder's public API.

+ * + * @param key key to delete + * @throws Exception when storage access fails + */ + default void delete(K key) throws Exception { + } + + /** + * Persists a batch of complete cache snapshots. + * + *

Each present value means upsert; each empty value means delete. Implementations that need + * maximum throughput should override this method and execute a single transaction with batch + * statements. The method must remain idempotent: the same snapshot can be retried after a + * previous failure.

+ * + *

The cache clears each dirty marker only after this method returns successfully. If this + * method throws, all involved dirty markers remain dirty and can be retried later.

+ * + * @param entries dirty snapshot keyed by cache key + * @throws Exception when storage access fails + */ + default void flush(Map> entries) throws Exception { + for (Map.Entry> entry : entries.entrySet()) { + if (entry.getValue().isPresent()) { + save(entry.getKey(), entry.getValue().get()); + } else { + delete(entry.getKey()); + } + } + } +} diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/GroupModule.java b/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/GroupModule.java new file mode 100644 index 00000000..38f7068b --- /dev/null +++ b/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/GroupModule.java @@ -0,0 +1,10 @@ +package io.wdsj.asw.bukkit.playergroup; + +public enum GroupModule { + CHAT, + COMMAND, + BOOK, + SIGN, + ANVIL, + ITEM +} diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/GroupModuleMode.java b/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/GroupModuleMode.java new file mode 100644 index 00000000..c9f933fe --- /dev/null +++ b/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/GroupModuleMode.java @@ -0,0 +1,7 @@ +package io.wdsj.asw.bukkit.playergroup; + +public enum GroupModuleMode { + DEFAULT, + ENABLED, + DISABLED +} diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerActivitySnapshot.java b/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerActivitySnapshot.java new file mode 100644 index 00000000..e60113de --- /dev/null +++ b/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerActivitySnapshot.java @@ -0,0 +1,22 @@ +package io.wdsj.asw.bukkit.playergroup; + +public record PlayerActivitySnapshot( + double score, + double playTimeHours, + long minedBlocks, + double movedBlocks, + long mobKills, + long usedItems, + long brokenItems, + long craftedItems, + long damageDealt, + long damageTaken, + long deaths, + long enchantedItems, + long fishCaught, + long villagerTrades +) { + public static PlayerActivitySnapshot empty() { + return new PlayerActivitySnapshot(0.0D, 0.0D, 0L, 0.0D, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L); + } +} diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroup.java b/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroup.java new file mode 100644 index 00000000..78ea3e68 --- /dev/null +++ b/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroup.java @@ -0,0 +1,19 @@ +package io.wdsj.asw.bukkit.playergroup; + +import java.util.Locale; + +public enum PlayerGroup { + NEWBIE, + PLAYER; + + public static PlayerGroup parse(String value) { + if (value == null) { + return null; + } + try { + return valueOf(value.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException exception) { + return null; + } + } +} diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroupService.java b/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroupService.java new file mode 100644 index 00000000..8067fe0a --- /dev/null +++ b/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroupService.java @@ -0,0 +1,549 @@ +package io.wdsj.asw.bukkit.playergroup; + +import com.github.Anon8281.universalScheduler.UniversalRunnable; +import com.github.Anon8281.universalScheduler.scheduling.tasks.MyScheduledTask; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.houbb.sensitive.word.bs.SensitiveWordBs; +import com.github.houbb.sensitive.word.support.check.WordChecks; +import com.github.houbb.sensitive.word.support.resultcondition.WordResultConditions; +import com.github.houbb.sensitive.word.support.tag.WordTags; +import com.zaxxer.hikari.HikariDataSource; +import io.wdsj.asw.bukkit.AdvancedSensitiveWords; +import io.wdsj.asw.bukkit.method.CharIgnore; +import io.wdsj.asw.bukkit.method.WordReplace; +import io.wdsj.asw.bukkit.persistence.*; +import io.wdsj.asw.bukkit.setting.PaperConfigurationService; +import io.wdsj.asw.bukkit.setting.PluginMessages; +import io.wdsj.asw.bukkit.setting.PluginSettings; +import io.wdsj.asw.bukkit.setting.SettingsConfiguration; +import io.wdsj.asw.bukkit.util.SchedulingUtils; +import io.wdsj.asw.bukkit.util.message.MessageUtils; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.Statistic; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.slf4j.Logger; + +import java.nio.file.Path; +import java.time.Duration; +import java.util.Locale; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +public final class PlayerGroupService implements AutoCloseable { + private static final String[] DISTANCE_STATISTICS = { + "WALK_ONE_CM", "SPRINT_ONE_CM", "SWIM_ONE_CM", "FALL_ONE_CM", "CLIMB_ONE_CM", + "FLY_ONE_CM", "BOAT_ONE_CM", "MINECART_ONE_CM", "PIG_ONE_CM", "HORSE_ONE_CM", + "AVIATE_ONE_CM", "STRIDER_ONE_CM" + }; + + private final AdvancedSensitiveWords plugin; + private final PaperConfigurationService configuration; + private final Logger logger; + private final HikariDataSource dataSource; + private final WriteBackCache groupStates; + private final ExecutorService statisticExecutor; + private final ConcurrentMap activitySnapshots = new ConcurrentHashMap<>(); + private final ConcurrentMap tokenBuckets = new ConcurrentHashMap<>(); + private final Object newbieLinkWordBsLock = new Object(); + private final AtomicLong newbieLinkWordBsReloadVersion = new AtomicLong(); + private volatile SensitiveWordBs newbieLinkWordBs; + private volatile CompletableFuture newbieLinkWordBsReload; + private volatile boolean closed; + private MyScheduledTask refreshTask; + + public PlayerGroupService(AdvancedSensitiveWords plugin) throws Exception { + this.plugin = plugin; + this.configuration = plugin.getConfigurationService(); + this.logger = AdvancedSensitiveWords.LOGGER; + this.statisticExecutor = Executors.newSingleThreadExecutor(runnable -> { + Thread thread = new Thread(runnable, "ASW Statistic Refresher"); + thread.setPriority(Thread.NORM_PRIORITY - 2); + thread.setDaemon(true); + return thread; + }); + SettingsConfiguration.PlayerGroups groups = configuration.get(PluginSettings.PLAYER_GROUPS); + StorageConfig storageConfig = toStorageConfig(groups.storage, configuration.dataDirectory()); + this.dataSource = DataSourceFactory.create(storageConfig); + PlayerGroupStateRepository repository = new PlayerGroupStateRepository(dataSource, storageConfig.type()); + repository.initialize(); + this.groupStates = new WriteBackCache<>( + "PlayerGroups", + repository, + logger, + Caffeine.newBuilder().expireAfterWrite(30L, TimeUnit.MINUTES).maximumSize(10_000L), + Duration.ofMinutes(2L), + Duration.ofSeconds(10L), + FlushPolicy.PERIODIC + ); + reloadConfiguration(); + } + + public void start() { + long intervalTicks = Math.max(1L, configuration.get(PluginSettings.PLAYER_GROUPS).refreshIntervalSeconds) * 20L; + refreshTask = new UniversalRunnable() { + @Override + public void run() { + refreshOnlinePlayers(); + } + }.runTaskTimerAsynchronously(plugin, intervalTicks, intervalTicks); + } + + public void reloadConfiguration() { + if (closed) { + return; + } + long version = newbieLinkWordBsReloadVersion.incrementAndGet(); + CompletableFuture previousReload = newbieLinkWordBsReload; + if (previousReload != null) { + previousReload.cancel(false); + } + newbieLinkWordBsReload = CompletableFuture.supplyAsync(this::createLinkWordBs, statisticExecutor) + .thenAccept(created -> { + if (closed || newbieLinkWordBsReloadVersion.get() != version) { + destroyWordBs(created); + return; + } + synchronized (newbieLinkWordBsLock) { + SensitiveWordBs previous = newbieLinkWordBs; + newbieLinkWordBs = created; + destroyWordBs(previous); + } + }) + .exceptionally(exception -> { + if (!closed) { + logger.error("Failed to initialize newbie link detector.", exception); + } + return null; + }); + } + + private static void destroyWordBs(SensitiveWordBs wordBs) { + if (wordBs != null) { + wordBs.destroy(); + } + } + + private SensitiveWordBs currentNewbieLinkWordBs() { + synchronized (newbieLinkWordBsLock) { + return newbieLinkWordBs; + } + } + + private void clearNewbieLinkWordBs() { + CompletableFuture reload = newbieLinkWordBsReload; + if (reload != null) { + reload.cancel(false); + newbieLinkWordBsReload = null; + } + synchronized (newbieLinkWordBsLock) { + SensitiveWordBs wordBs = newbieLinkWordBs; + newbieLinkWordBs = null; + destroyWordBs(wordBs); + } + } + + private boolean newbieLinkWordBsReady() { + return currentNewbieLinkWordBs() != null; + } + + private boolean matchNewbieLink(String content) { + synchronized (newbieLinkWordBsLock) { + SensitiveWordBs wordBs = newbieLinkWordBs; + return wordBs != null && !wordBs.findAll(content).isEmpty(); + } + } + + private boolean shouldRunNewbieLinkCheck(SettingsConfiguration.LinkCheck linkCheck) { + if (!linkCheck.enabled) { + return false; + } + if (!newbieLinkWordBsReady()) { + CompletableFuture reload = newbieLinkWordBsReload; + if (reload == null || reload.isDone()) { + reloadConfiguration(); + } + return false; + } + return true; + } + + public void handleJoin(Player player) { + if (!isEnabled()) { + return; + } + refreshAndSynchronize(player).thenRun(() -> + SchedulingUtils.runForOnlinePlayer(player.getUniqueId(), current -> { + if (currentGroup(current) == PlayerGroup.NEWBIE + && configuration.get(PluginSettings.PLAYER_GROUPS).newbie.sendJoinMessage) { + MessageUtils.sendMessage(current, PluginMessages.PLAYER_GROUP_NEWBIE_JOIN); + } + }) + ).exceptionally(exception -> { + logger.error("Failed to load player group override for {}.", player.getUniqueId(), exception); + return null; + }); + } + + public void handleQuit(UUID playerId) { + activitySnapshots.remove(playerId); + tokenBuckets.remove(playerId); + groupStates.invalidate(playerId); + } + + public boolean isModuleEnabled(Player player, GroupModule module, boolean globalEnabled) { + if (!globalEnabled) { + return false; + } + if (!isEnabled()) { + return true; + } + SettingsConfiguration.GroupPolicy policy = policyFor(currentGroup(player)); + GroupModuleMode mode = modeFor(policy.modules, module); + return mode != GroupModuleMode.DISABLED; + } + + public boolean isNewbie(Player player) { + return isEnabled() && currentGroup(player) == PlayerGroup.NEWBIE; + } + + public boolean consumeNewbieToken(Player player) { + if (!isNewbie(player)) { + return true; + } + SettingsConfiguration.RateLimit rateLimit = configuration.get(PluginSettings.PLAYER_GROUPS).newbie.rateLimit; + if (!rateLimit.enabled) { + return true; + } + TokenBucket bucket = tokenBuckets.compute(player.getUniqueId(), (ignored, existing) -> { + if (existing == null || existing.capacity != rateLimit.capacity + || existing.refillIntervalMillis != rateLimit.refillIntervalSeconds * 1000L) { + return new TokenBucket(rateLimit.capacity, rateLimit.refillIntervalSeconds * 1000L); + } + return existing; + }); + return bucket.tryConsume(System.currentTimeMillis()); + } + + public boolean containsNewbieLink(Player player, String content) { + if (!isNewbie(player)) { + return false; + } + SettingsConfiguration.LinkCheck linkCheck = configuration.get(PluginSettings.PLAYER_GROUPS).newbie.linkCheck; + if (content == null || !shouldRunNewbieLinkCheck(linkCheck)) { + return false; + } + return matchNewbieLink(content); + } + + public CompletableFuture statusAsync(Player player) { + return refreshAndSynchronize(player) + .thenApply(ignored -> status(player)); + } + + public PlayerGroupStatus status(Player player) { + PlayerActivitySnapshot cachedSnapshot = activitySnapshots.get(player.getUniqueId()); + PlayerActivitySnapshot snapshot = cachedSnapshot == null ? PlayerActivitySnapshot.empty() : cachedSnapshot; + Optional state = groupStates.getIfCached(player.getUniqueId()); + PlayerGroup group = state.map(PlayerGroupState::group) + .orElseGet(() -> cachedSnapshot == null ? unloadedFallbackGroup() : automaticGroup(snapshot)); + PlayerGroupSource source = state.filter(PlayerGroupState::manualOverride).isPresent() + ? PlayerGroupSource.MANUAL + : PlayerGroupSource.AUTOMATIC; + return new PlayerGroupStatus(group, source, snapshot, configuration.get(PluginSettings.PLAYER_GROUPS).playerThreshold); + } + + public CompletableFuture setManualGroup(Player target, PlayerGroup group, CommandSender actor) { + PlayerGroupState state = new PlayerGroupState( + target.getUniqueId(), + normalizeName(target.getName()), + group, + true, + System.currentTimeMillis(), + actor instanceof Player player ? player.getUniqueId() : null, + actor.getName() + ); + return groupStates.putAndFlushAsync(target.getUniqueId(), state) + .thenCompose(ignored -> refreshSnapshotAsync(target)) + .thenApply(ignored -> null); + } + + public CompletableFuture clearManualGroup(Player target) { + return groupStates.deleteAndFlushAsync(target.getUniqueId()) + .thenCompose(ignored -> refreshSnapshotAsync(target)) + .thenCompose(snapshot -> groupStates.getAsync(target.getUniqueId()) + .thenCompose(state -> synchronizeAutomaticState(target, snapshot, state))) + .thenApply(ignored -> null); + } + + private boolean isEnabled() { + return configuration.get(PluginSettings.PLAYER_GROUPS_ENABLED); + } + + private PlayerGroup currentGroup(Player player) { + return status(player).group(); + } + + private PlayerGroup automaticGroup(PlayerActivitySnapshot snapshot) { + return snapshot.score() >= configuration.get(PluginSettings.PLAYER_GROUPS).playerThreshold + ? PlayerGroup.PLAYER + : PlayerGroup.NEWBIE; + } + + private PlayerGroup unloadedFallbackGroup() { + return PlayerGroup.NEWBIE; + } + + private SettingsConfiguration.GroupPolicy policyFor(PlayerGroup group) { + SettingsConfiguration.PlayerGroups groups = configuration.get(PluginSettings.PLAYER_GROUPS); + return group == PlayerGroup.PLAYER ? groups.player : groups.newbie; + } + + private void refreshOnlinePlayers() { + if (!isEnabled()) { + return; + } + for (Player player : Bukkit.getOnlinePlayers()) { + refreshAndSynchronize(player); + } + } + + private CompletableFuture refreshAndSynchronize(Player player) { + CompletableFuture> stateFuture = groupStates.getAsync(player.getUniqueId()); + CompletableFuture snapshotFuture = refreshSnapshotAsync(player); + return stateFuture.thenCombine(snapshotFuture, GroupRefresh::new) + .thenCompose(refresh -> synchronizeAutomaticState(player, refresh.snapshot(), refresh.state())); + } + + private CompletableFuture refreshSnapshotAsync(Player player) { + UUID playerId = player.getUniqueId(); + SettingsConfiguration.ActivityWeights weights = configuration.get(PluginSettings.PLAYER_GROUPS).weights; + return CompletableFuture.supplyAsync(() -> { + if (!player.isOnline()) { + return PlayerActivitySnapshot.empty(); + } + PlayerActivitySnapshot snapshot = calculateSnapshot(player, weights); + activitySnapshots.put(playerId, snapshot); + return snapshot; + }, statisticExecutor).exceptionally(exception -> { + logger.error("Failed to refresh player activity snapshot for {}.", playerId, exception); + return activitySnapshots.getOrDefault(playerId, PlayerActivitySnapshot.empty()); + }); + } + + private CompletableFuture synchronizeAutomaticState( + Player player, + PlayerActivitySnapshot snapshot, + Optional currentState + ) { + PlayerGroup automaticGroup = automaticGroup(snapshot); + if (currentState.isPresent() && currentState.get().manualOverride()) { + return CompletableFuture.completedFuture(null); + } + if (currentState.isPresent() && currentState.get().group() == PlayerGroup.PLAYER) { + return CompletableFuture.completedFuture(null); + } + if (automaticGroup == PlayerGroup.PLAYER) { + PlayerGroupState state = new PlayerGroupState( + player.getUniqueId(), + normalizeName(player.getName()), + PlayerGroup.PLAYER, + false, + System.currentTimeMillis(), + null, + "AUTO" + ); + return groupStates.putAndFlushAsync(player.getUniqueId(), state); + } + if (currentState.isPresent()) { + return groupStates.deleteAndFlushAsync(player.getUniqueId()); + } + return CompletableFuture.completedFuture(null); + } + + private PlayerActivitySnapshot calculateSnapshot(Player player, SettingsConfiguration.ActivityWeights weights) { + double playHours = statistic(player, "PLAY_ONE_MINUTE") / 72000.0D; + long minedBlocks = materialStatistic(player, "MINE_BLOCK", true); + double movedBlocks = distanceCentimeters(player) / 100.0D; + long mobKills = statistic(player, "MOB_KILLS"); + long usedItems = materialStatistic(player, "USE_ITEM", false); + long brokenItems = materialStatistic(player, "BREAK_ITEM", false); + long craftedItems = materialStatistic(player, "CRAFT_ITEM", false); + long damageDealt = statistic(player, "DAMAGE_DEALT"); + long damageTaken = statistic(player, "DAMAGE_TAKEN"); + long deaths = statistic(player, "DEATHS"); + long enchantedItems = statistic(player, "ITEM_ENCHANTED"); + long fishCaught = statistic(player, "FISH_CAUGHT"); + long villagerTrades = statistic(player, "TRADED_WITH_VILLAGER"); + double score = playHours * weights.playTimeHours + + minedBlocks * weights.minedBlocks + + movedBlocks * weights.movedBlocks + + mobKills * weights.mobKills + + usedItems * weights.usedItems + + brokenItems * weights.brokenItems + + craftedItems * weights.craftedItems + + damageDealt * weights.damageDealt + + damageTaken * weights.damageTaken + + deaths * weights.deaths + + enchantedItems * weights.enchantedItems + + fishCaught * weights.fishCaught + + villagerTrades * weights.villagerTrades; + return new PlayerActivitySnapshot(score, playHours, minedBlocks, movedBlocks, mobKills, usedItems, + brokenItems, craftedItems, damageDealt, damageTaken, deaths, enchantedItems, fishCaught, villagerTrades); + } + + private long distanceCentimeters(Player player) { + long total = 0L; + for (String statisticName : DISTANCE_STATISTICS) { + total += statistic(player, statisticName); + } + return total; + } + + private static long statistic(Player player, String statisticName) { + try { + return player.getStatistic(Statistic.valueOf(statisticName)); + } catch (IllegalArgumentException exception) { + return 0L; + } + } + + private static long materialStatistic(Player player, String statisticName, boolean blocksOnly) { + Statistic statistic; + try { + statistic = Statistic.valueOf(statisticName); + } catch (IllegalArgumentException exception) { + return 0L; + } + long total = 0L; + for (Material material : Material.values()) { + if (blocksOnly && (!material.isBlock() || material.isAir())) { + continue; + } + if (!blocksOnly && !material.isItem()) { + continue; + } + try { + total += player.getStatistic(statistic, material); + } catch (IllegalArgumentException ignored) { + } + } + return total; + } + + private static GroupModuleMode modeFor(SettingsConfiguration.GroupModules modules, GroupModule module) { + return switch (module) { + case CHAT -> modules.chat; + case COMMAND -> modules.command; + case BOOK -> modules.book; + case SIGN -> modules.sign; + case ANVIL -> modules.anvil; + case ITEM -> modules.item; + }; + } + + private static StorageConfig toStorageConfig(SettingsConfiguration.GroupStorage storage, Path dataDirectory) { + return new StorageConfig( + storage.type, + storage.type == StorageType.SQLITE + ? dataDirectory.resolve(storage.sqliteFile) + : null, + storage.mysql.host, + storage.mysql.port, + storage.mysql.database, + storage.mysql.username, + storage.mysql.password, + storage.poolName, + storage.maximumPoolSize, + storage.minimumIdle, + Duration.ofMillis(storage.connectionTimeoutMillis), + storage.sqliteWal + ); + } + + private static String normalizeName(String name) { + return name.toLowerCase(Locale.ROOT); + } + + private SensitiveWordBs createLinkWordBs() { + SettingsConfiguration.PlayerGroups groups = configuration.get(PluginSettings.PLAYER_GROUPS); + if (!groups.newbie.linkCheck.enabled) { + return null; + } + return SensitiveWordBs.newInstance() + .ignoreCase(configuration.get(PluginSettings.IGNORE_CASE)) + .ignoreWidth(configuration.get(PluginSettings.IGNORE_WIDTH)) + .ignoreNumStyle(configuration.get(PluginSettings.IGNORE_NUM_STYLE)) + .ignoreChineseStyle(configuration.get(PluginSettings.IGNORE_CHINESE_STYLE)) + .ignoreEnglishStyle(configuration.get(PluginSettings.IGNORE_ENGLISH_STYLE)) + .ignoreRepeat(configuration.get(PluginSettings.IGNORE_REPEAT)) + .enableNumCheck(false) + .enableEmailCheck(false) + .enableUrlCheck(true) + .enableWordCheck(false) + .wordResultCondition(WordResultConditions.alwaysTrue()) + .wordCheckUrl(configuration.get(PluginSettings.URL_CHECK_NO_PREFIX) ? WordChecks.urlNoPrefix() : WordChecks.url()) + .wordTag(WordTags.none()) + .wordReplace(new WordReplace()) + .charIgnore(new CharIgnore(configuration.get(PluginSettings.IGNORE_CHAR), CharIgnore.NETWORK_SYNTAX_CHARS)) + .enableIpv4Check(true) + .wordFailFast(true) + .init(); + } + + @Override + public void close() { + closed = true; + SchedulingUtils.cancelTaskSafely(refreshTask); + statisticExecutor.shutdownNow(); + groupStates.close(); + dataSource.close(); + clearNewbieLinkWordBs(); + activitySnapshots.clear(); + tokenBuckets.clear(); + } + + private record GroupRefresh(Optional state, PlayerActivitySnapshot snapshot) { + } + + private static final class TokenBucket { + private final int capacity; + private final long refillIntervalMillis; + private double tokens; + private long lastRefillMillis; + + private TokenBucket(int capacity, long refillIntervalMillis) { + this.capacity = capacity; + this.refillIntervalMillis = refillIntervalMillis; + this.tokens = capacity; + this.lastRefillMillis = System.currentTimeMillis(); + } + + private synchronized boolean tryConsume(long nowMillis) { + refill(nowMillis); + if (tokens < 1.0D) { + return false; + } + tokens -= 1.0D; + return true; + } + + private void refill(long nowMillis) { + long elapsed = Math.max(0L, nowMillis - lastRefillMillis); + if (elapsed == 0L) { + return; + } + double refill = (double) elapsed / refillIntervalMillis * capacity; + tokens = Math.min(capacity, tokens + refill); + lastRefillMillis = nowMillis; + } + } +} diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroupSource.java b/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroupSource.java new file mode 100644 index 00000000..41d0405b --- /dev/null +++ b/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroupSource.java @@ -0,0 +1,6 @@ +package io.wdsj.asw.bukkit.playergroup; + +public enum PlayerGroupSource { + MANUAL, + AUTOMATIC +} diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroupState.java b/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroupState.java new file mode 100644 index 00000000..b5e89bf4 --- /dev/null +++ b/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroupState.java @@ -0,0 +1,21 @@ +package io.wdsj.asw.bukkit.playergroup; + +import java.util.Objects; +import java.util.UUID; + +public record PlayerGroupState( + UUID playerUuid, + String playerNameLower, + PlayerGroup group, + boolean manualOverride, + long updatedAtMillis, + UUID updatedByUuid, + String updatedByName +) { + public PlayerGroupState { + Objects.requireNonNull(playerUuid, "playerUuid"); + Objects.requireNonNull(playerNameLower, "playerNameLower"); + Objects.requireNonNull(group, "group"); + Objects.requireNonNull(updatedByName, "updatedByName"); + } +} diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroupStateRepository.java b/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroupStateRepository.java new file mode 100644 index 00000000..18c12efc --- /dev/null +++ b/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroupStateRepository.java @@ -0,0 +1,168 @@ +package io.wdsj.asw.bukkit.playergroup; + +import io.wdsj.asw.bukkit.persistence.StorageType; +import io.wdsj.asw.bukkit.persistence.WriteBackRepository; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +final class PlayerGroupStateRepository implements WriteBackRepository { + private static final String TABLE = "asw_player_group_states"; + + private final DataSource dataSource; + private final StorageType storageType; + + PlayerGroupStateRepository(DataSource dataSource, StorageType storageType) { + this.dataSource = dataSource; + this.storageType = storageType; + } + + void initialize() throws SQLException { + try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement()) { + if (storageType == StorageType.MYSQL) { + statement.execute(""" + CREATE TABLE IF NOT EXISTS asw_player_group_states ( + player_uuid VARCHAR(36) NOT NULL PRIMARY KEY, + player_name_lower VARCHAR(64) NOT NULL, + group_name VARCHAR(16) NOT NULL, + manual_override BOOLEAN NOT NULL, + updated_at BIGINT NOT NULL, + updated_by_uuid VARCHAR(36) NULL, + updated_by_name VARCHAR(64) NOT NULL, + INDEX idx_asw_group_state_player_name (player_name_lower), + INDEX idx_asw_group_state_group_name (group_name), + INDEX idx_asw_group_state_manual (manual_override) + ) + """); + } else { + statement.execute(""" + CREATE TABLE IF NOT EXISTS asw_player_group_states ( + player_uuid TEXT NOT NULL PRIMARY KEY, + player_name_lower TEXT NOT NULL, + group_name TEXT NOT NULL, + manual_override INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + updated_by_uuid TEXT NULL, + updated_by_name TEXT NOT NULL + ) + """); + statement.execute("CREATE INDEX IF NOT EXISTS idx_asw_group_state_player_name ON " + TABLE + " (player_name_lower)"); + statement.execute("CREATE INDEX IF NOT EXISTS idx_asw_group_state_group_name ON " + TABLE + " (group_name)"); + statement.execute("CREATE INDEX IF NOT EXISTS idx_asw_group_state_manual ON " + TABLE + " (manual_override)"); + } + } + } + + @Override + public Optional load(UUID key) throws Exception { + String sql = "SELECT player_uuid, player_name_lower, group_name, manual_override, updated_at, updated_by_uuid, updated_by_name FROM " + + TABLE + " WHERE player_uuid = ?"; + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, key.toString()); + try (ResultSet resultSet = statement.executeQuery()) { + if (!resultSet.next()) { + return Optional.empty(); + } + return Optional.of(readState(resultSet)); + } + } + } + + @Override + public void save(UUID key, PlayerGroupState value) throws Exception { + try (Connection connection = dataSource.getConnection()) { + saveWithConnection(connection, value); + } + } + + @Override + public void delete(UUID key) throws Exception { + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement("DELETE FROM " + TABLE + " WHERE player_uuid = ?")) { + statement.setString(1, key.toString()); + statement.executeUpdate(); + } + } + + @Override + public void flush(Map> entries) throws Exception { + try (Connection connection = dataSource.getConnection()) { + boolean previousAutoCommit = connection.getAutoCommit(); + connection.setAutoCommit(false); + try { + for (Map.Entry> entry : entries.entrySet()) { + if (entry.getValue().isPresent()) { + saveWithConnection(connection, entry.getValue().get()); + } else { + deleteWithConnection(connection, entry.getKey()); + } + } + connection.commit(); + } catch (Exception exception) { + connection.rollback(); + throw exception; + } finally { + connection.setAutoCommit(previousAutoCommit); + } + } + } + + private void saveWithConnection(Connection connection, PlayerGroupState value) throws SQLException { + String sql; + if (storageType == StorageType.MYSQL) { + sql = value.manualOverride() + ? "INSERT INTO " + TABLE + " (player_uuid, player_name_lower, group_name, manual_override, updated_at, updated_by_uuid, updated_by_name) VALUES (?, ?, ?, ?, ?, ?, ?) " + + "ON DUPLICATE KEY UPDATE player_name_lower = VALUES(player_name_lower), group_name = VALUES(group_name), manual_override = VALUES(manual_override), updated_at = VALUES(updated_at), updated_by_uuid = VALUES(updated_by_uuid), updated_by_name = VALUES(updated_by_name)" + : "INSERT INTO " + TABLE + " (player_uuid, player_name_lower, group_name, manual_override, updated_at, updated_by_uuid, updated_by_name) VALUES (?, ?, ?, ?, ?, ?, ?) " + + "ON DUPLICATE KEY UPDATE player_name_lower = IF(manual_override, player_name_lower, VALUES(player_name_lower)), group_name = IF(manual_override, group_name, VALUES(group_name)), manual_override = IF(manual_override, manual_override, VALUES(manual_override)), updated_at = IF(manual_override, updated_at, VALUES(updated_at)), updated_by_uuid = IF(manual_override, updated_by_uuid, VALUES(updated_by_uuid)), updated_by_name = IF(manual_override, updated_by_name, VALUES(updated_by_name))"; + } else { + sql = value.manualOverride() + ? "INSERT INTO " + TABLE + " (player_uuid, player_name_lower, group_name, manual_override, updated_at, updated_by_uuid, updated_by_name) VALUES (?, ?, ?, ?, ?, ?, ?) " + + "ON CONFLICT(player_uuid) DO UPDATE SET player_name_lower = excluded.player_name_lower, group_name = excluded.group_name, manual_override = excluded.manual_override, updated_at = excluded.updated_at, updated_by_uuid = excluded.updated_by_uuid, updated_by_name = excluded.updated_by_name" + : "INSERT INTO " + TABLE + " (player_uuid, player_name_lower, group_name, manual_override, updated_at, updated_by_uuid, updated_by_name) VALUES (?, ?, ?, ?, ?, ?, ?) " + + "ON CONFLICT(player_uuid) DO UPDATE SET player_name_lower = excluded.player_name_lower, group_name = excluded.group_name, manual_override = excluded.manual_override, updated_at = excluded.updated_at, updated_by_uuid = excluded.updated_by_uuid, updated_by_name = excluded.updated_by_name WHERE " + TABLE + ".manual_override = 0"; + } + try (PreparedStatement statement = connection.prepareStatement(sql)) { + bindSave(statement, value); + statement.executeUpdate(); + } + } + + private void deleteWithConnection(Connection connection, UUID key) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement("DELETE FROM " + TABLE + " WHERE player_uuid = ?")) { + statement.setString(1, key.toString()); + statement.executeUpdate(); + } + } + + private static void bindSave(PreparedStatement statement, PlayerGroupState value) throws SQLException { + statement.setString(1, value.playerUuid().toString()); + statement.setString(2, value.playerNameLower()); + statement.setString(3, value.group().name()); + statement.setBoolean(4, value.manualOverride()); + statement.setLong(5, value.updatedAtMillis()); + statement.setString(6, value.updatedByUuid() == null ? null : value.updatedByUuid().toString()); + statement.setString(7, value.updatedByName()); + } + + private static PlayerGroupState readState(ResultSet resultSet) throws SQLException { + String updatedByUuid = resultSet.getString("updated_by_uuid"); + return new PlayerGroupState( + UUID.fromString(resultSet.getString("player_uuid")), + resultSet.getString("player_name_lower"), + PlayerGroup.valueOf(resultSet.getString("group_name")), + resultSet.getBoolean("manual_override"), + resultSet.getLong("updated_at"), + updatedByUuid == null ? null : UUID.fromString(updatedByUuid), + resultSet.getString("updated_by_name") + ); + } +} diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroupStatus.java b/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroupStatus.java new file mode 100644 index 00000000..e269ff57 --- /dev/null +++ b/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroupStatus.java @@ -0,0 +1,9 @@ +package io.wdsj.asw.bukkit.playergroup; + +public record PlayerGroupStatus( + PlayerGroup group, + PlayerGroupSource source, + PlayerActivitySnapshot activity, + double threshold +) { +} diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/service/ListenerService.java b/paper/src/main/java/io/wdsj/asw/bukkit/service/ListenerService.java index fba82b3c..142d5c19 100644 --- a/paper/src/main/java/io/wdsj/asw/bukkit/service/ListenerService.java +++ b/paper/src/main/java/io/wdsj/asw/bukkit/service/ListenerService.java @@ -18,6 +18,7 @@ public class ListenerService { private final AdvancedSensitiveWords plugin; private final PaperConfigurationService configuration; private final LlmChatDetectionService llmChatDetectionService; + private boolean playerGroupListenerRegistered; public ListenerService(AdvancedSensitiveWords plugin) { this.plugin = plugin; @@ -51,6 +52,7 @@ public void registerListeners() { registerEventListener(new BroadcastListener(configuration)); } registerEventListener(new QuitDataCleaner()); + registerPlayerGroupListenerIfNeeded(); if (configuration.get(PluginSettings.CHECK_FOR_UPDATE)) { registerEventListener(new JoinUpdateNotifier(configuration)); } @@ -64,6 +66,7 @@ public void unregisterListeners() { public void reloadConfiguration() { llmChatDetectionService.reload(); + registerPlayerGroupListenerIfNeeded(); } public LlmChatDetectionService getLlmChatDetectionService() { @@ -75,6 +78,14 @@ private void registerEventListener(Listener listener) { Bukkit.getPluginManager().registerEvents(listener, plugin); } + private void registerPlayerGroupListenerIfNeeded() { + if (playerGroupListenerRegistered || plugin.getPlayerGroupService() == null) { + return; + } + registerEventListener(new PlayerGroupListener(plugin.getPlayerGroupService())); + playerGroupListenerRegistered = true; + } + private void registerChatBookEventListeners() { if (configuration.get(PluginSettings.ENABLE_CHAT_CHECK)) { registerEventListener(llmChatDetectionService); diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/setting/ChineseMessagesConfiguration.java b/paper/src/main/java/io/wdsj/asw/bukkit/setting/ChineseMessagesConfiguration.java index 6d3ef218..59d19764 100644 --- a/paper/src/main/java/io/wdsj/asw/bukkit/setting/ChineseMessagesConfiguration.java +++ b/paper/src/main/java/io/wdsj/asw/bukkit/setting/ChineseMessagesConfiguration.java @@ -27,6 +27,21 @@ public ChineseMessagesConfiguration() { 无效 %invalid% | 已执行 %enforced% 队列 %active% 活跃 / %queued% 排队 / %pool_size% 线程 通知/处罚阈值 %thresholds%"""; + plugin.playerGroupNewbieJoin = "服务器新玩家会有一段时间的聊天严管,请勿频繁发送消息、发送长消息或发送链接。正常游玩即可结束严管。"; + plugin.playerGroupNewbieRateLimit = "请放慢聊天速度,新玩家存在临时聊天频率限制。"; + plugin.playerGroupNewbieLinkBlocked = "新玩家暂时不能发送链接。"; + plugin.playerGroupInfo = """ + 玩家活跃分组 | %player% + 分组 %group% | 来源 %source% + 活跃分 %score% / %threshold% + 游玩 %play_hours%h | 移动 %moved_blocks% 方块 | 挖掘 %mined_blocks% + 击杀 %mob_kills% | 使用 %used_items% | 损坏 %broken_items% | 合成 %crafted_items% + 伤害 %damage_dealt%/%damage_taken% | 死亡 %deaths% | 附魔 %enchanted_items% + 钓鱼 %fish_caught% | 交易 %villager_trades%"""; + plugin.playerGroupSet = "已将玩家 %player% 设置为 %group% 分组。"; + plugin.playerGroupClear = "已清除玩家 %player% 的手动分组覆盖。"; + plugin.playerGroupDisabled = "玩家分组系统未启用。"; + plugin.playerGroupStorageError = "玩家分组存储操作失败。"; plugin.commandTest.testResultTrue = """ 鉴定报告 原消息 %original_msg% diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/setting/MessagesConfiguration.java b/paper/src/main/java/io/wdsj/asw/bukkit/setting/MessagesConfiguration.java index 407abcaf..fc490f11 100644 --- a/paper/src/main/java/io/wdsj/asw/bukkit/setting/MessagesConfiguration.java +++ b/paper/src/main/java/io/wdsj/asw/bukkit/setting/MessagesConfiguration.java @@ -79,6 +79,29 @@ public static final class Plugin { Invalid %invalid% | Enforced %enforced% Queue %active% active / %queued% queued / %pool_size% workers Notify/Punish thresholds %thresholds%"""; + @Comment("Message sent to NEWBIE players when they join.") + public String playerGroupNewbieJoin = "New players are temporarily moderated more strictly. Please avoid spam, long repeated messages, and links."; + @Comment("Message sent when NEWBIE rate limiting cancels chat or a command.") + public String playerGroupNewbieRateLimit = "Please slow down. New players have a temporary chat rate limit."; + @Comment("Message sent when NEWBIE link checking cancels chat or a command.") + public String playerGroupNewbieLinkBlocked = "New players cannot send links yet."; + @Comment("Player group information command output.") + public String playerGroupInfo = """ + Player Group | %player% + Group %group% | Source %source% + Activity %score% / %threshold% + Play %play_hours%h | Move %moved_blocks% blocks | Mined %mined_blocks% + Mobs %mob_kills% | Use %used_items% | Break %broken_items% | Craft %crafted_items% + Damage %damage_dealt%/%damage_taken% | Deaths %deaths% | Enchant %enchanted_items% + Fish %fish_caught% | Trades %villager_trades%"""; + @Comment("Message sent after manually setting a player group.") + public String playerGroupSet = "Set %player% to group %group%."; + @Comment("Message sent after clearing a manual player group override.") + public String playerGroupClear = "Cleared manual group override for %player%."; + @Comment("Message sent when player groups are disabled.") + public String playerGroupDisabled = "Player groups are disabled."; + @Comment("Message sent when player group storage fails.") + public String playerGroupStorageError = "Player group storage operation failed."; @Comment("Messages used by the test command.") public CommandTest commandTest = new CommandTest(); @Comment("Messages used by the punishment command.") diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/setting/PaperConfigurationService.java b/paper/src/main/java/io/wdsj/asw/bukkit/setting/PaperConfigurationService.java index c2023bba..d566a1af 100644 --- a/paper/src/main/java/io/wdsj/asw/bukkit/setting/PaperConfigurationService.java +++ b/paper/src/main/java/io/wdsj/asw/bukkit/setting/PaperConfigurationService.java @@ -99,6 +99,14 @@ public String message(PluginMessages key) { case MESSAGE_ON_VIOLATION_RESET -> snapshot.plugin.messageOnViolationReset; case MESSAGE_ON_COMMAND_STATUS -> snapshot.plugin.messageOnCommandStatus; case MESSAGE_ON_AI_STATUS -> snapshot.plugin.messageOnAiStatus; + case PLAYER_GROUP_NEWBIE_JOIN -> snapshot.plugin.playerGroupNewbieJoin; + case PLAYER_GROUP_NEWBIE_RATE_LIMIT -> snapshot.plugin.playerGroupNewbieRateLimit; + case PLAYER_GROUP_NEWBIE_LINK_BLOCKED -> snapshot.plugin.playerGroupNewbieLinkBlocked; + case PLAYER_GROUP_INFO -> snapshot.plugin.playerGroupInfo; + case PLAYER_GROUP_SET -> snapshot.plugin.playerGroupSet; + case PLAYER_GROUP_CLEAR -> snapshot.plugin.playerGroupClear; + case PLAYER_GROUP_DISABLED -> snapshot.plugin.playerGroupDisabled; + case PLAYER_GROUP_STORAGE_ERROR -> snapshot.plugin.playerGroupStorageError; case MESSAGE_ON_COMMAND_TEST -> snapshot.plugin.commandTest.testResultTrue; case MESSAGE_ON_COMMAND_TEST_PASS -> snapshot.plugin.commandTest.testResultPass; case MESSAGE_ON_COMMAND_TEST_NOT_INIT -> snapshot.plugin.commandTest.testNotInit; @@ -166,6 +174,7 @@ static void validateSettings(SettingsConfiguration settings) { } validateCategoryPolicy(ai.categoryPolicy); validateAntiSpam(settings.chat.antiSpam); + validatePlayerGroups(settings.playerGroups); CommandArgumentRuleSet.compile(settings.chat.commandWhiteList); if (!ai.enabled) { @@ -251,6 +260,80 @@ private static void validateAntiSpam(SettingsConfiguration.AntiSpam antiSpam) { } } + private static void validatePlayerGroups(SettingsConfiguration.PlayerGroups playerGroups) { + if (playerGroups == null) { + throw new IllegalArgumentException("player-groups settings cannot be null"); + } + if (!Double.isFinite(playerGroups.playerThreshold) || playerGroups.playerThreshold < 0.0D) { + throw new IllegalArgumentException("player-groups.player-threshold must be a finite non-negative number"); + } + if (playerGroups.refreshIntervalSeconds < 1) { + throw new IllegalArgumentException("player-groups.refresh-interval-seconds must be at least 1"); + } + validateGroupStorage(playerGroups.storage); + validateWeights(playerGroups.weights); + validateGroupPolicy(playerGroups.newbie, "newbie"); + validateGroupPolicy(playerGroups.player, "player"); + } + + private static void validateGroupStorage(SettingsConfiguration.GroupStorage storage) { + if (storage == null) { + throw new IllegalArgumentException("player-groups.storage cannot be null"); + } + if (storage.type == null) { + throw new IllegalArgumentException("player-groups.storage.type cannot be null"); + } + if (storage.sqliteFile == null || storage.sqliteFile.isBlank()) { + throw new IllegalArgumentException("player-groups.storage.sqlite-file cannot be blank"); + } + if (storage.poolName == null || storage.poolName.isBlank()) { + throw new IllegalArgumentException("player-groups.storage.pool-name cannot be blank"); + } + if (storage.maximumPoolSize < 1 || storage.minimumIdle < 0 || storage.minimumIdle > storage.maximumPoolSize) { + throw new IllegalArgumentException("player-groups.storage Hikari pool settings are invalid"); + } + if (storage.connectionTimeoutMillis < 1L) { + throw new IllegalArgumentException("player-groups.storage.connection-timeout-millis must be positive"); + } + if (storage.type == io.wdsj.asw.bukkit.persistence.StorageType.MYSQL) { + SettingsConfiguration.Mysql mysql = storage.mysql; + if (mysql == null + || mysql.host == null || mysql.host.isBlank() + || mysql.port <= 0 + || mysql.database == null || mysql.database.isBlank() + || mysql.username == null + || mysql.password == null) { + throw new IllegalArgumentException("player-groups.storage.mysql host, port, database, username and password are required"); + } + } + } + + private static void validateWeights(SettingsConfiguration.ActivityWeights weights) { + if (weights == null) { + throw new IllegalArgumentException("player-groups.weights cannot be null"); + } + double[] values = { + weights.playTimeHours, weights.minedBlocks, weights.movedBlocks, weights.mobKills, + weights.usedItems, weights.brokenItems, weights.craftedItems, weights.damageDealt, + weights.damageTaken, weights.deaths, weights.enchantedItems, weights.fishCaught, + weights.villagerTrades + }; + for (double value : values) { + if (!Double.isFinite(value) || value < 0.0D) { + throw new IllegalArgumentException("player-groups weights must be finite non-negative numbers"); + } + } + } + + private static void validateGroupPolicy(SettingsConfiguration.GroupPolicy policy, String name) { + if (policy == null || policy.modules == null || policy.rateLimit == null || policy.linkCheck == null) { + throw new IllegalArgumentException("player-groups.groups." + name + " is incomplete"); + } + if (policy.rateLimit.capacity < 1 || policy.rateLimit.refillIntervalSeconds < 1) { + throw new IllegalArgumentException("player-groups.groups." + name + ".rate-limit settings must be at least 1"); + } + } + private static void validateHttpUrl(String value, String settingName) { requireText(value, settingName); URI uri; diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/setting/PluginMessages.java b/paper/src/main/java/io/wdsj/asw/bukkit/setting/PluginMessages.java index 8ae409d5..88650e7e 100644 --- a/paper/src/main/java/io/wdsj/asw/bukkit/setting/PluginMessages.java +++ b/paper/src/main/java/io/wdsj/asw/bukkit/setting/PluginMessages.java @@ -15,6 +15,14 @@ public enum PluginMessages { MESSAGE_ON_VIOLATION_RESET, MESSAGE_ON_COMMAND_STATUS, MESSAGE_ON_AI_STATUS, + PLAYER_GROUP_NEWBIE_JOIN, + PLAYER_GROUP_NEWBIE_RATE_LIMIT, + PLAYER_GROUP_NEWBIE_LINK_BLOCKED, + PLAYER_GROUP_INFO, + PLAYER_GROUP_SET, + PLAYER_GROUP_CLEAR, + PLAYER_GROUP_DISABLED, + PLAYER_GROUP_STORAGE_ERROR, MESSAGE_ON_COMMAND_TEST, MESSAGE_ON_COMMAND_TEST_PASS, MESSAGE_ON_COMMAND_TEST_NOT_INIT, diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/setting/PluginSettings.java b/paper/src/main/java/io/wdsj/asw/bukkit/setting/PluginSettings.java index 01955a85..2f09d4a8 100644 --- a/paper/src/main/java/io/wdsj/asw/bukkit/setting/PluginSettings.java +++ b/paper/src/main/java/io/wdsj/asw/bukkit/setting/PluginSettings.java @@ -131,6 +131,8 @@ public final class PluginSettings { public static final SettingKey ITEM_METHOD = key(settings -> settings.item.method); public static final SettingKey ITEM_SEND_MESSAGE = key(settings -> settings.item.sendMessage); public static final SettingKey> ITEM_PUNISHMENT = key(settings -> settings.item.punishment); + public static final SettingKey PLAYER_GROUPS = key(settings -> settings.playerGroups); + public static final SettingKey PLAYER_GROUPS_ENABLED = key(settings -> settings.playerGroups.enabled); private PluginSettings() { } diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/setting/SettingsConfiguration.java b/paper/src/main/java/io/wdsj/asw/bukkit/setting/SettingsConfiguration.java index d0817f89..cca351d7 100644 --- a/paper/src/main/java/io/wdsj/asw/bukkit/setting/SettingsConfiguration.java +++ b/paper/src/main/java/io/wdsj/asw/bukkit/setting/SettingsConfiguration.java @@ -3,6 +3,8 @@ import de.exlll.configlib.Comment; import de.exlll.configlib.Configuration; import io.wdsj.asw.bukkit.ai.LlmApiMode; +import io.wdsj.asw.bukkit.persistence.StorageType; +import io.wdsj.asw.bukkit.playergroup.GroupModuleMode; import io.wdsj.asw.bukkit.type.ProcessMethod; import java.util.ArrayList; @@ -28,6 +30,8 @@ public final class SettingsConfiguration { public Name name = new Name(); @Comment({"Item filtering settings."}) public Item item = new Item(); + @Comment({"Player activity group settings."}) + public PlayerGroups playerGroups = new PlayerGroups(); @Configuration public static final class Plugin { @@ -92,10 +96,10 @@ public static final class Plugin { public boolean hookVelocity = false; @Comment("Plugin compatibility settings.") public Compatibility compatibility = new Compatibility(); - @Comment("Characters ignored by the sensitive-word matcher.") - public String ignoreChar = "`-—=~~!!@#$%^&§*()_+[]{}\\|;:'\"“”,,.。、()<>??¥【】《》 "; + @Comment("Characters ignored by the sensitive-word matcher. URL/IP/email syntax characters are protected automatically when those checks are enabled.") + public String ignoreChar = "`-—=~~!!@#$%^&§*()_+[]{}\\|;:'\"“”,,。、()<>??¥【】《》 "; @Comment("Whether to remove formatting before detection.") - public boolean enablePreProcess = false; + public boolean enablePreProcess = true; @Comment("Regular expression used by preprocessing.") public String preProcessRegex = "[§&][0-9A-Fa-fK-Ok-oRr]"; @Comment("Whether to ignore letter case.") @@ -121,7 +125,7 @@ public static final class Plugin { @Comment("Whether to check URLs.") public boolean enableUrlCheck = true; @Comment("Whether URLs without an HTTP(S) prefix should be checked.") - public boolean urlCheckNoPrefix = false; + public boolean urlCheckNoPrefix = true; @Comment("Whether to check sensitive English words.") public boolean enableWordCheck = true; @Comment("Whether to check IPv4 addresses.") @@ -383,4 +387,140 @@ public static final class Item { @Comment("Punishment actions for item violations. Leave empty to disable automatic punishment.") public List punishment = new ArrayList<>(); } + + @Configuration + public static final class PlayerGroups { + @Comment("Whether to enable activity-based player groups.") + public boolean enabled = false; + @Comment("Activity score required to be treated as PLAYER instead of NEWBIE.") + public double playerThreshold = 70000.0D; + @Comment("How often online player statistics are refreshed on the server thread.") + public int refreshIntervalSeconds = 60; + @Comment("Persistent storage for cross-server group states and manual group overrides.") + public GroupStorage storage = new GroupStorage(); + @Comment("Statistic weights used to calculate activity score.") + public ActivityWeights weights = new ActivityWeights(); + @Comment("NEWBIE group policy.") + public GroupPolicy newbie = GroupPolicy.newbie(); + @Comment("PLAYER group policy.") + public GroupPolicy player = GroupPolicy.player(); + } + + @Configuration + public static final class GroupStorage { + @Comment("Storage backend for player group states: SQLITE or MYSQL.") + public StorageType type = StorageType.SQLITE; + @Comment("SQLite database path relative to the plugin data folder.") + public String sqliteFile = "player-groups/groups.db"; + @Comment("Whether SQLite should use WAL journal mode.") + public boolean sqliteWal = true; + @Comment("HikariCP pool name.") + public String poolName = "ASW-PlayerGroups"; + @Comment("Maximum HikariCP pool size. SQLite is capped internally to 3.") + public int maximumPoolSize = 3; + @Comment("Minimum idle HikariCP connections.") + public int minimumIdle = 1; + @Comment("Connection timeout in milliseconds.") + public long connectionTimeoutMillis = 5000L; + @Comment("MySQL storage settings. Used only when type is MYSQL.") + public Mysql mysql = new Mysql(); + } + + @Configuration + public static final class Mysql { + @Comment("MySQL host.") + public String host = "127.0.0.1"; + @Comment("MySQL port.") + public int port = 3306; + @Comment("MySQL database.") + public String database = "advanced_sensitive_words"; + @Comment("MySQL username.") + public String username = "root"; + @Comment("MySQL password.") + public String password = ""; + } + + @Configuration + public static final class ActivityWeights { + @Comment("Weight per hour of play time.") + public double playTimeHours = 1.0D; + @Comment("Weight per mined block.") + public double minedBlocks = 0.2D; + @Comment("Weight per moved block.") + public double movedBlocks = 0.001D; + @Comment("Weight per mob kill.") + public double mobKills = 1.0D; + @Comment("Weight per used item statistic.") + public double usedItems = 0.02D; + @Comment("Weight per broken item statistic.") + public double brokenItems = 1.0D; + @Comment("Weight per crafted item statistic.") + public double craftedItems = 0.1D; + @Comment("Weight per raw damage-dealt statistic point.") + public double damageDealt = 0.01D; + @Comment("Weight per raw damage-taken statistic point.") + public double damageTaken = 0.01D; + @Comment("Weight per death.") + public double deaths = 1.0D; + @Comment("Weight per enchanted item.") + public double enchantedItems = 1.0D; + @Comment("Weight per fish caught.") + public double fishCaught = 1.0D; + @Comment("Weight per villager trade.") + public double villagerTrades = 1.0D; + } + + @Configuration + public static final class GroupPolicy { + @Comment({ + "Per-module overrides. DEFAULT inherits the global enable switch.", + "ENABLED only has an effect when the global module switch is already enabled.", + "DISABLED skips this module for players in this group." + }) + public GroupModules modules = new GroupModules(); + @Comment("Whether players in this group receive a join message.") + public boolean sendJoinMessage = false; + @Comment("NEWBIE-only token-bucket chat/command rate limit.") + public RateLimit rateLimit = new RateLimit(); + @Comment("NEWBIE-only link check for chat and selected command arguments.") + public LinkCheck linkCheck = new LinkCheck(); + + private static GroupPolicy newbie() { + GroupPolicy policy = new GroupPolicy(); + policy.sendJoinMessage = true; + policy.rateLimit.enabled = true; + policy.linkCheck.enabled = true; + return policy; + } + + private static GroupPolicy player() { + return new GroupPolicy(); + } + } + + @Configuration + public static final class GroupModules { + public GroupModuleMode chat = GroupModuleMode.DEFAULT; + public GroupModuleMode command = GroupModuleMode.DEFAULT; + public GroupModuleMode book = GroupModuleMode.DEFAULT; + public GroupModuleMode sign = GroupModuleMode.DEFAULT; + public GroupModuleMode anvil = GroupModuleMode.DEFAULT; + public GroupModuleMode item = GroupModuleMode.DEFAULT; + } + + @Configuration + public static final class RateLimit { + @Comment("Whether to enable the NEWBIE token bucket.") + public boolean enabled = false; + @Comment("Maximum stored tokens, also the allowed burst size.") + public int capacity = 3; + @Comment("Seconds needed to refill the full bucket.") + public int refillIntervalSeconds = 15; + } + + @Configuration + public static final class LinkCheck { + @Comment("Whether NEWBIE players are blocked from sending links in chat and selected command arguments.") + public boolean enabled = false; + } } diff --git a/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/AnvilListener.kt b/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/AnvilListener.kt index ee9e47d3..7f975734 100644 --- a/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/AnvilListener.kt +++ b/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/AnvilListener.kt @@ -1,6 +1,7 @@ package io.wdsj.asw.bukkit.listener -import io.wdsj.asw.bukkit.AdvancedSensitiveWords.sensitiveWordBs +import io.wdsj.asw.bukkit.AdvancedSensitiveWords +import io.wdsj.asw.bukkit.playergroup.GroupModule import io.wdsj.asw.bukkit.permission.PermissionsEnum import io.wdsj.asw.bukkit.setting.PaperConfigurationService import io.wdsj.asw.bukkit.setting.PluginMessages @@ -24,12 +25,14 @@ class AnvilListener(private val configuration: PaperConfigurationService) : List @EventHandler(priority = EventPriority.LOWEST) fun onAnvil(event: InventoryClickEvent) { - if (!configuration.get(PluginSettings.ENABLE_ANVIL_EDIT_CHECK)) return + val globalEnabled = configuration.get(PluginSettings.ENABLE_ANVIL_EDIT_CHECK) + if (!globalEnabled) return if (event.inventory.type != InventoryType.ANVIL) return if (event.rawSlot != 2) return val player = event.whoClicked as? Player ?: return if (processingGuard.shouldSkipBasic(player, PermissionsEnum.BYPASS_ANVIL)) return + if (processingGuard.shouldSkipGroupModule(player, GroupModule.ANVIL, globalEnabled)) return val outputItem = event.currentItem ?: return if (!outputItem.hasItemMeta()) return @@ -40,7 +43,7 @@ class AnvilListener(private val configuration: PaperConfigurationService) : List val originalNameComponent = itemMeta.displayName() ?: return val originalItemName = preprocess(MessageUtils.plainText(originalNameComponent)) val startTime = System.currentTimeMillis() - val censoredWords = sensitiveWordBs.findAll(originalItemName) + val censoredWords = AdvancedSensitiveWords.findAllSensitive(originalItemName) SensitiveFilterEvents.post(event.isAsynchronous, ModuleType.ANVIL, player, originalItemName, censoredWords) if (censoredWords.isEmpty()) return @@ -51,7 +54,7 @@ class AnvilListener(private val configuration: PaperConfigurationService) : List MessageUtils.replaceLiteral( originalNameComponent, originalItemName, - sensitiveWordBs.replace(originalItemName), + AdvancedSensitiveWords.replaceSensitive(originalItemName), ), ) outputItem.setItemMeta(itemMeta) diff --git a/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/BookListener.kt b/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/BookListener.kt index 73dcd6fb..e9efab31 100644 --- a/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/BookListener.kt +++ b/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/BookListener.kt @@ -1,6 +1,7 @@ package io.wdsj.asw.bukkit.listener -import io.wdsj.asw.bukkit.AdvancedSensitiveWords.sensitiveWordBs +import io.wdsj.asw.bukkit.AdvancedSensitiveWords +import io.wdsj.asw.bukkit.playergroup.GroupModule import io.wdsj.asw.bukkit.permission.PermissionsEnum import io.wdsj.asw.bukkit.setting.PaperConfigurationService import io.wdsj.asw.bukkit.setting.PluginMessages @@ -25,10 +26,12 @@ class BookListener(private val configuration: PaperConfigurationService) : Liste @EventHandler(priority = EventPriority.LOWEST) fun onBook(event: PlayerEditBookEvent) { - if (!configuration.get(PluginSettings.ENABLE_BOOK_EDIT_CHECK)) return + val globalEnabled = configuration.get(PluginSettings.ENABLE_BOOK_EDIT_CHECK) + if (!globalEnabled) return val player = event.player if (processingGuard.shouldSkipBasic(player, PermissionsEnum.BYPASS_BOOK)) return + if (processingGuard.shouldSkipGroupModule(player, GroupModule.BOOK, globalEnabled)) return val startTime = System.currentTimeMillis() val bookMeta = event.newBookMeta @@ -87,7 +90,7 @@ class BookListener(private val configuration: PaperConfigurationService) : Liste val originalPageCrossed = (1..event.newBookMeta.pageCount) .joinToString("") { MessageUtils.plainText(event.newBookMeta.page(it)) } .let { preprocessPageText(it) } - val censoredWords = sensitiveWordBs.findAll(originalPageCrossed) + val censoredWords = AdvancedSensitiveWords.findAllSensitive(originalPageCrossed) SensitiveFilterEvents.post(event.isAsynchronous, ModuleType.BOOK, event.player, originalPageCrossed, censoredWords) if (censoredWords.isEmpty()) return null @@ -98,14 +101,14 @@ class BookListener(private val configuration: PaperConfigurationService) : Liste private fun censorAuthor(event: PlayerEditBookEvent, bookMeta: BookMeta): BookViolation? { val author = event.newBookMeta.author() ?: return null val originalAuthor = preprocessText(MessageUtils.plainText(author)) - val censoredWords = sensitiveWordBs.findAll(originalAuthor) + val censoredWords = AdvancedSensitiveWords.findAllSensitive(originalAuthor) SensitiveFilterEvents.post(event.isAsynchronous, ModuleType.BOOK, event.player, originalAuthor, censoredWords) if (censoredWords.isEmpty()) return null if (isCancelMode()) { event.isCancelled = true } else { - bookMeta.author(MessageUtils.replaceLiteral(author, originalAuthor, sensitiveWordBs.replace(originalAuthor))) + bookMeta.author(MessageUtils.replaceLiteral(author, originalAuthor, AdvancedSensitiveWords.replaceSensitive(originalAuthor))) } return BookViolation(originalAuthor, censoredWords) } @@ -113,37 +116,37 @@ class BookListener(private val configuration: PaperConfigurationService) : Liste private fun censorTitle(event: PlayerEditBookEvent, bookMeta: BookMeta): BookViolation? { val title = event.newBookMeta.title() ?: return null val originalTitle = preprocessText(MessageUtils.plainText(title)) - val censoredWords = sensitiveWordBs.findAll(originalTitle) + val censoredWords = AdvancedSensitiveWords.findAllSensitive(originalTitle) SensitiveFilterEvents.post(event.isAsynchronous, ModuleType.BOOK, event.player, originalTitle, censoredWords) if (censoredWords.isEmpty()) return null if (isCancelMode()) { event.isCancelled = true } else { - bookMeta.title(MessageUtils.replaceLiteral(title, originalTitle, sensitiveWordBs.replace(originalTitle))) + bookMeta.title(MessageUtils.replaceLiteral(title, originalTitle, AdvancedSensitiveWords.replaceSensitive(originalTitle))) } return BookViolation(originalTitle, censoredWords) } private fun findPageCensoredWords(page: String): List { if (!configuration.get(PluginSettings.BOOK_CACHE)) { - return sensitiveWordBs.findAll(page) + return AdvancedSensitiveWords.findAllSensitive(page) } if (BookCache.isBookCached(page)) { return BookCache.getCachedBookSensitiveWordList(page) } - return sensitiveWordBs.findAll(page) + return AdvancedSensitiveWords.findAllSensitive(page) } private fun replacePage(page: Component, pagePlainText: String, censoredWords: List): Component { if (!configuration.get(PluginSettings.BOOK_CACHE)) { - return MessageUtils.replaceLiteral(page, pagePlainText, sensitiveWordBs.replace(pagePlainText)) + return MessageUtils.replaceLiteral(page, pagePlainText, AdvancedSensitiveWords.replaceSensitive(pagePlainText)) } if (BookCache.isBookCached(pagePlainText)) { return MessageUtils.replaceLiteral(page, pagePlainText, BookCache.getCachedProcessedBookContent(pagePlainText)) } - val processedPage = sensitiveWordBs.replace(pagePlainText) + val processedPage = AdvancedSensitiveWords.replaceSensitive(pagePlainText) BookCache.addToBookCache(pagePlainText, processedPage, censoredWords) return MessageUtils.replaceLiteral(page, pagePlainText, processedPage) } diff --git a/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/BroadcastListener.kt b/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/BroadcastListener.kt index 73476c06..87f628ac 100644 --- a/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/BroadcastListener.kt +++ b/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/BroadcastListener.kt @@ -1,7 +1,7 @@ package io.wdsj.asw.bukkit.listener import io.wdsj.asw.bukkit.AdvancedSensitiveWords.isInitialized -import io.wdsj.asw.bukkit.AdvancedSensitiveWords.sensitiveWordBs +import io.wdsj.asw.bukkit.AdvancedSensitiveWords import io.wdsj.asw.bukkit.setting.PaperConfigurationService import io.wdsj.asw.bukkit.setting.PluginSettings import io.wdsj.asw.bukkit.type.ModuleType @@ -24,7 +24,7 @@ class BroadcastListener(private val configuration: PaperConfigurationService) : val originalComponent = event.message() val originalMessage = preprocess(MessageUtils.plainText(originalComponent)) val startTime = System.currentTimeMillis() - val censoredWords = sensitiveWordBs.findAll(originalMessage) + val censoredWords = AdvancedSensitiveWords.findAllSensitive(originalMessage) SensitiveFilterEvents.post( event.isAsynchronous, ModuleType.BROADCAST, @@ -43,7 +43,7 @@ class BroadcastListener(private val configuration: PaperConfigurationService) : MessageUtils.replaceLiteral( originalComponent, originalMessage, - sensitiveWordBs.replace(originalMessage), + AdvancedSensitiveWords.replaceSensitive(originalMessage), ), ) } diff --git a/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/CommandListener.kt b/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/CommandListener.kt index 3cd9c0f5..056989da 100644 --- a/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/CommandListener.kt +++ b/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/CommandListener.kt @@ -1,7 +1,8 @@ package io.wdsj.asw.bukkit.listener -import io.wdsj.asw.bukkit.AdvancedSensitiveWords.sensitiveWordBs +import io.wdsj.asw.bukkit.AdvancedSensitiveWords import io.wdsj.asw.bukkit.listener.command.CommandArgumentRuleSet +import io.wdsj.asw.bukkit.playergroup.GroupModule import io.wdsj.asw.bukkit.permission.PermissionsEnum import io.wdsj.asw.bukkit.setting.PaperConfigurationService import io.wdsj.asw.bukkit.setting.PluginMessages @@ -24,17 +25,20 @@ class CommandListener(private val configuration: PaperConfigurationService) : Li @EventHandler(priority = EventPriority.LOWEST) fun onCommand(event: PlayerCommandPreprocessEvent) { - if (!configuration.get(PluginSettings.ENABLE_CHAT_CHECK)) return + val globalEnabled = configuration.get(PluginSettings.ENABLE_CHAT_CHECK) + if (!globalEnabled) return val originalCommand = preprocess(event.message) val player = event.player if (processingGuard.shouldSkip(player, PermissionsEnum.BYPASS_COMMAND)) return + if (processingGuard.shouldSkipGroupModule(player, GroupModule.COMMAND, globalEnabled)) return val selection = configuration.commandArgumentRules().select(originalCommand) if (!configuration.shouldInspectCommand(selection) || selection.segments().isEmpty()) return + if (handleNewbieRestrictions(event, player, selection.scannedContent())) return val startTime = System.currentTimeMillis() - val censoredWords = selection.segments().flatMap { segment -> sensitiveWordBs.findAll(segment.content()) } + val censoredWords = selection.segments().flatMap { segment -> AdvancedSensitiveWords.findAllSensitive(segment.content()) } SensitiveFilterEvents.post(event.isAsynchronous, ModuleType.CHAT, player, selection.scannedContent(), censoredWords) if (censoredWords.isEmpty()) return @@ -42,6 +46,25 @@ class CommandListener(private val configuration: PaperConfigurationService) : Li recordViolation(event, player, selection.scannedContent(), censoredWords, startTime) } + private fun handleNewbieRestrictions( + event: PlayerCommandPreprocessEvent, + player: Player, + content: String, + ): Boolean { + val service = io.wdsj.asw.bukkit.AdvancedSensitiveWords.getInstance().playerGroupService ?: return false + if (!service.consumeNewbieToken(player)) { + event.isCancelled = true + MessageUtils.sendMessage(player, PluginMessages.PLAYER_GROUP_NEWBIE_RATE_LIMIT) + return true + } + if (service.containsNewbieLink(player, content)) { + event.isCancelled = true + MessageUtils.sendMessage(player, PluginMessages.PLAYER_GROUP_NEWBIE_LINK_BLOCKED) + return true + } + return false + } + private fun preprocess(message: String): String { if (!configuration.get(PluginSettings.PRE_PROCESS)) return message return message.replace(Utils.preProcessRegex.toRegex(), "") @@ -56,7 +79,7 @@ class CommandListener(private val configuration: PaperConfigurationService) : Li return } - val processedCommand = selection.replaceSelected(sensitiveWordBs::replace) + val processedCommand = selection.replaceSelected(AdvancedSensitiveWords::replaceSensitive) event.message = if (Utils.isCommand(processedCommand)) processedCommand else "/$processedCommand" } diff --git a/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/PlayerGroupListener.kt b/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/PlayerGroupListener.kt new file mode 100644 index 00000000..28499126 --- /dev/null +++ b/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/PlayerGroupListener.kt @@ -0,0 +1,13 @@ +package io.wdsj.asw.bukkit.listener + +import io.wdsj.asw.bukkit.playergroup.PlayerGroupService +import org.bukkit.event.EventHandler +import org.bukkit.event.Listener +import org.bukkit.event.player.PlayerJoinEvent + +class PlayerGroupListener(private val playerGroupService: PlayerGroupService) : Listener { + @EventHandler + fun onJoin(event: PlayerJoinEvent) { + playerGroupService.handleJoin(event.player) + } +} diff --git a/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/PlayerItemListener.kt b/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/PlayerItemListener.kt index 30352265..81017ec2 100644 --- a/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/PlayerItemListener.kt +++ b/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/PlayerItemListener.kt @@ -1,6 +1,7 @@ package io.wdsj.asw.bukkit.listener -import io.wdsj.asw.bukkit.AdvancedSensitiveWords.sensitiveWordBs +import io.wdsj.asw.bukkit.AdvancedSensitiveWords +import io.wdsj.asw.bukkit.playergroup.GroupModule import io.wdsj.asw.bukkit.permission.PermissionsEnum import io.wdsj.asw.bukkit.setting.PaperConfigurationService import io.wdsj.asw.bukkit.setting.PluginMessages @@ -29,10 +30,12 @@ class PlayerItemListener(private val configuration: PaperConfigurationService) : @EventHandler(priority = EventPriority.LOW) fun onPlayerHeldItem(event: PlayerItemHeldEvent) { - if (!configuration.get(PluginSettings.ENABLE_PLAYER_ITEM_CHECK)) return + val globalEnabled = configuration.get(PluginSettings.ENABLE_PLAYER_ITEM_CHECK) + if (!globalEnabled) return val player = event.player if (processingGuard.shouldSkipBasic(player, PermissionsEnum.BYPASS_ITEM)) return + if (processingGuard.shouldSkipGroupModule(player, GroupModule.ITEM, globalEnabled)) return val item = player.inventory.getItem(event.newSlot) ?: return censorItemName(player, item, event) @@ -40,20 +43,24 @@ class PlayerItemListener(private val configuration: PaperConfigurationService) : @EventHandler(priority = EventPriority.LOW) fun onDrop(event: PlayerDropItemEvent) { - if (!configuration.get(PluginSettings.ENABLE_PLAYER_ITEM_CHECK)) return + val globalEnabled = configuration.get(PluginSettings.ENABLE_PLAYER_ITEM_CHECK) + if (!globalEnabled) return val player = event.player if (processingGuard.shouldSkipBasic(player, PermissionsEnum.BYPASS_ITEM)) return + if (processingGuard.shouldSkipGroupModule(player, GroupModule.ITEM, globalEnabled)) return censorItemName(player, event.itemDrop.itemStack, event) } @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) fun onClick(event: InventoryClickEvent) { - if (!configuration.get(PluginSettings.ENABLE_PLAYER_ITEM_CHECK)) return + val globalEnabled = configuration.get(PluginSettings.ENABLE_PLAYER_ITEM_CHECK) + if (!globalEnabled) return val player = event.whoClicked as? Player ?: return if (processingGuard.shouldSkipBasic(player, PermissionsEnum.BYPASS_ITEM)) return + if (processingGuard.shouldSkipGroupModule(player, GroupModule.ITEM, globalEnabled)) return if (event.clickedInventory?.type != InventoryType.PLAYER) return val item = event.currentItem ?: return @@ -70,7 +77,7 @@ class PlayerItemListener(private val configuration: PaperConfigurationService) : val originalName = preprocess(MessageUtils.plainText(originalNameComponent)) val startTime = System.currentTimeMillis() val asynchronous = (event as? Event)?.isAsynchronous ?: false - val censoredWords = sensitiveWordBs.findAll(originalName) + val censoredWords = AdvancedSensitiveWords.findAllSensitive(originalName) SensitiveFilterEvents.post(asynchronous, ModuleType.ITEM, player, originalName, censoredWords) if (censoredWords.isEmpty()) return @@ -81,7 +88,7 @@ class PlayerItemListener(private val configuration: PaperConfigurationService) : MessageUtils.replaceLiteral( originalNameComponent, originalName, - sensitiveWordBs.replace(originalName), + AdvancedSensitiveWords.replaceSensitive(originalName), ), ) item.setItemMeta(meta) diff --git a/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/PlayerLoginListener.kt b/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/PlayerLoginListener.kt index 5d69e91c..b39156c4 100644 --- a/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/PlayerLoginListener.kt +++ b/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/PlayerLoginListener.kt @@ -1,7 +1,7 @@ package io.wdsj.asw.bukkit.listener import io.papermc.paper.event.player.PlayerServerFullCheckEvent -import io.wdsj.asw.bukkit.AdvancedSensitiveWords.sensitiveWordBs +import io.wdsj.asw.bukkit.AdvancedSensitiveWords import io.wdsj.asw.bukkit.setting.PaperConfigurationService import io.wdsj.asw.bukkit.manage.notice.Notifier import io.wdsj.asw.bukkit.setting.PluginMessages @@ -26,7 +26,7 @@ class PlayerLoginListener(private val configuration: PaperConfigurationService) val playerName = event.playerProfile.name ?: return val startTime = System.currentTimeMillis() - val censoredWords = sensitiveWordBs.findAll(playerName) + val censoredWords = AdvancedSensitiveWords.findAllSensitive(playerName) SensitiveFilterEvents.post( event.isAsynchronous, ModuleType.NAME, diff --git a/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/QuitDataCleaner.kt b/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/QuitDataCleaner.kt index 07d9d29e..fa8089b6 100644 --- a/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/QuitDataCleaner.kt +++ b/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/QuitDataCleaner.kt @@ -1,5 +1,6 @@ package io.wdsj.asw.bukkit.listener +import io.wdsj.asw.bukkit.AdvancedSensitiveWords import io.wdsj.asw.bukkit.service.chat.antispam.ChatAntiSpamService import io.wdsj.asw.bukkit.manage.punish.PlayerShadowController import io.wdsj.asw.bukkit.util.context.ChatContext @@ -28,6 +29,7 @@ class QuitDataCleaner : Listener { ChatContext.clearPlayerContext(player) SignContext.clearPlayerContext(player) ChatAntiSpamService.clear(player.uniqueId) + AdvancedSensitiveWords.getInstance().playerGroupService?.handleQuit(player.uniqueId) PlayerShadowController.unshadowPlayer(player) } } diff --git a/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/SignListener.kt b/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/SignListener.kt index 50aa697b..80381748 100644 --- a/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/SignListener.kt +++ b/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/SignListener.kt @@ -1,10 +1,9 @@ package io.wdsj.asw.bukkit.listener import com.github.houbb.sensitive.word.api.IWordResult -import com.github.houbb.sensitive.word.support.result.WordResultHandlers import io.wdsj.asw.bukkit.AdvancedSensitiveWords -import io.wdsj.asw.bukkit.AdvancedSensitiveWords.sensitiveWordBs import io.wdsj.asw.bukkit.permission.PermissionsEnum +import io.wdsj.asw.bukkit.playergroup.GroupModule import io.wdsj.asw.bukkit.setting.PaperConfigurationService import io.wdsj.asw.bukkit.integration.packetevents.sign.SignFakeViewService import io.wdsj.asw.bukkit.setting.PluginMessages @@ -37,11 +36,13 @@ class SignListener(private val configuration: PaperConfigurationService) : Liste @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) fun onSign(event: SignChangeEvent) { - if (!configuration.get(PluginSettings.ENABLE_SIGN_EDIT_CHECK)) return + val globalEnabled = configuration.get(PluginSettings.ENABLE_SIGN_EDIT_CHECK) + if (!globalEnabled) return if (event.lines().isEmpty()) return val player = event.player if (processingGuard.shouldSkipBasic(player, PermissionsEnum.BYPASS_SIGN)) return + if (processingGuard.shouldSkipGroupModule(player, GroupModule.SIGN, globalEnabled)) return val startTime = System.currentTimeMillis() val attemptedLines = event.lines().toList() @@ -105,7 +106,7 @@ class SignListener(private val configuration: PaperConfigurationService) : Liste for (lineIndex in event.lines().indices) { val originalComponent = event.line(lineIndex) ?: continue val originalMessage = preprocess(MessageUtils.plainText(originalComponent)) - val censoredWords = sensitiveWordBs.findAll(originalMessage) + val censoredWords = AdvancedSensitiveWords.findAllSensitive(originalMessage) SensitiveFilterEvents.post(event.isAsynchronous, ModuleType.SIGN, player, originalMessage, censoredWords) if (censoredWords.isEmpty()) { @@ -121,7 +122,7 @@ class SignListener(private val configuration: PaperConfigurationService) : Liste event.isCancelled = true continue } - val processedMessage = sensitiveWordBs.replace(originalMessage) + val processedMessage = AdvancedSensitiveWords.replaceSensitive(originalMessage) event.line(lineIndex, MessageUtils.replaceLiteral(originalComponent, originalMessage, processedMessage)) } @@ -133,14 +134,14 @@ class SignListener(private val configuration: PaperConfigurationService) : Liste if (lineScan.cleanLineIndexes.isEmpty()) return null val originalContent = lineScan.cleanLineContent - val censoredWords = sensitiveWordBs.findAll(originalContent) + val censoredWords = AdvancedSensitiveWords.findAllSensitive(originalContent) SensitiveFilterEvents.post(event.isAsynchronous, ModuleType.SIGN, event.player, originalContent, censoredWords) if (censoredWords.isEmpty()) return null if (isCancelMode()) { event.isCancelled = true } else { - val processedMessage = sensitiveWordBs.replace(originalContent) + val processedMessage = AdvancedSensitiveWords.replaceSensitive(originalContent) for (lineIndex in lineScan.cleanLineIndexes) { event.line(lineIndex, MessageUtils.plainTextComponent(processedMessage)) } @@ -156,7 +157,7 @@ class SignListener(private val configuration: PaperConfigurationService) : Liste SignContext.addMessage(player, entry) val entries = SignContext.getHistory(player) val originalContext = entries.joinToString("") { it.content } - val censoredWords = sensitiveWordBs.findAll(originalContext) + val censoredWords = AdvancedSensitiveWords.findAllSensitive(originalContext) SensitiveFilterEvents.post(event.isAsynchronous, ModuleType.SIGN, player, originalContext, censoredWords) if (censoredWords.isEmpty()) return null @@ -236,7 +237,7 @@ class SignListener(private val configuration: PaperConfigurationService) : Liste val replacements = Array(entries.size) { StringBuilder() } val affectedEntries = linkedSetOf() - val results = sensitiveWordBs.findAll(context, WordResultHandlers.raw()) + val results = AdvancedSensitiveWords.findAllSensitiveRaw(context) .sortedWith(compareBy { it.startIndex() }.thenByDescending { it.endIndex() }) var cursor = 0 for (result in results) { diff --git a/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/paper/PaperChatListener.kt b/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/paper/PaperChatListener.kt index 40510307..4a6c83e1 100644 --- a/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/paper/PaperChatListener.kt +++ b/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/paper/PaperChatListener.kt @@ -1,10 +1,11 @@ package io.wdsj.asw.bukkit.listener.paper import io.papermc.paper.event.player.AsyncChatEvent -import io.wdsj.asw.bukkit.AdvancedSensitiveWords.sensitiveWordBs +import io.wdsj.asw.bukkit.AdvancedSensitiveWords import io.wdsj.asw.bukkit.ai.LlmChatDetectionService import io.wdsj.asw.bukkit.integration.trchat.TrChatCompat import io.wdsj.asw.bukkit.listener.abstraction.AbstractFakeMessageExecutor +import io.wdsj.asw.bukkit.playergroup.GroupModule import io.wdsj.asw.bukkit.permission.PermissionsEnum import io.wdsj.asw.bukkit.service.chat.antispam.ChatAntiSpamService import io.wdsj.asw.bukkit.setting.PaperConfigurationService @@ -36,14 +37,17 @@ class PaperChatListener( @EventHandler(priority = EventPriority.LOWEST) fun onChat(event: AsyncChatEvent) { - if (!configuration.get(PluginSettings.ENABLE_CHAT_CHECK)) return + val globalEnabled = configuration.get(PluginSettings.ENABLE_CHAT_CHECK) + if (!globalEnabled) return val player = event.player if (processingGuard.shouldSkip(player, PermissionsEnum.BYPASS_CHAT)) return + if (processingGuard.shouldSkipGroupModule(player, GroupModule.CHAT, globalEnabled)) return val startTime = System.currentTimeMillis() val originalMessage = preprocess(event.message()) val originalPlainText = PlainTextComponentSerializer.plainText().serialize(originalMessage) + if (handleNewbieRestrictions(event, player, originalPlainText)) return if (antiSpamService.check(player.uniqueId, originalPlainText) == ChatAntiSpamService.Result.SPAM) { event.isCancelled = true @@ -53,7 +57,7 @@ class PaperChatListener( return } - val censoredWords = sensitiveWordBs.findAll(originalPlainText) + val censoredWords = AdvancedSensitiveWords.findAllSensitive(originalPlainText) SensitiveFilterEvents.post( event.isAsynchronous, ModuleType.CHAT, @@ -63,7 +67,7 @@ class PaperChatListener( ) if (censoredWords.isNotEmpty()) { - handleDirectMessage(event, player, originalMessage, originalPlainText, censoredWords, startTime) + handleDirectMessage(event, player, originalMessage, originalPlainText = originalPlainText, censoredWords, startTime) return } @@ -72,6 +76,21 @@ class PaperChatListener( } } + private fun handleNewbieRestrictions(event: AsyncChatEvent, player: Player, content: String): Boolean { + val service = AdvancedSensitiveWords.getInstance().playerGroupService ?: return false + if (!service.consumeNewbieToken(player)) { + event.isCancelled = true + MessageUtils.sendMessage(player, PluginMessages.PLAYER_GROUP_NEWBIE_RATE_LIMIT) + return true + } + if (service.containsNewbieLink(player, content)) { + event.isCancelled = true + MessageUtils.sendMessage(player, PluginMessages.PLAYER_GROUP_NEWBIE_LINK_BLOCKED) + return true + } + return false + } + private fun preprocess(message: Component): Component { if (!configuration.get(PluginSettings.PRE_PROCESS)) return message @@ -108,7 +127,7 @@ class PaperChatListener( return } - val processedMessage = sensitiveWordBs.replace(originalPlainText) + val processedMessage = AdvancedSensitiveWords.replaceSensitive(originalPlainText) val replacementConfig = TextReplacementConfig.builder() .matchLiteral(originalPlainText) .replacement(processedMessage) @@ -126,7 +145,7 @@ class PaperChatListener( ChatContext.addMessage(player, originalPlainText) val originalContext = ChatContext.getHistory(player).joinToString("") - val censoredWords = sensitiveWordBs.findAll(originalContext) + val censoredWords = AdvancedSensitiveWords.findAllSensitive(originalContext) SensitiveFilterEvents.post(event.isAsynchronous, ModuleType.CHAT, player, originalContext, censoredWords) if (censoredWords.isEmpty()) return false diff --git a/paper/src/main/kotlin/io/wdsj/asw/bukkit/util/PlayerProcessingGuard.kt b/paper/src/main/kotlin/io/wdsj/asw/bukkit/util/PlayerProcessingGuard.kt index 6c3bac0b..38086b06 100644 --- a/paper/src/main/kotlin/io/wdsj/asw/bukkit/util/PlayerProcessingGuard.kt +++ b/paper/src/main/kotlin/io/wdsj/asw/bukkit/util/PlayerProcessingGuard.kt @@ -1,8 +1,10 @@ package io.wdsj.asw.bukkit.util import fr.xephi.authme.api.v3.AuthMeApi +import io.wdsj.asw.bukkit.AdvancedSensitiveWords import io.wdsj.asw.bukkit.AdvancedSensitiveWords.isAuthMeAvailable import io.wdsj.asw.bukkit.AdvancedSensitiveWords.isInitialized +import io.wdsj.asw.bukkit.playergroup.GroupModule import io.wdsj.asw.bukkit.setting.PaperConfigurationService import io.wdsj.asw.bukkit.permission.PermissionsEnum import io.wdsj.asw.bukkit.permission.cache.CachingPermTool @@ -22,6 +24,11 @@ class PlayerProcessingGuard(private val configuration: PaperConfigurationService CachingPermTool.hasPermission(bypassPermission, player) } + fun shouldSkipGroupModule(player: Player, module: GroupModule, globalEnabled: Boolean): Boolean { + val service = AdvancedSensitiveWords.getInstance().playerGroupService ?: return false + return !service.isModuleEnabled(player, module, globalEnabled) + } + private fun isUnauthenticated(player: Player): Boolean { return isAuthMeAvailable && configuration.get(PluginSettings.ENABLE_AUTHME_COMPATIBILITY) && diff --git a/paper/src/main/resources/plugin.yml b/paper/src/main/resources/plugin.yml index 63850f9b..a35973c2 100644 --- a/paper/src/main/resources/plugin.yml +++ b/paper/src/main/resources/plugin.yml @@ -11,6 +11,8 @@ libraries: - dev.langchain4j:langchain4j-open-ai:${langchain4jVersion} - dev.langchain4j:langchain4j-anthropic:${langchain4jVersion} - dev.langchain4j:langchain4j-http-client-jdk:${langchain4jVersion} + - com.github.ben-manes.caffeine:caffeine:${caffeineVersion} + - com.zaxxer:HikariCP:${hikariCPVersion} loadbefore: - YouCanUseThisToPrioritizeOtherChatPlugins softdepend: @@ -57,6 +59,9 @@ permissions: advancedsensitivewords.command.player.reset: true advancedsensitivewords.command.player.punish: true advancedsensitivewords.command.ai.status: true + advancedsensitivewords.command.group.info: true + advancedsensitivewords.command.group.set: true + advancedsensitivewords.command.group.clear: true advancedsensitivewords.bypass: description: Global bypass for all player-owned Advanced Sensitive Words filters. default: false @@ -123,3 +128,12 @@ permissions: advancedsensitivewords.command.ai.status: description: Show AI status. default: op + advancedsensitivewords.command.group.info: + description: Show a player's activity group. + default: op + advancedsensitivewords.command.group.set: + description: Set a player's manual activity group. + default: op + advancedsensitivewords.command.group.clear: + description: Clear a player's manual activity group. + default: op diff --git a/paper/src/test/java/io/wdsj/asw/bukkit/persistence/WriteBackCacheStressTest.java b/paper/src/test/java/io/wdsj/asw/bukkit/persistence/WriteBackCacheStressTest.java new file mode 100644 index 00000000..f1bf3e50 --- /dev/null +++ b/paper/src/test/java/io/wdsj/asw/bukkit/persistence/WriteBackCacheStressTest.java @@ -0,0 +1,261 @@ +package io.wdsj.asw.bukkit.persistence; + +import com.github.benmanes.caffeine.cache.Caffeine; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Level; +import java.util.logging.Logger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class WriteBackCacheStressTest { + @Test + void batchReadAndCustomQueryCanObserveFlushedDirtyWrites() { + InMemoryRepository repository = new InMemoryRepository(); + repository.store.put(1, new TestValue(1, 100L)); + repository.store.put(2, new TestValue(2, 200L)); + + try (WriteBackCache cache = newCache("BatchQuery", repository, 100L)) { + Map> loaded = cache.getAllAsync(List.of(2, 3, 1)).join(); + + assertEquals(List.of(2, 3, 1), new ArrayList<>(loaded.keySet())); + assertEquals(Optional.of(new TestValue(2, 200L)), loaded.get(2)); + assertEquals(Optional.empty(), loaded.get(3)); + assertEquals(Optional.of(new TestValue(1, 100L)), loaded.get(1)); + + cache.put(3, new TestValue(3, 300L)); + + List queried = cache.flushDirtyAsync() + .thenCompose(ignored -> cache.queryAsync(() -> repository.findUpdatedSince(250L))) + .join(); + assertEquals(List.of(3), queried); + } + } + + @Test + void loadFailureCompletesFutureExceptionally() { + withCaffeineAsyncLoadWarningsSuppressed(() -> { + InMemoryRepository repository = new InMemoryRepository(); + repository.failLoads = true; + + try (WriteBackCache cache = newCache("LoadFailure", repository, 100L)) { + assertThrows(RuntimeException.class, () -> cache.getAsync(1).join()); + } + }); + } + + @Test + void batchLoadFailureCompletesFutureExceptionally() { + withCaffeineAsyncLoadWarningsSuppressed(() -> { + InMemoryRepository repository = new InMemoryRepository(); + repository.store.put(1, new TestValue(1, 100L)); + repository.failLoads = true; + + try (WriteBackCache cache = newCache("BatchLoadFailure", repository, 100L)) { + assertThrows(RuntimeException.class, () -> cache.getAllAsync(List.of(1, 2)).join()); + } + }); + } + + @Test + void staleFlushDoesNotClearNewerDirtyWriteForSameKey() throws Exception { + InMemoryRepository repository = new InMemoryRepository(); + repository.blockNextSaveForKey(1); + + try (WriteBackCache cache = newCache("SameKeyRace", repository, 100L)) { + cache.put(1, new TestValue(10, 10L)); + + CompletableFuture firstFlush = cache.flushDirtyAsync(); + assertTrue(repository.awaitBlockedSave(), "first save did not start"); + + cache.put(1, new TestValue(20, 20L)); + repository.releaseBlockedSave(); + firstFlush.join(); + + cache.flushDirtyAsync().join(); + } + + assertEquals(new TestValue(20, 20L), repository.store.get(1)); + } + + @Test + void dirtyFlushUsesSingleRepositoryBatch() { + int entries = 128; + InMemoryRepository repository = new InMemoryRepository(); + + try (WriteBackCache cache = newCache("BatchFlush", repository, entries * 2L)) { + for (int key = 0; key < entries; key++) { + cache.put(key, new TestValue(key, key)); + } + + cache.flushDirtyAsync().join(); + } + + assertEquals(1, repository.flushCalls.get()); + assertEquals(entries, repository.saveCalls.get()); + assertEquals(entries, repository.store.size()); + } + + @Test + void concurrentWritesAndInvalidationsArePersistedWithoutBlockingCallers() { + int entries = 3_000; + InMemoryRepository repository = new InMemoryRepository(); + + try (WriteBackCache cache = newCache("Stress", repository, entries * 2L)) { + ExecutorService callers = Executors.newFixedThreadPool(24); + try { + List> futures = new ArrayList<>(entries); + for (int key = 0; key < entries; key++) { + int currentKey = key; + futures.add(CompletableFuture.runAsync(() -> { + cache.put(currentKey, new TestValue(currentKey, currentKey)); + if ((currentKey & 1) == 0) { + cache.invalidate(currentKey); + } + if (currentKey % 17 == 0) { + cache.putAndFlushAsync(currentKey, new TestValue(currentKey, currentKey)).join(); + } + }, callers)); + } + CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)).join(); + } finally { + callers.shutdown(); + assertTrue(awaitTermination(callers), "caller executor did not stop"); + } + } + + assertEquals(entries, repository.store.size()); + for (int key = 0; key < entries; key++) { + assertEquals(new TestValue(key, key), repository.store.get(key)); + } + } + + private static WriteBackCache newCache( + String name, + InMemoryRepository repository, + long maximumSize + ) { + return new WriteBackCache<>( + name, + repository, + LoggerFactory.getLogger(WriteBackCacheStressTest.class), + Caffeine.newBuilder() + .expireAfterAccess(30L, TimeUnit.MINUTES) + .maximumSize(maximumSize), + Duration.ofHours(1L), + Duration.ofSeconds(30L), + FlushPolicy.PERIODIC + ); + } + + private static boolean awaitTermination(ExecutorService executor) { + try { + return executor.awaitTermination(30L, TimeUnit.SECONDS); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + return false; + } + } + + private static void withCaffeineAsyncLoadWarningsSuppressed(Runnable runnable) { + Logger logger = Logger.getLogger("com.github.benmanes.caffeine.cache.LocalAsyncCache"); + Level previousLevel = logger.getLevel(); + logger.setLevel(Level.OFF); + try { + runnable.run(); + } finally { + logger.setLevel(previousLevel); + } + } + + private record TestValue(int value, long updatedAt) { + } + + private static final class InMemoryRepository implements WriteBackRepository { + private final Map store = new ConcurrentHashMap<>(); + private final AtomicBoolean blockSave = new AtomicBoolean(); + private final AtomicInteger flushCalls = new AtomicInteger(); + private final AtomicInteger saveCalls = new AtomicInteger(); + private final AtomicInteger deleteCalls = new AtomicInteger(); + private volatile boolean failLoads; + private volatile int blockedKey; + private volatile CountDownLatch saveStarted = new CountDownLatch(0); + private volatile CountDownLatch releaseSave = new CountDownLatch(0); + + @Override + public Optional load(Integer key) { + if (failLoads) { + throw new IllegalStateException("load failed"); + } + return Optional.ofNullable(store.get(key)); + } + + @Override + public void save(Integer key, TestValue value) throws InterruptedException { + saveCalls.incrementAndGet(); + if (key == blockedKey && blockSave.compareAndSet(true, false)) { + saveStarted.countDown(); + assertTrue(releaseSave.await(30L, TimeUnit.SECONDS), "blocked save was not released"); + } + store.put(key, value); + } + + @Override + public void delete(Integer key) { + deleteCalls.incrementAndGet(); + store.remove(key); + } + + @Override + public void flush(Map> entries) throws Exception { + flushCalls.incrementAndGet(); + WriteBackRepository.super.flush(entries); + } + + private List findUpdatedSince(long since) { + return store.entrySet().stream() + .filter(entry -> entry.getValue().updatedAt() >= since) + .sorted(Map.Entry.comparingByValue((left, right) -> Long.compare(right.updatedAt(), left.updatedAt()))) + .collect( + LinkedHashMap::new, + (map, entry) -> map.put(entry.getKey(), entry.getValue()), + LinkedHashMap::putAll + ) + .keySet() + .stream() + .toList(); + } + + private void blockNextSaveForKey(int key) { + blockedKey = key; + saveStarted = new CountDownLatch(1); + releaseSave = new CountDownLatch(1); + blockSave.set(true); + } + + private boolean awaitBlockedSave() throws InterruptedException { + return saveStarted.await(30L, TimeUnit.SECONDS); + } + + private void releaseBlockedSave() { + releaseSave.countDown(); + } + } +} From 007bbe6082ef01cf2348fedd8522e9bb3b2c22ef Mon Sep 17 00:00:00 2001 From: HaHaWTH <102713261+HaHaWTH@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:45:47 +0800 Subject: [PATCH 2/2] move package & merge activities cross servers --- .../asw/bukkit/AdvancedSensitiveWords.java | 2 +- .../bukkit/command/AswCommandRegistrar.java | 2 +- .../asw/bukkit/command/AswCommandService.java | 8 +- .../persistence/DataSourceFactory.java | 2 +- .../{ => core}/persistence/FlushPolicy.java | 2 +- .../{ => core}/persistence/StorageConfig.java | 2 +- .../{ => core}/persistence/StorageType.java | 2 +- .../persistence/WriteBackCache.java | 2 +- .../persistence/WriteBackRepository.java | 2 +- .../{ => manage}/playergroup/GroupModule.java | 2 +- .../playergroup/GroupModuleMode.java | 2 +- .../playergroup/PlayerActivitySnapshot.java | 60 +++++ .../{ => manage}/playergroup/PlayerGroup.java | 2 +- .../PlayerGroupActivityRepository.java | 241 ++++++++++++++++++ .../playergroup/PlayerGroupActivityState.java | 17 ++ .../playergroup/PlayerGroupService.java | 139 +++++++--- .../playergroup/PlayerGroupSource.java | 2 +- .../playergroup/PlayerGroupState.java | 2 +- .../PlayerGroupStateRepository.java | 6 +- .../playergroup/PlayerGroupStatus.java | 2 +- .../playergroup/PlayerActivitySnapshot.java | 22 -- .../setting/PaperConfigurationService.java | 9 +- .../bukkit/setting/SettingsConfiguration.java | 7 +- .../wdsj/asw/bukkit/listener/AnvilListener.kt | 2 +- .../wdsj/asw/bukkit/listener/BookListener.kt | 2 +- .../asw/bukkit/listener/CommandListener.kt | 2 +- .../bukkit/listener/PlayerGroupListener.kt | 8 +- .../asw/bukkit/listener/PlayerItemListener.kt | 2 +- .../asw/bukkit/listener/QuitDataCleaner.kt | 2 +- .../wdsj/asw/bukkit/listener/SignListener.kt | 2 +- .../listener/paper/PaperChatListener.kt | 2 +- .../asw/bukkit/util/PlayerProcessingGuard.kt | 2 +- .../persistence/WriteBackCacheStressTest.java | 3 + 33 files changed, 474 insertions(+), 90 deletions(-) rename paper/src/main/java/io/wdsj/asw/bukkit/{ => core}/persistence/DataSourceFactory.java (98%) rename paper/src/main/java/io/wdsj/asw/bukkit/{ => core}/persistence/FlushPolicy.java (91%) rename paper/src/main/java/io/wdsj/asw/bukkit/{ => core}/persistence/StorageConfig.java (98%) rename paper/src/main/java/io/wdsj/asw/bukkit/{ => core}/persistence/StorageType.java (53%) rename paper/src/main/java/io/wdsj/asw/bukkit/{ => core}/persistence/WriteBackCache.java (99%) rename paper/src/main/java/io/wdsj/asw/bukkit/{ => core}/persistence/WriteBackRepository.java (98%) rename paper/src/main/java/io/wdsj/asw/bukkit/{ => manage}/playergroup/GroupModule.java (66%) rename paper/src/main/java/io/wdsj/asw/bukkit/{ => manage}/playergroup/GroupModuleMode.java (60%) create mode 100644 paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerActivitySnapshot.java rename paper/src/main/java/io/wdsj/asw/bukkit/{ => manage}/playergroup/PlayerGroup.java (88%) create mode 100644 paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupActivityRepository.java create mode 100644 paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupActivityState.java rename paper/src/main/java/io/wdsj/asw/bukkit/{ => manage}/playergroup/PlayerGroupService.java (79%) rename paper/src/main/java/io/wdsj/asw/bukkit/{ => manage}/playergroup/PlayerGroupSource.java (56%) rename paper/src/main/java/io/wdsj/asw/bukkit/{ => manage}/playergroup/PlayerGroupState.java (92%) rename paper/src/main/java/io/wdsj/asw/bukkit/{ => manage}/playergroup/PlayerGroupStateRepository.java (98%) rename paper/src/main/java/io/wdsj/asw/bukkit/{ => manage}/playergroup/PlayerGroupStatus.java (78%) delete mode 100644 paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerActivitySnapshot.java diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/AdvancedSensitiveWords.java b/paper/src/main/java/io/wdsj/asw/bukkit/AdvancedSensitiveWords.java index 95231ba2..9d9a0dac 100644 --- a/paper/src/main/java/io/wdsj/asw/bukkit/AdvancedSensitiveWords.java +++ b/paper/src/main/java/io/wdsj/asw/bukkit/AdvancedSensitiveWords.java @@ -18,7 +18,7 @@ import io.wdsj.asw.bukkit.ai.LlmChatDetectionService; import io.wdsj.asw.bukkit.core.condition.WordResultConditionNumMatch; import io.wdsj.asw.bukkit.integration.placeholder.ASWExpansion; -import io.wdsj.asw.bukkit.playergroup.PlayerGroupService; +import io.wdsj.asw.bukkit.manage.playergroup.PlayerGroupService; import io.wdsj.asw.bukkit.service.chat.antispam.ChatAntiSpamService; import io.wdsj.asw.bukkit.manage.punish.PlayerAltController; import io.wdsj.asw.bukkit.manage.punish.PlayerShadowController; diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/command/AswCommandRegistrar.java b/paper/src/main/java/io/wdsj/asw/bukkit/command/AswCommandRegistrar.java index 7ad61ca1..59ff7092 100644 --- a/paper/src/main/java/io/wdsj/asw/bukkit/command/AswCommandRegistrar.java +++ b/paper/src/main/java/io/wdsj/asw/bukkit/command/AswCommandRegistrar.java @@ -1,7 +1,7 @@ package io.wdsj.asw.bukkit.command; import io.wdsj.asw.bukkit.AdvancedSensitiveWords; -import io.wdsj.asw.bukkit.playergroup.PlayerGroup; +import io.wdsj.asw.bukkit.manage.playergroup.PlayerGroup; import io.wdsj.asw.bukkit.setting.PluginMessages; import io.wdsj.asw.bukkit.type.ModuleType; import io.wdsj.asw.bukkit.util.message.MessageUtils; diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/command/AswCommandService.java b/paper/src/main/java/io/wdsj/asw/bukkit/command/AswCommandService.java index 6c10d10e..7481d3d2 100644 --- a/paper/src/main/java/io/wdsj/asw/bukkit/command/AswCommandService.java +++ b/paper/src/main/java/io/wdsj/asw/bukkit/command/AswCommandService.java @@ -7,10 +7,10 @@ import io.wdsj.asw.bukkit.api.moderation.LlmModerationCategory; import io.wdsj.asw.bukkit.manage.punish.PunishmentService; import io.wdsj.asw.bukkit.manage.punish.ViolationCounter; -import io.wdsj.asw.bukkit.playergroup.PlayerActivitySnapshot; -import io.wdsj.asw.bukkit.playergroup.PlayerGroup; -import io.wdsj.asw.bukkit.playergroup.PlayerGroupService; -import io.wdsj.asw.bukkit.playergroup.PlayerGroupStatus; +import io.wdsj.asw.bukkit.manage.playergroup.PlayerActivitySnapshot; +import io.wdsj.asw.bukkit.manage.playergroup.PlayerGroup; +import io.wdsj.asw.bukkit.manage.playergroup.PlayerGroupService; +import io.wdsj.asw.bukkit.manage.playergroup.PlayerGroupStatus; import io.wdsj.asw.bukkit.setting.PluginMessages; import io.wdsj.asw.bukkit.setting.PluginSettings; import io.wdsj.asw.bukkit.setting.PaperConfigurationService; diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/persistence/DataSourceFactory.java b/paper/src/main/java/io/wdsj/asw/bukkit/core/persistence/DataSourceFactory.java similarity index 98% rename from paper/src/main/java/io/wdsj/asw/bukkit/persistence/DataSourceFactory.java rename to paper/src/main/java/io/wdsj/asw/bukkit/core/persistence/DataSourceFactory.java index 35edfe3d..58a9e913 100644 --- a/paper/src/main/java/io/wdsj/asw/bukkit/persistence/DataSourceFactory.java +++ b/paper/src/main/java/io/wdsj/asw/bukkit/core/persistence/DataSourceFactory.java @@ -1,4 +1,4 @@ -package io.wdsj.asw.bukkit.persistence; +package io.wdsj.asw.bukkit.core.persistence; import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/persistence/FlushPolicy.java b/paper/src/main/java/io/wdsj/asw/bukkit/core/persistence/FlushPolicy.java similarity index 91% rename from paper/src/main/java/io/wdsj/asw/bukkit/persistence/FlushPolicy.java rename to paper/src/main/java/io/wdsj/asw/bukkit/core/persistence/FlushPolicy.java index c554b356..d9626642 100644 --- a/paper/src/main/java/io/wdsj/asw/bukkit/persistence/FlushPolicy.java +++ b/paper/src/main/java/io/wdsj/asw/bukkit/core/persistence/FlushPolicy.java @@ -1,4 +1,4 @@ -package io.wdsj.asw.bukkit.persistence; +package io.wdsj.asw.bukkit.core.persistence; public enum FlushPolicy { /** diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/persistence/StorageConfig.java b/paper/src/main/java/io/wdsj/asw/bukkit/core/persistence/StorageConfig.java similarity index 98% rename from paper/src/main/java/io/wdsj/asw/bukkit/persistence/StorageConfig.java rename to paper/src/main/java/io/wdsj/asw/bukkit/core/persistence/StorageConfig.java index aa4a92ac..01ec2b92 100644 --- a/paper/src/main/java/io/wdsj/asw/bukkit/persistence/StorageConfig.java +++ b/paper/src/main/java/io/wdsj/asw/bukkit/core/persistence/StorageConfig.java @@ -1,4 +1,4 @@ -package io.wdsj.asw.bukkit.persistence; +package io.wdsj.asw.bukkit.core.persistence; import java.nio.file.Path; import java.time.Duration; diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/persistence/StorageType.java b/paper/src/main/java/io/wdsj/asw/bukkit/core/persistence/StorageType.java similarity index 53% rename from paper/src/main/java/io/wdsj/asw/bukkit/persistence/StorageType.java rename to paper/src/main/java/io/wdsj/asw/bukkit/core/persistence/StorageType.java index 61725153..c42043ff 100644 --- a/paper/src/main/java/io/wdsj/asw/bukkit/persistence/StorageType.java +++ b/paper/src/main/java/io/wdsj/asw/bukkit/core/persistence/StorageType.java @@ -1,4 +1,4 @@ -package io.wdsj.asw.bukkit.persistence; +package io.wdsj.asw.bukkit.core.persistence; public enum StorageType { SQLITE, diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/persistence/WriteBackCache.java b/paper/src/main/java/io/wdsj/asw/bukkit/core/persistence/WriteBackCache.java similarity index 99% rename from paper/src/main/java/io/wdsj/asw/bukkit/persistence/WriteBackCache.java rename to paper/src/main/java/io/wdsj/asw/bukkit/core/persistence/WriteBackCache.java index 9e67da82..b4d31ec4 100644 --- a/paper/src/main/java/io/wdsj/asw/bukkit/persistence/WriteBackCache.java +++ b/paper/src/main/java/io/wdsj/asw/bukkit/core/persistence/WriteBackCache.java @@ -1,4 +1,4 @@ -package io.wdsj.asw.bukkit.persistence; +package io.wdsj.asw.bukkit.core.persistence; import com.github.benmanes.caffeine.cache.AsyncLoadingCache; import com.github.benmanes.caffeine.cache.Caffeine; diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/persistence/WriteBackRepository.java b/paper/src/main/java/io/wdsj/asw/bukkit/core/persistence/WriteBackRepository.java similarity index 98% rename from paper/src/main/java/io/wdsj/asw/bukkit/persistence/WriteBackRepository.java rename to paper/src/main/java/io/wdsj/asw/bukkit/core/persistence/WriteBackRepository.java index 43504485..f8fcc0af 100644 --- a/paper/src/main/java/io/wdsj/asw/bukkit/persistence/WriteBackRepository.java +++ b/paper/src/main/java/io/wdsj/asw/bukkit/core/persistence/WriteBackRepository.java @@ -1,4 +1,4 @@ -package io.wdsj.asw.bukkit.persistence; +package io.wdsj.asw.bukkit.core.persistence; import java.util.Map; import java.util.Optional; diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/GroupModule.java b/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/GroupModule.java similarity index 66% rename from paper/src/main/java/io/wdsj/asw/bukkit/playergroup/GroupModule.java rename to paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/GroupModule.java index 38f7068b..2cce3de5 100644 --- a/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/GroupModule.java +++ b/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/GroupModule.java @@ -1,4 +1,4 @@ -package io.wdsj.asw.bukkit.playergroup; +package io.wdsj.asw.bukkit.manage.playergroup; public enum GroupModule { CHAT, diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/GroupModuleMode.java b/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/GroupModuleMode.java similarity index 60% rename from paper/src/main/java/io/wdsj/asw/bukkit/playergroup/GroupModuleMode.java rename to paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/GroupModuleMode.java index c9f933fe..4fee2f50 100644 --- a/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/GroupModuleMode.java +++ b/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/GroupModuleMode.java @@ -1,4 +1,4 @@ -package io.wdsj.asw.bukkit.playergroup; +package io.wdsj.asw.bukkit.manage.playergroup; public enum GroupModuleMode { DEFAULT, diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerActivitySnapshot.java b/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerActivitySnapshot.java new file mode 100644 index 00000000..eed505a4 --- /dev/null +++ b/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerActivitySnapshot.java @@ -0,0 +1,60 @@ +package io.wdsj.asw.bukkit.manage.playergroup; + +public record PlayerActivitySnapshot( + double score, + double playTimeHours, + long minedBlocks, + double movedBlocks, + long mobKills, + long usedItems, + long brokenItems, + long craftedItems, + long damageDealt, + long damageTaken, + long deaths, + long enchantedItems, + long fishCaught, + long villagerTrades +) { + public static PlayerActivitySnapshot empty() { + return new PlayerActivitySnapshot(0.0D, 0.0D, 0L, 0.0D, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L); + } + + public static PlayerActivitySnapshot withScore(PlayerActivitySnapshot snapshot, double score) { + return new PlayerActivitySnapshot( + score, + snapshot.playTimeHours(), + snapshot.minedBlocks(), + snapshot.movedBlocks(), + snapshot.mobKills(), + snapshot.usedItems(), + snapshot.brokenItems(), + snapshot.craftedItems(), + snapshot.damageDealt(), + snapshot.damageTaken(), + snapshot.deaths(), + snapshot.enchantedItems(), + snapshot.fishCaught(), + snapshot.villagerTrades() + ); + } + + public PlayerActivitySnapshot merge(PlayerActivitySnapshot other) { + return new PlayerActivitySnapshot( + score + other.score(), + playTimeHours + other.playTimeHours(), + minedBlocks + other.minedBlocks(), + movedBlocks + other.movedBlocks(), + mobKills + other.mobKills(), + usedItems + other.usedItems(), + brokenItems + other.brokenItems(), + craftedItems + other.craftedItems(), + damageDealt + other.damageDealt(), + damageTaken + other.damageTaken(), + deaths + other.deaths(), + enchantedItems + other.enchantedItems(), + fishCaught + other.fishCaught(), + villagerTrades + other.villagerTrades() + ); + } +} diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroup.java b/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroup.java similarity index 88% rename from paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroup.java rename to paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroup.java index 78ea3e68..8af9423c 100644 --- a/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroup.java +++ b/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroup.java @@ -1,4 +1,4 @@ -package io.wdsj.asw.bukkit.playergroup; +package io.wdsj.asw.bukkit.manage.playergroup; import java.util.Locale; diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupActivityRepository.java b/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupActivityRepository.java new file mode 100644 index 00000000..5c0cf42d --- /dev/null +++ b/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupActivityRepository.java @@ -0,0 +1,241 @@ +package io.wdsj.asw.bukkit.manage.playergroup; + +import io.wdsj.asw.bukkit.core.persistence.StorageType; +import io.wdsj.asw.bukkit.core.persistence.WriteBackRepository; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +final class PlayerGroupActivityRepository implements WriteBackRepository { + private static final String TABLE = "asw_player_group_activity"; + + private final DataSource dataSource; + private final StorageType storageType; + private final String serverId; + + PlayerGroupActivityRepository(DataSource dataSource, StorageType storageType, String serverId) { + this.dataSource = dataSource; + this.storageType = storageType; + this.serverId = serverId; + } + + void initialize() throws SQLException { + try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement()) { + if (storageType == StorageType.MYSQL) { + statement.execute(""" + CREATE TABLE IF NOT EXISTS asw_player_group_activity ( + player_uuid VARCHAR(36) NOT NULL, + server_id VARCHAR(64) NOT NULL, + player_name_lower VARCHAR(64) NOT NULL, + updated_at BIGINT NOT NULL, + play_time_hours DOUBLE NOT NULL, + mined_blocks BIGINT NOT NULL, + moved_blocks DOUBLE NOT NULL, + mob_kills BIGINT NOT NULL, + used_items BIGINT NOT NULL, + broken_items BIGINT NOT NULL, + crafted_items BIGINT NOT NULL, + damage_dealt BIGINT NOT NULL, + damage_taken BIGINT NOT NULL, + deaths BIGINT NOT NULL, + enchanted_items BIGINT NOT NULL, + fish_caught BIGINT NOT NULL, + villager_trades BIGINT NOT NULL, + PRIMARY KEY (player_uuid, server_id), + INDEX idx_asw_group_activity_player_name (player_name_lower), + INDEX idx_asw_group_activity_server (server_id) + ) + """); + return; + } + statement.execute(""" + CREATE TABLE IF NOT EXISTS asw_player_group_activity ( + player_uuid TEXT NOT NULL, + server_id TEXT NOT NULL, + player_name_lower TEXT NOT NULL, + updated_at INTEGER NOT NULL, + play_time_hours REAL NOT NULL, + mined_blocks INTEGER NOT NULL, + moved_blocks REAL NOT NULL, + mob_kills INTEGER NOT NULL, + used_items INTEGER NOT NULL, + broken_items INTEGER NOT NULL, + crafted_items INTEGER NOT NULL, + damage_dealt INTEGER NOT NULL, + damage_taken INTEGER NOT NULL, + deaths INTEGER NOT NULL, + enchanted_items INTEGER NOT NULL, + fish_caught INTEGER NOT NULL, + villager_trades INTEGER NOT NULL, + PRIMARY KEY (player_uuid, server_id) + ) + """); + statement.execute("CREATE INDEX IF NOT EXISTS idx_asw_group_activity_player_name ON " + TABLE + " (player_name_lower)"); + statement.execute("CREATE INDEX IF NOT EXISTS idx_asw_group_activity_server ON " + TABLE + " (server_id)"); + } + } + + @Override + public Optional load(UUID key) throws Exception { + String sql = "SELECT * FROM " + TABLE + " WHERE player_uuid = ? AND server_id = ?"; + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, key.toString()); + statement.setString(2, serverId); + try (ResultSet resultSet = statement.executeQuery()) { + if (!resultSet.next()) { + return Optional.empty(); + } + return Optional.of(readState(resultSet)); + } + } + } + + PlayerActivitySnapshot loadRemoteTotal(UUID playerId) throws SQLException { + String sql = """ + SELECT + COALESCE(SUM(play_time_hours), 0) AS play_time_hours, + COALESCE(SUM(mined_blocks), 0) AS mined_blocks, + COALESCE(SUM(moved_blocks), 0) AS moved_blocks, + COALESCE(SUM(mob_kills), 0) AS mob_kills, + COALESCE(SUM(used_items), 0) AS used_items, + COALESCE(SUM(broken_items), 0) AS broken_items, + COALESCE(SUM(crafted_items), 0) AS crafted_items, + COALESCE(SUM(damage_dealt), 0) AS damage_dealt, + COALESCE(SUM(damage_taken), 0) AS damage_taken, + COALESCE(SUM(deaths), 0) AS deaths, + COALESCE(SUM(enchanted_items), 0) AS enchanted_items, + COALESCE(SUM(fish_caught), 0) AS fish_caught, + COALESCE(SUM(villager_trades), 0) AS villager_trades + FROM %s + WHERE player_uuid = ? AND server_id <> ? + """.formatted(TABLE); + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, playerId.toString()); + statement.setString(2, serverId); + try (ResultSet resultSet = statement.executeQuery()) { + if (!resultSet.next()) { + return PlayerActivitySnapshot.empty(); + } + return readSnapshot(resultSet); + } + } + } + + @Override + public void save(UUID key, PlayerGroupActivityState value) throws Exception { + try (Connection connection = dataSource.getConnection()) { + saveWithConnection(connection, value); + } + } + + @Override + public void delete(UUID key) throws Exception { + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement("DELETE FROM " + TABLE + " WHERE player_uuid = ? AND server_id = ?")) { + statement.setString(1, key.toString()); + statement.setString(2, serverId); + statement.executeUpdate(); + } + } + + @Override + public void flush(Map> entries) throws Exception { + try (Connection connection = dataSource.getConnection()) { + boolean previousAutoCommit = connection.getAutoCommit(); + connection.setAutoCommit(false); + try { + for (Map.Entry> entry : entries.entrySet()) { + if (entry.getValue().isPresent()) { + saveWithConnection(connection, entry.getValue().get()); + } else { + deleteWithConnection(connection, entry.getKey()); + } + } + connection.commit(); + } catch (Exception exception) { + connection.rollback(); + throw exception; + } finally { + connection.setAutoCommit(previousAutoCommit); + } + } + } + + private void saveWithConnection(Connection connection, PlayerGroupActivityState value) throws SQLException { + String sql = storageType == StorageType.MYSQL + ? "INSERT INTO " + TABLE + " (player_uuid, server_id, player_name_lower, updated_at, play_time_hours, mined_blocks, moved_blocks, mob_kills, used_items, broken_items, crafted_items, damage_dealt, damage_taken, deaths, enchanted_items, fish_caught, villager_trades) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + + "ON DUPLICATE KEY UPDATE player_name_lower = VALUES(player_name_lower), updated_at = VALUES(updated_at), play_time_hours = VALUES(play_time_hours), mined_blocks = VALUES(mined_blocks), moved_blocks = VALUES(moved_blocks), mob_kills = VALUES(mob_kills), used_items = VALUES(used_items), broken_items = VALUES(broken_items), crafted_items = VALUES(crafted_items), damage_dealt = VALUES(damage_dealt), damage_taken = VALUES(damage_taken), deaths = VALUES(deaths), enchanted_items = VALUES(enchanted_items), fish_caught = VALUES(fish_caught), villager_trades = VALUES(villager_trades)" + : "INSERT INTO " + TABLE + " (player_uuid, server_id, player_name_lower, updated_at, play_time_hours, mined_blocks, moved_blocks, mob_kills, used_items, broken_items, crafted_items, damage_dealt, damage_taken, deaths, enchanted_items, fish_caught, villager_trades) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + + "ON CONFLICT(player_uuid, server_id) DO UPDATE SET player_name_lower = excluded.player_name_lower, updated_at = excluded.updated_at, play_time_hours = excluded.play_time_hours, mined_blocks = excluded.mined_blocks, moved_blocks = excluded.moved_blocks, mob_kills = excluded.mob_kills, used_items = excluded.used_items, broken_items = excluded.broken_items, crafted_items = excluded.crafted_items, damage_dealt = excluded.damage_dealt, damage_taken = excluded.damage_taken, deaths = excluded.deaths, enchanted_items = excluded.enchanted_items, fish_caught = excluded.fish_caught, villager_trades = excluded.villager_trades"; + try (PreparedStatement statement = connection.prepareStatement(sql)) { + bindSave(statement, value); + statement.executeUpdate(); + } + } + + private void deleteWithConnection(Connection connection, UUID key) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement("DELETE FROM " + TABLE + " WHERE player_uuid = ? AND server_id = ?")) { + statement.setString(1, key.toString()); + statement.setString(2, serverId); + statement.executeUpdate(); + } + } + + private void bindSave(PreparedStatement statement, PlayerGroupActivityState value) throws SQLException { + PlayerActivitySnapshot snapshot = value.snapshot(); + statement.setString(1, value.playerUuid().toString()); + statement.setString(2, serverId); + statement.setString(3, value.playerNameLower()); + statement.setLong(4, value.updatedAtMillis()); + statement.setDouble(5, snapshot.playTimeHours()); + statement.setLong(6, snapshot.minedBlocks()); + statement.setDouble(7, snapshot.movedBlocks()); + statement.setLong(8, snapshot.mobKills()); + statement.setLong(9, snapshot.usedItems()); + statement.setLong(10, snapshot.brokenItems()); + statement.setLong(11, snapshot.craftedItems()); + statement.setLong(12, snapshot.damageDealt()); + statement.setLong(13, snapshot.damageTaken()); + statement.setLong(14, snapshot.deaths()); + statement.setLong(15, snapshot.enchantedItems()); + statement.setLong(16, snapshot.fishCaught()); + statement.setLong(17, snapshot.villagerTrades()); + } + + private static PlayerGroupActivityState readState(ResultSet resultSet) throws SQLException { + return new PlayerGroupActivityState( + UUID.fromString(resultSet.getString("player_uuid")), + resultSet.getString("player_name_lower"), + readSnapshot(resultSet), + resultSet.getLong("updated_at") + ); + } + + private static PlayerActivitySnapshot readSnapshot(ResultSet resultSet) throws SQLException { + return new PlayerActivitySnapshot( + 0.0D, + resultSet.getDouble("play_time_hours"), + resultSet.getLong("mined_blocks"), + resultSet.getDouble("moved_blocks"), + resultSet.getLong("mob_kills"), + resultSet.getLong("used_items"), + resultSet.getLong("broken_items"), + resultSet.getLong("crafted_items"), + resultSet.getLong("damage_dealt"), + resultSet.getLong("damage_taken"), + resultSet.getLong("deaths"), + resultSet.getLong("enchanted_items"), + resultSet.getLong("fish_caught"), + resultSet.getLong("villager_trades") + ); + } +} diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupActivityState.java b/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupActivityState.java new file mode 100644 index 00000000..c194358d --- /dev/null +++ b/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupActivityState.java @@ -0,0 +1,17 @@ +package io.wdsj.asw.bukkit.manage.playergroup; + +import java.util.Objects; +import java.util.UUID; + +public record PlayerGroupActivityState( + UUID playerUuid, + String playerNameLower, + PlayerActivitySnapshot snapshot, + long updatedAtMillis +) { + public PlayerGroupActivityState { + Objects.requireNonNull(playerUuid, "playerUuid"); + Objects.requireNonNull(playerNameLower, "playerNameLower"); + Objects.requireNonNull(snapshot, "snapshot"); + } +} diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroupService.java b/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupService.java similarity index 79% rename from paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroupService.java rename to paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupService.java index 8067fe0a..d932e453 100644 --- a/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroupService.java +++ b/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupService.java @@ -1,4 +1,4 @@ -package io.wdsj.asw.bukkit.playergroup; +package io.wdsj.asw.bukkit.manage.playergroup; import com.github.Anon8281.universalScheduler.UniversalRunnable; import com.github.Anon8281.universalScheduler.scheduling.tasks.MyScheduledTask; @@ -9,9 +9,9 @@ import com.github.houbb.sensitive.word.support.tag.WordTags; import com.zaxxer.hikari.HikariDataSource; import io.wdsj.asw.bukkit.AdvancedSensitiveWords; +import io.wdsj.asw.bukkit.core.persistence.*; import io.wdsj.asw.bukkit.method.CharIgnore; import io.wdsj.asw.bukkit.method.WordReplace; -import io.wdsj.asw.bukkit.persistence.*; import io.wdsj.asw.bukkit.setting.PaperConfigurationService; import io.wdsj.asw.bukkit.setting.PluginMessages; import io.wdsj.asw.bukkit.setting.PluginSettings; @@ -30,12 +30,7 @@ import java.util.Locale; import java.util.Optional; import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; +import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicLong; public final class PlayerGroupService implements AutoCloseable { @@ -50,6 +45,8 @@ public final class PlayerGroupService implements AutoCloseable { private final Logger logger; private final HikariDataSource dataSource; private final WriteBackCache groupStates; + private final PlayerGroupActivityRepository activityRepository; + private final WriteBackCache localActivityStates; private final ExecutorService statisticExecutor; private final ConcurrentMap activitySnapshots = new ConcurrentHashMap<>(); private final ConcurrentMap tokenBuckets = new ConcurrentHashMap<>(); @@ -75,12 +72,24 @@ public PlayerGroupService(AdvancedSensitiveWords plugin) throws Exception { this.dataSource = DataSourceFactory.create(storageConfig); PlayerGroupStateRepository repository = new PlayerGroupStateRepository(dataSource, storageConfig.type()); repository.initialize(); + String serverId = groups.serverId.trim(); + this.activityRepository = new PlayerGroupActivityRepository(dataSource, storageConfig.type(), serverId); + activityRepository.initialize(); this.groupStates = new WriteBackCache<>( "PlayerGroups", repository, logger, Caffeine.newBuilder().expireAfterWrite(30L, TimeUnit.MINUTES).maximumSize(10_000L), - Duration.ofMinutes(2L), + Duration.ofSeconds(30L), + Duration.ofSeconds(10L), + FlushPolicy.PERIODIC + ); + this.localActivityStates = new WriteBackCache<>( + "PlayerGroupActivity", + activityRepository, + logger, + Caffeine.newBuilder().expireAfterWrite(30L, TimeUnit.MINUTES).maximumSize(10_000L), + Duration.ofSeconds(30L), Duration.ofSeconds(10L), FlushPolicy.PERIODIC ); @@ -176,6 +185,18 @@ private boolean shouldRunNewbieLinkCheck(SettingsConfiguration.LinkCheck linkChe return true; } + public void preload(UUID playerId, String playerName) { + if (!isEnabled()) { + return; + } + try { + logger.info("Loading group info for player {}", playerName); + groupStates.getAsync(playerId).join(); + } catch (Exception exception) { + logger.warn("Unable to preload player group information for {}.", playerId, exception); + } + } + public void handleJoin(Player player) { if (!isEnabled()) { return; @@ -193,7 +214,19 @@ public void handleJoin(Player player) { }); } - public void handleQuit(UUID playerId) { + public void handleQuit(Player player) { + UUID playerId = player.getUniqueId(); + if (isEnabled()) { + persistLocalActivityOnQuit(player).whenComplete((ignored, exception) -> { + if (exception != null) { + logger.error("Failed to persist final player activity snapshot for {}.", playerId, exception); + return; + } + localActivityStates.invalidate(playerId); + }); + } else { + localActivityStates.invalidate(playerId); + } activitySnapshots.remove(playerId); tokenBuckets.remove(playerId); groupStates.invalidate(playerId); @@ -324,19 +357,50 @@ private CompletableFuture refreshAndSynchronize(Player player) { } private CompletableFuture refreshSnapshotAsync(Player player) { + if (!player.isOnline()) { + return CompletableFuture.completedFuture(PlayerActivitySnapshot.empty()); + } UUID playerId = player.getUniqueId(); SettingsConfiguration.ActivityWeights weights = configuration.get(PluginSettings.PLAYER_GROUPS).weights; - return CompletableFuture.supplyAsync(() -> { - if (!player.isOnline()) { - return PlayerActivitySnapshot.empty(); - } - PlayerActivitySnapshot snapshot = calculateSnapshot(player, weights); - activitySnapshots.put(playerId, snapshot); - return snapshot; - }, statisticExecutor).exceptionally(exception -> { - logger.error("Failed to refresh player activity snapshot for {}.", playerId, exception); - return activitySnapshots.getOrDefault(playerId, PlayerActivitySnapshot.empty()); - }); + return CompletableFuture.supplyAsync(() -> calculateSnapshot(player, weights), statisticExecutor).thenCompose(localSnapshot -> persistAndAggregateActivity(player, localSnapshot, weights)) + .exceptionally(exception -> { + logger.error("Failed to refresh player activity snapshot for {}.", playerId, exception); + return activitySnapshots.getOrDefault(playerId, PlayerActivitySnapshot.empty()); + }); + } + + private CompletableFuture persistLocalActivityOnQuit(Player player) { + UUID playerId = player.getUniqueId(); + SettingsConfiguration.ActivityWeights weights = configuration.get(PluginSettings.PLAYER_GROUPS).weights; + return CompletableFuture.supplyAsync(() -> calculateSnapshot(player, weights), statisticExecutor) + .thenCompose(snapshot -> localActivityStates.putAndFlushAsync(playerId, new PlayerGroupActivityState( + playerId, + normalizeName(player.getName()), + snapshot, + System.currentTimeMillis() + ))); + } + + private CompletableFuture persistAndAggregateActivity( + Player player, + PlayerActivitySnapshot localSnapshot, + SettingsConfiguration.ActivityWeights weights + ) { + UUID playerId = player.getUniqueId(); + PlayerGroupActivityState localState = new PlayerGroupActivityState( + playerId, + normalizeName(player.getName()), + localSnapshot, + System.currentTimeMillis() + ); + return localActivityStates.putAndFlushAsync(playerId, localState) + .thenCompose(ignored -> localActivityStates.queryAsync(() -> activityRepository.loadRemoteTotal(playerId))) + .thenApply(remoteSnapshot -> { + PlayerActivitySnapshot totalRaw = localSnapshot.merge(remoteSnapshot); + PlayerActivitySnapshot total = PlayerActivitySnapshot.withScore(totalRaw, calculateScore(totalRaw, weights)); + activitySnapshots.put(playerId, total); + return total; + }); } private CompletableFuture synchronizeAutomaticState( @@ -383,21 +447,25 @@ private PlayerActivitySnapshot calculateSnapshot(Player player, SettingsConfigur long enchantedItems = statistic(player, "ITEM_ENCHANTED"); long fishCaught = statistic(player, "FISH_CAUGHT"); long villagerTrades = statistic(player, "TRADED_WITH_VILLAGER"); - double score = playHours * weights.playTimeHours - + minedBlocks * weights.minedBlocks - + movedBlocks * weights.movedBlocks - + mobKills * weights.mobKills - + usedItems * weights.usedItems - + brokenItems * weights.brokenItems - + craftedItems * weights.craftedItems - + damageDealt * weights.damageDealt - + damageTaken * weights.damageTaken - + deaths * weights.deaths - + enchantedItems * weights.enchantedItems - + fishCaught * weights.fishCaught - + villagerTrades * weights.villagerTrades; - return new PlayerActivitySnapshot(score, playHours, minedBlocks, movedBlocks, mobKills, usedItems, + PlayerActivitySnapshot snapshot = new PlayerActivitySnapshot(0.0D, playHours, minedBlocks, movedBlocks, mobKills, usedItems, brokenItems, craftedItems, damageDealt, damageTaken, deaths, enchantedItems, fishCaught, villagerTrades); + return PlayerActivitySnapshot.withScore(snapshot, calculateScore(snapshot, weights)); + } + + private static double calculateScore(PlayerActivitySnapshot snapshot, SettingsConfiguration.ActivityWeights weights) { + return snapshot.playTimeHours() * weights.playTimeHours + + snapshot.minedBlocks() * weights.minedBlocks + + snapshot.movedBlocks() * weights.movedBlocks + + snapshot.mobKills() * weights.mobKills + + snapshot.usedItems() * weights.usedItems + + snapshot.brokenItems() * weights.brokenItems + + snapshot.craftedItems() * weights.craftedItems + + snapshot.damageDealt() * weights.damageDealt + + snapshot.damageTaken() * weights.damageTaken + + snapshot.deaths() * weights.deaths + + snapshot.enchantedItems() * weights.enchantedItems + + snapshot.fishCaught() * weights.fishCaught + + snapshot.villagerTrades() * weights.villagerTrades; } private long distanceCentimeters(Player player) { @@ -504,6 +572,7 @@ public void close() { closed = true; SchedulingUtils.cancelTaskSafely(refreshTask); statisticExecutor.shutdownNow(); + localActivityStates.close(); groupStates.close(); dataSource.close(); clearNewbieLinkWordBs(); diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroupSource.java b/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupSource.java similarity index 56% rename from paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroupSource.java rename to paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupSource.java index 41d0405b..a527c3d1 100644 --- a/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroupSource.java +++ b/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupSource.java @@ -1,4 +1,4 @@ -package io.wdsj.asw.bukkit.playergroup; +package io.wdsj.asw.bukkit.manage.playergroup; public enum PlayerGroupSource { MANUAL, diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroupState.java b/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupState.java similarity index 92% rename from paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroupState.java rename to paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupState.java index b5e89bf4..8663d567 100644 --- a/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroupState.java +++ b/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupState.java @@ -1,4 +1,4 @@ -package io.wdsj.asw.bukkit.playergroup; +package io.wdsj.asw.bukkit.manage.playergroup; import java.util.Objects; import java.util.UUID; diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroupStateRepository.java b/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupStateRepository.java similarity index 98% rename from paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroupStateRepository.java rename to paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupStateRepository.java index 18c12efc..3f0c34d8 100644 --- a/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroupStateRepository.java +++ b/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupStateRepository.java @@ -1,7 +1,7 @@ -package io.wdsj.asw.bukkit.playergroup; +package io.wdsj.asw.bukkit.manage.playergroup; -import io.wdsj.asw.bukkit.persistence.StorageType; -import io.wdsj.asw.bukkit.persistence.WriteBackRepository; +import io.wdsj.asw.bukkit.core.persistence.StorageType; +import io.wdsj.asw.bukkit.core.persistence.WriteBackRepository; import javax.sql.DataSource; import java.sql.Connection; diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroupStatus.java b/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupStatus.java similarity index 78% rename from paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroupStatus.java rename to paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupStatus.java index e269ff57..38ce426e 100644 --- a/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerGroupStatus.java +++ b/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupStatus.java @@ -1,4 +1,4 @@ -package io.wdsj.asw.bukkit.playergroup; +package io.wdsj.asw.bukkit.manage.playergroup; public record PlayerGroupStatus( PlayerGroup group, diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerActivitySnapshot.java b/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerActivitySnapshot.java deleted file mode 100644 index e60113de..00000000 --- a/paper/src/main/java/io/wdsj/asw/bukkit/playergroup/PlayerActivitySnapshot.java +++ /dev/null @@ -1,22 +0,0 @@ -package io.wdsj.asw.bukkit.playergroup; - -public record PlayerActivitySnapshot( - double score, - double playTimeHours, - long minedBlocks, - double movedBlocks, - long mobKills, - long usedItems, - long brokenItems, - long craftedItems, - long damageDealt, - long damageTaken, - long deaths, - long enchantedItems, - long fishCaught, - long villagerTrades -) { - public static PlayerActivitySnapshot empty() { - return new PlayerActivitySnapshot(0.0D, 0.0D, 0L, 0.0D, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L); - } -} diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/setting/PaperConfigurationService.java b/paper/src/main/java/io/wdsj/asw/bukkit/setting/PaperConfigurationService.java index d566a1af..d4777746 100644 --- a/paper/src/main/java/io/wdsj/asw/bukkit/setting/PaperConfigurationService.java +++ b/paper/src/main/java/io/wdsj/asw/bukkit/setting/PaperConfigurationService.java @@ -6,6 +6,7 @@ import io.wdsj.asw.bukkit.AdvancedSensitiveWords; import io.wdsj.asw.bukkit.ai.LlmApiMode; import io.wdsj.asw.bukkit.api.moderation.LlmModerationCategory; +import io.wdsj.asw.bukkit.core.persistence.StorageType; import io.wdsj.asw.bukkit.listener.command.CommandArgumentRuleSet; import org.slf4j.Logger; @@ -267,6 +268,12 @@ private static void validatePlayerGroups(SettingsConfiguration.PlayerGroups play if (!Double.isFinite(playerGroups.playerThreshold) || playerGroups.playerThreshold < 0.0D) { throw new IllegalArgumentException("player-groups.player-threshold must be a finite non-negative number"); } + if (playerGroups.serverId == null || playerGroups.serverId.isBlank()) { + throw new IllegalArgumentException("player-groups.server-id cannot be blank"); + } + if (playerGroups.serverId.length() > 64) { + throw new IllegalArgumentException("player-groups.server-id cannot be longer than 64 characters"); + } if (playerGroups.refreshIntervalSeconds < 1) { throw new IllegalArgumentException("player-groups.refresh-interval-seconds must be at least 1"); } @@ -295,7 +302,7 @@ private static void validateGroupStorage(SettingsConfiguration.GroupStorage stor if (storage.connectionTimeoutMillis < 1L) { throw new IllegalArgumentException("player-groups.storage.connection-timeout-millis must be positive"); } - if (storage.type == io.wdsj.asw.bukkit.persistence.StorageType.MYSQL) { + if (storage.type == StorageType.MYSQL) { SettingsConfiguration.Mysql mysql = storage.mysql; if (mysql == null || mysql.host == null || mysql.host.isBlank() diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/setting/SettingsConfiguration.java b/paper/src/main/java/io/wdsj/asw/bukkit/setting/SettingsConfiguration.java index cca351d7..c2bf6a0f 100644 --- a/paper/src/main/java/io/wdsj/asw/bukkit/setting/SettingsConfiguration.java +++ b/paper/src/main/java/io/wdsj/asw/bukkit/setting/SettingsConfiguration.java @@ -3,14 +3,15 @@ import de.exlll.configlib.Comment; import de.exlll.configlib.Configuration; import io.wdsj.asw.bukkit.ai.LlmApiMode; -import io.wdsj.asw.bukkit.persistence.StorageType; -import io.wdsj.asw.bukkit.playergroup.GroupModuleMode; +import io.wdsj.asw.bukkit.core.persistence.StorageType; +import io.wdsj.asw.bukkit.manage.playergroup.GroupModuleMode; import io.wdsj.asw.bukkit.type.ProcessMethod; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; @Configuration public final class SettingsConfiguration { @@ -394,6 +395,8 @@ public static final class PlayerGroups { public boolean enabled = false; @Comment("Activity score required to be treated as PLAYER instead of NEWBIE.") public double playerThreshold = 70000.0D; + @Comment("Unique identifier for this backend server when aggregating activity across servers. Generated as a numeric string by default; keep it different on every backend server.") + public String serverId = Long.toUnsignedString(ThreadLocalRandom.current().nextLong()); @Comment("How often online player statistics are refreshed on the server thread.") public int refreshIntervalSeconds = 60; @Comment("Persistent storage for cross-server group states and manual group overrides.") diff --git a/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/AnvilListener.kt b/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/AnvilListener.kt index 7f975734..00110be5 100644 --- a/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/AnvilListener.kt +++ b/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/AnvilListener.kt @@ -1,7 +1,7 @@ package io.wdsj.asw.bukkit.listener import io.wdsj.asw.bukkit.AdvancedSensitiveWords -import io.wdsj.asw.bukkit.playergroup.GroupModule +import io.wdsj.asw.bukkit.manage.playergroup.GroupModule import io.wdsj.asw.bukkit.permission.PermissionsEnum import io.wdsj.asw.bukkit.setting.PaperConfigurationService import io.wdsj.asw.bukkit.setting.PluginMessages diff --git a/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/BookListener.kt b/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/BookListener.kt index e9efab31..7d895fe3 100644 --- a/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/BookListener.kt +++ b/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/BookListener.kt @@ -1,7 +1,7 @@ package io.wdsj.asw.bukkit.listener import io.wdsj.asw.bukkit.AdvancedSensitiveWords -import io.wdsj.asw.bukkit.playergroup.GroupModule +import io.wdsj.asw.bukkit.manage.playergroup.GroupModule import io.wdsj.asw.bukkit.permission.PermissionsEnum import io.wdsj.asw.bukkit.setting.PaperConfigurationService import io.wdsj.asw.bukkit.setting.PluginMessages diff --git a/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/CommandListener.kt b/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/CommandListener.kt index 056989da..5a54b2a9 100644 --- a/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/CommandListener.kt +++ b/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/CommandListener.kt @@ -2,7 +2,7 @@ package io.wdsj.asw.bukkit.listener import io.wdsj.asw.bukkit.AdvancedSensitiveWords import io.wdsj.asw.bukkit.listener.command.CommandArgumentRuleSet -import io.wdsj.asw.bukkit.playergroup.GroupModule +import io.wdsj.asw.bukkit.manage.playergroup.GroupModule import io.wdsj.asw.bukkit.permission.PermissionsEnum import io.wdsj.asw.bukkit.setting.PaperConfigurationService import io.wdsj.asw.bukkit.setting.PluginMessages diff --git a/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/PlayerGroupListener.kt b/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/PlayerGroupListener.kt index 28499126..31dc8408 100644 --- a/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/PlayerGroupListener.kt +++ b/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/PlayerGroupListener.kt @@ -1,11 +1,17 @@ package io.wdsj.asw.bukkit.listener -import io.wdsj.asw.bukkit.playergroup.PlayerGroupService +import io.wdsj.asw.bukkit.manage.playergroup.PlayerGroupService import org.bukkit.event.EventHandler import org.bukkit.event.Listener +import org.bukkit.event.player.AsyncPlayerPreLoginEvent import org.bukkit.event.player.PlayerJoinEvent class PlayerGroupListener(private val playerGroupService: PlayerGroupService) : Listener { + @EventHandler + fun onPreLogin(event: AsyncPlayerPreLoginEvent) { + playerGroupService.preload(event.uniqueId, event.name) + } + @EventHandler fun onJoin(event: PlayerJoinEvent) { playerGroupService.handleJoin(event.player) diff --git a/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/PlayerItemListener.kt b/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/PlayerItemListener.kt index 81017ec2..5d8c8b4f 100644 --- a/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/PlayerItemListener.kt +++ b/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/PlayerItemListener.kt @@ -1,7 +1,7 @@ package io.wdsj.asw.bukkit.listener import io.wdsj.asw.bukkit.AdvancedSensitiveWords -import io.wdsj.asw.bukkit.playergroup.GroupModule +import io.wdsj.asw.bukkit.manage.playergroup.GroupModule import io.wdsj.asw.bukkit.permission.PermissionsEnum import io.wdsj.asw.bukkit.setting.PaperConfigurationService import io.wdsj.asw.bukkit.setting.PluginMessages diff --git a/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/QuitDataCleaner.kt b/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/QuitDataCleaner.kt index fa8089b6..c4fb8f0c 100644 --- a/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/QuitDataCleaner.kt +++ b/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/QuitDataCleaner.kt @@ -29,7 +29,7 @@ class QuitDataCleaner : Listener { ChatContext.clearPlayerContext(player) SignContext.clearPlayerContext(player) ChatAntiSpamService.clear(player.uniqueId) - AdvancedSensitiveWords.getInstance().playerGroupService?.handleQuit(player.uniqueId) + AdvancedSensitiveWords.getInstance().playerGroupService?.handleQuit(player) PlayerShadowController.unshadowPlayer(player) } } diff --git a/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/SignListener.kt b/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/SignListener.kt index 80381748..019b3a5c 100644 --- a/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/SignListener.kt +++ b/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/SignListener.kt @@ -3,7 +3,7 @@ package io.wdsj.asw.bukkit.listener import com.github.houbb.sensitive.word.api.IWordResult import io.wdsj.asw.bukkit.AdvancedSensitiveWords import io.wdsj.asw.bukkit.permission.PermissionsEnum -import io.wdsj.asw.bukkit.playergroup.GroupModule +import io.wdsj.asw.bukkit.manage.playergroup.GroupModule import io.wdsj.asw.bukkit.setting.PaperConfigurationService import io.wdsj.asw.bukkit.integration.packetevents.sign.SignFakeViewService import io.wdsj.asw.bukkit.setting.PluginMessages diff --git a/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/paper/PaperChatListener.kt b/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/paper/PaperChatListener.kt index 4a6c83e1..827052c6 100644 --- a/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/paper/PaperChatListener.kt +++ b/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/paper/PaperChatListener.kt @@ -5,7 +5,7 @@ import io.wdsj.asw.bukkit.AdvancedSensitiveWords import io.wdsj.asw.bukkit.ai.LlmChatDetectionService import io.wdsj.asw.bukkit.integration.trchat.TrChatCompat import io.wdsj.asw.bukkit.listener.abstraction.AbstractFakeMessageExecutor -import io.wdsj.asw.bukkit.playergroup.GroupModule +import io.wdsj.asw.bukkit.manage.playergroup.GroupModule import io.wdsj.asw.bukkit.permission.PermissionsEnum import io.wdsj.asw.bukkit.service.chat.antispam.ChatAntiSpamService import io.wdsj.asw.bukkit.setting.PaperConfigurationService diff --git a/paper/src/main/kotlin/io/wdsj/asw/bukkit/util/PlayerProcessingGuard.kt b/paper/src/main/kotlin/io/wdsj/asw/bukkit/util/PlayerProcessingGuard.kt index 38086b06..eca82492 100644 --- a/paper/src/main/kotlin/io/wdsj/asw/bukkit/util/PlayerProcessingGuard.kt +++ b/paper/src/main/kotlin/io/wdsj/asw/bukkit/util/PlayerProcessingGuard.kt @@ -4,7 +4,7 @@ import fr.xephi.authme.api.v3.AuthMeApi import io.wdsj.asw.bukkit.AdvancedSensitiveWords import io.wdsj.asw.bukkit.AdvancedSensitiveWords.isAuthMeAvailable import io.wdsj.asw.bukkit.AdvancedSensitiveWords.isInitialized -import io.wdsj.asw.bukkit.playergroup.GroupModule +import io.wdsj.asw.bukkit.manage.playergroup.GroupModule import io.wdsj.asw.bukkit.setting.PaperConfigurationService import io.wdsj.asw.bukkit.permission.PermissionsEnum import io.wdsj.asw.bukkit.permission.cache.CachingPermTool diff --git a/paper/src/test/java/io/wdsj/asw/bukkit/persistence/WriteBackCacheStressTest.java b/paper/src/test/java/io/wdsj/asw/bukkit/persistence/WriteBackCacheStressTest.java index f1bf3e50..5e0d6373 100644 --- a/paper/src/test/java/io/wdsj/asw/bukkit/persistence/WriteBackCacheStressTest.java +++ b/paper/src/test/java/io/wdsj/asw/bukkit/persistence/WriteBackCacheStressTest.java @@ -1,6 +1,9 @@ package io.wdsj.asw.bukkit.persistence; import com.github.benmanes.caffeine.cache.Caffeine; +import io.wdsj.asw.bukkit.core.persistence.FlushPolicy; +import io.wdsj.asw.bukkit.core.persistence.WriteBackCache; +import io.wdsj.asw.bukkit.core.persistence.WriteBackRepository; import org.junit.jupiter.api.Test; import org.slf4j.LoggerFactory;