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/5] 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/5] 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; From e6b22a941be823131464a4b57e0fce18d7ae02b4 Mon Sep 17 00:00:00 2001 From: HaHaWTH <102713261+HaHaWTH@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:51:03 +0800 Subject: [PATCH 3/5] remove onlyResetOnlinePlayers --- .../io/wdsj/asw/bukkit/setting/PluginSettings.java | 1 - .../asw/bukkit/setting/SettingsConfiguration.java | 8 +++----- .../asw/bukkit/task/punish/ViolationResetTask.java | 13 +------------ .../ai/LlmChatDetectionServiceStatusTest.java | 5 +++-- 4 files changed, 7 insertions(+), 20 deletions(-) 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 2f09d4a8..62988fca 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 @@ -24,7 +24,6 @@ public final class PluginSettings { public static final SettingKey NOTICE_OPERATOR = key(settings -> settings.plugin.noticeOperator); public static final SettingKey> MANUAL_PUNISHMENT = key(settings -> settings.plugin.manualPunishment); public static final SettingKey VIOLATION_RESET_TIME = key(settings -> settings.plugin.violationResetTime); - public static final SettingKey ONLY_RESET_ONLINE_PLAYERS = key(settings -> settings.plugin.onlyResetOnlinePlayers); public static final SettingKey PURGE_LOG_FILE = key(settings -> settings.plugin.purgeLogFile); public static final SettingKey REPLACEMENT = key(settings -> settings.plugin.replacement); public static final SettingKey> DEFINED_REPLACEMENT = key(settings -> settings.plugin.definedReplacement); 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 c2bf6a0f..2b97f388 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 @@ -69,8 +69,6 @@ public static final class Plugin { public List manualPunishment = new ArrayList<>(); @Comment("Violation counter reset interval in minutes.") public long violationResetTime = 20L; - @Comment("Whether to reset only the violation counters of online players.") - public boolean onlyResetOnlinePlayers = false; @Comment("Whether to clear old violation logs on startup.") public boolean purgeLogFile = false; @Comment("Replacement character used for blocked content.") @@ -394,7 +392,7 @@ 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; + public double playerThreshold = 6000.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.") @@ -481,7 +479,7 @@ public static final class GroupPolicy { "DISABLED skips this module for players in this group." }) public GroupModules modules = new GroupModules(); - @Comment("Whether players in this group receive a join message.") + @Comment("NEWBIE-only 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(); @@ -518,7 +516,7 @@ public static final class RateLimit { @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; + public int refillIntervalSeconds = 30; } @Configuration diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/task/punish/ViolationResetTask.java b/paper/src/main/java/io/wdsj/asw/bukkit/task/punish/ViolationResetTask.java index 3b6bc8b9..c4cf4492 100644 --- a/paper/src/main/java/io/wdsj/asw/bukkit/task/punish/ViolationResetTask.java +++ b/paper/src/main/java/io/wdsj/asw/bukkit/task/punish/ViolationResetTask.java @@ -4,12 +4,7 @@ import io.wdsj.asw.bukkit.manage.notice.Notifier; import io.wdsj.asw.bukkit.manage.punish.ViolationCounter; import io.wdsj.asw.bukkit.setting.PluginMessages; -import io.wdsj.asw.bukkit.setting.PluginSettings; import io.wdsj.asw.bukkit.util.message.MessageUtils; -import org.bukkit.Bukkit; -import org.bukkit.entity.Player; - -import static io.wdsj.asw.bukkit.AdvancedSensitiveWords.setting; /** * Asynchronous task to reset the violation count of players. @@ -17,13 +12,7 @@ public class ViolationResetTask extends UniversalRunnable { @Override public void run() { - if (setting(PluginSettings.ONLY_RESET_ONLINE_PLAYERS)) { - for (Player player : Bukkit.getOnlinePlayers()) { - ViolationCounter.INSTANCE.resetViolationCount(player); - } - } else { - ViolationCounter.INSTANCE.resetAllViolations(); - } + ViolationCounter.INSTANCE.resetAllViolations(); String message = MessageUtils.retrieveMessage(PluginMessages.MESSAGE_ON_VIOLATION_RESET); Notifier.normalNotice(message); } diff --git a/paper/src/test/java/io/wdsj/asw/bukkit/ai/LlmChatDetectionServiceStatusTest.java b/paper/src/test/java/io/wdsj/asw/bukkit/ai/LlmChatDetectionServiceStatusTest.java index 6c93375c..4eab416c 100644 --- a/paper/src/test/java/io/wdsj/asw/bukkit/ai/LlmChatDetectionServiceStatusTest.java +++ b/paper/src/test/java/io/wdsj/asw/bukkit/ai/LlmChatDetectionServiceStatusTest.java @@ -1,5 +1,6 @@ package io.wdsj.asw.bukkit.ai; +import io.wdsj.asw.bukkit.api.moderation.LlmModerationCategory; import io.wdsj.asw.bukkit.setting.PaperConfigurationService; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -37,9 +38,9 @@ void reportsDisabledAiWithoutCreatingExecutorThreads() { assertEquals("deepseek-v4-flash", status.modelName()); assertEquals(LlmApiMode.CHAT_COMPLETIONS, status.apiMode()); LlmCategoryPolicy harassment = status.categoryPolicy().get( - io.wdsj.asw.bukkit.api.moderation.LlmModerationCategory.HARASSMENT); + LlmModerationCategory.HARASSMENT); LlmCategoryPolicy clean = status.categoryPolicy().get( - io.wdsj.asw.bukkit.api.moderation.LlmModerationCategory.CLEAN); + LlmModerationCategory.CLEAN); assertEquals(0.75D, harassment.notifyConfidence()); assertEquals(0.90D, harassment.punishConfidence()); assertEquals(-1.0D, clean.notifyConfidence()); From 8907000080b98448df6e751f95f73824f9191ffa Mon Sep 17 00:00:00 2001 From: HaHaWTH <102713261+HaHaWTH@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:21:11 +0800 Subject: [PATCH 4/5] Revert player group --- paper/build.gradle.kts | 7 - .../asw/bukkit/AdvancedSensitiveWords.java | 29 - .../bukkit/command/AswCommandRegistrar.java | 52 -- .../asw/bukkit/command/AswCommandService.java | 83 --- .../bukkit/command/CommandPermissions.java | 3 - .../core/persistence/DataSourceFactory.java | 68 -- .../bukkit/core/persistence/FlushPolicy.java | 15 - .../core/persistence/StorageConfig.java | 78 --- .../bukkit/core/persistence/StorageType.java | 6 - .../core/persistence/WriteBackCache.java | 458 ------------- .../core/persistence/WriteBackRepository.java | 88 --- .../manage/playergroup/GroupModule.java | 10 - .../manage/playergroup/GroupModuleMode.java | 7 - .../playergroup/PlayerActivitySnapshot.java | 60 -- .../manage/playergroup/PlayerGroup.java | 19 - .../PlayerGroupActivityRepository.java | 241 ------- .../playergroup/PlayerGroupActivityState.java | 17 - .../playergroup/PlayerGroupService.java | 618 ------------------ .../manage/playergroup/PlayerGroupSource.java | 6 - .../manage/playergroup/PlayerGroupState.java | 21 - .../PlayerGroupStateRepository.java | 168 ----- .../manage/playergroup/PlayerGroupStatus.java | 9 - .../asw/bukkit/service/ListenerService.java | 11 - .../setting/ChineseMessagesConfiguration.java | 15 - .../bukkit/setting/MessagesConfiguration.java | 23 - .../setting/PaperConfigurationService.java | 92 +-- .../asw/bukkit/setting/PluginMessages.java | 8 - .../asw/bukkit/setting/PluginSettings.java | 5 +- .../bukkit/setting/SettingsConfiguration.java | 154 +---- .../wdsj/asw/bukkit/listener/AnvilListener.kt | 2 - .../wdsj/asw/bukkit/listener/BookListener.kt | 2 - .../asw/bukkit/listener/CommandListener.kt | 22 - .../bukkit/listener/PlayerGroupListener.kt | 19 - .../asw/bukkit/listener/PlayerItemListener.kt | 4 - .../asw/bukkit/listener/QuitDataCleaner.kt | 2 - .../wdsj/asw/bukkit/listener/SignListener.kt | 2 - .../listener/paper/PaperChatListener.kt | 18 - .../chat/antispam/ChatAntiSpamService.kt | 42 ++ .../asw/bukkit/util/PlayerProcessingGuard.kt | 7 - paper/src/main/resources/plugin.yml | 14 - .../persistence/WriteBackCacheStressTest.java | 264 -------- 41 files changed, 61 insertions(+), 2708 deletions(-) delete mode 100644 paper/src/main/java/io/wdsj/asw/bukkit/core/persistence/DataSourceFactory.java delete mode 100644 paper/src/main/java/io/wdsj/asw/bukkit/core/persistence/FlushPolicy.java delete mode 100644 paper/src/main/java/io/wdsj/asw/bukkit/core/persistence/StorageConfig.java delete mode 100644 paper/src/main/java/io/wdsj/asw/bukkit/core/persistence/StorageType.java delete mode 100644 paper/src/main/java/io/wdsj/asw/bukkit/core/persistence/WriteBackCache.java delete mode 100644 paper/src/main/java/io/wdsj/asw/bukkit/core/persistence/WriteBackRepository.java delete mode 100644 paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/GroupModule.java delete mode 100644 paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/GroupModuleMode.java delete mode 100644 paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerActivitySnapshot.java delete mode 100644 paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroup.java delete mode 100644 paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupActivityRepository.java delete mode 100644 paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupActivityState.java delete mode 100644 paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupService.java delete mode 100644 paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupSource.java delete mode 100644 paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupState.java delete mode 100644 paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupStateRepository.java delete mode 100644 paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupStatus.java delete mode 100644 paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/PlayerGroupListener.kt delete mode 100644 paper/src/test/java/io/wdsj/asw/bukkit/persistence/WriteBackCacheStressTest.java diff --git a/paper/build.gradle.kts b/paper/build.gradle.kts index 45e68ae7..45746db6 100644 --- a/paper/build.gradle.kts +++ b/paper/build.gradle.kts @@ -43,8 +43,6 @@ dependencies { 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")}") @@ -61,9 +59,6 @@ dependencies { 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")}") @@ -110,8 +105,6 @@ 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 9d9a0dac..5cabb189 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,6 @@ 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.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; @@ -70,7 +69,6 @@ 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; } @@ -94,10 +92,6 @@ public LlmChatDetectionService getLlmChatDetectionService() { return listenerService.getLlmChatDetectionService(); } - public PlayerGroupService getPlayerGroupService() { - return playerGroupService; - } - public static T setting(SettingKey key) { return instance.configurationService.get(key); } @@ -125,7 +119,6 @@ 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); @@ -193,10 +186,6 @@ public void onDisable() { ViolationCounter.INSTANCE.resetAllViolations(); SchedulingUtils.cancelTaskSafely(violationResetTask); if (permCache != null) permCache.disable(); - if (playerGroupService != null) { - playerGroupService.close(); - playerGroupService = null; - } if (isInitialized) { sensitiveWordBs.destroy(); if (networkSensitiveWordBs != null) { @@ -209,11 +198,6 @@ public void onDisable() { 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(); } @@ -249,19 +233,6 @@ 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; 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 59ff7092..dcb9fb6a 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,6 @@ package io.wdsj.asw.bukkit.command; import io.wdsj.asw.bukkit.AdvancedSensitiveWords; -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; @@ -46,16 +45,6 @@ public final class AswCommandRegistrar { } 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; @@ -81,7 +70,6 @@ public void register() { registerReloadCommands(); registerWordCommands(); registerPlayerCommands(); - registerGroupCommands(); } private void registerRootCommand() { @@ -204,32 +192,6 @@ 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)); @@ -242,10 +204,6 @@ 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); }); } @@ -278,14 +236,4 @@ public Throwable fillInStackTrace() { } } - 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 7481d3d2..6e5ecb3f 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,6 @@ 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.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; @@ -178,59 +174,6 @@ 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; @@ -290,30 +233,4 @@ private static String formatCategoryPolicies(Map 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/core/persistence/FlushPolicy.java b/paper/src/main/java/io/wdsj/asw/bukkit/core/persistence/FlushPolicy.java deleted file mode 100644 index d9626642..00000000 --- a/paper/src/main/java/io/wdsj/asw/bukkit/core/persistence/FlushPolicy.java +++ /dev/null @@ -1,15 +0,0 @@ -package io.wdsj.asw.bukkit.core.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/core/persistence/StorageConfig.java b/paper/src/main/java/io/wdsj/asw/bukkit/core/persistence/StorageConfig.java deleted file mode 100644 index 01ec2b92..00000000 --- a/paper/src/main/java/io/wdsj/asw/bukkit/core/persistence/StorageConfig.java +++ /dev/null @@ -1,78 +0,0 @@ -package io.wdsj.asw.bukkit.core.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/core/persistence/StorageType.java b/paper/src/main/java/io/wdsj/asw/bukkit/core/persistence/StorageType.java deleted file mode 100644 index c42043ff..00000000 --- a/paper/src/main/java/io/wdsj/asw/bukkit/core/persistence/StorageType.java +++ /dev/null @@ -1,6 +0,0 @@ -package io.wdsj.asw.bukkit.core.persistence; - -public enum StorageType { - SQLITE, - MYSQL -} diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/core/persistence/WriteBackCache.java b/paper/src/main/java/io/wdsj/asw/bukkit/core/persistence/WriteBackCache.java deleted file mode 100644 index b4d31ec4..00000000 --- a/paper/src/main/java/io/wdsj/asw/bukkit/core/persistence/WriteBackCache.java +++ /dev/null @@ -1,458 +0,0 @@ -package io.wdsj.asw.bukkit.core.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/core/persistence/WriteBackRepository.java b/paper/src/main/java/io/wdsj/asw/bukkit/core/persistence/WriteBackRepository.java deleted file mode 100644 index f8fcc0af..00000000 --- a/paper/src/main/java/io/wdsj/asw/bukkit/core/persistence/WriteBackRepository.java +++ /dev/null @@ -1,88 +0,0 @@ -package io.wdsj.asw.bukkit.core.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/manage/playergroup/GroupModule.java b/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/GroupModule.java deleted file mode 100644 index 2cce3de5..00000000 --- a/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/GroupModule.java +++ /dev/null @@ -1,10 +0,0 @@ -package io.wdsj.asw.bukkit.manage.playergroup; - -public enum GroupModule { - CHAT, - COMMAND, - BOOK, - SIGN, - ANVIL, - ITEM -} diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/GroupModuleMode.java b/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/GroupModuleMode.java deleted file mode 100644 index 4fee2f50..00000000 --- a/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/GroupModuleMode.java +++ /dev/null @@ -1,7 +0,0 @@ -package io.wdsj.asw.bukkit.manage.playergroup; - -public enum GroupModuleMode { - DEFAULT, - ENABLED, - DISABLED -} 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 deleted file mode 100644 index eed505a4..00000000 --- a/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerActivitySnapshot.java +++ /dev/null @@ -1,60 +0,0 @@ -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/manage/playergroup/PlayerGroup.java b/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroup.java deleted file mode 100644 index 8af9423c..00000000 --- a/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroup.java +++ /dev/null @@ -1,19 +0,0 @@ -package io.wdsj.asw.bukkit.manage.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/manage/playergroup/PlayerGroupActivityRepository.java b/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupActivityRepository.java deleted file mode 100644 index 5c0cf42d..00000000 --- a/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupActivityRepository.java +++ /dev/null @@ -1,241 +0,0 @@ -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 deleted file mode 100644 index c194358d..00000000 --- a/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupActivityState.java +++ /dev/null @@ -1,17 +0,0 @@ -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/manage/playergroup/PlayerGroupService.java b/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupService.java deleted file mode 100644 index d932e453..00000000 --- a/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupService.java +++ /dev/null @@ -1,618 +0,0 @@ -package io.wdsj.asw.bukkit.manage.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.core.persistence.*; -import io.wdsj.asw.bukkit.method.CharIgnore; -import io.wdsj.asw.bukkit.method.WordReplace; -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.*; -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 PlayerGroupActivityRepository activityRepository; - private final WriteBackCache localActivityStates; - 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(); - 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.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 - ); - 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 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; - } - 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(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); - } - - 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) { - if (!player.isOnline()) { - return CompletableFuture.completedFuture(PlayerActivitySnapshot.empty()); - } - UUID playerId = player.getUniqueId(); - SettingsConfiguration.ActivityWeights weights = configuration.get(PluginSettings.PLAYER_GROUPS).weights; - 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( - 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"); - 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) { - 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(); - localActivityStates.close(); - 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/manage/playergroup/PlayerGroupSource.java b/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupSource.java deleted file mode 100644 index a527c3d1..00000000 --- a/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupSource.java +++ /dev/null @@ -1,6 +0,0 @@ -package io.wdsj.asw.bukkit.manage.playergroup; - -public enum PlayerGroupSource { - MANUAL, - AUTOMATIC -} diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupState.java b/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupState.java deleted file mode 100644 index 8663d567..00000000 --- a/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupState.java +++ /dev/null @@ -1,21 +0,0 @@ -package io.wdsj.asw.bukkit.manage.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/manage/playergroup/PlayerGroupStateRepository.java b/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupStateRepository.java deleted file mode 100644 index 3f0c34d8..00000000 --- a/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupStateRepository.java +++ /dev/null @@ -1,168 +0,0 @@ -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 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/manage/playergroup/PlayerGroupStatus.java b/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupStatus.java deleted file mode 100644 index 38ce426e..00000000 --- a/paper/src/main/java/io/wdsj/asw/bukkit/manage/playergroup/PlayerGroupStatus.java +++ /dev/null @@ -1,9 +0,0 @@ -package io.wdsj.asw.bukkit.manage.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 142d5c19..fba82b3c 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,7 +18,6 @@ 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; @@ -52,7 +51,6 @@ public void registerListeners() { registerEventListener(new BroadcastListener(configuration)); } registerEventListener(new QuitDataCleaner()); - registerPlayerGroupListenerIfNeeded(); if (configuration.get(PluginSettings.CHECK_FOR_UPDATE)) { registerEventListener(new JoinUpdateNotifier(configuration)); } @@ -66,7 +64,6 @@ public void unregisterListeners() { public void reloadConfiguration() { llmChatDetectionService.reload(); - registerPlayerGroupListenerIfNeeded(); } public LlmChatDetectionService getLlmChatDetectionService() { @@ -78,14 +75,6 @@ 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 59d19764..6d3ef218 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,21 +27,6 @@ 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 fc490f11..407abcaf 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,29 +79,6 @@ 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 d4777746..cfaa1870 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,7 +6,6 @@ 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; @@ -100,14 +99,6 @@ 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; @@ -175,7 +166,6 @@ static void validateSettings(SettingsConfiguration settings) { } validateCategoryPolicy(ai.categoryPolicy); validateAntiSpam(settings.chat.antiSpam); - validatePlayerGroups(settings.playerGroups); CommandArgumentRuleSet.compile(settings.chat.commandWhiteList); if (!ai.enabled) { @@ -259,85 +249,11 @@ private static void validateAntiSpam(SettingsConfiguration.AntiSpam antiSpam) { || antiSpam.similarMaxSimilarity > 1.1D) { throw new IllegalArgumentException("chat.anti-spam.similar-max-similarity must be between 0.0 and 1.1"); } - } - - 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.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"); - } - 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 == 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 (antiSpam.rateLimit == null) { + throw new IllegalArgumentException("chat.anti-spam.rate-limit cannot be null"); } - if (policy.rateLimit.capacity < 1 || policy.rateLimit.refillIntervalSeconds < 1) { - throw new IllegalArgumentException("player-groups.groups." + name + ".rate-limit settings must be at least 1"); + if (antiSpam.rateLimit.capacity < 1 || antiSpam.rateLimit.refillIntervalSeconds < 1) { + throw new IllegalArgumentException("chat.anti-spam.rate-limit settings must be at least 1"); } } 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 88650e7e..8ae409d5 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,14 +15,6 @@ 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 62988fca..93caa144 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 @@ -72,6 +72,9 @@ public final class PluginSettings { public static final SettingKey CHAT_ANTI_SPAM_SIMILAR_CHECK_AMOUNT = key(settings -> settings.chat.antiSpam.similarCheckAmount); public static final SettingKey CHAT_ANTI_SPAM_SIMILAR_MIN_DISTANCE = key(settings -> settings.chat.antiSpam.similarMinDistance); public static final SettingKey CHAT_ANTI_SPAM_SIMILAR_MAX_SIMILARITY = key(settings -> settings.chat.antiSpam.similarMaxSimilarity); + public static final SettingKey CHAT_ANTI_SPAM_RATE_LIMIT_ENABLED = key(settings -> settings.chat.antiSpam.rateLimit.enabled); + public static final SettingKey CHAT_ANTI_SPAM_RATE_LIMIT_CAPACITY = key(settings -> settings.chat.antiSpam.rateLimit.capacity); + public static final SettingKey CHAT_ANTI_SPAM_RATE_LIMIT_REFILL_INTERVAL_SECONDS = key(settings -> settings.chat.antiSpam.rateLimit.refillIntervalSeconds); public static final SettingKey CHAT_ANTI_SPAM_SEND_MESSAGE = key(settings -> settings.chat.antiSpam.sendMessage); public static final SettingKey> CHAT_PUNISHMENT = key(settings -> settings.chat.punishment); public static final SettingKey CHAT_BROADCAST_CHECK = key(settings -> settings.chat.broadcastCheck); @@ -130,8 +133,6 @@ 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 2b97f388..ed2a877e 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,15 +3,12 @@ import de.exlll.configlib.Comment; import de.exlll.configlib.Configuration; import io.wdsj.asw.bukkit.ai.LlmApiMode; -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 { @@ -31,8 +28,6 @@ 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 { @@ -207,10 +202,22 @@ public static final class AntiSpam { public int similarMinDistance = 2; @Comment("Similarity threshold from 0.0 to 1.0. Messages at or above this value are treated as spam. Use 1.1 to disable.") public double similarMaxSimilarity = 0.90D; + @Comment("Token-bucket message rate limit. This is part of chat anti-spam and applies to all checked chat messages.") + public AntiSpamRateLimit rateLimit = new AntiSpamRateLimit(); @Comment("Whether to send a message when anti-spam cancels chat.") public boolean sendMessage = true; } + @Configuration + public static final class AntiSpamRateLimit { + @Comment("Whether to enable token-bucket chat rate limiting.") + 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 = 30; + } + @Configuration public static final class Ai { @Comment("Whether to enable LLM-assisted chat moderation.") @@ -387,141 +394,4 @@ public static final class Item { 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 = 6000.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.") - 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("NEWBIE-only 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 = 30; - } - - @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 00110be5..b3258363 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,6 @@ package io.wdsj.asw.bukkit.listener import io.wdsj.asw.bukkit.AdvancedSensitiveWords -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 @@ -32,7 +31,6 @@ class AnvilListener(private val configuration: PaperConfigurationService) : List 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 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 7d895fe3..abb8b72a 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,6 @@ package io.wdsj.asw.bukkit.listener import io.wdsj.asw.bukkit.AdvancedSensitiveWords -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 @@ -31,7 +30,6 @@ class BookListener(private val configuration: PaperConfigurationService) : Liste 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 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 5a54b2a9..c6ad0701 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,6 @@ 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.manage.playergroup.GroupModule import io.wdsj.asw.bukkit.permission.PermissionsEnum import io.wdsj.asw.bukkit.setting.PaperConfigurationService import io.wdsj.asw.bukkit.setting.PluginMessages @@ -31,11 +30,9 @@ class CommandListener(private val configuration: PaperConfigurationService) : Li 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 -> AdvancedSensitiveWords.findAllSensitive(segment.content()) } @@ -46,25 +43,6 @@ 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(), "") 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 deleted file mode 100644 index 31dc8408..00000000 --- a/paper/src/main/kotlin/io/wdsj/asw/bukkit/listener/PlayerGroupListener.kt +++ /dev/null @@ -1,19 +0,0 @@ -package io.wdsj.asw.bukkit.listener - -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 5d8c8b4f..5ae508bb 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,6 @@ package io.wdsj.asw.bukkit.listener import io.wdsj.asw.bukkit.AdvancedSensitiveWords -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 @@ -35,7 +34,6 @@ class PlayerItemListener(private val configuration: PaperConfigurationService) : 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) @@ -48,7 +46,6 @@ class PlayerItemListener(private val configuration: PaperConfigurationService) : 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) } @@ -60,7 +57,6 @@ class PlayerItemListener(private val configuration: PaperConfigurationService) : 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 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 c4fb8f0c..07d9d29e 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,6 +1,5 @@ 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 @@ -29,7 +28,6 @@ class QuitDataCleaner : Listener { ChatContext.clearPlayerContext(player) SignContext.clearPlayerContext(player) ChatAntiSpamService.clear(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 019b3a5c..205bf988 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,6 @@ 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.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 @@ -42,7 +41,6 @@ class SignListener(private val configuration: PaperConfigurationService) : Liste 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() 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 827052c6..c8e7d755 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,6 @@ 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.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 @@ -42,12 +41,10 @@ class PaperChatListener( 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 @@ -76,21 +73,6 @@ 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 diff --git a/paper/src/main/kotlin/io/wdsj/asw/bukkit/service/chat/antispam/ChatAntiSpamService.kt b/paper/src/main/kotlin/io/wdsj/asw/bukkit/service/chat/antispam/ChatAntiSpamService.kt index 9f10d979..265aff7a 100644 --- a/paper/src/main/kotlin/io/wdsj/asw/bukkit/service/chat/antispam/ChatAntiSpamService.kt +++ b/paper/src/main/kotlin/io/wdsj/asw/bukkit/service/chat/antispam/ChatAntiSpamService.kt @@ -19,6 +19,7 @@ class ChatAntiSpamService(private val configuration: PaperConfigurationService) fun check(playerId: UUID, message: String, nowMillis: Long = System.currentTimeMillis()): Result { if (!configuration.get(PluginSettings.CHAT_ANTI_SPAM_ENABLED)) return Result.CLEAN + if (!consumeToken(playerId, nowMillis)) return Result.SPAM val preprocessed = preprocess(message) val visibleCodePoints = MessageEntropy.visibleCodePointCount(preprocessed) @@ -47,6 +48,19 @@ class ChatAntiSpamService(private val configuration: PaperConfigurationService) return result } + private fun consumeToken(playerId: UUID, nowMillis: Long): Boolean { + if (!configuration.get(PluginSettings.CHAT_ANTI_SPAM_RATE_LIMIT_ENABLED)) return true + + val capacity = configuration.get(PluginSettings.CHAT_ANTI_SPAM_RATE_LIMIT_CAPACITY) + val refillIntervalMillis = configuration.get(PluginSettings.CHAT_ANTI_SPAM_RATE_LIMIT_REFILL_INTERVAL_SECONDS) * 1000L + val bucket = tokenBuckets.compute(playerId) { _, existing -> + existing?.takeIf { + it.capacity == capacity && it.refillIntervalMillis == refillIntervalMillis + } ?: TokenBucket(capacity, refillIntervalMillis, nowMillis) + } ?: return true + return bucket.tryConsume(nowMillis) + } + private fun checkSimilarity(playerId: UUID, normalized: String, nowMillis: Long): Result { val history = histories[playerId] ?: return Result.CLEAN val maxAgeMillis = configuration.get(PluginSettings.CHAT_ANTI_SPAM_HISTORY_MAX_AGE_SECONDS) * 1000L @@ -92,6 +106,31 @@ class ChatAntiSpamService(private val configuration: PaperConfigurationService) val list: EvictingRingList = EvictingRingList(capacity), ) + private class TokenBucket( + val capacity: Int, + val refillIntervalMillis: Long, + nowMillis: Long, + ) { + private var tokens = capacity.toDouble() + private var lastRefillMillis = nowMillis + + @Synchronized + fun tryConsume(nowMillis: Long): Boolean { + refill(nowMillis) + if (tokens < 1.0) return false + tokens -= 1.0 + return true + } + + private fun refill(nowMillis: Long) { + val elapsed = nowMillis - lastRefillMillis + if (elapsed <= 0L) return + + tokens = min(capacity.toDouble(), tokens + elapsed.toDouble() * capacity / refillIntervalMillis) + lastRefillMillis = nowMillis + } + } + enum class Result { CLEAN, SPAM, @@ -99,15 +138,18 @@ class ChatAntiSpamService(private val configuration: PaperConfigurationService) companion object { private val histories = ConcurrentHashMap() + private val tokenBuckets = ConcurrentHashMap() @JvmStatic fun clear(playerId: UUID) { histories.remove(playerId) + tokenBuckets.remove(playerId) } @JvmStatic fun clearAll() { histories.clear() + tokenBuckets.clear() } private fun normalize(message: String): String { 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 eca82492..6c3bac0b 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,10 +1,8 @@ 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.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 @@ -24,11 +22,6 @@ 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 a35973c2..63850f9b 100644 --- a/paper/src/main/resources/plugin.yml +++ b/paper/src/main/resources/plugin.yml @@ -11,8 +11,6 @@ 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: @@ -59,9 +57,6 @@ 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 @@ -128,12 +123,3 @@ 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 deleted file mode 100644 index 5e0d6373..00000000 --- a/paper/src/test/java/io/wdsj/asw/bukkit/persistence/WriteBackCacheStressTest.java +++ /dev/null @@ -1,264 +0,0 @@ -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; - -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 97140ff54e6fbab9e9d1f5cd669f22d460afc919 Mon Sep 17 00:00:00 2001 From: HaHaWTH <102713261+HaHaWTH@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:56:40 +0800 Subject: [PATCH 5/5] Per player detection overrides --- gradle.properties | 2 - .../bukkit/ai/LlmChatDetectionService.java | 50 ++++- .../permission/cache/CachingPermTool.java | 41 ++++ .../option/PlayerOptionResolver.java | 187 ++++++++++++++++++ .../permission/option/PlayerOptionView.java | 34 ++++ .../permission/option/PlayerOptions.java | 52 +++++ .../bukkit/setting/SettingsConfiguration.java | 2 +- .../wdsj/asw/bukkit/listener/AnvilListener.kt | 12 +- .../wdsj/asw/bukkit/listener/BookListener.kt | 58 +++--- .../asw/bukkit/listener/CommandListener.kt | 18 +- .../asw/bukkit/listener/PlayerItemListener.kt | 22 ++- .../wdsj/asw/bukkit/listener/SignListener.kt | 46 +++-- .../listener/paper/PaperChatListener.kt | 38 ++-- .../chat/antispam/ChatAntiSpamService.kt | 84 ++++++-- .../asw/bukkit/util/context/SignContext.kt | 14 +- paper/src/main/resources/plugin.yml | 29 +++ 16 files changed, 576 insertions(+), 113 deletions(-) create mode 100644 paper/src/main/java/io/wdsj/asw/bukkit/permission/option/PlayerOptionResolver.java create mode 100644 paper/src/main/java/io/wdsj/asw/bukkit/permission/option/PlayerOptionView.java create mode 100644 paper/src/main/java/io/wdsj/asw/bukkit/permission/option/PlayerOptions.java diff --git a/gradle.properties b/gradle.properties index 40017098..7d245362 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,6 +10,4 @@ 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/src/main/java/io/wdsj/asw/bukkit/ai/LlmChatDetectionService.java b/paper/src/main/java/io/wdsj/asw/bukkit/ai/LlmChatDetectionService.java index b6b988eb..08aeb49a 100644 --- a/paper/src/main/java/io/wdsj/asw/bukkit/ai/LlmChatDetectionService.java +++ b/paper/src/main/java/io/wdsj/asw/bukkit/ai/LlmChatDetectionService.java @@ -4,14 +4,18 @@ import io.wdsj.asw.bukkit.api.event.AsyncModerationResponseEvent; import io.wdsj.asw.bukkit.api.moderation.LlmChatModerationResult; import io.wdsj.asw.bukkit.api.moderation.LlmModerationCategory; +import io.wdsj.asw.bukkit.permission.option.PlayerOptionView; +import io.wdsj.asw.bukkit.permission.option.PlayerOptions; import io.wdsj.asw.bukkit.setting.PaperConfigurationService; import io.wdsj.asw.bukkit.setting.PluginSettings; +import io.wdsj.asw.bukkit.setting.SettingKey; import io.wdsj.asw.bukkit.type.ModuleType; import io.wdsj.asw.bukkit.util.SchedulingUtils; import io.wdsj.asw.bukkit.util.ViolationReporter; import io.wdsj.asw.common.utils.MessageEntropy; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; +import org.bukkit.entity.Player; import org.bukkit.event.Listener; import org.bukkit.event.player.PlayerKickEvent; import org.bukkit.event.player.PlayerQuitEvent; @@ -29,6 +33,7 @@ import java.util.function.Function; import java.nio.charset.StandardCharsets; import io.wdsj.asw.bukkit.setting.SettingsConfiguration; +import org.jetbrains.annotations.VisibleForTesting; public final class LlmChatDetectionService implements Listener, AutoCloseable { private final PaperConfigurationService configuration; @@ -80,28 +85,46 @@ public synchronized void reload() { } } + @VisibleForTesting public void submit(UUID playerId, String playerName, String message) { + submit(playerId, playerName, message, null); + } + + public void submit(Player player, String message, PlayerOptionView options) { + submit(player.getUniqueId(), player.getName(), message, options); + } + + private void submit(UUID playerId, String playerName, String message, PlayerOptionView options) { RuntimeState state = runtime; if (state == null || closed || !AdvancedSensitiveWords.isInitialized || AdvancedSensitiveWords.sensitiveWordBs == null) { return; } - LlmSettings settings = state.settings(); + if (!optionBoolean(options, PlayerOptions.AI_ENABLED, PluginSettings.AI_ENABLED)) { + return; + } int rawCodePoints = message.codePointCount(0, message.length()); - if (rawCodePoints > settings.maximumMessageCodePoints()) { + if (rawCodePoints > optionInt(options, PlayerOptions.AI_MAXIMUM_MESSAGE_CODE_POINTS, PluginSettings.AI_MAXIMUM_MESSAGE_CODE_POINTS)) { return; } int visibleCodePoints = MessageEntropy.visibleCodePointCount(message); - if (visibleCodePoints < settings.minimumMessageCodePoints()) { + if (visibleCodePoints < optionInt(options, PlayerOptions.AI_MINIMUM_MESSAGE_CODE_POINTS, PluginSettings.AI_MINIMUM_MESSAGE_CODE_POINTS)) { return; } double entropy = MessageEntropy.shannonEntropyBits(message); - if (entropy < settings.minimumEntropyBits()) { + if (entropy < optionDouble(options, PlayerOptions.AI_MINIMUM_ENTROPY_BITS, PluginSettings.AI_MINIMUM_ENTROPY_BITS)) { return; } - Candidate candidate = reserveCandidate(state, playerId, playerName, message, entropy); + Candidate candidate = reserveCandidate( + state, + playerId, + playerName, + message, + entropy, + optionInt(options, PlayerOptions.AI_PER_PLAYER_COOLDOWN_SECONDS, PluginSettings.AI_PLAYER_COOLDOWN_SECONDS) + ); if (candidate == null) { droppedRequests.increment(); return; @@ -207,14 +230,15 @@ private Candidate reserveCandidate( UUID playerId, String playerName, String message, - double entropy + double entropy, + int cooldownSeconds ) { if (!isCurrent(state) || inFlightGenerations.putIfAbsent(playerId, state.generation()) != null) { return null; } long now = System.currentTimeMillis(); - long deadline = now + TimeUnit.SECONDS.toMillis(state.settings().perPlayerCooldownSeconds()); + long deadline = now + TimeUnit.SECONDS.toMillis(Math.max(0, cooldownSeconds)); AtomicBoolean granted = new AtomicBoolean(); cooldowns.compute(playerId, (ignored, existing) -> { if (existing != null && existing.deadlineMillis() > now) { @@ -399,6 +423,18 @@ private static Map toCategoryPolicies( return policies; } + private boolean optionBoolean(PlayerOptionView options, String optionPath, SettingKey key) { + return options == null ? configuration.get(key) : options.bool(optionPath, key); + } + + private int optionInt(PlayerOptionView options, String optionPath, SettingKey key) { + return options == null ? configuration.get(key) : options.integer(optionPath, key); + } + + private double optionDouble(PlayerOptionView options, String optionPath, SettingKey key) { + return options == null ? configuration.get(key) : options.decimal(optionPath, key); + } + record LlmSettings( String baseUrl, LlmApiMode apiMode, diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/permission/cache/CachingPermTool.java b/paper/src/main/java/io/wdsj/asw/bukkit/permission/cache/CachingPermTool.java index 18536eab..4b17694c 100644 --- a/paper/src/main/java/io/wdsj/asw/bukkit/permission/cache/CachingPermTool.java +++ b/paper/src/main/java/io/wdsj/asw/bukkit/permission/cache/CachingPermTool.java @@ -8,11 +8,15 @@ import org.bukkit.event.EventPriority; import org.bukkit.event.HandlerList; import org.bukkit.event.Listener; +import org.bukkit.permissions.PermissionAttachmentInfo; import org.bukkit.event.player.PlayerKickEvent; import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.plugin.java.JavaPlugin; import org.jetbrains.annotations.NotNull; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; @@ -22,6 +26,8 @@ public final class CachingPermTool implements Listener { private static final Map> permissionCacheMap = new ConcurrentHashMap<>(); + private static final Map> stringPermissionCacheMap = new ConcurrentHashMap<>(); + private static final Map>> effectivePermissionCacheMap = new ConcurrentHashMap<>(); CachingPermTool(JavaPlugin plugin) { plugin.getServer().getPluginManager().registerEvents(this, plugin); @@ -37,6 +43,8 @@ public void disable() { entry.getValue().cleanUp(); } permissionCacheMap.clear(); + stringPermissionCacheMap.clear(); + effectivePermissionCacheMap.clear(); } public static boolean hasPermission(PermissionsEnum permission, @NotNull HumanEntity human) { @@ -50,13 +58,46 @@ public static boolean hasPermission(PermissionsEnum permission, @NotNull HumanEn return hasPermission; } + public static boolean hasPermission(@NotNull String permission, @NotNull HumanEntity human) { + Cache permCache = stringPermissionCacheMap.computeIfAbsent(human.getUniqueId(), + k -> CacheBuilder.newBuilder().expireAfterWrite(8, TimeUnit.SECONDS).build()); + String normalizedPermission = permission.toLowerCase(Locale.ROOT); + Boolean hasPermission = permCache.getIfPresent(normalizedPermission); + if (hasPermission == null) { + hasPermission = human.hasPermission(permission); + permCache.put(normalizedPermission, hasPermission); + } + return hasPermission; + } + + public static List effectivePermissions(@NotNull HumanEntity human) { + Cache> permCache = effectivePermissionCacheMap.computeIfAbsent(human.getUniqueId(), + k -> CacheBuilder.newBuilder().expireAfterWrite(8, TimeUnit.SECONDS).build()); + List permissions = permCache.getIfPresent("effective"); + if (permissions == null) { + List collected = new ArrayList<>(); + for (PermissionAttachmentInfo permission : human.getEffectivePermissions()) { + if (permission.getValue()) { + collected.add(permission.getPermission().toLowerCase(Locale.ROOT)); + } + } + permissions = List.copyOf(collected); + permCache.put("effective", permissions); + } + return permissions; + } + @EventHandler(priority = EventPriority.MONITOR) private void onQuit(PlayerQuitEvent event) { permissionCacheMap.remove(event.getPlayer().getUniqueId()); + stringPermissionCacheMap.remove(event.getPlayer().getUniqueId()); + effectivePermissionCacheMap.remove(event.getPlayer().getUniqueId()); } @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) private void onKick(PlayerKickEvent event) { permissionCacheMap.remove(event.getPlayer().getUniqueId()); + stringPermissionCacheMap.remove(event.getPlayer().getUniqueId()); + effectivePermissionCacheMap.remove(event.getPlayer().getUniqueId()); } } diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/permission/option/PlayerOptionResolver.java b/paper/src/main/java/io/wdsj/asw/bukkit/permission/option/PlayerOptionResolver.java new file mode 100644 index 00000000..ee74b8ca --- /dev/null +++ b/paper/src/main/java/io/wdsj/asw/bukkit/permission/option/PlayerOptionResolver.java @@ -0,0 +1,187 @@ +package io.wdsj.asw.bukkit.permission.option; + +import io.wdsj.asw.bukkit.permission.cache.CachingPermTool; +import io.wdsj.asw.bukkit.setting.PaperConfigurationService; +import io.wdsj.asw.bukkit.type.ProcessMethod; +import org.bukkit.entity.Player; + +import java.util.Collection; +import java.util.List; +import java.util.Locale; +import java.util.Optional; + +public final class PlayerOptionResolver { + static final String PREFIX = "advancedsensitivewords.option."; + private static final String PRIORITY_MARKER = ".priority."; + private static final int DEFAULT_PRIORITY = 0; + + private PlayerOptionResolver() { + } + + public static PlayerOptionView resolve(PaperConfigurationService configuration, Player player) { + return new PlayerOptionView(configuration, CachingPermTool.effectivePermissions(player)); + } + + static boolean resolveBoolean(String optionPath, boolean defaultValue, Collection permissions) { + Optional action = resolveAction(optionPath, permissions, ActionType.BOOLEAN); + if (action.isEmpty() || action.get().kind() == ActionKind.DEFAULT) { + return defaultValue; + } + return action.get().kind() == ActionKind.ENABLE; + } + + static ProcessMethod resolveMethod(String optionPath, ProcessMethod defaultValue, Collection permissions) { + Optional action = resolveAction(optionPath, permissions, ActionType.METHOD); + if (action.isEmpty() || action.get().kind() == ActionKind.DEFAULT) { + return defaultValue; + } + return action.get().kind() == ActionKind.CANCEL ? ProcessMethod.CANCEL : ProcessMethod.REPLACE; + } + + static int resolveInteger(String optionPath, int defaultValue, Collection permissions) { + Optional value = resolveNumber(optionPath, permissions); + return value.map(number -> (int) Math.floor(number.value())).orElse(defaultValue); + } + + static double resolveDouble(String optionPath, double defaultValue, Collection permissions) { + Optional value = resolveNumber(optionPath, permissions); + return value.map(ResolvedNumber::value).orElse(defaultValue); + } + + private static Optional resolveAction( + String optionPath, + Collection permissions, + ActionType actionType + ) { + String optionPrefix = PREFIX + optionPath.toLowerCase(Locale.ROOT) + "."; + ResolvedAction selected = null; + for (String rawPermission : permissions) { + String permission = rawPermission.toLowerCase(Locale.ROOT); + if (!permission.startsWith(optionPrefix)) { + continue; + } + ParsedSuffix parsed = parseSuffix(permission.substring(optionPrefix.length())); + ActionKind kind = switch (parsed.action()) { + case "default" -> ActionKind.DEFAULT; + case "enable" -> actionType == ActionType.BOOLEAN ? ActionKind.ENABLE : null; + case "disable" -> actionType == ActionType.BOOLEAN ? ActionKind.DISABLE : null; + case "cancel" -> actionType == ActionType.METHOD ? ActionKind.CANCEL : null; + case "replace" -> actionType == ActionType.METHOD ? ActionKind.REPLACE : null; + default -> null; + }; + if (kind == null) { + continue; + } + ResolvedAction candidate = new ResolvedAction(parsed.priority(), kind); + if (selected == null || candidate.compareTo(selected) > 0) { + selected = candidate; + } + } + return Optional.ofNullable(selected); + } + + private static Optional resolveNumber(String optionPath, Collection permissions) { + String optionPrefix = PREFIX + optionPath.toLowerCase(Locale.ROOT) + "."; + ResolvedNumber selected = null; + ResolvedDefault selectedDefault = null; + for (String rawPermission : permissions) { + String permission = rawPermission.toLowerCase(Locale.ROOT); + if (!permission.startsWith(optionPrefix)) { + continue; + } + ParsedSuffix parsed = parseSuffix(permission.substring(optionPrefix.length())); + if ("default".equals(parsed.action())) { + ResolvedDefault candidate = new ResolvedDefault(parsed.priority()); + if (selectedDefault == null || candidate.priority() > selectedDefault.priority()) { + selectedDefault = candidate; + } + continue; + } + if (!parsed.action().startsWith("value.")) { + continue; + } + String valueText = parsed.action().substring("value.".length()); + double value; + try { + value = Double.parseDouble(valueText); + } catch (NumberFormatException exception) { + continue; + } + if (!Double.isFinite(value) || value < 0.0D) { + continue; + } + ResolvedNumber candidate = new ResolvedNumber(parsed.priority(), value); + if (selected == null || candidate.compareTo(selected) > 0) { + selected = candidate; + } + } + if (selected == null) { + return Optional.empty(); + } + if (selectedDefault != null && selectedDefault.priority() > selected.priority()) { + return Optional.empty(); + } + return Optional.of(selected); + } + + private static ParsedSuffix parseSuffix(String suffix) { + int priorityIndex = suffix.lastIndexOf(PRIORITY_MARKER); + if (priorityIndex < 0) { + return new ParsedSuffix(suffix, DEFAULT_PRIORITY); + } + String action = suffix.substring(0, priorityIndex); + String priorityText = suffix.substring(priorityIndex + PRIORITY_MARKER.length()); + try { + return new ParsedSuffix(action, Integer.parseInt(priorityText)); + } catch (NumberFormatException exception) { + return new ParsedSuffix(action, DEFAULT_PRIORITY); + } + } + + private enum ActionType { + BOOLEAN, + METHOD + } + + private enum ActionKind { + DEFAULT(0), + ENABLE(1), + DISABLE(2), + REPLACE(1), + CANCEL(2); + + private final int strictness; + + ActionKind(int strictness) { + this.strictness = strictness; + } + } + + private record ParsedSuffix(String action, int priority) { + } + + private record ResolvedDefault(int priority) { + } + + private record ResolvedAction(int priority, ActionKind kind) implements Comparable { + @Override + public int compareTo(ResolvedAction other) { + int priorityCompare = Integer.compare(priority, other.priority); + if (priorityCompare != 0) { + return priorityCompare; + } + return Integer.compare(kind.strictness, other.kind.strictness); + } + } + + private record ResolvedNumber(int priority, double value) implements Comparable { + @Override + public int compareTo(ResolvedNumber other) { + int priorityCompare = Integer.compare(priority, other.priority); + if (priorityCompare != 0) { + return priorityCompare; + } + return Double.compare(value, other.value); + } + } +} diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/permission/option/PlayerOptionView.java b/paper/src/main/java/io/wdsj/asw/bukkit/permission/option/PlayerOptionView.java new file mode 100644 index 00000000..5cf3c4c0 --- /dev/null +++ b/paper/src/main/java/io/wdsj/asw/bukkit/permission/option/PlayerOptionView.java @@ -0,0 +1,34 @@ +package io.wdsj.asw.bukkit.permission.option; + +import io.wdsj.asw.bukkit.setting.PaperConfigurationService; +import io.wdsj.asw.bukkit.setting.SettingKey; +import io.wdsj.asw.bukkit.type.ProcessMethod; + +import java.util.List; +import java.util.Objects; + +public final class PlayerOptionView { + private final PaperConfigurationService configuration; + private final List permissions; + + PlayerOptionView(PaperConfigurationService configuration, List permissions) { + this.configuration = Objects.requireNonNull(configuration, "configuration"); + this.permissions = List.copyOf(permissions); + } + + public boolean bool(String optionPath, SettingKey defaultKey) { + return PlayerOptionResolver.resolveBoolean(optionPath, configuration.get(defaultKey), permissions); + } + + public ProcessMethod method(String optionPath, SettingKey defaultKey) { + return PlayerOptionResolver.resolveMethod(optionPath, configuration.get(defaultKey), permissions); + } + + public int integer(String optionPath, SettingKey defaultKey) { + return PlayerOptionResolver.resolveInteger(optionPath, configuration.get(defaultKey), permissions); + } + + public double decimal(String optionPath, SettingKey defaultKey) { + return PlayerOptionResolver.resolveDouble(optionPath, configuration.get(defaultKey), permissions); + } +} diff --git a/paper/src/main/java/io/wdsj/asw/bukkit/permission/option/PlayerOptions.java b/paper/src/main/java/io/wdsj/asw/bukkit/permission/option/PlayerOptions.java new file mode 100644 index 00000000..7aba4dce --- /dev/null +++ b/paper/src/main/java/io/wdsj/asw/bukkit/permission/option/PlayerOptions.java @@ -0,0 +1,52 @@ +package io.wdsj.asw.bukkit.permission.option; + +/** + * Player-scoped option permission paths below {@code advancedsensitivewords.option}. + */ +public final class PlayerOptions { + public static final String CHAT_METHOD = "chat.method"; + public static final String CHAT_FAKE_MESSAGE_ON_CANCEL = "chat.fake-message-on-cancel"; + public static final String CHAT_SEND_MESSAGE = "chat.send-message"; + public static final String CHAT_CONTEXT_CHECK = "chat.context-check"; + public static final String CHAT_ANTI_SPAM_ENABLED = "chat.anti-spam.enabled"; + public static final String CHAT_ANTI_SPAM_SEND_MESSAGE = "chat.anti-spam.send-message"; + public static final String CHAT_ANTI_SPAM_RATE_LIMIT_ENABLED = "chat.anti-spam.rate-limit.enabled"; + public static final String CHAT_ANTI_SPAM_MINIMUM_ENTROPY_CODE_POINTS = "chat.anti-spam.minimum-entropy-code-points"; + public static final String CHAT_ANTI_SPAM_MINIMUM_SIMILARITY_CODE_POINTS = "chat.anti-spam.minimum-similarity-code-points"; + public static final String CHAT_ANTI_SPAM_HISTORY_SIZE = "chat.anti-spam.history-size"; + public static final String CHAT_ANTI_SPAM_HISTORY_MAX_AGE_SECONDS = "chat.anti-spam.history-max-age-seconds"; + public static final String CHAT_ANTI_SPAM_SIMILAR_CHECK_AMOUNT = "chat.anti-spam.similar-check-amount"; + public static final String CHAT_ANTI_SPAM_SIMILAR_MIN_DISTANCE = "chat.anti-spam.similar-min-distance"; + public static final String CHAT_ANTI_SPAM_SIMILAR_MAX_SIMILARITY = "chat.anti-spam.similar-max-similarity"; + public static final String CHAT_ANTI_SPAM_RATE_LIMIT_CAPACITY = "chat.anti-spam.rate-limit.capacity"; + public static final String CHAT_ANTI_SPAM_RATE_LIMIT_REFILL_INTERVAL_SECONDS = "chat.anti-spam.rate-limit.refill-interval-seconds"; + + public static final String BOOK_METHOD = "book.method"; + public static final String BOOK_IGNORE_NEW_LINE = "book.ignore-new-line"; + public static final String BOOK_CROSS_PAGE_CHECK = "book.cross-page-check"; + public static final String BOOK_SEND_MESSAGE = "book.send-message"; + public static final String BOOK_CACHE_ENABLE_CACHE = "book.cache.enable-cache"; + + public static final String SIGN_METHOD = "sign.method"; + public static final String SIGN_FAKE_ON_CANCEL = "sign.fake-on-cancel"; + public static final String SIGN_MULTI_LINE_CHECK = "sign.multi-line-check"; + public static final String SIGN_CONTEXT_CHECK = "sign.context-check"; + public static final String SIGN_CONTEXT_MAX_SIZE = "sign.context-max-size"; + public static final String SIGN_CONTEXT_MAX_TIME = "sign.context-max-time"; + public static final String SIGN_SEND_MESSAGE = "sign.send-message"; + + public static final String ANVIL_METHOD = "anvil.method"; + public static final String ANVIL_SEND_MESSAGE = "anvil.send-message"; + + public static final String ITEM_METHOD = "item.method"; + public static final String ITEM_SEND_MESSAGE = "item.send-message"; + + public static final String AI_ENABLED = "ai.enabled"; + public static final String AI_MINIMUM_MESSAGE_CODE_POINTS = "ai.minimum-message-code-points"; + public static final String AI_MAXIMUM_MESSAGE_CODE_POINTS = "ai.maximum-message-code-points"; + public static final String AI_MINIMUM_ENTROPY_BITS = "ai.minimum-entropy-bits"; + public static final String AI_PER_PLAYER_COOLDOWN_SECONDS = "ai.per-player-cooldown-seconds"; + + private PlayerOptions() { + } +} 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 ed2a877e..4006bc2f 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 @@ -91,7 +91,7 @@ public static final class Plugin { @Comment("Plugin compatibility settings.") public Compatibility compatibility = new Compatibility(); @Comment("Characters ignored by the sensitive-word matcher. URL/IP/email syntax characters are protected automatically when those checks are enabled.") - public String ignoreChar = "`-—=~~!!@#$%^&§*()_+[]{}\\|;:'\"“”,,。、()<>??¥【】《》 "; + public String ignoreChar = "`-—=~~!!@#$%^&§*()_+[]{}\\|;:'\"“”,,.。、()<>??¥【】《》 "; @Comment("Whether to remove formatting before detection.") public boolean enablePreProcess = true; @Comment("Regular expression used by preprocessing.") 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 b3258363..cbf22b65 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 @@ -2,6 +2,9 @@ package io.wdsj.asw.bukkit.listener import io.wdsj.asw.bukkit.AdvancedSensitiveWords import io.wdsj.asw.bukkit.permission.PermissionsEnum +import io.wdsj.asw.bukkit.permission.option.PlayerOptionResolver +import io.wdsj.asw.bukkit.permission.option.PlayerOptionView +import io.wdsj.asw.bukkit.permission.option.PlayerOptions import io.wdsj.asw.bukkit.setting.PaperConfigurationService import io.wdsj.asw.bukkit.setting.PluginMessages import io.wdsj.asw.bukkit.setting.PluginSettings @@ -31,6 +34,7 @@ class AnvilListener(private val configuration: PaperConfigurationService) : List val player = event.whoClicked as? Player ?: return if (processingGuard.shouldSkipBasic(player, PermissionsEnum.BYPASS_ANVIL)) return + val options = PlayerOptionResolver.resolve(configuration, player) val outputItem = event.currentItem ?: return if (!outputItem.hasItemMeta()) return @@ -45,7 +49,7 @@ class AnvilListener(private val configuration: PaperConfigurationService) : List SensitiveFilterEvents.post(event.isAsynchronous, ModuleType.ANVIL, player, originalItemName, censoredWords) if (censoredWords.isEmpty()) return - if (isCancelMode()) { + if (isCancelMode(options)) { event.isCancelled = true } else { itemMeta.displayName( @@ -58,7 +62,7 @@ class AnvilListener(private val configuration: PaperConfigurationService) : List outputItem.setItemMeta(itemMeta) } - if (configuration.get(PluginSettings.ANVIL_SEND_MESSAGE)) { + if (options.bool(PlayerOptions.ANVIL_SEND_MESSAGE, PluginSettings.ANVIL_SEND_MESSAGE)) { MessageUtils.sendMessage(player, PluginMessages.MESSAGE_ON_ANVIL_RENAME) } @@ -79,7 +83,7 @@ class AnvilListener(private val configuration: PaperConfigurationService) : List return text.replace(Utils.preProcessRegex.toRegex(), "") } - private fun isCancelMode(): Boolean { - return configuration.get(PluginSettings.ANVIL_METHOD).isCancel + private fun isCancelMode(options: PlayerOptionView): Boolean { + return options.method(PlayerOptions.ANVIL_METHOD, PluginSettings.ANVIL_METHOD).isCancel } } 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 abb8b72a..d2851fe9 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 @@ -2,6 +2,9 @@ package io.wdsj.asw.bukkit.listener import io.wdsj.asw.bukkit.AdvancedSensitiveWords import io.wdsj.asw.bukkit.permission.PermissionsEnum +import io.wdsj.asw.bukkit.permission.option.PlayerOptionResolver +import io.wdsj.asw.bukkit.permission.option.PlayerOptionView +import io.wdsj.asw.bukkit.permission.option.PlayerOptions import io.wdsj.asw.bukkit.setting.PaperConfigurationService import io.wdsj.asw.bukkit.setting.PluginMessages import io.wdsj.asw.bukkit.setting.PluginSettings @@ -30,21 +33,22 @@ class BookListener(private val configuration: PaperConfigurationService) : Liste val player = event.player if (processingGuard.shouldSkipBasic(player, PermissionsEnum.BYPASS_BOOK)) return + val options = PlayerOptionResolver.resolve(configuration, player) val startTime = System.currentTimeMillis() val bookMeta = event.newBookMeta - var violation = censorPages(event, bookMeta) + var violation = censorPages(event, bookMeta, options) if (violation == null) { - violation = censorCrossPage(event) + violation = censorCrossPage(event, options) } - censorAuthor(event, bookMeta)?.let { violation = it } - censorTitle(event, bookMeta)?.let { violation = it } + censorAuthor(event, bookMeta, options)?.let { violation = it } + censorTitle(event, bookMeta, options)?.let { violation = it } val finalViolation = violation ?: return event.newBookMeta = bookMeta - if (configuration.get(PluginSettings.BOOK_SEND_MESSAGE)) { + if (options.bool(PlayerOptions.BOOK_SEND_MESSAGE, PluginSettings.BOOK_SEND_MESSAGE)) { MessageUtils.sendMessage(player, PluginMessages.MESSAGE_ON_BOOK) } @@ -60,34 +64,34 @@ class BookListener(private val configuration: PaperConfigurationService) : Liste ) } - private fun censorPages(event: PlayerEditBookEvent, bookMeta: BookMeta): BookViolation? { + private fun censorPages(event: PlayerEditBookEvent, bookMeta: BookMeta, options: PlayerOptionView): BookViolation? { if (!bookMeta.hasPages()) return null var violation: BookViolation? = null for (pageIndex in 1..bookMeta.pageCount) { val page = bookMeta.page(pageIndex) - val originalPage = preprocessPage(page) - val censoredWords = findPageCensoredWords(originalPage) + val originalPage = preprocessPage(page, options) + val censoredWords = findPageCensoredWords(originalPage, options) SensitiveFilterEvents.post(event.isAsynchronous, ModuleType.BOOK, event.player, originalPage, censoredWords) if (censoredWords.isEmpty()) continue violation = BookViolation(originalPage, censoredWords) - if (isCancelMode()) { + if (isCancelMode(options)) { event.isCancelled = true break } - bookMeta.page(pageIndex, replacePage(page, originalPage, censoredWords)) + bookMeta.page(pageIndex, replacePage(page, originalPage, censoredWords, options)) } return violation } - private fun censorCrossPage(event: PlayerEditBookEvent): BookViolation? { - if (!configuration.get(PluginSettings.BOOK_CROSS_PAGE)) return null + private fun censorCrossPage(event: PlayerEditBookEvent, options: PlayerOptionView): BookViolation? { + if (!options.bool(PlayerOptions.BOOK_CROSS_PAGE_CHECK, PluginSettings.BOOK_CROSS_PAGE)) return null val originalPageCrossed = (1..event.newBookMeta.pageCount) .joinToString("") { MessageUtils.plainText(event.newBookMeta.page(it)) } - .let { preprocessPageText(it) } + .let { preprocessPageText(it, options) } val censoredWords = AdvancedSensitiveWords.findAllSensitive(originalPageCrossed) SensitiveFilterEvents.post(event.isAsynchronous, ModuleType.BOOK, event.player, originalPageCrossed, censoredWords) if (censoredWords.isEmpty()) return null @@ -96,14 +100,14 @@ class BookListener(private val configuration: PaperConfigurationService) : Liste return BookViolation(originalPageCrossed, censoredWords) } - private fun censorAuthor(event: PlayerEditBookEvent, bookMeta: BookMeta): BookViolation? { + private fun censorAuthor(event: PlayerEditBookEvent, bookMeta: BookMeta, options: PlayerOptionView): BookViolation? { val author = event.newBookMeta.author() ?: return null val originalAuthor = preprocessText(MessageUtils.plainText(author)) val censoredWords = AdvancedSensitiveWords.findAllSensitive(originalAuthor) SensitiveFilterEvents.post(event.isAsynchronous, ModuleType.BOOK, event.player, originalAuthor, censoredWords) if (censoredWords.isEmpty()) return null - if (isCancelMode()) { + if (isCancelMode(options)) { event.isCancelled = true } else { bookMeta.author(MessageUtils.replaceLiteral(author, originalAuthor, AdvancedSensitiveWords.replaceSensitive(originalAuthor))) @@ -111,14 +115,14 @@ class BookListener(private val configuration: PaperConfigurationService) : Liste return BookViolation(originalAuthor, censoredWords) } - private fun censorTitle(event: PlayerEditBookEvent, bookMeta: BookMeta): BookViolation? { + private fun censorTitle(event: PlayerEditBookEvent, bookMeta: BookMeta, options: PlayerOptionView): BookViolation? { val title = event.newBookMeta.title() ?: return null val originalTitle = preprocessText(MessageUtils.plainText(title)) val censoredWords = AdvancedSensitiveWords.findAllSensitive(originalTitle) SensitiveFilterEvents.post(event.isAsynchronous, ModuleType.BOOK, event.player, originalTitle, censoredWords) if (censoredWords.isEmpty()) return null - if (isCancelMode()) { + if (isCancelMode(options)) { event.isCancelled = true } else { bookMeta.title(MessageUtils.replaceLiteral(title, originalTitle, AdvancedSensitiveWords.replaceSensitive(originalTitle))) @@ -126,8 +130,8 @@ class BookListener(private val configuration: PaperConfigurationService) : Liste return BookViolation(originalTitle, censoredWords) } - private fun findPageCensoredWords(page: String): List { - if (!configuration.get(PluginSettings.BOOK_CACHE)) { + private fun findPageCensoredWords(page: String, options: PlayerOptionView): List { + if (!options.bool(PlayerOptions.BOOK_CACHE_ENABLE_CACHE, PluginSettings.BOOK_CACHE)) { return AdvancedSensitiveWords.findAllSensitive(page) } if (BookCache.isBookCached(page)) { @@ -136,8 +140,8 @@ class BookListener(private val configuration: PaperConfigurationService) : Liste return AdvancedSensitiveWords.findAllSensitive(page) } - private fun replacePage(page: Component, pagePlainText: String, censoredWords: List): Component { - if (!configuration.get(PluginSettings.BOOK_CACHE)) { + private fun replacePage(page: Component, pagePlainText: String, censoredWords: List, options: PlayerOptionView): Component { + if (!options.bool(PlayerOptions.BOOK_CACHE_ENABLE_CACHE, PluginSettings.BOOK_CACHE)) { return MessageUtils.replaceLiteral(page, pagePlainText, AdvancedSensitiveWords.replaceSensitive(pagePlainText)) } if (BookCache.isBookCached(pagePlainText)) { @@ -149,12 +153,12 @@ class BookListener(private val configuration: PaperConfigurationService) : Liste return MessageUtils.replaceLiteral(page, pagePlainText, processedPage) } - private fun preprocessPage(page: Component): String { - return preprocessPageText(MessageUtils.plainText(page)) + private fun preprocessPage(page: Component, options: PlayerOptionView): String { + return preprocessPageText(MessageUtils.plainText(page), options) } - private fun preprocessPageText(text: String): String { - val lineNormalized = if (configuration.get(PluginSettings.BOOK_IGNORE_NEWLINE)) { + private fun preprocessPageText(text: String, options: PlayerOptionView): String { + val lineNormalized = if (options.bool(PlayerOptions.BOOK_IGNORE_NEW_LINE, PluginSettings.BOOK_IGNORE_NEWLINE)) { text.replace("\n", "").replace("§0", "") } else { text @@ -167,8 +171,8 @@ class BookListener(private val configuration: PaperConfigurationService) : Liste return text.replace(Utils.preProcessRegex.toRegex(), "") } - private fun isCancelMode(): Boolean { - return configuration.get(PluginSettings.BOOK_METHOD).isCancel + private fun isCancelMode(options: PlayerOptionView): Boolean { + return options.method(PlayerOptions.BOOK_METHOD, PluginSettings.BOOK_METHOD).isCancel } private data class BookViolation( 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 c6ad0701..7a9a1bf3 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 @@ -3,6 +3,9 @@ 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.permission.PermissionsEnum +import io.wdsj.asw.bukkit.permission.option.PlayerOptionResolver +import io.wdsj.asw.bukkit.permission.option.PlayerOptionView +import io.wdsj.asw.bukkit.permission.option.PlayerOptions import io.wdsj.asw.bukkit.setting.PaperConfigurationService import io.wdsj.asw.bukkit.setting.PluginMessages import io.wdsj.asw.bukkit.setting.PluginSettings @@ -30,6 +33,7 @@ class CommandListener(private val configuration: PaperConfigurationService) : Li val originalCommand = preprocess(event.message) val player = event.player if (processingGuard.shouldSkip(player, PermissionsEnum.BYPASS_COMMAND)) return + val options = PlayerOptionResolver.resolve(configuration, player) val selection = configuration.commandArgumentRules().select(originalCommand) if (!configuration.shouldInspectCommand(selection) || selection.segments().isEmpty()) return @@ -39,8 +43,8 @@ class CommandListener(private val configuration: PaperConfigurationService) : Li SensitiveFilterEvents.post(event.isAsynchronous, ModuleType.CHAT, player, selection.scannedContent(), censoredWords) if (censoredWords.isEmpty()) return - applyCommandAction(event, selection) - recordViolation(event, player, selection.scannedContent(), censoredWords, startTime) + applyCommandAction(event, selection, options) + recordViolation(event, player, options, selection.scannedContent(), censoredWords, startTime) } private fun preprocess(message: String): String { @@ -51,8 +55,9 @@ class CommandListener(private val configuration: PaperConfigurationService) : Li private fun applyCommandAction( event: PlayerCommandPreprocessEvent, selection: CommandArgumentRuleSet.CommandSelection, + options: PlayerOptionView, ) { - if (isCancelMode()) { + if (isCancelMode(options)) { event.isCancelled = true return } @@ -64,11 +69,12 @@ class CommandListener(private val configuration: PaperConfigurationService) : Li private fun recordViolation( event: PlayerCommandPreprocessEvent, player: Player, + options: PlayerOptionView, originalCommand: String, censoredWords: List, startTime: Long, ) { - if (configuration.get(PluginSettings.CHAT_SEND_MESSAGE)) { + if (options.bool(PlayerOptions.CHAT_SEND_MESSAGE, PluginSettings.CHAT_SEND_MESSAGE)) { MessageUtils.sendMessage( player, configuration.message(PluginMessages.MESSAGE_ON_CHAT) @@ -89,7 +95,7 @@ class CommandListener(private val configuration: PaperConfigurationService) : Li ) } - private fun isCancelMode(): Boolean { - return configuration.get(PluginSettings.CHAT_METHOD).isCancel + private fun isCancelMode(options: PlayerOptionView): Boolean { + return options.method(PlayerOptions.CHAT_METHOD, PluginSettings.CHAT_METHOD).isCancel } } 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 5ae508bb..b89f1932 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 @@ -2,6 +2,9 @@ package io.wdsj.asw.bukkit.listener import io.wdsj.asw.bukkit.AdvancedSensitiveWords import io.wdsj.asw.bukkit.permission.PermissionsEnum +import io.wdsj.asw.bukkit.permission.option.PlayerOptionResolver +import io.wdsj.asw.bukkit.permission.option.PlayerOptionView +import io.wdsj.asw.bukkit.permission.option.PlayerOptions import io.wdsj.asw.bukkit.setting.PaperConfigurationService import io.wdsj.asw.bukkit.setting.PluginMessages import io.wdsj.asw.bukkit.setting.PluginSettings @@ -34,9 +37,10 @@ class PlayerItemListener(private val configuration: PaperConfigurationService) : val player = event.player if (processingGuard.shouldSkipBasic(player, PermissionsEnum.BYPASS_ITEM)) return + val options = PlayerOptionResolver.resolve(configuration, player) val item = player.inventory.getItem(event.newSlot) ?: return - censorItemName(player, item, event) + censorItemName(player, item, event, options) } @EventHandler(priority = EventPriority.LOW) @@ -46,8 +50,9 @@ class PlayerItemListener(private val configuration: PaperConfigurationService) : val player = event.player if (processingGuard.shouldSkipBasic(player, PermissionsEnum.BYPASS_ITEM)) return + val options = PlayerOptionResolver.resolve(configuration, player) - censorItemName(player, event.itemDrop.itemStack, event) + censorItemName(player, event.itemDrop.itemStack, event, options) } @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) @@ -58,12 +63,13 @@ class PlayerItemListener(private val configuration: PaperConfigurationService) : val player = event.whoClicked as? Player ?: return if (processingGuard.shouldSkipBasic(player, PermissionsEnum.BYPASS_ITEM)) return if (event.clickedInventory?.type != InventoryType.PLAYER) return + val options = PlayerOptionResolver.resolve(configuration, player) val item = event.currentItem ?: return - censorItemName(player, item, event) + censorItemName(player, item, event, options) } - private fun censorItemName(player: Player, item: ItemStack, event: Cancellable) { + private fun censorItemName(player: Player, item: ItemStack, event: Cancellable, options: PlayerOptionView) { if (!item.hasItemMeta()) return val meta = item.itemMeta ?: return @@ -77,7 +83,7 @@ class PlayerItemListener(private val configuration: PaperConfigurationService) : SensitiveFilterEvents.post(asynchronous, ModuleType.ITEM, player, originalName, censoredWords) if (censoredWords.isEmpty()) return - if (isCancelMode()) { + if (isCancelMode(options)) { event.isCancelled = true } else { meta.displayName( @@ -90,7 +96,7 @@ class PlayerItemListener(private val configuration: PaperConfigurationService) : item.setItemMeta(meta) } - if (configuration.get(PluginSettings.ITEM_SEND_MESSAGE)) { + if (options.bool(PlayerOptions.ITEM_SEND_MESSAGE, PluginSettings.ITEM_SEND_MESSAGE)) { MessageUtils.sendMessage(player, PluginMessages.MESSAGE_ON_ITEM) } @@ -110,7 +116,7 @@ class PlayerItemListener(private val configuration: PaperConfigurationService) : return text.replace(Utils.preProcessRegex.toRegex(), "") } - private fun isCancelMode(): Boolean { - return configuration.get(PluginSettings.ITEM_METHOD).isCancel + private fun isCancelMode(options: PlayerOptionView): Boolean { + return options.method(PlayerOptions.ITEM_METHOD, PluginSettings.ITEM_METHOD).isCancel } } 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 205bf988..01e6dedc 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,6 +3,9 @@ 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.permission.option.PlayerOptionResolver +import io.wdsj.asw.bukkit.permission.option.PlayerOptionView +import io.wdsj.asw.bukkit.permission.option.PlayerOptions import io.wdsj.asw.bukkit.setting.PaperConfigurationService import io.wdsj.asw.bukkit.integration.packetevents.sign.SignFakeViewService import io.wdsj.asw.bukkit.setting.PluginMessages @@ -41,16 +44,19 @@ class SignListener(private val configuration: PaperConfigurationService) : Liste val player = event.player if (processingGuard.shouldSkipBasic(player, PermissionsEnum.BYPASS_SIGN)) return + val options = PlayerOptionResolver.resolve(configuration, player) val startTime = System.currentTimeMillis() val attemptedLines = event.lines().toList() - val lineScan = censorSingleLines(event, player) + val lineScan = censorSingleLines(event, player, options) val violation = lineScan.violation - ?: censorMultiLine(event, lineScan) - ?: censorContext(event, player) + ?: censorMultiLine(event, lineScan, options) + ?: censorContext(event, player, options) ?: return - if (isCancelMode() && !violation.context && configuration.get(PluginSettings.SIGN_FAKE_ON_CANCEL)) { + if (isCancelMode(options) && !violation.context && + options.bool(PlayerOptions.SIGN_FAKE_ON_CANCEL, PluginSettings.SIGN_FAKE_ON_CANCEL) + ) { SignFakeViewService.recordCancelledEdit( event, player, @@ -60,7 +66,7 @@ class SignListener(private val configuration: PaperConfigurationService) : Liste ) } - if (configuration.get(PluginSettings.SIGN_SEND_MESSAGE)) { + if (options.bool(PlayerOptions.SIGN_SEND_MESSAGE, PluginSettings.SIGN_SEND_MESSAGE)) { MessageUtils.sendMessage(player, PluginMessages.MESSAGE_ON_SIGN) } @@ -96,7 +102,7 @@ class SignListener(private val configuration: PaperConfigurationService) : Liste )) } - private fun censorSingleLines(event: SignChangeEvent, player: Player): SignLineScan { + private fun censorSingleLines(event: SignChangeEvent, player: Player, options: PlayerOptionView): SignLineScan { var violation: SignViolation? = null val cleanLineIndexes = mutableListOf() val cleanLineContent = StringBuilder() @@ -116,7 +122,7 @@ class SignListener(private val configuration: PaperConfigurationService) : Liste } violation = SignViolation(originalMessage, censoredWords) - if (isCancelMode()) { + if (isCancelMode(options)) { event.isCancelled = true continue } @@ -127,8 +133,8 @@ class SignListener(private val configuration: PaperConfigurationService) : Liste return SignLineScan(violation, cleanLineIndexes, cleanLineContent.toString()) } - private fun censorMultiLine(event: SignChangeEvent, lineScan: SignLineScan): SignViolation? { - if (!configuration.get(PluginSettings.SIGN_MULTI_LINE_CHECK)) return null + private fun censorMultiLine(event: SignChangeEvent, lineScan: SignLineScan, options: PlayerOptionView): SignViolation? { + if (!options.bool(PlayerOptions.SIGN_MULTI_LINE_CHECK, PluginSettings.SIGN_MULTI_LINE_CHECK)) return null if (lineScan.cleanLineIndexes.isEmpty()) return null val originalContent = lineScan.cleanLineContent @@ -136,7 +142,7 @@ class SignListener(private val configuration: PaperConfigurationService) : Liste SensitiveFilterEvents.post(event.isAsynchronous, ModuleType.SIGN, event.player, originalContent, censoredWords) if (censoredWords.isEmpty()) return null - if (isCancelMode()) { + if (isCancelMode(options)) { event.isCancelled = true } else { val processedMessage = AdvancedSensitiveWords.replaceSensitive(originalContent) @@ -148,19 +154,21 @@ class SignListener(private val configuration: PaperConfigurationService) : Liste return SignViolation(originalContent, censoredWords) } - private fun censorContext(event: SignChangeEvent, player: Player): SignViolation? { - if (!configuration.get(PluginSettings.SIGN_CONTEXT_CHECK)) return null + private fun censorContext(event: SignChangeEvent, player: Player, options: PlayerOptionView): SignViolation? { + if (!options.bool(PlayerOptions.SIGN_CONTEXT_CHECK, PluginSettings.SIGN_CONTEXT_CHECK)) return null val entry = contextEntry(event) - SignContext.addMessage(player, entry) - val entries = SignContext.getHistory(player) + val contextMaxSize = options.integer(PlayerOptions.SIGN_CONTEXT_MAX_SIZE, PluginSettings.SIGN_CONTEXT_MAX_SIZE) + val contextMaxTime = options.integer(PlayerOptions.SIGN_CONTEXT_MAX_TIME, PluginSettings.SIGN_CONTEXT_TIME_LIMIT) + SignContext.addMessage(player, entry, contextMaxSize) + val entries = SignContext.getHistory(player, contextMaxSize, contextMaxTime) val originalContext = entries.joinToString("") { it.content } val censoredWords = AdvancedSensitiveWords.findAllSensitive(originalContext) SensitiveFilterEvents.post(event.isAsynchronous, ModuleType.SIGN, player, originalContext, censoredWords) if (censoredWords.isEmpty()) return null val resolution = resolveContext(entries, originalContext) - applyContextAction(event, resolution) + applyContextAction(event, resolution, options) SignContext.clearPlayerContext(player) return SignViolation(originalContext, censoredWords, true) } @@ -180,8 +188,8 @@ class SignListener(private val configuration: PaperConfigurationService) : Liste ) } - private fun applyContextAction(event: SignChangeEvent, resolution: ContextResolution) { - if (isCancelMode()) { + private fun applyContextAction(event: SignChangeEvent, resolution: ContextResolution, options: PlayerOptionView) { + if (isCancelMode(options)) { event.isCancelled = true resolution.affectedEntries.forEach { scheduleSignMutation(it, null) } return @@ -329,8 +337,8 @@ class SignListener(private val configuration: PaperConfigurationService) : Liste return text.replace(Utils.preProcessRegex.toRegex(), "") } - private fun isCancelMode(): Boolean { - return configuration.get(PluginSettings.SIGN_METHOD).isCancel + private fun isCancelMode(options: PlayerOptionView): Boolean { + return options.method(PlayerOptions.SIGN_METHOD, PluginSettings.SIGN_METHOD).isCancel } private data class SignLineScan( 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 c8e7d755..400470aa 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 @@ -6,6 +6,9 @@ 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.permission.PermissionsEnum +import io.wdsj.asw.bukkit.permission.option.PlayerOptionResolver +import io.wdsj.asw.bukkit.permission.option.PlayerOptionView +import io.wdsj.asw.bukkit.permission.option.PlayerOptions import io.wdsj.asw.bukkit.service.chat.antispam.ChatAntiSpamService import io.wdsj.asw.bukkit.setting.PaperConfigurationService import io.wdsj.asw.bukkit.setting.PluginMessages @@ -41,14 +44,15 @@ class PaperChatListener( val player = event.player if (processingGuard.shouldSkip(player, PermissionsEnum.BYPASS_CHAT)) return + val options = PlayerOptionResolver.resolve(configuration, player) val startTime = System.currentTimeMillis() val originalMessage = preprocess(event.message()) val originalPlainText = PlainTextComponentSerializer.plainText().serialize(originalMessage) - if (antiSpamService.check(player.uniqueId, originalPlainText) == ChatAntiSpamService.Result.SPAM) { + if (antiSpamService.check(player.uniqueId, originalPlainText, options) == ChatAntiSpamService.Result.SPAM) { event.isCancelled = true - if (configuration.get(PluginSettings.CHAT_ANTI_SPAM_SEND_MESSAGE)) { + if (options.bool(PlayerOptions.CHAT_ANTI_SPAM_SEND_MESSAGE, PluginSettings.CHAT_ANTI_SPAM_SEND_MESSAGE)) { MessageUtils.sendMessage(player, configuration.message(PluginMessages.MESSAGE_ON_CHAT_ANTI_SPAM)) } return @@ -64,12 +68,12 @@ class PaperChatListener( ) if (censoredWords.isNotEmpty()) { - handleDirectMessage(event, player, originalMessage, originalPlainText = originalPlainText, censoredWords, startTime) + handleDirectMessage(event, player, options, originalMessage, originalPlainText = originalPlainText, censoredWords, startTime) return } - if (!handleContextMessage(event, player, originalPlainText, startTime)) { - llmChatDetectionService.submit(player.uniqueId, player.name, originalPlainText) + if (!handleContextMessage(event, player, options, originalPlainText, startTime)) { + llmChatDetectionService.submit(player, originalPlainText, options) } } @@ -86,22 +90,24 @@ class PaperChatListener( private fun handleDirectMessage( event: AsyncChatEvent, player: Player, + options: PlayerOptionView, originalMessage: Component, originalPlainText: String, censoredWords: List, startTime: Long, ) { - applyDirectMessageAction(event, originalMessage, originalPlainText) - recordViolation(event, player, originalPlainText, originalPlainText, censoredWords, false, startTime) + applyDirectMessageAction(event, options, originalMessage, originalPlainText) + recordViolation(event, player, options, originalPlainText, originalPlainText, censoredWords, false, startTime) } private fun applyDirectMessageAction( event: AsyncChatEvent, + options: PlayerOptionView, originalMessage: Component, originalPlainText: String, ) { - if (isCancelMode()) { - if (configuration.get(PluginSettings.CHAT_FAKE_MESSAGE_ON_CANCEL)) { + if (isCancelMode(options)) { + if (options.bool(PlayerOptions.CHAT_FAKE_MESSAGE_ON_CANCEL, PluginSettings.CHAT_FAKE_MESSAGE_ON_CANCEL)) { markFakeMessage(event.player) } else { event.isCancelled = true @@ -120,10 +126,11 @@ class PaperChatListener( private fun handleContextMessage( event: AsyncChatEvent, player: Player, + options: PlayerOptionView, originalPlainText: String, startTime: Long, ): Boolean { - if (!configuration.get(PluginSettings.CHAT_CONTEXT_CHECK)) return false + if (!options.bool(PlayerOptions.CHAT_CONTEXT_CHECK, PluginSettings.CHAT_CONTEXT_CHECK)) return false ChatContext.addMessage(player, originalPlainText) val originalContext = ChatContext.getHistory(player).joinToString("") @@ -132,26 +139,27 @@ class PaperChatListener( if (censoredWords.isEmpty()) return false ChatContext.pollPlayerContext(player) - if (configuration.get(PluginSettings.CHAT_FAKE_MESSAGE_ON_CANCEL)) { + if (options.bool(PlayerOptions.CHAT_FAKE_MESSAGE_ON_CANCEL, PluginSettings.CHAT_FAKE_MESSAGE_ON_CANCEL)) { markFakeMessage(player) } else { event.isCancelled = true } - recordViolation(event, player, originalPlainText, originalContext, censoredWords, true, startTime) + recordViolation(event, player, options, originalPlainText, originalContext, censoredWords, true, startTime) return true } private fun recordViolation( event: AsyncChatEvent, player: Player, + options: PlayerOptionView, playerMessage: String, violationContent: String, censoredWords: List, contextCheck: Boolean, startTime: Long, ) { - if (configuration.get(PluginSettings.CHAT_SEND_MESSAGE)) { + if (options.bool(PlayerOptions.CHAT_SEND_MESSAGE, PluginSettings.CHAT_SEND_MESSAGE)) { MessageUtils.sendMessage( player, configuration.message(PluginMessages.MESSAGE_ON_CHAT) @@ -173,8 +181,8 @@ class PaperChatListener( ) } - private fun isCancelMode(): Boolean { - return configuration.get(PluginSettings.CHAT_METHOD).isCancel + private fun isCancelMode(options: PlayerOptionView): Boolean { + return options.method(PlayerOptions.CHAT_METHOD, PluginSettings.CHAT_METHOD).isCancel } private fun markFakeMessage(player: Player) { diff --git a/paper/src/main/kotlin/io/wdsj/asw/bukkit/service/chat/antispam/ChatAntiSpamService.kt b/paper/src/main/kotlin/io/wdsj/asw/bukkit/service/chat/antispam/ChatAntiSpamService.kt index 265aff7a..d6db8182 100644 --- a/paper/src/main/kotlin/io/wdsj/asw/bukkit/service/chat/antispam/ChatAntiSpamService.kt +++ b/paper/src/main/kotlin/io/wdsj/asw/bukkit/service/chat/antispam/ChatAntiSpamService.kt @@ -1,5 +1,7 @@ package io.wdsj.asw.bukkit.service.chat.antispam +import io.wdsj.asw.bukkit.permission.option.PlayerOptionView +import io.wdsj.asw.bukkit.permission.option.PlayerOptions import io.wdsj.asw.bukkit.setting.PaperConfigurationService import io.wdsj.asw.bukkit.setting.PluginSettings import io.wdsj.asw.bukkit.util.list.EvictingRingList @@ -17,42 +19,65 @@ class ChatAntiSpamService(private val configuration: PaperConfigurationService) .takeIf(String::isNotBlank) ?.let(Pattern::compile) - fun check(playerId: UUID, message: String, nowMillis: Long = System.currentTimeMillis()): Result { - if (!configuration.get(PluginSettings.CHAT_ANTI_SPAM_ENABLED)) return Result.CLEAN - if (!consumeToken(playerId, nowMillis)) return Result.SPAM + fun check( + playerId: UUID, + message: String, + options: PlayerOptionView, + nowMillis: Long = System.currentTimeMillis(), + ): Result { + if (!options.bool(PlayerOptions.CHAT_ANTI_SPAM_ENABLED, PluginSettings.CHAT_ANTI_SPAM_ENABLED)) return Result.CLEAN + if (!consumeToken(playerId, options, nowMillis)) return Result.SPAM val preprocessed = preprocess(message) val visibleCodePoints = MessageEntropy.visibleCodePointCount(preprocessed) val normalized = normalize(preprocessed) - if (visibleCodePoints >= configuration.get(PluginSettings.CHAT_ANTI_SPAM_MINIMUM_ENTROPY_CODE_POINTS)) { + if (visibleCodePoints >= options.integer( + PlayerOptions.CHAT_ANTI_SPAM_MINIMUM_ENTROPY_CODE_POINTS, + PluginSettings.CHAT_ANTI_SPAM_MINIMUM_ENTROPY_CODE_POINTS, + ).coerceAtLeast(1) + ) { val entropy = MessageEntropy.shannonEntropyBits(normalized) val minimumEntropy = configuration.get(PluginSettings.CHAT_ANTI_SPAM_MINIMUM_ENTROPY_BITS) if (minimumEntropy >= 0.0 && entropy < minimumEntropy) { - remember(playerId, normalized, nowMillis) + remember(playerId, normalized, options, nowMillis) return Result.SPAM } val minimumAverageEntropy = configuration.get(PluginSettings.CHAT_ANTI_SPAM_MINIMUM_AVERAGE_ENTROPY) if (minimumAverageEntropy >= 0.0 && averageEntropy(visibleCodePoints, entropy) < minimumAverageEntropy) { - remember(playerId, normalized, nowMillis) + remember(playerId, normalized, options, nowMillis) return Result.SPAM } } - val result = if (visibleCodePoints >= configuration.get(PluginSettings.CHAT_ANTI_SPAM_MINIMUM_SIMILARITY_CODE_POINTS)) { - checkSimilarity(playerId, normalized, nowMillis) + val result = if (visibleCodePoints >= options.integer( + PlayerOptions.CHAT_ANTI_SPAM_MINIMUM_SIMILARITY_CODE_POINTS, + PluginSettings.CHAT_ANTI_SPAM_MINIMUM_SIMILARITY_CODE_POINTS, + ).coerceAtLeast(1) + ) { + checkSimilarity(playerId, normalized, options, nowMillis) } else { Result.CLEAN } - remember(playerId, normalized, nowMillis) + remember(playerId, normalized, options, nowMillis) return result } - private fun consumeToken(playerId: UUID, nowMillis: Long): Boolean { - if (!configuration.get(PluginSettings.CHAT_ANTI_SPAM_RATE_LIMIT_ENABLED)) return true - - val capacity = configuration.get(PluginSettings.CHAT_ANTI_SPAM_RATE_LIMIT_CAPACITY) - val refillIntervalMillis = configuration.get(PluginSettings.CHAT_ANTI_SPAM_RATE_LIMIT_REFILL_INTERVAL_SECONDS) * 1000L + private fun consumeToken(playerId: UUID, options: PlayerOptionView, nowMillis: Long): Boolean { + if (!options.bool( + PlayerOptions.CHAT_ANTI_SPAM_RATE_LIMIT_ENABLED, + PluginSettings.CHAT_ANTI_SPAM_RATE_LIMIT_ENABLED, + ) + ) return true + + val capacity = options.integer( + PlayerOptions.CHAT_ANTI_SPAM_RATE_LIMIT_CAPACITY, + PluginSettings.CHAT_ANTI_SPAM_RATE_LIMIT_CAPACITY, + ).coerceAtLeast(1) + val refillIntervalMillis = options.integer( + PlayerOptions.CHAT_ANTI_SPAM_RATE_LIMIT_REFILL_INTERVAL_SECONDS, + PluginSettings.CHAT_ANTI_SPAM_RATE_LIMIT_REFILL_INTERVAL_SECONDS, + ).coerceAtLeast(1) * 1000L val bucket = tokenBuckets.compute(playerId) { _, existing -> existing?.takeIf { it.capacity == capacity && it.refillIntervalMillis == refillIntervalMillis @@ -61,17 +86,31 @@ class ChatAntiSpamService(private val configuration: PaperConfigurationService) return bucket.tryConsume(nowMillis) } - private fun checkSimilarity(playerId: UUID, normalized: String, nowMillis: Long): Result { + private fun checkSimilarity(playerId: UUID, normalized: String, options: PlayerOptionView, nowMillis: Long): Result { val history = histories[playerId] ?: return Result.CLEAN - val maxAgeMillis = configuration.get(PluginSettings.CHAT_ANTI_SPAM_HISTORY_MAX_AGE_SECONDS) * 1000L + val maxAgeMillis = options.integer( + PlayerOptions.CHAT_ANTI_SPAM_HISTORY_MAX_AGE_SECONDS, + PluginSettings.CHAT_ANTI_SPAM_HISTORY_MAX_AGE_SECONDS, + ).coerceAtLeast(1) * 1000L val compared = synchronized(history.list) { history.list.removeIf { nowMillis - it.createdAtMillis > maxAgeMillis } - history.list.takeLast(configuration.get(PluginSettings.CHAT_ANTI_SPAM_SIMILAR_CHECK_AMOUNT)).toList() + history.list.takeLast( + options.integer( + PlayerOptions.CHAT_ANTI_SPAM_SIMILAR_CHECK_AMOUNT, + PluginSettings.CHAT_ANTI_SPAM_SIMILAR_CHECK_AMOUNT, + ).coerceAtLeast(1), + ).toList() } if (compared.isEmpty()) return Result.CLEAN - val maxSimilarity = configuration.get(PluginSettings.CHAT_ANTI_SPAM_SIMILAR_MAX_SIMILARITY) - val minDistance = configuration.get(PluginSettings.CHAT_ANTI_SPAM_SIMILAR_MIN_DISTANCE) + val maxSimilarity = options.decimal( + PlayerOptions.CHAT_ANTI_SPAM_SIMILAR_MAX_SIMILARITY, + PluginSettings.CHAT_ANTI_SPAM_SIMILAR_MAX_SIMILARITY, + ) + val minDistance = options.integer( + PlayerOptions.CHAT_ANTI_SPAM_SIMILAR_MIN_DISTANCE, + PluginSettings.CHAT_ANTI_SPAM_SIMILAR_MIN_DISTANCE, + ) for (entry in compared) { val distance = editDistance(normalized, entry.message) val similarity = similarity(normalized, entry.message, distance) @@ -85,8 +124,11 @@ class ChatAntiSpamService(private val configuration: PaperConfigurationService) return Result.CLEAN } - private fun remember(playerId: UUID, normalized: String, nowMillis: Long) { - val maxSize = configuration.get(PluginSettings.CHAT_ANTI_SPAM_HISTORY_SIZE) + private fun remember(playerId: UUID, normalized: String, options: PlayerOptionView, nowMillis: Long) { + val maxSize = options.integer( + PlayerOptions.CHAT_ANTI_SPAM_HISTORY_SIZE, + PluginSettings.CHAT_ANTI_SPAM_HISTORY_SIZE, + ).coerceAtLeast(1) val history = histories.compute(playerId) { _, existing -> existing?.takeIf { it.capacity == maxSize } ?: History(maxSize) } ?: return diff --git a/paper/src/main/kotlin/io/wdsj/asw/bukkit/util/context/SignContext.kt b/paper/src/main/kotlin/io/wdsj/asw/bukkit/util/context/SignContext.kt index d7c876c3..69bb71ca 100644 --- a/paper/src/main/kotlin/io/wdsj/asw/bukkit/util/context/SignContext.kt +++ b/paper/src/main/kotlin/io/wdsj/asw/bukkit/util/context/SignContext.kt @@ -10,17 +10,25 @@ object SignContext { private val signEditHistory = ContextHistory() fun addMessage(player: Player, entry: SignContextEntry) { + addMessage(player, entry, contextCapacity()) + } + + fun addMessage(player: Player, entry: SignContextEntry, capacity: Int) { if (entry.content.isBlank()) return signEditHistory.removeMatching(player.uniqueId) { it.target == entry.target } - signEditHistory.add(player.uniqueId, contextCapacity(), entry) + signEditHistory.add(player.uniqueId, capacity.coerceAtLeast(1), entry) } fun getHistory(player: Player): List { + return getHistory(player, contextCapacity(), setting(PluginSettings.SIGN_CONTEXT_TIME_LIMIT)) + } + + fun getHistory(player: Player, capacity: Int, timeLimitSeconds: Int): List { return signEditHistory.snapshot( player.uniqueId, - contextCapacity(), - setting(PluginSettings.SIGN_CONTEXT_TIME_LIMIT) * 1_000L, + capacity.coerceAtLeast(1), + timeLimitSeconds.coerceAtLeast(1) * 1_000L, ) { it.time } } diff --git a/paper/src/main/resources/plugin.yml b/paper/src/main/resources/plugin.yml index 63850f9b..4072654a 100644 --- a/paper/src/main/resources/plugin.yml +++ b/paper/src/main/resources/plugin.yml @@ -30,6 +30,7 @@ permissions: advancedsensitivewords.notice: true advancedsensitivewords.update: true advancedsensitivewords.command.*: true + advancedsensitivewords.option.*: true advancedsensitivewords.bypass.*: description: Bypass all player-owned Advanced Sensitive Words filters. default: false @@ -57,6 +58,34 @@ permissions: advancedsensitivewords.command.player.reset: true advancedsensitivewords.command.player.punish: true advancedsensitivewords.command.ai.status: true + advancedsensitivewords.option.*: + description: All Advanced Sensitive Words player option overrides. + default: false + children: + advancedsensitivewords.option.chat.*: true + advancedsensitivewords.option.book.*: true + advancedsensitivewords.option.sign.*: true + advancedsensitivewords.option.anvil.*: true + advancedsensitivewords.option.item.*: true + advancedsensitivewords.option.ai.*: true + advancedsensitivewords.option.chat.*: + description: Chat, command, anti-spam, and chat AI option overrides. + default: false + advancedsensitivewords.option.book.*: + description: Book option overrides. + default: false + advancedsensitivewords.option.sign.*: + description: Sign option overrides. + default: false + advancedsensitivewords.option.anvil.*: + description: Anvil option overrides. + default: false + advancedsensitivewords.option.item.*: + description: Item option overrides. + default: false + advancedsensitivewords.option.ai.*: + description: LLM moderation option overrides. + default: false advancedsensitivewords.bypass: description: Global bypass for all player-owned Advanced Sensitive Words filters. default: false